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

+23 -3
View File
@@ -103,7 +103,14 @@ import {
} from './particles';
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
import { SHADER_RESOLUTION_KEY, SHADER_RESOLUTION_OPTIONS } from './cameraShader';
import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './sounds';
import {
DEFAULT_FADE_MS,
END_KEY as SOUND_END_KEY,
FADE_KEY as SOUND_FADE_KEY,
LOOP_KEY as SOUND_LOOP_KEY,
START_KEY as SOUND_START_KEY,
VOLUME_KEY as SOUND_VOLUME_KEY,
} from './sounds';
import {
BEAT_OPTIONS,
DEFAULT_BARS,
@@ -671,11 +678,24 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property. Nearest draws
// a pixel font a pixel per unit, magnified sharp.
font: [filterRow(true)],
// The file is intrinsic data (see SoundAsset); these two say how it is played by default, and a
// `Play sound` node scales the one and can add to the other.
// The file is intrinsic data (see SoundAsset); volume and loop say how it is played by default, and
// a `Play sound` node scales the one and can add to the other.
sound: [
{ key: SOUND_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
{ key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false },
// The stretch of the file it is (#343), set in its tab; an end of 0 is the end of the file.
{ key: SOUND_START_KEY, label: 'Start (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
{ key: SOUND_END_KEY, label: 'End (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
{
key: SOUND_FADE_KEY,
label: 'Edge fade (ms)',
kind: 'number',
default: DEFAULT_FADE_MS,
min: 0,
max: 1000,
step: 1,
decimals: 0,
},
],
// The effects are the asset's graph (see SoundFxAsset), not a property.
soundfx: [],
+19
View File
@@ -0,0 +1,19 @@
import type { SoundAsset } from '../model/Asset';
import { findAssetOf, type Project } from '../model/Project';
import { cutName } from '../model/sound';
import { resolveAssetProps } from './resolve';
import { cutOf } from './sounds';
/**
* What a sound asset is played by (#343): its file, or the stretch of it the asset is cut to. Every
* way a sound is heard asks this rather than for the file – the cut is the sample.
*/
export function playedSoundOf(project: Project, asset: SoundAsset): string {
return cutName(asset.file, cutOf(resolveAssetProps(project, asset)));
}
/** The same by id, empty when the id is unset or no longer names a sound. */
export function playedSoundById(project: Project, id: string): string {
const asset = id === '' ? undefined : findAssetOf(project, id, 'sound');
return asset ? playedSoundOf(project, asset) : '';
}
+17
View File
@@ -1,10 +1,17 @@
import { booleanOf, type PropertyBag } from '../model/Properties';
import type { SoundCut } from '../model/sound';
import { coerceAs } from './kinds';
/** How loud this sound is by itself, 0–1; a `Play sound` node scales this rather than replacing it. */
export const VOLUME_KEY = 'volume';
/** Whether this sound is music: it repeats until something stops it. */
export const LOOP_KEY = 'loop';
/** Where in its file the sound starts and ends, in seconds; an end of 0 is the end of the file (#343). */
export const START_KEY = 'start';
export const END_KEY = 'end';
/** Milliseconds each cut edge inside the file ramps over, so it does not click. */
export const FADE_KEY = 'fade';
export const DEFAULT_FADE_MS = 10;
export function volumeOf(bag: PropertyBag): number {
return coerceAs(bag[VOLUME_KEY], 'number', 1);
@@ -13,3 +20,13 @@ export function volumeOf(bag: PropertyBag): number {
export function loopsOf(bag: PropertyBag): boolean {
return booleanOf(bag[LOOP_KEY]);
}
/** The stretch of its file a sound is, its fade in seconds. */
export function cutOf(bag: PropertyBag): SoundCut {
const seconds = (key: string, fallback: number) => Math.max(0, coerceAs(bag[key], 'number', fallback));
return {
start: seconds(START_KEY, 0),
end: seconds(END_KEY, 0),
fade: seconds(FADE_KEY, DEFAULT_FADE_MS) / 1000,
};
}