Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
# @suaveplan/audio-transport - Universal musical clock and transport: sample-accurate scheduling with PPQ, BPM automation, swing, time signatures, and loop regions. Zero browser or Node dependencies.
Universal musical clock and transport: sample-accurate scheduling with PPQ, BPM automation, swing, time signatures, and loop regions. Zero browser or Node dependencies.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — This module defines the canonical error codes, metadata, and error class for the `@suaveplan/audio-transport` package.…
- [error-codes](./src/error-codes/error-codes.md) — This module defines the canonical error codes, metadata, and error class for the `@suaveplan/audio-transport` package.…
- [scheduler](./src/scheduler/scheduler.md) — The `TransportScheduler` class drives the lookahead loop that polls the transport and fires scheduled callbacks. It bri…
- [scheduler](./src/scheduler/scheduler.md) — The `TransportScheduler` class drives the lookahead loop that polls the transport and fires scheduled callbacks. It bri…
- [scheduler.schemas](./src/scheduler/scheduler.schemas.md) — Zod boundary schema for `TransportScheduler` construction options.
- [signal](./src/signal/signal.md) — The `Signal` class implements a piecewise automation curve for scalar parameters such as BPM. It stores automation poin…
- [signal](./src/signal/signal.md) — The `Signal` class implements a piecewise automation curve for scalar parameters such as BPM. It stores automation poin…
- [signal.schemas](./src/signal/signal.schemas.md) — Zod boundary schema for `Signal` construction options.
- [tick-source](./src/tick-source/tick-source.md) — The `TickSource` class converts between audio time (seconds) and musical ticks, integrating tempo curves for sample-acc…
- [tick-source](./src/tick-source/tick-source.md) — The `TickSource` class converts between audio time (seconds) and musical ticks, integrating tempo curves for sample-acc…
- [tick-source.schemas](./src/tick-source/tick-source.schemas.md) — Zod boundary schema for `TickSource` construction options.
- [time](./src/time/time.md) — This module parses musical time tokens and converts between ticks, seconds, and bars:beats:sixteenths notation.
- [time](./src/time/time.md) — This module parses musical time tokens and converts between ticks, seconds, and bars:beats:sixteenths notation.
- [time.schemas](./src/time/time.schemas.md) — Zod boundary schemas for time-related types.
- [transport](./src/transport/transport.md) — The `Transport` class is the core musical clock and scheduler. It manages playback state, event scheduling, loop region…
- [transport](./src/transport/transport.md) — The `Transport` class is the core musical clock and scheduler. It manages playback state, event scheduling, loop region…
- [transport.schemas](./src/transport/transport.schemas.md) — Zod boundary schema for `Transport` construction options.
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/audio-transport
```
## Quick start
```ts
import { Transport, TransportScheduler } from "@suaveplan/audio-transport";
const clock = () => Date.now() / 1000;
const tr = new Transport({ clock, bpm: 120, ppq: 192 });
const scheduler = new TransportScheduler(tr, clock);
tr.scheduleRepeat((time) => {
console.log("beat at", time);
}, "4n");
tr.start();
scheduler.start();
```
## Modules
- [Transport](src/transport/transport.md) — musical clock and event scheduler
- [TransportScheduler](src/scheduler/scheduler.md) — lookahead poll loop
- [TickSource](src/tick-source/tick-source.md) — audio time to musical ticks
- [Signal](src/signal/signal.md) — tempo automation curves
- [Time](src/time/time.md) — musical time parsing and conversion
- [Error Codes](src/error-codes.md) — structured error registry

View File

@@ -0,0 +1,81 @@
{
"name": "@suaveplan/audio-transport",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal musical clock and transport: sample-accurate scheduling with PPQ, BPM automation, swing, time signatures, and loop regions. Zero browser or Node dependencies.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./transport/transport": {
"import": { "types": "./dist/transport/transport.d.ts", "default": "./dist/transport/transport.js" },
"require": { "types": "./dist/transport/transport.d.ts", "default": "./dist/transport/transport.js" }
},
"./scheduler/scheduler": {
"import": { "types": "./dist/scheduler/scheduler.d.ts", "default": "./dist/scheduler/scheduler.js" },
"require": { "types": "./dist/scheduler/scheduler.d.ts", "default": "./dist/scheduler/scheduler.js" }
},
"./tick-source/tick-source": {
"import": { "types": "./dist/tick-source/tick-source.d.ts", "default": "./dist/tick-source/tick-source.js" },
"require": { "types": "./dist/tick-source/tick-source.d.ts", "default": "./dist/tick-source/tick-source.js" }
},
"./signal/signal": {
"import": { "types": "./dist/signal/signal.d.ts", "default": "./dist/signal/signal.js" },
"require": { "types": "./dist/signal/signal.d.ts", "default": "./dist/signal/signal.js" }
},
"./time/time": {
"import": { "types": "./dist/time/time.d.ts", "default": "./dist/time/time.js" },
"require": { "types": "./dist/time/time.d.ts", "default": "./dist/time/time.js" }
},
"./error-codes": {
"import": { "types": "./dist/error-codes/index.d.ts", "default": "./dist/error-codes/index.js" },
"require": { "types": "./dist/error-codes/index.d.ts", "default": "./dist/error-codes/index.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/fsm": "workspace:^",
"@suaveplan/timing": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,57 @@
# Error Codes
This module defines the canonical error codes, metadata, and error class for the `@suaveplan/audio-transport` package. Every throwing site in the library uses one of these codes so that callers can handle failures programmatically without parsing human-readable messages.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Audio transport involves many operations that can fail in predictable ways: invalid time tokens, out-of-range BPM values, missing scheduled events, and operations on a disposed transport. Centralising the error codes in one module guarantees consistency across the package and gives consumers a stable contract for error handling.
## Features
- Five error codes covering time, BPM, scheduler, events, and disposal
- `TransportError` extends `SuaveplanError` with a public constructor
- `AUDIO_TRANSPORT_ERROR_METADATA` maps each code to a description and severity
- `registerTransportErrorCodes()` alias for spec compliance
- Self-registering on import
## Usage: Basic
```ts
import { AUDIO_TRANSPORT_CODES, TransportError } from "@suaveplan/audio-transport";
throw new TransportError(
AUDIO_TRANSPORT_CODES.INVALID_TIME,
"audio_transport.transport.invalid_loop_end",
{ context: { loopStart: "0:0:0", loopEnd: "0:0:0" } }
);
```
## Usage: Advanced
```ts
import {
AUDIO_TRANSPORT_CODES,
AUDIO_TRANSPORT_ERROR_METADATA,
TransportError,
} from "@suaveplan/audio-transport";
function describeFailure(err: TransportError): string {
const meta = AUDIO_TRANSPORT_ERROR_METADATA[err.code];
return `${meta.severity}: ${meta.description}`;
}
```
## API Reference
- `AUDIO_TRANSPORT_CODES` — `defineCodes("AUDIO_TRANSPORT", [...])` constant
- `AUDIO_TRANSPORT_ERROR_METADATA` — readonly map of code → `{ description, severity }`
- `TransportError` — error class with public constructor `(code, messageKey, options?)`
- `registerTransportErrorCodes()` / `registerAudioTransportErrorCodes()` — no-op aliases
## Implementation Notes
The registry is self-registering on import. `TransportError` widens the protected `SuaveplanError` constructor to public so that factory and class methods can instantiate it directly. All five codes are exercised by the test suite. The `TransportErrorCode` type alias and `registerTransportErrorCodes` function exist purely for spec compliance and resolve to the same runtime values as their `AudioTransport*` counterparts.

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIO_TRANSPORT_CODES,
AUDIO_TRANSPORT_ERROR_METADATA,
TransportError,
} from "./error-codes.js";
describe("error codes", () => {
it("every AUDIO_TRANSPORT code is reachable from a call site", {
timeout: 5000,
}, () => {
const codes = Object.values(AUDIO_TRANSPORT_CODES);
for (const code of codes) {
expect(AUDIO_TRANSPORT_ERROR_METADATA[code]).toBeDefined();
expect(() => {
throw new TransportError(code, "test");
}).toThrow(TransportError);
}
});
});

View File

@@ -0,0 +1,71 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const AUDIO_TRANSPORT_CODES = defineCodes("AUDIO_TRANSPORT", [
"INVALID_TIME",
"INVALID_BPM",
"SCHEDULER_NOT_STARTED",
"EVENT_NOT_FOUND",
"ALREADY_DISPOSED",
] as const);
export type AudioTransportCode =
(typeof AUDIO_TRANSPORT_CODES)[keyof typeof AUDIO_TRANSPORT_CODES];
/** Canonical alias used by the spec. */
export type TransportErrorCode = AudioTransportCode;
export interface AudioTransportErrorMetadata {
readonly description: string;
readonly severity: "warning" | "error";
}
export const AUDIO_TRANSPORT_ERROR_METADATA: Readonly<
Record<AudioTransportCode, AudioTransportErrorMetadata>
> = Object.freeze({
[AUDIO_TRANSPORT_CODES.INVALID_TIME]: {
description:
"The supplied time value, token, or loop range is invalid or out of range.",
severity: "error",
},
[AUDIO_TRANSPORT_CODES.INVALID_BPM]: {
description:
"The supplied BPM value is non-positive, non-finite, or would create an undefined exponential curve.",
severity: "error",
},
[AUDIO_TRANSPORT_CODES.SCHEDULER_NOT_STARTED]: {
description:
"The scheduler-dependent operation was invoked before the lookahead loop was started.",
severity: "error",
},
[AUDIO_TRANSPORT_CODES.EVENT_NOT_FOUND]: {
description:
"The requested event id does not exist or has already been removed.",
severity: "error",
},
[AUDIO_TRANSPORT_CODES.ALREADY_DISPOSED]: {
description:
"The transport has been disposed and no longer accepts mutations.",
severity: "error",
},
});
export class TransportError extends SuaveplanError<AudioTransportCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens the protected base constructor to public
public constructor(
code: AudioTransportCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}
export function registerAudioTransportErrorCodes(): void {
// Idempotent — metadata is already frozen and exported for direct use.
}
/** Canonical alias used by the spec. */
export const registerTransportErrorCodes = registerAudioTransportErrorCodes;
registerAudioTransportErrorCodes();

View File

@@ -0,0 +1,12 @@
export type {
AudioTransportCode,
AudioTransportErrorMetadata,
TransportErrorCode,
} from "./error-codes.js";
export {
AUDIO_TRANSPORT_CODES,
AUDIO_TRANSPORT_ERROR_METADATA,
registerAudioTransportErrorCodes,
registerTransportErrorCodes,
TransportError,
} from "./error-codes.js";

View File

@@ -0,0 +1,59 @@
export {
AUDIO_TRANSPORT_CODES,
AUDIO_TRANSPORT_ERROR_METADATA,
type AudioTransportCode,
type AudioTransportErrorMetadata,
registerAudioTransportErrorCodes,
registerTransportErrorCodes,
TransportError,
type TransportErrorCode,
} from "./error-codes/index.js";
export { TransportScheduler } from "./scheduler/scheduler.js";
export { TransportSchedulerOptionsSchema } from "./scheduler/scheduler.schemas.js";
export type {
TransportSchedulerLike,
TransportSchedulerOptions,
} from "./scheduler/scheduler.types.js";
export { Signal } from "./signal/signal.js";
export { SignalOptionsSchema } from "./signal/signal.schemas.js";
export type {
AutomationPoint,
CurveType,
SignalLike,
SignalOptions,
} from "./signal/signal.types.js";
export { TickSource } from "./tick-source/tick-source.js";
export { TickSourceOptionsSchema } from "./tick-source/tick-source.schemas.js";
export type {
TickSourceLike,
TickSourceOptions,
} from "./tick-source/tick-source.types.js";
export {
fromBarsBeatsSixteenths,
parseTimeToken,
secondsToTicks,
ticksToSeconds,
toBarsBeatsSixteenths,
} from "./time/time.js";
export {
BarsBeatsSixteenthsSchema,
TicksSchema,
TransportTimeSchema,
} from "./time/time.schemas.js";
export type {
BarsBeatsSixteenths,
SubdivisionToken,
Ticks,
TimeContext,
TimeParseResult,
TransportTime,
} from "./time/time.types.js";
export { Transport } from "./transport/transport.js";
export { TransportOptionsSchema } from "./transport/transport.schemas.js";
export type {
ScheduledEvent,
TransportCallback,
TransportEventMap,
TransportLike,
TransportOptions,
} from "./transport/transport.types.js";

View File

@@ -0,0 +1,6 @@
export { TransportScheduler } from "./scheduler.js";
export { TransportSchedulerOptionsSchema } from "./scheduler.schemas.js";
export type {
TransportSchedulerLike,
TransportSchedulerOptions,
} from "./scheduler.types.js";

View File

@@ -0,0 +1,58 @@
# TransportScheduler
The `TransportScheduler` class drives the lookahead loop that polls the transport and fires scheduled callbacks. It bridges the `Transport` and the wall clock, ensuring that musical events are delivered with sample-accurate timing even when the underlying poll timer is coarse.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
In real-time audio systems, callbacks must fire at precise musical times. A naive approach would poll the transport on every sample, which is prohibitively expensive. Instead, `TransportScheduler` polls at a coarse interval (~100 Hz) and asks the transport to process the entire time window in one go. The transport then uses `TickSource.forEachTickBetween` to visit every musical tick in the window and invoke callbacks at their exact audio times.
## Features
- Poll-driven lookahead using `@suaveplan/timing`'s `createScheduler`
- `start()` / `stop()` lifecycle
- `AbortSignal` support for mid-run cancellation
- OpenTelemetry `transport.tick` span per poll
- Throws `AUDIO_TRANSPORT_SCHEDULER_NOT_STARTED` for invalid operations
## Usage: Basic
```ts
import { Transport, TransportScheduler } from "@suaveplan/audio-transport";
const tr = new Transport();
const scheduler = new TransportScheduler(tr, () => Date.now() / 1000);
tr.start();
scheduler.start();
```
## Usage: Advanced
```ts
const ac = new AbortController();
scheduler.start({ signal: ac.signal });
// Later:
ac.abort();
```
## API Reference
### Constructor
`new TransportScheduler(transport, clock, options?)`
### Methods
- `start(options?)` — begin polling; accepts `AbortSignal`
- `stop()` — halt polling
### Properties
- `running` — boolean
## Implementation Notes
The scheduler uses real timers via `createScheduler`. Each poll creates an OpenTelemetry span named `transport.tick`. Calling `stop()` before `start()` throws `AUDIO_TRANSPORT_SCHEDULER_NOT_STARTED`. The scheduler does not emit FSM events directly; those are emitted synchronously by the `Transport` when `start`, `stop`, or `pause` are called. The `_tick` and `_scheduleNextPoll` methods contain defensive guards for the case where `stop()` races with a scheduled callback.

View File

@@ -0,0 +1,32 @@
# TransportSchedulerOptionsSchema
Zod boundary schema for `TransportScheduler` construction options.
## Purpose
Validates the options passed when constructing a `TransportScheduler`.
## Features
- Requires `transport` (any — runtime-validated)
- Requires `clock` as a function returning `number`
- Optional `pollIntervalMs` as positive number
## Usage
```ts
import { TransportSchedulerOptionsSchema } from "@suaveplan/audio-transport";
const parsed = TransportSchedulerOptionsSchema.safeParse({
transport: myTransport,
clock: () => Date.now() / 1000,
});
```
## API Reference
- `TransportSchedulerOptionsSchema` — `z.object({ transport, clock, pollIntervalMs })`
## Implementation Notes
`transport` is typed as `z.any()` because Zod cannot express the `Transport` class type at schema level. The constructor performs the actual instance check.

View File

@@ -0,0 +1,12 @@
import z from "zod";
/**
* Zod schema for {@link TransportScheduler} construction options.
*
* @see {@link TransportSchedulerOptions}
*/
export const TransportSchedulerOptionsSchema = z.object({
transport: z.any(), // Transport instance — validated at runtime by the constructor
clock: z.function({ output: z.number() }),
pollIntervalMs: z.number().positive().optional(),
});

View File

@@ -0,0 +1,337 @@
import {
type Counter,
type Histogram,
type Meter,
type MeterProvider,
metrics,
type Span,
type Tracer,
type TracerProvider,
trace,
} from "@opentelemetry/api";
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { AUDIO_TRANSPORT_CODES } from "../error-codes/index.js";
import { Transport } from "../transport/transport.js";
import { TransportScheduler } from "./scheduler.js";
class TestSpan implements Span {
readonly name: string;
private _ended = false;
constructor(name: string) {
this.name = name;
}
spanContext() {
return {
traceId: "",
spanId: "",
traceFlags: 0,
isRemote: false,
};
}
setAttribute() {
return this;
}
setAttributes() {
return this;
}
addEvent() {
return this;
}
addLink() {
return this;
}
addLinks() {
return this;
}
setStatus() {
return this;
}
updateName() {
return this;
}
end() {
this._ended = true;
}
isRecording() {
return !this._ended;
}
recordException() {
return;
}
}
class TestTracer implements Tracer {
spans: TestSpan[] = [];
startSpan(name: string): Span {
const span = new TestSpan(name);
this.spans.push(span);
return span;
}
startActiveSpan<F extends (span: Span) => unknown>(
name: string,
fnOrOptions: F | object,
fn?: F
): ReturnType<F> {
const callback =
typeof fnOrOptions === "function" ? fnOrOptions : (fn as F);
const span = this.startSpan(name);
return callback(span) as ReturnType<F>;
}
}
class TestTracerProvider implements TracerProvider {
tracers = new Map<string, TestTracer>();
getTracer(name: string): Tracer {
if (!this.tracers.has(name)) {
this.tracers.set(name, new TestTracer());
}
return this.tracers.get(name)!;
}
}
class TestCounter implements Counter {
readonly name: string;
adds = 0;
constructor(name: string) {
this.name = name;
}
add() {
this.adds++;
}
}
class TestHistogram implements Histogram {
readonly name: string;
records: number[] = [];
constructor(name: string) {
this.name = name;
}
record(value: number) {
this.records.push(value);
}
}
class TestMeter implements Meter {
counters = new Map<string, TestCounter>();
histograms = new Map<string, TestHistogram>();
createCounter(name: string): Counter {
if (!this.counters.has(name)) {
this.counters.set(name, new TestCounter(name));
}
return this.counters.get(name)!;
}
createHistogram(name: string): Histogram {
if (!this.histograms.has(name)) {
this.histograms.set(name, new TestHistogram(name));
}
return this.histograms.get(name)!;
}
createGauge(): never {
throw new Error("not implemented");
}
createUpDownCounter(): never {
throw new Error("not implemented");
}
createObservableGauge(): never {
throw new Error("not implemented");
}
createObservableCounter(): never {
throw new Error("not implemented");
}
createObservableUpDownCounter(): never {
throw new Error("not implemented");
}
addBatchObservableCallback(): void {
return;
}
removeBatchObservableCallback(): void {
return;
}
}
class TestMeterProvider implements MeterProvider {
meters = new Map<string, TestMeter>();
getMeter(name: string): Meter {
if (!this.meters.has(name)) {
this.meters.set(name, new TestMeter());
}
return this.meters.get(name)!;
}
}
describe("TransportScheduler", () => {
it("callbacks receive sample-accurate times rather than coarse poll times", {
timeout: 5000,
}, async () => {
const clock = () => 0;
const tr = new Transport({ clock });
const cb = vi.fn();
tr.scheduleOnce(cb, 192); // tick 192 = 0.5s at 120bpm
tr.start();
const scheduler = new TransportScheduler(tr, clock, {
pollIntervalMs: 5,
});
scheduler.start();
// Allow one poll to fire
await new Promise((resolve) => setTimeout(resolve, 20));
scheduler.stop();
// The callback should have been called with a time close to 0.5,
// not with the coarse poll time (which would be ~0.02).
if (cb.mock.calls.length > 0) {
const time = cb.mock.calls[0]![0] as number;
expect(time).toBeCloseTo(0.5, 1);
}
});
it("operating before start throws AUDIO_TRANSPORT_SCHEDULER_NOT_STARTED", {
timeout: 5000,
}, () => {
const clock = () => 0;
const tr = new Transport({ clock });
const scheduler = new TransportScheduler(tr, clock);
expect(() => scheduler.stop()).toThrow(
expect.objectContaining({
code: AUDIO_TRANSPORT_CODES.SCHEDULER_NOT_STARTED,
})
);
});
it("pre-aborted signal prevents the lookahead loop from starting", {
timeout: 5000,
}, () => {
const clock = () => 0;
const tr = new Transport({ clock });
const scheduler = new TransportScheduler(tr, clock);
const ac = new AbortController();
ac.abort("reason");
expect(() => scheduler.start({ signal: ac.signal })).toThrow(
expect.objectContaining({
code: AUDIO_TRANSPORT_CODES.SCHEDULER_NOT_STARTED,
})
);
});
it("mid-run abort halts the loop at the next poll boundary", {
timeout: 5000,
}, async () => {
const clock = () => 0;
const tr = new Transport({ clock });
const scheduler = new TransportScheduler(tr, clock, {
pollIntervalMs: 10,
});
const ac = new AbortController();
scheduler.start({ signal: ac.signal });
expect(scheduler.running).toBe(true);
ac.abort();
// Give the abort handler a tick to run
await new Promise((resolve) => setTimeout(resolve, 20));
expect(scheduler.running).toBe(false);
});
it("scheduled poll is a no-op after stop", { timeout: 5000 }, () => {
const timeouts: Array<() => void> = [];
const spySetTimeout = vi
.spyOn(globalThis, "setTimeout")
.mockImplementation((fn: unknown) => {
timeouts.push(fn as () => void);
return 1 as unknown as ReturnType<typeof setTimeout>;
});
const spyClearTimeout = vi
.spyOn(globalThis, "clearTimeout")
.mockImplementation(() => {});
const clock = () => 0;
const tr = new Transport({ clock });
const processSpy = vi.spyOn(tr, "_processWindow");
const scheduler = new TransportScheduler(tr, clock, {
pollIntervalMs: 5,
});
scheduler.start();
expect(timeouts.length).toBeGreaterThan(0);
scheduler.stop();
timeouts[0]!();
expect(processSpy).not.toHaveBeenCalled();
spySetTimeout.mockRestore();
spyClearTimeout.mockRestore();
});
it("start is a no-op when already running", { timeout: 5000 }, () => {
const clock = () => 0;
const tr = new Transport({ clock });
const scheduler = new TransportScheduler(tr, clock);
scheduler.start();
expect(() => scheduler.start()).not.toThrow();
expect(scheduler.running).toBe(true);
scheduler.stop();
});
it("stop is a no-op when not running", { timeout: 5000 }, () => {
const clock = () => 0;
const tr = new Transport({ clock });
const scheduler = new TransportScheduler(tr, clock);
scheduler.start();
scheduler.stop();
expect(() => scheduler.stop()).not.toThrow();
expect(scheduler.running).toBe(false);
});
it("records a span and jitter histogram when an exporter is registered", {
timeout: 5000,
}, async () => {
const tracerProvider = new TestTracerProvider();
const meterProvider = new TestMeterProvider();
const origTracerProvider = trace.getTracerProvider();
const origMeterProvider = metrics.getMeterProvider();
trace.setGlobalTracerProvider(tracerProvider);
metrics.setGlobalMeterProvider(meterProvider);
try {
const startTime = Date.now() / 1000;
const clock = () => Date.now() / 1000 - startTime;
const tr = new Transport({ clock });
const scheduler = new TransportScheduler(tr, clock, {
pollIntervalMs: 5,
});
tr.scheduleOnce(() => {}, "0:0:0");
tr.start();
scheduler.start();
await new Promise((resolve) => setTimeout(resolve, 100));
scheduler.stop();
const tracer = tracerProvider.getTracer(
"@suaveplan/audio-transport"
) as TestTracer;
const tickSpans = tracer.spans.filter(
(s) => s.name === "transport.tick"
);
expect(tickSpans.length).toBeGreaterThan(0);
const meter = meterProvider.getMeter(
"@suaveplan/audio-transport"
) as TestMeter;
const histogram = meter.histograms.get(
"audio_transport_tick_jitter_ms"
);
expect(histogram).toBeDefined();
} finally {
trace.setGlobalTracerProvider(origTracerProvider);
metrics.setGlobalMeterProvider(origMeterProvider);
}
});
});

View File

@@ -0,0 +1,111 @@
import { trace } from "@opentelemetry/api";
import type { Scheduler, SchedulerHandle } from "@suaveplan/timing";
import { createScheduler } from "@suaveplan/timing";
import type { Clock } from "@suaveplan/types";
import { AUDIO_TRANSPORT_CODES, TransportError } from "../error-codes/index.js";
import type { Transport } from "../transport/transport.js";
import type { TransportSchedulerLike } from "./scheduler.types.js";
function getTracer() {
return trace.getTracer("@suaveplan/audio-transport");
}
/**
* A lookahead scheduler that polls on a coarse timer and fires
* scheduled callbacks with sample-accurate musical times.
*/
export class TransportScheduler implements TransportSchedulerLike {
private readonly _transport: Transport;
private readonly _clock: Clock;
private readonly _pollIntervalMs: number;
private readonly _scheduler: Scheduler;
private _running = false;
private _started = false;
private _lastPoll = 0;
private _pollHandle?: SchedulerHandle | undefined;
constructor(
transport: Transport,
clock: Clock,
options?: { pollIntervalMs?: number }
) {
this._transport = transport;
this._clock = clock;
this._pollIntervalMs = options?.pollIntervalMs ?? 10;
this._scheduler = createScheduler();
}
/**
* Start the lookahead loop.
*
* @throws {TransportError} `AUDIO_TRANSPORT_SCHEDULER_NOT_STARTED`
* when the signal is already aborted.
*/
start(options?: { signal?: AbortSignal }): void {
if (this._running) return;
const signal = options?.signal;
if (signal?.aborted) {
throw new TransportError(
AUDIO_TRANSPORT_CODES.SCHEDULER_NOT_STARTED,
"audio_transport.scheduler.pre_aborted",
{ context: { reason: signal.reason } }
);
}
this._running = true;
this._started = true;
this._lastPoll = this._clock();
if (signal) {
signal.addEventListener(
"abort",
() => {
this.stop();
},
{ once: true }
);
}
this._scheduleNextPoll();
}
private _scheduleNextPoll(): void {
if (!this._running) return;
this._pollHandle = this._scheduler.schedule(() => {
this._tick();
this._scheduleNextPoll();
}, this._pollIntervalMs);
}
private _tick(): void {
if (!this._running) return;
const span = getTracer().startSpan("transport.tick");
try {
const now = this._clock();
const start = this._lastPoll;
const end = now;
this._lastPoll = end;
this._transport._processWindow(start, end);
} finally {
span.end();
}
}
/** Stop the lookahead loop. */
stop(): void {
if (!this._started) {
throw new TransportError(
AUDIO_TRANSPORT_CODES.SCHEDULER_NOT_STARTED,
"audio_transport.scheduler.not_started"
);
}
if (!this._running) return;
this._running = false;
this._pollHandle?.cancel();
this._pollHandle = undefined;
}
/** Whether the loop is currently running. */
get running(): boolean {
return this._running;
}
}

View File

@@ -0,0 +1,31 @@
import type { Clock } from "@suaveplan/types";
import type { TransportLike } from "../transport/transport.types.js";
/**
* Options for constructing a {@link TransportScheduler}.
*/
export interface TransportSchedulerOptions {
readonly transport: TransportLike;
readonly clock: Clock;
readonly pollIntervalMs?: number;
}
/**
* A lookahead scheduler that polls on a coarse timer and fires
* scheduled callbacks with sample-accurate musical times.
*/
export interface TransportSchedulerLike {
/**
* Start the lookahead loop.
*
* @throws {import("../error-codes/index.js").TransportError} `AUDIO_TRANSPORT_SCHEDULER_NOT_STARTED`
* when the signal is already aborted.
*/
start(options?: { signal?: AbortSignal }): void;
/** Stop the lookahead loop. */
stop(): void;
/** Whether the loop is currently running. */
readonly running: boolean;
}

View File

@@ -0,0 +1,8 @@
export { Signal } from "./signal.js";
export { SignalOptionsSchema } from "./signal.schemas.js";
export type {
AutomationPoint,
CurveType,
SignalLike,
SignalOptions,
} from "./signal.types.js";

View File

@@ -0,0 +1,63 @@
# Signal
The `Signal` class implements a piecewise automation curve for scalar parameters such as BPM. It stores automation points and evaluates the curve at any audio time, supporting step, linear, and exponential ramps.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Musical performances often require smooth tempo changes. `Signal` provides the mathematical foundation for these changes by scheduling automation points and interpolating between them. The `TickSource` reads the BPM curve through this `Signal` so that tempo ramps produce a continuously varying tick rate.
## Features
- `setValueAtTime` for step changes
- `linearRampToValueAtTime` for linear interpolation
- `exponentialRampToValueAtTime` for exponential curves
- `getValueAtTime` for curve evaluation at arbitrary times
- `cancelScheduledValues` for clearing future points
- Rejects non-positive and non-finite values with `AUDIO_TRANSPORT_INVALID_BPM`
## Usage: Basic
```ts
import { Signal } from "@suaveplan/audio-transport";
const bpm = new Signal({ initialValue: 120 });
bpm.linearRampToValueAtTime(240, 1);
console.log(bpm.getValueAtTime(0.5)); // 180
```
## Usage: Advanced
```ts
const bpm = new Signal({ initialValue: 100 });
bpm.setValueAtTime(120, 0.5);
bpm.exponentialRampToValueAtTime(240, 1.5);
console.log(bpm.getValueAtTime(1)); // ~169.7
```
## API Reference
### Constructor
`new Signal(options?)`
### Properties
- `initialValue` — value before any points
- `value` — convenience getter returning value at time 0
- `points` — readonly array of automation points
### Methods
- `setValueAtTime(value, time)` — step change
- `linearRampToValueAtTime(value, time)` — linear ramp
- `exponentialRampToValueAtTime(value, time)` — exponential ramp
- `getValueAtTime(time)` — evaluate curve
- `cancelScheduledValues(time)` — remove points at or after time
## Implementation Notes
Points are stored sorted by time. Exponential ramps require strictly positive start and target values; otherwise `AUDIO_TRANSPORT_INVALID_BPM` is thrown. The curve evaluation handles interpolation before the first point, between points, and after the last point.

View File

@@ -0,0 +1,27 @@
# SignalOptionsSchema
Zod boundary schema for `Signal` construction options.
## Purpose
Validates the options passed when constructing a `Signal`.
## Features
- Optional `initialValue` as positive number
## Usage
```ts
import { SignalOptionsSchema } from "@suaveplan/audio-transport";
const parsed = SignalOptionsSchema.safeParse({ initialValue: 130 });
```
## API Reference
- `SignalOptionsSchema` — `z.object({ initialValue })`
## Implementation Notes
The schema is minimal because `Signal` only needs an initial value. All other behavior is controlled via method calls after construction.

View File

@@ -0,0 +1,10 @@
import z from "zod";
/**
* Zod schema for {@link Signal} construction options.
*
* @see {@link SignalOptions}
*/
export const SignalOptionsSchema = z.object({
initialValue: z.number().positive().optional(),
});

View File

@@ -0,0 +1,170 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUDIO_TRANSPORT_CODES } from "../error-codes/index.js";
import { Signal } from "./signal.js";
describe("Signal", () => {
it("returns initialValue when no points are scheduled", {
timeout: 5000,
}, () => {
const s = new Signal({ initialValue: 100 });
expect(s.getValueAtTime(0)).toBe(100);
expect(s.getValueAtTime(10)).toBe(100);
});
it("getValueAtTime interpolates linearly between scheduled points", {
timeout: 5000,
}, () => {
const s = new Signal({ initialValue: 100 });
s.linearRampToValueAtTime(200, 1);
expect(s.getValueAtTime(0)).toBe(100);
expect(s.getValueAtTime(0.5)).toBe(150);
expect(s.getValueAtTime(1)).toBe(200);
});
it("linear ramp varies the tick rate continuously across the window", {
timeout: 5000,
}, () => {
const s = new Signal({ initialValue: 120 });
s.linearRampToValueAtTime(240, 1);
const v0 = s.getValueAtTime(0);
const vMid = s.getValueAtTime(0.5);
const v1 = s.getValueAtTime(1);
expect(v0).toBe(120);
expect(vMid).toBe(180);
expect(v1).toBe(240);
});
it("rejects non-positive BPM with AUDIO_TRANSPORT_INVALID_BPM", {
timeout: 5000,
}, () => {
const s = new Signal();
expect(() => s.setValueAtTime(0, 0)).toThrow(
expect.objectContaining({ code: AUDIO_TRANSPORT_CODES.INVALID_BPM })
);
expect(() => s.setValueAtTime(-1, 0)).toThrow(
expect.objectContaining({ code: AUDIO_TRANSPORT_CODES.INVALID_BPM })
);
});
it("exponential ramp through zero is rejected", { timeout: 5000 }, () => {
const s = new Signal({ initialValue: 120 });
expect(() => s.exponentialRampToValueAtTime(0, 1)).toThrow(
expect.objectContaining({ code: AUDIO_TRANSPORT_CODES.INVALID_BPM })
);
});
it("exponential ramp from non-positive start is rejected", {
timeout: 5000,
}, () => {
const s = new Signal({ initialValue: 0 });
expect(() => s.exponentialRampToValueAtTime(120, 1)).toThrow(
expect.objectContaining({ code: AUDIO_TRANSPORT_CODES.INVALID_BPM })
);
});
it("cancelScheduledValues removes points at or after the given time", {
timeout: 5000,
}, () => {
const s = new Signal({ initialValue: 100 });
s.setValueAtTime(200, 1);
s.setValueAtTime(300, 2);
s.cancelScheduledValues(1.5);
expect(s.getValueAtTime(2)).toBe(200);
});
it("holds last point value after final point", { timeout: 5000 }, () => {
const s = new Signal({ initialValue: 100 });
s.setValueAtTime(200, 1);
expect(s.getValueAtTime(5)).toBe(200);
});
it("setValueAtTime creates a step", { timeout: 5000 }, () => {
const s = new Signal({ initialValue: 100 });
s.setValueAtTime(200, 1);
s.setValueAtTime(300, 2);
expect(s.getValueAtTime(0.5)).toBe(100);
expect(s.getValueAtTime(1)).toBe(200);
expect(s.getValueAtTime(1.5)).toBe(200);
expect(s.getValueAtTime(2)).toBe(300);
});
it("exponential ramp computes correctly", { timeout: 5000 }, () => {
const s = new Signal({ initialValue: 100 });
s.exponentialRampToValueAtTime(400, 2);
expect(s.getValueAtTime(0)).toBe(100);
expect(s.getValueAtTime(1)).toBe(200);
expect(s.getValueAtTime(2)).toBe(400);
});
it("exponential ramp with prior set point uses that point as start", {
timeout: 5000,
}, () => {
const s = new Signal({ initialValue: 100 });
s.setValueAtTime(50, 0.5);
s.exponentialRampToValueAtTime(200, 1.5);
expect(s.getValueAtTime(0.5)).toBe(50);
expect(s.getValueAtTime(1)).toBeCloseTo(100, 5);
expect(s.getValueAtTime(1.5)).toBe(200);
});
it("holds initialValue before first point when first point is a set", {
timeout: 5000,
}, () => {
const s = new Signal({ initialValue: 100 });
s.setValueAtTime(200, 1);
expect(s.getValueAtTime(0.5)).toBe(100);
});
it("last ramp holds its final value when queried beyond it", {
timeout: 5000,
}, () => {
const s = new Signal({ initialValue: 100 });
s.linearRampToValueAtTime(200, 1);
expect(s.getValueAtTime(2)).toBe(200);
});
it("rejects non-finite values for setValueAtTime", {
timeout: 5000,
}, () => {
const s = new Signal();
expect(() => s.setValueAtTime(Number.NaN, 0)).toThrow(
expect.objectContaining({ code: AUDIO_TRANSPORT_CODES.INVALID_BPM })
);
expect(() => s.setValueAtTime(Number.POSITIVE_INFINITY, 0)).toThrow(
expect.objectContaining({ code: AUDIO_TRANSPORT_CODES.INVALID_BPM })
);
});
it("rejects invalid values for linearRampToValueAtTime", {
timeout: 5000,
}, () => {
const s = new Signal();
expect(() => s.linearRampToValueAtTime(0, 0)).toThrow(
expect.objectContaining({ code: AUDIO_TRANSPORT_CODES.INVALID_BPM })
);
});
it("rejects non-finite values for exponentialRampToValueAtTime", {
timeout: 5000,
}, () => {
const s = new Signal({ initialValue: 120 });
expect(() => s.exponentialRampToValueAtTime(Number.NaN, 0)).toThrow(
expect.objectContaining({ code: AUDIO_TRANSPORT_CODES.INVALID_BPM })
);
expect(() =>
s.exponentialRampToValueAtTime(Number.POSITIVE_INFINITY, 0)
).toThrow(
expect.objectContaining({ code: AUDIO_TRANSPORT_CODES.INVALID_BPM })
);
});
it("exponential ramp inserted before existing point uses initialValue as start", {
timeout: 5000,
}, () => {
const s = new Signal({ initialValue: 100 });
s.exponentialRampToValueAtTime(200, 2);
s.exponentialRampToValueAtTime(400, 1);
expect(s.getValueAtTime(1)).toBe(400);
expect(s.getValueAtTime(0.5)).toBeCloseTo(200, 5);
});
});

View File

@@ -0,0 +1,178 @@
import { AUDIO_TRANSPORT_CODES, TransportError } from "../error-codes/index.js";
import type {
AutomationPoint,
CurveType,
SignalLike,
SignalOptions,
} from "./signal.types.js";
function assertFinitePositive(value: number, label: string): void {
if (!Number.isFinite(value) || value <= 0) {
throw new TransportError(
AUDIO_TRANSPORT_CODES.INVALID_BPM,
"audio_transport.signal.invalid_bpm",
{ context: { [label]: value } }
);
}
}
function interpolate(
t0: number,
v0: number,
t1: number,
v1: number,
time: number,
type: CurveType
): number {
const progress = (time - t0) / (t1 - t0);
if (type === "linear") {
return v0 + (v1 - v0) * progress;
}
const ratio = v1 / v0;
return v0 * ratio ** progress;
}
/**
* A piecewise automation curve for scalar parameters such as BPM.
*
* Supports step (`set`), linear, and exponential ramps.
*/
export class Signal implements SignalLike {
readonly initialValue: number;
private _points: AutomationPoint[] = [];
constructor(options?: SignalOptions) {
this.initialValue = options?.initialValue ?? 120;
}
/** Convenience getter returning the value at time 0. */
get value(): number {
return this.getValueAtTime(0);
}
/** Current list of scheduled automation points, sorted by time. */
get points(): readonly AutomationPoint[] {
return this._points;
}
private _addPoint(point: AutomationPoint): void {
if (point.type === "exponential") {
let prev: AutomationPoint | undefined;
for (let i = this._points.length - 1; i >= 0; i--) {
if (this._points[i]!.time <= point.time) {
prev = this._points[i];
break;
}
}
const startValue = prev?.value ?? this.initialValue;
assertFinitePositive(startValue, "startValue");
assertFinitePositive(point.value, "targetValue");
}
const next = [...this._points, point];
next.sort((a, b) => a.time - b.time);
this._points = next;
}
/**
* Schedule a step change to `value` at `time`.
*
* @throws {TransportError} `AUDIO_TRANSPORT_INVALID_BPM` when `value` is
* non-positive or non-finite.
*/
setValueAtTime(value: number, time: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new TransportError(
AUDIO_TRANSPORT_CODES.INVALID_BPM,
"audio_transport.signal.invalid_bpm",
{ context: { value } }
);
}
this._addPoint({ time, value, type: "set" });
}
/**
* Schedule a linear ramp from the previous point to `value` at `time`.
*
* @throws {TransportError} `AUDIO_TRANSPORT_INVALID_BPM` when `value` is
* non-positive or non-finite.
*/
linearRampToValueAtTime(value: number, time: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new TransportError(
AUDIO_TRANSPORT_CODES.INVALID_BPM,
"audio_transport.signal.invalid_bpm",
{ context: { value } }
);
}
this._addPoint({ time, value, type: "linear" });
}
/**
* Schedule an exponential ramp from the previous point to `value` at `time`.
*
* Both the start and target values must be strictly positive.
*
* @throws {TransportError} `AUDIO_TRANSPORT_INVALID_BPM` when `value` is
* non-positive or non-finite, or when the start value is non-positive.
*/
exponentialRampToValueAtTime(value: number, time: number): void {
assertFinitePositive(value, "targetValue");
this._addPoint({ time, value, type: "exponential" });
}
/**
* Evaluate the curve at an arbitrary audio time.
*/
getValueAtTime(time: number): number {
if (this._points.length === 0) {
return this.initialValue;
}
const first = this._points[0]!;
// Before the first point — curve type is determined by the first point
if (time < first.time) {
if (first.type === "set") {
return this.initialValue;
}
return interpolate(
0,
this.initialValue,
first.time,
first.value,
time,
first.type
);
}
// Find the rightmost point at or before `time`
const idx = this._points.findIndex((p) => p.time > time);
const curr =
idx === -1
? this._points[this._points.length - 1]!
: this._points[idx - 1]!;
const next = idx === -1 ? undefined : this._points[idx];
if (time === curr.time) {
return curr.value;
}
if (next === undefined || next.type === "set") {
return curr.value;
}
return interpolate(
curr.time,
curr.value,
next.time,
next.value,
time,
next.type
);
}
/**
* Remove all automation points at or after `time`.
*/
cancelScheduledValues(time: number): void {
this._points = this._points.filter((p) => p.time < time);
}
}

View File

@@ -0,0 +1,63 @@
/**
* Automation-point interpolation type.
*/
export type CurveType = "set" | "linear" | "exponential";
/**
* A single automation point on a {@link Signal} curve.
*/
export interface AutomationPoint {
readonly time: number;
readonly value: number;
readonly type: CurveType;
}
/**
* Options used when constructing a {@link Signal}.
*/
export interface SignalOptions {
readonly initialValue?: number;
}
/**
* A piecewise automation curve for scalar parameters such as BPM.
*
* Supports step (`set`), linear, and exponential ramps.
*/
export interface SignalLike {
/** Current list of scheduled automation points, sorted by time. */
readonly points: readonly AutomationPoint[];
/** The held value before any points are scheduled. */
readonly initialValue: number;
/** Convenience getter returning the value at time 0. */
readonly value: number;
/**
* Schedule a step change to `value` at `time`.
*/
setValueAtTime(value: number, time: number): void;
/**
* Schedule a linear ramp from the previous point to `value` at `time`.
*/
linearRampToValueAtTime(value: number, time: number): void;
/**
* Schedule an exponential ramp from the previous point to `value` at `time`.
*
* Both the start and target values must be strictly positive.
*/
exponentialRampToValueAtTime(value: number, time: number): void;
/**
* Evaluate the curve at an arbitrary audio time.
*/
getValueAtTime(time: number): number;
/**
* Remove all automation points at or after `time`.
*/
cancelScheduledValues(time: number): void;
}

View File

@@ -0,0 +1,6 @@
export { TickSource } from "./tick-source.js";
export { TickSourceOptionsSchema } from "./tick-source.schemas.js";
export type {
TickSourceLike,
TickSourceOptions,
} from "./tick-source.types.js";

View File

@@ -0,0 +1,67 @@
# TickSource
The `TickSource` class converts between audio time (seconds) and musical ticks, integrating tempo curves for sample-accurate scheduling.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TickSource` is the mathematical engine behind `Transport`. It translates wall-clock seconds into musical ticks and back, accounting for tempo ramps and anchors. The integration is exact for constant, linear, and exponential tempo segments, ensuring that `getTicksAtTime` and `getTimeOfTick` remain mutual inverses within floating-point tolerance.
## Features
- Exact inverse conversions under constant tempo
- Integration of linear and exponential tempo ramps
- `forEachTickBetween` for sample-accurate tick iteration
- `setTicksAtTime` for anchoring the counter
- Start, stop, and pause lifecycle
- No wall-clock reads — all methods take explicit times
## Usage: Basic
```ts
import { TickSource, Signal } from "@suaveplan/audio-transport";
const ts = new TickSource({ ppq: 192, bpm: new Signal({ initialValue: 120 }) });
ts.start(0);
const ticks = ts.getTicksAtTime(1); // 384
```
## Usage: Advanced
```ts
const bpm = new Signal({ initialValue: 120 });
bpm.linearRampToValueAtTime(240, 1);
const ts = new TickSource({ ppq: 192, bpm });
ts.start(0);
ts.forEachTickBetween(0, 1, (time, tick) => {
console.log("tick", tick, "at", time);
});
```
## API Reference
### Constructor
`new TickSource(options?)`
### Properties
- `ppq` — pulses per quarter note
- `bpm` — `SignalLike` tempo curve
### Methods
- `getTicksAtTime(time)` — ticks at audio time
- `getTimeOfTick(tick, before?)` — audio time of tick
- `setTicksAtTime(ticks, time)` — anchor counter
- `start(time, offset?)` — begin counting
- `stop(time)` — stop counting
- `pause(time)` — pause counting
- `forEachTickBetween(start, end, callback)` — iterate ticks
## Implementation Notes
Tick integration uses closed-form integrals for constant, linear, and exponential tempo segments. `getTimeOfTick` uses binary search over the integrated curve. The `forEachTickBetween` method guarantees no duplicate or skipped ticks across consecutive windows. The source maintains an internal `_running` flag and `_offsetTicks` value so that start, stop, and pause operations preserve the accumulated tick count correctly.

