Initial Commit
This commit is contained in:
47
packages/media/audio/audio-timeline/src/adapter/adapter.md
Normal file
47
packages/media/audio/audio-timeline/src/adapter/adapter.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# NullTimelineAdapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`NullTimelineAdapter` is a reference no-op implementation of the `TimelineAdapter` interface. It is useful for unit tests that need to verify `TimelineEngine` behaviour without setting up a real rendering backend. Because it implements every method, tests can exercise the full engine surface including transport, mixer, and loop APIs.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements every method of `TimelineAdapter` as a no-op
|
||||
- `getCurrentTime()` always returns `0`
|
||||
- `lookAhead = 0` and `ppqn = 96`
|
||||
- Safe to call `init`, `dispose`, `play`, `pause`, `stop`, `seek`, and all mixer methods
|
||||
- Accepts all declared parameters so TypeScript strict checks pass
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic
|
||||
|
||||
```ts
|
||||
import { NullTimelineAdapter } from "@suaveplan/audio-timeline/adapter";
|
||||
|
||||
const adapter = new NullTimelineAdapter();
|
||||
engine.setAdapter(adapter);
|
||||
engine.play();
|
||||
```
|
||||
|
||||
### Advanced
|
||||
|
||||
```ts
|
||||
const adapter = new NullTimelineAdapter();
|
||||
adapter.setTracks([]);
|
||||
adapter.updateTrack("t1", track);
|
||||
adapter.setLoop(0, 1000, true);
|
||||
adapter.setTempo(130);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
`NullTimelineAdapter` implements `TimelineAdapter`. All methods accept their declared arguments and return `void` (or `0` for `getCurrentTime`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The adapter deliberately does nothing. It exists so that engine tests can focus on arrangement logic, undo/redo, and event emission without needing a real audio context or Web Audio graph. In production, consumers provide their own adapter backed by a renderer such as a Web Audio scheduler or a waveform canvas. The interface is intentionally minimal so that adapters can be thin wrappers around existing audio engines or complex renderers with their own internal state. Because every method is a no-op, the NullTimelineAdapter is also safe to use as a default adapter before the real one is injected.
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createTrack } from "../track/index.js";
|
||||
import { NullTimelineAdapter } from "./adapter.js";
|
||||
|
||||
describe("NullTimelineAdapter", () => {
|
||||
it("implements the full TimelineAdapter interface without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const adapter = new NullTimelineAdapter();
|
||||
expect(adapter.lookAhead).toBe(0);
|
||||
expect(adapter.ppqn).toBe(96);
|
||||
adapter.init();
|
||||
adapter.setTracks([]);
|
||||
adapter.addTrack(createTrack());
|
||||
adapter.removeTrack("t1");
|
||||
adapter.updateTrack("t1", createTrack());
|
||||
adapter.play();
|
||||
adapter.pause();
|
||||
adapter.stop();
|
||||
adapter.seek(0);
|
||||
expect(adapter.getCurrentTime()).toBe(0);
|
||||
adapter.setLoop(0, 1, true);
|
||||
adapter.setMasterVolume(1);
|
||||
adapter.setTrackVolume("t1", 1);
|
||||
adapter.setTrackMute("t1", false);
|
||||
adapter.setTrackSolo("t1", false);
|
||||
adapter.setTrackPan("t1", 0);
|
||||
adapter.setTempo(120);
|
||||
adapter.dispose();
|
||||
});
|
||||
});
|
||||
28
packages/media/audio/audio-timeline/src/adapter/adapter.ts
Normal file
28
packages/media/audio/audio-timeline/src/adapter/adapter.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Track } from "../track/track.types.js";
|
||||
import type { TimelineAdapter } from "./adapter.types.js";
|
||||
|
||||
export class NullTimelineAdapter implements TimelineAdapter {
|
||||
lookAhead = 0;
|
||||
ppqn = 96;
|
||||
|
||||
init(_options?: { signal?: AbortSignal }): void {}
|
||||
dispose(): void {}
|
||||
setTracks(_tracks: Track[]): void {}
|
||||
addTrack(_track: Track): void {}
|
||||
removeTrack(_trackId: string): void {}
|
||||
updateTrack(_trackId: string, _track: Track): void {}
|
||||
play(_time?: number): void {}
|
||||
pause(_time?: number): void {}
|
||||
stop(_time?: number): void {}
|
||||
seek(_time: number): void {}
|
||||
getCurrentTime(): number {
|
||||
return 0;
|
||||
}
|
||||
setLoop(_start: number, _end: number, _enabled: boolean): void {}
|
||||
setMasterVolume(_volume: number): void {}
|
||||
setTrackVolume(_trackId: string, _volume: number): void {}
|
||||
setTrackMute(_trackId: string, _muted: boolean): void {}
|
||||
setTrackSolo(_trackId: string, _soloed: boolean): void {}
|
||||
setTrackPan(_trackId: string, _pan: number): void {}
|
||||
setTempo(_bpm: number, _atTick?: number): void {}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Track } from "../track/track.types.js";
|
||||
|
||||
export interface TimelineAdapter {
|
||||
readonly lookAhead: number;
|
||||
readonly ppqn: number;
|
||||
|
||||
init(options?: { signal?: AbortSignal }): Promise<void> | void;
|
||||
dispose(): void;
|
||||
|
||||
setTracks(tracks: Track[]): void;
|
||||
addTrack(track: Track): void;
|
||||
removeTrack(trackId: string): void;
|
||||
updateTrack(trackId: string, track: Track): void;
|
||||
|
||||
play(time?: number): void;
|
||||
pause(time?: number): void;
|
||||
stop(time?: number): void;
|
||||
seek(time: number): void;
|
||||
getCurrentTime(): number;
|
||||
|
||||
setLoop(start: number, end: number, enabled: boolean): void;
|
||||
setMasterVolume(volume: number): void;
|
||||
setTrackVolume(trackId: string, volume: number): void;
|
||||
setTrackMute(trackId: string, muted: boolean): void;
|
||||
setTrackSolo(trackId: string, soloed: boolean): void;
|
||||
setTrackPan(trackId: string, pan: number): void;
|
||||
setTempo(bpm: number, atTick?: number): void;
|
||||
|
||||
ticksToSeconds?(ticks: number): number;
|
||||
secondsToTicks?(seconds: number): number;
|
||||
}
|
||||
2
packages/media/audio/audio-timeline/src/adapter/index.ts
Normal file
2
packages/media/audio/audio-timeline/src/adapter/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { NullTimelineAdapter } from "./adapter.js";
|
||||
export type { TimelineAdapter } from "./adapter.types.js";
|
||||
49
packages/media/audio/audio-timeline/src/clip/clip.md
Normal file
49
packages/media/audio/audio-timeline/src/clip/clip.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Clip Helpers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `clip.ts` module contains pure geometry helpers for audio clips. A `Clip` is a slice of an `AudioDocument` placed on a timeline; it is defined by `startSample` (timeline position), `offsetSamples` (position inside the source document), and `durationSamples`. These helpers are used by both the timeline engine and by consumers who need to construct or validate clips outside the engine.
|
||||
|
||||
## Features
|
||||
|
||||
- `createClip` constructs a `Clip` with defaults and validates via `ClipSchema`
|
||||
- `validateClip` guards clip objects at runtime with Zod
|
||||
- `canSplitAt` predicates whether a split point is valid and produces two viable halves
|
||||
- `constrainBoundaryTrim` computes new geometry after dragging a clip edge, enforcing a minimum duration floor
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic
|
||||
|
||||
```ts
|
||||
import { createClip, canSplitAt } from "@suaveplan/audio-timeline/clip";
|
||||
|
||||
const clip = createClip({ documentId: "doc-1", durationSamples: 1000 });
|
||||
const ok = canSplitAt(clip, 500, 1); // true
|
||||
```
|
||||
|
||||
### Advanced
|
||||
|
||||
```ts
|
||||
import { constrainBoundaryTrim } from "@suaveplan/audio-timeline/clip";
|
||||
|
||||
const trimmed = constrainBoundaryTrim(clip, "left", 50, 10, 10000);
|
||||
// { startSample: 50, offsetSamples: 50, durationSamples: 950 }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `createClip` | `(options?) => Clip` | Build and validate a clip |
|
||||
| `validateClip` | `(clip) => asserts clip is Clip` | Zod guard |
|
||||
| `canSplitAt` | `(clip, atSample, minDuration) => boolean` | Split feasibility |
|
||||
| `constrainBoundaryTrim` | `(clip, boundary, delta, minDuration, docLength) => object` | Trim geometry |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Like the track module, every helper is immutable and side-effect free. Clip geometry is expressed in samples because that is the unit the rendering adapters consume. Musical-time variants (`startTick`) are optional metadata and do not affect the geometric helpers. The `minDuration` parameter prevents clips from shrinking below a usable size, which is important when the user is dragging edges with a mouse.
|
||||
13
packages/media/audio/audio-timeline/src/clip/clip.schemas.ts
Normal file
13
packages/media/audio/audio-timeline/src/clip/clip.schemas.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import z from "zod";
|
||||
import { FadeCurveSchema } from "../engine/engine.schemas.js";
|
||||
|
||||
export const ClipSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
documentId: z.string().min(1),
|
||||
startSample: z.number().int(),
|
||||
offsetSamples: z.number().int().min(0),
|
||||
durationSamples: z.number().int().min(1),
|
||||
startTick: z.number().optional(),
|
||||
fadeIn: FadeCurveSchema.optional(),
|
||||
fadeOut: FadeCurveSchema.optional(),
|
||||
});
|
||||
221
packages/media/audio/audio-timeline/src/clip/clip.test.ts
Normal file
221
packages/media/audio/audio-timeline/src/clip/clip.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AUDIO_TIMELINE_CODES, TimelineError } from "../error-codes/index.js";
|
||||
import {
|
||||
canSplitAt,
|
||||
constrainBoundaryTrim,
|
||||
createClip,
|
||||
validateClip,
|
||||
} from "./clip.js";
|
||||
|
||||
describe("clip helpers", () => {
|
||||
it("createClip generates an id and applies defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clip = createClip({ documentId: "doc1" });
|
||||
expect(clip.id).toBeTruthy();
|
||||
expect(clip.documentId).toBe("doc1");
|
||||
expect(clip.startSample).toBe(0);
|
||||
expect(clip.offsetSamples).toBe(0);
|
||||
expect(clip.durationSamples).toBe(1);
|
||||
});
|
||||
|
||||
it("createClip accepts option overrides", { timeout: 5000 }, () => {
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 10,
|
||||
durationSamples: 100,
|
||||
});
|
||||
expect(clip.startSample).toBe(10);
|
||||
expect(clip.durationSamples).toBe(100);
|
||||
});
|
||||
|
||||
it("createClip throws for invalid geometry", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
createClip({ documentId: "doc1", durationSamples: 0 })
|
||||
).toThrow(TimelineError);
|
||||
try {
|
||||
createClip({ documentId: "doc1", durationSamples: 0 });
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.INVALID_CLIP_MOVE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("validateClip rejects invalid clip geometry with the Zod issues attached", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
validateClip({
|
||||
id: "",
|
||||
documentId: "d",
|
||||
startSample: 0,
|
||||
offsetSamples: 0,
|
||||
durationSamples: -1,
|
||||
})
|
||||
).toThrow(TimelineError);
|
||||
try {
|
||||
validateClip({
|
||||
id: "",
|
||||
documentId: "d",
|
||||
startSample: 0,
|
||||
offsetSamples: 0,
|
||||
durationSamples: -1,
|
||||
});
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.INVALID_CLIP_MOVE
|
||||
);
|
||||
expect((err as TimelineError).context).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("canSplitAt returns true for a valid interior point", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 0,
|
||||
durationSamples: 100,
|
||||
});
|
||||
expect(canSplitAt(clip, 50, 10)).toBe(true);
|
||||
});
|
||||
|
||||
it("canSplitAt returns false at boundaries", { timeout: 5000 }, () => {
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 0,
|
||||
durationSamples: 100,
|
||||
});
|
||||
expect(canSplitAt(clip, 0, 10)).toBe(false);
|
||||
expect(canSplitAt(clip, 100, 10)).toBe(false);
|
||||
});
|
||||
|
||||
it("canSplitAt returns false if a half would be shorter than minDuration", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 0,
|
||||
durationSamples: 100,
|
||||
});
|
||||
expect(canSplitAt(clip, 5, 10)).toBe(false);
|
||||
expect(canSplitAt(clip, 95, 10)).toBe(false);
|
||||
});
|
||||
|
||||
it("constrainBoundaryTrim left adjusts startSample and offsetSamples", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 10,
|
||||
offsetSamples: 5,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const result = constrainBoundaryTrim(clip, "left", 20, 10, 1000);
|
||||
expect(result.startSample).toBe(30);
|
||||
expect(result.offsetSamples).toBe(25);
|
||||
expect(result.durationSamples).toBe(80);
|
||||
});
|
||||
|
||||
it("constrainBoundaryTrim left clamps offsetSamples to 0", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 10,
|
||||
offsetSamples: 5,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const result = constrainBoundaryTrim(clip, "left", -10, 10, 1000);
|
||||
expect(result.startSample).toBe(5);
|
||||
expect(result.offsetSamples).toBe(0);
|
||||
expect(result.durationSamples).toBe(105);
|
||||
});
|
||||
|
||||
it("constrainBoundaryTrim left throws when duration would fall below minDuration", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 10,
|
||||
offsetSamples: 5,
|
||||
durationSamples: 100,
|
||||
});
|
||||
expect(() => constrainBoundaryTrim(clip, "left", 95, 10, 1000)).toThrow(
|
||||
TimelineError
|
||||
);
|
||||
try {
|
||||
constrainBoundaryTrim(clip, "left", 95, 10, 1000);
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.INVALID_TRIM
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("constrainBoundaryTrim right adjusts durationSamples", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 10,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const result = constrainBoundaryTrim(clip, "right", -20, 10, 1000);
|
||||
expect(result.startSample).toBe(10);
|
||||
expect(result.offsetSamples).toBe(0);
|
||||
expect(result.durationSamples).toBe(80);
|
||||
});
|
||||
|
||||
it("constrainBoundaryTrim right throws when duration would fall below minDuration", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 10,
|
||||
durationSamples: 100,
|
||||
});
|
||||
expect(() =>
|
||||
constrainBoundaryTrim(clip, "right", -95, 10, 1000)
|
||||
).toThrow(TimelineError);
|
||||
try {
|
||||
constrainBoundaryTrim(clip, "right", -95, 10, 1000);
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.INVALID_TRIM
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("createClip falls back to counter id when crypto.randomUUID is unavailable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = (crypto as unknown as Record<string, unknown>)[
|
||||
"randomUUID"
|
||||
];
|
||||
(crypto as unknown as Record<string, unknown>)["randomUUID"] =
|
||||
undefined;
|
||||
const clip = createClip({ documentId: "doc1" });
|
||||
expect(clip.id).toMatch(/^clip-\d+$/);
|
||||
(crypto as unknown as Record<string, unknown>)["randomUUID"] = original;
|
||||
});
|
||||
|
||||
it("createClip requires documentId", { timeout: 5000 }, () => {
|
||||
expect(() => createClip()).toThrow(TimelineError);
|
||||
});
|
||||
|
||||
it("createClip includes optional fields when provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startTick: 5,
|
||||
fadeIn: { durationSamples: 10, type: "linear" },
|
||||
fadeOut: { durationSamples: 10, type: "linear" },
|
||||
});
|
||||
expect(clip.startTick).toBe(5);
|
||||
expect(clip.fadeIn).toBeDefined();
|
||||
expect(clip.fadeOut).toBeDefined();
|
||||
});
|
||||
});
|
||||
98
packages/media/audio/audio-timeline/src/clip/clip.ts
Normal file
98
packages/media/audio/audio-timeline/src/clip/clip.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { AUDIO_TIMELINE_CODES, TimelineError } from "../error-codes/index.js";
|
||||
import { ClipSchema } from "./clip.schemas.js";
|
||||
import type { Clip } from "./clip.types.js";
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
function generateId(): string {
|
||||
if (
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof crypto.randomUUID === "function"
|
||||
) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `clip-${++idCounter}`;
|
||||
}
|
||||
|
||||
export function createClip(options: Partial<Clip> = {}): Clip {
|
||||
const clip: Clip = {
|
||||
id: options.id ?? generateId(),
|
||||
documentId: options.documentId ?? "",
|
||||
startSample: options.startSample ?? 0,
|
||||
offsetSamples: options.offsetSamples ?? 0,
|
||||
durationSamples: options.durationSamples ?? 1,
|
||||
...(options.startTick !== undefined && {
|
||||
startTick: options.startTick,
|
||||
}),
|
||||
...(options.fadeIn !== undefined && { fadeIn: options.fadeIn }),
|
||||
...(options.fadeOut !== undefined && { fadeOut: options.fadeOut }),
|
||||
};
|
||||
validateClip(clip);
|
||||
return clip;
|
||||
}
|
||||
|
||||
export function validateClip(clip: unknown): asserts clip is Clip {
|
||||
const result = ClipSchema.safeParse(clip);
|
||||
if (!result.success) {
|
||||
throw new TimelineError(
|
||||
AUDIO_TIMELINE_CODES.INVALID_CLIP_MOVE,
|
||||
"Clip validation failed",
|
||||
{ context: { issues: result.error.issues } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function canSplitAt(
|
||||
clip: Clip,
|
||||
atSample: number,
|
||||
minDuration: number
|
||||
): boolean {
|
||||
const end = clip.startSample + clip.durationSamples;
|
||||
if (atSample <= clip.startSample || atSample >= end) {
|
||||
return false;
|
||||
}
|
||||
const leftDuration = atSample - clip.startSample;
|
||||
const rightDuration = end - atSample;
|
||||
return leftDuration >= minDuration && rightDuration >= minDuration;
|
||||
}
|
||||
|
||||
export function constrainBoundaryTrim(
|
||||
clip: Clip,
|
||||
boundary: "left" | "right",
|
||||
deltaSamples: number,
|
||||
minDuration: number,
|
||||
_documentLengthSamples: number
|
||||
): { startSample: number; offsetSamples: number; durationSamples: number } {
|
||||
if (boundary === "left") {
|
||||
let effectiveDelta = deltaSamples;
|
||||
const maxNegativeDelta = -clip.offsetSamples;
|
||||
if (effectiveDelta < maxNegativeDelta) {
|
||||
effectiveDelta = maxNegativeDelta;
|
||||
}
|
||||
const newDuration = clip.durationSamples - effectiveDelta;
|
||||
if (newDuration < minDuration) {
|
||||
throw new TimelineError(
|
||||
AUDIO_TIMELINE_CODES.INVALID_TRIM,
|
||||
`Left trim would reduce duration below minDuration (${minDuration})`
|
||||
);
|
||||
}
|
||||
return {
|
||||
startSample: clip.startSample + effectiveDelta,
|
||||
offsetSamples: clip.offsetSamples + effectiveDelta,
|
||||
durationSamples: newDuration,
|
||||
};
|
||||
}
|
||||
|
||||
const newDuration = clip.durationSamples + deltaSamples;
|
||||
if (newDuration < minDuration) {
|
||||
throw new TimelineError(
|
||||
AUDIO_TIMELINE_CODES.INVALID_TRIM,
|
||||
`Right trim would reduce duration below minDuration (${minDuration})`
|
||||
);
|
||||
}
|
||||
return {
|
||||
startSample: clip.startSample,
|
||||
offsetSamples: clip.offsetSamples,
|
||||
durationSamples: newDuration,
|
||||
};
|
||||
}
|
||||
12
packages/media/audio/audio-timeline/src/clip/clip.types.ts
Normal file
12
packages/media/audio/audio-timeline/src/clip/clip.types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { FadeCurve } from "../engine/engine.types.js";
|
||||
|
||||
export interface Clip {
|
||||
readonly id: string;
|
||||
readonly documentId: string;
|
||||
readonly startSample: number;
|
||||
readonly offsetSamples: number;
|
||||
readonly durationSamples: number;
|
||||
readonly startTick?: number;
|
||||
readonly fadeIn?: FadeCurve;
|
||||
readonly fadeOut?: FadeCurve;
|
||||
}
|
||||
6
packages/media/audio/audio-timeline/src/clip/index.ts
Normal file
6
packages/media/audio/audio-timeline/src/clip/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
canSplitAt,
|
||||
constrainBoundaryTrim,
|
||||
createClip,
|
||||
validateClip,
|
||||
} from "./clip.js";
|
||||
35
packages/media/audio/audio-timeline/src/engine/clip/clip.md
Normal file
35
packages/media/audio/audio-timeline/src/engine/clip/clip.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Engine Clip Helpers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `clip.ts` module contains the internal bridge between `TimelineEngine` public methods and the pure `clip-operations.ts` helpers. It wraps each operation with OTel span recording, histogram timing, and event emission so the engine class stays focused on orchestration.
|
||||
|
||||
## Features
|
||||
|
||||
- `engineAddClip` appends a clip and emits `clipChange`
|
||||
- `engineRemoveClip` filters a clip and emits `clipChange`
|
||||
- `engineMoveClip` applies constrained drags, skips no-ops, and records duration
|
||||
- `engineTrimClip` trims a boundary and records a `timeline.clip_operation` span
|
||||
- `engineSplitClip` splits a clip and records a `timeline.clip_operation` span
|
||||
|
||||
## Usage
|
||||
|
||||
These helpers are used internally by `TimelineEngine`. Consumers interact with the engine's public methods instead.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `engineAddClip` | `(deps, trackId, clip) => void` | Add clip |
|
||||
| `engineRemoveClip` | `(deps, trackId, clipId) => void` | Remove clip |
|
||||
| `engineMoveClip` | `(deps, trackId, clipId, delta) => number` | Move clip |
|
||||
| `engineTrimClip` | `(deps, trackId, clipId, boundary, delta) => void` | Trim clip |
|
||||
| `engineSplitClip` | `(deps, trackId, clipId, atSample) => void` | Split clip |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `ClipDeps` interface decouples the helpers from the engine class, making them testable in isolation. Histograms record `audio_timeline_clip_operation_duration_ms` for every trim and split. No-op moves (where the constrained delta is zero) skip both the undo push and the event emission, keeping the history clean. OTel spans include the operation name, track id, and clip id so that performance traces can pinpoint slow interactions.
|
||||
105
packages/media/audio/audio-timeline/src/engine/clip/clip.ts
Normal file
105
packages/media/audio/audio-timeline/src/engine/clip/clip.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import type { Clip } from "../../clip/clip.types.js";
|
||||
import {
|
||||
moveClip,
|
||||
removeClip as removeClipOp,
|
||||
splitClip,
|
||||
trimClip,
|
||||
} from "../../operations/clip-operations/index.js";
|
||||
import type { Track } from "../../track/track.types.js";
|
||||
|
||||
export interface ClipDeps {
|
||||
findTrack(trackId: string): Track;
|
||||
updateTrackInPlace(trackId: string, updated: Track): void;
|
||||
notifyClip(trackId: string, clipId: string): void;
|
||||
recordHistogram(ms: number): void;
|
||||
}
|
||||
|
||||
export function engineAddClip(
|
||||
deps: ClipDeps,
|
||||
trackId: string,
|
||||
clip: Clip
|
||||
): void {
|
||||
const track = deps.findTrack(trackId);
|
||||
const updated: Track = { ...track, clips: [...track.clips, clip] };
|
||||
deps.updateTrackInPlace(trackId, updated);
|
||||
deps.notifyClip(trackId, clip.id);
|
||||
}
|
||||
|
||||
export function engineRemoveClip(
|
||||
deps: ClipDeps,
|
||||
trackId: string,
|
||||
clipId: string
|
||||
): void {
|
||||
const track = deps.findTrack(trackId);
|
||||
const updated = removeClipOp(track, clipId);
|
||||
deps.updateTrackInPlace(trackId, updated);
|
||||
deps.notifyClip(trackId, clipId);
|
||||
}
|
||||
|
||||
export function engineMoveClip(
|
||||
deps: ClipDeps,
|
||||
trackId: string,
|
||||
clipId: string,
|
||||
deltaSamples: number
|
||||
): number {
|
||||
const start = performance.now();
|
||||
const track = deps.findTrack(trackId);
|
||||
const result = moveClip(track, clipId, deltaSamples);
|
||||
if (result.appliedDelta !== 0) {
|
||||
deps.updateTrackInPlace(trackId, result.track);
|
||||
deps.notifyClip(trackId, clipId);
|
||||
}
|
||||
deps.recordHistogram(performance.now() - start);
|
||||
return result.appliedDelta;
|
||||
}
|
||||
|
||||
export function engineTrimClip(
|
||||
deps: ClipDeps,
|
||||
trackId: string,
|
||||
clipId: string,
|
||||
boundary: "left" | "right",
|
||||
deltaSamples: number
|
||||
): void {
|
||||
const start = performance.now();
|
||||
const track = deps.findTrack(trackId);
|
||||
const updated = trimClip(
|
||||
track,
|
||||
clipId,
|
||||
boundary,
|
||||
deltaSamples,
|
||||
1,
|
||||
Number.MAX_SAFE_INTEGER
|
||||
);
|
||||
deps.updateTrackInPlace(trackId, updated);
|
||||
deps.notifyClip(trackId, clipId);
|
||||
deps.recordHistogram(performance.now() - start);
|
||||
const tracer = trace.getTracer("@suaveplan/audio-timeline");
|
||||
tracer.startActiveSpan("timeline.clip_operation", (span) => {
|
||||
span.setAttribute("operation", "trim");
|
||||
span.setAttribute("trackId", trackId);
|
||||
span.setAttribute("clipId", clipId);
|
||||
span.end();
|
||||
});
|
||||
}
|
||||
|
||||
export function engineSplitClip(
|
||||
deps: ClipDeps,
|
||||
trackId: string,
|
||||
clipId: string,
|
||||
atSample: number
|
||||
): void {
|
||||
const start = performance.now();
|
||||
const track = deps.findTrack(trackId);
|
||||
const updated = splitClip(track, clipId, atSample, 1);
|
||||
deps.updateTrackInPlace(trackId, updated);
|
||||
deps.notifyClip(trackId, clipId);
|
||||
deps.recordHistogram(performance.now() - start);
|
||||
const tracer = trace.getTracer("@suaveplan/audio-timeline");
|
||||
tracer.startActiveSpan("timeline.clip_operation", (span) => {
|
||||
span.setAttribute("operation", "split");
|
||||
span.setAttribute("trackId", trackId);
|
||||
span.setAttribute("clipId", clipId);
|
||||
span.end();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type { ClipDeps } from "./clip.js";
|
||||
export {
|
||||
engineAddClip,
|
||||
engineMoveClip,
|
||||
engineRemoveClip,
|
||||
engineSplitClip,
|
||||
engineTrimClip,
|
||||
} from "./clip.js";
|
||||
52
packages/media/audio/audio-timeline/src/engine/engine.md
Normal file
52
packages/media/audio/audio-timeline/src/engine/engine.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# TimelineEngine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TimelineEngine` is the central state container for the `@suaveplan/audio-timeline` package. It owns the multi-track arrangement, playback transport, mixer state, zoom/selection view state, and a full undo/redo history. All rendering is delegated to a pluggable `TimelineAdapter`, keeping the engine headless and suitable for both web and native targets.
|
||||
|
||||
## Features
|
||||
|
||||
- Track CRUD: `setTracks`, `addTrack`, `removeTrack`, `updateTrack`, `selectTrack`
|
||||
- Clip CRUD: `addClip`, `removeClip`, `moveClip`, `trimClip`, `splitClip`
|
||||
- Transport: `play`, `pause`, `stop`, `seek` guarded by an internal FSM
|
||||
- Mixer: `setMasterVolume`, `setTrackVolume`, `setTrackMute`, `setTrackSolo`, `setTrackPan`
|
||||
- View: `zoomIn`, `zoomOut`, `setZoomLevel`, `setSelection`, `setLoopRegion`, `setLoopEnabled`
|
||||
- Undo/redo with transaction support (`beginTransaction`, `commitTransaction`, `abortTransaction`)
|
||||
- Serialization via `toJSON` and static `fromJSON`
|
||||
- Typed events via `on` / `off`
|
||||
- OTel spans and counters for clip operations and transport
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic
|
||||
|
||||
```ts
|
||||
import { TimelineEngine, NullTimelineAdapter } from "@suaveplan/audio-timeline";
|
||||
|
||||
const engine = new TimelineEngine({ sampleRate: 44100 });
|
||||
engine.setAdapter(new NullTimelineAdapter());
|
||||
engine.addTrack({ name: "Drums" });
|
||||
engine.play();
|
||||
```
|
||||
|
||||
### Advanced
|
||||
|
||||
```ts
|
||||
engine.beginTransaction();
|
||||
engine.addClip(trackId, clip);
|
||||
engine.moveClip(trackId, clip.id, 500);
|
||||
engine.commitTransaction();
|
||||
engine.undo();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
`TimelineEngine` exposes the full public surface documented in the spec. Key getters: `getState()` returns an immutable snapshot; `getCurrentTime()` reads from the adapter; `getTempo()` returns current BPM.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The engine is deliberately headless. It does not touch the DOM or Web Audio directly; every output goes through the adapter. State mutations push `Command<Track[]>` instances onto an `UndoStack`. On undo/redo, if the track count is unchanged the engine calls `adapter.updateTrack` per changed track instead of `adapter.setTracks` to avoid playback interruption. Events are emitted through an `EventBus` so UI layers can subscribe to granular changes without polling.
|
||||
@@ -0,0 +1,36 @@
|
||||
import z from "zod";
|
||||
|
||||
export const FadeCurveSchema = z.object({
|
||||
durationSamples: z.number().int().min(0),
|
||||
type: z.enum(["linear", "exponential", "logarithmic"]),
|
||||
});
|
||||
|
||||
export const TimelineEngineOptionsSchema = z.object({
|
||||
sampleRate: z.number().int().min(1).optional(),
|
||||
ppqn: z.number().int().min(1).optional(),
|
||||
logger: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const TimelineStateSchema = z.object({
|
||||
tracks: z.array(z.unknown()),
|
||||
tracksVersion: z.number().int().min(0),
|
||||
duration: z.number().min(0),
|
||||
currentTime: z.number().min(0),
|
||||
isPlaying: z.boolean(),
|
||||
samplesPerPixel: z.number().positive(),
|
||||
sampleRate: z.number().int().positive(),
|
||||
selectedTrackId: z.string().nullable(),
|
||||
zoomIndex: z.number().int().min(0),
|
||||
canZoomIn: z.boolean(),
|
||||
canZoomOut: z.boolean(),
|
||||
selectionStart: z.number().min(0),
|
||||
selectionEnd: z.number().min(0),
|
||||
masterVolume: z.number().min(0).max(1),
|
||||
loopStart: z.number().min(0),
|
||||
loopEnd: z.number().min(0),
|
||||
isLoopEnabled: z.boolean(),
|
||||
bpm: z.number().positive(),
|
||||
ppqn: z.number().int().positive(),
|
||||
canUndo: z.boolean(),
|
||||
canRedo: z.boolean(),
|
||||
});
|
||||
819
packages/media/audio/audio-timeline/src/engine/engine.test.ts
Normal file
819
packages/media/audio/audio-timeline/src/engine/engine.test.ts
Normal file
@@ -0,0 +1,819 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import type { TimelineAdapter } from "../adapter/index.js";
|
||||
import { NullTimelineAdapter } from "../adapter/index.js";
|
||||
import type { Clip } from "../clip/clip.types.js";
|
||||
import { AUDIO_TIMELINE_CODES, TimelineError } from "../error-codes/index.js";
|
||||
import { TimelineEngine } from "./index.js";
|
||||
|
||||
describe("TimelineEngine", () => {
|
||||
const stubAdapter = (): TimelineAdapter => new NullTimelineAdapter();
|
||||
|
||||
it("constructs with default options", () => {
|
||||
const engine = new TimelineEngine();
|
||||
expect(engine.getState().sampleRate).toBe(44100);
|
||||
expect(engine.getState().ppqn).toBe(96);
|
||||
expect(engine.getState().tracks).toEqual([]);
|
||||
expect(engine.getTempo()).toBe(120);
|
||||
});
|
||||
|
||||
it("constructs with custom options", () => {
|
||||
const engine = new TimelineEngine({ sampleRate: 48000, ppqn: 48 });
|
||||
expect(engine.getState().sampleRate).toBe(48000);
|
||||
expect(engine.getState().ppqn).toBe(48);
|
||||
});
|
||||
|
||||
it("computes duration from clips", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "A" });
|
||||
engine.addClip(engine.getState().tracks[0]!.id, {
|
||||
id: "c1",
|
||||
documentId: "d1",
|
||||
startSample: 0,
|
||||
offsetSamples: 0,
|
||||
durationSamples: 500,
|
||||
});
|
||||
engine.addTrack({ name: "B" });
|
||||
engine.addClip(engine.getState().tracks[1]!.id, {
|
||||
id: "c2",
|
||||
documentId: "d2",
|
||||
startSample: 0,
|
||||
offsetSamples: 0,
|
||||
durationSamples: 200,
|
||||
});
|
||||
expect(engine.getState().duration).toBe(500);
|
||||
});
|
||||
|
||||
describe("Track CRUD", () => {
|
||||
it("adds a track", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "Drums" });
|
||||
expect(engine.getState().tracks).toHaveLength(1);
|
||||
expect(engine.getState().tracks[0]!.name).toBe("Drums");
|
||||
});
|
||||
|
||||
it("sets tracks in bulk", () => {
|
||||
const engine = new TimelineEngine();
|
||||
const tracks = [
|
||||
{
|
||||
id: "t1",
|
||||
name: "Vocals",
|
||||
clips: [
|
||||
{
|
||||
id: "c1",
|
||||
documentId: "d1",
|
||||
startSample: 0,
|
||||
offsetSamples: 0,
|
||||
durationSamples: 100,
|
||||
},
|
||||
],
|
||||
volume: 1,
|
||||
pan: 0,
|
||||
muted: false,
|
||||
soloed: false,
|
||||
},
|
||||
];
|
||||
engine.setTracks(tracks);
|
||||
expect(engine.getState().tracks[0]!.name).toBe("Vocals");
|
||||
expect(engine.getState().tracks[0]!.clips).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("removes a track by id", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "A" });
|
||||
const id = engine.getState().tracks[0]!.id;
|
||||
engine.removeTrack(id);
|
||||
expect(engine.getState().tracks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("throws TRACK_NOT_FOUND when removing unknown track", () => {
|
||||
const engine = new TimelineEngine();
|
||||
expect(() => engine.removeTrack("no")).toThrow(TimelineError);
|
||||
try {
|
||||
engine.removeTrack("no");
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.TRACK_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("updates a track", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "Old" });
|
||||
const id = engine.getState().tracks[0]!.id;
|
||||
engine.updateTrack(id, { name: "New" });
|
||||
expect(engine.getState().tracks[0]!.name).toBe("New");
|
||||
});
|
||||
|
||||
it("throws TRACK_NOT_FOUND when updating unknown track", () => {
|
||||
const engine = new TimelineEngine();
|
||||
expect(() => engine.updateTrack("no", {})).toThrow(TimelineError);
|
||||
try {
|
||||
engine.updateTrack("no", {});
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.TRACK_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("selects a track", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "Gtr" });
|
||||
const id = engine.getState().tracks[0]!.id;
|
||||
engine.selectTrack(id);
|
||||
expect(engine.getState().selectedTrackId).toBe(id);
|
||||
});
|
||||
|
||||
it("deselects a track via null", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "Gtr" });
|
||||
const id = engine.getState().tracks[0]!.id;
|
||||
engine.selectTrack(id);
|
||||
engine.selectTrack(null);
|
||||
expect(engine.getState().selectedTrackId).toBeNull();
|
||||
});
|
||||
|
||||
it("throws TRACK_NOT_FOUND when selecting unknown track", () => {
|
||||
const engine = new TimelineEngine();
|
||||
expect(() => engine.selectTrack("no")).toThrow(TimelineError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Clip CRUD", () => {
|
||||
const makeTrack = () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "Track" });
|
||||
return { engine, trackId: engine.getState().tracks[0]!.id };
|
||||
};
|
||||
|
||||
const makeClip = (overrides: Partial<Clip> = {}): Clip => ({
|
||||
id: overrides.id ?? "c1",
|
||||
documentId: overrides.documentId ?? "d1",
|
||||
startSample: overrides.startSample ?? 0,
|
||||
offsetSamples: overrides.offsetSamples ?? 0,
|
||||
durationSamples: overrides.durationSamples ?? 1000,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("adds a clip to a track", () => {
|
||||
const { engine, trackId } = makeTrack();
|
||||
engine.addClip(trackId, makeClip());
|
||||
expect(engine.getState().tracks[0]!.clips).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("removes a clip", () => {
|
||||
const { engine, trackId } = makeTrack();
|
||||
engine.addClip(trackId, makeClip());
|
||||
engine.removeClip(trackId, "c1");
|
||||
expect(engine.getState().tracks[0]!.clips).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("throws CLIP_NOT_FOUND when removing unknown clip", () => {
|
||||
const { engine, trackId } = makeTrack();
|
||||
expect(() => engine.removeClip(trackId, "no")).toThrow(
|
||||
TimelineError
|
||||
);
|
||||
try {
|
||||
engine.removeClip(trackId, "no");
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.CLIP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("moves a clip", () => {
|
||||
const { engine, trackId } = makeTrack();
|
||||
engine.addClip(trackId, makeClip());
|
||||
const applied = engine.moveClip(trackId, "c1", 500);
|
||||
expect(applied).toBe(500);
|
||||
expect(engine.getState().tracks[0]!.clips[0]!.startSample).toBe(
|
||||
500
|
||||
);
|
||||
});
|
||||
|
||||
it("throws CLIP_NOT_FOUND when moving unknown clip", () => {
|
||||
const { engine, trackId } = makeTrack();
|
||||
expect(() => engine.moveClip(trackId, "no", 10)).toThrow(
|
||||
TimelineError
|
||||
);
|
||||
});
|
||||
|
||||
it("trims a clip on the left", () => {
|
||||
const { engine, trackId } = makeTrack();
|
||||
engine.addClip(
|
||||
trackId,
|
||||
makeClip({
|
||||
startSample: 100,
|
||||
offsetSamples: 0,
|
||||
durationSamples: 1000,
|
||||
})
|
||||
);
|
||||
engine.trimClip(trackId, "c1", "left", 50);
|
||||
const c = engine.getState().tracks[0]!.clips[0]!;
|
||||
expect(c.startSample).toBe(150);
|
||||
expect(c.offsetSamples).toBe(50);
|
||||
expect(c.durationSamples).toBe(950);
|
||||
});
|
||||
|
||||
it("trims a clip on the right", () => {
|
||||
const { engine, trackId } = makeTrack();
|
||||
engine.addClip(trackId, makeClip({ durationSamples: 1000 }));
|
||||
engine.trimClip(trackId, "c1", "right", -200);
|
||||
const c = engine.getState().tracks[0]!.clips[0]!;
|
||||
expect(c.durationSamples).toBe(800);
|
||||
});
|
||||
|
||||
it("throws INVALID_TRIM when trim below minDuration", () => {
|
||||
const { engine, trackId } = makeTrack();
|
||||
engine.addClip(trackId, makeClip({ durationSamples: 1000 }));
|
||||
expect(() =>
|
||||
engine.trimClip(trackId, "c1", "right", -1000)
|
||||
).toThrow(TimelineError);
|
||||
try {
|
||||
engine.trimClip(trackId, "c1", "right", -1000);
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.INVALID_TRIM
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("splits a clip", () => {
|
||||
const { engine, trackId } = makeTrack();
|
||||
engine.addClip(trackId, makeClip({ durationSamples: 1000 }));
|
||||
engine.splitClip(trackId, "c1", 500);
|
||||
const clips = engine.getState().tracks[0]!.clips;
|
||||
expect(clips).toHaveLength(2);
|
||||
expect(clips[0]!.durationSamples).toBe(500);
|
||||
expect(clips[1]!.durationSamples).toBe(500);
|
||||
expect(clips[1]!.startSample).toBe(500);
|
||||
});
|
||||
|
||||
it("throws INVALID_SPLIT for invalid split point", () => {
|
||||
const { engine, trackId } = makeTrack();
|
||||
engine.addClip(trackId, makeClip({ durationSamples: 1000 }));
|
||||
expect(() => engine.splitClip(trackId, "c1", 0)).toThrow(
|
||||
TimelineError
|
||||
);
|
||||
try {
|
||||
engine.splitClip(trackId, "c1", 0);
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.INVALID_SPLIT
|
||||
);
|
||||
}
|
||||
expect(() => engine.splitClip(trackId, "c1", 1000)).toThrow(
|
||||
TimelineError
|
||||
);
|
||||
try {
|
||||
engine.splitClip(trackId, "c1", 1000);
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.INVALID_SPLIT
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Undo / Redo", () => {
|
||||
it("undoes addTrack", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "A" });
|
||||
expect(engine.getState().tracks).toHaveLength(1);
|
||||
engine.undo();
|
||||
expect(engine.getState().tracks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("redoes addTrack", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "A" });
|
||||
engine.undo();
|
||||
expect(engine.getState().tracks).toHaveLength(0);
|
||||
engine.redo();
|
||||
expect(engine.getState().tracks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("undo is no-op when empty", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.undo();
|
||||
expect(engine.getState().tracks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("redo is no-op when empty", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.redo();
|
||||
expect(engine.getState().tracks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("tracks canUndo / canRedo", () => {
|
||||
const engine = new TimelineEngine();
|
||||
expect(engine.getState().canUndo).toBe(false);
|
||||
expect(engine.getState().canRedo).toBe(false);
|
||||
engine.addTrack({ name: "A" });
|
||||
expect(engine.getState().canUndo).toBe(true);
|
||||
engine.undo();
|
||||
expect(engine.getState().canUndo).toBe(false);
|
||||
expect(engine.getState().canRedo).toBe(true);
|
||||
});
|
||||
|
||||
it("undo does not push a new undo command", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "A" });
|
||||
engine.undo();
|
||||
expect(engine.getState().canUndo).toBe(false);
|
||||
});
|
||||
|
||||
it("undo with same track count uses incremental adapter update", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.setAdapter(stubAdapter());
|
||||
engine.addTrack({ name: "A" });
|
||||
const id = engine.getState().tracks[0]!.id;
|
||||
engine.updateTrack(id, { name: "B" });
|
||||
engine.undo();
|
||||
expect(engine.getState().tracks[0]!.name).toBe("A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Transactions", () => {
|
||||
it("commits a transaction as one undo step", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.beginTransaction();
|
||||
engine.addTrack({ name: "A" });
|
||||
engine.addTrack({ name: "B" });
|
||||
engine.commitTransaction();
|
||||
expect(engine.getState().tracks).toHaveLength(2);
|
||||
engine.undo();
|
||||
expect(engine.getState().tracks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("abort rolls back to pre-transaction state", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "Pre" });
|
||||
engine.beginTransaction();
|
||||
engine.addTrack({ name: "A" });
|
||||
engine.abortTransaction();
|
||||
expect(engine.getState().tracks).toHaveLength(1);
|
||||
expect(engine.getState().tracks[0]!.name).toBe("Pre");
|
||||
});
|
||||
|
||||
it("throws when committing without active transaction", () => {
|
||||
const engine = new TimelineEngine();
|
||||
expect(() => engine.commitTransaction()).toThrow(TimelineError);
|
||||
try {
|
||||
engine.commitTransaction();
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.TRANSACTION_NOT_ACTIVE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when aborting without active transaction", () => {
|
||||
const engine = new TimelineEngine();
|
||||
expect(() => engine.abortTransaction()).toThrow(TimelineError);
|
||||
try {
|
||||
engine.abortTransaction();
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.TRANSACTION_NOT_ACTIVE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("commitTransaction does nothing for empty transaction", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.beginTransaction();
|
||||
engine.commitTransaction();
|
||||
expect(engine.getState().canUndo).toBe(false);
|
||||
});
|
||||
|
||||
it("commitTransaction pushes single command directly", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.beginTransaction();
|
||||
engine.addTrack({ name: "A" });
|
||||
engine.commitTransaction();
|
||||
expect(engine.getState().tracks).toHaveLength(1);
|
||||
engine.undo();
|
||||
expect(engine.getState().tracks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zoom", () => {
|
||||
it("zooms in", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.zoomIn();
|
||||
expect(engine.getState().samplesPerPixel).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("zooms out", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.zoomIn();
|
||||
engine.zoomOut();
|
||||
expect(engine.getState().samplesPerPixel).toBe(1);
|
||||
});
|
||||
|
||||
it("sets zoom level", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.setZoomLevel(128);
|
||||
expect(engine.getState().samplesPerPixel).toBe(128);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Selection", () => {
|
||||
it("sets selection range", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "A" });
|
||||
engine.addClip(engine.getState().tracks[0]!.id, {
|
||||
id: "c1",
|
||||
documentId: "d1",
|
||||
startSample: 0,
|
||||
offsetSamples: 0,
|
||||
durationSamples: 200,
|
||||
});
|
||||
engine.setSelection(10, 100);
|
||||
expect(engine.getState().selectionStart).toBe(10);
|
||||
expect(engine.getState().selectionEnd).toBe(100);
|
||||
});
|
||||
|
||||
it("normalizes reversed selection", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "A" });
|
||||
engine.addClip(engine.getState().tracks[0]!.id, {
|
||||
id: "c1",
|
||||
documentId: "d1",
|
||||
startSample: 0,
|
||||
offsetSamples: 0,
|
||||
durationSamples: 200,
|
||||
});
|
||||
engine.setSelection(100, 10);
|
||||
expect(engine.getState().selectionStart).toBe(10);
|
||||
expect(engine.getState().selectionEnd).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Loop", () => {
|
||||
it("sets loop region", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.setLoopRegion(100, 500);
|
||||
expect(engine.getState().loopStart).toBe(100);
|
||||
expect(engine.getState().loopEnd).toBe(500);
|
||||
});
|
||||
|
||||
it("enables and disables loop", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.setLoopEnabled(true);
|
||||
expect(engine.getState().isLoopEnabled).toBe(true);
|
||||
engine.setLoopEnabled(false);
|
||||
expect(engine.getState().isLoopEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mixer", () => {
|
||||
const setup = () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "A" });
|
||||
const id = engine.getState().tracks[0]!.id;
|
||||
return { engine, id };
|
||||
};
|
||||
|
||||
it("sets master volume", () => {
|
||||
const { engine } = setup();
|
||||
engine.setMasterVolume(0.75);
|
||||
expect(engine.getState().masterVolume).toBe(0.75);
|
||||
});
|
||||
|
||||
it("clamps master volume to [0,1]", () => {
|
||||
const { engine } = setup();
|
||||
engine.setMasterVolume(2);
|
||||
expect(engine.getState().masterVolume).toBe(1);
|
||||
engine.setMasterVolume(-1);
|
||||
expect(engine.getState().masterVolume).toBe(0);
|
||||
});
|
||||
|
||||
it("sets track volume", () => {
|
||||
const { engine, id } = setup();
|
||||
engine.setTrackVolume(id, 0.6);
|
||||
expect(engine.getState().tracks[0]!.volume).toBe(0.6);
|
||||
});
|
||||
|
||||
it("sets track mute", () => {
|
||||
const { engine, id } = setup();
|
||||
engine.setTrackMute(id, true);
|
||||
expect(engine.getState().tracks[0]!.muted).toBe(true);
|
||||
});
|
||||
|
||||
it("sets track solo", () => {
|
||||
const { engine, id } = setup();
|
||||
engine.setTrackSolo(id, true);
|
||||
expect(engine.getState().tracks[0]!.soloed).toBe(true);
|
||||
});
|
||||
|
||||
it("sets track pan", () => {
|
||||
const { engine, id } = setup();
|
||||
engine.setTrackPan(id, -0.5);
|
||||
expect(engine.getState().tracks[0]!.pan).toBe(-0.5);
|
||||
});
|
||||
|
||||
it("clamps track pan to [-1,1]", () => {
|
||||
const { engine, id } = setup();
|
||||
engine.setTrackPan(id, 2);
|
||||
expect(engine.getState().tracks[0]!.pan).toBe(1);
|
||||
engine.setTrackPan(id, -3);
|
||||
expect(engine.getState().tracks[0]!.pan).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Transport", () => {
|
||||
it("throws ADAPTER_NOT_SET when playing without adapter", () => {
|
||||
const engine = new TimelineEngine();
|
||||
expect(() => engine.play()).toThrow(TimelineError);
|
||||
try {
|
||||
engine.play();
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.ADAPTER_NOT_SET
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws ADAPTER_NOT_SET when pausing without adapter", () => {
|
||||
const engine = new TimelineEngine();
|
||||
expect(() => engine.pause()).toThrow(TimelineError);
|
||||
try {
|
||||
engine.pause();
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.ADAPTER_NOT_SET
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws ADAPTER_NOT_SET when stopping without adapter", () => {
|
||||
const engine = new TimelineEngine();
|
||||
expect(() => engine.stop()).toThrow(TimelineError);
|
||||
try {
|
||||
engine.stop();
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.ADAPTER_NOT_SET
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws ADAPTER_NOT_SET when seeking without adapter", () => {
|
||||
const engine = new TimelineEngine();
|
||||
expect(() => engine.seek(0)).toThrow(TimelineError);
|
||||
try {
|
||||
engine.seek(0);
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.ADAPTER_NOT_SET
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("plays when adapter is set", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.setAdapter(stubAdapter());
|
||||
engine.play();
|
||||
expect(engine.getState().isPlaying).toBe(true);
|
||||
});
|
||||
|
||||
it("pauses when playing", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.setAdapter(stubAdapter());
|
||||
engine.play();
|
||||
engine.pause();
|
||||
expect(engine.getState().isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it("stops and resets time", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.setAdapter(stubAdapter());
|
||||
engine.seek(5000);
|
||||
engine.play();
|
||||
engine.stop();
|
||||
expect(engine.getState().currentTime).toBe(0);
|
||||
expect(engine.getState().isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it("seeks while stopped", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.setAdapter(stubAdapter());
|
||||
engine.seek(2000);
|
||||
expect(engine.getState().currentTime).toBe(2000);
|
||||
});
|
||||
|
||||
it("ignores play while already playing", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.setAdapter(stubAdapter());
|
||||
engine.play();
|
||||
const state = engine.getState();
|
||||
engine.play();
|
||||
expect(engine.getState().tracksVersion).toBe(state.tracksVersion);
|
||||
});
|
||||
|
||||
it("ignores pause while stopped", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.setAdapter(stubAdapter());
|
||||
engine.pause();
|
||||
expect(engine.getState().isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stop while already stopped", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.setAdapter(stubAdapter());
|
||||
engine.stop();
|
||||
expect(engine.getState().currentTime).toBe(0);
|
||||
expect(engine.getState().isPlaying).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Events", () => {
|
||||
it("emits trackChange on addTrack", () => {
|
||||
const engine = new TimelineEngine();
|
||||
const fn = vi.fn();
|
||||
engine.on("trackChange", fn);
|
||||
engine.addTrack({ name: "A" });
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits clipChange on addClip", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "A" });
|
||||
const fn = vi.fn();
|
||||
engine.on("clipChange", fn);
|
||||
engine.addClip(engine.getState().tracks[0]!.id, {
|
||||
id: "c1",
|
||||
documentId: "d1",
|
||||
startSample: 0,
|
||||
offsetSamples: 0,
|
||||
durationSamples: 100,
|
||||
});
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows unsubscribing via off", () => {
|
||||
const engine = new TimelineEngine();
|
||||
const fn = vi.fn();
|
||||
engine.on("trackChange", fn);
|
||||
engine.off("trackChange", fn);
|
||||
engine.addTrack({ name: "A" });
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns unsubscribe from on", () => {
|
||||
const engine = new TimelineEngine();
|
||||
const fn = vi.fn();
|
||||
const unsub = engine.on("trackChange", fn);
|
||||
unsub();
|
||||
engine.addTrack({ name: "A" });
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Serialization", () => {
|
||||
it("round-trips via toJSON / fromJSON", () => {
|
||||
const engine = new TimelineEngine({ sampleRate: 48000, ppqn: 48 });
|
||||
engine.addTrack({ name: "Vocals" });
|
||||
const trackId = engine.getState().tracks[0]!.id;
|
||||
engine.addClip(trackId, {
|
||||
id: "c1",
|
||||
documentId: "d1",
|
||||
startSample: 0,
|
||||
offsetSamples: 0,
|
||||
durationSamples: 1000,
|
||||
});
|
||||
engine.setMasterVolume(0.8);
|
||||
engine.setSelection(10, 100);
|
||||
engine.setLoopRegion(0, 2000);
|
||||
engine.setLoopEnabled(true);
|
||||
engine.setTempo(130);
|
||||
engine.zoomIn();
|
||||
|
||||
const json = engine.toJSON();
|
||||
const restored = TimelineEngine.fromJSON(json);
|
||||
expect(restored.getState().sampleRate).toBe(48000);
|
||||
expect(restored.getState().ppqn).toBe(48);
|
||||
expect(restored.getState().tracks).toHaveLength(1);
|
||||
expect(restored.getState().tracks[0]!.name).toBe("Vocals");
|
||||
expect(restored.getState().tracks[0]!.clips).toHaveLength(1);
|
||||
expect(restored.getState().masterVolume).toBe(0.8);
|
||||
expect(restored.getState().selectionStart).toBe(10);
|
||||
expect(restored.getState().selectionEnd).toBe(100);
|
||||
expect(restored.getState().loopStart).toBe(0);
|
||||
expect(restored.getState().loopEnd).toBe(2000);
|
||||
expect(restored.getState().isLoopEnabled).toBe(true);
|
||||
expect(restored.getTempo()).toBe(130);
|
||||
expect(restored.getState().samplesPerPixel).toBe(
|
||||
engine.getState().samplesPerPixel
|
||||
);
|
||||
});
|
||||
|
||||
it("fromJSON defaults zoomIndex when missing", () => {
|
||||
const json = new TimelineEngine().toJSON();
|
||||
const { zoomIndex: _, ...withoutZoom } = json;
|
||||
const restored = TimelineEngine.fromJSON(
|
||||
withoutZoom as typeof json
|
||||
);
|
||||
expect(restored.getState().zoomIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentTime", () => {
|
||||
it("returns internal time when no adapter is set", () => {
|
||||
const engine = new TimelineEngine();
|
||||
// Use fromJSON to set internal currentTime without adapter
|
||||
const json = engine.toJSON();
|
||||
const restored = TimelineEngine.fromJSON({
|
||||
...json,
|
||||
currentTime: 5000,
|
||||
});
|
||||
expect(restored.getCurrentTime()).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("init", () => {
|
||||
it("throws when signal is already aborted", async () => {
|
||||
const engine = new TimelineEngine();
|
||||
const controller = new AbortController();
|
||||
controller.abort("reason");
|
||||
await expect(
|
||||
engine.init({ signal: controller.signal })
|
||||
).rejects.toThrow(TimelineError);
|
||||
});
|
||||
|
||||
it("calls adapter.init when adapter is set", async () => {
|
||||
const engine = new TimelineEngine();
|
||||
const adapter = stubAdapter();
|
||||
let initCalled = false;
|
||||
adapter.init = () => {
|
||||
initCalled = true;
|
||||
};
|
||||
engine.setAdapter(adapter);
|
||||
await engine.init();
|
||||
expect(initCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("No-op clip move", () => {
|
||||
it("returns 0 when drag is fully constrained", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "A" });
|
||||
const trackId = engine.getState().tracks[0]!.id;
|
||||
engine.addClip(trackId, {
|
||||
id: "c1",
|
||||
documentId: "d1",
|
||||
startSample: 0,
|
||||
offsetSamples: 0,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const applied = engine.moveClip(trackId, "c1", -50);
|
||||
expect(applied).toBe(0);
|
||||
expect(engine.getState().tracks[0]!.clips[0]!.startSample).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Undo with swapped track ids", () => {
|
||||
it("handles incremental update when track ids change", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.setAdapter(stubAdapter());
|
||||
engine.addTrack({ id: "t1", name: "A" });
|
||||
engine.addTrack({ id: "t2", name: "B" });
|
||||
engine.setTracks([
|
||||
{
|
||||
id: "t3",
|
||||
name: "C",
|
||||
clips: [],
|
||||
volume: 1,
|
||||
pan: 0,
|
||||
muted: false,
|
||||
soloed: false,
|
||||
},
|
||||
{
|
||||
id: "t4",
|
||||
name: "D",
|
||||
clips: [],
|
||||
volume: 1,
|
||||
pan: 0,
|
||||
muted: false,
|
||||
soloed: false,
|
||||
},
|
||||
]);
|
||||
engine.undo();
|
||||
expect(engine.getState().tracks).toHaveLength(2);
|
||||
expect(engine.getState().tracks[0]!.id).toBe("t1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dispose", () => {
|
||||
it("disposes without error", () => {
|
||||
const engine = new TimelineEngine();
|
||||
engine.addTrack({ name: "A" });
|
||||
engine.dispose();
|
||||
expect(engine.getState().tracks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
548
packages/media/audio/audio-timeline/src/engine/engine.ts
Normal file
548
packages/media/audio/audio-timeline/src/engine/engine.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import type { TimelineAdapter } from "../adapter/index.js";
|
||||
import type { Clip } from "../clip/clip.types.js";
|
||||
import { AUDIO_TIMELINE_CODES, TimelineError } from "../error-codes/index.js";
|
||||
import { createTrack, removeTrack, updateTrack } from "../track/index.js";
|
||||
import type { Track } from "../track/track.types.js";
|
||||
import {
|
||||
engineAddClip,
|
||||
engineMoveClip,
|
||||
engineRemoveClip,
|
||||
engineSplitClip,
|
||||
engineTrimClip,
|
||||
} from "./clip/index.js";
|
||||
import type {
|
||||
TimelineEngineOptions,
|
||||
TimelineEventMap,
|
||||
TimelineSessionJSON,
|
||||
TimelineState,
|
||||
} from "./engine.types.js";
|
||||
import {
|
||||
engineSetMasterVolume,
|
||||
engineSetTrackMute,
|
||||
engineSetTrackPan,
|
||||
engineSetTrackSolo,
|
||||
engineSetTrackVolume,
|
||||
} from "./mixer/index.js";
|
||||
import { buildTimelineState } from "./state/index.js";
|
||||
import {
|
||||
enginePause,
|
||||
enginePlay,
|
||||
engineSeek,
|
||||
engineStop,
|
||||
} from "./transport/index.js";
|
||||
import { createPlaybackFsm } from "./transport-fsm/index.js";
|
||||
import {
|
||||
buildTracksCommand,
|
||||
createTracksUndoStack,
|
||||
findChangedTrackIds,
|
||||
type TracksCommand,
|
||||
} from "./undo/index.js";
|
||||
import {
|
||||
engineSetLoopEnabled,
|
||||
engineSetLoopRegion,
|
||||
engineSetSelection,
|
||||
engineSetZoomLevel,
|
||||
engineZoomIn,
|
||||
engineZoomOut,
|
||||
} from "./zoom/index.js";
|
||||
|
||||
function getMeter() {
|
||||
return metrics.getMeter("@suaveplan/audio-timeline");
|
||||
}
|
||||
|
||||
export class TimelineEngine {
|
||||
private _tracks: Track[] = [];
|
||||
private _tracksVersion = 0;
|
||||
private _sampleRate: number;
|
||||
private _ppqn: number;
|
||||
private _bpm = 120;
|
||||
private _currentTime = 0;
|
||||
private _isPlaying = false;
|
||||
private _zoomIndex = 0;
|
||||
private _selectedTrackId: string | null = null;
|
||||
private _selectionStart = 0;
|
||||
private _selectionEnd = 0;
|
||||
private _masterVolume = 1;
|
||||
private _loopStart = 0;
|
||||
private _loopEnd = 0;
|
||||
private _isLoopEnabled = false;
|
||||
private _adapter: TimelineAdapter | null = null;
|
||||
private _undoStack = createTracksUndoStack([]);
|
||||
private _bus = new EventBus<TimelineEventMap>();
|
||||
private _fsm = createPlaybackFsm();
|
||||
private _txActive = false;
|
||||
private _txCommands: TracksCommand[] = [];
|
||||
private _mutationsCounter = getMeter().createCounter(
|
||||
"audio_timeline_mutations_total"
|
||||
);
|
||||
private _undoCounter = getMeter().createCounter(
|
||||
"audio_timeline_undo_total"
|
||||
);
|
||||
private _opHistogram = getMeter().createHistogram(
|
||||
"audio_timeline_clip_operation_duration_ms"
|
||||
);
|
||||
|
||||
constructor(options?: TimelineEngineOptions) {
|
||||
this._sampleRate = options?.sampleRate ?? 44100;
|
||||
this._ppqn = options?.ppqn ?? 96;
|
||||
}
|
||||
|
||||
private _snapshot(): Track[] {
|
||||
return this._tracks.map((t) => ({
|
||||
...t,
|
||||
clips: t.clips.map((c) => ({ ...c })),
|
||||
}));
|
||||
}
|
||||
|
||||
private _setTracks(next: Track[], label: string): void {
|
||||
const prev = this._snapshot();
|
||||
this._tracks = next;
|
||||
this._tracksVersion++;
|
||||
this._pushUndo(prev, next, label);
|
||||
this._adapter?.setTracks(this._snapshot());
|
||||
for (const t of this._tracks)
|
||||
this._bus.emit("trackChange", { trackId: t.id });
|
||||
}
|
||||
|
||||
private _pushUndo(prev: Track[], next: Track[], label: string): void {
|
||||
const cmd = buildTracksCommand(label, prev, next);
|
||||
if (this._txActive) this._txCommands.push(cmd);
|
||||
else this._undoStack.push(cmd);
|
||||
this._mutationsCounter.add(1);
|
||||
}
|
||||
|
||||
private _findTrack(trackId: string): Track {
|
||||
const t = this._tracks.find((tr) => tr.id === trackId);
|
||||
if (!t)
|
||||
throw new TimelineError(
|
||||
AUDIO_TIMELINE_CODES.TRACK_NOT_FOUND,
|
||||
`Track not found: ${trackId}`,
|
||||
{ context: { trackId } }
|
||||
);
|
||||
return t;
|
||||
}
|
||||
|
||||
private _updateTrackInPlace(trackId: string, updated: Track): void {
|
||||
const prev = this._snapshot();
|
||||
this._tracks = this._tracks.map((t) =>
|
||||
t.id === trackId ? updated : t
|
||||
);
|
||||
this._tracksVersion++;
|
||||
this._pushUndo(prev, this._snapshot(), "updateTrack");
|
||||
this._adapter?.updateTrack(trackId, updated);
|
||||
this._bus.emit("trackChange", { trackId });
|
||||
}
|
||||
|
||||
private _assertAdapter(): TimelineAdapter {
|
||||
if (!this._adapter)
|
||||
throw new TimelineError(
|
||||
AUDIO_TIMELINE_CODES.ADAPTER_NOT_SET,
|
||||
"No TimelineAdapter is set on this engine"
|
||||
);
|
||||
return this._adapter;
|
||||
}
|
||||
|
||||
private _fsmSend(event: string): boolean {
|
||||
try {
|
||||
this._fsm.send(event);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private _syncAdapterAfterUndoRedo(
|
||||
prevTracks: Track[],
|
||||
prevCount: number
|
||||
): void {
|
||||
const nextCount = this._tracks.length;
|
||||
if (prevCount !== nextCount) {
|
||||
this._adapter?.setTracks(this._snapshot());
|
||||
} else {
|
||||
for (const id of findChangedTrackIds(prevTracks, this._tracks)) {
|
||||
const t = this._tracks.find((tr) => tr.id === id);
|
||||
if (t) this._adapter?.updateTrack(id, t);
|
||||
}
|
||||
}
|
||||
for (const t of this._tracks)
|
||||
this._bus.emit("trackChange", { trackId: t.id });
|
||||
}
|
||||
|
||||
getState(): TimelineState {
|
||||
return buildTimelineState({
|
||||
tracks: this._tracks,
|
||||
tracksVersion: this._tracksVersion,
|
||||
sampleRate: this._sampleRate,
|
||||
ppqn: this._ppqn,
|
||||
bpm: this._bpm,
|
||||
currentTime: this._currentTime,
|
||||
isPlaying: this._isPlaying,
|
||||
masterVolume: this._masterVolume,
|
||||
loopStart: this._loopStart,
|
||||
loopEnd: this._loopEnd,
|
||||
isLoopEnabled: this._isLoopEnabled,
|
||||
selectionStart: this._selectionStart,
|
||||
selectionEnd: this._selectionEnd,
|
||||
zoomIndex: this._zoomIndex,
|
||||
selectedTrackId: this._selectedTrackId,
|
||||
canUndo: this._undoStack.canUndo,
|
||||
canRedo: this._undoStack.canRedo,
|
||||
});
|
||||
}
|
||||
|
||||
async init(options?: { signal?: AbortSignal }): Promise<void> {
|
||||
if (options?.signal?.aborted) {
|
||||
throw new TimelineError(
|
||||
AUDIO_TIMELINE_CODES.ADAPTER_NOT_SET,
|
||||
"init aborted",
|
||||
{ context: { reason: options.signal.reason } }
|
||||
);
|
||||
}
|
||||
this._adapter?.init(options);
|
||||
}
|
||||
|
||||
setAdapter(adapter: TimelineAdapter): void {
|
||||
this._adapter = adapter;
|
||||
}
|
||||
|
||||
// Tracks
|
||||
setTracks(tracks: Track[]): void {
|
||||
this._setTracks(
|
||||
tracks.map((t) => ({
|
||||
...t,
|
||||
clips: t.clips.map((c) => ({ ...c })),
|
||||
})),
|
||||
"setTracks"
|
||||
);
|
||||
}
|
||||
addTrack(options?: Partial<Track>): void {
|
||||
this._setTracks([...this._tracks, createTrack(options)], "addTrack");
|
||||
}
|
||||
removeTrack(trackId: string): void {
|
||||
this._findTrack(trackId);
|
||||
this._setTracks(removeTrack(this._tracks, trackId), "removeTrack");
|
||||
}
|
||||
updateTrack(trackId: string, updates: Partial<Track>): void {
|
||||
this._findTrack(trackId);
|
||||
this._updateTrackInPlace(
|
||||
trackId,
|
||||
updateTrack(this._findTrack(trackId), updates)
|
||||
);
|
||||
}
|
||||
selectTrack(trackId: string | null): void {
|
||||
if (trackId !== null) this._findTrack(trackId);
|
||||
this._selectedTrackId = trackId;
|
||||
}
|
||||
|
||||
// Clips
|
||||
addClip(trackId: string, clip: Clip): void {
|
||||
engineAddClip(this._clipDeps(), trackId, clip);
|
||||
}
|
||||
removeClip(trackId: string, clipId: string): void {
|
||||
engineRemoveClip(this._clipDeps(), trackId, clipId);
|
||||
}
|
||||
moveClip(trackId: string, clipId: string, deltaSamples: number): number {
|
||||
return engineMoveClip(this._clipDeps(), trackId, clipId, deltaSamples);
|
||||
}
|
||||
trimClip(
|
||||
trackId: string,
|
||||
clipId: string,
|
||||
boundary: "left" | "right",
|
||||
deltaSamples: number
|
||||
): void {
|
||||
engineTrimClip(
|
||||
this._clipDeps(),
|
||||
trackId,
|
||||
clipId,
|
||||
boundary,
|
||||
deltaSamples
|
||||
);
|
||||
}
|
||||
splitClip(trackId: string, clipId: string, atSample: number): void {
|
||||
engineSplitClip(this._clipDeps(), trackId, clipId, atSample);
|
||||
}
|
||||
|
||||
private _clipDeps() {
|
||||
return {
|
||||
findTrack: (id: string) => this._findTrack(id),
|
||||
updateTrackInPlace: (id: string, u: Track) =>
|
||||
this._updateTrackInPlace(id, u),
|
||||
notifyClip: (tid: string, cid: string) =>
|
||||
this._bus.emit("clipChange", { trackId: tid, clipId: cid }),
|
||||
recordHistogram: (ms: number) => this._opHistogram.record(ms),
|
||||
};
|
||||
}
|
||||
|
||||
// Mixer
|
||||
setMasterVolume(volume: number): void {
|
||||
engineSetMasterVolume(this._mixerDeps(), volume);
|
||||
}
|
||||
setTrackVolume(trackId: string, volume: number): void {
|
||||
engineSetTrackVolume(this._mixerDeps(), trackId, volume);
|
||||
}
|
||||
setTrackMute(trackId: string, muted: boolean): void {
|
||||
engineSetTrackMute(this._mixerDeps(), trackId, muted);
|
||||
}
|
||||
setTrackSolo(trackId: string, soloed: boolean): void {
|
||||
engineSetTrackSolo(this._mixerDeps(), trackId, soloed);
|
||||
}
|
||||
setTrackPan(trackId: string, pan: number): void {
|
||||
engineSetTrackPan(this._mixerDeps(), trackId, pan);
|
||||
}
|
||||
|
||||
private _mixerDeps() {
|
||||
return {
|
||||
findTrack: (id: string) => this._findTrack(id),
|
||||
updateTrackInPlace: (id: string, u: Track) =>
|
||||
this._updateTrackInPlace(id, u),
|
||||
pushUndo: (label: string) =>
|
||||
this._pushUndo(this._snapshot(), this._snapshot(), label),
|
||||
snapshot: () => this._snapshot(),
|
||||
setMasterVolumeState: (v: number) => {
|
||||
this._masterVolume = v;
|
||||
},
|
||||
adapterSetMasterVolume: (v: number) =>
|
||||
this._adapter?.setMasterVolume(v),
|
||||
adapterSetTrackVolume: (id: string, v: number) =>
|
||||
this._adapter?.setTrackVolume(id, v),
|
||||
adapterSetTrackMute: (id: string, m: boolean) =>
|
||||
this._adapter?.setTrackMute(id, m),
|
||||
adapterSetTrackSolo: (id: string, s: boolean) =>
|
||||
this._adapter?.setTrackSolo(id, s),
|
||||
adapterSetTrackPan: (id: string, p: number) =>
|
||||
this._adapter?.setTrackPan(id, p),
|
||||
emitTrackChange: (id: string) =>
|
||||
this._bus.emit("trackChange", { trackId: id }),
|
||||
};
|
||||
}
|
||||
|
||||
// Zoom / Selection / Loop
|
||||
zoomIn(): void {
|
||||
engineZoomIn(this._zoomDeps());
|
||||
}
|
||||
zoomOut(): void {
|
||||
engineZoomOut(this._zoomDeps());
|
||||
}
|
||||
setZoomLevel(samplesPerPixel: number): void {
|
||||
engineSetZoomLevel(this._zoomDeps(), samplesPerPixel);
|
||||
}
|
||||
setSelection(start: number, end: number): void {
|
||||
engineSetSelection(this._zoomDeps(), start, end);
|
||||
}
|
||||
setLoopRegion(start: number, end: number): void {
|
||||
engineSetLoopRegion(this._zoomDeps(), start, end);
|
||||
}
|
||||
setLoopEnabled(enabled: boolean): void {
|
||||
engineSetLoopEnabled(this._zoomDeps(), enabled);
|
||||
}
|
||||
|
||||
private _zoomDeps() {
|
||||
return {
|
||||
zoomIndex: this._zoomIndex,
|
||||
setZoomIndex: (v: number) => {
|
||||
this._zoomIndex = v;
|
||||
},
|
||||
selectionStart: this._selectionStart,
|
||||
setSelectionStart: (v: number) => {
|
||||
this._selectionStart = v;
|
||||
},
|
||||
selectionEnd: this._selectionEnd,
|
||||
setSelectionEnd: (v: number) => {
|
||||
this._selectionEnd = v;
|
||||
},
|
||||
loopStart: this._loopStart,
|
||||
setLoopStart: (v: number) => {
|
||||
this._loopStart = v;
|
||||
},
|
||||
loopEnd: this._loopEnd,
|
||||
setLoopEnd: (v: number) => {
|
||||
this._loopEnd = v;
|
||||
},
|
||||
isLoopEnabled: this._isLoopEnabled,
|
||||
setLoopEnabled: (v: boolean) => {
|
||||
this._isLoopEnabled = v;
|
||||
},
|
||||
getState: () => this.getState(),
|
||||
adapterSetLoop: (s: number, e: number, en: boolean) =>
|
||||
this._adapter?.setLoop(s, e, en),
|
||||
emit: (event: string, payload: unknown) => {
|
||||
this._bus.emit(
|
||||
event as keyof TimelineEventMap,
|
||||
payload as never
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Transport
|
||||
play(): void {
|
||||
enginePlay(this._transportDeps());
|
||||
}
|
||||
pause(): void {
|
||||
enginePause(this._transportDeps());
|
||||
}
|
||||
stop(): void {
|
||||
engineStop(this._transportDeps());
|
||||
}
|
||||
seek(time: number): void {
|
||||
engineSeek(this._transportDeps(), time);
|
||||
}
|
||||
getCurrentTime(): number {
|
||||
return this._adapter?.getCurrentTime() ?? this._currentTime;
|
||||
}
|
||||
|
||||
private _transportDeps() {
|
||||
return {
|
||||
assertAdapter: () => this._assertAdapter(),
|
||||
fsmSend: (e: string) => this._fsmSend(e),
|
||||
isPlaying: this._isPlaying,
|
||||
currentTime: this._currentTime,
|
||||
setIsPlaying: (v: boolean) => {
|
||||
this._isPlaying = v;
|
||||
},
|
||||
setCurrentTime: (v: number) => {
|
||||
this._currentTime = v;
|
||||
},
|
||||
emit: (event: string, payload: number) => {
|
||||
this._bus.emit(event as keyof TimelineEventMap, payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Tempo
|
||||
setTempo(bpm: number, atTick?: number): void {
|
||||
const prev = this._snapshot();
|
||||
this._bpm = bpm;
|
||||
this._pushUndo(prev, this._snapshot(), "setTempo");
|
||||
this._adapter?.setTempo(bpm, atTick);
|
||||
this._bus.emit("tempoChange", { bpm });
|
||||
}
|
||||
getTempo(): number {
|
||||
return this._bpm;
|
||||
}
|
||||
|
||||
// Undo / Redo
|
||||
undo(): void {
|
||||
if (!this._undoStack.canUndo) return;
|
||||
const prevCount = this._tracks.length;
|
||||
const prev = this._snapshot();
|
||||
this._tracks = this._undoStack.undo();
|
||||
this._tracksVersion++;
|
||||
this._undoCounter.add(1);
|
||||
this._syncAdapterAfterUndoRedo(prev, prevCount);
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
if (!this._undoStack.canRedo) return;
|
||||
const prevCount = this._tracks.length;
|
||||
const prev = this._snapshot();
|
||||
this._tracks = this._undoStack.redo();
|
||||
this._tracksVersion++;
|
||||
this._undoCounter.add(1);
|
||||
this._syncAdapterAfterUndoRedo(prev, prevCount);
|
||||
}
|
||||
|
||||
// Transactions
|
||||
beginTransaction(): void {
|
||||
this._txActive = true;
|
||||
this._txCommands = [];
|
||||
}
|
||||
|
||||
commitTransaction(): void {
|
||||
if (!this._txActive)
|
||||
throw new TimelineError(
|
||||
AUDIO_TIMELINE_CODES.TRANSACTION_NOT_ACTIVE,
|
||||
"No transaction is active"
|
||||
);
|
||||
this._txActive = false;
|
||||
if (this._txCommands.length === 0) return;
|
||||
if (this._txCommands.length === 1) {
|
||||
this._undoStack.push(this._txCommands[0]!);
|
||||
return;
|
||||
}
|
||||
const first = this._txCommands[0]!;
|
||||
const last = this._txCommands[this._txCommands.length - 1]!;
|
||||
this._undoStack.push({
|
||||
id: `tx-${Date.now()}`,
|
||||
label: `transaction (${this._txCommands.length} ops)`,
|
||||
timestamp: Date.now(),
|
||||
execute: () => last.execute(this._tracks),
|
||||
undo: () => first.undo(this._tracks),
|
||||
});
|
||||
}
|
||||
|
||||
abortTransaction(): void {
|
||||
if (!this._txActive)
|
||||
throw new TimelineError(
|
||||
AUDIO_TIMELINE_CODES.TRANSACTION_NOT_ACTIVE,
|
||||
"No transaction is active"
|
||||
);
|
||||
this._txActive = false;
|
||||
for (let i = this._txCommands.length - 1; i >= 0; i--)
|
||||
this._tracks = this._txCommands[i]!.undo(this._tracks);
|
||||
this._txCommands = [];
|
||||
}
|
||||
|
||||
// Events
|
||||
on<K extends keyof TimelineEventMap>(
|
||||
event: K,
|
||||
listener: (payload: TimelineEventMap[K]) => void
|
||||
): () => void {
|
||||
return this._bus.on(event, listener as (payload: unknown) => void);
|
||||
}
|
||||
off<K extends keyof TimelineEventMap>(
|
||||
event: K,
|
||||
listener: (payload: TimelineEventMap[K]) => void
|
||||
): void {
|
||||
this._bus.off(event, listener as (payload: unknown) => void);
|
||||
}
|
||||
|
||||
// Serialize
|
||||
toJSON(): TimelineSessionJSON {
|
||||
return {
|
||||
tracks: this._snapshot(),
|
||||
sampleRate: this._sampleRate,
|
||||
ppqn: this._ppqn,
|
||||
bpm: this._bpm,
|
||||
currentTime: this._currentTime,
|
||||
masterVolume: this._masterVolume,
|
||||
loopStart: this._loopStart,
|
||||
loopEnd: this._loopEnd,
|
||||
isLoopEnabled: this._isLoopEnabled,
|
||||
selectionStart: this._selectionStart,
|
||||
selectionEnd: this._selectionEnd,
|
||||
samplesPerPixel: this.getState().samplesPerPixel,
|
||||
zoomIndex: this._zoomIndex,
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON(json: TimelineSessionJSON): TimelineEngine {
|
||||
const engine = new TimelineEngine({
|
||||
sampleRate: json.sampleRate,
|
||||
ppqn: json.ppqn,
|
||||
});
|
||||
engine._tracks = json.tracks.map((t) => ({
|
||||
...t,
|
||||
clips: t.clips.map((c) => ({ ...c })),
|
||||
}));
|
||||
engine._bpm = json.bpm;
|
||||
engine._currentTime = json.currentTime;
|
||||
engine._masterVolume = json.masterVolume;
|
||||
engine._loopStart = json.loopStart;
|
||||
engine._loopEnd = json.loopEnd;
|
||||
engine._isLoopEnabled = json.isLoopEnabled;
|
||||
engine._selectionStart = json.selectionStart;
|
||||
engine._selectionEnd = json.selectionEnd;
|
||||
engine._zoomIndex = json.zoomIndex ?? 0;
|
||||
return engine;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._adapter?.dispose();
|
||||
this._undoStack.clear();
|
||||
this._bus.clear();
|
||||
this._tracks = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Track } from "../track/track.types.js";
|
||||
|
||||
export interface FadeCurve {
|
||||
readonly durationSamples: number;
|
||||
readonly type: "linear" | "exponential" | "logarithmic";
|
||||
}
|
||||
|
||||
export interface TimelineEngineOptions {
|
||||
readonly sampleRate?: number;
|
||||
readonly ppqn?: number;
|
||||
readonly logger?: unknown;
|
||||
}
|
||||
|
||||
export interface TimelineState {
|
||||
readonly tracks: Track[];
|
||||
readonly tracksVersion: number;
|
||||
readonly duration: number;
|
||||
readonly currentTime: number;
|
||||
readonly isPlaying: boolean;
|
||||
readonly samplesPerPixel: number;
|
||||
readonly sampleRate: number;
|
||||
readonly selectedTrackId: string | null;
|
||||
readonly zoomIndex: number;
|
||||
readonly canZoomIn: boolean;
|
||||
readonly canZoomOut: boolean;
|
||||
readonly selectionStart: number;
|
||||
readonly selectionEnd: number;
|
||||
readonly masterVolume: number;
|
||||
readonly loopStart: number;
|
||||
readonly loopEnd: number;
|
||||
readonly isLoopEnabled: boolean;
|
||||
readonly bpm: number;
|
||||
readonly ppqn: number;
|
||||
readonly canUndo: boolean;
|
||||
readonly canRedo: boolean;
|
||||
}
|
||||
|
||||
export interface TimelineEventMap extends Record<string, unknown> {
|
||||
play: number;
|
||||
pause: number;
|
||||
stop: number;
|
||||
seek: number;
|
||||
trackChange: { trackId: string };
|
||||
clipChange: { trackId: string; clipId: string };
|
||||
tempoChange: { bpm: number };
|
||||
loopChange: { start: number; end: number; enabled: boolean };
|
||||
selectionChange: { start: number; end: number };
|
||||
zoomChange: { samplesPerPixel: number };
|
||||
}
|
||||
|
||||
export interface TimelineSessionJSON {
|
||||
readonly tracks: Track[];
|
||||
readonly sampleRate: number;
|
||||
readonly ppqn: number;
|
||||
readonly bpm: number;
|
||||
readonly currentTime: number;
|
||||
readonly masterVolume: number;
|
||||
readonly loopStart: number;
|
||||
readonly loopEnd: number;
|
||||
readonly isLoopEnabled: boolean;
|
||||
readonly selectionStart: number;
|
||||
readonly selectionEnd: number;
|
||||
readonly samplesPerPixel: number;
|
||||
readonly zoomIndex: number;
|
||||
}
|
||||
1
packages/media/audio/audio-timeline/src/engine/index.ts
Normal file
1
packages/media/audio/audio-timeline/src/engine/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { TimelineEngine } from "./engine.js";
|
||||
@@ -0,0 +1,8 @@
|
||||
export type { MixerDeps } from "./mixer.js";
|
||||
export {
|
||||
engineSetMasterVolume,
|
||||
engineSetTrackMute,
|
||||
engineSetTrackPan,
|
||||
engineSetTrackSolo,
|
||||
engineSetTrackVolume,
|
||||
} from "./mixer.js";
|
||||
@@ -0,0 +1,35 @@
|
||||
# Engine Mixer Helpers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `mixer.ts` module bridges `TimelineEngine` mixer public methods to the pure `mixer.ts` helpers. It manages undo pushes and adapter forwarding for every mixer mutation, keeping the engine class from growing too large.
|
||||
|
||||
## Features
|
||||
|
||||
- `engineSetMasterVolume` updates master volume and pushes undo
|
||||
- `engineSetTrackVolume` delegates to pure helper and forwards to adapter
|
||||
- `engineSetTrackMute` toggles mute and emits via adapter
|
||||
- `engineSetTrackSolo` toggles solo and emits via adapter
|
||||
- `engineSetTrackPan` clamps pan and emits via adapter
|
||||
|
||||
## Usage
|
||||
|
||||
These helpers are used internally by `TimelineEngine`. Consumers call `engine.setTrackVolume(id, 0.8)` etc.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `engineSetMasterVolume` | `(deps, volume) => void` | Master volume |
|
||||
| `engineSetTrackVolume` | `(deps, trackId, volume) => void` | Track volume |
|
||||
| `engineSetTrackMute` | `(deps, trackId, muted) => void` | Track mute |
|
||||
| `engineSetTrackSolo` | `(deps, trackId, soloed) => void` | Track solo |
|
||||
| `engineSetTrackPan` | `(deps, trackId, pan) => void` | Track pan |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `MixerDeps` interface isolates the helpers from the engine class. Every track mutation goes through `updateTrackInPlace`, which pushes an undo command automatically. The master volume operation is special-cased because it does not live on a track; it pushes its own undo command directly. All other mixer state lives on individual tracks and is therefore covered by the standard track-update undo path. This symmetry makes it easy to reason about what changes are reversible.
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
setTrackMute,
|
||||
setTrackPan,
|
||||
setTrackSolo,
|
||||
setTrackVolume,
|
||||
} from "../../mixer/index.js";
|
||||
import type { Track } from "../../track/track.types.js";
|
||||
|
||||
export interface MixerDeps {
|
||||
findTrack(trackId: string): Track;
|
||||
updateTrackInPlace(trackId: string, updated: Track): void;
|
||||
pushUndo(label: string): void;
|
||||
snapshot(): Track[];
|
||||
setMasterVolumeState(value: number): void;
|
||||
adapterSetMasterVolume(value: number): void;
|
||||
adapterSetTrackVolume(trackId: string, value: number): void;
|
||||
adapterSetTrackMute(trackId: string, muted: boolean): void;
|
||||
adapterSetTrackSolo(trackId: string, soloed: boolean): void;
|
||||
adapterSetTrackPan(trackId: string, pan: number): void;
|
||||
emitTrackChange(trackId: string): void;
|
||||
}
|
||||
|
||||
export function engineSetMasterVolume(deps: MixerDeps, volume: number): void {
|
||||
deps.snapshot();
|
||||
const clamped = Math.max(0, Math.min(1, volume));
|
||||
deps.setMasterVolumeState(clamped);
|
||||
deps.adapterSetMasterVolume(clamped);
|
||||
deps.pushUndo("setMasterVolume");
|
||||
deps.emitTrackChange("master");
|
||||
}
|
||||
|
||||
export function engineSetTrackVolume(
|
||||
deps: MixerDeps,
|
||||
trackId: string,
|
||||
volume: number
|
||||
): void {
|
||||
const track = deps.findTrack(trackId);
|
||||
const updated = setTrackVolume(track, volume);
|
||||
deps.updateTrackInPlace(trackId, updated);
|
||||
deps.adapterSetTrackVolume(trackId, volume);
|
||||
deps.emitTrackChange(trackId);
|
||||
}
|
||||
|
||||
export function engineSetTrackMute(
|
||||
deps: MixerDeps,
|
||||
trackId: string,
|
||||
muted: boolean
|
||||
): void {
|
||||
const track = deps.findTrack(trackId);
|
||||
const updated = setTrackMute(track, muted);
|
||||
deps.updateTrackInPlace(trackId, updated);
|
||||
deps.adapterSetTrackMute(trackId, muted);
|
||||
deps.emitTrackChange(trackId);
|
||||
}
|
||||
|
||||
export function engineSetTrackSolo(
|
||||
deps: MixerDeps,
|
||||
trackId: string,
|
||||
soloed: boolean
|
||||
): void {
|
||||
const track = deps.findTrack(trackId);
|
||||
const updated = setTrackSolo(track, soloed);
|
||||
deps.updateTrackInPlace(trackId, updated);
|
||||
deps.adapterSetTrackSolo(trackId, soloed);
|
||||
deps.emitTrackChange(trackId);
|
||||
}
|
||||
|
||||
export function engineSetTrackPan(
|
||||
deps: MixerDeps,
|
||||
trackId: string,
|
||||
pan: number
|
||||
): void {
|
||||
const track = deps.findTrack(trackId);
|
||||
const updated = setTrackPan(track, pan);
|
||||
deps.updateTrackInPlace(trackId, updated);
|
||||
deps.adapterSetTrackPan(trackId, pan);
|
||||
deps.emitTrackChange(trackId);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { StateDeps } from "./state.js";
|
||||
export { buildTimelineState } from "./state.js";
|
||||
@@ -0,0 +1,28 @@
|
||||
# Engine State Builder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `state.ts` module builds the immutable `TimelineState` snapshot returned by `TimelineEngine.getState()`. It derives computed fields such as `duration`, `samplesPerPixel`, `canZoomIn`, and `canZoomOut` from the engine's internal fields so consumers always see a consistent, derived view.
|
||||
|
||||
## Features
|
||||
|
||||
- `buildTimelineState` assembles a complete `TimelineState` object from internal deps
|
||||
- `computeDuration` scans all clips to find the timeline end
|
||||
|
||||
## Usage
|
||||
|
||||
Used internally by `TimelineEngine.getState()`.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `buildTimelineState` | `(deps: StateDeps) => TimelineState` | Build snapshot |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Duration is calculated by iterating every clip in every track and taking the maximum `startSample + durationSamples`. This is `O(clips)` and is only run when `getState()` is called, not on every mutation. The `StateDeps` interface decouples the builder from the engine class, allowing tests to construct snapshots without instantiating a full engine. Because duration is derived rather than stored, it always reflects the current arrangement even after complex undo/redo sequences. Consumers can rely on `duration` being accurate without subscribing to additional events. The snapshot also includes `canZoomIn` and `canZoomOut` booleans so UI buttons can disable themselves at table bounds. All derived fields are recalculated on every `getState()` call, which keeps the internal engine state minimal and avoids stale derived data.
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Track } from "../../track/track.types.js";
|
||||
import { canZoomIn, canZoomOut, DEFAULT_ZOOM_TABLE } from "../../zoom/index.js";
|
||||
import type { TimelineState } from "../engine.types.js";
|
||||
|
||||
function computeDuration(tracks: Track[]): number {
|
||||
let max = 0;
|
||||
for (const t of tracks) {
|
||||
for (const c of t.clips) {
|
||||
const end = c.startSample + c.durationSamples;
|
||||
if (end > max) max = end;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
export interface StateDeps {
|
||||
tracks: Track[];
|
||||
tracksVersion: number;
|
||||
sampleRate: number;
|
||||
ppqn: number;
|
||||
bpm: number;
|
||||
currentTime: number;
|
||||
isPlaying: boolean;
|
||||
masterVolume: number;
|
||||
loopStart: number;
|
||||
loopEnd: number;
|
||||
isLoopEnabled: boolean;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
zoomIndex: number;
|
||||
selectedTrackId: string | null;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
}
|
||||
|
||||
export function buildTimelineState(deps: StateDeps): TimelineState {
|
||||
const duration = computeDuration(deps.tracks);
|
||||
return {
|
||||
tracks: deps.tracks,
|
||||
tracksVersion: deps.tracksVersion,
|
||||
duration,
|
||||
currentTime: deps.currentTime,
|
||||
isPlaying: deps.isPlaying,
|
||||
samplesPerPixel: DEFAULT_ZOOM_TABLE[deps.zoomIndex] as number,
|
||||
sampleRate: deps.sampleRate,
|
||||
selectedTrackId: deps.selectedTrackId,
|
||||
zoomIndex: deps.zoomIndex,
|
||||
canZoomIn: canZoomIn(DEFAULT_ZOOM_TABLE, deps.zoomIndex),
|
||||
canZoomOut: canZoomOut(DEFAULT_ZOOM_TABLE, deps.zoomIndex),
|
||||
selectionStart: deps.selectionStart,
|
||||
selectionEnd: deps.selectionEnd,
|
||||
masterVolume: deps.masterVolume,
|
||||
loopStart: deps.loopStart,
|
||||
loopEnd: deps.loopEnd,
|
||||
isLoopEnabled: deps.isLoopEnabled,
|
||||
bpm: deps.bpm,
|
||||
ppqn: deps.ppqn,
|
||||
canUndo: deps.canUndo,
|
||||
canRedo: deps.canRedo,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createPlaybackFsm } from "./transport-fsm.js";
|
||||
@@ -0,0 +1,34 @@
|
||||
# Playback FSM
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `transport-fsm.ts` module creates the finite state machine that guards timeline transport transitions. It prevents illegal transitions such as pausing while already stopped, ensuring that the engine and adapter never receive contradictory commands.
|
||||
|
||||
## Features
|
||||
|
||||
- Three states: `stopped`, `playing`, `paused`
|
||||
- Valid transitions: `stopped --play--> playing`, `playing --pause--> paused`, `playing --stop--> stopped`, `paused --play--> playing`, `paused --stop--> stopped`, `stopped --seek--> stopped`, `playing --seek--> playing`, `paused --seek--> paused`
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { createPlaybackFsm } from "./transport-fsm.js";
|
||||
|
||||
const fsm = createPlaybackFsm();
|
||||
fsm.send("play");
|
||||
console.log(fsm.current); // "playing"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `createPlaybackFsm` | `() => FSMInterpreter` | Create playback FSM |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The FSM is created with `@suaveplan/fsm`. It uses an empty context object because the timeline engine stores playback state separately. Invalid transitions throw `FSMError`, which the engine catches and ignores. This design keeps transport state management declarative and easy to extend if new states (such as `recording`) are added later. The FSM interpreter is created fresh for each engine instance, so multiple engines can coexist with independent playback states. This is important for applications that may host multiple timeline views or sessions simultaneously. Each engine instance manages its own FSM lifecycle independently. The FSM definition is hard-coded in this module because the playback state machine is universal and does not need customisation for different adapters or rendering backends.
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createMachine } from "@suaveplan/fsm";
|
||||
|
||||
export function createPlaybackFsm() {
|
||||
return createMachine({
|
||||
id: "timeline-playback",
|
||||
initial: "stopped",
|
||||
context: {},
|
||||
states: {
|
||||
stopped: {
|
||||
on: {
|
||||
play: { target: "playing" },
|
||||
seek: { target: "stopped" },
|
||||
},
|
||||
},
|
||||
playing: {
|
||||
on: {
|
||||
pause: { target: "paused" },
|
||||
stop: { target: "stopped" },
|
||||
seek: { target: "playing" },
|
||||
},
|
||||
},
|
||||
paused: {
|
||||
on: {
|
||||
play: { target: "playing" },
|
||||
stop: { target: "stopped" },
|
||||
seek: { target: "paused" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { TransportDeps } from "./transport.js";
|
||||
export {
|
||||
enginePause,
|
||||
enginePlay,
|
||||
engineSeek,
|
||||
engineStop,
|
||||
} from "./transport.js";
|
||||
@@ -0,0 +1,33 @@
|
||||
# Engine Transport Helpers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `transport.ts` module bridges `TimelineEngine` transport public methods (`play`, `pause`, `stop`, `seek`) to the `TimelineAdapter`. Each method is guarded by the playback FSM and records OTel spans, ensuring that illegal transitions are silently ignored and every transport change is observable.
|
||||
|
||||
## Features
|
||||
|
||||
- `enginePlay` sends `play` to FSM, calls `adapter.play`, and emits event
|
||||
- `enginePause` sends `pause` to FSM, calls `adapter.pause`, and emits event
|
||||
- `engineStop` sends `stop` to FSM, calls `adapter.stop`, resets time, and emits event
|
||||
- `engineSeek` calls `adapter.seek` directly without FSM involvement
|
||||
|
||||
## Usage
|
||||
|
||||
Used internally by `TimelineEngine`. Consumers call `engine.play()` etc.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `enginePlay` | `(deps) => void` | Start playback |
|
||||
| `enginePause` | `(deps) => void` | Pause playback |
|
||||
| `engineStop` | `(deps) => void` | Stop and reset |
|
||||
| `engineSeek` | `(deps, time) => void` | Seek to time |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every transport mutation creates a `timeline.transport` OTel span. The FSM prevents illegal transitions (e.g. `pause` while stopped) by throwing `FSMError`, which the engine silently catches and ignores. Seek does not consult the FSM because it is valid in any state. This allows users to scrub the playhead while playback is running, paused, or stopped without triggering transition errors. The adapter receives the seek command immediately so audio output can stay in sync with the UI.
|
||||
@@ -0,0 +1,59 @@
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import type { TimelineAdapter } from "../../adapter/index.js";
|
||||
|
||||
export interface TransportDeps {
|
||||
assertAdapter(): TimelineAdapter;
|
||||
fsmSend(event: string): boolean;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
setIsPlaying(value: boolean): void;
|
||||
setCurrentTime(value: number): void;
|
||||
emit(event: string, payload: number): void;
|
||||
}
|
||||
|
||||
export function enginePlay(deps: TransportDeps): void {
|
||||
const adapter = deps.assertAdapter();
|
||||
const tracer = trace.getTracer("@suaveplan/audio-timeline");
|
||||
tracer.startActiveSpan("timeline.transport", (span) => {
|
||||
if (deps.fsmSend("play")) {
|
||||
adapter.play(deps.currentTime);
|
||||
deps.setIsPlaying(true);
|
||||
deps.emit("play", deps.currentTime);
|
||||
}
|
||||
span.end();
|
||||
});
|
||||
}
|
||||
|
||||
export function enginePause(deps: TransportDeps): void {
|
||||
const adapter = deps.assertAdapter();
|
||||
const tracer = trace.getTracer("@suaveplan/audio-timeline");
|
||||
tracer.startActiveSpan("timeline.transport", (span) => {
|
||||
if (deps.fsmSend("pause")) {
|
||||
adapter.pause(deps.currentTime);
|
||||
deps.setIsPlaying(false);
|
||||
deps.emit("pause", deps.currentTime);
|
||||
}
|
||||
span.end();
|
||||
});
|
||||
}
|
||||
|
||||
export function engineStop(deps: TransportDeps): void {
|
||||
const adapter = deps.assertAdapter();
|
||||
const tracer = trace.getTracer("@suaveplan/audio-timeline");
|
||||
tracer.startActiveSpan("timeline.transport", (span) => {
|
||||
if (deps.fsmSend("stop")) {
|
||||
adapter.stop(0);
|
||||
deps.setCurrentTime(0);
|
||||
deps.setIsPlaying(false);
|
||||
deps.emit("stop", 0);
|
||||
}
|
||||
span.end();
|
||||
});
|
||||
}
|
||||
|
||||
export function engineSeek(deps: TransportDeps, time: number): void {
|
||||
const adapter = deps.assertAdapter();
|
||||
deps.setCurrentTime(time);
|
||||
adapter.seek(time);
|
||||
deps.emit("seek", time);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { TracksCommand } from "./undo.js";
|
||||
export {
|
||||
buildTracksCommand,
|
||||
createTracksUndoStack,
|
||||
findChangedTrackIds,
|
||||
} from "./undo.js";
|
||||
31
packages/media/audio/audio-timeline/src/engine/undo/undo.md
Normal file
31
packages/media/audio/audio-timeline/src/engine/undo/undo.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Engine Undo Helpers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `undo.ts` module provides the command builder and incremental adapter diff logic used by `TimelineEngine` for undo/redo. It converts before/after track snapshots into `Command<Track[]>` instances and detects which individual tracks changed so the engine can forward minimal updates to the adapter.
|
||||
|
||||
## Features
|
||||
|
||||
- `createTracksUndoStack` constructs an `UndoStack<Track[]>` with a 500-entry limit
|
||||
- `buildTracksCommand` creates a `Command<Track[]>` from before/after snapshots
|
||||
- `findChangedTrackIds` compares two track arrays to detect which tracks changed
|
||||
|
||||
## Usage
|
||||
|
||||
Used internally by `TimelineEngine`.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `createTracksUndoStack` | `(initial) => UndoStack<Track[]>` | Create undo stack |
|
||||
| `buildTracksCommand` | `(label, prev, next) => TracksCommand` | Build command |
|
||||
| `findChangedTrackIds` | `(prev, next) => string[]` | Diff tracks |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`findChangedTrackIds` uses `JSON.stringify` for a fast deep-equal check. This is sufficient because tracks are plain JSON-compatible objects with no circular references. The function returns ids present in `prev` but not `next` (removed tracks) as well as ids whose contents differ. When the track count is unchanged, the engine uses these ids to call `adapter.updateTrack` individually, avoiding a full `setTracks` reset that could interrupt playback. The 500-entry limit prevents unbounded memory growth in long editing sessions. When the limit is reached, the oldest entries are discarded automatically so the stack stays bounded.
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { findChangedTrackIds } from "./undo.js";
|
||||
|
||||
describe("findChangedTrackIds", () => {
|
||||
it("returns empty when tracks are identical", () => {
|
||||
const t = {
|
||||
id: "t1",
|
||||
name: "A",
|
||||
clips: [],
|
||||
volume: 1,
|
||||
pan: 0,
|
||||
muted: false,
|
||||
soloed: false,
|
||||
};
|
||||
expect(findChangedTrackIds([t], [{ ...t }])).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects removed tracks", () => {
|
||||
const t = {
|
||||
id: "t1",
|
||||
name: "A",
|
||||
clips: [],
|
||||
volume: 1,
|
||||
pan: 0,
|
||||
muted: false,
|
||||
soloed: false,
|
||||
};
|
||||
expect(findChangedTrackIds([t], [])).toEqual(["t1"]);
|
||||
});
|
||||
|
||||
it("detects added tracks", () => {
|
||||
const t = {
|
||||
id: "t1",
|
||||
name: "A",
|
||||
clips: [],
|
||||
volume: 1,
|
||||
pan: 0,
|
||||
muted: false,
|
||||
soloed: false,
|
||||
};
|
||||
expect(findChangedTrackIds([], [t])).toEqual(["t1"]);
|
||||
});
|
||||
|
||||
it("detects changed tracks", () => {
|
||||
const a = {
|
||||
id: "t1",
|
||||
name: "A",
|
||||
clips: [],
|
||||
volume: 1,
|
||||
pan: 0,
|
||||
muted: false,
|
||||
soloed: false,
|
||||
};
|
||||
const b = {
|
||||
id: "t1",
|
||||
name: "B",
|
||||
clips: [],
|
||||
volume: 1,
|
||||
pan: 0,
|
||||
muted: false,
|
||||
soloed: false,
|
||||
};
|
||||
expect(findChangedTrackIds([a], [b])).toEqual(["t1"]);
|
||||
});
|
||||
});
|
||||
45
packages/media/audio/audio-timeline/src/engine/undo/undo.ts
Normal file
45
packages/media/audio/audio-timeline/src/engine/undo/undo.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { UndoStack } from "@suaveplan/undo";
|
||||
import type { Track } from "../../track/track.types.js";
|
||||
|
||||
export interface TracksCommand {
|
||||
id: string;
|
||||
label: string;
|
||||
timestamp: number;
|
||||
execute(tracks: Track[]): Track[];
|
||||
undo(tracks: Track[]): Track[];
|
||||
}
|
||||
|
||||
export function createTracksUndoStack(initial: Track[]): UndoStack<Track[]> {
|
||||
return new UndoStack<Track[]>(initial, { maxEntries: 500 });
|
||||
}
|
||||
|
||||
export function buildTracksCommand(
|
||||
label: string,
|
||||
prev: Track[],
|
||||
next: Track[]
|
||||
): TracksCommand {
|
||||
return {
|
||||
id: `${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
label,
|
||||
timestamp: Date.now(),
|
||||
execute: () => next,
|
||||
undo: () => prev,
|
||||
};
|
||||
}
|
||||
|
||||
export function findChangedTrackIds(prev: Track[], next: Track[]): string[] {
|
||||
const changed = new Set<string>();
|
||||
const prevMap = new Map(prev.map((t) => [t.id, t]));
|
||||
const nextMap = new Map(next.map((t) => [t.id, t]));
|
||||
|
||||
for (const [id, t] of nextMap) {
|
||||
const p = prevMap.get(id);
|
||||
if (!p || JSON.stringify(p) !== JSON.stringify(t)) {
|
||||
changed.add(id);
|
||||
}
|
||||
}
|
||||
for (const id of prevMap.keys()) {
|
||||
if (!nextMap.has(id)) changed.add(id);
|
||||
}
|
||||
return Array.from(changed);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type { ZoomDeps } from "./zoom.js";
|
||||
export {
|
||||
engineSetLoopEnabled,
|
||||
engineSetLoopRegion,
|
||||
engineSetSelection,
|
||||
engineSetZoomLevel,
|
||||
engineZoomIn,
|
||||
engineZoomOut,
|
||||
} from "./zoom.js";
|
||||
36
packages/media/audio/audio-timeline/src/engine/zoom/zoom.md
Normal file
36
packages/media/audio/audio-timeline/src/engine/zoom/zoom.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Engine Zoom Helpers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `zoom.ts` module bridges `TimelineEngine` zoom, selection, and loop public methods to the pure `timeline-operations.ts` and `zoom.ts` helpers. It manages internal state updates and event emission so the engine class stays focused on high-level orchestration.
|
||||
|
||||
## Features
|
||||
|
||||
- `engineZoomIn` / `engineZoomOut` step through the zoom table and emit `zoomChange`
|
||||
- `engineSetZoomLevel` snaps to the nearest table entry
|
||||
- `engineSetSelection` normalises ranges and emits `selectionChange`
|
||||
- `engineSetLoopRegion` updates loop bounds and forwards to adapter
|
||||
- `engineSetLoopEnabled` toggles loop and forwards to adapter
|
||||
|
||||
## Usage
|
||||
|
||||
These helpers are used internally by `TimelineEngine`. Consumers call `engine.zoomIn()` etc.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `engineZoomIn` | `(deps) => void` | Zoom in |
|
||||
| `engineZoomOut` | `(deps) => void` | Zoom out |
|
||||
| `engineSetZoomLevel` | `(deps, samplesPerPixel) => void` | Set zoom |
|
||||
| `engineSetSelection` | `(deps, start, end) => void` | Set selection |
|
||||
| `engineSetLoopRegion` | `(deps, start, end) => void` | Set loop region |
|
||||
| `engineSetLoopEnabled` | `(deps, enabled) => void` | Toggle loop |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `ZoomDeps` interface isolates the helpers from the engine class, making them testable in isolation. Loop changes are forwarded to the adapter immediately so the audio backend can update its loop markers. Selection changes are clamped to the current document duration so invalid ranges cannot be created. Zoom changes only affect the view and do not touch the arrangement, which is why they bypass the undo stack entirely.
|
||||
83
packages/media/audio/audio-timeline/src/engine/zoom/zoom.ts
Normal file
83
packages/media/audio/audio-timeline/src/engine/zoom/zoom.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { setSelection } from "../../operations/timeline-operations/index.js";
|
||||
import {
|
||||
DEFAULT_ZOOM_TABLE,
|
||||
setZoomLevel,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
} from "../../zoom/index.js";
|
||||
import type { TimelineState } from "../engine.types.js";
|
||||
|
||||
export interface ZoomDeps {
|
||||
zoomIndex: number;
|
||||
setZoomIndex(value: number): void;
|
||||
selectionStart: number;
|
||||
setSelectionStart(value: number): void;
|
||||
selectionEnd: number;
|
||||
setSelectionEnd(value: number): void;
|
||||
loopStart: number;
|
||||
setLoopStart(value: number): void;
|
||||
loopEnd: number;
|
||||
setLoopEnd(value: number): void;
|
||||
isLoopEnabled: boolean;
|
||||
setLoopEnabled(value: boolean): void;
|
||||
getState(): TimelineState;
|
||||
adapterSetLoop(start: number, end: number, enabled: boolean): void;
|
||||
emit<K extends string>(event: K, payload: unknown): void;
|
||||
}
|
||||
|
||||
export function engineZoomIn(deps: ZoomDeps): void {
|
||||
const next = zoomIn(DEFAULT_ZOOM_TABLE, deps.zoomIndex);
|
||||
deps.setZoomIndex(next);
|
||||
deps.emit("zoomChange", {
|
||||
samplesPerPixel: DEFAULT_ZOOM_TABLE[next] as number,
|
||||
});
|
||||
}
|
||||
|
||||
export function engineZoomOut(deps: ZoomDeps): void {
|
||||
const next = zoomOut(DEFAULT_ZOOM_TABLE, deps.zoomIndex);
|
||||
deps.setZoomIndex(next);
|
||||
deps.emit("zoomChange", {
|
||||
samplesPerPixel: DEFAULT_ZOOM_TABLE[next] as number,
|
||||
});
|
||||
}
|
||||
|
||||
export function engineSetZoomLevel(
|
||||
deps: ZoomDeps,
|
||||
samplesPerPixel: number
|
||||
): void {
|
||||
const result = setZoomLevel(DEFAULT_ZOOM_TABLE, samplesPerPixel);
|
||||
deps.setZoomIndex(result.zoomIndex);
|
||||
deps.emit("zoomChange", { samplesPerPixel: result.samplesPerPixel });
|
||||
}
|
||||
|
||||
export function engineSetSelection(
|
||||
deps: ZoomDeps,
|
||||
start: number,
|
||||
end: number
|
||||
): void {
|
||||
const sel = setSelection(deps.getState(), start, end);
|
||||
deps.setSelectionStart(sel.selectionStart);
|
||||
deps.setSelectionEnd(sel.selectionEnd);
|
||||
deps.emit("selectionChange", sel);
|
||||
}
|
||||
|
||||
export function engineSetLoopRegion(
|
||||
deps: ZoomDeps,
|
||||
start: number,
|
||||
end: number
|
||||
): void {
|
||||
deps.setLoopStart(start);
|
||||
deps.setLoopEnd(end);
|
||||
deps.adapterSetLoop(start, end, deps.isLoopEnabled);
|
||||
deps.emit("loopChange", { start, end, enabled: deps.isLoopEnabled });
|
||||
}
|
||||
|
||||
export function engineSetLoopEnabled(deps: ZoomDeps, enabled: boolean): void {
|
||||
deps.setLoopEnabled(enabled);
|
||||
deps.adapterSetLoop(deps.loopStart, deps.loopEnd, enabled);
|
||||
deps.emit("loopChange", {
|
||||
start: deps.loopStart,
|
||||
end: deps.loopEnd,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Error Codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `error-codes.ts` module defines the `AUDIO_TIMELINE` error namespace and the `TimelineError` class used throughout the timeline engine. Every error thrown by the package uses a code from this registry, making it easy for consumers to handle specific failure modes programmatically without parsing human-readable messages.
|
||||
|
||||
## Features
|
||||
|
||||
- `AUDIO_TIMELINE_CODES` object with typed constants for all timeline errors
|
||||
- `TimelineError` extends `SuaveplanError` with the `AUDIO_TIMELINE` namespace
|
||||
- `registerTimelineErrorCodes` for explicit side-effect dependency
|
||||
- Covers track not found, clip not found, invalid move, invalid split, invalid trim, adapter not set, and transaction not active
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic
|
||||
|
||||
```ts
|
||||
import { AUDIO_TIMELINE_CODES, TimelineError } from "@suaveplan/audio-timeline";
|
||||
|
||||
throw new TimelineError(AUDIO_TIMELINE_CODES.TRACK_NOT_FOUND, "Track missing");
|
||||
```
|
||||
|
||||
### Advanced
|
||||
|
||||
```ts
|
||||
import { registerTimelineErrorCodes } from "@suaveplan/audio-timeline";
|
||||
|
||||
registerTimelineErrorCodes(); // ensures codes are registered with the error system
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `AUDIO_TIMELINE_CODES` | `Record<string, string>` | Typed code constants |
|
||||
| `TimelineError` | `class` | Namespaced error class |
|
||||
| `registerTimelineErrorCodes` | `() => void` | Explicit registration hook |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Codes are created via `defineCodes("AUDIO_TIMELINE", [...])` from `@suaveplan/error`. The resulting string values are the full dotted form (`AUDIO_TIMELINE.TRACK_NOT_FOUND`). Hand-typing code literals is prohibited by project policy; always use the exported constants. This prevents silent drift when the namespace changes. The error metadata object is also exported so that telemetry systems can register the full code list at startup. This makes it easy to generate alerts and dashboards for specific timeline failure modes without grepping logs. The code list is statically typed, so TypeScript will catch any attempt to use an undefined error code at compile time.
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_TIMELINE_CODES,
|
||||
AUDIO_TIMELINE_ERROR_METADATA,
|
||||
registerTimelineErrorCodes,
|
||||
TimelineError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("error codes", () => {
|
||||
it("registerTimelineErrorCodes is callable", { timeout: 5000 }, () => {
|
||||
expect(() => registerTimelineErrorCodes()).not.toThrow();
|
||||
});
|
||||
|
||||
it("every AUDIO_TIMELINE code is reachable from a call site", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codes = new Set(AUDIO_TIMELINE_ERROR_METADATA.codes);
|
||||
for (const code of Object.values(AUDIO_TIMELINE_CODES)) {
|
||||
expect(codes.has(code)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("TimelineError carries code and context", { timeout: 5000 }, () => {
|
||||
const err = new TimelineError(
|
||||
AUDIO_TIMELINE_CODES.TRACK_NOT_FOUND,
|
||||
"missing",
|
||||
{ context: { id: "x" } }
|
||||
);
|
||||
expect(err.code).toBe(AUDIO_TIMELINE_CODES.TRACK_NOT_FOUND);
|
||||
expect(err.context).toEqual({ id: "x" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
export const AUDIO_TIMELINE_CODES = defineCodes("AUDIO_TIMELINE", [
|
||||
"TRACK_NOT_FOUND",
|
||||
"CLIP_NOT_FOUND",
|
||||
"INVALID_CLIP_MOVE",
|
||||
"INVALID_SPLIT",
|
||||
"INVALID_TRIM",
|
||||
"ADAPTER_NOT_SET",
|
||||
"TRANSACTION_NOT_ACTIVE",
|
||||
] as const);
|
||||
|
||||
export type TimelineErrorCode =
|
||||
(typeof AUDIO_TIMELINE_CODES)[keyof typeof AUDIO_TIMELINE_CODES];
|
||||
|
||||
export const AUDIO_TIMELINE_ERROR_METADATA = {
|
||||
domain: "audio-timeline",
|
||||
codes: Object.values(AUDIO_TIMELINE_CODES),
|
||||
} as const;
|
||||
|
||||
export function registerTimelineErrorCodes(): void {
|
||||
// Codes are registered at module-evaluation time via defineCodes.
|
||||
// This function exists so callers can explicitly depend on the side effect.
|
||||
}
|
||||
|
||||
export class TimelineError extends SuaveplanError {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
|
||||
public constructor(
|
||||
code: TimelineErrorCode,
|
||||
message: string,
|
||||
options?: {
|
||||
cause?: Error | undefined;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
) {
|
||||
super(code, message, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { TimelineErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
AUDIO_TIMELINE_CODES,
|
||||
AUDIO_TIMELINE_ERROR_METADATA,
|
||||
registerTimelineErrorCodes,
|
||||
TimelineError,
|
||||
} from "./error-codes.js";
|
||||
68
packages/media/audio/audio-timeline/src/index.ts
Normal file
68
packages/media/audio/audio-timeline/src/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export type { TimelineAdapter } from "./adapter/index.js";
|
||||
export { NullTimelineAdapter } from "./adapter/index.js";
|
||||
export { ClipSchema } from "./clip/clip.schemas.js";
|
||||
export type { Clip } from "./clip/clip.types.js";
|
||||
export {
|
||||
canSplitAt,
|
||||
constrainBoundaryTrim,
|
||||
createClip,
|
||||
validateClip,
|
||||
} from "./clip/index.js";
|
||||
export {
|
||||
FadeCurveSchema,
|
||||
TimelineEngineOptionsSchema,
|
||||
TimelineStateSchema,
|
||||
} from "./engine/engine.schemas.js";
|
||||
export type {
|
||||
FadeCurve,
|
||||
TimelineEngineOptions,
|
||||
TimelineEventMap,
|
||||
TimelineSessionJSON,
|
||||
TimelineState,
|
||||
} from "./engine/engine.types.js";
|
||||
export { TimelineEngine } from "./engine/index.js";
|
||||
export type { TimelineErrorCode } from "./error-codes/index.js";
|
||||
export {
|
||||
AUDIO_TIMELINE_CODES,
|
||||
AUDIO_TIMELINE_ERROR_METADATA,
|
||||
registerTimelineErrorCodes,
|
||||
TimelineError,
|
||||
} from "./error-codes/index.js";
|
||||
export {
|
||||
computeEffectiveGain,
|
||||
setMasterVolume,
|
||||
setTrackMute,
|
||||
setTrackPan,
|
||||
setTrackSolo,
|
||||
setTrackVolume,
|
||||
} from "./mixer/index.js";
|
||||
export {
|
||||
constrainClipDrag,
|
||||
moveClip,
|
||||
splitClip,
|
||||
trimClip,
|
||||
} from "./operations/clip-operations/index.js";
|
||||
export {
|
||||
setLoopRegion,
|
||||
setSelection,
|
||||
setZoomLevel,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
} from "./operations/timeline-operations/index.js";
|
||||
export {
|
||||
createTrack,
|
||||
removeTrack,
|
||||
updateTrack,
|
||||
validateTrack,
|
||||
} from "./track/index.js";
|
||||
export { TrackSchema } from "./track/track.schemas.js";
|
||||
export type { Track } from "./track/track.types.js";
|
||||
|
||||
export {
|
||||
canZoomIn,
|
||||
canZoomOut,
|
||||
DEFAULT_ZOOM_TABLE,
|
||||
setZoomLevel as zoomSetZoomLevel,
|
||||
zoomIn as zoomZoomIn,
|
||||
zoomOut as zoomZoomOut,
|
||||
} from "./zoom/index.js";
|
||||
8
packages/media/audio/audio-timeline/src/mixer/index.ts
Normal file
8
packages/media/audio/audio-timeline/src/mixer/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
computeEffectiveGain,
|
||||
setMasterVolume,
|
||||
setTrackMute,
|
||||
setTrackPan,
|
||||
setTrackSolo,
|
||||
setTrackVolume,
|
||||
} from "./mixer.js";
|
||||
54
packages/media/audio/audio-timeline/src/mixer/mixer.md
Normal file
54
packages/media/audio/audio-timeline/src/mixer/mixer.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Mixer Helpers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `mixer.ts` module provides pure state updates for per-track and master mixer settings. Every function returns a shallow copy so the timeline engine can diff changes cheaply and forward them to the `TimelineAdapter`. Because the helpers are pure, they can be used in reducers, tests, and audio-thread code without side effects.
|
||||
|
||||
## Features
|
||||
|
||||
- `setMasterVolume` clamps master volume to `0..1`
|
||||
- `setTrackVolume` clamps per-track volume to `0..1`
|
||||
- `setTrackMute` toggles the muted flag
|
||||
- `setTrackSolo` toggles the soloed flag
|
||||
- `setTrackPan` clamps pan to `-1..1`
|
||||
- `computeEffectiveGain` resolves final gain considering solo-group logic
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic
|
||||
|
||||
```ts
|
||||
import { setTrackVolume, setTrackMute, computeEffectiveGain } from "@suaveplan/audio-timeline/mixer";
|
||||
|
||||
const t1 = setTrackVolume(track, 0.8);
|
||||
const t2 = setTrackMute(t1, true);
|
||||
const gain = computeEffectiveGain(t2, false); // 0 (muted takes precedence)
|
||||
```
|
||||
|
||||
### Advanced
|
||||
|
||||
```ts
|
||||
import { computeEffectiveGain } from "@suaveplan/audio-timeline/mixer";
|
||||
|
||||
const anySoloed = tracks.some((t) => t.soloed);
|
||||
const gain = computeEffectiveGain(track, anySoloed); // 0 if non-soloed and any soloed
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `setMasterVolume` | `(state, volume) => { masterVolume }` | Clamp and return master |
|
||||
| `setTrackVolume` | `(track, volume) => Track` | Clamp and return track |
|
||||
| `setTrackMute` | `(track, muted) => Track` | Toggle mute |
|
||||
| `setTrackSolo` | `(track, soloed) => Track` | Toggle solo |
|
||||
| `setTrackPan` | `(track, pan) => Track` | Clamp pan |
|
||||
| `computeEffectiveGain` | `(track, anySoloed) => number` | Resolve effective gain |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Clamping happens inside the helpers so the engine never has to repeat the logic. The solo-group calculation is a simple predicate: the engine passes `anySoloed` (a boolean derived from the current track array) and the helper returns the effective gain without needing the full session context. This separation keeps the mixer module tiny and easy to test in isolation.
|
||||
73
packages/media/audio/audio-timeline/src/mixer/mixer.test.ts
Normal file
73
packages/media/audio/audio-timeline/src/mixer/mixer.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { TimelineState } from "../engine/engine.types.js";
|
||||
import { createTrack } from "../track/index.js";
|
||||
import {
|
||||
computeEffectiveGain,
|
||||
setMasterVolume,
|
||||
setTrackMute,
|
||||
setTrackPan,
|
||||
setTrackSolo,
|
||||
setTrackVolume,
|
||||
} from "./mixer.js";
|
||||
|
||||
function makeState(): TimelineState {
|
||||
return {
|
||||
tracks: [],
|
||||
tracksVersion: 0,
|
||||
duration: 0,
|
||||
currentTime: 0,
|
||||
isPlaying: false,
|
||||
samplesPerPixel: 1,
|
||||
sampleRate: 48000,
|
||||
selectedTrackId: null,
|
||||
zoomIndex: 0,
|
||||
canZoomIn: true,
|
||||
canZoomOut: false,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0,
|
||||
masterVolume: 1,
|
||||
loopStart: 0,
|
||||
loopEnd: 0,
|
||||
isLoopEnabled: false,
|
||||
bpm: 120,
|
||||
ppqn: 96,
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mixer helpers", () => {
|
||||
it("clamps volume and pan to their valid ranges", { timeout: 5000 }, () => {
|
||||
const track = createTrack();
|
||||
expect(setTrackVolume(track, 1.5).volume).toBe(1);
|
||||
expect(setTrackVolume(track, -0.5).volume).toBe(0);
|
||||
expect(setTrackPan(track, 2).pan).toBe(1);
|
||||
expect(setTrackPan(track, -2).pan).toBe(-1);
|
||||
|
||||
const state = makeState();
|
||||
expect(setMasterVolume(state, 1.5).masterVolume).toBe(1);
|
||||
expect(setMasterVolume(state, -0.5).masterVolume).toBe(0);
|
||||
});
|
||||
|
||||
it("solo group zeroes the gain of non-soloed tracks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const soloed = createTrack({ soloed: true, volume: 0.8 });
|
||||
const normal = createTrack({ soloed: false, volume: 0.5 });
|
||||
expect(computeEffectiveGain(soloed, true)).toBe(0.8);
|
||||
expect(computeEffectiveGain(normal, true)).toBe(0);
|
||||
expect(computeEffectiveGain(normal, false)).toBe(0.5);
|
||||
});
|
||||
|
||||
it("setTrackMute toggles the muted flag", { timeout: 5000 }, () => {
|
||||
const track = createTrack();
|
||||
expect(setTrackMute(track, true).muted).toBe(true);
|
||||
expect(setTrackMute(track, false).muted).toBe(false);
|
||||
});
|
||||
|
||||
it("setTrackSolo toggles the soloed flag", { timeout: 5000 }, () => {
|
||||
const track = createTrack();
|
||||
expect(setTrackSolo(track, true).soloed).toBe(true);
|
||||
expect(setTrackSolo(track, false).soloed).toBe(false);
|
||||
});
|
||||
});
|
||||
32
packages/media/audio/audio-timeline/src/mixer/mixer.ts
Normal file
32
packages/media/audio/audio-timeline/src/mixer/mixer.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { TimelineState } from "../engine/engine.types.js";
|
||||
import type { Track } from "../track/track.types.js";
|
||||
|
||||
export function setMasterVolume(
|
||||
_state: TimelineState,
|
||||
volume: number
|
||||
): Pick<TimelineState, "masterVolume"> {
|
||||
return { masterVolume: Math.max(0, Math.min(1, volume)) };
|
||||
}
|
||||
|
||||
export function setTrackVolume(track: Track, volume: number): Track {
|
||||
return { ...track, volume: Math.max(0, Math.min(1, volume)) };
|
||||
}
|
||||
|
||||
export function setTrackMute(track: Track, muted: boolean): Track {
|
||||
return { ...track, muted };
|
||||
}
|
||||
|
||||
export function setTrackSolo(track: Track, soloed: boolean): Track {
|
||||
return { ...track, soloed };
|
||||
}
|
||||
|
||||
export function setTrackPan(track: Track, pan: number): Track {
|
||||
return { ...track, pan: Math.max(-1, Math.min(1, pan)) };
|
||||
}
|
||||
|
||||
export function computeEffectiveGain(track: Track, anySoloed: boolean): number {
|
||||
if (!anySoloed || track.soloed) {
|
||||
return track.volume;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Clip Operations
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `clip-operations.ts` module implements the state-changing actions that the timeline engine performs on clips: drag, trim, and split. Each operation is a pure function that takes a `Track` and returns an updated `Track`, making them easy to wrap in undo/redo commands. Because the functions are pure, they can be tested in isolation without instantiating a full `TimelineEngine`.
|
||||
|
||||
## Features
|
||||
|
||||
- `constrainClipDrag` clamps drag deltas against timeline bounds and sibling overlap
|
||||
- `moveClip` applies constrained drags and signals no-ops when the delta is zero
|
||||
- `trimClip` resizes clip boundaries with a configurable minimum duration floor
|
||||
- `splitClip` replaces one clip with two adjacent clips, partitioning fade curves
|
||||
- `removeClip` filters a clip from a track by id
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic
|
||||
|
||||
```ts
|
||||
import { moveClip, trimClip, splitClip } from "@suaveplan/audio-timeline/operations";
|
||||
|
||||
const result = moveClip(track, "clip-1", 500);
|
||||
const trimmed = trimClip(track, "clip-1", "left", 100, 1, 10000);
|
||||
```
|
||||
|
||||
### Advanced
|
||||
|
||||
```ts
|
||||
import { constrainClipDrag } from "@suaveplan/audio-timeline/operations";
|
||||
|
||||
const safeDelta = constrainClipDrag(track, "clip-1", 2000, { allowOverlap: true });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `constrainClipDrag` | `(track, clipId, delta, opts?) => number` | Clamp drag delta |
|
||||
| `moveClip` | `(track, clipId, delta, opts?) => { track, appliedDelta }` | Move clip with constraint |
|
||||
| `trimClip` | `(track, clipId, boundary, delta, minDuration, docLength) => Track` | Resize boundary |
|
||||
| `splitClip` | `(track, clipId, atSample, minDuration) => Track` | Split into two clips |
|
||||
| `removeClip` | `(track, clipId) => Track` | Remove clip by id |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All operations preserve immutability: they shallow-copy the track and the clips array. The overlap check is a linear scan of siblings, which is fast because clips are kept sorted by `startSample`. When `moveClip` returns `appliedDelta: 0`, the engine skips pushing an undo command, keeping the history clean. The `minDuration` parameter enforces a floor on clip length so users cannot accidentally delete a clip by over-trimming. Splitting preserves fade curves by assigning `fadeIn` to the left clip and `fadeOut` to the right clip.
|
||||
@@ -0,0 +1,360 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createClip } from "../../clip/index.js";
|
||||
import {
|
||||
AUDIO_TIMELINE_CODES,
|
||||
TimelineError,
|
||||
} from "../../error-codes/index.js";
|
||||
import { createTrack } from "../../track/index.js";
|
||||
import { moveClip, splitClip, trimClip } from "./clip-operations.js";
|
||||
|
||||
function makeTrackWithClips(...clips: ReturnType<typeof createClip>[]) {
|
||||
return createTrack({ name: "Test", clips: [...clips] });
|
||||
}
|
||||
|
||||
describe("clip operations", () => {
|
||||
it("moveClip clamps drag to avoid sibling overlap", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clipA = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 0,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const clipB = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 200,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const track = makeTrackWithClips(clipA, clipB);
|
||||
|
||||
const { track: movedTrack, appliedDelta } = moveClip(
|
||||
track,
|
||||
clipA.id,
|
||||
500
|
||||
);
|
||||
expect(appliedDelta).toBe(100);
|
||||
const updatedA = movedTrack.clips.find((c) => c.id === clipA.id)!;
|
||||
expect(updatedA.startSample).toBe(100);
|
||||
});
|
||||
|
||||
it("moveClip with zero constrained delta returns the original track unchanged", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clipA = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 0,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const clipB = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 100,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const track = makeTrackWithClips(clipA, clipB);
|
||||
|
||||
const { track: movedTrack, appliedDelta } = moveClip(
|
||||
track,
|
||||
clipA.id,
|
||||
0
|
||||
);
|
||||
expect(appliedDelta).toBe(0);
|
||||
expect(movedTrack).toBe(track);
|
||||
});
|
||||
|
||||
it("trimClip shortens duration within the minDuration floor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 10,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const track = makeTrackWithClips(clip);
|
||||
|
||||
const trimmed = trimClip(track, clip.id, "right", -20, 10, 1000);
|
||||
const updated = trimmed.clips.find((c) => c.id === clip.id)!;
|
||||
expect(updated.durationSamples).toBe(80);
|
||||
expect(updated.startSample).toBe(10);
|
||||
});
|
||||
|
||||
it("trimClip throws AUDIO_TIMELINE_INVALID_TRIM below minDuration", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 10,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const track = makeTrackWithClips(clip);
|
||||
|
||||
expect(() => trimClip(track, clip.id, "right", -95, 10, 1000)).toThrow(
|
||||
TimelineError
|
||||
);
|
||||
try {
|
||||
trimClip(track, clip.id, "right", -95, 10, 1000);
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.INVALID_TRIM
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("splitClip produces two contiguous clips", { timeout: 5000 }, () => {
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 0,
|
||||
durationSamples: 100,
|
||||
fadeIn: { durationSamples: 10, type: "linear" },
|
||||
fadeOut: { durationSamples: 10, type: "linear" },
|
||||
});
|
||||
const track = makeTrackWithClips(clip);
|
||||
|
||||
const split = splitClip(track, clip.id, 50, 10);
|
||||
expect(split.clips).toHaveLength(2);
|
||||
const left = split.clips[0]!;
|
||||
const right = split.clips[1]!;
|
||||
expect(left.startSample).toBe(0);
|
||||
expect(left.durationSamples).toBe(50);
|
||||
expect(right.startSample).toBe(50);
|
||||
expect(right.durationSamples).toBe(50);
|
||||
expect(left.fadeIn).toBeDefined();
|
||||
expect(left.fadeOut).toBeUndefined();
|
||||
expect(right.fadeIn).toBeUndefined();
|
||||
expect(right.fadeOut).toBeDefined();
|
||||
expect(left.offsetSamples).toBe(0);
|
||||
expect(right.offsetSamples).toBe(50);
|
||||
});
|
||||
|
||||
it("splitClip throws AUDIO_TIMELINE_INVALID_SPLIT for an out-of-range point", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 0,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const track = makeTrackWithClips(clip);
|
||||
|
||||
expect(() => splitClip(track, clip.id, 0, 10)).toThrow(TimelineError);
|
||||
expect(() => splitClip(track, clip.id, 100, 10)).toThrow(TimelineError);
|
||||
try {
|
||||
splitClip(track, clip.id, 0, 10);
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.INVALID_SPLIT
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("moveClip clamps drag so startSample never goes negative", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 10,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const track = makeTrackWithClips(clip);
|
||||
|
||||
const { track: movedTrack, appliedDelta } = moveClip(
|
||||
track,
|
||||
clip.id,
|
||||
-50
|
||||
);
|
||||
expect(appliedDelta).toBe(-10);
|
||||
const updated = movedTrack.clips.find((c) => c.id === clip.id)!;
|
||||
expect(updated.startSample).toBe(0);
|
||||
});
|
||||
|
||||
it("moveClip clamps drag when a left sibling blocks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clipA = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 0,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const clipB = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 200,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const track = makeTrackWithClips(clipA, clipB);
|
||||
|
||||
const { track: movedTrack, appliedDelta } = moveClip(
|
||||
track,
|
||||
clipB.id,
|
||||
-500
|
||||
);
|
||||
expect(appliedDelta).toBe(-100);
|
||||
const updated = movedTrack.clips.find((c) => c.id === clipB.id)!;
|
||||
expect(updated.startSample).toBe(100);
|
||||
});
|
||||
|
||||
it("moveClip throws AUDIO_TIMELINE_CLIP_NOT_FOUND for unknown clipId", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const track = makeTrackWithClips();
|
||||
expect(() => moveClip(track, "bad-id", 10)).toThrow(TimelineError);
|
||||
try {
|
||||
moveClip(track, "bad-id", 10);
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.CLIP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("trimClip throws AUDIO_TIMELINE_CLIP_NOT_FOUND for unknown clipId", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const track = makeTrackWithClips();
|
||||
expect(() => trimClip(track, "bad-id", "right", -10, 1, 100)).toThrow(
|
||||
TimelineError
|
||||
);
|
||||
try {
|
||||
trimClip(track, "bad-id", "right", -10, 1, 100);
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.CLIP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("splitClip throws AUDIO_TIMELINE_CLIP_NOT_FOUND for unknown clipId", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const track = makeTrackWithClips();
|
||||
expect(() => splitClip(track, "bad-id", 50, 1)).toThrow(TimelineError);
|
||||
try {
|
||||
splitClip(track, "bad-id", 50, 1);
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.CLIP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("splitClip falls back to counter id when crypto.randomUUID is unavailable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = (crypto as unknown as Record<string, unknown>)[
|
||||
"randomUUID"
|
||||
];
|
||||
(crypto as unknown as Record<string, unknown>)["randomUUID"] =
|
||||
undefined;
|
||||
const clip = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 0,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const track = makeTrackWithClips(clip);
|
||||
const split = splitClip(track, clip.id, 50, 10);
|
||||
expect(split.clips[0]!.id).toMatch(/^clip-\d+$/);
|
||||
expect(split.clips[1]!.id).toMatch(/^clip-\d+$/);
|
||||
(crypto as unknown as Record<string, unknown>)["randomUUID"] = original;
|
||||
});
|
||||
|
||||
it("moveClip respects allowOverlap option", { timeout: 5000 }, () => {
|
||||
const clipA = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 0,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const clipB = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 200,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const track = makeTrackWithClips(clipA, clipB);
|
||||
|
||||
const { track: movedTrack, appliedDelta } = moveClip(
|
||||
track,
|
||||
clipB.id,
|
||||
-500,
|
||||
{ allowOverlap: true }
|
||||
);
|
||||
expect(appliedDelta).toBe(-200);
|
||||
const updated = movedTrack.clips.find((c) => c.id === clipB.id)!;
|
||||
expect(updated.startSample).toBe(0);
|
||||
});
|
||||
|
||||
it("constrainClipDrag ignores overlapping siblings that are neither left nor right", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clipA = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 0,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const clipB = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 50,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const track = makeTrackWithClips(clipA, clipB);
|
||||
|
||||
const { appliedDelta } = moveClip(track, clipB.id, 500);
|
||||
expect(appliedDelta).toBe(500);
|
||||
});
|
||||
|
||||
it("trimClip updates the correct clip when multiple clips exist", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clipA = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 0,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const clipB = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 200,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const track = makeTrackWithClips(clipA, clipB);
|
||||
|
||||
const trimmed = trimClip(track, clipB.id, "right", -20, 10, 1000);
|
||||
const updatedB = trimmed.clips.find((c) => c.id === clipB.id)!;
|
||||
expect(updatedB.durationSamples).toBe(80);
|
||||
const untouchedA = trimmed.clips.find((c) => c.id === clipA.id)!;
|
||||
expect(untouchedA.durationSamples).toBe(100);
|
||||
});
|
||||
|
||||
it("splitClip leaves sibling clips untouched", { timeout: 5000 }, () => {
|
||||
const clipA = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 0,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const clipB = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 200,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const track = makeTrackWithClips(clipA, clipB);
|
||||
|
||||
const split = splitClip(track, clipB.id, 250, 10);
|
||||
expect(split.clips).toHaveLength(3);
|
||||
const untouchedA = split.clips.find((c) => c.id === clipA.id)!;
|
||||
expect(untouchedA.durationSamples).toBe(100);
|
||||
});
|
||||
|
||||
it("moveClip does not clamp when left sibling is far enough away", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clipA = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 0,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const clipB = createClip({
|
||||
documentId: "doc1",
|
||||
startSample: 300,
|
||||
durationSamples: 100,
|
||||
});
|
||||
const track = makeTrackWithClips(clipA, clipB);
|
||||
|
||||
const { appliedDelta } = moveClip(track, clipB.id, -100);
|
||||
expect(appliedDelta).toBe(-100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { Clip } from "../../clip/clip.types.js";
|
||||
import { canSplitAt, constrainBoundaryTrim } from "../../clip/index.js";
|
||||
import {
|
||||
AUDIO_TIMELINE_CODES,
|
||||
TimelineError,
|
||||
} from "../../error-codes/index.js";
|
||||
import type { Track } from "../../track/track.types.js";
|
||||
|
||||
export interface ConstrainClipDragOptions {
|
||||
allowOverlap?: boolean;
|
||||
}
|
||||
|
||||
function findClip(track: Track, clipId: string): Clip {
|
||||
const clip = track.clips.find((c) => c.id === clipId);
|
||||
if (!clip) {
|
||||
throw new TimelineError(
|
||||
AUDIO_TIMELINE_CODES.CLIP_NOT_FOUND,
|
||||
`Clip not found: ${clipId}`,
|
||||
{ context: { clipId } }
|
||||
);
|
||||
}
|
||||
return clip;
|
||||
}
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
function generateId(): string {
|
||||
if (
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof crypto.randomUUID === "function"
|
||||
) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `clip-${++idCounter}`;
|
||||
}
|
||||
|
||||
export function constrainClipDrag(
|
||||
track: Track,
|
||||
clipId: string,
|
||||
deltaSamples: number,
|
||||
options: ConstrainClipDragOptions = {}
|
||||
): number {
|
||||
const clip = findClip(track, clipId);
|
||||
let constrained = deltaSamples;
|
||||
|
||||
const newStart = clip.startSample + constrained;
|
||||
if (newStart < 0) {
|
||||
constrained = -clip.startSample;
|
||||
}
|
||||
|
||||
if (!options.allowOverlap) {
|
||||
const clipEnd = clip.startSample + clip.durationSamples;
|
||||
|
||||
for (const other of track.clips) {
|
||||
if (other.id === clipId) {
|
||||
continue;
|
||||
}
|
||||
const otherEnd = other.startSample + other.durationSamples;
|
||||
|
||||
if (other.startSample >= clipEnd) {
|
||||
const maxDelta = other.startSample - clipEnd;
|
||||
if (constrained > maxDelta) {
|
||||
constrained = maxDelta;
|
||||
}
|
||||
} else if (otherEnd <= clip.startSample) {
|
||||
const minDelta = otherEnd - clip.startSample;
|
||||
if (constrained < minDelta) {
|
||||
constrained = minDelta;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return constrained;
|
||||
}
|
||||
|
||||
export interface MoveClipResult {
|
||||
track: Track;
|
||||
appliedDelta: number;
|
||||
}
|
||||
|
||||
export function moveClip(
|
||||
track: Track,
|
||||
clipId: string,
|
||||
deltaSamples: number,
|
||||
options?: ConstrainClipDragOptions
|
||||
): MoveClipResult {
|
||||
const appliedDelta = constrainClipDrag(
|
||||
track,
|
||||
clipId,
|
||||
deltaSamples,
|
||||
options
|
||||
);
|
||||
if (appliedDelta === 0) {
|
||||
return { track, appliedDelta: 0 };
|
||||
}
|
||||
|
||||
const clip = findClip(track, clipId);
|
||||
const updatedClip: Clip = {
|
||||
...clip,
|
||||
startSample: clip.startSample + appliedDelta,
|
||||
};
|
||||
|
||||
return {
|
||||
track: {
|
||||
...track,
|
||||
clips: track.clips.map((c) => (c.id === clipId ? updatedClip : c)),
|
||||
},
|
||||
appliedDelta,
|
||||
};
|
||||
}
|
||||
|
||||
export function trimClip(
|
||||
track: Track,
|
||||
clipId: string,
|
||||
boundary: "left" | "right",
|
||||
deltaSamples: number,
|
||||
minDuration: number,
|
||||
documentLengthSamples: number
|
||||
): Track {
|
||||
const clip = findClip(track, clipId);
|
||||
const constrained = constrainBoundaryTrim(
|
||||
clip,
|
||||
boundary,
|
||||
deltaSamples,
|
||||
minDuration,
|
||||
documentLengthSamples
|
||||
);
|
||||
const updatedClip: Clip = {
|
||||
...clip,
|
||||
startSample: constrained.startSample,
|
||||
offsetSamples: constrained.offsetSamples,
|
||||
durationSamples: constrained.durationSamples,
|
||||
};
|
||||
|
||||
return {
|
||||
...track,
|
||||
clips: track.clips.map((c) => (c.id === clipId ? updatedClip : c)),
|
||||
};
|
||||
}
|
||||
|
||||
export function removeClip(track: Track, clipId: string): Track {
|
||||
findClip(track, clipId);
|
||||
return {
|
||||
...track,
|
||||
clips: track.clips.filter((c) => c.id !== clipId),
|
||||
};
|
||||
}
|
||||
|
||||
export function splitClip(
|
||||
track: Track,
|
||||
clipId: string,
|
||||
atSample: number,
|
||||
minDuration: number
|
||||
): Track {
|
||||
const clip = findClip(track, clipId);
|
||||
|
||||
if (!canSplitAt(clip, atSample, minDuration)) {
|
||||
throw new TimelineError(
|
||||
AUDIO_TIMELINE_CODES.INVALID_SPLIT,
|
||||
`Cannot split clip ${clipId} at ${atSample}`,
|
||||
{ context: { clipId, atSample } }
|
||||
);
|
||||
}
|
||||
|
||||
const leftDuration = atSample - clip.startSample;
|
||||
const rightDuration = clip.startSample + clip.durationSamples - atSample;
|
||||
const { fadeIn, fadeOut, ...rest } = clip;
|
||||
|
||||
const left: Clip = {
|
||||
...rest,
|
||||
id: generateId(),
|
||||
durationSamples: leftDuration,
|
||||
...(fadeIn !== undefined && { fadeIn }),
|
||||
};
|
||||
|
||||
const right: Clip = {
|
||||
...rest,
|
||||
id: generateId(),
|
||||
startSample: atSample,
|
||||
offsetSamples: clip.offsetSamples + leftDuration,
|
||||
durationSamples: rightDuration,
|
||||
...(fadeOut !== undefined && { fadeOut }),
|
||||
};
|
||||
|
||||
return {
|
||||
...track,
|
||||
clips: track.clips.flatMap((c) =>
|
||||
c.id === clipId ? [left, right] : [c]
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
ConstrainClipDragOptions,
|
||||
MoveClipResult,
|
||||
} from "./clip-operations.js";
|
||||
export {
|
||||
constrainClipDrag,
|
||||
moveClip,
|
||||
removeClip,
|
||||
splitClip,
|
||||
trimClip,
|
||||
} from "./clip-operations.js";
|
||||
18
packages/media/audio/audio-timeline/src/operations/index.ts
Normal file
18
packages/media/audio/audio-timeline/src/operations/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type {
|
||||
ConstrainClipDragOptions,
|
||||
MoveClipResult,
|
||||
} from "./clip-operations/index.js";
|
||||
export {
|
||||
constrainClipDrag,
|
||||
moveClip,
|
||||
removeClip,
|
||||
splitClip,
|
||||
trimClip,
|
||||
} from "./clip-operations/index.js";
|
||||
export {
|
||||
setLoopRegion,
|
||||
setSelection,
|
||||
setZoomLevel,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
} from "./timeline-operations/index.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
setLoopRegion,
|
||||
setSelection,
|
||||
setZoomLevel,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
} from "./timeline-operations.js";
|
||||
@@ -0,0 +1,50 @@
|
||||
# Timeline Operations
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `timeline-operations.ts` module handles view-state mutations that do **not** affect the audio arrangement and therefore do not push undo commands. These helpers operate on `TimelineState` snapshots and return partial updates that the engine merges into its internal state. Keeping view logic separate from arrangement logic makes the engine easier to reason about and test.
|
||||
|
||||
## Features
|
||||
|
||||
- `setSelection` normalises reversed ranges and clamps to document duration
|
||||
- `setLoopRegion` updates loop boundaries without clamping
|
||||
- `zoomIn` / `zoomOut` step through a discrete zoom table
|
||||
- `setZoomLevel` snaps arbitrary `samplesPerPixel` values to the table
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic
|
||||
|
||||
```ts
|
||||
import { setSelection, setLoopRegion } from "@suaveplan/audio-timeline/operations";
|
||||
|
||||
const sel = setSelection(state, 100, 50); // normalises to 50..100
|
||||
const loop = setLoopRegion(state, 0, 5000);
|
||||
```
|
||||
|
||||
### Advanced
|
||||
|
||||
```ts
|
||||
import { zoomIn, zoomOut, setZoomLevel } from "@suaveplan/audio-timeline/operations";
|
||||
|
||||
const z1 = zoomIn(zoomTable, 3); // { zoomIndex: 4, samplesPerPixel: 16 }
|
||||
const z2 = setZoomLevel(zoomTable, 150); // snaps to 128
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `setSelection` | `(state, start, end) => { selectionStart, selectionEnd }` | Normalise and clamp |
|
||||
| `setLoopRegion` | `(state, start, end) => { loopStart, loopEnd }` | Set loop bounds |
|
||||
| `zoomIn` | `(table, index) => { zoomIndex, samplesPerPixel }` | Step in |
|
||||
| `zoomOut` | `(table, index) => { zoomIndex, samplesPerPixel }` | Step out |
|
||||
| `setZoomLevel` | `(table, spp) => { zoomIndex, samplesPerPixel }` | Snap to table |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Zoom and selection are purely view state; they are not part of the undo history because users expect zoom gestures to be ephemeral. Keeping these helpers pure makes them trivial to unit-test without instantiating a full `TimelineEngine`. The clamping logic in `setSelection` guarantees that consumers always read `selectionStart <= selectionEnd`, even when the user drags the selection backwards. Loop boundaries are not clamped because the engine may allow loops that extend beyond the current document length for live expansion workflows.
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { TimelineState } from "../../engine/engine.types.js";
|
||||
import {
|
||||
setLoopRegion,
|
||||
setSelection,
|
||||
setZoomLevel,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
} from "./timeline-operations.js";
|
||||
|
||||
function makeState(duration: number): TimelineState {
|
||||
return {
|
||||
tracks: [],
|
||||
tracksVersion: 0,
|
||||
duration,
|
||||
currentTime: 0,
|
||||
isPlaying: false,
|
||||
samplesPerPixel: 1,
|
||||
sampleRate: 48000,
|
||||
selectedTrackId: null,
|
||||
zoomIndex: 0,
|
||||
canZoomIn: true,
|
||||
canZoomOut: false,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0,
|
||||
masterVolume: 1,
|
||||
loopStart: 0,
|
||||
loopEnd: 0,
|
||||
isLoopEnabled: false,
|
||||
bpm: 120,
|
||||
ppqn: 96,
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("timeline operations", () => {
|
||||
it("setSelection normalises and clamps the range", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const state = makeState(100);
|
||||
const result = setSelection(state, 120, -10);
|
||||
expect(result.selectionStart).toBe(0);
|
||||
expect(result.selectionEnd).toBe(100);
|
||||
});
|
||||
|
||||
it("setSelection keeps normal order when start <= end", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const state = makeState(100);
|
||||
const result = setSelection(state, 10, 20);
|
||||
expect(result.selectionStart).toBe(10);
|
||||
expect(result.selectionEnd).toBe(20);
|
||||
});
|
||||
|
||||
it("zoomIn/zoomOut respect the table bounds", { timeout: 5000 }, () => {
|
||||
const table = [1, 2, 4, 8];
|
||||
expect(zoomIn(table, 0)).toEqual({
|
||||
zoomIndex: 1,
|
||||
samplesPerPixel: 2,
|
||||
});
|
||||
expect(zoomIn(table, 3)).toEqual({
|
||||
zoomIndex: 3,
|
||||
samplesPerPixel: 8,
|
||||
});
|
||||
expect(zoomOut(table, 3)).toEqual({
|
||||
zoomIndex: 2,
|
||||
samplesPerPixel: 4,
|
||||
});
|
||||
expect(zoomOut(table, 0)).toEqual({
|
||||
zoomIndex: 0,
|
||||
samplesPerPixel: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("setLoopRegion returns the updated loop fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const state = makeState(100);
|
||||
const result = setLoopRegion(state, 10, 90);
|
||||
expect(result.loopStart).toBe(10);
|
||||
expect(result.loopEnd).toBe(90);
|
||||
});
|
||||
|
||||
it("setZoomLevel snaps to the nearest table entry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const table = [1, 2, 4, 8];
|
||||
const result = setZoomLevel(table, 3);
|
||||
expect(result.zoomIndex).toBe(1);
|
||||
expect(result.samplesPerPixel).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { TimelineState } from "../../engine/engine.types.js";
|
||||
|
||||
export function setSelection(
|
||||
state: TimelineState,
|
||||
start: number,
|
||||
end: number
|
||||
): Pick<TimelineState, "selectionStart" | "selectionEnd"> {
|
||||
const [a, b] = start <= end ? [start, end] : [end, start];
|
||||
const clampedStart = Math.max(0, Math.min(a, state.duration));
|
||||
const clampedEnd = Math.max(0, Math.min(b, state.duration));
|
||||
return {
|
||||
selectionStart: clampedStart,
|
||||
selectionEnd: clampedEnd,
|
||||
};
|
||||
}
|
||||
|
||||
export function setLoopRegion(
|
||||
_state: TimelineState,
|
||||
start: number,
|
||||
end: number
|
||||
): Pick<TimelineState, "loopStart" | "loopEnd"> {
|
||||
return {
|
||||
loopStart: start,
|
||||
loopEnd: end,
|
||||
};
|
||||
}
|
||||
|
||||
export function zoomIn(
|
||||
zoomTable: readonly number[],
|
||||
zoomIndex: number
|
||||
): { zoomIndex: number; samplesPerPixel: number } {
|
||||
const next = Math.min(zoomIndex + 1, zoomTable.length - 1);
|
||||
return { zoomIndex: next, samplesPerPixel: zoomTable[next] as number };
|
||||
}
|
||||
|
||||
export function zoomOut(
|
||||
zoomTable: readonly number[],
|
||||
zoomIndex: number
|
||||
): { zoomIndex: number; samplesPerPixel: number } {
|
||||
const prev = Math.max(zoomIndex - 1, 0);
|
||||
return { zoomIndex: prev, samplesPerPixel: zoomTable[prev] as number };
|
||||
}
|
||||
|
||||
export function setZoomLevel(
|
||||
zoomTable: readonly number[],
|
||||
samplesPerPixel: number
|
||||
): { zoomIndex: number; samplesPerPixel: number } {
|
||||
let nearest = 0;
|
||||
let minDiff = Number.POSITIVE_INFINITY;
|
||||
for (let i = 0; i < zoomTable.length; i++) {
|
||||
const diff = Math.abs((zoomTable[i] as number) - samplesPerPixel);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
nearest = i;
|
||||
}
|
||||
}
|
||||
return {
|
||||
zoomIndex: nearest,
|
||||
samplesPerPixel: zoomTable[nearest] as number,
|
||||
};
|
||||
}
|
||||
6
packages/media/audio/audio-timeline/src/track/index.ts
Normal file
6
packages/media/audio/audio-timeline/src/track/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createTrack,
|
||||
removeTrack,
|
||||
updateTrack,
|
||||
validateTrack,
|
||||
} from "./track.js";
|
||||
49
packages/media/audio/audio-timeline/src/track/track.md
Normal file
49
packages/media/audio/audio-timeline/src/track/track.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Track Helpers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `track.ts` module provides pure, immutable CRUD operations for the `Track` model used by the `@suaveplan/audio-timeline` engine. A track represents a horizontal lane in a DAW timeline that owns an ordered list of clips together with per-track mixer state (`volume`, `muted`, `soloed`, `pan`). These helpers are the building blocks for the engine's track operations and can be used independently in reducers or tests.
|
||||
|
||||
## Features
|
||||
|
||||
- `createTrack` builds a validated track with sensible defaults and a generated id
|
||||
- `updateTrack` returns a shallow-copied track with validated overrides
|
||||
- `removeTrack` filters a track array by id without mutating the input
|
||||
- `validateTrack` runs runtime Zod validation for ad-hoc track objects
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic
|
||||
|
||||
```ts
|
||||
import { createTrack, updateTrack, removeTrack } from "@suaveplan/audio-timeline/track";
|
||||
|
||||
const track = createTrack({ name: "Vocals" });
|
||||
const renamed = updateTrack(track, { name: "Lead Vocals" });
|
||||
const fewer = removeTrack([track, renamed], track.id);
|
||||
```
|
||||
|
||||
### Advanced
|
||||
|
||||
```ts
|
||||
import { validateTrack } from "@suaveplan/audio-timeline/track";
|
||||
|
||||
validateTrack({ id: "t1", name: "X", clips: [], volume: 1, muted: false, soloed: false, pan: 0 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `createTrack` | `(options?) => Track` | Build a validated track |
|
||||
| `updateTrack` | `(track, updates) => Track` | Immutable update with re-validation |
|
||||
| `removeTrack` | `(tracks, trackId) => Track[]` | Filter out one track |
|
||||
| `validateTrack` | `(track) => asserts track is Track` | Zod guard |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All helpers are side-effect free. They never mutate input objects, which makes them trivial to use inside undo/redo commands and React render cycles. The validation step guarantees that every track that leaves the module satisfies the invariants required by the timeline engine (e.g. `volume` in `0..1`, `pan` in `-1..1`). Track ids are generated via `crypto.randomUUID` when available, with a deterministic counter fallback for test environments.
|
||||
@@ -0,0 +1,12 @@
|
||||
import z from "zod";
|
||||
import { ClipSchema } from "../clip/clip.schemas.js";
|
||||
|
||||
export const TrackSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string(),
|
||||
clips: z.array(ClipSchema),
|
||||
volume: z.number().min(0).max(1),
|
||||
muted: z.boolean(),
|
||||
soloed: z.boolean(),
|
||||
pan: z.number().min(-1).max(1),
|
||||
});
|
||||
120
packages/media/audio/audio-timeline/src/track/track.test.ts
Normal file
120
packages/media/audio/audio-timeline/src/track/track.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_TIMELINE_CODES,
|
||||
registerTimelineErrorCodes,
|
||||
TimelineError,
|
||||
} from "../error-codes/index.js";
|
||||
import {
|
||||
createTrack,
|
||||
removeTrack,
|
||||
updateTrack,
|
||||
validateTrack,
|
||||
} from "./track.js";
|
||||
|
||||
describe("track helpers", () => {
|
||||
it("createTrack generates an id and applies defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const track = createTrack();
|
||||
expect(track.id).toBeTruthy();
|
||||
expect(track.name).toBe("Track");
|
||||
expect(track.volume).toBe(1);
|
||||
expect(track.muted).toBe(false);
|
||||
expect(track.soloed).toBe(false);
|
||||
expect(track.pan).toBe(0);
|
||||
expect(track.clips).toEqual([]);
|
||||
});
|
||||
|
||||
it("createTrack accepts option overrides", { timeout: 5000 }, () => {
|
||||
const track = createTrack({
|
||||
name: "Drums",
|
||||
volume: 0.5,
|
||||
muted: true,
|
||||
pan: -0.5,
|
||||
});
|
||||
expect(track.name).toBe("Drums");
|
||||
expect(track.volume).toBe(0.5);
|
||||
expect(track.muted).toBe(true);
|
||||
expect(track.pan).toBe(-0.5);
|
||||
});
|
||||
|
||||
it("createTrack throws for invalid options", { timeout: 5000 }, () => {
|
||||
expect(() => createTrack({ volume: 1.5 })).toThrow(TimelineError);
|
||||
try {
|
||||
createTrack({ volume: 1.5 });
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.INVALID_CLIP_MOVE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("updateTrack returns a new track with merged fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const track = createTrack({ name: "Bass" });
|
||||
const updated = updateTrack(track, { volume: 0.8 });
|
||||
expect(updated.name).toBe("Bass");
|
||||
expect(updated.volume).toBe(0.8);
|
||||
expect(updated.id).toBe(track.id);
|
||||
});
|
||||
|
||||
it("removeTrack returns a new array without the matching track", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = createTrack({ name: "A" });
|
||||
const b = createTrack({ name: "B" });
|
||||
const result = removeTrack([a, b], a.id);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe(b.id);
|
||||
});
|
||||
|
||||
it("validateTrack rejects invalid geometry with the Zod issues attached", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
validateTrack({
|
||||
id: "",
|
||||
name: "X",
|
||||
clips: [],
|
||||
volume: 0,
|
||||
muted: false,
|
||||
soloed: false,
|
||||
pan: 0,
|
||||
})
|
||||
).toThrow(TimelineError);
|
||||
try {
|
||||
validateTrack({
|
||||
id: "",
|
||||
name: "X",
|
||||
clips: [],
|
||||
volume: 0,
|
||||
muted: false,
|
||||
soloed: false,
|
||||
pan: 0,
|
||||
});
|
||||
} catch (err) {
|
||||
expect((err as TimelineError).code).toBe(
|
||||
AUDIO_TIMELINE_CODES.INVALID_CLIP_MOVE
|
||||
);
|
||||
expect((err as TimelineError).context).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("createTrack falls back to counter id when crypto.randomUUID is unavailable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = (crypto as unknown as Record<string, unknown>)[
|
||||
"randomUUID"
|
||||
];
|
||||
(crypto as unknown as Record<string, unknown>)["randomUUID"] =
|
||||
undefined;
|
||||
const track = createTrack();
|
||||
expect(track.id).toMatch(/^track-\d+$/);
|
||||
(crypto as unknown as Record<string, unknown>)["randomUUID"] = original;
|
||||
});
|
||||
|
||||
it("registerTimelineErrorCodes is callable", { timeout: 5000 }, () => {
|
||||
expect(() => registerTimelineErrorCodes()).not.toThrow();
|
||||
});
|
||||
});
|
||||
53
packages/media/audio/audio-timeline/src/track/track.ts
Normal file
53
packages/media/audio/audio-timeline/src/track/track.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { AUDIO_TIMELINE_CODES, TimelineError } from "../error-codes/index.js";
|
||||
import { TrackSchema } from "./track.schemas.js";
|
||||
import type { Track } from "./track.types.js";
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
function generateId(): string {
|
||||
if (
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof crypto.randomUUID === "function"
|
||||
) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `track-${++idCounter}`;
|
||||
}
|
||||
|
||||
export function createTrack(options: Partial<Track> = {}): Track {
|
||||
const track: Track = {
|
||||
id: options.id ?? generateId(),
|
||||
name: options.name ?? "Track",
|
||||
clips: options.clips ?? [],
|
||||
volume: options.volume ?? 1,
|
||||
muted: options.muted ?? false,
|
||||
soloed: options.soloed ?? false,
|
||||
pan: options.pan ?? 0,
|
||||
};
|
||||
validateTrack(track);
|
||||
return track;
|
||||
}
|
||||
|
||||
export function updateTrack(track: Track, updates: Partial<Track>): Track {
|
||||
const updated: Track = { ...track, ...updates };
|
||||
validateTrack(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function removeTrack(
|
||||
tracks: readonly Track[],
|
||||
trackId: string
|
||||
): Track[] {
|
||||
return tracks.filter((t) => t.id !== trackId);
|
||||
}
|
||||
|
||||
export function validateTrack(track: unknown): asserts track is Track {
|
||||
const result = TrackSchema.safeParse(track);
|
||||
if (!result.success) {
|
||||
throw new TimelineError(
|
||||
AUDIO_TIMELINE_CODES.INVALID_CLIP_MOVE,
|
||||
"Track validation failed",
|
||||
{ context: { issues: result.error.issues } }
|
||||
);
|
||||
}
|
||||
}
|
||||
11
packages/media/audio/audio-timeline/src/track/track.types.ts
Normal file
11
packages/media/audio/audio-timeline/src/track/track.types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Clip } from "../clip/clip.types.js";
|
||||
|
||||
export interface Track {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly clips: Clip[];
|
||||
readonly volume: number;
|
||||
readonly muted: boolean;
|
||||
readonly soloed: boolean;
|
||||
readonly pan: number;
|
||||
}
|
||||
8
packages/media/audio/audio-timeline/src/zoom/index.ts
Normal file
8
packages/media/audio/audio-timeline/src/zoom/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
canZoomIn,
|
||||
canZoomOut,
|
||||
DEFAULT_ZOOM_TABLE,
|
||||
setZoomLevel,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
} from "./zoom.js";
|
||||
52
packages/media/audio/audio-timeline/src/zoom/zoom.md
Normal file
52
packages/media/audio/audio-timeline/src/zoom/zoom.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Zoom Helpers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `zoom.ts` module provides a discrete zoom table and navigation helpers for the timeline view. Zoom levels are expressed as `samplesPerPixel`; larger values show more timeline in fewer pixels. The module is pure and side-effect free, making it safe to use in reducers, audio-thread code, and React memoization.
|
||||
|
||||
## Features
|
||||
|
||||
- Predefined `DEFAULT_ZOOM_TABLE` with 14 powers-of-two entries (1 to 8192)
|
||||
- `zoomIn` / `zoomOut` index arithmetic with boundary capping
|
||||
- `setZoomLevel` to snap arbitrary `samplesPerPixel` values to the nearest table entry
|
||||
- `canZoomIn` / `canZoomOut` boolean predicates for UI button disabled states
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic
|
||||
|
||||
```ts
|
||||
import { zoomIn, zoomOut, DEFAULT_ZOOM_TABLE } from "@suaveplan/audio-timeline/zoom";
|
||||
|
||||
let index = 0;
|
||||
index = zoomIn(DEFAULT_ZOOM_TABLE, index); // 1
|
||||
index = zoomOut(DEFAULT_ZOOM_TABLE, index); // 0
|
||||
```
|
||||
|
||||
### Advanced
|
||||
|
||||
```ts
|
||||
import { setZoomLevel, canZoomIn } from "@suaveplan/audio-timeline/zoom";
|
||||
|
||||
const result = setZoomLevel(DEFAULT_ZOOM_TABLE, 150); // snaps to 128
|
||||
const canGoFiner = canZoomIn(DEFAULT_ZOOM_TABLE, result.zoomIndex);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `DEFAULT_ZOOM_TABLE` | `readonly number[]` | Default discrete zoom table |
|
||||
| `zoomIn` | `(table, index) => number` | Increment zoom index, capped at max |
|
||||
| `zoomOut` | `(table, index) => number` | Decrement zoom index, floored at 0 |
|
||||
| `setZoomLevel` | `(table, spp) => { zoomIndex, samplesPerPixel }` | Snap to nearest table entry |
|
||||
| `canZoomIn` | `(table, index) => boolean` | True if index < max |
|
||||
| `canZoomOut` | `(table, index) => boolean` | True if index > 0 |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Using a discrete table instead of a continuous float keeps zoom states comparable by simple index equality. This is useful for React memoization and for persisting user preferences. All helpers are pure and operate only on the table and index, so they can be used in reducers, tests, and even audio-thread code without side effects.
|
||||
41
packages/media/audio/audio-timeline/src/zoom/zoom.test.ts
Normal file
41
packages/media/audio/audio-timeline/src/zoom/zoom.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
canZoomIn,
|
||||
canZoomOut,
|
||||
DEFAULT_ZOOM_TABLE,
|
||||
setZoomLevel,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
} from "./zoom.js";
|
||||
|
||||
describe("zoom helpers", () => {
|
||||
it("zoomIn/zoomOut respect the table bounds", { timeout: 5000 }, () => {
|
||||
expect(zoomIn(DEFAULT_ZOOM_TABLE, 0)).toBe(1);
|
||||
expect(zoomIn(DEFAULT_ZOOM_TABLE, DEFAULT_ZOOM_TABLE.length - 1)).toBe(
|
||||
DEFAULT_ZOOM_TABLE.length - 1
|
||||
);
|
||||
expect(zoomOut(DEFAULT_ZOOM_TABLE, 0)).toBe(0);
|
||||
expect(zoomOut(DEFAULT_ZOOM_TABLE, DEFAULT_ZOOM_TABLE.length - 1)).toBe(
|
||||
DEFAULT_ZOOM_TABLE.length - 2
|
||||
);
|
||||
});
|
||||
|
||||
it("canZoomIn and canZoomOut reflect bounds", { timeout: 5000 }, () => {
|
||||
expect(canZoomIn(DEFAULT_ZOOM_TABLE, 0)).toBe(true);
|
||||
expect(
|
||||
canZoomIn(DEFAULT_ZOOM_TABLE, DEFAULT_ZOOM_TABLE.length - 1)
|
||||
).toBe(false);
|
||||
expect(canZoomOut(DEFAULT_ZOOM_TABLE, 0)).toBe(false);
|
||||
expect(
|
||||
canZoomOut(DEFAULT_ZOOM_TABLE, DEFAULT_ZOOM_TABLE.length - 1)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("setZoomLevel snaps to the nearest table entry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = setZoomLevel(DEFAULT_ZOOM_TABLE, 300);
|
||||
expect(result.zoomIndex).toBe(8);
|
||||
expect(result.samplesPerPixel).toBe(256);
|
||||
});
|
||||
});
|
||||
35
packages/media/audio/audio-timeline/src/zoom/zoom.ts
Normal file
35
packages/media/audio/audio-timeline/src/zoom/zoom.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export const DEFAULT_ZOOM_TABLE: readonly number[] = [
|
||||
1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192,
|
||||
];
|
||||
|
||||
export function zoomIn(table: readonly number[], index: number): number {
|
||||
return Math.min(index + 1, table.length - 1);
|
||||
}
|
||||
|
||||
export function zoomOut(_table: readonly number[], index: number): number {
|
||||
return Math.max(index - 1, 0);
|
||||
}
|
||||
|
||||
export function setZoomLevel(
|
||||
table: readonly number[],
|
||||
samplesPerPixel: number
|
||||
): { zoomIndex: number; samplesPerPixel: number } {
|
||||
let nearest = 0;
|
||||
let minDiff = Number.POSITIVE_INFINITY;
|
||||
for (let i = 0; i < table.length; i++) {
|
||||
const diff = Math.abs((table[i] as number) - samplesPerPixel);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
nearest = i;
|
||||
}
|
||||
}
|
||||
return { zoomIndex: nearest, samplesPerPixel: table[nearest] as number };
|
||||
}
|
||||
|
||||
export function canZoomIn(table: readonly number[], index: number): boolean {
|
||||
return index < table.length - 1;
|
||||
}
|
||||
|
||||
export function canZoomOut(_table: readonly number[], index: number): boolean {
|
||||
return index > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user