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:
adminandClaude Opus 5.5 committed 2026-10-08 20:18:47 +02:00
1 parent c7fa1d15b0
commit 42a3759d9d
32 files changed
+1230 -363

No files matched your search

+2 -2
View File
@@ -527,12 +527,12 @@ export class Editor implements PlayHost {
const focus = ui().focus;
focus.tabBelongsElsewhere = () => this.tab === 'game';
this.cleanups.push(() => (focus.tabBelongsElsewhere = () => false));
// A node, a frame and a template object exist only in their own document, so selecting one
// A node, a frame, a clip and a template object exist only in their own document, so selecting one
// brings it up. Selecting an asset never does: that is what opening it (double-click, Enter,
// creating it) is for – see the `open` event below.
state.on('selection', () => {
const s = state.selection;
if (s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'song')
if (s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'clip' || s?.kind === 'song')
this.openDocument(`asset:${s.assetId}`);
if (s?.kind === 'object' && findAsset(state.project, s.holderId)?.type === 'container')
this.openDocument(`asset:${s.holderId}`);
+4
View File
@@ -15,6 +15,7 @@ import {
} from '../core/commands/colliderCommands';
import { oneStep } from '../core/commands/CompoundCommand';
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveClipCommand } from '../core/commands/clipCommands';
import { RemoveFrameCommand } from '../core/commands/frameCommands';
import { DisconnectCommand, FoldEdgeCommand, RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
@@ -212,6 +213,9 @@ export function removeSelection(state: EditorState): boolean {
case 'frame':
state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
return true;
case 'clip':
state.execute(new RemoveClipCommand(s.assetId, s.clipId));
return true;
case 'shape':
removeShapes(state, s.assetId, s.shapeIds);
return true;
+14 -5
View File
@@ -1,4 +1,5 @@
import { findAssetOf } from '../../core/model/Project';
import { clipsOf, findSound } from '../../core/model/sound';
import { playedSoundOf } from '../../core/properties/playedSound';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { centreOf, type HookContext } from './hookHelpers';
@@ -25,11 +26,19 @@ export function soundHooks({ state, editor }: HookContext) {
playhead: () => soundEditor.canvas.playhead,
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,
enabled: soundEditor.reverseButton.enabled,
}),
/** The clips of the sound shown (#357): id, name and stored values; the picked one's id. */
clips: () => {
const asset = findAssetOf(state.project, soundEditor.currentAssetId, 'sound');
return asset ? clipsOf(asset).map((c) => ({ id: c.id, name: c.name, props: { ...c.props } })) : [];
},
pickedClip: () => soundEditor.canvas.pickedClip()?.id ?? null,
/** The middle of the clip strip at a second of the file, on screen. */
stripAt: (seconds: number) => soundEditor.canvas.stripAt(seconds),
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. */
@@ -41,10 +50,10 @@ export function soundHooks({ state, editor }: HookContext) {
...centreOf(soundEditor.curveOutButton),
tooltip: soundEditor.curveOutButton.tooltipText(),
}),
/** What a sound asset is played by: its file, or the name of its cut. */
played: (assetId: string) => {
const asset = findAssetOf(state.project, assetId, 'sound');
return asset ? playedSoundOf(state.project, asset) : null;
/** What a sound or a clip of one is played by: its file, or the name of the clip's cut. */
played: (id: string) => {
const ref = findSound(state.project, id);
return ref ? playedSoundOf(ref) : null;
},
/** The samples a name plays, cut and faded: their count, rate, and the first and last few of channel 0. */
samples: async (name: string) => {
+16 -11
View File
@@ -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();
}
}
+72
View File
@@ -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');
}
+9 -2
View File
@@ -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,
+27 -2
View File
@@ -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);
+5 -1
View File
@@ -20,6 +20,7 @@ import { sampleCount, takeName, takeStart, takeTrack, takeTrackIndex, wavBytes }
import { ticksOfSeconds } from '../../core/music/time';
import type { Asset, SongAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import { findSound } from '../../core/model/sound';
import { textOf } from '../../core/model/Properties';
import { assetFxOf } from '../../core/properties/assetFx';
import type { SoundStore } from '../../core/store/SoundStore';
@@ -156,7 +157,10 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
itemId: item.id,
round: roundAt(item, patternLength(pattern), tick),
});
activateAsset(state, item.asset);
// An item playing a clip (#357) opens its sound with the clip picked.
const sound = item.kind === 'sound' ? findSound(state.project, item.asset) : null;
if (sound?.clip) state.select({ kind: 'clip', assetId: sound.asset.id, clipId: sound.clip.id });
else activateAsset(state, item.asset);
};
timeline.onDiscardTake = () => this.stop({ discard: true });
this.mount(timeline, [
+15 -12
View File
@@ -14,13 +14,13 @@ import {
embeddedUsedBy,
instrumentNear,
isEmbedded,
markedName,
listedAssets,
songClockProps,
} from '../../core/music/embedded';
import { PATTERN_BARS_KEY, PATTERN_INSTRUMENT_KEY, patternLength } from '../../core/music/pattern';
import type { LoopRange } from '../../core/music/sequence';
import {
itemAsset,
itemEnd,
newItem,
newTrack,
@@ -54,6 +54,7 @@ import {
Widget,
zoomFactor,
} from '../../ui';
import { assetRefLabel } from '../../core/properties/assetRef';
import { playedSoundById, playedSoundOf } from '../../core/properties/playedSound';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { TimeRuler } from '../music/TimeRuler';
@@ -959,7 +960,7 @@ export class SongTimeline extends Widget {
if (dragged.type === 'pattern')
length = droppedLength(null, patternLength(dragged), songTempo(asset.props), bar);
else {
const played = playedSoundOf(this.state.project, dragged);
const played = playedSoundOf({ asset: dragged, clip: null });
this.ensureLoaded(played);
length = droppedLength(soundLibrary.duration(played), null, songTempo(asset.props), bar);
}
@@ -1030,14 +1031,14 @@ export class SongTimeline extends Widget {
const hit = at && asset.items.find((i) => i.id === at.id);
if (!hit) return this.openSpotMenu(asset, g, global);
if (!this.pickedIds.includes(hit.id)) this.pick([hit.id]);
const played = findAsset(this.state.project, hit.asset);
const played = itemAsset(this.state.project, hit);
const embedded = played !== undefined && isEmbedded(played);
new Menu([
{ label: 'Open', disabled: !played, onSelect: () => this.onOpenItem(hit, hit.tick) },
embedded
played && embedded
? {
label: 'Make project asset',
onSelect: () => this.state.execute(new MakeProjectAssetCommand(hit.asset)),
onSelect: () => this.state.execute(new MakeProjectAssetCommand(played.id)),
}
: { label: 'Embed a copy', disabled: !played, onSelect: () => this.embedCopy(asset, hit) },
{ separator: true },
@@ -1087,12 +1088,14 @@ export class SongTimeline extends Widget {
/** Points the item at a copy of its asset that only the song has (#355), to bend without touching the rest. */
embedCopy(asset: SongAsset, item: SongItem): boolean {
const played = findAsset(this.state.project, item.asset);
const played = itemAsset(this.state.project, item);
if (!played || isEmbedded(played)) return false;
const copy = embeddedCopy(played, asset);
// An item playing a clip (#357) plays the same clip of the copy.
const { copy, ids } = embeddedCopy(played, asset);
const named = ids.get(item.asset) ?? copy.id;
this.commit(
asset,
{ items: asset.items.map((i) => (i.id === item.id ? { ...i, asset: copy.id } : i)), spare: [copy] },
{ items: asset.items.map((i) => (i.id === item.id ? { ...i, asset: named } : i)), spare: [copy] },
'Embed a copy',
);
return true;
@@ -1244,10 +1247,10 @@ export class SongTimeline extends Widget {
return rounds ? loopSeams(item, rounds.period, item.tick - rounds.base) : [];
}
/** Its asset's name, with a mark in front for one the song keeps to itself (#355). */
/** Its asset's name, with a mark in front for one the song keeps to itself (#355); "Sound / Clip" for a clip (#357). */
private labelOf(item: SongItem): string {
const asset = findAsset(this.state.project, item.asset);
return asset ? markedName(asset) : 'missing';
const project = this.state.project;
return itemAsset(project, item) ? assetRefLabel(project, item.asset) : 'missing';
}
private contentOf(item: SongItem): 'notes' | 'wave' | 'none' {
@@ -1394,7 +1397,7 @@ export class SongTimeline extends Widget {
if (row === undefined) continue;
const box = itemBox(this.view, row, item);
if (box.x > lw || box.x + box.w < 0 || box.y > lh || box.y + box.h < 0) continue;
const missing = !findAsset(this.state.project, item.asset);
const missing = !itemAsset(this.state.project, item);
const color = missing ? MISSING_COLOR : item.kind === 'pattern' ? PATTERN_COLOR : SOUND_COLOR;
const lit = picked.has(item.id);
this.itemsG
+55 -48
View File
@@ -1,5 +1,6 @@
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import type { Asset, SoundAsset } from '../../core/model/Asset';
import { SetClipPropsCommand } from '../../core/commands/clipCommands';
import type { Asset, SoundAsset, SoundClip } from '../../core/model/Asset';
import type { PropertyValue } from '../../core/model/Properties';
import { playedSoundOf } from '../../core/properties/playedSound';
import { resolveAssetProps } from '../../core/properties/resolve';
import { FADE_CURVES, type FadeCurve, fadeGain } from '../../core/model/sound';
@@ -52,21 +53,20 @@ 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), turns it backwards, sets the fade at each edge and its
* curve, and puts the whole file back.
* The Sound tab (#343): the file as a waveform, and its clips (#357), the stretches of it a song item,
* a `Play sound` or an instrument's Sample can play instead of the whole file. The header plays the
* picked clip, else the whole file (Space does too), and turns the picked clip backwards and sets the
* fade at each of its edges and its curve.
*/
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;
readonly curveOutButton: Dropdown;
/** The voice the tab plays, 0 while none, and the sound it is of. */
/** The voice the tab plays, 0 while none, and the sound and clip it is of ("sound:clip"). */
private voice = 0;
private playingId = '';
private readonly halt = () => this.stop();
@@ -76,17 +76,13 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
this.playButton = new Button('Play', {
icon: TOOLBAR_ICON_SVG.play,
minWidth: 72,
tooltip: 'Play the stretch between the handles (Space)',
tooltip: 'Play the picked clip, else the whole file (Space)',
onClick: () => this.togglePlay(),
});
this.wholeButton = new Button('Whole file', {
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',
tooltip: 'Reverse: play the picked clip backwards, from its end to its start',
onClick: () => this.toggleReverse(),
});
const fadeField = (key: string) =>
@@ -117,7 +113,6 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
canvas.onLoaded = () => this.refresh();
this.mount(canvas, [
this.playButton,
this.wholeButton,
this.reverseButton,
// In milliseconds, as the inspector says; the header has no room for the unit.
new Label('Fade in', { style: 'muted' }),
@@ -128,6 +123,8 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
this.curveOutButton,
]);
state.on('load', () => this.stop());
// Picking a clip changes what the header plays and edits.
state.on('selection', () => this.refresh());
}
override selectedId(): string {
@@ -147,19 +144,20 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
else this.play();
}
/** Plays the stretch once, at the sound's own volume, from its start. */
/** Plays the picked clip or the whole file once, at the sound's own volume, from its start. */
play(): void {
const asset = this.asset();
if (!asset?.file) return;
const clip = this.canvas.pickedClip();
this.stop();
claimTransport(this.halt);
const props = resolveAssetProps(this.state.project, asset);
// Through its sound FX (#352), its bus and the master, as the game hears it (#325).
const bus = playedBus(this.state.project, '', props);
const fx = fxVoiceOf(this.state.project, assetFxOf(props), DEFAULT_TEMPO) ?? DRY_VOICE;
const file = playedSoundOf(this.state.project, asset);
const file = playedSoundOf({ asset, clip });
this.voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus);
this.playingId = this.voice ? asset.id : '';
this.playingId = this.voice ? `${asset.id}:${clip?.id ?? ''}` : '';
this.syncPlayButton();
}
@@ -174,7 +172,7 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
}
protected override closed(assetId: string): void {
if (assetId === this.playingId) this.stop();
if (this.playingId.startsWith(`${assetId}:`)) this.stop();
}
/** Only called while the tab is visible: the playhead follows what is heard, and stops with it. */
@@ -190,37 +188,43 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
this.canvas.invalidate();
}
private toggleReverse(): void {
/** Sets one value of the picked clip, in one undo step. */
private setOnClip(key: string, value: PropertyValue, label: string): 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));
const clip = this.canvas.pickedClip();
if (asset && clip)
this.state.execute(new SetClipPropsCommand(asset.id, clip.id, { [key]: value }, label));
}
private toggleReverse(): void {
const clip = this.canvas.pickedClip();
if (clip) this.setOnClip(REVERSE_KEY, !cutOf(clip.props).reverse, 'Reverse clip');
}
private setFade(key: string, ms: number): void {
const asset = this.asset();
const cut = asset && cutOf(resolveAssetProps(this.state.project, asset));
if (!asset || !cut) return;
const clip = this.canvas.pickedClip();
if (!clip) return;
const cut = cutOf(clip.props);
const now = Math.round((key === FADE_IN_KEY ? cut.fadeIn : cut.fadeOut) * 1000);
if (ms !== now) this.state.execute(new SetAssetPropertyCommand(asset.id, key, ms));
if (ms !== now) this.setOnClip(key, ms, 'Set clip fade');
}
/** The curve a key stands for, as the sound has it now. */
private curveOf(asset: SoundAsset, key: string): FadeCurve {
const cut = cutOf(resolveAssetProps(this.state.project, asset));
/** The curve a key stands for, as the clip has it now. */
private curveOf(clip: SoundClip, key: string): FadeCurve {
const cut = cutOf(clip.props);
return key === FADE_IN_CURVE_KEY ? cut.curveIn : cut.curveOut;
}
/** The curve a dropdown stands for on the sound shown, the default while there is none. */
/** The curve a dropdown stands for on the picked clip, the default while there is none. */
shownCurve(key: string): FadeCurve {
const asset = this.asset();
return asset ? this.curveOf(asset, key) : DEFAULT_FADE_CURVE;
const clip = this.canvas.pickedClip();
return clip ? this.curveOf(clip, key) : DEFAULT_FADE_CURVE;
}
private openCurveMenu(key: string, button: Dropdown, rising: boolean): void {
const asset = this.asset();
if (!asset) return;
const now = this.curveOf(asset, key);
const clip = this.canvas.pickedClip();
if (!clip) return;
const now = this.curveOf(clip, key);
new Menu(
FADE_CURVES.map((curve) => ({
label: curve.label,
@@ -232,9 +236,8 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
}
private setCurve(key: string, curve: FadeCurve): void {
const asset = this.asset();
if (!asset || this.curveOf(asset, key) === curve) return;
this.state.execute(new SetAssetPropertyCommand(asset.id, key, curve));
const clip = this.canvas.pickedClip();
if (clip && this.curveOf(clip, key) !== curve) this.setOnClip(key, curve, 'Set clip fade curve');
}
private syncPlayButton(): void {
@@ -254,21 +257,25 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
protected override syncExtras(asset: SoundAsset | null): void {
const ready = asset !== null && Boolean(asset.file);
const clip = ready ? this.canvas.pickedClip() : null;
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;
// What a clip has: with none picked there is nothing to turn or fade.
for (const control of [
this.reverseButton,
this.fadeInField,
this.fadeOutField,
this.curveInButton,
this.curveOutButton,
])
control.setEnabled(clip !== null);
const cut = clip ? cutOf(clip.props) : 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));
this.curveOutButton.setIcon(curveIcon(cut?.curveOut ?? DEFAULT_FADE_CURVE, false));
// Another sound on screen: what was playing is no longer what the tab shows.
if (this.isPlaying && asset?.id !== this.playingId) this.stop();
// Another sound or clip on screen: what was playing is no longer what the tab shows.
if (this.isPlaying && `${asset?.id}:${clip?.id ?? ''}` !== this.playingId) this.stop();
this.syncPlayButton();
}
}
+200 -82
View File
@@ -5,19 +5,19 @@ import {
Graphics,
type Text,
} from 'pixi.js';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import type { SoundAsset } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { AddClipCommand, SetClipPropsCommand } from '../../core/commands/clipCommands';
import type { SoundAsset, SoundClip } from '../../core/model/Asset';
import type { PropertyBag, Vec2 } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import { cutRange, type FadeCurve, fadeGain } from '../../core/model/sound';
import { resolveAssetProps } from '../../core/properties/resolve';
import { clipsOf, cutRange, type FadeCurve, fadeGain } from '../../core/model/sound';
import { cutOf, END_KEY, START_KEY } from '../../core/properties/sounds';
import { crispText, type Size, textStyle, Theme, ui, Widget, zoomFactor } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import type { EditorState } from '../state';
import {
clampWave,
type ClipBand,
clipAt,
type CutSpan,
draggedSpan,
edgeAt,
@@ -39,6 +39,10 @@ import {
} from './waveView';
const RULER_HEIGHT = 24;
/** The strip under the ruler the clips stand in as bands (#357). */
const STRIP_HEIGHT = 20;
/** Where the wave starts, under the ruler and the strip. */
const WAVE_TOP = RULER_HEIGHT + STRIP_HEIGHT;
/** The level scale left of the wave, in decibels (#357). */
const SCALE_WIDTH = 36;
/** Pixels apart the level scale's marks are at least. */
@@ -53,18 +57,27 @@ const LABEL_SPACING = 70;
const LEAST_SPAN = 0.001;
/** Pixels a press has to travel before it spans a new stretch. */
const DRAG_START = 3;
/** The name on the picked clip's band, dark on its light fill. */
const LIT_LABEL = textStyle('small').clone();
LIT_LABEL.fill = 0x30343c;
/** Line pieces a fade's curve is drawn with at most. */
const RAMP_STEPS = 32;
/**
* A drag that spans: an edge of the clip `clipId`, that clip anew, or with `clipId` null a new clip.
* `picks` is what a press in the strip selects when it does not move: a clip, or null for the sound.
*/
type Gesture =
| { kind: 'pan'; pointerX: number; panX: number }
| {
kind: 'span';
clipId: string | null;
edge: 'start' | 'end' | null;
from: number;
pointerX: number;
moved: boolean;
span: CutSpan;
picks?: string | null;
};
/** A file's samples as the canvas draws them: decoded once, its block peaks made on first sight. */
@@ -88,10 +101,12 @@ function waveOf(buffer: AudioBuffer): Wave {
/**
* The Sound tab's canvas (#343): the whole file as one waveform, the loudest of its channels, under
* a ruler in seconds. The stretch the asset is cut to stands between two handles, what lies outside
* it darker; dragging a handle moves that edge, dragging beside them spans a new stretch. One drag
* is one undo step. The wheel zooms, the middle button pans, as in the music tabs; over the level
* scale on the left the wheel zooms the wave's height (#357).
* a ruler in seconds. The clips of the sound (#357) stand as bands in a strip under the ruler: a
* click picks one, a drag there spans a new one. The picked clip stands between two handles, what
* lies outside it darker; dragging a handle moves that edge, dragging beside them spans it anew.
* With no clip picked, a drag over the wave spans a new one too. One drag is one undo step. The
* wheel zooms, the middle button pans, as in the music tabs; over the level scale on the left the
* wheel zooms the wave's height.
*/
export class WaveCanvas extends Widget {
private readonly bg = new Graphics();
@@ -101,6 +116,8 @@ export class WaveCanvas extends Widget {
private readonly waveG = new Graphics();
private readonly overlay = new Graphics();
private readonly labels = new Container();
private readonly stripLabels = new Container();
private readonly stripTexts: Text[] = [];
/** Wave, cut and ruler, kept to the widget: a handle panned out of view must not draw beside it. */
private readonly content = new Container();
private readonly clip = new Graphics();
@@ -124,10 +141,11 @@ export class WaveCanvas extends Widget {
super();
this.focusable = true;
this.labels.eventMode = 'none';
this.stripLabels.eventMode = 'none';
this.scaleLabels.eventMode = 'none';
this.hint.eventMode = 'none';
this.content.x = SCALE_WIDTH;
this.content.addChild(this.waveG, this.overlay, this.labels);
this.content.addChild(this.waveG, this.overlay, this.labels, this.stripLabels);
this.content.mask = this.clip;
this.addChild(this.bg, this.scaleG, this.scaleLabels, this.content, this.clip, this.hint);
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
@@ -135,6 +153,7 @@ export class WaveCanvas extends Widget {
this.onPointerRelease(() => this.onPointerUp());
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
state.on('project', () => this.invalidate());
state.on('selection', () => this.invalidate());
}
override measure(): Size {
@@ -187,29 +206,66 @@ export class WaveCanvas extends Widget {
/** The middle of the level scale on screen (test hook). */
scaleCentre(): Vec2 {
const origin = this.getGlobalPosition();
return { x: origin.x + SCALE_WIDTH / 2, y: origin.y + RULER_HEIGHT + this.waveHeight / 2 };
return { x: origin.x + SCALE_WIDTH / 2, y: origin.y + WAVE_TOP + this.waveHeight / 2 };
}
/** Where the top of a full-scale wave is drawn on screen, before it is held inside the tab (test hook). */
fullScaleTop(): number {
return (
this.getGlobalPosition().y +
RULER_HEIGHT +
this.waveHeight / 2 -
levelOffset(1, this.level, this.fullHalf)
this.getGlobalPosition().y + WAVE_TOP + this.waveHeight / 2 - levelOffset(1, this.level, this.fullHalf)
);
}
/** The stretch the asset is cut to, as shown – while a drag runs, as it would leave it. */
/**
* What the tab plays: the picked clip, else the whole file. While a drag runs, the span it would
* leave.
*/
shownSpan(): CutSpan | null {
const asset = this.asset();
const wave = this.wave();
if (!asset || !wave) return null;
if (this.gesture?.kind === 'span') return this.gesture.span;
const cut = cutOf(resolveAssetProps(this.state.project, asset));
if (!this.asset() || !wave) return null;
return this.shownClipSpan() ?? { start: 0, end: wave.duration };
}
/** The picked clip of the sound shown, null for none (#357). */
pickedClip(): SoundClip | null {
const asset = this.asset();
const id = this.state.selectedClipId;
return (asset && id && clipsOf(asset).find((c) => c.id === id)) || null;
}
/** The span handles stand at: a drag's, else the picked clip's; null with neither. */
private shownClipSpan(): CutSpan | null {
const wave = this.wave();
if (!wave) return null;
if (this.gesture?.kind === 'span' && this.gesture.moved) return this.gesture.span;
const clip = this.pickedClip();
return clip ? this.clipSpan(clip, wave) : null;
}
private clipSpan(clip: SoundClip, wave: Wave): CutSpan {
const cut = cutOf(clip.props);
return spanOf(cut.start, cut.end, wave.duration);
}
/** The clips as their bands in the strip, a clip being dragged where the drag would leave it. */
private bands(asset: SoundAsset, wave: Wave): ClipBand[] {
const g = this.gesture?.kind === 'span' && this.gesture.moved ? this.gesture : null;
const bands = clipsOf(asset).map((clip) => ({
id: clip.id,
span: g?.clipId === clip.id ? g.span : this.clipSpan(clip, wave),
}));
return g && g.clipId === null ? [...bands, { id: '', span: g.span }] : bands;
}
/** The middle of the strip at a second of the file, on screen (test hook). */
stripAt(seconds: number): Vec2 {
const origin = this.getGlobalPosition();
return {
x: origin.x + SCALE_WIDTH + secondsToX(this.view, seconds),
y: origin.y + RULER_HEIGHT + STRIP_HEIGHT / 2,
};
}
/** The file's length in seconds, null while it is not loaded (test hook). */
fileDuration(): number | null {
return this.wave()?.duration ?? null;
@@ -220,17 +276,10 @@ export class WaveCanvas extends Widget {
const origin = this.getGlobalPosition();
return {
x: origin.x + SCALE_WIDTH + secondsToX(this.view, seconds),
y: origin.y + RULER_HEIGHT + this.waveHeight / 2,
y: origin.y + WAVE_TOP + this.waveHeight / 2,
};
}
/** Puts the whole file back, in one undo step. */
resetCut(): void {
const asset = this.asset();
const wave = this.wave();
if (asset && wave) this.commit(asset, { start: 0, end: wave.duration }, wave, 'Reset sound cut');
}
private asset(): SoundAsset | null {
return (this.assetId && findAssetOf(this.state.project, this.assetId, 'sound')) || null;
}
@@ -252,7 +301,7 @@ export class WaveCanvas extends Widget {
}
private get waveHeight(): number {
return Math.max(0, this.h - RULER_HEIGHT);
return Math.max(0, this.h - WAVE_TOP);
}
/** Pixels from the middle a full-scale sample stands at level 1. */
@@ -278,9 +327,9 @@ export class WaveCanvas extends Widget {
}
private onPointerDown(e: FederatedPointerEvent): void {
const asset = this.asset();
const wave = this.wave();
const span = this.shownSpan();
if (!wave || !span) return;
if (!asset || !wave) return;
const p = this.areaPoint(e);
e.stopPropagation();
ui().focus.focus(this);
@@ -290,14 +339,26 @@ export class WaveCanvas extends Widget {
}
if (e.button !== 0 || p.x < 0) return;
const from = Math.max(0, Math.min(wave.duration, xToSeconds(this.view, p.x)));
this.gesture = {
const start = {
kind: 'span',
edge: edgeAt(this.view, span, p.x),
from,
pointerX: p.x,
moved: false,
span,
};
span: { start: from, end: from },
} as const;
if (p.y >= RULER_HEIGHT && p.y < WAVE_TOP) {
// In the strip a press picks the band under it, a drag spans a new clip.
this.gesture = {
...start,
clipId: null,
edge: null,
picks: clipAt(this.view, this.bands(asset, wave), p.x),
};
return;
}
const clip = this.pickedClip();
const span = clip && this.clipSpan(clip, wave);
this.gesture = { ...start, clipId: clip?.id ?? null, edge: span ? edgeAt(this.view, span, p.x) : null };
}
private onPointerMove(e: FederatedPointerEvent): void {
@@ -316,8 +377,9 @@ export class WaveCanvas extends Widget {
}
if (!g.moved && g.edge === null && Math.abs(p.x - g.pointerX) < DRAG_START) return;
g.moved = true;
const before = this.committedSpan(wave);
if (!before) return;
// Only the picked clip has handles: an edge moves that one.
const clip = this.pickedClip();
const before = clip && clip.id === g.clipId ? this.clipSpan(clip, wave) : g.span;
const at = xToSeconds(this.view, p.x);
g.span = draggedSpan(before, g.edge, g.from, at, wave.duration, LEAST_SPAN);
this.redraw();
@@ -326,28 +388,42 @@ export class WaveCanvas extends Widget {
private onPointerUp(): void {
const g = this.gesture;
this.gesture = null;
if (g?.kind !== 'span' || !g.moved) return this.redraw();
if (g?.kind !== 'span') return this.redraw();
const asset = this.asset();
const wave = this.wave();
if (asset && wave) this.commit(asset, g.span, wave, g.edge ? 'Move sound cut' : 'Cut sound');
if (!g.moved) {
if (asset && g.picks !== undefined)
this.state.select(
g.picks
? { kind: 'clip', assetId: asset.id, clipId: g.picks }
: { kind: 'asset', assetId: asset.id },
);
return this.redraw();
}
if (asset && wave) this.commit(asset, g, wave);
this.redraw();
}
private committedSpan(wave: Wave): CutSpan | null {
const asset = this.asset();
if (!asset) return null;
const cut = cutOf(resolveAssetProps(this.state.project, asset));
return spanOf(cut.start, cut.end, wave.duration);
}
/** Writes a span as the asset's start and end, both in one step; nothing where neither changes. */
private commit(asset: SoundAsset, span: CutSpan, wave: Wave, label: string): void {
const next = storedCut(span, wave.duration, wave.rate);
const now = cutOf(resolveAssetProps(this.state.project, asset));
const commands = [];
if (next.start !== now.start) commands.push(new SetAssetPropertyCommand(asset.id, START_KEY, next.start));
if (next.end !== now.end) commands.push(new SetAssetPropertyCommand(asset.id, END_KEY, next.end));
if (commands.length > 0) this.state.execute(new CompoundCommand(label, commands));
/**
* Writes a drag's span as the clip's start and end in one step, nothing where neither changes – or
* as a new clip, which is then picked.
*/
private commit(asset: SoundAsset, g: Extract<Gesture, { kind: 'span' }>, wave: Wave): void {
const next = storedCut(g.span, wave.duration, wave.rate);
if (g.clipId === null) {
const add = new AddClipCommand(asset.id, { [START_KEY]: next.start, [END_KEY]: next.end });
if (this.state.execute(add)) this.state.select({ kind: 'clip', assetId: asset.id, clipId: add.clipId });
return;
}
const clip = clipsOf(asset).find((c) => c.id === g.clipId);
if (!clip) return;
const now = cutOf(clip.props);
const values: PropertyBag = {};
if (next.start !== now.start) values[START_KEY] = next.start;
if (next.end !== now.end) values[END_KEY] = next.end;
const label = g.edge ? 'Move clip edge' : 'Span clip';
if (Object.keys(values).length > 0)
this.state.execute(new SetClipPropsCommand(asset.id, clip.id, values, label));
}
private onWheel(e: FederatedWheelEvent): void {
@@ -362,8 +438,8 @@ export class WaveCanvas extends Widget {
}
private updateCursor(x: number, y: number): void {
const span = this.shownSpan();
const inside = x >= 0 && x <= this.areaWidth && y >= 0 && y <= this.h;
const span = this.shownClipSpan();
const inside = x >= 0 && x <= this.areaWidth && y >= WAVE_TOP && y <= this.h;
const cursor = inside && span && edgeAt(this.view, span, x) ? 'ew-resize' : 'default';
if (this.cursor !== cursor) this.cursor = cursor;
}
@@ -382,14 +458,15 @@ export class WaveCanvas extends Widget {
this.bg.clear().rect(0, 0, this.w, h).fill(c.viewport);
this.bg.rect(0, 0, this.w, RULER_HEIGHT).fill(c.panelHeader);
this.bg.rect(0, RULER_HEIGHT - 1, this.w, 1).fill(c.border);
this.bg.rect(0, RULER_HEIGHT, SCALE_WIDTH, wh).fill(c.panelHeader);
this.bg.rect(SCALE_WIDTH - 1, RULER_HEIGHT, 1, wh).fill(c.border);
this.bg.rect(0, RULER_HEIGHT, this.w, STRIP_HEIGHT).fill(c.panelHeader);
this.bg.rect(0, WAVE_TOP - 1, this.w, 1).fill(c.border);
this.bg.rect(0, WAVE_TOP, SCALE_WIDTH, wh).fill(c.panelHeader);
this.bg.rect(SCALE_WIDTH - 1, RULER_HEIGHT, 1, STRIP_HEIGHT + wh).fill(c.border);
this.clip.clear().rect(SCALE_WIDTH, 0, w, h).fill(0xffffff);
this.scaleG.clear();
this.waveG.clear();
this.overlay.clear();
const asset = this.asset();
const span = this.shownSpan();
this.hint.visible = !wave;
this.hint.text = !asset
? 'No sound selected – pick one in the Assets panel.'
@@ -398,26 +475,28 @@ export class WaveCanvas extends Widget {
: soundLibrary.failedToLoad(asset.file)
? 'Could not be decoded.'
: 'Loading…';
this.hint.position.set(SCALE_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
if (!wave || !span || !asset) {
this.hint.position.set(SCALE_WIDTH + Theme.padding * 2, WAVE_TOP + Theme.padding * 2);
if (!wave || !asset) {
this.syncLabels([]);
this.syncScale([]);
this.syncStrip([]);
return;
}
const x0 = secondsToX(this.view, 0);
const x1 = secondsToX(this.view, wave.duration);
const mid = RULER_HEIGHT + wh / 2;
const mid = WAVE_TOP + wh / 2;
const half = this.level * this.fullHalf;
const room = wh / 2;
// The file itself a shade lighter than what lies before and after it.
const left = Math.max(0, x0);
const right = Math.min(w, x1);
if (right > left)
this.waveG.rect(left, RULER_HEIGHT, right - left, wh).fill({ color: c.panel, alpha: 0.5 });
if (right > left) this.waveG.rect(left, WAVE_TOP, right - left, wh).fill({ color: c.panel, alpha: 0.5 });
if (right > left) this.waveG.rect(left, mid, right - left, 1).fill({ color: c.border });
this.drawWave(wave, left, right, mid, half, room);
this.drawRuler(left, right);
this.drawCut(asset, wave, span, mid, half, room);
this.drawStrip(asset, wave);
const span = this.shownClipSpan();
if (span) this.drawCut(wave, span, mid, half, room);
this.drawScale(mid, room);
if (this.playhead !== null) {
const x = secondsToX(this.view, this.playhead);
@@ -475,25 +554,45 @@ export class WaveCanvas extends Widget {
this.syncLabels(placed);
}
/** A band per clip with its name, the picked one lit, and the one a drag spans. */
private drawStrip(asset: SoundAsset, wave: Wave): void {
const c = Theme.colors;
const picked =
this.gesture?.kind === 'span' && this.gesture.moved ? this.gesture.clipId : this.pickedClip()?.id;
const names = new Map(clipsOf(asset).map((clip) => [clip.id, clip.name]));
const placed: { text: string; x: number; room: number; lit: boolean }[] = [];
for (const { id, span } of this.bands(asset, wave)) {
const a = secondsToX(this.view, span.start);
const b = secondsToX(this.view, span.end);
if (b < 0 || a > this.areaWidth) continue;
const lit = id === (picked ?? '');
this.overlay
.rect(a, RULER_HEIGHT + 2, Math.max(1, b - a), STRIP_HEIGHT - 4)
.fill({ color: c.accent, alpha: lit ? 0.85 : 0.35 })
.stroke({ width: 1, color: lit ? c.accentText : c.accent, alpha: lit ? 1 : 0.7, alignment: 1 });
const name = names.get(id);
if (name)
placed.push({
text: name,
x: Math.max(a, 0) + 4,
room: Math.min(b, this.areaWidth) - Math.max(a, 0) - 8,
lit,
});
}
this.syncStrip(placed);
}
/** Dark outside the stretch, a handle at each edge, and the ramp at each edge inside the file. */
private drawCut(
asset: SoundAsset,
wave: Wave,
span: CutSpan,
mid: number,
half: number,
room: number,
): void {
private drawCut(wave: Wave, span: CutSpan, mid: number, half: number, room: number): void {
const c = Theme.colors;
const { h } = this;
const w = this.areaWidth;
const a = secondsToX(this.view, span.start);
const b = secondsToX(this.view, span.end);
const shade = { color: 0x000000, alpha: 0.55 };
if (a > 0) this.overlay.rect(0, RULER_HEIGHT, a, h - RULER_HEIGHT).fill(shade);
if (b < w) this.overlay.rect(b, RULER_HEIGHT, w - b, h - RULER_HEIGHT).fill(shade);
this.overlay.rect(a, 0, Math.max(1, b - a), 4).fill({ color: c.accent, alpha: 0.6 });
const cut = cutOf(resolveAssetProps(this.state.project, asset));
if (a > 0) this.overlay.rect(0, WAVE_TOP, a, h - WAVE_TOP).fill(shade);
if (b < w) this.overlay.rect(b, WAVE_TOP, w - b, h - WAVE_TOP).fill(shade);
const cut = cutOf(this.pickedClip()?.props ?? {});
const range = cutRange(wave.channels[0]!.length, wave.rate, { ...cut, ...span });
const ramp = (from: number, to: number, rising: boolean, curve: FadeCurve) => {
const xa = secondsToX(this.view, from);
@@ -521,12 +620,12 @@ export class WaveCanvas extends Widget {
[b, 'end'],
] as const) {
const held = this.gesture?.kind === 'span' && this.gesture.edge === edge;
this.overlay.rect(x - 1, 0, 2, h).fill(held ? c.hot : c.accent);
this.overlay.rect(x - 1, WAVE_TOP, 2, h - WAVE_TOP).fill(held ? c.hot : c.accent);
const toward = edge === 'start' ? 1 : -1;
this.overlay
.moveTo(x, 0)
.lineTo(x + toward * 8, 0)
.lineTo(x, 10)
.moveTo(x, WAVE_TOP)
.lineTo(x + toward * 8, WAVE_TOP)
.lineTo(x, WAVE_TOP + 10)
.closePath()
.fill(held ? c.hot : c.accent);
}
@@ -558,6 +657,25 @@ export class WaveCanvas extends Widget {
for (let i = placed.length; i < this.scaleTexts.length; i++) this.scaleTexts[i]!.visible = false;
}
/** A clip's name inside its band, cut short with "…" where the band is narrower. */
private syncStrip(placed: readonly { text: string; x: number; room: number; lit: boolean }[]): void {
placed.forEach((label, i) => {
let text = this.stripTexts[i];
if (!text) {
text = crispText({ style: textStyle('small') });
this.stripTexts.push(text);
this.stripLabels.addChild(text);
}
text.style = label.lit ? LIT_LABEL : textStyle('small');
if (text.text !== label.text) text.text = label.text;
for (let cut = label.text.length; text.width > label.room && cut > 1; cut--)
text.text = `${label.text.slice(0, cut - 1)}…`;
text.visible = text.width <= label.room;
text.position.set(Math.round(label.x), Math.round(RULER_HEIGHT + (STRIP_HEIGHT - text.height) / 2));
});
for (let i = placed.length; i < this.stripTexts.length; i++) this.stripTexts[i]!.visible = false;
}
private syncLabels(placed: readonly { text: string; x: number }[]): void {
placed.forEach((label, i) => {
let text = this.texts[i];
+16 -4
View File
@@ -147,10 +147,22 @@ export function draggedSpan(
return b - a >= gap ? { start: a, end: b } : { start: a, end: Math.min(duration, a + gap) };
}
/**
* What an asset stores for a span: seconds, an end at the end of the file as 0 – so a span over the
* whole file is no cut at all, and plays the file itself.
*/
/** A clip of the sound as its band in the strip (#357). */
export interface ClipBand {
id: string;
span: CutSpan;
}
/** The clip whose band is at `x`, the one drawn last where bands lie over each other; null for none. */
export function clipAt(view: WaveView, bands: readonly ClipBand[], x: number): string | null {
for (let i = bands.length - 1; i >= 0; i--) {
const { id, span } = bands[i]!;
if (x >= secondsToX(view, span.start) && x <= secondsToX(view, span.end)) return id;
}
return null;
}
/** What a clip stores for a span: seconds to the nearest sample, an end at the end of the file as 0. */
export function storedCut(span: CutSpan, duration: number, rate: number): { start: number; end: number } {
const sample = 1 / rate;
const start = span.start < sample / 2 ? 0 : roundTo(span.start, rate);
+19 -16
View File
@@ -1,12 +1,12 @@
import type { Asset, SoundFxAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import { findSound } from '../../core/model/sound';
import { assetRefLabel, soundChoices } from '../../core/properties/assetRef';
import { playedSoundOf } from '../../core/properties/playedSound';
import { resolveAssetProps } from '../../core/properties/resolve';
import { volumeOf } from '../../core/properties/sounds';
import { soundFxBuildFor, soundFxParams } from '../../core/soundfx/compile';
import { Button, Dropdown, HStack, Label, Menu, NumberField, Stack, Theme } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { listedAssets } from '../../core/music/embedded';
import { playedBus } from '../../core/music/mixer';
import { AssetTabEditor } from '../AssetTabEditor';
import { GraphCanvas } from '../graph/GraphCanvas';
@@ -75,10 +75,10 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
return asset.type === 'soundfx';
}
/** The sound the header plays: the one picked, else the project's first. */
/** The sound or clip (#357) the header plays: the one picked, else the project's first. */
get previewSoundId(): string {
const sounds = listedAssets(this.state.project).filter((a) => a.type === 'sound');
return sounds.some((a) => a.id === this.soundId) ? this.soundId : (sounds[0]?.id ?? '');
const sounds = soundChoices(this.state.project);
return sounds.some((s) => s.id === this.soundId) ? this.soundId : (sounds[0]?.id ?? '');
}
pickSound(id: string): void {
@@ -101,14 +101,14 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
/** Plays the picked sound through the graph; a graph that does not compile is not played at all. */
play(): void {
const asset = this.asset();
const sound = findAssetOf(this.state.project, this.previewSoundId, 'sound');
const sound = findSound(this.state.project, this.previewSoundId);
const plan = asset && soundFxBuildFor(asset, this.state.project).compiled;
if (!sound?.file || !plan) return;
const props = resolveAssetProps(this.state.project, sound);
if (!sound?.asset.file || !plan) return;
const props = resolveAssetProps(this.state.project, sound.asset);
const fx = { plan, params: { ...this.values } };
// On the bus of the sound, as the game would play it there (#325).
const bus = playedBus(this.state.project, '', props);
const file = playedSoundOf(this.state.project, sound);
const file = playedSoundOf(sound);
const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus);
if (voice) this.voices.push(voice);
this.playedFor = asset.id;
@@ -137,11 +137,12 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
protected override syncExtras(asset: SoundFxAsset | null): void {
this.snap.setEnabled(Boolean(asset));
const sound = findAssetOf(this.state.project, this.previewSoundId, 'sound');
this.soundPicker.setText(sound?.name ?? 'No sound');
const id = this.previewSoundId;
const sound = findSound(this.state.project, id);
this.soundPicker.setText(sound ? assetRefLabel(this.state.project, id) : 'No sound');
this.soundPicker.setEnabled(Boolean(asset && sound));
this.playButton.setEnabled(
Boolean(asset && sound?.file && soundFxBuildFor(asset, this.state.project).compiled),
Boolean(asset && sound?.asset.file && soundFxBuildFor(asset, this.state.project).compiled),
);
this.stopButton.setEnabled(Boolean(asset));
this.syncParams(asset);
@@ -190,9 +191,11 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
private openSoundPicker(): void {
const current = this.previewSoundId;
const items = listedAssets(this.state.project)
.filter((a) => a.type === 'sound')
.map((a) => ({ label: a.name, current: a.id === current, onSelect: () => this.pickSound(a.id) }));
if (items.length > 0) new Menu(items).openBelow(this.soundPicker);
const items = soundChoices(this.state.project).map((s) => ({
label: s.label,
current: s.id === current,
onSelect: () => this.pickSound(s.id),
}));
if (items.length > 0) new Menu(items, { searchable: true }).openBelow(this.soundPicker);
}
}
+19 -3
View File
@@ -20,6 +20,7 @@ import {
type Project,
startScene,
} from '../core/model/Project';
import { clipsOf } from '../core/model/sound';
import { deserializeProject, serializeProject } from '../core/store/serialize';
import { requestRender } from '../ui';
import { consoleLog } from './console/log';
@@ -52,6 +53,8 @@ export type Selection =
}
/** A frame rectangle inside a texture asset (the Texture tab). */
| { kind: 'frame'; assetId: string; frameId: string }
/** A clip of a sound asset (the Sound tab, #357). */
| { kind: 'clip'; assetId: string; clipId: string }
/**
* One or more shapes of a vector art asset (the Vector art tab, #129). `shapeId` is the primary
* one, whose points and style are edited; `shapeIds` holds all of them for combining (#187).
@@ -727,6 +730,13 @@ export class EditorState {
else if (!asset.frames.some((f) => f.id === s.frameId))
this.select({ kind: 'asset', assetId: s.assetId });
}
if (s.kind === 'clip') {
const asset = findAssetOf(this.project, s.assetId, 'sound');
// As with a frame: the sound stays open when its clip vanished.
if (!asset) this.select(null);
else if (!clipsOf(asset).some((c) => c.id === s.clipId))
this.select({ kind: 'asset', assetId: s.assetId });
}
if (s.kind === 'shape') {
const asset = findAssetOf(this.project, s.assetId, 'vectorart');
// As with a frame: the drawing stays open when its shapes vanished (a combine, an undo).
@@ -810,13 +820,18 @@ export class EditorState {
return findAssetOf(this.project, s.assetId, 'curve')?.id ?? null;
}
/** The sound the selection refers to (#343); the stretch it is cut to is set in its tab. */
/** The sound the selection refers to (#343): the asset, or one of its clips (#357). */
get selectedSoundId(): string | null {
const s = this.selection;
if (s?.kind !== 'asset') return null;
if (s?.kind !== 'asset' && s?.kind !== 'clip') return null;
return findAssetOf(this.project, s.assetId, 'sound')?.id ?? null;
}
/** The clip of a sound the selection is about (#357); empty for the sound itself. */
get selectedClipId(): string {
return this.selection?.kind === 'clip' ? this.selection.clipId : '';
}
/** The pattern the selection refers to (#333); only the asset itself, its notes are picked in the tab. */
get selectedPatternId(): string | null {
const s = this.selection;
@@ -867,7 +882,7 @@ export class EditorState {
private selectedGraphId(type: GraphAsset['type']): string | null {
const s = this.selection;
if (!s) return null;
if (s.kind === 'frame') return null;
if (s.kind === 'frame' || s.kind === 'clip') return null;
// A node names its asset; which tab that is depends on what the asset is. A group, a comment
// and a link name it the same way, so selecting one keeps its graph on screen.
const asset =
@@ -945,6 +960,7 @@ export function sameSelection(a: Selection, b: Selection): boolean {
);
}
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
if (a.kind === 'clip' && b.kind === 'clip') return a.assetId === b.assetId && a.clipId === b.clipId;
if (a.kind === 'shape' && b.kind === 'shape')
return a.assetId === b.assetId && a.shapeId === b.shapeId && sameIds(a.shapeIds, b.shapeIds);
if (a.kind === 'track' && b.kind === 'track')