fix: delay wooden fish audio upload
This commit is contained in:
241
src/components/common/creativeAudioProcessing.test.ts
Normal file
241
src/components/common/creativeAudioProcessing.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user