mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
4b1e46294e
commit
05ee085c36
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user