From 101b12504ebc8a01cfbb4edc1fc2dfa0ec2b2bd1 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 7 Oct 2026 16:08:37 +0200 Subject: [PATCH] Play a sound backwards with a Reverse switch on the asset (#321) A sound asset can now play its stretch backwards. The switch sits on the asset, so every way the sound is heard plays it reversed: Play sound, a song item, an instrument's Sample and a Pitch in a sound FX. There is no Reverse node in the sound FX graph. The user dropped it, because playing backwards needs the whole sound in advance, which a bus or a Chip does not have. - The cut gets a `reverse` flag, stored as the asset property `reverse` (default off). The cut name ends in `~reverse` when it is on, so a reversed sound over the whole file is a cut of its own. - The handles keep marking the same part of the file. The reversed copy is made once, like any cut, and the fades follow what is heard: Fade in ramps at the end handle, Fade out at the start handle. - The Sound tab has a toggle with a left-pointing play triangle. Its playhead runs from right to left, and the wave draws the ramps at the edges they now belong to. The inspector has a Reverse row. - The icon is a tab header icon (`HEADER_ICON_SVG`, next to the snap magnet), not a top bar icon. The first gate run had it in the top bar set, and the test that fixes that set went red. - A song item draws the waveform of a reversed sound backwards. Lengths stay as they are. - The header label reads "Fade in" instead of "Fade in (ms)" and the fade fields are 8 px narrower. With the new button the readout was cut to "0.03 s of 0..." at 1280 px, now it shows in full. The inspector keeps the unit. Tests: - Unit sounds 22, sound-library 85 (1 new each). Counter-checks: without turning the samples round, without moving the fade in to the end, and without mirroring the peaks, all red. - e2e 34: ALL PASSED, 72 checks (2 new). The reversed stretch starts with the last samples of the forward one, and the playhead ran 0.75, 0.60, 0.45, 0.30, 0.15 over the whole file. - Gate: first run red, 1 of 4031 (the top bar icon list). After moving the icon, CHECK EXIT 0, 260 files, 4031 tests. Co-Authored-By: Claude Opus 5.5 --- src/core/model/sound.ts | 33 +++++++++++++++------ src/core/properties/PropertySchema.ts | 3 ++ src/core/properties/sounds.ts | 3 ++ src/editor/debug/soundHooks.ts | 4 +++ src/editor/panels/headerIcons.ts | 3 +- src/editor/panels/icons/reverse.svg | 3 ++ src/editor/sound/SoundEditor.ts | 31 ++++++++++++++++---- src/editor/sound/WaveCanvas.ts | 9 ++++-- src/viewport/SoundLibrary.ts | 6 ++-- tests/core/sounds.test.ts | 42 ++++++++++++++++++++++++++- tests/viewport/sound-library.test.ts | 20 +++++++++++++ tools/e2e/34-sound.mjs | 40 +++++++++++++++++++++++++ 12 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 src/editor/panels/icons/reverse.svg 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.