在日常开发服务端工具、自动化脚本或本地 CLI 工具时,我们经常有让程序“开口说话”的需求:
- 长时间运行的打包编译或数据爬虫任务完成时,发出语音通知;
- 本地守护进程监控到异常告警时,播报即时警报;
- 在完全离线、无外网连接的局域网或保密机房中,无法调用商业云厂商(如阿里云、腾讯云、Azure)收费且必须联网的 TTS API。
很多开发者第一时间会想到引入基于 Python 的 pyttsx3,但这需要客户端预先安装 Python 运行环境;或者尝试安装一些 npm 上的 C++ 语音扩展包,却经常在 node-gyp 和 Visual Studio 编译环境前频频受挫。
其实在 Windows 生态下,操作系统底层早已内置了非常成熟的语音合成引擎(SAPI / .NET System.Speech)。我们只需在 Node.js 中通过轻量子进程管道与系统内置的 PowerShell 协同,就能做到零第三方笨重依赖、完全离线、毫秒级启动的原生文本转语音(TTS)播报。
一、极简原型:一行代码让 Windows 开口发声
利用 Node.js 原生的 child_process 模块结合 iconv-lite,我们可以用一行代码快速验证这一思路:
1 2 3 4 5 6
| const { exec } = require('child_process'); const iconv = require('iconv-lite');
exec(`powershell.exe Add-Type -AssemblyName System.speech; $speak = New-Object System.Speech.Synthesis.SpeechSynthesizer; $speak.Rate = 2; $speak.Speak([Console]::In.ReadLine()); exit`) .stdin.end(iconv.encode('任务已构建完毕,请注意查收。', 'gbk'));
|
运行这段脚本,你的 Windows 扬声器中就会立刻传出清晰标准的系统中文语音播报!
二、底层拆解:代码究竟是如何跑通的?
这段看似小巧的代码背后,实际上巧妙地串联了三个维度的系统能力:
1 2 3 4 5 6 7
| [Node.js 运行时] │ ▼ (创建子进程 child_process) [PowerShell.exe] ──> [动态反射加载 .NET 程序集 System.Speech] │ ▼ (标准输入 stdin 管道) [SpeechSynthesizer 实例化] ──> [从底层音频通道输出原生语音 (SAPI)]
|
1. 动态加载 .NET System.Speech 运行时
PowerShell 作为 Windows 现代自动化外壳,拥有与 .NET 框架天然互通的能力。
Add-Type -AssemblyName System.speech;:指示 CLR 在运行时直接加载全局程序集缓存(GAC)中的 System.Speech.dll;New-Object System.Speech.Synthesis.SpeechSynthesizer;:实例化核心语音合成器对象。该对象底层直接调用 Windows 系统的 SAPI(Speech Application Programming Interface),拥有对本地已安装语音库的完全调度权。
2. 关键参数调节
$speak.Rate:控制语速,取值范围为 -10(极慢)到 10(极快),0 为标准自然语速;$speak.Volume:控制播放音量,取值范围为 0 到 100;$speak.Speak(...):执行同步语音合成与发声。
三、踩坑重难点:为什么必须使用 iconv-lite 转码?
很多同学在初次尝试模仿上述代码时,往往会写成如下看似自然的写法:
1 2
| child.stdin.end('任务构建完成');
|
然而这会导致语音播报变成一连串不知所云的乱码杂音,甚至系统直接哑火!
根因深度剖析:Windows 默认控制台代码页(CP936)
- Node.js 端:内部所有字符串在流式传输时,默认使用 UTF-8 字节编码;
- Windows PowerShell 端:在绝大多数简体中文 Windows 系统中,控制台默认代码页是 CP936(GBK / GB2312);
- 当 PowerShell 通过
[Console]::In.ReadLine() 读取标准输入流时,会按照系统的本地默认编码(GBK)去解析传入的字节流; - UTF-8 的多字节中文字符被强行按双字节 GBK 解码,直接导致严重的乱码错位(如“浣犲ソ”乱码),语音引擎解析到的完全是一串无意义的生僻字!
因此,必须引入 iconv-lite,显式将要朗读的文本以 gbk 编码灌入子进程的 stdin:
1 2
| iconv.encode(text, 'gbk');
|
💡 进阶技巧:如果确实不想引入任何第三方 npm 包(包括 iconv-lite),也可以在 PowerShell 会话启动时强制切换其标准输入解码器为 UTF-8:
1
| [Console]::InputEncoding = [System.Text.Encoding]::UTF8;
|
四、工业级封装:从脚本片段到 Promise 异步库
上述原型虽然精炼,但直接使用 exec 存在明显的工程缺陷:
- 缺乏 Promise 异步等待,主程序无法准确得知语音何时朗读完毕;
- 如果文本包含反引号或特殊转义字符,通过字符串拼接命令容易引发注入或报错;
- 无法方便地导出为离线音频文件。
我们将其封装为一个健壮的、开箱即用的现代化模块 win-tts.js:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105
| const { spawn } = require('child_process'); const iconv = require('iconv-lite');
class WindowsTTS {
static speak(text, options = {}) { return new Promise((resolve, reject) => { if (!text || typeof text !== 'string') { return resolve(); }
const { rate = 0, volume = 100, saveToFile } = options;
let psScript = ` Add-Type -AssemblyName System.speech; $synthesizer = New-Object System.Speech.Synthesis.SpeechSynthesizer; $synthesizer.Rate = ${Math.max(-10, Math.min(10, rate))}; $synthesizer.Volume = ${Math.max(0, Math.min(100, volume))}; `;
if (saveToFile) { psScript += ` $synthesizer.SetOutputToWaveFile('${saveToFile.replace(/'/g, "''")}'); $synthesizer.Speak([Console]::In.ReadLine()); $synthesizer.Dispose(); `; } else { // 默认扬声器实时发声 psScript += ` $synthesizer.Speak([Console]::In.ReadLine()); $synthesizer.Dispose(); `; }
// 使用 spawn 替代 exec,保障流式通道安全性 const psProcess = spawn('powershell.exe', [ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', psScript ], { windowsHide: true });
let errorOutput = '';
psProcess.stderr.on('data', chunk => { errorOutput += chunk.toString(); });
psProcess.on('close', code => { if (code === 0) { resolve(); } else { reject(new Error(`TTS 进程异常退出 (code ${code}): ${errorOutput}`)); } });
psProcess.on('error', err => { reject(err); });
// 以 GBK 编码灌入标准输入流并关闭输入端 const encodedBuffer = iconv.encode(text.replace(/[\r\n]+/g, ' '), 'gbk'); psProcess.stdin.end(encodedBuffer); }); }
/** * 查询本地 Windows 系统已安装的人声音色列表 */ static getInstalledVoices() { return new Promise((resolve, reject) => { const script = ` Add-Type -AssemblyName System.speech; $speak = New-Object System.Speech.Synthesis.SpeechSynthesizer; $speak.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }; `; const proc = spawn('powershell.exe', ['-NoProfile', '-Command', script], { windowsHide: true }); let output = '';
proc.stdout.on('data', data => { output += data.toString(); }); proc.on('close', code => { if (code === 0) { resolve(output.split(/[\r\n]+/).map(v => v.trim()).filter(Boolean)); } else { reject(new Error('获取音色列表失败')); } }); }); } }
module.exports = WindowsTTS;
|
五、场景实战与使用方式
1. 异步链式顺序播报
有了 Promise 封装,多个长句子可以自然地按顺序依次播放,避免了并发语音重叠打架:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const WindowsTTS = require('./win-tts');
async function main() { console.log('正在获取当前系统已安装的音色...'); const voices = await WindowsTTS.getInstalledVoices(); console.log('可用系统音色:', voices);
console.log('开始播报第一段...'); await WindowsTTS.speak('第一阶段:数据库备份完成。', { rate: 1 });
console.log('开始播报第二段...'); await WindowsTTS.speak('第二阶段:所有节点服务重启正常,系统就绪。', { rate: 2, volume: 80 });
console.log('全部播报完毕!'); }
main();
|
2. 离线生成语音音频文件(WAV)
当我们需要离线批量生成某些提示音并上传 CDN 时,该方案还能直接充当离线语音生成器,无需联网调用收费 API:
1 2 3 4 5
| await WindowsTTS.speak('警告:服务器 CPU 占用已突破 90%,请即刻检查!', { saveToFile: './alert.wav', rate: 0 });
|
六、跨平台全景思考
| 操作系统 | 推荐原生离线方案 | 实现方式 |
|---|
| Windows | PowerShell + System.Speech | 原生内置,免装依赖,支持高质量中文库(如 Huihui/Kangkang) |
| macOS | 系统自带 say 命令行 | 直接 spawn('say', ['-v', 'Ting-Ting', text]),原生顶尖语音库 |
| Linux (Ubuntu/CentOS) | espeak-ng 或 festival | 需要通过 apt install espeak-ng 安装,音质偏向机械电子音 |
七、总结
在特定技术场景下,最优雅的解法往往不是盲目引入数百兆的重型运行时或花费昂贵的第三方云服务,而是充分挖掘操作系统自带的原生基础设施。
通过 Node.js 与 Windows .NET System.Speech 的跨进程轻量级握手,并妥善解决编码与流控制问题,我们以极低的代码成本实现了高可用、完全离线、毫秒级响应的工业级文本转语音管道。
参考与扩展链接