| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- 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 同理。
- */
- /**
- * 想要的采样率。Azure 发音评估原生就是 16kHz —— 送 48kHz 不会让评分更准,只是把
- * 重采样挪到别人那边做,而每一段录音的三个成本都是 3 倍:
- *
- * 记忆体(180s 单声道) 上行频宽 S3 存储
- * 48kHz 16.5 MB 96 KB/s ×3
- * 16kHz 5.5 MB 32 KB/s ×1
- *
- * 一个班 30 人同时录,上行是 2.8 MB/s 对 0.9 MB/s —— 在学校 wifi 上这一项比记忆体
- * 更容易先出事。
- */
- const TARGET_SAMPLE_RATE = 16000
- /**
- * 优先要 16kHz,拿不到就用硬件默认。
- *
- * 原来这里是写死的 `new AudioContext()`,注解写着「iOS Safari 不支持自定义采样率」。
- * 那在 Safari 14.1(2021)之前是对的。但与其去赌教室里那台 iPad 的版本号,不如让
- * 「赌输」变成一件无所谓的事 —— **整条链路带的都是真实采样率**:WS handshake 送
- * `recorder.sampleRate.value`(useArticleReadingEngine.ts),WAV header 写
- * `audioContext.sampleRate`(encodeWAV),后端的评估器格式与字节上限又是从那个
- * handshake 推出来的。所以退回 48kHz 时一切照常,只是没省到。
- *
- * 两种失败都要接住,而且形状不同:
- * ① 构造抛(NotSupportedError)→ 这里 catch,退回默认;
- * ② 构造成功但浏览器无视这个提示,sampleRate 仍是硬件值 → 不需要任何处理,
- * 因为下面读的是 `audioContext.sampleRate` 而不是 TARGET_SAMPLE_RATE。
- * 千万别把 sampleRate.value 写成常数 —— 那会让 ② 变成一个静默的谎:后端按 16kHz
- * 解 48kHz 的数据,回放速度慢三倍,而没有任何一层会报错。
- */
- function createAudioContext(): AudioContext {
- try {
- return new AudioContext({ sampleRate: TARGET_SAMPLE_RATE })
- }
- catch (err) {
- console.debug('[recorder] 16kHz AudioContext rejected, using hardware default', err)
- return new AudioContext()
- }
- }
- 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
- // 墙钟起点。用它而不是 recordingDuration —— 采集图若被建了两套,那个 interval 也会
- // 跟着翻倍,两边一起错就比不出问题。samplesSeconds / wallSeconds 明显大于 1 = 采样重复。
- let debugStartedAt = 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')
- }
- // 优先 16kHz(见 createAudioContext)。sampleRate.value 一定是**实际拿到的**那个,
- // 不是我们要的那个 —— 浏览器可以无视这个提示,而下游全部按这个值走。
- audioContext = createAudioContext()
- sampleRate.value = audioContext.sampleRate
- // createMediaStreamSource 会由浏览器在 graph 层把麦克风的硬件采样率重采样到
- // context 的采样率,带正规的低通。别改成自己抽样:无低通的降采样会混叠,而混叠
- // 的表现是发音分数**普遍偏低** —— 没有错误、没有日志,只有「这批学生分数怎么低了」。
- const source = audioContext.createMediaStreamSource(mediaStream)
- // 诊断录音管线是否健康:mic track 状态 + AudioContext 状态
- console.debug('[recorder] startRecording', {
- contextState: audioContext.state,
- requestedSampleRate: TARGET_SAMPLE_RATE,
- contextSampleRate: audioContext.sampleRate,
- sampleRateHonoured: audioContext.sampleRate === TARGET_SAMPLE_RATE,
- 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
- debugStartedAt = Date.now()
- // 收集 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 停止。onmessage 必须当场摘掉:'stop' 是投递到音频线程的异步消息,
- // 在它生效之前音频线程还会再吐几块,那几块会落进**下一段**录音的 pcmChunks
- // (pcmChunks 是跨 start/stop 复用的同一个闭包变量)。
- workletNode.port.onmessage = null
- 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
- const wallSeconds = (Date.now() - debugStartedAt) / 1000
- const samplesSeconds = totalSamples / sampleRate
- console.debug('[recorder] stopRecording final', {
- chunks: debugChunkCount,
- totalSamples,
- sampleRate,
- duration: samplesSeconds.toFixed(2) + 's',
- wallSeconds: wallSeconds.toFixed(2) + 's',
- // 1.0 = 正常。≈2 就是同时跑着两套采集图,采样重复 → 回放慢一半且刺啦
- sampleRatio: (samplesSeconds / Math.max(wallSeconds, 0.001)).toFixed(2),
- 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()
- // stopRecording() 之外的路径(reset / 卸载 / startRecording 失败)也会走到这里,
- // 那时 worklet 还挂着 onmessage。不摘掉它,孤儿 worklet 会继续往 pcmChunks 灌音频。
- if (workletNode) {
- workletNode.port.onmessage = null
- try { workletNode.port.postMessage('stop') } catch { /* context 已关闭 */ }
- try { workletNode.disconnect() } catch { /* 已断开 */ }
- workletNode = null
- }
- 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))
- }
- }
|