useAudioRecorder.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  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. export function useAudioRecorder() {
  9. const isRecording = ref(false)
  10. const permissionState = ref<PermissionState>('prompt')
  11. const recordingDuration = ref(0)
  12. const silenceDetected = ref(false)
  13. /** 实际硬件采样率,startRecording 后可读;用于 WebSocket 上报给后端 */
  14. const sampleRate = ref<number>(0)
  15. /** 每收到一块 PCM chunk 时触发(外部可订阅做流式上传) */
  16. const onChunk = ref<((pcm16: ArrayBuffer) => void) | null>(null)
  17. let audioContext: AudioContext | null = null
  18. let mediaStream: MediaStream | null = null
  19. let workletNode: AudioWorkletNode | null = null
  20. let pcmChunks: Float32Array[] = []
  21. let durationTimer: ReturnType<typeof setInterval> | null = null
  22. let silenceCheckTimer: ReturnType<typeof setInterval> | null = null
  23. let analyser: AnalyserNode | null = null
  24. // [DEBUG] 跨 start/stop 共享的录音诊断计数器
  25. let debugChunkCount = 0
  26. let debugPeakFloat = 0
  27. // Check permission state
  28. async function checkPermission() {
  29. try {
  30. const status = await navigator.permissions.query({ name: 'microphone' as PermissionName })
  31. permissionState.value = status.state
  32. status.onchange = () => { permissionState.value = status.state }
  33. } catch {
  34. // permissions API may not support microphone query in all browsers
  35. }
  36. }
  37. // Silence detection using AnalyserNode
  38. function startSilenceDetection(source: MediaStreamAudioSourceNode) {
  39. analyser = audioContext!.createAnalyser()
  40. analyser.fftSize = 512
  41. source.connect(analyser)
  42. const dataArray = new Uint8Array(analyser.frequencyBinCount)
  43. let silentFrames = 0
  44. const SILENCE_THRESHOLD = 10
  45. const FRAMES_FOR_5S = Math.ceil(5000 / 200)
  46. silenceCheckTimer = setInterval(() => {
  47. if (!analyser) return
  48. analyser.getByteFrequencyData(dataArray)
  49. const average = dataArray.reduce((sum, v) => sum + v, 0) / dataArray.length
  50. if (average < SILENCE_THRESHOLD) {
  51. silentFrames++
  52. if (silentFrames >= FRAMES_FOR_5S) {
  53. silenceDetected.value = true
  54. }
  55. } else {
  56. silentFrames = 0
  57. silenceDetected.value = false
  58. }
  59. }, 200)
  60. }
  61. function stopSilenceDetection() {
  62. silenceDetected.value = false
  63. if (silenceCheckTimer) { clearInterval(silenceCheckTimer); silenceCheckTimer = null }
  64. analyser = null
  65. }
  66. async function startRecording(signal?: AbortSignal): Promise<void> {
  67. pcmChunks = []
  68. silenceDetected.value = false
  69. if (signal?.aborted) {
  70. throw new DOMException('Aborted', 'AbortError')
  71. }
  72. // 获取麦克风
  73. try {
  74. mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
  75. permissionState.value = 'granted'
  76. } catch (err: any) {
  77. if (err.name === 'NotAllowedError') {
  78. permissionState.value = 'denied'
  79. }
  80. throw err
  81. }
  82. if (signal?.aborted) {
  83. // User cancelled while the permission prompt was open. Release the track.
  84. mediaStream.getTracks().forEach(t => t.stop())
  85. mediaStream = null
  86. throw new DOMException('Aborted', 'AbortError')
  87. }
  88. // 创建 AudioContext(不指定 sampleRate,用硬件默认 — iOS Safari 不支持自定义采样率)
  89. audioContext = new AudioContext()
  90. sampleRate.value = audioContext.sampleRate
  91. const source = audioContext.createMediaStreamSource(mediaStream)
  92. // 诊断录音管线是否健康:mic track 状态 + AudioContext 状态
  93. console.debug('[recorder] startRecording', {
  94. contextState: audioContext.state,
  95. contextSampleRate: audioContext.sampleRate,
  96. tracks: mediaStream.getAudioTracks().map(t => ({
  97. label: t.label,
  98. enabled: t.enabled,
  99. muted: t.muted,
  100. readyState: t.readyState,
  101. settings: t.getSettings?.(),
  102. })),
  103. })
  104. // 有些浏览器 AudioContext 默认 suspended,必须 resume 才会 tick worklet
  105. if (audioContext.state === 'suspended') {
  106. await audioContext.resume()
  107. console.debug('[recorder] AudioContext resumed →', audioContext.state)
  108. }
  109. // 加载 AudioWorklet processor
  110. await audioContext.audioWorklet.addModule('/pcm-recorder-worklet.js')
  111. workletNode = new AudioWorkletNode(audioContext, 'pcm-recorder-processor')
  112. // 重置诊断计数器(很便宜,一直追踪;只有打印受 DevTools Verbose filter 控制)
  113. debugChunkCount = 0
  114. debugPeakFloat = 0
  115. // 收集 PCM 数据;同时(可选)通过 onChunk 把 int16 字节推给外部(WebSocket 流式上传)
  116. workletNode.port.onmessage = (e: MessageEvent<Float32Array>) => {
  117. pcmChunks.push(e.data)
  118. debugChunkCount++
  119. for (let i = 0; i < e.data.length; i++) {
  120. const a = Math.abs(e.data[i])
  121. if (a > debugPeakFloat) debugPeakFloat = a
  122. }
  123. // 每 50 个 chunk 打一次(≈ 128*50/48000 ≈ 133ms 一条,避免刷屏)
  124. if (debugChunkCount % 50 === 0) {
  125. console.debug(`[recorder] chunks=${debugChunkCount} peak_float=${debugPeakFloat.toFixed(4)}`)
  126. }
  127. const cb = onChunk.value
  128. if (cb) {
  129. const int16 = float32ToInt16(e.data)
  130. cb(int16.buffer as ArrayBuffer)
  131. }
  132. }
  133. source.connect(workletNode)
  134. workletNode.connect(audioContext.destination) // 需要连接才能驱动处理
  135. // 静音检测
  136. startSilenceDetection(source)
  137. isRecording.value = true
  138. recordingDuration.value = 0
  139. durationTimer = setInterval(() => { recordingDuration.value++ }, 1000)
  140. }
  141. async function stopRecording(): Promise<Blob> {
  142. if (!workletNode || !audioContext) {
  143. throw new Error('No active recording')
  144. }
  145. // 通知 worklet 停止
  146. workletNode.port.postMessage('stop')
  147. workletNode.disconnect()
  148. workletNode = null
  149. // 合并 PCM chunks 并按实际硬件采样率编码 WAV(后端按 WAV header 解析)
  150. const sampleRate = audioContext.sampleRate
  151. // 合并前打一条"整段录音的最终统计"—— peak_float ≈ 0 就是录音管线产零
  152. let totalSamples = 0
  153. for (const c of pcmChunks) totalSamples += c.length
  154. console.debug('[recorder] stopRecording final', {
  155. chunks: debugChunkCount,
  156. totalSamples,
  157. duration: (totalSamples / sampleRate).toFixed(2) + 's',
  158. peakFloat: debugPeakFloat.toFixed(6),
  159. contextState: audioContext.state,
  160. trackMuted: mediaStream?.getAudioTracks()[0]?.muted,
  161. trackEnabled: mediaStream?.getAudioTracks()[0]?.enabled,
  162. })
  163. const wavBlob = encodeWAV(pcmChunks, sampleRate)
  164. pcmChunks = []
  165. cleanup()
  166. return wavBlob
  167. }
  168. function cleanup() {
  169. isRecording.value = false
  170. recordingDuration.value = 0
  171. if (durationTimer) { clearInterval(durationTimer); durationTimer = null }
  172. stopSilenceDetection()
  173. if (audioContext) { audioContext.close().catch(() => {}); audioContext = null }
  174. if (mediaStream) {
  175. mediaStream.getTracks().forEach(t => t.stop())
  176. mediaStream = null
  177. }
  178. }
  179. checkPermission()
  180. onUnmounted(() => {
  181. cleanup()
  182. })
  183. return {
  184. isRecording,
  185. permissionState,
  186. recordingDuration,
  187. silenceDetected,
  188. sampleRate,
  189. onChunk,
  190. startRecording,
  191. stopRecording,
  192. cleanup,
  193. }
  194. }
  195. // ==================== Helpers ====================
  196. function float32ToInt16(float32: Float32Array): Int16Array {
  197. const int16 = new Int16Array(float32.length)
  198. for (let i = 0; i < float32.length; i++) {
  199. const s = Math.max(-1, Math.min(1, float32[i]))
  200. int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF
  201. }
  202. return int16
  203. }
  204. // ==================== WAV 编码 ====================
  205. function encodeWAV(chunks: Float32Array[], sampleRate: number): Blob {
  206. // 计算总长度
  207. let totalLength = 0
  208. for (const chunk of chunks) totalLength += chunk.length
  209. // 合并为单个 Float32Array
  210. const pcm = new Float32Array(totalLength)
  211. let offset = 0
  212. for (const chunk of chunks) {
  213. pcm.set(chunk, offset)
  214. offset += chunk.length
  215. }
  216. // Float32 → Int16
  217. const int16 = float32ToInt16(pcm)
  218. // 写 WAV header + data
  219. const numChannels = 1
  220. const bitsPerSample = 16
  221. const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
  222. const blockAlign = numChannels * (bitsPerSample / 8)
  223. const dataSize = int16.length * (bitsPerSample / 8)
  224. const buffer = new ArrayBuffer(44 + dataSize)
  225. const view = new DataView(buffer)
  226. // RIFF header
  227. writeString(view, 0, 'RIFF')
  228. view.setUint32(4, 36 + dataSize, true)
  229. writeString(view, 8, 'WAVE')
  230. // fmt chunk
  231. writeString(view, 12, 'fmt ')
  232. view.setUint32(16, 16, true) // chunk size
  233. view.setUint16(20, 1, true) // PCM format
  234. view.setUint16(22, numChannels, true)
  235. view.setUint32(24, sampleRate, true)
  236. view.setUint32(28, byteRate, true)
  237. view.setUint16(32, blockAlign, true)
  238. view.setUint16(34, bitsPerSample, true)
  239. // data chunk
  240. writeString(view, 36, 'data')
  241. view.setUint32(40, dataSize, true)
  242. // PCM data
  243. const int16View = new Int16Array(buffer, 44)
  244. int16View.set(int16)
  245. return new Blob([buffer], { type: 'audio/wav' })
  246. }
  247. function writeString(view: DataView, offset: number, str: string) {
  248. for (let i = 0; i < str.length; i++) {
  249. view.setUint8(offset + i, str.charCodeAt(i))
  250. }
  251. }