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

+139 -33
View File
@@ -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)],
};
},
},
/**
+8 -8
View File
@@ -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);
+26 -25
View File
@@ -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` : '',
};
}