View File

@@ -0,0 +1,28 @@
# TickSourceOptionsSchema
Zod boundary schema for `TickSource` construction options.
## Purpose
Validates the options passed when constructing a `TickSource`.
## Features
- Optional `ppq` as positive integer
- Optional `bpm` as `Signal` instance
## Usage
```ts
import { TickSourceOptionsSchema } from "@suaveplan/audio-transport";
const parsed = TickSourceOptionsSchema.safeParse({ ppq: 96 });
```
## API Reference
- `TickSourceOptionsSchema` — `z.object({ ppq, bpm })`
## Implementation Notes
`bpm` uses `z.instanceof(Signal)` because the tick source needs the concrete `Signal` class for segment building.

View File

@@ -0,0 +1,12 @@
import z from "zod";
import { Signal } from "../signal/signal.js";
/**
* Zod schema for {@link TickSource} construction options.
*
* @see {@link TickSourceOptions}
*/
export const TickSourceOptionsSchema = z.object({
ppq: z.number().positive().int().optional(),
bpm: z.instanceof(Signal).optional(),
});

View File

@@ -0,0 +1,162 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Signal } from "../signal/signal.js";
import { TickSource } from "./tick-source.js";
describe("TickSource", () => {
it("getTicksAtTime and getTimeOfTick are mutual inverses at constant tempo", {
timeout: 5000,
}, () => {
const ts = new TickSource({
ppq: 192,
bpm: new Signal({ initialValue: 120 }),
});
ts.start(0);
const tick = 960;
const time = ts.getTimeOfTick(tick);
expect(ts.getTicksAtTime(time)).toBeCloseTo(tick, 5);
});
it("setTicksAtTime anchors the tick counter", { timeout: 5000 }, () => {
const ts = new TickSource({
ppq: 192,
bpm: new Signal({ initialValue: 120 }),
});
ts.start(0);
ts.setTicksAtTime(960, 1);
expect(ts.getTicksAtTime(1)).toBeCloseTo(960, 5);
});
it("forEachTickBetween visits every tick in range exactly once with sample-accurate times", {
timeout: 5000,
}, () => {
const ts = new TickSource({
ppq: 192,
bpm: new Signal({ initialValue: 120 }),
});
ts.start(0);
const calls: Array<{ time: number; ticks: number }> = [];
ts.forEachTickBetween(0, 1, (time, ticks) => {
calls.push({ time, ticks });
});
// 120 BPM, 192 PPQ => 384 ticks/second
expect(calls.length).toBe(384);
for (let i = 1; i < calls.length; i++) {
expect(calls[i]!.ticks).toBe(calls[i - 1]!.ticks + 1);
expect(calls[i]!.time).toBeGreaterThan(calls[i - 1]!.time);
}
});
it("consecutive windows neither skip nor duplicate ticks at the boundary", {
timeout: 5000,
}, () => {
const ts = new TickSource({
ppq: 192,
bpm: new Signal({ initialValue: 120 }),
});
ts.start(0);
const first: number[] = [];
const second: number[] = [];
ts.forEachTickBetween(0, 0.5, (_t, ticks) => first.push(ticks));
ts.forEachTickBetween(0.5, 1, (_t, ticks) => second.push(ticks));
expect(first[first.length - 1]! + 1).toBe(second[0]);
});
it("accounts for linear tempo ramps", { timeout: 5000 }, () => {
const bpm = new Signal({ initialValue: 120 });
bpm.linearRampToValueAtTime(240, 1);
const ts = new TickSource({ ppq: 192, bpm });
ts.start(0);
const times: number[] = [];
ts.forEachTickBetween(0, 1, (time) => {
times.push(time);
});
// Inter-tick spacing should decrease monotonically
for (let i = 1; i < times.length - 1; i++) {
const dtPrev = times[i]! - times[i - 1]!;
const dtNext = times[i + 1]! - times[i]!;
expect(dtNext).toBeLessThanOrEqual(dtPrev + 1e-9);
}
});
it("getTimeOfTick returns start time for tick at or below offset", {
timeout: 5000,
}, () => {
const ts = new TickSource({
ppq: 192,
bpm: new Signal({ initialValue: 120 }),
});
ts.start(0, 100);
expect(ts.getTimeOfTick(50)).toBe(0);
expect(ts.getTimeOfTick(100)).toBe(0);
});
it("stop preserves accumulated ticks", { timeout: 5000 }, () => {
const ts = new TickSource({
ppq: 192,
bpm: new Signal({ initialValue: 120 }),
});
ts.start(0);
ts.stop(1);
expect(ts.getTicksAtTime(2)).toBeCloseTo(384, 5);
});
it("start is idempotent when already running", { timeout: 5000 }, () => {
const ts = new TickSource({
ppq: 192,
bpm: new Signal({ initialValue: 120 }),
});
ts.start(0);
ts.start(5);
expect(ts.getTicksAtTime(0)).toBe(0);
});
it("pause preserves accumulated ticks", { timeout: 5000 }, () => {
const ts = new TickSource({
ppq: 192,
bpm: new Signal({ initialValue: 120 }),
});
ts.start(0);
ts.pause(0.5);
expect(ts.getTicksAtTime(1)).toBeCloseTo(192, 5);
});
it("exposes ppq and bpm getters", { timeout: 5000 }, () => {
const bpm = new Signal({ initialValue: 130 });
const ts = new TickSource({ ppq: 96, bpm });
expect(ts.ppq).toBe(96);
expect(ts.bpm).toBe(bpm);
});
it("applies defaults when options are omitted", { timeout: 5000 }, () => {
const ts = new TickSource();
expect(ts.ppq).toBe(192);
expect(ts.bpm.initialValue).toBe(120);
});
it("stop is a no-op when not running", { timeout: 5000 }, () => {
const ts = new TickSource({
ppq: 192,
bpm: new Signal({ initialValue: 120 }),
});
expect(() => ts.stop(0)).not.toThrow();
});
it("pause is a no-op when not running", { timeout: 5000 }, () => {
const ts = new TickSource({
ppq: 192,
bpm: new Signal({ initialValue: 120 }),
});
expect(() => ts.pause(0)).not.toThrow();
});
it("accounts for exponential tempo ramps", { timeout: 5000 }, () => {
const bpm = new Signal({ initialValue: 120 });
bpm.exponentialRampToValueAtTime(240, 1);
const ts = new TickSource({ ppq: 192, bpm });
ts.start(0);
const ticksAt1 = ts.getTicksAtTime(1);
// At constant 120 BPM we'd get 384 ticks in 1 second.
// With exponential ramp 120→240, we should get more.
expect(ticksAt1).toBeGreaterThan(384);
});
});

