feat(renderer): add OfflineAudioContext-based audio mixer

Implements mixAudio() with TDD: decodes audio sources, applies volume/timing
via GainNode, and renders a mixed PCM AudioBuffer using OfflineAudioContext.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-04-10 05:24:44 +02:00
co-authored by Claude Sonnet 4.6
parent 4b1e46294e
commit 05ee085c36
2 changed files with 120 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from "vitest";
import type { AudioMixConfig } from "../types.js";
// Mock OfflineAudioContext for jsdom environment
if (typeof globalThis.OfflineAudioContext === "undefined") {
globalThis.OfflineAudioContext = vi
.fn()
.mockImplementation((channels: number, length: number, sampleRate: number) => ({
createBufferSource: () => ({
buffer: null,
connect: vi.fn(),
start: vi.fn(),
}),
createGain: () => ({
gain: { value: 1 },
connect: vi.fn(),
}),
destination: {},
decodeAudioData: vi.fn().mockResolvedValue({
duration: 1,
numberOfChannels: channels,
sampleRate,
getChannelData: () => new Float32Array(length),
}),
startRendering: vi.fn().mockResolvedValue({
duration: length / sampleRate,
numberOfChannels: channels,
sampleRate,
length,
getChannelData: () => new Float32Array(length),
}),
})) as unknown as typeof OfflineAudioContext;
}
// Dynamic import AFTER mock is set up
const { mixAudio } = await import("./mixer.js");
describe("mixAudio", () => {
it("returns silent audio when no sources provided", async () => {
const config: AudioMixConfig = {
duration: 1.0,
sampleRate: 44100,
channels: 2,
sources: [],
};
const result = await mixAudio(config);
expect(result.sampleRate).toBe(44100);
expect(result.channels).toBe(2);
});
it("uses default sample rate and channels", async () => {
const result = await mixAudio({ duration: 0.5, sources: [] });
expect(result.sampleRate).toBe(44100);
expect(result.channels).toBe(2);
});
});
+64
View File
@@ -0,0 +1,64 @@
/**
* Audio Mixer
*
* Mixes audio sources using OfflineAudioContext.
* Decodes audio files, applies volume/timing offsets,
* and renders to a single AudioBuffer (PCM).
*/
import type { AudioMixConfig, AudioMixResult } from "../types.js";
const DEFAULT_SAMPLE_RATE = 44100;
const DEFAULT_CHANNELS = 2;
export async function mixAudio(config: AudioMixConfig): Promise<AudioMixResult> {
const sampleRate = config.sampleRate ?? DEFAULT_SAMPLE_RATE;
const channels = config.channels ?? DEFAULT_CHANNELS;
const totalSamples = Math.ceil(config.duration * sampleRate);
const offlineCtx = new OfflineAudioContext(channels, totalSamples, sampleRate);
for (const source of config.sources) {
const arrayBuffer = await fetchAudioData(source.src);
const audioBuffer = await offlineCtx.decodeAudioData(arrayBuffer);
const bufferSource = offlineCtx.createBufferSource();
bufferSource.buffer = audioBuffer;
// Apply volume
const gainNode = offlineCtx.createGain();
gainNode.gain.value = source.volume ?? 1;
bufferSource.connect(gainNode);
gainNode.connect(offlineCtx.destination);
// Calculate timing
const mediaOffset = source.mediaOffset ?? 0;
const clipDuration = source.endTime - source.startTime;
if (mediaOffset > 0) {
bufferSource.start(source.startTime, mediaOffset, clipDuration);
} else {
bufferSource.start(source.startTime, 0, clipDuration);
}
}
const renderedBuffer = await offlineCtx.startRendering();
return {
buffer: renderedBuffer,
sampleRate,
channels,
};
}
async function fetchAudioData(src: string | Blob): Promise<ArrayBuffer> {
if (src instanceof Blob) {
return src.arrayBuffer();
}
const response = await fetch(src);
if (!response.ok) {
throw new Error(`Failed to fetch audio: ${src} (${response.status})`);
}
return response.arrayBuffer();
}