Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Expected source layout after the scaffold task:
- `src/persistence/`: import, export, storage, and migration helpers.
- `src/utils/`: pure utilities such as tick/time conversion.
- `src/styles/`: global CSS, tokens, and shared style primitives.
- `tests/unit/`: unit tests for utilities, model transformations, scheduler calculations, and other isolated logic.
- `tests/integration/`: integration tests for multi-module flows when needed.
- `docs/`: product, architecture, data model, audio, UI, testing, and feature specs.

Preserve the existing structure unless a refactor is clearly required by the task.
Expand Down Expand Up @@ -83,6 +85,14 @@ If these scripts do not exist yet, future scaffold work must add them. CI uses `
- Do not add Tailwind or CDN-based styling unless explicitly requested in a feature spec.
- Inline styles are acceptable for dynamic editor geometry such as note positions, widths, grid coordinates, and velocity heights.

## Testing Rules

- Keep production code under `src/`; place unit tests under `tests/unit/`.
- Add integration tests under `tests/integration/` only when a task needs multi-module workflow coverage.
- Do not add an end-to-end test framework or `tests/e2e/` until a feature spec calls for browser flow testing.
- Keep `tsconfig.test.json` in sync with test locations so `npm run typecheck` checks test files.
- Do not remove tests or checks to make a task pass.

## Issue Workflow

- Use GitHub Issues as the task queue for feature work, bugs, and larger refactors.
Expand Down
7 changes: 3 additions & 4 deletions PLANS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,9 @@ M1 should include:

Use this section as the current execution order for agent work. Feature document numbering describes the planned product sequence, but actual issue order may change as dependencies, review feedback, or implementation risks become clearer.

1. #11 Main DAW UI Shell -> `docs/features/06-main-daw-ui-shell.md` (PR #13 in review)
2. #5 Lookahead Scheduler -> `docs/features/03-lookahead-scheduler.md`
3. #3 Drum Step Sequencer -> `docs/features/04-drum-step-sequencer.md`
4. #2 Basic Piano Roll -> `docs/features/05-basic-piano-roll.md`
1. #5 Lookahead Scheduler -> `docs/features/03-lookahead-scheduler.md` (PR #14 in review)
2. #3 Drum Step Sequencer -> `docs/features/04-drum-step-sequencer.md`
3. #2 Basic Piano Roll -> `docs/features/05-basic-piano-roll.md`

## Planned Milestones

Expand Down
4 changes: 4 additions & 0 deletions docs/audio-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Example terms:
- `nextTick`: next musical tick to inspect.
- `loopStartTick` and `loopEndTick`: musical loop boundaries.

The browser audio engine exposes sample loop playback through a typed API that accepts tick-based sample events, tempo, loop bounds, lookahead cadence, and schedule-ahead time. The scheduler itself is independent from React and can be unit tested without DOM rendering.

## Tick-to-audio-time Conversion

Ticks convert to seconds using tempo and PPQ:
Expand Down Expand Up @@ -86,6 +88,8 @@ For M1, loop playback targets a selected 1-bar clip. The default loop range is 0

Events at the loop start should play when the loop begins. Events at the loop end should belong to the next loop iteration only if explicitly represented there; avoid double-triggering boundary events.

Loop stop clears the scheduler timer and prevents future windows from being scheduled. Events already submitted to Web Audio inside the current schedule-ahead window may still play briefly; keep `scheduleAheadTime` short enough that this limitation remains acceptable for interactive editing.

## UI Playhead Separation

UI cursor and playhead animation may use `requestAnimationFrame` and read transport position from the audio engine. Visual playhead timing can be approximate. Exact sound timing must come from scheduled Web Audio events.
Expand Down
5 changes: 4 additions & 1 deletion docs/code-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Use these conventions to keep agent-authored code consistent across features. Pr
- Feature folders use `kebab-case`: `drum-step-sequencer`, `piano-roll`.
- General TypeScript utility files use `kebab-case.ts`: `tick-time.ts`, `sample-loader.ts`.
- Barrel files may use `index.ts` only when they simplify imports without hiding ownership.
- Test files use `*.test.ts` or `*.test.tsx` next to the code they cover unless a feature establishes a clearer local pattern.
- Unit test files use `*.test.ts` or `*.test.tsx` under `tests/unit/`, grouped by the production area they cover.

## TypeScript Naming

Expand Down Expand Up @@ -73,6 +73,9 @@ Use these conventions to keep agent-authored code consistent across features. Pr
## Tests

- Test names should describe behavior, not implementation.
- Keep production source files under `src/`; place unit tests under `tests/unit/`.
- Use `tests/integration/` only when a task needs multi-module workflow coverage.
- Do not add an end-to-end test framework until a feature spec calls for browser flow testing.
- Prefer examples such as:
- `converts ticks to seconds at 120 bpm`
- `does not schedule duplicate events at the loop end`
Expand Down
2 changes: 1 addition & 1 deletion docs/features/03-lookahead-scheduler.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Feature: 03 Lookahead Scheduler

## Status
Planned
In Review

## Goal

Expand Down
19 changes: 18 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,24 @@ CI uses `--if-present` while the repository is still before the Vite scaffold.
- Data model transformations: unit tests.
- Scheduler calculations: unit tests where possible.
- UI interactions: component tests later.
- Critical flows: end-to-end tests later.
- Critical flows: browser end-to-end tests later, after the UI and workflows are stable enough to justify the framework.

## Test Source Layout

- `src/`: production code only.
- `tests/unit/`: unit tests for pure utilities, scheduler calculations, model transformations, and isolated module behavior.
- `tests/integration/`: integration tests for multi-module workflows when needed.

Do not add an end-to-end test directory or framework yet. Add it only when a future feature spec needs browser flow coverage.

Test files should use `*.test.ts` or `*.test.tsx`. Keep paths grouped by the production area they cover, for example:

```text
tests/unit/audio/lookahead-scheduler.test.ts
tests/unit/utils/tick-time.test.ts
```

`tsconfig.test.json` owns TypeScript settings for tests. The root `tsconfig.json` should reference it so `npm run typecheck` checks test files as well as production code.

## High-risk Areas

Expand Down
93 changes: 91 additions & 2 deletions src/audio/browser-audio-engine.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { BUNDLED_DRUM_SAMPLES } from "./bundled-samples";
import { LookaheadScheduler } from "./lookahead-scheduler";
import type {
AudioEngine,
AudioEngineSnapshot,
BundledSampleMeta,
PlaySampleOptions,
SampleId,
SampleLoopEvent,
StartSampleLoopOptions,
TransportSnapshot,
} from "./types";
import { TICKS_PER_4_4_BAR } from "../utils";

const DEFAULT_SAMPLE_GAIN = 0.9;
const DEFAULT_TRANSPORT_TEMPO_BPM = 120;

type AudioContextConstructor = new () => AudioContext;

Expand All @@ -22,6 +28,7 @@ export class BrowserAudioEngine implements AudioEngine {
private readonly sampleCache = new Map<SampleId, AudioBuffer>();
private readonly loadingSamples = new Map<SampleId, Promise<AudioBuffer>>();
private audioContext: AudioContext | null = null;
private sampleLoopScheduler: LookaheadScheduler<SampleLoopEvent> | null = null;

constructor(samples: readonly BundledSampleMeta[]) {
this.samplesById = new Map(samples.map((sample) => [sample.id, sample]));
Expand All @@ -31,6 +38,23 @@ export class BrowserAudioEngine implements AudioEngine {
return {
contextState: this.audioContext?.state ?? "not-created",
loadedSampleIds: Array.from(this.sampleCache.keys()),
transport: this.getTransportSnapshot(),
};
}

getTransportSnapshot(): TransportSnapshot {
if (this.sampleLoopScheduler) {
return this.sampleLoopScheduler.getSnapshot();
}

return {
audioStartTime: null,
currentTick: 0,
loopEndTick: TICKS_PER_4_4_BAR,
loopStartTick: 0,
nextScheduleTick: 0,
status: "stopped",
tempoBpm: DEFAULT_TRANSPORT_TEMPO_BPM,
};
}

Expand All @@ -45,6 +69,8 @@ export class BrowserAudioEngine implements AudioEngine {
}

async suspend(): Promise<AudioEngineSnapshot> {
this.stopLoop();

if (this.audioContext && this.audioContext.state === "running") {
await this.audioContext.suspend();
}
Expand Down Expand Up @@ -91,11 +117,74 @@ export class BrowserAudioEngine implements AudioEngine {
sampleId: SampleId,
options: PlaySampleOptions = {},
): Promise<void> {
const audioContext = this.getOrCreateAudioContext();
await this.resume();
await this.loadSample(sampleId);

this.scheduleLoadedSample(sampleId, options);
}

async startSampleLoop({
events,
loopEndTick = TICKS_PER_4_4_BAR,
loopStartTick = 0,
lookaheadMs,
ppq,
scheduleAheadTime,
tempoBpm,
}: StartSampleLoopOptions): Promise<TransportSnapshot> {
await this.resume();

const audioBuffer = await this.loadSample(sampleId);
await Promise.all(
Array.from(
new Set(events.map((event) => event.sampleId)),
(sampleId) => this.loadSample(sampleId),
),
);

this.stopLoop();

const audioContext = this.getOrCreateAudioContext();
this.sampleLoopScheduler = new LookaheadScheduler<SampleLoopEvent>({
events,
getAudioTime: () => audioContext.currentTime,
lookaheadMs,
loopEndTick,
loopStartTick,
ppq,
scheduleAheadTime,
scheduleEvent: ({ audioTime, event }) => {
this.scheduleLoadedSample(event.sampleId, {
gain: event.gain,
when: audioTime,
});
},
tempoBpm,
});

return this.sampleLoopScheduler.start();
}

stopLoop(): TransportSnapshot {
if (!this.sampleLoopScheduler) {
return this.getTransportSnapshot();
}

const snapshot = this.sampleLoopScheduler.stop();
this.sampleLoopScheduler = null;
return snapshot;
}

private scheduleLoadedSample(
sampleId: SampleId,
options: PlaySampleOptions = {},
): void {
const audioContext = this.getOrCreateAudioContext();
const audioBuffer = this.sampleCache.get(sampleId);

if (!audioBuffer) {
throw new Error(`Sample "${sampleId}" must be loaded before scheduling.`);
}

const sourceNode = audioContext.createBufferSource();
const gainNode = audioContext.createGain();

Expand Down
16 changes: 16 additions & 0 deletions src/audio/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
export { BrowserAudioEngine, createAudioEngine } from "./browser-audio-engine";
export { BUNDLED_DRUM_SAMPLES } from "./bundled-samples";
export {
LookaheadScheduler,
collectScheduledEventsForWindow,
getLoopTickAtAbsoluteTick,
} from "./lookahead-scheduler";
export type {
LookaheadSchedulerOptions,
ScheduledTickEvent,
ScheduleWindowOptions,
SchedulerSnapshot,
SchedulerStatus,
TickEvent,
} from "./lookahead-scheduler";
export type {
AudioEngine,
AudioEngineSnapshot,
BundledSampleMeta,
PlaySampleOptions,
SampleId,
SampleLoopEvent,
StartSampleLoopOptions,
TransportSnapshot,
} from "./types";
Loading
Loading