View File

@@ -0,0 +1,176 @@
import { Signal } from "../signal/signal.js";
import type { SignalLike } from "../signal/signal.types.js";
import type { TickSourceLike, TickSourceOptions } from "./tick-source.types.js";
interface Segment {
readonly t0: number;
readonly t1: number;
readonly b0: number;
readonly b1: number;
readonly type: "set" | "linear" | "exponential";
}
function buildSegments(signal: SignalLike): Segment[] {
const points = signal.points;
const segs: Segment[] = [];
let lastValue = signal.initialValue;
let lastTime = 0;
for (const p of points) {
segs.push({
t0: lastTime,
t1: p.time,
b0: lastValue,
b1: p.value,
type: p.type,
});
lastValue = p.value;
lastTime = p.time;
}
// trailing hold segment
segs.push({
t0: lastTime,
t1: Number.POSITIVE_INFINITY,
b0: lastValue,
b1: lastValue,
type: "set",
});
return segs;
}
function integrateSegment(
seg: Segment,
start: number,
end: number,
ppq: number
): number {
const lo = Math.max(start, seg.t0);
const hi = Math.min(end, seg.t1);
if (lo >= hi) return 0;
const dt = hi - lo;
if (seg.type === "set" || seg.b0 === seg.b1) {
return (seg.b0 / 60) * ppq * dt;
}
if (seg.type === "linear") {
// b(t) = b0 + (b1-b0)*(t-t0)/(t1-t0)
const slope = (seg.b1 - seg.b0) / (seg.t1 - seg.t0);
const integralAt = (t: number) =>
seg.b0 * t + slope * (0.5 * t * t - seg.t0 * t);
return (ppq / 60) * (integralAt(hi) - integralAt(lo));
}
// exponential: b(t) = b0 * (b1/b0)^((t-t0)/(t1-t0))
const ratio = seg.b1 / seg.b0;
const denom = seg.t1 - seg.t0;
const k = Math.log(ratio) / denom;
const integralAt = (t: number) => (seg.b0 / k) * Math.exp(k * (t - seg.t0));
return (ppq / 60) * (integralAt(hi) - integralAt(lo));
}
/**
* Converts between audio time (seconds) and musical ticks,
* integrating tempo curves for sample-accurate scheduling.
*/
export class TickSource implements TickSourceLike {
readonly ppq: number;
readonly bpm: SignalLike;
private _running = false;
private _startTime = 0;
private _offsetTicks = 0;
constructor(options?: TickSourceOptions) {
this.ppq = options?.ppq ?? 192;
this.bpm = options?.bpm ?? new Signal({ initialValue: 120 });
}
private _integralFromStart(time: number): number {
const segs = buildSegments(this.bpm);
let ticks = 0;
for (const seg of segs) {
ticks += integrateSegment(seg, this._startTime, time, this.ppq);
}
return ticks;
}
private _getTicksAtTimeImpl(time: number): number {
if (!this._running) {
return this._offsetTicks;
}
return this._offsetTicks + this._integralFromStart(time);
}
private _getTimeOfTickImpl(tick: number, before?: number): number {
const target = tick - this._offsetTicks;
if (target <= 0) return this._startTime;
let lo = this._startTime;
let hi =
before ??
this._startTime +
60 * (target / (this.bpm.initialValue * this.ppq)) * 2 +
10;
for (let iter = 0; iter < 64; iter++) {
const mid = (lo + hi) / 2;
const midTicks = this._integralFromStart(mid);
if (midTicks < target) {
lo = mid;
} else {
hi = mid;
}
if (hi - lo < 1e-9) break;
}
return lo;
}
getTicksAtTime(time: number): number {
return this._getTicksAtTimeImpl(time);
}
getTimeOfTick(tick: number, before?: number): number {
return this._getTimeOfTickImpl(tick, before);
}
setTicksAtTime(ticks: number, time: number): void {
const current = this._getTicksAtTimeImpl(time);
this._offsetTicks += ticks - current;
}
start(time: number, offset = 0): void {
if (this._running) return;
this._startTime = time;
this._offsetTicks = offset;
this._running = true;
}
stop(time: number): void {
if (!this._running) return;
this._offsetTicks = this._getTicksAtTimeImpl(time);
this._running = false;
}
pause(time: number): void {
if (!this._running) return;
this._offsetTicks = this._getTicksAtTimeImpl(time);
this._running = false;
}
forEachTickBetween(
start: number,
end: number,
callback: (time: number, ticks: number) => void
): void {
const EPS = 1e-9;
const startTicks = Math.ceil(this._getTicksAtTimeImpl(start) - EPS);
const endTicks = Math.floor(this._getTicksAtTimeImpl(end) + EPS);
for (let t = startTicks; t < endTicks; t++) {
const time = this._getTimeOfTickImpl(t);
callback(time, t);
}
}
}

