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
@@ -10,9 +10,13 @@ import {
|
||||
type FxPassFilter,
|
||||
passOf,
|
||||
SoundFxBitcrusher,
|
||||
FLANGER_ROWS,
|
||||
PHASER_ROWS,
|
||||
SoundFxDelay,
|
||||
SoundFxFilter,
|
||||
SoundFxFlanger,
|
||||
SoundFxMix,
|
||||
SoundFxPhaser,
|
||||
SoundFxPitch,
|
||||
} from '../soundfx/nodes';
|
||||
import {
|
||||
@@ -30,7 +34,8 @@ import { HIGHEST_NOTE, LOWEST_NOTE } from './notes';
|
||||
/**
|
||||
* The Preset menu, Randomize and Mutate of the Instrument tab (#329), the way sfxr makes its sounds:
|
||||
* its generators and its randomizer draw sfxr's settings, 0 to 1 as its sliders have them, which are
|
||||
* then worked out into the numbers of a Chip, an Envelope, a Filter and a Bitcrusher. sfxr is
|
||||
* then worked out into the numbers of a Chip, an Envelope, a Filter, a Bitcrusher and a Flanger (#341),
|
||||
* which sfxr calls its phaser. sfxr is
|
||||
* Copyright (c) 2007 Tomas Pettersson, MIT licence.
|
||||
*
|
||||
* Next to those sounds the menu has instruments to play tunes on: Chips that follow the note and hold
|
||||
@@ -45,6 +50,7 @@ export type TonePreset =
|
||||
| 'brass'
|
||||
| 'detunedSaws'
|
||||
| 'stringPad'
|
||||
| 'phasedPad'
|
||||
| 'organ'
|
||||
| 'flute'
|
||||
| 'bell'
|
||||
@@ -62,6 +68,7 @@ export const TONE_PRESETS: readonly { value: TonePreset; label: string }[] = [
|
||||
{ value: 'brass', label: 'Synth Brass' },
|
||||
{ value: 'detunedSaws', label: 'Detuned Saws' },
|
||||
{ value: 'stringPad', label: 'String Pad' },
|
||||
{ value: 'phasedPad', label: 'Phased Pad' },
|
||||
{ value: 'organ', label: 'Organ' },
|
||||
{ value: 'flute', label: 'Flute' },
|
||||
{ value: 'bell', label: 'Bell' },
|
||||
@@ -112,6 +119,8 @@ interface Sfxr {
|
||||
repeatSpeed: number;
|
||||
arpSpeed: number;
|
||||
arpMod: number;
|
||||
phaOffset: number;
|
||||
phaRamp: number;
|
||||
}
|
||||
|
||||
const reset = (): Sfxr => ({
|
||||
@@ -133,6 +142,8 @@ const reset = (): Sfxr => ({
|
||||
repeatSpeed: 0,
|
||||
arpSpeed: 0,
|
||||
arpMod: 0,
|
||||
phaOffset: 0,
|
||||
phaRamp: 0,
|
||||
});
|
||||
|
||||
/** sfxr's waves by their number there. */
|
||||
@@ -192,6 +203,10 @@ function presetSfxr(preset: EffectPreset, random: Random): Sfxr {
|
||||
if (r(2) === 0) p.repeatSpeed = 0.3 + f(0.5);
|
||||
p.envSustain = 0.1 + f(0.3);
|
||||
p.envDecay = f(0.5);
|
||||
if (r(1)) {
|
||||
p.phaOffset = -0.3 + f(0.9);
|
||||
p.phaRamp = -f(0.3);
|
||||
}
|
||||
if (r(1)) {
|
||||
p.vibStrength = f(0.7);
|
||||
p.vibSpeed = f(0.6);
|
||||
@@ -277,6 +292,9 @@ function randomSfxr(random: Random): Sfxr {
|
||||
p.repeatSpeed = signed();
|
||||
p.arpSpeed = signed();
|
||||
p.arpMod = signed();
|
||||
// Drawn last, not where sfxr has them, so the numbers before stay as they were.
|
||||
p.phaOffset = signed() ** 3;
|
||||
p.phaRamp = signed() ** 3;
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -331,6 +349,20 @@ function filterNumbers(p: Sfxr, filter: FxPassFilter): Record<'cutoff' | 'resona
|
||||
return { cutoff: 17756 * Math.max(0, p.lpfFreq) ** 1.5, resonance: Math.abs(p.lpfResonance) };
|
||||
}
|
||||
|
||||
/**
|
||||
* sfxr's phaser as a Flanger: its delay is `offset²` of 1020 samples taken 8 times over, and moves
|
||||
* `ramp²` of one of them every sample. Mirrored at 0 as it is, a sweep towards 0 is one with the
|
||||
* other sign than the offset.
|
||||
*/
|
||||
function flangerNumbers(p: Sfxr): Record<'offset' | 'sweep', number> {
|
||||
const taken = 8 * SFXR_RATE;
|
||||
const away = Math.sign(p.phaOffset || 1) * Math.sign(p.phaRamp);
|
||||
return {
|
||||
offset: ((p.phaOffset ** 2 * 1020) / taken) * 1000,
|
||||
sweep: (away * p.phaRamp ** 2 * SFXR_RATE * 1000) / taken,
|
||||
};
|
||||
}
|
||||
|
||||
/** A number held to its row and rounded to as many places as its step has. */
|
||||
function heldTo(value: number, row: FlatDef<'number'>): number {
|
||||
const places = Math.max(0, -Math.floor(Math.log10(row.step ?? 0.01)));
|
||||
@@ -391,7 +423,7 @@ const everyNote: PropertyBag = { from: String(LOWEST_NOTE), to: String(HIGHEST_N
|
||||
|
||||
/**
|
||||
* An effect preset as a whole graph (#329): every note plays one Chip, which ends by itself through
|
||||
* an Envelope, a Filter and a Bitcrusher. The Chip keeps to the preset's own frequency, as sfxr does,
|
||||
* an Envelope, a Filter, sfxr's phaser where it has one, and a Bitcrusher. The Chip keeps to the preset's own frequency, as sfxr does,
|
||||
* so every key plays the same sound until "follow note" is turned on. Each call draws it anew.
|
||||
*/
|
||||
function effectGraph(preset: EffectPreset, random: Random): Graph {
|
||||
@@ -406,11 +438,21 @@ function effectGraph(preset: EffectPreset, random: Random): Graph {
|
||||
...held(chipNumbers(p), CHIP_ROWS),
|
||||
});
|
||||
s.wire(notes, 'any', chip, 'trigger');
|
||||
const flanged: Step[] =
|
||||
p.phaOffset !== 0 || p.phaRamp !== 0
|
||||
? [
|
||||
[
|
||||
SoundFxFlanger.type,
|
||||
held({ ...flangerNumbers(p), rate: 0, depth: 0, feedback: 0, mix: 1 }, FLANGER_ROWS),
|
||||
],
|
||||
]
|
||||
: [];
|
||||
return s.end(
|
||||
s.line(
|
||||
chip,
|
||||
[INSTRUMENT_ENVELOPE, held(envelopeNumbers(p), ENVELOPE_ROWS)],
|
||||
[SoundFxFilter.type, { filter, ...held(filterNumbers(p, filter), FILTER_ROWS) }],
|
||||
...flanged,
|
||||
[
|
||||
SoundFxBitcrusher.type,
|
||||
held({ bits: crushed ? 8 : 16, rate: crushed ? 22050 : 44100, mix: 1 }, CRUSH_ROWS),
|
||||
@@ -448,6 +490,8 @@ const envelope = (attack: number, decay: number, sustain: number, release: numbe
|
||||
const pass = (filter: FxPassFilter, cutoff: number, resonance = 0) =>
|
||||
[SoundFxFilter.type, { filter, cutoff, resonance }] as const;
|
||||
const pitch = (semitones: number) => [SoundFxPitch.type, { semitones }] as const;
|
||||
const phaser = (stages: number, frequency: number, rate: number, depth: number, feedback: number) =>
|
||||
[SoundFxPhaser.type, { stages: String(stages), frequency, rate, depth, feedback, mix: 1 }] as const;
|
||||
const echo = (time: number, feedback: number, mix: number, pingPong = false) =>
|
||||
[SoundFxDelay.type, { time, sync: 'beats', feedback, damping: 0.4, mix, pingPong }] as const;
|
||||
|
||||
@@ -587,6 +631,14 @@ const TONES: Record<Exclude<TonePreset, 'drumKit'>, (s: Sketch, notes: GraphNode
|
||||
envelope(0.5, 0.5, 0.8, 1),
|
||||
pass('lowpass', 2200, 0.1),
|
||||
),
|
||||
// A saw pad, slow to come, swirled by a phaser of six stages.
|
||||
phasedPad: (s, notes) =>
|
||||
s.line(
|
||||
chipOn(s, notes, 0, 'saw', { vibratoDepth: 0.1, vibratoSpeed: 5 }),
|
||||
envelope(0.3, 0.5, 0.8, 0.8),
|
||||
pass('lowpass', 3000, 0.1),
|
||||
phaser(6, 600, 0.3, 2.5, 0.5),
|
||||
),
|
||||
// A square and the square an octave up, held at full while the key is.
|
||||
organ: (s, notes) => {
|
||||
const low = chipOn(s, notes, 0, 'square');
|
||||
@@ -661,6 +713,10 @@ function rowsOf(type: string): Record<string, FlatDef<'number'>> | null {
|
||||
return FILTER_ROWS;
|
||||
case SoundFxBitcrusher.type:
|
||||
return CRUSH_ROWS;
|
||||
case SoundFxPhaser.type:
|
||||
return PHASER_ROWS;
|
||||
case SoundFxFlanger.type:
|
||||
return FLANGER_ROWS;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -684,15 +740,36 @@ function randomNumbers(node: GraphNode, random: Random): Record<string, number>
|
||||
return held(filterNumbers(randomSfxr(random), passOf(node.params)), FILTER_ROWS);
|
||||
case SoundFxBitcrusher.type:
|
||||
return held({ bits: 4 + random() * 12, rate: 2000 * 24 ** random() }, CRUSH_ROWS);
|
||||
case SoundFxPhaser.type:
|
||||
return held(
|
||||
{
|
||||
frequency: 200 * 20 ** random(),
|
||||
rate: 0.05 * 100 ** random(),
|
||||
depth: 0.5 + random() * 3.5,
|
||||
feedback: random() * 0.8,
|
||||
},
|
||||
PHASER_ROWS,
|
||||
);
|
||||
case SoundFxFlanger.type:
|
||||
return held(
|
||||
{
|
||||
...flangerNumbers(randomSfxr(random)),
|
||||
rate: 0.05 * 100 ** random(),
|
||||
depth: random() * 4,
|
||||
feedback: random() * 0.8,
|
||||
},
|
||||
FLANGER_ROWS,
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Randomize (#329): the numbers of every Chip, Envelope, Filter and Bitcrusher drawn anew, as sfxr's
|
||||
* randomizer draws them. Waves, filter types, the Mix of a Bitcrusher and an Envelope's "from" and
|
||||
* "to" stay, and so does everything else in the graph.
|
||||
* Randomize (#329): the numbers of every Chip, Envelope, Filter, Bitcrusher, Phaser and Flanger
|
||||
* (#341) drawn anew, as sfxr's randomizer draws them; a Phaser's, and a Flanger's LFO, which sfxr
|
||||
* has not, within bounds of their own. Waves, filter types, the stages of a Phaser, every Mix and
|
||||
* an Envelope's "from" and "to" stay, and so does everything else in the graph.
|
||||
*/
|
||||
export function randomizedParams(graph: Graph, random: Random = Math.random): ParamChange[] {
|
||||
const changes: ParamChange[] = [];
|
||||
@@ -726,6 +803,10 @@ const MUTATE: Readonly<Record<string, { octaves: number } | { by: number }>> = {
|
||||
release: { by: 0.01 },
|
||||
resonance: { by: 0.05 },
|
||||
bits: { by: 0.5 },
|
||||
offset: { by: 0.1 },
|
||||
sweep: { by: 2 },
|
||||
depth: { by: 0.2 },
|
||||
feedback: { by: 0.05 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -355,6 +355,25 @@ function bindStage(stage: FxStagePlan, bind: (amount: FxAmount) => FxAmount): Fx
|
||||
if (stage.kind === 'envelope') return stage;
|
||||
if (stage.kind === 'crush')
|
||||
return { ...stage, bits: bind(stage.bits), rate: bind(stage.rate), mix: bind(stage.mix) };
|
||||
if (stage.kind === 'phaser')
|
||||
return {
|
||||
...stage,
|
||||
frequency: bind(stage.frequency),
|
||||
rate: bind(stage.rate),
|
||||
depth: bind(stage.depth),
|
||||
feedback: bind(stage.feedback),
|
||||
mix: bind(stage.mix),
|
||||
};
|
||||
if (stage.kind === 'flanger')
|
||||
return {
|
||||
...stage,
|
||||
offset: bind(stage.offset),
|
||||
sweep: bind(stage.sweep),
|
||||
rate: bind(stage.rate),
|
||||
depth: bind(stage.depth),
|
||||
feedback: bind(stage.feedback),
|
||||
mix: bind(stage.mix),
|
||||
};
|
||||
return {
|
||||
...stage,
|
||||
time: bind(stage.time),
|
||||
|
||||
Reference in new issue
Block a user