Files
feuerwerk/src/editor/soundfx/acidView.ts
T
adminandClaude Opus 5.5 d844f2734a Add an Acid instrument node for TB-303 style bass lines (#421)
The instruments could only come near a 303 with a Chip, a Filter and
two envelopes. The biquad filter does not saturate, every note strikes
anew, and there was no slide and no accent.

- Acid is a new instrument node that plays a saw or a square through a
  four-pole ladder low-pass with tanh saturation, oversampled twice.
  Knobs: cutoff, resonance, env mod, decay, accent, tuning and slide
  time.
- An accent comes from the velocity, from 0.9 on. It is louder, opens
  the filter further with a shorter decay, and accents close behind
  each other build up.
- Two notes that touch or overlap slide. Each note stays a voice of
  its own, so the scheduler is unchanged. The notes of a line share
  one synth in the worklet, and the new voice takes it over from its
  first sample on.
- The node sets its own volume, so it needs no envelope behind it.
- The graph shows it as a device with the filter curve at rest and at
  the top of the sweep.
- A preset Acid Bass puts an Acid in front of a short echo.

Tests:
- tsc, eslint, Prettier clean
- Unit: 14 new tests in acid.test.ts for pitch, low-pass, resonance,
  sweep, accent, slide and the handover between two voices.
- e2e 34 on melektaus-atx: ALL PASSED, 148 checks (3 new). The
  handover sits on sample 4800 and jumps by 0.0012, of three notes
  only the second slides.
- Without the handover, or with a slide that strikes anew, the unit
  tests are red.
- Gate on melektaus-atx: tsc, eslint, Prettier clean, 4753 of 4754
  tests green. The red one was sound-library.test.ts, which did not
  know the new slid count in the stats. Fixed, and the file alone
  is green again with 128 tests. No second full gate.
- Not checked: the sound by ear, slide at the keyboard, a bake with
  Acid, the CPU load of many notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:08:57 +02:00

164 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* What an Acid's node draws (#421), as numbers: where the parts of its device sit, how its knobs
* turn, and its filter where it rests and at the top of a note's sweep. Kept apart from the drawing
* so it is checked without a browser.
*/
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 { type Box, type KnobSpot, KNOB_CAPTION, screwsOf, SMALL_DIAL } from './hardwareView';
export type AcidKey = keyof typeof ACID_ROWS;
export type AcidValues = Record<AcidKey, number>;
export const ACID_KEYS = Object.keys(ACID_ROWS) as AcidKey[];
/** The numbers of an Acid node, each its row's default where none is typed in. */
export function acidValues(params: PropertyBag): AcidValues {
const values = {} as AcidValues;
for (const key of ACID_KEYS) {
const value = params[key];
values[key] = typeof value === 'number' && Number.isFinite(value) ? value : ACID_ROWS[key].default;
}
return values;
}
export interface AcidKnob {
/** The engraved name above it, its group's name above the group. */
label: string;
taper: number;
decimals: number;
bipolar?: boolean;
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. */
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 },
};
/** 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'] },
];
// --- The plate. ---
/** The plate: as the Compressor's (#379), the panel never takes another size. */
export const ACID_PANEL = { w: 520, h: 280 };
const KNOB_SLOT = 64;
const GROUP_GAP = 22;
/** The height of a group's name and its rule. */
const GROUP_LINE = 16;
export interface AcidLayout {
title: Box;
waves: Box[];
/** The dark frame round the screen, and the screen in it. */
bezel: Box;
screen: Box;
groups: { label: string; box: Box }[];
knobs: Record<AcidKey, KnobSpot>;
screws: { x: number; y: number }[];
}
function acidLayout(): AcidLayout {
const { w } = ACID_PANEL;
const waves = [0, 1].map((i) => ({ x: 104 + i * 70, y: 13, w: 64, h: 28 }));
const bezel = { x: 24, y: 52, w: w - 48, h: 118 };
const screen = { x: bezel.x + 10, y: bezel.y + 16, w: bezel.w - 20, h: bezel.h - 24 };
const rowWidth =
ACID_GROUPS.reduce((sum, group) => sum + group.keys.length * KNOB_SLOT, 0) +
(ACID_GROUPS.length - 1) * GROUP_GAP;
const groupTop = bezel.y + bezel.h + 6;
const dialY = groupTop + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2;
const knobs = {} as Record<AcidKey, KnobSpot>;
const groups: { label: string; box: Box }[] = [];
let x = (w - rowWidth) / 2;
for (const group of ACID_GROUPS) {
groups.push({
label: group.label,
box: { x, y: groupTop, w: group.keys.length * KNOB_SLOT, h: GROUP_LINE },
});
for (const key of group.keys) {
knobs[key] = { x: x + KNOB_SLOT / 2, y: dialY, size: SMALL_DIAL };
x += KNOB_SLOT;
}
x += GROUP_GAP;
}
return {
title: { x: 34, y: 14, w: 60, h: 30 },
waves,
bezel,
screen,
groups,
knobs,
screws: screwsOf(ACID_PANEL),
};
}
export const ACID_LAYOUT: AcidLayout = acidLayout();
// --- The screen: the filter at rest and at the top of a sweep. ---
/** The span of the screen: 20 Hz to 20 kHz across, +24 dB to -48 dB down. */
export const RESPONSE_LOW_HZ = 20;
export const RESPONSE_HIGH_HZ = 20000;
export const RESPONSE_TOP_DB = 24;
export const RESPONSE_FLOOR_DB = -48;
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. */
restHz: number;
topHz: number;
accentHz: number;
}
/** The frequency of column `i` of `count`, evenly in octaves from 20 Hz to 20 kHz. */
export function columnHz(i: number, count: number): number {
return RESPONSE_LOW_HZ * (RESPONSE_HIGH_HZ / RESPONSE_LOW_HZ) ** (i / Math.max(1, count - 1));
}
/**
* 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.
*/
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 curve = (fc: number) =>
Array.from({ length: columns }, (_, i) => acidResponseDb(columnHz(i, columns), fc, 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),
};
}
/** 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 } {
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',
};
}