简介:面向希望在桌面程序中加入语音识别、语音合成与语音命令控制的开发者,这套 Speech SDK 5.1 及中文语言包适合入门语音交互、教学演示和旧项目兼容维护,也便于需要补齐微软语音技术栈但不想从零搭建环境的中级开发者快速验证方案。压缩包共 15 个文件,以 4 个 exe 安装引导程序、3 个 cab 语言与数据压缩包、2 个 msi 主安装包为主,另含 chm 帮助文档、ini 配置及 htm/inf 说明文件,整体约 147.88MB,兼顾部署与查阅。安装时先装 Speech SDK 5.1,再装中文语言包,安装路径下附带示例可供参考。已有 4640 人学习下载。读者可获得 SDK 本体、中文语音支持、安装说明和官方帮助文档,并借助示例理解识别与合成的调用方式,了解授权与重分发说明,减少环境配置、语言包匹配和兼容性排查上的摸索成本。
1. 先别急着装:Speech SDK 5.1 到底是什么东西
接手老工控机的人大概都碰过这种局面:现场没有外网,机箱后面只有一个 3.5 接口,业务方的要求朴素得让人感动——告警文本能念出来,三五个命令词能听出来。这时候被翻出来的往往是 Speech SDK 5.1 这类老包:一套 COM 组件加一个中文语音包,装上之后 TTS 语音识别两件事都能在本机闭环,不碰任何在线接口。它不是先进方案,但在「断网 + 老系统 + 能用就行」的约束下,它是最省事的一档。下面按我拆包的顺序写:先把组件拓扑和注册表落点看清楚,再跑通发声与识别,然后聊参数边界和几个血泪坑,最后讲怎么把它塞进现代工程里当兜底引擎。适合手上还有 VB6、.NET Framework 或 MFC 老项目要续命的人,也适合想搞明白 SAPI 这套东西究竟怎么运转的人。
2. 组件拓扑拆解:COM 分层与中文包的注册表落点
对这套东西的误解,八成来自把它当成一个「库」。它不是库,是「COM 运行时 + 注册表索引 + 若干独立引擎 DLL」的组合。开发包装完之后,真正被注册进系统的只有三样:sapi.dll 这个 COM 运行时、一批示例程序、一份文档。中文能不能念出来,取决于语言包有没有把对应的引擎 token 写进注册表,跟开发包本身装没装成功关系不大。所以排查顺序永远是:先看 token,再看进程位数,最后才怀疑代码。
2.1 三层结构:应用层、运行时层、引擎层
应用层拿到的是接口指针:TTS 侧是ISpVoice,识别侧是ISpRecognizer加ISpRecoContext。这两个接口由 sapi.dll 实现,CoCreateInstance(CLSID_SpVoice)创建的就是运行时对象,跟具体哪家的引擎没有关系。
运行时层做三件事。第一是 token 枚举与实例化——SpEnumTokens遍历注册表,SpCreateObjectFromToken按 token 里的 CLSID 把引擎 DLL 拉起来。第二是音频流路由,ISpStream、ISpStreamFormatConverter负责把合成结果写到文件、内存或设备。第三是事件队列,TTS 的ISpNotifySource、识别的SPEI_RECOGNITION都从这里出来,窗口消息模式和回调模式二选一。
引擎层是真正的 DLL。TTS 引擎实现的是ISpTTSEngine,识别引擎躲在ISpRecognizer后面实现ISpSREngine。这两类引擎互不相干,一个语言包里可能只有 TTS 没有识别,也可能反过来,装完必须分别验证。
这个分层最实用的一点:CoCreateInstance返回S_OK只证明运行时在,不证明有引擎。太多人卡在「代码没报错但没声音」,就是把这半步当成了全部。
所谓 token,本质就是一个注册表项。Attributes子键里放Name、Language、Gender、Age、Vendor,另外还有一组CLSID子键指向引擎类和数据文件。运行时按属性过滤,按 CLSID 加载,就这么简单。理解了这一点,「引擎列表为空」这类问题基本都能自己推出来。
2.2 中文语音包往注册表里写了什么
不同版本的语言包写入内容略有差异,但结构是固定的。下面这张表是我排查时必看的几处:
| 注册表位置 | 内容 | 什么时候会用到 |
|---|---|---|
HKLM\SOFTWARE\Microsoft\Speech\Voices\Tokens\<TokenId>\Attributes | Name / Language / Gender / Age / Vendor | 枚举引擎、按语言过滤、选默认引擎 |
HKLM\SOFTWARE\Microsoft\Speech\Voices\Tokens\<TokenId>\CLSID | 引擎类 CLSID 与数据文件路径 | CoCreateInstance时定位 DLL |
HKLM\SOFTWARE\Microsoft\Speech\Recognizers\Tokens | 识别引擎 token | 创建ISpRecognizer |
HKCU\SOFTWARE\Microsoft\Speech\Voices | 当前用户默认引擎 | 代码里不调SetVoice时用哪个 |
其中Language的值是十六进制 LCID,不带0x前缀。常见几个记牢:409是英文(en-US),804是简体中文(zh-CN),404是繁体中文(zh-TW),411是日文(ja-JP)。写过滤条件时直接写Language=804,比在代码里比对描述字符串靠谱得多——描述是本地化文本,换个系统语言就变了。
用 PowerShell 一次性把 32 位视图下的引擎列出来,比翻注册表编辑器快:
# 列出 32 位视图下所有语音 Token 及其关键属性 $root = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Speech\Voices\Tokens' Get-ChildItem $root -ErrorAction SilentlyContinue | ForEach-Object { $attr = Get-ItemProperty "$($_.PSPath)\Attributes" -ErrorAction SilentlyContinue [pscustomobject]@{ Token = $_.PSChildName Name = $attr.Name Language = $attr.Language # 804 = 简体中文 Gender = $attr.Gender Vendor = $attr.Vendor } } | Format-Table -AutoSize这段脚本读的是WOW6432Node分支,也就是 32 位进程视角。如果输出为空,说明语言包压根没写进这一支;如果输出有内容但程序里枚举不到,问题多半在位数上,往下看 2.3。
提示:
Language为空或者不存在,不代表引擎不能用,但用Language=804过滤时会把它漏掉。碰到「明明装了却过滤不出来」,先把Language去掉再枚举一遍。
2.3 32 位与 64 位:两套注册表视图是最大的坑源
注册表重定向这件事,做 Windows 开发的人多少知道,但很少有人在语音这块第一时间想到。规则很简单:一个 32 位进程访问HKLM\SOFTWARE\...时,会被透明重定向到HKLM\SOFTWARE\WOW6432Node\...;64 位进程则不重定向,直接读原路径。
Speech SDK 5.1 时代的引擎 DLL 全是 32 位的。语言包的安装程序本身也是 32 位,它写注册表时被重定向,token 全落在WOW6432Node下面。而你在 Visual Studio 里新建的 C# 项目默认是AnyCPU,在 64 位系统上跑起来就是 64 位进程,SpEnumTokens读的是 64 位视图,那里没有 5.1 语言包写的 token,枚举结果自然是零。
更迷惑的是它不报错。CoCreateInstance成功,Speak返回S_OK,就是不出声,因为运行时找不到引擎时会退化到默认引擎,而默认引擎可能是个英文的。
判断方法很土但有效:任务管理器里看进程名后面有没有「(32 位)」;代码里打一行IntPtr.Size,4 就是 32 位,8 就是 64 位。解决方式两条:一是把主程序的平台目标改成 x86;二是单独做一个 32 位的宿主 exe 专门跑语音,主程序通过管道或本地回环调它。工程上我更倾向后者,后面第 6 章展开。
还有一个容易被忽略的点:64 位视图里并非「完全没有中文」。系统自带的 SAPI 5.3/5.4 中文引擎是 64 位的,token 在 64 位视图里躺着。所以 64 位进程跑起来可能有中文声音,但那不是 5.1 语言包里的那个引擎,音色、断句、支持的采样率都不一样。搞清楚这个区别,能省掉半天自我怀疑。
2.4 与系统自带语音组件的共存关系
从 Windows 7 开始,系统自带 SAPI 5.3/5.4,token 和 5.1 写在同一个Speech树里,共用同一套枚举逻辑。麻烦在于 sapi.dll:5.1 安装包里带的是旧版本,安装过程可能把相关 CLSID 指向旧 DLL,结果是系统自带的 TTS、讲述人或者别的依赖语音的程序行为异常。
我的习惯是先打快照再装,装完立刻做两件事验证:一是用系统自带的朗读功能念一段中文,二是随便找个依赖 SAPI 的老程序跑一遍。两个都正常再往下做开发。真出问题,卸载 5.1 之后用系统文件检查把组件修回来,具体命令按现场环境定,别在产线上试。
反过来说,如果你的需求只是「中文 TTS」,很多机器上根本不需要装 5.1——系统自带的中文引擎就够用,而且位数齐全。5.1 真正的价值在三处:老代码的接口兼容(ISpVoice那一套写法)、随包附带的示例与文档、以及某些版本里带的那个中文识别引擎。先想清楚要哪一样,再决定装不装。
3. 从枚举到出声:三条路径跑通 TTS 与一次识别
动手阶段我建议按「体检 → 托管层出声 → COM 层出声 → 识别闭环」的顺序走。每一步都有独立可验证的产物,出问题能立刻定位到哪一层,不会一上来就在 COM 的HRESULT里泡着。
3.1 引擎体检:用 SpEnumTokens 把家底列清楚
先写一个不到 30 行的小程序,把当前进程能看到的引擎、描述、语言属性全打出来。这段代码后面会反复用到,建议单独建个工程留着。
#include <windows.h> #include <sapi.h> #include <sphelper.h> #include <atlbase.h> #include <cstdio> #pragma comment(lib, "sapi.lib") int wmain() { ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); CComPtr<IEnumSpObjectTokens> pEnum; HRESULT hr = SpEnumTokens(SPCAT_VOICES, nullptr, nullptr, &pEnum); if (FAILED(hr)) { wprintf(L"SpEnumTokens failed 0x%08X\n", hr); return 1; } DWORD count = 0; pEnum->GetCount(&count); wprintf(L"process bits=%d, voices=%lu\n", (int)(sizeof(void*) * 8), count); for (DWORD i = 0; i < count; ++i) { CComPtr<ISpObjectToken> pToken; if (FAILED(pEnum->Item(i, &pToken))) continue; CSpDynamicString dsDesc, dsLang, dsName; SpGetDescription(pToken, &dsDesc); // 本地化描述,仅用于显示 pToken->GetStringValue(L"Language", &dsLang); // 十六进制 LCID pToken->GetStringValue(L"Name", &dsName); // 稳定的英文名 wprintf(L"[%lu] %s | lang=%s | name=%s\n", i, (LPCWSTR)dsDesc, dsLang ? (LPCWSTR)dsLang : L"?", dsName ? (LPCWSTR)dsName : L"?"); } ::CoUninitialize(); return 0; }逻辑很直白:SpEnumTokens的第一个参数是类别 ID,SPCAT_VOICES对应HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Speech\Voices;第二、三个参数是必需属性和可选属性,都传nullptr表示不过滤。GetStringValue读的正是 token 的Attributes子键。
要注意的是SpGetDescription返回的是随系统语言变化的描述文本,只适合打印给人看;做逻辑判断一律用Name或者Language。我见过有人拿描述字符串去if (desc == "Microsoft Simplified Chinese Voice"),换台机器就失效。
编译时如果提示找不到sphelper.h,把开发包的 include 目录加进工程;链接要带sapi.lib。工程配置成 Win32,否则你看到的就是 64 位视图。
3.2 托管层出声:C# 用 System.Speech 最快验证
确认引擎在之后,先用托管层跑一遍,把「引擎能用」和「我的代码能用」两件事分开验证。
using System; using System.Speech.Synthesis; class Program { static void Main() { using (var synth = new SpeechSynthesizer()) { // 1) 枚举并打印,确认中文引擎在列表里 foreach (var v in synth.GetInstalledVoices()) { var info = v.VoiceInfo; Console.WriteLine($"{info.Name} | {info.Culture} | {info.Gender} | enabled={v.Enabled}"); } // 2) 选引擎:名称以枚举结果为准,别照抄 synth.SelectVoice("Microsoft Simplified Chinese Voice"); // 3) 参数:Rate -10..10,Volume 0..100 synth.Rate = -1; synth.Volume = 90; // 4) 落成 WAV,避开现场没有扬声器的情况 synth.SetOutputToWaveFile( @"out.wav", new SpeechAudioFormatInfo(16000, AudioBitsPerSample.Sixteen, AudioChannel.Mono)); synth.Speak("这是一段用于验证引擎是否可用的测试语音。"); } } }三个参数值得展开说。Rate是相对值不是倍率,0为引擎默认,负值变慢、正值变快,中文引擎在±3以外就开始吞字。Volume范围0~100,实测低于 40 时响度衰减非常明显。SetOutputToWaveFile的第二个参数是输出格式,必须落在引擎支持的集合里——常见支持 8k、11.025k、16k、22.05k、16 位、单声道,乱填可能得到一个能生成但播放是噪音的文件。
工程属性里把平台目标设为x86。用 .NET Framework 的话System.Speech在框架里;用新版 .NET 需要单独引 NuGet 上的System.Speech包,而且只在 Windows 上有效。
3.3 COM 层出声:ISpVoice 直连,控制粒度最大
托管层够用,但有些老项目必须走 COM,或者你需要在合成过程中拿到 SSML、事件、流重定向这些能力。这时候直接操作ISpVoice。
#include <windows.h> #include <sapi.h> #include <sphelper.h> #include <atlbase.h> #include <cstdio> #pragma comment(lib, "sapi.lib") int wmain() { ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); HRESULT hr = S_OK; CComPtr<ISpVoice> pVoice; hr = pVoice.CoCreateInstance(CLSID_SpVoice); if (FAILED(hr)) { wprintf(L"create voice failed 0x%08X\n", hr); return 1; } // 按 Language=804 过滤出简体中文引擎 CComPtr<IEnumSpObjectTokens> pEnum; if (SUCCEEDED(SpEnumTokens(SPCAT_VOICES, L"Language=804", nullptr, &pEnum))) { CComPtr<ISpObjectToken> pToken; if (SUCCEEDED(pEnum->Item(0, &pToken)) && pToken) pVoice->SetVoice(pToken); // 失败会保持原引擎,务必检查返回值 } pVoice->SetRate(-1); // -10..10 pVoice->SetVolume(90); // 0..100 // 绑定输出流,格式必须与后续 Speak 的输出一致 CSpStreamFormat fmt(SPSF_16kHz16BitMono, &hr); if (FAILED(hr)) return 2; CComPtr<ISpStream> pStream; pStream.CoCreateInstance(CLSID_SpStream); hr = pStream->BindToFile(L"out_com.wav", SPFM_CREATE_ALWAYS, &fmt.FormatId(), fmt.WAVEFORMATEX(), 0); if (FAILED(hr)) { wprintf(L"BindToFile failed 0x%08X\n", hr); return 3; } pVoice->SetOutput(pStream, FALSE); // FALSE = 不接管流的生命周期 hr = pVoice->Speak(L"<speak>合成测试<break time=\"300ms\"/>第二句。</speak>", SPF_IS_XML | SPF_PURGEBEFORESPEAK, nullptr); if (FAILED(hr)) { wprintf(L"Speak failed 0x%08X\n", hr); return 4; } pVoice->WaitUntilDone(INFINITE); // 同步等待,文件写完才返回 pStream->Close(); ::CoUninitialize(); return 0; }标志位是这条路线的核心。SPF_IS_XML表示文本按 SSML 解析,不加就按纯文本念,标签会被逐字读出来;SPF_PURGEBEFORESPEAK打断当前正在念的内容;要异步就加SPF_ASYNC,此时Speak立即返回,靠WaitUntilDone(0)轮询状态或者挂ISpNotifySource收消息。
BindToFile的格式参数和后续写出的格式必须一致,否则 WAV 头里的采样率和实际数据对不上,播放器放出来是变调的噪音。这是新手最常见的翻车点之一,而且现象很隐蔽——文件大小正常,就是声音不对。
SetVoice的返回值千万别忽略。token 无效时它返回失败但引擎不变,程序继续用默认引擎念英文,你会以为中文包装坏了。
3.4 识别侧最小闭环:命令词先跑通,再谈听写
识别我建议永远从命令词开始。中文大词汇量听写在这套老引擎上效果很一般,命令词才是它的舒适区。
先确认装了哪些识别引擎:
using System; using System.Speech.Recognition; foreach (var r in SpeechRecognitionEngine.InstalledRecognizers()) Console.WriteLine($"{r.Id} | {r.Culture} | {r.Description}");如果列表里没有zh-CN,后面的代码会直接抛ArgumentException,别浪费时间调参数。有的话,跑这个最小闭环:
using System; using System.Speech.Recognition; class Program { static void Main() { // 1) 指定中文引擎,并加载命令词 using (var engine = new SpeechRecognitionEngine(new System.Globalization.CultureInfo("zh-CN"))) { var choices = new Choices("打开一号泵", "关闭一号泵", "急停"); engine.LoadGrammar(new Grammar(new GrammarBuilder(choices))); // 2) 输入源与超时 engine.SetInputToDefaultAudioDevice(); engine.InitialSilenceTimeout = TimeSpan.FromSeconds(5); engine.BabbleTimeout = TimeSpan.FromSeconds(3); engine.EndSilenceTimeout = TimeSpan.FromMilliseconds(400); // 3) 置信度门槛,0~100,越高越保守 engine.UpdateRecognizerSetting("CFGConfidenceRejectionThreshold", 60); engine.SpeechRecognized += (s, e) => Console.WriteLine($"text={e.Result.Text} conf={e.Result.Confidence:F2}"); engine.RecognizeAsync(RecognizeMode.Multiple); Console.ReadLine(); } } }CFGConfidenceRejectionThreshold是最值得调的一个参数。现场噪声大、误触发多,往上推到 70~80;识别太迟钝、老是识别不出来,降到 50 附近。BabbleTimeout控制「持续噪声多久后放弃」,EndSilenceTimeout决定断句灵敏度,中文说快了容易在字间停顿被误判成句尾,把这个值适当加大能明显改善。
走 C++ 的话,CLSID_SpSharedRecognizer是共享识别器(多个进程共用一个麦克风,需要消息泵),CLSID_SpInprocRecognizer是进程内独占。语法用ISpRecoGrammar::LoadCmdFromFile加载 SRGS 文件,SetRuleState(NULL, NULL, SGDS_ACTIVE, NULL)激活,然后在消息循环里用CSpEvent取SPEI_RECOGNITION。共享识别器没有消息泵就收不到事件,这是另一个高频坑。
4. 参数与边界:让中文念得能听、识别不误触
代码跑通只是及格线,真正决定这套东西能不能交付的是参数。中文引擎的调参空间比英文窄得多,有些旋钮看着有、拧下去没反应,得先知道边界在哪。
4.1 Rate 与 Volume:能调的只有这两个半
Rate的取值是-10到10的整数,代表的是引擎内部的语速档位而不是倍率。同一个数值在不同引擎上的实际语速完全不一样,所以别把Rate = 2当成「快 20%」写进需求文档。
| Rate | 中文主观听感 | 适用场景 |
|---|---|---|
| -3 ~ -2 | 明显放慢,字间停顿拉长 | 面向老人的提示、需要听清的号码播报 |
| -1 ~ 1 | 接近自然语速 | 大多数告警播报 |
| 2 ~ 3 | 偏快但仍可懂 | 高频短提示,如「已确认」 |
| ≥ 5 | 开始吞字、连读变形 | 不建议用于需要听清的内容 |
Volume是0~100,实测在 40 以下响度衰减很陡,如果现场需要更细的音量控制,建议保持Volume在 80~100,改用播放侧的混音器调。第三个「半」是音高:ISpVoice根本没有SetPitch方法,只能靠 SSML 的<pitch>标签,而中文引擎对这个标签的支持普遍很敷衍,很多版本直接忽略。想要音量、音高、语速三者独立可控,靠这套引擎做不到,得在音频后处理环节补。
一条经验:中文引擎的Rate每变化一档,实际语速变化大约在 8%~15% 之间,且非线性。要精确控制,别调Rate,改用文本侧插入<break>控制停顿,效果更可预期。
4.2 SSML 的支持边界与文本归一化
5.1 支持的 SSML 是子集,能稳定用的标签就那么几个:<speak>、<break>、`<
本文还有配套的精品资源,点击获取