diff --git a/api/index.php b/api/index.php index 4ca149c1..84636ee4 100644 --- a/api/index.php +++ b/api/index.php @@ -93,7 +93,7 @@ require __DIR__ . '/lib/TextureStore.php'; require __DIR__ . '/lib/UserStore.php'; /** Has to clear the largest single upload below, which is a sound; the web server has its own say. */ -const MAX_BODY_BYTES = 20 * 1024 * 1024; +const MAX_BODY_BYTES = 256 * 1024 * 1024; /** Upload ceiling for a single image. The editor downscales larger pictures before sending. */ const MAX_TEXTURE_BYTES = 4 * 1024 * 1024; @@ -101,8 +101,8 @@ const MAX_TEXTURE_BYTES = 4 * 1024 * 1024; /** Upload ceiling for a single font. A font cannot be shrunk on the way, so this is a plain limit. */ const MAX_FONT_BYTES = 4 * 1024 * 1024; -/** The same for a sound, four times as high: one music track is worth more than four typefaces. */ -const MAX_SOUND_BYTES = 16 * 1024 * 1024; +/** The same for a sound, far higher: a recorded take is plain WAV, about 46 minutes in mono. */ +const MAX_SOUND_BYTES = 256 * 1024 * 1024; set_error_handler(static function (int $severity, string $message, string $file, int $line): bool { // PHP calls the handler for a call silenced with `@` as well, only with less reported: that diff --git a/src/core/music/Scheduler.ts b/src/core/music/Scheduler.ts index 774743d2..af605523 100644 --- a/src/core/music/Scheduler.ts +++ b/src/core/music/Scheduler.ts @@ -110,6 +110,11 @@ export class Scheduler { return this.anchor !== null; } + /** The second of the audio clock where it plays from (before any change of tempo); null while stopped. */ + get startedAt(): number | null { + return this.anchor; + } + /** Played to its end, and none of its notes is heard any more. */ get done(): boolean { return this.ended && this.voices.every((voice) => !this.host.playing(voice)); diff --git a/src/core/music/take.ts b/src/core/music/take.ts new file mode 100644 index 00000000..eb664ed9 --- /dev/null +++ b/src/core/music/take.ts @@ -0,0 +1,111 @@ +/** + * A take recorded in the Song tab (#342): the input's samples from where the song was heard to start + * on, as 16-bit mono WAV. The samples come with the frame of the audio clock they were taken at, so + * the take is cut to the first one that belongs to the start – what was played to the song lands on + * the bar it was played to, the output's and the input's delay taken off. + */ +import { nextNumberedName } from '../commands/naming'; +import { newTrack, type SongTrack } from './song'; + +/** What a sound may weigh on the server, less a little for the header. */ +export const MAX_TAKE_BYTES = 256 * 1024 * 1024 - 1024; +export const WAV_HEADER_BYTES = 44; + +/** A run of samples as the input gave them, from `frame` of the audio clock on. */ +export interface TakeChunk { + frame: number; + samples: Int16Array; +} + +export function toPcm16(samples: Float32Array): Int16Array { + const out = new Int16Array(samples.length); + for (let i = 0; i < samples.length; i++) { + const s = Math.max(-1, Math.min(1, samples[i]!)); + out[i] = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7fff); + } + return out; +} + +/** + * The second of the audio clock a sample has to be taken at to belong to the start: the song is + * heard `output` seconds after its anchor, and what is played to it reaches the input `input` later. + */ +export function takeStart(anchor: number, output: number, input: number): number { + return anchor + Math.max(0, output) + Math.max(0, input); +} + +/** The chunks from `from` frames on: what came before is cut, a chunk that straddles it in two. */ +export function trimmedTake(chunks: readonly TakeChunk[], from: number): Int16Array[] { + const out: Int16Array[] = []; + for (const chunk of chunks) { + const end = chunk.frame + chunk.samples.length; + if (end <= from) continue; + out.push(chunk.frame >= from ? chunk.samples : chunk.samples.subarray(Math.round(from - chunk.frame))); + } + return out; +} + +export function sampleCount(parts: readonly Int16Array[]): number { + return parts.reduce((n, part) => n + part.length, 0); +} + +/** The first `max` samples of the parts. */ +export function cappedTake(parts: readonly Int16Array[], max: number): Int16Array[] { + const out: Int16Array[] = []; + let left = max; + for (const part of parts) { + if (left <= 0) break; + out.push(part.length <= left ? part : part.subarray(0, left)); + left -= part.length; + } + return out; +} + +/** How many samples fit into a sound the server takes. */ +export function maxTakeSamples(): number { + return Math.floor((MAX_TAKE_BYTES - WAV_HEADER_BYTES) / 2); +} + +/** The samples as a WAV file: RIFF, PCM, one channel, 16 bits. */ +export function wavBytes(parts: readonly Int16Array[], sampleRate: number): Uint8Array { + const count = sampleCount(parts); + const bytes = new Uint8Array(WAV_HEADER_BYTES + count * 2); + const view = new DataView(bytes.buffer); + const text = (at: number, value: string) => { + for (let i = 0; i < value.length; i++) view.setUint8(at + i, value.charCodeAt(i)); + }; + const rate = Math.round(sampleRate); + text(0, 'RIFF'); + view.setUint32(4, 36 + count * 2, true); + text(8, 'WAVE'); + text(12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, rate, true); + view.setUint32(28, rate * 2, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + text(36, 'data'); + view.setUint32(40, count * 2, true); + let at = WAV_HEADER_BYTES; + for (const part of parts) { + for (let i = 0; i < part.length; i++, at += 2) view.setInt16(at, part[i]!, true); + } + return bytes; +} + +/** "Song – Take 1", 2, 3 …: the first number no asset of the project has. */ +export function takeName(songName: string, assetNames: readonly string[]): string { + return nextNumberedName(assetNames, `${songName} – Take`); +} + +/** Where the take's own track goes: right under the track clicked last, else at the bottom. */ +export function takeTrackIndex(tracks: readonly SongTrack[], picked: string): number { + const at = tracks.findIndex((t) => t.id === picked); + return at < 0 ? tracks.length : at + 1; +} + +export function takeTrack(): SongTrack { + return { ...newTrack(1), name: 'Recording' }; +} diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index f7903811..5957536f 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -317,7 +317,7 @@ export class Editor implements PlayHost { this.soundFxEditor = new SoundFxEditor(state); this.instrumentEditor = new InstrumentEditor(state); this.patternEditor = new PatternEditor(state); - this.songEditor = new SongEditor(state); + this.songEditor = new SongEditor(state, sounds); this.textureEditor = new TextureEditor(state); this.vectorArtEditor = new VectorArtEditor(state); this.pixelArtEditor = new PixelArtEditor(state); diff --git a/src/editor/debug/songHooks.ts b/src/editor/debug/songHooks.ts index ea2d0f9f..3b579e46 100644 --- a/src/editor/debug/songHooks.ts +++ b/src/editor/debug/songHooks.ts @@ -34,6 +34,9 @@ export function songHooks({ state, editor }: HookContext) { playAt: () => ({ ...centreOf(songEditor.playButton), text: songEditor.playButton.text }), fitAt: () => centreOf(songEditor.fitButton), playing: () => songEditor.isPlaying, + /** Record (#342): its button, whether a take is being made, and the input's level. */ + recordAt: () => ({ ...centreOf(songEditor.recordButton), text: songEditor.recordButton.text }), + recording: () => songEditor.isRecording, /** The notes and clips the scheduler handed to the player since Play, and where it stands. */ planned: () => [...songEditor.music.planned], clips: () => [...songEditor.music.clips], diff --git a/src/editor/panels/icons/record.svg b/src/editor/panels/icons/record.svg new file mode 100644 index 00000000..a85c2a64 --- /dev/null +++ b/src/editor/panels/icons/record.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/editor/panels/toolbarIcons.ts b/src/editor/panels/toolbarIcons.ts index ad423d47..82166b35 100644 --- a/src/editor/panels/toolbarIcons.ts +++ b/src/editor/panels/toolbarIcons.ts @@ -7,6 +7,7 @@ import eye from './icons/eye.svg?raw'; import menu from './icons/menu.svg?raw'; import pause from './icons/pause.svg?raw'; import play from './icons/play.svg?raw'; +import record from './icons/record.svg?raw'; import redo from './icons/redo.svg?raw'; import shader from './icons/shader.svg?raw'; import showLabel from './icons/show-label.svg?raw'; @@ -15,7 +16,7 @@ import showSymbol from './icons/show-symbol.svg?raw'; import stop from './icons/stop.svg?raw'; import undo from './icons/undo.svg?raw'; -export const TOOLBAR_ICON_SVG = { menu, undo, redo, eye, play, pause, stop }; +export const TOOLBAR_ICON_SVG = { menu, undo, redo, eye, play, pause, stop, record }; /** The switches behind the eye: a type's symbol, its name, a collider's outline, a camera's shader. */ export const VISIBILITY_ICON_SVG: Record = { diff --git a/src/editor/song/SongEditor.ts b/src/editor/song/SongEditor.ts index ffd826b0..e5a832ce 100644 --- a/src/editor/song/SongEditor.ts +++ b/src/editor/song/SongEditor.ts @@ -1,5 +1,8 @@ -import { SetAssetPropertyCommand } from '../../core/commands/assetCommands'; +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, songBuses, @@ -8,37 +11,77 @@ import { songSequence, songTempo, } from '../../core/music/song'; +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 { Button, Dropdown, Label, Menu } from '../../ui'; +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) { + constructor( + state: EditorState, + private readonly sounds: SoundStore, + ) { super(state, { infoWidth: INFO_WIDTH }); this.playButton = new Button('Play', { icon: TOOLBAR_ICON_SVG.play, @@ -46,6 +89,13 @@ export class SongEditor extends AssetTabEditor { 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(); @@ -57,8 +107,17 @@ export class SongEditor extends AssetTabEditor { if (asset && this.isPlaying) this.music.setBuses(songBuses({ id: asset.id, tracks })); }; timeline.onOpenPattern = (patternId) => activateAsset(state, patternId); - this.mount(timeline, [this.playButton, new Label('Grid', { style: 'muted' }), this.gridButton]); - state.on('load', () => this.stop()); + 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 { @@ -73,6 +132,10 @@ export class SongEditor extends AssetTabEditor { return this.playingId !== ''; } + get isRecording(): boolean { + return this.take !== null; + } + togglePlay(): void { if (this.isPlaying) this.stop(); else this.play(); @@ -95,27 +158,235 @@ export class SongEditor extends AssetTabEditor { this.syncPlayButton(); } - stop(): void { + /** 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(); } - /** Only called while the tab is visible: the playhead follows what is heard. */ + 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(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; @@ -149,12 +420,19 @@ export class SongEditor extends AssetTabEditor { const scheduler = this.music.current; scheduler?.setTempo(songTempo(asset.props)); scheduler?.setMeter(songMeter(asset.props)); - scheduler?.setLoop(songLoop(asset)); + scheduler?.setLoop(this.take ? null : songLoop(asset)); } private syncPlayButton(): void { - this.playButton.setIcon(this.isPlaying ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.play); - this.playButton.setText(this.isPlaying ? 'Stop' : 'Play'); + 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 { @@ -165,10 +443,26 @@ export class SongEditor extends AssetTabEditor { protected override syncExtras(asset: SongAsset | null): void { this.fitButton.setEnabled(asset !== null); - this.playButton.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); +} diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index 86f37d99..d256088e 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -108,6 +108,9 @@ const PATTERN_COLOR = 0x35598f; const SOUND_COLOR = 0x2f6f52; const MISSING_COLOR = 0x6b3a3a; const MUTE_COLOR = 0xd08a2c; +const TAKE_COLOR = 0xb33a3a; +/** The id the track of a take being recorded is drawn under, until it is made. */ +const TAKE_TRACK = '__take'; const SOLO_COLOR = 0xc9c23a; /** Height of an item's name strip; what it plays is drawn below. */ const LABEL_STRIP = 15; @@ -159,6 +162,19 @@ type Gesture = | { kind: 'trim'; anchor: SongItem; by: number; shift: ShiftPress } | { kind: 'slider'; track: string; part: 'volume' | 'pan'; value: number }; +/** + * A take being recorded (#342): the track it gets at `index`, where it starts, how far it got, and + * its waveform – a peak per `ticksPerPeak` ticks. + */ +export interface TakeView { + index: number; + tick: number; + length: number; + peaks: readonly number[]; + ticksPerPeak: number; + uploading: boolean; +} + /** An asset dragged in from the panel, where it would land. */ interface Drop { kind: SongItemKind; @@ -208,6 +224,8 @@ export class SongTimeline extends Widget { private readonly loading = new Set(); /** Where the playhead stands while the song plays, in ticks; null while it does not. */ playhead: number | null = null; + /** While set, the lanes take no edits: only a pan, Space to stop and Esc to throw it away. */ + take: TakeView | null = null; onTogglePlay: () => void = () => {}; /** Where playing starts was moved: a playing song jumps there. */ onStart: (tick: number) => void = () => {}; @@ -215,6 +233,7 @@ export class SongTimeline extends Widget { onTracksPreview: (tracks: readonly SongTrack[]) => void = () => {}; /** A pattern item was double-clicked. */ onOpenPattern: (patternId: string) => void = () => {}; + onDiscardTake: () => void = () => {}; constructor(private readonly state: EditorState) { super(); @@ -451,6 +470,11 @@ export class SongTimeline extends Widget { this.onTogglePlay(); return true; } + if (this.take) { + if (event.key !== 'Escape') return false; + this.onDiscardTake(); + return true; + } if (mod && key === 'c') return this.copy(); if (mod && key === 'v') return this.paste(); if (mod && key === 'd') return this.duplicate() || true; @@ -527,6 +551,7 @@ export class SongTimeline extends Widget { drop: Drop | null; hover: { id: string; part: ItemPart } | null; cursor: string; + take: (Omit & { peaks: number }) | null; } { return { ...this.view, @@ -536,6 +561,7 @@ export class SongTimeline extends Widget { drop: this.drop, hover: this.hover, cursor: String(this.cursor ?? ''), + take: this.take ? { ...this.take, peaks: this.take.peaks.length } : null, }; } @@ -604,6 +630,7 @@ export class SongTimeline extends Widget { this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY }; return; } + if (this.take) return; const g = this.inLanes(p); const tick = xToTick(this.view, g.x); if (area === 'heads') return this.pressHead(asset, e, g.y, p.x); @@ -727,7 +754,7 @@ export class SongTimeline extends Widget { const gesture = this.gesture; if (!asset) return; if (!gesture) { - this.followHover(asset, local, g); + if (!this.take) this.followHover(asset, local, g); return; } const tick = xToTick(this.view, g.x); @@ -1072,7 +1099,18 @@ export class SongTimeline extends Widget { private shownTracks(asset: SongAsset): SongTrack[] { const g = this.gesture; - return g?.kind === 'slider' ? this.shownTracksWith(asset, g) : asset.tracks; + const tracks = g?.kind === 'slider' ? this.shownTracksWith(asset, g) : asset.tracks; + if (!this.take) return tracks; + const shown = [...tracks]; + shown.splice(Math.min(this.take.index, shown.length), 0, { + id: TAKE_TRACK, + name: 'Recording', + volume: 0, + pan: 0, + mute: false, + solo: false, + }); + return shown; } private shownTracksWith(asset: SongAsset, g: Extract): SongTrack[] { @@ -1241,7 +1279,7 @@ export class SongTimeline extends Widget { private drawItems(asset: SongAsset, lw: number, lh: number): void { const c = Theme.colors; - const rows = indexOf(asset.tracks); + const rows = indexOf(this.shownTracks(asset)); const picked = new Set(this.pickedIds); const labels: Placed[] = []; for (const item of this.shownItems(asset)) { @@ -1267,9 +1305,36 @@ export class SongTimeline extends Widget { if (this.hover?.id === item.id && this.hover.part !== 'body') this.drawGrip(box, this.hover.part); if (box.w > 18) labels.push({ text: this.labelOf(item), x: box.x + 4, y: box.y + 1, w: box.w - 8 }); } + const take = this.take && this.drawTake(this.take, lw); + if (take) labels.push(take); this.syncTexts(this.itemTexts, this.itemLabels, labels); } + /** The take growing on its track as it is recorded, red, with what came in so far. */ + private drawTake(take: TakeView, lw: number): Placed | null { + const box = itemBox(this.view, take.index, { tick: take.tick, length: Math.max(1, take.length) }); + if (box.x > lw || box.x + box.w < 0) return null; + this.itemsG + .roundRect(box.x, box.y, box.w, box.h, 3) + .fill({ color: TAKE_COLOR, alpha: take.uploading ? 0.55 : 0.9 }) + .stroke({ width: 1, color: 0x000000, alpha: 0.45, alignment: 1 }); + const mid = box.y + LABEL_STRIP + (box.h - LABEL_STRIP) / 2; + const half = (box.h - LABEL_STRIP - 4) / 2; + const perPx = 1 / this.view.pxPerTick / take.ticksPerPeak; + for (let x = Math.max(0, Math.ceil(box.x)); x < Math.min(lw, box.x + box.w); x += 2) { + const from = Math.floor((x - box.x) * perPx); + const to = Math.max(from + 1, Math.floor((x + 2 - box.x) * perPx)); + let peak = 0; + for (let i = from; i < to && i < take.peaks.length; i++) peak = Math.max(peak, take.peaks[i]!); + if (from >= take.peaks.length) break; + const reach = Math.max(0.5, peak * half); + this.itemsG.rect(x, mid - reach, 1.5, reach * 2).fill({ color: 0xffffff, alpha: 0.7 }); + } + if (box.w <= 18) return null; + const text = take.uploading ? 'Uploading…' : 'Recording…'; + return { text, x: box.x + 4, y: box.y + 1, w: box.w - 8 }; + } + /** A line where what the item plays starts over, the label strip left clear. */ private drawSeams(item: SongItem, box: Box, lw: number): void { for (const tick of this.seamsOf(item)) { diff --git a/src/editor/song/TakeMeter.ts b/src/editor/song/TakeMeter.ts new file mode 100644 index 00000000..2c17aa72 --- /dev/null +++ b/src/editor/song/TakeMeter.ts @@ -0,0 +1,44 @@ +import { Graphics } from 'pixi.js'; +import { type Size, Theme, Widget } from '../../ui'; + +const WIDTH = 36; +const HEIGHT = 8; +/** From here on the bar turns red: the take is close to clipping. */ +const HOT = 0.9; + +/** The input's level while a take is recorded (#342), a bar that is empty otherwise. */ +export class TakeMeter extends Widget { + private readonly bar = new Graphics(); + private level = 0; + + constructor() { + super(); + this.addChild(this.bar); + this.eventMode = 'none'; + } + + override measure(): Size { + return { w: WIDTH, h: HEIGHT }; + } + + set(level: number): void { + const held = Math.max(0, Math.min(1, level)); + if (Math.abs(held - this.level) < 0.005) return; + this.level = held; + this.invalidate(); + } + + override redraw(): void { + const c = Theme.colors; + const y = Math.round((this.h - HEIGHT) / 2); + this.bar + .clear() + .rect(0, y, WIDTH, HEIGHT) + .fill(c.viewport) + .stroke({ width: 1, color: c.border, alignment: 1 }); + if (this.level > 0) + this.bar + .rect(1, y + 1, Math.max(1, (WIDTH - 2) * this.level), HEIGHT - 2) + .fill(this.level >= HOT ? 0xd04040 : 0x4fae6a); + } +} diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index cbde3b10..7b921f2e 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -710,6 +710,11 @@ export class SoundLibrary implements AudioSink { return context.currentTime; } + /** The context everything plays on, opened where there is none yet: a take is recorded on its clock (#342). */ + liveContext(): AudioContext { + return this.audio(); + } + /** Seconds between the audio clock and what is heard; 0 where the browser does not say. */ latency(): number { const context = this.context; diff --git a/src/viewport/takeRecorder.ts b/src/viewport/takeRecorder.ts new file mode 100644 index 00000000..62dfecc9 --- /dev/null +++ b/src/viewport/takeRecorder.ts @@ -0,0 +1,203 @@ +import { cappedTake, maxTakeSamples, type TakeChunk, toPcm16, trimmedTake } from '../core/music/take'; +import { soundLibrary } from './SoundLibrary'; +import takeWorklet from './takeWorklet.ts?worker&url'; + +/** Samples per column of the waveform drawn while recording. */ +export const PEAK_SPAN = 512; +/** How fast the level meter falls back, per message of the worklet. */ +const LEVEL_FALL = 0.85; +/** How long the worklet gets to send its last samples before the take is closed without them. */ +const LAST_WORDS_MS = 1000; + +export interface InputDevice { + id: string; + label: string; +} + +const loaded = new WeakMap>(); + +function loadWorklet(context: AudioContext): Promise { + let loading = loaded.get(context); + if (!loading) { + loading = context.audioWorklet.addModule(takeWorklet); + loaded.set(context, loading); + void loading.catch(() => loaded.delete(context)); + } + return loading; +} + +/** The inputs the browser offers; their names only once recording has been allowed. */ +export async function inputDevices(): Promise { + const devices = (await media()?.enumerateDevices()) ?? []; + // Chrome lists its own "default" and "communications" besides the real ones. + return devices + .filter((d) => d.kind === 'audioinput' && !['', 'default', 'communications'].includes(d.deviceId)) + .map((d, i) => ({ id: d.deviceId, label: d.label || `Input ${i + 1}` })); +} + +/** Missing outside a secure page, whatever the types say. */ +function media(): MediaDevices | undefined { + const page: { mediaDevices?: MediaDevices } = navigator; + return page.mediaDevices; +} + +/** Why recording could not start, said so a person can do something about it. */ +export function inputProblem(error: unknown): string { + const name = error instanceof Error ? error.name : ''; + if (name === 'NotAllowedError' || name === 'SecurityError') + return 'Recording is not allowed – allow the microphone with the lock in the address bar.'; + if (name === 'NotFoundError' || name === 'OverconstrainedError') return 'No microphone found.'; + if (name === 'NotReadableError') return 'The microphone is busy in another program.'; + return `Cannot record: ${error instanceof Error ? error.message : String(error)}`; +} + +/** + * The microphone on the sound library's clock (#342): its samples arrive with the frame they were + * taken at, so the take can be cut exactly where the song was heard to start. The browser's echo + * cancelling, noise filter and gain control are off – they bend music. Nothing is played back. + */ +export class TakeRecorder { + readonly sampleRate: number; + /** Seconds the input lags behind what reaches it, where the browser says. */ + readonly inputLatency: number; + /** One per `PEAK_SPAN` samples since the start, 0 to 1. */ + readonly peaks: number[] = []; + /** The loudest of the last moment, 0 to 1. */ + level = 0; + /** As long as a sound may be: nothing more is kept. */ + full = false; + private readonly chunks: TakeChunk[] = []; + /** The frame of the start, unknown until the song has started. */ + private from = Infinity; + private kept = 0; + private peakChunk = 0; + private peak = 0; + private peakCount = 0; + private closed = false; + private done: (() => void) | null = null; + + private constructor( + context: AudioContext, + private readonly stream: MediaStream, + private readonly nodes: AudioNode[], + private readonly node: AudioWorkletNode, + ) { + this.sampleRate = context.sampleRate; + const settings = stream.getAudioTracks()[0]?.getSettings() as { latency?: number } | undefined; + this.inputLatency = Number.isFinite(settings?.latency) ? Math.max(0, settings!.latency!) : 0; + node.port.onmessage = (event: MessageEvent) => { + if (event.data === 'done') this.done?.(); + else this.take(event.data as { frame: number; samples: Float32Array }); + }; + } + + /** Asks for the input – the first time, the browser asks the user – and starts taking samples. */ + static async open(deviceId: string): Promise { + const devices = media(); + if (!devices) throw new Error('this page cannot record – it needs https'); + const context = soundLibrary.liveContext(); + const stream = await devices.getUserMedia({ + audio: { + ...(deviceId && { deviceId: { ideal: deviceId } }), + channelCount: { ideal: 1 }, + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + }, + }); + try { + await loadWorklet(context); + if (context.state === 'suspended') await context.resume(); + const source = context.createMediaStreamSource(stream); + const node = new AudioWorkletNode(context, 'game-maker-take', { + numberOfInputs: 1, + numberOfOutputs: 1, + outputChannelCount: [1], + }); + // Silent, but on the way to the speakers: a node nobody listens to may not be run. + const sink = context.createGain(); + sink.gain.value = 0; + source.connect(node).connect(sink).connect(context.destination); + return new TakeRecorder(context, stream, [source, node, sink], node); + } catch (error) { + for (const track of stream.getTracks()) track.stop(); + throw error; + } + } + + /** The second of the audio clock the take starts at; what came before is dropped. */ + startAt(seconds: number): void { + this.from = Math.round(seconds * this.sampleRate); + this.feed(); + } + + get started(): boolean { + return Number.isFinite(this.from); + } + + /** Seconds kept since the start. */ + get seconds(): number { + return this.kept / this.sampleRate; + } + + /** Stops taking samples and gives back the take, cut to its start. */ + async finish(): Promise { + if (!this.closed) { + const last = new Promise((resolve) => { + this.done = resolve; + setTimeout(resolve, LAST_WORDS_MS); + }); + this.node.port.postMessage('stop'); + await last; + this.close(); + } + const from = this.started ? this.from : Infinity; + return cappedTake(trimmedTake(this.chunks, from), maxTakeSamples()); + } + + /** Stops taking samples and forgets them. */ + cancel(): void { + this.close(); + this.chunks.length = 0; + } + + private close(): void { + if (this.closed) return; + this.closed = true; + this.node.port.onmessage = null; + for (const node of this.nodes) node.disconnect(); + for (const track of this.stream.getTracks()) track.stop(); + } + + private take({ frame, samples }: { frame: number; samples: Float32Array }): void { + let loudest = 0; + for (const s of samples) loudest = Math.max(loudest, Math.abs(s)); + this.level = Math.max(loudest, this.level * LEVEL_FALL); + if (this.full) return; + // Before the start is known everything is kept; after it, only what is not before it. + if (this.started && frame + samples.length <= this.from) return; + this.chunks.push({ frame, samples: toPcm16(samples) }); + this.feed(); + } + + /** Counts and draws what has arrived since the start. */ + private feed(): void { + if (!this.started) return; + const max = maxTakeSamples(); + for (; this.peakChunk < this.chunks.length; this.peakChunk++) { + const { frame, samples } = this.chunks[this.peakChunk]!; + for (let i = Math.max(0, Math.round(this.from - frame)); i < samples.length; i++) { + this.peak = Math.max(this.peak, Math.abs(samples[i]!) / 0x8000); + if (++this.peakCount === PEAK_SPAN) { + this.peaks.push(this.peak); + this.peak = 0; + this.peakCount = 0; + } + if (++this.kept >= max) { + this.full = true; + return; + } + } + } + } +} diff --git a/src/viewport/takeWorklet.ts b/src/viewport/takeWorklet.ts new file mode 100644 index 00000000..815f12a0 --- /dev/null +++ b/src/viewport/takeWorklet.ts @@ -0,0 +1,52 @@ +/** + * The audio thread's side of a take (#342): mixes the input down to one channel and sends it on in + * runs of `BATCH` samples, each with the frame of the audio clock its first sample was taken at. + * Loaded into the context by the recorder; nothing on the page imports it. + */ + +declare const currentFrame: number; +declare class AudioWorkletProcessor { + readonly port: MessagePort; +} +declare function registerProcessor(name: string, processor: new () => object): void; + +const BATCH = 2048; + +class TakeProcessor extends AudioWorkletProcessor { + private batch = new Float32Array(BATCH); + private filled = 0; + private frame = 0; + private stopped = false; + + constructor() { + super(); + this.port.onmessage = () => { + this.flush(); + this.stopped = true; + this.port.postMessage('done'); + }; + } + + process(inputs: Float32Array[][]): boolean { + if (this.stopped) return false; + const channels = inputs[0] ?? []; + const length = channels[0]?.length ?? 0; + for (let i = 0; i < length; i++) { + if (this.filled === 0) this.frame = currentFrame + i; + let sum = 0; + for (const channel of channels) sum += channel[i]!; + this.batch[this.filled++] = sum / channels.length; + if (this.filled === BATCH) this.flush(); + } + return true; + } + + private flush(): void { + if (this.filled === 0) return; + const samples = this.batch.slice(0, this.filled); + this.port.postMessage({ frame: this.frame, samples }, [samples.buffer]); + this.filled = 0; + } +} + +registerProcessor('game-maker-take', TakeProcessor); diff --git a/tests/core/take.test.ts b/tests/core/take.test.ts new file mode 100644 index 00000000..2c86f5f9 --- /dev/null +++ b/tests/core/take.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { newTrack } from '../../src/core/music/song'; +import { + cappedTake, + MAX_TAKE_BYTES, + maxTakeSamples, + sampleCount, + takeName, + takeStart, + takeTrack, + takeTrackIndex, + toPcm16, + trimmedTake, + wavBytes, +} from '../../src/core/music/take'; + +const pcm = (...values: number[]) => Int16Array.from(values); + +describe('a recorded take (#342)', () => { + it('turns samples into 16 bits, clipped at full scale', () => { + expect([...toPcm16(Float32Array.from([0, 1, -1, 0.5, 2, -3]))]).toEqual([ + 0, 32767, -32768, 16384, 32767, -32768, + ]); + }); + + it('starts where the song is heard back through the input', () => { + expect(takeStart(10, 0.02, 0.01)).toBeCloseTo(10.03, 9); + expect(takeStart(10, -1, -1)).toBe(10); + }); + + it('drops what came before the start and cuts the chunk it falls in', () => { + const chunks = [ + { frame: 0, samples: pcm(1, 2, 3, 4) }, + { frame: 4, samples: pcm(5, 6, 7, 8) }, + { frame: 8, samples: pcm(9, 10) }, + ]; + expect(trimmedTake(chunks, 6).map((part) => [...part])).toEqual([ + [7, 8], + [9, 10], + ]); + expect(trimmedTake(chunks, 4).map((part) => [...part])).toEqual([ + [5, 6, 7, 8], + [9, 10], + ]); + expect(sampleCount(trimmedTake(chunks, 0))).toBe(10); + expect(trimmedTake(chunks, 20)).toEqual([]); + }); + + it('keeps no more than a sound may hold', () => { + expect(cappedTake([pcm(1, 2, 3), pcm(4, 5, 6)], 4).map((part) => [...part])).toEqual([[1, 2, 3], [4]]); + expect(maxTakeSamples() * 2 + 44).toBeLessThanOrEqual(MAX_TAKE_BYTES); + expect(MAX_TAKE_BYTES).toBeLessThan(256 * 1024 * 1024); + }); + + it('writes a mono 16-bit PCM WAV the server knows by its RIFF header', () => { + const bytes = wavBytes([pcm(1, -2), pcm(300)], 48000); + const view = new DataView(bytes.buffer); + const text = (at: number) => String.fromCharCode(...bytes.subarray(at, at + 4)); + expect(bytes.length).toBe(44 + 6); + expect([text(0), text(8), text(12), text(36)]).toEqual(['RIFF', 'WAVE', 'fmt ', 'data']); + expect(view.getUint32(4, true)).toBe(36 + 6); + expect(view.getUint16(20, true)).toBe(1); + expect(view.getUint16(22, true)).toBe(1); + expect(view.getUint32(24, true)).toBe(48000); + expect(view.getUint32(28, true)).toBe(96000); + expect(view.getUint16(34, true)).toBe(16); + expect(view.getUint32(40, true)).toBe(6); + expect([view.getInt16(44, true), view.getInt16(46, true), view.getInt16(48, true)]).toEqual([1, -2, 300]); + }); + + it('is named after its song, numbered past the takes there are', () => { + expect(takeName('Theme', ['Theme'])).toBe('Theme – Take 1'); + expect(takeName('Theme', ['Theme', 'Theme – Take 1', 'Theme – Take 2'])).toBe('Theme – Take 3'); + }); + + it('gets a track of its own under the one clicked last, else at the bottom', () => { + const tracks = [newTrack(1), newTrack(2), newTrack(3)]; + expect(takeTrackIndex(tracks, tracks[0]!.id)).toBe(1); + expect(takeTrackIndex(tracks, tracks[2]!.id)).toBe(3); + expect(takeTrackIndex(tracks, '')).toBe(3); + expect(takeTrack().name).toBe('Recording'); + expect(takeTrack().id).not.toBe(takeTrack().id); + }); +}); diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index 770380e3..c2e4e192 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -105,11 +105,12 @@ describe('icon files', () => { 'menu', 'pause', 'play', + 'record', 'redo', 'stop', 'undo', ]); - expect(new Set(Object.values(TOOLBAR_ICON_SVG)).size).toBe(7); + expect(new Set(Object.values(TOOLBAR_ICON_SVG)).size).toBe(8); }); it('has one picture per switch behind the eye', () => { diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index 48321aa3..67158fa5 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -12,7 +12,8 @@ import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs'; * wheel zooms tracks or time, the strip above the ruler sets the loop. A head mutes, solos and sets * the volume of its track, a double-click sets a slider back to 0, its menu adds one. Space plays the song through the scheduler: * the pattern round and round at the song's tempo, the sound as a clip, each track on its own bus. - * A pattern started meanwhile takes over, and a double-click opens a pattern item in its tab. + * A pattern started meanwhile takes over, and a double-click opens a pattern item in its tab. Record + * takes chromium's test tone onto a track of its own (#342), Esc throws a take away. * Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and clips * and what the buses were set to, as in e2e 64. * @@ -601,6 +602,98 @@ export async function song(t) { ); const left = await rig.remove(); + // 65l. Record (#342) plays from the cursor and takes chromium's test tone: the take grows red on a + // track of its own under the one clicked, Space keeps it – WAV up, sound, track and item one + // undo step –, Esc throws the next one away. + await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); + await sleep(200); + const before = await model(); + p = await json("__gm.song.headAt(0, 'name')"); + await click(p.x, p.y); + p = await json(`__gm.song.rulerAt(${BAR})`); + await click(p.x, p.y); + p = await json('__gm.song.recordAt()'); + await click(p.x, p.y); + const growing = await waitFor( + () => + json('{ view: __gm.song.view(), recording: __gm.song.recording(), button: __gm.song.recordAt().text }'), + (s) => s.view.take?.peaks > 120, + 60, + ); + await shot('03-recording'); + check( + 'Record plays from the cursor and grows the take, red, on a new track under the one clicked', + growing.recording && + growing.button === 'Stop' && + growing.view.playhead !== null && + growing.view.take?.index === 1 && + growing.view.take.tick === BAR && + growing.view.take.length > 0 && + !growing.view.take.uploading, + JSON.stringify(growing), + ); + await press('Space'); + const kept = await waitFor( + () => model(), + (m) => m.tracks.length === before.tracks.length + 1, + 60, + ); + const takeTrack = kept.tracks[1]; + const takeItem = kept.items.find((i) => i.track === takeTrack?.id); + const takeAsset = await json( + `(() => { const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(takeItem?.asset ?? '')}); return a ? { name: a.name, file: a.file, type: a.type } : null; })()`, + ); + const songName = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).name`); + const takeSeconds = takeAsset ? await evaluate(`__gm.sound.load(${JSON.stringify(takeAsset.file)})`) : 0; + check( + 'Space keeps it: a WAV sound named after the song, on the track "Recording", from the cursor, as long as it is', + kept.tracks[0]?.id === before.tracks[0]?.id && + takeTrack?.name === 'Recording' && + takeItem?.kind === 'sound' && + takeItem.tick === BAR && + takeAsset?.type === 'sound' && + takeAsset.name === `${songName} – Take 1` && + /^[a-f0-9]{64}\.wav$/.test(takeAsset.file) && + takeSeconds > 1 && + Math.abs(takeItem.length - Math.floor(takeSeconds * 960)) <= 1 && + !(await json('__gm.song.recording()')) && + (await json('__gm.song.view()')).take === null, + JSON.stringify({ kept, takeAsset, takeSeconds }), + ); + await press('z', { ctrl: true }); + const undone = await model(); + check( + 'one Ctrl+Z takes sound, track and item back', + undone.tracks.length === before.tracks.length && + undone.items.length === before.items.length && + (await json( + `__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(takeItem?.asset ?? '')})`, + )) === false, + JSON.stringify(undone), + ); + const assetCount = await json('__gm.state.project.assets.length'); + p = await json('__gm.song.recordAt()'); + await click(p.x, p.y); + await waitFor( + () => json('__gm.song.view()'), + (v) => v.take?.peaks > 20, + 60, + ); + await press('Escape'); + await sleep(300); + const thrown = await json( + '{ recording: __gm.song.recording(), take: __gm.song.view().take, playing: __gm.song.playing(), assets: __gm.state.project.assets.length }', + ); + check( + 'Esc while recording throws the take away: nothing is added, playing stops', + !thrown.recording && + thrown.take === null && + !thrown.playing && + thrown.assets === assetCount && + (await model()).tracks.length === before.tracks.length, + JSON.stringify(thrown), + ); + // 65j. The step takes back everything it made. await evaluate('__gm.sound.stopAll()'); await mutate(` diff --git a/tools/lib/browser.mjs b/tools/lib/browser.mjs index 6d0f95df..96c71c4a 100644 --- a/tools/lib/browser.mjs +++ b/tools/lib/browser.mjs @@ -14,6 +14,9 @@ const COMMON = [ // asked for one. Muted plus autoplay-allowed is exactly that: it plays, silently. '--mute-audio', '--autoplay-policy=no-user-gesture-required', + // A take of the Song tab (#342) records chromium's test tone, without asking for leave. + '--use-fake-device-for-media-stream', + '--use-fake-ui-for-media-stream', ]; const HEADLESS = [