Rebuild the Acid node after the acid voice of nodeseq (#421)
game-maker / gate (push) Successful in 4m23s
game-maker / suite (push) Failing after 18m20s

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:
adminandClaude Opus 5.5 committed 2026-10-13 12:39:01 +02:00
1 parent d844f2734a
commit f92d69d902
15 files changed
+645 -592

No files matched your search

+62 -38
View File
@@ -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. */