Swirl and comb a sound with a Phaser and a Flanger node (#341)
sfxr's "phaser" is a comb filter, the sound plus a copy delayed by up to 2.9 ms. That is a flanger, not the phaser a guitarist knows. Both effects come in as nodes of their own, so each name fits its sound and sfxr is covered in full. Both run on the FX worklet like the Bitcrusher, because a feedback loop in Web Audio costs a render quantum per round. - Phaser: first-order allpasses (Stages 2, 4, 6, 8, 12) around Frequency, moved by a sine LFO Depth octaves wide at Rate Hz, with Feedback and Mix. An Envelope can move Frequency and Mix. - Flanger: a delay line of Offset ms. Sweep moves it on in ms/s from the start of the voice, an LFO adds up to Depth ms. As in sfxr the delay is mirrored at 0, and it never goes past 20 ms. Feedback comes back exactly one delay later. An Envelope can move Offset and Mix. - Mix 0 leaves the sound as it is, 1 is half sound and half copy. The notches are deepest there and the peaks stay at the sound's level. sfxr adds both at full, so its sound comes out 6 dB louder. - The Flanger's processor gets the start of its voice and begins the sweep on that sample. stageNodes takes it as a new argument `at`. - Explosion gets a Flanger in one draw out of two, with sfxr's offset and sweep and no LFO. Offset is offset^2 * 2.89 ms, Sweep is ramp^2 * 125 ms/s, signed so the direction holds. - Randomize draws a Flanger's offset and sweep like sfxr. Its LFO and feedback and all of a Phaser are drawn within bounds of their own. Mix and Stages stay, as a Bitcrusher's Mix does. Mutate moves them too, and a 0 stays 0. - New instrument preset Phased Pad: saw, Envelope, low-pass, Phaser of six stages. The other 14 are unchanged. - The Bitcrusher's worklet setup is now shared with both nodes (workletNodes), its behaviour is unchanged. Tests: - Unit phaser (new): 8 green. Counter-check with clamping at 0 instead of mirroring and a flipped allpass coefficient: 3 red. - Unit soundfx, sound-library, chip-presets: 45 / 76 / 14 green. Counter-checks: start time not passed on, phaser frequency eased linearly, sweep sign dropped, Phaser left out of Mutate, each red. - Unit core, viewport, graph: 179 files, 2979 tests green. - e2e 34 (melektaus-atx): ALL PASSED, 70 checks (1 new), first run. Chrome offline: 1 kHz notch RMS 0.0000 with two stages, 0.5657 with four. Flanger echo after 48 samples, swept echo at 533/534. About 47 Phasers (12 stages) or 63 Flangers fill the audio thread. - Gate (melektaus-atx): CHECK EXIT 0, 257 files, 3991 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
29b604dc07
commit
01fd6e6464
13 files changed
+999
-48
No files matched your search
+81
-1
@@ -859,6 +859,86 @@ export async function sound(t) {
|
||||
crushed.halfOff < 1e-6,
|
||||
JSON.stringify(crushed),
|
||||
);
|
||||
// #341: Phaser and Flanger on the worklet, rendered offline. A 1 kHz sine at 0.8 through a Phaser
|
||||
// at 1 kHz: two stages cut it out, four leave it whole. Swept 4 octaves wide once a second,
|
||||
// the notch passes it and is two octaves off between, where cos(28°) of it is left. A
|
||||
// Flanger of a voice starting at 0.25 s: an impulse 1 ms later comes back after 48 samples
|
||||
// at half. Sweeping 100 ms/s from that start, an impulse 0.1 s in comes back 533 samples
|
||||
// later. Then 64 stereo voices of each for 2 s, timed.
|
||||
const swirled = await evaluate(`
|
||||
(async () => {
|
||||
const RATE = 48000;
|
||||
const render = async (voices, seconds, channels, stage, fill, at = 0) => {
|
||||
const context = new OfflineAudioContext(channels, RATE * seconds, RATE);
|
||||
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
|
||||
const sound = context.createBuffer(channels, RATE * seconds, RATE);
|
||||
for (let c = 0; c < channels; c++) fill(sound.getChannelData(c));
|
||||
for (let v = 0; v < voices; v++) {
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = sound;
|
||||
const nodes = __gm.sound.stageNodes(context, stage, at);
|
||||
source.connect(nodes.input);
|
||||
nodes.output.connect(context.destination);
|
||||
source.start();
|
||||
}
|
||||
const started = performance.now();
|
||||
const out = (await context.startRendering()).getChannelData(0);
|
||||
return { out, ms: performance.now() - started };
|
||||
};
|
||||
const sine = (data) => {
|
||||
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * 0.8;
|
||||
};
|
||||
const rms = (out, from, to) => {
|
||||
let sum = 0;
|
||||
for (let i = from; i < to; i++) sum += out[i] ** 2;
|
||||
return Math.sqrt(sum / (to - from));
|
||||
};
|
||||
const phaser = (stages, rate, depth) =>
|
||||
({ kind: 'phaser', stages, frequency: 1000, rate, depth, feedback: 0, mix: 1 });
|
||||
const notch = (await render(1, 0.3, 1, phaser(2, 0, 0), sine)).out;
|
||||
const peak = (await render(1, 0.3, 1, phaser(4, 0, 0), sine)).out;
|
||||
const swept = (await render(1, 1, 1, phaser(2, 1, 4), sine)).out;
|
||||
const windows = Array.from({ length: 40 }, (_, w) => rms(swept, w * 1200, (w + 1) * 1200));
|
||||
const flanger = (offset, sweep) =>
|
||||
({ kind: 'flanger', offset, sweep, rate: 0, depth: 0, feedback: 0, mix: 1 });
|
||||
const impulse = (at) => (data) => { data[Math.round(at * RATE)] = 1; };
|
||||
const heard = (out, from) => {
|
||||
const found = [];
|
||||
for (let i = from; i < from + 2000; i++) if (Math.abs(out[i]) > 1e-4) found.push([i - from, +out[i].toFixed(3)]);
|
||||
return found;
|
||||
};
|
||||
const still = heard((await render(1, 0.5, 1, flanger(1, 0), impulse(0.251), 0.25)).out, Math.round(0.251 * RATE));
|
||||
const sweeping = heard((await render(1, 0.5, 1, flanger(0, 100), impulse(0.35), 0.25)).out, Math.round(0.35 * RATE));
|
||||
const noise = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin(i * 12.9898) % 1 * 0.5; };
|
||||
const phaserLoad = await render(64, 2, 2, { ...phaser(12, 0.5, 2), feedback: 0.6 }, noise);
|
||||
const flangerLoad = await render(64, 2, 2, { kind: 'flanger', offset: 1, sweep: 2, rate: 0.3, depth: 3, feedback: 0.5, mix: 1 }, noise);
|
||||
return {
|
||||
notch: +rms(notch, 4800, notch.length).toFixed(4),
|
||||
peak: +rms(peak, 4800, peak.length).toFixed(4),
|
||||
sweptLow: +Math.min(...windows).toFixed(3),
|
||||
sweptHigh: +Math.max(...windows).toFixed(3),
|
||||
still,
|
||||
sweeping,
|
||||
phaserVoices: Math.round((64 * 2000) / phaserLoad.ms),
|
||||
flangerVoices: Math.round((64 * 2000) / flangerLoad.ms),
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
'the Phaser cuts a notch at its frequency and sweeps it, the Flanger comes back offset ms late and sweeps from its voice',
|
||||
swirled.notch < 0.01 &&
|
||||
Math.abs(swirled.peak - 0.8 * Math.SQRT1_2) < 0.01 &&
|
||||
swirled.sweptLow < 0.1 &&
|
||||
swirled.sweptHigh > 0.46 &&
|
||||
swirled.sweptHigh < 0.51 &&
|
||||
JSON.stringify(swirled.still) ===
|
||||
JSON.stringify([
|
||||
[0, 0.5],
|
||||
[48, 0.5],
|
||||
]) &&
|
||||
swirled.sweeping[0]?.[1] === 0.5 &&
|
||||
JSON.stringify(swirled.sweeping.slice(1).map(([i]) => i)) === JSON.stringify([533, 534]),
|
||||
JSON.stringify(swirled),
|
||||
);
|
||||
// #329: the Chip on the worklet, rendered offline. A square at 220 Hz with a pitch of 12 and a duty
|
||||
// of 0.25, starting at 0.25 s: silence until then, 440 periods a second after it, up a
|
||||
// quarter of each. Sliding two octaves a second down from 440 Hz with a lowest frequency of
|
||||
@@ -1215,7 +1295,7 @@ export async function sound(t) {
|
||||
const unled = await insGraph();
|
||||
check(
|
||||
'Preset → Square Lead plays every note at its own pitch and holds it; one undo takes it back',
|
||||
['Square Lead', 'Organ', 'Bell', 'Drum Kit'].every((label) => presetMenu.includes(label)) &&
|
||||
['Square Lead', 'Phased Pad', 'Organ', 'Bell', 'Drum Kit'].every((label) => presetMenu.includes(label)) &&
|
||||
leadGraph.chip?.followNote === true &&
|
||||
leadGraph.envelope?.sustain > 0 &&
|
||||
lead.last?.file === `instrument:${insId}` &&
|
||||
|
||||
Reference in new issue
Block a user