/** * What a song keeps to itself (#355): a sound, a pattern or a sound FX made in a song is an asset like * any other, so everything that finds an asset by its id finds it, but its `owner` names the song. * The lists of assets leave it out. The places of its song may share it (#357): the song itself, its * tracks and its items. `settleEmbedded` holds a song to that after every edit, so a paste from another * song gets a copy of its own and what nothing uses any more goes. */ import type { Graph } from '../graph/Graph'; import { SOUND_FX_INPUT, SOUND_FX_OUTPUT } from '../graph/nested'; import { newId } from '../ids'; import type { Asset, 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_SIGN_KEY, PATTERN_TEMPO_KEY, patternInstrument } from './pattern'; import { type SongItem, songMeter, songTempo, type SongTrack } from './song'; import { signOf } from './time'; export const EMBEDDABLE_TYPES: readonly AssetType[] = ['sound', 'pattern', 'soundfx']; /** * The rows of an embedded asset that do nothing in a song: how it repeats is the song's. A pattern * keeps its tempo and meter (#363), as one of the project does. */ const HIDDEN_ROWS: Partial> = { sound: ['loop'], }; export function isEmbeddable(type: AssetType): boolean { return EMBEDDABLE_TYPES.includes(type); } export function isEmbedded(asset: Asset): boolean { return (asset.owner ?? '') !== ''; } /** In front of the name of what the song keeps to itself, wherever it is named. */ export const EMBEDDED_MARK = '•'; export function markedName(asset: Asset): string { return isEmbedded(asset) ? `${EMBEDDED_MARK} ${asset.name}` : asset.name; } /** The assets a list offers: all but those a song keeps to itself. */ export function listedAssets(project: Project): Asset[] { return project.assets.filter((a) => !isEmbedded(a)); } export function embeddedIn(project: Project, songId: string): Asset[] { return project.assets.filter((a) => a.owner === songId); } /** The song an asset belongs to; undefined for a project asset. */ export function ownerSong(project: Project, asset: Asset): SongAsset | undefined { const owner = asset.owner ? findAsset(project, asset.owner) : undefined; return owner?.type === 'song' ? owner : undefined; } export function isRowHiddenWhenEmbedded(asset: Asset, key: string): boolean { return isEmbedded(asset) && (HIDDEN_ROWS[asset.type]?.includes(key) ?? false); } /** "Song › Take 1" for an embedded asset, its own name for any other. */ export function assetTitle(project: Project, asset: Asset): string { const song = ownerSong(project, asset); return song ? `${song.name} › ${asset.name}` : asset.name; } /** The tempo and the meter of a song, as the props a pattern made in it starts with. */ export function songClockProps(props: PropertyBag): PropertyBag { return { [PATTERN_TEMPO_KEY]: songTempo(props), [PATTERN_SIGN_KEY]: signOf(songMeter(props)) }; } /** * 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: Asset = { ...structuredClone(asset), id: newId(), owner: song.id }; const ids = new Map([[asset.id, copy.id]]); renewClipIds(copy, ids); return { copy, ids }; } /** "Pattern 1", 2, 3 … ("Sound FX 1" …): the first number none of what the song keeps to itself has. */ export function embeddedName(project: Project, songId: string, base: string): string { return nextNumberedName( embeddedIn(project, songId).map((a) => a.name), base, ); } /** What a sound FX made in a song starts as (#355): the Input wired to the Output, so it lets the sound through. */ export function passThroughFxGraph(): Graph { const input = newId(); const output = newId(); return { nodes: [ { id: input, type: SOUND_FX_INPUT, x: 0, y: 0, params: {} }, { id: output, type: SOUND_FX_OUTPUT, x: 260, y: 0, params: {} }, ], edges: [{ id: newId(), from: { node: input, port: 'out' }, to: { node: output, port: 'in' } }], }; } /** * The instrument a pattern made at `tick` on a track is played with: that of the pattern item there * that starts last before it, else of the first one after; '' on a track without one. */ export function instrumentNear( project: Project, items: readonly SongItem[], track: string, tick: number, ): string { const patterns = items .filter((i) => i.track === track && i.kind === 'pattern') .sort((a, b) => a.tick - b.tick); const near = patterns.filter((i) => i.tick <= tick).at(-1) ?? patterns[0]; if (!near) return ''; if (near.instrument !== '') return near.instrument; const pattern = findAssetOf(project, near.asset, 'pattern'); return pattern ? patternInstrument(pattern.props) : ''; } /** A place in a song that names an asset, in the order the places claim what they name. */ interface Slot { id: string; types: readonly AssetType[]; set(id: string): void; } function slotsOf(props: SongAsset['props'], tracks: SongTrack[], items: SongItem[]): Slot[] { const slots: Slot[] = [ { id: assetFxOf(props), types: ['soundfx'], set: (id) => (props[ASSET_FX_KEY] = id) }, ]; for (const track of tracks) slots.push({ id: track.fx, types: ['soundfx'], set: (id) => (track.fx = id) }); for (const item of items) { slots.push({ id: item.asset, types: [item.kind], set: (id) => (item.asset = id) }); slots.push({ id: item.fx, types: ['soundfx'], set: (id) => (item.fx = id) }); } return slots; } /** 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 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 { /** Copies made for a place whose asset another place claimed first, or that belongs elsewhere. */ added: Asset[]; /** The song's own assets nothing names any more. */ dropped: string[]; } /** * Holds the song to what it keeps to itself, writing the places in `tracks` and `items` (fresh lists * the caller owns). The places of the song share its assets (#357), so the clips of one take serve * many items. A place naming an asset of another song gets a copy, one for all places naming it; one * naming an asset that is gone but in `spare` – what was copied along with items – gets that back. * The song's own props are only read: they claim, never copy. */ export function settleEmbedded( project: Project, song: SongAsset, tracks: SongTrack[], items: SongItem[], spare: readonly Asset[] = [], ): Settled { const claimed = new Set(); const added: Asset[] = []; const copies = new Map>(); const taken = new Set(project.assets.map((a) => a.id)); const own = { ...song.props }; slotsOf(own, tracks, items).forEach((slot, index) => { if (slot.id === '' || slot.id === NO_FX) return; // 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.add(found.id); return; } if (index === 0) return; let ids = copies.get(source.id); if (!ids) { const fresh = found !== undefined || taken.has(source.id); const id = fresh ? newId() : source.id; const copy: Asset = { ...structuredClone(source), id, owner: song.id }; ids = new Map([[source.id, id]]); if (fresh) renewClipIds(copy, ids); copies.set(source.id, ids); taken.add(id); added.push(copy); claimed.add(id); } slot.set(ids.get(slot.id) ?? ids.get(source.id) ?? slot.id); }); const dropped = project.assets.filter((a) => a.owner === song.id && !claimed.has(a.id)).map((a) => a.id); return { added, dropped }; }