简介:C#语音朗读类源码包,基于System.Speech.Synthesis命名空间中的SpeechSynthesizer构建,完整提供播放、停止、暂停、继续四项操作方法,并支持中英文语音的切换与选择。面向需要在桌面工具、阅读辅助软件、语音播报系统或自动通知模块中集成文本转语音功能的C#开发者,代码采用清晰类结构,可直接复制到项目中复用,也可按需调整语速、音量、音调等参数来实现个性化语音效果。资源包共24个文件,其中6个cs源文件为类实现与示例主体,sln/csproj为Visual Studio解决方案与项目文件,exe为编译后的可直接运行程序,dll和pdb包含运行依赖与调试信息,resx/resources则用于存放界面或本地化字符串,整体压缩包仅124KB,轻量且便于快速部署。暂停和继续功能基于语音播放位置记录与恢复实现,配合异步调用和状态判断,可在不阻塞界面的前提下控制朗读进度。目前已有1176人学习,这份完整示例对刚接触TTS或希望快速完成语音朗读模块的初级开发者尤为实用,不仅省去从零编写的时间,还能通过现有代码理解SpeechSynthesizer的常见用法。 做桌面工具时,经常需要让程序把文字“念出来”,比如串口上位机的语音报警、文本校对工具的逐句朗读、无障碍辅助的实时播报。C#里实现语音朗读并不难,System.Speech.Synthesis命名空间下的SpeechSynthesizer就能搞定大部分需求。但直接裸调它做小功能,和把它封装成一个四个核心操作(播放、停止、暂停、继续)都齐全的C#语音朗读类,完全是两种体验。这篇文章就是要把这套状态管理完整打包,做一个能直接拖进WinForms用的SpeechHelper类,顺带把底层原理、资源释放、跨线程更新UI这些坑一次讲清楚。
1. 语音朗读类的设计思路:为什么不能直接裸调SpeechSynthesizer
1.1 直接裸调会遇到哪些问题
很多新手第一次接触SpeechSynthesizer,写的代码通常长这样:
SpeechSynthesizer synth = new SpeechSynthesizer(); synth.SpeakAsync("你好,世界");表面看没问题,能出声。但一旦你在实际项目里用,马上会碰到几个很现实的问题:
SpeakAsync是异步方法,但它没有提供“当前是否正在朗读”的状态,按钮点击一次、两次、三次,语音就会排队,最后几段文字叠在一起念,场面非常混乱。- 你想做“暂停”按钮,但
SpeechSynthesizer的Pause()方法有严格状态限制:不在Speaking状态下调用会直接抛InvalidOperationException。用户随便点两下按钮,程序就崩了。 - 你想在朗读结束时更新界面按钮状态,但
SpeakCompleted事件回调线程不在UI线程上,直接操作控件会抛出跨线程异常。 - 程序退出时如果没有正确释放
SpeechSynthesizer,语音合成器会继续占用音频设备,表现为“程序关了,但声音还在往外播”。
这些问题不是语法难,而是状态管理难。把播放、停止、暂停、继续这四个动作封装成一个语音朗读类,本质上是把这套状态机的复杂度收敛到一个地方,再对外暴露稳定、安全的方法和事件,业务侧只需要关注按钮逻辑。
1.2 语音朗读类的状态机模型
在设计SpeechHelper之前,先理清语音合成器的核心状态流转。SpeechSynthesizer内部有自己的状态枚举SynthesizerState,包括Ready(就绪)、Speaking(朗读中)、Paused(已暂停)。它的方法对应关系是:
| 方法 | 适用状态 | 效果 | 异常风险 |
|---|---|---|---|
SpeakAsync(string) | Ready / Speaking兼容 | 排队朗读文本 | 连续调用会排队,不符合播放语义 |
Pause() | Speaking | 暂停朗读 | 非Speaking状态调用会抛异常 |
Resume() | Paused | 继续朗读 | 非Paused状态调用会抛异常 |
SpeakAsyncCancelAll() | 任意状态 | 清空队列并停止 | 无异常,但需注意事件回调 |
而用户对“播放、停止、暂停、继续”四个按钮的直观理解是一个更简单的状态机:“停止”是初始态,“播放”进入朗读态,“暂停”从朗读态进入暂停态,“继续”从暂停态回到朗读态,“停止”从任何状态回到初始态。
这个差异就是封装的核心:内部把SpeechSynthesizer的状态和我们自己维护的业务状态解耦,业务状态用两个布尔值表示——IsSpeaking(是否在朗读)和IsPaused(是否已暂停)。每次调用方法前先检查这两个布尔值,就能避免绝大部分非法调用异常。
1.3 对外API如何设计才顺手
封装后的SpeechHelper类的对外方法要尽量符合直觉,每个操作都幂等,重复调用不会炸,即使是连点按钮也不会有副作用。核心API我建议设计成这样:
public void Play(string text); // 播放:如果正在朗读,先停止再播放新内容 public void Stop(); // 停止:取消所有朗读,回到初始状态 public void Pause(); // 暂停:仅当正在朗读时生效 public void Resume(); // 继续:仅当已暂停时生效另外还需要暴露几个属性让UI层能查询状态:
public bool IsSpeaking { get; } public bool IsPaused { get; }以及事件,通知UI层状态变化:
public event EventHandler<SpeechStateChangedEventArgs> StateChanged;这样一个类既能屏蔽底层SpeechSynthesizer的细节,又能让WinForms、WPF、控制台程序都能复用。把声音相关的资源、线程、事件全部装进一个类里,整个项目只有一个地方接触SpeechSynthesizer,出问题时排查范围很小。
2. 四个核心方法的底层原理与正确写法
2.1 播放(Play):关键不是SpeakAsync,而是先Cancel
Play方法的正确写法是:如果当前已经在朗读,先停止,再播放新的内容。直接调用SpeakAsync而不先CancelAll,会导致多段文本排队。所以标准的Play方法应该是:
public void Play(string text) { if (string.IsNullOrWhiteSpace(text)) return; if (_isSpeaking) Stop(); _synthesizer.SpeakAsync(text); _isSpeaking = true; _isPaused = false; }这里有一个很多人容易踩的坑:调用SpeakAsyncCancelAll()之后,SpeechSynthesizer会异步触发SpeakCompleted事件,但这并不会立刻改变内部状态。如果你在Stop()之后立刻调用Play(),又是在同一个实例上操作,某些情况下会收到InvalidOperationException。稳妥的做法是在Stop()里面把布尔状态同步更新,然后在Play()里直接基于布尔状态做判断——这就是封装的价值。
另外一个细节:SpeakAsync默认使用“队列模式”。即使你在同一个句子说完之前再次调用SpeakAsync,它会等到前一个说完才开始新的。这本身没错,但对“播放”按钮来说,用户的预期是“点一下播这段,再点一下替换成那段”,而不是“排队念完”。所以Play内部必须先Stop再播放。
2.2 停止(Stop):清空队列与事件陷阱
Stop()对应的是SpeakAsyncCancelAll(),它会取消所有尚未完成的语音请求,并触发SpeakCompleted事件,事件参数中的Cancelled属性为true。正常写法是:
public void Stop() { if (_synthesizer != null) { _synthesizer.SpeakAsyncCancelAll(); _isSpeaking = false; _isPaused = false; } }但这里有个隐藏问题:SpeakAsyncCancelAll()之后,SpeechSynthesizer内部状态可能还没完全回到Ready,如果你紧接着在另一段代码里调用Pause(),就会抛出异常。因此,正确的Pause()必须加状态保护:
public void Pause() { if (!_isSpeaking || _isPaused) return; _synthesizer.Pause(); _isPaused = true; }这里我特意用自己维护的_isPaused而不是直接判断SpeechSynthesizer.State,原因在于:SpeechSynthesizer的状态更新有延迟,事件回调是异步的,而业务上我们需要在按钮点击的瞬间就做出正确响应,自己维护状态比查底层状态更可靠。
2.3 暂停与继续(Pause / Resume):状态保护的边界
Pause()和Resume()在SpeechSynthesizer底层是成对的操作,必须严格守着状态边界。调用Pause()后,合成器的状态变成Paused,音频输出立即中断;调用Resume()后,状态回到Speaking,继续从暂停位置朗读。
Resume的写法:
public void Resume() { if (!_isPaused) return; _synthesizer.Resume(); _isPaused = false; }特别注意:不要在Pause()之后直接Dispose()合成器。如果程序需要在暂停状态下退出,必须先把_isPaused置回false,或者直接调用Stop()清理,否则音频设备可能处于被占用的状态。我遇到过用户在暂停时直接关窗体,结果后台进程还占着声卡,导致其他软件没有声音的诡异故障。
2.4 事件通知:StateChanged与UI线程
SpeechSynthesizer有两个重要事件:StateChanged在状态变化时触发,SpeakCompleted在朗读完成时触发。在你的语音朗读类中,建议把这两个事件统一封装为对外的一个StateChanged事件:
private void Synthesizer_StateChanged(object sender, StateChangedEventArgs e) { if (e.State == SynthesizerState.Speaking) { _isSpeaking = true; _isPaused = false; } else if (e.State == SynthesizerState.Ready) { _isSpeaking = false; _isPaused = false; } StateChanged?.Invoke(this, new SpeechStateChangedEventArgs(_isSpeaking, _isPaused)); } private void Synthesizer_SpeakCompleted(object sender, SpeakCompletedEventArgs e) { _isSpeaking = false; _isPaused = false; StateChanged?.Invoke(this, new SpeechStateChangedEventArgs(_isSpeaking, _isPaused)); }事件发生在非UI线程,所以事件订阅方在更新控件时必须使用Invoke。这个我在下一节的实战里会给出完整的WinForms对接代码。
3. 实战:WinForms里落地一个可用的语音朗读面板
3.1 完整的SpeechHelper类代码
把上面的设计全部落到一个类里,核心代码可以在WinForms、WPF、控制台程序中直接复用。我自己在实际项目里用的就是下面这个版本,去掉了与业务相关的内容,只保留最核心的播放控制逻辑:
using System; using System.Speech.Synthesis; /// <summary> /// C#语音朗读类:封装播放、停止、暂停、继续四个核心操作。 /// </summary> public class SpeechHelper : IDisposable { private SpeechSynthesizer _synthesizer; /// <summary>当前是否正在朗读(包括暂停中)</summary> public bool IsSpeaking { get; private set; } /// <summary>当前是否处于暂停状态</summary> public bool IsPaused { get; private set; } /// <summary>状态变化事件,参数携带IsSpeaking和IsPaused</summary> public event EventHandler<SpeechStateChangedEventArgs> StateChanged; public SpeechHelper() { _synthesizer = new SpeechSynthesizer(); _synthesizer.StateChanged += OnStateChanged; _synthesizer.SpeakCompleted += OnSpeakCompleted; } /// <summary>播放文本。如果正在播放,会先停止再播放新内容。</summary> public void Play(string text) { if (string.IsNullOrWhiteSpace(text)) return; if (IsSpeaking) Stop(); _synthesizer.SpeakAsync(text); IsSpeaking = true; IsPaused = false; RaiseStateChanged(); } /// <summary>停止播放并清空队列。</summary> public void Stop() { if (_synthesizer == null) return; _synthesizer.SpeakAsyncCancelAll(); IsSpeaking = false; IsPaused = false; RaiseStateChanged(); } /// <summary>暂停朗读。仅在正在朗读且未暂停时生效。</summary> public void Pause() { if (!IsSpeaking || IsPaused) return; _synthesizer.Pause(); IsPaused = true; RaiseStateChanged(); } /// <summary>继续朗读。仅在已暂停时生效。</summary> public void Resume() { if (!IsPaused) return; _synthesizer.Resume(); IsPaused = false; RaiseStateChanged(); } /// <summary>设置语速,范围-10到10,0为正常语速。</summary> public void SetRate(int rate) { if (_synthesizer != null) _synthesizer.Rate = Math.Max(-10, Math.Min(10, rate)); } /// <summary>设置音量,范围0到100。</summary> public void SetVolume(int volume) { if (_synthesizer != null) _synthesizer.Volume = Math.Max(0, Math.Min(100, volume)); } /// <summary>选择发音人,可通过GetInstalledVoices枚举。</summary> public void SelectVoice(string voiceName) { if (_synthesizer != null && !string.IsNullOrWhiteSpace(voiceName)) _synthesizer.SelectVoice(voiceName); } /// <summary>获取当前系统已安装的发音人列表。</summary> public System.Collections.Generic.List<string> GetInstalledVoices() { var voices = new System.Collections.Generic.List<string>(); if (_synthesizer == null) return voices; foreach (InstalledVoice voice in _synthesizer.GetInstalledVoices()) { voices.Add(voice.VoiceInfo.Name); } return voices; } private void OnStateChanged(object sender, StateChangedEventArgs e) { if (e.State == SynthesizerState.Speaking) { IsSpeaking = true; IsPaused = false; } else if (e.State == SynthesizerState.Ready) { // 注意:Pause状态下State是Paused,这里不能把IsPaused置false if (!IsPaused) { IsSpeaking = false; } } RaiseStateChanged(); } private void OnSpeakCompleted(object sender, SpeakCompletedEventArgs e) { IsSpeaking = false; IsPaused = false; RaiseStateChanged(); } private void RaiseStateChanged() { StateChanged?.Invoke(this, new SpeechStateChangedEventArgs(IsSpeaking, IsPaused)); } /// <summary>释放资源,必须在窗体关闭等时机调用。</summary> public void Dispose() { if (_synthesizer != null) { Stop(); _synthesizer.StateChanged -= OnStateChanged; _synthesizer.SpeakCompleted -= OnSpeakCompleted; _synthesizer.Dispose(); _synthesizer = null; } } } /// <summary> /// 语音朗读状态变化事件参数。 /// </summary> public class SpeechStateChangedEventArgs : EventArgs { public bool IsSpeaking { get; } public bool IsPaused { get; } public SpeechStateChangedEventArgs(bool isSpeaking, bool isPaused) { IsSpeaking = isSpeaking; IsPaused = isPaused; } }这个类我在多个项目里实际跑过,基本稳定。值得注意的一点是OnStateChanged里对Ready状态的处理:当SpeechSynthesizer进入Paused状态时,它不会触发Ready,所以IsPaused不会被误清;但是当SpeakAsyncCancelAll()之后状态回到Ready,IsPaused已经由Stop()置为false,这里就能安全地把IsSpeaking置为false。
3.2 WinForms界面按钮状态联动
在窗体上放四个按钮(播放、停止、暂停、继续)、一个多行文本框(输入内容)、一个语速滑块。关键点是跨线程更新UI控件,事件回调在非UI线程执行,所以要在StateChanged事件处理器中用Invoke:
public partial class MainForm : Form { private SpeechHelper _speechHelper; public MainForm() { InitializeComponent(); _speechHelper = new SpeechHelper(); _speechHelper.StateChanged += SpeechHelper_StateChanged; // 填充发音人下拉框 comboVoice.Items.AddRange(_speechHelper.GetInstalledVoices().ToArray()); if (comboVoice.Items.Count > 0) comboVoice.SelectedIndex = 0; } private void SpeechHelper_StateChanged(object sender, SpeechStateChangedEventArgs e) { if (this.IsDisposed) return; this.Invoke(new Action(() => { btnPlay.Enabled = !e.IsSpeaking; btnStop.Enabled = e.IsSpeaking; btnPause.Enabled = e.IsSpeaking && !e.IsPaused; btnResume.Enabled = e.IsSpeaking && e.IsPaused; })); } private void btnPlay_Click(object sender, EventArgs e) { _speechHelper.Play(txtContent.Text); } private void btnStop_Click(object sender, EventArgs e) { _speechHelper.Stop(); } private void btnPause_Click(object sender, EventArgs e) { _speechHelper.Pause(); } private void btnResume_Click(object sender, EventArgs e) { _speechHelper.Resume(); } private void trackRate_Scroll(object sender, EventArgs e) { _speechHelper.SetRate(trackRate.Value); // -10 到 10 } private void comboVoice_SelectedIndexChanged(object sender, EventArgs e) { if (comboVoice.SelectedItem != null) _speechHelper.SelectVoice(comboVoice.SelectedItem.ToString()); } protected override void OnFormClosing(FormClosingEventArgs e) { _speechHelper.Dispose(); base.OnFormClosing(e); } }这里的核心思路是:所有按钮的Enabled都依赖SpeechStateChangedEventArgs里的两个状态。这样用户在任何时候点按钮,程序都能给出正确的响应,不需要自己写一堆条件判断。实测下来,连续快速点击按钮也不会出现界面卡死或未响应的情况。
3.3 语速、音量、发音人选择
SpeechSynthesizer支持三个常用参数,建议在语音朗读类里做成可配置的方法:
Rate:语速,范围-10到10,-10是最慢,10是最快,0是正常速度。在WinForms里可以直接绑一个TrackBar。Volume:音量,范围0到100,默认是100。做上位机语音报警时,我一般会提供一个静音开关。SelectVoice:发音人。通过GetInstalledVoices()可以得到当前系统安装的所有语音包名称。
如果是中文语音,Windows 10/11系统一般自带“Microsoft Huihui Desktop”或“Microsoft Kangkang Desktop”等语音包。如果GetInstalledVoices()返回空或选择语音时抛异常,通常是系统里没有安装对应语言的语音包,需要去“设置—时间和语言—语音”里手动添加中文语音模块。
有一个很容易忽略的点:Rate和Volume必须在调用Play之前设置好,才对新文本生效。如果你在朗读过程中修改Rate,底层SpeechSynthesizer会尝试应用新语速,但效果不稳定,我建议在播放前统一设置,播放过程中不允许修改,用UI层限制即可。
4. 常见异常与排查技巧速查
4.1 “在语音合成器上执行了操作”的InvalidOperationException
这是我碰到最多的报错。出现这个问题的根本原因是用户在Speaking状态下调用了SpeakAsync,或者没有经过状态检查就调用了Pause()、Resume()。在使用封装类的情况下,这类异常基本不会出现,因为Play会先Stop,Pause和Resume都有状态守卫。
如果你仍然遇到这个异常,建议优先检查是否在多个线程上同时操作了同一个SpeechSynthesizer实例。SpeechSynthesizer不是线程安全的,所有方法调用必须在同一个线程上。如果你在BackgroundWorker里调用Play,又在UI线程调用Stop,很可能会出现偶发异常。解决方法就是:所有对语音朗读类的调用都走UI线程,后台线程只负责生成文本内容。
4.2 事件回调不在UI线程导致跨线程控件访问异常
SpeakCompleted事件触发时不一定在UI线程。在SpeechHelper_StateChanged事件处理函数里,我用了this.Invoke封送调用,这是最标准的做法。如果你用的是WPF,把Invoke换成Dispatcher.BeginInvoke即可。
一个容易漏掉的地方:在窗体关闭时,如果语音朗读还在进行,StateChanged事件可能在窗体销毁后到达,这时候访问btnPlay控件会抛ObjectDisposedException,所以事件处理函数开头要加if (this.IsDisposed) return;。
4.3 程序关闭后声音继续播放
这个问题十有八九是SpeechSynthesizer没有释放。每个SpeechSynthesizer实例在初始化时会占用音频设备资源,如果你不调用Dispose(),Windows会暂时保留这个音频会话,有时表现为程序窗口关了,声音还在输出几秒才停止。
解决方法是:在窗体OnFormClosing里调用SpeechHelper.Dispose(),并且在Dispose里先Stop()再Dispose,顺序不能反。先Stop()会触发SpeakAsyncCancelAll清空队列,让音频设备能及时释放。
4.4 文本太长导致暂停/继续失效
SpeakAsync会把整段文本一次性交给语音引擎。如果文本有几万字,语音引擎内部的队列会很大,Pause()之后语音不会立即停止,而是把当前正在读的一句话读完才会暂停。实测下来,一句话以内的文本暂停效果最干净;长文本会出现“点暂停后还多读了几个字”的现象,这是语音引擎的音频缓冲导致的,不是代码问题。
解决方案是分段朗读。把长文本按标点切分成短句,逐句调用Play,每句播放完成后再播下一句。这样暂停、停止的响应速度会大幅提升,但实现复杂度也会上一个台阶,需要维护一个句子队列。如果只是做短文本提醒、提示音,直接用本文的SpeechHelper就够了。
4.5 没有安装语音包导致SelectVoice失败
SelectVoice传入一个不存在的发音人名时会抛ArgumentException或者直接没有任何声音。建议在界面加载时用GetInstalledVoices()动态填充下拉框,不要硬编码发音人名。另外,部分精简版Windows系统可能没有中文语音包,运行时会发现SpeakAsync不报错但没有任何声音,排查方式是在系统设置里检查语音包是否已安装。
4.6 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 点击按钮后没有声音 | 未安装对应语言语音包 | 检查GetInstalledVoices结果 |
| 连续点击播放,语音重叠 | 没有先停止再播放 | 使用封装类的Play,内部先Stop |
| 点击暂停抛异常 | 未在Speaking状态调用 | 用IsSpeaking和IsPaused做状态守卫 |
| 程序关闭后仍有声音 | SpeechSynthesizer未释放 | 在OnFormClosing调用Dispose |
| 更新UI控件抛跨线程异常 | 事件回调不在UI线程 | 使用Invoke或Dispatcher |
| 暂停后继续失效 | 文本太长,语音引擎缓冲 | 分段朗读,短句文本效果最佳 |
4.7 个人实操心得
我在做上位机语音报警功能时,最初就是直接裸调SpeechSynthesizer,结果被状态管理折磨得不轻。后来封装成独立的语音朗读类,把所有状态判断收拢到一个类里,界面层只需要调用四个方法,代码量反而减少了一半,出错率也大幅下降。
封装这个类之后,后续再复用到别的项目就非常顺手。比如同一个上位机项目里,我把它从WinForm搬到了控制台服务里,只需要把事件回调里的UI更新逻辑屏蔽掉,核心方法一行都不用改。如果你的项目里也需要类似的语音朗读功能,直接照着这个结构封装一个,绝对比每次写一堆状态判断靠谱得多。
本文还有配套的精品资源,点击获取