import { CreateSoundCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands'; import { CompoundCommand } from '../../core/commands/CompoundCommand'; import { SetSongCommand } from '../../core/commands/songCommands'; import { newItem, SONG_GRID_KEY, SONG_GRID_OPTIONS, songLoop, songMeter, songSequence, songTempo, } from '../../core/music/song'; import { roundAt } from '../../core/music/ghosts'; import { patternLength } from '../../core/music/pattern'; 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 { textOf } from '../../core/model/Properties'; import type { SoundStore } from '../../core/store/SoundStore'; import { Button, Dialog, Dropdown, Label, Menu, ui } from '../../ui'; import { Music } from '../../viewport/music'; 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 { 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'; /** 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. */ export class SongEditor extends AssetTabEditor { /** 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(); readonly music = new Music(() => this.state.project); /** 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.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.onOpenPattern = (item, tick) => { const song = this.asset(); const pattern = findAssetOf(state.project, item.asset, 'pattern'); if (song && pattern) state.patternPlaces.set(pattern.id, { songId: song.id, itemId: item.id, round: roundAt(item, patternLength(pattern), tick), }); activateAsset(state, item.asset); }; timeline.onDiscardTake = () => this.stop({ discard: true }); this.mount(timeline, [ 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 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 { 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(); } override destroy(options?: Parameters['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 { 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 song = findAssetOf(this.state.project, take.songId, 'song'); const name = takeName( song?.name ?? 'Recording', this.state.project.assets.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, name: string, seconds: number, ): Promise { 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 song gone meanwhile, only the sound. */ private place(take: Take, file: string, name: string, seconds: number): void { const create = new CreateSoundCommand(name, file); const song = findAssetOf(this.state.project, take.songId, 'song'); if (!song) { if (this.state.execute(create)) this.state.setNotice(`Recorded "${name}" – its song is gone`); return; } 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 { try { this.inputs = await inputDevices(); } catch { this.inputs = []; } this.syncInputButton(); } private async openInputMenu(): Promise { 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 { this.fitButton.setEnabled(asset !== null); this.gridButton.setEnabled(asset !== null); 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, 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); }