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;
|
||||
});
|
||||
}
|
||||
@@ -103,10 +103,15 @@ import {
|
||||
} from './particles';
|
||||
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
|
||||
import { SHADER_RESOLUTION_KEY, SHADER_RESOLUTION_OPTIONS } from './cameraShader';
|
||||
import { FADE_CURVES } from '../model/sound';
|
||||
import {
|
||||
DEFAULT_FADE_CURVE,
|
||||
DEFAULT_FADE_MS,
|
||||
END_KEY as SOUND_END_KEY,
|
||||
FADE_KEY as SOUND_FADE_KEY,
|
||||
FADE_IN_CURVE_KEY as SOUND_FADE_IN_CURVE_KEY,
|
||||
FADE_IN_KEY as SOUND_FADE_IN_KEY,
|
||||
FADE_OUT_CURVE_KEY as SOUND_FADE_OUT_CURVE_KEY,
|
||||
FADE_OUT_KEY as SOUND_FADE_OUT_KEY,
|
||||
LOOP_KEY as SOUND_LOOP_KEY,
|
||||
START_KEY as SOUND_START_KEY,
|
||||
VOLUME_KEY as SOUND_VOLUME_KEY,
|
||||
@@ -686,16 +691,31 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
// 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 ramps at a start and an end inside the file, each with its shape (#345).
|
||||
...(
|
||||
[
|
||||
[SOUND_FADE_IN_KEY, 'Fade in (ms)', SOUND_FADE_IN_CURVE_KEY, 'Fade in curve'],
|
||||
[SOUND_FADE_OUT_KEY, 'Fade out (ms)', SOUND_FADE_OUT_CURVE_KEY, 'Fade out curve'],
|
||||
] as const
|
||||
).flatMap(([key, label, curveKey, curveLabel]) => [
|
||||
{
|
||||
key,
|
||||
label,
|
||||
kind: 'number' as const,
|
||||
default: DEFAULT_FADE_MS,
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 1,
|
||||
decimals: 0,
|
||||
},
|
||||
{
|
||||
key: curveKey,
|
||||
label: curveLabel,
|
||||
kind: 'enum' as const,
|
||||
default: DEFAULT_FADE_CURVE,
|
||||
options: FADE_CURVES,
|
||||
},
|
||||
]),
|
||||
],
|
||||
// The effects are the asset's graph (see SoundFxAsset), not a property.
|
||||
soundfx: [],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { booleanOf, type PropertyBag } from '../model/Properties';
|
||||
import type { SoundCut } from '../model/sound';
|
||||
import { type FadeCurve, isFadeCurve, 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. */
|
||||
@@ -9,9 +9,14 @@ 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;
|
||||
/** Milliseconds the start and the end ramp over when they lie inside the file, so they do not click (#345). */
|
||||
export const FADE_IN_KEY = 'fadeIn';
|
||||
export const FADE_OUT_KEY = 'fadeOut';
|
||||
export const DEFAULT_FADE_MS = 5;
|
||||
/** The shape of each ramp. */
|
||||
export const FADE_IN_CURVE_KEY = 'fadeInCurve';
|
||||
export const FADE_OUT_CURVE_KEY = 'fadeOutCurve';
|
||||
export const DEFAULT_FADE_CURVE: FadeCurve = 'linear';
|
||||
|
||||
export function volumeOf(bag: PropertyBag): number {
|
||||
return coerceAs(bag[VOLUME_KEY], 'number', 1);
|
||||
@@ -21,12 +26,19 @@ export function loopsOf(bag: PropertyBag): boolean {
|
||||
return booleanOf(bag[LOOP_KEY]);
|
||||
}
|
||||
|
||||
/** The stretch of its file a sound is, its fade in seconds. */
|
||||
/** The stretch of its file a sound is, its fades in seconds. */
|
||||
export function cutOf(bag: PropertyBag): SoundCut {
|
||||
const seconds = (key: string, fallback: number) => Math.max(0, coerceAs(bag[key], 'number', fallback));
|
||||
const curve = (key: string) => {
|
||||
const value = bag[key];
|
||||
return isFadeCurve(value) ? value : DEFAULT_FADE_CURVE;
|
||||
};
|
||||
return {
|
||||
start: seconds(START_KEY, 0),
|
||||
end: seconds(END_KEY, 0),
|
||||
fade: seconds(FADE_KEY, DEFAULT_FADE_MS) / 1000,
|
||||
fadeIn: seconds(FADE_IN_KEY, DEFAULT_FADE_MS) / 1000,
|
||||
fadeOut: seconds(FADE_OUT_KEY, DEFAULT_FADE_MS) / 1000,
|
||||
curveIn: curve(FADE_IN_CURVE_KEY),
|
||||
curveOut: curve(FADE_OUT_CURVE_KEY),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user