A sound asset now opens in a tab that shows its file as a waveform. Two handles set where the sound starts and ends in the file, and that stretch is the sample: Play sound, a song item and an instrument's Sample all play it and nothing else. The file itself is never changed. - The asset has three new rows: Start (s), End (s) and Edge fade (ms). End 0 is the end of the file, the fade is 10 ms by default. Older assets have no values and play the whole file as before. - Every way of playing asks playedSoundOf() for a name instead of the file: the file, or <file>#<start>~<end>~<fade> for a cut. - SoundLibrary makes the cut from the decoded file the first time that name plays and ramps each edge inside the file over the fade. Length and peaks of a cut are read from the file without a copy. - Cut buffers can be made again at any time, so the library lets the oldest go past 64 Mi samples. A voice holds its buffers itself. - The tab draws min/max per pixel from 256-sample block peaks, and the samples as a line when zoomed in far. Wheel zooms, the middle button pans, as in the music tabs. Wave, cut and ruler sit under a mask, so a handle panned out of view does not draw over the panels beside. - Dragging a handle moves that edge, dragging elsewhere spans a new stretch. One drag is one undo step for both edges. Whole file puts the handles back, Play or Space plays the stretch with a playhead. - The inspector preview and the Sound FX preview play the cut too, and the song lanes draw and repeat it. Tests: - Unit: sounds 20, wave-view 8 (new), sound-library 68, plus 258 in the touched areas (instrument, song, pattern, inspector, tabs) - e2e 34 on melektaus-atx: ALL PASSED, 57 checks (7 new) - Gate on melektaus-atx: CHECK EXIT 0, 253 files, 3936 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
65 lines
2.4 KiB
TypeScript
65 lines
2.4 KiB
TypeScript
import { playedSoundById } from '../../core/properties/playedSound';
|
|
import { Button, HStack, Label, Theme } from '../../ui';
|
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
|
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
|
import type { EditorState } from '../state';
|
|
|
|
/**
|
|
* What a sound asset sounds like: a button that plays it and the length the decoder reports. Both
|
|
* are of the stretch the asset is cut to (#343); the waveform is in its tab.
|
|
*
|
|
* Playing here goes through the same library a running game uses, so what the inspector plays is
|
|
* exactly what the graph will: same decode, same device, and `Stop` reaches both.
|
|
*/
|
|
export class SoundPreview extends HStack {
|
|
private readonly length: Label;
|
|
private shown = '';
|
|
private readonly stopWatching: () => void;
|
|
|
|
constructor(
|
|
private readonly state: EditorState,
|
|
private readonly assetId: string,
|
|
) {
|
|
super({ gap: Theme.gap, align: 'center' });
|
|
const play = new Button('Play', { icon: TOOLBAR_ICON_SVG.play });
|
|
play.onClick = () => soundLibrary.play(this.played(), 1, false);
|
|
this.add(play);
|
|
const stop = new Button('Stop', { icon: TOOLBAR_ICON_SVG.stop });
|
|
stop.onClick = () => soundLibrary.stop(this.played());
|
|
this.add(stop);
|
|
this.length = new Label('', { style: 'muted' });
|
|
this.add(this.length, 1);
|
|
this.showLength();
|
|
this.stopWatching = state.on('project', () => this.showLength());
|
|
}
|
|
|
|
private played(): string {
|
|
return playedSoundById(this.state.project, this.assetId);
|
|
}
|
|
|
|
/**
|
|
* The length is only known once the bytes are decoded, and decoding needs an audio device. So it
|
|
* is asked for rather than waited on: a preview nobody has played yet says nothing about length,
|
|
* which is more honest than a number guessed from the file size.
|
|
*/
|
|
private showLength(): void {
|
|
const name = this.played();
|
|
if (name === this.shown) return;
|
|
this.shown = name;
|
|
const known = soundLibrary.duration(name);
|
|
if (known !== null) {
|
|
this.length.setText(`${known.toFixed(2)} s`);
|
|
return;
|
|
}
|
|
void soundLibrary.load(name).then((buffer) => {
|
|
if (this.destroyed || this.shown !== name) return;
|
|
this.length.setText(buffer ? `${buffer.duration.toFixed(2)} s` : 'Could not be decoded.');
|
|
});
|
|
}
|
|
|
|
override destroy(options?: Parameters<HStack['destroy']>[0]): void {
|
|
this.stopWatching();
|
|
super.destroy(options);
|
|
}
|
|
}
|