Files
feuerwerk/src/editor/song/SongEditor.ts
T
adminandClaude Opus 5.5 42a3759d9d Cut a sound into named clips, as a texture into frames (#357)
A sound could only be cut to one stretch, set on the asset itself. To
play two parts of one file, the file had to be imported twice. Now a
sound is always the whole file, and its clips are the stretches of it,
each with a name and a cut of its own.

- A clip is { id, name, props } on SoundAsset.clips. Its id shares the
  generator with asset ids, so a sound field holds a sound or a clip,
  as a texture field holds a texture or a frame.
- props are the cut: start, end, the fades and their curves, reverse.
  Volume, loop, sound FX and bus stay on the sound for all its clips.
  The cut rows left the sound's schema for CLIP_ROWS.
- findSound resolves either id. Play sound, the Sample of an
  instrument, a Reverb impulse, song items, the scheduler and the test
  sound of the Sound FX tab all go through it.
- Every sound field offers each sound followed by its clips, "Kick",
  "Kick / Clip 1". A clip that is gone reads as "<id>?".
- The Sound tab has a strip under the ruler with the clips as bands.
  A drag there makes a clip, a click picks one, beside the bands the
  sound. Only the picked clip has handles. Reverse, fades and curves
  in the header work on it and are greyed out without one.
- Whole file is gone from the header, as the user asked.
- Picking a clip is a selection of its own, like a frame. The
  inspector shows its name, its cut rows and Delete clip. Delete
  takes it away without asking, as with a frame. Ctrl+Z brings it
  back.
- Copies get fresh clip ids: Duplicate, Embed a copy, a song
  duplicated with its takes, and the copy a split or paste makes of
  an embedded take. An item on a clip plays the same clip of its copy.
- A double-click on a song item playing a clip opens its sound with
  the clip picked. The Sound row of an item on an embedded take
  offers the take and its clips, which no list shows.
- A new take stays the whole sound. Clips are made by hand, as the
  user decided.
- No migration in the code, as the user decided. The one project
  with cut sounds was changed over by hand.

Deviation from the issue: deleting a used clip does not warn. A frame
does not warn either, and the issue had promised "like a frame".

Tests:
- Unit sound-clips (new): 11 green, 7 counter-checks each red
- Unit sounds, embedded: adjusted to clips, green
- e2e 34 (melektaus-atx): ALL PASSED, 90 checks (3 new, 12 rewritten)
- Counter-check in the browser: clips left out of the sound fields,
  the picker check fails
- Gate (melektaus-atx): CHECK EXIT 0, 271 files, 4194 tests

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

569 lines
20 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,
} 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 { playedBus } 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();
/** 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 on the bus of the asset and the master, as in the game (#325). */
readonly music = new Music(
() => this.state.project,
() => playedBus(this.state.project, '', this.asset()?.props ?? {}),
() => {
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.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);
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();
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.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();
}
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);
}