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
+421 -96

No files matched your search

+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');
+112 -21
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, {
width: FADE_WIDTH,
min: 0,
max: 1000,
step: 1,
decimals: 0,
onChange: (ms) => this.setFade(ms),
});
const fadeField = (key: string) =>
new NumberField(0, {
width: FADE_WIDTH,
min: 0,
max: 1000,
step: 1,
decimals: 0,
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'],