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
+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` : '',
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user