const TARGET_SAMPLE_RATE = 16_000; function mixToMono(buffer: AudioBuffer): AudioBuffer { if (buffer.numberOfChannels === 1) return buffer; const mono = new AudioBuffer({ length: buffer.length, numberOfChannels: 1, sampleRate: buffer.sampleRate, }); const out = mono.getChannelData(0); const channels = buffer.numberOfChannels; for (let ch = 0; ch < channels; ch += 1) { const data = buffer.getChannelData(ch); for (let i = 0; i < buffer.length; i += 1) { out[i] += data[i] / channels; } } return mono; } function encodeWav(audioBuffer: AudioBuffer): Blob { const channel = audioBuffer.getChannelData(0); const dataSize = channel.length * 2; const buffer = new ArrayBuffer(44 + dataSize); const view = new DataView(buffer); const writeString = (offset: number, value: string) => { for (let i = 0; i < value.length; i += 1) { view.setUint8(offset + i, value.charCodeAt(i)); } }; writeString(0, 'RIFF'); view.setUint32(4, 36 + dataSize, true); writeString(8, 'WAVE'); writeString(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true); view.setUint32(24, audioBuffer.sampleRate, true); view.setUint32(28, audioBuffer.sampleRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true); writeString(36, 'data'); view.setUint32(40, dataSize, true); let offset = 44; for (let i = 0; i < channel.length; i += 1) { const sample = Math.max(-1, Math.min(1, channel[i] ?? 0)); view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true); offset += 2; } return new Blob([buffer], { type: 'audio/wav' }); } /** 将浏览器录音转为 16kHz 单声道 WAV,提升 ASR 解码成功率。失败时返回原 blob。 */ export async function blobToWav16kMono(blob: Blob): Promise { if (!blob.size) return blob; const audioContext = new AudioContext(); try { const arrayBuffer = await blob.arrayBuffer(); let decoded: AudioBuffer; try { decoded = await audioContext.decodeAudioData(arrayBuffer.slice(0)); } catch { return blob; } const mono = mixToMono(decoded); const duration = mono.duration; if (!Number.isFinite(duration) || duration <= 0) return blob; const offline = new OfflineAudioContext( 1, Math.max(1, Math.ceil(duration * TARGET_SAMPLE_RATE)), TARGET_SAMPLE_RATE, ); const source = offline.createBufferSource(); source.buffer = mono; source.connect(offline.destination); source.start(0); const rendered = await offline.startRendering(); if (!rendered.length) return blob; return encodeWav(rendered); } finally { await audioContext.close().catch(() => undefined); } }