test-article-recorder-sample-rate.mjs 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. /**
  2. * useAudioRecorder 的采样率协商。
  3. *
  4. * 钉的是一件事:**上报的永远是实际拿到的采样率,不是我们要的那个**。
  5. * 这条不变量一破,后端就会按 16kHz 去解 48kHz 的数据 —— 回放慢三倍、评分全错,
  6. * 而整条链路没有任何一层会报错。三种浏览器行为都要走一遍。
  7. */
  8. import assert from 'node:assert/strict'
  9. import { readFile } from 'node:fs/promises'
  10. import ts from 'typescript'
  11. const sourceUrl = new URL(
  12. '../src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts',
  13. import.meta.url,
  14. )
  15. let source = await readFile(sourceUrl, 'utf8')
  16. // Vue runtime → 本地 shim(同 test-article-reading-engine.mjs 的做法)
  17. source = source.replace(
  18. "import { ref, onUnmounted } from 'vue'",
  19. `const ref = v => ({ value: v })
  20. const onUnmounted = () => {}`,
  21. )
  22. const compiled = ts.transpileModule(source, {
  23. compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
  24. }).outputText
  25. const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
  26. // ---- 浏览器环境替身 ----
  27. const HARDWARE_RATE = 48000
  28. function installEnvironment({ audioContextFactory }) {
  29. const track = {
  30. label: 'fake mic', enabled: true, muted: false, readyState: 'live',
  31. getSettings: () => ({}), stop() { this.readyState = 'ended' },
  32. }
  33. // Node 22 的 globalThis.navigator 只有 getter,赋值会抛 —— 用 defineProperty 盖掉
  34. Object.defineProperty(globalThis, 'navigator', {
  35. configurable: true,
  36. value: {
  37. mediaDevices: { getUserMedia: async () => ({ getTracks: () => [track], getAudioTracks: () => [track] }) },
  38. permissions: { query: async () => { throw new Error('unsupported') } },
  39. },
  40. })
  41. globalThis.AudioContext = audioContextFactory
  42. globalThis.AudioWorkletNode = class {
  43. constructor() { this.port = { onmessage: null, postMessage() {} } }
  44. connect() {}
  45. disconnect() {}
  46. }
  47. globalThis.DOMException = globalThis.DOMException ?? class extends Error {}
  48. }
  49. /** 造一个 AudioContext 类,`resolveRate` 决定它对 sampleRate 提示的反应。 */
  50. function makeAudioContextClass(resolveRate) {
  51. return class FakeAudioContext {
  52. static requested = []
  53. constructor(options) {
  54. FakeAudioContext.requested.push(options?.sampleRate ?? null)
  55. this.sampleRate = resolveRate(options?.sampleRate) // 可以抛
  56. this.state = 'running'
  57. this.destination = {}
  58. this.audioWorklet = { addModule: async () => {} }
  59. }
  60. createMediaStreamSource() { return { connect() {} } }
  61. createAnalyser() {
  62. return { fftSize: 0, frequencyBinCount: 256, connect() {}, getByteFrequencyData() {} }
  63. }
  64. async resume() { this.state = 'running' }
  65. async close() { this.state = 'closed' }
  66. }
  67. }
  68. async function startAndReadRate(AudioContextClass) {
  69. installEnvironment({ audioContextFactory: AudioContextClass })
  70. const recorder = mod.useAudioRecorder()
  71. await recorder.startRecording()
  72. const rate = recorder.sampleRate.value
  73. recorder.cleanup()
  74. return rate
  75. }
  76. // ---- 1. 浏览器支持:拿到 16kHz ----
  77. {
  78. const Ctx = makeAudioContextClass(hint => hint ?? HARDWARE_RATE)
  79. const rate = await startAndReadRate(Ctx)
  80. assert.equal(rate, 16000, '支持自定义采样率时应该拿到 16kHz')
  81. assert.deepEqual(Ctx.requested, [16000], '应该只构造一次,并且带着 16000 的提示')
  82. }
  83. // ---- 2. 浏览器拒绝(旧 iOS Safari):退回硬件默认,录音照常 ----
  84. {
  85. const Ctx = makeAudioContextClass(hint => {
  86. if (hint) throw new Error('NotSupportedError: sample rate not supported')
  87. return HARDWARE_RATE
  88. })
  89. const rate = await startAndReadRate(Ctx)
  90. assert.equal(rate, HARDWARE_RATE, '构造抛出时必须退回硬件默认,而不是整个录音失败')
  91. assert.deepEqual(Ctx.requested, [16000, null], '先试 16000,抛了再试不带提示的')
  92. }
  93. // ---- 3. 浏览器**默默无视**提示:上报真值,不是我们要的值 ----
  94. // 1 是「照办」、2 是「明着拒绝」,这条是第三种行为:收下提示、返回硬件值、不抛。
  95. // 前两条都覆盖不到它,而它正是最坏的那种失败 —— 没有异常、没有日志,只是后端
  96. // 从此按 16kHz 去解 48kHz 的数据。
  97. {
  98. const Ctx = makeAudioContextClass(() => HARDWARE_RATE) // 提示照收,返回值不变
  99. const rate = await startAndReadRate(Ctx)
  100. assert.equal(rate, HARDWARE_RATE, '浏览器无视提示时必须上报实际采样率')
  101. assert.notEqual(rate, 16000)
  102. }
  103. console.log('✓ test-article-recorder-sample-rate: 3 cases passed')