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>
This commit is contained in:
1 parent
c7fa1d15b0
commit
42a3759d9d
32 files changed
+1230
-363
No files matched your search
@@ -7,6 +7,7 @@ import { assetSignature, buildAssetSection, refreshAssetSection } from './assetS
|
||||
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
|
||||
import { buildEdgeSection, edgeSignature, refreshEdgeSection } from './edgeSection';
|
||||
import { buildFrameSection, frameSignature, refreshFrameSection } from './frameSection';
|
||||
import { buildClipSection, clipSignature, refreshClipSection } from './clipSection';
|
||||
import { emptyHost, type InspectorHost } from './host';
|
||||
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
|
||||
import { buildShapeSection, refreshShapeSection, shapeSignature } from './shapeSection';
|
||||
@@ -131,6 +132,7 @@ export class Inspector extends Panel {
|
||||
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId);
|
||||
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
|
||||
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
|
||||
else if (s.kind === 'clip') buildClipSection(this.host, s.assetId, s.clipId);
|
||||
else if (s.kind === 'shape') buildShapeSection(this.host, s.assetId, s.shapeId, s.shapeIds);
|
||||
else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId);
|
||||
else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys);
|
||||
@@ -147,6 +149,7 @@ export class Inspector extends Panel {
|
||||
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
|
||||
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
|
||||
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
|
||||
if (s.kind === 'clip') return clipSignature(this.state.project, s.assetId, s.clipId);
|
||||
if (s.kind === 'shape') return shapeSignature(this.state.project, s.assetId, s.shapeId, s.shapeIds);
|
||||
if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId);
|
||||
if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys);
|
||||
@@ -177,17 +180,19 @@ export class Inspector extends Panel {
|
||||
? refreshNodeSection(this.host, s.assetId, s.nodeId)
|
||||
: s.kind === 'frame'
|
||||
? refreshFrameSection(this.host, s.assetId, s.frameId)
|
||||
: s.kind === 'shape'
|
||||
? refreshShapeSection(this.host, s.assetId, s.shapeId)
|
||||
: s.kind === 'annotation'
|
||||
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
|
||||
: s.kind === 'track'
|
||||
? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys)
|
||||
: s.kind === 'edge'
|
||||
? refreshEdgeSection(this.host, s.assetId, s.edgeId)
|
||||
: s.kind === 'song'
|
||||
? refreshSongSection(this.host, s.assetId, s.trackId, s.itemIds)
|
||||
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
|
||||
: s.kind === 'clip'
|
||||
? refreshClipSection(this.host, s.assetId, s.clipId)
|
||||
: s.kind === 'shape'
|
||||
? refreshShapeSection(this.host, s.assetId, s.shapeId)
|
||||
: s.kind === 'annotation'
|
||||
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
|
||||
: s.kind === 'track'
|
||||
? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys)
|
||||
: s.kind === 'edge'
|
||||
? refreshEdgeSection(this.host, s.assetId, s.edgeId)
|
||||
: s.kind === 'song'
|
||||
? refreshSongSection(this.host, s.assetId, s.trackId, s.itemIds)
|
||||
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
|
||||
if (!ok) this.rebuild();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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');
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
assetRefLabel,
|
||||
MASTER_LABEL,
|
||||
NO_ASSET_LABEL,
|
||||
soundChoices,
|
||||
soundEntries,
|
||||
} from '../../core/properties/assetRef';
|
||||
import { NO_FX } from '../../core/properties/assetFx';
|
||||
import { busesInto, MASTER_BUS } from '../../core/properties/buses';
|
||||
@@ -408,7 +410,12 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
|
||||
...(extras?.noFx ? [{ id: NO_FX, label: NO_ASSET_LABEL }] : []),
|
||||
...assetRefChoices(project, extras?.assetTypes)
|
||||
.filter((asset) => !left.has(asset.id))
|
||||
.map((asset) => ({ id: asset.id, label: assetChoiceLabel(asset) })),
|
||||
.flatMap((asset) =>
|
||||
// A sound with its clips (#357), each as "Kick / Clip 1 (sound)".
|
||||
asset.type === 'sound'
|
||||
? soundEntries(asset).map((entry) => ({ id: entry.id, label: `${entry.label} (sound)` }))
|
||||
: [{ id: asset.id, label: assetChoiceLabel(asset) }],
|
||||
),
|
||||
];
|
||||
},
|
||||
},
|
||||
@@ -429,7 +436,7 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
|
||||
sound: {
|
||||
none: NO_ASSET_LABEL,
|
||||
label: assetRefLabel,
|
||||
choices: (project) => assetRefChoices(project, ['sound']).map((a) => ({ id: a.id, label: a.name })),
|
||||
choices: soundChoices,
|
||||
},
|
||||
scene: {
|
||||
none: NO_ASSET_LABEL,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { SetSongCommand } from '../../core/commands/songCommands';
|
||||
import type { SongAsset } from '../../core/model/Asset';
|
||||
import { ASSET_TYPE_LABELS } from '../../core/model/AssetTypes';
|
||||
import { findAsset, findAssetOf, type Project } from '../../core/model/Project';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import { findSound } from '../../core/model/sound';
|
||||
import { isEmbedded } from '../../core/music/embedded';
|
||||
import { assetRefLabel, soundEntries } from '../../core/properties/assetRef';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
ITEM_MIN_DB,
|
||||
itemAsset,
|
||||
itemMaxDb,
|
||||
MAX_TRANSPOSE,
|
||||
type SongItem,
|
||||
@@ -12,7 +16,9 @@ import {
|
||||
TRACK_MAX_DB,
|
||||
TRACK_MIN_DB,
|
||||
} from '../../core/music/song';
|
||||
import type { MenuEntry } from '../../ui';
|
||||
import { ownFxEntries } from '../song/ownFx';
|
||||
import type { EditorState } from '../state';
|
||||
import { createFieldRow } from './fieldFactory';
|
||||
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
@@ -180,7 +186,7 @@ function buildItem(host: InspectorHost, asset: SongAsset, item: SongItem, picked
|
||||
const { state } = host;
|
||||
addTitle(host, 'SONG ITEM');
|
||||
const track = asset.tracks.find((t) => t.id === item.track);
|
||||
const what = findAsset(state.project, item.asset)?.name ?? 'missing';
|
||||
const what = itemAsset(state.project, item) ? assetRefLabel(state.project, item.asset) : 'missing';
|
||||
const others = picked > 1 ? ` ${picked - 1} more picked.` : '';
|
||||
addNote(host, `${ASSET_TYPE_LABELS[item.kind]} "${what}" on ${track?.name ?? 'a track'}.${others}`);
|
||||
for (const def of itemRows(item)) {
|
||||
@@ -198,6 +204,8 @@ function buildItem(host: InspectorHost, asset: SongAsset, item: SongItem, picked
|
||||
});
|
||||
if (def.key === FX_ROW.key)
|
||||
handlers.moreChoices = (current) => ownFxEntries(state, asset.id, current, commit);
|
||||
if (def.key === 'asset' && item.kind === 'sound')
|
||||
handlers.moreChoices = (current) => ownSoundEntries(state, current, (id) => state.execute(commit(id)));
|
||||
const row = createFieldRow(host, def, item[def.key as keyof SongItem], handlers);
|
||||
host.fields.set(def.key, row);
|
||||
host.body.add(row.row);
|
||||
@@ -211,6 +219,23 @@ function buildItem(host: InspectorHost, asset: SongAsset, item: SongItem, picked
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sound the song keeps for the item (#355) and its clips (#357), which no list offers: a take's
|
||||
* clips are picked here.
|
||||
*/
|
||||
function ownSoundEntries(state: EditorState, current: string, pick: (id: string) => void): MenuEntry[] {
|
||||
const sound = findSound(state.project, current)?.asset;
|
||||
if (!sound || !isEmbedded(sound)) return [];
|
||||
return [
|
||||
{ separator: true },
|
||||
...soundEntries(sound).map((entry) => ({
|
||||
label: entry.label,
|
||||
current: entry.id === current,
|
||||
onSelect: () => pick(entry.id),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function itemOf(asset: SongAsset | undefined, itemIds: readonly string[]): SongItem | undefined {
|
||||
const id = itemIds.at(-1);
|
||||
return id === undefined ? undefined : asset?.items.find((i) => i.id === id);
|
||||
|
||||
Reference in new issue
Block a user