diff --git a/src/core/instrument/chipPresets.ts b/src/core/instrument/chipPresets.ts index 2f3a114c..899e57fe 100644 --- a/src/core/instrument/chipPresets.ts +++ b/src/core/instrument/chipPresets.ts @@ -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, (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> | 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 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> = { 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 }, }; /** diff --git a/src/core/instrument/compile.ts b/src/core/instrument/compile.ts index 25f3be12..afe69c27 100644 --- a/src/core/instrument/compile.ts +++ b/src/core/instrument/compile.ts @@ -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), diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index 67af1c6f..09ee4c69 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -5,6 +5,7 @@ import type { PropertyBag } from '../model/Properties'; import { DEFAULT_TEMPO, heldTempo, secondsOfQuarters } from '../music/time'; import type { FlatDef } from '../properties/PropertySchema'; import type { ChipShape, ChipWave } from './chip'; +import { PHASER_STAGES } from './phaser'; export { SOUND_FX_INPUT, SOUND_FX_OUTPUT }; @@ -25,7 +26,35 @@ export type FxStage = | FxPass | { kind: 'envelope'; envelope: FxEnvelope } | FxDelay - | FxCrush; + | FxCrush + | FxPhaser + | FxFlanger; + +/** A Phaser (#341): its allpass stages, where they turn, how fast and how far an LFO moves them. */ +export interface FxPhaser { + kind: 'phaser'; + stages: number; + frequency: number; + rate: number; + depth: number; + feedback: number; + mix: number; + frequencyMod?: FxMod; + mixMod?: FxMod; +} + +/** A Flanger (#341): its delay in ms, how it sweeps and swings, all as its worklet takes them. */ +export interface FxFlanger { + kind: 'flanger'; + offset: number; + sweep: number; + rate: number; + depth: number; + feedback: number; + mix: number; + offsetMod?: FxMod; + mixMod?: FxMod; +} /** A Bitcrusher (#322): bits, the rate in Hz it holds the sound at, and how much of it is heard. */ export interface FxCrush { @@ -176,7 +205,12 @@ export type FxStagePlan = mix: FxAmount; pingPong: boolean; } - | { kind: 'crush'; bits: FxAmount; rate: FxAmount; mix: FxAmount }; + | { kind: 'crush'; bits: FxAmount; rate: FxAmount; mix: FxAmount } + | ({ kind: 'phaser'; stages: number } & Record< + 'frequency' | 'rate' | 'depth' | 'feedback' | 'mix', + FxAmount + >) + | ({ kind: 'flanger' } & Record<'offset' | 'sweep' | 'rate' | 'depth' | 'feedback' | 'mix', FxAmount>); /** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */ export type FxPlan = @@ -533,6 +567,113 @@ export const SoundFxBitcrusher: SoundFxNodeDef = { }, }; +const PHASER_FREQUENCY = { ...numberRow('frequency', 'Frequency (Hz)', 20, 20000, 1), default: 800 }; +const PHASER_RATE = { ...numberRow('rate', 'Rate (Hz)', 0, 10, 0.01), default: 0.5 }; +const PHASER_DEPTH = { ...numberRow('depth', 'Depth (oct)', 0, 6, 0.1), default: 2 }; +const PHASER_FEEDBACK = { ...numberRow('feedback', 'Feedback', 0, 0.95, 0.01), default: 0.4 }; +const PHASER_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 1 }; +export const PHASER_ROWS = { + frequency: PHASER_FREQUENCY, + rate: PHASER_RATE, + depth: PHASER_DEPTH, + feedback: PHASER_FEEDBACK, + mix: PHASER_MIX, +}; +const PHASER_STAGE_ROW = { + key: 'stages', + label: 'Stages', + kind: 'enum' as const, + default: '4', + options: PHASER_STAGES.map((n) => ({ value: String(n), label: String(n) })), +}; + +export function phaserStagesOf(settings: PropertyBag): number { + const stages = Number(settings[PHASER_STAGE_ROW.key] ?? PHASER_STAGE_ROW.default); + return PHASER_STAGES.find((n) => n === stages) ?? 4; +} + +export const SoundFxPhaser: SoundFxNodeDef = { + type: 'SoundFxPhaser', + label: 'Phaser', + category: 'sound', + description: + 'Sweeps notches through the sound, the swirl of a phaser pedal. "stages" is how many allpass stages the sound passes: every two make one notch, so 4 has two. They sit around "frequency" and an LFO moves them up and down, "depth" octaves wide, "rate" times a second. "feedback" sharpens the notches into a ring. "mix" 0 leaves the sound as it is, 1 is half sound and half phased, where the notches are deepest; it never gets louder. In an instrument an Envelope can move frequency and mix while a note plays.', + inputs: [ + audioIn, + followingIn(PHASER_FREQUENCY), + numberIn(PHASER_RATE), + numberIn(PHASER_DEPTH), + numberIn(PHASER_FEEDBACK), + followingIn(PHASER_MIX), + ], + outputs: [audioOut], + params: [PHASER_STAGE_ROW], + emit: (sounds, amounts, settings) => { + const mix = held(amounts.mix, PHASER_MIX); + if (isNone(mix)) return arriving(sounds); + return withStages(arriving(sounds), [ + { + kind: 'phaser', + stages: phaserStagesOf(settings), + frequency: held(amounts.frequency, PHASER_FREQUENCY), + rate: held(amounts.rate, PHASER_RATE), + depth: held(amounts.depth, PHASER_DEPTH), + feedback: held(amounts.feedback, PHASER_FEEDBACK), + mix, + }, + ]); + }, +}; + +const FLANGER_OFFSET = { ...numberRow('offset', 'Offset (ms)', 0, 10, 0.01), default: 1 }; +const FLANGER_SWEEP = numberRow('sweep', 'Sweep (ms/s)', -125, 125, 0.1); +const FLANGER_RATE = { ...numberRow('rate', 'Rate (Hz)', 0, 10, 0.01), default: 0.25 }; +const FLANGER_DEPTH = { ...numberRow('depth', 'Depth (ms)', 0, 10, 0.01), default: 2 }; +const FLANGER_FEEDBACK = { ...numberRow('feedback', 'Feedback', 0, 0.95, 0.01), default: 0.5 }; +const FLANGER_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 1 }; +export const FLANGER_ROWS = { + offset: FLANGER_OFFSET, + sweep: FLANGER_SWEEP, + rate: FLANGER_RATE, + depth: FLANGER_DEPTH, + feedback: FLANGER_FEEDBACK, + mix: FLANGER_MIX, +}; + +export const SoundFxFlanger: SoundFxNodeDef = { + type: 'SoundFxFlanger', + label: 'Flanger', + category: 'sound', + description: + 'Blends the sound with a copy of itself a few milliseconds late, which combs out a row of frequencies: the jet sound of a flanger, and what sfxr calls its phaser. "offset" is how late the copy is when the sound starts. "sweep" moves that on by so many ms a second, for as long as the sound plays; one sweeping down passes 0 and comes back up, as in sfxr. An LFO adds up to "depth" ms to it, "rate" times a second; at depth 0 the copy only sweeps. The copy is never more than 20 ms late. "feedback" sends it back in, for a sharper, ringing comb. "mix" 0 leaves the sound as it is, 1 is half sound and half copy, where the comb is deepest; it never gets louder. In an instrument an Envelope can move offset and mix while a note plays.', + inputs: [ + audioIn, + followingIn(FLANGER_OFFSET), + numberIn(FLANGER_SWEEP), + numberIn(FLANGER_RATE), + numberIn(FLANGER_DEPTH), + numberIn(FLANGER_FEEDBACK), + followingIn(FLANGER_MIX), + ], + outputs: [audioOut], + params: [], + emit: (sounds, amounts) => { + const mix = held(amounts.mix, FLANGER_MIX); + if (isNone(mix)) return arriving(sounds); + return withStages(arriving(sounds), [ + { + kind: 'flanger', + offset: held(amounts.offset, FLANGER_OFFSET), + sweep: held(amounts.sweep, FLANGER_SWEEP), + rate: held(amounts.rate, FLANGER_RATE), + depth: held(amounts.depth, FLANGER_DEPTH), + feedback: held(amounts.feedback, FLANGER_FEEDBACK), + mix, + }, + ]); + }, +}; + /** Where damping 0 leaves the echoes and damping 1 takes them: evenly in octaves between. */ const UNDAMPED_HZ = 20000; const DAMPED_HZ = 500; @@ -551,6 +692,8 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [ SoundFxMix, SoundFxDelay, SoundFxBitcrusher, + SoundFxPhaser, + SoundFxFlanger, ]; /** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */ @@ -642,6 +785,38 @@ function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] { }); continue; } + if (stage.kind === 'phaser') { + const frequencyMod = modOf(stage.frequency, params); + const mixMod = modOf(stage.mix, params); + stages.push({ + kind: 'phaser', + stages: stage.stages, + frequency: amountOf(stage.frequency, params), + rate: amountOf(stage.rate, params), + depth: amountOf(stage.depth, params), + feedback: amountOf(stage.feedback, params), + mix: amountOf(stage.mix, params), + ...(frequencyMod && { frequencyMod }), + ...(mixMod && { mixMod }), + }); + continue; + } + if (stage.kind === 'flanger') { + const offsetMod = modOf(stage.offset, params); + const mixMod = modOf(stage.mix, params); + stages.push({ + kind: 'flanger', + offset: amountOf(stage.offset, params), + sweep: amountOf(stage.sweep, params), + rate: amountOf(stage.rate, params), + depth: amountOf(stage.depth, params), + feedback: amountOf(stage.feedback, params), + mix: amountOf(stage.mix, params), + ...(offsetMod && { offsetMod }), + ...(mixMod && { mixMod }), + }); + continue; + } const gain = amountOf(stage.gain, params); const mod = modOf(stage.gain, params); if (gain !== 0 || stage.gain.param !== undefined || mod) diff --git a/src/core/soundfx/phaser.ts b/src/core/soundfx/phaser.ts new file mode 100644 index 00000000..cf628ad3 --- /dev/null +++ b/src/core/soundfx/phaser.ts @@ -0,0 +1,138 @@ +/** + * The Phaser and the Flanger (#341) sample by sample, as their worklet runs them. Both blend the + * sound with a shifted copy of itself, so some frequencies cancel: `mix` 0 leaves the sound as it + * is, 1 is half and half, where the notches are deepest and the peaks stay at full. Each number may + * be one value for the whole block or one per sample, as an AudioParam hands it over. + */ + +/** How many allpass stages a Phaser can have: every two make one notch. */ +export const PHASER_STAGES = [2, 4, 6, 8, 12] as const; + +export type PhaserNumbers = Readonly< + Record<'frequency' | 'rate' | 'depth' | 'feedback' | 'mix', Float32Array> +>; +export type FlangerNumbers = Readonly< + Record<'offset' | 'sweep' | 'rate' | 'depth' | 'feedback' | 'mix', Float32Array> +>; + +/** Feedback past this would ring on for ever. */ +const MAX_FEEDBACK = 0.95; + +/** + * The classic phaser: a chain of first-order allpasses, each turning the phase by 90° at a + * frequency a sine LFO moves `depth` octaves wide around `frequency`. What leaves the chain is fed + * back into it by `feedback`, a sample later, which sharpens the notches. + */ +export class Phaser { + private readonly stages: number; + private lfo = 0; + /** Per channel: the last input and output of each stage, and what left the chain. */ + private inputs: Float64Array[] = []; + private outputs: Float64Array[] = []; + private last: number[] = []; + + constructor( + private readonly sampleRate: number, + stages: number, + ) { + this.stages = PHASER_STAGES.includes(stages as never) ? stages : 4; + } + + render(input: readonly Float32Array[], output: readonly Float32Array[], numbers: PhaserNumbers): void { + const length = output[0]?.length ?? 0; + const top = this.sampleRate * 0.45; + for (let i = 0; i < length; i++) { + const sweep = Math.sin(this.lfo); + this.lfo = + (this.lfo + (2 * Math.PI * Math.max(0, at(numbers.rate, i))) / this.sampleRate) % (2 * Math.PI); + const f = Math.max( + 10, + Math.min(top, at(numbers.frequency, i) * 2 ** ((at(numbers.depth, i) / 2) * sweep)), + ); + const t = Math.tan((Math.PI * f) / this.sampleRate); + const a = (t - 1) / (t + 1); + const feedback = clamp(at(numbers.feedback, i), -MAX_FEEDBACK, MAX_FEEDBACK); + const share = clamp(at(numbers.mix, i), 0, 1) / 2; + output.forEach((channel, c) => { + const dry = input[c]?.[i] ?? input[0]?.[i] ?? 0; + const xs = (this.inputs[c] ??= new Float64Array(this.stages)); + const ys = (this.outputs[c] ??= new Float64Array(this.stages)); + let v = dry + feedback * (this.last[c] ?? 0); + for (let s = 0; s < this.stages; s++) { + const y = a * v + xs[s]! - a * ys[s]!; + xs[s] = v; + ys[s] = y; + v = y; + } + this.last[c] = v; + channel[i] = dry + (v - dry) * share; + }); + } + } +} + +/** The longest a Flanger delays its copy, in ms; a delay past it stays there, as sfxr's does at its own end. */ +export const FLANGER_MAX_MS = 20; + +/** + * The flanger, which sfxr calls its phaser: the sound and a copy `offset` ms late. `sweep` moves + * that delay on by so many ms a second from the start of the sound, and an LFO adds up to `depth` ms + * to it, `rate` times a second. As in sfxr the delay is mirrored at 0, so one sweeping down passes + * through 0 and comes back up. `feedback` sends the copy back into the line. + */ +export class Flanger { + private readonly lines: Float32Array[] = []; + private write = 0; + private lfo = 0; + /** Ms the sweep has moved the delay by so far. */ + private swept = 0; + + constructor(private readonly sampleRate: number) {} + + private get size(): number { + return Math.ceil((FLANGER_MAX_MS / 1000) * this.sampleRate) + 2; + } + + render(input: readonly Float32Array[], output: readonly Float32Array[], numbers: FlangerNumbers): void { + const length = output[0]?.length ?? 0; + const size = this.size; + const msToSamples = this.sampleRate / 1000; + for (let i = 0; i < length; i++) { + const swing = (1 - Math.cos(this.lfo)) / 2; + this.lfo = + (this.lfo + (2 * Math.PI * Math.max(0, at(numbers.rate, i))) / this.sampleRate) % (2 * Math.PI); + const offset = at(numbers.offset, i); + const ms = Math.min( + FLANGER_MAX_MS, + Math.abs(offset + this.swept) + Math.max(0, at(numbers.depth, i)) * swing, + ); + // Held where it can no longer move the delay, so it never drifts off into the far numbers. + this.swept = clamp( + this.swept + at(numbers.sweep, i) / this.sampleRate, + -2 * FLANGER_MAX_MS, + 2 * FLANGER_MAX_MS, + ); + const delay = Math.min(size - 2, ms * msToSamples); + const whole = Math.floor(delay); + const part = delay - whole; + const feedback = clamp(at(numbers.feedback, i), 0, MAX_FEEDBACK); + const share = clamp(at(numbers.mix, i), 0, 1) / 2; + output.forEach((channel, c) => { + const dry = input[c]?.[i] ?? input[0]?.[i] ?? 0; + const line = (this.lines[c] ??= new Float32Array(size)); + // Read before this sample is written, so every echo of the feedback comes `delay` later. + const near = whole === 0 ? dry : line[(this.write - whole + size) % size]!; + const far = line[(this.write - whole - 1 + size) % size]!; + const late = near + (far - near) * part; + line[this.write] = dry + feedback * late; + channel[i] = dry + (late - dry) * share; + }); + this.write = (this.write + 1) % size; + } + } +} + +const clamp = (value: number, low: number, high: number) => + Math.max(low, Math.min(high, Number.isFinite(value) ? value : 0)); + +const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 927e2d0d..f33eccac 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -73,8 +73,12 @@ export function installDebugHook(app: Application, state: EditorState, editor: E playbacks: () => soundLibrary.playbacks(), /** Whether the audio clock is held by the pause of the toolbar or runs. */ clockState: () => soundLibrary.clockState(), - /** The nodes a stage of a voice becomes, in any context – an offline one renders what it does. */ - stageNodes: (context: BaseAudioContext, stage: FxStage) => stageNodes(context, stage), + /** + * The nodes a stage of a voice becomes, in any context – an offline one renders what it does. + * `at` is when its voice starts, which a Flanger's sweep counts from (#341). + */ + stageNodes: (context: BaseAudioContext, stage: FxStage, at?: number) => + stageNodes(context, stage, undefined, at), /** What drives the two shares of a Mix "Both at center", in any context (unstarted). */ bothAtCenter: (context: BaseAudioContext, mix: number, a: GainNode, b: GainNode) => bothAtCenter(context, mix, a, b), diff --git a/src/editor/instrument/InstrumentEditor.ts b/src/editor/instrument/InstrumentEditor.ts index 767d3818..5fa385bb 100644 --- a/src/editor/instrument/InstrumentEditor.ts +++ b/src/editor/instrument/InstrumentEditor.ts @@ -91,11 +91,12 @@ export class InstrumentEditor extends AssetTabEditor this.openPresetMenu(), }); this.randomizeButton = new Button('Randomize', { - tooltip: 'Draw every number of the Chips, Envelopes, Filters and Bitcrushers anew', + tooltip: 'Draw every number of the Chips, Envelopes, Filters, Bitcrushers, Phasers and Flangers anew', onClick: () => this.randomize(), }); this.mutateButton = new Button('Mutate', { - tooltip: 'Move some numbers of the Chips, Envelopes, Filters and Bitcrushers a little', + tooltip: + 'Move some numbers of the Chips, Envelopes, Filters, Bitcrushers, Phasers and Flangers a little', onClick: () => this.mutate(), }); this.auditionButton = new Button('Audition', { diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 95b54d12..4cf6656f 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -8,13 +8,16 @@ import { type FxCrush, type FxDelay, type FxEnvelope, + type FxFlanger, type FxMod, type FxPass, + type FxPhaser, fxRings, fxTailOf, type FxParams, type FxPlan, type FxStage, + type FxStagePlan, type FxVoice, } from '../core/soundfx/nodes'; import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope'; @@ -25,7 +28,7 @@ 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 { ChipOptions, PitchOptions } from './fxWorklet'; +import type { ChipOptions, FlangerOptions, PhaserOptions, PitchOptions } from './fxWorklet'; import fxWorkletUrl from './fxWorklet.ts?worker&url'; import { wake } from './wake'; @@ -1083,7 +1086,7 @@ export class SoundLibrary implements AudioSink { const under = wiring.sources.slice(first); for (const stage of chain.stages) { if (stage.kind === 'envelope') this.envelope(wiring, stage.envelope).sources.push(...under); - const made = stageNodes(context, stage, follow); + const made = stageNodes(context, stage, follow, wiring.at); tail.connect(made.input); tail = made.output; knobs.push(...made.knobs); @@ -1343,14 +1346,14 @@ export class SoundLibrary implements AudioSink { return this.failed.has(parseCutName(name).file); } - /** Loads the FX worklet (Pitch, Bitcrusher, Chip) into the context once; says whether it is there. */ + /** Loads the FX worklet (Pitch, Bitcrusher, Chip, Phaser, Flanger) into the context once; says whether it is there. */ private fxWorklet(): Promise { return (this.workletLoading ??= (async () => { try { await this.audio().audioWorklet.addModule(fxWorkletUrl); return (this.hasWorklet = true); } catch (error: unknown) { - console.error('[sounds] cannot load the FX worklet, a Pitch, Bitcrusher or Chip is not heard', error); + console.error('[sounds] cannot load the FX worklet, its effects and Chips are not heard', error); return (this.hasWorklet = false); } })()); @@ -1398,9 +1401,12 @@ interface LastVoice { envelopes: number; } -/** Whether a plan has a Pitch, a Bitcrusher or a Chip anywhere, and so needs the worklet. */ +/** The stages that run on the worklet. */ +const ON_WORKLET: ReadonlySet = new Set(['crush', 'phaser', 'flanger']); + +/** Whether a plan has a Pitch, a Chip or a stage of the worklet anywhere, and so needs the worklet. */ function needsWorklet(plan: FxPlan): boolean { - if (plan.stages.some((stage) => stage.kind === 'crush')) return true; + if (plan.stages.some((stage) => ON_WORKLET.has(stage.kind))) return true; return 'mix' in plan ? needsWorklet(plan.a) || needsWorklet(plan.b) : plan.pitch.length > 0 || !!plan.chip; } @@ -1468,9 +1474,15 @@ export function followCurve( /** * Exported for a check that renders a stage offline in a real browser (test hook). Without `follow` - * a number an Envelope moves stays at 0, and an Envelope's own stage silent. + * a number an Envelope moves stays at 0, and an Envelope's own stage silent. `at` is when the voice + * starts. */ -export function stageNodes(context: BaseAudioContext, stage: FxStage, follow?: Follow): StageNodes { +export function stageNodes( + context: BaseAudioContext, + stage: FxStage, + follow?: Follow, + at = context.currentTime, +): StageNodes { if (stage.kind === 'gain') { const gain = gainNode(context, stage.mod ? 0 : stage.gain); if (stage.mod) follow?.(stage.mod, gain.gain, true); @@ -1485,6 +1497,8 @@ 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); + if (stage.kind === 'phaser') return phaserNodes(context, stage, follow); + if (stage.kind === 'flanger') return flangerNodes(context, stage, at, follow); const filter = context.createBiquadFilter(); filter.type = stage.filter; filter.frequency.value = stage.frequency; @@ -1613,41 +1627,46 @@ function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes { }; } +/** A number of a stage on the worklet: what it holds, the Envelope moving it instead, how it moves. */ +interface WorkletNumber { + name: string; + value: number; + mod?: FxMod | undefined; + even: Knob['even']; +} + /** - * 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. + * A stage on the worklet (#322, #341), its numbers AudioParams of the processor. One an Envelope + * moves starts at 0 and leaves its knob to it. Where the worklet is not there the sound goes + * through as it is. */ -function crushNodes(context: BaseAudioContext, stage: FxCrush, follow?: Follow): StageNodes { +function workletNodes( + context: BaseAudioContext, + processor: string, + numbers: readonly WorkletNumber[], + follow?: Follow, + processorOptions?: object, +): 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, - }, + node = new AudioWorkletNode(context, processor, { + parameterData: Object.fromEntries(numbers.map((n) => [n.name, n.mod ? 0 : n.value])), + ...(processorOptions && { processorOptions }), }); } 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 knobs = numbers.map((n): Knob => ({ param: null, target: n.value, even: n.even })); + return { input: through, output: through, knobs }; } - 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); + const knobs = numbers.map(({ name, value, mod, even }): Knob => { + const param = node.parameters.get(name)!; + if (mod) follow?.(mod, param, even === 'ratio'); + return { param: mod ? null : param, target: value, even }; + }); 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' }, - ], + knobs, stop: () => { node.port.postMessage('stop'); node.disconnect(); @@ -1655,6 +1674,57 @@ function crushNodes(context: BaseAudioContext, stage: FxCrush, follow?: Follow): }; } +/** A Bitcrusher (#322): bits and mix move in what they hold, the rate in octaves. */ +function crushNodes(context: BaseAudioContext, stage: FxCrush, follow?: Follow): StageNodes { + return workletNodes( + context, + 'bit-crusher', + [ + { name: 'bits', value: stage.bits, mod: stage.bitsMod, even: 'value' }, + { name: 'rate', value: stage.rate, mod: stage.rateMod, even: 'ratio' }, + { name: 'mix', value: stage.mix, mod: stage.mixMod, even: 'value' }, + ], + follow, + ); +} + +/** A Phaser (#341): its frequency moves in octaves, the rest in what they hold. */ +function phaserNodes(context: BaseAudioContext, stage: FxPhaser, follow?: Follow): StageNodes { + const options: PhaserOptions = { stages: stage.stages }; + return workletNodes( + context, + 'phaser', + [ + { name: 'frequency', value: stage.frequency, mod: stage.frequencyMod, even: 'ratio' }, + { name: 'rate', value: stage.rate, even: 'value' }, + { name: 'depth', value: stage.depth, even: 'value' }, + { name: 'feedback', value: stage.feedback, even: 'value' }, + { name: 'mix', value: stage.mix, mod: stage.mixMod, even: 'value' }, + ], + follow, + options, + ); +} + +/** A Flanger (#341), whose sweep counts from `at`, when the voice starts. */ +function flangerNodes(context: BaseAudioContext, stage: FxFlanger, at: number, follow?: Follow): StageNodes { + const options: FlangerOptions = { startAt: at }; + return workletNodes( + context, + 'flanger', + [ + { name: 'offset', value: stage.offset, mod: stage.offsetMod, even: 'value' }, + { name: 'sweep', value: stage.sweep, even: 'value' }, + { name: 'rate', value: stage.rate, even: 'value' }, + { name: 'depth', value: stage.depth, even: 'value' }, + { name: 'feedback', value: stage.feedback, even: 'value' }, + { name: 'mix', value: stage.mix, mod: stage.mixMod, even: 'value' }, + ], + follow, + options, + ); +} + /** 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]); @@ -1761,6 +1831,10 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] { 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 === 'phaser') + into.push(stage.frequency, stage.rate, stage.depth, stage.feedback, stage.mix); + else if (stage.kind === 'flanger') + into.push(stage.offset, stage.sweep, stage.rate, stage.depth, stage.feedback, stage.mix); else if (stage.kind !== 'envelope') into.push(stage.gain); return into; } diff --git a/src/viewport/fxWorklet.ts b/src/viewport/fxWorklet.ts index 8c9acf73..0f6dd7b8 100644 --- a/src/viewport/fxWorklet.ts +++ b/src/viewport/fxWorklet.ts @@ -2,11 +2,12 @@ * 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`, a Chip - * (#329) a `ChipPlayer`. Loaded into the context once by the sound library; nothing on the page + * (#329) a `ChipPlayer`, a Phaser and a Flanger (#341) theirs. Loaded into the context once by the sound library; nothing on the page * imports it. */ import { type ChipShape, ChipPlayer } from '../core/soundfx/chip'; import { Crusher } from '../core/soundfx/crusher'; +import { Flanger, type FlangerNumbers, Phaser, type PhaserNumbers } from '../core/soundfx/phaser'; import { PitchPlayer } from '../core/soundfx/pitchPlayer'; /** What the audio thread has and the page's types do not. */ @@ -118,6 +119,96 @@ class CrushProcessor extends AudioWorkletProcessor { registerProcessor('bit-crusher', CrushProcessor); +export interface PhaserOptions { + stages: number; +} + +const PHASER_PARAMS = ['frequency', 'rate', 'depth', 'feedback', 'mix'] as const; + +/** A Phaser (#341) between two stages of a voice; it runs until the voice tells it to stop. */ +class PhaserProcessor extends AudioWorkletProcessor { + private readonly phaser: Phaser; + private stopped = false; + + constructor({ processorOptions }: { processorOptions: PhaserOptions }) { + super(); + this.phaser = new Phaser(sampleRate, processorOptions.stages); + this.port.onmessage = () => { + this.stopped = true; + }; + } + + process( + inputs: Float32Array[][], + outputs: Float32Array[][], + parameters: Record, + ): boolean { + if (this.stopped) return false; + this.phaser.render(inputs[0] ?? [], outputs[0]!, parameters as PhaserNumbers); + return true; + } + + static get parameterDescriptors() { + return PHASER_PARAMS.map((name) => ({ name, defaultValue: 0 })); + } +} + +registerProcessor('phaser', PhaserProcessor); + +export interface FlangerOptions { + /** The second of the audio clock its sound starts at: the sweep counts from there. */ + startAt: number; +} + +const FLANGER_PARAMS = ['offset', 'sweep', 'rate', 'depth', 'feedback', 'mix'] as const; + +/** A Flanger (#341) between two stages of a voice; its sweep starts with the voice. */ +class FlangerProcessor extends AudioWorkletProcessor { + private readonly flanger = new Flanger(sampleRate); + private stopped = false; + private startAt: number; + + constructor({ processorOptions }: { processorOptions: FlangerOptions }) { + super(); + this.startAt = processorOptions.startAt; + this.port.onmessage = () => { + this.stopped = true; + }; + } + + process( + inputs: Float32Array[][], + outputs: Float32Array[][], + parameters: Record, + ): boolean { + if (this.stopped) return false; + let input = inputs[0] ?? []; + let out = outputs[0]!; + let numbers = parameters as FlangerNumbers; + // Nothing is heard before the voice starts, and the sweep waits for its first sample. + if (this.startAt > currentTime) { + const wait = Math.round((this.startAt - currentTime) * sampleRate); + for (const channel of out) channel.fill(0, 0, wait); + if (wait >= (out[0]?.length ?? 0)) return true; + const from = (values: Float32Array) => (values.length > 1 ? values.subarray(wait) : values); + input = input.map(from); + out = out.map((channel) => channel.subarray(wait)); + numbers = Object.fromEntries( + FLANGER_PARAMS.map((name) => [name, from(numbers[name])]), + ) as FlangerNumbers; + this.startAt = 0; + } + this.flanger.render(input, out, numbers); + return true; + } + + static get parameterDescriptors() { + return FLANGER_PARAMS.map((name) => ({ name, defaultValue: 0 })); + } +} + +registerProcessor('flanger', FlangerProcessor); + export interface ChipOptions extends ChipShape { /** The second of the audio clock its first sample is heard at; before it, silence. */ startAt: number; diff --git a/tests/core/chip-presets.test.ts b/tests/core/chip-presets.test.ts index 9919362c..18931761 100644 --- a/tests/core/chip-presets.test.ts +++ b/tests/core/chip-presets.test.ts @@ -1,6 +1,7 @@ /** * The Preset menu, Randomize and Mutate of the Instrument tab (#329): sfxr's generators worked out - * into a Chip, an Envelope, a Filter and a Bitcrusher, and its randomizer and mutator on their numbers. + * into a Chip, an Envelope, a Filter, a Bitcrusher and a Flanger (#341), and its randomizer and mutator + * on their numbers. */ import { describe, expect, it } from 'vitest'; import { ReplaceGraphCommand } from '../../src/core/commands/graphCommands'; @@ -21,11 +22,13 @@ import type { InstrumentAsset } from '../../src/core/model/Asset'; import type { FlatDef } from '../../src/core/properties/PropertySchema'; import { CRUSH_ROWS, + FLANGER_ROWS, type FxChipPlan, type FxEnvelopePlan, FILTER_ROWS, type FxPlan, fxChainOf, + PHASER_ROWS, } from '../../src/core/soundfx/nodes'; import { makeProject } from '../helpers'; @@ -44,6 +47,8 @@ const ROWS: Record>> = { InstrumentEnvelope: ENVELOPE_ROWS, SoundFxFilter: FILTER_ROWS, SoundFxBitcrusher: CRUSH_ROWS, + SoundFxPhaser: PHASER_ROWS, + SoundFxFlanger: FLANGER_ROWS, }; function withinRows(node: GraphNode, keys: string[]): void { @@ -61,13 +66,16 @@ describe('a preset (#329)', () => { ...TONE_PRESETS.map((p) => p.value), ...EFFECT_PRESETS.map((p) => p.value), ]); - expect(TONE_PRESETS).toHaveLength(14); + expect(TONE_PRESETS).toHaveLength(15); }); it('of sfxr is a chain from every note through Chip, Envelope, Filter and Bitcrusher, ending by itself', () => { const sources = instrumentSourcesOf(makeProject().project); for (const { value } of EFFECT_PRESETS) { const graph = chipPresetGraph(value, seeded()); + // Only an explosion can have sfxr's phaser, as a Flanger after the Filter. + const flanged = graph.nodes.some((n) => n.type === 'SoundFxFlanger'); + expect(flanged && value !== 'explosion', value).toBe(false); expect( graph.nodes.map((n) => n.type), value, @@ -76,6 +84,7 @@ describe('a preset (#329)', () => { 'InstrumentChip', 'InstrumentEnvelope', 'SoundFxFilter', + ...(flanged ? ['SoundFxFlanger'] : []), 'SoundFxBitcrusher', 'InstrumentOutput', ]); @@ -86,6 +95,7 @@ describe('a preset (#329)', () => { expect('chip' in chain && chain.stages.map((s) => s.kind), value).toEqual([ 'envelope', 'pass', + ...(flanged ? ['flanger'] : []), 'crush', ]); const envelope = chain.stages[0]!; @@ -124,6 +134,31 @@ describe('a preset (#329)', () => { expect(byType(chipPresetGraph('explosion', seeded(3)), 'InstrumentChip').params.wave).toBe('noise'); }); + it("gives one explosion in two sfxr's phaser, as a Flanger with its offset and sweep (#341)", () => { + // Near 1: sfxr's offset 0.5991 and ramp -0.2997, a delay of 1.04 ms falling 11.2 ms a second. + expect( + byType( + chipPresetGraph('explosion', () => 0.999), + 'SoundFxFlanger', + ).params, + ).toEqual({ + offset: 1.04, + sweep: -11.2, + rate: 0, + depth: 0, + feedback: 0, + mix: 1, + }); + expect(chipPresetGraph('explosion', () => 0).nodes.some((n) => n.type === 'SoundFxFlanger')).toBe(false); + const random = seeded(4); + const flangers = Array.from({ length: 400 }, () => chipPresetGraph('explosion', random)).flatMap( + (graph) => graph.nodes.filter((n) => n.type === 'SoundFxFlanger'), + ); + expect(flangers.length).toBeGreaterThan(160); + expect(flangers.length).toBeLessThan(240); + for (const flanger of flangers) withinRows(flanger, Object.keys(FLANGER_ROWS)); + }); + it('is drawn anew every time', () => { const random = seeded(7); const a = byType(chipPresetGraph('laser', random), 'InstrumentChip').params; @@ -202,6 +237,9 @@ describe('an instrument preset', () => { arpeggio: 12, repeat: 0.08, }); + // Phased Pad: a saw through a Phaser of six stages (#341). + const pad = build(chipPresetGraph('phasedPad'), 60).layers[0]!; + expect(fxChainOf(pad).stages.at(-1)).toMatchObject({ kind: 'phaser', stages: 6, frequency: 600, mix: 1 }); }); it('Drum Kit: kick, snare and hi-hats on their General MIDI keys, each ending by itself', () => { @@ -287,6 +325,32 @@ describe('Randomize and Mutate (#329)', () => { expect(mutatedParams(graph, () => 0.1)).toEqual([]); }); + it('draw and move the numbers of a Phaser and a Flanger too, but not their mix or stages (#341)', () => { + const graph = chipPresetGraph('explosion', () => 0.999); + const pad = chipPresetGraph('phasedPad'); + graph.nodes.push(byType(pad, 'SoundFxPhaser')); + byType(graph, 'SoundFxFlanger').params.mix = 0.4; + const changes = randomizedParams(graph, seeded(13)); + const keysOf = (type: string) => + changes.filter((c) => c.node === byType(graph, type).id).map((c) => c.key); + expect(keysOf('SoundFxPhaser')).toEqual(['frequency', 'rate', 'depth', 'feedback']); + expect(keysOf('SoundFxFlanger')).toEqual(['offset', 'sweep', 'rate', 'depth', 'feedback']); + apply(graph, changes); + withinRows(byType(graph, 'SoundFxPhaser'), Object.keys(PHASER_ROWS)); + withinRows(byType(graph, 'SoundFxFlanger'), Object.keys(FLANGER_ROWS)); + expect(byType(graph, 'SoundFxPhaser').params).toMatchObject({ stages: '6', mix: 1 }); + expect(byType(graph, 'SoundFxFlanger').params.mix).toBe(0.4); + // Mutate leaves the Flanger's LFO off and moves its offset and sweep, and the Phaser's numbers. + const fresh = chipPresetGraph('explosion', () => 0.999); + fresh.nodes.push(byType(chipPresetGraph('phasedPad'), 'SoundFxPhaser')); + const moved = (type: string) => + mutatedParams(fresh, () => 0.9) + .filter((c) => c.node === byType(fresh, type).id) + .map((c) => c.key); + expect(moved('SoundFxFlanger')).toEqual(['offset', 'sweep']); + expect(moved('SoundFxPhaser')).toEqual(['frequency', 'rate', 'depth', 'feedback']); + }); + it('find nothing to change in an instrument of Samples', () => { expect(hasChipNumbers(starterInstrumentGraph())).toBe(false); expect(randomizedParams(starterInstrumentGraph())).toEqual([]); diff --git a/tests/core/phaser.test.ts b/tests/core/phaser.test.ts new file mode 100644 index 00000000..df214c77 --- /dev/null +++ b/tests/core/phaser.test.ts @@ -0,0 +1,135 @@ +/** The Phaser's and the Flanger's samples (#341), as their worklet makes them. */ +import { describe, expect, it } from 'vitest'; +import { Flanger, type FlangerNumbers, Phaser, type PhaserNumbers } from '../../src/core/soundfx/phaser'; + +const RATE = 48000; +const one = (value: number) => new Float32Array([value]); + +function flange( + input: Float32Array, + numbers: Partial>, + flanger = new Flanger(RATE), +) { + const out = new Float32Array(input.length); + const all = { offset: 0, sweep: 0, rate: 0, depth: 0, feedback: 0, mix: 1, ...numbers }; + flanger.render( + [input], + [out], + Object.fromEntries(Object.entries(all).map(([k, v]) => [k, one(v)])) as FlangerNumbers, + ); + return out; +} + +function phase(input: Float32Array, numbers: Partial>, stages = 4) { + const out = new Float32Array(input.length); + const all = { frequency: 1000, rate: 0, depth: 0, feedback: 0, mix: 1, ...numbers }; + new Phaser(RATE, stages).render( + [input], + [out], + Object.fromEntries(Object.entries(all).map(([k, v]) => [k, one(v)])) as PhaserNumbers, + ); + return out; +} + +const impulse = (length: number, at = 0) => { + const samples = new Float32Array(length); + samples[at] = 1; + return samples; +}; + +const sine = (hz: number, seconds: number) => + Float32Array.from({ length: Math.round(seconds * RATE) }, (_, i) => + Math.sin((2 * Math.PI * hz * i) / RATE), + ); + +const rms = (samples: Float32Array, from = 0, to = samples.length) => { + let sum = 0; + for (let i = from; i < to; i++) sum += samples[i]! ** 2; + return Math.sqrt(sum / (to - from)); +}; + +/** Where an impulse comes out again, after its own sample: the samples with anything in them. */ +const heard = (samples: Float32Array, from = 1) => + [...samples].flatMap((v, i) => (i >= from && Math.abs(v) > 1e-6 ? [[i, +v.toFixed(4)]] : [])); + +describe('the Flanger (#341)', () => { + it('adds the sound offset ms late, half and half at mix 1, and leaves it be at mix 0', () => { + const out = flange(impulse(200), { offset: 1 }); + expect(out[0]).toBeCloseTo(0.5); + expect(heard(out)).toEqual([[48, 0.5]]); + expect([...flange(impulse(200), { offset: 1, mix: 0 })]).toEqual([...impulse(200)]); + }); + + it('feeds the copy back, every echo one offset after the last', () => { + expect(heard(flange(impulse(200), { offset: 1, feedback: 0.5 }))).toEqual([ + [48, 0.5], + [96, 0.25], + [144, 0.125], + [192, 0.0625], + ]); + }); + + it('sweeps the delay on from the start of the sound, and mirrors it at 0 as sfxr does', () => { + // 100 ms/s from 0: after 0.1 s the copy is 10 ms late and still moving, so an impulse then + // comes back at j = 480 + 0.1 j, 533.3 samples on. + const flanger = new Flanger(RATE); + flange(new Float32Array(4800), { sweep: 100 }, flanger); + const later = heard(flange(impulse(800), { sweep: 100 }, flanger)); + expect(later.map(([i]) => i)).toEqual([533, 534]); + // Read 0.9 samples a sample while the line grows: the copy is drawn out, by 1/0.9 on average. + const drawn = later.reduce((sum, [, v]) => sum + v!, 0); + expect(drawn).toBeGreaterThan(0.5); + expect(drawn).toBeLessThan(0.6); + // From 1 ms down at 100 ms/s: 20 ms in, the delay has passed 0 and is 1 ms again, growing. + const down = new Flanger(RATE); + flange(new Float32Array(960), { offset: 1, sweep: -100 }, down); + const back = flange(impulse(200), { offset: 1, sweep: -100 }, down); + expect(back[0]).toBeCloseTo(0.5); + expect(heard(back).map(([i]) => i)).toEqual([53, 54]); + }); + + it('swings the delay up to depth ms with its LFO, and never past 20 ms', () => { + // A quarter of a 10 Hz swing is 25 ms in: half way up from 1 ms towards 1 + 4 ms. + const flanger = new Flanger(RATE); + flange(new Float32Array(1200), { offset: 1, rate: 10, depth: 4 }, flanger); + const out = flange(impulse(400), { offset: 1, rate: 10, depth: 4 }, flanger); + const at = heard(out)[0]![0]!; + expect(at).toBeGreaterThan(48 * 2.5); + expect(at).toBeLessThan(48 * 3.6); + const far = new Flanger(RATE); + flange(new Float32Array(48000), { sweep: 125 }, far); + expect(heard(flange(impulse(2000), { sweep: 125 }, far))).toEqual([[960, 0.5]]); + }); +}); + +describe('the Phaser (#341)', () => { + it('cuts a notch at its frequency with two stages, where four make a peak', () => { + const tone = sine(1000, 0.2); + expect(rms(phase(tone, {}, 2), 4800)).toBeLessThan(0.01); + expect(rms(phase(tone, {}, 4), 4800)).toBeCloseTo(Math.SQRT1_2, 2); + // Far below the notch the two stages hardly turn the phase: the sound stays. + expect(rms(phase(sine(50, 0.4), {}, 2), 4800)).toBeGreaterThan(0.69); + }); + + it('leaves the sound be at mix 0', () => { + const tone = sine(1000, 0.05); + expect([...phase(tone, { mix: 0 }, 2)]).toEqual([...tone]); + }); + + it('moves the notch with its LFO, depth octaves wide', () => { + // At 1 Hz over 4 octaves the notch passes 1 kHz twice a second and is two octaves off between, + // where two stages turn it by 2 * 2 * atan(1/4): cos(28°) of it is left, 0.62 of the RMS. + const out = phase(sine(1000, 1), { rate: 1, depth: 4 }, 2); + const windows = Array.from({ length: 40 }, (_, w) => rms(out, w * 1200, (w + 1) * 1200)); + expect(Math.min(...windows)).toBeLessThan(0.15); + expect(Math.max(...windows)).toBeGreaterThan(0.58); + expect(Math.max(...windows)).toBeLessThan(0.63); + }); + + it('stays bounded at the most feedback there is', () => { + const noise = Float32Array.from({ length: RATE }, (_, i) => Math.sin(i * 12.9898) % 1); + const out = phase(noise, { feedback: 0.95, rate: 2, depth: 4 }, 12); + expect(Math.max(...out.map(Math.abs))).toBeLessThan(20); + expect(out.every(Number.isFinite)).toBe(true); + }); +}); diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index 0e179f2c..e8cf988b 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -122,6 +122,8 @@ describe('the sound FX asset', () => { 'SoundFxMix', 'SoundFxDelay', 'SoundFxBitcrusher', + 'SoundFxPhaser', + 'SoundFxFlanger', ]); f.edit.addNode('SoundFxInput'); f.edit.addNode('SoundFxOutput'); @@ -133,6 +135,8 @@ describe('the sound FX asset', () => { 'SoundFxMix', 'SoundFxDelay', 'SoundFxBitcrusher', + 'SoundFxPhaser', + 'SoundFxFlanger', ]); const behavior = makeBehavior(f, 'Logic'); @@ -365,6 +369,36 @@ describe('a Bitcrusher in a sound FX graph (#322)', () => { }); }); +describe('a Phaser and a Flanger in a sound FX graph (#341)', () => { + const at = (type: string, params?: Record) => + fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages; + + it('play at their defaults and hold their numbers to their rows', () => { + expect(at('SoundFxPhaser')).toEqual([ + { kind: 'phaser', stages: 4, frequency: 800, rate: 0.5, depth: 2, feedback: 0.4, mix: 1 }, + ]); + expect(at('SoundFxPhaser', { stages: '12', frequency: 5, feedback: 2, depth: -1 })).toMatchObject([ + { stages: 12, frequency: 20, feedback: 0.95, depth: 0 }, + ]); + expect(at('SoundFxPhaser', { stages: '5' })).toMatchObject([{ stages: 4 }]); + expect(at('SoundFxFlanger')).toEqual([ + { kind: 'flanger', offset: 1, sweep: 0, rate: 0.25, depth: 2, feedback: 0.5, mix: 1 }, + ]); + expect(at('SoundFxFlanger', { offset: 50, sweep: -500, depth: 99 })).toMatchObject([ + { offset: 10, sweep: -125, depth: 10 }, + ]); + }); + + it('are left out at a typed-in mix of 0, and kept for a mix from a parameter', () => { + expect(at('SoundFxPhaser', { mix: 0 })).toEqual([]); + expect(at('SoundFxFlanger', { mix: 0 })).toEqual([]); + const f = withParam('SoundFxFlanger', 'mix', [{ key: 'wet', kind: 'number', default: 0 }]); + const plan = soundFxBuildFor(f.fx).compiled!; + expect(fxChainOf(plan).stages).toMatchObject([{ kind: 'flanger', mix: 0 }]); + expect(fxChainOf(plan, { wet: 0.3 }).stages).toMatchObject([{ kind: 'flanger', mix: 0.3 }]); + }); +}); + describe('the tail of a voice (#324)', () => { const echo = (time: number, feedback: number, mix: number): FxStage => ({ kind: 'delay', diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index 4394bbdc..e3eed80a 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -311,7 +311,14 @@ beforeEach(() => { const parameters = new Map( Object.entries(options.parameterData).map(([key, value]) => [key, param(value)] as const), ); - const kind = name === 'bit-crusher' ? 'crush' : name === 'chip-player' ? 'chip' : 'pitch'; + const kind = + name === 'bit-crusher' + ? 'crush' + : name === 'chip-player' + ? 'chip' + : name === 'phaser' || name === 'flanger' + ? name + : 'pitch'; const node: FakeWorklet = wired(kind, { options, pitch: parameters.get('pitch') ?? param(0), @@ -562,6 +569,54 @@ describe('the sound library', () => { expect(node.parameters.get('mix')!.ramps).toEqual([]); }); + it('sends a voice through a Phaser and a Flanger on the worklet, the sweep from its start (#341)', async () => { + const library = new SoundLibrary(); + const amounts = (values: Record) => + Object.fromEntries(Object.entries(values).map(([key, value]) => [key, { value }])); + const plan: FxPlan = { + pitch: [], + stages: [ + { + kind: 'phaser', + stages: 6, + ...amounts({ rate: 0.5, depth: 2, feedback: 0.4, mix: 1 }), + frequency: { value: 800, param: 'f' }, + } as FxStagePlan, + { + kind: 'flanger', + ...amounts({ sweep: -3, rate: 0, depth: 0, feedback: 0, mix: 1 }), + offset: { value: 1.5, param: 'o' }, + } as FxStagePlan, + ], + }; + const voice = await play(library, FILE, {}, { plan, params: {} }); + expect(wires).toEqual(['source > phaser', 'phaser > flanger', 'flanger > gain', 'gain > destination']); + const [phaser, flanger] = worklets as [FakeWorklet, FakeWorklet]; + expect(phaser.options.parameterData).toEqual({ + frequency: 800, + rate: 0.5, + depth: 2, + feedback: 0.4, + mix: 1, + }); + expect(phaser.options.processorOptions).toEqual({ stages: 6 }); + expect(flanger.options.parameterData).toEqual({ + offset: 1.5, + sweep: -3, + rate: 0, + depth: 0, + feedback: 0, + mix: 1, + }); + expect(flanger.options.processorOptions).toEqual({ startAt: library.stats().last!.at }); + expect(library.tune(voice, { f: 1600, o: 3 }, 0.5)).toBe(true); + expect(phaser.parameters.get('frequency')!.ramps).toEqual([['exponential', 1600, 0.5]]); + expect(flanger.parameters.get('offset')!.ramps).toEqual([['linear', 3, 0.5]]); + library.stopVoice(voice); + clockTo(); + expect([phaser.posted, flanger.posted]).toEqual([['stop'], ['stop']]); + }); + it('without the worklet a Bitcrusher lets the sound through as it is (#322)', async () => { loadModule = () => Promise.reject(new Error('no worklets here')); vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 875cd483..96138b08 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -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}` &&