简介:这份 WinForm 语音识别与语音合成演示工程面向具备 C# 基础、希望把语音交互接入桌面应用的开发者,重点解决语音转文字、文本朗读以及识别结果自动写入 Word 文档的集成问题。压缩包共 25 个文件、约 828KB,以 6 个 cs 源码文件和 csproj 项目配置为核心,配合 resx/resources 资源、exe 可执行程序、dll 依赖及 pdb 调试文件,便于直接运行、对照源码和排查问题。工程基于微软 Speech API 与 System.SpeechSynthesizer,演示识别引擎设置、词典与语法配置、识别事件处理,以及朗读、暂停、恢复和重读控制;同时通过 Word 互操作库在后台新建文档并写入识别文本。已有 668 人学习,适合参考窗体交互、项目结构、异常处理和语音质量不佳时的识别优化思路。
1. 一个 WinForm 语音识别和语音合成 demo,能省掉多少手工活
某公司的质检班组有个很典型的问题:点检员戴着手套在设备旁边,一手拿表一手拿笔,记录完还要回工位敲进系统,一条记录要录两遍。后来有人丢过来一个用 c# 写的 WinForm 语音识别和语音合成 demo,界面就一个多行文本框加四个按钮,说话就出字,点一下就把文本框里的内容导成 Word 文档。功能不复杂,但它把语音识别和语音合成两个方向都跑通了,而且全程离线,机器不出内网。
这个项目适合两类人:一类是手上有类似场景、想快速验证离线语音录入能不能用的开发者;另一类是想拆开看看 System.Speech 这套原生接口到底怎么用、踩过哪些坑的人。我按工程结构、识别链路、合成与导出、避坑、二次开发这个顺序把它拆一遍,照着代码抄基本能跑起来。
2. 工程结构:WinForm 外壳下跑的是两条互不相干的链路
2.1 从解决方案到窗体:文件清单先摸清
这类小工具最容易写成“一个 Form 里塞 800 行”,好在这个 demo 的分层还算清楚:识别、合成、导出各自独立成类,窗体的职责只剩界面状态和按钮事件。把目录摊开看,一共就那么几个文件,读完基本就掌握全貌了。
VoiceDemo/ ├── VoiceDemo.sln ├── VoiceDemo/ │ ├── Program.cs # 入口,STAThread │ ├── Forms/ │ │ ├── MainForm.cs # 按钮事件、状态切换 │ │ └── MainForm.Designer.cs │ ├── Speech/ │ │ ├── RecognizerService.cs # 识别引擎的封装与生命周期 │ │ ├── SynthesizerService.cs # 合成器的封装 │ │ └── CommandRouter.cs # 命令词分发表 │ ├── Export/ │ │ └── WordExporter.cs # OpenXML 写 docx │ ├── Audio/ # 离线测试用的 wav 样本 │ ├── app.config │ └── VoiceDemo.csproj └── README.md| 文件 | 职责 | 二次开发改动频率 |
|---|---|---|
| RecognizerService.cs | 创建/销毁识别引擎、加载语法、暴露事件 | 高 |
| SynthesizerService.cs | 选发音人、设语速音量、朗读与存 WAV | 中 |
| CommandRouter.cs | 把“开始记录/导出文档”映射到具体方法 | 高 |
| WordExporter.cs | 段落、样式、表格写入 docx | 中 |
| MainForm.cs | UI 线程调度、按钮禁用状态 | 中 |
这个结构里最关键的一点是:识别引擎和合成器都不放在 Form 里。原因很实际——SpeechRecognitionEngine 需要在窗体关闭时显式 Dispose,否则麦克风句柄会一直挂着,第二次启动程序就报设备被占用。把它封装成 service,生命周期跟着窗体走,一行Dispose()就能收干净。
2.2 技术选型:为什么是 System.Speech 而不是云端接口
标题里是“demo”,但选型决定了这个 demo 能不能直接用到内网。项目本身用的是 .NET Framework 4.8 自带的 System.Speech,好处是零依赖、零配置、零费用,装上系统语言包就能跑;坏处也很明显,中文听写的准确率只能算“能用”,遇到方言、口音、背景噪音掉得厉害。
我把几条常见路线摆在一起对比过:
| 方案 | 运行时依赖 | 能否离线 | 中文识别效果 | 适用场景 |
|---|---|---|---|---|
| System.Speech(本项目) | 系统语音组件 + 中文语言包 | 是 | 中等,安静环境下短句可用 | 内网工具、命令词控制、朗读 |
| 云端流式识别 | 网络 + 账号凭据 | 否 | 好 | 会议记录、长语音转写 |
| 本地离线引擎 | 独立运行时 + 模型文件 | 是 | 好但部署重 | 隐私要求高、可接受包体大 |
判断标准就一条:如果核心诉求是“说话控制软件”而不是“把两小时录音转成纪要”,System.Speech 完全够用。命令词识别在这种引擎里是查表匹配,说“导出文档”四个字的命中率远高于自由听写,这也是这个 demo 同时挂两套语法的原因,后面 3.2 会细讲。反过来说,如果客户要的是“会议记录准确率 95%”,这套原生接口别硬扛,早点换方案,血泪经验。
2.3 第一次跑通:引用、平台目标与语音包自检
按下面几步走,基本不会在环境上翻车。
第一步,新建“Windows 窗体应用(.NET Framework)”,目标框架选 4.8。不建议一上来用 .NET 6/8 的模板,因为 System.Speech 在新版运行时里需要通过 NuGet 单独引包,而且语音组件的部分接口在非 Windows 目标框架下编译不过,新手容易在这一步卡两天。
第二步,右键项目 → 添加引用 → 程序集 → 框架,勾选System.Speech;再用 NuGet 装DocumentFormat.OpenXml(导出 Word 用,不装 Office 也能写 docx)。
第三步,项目属性 → 生成 → 平台目标。AnyCPU 时把“首选 32 位”取消掉,或者直接锁 x64。语音组件在 32 位和 64 位下的注册位置不一样,混着来就会出现“明明装了中文语音包,程序里一个发音人都看不到”的玄学现场。
第四步,跑一段自检代码,把本机可用的发音人和识别引擎列出来。这段代码建议直接放进窗体的 Load 事件,结果输出到一个只读文本框或者日志文件里:
using System; using System.Globalization; using System.Linq; using System.Speech.Recognition; using System.Speech.Synthesis; public static class SpeechProbe { // 列出本机可用的识别引擎(每个 Culture 一个) public static void DumpRecognizers() { foreach (RecognizerInfo info in SpeechRecognitionEngine.InstalledRecognizers()) { // 没装中文识别包时,这里只会出现 en-US Console.WriteLine($"识别引擎: {info.Culture.Name} / {info.Name}"); } } // 列出本机可用的合成发音人 public static void DumpVoices() { using (var synth = new SpeechSynthesizer()) { foreach (InstalledVoice v in synth.GetInstalledVoices()) { // Enabled=false 表示注册表里有记录但组件不可用,不能直接拿来 SelectVoice Console.WriteLine($"合成: Enabled={v.Enabled}, Name={v.VoiceInfo.Name}, " + $"Culture={v.VoiceInfo.Culture.Name}, Gender={v.VoiceInfo.Gender}"); } } } // 找一个可用的中文发音人,找不到返回 null public static string PickChineseVoice(SpeechSynthesizer synth) { return synth.GetInstalledVoices() .FirstOrDefault(v => v.Enabled && v.VoiceInfo.Culture.Name.StartsWith("zh", StringComparison.OrdinalIgnoreCase)) ?.VoiceInfo.Name; } }InstalledRecognizers()返回的是识别引擎,GetInstalledVoices()返回的是合成发音人,两者由不同的系统组件提供,装了一个不代表另一个也有。很多人第一次跑发现“能朗读但不能识别”,就是只装了合成语音包没装识别语言包。
提示:
PickChineseVoice返回 null 的时候不要硬往下走,直接在界面上提示“未检测到中文发音人”,比抛一个ArgumentException让用户猜要友好得多。
自检通过后按 F5,窗体出来点“开始”,对着麦克风说“开始记录”,文本框开始出字,这一步就算跑通了。
3. 语音识别链路:一个麦克风采样到一行文字的完整过程
3.1 初始化引擎:Culture、输入设备与语法加载
识别的初始化就四件事:指定语言、绑定输入源、挂语法、开异步。顺序不能乱,语法必须在RecognizeAsync之前LoadGrammar,否则引擎已经开始跑了,新加的语法要等到下一轮才生效,表现出来就是“第一句话没反应”。
using System; using System.Globalization; using System.Speech.Recognition; public class RecognizerService : IDisposable { private SpeechRecognitionEngine _engine; public event Action<string, float> TextRecognized; // 文本 + 置信度 public event Action<string> CommandRecognized; // 命中的命令词 public void Start() { if (_engine != null) return; // 防止重复初始化导致麦克风被抢 // 用 zh-CN 建引擎;如果本机没有中文识别包,这里会抛 ArgumentException _engine = new SpeechRecognitionEngine(new CultureInfo("zh-CN")); // 绑定默认录音设备;也可以 SetInputToWaveFile 用录音文件回放做回归测试 _engine.SetInputToDefaultAudioDevice(); // 听写语法:覆盖所有词,灵活但准确率一般 _engine.LoadGrammar(new DictationGrammar()); // 命令词语法:只认这几句,命中率高,用来控制程序本身 var choices = new Choices(new[] { "开始记录", "停止记录", "朗读全文", "导出文档", "清空内容" }); var builder = new GrammarBuilder(choices) { Culture = new CultureInfo("zh-CN") }; _engine.LoadGrammar(new Grammar(builder) { Name = "CmdGrammar", Priority = 1 }); _engine.SpeechRecognized += OnSpeechRecognized; _engine.RecognizeCompleted += OnRecognizeCompleted; // Multiple 表示连续识别,说完一句继续听;Single 只认一次就停 _engine.RecognizeAsync(RecognizeMode.Multiple); } public void Stop() { // 先取消再 Dispose,直接 Dispose 偶尔会让回调线程还在跑,界面已关导致崩 _engine?.RecognizeAsyncCancel(); _engine?.Dispose(); _engine = null; } public void Dispose() => Stop(); }几个参数值得单独说。new CultureInfo("zh-CN")决定了用哪套声学模型,写错了会直接抛异常,不会静默降级成英文。SetInputToDefaultAudioDevice()取的是系统默认录音设备,如果机器上插了耳麦但默认设备还是内置麦克风,就得去系统设置里改默认,代码层面没有更好的办法。
Priority是语法优先级,数值越大越优先。命令词语法给 1,听写语法保持默认 0,这样同一句话既可能被听写成文本、也可能命中命令时,引擎会优先按命令处理,避免“说导出文档,结果文本框里多出四个字”这种尴尬。
RecognizeMode.Multiple是连续识别的关键,用Single的话说完一句引擎就停了,必须手动再调一次才能继续听,做实时录入肯定不行。
3.2 两套语法各管一段:DictationGrammar 与 Choices 命令词
为什么不干脆只挂一套听写语法,用代码判断文本里有没有“导出文档”四个字?因为听写语法识别“导出文档”的成功率并不高,可能给你返回“倒出文档”“导出文当”,你再做字符串匹配就是自找麻烦。
分两套语法之后,逻辑变成这样:命令词走Choices,引擎只在这几个候选里做匹配,本质上是分类任务,准确率能到很高;其余的话走DictationGrammar,允许出错,反正只是文本录入,个别错字用户自己改。分工明确,各干各的。
命令词表要跟着功能改。我一般把候选词全部集中在一个静态数组里,CommandRouter用字典做分发,加一个功能只需加一个词加一个委托:
public static class CommandRouter { // 命令词 -> 处理动作;键必须和 Choices 里的字符串完全一致 private static readonly Dictionary<string, Action> Map = new Dictionary<string, Action> { ["开始记录"] = () => { /* 打开引擎,清空状态栏 */ }, ["停止记录"] = () => { /* 关闭引擎,保存草稿 */ }, ["朗读全文"] = () => { /* 调用合成服务 */ }, ["导出文档"] = () => { /* 调用 WordExporter */ }, ["清空内容"] = () => { /* 二次确认后清空文本框 */ }, }; public static bool TryDispatch(string command) { if (Map.TryGetValue(command, out var action)) { action(); return true; } return false; } }字典的键和Choices里的字符串一定要一字不差。有一次我把“导出文档”改成了“导出文件”,只改了Choices没改字典,结果识别命中了、程序没反应,查了半天以为是引擎问题——这种翻车纯属自找。
如果命令词多到几十条、还要带参数(比如“导出第 3 条”),Choices就不够用了,得换 SRGS 语法文件,用 XML 描述槽位。日常工具用Choices完全够。
3.3 事件回调不是 UI 线程:结果分流、置信度过滤与文本落地
SpeechRecognized事件在后台线程触发,这是整个项目最容易踩的坑。回调里直接txtResult.AppendText(...),轻则抛跨线程异常,重则界面假死,调试时看到异常堆栈在System.Windows.Forms.Control里,容易误判成控件问题。
正确处理分三步:置信度过滤、按语法名分流、Invoke回主线程。
private void OnSpeechRecognized(object sender, SpeechRecognizedEventArgs e) { var result = e.Result; // 置信度阈值:命令词可以严一点,听写放宽一点 float threshold = result.Grammar?.Name == "CmdGrammar" ? 0.80f : 0.55f; if (result.Confidence < threshold) { // 低置信度不落文本,但要记日志,方便回头调阈值 System.Diagnostics.Debug.WriteLine($"[丢弃] {result.Text} ({result.Confidence:F2})"); return; } if (result.Grammar?.Name == "CmdGrammar") { CommandRecognized?.Invoke(result.Text); return; } TextRecognized?.Invoke(result.Text, result.Confidence); } private void OnRecognizeCompleted(object sender, RecognizeCompletedEventArgs e) { // 用户关掉麦克风、设备被拔出、或者调了 Cancel,都会走到这里 if (e.Error != null) System.Diagnostics.Debug.WriteLine($"识别中断: {e.Error.Message}"); }界面上订阅这个事件,在处理方法里做线程封送:
private void Recognizer_TextRecognized(string text, float confidence) { if (txtResult.InvokeRequired) { // BeginInvoke 不阻塞识别线程,避免回调堆积 txtResult.BeginInvoke(new Action<string, float>(Recognizer_TextRecognized), text, confidence); return; } txtResult.AppendText(text + Environment.NewLine); lblStatus.Text = $"识别完成,置信度 {confidence:F2}"; }Confidence的取值范围是 0 到 1,实测听写场景稳定在 0.6 到 0.9 之间,低于 0.5 的基本是噪音或者半句话。阈值别一刀切,命令词调到 0.8 是因为误触发的代价高(说错一句就可能把文档导出了),听写放宽到 0.55 是因为文本反正是给人看的,错字可以改,丢掉一句话更烦。
还有一个实用技巧:把SetInputToDefaultAudioDevice()换成SetInputToWaveFile("Audio/sample01.wav"),就能用预录的音频跑回归测试,不用每次都对着麦克风念。改识别逻辑的时候我基本都这么干,比人肉复现有耐心多了。
4. 语音合成与 Word 落地:把结果变成一份能交出去的文档
4.1 SpeechSynthesizer 的三个参数:Voice、Rate、Volume
合成比识别简单得多,但三个参数的取值直接决定听起来是“能用”还是“想砸电脑”。
using System.Speech.Synthesis; public class SynthesizerService : IDisposable { private readonly SpeechSynthesizer _synth = new SpeechSynthesizer(); public SynthesizerService() { var voice = SpeechProbe.PickChineseVoice(_synth); if (voice != null) _synth.SelectVoice(voice); // 名字必须来自枚举,不能凭猜 _synth.Rate = -1; // 范围 -10 ~ 10,负数更慢,默认 0 偏快 _synth.Volume = 90; // 0 ~ 100 } public void Speak(string text) { if (string.IsNullOrWhiteSpace(text)) return; _synth.SpeakAsyncCancelAll(); // 新内容打断旧内容,避免排队念半小时 _synth.SpeakAsync(text); } public void Dispose() { _synth.SpeakAsyncCancelAll(); _synth.Dispose(); } }SelectVoice传不存在的名字会抛ArgumentException,所以名字只能从GetInstalledVoices()里拿。Rate调到 -3 以下就开始拖沓,+3 以上中文的声调会变形,听着像快进的导航。Volume建议不要给 100,留一点余量,某些声卡在满幅输出时会破音。
SpeakAsyncCancelAll()这行是给“朗读全文”按钮准备的。用户点了朗读,念到一半又点一次,如果不取消,两段语音会排队,第一遍念完才念第二遍,用户以为程序卡死了。
4.2 合成到 WAV 与合成到扬声器是两条不同的输出路径
同一个合成器对象,输出目标只能有一个。想同时“念出来”又“存一份”,得用两个实例,或者先存文件再播文件。
using System.IO; using System.Speech.Synthesis; public static string SaveToWav(string text, string filePath) { using (var synth = new SpeechSynthesizer()) { // 指定输出到文件后,Speak 会把音频写进去,不会从扬声器播出 synth.SetOutputToWaveFile(filePath); synth.Speak(text); // 这里必须用同步的 Speak,用 SpeakAsync 会提前走到 SetOutputToNull synth.SetOutputToNull(); // 解除文件占用,否则 wav 被锁住删不掉 } return filePath; }这里有两个必须注意的点。第一,写文件时用Speak而不是SpeakAsync,异步版本会立刻返回,using块结束时文件还没写完就被关掉,得到的 wav 要么是空的要么是半截。第二,SetOutputToNull()不能省,不解除输出绑定,文件句柄不会释放,紧接着File.Delete就报“正被另一进程使用”。
生成 WAV 的典型用途是给没有音响的工位做提示音,或者把一段标准话术预生成好,避免每次播放都实时合成。合成一条几十秒的文本大概要一两秒,预生成之后播放就是毫秒级。
4.3 用 OpenXML 写 Word:不装 Office 也能导出 docx
关键词里有 word,这块是这个 demo 落地价值最高的部分——识别出来的文本如果只能停在文本框里,用户还得复制粘贴一遍,等于没省事。
导出的实现有两种:COM Interop 调 Office 组件,或者 OpenXML 直接写 docx。前者要求目标机器装 Office,而且 COM 对象不释放就会留下一个看不见的 WINWORD 进程,任务管理器里越堆越多;后者只要一个 NuGet 包,纯托管代码,服务器上也能跑。我一般选后者。
using System.Collections.Generic; using System.IO; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; public static class WordExporter { public static void Export(string path, string title, IEnumerable<string> lines) { // 先写临时文件再替换,避免写到一半失败把原文档写坏 string tmp = path + ".tmp"; using (var doc = WordprocessingDocument.Create(tmp, WordprocessingDocumentType.Document)) { var mainPart = doc.AddMainDocumentPart(); mainPart.Document = new Document(); var body = new Body(); // 一级标题 body.Append(new Paragraph( new ParagraphProperties(new ParagraphStyleId { Val = "Heading1" }), new Run(new Text(title)))); foreach (var line in lines) { // Space=Preserve 保住首尾空格;不设的话前后空白会被吞掉 var run = new Run(new Text(line) { Space = SpaceProcessingModeValues.Preserve }); body.Append(new Paragraph(run)); } mainPart.Document.Append(body); mainPart.Document.Save(); } if (File.Exists(path)) File.Delete(path); File.Move(tmp, path); } }ParagraphStyleId里的Heading1是 Word 的内置样式名,打开文档后它会显示成“标题 1”,可以直接在导航窗格里跳转。如果想加时间戳、操作人这类信息,用Table拼一个两行两列的小表插在正文前面就行,OpenXML 的对象模型和 Word 的文档结构是一一对应的:Body里放Paragraph和Table,Paragraph里放Run,Run里放Text。
反过来读取 Word 里的段落拿去朗读,也用同一套 API:
public static IEnumerable<string> ReadParagraphs(string path) { using (var doc = WordprocessingDocument.Open(path, false)) { var body = doc.MainDocumentPart?.Document?.Body; if (body == null) yield break; foreach (var p in body.Descendants<Paragraph>()) { var text = p.InnerText; if (!string.IsNullOrWhiteSpace(text)) yield return text; } } }Descendants<Paragraph>()会连表格单元格里的段落一起取出来,InnerText自动拼接所有Run的文本,对纯文本场景够用。要是文档里有大量修订记录或者域代码,InnerText可能带出一些奇怪的东西,那就得自己遍历Run过滤了。
5. 避坑与常见问题:那些让 demo 跑不起来的细节
5.1 启动与设备类:引擎起不来、麦克风抓不到
现象:new SpeechRecognitionEngine(new CultureInfo("zh-CN"))直接抛ArgumentException: 未安装所需语言的识别器。原因:系统里只有英文识别引擎,中文识别语言包没装。InstalledRecognizers()里根本不会有 zh-CN 这一项。解决:先跑 2.3 的自检代码确认,没有就把语言包补上;或者退一步用SpeechRecognitionEngine.InstalledRecognizers().First()取第一个可用引擎,同时在界面上明确提示当前语言,不要假装支持中文。
现象:第一次运行正常,关掉再开就报“设备正在使用中”。原因:窗体关闭时没有Dispose识别引擎,麦克风句柄被上一个进程占着。调试时按了停止按钮但没走FormClosing,问题更隐蔽。解决:在FormClosing里显式调RecognizerService.Dispose(),内部先RecognizeAsyncCancel()再Dispose()。顺序反了偶尔会留下后台回调。
现象:项目从 .NET Framework 迁到 .NET 8 之后编译不过,提示找不到System.Speech。原因:新版运行时不自带这个程序集,需要引 NuGet 包,且目标框架要写成net8.0-windows,纯net8.0编不过。解决:装System.SpeechNuGet 包并改目标框架;如果只是内部工具,建议老老实实留在 .NET Framework 4.8,省一堆兼容问题。
5.2 识别与合成类:听不准、读不出
现象:说话没反应,但日志显示事件确实触发了,文本也没进文本框。原因:置信度阈值设太高。默认的听写结果置信度常见在 0.5 到 0.7 之间,阈值定 0.8 会把大部分正常结果全过滤掉。解决:听写阈值降到 0.5 左右,命令词保持 0.8;把被丢弃的结果和置信度写进日志,观察一周再定值。
现象:说了几句话,只有第一句进文本框。原因:用了RecognizeMode.Single,或者虽然用了 Multiple 但中途某次回调里抛了未捕获异常,把识别线程搞挂了。解决:确认是 Multiple;所有事件回调里包 try/catch,异常写日志,绝不让它冒到引擎线程。
现象:SelectVoice抛ArgumentException,或者朗读出来是英文腔。原因:硬编码了一个别的机器上存在的发音人名字;或者程序是 32 位、发音人只注册在 64 位下。解决:一律走GetInstalledVoices()枚举再按 Culture 选;平台目标统一成 x64。
5.3 导出与资源类:Word 打不开、句柄不释放
现象:第二次点“导出文档”报“文件正被另一进程使用”。原因:用了 COM Interop 且没调Marshal.ReleaseComObject,后台残留一个不可见的 Word 进程锁着文件。解决:换 OpenXML 纯托管写;如果非要用 Interop,finally里显式关闭文档、退出应用、释放对象,三件套一个都不能少。
现象:导出的 docx 用 Word 打开提示“内容有问题,是否修复”。原因:WordprocessingDocument.Create之后忘了mainPart.Document.Save(),或者Body没有挂到Document上,包结构不完整。解决:严格按“建 Part → 建 Document → Append Body → Save”的顺序;写完先用 OpenXML SDK 自带的校验器跑一遍,别等用户来报。
现象:连续识别十几分钟后内存持续上涨,界面越来越卡。原因:每次点“开始”都new一个引擎和一个合成器,旧的没释放;或者AppendText在一个循环里被高频调用,UI 消息队列堆积。解决:引擎和合成器做成单例,只在窗体关闭时释放;文本追加用BeginInvoke并在高频场景下攒批刷新(比如 200ms 合并一次)。
6. 二次开发:把这个 demo 改成能交付的小工具
从“能跑”到“能交付”,中间差的是几处小改造,改动量都不大,但体验差别明显。
第一处是文本后处理。听写结果的标点基本靠猜,我的做法是维护一张替换表,把高频同音错词纠正过来,再按停顿长度补逗号句号。替换表放在外部 json 里,现场调试时不用重新编译,改完重启就生效。
第二处是识别线程与 UI 解耦。现在是一条结果一次BeginInvoke,说话快的时候 UI 会抖。改成识别回调只往ConcurrentQueue<string>里塞,界面上挂一个 200ms 的Timer批量取出拼一次,界面立刻顺滑。这也是我后来所有实时文本类工具的标准做法。
第三处是导出模板化。把WordExporter里的硬编码标题换成模板文件,用 OpenXML 打开模板、定位书签、往书签位置写内容,格式和公司抬头都能复用,不用每次改代码。
| 改造点 | 改动位置 | 风险 |
|---|---|---|
| 热词/纠错表 | 新增 TextPostProcessor | 替换过度会把正确词改错,需要白名单 |
| 批量刷新 UI | MainForm 定时器 | 刷新间隔太长会有延迟感,200ms 是折中 |
| 模板导出 | WordExporter | 书签名字改了要同步代码 |
| 命令词扩展 | Choices + CommandRouter | 两处必须同时改,建议抽成一个配置 |
改完之后怎么验证?别靠人嘴。录十几条典型音频放进Audio/目录,写个控制台入口,把SetInputToDefaultAudioDevice()换成SetInputToWaveFile(),跑一遍统计每条的正确率和置信度分布,改动前后各跑一次,效果是涨是跌一目了然。这个习惯是被坑出来的——有一次我调了阈值觉得“明显变好了”,跑完离线集才发现命令词误触发翻了三倍。
从那以后我每次动识别相关的参数,都强制先跑一遍 wav 回归集再上机试,再急也不跳过这一步。希望帮到你。
本文还有配套的精品资源,点击获取