简介:针对C#语音朗读功能封装的VoiceReader类资源,面向需要在WinForms/WPF等桌面应用中快速集成文本转语音能力的开发者。该类基于System.Speech.Synthesis命名空间下的SpeechSynthesizer实现,完整提供播放、停止、暂停、继续四项核心操作,并支持通过SelectVoice切换中英文语音,可调整语速、音调等参数。压缩包共24个文件,包含6个.cs源代码、2个.dll库文件以及可直接运行的exe演示程序,另有sln/csproj工程文件、resx资源和settings配置,方便直接打开项目调试或抽取类库复用,整体仅124KB,轻量易部署。已有1176人学习下载,适合有一定C#基础、希望避免重复造轮子的开发者。资源附带完整项目结构与可执行示例,能帮助读者快速理解SpeakAsyncCancelAll、GetPosition/SetPosition等API的暂停续读实现细节,并在此基础上扩展更多语音交互功能。 最近在给一个工控小工具加语音播报功能,需求很直接:报警的时候把设备状态用中文喊出来,用户手动点按钮可以随时停止、暂停、继续。网上搜了一圈,关于 C# 语音朗读类的资料很多都在重复讲Speak和SpeakAsync,真正把播放、停止、暂停、继续这四个动作做成一个能直接拿来用的类,并且把暂停/继续这两个坑讲清楚的,确实不多。
这篇文章把我封装VoiceSpeaker类的完整过程整理出来,从方案选型到完整代码再到实测问题,一次讲透。不管你是在做桌面工具、上位机、还是给文章阅读器加朗读功能,看完都能直接落地。
1. 需求拆解与方案选型
1.1 这个类的使用场景到底有哪些
先别急着写代码,明确一下这个语音朗读类到底解决什么问题。我做这个小工具时,用户要求是:程序后台检测到设备异常,自动朗读报警内容;操作员听到后,可以按“暂停”停止当前播报,处理完问题再按“继续”接着听;如果这条报警不需要了,直接“停止”清空。
这类需求在 C# 开发里非常常见。最常见的是 WinForms/WPF 桌面程序里的 TTS(Text To Speech,文本转语音)功能,典型场景包括:
- 工控上位机:设备状态、报警信息语音播报,操作员不用一直盯屏幕;
- 仓储管理系统:扫码、入库、出库操作提示音替换成语音;
- 辅助阅读工具:把文章内容朗读出来,适合做“听书”功能;
- 定时任务提醒:到点播报工作计划、闹钟提醒;
- 门店叫号系统:呼叫号码和窗口号,替代人工喊话。
这些场景的共性需求就是四个操作:播放、停止、暂停、继续。区别只在于触发方式和界面不同。所以我们真正要交付的不是一段调用SpeechSynthesizer的散装代码,而是一个封装好的VoiceSpeaker类,对外只暴露几个方法,调用方不需要关心底层细节。
1.2 选型:System.Speech 还是其他方案
C# 里做语音朗读,并不是只有一条路。我实际调研过的方案有四种,这里直接列个对比表:
| 方案 | 使用方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| System.Speech.Synthesis | 托管类库,直接调用 | 使用简单、文档多、离线可用 | 仅支持 Windows;没有原生暂停/继续 | 绝大多数 Windows 桌面程序 |
| Windows.Media.SpeechSynthesis | UWP/WinRT API | 语音质量好、支持在线语音 | 异步 API 用起来绕,桌面程序要处理初始化 | 现代 Windows 应用 |
| SAPI COM(SpVoice) | COM 互操作 | 有原生 Pause/Resume 方法 | API 老、类型不友好 | 需要引擎级暂停/继续时 |
| 第三方云 TTS | HTTP 接口 | 音质自然、支持多音色 | 需要网络、有 API 费用、延迟 | 在线语音播报、语音助手 |
这套需求里我们优先选第一种,System.Speech.Synthesis下的SpeechSynthesizer。原因很简单:
- 离线可用:
SpeechSynthesizer调用的是 Windows 自带的 SAPI(Speech API)语音引擎,不需要联网,也不依赖第三方服务,这在工控、内网环境下是刚需。 - 使用成本低:
SpeakAsync是现成的异步方法,不会阻塞 UI 线程,对 WinForms 开发非常友好。 - 中文支持完善:Windows 10/11 自带简体中文语音包(默认是 Microsoft Huihui),直接就能读中文。
不过它有一个绕不开的问题:SpeechSynthesizer类本身没有提供 Pause 和 Resume 方法。这也是网上很多教程没说透的地方。后面第三节我会详细讲怎么处理。
1.3 一个绕不开的坑:没有内置暂停/继续
SpeechSynthesizer具备的方法大概有:Speak(同步朗读)、SpeakAsync(异步朗读)、SpeakAsyncCancel(取消特定异步操作)、SpeakAsyncCancelAll(取消所有异步朗读)、Pause?——抱歉,真没有。
查一下System.Speech.Synthesis命名空间下的成员就知道,SpeechSynthesizer只有播放和取消两类能力。想做暂停/继续,必须通过其他手段绕过去。我当时试了三条路:
- 路线A:调用 WinMM 音频设备接口,直接对系统默认音频输出设备执行暂停/恢复。优点是代码量小、现有朗读任务不会被破坏;缺点是它暂停的是整个音频输出设备,严格说不只是当前 TTS 任务,而且在部分音频驱动下可能失效。
- 路线B:改用 SAPI COM 的 SpVoice 对象,它有原生的
Pause()和Resume()方法。这是引擎级暂停,最正规,但代码风格老派,需要引用 COM 库。 - 路线C:自己按句子切分文本,用信号量控制暂停。跨平台稳定,但句子之间会有间隙,而且暂停点不够精确。
最终我选择了“路线A为主、路线B为备选”的组合方案,下面会给出两套完整代码。
2. VoiceSpeaker 类的设计思路与核心 API
2.1 对外暴露的四个方法
设计一个语音朗读类,最重要的不是把代码堆上去,而是让调用方觉得“顺手”。我最终对外暴露的方法只有四个,和标题一一对应:
| 方法 | 功能说明 | 调用后状态 |
|---|---|---|
Play(string text) | 开始朗读,或重新开始朗读新文本 | Speaking |
Stop() | 停止所有朗读并清空队列 | Ready |
Pause() | 暂停当前朗读 | Paused |
Continue() | 从暂停位置继续朗读 | Speaking |
实际使用中,调用方只需要这样操作:
VoiceSpeaker speaker = new VoiceSpeaker(); speaker.Play("设备一号发生高温报警,请立即处理"); speaker.Pause(); // 操作员暂时离开 speaker.Continue(); // 回来继续听 speaker.Stop(); // 这条播报不需要了这里有个设计上的取舍:有些场景要求“播放”时如果已经在朗读,就自动打断旧内容、播报新内容。我的做法是Play内部先调用Stop再开新任务,保证语义清晰。没有把“打断播报”和“首次播报”做成两个方法,因为绝大多数前端 UI 只需要一个播放按钮。
2.2 状态管理与 UI 联动
WinForms 或 WPF 界面上,按钮是否可点取决于当前朗读状态。比如“暂停”和“继续”不能同时可用,应该是个互斥状态。如果每次状态变化都让外层自己去判断,代码很快就会乱。
所以我给VoiceSpeaker增加了两个只读属性:
public bool IsSpeaking { get; private set; } public bool IsPaused { get; private set; }再配合一个StateChanged事件,状态一旦变化,UI 直接刷新按钮。典型的绑定逻辑如下:
speaker.StateChanged += (s, e) => { btnPlay.Enabled = !speaker.IsSpeaking || speaker.IsPaused; btnStop.Enabled = speaker.IsSpeaking; btnPause.Enabled = speaker.IsSpeaking && !speaker.IsPaused; btnContinue.Enabled = speaker.IsPaused; };这套逻辑跑起来后,界面上四个按钮永远只有合理的那几个能点。比如正在朗读时,“播放”置灰,“暂停”亮着;暂停后“暂停”置灰,“继续”亮着。用户不会误操作。
2.3 基础引用与环境准备
使用SpeechSynthesizer,项目里需要添加引用:
- .NET Framework:项目右键→添加引用→程序集→框架,勾选
System.Speech。 - .NET Core / .NET 5+:System.Speech 不再随框架内置,需要通过 NuGet 安装:
Install-Package System.Speech这里特别提醒一下:System.Speech只支持 Windows,跨平台项目不要选它。如果你的目标平台是 Linux 服务器,请直接跳到第 4 节看替代方案。
3. 核心功能实现与完整代码
3.1 播放:为什么必须用 SpeakAsync
SpeechSynthesizer的Speak(string text)是同步方法,它会阻塞调用线程直到朗读完毕。如果你在 WinForms 的按钮点击事件里直接调Speak,界面会直接“卡死”在朗读这段时间里,窗口拖不动、按钮点不了,非常糟糕。
正确用法是SpeakAsync(string text),它把朗读任务丢到后台线程池执行,立即返回,UI 线程保持流畅:
public void Play(string text) { if (string.IsNullOrWhiteSpace(text)) return; Stop(); // 先取消上一次任务 _synthesizer.SpeakAsync(text); }有个细节要讲:SpeakAsync调用后,同一个SpeechSynthesizer实例会进入“Speaking”状态,但如果之前有未取消的任务,新任务会排队而不是立即打断。所以Play里必须先Stop,否则用户连续点两次“播放”,第二次会等第一次读完才读,达不到“更新播报内容”的效果。
再补充一个SpeakAsync的变体:SpeakAsync(PromptBuilder),它可以组合多段文本、书签、音频,适合做复杂播报。我们这个类里暂时用不上,但如果以后要插入提示音,可以在PromptBuilder上扩展。
3.2 停止:取消队列而不是清空文本
停止逻辑很简单,调用SpeakAsyncCancelAll()就行:
public void Stop() { Interlocked.Increment(ref _version); // 防止旧回调干扰 _synthesizer.SpeakAsyncCancelAll(); IsSpeaking = false; IsPaused = false; }这里有两个注意点。
第一,SpeakAsyncCancelAll是异步取消,调用后不会立刻触发完成事件,所以不要在调用后用同步方式等待状态变回“Ready”。如果你的业务里需要“停止后立刻做某件事”,最好在SpeakAsyncCompleted事件回调里做。
第二,类的属性状态要主动复位。因为取消后StateChanged事件不一定马上触发,万一 UI 在取消后立刻查询IsSpeaking,可能还是true。我选择在Stop里直接复位状态,保证属性值和实际操作语义一致。
3.3 暂停/继续:两种实现方式
这一节是重点。SpeechSynthesizer没有原生暂停/继续,我用两种方式解决,分别应对不同场景。
方式一:调用 WinMM 音频设备接口(推荐)
思路是:Windows 有底层音频设备 API,winmm.dll里的waveOutPause和waveOutResume可以暂停和恢复默认音频设备输出。TTS 朗读的内容最终要经过音频设备播放,设备一暂停,声音自然就停了。
using System.Runtime.InteropServices; [DllImport("winmm.dll")] private static extern int waveOutPause(IntPtr hwo); [DllImport("winmm.dll")] private static extern int waveOutResume(IntPtr hwo); public void Pause() { if (!IsSpeaking || IsPaused) return; int result = waveOutPause(IntPtr.Zero); if (result == 0) { IsPaused = true; } } public void Continue() { if (!IsPaused) return; int result = waveOutResume(IntPtr.Zero); if (result == 0) { IsPaused = false; } }传入IntPtr.Zero表示操作默认音频输出设备。实测下来,大部分 Windows 10/11 系统上这个方案是有效的。优点是改动小、不用换 TTS 引擎;缺点是它暂停的是整个系统音频输出,如果用户同时放着音乐,音乐也会跟着暂停。
方式二:使用 SAPI COM 的 SpVoice(引擎级暂停)
如果要求“只暂停当前朗读,不影响其他声音”,或者waveOutPause在你的目标机器上失效了,就该上 COM 方案。项目里引用Microsoft Speech Object Library(即 SpeechLib),然后这样操作:
using SpeechLib; SpVoice voice = new SpVoice(); // 异步朗读 voice.Speak("设备报警", SpeechVoiceSpeakFlags.SVSFlagsAsync); // 引擎级暂停 voice.Pause(); // 引擎级恢复 voice.Resume(); // 停止 voice.Speak(string.Empty, SpeechVoiceSpeakFlags.SVSFPurgeBeforeSpeak);SpVoice是 SAPI 5 的 COM 封装,Pause()和Resume()是原生方法,暂停后引擎会完整保留当前的朗读位置,继续时无缝衔接。不过它的缺点也很明显:API 风格老、没有现成的异步取消事件,做复杂封装时要自己处理线程回调。
我的建议是:你自己封装的类,优先用SpeechSynthesizer做播放和停止,用 WinMM 做暂停/继续,代码量最少,也最容易维护。只有当音频设备层面出现兼容性问题时,再整体替换为SpVoice方案。
3.4 中文音色、语速、音量调节
朗读类如果只能读默认音色,实用性会大打折扣。中文系统下,默认的语音引擎可能是Microsoft Huihui Desktop,但遇到英文系统或者语音包缺失,读中文会变成奇怪的英文发音。因此我建议在类里暴露三个调节方法:
public void SetVoice(string voiceName) { if (!string.IsNullOrEmpty(voiceName)) _synthesizer.SelectVoice(voiceName); } public void SetRate(int rate) { _synthesizer.Rate = Math.Max(-10, Math.Min(10, rate)); } public void SetVolume(int volume) { _synthesizer.Volume = Math.Max(0, Math.Min(100, volume)); }Rate的取值范围是 -10 到 10,-10 最慢,10 最快,默认 0。Volume是 0 到 100,默认 100。如果不知道怎么选音色,可以用下面这段代码把所有可用音色打出来:
foreach (var voice in _synthesizer.GetInstalledVoices()) { var info = voice.VoiceInfo; Console.WriteLine($"名称:{info.Name},语言:{info.Culture.Name},性别:{info.Gender}"); }然后挑一个中文音色,例如:
speaker.SetVoice("Microsoft Huihui Desktop");这里有个小坑:SelectVoice如果传入了不存在的音色名称,会抛出异常。所以稳妥的做法是先用GetInstalledVoices过滤一遍,存在才设置。
3.5 完整代码
把上面的思路合并,就是一个开箱即用的VoiceSpeaker类。以下是我在项目里实际使用的版本,已经去掉业务耦合,可以直接复制:
using System; using System.Runtime.InteropServices; using System.Speech.Synthesis; using System.Threading; namespace SpeechDemo { /// <summary> /// 语音朗读类:播放、停止、暂停、继续 /// </summary> public class VoiceSpeaker : IDisposable { private SpeechSynthesizer _synthesizer; private int _version; public event EventHandler<StateChangedEventArgs> StateChanged; public bool IsSpeaking { get; private set; } public bool IsPaused { get; private set; } [DllImport("winmm.dll")] private static extern int waveOutPause(IntPtr hwo); [DllImport("winmm.dll")] private static extern int waveOutResume(IntPtr hwo); public VoiceSpeaker() { _synthesizer = new SpeechSynthesizer(); _synthesizer.StateChanged += OnStateChanged; _synthesizer.SpeakAsyncCompleted += OnSpeakAsyncCompleted; } public void Play(string text) { if (string.IsNullOrWhiteSpace(text)) return; // 取消上一次任务,避免排队 Stop(); // 如果之前是暂停状态,先恢复设备 waveOutResume(IntPtr.Zero); IsPaused = false; int currentVersion = Interlocked.Increment(ref _version); _synthesizer.SpeakAsync(text); } public void Stop() { Interlocked.Increment(ref _version); _synthesizer.SpeakAsyncCancelAll(); IsSpeaking = false; IsPaused = false; } public void Pause() { if (!IsSpeaking || IsPaused) return; int result = waveOutPause(IntPtr.Zero); if (result == 0) { IsPaused = true; } } public void Continue() { if (!IsPaused) return; int result = waveOutResume(IntPtr.Zero); if (result == 0) { IsPaused = false; } } public void SetVoice(string voiceName) { if (string.IsNullOrEmpty(voiceName)) return; bool voiceExists = false; foreach (var voice in _synthesizer.GetInstalledVoices()) { if (voice.VoiceInfo.Name == voiceName) { voiceExists = true; break; } } if (voiceExists) { _synthesizer.SelectVoice(voiceName); } } public void SetRate(int rate) { _synthesizer.Rate = Math.Max(-10, Math.Min(10, rate)); } public void SetVolume(int volume) { _synthesizer.Volume = Math.Max(0, Math.Min(100, volume)); } private void OnStateChanged(object sender, StateChangedEventArgs e) { IsSpeaking = e.State == SynthesizerState.Speaking || e.State == SynthesizerState.Paused; if (e.State == SynthesizerState.Ready) { IsPaused = false; } StateChanged?.Invoke(this, e); } private void OnSpeakAsyncCompleted(object sender, SpeakAsyncCompletedEventArgs e) { IsSpeaking = false; IsPaused = false; } public void Dispose() { if (_synthesizer != null) { _synthesizer.SpeakAsyncCancelAll(); _synthesizer.Dispose(); _synthesizer = null; } } } }这个类有几个细节值得说明:
Play里先调Stop,再调waveOutResume,原因是如果之前是暂停状态,直接SpeakAsync后 TTS 开始朗读了,但音频设备还挂着“暂停”,会没有声音。Stop后旧任务的SpeakAsyncCompleted事件可能还会触发,所以事件回调里只复位状态,不操作新任务。_version字段用来标记一个新任务版本,如果以后要扩展“朗读途中播放更高优先级内容”,可以靠版本号区分旧回调。
4. 实测记录与常见问题排查
4.1 不同环境下的实测表现
我分别在 Windows 10 和 Windows 11 上做了测试,覆盖 .NET Framework 4.7.2 和 .NET 6 环境,运行情况如下:
| 环境 | 播放 | 停止 | 暂停 | 继续 | 备注 |
|---|---|---|---|---|---|
| Win10 + .NET Framework 4.7.2 | 正常 | 正常 | 正常 | 正常 | 中文音色需要装语音包 |
| Win10 + .NET 6 | 正常 | 正常 | 正常 | 正常 | 需要 NuGet 安装 System.Speech |
| Win11 + .NET 6 | 正常 | 正常 | 正常 | 正常 | 默认使用 Microsoft Xiaoxiao 在线语音时要注意网络 |
这里有一个额外发现:Windows 11 部分系统默认的 TTS 音色可能从离线引擎切换到了“在线自然语音”。在线语音音质更好,但如果电脑离线,朗读会失败或者回退到本地音色。如果你的工具部署在离线环境,务必在初始化时检查GetInstalledVoices()列表,并显式指定一个离线语音。
4.2 暂停失效怎么办
waveOutPause这个方案最大的问题在于:它不是 TTS 引擎级的暂停,而是音频设备级的暂停。如果你遇到“点了暂停,声音还在继续”的情况,最常见的原因是:
- 系统音频设备变了:比如用户插拔了耳机、蓝牙音频设备切换,
IntPtr.Zero指向的默认设备和实际播放设备不一致。 - TTS 走的是新的音频会话:部分语音引擎使用独立音频会话,不一定挂在
waveOut设备上。 - 程序权限问题:某些精简版系统、安全软件会拦截底层音频调用。
排查时先调函数返回值。waveOutPause返回 0 表示成功,返回其他值(比如MMSYSERR_INVALHANDLE)说明设备句柄无效。如果确认是设备句柄问题,可以改用waveOutOpen打开默认设备后,再用返回的句柄调用暂停接口。这个做法更严谨,但代码量会大一些。
最省事的兜底方案:换成SpVoiceCOM 对象做暂停/继续。它的Pause是引擎级操作,不依赖系统音频设备句柄,稳定性高很多。我有两个项目都是因为现场主机音频驱动太杂,最终切到了SpVoice。
4.3 UI 线程卡顿和事件回调问题
很多初学者会把Speak直接放在按钮点击事件里,界面卡死是必然的。另外,SpeakAsyncCompleted事件的回调线程是线程池线程,不是 UI 线程。你在回调里想更新TextBox、Label时,要用Invoke或BeginInvoke跳回 UI 线程:
speaker.StateChanged += (s, e) => { if (this.InvokeRequired) { this.BeginInvoke(new Action(() => UpdateButtons())); } else { UpdateButtons(); } };还有一个常见的竞态问题:快速连点“停止”和“播放”,可能上一次朗读的SpeakAsyncCompleted事件在新一次播放之后才触发,把新任务的IsSpeaking误置为false。我代码里的_version字段就是为了处理这类问题。扩展一下,在事件回调里判断版本号是否最新,不是最新就直接丢弃:
private int _currentVersion; private void OnSpeakAsyncCompleted(object sender, SpeakAsyncCompletedEventArgs e) { if (Interlocked.CompareExchange(ref _currentVersion, 0, 0) != _latestVersion) return; IsSpeaking = false; }4.4 常见问题速查表
把我在项目维护中遇到的高频问题整理成了一张表,方便你直接对照排查:
| 问题现象 | 根本原因 | 解决方法 |
|---|---|---|
| 读出来是英文发音 | 当前音色不支持中文 | 用GetInstalledVoices检查并SelectVoice选择中文音色 |
| 朗读时界面卡死 | 用了同步Speak | 换成SpeakAsync |
| 连续点播放不生效 | SpeakAsync任务排队 | Play内部先SpeakAsyncCancelAll |
| 暂停后没有声音 | 音频设备句柄无效 | 检查返回值,或换SpVoice方案 |
| 播放时没声音 | 之前处于暂停状态 | 播放前先waveOutResume |
| 停止后状态没更新 | 回调线程异步 | 在事件回调里复位状态,Stop方法里同步复位 |
| 电脑离线后朗读失败 | 默认语音是在线语音 | 显式指定离线音色 |
| 程序退出还有声音在响 | 未调用 Dispose | 窗体关闭时调用speaker.Dispose() |
最后再分享一个小技巧:SpeechSynthesizer支持PromptBuilder和AppendTextWithBookmark,如果你想对朗读进度做精确控制(比如高亮当前读到的句子),可以用BookmarkReached事件拿到书签位置。我在做“逐句跟读”功能时就是用这个方案,比按时间估算朗读位置可靠得多。
这个VoiceSpeaker类目前已经在我自己的两个工具里跑了大半年,每次改动基本都是在外围业务逻辑,四个核心方法几乎没动过。如果你在设计 UI 时有任何状态切换的疑问,建议先跑起来,用最小 Demo 验证 WinMM 暂停在你的目标系统上是否可靠,再决定要不要继续往下封装。
本文还有配套的精品资源,点击获取