A sound could only be cut to one stretch, set on the asset itself. To
play two parts of one file, the file had to be imported twice. Now a
sound is always the whole file, and its clips are the stretches of it,
each with a name and a cut of its own.
- A clip is { id, name, props } on SoundAsset.clips. Its id shares the
generator with asset ids, so a sound field holds a sound or a clip,
as a texture field holds a texture or a frame.
- props are the cut: start, end, the fades and their curves, reverse.
Volume, loop, sound FX and bus stay on the sound for all its clips.
The cut rows left the sound's schema for CLIP_ROWS.
- findSound resolves either id. Play sound, the Sample of an
instrument, a Reverb impulse, song items, the scheduler and the test
sound of the Sound FX tab all go through it.
- Every sound field offers each sound followed by its clips, "Kick",
"Kick / Clip 1". A clip that is gone reads as "<id>?".
- The Sound tab has a strip under the ruler with the clips as bands.
A drag there makes a clip, a click picks one, beside the bands the
sound. Only the picked clip has handles. Reverse, fades and curves
in the header work on it and are greyed out without one.
- Whole file is gone from the header, as the user asked.
- Picking a clip is a selection of its own, like a frame. The
inspector shows its name, its cut rows and Delete clip. Delete
takes it away without asking, as with a frame. Ctrl+Z brings it
back.
- Copies get fresh clip ids: Duplicate, Embed a copy, a song
duplicated with its takes, and the copy a split or paste makes of
an embedded take. An item on a clip plays the same clip of its copy.
- A double-click on a song item playing a clip opens its sound with
the clip picked. The Sound row of an item on an embedded take
offers the take and its clips, which no list shows.
- A new take stays the whole sound. Clips are made by hand, as the
user decided.
- No migration in the code, as the user decided. The one project
with cut sounds was changed over by hand.
Deviation from the issue: deleting a used clip does not warn. A frame
does not warn either, and the issue had promised "like a frame".
Tests:
- Unit sound-clips (new): 11 green, 7 counter-checks each red
- Unit sounds, embedded: adjusted to clips, green
- e2e 34 (melektaus-atx): ALL PASSED, 90 checks (3 new, 12 rewritten)
- Counter-check in the browser: clips left out of the sound fields,
the picker check fails
- Gate (melektaus-atx): CHECK EXIT 0, 271 files, 4194 tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
73 lines
3.3 KiB
TypeScript
73 lines
3.3 KiB
TypeScript
import { RemoveClipCommand, RenameClipCommand, SetClipPropsCommand } from '../../core/commands/clipCommands';
|
||
import type { SoundAsset, SoundClip } from '../../core/model/Asset';
|
||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||
import { clipsOf } from '../../core/model/sound';
|
||
import { CLIP_ROWS } from '../../core/properties/sounds';
|
||
import { Button } from '../../ui';
|
||
import { createFieldRow } from './fieldFactory';
|
||
import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||
import { previewingHandlers } from './previewHandlers';
|
||
|
||
/** Structure-defining facts of a clip selection: none – only the clip disappearing rebuilds. */
|
||
export function clipSignature(project: Project, assetId: string, clipId: string): string {
|
||
return currentClip(project, assetId, clipId) ? 'clip' : 'missing';
|
||
}
|
||
|
||
/** Name, the stretch of the file it is and how it is played, and the way to delete it (#357). */
|
||
export function buildClipSection(host: InspectorHost, assetId: string, clipId: string): void {
|
||
const asset = currentSound(host.state.project, assetId);
|
||
const clip = asset && currentClip(host.state.project, assetId, clipId);
|
||
if (!asset || !clip) return;
|
||
|
||
addTitle(host, 'SOUND CLIP');
|
||
addNameField(host, clip.name, {
|
||
onChange: (name) => {
|
||
// The command rejects a blank name but cannot put the old text back into the field.
|
||
if (name.trim()) host.state.execute(new RenameClipCommand(assetId, clipId, name.trim()));
|
||
else host.nameField?.setValue(clip.name);
|
||
},
|
||
});
|
||
addNote(host, `Clip of the sound "${asset.name}". Its volume, loop, bus and sound FX are the sound's.`);
|
||
|
||
for (const def of CLIP_ROWS) {
|
||
const handlers = previewingHandlers<SoundClip>(host, {
|
||
target: () => currentClip(host.state.project, assetId, clipId),
|
||
read: (target) => target.props[def.key],
|
||
write: (target, value) => (target.props[def.key] = value),
|
||
remove: (target) => delete target.props[def.key],
|
||
commit: (value) =>
|
||
new SetClipPropsCommand(assetId, clipId, { [def.key]: value }, `Set clip ${def.label}`),
|
||
});
|
||
const row = createFieldRow(host, def, clip.props[def.key] ?? def.default, handlers);
|
||
host.fields.set(def.key, row);
|
||
host.body.add(row.row);
|
||
}
|
||
|
||
addGap(host);
|
||
host.body.add(
|
||
new Button('Delete clip', {
|
||
variant: 'danger',
|
||
onClick: () => host.state.execute(new RemoveClipCommand(assetId, clipId)),
|
||
}),
|
||
);
|
||
}
|
||
|
||
/** Pushes the clip's current name and values into the rows. False when it is gone (rebuild). */
|
||
export function refreshClipSection(host: InspectorHost, assetId: string, clipId: string): boolean {
|
||
const clip = currentClip(host.state.project, assetId, clipId);
|
||
if (!clip) return false;
|
||
host.nameField?.setValue(clip.name);
|
||
for (const def of CLIP_ROWS) host.fields.get(def.key)?.setValue(clip.props[def.key] ?? def.default);
|
||
return true;
|
||
}
|
||
|
||
/** Looked up inside the sound, so a selection left over from a deleted sound edits nothing. */
|
||
function currentClip(project: Project, assetId: string, clipId: string): SoundClip | undefined {
|
||
const asset = currentSound(project, assetId);
|
||
return asset && clipsOf(asset).find((c) => c.id === clipId);
|
||
}
|
||
|
||
function currentSound(project: Project, assetId: string): SoundAsset | undefined {
|
||
return findAssetOf(project, assetId, 'sound');
|
||
}
|