Cut a sound to a stretch of its file in its own Sound tab (#343)
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:
1 parent
2fdf97f7de
commit
f1f4a57589
24 files changed
+1442
-53
No files matched your search
@@ -15,6 +15,7 @@ import type { InstrumentAsset } from '../model/Asset';
|
||||
import { graphLookup } from '../model/graphLookup';
|
||||
import { textOf } from '../model/Properties';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { playedSoundOf } from '../properties/playedSound';
|
||||
import { resolveAssetProps } from '../properties/resolve';
|
||||
import { volumeOf } from '../properties/sounds';
|
||||
import { soundFxBuildFor } from '../soundfx/compile';
|
||||
@@ -71,7 +72,10 @@ export function instrumentSourcesOf(project: Project): InstrumentSources {
|
||||
lookup: graphLookup(project),
|
||||
sound: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'sound');
|
||||
return asset ? { file: asset.file, volume: volumeOf(resolveAssetProps(project, asset)) } : null;
|
||||
if (!asset) return null;
|
||||
// An asset without a file stays without one, so the Sample can say so.
|
||||
const file = asset.file ? playedSoundOf(project, asset) : '';
|
||||
return { file, volume: volumeOf(resolveAssetProps(project, asset)) };
|
||||
},
|
||||
soundFx: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'soundfx');
|
||||
@@ -79,7 +83,7 @@ export function instrumentSourcesOf(project: Project): InstrumentSources {
|
||||
},
|
||||
key: (assetId) => {
|
||||
const sound = findAssetOf(project, assetId, 'sound');
|
||||
if (sound) return `${sound.file}@${volumeOf(resolveAssetProps(project, sound))}`;
|
||||
if (sound) return `${playedSoundOf(project, sound)}@${volumeOf(resolveAssetProps(project, sound))}`;
|
||||
const fx = findAssetOf(project, assetId, 'soundfx');
|
||||
return fx ? graphFingerprint(fx.graph) : '';
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { instrumentBuildFor, instrumentSourcesOf } from '../instrument/compile';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { playedSoundById } from '../properties/playedSound';
|
||||
import { soundFxBuildFor } from '../soundfx/compile';
|
||||
import { DRY_VOICE, type FxPlan, type FxVoice } from '../soundfx/nodes';
|
||||
import type { SequenceNeeds } from './playbacks';
|
||||
@@ -26,7 +27,7 @@ export function sequenceNeeds(project: Project, sequence: Sequence): SequenceNee
|
||||
}
|
||||
const files = new Set<string>();
|
||||
for (const clip of sequence.clips ?? []) {
|
||||
const file = findAssetOf(project, clip.sound, 'sound')?.file;
|
||||
const file = playedSoundById(project, clip.sound);
|
||||
if (file) files.add(file);
|
||||
const fx = clipFx(project, clip.fx, 120);
|
||||
if (fx !== DRY_VOICE) layers.push(fx.plan);
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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) : '';
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user