From 42a3759d9db78857d50faa3ef1a930b47b5de579 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 8 Oct 2026 20:18:47 +0200 Subject: [PATCH] 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 "?". - 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 --- src/core/commands/assetCommands.ts | 13 +- src/core/commands/clipCommands.ts | 143 +++++++++++++ src/core/instrument/compile.ts | 12 +- src/core/model/Asset.ts | 17 +- src/core/model/sound.ts | 83 +++++++- src/core/music/embedded.ts | 31 ++- src/core/music/song.ts | 17 +- src/core/properties/PropertySchema.ts | 46 +--- src/core/properties/assetRef.ts | 24 ++- src/core/properties/playedSound.ts | 21 +- src/core/properties/references.ts | 7 +- src/core/properties/sounds.ts | 25 ++- src/core/store/serialize.ts | 22 +- src/editor/Editor.ts | 4 +- src/editor/actions.ts | 4 + src/editor/debug/soundHooks.ts | 19 +- src/editor/inspector/Inspector.ts | 27 ++- src/editor/inspector/clipSection.ts | 72 +++++++ src/editor/inspector/fieldFactory.ts | 11 +- src/editor/inspector/songSection.ts | 29 ++- src/editor/song/SongEditor.ts | 6 +- src/editor/song/SongTimeline.ts | 27 +-- src/editor/sound/SoundEditor.ts | 103 ++++----- src/editor/sound/WaveCanvas.ts | 282 ++++++++++++++++++------- src/editor/sound/waveView.ts | 20 +- src/editor/soundfx/SoundFxEditor.ts | 35 +-- src/editor/state.ts | 22 +- src/runtime/systems/behaviorContext.ts | 17 +- tests/core/embedded.test.ts | 4 +- tests/core/sound-clips.test.ts | 219 +++++++++++++++++++ tests/core/sounds.test.ts | 43 ++-- tools/e2e/34-sound.mjs | 188 ++++++++++++----- 32 files changed, 1230 insertions(+), 363 deletions(-) create mode 100644 src/core/commands/clipCommands.ts create mode 100644 src/editor/inspector/clipSection.ts create mode 100644 tests/core/sound-clips.test.ts diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 9b86252f..39701130 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -18,7 +18,7 @@ import { heldSongGraph } from '../music/routing'; import { linearCurve } from '../model/curve'; import { isFontFile } from '../model/font'; import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart'; -import { isSoundFile } from '../model/sound'; +import { isSoundFile, renewClipIds } from '../model/sound'; import { removeSubtrees } from '../model/hierarchy'; import { dropReferencesToGone, renamedReference, shownObjectIds, swapReferences } from '../model/objectRefs'; import { type PropertyBag, type PropertyValue, setBagValue } from '../model/Properties'; @@ -192,7 +192,13 @@ export class DuplicateAssetCommand implements Command { /** Copies of the assets `from` keeps to itself, owned by `song`, whose places are pointed at them. */ function copyEmbedded(project: Project, from: string, song: SongAsset): Asset[] { - const ids = new Map(embeddedIn(project, from).map((a) => [a.id, newId()])); + const ids = new Map(); + const copies = embeddedIn(project, from).map((a) => { + const copy = { ...structuredClone(a), id: newId(), owner: song.id }; + ids.set(a.id, copy.id); + renewClipIds(copy, ids); + return copy; + }); const renamed = (id: string) => ids.get(id) ?? id; for (const track of song.tracks) track.fx = renamed(track.fx); for (const item of song.items) { @@ -201,7 +207,7 @@ function copyEmbedded(project: Project, from: string, song: SongAsset): Asset[] } const fx = song.props[ASSET_FX_KEY]; if (typeof fx === 'string') song.props[ASSET_FX_KEY] = renamed(fx); - return embeddedIn(project, from).map((a) => ({ ...structuredClone(a), id: renamed(a.id), owner: song.id })); + return copies; } /** A copy is a project asset of its own, even one of something a song kept to itself. */ @@ -210,6 +216,7 @@ function copyOf(original: Asset, id: string, name: string): Asset { delete copy.owner; if (copy.type === 'texture' || copy.type === 'pixelart') for (const frame of copy.frames) frame.id = newId(); + renewClipIds(copy, new Map()); if (copy.type === 'scene' || copy.type === 'container') { const ids = new Map(copy.objects.map((o) => [o.id, newId()])); for (const object of copy.objects) { diff --git a/src/core/commands/clipCommands.ts b/src/core/commands/clipCommands.ts new file mode 100644 index 00000000..72dca76f --- /dev/null +++ b/src/core/commands/clipCommands.ts @@ -0,0 +1,143 @@ +/** The clips of a sound (#357): stretches of its file with a name, as frames are of a texture. */ +import { newId } from '../ids'; +import type { SoundAsset, SoundClip } from '../model/Asset'; +import type { PropertyBag, PropertyValue } from '../model/Properties'; +import type { Project } from '../model/Project'; +import { clipsOf, nextClipName, uniqueClipName } from '../model/sound'; +import { CLIP_ROWS } from '../properties/sounds'; +import { type Removed, removeFirst, restoreAt } from '../util/list'; +import { type Command, CommandError } from './Command'; +import { requireAsset } from './require'; + +function requireSound(project: Project, assetId: string): SoundAsset { + const asset = requireAsset(project, assetId); + if (asset.type !== 'sound') throw new CommandError(`${asset.name} is not a sound`); + return asset; +} + +function requireClip(asset: SoundAsset, clipId: string): SoundClip { + const clip = clipsOf(asset).find((c) => c.id === clipId); + if (!clip) throw new CommandError(`Clip ${clipId} not found in ${asset.name}`); + return clip; +} + +/** What a clip may hold: a row of `CLIP_ROWS`, a finite number at least 0, a curve it offers. */ +function heldClipValue(key: string, value: PropertyValue): PropertyValue { + const def = CLIP_ROWS.find((row) => row.key === key); + if (!def) throw new CommandError(`A clip has no "${key}"`); + if (def.kind === 'number' && (typeof value !== 'number' || !Number.isFinite(value) || value < 0)) + throw new CommandError(`${def.label} needs a number of at least 0`); + if (def.kind === 'boolean' && typeof value !== 'boolean') + throw new CommandError(`${def.label} is on or off`); + if (def.kind === 'enum' && !def.options.some((option) => option.value === value)) + throw new CommandError(`${def.label} has no ${JSON.stringify(value)}`); + return value; +} + +/** `clipId` is known before execution, so the caller can select the new clip right after executing. */ +export class AddClipCommand implements Command { + readonly label = 'Add clip'; + readonly clipId = newId(); + + constructor( + private readonly assetId: string, + private readonly props: PropertyBag, + ) {} + + execute(project: Project): void { + const asset = requireSound(project, this.assetId); + const props: PropertyBag = {}; + for (const [key, value] of Object.entries(this.props)) props[key] = heldClipValue(key, value); + // Named per execution: a redo has to fit the names that exist when it runs. + asset.clips = [...clipsOf(asset), { id: this.clipId, name: nextClipName(asset), props }]; + } + + undo(project: Project): void { + const asset = requireSound(project, this.assetId); + asset.clips = clipsOf(asset).filter((c) => c.id !== this.clipId); + } +} + +/** + * Some of a clip's values in one step – a drag sets its start and its end together. The values to + * revert to are read at execution time. + */ +export class SetClipPropsCommand implements Command { + private previous: [string, PropertyValue | undefined][] = []; + + constructor( + private readonly assetId: string, + private readonly clipId: string, + private readonly values: PropertyBag, + readonly label = 'Set clip', + ) {} + + execute(project: Project): void { + const clip = requireClip(requireSound(project, this.assetId), this.clipId); + const held = Object.entries(this.values).map(([key, value]) => [key, heldClipValue(key, value)] as const); + this.previous = held.map(([key]) => [key, clip.props[key]]); + for (const [key, value] of held) clip.props[key] = value; + } + + undo(project: Project): void { + const clip = requireClip(requireSound(project, this.assetId), this.clipId); + for (const [key, value] of this.previous) + if (value === undefined) delete clip.props[key]; + else clip.props[key] = value; + } +} + +export class RenameClipCommand implements Command { + readonly label = 'Rename clip'; + private previous = ''; + + constructor( + private readonly assetId: string, + private readonly clipId: string, + private readonly name: string, + ) {} + + execute(project: Project): void { + const asset = requireSound(project, this.assetId); + const clip = requireClip(asset, this.clipId); + const name = this.name.trim(); + if (!name) throw new CommandError('A clip needs a name'); + this.previous = clip.name; + clip.name = uniqueClipName(asset, name, this.clipId); + } + + undo(project: Project): void { + requireClip(requireSound(project, this.assetId), this.clipId).name = this.previous; + } +} + +/** + * Undo puts it back where it was. What plays it is left pointing at it, as a sprite is at a deleted + * frame: the reference reads as missing and comes back by itself on undo. + */ +export class RemoveClipCommand implements Command { + readonly label = 'Delete clip'; + private removed?: Removed; + + constructor( + private readonly assetId: string, + private readonly clipId: string, + ) {} + + execute(project: Project): void { + const asset = requireSound(project, this.assetId); + const clips = [...clipsOf(asset)]; + const removed = removeFirst(clips, (c) => c.id === this.clipId); + if (!removed) throw new CommandError(`Clip ${this.clipId} not found in ${asset.name}`); + this.removed = removed; + asset.clips = clips; + } + + undo(project: Project): void { + if (!this.removed) return; + const asset = requireSound(project, this.assetId); + const clips = [...clipsOf(asset)]; + restoreAt(clips, [this.removed]); + asset.clips = clips; + } +} diff --git a/src/core/instrument/compile.ts b/src/core/instrument/compile.ts index 5f56999d..177f701e 100644 --- a/src/core/instrument/compile.ts +++ b/src/core/instrument/compile.ts @@ -15,6 +15,7 @@ import type { InstrumentAsset } from '../model/Asset'; import { graphLookup } from '../model/graphLookup'; import { textOf } from '../model/Properties'; import { findAssetOf, type Project } from '../model/Project'; +import { findSound } from '../model/sound'; import { playedSoundOf } from '../properties/playedSound'; import { resolveAssetProps } from '../properties/resolve'; import { volumeOf } from '../properties/sounds'; @@ -77,19 +78,18 @@ export function instrumentSourcesOf(project: Project): InstrumentSources { return { lookup: graphLookup(project), sound: (assetId) => { - const asset = findAssetOf(project, assetId, 'sound'); - if (!asset) return null; + const ref = findSound(project, assetId); + if (!ref) return null; // An asset without a file stays without one, so the Sample can say so. - const file = asset.file ? playedSoundOf(project, asset) : ''; - return { file, volume: volumeOf(resolveAssetProps(project, asset)) }; + return { file: playedSoundOf(ref), volume: volumeOf(resolveAssetProps(project, ref.asset)) }; }, soundFx: (assetId) => { const asset = findAssetOf(project, assetId, 'soundfx'); return asset ? soundFxBuildFor(asset, project).compiled : null; }, key: (assetId) => { - const sound = findAssetOf(project, assetId, 'sound'); - if (sound) return `${playedSoundOf(project, sound)}@${volumeOf(resolveAssetProps(project, sound))}`; + const sound = findSound(project, assetId); + if (sound) return `${playedSoundOf(sound)}@${volumeOf(resolveAssetProps(project, sound.asset))}`; const fx = findAssetOf(project, assetId, 'soundfx'); return fx ? soundFxKey(fx, project) : ''; }, diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index e24d4f01..d21586e9 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -308,11 +308,26 @@ export interface FontAsset extends AssetBase { /** * `file` names the sound in `data/sounds/` the way a font names its typeface: content-addressed * `{folder}/{sha256}.{ext}`, empty when the upload failed. Nothing wears a sound – a graph plays it with - * `Play sound`, and `props` says how loud and whether it repeats (see `properties/sounds.ts`). + * `Play sound`, and `props` says how loud and whether it repeats (see `properties/sounds.ts`). The + * sound itself is the whole file; its clips are stretches of it (#357). */ export interface SoundAsset extends AssetBase { type: 'sound'; file: string; + /** Absent for a sound without clips. */ + clips?: SoundClip[]; +} + +/** + * A stretch of a sound's file with a name (#357), as a frame is a cut-out of a texture. `id` shares + * the generator with asset ids, so a sound field holds either. `props` are the cut's: start, end, + * fades, their curves and reverse (see `properties/sounds.ts`); volume, loop, bus and FX stay the + * sound's. + */ +export interface SoundClip { + id: string; + name: string; + props: PropertyBag; } /** diff --git a/src/core/model/sound.ts b/src/core/model/sound.ts index 01b5f5eb..6d573673 100644 --- a/src/core/model/sound.ts +++ b/src/core/model/sound.ts @@ -1,3 +1,6 @@ +import { nextFreeName, nextNumberedName } from '../commands/naming'; +import { newId } from '../ids'; +import type { Asset, SoundAsset, SoundClip } from './Asset'; import { findAsset, type Project } from './Project'; import { storedFilePattern } from './storedFile'; @@ -7,11 +10,85 @@ export function isSoundFile(name: string): boolean { return FILE_PATTERN.test(name); } +export const CLIP_SEPARATOR = ' / '; +export const DEFAULT_CLIP_NAME = 'Clip'; + +export function clipsOf(asset: SoundAsset): readonly SoundClip[] { + return asset.clips ?? []; +} + +/** What a sound field names (#357): a sound, the whole file, or one of its clips. */ +export interface SoundRef { + asset: SoundAsset; + clip: SoundClip | null; +} + +/** + * Clip ids share the generator with asset ids and are unique across the document, which lets a + * sound field hold either – as a texture field holds a texture or one of its frames. + */ +export function findSound(project: Project, id: string): SoundRef | null { + if (id === '') return null; + const asset = findAsset(project, id); + if (asset) return asset.type === 'sound' ? { asset, clip: null } : null; + for (const sound of project.assets) { + if (sound.type !== 'sound') continue; + const clip = clipsOf(sound).find((c) => c.id === id); + if (clip) return { asset: sound, clip }; + } + return null; +} + +/** The asset among `assets` an id names: the asset itself, or the sound one of whose clips it is. */ +export function assetNamedBy(assets: readonly Asset[], id: string): Asset | undefined { + if (id === '') return undefined; + return assets.find((a) => a.id === id || (a.type === 'sound' && clipsOf(a).some((c) => c.id === id))); +} + +/** + * Gives the clips of a copy fresh ids, since a clip's id is named across the document, and notes + * each old id with its new one in `ids` so the places that named a clip can follow. + */ +export function renewClipIds(copy: Asset, ids: Map): void { + if (copy.type !== 'sound') return; + for (const clip of clipsOf(copy)) { + const id = newId(); + ids.set(clip.id, id); + clip.id = id; + } +} + +/** Every id that means "this sound": the sound itself and each of its clips. */ +export function soundRefIds(asset: SoundAsset): string[] { + return [asset.id, ...clipsOf(asset).map((c) => c.id)]; +} + +/** "Kick" for the sound, "Kick / Clip 1" for a clip of it. */ +export function soundRefName(ref: SoundRef, soundName = ref.asset.name): string { + return ref.clip ? `${soundName}${CLIP_SEPARATOR}${ref.clip.name}` : soundName; +} + +/** "Clip 1", 2, 3 …: the first number none of the sound's clips has. */ +export function nextClipName(asset: SoundAsset): string { + return nextNumberedName( + clipsOf(asset).map((c) => c.name), + DEFAULT_CLIP_NAME, + ); +} + +/** A name free among the sound's clips: `name`, else "name 2", "name 3" … */ +export function uniqueClipName(asset: SoundAsset, name: string, ignoreId = ''): string { + return nextFreeName( + clipsOf(asset) + .filter((c) => c.id !== ignoreId) + .map((c) => c.name), + name, + ); +} + /** The file a `sound` value points at, empty when the id is unset or no longer resolves. */ export function soundFileOf(project: Project, id: string): string { - if (id === '') return ''; - const asset = findAsset(project, id); - return asset?.type === 'sound' ? asset.file : ''; + return findSound(project, id)?.asset.file ?? ''; } /** The shape of the ramp at a cut edge (#345), from silence to full; the end runs it backwards. */ diff --git a/src/core/music/embedded.ts b/src/core/music/embedded.ts index 464efd4a..1fd3b214 100644 --- a/src/core/music/embedded.ts +++ b/src/core/music/embedded.ts @@ -12,6 +12,7 @@ import type { Asset, PatternAsset, SongAsset } from '../model/Asset'; import type { AssetType } from '../model/AssetTypes'; import type { PropertyBag } from '../model/Properties'; import { findAsset, findAssetOf, type Project } from '../model/Project'; +import { assetNamedBy, renewClipIds } from '../model/sound'; import { ASSET_FX_KEY, assetFxOf, NO_FX } from '../properties/assetFx'; import { nextNumberedName } from '../commands/naming'; import { PATTERN_BEAT_KEY, PATTERN_BEATS_KEY, PATTERN_TEMPO_KEY, patternInstrument } from './pattern'; @@ -91,9 +92,15 @@ function ownedBy(asset: T, song: SongAsset): T { return asset; } -/** A copy of a project asset for the song to keep to itself (#355), to bend without touching the rest. */ -export function embeddedCopy(asset: Asset, song: SongAsset): Asset { - return ownedBy({ ...structuredClone(asset), id: newId() }, song); +/** + * A copy of a project asset for the song to keep to itself (#355), to bend without touching the rest. + * `ids` says what the asset and its clips (#357) are called in the copy. + */ +export function embeddedCopy(asset: Asset, song: SongAsset): { copy: Asset; ids: Map } { + const copy = ownedBy({ ...structuredClone(asset), id: newId() }, song); + const ids = new Map([[asset.id, copy.id]]); + renewClipIds(copy, ids); + return { copy, ids }; } /** The patterns that follow `key` of an asset: those a song keeps, when the key is part of its clock. */ @@ -164,8 +171,10 @@ function slotsOf(props: SongAsset['props'], tracks: SongTrack[], items: SongItem /** The embedded assets the places of these items name, wherever they belong: what a copy of them takes along. */ export function embeddedUsedBy(project: Project, items: readonly SongItem[]): Asset[] { - const ids = new Set(items.flatMap((i) => [i.asset, i.fx])); - return project.assets.filter((a) => isEmbedded(a) && ids.has(a.id)); + const named = new Set( + items.flatMap((i) => [i.asset, i.fx]).map((id) => assetNamedBy(project.assets, id)?.id), + ); + return project.assets.filter((a) => isEmbedded(a) && named.has(a.id)); } export interface Settled { @@ -194,20 +203,24 @@ export function settleEmbedded( const own = { ...song.props }; slotsOf(own, tracks, items).forEach((slot, index) => { if (slot.id === '' || slot.id === NO_FX) return; - const found = findAsset(project, slot.id) ?? added.find((a) => a.id === slot.id); - const source = found ?? spare.find((a) => a.id === slot.id); + // A place may name a clip of a sound (#357): the sound is what it claims. + const found = assetNamedBy(project.assets, slot.id) ?? assetNamedBy(added, slot.id); + const source = found ?? assetNamedBy(spare, slot.id); if (!source || !slot.types.includes(source.type) || (found && !isEmbedded(found))) return; if (found?.owner === song.id && !claimed.has(found.id)) { claimed.add(found.id); return; } if (index === 0) return; - const id = found || taken.has(source.id) ? newId() : source.id; + const fresh = found !== undefined || taken.has(source.id); + const id = fresh ? newId() : source.id; const copy = ownedBy({ ...structuredClone(source), id }, song); + const ids = new Map([[source.id, id]]); + if (fresh) renewClipIds(copy, ids); taken.add(id); added.push(copy); claimed.add(id); - slot.set(id); + slot.set(ids.get(slot.id) ?? id); }); const dropped = project.assets.filter((a) => a.owner === song.id && !claimed.has(a.id)).map((a) => a.id); return { added, dropped }; diff --git a/src/core/music/song.ts b/src/core/music/song.ts index 1dd7bce5..c2496bf7 100644 --- a/src/core/music/song.ts +++ b/src/core/music/song.ts @@ -7,9 +7,10 @@ */ import { newId } from '../ids'; import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes'; -import type { PatternAsset, SongAsset } from '../model/Asset'; +import type { PatternAsset, SongAsset, SoundAsset } from '../model/Asset'; import { type PropertyBag, textOf } from '../model/Properties'; -import { findAsset, findAssetOf, type Project } from '../model/Project'; +import { findAssetOf, type Project } from '../model/Project'; +import { findSound } from '../model/sound'; import { pickedFx } from '../properties/assetFx'; import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern'; import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence'; @@ -148,8 +149,14 @@ export function dbToGain(db: number): number { /** The sound FX an item goes through (#352): its own pick, else the one of what it plays; '' for none. */ export function itemFx(project: Project, item: SongItem): string { - const played = findAsset(project, item.asset); - return pickedFx(item.fx, played?.type === item.kind ? played.props : {}); + return pickedFx(item.fx, itemAsset(project, item)?.props ?? {}); +} + +/** The asset an item plays: its pattern, or its sound – also when it plays a clip of it (#357). */ +export function itemAsset(project: Project, item: SongItem): PatternAsset | SoundAsset | undefined { + return item.kind === 'sound' + ? findSound(project, item.asset)?.asset + : findAssetOf(project, item.asset, 'pattern'); } /** Whether a pattern item is heard on a bus of its own (#346): it goes through a sound FX that is there. */ @@ -207,7 +214,7 @@ export function songSequence(project: Project, song: SongAsset): Sequence { const volume = dbToGain(item.volume); const bus = hasItemBus(project, item) ? itemBus(song.id, item) : trackItemsBus(song.id, item.track); if (item.kind === 'sound') { - if (!findAssetOf(project, item.asset, 'sound')?.file) continue; + if (!findSound(project, item.asset)?.asset.file) continue; clips.push({ tick: item.tick, length: item.length, diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 74971532..475494eb 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -103,20 +103,7 @@ import { } from './particles'; import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders'; import { SHADER_RESOLUTION_KEY, SHADER_RESOLUTION_OPTIONS } from './cameraShader'; -import { FADE_CURVES } from '../model/sound'; -import { - DEFAULT_FADE_CURVE, - DEFAULT_FADE_MS, - END_KEY as SOUND_END_KEY, - FADE_IN_CURVE_KEY as SOUND_FADE_IN_CURVE_KEY, - FADE_IN_KEY as SOUND_FADE_IN_KEY, - FADE_OUT_CURVE_KEY as SOUND_FADE_OUT_CURVE_KEY, - FADE_OUT_KEY as SOUND_FADE_OUT_KEY, - LOOP_KEY as SOUND_LOOP_KEY, - REVERSE_KEY as SOUND_REVERSE_KEY, - START_KEY as SOUND_START_KEY, - VOLUME_KEY as SOUND_VOLUME_KEY, -} from './sounds'; +import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './sounds'; import { BEAT_OPTIONS, DEFAULT_BARS, @@ -717,39 +704,10 @@ const ASSET_SCHEMAS: Record = { font: [filterRow(true)], // The file is intrinsic data (see SoundAsset); volume and loop say how it is played by default, and // a `Play sound` node scales the one and can add to the other. + // The sound is the whole file; a clip of it is a stretch with a cut of its own (#357, `CLIP_ROWS`). sound: [ { key: SOUND_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 }, { key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false }, - // The stretch of the file it is (#343), set in its tab; an end of 0 is the end of the file. - { key: SOUND_START_KEY, label: 'Start (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 }, - { key: SOUND_END_KEY, label: 'End (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 }, - // That stretch backwards (#321), its fade in at the end it starts from. - { key: SOUND_REVERSE_KEY, label: 'Reverse', kind: 'boolean', default: false }, - // The ramps at a start and an end inside the file, each with its shape (#345). - ...( - [ - [SOUND_FADE_IN_KEY, 'Fade in (ms)', SOUND_FADE_IN_CURVE_KEY, 'Fade in curve'], - [SOUND_FADE_OUT_KEY, 'Fade out (ms)', SOUND_FADE_OUT_CURVE_KEY, 'Fade out curve'], - ] as const - ).flatMap(([key, label, curveKey, curveLabel]) => [ - { - key, - label, - kind: 'number' as const, - default: DEFAULT_FADE_MS, - min: 0, - max: 1000, - step: 1, - decimals: 0, - }, - { - key: curveKey, - label: curveLabel, - kind: 'enum' as const, - default: DEFAULT_FADE_CURVE, - options: FADE_CURVES, - }, - ]), FX_ROW, BUS_ROW, ], diff --git a/src/core/properties/assetRef.ts b/src/core/properties/assetRef.ts index 4ee1e49a..eb8b25a8 100644 --- a/src/core/properties/assetRef.ts +++ b/src/core/properties/assetRef.ts @@ -1,6 +1,7 @@ -import type { Asset } from '../model/Asset'; +import type { Asset, SoundAsset } from '../model/Asset'; import { type AssetType, isPlaceableType } from '../model/AssetTypes'; import { findAsset, type Project } from '../model/Project'; +import { clipsOf, findSound, soundRefName } from '../model/sound'; import { listedAssets, markedName } from '../music/embedded'; import { NO_FX } from './assetFx'; import { MASTER_BUS } from './buses'; @@ -17,7 +18,26 @@ export function assetRefLabel(project: Project | null, id: string): string { if (id === '' || id === NO_FX) return NO_ASSET_LABEL; if (id === MASTER_BUS) return MASTER_LABEL; const asset = project ? findAsset(project, id) : undefined; - return asset ? markedName(asset) : `${id}?`; + if (asset) return markedName(asset); + // A clip of a sound (#357) reads "Sound / Clip", as a frame does. + const clip = project ? findSound(project, id) : null; + return clip ? soundRefName(clip, markedName(clip.asset)) : `${id}?`; +} + +/** What a sound field offers (#357): each sound, followed by its clips. */ +export function soundChoices(project: Project): { id: string; label: string }[] { + return assetRefChoices(project, ['sound']).flatMap((asset) => + asset.type === 'sound' ? soundEntries(asset) : [], + ); +} + +/** A sound and its clips as choices, "Kick", "Kick / Clip 1" … */ +export function soundEntries(asset: SoundAsset): { id: string; label: string }[] { + const name = markedName(asset); + return [ + { id: asset.id, label: name }, + ...clipsOf(asset).map((clip) => ({ id: clip.id, label: soundRefName({ asset, clip }, name) })), + ]; } /** diff --git a/src/core/properties/playedSound.ts b/src/core/properties/playedSound.ts index 85417038..c2acab88 100644 --- a/src/core/properties/playedSound.ts +++ b/src/core/properties/playedSound.ts @@ -1,19 +1,18 @@ -import type { SoundAsset } from '../model/Asset'; -import { findAssetOf, type Project } from '../model/Project'; -import { cutName } from '../model/sound'; -import { resolveAssetProps } from './resolve'; +import type { Project } from '../model/Project'; +import { cutName, findSound, type SoundRef } from '../model/sound'; import { cutOf } from './sounds'; /** - * What a sound asset is played by (#343): its file, or the stretch of it the asset is cut to. Every - * way a sound is heard asks this rather than for the file – the cut is the sample. + * What a sound is played by: the whole file for the sound itself, the stretch of it a clip is cut to + * for one of its clips (#357). Every way a sound is heard asks this rather than for the file – the + * cut is the sample. */ -export function playedSoundOf(project: Project, asset: SoundAsset): string { - return cutName(asset.file, cutOf(resolveAssetProps(project, asset))); +export function playedSoundOf(ref: SoundRef): string { + return ref.clip ? cutName(ref.asset.file, cutOf(ref.clip.props)) : ref.asset.file; } -/** The same by id, empty when the id is unset or no longer names a sound. */ +/** The same by id, empty when the id is unset or names neither a sound nor a clip of one. */ export function playedSoundById(project: Project, id: string): string { - const asset = id === '' ? undefined : findAssetOf(project, id, 'sound'); - return asset ? playedSoundOf(project, asset) : ''; + const ref = findSound(project, id); + return ref ? playedSoundOf(ref) : ''; } diff --git a/src/core/properties/references.ts b/src/core/properties/references.ts index 6d109130..8073630b 100644 --- a/src/core/properties/references.ts +++ b/src/core/properties/references.ts @@ -1,20 +1,23 @@ import { type PropertyBag, type PropertyValue, textOf } from '../model/Properties'; import { findPixelFrame } from '../model/pixelart'; import { findAsset, type Project } from '../model/Project'; +import { findSound } from '../model/sound'; import { findFrame } from '../model/texture'; import { isKindWith } from './kinds'; import type { PropertyDef } from './PropertySchema'; /** * The asset a reference field points at, null when it names nothing or no longer resolves. A frame - * names the texture or pixel art it was cut from – that is the asset one can open (#221). + * names the texture or pixel art it was cut from – that is the asset one can open (#221) – and a + * clip its sound. */ export function referencedAssetId(project: Project, def: PropertyDef, value: PropertyValue): string | null { if (!isKindWith('reference', def.kind)) return null; const id = textOf(value); if (id === '') return null; if (findAsset(project, id)) return id; - if (def.kind !== 'texture') return null; + // A clip (#357) names the sound it was cut from, as a frame does. + if (def.kind !== 'texture') return findSound(project, id)?.asset.id ?? null; return findFrame(project, id)?.asset.id ?? findPixelFrame(project, id)?.asset.id ?? null; } diff --git a/src/core/properties/sounds.ts b/src/core/properties/sounds.ts index 6d8f781a..9ba295d4 100644 --- a/src/core/properties/sounds.ts +++ b/src/core/properties/sounds.ts @@ -1,12 +1,13 @@ import { booleanOf, type PropertyBag } from '../model/Properties'; -import { type FadeCurve, isFadeCurve, type SoundCut } from '../model/sound'; +import { FADE_CURVES, type FadeCurve, isFadeCurve, type SoundCut } from '../model/sound'; import { coerceAs } from './kinds'; +import type { PropertyDef } from './PropertySchema'; /** How loud this sound is by itself, 0–1; a `Play sound` node scales this rather than replacing it. */ export const VOLUME_KEY = 'volume'; /** Whether this sound is music: it repeats until something stops it. */ export const LOOP_KEY = 'loop'; -/** Where in its file the sound starts and ends, in seconds; an end of 0 is the end of the file (#343). */ +/** Where in its file a clip starts and ends, in seconds; an end of 0 is the end of the file (#343). */ export const START_KEY = 'start'; export const END_KEY = 'end'; /** Milliseconds the start and the end ramp over when they lie inside the file, so they do not click (#345). */ @@ -20,6 +21,24 @@ export const DEFAULT_FADE_CURVE: FadeCurve = 'linear'; /** Whether the stretch plays backwards (#321). */ export const REVERSE_KEY = 'reverse'; +/** The rows of a clip of a sound (#357): the stretch it is, set in the Sound tab, and how it is played. */ +export const CLIP_ROWS: readonly PropertyDef[] = [ + { key: START_KEY, label: 'Start (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 }, + { key: END_KEY, label: 'End (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 }, + // That stretch backwards (#321), its fade in at the end it starts from. + { key: REVERSE_KEY, label: 'Reverse', kind: 'boolean', default: false }, + // The ramps at a start and an end inside the file, each with its shape (#345). + ...( + [ + [FADE_IN_KEY, 'Fade in (ms)', FADE_IN_CURVE_KEY, 'Fade in curve'], + [FADE_OUT_KEY, 'Fade out (ms)', FADE_OUT_CURVE_KEY, 'Fade out curve'], + ] as const + ).flatMap(([key, label, curveKey, curveLabel]): PropertyDef[] => [ + { key, label, kind: 'number', default: DEFAULT_FADE_MS, min: 0, max: 1000, step: 1, decimals: 0 }, + { key: curveKey, label: curveLabel, kind: 'enum', default: DEFAULT_FADE_CURVE, options: FADE_CURVES }, + ]), +]; + export function volumeOf(bag: PropertyBag): number { return coerceAs(bag[VOLUME_KEY], 'number', 1); } @@ -28,7 +47,7 @@ export function loopsOf(bag: PropertyBag): boolean { return booleanOf(bag[LOOP_KEY]); } -/** The stretch of its file a sound is, its fades in seconds. */ +/** The stretch of its file a clip is, its fades in seconds. */ export function cutOf(bag: PropertyBag): SoundCut { const seconds = (key: string, fallback: number) => Math.max(0, coerceAs(bag[key], 'number', fallback)); const curve = (key: string) => { diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 041c41c6..f87fcb29 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -39,6 +39,7 @@ import type { PixelFrame, PixelLayer, SceneAsset, + SoundClip, StateMachineAsset, TextureFrame, GradientStop, @@ -112,6 +113,7 @@ import { variableDef, } from '../properties/PropertySchema'; import { FIELD_SHADER_KEY, particleParamDefs } from '../properties/particles'; +import { CLIP_ROWS } from '../properties/sounds'; import { SHADER_KEY, shaderParamDefs } from '../properties/shaders'; /** @@ -258,7 +260,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { assets.push({ id, type, name, props, file }); } else if (type === 'sound') { const file = typeof a.file === 'string' && isSoundFile(a.file) ? a.file : ''; - assets.push({ id, type, name, props, file }); + const clips = normalizeClips(a.clips, takenIds); + assets.push({ id, type, name, props, file, ...(clips.length > 0 && { clips }) }); } else if (type === 'tile') { // A picture naming nothing is kept, like an animation's dangling root: it reads as missing // in the tab and comes back when the texture it named is restored by an undo. @@ -738,6 +741,23 @@ function normalizeTexture( return { file, width, height, frames }; } +/** The clips of a sound (#357); like a frame's, a clip's id may not repeat one the document has. */ +function normalizeClips(raw: unknown, takenIds: Set): SoundClip[] { + const clips: SoundClip[] = []; + for (const item of Array.isArray(raw) ? raw : []) { + const c = asRecord(item); + const id = typeof c.id === 'string' ? c.id : ''; + if (!id || takenIds.has(id)) continue; + takenIds.add(id); + clips.push({ + id, + name: typeof c.name === 'string' ? c.name : '', + props: normalizeBag(c.props, CLIP_ROWS, true), + }); + } + return clips; +} + /** * The nine-slice and the sort line of a texture frame or a drawing (#308), measured against `size`. * A picture without them is one nobody sliced, and keeps saying so. diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index c8186d4e..e2611e01 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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}`); diff --git a/src/editor/actions.ts b/src/editor/actions.ts index ebdfc871..9be3e9cb 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -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; diff --git a/src/editor/debug/soundHooks.ts b/src/editor/debug/soundHooks.ts index 74744a40..97346d49 100644 --- a/src/editor/debug/soundHooks.ts +++ b/src/editor/debug/soundHooks.ts @@ -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) => { diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index 3b41b1bd..55999ae7 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -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(); } } diff --git a/src/editor/inspector/clipSection.ts b/src/editor/inspector/clipSection.ts new file mode 100644 index 00000000..8e222f55 --- /dev/null +++ b/src/editor/inspector/clipSection.ts @@ -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(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'); +} diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 2323b9de..bab336ad 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -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 = { ...(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 = { 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, diff --git a/src/editor/inspector/songSection.ts b/src/editor/inspector/songSection.ts index 63617462..a7f4cf64 100644 --- a/src/editor/inspector/songSection.ts +++ b/src/editor/inspector/songSection.ts @@ -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); diff --git a/src/editor/song/SongEditor.ts b/src/editor/song/SongEditor.ts index cf92ef3b..bd90af33 100644 --- a/src/editor/song/SongEditor.ts +++ b/src/editor/song/SongEditor.ts @@ -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 { 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, [ diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index aabd5157..b9c89828 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -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 diff --git a/src/editor/sound/SoundEditor.ts b/src/editor/sound/SoundEditor.ts index b08a18fa..53c58f3f 100644 --- a/src/editor/sound/SoundEditor.ts +++ b/src/editor/sound/SoundEditor.ts @@ -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 { /** 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 { 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 { 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 { 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 { 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 { } 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 { 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 { } 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 { 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(); } } diff --git a/src/editor/sound/WaveCanvas.ts b/src/editor/sound/WaveCanvas.ts index ec3f6c54..a2f31eb2 100644 --- a/src/editor/sound/WaveCanvas.ts +++ b/src/editor/sound/WaveCanvas.ts @@ -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, 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]; diff --git a/src/editor/sound/waveView.ts b/src/editor/sound/waveView.ts index c76b7298..785aaa88 100644 --- a/src/editor/sound/waveView.ts +++ b/src/editor/sound/waveView.ts @@ -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); diff --git a/src/editor/soundfx/SoundFxEditor.ts b/src/editor/soundfx/SoundFxEditor.ts index 962d6be1..ee31125a 100644 --- a/src/editor/soundfx/SoundFxEditor.ts +++ b/src/editor/soundfx/SoundFxEditor.ts @@ -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 { 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 { /** 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 { 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 { 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); } } diff --git a/src/editor/state.ts b/src/editor/state.ts index ff9e2b3e..c9de12af 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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') diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index a3543715..3f21f7e8 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -10,6 +10,7 @@ import type { Vec2 } from '../../core/model/Properties'; import { JOINT_CONNECTED_KEY } from '../../core/model/joint'; import { objectReference } from '../../core/model/objectRefs'; import { findAsset, findAssetOf, findScene, type Project } from '../../core/model/Project'; +import { findSound } from '../../core/model/sound'; import { renderLayerAlong } from '../../core/model/renderLayers'; import { pickedFx } from '../../core/properties/assetFx'; import { playedSoundById, playedSoundOf } from '../../core/properties/playedSound'; @@ -283,13 +284,13 @@ function playSound( tempo: number, bus: string, ): number { - const asset = findAssetOf(project, assetId, 'sound'); - if (!asset?.file) return 0; - const props = resolveAssetProps(project, asset); + const ref = findSound(project, assetId); + if (!ref?.asset.file) return 0; + const props = resolveAssetProps(project, ref.asset); const level = Math.max(0, Math.min(1, volumeOf(props) * volume)); const fx = fxVoiceOf(project, pickedFx(fxId, props), tempo, fxParams); return audio.play( - playedSoundOf(project, asset), + playedSoundOf(ref), level, loop ?? loopsOf(props), owner, @@ -463,14 +464,14 @@ export function playClip( { project, audio }: Pick, clip: ScheduledClip, ): number { - const asset = findAssetOf(project, clip.sound, 'sound'); - if (!asset?.file) return 0; + const ref = findSound(project, clip.sound); + if (!ref?.asset.file) return 0; const volume = Math.max( 0, - Math.min(dbToGain(SOUND_ITEM_MAX_DB), volumeOf(resolveAssetProps(project, asset)) * clip.volume), + Math.min(dbToGain(SOUND_ITEM_MAX_DB), volumeOf(resolveAssetProps(project, ref.asset)) * clip.volume), ); return audio.playClip( - playedSoundOf(project, asset), + playedSoundOf(ref), { at: clip.at, offset: clip.offset, duration: clip.duration, volume, bus: clip.bus }, clipFx(project, clip.fx, clip.tempo), ); diff --git a/tests/core/embedded.test.ts b/tests/core/embedded.test.ts index 6d774768..7f9bf8f2 100644 --- a/tests/core/embedded.test.ts +++ b/tests/core/embedded.test.ts @@ -157,7 +157,7 @@ describe('an embedded asset (#355)', () => { it('is embedded as a copy of a project asset, which stays', () => { const { f, song, blipId, edit, track } = setup(); - const copy = embeddedCopy(findAsset(f.project, blipId)!, song()); + const { copy } = embeddedCopy(findAsset(f.project, blipId)!, song()); edit([newItem({ track, kind: 'sound', asset: copy.id, tick: 0, length: BAR })], { spare: [copy] }); expect(song().items[0]!.asset).toBe(copy.id); expect(findAsset(f.project, copy.id)?.owner).toBe(song().id); @@ -311,7 +311,7 @@ describe('a pattern made in a song (#355)', () => { it("takes the clock of the song it comes to as a copy, a project pattern's or another song's", () => { const { f, song, loose, track, edit } = withPatterns(); - const copy = embeddedCopy(loose, song()) as PatternAsset; + const copy = embeddedCopy(loose, song()).copy as PatternAsset; expect([patternTempo(copy.props), patternTempo(loose.props)]).toEqual([140, 120]); const other = new CreateAssetCommand('song', 'Other'); f.stack.execute(other); diff --git a/tests/core/sound-clips.test.ts b/tests/core/sound-clips.test.ts new file mode 100644 index 00000000..133ab71b --- /dev/null +++ b/tests/core/sound-clips.test.ts @@ -0,0 +1,219 @@ +/** + * The clips of a sound (#357): stretches of its file with a name and a cut of their own, named by a + * sound field the way a frame is by a texture field. The sound itself is the whole file. + */ +import { describe, expect, it } from 'vitest'; +import { + CreateAssetCommand, + CreateSoundCommand, + DuplicateAssetCommand, +} from '../../src/core/commands/assetCommands'; +import { + AddClipCommand, + RemoveClipCommand, + RenameClipCommand, + SetClipPropsCommand, +} from '../../src/core/commands/clipCommands'; +import { SetSongCommand } from '../../src/core/commands/songCommands'; +import type { SoundAsset } from '../../src/core/model/Asset'; +import { findAsset, findAssetOf } from '../../src/core/model/Project'; +import { clipsOf, findSound } from '../../src/core/model/sound'; +import { embeddedCopy, embeddedUsedBy } from '../../src/core/music/embedded'; +import { sequenceNeeds } from '../../src/core/music/needs'; +import { itemFx, newItem, songSequence } from '../../src/core/music/song'; +import { assetRefLabel, soundChoices } from '../../src/core/properties/assetRef'; +import { playedSoundById } from '../../src/core/properties/playedSound'; +import { referencedAssetId } from '../../src/core/properties/references'; +import { END_KEY, START_KEY } from '../../src/core/properties/sounds'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { clipAt } from '../../src/editor/sound/waveView'; +import { makeProject } from '../helpers'; + +const FILE = `${'d5'.repeat(32)}.wav`; +const BAR = 1920; + +/** A sound "Kick" with two clips, and a song with a take of its own that has one clip. */ +function setup() { + const f = makeProject(); + const kick = new CreateSoundCommand('Kick', FILE); + f.stack.execute(kick); + const first = new AddClipCommand(kick.asset.id, { [START_KEY]: 0.5, [END_KEY]: 0.75 }); + const second = new AddClipCommand(kick.asset.id, { [START_KEY]: 1 }); + f.stack.execute(first); + f.stack.execute(second); + const made = new CreateAssetCommand('song', 'Theme'); + f.stack.execute(made); + const songId = made.asset.id; + const song = () => findAssetOf(f.project, songId, 'song')!; + const take = new CreateSoundCommand('Take 1', FILE, songId); + f.stack.execute(take); + const takeClip = new AddClipCommand(take.asset.id, { [START_KEY]: 2, [END_KEY]: 3 }); + f.stack.execute(takeClip); + const track = song().tracks[0]!.id; + const item = newItem({ track, kind: 'sound', asset: takeClip.clipId, tick: 0, length: BAR }); + f.stack.execute(new SetSongCommand(songId, { items: [item] })); + return { + f, + song, + songId, + track, + kick: () => findAssetOf(f.project, kick.asset.id, 'sound')!, + firstId: first.clipId, + secondId: second.clipId, + takeId: take.asset.id, + takeClipId: takeClip.clipId, + }; +} + +describe('a clip of a sound (#357)', () => { + it('is named "Clip 1", 2 … per sound, and resolves to its sound', () => { + const { f, kick, firstId, secondId, takeId, takeClipId } = setup(); + expect(clipsOf(kick()).map((c) => c.name)).toEqual(['Clip 1', 'Clip 2']); + expect(findSound(f.project, secondId)).toEqual({ asset: kick(), clip: clipsOf(kick())[1] }); + expect(findSound(f.project, kick().id)).toEqual({ asset: kick(), clip: null }); + expect(findSound(f.project, takeClipId)?.asset.id).toBe(takeId); + expect(findSound(f.project, 'nothing')).toBeNull(); + expect(findSound(f.project, f.project.assets.find((a) => a.type === 'song')!.id)).toBeNull(); + expect(firstId).not.toBe(secondId); + }); + + it('plays its stretch, the sound the whole file', () => { + const { f, kick, firstId } = setup(); + expect(playedSoundById(f.project, kick().id)).toBe(FILE); + expect(playedSoundById(f.project, firstId)).toBe(`${FILE}#0.5~0.75~0.005~0.005~linear~linear`); + }); + + it('reads "Sound / Clip" wherever a field names it, and is offered after its sound', () => { + const { f, kick, firstId, secondId, takeClipId } = setup(); + expect(assetRefLabel(f.project, firstId)).toBe('Kick / Clip 1'); + expect(assetRefLabel(f.project, takeClipId)).toBe('• Take 1 / Clip 1'); + expect(assetRefLabel(f.project, 'gone')).toBe('gone?'); + // What a song keeps to itself is offered nowhere, nor are its clips. + expect(soundChoices(f.project)).toEqual([ + { id: kick().id, label: 'Kick' }, + { id: firstId, label: 'Kick / Clip 1' }, + { id: secondId, label: 'Kick / Clip 2' }, + ]); + expect( + referencedAssetId(f.project, { key: 'sound', label: 'Sound', kind: 'sound', default: '' }, firstId), + ).toBe(kick().id); + }); + + it('is set, renamed and deleted in steps an undo takes back', () => { + const { f, kick, firstId, secondId } = setup(); + f.stack.execute(new SetClipPropsCommand(kick().id, firstId, { [START_KEY]: 0.25, [END_KEY]: 0.5 })); + expect(clipsOf(kick())[0]!.props).toEqual({ [START_KEY]: 0.25, [END_KEY]: 0.5 }); + f.stack.undo(); + expect(clipsOf(kick())[0]!.props).toEqual({ [START_KEY]: 0.5, [END_KEY]: 0.75 }); + + f.stack.execute(new RenameClipCommand(kick().id, secondId, 'Clip 1')); + expect(clipsOf(kick())[1]!.name).toBe('Clip 1 2'); + f.stack.undo(); + + f.stack.execute(new RemoveClipCommand(kick().id, firstId)); + expect(clipsOf(kick()).map((c) => c.id)).toEqual([secondId]); + expect(playedSoundById(f.project, firstId)).toBe(''); + f.stack.undo(); + expect(clipsOf(kick()).map((c) => c.id)).toEqual([firstId, secondId]); + }); + + it('refuses a value a clip cannot hold', () => { + const { f, kick, firstId } = setup(); + const before = structuredClone(clipsOf(kick())); + const set = (values: Record) => () => + f.stack.execute(new SetClipPropsCommand(kick().id, firstId, values)); + expect(set({ volume: 1 })).toThrow('A clip has no "volume"'); + expect(set({ [START_KEY]: -1 })).toThrow('Start (s) needs a number of at least 0'); + expect(set({ [START_KEY]: Number.NaN })).toThrow('Start (s) needs a number of at least 0'); + expect(set({ fadeInCurve: 'wobbly' })).toThrow('Fade in curve has no "wobbly"'); + expect(() => f.stack.execute(new RenameClipCommand(kick().id, firstId, ' '))).toThrow( + 'A clip needs a name', + ); + expect(clipsOf(kick())).toEqual(before); + }); + + it('survives a round trip; a clip repeating an id the document has is dropped', () => { + const { f, kick, firstId } = setup(); + expect(deserializeProject(serializeProject(f.project))).toEqual(f.project); + const raw = serializeProject(f.project) as { assets: { id: string; clips?: unknown[] }[] }; + const stored = raw.assets.find((a) => a.id === kick().id)!; + stored.clips = [ + ...stored.clips!, + { id: firstId, name: 'Twin', props: {} }, + { id: kick().id, name: 'Sound', props: {} }, + { id: 'odd', name: 'Odd', props: { start: 1, volume: 0.5 } }, + ]; + const read = findAssetOf(deserializeProject(raw), kick().id, 'sound')!; + expect(clipsOf(read).map((c) => c.name)).toEqual(['Clip 1', 'Clip 2', 'Odd']); + expect(clipsOf(read)[2]!.props).toEqual({ start: 1 }); + }); + + it('gets a fresh id in a duplicate of its sound', () => { + const { f, kick, firstId, secondId } = setup(); + const duplicate = new DuplicateAssetCommand(kick().id); + f.stack.execute(duplicate); + const copy = findAssetOf(f.project, duplicate.copyId, 'sound')!; + expect(clipsOf(copy).map((c) => c.name)).toEqual(['Clip 1', 'Clip 2']); + expect(clipsOf(copy).map((c) => c.id)).not.toContain(firstId); + expect(clipsOf(copy).map((c) => c.id)).not.toContain(secondId); + expect(findSound(f.project, firstId)?.asset.id).toBe(kick().id); + }); +}); + +describe('a clip in a song (#357)', () => { + it('is played by its item, through the FX of its sound', () => { + const { f, song, takeClipId } = setup(); + const take = findSound(f.project, takeClipId)!.asset; + take.props.fx = 'some-fx'; + expect(songSequence(f.project, song()).clips?.map((c) => c.sound)).toEqual([takeClipId]); + expect(sequenceNeeds(f.project, songSequence(f.project, song())).files).toEqual([ + `${FILE}#2~3~0.005~0.005~linear~linear`, + ]); + expect(itemFx(f.project, song().items[0]!)).toBe('some-fx'); + }); + + it('keeps its take, and a second item on a clip of it gets a copy with its own clip ids', () => { + const { f, song, songId, track, takeId, takeClipId } = setup(); + expect(embeddedUsedBy(f.project, song().items).map((a) => a.id)).toEqual([takeId]); + const [item] = song().items; + f.stack.execute(new SetSongCommand(songId, { items: [item!, { ...item!, id: 'second', tick: BAR }] })); + const named = song().items[1]!.asset; + const copy = findSound(f.project, named)!; + expect(copy.asset.id).not.toBe(takeId); + expect(copy.asset.owner).toBe(songId); + expect(copy.clip?.name).toBe('Clip 1'); + expect(named).not.toBe(takeClipId); + expect(findSound(f.project, takeClipId)?.asset.id).toBe(takeId); + // The take nothing else names stays. + expect(song().items[0]!.asset).toBe(takeClipId); + expect(song().items.map((i) => i.track)).toEqual([track, track]); + }); + + it('is copied along when an item is given a copy of the sound or the song is duplicated', () => { + const { f, song, kick, secondId, takeClipId } = setup(); + const { copy, ids } = embeddedCopy(kick(), song()); + const copied = clipsOf(copy as SoundAsset); + expect(ids.get(secondId)).toBe(copied[1]!.id); + expect(ids.get(kick().id)).toBe(copy.id); + + const duplicate = new DuplicateAssetCommand(song().id); + f.stack.execute(duplicate); + const twin = findAssetOf(f.project, duplicate.copyId, 'song')!; + const named = twin.items[0]!.asset; + expect(named).not.toBe(takeClipId); + const found = findSound(f.project, named)!; + expect([found.asset.owner, found.clip?.name]).toEqual([twin.id, 'Clip 1']); + expect(findAsset(f.project, found.asset.id)).toBe(found.asset); + }); +}); + +describe('the strip of the Sound tab (#357)', () => { + it('picks the band under the pointer, the one drawn last where they lie over each other', () => { + const view = { panX: 0, pxPerSecond: 100 }; + const bands = [ + { id: 'a', span: { start: 0, end: 1 } }, + { id: 'b', span: { start: 0.5, end: 2 } }, + ]; + expect([10, 60, 150, 250].map((x) => clipAt(view, bands, x))).toEqual(['a', 'b', 'b', null]); + }); +}); diff --git a/tests/core/sounds.test.ts b/tests/core/sounds.test.ts index a6ca35d9..f9e8a5cf 100644 --- a/tests/core/sounds.test.ts +++ b/tests/core/sounds.test.ts @@ -9,6 +9,7 @@ import { CreateSoundCommand, SetAssetPropertyCommand, } from '../../src/core/commands/assetCommands'; +import { AddClipCommand, SetClipPropsCommand } from '../../src/core/commands/clipCommands'; import type { BehaviorAsset, SoundAsset } from '../../src/core/model/Asset'; import { isPlaceableType } from '../../src/core/model/AssetTypes'; import { @@ -25,6 +26,7 @@ import { assetRefChoices } from '../../src/core/properties/assetRef'; import { schemaFor } from '../../src/core/properties/PropertySchema'; import { playedSoundById, playedSoundOf } from '../../src/core/properties/playedSound'; import { + CLIP_ROWS, cutOf, END_KEY, FADE_IN_CURVE_KEY, @@ -128,9 +130,9 @@ describe('the sound asset', () => { expect(asset).toMatchObject({ type: 'sound', file: '' }); expect(volumeOf(asset.props)).toBe(1); expect(loopsOf(asset.props)).toBe(false); - expect(schemaFor('sound').map((def) => def.key)).toEqual([ - VOLUME_KEY, - LOOP_KEY, + // The cut belongs to a clip of the sound (#357), the sound is the whole file. + expect(schemaFor('sound').map((def) => def.key)).toEqual([VOLUME_KEY, LOOP_KEY, 'fx', BUS_KEY]); + expect(CLIP_ROWS.map((def) => def.key)).toEqual([ START_KEY, END_KEY, REVERSE_KEY, @@ -138,10 +140,8 @@ describe('the sound asset', () => { FADE_IN_CURVE_KEY, FADE_OUT_KEY, FADE_OUT_CURVE_KEY, - 'fx', - BUS_KEY, ]); - expect(cutOf(asset.props)).toEqual({ + expect(cutOf({})).toEqual({ start: 0, end: 0, fadeIn: 0.005, @@ -293,17 +293,23 @@ describe('the cut of a sound (#343)', () => { expect(Array.from(kept!)).toEqual([0, 0.0625, 0.25, 0.5625, 1, 1, 1, 1, 0.9375, 0.75, 0.4375, 0]); }); - it('is what every way of playing the asset is given, its fades in milliseconds on the asset', () => { + it('is what every way of playing a clip is given, its fades in milliseconds on the clip (#357)', () => { const f = playingScene(); - expect(playedSoundOf(f.project, f.sound)).toBe(FILE); - f.stack.execute(new SetAssetPropertyCommand(f.sound.id, START_KEY, 0.5)); - f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_IN_KEY, 25)); - f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_OUT_KEY, 40)); - f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_OUT_CURVE_KEY, 'equalPower')); + expect(playedSoundOf({ asset: f.sound, clip: null })).toBe(FILE); + const add = new AddClipCommand(f.sound.id, { [START_KEY]: 0.5, [FADE_IN_KEY]: 25 }); + f.stack.execute(add); + f.stack.execute( + new SetClipPropsCommand(f.sound.id, add.clipId, { + [FADE_OUT_KEY]: 40, + [FADE_OUT_CURVE_KEY]: 'equalPower', + }), + ); const name = `${FILE}#0.5~0~0.025~0.04~linear~equalPower`; - expect(playedSoundOf(f.project, f.sound)).toBe(name); - expect(playedSoundById(f.project, f.sound.id)).toBe(name); + expect(playedSoundById(f.project, add.clipId)).toBe(name); + // The sound itself stays the whole file. + expect(playedSoundById(f.project, f.sound.id)).toBe(FILE); expect(playedSoundById(f.project, '')).toBe(''); + f.play.params.sound = add.clipId; const audio = fakeAudio(); startWorld(f, { behaviors: true, audio }).frame(); expect(audio.played).toMatchObject([{ file: name }]); @@ -333,12 +339,13 @@ describe('the cut of a sound (#343)', () => { expect(Array.from(ramp)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); const f = playingScene(); - f.stack.execute(new SetAssetPropertyCommand(f.sound.id, REVERSE_KEY, true)); + const add = new AddClipCommand(f.sound.id, { [REVERSE_KEY]: true }); + f.stack.execute(add); const name = `${FILE}#0~0~0.005~0.005~linear~linear~reverse`; - expect(playedSoundOf(f.project, f.sound)).toBe(name); + expect(playedSoundById(f.project, add.clipId)).toBe(name); expect(parseCutName(name).cut).toMatchObject({ start: 0, end: 0, reverse: true }); - f.stack.execute(new SetAssetPropertyCommand(f.sound.id, REVERSE_KEY, false)); - expect(playedSoundOf(f.project, f.sound)).toBe(FILE); + f.stack.execute(new SetClipPropsCommand(f.sound.id, add.clipId, { [REVERSE_KEY]: false })); + expect(playedSoundById(f.project, add.clipId)).toBe(FILE); }); }); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 201134b1..a87d010f 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -1912,25 +1912,36 @@ export async function sound(t) { JSON.stringify({ dropped, both, notice: await json('__gm.state.notice') }), ); - // 34h-2. The Sound tab (#343): the drone's 0.75 s as a wave between two handles. Dragging the end - // handle cuts the sound there, a drag beside the handles spans a new stretch, and that - // stretch is what plays: a name of its own, its samples cut to the sample and ramped from - // silence at an edge inside the file. One drag is one undo step. + // 34h-2. The Sound tab (#343): the drone's 0.75 s as a wave, the sound itself the whole file. Its + // clips (#357) stand as bands in a strip under the ruler: a drag there spans a new one, a + // click picks one, and the picked clip stands between two handles. A clip is played by a + // name of its own, its samples cut to the sample and ramped from silence at an edge inside + // the file. One drag is one undo step. const droneId = both.find((a) => a.file === drone.file)?.id; await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`); const tab = () => json('__gm.soundTab.span()'); + const clips = () => json('__gm.soundTab.clips()'); + const picked = () => json('__gm.soundTab.pickedClip()'); await waitFor(tab, (span) => span !== null); const tabOpened = { current: await json('__gm.soundTab.current()'), span: await tab(), info: await json('__gm.soundTab.info()'), + clips: await clips(), + picked: await picked(), + reverse: await json('__gm.soundTab.reverseButton()'), + played: await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`), }; check( - 'opening a sound shows its tab: the whole file between the handles', + 'opening a sound shows its tab: the whole file, no clip, nothing to turn or fade', tabOpened.current === droneId && tabOpened.span?.start === 0 && Math.abs(tabOpened.span?.end - 0.75) < 0.001 && - tabOpened.info === '0.75 s', + tabOpened.info === '0.75 s' && + tabOpened.clips.length === 0 && + tabOpened.picked === null && + tabOpened.reverse.enabled === false && + tabOpened.played === drone.file, JSON.stringify(tabOpened), ); // 34h-2a. The wheel over the level scale left of the wave zooms its height (#357): out leaves room @@ -1969,51 +1980,74 @@ export async function sound(t) { Math.abs(refitted.top - fullTop) < 0.5, JSON.stringify({ levelScale, levelRect, fullTop, secondAt, zoomedOut, zoomedIn, refitted }), ); - const droneProps = async () => (await assetsOfType('sound')).find((a) => a.id === droneId)?.props; - let dragFrom = await json('__gm.soundTab.at(0.75)'); - let dragTo = await json('__gm.soundTab.at(0.5)'); + const clipProps = async () => (await clips())[0]?.props; + let dragFrom = await json('__gm.soundTab.stripAt(0.1)'); + let dragTo = await json('__gm.soundTab.stripAt(0.3)'); + await t.drag(dragFrom.x, dragFrom.y, dragTo.x, dragTo.y); + const spanned = { clips: await clips(), picked: await picked(), info: await json('__gm.soundTab.info()') }; + const clipId = spanned.clips[0]?.id; + check( + 'a drag in the strip spans a new clip, "Clip 1", picked, and the sound stays the whole file', + spanned.clips.length === 1 && + spanned.clips[0].name === 'Clip 1' && + Math.abs(spanned.clips[0].props.start - 0.1) < 0.01 && + Math.abs(spanned.clips[0].props.end - 0.3) < 0.01 && + spanned.picked === clipId && + spanned.info === + `${(spanned.clips[0].props.end - spanned.clips[0].props.start).toFixed(2)} s of 0.75 s` && + (await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`)) === drone.file, + JSON.stringify(spanned), + ); + await press('z', { ctrl: true }); + const undoneClip = { clips: await clips(), picked: await picked() }; + await press('z', { ctrl: true, shift: true }); + const redoneClip = await clips(); + const beside = await json('__gm.soundTab.stripAt(0.5)'); + await click(beside.x, beside.y); + const pickedBeside = await picked(); + const onBand = await json('__gm.soundTab.stripAt(0.2)'); + await click(onBand.x, onBand.y); + check( + 'one undo takes the clip away, a redo brings it back; a click in the strip picks it, beside it the sound', + undoneClip.clips.length === 0 && + undoneClip.picked === null && + redoneClip.map((c) => c.id).join() === clipId && + pickedBeside === null && + (await picked()) === clipId, + JSON.stringify({ undoneClip, redoneClip, pickedBeside, picked: await picked() }), + ); + dragFrom = await json('__gm.soundTab.at(0.3)'); + dragTo = await json('__gm.soundTab.at(0.5)'); await t.mouse('mouseMoved', dragFrom.x - 1, dragFrom.y); await t.mouse('mouseMoved', dragFrom.x, dragFrom.y); const onHandle = await json('__gm.soundTab.cursor()'); await t.drag(dragFrom.x, dragFrom.y, dragTo.x, dragTo.y); - const shortened = { props: await droneProps(), info: await json('__gm.soundTab.info()') }; - check( - 'dragging the end handle cuts the sound there, the handle showing it can be dragged', - onHandle === 'ew-resize' && - shortened.props?.start === 0 && - Math.abs(shortened.props?.end - 0.5) < 0.01 && - shortened.info === `${shortened.props?.end.toFixed(2)} s of 0.75 s`, - JSON.stringify({ onHandle, ...shortened }), - ); + const lengthened = await clipProps(); await press('z', { ctrl: true }); - const undoneCut = await droneProps(); - await press('z', { ctrl: true, shift: true }); check( - 'one undo puts the whole file back, a redo the cut', - (undoneCut?.end ?? 0) === 0 && Math.abs((await droneProps())?.end - 0.5) < 0.01, - JSON.stringify({ undoneCut, redone: await droneProps() }), + 'dragging the end handle moves that edge of the picked clip, the handle showing it can be dragged', + onHandle === 'ew-resize' && + Math.abs(lengthened?.start - 0.1) < 0.01 && + Math.abs(lengthened?.end - 0.5) < 0.01 && + Math.abs((await clipProps())?.end - 0.3) < 0.01, + JSON.stringify({ onHandle, lengthened, undone: await clipProps() }), ); - dragFrom = await json('__gm.soundTab.at(0.1)'); - dragTo = await json('__gm.soundTab.at(0.3)'); - await t.drag(dragFrom.x, dragFrom.y, dragTo.x, dragTo.y); - const spanned = await droneProps(); - const cutPlayed = await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`); + const clipCut = await clipProps(); + const cutPlayed = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`); const cutSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(cutPlayed)})`); - const keptSamples = spanned ? Math.round((spanned.end - spanned.start) * (cutSamples?.rate ?? 0)) : -1; + const keptSamples = clipCut ? Math.round((clipCut.end - clipCut.start) * (cutSamples?.rate ?? 0)) : -1; check( - 'a drag beside the handles spans a new stretch, played by a name of its own, cut to the sample', - Math.abs(spanned?.start - 0.1) < 0.01 && - Math.abs(spanned?.end - 0.3) < 0.01 && - cutPlayed === `${drone.file}#${spanned?.start}~${spanned?.end}~0.005~0.005~linear~linear` && + 'the clip is played by a name of its own, cut to the sample', + cutPlayed === `${drone.file}#${clipCut?.start}~${clipCut?.end}~0.005~0.005~linear~linear` && cutSamples?.length === keptSamples, - JSON.stringify({ spanned, cutPlayed, cutSamples, keptSamples }), + JSON.stringify({ clipCut, cutPlayed, cutSamples, keptSamples }), ); check( 'the cut rises from silence and falls back to it, the default 5 ms at each edge inside the file', cutSamples?.head[0] === 0 && Math.abs(cutSamples?.tail.at(-1)) < 0.01, JSON.stringify(cutSamples), ); - // 34h-ii. Each edge has a fade of its own, each shaped by its own curve (#345). + // 34h-ii. Each edge of the picked clip has a fade of its own, each shaped by its own curve (#345). const tabFadeOut = await json('__gm.soundTab.fadeOutField()'); await click(tabFadeOut.x, tabFadeOut.y); await t.typeText('40'); @@ -2023,17 +2057,17 @@ export async function sound(t) { await click(curveOutButton.x, curveOutButton.y); await clickText('Exponential'); await sleep(150); - const curved = await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`); + const curved = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`); const curvedSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(curved)})`); check( 'the header sets the fade out and its curve on their own, the fade in staying Linear', - (await droneProps())?.fadeOut === 40 && - (await droneProps())?.fadeOutCurve === 'exponential' && - ((await droneProps())?.fadeInCurve ?? 'linear') === 'linear' && + (await clipProps())?.fadeOut === 40 && + (await clipProps())?.fadeOutCurve === 'exponential' && + ((await clipProps())?.fadeInCurve ?? 'linear') === 'linear' && (await json('__gm.soundTab.curveOutButton()')).tooltip === 'Fade out curve: Exponential' && (await json('__gm.soundTab.curveInButton()')).tooltip === 'Fade in curve: Linear' && curved.endsWith('~0.005~0.04~linear~exponential'), - JSON.stringify({ props: await droneProps(), curved }), + JSON.stringify({ props: await clipProps(), curved }), ); await press('z', { ctrl: true }); await press('z', { ctrl: true }); @@ -2046,14 +2080,14 @@ export async function sound(t) { await t.typeText('0'); await press('Enter'); await sleep(150); - const unfaded = await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`); + const unfaded = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`); check( 'after two undos the curve is Linear again, and both fades go to 0 in the header', - (await droneProps())?.fadeIn === 0 && - (await droneProps())?.fadeOut === 0 && - ((await droneProps())?.fadeOutCurve ?? 'linear') === 'linear' && + (await clipProps())?.fadeIn === 0 && + (await clipProps())?.fadeOut === 0 && + ((await clipProps())?.fadeOutCurve ?? 'linear') === 'linear' && unfaded.endsWith('~0~0~linear~linear'), - JSON.stringify({ props: await droneProps(), unfaded }), + JSON.stringify({ props: await clipProps(), unfaded }), ); // The same stretch unfaded is the drone itself: against it, the i-th sample before the end of the // exponential fall is (i / ramp)² of it, where a straight line would leave i / ramp. @@ -2083,7 +2117,7 @@ export async function sound(t) { (on) => on === false, ); check( - 'Play plays only the stretch, its playhead running inside it, and stops when it ends', + 'Play plays only the picked clip, its playhead running inside it, and stops when it ends', tabHeard.last?.file === unfaded && Math.abs(tabHeard.last?.duration - 0.2) < 0.01 && tabPlayhead !== null && @@ -2093,25 +2127,32 @@ export async function sound(t) { (await json('__gm.soundTab.playhead()')) === null, JSON.stringify({ last: tabHeard.last, tabPlayhead, tabEnded }), ); - // 34h-iii. Reverse plays the same stretch backwards, the playhead running from its end (#321). + // 34h-iii. Reverse plays the picked clip backwards, the playhead running from its end (#321). const reverseButton = await json('__gm.soundTab.reverseButton()'); await click(reverseButton.x, reverseButton.y); await sleep(150); - const reversed = await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`); + const reversed = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`); const reversedSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(reversed)})`); check( - 'Reverse turns the stretch around: its first samples are the last of the stretch played forwards', - (await droneProps())?.reverse === true && + 'Reverse turns the clip around: its first samples are the last of the clip played forwards', + (await clipProps())?.reverse === true && (await json('__gm.soundTab.reverseButton()')).on === true && reversed === `${unfaded}~reverse` && reversedSamples?.length === rawSamples?.length && reversedSamples?.head.every((v, i) => v === rawSamples?.tail.at(-1 - i)), JSON.stringify({ reversed, head: reversedSamples?.head, tail: rawSamples?.tail }), ); - // The whole file (0.75 s) gives the playhead time for several readings. - const tabWhole = await json('__gm.soundTab.wholeButton()'); - await click(tabWhole.x, tabWhole.y); - await sleep(150); + // Over the whole file (0.75 s) the playhead has time for several readings: the handles are dragged + // past both ends of it. + for (const [from, to] of [ + [0.1, -0.1], + [0.3, 0.9], + ]) { + const a = await json(`__gm.soundTab.at(${from})`); + const b = await json(`__gm.soundTab.at(${to})`); + await t.drag(a.x, a.y, b.x, b.y); + } + const whole = await clipProps(); await click(tabPlay.x, tabPlay.y); await waitFor( () => json('__gm.soundTab.playhead()'), @@ -2125,15 +2166,48 @@ export async function sound(t) { ) ).filter((seconds) => seconds !== null); check( - 'played backwards, the playhead runs from the end of the file towards its start', - (await droneProps())?.reverse === true && + 'played backwards, the playhead runs from the end of the clip towards its start', + whole?.reverse === true && + whole?.start === 0 && + whole?.end === 0 && backwards.length >= 2 && backwards.every((seconds) => seconds >= 0 && seconds <= 0.75) && backwards.every((seconds, i) => i === 0 || seconds <= backwards[i - 1]) && backwards.at(-1) < backwards[0], - JSON.stringify(backwards), + JSON.stringify({ whole, backwards }), ); await shot('34-sound-tab'); + // 34h-iv. A sound field offers the clip after its sound, as "drone / Clip 1". + await select({ kind: 'node', assetId: 'sn-logic', nodeId: 'sn-play' }); + // The row is named after the port, as the node body names it. + const soundRow = await json("__gm.inspectorField('sound', true)"); + await click(soundRow.x, soundRow.y); + const soundOffered = await json('__gm.overlayTexts()'); + await clickText('drone / Clip 1'); + await sleep(150); + const playNode = async () => + (await json("__gm.state.project.assets.find((a) => a.id === 'sn-logic').graph")).nodes.find( + (n) => n.id === 'sn-play', + ); + check( + 'a sound field offers each clip after its sound, and picking one names the clip', + soundOffered.indexOf('drone / Clip 1') === soundOffered.indexOf('drone') + 1 && + (await playNode())?.params.sound === clipId, + JSON.stringify({ soundOffered, node: await playNode() }), + ); + // 34h-v. Delete takes the picked clip away, and the sound stays. + await evaluate( + `__gm.state.select({ kind: 'clip', assetId: ${JSON.stringify(droneId)}, clipId: ${JSON.stringify(clipId)} })`, + ); + await waitFor(picked, (id) => id === clipId); + await press('Delete'); + check( + 'Delete takes the picked clip away, the sound staying on screen', + (await clips()).length === 0 && + (await picked()) === null && + (await json('__gm.soundTab.current()')) === droneId, + JSON.stringify({ clips: await clips(), current: await json('__gm.soundTab.current()') }), + ); // 34h-3. Buses and the master (#325). "+ → Bus" makes one into the master; its Into never offers // itself nor a bus that goes into it; the Sound tab plays a sound on its bus, and the