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
@@ -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'],
|
||||
|
||||
Reference in new issue
Block a user