diff --git a/src/core/model/sound.ts b/src/core/model/sound.ts index 4a54c13d..dead5cf7 100644 --- a/src/core/model/sound.ts +++ b/src/core/model/sound.ts @@ -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 `#~~`, a name of its own. */ +/** + * What a sound is played by: its file, or for a cut + * `#~~~~~`, 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[] { - 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; }); } diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index c123a593..b3332a24 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = { // 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: [], diff --git a/src/core/properties/sounds.ts b/src/core/properties/sounds.ts index c908da15..2f77e95a 100644 --- a/src/core/properties/sounds.ts +++ b/src/core/properties/sounds.ts @@ -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), }; } diff --git a/src/editor/debug/soundHooks.ts b/src/editor/debug/soundHooks.ts index e6f68c7b..ec8ac387 100644 --- a/src/editor/debug/soundHooks.ts +++ b/src/editor/debug/soundHooks.ts @@ -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'); diff --git a/src/editor/sound/SoundEditor.ts b/src/editor/sound/SoundEditor.ts index d85ce71a..d30cc957 100644 --- a/src/editor/sound/SoundEditor.ts +++ b/src/editor/sound/SoundEditor.ts @@ -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 ( + '' + + `` + + '' + ); +} + +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 { /** 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 { 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 { 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 { 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(); diff --git a/src/editor/sound/WaveCanvas.ts b/src/editor/sound/WaveCanvas.ts index b2aeec2c..79d4a6f6 100644 --- a/src/editor/sound/WaveCanvas.ts +++ b/src/editor/sound/WaveCanvas.ts @@ -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'], diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index a51e4f2a..95b54d12 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -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) }; } diff --git a/tests/core/sounds.test.ts b/tests/core/sounds.test.ts index 91dad7c2..3f7fe46d 100644 --- a/tests/core/sounds.test.ts +++ b/tests/core/sounds.test.ts @@ -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(''); diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index 00dbd9b0..4394bbdc 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -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); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 5e7605ee..875cd483 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -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);