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:
adminandClaude Opus 5.5 committed 2026-10-07 09:48:59 +02:00
1 parent 6b49a0bea7
commit 29b604dc07
10 files changed
+408 -83

No files matched your search

+78 -18
View File
@@ -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;
});
}
+24 -4
View File
@@ -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 },
// 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: SOUND_FADE_KEY,
label: 'Edge fade (ms)',
kind: 'number',
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: [],
+18 -6
View File
@@ -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),
};
}
+11 -1
View File
@@ -21,7 +21,17 @@ export function soundHooks({ state, editor }: HookContext) {
playing: () => soundEditor.isPlaying,
playButton: () => ({ ...centreOf(soundEditor.playButton), text: soundEditor.playButton.text }),
wholeButton: () => ({ ...centreOf(soundEditor.wholeButton), enabled: soundEditor.wholeButton.enabled }),
fadeField: () => ({ ...centreOf(soundEditor.fadeField), text: soundEditor.fadeField.value }),
fadeInField: () => ({ ...centreOf(soundEditor.fadeInField), text: soundEditor.fadeInField.value }),
fadeOutField: () => ({ ...centreOf(soundEditor.fadeOutField), text: soundEditor.fadeOutField.value }),
/** The curve dropdowns show a picture of their curve; the tooltip names it. */
curveInButton: () => ({
...centreOf(soundEditor.curveInButton),
tooltip: soundEditor.curveInButton.tooltipText(),
}),
curveOutButton: () => ({
...centreOf(soundEditor.curveOutButton),
tooltip: soundEditor.curveOutButton.tooltipText(),
}),
/** What a sound asset is played by: its file, or the name of its cut. */
played: (assetId: string) => {
const asset = findAssetOf(state.project, assetId, 'sound');
+106 -15
View File
@@ -2,8 +2,17 @@ import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import type { Asset, SoundAsset } from '../../core/model/Asset';
import { playedSoundOf } from '../../core/properties/playedSound';
import { resolveAssetProps } from '../../core/properties/resolve';
import { cutOf, FADE_KEY, volumeOf } from '../../core/properties/sounds';
import { Button, Label, NumberField } from '../../ui';
import { FADE_CURVES, type FadeCurve, fadeGain } from '../../core/model/sound';
import {
cutOf,
DEFAULT_FADE_CURVE,
FADE_IN_CURVE_KEY,
FADE_IN_KEY,
FADE_OUT_CURVE_KEY,
FADE_OUT_KEY,
volumeOf,
} from '../../core/properties/sounds';
import { Button, Dropdown, Label, Menu, NumberField } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { AssetTabEditor } from '../AssetTabEditor';
import { claimTransport, releaseTransport } from '../music/transport';
@@ -14,17 +23,41 @@ import { WaveCanvas } from './WaveCanvas';
const INFO_WIDTH = 140;
const FADE_WIDTH = 56;
const PREVIEW_OWNER = 'sound-preview';
/** Points along the curve in its icon. */
const ICON_STEPS = 12;
/** The shape of a fade as a 16 px icon, rising for a fade in and falling for a fade out. */
function curveIcon(curve: FadeCurve, rising: boolean): string {
const points = Array.from({ length: ICON_STEPS + 1 }, (_, i) => {
const t = i / ICON_STEPS;
const gain = fadeGain(curve, rising ? t : 1 - t);
return `${(-6 + 12 * t).toFixed(2)} ${(6 - 12 * gain).toFixed(2)}`;
});
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">' +
`<path d="M${points.join(' L')}" fill="none" stroke="currentColor" stroke-width="1.5" />` +
'</svg>'
);
}
function curveLabel(curve: FadeCurve | undefined): string {
return FADE_CURVES.find((entry) => entry.value === curve)?.label ?? FADE_CURVES[0]!.label;
}
/**
* The Sound tab (#343): the file as a waveform, and the stretch of it the asset is. That stretch is
* the sample – a song item, a `Play sound` and an instrument's Sample all play it and nothing else.
* The header plays it (Space does too), sets the fade at its edges and puts the whole file back.
* The header plays it (Space does too), sets the fade at each edge and its curve, and puts the
* whole file back.
*/
export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
/** Public for the check scripts. */
readonly playButton: Button;
readonly wholeButton: Button;
readonly fadeField: NumberField;
readonly fadeInField: NumberField;
readonly fadeOutField: NumberField;
readonly curveInButton: Dropdown;
readonly curveOutButton: Dropdown;
/** The voice the tab plays, 0 while none, and the sound it is of. */
private voice = 0;
private playingId = '';
@@ -42,22 +75,41 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
tooltip: 'Play the whole file again: the handles go back to its start and end',
onClick: () => this.canvas.resetCut(),
});
this.fadeField = new NumberField(0, {
const fadeField = (key: string) =>
new NumberField(0, {
width: FADE_WIDTH,
min: 0,
max: 1000,
step: 1,
decimals: 0,
onChange: (ms) => this.setFade(ms),
onChange: (ms) => this.setFade(key, ms),
});
this.fadeInField = fadeField(FADE_IN_KEY);
this.fadeOutField = fadeField(FADE_OUT_KEY);
// The curve as a picture rather than its name, so the header keeps room for the readout.
const curveButton = (key: string, edge: string): Dropdown => {
const rising = key === FADE_IN_CURVE_KEY;
const button: Dropdown = new Dropdown('', {
icon: curveIcon(DEFAULT_FADE_CURVE, rising),
tooltip: () => `Fade ${edge} curve: ${curveLabel(this.shownCurve(key))}`,
onClick: () => this.openCurveMenu(key, button, rising),
});
return button;
};
this.curveInButton = curveButton(FADE_IN_CURVE_KEY, 'in');
this.curveOutButton = curveButton(FADE_OUT_CURVE_KEY, 'out');
const canvas = new WaveCanvas(state);
canvas.onTogglePlay = () => this.togglePlay();
canvas.onLoaded = () => this.refresh();
this.mount(canvas, [
this.playButton,
this.wholeButton,
new Label('Edge fade (ms)', { style: 'muted' }),
this.fadeField,
new Label('Fade in (ms)', { style: 'muted' }),
this.fadeInField,
this.curveInButton,
new Label('out', { style: 'muted' }),
this.fadeOutField,
this.curveOutButton,
]);
state.on('load', () => this.stop());
}
@@ -113,11 +165,44 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
this.canvas.invalidate();
}
private setFade(ms: number): void {
private setFade(key: string, ms: number): void {
const asset = this.asset();
const now = asset && Math.round(cutOf(resolveAssetProps(this.state.project, asset)).fade * 1000);
if (!asset || ms === now) return;
this.state.execute(new SetAssetPropertyCommand(asset.id, FADE_KEY, ms));
const cut = asset && cutOf(resolveAssetProps(this.state.project, asset));
if (!asset || !cut) return;
const now = Math.round((key === FADE_IN_KEY ? cut.fadeIn : cut.fadeOut) * 1000);
if (ms !== now) this.state.execute(new SetAssetPropertyCommand(asset.id, key, ms));
}
/** The curve a key stands for, as the sound has it now. */
private curveOf(asset: SoundAsset, key: string): FadeCurve {
const cut = cutOf(resolveAssetProps(this.state.project, asset));
return key === FADE_IN_CURVE_KEY ? cut.curveIn : cut.curveOut;
}
/** The curve a dropdown stands for on the sound shown, the default while there is none. */
shownCurve(key: string): FadeCurve {
const asset = this.asset();
return asset ? this.curveOf(asset, key) : DEFAULT_FADE_CURVE;
}
private openCurveMenu(key: string, button: Dropdown, rising: boolean): void {
const asset = this.asset();
if (!asset) return;
const now = this.curveOf(asset, key);
new Menu(
FADE_CURVES.map((curve) => ({
label: curve.label,
icon: curveIcon(curve.value, rising),
current: curve.value === now,
onSelect: () => this.setCurve(key, curve.value),
})),
).openBelow(button);
}
private setCurve(key: string, curve: FadeCurve): void {
const asset = this.asset();
if (!asset || this.curveOf(asset, key) === curve) return;
this.state.execute(new SetAssetPropertyCommand(asset.id, key, curve));
}
private syncPlayButton(): void {
@@ -139,9 +224,15 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
const ready = asset !== null && Boolean(asset.file);
this.fitButton.setEnabled(ready);
this.wholeButton.setEnabled(ready);
this.fadeField.setEnabled(ready);
const fade = asset ? cutOf(resolveAssetProps(this.state.project, asset)).fade : 0;
this.fadeField.setNumber(Math.round(fade * 1000));
this.fadeInField.setEnabled(ready);
this.fadeOutField.setEnabled(ready);
this.curveInButton.setEnabled(ready);
this.curveOutButton.setEnabled(ready);
const cut = asset ? cutOf(resolveAssetProps(this.state.project, asset)) : null;
this.fadeInField.setNumber(Math.round((cut?.fadeIn ?? 0) * 1000));
this.fadeOutField.setNumber(Math.round((cut?.fadeOut ?? 0) * 1000));
this.curveInButton.setIcon(curveIcon(cut?.curveIn ?? DEFAULT_FADE_CURVE, true));
this.curveOutButton.setIcon(curveIcon(cut?.curveOut ?? DEFAULT_FADE_CURVE, false));
// Another sound on screen: what was playing is no longer what the tab shows.
if (this.isPlaying && asset?.id !== this.playingId) this.stop();
this.syncPlayButton();
+17 -11
View File
@@ -10,7 +10,7 @@ import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import type { SoundAsset } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import { cutRange } from '../../core/model/sound';
import { cutRange, type FadeCurve, fadeGain } from '../../core/model/sound';
import { resolveAssetProps } from '../../core/properties/resolve';
import { cutOf, END_KEY, START_KEY } from '../../core/properties/sounds';
import { crispText, type Size, textStyle, Theme, ui, Widget, zoomFactor } from '../../ui';
@@ -44,6 +44,8 @@ const LABEL_SPACING = 70;
const LEAST_SPAN = 0.001;
/** Pixels a press has to travel before it spans a new stretch. */
const DRAG_START = 3;
/** Line pieces a fade's curve is drawn with at most. */
const RAMP_STEPS = 32;
type Gesture =
| { kind: 'pan'; pointerX: number; panX: number }
@@ -411,20 +413,24 @@ export class WaveCanvas extends Widget {
if (a > 0) this.overlay.rect(0, RULER_HEIGHT, a, h - RULER_HEIGHT).fill(shade);
if (b < this.w) this.overlay.rect(b, RULER_HEIGHT, this.w - b, h - RULER_HEIGHT).fill(shade);
this.overlay.rect(a, 0, Math.max(1, b - a), 4).fill({ color: c.accent, alpha: 0.6 });
const fade = cutOf(resolveAssetProps(this.state.project, asset)).fade;
const range = cutRange(wave.channels[0]!.length, wave.rate, { ...span, fade });
const top = mid - half;
const ramp = (from: number, to: number, rising: boolean) => {
const cut = cutOf(resolveAssetProps(this.state.project, asset));
const range = cutRange(wave.channels[0]!.length, wave.rate, { ...cut, ...span });
const ramp = (from: number, to: number, rising: boolean, curve: FadeCurve) => {
const xa = secondsToX(this.view, from);
const xb = secondsToX(this.view, to);
if (xb - xa < 2) return;
this.overlay
.moveTo(xa, rising ? mid + half : top)
.lineTo(xb, rising ? top : mid + half)
.stroke({ width: 1, color: c.hot, alpha: 0.7 });
// The gain along the ramp, silence at the bottom of the wave and full at its top.
const steps = Math.max(1, Math.min(RAMP_STEPS, Math.round(xb - xa)));
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const y = mid + half - 2 * half * fadeGain(curve, rising ? t : 1 - t);
if (i === 0) this.overlay.moveTo(xa, y);
else this.overlay.lineTo(xa + t * (xb - xa), y);
}
this.overlay.stroke({ width: 1, color: c.hot, alpha: 0.7 });
};
if (range.fadeIn > 0) ramp(span.start, span.start + range.fadeIn / wave.rate, true);
if (range.fadeOut > 0) ramp(span.end - range.fadeOut / wave.rate, span.end, false);
if (range.fadeIn > 0) ramp(span.start, span.start + range.fadeIn / wave.rate, true, range.curveIn);
if (range.fadeOut > 0) ramp(span.end - range.fadeOut / wave.rate, span.end, false, range.curveOut);
for (const [x, edge] of [
[a, 'start'],
[b, 'end'],
+10 -1
View File
@@ -1325,7 +1325,16 @@ export class SoundLibrary implements AudioSink {
const { file, cut } = parseCutName(name);
const whole = this.buffers.get(file);
if (!whole) return null;
if (!cut) return { whole, from: 0, to: whole.length, fadeIn: 0, fadeOut: 0 };
if (!cut)
return {
whole,
from: 0,
to: whole.length,
fadeIn: 0,
fadeOut: 0,
curveIn: 'linear',
curveOut: 'linear',
};
return { whole, ...cutRange(whole.length, whole.sampleRate, cut) };
}
+92 -20
View File
@@ -15,6 +15,8 @@ import {
cutChannels,
cutName,
cutRange,
FADE_CURVES,
fadeGain,
isSoundFile,
parseCutName,
soundFileOf,
@@ -25,7 +27,10 @@ import { playedSoundById, playedSoundOf } from '../../src/core/properties/played
import {
cutOf,
END_KEY,
FADE_KEY,
FADE_IN_CURVE_KEY,
FADE_OUT_CURVE_KEY,
FADE_IN_KEY,
FADE_OUT_KEY,
LOOP_KEY,
loopsOf,
START_KEY,
@@ -121,9 +126,19 @@ describe('the sound asset', () => {
LOOP_KEY,
START_KEY,
END_KEY,
FADE_KEY,
FADE_IN_KEY,
FADE_IN_CURVE_KEY,
FADE_OUT_KEY,
FADE_OUT_CURVE_KEY,
]);
expect(cutOf(asset.props)).toEqual({ start: 0, end: 0, fade: 0.01 });
expect(cutOf(asset.props)).toEqual({
start: 0,
end: 0,
fadeIn: 0.005,
fadeOut: 0.005,
curveIn: 'linear',
curveOut: 'linear',
});
});
it('refuses a file name the server could not have produced', () => {
@@ -168,51 +183,108 @@ describe('the sound asset', () => {
describe('the cut of a sound (#343)', () => {
it('is no cut at all over the whole file, and a name of its own otherwise', () => {
const whole = { start: 0, end: 0, fade: 0.01 };
const linear = { curveIn: 'linear', curveOut: 'linear' } as const;
const whole = { start: 0, end: 0, fadeIn: 0.005, fadeOut: 0.005, ...linear };
expect(cutName(FILE, whole)).toBe(FILE);
expect(cutName('', { ...whole, start: 1 })).toBe('');
const name = cutName(FILE, { start: 0.25, end: 1.5, fade: 0.01 });
expect(name).toBe(`${FILE}#0.25~1.5~0.01`);
expect(parseCutName(name)).toEqual({ file: FILE, cut: { start: 0.25, end: 1.5, fade: 0.01 } });
const cut = {
start: 0.25,
end: 1.5,
fadeIn: 0.005,
fadeOut: 0.2,
curveIn: 'sCurve',
curveOut: 'equalPower',
} as const;
const name = cutName(FILE, cut);
expect(name).toBe(`${FILE}#0.25~1.5~0.005~0.2~sCurve~equalPower`);
expect(parseCutName(name)).toEqual({ file: FILE, cut });
expect(parseCutName(FILE)).toEqual({ file: FILE, cut: null });
expect(parseCutName(`${FILE}#x~-2`)).toEqual({ file: FILE, cut: { start: 0, end: 0, fade: 0 } });
expect(parseCutName(`${FILE}#x~-2`)).toEqual({
file: FILE,
cut: { start: 0, end: 0, fadeIn: 0, fadeOut: 0, ...linear },
});
});
it('keeps at least a sample inside the file, and ramps only the edges inside it', () => {
it('keeps at least a sample inside the file, and ramps only the edges inside it, each by its own fade', () => {
// 10 samples at 10 Hz: one sample per tenth of a second.
expect(cutRange(10, 10, { start: 0.2, end: 0.8, fade: 0.2 })).toEqual({
const fades = (fadeIn: number, fadeOut = fadeIn) => ({
fadeIn,
fadeOut,
curveIn: 'linear' as const,
curveOut: 'linear' as const,
});
expect(cutRange(10, 10, { start: 0.2, end: 0.8, ...fades(0.2) })).toEqual({
from: 2,
to: 8,
fadeIn: 2,
fadeOut: 2,
curveIn: 'linear',
curveOut: 'linear',
});
expect(cutRange(10, 10, { start: 0, end: 0.5, fade: 0.2 })).toMatchObject({ fadeIn: 0, fadeOut: 2 });
expect(cutRange(10, 10, { start: 0.5, end: 0, fade: 0.2 })).toMatchObject({
expect(cutRange(10, 10, { start: 0.1, end: 0.9, ...fades(0.1, 0.3) })).toMatchObject({
fadeIn: 1,
fadeOut: 3,
});
expect(cutRange(10, 10, { start: 0, end: 0.5, ...fades(0.2) })).toMatchObject({ fadeIn: 0, fadeOut: 2 });
expect(cutRange(10, 10, { start: 0.5, end: 0, ...fades(0.2) })).toMatchObject({
to: 10,
fadeIn: 2,
fadeOut: 0,
});
// Past the end, an end before the start, and a fade longer than half of what is kept.
expect(cutRange(10, 10, { start: 5, end: 0, fade: 0 })).toMatchObject({ from: 9, to: 10 });
expect(cutRange(10, 10, { start: 0.6, end: 0.3, fade: 0 })).toMatchObject({ from: 6, to: 7 });
expect(cutRange(10, 10, { start: 0.2, end: 0.6, fade: 1 })).toMatchObject({ fadeIn: 2, fadeOut: 2 });
// Past the end, an end before the start, and fades longer than half of what is kept.
expect(cutRange(10, 10, { start: 5, end: 0, ...fades(0) })).toMatchObject({ from: 9, to: 10 });
expect(cutRange(10, 10, { start: 0.6, end: 0.3, ...fades(0) })).toMatchObject({ from: 6, to: 7 });
expect(cutRange(10, 10, { start: 0.2, end: 0.6, ...fades(1, 0.1) })).toMatchObject({
fadeIn: 2,
fadeOut: 1,
});
});
it('cuts every channel alike, rising from silence and falling to it at an inner edge', () => {
const left = new Float32Array(10).fill(1);
const right = new Float32Array(10).fill(-0.5);
const [l, r] = cutChannels([left, right], { from: 2, to: 8, fadeIn: 2, fadeOut: 2 });
const [l, r] = cutChannels([left, right], {
from: 2,
to: 8,
fadeIn: 2,
fadeOut: 2,
curveIn: 'linear',
curveOut: 'linear',
});
expect(Array.from(l!)).toEqual([0, 0.5, 1, 1, 0.5, 0]);
expect(Array.from(r!)).toEqual([-0, -0.25, -0.5, -0.5, -0.25, -0]);
expect(Array.from(left)).toEqual(new Array(10).fill(1));
});
it('is what every way of playing the asset is given, its fade in milliseconds on the asset', () => {
it('shapes each ramp by its own curve, the end running it backwards (#345)', () => {
for (const { value } of FADE_CURVES) {
expect(fadeGain(value, 0)).toBeCloseTo(0, 12);
expect(fadeGain(value, 1)).toBeCloseTo(1, 12);
}
expect(fadeGain('linear', 0.5)).toBe(0.5);
expect(fadeGain('equalPower', 0.5)).toBeCloseTo(Math.SQRT1_2, 12);
expect(fadeGain('sCurve', 0.25)).toBeCloseTo(1 - fadeGain('sCurve', 0.75), 12);
expect(fadeGain('exponential', 0.5)).toBe(0.25);
expect(fadeGain('logarithmic', 0.5)).toBe(0.75);
const [kept] = cutChannels([new Float32Array(12).fill(1)], {
from: 0,
to: 12,
fadeIn: 4,
fadeOut: 4,
curveIn: 'exponential',
curveOut: 'logarithmic',
});
expect(Array.from(kept!)).toEqual([0, 0.0625, 0.25, 0.5625, 1, 1, 1, 1, 0.9375, 0.75, 0.4375, 0]);
});
it('is what every way of playing the asset is given, its fades in milliseconds on the asset', () => {
const f = playingScene();
expect(playedSoundOf(f.project, f.sound)).toBe(FILE);
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, START_KEY, 0.5));
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_KEY, 25));
const name = `${FILE}#0.5~0~0.025`;
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_IN_KEY, 25));
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_OUT_KEY, 40));
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_OUT_CURVE_KEY, 'equalPower'));
const name = `${FILE}#0.5~0~0.025~0.04~linear~equalPower`;
expect(playedSoundOf(f.project, f.sound)).toBe(name);
expect(playedSoundById(f.project, f.sound.id)).toBe(name);
expect(playedSoundById(f.project, '')).toBe('');
+1 -1
View File
@@ -413,7 +413,7 @@ describe('the sound library', () => {
it('plays the stretch a sound is cut to, made from its file without fetching it again (#343)', async () => {
const library = new SoundLibrary();
// The file is 1200 samples at 800 Hz: 1.5 s. The cut keeps 0.5 s to 1 s.
const cut = `${FILE}#0.5~1~0.01`;
const cut = `${FILE}#0.5~1~0.005~0.005~linear~linear`;
await play(library, cut);
expect(library.stats()).toMatchObject({ started: 1, decoded: 1, playing: 1 });
expect((sources[0]?.buffer as { length: number }).length).toBe(400);
+51 -6
View File
@@ -1513,26 +1513,71 @@ export async function sound(t) {
'a drag beside the handles spans a new stretch, played by a name of its own, cut to the sample',
Math.abs(spanned?.start - 0.1) < 0.01 &&
Math.abs(spanned?.end - 0.3) < 0.01 &&
cutPlayed === `${drone.file}#${spanned?.start}~${spanned?.end}~0.01` &&
cutPlayed === `${drone.file}#${spanned?.start}~${spanned?.end}~0.005~0.005~linear~linear` &&
cutSamples?.length === keptSamples,
JSON.stringify({ spanned, cutPlayed, cutSamples, keptSamples }),
);
check(
'the cut rises from silence and falls back to it, the default 10 ms at each edge inside the file',
'the cut rises from silence and falls back to it, the default 5 ms at each edge inside the file',
cutSamples?.head[0] === 0 && Math.abs(cutSamples?.tail.at(-1)) < 0.01,
JSON.stringify(cutSamples),
);
const tabFade = await json('__gm.soundTab.fadeField()');
await click(tabFade.x, tabFade.y);
// 34h-ii. Each edge has a fade of its own, each shaped by its own curve (#345).
const tabFadeOut = await json('__gm.soundTab.fadeOutField()');
await click(tabFadeOut.x, tabFadeOut.y);
await t.typeText('40');
await press('Enter');
await sleep(150);
const curveOutButton = await json('__gm.soundTab.curveOutButton()');
await click(curveOutButton.x, curveOutButton.y);
await clickText('Exponential');
await sleep(150);
const curved = await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`);
const curvedSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(curved)})`);
check(
'the header sets the fade out and its curve on their own, the fade in staying Linear',
(await droneProps())?.fadeOut === 40 &&
(await droneProps())?.fadeOutCurve === 'exponential' &&
((await droneProps())?.fadeInCurve ?? 'linear') === 'linear' &&
(await json('__gm.soundTab.curveOutButton()')).tooltip === 'Fade out curve: Exponential' &&
(await json('__gm.soundTab.curveInButton()')).tooltip === 'Fade in curve: Linear' &&
curved.endsWith('~0.005~0.04~linear~exponential'),
JSON.stringify({ props: await droneProps(), curved }),
);
await press('z', { ctrl: true });
await press('z', { ctrl: true });
const tabFadeIn = await json('__gm.soundTab.fadeInField()');
await click(tabFadeIn.x, tabFadeIn.y);
await t.typeText('0');
await press('Enter');
const fadeOutField = await json('__gm.soundTab.fadeOutField()');
await click(fadeOutField.x, fadeOutField.y);
await t.typeText('0');
await press('Enter');
await sleep(150);
const unfaded = await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`);
check(
'the header sets the fade at the edges, in milliseconds on the asset',
(await droneProps())?.fade === 0 && unfaded.endsWith('~0'),
'after two undos the curve is Linear again, and both fades go to 0 in the header',
(await droneProps())?.fadeIn === 0 &&
(await droneProps())?.fadeOut === 0 &&
((await droneProps())?.fadeOutCurve ?? 'linear') === 'linear' &&
unfaded.endsWith('~0~0~linear~linear'),
JSON.stringify({ props: await droneProps(), unfaded }),
);
// The same stretch unfaded is the drone itself: against it, the i-th sample before the end of the
// exponential fall is (i / ramp)² of it, where a straight line would leave i / ramp.
const rawSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(unfaded)})`);
const outRamp = Math.round(0.04 * (rawSamples?.rate ?? 0));
const fall = [1, 2, 3]
.map((i) => ({ i, raw: rawSamples?.tail.at(-1 - i), curved: curvedSamples?.tail.at(-1 - i) }))
.filter((s) => Math.abs(s.raw) > 1e-3);
check(
'the end falls along the curve: (i / ramp)² of the drone on an exponential one',
curvedSamples?.tail.at(-1) === 0 &&
fall.length > 0 &&
fall.every((s) => Math.abs(s.curved / s.raw / (s.i / outRamp) ** 2 - 1) < 1e-3),
JSON.stringify({ fall, outRamp }),
);
await evaluate('__gm.sound.stopAll()');
const tabPlay = await json('__gm.soundTab.playButton()');
await click(tabPlay.x, tabPlay.y);