diff --git a/README.md b/README.md index 29aaf2c1..6310fe01 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2062 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2065 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/instrument/chipPresets.ts b/src/core/instrument/chipPresets.ts index 9a7f3cb0..fbdfe9d4 100644 --- a/src/core/instrument/chipPresets.ts +++ b/src/core/instrument/chipPresets.ts @@ -21,8 +21,10 @@ import { SoundFxPitch, } from '../soundfx/nodes'; import { + ACID_ROWS, CHIP_ROWS, ENVELOPE_ROWS, + INSTRUMENT_ACID, INSTRUMENT_CHIP, INSTRUMENT_ENVELOPE, INSTRUMENT_NOTES, @@ -58,6 +60,7 @@ export type TonePreset = | 'pluck' | 'triangleBass' | 'pluckBass' + | 'acidBass' | 'drumKit'; export type ChipPreset = EffectPreset | TonePreset; @@ -76,6 +79,7 @@ export const TONE_PRESETS: readonly { value: TonePreset; label: string }[] = [ { value: 'pluck', label: 'Pluck' }, { value: 'triangleBass', label: 'Triangle Bass' }, { value: 'pluckBass', label: 'Pluck Bass' }, + { value: 'acidBass', label: 'Acid Bass' }, { value: 'drumKit', label: 'Drum Kit' }, ]; @@ -396,7 +400,7 @@ const ROW = 340; /** * Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide, * a Chip's device (#386) wider, an Envelope's (#388) a little less, a Pitch's and a Mix's (#390) two, - * a Delay's (#393), a Phaser's, a Flanger's (#398) and a Bitcrusher's (#399) four. + * a Delay's (#393), a Phaser's, a Flanger's (#398) and a Bitcrusher's (#399) four, an Acid's (#421) three. */ const COLUMNS_OF: Readonly> = { [SoundFxFilter.type]: 4, @@ -408,6 +412,7 @@ const COLUMNS_OF: Readonly> = { [SoundFxMix.type]: 2, [INSTRUMENT_CHIP]: 5, [INSTRUMENT_ENVELOPE]: 3, + [INSTRUMENT_ACID]: 3, }; /** The column right of a node, past all it takes. */ @@ -711,6 +716,15 @@ const TONES: Record, (s: Sketch, notes: GraphNode sustain: 0.15, release: 0.08, }), + // The TB-303 (#421): a saw through a filter that rings and snaps shut, with a short echo. + acidBass: (s, notes) => { + const acid = s.add(INSTRUMENT_ACID, 1, 0, { + wave: 'saw', + ...held({ cutoff: 300, resonance: 0.8, envMod: 0.6, decay: 0.4, accent: 0.7 }, ACID_ROWS), + }); + s.wire(notes, 'any', acid, 'trigger'); + return s.line(acid, echo(0.75, 0.25, 0.1)); + }, }; /** An instrument preset: every note at its own pitch, the same graph each time it is picked. */ diff --git a/src/core/instrument/compile.ts b/src/core/instrument/compile.ts index 063a0a9a..c735ca11 100644 --- a/src/core/instrument/compile.ts +++ b/src/core/instrument/compile.ts @@ -21,6 +21,7 @@ import { resolveAssetProps } from '../properties/resolve'; import { volumeOf } from '../properties/sounds'; import { soundFxBuildFor, soundFxKey } from '../soundfx/compile'; import { + type FxAcidPlan, type FxAmount, type FxChipPlan, type FxEnvelopePlan, @@ -41,9 +42,12 @@ import { withTaps, } from '../soundfx/nodes'; import { + ACID_ROWS, + acidWave, CHIP_ROWS, chipWave, ENVELOPE_ROWS, + INSTRUMENT_ACID, INSTRUMENT_CHIP, INSTRUMENT_ENVELOPE, INSTRUMENT_NOTES, @@ -210,6 +214,11 @@ export function compileInstrument( if (!trigger || !('on' in trigger) || !trigger.on) return { out: SILENT }; return { out: { plans: [{ pitch: [], stages: [], chip: chipOf(node, note, amounts) }] } }; } + case INSTRUMENT_ACID: { + const trigger = inputs.trigger; + if (!trigger || !('on' in trigger) || !trigger.on) return { out: SILENT }; + return { out: { plans: [{ pitch: [], stages: [], acid: acidOf(node, note, amounts) }] } }; + } case INSTRUMENT_SOUND_FX: { const fx = sources.soundFx(textOf(node.params.fx ?? '')); const arriving = plansAt('in'); @@ -293,6 +302,24 @@ export function compileInstrument( }; } + /** An Acid's numbers, held to its rows (#421): it always plays the note, `tuning` off it. */ + function acidOf(node: GraphNode, played: number, amounts: Readonly>): FxAcidPlan { + const rows = ACID_ROWS; + return { + id: node.id, + wave: acidWave(node.params), + frequency: { value: noteFrequency(played) }, + velocity: { value: 1, param: VELOCITY_PARAM, min: 0, max: 1 }, + tuning: held(amounts.tuning, rows.tuning), + slideTime: held(amounts.slideTime, rows.slideTime), + cutoff: held(amounts.cutoff, rows.cutoff), + resonance: held(amounts.resonance, rows.resonance), + envMod: held(amounts.envMod, rows.envMod), + decay: held(amounts.decay, rows.decay), + accent: held(amounts.accent, rows.accent), + }; + } + const output = graph.nodes.find((n) => n.type === INSTRUMENT_OUTPUT); if (!output) return { layers: [], problems: walk.problems, reason: 'No Output node' }; // Saved while the Output still took any number of sounds: only the first would be heard. diff --git a/src/core/instrument/nodes.ts b/src/core/instrument/nodes.ts index 36032342..8a46c21a 100644 --- a/src/core/instrument/nodes.ts +++ b/src/core/instrument/nodes.ts @@ -11,6 +11,7 @@ import type { NodeDef, PortDef } from '../graph/NodeRegistry'; import { asNumber } from '../graph/values'; import { type PropertyBag, textOf } from '../model/Properties'; import type { FlatDef, PropertyDef } from '../properties/PropertySchema'; +import type { AcidWave } from '../soundfx/acid'; import type { ChipWave } from '../soundfx/chip'; import { type FxChipPlan, soundFxParamPort } from '../soundfx/nodes'; import { HIGHEST_NOTE, heldNote, LOWEST_NOTE, MIDDLE_C, NOTE_OPTIONS, noteName } from './notes'; @@ -344,6 +345,56 @@ export const InstrumentChip: InstrumentNodeDef = { }, }; +export const INSTRUMENT_ACID = 'InstrumentAcid'; + +export const ACID_WAVES: { value: AcidWave; label: string }[] = [ + { value: 'saw', label: 'Saw' }, + { value: 'square', label: 'Square' }, +]; +const ACID_WAVE: PropertyDef = { + key: 'wave', + label: 'Wave', + kind: 'enum', + default: 'saw', + options: ACID_WAVES, +}; + +export function acidWave(params: PropertyBag): AcidWave { + return ACID_WAVES.find((option) => option.value === params[ACID_WAVE.key])?.value ?? 'saw'; +} + +export const ACID_ROWS = { + tuning: chipRow('tuning', 'Tuning (st)', -12, 12, 0.01), + cutoff: chipRow('cutoff', 'Cutoff (Hz)', 30, 12000, 1, 400), + resonance: chipRow('resonance', 'Resonance', 0, 1, 0.01, 0.7), + envMod: chipRow('envMod', 'Env mod', 0, 1, 0.01, 0.5), + decay: chipRow('decay', 'Decay (s)', 0.2, 2, 0.01, 0.5), + accent: chipRow('accent', 'Accent', 0, 1, 0.01, 0.6), + slideTime: chipRow('slideTime', 'Slide time (s)', 0, 0.5, 0.001, 0.06), +} satisfies Record>; + +export const InstrumentAcid: InstrumentNodeDef = { + type: INSTRUMENT_ACID, + label: 'Acid', + category: 'sound', + instrument: true, + description: + 'A bass line in the way of the Roland TB-303, one note at a time. "Wave" is a Saw or a Square, played at the pitch of the note and "tuning" semitones off it, through a resonant low-pass that saturates. "cutoff" is where the filter rests, "resonance" how sharply it rings there, up to just short of a whistle. Every note opens the filter by up to four octaves with "env mod" and lets it fall back in "decay" seconds. A note struck at a velocity of 0.9 or more is accented: louder, its sweep shorter and higher by "accent", and accents close behind each other build up. A note that starts while the one before still plays, or right where it ends, slides: the pitch glides there in "slide time" seconds and nothing is struck anew, so the sweep and the volume run on. Notes that start together all play. The Acid shapes its own volume and ends with its note, faded out in a few milliseconds; an Envelope behind it starts over with every note, also on a slide. An Envelope can move "cutoff", a Pitch moves its frequency. In the graph the node is a device: it draws the filter where it rests and at the top of its sweep.', + inputs: [ + triggerIn, + ...Object.values(ACID_ROWS).map((row): PortDef => + row.key === ACID_ROWS.cutoff.key ? { ...numberIn(row), followsEnvelope: true } : numberIn(row), + ), + ], + outputs: [audioOut], + params: [ACID_WAVE], + // Nothing sweeps the filter: there is nothing for the decay to let fall. + idleParams: (params, fed = () => false) => + !fed('envMod') && !fed('decay') && asNumber(params.envMod ?? ACID_ROWS.envMod.default) <= 0 + ? [ACID_ROWS.decay.key] + : [], +}; + export const InstrumentOutput: InstrumentNodeDef = { type: INSTRUMENT_OUTPUT, label: 'Output', @@ -360,6 +411,7 @@ export const INSTRUMENT_NODES: readonly InstrumentNodeDef[] = [ InstrumentNotes, InstrumentSample, InstrumentChip, + InstrumentAcid, InstrumentParameters, InstrumentSoundFx, InstrumentEnvelope, diff --git a/src/core/soundfx/acid.ts b/src/core/soundfx/acid.ts new file mode 100644 index 00000000..e06509ac --- /dev/null +++ b/src/core/soundfx/acid.ts @@ -0,0 +1,299 @@ +/** + * The Acid of an instrument (#421) sample by sample, as its worklet runs it: a bass line in the way + * of the Roland TB-303. A saw or a square, band-limited, goes through a four-pole ladder low-pass + * that saturates in its feedback. An envelope opens the filter at once and lets it fall in "decay", + * the volume rises in a few milliseconds and dies away slowly. An accented note is louder, its sweep + * shorter and higher, and accents close behind each other build up, as the 303's accent circuit + * charges a capacitor. A note that slides glides to its pitch and strikes nothing anew. + * + * The knobs come as an AudioParam hands them over: one value for the block or one per sample. + */ +export type AcidWave = 'saw' | 'square'; + +export interface AcidShape { + wave: AcidWave; + /** Seconds a slide takes to all but reach its note: 95 % of the way. */ + slideTime: number; +} + +/** Cutoff in Hz, resonance, env mod and accent 0 to 1, decay in seconds, pitch in semitones. */ +export interface AcidKnobs { + cutoff: Float32Array; + resonance: Float32Array; + envMod: Float32Array; + decay: Float32Array; + accent: Float32Array; + pitch: Float32Array; +} + +export interface AcidNote { + /** Hz, with the tuning in it. */ + frequency: number; + accented: boolean; + /** Glides from the note before rather than striking: the envelopes run on. */ + slide: boolean; +} + +/** The velocity from which a note is accented: the piano roll strikes at 0.8 unless told otherwise. */ +export const ACCENT_VELOCITY = 0.9; +const OVERSAMPLE = 2; +/** Octaves the envelope opens the filter by at full env mod, and an accent at full on top. */ +export const ENV_MOD_OCTAVES = 4; +export const ACCENT_OCTAVES = 2; +/** The decay of an accented note, whatever the knob says: the 303's shortest. */ +export const ACCENT_DECAY = 0.2; +/** The feedback of the ladder at full resonance: just short of where it would whistle by itself. */ +export const MOST_FEEDBACK = 3.9; +/** How hard the oscillator drives the filter, and how much of the bass the resonance takes is given back. */ +const DRIVE = 1.4; +const BASS_KEPT = 0.3; +/** Seconds of the volume's rise, and its time constant as it dies away while the note is held. */ +const AMP_ATTACK = 0.003; +const AMP_FALL = 1.6; +/** How much louder an accent makes the top of a note: twice, 6 dB, at full. */ +const ACCENT_LOUDER = 1; +/** The accent's capacitor: time constants as it charges and as it lets go. */ +const ACCENT_CHARGE = 0.02; +const ACCENT_LEAK = 0.25; +/** Where a decay counts as over: 1 % of the sweep. */ +const DECAY_FLOOR = Math.log(100); +const OUTPUT = 0.5; +const LOWEST_HZ = 20; + +export class AcidSynth { + private readonly rate: number; + private phase = 0; + /** log2 of the frequency played now, and of the note it glides to. */ + private octave = 0; + private target = 0; + private sweep = 0; + private amp = 0; + private rising = false; + private accented = false; + private charge = 0; + private struck = false; + private readonly stages = new Float64Array(4); + private readonly glide: number; + private readonly rise: number; + private readonly fall: number; + private readonly chargeStep: number; + private readonly leakStep: number; + private decayFor = -1; + private decayStep = 0; + + constructor( + private readonly shape: AcidShape, + sampleRate: number, + ) { + this.rate = sampleRate * OVERSAMPLE; + const step = (seconds: number) => (seconds > 0 ? 1 - Math.exp(-1 / (seconds * this.rate)) : 1); + // 95 % of the way in the slide time is three time constants. + this.glide = step(Math.max(0, shape.slideTime) / 3); + this.rise = step(AMP_ATTACK / 3); + this.fall = Math.exp(-1 / (AMP_FALL * this.rate)); + this.chargeStep = step(ACCENT_CHARGE); + this.leakStep = step(ACCENT_LEAK); + } + + /** Where it stands: the frequency played, the sweep from 1 down, the accent's charge and the volume. */ + get state(): { hz: number; sweep: number; charge: number; amp: number } { + return { hz: 2 ** this.octave, sweep: this.sweep, charge: this.charge, amp: this.amp }; + } + + /** A note from the next sample on. The first a synth plays never slides: there is nothing to glide from. */ + strike(note: AcidNote): void { + this.target = Math.log2(Math.max(LOWEST_HZ, note.frequency)); + this.accented = note.accented; + if (note.slide && this.struck) return; + this.struck = true; + this.octave = this.target; + this.sweep = 1; + this.rising = true; + } + + /** Fills `out` from `from` up to `to`. */ + render(out: Float32Array, from: number, to: number, knobs: AcidKnobs): void { + const { rate } = this; + for (let i = from; i < to; i++) { + const cutoff = finite(at(knobs.cutoff, i), 500); + const resonance = clamp01(finite(at(knobs.resonance, i), 0)); + const envMod = clamp01(finite(at(knobs.envMod, i), 0)); + const accent = clamp01(finite(at(knobs.accent, i), 0)); + const fallStep = this.decayStepOf(this.accented ? ACCENT_DECAY : finite(at(knobs.decay, i), 0.5)); + const pitch = finite(at(knobs.pitch, i), 0); + const k = MOST_FEEDBACK * resonance; + let sum = 0; + for (let n = 0; n < OVERSAMPLE; n++) { + this.octave += (this.target - this.octave) * this.glide; + const hz = Math.min(rate / 2, 2 ** (this.octave + pitch / 12)); + const wave = this.oscillator(hz / rate); + const lift = accent * (this.accented ? 1 : 0); + this.charge += + (lift * this.sweep - this.charge) * + (lift * this.sweep > this.charge ? this.chargeStep : this.leakStep); + const octaves = + envMod * ENV_MOD_OCTAVES * this.sweep + + accent * ACCENT_OCTAVES * this.charge * (0.5 + 0.5 * resonance); + const fc = Math.min(0.45 * rate, Math.max(LOWEST_HZ, cutoff * 2 ** octaves)); + const filtered = this.ladder(wave * DRIVE * (1 + BASS_KEPT * k), fc, k); + if (this.rising) { + this.amp += (1 - this.amp) * this.rise; + if (this.amp >= 0.999) this.rising = false; + } else this.amp *= this.fall; + const level = this.amp * (1 + ACCENT_LOUDER * lift * this.sweep); + sum += filtered * level; + this.sweep *= 1 - fallStep; + } + out[i] = (sum / OVERSAMPLE) * OUTPUT; + } + } + + /** The step per sample a sweep of `seconds` falls by, worked out again only when it changes. */ + private decayStepOf(seconds: number): number { + if (seconds !== this.decayFor) { + this.decayFor = seconds; + const tau = Math.max(1e-3, seconds) / DECAY_FLOOR; + this.decayStep = 1 - Math.exp(-1 / (tau * this.rate)); + } + return this.decayStep; + } + + /** One value of the wave, its corners rounded off by PolyBLEP so it does not alias. */ + private oscillator(step: number): number { + this.phase += step; + if (this.phase >= 1) this.phase -= Math.floor(this.phase); + const p = this.phase; + if (this.shape.wave === 'saw') return 2 * p - 1 - blep(p, step); + const half = (p + 0.5) % 1; + return (p < 0.5 ? 1 : -1) + blep(p, step) - blep(half, step); + } + + /** + * The ladder: four one-pole low-passes in a row, solved without a delay in the loop (Zavalishin's + * topology-preserving transform), the signal into it saturated by `tanh` after the feedback. + */ + private ladder(x: number, fc: number, k: number): number { + const g = Math.tan((Math.PI * fc) / this.rate); + const G = g / (1 + g); + const s = this.stages; + const rest = 1 - G; + const S = G * G * G * s[0]! * rest + G * G * s[1]! * rest + G * s[2]! * rest + s[3]! * rest; + const G4 = G * G * G * G; + let y = Math.tanh((x - k * S) / (1 + k * G4)); + for (let n = 0; n < 4; n++) { + const v = (y - s[n]!) * G; + const out = v + s[n]!; + s[n] = out + v; + y = out; + } + return y; + } +} + +/** The correction PolyBLEP puts on a jump of the wave at phase 0, `step` the phase moved per sample. */ +function blep(p: number, step: number): number { + if (p < step) { + const t = p / step; + return t + t - t * t - 1; + } + if (p > 1 - step) { + const t = (p - 1) / step; + return t * t + t + t + 1; + } + return 0; +} + +/** + * How loud the ladder passes `hz` with its cutoff at `fc` and its resonance at `resonance`, in dB: + * the four poles and their feedback as an analogue filter has them, without the saturation. What a + * device draws (#421). + */ +export function acidResponseDb(hz: number, fc: number, resonance: number): number { + const k = MOST_FEEDBACK * clamp01(resonance); + const w = hz / Math.max(LOWEST_HZ, fc); + // One pole: 1 / (1 + jw). Four of them: (1 + jw)^-4, as a complex number. + let re = 1; + let im = 0; + for (let n = 0; n < 4; n++) { + const r = re - im * w; + im = re * w + im; + re = r; + } + const mag2 = re * re + im * im; + const pr = re / mag2; + const pi = -im / mag2; + const dr = 1 + k * pr; + const di = k * pi; + const h = Math.sqrt((pr * pr + pi * pi) / (dr * dr + di * di)); + return 20 * Math.log10(Math.max(1e-9, h * (1 + BASS_KEPT * k))); +} + +/** + * The notes of one line of an Acid (#421) on the audio thread, each a voice of its own with its + * processor. A note that slides out of the one before plays on the same synth: from its first sample + * on it is the one that hears it, and the note before is silent. The synth runs once per block for + * all of them, whichever asks first, so it makes no difference in which order they are run. + */ +export class AcidLine { + private readonly strikes: { frame: number; owner: Owner; note: AcidNote }[] = []; + private readonly members = new Set(); + private owner: Owner | null = null; + private block = new Float32Array(0); + private blockAt = -1; + /** Who hears the block from where on. */ + private turns: { from: number; owner: Owner | null }[] = []; + + constructor(private readonly synth: AcidSynth) {} + + /** A note of `owner` from the sample `frame` of the audio clock on; one already past is played at once. */ + strike(owner: Owner, frame: number, note: AcidNote): void { + this.members.add(owner); + this.strikes.push({ frame, owner, note }); + this.strikes.sort((a, b) => a.frame - b.frame); + } + + /** A note gone; says whether the line has none left. */ + leave(owner: Owner): boolean { + this.members.delete(owner); + return this.members.size === 0; + } + + /** Fills `out` with the block from `frame` on where `who` is the one heard, and silence elsewhere. */ + play(who: Owner, frame: number, out: Float32Array, knobs: AcidKnobs): void { + if (this.blockAt !== frame || this.block.length !== out.length) this.render(frame, out.length, knobs); + out.fill(0); + this.turns.forEach((turn, i) => { + if (turn.owner !== who) return; + const to = this.turns[i + 1]?.from ?? out.length; + out.set(this.block.subarray(turn.from, to), turn.from); + }); + } + + private render(frame: number, length: number, knobs: AcidKnobs): void { + if (this.block.length !== length) this.block = new Float32Array(length); + this.blockAt = frame; + this.turns = [{ from: 0, owner: this.owner }]; + let done = 0; + while (this.strikes.length > 0 && this.strikes[0]!.frame < frame + length) { + const { frame: due, owner, note } = this.strikes.shift()!; + const at = Math.max(done, Math.min(length, due - frame)); + this.run(done, at, knobs); + done = at; + this.synth.strike(note); + this.owner = owner; + this.turns.push({ from: at, owner }); + } + this.run(done, length, knobs); + } + + private run(from: number, to: number, knobs: AcidKnobs): void { + if (to <= from) return; + if (this.owner === null) this.block.fill(0, from, to); + else this.synth.render(this.block, from, to, knobs); + } +} + +const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!); +const finite = (value: number | undefined, fallback: number) => + value !== undefined && Number.isFinite(value) ? value : fallback; +const clamp01 = (value: number) => Math.max(0, Math.min(1, value)); diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index 1b60ac60..08a91a09 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -4,6 +4,7 @@ import type { NodeDef, PortDef } from '../graph/NodeRegistry'; import { booleanOf, type PropertyBag, textOf } from '../model/Properties'; import { DEFAULT_TEMPO, heldTempo, secondsOfQuarters } from '../music/time'; import type { FlatDef } from '../properties/PropertySchema'; +import { ACCENT_VELOCITY, type AcidShape, type AcidWave } from './acid'; import type { ChipShape, ChipWave } from './chip'; import { COMPRESSOR_LATENCY, compressorMakeup, limiterLatency } from './dynamics'; import { LFO_SHAPES, type LfoShape, vibratoDelay } from './lfo'; @@ -253,6 +254,29 @@ export type FxChipPlan = { wave: ChipWave } & Record< FxAmount >; +/** + * An Acid as a way of a note plays it (#421): the note's frequency with the tuning in it, whether it + * is accented, and its knobs, the cutoff of which an Envelope can move. `id` is its node: the notes + * of one Acid slide on a line of their own. + */ +export interface FxAcid extends AcidShape { + id: string; + frequency: number; + accented: boolean; + cutoff: number; + resonance: number; + envMod: number; + decay: number; + accent: number; + cutoffMod?: FxMod; +} + +/** An Acid with its numbers still open; `velocity` is the note's, which says whether it is accented. */ +export type FxAcidPlan = { id: string; wave: AcidWave } & Record< + 'frequency' | 'tuning' | 'velocity' | 'slideTime' | 'cutoff' | 'resonance' | 'envMod' | 'decay' | 'accent', + FxAmount +>; + /** * A sound on its way through the graph: how many semitones it is played up or down, and what it * passes, in order. `gain` is a factor, a filter's `gain` is in dB as Web Audio takes it. `shifted` @@ -269,6 +293,7 @@ export type FxChain = stages: readonly FxStage[]; source?: FxSource; chip?: FxChip; + acid?: FxAcid; pitchMods?: readonly FxMod[]; } | { mix: number; curve?: MixCurve; a: FxChain; b: FxChain; stages: readonly FxStage[]; mixMod?: FxMod }; @@ -330,7 +355,13 @@ export type FxStagePlan = /** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */ export type FxPlan = - | { pitch: readonly FxAmount[]; stages: readonly FxStagePlan[]; source?: FxSource; chip?: FxChipPlan } + | { + pitch: readonly FxAmount[]; + stages: readonly FxStagePlan[]; + source?: FxSource; + chip?: FxChipPlan; + acid?: FxAcidPlan; + } | { mix: FxAmount; curve?: MixCurve; a: FxPlan; b: FxPlan; stages: readonly FxStagePlan[] }; export const EMPTY_PLAN: FxPlan = { pitch: [], stages: [] }; @@ -1535,6 +1566,7 @@ export function fxChainOf(plan: FxPlan, params: FxParams = {}, tempo = DEFAULT_T stages, ...(pitchMods.length > 0 && { pitchMods }), ...(plan.chip && { chip: chipOf(plan.chip, params) }), + ...(plan.acid && { acid: acidOf(plan.acid, params) }), }; return plan.source ? { ...chain, source: plan.source } : chain; } @@ -1571,6 +1603,24 @@ function chipOf(plan: FxChipPlan, params: FxParams): FxChip { }; } +function acidOf(plan: FxAcidPlan, params: FxParams): FxAcid { + const value = (amount: FxAmount) => amountOf(amount, params); + const cutoffMod = modOf(plan.cutoff, params); + return { + id: plan.id, + wave: plan.wave, + frequency: value(plan.frequency) * 2 ** (value(plan.tuning) / 12), + accented: value(plan.velocity) >= ACCENT_VELOCITY, + slideTime: value(plan.slideTime), + cutoff: value(plan.cutoff), + resonance: value(plan.resonance), + envMod: value(plan.envMod), + decay: value(plan.decay), + accent: value(plan.accent), + ...(cutoffMod && { cutoffMod }), + }; +} + /** The stages that sound on after their input ends: an echo, and what holds the sound back on the worklet. */ const RINGING: ReadonlySet = new Set(['delay', 'limit', 'vibrato', 'reverb']); diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 73f74224..a27069ef 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -3,6 +3,7 @@ import { NameTransitionCommand } from '../../core/commands/graphCommands'; import { soundLibrary } from '../../viewport/SoundLibrary'; import type { Widget } from '../../ui'; import { currentNodePalette } from '../graph/NodePalette'; +import { AcidPanel } from '../soundfx/AcidPanel'; import { ChipPanel } from '../soundfx/ChipPanel'; import { EnvelopePanel } from '../soundfx/EnvelopePanel'; import { CompressorPanel } from '../soundfx/CompressorPanel'; @@ -382,6 +383,93 @@ export function graphHooks({ state, editor }: HookContext) { }; }, }, + /** + * The panel an Acid node shows in the instrument graph (#421): whether it is shown and where, its + * knobs, which settings are idle, the picked wave, what the screen says and the node's ports. + */ + acid: { + panel: (nodeId: string) => { + const canvas = editor.instrumentEditor.canvas; + const panel = canvas.faceOf(nodeId)?.widget; + if (!(panel instanceof AcidPanel)) return null; + const node = canvas.nodeScreen(nodeId); + const ports = (keys: string[], isOutput: boolean) => + Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)])); + const trace = panel.screen.trace; + return { + shown: shownOnScreen(panel), + rect: screenRect(panel), + size: { w: panel.w, h: panel.h }, + node: node?.rect ?? null, + inputs: ports(node?.inputs ?? [], false), + outputs: ports(node?.outputs ?? [], true), + knobs: Object.fromEntries( + Object.entries(panel.knobs).map(([key, knob]) => [ + key, + { value: knob.value, number: knob.shown, enabled: knob.enabled, dial: knob.dialGlobal() }, + ]), + ), + idle: [...panel.idle], + wave: panel.waveButtons.find((b) => b.picked)?.value ?? null, + waves: Object.fromEntries(panel.waveButtons.map((b) => [b.value, screenCentre(b)])), + screen: { ...panel.screen.says, accent: panel.accent.text }, + trace: trace && { + restHz: trace.restHz, + topHz: trace.topHz, + /** The highest the curve at the top of the sweep reaches, in dB. */ + peakDb: Math.max(...trace.top), + columns: trace.top.length, + }, + }; + }, + /** + * Two notes of an Acid played offline by the worklet itself, the second sliding in `slideAt` + * seconds in (#421): the samples each was heard from and to, and how far the sound of both + * together jumps where the second takes over, beside the largest step anywhere else. + */ + render: async (slideAt = 0.1, seconds = 0.3) => { + const rate = 48000; + const frames = Math.round(seconds * rate); + const context = new OfflineAudioContext(2, frames, rate); + await context.audioWorklet.addModule(soundLibrary.fxWorkletUrl()); + const merger = context.createChannelMerger(2); + merger.connect(context.destination); + const knobs = { cutoff: 1200, resonance: 0.7, envMod: 0.5, decay: 0.5, accent: 0, pitch: 0 }; + [ + { slide: false, frequency: 110, startAt: 0 }, + { slide: true, frequency: 165, startAt: slideAt }, + ].forEach((note, i) => { + const node = new AudioWorkletNode(context, 'acid-player', { + numberOfInputs: 0, + outputChannelCount: [1], + parameterData: knobs, + processorOptions: { wave: 'saw', slideTime: 0.06, line: 1, accented: false, ...note }, + }); + node.connect(merger, 0, i); + }); + const rendered = await context.startRendering(); + const [a, b] = [rendered.getChannelData(0), rendered.getChannelData(1)]; + const heard = (data: Float32Array) => { + let first = -1; + let last = -1; + data.forEach((value, i) => { + if (value === 0) return; + if (first < 0) first = i; + last = i; + }); + return { first, last }; + }; + const handover = Math.round(slideAt * rate); + let jump = 0; + let step = 0; + for (let i = 1; i < frames; i++) { + const d = Math.abs(a[i]! + b[i]! - a[i - 1]! - b[i - 1]!); + if (Math.abs(i - handover) <= 1) jump = Math.max(jump, d); + else step = Math.max(step, d); + } + return { a: heard(a), b: heard(b), handover, jump, step }; + }, + }, /** * The panel an Envelope node shows in the instrument graph (#388): whether it is shown and * where, its knobs and fields, which settings are idle, the curve's stages, what the screen says diff --git a/src/editor/soundfx/AcidFace.ts b/src/editor/soundfx/AcidFace.ts new file mode 100644 index 00000000..f41e6e90 --- /dev/null +++ b/src/editor/soundfx/AcidFace.ts @@ -0,0 +1,72 @@ +import { SetNodeParamCommand } from '../../core/commands/graphCommands'; +import type { GraphNode } from '../../core/graph'; +import { acidWave, InstrumentAcid } from '../../core/instrument/nodes'; +import { findGraphAsset } from '../../core/model/Project'; +import type { AcidWave } from '../../core/soundfx/acid'; +import type { NodeFace } from '../graph/nodeFace'; +import type { EditorState } from '../state'; +import { type AcidHost, AcidPanel } from './AcidPanel'; +import { type AcidKey, acidValues } from './acidView'; +import { compressorWired } from './CompressorEditor'; +import { NodeParamDrag } from './nodeParamDrag'; + +/** + * An Acid node drawn as its device (#421): its filter drawn from its settings, every one of them + * worked right in the graph. It listens to nothing, so it has nothing to move between changes. + */ +export class AcidFace implements NodeFace, AcidHost { + readonly widget: AcidPanel; + private readonly drag: NodeParamDrag; + + constructor( + private readonly state: EditorState, + private readonly assetId: string, + private readonly nodeId: string, + ) { + this.widget = new AcidPanel(this); + this.drag = new NodeParamDrag(state, 'acid-face'); + } + + private node(): GraphNode | null { + const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find( + (n) => n.id === this.nodeId, + ); + return node?.type === InstrumentAcid.type ? node : null; + } + + private wired = (key: string): boolean => + compressorWired(this.state.project, this.assetId, this.nodeId, key); + + sync(): void { + const node = this.node(); + if (!node) return; + this.widget.sync({ + values: acidValues(node.params), + wave: acidWave(node.params), + wired: this.wired, + idle: new Set((InstrumentAcid.idleParams?.(node.params, this.wired) ?? []) as AcidKey[]), + }); + } + + tick(): boolean { + return false; + } + + hide(): void {} + + preview(key: AcidKey, value: number): void { + const node = this.node(); + if (node) this.drag.preview(node, key, value); + } + + commit(key: AcidKey, value: number): void { + const node = this.node(); + if (node) this.drag.commit(this.assetId, node, key, value); + } + + pickWave(wave: AcidWave): void { + const node = this.node(); + if (node && acidWave(node.params) !== wave) + this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, 'wave', wave)); + } +} diff --git a/src/editor/soundfx/AcidPanel.ts b/src/editor/soundfx/AcidPanel.ts new file mode 100644 index 00000000..d4731720 --- /dev/null +++ b/src/editor/soundfx/AcidPanel.ts @@ -0,0 +1,281 @@ +/** + * The Acid as a device (#421), what its node shows in the instrument graph: a silver plate in the + * way of the TB-303, a button for each wave, a dark screen that draws the filter where it rests and + * at the top of a note's sweep in acid green, and a chrome knob for each number. + */ +import { Graphics, type Text, type TextStyle } from 'pixi.js'; +import { ACID_ROWS, ACID_WAVES } from '../../core/instrument/nodes'; +import type { AcidWave } from '../../core/soundfx/acid'; +import { crispText, Knob, textStyle, Widget } from '../../ui'; +import { + ACID_GROUPS, + ACID_KEYS, + ACID_KNOBS, + ACID_LAYOUT, + ACID_PANEL, + type AcidKey, + type AcidTrace, + acidTexts, + acidTrace, + type AcidValues, + columnHz, + RESPONSE_FLOOR_DB, + RESPONSE_HIGH_HZ, + RESPONSE_LOW_HZ, + RESPONSE_TOP_DB, +} from './acidView'; +import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel'; +import { type Box, KNOB_CAPTION } from './hardwareView'; +import { PickButton, type PickLook } from './ReverbPanel'; + +/** What the panel asks of the node it sits in. */ +export interface AcidHost { + preview(key: AcidKey, value: number): void; + commit(key: AcidKey, value: number): void; + pickWave(wave: AcidWave): void; +} + +/** What the panel shows of a node. */ +export interface AcidShown { + values: AcidValues; + wave: AcidWave; + wired: (key: AcidKey) => boolean; + /** The settings that do nothing as things stand. */ + idle: ReadonlySet; +} + +/** Brushed silver, as the 303's own panel. */ +const SILVER: PlateLook = { + stops: [ + [0, 0xe8e9e5], + [0.5, 0xd3d5d0], + [1, 0xb2b5ae], + ], + rim: 0x6c7069, + grain: 0x4a4e48, +}; +const WAVE_BUTTONS: PickLook = { face: 0x3a3c3a, hover: 0x4a4c4a, picked: 0xe0552a, shadow: 0x1c1d1c }; +const BEZEL = 0x262826; +const BEZEL_TEXT = 0xc8d6bc; +/** Black glass, the filter at rest in a dull green and at the top of its sweep in acid green. */ +const SCOPE = { + glass: 0x0b120a, + grid: 0x1c2b1a, + rest: 0x5e8f3a, + top: 0xb8ff3c, + text: 0xb8ff3c, + dim: 0x6f9a52, +}; +const IDLE_ALPHA = 0.4; +/** The row of text at the top of the screen. */ +const TEXT_ROW = 14; + +function scopeStyle(fill: number): TextStyle { + const style = textStyle('small').clone(); + style.fill = fill; + style.fontFamily = 'monospace'; + style.fontSize = 9; + style.fontWeight = 'bold'; + return style; +} + +/** The screen: the filter's curve at rest and at the top of a sweep, the room between them lit faintly. */ +class AcidScreen extends Widget { + private readonly g = new Graphics(); + private readonly rest: Text; + private readonly top: Text; + trace: AcidTrace | null = null; + /** What the texts say now (checks). */ + readonly says = { rest: '', top: '' }; + + constructor() { + super(); + this.rest = crispText({ text: '', style: scopeStyle(SCOPE.dim) }); + this.top = crispText({ text: '', style: scopeStyle(SCOPE.text) }); + this.top.anchor.set(1, 0); + for (const t of [this.rest, this.top]) t.eventMode = 'none'; + this.addChild(this.g, this.rest, this.top); + } + + show(trace: AcidTrace, texts: { rest: string; top: string }): void { + this.trace = trace; + this.says.rest = texts.rest; + this.says.top = texts.top; + this.invalidate(); + } + + /** Where the curves are drawn: under the row of text, a little in from the sides. */ + get plot(): Box { + return { x: 6, y: TEXT_ROW + 4, w: this.w - 12, h: this.h - TEXT_ROW - 8 }; + } + + override redraw(): void { + const g = this.g.clear(); + g.roundRect(0, 0, this.w, this.h, 3).fill(SCOPE.glass); + this.rest.text = this.says.rest; + this.top.text = this.says.top; + this.rest.position.set(6, 2); + this.top.position.set(this.w - 6, 2); + const trace = this.trace; + if (!trace) return; + const p = this.plot; + const span = Math.log2(RESPONSE_HIGH_HZ / RESPONSE_LOW_HZ); + const xOfHz = (hz: number) => p.x + (Math.log2(hz / RESPONSE_LOW_HZ) / span) * p.w; + const yOfDb = (db: number) => { + const held = Math.max(RESPONSE_FLOOR_DB, Math.min(RESPONSE_TOP_DB, db)); + return p.y + ((RESPONSE_TOP_DB - held) / (RESPONSE_TOP_DB - RESPONSE_FLOOR_DB)) * p.h; + }; + for (const hz of [100, 1000, 10000]) g.moveTo(xOfHz(hz), p.y).lineTo(xOfHz(hz), p.y + p.h); + for (const db of [0, -24]) g.moveTo(p.x, yOfDb(db)).lineTo(p.x + p.w, yOfDb(db)); + g.stroke({ color: SCOPE.grid, width: 1 }); + const count = trace.rest.length; + const line = (dbs: number[]) => dbs.map((db, i) => ({ x: xOfHz(columnHz(i, count)), y: yOfDb(db) })); + const rest = line(trace.rest); + const top = line(trace.top); + // The room a note's sweep crosses, a strip per column: one shape for all would cross itself + // where the resting curve's peak stands above the other. + for (let i = 1; i < count; i++) + g.poly([top[i - 1]!, top[i]!, rest[i]!, rest[i - 1]!].flatMap((q) => [q.x, q.y])); + g.fill({ color: SCOPE.top, alpha: 0.1 }); + rest.forEach((q, i) => (i === 0 ? g.moveTo(q.x, q.y) : g.lineTo(q.x, q.y))); + g.stroke({ color: SCOPE.rest, width: 1.5, join: 'round' }); + top.forEach((q, i) => (i === 0 ? g.moveTo(q.x, q.y) : g.lineTo(q.x, q.y))); + g.stroke({ color: SCOPE.top, width: 2, join: 'round' }); + } +} + +export class AcidPanel extends Widget { + private readonly plate = new Graphics(); + private readonly bezel = new Graphics(); + private readonly rules = new Graphics(); + readonly screen = new AcidScreen(); + readonly knobs = {} as Record; + readonly waveButtons: PickButton[]; + private readonly captions = {} as Record; + private readonly groupNames: { ink: Text; edge: Text }[]; + private readonly title: { ink: Text; edge: Text }; + private readonly screenName: Text; + /** How far a full accent opens the filter, on the bezel (checks). */ + readonly accent: Text; + /** The settings shown idle now (checks). */ + idle: ReadonlySet = new Set(); + + constructor(host: AcidHost) { + super(); + this.minWidth = ACID_PANEL.w; + this.minHeight = ACID_PANEL.h; + this.addChild(this.plate, this.rules, this.bezel, this.screen); + this.title = engrave(this, 'ACID', 16); + const nameStyle = textStyle('small').clone(); + nameStyle.fill = BEZEL_TEXT; + nameStyle.fontSize = 9; + nameStyle.fontWeight = 'bold'; + nameStyle.letterSpacing = 1; + this.screenName = crispText({ text: 'FILTER OVER A NOTE', style: nameStyle }); + this.screenName.eventMode = 'none'; + this.accent = crispText({ text: '', style: nameStyle }); + this.accent.anchor.set(1, 0); + this.accent.eventMode = 'none'; + this.addChild(this.screenName, this.accent); + this.waveButtons = ACID_WAVES.map((wave) => { + const button = new PickButton(wave.value, wave.label, WAVE_BUTTONS, (picked) => host.pickWave(picked)); + this.addChild(button); + return button; + }); + this.groupNames = ACID_GROUPS.map((group) => engrave(this, group.label, 9)); + for (const key of ACID_KEYS) { + const row = ACID_ROWS[key]; + const look = ACID_KNOBS[key]; + const knob = new Knob('', row.default, { + min: row.min ?? 0, + max: row.max ?? 0, + step: row.step ?? 1, + taper: look.taper, + default: row.default, + decimals: look.decimals, + size: ACID_LAYOUT.knobs[key].size, + look: 'chrome', + typed: false, + ...(look.bipolar && { bipolar: true }), + ...(look.format && { format: look.format }), + ink: INK, + onInput: (value) => host.preview(key, value), + onChange: (value) => host.commit(key, value), + }); + this.knobs[key] = knob; + this.captions[key] = engrave(this, look.label.toUpperCase(), 9); + this.addChild(knob); + } + } + + override measure(): { w: number; h: number } { + return { ...ACID_PANEL }; + } + + sync(shown: AcidShown): void { + for (const key of ACID_KEYS) { + const knob = this.knobs[key]; + knob.setValue(shown.values[key]); + knob.setEnabled(!shown.wired(key)); + const alpha = shown.idle.has(key) ? IDLE_ALPHA : 1; + knob.alpha = alpha; + this.captions[key].ink.alpha = alpha; + } + for (const button of this.waveButtons) button.pick(button.value === shown.wave); + this.idle = shown.idle; + const trace = acidTrace(shown.values); + const texts = acidTexts(trace, shown.values); + this.screen.show(trace, texts); + this.accent.text = texts.accent; + } + + override layout(): void { + const L = ACID_LAYOUT; + this.screen.position.set(L.screen.x, L.screen.y); + this.screen.resize(L.screen.w, L.screen.h); + this.screenName.position.set(L.screen.x, L.bezel.y + 3); + this.accent.position.set(L.screen.x + L.screen.w, L.bezel.y + 3); + this.waveButtons.forEach((button, i) => { + const box = L.waves[i]!; + button.position.set(box.x, box.y); + button.resize(box.w, box.h); + }); + for (const key of ACID_KEYS) { + const spot = L.knobs[key]; + const knob = this.knobs[key]; + const size = knob.measure(); + knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2)); + knob.resize(size.w, size.h); + placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION); + } + L.groups.forEach((group, i) => + placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y), + ); + placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4); + } + + override redraw(): void { + const L = ACID_LAYOUT; + drawPlate(this.plate.clear(), ACID_PANEL, SILVER, L.screws); + const b = L.bezel; + this.bezel + .clear() + .roundRect(b.x, b.y, b.w, b.h, 8) + .fill(BEZEL) + .roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8) + .stroke({ color: 0x151615, width: 1 }); + // A rule each side of a group's name, engraved as the names are. + const r = this.rules.clear(); + L.groups.forEach((group, i) => { + const name = this.groupNames[i]!.ink; + const mid = group.box.y + 6; + const left = group.box.x + 6; + const right = group.box.x + group.box.w - 6; + const gap = name.width / 2 + 6; + const centre = group.box.x + group.box.w / 2; + if (centre - gap > left) r.moveTo(left, mid).lineTo(centre - gap, mid); + if (centre + gap < right) r.moveTo(centre + gap, mid).lineTo(right, mid); + }); + r.stroke({ color: INK, width: 1, alpha: 0.5 }); + } +} diff --git a/src/editor/soundfx/acidView.ts b/src/editor/soundfx/acidView.ts new file mode 100644 index 00000000..95df3ff0 --- /dev/null +++ b/src/editor/soundfx/acidView.ts @@ -0,0 +1,163 @@ +/** + * What an Acid's node draws (#421), as numbers: where the parts of its device sit, how its knobs + * turn, and its filter where it rests and at the top of a note's sweep. Kept apart from the drawing + * so it is checked without a browser. + */ +import { ACID_ROWS } from '../../core/instrument/nodes'; +import type { PropertyBag } from '../../core/model/Properties'; +import { ACCENT_OCTAVES, acidResponseDb, ENV_MOD_OCTAVES } from '../../core/soundfx/acid'; +import { hzText, secondsText, semitoneText } from './chipView'; +import { type Box, type KnobSpot, KNOB_CAPTION, screwsOf, SMALL_DIAL } from './hardwareView'; + +export type AcidKey = keyof typeof ACID_ROWS; +export type AcidValues = Record; + +export const ACID_KEYS = Object.keys(ACID_ROWS) as AcidKey[]; + +/** The numbers of an Acid node, each its row's default where none is typed in. */ +export function acidValues(params: PropertyBag): AcidValues { + const values = {} as AcidValues; + for (const key of ACID_KEYS) { + const value = params[key]; + values[key] = typeof value === 'number' && Number.isFinite(value) ? value : ACID_ROWS[key].default; + } + return values; +} + +export interface AcidKnob { + /** The engraved name above it, its group's name above the group. */ + label: string; + taper: number; + decimals: number; + bipolar?: boolean; + format?: (value: number) => string; +} + +/** How each knob turns: the cutoff gives most of its turn to the low end, the tuning has 0 straight up. */ +export const ACID_KNOBS: Record = { + tuning: { label: 'Tune', taper: 1, decimals: 2, bipolar: true, format: semitoneText }, + cutoff: { label: 'Cutoff', taper: 4, decimals: 0, format: hzText }, + resonance: { label: 'Reso', taper: 1, decimals: 2 }, + envMod: { label: 'Env mod', taper: 1, decimals: 2 }, + decay: { label: 'Decay', taper: 2, decimals: 2, format: secondsText }, + accent: { label: 'Accent', taper: 1, decimals: 2 }, + slideTime: { label: 'Slide', taper: 2, decimals: 3, format: secondsText }, +}; + +/** The knobs as the plate groups them, left to right. */ +export const ACID_GROUPS: readonly { label: string; keys: readonly AcidKey[] }[] = [ + { label: 'FILTER', keys: ['cutoff', 'resonance', 'envMod', 'decay'] }, + { label: 'NOTE', keys: ['tuning', 'accent', 'slideTime'] }, +]; + +// --- The plate. --- + +/** The plate: as the Compressor's (#379), the panel never takes another size. */ +export const ACID_PANEL = { w: 520, h: 280 }; + +const KNOB_SLOT = 64; +const GROUP_GAP = 22; +/** The height of a group's name and its rule. */ +const GROUP_LINE = 16; + +export interface AcidLayout { + title: Box; + waves: Box[]; + /** The dark frame round the screen, and the screen in it. */ + bezel: Box; + screen: Box; + groups: { label: string; box: Box }[]; + knobs: Record; + screws: { x: number; y: number }[]; +} + +function acidLayout(): AcidLayout { + const { w } = ACID_PANEL; + const waves = [0, 1].map((i) => ({ x: 104 + i * 70, y: 13, w: 64, h: 28 })); + const bezel = { x: 24, y: 52, w: w - 48, h: 118 }; + const screen = { x: bezel.x + 10, y: bezel.y + 16, w: bezel.w - 20, h: bezel.h - 24 }; + const rowWidth = + ACID_GROUPS.reduce((sum, group) => sum + group.keys.length * KNOB_SLOT, 0) + + (ACID_GROUPS.length - 1) * GROUP_GAP; + const groupTop = bezel.y + bezel.h + 6; + const dialY = groupTop + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2; + const knobs = {} as Record; + const groups: { label: string; box: Box }[] = []; + let x = (w - rowWidth) / 2; + for (const group of ACID_GROUPS) { + groups.push({ + label: group.label, + box: { x, y: groupTop, w: group.keys.length * KNOB_SLOT, h: GROUP_LINE }, + }); + for (const key of group.keys) { + knobs[key] = { x: x + KNOB_SLOT / 2, y: dialY, size: SMALL_DIAL }; + x += KNOB_SLOT; + } + x += GROUP_GAP; + } + return { + title: { x: 34, y: 14, w: 60, h: 30 }, + waves, + bezel, + screen, + groups, + knobs, + screws: screwsOf(ACID_PANEL), + }; +} + +export const ACID_LAYOUT: AcidLayout = acidLayout(); + +// --- The screen: the filter at rest and at the top of a sweep. --- + +/** The span of the screen: 20 Hz to 20 kHz across, +24 dB to -48 dB down. */ +export const RESPONSE_LOW_HZ = 20; +export const RESPONSE_HIGH_HZ = 20000; +export const RESPONSE_TOP_DB = 24; +export const RESPONSE_FLOOR_DB = -48; + +export interface AcidTrace { + /** dB at each column, from 20 Hz to 20 kHz evenly in octaves, where it rests and at the top. */ + rest: number[]; + top: number[]; + /** Hz of the cutoff at rest, at the top of a plain note's sweep and of a full accent's. */ + restHz: number; + topHz: number; + accentHz: number; +} + +/** The frequency of column `i` of `count`, evenly in octaves from 20 Hz to 20 kHz. */ +export function columnHz(i: number, count: number): number { + return RESPONSE_LOW_HZ * (RESPONSE_HIGH_HZ / RESPONSE_LOW_HZ) ** (i / Math.max(1, count - 1)); +} + +/** + * The filter as a note opens it (#421): at rest, and at the top of its sweep, `env mod` octaves above. + * An accent at full opens it further still, by as much as its capacitor holds at once – the screen + * names how far, the curve shows a plain note. + */ +export function acidTrace(v: AcidValues, columns = 240): AcidTrace { + const restHz = Math.max(1, v.cutoff); + const topHz = restHz * 2 ** (Math.max(0, v.envMod) * ENV_MOD_OCTAVES); + const resonance = Math.max(0, Math.min(1, v.resonance)); + const accentHz = topHz * 2 ** (Math.max(0, v.accent) * ACCENT_OCTAVES * (0.5 + 0.5 * resonance)); + const curve = (fc: number) => + Array.from({ length: columns }, (_, i) => acidResponseDb(columnHz(i, columns), fc, resonance)); + const shown = (hz: number) => Math.min(RESPONSE_HIGH_HZ, hz); + return { + rest: curve(restHz), + top: curve(topHz), + restHz: shown(restHz), + topHz: shown(topHz), + accentHz: shown(accentHz), + }; +} + +/** What the screen writes: where the filter rests, how far a note sweeps it, and an accent. */ +export function acidTexts(trace: AcidTrace, v: AcidValues): { rest: string; top: string; accent: string } { + return { + rest: `REST ${hzText(trace.restHz)} Hz`, + top: v.envMod > 0 ? `SWEEP TO ${hzText(trace.topHz)} Hz` : 'NO SWEEP', + accent: v.accent > 0 ? `ACCENT UP TO ${hzText(trace.accentHz)} Hz` : 'NO ACCENT', + }; +} diff --git a/src/editor/soundfx/nodeFaces.ts b/src/editor/soundfx/nodeFaces.ts index 7e05c274..6c711b92 100644 --- a/src/editor/soundfx/nodeFaces.ts +++ b/src/editor/soundfx/nodeFaces.ts @@ -1,5 +1,10 @@ import type { GraphNode } from '../../core/graph'; -import { InstrumentChip, InstrumentEnvelope, InstrumentSample } from '../../core/instrument/nodes'; +import { + InstrumentAcid, + InstrumentChip, + InstrumentEnvelope, + InstrumentSample, +} from '../../core/instrument/nodes'; import { SoundFxBitcrusher, SoundFxCompressor, @@ -12,6 +17,7 @@ import { } from '../../core/soundfx/nodes'; import type { NodeFace } from '../graph/nodeFace'; import type { EditorState } from '../state'; +import { AcidFace } from './AcidFace'; import { ChipFace } from './ChipFace'; import { CompressorFace } from './CompressorFace'; import { CrusherFace } from './CrusherFace'; @@ -29,9 +35,10 @@ import { ReverbFace } from './ReverbFace'; import { SampleFace } from './SampleFace'; import { SaturationFace } from './SaturationFace'; -/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393, #396–#400), by type. */ +/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393, #396–#400, #421), by type. */ export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null { if (node.type === InstrumentChip.type) return new ChipFace(state, assetId, node.id); + if (node.type === InstrumentAcid.type) return new AcidFace(state, assetId, node.id); if (node.type === InstrumentEnvelope.type) return new EnvelopeFace(state, assetId, node.id); if (node.type === InstrumentSample.type) return new SampleFace(state, assetId, node.id); if (node.type === SoundFxCompressor.type) return new CompressorFace(state, assetId, node.id); diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index f6743740..5bd23b59 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -3,6 +3,7 @@ import { fxChainOf, fxLatencyOf, type FxChain, + type FxAcid, type FxChip, type FxEnvelope, fxRings, @@ -37,7 +38,7 @@ import { type CutRange, cutChannels, cutRange, parseCutName } from '../core/mode import { shifterLatency } from '../core/soundfx/pitchPlayer'; import { REVERB_MAX_DECAY } from '../core/soundfx/reverb'; import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio'; -import type { ChipOptions, PitchOptions, StreamOptions } from './fxWorklet'; +import type { AcidOptions, ChipOptions, PitchOptions, StreamOptions } from './fxWorklet'; import fxWorkletUrl from './fxWorklet.ts?worker&url'; import { bothAtCenter, @@ -85,6 +86,10 @@ const GUARD_RELEASE = 0.1; * sound drops out until enough of them have ended. */ const MAX_VOCODERS = 12; +/** Seconds a note of an Acid may start after the last one ended and still slide out of it (#421). */ +const SLIDE_SLACK = 0.002; +/** The number of the last line of an Acid made, in every library: the worklet's scope keeps them by it. */ +let acidLineCount = 0; /** How finely `peaks` cuts a sound up. */ const PEAK_BUCKETS = 1024; /** Samples the cuts made so far may hold together before the oldest are let go; one is always kept. */ @@ -195,6 +200,9 @@ type Way = Exclude; */ interface Wiring { context: BaseAudioContext; + /** The voice it is for, and who plays it: the notes of an Acid by the same slide on one line (#421). */ + id: number; + player: string; /** When its sources start on the audio clock. */ at: number; buffers: ReadonlyMap; @@ -213,6 +221,8 @@ interface Wiring { knobs: Knob[]; stages: number; vocoders: number; + /** The ways of an Acid that slid out of the note before (#421). */ + slid: number; } /** Everything a voice is started with, the same whether it waits on its bytes first or not. */ @@ -318,6 +328,15 @@ export class SoundLibrary implements AudioSink { private readonly failed = new Set(); private voices: Voice[] = []; private pending: Pending[] = []; + /** + * The last note of each line of an Acid (#421), by who plays it and the Acid: the line it plays + * on, when it starts and until when it is held, and its voice. The next note slides on that line + * where it starts within it or right at its end. + */ + private readonly acidLines = new Map< + string, + { line: number; from: number; until: number; voice: number } + >(); /** The number the next play is given; 0 stays free for "nothing plays". */ private nextId = 1; private started = 0; @@ -1408,7 +1427,9 @@ export class SoundLibrary implements AudioSink { if (waiting) waiting.released = true; const voice = this.voices.find((other) => other.id === id); if (!voice) return; - this.letGo(voice, Math.max(at, this.audio().currentTime), false); + const when = Math.max(at, this.audio().currentTime); + this.holdLines(id, when); + this.letGo(voice, when, false); } /** @@ -2014,7 +2035,14 @@ export class SoundLibrary implements AudioSink { return Math.max(0, ...fx.layers.map((plan) => fxLatencyOf(fxChainOf(plan, fx.params, fx.tempo), rate))); } + /** The lines of an Acid whose last note is this voice are held only until `until` (#421). */ + private holdLines(id: number, until: number): void { + for (const held of this.acidLines.values()) + if (held.voice === id) held.until = Math.min(held.until, until); + } + private forget(voice: Voice): void { + for (const [key, held] of this.acidLines) if (held.voice === voice.id) this.acidLines.delete(key); voice.quiet?.(); voice.quiet = null; this.voices = this.voices.filter((other) => other !== voice); @@ -2090,6 +2118,8 @@ export class SoundLibrary implements AudioSink { const rings = fx.layers.some(fxRings); const wiring: Wiring = { context, + id, + player: `${file}|${bus}|${owner}`, at: when, // Held here, not looked up later: a cut can be let go while its voice plays. buffers: new Map( @@ -2111,6 +2141,7 @@ export class SoundLibrary implements AudioSink { knobs: [], stages: 0, vocoders: 0, + slid: 0, }; chains.forEach((chain, i) => heldBack(context, this.wire(wiring, chain), late - lates[i]!).connect(gain)); gain.connect(this.busInput(bus)); @@ -2126,6 +2157,7 @@ export class SoundLibrary implements AudioSink { at: when, envelopes: wiring.envelopes.size, bus, + slid: wiring.slid, }; const voice: Voice = { id, @@ -2246,9 +2278,13 @@ export class SoundLibrary implements AudioSink { * its volume, and can fade out by itself when its note ends. */ private source(wiring: Wiring, chain: Way): AudioNode { - const sound = chain.chip ? this.chip(wiring, chain, chain.chip) : this.sound(wiring, chain); + const sound = chain.chip + ? this.chip(wiring, chain, chain.chip) + : chain.acid + ? this.acid(wiring, chain, chain.acid) + : this.sound(wiring, chain); const own = chain.source; - if (!wiring.rings && !own && !chain.chip && !wiring.clip) return sound; + if (!wiring.rings && !own && !chain.chip && !chain.acid && !wiring.clip) return sound; const fade = gainNode(wiring.context, own?.volume ?? 1); wiring.sources.at(-1)!.fade = fade; return sound.connect(fade); @@ -2519,6 +2555,74 @@ export class SoundLibrary implements AudioSink { return node; } + /** + * An Acid (#421): a worklet of its own that ends with the note, on the line of its Acid. It slides + * where the last note of that line, by the same player, still sounds when it starts or ends right + * then; notes that start together each start a line. Without the worklet the way is silent. + */ + private acid(wiring: Wiring, { pitch, shifted, pitchMods }: Way, acid: FxAcid): AudioNode { + const { context, knobs } = wiring; + wiring.pitches.push(pitch); + const { cutoff, resonance, envMod, decay, accent, cutoffMod } = acid; + const levels = [cutoff, resonance, envMod, decay, accent]; + if (!this.hasWorklet) { + const silent = context.createBufferSource(); + silent.buffer = context.createBuffer(1, 1, context.sampleRate); + wiring.sources.push({ + start: (at) => silent.start(at), + stop: () => silent.stop(), + ended: (then) => { + silent.onended = then; + }, + fade: null, + gate: true, + leave: null, + }); + if (shifted) knobs.push({ param: null, target: pitch, even: 'value' }); + for (const [i, level] of levels.entries()) + knobs.push({ param: null, target: level, even: i === 0 ? 'ratio' : 'value' }); + return silent; + } + const key = `${wiring.player}|${acid.id}`; + const last = this.acidLines.get(key); + const slide = last !== undefined && last.from < wiring.at && last.until >= wiring.at - SLIDE_SLACK; + const line = slide ? last.line : ++acidLineCount; + this.acidLines.set(key, { line, from: wiring.at, until: Infinity, voice: wiring.id }); + if (slide) wiring.slid += 1; + const options: AcidOptions = { + wave: acid.wave, + slideTime: acid.slideTime, + line, + slide, + frequency: acid.frequency, + accented: acid.accented, + startAt: wiring.at, + }; + const node = new AudioWorkletNode(context, 'acid-player', { + numberOfInputs: 0, + outputChannelCount: [1], + parameterData: { cutoff: cutoffMod ? 0 : cutoff, resonance, envMod, decay, accent, pitch }, + processorOptions: options, + }); + wiring.sources.push({ + start: () => {}, + stop: () => stopProcessor(node), + ended: (then) => endedProcessor(node, then), + fade: null, + gate: true, + leave: null, + }); + const param = (name: string) => node.parameters.get(name)!; + if (shifted) knobs.push({ param: param('pitch'), target: pitch, even: 'value' }); + knobs.push({ param: cutoffMod ? null : param('cutoff'), target: cutoff, even: 'ratio' }); + for (const name of ['resonance', 'envMod', 'decay', 'accent'] as const) + knobs.push({ param: param(name), target: acid[name], even: 'value' }); + const follow = this.follower(wiring); + if (cutoffMod) follow(cutoffMod, param('cutoff'), true); + for (const mod of pitchMods ?? []) follow(mod, param('pitch'), false); + return node; + } + /** * Sends every knob of a voice towards what its values say now, in `time` seconds of the audio * clock along `ease`, or else in a quick glide. Says whether any of them had somewhere to go. @@ -2755,6 +2859,8 @@ interface LastVoice { envelopes: number; /** The bus it went into, `''` for the master (#325). */ bus: string; + /** How many of its Acids slid out of the note before (#421). */ + slid?: number; } /** The stages that run on the worklet. */ @@ -2770,15 +2876,19 @@ 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(onWorklet)) return true; - return 'mix' in plan ? needsWorklet(plan.a) || needsWorklet(plan.b) : plan.pitch.length > 0 || !!plan.chip; + return 'mix' in plan + ? needsWorklet(plan.a) || needsWorklet(plan.b) + : plan.pitch.length > 0 || !!plan.chip || !!plan.acid; } 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. */ +/** Whether a way of a plan plays a Chip (#329) or an Acid (#421), which need no file. */ function hasChip(plan: FxPlan): boolean { - return 'mix' in plan ? hasChip(plan.a) || hasChip(plan.b) : plan.chip !== undefined; + return 'mix' in plan + ? hasChip(plan.a) || hasChip(plan.b) + : plan.chip !== undefined || plan.acid !== undefined; } /** The files the ways of a plan read with a source of their own (#331). */ diff --git a/src/viewport/fxNodes.ts b/src/viewport/fxNodes.ts index f7284daa..38da5370 100644 --- a/src/viewport/fxNodes.ts +++ b/src/viewport/fxNodes.ts @@ -703,6 +703,10 @@ export function levelsOf(chain: FxChain, into: number[] = []): number[] { } else { if (chain.shifted) into.push(chain.pitch); if (chain.chip) into.push(chain.chip.frequency, chain.chip.duty); + if (chain.acid) { + const { cutoff, resonance, envMod, decay, accent } = chain.acid; + into.push(cutoff, resonance, envMod, decay, accent); + } } for (const stage of chain.stages) stageLevels(stage, into); return into; diff --git a/src/viewport/fxWorklet.ts b/src/viewport/fxWorklet.ts index 60d17f66..e7984111 100644 --- a/src/viewport/fxWorklet.ts +++ b/src/viewport/fxWorklet.ts @@ -4,9 +4,10 @@ * glides, ramps and eases like any other knob. A Bitcrusher (#322) runs a `Crusher`, a Chip * (#329) a `ChipPlayer`, a Phaser and a Flanger (#341) theirs, the pitch of a bus (#346) a * `PitchShifter`, a Limiter (#326) a `Limiter`, a Tremolo and a Vibrato (#320) theirs, a streamed - * sound (#349) a `StreamQueue`. Loaded into + * sound (#349) a `StreamQueue`, an Acid (#421) its `AcidLine`. Loaded into * the context once by the sound library; nothing on the page imports it. */ +import { AcidLine, type AcidKnobs, type AcidShape, AcidSynth } from '../core/soundfx/acid'; import { type ChipShape, ChipPlayer } from '../core/soundfx/chip'; import { Crusher } from '../core/soundfx/crusher'; import { Limiter, type LimiterNumbers } from '../core/soundfx/dynamics'; @@ -18,6 +19,7 @@ import { STREAM_RATE, StreamQueue } from '../core/music/streamQueue'; /** What the audio thread has and the page's types do not. */ declare const sampleRate: number; declare const currentTime: number; +declare const currentFrame: number; declare class AudioWorkletProcessor { readonly port: MessagePort; } @@ -373,6 +375,61 @@ class ChipProcessor extends Source { registerProcessor('chip-player', ChipProcessor); +export interface AcidOptions extends AcidShape { + /** The line its note plays on, and whether it slides out of the note before on it. */ + line: number; + slide: boolean; + frequency: number; + accented: boolean; + /** The second of the audio clock its first sample is heard at; before it, silence. */ + startAt: number; +} + +export const ACID_PARAMS = ['cutoff', 'resonance', 'envMod', 'decay', 'accent', 'pitch'] as const; + +/** The lines of the Acids playing (#421), by number: processors share this scope. */ +const acidLines = new Map>(); + +/** + * The Acid of a note (#421): a source of its own on the line of its Acid. One that slides plays on + * the synth of the note before; any other starts a line of its own. + */ +class AcidProcessor extends Stoppable { + private readonly line: AcidLine; + private readonly number: number; + + constructor({ processorOptions }: { processorOptions: AcidOptions }) { + super(); + const { line, slide, frequency, accented, startAt } = processorOptions; + this.number = line; + let played = slide ? acidLines.get(line) : undefined; + if (!played) { + played = new AcidLine(new AcidSynth(processorOptions, sampleRate)); + acidLines.set(line, played); + } + this.line = played; + played.strike(this, Math.round(startAt * sampleRate), { frequency, accented, slide }); + this.port.onmessage = () => this.stop(); + } + + process(_inputs: Float32Array[][], outputs: Float32Array[][], parameters: ParamValues): boolean { + if (this.stopped) { + if (this.line.leave(this) && acidLines.get(this.number) === this.line) acidLines.delete(this.number); + return false; + } + const out = outputs[0]!; + this.line.play(this, currentFrame, out[0]!, parameters as unknown as AcidKnobs); + for (const channel of out.slice(1)) channel.set(out[0]!); + return true; + } + + static get parameterDescriptors() { + return ACID_PARAMS.map((name) => ({ name, defaultValue: 0 })); + } +} + +registerProcessor('acid-player', AcidProcessor); + /** * Says how many channels reach it at most (#340): whether what goes into the master of a pattern * being baked is mono, so its bake is too and goes on the same way. diff --git a/tests/core/acid.test.ts b/tests/core/acid.test.ts new file mode 100644 index 00000000..49920de2 --- /dev/null +++ b/tests/core/acid.test.ts @@ -0,0 +1,275 @@ +/** + * The Acid of an instrument (#421): a TB-303 line rendered sample by sample as its worklet does it, + * the line that lets a note slide out of the one before, and the node that makes it. + */ +import { describe, expect, it } from 'vitest'; +import { compileInstrument, instrumentSourcesOf } from '../../src/core/instrument/compile'; +import { INSTRUMENT_ACID, INSTRUMENT_NOTES, INSTRUMENT_OUTPUT } from '../../src/core/instrument/nodes'; +import { noteFrequency } from '../../src/core/instrument/notes'; +import type { Graph } from '../../src/core/graph/Graph'; +import type { PropertyBag } from '../../src/core/model/Properties'; +import { + ACCENT_VELOCITY, + type AcidKnobs, + AcidLine, + type AcidNote, + acidResponseDb, + type AcidShape, + AcidSynth, +} from '../../src/core/soundfx/acid'; +import { fxChainOf } from '../../src/core/soundfx/nodes'; +import { levelsOf } from '../../src/viewport/fxNodes'; +import { makeProject } from '../helpers'; + +const RATE = 48000; +const BLOCK = 128; +const SAW: AcidShape = { wave: 'saw', slideTime: 0.06 }; + +function knobs(set: Partial> = {}): AcidKnobs { + const one = (value: number) => new Float32Array([value]); + return { + cutoff: one(set.cutoff ?? 500), + resonance: one(set.resonance ?? 0), + envMod: one(set.envMod ?? 0), + decay: one(set.decay ?? 0.5), + accent: one(set.accent ?? 0), + pitch: one(set.pitch ?? 0), + }; +} + +const note = (frequency: number, more: Partial = {}): AcidNote => ({ + frequency, + accented: false, + slide: false, + ...more, +}); + +/** `seconds` of a synth struck with `first`, in blocks; `then` strikes more notes at their second. */ +function render( + set: Partial>, + first: AcidNote, + seconds: number, + then: { at: number; note: AcidNote }[] = [], + shape: AcidShape = SAW, +) { + const synth = new AcidSynth(shape, RATE); + synth.strike(first); + const out = new Float32Array(Math.round(seconds * RATE)); + const k = knobs(set); + const due = [...then]; + let done = 0; + while (done < out.length) { + const next = due[0] ? Math.round(due[0].at * RATE) : Infinity; + const to = Math.min(out.length, done + BLOCK, next); + synth.render(out, done, to, k); + done = to; + if (done === next) synth.strike(due.shift()!.note); + } + return { out, synth }; +} + +/** How loud `hz` is in a stretch of the sound: the Goertzel magnitude through a Hann window, per sample. */ +function energyAt(out: Float32Array, hz: number, from = 0, to = out.length): number { + const w = (2 * Math.PI * hz) / RATE; + const c = 2 * Math.cos(w); + let s1 = 0; + let s2 = 0; + const n = to - from; + for (let i = from; i < to; i++) { + const hann = 0.5 - 0.5 * Math.cos((2 * Math.PI * (i - from)) / (n - 1)); + const s0 = out[i]! * hann + c * s1 - s2; + s2 = s1; + s1 = s0; + } + return Math.sqrt(s1 * s1 + s2 * s2 - c * s1 * s2) / (to - from); +} + +const peak = (out: Float32Array, from = 0, to = out.length) => { + let most = 0; + for (let i = from; i < to; i++) most = Math.max(most, Math.abs(out[i]!)); + return most; +}; + +describe('the Acid synth (#421)', () => { + it('plays a saw at the frequency of its note', () => { + const { out } = render({ cutoff: 8000 }, note(110), 0.5); + const at = energyAt(out, 110, 4800); + expect(at).toBeGreaterThan(energyAt(out, 117, 4800) * 10); + expect(energyAt(out, 220, 4800)).toBeGreaterThan(at * 0.2); + expect(peak(out)).toBeLessThan(1); + }); + + it('takes the highs away below its cutoff', () => { + const open = render({ cutoff: 6000 }, note(110), 0.3).out; + const shut = render({ cutoff: 200 }, note(110), 0.3).out; + const high = (out: Float32Array) => energyAt(out, 1100, 4800); + expect(high(shut)).toBeLessThan(high(open) / 30); + }); + + it('rings at its cutoff the more, the higher the resonance', () => { + // The 8th harmonic of 110 Hz sits right at the cutoff. + const flat = render({ cutoff: 880, resonance: 0 }, note(110), 0.4).out; + const ringing = render({ cutoff: 880, resonance: 0.9 }, note(110), 0.4).out; + const ratio = (out: Float32Array) => energyAt(out, 880, 4800) / energyAt(out, 110, 4800); + expect(ratio(ringing)).toBeGreaterThan(ratio(flat) * 4); + }); + + it('stays finite and in range at full resonance, sweep and accent', () => { + const { out } = render( + { cutoff: 12000, resonance: 1, envMod: 1, accent: 1, decay: 2 }, + note(55, { accented: true }), + 3, + [0.5, 1, 1.5, 2].map((at, i) => ({ at, note: note(55 * 2 ** (i / 3), { accented: true }) })), + ); + expect(out.every(Number.isFinite)).toBe(true); + expect(peak(out)).toBeLessThan(1.5); + }); + + it('sweeps its filter open on every note and lets it fall in the decay', () => { + const { out } = render({ cutoff: 200, envMod: 1, decay: 0.4 }, note(110), 0.6); + const early = energyAt(out, 1100, 480, 2880); + const late = energyAt(out, 1100, 21600, 24000); + expect(early).toBeGreaterThan(late * 10); + }); + + it('makes an accented note louder and builds up on accents close behind each other', () => { + const plain = render({ envMod: 0.5, accent: 1 }, note(110), 0.1).out; + const accented = render({ envMod: 0.5, accent: 1 }, note(110, { accented: true }), 0.1).out; + expect(peak(accented)).toBeGreaterThan(peak(plain) * 1.5); + // A sixteenth at 120 BPM apart: the second accent starts with the first one's charge in it. + const step = 0.125; + const top = 0.04; + const one = render({ accent: 1 }, note(110, { accented: true }), top).synth.state.charge; + const two = render({ accent: 1 }, note(110, { accented: true }), step + top, [ + { at: step, note: note(110, { accented: true }) }, + ]).synth.state.charge; + expect(two).toBeGreaterThan(one * 1.2); + }); + + it('glides to a note it slides to, without striking anew', () => { + const shape = { wave: 'saw' as const, slideTime: 0.06 }; + const { synth } = render({}, note(110), 0.2, [{ at: 0.1, note: note(220, { slide: true }) }], shape); + expect(synth.state.hz).toBeGreaterThan(215); + const midway = render({}, note(110), 0.12, [{ at: 0.1, note: note(220, { slide: true }) }], shape); + expect(midway.synth.state.hz).toBeGreaterThan(130); + expect(midway.synth.state.hz).toBeLessThan(200); + // The sweep runs on: it fell for 0.12 s and was not set back to the top. + expect(midway.synth.state.sweep).toBeLessThan(0.5); + const struck = render({}, note(110), 0.1005, [{ at: 0.1, note: note(220) }], shape); + expect(struck.synth.state.hz).toBeCloseTo(220, 3); + expect(struck.synth.state.sweep).toBeGreaterThan(0.9); + }); + + it('never slides on its first note', () => { + const { synth } = render({}, note(220, { slide: true }), 0.01); + expect(synth.state.hz).toBeCloseTo(220, 3); + expect(synth.state.sweep).toBeGreaterThan(0.9); + }); + + it('draws a low-pass that loses 12 dB at its cutoff and peaks there with resonance', () => { + expect(acidResponseDb(1000, 1000, 0)).toBeCloseTo(-12.04, 1); + expect(acidResponseDb(50, 1000, 0)).toBeCloseTo(0, 1); + expect(acidResponseDb(1000, 1000, 1)).toBeGreaterThan(acidResponseDb(50, 1000, 1) + 12); + expect(acidResponseDb(8000, 1000, 0)).toBeLessThan(-60); + }); +}); + +describe('the line of an Acid (#421)', () => { + /** Two notes on a line, the second sliding in at `slideAt`, played block by block in the order given. */ + function played(slideAt: number, blocks: number, order: 'first' | 'second') { + const line = new AcidLine<'a' | 'b'>(new AcidSynth(SAW, RATE)); + line.strike('a', 0, note(110)); + line.strike('b', slideAt, note(165, { slide: true })); + const a = new Float32Array(blocks * BLOCK); + const b = new Float32Array(blocks * BLOCK); + const k = knobs({ cutoff: 2000 }); + for (let n = 0; n < blocks; n++) { + const frame = n * BLOCK; + const into = (out: Float32Array) => out.subarray(frame, frame + BLOCK); + if (order === 'first') { + line.play('a', frame, into(a), k); + line.play('b', frame, into(b), k); + } else { + line.play('b', frame, into(b), k); + line.play('a', frame, into(a), k); + } + } + return { a, b }; + } + + it('hands the synth to the note that slides in, from its first sample on', () => { + const { a, b } = played(200, 4, 'first'); + expect(peak(a, 0, 200)).toBeGreaterThan(0.05); + expect(peak(a, 200)).toBe(0); + expect(peak(b, 0, 200)).toBe(0); + expect(peak(b, 200)).toBeGreaterThan(0.05); + // One synth: what both hear is what it plays with both notes struck in turn. + const reference = render({ cutoff: 2000 }, note(110), (4 * BLOCK) / RATE, [ + { at: 200 / RATE, note: note(165, { slide: true }) }, + ]).out; + const heard = a.map((value, i) => value + b[i]!); + expect(Array.from(heard)).toEqual(Array.from(reference)); + }); + + it('plays the same whichever note is run first in a block', () => { + const one = played(300, 5, 'first'); + const two = played(300, 5, 'second'); + expect(Array.from(two.a)).toEqual(Array.from(one.a)); + expect(Array.from(two.b)).toEqual(Array.from(one.b)); + }); + + it('is silent before its first note and says when its last one has gone', () => { + const line = new AcidLine<'a'>(new AcidSynth(SAW, RATE)); + line.strike('a', 1000, note(110)); + const out = new Float32Array(BLOCK); + line.play('a', 0, out, knobs()); + expect(peak(out)).toBe(0); + expect(line.leave('a')).toBe(true); + }); +}); + +describe('the Acid node (#421)', () => { + const graph = (params: PropertyBag = {}): Graph => ({ + nodes: [ + { id: 'notes', type: INSTRUMENT_NOTES, x: 0, y: 0, params: {} }, + { id: 'acid', type: INSTRUMENT_ACID, x: 200, y: 0, params }, + { id: 'out', type: INSTRUMENT_OUTPUT, x: 900, y: 0, params: {} }, + ], + edges: [ + { id: 'e1', from: { node: 'notes', port: 'any' }, to: { node: 'acid', port: 'trigger' } }, + { id: 'e2', from: { node: 'acid', port: 'out' }, to: { node: 'out', port: 'in' } }, + ], + }); + + it('plays the note, tuned, with its knobs, and is accented by a hard velocity', () => { + const sources = instrumentSourcesOf(makeProject().project); + const build = compileInstrument(graph({ wave: 'square', tuning: 12, cutoff: 900 }), 60, sources); + expect(build.reason).toBe(''); + const [layer] = build.layers; + const soft = fxChainOf(layer!, { '@velocity': 0.8 }); + const hard = fxChainOf(layer!, { '@velocity': ACCENT_VELOCITY }); + if ('mix' in soft || 'mix' in hard) throw new Error('one way'); + expect(soft.acid).toMatchObject({ + id: 'acid', + wave: 'square', + cutoff: 900, + resonance: 0.7, + envMod: 0.5, + decay: 0.5, + accent: 0.6, + slideTime: 0.06, + accented: false, + }); + expect(soft.acid!.frequency).toBeCloseTo(noteFrequency(72), 6); + expect(hard.acid!.accented).toBe(true); + expect(levelsOf(soft)).toEqual([900, 0.7, 0.5, 0.5, 0.6]); + }); + + it('holds its knobs to their rows', () => { + const sources = instrumentSourcesOf(makeProject().project); + const [layer] = compileInstrument(graph({ cutoff: 99999, resonance: 3, decay: 0 }), 60, sources).layers; + const chain = fxChainOf(layer!, {}); + if ('mix' in chain) throw new Error('one way'); + expect(chain.acid).toMatchObject({ cutoff: 12000, resonance: 1, decay: 0.2 }); + }); +}); diff --git a/tests/core/chip-presets.test.ts b/tests/core/chip-presets.test.ts index dd00f766..f145c409 100644 --- a/tests/core/chip-presets.test.ts +++ b/tests/core/chip-presets.test.ts @@ -66,7 +66,7 @@ describe('a preset (#329)', () => { ...TONE_PRESETS.map((p) => p.value), ...EFFECT_PRESETS.map((p) => p.value), ]); - expect(TONE_PRESETS).toHaveLength(15); + expect(TONE_PRESETS).toHaveLength(16); }); it('of sfxr is a chain from every note through Chip, Envelope, Filter and Bitcrusher, ending by itself', () => { @@ -205,7 +205,8 @@ describe('an instrument preset', () => { const STRUCK = new Set(['bell', 'pluck', 'drumKit']); it('plays every key at its own pitch, without a problem, and holds it unless it is struck', () => { - for (const { value } of TONE_PRESETS.filter((p) => p.value !== 'drumKit')) { + // The Acid Bass plays no Chip (#421): it has a check of its own below. + for (const { value } of TONE_PRESETS.filter((p) => p.value !== 'drumKit' && p.value !== 'acidBass')) { const graph = chipPresetGraph(value); const [c4, c5] = [60, 72].map((note) => build(graph, note)); expect(c4!.reason, value).toBe(''); @@ -240,6 +241,16 @@ describe('an instrument preset', () => { arpeggio: 12, repeat: 0.08, }); + // Acid Bass (#421): an Acid at the pitch of the note, into a Delay. + const acid = [60, 72].map((note) => { + const built = compileInstrument(chipPresetGraph('acidBass'), note, sources()); + expect(built.problems).toEqual({}); + const chain = fxChainOf(built.layers[0]!); + return 'mix' in chain ? null : chain; + }); + expect(acid[1]!.acid!.frequency / acid[0]!.acid!.frequency).toBeCloseTo(2, 6); + expect(acid[0]!.acid).toMatchObject({ wave: 'saw', cutoff: 300, resonance: 0.8 }); + expect(acid[0]!.stages.map((stage) => stage.kind)).toEqual(['delay']); // 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 }); diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index b3fd983a..8a442c94 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -638,6 +638,7 @@ describe('the sound library', () => { layers: 1, pitches: [0], stages: 0, + slid: 0, duration: 1.5, at: 0, envelopes: 0, diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 99cacbfe..3d735a97 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -2920,6 +2920,95 @@ export async function sound(t) { JSON.stringify({ envShown, envTurned, envScrubbed, envUndone, laser: laser.envelope }), ); + // #421: Preset → Acid Bass makes Notes → Acid → Delay → Output, the Acid drawn as a device between + // its ports: a button per wave and a knob per number, each change one undo step, its screen + // drawn from it. The worklet hands the synth to a note that slides in; of three notes the + // second, struck where the first ends, slides, the third after a gap does not. An undo + // brings the Laser back. + await clickHook('presetAt'); + await clickText('Acid Bass'); + await sleep(300); + await clickText('Fit'); + await sleep(300); + const acidGraph = () => + json(`(() => { + const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph; + const acid = graph.nodes.find((n) => n.type === 'InstrumentAcid'); + return { types: graph.nodes.map((n) => n.type), id: acid?.id ?? null, params: acid?.params ?? null }; + })()`); + const acidBuilt = await acidGraph(); + const acidPanel = () => json(`__gm.acid.panel('${acidBuilt.id}')`); + const acidShown = await acidPanel(); + const acidRect = acidShown?.rect ?? { x: 0, y: 0, w: 0, h: 0 }; + const acidBeside = (p) => p.y > acidRect.y && p.y < acidRect.y + acidRect.h; + await click(acidShown.waves.square.x, acidShown.waves.square.y); + const acidSquared = { params: (await acidGraph()).params, panel: await acidPanel() }; + const cutoffDial = acidShown.knobs.cutoff.dial; + await drag(cutoffDial.x, cutoffDial.y, cutoffDial.x, cutoffDial.y - 20); + const acidTurned = { params: (await acidGraph()).params, panel: await acidPanel() }; + for (let i = 0; i < 2; i++) await press('z', { ctrl: true }); + const acidUndone = (await acidGraph()).params; + check( + 'Preset → Acid Bass makes an Acid shown as its device; a wave button and a knob each change it as one undo step, and its screen follows', + acidBuilt.types.join() === 'InstrumentNotes,InstrumentAcid,SoundFxDelay,InstrumentOutput' && + acidShown.shown && + acidShown.size.w === 520 && + acidShown.size.h === 280 && + Object.values(acidShown.inputs).length === 8 && + Object.values(acidShown.inputs).every((p) => p.x < acidRect.x && acidBeside(p)) && + Object.values(acidShown.outputs).every((p) => p.x > acidRect.x + acidRect.w && acidBeside(p)) && + acidShown.wave === 'saw' && + acidShown.knobs.cutoff.value === 300 && + acidShown.screen.rest === 'REST 300 Hz' && + acidShown.trace.peakDb > 6 && + acidSquared.params.wave === 'square' && + acidSquared.panel.wave === 'square' && + acidTurned.params.cutoff > 300 && + acidTurned.panel.screen.rest !== acidShown.screen.rest && + acidUndone.wave === 'saw' && + acidUndone.cutoff === 300, + JSON.stringify({ acidBuilt, acidShown, acidSquared, acidTurned, acidUndone }), + ); + + const acidRender = JSON.parse(await evaluate('__gm.acid.render(0.1, 0.3).then(JSON.stringify)')); + check( + 'the worklet hands the synth of an Acid to the note that slides in, from its first sample on and without a jump', + acidRender.a.first >= 0 && + acidRender.a.first < 4 && + acidRender.a.last < acidRender.handover && + acidRender.b.first === acidRender.handover && + acidRender.b.last === 0.3 * 48000 - 1 && + acidRender.jump > 0 && + acidRender.jump <= acidRender.step, + JSON.stringify(acidRender), + ); + + await evaluate('__gm.sound.stopAll()'); + const beforeLine = await stats(); + await evaluate( + `__gm.music.play(${JSON.stringify({ + notes: [ + { tick: 0, length: 480, note: 48, velocity: 0.8, instrument: insId }, + { tick: 480, length: 480, note: 55, velocity: 0.8, instrument: insId }, + { tick: 1440, length: 480, note: 48, velocity: 1, instrument: insId }, + ], + })}, { tempo: 120 })`, + ); + const acidLine = await trace(stats, (s) => s.started >= beforeLine.started + 3 && s.playing === 0); + await evaluate('__gm.music.stop()'); + const slidOf = (n) => acidLine.find((s) => s.started === beforeLine.started + n)?.last?.slid ?? null; + await press('z', { ctrl: true }); + const acidGone = await acidGraph(); + check( + 'of three Acid notes the one struck where the last ends slides, the one after a gap does not; an undo brings the Laser back', + slidOf(1) === 0 && slidOf(2) === 1 && slidOf(3) === 0 && acidGone.types.join() === laser.types.join(), + JSON.stringify({ + slid: [1, 2, 3].map(slidOf), + started: acidLine.map((s) => s.started), + acidGone: acidGone.types, + }), + ); + const beforeRandom = await stats(); await clickHook('randomizeAt'); const randomized = await waitFor(stats, (s) => s.started > beforeRandom.started);