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
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user