Files
feuerwerk/src/editor/song/SongEditor.ts
T
adminandClaude Opus 5.5 0e73fd85b9
game-maker / gate (push) Successful in 4m20s
game-maker / suite (push) Failing after 15m17s
Route sounds, songs and buses in one Master routing graph (#351)
A project bus could only go into one other bus, and a sound, an
instrument, a pattern or a song into exactly one bus. A shared reverb
needs a sound to go there in part and on dry at the same time. Instead
of a list of sends on every asset, the project gets one routing graph
like the routing of a song (#350), one level up.

- Menu -> Master routing opens a tab with one graph per project. It has
  a node per sound, instrument, pattern, song and bus, named after its
  asset, and one Master. Embedded assets have none, their song routes
  them.
- The nodes follow the assets. Every edit settles the routing in the
  same undo step (KeepRoutingCommand), so an undone delete brings the
  links of its asset back. A new asset gets a node and a link to the
  master. What went only into a deleted bus goes to the master.
- New nodes start in three columns: what plays, the buses, and the
  master a row above, so no link into it runs behind a bus.
- Links are sends: -60 to +12 dB, and a Mode on links out of a bus or a
  song. Out of a sound, an instrument or a pattern a link always takes
  the sound after its volume and FX. Loops are refused.
- A node without a link is silent and says "Goes nowhere".
- The rows Bus and Into are gone. Play nodes with a picked bus still
  overrule the routing.
- A click on a node shows its asset in the inspector, the Master shows
  the master volume and sound FX. A double click opens the asset's tab.
- The graph lives in project.routing and is held to the assets when a
  project is read, but nothing is added there. The editor adds the
  nodes when it opens a project. An asset without a node plays on the
  master, so old projects sound as before without a migration.
- In the mix an asset with one link at 0 dB goes straight into that bus.
  A sound or instrument with more gets a bus of its own with the sends.
  A pattern or song sends from the master of its playing, which is
  where its Mode taps. Buses of the mix can have sends now too.

Found:

- The check "the scheduler plans a looped bar" in e2e 34 wobbles. It
  failed in 2 of 5 runs here and in CI task 580 at 490fcba, before
  this change. HEAD passed it 2 of 2. Not fixed here.

Tests:

- Unit master-routing (new, 10), sound-library (2 new), buses
  rewritten onto the routing, 5 lists without the Bus row. 5 counter
  checks red.
- e2e 34 (melektaus-atx): ALL PASSED, 93 checks (3 new, 2 rewritten).
  Counter check with the Sound tab ignoring the routing: the routing
  check red.
- Gate (melektaus-atx): CHECK EXIT 0, 273 files, 4225 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:18:46 +02:00

598 lines
21 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 { CreateSoundCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { nextAssetName } from '../../core/commands/naming';
import { SetSongCommand } from '../../core/commands/songCommands';
import {
newItem,
SONG_GRID_KEY,
SONG_GRID_OPTIONS,
songLoop,
songMeter,
songSequence,
songTempo,
trackBus,
} from '../../core/music/song';
import { embeddedIn } from '../../core/music/embedded';
import { roundAt } from '../../core/music/ghosts';
import { patternLength } from '../../core/music/pattern';
import { fxVoiceOf } from '../../core/music/needs';
import { songBuses } from '../../core/music/songBuses';
import { sampleCount, takeName, takeStart, takeTrack, takeTrackIndex, wavBytes } from '../../core/music/take';
import { ticksOfSeconds } from '../../core/music/time';
import type { Asset, SongAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import { findSound } from '../../core/model/sound';
import { textOf } from '../../core/model/Properties';
import { assetFxOf } from '../../core/properties/assetFx';
import type { SoundStore } from '../../core/store/SoundStore';
import { Button, Dialog, Dropdown, Label, Menu, ui, VStack, type Widget } from '../../ui';
import { Music } from '../../viewport/music';
import { playedRoute } from '../../core/music/mixer';
import { soundLibrary } from '../../viewport/SoundLibrary';
import {
type InputDevice,
inputDevices,
inputProblem,
PEAK_SPAN,
TakeRecorder,
} from '../../viewport/takeRecorder';
import { activateAsset } from '../actions';
import { AssetTabEditor } from '../AssetTabEditor';
import { GraphCanvas } from '../graph/GraphCanvas';
import { SnapToggle } from '../graph/SnapToggle';
import { claimTransport, releaseTransport } from '../music/transport';
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
import type { EditorState } from '../state';
import { SongTimeline } from './SongTimeline';
import { TakeMeter } from './TakeMeter';
const INFO_WIDTH = 200;
const GRID_WIDTH = 64;
const INPUT_WIDTH = 104;
/** Where the browser keeps the input picked last. */
const INPUT_KEY = 'game-maker.record-input';
/** What the tab shows below its header: the tracks along time, or where they go (#350). */
export type SongView = 'timeline' | 'routing';
/** A take being recorded: the song it goes into, where, and the input it comes from. */
interface Take {
recorder: TakeRecorder;
songId: string;
tick: number;
index: number;
tempo: number;
}
/**
* The Song tab (#334): the tracks and their items, and in the header Play – the Space bar does the
* same – and the grid items snap to. The song plays round its loop through the scheduler (#332); an
* edit is heard from its next window on, a track's level and pan at once.
*
* Record (#342) plays the song from where the ruler says, once through and on past its end, and
* takes the input meanwhile. Stop keeps the take: it goes up as WAV and lands on a track of its own
* under the one clicked last, sound and item one undo step. Esc throws it away.
*
* Routing (#350) shows the same song as a graph instead: a node per track and the output, the links
* between them the sends, edited as any graph is.
*/
export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
/** Public for the check scripts. */
readonly playButton: Button;
readonly gridButton = new Dropdown('Bar', { width: GRID_WIDTH });
readonly recordButton: Button;
readonly inputButton = new Dropdown('Default input', { width: INPUT_WIDTH });
readonly meter = new TakeMeter();
/** The page's clock in seconds when the meters were stepped last, null while they are not. */
private meteredAt: number | null = null;
/** Public for the check scripts. */
readonly timelineButton: Button;
readonly routingButton: Button;
readonly routing: GraphCanvas;
readonly snap = new SnapToggle(() => this.routing);
private shown: SongView = 'timeline';
private readonly pane = new VStack();
/** Heard through the sound FX (#352) and along the routing of the asset (#351), as in the game. */
readonly music = new Music(
() => this.state.project,
() => playedRoute(this.state.project, '', this.asset()?.id ?? ''),
() => {
const asset = this.asset();
return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), songTempo(asset.props));
},
);
/** The song playing, `''` while none is. */
private playingId = '';
private take: Take | null = null;
/** Waiting for the browser to give the input, or for a take to go up: Record waits too. */
private busy = false;
private inputs: InputDevice[] = [];
private inputId = localStorage.getItem(INPUT_KEY) ?? '';
/** What the scheduler was last told, so only a change is passed on. */
private told = '';
private readonly halt = () => this.stop();
constructor(
state: EditorState,
private readonly sounds: SoundStore,
) {
super(state, { infoWidth: INFO_WIDTH });
this.playButton = new Button('Play', {
icon: TOOLBAR_ICON_SVG.play,
minWidth: 72,
tooltip: 'Play the song round its loop (Space)',
onClick: () => this.togglePlay(),
});
this.recordButton = new Button('Record', {
icon: TOOLBAR_ICON_SVG.record,
minWidth: 80,
tooltip: 'Record from the input while the song plays – headphones keep the song out of the take',
onClick: () => this.toggleRecord(),
});
this.timelineButton = new Button('Timeline', {
toggle: true,
tooltip: 'The tracks and their items along time',
onClick: () => this.showView('timeline'),
});
this.routingButton = new Button('Routing', {
toggle: true,
tooltip: 'Where each track goes: into the output or through other tracks',
onClick: () => this.showView('routing'),
});
this.routing = new GraphCanvas(state, 'song');
this.inputButton.onClick = () => void this.openInputMenu();
this.gridButton.onClick = () => this.openGridMenu();
const timeline = new SongTimeline(state);
timeline.onTogglePlay = () => this.togglePlay();
timeline.onStart = (tick) => {
if (this.isPlaying) this.music.current?.seek(tick);
};
timeline.onTracksPreview = (tracks) => {
const asset = this.asset();
if (asset && this.isPlaying) this.music.setBuses(songBuses(this.state.project, { ...asset, tracks }));
};
timeline.onOpenItem = (item, tick) => {
const song = this.asset();
const pattern = item.kind === 'pattern' ? findAssetOf(state.project, item.asset, 'pattern') : undefined;
if (song && pattern)
state.patternPlaces.set(pattern.id, {
songId: song.id,
itemId: item.id,
round: roundAt(item, patternLength(pattern), tick),
});
// An item playing a clip (#357) opens its sound with the clip picked.
const sound = item.kind === 'sound' ? findSound(state.project, item.asset) : null;
if (sound?.clip) state.select({ kind: 'clip', assetId: sound.asset.id, clipId: sound.clip.id });
else activateAsset(state, item.asset);
};
timeline.onDiscardTake = () => this.stop({ discard: true });
this.mount(timeline, [
this.timelineButton,
this.routingButton,
...this.snap.widgets,
this.playButton,
this.recordButton,
this.inputButton,
this.meter,
new Label('Grid', { style: 'muted' }),
this.gridButton,
]);
state.on('load', () => this.stop({ discard: true }));
void this.listInputs();
}
override selectedId(): string {
return this.state.selectedSongId ?? '';
}
override isMine(asset: Asset): asset is SongAsset {
return asset.type === 'song';
}
get view(): SongView {
return this.shown;
}
/** Puts the timeline or the routing below the header; the routing is framed the first time it shows. */
showView(view: SongView): void {
this.shown = view;
this.canvas.visible = view === 'timeline';
this.routing.visible = view === 'routing';
this.timelineButton.setToggled(view === 'timeline');
this.routingButton.setToggled(view === 'routing');
this.pane.relayout();
if (view === 'routing') this.routing.show(this.canvas.currentAssetId);
this.syncViewButtons(this.asset());
this.syncPlayButton();
}
/** The grid is the timeline's, the snap the routing's. */
private syncViewButtons(asset: SongAsset | null): void {
this.gridButton.setEnabled(asset !== null && this.shown === 'timeline');
this.snap.setEnabled(asset !== null && this.shown === 'routing');
}
protected override body(canvas: SongTimeline): Widget {
this.pane.add(canvas, 1);
this.pane.add(this.routing, 1);
this.routing.visible = false;
this.timelineButton.setToggled(true);
return this.pane;
}
protected override fit(): void {
if (this.shown === 'routing') this.routing.fit();
else super.fit();
}
get isPlaying(): boolean {
return this.playingId !== '';
}
get isRecording(): boolean {
return this.take !== null;
}
togglePlay(): void {
if (this.isPlaying) this.stop();
else this.play();
}
/** Plays the song shown from where the ruler says, round its loop. */
play(): void {
const asset = this.asset();
if (!asset) return;
claimTransport(this.halt);
this.playingId = asset.id;
this.told = this.settingsOf(asset);
this.canvas.clearMeters();
this.music.setBuses(songBuses(this.state.project, asset));
void this.music.play(songSequence(this.state.project, asset), {
tempo: songTempo(asset.props),
meter: songMeter(asset.props),
loop: songLoop(asset),
from: this.canvas.start,
});
this.syncPlayButton();
}
/** Ends playing; a take being recorded is kept, or with `discard` thrown away. */
stop(options: { discard?: boolean } = {}): void {
releaseTransport(this.halt);
this.music.stop();
this.playingId = '';
this.canvas.playhead = null;
const take = this.take;
this.take = null;
this.meter.set(0);
this.hideMeters();
this.canvas.hushMeters();
if (take && options.discard) {
take.recorder.cancel();
this.canvas.take = null;
this.state.setNotice('Threw the take away');
} else if (take) void this.keep(take);
this.canvas.invalidate();
this.syncPlayButton();
}
toggleRecord(): void {
if (this.isRecording) this.stop();
else void this.record();
}
/** Asks for the input, then plays the song from the cursor – once, on past its end – and takes it in. */
async record(): Promise<void> {
const asked = this.asset();
if (!asked || this.busy) return;
this.stop();
this.busy = true;
this.syncPlayButton();
let recorder: TakeRecorder;
try {
recorder = await TakeRecorder.open(this.inputId);
} catch (error) {
this.state.setNotice(inputProblem(error), 'error');
return;
} finally {
this.busy = false;
this.syncPlayButton();
}
const asset = this.asset();
if (!asset || asset.id !== asked.id) {
recorder.cancel();
return;
}
const tempo = songTempo(asset.props);
const take: Take = {
recorder,
songId: asset.id,
tick: this.canvas.start,
index: takeTrackIndex(asset.tracks, this.canvas.pickedTrack),
tempo,
};
claimTransport(this.halt);
this.take = take;
this.playingId = asset.id;
this.told = this.settingsOf(asset);
this.showTake(take, false);
this.syncPlayButton();
this.canvas.clearMeters();
ui().focus.focus(this.canvas);
this.music.setBuses(songBuses(this.state.project, asset));
await this.music.play(songSequence(this.state.project, asset), {
tempo,
meter: songMeter(asset.props),
loop: null,
from: take.tick,
});
const anchor = this.music.current?.startedAt;
if (this.take !== take || anchor === null || anchor === undefined) return;
recorder.startAt(takeStart(anchor, soundLibrary.latency(), recorder.inputLatency));
this.state.setNotice('Recording – Space or Stop keeps the take, Esc throws it away');
void this.listInputs();
}
/** Only called while the tab is visible: the playhead follows what is heard, a take grows. */
tick(): void {
if (!this.isPlaying) return;
this.stepMeters();
this.canvas.playhead = this.music.current?.position().ticks ?? null;
const take = this.take;
if (take) {
this.showTake(take, false);
this.meter.set(take.recorder.level);
if (take.recorder.full) {
this.state.setNotice('The take is as long as a sound may be, so it was stopped');
this.stop();
}
}
this.canvas.invalidate();
}
/** The meters of the tracks and Out a frame on, from the loudest samples on their buses since. */
private stepMeters(): void {
const asset = this.asset();
if (!asset || asset.id !== this.playingId) return;
const now = performance.now() / 1000;
const dt = this.meteredAt === null ? 0 : now - this.meteredAt;
this.meteredAt = now;
const names = new Map(asset.tracks.map((track) => [trackBus(asset.id, track.id), track.id]));
const peaks = soundLibrary.busPeaks([...names.keys(), this.music.outBus]);
const tracks = new Map<string, [number, number]>();
for (const [name, id] of names) tracks.set(id, peaks.get(name) ?? [0, 0]);
this.canvas.stepMeters(tracks, peaks.get(this.music.outBus) ?? [0, 0], now, dt);
}
/** Lets the taps of the meters go: the tab is not seen, or nothing plays. */
hideMeters(): void {
if (this.meteredAt === null) return;
this.meteredAt = null;
soundLibrary.unmeter();
}
protected override closed(assetId: string): void {
if (assetId === this.playingId) this.stop();
}
override destroy(options?: Parameters<AssetTabEditor<SongAsset, SongTimeline>['destroy']>[0]): void {
this.music.stop();
this.take?.recorder.cancel();
super.destroy(options);
}
private showTake(take: Take, uploading: boolean): void {
const shown = this.canvas.currentAssetId === take.songId;
this.canvas.take = shown
? {
index: take.index,
tick: take.tick,
length: ticksOfSeconds(take.recorder.seconds, take.tempo),
peaks: take.recorder.peaks,
ticksPerPeak: ticksOfSeconds(PEAK_SPAN / take.recorder.sampleRate, take.tempo),
uploading,
}
: null;
this.canvas.invalidate();
}
/** The take as WAV to the server, then onto its track. */
private async keep(take: Take): Promise<void> {
this.busy = true;
this.showTake(take, true);
this.syncPlayButton();
const parts = await take.recorder.finish();
if (sampleCount(parts) === 0) {
this.endUpload();
this.state.setNotice('Nothing was recorded');
return;
}
const name = takeName(embeddedIn(this.state.project, take.songId).map((a) => a.name));
const seconds = sampleCount(parts) / take.recorder.sampleRate;
await this.upload(take, wavBytes(parts, take.recorder.sampleRate), name, seconds);
}
private async upload(
take: Take,
wav: Uint8Array<ArrayBuffer>,
name: string,
seconds: number,
): Promise<void> {
this.busy = true;
this.syncPlayButton();
try {
const blob = new Blob([wav], { type: 'audio/wav' });
const uploaded = await this.sounds.upload(blob, 'audio/wav', `${name}.wav`);
this.endUpload();
this.place(take, uploaded.file, name, seconds);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
new Dialog('The take could not be uploaded', reason, [
{
label: 'Download WAV',
onClick: () => {
this.endUpload();
downloadWav(wav, name);
},
},
{
label: 'Discard',
danger: true,
onClick: () => {
this.endUpload();
this.state.setNotice('Threw the take away');
},
},
{ label: 'Retry', primary: true, onClick: () => void this.upload(take, wav, name, seconds) },
]).openCentered();
}
}
private endUpload(): void {
this.busy = false;
this.canvas.take = null;
this.canvas.invalidate();
this.syncPlayButton();
}
/**
* The sound, its track and its item, one undo step. The sound is the song's own (#355); with the
* song gone meanwhile, it is a project asset by itself.
*/
private place(take: Take, file: string, name: string, seconds: number): void {
const song = findAssetOf(this.state.project, take.songId, 'song');
if (!song) {
const create = new CreateSoundCommand(nextAssetName(this.state.project, name), file);
if (this.state.execute(create))
this.state.setNotice(`Recorded "${create.asset.name}" – its song is gone`);
return;
}
const create = new CreateSoundCommand(name, file, song.id);
const track = takeTrack();
const tracks = [...song.tracks];
tracks.splice(Math.min(take.index, tracks.length), 0, track);
const length = Math.max(1, Math.floor(ticksOfSeconds(seconds, songTempo(song.props))));
const item = newItem({ track: track.id, kind: 'sound', asset: create.asset.id, tick: take.tick, length });
const placed = new SetSongCommand(song.id, { tracks, items: [...song.items, item] }, 'Record take');
if (!this.state.execute(new CompoundCommand('Record take', [create, placed]))) return;
if (this.canvas.currentAssetId === song.id) this.canvas.pick([item.id]);
this.state.setNotice(`Recorded "${name}" (${clockText(seconds)})`);
}
private async listInputs(): Promise<void> {
try {
this.inputs = await inputDevices();
} catch {
this.inputs = [];
}
this.syncInputButton();
}
private async openInputMenu(): Promise<void> {
await this.listInputs();
new Menu([
{
label: 'Default input',
current: !this.inputs.some((d) => d.id === this.inputId),
onSelect: () => this.pickInput(''),
},
...this.inputs.map((device) => ({
label: device.label,
current: device.id === this.inputId,
onSelect: () => this.pickInput(device.id),
})),
]).openBelow(this.inputButton);
}
private pickInput(id: string): void {
this.inputId = id;
if (id) localStorage.setItem(INPUT_KEY, id);
else localStorage.removeItem(INPUT_KEY);
this.syncInputButton();
}
private syncInputButton(): void {
this.inputButton.setText(this.inputs.find((d) => d.id === this.inputId)?.label ?? 'Default input');
}
private openGridMenu(): void {
const asset = this.asset();
if (!asset) return;
const current = textOf(asset.props[SONG_GRID_KEY] ?? 'bar');
new Menu(
SONG_GRID_OPTIONS.map((option) => ({
label: option.label,
current: option.value === current,
onSelect: () =>
this.state.execute(new SetAssetPropertyCommand(asset.id, SONG_GRID_KEY, option.value)),
})),
).openBelow(this.gridButton);
}
private settingsOf(asset: SongAsset): string {
return JSON.stringify([songTempo(asset.props), songMeter(asset.props), songLoop(asset)]);
}
/** What changed while playing goes to the scheduler and the tracks; another song shown stops it. */
private follow(asset: SongAsset | null): void {
if (!this.isPlaying) return;
if (!asset || asset.id !== this.playingId) {
this.stop();
return;
}
this.music.edit(songSequence(this.state.project, asset));
this.music.setBuses(songBuses(this.state.project, asset));
const settings = this.settingsOf(asset);
if (settings === this.told) return;
this.told = settings;
const scheduler = this.music.current;
scheduler?.setTempo(songTempo(asset.props));
scheduler?.setMeter(songMeter(asset.props));
scheduler?.setLoop(this.take ? null : songLoop(asset));
}
private syncPlayButton(): void {
const recording = this.isRecording;
this.playButton.setIcon(this.isPlaying && !recording ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.play);
this.playButton.setText(this.isPlaying && !recording ? 'Stop' : 'Play');
this.recordButton.setIcon(recording ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.record);
this.recordButton.setText(recording ? 'Stop' : 'Record');
const asset = this.asset();
this.playButton.setEnabled(asset !== null && !this.busy);
this.recordButton.setEnabled(asset !== null && (recording || !this.busy));
this.inputButton.setEnabled(!recording && !this.busy);
}
protected override infoText(asset: SongAsset): string {
const tracks = asset.tracks.length;
const items = asset.items.length;
return `${tracks} ${tracks === 1 ? 'track' : 'tracks'} · ${items} ${items === 1 ? 'item' : 'items'}`;
}
protected override syncExtras(asset: SongAsset | null): void {
if (this.routing.currentAssetId !== (asset?.id ?? '')) this.routing.show(asset?.id ?? '');
this.fitButton.setEnabled(asset !== null);
this.syncViewButtons(asset);
this.syncPlayButton();
const grid = asset ? textOf(asset.props[SONG_GRID_KEY] ?? 'bar') : 'bar';
this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? 'Bar');
this.follow(asset);
}
}
/** "1:05" */
function clockText(seconds: number): string {
const whole = Math.round(seconds);
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;
}
/** Hands the take to the browser as a file, for when the server would not take it. */
function downloadWav(wav: Uint8Array<ArrayBuffer>, name: string): void {
const url = URL.createObjectURL(new Blob([wav], { type: 'audio/wav' }));
const link = document.createElement('a');
link.href = url;
link.download = `${name}.wav`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}