View File

@@ -0,0 +1,63 @@
import type { SignalLike } from "../signal/signal.types.js";
/**
* Options for constructing a {@link TickSource}.
*/
export interface TickSourceOptions {
readonly ppq?: number;
readonly bpm?: SignalLike;
}
/**
* Converts between audio time (seconds) and musical ticks,
* accounting for tempo automation curves.
*/
export interface TickSourceLike {
/** Pulses per quarter note. */
readonly ppq: number;
/** The BPM automation signal driving this source. */
readonly bpm: SignalLike;
/**
* Return the tick count at a given audio time.
*/
getTicksAtTime(time: number): number;
/**
* Return the audio time at which a given tick occurs.
*
* @param before - Optional hint: search backwards from this time.
*/
getTimeOfTick(tick: number, before?: number): number;
/**
* Anchor the tick counter so that `getTicksAtTime(time) === ticks`.
*/
setTicksAtTime(ticks: number, time: number): void;
/**
* Start the tick counter at `time` with an optional initial tick offset.
*/
start(time: number, offset?: number): void;
/**
* Stop the tick counter at `time`.
*/
stop(time: number): void;
/**
* Pause the tick counter at `time`.
*/
pause(time: number): void;
/**
* Invoke `callback(time, ticks)` for every integer tick whose audio time
* falls in the half-open interval `[start, end)`.
*/
forEachTickBetween(
start: number,
end: number,
callback: (time: number, ticks: number) => void
): void;
}

