Files
feuerwerk/src/core/model/sound.ts
T
adminandClaude Opus 5.5 2e4c0a09ea
feuerwerk / gate (push) Successful in 4m58s
feuerwerk / suite (push) Failing after 19m37s
Open clips and frames from the asset panel by double click (#430)
A double click on a clip row did not open its tab. The first click of
the double click had already selected the clip for the inspector, so
the second click selected the same thing again and the editor saw no
change. Frames of textures and pixel art had no rows at all.

- A double click now asks for the tab with openSelection, even when
  the selection is already right.
- Textures with frames and pixel art with more than one frame get a
  chevron and one row per frame, like sounds and videos. Which rows an
  asset has is decided in one place, src/core/model/assetParts.ts,
  used by the panel and the inspector link.
- A texture frame shows in the inspector on click and opens the
  texture with the frame selected on double click.
- A pixel art frame selects the pixel art on click, so an open tab
  paints on that frame. A double click opens the tab on the frame.
- A frame can be dragged onto a composition track like a video clip.
  The item carries the sprite of the whole image, the frame becomes
  the texture of its object, and it is 3 s long. The agent can do the
  same with add_composition_item.
- A texture label that names a frame now points at the frame row.

Found on the way: the e2e check from #425 only double clicked the
texture label, where the first click is a ping, so the bug never
showed there.

Tests:
- Unit: asset-parts (new, 6), composition-sprites and references
  extended, related tests 303/303, agent tests 21/21
- 5 mutations, all red in the matching test
- e2e 69 (atx): ALL PASSED, 26 checks, 69t new
- Gate (atx): CHECK EXIT 0, 325 files, 5017 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:30:29 +02:00

234 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<C>(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<string, string>): 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
* `<file>#<start>~<end>~<fadeIn>~<fadeOut>~<curveIn>~<curveOut>`, 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<ArrayBuffer>[] {
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;
});
}