| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300 |
- import { ref, onUnmounted } from 'vue'
- /**
- * 诊断日志说明:本文件的 `console.debug('[recorder] ...')` 用于排查"录不到声音"类问题
- * (mic track 状态 / AudioContext 状态 / 每 50 个 chunk 的 Float32 峰值 / 结束时整体统计)。
- * 默认在 Chrome DevTools 不显示——浏览器 Console 的 log level filter 切到 "Verbose"
- * (或 "All levels")即可看到。Safari / Firefox 同理。
- */
- export function useAudioRecorder() {
- const isRecording = ref(false)
- const permissionState = ref<PermissionState>('prompt')
- const recordingDuration = ref(0)
- const silenceDetected = ref(false)
- /** 实际硬件采样率,startRecording 后可读;用于 WebSocket 上报给后端 */
- const sampleRate = ref<number>(0)
- /** 每收到一块 PCM chunk 时触发(外部可订阅做流式上传) */
- const onChunk = ref<((pcm16: ArrayBuffer) => void) | null>(null)
- let audioContext: AudioContext | null = null
- let mediaStream: MediaStream | null = null
- let workletNode: AudioWorkletNode | null = null
- let pcmChunks: Float32Array[] = []
- let durationTimer: ReturnType<typeof setInterval> | null = null
- let silenceCheckTimer: ReturnType<typeof setInterval> | null = null
- let analyser: AnalyserNode | null = null
- // [DEBUG] 跨 start/stop 共享的录音诊断计数器
- let debugChunkCount = 0
- let debugPeakFloat = 0
- // Check permission state
- async function checkPermission() {
- try {
- const status = await navigator.permissions.query({ name: 'microphone' as PermissionName })
- permissionState.value = status.state
- status.onchange = () => { permissionState.value = status.state }
- } catch {
- // permissions API may not support microphone query in all browsers
- }
- }
- // Silence detection using AnalyserNode
- function startSilenceDetection(source: MediaStreamAudioSourceNode) {
- analyser = audioContext!.createAnalyser()
- analyser.fftSize = 512
- source.connect(analyser)
- const dataArray = new Uint8Array(analyser.frequencyBinCount)
- let silentFrames = 0
- const SILENCE_THRESHOLD = 10
- const FRAMES_FOR_5S = Math.ceil(5000 / 200)
- silenceCheckTimer = setInterval(() => {
- if (!analyser) return
- analyser.getByteFrequencyData(dataArray)
- const average = dataArray.reduce((sum, v) => sum + v, 0) / dataArray.length
- if (average < SILENCE_THRESHOLD) {
- silentFrames++
- if (silentFrames >= FRAMES_FOR_5S) {
- silenceDetected.value = true
- }
- } else {
- silentFrames = 0
- silenceDetected.value = false
- }
- }, 200)
- }
- function stopSilenceDetection() {
- silenceDetected.value = false
- if (silenceCheckTimer) { clearInterval(silenceCheckTimer); silenceCheckTimer = null }
- analyser = null
- }
- async function startRecording(signal?: AbortSignal): Promise<void> {
- pcmChunks = []
- silenceDetected.value = false
- if (signal?.aborted) {
- throw new DOMException('Aborted', 'AbortError')
- }
- // 获取麦克风
- try {
- mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
- permissionState.value = 'granted'
- } catch (err: any) {
- if (err.name === 'NotAllowedError') {
- permissionState.value = 'denied'
- }
- throw err
- }
- if (signal?.aborted) {
- // User cancelled while the permission prompt was open. Release the track.
- mediaStream.getTracks().forEach(t => t.stop())
- mediaStream = null
- throw new DOMException('Aborted', 'AbortError')
- }
- // 创建 AudioContext(不指定 sampleRate,用硬件默认 — iOS Safari 不支持自定义采样率)
- audioContext = new AudioContext()
- sampleRate.value = audioContext.sampleRate
- const source = audioContext.createMediaStreamSource(mediaStream)
- // 诊断录音管线是否健康:mic track 状态 + AudioContext 状态
- console.debug('[recorder] startRecording', {
- contextState: audioContext.state,
- contextSampleRate: audioContext.sampleRate,
- tracks: mediaStream.getAudioTracks().map(t => ({
- label: t.label,
- enabled: t.enabled,
- muted: t.muted,
- readyState: t.readyState,
- settings: t.getSettings?.(),
- })),
- })
- // 有些浏览器 AudioContext 默认 suspended,必须 resume 才会 tick worklet
- if (audioContext.state === 'suspended') {
- await audioContext.resume()
- console.debug('[recorder] AudioContext resumed →', audioContext.state)
- }
- // 加载 AudioWorklet processor
- await audioContext.audioWorklet.addModule('/pcm-recorder-worklet.js')
- workletNode = new AudioWorkletNode(audioContext, 'pcm-recorder-processor')
- // 重置诊断计数器(很便宜,一直追踪;只有打印受 DevTools Verbose filter 控制)
- debugChunkCount = 0
- debugPeakFloat = 0
- // 收集 PCM 数据;同时(可选)通过 onChunk 把 int16 字节推给外部(WebSocket 流式上传)
- workletNode.port.onmessage = (e: MessageEvent<Float32Array>) => {
- pcmChunks.push(e.data)
- debugChunkCount++
- for (let i = 0; i < e.data.length; i++) {
- const a = Math.abs(e.data[i])
- if (a > debugPeakFloat) debugPeakFloat = a
- }
- // 每 50 个 chunk 打一次(≈ 128*50/48000 ≈ 133ms 一条,避免刷屏)
- if (debugChunkCount % 50 === 0) {
- console.debug(`[recorder] chunks=${debugChunkCount} peak_float=${debugPeakFloat.toFixed(4)}`)
- }
- const cb = onChunk.value
- if (cb) {
- const int16 = float32ToInt16(e.data)
- cb(int16.buffer as ArrayBuffer)
- }
- }
- source.connect(workletNode)
- workletNode.connect(audioContext.destination) // 需要连接才能驱动处理
- // 静音检测
- startSilenceDetection(source)
- isRecording.value = true
- recordingDuration.value = 0
- durationTimer = setInterval(() => { recordingDuration.value++ }, 1000)
- }
- async function stopRecording(): Promise<Blob> {
- if (!workletNode || !audioContext) {
- throw new Error('No active recording')
- }
- // 通知 worklet 停止
- workletNode.port.postMessage('stop')
- workletNode.disconnect()
- workletNode = null
- // 合并 PCM chunks 并按实际硬件采样率编码 WAV(后端按 WAV header 解析)
- const sampleRate = audioContext.sampleRate
- // 合并前打一条"整段录音的最终统计"—— peak_float ≈ 0 就是录音管线产零
- let totalSamples = 0
- for (const c of pcmChunks) totalSamples += c.length
- console.debug('[recorder] stopRecording final', {
- chunks: debugChunkCount,
- totalSamples,
- duration: (totalSamples / sampleRate).toFixed(2) + 's',
- peakFloat: debugPeakFloat.toFixed(6),
- contextState: audioContext.state,
- trackMuted: mediaStream?.getAudioTracks()[0]?.muted,
- trackEnabled: mediaStream?.getAudioTracks()[0]?.enabled,
- })
- const wavBlob = encodeWAV(pcmChunks, sampleRate)
- pcmChunks = []
- cleanup()
- return wavBlob
- }
- function cleanup() {
- isRecording.value = false
- recordingDuration.value = 0
- if (durationTimer) { clearInterval(durationTimer); durationTimer = null }
- stopSilenceDetection()
- if (audioContext) { audioContext.close().catch(() => {}); audioContext = null }
- if (mediaStream) {
- mediaStream.getTracks().forEach(t => t.stop())
- mediaStream = null
- }
- }
- checkPermission()
- onUnmounted(() => {
- cleanup()
- })
- return {
- isRecording,
- permissionState,
- recordingDuration,
- silenceDetected,
- sampleRate,
- onChunk,
- startRecording,
- stopRecording,
- cleanup,
- }
- }
- // ==================== Helpers ====================
- function float32ToInt16(float32: Float32Array): Int16Array {
- const int16 = new Int16Array(float32.length)
- for (let i = 0; i < float32.length; i++) {
- const s = Math.max(-1, Math.min(1, float32[i]))
- int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF
- }
- return int16
- }
- // ==================== WAV 编码 ====================
- function encodeWAV(chunks: Float32Array[], sampleRate: number): Blob {
- // 计算总长度
- let totalLength = 0
- for (const chunk of chunks) totalLength += chunk.length
- // 合并为单个 Float32Array
- const pcm = new Float32Array(totalLength)
- let offset = 0
- for (const chunk of chunks) {
- pcm.set(chunk, offset)
- offset += chunk.length
- }
- // Float32 → Int16
- const int16 = float32ToInt16(pcm)
- // 写 WAV header + data
- const numChannels = 1
- const bitsPerSample = 16
- const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
- const blockAlign = numChannels * (bitsPerSample / 8)
- const dataSize = int16.length * (bitsPerSample / 8)
- const buffer = new ArrayBuffer(44 + dataSize)
- const view = new DataView(buffer)
- // RIFF header
- writeString(view, 0, 'RIFF')
- view.setUint32(4, 36 + dataSize, true)
- writeString(view, 8, 'WAVE')
- // fmt chunk
- writeString(view, 12, 'fmt ')
- view.setUint32(16, 16, true) // chunk size
- view.setUint16(20, 1, true) // PCM format
- view.setUint16(22, numChannels, true)
- view.setUint32(24, sampleRate, true)
- view.setUint32(28, byteRate, true)
- view.setUint16(32, blockAlign, true)
- view.setUint16(34, bitsPerSample, true)
- // data chunk
- writeString(view, 36, 'data')
- view.setUint32(40, dataSize, true)
- // PCM data
- const int16View = new Int16Array(buffer, 44)
- int16View.set(int16)
- return new Blob([buffer], { type: 'audio/wav' })
- }
- function writeString(view: DataView, offset: number, str: string) {
- for (let i = 0; i < str.length; i++) {
- view.setUint8(offset + i, str.charCodeAt(i))
- }
- }
|