Files
feuerwerk/src/editor/instrument/InstrumentEditor.ts
T
adminandClaude Opus 5.5 f3ae19431a
game-maker / gate (push) Successful in 3m25s
game-maker / suite (push) Failing after 14m27s
Make an 8-bit sound with a Preset, Randomize or Mutate (#329)
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>
2026-10-06 23:29:39 +02:00

313 lines
12 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.
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])]);
}