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('prompt') const recordingDuration = ref(0) const silenceDetected = ref(false) /** 实际硬件采样率,startRecording 后可读;用于 WebSocket 上报给后端 */ const sampleRate = ref(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 | null = null let silenceCheckTimer: ReturnType | 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 { 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) => { 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 { 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)) } }