import { nextFreeName, nextNumberedName } from '../commands/naming'; import { newId } from '../ids'; import type { Asset, SoundAsset, SoundClip, VideoAsset } from './Asset'; import { findAsset, type Project } from './Project'; import { storedFilePattern } from './storedFile'; const FILE_PATTERN = storedFilePattern('mp3|ogg|wav|m4a'); export function isSoundFile(name: string): boolean { return FILE_PATTERN.test(name); } const CLIP_SEPARATOR = ' / '; const DEFAULT_CLIP_NAME = 'Clip'; /** The clips of a sound (#357) or a video (#402). */ export function clipsOf(asset: { clips?: C[] }): readonly C[] { return asset.clips ?? []; } /** What has clips: a sound and a video, each a file with named stretches of it. */ export type ClipOwner = SoundAsset | VideoAsset; export function hasClips(asset: Asset): asset is ClipOwner { return asset.type === 'sound' || asset.type === 'video'; } /** 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 one 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 || (hasClips(a) && clipsOf<{ id: string }>(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 (!hasClips(copy)) return; for (const clip of clipsOf<{ id: string }>(copy)) { const id = newId(); ids.set(clip.id, id); clip.id = 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 ? clipRefName(soundName, ref.clip.name) : soundName; } /** "Kick / Clip 1": a clip of a sound or a video by name. */ export function clipRefName(assetName: string, clipName: string): string { return `${assetName}${CLIP_SEPARATOR}${clipName}`; } /** "Clip 1", 2, 3 …: the first number none of the asset's clips has. */ export function nextClipName(asset: ClipOwner): string { return nextNumberedName( clipsOf<{ name: string }>(asset).map((c) => c.name), DEFAULT_CLIP_NAME, ); } /** A name free among the asset's clips: `name`, else "name 2", "name 3" … */ export function uniqueClipName(asset: ClipOwner, name: string, ignoreId = ''): string { return nextFreeName( clipsOf<{ id: string; name: string }>(asset) .filter((c) => c.id !== ignoreId) .map((c) => c.name), name, ); } /** The shape of the ramp at a cut edge (#345), from silence to full; the end runs it backwards. */ export type FadeCurve = 'linear' | 'equalPower' | 'sCurve' | 'exponential' | 'logarithmic'; export const FADE_CURVES: { value: FadeCurve; label: string }[] = [ { value: 'linear', label: 'Linear' }, { value: 'equalPower', label: 'Equal power' }, { value: 'sCurve', label: 'S-curve' }, { value: 'exponential', label: 'Exponential' }, { value: 'logarithmic', label: 'Logarithmic' }, ]; export function isFadeCurve(value: unknown): value is FadeCurve { return FADE_CURVES.some((curve) => curve.value === value); } /** How loud a ramp of that curve is `t` of the way from silence (0) to full (1). */ export function fadeGain(curve: FadeCurve, t: number): number { const x = Math.min(1, Math.max(0, t)); switch (curve) { case 'equalPower': return Math.sin((x * Math.PI) / 2); case 'sCurve': return (1 - Math.cos(x * Math.PI)) / 2; case 'exponential': return x * x; case 'logarithmic': return 1 - (1 - x) * (1 - x); default: return x; } } /** * The stretch of its file a sound asset is (#343), in seconds: `end` 0 runs to the end of the file. * `fadeIn` and `fadeOut` are the ramps at the start and the end when that edge lies inside the file, * so a cut through a wave does not click, shaped by `curveIn` and `curveOut` (#345). `reverse` plays * the same stretch backwards (#321): what is heard first is its end, and the fade in ramps there. */ export interface SoundCut { start: number; end: number; fadeIn: number; fadeOut: number; curveIn: FadeCurve; curveOut: FadeCurve; reverse: boolean; } /** * Samples of a file a cut keeps, `from` up to `to`, the samples of each ramp at the edge it is heard * at and their shape, and whether they run backwards. */ export interface CutRange { from: number; to: number; fadeIn: number; fadeOut: number; curveIn: FadeCurve; curveOut: FadeCurve; reverse: boolean; } const REVERSED = 'reverse'; /** * What a sound is played by: its file, or for a cut * `#~~~~~`, a name of its own, with `~reverse` * after it when it runs backwards. */ export function cutName(file: string, cut: SoundCut): string { if (file === '' || (cut.start <= 0 && cut.end <= 0 && !cut.reverse)) return file; const name = `${file}#${cut.start}~${cut.end}~${cut.fadeIn}~${cut.fadeOut}~${cut.curveIn}~${cut.curveOut}`; return cut.reverse ? `${name}~${REVERSED}` : name; } /** A name `cutName` made, taken apart again; a plain file has no cut. */ export function parseCutName(name: string): { file: string; cut: SoundCut | null } { const hash = name.indexOf('#'); if (hash < 0) return { file: name, cut: null }; const parts = name.slice(hash + 1).split('~'); const [start, end, fadeIn, fadeOut] = parts.slice(0, 4).map((part) => Number(part)); const held = (value: number | undefined) => (Number.isFinite(value) ? Math.max(0, value!) : 0); const curve = (part: string | undefined): FadeCurve => (isFadeCurve(part) ? part : 'linear'); return { file: name.slice(0, hash), cut: { start: held(start), end: held(end), fadeIn: held(fadeIn), fadeOut: held(fadeOut), curveIn: curve(parts[4]), curveOut: curve(parts[5]), reverse: parts[6] === REVERSED, }, }; } /** * Where a cut lies in `length` samples at `rate`: at least one sample, inside the file. An edge at * the start or the end of the file is left as it is; one inside ramps over its fade, no more than * half of what is kept so the two ramps never cross. Played backwards, the fade in belongs to the * end of the stretch and the fade out to its start. */ export function cutRange(length: number, rate: number, cut: SoundCut): CutRange { const last = Math.max(1, length); const from = Math.min(last - 1, Math.max(0, Math.round(cut.start * rate))); const asked = cut.end > 0 ? Math.min(last, Math.round(cut.end * rate)) : last; const to = Math.max(from + 1, asked); const ramp = (seconds: number) => Math.min(Math.round(Math.max(0, seconds) * rate), Math.floor((to - from) / 2)); const [heardFirst, heardLast] = cut.reverse ? [to < last, from > 0] : [from > 0, to < last]; return { from, to, fadeIn: heardFirst ? ramp(cut.fadeIn) : 0, fadeOut: heardLast ? ramp(cut.fadeOut) : 0, curveIn: cut.curveIn, curveOut: cut.curveOut, reverse: cut.reverse, }; } /** The samples of each channel a cut keeps in the order heard, ramped in and out along their curves. */ export function cutChannels(channels: readonly Float32Array[], range: CutRange): Float32Array[] { const { from, to, fadeIn, fadeOut, curveIn, curveOut } = range; const rise = Array.from({ length: fadeIn }, (_, i) => fadeGain(curveIn, i / fadeIn)); const fall = Array.from({ length: fadeOut }, (_, i) => fadeGain(curveOut, i / fadeOut)); return channels.map((data) => { const kept = data.slice(from, to); if (range.reverse) kept.reverse(); rise.forEach((gain, i) => (kept[i]! *= gain)); fall.forEach((gain, i) => (kept[kept.length - 1 - i]! *= gain)); return kept; }); }