Files
Genarrative/src/components/common/creativeAudioProcessing.test.ts
2026-06-06 22:56:53 +08:00

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);
});