fix: delay wooden fish audio upload

This commit is contained in:
kdletters
2026-06-06 22:53:05 +08:00
parent d5b51a4242
commit ff7a2f6284
13 changed files with 1771 additions and 38 deletions

View File

@@ -0,0 +1,300 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import {
CreativeAudioInputPanel,
} from './CreativeAudioInputPanel';
import type { CreativeAudioAsset } from './creativeAudioFileAsset';
type TestAudioAsset = CreativeAudioAsset;
const originalMediaRecorder = globalThis.MediaRecorder;
const originalMediaDevices = navigator.mediaDevices;
afterEach(() => {
globalThis.MediaRecorder = originalMediaRecorder;
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: originalMediaDevices,
});
vi.restoreAllMocks();
});
function buildAsset(overrides: Partial<TestAudioAsset> = {}): TestAudioAsset {
return {
assetId: 'asset-test',
audioSrc: 'blob:audio-preview',
audioObjectKey: '',
assetObjectId: '',
source: 'uploaded',
prompt: 'hit.wav',
durationMs: 800,
...overrides,
};
}
function renderPanel(
overrides: Partial<ComponentProps<typeof CreativeAudioInputPanel<TestAudioAsset>>> = {},
) {
const onAssetChange = vi.fn();
const onError = vi.fn();
const readFileAsAsset = vi.fn(async (file: File, source: 'uploaded' | 'recorded') =>
buildAsset({
audioSrc: `blob:${source}`,
source,
prompt: file.name,
}),
);
const rendered = render(
<CreativeAudioInputPanel<TestAudioAsset>
title="敲击音效"
defaultLabel="默认木鱼音"
asset={null}
buildRecordedFileName={() => 'recorded-hit.webm'}
onAssetChange={onAssetChange}
onError={onError}
readFileAsAsset={readFileAsAsset}
{...overrides}
/>,
);
return { ...rendered, onAssetChange, onError, readFileAsAsset };
}
function getUploadInput() {
const input = screen
.getByText('上传')
.closest('label')
?.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
return input!;
}
test('音频面板按需显示最长限制标签', () => {
renderPanel({ limitLabel: '最长 1 秒' });
expect(screen.getByText('最长 1 秒')).toBeTruthy();
});
test('音频面板未传限制标签时不渲染限制提示', () => {
renderPanel();
expect(screen.queryByText('最长 1 秒')).toBeNull();
});
test('音频面板无资产时显示默认音效文案', () => {
renderPanel();
expect(screen.getByText('默认木鱼音')).toBeTruthy();
});
test('音频面板有预览地址时渲染 audio 控件', () => {
const { container } = render(
<CreativeAudioInputPanel<TestAudioAsset>
title="敲击音效"
defaultLabel="默认木鱼音"
asset={buildAsset({ audioSrc: 'blob:preview' })}
buildRecordedFileName={() => 'recorded-hit.webm'}
onAssetChange={() => {}}
onError={() => {}}
/>,
);
expect(container.querySelector('audio')?.getAttribute('src')).toBe(
'blob:preview',
);
});
test('音频面板有资产但无预览地址时显示已选择状态', () => {
renderPanel({ asset: buildAsset({ audioSrc: '' }) });
expect(screen.getByText('音效已选择')).toBeTruthy();
});
test('点击重置清空当前音频资产', () => {
const onAssetChange = vi.fn();
renderPanel({
asset: buildAsset(),
onAssetChange,
});
fireEvent.click(screen.getByRole('button', { name: '重置' }));
expect(onAssetChange).toHaveBeenCalledWith(null);
});
test('取消上传选择时不读取音频', () => {
const { readFileAsAsset, onAssetChange } = renderPanel();
fireEvent.change(getUploadInput(), { target: { files: [] } });
expect(readFileAsAsset).not.toHaveBeenCalled();
expect(onAssetChange).not.toHaveBeenCalled();
});
test('上传音频成功后清空错误并写入资产', async () => {
const audioFile = new File(['audio'], 'hit.webm', { type: 'audio/webm' });
const { readFileAsAsset, onAssetChange, onError } = renderPanel();
fireEvent.change(getUploadInput(), { target: { files: [audioFile] } });
await waitFor(() =>
expect(readFileAsAsset).toHaveBeenCalledWith(audioFile, 'uploaded'),
);
await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1));
expect(onError).toHaveBeenCalledWith(null);
});
test('上传音频失败时提示错误且不写入资产', async () => {
const readFileAsAsset = vi.fn(async () => {
throw new Error('音频最长 1 秒。');
});
const onAssetChange = vi.fn();
const onError = vi.fn();
renderPanel({ readFileAsAsset, onAssetChange, onError });
fireEvent.change(getUploadInput(), {
target: {
files: [new File(['audio'], 'hit.webm', { type: 'audio/webm' })],
},
});
await waitFor(() => expect(onError).toHaveBeenCalledWith('音频最长 1 秒。'));
expect(onAssetChange).not.toHaveBeenCalled();
});
test('浏览器不支持录音时提示错误', async () => {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: undefined,
});
globalThis.MediaRecorder = undefined as unknown as typeof MediaRecorder;
const { onError } = renderPanel();
fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() =>
expect(onError).toHaveBeenCalledWith('当前浏览器不支持录音。'),
);
});
test('录音启动失败时透传启动错误', async () => {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn(async () => {
throw new Error('麦克风拒绝授权。');
}),
},
});
globalThis.MediaRecorder = class {
start = vi.fn();
stop = vi.fn();
} as unknown as typeof MediaRecorder;
const { onError } = renderPanel();
fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() => expect(onError).toHaveBeenCalledWith('麦克风拒绝授权。'));
});
test('录音停止后按 recorded 来源读取音频', async () => {
const stopTrack = vi.fn();
const recorderInstances: Array<{
ondataavailable: ((event: BlobEvent) => void) | null;
onstop: (() => void) | null;
}> = [];
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn(async () => ({
getTracks: () => [{ stop: stopTrack }],
})),
},
});
globalThis.MediaRecorder = class {
mimeType = 'audio/webm';
ondataavailable: ((event: BlobEvent) => void) | null = null;
onstop: (() => void) | null = null;
constructor() {
recorderInstances.push(this);
}
start = vi.fn();
stop = vi.fn(() => {
this.ondataavailable?.({
data: new Blob(['recorded-audio'], { type: 'audio/webm' }),
} as BlobEvent);
this.onstop?.();
});
} as unknown as typeof MediaRecorder;
const { readFileAsAsset, onAssetChange } = renderPanel();
fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy());
fireEvent.click(screen.getByRole('button', { name: '停止' }));
await waitFor(() => expect(readFileAsAsset).toHaveBeenCalledTimes(1));
const [recordedFile, source] = readFileAsAsset.mock.calls[0]!;
expect(recordedFile).toBeInstanceOf(File);
expect((recordedFile as File).name).toBe('recorded-hit.webm');
expect(source).toBe('recorded');
expect(stopTrack).toHaveBeenCalledTimes(1);
await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1));
expect(recorderInstances).toHaveLength(1);
});
test('录音保存失败时提示错误', async () => {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn(async () => ({
getTracks: () => [],
})),
},
});
globalThis.MediaRecorder = class {
mimeType = 'audio/webm';
ondataavailable: ((event: BlobEvent) => void) | null = null;
onstop: (() => void) | null = null;
start = vi.fn();
stop = vi.fn(() => this.onstop?.());
} as unknown as typeof MediaRecorder;
const readFileAsAsset = vi.fn(async () => {
throw new Error('音频声音过小,请重新录制或上传。');
});
const onError = vi.fn();
renderPanel({ readFileAsAsset, onError });
fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy());
fireEvent.click(screen.getByRole('button', { name: '停止' }));
await waitFor(() =>
expect(onError).toHaveBeenCalledWith('音频声音过小,请重新录制或上传。'),
);
});
test('禁用状态不启动录音也不允许上传', () => {
const getUserMedia = vi.fn();
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia },
});
const { container } = renderPanel({ disabled: true });
fireEvent.click(screen.getByRole('button', { name: '录音' }));
expect(getUserMedia).not.toHaveBeenCalled();
const input = container.querySelector('input[type="file"]');
expect(input).not.toBeNull();
expect((input as HTMLInputElement).disabled).toBe(true);
});

