diff --git a/src/core/model/sound.ts b/src/core/model/sound.ts index dead5cf7..01b5f5eb 100644 --- a/src/core/model/sound.ts +++ b/src/core/model/sound.ts @@ -49,7 +49,8 @@ export function fadeGain(curve: FadeCurve, t: number): number { /** * The stretch of its file a sound asset is (#343), in seconds: `end` 0 runs to the end of the file. * `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). + * so a cut through a wave does not click, shaped by `curveIn` and `curveOut` (#345). `reverse` plays + * the same stretch backwards (#321): what is heard first is its end, and the fade in ramps there. */ export interface SoundCut { start: number; @@ -58,9 +59,13 @@ export interface SoundCut { fadeOut: number; curveIn: FadeCurve; curveOut: FadeCurve; + reverse: boolean; } -/** Samples of a file a cut keeps, `from` up to `to`, the samples of each ramp at an inner edge and their shape. */ +/** + * Samples of a file a cut keeps, `from` up to `to`, the samples of each ramp at the edge it is heard + * at and their shape, and whether they run backwards. + */ export interface CutRange { from: number; to: number; @@ -68,15 +73,20 @@ export interface CutRange { fadeOut: number; curveIn: FadeCurve; curveOut: FadeCurve; + reverse: boolean; } +const REVERSED = 'reverse'; + /** * What a sound is played by: its file, or for a cut - * `#~~~~~`, a name of its own. + * `#~~~~~`, a name of its own, with `~reverse` + * after it when it runs backwards. */ 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.fadeIn}~${cut.fadeOut}~${cut.curveIn}~${cut.curveOut}`; + if (file === '' || (cut.start <= 0 && cut.end <= 0 && !cut.reverse)) return file; + const name = `${file}#${cut.start}~${cut.end}~${cut.fadeIn}~${cut.fadeOut}~${cut.curveIn}~${cut.curveOut}`; + return cut.reverse ? `${name}~${REVERSED}` : name; } /** A name `cutName` made, taken apart again; a plain file has no cut. */ @@ -96,6 +106,7 @@ export function parseCutName(name: string): { file: string; cut: SoundCut | null fadeOut: held(fadeOut), curveIn: curve(parts[4]), curveOut: curve(parts[5]), + reverse: parts[6] === REVERSED, }, }; } @@ -103,7 +114,8 @@ export function parseCutName(name: string): { file: string; cut: SoundCut | null /** * 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 its fade, no more than - * half of what is kept so the two ramps never cross. + * half of what is kept so the two ramps never cross. Played backwards, the fade in belongs to the + * end of the stretch and the fade out to its start. */ export function cutRange(length: number, rate: number, cut: SoundCut): CutRange { const last = Math.max(1, length); @@ -112,23 +124,26 @@ export function cutRange(length: number, rate: number, cut: SoundCut): CutRange const to = Math.max(from + 1, asked); const ramp = (seconds: number) => Math.min(Math.round(Math.max(0, seconds) * rate), Math.floor((to - from) / 2)); + const [heardFirst, heardLast] = cut.reverse ? [to < last, from > 0] : [from > 0, to < last]; return { from, to, - fadeIn: from > 0 ? ramp(cut.fadeIn) : 0, - fadeOut: to < last ? ramp(cut.fadeOut) : 0, + fadeIn: heardFirst ? ramp(cut.fadeIn) : 0, + fadeOut: heardLast ? ramp(cut.fadeOut) : 0, curveIn: cut.curveIn, curveOut: cut.curveOut, + reverse: cut.reverse, }; } -/** The samples of each channel a cut keeps, its inner edges ramped in and out, each along its curve. */ +/** The samples of each channel a cut keeps in the order heard, ramped in and out along their curves. */ export function cutChannels(channels: readonly Float32Array[], range: CutRange): Float32Array[] { 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); + if (range.reverse) kept.reverse(); 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 b3332a24..74b415d0 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -113,6 +113,7 @@ import { FADE_OUT_CURVE_KEY as SOUND_FADE_OUT_CURVE_KEY, FADE_OUT_KEY as SOUND_FADE_OUT_KEY, LOOP_KEY as SOUND_LOOP_KEY, + REVERSE_KEY as SOUND_REVERSE_KEY, START_KEY as SOUND_START_KEY, VOLUME_KEY as SOUND_VOLUME_KEY, } from './sounds'; @@ -691,6 +692,8 @@ 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 }, + // That stretch backwards (#321), its fade in at the end it starts from. + { key: SOUND_REVERSE_KEY, label: 'Reverse', kind: 'boolean', default: false }, // The ramps at a start and an end inside the file, each with its shape (#345). ...( [ diff --git a/src/core/properties/sounds.ts b/src/core/properties/sounds.ts index 2f77e95a..6d8f781a 100644 --- a/src/core/properties/sounds.ts +++ b/src/core/properties/sounds.ts @@ -17,6 +17,8 @@ export const DEFAULT_FADE_MS = 5; export const FADE_IN_CURVE_KEY = 'fadeInCurve'; export const FADE_OUT_CURVE_KEY = 'fadeOutCurve'; export const DEFAULT_FADE_CURVE: FadeCurve = 'linear'; +/** Whether the stretch plays backwards (#321). */ +export const REVERSE_KEY = 'reverse'; export function volumeOf(bag: PropertyBag): number { return coerceAs(bag[VOLUME_KEY], 'number', 1); @@ -40,5 +42,6 @@ export function cutOf(bag: PropertyBag): SoundCut { fadeOut: seconds(FADE_OUT_KEY, DEFAULT_FADE_MS) / 1000, curveIn: curve(FADE_IN_CURVE_KEY), curveOut: curve(FADE_OUT_CURVE_KEY), + reverse: booleanOf(bag[REVERSE_KEY]), }; } diff --git a/src/editor/debug/soundHooks.ts b/src/editor/debug/soundHooks.ts index ec8ac387..4ab3e960 100644 --- a/src/editor/debug/soundHooks.ts +++ b/src/editor/debug/soundHooks.ts @@ -21,6 +21,10 @@ 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 }), + reverseButton: () => ({ + ...centreOf(soundEditor.reverseButton), + on: soundEditor.reverseButton.toggled, + }), 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. */ diff --git a/src/editor/panels/headerIcons.ts b/src/editor/panels/headerIcons.ts index 3eb939d8..65aeb9ea 100644 --- a/src/editor/panels/headerIcons.ts +++ b/src/editor/panels/headerIcons.ts @@ -1,4 +1,5 @@ /** Icons of the tab headers' switches (#194): a picture instead of a caption. */ import magnet from './icons/magnet.svg?raw'; +import reverse from './icons/reverse.svg?raw'; -export const HEADER_ICON_SVG = { snap: magnet }; +export const HEADER_ICON_SVG = { snap: magnet, reverse }; diff --git a/src/editor/panels/icons/reverse.svg b/src/editor/panels/icons/reverse.svg new file mode 100644 index 00000000..af9031cb --- /dev/null +++ b/src/editor/panels/icons/reverse.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/editor/sound/SoundEditor.ts b/src/editor/sound/SoundEditor.ts index d30cc957..60bd9e28 100644 --- a/src/editor/sound/SoundEditor.ts +++ b/src/editor/sound/SoundEditor.ts @@ -10,18 +10,20 @@ import { FADE_IN_KEY, FADE_OUT_CURVE_KEY, FADE_OUT_KEY, + REVERSE_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'; +import { HEADER_ICON_SVG } from '../panels/headerIcons'; import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons'; import type { EditorState } from '../state'; import { WaveCanvas } from './WaveCanvas'; const INFO_WIDTH = 140; -const FADE_WIDTH = 56; +const FADE_WIDTH = 48; const PREVIEW_OWNER = 'sound-preview'; /** Points along the curve in its icon. */ const ICON_STEPS = 12; @@ -47,13 +49,14 @@ function curveLabel(curve: FadeCurve | undefined): string { /** * 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 each edge and its curve, and puts the - * whole file back. + * The header plays it (Space does too), turns it backwards, 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 reverseButton: Button; readonly fadeInField: NumberField; readonly fadeOutField: NumberField; readonly curveInButton: Dropdown; @@ -75,6 +78,12 @@ 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.reverseButton = new Button('', { + icon: HEADER_ICON_SVG.reverse, + toggle: true, + tooltip: 'Reverse: play the stretch backwards, from its end to its start', + onClick: () => this.toggleReverse(), + }); const fadeField = (key: string) => new NumberField(0, { width: FADE_WIDTH, @@ -104,7 +113,9 @@ export class SoundEditor extends AssetTabEditor { this.mount(canvas, [ this.playButton, this.wholeButton, - new Label('Fade in (ms)', { style: 'muted' }), + this.reverseButton, + // In milliseconds, as the inspector says; the header has no room for the unit. + new Label('Fade in', { style: 'muted' }), this.fadeInField, this.curveInButton, new Label('out', { style: 'muted' }), @@ -161,10 +172,18 @@ export class SoundEditor extends AssetTabEditor { const span = this.canvas.shownSpan(); if (started === null || !span) return; const heard = soundLibrary.clock() - started - soundLibrary.latency(); - this.canvas.playhead = span.start + Math.max(0, Math.min(span.end - span.start, heard)); + const into = Math.max(0, Math.min(span.end - span.start, heard)); + this.canvas.playhead = this.reverseButton.toggled ? span.end - into : span.start + into; this.canvas.invalidate(); } + private toggleReverse(): void { + const asset = this.asset(); + if (!asset) return; + const reverse = cutOf(resolveAssetProps(this.state.project, asset)).reverse; + this.state.execute(new SetAssetPropertyCommand(asset.id, REVERSE_KEY, !reverse)); + } + private setFade(key: string, ms: number): void { const asset = this.asset(); const cut = asset && cutOf(resolveAssetProps(this.state.project, asset)); @@ -224,11 +243,13 @@ export class SoundEditor extends AssetTabEditor { const ready = asset !== null && Boolean(asset.file); this.fitButton.setEnabled(ready); this.wholeButton.setEnabled(ready); + this.reverseButton.setEnabled(ready); 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.reverseButton.setToggled(cut?.reverse ?? false); 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)); diff --git a/src/editor/sound/WaveCanvas.ts b/src/editor/sound/WaveCanvas.ts index 79d4a6f6..34799dac 100644 --- a/src/editor/sound/WaveCanvas.ts +++ b/src/editor/sound/WaveCanvas.ts @@ -429,8 +429,13 @@ export class WaveCanvas extends Widget { } 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, range.curveIn); - if (range.fadeOut > 0) ramp(span.end - range.fadeOut / wave.rate, span.end, false, range.curveOut); + // Played backwards, the stretch is heard from its end: the fade in ramps there. + const [first, last] = range.reverse ? [range.fadeOut, range.fadeIn] : [range.fadeIn, range.fadeOut]; + const [firstCurve, lastCurve] = range.reverse + ? [range.curveOut, range.curveIn] + : [range.curveIn, range.curveOut]; + if (first > 0) ramp(span.start, span.start + first / wave.rate, true, firstCurve); + if (last > 0) ramp(span.end - last / wave.rate, span.end, false, lastCurve); for (const [x, edge] of [ [a, 'start'], [b, 'end'], diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index e853b127..ce9e8eb4 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -1078,12 +1078,13 @@ export class SoundLibrary implements AudioSink { if (known) return known; const range = this.rangeOf(file); if (!range) return null; - const { whole, from, to } = range; + const { whole, from, to, reverse } = range; const peaks = new Float32Array(PEAK_BUCKETS); for (let c = 0; c < whole.numberOfChannels; c++) { const data = whole.getChannelData(c); for (let i = from; i < to; i++) { - const bucket = Math.floor(((i - from) / (to - from)) * PEAK_BUCKETS); + const heard = reverse ? to - 1 - i : i - from; + const bucket = Math.floor((heard / (to - from)) * PEAK_BUCKETS); peaks[bucket] = Math.max(peaks[bucket]!, Math.abs(data[i]!)); } } @@ -1623,6 +1624,7 @@ export class SoundLibrary implements AudioSink { fadeOut: 0, curveIn: 'linear', curveOut: 'linear', + reverse: false, }; return { whole, ...cutRange(whole.length, whole.sampleRate, cut) }; } diff --git a/tests/core/sounds.test.ts b/tests/core/sounds.test.ts index 3f7fe46d..89e7b1ef 100644 --- a/tests/core/sounds.test.ts +++ b/tests/core/sounds.test.ts @@ -33,6 +33,7 @@ import { FADE_OUT_KEY, LOOP_KEY, loopsOf, + REVERSE_KEY, START_KEY, VOLUME_KEY, volumeOf, @@ -126,6 +127,7 @@ describe('the sound asset', () => { LOOP_KEY, START_KEY, END_KEY, + REVERSE_KEY, FADE_IN_KEY, FADE_IN_CURVE_KEY, FADE_OUT_KEY, @@ -138,6 +140,7 @@ describe('the sound asset', () => { fadeOut: 0.005, curveIn: 'linear', curveOut: 'linear', + reverse: false, }); }); @@ -183,7 +186,7 @@ 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 linear = { curveIn: 'linear', curveOut: 'linear' } as const; + const linear = { curveIn: 'linear', curveOut: 'linear', reverse: false } 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(''); @@ -194,6 +197,7 @@ describe('the cut of a sound (#343)', () => { fadeOut: 0.2, curveIn: 'sCurve', curveOut: 'equalPower', + reverse: false, } as const; const name = cutName(FILE, cut); expect(name).toBe(`${FILE}#0.25~1.5~0.005~0.2~sCurve~equalPower`); @@ -212,6 +216,7 @@ describe('the cut of a sound (#343)', () => { fadeOut, curveIn: 'linear' as const, curveOut: 'linear' as const, + reverse: false, }); expect(cutRange(10, 10, { start: 0.2, end: 0.8, ...fades(0.2) })).toEqual({ from: 2, @@ -220,6 +225,7 @@ describe('the cut of a sound (#343)', () => { fadeOut: 2, curveIn: 'linear', curveOut: 'linear', + reverse: false, }); expect(cutRange(10, 10, { start: 0.1, end: 0.9, ...fades(0.1, 0.3) })).toMatchObject({ fadeIn: 1, @@ -250,6 +256,7 @@ describe('the cut of a sound (#343)', () => { fadeOut: 2, curveIn: 'linear', curveOut: 'linear', + reverse: false, }); 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]); @@ -273,6 +280,7 @@ describe('the cut of a sound (#343)', () => { fadeOut: 4, curveIn: 'exponential', curveOut: 'logarithmic', + reverse: false, }); expect(Array.from(kept!)).toEqual([0, 0.0625, 0.25, 0.5625, 1, 1, 1, 1, 0.9375, 0.75, 0.4375, 0]); }); @@ -292,6 +300,38 @@ describe('the cut of a sound (#343)', () => { startWorld(f, { behaviors: true, audio }).frame(); expect(audio.played).toMatchObject([{ file: name }]); }); + + it('plays backwards from its end, the fade in ramping there (#321)', () => { + const fades = { fadeIn: 0.2, fadeOut: 0.2, curveIn: 'linear', curveOut: 'linear' } as const; + // Kept up to the middle of a 10-sample file: only the end lies inside, and it is heard first. + expect(cutRange(10, 10, { start: 0, end: 0.5, ...fades, reverse: true })).toMatchObject({ + from: 0, + to: 5, + fadeIn: 2, + fadeOut: 0, + reverse: true, + }); + const ramp = Float32Array.from({ length: 10 }, (_, i) => i); + const [kept] = cutChannels([ramp], { + from: 2, + to: 8, + fadeIn: 2, + fadeOut: 1, + curveIn: 'linear', + curveOut: 'linear', + reverse: true, + }); + expect(Array.from(kept!)).toEqual([0, 3, 5, 4, 3, 0]); + expect(Array.from(ramp)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + + const f = playingScene(); + f.stack.execute(new SetAssetPropertyCommand(f.sound.id, REVERSE_KEY, true)); + const name = `${FILE}#0~0~0.005~0.005~linear~linear~reverse`; + expect(playedSoundOf(f.project, f.sound)).toBe(name); + expect(parseCutName(name).cut).toMatchObject({ start: 0, end: 0, reverse: true }); + f.stack.execute(new SetAssetPropertyCommand(f.sound.id, REVERSE_KEY, false)); + expect(playedSoundOf(f.project, f.sound)).toBe(FILE); + }); }); describe('Play sound', () => { diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index f8e079d7..d893e4b6 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -1655,6 +1655,26 @@ describe('clips and tracks of a song (#334)', () => { expect(peaks).toHaveLength(1024); expect(library.peaks(FILE)).toBe(peaks); }); + + it('plays and draws a reversed sound from its end (#321)', async () => { + const library = new SoundLibrary(); + const reversed = `${FILE}#0~0~0~0~linear~linear~reverse`; + void library.load(FILE); + await settle(); + await arrive(); + // The file rises from silence to full over its 1200 samples. + library + .decoded(FILE)! + .getChannelData(0) + .forEach((_, i, data) => (data[i] = i / 1199)); + await play(library, reversed); + const heard = (sources[0]?.buffer as { getChannelData: () => Float32Array }).getChannelData(); + expect([heard[0], heard[1199]]).toEqual([1, 0]); + expect(library.duration(reversed)).toBe(1.5); + const peaks = library.peaks(reversed)!; + expect(peaks[0]).toBeCloseTo(1, 2); + expect(peaks[1023]).toBeLessThan(0.01); + }); }); describe('the patterns and songs a game plays (#336)', () => { diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 96138b08..d8e9f5bc 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -1682,6 +1682,46 @@ export async function sound(t) { (await json('__gm.soundTab.playhead()')) === null, JSON.stringify({ last: tabHeard.last, tabPlayhead, tabEnded }), ); + // 34h-iii. Reverse plays the same stretch backwards, the playhead running from its end (#321). + const reverseButton = await json('__gm.soundTab.reverseButton()'); + await click(reverseButton.x, reverseButton.y); + await sleep(150); + const reversed = await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`); + const reversedSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(reversed)})`); + check( + 'Reverse turns the stretch around: its first samples are the last of the stretch played forwards', + (await droneProps())?.reverse === true && + (await json('__gm.soundTab.reverseButton()')).on === true && + reversed === `${unfaded}~reverse` && + reversedSamples?.length === rawSamples?.length && + reversedSamples?.head.every((v, i) => v === rawSamples?.tail.at(-1 - i)), + JSON.stringify({ reversed, head: reversedSamples?.head, tail: rawSamples?.tail }), + ); + // The whole file (0.75 s) gives the playhead time for several readings. + const tabWhole = await json('__gm.soundTab.wholeButton()'); + await click(tabWhole.x, tabWhole.y); + await sleep(150); + await click(tabPlay.x, tabPlay.y); + await waitFor( + () => json('__gm.soundTab.playhead()'), + (seconds) => seconds !== null, + 10, + ); + const backwards = ( + await trace( + () => json('__gm.soundTab.playhead()'), + (seconds) => seconds === null, + ) + ).filter((seconds) => seconds !== null); + check( + 'played backwards, the playhead runs from the end of the file towards its start', + (await droneProps())?.reverse === true && + backwards.length >= 2 && + backwards.every((seconds) => seconds >= 0 && seconds <= 0.75) && + backwards.every((seconds, i) => i === 0 || seconds <= backwards[i - 1]) && + backwards.at(-1) < backwards[0], + JSON.stringify(backwards), + ); await shot('34-sound-tab'); // 34i. The step takes back everything it made, so the steps after it see the fixture again.