A bass line is written against the chords it goes with. Until now the piano roll showed only the pattern itself, so writing a part to fit the rest of a song meant switching between the Pattern and the Song tab. - A double-click on a pattern item remembers song, item and the round clicked into for that pattern. The place lives in the session, not in the project, and goes by the item's id, so moving the item moves the ghost notes along. - Opened from the Assets panel, the pattern shows the item of the last double-click. Without one, a pattern that lies in exactly one item of all the songs shows that item's first round. - Ghost notes are the pattern items of the other tracks all along the song, and on the own track the items before and after and the other rounds of the item. The round edited itself does not count. Sound items have no notes. - A ghost note sits on the row it sounds with: its own transpose, less the transpose of the item the pattern was opened from. - The roll draws them grey, without an edge, under the notes. Resting on one names its track and its pattern. - The header has a Ghosts switch, on by default, and names where they come from, e.g. "Tune · Bass · bar 5". - The time axis pans back to the start of the song, with bar and grid lines before the pattern. Fit still frames only the pattern. - A click or a paste outside the pattern no longer lands. Before, only the time left of bar 1 was closed, notes could still be drawn past the end, where they are kept but never played. - The rounds of a pattern item are now worked out in one place, itemNotes() in song.ts, which songSequence() and the ghost notes share. Tests: - Unit ghosts (new): 5 green. Counter-check with the round edited not left out and the own transpose not taken off: 2 red. - Unit piano-roll: 1 new (pan back to the song, lines before bar 1). Counter-check without the song's start: red. - e2e 65 (melektaus-atx): ALL PASSED - 38 checks (3 new). The first run was red on the tooltip check: the ghost note it rested on lay a fifth up, outside the rows Fit frames. The test now uses a tone. - e2e 64 (melektaus-atx): ALL PASSED - 30 checks. - Gate (melektaus-atx): CHECK EXIT 0, 260 files, 4027 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
481 lines
17 KiB
TypeScript
481 lines
17 KiB
TypeScript
import { CreateSoundCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||
import { SetSongCommand } from '../../core/commands/songCommands';
|
||
import {
|
||
newItem,
|
||
SONG_GRID_KEY,
|
||
SONG_GRID_OPTIONS,
|
||
songLoop,
|
||
songMeter,
|
||
songSequence,
|
||
songTempo,
|
||
} from '../../core/music/song';
|
||
import { roundAt } from '../../core/music/ghosts';
|
||
import { patternLength } from '../../core/music/pattern';
|
||
import { songBuses } from '../../core/music/songBuses';
|
||
import { sampleCount, takeName, takeStart, takeTrack, takeTrackIndex, wavBytes } from '../../core/music/take';
|
||
import { ticksOfSeconds } from '../../core/music/time';
|
||
import type { Asset, SongAsset } from '../../core/model/Asset';
|
||
import { findAssetOf } from '../../core/model/Project';
|
||
import { textOf } from '../../core/model/Properties';
|
||
import type { SoundStore } from '../../core/store/SoundStore';
|
||
import { Button, Dialog, Dropdown, Label, Menu, ui } from '../../ui';
|
||
import { Music } from '../../viewport/music';
|
||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||
import {
|
||
type InputDevice,
|
||
inputDevices,
|
||
inputProblem,
|
||
PEAK_SPAN,
|
||
TakeRecorder,
|
||
} from '../../viewport/takeRecorder';
|
||
import { activateAsset } from '../actions';
|
||
import { AssetTabEditor } from '../AssetTabEditor';
|
||
import { claimTransport, releaseTransport } from '../music/transport';
|
||
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
||
import type { EditorState } from '../state';
|
||
import { SongTimeline } from './SongTimeline';
|
||
import { TakeMeter } from './TakeMeter';
|
||
|
||
const INFO_WIDTH = 200;
|
||
const GRID_WIDTH = 64;
|
||
const INPUT_WIDTH = 104;
|
||
/** Where the browser keeps the input picked last. */
|
||
const INPUT_KEY = 'game-maker.record-input';
|
||
|
||
/** A take being recorded: the song it goes into, where, and the input it comes from. */
|
||
interface Take {
|
||
recorder: TakeRecorder;
|
||
songId: string;
|
||
tick: number;
|
||
index: number;
|
||
tempo: number;
|
||
}
|
||
|
||
/**
|
||
* The Song tab (#334): the tracks and their items, and in the header Play – the Space bar does the
|
||
* same – and the grid items snap to. The song plays round its loop through the scheduler (#332); an
|
||
* edit is heard from its next window on, a track's level and pan at once.
|
||
*
|
||
* Record (#342) plays the song from where the ruler says, once through and on past its end, and
|
||
* takes the input meanwhile. Stop keeps the take: it goes up as WAV and lands on a track of its own
|
||
* under the one clicked last, sound and item one undo step. Esc throws it away.
|
||
*/
|
||
export class SongEditor extends AssetTabEditor<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();
|
||
readonly music = new Music(() => this.state.project);
|
||
/** The song playing, `''` while none is. */
|
||
private playingId = '';
|
||
private take: Take | null = null;
|
||
/** Waiting for the browser to give the input, or for a take to go up: Record waits too. */
|
||
private busy = false;
|
||
private inputs: InputDevice[] = [];
|
||
private inputId = localStorage.getItem(INPUT_KEY) ?? '';
|
||
/** What the scheduler was last told, so only a change is passed on. */
|
||
private told = '';
|
||
private readonly halt = () => this.stop();
|
||
|
||
constructor(
|
||
state: EditorState,
|
||
private readonly sounds: SoundStore,
|
||
) {
|
||
super(state, { infoWidth: INFO_WIDTH });
|
||
this.playButton = new Button('Play', {
|
||
icon: TOOLBAR_ICON_SVG.play,
|
||
minWidth: 72,
|
||
tooltip: 'Play the song round its loop (Space)',
|
||
onClick: () => this.togglePlay(),
|
||
});
|
||
this.recordButton = new Button('Record', {
|
||
icon: TOOLBAR_ICON_SVG.record,
|
||
minWidth: 80,
|
||
tooltip: 'Record from the input while the song plays – headphones keep the song out of the take',
|
||
onClick: () => this.toggleRecord(),
|
||
});
|
||
this.inputButton.onClick = () => void this.openInputMenu();
|
||
this.gridButton.onClick = () => this.openGridMenu();
|
||
const timeline = new SongTimeline(state);
|
||
timeline.onTogglePlay = () => this.togglePlay();
|
||
timeline.onStart = (tick) => {
|
||
if (this.isPlaying) this.music.current?.seek(tick);
|
||
};
|
||
timeline.onTracksPreview = (tracks) => {
|
||
const asset = this.asset();
|
||
if (asset && this.isPlaying) this.music.setBuses(songBuses(this.state.project, { ...asset, tracks }));
|
||
};
|
||
timeline.onOpenPattern = (item, tick) => {
|
||
const song = this.asset();
|
||
const pattern = findAssetOf(state.project, item.asset, 'pattern');
|
||
if (song && pattern)
|
||
state.patternPlaces.set(pattern.id, {
|
||
songId: song.id,
|
||
itemId: item.id,
|
||
round: roundAt(item, patternLength(pattern), tick),
|
||
});
|
||
activateAsset(state, item.asset);
|
||
};
|
||
timeline.onDiscardTake = () => this.stop({ discard: true });
|
||
this.mount(timeline, [
|
||
this.playButton,
|
||
this.recordButton,
|
||
this.inputButton,
|
||
this.meter,
|
||
new Label('Grid', { style: 'muted' }),
|
||
this.gridButton,
|
||
]);
|
||
state.on('load', () => this.stop({ discard: true }));
|
||
void this.listInputs();
|
||
}
|
||
|
||
override selectedId(): string {
|
||
return this.state.selectedSongId ?? '';
|
||
}
|
||
|
||
override isMine(asset: Asset): asset is SongAsset {
|
||
return asset.type === 'song';
|
||
}
|
||
|
||
get isPlaying(): boolean {
|
||
return this.playingId !== '';
|
||
}
|
||
|
||
get isRecording(): boolean {
|
||
return this.take !== null;
|
||
}
|
||
|
||
togglePlay(): void {
|
||
if (this.isPlaying) this.stop();
|
||
else this.play();
|
||
}
|
||
|
||
/** Plays the song shown from where the ruler says, round its loop. */
|
||
play(): void {
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
claimTransport(this.halt);
|
||
this.playingId = asset.id;
|
||
this.told = this.settingsOf(asset);
|
||
this.music.setBuses(songBuses(this.state.project, asset));
|
||
void this.music.play(songSequence(this.state.project, asset), {
|
||
tempo: songTempo(asset.props),
|
||
meter: songMeter(asset.props),
|
||
loop: songLoop(asset),
|
||
from: this.canvas.start,
|
||
});
|
||
this.syncPlayButton();
|
||
}
|
||
|
||
/** Ends playing; a take being recorded is kept, or with `discard` thrown away. */
|
||
stop(options: { discard?: boolean } = {}): void {
|
||
releaseTransport(this.halt);
|
||
this.music.stop();
|
||
this.playingId = '';
|
||
this.canvas.playhead = null;
|
||
const take = this.take;
|
||
this.take = null;
|
||
this.meter.set(0);
|
||
if (take && options.discard) {
|
||
take.recorder.cancel();
|
||
this.canvas.take = null;
|
||
this.state.setNotice('Threw the take away');
|
||
} else if (take) void this.keep(take);
|
||
this.canvas.invalidate();
|
||
this.syncPlayButton();
|
||
}
|
||
|
||
toggleRecord(): void {
|
||
if (this.isRecording) this.stop();
|
||
else void this.record();
|
||
}
|
||
|
||
/** Asks for the input, then plays the song from the cursor – once, on past its end – and takes it in. */
|
||
async record(): Promise<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();
|
||
}
|
||
|
||
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 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 {
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
const current = textOf(asset.props[SONG_GRID_KEY] ?? 'bar');
|
||
new Menu(
|
||
SONG_GRID_OPTIONS.map((option) => ({
|
||
label: option.label,
|
||
current: option.value === current,
|
||
onSelect: () =>
|
||
this.state.execute(new SetAssetPropertyCommand(asset.id, SONG_GRID_KEY, option.value)),
|
||
})),
|
||
).openBelow(this.gridButton);
|
||
}
|
||
|
||
private settingsOf(asset: SongAsset): string {
|
||
return JSON.stringify([songTempo(asset.props), songMeter(asset.props), songLoop(asset)]);
|
||
}
|
||
|
||
/** What changed while playing goes to the scheduler and the tracks; another song shown stops it. */
|
||
private follow(asset: SongAsset | null): void {
|
||
if (!this.isPlaying) return;
|
||
if (!asset || asset.id !== this.playingId) {
|
||
this.stop();
|
||
return;
|
||
}
|
||
this.music.edit(songSequence(this.state.project, asset));
|
||
this.music.setBuses(songBuses(this.state.project, asset));
|
||
const settings = this.settingsOf(asset);
|
||
if (settings === this.told) return;
|
||
this.told = settings;
|
||
const scheduler = this.music.current;
|
||
scheduler?.setTempo(songTempo(asset.props));
|
||
scheduler?.setMeter(songMeter(asset.props));
|
||
scheduler?.setLoop(this.take ? null : songLoop(asset));
|
||
}
|
||
|
||
private syncPlayButton(): void {
|
||
const recording = this.isRecording;
|
||
this.playButton.setIcon(this.isPlaying && !recording ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.play);
|
||
this.playButton.setText(this.isPlaying && !recording ? 'Stop' : 'Play');
|
||
this.recordButton.setIcon(recording ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.record);
|
||
this.recordButton.setText(recording ? 'Stop' : 'Record');
|
||
const asset = this.asset();
|
||
this.playButton.setEnabled(asset !== null && !this.busy);
|
||
this.recordButton.setEnabled(asset !== null && (recording || !this.busy));
|
||
this.inputButton.setEnabled(!recording && !this.busy);
|
||
}
|
||
|
||
protected override infoText(asset: SongAsset): string {
|
||
const tracks = asset.tracks.length;
|
||
const items = asset.items.length;
|
||
return `${tracks} ${tracks === 1 ? 'track' : 'tracks'} · ${items} ${items === 1 ? 'item' : 'items'}`;
|
||
}
|
||
|
||
protected override syncExtras(asset: SongAsset | null): void {
|
||
this.fitButton.setEnabled(asset !== null);
|
||
this.gridButton.setEnabled(asset !== null);
|
||
this.syncPlayButton();
|
||
const grid = asset ? textOf(asset.props[SONG_GRID_KEY] ?? 'bar') : 'bar';
|
||
this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? 'Bar');
|
||
this.follow(asset);
|
||
}
|
||
}
|
||
|
||
/** "1:05" */
|
||
function clockText(seconds: number): string {
|
||
const whole = Math.round(seconds);
|
||
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;
|
||
}
|
||
|
||
/** Hands the take to the browser as a file, for when the server would not take it. */
|
||
function downloadWav(wav: Uint8Array<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);
|
||
}
|