useAudioRecorder.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. import { ref, onUnmounted } from 'vue'
  2. /**
  3. * 诊断日志说明:本文件的 `console.debug('[recorder] ...')` 用于排查"录不到声音"类问题
  4. * (mic track 状态 / AudioContext 状态 / 每 50 个 chunk 的 Float32 峰值 / 结束时整体统计)。
  5. * 默认在 Chrome DevTools 不显示——浏览器 Console 的 log level filter 切到 "Verbose"
  6. * (或 "All levels")即可看到。Safari / Firefox 同理。
  7. */
  8. /**
  9. * 想要的采样率。Azure 发音评估原生就是 16kHz —— 送 48kHz 不会让评分更准,只是把
  10. * 重采样挪到别人那边做,而每一段录音的三个成本都是 3 倍:
  11. *
  12. * 记忆体(180s 单声道) 上行频宽 S3 存储
  13. * 48kHz 16.5 MB 96 KB/s ×3
  14. * 16kHz 5.5 MB 32 KB/s ×1
  15. *
  16. * 一个班 30 人同时录,上行是 2.8 MB/s 对 0.9 MB/s —— 在学校 wifi 上这一项比记忆体
  17. * 更容易先出事。
  18. */
  19. const TARGET_SAMPLE_RATE = 16000
  20. /**
  21. * 优先要 16kHz,拿不到就用硬件默认。
  22. *
  23. * 原来这里是写死的 `new AudioContext()`,注解写着「iOS Safari 不支持自定义采样率」。
  24. * 那在 Safari 14.1(2021)之前是对的。但与其去赌教室里那台 iPad 的版本号,不如让
  25. * 「赌输」变成一件无所谓的事 —— **整条链路带的都是真实采样率**:WS handshake 送
  26. * `recorder.sampleRate.value`(useArticleReadingEngine.ts),WAV header 写
  27. * `audioContext.sampleRate`(encodeWAV),后端的评估器格式与字节上限又是从那个
  28. * handshake 推出来的。所以退回 48kHz 时一切照常,只是没省到。
  29. *
  30. * 两种失败都要接住,而且形状不同:
  31. * ① 构造抛(NotSupportedError)→ 这里 catch,退回默认;
  32. * ② 构造成功但浏览器无视这个提示,sampleRate 仍是硬件值 → 不需要任何处理,
  33. * 因为下面读的是 `audioContext.sampleRate` 而不是 TARGET_SAMPLE_RATE。
  34. * 千万别把 sampleRate.value 写成常数 —— 那会让 ② 变成一个静默的谎:后端按 16kHz
  35. * 解 48kHz 的数据,回放速度慢三倍,而没有任何一层会报错。
  36. */
  37. function createAudioContext(): AudioContext {
  38. try {
  39. return new AudioContext({ sampleRate: TARGET_SAMPLE_RATE })
  40. }
  41. catch (err) {
  42. console.debug('[recorder] 16kHz AudioContext rejected, using hardware default', err)
  43. return new AudioContext()
  44. }
  45. }
  46. export function useAudioRecorder() {
  47. const isRecording = ref(false)
  48. const permissionState = ref<PermissionState>('prompt')
  49. const recordingDuration = ref(0)
  50. const silenceDetected = ref(false)
  51. /** 实际硬件采样率,startRecording 后可读;用于 WebSocket 上报给后端 */
  52. const sampleRate = ref<number>(0)
  53. /** 每收到一块 PCM chunk 时触发(外部可订阅做流式上传) */
  54. const onChunk = ref<((pcm16: ArrayBuffer) => void) | null>(null)
  55. let audioContext: AudioContext | null = null
  56. let mediaStream: MediaStream | null = null
  57. let workletNode: AudioWorkletNode | null = null
  58. let pcmChunks: Float32Array[] = []
  59. let durationTimer: ReturnType<typeof setInterval> | null = null
  60. let silenceCheckTimer: ReturnType<typeof setInterval> | null = null
  61. let analyser: AnalyserNode | null = null
  62. // [DEBUG] 跨 start/stop 共享的录音诊断计数器
  63. let debugChunkCount = 0
  64. let debugPeakFloat = 0
  65. // 墙钟起点。用它而不是 recordingDuration —— 采集图若被建了两套,那个 interval 也会
  66. // 跟着翻倍,两边一起错就比不出问题。samplesSeconds / wallSeconds 明显大于 1 = 采样重复。
  67. let debugStartedAt = 0
  68. // Check permission state
  69. async function checkPermission() {
  70. try {
  71. const status = await navigator.permissions.query({ name: 'microphone' as PermissionName })
  72. permissionState.value = status.state
  73. status.onchange = () => { permissionState.value = status.state }
  74. } catch {
  75. // permissions API may not support microphone query in all browsers
  76. }
  77. }
  78. // Silence detection using AnalyserNode
  79. function startSilenceDetection(source: MediaStreamAudioSourceNode) {
  80. analyser = audioContext!.createAnalyser()
  81. analyser.fftSize = 512
  82. source.connect(analyser)
  83. const dataArray = new Uint8Array(analyser.frequencyBinCount)
  84. let silentFrames = 0
  85. const SILENCE_THRESHOLD = 10
  86. const FRAMES_FOR_5S = Math.ceil(5000 / 200)
  87. silenceCheckTimer = setInterval(() => {
  88. if (!analyser) return
  89. analyser.getByteFrequencyData(dataArray)
  90. const average = dataArray.reduce((sum, v) => sum + v, 0) / dataArray.length
  91. if (average < SILENCE_THRESHOLD) {
  92. silentFrames++
  93. if (silentFrames >= FRAMES_FOR_5S) {
  94. silenceDetected.value = true
  95. }
  96. } else {
  97. silentFrames = 0
  98. silenceDetected.value = false
  99. }
  100. }, 200)
  101. }
  102. function stopSilenceDetection() {
  103. silenceDetected.value = false
  104. if (silenceCheckTimer) { clearInterval(silenceCheckTimer); silenceCheckTimer = null }
  105. analyser = null
  106. }
  107. async function startRecording(signal?: AbortSignal): Promise<void> {
  108. pcmChunks = []
  109. silenceDetected.value = false
  110. if (signal?.aborted) {
  111. throw new DOMException('Aborted', 'AbortError')
  112. }
  113. // 获取麦克风
  114. try {
  115. mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
  116. permissionState.value = 'granted'
  117. } catch (err: any) {
  118. if (err.name === 'NotAllowedError') {
  119. permissionState.value = 'denied'
  120. }
  121. throw err
  122. }
  123. if (signal?.aborted) {
  124. // User cancelled while the permission prompt was open. Release the track.
  125. mediaStream.getTracks().forEach(t => t.stop())
  126. mediaStream = null
  127. throw new DOMException('Aborted', 'AbortError')
  128. }
  129. // 优先 16kHz(见 createAudioContext)。sampleRate.value 一定是**实际拿到的**那个,
  130. // 不是我们要的那个 —— 浏览器可以无视这个提示,而下游全部按这个值走。
  131. audioContext = createAudioContext()
  132. sampleRate.value = audioContext.sampleRate
  133. // createMediaStreamSource 会由浏览器在 graph 层把麦克风的硬件采样率重采样到
  134. // context 的采样率,带正规的低通。别改成自己抽样:无低通的降采样会混叠,而混叠
  135. // 的表现是发音分数**普遍偏低** —— 没有错误、没有日志,只有「这批学生分数怎么低了」。
  136. const source = audioContext.createMediaStreamSource(mediaStream)
  137. // 诊断录音管线是否健康:mic track 状态 + AudioContext 状态
  138. console.debug('[recorder] startRecording', {
  139. contextState: audioContext.state,
  140. requestedSampleRate: TARGET_SAMPLE_RATE,
  141. contextSampleRate: audioContext.sampleRate,
  142. sampleRateHonoured: audioContext.sampleRate === TARGET_SAMPLE_RATE,
  143. tracks: mediaStream.getAudioTracks().map(t => ({
  144. label: t.label,
  145. enabled: t.enabled,
  146. muted: t.muted,
  147. readyState: t.readyState,
  148. settings: t.getSettings?.(),
  149. })),
  150. })
  151. // 有些浏览器 AudioContext 默认 suspended,必须 resume 才会 tick worklet
  152. if (audioContext.state === 'suspended') {
  153. await audioContext.resume()
  154. console.debug('[recorder] AudioContext resumed →', audioContext.state)
  155. }
  156. // 加载 AudioWorklet processor
  157. await audioContext.audioWorklet.addModule('/pcm-recorder-worklet.js')
  158. workletNode = new AudioWorkletNode(audioContext, 'pcm-recorder-processor')
  159. // 重置诊断计数器(很便宜,一直追踪;只有打印受 DevTools Verbose filter 控制)
  160. debugChunkCount = 0
  161. debugPeakFloat = 0
  162. debugStartedAt = Date.now()
  163. // 收集 PCM 数据;同时(可选)通过 onChunk 把 int16 字节推给外部(WebSocket 流式上传)
  164. workletNode.port.onmessage = (e: MessageEvent<Float32Array>) => {
  165. pcmChunks.push(e.data)
  166. debugChunkCount++
  167. for (let i = 0; i < e.data.length; i++) {
  168. const a = Math.abs(e.data[i])
  169. if (a > debugPeakFloat) debugPeakFloat = a
  170. }
  171. // 每 50 个 chunk 打一次(≈ 128*50/48000 ≈ 133ms 一条,避免刷屏)
  172. if (debugChunkCount % 50 === 0) {
  173. console.debug(`[recorder] chunks=${debugChunkCount} peak_float=${debugPeakFloat.toFixed(4)}`)
  174. }
  175. const cb = onChunk.value
  176. if (cb) {
  177. const int16 = float32ToInt16(e.data)
  178. cb(int16.buffer as ArrayBuffer)
  179. }
  180. }
  181. source.connect(workletNode)
  182. workletNode.connect(audioContext.destination) // 需要连接才能驱动处理
  183. // 静音检测
  184. startSilenceDetection(source)
  185. isRecording.value = true
  186. recordingDuration.value = 0
  187. durationTimer = setInterval(() => { recordingDuration.value++ }, 1000)
  188. }
  189. async function stopRecording(): Promise<Blob> {
  190. if (!workletNode || !audioContext) {
  191. throw new Error('No active recording')
  192. }
  193. // 通知 worklet 停止。onmessage 必须当场摘掉:'stop' 是投递到音频线程的异步消息,
  194. // 在它生效之前音频线程还会再吐几块,那几块会落进**下一段**录音的 pcmChunks
  195. // (pcmChunks 是跨 start/stop 复用的同一个闭包变量)。
  196. workletNode.port.onmessage = null
  197. workletNode.port.postMessage('stop')
  198. workletNode.disconnect()
  199. workletNode = null
  200. // 合并 PCM chunks 并按实际硬件采样率编码 WAV(后端按 WAV header 解析)
  201. const sampleRate = audioContext.sampleRate
  202. // 合并前打一条"整段录音的最终统计"—— peak_float ≈ 0 就是录音管线产零
  203. let totalSamples = 0
  204. for (const c of pcmChunks) totalSamples += c.length
  205. const wallSeconds = (Date.now() - debugStartedAt) / 1000
  206. const samplesSeconds = totalSamples / sampleRate
  207. console.debug('[recorder] stopRecording final', {
  208. chunks: debugChunkCount,
  209. totalSamples,
  210. sampleRate,
  211. duration: samplesSeconds.toFixed(2) + 's',
  212. wallSeconds: wallSeconds.toFixed(2) + 's',
  213. // 1.0 = 正常。≈2 就是同时跑着两套采集图,采样重复 → 回放慢一半且刺啦
  214. sampleRatio: (samplesSeconds / Math.max(wallSeconds, 0.001)).toFixed(2),
  215. peakFloat: debugPeakFloat.toFixed(6),
  216. contextState: audioContext.state,
  217. trackMuted: mediaStream?.getAudioTracks()[0]?.muted,
  218. trackEnabled: mediaStream?.getAudioTracks()[0]?.enabled,
  219. })
  220. const wavBlob = encodeWAV(pcmChunks, sampleRate)
  221. pcmChunks = []
  222. cleanup()
  223. return wavBlob
  224. }
  225. function cleanup() {
  226. isRecording.value = false
  227. recordingDuration.value = 0
  228. if (durationTimer) { clearInterval(durationTimer); durationTimer = null }
  229. stopSilenceDetection()
  230. // stopRecording() 之外的路径(reset / 卸载 / startRecording 失败)也会走到这里,
  231. // 那时 worklet 还挂着 onmessage。不摘掉它,孤儿 worklet 会继续往 pcmChunks 灌音频。
  232. if (workletNode) {
  233. workletNode.port.onmessage = null
  234. try { workletNode.port.postMessage('stop') } catch { /* context 已关闭 */ }
  235. try { workletNode.disconnect() } catch { /* 已断开 */ }
  236. workletNode = null
  237. }
  238. if (audioContext) { audioContext.close().catch(() => {}); audioContext = null }
  239. if (mediaStream) {
  240. mediaStream.getTracks().forEach(t => t.stop())
  241. mediaStream = null
  242. }
  243. }
  244. checkPermission()
  245. onUnmounted(() => {
  246. cleanup()
  247. })
  248. return {
  249. isRecording,
  250. permissionState,
  251. recordingDuration,
  252. silenceDetected,
  253. sampleRate,
  254. onChunk,
  255. startRecording,
  256. stopRecording,
  257. cleanup,
  258. }
  259. }
  260. // ==================== Helpers ====================
  261. function float32ToInt16(float32: Float32Array): Int16Array {
  262. const int16 = new Int16Array(float32.length)
  263. for (let i = 0; i < float32.length; i++) {
  264. const s = Math.max(-1, Math.min(1, float32[i]))
  265. int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF
  266. }
  267. return int16
  268. }
  269. // ==================== WAV 编码 ====================
  270. function encodeWAV(chunks: Float32Array[], sampleRate: number): Blob {
  271. // 计算总长度
  272. let totalLength = 0
  273. for (const chunk of chunks) totalLength += chunk.length
  274. // 合并为单个 Float32Array
  275. const pcm = new Float32Array(totalLength)
  276. let offset = 0
  277. for (const chunk of chunks) {
  278. pcm.set(chunk, offset)
  279. offset += chunk.length
  280. }
  281. // Float32 → Int16
  282. const int16 = float32ToInt16(pcm)
  283. // 写 WAV header + data
  284. const numChannels = 1
  285. const bitsPerSample = 16
  286. const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
  287. const blockAlign = numChannels * (bitsPerSample / 8)
  288. const dataSize = int16.length * (bitsPerSample / 8)
  289. const buffer = new ArrayBuffer(44 + dataSize)
  290. const view = new DataView(buffer)
  291. // RIFF header
  292. writeString(view, 0, 'RIFF')
  293. view.setUint32(4, 36 + dataSize, true)
  294. writeString(view, 8, 'WAVE')
  295. // fmt chunk
  296. writeString(view, 12, 'fmt ')
  297. view.setUint32(16, 16, true) // chunk size
  298. view.setUint16(20, 1, true) // PCM format
  299. view.setUint16(22, numChannels, true)
  300. view.setUint32(24, sampleRate, true)
  301. view.setUint32(28, byteRate, true)
  302. view.setUint16(32, blockAlign, true)
  303. view.setUint16(34, bitsPerSample, true)
  304. // data chunk
  305. writeString(view, 36, 'data')
  306. view.setUint32(40, dataSize, true)
  307. // PCM data
  308. const int16View = new Int16Array(buffer, 44)
  309. int16View.set(int16)
  310. return new Blob([buffer], { type: 'audio/wav' })
  311. }
  312. function writeString(view: DataView, offset: number, str: string) {
  313. for (let i = 0; i < str.length; i++) {
  314. view.setUint8(offset + i, str.charCodeAt(i))
  315. }
  316. }