feat: add bounce-to-WAV export with offline rendering via Tone.Offline

This commit is contained in:
Xiaohan-Tian
2026-04-11 12:18:30 -07:00
parent d94b53dcf5
commit d4864426e3
6 changed files with 643 additions and 2 deletions
@@ -0,0 +1,147 @@
import { describe, it, expect } from 'vitest';
import { encodeWav } from './KGOfflineRenderer';
/**
* Create a minimal AudioBuffer-like object for testing.
* In the jsdom test environment, AudioBuffer is not available,
* so we create a plain object that matches the interface used by encodeWav.
*/
function createMockAudioBuffer(
options: { numberOfChannels: number; sampleRate: number; length: number },
channelData?: Float32Array[]
): AudioBuffer {
const channels = channelData ?? Array.from({ length: options.numberOfChannels }, () =>
new Float32Array(options.length)
);
return {
numberOfChannels: options.numberOfChannels,
sampleRate: options.sampleRate,
length: options.length,
duration: options.length / options.sampleRate,
getChannelData: (ch: number) => channels[ch],
} as unknown as AudioBuffer;
}
describe('encodeWav', () => {
it('should produce a valid RIFF/WAV header for stereo 44100Hz', () => {
const audioBuffer = createMockAudioBuffer({
numberOfChannels: 2,
sampleRate: 44100,
length: 100,
});
const result = encodeWav(audioBuffer);
const view = new DataView(result);
// RIFF header
expect(String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3))).toBe('RIFF');
expect(String.fromCharCode(view.getUint8(8), view.getUint8(9), view.getUint8(10), view.getUint8(11))).toBe('WAVE');
// File size field: total - 8
const dataSize = 100 * 2 * 2; // 100 frames * 2 channels * 2 bytes
expect(view.getUint32(4, true)).toBe(44 + dataSize - 8);
// fmt sub-chunk
expect(String.fromCharCode(view.getUint8(12), view.getUint8(13), view.getUint8(14), view.getUint8(15))).toBe('fmt ');
expect(view.getUint32(16, true)).toBe(16); // PCM sub-chunk size
expect(view.getUint16(20, true)).toBe(1); // audio format = PCM
expect(view.getUint16(22, true)).toBe(2); // channels
expect(view.getUint32(24, true)).toBe(44100); // sample rate
expect(view.getUint32(28, true)).toBe(44100 * 4); // byte rate (sampleRate * blockAlign)
expect(view.getUint16(32, true)).toBe(4); // block align (channels * bytesPerSample)
expect(view.getUint16(34, true)).toBe(16); // bits per sample
// data sub-chunk
expect(String.fromCharCode(view.getUint8(36), view.getUint8(37), view.getUint8(38), view.getUint8(39))).toBe('data');
expect(view.getUint32(40, true)).toBe(dataSize);
});
it('should produce correct header for mono 48000Hz', () => {
const audioBuffer = createMockAudioBuffer({
numberOfChannels: 1,
sampleRate: 48000,
length: 50,
});
const result = encodeWav(audioBuffer);
const view = new DataView(result);
expect(view.getUint16(22, true)).toBe(1); // 1 channel
expect(view.getUint32(24, true)).toBe(48000); // sample rate
expect(view.getUint16(32, true)).toBe(2); // block align (1 * 2)
expect(view.getUint32(28, true)).toBe(48000 * 2); // byte rate
expect(view.getUint32(40, true)).toBe(50 * 2); // data size
});
it('should have correct total buffer size', () => {
const audioBuffer = createMockAudioBuffer({
numberOfChannels: 2,
sampleRate: 44100,
length: 200,
});
const result = encodeWav(audioBuffer);
// 44 header + 200 frames * 2 channels * 2 bytes
expect(result.byteLength).toBe(44 + 200 * 2 * 2);
});
it('should correctly convert float32 samples to int16', () => {
const left = new Float32Array([0, 1, -1, 0.5, -0.5]);
const right = new Float32Array([0, -1, 1, -0.5, 0.5]);
const audioBuffer = createMockAudioBuffer(
{ numberOfChannels: 2, sampleRate: 44100, length: 5 },
[left, right]
);
const result = encodeWav(audioBuffer);
const view = new DataView(result);
// Sample data starts at offset 44, interleaved L/R as int16 LE
// Sample 0: L=0 → 0, R=0 → 0
expect(view.getInt16(44, true)).toBe(0);
expect(view.getInt16(46, true)).toBe(0);
// Sample 1: L=1.0 → 32767, R=-1.0 → -32768
expect(view.getInt16(48, true)).toBe(32767);
expect(view.getInt16(50, true)).toBe(-32768);
// Sample 2: L=-1.0 → -32768, R=1.0 → 32767
expect(view.getInt16(52, true)).toBe(-32768);
expect(view.getInt16(54, true)).toBe(32767);
// Sample 3: L=0.5 → ~16383, R=-0.5 → ~-16384
expect(view.getInt16(56, true)).toBeCloseTo(16383, -1);
expect(view.getInt16(58, true)).toBeCloseTo(-16384, -1);
});
it('should clamp values outside [-1, 1]', () => {
const data = new Float32Array([1.5, -1.5]);
const audioBuffer = createMockAudioBuffer(
{ numberOfChannels: 1, sampleRate: 44100, length: 2 },
[data]
);
const result = encodeWav(audioBuffer);
const view = new DataView(result);
// 1.5 clamped to 1.0 → 32767
expect(view.getInt16(44, true)).toBe(32767);
// -1.5 clamped to -1.0 → -32768
expect(view.getInt16(46, true)).toBe(-32768);
});
it('should handle zero-length audio', () => {
const audioBuffer = createMockAudioBuffer({
numberOfChannels: 2,
sampleRate: 44100,
length: 0,
});
const result = encodeWav(audioBuffer);
expect(result.byteLength).toBe(44); // header only
const view = new DataView(result);
expect(view.getUint32(40, true)).toBe(0); // data size = 0
});
});