我去年给某个内部工具加语音控制的时候,发现很多人一提到"c# windows语音识别与朗读"就觉得必须上云端API或者深度学习模型,其实Windows系统自带的System.Speech命名空间,几十行代码就能跑通本地语音识别和语音合成(TTS)的完整链路。这套东西从.NET Framework 3.0时代就有了,一直活到现在,依然能打。它最大的价值在于:离线、免费、低延迟、不依赖网络,适合给桌面软件加语音指令入口、做读屏辅助、做自动化控制,或者单纯想在WinForms/WPF里加一个会说话的机器人。这篇文章把识别端、朗读端、串联Demo和所有坑都过一遍,适合刚开始接触SAPI、被各种术语绕晕的C#开发者,也适合想快速评估"本地语音方案到底行不行"的人。
1. 项目边界与技术选型:本地语音不是只有"听写"这一条路
1.1 先明确你要做的是"听写"还是"命令控制"
很多新手第一次接触语音识别时,脑子里默认的想法是:我要把用户说的每一句话原封不动转成文字,也就是自由听写。这在System.Speech里确实能做到,用DictationGrammar加载一个听写语法就行。但请你记住,本地引擎的自由听写效果远没有云端大模型那么聪明,中文长句、专业名词、网络热词经常翻车。
更实用的场景其实是"命令控制":你预设好一组短语,比如"打开记事本""现在几点""下一个",让引擎去匹配这组短语里的内容。这种方式在System.Speech里叫Grammar(语法约束),它不是等用户随便说,而是拿着"标准答案列表"去比对麦克风进来的声音,所以准确率高一个档次,响应速度也快。我实际测下来,限定命令的识别延迟大概在200-500毫秒,比自由听写稳定得多。
所以做技术选型之前先问自己:是要做一个什么都听得懂的通用助手,还是要一个听得懂特定指令的桌面工具?前者建议走云端方案,后者完全可以用本文这套本地方案。
1.2 System.Speech和Windows.Media.SpeechRecognition怎么选
桌面C#项目里,Windows平台可选的本地语音方案主要有两个:
| 对比维度 | System.Speech(桌面SAPI) | Windows.Media.SpeechRecognition(WinRT API) |
|---|---|---|
| 兼容性 | .NET Framework全程可用,.NET Core/5+可通过NuGet包引用 | 需要Windows 10+,项目需引用WinRT API |
| 离线能力 | 完全离线 | 默认可能走在线,也可指定离线识别 |
| 识别质量 | 中规中矩,依赖系统语言包 | 整体更好,尤其是配合语法文件时 |
| 编码风格 | 事件回调 + 同步/异步方法,老式但稳定 | 异步为主(IAsyncOperation),配合async/await顺手 |
| 坑的数量 | 相对少,资料多 | 桌面端和UWP配置差异容易踩坑 |
我的结论是:如果你做的是传统WinForms/WPF应用,优先选System.Speech,稳定、文档多、报错好查。如果你用的是.NET 6+的Windows应用,想尝鲜更现代的API,可以尝试Windows.Media.SpeechRecognition,但要接受桌面端初始化比较复杂、某些系统版本行为不一致的现实。本文后面的代码和踩坑主要围绕System.Speech展开,因为它覆盖了大多数人的需求。
1.3 环境准备与最容易忽略的语言包问题
在动手写代码之前,先确认三件事:
- 操作系统是Windows 10或11,笔记本麦克风正常,桌面麦克风阵列的采样率设置正确(16位、44.1kHz或48kHz都行)。
- 控制面板的语言设置里,中文语言包已安装。"语音识别"功能如果被精简系统砍掉,引擎初始化会直接抛异常,我后面专门讲这个问题。
- 项目里引用System.Speech。如果还是.NET Framework,直接从添加引用里选;如果是.NET 6/7/8,NuGet里搜System.Speech装一下就行,注意它只在Windows上生效,跨平台项目要加运行平台判断。
做完这三件事,再跑下面的初始化代码,基本就成功一半了。不过在实际项目中,环境问题往往比代码问题更折磨人,所以我会用一整节把识别主干的每个步骤拆开讲清楚。
2. 把识别主干跑起来:引擎初始化、语法加载与事件回调
2.1 初始化引擎时的三个关键动作
SpeechRecognitionEngine的使用模式很固定:创建引擎、挂语法、指定输入、挂事件、启动识别。代码看起来很简单,但每一步都有讲究。
using System.Speech.Recognition; var culture = new System.Globalization.CultureInfo("zh-CN"); using var recognizer = new SpeechRecognitionEngine(culture); // 加载一个自由听写语法,给用户"随便说什么都能识别"的能力 recognizer.LoadGrammar(new DictationGrammar()); // 指定输入源为默认麦克风 recognizer.SetInputToDefaultAudioDevice(); // 订阅识别事件 recognizer.SpeechRecognized += OnSpeechRecognized; recognizer.RecognizeCompleted += OnRecognizeCompleted; // 重点:Multiple模式让引擎在识别完一句后自动继续收听 recognizer.RecognizeAsync(RecognizeMode.Multiple);第一,构造函数里的CultureInfo决定了引擎用哪个语言包。如果你传了zh-CN但系统没装中文语音识别组件,构造函数会直接抛ArgumentException,提示"未找到匹配的语音识别引擎"。这个异常表面上看是代码问题,实际上是系统组件缺失,后面我会教你怎么排查。
第二,LoadGrammar和SetInputToDefaultAudioDevice的顺序其实不严格,但建议先加载语法再设置输入源。如果你的电脑有多个麦克风设备,默认设备恰好是声卡虚拟输入,识别会时好时坏,这时用SetInputToWaveFile或者指定音频流反而更可控,这招在自动化测试里非常实用。
第三,RecognizeAsync(RecognizeMode.Multiple)表示"每轮识别完成后自动进入下一轮"。但注意,这个模式并不是永不停止的,它会被两个隐形定时器打断:一个是静音超时,一个是持续噪声超时,触发之后会走RecognizeCompleted,然后停止。很多人的"识别几次后失灵"问题就出在这里。
2.2 两种 Grammar:自由听写和限定命令的取舍
先说说Grammar这个东西。它的本质是"告诉识别引擎:你接下来可能听到哪些内容",引擎拿到这些约束后,声学匹配和语言模型匹配都在这个候选集里做,结果就会集中很多。
自由听写语法最简单,一行代码搞定:new DictationGrammar()。但注意,这种语法是拿来应付"不限内容"的听写的,代价是引擎内部要跑很大的语言模型,识别率低、耗时高。我除了做"随便说啥都能转成字"的实验外,几乎不在正式工具里用它。
更推荐的做法是自定义Choices语法:
var colors = new Choices("打开记事本", "打开计算器", "现在几点", "关闭程序"); var builder = new GrammarBuilder(colors); var grammar = new Grammar(builder); recognizer.LoadGrammar(grammar);这种方式引擎只需要在几个候选短语之间做区分,识别率可以拉到很高。你可以理解为:自由听写是让引擎做一道有无数选项的选择题,限定命令是四选一,哪个命中率高不言而喻。
如果你的命令集合特别复杂,比如命令带参数"播放 周杰伦""播放 李宗盛",可以用GrammarBuilder的Append来组织结构,也可以用SRGS语法文件(.grxml)。SRGS是W3C标准,System.Speech原生支持,写成XML后加载进来,对复杂嵌套命令的表达力更强。我有一次做电话客服式的多层菜单,就是靠grxml文件维护几百条命令的,比在代码里拼字符串干净得多。
2.3 识别主循环和超时重置问题
接下来说说那个"识别几次后哑火"的坑。先看普通写法:
static void OnSpeechRecognized(object? sender, SpeechRecognizedEventArgs e) { Console.WriteLine($"识别结果: {e.Result.Text},置信度: {e.Result.Confidence:F2}"); } static void OnRecognizeCompleted(object? sender, RecognizeCompletedEventArgs e) { if (e.InitialSilenceTimeout || e.BabbleTimeout) { Console.WriteLine("超时,重启识别..."); _recognizer.RecognizeAsync(RecognizeMode.Multiple); } }RecognizeCompleted事件里的InitialSilenceTimeout表示"从启动到用户开口前,静默太久",BabbleTimeout表示"外界一直有持续噪声,引擎等不到清晰的语音结束"。这两个超时默认值都很短,在安静的办公室里实测,大约几十秒就会触发一次。如果你在识别一次之后什么都不做,引擎就会停在那里,后续说话完全没反应。所以工程上必须监听RecognizeCompleted,判断超时原因后重新调用RecognizeAsync。
更稳妥的做法是把两个超时值调大,并设置合理的句尾停顿阈值:
recognizer.UpdateRecognizerSetting("InitialSilenceTimeout", TimeSpan.FromSeconds(10)); recognizer.UpdateRecognizerSetting("BabbleTimeout", TimeSpan.FromSeconds(30)); recognizer.UpdateRecognizerSetting("EndSilenceTimeout", TimeSpan.FromSeconds(1));这里EndSilenceTimeout控制的是"用户说完一句话之后,停顿多久算一句话结束"。设得太短,用户思考时稍微顿一下就被切断;设得太长,整句话响应会显得迟钝。我调了多次之后认为1秒在大多数场景下比较均衡,如果做命令控制甚至可以压到0.5秒。
3. 置信度过滤与词表设计:让识别结果真正"敢用"
3.1 Confidence是最后一道闸门
SpeechRecognized事件返回的e.Result有一个Confidence属性,范围是0到1,表示识别引擎对自己的结果有多大把握。但很多人忽略了这个值,直接拿e.Result.Text去执行命令,结果就是:用户明明说的是"打开计算器",引擎却听成了"打开记事本",程序照样打开了记事本。这种误判比识别不出更可怕,因为用户根本不知道哪里出了问题。
我的做法是,拿到识别结果后,先看置信度,低就直接拒绝,宁可回一句"我没听清"也不要执行错误动作:
static void OnSpeechRecognized(object? sender, SpeechRecognizedEventArgs e) { if (e.Result == null || e.Result.Confidence < 0.6) { _synthesizer.SpeakAsync("不好意思,我没听清,请再说一遍"); return; } Console.WriteLine($"识别:{e.Result.Text},置信度:{e.Result.Confidence:F2}"); ExecuteCommand(e.Result.Text); }阈值设多少合适?没有统一答案。限定命令集小、环境安静,阈值可以放到0.5;命令集大、环境嘈杂,最多我用到过0.75。我建议你在开发期把每次识别结果的置信度都打出来,观察正常说话和误识别时的分布,再定一个对自己场景合适的值。置信度本身就是噪声和词表设计好坏的风向标,如果常见命令都低于0.6,说明你的Grammar词表设计有问题,不是阈值的问题。
3.2 精心设计自定义语法,而不是靠引擎智能
限定命令虽然准确率高,但它的短板也很明显:用户说的话只要偏离你预设的措辞,就匹配不上。解决这个问题的思路不是让引擎更聪明,而是让预设列表更全面。
比如你要做一个"打开应用"的命令,不要只放一个"打开记事本"。把用户可能说的自然表达都列进去:
var openNotepad = new Choices("打开记事本", "记事本", "帮我打开记事本", "notepad"); var openBrowser = new Choices("打开浏览器", "浏览器", "上网", "打开网页");你会发现,命令越多,Choices之间越容易产生音频特征相近的组合,导致误识别。这时候有两个优化方向:一是命令尽量设计成"动词+宾语"的结构,比如"打开 浏览器"比起"浏览器"更好识别,因为"打开"这个动词提供了额外的声学上下文;二是把高频命令加上唤醒词前缀,让引擎始终在"先听后唤醒"的状态,而不是一直暴露在开放式识别中。
还有一个细节:Grammar是可以在运行期动态更新的。如果你做的是一个插件系统,命令列表不固定,就调用LoadGrammar/UnloadGrammar动态增删。但注意,UnloadGrammar之后引擎不会立刻停止识别老语法,通常要到下一轮识别才完全生效。所以身份敏感的指令切换,建议先RecognizeAsyncCancel,等停止事件回来,再改Grammar,最后重新启动。
3.3 用EmulateRecognize做无声测试
很多人在麦克风环境下调试识别逻辑,效率特别低,尤其是办公室环境噪音大,一句话要喊好几遍才识别对。System.Speech提供了一个特别好的调试方法:EmulateRecognize,它可以直接把一段文本喂给识别引擎,模拟一次识别结果,全程不需要麦克风。
recognizer.EmulateRecognize("打开记事本");这相当于给引擎做一次"单元测试":你不需要真的张嘴,就能验证Grammar加载、事件回调、命令分发一整条链路对不对。我强烈建议把常用的30条命令写成测试用例,每次改完词表先跑一遍EmulateRecognize,再去真人实测,能省非常多时间。
另外,如果你有语音识别测试用的wav音频文件,可以调用SetInputToWaveFile(path)而不是SetInputToDefaultAudioDevice(),引擎会直接从文件里识别。这两个工具结合起来,我可以在不打扰别人的情况下把整个识别流程打磨到稳定上线。
4. 朗读模块:声音选择、SSML与发音控制
4.1 SpeechSynthesizer基础用法与对象复用
识别之外,另一半核心是朗读,在System.Speech里由SpeechSynthesizer负责。它的基本用法极其简单:
using System.Speech.Synthesis; using var synthesizer = new SpeechSynthesizer(); synthesizer.SetOutputToDefaultAudioDevice(); synthesizer.Speak("你好,你刚才说的是打开记事本");但实际项目中我不会为每一次朗读都new一个SpeechSynthesizer,而是把它做成长期存活的单例。原因有两个:一是SpeechSynthesizer加载语音库需要时间,反复创建销毁会明显感觉卡顿;二是每个实例都会占用音频设备资源,频繁创建可能会偶发"读不出来"的问题。
一个容易踩的坑是SpeakAsync的并发限制。你如果快速触发多次SpeakAsync,第二次调用还没执行完,第三次又来了,会抛InvalidOperationException,提示上一个操作还没完成。解决方案是:
if (synthesizer.State == SynthesizerState.Speaking) { synthesizer.SpeakAsyncCancelAll(); } synthesizer.SpeakAsync(内容);这个判断在我的Demo里几乎是每次朗读前必写的前置逻辑。尤其是语音助手场景:用户说一句话,程序要立即朗读回复,上一句还没读完,新回复就来了,不取消就会崩溃。
4.2 声音选择、语速与音量的调节技巧
SpeechSynthesizer支持的语音取决于系统里安装了哪些声音。在Windows上,中文通常有"Microsoft Huihui Desktop"之类的桌面声音,以及新版Windows 10/11自带的在线/离线语音包,名称可能显示为"Microsoft Yiyi"、"Microsoft Kangkang"。获取声音列表用这段代码:
foreach (InstalledVoice voice in synthesizer.GetInstalledVoices()) { var info = voice.VoiceInfo; Console.WriteLine($"{info.Name} | 语言: {info.Culture} | 启用: {voice.Enabled}"); }SelectVoice时直接传声音名称,注意不同机器上名称可能不一样,如果机器没装对应语音,SelectVoice会抛异常。稳妥的方案是根据Culture和性别信息筛选,而不是写死名称:
var voices = synthesizer.GetInstalledVoices() .Where(v => v.VoiceInfo.Culture.Name.StartsWith("zh")) .Where(v => v.Enabled) .ToList(); if (voices.Count > 0) { synthesizer.SelectVoice(voices[0].VoiceInfo.Name); }语速和音量通过Rate和Volume设置。Rate取值范围是-10到10,0是正常语速,-4到-2听起来像是"耐心解释",2到4适合做提示音般的快速播报。我一般把朗读正文设为1,播报短提示设为2。音量Volume是0到100,默认100,要注意的是音量会在系统混音器里单独体现,调太低容易被系统音量盖过。
4.3 用SSML和PromptBuilder控制数字、多音字与停顿
系统自带的声音引擎在朗读数字、年份、英文缩写时经常闹笑话。比如"2024"可能读成"二零二四",也可能读成"二千零二十四",不同声音默认行为还不一样。要精确控制,就得用到PromptBuilder,它底层生成的就是SSML(语音合成标记语言)。
var builder = new PromptBuilder(); builder.StartVoice("Microsoft Huihui Desktop"); builder.AppendText("今天是"); builder.AppendTextWithHint("2024", SayAs.Year); builder.AppendText("年,您的订单金额是"); builder.AppendTextWithHint("123", SayAs.Number); builder.AppendBreak(TimeSpan.FromMilliseconds(300)); builder.AppendText("请确认。"); builder.EndVoice(); synthesizer.Speak(builder);SayAs枚举里有几个常用的:Year(按年份读)、Number(按数值读)、Cardinal(按基数词读)、Ordinal(按序数词读)、Time(按时间读)、Telephone(按电话号逐位读)、SpellOut(逐个字母读)。这些提示能解决绝大多数格式数字的朗读问题。
多音字的问题,PromptBuilder也留了口子:AppendTextWithPronunciation可以指定拼音。比如"重"字,你说"重要"还是"重新",完全可以手动指定:
builder.AppendTextWithPronunciation("重庆", "chóng qìng");这里要注意,Pronunciation参数是拼音字符,不是声母韵母的注音符号,写错了反而会读得更离谱。如果项目里有一批固定的人名、地名,建一个词典,在合成前统一替换,最稳定。
4.4 把语音导出到WAV:抢占音频设备的又一坑
除了直接朗读,TTS还常用来生成音频文件——做产品提示音、给视频配音、批量生成语音。SpeechSynthesizer支持把输出切换到wav流:
synthesizer.SetOutputToWaveFile(@"C:\temp\output.wav"); synthesizer.Speak("这是导出的语音"); synthesizer.SetOutputToDefaultAudioDevice();注意,SetOutputToDefaultAudioDevice之后,如果还有没读完的异步任务,要及时取消,否则新输出设备上的朗读会和文件写入混在一起。如果你要导出成mp3,System.Speech本身不支持,需要先用wav生成,再用别的音频编码库转码。这块不是本文重点,但经常有人问,我就顺手提一句。
5. 串成Demo:唤醒词、命令分发与完整闭环
5.1 两阶段状态机:先唤醒,再执行
把识别和朗读拼在一起时,最容易设计失控的点是"识别一开,机器人就不停插话"。如果引擎一直在监听,TTS在朗读回复时麦克风又把朗读的内容当成用户指令识别回去,于是出现自问自答的死循环。
解决方案是做一个简单的两状态状态机:
- 空闲态(Idle):只在识别"唤醒词",比如"小助手";听到唤醒词后,TTS回复"我在"。
- 命令态(Active):识别具体的操作命令,比如"打开记事本""现在几点""退出";执行完再回到空闲态。
这样TTS在朗读时,即便引擎监听到了自己的声音,只要那条音频匹配不到唤醒词,就不会被误触发。这个两段式结构还有一个好处:大部分时间里引擎识别范围很小,唤醒词的Grammar词表就几个词,准确率和响应速度都极高。
5.2 完整Demo代码:从初始化到命令执行
下面是一个可以跑起来的完整示例,我把它设计成控制台程序,方便你直接复制观察输出。
using System.Speech.Recognition; using System.Speech.Synthesis; class VoiceAssistant { private SpeechRecognitionEngine _recognizer = null!; private SpeechSynthesizer _synthesizer = new SpeechSynthesizer(); private bool _inCommandMode = false; public void Start() { _synthesizer.SetOutputToDefaultAudioDevice(); _synthesizer.Rate = 1; _recognizer = new SpeechRecognitionEngine(new System.Globalization.CultureInfo("zh-CN")); LoadIdleGrammar(); _recognizer.SetInputToDefaultAudioDevice(); _recognizer.SpeechRecognized += OnSpeechRecognized; _recognizer.RecognizeCompleted += OnRecognizeCompleted; _recognizer.RecognizeAsync(RecognizeMode.Multiple); _synthesizer.SpeakAsync("语音助手已启动,请说" + "小助手" + "唤醒我"); Console.WriteLine("按Q退出。"); } void LoadIdleGrammar() { _recognizer.UnloadAllGrammars(); var wakeWord = new Choices("小助手", "你好助手", "开始听写"); var gb = new GrammarBuilder(wakeWord); _recognizer.LoadGrammar(new Grammar(gb)); } void LoadCommandGrammar() { _recognizer.UnloadAllGrammars(); var commands = new Choices("打开记事本", "打开计算器", "现在几点", "重复一遍", "退出"); var gb = new GrammarBuilder(commands); var grammar = new Grammar(gb); _recognizer.LoadGrammar(grammar); } void OnSpeechRecognized(object? sender, SpeechRecognizedEventArgs e) { if (e.Result == null) return; string text = e.Result.Text; Console.WriteLine($"置信度 {e.Result.Confidence:F2},文本:{text}"); if (!_inCommandMode) { _synthesizer.SpeakAsync("我在,请说指令"); _inCommandMode = true; LoadCommandGrammar(); return; } _synthesizer.SpeakAsyncCancelAll(); Execute(text); _inCommandMode = false; LoadIdleGrammar(); } void Execute(string command) { switch (command) { case "打开记事本": System.Diagnostics.Process.Start("notepad.exe"); _synthesizer.SpeakAsync("已打开记事本"); break; case "打开计算器": System.Diagnostics.Process.Start("calc.exe"); _synthesizer.SpeakAsync("已打开计算器"); break; case "现在几点": _synthesizer.SpeakAsync($"现在是{DateTime.Now:HH点mm分}"); break; case "退出": _synthesizer.SpeakAsync("再见"); shutdown(); break; default: _synthesizer.SpeakAsync("我没有听懂这个指令"); break; } } void OnRecognizeCompleted(object? sender, RecognizeCompletedEventArgs e) { if (e.InitialSilenceTimeout || e.BabbleTimeout) { if (!_shutdown) { _recognizer.RecognizeAsync(RecognizeMode.Multiple); } } } private bool _shutdown = false; void shutdown() { _shutdown = true; _recognizer.RecognizeAsyncCancel(); _recognizer.Dispose(); _synthesizer.SpeakAsyncCancelAll(); _synthesizer.Dispose(); Environment.Exit(0); } public void Run() { Start(); while (Console.ReadKey().Key != ConsoleKey.Q) { } shutdown(); } static void Main() => new VoiceAssistant().Run(); }这个Demo有几个地方值得注意:每次状态切换时都UnloadAllGrammars再LoadGrammar,为的是让引擎在低候选量下工作;执行指令前调用SpeakAsyncCancelAll,防止上一条回复没读完就播新的;最后退出时把识别引擎和合成器都释放干净,否则程序退出后麦克风可能还存在占用感。
如果把识别事件改成异步UI调用,也就是Dispatcher.Invoke,再配上一点按钮和日志框,这个控制台助手就能变成WinForms/WPF桌面工具。做法不复杂,但要注意事件其实是在后台线程触发的,直接碰UI控件会跨线程异常,用Invoke转一下就行。
5.3 线程、音频资源与进程启动的注意事项
进程启动的代码我用了System.Diagnostics.Process.Start,在.NET Core/5+上,传notepad.exe这种系统程序名一般没问题。但如果打开的是自己软件目录下的exe或者带参数的程序,最好传完整路径,并且设置UseShellExecute。在控制台程序里还可以,在WinForms/WPF里启动外部GUI进程,建议UseShellExecute=true,避免从当前进程继承奇怪的环境变量。
音频资源上最需要注意的是:识别引擎用的是麦克风,合成器用的是扬声器,这两者在音频API层面一般不冲突,但如果你用虚拟声卡把扬声器输出又接回麦克风输入,恭喜你,无限回环。我做Demo时有过一次,机器人说完话,下一秒就识别出同一句话,然后陷入"我说你听,你听我说"的循环。遇到这种诡异情况,先查系统录音设备是不是开了"立体声混音",关掉再试。
6. 实测中踩过的坑与排查思路
6.1 "识别引擎不存在"的异常是系统组件问题,不是代码问题
有一次我在一台精简版Windows上跑Demo,构造函数卡在new SpeechRecognitionEngine(new CultureInfo("zh-CN")),抛异常说找不到识别引擎。我一开始以为是代码少引用了东西,查半天发现根本不是。System.Speech的识别引擎是Windows的可选功能,路径在"设置-系统-可选功能-语音识别"里。精简系统为了省空间,把这部分砍了,代码再对也没用。
排查方法很简单:打开注册表编辑器,看这个路径是否存在,或者直接查系统里有没有speech recognition组件。没有就装,装完重启再跑代码。这个坑最大的迷惑性在于它只在你换一台新机器时才跳出来,平时开发机器正常,一到客户机器就"水土不服"。
6.2 "识别一次就哑火"绝大多数是超时没重置
如果你发现Demo在启动后第一次识别正常,之后怎么喊都没反应,十有八九是RecognizeCompleted里的超时分支没处理。前面讲过的InitialSilenceTimeout和BabbleTimeout,一旦触发,引擎自动停止,人的耳朵听不见任何声音,但麦克风已经"下班"了。你必须在RecognizeCompleted里重新调用RecognizeAsync,或者把超时调大。我把这两个超时调整到10秒和30秒之后,哑火现象基本绝迹,但如果环境安静到一根针掉地上都能听到,初始静默超时还是会偶尔触发,重置逻辑不能省。
6.3 TTS朗读被自己识别回去:唤醒词解决了大部分问题
语音助手的自循环问题,我在5.1已经讲了。再补一个细节:如果你不希望加唤醒词,也可以让TTS在朗读之前先暂停识别,朗读完成后再恢复识别。System.Speech没有直接的Pause方法,但是可以调用RecognizeAsyncCancel配合重新启动来模拟。这个方案的问题在于"朗读完成"这个时机不好把握,所以我还是推荐用唤醒词方案——它天然把TTS打扰和识别过程分隔开。
6.4 新词、网络热词识别失败:把词汇加进Grammar才是正路
用户说了句"帮我刷一下短视频",你的Grammar只有"打开浏览器"这种命令,引擎当然识别不出来。但这不是引擎笨,而是词表里根本没有这个词。解决思路有两个层次:第一层,预设命令库做全一点,把用户想要的动作描述清楚;第二层,如果必须识别任意文本,比如"搜索xxx",就做一个自由听写语法专门处理动词后面的宾语部分,然后用正则从e.Result.Text里抠出关键词。但默认的DictationGrammar对网络热词依然不友好,因为本地语言模型是固定的。针对这类需求,我一般会明确告诉使用者:本地引擎解决"固定指令"非常优秀,解决"任意长句理解"别指望,这是技术边界,不是调参问题。
6.5 置信度曲线:开发阶段必须记录
最后说一个很多人忽略的习惯:在开发阶段,把每条识别结果的习惯文本、置信度、耗时打到日志里。不是给你自己看,是给你调参用的。我当年就是靠着几百条日志,才摸清楚自己场景下"0.6阈值"和"1秒句尾停顿"这两个经验值是怎么来的。如果一上来就追求"写几行代码就能用",那后面大概率会在莫名其妙的识别错误上耗掉一整天。
这个内容如果你也想做一版,建议从最简单的"唤醒词+两条命令"开始跑,跑通了再逐步加语法。等你把词表、置信度、超时三个东西调节顺了,那套本地离线语音方案基本就真的属于你了。