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
2 changes: 2 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1589,6 +1589,7 @@ export function initSandboxRuntimeModular(): void {
onSetPlaybackRate: (rate) => {
applyPlaybackRate(rate);
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
webAudio.setRate(state.playbackRate);
},
onEnablePickMode: () => picker.enablePickMode(),
onDisablePickMode: () => picker.disablePickMode(),
Expand Down Expand Up @@ -1858,6 +1859,7 @@ export function initSandboxRuntimeModular(): void {
clock.now(),
vol * state.bridgeVolume,
gen,
state.playbackRate,
);
});
}
Expand Down
198 changes: 159 additions & 39 deletions packages/core/src/runtime/webAudioTransport.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,49 @@
import { describe, it, expect, vi } from "vitest";
import { WebAudioTransport } from "./webAudioTransport";

function createMockAudioContext(currentTime = 100) {
const startFn = vi.fn();
const sourceNode = {
buffer: null as AudioBuffer | null,
playbackRate: { value: 1 },
start: startFn,
stop: vi.fn(),
disconnect: vi.fn(),
connect: vi.fn(),
};
const gainNode = {
gain: { value: 1 },
connect: vi.fn(),
disconnect: vi.fn(),
};
const masterGain = {
gain: { value: 1 },
connect: vi.fn(),
};
const ctx = {
currentTime,
state: "running",
resume: vi.fn(),
createBufferSource: vi.fn(() => sourceNode),
createGain: vi.fn(() => gainNode),
destination: {},
close: vi.fn(),
};
return { ctx, sourceNode, gainNode, masterGain, startFn };
}

function setupTransport(currentTime = 100) {
const transport = new WebAudioTransport();
const mock = createMockAudioContext(currentTime);
(transport as unknown as { _ctx: unknown })._ctx = mock.ctx;
(transport as unknown as { _masterGain: unknown })._masterGain = mock.masterGain;
const gen = transport.startGeneration();
return { transport, mock, gen };
}

const mockBuffer = {} as AudioBuffer;
const mockEl = { muted: false } as HTMLMediaElement;

describe("WebAudioTransport", () => {
it("tracks play generation for async race prevention", () => {
const transport = new WebAudioTransport();
Expand Down Expand Up @@ -82,45 +125,6 @@ describe("WebAudioTransport", () => {
});

describe("schedulePlayback timing", () => {
function createMockAudioContext(currentTime = 100) {
const startFn = vi.fn();
const sourceNode = {
buffer: null as AudioBuffer | null,
start: startFn,
connect: vi.fn(),
};
const gainNode = {
gain: { value: 1 },
connect: vi.fn(),
};
const masterGain = {
gain: { value: 1 },
connect: vi.fn(),
};
const ctx = {
currentTime,
state: "running",
resume: vi.fn(),
createBufferSource: vi.fn(() => sourceNode),
createGain: vi.fn(() => gainNode),
destination: {},
close: vi.fn(),
};
return { ctx, sourceNode, gainNode, masterGain, startFn };
}

function setupTransport(currentTime = 100) {
const transport = new WebAudioTransport();
const mock = createMockAudioContext(currentTime);
(transport as unknown as { _ctx: unknown })._ctx = mock.ctx;
(transport as unknown as { _masterGain: unknown })._masterGain = mock.masterGain;
const gen = transport.startGeneration();
return { transport, mock, gen };
}

const mockBuffer = {} as AudioBuffer;
const mockEl = { muted: false } as HTMLMediaElement;

it("starts in-progress clips immediately with correct buffer offset", async () => {
const { transport, mock, gen } = setupTransport(100);

Expand Down Expand Up @@ -161,4 +165,120 @@ describe("WebAudioTransport", () => {
expect(mock.startFn).toHaveBeenCalledWith(0, 0);
});
});

describe("playback rate", () => {
it("sets sourceNode.playbackRate.value when rate is provided", async () => {
const { transport, mock, gen } = setupTransport(100);

await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);

expect(mock.sourceNode.playbackRate.value).toBe(2);
});

it("defaults rate to 1 when not provided", async () => {
const { transport, mock, gen } = setupTransport(100);

await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen);

expect(mock.sourceNode.playbackRate.value).toBe(1);
});

it("scales delay by rate for future clips so they fire at the right wallclock", async () => {
const { transport, mock, gen } = setupTransport(100);

// compStart=10, compositionTime=2, rate=2 → 8s of comp time = 4s wallclock
await transport.schedulePlayback(mockEl, mockBuffer, 10, 0, 2, 1, gen, 2);

expect(mock.startFn).toHaveBeenCalledWith(104, 0);
});

it("keeps in-progress buffer offset at elapsed + mediaStart regardless of rate", async () => {
const { transport, mock, gen } = setupTransport(100);

await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);

expect(mock.startFn).toHaveBeenCalledWith(0, 3);
});

it("setRate updates active sources in place", async () => {
const { transport, mock, gen } = setupTransport(100);

await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1);
expect(mock.sourceNode.playbackRate.value).toBe(1);

transport.setRate(2);

expect(mock.sourceNode.playbackRate.value).toBe(2);
});

it("setRate before any sources are scheduled does not throw", () => {
const transport = new WebAudioTransport();
expect(() => transport.setRate(2)).not.toThrow();
});

it("setRate is a no-op when the rate is unchanged", async () => {
const { transport, mock, gen } = setupTransport(100);
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);

mock.ctx.currentTime = 100.5;
const timeBefore = transport.getTime();
transport.setRate(2);
const timeAfter = transport.getTime();

expect(timeAfter).toBe(timeBefore);
// No re-anchor, so the next 0.5s of wallclock still maps to 1s of comp time.
mock.ctx.currentTime = 101;
expect(transport.getTime()).toBeCloseTo(10, 10);
});

it("setRate clamps non-finite or non-positive values to 1", async () => {
const { transport, mock, gen } = setupTransport(100);
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);
expect(mock.sourceNode.playbackRate.value).toBe(2);

transport.setRate(Number.NaN);
expect(mock.sourceNode.playbackRate.value).toBe(1);

transport.setRate(2);
transport.setRate(0);
expect(mock.sourceNode.playbackRate.value).toBe(1);

transport.setRate(2);
transport.setRate(-1);
expect(mock.sourceNode.playbackRate.value).toBe(1);
});

it("getTime advances at the configured rate", async () => {
const { transport, mock, gen } = setupTransport(100);

await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);

// At schedule time, ctx.currentTime=100, compositionTime=8.
expect(transport.getTime()).toBeCloseTo(8, 10);

// Advance the audio-context clock by 0.5 wallclock seconds; at rate=2,
// composition time should have advanced 1s.
mock.ctx.currentTime = 100.5;
expect(transport.getTime()).toBeCloseTo(9, 10);
});

it("getTime tracks composition time after a mid-playback setRate", async () => {
const { transport, mock, gen } = setupTransport(100);

await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1);
expect(transport.getTime()).toBeCloseTo(8, 10);

// 0.5s passes at rate=1 → composition time = 8.5
mock.ctx.currentTime = 100.5;
expect(transport.getTime()).toBeCloseTo(8.5, 10);

// Bump rate to 2 — composition time should NOT jump.
transport.setRate(2);
expect(transport.getTime()).toBeCloseTo(8.5, 10);

// Another 0.5s wallclock at rate=2 → composition time = 9.5
mock.ctx.currentTime = 101;
expect(transport.getTime()).toBeCloseTo(9.5, 10);
});
});
});
47 changes: 43 additions & 4 deletions packages/core/src/runtime/webAudioTransport.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { swallow } from "./diagnostics";

