Bake a pattern or song to Opus, played in its place by a game (#340)
A pattern or song played by a game runs every note through the scheduler, the instruments and their FX, live. Bake works that out once, keeps it as a file on the asset, and Play pattern and Play song play the file instead, as one voice, for as long as nothing it is made of has changed. The tabs always play live. - Bake is an icon in the header of the Pattern and the Song tab, lit while the bake holds. A click bakes, cancels a bake that runs, or offers Bake again and Remove bake. The toolbar shows the progress. The info of the tab says "baked" or "bake outdated". - The bake holds everything up to the output of the pattern or song. The sound FX of the node, fx params, volume, fade in, bus, Master routing and stopping with a fade stay live. - A print of the worked-out plan decides whether it holds: notes and clips, tempo, length, loop, tracks, the build of each instrument for each note, sound FX, files, cuts and levels. A rename keeps it, an undo back to the baked state makes it hold again. - The file holds the straight run and then one more round of the loop as heard the second time, tails included. Looped, it plays to the loop end and then round that round, to the sample. Once, it fades out over its last 10 ms. Let ring fades it out over 0.3 s. - It is worked out by a second SoundLibrary on an OfflineAudioContext, its scheduler woken by suspends of the offline clock, and what reaches the master is taken. A worklet probe tells whether that was mono, so a mono bake goes on exactly as the notes did. - Opus in Ogg, 160 kbit/s stereo or 96 kbit/s mono, encoded with WebCodecs, the Ogg pages written by hand. Normalized to full scale, the peak kept and played back. - The file goes up into the sounds of the account as "[bake] name", which the sound picker leaves out. An old bake file is deleted once the project is saved without it. Only a bake that holds is loaded. - Ogg files are decoded at 48 kHz: resampled to 44.1 kHz, the loop of a bake lost a sample a round. - Baking and removing a bake are no undo steps, like the EQ's zoom. Findings: - Chrome's AudioEncoder refuses opus.format 'ogg' (NotSupportedError) but hands out the OpusHead with a pre-skip of 312 for 'opus'. - The Song tab's header is full at 1280 px: a captioned Bake pushed Fit off the tab. Hence the icon. Tests: - Unit: tests/core/ogg.test.ts (6), tests/core/bake.test.ts (16), four new in sound-library.test.ts, one in song-play.test.ts. One test cuts, reverses and fades a clip on a song's track: each change outdates the bake, each undo makes it hold again. - e2e 64 (melektaus-atx): ALL PASSED, 36 checks (6 new). Live and baked rendered offline differ by at most 0.06 dB over 114 windows of 20 ms, two loop ends among them. e2e 65 had no red check with the icon in its header. - Gate (melektaus-atx): CHECK EXIT 0, 277 files, 4281 tests. - Counter-checks: print without instrument builds, print taking the sound's file instead of the clip's cut, round taken from the first pass, bake voice without its looping source, Play pattern or Play song ignoring the bake: one unit test red each. Pre-skip set to 0: the e2e comparison 4.8 dB off, only that check red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3377524958
commit
84cd36f902
28 files changed
+1979
-78
No files matched your search
@@ -352,7 +352,7 @@ export class Editor implements PlayHost {
|
||||
this.soundEditor = new SoundEditor(state);
|
||||
this.soundFxEditor = new SoundFxEditor(state);
|
||||
this.instrumentEditor = new InstrumentEditor(state);
|
||||
this.patternEditor = new PatternEditor(state);
|
||||
this.patternEditor = new PatternEditor(state, sounds);
|
||||
this.songEditor = new SongEditor(state, sounds);
|
||||
this.textureEditor = new TextureEditor(state);
|
||||
this.vectorArtEditor = new VectorArtEditor(state);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { BAKE_RATE, type BakedAsset, bakePlayback, bakeSourceOf, validBake } from '../../core/music/bake';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { renderRun } from '../../viewport/bakeRender';
|
||||
import { centreOf, type HookContext } from './hookHelpers';
|
||||
|
||||
/** Bakes of patterns and songs (#340). */
|
||||
export function bakeHooks({ state, editor }: HookContext) {
|
||||
const asset = (id: string): BakedAsset | null => {
|
||||
const found = findAsset(state.project, id);
|
||||
return found?.type === 'pattern' || found?.type === 'song' ? found : null;
|
||||
};
|
||||
const button = (id: string) =>
|
||||
asset(id)?.type === 'song' ? editor.songEditor.bakeButton : editor.patternEditor.bakeButton;
|
||||
return {
|
||||
bake: {
|
||||
/** Bakes it as Bake does in its tab; done once it is baked, failed or cancelled. */
|
||||
run: (id: string) => button(id).bake(asset(id)),
|
||||
cancel: (id: string) => button(id).cancel(),
|
||||
/** What it keeps of its bake, whether that still holds, and the button of the tab showing it. */
|
||||
info: (id: string) => {
|
||||
const found = asset(id);
|
||||
const shown = button(id);
|
||||
return {
|
||||
bake: found?.bake ?? null,
|
||||
valid: found !== null && validBake(state.project, found) !== null,
|
||||
status: shown.status(found),
|
||||
button: {
|
||||
...centreOf(shown),
|
||||
toggled: shown.toggled,
|
||||
enabled: shown.enabled,
|
||||
tooltip: shown.tooltipText(),
|
||||
},
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Plays `seconds` of it offline from its notes and from its bake, looped or once, and holds the
|
||||
* two together: the level of every `window` seconds of each in dBFS, -Infinity for silence.
|
||||
*/
|
||||
compare: async (id: string, loop: boolean, seconds: number, window: number) => {
|
||||
const found = asset(id);
|
||||
const bake = found && validBake(state.project, found);
|
||||
const source = found && bakeSourceOf(state.project, found);
|
||||
if (!bake || !source) return null;
|
||||
const frames = Math.round(seconds * BAKE_RATE);
|
||||
const watch = { progress: () => {}, cancelled: () => false };
|
||||
const played = { file: bake.file, gain: bake.peak, playback: bakePlayback(bake, loop) };
|
||||
const live = await renderRun(state.project, source, loop, frames, null, watch);
|
||||
const baked = await renderRun(state.project, source, loop, frames, played, watch);
|
||||
return { live: levels(live.channels, window), baked: levels(baked.channels, window) };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function levels(channels: readonly Float32Array[], window: number): number[] {
|
||||
const span = Math.round(window * BAKE_RATE);
|
||||
const length = channels[0]?.length ?? 0;
|
||||
const out: number[] = [];
|
||||
for (let from = 0; from + span <= length; from += span) {
|
||||
let sum = 0;
|
||||
for (const side of channels) for (let i = from; i < from + span; i++) sum += side[i]! * side[i]!;
|
||||
out.push(10 * Math.log10(sum / (span * channels.length)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Music } from '../viewport/music';
|
||||
import { bothAtCenter, envelopeDriver, followCurve, releaseEnvelope, stageNodes } from '../viewport/fxNodes';
|
||||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||||
import { saturationAutoGain } from '../core/soundfx/saturation';
|
||||
import { bakeHooks } from './debug/bakeHooks';
|
||||
import { curveHooks } from './debug/curveHooks';
|
||||
import { soundHooks } from './debug/soundHooks';
|
||||
import { patternHooks } from './debug/patternHooks';
|
||||
@@ -47,6 +48,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
...songHooks(context),
|
||||
...routingHooks(context),
|
||||
...soundHooks(context),
|
||||
...bakeHooks(context),
|
||||
...renderHooks(context),
|
||||
/**
|
||||
* Voices started, stopped and in flight. CI runs the browser with `--mute-audio`, so nothing
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
BAKE_RATE,
|
||||
type BakedAsset,
|
||||
bakePrint,
|
||||
bakeSourceOf,
|
||||
bakeUploadName,
|
||||
validBake,
|
||||
} from '../../core/music/bake';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import type { SoundStore } from '../../core/store/SoundStore';
|
||||
import { Button, formatBytes, Menu, Theme } from '../../ui';
|
||||
import { BakeCancelled, makeBake } from '../../viewport/bakeRender';
|
||||
import { HEADER_ICON_SVG } from '../panels/headerIcons';
|
||||
import type { EditorState } from '../state';
|
||||
import { clockText } from './timeView';
|
||||
|
||||
/** Where a bake stands for the asset shown. */
|
||||
export type BakeStatus = 'none' | 'baking' | 'baked' | 'outdated';
|
||||
|
||||
/**
|
||||
* Bake in the header of the Pattern and the Song tab (#340), an icon – the Song tab's header has no
|
||||
* room for a caption: works out once what the one shown plays, puts it up as a file and keeps that on
|
||||
* the asset, and a game plays it from there. It is lit while the bake holds. While it runs, the
|
||||
* toolbar says how far it has got and a click cancels it; once baked, a click offers to bake it again
|
||||
* or to remove the bake. The tab itself always plays live.
|
||||
*/
|
||||
export class BakeButton extends Button {
|
||||
private job: { asset: BakedAsset; share: number; cancelled: boolean; done: Promise<void> } | null = null;
|
||||
|
||||
/** `refreshed` refreshes the tab: a bake starting or ending changes what its info says. */
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
private readonly sounds: SoundStore,
|
||||
private readonly shown: () => BakedAsset | null,
|
||||
private readonly refreshed: () => void,
|
||||
) {
|
||||
super('', { width: Theme.controlHeight, icon: HEADER_ICON_SVG.bake, toggle: true });
|
||||
this.tooltip = () => this.tip();
|
||||
this.onClick = () => this.clicked();
|
||||
}
|
||||
|
||||
status(asset = this.shown()): BakeStatus {
|
||||
if (this.job) return 'baking';
|
||||
if (!asset?.bake) return 'none';
|
||||
return validBake(this.state.project, asset) ? 'baked' : 'outdated';
|
||||
}
|
||||
|
||||
/** What the info of the tab adds: '' where there is no bake. */
|
||||
statusText(asset: BakedAsset): string {
|
||||
const status = this.status(asset);
|
||||
return status === 'baked' ? 'baked' : status === 'outdated' ? 'bake outdated' : '';
|
||||
}
|
||||
|
||||
/** Lights it while the bake of the asset shown holds; called whenever the project changes. */
|
||||
sync(): void {
|
||||
const asset = this.shown();
|
||||
this.setToggled(this.status(asset) === 'baked');
|
||||
const bakeable = asset !== null && bakeSourceOf(this.state.project, asset) !== null;
|
||||
this.setEnabled(
|
||||
this.job !== null || (this.state.mode !== 'play' && (bakeable || asset?.bake !== undefined)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Bakes the asset shown; done once it is baked, failed or cancelled (test hook). */
|
||||
bake(asset = this.shown()): Promise<void> {
|
||||
if (this.job) return this.job.done;
|
||||
if (!asset || this.state.mode === 'play') return Promise.resolve();
|
||||
const job = { asset, share: 0, cancelled: false, done: Promise.resolve() };
|
||||
this.job = job;
|
||||
job.done = this.run(job).finally(() => {
|
||||
this.job = null;
|
||||
this.refreshed();
|
||||
});
|
||||
this.refreshed();
|
||||
return job.done;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.job) this.job.cancelled = true;
|
||||
}
|
||||
|
||||
private clicked(): void {
|
||||
if (this.job) return this.cancel();
|
||||
const asset = this.shown();
|
||||
if (!asset) return;
|
||||
if (!asset.bake) return void this.bake(asset);
|
||||
new Menu([
|
||||
{ label: 'Bake again', onSelect: () => void this.bake(asset) },
|
||||
{ label: 'Remove bake', onSelect: () => this.remove(asset) },
|
||||
]).openBelow(this);
|
||||
}
|
||||
|
||||
private tip(): string {
|
||||
const asset = this.shown();
|
||||
switch (this.status(asset)) {
|
||||
case 'baking':
|
||||
return `Baking ${this.job?.asset.name ?? ''}: ${Math.round((this.job?.share ?? 0) * 100)}% – click to cancel`;
|
||||
case 'baked': {
|
||||
const bake = asset!.bake!;
|
||||
return `Baked: ${clockText(bake.length / BAKE_RATE)}, ${formatBytes(bake.bytes)}. A game plays the bake, this tab plays live`;
|
||||
}
|
||||
case 'outdated':
|
||||
return 'Changed since it was baked, so a game plays it live. Bake it again, or remove the bake';
|
||||
default:
|
||||
return 'Bake: work out once what it plays, so a game plays that instead of every note';
|
||||
}
|
||||
}
|
||||
|
||||
private async run(job: { asset: BakedAsset; share: number; cancelled: boolean }): Promise<void> {
|
||||
const { asset } = job;
|
||||
try {
|
||||
const made = await makeBake(this.state.project, asset, {
|
||||
progress: (share) => {
|
||||
const before = Math.round(job.share * 100);
|
||||
job.share = share;
|
||||
const now = Math.round(share * 100);
|
||||
if (now !== before) this.state.setNotice(`Baking ${asset.name}: ${now}%`, 'quiet');
|
||||
},
|
||||
cancelled: () => job.cancelled,
|
||||
});
|
||||
const { data, ...rest } = made;
|
||||
if (!this.unchanged(asset, made.print)) return this.changed(asset);
|
||||
const uploaded = await this.sounds.upload(data.slice().buffer, 'audio/ogg', bakeUploadName(asset.name));
|
||||
// Nothing names the file yet, so it can go at once.
|
||||
if (!this.unchanged(asset, made.print)) {
|
||||
void this.sounds.delete(uploaded.file).catch(() => {});
|
||||
return this.changed(asset);
|
||||
}
|
||||
const old = this.current(asset.id)?.bake?.file;
|
||||
this.state.setBake(asset.id, { file: uploaded.file, bytes: uploaded.bytes, ...rest });
|
||||
if (old && old !== uploaded.file) this.dropWhenSaved(old);
|
||||
this.state.setNotice(
|
||||
`Baked ${asset.name}: ${clockText(rest.length / BAKE_RATE)}, ${formatBytes(uploaded.bytes)}`,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof BakeCancelled) this.state.setNotice(`Baking ${asset.name} was cancelled`);
|
||||
else
|
||||
this.state.setNotice(
|
||||
`Baking ${asset.name} failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private remove(asset: BakedAsset): void {
|
||||
const old = asset.bake?.file;
|
||||
this.state.setBake(asset.id, null);
|
||||
if (old) this.dropWhenSaved(old);
|
||||
}
|
||||
|
||||
private current(id: string): BakedAsset | null {
|
||||
const asset = findAsset(this.state.project, id);
|
||||
return asset?.type === 'pattern' || asset?.type === 'song' ? asset : null;
|
||||
}
|
||||
|
||||
/** Whether it still sounds as it did when the bake was started. */
|
||||
private unchanged(asset: BakedAsset, print: string): boolean {
|
||||
const now = this.current(asset.id);
|
||||
return now !== null && bakePrint(this.state.project, now) === print;
|
||||
}
|
||||
|
||||
private changed(asset: BakedAsset): void {
|
||||
this.state.setNotice(`${asset.name} changed while it was baked – bake it again`, 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the file of a bake that has gone, once the project no longer names it on the server: the
|
||||
* server keeps a file any saved project or snapshot names (409), and so does this.
|
||||
*/
|
||||
private dropWhenSaved(file: string): void {
|
||||
const off = this.state.on('save', () => {
|
||||
const status = this.state.saveStatus;
|
||||
if (status === 'dirty' || status === 'saving') return;
|
||||
off();
|
||||
if (status === 'saved') void this.sounds.delete(file).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -132,3 +132,9 @@ export function barLabelStep(view: TimeView, meter: Meter): number {
|
||||
while (n * barPx < BAR_LABEL_MIN_PX && n < 1024) n *= 2;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** "1:05" */
|
||||
export function clockText(seconds: number): string {
|
||||
const whole = Math.round(seconds);
|
||||
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Icons of the tab headers' switches (#194): a picture instead of a caption. */
|
||||
import bake from './icons/bake.svg?raw';
|
||||
import magnet from './icons/magnet.svg?raw';
|
||||
import reverse from './icons/reverse.svg?raw';
|
||||
|
||||
export const HEADER_ICON_SVG = { snap: magnet, reverse };
|
||||
export const HEADER_ICON_SVG = { snap: magnet, reverse, bake };
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<!-- What it plays, laid down once: a stretch of wave inside the frame of a file. -->
|
||||
<path d="M-7 -6 H7 V6 H-7 Z" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M-4.5 -1 V1 M-2.5 -3.5 V3.5 M-0.5 -2 V2 M1.5 -4 V4 M3.5 -1.5 V1.5" fill="none" stroke="currentColor" stroke-width="1.3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 402 B |
@@ -26,7 +26,9 @@ import { Button, Dropdown, Label, Menu, type Widget } from '../../ui';
|
||||
import { Music } from '../../viewport/music';
|
||||
import { MASTER_MIX } from '../../core/music/mixer';
|
||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
import type { SoundStore } from '../../core/store/SoundStore';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { BakeButton } from '../music/BakeButton';
|
||||
import { claimTransport, releaseTransport } from '../music/transport';
|
||||
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
||||
import type { EditorState } from '../state';
|
||||
@@ -42,7 +44,8 @@ const PREVIEW_OWNER = 'pattern-preview';
|
||||
/**
|
||||
* The Pattern tab (#333): the piano roll, and in the header Play – the Space bar does the same –
|
||||
* and the grid notes are drawn on. The pattern plays round its loop through the scheduler (#332);
|
||||
* an edit is heard from its next window on, without starting over.
|
||||
* an edit is heard from its next window on, without starting over. Bake (#340) is for a game: the
|
||||
* tab always plays the notes.
|
||||
*/
|
||||
export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
|
||||
/** Public for the check scripts. */
|
||||
@@ -54,6 +57,7 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
|
||||
readonly ghostsButton: Button;
|
||||
/** Where the ghost notes come from: song, track and bar. */
|
||||
readonly ghostSource = new Label('', { style: 'muted' });
|
||||
readonly bakeButton: BakeButton;
|
||||
/** Heard through the sound FX (#352), then straight into the master: a preview leaves the routing out (#368). */
|
||||
readonly music = new Music(
|
||||
() => this.state.project,
|
||||
@@ -69,8 +73,14 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
|
||||
private told = '';
|
||||
private readonly halt = () => this.stop();
|
||||
|
||||
constructor(state: EditorState) {
|
||||
constructor(state: EditorState, sounds: SoundStore) {
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.bakeButton = new BakeButton(
|
||||
state,
|
||||
sounds,
|
||||
() => this.asset(),
|
||||
() => this.refresh(),
|
||||
);
|
||||
this.playButton = new Button('Play', {
|
||||
icon: TOOLBAR_ICON_SVG.play,
|
||||
minWidth: 72,
|
||||
@@ -111,6 +121,7 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
|
||||
this.gridButton,
|
||||
this.auditionButton,
|
||||
this.ghostsButton,
|
||||
this.bakeButton,
|
||||
this.ghostSource,
|
||||
]);
|
||||
state.on('load', () => this.stop());
|
||||
@@ -238,7 +249,9 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
|
||||
const count = asset.notes.length;
|
||||
const notes = `${count} ${count === 1 ? 'note' : 'notes'}`;
|
||||
const instrument = findAssetOf(this.state.project, patternInstrument(asset.props), 'instrument');
|
||||
return instrument ? `${notes} · ${instrument.name}` : `${notes} · no instrument`;
|
||||
const text = instrument ? `${notes} · ${instrument.name}` : `${notes} · no instrument`;
|
||||
const bake = this.bakeButton.statusText(asset);
|
||||
return bake ? `${text} · ${bake}` : text;
|
||||
}
|
||||
|
||||
protected override syncExtras(asset: PatternAsset | null): void {
|
||||
@@ -257,6 +270,7 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
|
||||
this.canvas.setInstrument(new Set(sounding), namedNotes(instrument.graph));
|
||||
} else this.canvas.setInstrument(new Set(), new Map());
|
||||
this.syncGhosts(asset);
|
||||
this.bakeButton.sync();
|
||||
this.follow(asset);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ import { activateAsset } from '../actions';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { GraphCanvas } from '../graph/GraphCanvas';
|
||||
import { SnapToggle } from '../graph/SnapToggle';
|
||||
import { BakeButton } from '../music/BakeButton';
|
||||
import { clockText } from '../music/timeView';
|
||||
import { claimTransport, releaseTransport } from '../music/transport';
|
||||
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
||||
import type { EditorState } from '../state';
|
||||
@@ -89,6 +91,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
/** Public for the check scripts. */
|
||||
readonly timelineButton: Button;
|
||||
readonly routingButton: Button;
|
||||
readonly bakeButton: BakeButton;
|
||||
readonly routing: GraphCanvas;
|
||||
readonly snap = new SnapToggle(() => this.routing);
|
||||
private shown: SongView = 'timeline';
|
||||
@@ -141,6 +144,12 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
onClick: () => this.showView('routing'),
|
||||
});
|
||||
this.routing = new GraphCanvas(state, 'song');
|
||||
this.bakeButton = new BakeButton(
|
||||
state,
|
||||
sounds,
|
||||
() => this.asset(),
|
||||
() => this.refresh(),
|
||||
);
|
||||
this.inputButton.onClick = () => void this.openInputMenu();
|
||||
this.gridButton.onClick = () => this.openGridMenu();
|
||||
const timeline = new SongTimeline(state);
|
||||
@@ -177,6 +186,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
this.meter,
|
||||
new Label('Grid', { style: 'muted' }),
|
||||
this.gridButton,
|
||||
this.bakeButton,
|
||||
]);
|
||||
state.on('load', () => this.stop({ discard: true }));
|
||||
void this.listInputs();
|
||||
@@ -567,7 +577,9 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
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'}`;
|
||||
const text = `${tracks} ${tracks === 1 ? 'track' : 'tracks'} · ${items} ${items === 1 ? 'item' : 'items'}`;
|
||||
const bake = this.bakeButton.statusText(asset);
|
||||
return bake ? `${text} · ${bake}` : text;
|
||||
}
|
||||
|
||||
protected override syncExtras(asset: SongAsset | null): void {
|
||||
@@ -577,16 +589,11 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
this.syncPlayButton();
|
||||
const grid = textOf(asset?.props[SONG_GRID_KEY] ?? DEFAULT_SONG_GRID);
|
||||
this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? DEFAULT_SONG_GRID);
|
||||
this.bakeButton.sync();
|
||||
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' }));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Container, type FederatedPointerEvent, Graphics, type Text } from 'pixi.js';
|
||||
import { isBakeUpload } from '../../core/music/bake';
|
||||
import type { StoredSound } from '../../core/store/SoundStore';
|
||||
import { drawSvg, formatBytes, textStyle, Theme, truncate, crispText } from '../../ui';
|
||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
@@ -118,8 +119,10 @@ class SoundPicker extends FilePicker<SoundItem> {
|
||||
soundLibrary.stopAll();
|
||||
}
|
||||
|
||||
/** A bake (#340) is in the sounds, but no sound to pick. */
|
||||
protected async fetchItems(): Promise<SoundItem[]> {
|
||||
return withAssetNames(await this.deps.sounds.list(), this.deps.state.project, 'sound');
|
||||
const sounds = (await this.deps.sounds.list()).filter((sound) => !isBakeUpload(sound.name));
|
||||
return withAssetNames(sounds, this.deps.state.project, 'sound');
|
||||
}
|
||||
|
||||
protected contentHeight(count: number): { unit: number; total: number } {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { KeyClipboard } from '../core/commands/animationCommands';
|
||||
import type { PatternPlace } from '../core/music/ghosts';
|
||||
import type { Bake } from '../core/music/bake';
|
||||
import type { EditorClipboard } from './clipboard';
|
||||
import type { ObjectPick, PickEnd } from './objectPick';
|
||||
import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation';
|
||||
@@ -427,6 +428,21 @@ export class EditorState {
|
||||
this.emit('project');
|
||||
}
|
||||
|
||||
/**
|
||||
* The bake of a pattern or song (#340), or none: saved, and like the game scale no undo step – an
|
||||
* undo back to what was baked finds it again.
|
||||
*/
|
||||
setBake(assetId: string, bake: Bake | null): void {
|
||||
for (const project of [this.project, this.edit?.project]) {
|
||||
const asset = project && findAsset(project, assetId);
|
||||
if (asset?.type !== 'pattern' && asset?.type !== 'song') continue;
|
||||
if (bake) asset.bake = bake;
|
||||
else delete asset.bake;
|
||||
}
|
||||
this.setSaveStatus('dirty');
|
||||
this.emit('project');
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a command through the undo stack. Returns false (and sets a notice) when invalid.
|
||||
* In play mode the command changes the runtime copy only (see `enterPlay`).
|
||||
|
||||
Reference in new issue
Block a user