diff --git a/packages/renderer/src/audio/mixer.test.ts b/packages/renderer/src/audio/mixer.test.ts new file mode 100644 index 0000000000..74524a7a62 --- /dev/null +++ b/packages/renderer/src/audio/mixer.test.ts @@ -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); + }); +}); diff --git a/packages/renderer/src/audio/mixer.ts b/packages/renderer/src/audio/mixer.ts new file mode 100644 index 0000000000..7ab202d6cd --- /dev/null +++ b/packages/renderer/src/audio/mixer.ts @@ -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 { + 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 { + 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(); +}