View File

@@ -5,12 +5,12 @@ import {
type CreativeAudioAsset,
readCreativeAudioFileAsAsset,
} from './creativeAudioFileAsset';
import { trimLeadingSilenceFromRecordedAudioFile } from './creativeAudioSilenceTrim';
type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
disabled?: boolean;
title: string;
defaultLabel: string;
limitLabel?: string;
asset: TAsset | null;
buildRecordedFileName: () => string;
onAssetChange: (asset: TAsset | null) => void;
@@ -25,6 +25,7 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
disabled = false,
title,
defaultLabel,
limitLabel,
asset,
buildRecordedFileName,
onAssetChange,
@@ -64,8 +65,7 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
const file = new File([blob], buildRecordedFileName(), {
type: blob.type,
});
void trimLeadingSilenceFromRecordedAudioFile(file)
.then((trimmedFile) => readFileAsAsset(trimmedFile, 'recorded'))
void readFileAsAsset(file, 'recorded')
.then(onAssetChange)
.catch((caughtError) => {
onError(
@@ -95,8 +95,15 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
return (
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
{title}
<div className="flex min-w-0 items-center gap-2">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
{title}
</div>
{limitLabel ? (
<div className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-black text-[var(--platform-text-soft)]">
{limitLabel}
</div>
) : null}
</div>
{asset ? (
<button

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

View File

@@ -0,0 +1,308 @@
import {
type CreativeAudioAsset,
} from './creativeAudioFileAsset';
type BrowserAudioGlobal = typeof globalThis & {
webkitAudioContext?: typeof AudioContext;
};
export type CreativeAudioSource = 'uploaded' | 'recorded';
export type PendingCreativeAudioAsset = CreativeAudioAsset & {
fileName: string;
mimeType: string;
blob: Blob;
source: CreativeAudioSource;
previewUrl: string;
durationMs: number;
};
export type CreativeAudioProcessingOptions = {
maxDurationMs?: number;
silenceThreshold?: number;
targetLkfs?: number;
peakCeiling?: number;
};
export type AudibleFrameRange = {
startFrame: number;
frameCount: number;
};
const DEFAULT_MAX_DURATION_MS = 1000;
const DEFAULT_SILENCE_THRESHOLD = 0.01;
const DEFAULT_TARGET_LKFS = -15;
const DEFAULT_PEAK_CEILING = 0.98;
const WAV_HEADER_BYTE_LENGTH = 44;
const WAV_BITS_PER_SAMPLE = 16;
const WAV_BYTES_PER_SAMPLE = WAV_BITS_PER_SAMPLE / 8;
export async function prepareCreativeAudioFileForLocalUse(
file: File,
source: CreativeAudioSource,
options: CreativeAudioProcessingOptions = {},
): Promise<PendingCreativeAudioAsset> {
validateCreativeAudioFile(file);
const decodedBuffer = await decodeCreativeAudioFile(file);
const range = findAudibleFrameRange(
decodedBuffer,
options.silenceThreshold ?? DEFAULT_SILENCE_THRESHOLD,
);
if (!range) {
throw new Error('音频声音过小,请重新录制或上传。');
}
const durationMs = Math.round(
(range.frameCount / decodedBuffer.sampleRate) * 1000,
);
const maxDurationMs = options.maxDurationMs ?? DEFAULT_MAX_DURATION_MS;
if (durationMs > maxDurationMs) {
throw new Error(`音频最长 ${formatDurationSeconds(maxDurationMs)} 秒。`);
}
const normalized = normalizeAudioBufferSection(decodedBuffer, range, {
targetLkfs: options.targetLkfs ?? DEFAULT_TARGET_LKFS,
peakCeiling: options.peakCeiling ?? DEFAULT_PEAK_CEILING,
});
const blob = encodePcmChannelsToWavBlob(normalized, decodedBuffer.sampleRate);
const fileName = buildProcessedAudioFileName(file.name);
const previewUrl =
typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function'
? URL.createObjectURL(blob)
: '';
return {
assetId: `local-${source}-${Date.now()}`,
audioSrc: previewUrl,
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs,
fileName,
mimeType: blob.type,
blob,
previewUrl,
};
}
export function findAudibleFrameRange(
buffer: AudioBuffer,
silenceThreshold = DEFAULT_SILENCE_THRESHOLD,
): AudibleFrameRange | null {
const threshold = Math.max(0, silenceThreshold);
let startFrame: number | null = null;
let endFrame: number | null = null;
for (let frameIndex = 0; frameIndex < buffer.length; frameIndex += 1) {
if (isFrameAudible(buffer, frameIndex, threshold)) {
startFrame = frameIndex;
break;
}
}
if (startFrame === null) {
return null;
}
for (let frameIndex = buffer.length - 1; frameIndex >= startFrame; frameIndex -= 1) {
if (isFrameAudible(buffer, frameIndex, threshold)) {
endFrame = frameIndex;
break;
}
}
if (endFrame === null) {
return null;
}
return {
startFrame,
frameCount: endFrame - startFrame + 1,
};
}
export function normalizeAudioBufferSection(
buffer: AudioBuffer,
range: AudibleFrameRange,
options: Pick<CreativeAudioProcessingOptions, 'targetLkfs' | 'peakCeiling'> = {},
) {
const channelCount = Math.max(1, buffer.numberOfChannels);
const targetLkfs = options.targetLkfs ?? DEFAULT_TARGET_LKFS;
const peakCeiling = Math.max(0.01, options.peakCeiling ?? DEFAULT_PEAK_CEILING);
const channels = Array.from({ length: channelCount }, (_value, channelIndex) =>
copyChannelSection(buffer, channelIndex, range),
);
const stats = measurePcmStats(channels);
if (stats.rms <= 0 || stats.peak <= 0) {
throw new Error('音频声音过小,请重新录制或上传。');
}
// 浏览器端近似:用全通道 RMS 估算 LKFS再按 GY/T 377-2023 目标值拉到 -15 LKFS。
const targetLinear = Math.pow(10, targetLkfs / 20);
const loudnessGain = targetLinear / stats.rms;
const protectedGain = Math.min(loudnessGain, peakCeiling / stats.peak);
return channels.map((channel) =>
Float32Array.from(channel, (sample) => clampSample(sample * protectedGain)),
);
}
export function encodePcmChannelsToWavBlob(
channels: Float32Array[],
sampleRate: number,
) {
const channelCount = Math.max(1, channels.length);
const frameCount = channels[0]?.length ?? 0;
const dataByteLength = frameCount * channelCount * WAV_BYTES_PER_SAMPLE;
const output = new ArrayBuffer(WAV_HEADER_BYTE_LENGTH + dataByteLength);
const view = new DataView(output);
writeAscii(view, 0, 'RIFF');
view.setUint32(4, 36 + dataByteLength, true);
writeAscii(view, 8, 'WAVE');
writeAscii(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channelCount, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * channelCount * WAV_BYTES_PER_SAMPLE, true);
view.setUint16(32, channelCount * WAV_BYTES_PER_SAMPLE, true);
view.setUint16(34, WAV_BITS_PER_SAMPLE, true);
writeAscii(view, 36, 'data');
view.setUint32(40, dataByteLength, true);
let outputOffset = WAV_HEADER_BYTE_LENGTH;
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
const sample = channels[channelIndex]?.[frameIndex] ?? 0;
view.setInt16(outputOffset, toSignedPcm16(sample), true);
outputOffset += WAV_BYTES_PER_SAMPLE;
}
}
return new Blob([output], { type: 'audio/wav' });
}
function validateCreativeAudioFile(file: File) {
if (file.size <= 0) {
throw new Error('音频文件为空,请重新选择。');
}
if (!resolveFileMimeType(file).startsWith('audio/')) {
throw new Error('请选择音频文件。');
}
}
async function decodeCreativeAudioFile(file: File) {
const AudioContextConstructor = getAudioContextConstructor();
if (!AudioContextConstructor) {
throw new Error('当前浏览器不支持音频处理。');
}
const context = new AudioContextConstructor();
try {
const bytes = await file.arrayBuffer();
return await context.decodeAudioData(bytes.slice(0));
} catch {
throw new Error('音频解码失败,请重新选择。');
} finally {
void context.close();
}
}
function getAudioContextConstructor() {
const audioGlobal = globalThis as BrowserAudioGlobal;
return audioGlobal.AudioContext ?? audioGlobal.webkitAudioContext ?? null;
}
function resolveFileMimeType(file: File) {
if (file.type.trim()) {
return file.type.trim();
}
return '';
}
function isFrameAudible(
buffer: AudioBuffer,
frameIndex: number,
threshold: number,
) {
for (
let channelIndex = 0;
channelIndex < buffer.numberOfChannels;
channelIndex += 1
) {
const channelData = buffer.getChannelData(channelIndex);
if (Math.abs(channelData[frameIndex] ?? 0) > threshold) {
return true;
}
}
return false;
}
function copyChannelSection(
buffer: AudioBuffer,
channelIndex: number,
range: AudibleFrameRange,
) {
const source =
channelIndex < buffer.numberOfChannels
? buffer.getChannelData(channelIndex)
: new Float32Array(buffer.length);
const output = new Float32Array(range.frameCount);
for (let frameOffset = 0; frameOffset < range.frameCount; frameOffset += 1) {
output[frameOffset] = source[range.startFrame + frameOffset] ?? 0;
}
return output;
}
function measurePcmStats(channels: Float32Array[]) {
let sumSquares = 0;
let peak = 0;
let sampleCount = 0;
for (const channel of channels) {
for (const sample of channel) {
sumSquares += sample * sample;
peak = Math.max(peak, Math.abs(sample));
sampleCount += 1;
}
}
return {
rms: sampleCount > 0 ? Math.sqrt(sumSquares / sampleCount) : 0,
peak,
};
}
function clampSample(sample: number) {
return Math.max(-1, Math.min(1, sample));
}
function toSignedPcm16(sample: number) {
const clamped = clampSample(sample);
return clamped < 0
? Math.round(clamped * 0x8000)
: Math.round(clamped * 0x7fff);
}
function writeAscii(view: DataView, offset: number, value: string) {
for (let index = 0; index < value.length; index += 1) {
view.setUint8(offset + index, value.charCodeAt(index));
}
}
function buildProcessedAudioFileName(fileName: string) {
const normalizedName = fileName.trim();
if (!normalizedName) {
return 'creative-audio.wav';
}
return /\.[^.]+$/u.test(normalizedName)
? normalizedName.replace(/\.[^.]+$/u, '.wav')
: `${normalizedName}.wav`;
}
function formatDurationSeconds(durationMs: number) {
return Number.isInteger(durationMs / 1000)
? String(durationMs / 1000)
: (durationMs / 1000).toFixed(1);
}

View File

@@ -4,7 +4,12 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import { beforeEach, expect, test, vi } from 'vitest';
import { woodenFishClient } from '../../../services/wooden-fish/woodenFishClient';
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT } from '../../../services/wooden-fish/woodenFishDefaults';
import { uploadWoodenFishHitSoundAsset } from '../../../services/wooden-fish/woodenFishAssetClient';
import {
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
} from '../../../services/wooden-fish/woodenFishDefaults';
import { prepareCreativeAudioFileForLocalUse } from '../../common/creativeAudioProcessing';
import { WoodenFishCreationWorkspace } from './WoodenFishCreationWorkspace';
vi.mock('../../../services/wooden-fish/woodenFishClient', () => ({
@@ -13,8 +18,18 @@ vi.mock('../../../services/wooden-fish/woodenFishClient', () => ({
},
}));
vi.mock('../../../services/wooden-fish/woodenFishAssetClient', () => ({
uploadWoodenFishHitSoundAsset: vi.fn(),
}));
vi.mock('../../common/creativeAudioProcessing', () => ({
prepareCreativeAudioFileForLocalUse: vi.fn(),
}));
beforeEach(() => {
vi.mocked(woodenFishClient.createSession).mockReset();
vi.mocked(uploadWoodenFishHitSoundAsset).mockReset();
vi.mocked(prepareCreativeAudioFileForLocalUse).mockReset();
vi.mocked(woodenFishClient.createSession).mockResolvedValue({
session: {
sessionId: 'wooden-fish-session-test',
@@ -25,6 +40,30 @@ beforeEach(() => {
updatedAt: '2026-05-24T00:00:00Z',
},
});
vi.mocked(uploadWoodenFishHitSoundAsset).mockResolvedValue({
assetId: 'uploaded-hit-sound-asset',
audioSrc: '/generated-wooden-fish-assets/draft/hit-sound.webm',
audioObjectKey: 'generated-wooden-fish-assets/draft/hit-sound.webm',
assetObjectId: 'asset-object-hit-sound',
source: 'uploaded',
prompt: 'hit-sound.webm',
durationMs: null,
});
vi.mocked(prepareCreativeAudioFileForLocalUse).mockImplementation(
async (file, source) => ({
assetId: 'local-hit-sound-asset',
audioSrc: 'blob:local-hit-sound',
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs: 800,
fileName: 'hit-sound.wav',
mimeType: 'audio/wav',
blob: new Blob(['processed-audio-bytes'], { type: 'audio/wav' }),
previewUrl: 'blob:local-hit-sound',
}),
);
});
test('敲什么输入栏初始置空但提交时仍使用默认生成提示词', async () => {
@@ -103,10 +142,303 @@ test('敲击音效临时关闭提示词生成入口,仅保留上传和录音',
expect(section).not.toBeNull();
expect(within(section as HTMLElement).queryByText('音效描述')).toBeNull();
expect(within(section as HTMLElement).getByText('最长 1 秒')).toBeTruthy();
expect(within(section as HTMLElement).getByText('上传')).toBeTruthy();
expect(within(section as HTMLElement).getByText('录音')).toBeTruthy();
});
test('敲击音效上传后只生成本地待提交音频,点击生成时才上传 OSS', async () => {
const onSubmitted = vi.fn();
const audioFile = new File(['audio-bytes'], 'hit-sound.webm', {
type: 'audio/webm',
});
render(
<WoodenFishCreationWorkspace
onBack={() => {}}
onSubmitted={onSubmitted}
/>,
);
const input = screen
.getByText('上传')
.closest('label')
?.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
fireEvent.change(input as HTMLInputElement, {
target: { files: [audioFile] },
});
await waitFor(() =>
expect(prepareCreativeAudioFileForLocalUse).toHaveBeenCalledWith(
audioFile,
'uploaded',
),
);
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
expect(uploadWoodenFishHitSoundAsset).toHaveBeenCalledTimes(1);
const uploadCall = vi.mocked(uploadWoodenFishHitSoundAsset).mock.calls[0];
expect(uploadCall).toBeDefined();
const [uploadedFile, source] = uploadCall!;
expect(uploadedFile).toBeInstanceOf(File);
const uploadedAudioFile = uploadedFile as File;
expect(uploadedAudioFile.name).toBe('hit-sound.wav');
expect(uploadedFile.type).toBe('audio/wav');
expect(source).toBe('uploaded');
const payload = onSubmitted.mock.calls[0]?.[1];
expect(payload.hitSoundAsset).toMatchObject({
assetObjectId: 'asset-object-hit-sound',
audioSrc: '/generated-wooden-fish-assets/draft/hit-sound.webm',
});
expect(payload.hitSoundAsset.audioSrc).not.toContain('data:audio');
});
test('未选择敲击音效时生成不上传 OSS 且使用默认木鱼音', async () => {
const onSubmitted = vi.fn();
render(
<WoodenFishCreationWorkspace
onBack={() => {}}
onSubmitted={onSubmitted}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
expect(onSubmitted.mock.calls[0]?.[1].hitSoundAsset).toEqual(
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
);
});
test('敲击音效超过 1 秒时展示错误且不提交音频', async () => {
const onSubmitted = vi.fn();
vi.mocked(prepareCreativeAudioFileForLocalUse).mockRejectedValueOnce(
new Error('音频最长 1 秒。'),
);
render(
<WoodenFishCreationWorkspace
onBack={() => {}}
onSubmitted={onSubmitted}
/>,
);
const input = screen
.getByText('上传')
.closest('label')
?.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
fireEvent.change(input as HTMLInputElement, {
target: {
files: [
new File(['too-long-audio'], 'too-long.webm', {
type: 'audio/webm',
}),
],
},
});
expect(await screen.findByText('音频最长 1 秒。')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
expect(onSubmitted.mock.calls[0]?.[1].hitSoundAsset.audioSrc).not.toContain(
'data:audio',
);
});
test('敲击音效上传失败时停留在工作台且不创建 session', async () => {
vi.mocked(uploadWoodenFishHitSoundAsset).mockRejectedValueOnce(
new Error('上传敲击音效失败。'),
);
render(
<WoodenFishCreationWorkspace
onBack={() => {}}
onSubmitted={() => {}}
/>,
);
const input = screen
.getByText('上传')
.closest('label')
?.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
fireEvent.change(input as HTMLInputElement, {
target: {
files: [
new File(['audio-bytes'], 'hit-sound.webm', {
type: 'audio/webm',
}),
],
},
});
await waitFor(() =>
expect(prepareCreativeAudioFileForLocalUse).toHaveBeenCalledTimes(1),
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(await screen.findByText('上传敲击音效失败。')).toBeTruthy();
expect(woodenFishClient.createSession).not.toHaveBeenCalled();
});
test('创建木鱼草稿失败时停留工作台并展示错误', async () => {
vi.mocked(woodenFishClient.createSession).mockRejectedValueOnce(
new Error('创建敲木鱼共创会话失败'),
);
render(
<WoodenFishCreationWorkspace
onBack={() => {}}
onSubmitted={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(await screen.findByText('创建敲木鱼共创会话失败')).toBeTruthy();
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
});
test('提交中重复点击生成不会重复上传或创建 session', async () => {
let resolveUpload: (
value: Awaited<ReturnType<typeof uploadWoodenFishHitSoundAsset>>,
) => void = () => {};
vi.mocked(uploadWoodenFishHitSoundAsset).mockImplementationOnce(
() =>
new Promise<Awaited<ReturnType<typeof uploadWoodenFishHitSoundAsset>>>((resolve) => {
resolveUpload = resolve;
}),
);
render(
<WoodenFishCreationWorkspace
onBack={() => {}}
onSubmitted={() => {}}
/>,
);
const input = screen
.getByText('上传')
.closest('label')
?.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
fireEvent.change(input as HTMLInputElement, {
target: {
files: [
new File(['audio-bytes'], 'hit-sound.webm', {
type: 'audio/webm',
}),
],
},
});
await waitFor(() =>
expect(prepareCreativeAudioFileForLocalUse).toHaveBeenCalledTimes(1),
);
const submitButton = screen.getByRole('button', { name: '生成' });
fireEvent.click(submitButton);
fireEvent.click(submitButton);
expect(uploadWoodenFishHitSoundAsset).toHaveBeenCalledTimes(1);
expect(woodenFishClient.createSession).not.toHaveBeenCalled();
resolveUpload({
assetId: 'uploaded-hit-sound-asset',
audioSrc: '/generated-wooden-fish-assets/draft/hit-sound.webm',
audioObjectKey: 'generated-wooden-fish-assets/draft/hit-sound.webm',
assetObjectId: 'asset-object-hit-sound',
source: 'uploaded',
prompt: 'hit-sound.webm',
durationMs: null,
});
await waitFor(() => expect(woodenFishClient.createSession).toHaveBeenCalledTimes(1));
});
test('替换本地敲击音效时回收旧预览地址', async () => {
const revokeObjectURL = vi.fn();
URL.revokeObjectURL = revokeObjectURL;
vi.mocked(prepareCreativeAudioFileForLocalUse)
.mockResolvedValueOnce({
assetId: 'local-hit-sound-old',
audioSrc: 'blob:old-hit-sound',
audioObjectKey: '',
assetObjectId: '',
source: 'uploaded',
prompt: 'old.webm',
durationMs: 800,
fileName: 'old.wav',
mimeType: 'audio/wav',
blob: new Blob(['old-audio'], { type: 'audio/wav' }),
previewUrl: 'blob:old-hit-sound',
})
.mockResolvedValueOnce({
assetId: 'local-hit-sound-new',
audioSrc: 'blob:new-hit-sound',
audioObjectKey: '',
assetObjectId: '',
source: 'uploaded',
prompt: 'new.webm',
durationMs: 700,
fileName: 'new.wav',
mimeType: 'audio/wav',
blob: new Blob(['new-audio'], { type: 'audio/wav' }),
previewUrl: 'blob:new-hit-sound',
});
render(
<WoodenFishCreationWorkspace
onBack={() => {}}
onSubmitted={() => {}}
/>,
);
const input = screen
.getByText('上传')
.closest('label')
?.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
fireEvent.change(input as HTMLInputElement, {
target: {
files: [
new File(['old'], 'old.webm', {
type: 'audio/webm',
}),
],
},
});
await waitFor(() => expect(screen.getByText('重置')).toBeTruthy());
fireEvent.change(input as HTMLInputElement, {
target: {
files: [
new File(['new'], 'new.webm', {
type: 'audio/webm',
}),
],
},
});
await waitFor(() =>
expect(revokeObjectURL).toHaveBeenCalledWith('blob:old-hit-sound'),
);
});
test('敲击音效和功德词条不放进独立滚动窗', () => {
const { container } = render(
<WoodenFishCreationWorkspace

View File

@@ -13,12 +13,17 @@ import type {
WoodenFishWorkspaceCreateRequest,
} from '../../../../packages/shared/src/contracts/woodenFish';
import { readPuzzleReferenceImageAsDataUrl } from '../../../services/puzzleReferenceImage';
import { uploadWoodenFishHitSoundAsset } from '../../../services/wooden-fish/woodenFishAssetClient';
import { woodenFishClient } from '../../../services/wooden-fish/woodenFishClient';
import {
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
} from '../../../services/wooden-fish/woodenFishDefaults';
import { CreativeAudioInputPanel } from '../../common/CreativeAudioInputPanel';
import {
type PendingCreativeAudioAsset,
prepareCreativeAudioFileForLocalUse,
} from '../../common/creativeAudioProcessing';
import { CreativeImageInputPanel } from '../../common/CreativeImageInputPanel';
type WoodenFishCreationWorkspaceProps = {
@@ -36,7 +41,7 @@ type WoodenFishCreationWorkspaceProps = {
type WoodenFishWorkspaceFormState = {
hitObjectPrompt: string;
hitObjectReferenceImageSrc: string;
hitSoundAsset: WoodenFishAudioAsset | null;
hitSoundAsset: PendingCreativeAudioAsset | null;
floatingWords: string[];
};
@@ -132,6 +137,14 @@ export function WoodenFishCreationWorkspace({
setLocalError(null);
try {
const hitSoundAsset: WoodenFishAudioAsset = formState.hitSoundAsset
? await uploadWoodenFishHitSoundAsset(
new File([formState.hitSoundAsset.blob], formState.hitSoundAsset.fileName, {
type: formState.hitSoundAsset.mimeType,
}),
formState.hitSoundAsset.source,
)
: WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET;
const payload: WoodenFishWorkspaceCreateRequest = {
templateId: 'wooden-fish',
workTitle: '',
@@ -143,8 +156,7 @@ export function WoodenFishCreationWorkspace({
hitObjectReferenceImageSrc:
formState.hitObjectReferenceImageSrc.trim() || null,
hitSoundPrompt: null,
hitSoundAsset:
formState.hitSoundAsset ?? WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
hitSoundAsset,
floatingWords: normalizedFloatingWords,
};
const response = await woodenFishClient.createSession(payload);
@@ -246,18 +258,28 @@ export function WoodenFishCreationWorkspace({
</div>
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
<CreativeAudioInputPanel<WoodenFishAudioAsset>
<CreativeAudioInputPanel<PendingCreativeAudioAsset>
disabled={isBusy || isSubmitting}
title="敲击音效"
defaultLabel="默认木鱼音"
limitLabel="最长 1 秒"
asset={formState.hitSoundAsset}
buildRecordedFileName={() => `wooden-fish-hit-${Date.now()}.webm`}
onAssetChange={(asset) =>
readFileAsAsset={prepareCreativeAudioFileForLocalUse}
onAssetChange={(asset) => {
if (
formState.hitSoundAsset?.previewUrl &&
formState.hitSoundAsset.previewUrl !== asset?.previewUrl &&
typeof URL !== 'undefined' &&
typeof URL.revokeObjectURL === 'function'
) {
URL.revokeObjectURL(formState.hitSoundAsset.previewUrl);
}
setFormState((current) => ({
...current,
hitSoundAsset: asset,
}))
}
}));
}}
onError={setLocalError}
/>