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