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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-13 11:08:57 +02:00
1 parent 3443368855
commit d844f2734a
18 files changed
+1615 -15

No files matched your search

+88
View File
@@ -3,6 +3,7 @@ import { NameTransitionCommand } from '../../core/commands/graphCommands';
import { soundLibrary } from '../../viewport/SoundLibrary';
import type { Widget } from '../../ui';
import { currentNodePalette } from '../graph/NodePalette';
import { AcidPanel } from '../soundfx/AcidPanel';
import { ChipPanel } from '../soundfx/ChipPanel';
import { EnvelopePanel } from '../soundfx/EnvelopePanel';
import { CompressorPanel } from '../soundfx/CompressorPanel';
@@ -382,6 +383,93 @@ export function graphHooks({ state, editor }: HookContext) {
};
},
},
/**
* The panel an Acid node shows in the instrument graph (#421): whether it is shown and where, its
* knobs, which settings are idle, the picked wave, what the screen says and the node's ports.
*/
acid: {
panel: (nodeId: string) => {
const canvas = editor.instrumentEditor.canvas;
const panel = canvas.faceOf(nodeId)?.widget;
if (!(panel instanceof AcidPanel)) return null;
const node = canvas.nodeScreen(nodeId);
const ports = (keys: string[], isOutput: boolean) =>
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
const trace = panel.screen.trace;
return {
shown: shownOnScreen(panel),
rect: screenRect(panel),
size: { w: panel.w, h: panel.h },
node: node?.rect ?? null,
inputs: ports(node?.inputs ?? [], false),
outputs: ports(node?.outputs ?? [], true),
knobs: Object.fromEntries(
Object.entries(panel.knobs).map(([key, knob]) => [
key,
{ value: knob.value, number: knob.shown, enabled: knob.enabled, dial: knob.dialGlobal() },
]),
),
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 },
trace: trace && {
restHz: trace.restHz,
topHz: trace.topHz,
/** The highest the curve at the top of the sweep reaches, in dB. */
peakDb: Math.max(...trace.top),
columns: trace.top.length,
},
};
},
/**
* 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.
*/
render: async (slideAt = 0.1, seconds = 0.3) => {
const rate = 48000;
const frames = Math.round(seconds * rate);
const context = new OfflineAudioContext(2, frames, 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 node = new AudioWorkletNode(context, 'acid-player', {
numberOfInputs: 0,
outputChannelCount: [1],
parameterData: knobs,
processorOptions: { wave: 'saw', slideTime: 0.06, line: 1, accented: false, ...note },
});
node.connect(merger, 0, i);
});
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 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);
}
return { a: heard(a), b: heard(b), handover, jump, step };
},
},
/**
* The panel an Envelope node shows in the instrument graph (#388): whether it is shown and
* where, its knobs and fields, which settings are idle, the curve's stages, what the screen says
+72
View File
@@ -0,0 +1,72 @@
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { acidWave, InstrumentAcid } from '../../core/instrument/nodes';
import { findGraphAsset } from '../../core/model/Project';
import type { AcidWave } from '../../core/soundfx/acid';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { type AcidHost, AcidPanel } from './AcidPanel';
import { type AcidKey, acidValues } from './acidView';
import { compressorWired } from './CompressorEditor';
import { NodeParamDrag } from './nodeParamDrag';
/**
* An Acid node drawn as its device (#421): its filter drawn from its settings, every one of them
* worked right in the graph. It listens to nothing, so it has nothing to move between changes.
*/
export class AcidFace implements NodeFace, AcidHost {
readonly widget: AcidPanel;
private readonly drag: NodeParamDrag;
constructor(
private readonly state: EditorState,
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new AcidPanel(this);
this.drag = new NodeParamDrag(state, 'acid-face');
}
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === InstrumentAcid.type ? node : null;
}
private wired = (key: string): boolean =>
compressorWired(this.state.project, this.assetId, this.nodeId, key);
sync(): void {
const node = this.node();
if (!node) return;
this.widget.sync({
values: acidValues(node.params),
wave: acidWave(node.params),
wired: this.wired,
idle: new Set((InstrumentAcid.idleParams?.(node.params, this.wired) ?? []) as AcidKey[]),
});
}
tick(): boolean {
return false;
}
hide(): void {}
preview(key: AcidKey, value: number): void {
const node = this.node();
if (node) this.drag.preview(node, key, value);
}
commit(key: AcidKey, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
pickWave(wave: AcidWave): void {
const node = this.node();
if (node && acidWave(node.params) !== wave)
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, 'wave', wave));
}
}
+281
View File
@@ -0,0 +1,281 @@
/**
* The Acid as a device (#421), what its node shows in the instrument graph: a silver plate in the
* way of the TB-303, a button for each wave, a dark screen that draws the filter where it rests and
* at the top of a note's sweep in acid green, and a chrome knob for each number.
*/
import { Graphics, type Text, type TextStyle } from 'pixi.js';
import { ACID_ROWS, ACID_WAVES } from '../../core/instrument/nodes';
import type { AcidWave } from '../../core/soundfx/acid';
import { crispText, Knob, textStyle, Widget } from '../../ui';
import {
ACID_GROUPS,
ACID_KEYS,
ACID_KNOBS,
ACID_LAYOUT,
ACID_PANEL,
type AcidKey,
type AcidTrace,
acidTexts,
acidTrace,
type AcidValues,
columnHz,
RESPONSE_FLOOR_DB,
RESPONSE_HIGH_HZ,
RESPONSE_LOW_HZ,
RESPONSE_TOP_DB,
} from './acidView';
import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel';
import { type Box, KNOB_CAPTION } from './hardwareView';
import { PickButton, type PickLook } from './ReverbPanel';
/** What the panel asks of the node it sits in. */
export interface AcidHost {
preview(key: AcidKey, value: number): void;
commit(key: AcidKey, value: number): void;
pickWave(wave: AcidWave): void;
}
/** What the panel shows of a node. */
export interface AcidShown {
values: AcidValues;
wave: AcidWave;
wired: (key: AcidKey) => boolean;
/** The settings that do nothing as things stand. */
idle: ReadonlySet<AcidKey>;
}
/** Brushed silver, as the 303's own panel. */
const SILVER: PlateLook = {
stops: [
[0, 0xe8e9e5],
[0.5, 0xd3d5d0],
[1, 0xb2b5ae],
],
rim: 0x6c7069,
grain: 0x4a4e48,
};
const WAVE_BUTTONS: PickLook = { face: 0x3a3c3a, hover: 0x4a4c4a, picked: 0xe0552a, shadow: 0x1c1d1c };
const BEZEL = 0x262826;
const BEZEL_TEXT = 0xc8d6bc;
/** Black glass, the filter at rest in a dull green and at the top of its sweep in acid green. */
const SCOPE = {
glass: 0x0b120a,
grid: 0x1c2b1a,
rest: 0x5e8f3a,
top: 0xb8ff3c,
text: 0xb8ff3c,
dim: 0x6f9a52,
};
const IDLE_ALPHA = 0.4;
/** The row of text at the top of the screen. */
const TEXT_ROW = 14;
function scopeStyle(fill: number): TextStyle {
const style = textStyle('small').clone();
style.fill = fill;
style.fontFamily = 'monospace';
style.fontSize = 9;
style.fontWeight = 'bold';
return style;
}
/** The screen: the filter's curve at rest and at the top of a sweep, the room between them lit faintly. */
class AcidScreen extends Widget {
private readonly g = new Graphics();
private readonly rest: Text;
private readonly top: Text;
trace: AcidTrace | null = null;
/** What the texts say now (checks). */
readonly says = { rest: '', top: '' };
constructor() {
super();
this.rest = crispText({ text: '', style: scopeStyle(SCOPE.dim) });
this.top = crispText({ text: '', style: scopeStyle(SCOPE.text) });
this.top.anchor.set(1, 0);
for (const t of [this.rest, this.top]) t.eventMode = 'none';
this.addChild(this.g, this.rest, this.top);
}
show(trace: AcidTrace, texts: { rest: string; top: string }): void {
this.trace = trace;
this.says.rest = texts.rest;
this.says.top = texts.top;
this.invalidate();
}
/** Where the curves are drawn: under the row of text, a little in from the sides. */
get plot(): Box {
return { x: 6, y: TEXT_ROW + 4, w: this.w - 12, h: this.h - TEXT_ROW - 8 };
}
override redraw(): void {
const g = this.g.clear();
g.roundRect(0, 0, this.w, this.h, 3).fill(SCOPE.glass);
this.rest.text = this.says.rest;
this.top.text = this.says.top;
this.rest.position.set(6, 2);
this.top.position.set(this.w - 6, 2);
const trace = this.trace;
if (!trace) return;
const p = this.plot;
const span = Math.log2(RESPONSE_HIGH_HZ / RESPONSE_LOW_HZ);
const xOfHz = (hz: number) => p.x + (Math.log2(hz / RESPONSE_LOW_HZ) / span) * p.w;
const yOfDb = (db: number) => {
const held = Math.max(RESPONSE_FLOOR_DB, Math.min(RESPONSE_TOP_DB, db));
return p.y + ((RESPONSE_TOP_DB - held) / (RESPONSE_TOP_DB - RESPONSE_FLOOR_DB)) * p.h;
};
for (const hz of [100, 1000, 10000]) g.moveTo(xOfHz(hz), p.y).lineTo(xOfHz(hz), p.y + p.h);
for (const db of [0, -24]) g.moveTo(p.x, yOfDb(db)).lineTo(p.x + p.w, yOfDb(db));
g.stroke({ color: SCOPE.grid, width: 1 });
const count = trace.rest.length;
const line = (dbs: number[]) => dbs.map((db, i) => ({ x: xOfHz(columnHz(i, count)), y: yOfDb(db) }));
const rest = line(trace.rest);
const top = line(trace.top);
// The room a note's sweep crosses, a strip per column: one shape for all would cross itself
// where the resting curve's peak stands above the other.
for (let i = 1; i < count; i++)
g.poly([top[i - 1]!, top[i]!, rest[i]!, rest[i - 1]!].flatMap((q) => [q.x, q.y]));
g.fill({ color: SCOPE.top, alpha: 0.1 });
rest.forEach((q, i) => (i === 0 ? g.moveTo(q.x, q.y) : g.lineTo(q.x, q.y)));
g.stroke({ color: SCOPE.rest, width: 1.5, join: 'round' });
top.forEach((q, i) => (i === 0 ? g.moveTo(q.x, q.y) : g.lineTo(q.x, q.y)));
g.stroke({ color: SCOPE.top, width: 2, join: 'round' });
}
}
export class AcidPanel extends Widget {
private readonly plate = new Graphics();
private readonly bezel = new Graphics();
private readonly rules = new Graphics();
readonly screen = new AcidScreen();
readonly knobs = {} as Record<AcidKey, Knob>;
readonly waveButtons: PickButton<AcidWave>[];
private readonly captions = {} as Record<AcidKey, { ink: Text; edge: Text }>;
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;
/** The settings shown idle now (checks). */
idle: ReadonlySet<AcidKey> = new Set();
constructor(host: AcidHost) {
super();
this.minWidth = ACID_PANEL.w;
this.minHeight = ACID_PANEL.h;
this.addChild(this.plate, this.rules, this.bezel, this.screen);
this.title = engrave(this, 'ACID', 16);
const nameStyle = textStyle('small').clone();
nameStyle.fill = BEZEL_TEXT;
nameStyle.fontSize = 9;
nameStyle.fontWeight = 'bold';
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.waveButtons = ACID_WAVES.map((wave) => {
const button = new PickButton(wave.value, wave.label, WAVE_BUTTONS, (picked) => host.pickWave(picked));
this.addChild(button);
return button;
});
this.groupNames = ACID_GROUPS.map((group) => engrave(this, group.label, 9));
for (const key of ACID_KEYS) {
const row = ACID_ROWS[key];
const look = ACID_KNOBS[key];
const knob = new Knob('', row.default, {
min: row.min ?? 0,
max: row.max ?? 0,
step: row.step ?? 1,
taper: look.taper,
default: row.default,
decimals: look.decimals,
size: ACID_LAYOUT.knobs[key].size,
look: 'chrome',
typed: false,
...(look.bipolar && { bipolar: true }),
...(look.format && { format: look.format }),
ink: INK,
onInput: (value) => host.preview(key, value),
onChange: (value) => host.commit(key, value),
});
this.knobs[key] = knob;
this.captions[key] = engrave(this, look.label.toUpperCase(), 9);
this.addChild(knob);
}
}
override measure(): { w: number; h: number } {
return { ...ACID_PANEL };
}
sync(shown: AcidShown): void {
for (const key of ACID_KEYS) {
const knob = this.knobs[key];
knob.setValue(shown.values[key]);
knob.setEnabled(!shown.wired(key));
const alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
knob.alpha = alpha;
this.captions[key].ink.alpha = alpha;
}
for (const button of this.waveButtons) button.pick(button.value === shown.wave);
this.idle = shown.idle;
const trace = acidTrace(shown.values);
const texts = acidTexts(trace, shown.values);
this.screen.show(trace, texts);
this.accent.text = texts.accent;
}
override layout(): void {
const L = ACID_LAYOUT;
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.waveButtons.forEach((button, i) => {
const box = L.waves[i]!;
button.position.set(box.x, box.y);
button.resize(box.w, box.h);
});
for (const key of ACID_KEYS) {
const spot = L.knobs[key];
const knob = this.knobs[key];
const size = knob.measure();
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
knob.resize(size.w, size.h);
placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
}
L.groups.forEach((group, i) =>
placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y),
);
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
}
override redraw(): void {
const L = ACID_LAYOUT;
drawPlate(this.plate.clear(), ACID_PANEL, SILVER, L.screws);
const b = L.bezel;
this.bezel
.clear()
.roundRect(b.x, b.y, b.w, b.h, 8)
.fill(BEZEL)
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8)
.stroke({ color: 0x151615, width: 1 });
// A rule each side of a group's name, engraved as the names are.
const r = this.rules.clear();
L.groups.forEach((group, i) => {
const name = this.groupNames[i]!.ink;
const mid = group.box.y + 6;
const left = group.box.x + 6;
const right = group.box.x + group.box.w - 6;
const gap = name.width / 2 + 6;
const centre = group.box.x + group.box.w / 2;
if (centre - gap > left) r.moveTo(left, mid).lineTo(centre - gap, mid);
if (centre + gap < right) r.moveTo(centre + gap, mid).lineTo(right, mid);
});
r.stroke({ color: INK, width: 1, alpha: 0.5 });
}
}
+163
View File
@@ -0,0 +1,163 @@
/**
* 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',
};
}
+9 -2
View File
@@ -1,5 +1,10 @@
import type { GraphNode } from '../../core/graph';
import { InstrumentChip, InstrumentEnvelope, InstrumentSample } from '../../core/instrument/nodes';
import {
InstrumentAcid,
InstrumentChip,
InstrumentEnvelope,
InstrumentSample,
} from '../../core/instrument/nodes';
import {
SoundFxBitcrusher,
SoundFxCompressor,
@@ -12,6 +17,7 @@ import {
} from '../../core/soundfx/nodes';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { AcidFace } from './AcidFace';
import { ChipFace } from './ChipFace';
import { CompressorFace } from './CompressorFace';
import { CrusherFace } from './CrusherFace';
@@ -29,9 +35,10 @@ import { ReverbFace } from './ReverbFace';
import { SampleFace } from './SampleFace';
import { SaturationFace } from './SaturationFace';
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393, #396–#400), by type. */
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393, #396–#400, #421), by type. */
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
if (node.type === InstrumentChip.type) return new ChipFace(state, assetId, node.id);
if (node.type === InstrumentAcid.type) return new AcidFace(state, assetId, node.id);
if (node.type === InstrumentEnvelope.type) return new EnvelopeFace(state, assetId, node.id);
if (node.type === InstrumentSample.type) return new SampleFace(state, assetId, node.id);
if (node.type === SoundFxCompressor.type) return new CompressorFace(state, assetId, node.id);