Crush a sound to fewer bits and a lower rate with a Bitcrusher (#322)
A new effect for sound FX and instruments makes a sound rough like an
old console. It rounds the sound to fewer steps and holds it at a lower
sample rate, and all three of its numbers move while the sound plays.
- Bitcrusher node with bits (1 to 16, not only whole), rate (100 Hz to
48 kHz, sample-and-hold) and mix. A mix typed in at 0 leaves it out.
- It runs on the audio thread as a second processor next to the pitch
player. The module is now fxWorklet.ts and the test hook is called
fxWorkletUrl.
- A play that needs the worklet waits for it, for a Pitch or a
Bitcrusher alike. Without the worklet the sound goes through as is.
- Bits and mix move in what they hold, the rate in octaves. An
Envelope can move all three in an instrument.
- A stage that runs on the worklet is stopped with its voice, like the
drivers of an Envelope or a Mix.
- The math is a plain class, Crusher, so it is tested in Node.
The cleanup check of e2e 64 from af9b202 turned suite 553 red. It
asked that no editor run was left at all. In CI a step before 64 still
leaves one of its own running. The check now asks only about its own
behavior and prints what is still running, so the next suite names it.
Tests:
- Unit crusher (new) 4, soundfx +2, sound-library +3. All of tests/core,
tests/viewport and tests/editor/graph: 176 files, 2934 green.
- Counter-check: without the rounding 2 of 4 crusher tests fail.
- e2e 34 (melektaus-atx): ALL PASSED, 58 checks (1 new). Offline in
Chrome 2 bits at 4 kHz leave five levels held for 12 samples each.
64 stereo voices for 2 s render in 1.9 s, about 68 at once.
- e2e 64 (melektaus-atx): ALL PASSED, 30 checks.
- Gate (melektaus-atx): CHECK EXIT 0, 254 files, 3946 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af9b202100
commit
165f022fc0
11 files changed
+464
-59
No files matched your search
@@ -320,6 +320,8 @@ function bindStage(stage: FxStagePlan, bind: (amount: FxAmount) => FxAmount): Fx
|
||||
if (stage.kind === 'pass')
|
||||
return { ...stage, cutoff: bind(stage.cutoff), resonance: bind(stage.resonance) };
|
||||
if (stage.kind === 'envelope') return stage;
|
||||
if (stage.kind === 'crush')
|
||||
return { ...stage, bits: bind(stage.bits), rate: bind(stage.rate), mix: bind(stage.mix) };
|
||||
return {
|
||||
...stage,
|
||||
time: bind(stage.time),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* The Bitcrusher (#322) sample by sample, as its worklet runs it: the sound is held for a while
|
||||
* ("rate", sample-and-hold) and rounded to fewer steps ("bits"), then blended with what came in
|
||||
* ("mix"). Bits need not be whole, so a ramp on them never jumps. Each number may be one value for
|
||||
* the whole block or one per sample, as an AudioParam hands it over.
|
||||
*/
|
||||
export class Crusher {
|
||||
/** Where the next sample is taken, in samples of the rate; at 1 or past it one is taken now. */
|
||||
private phase = 1;
|
||||
private held: number[] = [];
|
||||
|
||||
constructor(private readonly sampleRate: number) {}
|
||||
|
||||
render(
|
||||
input: readonly Float32Array[],
|
||||
output: readonly Float32Array[],
|
||||
bits: Float32Array,
|
||||
rate: Float32Array,
|
||||
mix: Float32Array,
|
||||
): void {
|
||||
// Nothing coming in: what is held would stay on as a click of DC.
|
||||
if (input.length === 0) {
|
||||
this.held = [];
|
||||
this.phase = 1;
|
||||
for (const channel of output) channel.fill(0);
|
||||
return;
|
||||
}
|
||||
const length = output[0]?.length ?? 0;
|
||||
for (let i = 0; i < length; i++) {
|
||||
const take = this.phase >= 1;
|
||||
if (take) this.phase -= Math.floor(this.phase);
|
||||
this.phase += crushRate(at(rate, i), this.sampleRate) / this.sampleRate;
|
||||
const steps = crushSteps(at(bits, i));
|
||||
const wet = Math.max(0, Math.min(1, at(mix, i)));
|
||||
output.forEach((channel, c) => {
|
||||
const dry = input[c]?.[i] ?? input[0]?.[i] ?? 0;
|
||||
if (take || this.held[c] === undefined) this.held[c] = dry;
|
||||
const crushed = Math.round(this.held[c] * steps) / steps;
|
||||
channel[i] = dry + (crushed - dry) * wet;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Steps between 0 and full scale at that many bits: 2^(bits-1), so 1 bit leaves -1, 0 and 1. */
|
||||
export function crushSteps(bits: number): number {
|
||||
return 2 ** (Math.max(1, Math.min(16, Number.isFinite(bits) ? bits : 16)) - 1);
|
||||
}
|
||||
|
||||
/** How often a sample is taken, never more often than the context makes them. */
|
||||
function crushRate(rate: number, sampleRate: number): number {
|
||||
return Math.max(1, Math.min(sampleRate, Number.isFinite(rate) ? rate : sampleRate));
|
||||
}
|
||||
|
||||
const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!);
|
||||
@@ -23,7 +23,19 @@ export type FxStage =
|
||||
| { kind: 'filter'; filter: FxFilter; frequency: number; gain: number; q: number; mod?: FxMod }
|
||||
| FxPass
|
||||
| { kind: 'envelope'; envelope: FxEnvelope }
|
||||
| FxDelay;
|
||||
| FxDelay
|
||||
| FxCrush;
|
||||
|
||||
/** A Bitcrusher (#322): bits, the rate in Hz it holds the sound at, and how much of it is heard. */
|
||||
export interface FxCrush {
|
||||
kind: 'crush';
|
||||
bits: number;
|
||||
rate: number;
|
||||
mix: number;
|
||||
bitsMod?: FxMod;
|
||||
rateMod?: FxMod;
|
||||
mixMod?: FxMod;
|
||||
}
|
||||
|
||||
/** A filter as Web Audio takes it: `q` in dB for a low- or high-pass, as a factor for a band-pass. */
|
||||
export interface FxPass {
|
||||
@@ -144,7 +156,8 @@ export type FxStagePlan =
|
||||
damping: FxAmount;
|
||||
mix: FxAmount;
|
||||
pingPong: boolean;
|
||||
};
|
||||
}
|
||||
| { kind: 'crush'; bits: FxAmount; rate: FxAmount; mix: FxAmount };
|
||||
|
||||
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
|
||||
export type FxPlan =
|
||||
@@ -477,6 +490,28 @@ export const SoundFxDelay: SoundFxNodeDef = {
|
||||
},
|
||||
};
|
||||
|
||||
const CRUSH_BITS = { ...numberRow('bits', 'Bits', 1, 16, 0.1), default: 8 };
|
||||
const CRUSH_RATE = { ...numberRow('rate', 'Rate (Hz)', 100, 48000, 1), default: 8000 };
|
||||
const CRUSH_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 1 };
|
||||
|
||||
export const SoundFxBitcrusher: SoundFxNodeDef = {
|
||||
type: 'SoundFxBitcrusher',
|
||||
label: 'Bitcrusher',
|
||||
category: 'sound',
|
||||
description:
|
||||
'Makes the sound rough, like an old game console. "bits" rounds it to fewer steps: 16 is hardly heard, 8 crackles, 4 and below hisses and buzzes. Bits need not be whole, 3.5 lies between 3 and 4. "rate" takes a sample only so many times a second and holds it until the next: 48000 leaves the sound as it is, 8000 is dull and metallic, a few hundred Hz is a buzz of its own. "mix" is how much of the crushed sound is heard instead of the sound itself. All three can change while the sound plays, and in an instrument an Envelope can move them.',
|
||||
inputs: [audioIn, ...[CRUSH_BITS, CRUSH_RATE, CRUSH_MIX].map(followingIn)],
|
||||
outputs: [audioOut],
|
||||
params: [],
|
||||
emit: (sounds, amounts) => {
|
||||
const mix = held(amounts.mix, CRUSH_MIX);
|
||||
if (isNone(mix)) return arriving(sounds);
|
||||
return withStages(arriving(sounds), [
|
||||
{ kind: 'crush', bits: held(amounts.bits, CRUSH_BITS), rate: held(amounts.rate, CRUSH_RATE), mix },
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
/** Where damping 0 leaves the echoes and damping 1 takes them: evenly in octaves between. */
|
||||
const UNDAMPED_HZ = 20000;
|
||||
const DAMPED_HZ = 500;
|
||||
@@ -494,6 +529,7 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
|
||||
SoundFxFilter,
|
||||
SoundFxMix,
|
||||
SoundFxDelay,
|
||||
SoundFxBitcrusher,
|
||||
];
|
||||
|
||||
/** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */
|
||||
@@ -570,6 +606,21 @@ function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (stage.kind === 'crush') {
|
||||
const bitsMod = modOf(stage.bits, params);
|
||||
const rateMod = modOf(stage.rate, params);
|
||||
const mixMod = modOf(stage.mix, params);
|
||||
stages.push({
|
||||
kind: 'crush',
|
||||
bits: amountOf(stage.bits, params),
|
||||
rate: amountOf(stage.rate, params),
|
||||
mix: amountOf(stage.mix, params),
|
||||
...(bitsMod && { bitsMod }),
|
||||
...(rateMod && { rateMod }),
|
||||
...(mixMod && { mixMod }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const gain = amountOf(stage.gain, params);
|
||||
const mod = modOf(stage.gain, params);
|
||||
if (gain !== 0 || stage.gain.param !== undefined || mod)
|
||||
|
||||
@@ -61,7 +61,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
duration: (file: string) => soundLibrary.duration(file),
|
||||
/** Where the knobs of each voice of that file are now, oldest first, a pitch as its speed (#315). */
|
||||
levels: (file: string) => soundLibrary.levels(file),
|
||||
pitchWorkletUrl: () => soundLibrary.pitchWorkletUrl(),
|
||||
fxWorkletUrl: () => soundLibrary.fxWorkletUrl(),
|
||||
/** Seconds on the audio clock: the time a voice really runs on. */
|
||||
clock: () => soundLibrary.clock(),
|
||||
/** Decodes ahead of a check, so a play can be asserted without racing the network. */
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
FLAT_Q,
|
||||
fxChainOf,
|
||||
type FxChain,
|
||||
type FxCrush,
|
||||
type FxDelay,
|
||||
type FxEnvelope,
|
||||
type FxMod,
|
||||
@@ -23,8 +24,8 @@ import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
|
||||
import { DEFAULT_TEMPO } from '../core/music/time';
|
||||
import { type CutRange, cutChannels, cutRange, parseCutName } from '../core/model/sound';
|
||||
import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
|
||||
import type { PitchOptions } from './pitchWorklet';
|
||||
import pitchWorklet from './pitchWorklet.ts?worker&url';
|
||||
import type { PitchOptions } from './fxWorklet';
|
||||
import fxWorkletUrl from './fxWorklet.ts?worker&url';
|
||||
import { wake } from './wake';
|
||||
|
||||
/** A voice is no louder than a sound item of a song turned all the way up. */
|
||||
@@ -76,6 +77,12 @@ interface Source {
|
||||
leave: (() => void) | null;
|
||||
}
|
||||
|
||||
/** Something of a voice that is started with it and stopped when it goes. */
|
||||
interface Driver {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/** Whether a note's end leaves a source as it is: a one-shot, which plays to the end of its sound. */
|
||||
const playsThrough = (source: Source) => !source.gate && !source.leave;
|
||||
|
||||
@@ -111,10 +118,10 @@ interface Voice {
|
||||
/** Sources already set to end, by a release or a stop. */
|
||||
ending: Set<Source>;
|
||||
/**
|
||||
* What carries a number of it on the audio thread (a Mix "Both at center", an Envelope): stopped
|
||||
* when it goes.
|
||||
* What carries a number of it on the audio thread (a Mix "Both at center", an Envelope) or runs
|
||||
* there until told otherwise (a Bitcrusher): stopped when it goes.
|
||||
*/
|
||||
drivers: ConstantSourceNode[];
|
||||
drivers: Driver[];
|
||||
envelopes: EnvelopeRun[];
|
||||
/** Calls the end of one source in, once however it comes: by itself or by a release. */
|
||||
finish: ((source: Source) => void) | null;
|
||||
@@ -157,7 +164,7 @@ interface Wiring {
|
||||
offset: number;
|
||||
clip: boolean;
|
||||
sources: Source[];
|
||||
drivers: ConstantSourceNode[];
|
||||
drivers: Driver[];
|
||||
envelopes: Map<string, EnvelopeRun>;
|
||||
pitches: number[];
|
||||
knobs: Knob[];
|
||||
@@ -231,8 +238,8 @@ export class SoundLibrary implements AudioSink {
|
||||
*/
|
||||
private readonly cuts = new Map<string, AudioBuffer>();
|
||||
/** The pitch worklet being loaded, and whether it is there once it is known; null before that. */
|
||||
private pitching: Promise<boolean> | null = null;
|
||||
private canPitch: boolean | null = null;
|
||||
private workletLoading: Promise<boolean> | null = null;
|
||||
private hasWorklet: boolean | null = null;
|
||||
/** The files whose samples the worklet holds already, so the next voice of one is sent without. */
|
||||
private readonly sent = new Set<string>();
|
||||
private readonly loading = new Map<string, Promise<AudioBuffer | null>>();
|
||||
@@ -547,8 +554,8 @@ export class SoundLibrary implements AudioSink {
|
||||
const id = this.nextId++;
|
||||
const asked: Start = { ...start, id };
|
||||
const loaded = start.files.every((file) => this.buffer(file) !== null);
|
||||
const pitching = start.fx.layers.some(pitches) ? this.pitchWorklet() : null;
|
||||
if (loaded && (!pitching || this.canPitch !== null)) {
|
||||
const worklet = start.fx.layers.some(needsWorklet) ? this.fxWorklet() : null;
|
||||
if (loaded && (!worklet || this.hasWorklet !== null)) {
|
||||
this.start(asked);
|
||||
return id;
|
||||
}
|
||||
@@ -562,18 +569,16 @@ export class SoundLibrary implements AudioSink {
|
||||
released: false,
|
||||
};
|
||||
this.pending.push(waiting);
|
||||
void Promise.all([Promise.all(start.files.map((file) => this.load(file))), pitching]).then(
|
||||
([buffers]) => {
|
||||
this.pending = this.pending.filter((other) => other !== waiting);
|
||||
// A `Stop sound` – or the destruction of the behavior that asked – that happened while the
|
||||
// bytes were in flight has to win, or a sound the run already ended would start afterwards.
|
||||
if (buffers.every((buffer) => !buffer) || waiting.stopped) return;
|
||||
if (this.cancelsFor(start.file, start.owner) !== askedAt) return;
|
||||
this.start({ ...asked, fx: waiting.fx });
|
||||
for (const { params, time, ease } of waiting.timed) this.tune(id, params, time, ease);
|
||||
if (waiting.released) this.release(id);
|
||||
},
|
||||
);
|
||||
void Promise.all([Promise.all(start.files.map((file) => this.load(file))), worklet]).then(([buffers]) => {
|
||||
this.pending = this.pending.filter((other) => other !== waiting);
|
||||
// A `Stop sound` – or the destruction of the behavior that asked – that happened while the
|
||||
// bytes were in flight has to win, or a sound the run already ended would start afterwards.
|
||||
if (buffers.every((buffer) => !buffer) || waiting.stopped) return;
|
||||
if (this.cancelsFor(start.file, start.owner) !== askedAt) return;
|
||||
this.start({ ...asked, fx: waiting.fx });
|
||||
for (const { params, time, ease } of waiting.timed) this.tune(id, params, time, ease);
|
||||
if (waiting.released) this.release(id);
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -689,7 +694,7 @@ export class SoundLibrary implements AudioSink {
|
||||
tunedAt: this.tunedAt,
|
||||
decoded: this.buffers.size,
|
||||
failed: this.failed.size,
|
||||
worklet: this.canPitch,
|
||||
worklet: this.hasWorklet,
|
||||
last: this.last,
|
||||
};
|
||||
}
|
||||
@@ -748,13 +753,13 @@ export class SoundLibrary implements AudioSink {
|
||||
...[...new Set([...layers.flatMap(filesOf), ...files])]
|
||||
.filter((file) => file !== '')
|
||||
.map((file) => this.load(file)),
|
||||
layers.some(pitches) ? this.pitchWorklet() : null,
|
||||
layers.some(needsWorklet) ? this.fxWorklet() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Where the pitch worklet is served from, for a check that runs it in an offline context (test hook). */
|
||||
pitchWorkletUrl(): string {
|
||||
return pitchWorklet;
|
||||
/** Where the FX worklet is served from, for a check that runs it in an offline context (test hook). */
|
||||
fxWorkletUrl(): string {
|
||||
return fxWorkletUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1076,6 +1081,7 @@ export class SoundLibrary implements AudioSink {
|
||||
tail.connect(made.input);
|
||||
tail = made.output;
|
||||
knobs.push(...made.knobs);
|
||||
if (made.stop) wiring.drivers.push({ start: () => {}, stop: made.stop });
|
||||
}
|
||||
wiring.stages += chain.stages.length;
|
||||
return tail;
|
||||
@@ -1121,7 +1127,7 @@ export class SoundLibrary implements AudioSink {
|
||||
// A file that could not be loaded leaves its way silent rather than the whole note.
|
||||
const buffer = wiring.buffers.get(file) ?? context.createBuffer(1, 1, context.sampleRate);
|
||||
wiring.pitches.push(pitch);
|
||||
if (shifted && this.canPitch) {
|
||||
if (shifted && this.hasWorklet) {
|
||||
const options: PitchOptions = {
|
||||
file,
|
||||
step: buffer.sampleRate / context.sampleRate,
|
||||
@@ -1259,15 +1265,15 @@ export class SoundLibrary implements AudioSink {
|
||||
return this.failed.has(parseCutName(name).file);
|
||||
}
|
||||
|
||||
/** Loads the pitch worklet into the context once; says whether it is there. */
|
||||
private pitchWorklet(): Promise<boolean> {
|
||||
return (this.pitching ??= (async () => {
|
||||
/** Loads the FX worklet (Pitch, Bitcrusher) into the context once; says whether it is there. */
|
||||
private fxWorklet(): Promise<boolean> {
|
||||
return (this.workletLoading ??= (async () => {
|
||||
try {
|
||||
await this.audio().audioWorklet.addModule(pitchWorklet);
|
||||
return (this.canPitch = true);
|
||||
await this.audio().audioWorklet.addModule(fxWorkletUrl);
|
||||
return (this.hasWorklet = true);
|
||||
} catch (error: unknown) {
|
||||
console.error('[sounds] cannot load the pitch worklet, a Pitch is not heard', error);
|
||||
return (this.canPitch = false);
|
||||
console.error('[sounds] cannot load the FX worklet, a Pitch or Bitcrusher is not heard', error);
|
||||
return (this.hasWorklet = false);
|
||||
}
|
||||
})());
|
||||
}
|
||||
@@ -1314,9 +1320,10 @@ interface LastVoice {
|
||||
envelopes: number;
|
||||
}
|
||||
|
||||
/** Whether any way of a plan has a Pitch, and so needs the worklet. */
|
||||
function pitches(plan: FxPlan): boolean {
|
||||
return 'mix' in plan ? pitches(plan.a) || pitches(plan.b) : plan.pitch.length > 0;
|
||||
/** Whether a plan has a Pitch or a Bitcrusher anywhere, and so needs the worklet. */
|
||||
function needsWorklet(plan: FxPlan): boolean {
|
||||
if (plan.stages.some((stage) => stage.kind === 'crush')) return true;
|
||||
return 'mix' in plan ? needsWorklet(plan.a) || needsWorklet(plan.b) : plan.pitch.length > 0;
|
||||
}
|
||||
|
||||
/** The files the ways of a plan read with a source of their own (#331). */
|
||||
@@ -1348,6 +1355,8 @@ interface StageNodes {
|
||||
input: AudioNode;
|
||||
output: AudioNode;
|
||||
knobs: Knob[];
|
||||
/** Ends a stage that runs on the audio thread until it is told to. */
|
||||
stop?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1392,6 +1401,7 @@ export function stageNodes(context: BaseAudioContext, stage: FxStage, follow?: F
|
||||
}
|
||||
if (stage.kind === 'pass') return passNodes(context, stage, follow);
|
||||
if (stage.kind === 'delay') return delayNodes(context, stage);
|
||||
if (stage.kind === 'crush') return crushNodes(context, stage, follow);
|
||||
const filter = context.createBiquadFilter();
|
||||
filter.type = stage.filter;
|
||||
filter.frequency.value = stage.frequency;
|
||||
@@ -1520,6 +1530,48 @@ function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A Bitcrusher (#322) on the worklet. Bits and mix move in what they hold, the rate in octaves.
|
||||
* Where the worklet is not there the sound goes through as it is.
|
||||
*/
|
||||
function crushNodes(context: BaseAudioContext, stage: FxCrush, follow?: Follow): StageNodes {
|
||||
let node: AudioWorkletNode;
|
||||
try {
|
||||
node = new AudioWorkletNode(context, 'bit-crusher', {
|
||||
parameterData: {
|
||||
bits: stage.bitsMod ? 0 : stage.bits,
|
||||
rate: stage.rateMod ? 0 : stage.rate,
|
||||
mix: stage.mixMod ? 0 : stage.mix,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
const through = context.createGain();
|
||||
const still = (target: number): Knob => ({ param: null, target, even: 'value' });
|
||||
return {
|
||||
input: through,
|
||||
output: through,
|
||||
knobs: [still(stage.bits), still(stage.rate), still(stage.mix)],
|
||||
};
|
||||
}
|
||||
const param = (name: string) => node.parameters.get(name)!;
|
||||
if (stage.bitsMod) follow?.(stage.bitsMod, param('bits'), false);
|
||||
if (stage.rateMod) follow?.(stage.rateMod, param('rate'), true);
|
||||
if (stage.mixMod) follow?.(stage.mixMod, param('mix'), false);
|
||||
return {
|
||||
input: node,
|
||||
output: node,
|
||||
knobs: [
|
||||
{ param: stage.bitsMod ? null : param('bits'), target: stage.bits, even: 'value' },
|
||||
{ param: stage.rateMod ? null : param('rate'), target: stage.rate, even: 'ratio' },
|
||||
{ param: stage.mixMod ? null : param('mix'), target: stage.mix, even: 'value' },
|
||||
],
|
||||
stop: () => {
|
||||
node.port.postMessage('stop');
|
||||
node.disconnect();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The shares of a and b over `mix` at 0, 0.5 and 1: a WaveShaper reads -1…1, so its curve starts below 0. */
|
||||
const SHARE_A = new Float32Array([1, 1, 1, 1, 0]);
|
||||
const SHARE_B = new Float32Array([0, 0, 0, 1, 1]);
|
||||
@@ -1622,6 +1674,7 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] {
|
||||
for (const stage of chain.stages)
|
||||
if (stage.kind === 'delay') into.push(stage.time, stage.feedback, stage.damping, stage.mix);
|
||||
else if (stage.kind === 'pass') into.push(stage.cutoff, stage.q);
|
||||
else if (stage.kind === 'crush') into.push(stage.bits, stage.rate, stage.mix);
|
||||
else if (stage.kind !== 'envelope') into.push(stage.gain);
|
||||
return into;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* The audio thread's side of a pitched voice (#317): plays one sound through a `PitchPlayer`, its
|
||||
* pitch the AudioParam `pitch` in semitones, so it glides, ramps and eases like any other knob. Loaded
|
||||
* into the context once by the sound library; nothing on the page imports it.
|
||||
* The audio thread's side of the effects that Web Audio has no node for. A pitched voice (#317)
|
||||
* plays one sound through a `PitchPlayer`, its pitch the AudioParam `pitch` in semitones, so it
|
||||
* glides, ramps and eases like any other knob. A Bitcrusher (#322) runs a `Crusher`. Loaded into
|
||||
* the context once by the sound library; nothing on the page imports it.
|
||||
*/
|
||||
import { Crusher } from '../core/soundfx/crusher';
|
||||
import { PitchPlayer } from '../core/soundfx/pitchPlayer';
|
||||
|
||||
/** What the audio thread has and the page's types do not. */
|
||||
@@ -11,9 +13,9 @@ declare const currentTime: number;
|
||||
declare class AudioWorkletProcessor {
|
||||
readonly port: MessagePort;
|
||||
}
|
||||
declare function registerProcessor(
|
||||
declare function registerProcessor<T>(
|
||||
name: string,
|
||||
processor: new (options: { processorOptions: PitchOptions }) => object,
|
||||
processor: new (options: { processorOptions: T }) => object,
|
||||
): void;
|
||||
|
||||
export interface PitchOptions {
|
||||
@@ -80,3 +82,36 @@ class PitchProcessor extends AudioWorkletProcessor {
|
||||
}
|
||||
|
||||
registerProcessor('pitch-player', PitchProcessor);
|
||||
|
||||
/** A Bitcrusher (#322) between two stages of a voice; it runs until the voice tells it to stop. */
|
||||
class CrushProcessor extends AudioWorkletProcessor {
|
||||
private readonly crusher = new Crusher(sampleRate);
|
||||
private stopped = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.port.onmessage = () => {
|
||||
this.stopped = true;
|
||||
};
|
||||
}
|
||||
|
||||
process(
|
||||
inputs: Float32Array[][],
|
||||
outputs: Float32Array[][],
|
||||
parameters: Record<string, Float32Array>,
|
||||
): boolean {
|
||||
if (this.stopped) return false;
|
||||
this.crusher.render(inputs[0] ?? [], outputs[0]!, parameters.bits!, parameters.rate!, parameters.mix!);
|
||||
return true;
|
||||
}
|
||||
|
||||
static get parameterDescriptors() {
|
||||
return [
|
||||
{ name: 'bits', defaultValue: 16 },
|
||||
{ name: 'rate', defaultValue: 48000 },
|
||||
{ name: 'mix', defaultValue: 0 },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('bit-crusher', CrushProcessor);
|
||||
Reference in new issue
Block a user