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:
1 parent
3443368855
commit
d844f2734a
18 files changed
+1615
-15
No files matched your search
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user