import { afterEach, expect, test, vi } from 'vitest'; import { encodePcmChannelsToWavBlob, findAudibleFrameRange, normalizeAudioBufferSection, prepareCreativeAudioFileForLocalUse, } from './creativeAudioProcessing'; const originalAudioContext = globalThis.AudioContext; const originalCreateObjectUrl = URL.createObjectURL; afterEach(() => { globalThis.AudioContext = originalAudioContext; URL.createObjectURL = originalCreateObjectUrl; vi.restoreAllMocks(); }); function createAudioBufferStub( channels: number[][], sampleRate = 1000, ): AudioBuffer { return { length: channels[0]?.length ?? 0, numberOfChannels: channels.length, sampleRate, duration: (channels[0]?.length ?? 0) / sampleRate, getChannelData: (channel: number) => new Float32Array(channels[channel] ?? []), } as AudioBuffer; } function installAudioContextMock( decodeAudioData: (bytes: ArrayBuffer) => Promise, ) { globalThis.AudioContext = class { decodeAudioData = decodeAudioData; close = vi.fn(); } as unknown as typeof AudioContext; } test('prepareCreativeAudioFileForLocalUse rejects empty audio files', async () => { await expect( prepareCreativeAudioFileForLocalUse( new File([], 'empty.webm', { type: 'audio/webm' }), 'uploaded', ), ).rejects.toThrow('音频文件为空,请重新选择。'); }); test('prepareCreativeAudioFileForLocalUse rejects non-audio files', async () => { await expect( prepareCreativeAudioFileForLocalUse( new File(['not-audio'], 'note.txt', { type: 'text/plain' }), 'uploaded', ), ).rejects.toThrow('请选择音频文件。'); }); test('prepareCreativeAudioFileForLocalUse reports decode failures', async () => { installAudioContextMock(async () => { throw new Error('decode failed'); }); await expect( prepareCreativeAudioFileForLocalUse( new File(['audio-bytes'], 'broken.webm', { type: 'audio/webm' }), 'uploaded', ), ).rejects.toThrow('音频解码失败,请重新选择。'); }); test('prepareCreativeAudioFileForLocalUse rejects when AudioContext is unavailable', async () => { globalThis.AudioContext = undefined as unknown as typeof AudioContext; await expect( prepareCreativeAudioFileForLocalUse( new File(['audio-bytes'], 'hit.webm', { type: 'audio/webm' }), 'uploaded', ), ).rejects.toThrow('当前浏览器不支持音频处理。'); }); test('prepareCreativeAudioFileForLocalUse rejects all-silent audio', async () => { installAudioContextMock(async () => createAudioBufferStub([[0, 0.001, 0]], 1000)); await expect( prepareCreativeAudioFileForLocalUse( new File(['audio-bytes'], 'silent.webm', { type: 'audio/webm' }), 'recorded', ), ).rejects.toThrow('音频声音过小,请重新录制或上传。'); }); test('prepareCreativeAudioFileForLocalUse allows audio exactly at the visible limit', async () => { URL.createObjectURL = vi.fn(() => 'blob:one-second-audio'); installAudioContextMock(async () => createAudioBufferStub([Array.from({ length: 1000 }, () => 0.2)], 1000), ); const asset = await prepareCreativeAudioFileForLocalUse( new File(['audio-bytes'], 'one-second.webm', { type: 'audio/webm' }), 'uploaded', ); expect(asset.durationMs).toBe(1000); expect(asset.audioSrc).toBe('blob:one-second-audio'); }); test('prepareCreativeAudioFileForLocalUse rejects audio longer than the visible limit after trimming', async () => { installAudioContextMock(async () => createAudioBufferStub([[0, ...Array.from({ length: 1001 }, () => 0.2), 0]], 1000), ); await expect( prepareCreativeAudioFileForLocalUse( new File(['audio-bytes'], 'long.webm', { type: 'audio/webm' }), 'uploaded', ), ).rejects.toThrow('音频最长 1 秒。'); }); test('findAudibleFrameRange trims quiet leading and trailing frames', () => { const buffer = createAudioBufferStub([ [0, 0.003, 0.02, 0.2, -0.03, 0.004], [0, 0, 0, 0.05, 0, 0], ]); expect(findAudibleFrameRange(buffer, 0.01)).toEqual({ startFrame: 2, frameCount: 3, }); }); test('normalizeAudioBufferSection pulls samples toward -15 LKFS approximation', () => { const buffer = createAudioBufferStub([[0.02, -0.02, 0.02, -0.02]], 1000); const normalized = normalizeAudioBufferSection( buffer, { startFrame: 0, frameCount: 4 }, { targetLkfs: -15, peakCeiling: 0.98 }, ); const channel = normalized[0]; expect(channel).toBeDefined(); const rms = Math.sqrt( channel!.reduce((sum, sample) => sum + sample * sample, 0) / channel!.length, ); expect(rms).toBeCloseTo(Math.pow(10, -15 / 20), 3); }); test('normalizeAudioBufferSection avoids clipping when target gain is too high', () => { const buffer = createAudioBufferStub([[0.8, -0.8, 0.4, -0.4]], 1000); const normalized = normalizeAudioBufferSection( buffer, { startFrame: 0, frameCount: 4 }, { targetLkfs: 0, peakCeiling: 0.5 }, ); const channel = normalized[0]; expect(channel).toBeDefined(); const peak = Math.max(...channel!.map((sample) => Math.abs(sample))); expect(peak).toBeLessThanOrEqual(0.5); }); test('normalizeAudioBufferSection rejects zero-energy sections', () => { const buffer = createAudioBufferStub([[0, 0, 0]], 1000); expect(() => normalizeAudioBufferSection(buffer, { startFrame: 0, frameCount: 3 }), ).toThrow('音频声音过小,请重新录制或上传。'); }); test('prepareCreativeAudioFileForLocalUse writes trimmed normalized wav blob', async () => { URL.createObjectURL = vi.fn(() => 'blob:processed-audio'); installAudioContextMock(async () => createAudioBufferStub([[0, 0, 0.12, -0.12, 0]], 1000), ); const asset = await prepareCreativeAudioFileForLocalUse( new File(['audio-bytes'], 'hit.webm', { type: 'audio/webm' }), 'uploaded', ); const bytes = await asset.blob.arrayBuffer(); expect(asset.fileName).toBe('hit.wav'); expect(asset.mimeType).toBe('audio/wav'); expect(asset.audioSrc).toBe('blob:processed-audio'); expect(asset.durationMs).toBe(2); expect(String.fromCharCode(...new Uint8Array(bytes, 0, 4))).toBe('RIFF'); expect(String.fromCharCode(...new Uint8Array(bytes, 8, 4))).toBe('WAVE'); }); test('prepareCreativeAudioFileForLocalUse still succeeds without object URL support', async () => { URL.createObjectURL = undefined as unknown as typeof URL.createObjectURL; installAudioContextMock(async () => createAudioBufferStub([[0.12, -0.12]], 1000), ); const asset = await prepareCreativeAudioFileForLocalUse( new File(['audio-bytes'], 'hit.webm', { type: 'audio/webm' }), 'recorded', ); expect(asset.audioSrc).toBe(''); expect(asset.previewUrl).toBe(''); }); test('prepareCreativeAudioFileForLocalUse normalizes processed wav file names', async () => { URL.createObjectURL = vi.fn(() => 'blob:processed-audio'); installAudioContextMock(async () => createAudioBufferStub([[0.12, -0.12]], 1000), ); await expect( prepareCreativeAudioFileForLocalUse( new File(['audio-bytes'], 'hit-sound', { type: 'audio/webm' }), 'uploaded', ), ).resolves.toMatchObject({ fileName: 'hit-sound.wav' }); await expect( prepareCreativeAudioFileForLocalUse( new File(['audio-bytes'], ' ', { type: 'audio/webm' }), 'uploaded', ), ).resolves.toMatchObject({ fileName: 'creative-audio.wav' }); }); test('encodePcmChannelsToWavBlob writes pcm16 wav bytes', async () => { const blob = encodePcmChannelsToWavBlob( [new Float32Array([0.25, -0.5])], 1000, ); const bytes = await blob.arrayBuffer(); const view = new DataView(bytes); expect(blob.type).toBe('audio/wav'); expect(view.getUint32(40, true)).toBe(4); expect(view.getInt16(44, true)).toBeCloseTo(8191, -1); expect(view.getInt16(46, true)).toBeCloseTo(-16384, -1); });