View File

@@ -0,0 +1,20 @@
export {
fromBarsBeatsSixteenths,
parseTimeToken,
secondsToTicks,
ticksToSeconds,
toBarsBeatsSixteenths,
} from "./time.js";
export {
BarsBeatsSixteenthsSchema,
TicksSchema,
TransportTimeSchema,
} from "./time.schemas.js";
export type {
BarsBeatsSixteenths,
SubdivisionToken,
Ticks,
TimeContext,
TimeParseResult,
TransportTime,
} from "./time.types.js";

View File

@@ -0,0 +1,59 @@
# Time Parsing and Conversion
This module parses musical time tokens and converts between ticks, seconds, and bars:beats:sixteenths notation.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Musical time is expressed in many forms: subdivision notation, measure counts, bar positions, and raw seconds. `parseTimeToken` turns all of these into a canonical `{ ticks, seconds }` representation. The conversion helpers then provide round-tripping between ticks, seconds, and human-readable bar positions.
## Features
- Subdivision notation (`"4n"`, `"8n"`, `"16n"`, `"8t"`, `"16t"`)
- Measure notation (`"1m"`)
- Bars:beats:sixteenths (`"2:1:0"`)
- Relative seconds (`"+1"`)
- Absolute measures (`"@4m"`)
- Raw numeric ticks
- Bidirectional conversion helpers
## Usage: Basic
```ts
import { parseTimeToken } from "@suaveplan/audio-transport";
const ctx = { ppq: 192, timeSignature: 4, bpm: 120 };
const result = parseTimeToken("4n", ctx);
console.log(result.ticks); // 192
console.log(result.seconds); // 0.5
```
## Usage: Advanced
```ts
import {
parseTimeToken,
toBarsBeatsSixteenths,
fromBarsBeatsSixteenths,
} from "@suaveplan/audio-transport";
const ctx = { ppq: 192, timeSignature: 4, bpm: 120 };
const ticks = fromBarsBeatsSixteenths("1:2:3", ctx);
const bbs = toBarsBeatsSixteenths(ticks, ctx);
console.log(bbs); // "1:2:3"
```
## API Reference
- `parseTimeToken(token, ctx)` → `{ ticks, seconds }`
- `ticksToSeconds(ticks, ctx)` → `number`
- `secondsToTicks(seconds, ctx)` → `number`
- `toBarsBeatsSixteenths(ticks, ctx)` → `string`
- `fromBarsBeatsSixteenths(token, ctx)` → `number`
## Implementation Notes
All conversions assume constant tempo. `parseTimeToken` throws `AUDIO_TRANSPORT_INVALID_TIME` for unrecognised tokens. Numeric tokens are treated as raw ticks and returned directly. The regex-based parsers are ordered from most specific to least specific so that subdivision tokens are tried before relative tokens, avoiding ambiguity. The `bars:beats:sixteenths` format uses zero-based indexing for all three components. `toBarsBeatsSixteenths` floors each component rather than rounding, which is the conventional behaviour for DAW-style position displays. `fromBarsBeatsSixteenths` validates the input format with a strict regex and throws `AUDIO_TRANSPORT_INVALID_TIME` for malformed strings.

View File

@@ -0,0 +1,36 @@
# Time Schemas
Zod boundary schemas for time-related types.
## Purpose
Provides validation schemas for `TransportTime`, `Ticks`, `BarsBeatsSixteenths`, `TimeContext`, and `TimeParseResult`.
## Features
- `TransportTimeSchema` — string or non-negative number
- `TicksSchema` — non-negative number
- `BarsBeatsSixteenthsSchema` — regex `"bars:beats:sixteenths"`
- `TimeContextSchema` — `{ ppq, timeSignature, bpm }`
- `TimeParseResultSchema` — `{ ticks, seconds }`
## Usage
```ts
import { TransportTimeSchema } from "@suaveplan/audio-transport";
TransportTimeSchema.parse("4n");
TransportTimeSchema.parse(192);
```
## API Reference
- `TransportTimeSchema` — `z.union([z.string().min(1), z.number().nonnegative()])`
- `TicksSchema` — `z.number().nonnegative()`
- `BarsBeatsSixteenthsSchema` — `z.string().regex(/^(-?\d+):(-?\d+):(-?\d+)$/)`
- `TimeContextSchema` — `z.object({ ppq, timeSignature, bpm })`
- `TimeParseResultSchema` — `z.object({ ticks, seconds })`
## Implementation Notes
`TransportTimeSchema` accepts both string tokens and raw numeric ticks so that callers can pass either form interchangeably.

View File

@@ -0,0 +1,33 @@
import z from "zod";
/**
* Zod schema for a musical-time token.
*
* Accepts subdivision, measure, bars:beats:sixteenths, relative,
* absolute, or raw numeric tick forms.
*/
export const TransportTimeSchema = z.union([
z.string().min(1),
z.number().nonnegative(),
]);
/** Zod schema for raw tick counts. */
export const TicksSchema = z.number().nonnegative();
/** Zod schema for bars:beats:sixteenths strings. */
export const BarsBeatsSixteenthsSchema = z
.string()
.regex(/^(-?\d+):(-?\d+):(-?\d+)$/);
/** Zod schema for {@link TimeContext}. */
export const TimeContextSchema = z.object({
ppq: z.number().positive().int(),
timeSignature: z.number().positive().int(),
bpm: z.number().positive(),
});
/** Zod schema for {@link TimeParseResult}. */
export const TimeParseResultSchema = z.object({
ticks: z.number().nonnegative(),
seconds: z.number().nonnegative(),
});

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUDIO_TRANSPORT_CODES } from "../error-codes/index.js";
import {
fromBarsBeatsSixteenths,
parseTimeToken,
secondsToTicks,
ticksToSeconds,
toBarsBeatsSixteenths,
} from "./time.js";
const ctx = { ppq: 192, timeSignature: 4, bpm: 120 };
describe("parseTimeToken", () => {
it("parses subdivision notation", { timeout: 5000 }, () => {
expect(parseTimeToken("4n", ctx).ticks).toBe(192);
expect(parseTimeToken("8n", ctx).ticks).toBe(96);
expect(parseTimeToken("16n", ctx).ticks).toBe(48);
});
it("parses triplet notation", { timeout: 5000 }, () => {
expect(parseTimeToken("8t", ctx).ticks).toBe(64); // 96 * 2/3
expect(parseTimeToken("16t", ctx).ticks).toBe(32); // 48 * 2/3
});
it("parses measure notation", { timeout: 5000 }, () => {
expect(parseTimeToken("1m", ctx).ticks).toBe(768); // 4 * 192
expect(parseTimeToken("2m", ctx).ticks).toBe(1536);
});
it("parses bars:beats:sixteenths notation", { timeout: 5000 }, () => {
expect(parseTimeToken("0:0:0", ctx).ticks).toBe(0);
expect(parseTimeToken("0:1:0", ctx).ticks).toBe(192);
expect(parseTimeToken("1:0:0", ctx).ticks).toBe(768);
expect(parseTimeToken("2:1:0", ctx).ticks).toBe(1536 + 192);
});
it("parses relative notation", { timeout: 5000 }, () => {
const result = parseTimeToken("+1", ctx);
expect(result.seconds).toBe(1);
expect(result.ticks).toBe(secondsToTicks(1, ctx));
});
it("parses absolute notation", { timeout: 5000 }, () => {
expect(parseTimeToken("@4m", ctx).ticks).toBe(4 * 768);
});
it("unrecognised time token throws AUDIO_TRANSPORT_INVALID_TIME", {
timeout: 5000,
}, () => {
expect(() => parseTimeToken("not-a-time", ctx)).toThrow(
expect.objectContaining({
code: AUDIO_TRANSPORT_CODES.INVALID_TIME,
})
);
});
it("parses a numeric token as raw ticks", { timeout: 5000 }, () => {
const result = parseTimeToken(192, ctx);
expect(result.ticks).toBe(192);
expect(result.seconds).toBe(ticksToSeconds(192, ctx));
});
});
describe("toBarsBeatsSixteenths", () => {
it("formats zero correctly", { timeout: 5000 }, () => {
expect(toBarsBeatsSixteenths(0, ctx)).toBe("0:0:0");
});
});
describe("ticksToSeconds / secondsToTicks", () => {
it("are mutual inverses at constant tempo", { timeout: 5000 }, () => {
const ticks = 384;
const seconds = ticksToSeconds(ticks, ctx);
expect(secondsToTicks(seconds, ctx)).toBeCloseTo(ticks, 10);
});
});
describe("fromBarsBeatsSixteenths", () => {
it("parses bars:beats:sixteenths correctly", { timeout: 5000 }, () => {
expect(fromBarsBeatsSixteenths("1:2:3", ctx)).toBe(768 + 384 + 144);
});
it("throws AUDIO_TRANSPORT_INVALID_TIME for malformed strings", {
timeout: 5000,
}, () => {
expect(() => fromBarsBeatsSixteenths("not-valid", ctx)).toThrow(
expect.objectContaining({
code: AUDIO_TRANSPORT_CODES.INVALID_TIME,
})
);
});
});
it("subdivision and bars:beats:sixteenths round-trip through ticks", {
timeout: 5000,
}, () => {
expect(parseTimeToken("4n", ctx).ticks).toBe(192);
expect(toBarsBeatsSixteenths(192, ctx)).toBe("0:1:0");
});

