做上位机的人尤其是搞自动化测试、产线质量追溯的基本都躲不开一个需求让程序把一行文本变成语音再把这些语音按生产顺序拼成一个音频文件存档。前阵子我帮客户做老化测试工位的数据追溯模块遇到的就是这个问题——产品下线时要把SN码、测试结论、操作员编号合成语音播报同时把每台产品对应的三段语音SN播报、结果播报、异常提示按顺序合并成一个总录音方便事后质量回溯。项目本身不算难但真正做起来TTS选型、WAV格式处理、合并后音质这些细节有不少坑中文社区里讲得又比较散。这篇文章就把我这套做法完整梳理一遍给需要做C#语音播报、C#多音频合并的朋友一个可以直接参考的方案。1. 需求拆解与整体方案设计1.1 这个项目到底要解决什么把标题拆开看其实就是两件事文本转语音TTS、多音频合并。前者负责把字符串变成可以播放的音频后者负责把多段音频按顺序拼成一个文件。两个功能本身都不算复杂但放在一起时要考虑的东西就变多了。先说TTS。客户端提出来的要求是离线可用、音色可调、支持中文最后还要能输出成WAV文件。为什么要求WAV因为WAV是未压缩的PCM裸数据后面合并的时候最省事不用做格式转码。如果输出成MP3合并之前还得先解码凭空多一道工序。所以方案一开始就定了TTS输出统一走WAV。再说多音频合并。合并之前必须先想清楚是“顺序拼接”还是“混音”。顺序拼接是把多个音频按先后顺序连成一个长音频比如“叮咚一声 SN播报 测试结论”这样的结构。混音则是把多路音频叠加到同一时间轴上比如“背景音乐 语音播报”。我做这个项目只需要顺序拼接但后面我会把两种方式的实现都讲一下因为很多场景下你两个都要用到。还有一个隐藏需求合并后的音频必须能在产线电脑上直接播放不依赖额外的解码器。这个要求在选型时起到了决定性作用——Windows上最通用、最不容易出错的音频格式就是PCM WAV。1.2 TTS选型系统内置、在线服务、本地模型三选一C#里做TTS粗略分有三条路线。第一条是系统内置方案也就是System.Speech.Synthesis和Windows.Media.SpeechSynthesis。这类方案优点是离线可用、免费、部署简单缺点也明显音色偏机械而且机器上必须装了对应的语言语音包否则中文可能读不出来。它们适合产线播报、提示音这类对自然度要求不高的场景。第二条是云端语音合成服务比如常见的云厂商TTS接口。这类方案自然度很高甚至能复刻真人音色但必须联网并且按调用量计费。产线环境网络不稳定而且音频数据存在隐私问题所以在这个项目里我直接排除了。第三条是本地神经网络TTS比如用ONNX Runtime加载开源的TTS模型做本地推理。这是这几年比较火的路子优势是离线高自然度但工程复杂度高光是把文本转成音素序列、管理声码器这些前置工作就够写一篇长文了。下面这张表把常见选项拉出来对比方案离线自然度部署成本适合场景System.Speech支持中低低但依赖系统语音包提示音、播报Windows.Media.SpeechSynthesis支持中低Win10现代Windows应用云端TTS服务不支持高低但需联网付费音质要求极高的场景本地神经网络TTSONNX支持高高需准备模型对自然度敏感的离线项目我最终选的是System.Speech理由很直接客户要求的语速、音量、中文合成它都能满足而且产线工控机基本都是Windows离线跑起来没有任何外部依赖。音色机械一点没关系产线工人听清“SN码123456测试合格”这几个关键字就够了。1.3 合并前要想清楚顺序拼接还是混音很多第一次做音频合并的人会栽在这里拿着两个WAV想着“把字节拼起来不就行了”。部分场景确实如此但前提是你得明白两者在时间轴上的区别。顺序拼接是把A的完整音频播完再播B。你可能会把“你好”和“世界”拼成“你好世界”中间不重叠。这个操作对应的是ConcatenatingSampleProvider这类工具或者是手动把PCM数据块依次写入目标文件。混音是把A和B同时播放比如人声和背景音乐叠加。对应的是MixingSampleProvider或手动把采样点相加后再做归一化。我这边的业务是“按顺序播报三段语音并保存”所以用的是顺序拼接。但如果你的场景是“语音导航背景音乐”那就必须走混音。这两个概念别搞混否则你会发现“合并出来的音频长度不对”或者“声音叠在一起完全没法听”。2. TTS核心原理与WAV格式速成2.1 TTS到底是怎么把文字变成声音的一旦遇到诡异问题比如“数字读法不对”“多音字读错了”“英文缩写乱读”你就得回头理解TTS的内部流程否则只能瞎试。TTS引擎处理文本大致走这么几步文本预处理分词、拆句、把“12345”转成“一万两千三百四十五”或“一二三四五”把“18℃”转成“十八摄氏度”。语言学分析给每个字、词标注音素拼音的最小发音单元做词性判断和韵律预测决定哪里停顿、哪里重读。声学模型根据音素序列和韵律信息生成对应的声学参数音高、时长、频谱等。声码器合成把声学参数变成真正的波形采样数据最终写进WAV文件。打个比方文本预处理就是给演员“画剧本”语言学分析是告诉演员“哪个字重读、哪里换气”声学模型是设计台词的情感曲线声码器则是演员最后喊出来的那一声。任何一个环节出问题最终听感都会不对。实际项目里我遇到过产品型号“R-100D”被读成“R减一百D”的情况就是因为引擎把连字符当成了减号。解决办法是在文本合成前做一次规则替换把“R-100D”提前改写成“R杠一百D”。这种问题排查起来一定要从前端预处理下手而不是去调引擎参数。2.2 认识WAV文件一段裸数据加一个文件头WAV格式不复杂但你得清楚它内部长什么样否则后面合并音频出问题时你连从哪查起都不知道。一个标准的PCM WAV文件由两部分组成文件头 音频数据。文件头通常是44字节包含了“RIFF”标记、文件大小、“WAVE”标记、以及一个“fmt ”块注意fmt后面有个空格和“data”块。用十六进制编辑器打开一个WAV文件开头一定是52 49 46 46也就是ASCII码的“RIFF”偏移8字节处是“WAVE”偏移36字节处是“data”。音频数据就是纯粹的PCM采样点按时间顺序排列。采样点怎么解释取决于头里记录的三个关键参数采样率、位深、声道数。这三个参数决定了播放器如何把数据还原成声音。我之所以在这个项目里强调“统一输出参数”就是因为WAV文件本质上是“头裸数据”播放器拿到文件后只会按照文件头里写的采样率去播放所有采样点。如果你把一段44100Hz的数据和一段16000Hz的数据拼在一起只改文件头写着44100Hz那么后一段的播放速度就会变快音调直接飘上去听起来就是“花栗鼠说话”。2.3 拼接前必须确认的三个参数合并音频之前先别急着写代码先确认三件事采样率、位深、声道数。采样率Sample Rate每秒采集多少个采样点单位Hz。常见有8000、16000、22050、44100、48000。采样率越高能还原的频率范围越大音频越“通透”但文件体积也越大。位深Bit Depth每个采样点用多少位来存。常见8位、16位、24位、32位。16位是CD音质标准也是我最常用的选择。声道数Channels单声道还是立体声。单声道一个采样点立体声每帧两个采样点左、右。这三个参数和文件大小、音质有直接关系。按我的经验TTS播报场景统一用“44100Hz、16bit、单声道”是比较平衡的选择——音质足够体积可控而且绝大多数播放器都能直接播放。换算一下单声道16bit的WAV每秒数据量约44100×288200字节大约86KB一分钟约5MB完全在接受范围内。注意如果你用的是专业语音合成引擎输出格式可能默认是“22050Hz、16bit、单声道”这也没问题。重点不是用哪个采样率而是所有参与合并的音频必须用同一组参数。3. C#实现TTS三种方案的可运行代码3.1 方案一System.SpeechWindows下最省事System.Speech.Synthesis是.NET Framework时代就有的内置方案在.NET Framework 4.8里直接能用。如果你在.NET 6/8里开发需要先装一个NuGet包System.Speech并且只能在Windows上运行。先写一段枚举语音包的代码目的是确认系统里到底装了几个中文语音using System.Speech.Synthesis; using (var synth new SpeechSynthesizer()) { foreach (var voice in synth.GetInstalledVoices()) { Console.WriteLine(${voice.VoiceInfo.Name} | {voice.VoiceInfo.Culture} | Enabled{voice.Enabled}); } }正常情况下简体中文语音包会显示类似“Microsoft Huihui Desktop”的名字。如果你的机器上运行这段代码后什么都列不出来说明Windows没装中文语音需要去系统设置的“语音”里把中文语言包装上。接下来是把文本合成成WAV并输出到内存的方法using System.Speech.AudioFormat; using System.Speech.Synthesis; public static byte[] SpeakToWav(string text) { using (var synth new SpeechSynthesizer()) using (var ms new MemoryStream()) { // 优先选择简体中文语音 var voice synth.GetInstalledVoices() .FirstOrDefault(v v.VoiceInfo.Culture.Name.StartsWith(zh-CN)); if (voice ! null) { synth.SelectVoice(voice.VoiceInfo.Name); } synth.SetOutputToWaveStream(ms); synth.Speak(text); synth.SetOutputToNull(); // 关键不调用可能拿不到完整的WAV return ms.ToArray(); } }这里有几个细节值得说。第一SetOutputToWaveStream把合成音频写入MemoryStream好处是后续可以直接拿到byte[]做合并不用落地临时文件。第二SetOutputToNull一定要调用它的作用是通知引擎“输出结束了”不调用的话流的开头文件头部分可能没有正确写入最终拿到的WAV文件头大小会不对。第三SpeechSynthesizer实现了IDisposable用完要释放批量合成时尤其要注意否则句柄和内存都会涨。如果希望调整语速和音量在Speak前设置就行synth.Rate 0; // 取值范围 -10 到 100是正常速度 synth.Volume 100; // 0 到 100在我的项目里产线播报语速设0刚好音量设100有点吵调到85比较合适。这些参数看场景调没有标准答案。3.2 方案二Windows.Media.SpeechSynthesis异步方案更顺手Windows.Media.SpeechSynthesis是WinRT时代的方案适用于Win10/11API是异步的在实际使用中手感比System.Speech更现代。不过它默认输出的是一个WAV流需要你自己把它存成文件。它的基本用法是这样的using Windows.Media.SpeechSynthesis; using Windows.Storage; public async Task SaveSpeechAsync(string text, string outputPath) { using (var synthesizer new SpeechSynthesizer()) { var zhVoice SpeechSynthesizer.AllVoices .FirstOrDefault(v v.Language.StartsWith(zh-CN)); if (zhVoice ! null) { synthesizer.Voice zhVoice; } var stream await synthesizer.SynthesizeTextToStreamAsync(text); var file await StorageFile.GetFileFromPathAsync(outputPath); using (var outputStream await file.OpenStreamForWriteAsync()) { await stream.AsStreamForRead().CopyToAsync(outputStream); } } }这里有个工程坑如果你在WPF或WinForms里用光引命名空间是不够的需要让项目支持Windows Runtime API。在.NET 5的新项目里建议安装Microsoft.Windows.SDK.Contracts包或者直接按WinRT互操作的文档配置TargetPlatformVersion。如果项目目标是.NET Framework 4.7.2以下倒是可以直接用。选这个方案的好处是API干净、异步友好在界面程序里不会卡UI。缺点是它同样依赖系统语音包自然度和System.Speech半斤八两。我的建议是如果你只做Win10/11的桌面工具优先用它如果还要兼顾老工控机比如Win7那就老老实实用System.Speech。3.3 方案三本地神经网络TTS追求自然音色的离线路线如果你的客户对音色有要求说“这个机器人声音太难听”那System.Speech就顶不住了。这时候可以考虑本地神经网络TTS。做本地推理最通用的路子是用onnxruntime的C#包加载一个TTS模型。整个流程大概是文本 - 音素序列 - 模型推理生成声学特征 - 声码器还原波形。大致的调用骨架长这样using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; var session new InferenceSession(tts_model.onnx); // 注意这里的输入输出名、数据形状完全取决于你用的模型 long[] phonemeIds TextToPhonemeIds(你好世界); var input new DenseTensorlong(phonemeIds, new[] { 1, phonemeIds.Length }); using var results session.Run(new[] { NamedOnnxValue.CreateFromTensor(text, input) }); var audio results.First().AsTensorfloat().ToArray(); // 再把 audio 采样点写入 WAV 文件别看我写得轻松这条路真正麻烦的不是这几行推理代码而是“文本转音素”这一步。开源模型很多是按英文音素训练的你要接中文需要分词、注音、多音字消歧这一整套前端。当时我为了跑通一个中文VITS模型光是把文本前端理顺就花了两三天。所以我的结论是如果只是提示音级别别碰本地模型如果确实要自然音色且预算允许建议优先考虑云端服务本地模型适合有算法背景或确实需要离线部署的团队。4. C#实现多音频合并从手动拼接到底层工具库4.1 先搞懂WAV拼接的底层原理这一节我用手动拼接来演示原理因为你不懂底层的“字节是怎么排列的”后面遇到非标准文件时会非常被动。手动合并WAV的思路很简单读取每个文件的PCM数据部分跳过文件头把数据块按顺序拼在一起最后重新构造一个WAV文件头。下面这段代码只支持标准44字节头的16bit PCM WAV用于教学完全够public static void ConcatenateWavs(string[] inputPaths, string outputPath) { var dataBlocks new Listbyte[](); int sampleRate 0, bytesPerSample 2, channels 1; int totalDataLen 0; foreach (var path in inputPaths) { var bytes File.ReadAllBytes(path); if (bytes[0] ! R || bytes[8] ! W) throw new InvalidDataException($不是标准WAV文件: {path}); // 遍历块找到 data 块 int offset 12; int dataOffset -1, dataSize 0; while (offset 8 bytes.Length) { string blockId System.Text.Encoding.ASCII.GetString(bytes, offset, 4); int blockSize BitConverter.ToInt32(bytes, offset 4); if (blockId data) { dataOffset offset 8; dataSize blockSize; break; } offset 8 blockSize (blockSize % 2); // WAV块按2字节对齐 } if (dataSize 0) throw new InvalidDataException($找不到data块: {path}); if (dataBlocks.Count 0) { channels BitConverter.ToInt16(bytes, 22); sampleRate BitConverter.ToInt32(bytes, 24); bytesPerSample BitConverter.ToInt16(bytes, 34) / 8; } var block new byte[dataSize]; Array.Copy(bytes, dataOffset, block, 0, dataSize); dataBlocks.Add(block); totalDataLen dataSize; } using var fs File.Create(outputPath); using var bw new BinaryWriter(fs); int bytesPerSec sampleRate * channels * bytesPerSample; int blockAlign channels * bytesPerSample; bw.Write(System.Text.Encoding.ASCII.GetBytes(RIFF)); bw.Write(36 totalDataLen); // 文件总大小 - 8 bw.Write(System.Text.Encoding.ASCII.GetBytes(WAVE)); bw.Write(System.Text.Encoding.ASCII.GetBytes(fmt )); bw.Write(16); // fmt块长度 bw.Write((short)1); // 1PCM bw.Write((short)channels); bw.Write(sampleRate); bw.Write(bytesPerSec); bw.Write((short)blockAlign); bw.Write((short)(bytesPerSample * 8)); bw.Write(System.Text.Encoding.ASCII.GetBytes(data)); bw.Write(totalDataLen); foreach (var block in dataBlocks) bw.Write(block); }这段代码有几点要说明。第一我遍历块而不是直接假设从44字节开始取数据是为了兼容某些WAV文件在fmt块和data块之间还夹着LIST等附加块的情况这种文件在录音笔、专业软件里很常见。第二文件头里最关键的两个数字是bytesPerSec和blockAlign写错的话播放器要么速度不对要么直接无法播放。第三如果两个文件的采样率不一致这段代码不会报错但播放结果会“变调”所以你必须在外面保证所有输入参数一致。4.2 用NAudio实现顺序拼接三行代码搞定手动拼接适合理解原理生产环境我建议直接用NAudio。这个库在C#音频领域基本是事实标准老版本和新版本的命名空间有些差异但我下面用的API在NAudio 2.x里是稳定的。先装包dotnet add package NAudio顺序拼接的代码using NAudio.Wave; using NAudio.Wave.SampleProviders; var files new[] { part1.wav, part2.wav, part3.wav }; var readers files.Select(f new AudioFileReader(f)).ToArray(); var providers readers.Select(r r.ToSampleProvider()).ToArray(); var concat new ConcatenatingSampleProvider(providers); WaveFileWriter.CreateWaveFile16(combined.wav, concat); foreach (var reader in readers) reader.Dispose();就这么简单。AudioFileReader对WAV、MP3等格式都能读统一转成ISampleProvider后ConcatenatingSampleProvider负责按顺序把数据串起来最后WaveFileWriter.CreateWaveFile16把结果写成16bit WAV。不过这里有个使用前提传入的多个provider必须拥有相同的采样率和声道数否则ConcatenatingSampleProvider初始化时会直接抛异常。这也印证了前面反复强调的那句话——所有输入音频要提前统一参数。4.3 采样率不一致怎么办重采样与统一参数如果输入音频的采样率不一致不能直接扔给ConcatenatingSampleProvider必须先重采样。NAudio里有个WdlResamplingSampleProvider可以用来把采样率统一到目标值var targetRate 44100; var targetChannels 1; var inputs new ListISampleProvider(); foreach (var file in files) { var reader new AudioFileReader(file); ISampleProvider provider reader; // 采样率不一致重采样 if (reader.WaveFormat.SampleRate ! targetRate) { provider new WdlResamplingSampleProvider(provider, targetRate); } // 声道数不一致转换 if (reader.WaveFormat.Channels ! targetChannels) { if (reader.WaveFormat.Channels 1 targetChannels 2) provider new MonoToStereoSampleProvider(provider); else if (reader.WaveFormat.Channels 2 targetChannels 1) provider new StereoToMonoSampleProvider(provider); } inputs.Add(provider); } var combined new ConcatenatingSampleProvider(inputs); WaveFileWriter.CreateWaveFile16(combined.wav, combined);这里要注意WdlResamplingSampleProvider只负责采样率转换不管声道数。声道数不一致要用额外的MonoToStereoSampleProvider或StereoToMonoSampleProvider去处理不然报错或者声音错位都正常。但说实话重采样是补救手段不是首选。最稳妥的做法是在TTS生成阶段就统一参数让所有待合并的WAV从一开始就是同一个采样率、位深、声道数。我的实践是先在公共配置里定好“44100Hz、16bit、单声道”所有合成语音一律用这个参数输出合并环节就不需要任何重采样逻辑既减少代码也减少出问题的可能性。5. 常见问题与排查技巧实录5.1 常见问题速查表做这个项目的过程中我整理了这几个高频问题直接列成表格方便各位排查。症状可能原因解决方法中文读不出来或者读成英文系统缺少中文语音包在系统设置里安装中文语音用GetInstalledVoices()检查生成的WAV播放时提示文件损坏没有调用SetOutputToNull在Speak之后、释放之前调用一次合并后声音忽快忽慢采样率不一致没有重采样用WdlResamplingSampleProvider统一采样率Speak调用时UI卡死合成是同步阻塞操作改用SpeakAsync或放到后台线程批量合成时内存飙升SpeechSynthesizer重复创建且未释放复用单个实例用完Dispose在部分机器上找不到任何语音系统是精简版语言包没装全检查Windows语言设置安装对应语言功能5.2 我踩过的几个坑和心得第一个坑是“TTS和合并分开测试都没问题合在一起就出问题”。原因是我在TTS阶段用了默认输出格式可能是22050Hz而合并模块里又拿了另一批44100Hz的音频来拼最终结果就是前一段正常、后一段“加速”。从那以后我把TTS输出参数写成了全局常量所有模块共用一份配置再也没出现这种问题。第二个坑是SpeechSynthesizer在多线程环境下的表现。它是非线程安全的我最初图省事用Parallel.For批量合成几十条语音结果偶发出现“语音错乱”或者“文件头损坏”。排查后确认是同一实例被多线程同时调用导致的。解决办法很简单用线程安全的任务队列单线程串行合成按我的测试几百条短语音也就几十秒完全能接受。第三个坑是关于合并后“爆音”的。如果你手动拼接WAV并且在构造文件头时blockAlign或bytesPerSec算错了播放时可能不是报错而是出现“咔哒”的爆音。测过几个播放器有的会忽略错误继续播有的会按错误参数播表现不一。所以每次合成完我习惯用Audacity打开看一波波形确认首尾没有异常这个习惯帮我抓到了好几处低级错误。5.3 给产线场景的几个额外建议如果你的使用场景和我一样是产线、工控、自动化测试还有几个建议可以参考。一是TTS合成尽量把音频放在内存里操作避免反复读写临时文件。产线工控机的磁盘可能比较老频繁写小文件不仅慢还有损耗风险。我用MemoryStream拿byte[]合并时直接操作字节数组最后一次性写出性能和稳定性都更好。二是注意播报文本里的特殊字符。产线数据经常出现“SNABC123-0”“良率98.7%”“温度25℃”这类内容TTS引擎不一定能正确朗读。我一般会在合成前跑一个“文本规整”函数把特殊符号替换成引擎能理解的写法比如“-”替换成“杠”“%”替换成“百分之”。别小看这一步它直接影响产线工人能不能听清。三是大文件合并要考虑内存。如果合并的是几十个长音频一次性把所有数据加载进内存会导致内存占用过高。这时候建议改用流式处理NAudio的AudioFileReader本身就是流式读取而ConcatenatingSampleProvider在内部也是边读边写配合WaveFileWriter边合成边写文件内存占用可以控制得很低。我后来做长时间“音频留档”时就是这么处理的内存始终维持在一个比较稳定的水平。做完这个项目我最大的感受是TTS和音频合并本身不是高深技术但把它串起来并保证在产线环境里可靠运行需要的是对格式、参数、资源释放这些基础细节的敬畏。尤其是“所有输入统一格式”这条原则帮助我省去了后面几乎所有的返工。如果你也在做C#相关的语音播报、音频工具希望这篇文章能帮你少走几步弯路。