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>
164 lines
6.1 KiB
TypeScript
164 lines
6.1 KiB
TypeScript
/**
|
||
* 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',
|
||
};
|
||
}
|