A Chip could play a sound, but finding a good one meant typing a dozen numbers. sfxr's buttons do that in one click: a kind of sound drawn anew every time, a wild draw, or a small nudge. The Instrument tab now has the same, and plays the result at once. - Preset menu with Pickup, Laser, Explosion, Powerup, Hit, Jump and Blip. It puts a new graph in place of the instrument's, as one undo step: Notes for all 128 notes, Chip, Envelope, Filter, Bitcrusher, Output. The Chip keeps its own frequency with follow note off, so every key plays the same effect, and sustain 0 ends it by itself. - The presets are sfxr's generators, worked out into Hz, semitones and seconds with sfxr's own formulas, in chipPresets.ts. sfxr's envelope holds and falls in a line, ours falls quick at first. So the decay is three times sfxr's hold and fall. - Randomize draws every number of the Chips, Envelopes, Filters and Bitcrushers anew, as sfxr's randomizer does. Waves, filter types, the Bitcrusher's mix and an Envelope's from and to stay. - Mutate moves each of those numbers a little, one in two. A 0 stays 0, so nothing that was off comes on. - An input fed by an edge is left alone by both. Both are grey while the graph has none of those nodes. - Audition, off by default as in the Pattern tab, plays the note last played again 150 ms after a change to the graph rests. - Preset, Randomize and Mutate always play that note, for up to 2 s. - ReplaceGraphCommand swaps nodes and edges and keeps groups and notes. - The info on the right of the header kept 220 px and was cut off with the four new buttons. 140 px still fit "No sound picked". Not done: punch, filter sweeps and the phaser of sfxr (#341). Tests: - Unit chip-presets (new) 7. Counter-check: a wrong Hz formula and fed inputs not left alone turn 3 of them red. Unit tests of the instrument tab, graph commands and sound FX nodes: 49 files, 851 green. - e2e 34 (melektaus-atx): ALL PASSED, 64 checks (4 new): Preset, Randomize and Mutate, Audition, three undos back to the Sample. - Screenshot with a Laser preset: the header fits at 1280 px, the chain is fitted, the Chip is a saw with its duty grey. - Gate (melektaus-atx): CHECK EXIT 0, 256 files, 3969 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||
import { ReplaceGraphCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||
import {
|
||
CHIP_PRESETS,
|
||
type ChipPreset,
|
||
chipPresetGraph,
|
||
hasChipNumbers,
|
||
mutatedParams,
|
||
type ParamChange,
|
||
randomizedParams,
|
||
} from '../../core/instrument/chipPresets';
|
||
import {
|
||
instrumentBuildFor,
|
||
instrumentCheck,
|
||
instrumentParams,
|
||
instrumentSourcesOf,
|
||
playedNotes,
|
||
} from '../../core/instrument/compile';
|
||
import { MIDDLE_C, noteName } from '../../core/instrument/notes';
|
||
import type { Asset, InstrumentAsset } from '../../core/model/Asset';
|
||
import { playNote } from '../../runtime/systems/behaviorContext';
|
||
import {
|
||
Button,
|
||
Dropdown,
|
||
HStack,
|
||
Label,
|
||
Menu,
|
||
NumberField,
|
||
Stack,
|
||
Theme,
|
||
VStack,
|
||
type Widget,
|
||
} from '../../ui';
|
||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||
import { AssetTabEditor } from '../AssetTabEditor';
|
||
import { GraphCanvas } from '../graph/GraphCanvas';
|
||
import { SnapToggle } from '../graph/SnapToggle';
|
||
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
||
import type { EditorState } from '../state';
|
||
import { PianoKeyboard } from './PianoKeyboard';
|
||
|
||
/** Room kept for "N notes", or for the reason the graph does not compile: "No sound picked" fits, a longer one is cut. */
|
||
const INFO_WIDTH = 140;
|
||
const PARAM_WIDTH = 48;
|
||
/** Whose voices the tab's Stop silences: its own, never a running game's. */
|
||
const PREVIEW_OWNER = 'instrument-preview';
|
||
/** How long a note is heard after a Preset, Randomize, Mutate or a change with Audition on: a held one is let go then. */
|
||
const AUDITION_SECONDS = 2;
|
||
/** How long a change waits to be heard with Audition on, so dragging a number plays once it rests. */
|
||
const AUDITION_DELAY_MS = 150;
|
||
|
||
/**
|
||
* The nodes of one instrument (#331), and below them its keys: a note is heard while a key is held,
|
||
* by the pointer or by the computer keyboard. The header holds a value for each of its parameters –
|
||
* the tab's own, never written into the asset – and a Stop for everything the tab still plays. Its
|
||
* Preset menu, Randomize and Mutate make 8-bit sounds the way sfxr does (#329) and play them at once.
|
||
*/
|
||
export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanvas> {
|
||
readonly snap = new SnapToggle(() => this.canvas);
|
||
/** Public for the check scripts. */
|
||
readonly keyboard = new PianoKeyboard();
|
||
readonly stopButton: Button;
|
||
readonly presetButton: Dropdown;
|
||
readonly randomizeButton: Button;
|
||
readonly mutateButton: Button;
|
||
/** Whether a change to the graph is heard; off by default, as in the Pattern tab. */
|
||
readonly auditionButton: Button;
|
||
/** The note a Preset, Randomize, Mutate or Audition plays: the one last played. */
|
||
lastNote = MIDDLE_C;
|
||
private readonly octaveLabel = new Label('', { style: 'small' });
|
||
private readonly paramBar = new HStack({ gap: Theme.gap, align: 'center' });
|
||
private readonly paramFields = new Map<string, NumberField>();
|
||
private values: Record<string, number> = {};
|
||
private defaults: Record<string, number> = {};
|
||
private paramsKey = '';
|
||
private valuesFor = '';
|
||
/** The voices the tab started that may still be playing – what a moved field reaches. */
|
||
private voices: number[] = [];
|
||
/** The graph as last heard or shown, by `soundPrint`, and whose it was. */
|
||
private heard = '';
|
||
private heardFor = '';
|
||
private auditionTimer: ReturnType<typeof setTimeout> | undefined;
|
||
|
||
constructor(state: EditorState) {
|
||
super(state, { infoWidth: INFO_WIDTH });
|
||
this.stopButton = new Button('Stop', { icon: TOOLBAR_ICON_SVG.stop, onClick: () => this.stop() });
|
||
this.presetButton = new Dropdown('Preset', {
|
||
tooltip: 'Make the instrument an 8-bit sound of that kind, drawn anew every time',
|
||
onClick: () => this.openPresetMenu(),
|
||
});
|
||
this.randomizeButton = new Button('Randomize', {
|
||
tooltip: 'Draw every number of the Chips, Envelopes, Filters and Bitcrushers anew',
|
||
onClick: () => this.randomize(),
|
||
});
|
||
this.mutateButton = new Button('Mutate', {
|
||
tooltip: 'Move some numbers of the Chips, Envelopes, Filters and Bitcrushers a little',
|
||
onClick: () => this.mutate(),
|
||
});
|
||
this.auditionButton = new Button('Audition', {
|
||
toggle: true,
|
||
tooltip: 'Play the last note again when the instrument changes',
|
||
onClick: () => this.auditionButton.setToggled(!this.auditionButton.toggled),
|
||
});
|
||
this.keyboard.onNoteOn = (note) => this.playNote(note);
|
||
this.keyboard.onNoteOff = (voice) => soundLibrary.release(voice);
|
||
this.keyboard.isPlaying = (voice) => soundLibrary.playing(voice);
|
||
this.keyboard.onStop = () => this.stop();
|
||
this.keyboard.onOctave = () => this.syncOctave();
|
||
this.mount(new GraphCanvas(state, 'instrument'), [
|
||
...this.snap.widgets,
|
||
this.stopButton,
|
||
this.presetButton,
|
||
this.randomizeButton,
|
||
this.mutateButton,
|
||
this.auditionButton,
|
||
this.octaveLabel,
|
||
this.paramBar,
|
||
]);
|
||
this.syncOctave();
|
||
state.on('load', () => {
|
||
this.valuesFor = '';
|
||
this.heardFor = '';
|
||
});
|
||
}
|
||
|
||
override selectedId(): string {
|
||
return this.state.selectedInstrumentId ?? '';
|
||
}
|
||
|
||
override isMine(asset: Asset): asset is InstrumentAsset {
|
||
return asset.type === 'instrument';
|
||
}
|
||
|
||
protected override body(canvas: GraphCanvas): Widget {
|
||
const column = new VStack();
|
||
column.add(canvas, 1);
|
||
column.add(this.keyboard);
|
||
return column;
|
||
}
|
||
|
||
/** What the header's parameter fields are set to – the check scripts read this. */
|
||
get previewValues(): Record<string, number> {
|
||
return { ...this.values };
|
||
}
|
||
|
||
setPreviewValue(key: string, value: number): void {
|
||
if (!(key in this.values)) return;
|
||
this.paramFields.get(key)?.setNumber(value);
|
||
this.setValue(key, value);
|
||
}
|
||
|
||
/** Starts one note as the keys do, held until it is released or for `duration` seconds; gives back its voice. */
|
||
playNote(note: number, duration = 0): number {
|
||
const asset = this.asset();
|
||
if (!asset) return 0;
|
||
this.lastNote = note;
|
||
const struck = { note, velocity: 1, volume: 1, duration };
|
||
const voice = playNote(
|
||
{ project: this.state.project, audio: soundLibrary },
|
||
asset.id,
|
||
struck,
|
||
PREVIEW_OWNER,
|
||
{ ...this.values },
|
||
);
|
||
if (voice) this.voices.push(voice);
|
||
return voice;
|
||
}
|
||
|
||
/** Plays the note last played on its own, for a while: what Preset, Randomize, Mutate and Audition do. */
|
||
audition(): number {
|
||
clearTimeout(this.auditionTimer);
|
||
this.stop();
|
||
return this.playNote(this.lastNote, AUDITION_SECONDS);
|
||
}
|
||
|
||
applyPreset(preset: ChipPreset): void {
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
const label = CHIP_PRESETS.find((option) => option.value === preset)?.label ?? preset;
|
||
if (!this.state.execute(new ReplaceGraphCommand(asset.id, chipPresetGraph(preset), `Preset ${label}`)))
|
||
return;
|
||
this.canvas.fit();
|
||
this.audition();
|
||
}
|
||
|
||
randomize(): void {
|
||
const asset = this.asset();
|
||
if (asset) this.change('Randomize', randomizedParams(asset.graph));
|
||
}
|
||
|
||
mutate(): void {
|
||
const asset = this.asset();
|
||
if (asset) this.change('Mutate', mutatedParams(asset.graph));
|
||
}
|
||
|
||
private change(label: string, changes: readonly ParamChange[]): void {
|
||
const asset = this.asset();
|
||
if (!asset || changes.length === 0) return;
|
||
const commands = changes.map((c) => new SetNodeParamCommand(asset.id, c.node, c.key, c.value));
|
||
if (this.state.execute(new CompoundCommand(label, commands))) this.audition();
|
||
}
|
||
|
||
private openPresetMenu(): void {
|
||
if (!this.asset()) return;
|
||
new Menu(
|
||
CHIP_PRESETS.map((preset) => ({ label: preset.label, onSelect: () => this.applyPreset(preset.value) })),
|
||
).openBelow(this.presetButton);
|
||
}
|
||
|
||
stop(): void {
|
||
this.keyboard.releaseAll();
|
||
soundLibrary.stopOwner(PREVIEW_OWNER);
|
||
this.voices = [];
|
||
this.keyboard.syncRinging();
|
||
}
|
||
|
||
/** The notes the keys show as making a sound (test hook). */
|
||
soundingNotes(): number[] {
|
||
const asset = this.asset();
|
||
if (!asset) return [];
|
||
const sources = instrumentSourcesOf(this.state.project);
|
||
return [...playedNotes(asset.graph)]
|
||
.filter((note) => instrumentBuildFor(asset, note, sources).layers.length > 0)
|
||
.sort((a, b) => a - b);
|
||
}
|
||
|
||
private setValue(key: string, value: number): void {
|
||
this.values[key] = value;
|
||
this.voices = this.voices.filter((voice) => soundLibrary.tune(voice, { [key]: value }));
|
||
}
|
||
|
||
protected override infoText(asset: InstrumentAsset): string {
|
||
const build = instrumentCheck(asset, instrumentSourcesOf(this.state.project));
|
||
if (build.reason) return build.reason;
|
||
const count = this.soundingNotes().length;
|
||
return `${count} ${count === 1 ? 'note' : 'notes'}`;
|
||
}
|
||
|
||
protected override syncExtras(asset: InstrumentAsset | null): void {
|
||
this.snap.setEnabled(Boolean(asset));
|
||
this.stopButton.setEnabled(Boolean(asset));
|
||
this.presetButton.setEnabled(Boolean(asset));
|
||
this.randomizeButton.setEnabled(Boolean(asset && hasChipNumbers(asset.graph)));
|
||
this.mutateButton.setEnabled(Boolean(asset && hasChipNumbers(asset.graph)));
|
||
this.auditionButton.setEnabled(Boolean(asset));
|
||
this.keyboard.setEnabled(Boolean(asset));
|
||
if (!asset) this.keyboard.releaseAll();
|
||
this.keyboard.setSounding(new Set(this.soundingNotes()));
|
||
this.syncParams(asset);
|
||
this.followEdits(asset);
|
||
}
|
||
|
||
/** With Audition on, a change to the graph shown is heard once it rests; another asset shown is not a change. */
|
||
private followEdits(asset: InstrumentAsset | null): void {
|
||
const print = asset ? soundPrint(asset) : '';
|
||
const changed = (asset?.id ?? '') === this.heardFor && print !== this.heard;
|
||
this.heard = print;
|
||
this.heardFor = asset?.id ?? '';
|
||
if (!changed || !this.auditionButton.toggled) return;
|
||
clearTimeout(this.auditionTimer);
|
||
this.auditionTimer = setTimeout(() => this.audition(), AUDITION_DELAY_MS);
|
||
}
|
||
|
||
private syncOctave(): void {
|
||
const octave = this.keyboard.bottomOctave;
|
||
this.octaveLabel.setText(`Keys ${noteName((octave + 1) * 12)} to ${noteName((octave + 3) * 12 + 4)}`);
|
||
}
|
||
|
||
/** As in the Sound FX tab: another asset starts from its defaults, an edit keeps a moved value. */
|
||
private syncParams(asset: InstrumentAsset | null): void {
|
||
const params = asset ? instrumentParams(asset.graph) : [];
|
||
const same = (asset?.id ?? '') === this.valuesFor;
|
||
this.valuesFor = asset?.id ?? '';
|
||
const values: Record<string, number> = {};
|
||
const defaults: Record<string, number> = {};
|
||
for (const port of params) {
|
||
const fallback = port.default ?? 0;
|
||
const kept = same && this.defaults[port.key] === fallback ? this.values[port.key] : undefined;
|
||
values[port.key] = kept ?? fallback;
|
||
defaults[port.key] = fallback;
|
||
}
|
||
this.values = values;
|
||
this.defaults = defaults;
|
||
|
||
const key = params.map((p) => p.key).join('\u0000');
|
||
if (key !== this.paramsKey) {
|
||
this.paramsKey = key;
|
||
for (const child of this.paramBar.removeChildren()) child.destroy({ children: true });
|
||
this.paramFields.clear();
|
||
for (const port of params) {
|
||
this.paramBar.add(new Label(port.key, { style: 'small' }));
|
||
const field = new NumberField(values[port.key], {
|
||
width: PARAM_WIDTH,
|
||
step: 0.1,
|
||
onInput: (value) => this.setValue(port.key, value),
|
||
onChange: (value) => this.setValue(port.key, value),
|
||
});
|
||
this.paramFields.set(port.key, field);
|
||
this.paramBar.add(field);
|
||
}
|
||
if (this.paramBar.parent instanceof Stack) this.paramBar.parent.relayout();
|
||
this.paramBar.relayout();
|
||
}
|
||
for (const [name, field] of this.paramFields) field.setNumber(this.values[name] ?? 0);
|
||
}
|
||
}
|
||
|
||
/** How a note of the instrument sounds hangs on its nodes' settings and edges, not on where they lie. */
|
||
function soundPrint(asset: InstrumentAsset): string {
|
||
const { nodes, edges } = asset.graph;
|
||
return JSON.stringify([nodes.map((n) => [n.id, n.type, n.params]), edges.map((e) => [e.from, e.to])]);
|
||
}
|