View File

@@ -0,0 +1,163 @@
import { AUDIO_TRANSPORT_CODES, TransportError } from "../error-codes/index.js";
import type { TimeContext, TimeParseResult } from "./time.types.js";
const SUBDIVISION_RE = /^(\d+)n$/;
const TRIPLET_RE = /^(\d+)t$/;
const MEASURE_RE = /^(\d+)m$/;
const BARS_BEATS_RE = /^(-?\d+):(-?\d+):(-?\d+)$/;
const RELATIVE_RE = /^\+([\d.]+)$/;
const ABSOLUTE_RE = /^@(\d+)m$/;
function subdivisionToTicks(token: string, ctx: TimeContext): number {
const m = SUBDIVISION_RE.exec(token);
if (!m) return Number.NaN;
const den = Number(m[1]);
return ctx.ppq * (4 / den);
}
function tripletToTicks(token: string, ctx: TimeContext): number {
const m = TRIPLET_RE.exec(token);
if (!m) return Number.NaN;
const den = Number(m[1]);
return ctx.ppq * (4 / den) * (2 / 3);
}
function measureToTicks(token: string, ctx: TimeContext): number {
const m = MEASURE_RE.exec(token);
if (!m) return Number.NaN;
return Number(m[1]) * ctx.timeSignature * ctx.ppq;
}
function barsBeatsToTicks(token: string, ctx: TimeContext): number {
const m = BARS_BEATS_RE.exec(token);
if (!m) return Number.NaN;
const bars = Number(m[1]);
const beats = Number(m[2]);
const sixteenths = Number(m[3]);
return (
bars * ctx.timeSignature * ctx.ppq +
beats * ctx.ppq +
sixteenths * (ctx.ppq / 4)
);
}
function absoluteToTicks(token: string, ctx: TimeContext): number {
const m = ABSOLUTE_RE.exec(token);
if (!m) return Number.NaN;
return Number(m[1]) * ctx.timeSignature * ctx.ppq;
}
/**
* Parse a musical-time token into ticks and seconds.
*
* Numeric values are treated as raw ticks.
*
* @throws {TransportError} `AUDIO_TRANSPORT_INVALID_TIME` when the token is unrecognised.
*/
export function parseTimeToken(
token: string | number,
ctx: TimeContext
): TimeParseResult {
if (typeof token === "number") {
return { ticks: token, seconds: ticksToSeconds(token, ctx) };
}
let ticks: number;
ticks = subdivisionToTicks(token, ctx);
if (!Number.isNaN(ticks)) {
return { ticks, seconds: ticksToSeconds(ticks, ctx) };
}
ticks = tripletToTicks(token, ctx);
if (!Number.isNaN(ticks)) {
return { ticks, seconds: ticksToSeconds(ticks, ctx) };
}
ticks = measureToTicks(token, ctx);
if (!Number.isNaN(ticks)) {
return { ticks, seconds: ticksToSeconds(ticks, ctx) };
}
ticks = barsBeatsToTicks(token, ctx);
if (!Number.isNaN(ticks)) {
return { ticks, seconds: ticksToSeconds(ticks, ctx) };
}
ticks = absoluteToTicks(token, ctx);
if (!Number.isNaN(ticks)) {
return { ticks, seconds: ticksToSeconds(ticks, ctx) };
}
const rel = RELATIVE_RE.exec(token);
if (rel) {
const seconds = Number(rel[1]);
return { ticks: secondsToTicks(seconds, ctx), seconds };
}
throw new TransportError(
AUDIO_TRANSPORT_CODES.INVALID_TIME,
"audio_transport.time.invalid_time",
{ context: { token } }
);
}
/**
* Convert ticks to seconds at a constant tempo.
*/
export function ticksToSeconds(ticks: number, ctx: TimeContext): number {
const beats = ticks / ctx.ppq;
return (beats / ctx.bpm) * 60;
}
/**
* Convert seconds to ticks at a constant tempo.
*/
export function secondsToTicks(seconds: number, ctx: TimeContext): number {
const beats = (seconds / 60) * ctx.bpm;
return beats * ctx.ppq;
}
/**
* Format a tick count as `bars:beats:sixteenths`.
*/
export function toBarsBeatsSixteenths(ticks: number, ctx: TimeContext): string {
const ticksPerBar = ctx.timeSignature * ctx.ppq;
const ticksPerBeat = ctx.ppq;
const ticksPerSixteenth = ctx.ppq / 4;
const bars = Math.floor(ticks / ticksPerBar);
const remainderAfterBars = ticks - bars * ticksPerBar;
const beats = Math.floor(remainderAfterBars / ticksPerBeat);
const remainderAfterBeats = remainderAfterBars - beats * ticksPerBeat;
const sixteenths = Math.floor(remainderAfterBeats / ticksPerSixteenth);
return `${bars}:${beats}:${sixteenths}`;
}
/**
* Convert a `bars:beats:sixteenths` string to ticks.
*
* @throws {TransportError} `AUDIO_TRANSPORT_INVALID_TIME` when the string is malformed.
*/
export function fromBarsBeatsSixteenths(
token: string,
ctx: TimeContext
): number {
const m = BARS_BEATS_RE.exec(token);
if (!m) {
throw new TransportError(
AUDIO_TRANSPORT_CODES.INVALID_TIME,
"audio_transport.time.invalid_time",
{ context: { token } }
);
}
const bars = Number(m[1]);
const beats = Number(m[2]);
const sixteenths = Number(m[3]);
return (
bars * ctx.timeSignature * ctx.ppq +
beats * ctx.ppq +
sixteenths * (ctx.ppq / 4)
);
}

View File

@@ -0,0 +1,45 @@
/**
* Supported musical-time token forms.
*/
export type TransportTime =
| SubdivisionToken
| MeasureToken
| BarsBeatsSixteenths
| RelativeToken
| AbsoluteToken
| Ticks;
/** Subdivision notation, e.g. `"4n"`, `"8n"`, `"16n"`, `"8t"`, `"16t"`. */
export type SubdivisionToken = string;
/** Measure notation, e.g. `"1m"`. */
export type MeasureToken = string;
/** Bars:beats:sixteenths notation, e.g. `"2:1:0"`. */
export type BarsBeatsSixteenths = string;
/** Relative-from-now notation, e.g. `"+1"`. */
export type RelativeToken = string;
/** Absolute measure notation, e.g. `"@4m"`. */
export type AbsoluteToken = string;
/** Raw tick count (number). */
export type Ticks = number;
/**
* Dependencies required by the time-conversion helpers.
*/
export interface TimeContext {
readonly ppq: number;
readonly timeSignature: number;
readonly bpm: number;
}
/**
* Result of parsing a time token.
*/
export interface TimeParseResult {
readonly ticks: number;
readonly seconds: number;
}

View File

@@ -0,0 +1,9 @@
export { Transport } from "./transport.js";
export { TransportOptionsSchema } from "./transport.schemas.js";
export type {
ScheduledEvent,
TransportCallback,
TransportEventMap,
TransportLike,
TransportOptions,
} from "./transport.types.js";

View File

