/** * useAudioRecorder 的采样率协商。 * * 钉的是一件事:**上报的永远是实际拿到的采样率,不是我们要的那个**。 * 这条不变量一破,后端就会按 16kHz 去解 48kHz 的数据 —— 回放慢三倍、评分全错, * 而整条链路没有任何一层会报错。三种浏览器行为都要走一遍。 */ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import ts from 'typescript' const sourceUrl = new URL( '../src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts', import.meta.url, ) let source = await readFile(sourceUrl, 'utf8') // Vue runtime → 本地 shim(同 test-article-reading-engine.mjs 的做法) source = source.replace( "import { ref, onUnmounted } from 'vue'", `const ref = v => ({ value: v }) const onUnmounted = () => {}`, ) const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, }).outputText const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`) // ---- 浏览器环境替身 ---- const HARDWARE_RATE = 48000 function installEnvironment({ audioContextFactory }) { const track = { label: 'fake mic', enabled: true, muted: false, readyState: 'live', getSettings: () => ({}), stop() { this.readyState = 'ended' }, } // Node 22 的 globalThis.navigator 只有 getter,赋值会抛 —— 用 defineProperty 盖掉 Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { mediaDevices: { getUserMedia: async () => ({ getTracks: () => [track], getAudioTracks: () => [track] }) }, permissions: { query: async () => { throw new Error('unsupported') } }, }, }) globalThis.AudioContext = audioContextFactory globalThis.AudioWorkletNode = class { constructor() { this.port = { onmessage: null, postMessage() {} } } connect() {} disconnect() {} } globalThis.DOMException = globalThis.DOMException ?? class extends Error {} } /** 造一个 AudioContext 类,`resolveRate` 决定它对 sampleRate 提示的反应。 */ function makeAudioContextClass(resolveRate) { return class FakeAudioContext { static requested = [] constructor(options) { FakeAudioContext.requested.push(options?.sampleRate ?? null) this.sampleRate = resolveRate(options?.sampleRate) // 可以抛 this.state = 'running' this.destination = {} this.audioWorklet = { addModule: async () => {} } } createMediaStreamSource() { return { connect() {} } } createAnalyser() { return { fftSize: 0, frequencyBinCount: 256, connect() {}, getByteFrequencyData() {} } } async resume() { this.state = 'running' } async close() { this.state = 'closed' } } } async function startAndReadRate(AudioContextClass) { installEnvironment({ audioContextFactory: AudioContextClass }) const recorder = mod.useAudioRecorder() await recorder.startRecording() const rate = recorder.sampleRate.value recorder.cleanup() return rate } // ---- 1. 浏览器支持:拿到 16kHz ---- { const Ctx = makeAudioContextClass(hint => hint ?? HARDWARE_RATE) const rate = await startAndReadRate(Ctx) assert.equal(rate, 16000, '支持自定义采样率时应该拿到 16kHz') assert.deepEqual(Ctx.requested, [16000], '应该只构造一次,并且带着 16000 的提示') } // ---- 2. 浏览器拒绝(旧 iOS Safari):退回硬件默认,录音照常 ---- { const Ctx = makeAudioContextClass(hint => { if (hint) throw new Error('NotSupportedError: sample rate not supported') return HARDWARE_RATE }) const rate = await startAndReadRate(Ctx) assert.equal(rate, HARDWARE_RATE, '构造抛出时必须退回硬件默认,而不是整个录音失败') assert.deepEqual(Ctx.requested, [16000, null], '先试 16000,抛了再试不带提示的') } // ---- 3. 浏览器**默默无视**提示:上报真值,不是我们要的值 ---- // 1 是「照办」、2 是「明着拒绝」,这条是第三种行为:收下提示、返回硬件值、不抛。 // 前两条都覆盖不到它,而它正是最坏的那种失败 —— 没有异常、没有日志,只是后端 // 从此按 16kHz 去解 48kHz 的数据。 { const Ctx = makeAudioContextClass(() => HARDWARE_RATE) // 提示照收,返回值不变 const rate = await startAndReadRate(Ctx) assert.equal(rate, HARDWARE_RATE, '浏览器无视提示时必须上报实际采样率') assert.notEqual(rate, 16000) } console.log('✓ test-article-recorder-sample-rate: 3 cases passed')