WPF音频录制与播放:NAudio采集、WAV落盘、MP3转码与避坑 简介面向在 .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 录音接口的封装最大的优点是能自己指定 WaveFormat44.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把它调到 100200 之间听感会更跟手但调太小在低配机器上容易爆音这是个取舍。验收标准很简单能听到声音就说明包、运行时、输出设备三件事都通了。如果这一步就没声先去看系统音量合成器里这个进程是不是被单独静音了——这个坑我在第 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 的四个参数决定了文件大小、兼容性和后续转码成本给一张我常用的对照表。参数常用取值影响建议SampleRate16000 / 44100 / 48000越高越清晰文件越大语音 16k 够用音乐/会议 44.1k 或 48kBits16 / 32(float)16bit 兼容性最好落盘统一 16bit中间处理可用浮点Channels1 / 2单声道省一半空间麦克风录音一律单声道BufferMilliseconds20 ~ 100越小延迟越低越容易丢包桌面端 50 是甜点NumberOfBuffers2 ~ 4缓冲队列深度3 比较稳机器卡就加到 4BufferMilliseconds和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里把标志置 trueDragCompleted里置 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); }两条路的差别整理成表更清楚。维度MediaFoundationEncoderLameMP3FileWriter额外依赖无系统自带需引入 NAudio.Lame 及其原生库码率控制固定码率参数简单支持 CBR/VBR/预设系统兼容依赖系统媒体组件精简版系统可能缺自带编码器兼容性更稳部署注意无原生库要跟平台位数对上适合场景内部工具、快速出结果对外发布的产品我一般默认用第一条简单、不用管部署如果目标机器是精简过的系统镜像或者客户对码率有明确要求再换第二条。换第二条的时候注意平台位数NAudio.Lame带原生库项目里如果开了Prefer32Bit或者发布成 x86原生库必须对应 x86对不上就是运行时DllNotFoundException报错信息还很不直观。另外转码不要在 UI 线程做。一小时 WAV 转 MP3 大概要几秒到几十秒直接调会把界面冻住。丢到Task.Run里跑进度通过IProgressdouble回报这是标准做法。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 Actiondouble LevelChanged; // 采集异常参数为面向用户的提示文案 event Actionstring 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。这套顺序看着慢实际上省掉的是后面反复排查静音的时间。希望帮到你。本文还有配套的精品资源点击获取