Files
feuerwerk/src/editor/song/SongEditor.ts
T
adminandClaude Opus 5.5 e191dc4b2e
game-maker / gate (push) Successful in 4m14s
game-maker / suite (push) Successful in 17m7s
Record the game view from a behavior into a video asset (#405)
A game can now film itself. A behavior starts and stops a take, and
the editor turns it into a video asset with the run's picture and the
sound the player hears. The exported game hands the file over as a
download, since there is no project to keep it in.

- New action nodes `Start recording` (Name, Resolution, FPS) and
  `Stop recording`. An empty name becomes `Recording <timestamp>`.
- Resolution is `Game view`, `720p` or `1080p`. For the fixed sizes
  the height is set and the width follows the camera.
- The picture is the camera only, without hints, overlays or the
  letterbox. It is drawn into a render texture of the target size,
  so a smaller window still gives full 1080p.
- `PictureReader` reads the texture back through pixel buffers with
  a fence. The editor never waits on the GPU, and a frame with all
  three buffers busy is dropped and counted.
- Nothing of the take reaches the visible canvas. A first version
  drew tiles into it and copied them out, and with the low-latency
  canvas that midway state flickered on screen.
- The sound is tapped behind the master limiter in the FX worklet
  and encoded as Opus.
- H.264 is chosen when the browser has it, VP9 otherwise. A small
  own Matroska writer packs both tracks, no new dependency. It names
  the frame rate and BT.709 so ffmpeg keeps both.
- A second `Start recording` is ignored with a warning in the log.
  A take goes on through `Load scene` and a restart, pauses with the
  editor and is saved when the run ends. It stops at 2 GB.
- The editor uploads the take on the existing video route and makes
  the asset once the run is over. The video tab gets a Download
  button for the converted MP4.
- `downloadBlob` in `src/ui` replaces the song editor's own copy.

Findings:
- `On start` runs before the game screen knows the world. A take
  therefore fixes its size on the first frame that has a camera.
- Without a frame rate in the file, ffmpeg turned 30 fps into 59.94.
- The worker's poster JPEG is off in colour for every upload (red
  255 comes out as 229, BT.601). Not touched here.

Tests:
- Unit recording + matroska: 21 green, plus the node catalog cases.
  The Matroska test reads the file back with its own EBML reader.
- e2e 67 (new) on melektaus-atx: ALL PASSED. 1080p30 from a
  1280x800 window gives a 1920x1080 MP4 with sound and no dropped
  frame. Every 4th pixel is checked, a green quarter catches an
  upside-down picture. A take ended by Esc is saved with the
  timestamp name.
- e2e 60j: the exported game downloads an `.mkv` with EBML magic.
- The flicker is gone on the user's machine without `?vsync`.
- Gate on melektaus-atx: CHECK EXIT 0, 299 files, 4586 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:11:05 +02:00

601 lines
22 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 {
DEFAULT_SONG_GRID,
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, downloadBlob, Dropdown, Label, Menu, ui, VStack, type Widget } from '../../ui';
import { Music } from '../../viewport/music';
import { MASTER_MIX } 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 { GRAPH_SNAPS_AT_FIRST, SnapToggle } from '../graph/SnapToggle';
import { BakeButton } from '../music/BakeButton';
import { clockText } from '../music/timeView';
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(DEFAULT_SONG_GRID, { 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 bakeButton: BakeButton;
readonly routing: GraphCanvas;
readonly snap = new SnapToggle(() => this.routing, GRAPH_SNAPS_AT_FIRST);
private shown: SongView = 'timeline';
private readonly pane = new VStack();
/** Heard through the sound FX (#352), then straight into the master: a preview leaves the routing out (#368). */
readonly music = new Music(
() => this.state.project,
() => MASTER_MIX,
() => {
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.bakeButton = new BakeButton(
state,
sounds,
() => this.asset(),
() => this.refresh(),
);
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,
this.bakeButton,
]);
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] ?? DEFAULT_SONG_GRID);
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;
const text = `${tracks} ${tracks === 1 ? 'track' : 'tracks'} · ${items} ${items === 1 ? 'item' : 'items'}`;
const bake = this.bakeButton.statusText(asset);
return bake ? `${text} · ${bake}` : text;
}
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 = textOf(asset?.props[SONG_GRID_KEY] ?? DEFAULT_SONG_GRID);
this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? DEFAULT_SONG_GRID);
this.bakeButton.sync();
this.follow(asset);
}
}
/** 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 {
downloadBlob(new Blob([wav], { type: 'audio/wav' }), `${name}.wav`);
}