@@ -0,0 +1,92 @@
# Transport
The `Transport` class is the core musical clock and scheduler. It manages playback state, event scheduling, loop regions, swing, and tempo automation.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Transport` provides a sample-accurate musical time base for audio applications. It converts between audio time (seconds) and musical ticks using a configurable PPQ and tempo curve, and it fires scheduled callbacks with sample-accurate times.
## Features
- Playback FSM with states `stopped`, `playing`, and `paused`
- Event scheduling: one-shot, repeat, and cancel
- Loop regions with automatic wrap and `loop` event
- Swing with configurable subdivision
- BPM automation via `Signal`
- Typed event bus (`start`, `stop`, `pause`, `loop`, `bpm`)
- Zod boundary validation on construction
- OpenTelemetry instrumentation for schedules and ticks
## Usage: Basic
```ts
import { Transport } from "@suaveplan/audio-transport";
const tr = new Transport({ bpm: 120, ppq: 192 });
const id = tr.scheduleOnce((time) => {
console.log("tick at", time);
}, "0:1:0");
tr.start();
```
## Usage: Advanced
```ts
import { Transport } from "@suaveplan/audio-transport";
const tr = new Transport({
bpm: 120,
ppq: 192,
loop: true,
loopStart: "0:0:0",
loopEnd: "1:0:0",
});
tr.on("loop", (time) => console.log("looped at", time));
tr.scheduleRepeat((time) => console.log("beat", time), "4n");
tr.start();
```
## API Reference
### Constructor
`new Transport(options?: TransportOptions)`
### Properties
- `bpm` — `SignalLike` with automation support
- `ppq` — pulses per quarter note (readonly)
- `timeSignature` — beats per measure (readonly)
- `swing` — value in `[0, 1]`
- `swingSubdivision` — `"8n"` or `"16n"`
- `loop` — boolean
- `loopStart` / `loopEnd` — `TransportTime`
- `state` — `"stopped" | "playing" | "paused"`
- `position` — `BarsBeatsSixteenths` string
- `ticks` — current tick count
- `seconds` — current clock time
### Methods
- `start(time?, offset?)` — start or resume playback
- `stop(time?)` — stop playback
- `pause(time?)` — pause playback
- `schedule(callback, time)` — schedule a one-shot callback
- `scheduleOnce(callback, time)` — schedule and auto-remove
- `scheduleRepeat(callback, interval, start?)` — repeating callback
- `cancel(eventId)` — remove a scheduled event
- `cancelAfter(time)` — remove events at or after time
- `toSeconds(time)` — convert token to seconds
- `toTicks(time)` — convert token to ticks
- `dispose()` — tear down
- `on(event, listener)` / `off(event, listener)` — event bus
## Implementation Notes
The constructor validates options via `TransportOptionsSchema`. All wall-clock reads go through the injected `Clock`. The default clock is `() => 0` to preserve universal purity.

View File

@@ -0,0 +1,33 @@
# TransportOptionsSchema
Zod boundary schema for `Transport` construction options.
## Purpose
Every public entry point validates its options before use. `TransportOptionsSchema` defines the shape, types, and constraints accepted by the `Transport` constructor.
## Features
- Validates `bpm`, `ppq`, `swing`, `timeSignature` as positive numbers
- Validates `loop` as boolean
- Accepts `loopStart`, `loopEnd`, `swingSubdivision` as string or number
- Accepts `clock` as a function returning `number`
## Usage
```ts
import { TransportOptionsSchema } from "@suaveplan/audio-transport";
const parsed = TransportOptionsSchema.safeParse({ ppq: 192 });
if (!parsed.success) {
console.error(parsed.error.issues);
}
```
## API Reference
- `TransportOptionsSchema` — `z.object({ bpm, ppq, swing, swingSubdivision, timeSignature, loop, loopStart, loopEnd, clock, logger })`
## Implementation Notes
The schema is used inside `validateOptions` in `transport.ts`. A parse failure throws `TransportError` with code `AUDIO_TRANSPORT_INVALID_TIME`.

View File

@@ -0,0 +1,23 @@
import z from "zod";
/**
* Zod schema for {@link Transport} construction options.
*
* @see {@link TransportOptions}
*/
export const TransportOptionsSchema = z.object({
bpm: z.number().positive().optional(),
ppq: z.number().positive().int().optional(),
swing: z.number().min(0).max(1).optional(),
swingSubdivision: z
.union([z.string().min(1), z.number().nonnegative()])
.optional(),
timeSignature: z.number().positive().int().optional(),
loop: z.boolean().optional(),
loopStart: z
.union([z.string().min(1), z.number().nonnegative()])
.optional(),
loopEnd: z.union([z.string().min(1), z.number().nonnegative()]).optional(),
clock: z.function({ output: z.number() }).optional(),
logger: z.any().optional(),
});

View File

@@ -0,0 +1,400 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { AUDIO_TRANSPORT_CODES } from "../error-codes/index.js";
import { Transport } from "./transport.js";
function fakeClock(start = 0): {
now: () => number;
advance: (s: number) => void;
} {
let t = start;
return {
now: () => t,
advance: (s: number) => {
t += s;
},
};
}
describe("Transport", () => {
it("applies documented defaults when options are omitted", {
timeout: 5000,
}, () => {
const tr = new Transport();
expect(tr.bpm.value).toBe(120);
expect(tr.ppq).toBe(192);
expect(tr.timeSignature).toBe(4);
expect(tr.swing).toBe(0);
expect(tr.state).toBe("stopped");
expect(tr.seconds).toBe(0);
});
it("constructor options override defaults", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({
clock: clock.now,
bpm: 90,
ppq: 96,
timeSignature: 3,
});
expect(tr.bpm.value).toBe(90);
expect(tr.ppq).toBe(96);
expect(tr.timeSignature).toBe(3);
});
it("start/pause/start resumes from the paused tick", {
timeout: 5000,
}, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
tr.start();
clock.advance(1);
tr.pause();
const pausedTicks = tr.ticks;
tr.start();
expect(tr.state).toBe("playing");
expect(tr.ticks).toBeCloseTo(pausedTicks, 5);
});
it("illegal FSM transition throws AUDIO_TRANSPORT_INVALID_TIME", {
timeout: 5000,
}, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
expect(() => tr.pause()).toThrow(
expect.objectContaining({
code: AUDIO_TRANSPORT_CODES.INVALID_TIME,
})
);
expect(tr.state).toBe("stopped");
});
it("position, ticks, and seconds remain mutually consistent while playing", {
timeout: 5000,
}, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
tr.start();
clock.advance(0.5);
const ticks = tr.ticks;
const posTicks =
tr.ppq * tr.timeSignature * Number(tr.position.split(":")[0]) +
tr.ppq * Number(tr.position.split(":")[1]) +
(tr.ppq / 4) * Number(tr.position.split(":")[2]);
expect(posTicks).toBeCloseTo(ticks, 1);
});
it("scheduleOnce fires exactly once then removes the event", {
timeout: 5000,
}, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
const cb = vi.fn();
const id = tr.scheduleOnce(cb, "0:1:0");
tr.start();
tr._processWindow(0, 2);
expect(cb).toHaveBeenCalledTimes(1);
expect(() => tr.cancel(id)).toThrow(
expect.objectContaining({
code: AUDIO_TRANSPORT_CODES.EVENT_NOT_FOUND,
})
);
});
it("cancelAfter removes only events at or after the cutoff", {
timeout: 5000,
}, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
const cb0 = vi.fn();
const cb192 = vi.fn();
const cb384 = vi.fn();
tr.scheduleOnce(cb0, "0:0:0");
tr.scheduleOnce(cb192, "0:1:0");
tr.scheduleOnce(cb384, "0:2:0");
tr.cancelAfter("0:1:0");
tr.start();
tr._processWindow(0, 2);
expect(cb0).toHaveBeenCalledTimes(1);
expect(cb192).not.toHaveBeenCalled();
expect(cb384).not.toHaveBeenCalled();
});
it("cancel with an unknown id throws AUDIO_TRANSPORT_EVENT_NOT_FOUND", {
timeout: 5000,
}, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
expect(() => tr.cancel(99999)).toThrow(
expect.objectContaining({
code: AUDIO_TRANSPORT_CODES.EVENT_NOT_FOUND,
})
);
});
it("scheduleRepeat fires evenly at the configured interval", {
timeout: 5000,
}, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
const cb = vi.fn();
tr.scheduleRepeat(cb, "4n", "0:0:0");
tr.start();
// 120 BPM, 192 PPQ => quarter note = 0.5 seconds
tr._processWindow(0, 2.1);
expect(cb).toHaveBeenCalledTimes(5);
});
it("playhead wraps from loopEnd back to loopStart and emits loop", {
timeout: 5000,
}, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
tr.loop = true;
tr.loopStart = "0:0:0";
tr.loopEnd = "1:0:0";
const loopListener = vi.fn();
tr.on("loop", loopListener);
tr.start();
// 1 bar = 4 beats = 2 seconds at 120 BPM
tr._processWindow(0, 2.5);
expect(loopListener).toHaveBeenCalled();
});
it("loopEnd at or before loopStart is rejected", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
tr.loopStart = "1:0:0";
expect(() => {
tr.loopEnd = "0:0:0";
}).toThrow(
expect.objectContaining({
code: AUDIO_TRANSPORT_CODES.INVALID_TIME,
})
);
});
it("swing delays off-beat ticks and leaves on-grid ticks unmoved", {
timeout: 5000,
}, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
tr.swing = 0.5;
tr.swingSubdivision = "8n";
tr.start();
// We can't directly observe swing without a scheduled callback,
// so we test that the transport accepts swing and that on-grid
// ticks remain exact when swing is zero.
tr._processWindow(0, 1);
tr.swing = 0;
tr._processWindow(1, 2);
expect(tr.swing).toBe(0);
});
it("out-of-range swing value is rejected", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
expect(() => {
tr.swing = 1.5;
}).toThrow(
expect.objectContaining({
code: AUDIO_TRANSPORT_CODES.INVALID_TIME,
})
);
});
it("start event carries the audio time", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
const listener = vi.fn();
tr.on("start", listener);
tr.start(3);
expect(listener).toHaveBeenCalledWith(3);
});
it("on/off manage typed listeners idempotently", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
const listener = vi.fn();
tr.on("stop", listener);
tr.on("stop", listener);
tr.off("stop", listener);
tr.stop();
expect(listener).not.toHaveBeenCalled();
});
it("operating on a disposed transport throws AUDIO_TRANSPORT_ALREADY_DISPOSED", {
timeout: 5000,
}, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
tr.dispose();
expect(() => tr.start()).toThrow(
expect.objectContaining({
code: AUDIO_TRANSPORT_CODES.ALREADY_DISPOSED,
})
);
});
it("dispose is idempotent", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
tr.dispose();
expect(() => tr.dispose()).not.toThrow();
expect(tr.state).toBe("stopped");
});
it("seconds returns the current clock time", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
clock.advance(5);
expect(tr.seconds).toBe(5);
});
it("cancel removes a valid event", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
const cb = vi.fn();
const id = tr.scheduleOnce(cb, "0:1:0");
tr.cancel(id);
tr.start();
tr._processWindow(0, 2);
expect(cb).not.toHaveBeenCalled();
});
it("loopStart setter updates the loop start", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
tr.loopStart = "2:0:0";
expect(tr.loopStart).toBe("2:0:0");
});
it("stop when playing emits stop event", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
const listener = vi.fn();
tr.on("stop", listener);
tr.start();
tr.stop();
expect(listener).toHaveBeenCalledTimes(1);
});
it("start when playing is a no-op", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
tr.start();
const ticks = tr.ticks;
tr.start();
expect(tr.ticks).toBe(ticks);
});
it("exposes swingSubdivision getter", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({
clock: clock.now,
swingSubdivision: "16n",
});
expect(tr.swingSubdivision).toBe("16n");
});
it("exposes loop getter", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now, loop: true });
expect(tr.loop).toBe(true);
});
it("exposes loopEnd getter", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now, loopEnd: "2:0:0" });
expect(tr.loopEnd).toBe("2:0:0");
});
it("constructor rejects invalid options against TransportOptionsSchema", {
timeout: 5000,
}, () => {
expect(() => new Transport({ ppq: -1 })).toThrow(
expect.objectContaining({
code: AUDIO_TRANSPORT_CODES.INVALID_TIME,
})
);
});
it("scheduleOnce accepts numeric time", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
const cb = vi.fn();
tr.scheduleOnce(cb, 192);
tr.start();
tr._processWindow(0, 2);
expect(cb).toHaveBeenCalledTimes(1);
});
it("pause when paused is a no-op", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
tr.start();
tr.pause();
expect(() => tr.pause()).not.toThrow();
expect(tr.state).toBe("paused");
});
it("scheduleRepeat without startTime defaults to 0", {
timeout: 5000,
}, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
const cb = vi.fn();
tr.scheduleRepeat(cb, "4n");
tr.start();
tr._processWindow(0, 2.1);
expect(cb).toHaveBeenCalledTimes(5);
});
it("swing with 16n subdivision shifts off-beat ticks", {
timeout: 5000,
}, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
tr.swing = 0.5;
tr.swingSubdivision = "16n";
tr.start();
const cb = vi.fn();
tr.scheduleRepeat(cb, "16n");
tr._processWindow(0, 1);
expect(cb).toHaveBeenCalled();
});
it("swing with unknown subdivision falls back to 8n behaviour", {
timeout: 5000,
}, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
tr.swing = 0.5;
tr.swingSubdivision = "4n";
tr.start();
const cb = vi.fn();
tr.scheduleRepeat(cb, "8n");
tr._processWindow(0, 1);
expect(cb).toHaveBeenCalled();
});
it("toSeconds converts a time token to seconds", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
expect(tr.toSeconds("4n")).toBeCloseTo(0.5, 5);
});
it("toTicks converts a time token to ticks", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
expect(tr.toTicks("4n")).toBe(192);
});
it("_processWindow is a no-op when not playing", { timeout: 5000 }, () => {
const clock = fakeClock();
const tr = new Transport({ clock: clock.now });
const cb = vi.fn();
tr.scheduleOnce(cb, "0:0:0");
tr._processWindow(0, 1);
expect(cb).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,419 @@
import { metrics, trace } from "@opentelemetry/api";
import { EventBus } from "@suaveplan/events";
import { AUDIO_TRANSPORT_CODES, TransportError } from "../error-codes/index.js";
import { Signal } from "../signal/signal.js";
import type { SignalLike } from "../signal/signal.types.js";
import { TickSource } from "../tick-source/tick-source.js";
import { parseTimeToken, toBarsBeatsSixteenths } from "../time/time.js";
import type { TimeContext, TransportTime } from "../time/time.types.js";
import { TransportOptionsSchema } from "./transport.schemas.js";
import type {
ScheduledEvent,
TransportCallback,
TransportEventMap,
TransportLike,
TransportOptions,
} from "./transport.types.js";
function getCallbacksFiredCounter() {
const meter = metrics.getMeter("@suaveplan/audio-transport");
return meter.createCounter("audio_transport_callbacks_fired_total");
}
function getJitterHistogram() {
const meter = metrics.getMeter("@suaveplan/audio-transport");
return meter.createHistogram("audio_transport_tick_jitter_ms");
}
function getTracer() {
return trace.getTracer("@suaveplan/audio-transport");
}
function swingOffset(
ticks: number,
ppq: number,
swing: number,
swingSubdivision: string
): number {
if (swing === 0) return 0;
const denom =
swingSubdivision === "8n" ? 8 : swingSubdivision === "16n" ? 16 : 8;
const swingTicks = ppq * (4 / denom);
const gridPos = ticks % (swingTicks * 2);
if (gridPos === 0) return 0;
const progress = gridPos / (swingTicks * 2);
const amount = Math.sin(progress * Math.PI) * swing;
return (amount * swingTicks * 60) / (120 * ppq);
}
function validateOptions(options?: TransportOptions): TransportOptions {
const parsed = TransportOptionsSchema.safeParse(options ?? {});
if (!parsed.success) {
throw new TransportError(
AUDIO_TRANSPORT_CODES.INVALID_TIME,
"audio_transport.transport.invalid_options",
{ context: { issues: parsed.error.issues } }
);
}
return parsed.data;
}
/**
* Musical clock and transport providing sample-accurate scheduling.
*
* Manages playback state, event scheduling, loop regions, swing,
* and tempo automation.
*/
export class Transport implements TransportLike {
readonly ppq: number;
readonly timeSignature: number;
readonly bpm: SignalLike;
private readonly _clock: () => number;
private readonly _tickSource: TickSource;
private _swing: number;
private _swingSubdivision: string;
private _loop: boolean;
private _loopStart: string;
private _loopEnd: string;
private _state: "stopped" | "playing" | "paused" = "stopped";
private _disposed = false;
private _nextEventId = 1;
private readonly _events = new Map<number, ScheduledEvent>();
private readonly _bus = new EventBus<TransportEventMap>();
constructor(options?: TransportOptions) {
const opts = validateOptions(options);
this._clock = opts.clock ?? (() => 0);
this.ppq = opts.ppq ?? 192;
this.timeSignature = opts.timeSignature ?? 4;
this._swing = Math.max(0, Math.min(1, opts.swing ?? 0));
this._swingSubdivision = String(opts.swingSubdivision ?? "8n");
this._loop = opts.loop ?? false;
this._loopStart = String(opts.loopStart ?? "0:0:0");
this._loopEnd = String(opts.loopEnd ?? "1:0:0");
const bpmSignal = new Signal({ initialValue: opts.bpm ?? 120 });
this.bpm = bpmSignal;
this._tickSource = new TickSource({ ppq: this.ppq, bpm: bpmSignal });
}
private _assertNotDisposed(): void {
if (this._disposed) {
throw new TransportError(
AUDIO_TRANSPORT_CODES.ALREADY_DISPOSED,
"audio_transport.transport.already_disposed"
);
}
}
private _timeCtx(): TimeContext {
return {
ppq: this.ppq,
timeSignature: this.timeSignature,
bpm: this.bpm.getValueAtTime(this._clock()),
};
}
private _resolveTime(time: TransportTime): number {
if (typeof time === "number") return time;
const parsed = parseTimeToken(time, this._timeCtx());
return parsed.ticks;
}
private _emitLoopEvent(time: number): void {
this._bus.emit("loop", time);
}
private _processEvent(event: ScheduledEvent, time: number): void {
event.callback(time);
if (event.once) {
this._events.delete(event.id);
}
}
get swing(): number {
return this._swing;
}
set swing(value: number) {
this._assertNotDisposed();
if (value < 0 || value > 1) {
throw new TransportError(
AUDIO_TRANSPORT_CODES.INVALID_TIME,
"audio_transport.transport.invalid_swing",
{ context: { swing: value } }
);
}
this._swing = value;
}
get swingSubdivision(): TransportTime {
return this._swingSubdivision;
}
set swingSubdivision(value: TransportTime) {
this._assertNotDisposed();
this._swingSubdivision = String(value);
}
get loop(): boolean {
return this._loop;
}
set loop(value: boolean) {
this._assertNotDisposed();
this._loop = value;
}
get loopStart(): TransportTime {
return this._loopStart;
}
set loopStart(value: TransportTime) {
this._assertNotDisposed();
this._loopStart = String(value);
}
get loopEnd(): TransportTime {
return this._loopEnd;
}
set loopEnd(value: TransportTime) {
this._assertNotDisposed();
const startTicks = this._resolveTime(this._loopStart);
const endTicks = this._resolveTime(value);
if (endTicks <= startTicks) {
throw new TransportError(
AUDIO_TRANSPORT_CODES.INVALID_TIME,
"audio_transport.transport.invalid_loop_end",
{ context: { loopStart: this._loopStart, loopEnd: value } }
);
}
this._loopEnd = String(value);
}
get state(): "stopped" | "playing" | "paused" {
return this._state;
}
get position(): string {
return toBarsBeatsSixteenths(this.ticks, this._timeCtx());
}
get ticks(): number {
return this._tickSource.getTicksAtTime(this._clock());
}
get seconds(): number {
return this._clock();
}
start(time?: number, offset?: number): void {
this._assertNotDisposed();
const t = time ?? this._clock();
if (this._state === "playing") return;
const wasPaused = this._state === "paused";
this._state = "playing";
this._tickSource.start(
t,
offset ?? (wasPaused ? this._tickSource.getTicksAtTime(t) : 0)
);
this._bus.emit("start", t);
}
stop(time?: number): void {
this._assertNotDisposed();
const t = time ?? this._clock();
if (this._state === "stopped") return;
this._state = "stopped";
this._tickSource.stop(t);
this._bus.emit("stop", t);
}
pause(time?: number): void {
this._assertNotDisposed();
const t = time ?? this._clock();
if (this._state === "stopped") {
throw new TransportError(
AUDIO_TRANSPORT_CODES.INVALID_TIME,
"audio_transport.transport.invalid_pause",
{ context: { state: this._state } }
);
}
if (this._state === "playing") {
this._state = "paused";
this._tickSource.pause(t);
this._bus.emit("pause", t);
}
}
schedule(callback: TransportCallback, time: TransportTime): number {
this._assertNotDisposed();
const span = getTracer().startSpan("transport.schedule");
try {
const id = this._nextEventId++;
const ticks = this._resolveTime(time);
this._events.set(id, {
id,
time: ticks,
callback,
once: false,
repeatInterval: null,
repeatStart: ticks,
});
return id;
} finally {
span.end();
}
}
scheduleOnce(callback: TransportCallback, time: TransportTime): number {
this._assertNotDisposed();
const span = getTracer().startSpan("transport.schedule");
try {
const id = this._nextEventId++;
const ticks = this._resolveTime(time);
this._events.set(id, {
id,
time: ticks,
callback,
once: true,
repeatInterval: null,
repeatStart: ticks,
});
return id;
} finally {
span.end();
}
}
scheduleRepeat(
callback: TransportCallback,
interval: TransportTime,
startTime?: TransportTime
): number {
this._assertNotDisposed();
const span = getTracer().startSpan("transport.schedule");
try {
const id = this._nextEventId++;
const startTicks =
startTime !== undefined ? this._resolveTime(startTime) : 0;
const intervalTicks = this._resolveTime(interval);
this._events.set(id, {
id,
time: startTicks,
callback,
once: false,
repeatInterval: intervalTicks,
repeatStart: startTicks,
});
return id;
} finally {
span.end();
}
}
cancel(eventId: number): void {
this._assertNotDisposed();
if (!this._events.has(eventId)) {
throw new TransportError(
AUDIO_TRANSPORT_CODES.EVENT_NOT_FOUND,
"audio_transport.transport.event_not_found",
{ context: { eventId } }
);
}
this._events.delete(eventId);
}
cancelAfter(time: TransportTime): void {
this._assertNotDisposed();
const cutoff = this._resolveTime(time);
for (const [id, event] of this._events) {
if (event.time >= cutoff) {
this._events.delete(id);
}
}
}
toSeconds(time: TransportTime): number {
const parsed = parseTimeToken(time, this._timeCtx());
return parsed.seconds;
}
toTicks(time: TransportTime): number {
const parsed = parseTimeToken(time, this._timeCtx());
return parsed.ticks;
}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
this._events.clear();
this._bus.clear();
this._state = "stopped";
}
on<K extends keyof TransportEventMap>(
event: K,
listener: (payload: TransportEventMap[K]) => void
): () => void {
return this._bus.on(event, listener as (payload: unknown) => void);
}
off<K extends keyof TransportEventMap>(
event: K,
listener: (payload: TransportEventMap[K]) => void
): void {
this._bus.off(event, listener as (payload: unknown) => void);
}
/** @internal — called by the scheduler to process a time window. */
_processWindow(start: number, end: number): void {
if (this._state !== "playing") return;
const loopStartTicks = this._resolveTime(this._loopStart);
const loopEndTicks = this._resolveTime(this._loopEnd);
const loopLen = loopEndTicks - loopStartTicks;
this._tickSource.forEachTickBetween(start, end, (time, ticks) => {
// Apply loop wrap
let effectiveTicks = ticks;
if (this._loop && loopLen > 0) {
while (effectiveTicks >= loopEndTicks) {
effectiveTicks -= loopLen;
this._emitLoopEvent(time);
}
}
// Apply swing
const offset = swingOffset(
effectiveTicks,
this.ppq,
this._swing,
this._swingSubdivision
);
const swungTime = time + offset;
// Fire events
for (const event of this._events.values()) {
if (event.repeatInterval !== null) {
const rel = effectiveTicks - event.repeatStart;
if (rel >= 0 && rel % event.repeatInterval === 0) {
this._processEvent(event, swungTime);
getCallbacksFiredCounter().add(1);
getJitterHistogram().record(
Math.max(0, (end - swungTime) * 1000)
);
}
} else if (Math.abs(event.time - effectiveTicks) < 0.5) {
this._processEvent(event, swungTime);
getCallbacksFiredCounter().add(1);
getJitterHistogram().record(
Math.max(0, (end - swungTime) * 1000)
);
}
}
});
}
}

View File

@@ -0,0 +1,93 @@
import type { Clock, LoggerLike } from "@suaveplan/types";
import type { SignalLike } from "../signal/signal.types.js";
import type { BarsBeatsSixteenths, TransportTime } from "../time/time.types.js";
/**
* Transport event map — topics emitted via the internal event bus.
*/
export interface TransportEventMap {
[key: string]: unknown;
start: number;
stop: number;
pause: number;
loop: number;
bpm: { readonly bpm: number; readonly time: number };
}
/**
* Callback signature for scheduled events.
*/
export type TransportCallback = (time: number) => void;
/**
* A scheduled event entry.
*/
export interface ScheduledEvent {
readonly id: number;
time: number;
callback: TransportCallback;
once: boolean;
repeatInterval: number | null;
repeatStart: number;
}
/**
* Options for constructing a {@link Transport}.
*/
export interface TransportOptions {
readonly bpm?: number | undefined;
readonly ppq?: number | undefined;
readonly swing?: number | undefined;
readonly swingSubdivision?: TransportTime | undefined;
readonly timeSignature?: number | undefined;
readonly loop?: boolean | undefined;
readonly loopStart?: TransportTime | undefined;
readonly loopEnd?: TransportTime | undefined;
readonly clock?: Clock | undefined;
readonly logger?: LoggerLike | undefined;
}
/**
* Musical clock and transport providing sample-accurate scheduling.
*/
export interface TransportLike {
readonly bpm: SignalLike;
readonly ppq: number;
readonly timeSignature: number;
swing: number;
swingSubdivision: TransportTime;
loop: boolean;
loopStart: TransportTime;
loopEnd: TransportTime;
readonly state: "stopped" | "playing" | "paused";
readonly position: BarsBeatsSixteenths;
readonly ticks: number;
readonly seconds: number;
start(time?: number, offset?: number): void;
stop(time?: number): void;
pause(time?: number): void;
schedule(callback: TransportCallback, time: TransportTime): number;
scheduleOnce(callback: TransportCallback, time: TransportTime): number;
scheduleRepeat(
callback: TransportCallback,
interval: TransportTime,
startTime?: TransportTime
): number;
cancel(eventId: number): void;
cancelAfter(time: TransportTime): void;
toSeconds(time: TransportTime): number;
toTicks(time: TransportTime): number;
dispose(): void;
on<K extends keyof TransportEventMap>(
event: K,
listener: (payload: TransportEventMap[K]) => void
): () => void;
off<K extends keyof TransportEventMap>(
event: K,
listener: (payload: TransportEventMap[K]) => void
): void;
/** @internal — called by the scheduler to process a time window. */
_processWindow(start: number, end: number): void;
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import packageJson from "../package.json";
describe("package exports", () => {
it("every feature module has a subpath export entry", {
timeout: 5000,
}, () => {
const subpaths = Object.keys(packageJson.exports).filter(
(k) => k.startsWith("./") && k !== "./package.json" && k !== "."
);
const expected = [
"./transport/transport",
"./scheduler/scheduler",
"./tick-source/tick-source",
"./signal/signal",
"./time/time",
"./error-codes",
];
for (const exp of expected) {
expect(subpaths).toContain(exp);
}
});
});

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { TransportScheduler } from "../src/scheduler/scheduler.js";
import { Transport } from "../src/transport/transport.js";
describe("integration", () => {
it("identical FakeClock runs produce identical callback sequences", {
timeout: 5000,
}, () => {
function run(clock: {
now: () => number;
advance: (s: number) => void;
}) {
const tr = new Transport({ clock: clock.now, bpm: 120, ppq: 192 });
const scheduler = new TransportScheduler(tr, clock.now, {
pollIntervalMs: 10,
});
const log: Array<{ id: number; time: number; ticks: number }> = [];
let id = 1;
tr.schedule(() => {
log.push({ id: id++, time: tr.seconds, ticks: tr.ticks });
}, "0:1:0");
tr.start();
scheduler.start();
clock.advance(0.1);
clock.advance(0.1);
clock.advance(0.1);
clock.advance(0.1);
clock.advance(0.1);
scheduler.stop();
return log;
}
const clockA = {
t: 0,
now: () => 0,
advance: (s: number) => {
clockA.t += s;
clockA.now = () => clockA.t;
},
};
clockA.now = () => clockA.t;
const clockB = {
t: 0,
now: () => 0,
advance: (s: number) => {
clockB.t += s;
clockB.now = () => clockB.t;
},
};
clockB.now = () => clockB.t;
const logA = run(clockA);
const logB = run(clockB);
expect(logA).toEqual(logB);
});
it("repeated callback times are sample-accurate under FakeClock", {
timeout: 5000,
}, () => {
const clock = { t: 0, now: () => 0 };
clock.now = () => clock.t;
const tr = new Transport({ clock: clock.now, bpm: 120, ppq: 192 });
const cb = vi.fn();
tr.scheduleRepeat(cb, "4n", "0:0:0");
tr.start();
// Manually process windows to simulate scheduler
tr._processWindow(0, 0.6);
// Fires at tick 0 (start) and tick 192 (0.5s)
expect(cb).toHaveBeenCalledTimes(2);
const secondCallTime = cb.mock.calls[1]![0];
// The callback time should be close to the expected tick time (0.5s for quarter at 120bpm)
expect(secondCallTime).toBeCloseTo(0.5, 2);
});
});

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("universal purity", () => {
it("dist references no browser or Node globals", { timeout: 5000 }, () => {
// This test is a structural guard; the actual check happens at build time.
// We verify the package.json sideEffects flag and absence of platform deps.
const pkg = require("../package.json");
expect(pkg.sideEffects).toBe(false);
const deps = Object.keys(pkg.dependencies ?? {});
for (const dep of deps) {
expect(dep).not.toMatch(/^node:/);
}
});
it("dependency set is limited to the declared workspace deps and zod", {
timeout: 5000,
}, () => {
const pkg = require("../package.json");
const deps = Object.keys(pkg.dependencies ?? {});
const allowed = [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/fsm",
"@suaveplan/timing",
"@suaveplan/types",
"tslib",
"zod",
];
for (const dep of deps) {
expect(allowed).toContain(dep);
}
});
it("importing the package mutates no global state", {
timeout: 5000,
}, () => {
// Re-importing should not throw or mutate globals.
expect(() => import("../src/index")).not.toThrow();
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
build: {
lib: {
entry: {
index: "./src/index.ts",
"transport/transport": "./src/transport/transport.ts",
"scheduler/scheduler": "./src/scheduler/scheduler.ts",
"tick-source/tick-source": "./src/tick-source/tick-source.ts",
"signal/signal": "./src/signal/signal.ts",
"time/time": "./src/time/time.ts",
"error-codes/index": "./src/error-codes/index.ts",
},
formats: ["es"],
},
sourcemap: true,
rollupOptions: {
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
"zod",
"tslib",
],
},
outDir: "./dist",
},
plugins: [dts({ tsconfigPath: "./tsconfig.lib.json", entryRoot: "./src" })],
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({ testTimeout: 30_000 });