Cut a sound to a stretch of its file in its own Sound tab (#343)
game-maker / gate (push) Successful in 3m32s
game-maker / suite (push) Failing after 18m20s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 21:42:21 +02:00
1 parent 2fdf97f7de
commit f1f4a57589
24 files changed
+1442 -53

No files matched your search

+61
View File
@@ -13,3 +13,64 @@ export function soundFileOf(project: Project, id: string): string {
const asset = findAsset(project, id);
return asset?.type === 'sound' ? asset.file : '';
}
/**
* The stretch of its file a sound asset is (#343), in seconds: `end` 0 runs to the end of the file.
* `fade` is the ramp each edge inside the file gets, so a cut through a wave does not click.
*/
export interface SoundCut {
start: number;
end: number;
fade: number;
}
/** Samples of a file a cut keeps, `from` up to `to`, and the samples of each ramp at an inner edge. */
export interface CutRange {
from: number;
to: number;
fadeIn: number;
fadeOut: number;
}
/** What a sound is played by: its file, or for a cut `<file>#<start>~<end>~<fade>`, a name of its own. */
export function cutName(file: string, cut: SoundCut): string {
if (file === '' || (cut.start <= 0 && cut.end <= 0)) return file;
return `${file}#${cut.start}~${cut.end}~${cut.fade}`;
}
/** A name `cutName` made, taken apart again; a plain file has no cut. */
export function parseCutName(name: string): { file: string; cut: SoundCut | null } {
const hash = name.indexOf('#');
if (hash < 0) return { file: name, cut: null };
const [start, end, fade] = name
.slice(hash + 1)
.split('~')
.map((part) => Number(part));
const held = (value: number | undefined) => (Number.isFinite(value) ? Math.max(0, value!) : 0);
return { file: name.slice(0, hash), cut: { start: held(start), end: held(end), fade: held(fade) } };
}
/**
* Where a cut lies in `length` samples at `rate`: at least one sample, inside the file. An edge at
* the start or the end of the file is left as it is; one inside ramps over `fade`, no more than half
* of what is kept so the two ramps never cross.
*/
export function cutRange(length: number, rate: number, cut: SoundCut): CutRange {
const last = Math.max(1, length);
const from = Math.min(last - 1, Math.max(0, Math.round(cut.start * rate)));
const asked = cut.end > 0 ? Math.min(last, Math.round(cut.end * rate)) : last;
const to = Math.max(from + 1, asked);
const ramp = Math.min(Math.round(Math.max(0, cut.fade) * rate), Math.floor((to - from) / 2));
return { from, to, fadeIn: from > 0 ? ramp : 0, fadeOut: to < last ? ramp : 0 };
}
/** The samples of each channel a cut keeps, its inner edges ramped in and out. */
export function cutChannels(channels: readonly Float32Array[], range: CutRange): Float32Array<ArrayBuffer>[] {
const { from, to, fadeIn, fadeOut } = range;
return channels.map((data) => {
const kept = data.slice(from, to);
for (let i = 0; i < fadeIn; i++) kept[i]! *= i / fadeIn;
for (let i = 0; i < fadeOut; i++) kept[kept.length - 1 - i]! *= i / fadeOut;
return kept;
});
}