| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116 |
- /**
- * 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')
|