import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { AudioEngine } from '../src/audio/AudioEngine'; import { AudioFileError, MAX_FILE_BYTES, decodeAudioFile, downmixToMono, formatDuration, } from '../src/audio/userAudio'; import { MockAudioContext, installMockAudio } from './mockAudioContext'; const ctx = new MockAudioContext() as unknown as BaseAudioContext; /** A File whose bytes are controllable; first byte 0 means "undecodable". */ function fakeFile(name: string, bytes: number[], size?: number): File { const data = new Uint8Array(bytes); return { name, size: size ?? data.length, arrayBuffer: async () => data.buffer, } as unknown as File; } describe('downmix to mono', () => { it('averages the channels', () => { const stereo = ctx.createBuffer(2, 4, 48000); stereo.getChannelData(0).set([1, 0, 0.5, -1]); stereo.getChannelData(1).set([0, 1, 0.5, -1]); const mono = downmixToMono(ctx, stereo); expect(mono.numberOfChannels).toBe(1); // Averages to [0.5, 0.5, 0.5, -1], then the −1 peak is pulled to 0.98. const data = mono.getChannelData(0); expect(data[0]).toBeCloseTo(0.49, 5); expect(data[1]).toBeCloseTo(0.49, 5); expect(data[2]).toBeCloseTo(0.49, 5); expect(data[3]).toBeCloseTo(-0.98, 5); }); it('leaves a mono buffer untouched', () => { const source = ctx.createBuffer(1, 4, 48000); expect(downmixToMono(ctx, source)).toBe(source); }); it('pulls the peak back under unity', () => { const stereo = ctx.createBuffer(2, 2, 48000); stereo.getChannelData(0).set([1, 1]); stereo.getChannelData(1).set([1, 1]); const mono = downmixToMono(ctx, stereo); // Float32 rounding can land a hair above the target, hence the epsilon. for (const sample of mono.getChannelData(0)) expect(Math.abs(sample)).toBeLessThan(0.981); }); it('preserves length and sample rate', () => { const stereo = ctx.createBuffer(2, 777, 44100); const mono = downmixToMono(ctx, stereo); expect(mono.length).toBe(777); expect(mono.sampleRate).toBe(44100); }); }); describe('decoding a file', () => { it('returns a mono buffer for good audio', async () => { const buffer = await decodeAudioFile(ctx, fakeFile('song.mp3', [1, 2, 3, 4])); expect(buffer.numberOfChannels).toBe(1); expect(buffer.length).toBeGreaterThan(0); }); it('rejects an empty file with a readable message', async () => { await expect(decodeAudioFile(ctx, fakeFile('empty.mp3', []))).rejects.toBeInstanceOf( AudioFileError ); }); it('rejects an absurdly large file before reading it', async () => { const huge = fakeFile('huge.wav', [1], MAX_FILE_BYTES + 1); await expect(decodeAudioFile(ctx, huge)).rejects.toThrow(/MB/); }); it('turns a decoder failure into advice, not a DOMException', async () => { const bad = fakeFile('notes.txt', [0, 0, 0]); await expect(decodeAudioFile(ctx, bad)).rejects.toThrow(/MP3, WAV, FLAC/); await expect(decodeAudioFile(ctx, bad)).rejects.toBeInstanceOf(AudioFileError); }); it('names the offending file in the error', async () => { await expect(decodeAudioFile(ctx, fakeFile('holiday.docx', [0]))).rejects.toThrow( /holiday\.docx/ ); }); }); describe('duration formatting', () => { it('renders minutes and seconds', () => { expect(formatDuration(0)).toBe('0:00'); expect(formatDuration(9)).toBe('0:09'); expect(formatDuration(227)).toBe('3:47'); expect(formatDuration(3600)).toBe('60:00'); }); it('survives nonsense', () => { expect(formatDuration(Number.NaN)).toBe('—'); expect(formatDuration(-5)).toBe('—'); }); }); describe('engine integration', () => { let audio: ReturnType; beforeEach(() => { audio = installMockAudio(); }); afterEach(() => { audio.restore(); }); it('loads a file, selects it, and reports it', async () => { const engine = new AudioEngine(); const info = await engine.loadUserAudio(fakeFile('track.flac', [1, 2, 3])); expect(info.name).toBe('track.flac'); expect(info.duration).toBeGreaterThan(0); expect(engine.getSettings().preset).toBe('file'); expect(engine.getUserAudio()?.name).toBe('track.flac'); }); it('creates the AudioContext on demand so a picker click can decode', async () => { const engine = new AudioEngine(); expect(engine.context).toBeNull(); await engine.loadUserAudio(fakeFile('track.wav', [1])); expect(engine.context).not.toBeNull(); }); it('plays the loaded buffer rather than a generated preset', async () => { const engine = new AudioEngine(); await engine.loadUserAudio(fakeFile('track.wav', [1])); await engine.start(); const ctxInstance = audio.instances[0]; const started = ctxInstance.started.at(-1) as unknown as { buffer: AudioBuffer }; expect(started.buffer).toBe( (engine as unknown as { userBuffer: AudioBuffer }).userBuffer ); expect(started.buffer.numberOfChannels).toBe(1); }); it('swaps to a second file while the first is playing', async () => { const engine = new AudioEngine(); await engine.loadUserAudio(fakeFile('one.wav', [1])); await engine.start(); const startsAfterFirst = audio.instances[0].started.length; await engine.loadUserAudio(fakeFile('two.wav', [2])); expect(engine.getUserAudio()?.name).toBe('two.wav'); // A second load must actually restart the source, not silently no-op // because the selected source id was already 'file'. expect(audio.instances[0].started.length).toBeGreaterThan(startsAfterFirst); }); it('leaves the source alone when a bad file is offered', async () => { const engine = new AudioEngine(); await engine.start(); await expect(engine.loadUserAudio(fakeFile('bad.txt', [0]))).rejects.toBeInstanceOf( AudioFileError ); expect(engine.getSettings().preset).toBe('sawtooth'); expect(engine.getUserAudio()).toBeNull(); }); it('ignores a request to select the file source when nothing is loaded', () => { const engine = new AudioEngine(); engine.update({ preset: 'file' }); expect(engine.getSettings().preset).toBe('sawtooth'); }); it('can switch back to a generated preset after loading a file', async () => { const engine = new AudioEngine(); await engine.loadUserAudio(fakeFile('track.wav', [1])); engine.update({ preset: 'pink' }); expect(engine.getSettings().preset).toBe('pink'); // The file stays loaded, so the dropdown entry remains valid. expect(engine.getUserAudio()?.name).toBe('track.wav'); }); });