A sound could only be cut to one stretch, set on the asset itself. To
play two parts of one file, the file had to be imported twice. Now a
sound is always the whole file, and its clips are the stretches of it,
each with a name and a cut of its own.
- A clip is { id, name, props } on SoundAsset.clips. Its id shares the
generator with asset ids, so a sound field holds a sound or a clip,
as a texture field holds a texture or a frame.
- props are the cut: start, end, the fades and their curves, reverse.
Volume, loop, sound FX and bus stay on the sound for all its clips.
The cut rows left the sound's schema for CLIP_ROWS.
- findSound resolves either id. Play sound, the Sample of an
instrument, a Reverb impulse, song items, the scheduler and the test
sound of the Sound FX tab all go through it.
- Every sound field offers each sound followed by its clips, "Kick",
"Kick / Clip 1". A clip that is gone reads as "<id>?".
- The Sound tab has a strip under the ruler with the clips as bands.
A drag there makes a clip, a click picks one, beside the bands the
sound. Only the picked clip has handles. Reverse, fades and curves
in the header work on it and are greyed out without one.
- Whole file is gone from the header, as the user asked.
- Picking a clip is a selection of its own, like a frame. The
inspector shows its name, its cut rows and Delete clip. Delete
takes it away without asking, as with a frame. Ctrl+Z brings it
back.
- Copies get fresh clip ids: Duplicate, Embed a copy, a song
duplicated with its takes, and the copy a split or paste makes of
an embedded take. An item on a clip plays the same clip of its copy.
- A double-click on a song item playing a clip opens its sound with
the clip picked. The Sound row of an item on an embedded take
offers the take and its clips, which no list shows.
- A new take stays the whole sound. Clips are made by hand, as the
user decided.
- No migration in the code, as the user decided. The one project
with cut sounds was changed over by hand.
Deviation from the issue: deleting a used clip does not warn. A frame
does not warn either, and the issue had promised "like a frame".
Tests:
- Unit sound-clips (new): 11 green, 7 counter-checks each red
- Unit sounds, embedded: adjusted to clips, green
- e2e 34 (melektaus-atx): ALL PASSED, 90 checks (3 new, 12 rewritten)
- Counter-check in the browser: clips left out of the sound fields,
the picker check fails
- Gate (melektaus-atx): CHECK EXIT 0, 271 files, 4194 tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
569 lines
20 KiB
TypeScript
569 lines
20 KiB
TypeScript
import { CreateSoundCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||
import { nextAssetName } from '../../core/commands/naming';
|
||
import { SetSongCommand } from '../../core/commands/songCommands';
|
||
import {
|
||
newItem,
|
||
SONG_GRID_KEY,
|
||
SONG_GRID_OPTIONS,
|
||
songLoop,
|
||
songMeter,
|
||
songSequence,
|
||
songTempo,
|
||
} from '../../core/music/song';
|
||
import { embeddedIn } from '../../core/music/embedded';
|
||
import { roundAt } from '../../core/music/ghosts';
|
||
import { patternLength } from '../../core/music/pattern';
|
||
import { fxVoiceOf } from '../../core/music/needs';
|
||
import { songBuses } from '../../core/music/songBuses';
|
||
import { sampleCount, takeName, takeStart, takeTrack, takeTrackIndex, wavBytes } from '../../core/music/take';
|
||
import { ticksOfSeconds } from '../../core/music/time';
|
||
import type { Asset, SongAsset } from '../../core/model/Asset';
|
||
import { findAssetOf } from '../../core/model/Project';
|
||
import { findSound } from '../../core/model/sound';
|
||
import { textOf } from '../../core/model/Properties';
|
||
import { assetFxOf } from '../../core/properties/assetFx';
|
||
import type { SoundStore } from '../../core/store/SoundStore';
|
||
import { Button, Dialog, Dropdown, Label, Menu, ui, VStack, type Widget } from '../../ui';
|
||
import { Music } from '../../viewport/music';
|
||
import { playedBus } from '../../core/music/mixer';
|
||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||
import {
|
||
type InputDevice,
|
||
inputDevices,
|
||
inputProblem,
|
||
PEAK_SPAN,
|
||
TakeRecorder,
|
||
} from '../../viewport/takeRecorder';
|
||
import { activateAsset } from '../actions';
|
||
import { AssetTabEditor } from '../AssetTabEditor';
|
||
import { GraphCanvas } from '../graph/GraphCanvas';
|
||
import { SnapToggle } from '../graph/SnapToggle';
|
||
import { claimTransport, releaseTransport } from '../music/transport';
|
||
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
||
import type { EditorState } from '../state';
|
||
import { SongTimeline } from './SongTimeline';
|
||
import { TakeMeter } from './TakeMeter';
|
||
|
||
const INFO_WIDTH = 200;
|
||
const GRID_WIDTH = 64;
|
||
const INPUT_WIDTH = 104;
|
||
/** Where the browser keeps the input picked last. */
|
||
const INPUT_KEY = 'game-maker.record-input';
|
||
|
||
/** What the tab shows below its header: the tracks along time, or where they go (#350). */
|
||
export type SongView = 'timeline' | 'routing';
|
||
|
||
/** A take being recorded: the song it goes into, where, and the input it comes from. */
|
||
interface Take {
|
||
recorder: TakeRecorder;
|
||
songId: string;
|
||
tick: number;
|
||
index: number;
|
||
tempo: number;
|
||
}
|
||
|
||
/**
|
||
* The Song tab (#334): the tracks and their items, and in the header Play – the Space bar does the
|
||
* same – and the grid items snap to. The song plays round its loop through the scheduler (#332); an
|
||
* edit is heard from its next window on, a track's level and pan at once.
|
||
*
|
||
* Record (#342) plays the song from where the ruler says, once through and on past its end, and
|
||
* takes the input meanwhile. Stop keeps the take: it goes up as WAV and lands on a track of its own
|
||
* under the one clicked last, sound and item one undo step. Esc throws it away.
|
||
*
|
||
* Routing (#350) shows the same song as a graph instead: a node per track and the output, the links
|
||
* between them the sends, edited as any graph is.
|
||
*/
|
||
export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||
/** Public for the check scripts. */
|
||
readonly playButton: Button;
|
||
readonly gridButton = new Dropdown('Bar', { width: GRID_WIDTH });
|
||
readonly recordButton: Button;
|
||
readonly inputButton = new Dropdown('Default input', { width: INPUT_WIDTH });
|
||
readonly meter = new TakeMeter();
|
||
/** Public for the check scripts. */
|
||
readonly timelineButton: Button;
|
||
readonly routingButton: Button;
|
||
readonly routing: GraphCanvas;
|
||
readonly snap = new SnapToggle(() => this.routing);
|
||
private shown: SongView = 'timeline';
|
||
private readonly pane = new VStack();
|
||
/** Heard through the sound FX (#352) and on the bus of the asset and the master, as in the game (#325). */
|
||
readonly music = new Music(
|
||
() => this.state.project,
|
||
() => playedBus(this.state.project, '', this.asset()?.props ?? {}),
|
||
() => {
|
||
const asset = this.asset();
|
||
return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), songTempo(asset.props));
|
||
},
|
||
);
|
||
/** The song playing, `''` while none is. */
|
||
private playingId = '';
|
||
private take: Take | null = null;
|
||
/** Waiting for the browser to give the input, or for a take to go up: Record waits too. */
|
||
private busy = false;
|
||
private inputs: InputDevice[] = [];
|
||
private inputId = localStorage.getItem(INPUT_KEY) ?? '';
|
||
/** What the scheduler was last told, so only a change is passed on. */
|
||
private told = '';
|
||
private readonly halt = () => this.stop();
|
||
|
||
constructor(
|
||
state: EditorState,
|
||
private readonly sounds: SoundStore,
|
||
) {
|
||
super(state, { infoWidth: INFO_WIDTH });
|
||
this.playButton = new Button('Play', {
|
||
icon: TOOLBAR_ICON_SVG.play,
|
||
minWidth: 72,
|
||
tooltip: 'Play the song round its loop (Space)',
|
||
onClick: () => this.togglePlay(),
|
||
});
|
||
this.recordButton = new Button('Record', {
|
||
icon: TOOLBAR_ICON_SVG.record,
|
||
minWidth: 80,
|
||
tooltip: 'Record from the input while the song plays – headphones keep the song out of the take',
|
||
onClick: () => this.toggleRecord(),
|
||
});
|
||
this.timelineButton = new Button('Timeline', {
|
||
toggle: true,
|
||
tooltip: 'The tracks and their items along time',
|
||
onClick: () => this.showView('timeline'),
|
||
});
|
||
this.routingButton = new Button('Routing', {
|
||
toggle: true,
|
||
tooltip: 'Where each track goes: into the output or through other tracks',
|
||
onClick: () => this.showView('routing'),
|
||
});
|
||
this.routing = new GraphCanvas(state, 'song');
|
||
this.inputButton.onClick = () => void this.openInputMenu();
|
||
this.gridButton.onClick = () => this.openGridMenu();
|
||
const timeline = new SongTimeline(state);
|
||
timeline.onTogglePlay = () => this.togglePlay();
|
||
timeline.onStart = (tick) => {
|
||
if (this.isPlaying) this.music.current?.seek(tick);
|
||
};
|
||
timeline.onTracksPreview = (tracks) => {
|
||
const asset = this.asset();
|
||
if (asset && this.isPlaying) this.music.setBuses(songBuses(this.state.project, { ...asset, tracks }));
|
||
};
|
||
timeline.onOpenItem = (item, tick) => {
|
||
const song = this.asset();
|
||
const pattern = item.kind === 'pattern' ? findAssetOf(state.project, item.asset, 'pattern') : undefined;
|
||
if (song && pattern)
|
||
state.patternPlaces.set(pattern.id, {
|
||
songId: song.id,
|
||
itemId: item.id,
|
||
round: roundAt(item, patternLength(pattern), tick),
|
||
});
|
||
// An item playing a clip (#357) opens its sound with the clip picked.
|
||
const sound = item.kind === 'sound' ? findSound(state.project, item.asset) : null;
|
||
if (sound?.clip) state.select({ kind: 'clip', assetId: sound.asset.id, clipId: sound.clip.id });
|
||
else activateAsset(state, item.asset);
|
||
};
|
||
timeline.onDiscardTake = () => this.stop({ discard: true });
|
||
this.mount(timeline, [
|
||
this.timelineButton,
|
||
this.routingButton,
|
||
...this.snap.widgets,
|
||
this.playButton,
|
||
this.recordButton,
|
||
this.inputButton,
|
||
this.meter,
|
||
new Label('Grid', { style: 'muted' }),
|
||
this.gridButton,
|
||
]);
|
||
state.on('load', () => this.stop({ discard: true }));
|
||
void this.listInputs();
|
||
}
|
||
|
||
override selectedId(): string {
|
||
return this.state.selectedSongId ?? '';
|
||
}
|
||
|
||
override isMine(asset: Asset): asset is SongAsset {
|
||
return asset.type === 'song';
|
||
}
|
||
|
||
get view(): SongView {
|
||
return this.shown;
|
||
}
|
||
|
||
/** Puts the timeline or the routing below the header; the routing is framed the first time it shows. */
|
||
showView(view: SongView): void {
|
||
this.shown = view;
|
||
this.canvas.visible = view === 'timeline';
|
||
this.routing.visible = view === 'routing';
|
||
this.timelineButton.setToggled(view === 'timeline');
|
||
this.routingButton.setToggled(view === 'routing');
|
||
this.pane.relayout();
|
||
if (view === 'routing') this.routing.show(this.canvas.currentAssetId);
|
||
this.syncViewButtons(this.asset());
|
||
this.syncPlayButton();
|
||
}
|
||
|
||
/** The grid is the timeline's, the snap the routing's. */
|
||
private syncViewButtons(asset: SongAsset | null): void {
|
||
this.gridButton.setEnabled(asset !== null && this.shown === 'timeline');
|
||
this.snap.setEnabled(asset !== null && this.shown === 'routing');
|
||
}
|
||
|
||
protected override body(canvas: SongTimeline): Widget {
|
||
this.pane.add(canvas, 1);
|
||
this.pane.add(this.routing, 1);
|
||
this.routing.visible = false;
|
||
this.timelineButton.setToggled(true);
|
||
return this.pane;
|
||
}
|
||
|
||
protected override fit(): void {
|
||
if (this.shown === 'routing') this.routing.fit();
|
||
else super.fit();
|
||
}
|
||
|
||
get isPlaying(): boolean {
|
||
return this.playingId !== '';
|
||
}
|
||
|
||
get isRecording(): boolean {
|
||
return this.take !== null;
|
||
}
|
||
|
||
togglePlay(): void {
|
||
if (this.isPlaying) this.stop();
|
||
else this.play();
|
||
}
|
||
|
||
/** Plays the song shown from where the ruler says, round its loop. */
|
||
play(): void {
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
claimTransport(this.halt);
|
||
this.playingId = asset.id;
|
||
this.told = this.settingsOf(asset);
|
||
this.music.setBuses(songBuses(this.state.project, asset));
|
||
void this.music.play(songSequence(this.state.project, asset), {
|
||
tempo: songTempo(asset.props),
|
||
meter: songMeter(asset.props),
|
||
loop: songLoop(asset),
|
||
from: this.canvas.start,
|
||
});
|
||
this.syncPlayButton();
|
||
}
|
||
|
||
/** Ends playing; a take being recorded is kept, or with `discard` thrown away. */
|
||
stop(options: { discard?: boolean } = {}): void {
|
||
releaseTransport(this.halt);
|
||
this.music.stop();
|
||
this.playingId = '';
|
||
this.canvas.playhead = null;
|
||
const take = this.take;
|
||
this.take = null;
|
||
this.meter.set(0);
|
||
if (take && options.discard) {
|
||
take.recorder.cancel();
|
||
this.canvas.take = null;
|
||
this.state.setNotice('Threw the take away');
|
||
} else if (take) void this.keep(take);
|
||
this.canvas.invalidate();
|
||
this.syncPlayButton();
|
||
}
|
||
|
||
toggleRecord(): void {
|
||
if (this.isRecording) this.stop();
|
||
else void this.record();
|
||
}
|
||
|
||
/** Asks for the input, then plays the song from the cursor – once, on past its end – and takes it in. */
|
||
async record(): Promise<void> {
|
||
const asked = this.asset();
|
||
if (!asked || this.busy) return;
|
||
this.stop();
|
||
this.busy = true;
|
||
this.syncPlayButton();
|
||
let recorder: TakeRecorder;
|
||
try {
|
||
recorder = await TakeRecorder.open(this.inputId);
|
||
} catch (error) {
|
||
this.state.setNotice(inputProblem(error), 'error');
|
||
return;
|
||
} finally {
|
||
this.busy = false;
|
||
this.syncPlayButton();
|
||
}
|
||
const asset = this.asset();
|
||
if (!asset || asset.id !== asked.id) {
|
||
recorder.cancel();
|
||
return;
|
||
}
|
||
const tempo = songTempo(asset.props);
|
||
const take: Take = {
|
||
recorder,
|
||
songId: asset.id,
|
||
tick: this.canvas.start,
|
||
index: takeTrackIndex(asset.tracks, this.canvas.pickedTrack),
|
||
tempo,
|
||
};
|
||
claimTransport(this.halt);
|
||
this.take = take;
|
||
this.playingId = asset.id;
|
||
this.told = this.settingsOf(asset);
|
||
this.showTake(take, false);
|
||
this.syncPlayButton();
|
||
ui().focus.focus(this.canvas);
|
||
this.music.setBuses(songBuses(this.state.project, asset));
|
||
await this.music.play(songSequence(this.state.project, asset), {
|
||
tempo,
|
||
meter: songMeter(asset.props),
|
||
loop: null,
|
||
from: take.tick,
|
||
});
|
||
const anchor = this.music.current?.startedAt;
|
||
if (this.take !== take || anchor === null || anchor === undefined) return;
|
||
recorder.startAt(takeStart(anchor, soundLibrary.latency(), recorder.inputLatency));
|
||
this.state.setNotice('Recording – Space or Stop keeps the take, Esc throws it away');
|
||
void this.listInputs();
|
||
}
|
||
|
||
/** Only called while the tab is visible: the playhead follows what is heard, a take grows. */
|
||
tick(): void {
|
||
if (!this.isPlaying) return;
|
||
this.canvas.playhead = this.music.current?.position().ticks ?? null;
|
||
const take = this.take;
|
||
if (take) {
|
||
this.showTake(take, false);
|
||
this.meter.set(take.recorder.level);
|
||
if (take.recorder.full) {
|
||
this.state.setNotice('The take is as long as a sound may be, so it was stopped');
|
||
this.stop();
|
||
}
|
||
}
|
||
this.canvas.invalidate();
|
||
}
|
||
|
||
protected override closed(assetId: string): void {
|
||
if (assetId === this.playingId) this.stop();
|
||
}
|
||
|
||
override destroy(options?: Parameters<AssetTabEditor<SongAsset, SongTimeline>['destroy']>[0]): void {
|
||
this.music.stop();
|
||
this.take?.recorder.cancel();
|
||
super.destroy(options);
|
||
}
|
||
|
||
private showTake(take: Take, uploading: boolean): void {
|
||
const shown = this.canvas.currentAssetId === take.songId;
|
||
this.canvas.take = shown
|
||
? {
|
||
index: take.index,
|
||
tick: take.tick,
|
||
length: ticksOfSeconds(take.recorder.seconds, take.tempo),
|
||
peaks: take.recorder.peaks,
|
||
ticksPerPeak: ticksOfSeconds(PEAK_SPAN / take.recorder.sampleRate, take.tempo),
|
||
uploading,
|
||
}
|
||
: null;
|
||
this.canvas.invalidate();
|
||
}
|
||
|
||
/** The take as WAV to the server, then onto its track. */
|
||
private async keep(take: Take): Promise<void> {
|
||
this.busy = true;
|
||
this.showTake(take, true);
|
||
this.syncPlayButton();
|
||
const parts = await take.recorder.finish();
|
||
if (sampleCount(parts) === 0) {
|
||
this.endUpload();
|
||
this.state.setNotice('Nothing was recorded');
|
||
return;
|
||
}
|
||
const name = takeName(embeddedIn(this.state.project, take.songId).map((a) => a.name));
|
||
const seconds = sampleCount(parts) / take.recorder.sampleRate;
|
||
await this.upload(take, wavBytes(parts, take.recorder.sampleRate), name, seconds);
|
||
}
|
||
|
||
private async upload(
|
||
take: Take,
|
||
wav: Uint8Array<ArrayBuffer>,
|
||
name: string,
|
||
seconds: number,
|
||
): Promise<void> {
|
||
this.busy = true;
|
||
this.syncPlayButton();
|
||
try {
|
||
const blob = new Blob([wav], { type: 'audio/wav' });
|
||
const uploaded = await this.sounds.upload(blob, 'audio/wav', `${name}.wav`);
|
||
this.endUpload();
|
||
this.place(take, uploaded.file, name, seconds);
|
||
} catch (error) {
|
||
const reason = error instanceof Error ? error.message : String(error);
|
||
new Dialog('The take could not be uploaded', reason, [
|
||
{
|
||
label: 'Download WAV',
|
||
onClick: () => {
|
||
this.endUpload();
|
||
downloadWav(wav, name);
|
||
},
|
||
},
|
||
{
|
||
label: 'Discard',
|
||
danger: true,
|
||
onClick: () => {
|
||
this.endUpload();
|
||
this.state.setNotice('Threw the take away');
|
||
},
|
||
},
|
||
{ label: 'Retry', primary: true, onClick: () => void this.upload(take, wav, name, seconds) },
|
||
]).openCentered();
|
||
}
|
||
}
|
||
|
||
private endUpload(): void {
|
||
this.busy = false;
|
||
this.canvas.take = null;
|
||
this.canvas.invalidate();
|
||
this.syncPlayButton();
|
||
}
|
||
|
||
/**
|
||
* The sound, its track and its item, one undo step. The sound is the song's own (#355); with the
|
||
* song gone meanwhile, it is a project asset by itself.
|
||
*/
|
||
private place(take: Take, file: string, name: string, seconds: number): void {
|
||
const song = findAssetOf(this.state.project, take.songId, 'song');
|
||
if (!song) {
|
||
const create = new CreateSoundCommand(nextAssetName(this.state.project, name), file);
|
||
if (this.state.execute(create))
|
||
this.state.setNotice(`Recorded "${create.asset.name}" – its song is gone`);
|
||
return;
|
||
}
|
||
const create = new CreateSoundCommand(name, file, song.id);
|
||
const track = takeTrack();
|
||
const tracks = [...song.tracks];
|
||
tracks.splice(Math.min(take.index, tracks.length), 0, track);
|
||
const length = Math.max(1, Math.floor(ticksOfSeconds(seconds, songTempo(song.props))));
|
||
const item = newItem({ track: track.id, kind: 'sound', asset: create.asset.id, tick: take.tick, length });
|
||
const placed = new SetSongCommand(song.id, { tracks, items: [...song.items, item] }, 'Record take');
|
||
if (!this.state.execute(new CompoundCommand('Record take', [create, placed]))) return;
|
||
if (this.canvas.currentAssetId === song.id) this.canvas.pick([item.id]);
|
||
this.state.setNotice(`Recorded "${name}" (${clockText(seconds)})`);
|
||
}
|
||
|
||
private async listInputs(): Promise<void> {
|
||
try {
|
||
this.inputs = await inputDevices();
|
||
} catch {
|
||
this.inputs = [];
|
||
}
|
||
this.syncInputButton();
|
||
}
|
||
|
||
private async openInputMenu(): Promise<void> {
|
||
await this.listInputs();
|
||
new Menu([
|
||
{
|
||
label: 'Default input',
|
||
current: !this.inputs.some((d) => d.id === this.inputId),
|
||
onSelect: () => this.pickInput(''),
|
||
},
|
||
...this.inputs.map((device) => ({
|
||
label: device.label,
|
||
current: device.id === this.inputId,
|
||
onSelect: () => this.pickInput(device.id),
|
||
})),
|
||
]).openBelow(this.inputButton);
|
||
}
|
||
|
||
private pickInput(id: string): void {
|
||
this.inputId = id;
|
||
if (id) localStorage.setItem(INPUT_KEY, id);
|
||
else localStorage.removeItem(INPUT_KEY);
|
||
this.syncInputButton();
|
||
}
|
||
|
||
private syncInputButton(): void {
|
||
this.inputButton.setText(this.inputs.find((d) => d.id === this.inputId)?.label ?? 'Default input');
|
||
}
|
||
|
||
private openGridMenu(): void {
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
const current = textOf(asset.props[SONG_GRID_KEY] ?? 'bar');
|
||
new Menu(
|
||
SONG_GRID_OPTIONS.map((option) => ({
|
||
label: option.label,
|
||
current: option.value === current,
|
||
onSelect: () =>
|
||
this.state.execute(new SetAssetPropertyCommand(asset.id, SONG_GRID_KEY, option.value)),
|
||
})),
|
||
).openBelow(this.gridButton);
|
||
}
|
||
|
||
private settingsOf(asset: SongAsset): string {
|
||
return JSON.stringify([songTempo(asset.props), songMeter(asset.props), songLoop(asset)]);
|
||
}
|
||
|
||
/** What changed while playing goes to the scheduler and the tracks; another song shown stops it. */
|
||
private follow(asset: SongAsset | null): void {
|
||
if (!this.isPlaying) return;
|
||
if (!asset || asset.id !== this.playingId) {
|
||
this.stop();
|
||
return;
|
||
}
|
||
this.music.edit(songSequence(this.state.project, asset));
|
||
this.music.setBuses(songBuses(this.state.project, asset));
|
||
const settings = this.settingsOf(asset);
|
||
if (settings === this.told) return;
|
||
this.told = settings;
|
||
const scheduler = this.music.current;
|
||
scheduler?.setTempo(songTempo(asset.props));
|
||
scheduler?.setMeter(songMeter(asset.props));
|
||
scheduler?.setLoop(this.take ? null : songLoop(asset));
|
||
}
|
||
|
||
private syncPlayButton(): void {
|
||
const recording = this.isRecording;
|
||
this.playButton.setIcon(this.isPlaying && !recording ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.play);
|
||
this.playButton.setText(this.isPlaying && !recording ? 'Stop' : 'Play');
|
||
this.recordButton.setIcon(recording ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.record);
|
||
this.recordButton.setText(recording ? 'Stop' : 'Record');
|
||
const asset = this.asset();
|
||
this.playButton.setEnabled(asset !== null && !this.busy);
|
||
this.recordButton.setEnabled(asset !== null && (recording || !this.busy));
|
||
this.inputButton.setEnabled(!recording && !this.busy);
|
||
}
|
||
|
||
protected override infoText(asset: SongAsset): string {
|
||
const tracks = asset.tracks.length;
|
||
const items = asset.items.length;
|
||
return `${tracks} ${tracks === 1 ? 'track' : 'tracks'} · ${items} ${items === 1 ? 'item' : 'items'}`;
|
||
}
|
||
|
||
protected override syncExtras(asset: SongAsset | null): void {
|
||
if (this.routing.currentAssetId !== (asset?.id ?? '')) this.routing.show(asset?.id ?? '');
|
||
this.fitButton.setEnabled(asset !== null);
|
||
this.syncViewButtons(asset);
|
||
this.syncPlayButton();
|
||
const grid = asset ? textOf(asset.props[SONG_GRID_KEY] ?? 'bar') : 'bar';
|
||
this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? 'Bar');
|
||
this.follow(asset);
|
||
}
|
||
}
|
||
|
||
/** "1:05" */
|
||
function clockText(seconds: number): string {
|
||
const whole = Math.round(seconds);
|
||
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;
|
||
}
|
||
|
||
/** Hands the take to the browser as a file, for when the server would not take it. */
|
||
function downloadWav(wav: Uint8Array<ArrayBuffer>, name: string): void {
|
||
const url = URL.createObjectURL(new Blob([wav], { type: 'audio/wav' }));
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `${name}.wav`;
|
||
link.click();
|
||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||
}
|