Rebuild the Acid node after the acid voice of nodeseq (#421)
The first Acid hardly squelched. Its tanh ate the resonance, and its sweep fell evenly in octaves. The user pointed at the acid tone of nodeseq.com/studio, so the node now follows how that voice is built from Web Audio parts. - The filter is a two-pole 12 dB low-pass with Q in dB up to 17.5, like a BiquadFilterNode. The ladder and its tanh are gone. - One ADSR drives both the volume and the filter. The sweep falls back in Hz, fast from the top and long at the bottom. - Every note glides from the one before. A new note cuts the last one off, and the node plays its release itself. - Knobs: cutoff, reso, env, attack, decay, sustain, release and glide. The defaults are the acid preset of nodeseq. - Accent, slide, drive and tuning are dropped. Old projects were not kept, the user had only placed the node for a try. - A hook __gm.acid.compare renders a line offline through our worklet and through the nodeseq graph, for e2e 34. Found on the way: in Chrome an oscillator that starts inside a render block runs its first samples at 440 Hz. The comparison evens this out. Tests: - tsc, eslint, Prettier clean - Unit: acid.test.ts rewritten with 12 tests. chip-presets and sound-library follow the new node. - e2e 34 on melektaus-atx: ALL PASSED, 148 checks. Against nodeseq the loudness per 10 ms window is at most 0.14 dB apart, and 95 to 97 % of the windows are equally bright. - With a linear Q instead of Q in dB the comparison is red. - Gate on melektaus-atx: green, 306 files, 4752 tests. That is 4754 less the 14 old and plus the 12 new Acid tests. - Not checked: the sound by ear, the master chain of nodeseq, the keyboard and a bake with the new node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d844f2734a
commit
f92d69d902
15 files changed
+645
-592
No files matched your search
@@ -716,11 +716,11 @@ const TONES: Record<Exclude<TonePreset, 'drumKit'>, (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.
|
||||
// nodeseq's acid (#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),
|
||||
...held({ cutoff: 250, resonance: 0.78, envMod: 0.62 }, ACID_ROWS),
|
||||
});
|
||||
s.wire(notes, 'any', acid, 'trigger');
|
||||
return s.line(acid, echo(0.75, 0.25, 0.1));
|
||||
|
||||
@@ -302,7 +302,7 @@ export function compileInstrument(
|
||||
};
|
||||
}
|
||||
|
||||
/** An Acid's numbers, held to its rows (#421): it always plays the note, `tuning` off it. */
|
||||
/** An Acid's numbers, held to its rows (#421): it always plays the note, as loud as its velocity. */
|
||||
function acidOf(node: GraphNode, played: number, amounts: Readonly<Record<string, FxAmount>>): FxAcidPlan {
|
||||
const rows = ACID_ROWS;
|
||||
return {
|
||||
@@ -310,13 +310,14 @@ export function compileInstrument(
|
||||
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),
|
||||
attack: held(amounts.attack, rows.attack),
|
||||
decay: held(amounts.decay, rows.decay),
|
||||
accent: held(amounts.accent, rows.accent),
|
||||
sustain: held(amounts.sustain, rows.sustain),
|
||||
release: held(amounts.release, rows.release),
|
||||
glide: held(amounts.glide, rows.glide),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -363,23 +363,28 @@ export function acidWave(params: PropertyBag): AcidWave {
|
||||
return ACID_WAVES.find((option) => option.value === params[ACID_WAVE.key])?.value ?? 'saw';
|
||||
}
|
||||
|
||||
/** The defaults are nodeseq's acid bass: Cutoff 30, Reso 78, Env 62 of its 100, its ADSR and its glide. */
|
||||
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),
|
||||
cutoff: chipRow('cutoff', 'Cutoff (Hz)', 40, 18000, 1, 250),
|
||||
resonance: chipRow('resonance', 'Resonance', 0, 1, 0.01, 0.78),
|
||||
envMod: chipRow('envMod', 'Env mod', 0, 1, 0.01, 0.62),
|
||||
attack: chipRow('attack', 'Attack (s)', 0.001, 2, 0.001, 0.003),
|
||||
decay: chipRow('decay', 'Decay (s)', 0.02, 3, 0.01, 0.16),
|
||||
sustain: chipRow('sustain', 'Sustain', 0, 1, 0.01, 0.15),
|
||||
release: chipRow('release', 'Release (s)', 0.02, 4, 0.01, 0.08),
|
||||
glide: chipRow('glide', 'Glide (s)', 0, 0.5, 0.001, 0.05),
|
||||
} satisfies Record<string, FlatDef<'number'>>;
|
||||
|
||||
/** The numbers an Acid takes as its note plays: the rest it fixes when the note starts. */
|
||||
export const ACID_MOVING = ['cutoff', 'resonance', 'envMod'] as const;
|
||||
|
||||
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.',
|
||||
'An acid bass, one note at a time, as nodeseq.com plays it; its defaults are nodeseq\'s acid. "Wave" is a Saw or a Square at the pitch of the note, through a low-pass with a sharp resonance. "cutoff" is where the filter rests, "resonance" how sharply it rings there. One ADSR shapes the volume and the filter alike: in "attack" seconds the filter opens by up to five octaves with "env mod", in "decay" it falls back to where "sustain" holds it, and when the note ends it closes in "release". Every note strikes anew and glides from the pitch of the note before in "glide" seconds; a note that starts while the one before still sounds cuts it short. Notes that start together all play. The velocity makes it louder or softer. The Acid shapes its own volume and plays out its release, so it needs no Envelope behind it. 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, at the top of its sweep and where the sustain holds it.',
|
||||
inputs: [
|
||||
triggerIn,
|
||||
...Object.values(ACID_ROWS).map((row): PortDef =>
|
||||
@@ -388,9 +393,12 @@ export const InstrumentAcid: InstrumentNodeDef = {
|
||||
],
|
||||
outputs: [audioOut],
|
||||
params: [ACID_WAVE],
|
||||
// Nothing sweeps the filter: there is nothing for the decay to let fall.
|
||||
// At full sustain and without a sweep nothing falls: the decay has nothing to do.
|
||||
idleParams: (params, fed = () => false) =>
|
||||
!fed('envMod') && !fed('decay') && asNumber(params.envMod ?? ACID_ROWS.envMod.default) <= 0
|
||||
!fed('sustain') &&
|
||||
!fed('envMod') &&
|
||||
asNumber(params.sustain ?? ACID_ROWS.sustain.default) >= 1 &&
|
||||
asNumber(params.envMod ?? ACID_ROWS.envMod.default) <= 0
|
||||
? [ACID_ROWS.decay.key]
|
||||
: [],
|
||||
};
|
||||
|
||||
+160
-228
@@ -1,161 +1,177 @@
|
||||
/**
|
||||
* 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 Acid of an instrument (#421) sample by sample, as its worklet runs it: the acid bass of
|
||||
* nodeseq.com, built as its studio builds it from Web Audio nodes and worked here in one. A saw or a
|
||||
* square, band-limited, goes through a two-pole low-pass with a sharp resonance, the one Chrome's
|
||||
* `BiquadFilterNode` is, its Q in dB. One ADSR shapes the volume and the filter alike: in the attack
|
||||
* the cutoff climbs straight to the top of the sweep, in the decay it falls back to where the sustain
|
||||
* holds it, and in the release down to where it rests. It falls in Hz, not in octaves, so it drops
|
||||
* fast from the top and lingers low – what makes it squelch. Every note strikes anew and glides from
|
||||
* the pitch of the note before; a note that starts while the one before still sounds cuts it short,
|
||||
* one at a time.
|
||||
*
|
||||
* The knobs come as an AudioParam hands them over: one value for the block or one per sample.
|
||||
* Cutoff, resonance, env mod and pitch come as an AudioParam hands them over: one value for the
|
||||
* block or one per sample. So do the note's end (`gate` falls to 0) and the cut (`cut` rises to 1),
|
||||
* which the page sets on the audio clock to the sample.
|
||||
*/
|
||||
export type AcidWave = 'saw' | 'square';
|
||||
|
||||
export interface AcidShape {
|
||||
/** What a note is struck with: all of it fixed for the note, as nodeseq fixes it. */
|
||||
export interface AcidNote {
|
||||
wave: AcidWave;
|
||||
/** Seconds a slide takes to all but reach its note: 95 % of the way. */
|
||||
slideTime: number;
|
||||
/** Hz, and the Hz of the note before it glides from: null where there was none. */
|
||||
frequency: number;
|
||||
from: number | null;
|
||||
/** Seconds the glide takes, and the ADSR's, the sustain 0 to 1. */
|
||||
glide: number;
|
||||
attack: number;
|
||||
decay: number;
|
||||
sustain: number;
|
||||
release: number;
|
||||
/** 0 to 1: how loud. */
|
||||
velocity: number;
|
||||
}
|
||||
|
||||
/** Cutoff in Hz, resonance, env mod and accent 0 to 1, decay in seconds, pitch in semitones. */
|
||||
/** Cutoff in Hz, resonance and env mod 0 to 1, pitch in semitones; `gate` 1 while held, `cut` 1 once cut. */
|
||||
export interface AcidKnobs {
|
||||
cutoff: Float32Array;
|
||||
resonance: Float32Array;
|
||||
envMod: Float32Array;
|
||||
decay: Float32Array;
|
||||
accent: Float32Array;
|
||||
pitch: Float32Array;
|
||||
gate: Float32Array;
|
||||
cut: 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 resonance in dB at none and at full, as nodeseq turns its Reso 0 to 100 into the filter's Q. */
|
||||
const LEAST_Q_DB = 0.5;
|
||||
const MOST_Q_DB = 17.5;
|
||||
/** Octaves the envelope opens the filter by at full env mod, and the highest it opens it to. */
|
||||
export const ENV_MOD_OCTAVES = 5;
|
||||
export const TOP_HZ = 18000;
|
||||
/** How much of the sweep the sustain holds at full: at sustain 1 the cutoff stays 60 % of the way up. */
|
||||
export const SUSTAIN_SWEEP = 0.6;
|
||||
/**
|
||||
* How loud a note at velocity 1 is: nodeseq's bass at its volume 0.8, a saw into it at 0.8, and
|
||||
* Chrome's sawtooth peaks at 1 with its ripple, so it is 0.85 of a plain one.
|
||||
*/
|
||||
const OUTPUT = 0.32 * 0.85;
|
||||
/** Time constant of the fall where the next note cuts a note short, and when it stops then. */
|
||||
const CUT_FALL = 0.006;
|
||||
const CUT_STOP = 0.06;
|
||||
/** When a note stops after its release starts: so many releases and a little more. */
|
||||
const RELEASE_STOPS = 1.6;
|
||||
const RELEASE_MORE = 0.05;
|
||||
const LOWEST_HZ = 10;
|
||||
|
||||
/** The filter's Q, as a plain number, at `resonance` 0 to 1. */
|
||||
export function acidQ(resonance: number): number {
|
||||
return 10 ** ((LEAST_Q_DB + (MOST_Q_DB - LEAST_Q_DB) * clamp01(resonance)) / 20);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** The top of a note's sweep, in Hz, from the cutoff at rest. */
|
||||
export function acidTopHz(cutoff: number, envMod: number): number {
|
||||
return Math.min(TOP_HZ, Math.max(LOWEST_HZ, cutoff) * 2 ** (ENV_MOD_OCTAVES * clamp01(envMod)));
|
||||
}
|
||||
|
||||
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 phase = 0.5;
|
||||
/** Samples since the note started, when its glide and attack are over, and when it stops. */
|
||||
private t = 0;
|
||||
private readonly glideFor: number;
|
||||
private readonly attackFor: number;
|
||||
private stopAt = Infinity;
|
||||
/** log2 of the frequency it glides from and to. */
|
||||
private readonly fromOctave: number;
|
||||
private readonly toOctave: number;
|
||||
private readonly peak: number;
|
||||
private readonly decayStep: number;
|
||||
private readonly releaseStep: number;
|
||||
private readonly cutStep: number;
|
||||
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;
|
||||
/** How far up the sweep the cutoff stands, 0 at rest and 1 at the top. */
|
||||
private env = 0;
|
||||
private released = false;
|
||||
private cutting = false;
|
||||
/** The filter's two states. */
|
||||
private ic1 = 0;
|
||||
private ic2 = 0;
|
||||
|
||||
constructor(
|
||||
private readonly shape: AcidShape,
|
||||
private readonly note: AcidNote,
|
||||
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);
|
||||
this.rate = sampleRate;
|
||||
const step = (tau: number) => (tau > 0 ? 1 - Math.exp(-1 / (tau * sampleRate)) : 1);
|
||||
this.toOctave = Math.log2(Math.max(LOWEST_HZ, note.frequency));
|
||||
this.fromOctave =
|
||||
note.from !== null && note.glide > 0 ? Math.log2(Math.max(LOWEST_HZ, note.from)) : this.toOctave;
|
||||
this.glideFor = Math.max(1, Math.round(Math.max(0, note.glide) * sampleRate));
|
||||
this.attackFor = Math.max(1, Math.round(Math.max(0.001, note.attack) * sampleRate));
|
||||
this.peak = OUTPUT * clamp01(note.velocity);
|
||||
// nodeseq's decay and release run as `setTargetAtTime` with a third and a quarter of the time.
|
||||
this.decayStep = step(Math.max(0.02, note.decay) / 3);
|
||||
this.releaseStep = step(Math.max(0.02, note.release) / 4);
|
||||
this.cutStep = step(CUT_FALL);
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
/** Where it stands: the frequency played, the volume, and how far up its sweep the filter is. */
|
||||
get state(): { hz: number; amp: number; env: number; released: boolean; cut: boolean } {
|
||||
return {
|
||||
hz: 2 ** this.octave(),
|
||||
amp: this.amp,
|
||||
env: this.env,
|
||||
released: this.released,
|
||||
cut: this.cutting,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** Fills `out`; false once the note has stopped, the rest of `out` silent. */
|
||||
render(out: Float32Array, knobs: AcidKnobs): boolean {
|
||||
const { rate, note } = this;
|
||||
const sustain = clamp01(note.sustain);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
if (!this.released && at(knobs.gate, i) < 0.5) {
|
||||
this.released = true;
|
||||
this.stopAt = Math.min(
|
||||
this.stopAt,
|
||||
this.t + Math.round((Math.max(0.02, note.release) * RELEASE_STOPS + RELEASE_MORE) * rate),
|
||||
);
|
||||
}
|
||||
out[i] = (sum / OVERSAMPLE) * OUTPUT;
|
||||
if (!this.cutting && at(knobs.cut, i) >= 0.5) {
|
||||
this.cutting = true;
|
||||
this.stopAt = Math.min(this.stopAt, this.t + Math.round(CUT_STOP * rate));
|
||||
}
|
||||
if (this.t >= this.stopAt) {
|
||||
out.fill(0, i);
|
||||
return false;
|
||||
}
|
||||
if (this.cutting) this.amp -= this.amp * this.cutStep;
|
||||
if (this.released) {
|
||||
if (!this.cutting) this.amp -= this.amp * this.releaseStep;
|
||||
this.env -= this.env * this.releaseStep;
|
||||
} else if (this.t < this.attackFor) {
|
||||
const share = this.t / this.attackFor;
|
||||
if (!this.cutting) this.amp = this.peak * share;
|
||||
this.env = share;
|
||||
} else {
|
||||
if (!this.cutting) this.amp += (this.peak * sustain - this.amp) * this.decayStep;
|
||||
this.env += (sustain * SUSTAIN_SWEEP - this.env) * this.decayStep;
|
||||
}
|
||||
const cutoff = Math.max(LOWEST_HZ, finite(at(knobs.cutoff, i), 250));
|
||||
const top = acidTopHz(cutoff, finite(at(knobs.envMod, i), 0));
|
||||
const fc = Math.min(0.49 * rate, cutoff + (top - cutoff) * this.env);
|
||||
const hz = Math.min(0.49 * rate, 2 ** (this.octave() + finite(at(knobs.pitch, i), 0) / 12));
|
||||
const wave = this.oscillator(hz / rate);
|
||||
out[i] = this.lowPass(wave, fc, acidQ(finite(at(knobs.resonance, i), 0))) * this.amp;
|
||||
this.t += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** log2 of the frequency now: an even glide in octaves, as `exponentialRampToValueAtTime` makes it. */
|
||||
private octave(): number {
|
||||
if (this.t >= this.glideFor) return this.toOctave;
|
||||
return this.fromOctave + (this.toOctave - this.fromOctave) * (this.t / this.glideFor);
|
||||
}
|
||||
|
||||
/** One value of the wave, its corners rounded off by PolyBLEP so it does not alias. */
|
||||
@@ -163,30 +179,27 @@ export class AcidSynth {
|
||||
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);
|
||||
if (this.note.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.
|
||||
* The low-pass: a state-variable filter solved without a delay in the loop (Simper's), the same
|
||||
* curve as the cookbook biquad Chrome runs, and steady however fast the cutoff moves.
|
||||
*/
|
||||
private ladder(x: number, fc: number, k: number): number {
|
||||
private lowPass(x: number, fc: number, q: 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;
|
||||
const k = 1 / q;
|
||||
const a1 = 1 / (1 + g * (g + k));
|
||||
const a2 = g * a1;
|
||||
const a3 = g * a2;
|
||||
const v3 = x - this.ic2;
|
||||
const v1 = a1 * this.ic1 + a2 * v3;
|
||||
const v2 = this.ic2 + a2 * this.ic1 + a3 * v3;
|
||||
this.ic1 = 2 * v1 - this.ic1;
|
||||
this.ic2 = 2 * v2 - this.ic2;
|
||||
return v2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,94 +216,13 @@ function blep(p: number, step: number): number {
|
||||
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).
|
||||
*/
|
||||
/** How loud the low-pass passes `hz` with its cutoff at `fc` and its resonance at `resonance`, in dB: what a device draws. */
|
||||
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<Owner> {
|
||||
private readonly strikes: { frame: number; owner: Owner; note: AcidNote }[] = [];
|
||||
private readonly members = new Set<Owner>();
|
||||
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 q = acidQ(resonance);
|
||||
const re = 1 - w * w;
|
||||
const im = w / q;
|
||||
return -10 * Math.log10(Math.max(1e-18, re * re + im * im));
|
||||
}
|
||||
|
||||
const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!);
|
||||
|
||||
+19
-13
@@ -4,7 +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 { AcidWave } from './acid';
|
||||
import type { ChipShape, ChipWave } from './chip';
|
||||
import { COMPRESSOR_LATENCY, compressorMakeup, limiterLatency } from './dynamics';
|
||||
import { LFO_SHAPES, type LfoShape, vibratoDelay } from './lfo';
|
||||
@@ -255,25 +255,29 @@ export type FxChipPlan = { wave: ChipWave } & Record<
|
||||
>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* An Acid as a way of a note plays it (#421): the note's frequency and velocity, its ADSR and glide,
|
||||
* and its knobs, the cutoff of which an Envelope can move. `id` is its node: a note glides from the
|
||||
* one before of the same Acid and cuts it short.
|
||||
*/
|
||||
export interface FxAcid extends AcidShape {
|
||||
export interface FxAcid {
|
||||
id: string;
|
||||
wave: AcidWave;
|
||||
frequency: number;
|
||||
accented: boolean;
|
||||
velocity: number;
|
||||
cutoff: number;
|
||||
resonance: number;
|
||||
envMod: number;
|
||||
attack: number;
|
||||
decay: number;
|
||||
accent: number;
|
||||
sustain: number;
|
||||
release: number;
|
||||
glide: number;
|
||||
cutoffMod?: FxMod;
|
||||
}
|
||||
|
||||
/** An Acid with its numbers still open; `velocity` is the note's, which says whether it is accented. */
|
||||
/** An Acid with its numbers still open. */
|
||||
export type FxAcidPlan = { id: string; wave: AcidWave } & Record<
|
||||
'frequency' | 'tuning' | 'velocity' | 'slideTime' | 'cutoff' | 'resonance' | 'envMod' | 'decay' | 'accent',
|
||||
Exclude<keyof FxAcid, 'id' | 'wave' | 'cutoffMod'>,
|
||||
FxAmount
|
||||
>;
|
||||
|
||||
@@ -1609,14 +1613,16 @@ function acidOf(plan: FxAcidPlan, params: FxParams): FxAcid {
|
||||
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),
|
||||
frequency: value(plan.frequency),
|
||||
velocity: value(plan.velocity),
|
||||
cutoff: value(plan.cutoff),
|
||||
resonance: value(plan.resonance),
|
||||
envMod: value(plan.envMod),
|
||||
attack: value(plan.attack),
|
||||
decay: value(plan.decay),
|
||||
accent: value(plan.accent),
|
||||
sustain: value(plan.sustain),
|
||||
release: value(plan.release),
|
||||
glide: value(plan.glide),
|
||||
...(cutoffMod && { cutoffMod }),
|
||||
};
|
||||
}
|
||||
|
||||
+139
-33
@@ -1,5 +1,6 @@
|
||||
import type { Container } from 'pixi.js';
|
||||
import { NameTransitionCommand } from '../../core/commands/graphCommands';
|
||||
import { ACID_ROWS } from '../../core/instrument/nodes';
|
||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
import type { Widget } from '../../ui';
|
||||
import { currentNodePalette } from '../graph/NodePalette';
|
||||
@@ -412,7 +413,7 @@ export function graphHooks({ state, editor }: HookContext) {
|
||||
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 },
|
||||
screen: { ...panel.screen.says, held: panel.held.text },
|
||||
trace: trace && {
|
||||
restHz: trace.restHz,
|
||||
topHz: trace.topHz,
|
||||
@@ -423,51 +424,156 @@ export function graphHooks({ state, editor }: HookContext) {
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 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.
|
||||
* A line of an Acid played offline twice (#421): left by its worklet, right as nodeseq.com
|
||||
* builds the same voice from Web Audio nodes – a sawtooth, a biquad low-pass with its Q in dB,
|
||||
* the same automation on cutoff and volume, a glide from the note before and the cut of it.
|
||||
* For every 10 ms both are heard in: how loud (dB) and how bright (the RMS frequency, Hz); how
|
||||
* far apart in loudness they are at most, the share of windows no more than a tenth of an octave
|
||||
* apart in brightness – a window without the saw's jump in it is dark, and where exactly the
|
||||
* jump falls moves it a lot – and when each is last heard. `set` overrides the Acid's defaults.
|
||||
*/
|
||||
render: async (slideAt = 0.1, seconds = 0.3) => {
|
||||
compare: async (set: Partial<Record<keyof typeof ACID_ROWS, number>> = {}) => {
|
||||
const rate = 48000;
|
||||
const frames = Math.round(seconds * rate);
|
||||
const context = new OfflineAudioContext(2, frames, rate);
|
||||
const seconds = 1;
|
||||
const context = new OfflineAudioContext(2, Math.round(seconds * rate), 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 v = Object.fromEntries(
|
||||
Object.values(ACID_ROWS).map((row) => [
|
||||
row.key,
|
||||
set[row.key as keyof typeof ACID_ROWS] ?? row.default,
|
||||
]),
|
||||
) as Record<keyof typeof ACID_ROWS, number>;
|
||||
// Sixteenths at 132 BPM, a gap after the third, each a little short of its step.
|
||||
const notes = [33, 45, 33, 36, 33].map((midi, i) => ({
|
||||
hz: 440 * 2 ** ((midi - 69) / 12),
|
||||
at: [0, 0.114, 0.227, 0.455, 0.568][i]!,
|
||||
length: 0.09,
|
||||
}));
|
||||
let cutLast: ((at: number) => void) | null = null;
|
||||
notes.forEach((note, i) => {
|
||||
const from = i > 0 ? notes[i - 1]!.hz : null;
|
||||
cutLast?.(note.at);
|
||||
const node = new AudioWorkletNode(context, 'acid-player', {
|
||||
numberOfInputs: 0,
|
||||
outputChannelCount: [1],
|
||||
parameterData: knobs,
|
||||
processorOptions: { wave: 'saw', slideTime: 0.06, line: 1, accented: false, ...note },
|
||||
parameterData: {
|
||||
cutoff: v.cutoff,
|
||||
resonance: v.resonance,
|
||||
envMod: v.envMod,
|
||||
pitch: 0,
|
||||
gate: 1,
|
||||
cut: 0,
|
||||
},
|
||||
processorOptions: {
|
||||
wave: 'saw',
|
||||
frequency: note.hz,
|
||||
from,
|
||||
glide: v.glide,
|
||||
attack: v.attack,
|
||||
decay: v.decay,
|
||||
sustain: v.sustain,
|
||||
release: v.release,
|
||||
velocity: 1,
|
||||
startAt: note.at,
|
||||
},
|
||||
});
|
||||
node.connect(merger, 0, i);
|
||||
node.parameters.get('gate')!.setValueAtTime(0, note.at + note.length);
|
||||
cutLast = (at) => node.parameters.get('cut')!.setValueAtTime(1, at);
|
||||
node.connect(merger, 0, 0);
|
||||
});
|
||||
// nodeseq's playVoice, as its studio has it, for a bass at volume 0.8 and velocity 1.
|
||||
let cutNative: ((at: number) => void) | null = null;
|
||||
notes.forEach((note, i) => {
|
||||
const e = note.at;
|
||||
const h = Math.max(0.001, v.attack);
|
||||
const d = Math.max(0.02, v.decay);
|
||||
const u = Math.max(0.02, v.release);
|
||||
const m = Math.max(0, Math.min(1, v.sustain));
|
||||
const p = e + Math.max(h + 0.01, note.length);
|
||||
const y = p + u * 1.6 + 0.05;
|
||||
cutNative?.(e);
|
||||
const level = 0.8 * 0.5;
|
||||
const amp = context.createGain();
|
||||
const w = amp.gain;
|
||||
w.value = 0;
|
||||
w.setValueAtTime(0, e);
|
||||
w.linearRampToValueAtTime(level, e + h);
|
||||
w.setTargetAtTime(level * m, e + h, d / 3);
|
||||
w.setTargetAtTime(0, p, u / 4);
|
||||
const filter = context.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.Q.value = 0.5 + v.resonance * 17;
|
||||
const low = v.cutoff;
|
||||
const top = Math.min(18000, low * 2 ** (v.envMod * 5));
|
||||
const f = filter.frequency;
|
||||
f.setValueAtTime(low, e);
|
||||
f.linearRampToValueAtTime(top, e + h);
|
||||
f.setTargetAtTime(low + (top - low) * m * 0.6, e + h, d / 3);
|
||||
f.setTargetAtTime(low, p, u / 4);
|
||||
const osc = context.createOscillator();
|
||||
osc.type = 'sawtooth';
|
||||
// Started inside a block, Chrome's oscillator runs its first samples at the frequency it
|
||||
// had before the start, 440 Hz unless told otherwise: a phase at random, which nobody
|
||||
// hears but which the 10 ms windows would.
|
||||
osc.frequency.value = i > 0 && v.glide > 0 ? notes[i - 1]!.hz : note.hz;
|
||||
if (i > 0 && v.glide > 0) {
|
||||
osc.frequency.setValueAtTime(notes[i - 1]!.hz, e);
|
||||
osc.frequency.exponentialRampToValueAtTime(note.hz, e + v.glide);
|
||||
} else osc.frequency.setValueAtTime(note.hz, e);
|
||||
const oscGain = context.createGain();
|
||||
oscGain.gain.value = 0.8;
|
||||
osc.connect(oscGain).connect(filter).connect(amp).connect(merger, 0, 1);
|
||||
osc.start(e);
|
||||
osc.stop(y);
|
||||
cutNative = (at) => {
|
||||
w.cancelScheduledValues(at);
|
||||
w.setTargetAtTime(0, at, 0.006);
|
||||
osc.stop(at + 0.06);
|
||||
};
|
||||
});
|
||||
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 [ours, native] = [rendered.getChannelData(0), rendered.getChannelData(1)];
|
||||
const span = Math.round(0.01 * rate);
|
||||
const windows: { at: number; db: [number, number]; hz: [number, number] }[] = [];
|
||||
const measure = (data: Float32Array, from: number) => {
|
||||
let sum = 0;
|
||||
let moved = 0;
|
||||
for (let i = from; i < from + span; i++) {
|
||||
sum += data[i]! * data[i]!;
|
||||
const step = data[i]! - data[i - 1]!;
|
||||
moved += step * step;
|
||||
}
|
||||
return {
|
||||
db: 10 * Math.log10(sum / span + 1e-20),
|
||||
hz: (Math.sqrt(moved / Math.max(1e-20, sum)) * rate) / (2 * Math.PI),
|
||||
};
|
||||
};
|
||||
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);
|
||||
let louder = 0;
|
||||
let alike = 0;
|
||||
for (let from = 1; from + span <= ours.length; from += span) {
|
||||
const a = measure(ours, from);
|
||||
const b = measure(native, from);
|
||||
if (Math.max(a.db, b.db) < -40) continue;
|
||||
windows.push({ at: from / rate, db: [a.db, b.db], hz: [a.hz, b.hz] });
|
||||
louder = Math.max(louder, Math.abs(a.db - b.db));
|
||||
if (Math.abs(Math.log2(a.hz / b.hz)) <= 0.1) alike += 1;
|
||||
}
|
||||
return { a: heard(a), b: heard(b), handover, jump, step };
|
||||
const lastHeard = (data: Float32Array) => {
|
||||
let last = data.length - 1;
|
||||
while (last > 0 && Math.abs(data[last]!) <= 1e-6) last -= 1;
|
||||
return last / rate;
|
||||
};
|
||||
return {
|
||||
windows,
|
||||
louder,
|
||||
alike: alike / Math.max(1, windows.length),
|
||||
peak: [ours, native].map((data) =>
|
||||
data.reduce((most, value) => Math.max(most, Math.abs(value)), 0),
|
||||
),
|
||||
lastHeard: [lastHeard(ours), lastHeard(native)],
|
||||
};
|
||||
},
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -155,8 +155,8 @@ export class AcidPanel extends Widget {
|
||||
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;
|
||||
/** Where the sustain holds the filter, on the bezel (checks). */
|
||||
readonly held: Text;
|
||||
/** The settings shown idle now (checks). */
|
||||
idle: ReadonlySet<AcidKey> = new Set();
|
||||
|
||||
@@ -173,10 +173,10 @@ export class AcidPanel extends Widget {
|
||||
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.held = crispText({ text: '', style: nameStyle });
|
||||
this.held.anchor.set(1, 0);
|
||||
this.held.eventMode = 'none';
|
||||
this.addChild(this.screenName, this.held);
|
||||
this.waveButtons = ACID_WAVES.map((wave) => {
|
||||
const button = new PickButton(wave.value, wave.label, WAVE_BUTTONS, (picked) => host.pickWave(picked));
|
||||
this.addChild(button);
|
||||
@@ -226,7 +226,7 @@ export class AcidPanel extends Widget {
|
||||
const trace = acidTrace(shown.values);
|
||||
const texts = acidTexts(trace, shown.values);
|
||||
this.screen.show(trace, texts);
|
||||
this.accent.text = texts.accent;
|
||||
this.held.text = texts.held;
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
@@ -234,7 +234,7 @@ export class AcidPanel extends Widget {
|
||||
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.held.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);
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
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 { acidResponseDb, acidTopHz, SUSTAIN_SWEEP } from '../../core/soundfx/acid';
|
||||
import { hzText, secondsText } from './chipView';
|
||||
import { type Box, type KnobSpot, KNOB_CAPTION, screwsOf, SMALL_DIAL } from './hardwareView';
|
||||
|
||||
export type AcidKey = keyof typeof ACID_ROWS;
|
||||
@@ -33,27 +33,29 @@ export interface AcidKnob {
|
||||
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. */
|
||||
/** How each knob turns: the cutoff and the times give most of their turn to the low end. */
|
||||
export const ACID_KNOBS: Record<AcidKey, AcidKnob> = {
|
||||
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 },
|
||||
envMod: { label: 'Env', taper: 1, decimals: 2 },
|
||||
attack: { label: 'Attack', taper: 4, decimals: 3, format: secondsText },
|
||||
decay: { label: 'Decay', taper: 3, decimals: 2, format: secondsText },
|
||||
sustain: { label: 'Sustain', taper: 1, decimals: 2 },
|
||||
release: { label: 'Release', taper: 3, decimals: 2, format: secondsText },
|
||||
glide: { label: 'Glide', 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'] },
|
||||
{ label: 'FILTER', keys: ['cutoff', 'resonance', 'envMod'] },
|
||||
{ label: 'ENVELOPE', keys: ['attack', 'decay', 'sustain', 'release'] },
|
||||
{ label: 'NOTE', keys: ['glide'] },
|
||||
];
|
||||
|
||||
// --- The plate. ---
|
||||
|
||||
/** The plate: as the Compressor's (#379), the panel never takes another size. */
|
||||
export const ACID_PANEL = { w: 520, h: 280 };
|
||||
export const ACID_PANEL = { w: 580, h: 280 };
|
||||
|
||||
const KNOB_SLOT = 64;
|
||||
const GROUP_GAP = 22;
|
||||
@@ -120,10 +122,10 @@ 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. */
|
||||
/** Hz of the cutoff at rest, at the top of a note's sweep and where the sustain holds it. */
|
||||
restHz: number;
|
||||
topHz: number;
|
||||
accentHz: number;
|
||||
heldHz: number;
|
||||
}
|
||||
|
||||
/** The frequency of column `i` of `count`, evenly in octaves from 20 Hz to 20 kHz. */
|
||||
@@ -132,32 +134,31 @@ export function columnHz(i: number, count: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The filter as a note opens it (#421): at rest, and at the top of its sweep, `env mod` times five
|
||||
* octaves above. Where the sustain holds it the screen names, the curve shows the top.
|
||||
*/
|
||||
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 topHz = acidTopHz(restHz, v.envMod);
|
||||
const heldHz = restHz + (topHz - restHz) * Math.max(0, Math.min(1, v.sustain)) * SUSTAIN_SWEEP;
|
||||
const curve = (fc: number) =>
|
||||
Array.from({ length: columns }, (_, i) => acidResponseDb(columnHz(i, columns), fc, resonance));
|
||||
Array.from({ length: columns }, (_, i) => acidResponseDb(columnHz(i, columns), fc, v.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),
|
||||
heldHz: shown(heldHz),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 } {
|
||||
/** What the screen writes: where the filter rests, how far a note sweeps it, and where it holds. */
|
||||
export function acidTexts(trace: AcidTrace, v: AcidValues): { rest: string; top: string; held: string } {
|
||||
const sweeps = v.envMod > 0;
|
||||
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',
|
||||
top: sweeps ? `SWEEP TO ${hzText(trace.topHz)} Hz` : 'NO SWEEP',
|
||||
held: sweeps ? `HOLDS AT ${hzText(trace.heldHz)} Hz` : '',
|
||||
};
|
||||
}
|
||||
@@ -86,10 +86,8 @@ 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;
|
||||
/** Seconds a note of an Acid may start after the last one ended and still glide from it (#421). */
|
||||
const GLIDE_MEMORY = 2;
|
||||
/** 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. */
|
||||
@@ -113,6 +111,8 @@ interface Source {
|
||||
gate: boolean;
|
||||
/** Plays on past its loop to the end of the sound; null where a release leaves it as it is. */
|
||||
leave: (() => void) | null;
|
||||
/** Plays a release of its own from that second of the audio clock and then ends (#421): an Acid. */
|
||||
release?: (at: number) => void;
|
||||
}
|
||||
|
||||
/** Something of a voice that is started with it and stopped when it goes. */
|
||||
@@ -125,7 +125,7 @@ interface Driver {
|
||||
const vocodersOf = (voice: Voice) => (voice.ringing ? 0 : voice.vocoders);
|
||||
|
||||
/** Whether a note's end leaves a source as it is: a one-shot, which plays to the end of its sound. */
|
||||
const playsThrough = (source: Source) => !source.gate && !source.leave;
|
||||
const playsThrough = (source: Source) => !source.gate && !source.leave && !source.release;
|
||||
|
||||
/**
|
||||
* One Envelope of a note (#335): what carries its curve on the audio thread, and the sources whose
|
||||
@@ -200,7 +200,7 @@ type Way = Exclude<FxChain, { mix: number }>;
|
||||
*/
|
||||
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). */
|
||||
/** The voice it is for, and who plays it: the notes of an Acid by the same glide on (#421). */
|
||||
id: number;
|
||||
player: string;
|
||||
/** When its sources start on the audio clock. */
|
||||
@@ -221,8 +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;
|
||||
/** The ways of an Acid that glide from the note before (#421). */
|
||||
glided: number;
|
||||
}
|
||||
|
||||
/** Everything a voice is started with, the same whether it waits on its bytes first or not. */
|
||||
@@ -329,13 +329,13 @@ export class SoundLibrary implements AudioSink {
|
||||
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.
|
||||
* The last note of each Acid (#421), by who plays it and the Acid: when it starts, until when it
|
||||
* is held, its frequency and voice, and what cuts it short – each of the notes that started then.
|
||||
* The next note glides from it and cuts it.
|
||||
*/
|
||||
private readonly acidLines = new Map<
|
||||
private readonly acidLast = new Map<
|
||||
string,
|
||||
{ line: number; from: number; until: number; voice: number }
|
||||
{ from: number; until: number; frequency: number; voice: number; cuts: ((at: number) => void)[] }
|
||||
>();
|
||||
/** The number the next play is given; 0 stays free for "nothing plays". */
|
||||
private nextId = 1;
|
||||
@@ -1447,6 +1447,7 @@ export class SoundLibrary implements AudioSink {
|
||||
this.endAt(voice, run.sources, when + releaseTime(run.envelope));
|
||||
}
|
||||
const context = this.audio();
|
||||
for (const source of voice.sources) source.release?.(when);
|
||||
const leaving = voice.sources.filter((source) => !source.gate && source.leave);
|
||||
const leave = () => {
|
||||
for (const source of leaving) source.leave!();
|
||||
@@ -2035,14 +2036,19 @@ 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). */
|
||||
/** The Acids whose last note is this voice hold it only until `until` (#421). */
|
||||
private holdLines(id: number, until: number): void {
|
||||
for (const held of this.acidLines.values())
|
||||
for (const held of this.acidLast.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);
|
||||
// A note gone still lends its pitch to the next to glide from, and has nothing left to cut.
|
||||
for (const held of this.acidLast.values())
|
||||
if (held.voice === voice.id) {
|
||||
held.cuts = [];
|
||||
held.until = Math.min(held.until, this.audio().currentTime);
|
||||
}
|
||||
voice.quiet?.();
|
||||
voice.quiet = null;
|
||||
this.voices = this.voices.filter((other) => other !== voice);
|
||||
@@ -2141,7 +2147,7 @@ export class SoundLibrary implements AudioSink {
|
||||
knobs: [],
|
||||
stages: 0,
|
||||
vocoders: 0,
|
||||
slid: 0,
|
||||
glided: 0,
|
||||
};
|
||||
chains.forEach((chain, i) => heldBack(context, this.wire(wiring, chain), late - lates[i]!).connect(gain));
|
||||
gain.connect(this.busInput(bus));
|
||||
@@ -2157,7 +2163,7 @@ export class SoundLibrary implements AudioSink {
|
||||
at: when,
|
||||
envelopes: wiring.envelopes.size,
|
||||
bus,
|
||||
slid: wiring.slid,
|
||||
glided: wiring.glided,
|
||||
};
|
||||
const voice: Voice = {
|
||||
id,
|
||||
@@ -2556,15 +2562,16 @@ export class SoundLibrary implements AudioSink {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* An Acid (#421): a worklet of its own that plays out its release when the note ends and then
|
||||
* ends by itself. It glides from the last note of its Acid by the same player, where that ended
|
||||
* no more than `GLIDE_MEMORY` seconds ago, and cuts that note short where it still sounds; notes
|
||||
* that start together neither glide nor cut. 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];
|
||||
const { cutoff, resonance, envMod, cutoffMod } = acid;
|
||||
const levels = [cutoff, resonance, envMod];
|
||||
if (!this.hasWorklet) {
|
||||
const silent = context.createBufferSource();
|
||||
silent.buffer = context.createBuffer(1, 1, context.sampleRate);
|
||||
@@ -2584,38 +2591,55 @@ export class SoundLibrary implements AudioSink {
|
||||
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;
|
||||
for (const [other, held] of this.acidLast)
|
||||
if (held.until < wiring.at - GLIDE_MEMORY) this.acidLast.delete(other);
|
||||
const last = this.acidLast.get(key);
|
||||
const together = last !== undefined && last.from === wiring.at;
|
||||
const before = last !== undefined && last.from < wiring.at ? last : null;
|
||||
for (const cut of before?.cuts ?? []) cut(wiring.at);
|
||||
const from = before ? before.frequency : null;
|
||||
if (from !== null && acid.glide > 0) wiring.glided += 1;
|
||||
const options: AcidOptions = {
|
||||
wave: acid.wave,
|
||||
slideTime: acid.slideTime,
|
||||
line,
|
||||
slide,
|
||||
frequency: acid.frequency,
|
||||
accented: acid.accented,
|
||||
from,
|
||||
glide: acid.glide,
|
||||
attack: acid.attack,
|
||||
decay: acid.decay,
|
||||
sustain: acid.sustain,
|
||||
release: acid.release,
|
||||
velocity: acid.velocity,
|
||||
startAt: wiring.at,
|
||||
};
|
||||
const node = new AudioWorkletNode(context, 'acid-player', {
|
||||
numberOfInputs: 0,
|
||||
outputChannelCount: [1],
|
||||
parameterData: { cutoff: cutoffMod ? 0 : cutoff, resonance, envMod, decay, accent, pitch },
|
||||
parameterData: { cutoff: cutoffMod ? 0 : cutoff, resonance, envMod, pitch, gate: 1, cut: 0 },
|
||||
processorOptions: options,
|
||||
});
|
||||
const param = (name: string) => node.parameters.get(name)!;
|
||||
const cut = (at: number) => param('cut').setValueAtTime(1, at);
|
||||
if (together) last.cuts.push(cut);
|
||||
else
|
||||
this.acidLast.set(key, {
|
||||
from: wiring.at,
|
||||
until: Infinity,
|
||||
frequency: acid.frequency,
|
||||
voice: wiring.id,
|
||||
cuts: [cut],
|
||||
});
|
||||
wiring.sources.push({
|
||||
start: () => {},
|
||||
stop: () => stopProcessor(node),
|
||||
ended: (then) => endedProcessor(node, then),
|
||||
fade: null,
|
||||
gate: true,
|
||||
gate: false,
|
||||
leave: null,
|
||||
release: (at) => param('gate').setValueAtTime(0, at),
|
||||
});
|
||||
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)
|
||||
for (const name of ['resonance', 'envMod'] as const)
|
||||
knobs.push({ param: param(name), target: acid[name], even: 'value' });
|
||||
const follow = this.follower(wiring);
|
||||
if (cutoffMod) follow(cutoffMod, param('cutoff'), true);
|
||||
@@ -2859,8 +2883,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;
|
||||
/** How many of its Acids glided from the note before (#421). */
|
||||
glided?: number;
|
||||
}
|
||||
|
||||
/** The stages that run on the worklet. */
|
||||
|
||||
@@ -704,8 +704,8 @@ export 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);
|
||||
if (chain.acid) {
|
||||
const { cutoff, resonance, envMod, decay, accent } = chain.acid;
|
||||
into.push(cutoff, resonance, envMod, decay, accent);
|
||||
const { cutoff, resonance, envMod } = chain.acid;
|
||||
into.push(cutoff, resonance, envMod);
|
||||
}
|
||||
}
|
||||
for (const stage of chain.stages) stageLevels(stage, into);
|
||||
|
||||
+14
-38
@@ -4,10 +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`, an Acid (#421) its `AcidLine`. Loaded into
|
||||
* sound (#349) a `StreamQueue`, an Acid (#421) an `AcidSynth`. 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 AcidKnobs, type AcidNote, 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';
|
||||
@@ -375,56 +375,32 @@ 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;
|
||||
export interface AcidOptions extends AcidNote {
|
||||
/** 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 Acid's AudioParams: its knobs, its pitch, and the note's end and cut, set on the clock. */
|
||||
export const ACID_PARAMS = ['cutoff', 'resonance', 'envMod', 'pitch', 'gate', 'cut'] as const;
|
||||
|
||||
/** The lines of the Acids playing (#421), by number: processors share this scope. */
|
||||
const acidLines = new Map<number, AcidLine<AcidProcessor>>();
|
||||
|
||||
/**
|
||||
* 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<AcidProcessor>;
|
||||
private readonly number: number;
|
||||
/** The Acid of a note (#421): a source of its own that plays out its release and then ends. */
|
||||
class AcidProcessor extends Source {
|
||||
private readonly synth: AcidSynth;
|
||||
|
||||
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<AcidProcessor>(new AcidSynth(processorOptions, sampleRate));
|
||||
acidLines.set(line, played);
|
||||
}
|
||||
this.line = played;
|
||||
played.strike(this, Math.round(startAt * sampleRate), { frequency, accented, slide });
|
||||
super(processorOptions.startAt);
|
||||
this.synth = new AcidSynth(processorOptions, sampleRate);
|
||||
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);
|
||||
protected render(out: Float32Array[], parameters: ParamValues): boolean {
|
||||
const playing = this.synth.render(out[0]!, parameters as unknown as AcidKnobs);
|
||||
for (const channel of out.slice(1)) channel.set(out[0]!);
|
||||
return true;
|
||||
return playing;
|
||||
}
|
||||
|
||||
static get parameterDescriptors() {
|
||||
return ACID_PARAMS.map((name) => ({ name, defaultValue: 0 }));
|
||||
return ACID_PARAMS.map((name) => ({ name, defaultValue: name === 'gate' ? 1 : 0 }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+155
-166
@@ -1,21 +1,27 @@
|
||||
/**
|
||||
* 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.
|
||||
* The Acid of an instrument (#421): nodeseq's acid bass rendered sample by sample as its worklet
|
||||
* does it – the oscillator, the resonant low-pass, the ADSR on volume and filter alike, the glide
|
||||
* and the cut – 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 {
|
||||
ACID_ROWS,
|
||||
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,
|
||||
acidQ,
|
||||
acidResponseDb,
|
||||
type AcidShape,
|
||||
AcidSynth,
|
||||
acidTopHz,
|
||||
SUSTAIN_SWEEP,
|
||||
} from '../../src/core/soundfx/acid';
|
||||
import { fxChainOf } from '../../src/core/soundfx/nodes';
|
||||
import { levelsOf } from '../../src/viewport/fxNodes';
|
||||
@@ -23,49 +29,57 @@ import { makeProject } from '../helpers';
|
||||
|
||||
const RATE = 48000;
|
||||
const BLOCK = 128;
|
||||
const SAW: AcidShape = { wave: 'saw', slideTime: 0.06 };
|
||||
|
||||
function knobs(set: Partial<Record<keyof AcidKnobs, number>> = {}): 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),
|
||||
};
|
||||
}
|
||||
type Knobs = Partial<Record<'cutoff' | 'resonance' | 'envMod' | 'pitch', number>>;
|
||||
|
||||
const note = (frequency: number, more: Partial<AcidNote> = {}): AcidNote => ({
|
||||
wave: 'saw',
|
||||
frequency,
|
||||
accented: false,
|
||||
slide: false,
|
||||
from: null,
|
||||
glide: 0,
|
||||
attack: 0.003,
|
||||
decay: 0.16,
|
||||
sustain: 1,
|
||||
release: 0.08,
|
||||
velocity: 1,
|
||||
...more,
|
||||
});
|
||||
|
||||
/** `seconds` of a synth struck with `first`, in blocks; `then` strikes more notes at their second. */
|
||||
/**
|
||||
* `seconds` of a synth playing `played`, in blocks: the note ends at `endAt` and is cut at `cutAt`,
|
||||
* each a second or never. Says what it played, its state after, and the second it stopped at.
|
||||
*/
|
||||
function render(
|
||||
set: Partial<Record<keyof AcidKnobs, number>>,
|
||||
first: AcidNote,
|
||||
set: Knobs,
|
||||
played: AcidNote,
|
||||
seconds: number,
|
||||
then: { at: number; note: AcidNote }[] = [],
|
||||
shape: AcidShape = SAW,
|
||||
{ endAt = Infinity, cutAt = Infinity }: { endAt?: number; cutAt?: number } = {},
|
||||
) {
|
||||
const synth = new AcidSynth(shape, RATE);
|
||||
synth.strike(first);
|
||||
const synth = new AcidSynth(played, RATE);
|
||||
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);
|
||||
const one = (value: number) => new Float32Array([value]);
|
||||
let stopped = Infinity;
|
||||
for (let done = 0; done < out.length; done += BLOCK) {
|
||||
const block = out.subarray(done, done + BLOCK);
|
||||
// Per sample, as an AudioParam hands over one that is set on the clock.
|
||||
const switched = (at: number, before: number) =>
|
||||
Float32Array.from(block, (_, i) => ((done + i) / RATE < at ? before : 1 - before));
|
||||
const knobs: AcidKnobs = {
|
||||
cutoff: one(set.cutoff ?? 500),
|
||||
resonance: one(set.resonance ?? 0),
|
||||
envMod: one(set.envMod ?? 0),
|
||||
pitch: one(set.pitch ?? 0),
|
||||
gate: switched(endAt, 1),
|
||||
cut: switched(cutAt, 0),
|
||||
};
|
||||
if (!synth.render(block, knobs)) {
|
||||
let last = block.length - 1;
|
||||
while (last > 0 && block[last] === 0) last -= 1;
|
||||
stopped = (done + last + 1) / RATE;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { out, synth };
|
||||
return { out, synth, stopped };
|
||||
}
|
||||
|
||||
/** How loud `hz` is in a stretch of the sound: the Goertzel magnitude through a Hann window, per sample. */
|
||||
@@ -90,6 +104,8 @@ const peak = (out: Float32Array, from = 0, to = out.length) => {
|
||||
return most;
|
||||
};
|
||||
|
||||
const db = (value: number) => 20 * Math.log10(value);
|
||||
|
||||
describe('the Acid synth (#421)', () => {
|
||||
it('plays a saw at the frequency of its note', () => {
|
||||
const { out } = render({ cutoff: 8000 }, note(110), 0.5);
|
||||
@@ -99,132 +115,94 @@ describe('the Acid synth (#421)', () => {
|
||||
expect(peak(out)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('takes the highs away below its cutoff', () => {
|
||||
it('takes the highs away above 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);
|
||||
it('turns the resonance into a Q in dB as nodeseq does, 0.5 to 17.5', () => {
|
||||
expect(db(acidQ(0))).toBeCloseTo(0.5, 6);
|
||||
expect(db(acidQ(0.78))).toBeCloseTo(13.76, 6);
|
||||
expect(db(acidQ(1))).toBeCloseTo(17.5, 6);
|
||||
// A two-pole low-pass passes its cutoff Q times as loud.
|
||||
expect(acidResponseDb(1000, 1000, 1)).toBeCloseTo(17.5, 6);
|
||||
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);
|
||||
expect(acidResponseDb(10000, 1000, 0)).toBeCloseTo(-40, 0);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
it('rings at its cutoff as high as the filter it draws', () => {
|
||||
// The 18th harmonic of 55 Hz sits right at the cutoff; a saw's harmonics fall as 1/n.
|
||||
for (const resonance of [0, 0.78, 1]) {
|
||||
const { out } = render({ cutoff: 990, resonance }, note(55), 0.8);
|
||||
const heard = db(energyAt(out, 990, 14400) / energyAt(out, 55, 14400));
|
||||
const drawn = acidResponseDb(990, 990, resonance) - db(18) - acidResponseDb(55, 990, resonance);
|
||||
expect(heard).toBeGreaterThan(drawn - 1);
|
||||
expect(heard).toBeLessThan(drawn + 1);
|
||||
}
|
||||
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('shapes its volume as nodeseq automates it: a straight attack, then setTargetAtTime', () => {
|
||||
const played = note(110, { attack: 0.01, decay: 0.3, sustain: 0.25, velocity: 0.5 });
|
||||
// nodeseq's level, with Chrome's sawtooth 0.85 of a plain one, at velocity 0.5.
|
||||
const top = 0.32 * 0.85 * 0.5;
|
||||
const ampAt = (seconds: number) => render({ cutoff: 8000 }, played, seconds).synth.state.amp;
|
||||
expect(ampAt(0.005)).toBeCloseTo(top / 2, 3);
|
||||
// A decay's time constant is a third of it: after the whole decay e^-3 of the way is left.
|
||||
const held = top * 0.25;
|
||||
expect(ampAt(0.01 + 0.3)).toBeCloseTo(held + (top - held) * Math.exp(-3), 3);
|
||||
expect(ampAt(2)).toBeCloseTo(held, 4);
|
||||
});
|
||||
|
||||
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);
|
||||
it('opens the filter in the attack, lets it fall to where the sustain holds it, and closes it in the release', () => {
|
||||
const played = note(110, { attack: 0.005, decay: 0.2, sustain: 0.5, release: 0.2 });
|
||||
const envAt = (seconds: number, endAt = Infinity) =>
|
||||
render({ cutoff: 200, envMod: 0.6 }, played, seconds, { endAt }).synth.state.env;
|
||||
expect(envAt(0.005)).toBeCloseTo(1, 2);
|
||||
expect(envAt(1.5)).toBeCloseTo(0.5 * SUSTAIN_SWEEP, 3);
|
||||
// A release's time constant is a quarter of it.
|
||||
expect(envAt(1.5 + 0.05, 1.5)).toBeCloseTo(0.5 * SUSTAIN_SWEEP * Math.exp(-1), 2);
|
||||
expect(acidTopHz(200, 0.6)).toBeCloseTo(200 * 2 ** 3, 6);
|
||||
expect(acidTopHz(4000, 1)).toBe(18000);
|
||||
// And it is heard: the highs are there at the top of the sweep and gone once it fell.
|
||||
const { out } = render({ cutoff: 200, envMod: 1 }, note(110, { sustain: 0 }), 0.6);
|
||||
expect(energyAt(out, 1100, 0, 1440)).toBeGreaterThan(energyAt(out, 1100, 21600, 24000) * 10);
|
||||
});
|
||||
|
||||
it('plays out its release when the note ends and then stops by itself', () => {
|
||||
const { out, synth, stopped } = render({ cutoff: 2000 }, note(110, { release: 0.1 }), 1, { endAt: 0.2 });
|
||||
expect(synth.state.released).toBe(true);
|
||||
expect(peak(out, 0.2 * RATE, 0.21 * RATE)).toBeGreaterThan(0.05);
|
||||
// As nodeseq stops its oscillator: so many releases and 50 ms after the end.
|
||||
expect(stopped).toBeCloseTo(0.2 + 0.1 * 1.6 + 0.05, 2);
|
||||
expect(peak(out, Math.ceil(stopped * RATE))).toBe(0);
|
||||
});
|
||||
|
||||
it('glides from the note before, evenly in octaves, and strikes anew', () => {
|
||||
const gliding = note(220, { from: 110, glide: 0.05 });
|
||||
const hzAt = (seconds: number, played = gliding) => render({}, played, seconds).synth.state.hz;
|
||||
expect(hzAt(0.025)).toBeCloseTo(110 * Math.SQRT2, 0);
|
||||
expect(hzAt(0.06)).toBeCloseTo(220, 6);
|
||||
expect(hzAt(0.001, note(220))).toBeCloseTo(220, 6);
|
||||
expect(hzAt(0.001, note(220, { from: 110, glide: 0 }))).toBeCloseTo(220, 6);
|
||||
// The sweep starts at the top again: a glide is not a slide that runs on.
|
||||
expect(render({ envMod: 1 }, gliding, 0.003).synth.state.env).toBeCloseTo(1, 1);
|
||||
});
|
||||
|
||||
it('falls silent within 60 ms when the next note cuts it short', () => {
|
||||
const { out, synth, stopped } = render({ cutoff: 2000 }, note(110), 0.5, { cutAt: 0.1 });
|
||||
expect(synth.state.cut).toBe(true);
|
||||
expect(stopped).toBeCloseTo(0.16, 2);
|
||||
expect(peak(out, 0.15 * RATE, 0.16 * RATE)).toBeLessThan(peak(out, 0.09 * RATE, 0.1 * RATE) / 100);
|
||||
});
|
||||
|
||||
it('stays finite at full resonance and sweep, high and low', () => {
|
||||
for (const hz of [30, 55, 880, 4000]) {
|
||||
const { out } = render({ cutoff: 18000, resonance: 1, envMod: 1 }, note(hz, { sustain: 0.5 }), 0.5);
|
||||
expect(out.every(Number.isFinite)).toBe(true);
|
||||
expect(peak(out)).toBeLessThan(4);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,35 +219,46 @@ describe('the Acid node (#421)', () => {
|
||||
],
|
||||
});
|
||||
|
||||
it('plays the note, tuned, with its knobs, and is accented by a hard velocity', () => {
|
||||
it("plays the note with nodeseq's acid by default, as loud as its velocity", () => {
|
||||
const sources = instrumentSourcesOf(makeProject().project);
|
||||
const build = compileInstrument(graph({ wave: 'square', tuning: 12, cutoff: 900 }), 60, sources);
|
||||
const build = compileInstrument(graph({ wave: 'square', 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({
|
||||
const chain = fxChainOf(layer!, { '@velocity': 0.7 });
|
||||
if ('mix' in chain) throw new Error('one way');
|
||||
expect(chain.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,
|
||||
resonance: 0.78,
|
||||
envMod: 0.62,
|
||||
attack: 0.003,
|
||||
decay: 0.16,
|
||||
sustain: 0.15,
|
||||
release: 0.08,
|
||||
glide: 0.05,
|
||||
velocity: 0.7,
|
||||
});
|
||||
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]);
|
||||
expect(chain.acid!.frequency).toBeCloseTo(noteFrequency(60), 6);
|
||||
// What moves as the note plays: the cutoff, the resonance and the env mod.
|
||||
expect(levelsOf(chain)).toEqual([900, 0.78, 0.62]);
|
||||
});
|
||||
|
||||
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 [layer] = compileInstrument(
|
||||
graph({ cutoff: 99999, resonance: 3, attack: 0, sustain: 2, glide: -1 }),
|
||||
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 });
|
||||
expect(chain.acid).toMatchObject({
|
||||
cutoff: ACID_ROWS.cutoff.max,
|
||||
resonance: 1,
|
||||
attack: ACID_ROWS.attack.min,
|
||||
sustain: 1,
|
||||
glide: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -249,7 +249,7 @@ describe('an instrument preset', () => {
|
||||
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]!.acid).toMatchObject({ wave: 'saw', cutoff: 250, resonance: 0.78 });
|
||||
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]!;
|
||||
|
||||
@@ -638,7 +638,7 @@ describe('the sound library', () => {
|
||||
layers: 1,
|
||||
pitches: [0],
|
||||
stages: 0,
|
||||
slid: 0,
|
||||
glided: 0,
|
||||
duration: 1.5,
|
||||
at: 0,
|
||||
envelopes: 0,
|
||||
|
||||
+33
-23
@@ -2922,9 +2922,9 @@ export async function sound(t) {
|
||||
|
||||
// #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.
|
||||
// drawn from it. The worklet sounds as nodeseq's acid built from Web Audio nodes does; of
|
||||
// three notes the second and third glide from the one before, and all of them play out
|
||||
// their release and end by themselves. An undo brings the Laser back.
|
||||
await clickHook('presetAt');
|
||||
await clickText('Acid Bass');
|
||||
await sleep(300);
|
||||
@@ -2952,35 +2952,41 @@ export async function sound(t) {
|
||||
'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.w === 580 &&
|
||||
acidShown.size.h === 280 &&
|
||||
Object.values(acidShown.inputs).length === 8 &&
|
||||
Object.values(acidShown.inputs).length === 9 &&
|
||||
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.knobs.cutoff.value === 250 &&
|
||||
acidShown.screen.rest === 'REST 250 Hz' &&
|
||||
acidShown.trace.peakDb > 6 &&
|
||||
acidSquared.params.wave === 'square' &&
|
||||
acidSquared.panel.wave === 'square' &&
|
||||
acidTurned.params.cutoff > 300 &&
|
||||
acidTurned.params.cutoff > 250 &&
|
||||
acidTurned.panel.screen.rest !== acidShown.screen.rest &&
|
||||
acidUndone.wave === 'saw' &&
|
||||
acidUndone.cutoff === 300,
|
||||
acidUndone.cutoff === 250,
|
||||
JSON.stringify({ acidBuilt, acidShown, acidSquared, acidTurned, acidUndone }),
|
||||
);
|
||||
|
||||
const acidRender = JSON.parse(await evaluate('__gm.acid.render(0.1, 0.3).then(JSON.stringify)'));
|
||||
// The same line with nodeseq's acid and with the resonance and the sweep all the way up.
|
||||
const acidCompared = [];
|
||||
for (const set of [{}, { resonance: 1, envMod: 1 }])
|
||||
acidCompared.push(
|
||||
JSON.parse(await evaluate(`__gm.acid.compare(${JSON.stringify(set)}).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),
|
||||
"the Acid's worklet sounds as nodeseq's acid built from Web Audio nodes: as loud, as bright, and it ends where that stops",
|
||||
acidCompared.every(
|
||||
(c) =>
|
||||
c.windows.length > 40 &&
|
||||
c.louder < 0.5 &&
|
||||
c.alike > 0.9 &&
|
||||
c.lastHeard[0] > 0.8 &&
|
||||
Math.abs(c.lastHeard[0] - c.lastHeard[1]) < 0.03,
|
||||
),
|
||||
JSON.stringify(acidCompared.map(({ windows, ...rest }) => ({ ...rest, windows: windows.length }))),
|
||||
);
|
||||
|
||||
await evaluate('__gm.sound.stopAll()');
|
||||
@@ -2996,14 +3002,18 @@ export async function sound(t) {
|
||||
);
|
||||
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;
|
||||
const glidedOf = (n) => acidLine.find((s) => s.started === beforeLine.started + n)?.last?.glided ?? 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(),
|
||||
'of three Acid notes the second and third glide from the one before and all end by themselves; an undo brings the Laser back',
|
||||
glidedOf(1) === 0 &&
|
||||
glidedOf(2) === 1 &&
|
||||
glidedOf(3) === 1 &&
|
||||
acidLine.at(-1).playing === 0 &&
|
||||
acidGone.types.join() === laser.types.join(),
|
||||
JSON.stringify({
|
||||
slid: [1, 2, 3].map(slidOf),
|
||||
glided: [1, 2, 3].map(glidedOf),
|
||||
started: acidLine.map((s) => s.started),
|
||||
acidGone: acidGone.types,
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user