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
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user