| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104 |
- /**
- * 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 单声道`,
- };
- };
|