Fade a sound's start and end apart, each along its own curve (#345)
A cut sound had one edge fade for both ends, always a straight ramp. Now its start and its end each have a fade of their own, and each fade has a curve of its own. - Fade in (ms) and Fade out (ms) replace Edge fade (ms), 5 ms each by default. Like before they apply only to an edge inside the file. - Fade in curve and Fade out curve each offer Linear (the default), Equal power, S-curve, Exponential and Logarithmic. A fade out runs its curve backwards. - The Sound tab's header has a field and a curve dropdown per edge. Each dropdown shows its curve as a small picture, rising or falling, and names it in its tooltip. With two named dropdowns the header was too wide at 1280 px and cut off the readout on the right. - The inspector shows the same four rows, the curves by name. - The wave draws each ramp in its curve rather than as a line. - A cut plays by <file>#<start>~<end>~<fadeIn>~<fadeOut>~<curveIn>~<curveOut>. - No migration, as asked in the issue: the old fade key is no longer read, so saved sounds start with 5 ms each and Linear on both. Tests: - Unit sounds, sound-library: 96 tests - Counter-check (fade out along the fade in's curve): 1 red - e2e 34 (melektaus-atx): ALL PASSED - 69 checks - Gate (melektaus-atx): CHECK EXIT 0, 256 files, 3978 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b49a0bea7
commit
29b604dc07
10 files changed
+421
-96
No files matched your search
+78
-18
@@ -14,63 +14,123 @@ export function soundFileOf(project: Project, id: string): string {
|
||||
return asset?.type === 'sound' ? asset.file : '';
|
||||
}
|
||||
|
||||
/** The shape of the ramp at a cut edge (#345), from silence to full; the end runs it backwards. */
|
||||
export type FadeCurve = 'linear' | 'equalPower' | 'sCurve' | 'exponential' | 'logarithmic';
|
||||
|
||||
export const FADE_CURVES: { value: FadeCurve; label: string }[] = [
|
||||
{ value: 'linear', label: 'Linear' },
|
||||
{ value: 'equalPower', label: 'Equal power' },
|
||||
{ value: 'sCurve', label: 'S-curve' },
|
||||
{ value: 'exponential', label: 'Exponential' },
|
||||
{ value: 'logarithmic', label: 'Logarithmic' },
|
||||
];
|
||||
|
||||
export function isFadeCurve(value: unknown): value is FadeCurve {
|
||||
return FADE_CURVES.some((curve) => curve.value === value);
|
||||
}
|
||||
|
||||
/** How loud a ramp of that curve is `t` of the way from silence (0) to full (1). */
|
||||
export function fadeGain(curve: FadeCurve, t: number): number {
|
||||
const x = Math.min(1, Math.max(0, t));
|
||||
switch (curve) {
|
||||
case 'equalPower':
|
||||
return Math.sin((x * Math.PI) / 2);
|
||||
case 'sCurve':
|
||||
return (1 - Math.cos(x * Math.PI)) / 2;
|
||||
case 'exponential':
|
||||
return x * x;
|
||||
case 'logarithmic':
|
||||
return 1 - (1 - x) * (1 - x);
|
||||
default:
|
||||
return x;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `fadeIn` and `fadeOut` are the ramps at the start and the end when that edge lies inside the file,
|
||||
* so a cut through a wave does not click, shaped by `curveIn` and `curveOut` (#345).
|
||||
*/
|
||||
export interface SoundCut {
|
||||
start: number;
|
||||
end: number;
|
||||
fade: number;
|
||||
fadeIn: number;
|
||||
fadeOut: number;
|
||||
curveIn: FadeCurve;
|
||||
curveOut: FadeCurve;
|
||||
}
|
||||
|
||||
/** Samples of a file a cut keeps, `from` up to `to`, and the samples of each ramp at an inner edge. */
|
||||
/** Samples of a file a cut keeps, `from` up to `to`, the samples of each ramp at an inner edge and their shape. */
|
||||
export interface CutRange {
|
||||
from: number;
|
||||
to: number;
|
||||
fadeIn: number;
|
||||
fadeOut: number;
|
||||
curveIn: FadeCurve;
|
||||
curveOut: FadeCurve;
|
||||
}
|
||||
|
||||
/** What a sound is played by: its file, or for a cut `<file>#<start>~<end>~<fade>`, a name of its own. */
|
||||
/**
|
||||
* What a sound is played by: its file, or for a cut
|
||||
* `<file>#<start>~<end>~<fadeIn>~<fadeOut>~<curveIn>~<curveOut>`, 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}`;
|
||||
return `${file}#${cut.start}~${cut.end}~${cut.fadeIn}~${cut.fadeOut}~${cut.curveIn}~${cut.curveOut}`;
|
||||
}
|
||||
|
||||
/** 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 parts = name.slice(hash + 1).split('~');
|
||||
const [start, end, fadeIn, fadeOut] = parts.slice(0, 4).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) } };
|
||||
const curve = (part: string | undefined): FadeCurve => (isFadeCurve(part) ? part : 'linear');
|
||||
return {
|
||||
file: name.slice(0, hash),
|
||||
cut: {
|
||||
start: held(start),
|
||||
end: held(end),
|
||||
fadeIn: held(fadeIn),
|
||||
fadeOut: held(fadeOut),
|
||||
curveIn: curve(parts[4]),
|
||||
curveOut: curve(parts[5]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* the start or the end of the file is left as it is; one inside ramps over its 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 };
|
||||
const ramp = (seconds: number) =>
|
||||
Math.min(Math.round(Math.max(0, seconds) * rate), Math.floor((to - from) / 2));
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
fadeIn: from > 0 ? ramp(cut.fadeIn) : 0,
|
||||
fadeOut: to < last ? ramp(cut.fadeOut) : 0,
|
||||
curveIn: cut.curveIn,
|
||||
curveOut: cut.curveOut,
|
||||
};
|
||||
}
|
||||
|
||||
/** The samples of each channel a cut keeps, its inner edges ramped in and out. */
|
||||
/** The samples of each channel a cut keeps, its inner edges ramped in and out, each along its curve. */
|
||||
export function cutChannels(channels: readonly Float32Array[], range: CutRange): Float32Array<ArrayBuffer>[] {
|
||||
const { from, to, fadeIn, fadeOut } = range;
|
||||
const { from, to, fadeIn, fadeOut, curveIn, curveOut } = range;
|
||||
const rise = Array.from({ length: fadeIn }, (_, i) => fadeGain(curveIn, i / fadeIn));
|
||||
const fall = Array.from({ length: fadeOut }, (_, i) => fadeGain(curveOut, i / fadeOut));
|
||||
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;
|
||||
rise.forEach((gain, i) => (kept[i]! *= gain));
|
||||
fall.forEach((gain, i) => (kept[kept.length - 1 - i]! *= gain));
|
||||
return kept;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user