/** * NLS 验证用的**测试音频**工具:造语音 + 转成 NLS 要的 16k/16bit/单声道 PCM。 * * 为什么用系统 TTS 造:验证识别需要真语音,而仓库里没有音频素材。 * Windows 自带的 `System.Speech`(中文机器上一般有 `Microsoft Huihui Desktop`) * 能把任意文本合成 wav —— 于是"说一句话 → 看识别结果"这条链路可以**全自动**验。 * * 产物放在 `fixtures/_*`(按 .gitignore 不入库:它是产物,不是资料)。 */ import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); export const FIXTURE_DIR = join(HERE, 'fixtures'); export const SPEECH_WAV = join(FIXTURE_DIR, '_nls-speech.wav'); /** 合成一段中文语音到 `fixtures/_nls-speech.wav`,返回文件路径 */ export const synthesizeSpeechToWav = (text, voice = 'Microsoft Huihui Desktop') => { mkdirSync(FIXTURE_DIR, { recursive: true }); const escaped = String(text).replace(/'/g, "''"); const script = [ 'Add-Type -AssemblyName System.Speech', '$s = New-Object System.Speech.Synthesis.SpeechSynthesizer', `try { $s.SelectVoice('${voice}') } catch { }`, `$s.SetOutputToWaveFile('${SPEECH_WAV.replace(/\\/g, '\\\\')}')`, `$s.Speak('${escaped}')`, '$s.Dispose()', ].join('; '); execFileSync('powershell.exe', ['-NoProfile', '-Command', script], { stdio: 'inherit' }); return SPEECH_WAV; }; /** 本机有没有可用的 TTS(没有就跳过真实识别那一段) */ export const canSynthesizeSpeech = () => process.platform === 'win32'; /** WAV(RIFF)→ `{ sampleRate, channels, bits, data }`;不是 RIFF 就返回 null(当作裸 PCM) */ export const parseWav = (buffer) => { if (buffer.length < 44 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') { return null; } let offset = 12; let fmt = null; let data = null; while (offset + 8 <= buffer.length) { const id = buffer.toString('ascii', offset, offset + 4); const size = buffer.readUInt32LE(offset + 4); const body = buffer.subarray(offset + 8, offset + 8 + size); if (id === 'fmt ') { fmt = { format: body.readUInt16LE(0), channels: body.readUInt16LE(2), sampleRate: body.readUInt32LE(4), bits: body.readUInt16LE(14), }; } else if (id === 'data') { data = body; } offset += 8 + size + (size % 2); // 块按偶数字节对齐 } return fmt && data ? { ...fmt, data } : null; }; /** 线性插值重采样到 16k(int16 采样进、int16 采样出) */ export const resampleTo16k = (samples, fromRate) => { if (Math.round(fromRate) === 16000) return samples; const count = Math.round((samples.length * 16000) / fromRate); const out = new Int16Array(count); const step = (samples.length - 1) / Math.max(count - 1, 1); for (let i = 0; i < count; i++) { const pos = i * step; const before = Math.floor(pos); const after = Math.min(Math.ceil(pos), samples.length - 1); const ratio = pos - before; out[i] = Math.round(samples[before] + (samples[after] - samples[before]) * ratio); } return out; }; /** * 读任意 WAV / 裸 PCM → **16k / 单声道 / 16bit 小端** 的 Buffer。 * 非 16bit 直接报错(宁可报错,也不要静默送错格式给网关)。 */ export const readAudioAsPcm16kMono = (file) => { if (!existsSync(file)) throw new Error(`音频文件不存在:${file}`); const buffer = readFileSync(file); const wav = parseWav(buffer); if (!wav) return { pcm: buffer, note: '裸 PCM(按 16k/16bit/mono 直接用)' }; if (wav.bits !== 16) throw new Error(`只支持 16bit WAV,实际 ${wav.bits}bit`); let samples = new Int16Array(wav.data.buffer, wav.data.byteOffset, Math.floor(wav.data.byteLength / 2)); if (wav.channels === 2) { const mono = new Int16Array(Math.floor(samples.length / 2)); for (let i = 0; i < mono.length; i++) mono[i] = Math.round((samples[i * 2] + samples[i * 2 + 1]) / 2); samples = mono; } const resampled = resampleTo16k(samples, wav.sampleRate); return { pcm: Buffer.from(resampled.buffer, resampled.byteOffset, resampled.byteLength), note: `WAV ${wav.sampleRate}Hz/${wav.channels}ch/${wav.bits}bit → 重采样为 16k 单声道`, }; };