242 lines
7.7 KiB
TypeScript
242 lines
7.7 KiB
TypeScript
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<AudioBuffer>,
|
|
) {
|
|
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);
|
|
});
|