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 <noreply@anthropic.com>
This commit is contained in:
1 parent
d505f6ed27
commit
101b12504e
12 files changed
+177
-20
No files matched your search
+24
-9
@@ -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
|
||||
* `<file>#<start>~<end>~<fadeIn>~<fadeOut>~<curveIn>~<curveOut>`, a name of its own.
|
||||
* `<file>#<start>~<end>~<fadeIn>~<fadeOut>~<curveIn>~<curveOut>`, 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<ArrayBuffer>[] {
|
||||
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;
|
||||
|
||||
@@ -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<AssetType, PropertySchema> = {
|
||||
// The stretch of the file it is (#343), set in its tab; an end of 0 is the end of the file.
|
||||
{ key: SOUND_START_KEY, label: 'Start (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
|
||||
{ key: SOUND_END_KEY, label: 'End (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
|
||||
// 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).
|
||||
...(
|
||||
[
|
||||
|
||||
@@ -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]),
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="reverse" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<path d="M4.5 -6 L-6 0 L4.5 6 Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 164 B |
@@ -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<SoundAsset, WaveCanvas> {
|
||||
/** 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<SoundAsset, WaveCanvas> {
|
||||
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<SoundAsset, WaveCanvas> {
|
||||
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<SoundAsset, WaveCanvas> {
|
||||
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<SoundAsset, WaveCanvas> {
|
||||
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));
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user