nls-test-audio.mjs 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. /**
  2. * NLS 验证用的**测试音频**工具:造语音 + 转成 NLS 要的 16k/16bit/单声道 PCM。
  3. *
  4. * 为什么用系统 TTS 造:验证识别需要真语音,而仓库里没有音频素材。
  5. * Windows 自带的 `System.Speech`(中文机器上一般有 `Microsoft Huihui Desktop`)
  6. * 能把任意文本合成 wav —— 于是"说一句话 → 看识别结果"这条链路可以**全自动**验。
  7. *
  8. * 产物放在 `fixtures/_*`(按 .gitignore 不入库:它是产物,不是资料)。
  9. */
  10. import { execFileSync } from 'node:child_process';
  11. import { existsSync, mkdirSync, readFileSync } from 'node:fs';
  12. import { dirname, join } from 'node:path';
  13. import { fileURLToPath } from 'node:url';
  14. const HERE = dirname(fileURLToPath(import.meta.url));
  15. export const FIXTURE_DIR = join(HERE, 'fixtures');
  16. export const SPEECH_WAV = join(FIXTURE_DIR, '_nls-speech.wav');
  17. /** 合成一段中文语音到 `fixtures/_nls-speech.wav`,返回文件路径 */
  18. export const synthesizeSpeechToWav = (text, voice = 'Microsoft Huihui Desktop') => {
  19. mkdirSync(FIXTURE_DIR, { recursive: true });
  20. const escaped = String(text).replace(/'/g, "''");
  21. const script = [
  22. 'Add-Type -AssemblyName System.Speech',
  23. '$s = New-Object System.Speech.Synthesis.SpeechSynthesizer',
  24. `try { $s.SelectVoice('${voice}') } catch { }`,
  25. `$s.SetOutputToWaveFile('${SPEECH_WAV.replace(/\\/g, '\\\\')}')`,
  26. `$s.Speak('${escaped}')`,
  27. '$s.Dispose()',
  28. ].join('; ');
  29. execFileSync('powershell.exe', ['-NoProfile', '-Command', script], { stdio: 'inherit' });
  30. return SPEECH_WAV;
  31. };
  32. /** 本机有没有可用的 TTS(没有就跳过真实识别那一段) */
  33. export const canSynthesizeSpeech = () => process.platform === 'win32';
  34. /** WAV(RIFF)→ `{ sampleRate, channels, bits, data }`;不是 RIFF 就返回 null(当作裸 PCM) */
  35. export const parseWav = (buffer) => {
  36. if (buffer.length < 44 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') {
  37. return null;
  38. }
  39. let offset = 12;
  40. let fmt = null;
  41. let data = null;
  42. while (offset + 8 <= buffer.length) {
  43. const id = buffer.toString('ascii', offset, offset + 4);
  44. const size = buffer.readUInt32LE(offset + 4);
  45. const body = buffer.subarray(offset + 8, offset + 8 + size);
  46. if (id === 'fmt ') {
  47. fmt = {
  48. format: body.readUInt16LE(0),
  49. channels: body.readUInt16LE(2),
  50. sampleRate: body.readUInt32LE(4),
  51. bits: body.readUInt16LE(14),
  52. };
  53. } else if (id === 'data') {
  54. data = body;
  55. }
  56. offset += 8 + size + (size % 2); // 块按偶数字节对齐
  57. }
  58. return fmt && data ? { ...fmt, data } : null;
  59. };
  60. /** 线性插值重采样到 16k(int16 采样进、int16 采样出) */
  61. export const resampleTo16k = (samples, fromRate) => {
  62. if (Math.round(fromRate) === 16000) return samples;
  63. const count = Math.round((samples.length * 16000) / fromRate);
  64. const out = new Int16Array(count);
  65. const step = (samples.length - 1) / Math.max(count - 1, 1);
  66. for (let i = 0; i < count; i++) {
  67. const pos = i * step;
  68. const before = Math.floor(pos);
  69. const after = Math.min(Math.ceil(pos), samples.length - 1);
  70. const ratio = pos - before;
  71. out[i] = Math.round(samples[before] + (samples[after] - samples[before]) * ratio);
  72. }
  73. return out;
  74. };
  75. /**
  76. * 读任意 WAV / 裸 PCM → **16k / 单声道 / 16bit 小端** 的 Buffer。
  77. * 非 16bit 直接报错(宁可报错,也不要静默送错格式给网关)。
  78. */
  79. export const readAudioAsPcm16kMono = (file) => {
  80. if (!existsSync(file)) throw new Error(`音频文件不存在:${file}`);
  81. const buffer = readFileSync(file);
  82. const wav = parseWav(buffer);
  83. if (!wav) return { pcm: buffer, note: '裸 PCM(按 16k/16bit/mono 直接用)' };
  84. if (wav.bits !== 16) throw new Error(`只支持 16bit WAV,实际 ${wav.bits}bit`);
  85. let samples = new Int16Array(wav.data.buffer, wav.data.byteOffset, Math.floor(wav.data.byteLength / 2));
  86. if (wav.channels === 2) {
  87. const mono = new Int16Array(Math.floor(samples.length / 2));
  88. for (let i = 0; i < mono.length; i++) mono[i] = Math.round((samples[i * 2] + samples[i * 2 + 1]) / 2);
  89. samples = mono;
  90. }
  91. const resampled = resampleTo16k(samples, wav.sampleRate);
  92. return {
  93. pcm: Buffer.from(resampled.buffer, resampled.byteOffset, resampled.byteLength),
  94. note: `WAV ${wav.sampleRate}Hz/${wav.channels}ch/${wav.bits}bit → 重采样为 16k 单声道`,
  95. };
  96. };