819 lines
30 KiB
JavaScript
819 lines
30 KiB
JavaScript
/**
|
|
* 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_<ts>.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
|
|
|
|
// PR-FRONTEND-VOICE-SINGLETON (2026-09-29): uni.getRecorderManager() / wx.getRecorderManager()
|
|
// 是全局单例, 且微信内部状态极易因上一次未完全停止而残留, 导致下一次 start() 报
|
|
// "operateRecorder:fail:audio is recording, don't start"。
|
|
// 这里用模块级 Promise 把所有 start/stop 串行化, 确保前一个实例 onStop/onError 完成后
|
|
// 才允许下一个实例 start()。
|
|
let _singletonIdlePromise = Promise.resolve()
|
|
let _singletonIdleResolver = null
|
|
let _singletonRec = null
|
|
let _singletonBusyInstance = null
|
|
|
|
function _getSingletonRecorder() {
|
|
if (!_singletonRec) {
|
|
_singletonRec = uni.getRecorderManager()
|
|
}
|
|
return _singletonRec
|
|
}
|
|
|
|
let _singletonIdleTimer = null
|
|
|
|
function _setSingletonBusy(instance, maxDurationMs = DEFAULT_MAX_DURATION_MS) {
|
|
if (_singletonIdleTimer) {
|
|
clearTimeout(_singletonIdleTimer)
|
|
_singletonIdleTimer = null
|
|
}
|
|
if (_singletonIdleResolver) {
|
|
// 已经有未释放的 busy promise, 理论上不应发生; 先强制释放避免死锁
|
|
_singletonIdleResolver()
|
|
_singletonIdleResolver = null
|
|
}
|
|
_singletonIdlePromise = new Promise((resolve) => {
|
|
_singletonIdleResolver = resolve
|
|
})
|
|
_singletonBusyInstance = instance
|
|
// 保险: 即使 onStop/onError 因异常没触发, 最多等 10s 也要释放,
|
|
// 避免死锁导致后续所有 start() 永远挂起。
|
|
_singletonIdleTimer = setTimeout(() => {
|
|
console.warn('[voice-recorder:singleton] idle timeout, force release')
|
|
_setSingletonIdle(instance)
|
|
}, Math.min(maxDurationMs + 5000, 10000))
|
|
}
|
|
|
|
function _setSingletonIdle(instance) {
|
|
if (_singletonIdleTimer) {
|
|
clearTimeout(_singletonIdleTimer)
|
|
_singletonIdleTimer = null
|
|
}
|
|
if (_singletonBusyInstance && _singletonBusyInstance !== instance) {
|
|
// 不是当前占用者释放, 忽略
|
|
return
|
|
}
|
|
if (_singletonIdleResolver) {
|
|
_singletonIdleResolver()
|
|
_singletonIdleResolver = null
|
|
}
|
|
_singletonIdlePromise = Promise.resolve()
|
|
_singletonBusyInstance = null
|
|
}
|
|
|
|
// 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
|
|
// PR-FRONTEND-VOICE-FORMAT (2026-09-29): 微信小程序在部分 Android 机型上对 mp3/aac 兼容性差,
|
|
// 表现为一启动就 onStop(duration~100ms)。wav 是无压缩格式, 兼容性最好, 优先使用。
|
|
this._format = opts.format || 'wav'
|
|
this._sampleRate = opts.sampleRate || 16000
|
|
this._channels = opts.numberOfChannels || 1
|
|
this._bitRate = opts.encodeBitRate || 48000
|
|
|
|
this._listeners = { progress: [], error: [] }
|
|
this._isRecording = false
|
|
this._isStarting = false // start() 已调用, 但 onStart 尚未回调
|
|
this._stopResolve = null // 当前 stop() 等待 onStop 解析的 resolve
|
|
this._pendingStop = false // onStart 之前就调用了 stop() → 等开始后立即停
|
|
this._startTs = 0
|
|
this._id = Math.random().toString(36).slice(2, 8)
|
|
this._autoStopTimer = null
|
|
this._stopFallbackTimer = null // PR-FRONTEND-VOICE-FALLBACK: stop()/cancel() 兜底定时器
|
|
this._earlyStopResult = null // PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发时缓存结果
|
|
|
|
// 平台相关句柄
|
|
this._h5 = null // { mediaRecorder, stream, audioCtx, analyser, chunks, mime, rafId }
|
|
this._uniRec = null // uni.getRecorderManager() 返回值
|
|
console.log(`[voice-recorder:${this._id}] created`)
|
|
}
|
|
|
|
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 (this._isStarting) {
|
|
return Promise.reject(_err('ALREADY_STARTED', 'recorder is starting'))
|
|
}
|
|
|
|
this._isStarting = true
|
|
let p = null
|
|
if (isH5()) {
|
|
p = this._startH5()
|
|
} else if (isMpOrApp()) {
|
|
p = this._startUni()
|
|
} else {
|
|
this._isStarting = false
|
|
return Promise.reject(_err('NOT_SUPPORTED', 'voice input not supported on this platform'))
|
|
}
|
|
// 启动失败时复位状态, 避免后续 stop()/cancel() 一直挂起
|
|
p.catch((e) => {
|
|
this._isStarting = false
|
|
return Promise.reject(e)
|
|
})
|
|
return p
|
|
}
|
|
|
|
/**
|
|
* 停止录音并返回产物。
|
|
* resolve(null) 表示已取消 / 无产物,调用方应忽略。
|
|
*/
|
|
stop() {
|
|
console.log(`[voice-recorder:${this._id}] stop() called`, { isRecording: this._isRecording, isStarting: this._isStarting, hasUniRec: !!this._uniRec, hasStopResolve: !!this._stopResolve, earlyResult: !!this._earlyStopResult })
|
|
if (!this._isRecording) {
|
|
// PR-FRONTEND-VOICE-RACE (2026-09-29): 真机常见"按住即松手"——
|
|
// onStart 尚未回调 (录音未真正开始) 就调用 stop(), 原逻辑直接返回 null
|
|
// 导致上层判定"录音失败,请重试"。这里改为挂起, 等 onStart 触发后立即停,
|
|
// 产物仍走 onStop 正常返回 (太短的音轨后端可自行拒绝)。
|
|
if (this._isStarting) {
|
|
console.log(`[voice-recorder:${this._id}] stop() deferred: waiting for onStart`)
|
|
return new Promise((resolve) => {
|
|
this._pendingStop = true
|
|
this._stopResolve = resolve
|
|
})
|
|
}
|
|
// onStop 在 stop() 之前触发, 且有有效产物则返回缓存结果
|
|
if (this._earlyStopResult) {
|
|
const r = this._earlyStopResult
|
|
this._earlyStopResult = null
|
|
console.log(`[voice-recorder:${this._id}] stop() returning early onStop result`)
|
|
return Promise.resolve(r)
|
|
}
|
|
console.log(`[voice-recorder:${this._id}] stop() early return null (not recording)`)
|
|
return Promise.resolve(null)
|
|
}
|
|
if (isH5()) return this._stopH5()
|
|
return this._stopUni()
|
|
}
|
|
|
|
/**
|
|
* 中断录音并丢弃产物。
|
|
*/
|
|
cancel() {
|
|
if (this._isStarting) {
|
|
// PR-FRONTEND-VOICE-CANCEL-RACE (2026-09-29): onStart 尚未触发就取消,
|
|
// 不能立即返回, 否则录音器启动后会继续录制。挂起 pendingStop,
|
|
// 等 onStart 触发后立即 stop(), 产物被丢弃。
|
|
console.log(`[voice-recorder:${this._id}] cancel() deferred: waiting for onStart`)
|
|
return new Promise((resolve) => {
|
|
this._pendingStop = true
|
|
this._stopResolve = () => resolve()
|
|
})
|
|
}
|
|
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() {
|
|
// PR-FRONTEND-VOICE-SINGLETON: 串行化所有对微信录音单例的访问,
|
|
// 防止"上一个 onStop 还没触发, 下一个 start() 就报 audio is recording"。
|
|
return _singletonIdlePromise.then(() => {
|
|
// 防竞态: 注册 .then() 后到回调执行前, 可能有其他实例先占用了单例, 需要重新排队。
|
|
if (_singletonBusyInstance && _singletonBusyInstance !== this) {
|
|
console.log(`[voice-recorder:${this._id}] _startUni: singleton busy by ${_singletonBusyInstance._id}, re-queue`)
|
|
return this._startUni()
|
|
}
|
|
_setSingletonBusy(this, this._maxDuration)
|
|
console.log(`[voice-recorder:${this._id}] _startUni: singleton busy acquired`)
|
|
return this._doStartUni()
|
|
})
|
|
}
|
|
|
|
_doStartUni() {
|
|
return new Promise((resolve, reject) => {
|
|
let rec = null
|
|
try {
|
|
rec = _getSingletonRecorder()
|
|
} catch (e) {
|
|
_setSingletonIdle(this)
|
|
reject(_err('NO_RECORDER_MANAGER', 'uni.getRecorderManager unavailable'))
|
|
return
|
|
}
|
|
if (!rec) {
|
|
_setSingletonIdle(this)
|
|
reject(_err('NO_RECORDER_MANAGER', 'recorder manager unavailable'))
|
|
return
|
|
}
|
|
|
|
this._uniRec = rec
|
|
this._isStarting = true
|
|
console.log(`[voice-recorder:${this._id}] _startUni: start() invoked on singleton`)
|
|
|
|
rec.onStart(() => {
|
|
console.log(`[voice-recorder:${this._id}] onStart fired`)
|
|
this._isRecording = true
|
|
this._isStarting = false
|
|
this._startTs = Date.now()
|
|
this._setAutoStopTimer()
|
|
if (this._pendingStop) {
|
|
// 用户在录音真正开始前就松手: 立即结束, 产物经 onStop 返回
|
|
console.log(`[voice-recorder:${this._id}] onStart: pendingStop detected, calling rec.stop()`)
|
|
this._pendingStop = false
|
|
try { rec.stop() } catch (e) { /* onStop 自会兜底 */ }
|
|
}
|
|
resolve()
|
|
})
|
|
|
|
rec.onStop((res) => {
|
|
// res = { tempFilePath, duration, fileSize }
|
|
console.log(`[voice-recorder:${this._id}] onStop fired`, res, 'hasStopResolve=', !!this._stopResolve)
|
|
this._clearStopFallbackTimer()
|
|
this._uniRec = null
|
|
this._isRecording = false
|
|
this._isStarting = false
|
|
this._clearAutoStopTimer()
|
|
// PR-FRONTEND-VOICE-FORMAT (2026-09-29): 微信临时文件后缀才是真实容器格式:
|
|
// Android aac → .m4a, iOS aac → .aac, mp3 → .mp3。按实际后缀给 mime/文件名,
|
|
// 避免播放器/后端因后缀与容器不符而失败。
|
|
const tempPath = (res && res.tempFilePath) || ''
|
|
const rawExt = (tempPath.split('?')[0].split('/').pop().match(/\.([a-z0-9]+)$/i) || [])[1] || ''
|
|
const ext = rawExt.toLowerCase()
|
|
const mime = ext === 'm4a' ? 'audio/mp4'
|
|
: ext === 'aac' ? 'audio/aac'
|
|
: ext === 'mp3' ? 'audio/mpeg'
|
|
: ext === 'wav' ? 'audio/wav'
|
|
: (this._format === 'wav' ? 'audio/wav'
|
|
: this._format === 'aac' ? 'audio/aac'
|
|
: 'audio/mpeg')
|
|
// PR-FRONTEND-VOICE-DURATION-FIX (2026-09-29): 微信 onStop 的 duration
|
|
// 偶尔为 0, 用 start→stop 真实时长兜底, 避免下游按 duration=0 丢弃。
|
|
const realDuration = this._startTs ? Date.now() - this._startTs : 0
|
|
const reportedDuration = (res && typeof res.duration === 'number') ? res.duration : 0
|
|
const duration = reportedDuration > 0 ? reportedDuration : realDuration
|
|
const result = {
|
|
filePath: tempPath,
|
|
blob: null,
|
|
fileName: tempPath ? `voice_${Date.now()}.${ext || 'aac'}` : '',
|
|
duration,
|
|
mime,
|
|
size: res.fileSize || 0,
|
|
cancelled: false
|
|
}
|
|
if (this._stopResolve) {
|
|
const r = this._stopResolve
|
|
this._stopResolve = null
|
|
r(result)
|
|
} else if (result.filePath && result.duration > 0) {
|
|
// PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发且产物有效, 缓存起来
|
|
this._earlyStopResult = result
|
|
}
|
|
_setSingletonIdle(this)
|
|
})
|
|
|
|
rec.onError((err) => {
|
|
this._clearStopFallbackTimer()
|
|
const e = _err('UNI_RECORD_ERROR', (err && (err.errMsg || err.message)) || 'record failed')
|
|
console.error(`[voice-recorder:${this._id}] UNI_RECORD_ERROR raw:`, err, 'errMsg=', err && err.errMsg, 'message=', err && err.message)
|
|
this._uniRec = null
|
|
this._isRecording = false
|
|
this._isStarting = false
|
|
this._clearAutoStopTimer()
|
|
this._emit('error', e)
|
|
if (this._stopResolve) {
|
|
const r = this._stopResolve
|
|
this._stopResolve = null
|
|
r(null)
|
|
} else {
|
|
// 启动阶段的 reject
|
|
reject(e)
|
|
}
|
|
_setSingletonIdle(this)
|
|
})
|
|
|
|
// 振幅事件 (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({
|
|
// PR-FRONTEND-VOICE-DURATION-UNIT (2026-09-29): 微信 RecorderManager.start 的 duration
|
|
// 单位是毫秒(ms), 不是秒。之前 /1000 导致只录 60ms 就被自动停止。
|
|
duration: this._maxDuration,
|
|
sampleRate: this._sampleRate,
|
|
numberOfChannels: this._channels,
|
|
encodeBitRate: this._bitRate,
|
|
format: this._format
|
|
// PR-FRONTEND-VOICE-FRAMESIZE (2026-09-29): 去掉 frameSize, 部分机型对此参数敏感,
|
|
// 可能导致录音被提前中断。
|
|
})
|
|
} catch (e) {
|
|
this._clearStopFallbackTimer()
|
|
this._uniRec = null
|
|
this._isRecording = false
|
|
this._isStarting = false
|
|
this._clearAutoStopTimer()
|
|
_setSingletonIdle(this)
|
|
// 启动阶段就被 stop()/cancel() 抢占, 需要 Fulfill 挂起的 Promise, 而不是 reject
|
|
if (this._stopResolve && this._pendingStop) {
|
|
const r = this._stopResolve
|
|
this._stopResolve = null
|
|
this._pendingStop = false
|
|
r(null)
|
|
return
|
|
}
|
|
reject(e)
|
|
}
|
|
})
|
|
}
|
|
|
|
_stopUni() {
|
|
return new Promise((resolve) => {
|
|
console.log(`[voice-recorder:${this._id}] _stopUni called`)
|
|
const rec = this._uniRec
|
|
if (!rec) { resolve(null); return }
|
|
this._earlyStopResult = null
|
|
this._stopResolve = resolve
|
|
this._clearStopFallbackTimer()
|
|
// PR-FRONTEND-VOICE-FALLBACK: 真机偶发 onStop 不回调, 导致 stop() 永远挂起,
|
|
// UI 无法释放旧实例, 新录音提示"recorder already exists"。最多等 3s 强制 resolve。
|
|
this._stopFallbackTimer = setTimeout(() => {
|
|
console.warn(`[voice-recorder:${this._id}] _stopUni fallback timeout, force resolve`)
|
|
this._stopResolve = null
|
|
this._pendingStop = false
|
|
this._isRecording = false
|
|
this._isStarting = false
|
|
this._clearAutoStopTimer()
|
|
this._uniRec = null
|
|
_setSingletonIdle(this)
|
|
resolve(null)
|
|
}, 3000)
|
|
try { rec.stop() } catch (e) {
|
|
this._clearStopFallbackTimer()
|
|
this._stopResolve = null
|
|
this._isRecording = false
|
|
this._isStarting = false
|
|
this._clearAutoStopTimer()
|
|
this._uniRec = null
|
|
_setSingletonIdle(this)
|
|
resolve(null)
|
|
}
|
|
})
|
|
}
|
|
|
|
_cancelUni() {
|
|
return new Promise((resolve) => {
|
|
console.log(`[voice-recorder:${this._id}] _cancelUni called`)
|
|
this._earlyStopResult = null
|
|
const rec = this._uniRec
|
|
if (!rec) {
|
|
// 挂起的 stop 需要一并了结, 避免松手后卡在 pending
|
|
if (this._stopResolve && this._pendingStop) {
|
|
const r = this._stopResolve
|
|
this._stopResolve = null
|
|
this._pendingStop = false
|
|
r(null)
|
|
}
|
|
this._isRecording = false
|
|
this._isStarting = false
|
|
this._clearAutoStopTimer()
|
|
_setSingletonIdle(this)
|
|
resolve()
|
|
return
|
|
}
|
|
this._stopResolve = () => resolve()
|
|
this._pendingStop = false
|
|
this._clearStopFallbackTimer()
|
|
// PR-FRONTEND-VOICE-FALLBACK: cancel() 同样可能因 onStop 不回调而挂起。
|
|
this._stopFallbackTimer = setTimeout(() => {
|
|
console.warn(`[voice-recorder:${this._id}] _cancelUni fallback timeout, force resolve`)
|
|
this._stopResolve = null
|
|
this._isRecording = false
|
|
this._isStarting = false
|
|
this._clearAutoStopTimer()
|
|
this._uniRec = null
|
|
_setSingletonIdle(this)
|
|
resolve()
|
|
}, 3000)
|
|
try { rec.stop() } catch (e) {
|
|
this._clearStopFallbackTimer()
|
|
this._isRecording = false
|
|
this._isStarting = false
|
|
this._clearAutoStopTimer()
|
|
this._uniRec = null
|
|
_setSingletonIdle(this)
|
|
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
|
|
}
|
|
}
|
|
|
|
_clearStopFallbackTimer() {
|
|
if (this._stopFallbackTimer) {
|
|
clearTimeout(this._stopFallbackTimer)
|
|
this._stopFallbackTimer = null
|
|
}
|
|
}
|
|
}
|
|
|
|
export default createVoiceRecorder |