/** * voice-recorder.js — 跨端录音抽象层 * * PR-FRONTEND-VOICE-INPUT (2026-09-24) * 统一 H5 / 微信小程序 / App-Plus / 鸿蒙元服务 四端的录音接口, * 把麦克风采集 + 振幅事件 + 自动停止 + 资源回收 收敛到一处。 * * PR-FRONTEND-VOICE-WAV (2026-09-25) * H5 分支从 MediaRecorder (产出 webm/opus) 改为 AudioContext + ScriptProcessor * 采原始 PCM, 停止时手动封装成标准 WAV (PCM 16-bit LE mono, 16kHz). * 原因: Chrome MediaRecorder 只支持 webm/ogg 容器, 不支持 wav/mp3 录制; * 大多数 STT / 后端 Agent 框架无法解析 webm/opus. * 产 wav 后所有 STT 服务 (Whisper / 达摩院 / 腾讯 ASR / ...) 都吃. * * 平台分支: * - H5 : navigator.mediaDevices.getUserMedia → AudioContext * → ScriptProcessor 采 PCM Float32 → 停止时封装 WAV. * 振幅采样走 AnalyserNode (与采 PCM 并行, 不冲突). * - MP / APP / 鸿蒙: uni.getRecorderManager()(uni-app 鸿蒙 runtime 已映射) * 走 onFrameRecorded 推 progress 事件. * uni-app format 参数可指定 wav/mp3/aac. * * 统一事件: * progress → { level: 0..1, duration: ms } 持续采样, 用于驱动波形动画 * error → Error 录音失败 * * 统一结果 (stop() 解析): * { * filePath?: string, // MP/APP/鸿蒙: tempFilePath; H5: blob URL * blob?: Blob, // H5: WAV Blob (供 FileReader / uploadMedia 二次处理) * fileName?: string, // H5: voice_.wav; MP 按 uni 约定 * duration: number, // ms * mime: string, // 录音产物 MIME: H5='audio/wav', MP/APP/鸿蒙按 format * size: number, // bytes * cancelled: false * } * * 取消时 resolve(null);调用方拿到 null 时不要 push 到 pendingFiles。 */ const DEFAULT_MAX_DURATION_MS = 60 * 1000 // H5 录音参数: 16kHz mono PCM → 16-bit LE WAV // 16kHz/16-bit 是大多数 STT 服务 (Whisper / 达摩院 / 腾讯 ASR 等) 最常见的输入规格. const H5_SAMPLE_RATE = 16000 const H5_NUM_CHANNELS = 1 const H5_SCRIPT_PROCESSOR_BUFFER_SIZE = 4096 /** * 构造带 code 字段的 Error * - code: 稳定的英文枚举, 供调用方按 code 走 $lang() 翻译 * - message: 兜底英文 message, 用于开发/调试或未识别 code * 注意: voice-recorder.js 自身不持有 Vue 上下文, 翻译必须在调用方完成 */ function _err(code, message) { const e = new Error(message || code) e.code = code return e } /** * 检测当前运行环境 (避免编译器把所有分支都打进去) * - true / false 都是运行时结果;条件编译靠注释里的 #ifdef */ function isH5() { // #ifdef H5 return true // #endif // #ifndef H5 return false // #endif } function isMpOrApp() { // #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY return true // #endif // #ifndef MP-WEIXIN || APP-PLUS || MP-HARMONY return false // #endif } /** * 由 mime 反推文件扩展名 */ function mimeToExt(mime) { if (!mime) return 'wav' if (mime.indexOf('wav') !== -1) return 'wav' if (mime.indexOf('webm') !== -1) return 'webm' if (mime.indexOf('ogg') !== -1) return 'ogg' if (mime.indexOf('mp4') !== -1) return 'm4a' if (mime.indexOf('mpeg') !== -1) return 'mp3' if (mime.indexOf('aac') !== -1) return 'aac' return 'audio' } /** * 合并多个 Float32Array (PCM 帧缓冲) 为单个 Float32Array */ function mergeFloat32Arrays(arrays) { let totalLength = 0 for (let i = 0; i < arrays.length; i++) totalLength += arrays[i].length const out = new Float32Array(totalLength) let offset = 0 for (let i = 0; i < arrays.length; i++) { out.set(arrays[i], offset) offset += arrays[i].length } return out } /** * 将 Float32 PCM (-1..1) 编码为标准 WAV (PCM 16-bit LE, RIFF/WAVE 头). * 所有 STT 服务都吃这个格式; 浏览器 H5 录音不依赖 MediaRecorder 也能产出. * * 参考 RIFF/WAVE 规范: * https://docs.fileformat.com/audio/wav/ * http://soundfile.sapp.org/doc/WaveFormat/ */ function encodeWavPcm16LE(samples, sampleRate, numChannels) { const bytesPerSample = 2 // 16-bit const dataSize = samples.length * numChannels * bytesPerSample const buffer = new ArrayBuffer(44 + dataSize) const view = new DataView(buffer) // RIFF chunk descriptor (12 bytes) writeAscii(view, 0, 'RIFF') view.setUint32(4, 36 + dataSize, true) // ChunkSize = 文件总字节 - 8 writeAscii(view, 8, 'WAVE') // fmt subchunk (24 bytes) writeAscii(view, 12, 'fmt ') view.setUint32(16, 16, true) // Subchunk1Size for PCM = 16 view.setUint16(20, 1, true) // AudioFormat: 1 = PCM view.setUint16(22, numChannels, true) view.setUint32(24, sampleRate, true) view.setUint32(28, sampleRate * numChannels * bytesPerSample, true) // ByteRate view.setUint16(32, numChannels * bytesPerSample, true) // BlockAlign view.setUint16(34, 16, true) // BitsPerSample // data subchunk (8 + N bytes) writeAscii(view, 36, 'data') view.setUint32(40, dataSize, true) // PCM 样本: Float32 (-1..1) → Int16 LE (clamp 到 [-1, 1] 防溢出) let offset = 44 for (let i = 0; i < samples.length; i++) { let s = samples[i] if (s > 1) s = 1 else if (s < -1) s = -1 const int16 = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7FFF) view.setInt16(offset, int16, true) offset += 2 } return buffer } function writeAscii(view, offset, str) { for (let i = 0; i < str.length; i++) { view.setUint8(offset + i, str.charCodeAt(i)) } } /** * 创建录音器实例。 * * @param {object} [opts] * @param {number} [opts.maxDuration=60000] 自动停止时长 (ms) * @param {string} [opts.format] MP/APP/鸿蒙 'mp3'|'aac'|'wav' (uni-app 文档) * @param {number} [opts.sampleRate=16000] * @param {number} [opts.numberOfChannels=1] * @param {number} [opts.encodeBitRate=48000] * * @returns {VoiceRecorder} */ export function createVoiceRecorder(opts = {}) { return new VoiceRecorder(opts) } class VoiceRecorder { constructor(opts) { this._maxDuration = opts.maxDuration || DEFAULT_MAX_DURATION_MS this._format = opts.format || 'mp3' this._sampleRate = opts.sampleRate || 16000 this._channels = opts.numberOfChannels || 1 this._bitRate = opts.encodeBitRate || 48000 this._listeners = { progress: [], error: [] } this._isRecording = false this._startTs = 0 this._autoStopTimer = null // 平台相关句柄 this._h5 = null // { mediaRecorder, stream, audioCtx, analyser, chunks, mime, rafId } this._uniRec = null // uni.getRecorderManager() 返回值 } on(event, cb) { if (!this._listeners[event]) this._listeners[event] = [] this._listeners[event].push(cb) } off(event, cb) { const arr = this._listeners[event] if (!arr) return const idx = arr.indexOf(cb) if (idx !== -1) arr.splice(idx, 1) } _emit(event, payload) { const arr = this._listeners[event] if (!arr) return for (const cb of arr.slice()) { try { cb(payload) } catch (e) { /* swallow */ } } } get isRecording() { return this._isRecording } /** * 开始录音。 * resolve() 仅表示「启动成功」,真正的产物在 stop()/cancel() 中拿到。 */ start() { if (this._isRecording) { return Promise.reject(_err('ALREADY_STARTED', 'recorder already started')) } if (isH5()) { return this._startH5() } if (isMpOrApp()) { return this._startUni() } return Promise.reject(_err('NOT_SUPPORTED', 'voice input not supported on this platform')) } /** * 停止录音并返回产物。 * resolve(null) 表示已取消 / 无产物,调用方应忽略。 */ stop() { if (!this._isRecording) return Promise.resolve(null) if (isH5()) return this._stopH5() return this._stopUni() } /** * 中断录音并丢弃产物。 */ cancel() { if (!this._isRecording) { this._clearAutoStopTimer() return Promise.resolve() } if (isH5()) return this._cancelH5() return this._cancelUni() } // ===================== H5 实现 ===================== // PR-FRONTEND-VOICE-WAV (2026-09-25): 改为 AudioContext + ScriptProcessor 采 PCM, // 停止时手动封装标准 WAV. 不再依赖 MediaRecorder, 解决 Chrome 只能产 webm/opus // 导致后端 STT / Agent framework 无法解析的问题. // // 关键设计: // 1. PCM 采样率固定 16kHz mono, 浏览器会自动 resample 麦克风输入 // (实际由 AudioContext sampleRate 参数保证) // 2. ScriptProcessor 必须 connect 到 audioCtx.destination 才会触发 // onaudioprocess; 通过 GainNode(gain=0) 静音防止麦克风外放回声 // 3. 振幅采样走 AnalyserNode (与 PCM 采样并行, 不冲突) // 4. onaudioprocess 每帧必须拷贝 channelData (new Float32Array(input)), // 否则下一帧会覆盖导致 PCM 数据丢失 _startH5() { return new Promise((resolve, reject) => { if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { reject(_err('H5_NO_MIC_API', 'mic API not available (HTTPS or localhost required)')) return } const Ctx = window.AudioContext || window.webkitAudioContext if (!Ctx) { reject(_err('H5_NO_AUDIO_CTX', 'AudioContext not available')) return } const buffers = [] let stream = null let audioCtx = null let source = null let processor = null let analyser = null let silentGain = null let rafId = null // 清理 H5 资源 (失败 / 异常路径统一调用) const cleanup = () => { try { if (rafId) cancelAnimationFrame(rafId) } catch (e) { /* noop */ } try { if (processor) processor.disconnect() } catch (e) { /* noop */ } try { if (analyser) analyser.disconnect() } catch (e) { /* noop */ } try { if (source) source.disconnect() } catch (e) { /* noop */ } try { if (silentGain) silentGain.disconnect() } catch (e) { /* noop */ } try { if (audioCtx && audioCtx.state !== 'closed') audioCtx.close().catch(() => {}) } catch (e) { /* noop */ } try { if (stream) stream.getTracks().forEach(t => t.stop()) } catch (e) { /* noop */ } } navigator.mediaDevices.getUserMedia({ audio: { channelCount: H5_NUM_CHANNELS, // 注意: 部分浏览器会忽略 sampleRate 约束, 实际采样率以 AudioContext 为准 sampleRate: H5_SAMPLE_RATE, echoCancellation: true, noiseSuppression: true, autoGainControl: true } }) .then((s) => { stream = s audioCtx = new Ctx({ sampleRate: H5_SAMPLE_RATE }) // 麦克风 → ScriptProcessor → 静音 GainNode → destination // (ScriptProcessor 必须连到 destination 才会触发 onaudioprocess; // 用 GainNode(gain=0) 静音, 防止麦克风声音外放产生回声) source = audioCtx.createMediaStreamSource(stream) const BufferSize = H5_SCRIPT_PROCESSOR_BUFFER_SIZE processor = audioCtx.createScriptProcessor(BufferSize, H5_NUM_CHANNELS, H5_NUM_CHANNELS) processor.onaudioprocess = (e) => { const channelData = e.inputBuffer.getChannelData(0) // 必须拷贝: inputBuffer 数据会被下一帧覆盖, 不拷贝会导致 PCM 丢失 buffers.push(new Float32Array(channelData)) } silentGain = audioCtx.createGain() silentGain.gain.value = 0 source.connect(processor) processor.connect(silentGain) silentGain.connect(audioCtx.destination) // AnalyserNode 并行采振幅 (驱动波形动画) analyser = audioCtx.createAnalyser() analyser.fftSize = 256 source.connect(analyser) const freqBuf = new Uint8Array(analyser.frequencyBinCount) const tick = () => { if (!this._isRecording) return try { analyser.getByteFrequencyData(freqBuf) let sum = 0 for (let i = 0; i < freqBuf.length; i++) sum += freqBuf[i] const level = Math.min(1, sum / (freqBuf.length * 255)) this._emit('progress', { level, duration: Date.now() - this._startTs }) } catch (e) { /* noop */ } rafId = requestAnimationFrame(tick) } tick() this._h5 = { stream, audioCtx, source, processor, analyser, silentGain, rafId, buffers, sampleRate: audioCtx.sampleRate, cleanup } this._isRecording = true this._startTs = Date.now() this._setAutoStopTimer() resolve() }) .catch((err) => { // 麦克风权限拒绝 / 设备不可用 cleanup() reject(_err('H5_MIC_PERMISSION', (err && err.message) || 'mic permission denied or device unavailable')) }) }) } _stopH5() { return new Promise((resolve) => { const h5 = this._h5 if (!h5) { resolve(null); return } // 1) 拆 audio graph + 停流 (避免 onprocess 继续累积 buffer) try { h5.cleanup() } catch (e) { /* noop */ } // 2) 合并 PCM 帧缓冲 const sampleRate = h5.sampleRate || H5_SAMPLE_RATE const merged = mergeFloat32Arrays(h5.buffers || []) // 3) 编码标准 WAV (PCM 16-bit LE mono) const wavBuffer = encodeWavPcm16LE(merged, sampleRate, H5_NUM_CHANNELS) const blob = new Blob([wavBuffer], { type: 'audio/wav' }) this._h5 = null this._isRecording = false this._clearAutoStopTimer() if (merged.length === 0) { // 没有采到任何 PCM (例如用户没说话或立即 stop) resolve(null) return } const fileName = `voice_${Date.now()}.wav` const blobUrl = (typeof URL !== 'undefined' && URL.createObjectURL) ? URL.createObjectURL(blob) : '' resolve({ filePath: blobUrl, blob, fileName, duration: Date.now() - this._startTs, mime: 'audio/wav', size: blob.size, cancelled: false }) }) } _cancelH5() { return new Promise((resolve) => { const h5 = this._h5 if (!h5) { this._isRecording = false this._clearAutoStopTimer() resolve() return } try { h5.cleanup() } catch (e) { /* noop */ } this._h5 = null this._isRecording = false this._clearAutoStopTimer() resolve() }) } // ===================== uni-app (MP/APP/鸿蒙) 实现 ===================== _startUni() { return new Promise((resolve, reject) => { let rec = null try { rec = uni.getRecorderManager() } catch (e) { reject(_err('NO_RECORDER_MANAGER', 'uni.getRecorderManager unavailable')) return } if (!rec) { reject(_err('NO_RECORDER_MANAGER', 'recorder manager unavailable')) return } this._uniRec = rec rec.onStart(() => { this._isRecording = true this._startTs = Date.now() this._setAutoStopTimer() resolve() }) rec.onStop((res) => { // res = { tempFilePath, duration, fileSize } this._uniRec = null this._isRecording = false this._clearAutoStopTimer() const mime = this._format === 'wav' ? 'audio/wav' : this._format === 'aac' ? 'audio/aac' : 'audio/mpeg' if (this._uniResolve) { const r = this._uniResolve this._uniResolve = null r({ filePath: res.tempFilePath, blob: null, fileName: '', duration: typeof res.duration === 'number' ? res.duration : (Date.now() - this._startTs), mime, size: res.fileSize || 0, cancelled: false }) } }) rec.onError((err) => { const e = _err('UNI_RECORD_ERROR', (err && (err.errMsg || err.message)) || 'record failed') this._uniRec = null this._isRecording = false this._clearAutoStopTimer() this._emit('error', e) if (this._uniResolve) { const r = this._uniResolve this._uniResolve = null r(null) } else { // 启动阶段的 reject reject(e) } }) // 振幅事件 (uni-app 文档:onFrameRecorded, frameSize 单位 KB, frameBuffer ArrayBuffer) if (typeof rec.onFrameRecorded === 'function') { rec.onFrameRecorded((res) => { if (!res || !res.frameBuffer) return // frameBuffer 是 PCM 字节,简单用 |sample| / 0x8000 取平均振幅 try { const view = new DataView(res.frameBuffer) let sum = 0 const len = res.frameBuffer.byteLength const step = Math.max(2, Math.floor(len / 256)) for (let i = 0; i < len; i += step) { const v = view.getInt16(i, true) sum += Math.abs(v) } const avg = sum / Math.max(1, Math.floor(len / step)) const level = Math.min(1, avg / 16384) this._emit('progress', { level, duration: Date.now() - this._startTs }) } catch (e) { /* noop */ } }) } try { rec.start({ duration: this._maxDuration / 1000, // uni-app 单位:秒 sampleRate: this._sampleRate, numberOfChannels: this._channels, encodeBitRate: this._bitRate, format: this._format, frameSize: 4 // KB,触发 onFrameRecorded 频率 }) } catch (e) { this._uniRec = null reject(e) } }) } _stopUni() { return new Promise((resolve) => { const rec = this._uniRec if (!rec) { resolve(null); return } this._uniResolve = resolve try { rec.stop() } catch (e) { resolve(null) } }) } _cancelUni() { return new Promise((resolve) => { const rec = this._uniRec if (!rec) { this._isRecording = false this._clearAutoStopTimer() resolve() return } this._uniResolve = () => resolve() try { rec.stop() } catch (e) { this._isRecording = false this._clearAutoStopTimer() resolve() } }) } // ===================== 工具 ===================== _setAutoStopTimer() { this._clearAutoStopTimer() this._autoStopTimer = setTimeout(() => { // 自动停止 (上限到达) if (this._isRecording) { this.stop().catch(() => { /* swallow */ }) } }, this._maxDuration) } _clearAutoStopTimer() { if (this._autoStopTimer) { clearTimeout(this._autoStopTimer) this._autoStopTimer = null } } } export default createVoiceRecorder