ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WinCC语音报警方案:C#TTS从选型到队列限流的完整指南

WinCC语音报警方案:C#TTS从选型到队列限流的完整指南 简介面向工业自动化现场工程师与Wincc二次开发人员文档聚焦Wincc语音报警方案的不足与改进。文档先梳理了C脚本、VBScript以及内置HORN报警器的常规做法指出它们只能播放预先录制的WAV文件难以针对钢卷号、宽度、厚度等动态变化数据实时生成语音。随后重点讲解如何用C#与TTS技术实现动态语音播报通过FileSystemWatcher监视指定目录中的TXT文件当Wincc侧C脚本写入最新数据后触发事件再结合SpeechSynthesizer将文本转为语音播放同时采用多线程避免界面阻塞。文中包含YPC_TTS类的设计思路、核心方法注释及完整示例代码可帮助读者理解从数据写入、文件监控、文本解析到语音合成的全链路并直接迁移到自己的项目中。整套资料为docx格式共1个文件压缩包大小608KB已有99人学习下载适合具备一定C#基础和Wincc使用经验的读者参考。1. WinCC语音报警为什么我建议用C#单独做一个语音服务深夜的中控室报警条滚了一屏画面上的闪光也在跳但操作员正蹲在角落里泡面等他把头抬起来反应釜的温度已经压不住了。这不是段子是很多用WinCC做上位机的现场每个月都在发生的事。WinCC自带的报警功能处理“看得见”的消息很成熟可一旦轮到“听得见”它只能放一段预先录好的wav没法把变量值、点位描述临时拼成一句话念出来。C#里的文字转语音TTS正好补上这块短板把报警条目按模板拼成文本再用SpeechSynthesizer直接读出来。这篇笔记就是把WinCC报警接到C#语音服务从TTS选型、通信方式到报警排队和限流的踩坑都捋一遍适合正在做WinCC上位机、又不想为语音报警单独买商用模块的工程师。2. C#文字转语音两套TTS API项目里选哪个、怎么跑通2.1 为什么WinCC现场首选System.Speech而不是云TTS先解决一个方向问题C#做文字转语音有三条路System.Speech、Windows.Media.SpeechSynthesis、以及各家云TTS。工业现场的WinCC上位机我的默认答案是System.Speech。原因是WinCC的运行环境通常是Windows 10 LTSC或Windows ServerOT网段多数情况下和外网隔离云TTS哪怕音质再好一个需要联网、一个需要账号鉴权在封闭现场就是给自己埋雷。System.Speech是.NET对Windows SAPI 5的托管封装从.NET Framework 3.0起就有了WinCC项目跑.NET Framework 4.x时直接在“引用→程序集→框架”里勾上System.Speech就能用如果新建的是.NET 8项目从NuGet拉System.Speech包行为也一样。它调用的是系统本地语音引擎离线可用不依赖任何外部服务这是它能在WinCC项目里活这么多年的根本原因。实际项目里真正要操心的不是API本身而是中文字符编码和语音库是否安装这两件事后面单独开坑讲。先把最基础的代码跑通。2.2 最小示例命令行工具念出一句中文报警文本在Visual Studio里建一个控制台项目引用System.Speech代码如下using System; using System.Speech.Synthesis; class Program { static void Main(string[] args) { // 不带参数时打印用法避免误点弹出朗读 if (args.Length 0) { Console.WriteLine(用法: AlarmVoice.exe \报警文本\); return; } using (SpeechSynthesizer synth new SpeechSynthesizer()) { // Rate 范围 -10 到 100 是正常语速-1 会稍慢更适合报警场景 synth.Rate -1; // Volume 范围 0 到 100 synth.Volume 100; // 看一眼机器上有哪些语音可用方便排查 foreach (InstalledVoice voice in synth.GetInstalledVoices()) { Console.WriteLine($可用语音: {voice.VoiceInfo.Name} ({voice.VoiceInfo.Culture.Name})); } // Speak 是同步朗读读完才返回 synth.Speak(args[0]); } } }逻辑很简单Main接收命令行参数用SpeechSynthesizer把参数里的文本读出来。这里有几个参数要注意。Rate设为-1而不是0是因为报警文本里通常有设备号和数值稍微放慢一点更听得清Volume直接拉满到100语音报警喇叭一般接在工控机音频口上音量不够现场根本听不见。Speak是同步阻塞调用好处是程序不会自己退出去坏处是后面要多线程时会用到队列。2.3 中文语音库的检查和选择别让SAPI念英文部署到工控机后最常遇到的情况是程序跑起来了但念出来的是英文或者干脆没声。问题多半出在语音库。运行上面那段代码会打印出机器上所有已安装的语音注意看VoiceInfo.Culture.Name。如果是zh-CN或zh-HK这类中文开头说明系统里有中文语音如果列表里只有Microsoft David Desktop这种英文语音说明这台机器没装中文语音包。Windows 10在“设置→时间和语言→语音”里可以添加中文语音Windows Server还需要先确认“桌面体验”功能是否安装否则语音模型可能根本没部署。检查代码可以写成// 优先选择中文语音找不到时再退回系统默认 var voices synth.GetInstalledVoices(); var zhVoice voices.FirstOrDefault(v v.VoiceInfo.Culture.Name.StartsWith(zh)); if (zhVoice ! null) { synth.SelectVoice(zhVoice.VoiceInfo.Name); }注意FirstOrDefault需要using System.Linq这里不多说。选好中文语音后再处理语速和音量这样报警播报就不会出现中英文混杂。2.4 把TTS封装成TextToSpeechService语速、音量和生命周期一次管好工程上不能每个调用点都去new一个SpeechSynthesizer否则资源释放、语音选择、异常处理散落得到处都是。我一般会包一个TTS服务类using System; using System.Linq; using System.Speech.Synthesis; public class TextToSpeechService : IDisposable { private SpeechSynthesizer _synth new SpeechSynthesizer(); public TextToSpeechService() { _synth.Rate -1; _synth.Volume 100; // 优先中文语音 var zh _synth.GetInstalledVoices() .FirstOrDefault(v v.VoiceInfo.Culture.Name.StartsWith(zh)); if (zh ! null) { _synth.SelectVoice(zh.VoiceInfo.Name); } } public void Speak(string text) { if (string.IsNullOrWhiteSpace(text)) return; _synth.Speak(text); } public void Dispose() { _synth.Dispose(); } }这个类为什么值得单独写第一语音选择逻辑只出现一次后续所有报警都从这里走第二IDisposable保证进程退出时SAPI资源被释放否则工控机上长期跑会出现“语音越来越卡”的假故障第三后面加的队列、限流全部发生在调用Speak之前这个类本身不需要改。2.5 System.Speech和Windows.Media.SpeechSynthesis怎么选给一张选型表照着选就行。System.Speech底层是SAPI 5支持Windows 7到11依赖系统中文语音包适合WinCC项目里占主流的.NET Framework 4.x环境Windows.Media.SpeechSynthesis是Win10 1803以后才有的UWP语音API发音引擎和SAPI不同但项目要配net5.0-windows10.0.xxxxx这样的目标框架老工控机很可能跑不了。除非你是新项目、明确只跑Win10以上否则别为了尝鲜增加部署成本。3. 让WinCC报警和C#语音服务联动三条通信路线怎么选3.1 路线A报警消息触发VBS脚本写文件后调exe播报WinCC自身的报警器只能关联预录wav做不到动态拼文本所以最常见的做法是让报警消息触发一段脚本由脚本把报警文本写进文件再调用外部语音程序。在WinCC报警记录编辑器里打开某条消息的属性页找到一个叫“触发动作/后续动作”的页签7.x和8.x入口名称略有差别把写好的VBS脚本挂上去触发时机选“到达”。VBS脚本这样写Dim fso, f, shell Set fso CreateObject(Scripting.FileSystemObject) Set f fso.CreateTextFile(D:\AlarmVoice\msg.txt, True, False) f.WriteLine 3号反应釜温度高报警当前温度 86.5 摄氏度 f.Close Set shell CreateObject(WScript.Shell) shell.Run D:\AlarmVoice\AlarmVoice.exe D:\AlarmVoice\msg.txt, 0, False这里有几个参数要解释。CreateTextFile的第二个参数True表示覆盖已有文件第三个参数False表示按ANSI格式写入注意在中文Windows上ANSI就是GBK后面C#读取时要按GBK解否则中文变乱码。shell.Run第一个参数是启动命令行0表示窗口不显示False表示脚本不等待程序返回。如果这里写成TrueWinCC脚本会卡住直到语音播完报警多了整个画面都发硬。AlarmVoice.exe里读文件的部分用Encoding.Default在.NET Framework下就是系统ANSI代码页刚好对上GBKstring msg File.ReadAllText(args[0], Encoding.Default);这个路线胜在实现最快报警少、消息不频繁的项目完全够用缺点是每次报警都拉起一个新进程SAPI初始化需要几百毫秒报警一密集就会出现“前一条没念完后一条抢进度”。3.2 路线B常驻服务通过OPC DA读WinCC内部变量如果报警点位多、现场消息频繁我一般建议改成常驻服务。WinCC运行时自带OPC ServerProgID是OPCServer.WinCC.1C#以OPC DA客户端方式连上去轮询读一个报警文本变量。WinCC侧脚本负责把报警消息写进内部变量C#服务读到新消息就去播放。C#侧核心代码// 需在项目中引用 Interop.OPCAutomation.dllOPC DA Auto 2.0 COM 库 var opc new OPCAutomation.OPCServer(); opc.Connect(OPCServer.WinCC.1, Environment.MachineName); var group opc.OPCGroups.Add(VoiceAlarmGroup); group.UpdateRate 200; // 200ms 刷新一次 group.IsActive true; group.IsSubscribed true; // 添加一个文本变量假设WinCC侧已有内部变量 VoiceAlarmText var item group.OPCItems.AddItem(VoiceAlarmText, 0);逻辑说明OPCGroup负责定时刷新UpdateRate设为200毫秒对语音播报来说足够灵敏也不会把CPU吃掉。OPCItems.AddItem把WinCC内部变量映射成本地item之后通过item.Value就能拿到最新值。要注意的是WinCC的OPC Server授权是单独算的很多项目压根没勾这个组件连之前先确认授权否则Connect直接抛异常。这个路线的坑主要在授权和变量类型上。文本变量在WinCC里分8位字符集和16位字符集OPC读字符串时建议统一用8位字符集否则中文会变成Unicode乱码。如果现场已经用KepServerEX在做设备通讯也可以考虑把WinCC变量转发到KepServerEX再让C#去读那是另一套OPC架构这里先不展开。3.3 路线C文件中间层轮询老项目改造不碰授权有的老项目WinCC上没有OPC授权也不方便改报警消息触发动作。这时可以用一个不挑环境的方案WinCC侧只要能写文件C#服务就老老实实轮询。每条报警写一个独立txt文件文件名带时间戳C#服务每500毫秒扫一次目录。string dir D:\AlarmVoice\Messages; foreach (string path in Directory.GetFiles(dir, *.txt)) { string text File.ReadAllText(path, Encoding.GetEncoding(GBK)); _speechService.Speak(text); File.Delete(path); // 播完即删避免重复播 }File.ReadAllText要显式指定GBK而不是Encoding.Default因为.NET Core上Encoding.Default已经是UTF-8了同样一份GBK文件会被读成乱码。为了避免读到“正在写入的半截文件”写文件的程序要把文本一次性写入再关闭句柄输出侧扫到非空文件名后做一个读取重试简单防御即可。这个方案是三条路里最“土”的但它和WinCC版本无关、授权无关只要脚本能落盘就行。缺点是延迟比OPC高一拍适合本来就几十条报警、不追求毫秒级响应的老项目。3.4 WinCC脚本侧怎么写把报警文本拼好再交出去最终无论走哪条路WinCC里都要有一段脚本把报警文本拼出来。C脚本示例如下#include apdefap.h void OnAlarm(void) { char szText[128]; float fTemp GetTagFloat(Reactor_Temp); sprintf(szText, 反应釜温度高报警当前值 %.1f 度, fTemp); SetTagChar(VoiceAlarmText, szText); }这段C脚本挂在报警消息触发动作上先读过程值再把中文和数值拼成一个完整句子写到内部文本变量VoiceAlarmText。两个容易出错的地方一是sprintf里的中文字符串要确认WinCC脚本编辑器保存编码和后续读取端一致否则中文在变量里就坏了二是SetTagChar只能写文本变量建变量时选“文本变量8位字符集”别用16位。3.5 三条路线怎么选授权、延迟、报警频率一张表路线典型延迟授权依赖改造范围适用报警频率VBS脚本调exe0.5~2秒无改报警消息动作低频几分钟一条OPC DA读变量200ms左右WinCC OPC授权加脚本常驻服务中高频秒级连续文件中间层轮询1秒左右无加写文件脚本中低频老项目友好建议是新项目直接按路线B设计把服务写常驻后面做队列和限流都在服务里完成老项目不想动授权就先从路线A跑起跑稳了再换B。4. 语音服务的线程骨架队列、优先级和报警限流一起做4.1 为什么连续报警不能直接开线程念SAPI并发的三个坑语音服务一旦常驻立刻会遇到线程问题。第一SpeechSynthesizer不是线程安全的同一进程里开多个实例同时Speak声音会互相穿插人耳根本听不清第二如果每来一条报警就new一个新的SpeechSynthesizer旧实例还没释放新实例又抢声卡结果就是整段语音糊成一团第三报警文本的朗读时长不固定有的人念完要3秒有的要8秒不排队就会“后一条追上前一条”。所以常驻服务的核心就是一句话所有报警文本必须串行化一个消费者线程慢慢念。这在C#上位机项目里属于很常见的线程模型实现也不复杂。4.2 队列最小实现ConcurrentQueue AutoResetEvent 后台线程用单一后台线程消费队列生产者只管入队消费者慢慢Speakusing System; using System.Collections.Concurrent; using System.Threading; public class VoiceAlarmService : IDisposable { private readonly ConcurrentQueuestring _queue new ConcurrentQueuestring(); private readonly AutoResetEvent _signal new AutoResetEvent(false); private readonly TextToSpeechService _tts new TextToSpeechService(); private readonly Thread _worker; private volatile bool _running true; public VoiceAlarmService() { _worker new Thread(WorkerLoop) { IsBackground true, Name VoiceAlarmWorker }; _worker.Start(); } // 生产者外部线程任意调用 public void Enqueue(string text) { if (string.IsNullOrWhiteSpace(text)) return; _queue.Enqueue(text); _signal.Set(); } private void WorkerLoop() { while (_running) { // WaitOne 带超时保证退出时线程能醒过来 _signal.WaitOne(200); while (_queue.TryDequeue(out string text)) { try { _tts.Speak(text); // 同步朗读天然串行 } catch (Exception ex) { // 语音异常不能拖垮整个上位机记日志后继续 Console.WriteLine($[语音播报异常] {ex.Message}); } } } } public void Dispose() { _running false; _signal.Set(); _worker.Join(1000); _tts.Dispose(); } }逻辑说明ConcurrentQueue保证入队出队线程安全AutoResetEvent负责通知消费者“队列里有货”没有货时线程阻塞在WaitOne上不空转CPU。WaitOne(200)这个200毫秒超时是给退出留的口子Dispose把_running置false、Set一下信号worker线程就会醒过来检查状态并退出。参数方面的建议队列长度不设上限也不对长期堆积会造成播报延迟越来越离谱。可以在Enqueue里检查队列长度超过50条就丢弃后续普通报警只把紧急报警放进去避免报警风暴把语音服务拖垮。4.3 紧急报警插队双队列的优先级调度现场总有那么几个报警是“必须立刻念出来”的比如反应釜超温、联锁动作。这时候单队列不够我一般用两个队列紧急优先private readonly ConcurrentQueuestring _emergencyQueue new ConcurrentQueuestring(); private readonly ConcurrentQueuestring _normalQueue new ConcurrentQueuestring(); public void EnqueueAlarm(string text, bool isEmergency) { if (isEmergency) _emergencyQueue.Enqueue(text); else _normalQueue.Enqueue(text); _signal.Set(); } private void WorkerLoop() { while (_running) { // 紧急队列优先念完一条再看普通队列 if (_emergencyQueue.TryDequeue(out string text)) { _tts.Speak(text); continue; } if (_normalQueue.TryDequeue(out text)) { _tts.Speak(text); _normalQueue.TrimExcess? no } _signal.WaitOne(200); } }注意这里紧急队列是连续消费的如果10条紧急报警堆在一起普通报警会被一直饿着。工程上可接受因为语音播报本身就是告警通道人只能一句一句听排队先后远比“全都要念出来”重要。如果非要给普通报警一个保底可以在普通队列带时间戳超过20秒就强制插播一次但多数现场用不到。4.4 报警限流同一报警一分钟最多念一次语音报警最怕的不是漏报而是同一个报警反复播。现场模拟量在阈值边缘抖动时报警到达/离开能触发几十次操作员听第一遍还紧张听到第五遍就烦了最后直接把喇叭关了。限流是语音服务的刚需。private readonly Dictionarystring, DateTime _lastSpeakTime new Dictionarystring, DateTime(); private bool NeedSpeak(string key, int intervalSeconds) { if (!_lastSpeakTime.TryGetValue(key, out DateTime last)) return true; return (DateTime.Now - last).TotalSeconds intervalSeconds; } public void EnqueueAlarm(string key, string text, bool isEmergency false) { int interval isEmergency ? 15 : 60; // 紧急报警限流短一点 if (!NeedSpeak(key, interval)) return; _lastSpeakTime[key] DateTime.Now; if (isEmergency) _emergencyQueue.Enqueue(text); else _normalQueue.Enqueue(text); _signal.Set(); }key的选取直接影响限流效果。正确的是用“点位名称报警类型”比如“Reactor_Temp_High”不要直接拿拼接好的完整文本当key因为文本里有当前温度值每次都不一样限流就形同虚设了。还有一点Dictionary在多县城访问下要加锁或升级成ConcurrentDictionary否则会偶发报错。4.5 界面要显示播报状态从工作线程更新UI的正确姿势如果C#服务做了WinForms界面要显示“当前正在播报xxx”注意不能在worker线程里直接改Label.Text。同步上下文会抛跨线程异常正确做法是用Control.BeginInvoke把更新动作丢回UI线程this.BeginInvoke(new Action(() lblStatus.Text text));很多新手会折腾Timer轮询控件值本质上没必要一个BeginInvoke就解决了。如果你的服务是无界面的控制台进程那这步就整个跳过少一个坑。5. WinCC语音报警避坑五个高频翻车现场与排查顺序5.1 中文全变乱码或者语音念出一串英文现象报警文本里的中文在日志里是“???”或者朗读时中英文夹杂明显不是同一套编码体系。原因WinCC/VBS写文件用的ANSIGBKC#侧用UTF-8读取反过来也一样。另外系统中文语音包没装时SAPI会用英文语音生硬地读中文。解决读取端统一用Encoding.GetEncoding(GBK)或936检查一下GetInstalledVoices里有没有zh-CN语音。.NET Core项目要先用CodePagesEncodingProvider.RegisterProvider注册代码页提供程序否则GetEncoding(GBK)会直接报错。这个坑看着小第一次部署时十有八九会撞上。5.2 每次报警都拉起新exe声音互相打断还漏报现象报警密集时前一条还没念完就被后一条掐断甚至连续几条都没声音。原因路线A是每触发一次启动一个进程SAPI初始化本身要几百毫秒多个进程同时抢音频设备声音就会乱套。解决把播报逻辑改成常驻服务用第4章的队列串行消费如果暂时不改架构至少要在外部进程启动前检查是否已有AlarmVoice进程在跑if (Process.GetProcessesByName(AlarmVoice).Length 0) { Process.Start(AlarmVoice.exe, msg.txt); }但这只是兜底进程查完到启动之间还有竞争窗口治标不治本。5.3 报警抖动引发语音风暴操作员直接把喇叭关了现象某个模拟量在87.9和88.1之间反复横跳报警到达/离开连续触发语音播报像复读机一样念了五分钟。原因报警阈值没有回差变量在阈值附近抖动一次就触发一次或者限流没做key设计不当。解决先在WinCC阈值里加回差比如高于88报警、低于83才复位再按4.4做限流。现场报警抖动是常态语音播报最怕的就是把自己变成“狼来了”操作员一旦关喇叭整套系统就废了。5.4 WinCC脚本调用exe时弹黑窗看着像程序崩溃现象每次报警屏幕右下角闪一个黑色控制台窗口虽然语音正常但现场操作员一致认为“系统出故障了”。原因WScript.Shell.Run第二个参数写成了1窗口被激活并显示出来或者路径没加双引号路径里有空格直接解析失败。解决Run的第二个参数固定写0命令路径用一对双引号包住shell.Run D:\AlarmVoice\AlarmVoice.exe D:\AlarmVoice\msg.txt, 0, False顺带注意VBS里连续两个引号是转义写法新手很容易写少一个引号导致整个脚本不执行。5.5 做成了Windows服务开机自启后没有声音现象程序在计划任务或手动运行时都正常改成Windows服务开机自启后播报彻底没声。原因Windows服务默认运行在Session 0和用户桌面会话隔离SAPI找不到默认音频设备中文语音引擎也经常不可用。解决别把语音服务做成真正的Windows服务用任务计划程序设置“用户登录时启动”以当前用户交互身份运行。这个方法在工业上位机里尤其可靠开机自动启动和做成服务之间并不是一回事。我自己也在这上面翻过车后来一律用计划任务再没遇到Session 0的玄学问题。6. 进阶语速、语气和运维体验的最后一公里6.1 语速和音量做成配置文件现场自己就能调语音报警上线后操作员一定会提意见“念得太快/声音太小”。与其每次改代码重新编译不如把Rate和Volume放进App.configappSettings add keySpeechRate value-1 / add keySpeechVolume value90 / /appSettingsC#侧读取_synth.Rate int.Parse(ConfigurationManager.AppSettings[SpeechRate] ?? -1); _synth.Volume int.Parse(ConfigurationManager.AppSettings[SpeechVolume] ?? 100);把这两个参数外置后现场工艺员自己就能调试根本不需要厂家到场的血泪经验。6.2 用PromptBuilder控制停顿和重音长文本更易听报警文本一长一口气念下来容易糊。用PromptBuilder加停顿和重音var prompt new PromptBuilder(); prompt.AppendText(注意, PromptEmphasis.Strong); prompt.AppendBreak(TimeSpan.FromMilliseconds(300)); prompt.AppendText($反应釜温度高当前值 {temp:F1} 度); _synth.Speak(prompt);实测下来“警示词停顿设备状态当前值”三段式最好理解比一口气念完“反应釜温度高报警当前值86.5摄氏度”清楚得多。重点是把单位之间的停顿拉长数值不会被吞掉。6.3 每次播报都写日志事后回溯不背锅语音报警被现场投诉“胡说八道”时日志是唯一的证据。在EnqueueAlarm里顺手写一行File.AppendAllText( D:\AlarmVoice\speech.log, ${DateTime.Now:yyyy-MM-dd HH:mm:ss} [{key}] {text}\r\n, Encoding.UTF8 );日志不仅能定位漏播、重播还能复盘报警风暴到底是什么时候开始的。我第一次做这套东西时只做了队列和优先级没做限流结果一台泵的抖动报警把中控室念了一整夜第二天被点名。后来把回差和限流加上语音报警才算真正能留在现场。把它做成常驻服务、把参数外置、把日志留好剩下的就是让时间和现场帮你打磨话术了。希望帮到你。本文还有配套的精品资源点击获取
返回列表