diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index d89c4c2f..888a3244 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -133,6 +133,8 @@ export interface NodeDef { * input has an edge, which a value typed into it then does not count against. */ idleParams?(params: PropertyBag, fed?: (key: string) => boolean): string[]; + /** Whether the node does nothing at this value set and is drawn faded, like a bypassed effect (#354). */ + dimmed?(params: PropertyBag): boolean; /** * Which side the ports this node declares for itself (`GraphNode.ports`) appear on. Setting it * is what gives a node the port editor in the inspector. `both`: every port is an output, and an diff --git a/src/core/instrument/chipPresets.ts b/src/core/instrument/chipPresets.ts index 4695b583..35003475 100644 --- a/src/core/instrument/chipPresets.ts +++ b/src/core/instrument/chipPresets.ts @@ -503,8 +503,9 @@ const pass = (filter: FxPassFilter, cutoff: number, resonance = 0) => 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; +/** Below 0.5 a Delay's wet keeps the sound in full and has the echoes at twice it (#354). */ +const echo = (time: number, feedback: number, wet: number, pingPong = false) => + [SoundFxDelay.type, { time, sync: 'beats', feedback, damping: 0.4, mix: wet, pingPong }] as const; /** Two ways into one Mix, a column right of the further one; `b` weighs `share`. */ function mixed(s: Sketch, a: GraphNode, b: GraphNode, share: number, curve = 'crossfade'): GraphNode { @@ -615,7 +616,7 @@ const TONES: Record, (s: Sketch, notes: GraphNode s.line( chipOn(s, notes, 0, 'square', { duty: 0.25 }), envelope(0.005, 0.15, 0.5, 0.1), - echo(0.75, 0.35, 0.35, true), + echo(0.75, 0.35, 0.175, true), ), // The note and its octave by turns, 25 times a second: how a console played a chord. octaveArp: (s, notes) => @@ -666,7 +667,7 @@ const TONES: Record, (s: Sketch, notes: GraphNode bell: (s, notes) => { const low = s.line(chipOn(s, notes, 0, 'sine'), envelope(0.001, 2, 0, 0.4)); const high = s.line(chipOn(s, notes, 1, 'sine'), pitch(17.6), envelope(0.001, 0.6, 0, 0.2)); - return s.line(mixed(s, low, high, 0.35), echo(0.5, 0.3, 0.25)); + return s.line(mixed(s, low, high, 0.35), echo(0.5, 0.3, 0.125)); }, // Struck, bright at once and dull soon after, gone in a moment however long it is held. pluck: (s, notes) => diff --git a/src/core/instrument/compile.ts b/src/core/instrument/compile.ts index 18a42197..5f56999d 100644 --- a/src/core/instrument/compile.ts +++ b/src/core/instrument/compile.ts @@ -361,18 +361,31 @@ function throughFxWith(fx: FxPlan, sound: FxPlan, bind: (amount: FxAmount) => Fx b: throughFxWith(fx.b, sound, bind), stages, }; - const pitched = fx.pitch.reduce((plan, semitones) => withPitch(plan, bind(semitones)), sound); + const pitched = fx.pitch.reduce((plan, semitones) => withPitch(plan, boundAmount(semitones, bind)), sound); return withStages(pitched, stages); } /** Every number of a stage, bound; an Envelope's own numbers are the instrument's and stay. */ function bindStage(stage: FxStagePlan, bind: (amount: FxAmount) => FxAmount): FxStagePlan { if (stage.kind === 'envelope') return stage; + if (stage.kind === 'wet') + return { + ...stage, + wet: boundAmount(stage.wet, bind), + stages: stage.stages.map((inner) => bindStage(inner, bind)), + }; const bound: Record = {}; - for (const [key, value] of Object.entries(stage)) bound[key] = isAmount(value) ? bind(value) : value; + for (const [key, value] of Object.entries(stage)) + bound[key] = isAmount(value) ? boundAmount(value, bind) : value; return bound as FxStagePlan; } +/** An amount bound, and the share it is taken by (#354). */ +function boundAmount(amount: FxAmount, bind: (amount: FxAmount) => FxAmount): FxAmount { + const done = bind(amount); + return amount.times ? { ...done, times: boundAmount(amount.times, bind) } : done; +} + const isAmount = (value: unknown): value is FxAmount => typeof value === 'object' && value !== null && typeof (value as FxAmount).value === 'number'; diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index d6663755..fce3efd3 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -46,7 +46,19 @@ export type FxStage = | FxSaturate | FxLfo<'tremolo'> | FxLfo<'vibrato'> - | FxReverb; + | FxReverb + | FxWet; + +/** + * What one node adds, blended with what arrives at it (#354): `wet` of its stages and the rest of the + * sound as it came in, held back as far as the stages make it late. + */ +export interface FxWet { + kind: 'wet'; + wet: number; + wetMod?: FxMod; + stages: readonly FxStage[]; +} /** * A Reverb (#328): the decay and damping its impulse response is made with, or the file of a sound @@ -262,7 +274,8 @@ export const DRY: FxChain = { pitch: 0, shifted: false, stages: [] }; * A number of the graph as the compiler knows it: typed in, or a parameter of the Input that a * `Play sound` may hand a value for (#311). `value` is what it is otherwise, and a parameter is held * to the row it ends in only once its value is known. With `mod` an Envelope moves it (#335), and - * `value` is where it rests. + * `value` is where it rests. `times` is a share it is taken by, as a Pitch's Wet takes its semitones + * (#354). */ export interface FxAmount { value: number; @@ -270,6 +283,7 @@ export interface FxAmount { min?: number; max?: number; mod?: FxModPlan; + times?: FxAmount; } export type FxStagePlan = @@ -305,7 +319,8 @@ export type FxStagePlan = >) | { kind: 'tremolo'; shape: LfoShape; rate: FxAmount; depth: FxAmount } | { kind: 'vibrato'; shape: LfoShape; rate: FxAmount; depth: FxAmount } - | ({ kind: 'reverb'; impulse?: string } & Record<'decay' | 'damping' | 'predelay' | 'mix', FxAmount>); + | ({ kind: 'reverb'; impulse?: string } & Record<'decay' | 'damping' | 'predelay' | 'mix', FxAmount>) + | { kind: 'wet'; wet: FxAmount; stages: readonly FxStagePlan[] }; /** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */ export type FxPlan = @@ -403,6 +418,53 @@ export function withStages(plan: FxPlan, stages: readonly FxStagePlan[]): FxPlan return stages.length === 0 ? plan : { ...plan, stages: [...plan.stages, ...stages] }; } +const BYPASS = { key: 'bypass', label: 'Bypass', kind: 'boolean' as const, default: false }; +const WET = { ...numberRow('wet', 'Wet', 0, 1, 0.01), default: 1 }; + +/** Whether a node is bypassed (#354): the graph is compiled as if it were not there. */ +export const isBypassed = (settings: PropertyBag): boolean => settings[BYPASS.key] === true; + +/** + * An effect with a Bypass and a Wet (#354). Bypassed, it hands on what arrives. Its Wet (`blend`) + * blends what it adds with what arrives, after any ports it derives; one with a wet of its own + * (`own`) has it already. + */ +function effect(def: SoundFxNodeDef, wet: 'blend' | 'own'): SoundFxNodeDef { + const wetIn = def.inputs.some((port) => port.followsEnvelope) ? followingIn(WET) : numberIn(WET); + const derive = def.derivePorts; + const ports: Partial> = + wet === 'own' + ? {} + : derive + ? { + derivePorts: (source) => { + const grown = derive(source); + return { ...grown, inputs: [...(grown.inputs ?? []), wetIn] }; + }, + } + : { inputs: [...def.inputs, wetIn] }; + return { + ...def, + ...ports, + params: [BYPASS, ...def.params], + hiddenParams: (params) => (isBypassed(params) ? [] : [BYPASS.key]), + dimmed: isBypassed, + emit: (sounds, amounts, settings, project) => { + if (isBypassed(settings)) return arriving(sounds); + const made = def.emit(sounds, amounts, settings, project); + return wet === 'own' ? made : blended(arriving(sounds), made, held(amounts.wet, WET)); + }, + }; +} + +/** The stages a node added to `before`, blended with it at `wet`; none at a typed-in 0, all at 1. */ +function blended(before: FxPlan, after: FxPlan, wet: FxAmount): FxPlan { + const own = after.stages.slice(before.stages.length); + if (own.length === 0 || (isFixed(wet) && wet.value >= 1)) return after; + if (isFixed(wet) && wet.value <= 0) return before; + return withStages(before, [{ kind: 'wet', wet, stages: own }]); +} + /** A pitch is made where the sound is read, so behind a Mix it goes to both sounds. */ export function withPitch(plan: FxPlan, semitones: FxAmount): FxPlan { if ('mix' in plan) return { ...plan, a: withPitch(plan.a, semitones), b: withPitch(plan.b, semitones) }; @@ -442,37 +504,56 @@ export const SoundFxOutput: SoundFxNodeDef = { const GAIN = numberRow('gain', 'Gain (dB)', -120, 24, 0.5); -export const SoundFxGain: SoundFxNodeDef = { - type: 'SoundFxGain', - label: 'Gain', - category: 'sound', - description: - 'Makes the sound louder or quieter, in decibels: 0 leaves it as it is, -6 is about half as loud, +6 twice. Above 0 it can clip.', - inputs: [audioIn, followingIn(GAIN)], - outputs: [audioOut], - params: [], - emit: (sounds, amounts) => { - const db = held(amounts.gain, GAIN); - return withStages(arriving(sounds), isNone(db) ? [] : [{ kind: 'gain', db }]); +/** How an effect with a Bypass and a Wet says so, at the end of its description (#354). */ +const BLENDS = + ' "Bypass" leaves the node out as if it were not there. "wet" blends what it does with the sound as it came in: 1 is all of it, 0 none, 0.5 half of each.'; + +export const SoundFxGain: SoundFxNodeDef = effect( + { + type: 'SoundFxGain', + label: 'Gain', + category: 'sound', + description: + 'Makes the sound louder or quieter, in decibels: 0 leaves it as it is, -6 is about half as loud, +6 twice. Above 0 it can clip.' + + BLENDS, + inputs: [audioIn, followingIn(GAIN)], + outputs: [audioOut], + params: [], + emit: (sounds, amounts) => { + const db = held(amounts.gain, GAIN); + return withStages(arriving(sounds), isNone(db) ? [] : [{ kind: 'gain', db }]); + }, }, -}; + 'blend', +); const SEMITONES = numberRow('semitones', 'Semitones', -64, 64, 0.1); -export const SoundFxPitch: SoundFxNodeDef = { - type: 'SoundFxPitch', - label: 'Pitch', - category: 'sound', - description: - 'Plays the sound higher or lower, in semitones: 12 is an octave up, 0.5 a quarter tone. The sound keeps its length, also when the pitch changes while it plays. The pitch is made where the sound is read, so an effect before the Pitch acts as if it came after it. On a Chip it moves the frequency of the Chip itself.', - inputs: [audioIn, followingIn(SEMITONES)], - outputs: [audioOut], - params: [], - emit: (sounds, amounts) => { - const semitones = held(amounts.semitones, SEMITONES); - return isNone(semitones) ? arriving(sounds) : withPitch(arriving(sounds), semitones); +/** The semitones of a Pitch taken by its Wet (#354): a typed-in share is worked in at once. */ +function pitchTimes(semitones: FxAmount, wet: FxAmount): FxAmount { + if (!isFixed(wet)) return { ...semitones, times: wet }; + if (wet.value === 1) return semitones; + if (isFixed(semitones)) return { value: semitones.value * wet.value }; + return { ...semitones, times: wet }; +} + +export const SoundFxPitch: SoundFxNodeDef = effect( + { + type: 'SoundFxPitch', + label: 'Pitch', + category: 'sound', + description: + 'Plays the sound higher or lower, in semitones: 12 is an octave up, 0.5 a quarter tone. The sound keeps its length, also when the pitch changes while it plays. The pitch is made where the sound is read, so an effect before the Pitch acts as if it came after it. On a Chip it moves the frequency of the Chip itself. "Bypass" leaves the node out as if it were not there. "wet" takes the semitones from 0 up to all of them: at 0.5 a Pitch of 12 plays 6 semitones higher.', + inputs: [audioIn, followingIn(SEMITONES), numberIn(WET)], + outputs: [audioOut], + params: [], + emit: (sounds, amounts) => { + const semitones = pitchTimes(held(amounts.semitones, SEMITONES), held(amounts.wet, WET)); + return isNone(semitones) ? arriving(sounds) : withPitch(arriving(sounds), semitones); + }, }, -}; + 'own', +); /** The five bands of the EQ: a shelf at either end and three peaks between them. */ export const EQ_BANDS: readonly { key: string; label: string; filter: FxFilter; frequency: number }[] = [ @@ -487,25 +568,29 @@ export const EQ_BANDS: readonly { key: string; label: string; filter: FxFilter; const PEAK_Q = 1; const EQ_ROWS = EQ_BANDS.map((band) => numberRow(band.key, band.label, -60, 24, 0.5)); -export const SoundFxEq: SoundFxNodeDef = { - type: 'SoundFxEq', - label: '5-Band-EQ', - category: 'sound', - description: - 'A five-band equalizer: raises or lowers the lows (80 Hz), low mids (250 Hz), mids (1 kHz), high mids (4 kHz) and highs (12 kHz), each in decibels. A band at 0 does nothing.', - inputs: [audioIn, ...EQ_ROWS.map(followingIn)], - outputs: [audioOut], - params: [], - emit: (sounds, amounts) => { - const stages: FxStagePlan[] = []; - EQ_BANDS.forEach((band, i) => { - const gain = held(amounts[band.key], EQ_ROWS[i]!); - if (!isNone(gain)) - stages.push({ kind: 'filter', filter: band.filter, frequency: band.frequency, gain, q: PEAK_Q }); - }); - return withStages(arriving(sounds), stages); +export const SoundFxEq: SoundFxNodeDef = effect( + { + type: 'SoundFxEq', + label: '5-Band-EQ', + category: 'sound', + description: + 'A five-band equalizer: raises or lowers the lows (80 Hz), low mids (250 Hz), mids (1 kHz), high mids (4 kHz) and highs (12 kHz), each in decibels. A band at 0 does nothing.' + + BLENDS, + inputs: [audioIn, ...EQ_ROWS.map(followingIn)], + outputs: [audioOut], + params: [], + emit: (sounds, amounts) => { + const stages: FxStagePlan[] = []; + EQ_BANDS.forEach((band, i) => { + const gain = held(amounts[band.key], EQ_ROWS[i]!); + if (!isNone(gain)) + stages.push({ kind: 'filter', filter: band.filter, frequency: band.frequency, gain, q: PEAK_Q }); + }); + return withStages(arriving(sounds), stages); + }, }, -}; + 'blend', +); /** The Q of a filter with no bump at its cutoff, as a factor. */ export const FLAT_Q = Math.SQRT1_2; @@ -563,29 +648,35 @@ export function passStagesOf(settings: PropertyBag): number { return slope === '48' ? 4 : slope === '24' ? 2 : 1; } -export const SoundFxFilter: SoundFxNodeDef = { - type: 'SoundFxFilter', - label: 'Filter', - category: 'sound', - description: - 'Takes away what is above "frequency" (low-pass), what is below it (high-pass), all but a band around it (band-pass) or just that band (notch). A low or high shelf raises or lowers everything below or above it by "gain" in dB, a peak a band around it. An all-pass leaves every frequency as loud as it was and only turns its phase. "Q" from 0.1 to 30 says how sharp it is: at 0.707 a low- or high-pass has no bump, higher it rings at the frequency, and a band, a notch or a peak gets narrower. "Slope" makes a low-pass, high-pass, band-pass or notch steeper: 12 dB an octave is one filter, 24 two and 48 four in a row, and only the last of them has the Q. All three numbers can change while the sound plays, the frequency gliding in octaves, and in an instrument an Envelope can move them, for a filter sweep.', - inputs: [audioIn, followingIn(CUTOFF), followingIn(Q)], - outputs: [audioOut], - params: [PASS, SLOPE], - idleParams: (params) => (SLOPED.has(passOf(params)) ? [] : [SLOPE.key]), - derivePorts: ({ node }) => (GAINED.has(passOf(node.params)) ? { inputs: [followingIn(FILTER_GAIN)] } : {}), - emit: (sounds, amounts, settings) => - withStages(arriving(sounds), [ - { - kind: 'pass', - filter: passOf(settings), - stages: passStagesOf(settings), - cutoff: held(amounts.cutoff, CUTOFF), - q: held(amounts.q, Q), - gain: GAINED.has(passOf(settings)) ? held(amounts.gain, FILTER_GAIN) : { value: 0 }, - }, - ]), -}; +export const SoundFxFilter: SoundFxNodeDef = effect( + { + type: 'SoundFxFilter', + label: 'Filter', + category: 'sound', + description: + 'Takes away what is above "frequency" (low-pass), what is below it (high-pass), all but a band around it (band-pass) or just that band (notch). A low or high shelf raises or lowers everything below or above it by "gain" in dB, a peak a band around it. An all-pass leaves every frequency as loud as it was and only turns its phase. "Q" from 0.1 to 30 says how sharp it is: at 0.707 a low- or high-pass has no bump, higher it rings at the frequency, and a band, a notch or a peak gets narrower. "Slope" makes a low-pass, high-pass, band-pass or notch steeper: 12 dB an octave is one filter, 24 two and 48 four in a row, and only the last of them has the Q. All three numbers can change while the sound plays, the frequency gliding in octaves, and in an instrument an Envelope can move them, for a filter sweep.' + + BLENDS, + inputs: [audioIn, followingIn(CUTOFF), followingIn(Q)], + outputs: [audioOut], + params: [PASS, SLOPE], + idleParams: (params) => (SLOPED.has(passOf(params)) ? [] : [SLOPE.key]), + derivePorts: ({ node }) => ({ + inputs: GAINED.has(passOf(node.params)) ? [followingIn(FILTER_GAIN)] : [], + }), + emit: (sounds, amounts, settings) => + withStages(arriving(sounds), [ + { + kind: 'pass', + filter: passOf(settings), + stages: passStagesOf(settings), + cutoff: held(amounts.cutoff, CUTOFF), + q: held(amounts.q, Q), + gain: GAINED.has(passOf(settings)) ? held(amounts.gain, FILTER_GAIN) : { value: 0 }, + }, + ]), + }, + 'blend', +); const MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 }; const MIX_CURVE = { @@ -641,63 +732,79 @@ const DELAY_SYNC = { /** Never 1: the echoes would never fade, and above it they would grow. */ const DELAY_FEEDBACK = { ...numberRow('feedback', 'Feedback', 0, 0.95, 0.01), default: 0.4 }; const DELAY_DAMPING = { ...numberRow('damping', 'Damping', 0, 1, 0.01), default: 0.3 }; -const DELAY_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 }; +/** Wet of a Delay (#354): 0 the sound alone, 0.5 the sound and its echoes in full, 1 the echoes alone. */ +const DELAY_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 0.5 }; const PING_PONG = { key: 'pingPong', label: 'Ping-pong', kind: 'boolean' as const, default: false }; -export const SoundFxDelay: SoundFxNodeDef = { - type: 'SoundFxDelay', - label: 'Delay', - category: 'sound', - description: - 'Echoes the sound: it comes back after "time", again and again, each echo "feedback" times as loud as the one before. "time" is in seconds, or with "sync" on beats in quarter notes at the tempo of the voice: 1 is a quarter, 0.5 an eighth, 0.75 a dotted eighth, 0.333 an eighth triplet. A Play sound or Play note says that tempo, a song its own. Either way an echo comes back after 2 s at most. "damping" makes every echo duller than the last, like tape. "mix" is how loud the echoes are next to the sound, which itself stays as it is. With "ping-pong" the echoes take turns left and right. The echoes ring on after the sound ends; a voice lasts until they have faded.', - inputs: [audioIn, ...[DELAY_TIME, DELAY_FEEDBACK, DELAY_DAMPING, DELAY_MIX].map(numberIn)], - outputs: [audioOut], - params: [DELAY_SYNC, PING_PONG], - emit: (sounds, amounts, settings) => { - const mix = held(amounts.mix, DELAY_MIX); - if (isNone(mix)) return arriving(sounds); - return withStages(arriving(sounds), [ - { - kind: 'delay', - time: held(amounts.time, DELAY_TIME), - beats: settings[DELAY_SYNC.key] === 'beats', - feedback: held(amounts.feedback, DELAY_FEEDBACK), - damping: held(amounts.damping, DELAY_DAMPING), - mix, - pingPong: settings[PING_PONG.key] === true, - }, - ]); +/** How loud a Delay at `wet` lets the sound itself through, and how loud its echoes (#354). */ +export function delayShares(wet: number): { dry: number; echo: number } { + return { dry: Math.min(1, 2 * (1 - wet)), echo: Math.min(1, 2 * wet) }; +} + +export const SoundFxDelay: SoundFxNodeDef = effect( + { + type: 'SoundFxDelay', + label: 'Delay', + category: 'sound', + description: + 'Echoes the sound: it comes back after "time", again and again, each echo "feedback" times as loud as the one before. "time" is in seconds, or with "sync" on beats in quarter notes at the tempo of the voice: 1 is a quarter, 0.5 an eighth, 0.75 a dotted eighth, 0.333 an eighth triplet. A Play sound or Play note says that tempo, a song its own. Either way an echo comes back after 2 s at most. "damping" makes every echo duller than the last, like tape. "wet" 0 is the sound alone, 0.5 the sound and its echoes both in full, 1 the echoes alone; between them one fades as the other stays. With "ping-pong" the echoes take turns left and right. The echoes ring on after the sound ends; a voice lasts until they have faded. "Bypass" leaves the node out as if it were not there.', + inputs: [audioIn, ...[DELAY_TIME, DELAY_FEEDBACK, DELAY_DAMPING, DELAY_MIX].map(numberIn)], + outputs: [audioOut], + params: [DELAY_SYNC, PING_PONG], + emit: (sounds, amounts, settings) => { + const mix = held(amounts.mix, DELAY_MIX); + if (isNone(mix)) return arriving(sounds); + return withStages(arriving(sounds), [ + { + kind: 'delay', + time: held(amounts.time, DELAY_TIME), + beats: settings[DELAY_SYNC.key] === 'beats', + feedback: held(amounts.feedback, DELAY_FEEDBACK), + damping: held(amounts.damping, DELAY_DAMPING), + mix, + pingPong: settings[PING_PONG.key] === true, + }, + ]); + }, }, -}; + 'own', +); 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 }; +const CRUSH_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 1 }; export const CRUSH_ROWS = { bits: CRUSH_BITS, rate: CRUSH_RATE, mix: CRUSH_MIX }; -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 }, - ]); +/** How a node whose wet was its "mix" says the Bypass. */ +const BYPASSES = ' "Bypass" leaves the node out as if it were not there.'; + +export const SoundFxBitcrusher: SoundFxNodeDef = effect( + { + 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. "wet" 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.' + + BYPASSES, + 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 }, + ]); + }, }, -}; + 'own', +); 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 }; +const PHASER_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 1 }; export const PHASER_ROWS = { frequency: PHASER_FREQUENCY, rate: PHASER_RATE, @@ -718,45 +825,49 @@ export function phaserStagesOf(settings: PropertyBag): number { 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, - }, - ]); +export const SoundFxPhaser: SoundFxNodeDef = effect( + { + 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. "wet" 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 wet while a note plays.' + + BYPASSES, + 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, + }, + ]); + }, }, -}; + 'own', +); 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 }; +const FLANGER_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 1 }; export const FLANGER_ROWS = { offset: FLANGER_OFFSET, sweep: FLANGER_SWEEP, @@ -766,56 +877,64 @@ export const FLANGER_ROWS = { 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, - }, - ]); +export const SoundFxFlanger: SoundFxNodeDef = effect( + { + 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. "wet" 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 wet while a note plays.' + + BYPASSES, + 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, + }, + ]); + }, }, -}; + 'own', +); const PAN = numberRow('pan', 'Pan', -1, 1, 0.01); -export const SoundFxPan: SoundFxNodeDef = { - type: 'SoundFxPan', - label: 'Pan', - category: 'sound', - description: - 'Moves the sound left or right: -1 is all left, 1 all right, 0 leaves it in the middle. A mono sound is spread on both sides and then moved. A stereo sound keeps the side it goes to, and the other side moves over into it: at 1 both sides are heard on the right. It can change while the sound plays, and in an instrument an Envelope can move it.', - inputs: [audioIn, followingIn(PAN)], - outputs: [audioOut], - params: [], - emit: (sounds, amounts) => { - const pan = held(amounts.pan, PAN); - return withStages(arriving(sounds), isNone(pan) ? [] : [{ kind: 'pan', pan }]); +export const SoundFxPan: SoundFxNodeDef = effect( + { + type: 'SoundFxPan', + label: 'Pan', + category: 'sound', + description: + 'Moves the sound left or right: -1 is all left, 1 all right, 0 leaves it in the middle. A mono sound is spread on both sides and then moved. A stereo sound keeps the side it goes to, and the other side moves over into it: at 1 both sides are heard on the right. It can change while the sound plays, and in an instrument an Envelope can move it.' + + BLENDS, + inputs: [audioIn, followingIn(PAN)], + outputs: [audioOut], + params: [], + emit: (sounds, amounts) => { + const pan = held(amounts.pan, PAN); + return withStages(arriving(sounds), isNone(pan) ? [] : [{ kind: 'pan', pan }]); + }, }, -}; + 'blend', +); const COMPRESS_THRESHOLD = { ...numberRow('threshold', 'Threshold (dB)', -100, 0, 0.5), default: -24 }; const COMPRESS_RATIO = { ...numberRow('ratio', 'Ratio', 1, 20, 0.1), default: 4 }; @@ -834,29 +953,33 @@ export const COMPRESS_ROWS = { autoGain: AUTO_GAIN, }; -export const SoundFxCompressor: SoundFxNodeDef = { - type: 'SoundFxCompressor', - label: 'Compressor', - category: 'sound', - description: - 'Squeezes the loudness of the sound together: what goes above "threshold" (dB) comes out only 1/"ratio" as much louder, so 4 makes 8 dB over it 2 dB. "knee" (dB) eases into that from the threshold on instead of at once. "attack" is how fast it turns down once the sound is over the threshold, "release" how fast it lets go, both in seconds. "makeup" (dB) raises it again afterwards. "auto-gain" from 0 to 1 blends that with the gain that brings a sound at full scale back to full scale: 0 is the makeup alone, 1 the auto-gain alone. It is 6 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays.', - inputs: [audioIn, ...Object.values(COMPRESS_ROWS).map(numberIn)], - outputs: [audioOut], - params: [], - emit: (sounds, amounts) => - withStages(arriving(sounds), [ - { - kind: 'compress', - threshold: held(amounts.threshold, COMPRESS_THRESHOLD), - knee: held(amounts.knee, COMPRESS_KNEE), - ratio: held(amounts.ratio, COMPRESS_RATIO), - attack: held(amounts.attack, COMPRESS_ATTACK), - release: held(amounts.release, COMPRESS_RELEASE), - makeup: held(amounts.makeup, MAKEUP), - autoGain: held(amounts.autoGain, AUTO_GAIN), - }, - ]), -}; +export const SoundFxCompressor: SoundFxNodeDef = effect( + { + type: 'SoundFxCompressor', + label: 'Compressor', + category: 'sound', + description: + 'Squeezes the loudness of the sound together: what goes above "threshold" (dB) comes out only 1/"ratio" as much louder, so 4 makes 8 dB over it 2 dB. "knee" (dB) eases into that from the threshold on instead of at once. "attack" is how fast it turns down once the sound is over the threshold, "release" how fast it lets go, both in seconds. "makeup" (dB) raises it again afterwards. "auto-gain" from 0 to 1 blends that with the gain that brings a sound at full scale back to full scale: 0 is the makeup alone, 1 the auto-gain alone. It is 6 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays.' + + BLENDS, + inputs: [audioIn, ...Object.values(COMPRESS_ROWS).map(numberIn)], + outputs: [audioOut], + params: [], + emit: (sounds, amounts) => + withStages(arriving(sounds), [ + { + kind: 'compress', + threshold: held(amounts.threshold, COMPRESS_THRESHOLD), + knee: held(amounts.knee, COMPRESS_KNEE), + ratio: held(amounts.ratio, COMPRESS_RATIO), + attack: held(amounts.attack, COMPRESS_ATTACK), + release: held(amounts.release, COMPRESS_RELEASE), + makeup: held(amounts.makeup, MAKEUP), + autoGain: held(amounts.autoGain, AUTO_GAIN), + }, + ]), + }, + 'blend', +); const LIMIT_THRESHOLD = { ...numberRow('threshold', 'Threshold (dB)', -60, 0, 0.5), default: -6 }; const CEILING = { ...numberRow('ceiling', 'Ceiling (dB)', -30, 0, 0.1), default: -1 }; @@ -869,32 +992,36 @@ export const LIMIT_ROWS = { autoGain: LIMIT_AUTO_GAIN, }; -export const SoundFxLimiter: SoundFxNodeDef = { - type: 'SoundFxLimiter', - label: 'Limiter', - category: 'sound', - description: - 'Keeps every peak of the sound below "threshold" (dB), however loud it comes in: it looks 5 ms ahead and has the volume down by the time the peak is heard, then lets it come back up in "release" seconds. "auto-gain" from 0 to 1 raises the limited sound towards "ceiling" (dB): at 1 its peaks sit right at the ceiling, at 0 at the threshold. Nothing ever goes above the ceiling, and a threshold above the ceiling limits at the ceiling. It is 5 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays.', - inputs: [audioIn, ...Object.values(LIMIT_ROWS).map(numberIn)], - outputs: [audioOut], - params: [], - emit: (sounds, amounts) => - withStages(arriving(sounds), [ - { - kind: 'limit', - threshold: held(amounts.threshold, LIMIT_THRESHOLD), - ceiling: held(amounts.ceiling, CEILING), - release: held(amounts.release, LIMIT_RELEASE), - autoGain: held(amounts.autoGain, LIMIT_AUTO_GAIN), - }, - ]), -}; +export const SoundFxLimiter: SoundFxNodeDef = effect( + { + type: 'SoundFxLimiter', + label: 'Limiter', + category: 'sound', + description: + 'Keeps every peak of the sound below "threshold" (dB), however loud it comes in: it looks 5 ms ahead and has the volume down by the time the peak is heard, then lets it come back up in "release" seconds. "auto-gain" from 0 to 1 raises the limited sound towards "ceiling" (dB): at 1 its peaks sit right at the ceiling, at 0 at the threshold. Nothing ever goes above the ceiling, and a threshold above the ceiling limits at the ceiling. It is 5 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays.' + + BLENDS, + inputs: [audioIn, ...Object.values(LIMIT_ROWS).map(numberIn)], + outputs: [audioOut], + params: [], + emit: (sounds, amounts) => + withStages(arriving(sounds), [ + { + kind: 'limit', + threshold: held(amounts.threshold, LIMIT_THRESHOLD), + ceiling: held(amounts.ceiling, CEILING), + release: held(amounts.release, LIMIT_RELEASE), + autoGain: held(amounts.autoGain, LIMIT_AUTO_GAIN), + }, + ]), + }, + 'blend', +); const DRIVE = { ...numberRow('drive', 'Drive (dB)', 0, 48, 0.5), default: 12 }; const TONE = { ...numberRow('tone', 'Tone', 0, 1, 0.01), default: 1 }; const OUTPUT = numberRow('output', 'Output (dB)', -24, 24, 0.5); const SATURATION_AUTO_GAIN = { ...AUTO_GAIN, default: 1 }; -const SATURATION_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 1 }; +const SATURATION_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 1 }; export const SATURATION_ROWS = { drive: DRIVE, tone: TONE, @@ -914,31 +1041,35 @@ export function saturationTypeOf(settings: PropertyBag): SaturationType { return SATURATION_TYPES.find((option) => option.value === settings[SATURATION_TYPE.key])?.value ?? 'soft'; } -export const SoundFxSaturation: SoundFxNodeDef = { - type: 'SoundFxSaturation', - label: 'Saturation', - category: 'sound', - description: - 'Bends the sound, from warmth to a hard distortion. "Type" is the shape it is bent by: Soft (tanh) and Cubic round the peaks off, Hard clip cuts them, Tube and Fuzz bend one half harder than the other, Foldback and Sine fold mirror what goes past full scale back in, Rectify turns the lower half up, an octave higher. "drive" (dB) is how hard the sound is pushed into it. "tone" from 0 to 1 is a low-pass after it, from 1 kHz up to wide open. "output" (dB) sets the level of the bent sound, "auto-gain" from 0 to 1 blends that with the gain that keeps a sound at -12 dBFS as loud as it came in. "mix" is how much of the bent sound is heard instead of the sound itself. It is 4 ms late (192 samples), which a voice, a Mix and a bus make up for. All numbers can change while the sound plays.', - inputs: [audioIn, ...Object.values(SATURATION_ROWS).map(numberIn)], - outputs: [audioOut], - params: [SATURATION_TYPE], - emit: (sounds, amounts, settings) => { - const mix = held(amounts.mix, SATURATION_MIX); - if (isNone(mix)) return arriving(sounds); - return withStages(arriving(sounds), [ - { - kind: 'saturate', - curve: saturationTypeOf(settings), - drive: held(amounts.drive, DRIVE), - tone: held(amounts.tone, TONE), - output: held(amounts.output, OUTPUT), - autoGain: held(amounts.autoGain, SATURATION_AUTO_GAIN), - mix, - }, - ]); +export const SoundFxSaturation: SoundFxNodeDef = effect( + { + type: 'SoundFxSaturation', + label: 'Saturation', + category: 'sound', + description: + 'Bends the sound, from warmth to a hard distortion. "Type" is the shape it is bent by: Soft (tanh) and Cubic round the peaks off, Hard clip cuts them, Tube and Fuzz bend one half harder than the other, Foldback and Sine fold mirror what goes past full scale back in, Rectify turns the lower half up, an octave higher. "drive" (dB) is how hard the sound is pushed into it. "tone" from 0 to 1 is a low-pass after it, from 1 kHz up to wide open. "output" (dB) sets the level of the bent sound, "auto-gain" from 0 to 1 blends that with the gain that keeps a sound at -12 dBFS as loud as it came in. "wet" is how much of the bent sound is heard instead of the sound itself. It is 4 ms late (192 samples), which a voice, a Mix and a bus make up for. All numbers can change while the sound plays.' + + BYPASSES, + inputs: [audioIn, ...Object.values(SATURATION_ROWS).map(numberIn)], + outputs: [audioOut], + params: [SATURATION_TYPE], + emit: (sounds, amounts, settings) => { + const mix = held(amounts.mix, SATURATION_MIX); + if (isNone(mix)) return arriving(sounds); + return withStages(arriving(sounds), [ + { + kind: 'saturate', + curve: saturationTypeOf(settings), + drive: held(amounts.drive, DRIVE), + tone: held(amounts.tone, TONE), + output: held(amounts.output, OUTPUT), + autoGain: held(amounts.autoGain, SATURATION_AUTO_GAIN), + mix, + }, + ]); + }, }, -}; + 'own', +); const LFO_SHAPE = { key: 'shape', @@ -959,41 +1090,49 @@ const VIBRATO_DEPTH = { ...numberRow('depth', 'Depth (st)', 0, 4, 0.01), default export const TREMOLO_ROWS = { rate: TREMOLO_RATE, depth: TREMOLO_DEPTH }; export const VIBRATO_ROWS = { rate: VIBRATO_RATE, depth: VIBRATO_DEPTH }; -export const SoundFxTremolo: SoundFxNodeDef = { - type: 'SoundFxTremolo', - label: 'Tremolo', - category: 'sound', - description: - 'Makes the volume swing, "rate" times a second: from full down by "depth" percent and back. "Shape" is how it swings: Sine and Triangle smoothly, Square on and off, Saw up and then down at once. It starts at the same point of its swing every time a sound starts. Rate and depth can change while the sound plays.', - inputs: [audioIn, numberIn(TREMOLO_RATE), numberIn(TREMOLO_DEPTH)], - outputs: [audioOut], - params: [LFO_SHAPE], - emit: (sounds, amounts, settings) => { - const depth = held(amounts.depth, TREMOLO_DEPTH); - if (isNone(depth)) return arriving(sounds); - return withStages(arriving(sounds), [ - { kind: 'tremolo', shape: lfoShapeOf(settings), rate: held(amounts.rate, TREMOLO_RATE), depth }, - ]); +export const SoundFxTremolo: SoundFxNodeDef = effect( + { + type: 'SoundFxTremolo', + label: 'Tremolo', + category: 'sound', + description: + 'Makes the volume swing, "rate" times a second: from full down by "depth" percent and back. "Shape" is how it swings: Sine and Triangle smoothly, Square on and off, Saw up and then down at once. It starts at the same point of its swing every time a sound starts. Rate and depth can change while the sound plays.' + + BLENDS, + inputs: [audioIn, numberIn(TREMOLO_RATE), numberIn(TREMOLO_DEPTH)], + outputs: [audioOut], + params: [LFO_SHAPE], + emit: (sounds, amounts, settings) => { + const depth = held(amounts.depth, TREMOLO_DEPTH); + if (isNone(depth)) return arriving(sounds); + return withStages(arriving(sounds), [ + { kind: 'tremolo', shape: lfoShapeOf(settings), rate: held(amounts.rate, TREMOLO_RATE), depth }, + ]); + }, }, -}; + 'blend', +); -export const SoundFxVibrato: SoundFxNodeDef = { - type: 'SoundFxVibrato', - label: 'Vibrato', - category: 'sound', - description: - 'Makes the pitch swing, "rate" times a second, "depth" semitones up and as far down. "Shape" is how it swings: Sine and Triangle smoothly, Square jumps between two pitches, Saw glides up and drops. It starts at the same point of its swing every time a sound starts, and the sound keeps its length. The pitch is moved by a short delay that swings: the voice is late by as long as it swings out at the rate and depth it starts with, at 5 Hz and half a semitone under a millisecond, which a voice, a Mix and a bus make up for. Rate and depth can change while the sound plays; turning the depth up or the rate down later than the start can flatten the lower half of the swing.', - inputs: [audioIn, numberIn(VIBRATO_RATE), numberIn(VIBRATO_DEPTH)], - outputs: [audioOut], - params: [LFO_SHAPE], - emit: (sounds, amounts, settings) => { - const depth = held(amounts.depth, VIBRATO_DEPTH); - if (isNone(depth)) return arriving(sounds); - return withStages(arriving(sounds), [ - { kind: 'vibrato', shape: lfoShapeOf(settings), rate: held(amounts.rate, VIBRATO_RATE), depth }, - ]); +export const SoundFxVibrato: SoundFxNodeDef = effect( + { + type: 'SoundFxVibrato', + label: 'Vibrato', + category: 'sound', + description: + 'Makes the pitch swing, "rate" times a second, "depth" semitones up and as far down. "Shape" is how it swings: Sine and Triangle smoothly, Square jumps between two pitches, Saw glides up and drops. It starts at the same point of its swing every time a sound starts, and the sound keeps its length. The pitch is moved by a short delay that swings: the voice is late by as long as it swings out at the rate and depth it starts with, at 5 Hz and half a semitone under a millisecond, which a voice, a Mix and a bus make up for. Rate and depth can change while the sound plays; turning the depth up or the rate down later than the start can flatten the lower half of the swing.' + + BLENDS, + inputs: [audioIn, numberIn(VIBRATO_RATE), numberIn(VIBRATO_DEPTH)], + outputs: [audioOut], + params: [LFO_SHAPE], + emit: (sounds, amounts, settings) => { + const depth = held(amounts.depth, VIBRATO_DEPTH); + if (isNone(depth)) return arriving(sounds); + return withStages(arriving(sounds), [ + { kind: 'vibrato', shape: lfoShapeOf(settings), rate: held(amounts.rate, VIBRATO_RATE), depth }, + ]); + }, }, -}; + 'blend', +); const REVERB_ROOM = { key: 'room', @@ -1006,7 +1145,7 @@ const IMPULSE = { key: 'impulse', label: 'Sound', kind: 'sound' as const, defaul const REVERB_DECAY = { ...numberRow('decay', 'Decay (s)', 0.1, REVERB_MAX_DECAY, 0.01), default: 1.5 }; const REVERB_DAMPING = { ...numberRow('damping', 'Damping', 0, 1, 0.01), default: 0.4 }; const PREDELAY = { ...numberRow('predelay', 'Pre-delay (s)', 0, 0.5, 0.001), default: 0.01 }; -const REVERB_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.3 }; +const REVERB_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 0.3 }; export const REVERB_ROWS = { decay: REVERB_DECAY, damping: REVERB_DAMPING, @@ -1018,39 +1157,43 @@ export function reverbRoomOf(settings: PropertyBag): ReverbRoom { return REVERB_ROOMS.find((option) => option.value === settings[REVERB_ROOM.key])?.value ?? 'room'; } -export const SoundFxReverb: SoundFxNodeDef = { - type: 'SoundFxReverb', - label: 'Reverb', - category: 'sound', - description: - 'The sound in a space, from a small room to a cave. "Type" is a space with its own decay, damping and pre-delay (Room, Hall, Cave, Plate), Custom to set them yourself, or Sound to take a sound asset as the impulse response of a real space. "decay" (s, up to 10) is how long the reverb takes to fall by 60 dB, "damping" from 0 to 1 how much faster its highs fall, "pre-delay" (s) how long it waits before it starts. "mix" is how much of it is heard instead of the sound itself: 1 is the reverb alone, for a bus that only carries reverb. Only the mix can change while the sound plays; the rest is set when the sound starts, on a bus when it gets its FX. The sound lasts until the reverb has faded.', - inputs: [audioIn, numberIn(REVERB_MIX)], - outputs: [audioOut], - params: [REVERB_ROOM, IMPULSE], - idleParams: (params) => (reverbRoomOf(params) === 'sound' ? [] : [IMPULSE.key]), - derivePorts: ({ node }) => { - const room = reverbRoomOf(node.params); - if (room === 'custom') return { inputs: [REVERB_DECAY, REVERB_DAMPING, PREDELAY].map(numberIn) }; - return room === 'sound' ? { inputs: [numberIn(PREDELAY)] } : {}; +export const SoundFxReverb: SoundFxNodeDef = effect( + { + type: 'SoundFxReverb', + label: 'Reverb', + category: 'sound', + description: + 'The sound in a space, from a small room to a cave. "Type" is a space with its own decay, damping and pre-delay (Room, Hall, Cave, Plate), Custom to set them yourself, or Sound to take a sound asset as the impulse response of a real space. "decay" (s, up to 10) is how long the reverb takes to fall by 60 dB, "damping" from 0 to 1 how much faster its highs fall, "pre-delay" (s) how long it waits before it starts. "wet" is how much of it is heard instead of the sound itself: 1 is the reverb alone, for a bus that only carries reverb. Only the wet can change while the sound plays; the rest is set when the sound starts, on a bus when it gets its FX. The sound lasts until the reverb has faded.' + + BYPASSES, + inputs: [audioIn, numberIn(REVERB_MIX)], + outputs: [audioOut], + params: [REVERB_ROOM, IMPULSE], + idleParams: (params) => (reverbRoomOf(params) === 'sound' ? [] : [IMPULSE.key]), + derivePorts: ({ node }) => { + const room = reverbRoomOf(node.params); + if (room === 'custom') return { inputs: [REVERB_DECAY, REVERB_DAMPING, PREDELAY].map(numberIn) }; + return room === 'sound' ? { inputs: [numberIn(PREDELAY)] } : {}; + }, + emit: (sounds, amounts, settings, project) => { + const mix = held(amounts.mix, REVERB_MIX); + if (isNone(mix)) return arriving(sounds); + const room = reverbRoomOf(settings); + const preset = room === 'custom' || room === 'sound' ? null : REVERB_PRESETS[room]; + const impulse = room === 'sound' ? (project?.soundFile(textOf(settings[IMPULSE.key] ?? '')) ?? '') : ''; + return withStages(arriving(sounds), [ + { + kind: 'reverb', + decay: preset ? { value: preset.decay } : held(amounts.decay, REVERB_DECAY), + damping: preset ? { value: preset.damping } : held(amounts.damping, REVERB_DAMPING), + predelay: preset ? { value: preset.predelay } : held(amounts.predelay, PREDELAY), + mix, + ...(impulse && { impulse }), + }, + ]); + }, }, - emit: (sounds, amounts, settings, project) => { - const mix = held(amounts.mix, REVERB_MIX); - if (isNone(mix)) return arriving(sounds); - const room = reverbRoomOf(settings); - const preset = room === 'custom' || room === 'sound' ? null : REVERB_PRESETS[room]; - const impulse = room === 'sound' ? (project?.soundFile(textOf(settings[IMPULSE.key] ?? '')) ?? '') : ''; - return withStages(arriving(sounds), [ - { - kind: 'reverb', - decay: preset ? { value: preset.decay } : held(amounts.decay, REVERB_DECAY), - damping: preset ? { value: preset.damping } : held(amounts.damping, REVERB_DAMPING), - predelay: preset ? { value: preset.predelay } : held(amounts.predelay, PREDELAY), - mix, - ...(impulse && { impulse }), - }, - ]); - }, -}; + 'own', +); /** Where damping 0 leaves the echoes and damping 1 takes them: evenly in octaves between. */ const UNDAMPED_HZ = 20000; @@ -1081,11 +1224,15 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [ SoundFxReverb, ]; -/** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */ +/** + * An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. + * One with a share it is taken by is taken by it (#354). + */ function amountOf(amount: FxAmount, params: FxParams): number { + const share = amount.times ? amountOf(amount.times, params) : 1; const given = amount.param !== undefined ? params[amount.param] : undefined; - if (given === undefined || !Number.isFinite(given)) return amount.value; - return Math.max(amount.min ?? -Infinity, Math.min(amount.max ?? Infinity, given)); + if (given === undefined || !Number.isFinite(given)) return amount.value * share; + return Math.max(amount.min ?? -Infinity, Math.min(amount.max ?? Infinity, given)) * share; } /** Seconds of a delay's time, from beats at the voice's tempo where it is synced, never past the line. */ @@ -1107,19 +1254,30 @@ export function envelopeOf(plan: FxEnvelopePlan, params: FxParams): FxEnvelope { function modOf(amount: FxAmount, params: FxParams, convert = (value: number) => value): FxMod | undefined { const mod = amount.mod; if (!mod) return undefined; + const share = amount.times ? amountOf(amount.times, params) : 1; return { envelope: envelopeOf(mod.envelope, params), - from: convert(amountOf(mod.from, params)), - to: convert(amountOf(mod.to, params)), + from: convert(amountOf(mod.from, params) * share), + to: convert(amountOf(mod.to, params) * share), }; } const dbToGain = (db: number) => 10 ** (db / 20); /** A stage fed by a parameter stays at 0 as well: a value handed in later has to find it there. */ -function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] { +function stagesOf(plan: readonly FxStagePlan[], params: FxParams, tempo: number): FxStage[] { const stages: FxStage[] = []; - for (const stage of plan.stages) { + for (const stage of plan) { + if (stage.kind === 'wet') { + const wetMod = modOf(stage.wet, params); + stages.push({ + kind: 'wet', + wet: amountOf(stage.wet, params), + stages: stagesOf(stage.stages, params, tempo), + ...(wetMod && { wetMod }), + }); + continue; + } if (stage.kind === 'gain') { const db = amountOf(stage.db, params); const mod = modOf(stage.db, params, dbToGain); @@ -1291,7 +1449,7 @@ function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] { * `tempo` is what a Delay synced to beats counts by (#332). */ export function fxChainOf(plan: FxPlan, params: FxParams = {}, tempo = DEFAULT_TEMPO): FxChain { - const stages = stagesOf(plan, params, tempo); + const stages = stagesOf(plan.stages, params, tempo); if (!('mix' in plan)) { const pitch = plan.pitch.reduce((sum, amount) => (amount.mod ? sum : sum + amountOf(amount, params)), 0); const pitchMods = plan.pitch.flatMap((amount) => modOf(amount, params) ?? []); @@ -1342,10 +1500,13 @@ const RINGING: ReadonlySet = new Set(['delay', 'limit', 'vi /** Whether a plan can ring on after its sources end (#324): it has an echo somewhere, or a Limiter. */ export function fxRings(plan: FxPlan): boolean { - if (plan.stages.some((stage) => RINGING.has(stage.kind))) return true; + if (plan.stages.some(ringing)) return true; return 'mix' in plan && (fxRings(plan.a) || fxRings(plan.b)); } +const ringing = (stage: FxStagePlan): boolean => + stage.kind === 'wet' ? stage.stages.some(ringing) : RINGING.has(stage.kind); + /** How far below the sound a tail has to fall to count as gone: -60 dB. */ const SILENT = 1e-3; @@ -1361,12 +1522,17 @@ export function fxTailOf( soundLength: (file: string) => number = () => REVERB_MAX_DECAY, ): number { const ways = 'mix' in chain ? Math.max(fxTailOf(chain.a, soundLength), fxTailOf(chain.b, soundLength)) : 0; - return chain.stages.reduce((sum, stage) => { + return ways + stagesTail(chain.stages, soundLength); +} + +function stagesTail(stages: readonly FxStage[], soundLength: (file: string) => number): number { + return stages.reduce((sum, stage) => { if (stage.kind === 'delay') return sum + delayTail(stage); if (stage.kind === 'reverb') return sum + stage.predelay + (stage.impulse ? soundLength(stage.impulse) : stage.decay); + if (stage.kind === 'wet') return sum + stagesTail(stage.stages, soundLength); return sum; - }, ways); + }, 0); } /** The files the Reverbs of a plan take as their impulse response (#328). */ @@ -1379,13 +1545,15 @@ export function impulsesOf(plan: FxPlan): string[] { /** * Seconds a stage holds the sound back (#326): a Compressor's look-ahead, a Limiter's, a - * Saturation's oversampling, the delay a Vibrato swings around. + * Saturation's oversampling, the delay a Vibrato swings around, and those of the stages a Wet blends. */ export function stageLatency(stage: FxStage, sampleRate: number): number { if (stage.kind === 'compress') return COMPRESSOR_LATENCY; if (stage.kind === 'limit') return limiterLatency(sampleRate); if (stage.kind === 'saturate') return SHAPER_LATENCY_FRAMES / sampleRate; if (stage.kind === 'vibrato') return vibratoDelay(stage.shape, stage.rate, stage.depth); + if (stage.kind === 'wet') + return stage.stages.reduce((sum, inner) => sum + stageLatency(inner, sampleRate), 0); return 0; } @@ -1399,9 +1567,10 @@ export function fxLatencyOf(chain: FxChain, sampleRate: number): number { return chain.stages.reduce((sum, stage) => sum + stageLatency(stage, sampleRate), ways); } -/** The echo `k` is `mix * feedback^(k-1)` as loud as the sound; the last one still heard sets the tail. */ +/** The echo `k` is `echo * feedback^(k-1)` as loud as the sound; the last one still heard sets the tail. */ function delayTail({ time, feedback, mix }: FxDelay): number { - if (!(mix >= SILENT) || !(time > 0)) return 0; - const echoes = feedback > 0 ? 1 + Math.floor(Math.log(SILENT / mix) / Math.log(feedback)) : 1; + const { echo } = delayShares(mix); + if (!(echo >= SILENT) || !(time > 0)) return 0; + const echoes = feedback > 0 ? 1 + Math.floor(Math.log(SILENT / echo) / Math.log(feedback)) : 1; return echoes * time; } diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 8091942a..827da512 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -101,6 +101,8 @@ export function graphHooks({ state, editor }: HookContext) { info: () => editor.soundFxEditor.shownInfo, canvasRect: () => editor.soundFxEditor.canvas.canvasRect(), nodeProblem: (nodeId: string) => editor.soundFxEditor.canvas.nodeProblem(nodeId), + nodeSettings: (nodeId: string) => editor.soundFxEditor.canvas.nodeSettings(nodeId), + nodeAlpha: (nodeId: string) => editor.soundFxEditor.canvas.nodeAlpha(nodeId), /** The header's listening controls: which sound, and whether Play is offered. */ preview: () => ({ sound: editor.soundFxEditor.previewSoundId, diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 8e790f4c..8aaa5fd8 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -1159,6 +1159,11 @@ export class GraphCanvas extends Widget { return this.views.get(nodeId)?.settingLines ?? null; } + /** How opaque a node is drawn: below 1 for one that does nothing, a bypassed effect (#354). */ + nodeAlpha(nodeId: string): number | null { + return this.views.get(nodeId)?.alpha ?? null; + } + nodeInputs(nodeId: string): string[] | null { return this.views.get(nodeId)?.inputLines ?? null; } diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index a0451d0a..f870994c 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -208,6 +208,7 @@ export class NodeView extends Widget { ): void { this.def = def; this.problem = problem; + this.alpha = def.dimmed?.(node.params) ? DIMMED_ALPHA : 1; const hidden = new Set([ ...(this.def.hiddenParams?.(node.params) ?? []), ...(this.def.idleParams?.(node.params, (key) => edgesInto(graph, node.id, key).length > 0) ?? []), @@ -594,6 +595,8 @@ function formatField(def: PropertyDef, value: Value, project: Project | null): s } /** How a node says "no value" and "empty value" – for inputs and live outputs alike. */ +/** How a node that does nothing at its settings is drawn, a bypassed effect (#354). */ +const DIMMED_ALPHA = 0.45; const NULL_TEXT = '(null)'; const EMPTY_TEXT = '(empty)'; diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index f5b83bee..1b168f20 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -26,6 +26,8 @@ import { type FxStage, type FxStagePlan, type FxVoice, + type FxWet, + delayShares, stageLatency, } from '../core/soundfx/nodes'; import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope'; @@ -1987,10 +1989,13 @@ const ON_WORKLET: ReadonlySet = new Set([ /** 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) => ON_WORKLET.has(stage.kind))) return true; + if (plan.stages.some(onWorklet)) return true; return 'mix' in plan ? needsWorklet(plan.a) || needsWorklet(plan.b) : plan.pitch.length > 0 || !!plan.chip; } +const onWorklet = (stage: FxStagePlan): boolean => + stage.kind === 'wet' ? stage.stages.some(onWorklet) : ON_WORKLET.has(stage.kind); + /** Whether a way of a plan plays a Chip (#329), which needs no file. */ function hasChip(plan: FxPlan): boolean { return 'mix' in plan ? hasChip(plan.a) || hasChip(plan.b) : plan.chip !== undefined; @@ -2085,6 +2090,7 @@ export function stageNodes( if (stage.kind === 'saturate') return saturateNodes(context, stage); if (stage.kind === 'reverb') return reverbNodes(context, stage, impulse); if (stage.kind === 'tremolo' || stage.kind === 'vibrato') return lfoNodes(context, stage, at); + if (stage.kind === 'wet') return wetNodes(context, stage, follow, at); if (stage.kind === 'limit') return workletNodes(context, 'limiter', [ { name: 'threshold', value: stage.threshold, even: 'value' }, @@ -2208,6 +2214,44 @@ export function releaseEnvelope( level.setTargetAtTime(0, at, releaseTime(envelope) / SETTLE); } +/** + * What one node adds, blended with what arrives at it (#354): its stages one after another at `wet`, + * and the sound as it came in at the rest, held back as far as the stages are late. + */ +function wetNodes( + context: BaseAudioContext, + stage: FxWet, + follow: Follow | undefined, + at: number, +): StageNodes { + const input = context.createGain(); + const output = context.createGain(); + const knobs: Knob[] = []; + const stops: (() => void)[] = []; + let tail: AudioNode = input; + for (const inner of stage.stages) { + const made = stageNodes(context, inner, follow, at); + tail.connect(made.input); + tail = made.output; + knobs.push(...made.knobs); + if (made.stop) stops.push(made.stop); + } + const moved = stage.wetMod; + const wet = gainNode(context, moved ? 0 : stage.wet); + const dry = gainNode(context, moved ? 0 : 1 - stage.wet); + if (moved) { + follow?.(moved, wet.gain, false); + follow?.(moved, dry.gain, false, (share) => 1 - share); + } + tail.connect(wet).connect(output); + heldBack(context, input, stageLatency(stage, context.sampleRate)).connect(dry).connect(output); + knobs.push( + { param: moved ? null : wet.gain, target: stage.wet, even: 'value' }, + { param: moved ? null : dry.gain, target: 1 - stage.wet, even: 'value' }, + ); + return { input, output, knobs, ...(stops.length > 0 && { stop: () => stops.forEach((stop) => stop()) }) }; +} + /** * A Compressor (#326): Web Audio's node, and a gain after it for makeup and auto-gain. Its times * move in ratios, everything in dB as it is. @@ -2348,14 +2392,16 @@ function saturateNodes(context: BaseAudioContext, stage: FxSaturate): StageNodes } /** - * An echo (#327): the sound goes on as it is, and through a delay line whose output is damped, fed - * back into it and added at `mix`. Ping-pong sends the sound into the left of the line only and - * swaps the sides on every round. + * An echo (#327): the sound goes on, and through a delay line whose output is damped, fed back into + * it and added – each at its share of the wet `mix` (#354). Ping-pong sends the sound into the left + * of the line only and swaps the sides on every round. */ function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes { const input = context.createGain(); const output = context.createGain(); - input.connect(output); + const shares = delayShares(stage.mix); + const dry = gainNode(context, shares.dry); + input.connect(dry).connect(output); const line = context.createDelay(DELAY_MAX); line.delayTime.value = stage.time; const damp = context.createBiquadFilter(); @@ -2363,7 +2409,7 @@ function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes { damp.frequency.value = stage.damping; damp.Q.value = passQ('lowpass', FLAT_Q); const feedback = gainNode(context, stage.feedback); - const wet = gainNode(context, stage.mix); + const wet = gainNode(context, shares.echo); line.connect(damp); damp.connect(wet).connect(output); if (stage.pingPong) { @@ -2390,7 +2436,8 @@ function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes { { param: line.delayTime, target: stage.time, even: 'value' }, { param: feedback.gain, target: stage.feedback, even: 'value' }, { param: damp.frequency, target: stage.damping, even: 'ratio' }, - { param: wet.gain, target: stage.mix, even: 'value' }, + { param: wet.gain, target: shares.echo, even: 'value' }, + { param: dry.gain, target: shares.dry, even: 'value' }, ], }; } @@ -2622,25 +2669,33 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] { if (chain.shifted) into.push(chain.pitch); if (chain.chip) into.push(chain.chip.frequency, chain.chip.duty); } - 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, stage.gain); - 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 === 'pan') into.push(stage.pan); - else if (stage.kind === 'compress') - into.push(stage.threshold, stage.knee, stage.ratio, stage.attack, stage.release, stage.gain); - else if (stage.kind === 'limit') into.push(stage.threshold, stage.ceiling, stage.release, stage.autoGain); - else if (stage.kind === 'tremolo' || stage.kind === 'vibrato') into.push(stage.rate, stage.depth); - else if (stage.kind === 'reverb') - into.push(stage.decay, stage.damping, stage.predelay, stage.mix, 1 - stage.mix); - else if (stage.kind === 'saturate') - into.push(driveGain(stage.drive), stage.tone, stage.level, stage.mix, 1 - stage.mix); - else if (stage.kind !== 'envelope') into.push(stage.gain); + for (const stage of chain.stages) stageLevels(stage, into); return into; } +function stageLevels(stage: FxStage, into: number[]): void { + if (stage.kind === 'delay') { + const shares = delayShares(stage.mix); + into.push(stage.time, stage.feedback, stage.damping, shares.echo, shares.dry); + } else if (stage.kind === 'wet') { + for (const inner of stage.stages) stageLevels(inner, into); + into.push(stage.wet, 1 - stage.wet); + } else if (stage.kind === 'pass') into.push(stage.cutoff, stage.q, stage.gain); + 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 === 'pan') into.push(stage.pan); + else if (stage.kind === 'compress') + into.push(stage.threshold, stage.knee, stage.ratio, stage.attack, stage.release, stage.gain); + else if (stage.kind === 'limit') into.push(stage.threshold, stage.ceiling, stage.release, stage.autoGain); + else if (stage.kind === 'tremolo' || stage.kind === 'vibrato') into.push(stage.rate, stage.depth); + else if (stage.kind === 'reverb') + into.push(stage.decay, stage.damping, stage.predelay, stage.mix, 1 - stage.mix); + else if (stage.kind === 'saturate') + into.push(driveGain(stage.drive), stage.tone, stage.level, stage.mix, 1 - stage.mix); + else if (stage.kind !== 'envelope') into.push(stage.gain); +} + export const soundLibrary = new SoundLibrary(); diff --git a/tests/core/envelope.test.ts b/tests/core/envelope.test.ts index 27a73603..ffec204e 100644 --- a/tests/core/envelope.test.ts +++ b/tests/core/envelope.test.ts @@ -116,7 +116,7 @@ describe('a Filter', () => { it('has a gain only as a shelf or a peak, and a slope only where it gets steeper (#318)', () => { const peak = filterIn({ filter: 'peaking', gain: 6, slope: '48' }); const def = (node: GraphNode, fx: SoundFxAsset) => resolveDef(node, fx.graph, NO_LOOKUP)!; - expect(def(peak.filter, peak.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'gain']); + expect(def(peak.filter, peak.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'gain', 'wet']); expect(SoundFxFilter.idleParams!(peak.filter.params)).toEqual(['slope']); expect(fxChainOf(compileSoundFx(peak.fx.graph).compiled!).stages[0]).toMatchObject({ stages: 1, @@ -124,7 +124,7 @@ describe('a Filter', () => { }); const notch = filterIn({ filter: 'notch', gain: 6, slope: '48' }); - expect(def(notch.filter, notch.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q']); + expect(def(notch.filter, notch.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'wet']); expect(SoundFxFilter.idleParams!(notch.filter.params)).toEqual([]); // A gain typed in before the type changed does nothing. expect(fxChainOf(compileSoundFx(notch.fx.graph).compiled!).stages[0]).toMatchObject({ diff --git a/tests/core/instrument.test.ts b/tests/core/instrument.test.ts index 4905fc7c..289a8c3a 100644 --- a/tests/core/instrument.test.ts +++ b/tests/core/instrument.test.ts @@ -403,6 +403,46 @@ describe('compiling a note', () => { expect(callProblem(through, lookup, f.instrument.id)).toBe('No sound FX picked'); }); + it('binds the wet of a sound FX it sends a sound through, of a blend and of a Pitch (#354)', () => { + const f = makeInstrument(); + nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id; + // The FX: Input, a Gain and a Pitch, both at the wet of its parameter "blend", Output. + const made = new CreateAssetCommand('soundfx', 'Half'); + f.stack.execute(made); + const fx = made.asset as SoundFxAsset; + const fxEdit = graphEditor(f, fx as unknown as BehaviorAsset); + const input = fxEdit.addNode('SoundFxInput'); + f.stack.execute(new AddPortCommand(fx.id, input.id, { key: 'blend', kind: 'number' })); + f.stack.execute(new SetPortDefaultCommand(fx.id, input.id, 'blend', 0.5)); + const gain = fxEdit.addNode('SoundFxGain', { gain: -6 }); + const pitch = fxEdit.addNode('SoundFxPitch', { semitones: 12 }); + const fxOut = fxEdit.addNode('SoundFxOutput'); + fxEdit.wire([ + [input, 'out', gain, 'in'], + [input, 'blend', gain, 'wet'], + [gain, 'out', pitch, 'in'], + [input, 'blend', pitch, 'wet'], + [pitch, 'out', fxOut, 'in'], + ]); + const sample = nodeOf(f, 'InstrumentSample'); + const output = nodeOf(f, 'InstrumentOutput'); + f.instrument.graph.edges = f.instrument.graph.edges.filter((e) => e.to.node !== output.id); + const through = f.edit.addNode('InstrumentSoundFx', { fx: fx.id }); + f.edit.wire([ + [sample, 'out', through, 'in'], + [through, 'out', output, 'in'], + ]); + const params = f.edit.addNode('InstrumentParameters'); + f.stack.execute(new AddPortCommand(f.instrument.id, params.id, { key: 'dry', kind: 'number' })); + f.edit.connect(params, 'dry', through, 'blend'); + const bound = { value: 0, param: 'dry', min: 0, max: 1 }; + const played = way(compile(f, 60).layers[0])!; + expect(played.stages).toEqual([ + { kind: 'wet', wet: bound, stages: [{ kind: 'gain', db: { value: -6 } }] }, + ]); + expect(played.pitch).toEqual([{ value: 12, times: bound }]); + }); + it('keeps the curve of a Mix inside a sound FX it sends a sound through', () => { const f = makeInstrument(); nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id; diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index d40bc8f8..aa6a5f7c 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -25,7 +25,9 @@ import { type FxStage, fxTailOf, type FxVoice, + delayShares, impulsesOf, + SOUND_FX_NODES, SoundFxReverb, stageLatency, } from '../../src/core/soundfx/nodes'; @@ -582,6 +584,162 @@ describe('a Reverb in a sound FX graph (#328)', () => { }); }); +describe('Bypass and Wet (#354)', () => { + /** Every effect, and how it says its wet: a row of its own, or the "mix" it had. */ + const EFFECTS: Record = { + SoundFxEq: 'wet', + SoundFxBitcrusher: 'mix', + SoundFxCompressor: 'wet', + SoundFxDelay: 'mix', + SoundFxFilter: 'wet', + SoundFxFlanger: 'mix', + SoundFxGain: 'wet', + SoundFxLimiter: 'wet', + SoundFxPan: 'wet', + SoundFxPhaser: 'mix', + SoundFxPitch: 'wet', + SoundFxReverb: 'mix', + SoundFxSaturation: 'mix', + SoundFxTremolo: 'wet', + SoundFxVibrato: 'wet', + }; + const resolvedInputs = (type: string) => { + const f = line([{ type }]); + const node = f.fx.graph.nodes.find((n) => n.type === type)!; + return resolveDef(node, f.fx.graph, graphLookup(f.project))!.inputs; + }; + const stagesOf = (type: string, params: Record = {}) => + compileSoundFx(line([{ type, params }]).fx.graph).compiled!.stages; + + it('are on every effect, the wet of six of them the "mix" they had, and on nothing else', () => { + for (const def of SOUND_FX_NODES) { + const wet = EFFECTS[def.type]; + const inputs = wet ? resolvedInputs(def.type) : def.inputs; + expect([def.type, def.params.some((p) => p.key === 'bypass')]).toEqual([def.type, wet !== undefined]); + expect([def.type, inputs.filter((p) => p.label === 'Wet').map((p) => p.key)]).toEqual([ + def.type, + wet ? [wet] : [], + ]); + expect([def.type, inputs.some((p) => p.label === 'Mix')]).toEqual([ + def.type, + def.type === 'SoundFxMix', + ]); + } + expect(Object.keys(EFFECTS)).toHaveLength(15); + }); + + it('bypassed, an effect is left out as if it were not there, and its node is drawn faded', () => { + for (const type of Object.keys(EFFECTS)) { + const on = { gain: -6, semitones: 5, low: 3, pan: 1, depth: 50 }; + expect([type, stagesOf(type, on).length > 0 || type === 'SoundFxPitch']).toEqual([type, true]); + expect([ + type, + compileSoundFx(line([{ type, params: { ...on, bypass: true } }]).fx.graph).compiled, + ]).toEqual([type, EMPTY_PLAN]); + const def = SOUND_FX_NODES.find((d) => d.type === type)!; + expect([def.dimmed?.({ bypass: true }), def.dimmed?.({})]).toEqual([true, false]); + expect([def.hiddenParams?.({ bypass: true }), def.hiddenParams?.({})]).toEqual([[], ['bypass']]); + } + }); + + it('blends what a node adds with what arrives, all of it at 1 and none at a typed-in 0', () => { + expect(stagesOf('SoundFxGain', { gain: -6 })).toEqual([{ kind: 'gain', db: { value: -6 } }]); + expect(stagesOf('SoundFxGain', { gain: -6, wet: 0 })).toEqual([]); + expect(stagesOf('SoundFxGain', { gain: -6, wet: 0.25 })).toEqual([ + { kind: 'wet', wet: { value: 0.25 }, stages: [{ kind: 'gain', db: { value: -6 } }] }, + ]); + // Every band of an EQ in one blend, not each band on its own. + const eq = stagesOf('SoundFxEq', { low: 6, high: -3, wet: 0.5 }); + expect(eq).toMatchObject([{ kind: 'wet', stages: [{ filter: 'lowshelf' }, { filter: 'highshelf' }] }]); + // What came before the node stays outside the blend. + const f = line([ + { type: 'SoundFxGain', params: { gain: 3 } }, + { type: 'SoundFxPan', params: { pan: 1, wet: 0.5 } }, + ]); + expect(compileSoundFx(f.fx.graph).compiled!.stages).toMatchObject([ + { kind: 'gain' }, + { kind: 'wet', stages: [{ kind: 'pan' }] }, + ]); + }); + + it('takes its wet from a parameter and keeps the blend for it, also at 0 or 1 (#315)', () => { + const f = line([{ type: 'SoundFxCompressor' }]); + const compressor = f.fx.graph.nodes.find((n) => n.type === 'SoundFxCompressor')!; + f.input.ports = [{ key: 'blend', kind: 'number', default: 0.5 }]; + f.edit.connect(f.input, 'blend', compressor, 'wet'); + const plan = compileSoundFx(f.fx.graph).compiled!; + expect(fxChainOf(plan).stages).toMatchObject([{ kind: 'wet', wet: 0.5, stages: [{ kind: 'compress' }] }]); + expect(fxChainOf(plan, { blend: 0 }).stages).toMatchObject([{ kind: 'wet', wet: 0 }]); + expect(fxChainOf(plan, { blend: 7 }).stages).toMatchObject([{ kind: 'wet', wet: 1 }]); + }); + + it('a blend is as late as what it blends and rings as long', () => { + const [limit] = fxChainOf({ pitch: [], stages: stagesOf('SoundFxLimiter', { wet: 0.5 }) }).stages; + expect(limit).toMatchObject({ kind: 'wet' }); + expect(stageLatency(limit!, 48000)).toBe(0.005); + expect(fxRings({ pitch: [], stages: stagesOf('SoundFxLimiter', { wet: 0.5 }) })).toBe(true); + expect(fxRings({ pitch: [], stages: stagesOf('SoundFxTremolo', { wet: 0.5 }) })).toBe(false); + }); + + it('the wet of a Filter comes after the gain a shelf adds to it', () => { + const f = line([{ type: 'SoundFxFilter', params: { filter: 'lowshelf' } }]); + const filter = f.fx.graph.nodes.find((n) => n.type === 'SoundFxFilter')!; + const keys = () => resolveDef(filter, f.fx.graph, graphLookup(f.project))!.inputs.map((p) => p.key); + expect(keys()).toEqual(['in', 'cutoff', 'q', 'gain', 'wet']); + filter.params.filter = 'lowpass'; + expect(keys()).toEqual(['in', 'cutoff', 'q', 'wet']); + }); + + it('the wet of a Pitch takes its semitones from 0 up to all of them', () => { + expect( + pitchOf( + fxChainOf( + compileSoundFx(line([{ type: 'SoundFxPitch', params: { semitones: 12, wet: 0.5 } }]).fx.graph) + .compiled!, + ), + ), + ).toBe(6); + expect( + compileSoundFx(line([{ type: 'SoundFxPitch', params: { semitones: 12, wet: 0 } }]).fx.graph).compiled, + ).toEqual(EMPTY_PLAN); + + const f = line([{ type: 'SoundFxPitch', params: { semitones: 12 } }]); + const pitch = f.fx.graph.nodes.find((n) => n.type === 'SoundFxPitch')!; + f.input.ports = [ + { key: 'up', kind: 'number', default: 7 }, + { key: 'blend', kind: 'number', default: 1 }, + ]; + f.edit.connect(f.input, 'blend', pitch, 'wet'); + const plan = compileSoundFx(f.fx.graph).compiled!; + expect(pitchOf(fxChainOf(plan))).toBe(12); + expect(pitchOf(fxChainOf(plan, { blend: 0.25 }))).toBe(3); + expect(fxChainOf(plan, { blend: 0 })).toMatchObject({ pitch: 0, shifted: true }); + f.edit.connect(f.input, 'up', pitch, 'semitones'); + const both = compileSoundFx(f.fx.graph).compiled!; + expect(pitchOf(fxChainOf(both, { up: -10, blend: 0.5 }))).toBe(-5); + }); + + it('a Delay crossfades: the sound alone at 0, both in full at 0.5, the echoes alone at 1', () => { + expect([0, 0.25, 0.5, 0.75, 1].map(delayShares)).toEqual([ + { dry: 1, echo: 0 }, + { dry: 1, echo: 0.5 }, + { dry: 1, echo: 1 }, + { dry: 0.5, echo: 1 }, + { dry: 0, echo: 1 }, + ]); + // An echo at wet 0.25 is half as loud as at 0.5, so one fewer of them is still heard. + const tail = (mix: number) => + fxTailOf({ + pitch: 0, + shifted: false, + stages: [{ kind: 'delay', time: 1, feedback: 0.5, damping: 20000, mix, pingPong: false }], + }); + expect(tail(0.5)).toBe(10); + expect(tail(0.25)).toBe(9); + expect(tail(1)).toBe(10); + }); +}); + 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 6b3c5d45..19eeffb7 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -1014,14 +1014,15 @@ describe('the sound library', () => { expect(clocks).toHaveLength(0); }); - it('wires an echo: the sound goes on, the line feeds back through the damping, added at mix', async () => { + it('wires an echo: the sound goes on, the line feeds back through the damping, each at its share of wet', async () => { await play(new SoundLibrary(), FILE, {}, { pitch: 0, stages: [echo(0.4)] }); expect(delays).toMatchObject([{ max: 2, delayTime: { value: 0.25 } }]); expect(filters.map((f) => [f.type, f.frequency.value])).toEqual([['lowpass', 20000]]); expect(filters[0]!.Q.value).toBeCloseTo(-3.0103, 3); expect(wires).toEqual([ 'source > gain', // the source through its fade - 'gain > gain', // the sound on, as it is + 'gain > gain', // the sound on, at its share + 'gain > gain', 'delay > filter', 'filter > gain', // into the mix 'gain > gain', // and out @@ -1032,8 +1033,9 @@ describe('the sound library', () => { 'gain > gain', ...INTO_MASTER, ]); - // The voice, the fade made before it, the echo's in, out, feedback and mix, then the master's two. - expect(gains.map((g) => g.value)).toEqual([1, 1, 1, 1, 0.4, 0.5, 1, 1]); + // The voice, the fade made before it, the echo's in, out, sound, feedback and echoes, then the + // master's two. Wet 0.5 (#354): the sound and the echoes both in full. + expect(gains.map((g) => g.value)).toEqual([1, 1, 1, 1, 1, 0.4, 1, 1, 1]); }); it('wires a ping-pong echo into the left only, crossing sides on every round', async () => { @@ -1190,9 +1192,10 @@ describe('the sound library', () => { library.tune(voice, { time: 0.5, dull: 1, wet: 0.2 }, 2); expect(delays[0]!.delayTime.ramps).toEqual([['linear', 0.5, 2]]); expect(filters[0]!.frequency.ramps).toEqual([['exponential', expect.closeTo(500), 2]]); - // The mix of the echo, made before the master's two. - expect(gains.at(-3)!.ramps).toEqual([['linear', 0.2, 2]]); - expect(library.levels(FILE)).toEqual([[0.25, 0.4, 20000, 0.5]]); + // The echoes, made before the master's two: wet 0.2 has them at 0.4, the sound stays in full. + expect(gains.at(-3)!.ramps).toEqual([['linear', 0.4, 2]]); + expect(gains.at(-5)!.ramps).toEqual([]); + expect(library.levels(FILE)).toEqual([[0.25, 0.4, 20000, 1, 1]]); }); }); }); @@ -2478,3 +2481,73 @@ describe('the stages of #318 to #328', () => { expect(library.tail(voice)).toBeCloseTo(1.6, 9); }); }); + +describe('Wet (#354)', () => { + const compress: FxStage = { + kind: 'compress', + threshold: -24, + knee: 6, + ratio: 4, + attack: 0.003, + release: 0.25, + gain: 1, + }; + + it('blends what a node adds with the sound as it came in, held back as far as the node is late', () => { + const context = fakeContext() as unknown as BaseAudioContext; + const made = stageNodes(context, { kind: 'wet', wet: 0.3, stages: [compress] }); + expect(delays.map((delay) => delay.delayTime.value)).toEqual([0.006]); + expect(wires).toEqual([ + 'compressor > gain', // the node, made first + 'gain > compressor', // in, through it + 'gain > gain', // at its wet + 'gain > gain', // and out + 'gain > delay', // the sound as it came in, held back + 'delay > gain', // at the rest + 'gain > gain', + ]); + expect(made.knobs.slice(-2).map((knob) => [knob.target, knob.even])).toEqual([ + [0.3, 'value'], + [expect.closeTo(0.7, 9), 'value'], + ]); + expect(made.knobs).toHaveLength(8); + }); + + it('an Envelope moving the wet moves both shares, the rest as 1 - wet', () => { + const context = fakeContext() as unknown as BaseAudioContext; + const followed: [AudioParam, number | undefined][] = []; + const envelope = { id: 'e', attack: 0.1, decay: 0.1, sustain: 0.5, release: 0.1 }; + const made = stageNodes( + context, + { kind: 'wet', wet: 0, wetMod: { envelope, from: 0, to: 1 }, stages: [{ kind: 'pan', pan: 1 }] }, + (_mod, param, _ratio, map) => followed.push([param, map?.(0.25)]), + ); + const [wet, dry] = made.knobs.slice(-2); + expect(wet!.param).toBeNull(); + expect(dry!.param).toBeNull(); + expect(followed.map(([, mapped]) => mapped)).toEqual([undefined, 0.75]); + expect(followed.map(([param]) => param.value)).toEqual([0, 0]); + }); + + it('a wet from a parameter glides both shares while the voice plays', async () => { + const library = new SoundLibrary(); + const plan: FxPlan = { + pitch: [], + stages: [ + { + kind: 'wet', + wet: { value: 1, param: 'w', min: 0, max: 1 }, + stages: [{ kind: 'pan', pan: { value: 1 } }], + }, + ], + }; + const voice = await play(library, FILE, {}, { plan, params: {} }); + expect(library.levels(FILE)).toEqual([[1, 1, 0]]); + library.tune(voice, { w: 0.25 }, 2); + const ramps = gains.flatMap((gain) => gain.ramps); + expect(ramps).toEqual([ + ['linear', 0.25, 2], + ['linear', 0.75, 2], + ]); + }); +}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index f53bf592..78769889 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -436,9 +436,10 @@ export async function sound(t) { graph.edges = graph.edges.filter((e) => !['fx-e5', 'fx-e6', 'fx-e7'].includes(e.id)); graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`); // #324, #327: a ping-pong Delay behind the gain. The blip is a quarter of a second, its echoes come - // 0.3 s apart, each half as loud as the one before: nine are heard, 2.7 s – the voice - // stays that long after its sound and goes then. Asked to let ring, only the sound - // stops and the voice stays; a stop after that cuts it. + // 0.3 s apart, each half as loud as the one before: at wet 0.5 the first is in full + // (#354) and ten are heard, 3 s – the voice stays that long after its sound and goes + // then. Asked to let ring, only the sound stops and the voice stays; a stop after that + // cuts it. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify( @@ -466,11 +467,12 @@ export async function sound(t) { 'a Delay keeps the voice after its sound has ended, until its echoes have faded', delayInfo === '3 effects' && echoStarted.last?.stages === 2 && - echoKnobs.length === 4 && + echoKnobs.length === 5 && Math.abs(echoKnobs[0] - 0.3) < 1e-6 && Math.abs(echoKnobs[1] - 0.5) < 1e-6 && Math.abs(echoKnobs[2] / (20000 * 0.025 ** 0.3) - 1) < 1e-3 && - Math.abs(echoKnobs[3] - 0.5) < 1e-6 && + Math.abs(echoKnobs[3] - 1) < 1e-6 && + Math.abs(echoKnobs[4] - 1) < 1e-6 && ringing.playing === 1 && ringing.clock - startedAt < 2.4 && quiet.playing === 0 && @@ -494,6 +496,25 @@ export async function sound(t) { cut.stopped === beforeLetRing.stopped + 2, JSON.stringify({ before: beforeLetRing.stopped, letRing, cut }), ); + // #354: the Delay bypassed is drawn faded and says so, and the voice plays as if it were not there. + await mutate(` + const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; + graph.nodes.find((n) => n.id === 'fx-delay').params.bypass = true;`); + const bypassed = await json( + "({ alpha: __gm.soundfx.nodeAlpha('fx-delay'), settings: __gm.soundfx.nodeSettings('fx-delay'), gain: __gm.soundfx.nodeAlpha('fx-gain') })", + ); + const beforeBypass = await stats(); + await click(fxPlayAt.x, fxPlayAt.y); + const heardBypass = await waitFor(stats, (s) => s.started > beforeBypass.started); + await evaluate('__gm.sound.stopAll()'); + check( + 'a bypassed Delay is drawn faded with "Bypass: true", and the voice passes only the gain', + bypassed.alpha < 0.5 && + bypassed.gain === 1 && + bypassed.settings?.includes('Bypass: true') && + heardBypass.last?.stages === 1, + JSON.stringify({ bypassed, last: heardBypass.last }), + ); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-delay'); @@ -719,13 +740,13 @@ export async function sound(t) { JSON.stringify(offline), ); // #327: the player's own echo, rendered offline. A 10 ms burst into a ping-pong Delay (0.3 s, - // feedback 0.5, mix 0.5) comes back left at 0.3 s at half its level, right at 0.6 s at + // feedback 0.5, wet 0.25 – the mix 0.5 of before #354) comes back left at 0.3 s at half its level, right at 0.6 s at // half of that, left again at 0.9 s – in Chrome each round 128 samples later than that. Then // 64 stereo voices of it for 2 s, timed, for how many this machine plays at once (#324). const echo = await evaluate(` (async () => { const RATE = 48000; - const echoOf = (pingPong) => ({ kind: 'delay', time: 0.3, feedback: 0.5, damping: 20000, mix: 0.5, pingPong }); + const echoOf = (pingPong) => ({ kind: 'delay', time: 0.3, feedback: 0.5, damping: 20000, mix: 0.25, pingPong }); const render = async (voices, seconds, stage = echoOf(true)) => { const context = new OfflineAudioContext(2, RATE * seconds, RATE); const burst = context.createBuffer(1, RATE * 0.01, RATE); @@ -1196,6 +1217,57 @@ export async function sound(t) { JSON.stringify(reverbed.sound) === JSON.stringify([0.3 * 48000, 0.3 * 48000]), JSON.stringify(reverbed), ); + // #354: Wet rendered offline. A steady 0.5 through a Gain of 0 at wet 0.25 is 0.375. A Delay of + // 0.1 s without feedback on it: at wet 0.25 the sound in full and the echo at half, at 0.75 + // the sound at half and the echo in full, at 1 the echo alone. A Compressor at 1:1 blended + // half and half at 1083 Hz – where its 6 ms would cancel – stays as loud: the sound as it + // came in is held back to meet it. + const wetted = await evaluate(` + (async () => { + const RATE = 48000; + const render = async (stage, fill) => { + const context = new OfflineAudioContext(1, RATE / 2, RATE); + const sound = context.createBuffer(1, RATE / 2, RATE); + fill(sound.getChannelData(0)); + const source = context.createBufferSource(); + source.buffer = sound; + const nodes = __gm.sound.stageNodes(context, stage); + source.connect(nodes.input); + nodes.output.connect(context.destination); + source.start(); + return (await context.startRendering()).getChannelData(0); + }; + const steady = (data) => data.fill(0.5); + const sine = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1083.333 * i) / RATE) * 0.5; }; + const at = (out, seconds) => +out[Math.round(seconds * RATE)].toFixed(3); + const delay = async (mix) => { + const out = await render({ kind: 'delay', time: 0.1, feedback: 0, damping: 20000, mix, pingPong: false }, steady); + return [at(out, 0.05), at(out, 0.2)]; + }; + const compress = { kind: 'compress', threshold: 0, knee: 0, ratio: 1, attack: 0.003, release: 0.25, gain: 1 }; + const blended = await render({ kind: 'wet', wet: 0.5, stages: [compress] }, sine); + let sum = 0; + for (let i = RATE / 4; i < RATE / 2; i++) sum += blended[i] ** 2; + return { + gain: at(await render({ kind: 'wet', wet: 0.25, stages: [{ kind: 'gain', gain: 0 }] }, steady), 0.1), + quarter: await delay(0.25), + threeQuarters: await delay(0.75), + echoOnly: await delay(1), + compressed: +(20 * Math.log10(Math.sqrt(sum / (RATE / 4)) / (0.5 * Math.SQRT1_2))).toFixed(2), + }; + })()`); + check( + 'Wet blends a node with the sound as it came in, a Delay crossfades its echo, a late node meets it in step', + wetted.gain === 0.375 && + JSON.stringify([wetted.quarter, wetted.threeQuarters, wetted.echoOnly]) === + JSON.stringify([ + [0.5, 0.75], + [0.25, 0.75], + [0, 0.5], + ]) && + Math.abs(wetted.compressed) < 0.1, + JSON.stringify(wetted), + ); // #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