简介:面向在 .NET Framework 4.5 与 Visual Studio 2017 环境下开发 WPF 桌面程序的初中级开发者,这份示例工程围绕录音与音频播放两条主线展开,用于解决多媒体交互功能难以快速落地的问题。工程以 C# 代码为主体,含 45 个 cs 源文件、4 个 xaml 界面文件及对应 baml 编译产物,另有 NAudio 等 13 个 dll 依赖、5 个 wav 示例音频及 32 个 png 图片资源,并配套 sln、csproj 解决方案与 NuGet 包文件,可在本地直接还原编译;压缩包共 175 个文件,约 3.18MB。已有 928 人学习下载。读者可获取 WasapiLoopbackCapture 采集声卡数据并写入 wav 的完整实现、MediaPlayer 播放、暂停与停止的控制逻辑、波形绘制控件以及音频时长、比特率等元数据读取的参考代码,录音与播放的启停、资源释放等细节也有对应代码可循,适合按模块拆分复用,或作为聊天、教学类应用的音频功能原型。
1. WPF 录音与播放音频:从 MediaElement 换到 NAudio 的那次返工
WPF 录音和播放音频这件事,第一次接的时候我以为半天能收工——拖个 MediaElement 上去,麦克风一开,录完存成文件,齐活。真跑起来才知道 MediaElement 只负责放,不负责录;换成底层音频 API 之后,缓冲区、线程、文件句柄、设备采样率全冒出来了。在一个模拟项目里我为此返工两次,最后把整条链路拆成「采集 → 缓冲 → 落盘 → 播放/转码」四段,用 NAudio 一段段接起来才算稳。这篇就按这四段讲:设备怎么枚举、WaveFormat 怎么定、写盘节奏怎么控、UI 线程怎么不炸,以及那几个能把人逼疯的静音坑。适合正在做语音采集、会议记录、在线课堂、客服工单录音这类 WPF 桌面端的人。
2. 选型与环境:WaveInEvent、WasapiCapture、MediaElement 三条链路的边界
动手之前先把链路选对,后面能省掉一半的返工。WPF 里能碰到的音频入口其实就三条:MediaElement 负责播、WaveInEvent 负责录、WasapiCapture 负责更底层的录和回环采集。很多人翻车不是代码写错,是拿 MediaElement 去干录音的活,或者拿 WaveInEvent 去录系统声音,方向一开始就偏了。
2.1 三条音频链路的能力对比
先把三者摆到一张表里看,选型时对着勾。
| 链路 | 能录麦克风 | 能录系统声音 | 能播文件 | 格式控制 | 延迟量级 | 典型用途 |
|---|---|---|---|---|---|---|
| MediaElement | 否 | 否 | 是 | 只认容器,不认裸 PCM | 数百毫秒 | 单纯放个提示音、放个成品音频 |
| WaveInEvent | 是 | 否 | 否 | 可指定采样率/位深/声道 | 几十毫秒 | 麦克风录音、语音采集 |
| WasapiCapture | 是 | 否 | 否 | 跟随设备混音格式 | 十几毫秒 | 需要低延迟、需要拿设备原生格式 |
| WasapiLoopbackCapture | 否 | 是 | 否 | 跟随系统混音格式 | 十几毫秒 | 录会议、录播放器输出、录课件声音 |
MediaElement 的定位要摆正:它是播放器,不是采集器。它的好处是解码交给系统,MP3、WAV、WMA 丢进去就能响,坏处是延迟大、拿不到原始采样、没法做电平表和波形。凡是需要在播放过程中读样本做可视化的场景,就别指望它。
WaveInEvent 是 NAudio 里对 WinMM 录音接口的封装,最大的优点是能自己指定 WaveFormat,44.1kHz/16bit/单声道随口就开,文件直接是标准 WAV。缺点是它有额外的缓冲层,延迟比 WASAPI 高一些,做语音对讲勉强够,做实时耳返就不行了。
WasapiCapture 走的是 Windows 音频会话层,延迟低、能拿到设备的混音格式(通常是 32 位浮点、48kHz、立体声)。它的问题也在这儿:你没法像 WaveInEvent 那样随便指定格式,设备给什么就是什么,落盘前要么原样存,要么自己做重采样。我一般的原则是:录人声、要求文件小、后续要转 MP3 的,用 WaveInEvent;录系统声音、要求低延迟、要做实时处理的,用 Wasapi 系列。
2.2 建工程、装包,先让一段声音响起来
工程本身没什么讲究,WPF 项目(.NET 6/7/8 都行,Framework 4.7.2 以上也够用),目标平台先用 AnyCPU。音频库通过 NuGet 引入,命令行一句话。
# 建一个 WPF 工程 dotnet new wpf -n AudioDemo cd AudioDemo # 引入音频库,主包会自动带上 Core / WinMM / Wasapi 等子包 dotnet add package NAudio装完之后先别急着写录音,用一个最小播放把环境验一遍,确认包引对了、声音出得来。
using NAudio.Wave; // 最简单的播放:读文件、初始化输出设备、开播 public void PlayOnce(string filePath) { using var reader = new AudioFileReader(filePath); // 解码 + 统一成 32 位浮点 using var output = new WaveOutEvent(); // 共享模式输出,不抢占设备 output.Init(reader); output.Play(); // 控制台场景下等播完;WPF 里不要这么写,见 4.1 的状态机 while (output.PlaybackState == PlaybackState.Playing) { Thread.Sleep(100); } }这段代码的逻辑很直白:AudioFileReader负责把各种格式解码成统一的 32 位浮点样本,WaveOutEvent负责把样本推给声卡。WaveOutEvent内部有自己的后台线程做喂数据,所以Play()是异步返回的,不会卡住调用线程。参数上唯一值得改的是DesiredLatency(默认 300ms),把它调到 100~200 之间听感会更跟手,但调太小在低配机器上容易爆音,这是个取舍。
验收标准很简单:能听到声音,就说明包、运行时、输出设备三件事都通了。如果这一步就没声,先去看系统音量合成器里这个进程是不是被单独静音了——这个坑我在第 5 章还会再提一次。
2.3 设备枚举与 WaveFormat 定参
正式录音前必须先枚举设备,不能硬编码DeviceNumber = 0。用户的机器上可能插着 USB 声卡、蓝牙耳机、虚拟声卡,索引 0 是谁完全看运气。
using NAudio.CoreAudioApi; // 枚举所有处于活动状态的录音设备 public static void ListCaptureDevices() { using var enumerator = new MMDeviceEnumerator(); var devices = enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active); for (int i = 0; i < devices.Count; i++) { // i 是 MMDevice 集合下标,和 WaveInEvent.DeviceNumber 不是同一套编号 Console.WriteLine($"[{i}] {devices[i].FriendlyName} state={devices[i].State}"); } }这里有个很容易混淆的点:MMDeviceEnumerator给出的下标是 MMDevice 集合的顺序,而WaveInEvent.DeviceNumber用的是 WinMM 的设备编号,两套编号在设备增删后可能对不上。稳妥做法是启动时用WaveInEvent.DeviceCount配合WaveIn.GetCapabilities(i).ProductName再列一遍,让用户选完之后把DeviceNumber直接存下来,别跨会话复用。
WaveFormat 的四个参数决定了文件大小、兼容性和后续转码成本,给一张我常用的对照表。
| 参数 | 常用取值 | 影响 | 建议 |
|---|---|---|---|
| SampleRate | 16000 / 44100 / 48000 | 越高越清晰,文件越大 | 语音 16k 够用;音乐/会议 44.1k 或 48k |
| Bits | 16 / 32(float) | 16bit 兼容性最好 | 落盘统一 16bit,中间处理可用浮点 |
| Channels | 1 / 2 | 单声道省一半空间 | 麦克风录音一律单声道 |
| BufferMilliseconds | 20 ~ 100 | 越小延迟越低,越容易丢包 | 桌面端 50 是甜点 |
| NumberOfBuffers | 2 ~ 4 | 缓冲队列深度 | 3 比较稳,机器卡就加到 4 |
BufferMilliseconds和NumberOfBuffers是血泪参数。前者决定每次DataAvailable回调送来多少毫秒的数据,后者决定底层排几个缓冲。两个都调小,延迟好看,但在 CPU 抖一下的时候就会丢样本,录出来的声音带咔哒声;两个都调大,延迟上去了,电平表反应会明显滞后。我的经验值是 50ms × 3,除非明确要求低延迟,不要动。
3. 录音落盘:从麦克风缓冲到 WAV 文件的每一步
选型定完,进入真正的录音链路。这一段的核心是把「回调线程送来的字节」安全地写进文件,同时不影响 UI。听起来简单,但线程、句柄、文件头三件事各有一个坑。
3.1 WaveInEvent 的事件流与写盘节奏
WaveInEvent的工作方式是:启动后,底层每隔BufferMilliseconds毫秒触发一次DataAvailable,把这一段的 PCM 字节丢给你。你要做的就是把这些字节追加到文件里。
using NAudio.Wave; private WaveInEvent _waveIn; private WaveFileWriter _writer; private int _packetCount; public void StartRecording(string filePath, int deviceNumber) { _waveIn = new WaveInEvent { DeviceNumber = deviceNumber, WaveFormat = new WaveFormat(44100, 16, 1), // 44.1kHz / 16bit / 单声道 BufferMilliseconds = 50, NumberOfBuffers = 3 }; // 先建 writer,格式必须和采集格式完全一致,否则 WAV 头会写错 _writer = new WaveFileWriter(filePath, _waveIn.WaveFormat); _packetCount = 0; _waveIn.DataAvailable += OnDataAvailable; _waveIn.RecordingStopped += OnRecordingStopped; _waveIn.StartRecording(); } private void OnDataAvailable(object sender, WaveInEventArgs e) { if (_writer == null) return; _writer.Write(e.Buffer, 0, e.BytesRecorded); // 每 20 个包刷一次盘,兼顾掉电安全和 IO 开销 if (++_packetCount % 20 == 0) { _writer.Flush(); } }逻辑上有三处值得说明。第一,WaveFileWriter的构造参数里那个WaveFormat必须和WaveInEvent.WaveFormat是同一个对象或者值完全相等,如果写成 48kHz 去接 44.1kHz 的流,文件能录出来,但播放时音调会偏,这是最隐蔽的一类 bug。第二,Write传的是e.BytesRecorded而不是e.Buffer.Length,后者是缓冲区的容量,通常大于实际数据量,写多了会把上一包的残留数据带进文件。第三,Flush不是每包都调,因为它会去更新 WAV 头里的 RIFF 长度字段,频繁调用在机械盘上会有明显 IO 抖动,20 包一次是个折中。
OnDataAvailable运行在后台线程上,不是 UI 线程。这一点决定了这个回调里不能碰任何 WPF 控件,包括绑定的属性。想在回调里更新界面,必须走Dispatcher,下一节展开。
3.2 实时电平表与 UI 线程安全
录音界面没有电平表,用户根本不知道麦克风有没有收到声音,这是产品体验上的硬伤。电平计算本身不复杂,麻烦的是线程。
private void OnDataAvailable(object sender, WaveInEventArgs e) { if (_writer == null) return; _writer.Write(e.Buffer, 0, e.BytesRecorded); // 16bit 单声道:每 2 字节一个样本 double max = 0; for (int i = 0; i + 1 < e.BytesRecorded; i += 2) { short sample = BitConverter.ToInt16(e.Buffer, i); double v = Math.Abs(sample / 32768d); if (v > max) max = v; } // 映射成 0~100,乘 2 是为了让小音量也有可见变化 double level = Math.Min(100, max * 200); // 关键:切回 UI 线程再动控件 _dispatcher.BeginInvoke(new Action(() => { LevelBar.Value = level; })); }步长i += 2是针对 16 位单声道的。如果采集格式改成 16 位立体声,左右声道交错存放,步长要改成 4,并且要决定是取左右声道的最大值还是平均值——只按 2 走会把右声道的字节当成一个独立样本读,电平表会跳得很诡异,这是我在一个双麦项目里踩过的坑。
BeginInvoke和Invoke的区别也要注意:录音回调每 50ms 来一次,用Invoke会同步阻塞回调线程等 UI 处理完,UI 一卡,回调就积压,积压到缓冲区满就丢样本。电平表这种纯展示的数据一律用BeginInvoke,丢了也无所谓,不能反过来拖累采集。
顺带提一句,如果电平表刷新太频繁导致 UI 掉帧,可以在回调里做一次节流:每 3 个包才更新一次界面,人眼根本分辨不出 50ms 和 150ms 的差别。
3.3 停止、异常与 WAV 文件完整性
录音结束的处理比开始更容易出事。StopRecording()是异步的,它只是通知底层停止,真正的收尾在RecordingStopped事件里。
public void StopRecording() { _waveIn?.StopRecording(); // 不要在这里 Dispose writer } private void OnRecordingStopped(object sender, StoppedEventArgs e) { // 这里才是安全收尾点 _writer?.Flush(); _writer?.Dispose(); _writer = null; _waveIn?.Dispose(); _waveIn = null; if (e.Exception != null) { // 设备被拔出、被独占、驱动异常都会走到这里 _dispatcher.BeginInvoke(new Action(() => StatusText.Text = $"录音异常结束:{e.Exception.Message}")); } }WAV 的文件头里有两个长度字段(RIFF 大小和 data 大小),它们是在WaveFileWriter.Dispose()时回填的。如果在StopRecording()里直接把 writer 干掉,或者在DataAvailable里抛了异常导致没走到Dispose,录出来的文件头长度是 0,播放器要么打不开,要么显示时长 00:00。这就是为什么必须把释放动作放在RecordingStopped里——它是底层保证会触发的一次回调。
兜底方案也得有。进程被强杀、机器蓝屏这类情况,文件头一定残缺,但 PCM 数据其实还在。这种文件的修复思路是把头部 44 字节之外的数据按已知的采样率重新算一个头出来。我一般的做法是:录音开始时先往一个同目录的 sidecar 文件里写一行 JSON,记下 WaveFormat 三要素和开始时间;出问题后按这行信息重建头部。这个机制平时用不上,但真出事的时候是唯一的后悔药。
另外,StoppedEventArgs.Exception一定要判。麦克风被别的程序独占、USB 设备中途拔掉、系统隐私权限被关掉,都会通过这个字段冒出来。不判的话界面上就是「录音按钮点了没反应」,用户完全不知道发生了什么。
4. 播放与转码:进度条、暂停恢复、MP3 输出的实现细节
录完的文件得能听、能定位、能转格式。播放这块看着简单,但状态机写不好就会出现「点暂停再点播放,从头开始」或者「进度条拖不动」这类问题。
4.1 播放器状态机与进度条实现
WaveOutEvent配AudioFileReader是文件播放最稳的组合。AudioFileReader支持CurrentTime和TotalTime,进度条和拖动直接基于它做。
using NAudio.Wave; private WaveOutEvent _output; private AudioFileReader _reader; private DispatcherTimer _timer; public void Load(string filePath) { StopAndDispose(); _reader = new AudioFileReader(filePath); _output = new WaveOutEvent { DesiredLatency = 200 }; _output.Init(_reader); _output.PlaybackStopped += (s, e) => _dispatcher.BeginInvoke(new Action(RefreshUi)); PositionSlider.Maximum = _reader.TotalTime.TotalSeconds; // 200ms 刷一次进度,够顺滑也不吃 CPU _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(200) }; _timer.Tick += (s, e) => { if (_reader == null) return; if (!_isDragging) // 用户正在拖进度条时不要回写,否则会打架 PositionSlider.Value = _reader.CurrentTime.TotalSeconds; TimeText.Text = $"{_reader.CurrentTime:mm\\:ss} / {_reader.TotalTime:mm\\:ss}"; }; _timer.Start(); } public void TogglePause() { if (_output == null) return; if (_output.PlaybackState == PlaybackState.Playing) _output.Pause(); else if (_output.PlaybackState == PlaybackState.Paused) _output.Play(); } public void Seek(double seconds) { if (_reader == null) return; _reader.CurrentTime = TimeSpan.FromSeconds(seconds); }状态机的关键点是:暂停用Pause()而不是Stop()。Stop()会把AudioFileReader的位置重置到 0,再Play()就是从头开始,表现就是「暂停后继续播放,结果重头放了」。Pause()保留位置,Play()接着走。
_isDragging这个标志位是另一个必加的细节。进度条的Value每 200ms 被定时器写一次,用户拖动的时候也在写,两边一打架就会出现「拖到一半被拉回去」的抽搐。做法是在Thumb.DragStarted里把标志置 true,DragCompleted里置 false 并执行一次Seek。
清理也要写对。StopAndDispose里先_output.Stop(),再_output.Dispose(),再_reader.Dispose(),顺序反了会抛「对象已释放」的异常。AudioFileReader持有文件句柄,不释放的话这个文件在本次会话里删不掉,用户点「删除录音」会失败——这个 bug 我见过不止一个项目有。
4.2 转 MP3:两条路线的取舍
WAV 的体积是硬伤,44.1kHz/16bit/单声道一分钟大约 5MB,一小时的会议录音 300MB 起步。落地时基本都要转 MP3。NAudio 体系里有两条路。
第一条是MediaFoundationEncoder.EncodeToMp3,走 Windows 自带的媒体基金会,不引入额外依赖。
using NAudio.Wave; // 走系统媒体基金会转 MP3,码率 192kbps public void WavToMp3(string wavPath, string mp3Path, int bitrate = 192000) { using var reader = new AudioFileReader(wavPath); MediaFoundationEncoder.EncodeToMp3(reader, mp3Path, bitrate); }第二条是LameMP3FileWriter,需要额外装NAudio.Lame包,但码率控制更细、跨系统版本表现更一致。
dotnet add package NAudio.Lameusing NAudio.Lame; using NAudio.Wave; public void WavToMp3ByLame(string wavPath, string mp3Path) { using var reader = new AudioFileReader(wavPath); // 第三参是 LAME 预设,STANDARD 约等于 170kbps VBR,体积和音质平衡 using var writer = new LameMP3FileWriter(mp3Path, reader.WaveFormat, LAMEPreset.STANDARD); reader.CopyTo(writer); }两条路的差别整理成表更清楚。
| 维度 | MediaFoundationEncoder | LameMP3FileWriter |
|---|---|---|
| 额外依赖 | 无,系统自带 | 需引入 NAudio.Lame 及其原生库 |
| 码率控制 | 固定码率,参数简单 | 支持 CBR/VBR/预设 |
| 系统兼容 | 依赖系统媒体组件,精简版系统可能缺 | 自带编码器,兼容性更稳 |
| 部署注意 | 无 | 原生库要跟平台位数对上 |
| 适合场景 | 内部工具、快速出结果 | 对外发布的产品 |
我一般默认用第一条,简单、不用管部署;如果目标机器是精简过的系统镜像,或者客户对码率有明确要求,再换第二条。换第二条的时候注意平台位数:NAudio.Lame带原生库,项目里如果开了Prefer32Bit或者发布成 x86,原生库必须对应 x86,对不上就是运行时DllNotFoundException,报错信息还很不直观。
另外,转码不要在 UI 线程做。一小时 WAV 转 MP3 大概要几秒到几十秒,直接调会把界面冻住。丢到Task.Run里跑,进度通过IProgress<double>回报,这是标准做法。
4.3 系统声音回环采集
有些需求不是录麦克风,是录「电脑里正在放的声音」——在线会议、课件、播放器输出。这时候用WasapiLoopbackCapture。
using NAudio.Wave; private WasapiLoopbackCapture _loopback; private WaveFileWriter _loopWriter; public void StartLoopback(string filePath) { _loopback = new WasapiLoopbackCapture(); // 不需要指定设备,抓的是默认输出 _loopWriter = new WaveFileWriter(filePath, _loopback.WaveFormat); _loopback.DataAvailable += (s, e) => { _loopWriter?.Write(e.Buffer, 0, e.BytesRecorded); }; _loopback.RecordingStopped += (s, e) => { _loopWriter?.Dispose(); _loopWriter = null; _loopback?.Dispose(); _loopback = null; }; _loopback.StartRecording(); }这个类有几个必须知道的行为差异。第一,它的WaveFormat是系统混音格式,通常是 32 位浮点、48kHz、立体声,文件体积比麦克风录音大得多,转 MP3 之前最好先降到 16bit。第二,系统完全静音、没有任何程序在放声音的时候,回环采集可能不产生DataAvailable回调,导致录出来的文件时长比实际录制时间短。如果你的业务要求时间轴严格对齐,得自己补静音帧。第三,它抓的是默认输出设备的混音,用户中途切换输出设备(比如插上耳机),采集会中断或者跟着切,需要在RecordingStopped里做重连逻辑。
5. 避坑与排查:录音静音、崩溃、卡顿的六个现场
音频问题的恶心之处在于「没有报错」。程序跑得好好的,文件也生成了,就是没声音。下面六条是我和同事真实踩过的,按「现象 → 原因 → 解决」记下来。
5.1 六个高频现场
现场一:录出来的文件时长正常,播放全是静音。原因:Windows 隐私设置里麦克风权限没给,而底层不会抛异常,只是持续返回全零样本。这是最坑的一种,因为所有代码逻辑都「成功」了。 解决:录音开始前做一次 2 秒试采,计算样本绝对值之和,接近 0 就提示用户去系统设置里检查麦克风权限;同时在界面上给出明确的引导文案,而不是让用户自己猜。
现场二:点录音按钮程序直接退出,无异常弹窗。原因:WaveInEvent.DeviceNumber用了一个不存在的索引,比如硬编码 1 但机器上只有一个麦克风。异常抛在后台线程,WPF 的全局异常处理没接住,进程就被终结了。 解决:启动时校验deviceNumber在0 ~ WaveIn.DeviceCount - 1之间,越界就回落到-1(系统默认设备);同时在App.xaml.cs里挂DispatcherUnhandledException和AppDomain.UnhandledException兜底。
现场三:录了半小时,文件只有几秒,或者播放到一半断掉。原因:WaveFileWriter在一个线程写,另一个线程(比如定时清理任务)也在动同一个 writer;或者Flush时正好赶上Dispose,句柄已关。 解决:给 writer 加一把锁,所有写和刷盘操作都在锁内;StopRecording之后只等RecordingStopped回调里做释放,其它路径一律不碰 writer。
现场四:声音里有规律的「咔哒」声,节奏和缓冲时间对得上。原因:BufferMilliseconds设得太小(比如 10ms),或者NumberOfBuffers只有 1,系统调度稍一抖动就丢包。 解决:先回到 50ms × 3 的默认组合验证,确认咔哒声消失后再按需往里调;同时检查DataAvailable回调里有没有做重活(比如同步写日志、同步更新 UI),有就挪走。
现场五:电平表数值乱跳,说话时反而没反应。原因:采集格式是立体声,但样本解析按单声道步长走,把左右声道的数据混着读了;或者格式是 32 位浮点却按 16 位整数解析。 解决:电平计算必须和WaveFormat严格对应。16 位单声道步长 2、16 位立体声步长 4、32 位浮点用BitConverter.ToSingle。写死之前先Debug.Assert一下BitsPerSample和Channels。
现场六:开发机一切正常,装到客户机器上播放没声音。原因:客户机器上默认输出设备是蓝牙耳机但处于断开状态,或者系统音量合成器里这个进程被单独拉到 0;也有可能是MediaFoundationEncoder依赖的系统媒体组件在精简版系统里缺失。 解决:播放前枚举一次MMDeviceEnumerator的DataFlow.Render活动设备,数量为 0 就提示「未检测到可用输出设备」;转码失败时捕获异常并回落到 WAV 输出,不要让整个保存流程失败。
5.2 一套五分钟定位流程
出问题的时候别急着改代码,按这个顺序走一遍,八成能定位到层。
| 步骤 | 动作 | 判断依据 |
|---|---|---|
| 1 | 用系统录音机录一段 | 系统录音机也没声,问题在设备或权限,不在代码 |
| 2 | 打印WaveIn.DeviceCount和每个设备名 | 数量为 0 说明驱动层就没识别到设备 |
| 3 | 在DataAvailable里统计非零样本比例 | 全是 0 就是静音输入,权限或静音开关问题 |
| 4 | 把录到的 WAV 拖进任意播放器 | 能放说明采集没问题,问题在播放或转码环节 |
| 5 | 检查StoppedEventArgs.Exception | 有异常就是设备层报错,按消息查 |
这套流程的价值在于快速划清「代码问题」和「环境问题」的边界。我见过太多人一上来就怀疑自己的回调写错了,结果折腾半天发现是系统隐私设置没开。
6. 进阶:把音频模块抽成可替换服务,以及上线前的自检表
音频这块最容易腐化的地方,是把采集、写盘、UI 更新全揉在一个MainWindow.xaml.cs里。等到要加「录系统声音」或者「换成低延迟模式」的时候,改动会沿着整个文件扩散。抽一层接口的收益在这个场景里特别明显。
6.1 接口抽象与实现替换
定义一个最小的录音服务契约,把设备、格式、事件都收进去。
public interface IAudioRecorder : IDisposable { bool IsRecording { get; } WaveFormat CurrentFormat { get; } // 返回采样峰值 0~1,供 UI 做电平表 event Action<double> LevelChanged; // 采集异常,参数为面向用户的提示文案 event Action<string> Failed; void Start(string filePath, int deviceNumber, WaveFormat format); void Stop(); }麦克风实现和回环实现分别实现这个接口,ViewModel 只依赖IAudioRecorder。切换采集源的时候改一行注册代码即可,界面层完全不用动。
// 组合根里按场景注册 IAudioRecorder recorder = useLoopback ? new LoopbackRecorder() : new MicrophoneRecorder(); var vm = new RecordingViewModel(recorder);这里有个设计上的取舍值得说:LevelChanged事件里传的是原始比例,不是控件值。把「乘 100 取整」「节流到 150ms」这类展示逻辑放在 View 层,服务层只管算样本。这样换一个 UI 框架或者加一个波形图,服务层不用改。反过来,如果把ProgressBar直接塞进服务层,测试就没法写了。
另一个建议是把WaveFormat做成服务的构造参数而不是Start的参数。格式一旦确定,整个生命周期内不该变,放在Start里容易让人误以为可以中途切换,实际上一换就会破坏 WAV 头。
6.2 上线前的自检表
功能写完到交付之间,我会强制过一遍这张表。每一条都对应过一次真实事故。
| 检查项 | 验证方法 | 不合格的后果 |
|---|---|---|
| 无麦克风设备 | 禁用所有录音设备后启动 | 启动即崩 |
| 无输出设备 | 禁用所有播放设备后试播 | 播放按钮无响应且无提示 |
| 麦克风权限被拒 | 系统设置里关闭权限后录音 | 生成全静音文件,用户无感知 |
| 录制中拔设备 | 录音时拔 USB 麦克风 | 文件头损坏、进程卡死 |
| 磁盘写满 | 用小容量分区录长音频 | 写盘异常未捕获,界面冻住 |
| 超长录音 | 连续录 2 小时 | 内存增长、进度显示溢出 |
| 文件名含中文/空格 | 保存到中文路径 | 部分编码器路径解析失败 |
| 转码中途取消 | 转 MP3 时关窗口 | 残留半截文件、句柄未释放 |
这张表里我吃过亏最多的是「录制中拔设备」和「麦克风权限被拒」这两条。前者是因为开发时根本没想过用户会拔设备,后者的隐蔽性在于它不报错。所以现在这两个场景我已经做成了自动化用例:一个用虚拟设备模拟热插拔,一个在启动时主动探测权限状态。
还有一条不在表里但值得单独提:录音时长和文件大小要做交叉校验。录完之后用文件字节数 / (采样率 × 位深/8 × 声道数)算一遍理论时长,和界面上显示的录制时长比,偏差超过 5% 就说明中间丢过包,这时候宁可提示用户重录,也别把一个残缺的文件交上去。
从那以后我每次接音频需求,都强制先把设备枚举、权限探测、一段 10 秒试录这三件事跑通,再动 UI。这套顺序看着慢,实际上省掉的是后面反复排查静音的时间。希望帮到你。
本文还有配套的精品资源,点击获取