Files
feuerwerk/src/editor/inspector/clipSection.ts
T
adminandClaude Opus 5.5 42a3759d9d Cut a sound into named clips, as a texture into frames (#357)
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>
2026-10-08 20:18:47 +02:00

73 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
}