function normalizeRate(rate: number): number {
if (!Number.isFinite(rate) || rate <= 0) return 1;
return rate;
}

export type ScheduledSource = {
el: HTMLMediaElement;
sourceNode: AudioBufferSourceNode;
Expand All @@ -15,7 +20,12 @@ export class WebAudioTransport {
private _bufferCache = new Map<string, AudioBuffer>();
private _activeSources: ScheduledSource[] = [];
private _masterGain: GainNode | null = null;
private _scheduleOffset = 0;
// Composition-time reference frame: at AudioContext time `_rateAnchorCtx`,
// composition time was `_rateAnchorComp`, and time has been advancing at
// `_rate` composition-seconds per wallclock-second since.
private _rateAnchorCtx = 0;
private _rateAnchorComp = 0;
private _rate = 1;
private _paused = true;
private _playGeneration = 0;

Expand All @@ -36,7 +46,7 @@ export class WebAudioTransport {

getTime(): number {
if (!this._ctx || this._paused) return -1;
return this._ctx.currentTime - this._scheduleOffset;
return this._rateAnchorComp + (this._ctx.currentTime - this._rateAnchorCtx) * this._rate;
}

async decodeAudioElement(el: HTMLMediaElement): Promise<AudioBuffer | null> {
Expand Down Expand Up @@ -73,6 +83,7 @@ export class WebAudioTransport {
compositionTime: number,
volume: number,
generation: number,
rate = 1,
): Promise<ScheduledSource | null> {
if (!this._ctx || !this._masterGain) return null;
if (generation !== this._playGeneration) return null;
Expand All @@ -83,8 +94,11 @@ export class WebAudioTransport {
}
if (generation !== this._playGeneration) return null;

const safeRate = normalizeRate(rate);

const sourceNode = this._ctx.createBufferSource();
sourceNode.buffer = buffer;
sourceNode.playbackRate.value = safeRate;

const gainNode = this._ctx.createGain();
gainNode.gain.value = volume;
Expand All @@ -93,12 +107,14 @@ export class WebAudioTransport {

const elapsed = compositionTime - compositionStart;
const scheduledAt = this._ctx.currentTime;
this._scheduleOffset = scheduledAt - compositionTime;
this._rate = safeRate;
this._rateAnchorCtx = scheduledAt;
this._rateAnchorComp = compositionTime;

if (elapsed >= 0) {
sourceNode.start(0, elapsed + mediaStart);
} else {
const delay = -elapsed;
const delay = -elapsed / safeRate;
sourceNode.start(scheduledAt + delay, mediaStart);
}

Expand All @@ -123,6 +139,29 @@ export class WebAudioTransport {
}
}

/**
* Rebases the composition-time reference frame before swapping rate so
* `getTime()` stays continuous across the change. Sources scheduled to
* start in the future keep their original wallclock start time — callers
* that need rate-correct future starts should `stopAll()` and reschedule.
*/
setRate(rate: number): void {
const safeRate = normalizeRate(rate);
if (safeRate === this._rate) return;
if (this._ctx && !this._paused) {
this._rateAnchorComp = this.getTime();
this._rateAnchorCtx = this._ctx.currentTime;
}
this._rate = safeRate;
for (const source of this._activeSources) {
try {
source.sourceNode.playbackRate.value = safeRate;
} catch (err) {
swallow("webAudioTransport.setRate", err);
}
}
}

stopAll(): void {
for (const source of this._activeSources) {
try {
Expand Down
Loading