Record a take from the microphone in the Song tab (#342)
A song could only be filled with sounds that were made somewhere else. Record plays the song from the cursor and takes the input meanwhile, so a part played along lands on the bar it was played to. - An audio worklet takes the input's samples on the clock the song plays on. Each run carries the frame it was taken at, so the take is cut where the song was heard to start. The output's and the input's latency are taken off, as far as the browser reports them. - The take goes up as mono 16-bit WAV. Sound, a new track "Recording" under the track clicked last and the item at the cursor are one undo step. The sound is named "Song – Take 1", 2, 3 and so on. - While recording the take grows red with its waveform on its track, and a meter beside the input dropdown shows the level. The lanes take no edits then, only a middle-drag pans. - The loop is not used while recording. The song plays once through and on past its end until Stop, Play or Space keep the take. Esc throws it away and nothing goes up. - Echo cancelling, noise suppression and auto gain are off, nothing is played back. The input picked is kept in localStorage. - A take stops by itself at the size a sound may have. A failed upload offers Retry, Download WAV and Discard. - The API takes sounds up to 256 MB and bodies of the same size. nginx and PHP were raised to match outside the repo (body 256m, upload and post 256M, memory_limit 512M). - The pure rules (cut, cap, WAV header, name, track) live in core/music/take.ts. The scheduler tells where it started on the audio clock. - The e2e browser gets chromium's fake microphone and skips the permission prompt. Each run leaves its take in the sound store. Found on the way: - The header was too full with the new widgets and cut the track and item count on the right. Input dropdown and meter are narrower now. Tests: - Unit take.test.ts: 7 new, svg-icons adjusted to 8 toolbar icons - e2e 65 (melektaus-atx): ALL PASSED – 31 checks (+4) - Gate (melektaus-atx): CHECK EXIT 0, 252 files, 3923 tests - Probe on stein2: a 3 s take of the test tone, peak 32767, 285100 bytes, waveform seen in the screenshot Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
32051b5097
commit
2fdf97f7de
17 files changed
+988
-21
No files matched your search
+3
-3
@@ -93,7 +93,7 @@ require __DIR__ . '/lib/TextureStore.php';
|
|||||||
require __DIR__ . '/lib/UserStore.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. */
|
/** 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. */
|
/** Upload ceiling for a single image. The editor downscales larger pictures before sending. */
|
||||||
const MAX_TEXTURE_BYTES = 4 * 1024 * 1024;
|
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. */
|
/** 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;
|
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. */
|
/** The same for a sound, far higher: a recorded take is plain WAV, about 46 minutes in mono. */
|
||||||
const MAX_SOUND_BYTES = 16 * 1024 * 1024;
|
const MAX_SOUND_BYTES = 256 * 1024 * 1024;
|
||||||
|
|
||||||
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
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
|
// PHP calls the handler for a call silenced with `@` as well, only with less reported: that
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ export class Scheduler {
|
|||||||
return this.anchor !== null;
|
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. */
|
/** Played to its end, and none of its notes is heard any more. */
|
||||||
get done(): boolean {
|
get done(): boolean {
|
||||||
return this.ended && this.voices.every((voice) => !this.host.playing(voice));
|
return this.ended && this.voices.every((voice) => !this.host.playing(voice));
|
||||||
|
|||||||
@@ -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<ArrayBuffer> {
|
||||||
|
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' };
|
||||||
|
}
|
||||||
@@ -317,7 +317,7 @@ export class Editor implements PlayHost {
|
|||||||
this.soundFxEditor = new SoundFxEditor(state);
|
this.soundFxEditor = new SoundFxEditor(state);
|
||||||
this.instrumentEditor = new InstrumentEditor(state);
|
this.instrumentEditor = new InstrumentEditor(state);
|
||||||
this.patternEditor = new PatternEditor(state);
|
this.patternEditor = new PatternEditor(state);
|
||||||
this.songEditor = new SongEditor(state);
|
this.songEditor = new SongEditor(state, sounds);
|
||||||
this.textureEditor = new TextureEditor(state);
|
this.textureEditor = new TextureEditor(state);
|
||||||
this.vectorArtEditor = new VectorArtEditor(state);
|
this.vectorArtEditor = new VectorArtEditor(state);
|
||||||
this.pixelArtEditor = new PixelArtEditor(state);
|
this.pixelArtEditor = new PixelArtEditor(state);
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export function songHooks({ state, editor }: HookContext) {
|
|||||||
playAt: () => ({ ...centreOf(songEditor.playButton), text: songEditor.playButton.text }),
|
playAt: () => ({ ...centreOf(songEditor.playButton), text: songEditor.playButton.text }),
|
||||||
fitAt: () => centreOf(songEditor.fitButton),
|
fitAt: () => centreOf(songEditor.fitButton),
|
||||||
playing: () => songEditor.isPlaying,
|
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. */
|
/** The notes and clips the scheduler handed to the player since Play, and where it stands. */
|
||||||
planned: () => [...songEditor.music.planned],
|
planned: () => [...songEditor.music.planned],
|
||||||
clips: () => [...songEditor.music.clips],
|
clips: () => [...songEditor.music.clips],
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="record" viewBox="-8 -8 16 16" width="16" height="16">
|
||||||
|
<circle cx="0" cy="0" r="5.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 160 B |
@@ -7,6 +7,7 @@ import eye from './icons/eye.svg?raw';
|
|||||||
import menu from './icons/menu.svg?raw';
|
import menu from './icons/menu.svg?raw';
|
||||||
import pause from './icons/pause.svg?raw';
|
import pause from './icons/pause.svg?raw';
|
||||||
import play from './icons/play.svg?raw';
|
import play from './icons/play.svg?raw';
|
||||||
|
import record from './icons/record.svg?raw';
|
||||||
import redo from './icons/redo.svg?raw';
|
import redo from './icons/redo.svg?raw';
|
||||||
import shader from './icons/shader.svg?raw';
|
import shader from './icons/shader.svg?raw';
|
||||||
import showLabel from './icons/show-label.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 stop from './icons/stop.svg?raw';
|
||||||
import undo from './icons/undo.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. */
|
/** 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<VisibilityPart, string> = {
|
export const VISIBILITY_ICON_SVG: Record<VisibilityPart, string> = {
|
||||||
|
|||||||
+305
-11
@@ -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 {
|
import {
|
||||||
|
newItem,
|
||||||
SONG_GRID_KEY,
|
SONG_GRID_KEY,
|
||||||
SONG_GRID_OPTIONS,
|
SONG_GRID_OPTIONS,
|
||||||
songBuses,
|
songBuses,
|
||||||
@@ -8,37 +11,77 @@ import {
|
|||||||
songSequence,
|
songSequence,
|
||||||
songTempo,
|
songTempo,
|
||||||
} from '../../core/music/song';
|
} 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 type { Asset, SongAsset } from '../../core/model/Asset';
|
||||||
|
import { findAssetOf } from '../../core/model/Project';
|
||||||
import { textOf } from '../../core/model/Properties';
|
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 { 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 { activateAsset } from '../actions';
|
||||||
import { AssetTabEditor } from '../AssetTabEditor';
|
import { AssetTabEditor } from '../AssetTabEditor';
|
||||||
import { claimTransport, releaseTransport } from '../music/transport';
|
import { claimTransport, releaseTransport } from '../music/transport';
|
||||||
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { SongTimeline } from './SongTimeline';
|
import { SongTimeline } from './SongTimeline';
|
||||||
|
import { TakeMeter } from './TakeMeter';
|
||||||
|
|
||||||
const INFO_WIDTH = 200;
|
const INFO_WIDTH = 200;
|
||||||
const GRID_WIDTH = 64;
|
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
|
* 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
|
* 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.
|
* 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<SongAsset, SongTimeline> {
|
export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||||
/** Public for the check scripts. */
|
/** Public for the check scripts. */
|
||||||
readonly playButton: Button;
|
readonly playButton: Button;
|
||||||
readonly gridButton = new Dropdown('Bar', { width: GRID_WIDTH });
|
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);
|
readonly music = new Music(() => this.state.project);
|
||||||
/** The song playing, `''` while none is. */
|
/** The song playing, `''` while none is. */
|
||||||
private playingId = '';
|
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. */
|
/** What the scheduler was last told, so only a change is passed on. */
|
||||||
private told = '';
|
private told = '';
|
||||||
private readonly halt = () => this.stop();
|
private readonly halt = () => this.stop();
|
||||||
|
|
||||||
constructor(state: EditorState) {
|
constructor(
|
||||||
|
state: EditorState,
|
||||||
|
private readonly sounds: SoundStore,
|
||||||
|
) {
|
||||||
super(state, { infoWidth: INFO_WIDTH });
|
super(state, { infoWidth: INFO_WIDTH });
|
||||||
this.playButton = new Button('Play', {
|
this.playButton = new Button('Play', {
|
||||||
icon: TOOLBAR_ICON_SVG.play,
|
icon: TOOLBAR_ICON_SVG.play,
|
||||||
@@ -46,6 +89,13 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
tooltip: 'Play the song round its loop (Space)',
|
tooltip: 'Play the song round its loop (Space)',
|
||||||
onClick: () => this.togglePlay(),
|
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();
|
this.gridButton.onClick = () => this.openGridMenu();
|
||||||
const timeline = new SongTimeline(state);
|
const timeline = new SongTimeline(state);
|
||||||
timeline.onTogglePlay = () => this.togglePlay();
|
timeline.onTogglePlay = () => this.togglePlay();
|
||||||
@@ -57,8 +107,17 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
if (asset && this.isPlaying) this.music.setBuses(songBuses({ id: asset.id, tracks }));
|
if (asset && this.isPlaying) this.music.setBuses(songBuses({ id: asset.id, tracks }));
|
||||||
};
|
};
|
||||||
timeline.onOpenPattern = (patternId) => activateAsset(state, patternId);
|
timeline.onOpenPattern = (patternId) => activateAsset(state, patternId);
|
||||||
this.mount(timeline, [this.playButton, new Label('Grid', { style: 'muted' }), this.gridButton]);
|
timeline.onDiscardTake = () => this.stop({ discard: true });
|
||||||
state.on('load', () => this.stop());
|
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 {
|
override selectedId(): string {
|
||||||
@@ -73,6 +132,10 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
return this.playingId !== '';
|
return this.playingId !== '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isRecording(): boolean {
|
||||||
|
return this.take !== null;
|
||||||
|
}
|
||||||
|
|
||||||
togglePlay(): void {
|
togglePlay(): void {
|
||||||
if (this.isPlaying) this.stop();
|
if (this.isPlaying) this.stop();
|
||||||
else this.play();
|
else this.play();
|
||||||
@@ -95,27 +158,235 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
this.syncPlayButton();
|
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);
|
releaseTransport(this.halt);
|
||||||
this.music.stop();
|
this.music.stop();
|
||||||
this.playingId = '';
|
this.playingId = '';
|
||||||
this.canvas.playhead = null;
|
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.canvas.invalidate();
|
||||||
this.syncPlayButton();
|
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<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(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 {
|
tick(): void {
|
||||||
if (!this.isPlaying) return;
|
if (!this.isPlaying) return;
|
||||||
this.canvas.playhead = this.music.current?.position().ticks ?? null;
|
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();
|
this.canvas.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
override destroy(options?: Parameters<AssetTabEditor<SongAsset, SongTimeline>['destroy']>[0]): void {
|
override destroy(options?: Parameters<AssetTabEditor<SongAsset, SongTimeline>['destroy']>[0]): void {
|
||||||
this.music.stop();
|
this.music.stop();
|
||||||
|
this.take?.recorder.cancel();
|
||||||
super.destroy(options);
|
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 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<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 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<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 {
|
private openGridMenu(): void {
|
||||||
const asset = this.asset();
|
const asset = this.asset();
|
||||||
if (!asset) return;
|
if (!asset) return;
|
||||||
@@ -149,12 +420,19 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
const scheduler = this.music.current;
|
const scheduler = this.music.current;
|
||||||
scheduler?.setTempo(songTempo(asset.props));
|
scheduler?.setTempo(songTempo(asset.props));
|
||||||
scheduler?.setMeter(songMeter(asset.props));
|
scheduler?.setMeter(songMeter(asset.props));
|
||||||
scheduler?.setLoop(songLoop(asset));
|
scheduler?.setLoop(this.take ? null : songLoop(asset));
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncPlayButton(): void {
|
private syncPlayButton(): void {
|
||||||
this.playButton.setIcon(this.isPlaying ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.play);
|
const recording = this.isRecording;
|
||||||
this.playButton.setText(this.isPlaying ? 'Stop' : 'Play');
|
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 {
|
protected override infoText(asset: SongAsset): string {
|
||||||
@@ -165,10 +443,26 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
|
|
||||||
protected override syncExtras(asset: SongAsset | null): void {
|
protected override syncExtras(asset: SongAsset | null): void {
|
||||||
this.fitButton.setEnabled(asset !== null);
|
this.fitButton.setEnabled(asset !== null);
|
||||||
this.playButton.setEnabled(asset !== null);
|
|
||||||
this.gridButton.setEnabled(asset !== null);
|
this.gridButton.setEnabled(asset !== null);
|
||||||
|
this.syncPlayButton();
|
||||||
const grid = asset ? textOf(asset.props[SONG_GRID_KEY] ?? 'bar') : 'bar';
|
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.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? 'Bar');
|
||||||
this.follow(asset);
|
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);
|
||||||
|
}
|
||||||
@@ -108,6 +108,9 @@ const PATTERN_COLOR = 0x35598f;
|
|||||||
const SOUND_COLOR = 0x2f6f52;
|
const SOUND_COLOR = 0x2f6f52;
|
||||||
const MISSING_COLOR = 0x6b3a3a;
|
const MISSING_COLOR = 0x6b3a3a;
|
||||||
const MUTE_COLOR = 0xd08a2c;
|
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;
|
const SOLO_COLOR = 0xc9c23a;
|
||||||
/** Height of an item's name strip; what it plays is drawn below. */
|
/** Height of an item's name strip; what it plays is drawn below. */
|
||||||
const LABEL_STRIP = 15;
|
const LABEL_STRIP = 15;
|
||||||
@@ -159,6 +162,19 @@ type Gesture =
|
|||||||
| { kind: 'trim'; anchor: SongItem; by: number; shift: ShiftPress }
|
| { kind: 'trim'; anchor: SongItem; by: number; shift: ShiftPress }
|
||||||
| { kind: 'slider'; track: string; part: 'volume' | 'pan'; value: number };
|
| { 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. */
|
/** An asset dragged in from the panel, where it would land. */
|
||||||
interface Drop {
|
interface Drop {
|
||||||
kind: SongItemKind;
|
kind: SongItemKind;
|
||||||
@@ -208,6 +224,8 @@ export class SongTimeline extends Widget {
|
|||||||
private readonly loading = new Set<string>();
|
private readonly loading = new Set<string>();
|
||||||
/** Where the playhead stands while the song plays, in ticks; null while it does not. */
|
/** Where the playhead stands while the song plays, in ticks; null while it does not. */
|
||||||
playhead: number | null = null;
|
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 = () => {};
|
onTogglePlay: () => void = () => {};
|
||||||
/** Where playing starts was moved: a playing song jumps there. */
|
/** Where playing starts was moved: a playing song jumps there. */
|
||||||
onStart: (tick: number) => void = () => {};
|
onStart: (tick: number) => void = () => {};
|
||||||
@@ -215,6 +233,7 @@ export class SongTimeline extends Widget {
|
|||||||
onTracksPreview: (tracks: readonly SongTrack[]) => void = () => {};
|
onTracksPreview: (tracks: readonly SongTrack[]) => void = () => {};
|
||||||
/** A pattern item was double-clicked. */
|
/** A pattern item was double-clicked. */
|
||||||
onOpenPattern: (patternId: string) => void = () => {};
|
onOpenPattern: (patternId: string) => void = () => {};
|
||||||
|
onDiscardTake: () => void = () => {};
|
||||||
|
|
||||||
constructor(private readonly state: EditorState) {
|
constructor(private readonly state: EditorState) {
|
||||||
super();
|
super();
|
||||||
@@ -451,6 +470,11 @@ export class SongTimeline extends Widget {
|
|||||||
this.onTogglePlay();
|
this.onTogglePlay();
|
||||||
return true;
|
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 === 'c') return this.copy();
|
||||||
if (mod && key === 'v') return this.paste();
|
if (mod && key === 'v') return this.paste();
|
||||||
if (mod && key === 'd') return this.duplicate() || true;
|
if (mod && key === 'd') return this.duplicate() || true;
|
||||||
@@ -527,6 +551,7 @@ export class SongTimeline extends Widget {
|
|||||||
drop: Drop | null;
|
drop: Drop | null;
|
||||||
hover: { id: string; part: ItemPart } | null;
|
hover: { id: string; part: ItemPart } | null;
|
||||||
cursor: string;
|
cursor: string;
|
||||||
|
take: (Omit<TakeView, 'peaks'> & { peaks: number }) | null;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
...this.view,
|
...this.view,
|
||||||
@@ -536,6 +561,7 @@ export class SongTimeline extends Widget {
|
|||||||
drop: this.drop,
|
drop: this.drop,
|
||||||
hover: this.hover,
|
hover: this.hover,
|
||||||
cursor: String(this.cursor ?? ''),
|
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 };
|
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.take) return;
|
||||||
const g = this.inLanes(p);
|
const g = this.inLanes(p);
|
||||||
const tick = xToTick(this.view, g.x);
|
const tick = xToTick(this.view, g.x);
|
||||||
if (area === 'heads') return this.pressHead(asset, e, g.y, p.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;
|
const gesture = this.gesture;
|
||||||
if (!asset) return;
|
if (!asset) return;
|
||||||
if (!gesture) {
|
if (!gesture) {
|
||||||
this.followHover(asset, local, g);
|
if (!this.take) this.followHover(asset, local, g);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tick = xToTick(this.view, g.x);
|
const tick = xToTick(this.view, g.x);
|
||||||
@@ -1072,7 +1099,18 @@ export class SongTimeline extends Widget {
|
|||||||
|
|
||||||
private shownTracks(asset: SongAsset): SongTrack[] {
|
private shownTracks(asset: SongAsset): SongTrack[] {
|
||||||
const g = this.gesture;
|
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<Gesture, { kind: 'slider' }>): SongTrack[] {
|
private shownTracksWith(asset: SongAsset, g: Extract<Gesture, { kind: 'slider' }>): SongTrack[] {
|
||||||
@@ -1241,7 +1279,7 @@ export class SongTimeline extends Widget {
|
|||||||
|
|
||||||
private drawItems(asset: SongAsset, lw: number, lh: number): void {
|
private drawItems(asset: SongAsset, lw: number, lh: number): void {
|
||||||
const c = Theme.colors;
|
const c = Theme.colors;
|
||||||
const rows = indexOf(asset.tracks);
|
const rows = indexOf(this.shownTracks(asset));
|
||||||
const picked = new Set(this.pickedIds);
|
const picked = new Set(this.pickedIds);
|
||||||
const labels: Placed[] = [];
|
const labels: Placed[] = [];
|
||||||
for (const item of this.shownItems(asset)) {
|
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 (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 });
|
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);
|
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. */
|
/** A line where what the item plays starts over, the label strip left clear. */
|
||||||
private drawSeams(item: SongItem, box: Box, lw: number): void {
|
private drawSeams(item: SongItem, box: Box, lw: number): void {
|
||||||
for (const tick of this.seamsOf(item)) {
|
for (const tick of this.seamsOf(item)) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -710,6 +710,11 @@ export class SoundLibrary implements AudioSink {
|
|||||||
return context.currentTime;
|
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. */
|
/** Seconds between the audio clock and what is heard; 0 where the browser does not say. */
|
||||||
latency(): number {
|
latency(): number {
|
||||||
const context = this.context;
|
const context = this.context;
|
||||||
|
|||||||
@@ -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<BaseAudioContext, Promise<void>>();
|
||||||
|
|
||||||
|
function loadWorklet(context: AudioContext): Promise<void> {
|
||||||
|
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<InputDevice[]> {
|
||||||
|
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<TakeRecorder> {
|
||||||
|
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<Int16Array[]> {
|
||||||
|
if (!this.closed) {
|
||||||
|
const last = new Promise<void>((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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -105,11 +105,12 @@ describe('icon files', () => {
|
|||||||
'menu',
|
'menu',
|
||||||
'pause',
|
'pause',
|
||||||
'play',
|
'play',
|
||||||
|
'record',
|
||||||
'redo',
|
'redo',
|
||||||
'stop',
|
'stop',
|
||||||
'undo',
|
'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', () => {
|
it('has one picture per switch behind the eye', () => {
|
||||||
|
|||||||
+94
-1
@@ -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
|
* 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 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.
|
* 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
|
* 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.
|
* 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();
|
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.
|
// 65j. The step takes back everything it made.
|
||||||
await evaluate('__gm.sound.stopAll()');
|
await evaluate('__gm.sound.stopAll()');
|
||||||
await mutate(`
|
await mutate(`
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const COMMON = [
|
|||||||
// asked for one. Muted plus autoplay-allowed is exactly that: it plays, silently.
|
// asked for one. Muted plus autoplay-allowed is exactly that: it plays, silently.
|
||||||
'--mute-audio',
|
'--mute-audio',
|
||||||
'--autoplay-policy=no-user-gesture-required',
|
'--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 = [
|
const HEADLESS = [
|
||||||
|
|||||||
Reference in new issue
Block a user