
简介面向 WPF 桌面开发者的音频录制与播放示例工程基于 .NET Framework 4.5 与 Visual Studio 2017 环境搭建重点解决在界面程序中接入声卡与麦克风采集、音频文件播放以及本地音频信息读取的问题。代码以 NAudio 的采集事件回调为主线配合 MediaPlayer 完成播放、暂停与停止控制界面层通过按钮事件驱动并包含波形绘制与图表视图相关模块可用于聊天工具、教学软件等场景的音频能力验证。压缩包共 175 个文件、约 3.18MB以 45 个 cs 源码为核心辅以 xaml 与 baml 界面定义、dll 依赖库、png 截图、wav 测试音频及 sln、csproj 工程配置解压后即可用 VS 打开调试。已有 928 人学习下载适合具备一定 C# 基础、希望在 WPF 中快速跑通录音与播放链路的开发者参考复用。1. WPF 录音和播放音频别急着上 MediaElement先把采集链路搭对WPF 录音和播放音频这件事真正落到桌面端最先翻车的往往不是界面而是设备链路点了开始没反应录出来的 WAV 只有电流声切到蓝牙耳机后播放又没声。它要解决的是采集、落盘、播放、设备切换这条闭环不是拖一个 MediaElement 就能收工。适合做会议记录、语音备忘、客服质检小工具的后端和桌面端开发者也适合正在把 WinForms 音频模块迁到 WPF 的人。下面按选型、录音、播放、排查、验证的顺序拆代码可以直接放进 .NET 桌面工程跑。2. 选型与链路WaveInEvent、WasapiCapture 和 MediaElement 怎么排2.1 采集路径WaveInEvent、WasapiCapture、MediaFoundation 的边界WPF 本身没有录音 API桌面端常见做法是引 NAudio把 WinMM、WASAPI、Media Foundation 这几层包成可用的 C# 接口。选型时不要只看延迟数字先看设备兼容性和你要不要拿原始 PCM。WaveInEvent 走的是 WinMM 的 waveIn 接口兼容老声卡和 USB 麦克风代码简单适合语音备忘、会议记录这类对 30 到 100 毫秒延迟不敏感的场景。它的短板是格式协商能力有限设备不支持你写的采样率时会直接抛MmException而且部分蓝牙耳机的免提通道会强制切成 8k 或 16k 单声道录出来像电话音。WasapiCapture 走 Core Audio共享模式下能拿到系统混音后的数据延迟更低适合边录边播的监听链路。共享模式的格式由系统当前输出格式决定不能随便指定 44100 或 48000独占模式延迟更低但会占住设备采样率必须和硬件支持列表严格匹配调试成本高。MediaFoundation 在 NAudio 里更多用于读文件和解码不是首选的采集入口。我一般这样分普通录音用 WaveInEvent实时监听和低延迟通信用 WasapiCapture 共享模式需要处理 MP3、AAC 文件播放时用 MediaFoundationReader 或 AudioFileReader。采集方式底层典型延迟适合场景主要限制WaveInEventWinMM waveIn30 到 100ms语音备忘、会议记录、兼容老设备格式协商弱蓝牙免提通道容易降质WasapiCapture 共享模式Core Audio10 到 40ms实时监听、低延迟采集格式受系统混音格式约束WasapiCapture 独占模式Core Audio更低对延迟极敏感的专业采集占用设备采样率必须匹配MediaFoundationReaderMedia Foundation取决于文件播放 MP3、AAC、WAV不作为采集入口先跑一段设备枚举确认系统到底认出了几个采集端点。下面这段代码不依赖 WPF 界面放在控制台或窗口构造函数里都能跑using NAudio.Wave; static void PrintCaptureDevices() { int count WaveInEvent.DeviceCount; Console.WriteLine($采集设备数量: {count}); for (int i 0; i count; i) { var caps WaveInEvent.GetCapabilities(i); // ProductName 可能重名实际项目里建议把索引一起显示 Console.WriteLine($[{i}] {caps.ProductName} 通道数{caps.Channels}); } }DeviceCount返回的是当前系统可用采集设备数GetCapabilities(i)里的Channels是设备支持的通道数不是你现在要用的通道数。ProductName在部分机器上会出现两个同名端点比如同一个耳麦的模拟输入和 USB 输入所以 UI 上不要只显示名称把索引一起绑进去。如果DeviceCount是 0先别怀疑代码去系统隐私设置里看麦克风权限有没有放开再看设备管理器里有没有被禁用。DeviceNumber我一般显式取枚举列表里的索引不用-1去赌不同封装对默认设备的语义差异。2.2 播放路径WaveOutEvent、WasapiOut、MediaElement 怎么取舍播放比采集更容易被低估。WPF 自带 MediaElement拖一个控件就能播 MP3但它的设备选择、实时 PCM 处理、音量控制和完成事件都不够细。你要做录音回放、波形显示、边录边听MediaElement 会把链路搞成黑匣子拿不到采样数据没法算峰值也没法在播放前做增益。常见做法是文件播放用AudioFileReader WaveOutEvent低延迟监听用WasapiOut只有纯展示型播放才考虑 MediaElement。WaveOutEvent 基于 WinMM稳定、兼容性好配AudioFileReader可以播 WAV、MP3、AIFF内部会转成 32 位浮点再交给声卡。它的DesiredLatency和NumberOfBuffers能调但不要指望压到个位数毫秒桌面回放 80 到 150 毫秒足够。WasapiOut 延迟更低适合监听和通信但共享模式和独占模式的行为差异大设备被其他程序占用时容易初始化失败。MediaElement 的优势是 XAML 里声明式使用适合播放提示音、简单音频文件缺点是设备枚举弱播放失败时异常信息少做音频处理时基本使不上劲。播放方式适合场景设备选择实时处理注意点WaveOutEvent AudioFileReader文件回放、录音回放支持 DeviceNumber可拿 reader 做音量、进度停止和播放完成事件要区分WasapiOut低延迟监听、通信支持 MMDevice可接 BufferedWaveProvider共享/独占模式行为不同MediaElement简单提示音、展示型播放弱不方便异常信息少不适合音频分析播放设备枚举可以用 Core Audio 的MMDeviceEnumerator比 WinMM 的整数索引更好认using NAudio.CoreAudioApi; static void PrintRenderDevices() { using var enumerator new MMDeviceEnumerator(); var devices enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active); foreach (var device in devices) { // FriendlyName 是系统里显示的设备名适合直接给用户看 Console.WriteLine(${device.FriendlyName} | {device.State}); } }这段代码只枚举当前激活的播放端点DataFlow.Render表示输出设备DeviceState.Active过滤掉禁用和拔掉的设备。实际项目里可以把FriendlyName和ID一起存下来设备热插拔后按 ID 找回原设备。注意MMDeviceEnumerator要释放using不能省在 WPF 里枚举设备不要放在每次播放时做窗口加载时拉一次设备变更时再刷新。2.3 参数定标采样率、位深、缓冲区与延迟的换算录音参数没有万能值先看你要保什么。语音备忘用 16000Hz、16bit、单声道就够一小时文件大约 110MB会议记录如果要对音乐或多人声场留余量用 44100Hz、16bit、单声道音乐录制才上 48000Hz、24bit、立体声。WAV 文件大小可以按采样率 × 位深/8 × 声道 × 秒数估算16bit 单声道 16000Hz 每秒 32000 字节加上 44 字节文件头基本不会差太多。不要为了“显得专业”把语音录成 192000Hz 立体声后面做降噪和传输时全是负担。缓冲区是另一个容易拍脑袋的参数。BufferMilliseconds控制每次DataAvailable回调送多少音频NumberOfBuffers是驱动层缓冲数量。两者太小会爆音、断续太大延迟高、停止慢。语音采集我一般用 40 到 60 毫秒、3 个缓冲实时监听用 20 到 40 毫秒、3 个缓冲文件回放用DesiredLatency 120、NumberOfBuffers 3。总延迟不是简单相乘但可以按BufferMilliseconds × (NumberOfBuffers - 1)估个下限再留驱动和系统调度余量。using NAudio.Wave; // 语音备忘16k、16bit、单声道文件小兼容性好 var voiceFormat new WaveFormat(16000, 16, 1); // 音乐或会议留余量44.1k、16bit、单声道 var meetingFormat new WaveFormat(44100, 16, 1); // 立体声录制才用双声道别在语音场景硬上 var musicFormat new WaveFormat(48000, 16, 2);WaveFormat的第二个参数是位深第三个是声道数。16bit PCM 是桌面端最稳的选择8bit 底噪明显32bit 浮点适合内部处理但落盘成 WAV 会变大。设备不支持你指定的格式时StartRecording()会抛异常异常信息里通常带MmException和错误码。稳妥做法是先按目标格式启动失败后降级到 44100Hz、16bit、单声道再失败就提示用户换设备。录音格式和播放格式不必相同AudioFileReader会帮你解码转换但监听链路里采集格式和输出格式差太多会增加重采样开销和延迟。3. 录音落地从设备枚举到 WAV 落盘的完整代码3.1 枚举麦克风与默认设备WPF 里不要把设备枚举写在录音按钮的点击事件里点一次枚举一次设备多的时候界面会顿。常见做法是窗口加载时拉一份列表绑到 ComboBox录音时取 SelectedItem 里的索引。下面这个小的 record 适合直接做绑定项索引和名称一起带过去避免用户看到两个同名设备时选错using NAudio.Wave; public sealed record AudioDevice(int Index, string Name); public static IReadOnlyListAudioDevice GetCaptureDevices() { var list new ListAudioDevice(); int count WaveInEvent.DeviceCount; for (int i 0; i count; i) { var caps WaveInEvent.GetCapabilities(i); // 名称保留原始值索引用于 DeviceNumber list.Add(new AudioDevice(i, caps.ProductName)); } return list; }AudioDevice里的Index就是后面要传给WaveInEvent.DeviceNumber的值Name只负责显示。GetCaptureDevices()返回IReadOnlyListUI 绑定后不要再去改原列表。默认设备我一般取列表第一项并在界面上显示“系统默认采集设备”的提示如果列表为空按钮直接禁用提示去系统设置里检查麦克风权限而不是等用户点了开始再抛异常。设备热插拔后列表会变可以在窗口激活时重新拉一次或者监听WaveInEvent.DeviceCount变化做轻量刷新。3.2 WaveInEvent 采集与 WAV 写盘下面这个WaveRecorder类把启动、写盘、停止和释放收在一起可以直接放进 WPF 工程。关键点是WaveFileWriter只在DataAvailable回调线程里写并且加锁停止时先StopRecording()等RecordingStopped回调里再释放 writer避免回调还在写的时候把流关掉。using NAudio.Wave; public sealed class WaveRecorder : IDisposable { private WaveInEvent? _waveIn; private WaveFileWriter? _writer; private readonly object _gate new(); public bool IsRecording { get; private set; } public string? LastFilePath { get; private set; } public void Start(int deviceNumber, string filePath, WaveFormat? format null) { if (IsRecording) throw new InvalidOperationException(录音已在进行中); var directory Path.GetDirectoryName(filePath); if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory); // 语音场景默认 16k、16bit、单声道 format ?? new WaveFormat(16000, 16, 1); _waveIn new WaveInEvent { DeviceNumber deviceNumber, WaveFormat format, BufferMilliseconds 50, NumberOfBuffers 3 }; _writer new WaveFileWriter(filePath, format); _waveIn.DataAvailable OnDataAvailable; _waveIn.RecordingStopped OnRecordingStopped; LastFilePath filePath; IsRecording true; _waveIn.StartRecording(); } private void OnDataAvailable(object? sender, WaveInEventArgs e) { if (e.BytesRecorded 0) return; lock (_gate) { _writer?.Write(e.Buffer, 0, e.BytesRecorded); } } private void OnRecordingStopped(object? sender, StoppedEventArgs e) { lock (_gate) { _writer?.Dispose(); _writer null; _waveIn?.Dispose(); _waveIn null; IsRecording false; } if (e.Exception ! null) { // 这里不要直接弹 MessageBox回调线程不是 UI 线程 // 实际项目里把异常抛给上层由 Dispatcher 处理 } } public void Stop() { _waveIn?.StopRecording(); } public void Dispose() { Stop(); lock (_gate) { _writer?.Dispose(); _writer null; _waveIn?.Dispose(); _waveIn null; IsRecording false; } } }DeviceNumber用 3.1 里枚举出来的索引filePath建议用Path.Combine拼到用户文档目录不要写死到程序目录否则发布到只读目录会失败。WaveFormat必须和WaveFileWriter使用同一个实例不能一个写 16k 一个写 44.1k否则文件头和数据对不上播放器会显示异常时长。DataAvailable回调频率由BufferMilliseconds决定50 毫秒意味着每秒 20 次左右写文件足够快不要在回调里做 UI 更新、日志刷盘或网络发送这些操作会把回调拖慢表现就是录音断续。RecordingStopped里的e.Exception要处理设备被拔掉、驱动异常、权限被收回时都会从这里出来。实际项目里把异常存到字段再用Dispatcher.InvokeAsync回 UI 线程弹提示。Dispose()里重复调用Stop()和释放是防御性写法但不要在Stop()后立刻手动Disposewriter因为StopRecording()是异步停止回调可能还没跑完。3.3 暂停、停止与资源释放的顺序NAudio 的WaveInEvent没有原生暂停只有开始和停止。界面上需要暂停时常见做法是在DataAvailable里判断一个_paused标志暂停期间要么丢弃数据要么写静音。丢弃数据的后果是文件时长变短恢复后时间轴会跳写静音能保持时长但要自己生成零字节。对会议记录这种要求时间准确的场景我倾向写静音对语音备忘丢弃也能接受但要在 UI 上明确告诉用户暂停期间不录。private volatile bool _paused; private void OnDataAvailable(object? sender, WaveInEventArgs e) { if (e.BytesRecorded 0) return; lock (_gate) { if (_writer null) return; if (_paused) { // 暂停期间写静音保持文件时长连续 var silence new byte[e.BytesRecorded]; _writer.Write(silence, 0, silence.Length); } else { _writer.Write(e.Buffer, 0, e.BytesRecorded); } } }上面这段每次回调都new byte[]短时间没问题长时间高频录音会有 GC 压力。更稳的写法是预分配一个和最大回调长度一致的静音缓冲回调里只写需要的长度。_paused用volatile是为了让回调线程及时看到 UI 线程的修改但volatile不保证复合操作原子性真正的写入仍然要在_gate锁里做。停止顺序建议固定成三步先StopRecording()再在RecordingStopped里Disposewriter 和 waveIn最后通知 UI 恢复按钮状态。不要在Stop()方法里同步等待回调WPF UI 线程会被卡住如果必须等文件写完再打开用await Task.Run(() recorder.Stop())包一层或者用ManualResetEventSlim在后台等。释放顺序反了比如先writer.Dispose()再StopRecording()回调里写已释放的流轻则文件损坏重则进程直接崩。这个坑我见过不止一次血泪经验就是停止录音的代码越短越好清理逻辑全部放进RecordingStopped。4. 播放落地文件播放、实时监听与音量进度4.1 AudioFileReader 与 WaveOutEvent 播放文件文件播放推荐AudioFileReader WaveOutEvent。AudioFileReader负责解码 WAV、MP3、AIFF并统一转成 32 位浮点WaveOutEvent负责把数据送到声卡。下面这个AudioPlayer类把加载、播放、暂停、停止、跳转和释放收在一起重点是PlaybackStopped里要区分“自然播完”和“手动停止”否则 UI 会在用户点停止时也弹出“播放完成”。using NAudio.Wave; public sealed class AudioPlayer : IDisposable { private WaveOutEvent? _output; private AudioFileReader? _reader; private bool _manualStop; public event EventHandler? PlaybackEnded; public float Volume { get _reader?.Volume ?? 1f; set { if (_reader ! null) _reader.Volume Math.Clamp(value, 0f, 1f); } } public TimeSpan Position _reader?.CurrentTime ?? TimeSpan.Zero; public TimeSpan Duration _reader?.TotalTime ?? TimeSpan.Zero; public void Load(string filePath, int deviceNumber 0) { Dispose(); _reader new AudioFileReader(filePath); _output new WaveOutEvent { DeviceNumber deviceNumber, DesiredLatency 120, NumberOfBuffers 3 }; _output.Init(_reader); _output.PlaybackStopped (_, e) { if (e.Exception ! null) { // 播放设备被拔掉、文件损坏都会进这里 } if (!_manualStop) PlaybackEnded?.Invoke(this, EventArgs.Empty); }; } public void Play() { _manualStop false; _output?.Play(); } public void Pause() _output?.Pause(); public void Stop() { _manualStop true; _output?.Stop(); } public void Seek(TimeSpan position) { if (_reader null) return; var target position TimeSpan.Zero ? TimeSpan.Zero : position; if (target _reader.TotalTime) target _reader.TotalTime; _reader.CurrentTime target; } public void Dispose() { _manualStop true; _output?.Stop(); _output?.Dispose(); _output null; _reader?.Dispose(); _reader null; } }AudioFileReader的Volume是 0 到 1 的线性值直接绑 Slider 时记得在 ViewModel 里做范围限制。DeviceNumber用播放设备枚举的索引传错会初始化失败。DesiredLatency 120对文件回放够用太小在低性能机器上容易爆音。PlaybackStopped事件在手动Stop()时也会触发所以用_manualStop标志区分如果要在事件里更新 UI用Dispatcher.InvokeAsync不要直接改控件。释放顺序必须是先Stop()再Dispose()output最后Dispose()reader。反过来先释放 readeroutput 还在读会抛ObjectDisposedException或者直接静默崩溃。Load()里先Dispose()旧实例也是这个原因切换文件时先把旧播放链路拆干净再建新的。4.2 边录边播监听与反馈抑制边录边播的监听链路核心是把采集数据塞进一个缓冲再由播放设备取走。NAudio 里常见做法是WaveInEvent BufferedWaveProvider WaveOutEvent采集回调把字节写进缓冲播放设备从缓冲读。缓冲要设DiscardOnBufferOverflow true否则采集比播放快时缓冲会无限涨最后内存爆掉。using NAudio.Wave; public sealed class MonitorChain : IDisposable { private WaveInEvent? _waveIn; private BufferedWaveProvider? _buffer; private WaveOutEvent? _output; public void Start(int captureDeviceNumber, int renderDeviceNumber) { var format new WaveFormat(44100, 16, 1); _waveIn new WaveInEvent { DeviceNumber captureDeviceNumber, WaveFormat format, BufferMilliseconds 40, NumberOfBuffers 3 }; _buffer new BufferedWaveProvider(format) { BufferDuration TimeSpan.FromMilliseconds(600), DiscardOnBufferOverflow true }; _output new WaveOutEvent { DeviceNumber renderDeviceNumber, DesiredLatency 90, NumberOfBuffers 3 }; _output.Init(_buffer); _output.Play(); _waveIn.DataAvailable (_, e) { // 缓冲满时丢弃新数据保证延迟不继续涨 _buffer.AddSamples(e.Buffer, 0, e.BytesRecorded); }; _waveIn.StartRecording(); } public void Stop() { _waveIn?.StopRecording(); _output?.Stop(); } public void Dispose() { _waveIn?.Dispose(); _waveIn null; _output?.Dispose(); _output null; _buffer null; } }BufferDuration设 600 毫秒是给系统调度留余量太小会频繁 underrun表现是断断续续太大监听延迟明显说话会有回声感。DiscardOnBufferOverflow必须开不开的话采集端比播放端快几秒就能把内存吃上去。采集和播放都用 44100Hz、16bit、单声道是为了减少重采样如果输出设备只接受立体声可以把格式改成 44100Hz、16bit、2 声道但麦克风采集通常还是单声道中间要做声道转换代码会复杂一些。监听最大的坑是啸叫不是代码问题是声学反馈麦克风收到扬声器放出来的声音再放大循环几轮就尖叫。解决方式很直接戴耳机、降低输出音量、麦克风远离扬声器。软件端可以做增益限制和静音按钮但不要指望基础链路自带回声消除。需要真正 AEC 的场景得引入专门的音频处理模块普通 WPF 小工具用耳机就能绕过去。4.3 播放进度、音量与完成事件播放进度不要用CompositionTarget.Rendering每帧刷音频进度不需要 60fps。DispatcherTimer设 100 毫秒足够绑 Slider 时注意用户拖动进度条和自动更新会打架常见做法是拖动期间暂停自动更新松开后再Seek。下面这段只演示定时读位置using System.Windows.Threading; private DispatcherTimer? _progressTimer; private void StartProgressTimer(AudioPlayer player, Actiondouble onProgress) { _progressTimer new DispatcherTimer { Interval TimeSpan.FromMilliseconds(100) }; _progressTimer.Tick (_, _) { if (player.Duration TimeSpan.Zero) return; var value player.Position.TotalSeconds / player.Duration.TotalSeconds; onProgress(Math.Clamp(value, 0d, 1d)); }; _progressTimer.Start(); }onProgress里只做 Slider 赋值或 ViewModel 属性更新不要在里面读文件、写日志。player.Duration在未加载文件时是TimeSpan.Zero要先判断再除否则会得到NaNSlider 直接不显示。停止播放后记得_progressTimer.Stop()窗口关闭时也要停不然定时器会一直持有窗口引用。音量控制直接改AudioFileReader.Volume范围 0 到 1如果要做静音切换记住静音前的值恢复时还原不要直接把音量设成 1 炸用户耳朵。播放完成事件已经在AudioPlayer里用PlaybackEnded暴露UI 收到后把按钮状态切回“播放”需要循环播放就重新Seek(TimeSpan.Zero)再Play()。注意WaveOutEvent播放到文件末尾后不会自动回到开头下一次Play()前必须手动重置位置。5. 避坑与排查WPF 音频最常见的五类翻车5.1 现象点开始录音没反应日志里只有一行“没有设备”现象按钮点下去界面没报错录音文件也没生成日志里只有设备数量 0 或者WaveInEvent.DeviceCount返回 0。换一台机器又正常开发机上始终复现不了。原因最常见的是系统麦克风权限被关掉WPF 桌面应用虽然不像打包应用那样强依赖清单但 Windows 隐私设置里仍然可以禁止桌面程序访问麦克风。其次是设备被其他程序独占或者 USB 麦克风被禁用。还有一种情况是DeviceNumber传了枚举列表里不存在的索引StartRecording()抛异常但被上层吞了。解决先在系统设置里确认麦克风权限和输入设备状态再跑 2.1 的设备枚举代码把DeviceCount和每个设备的ProductName打出来。DeviceCount为 0 时不要继续调StartRecording()直接提示用户检查设备。DeviceNumber不要写死 0用 ComboBox 选中项的索引。启动录音的代码外面包一层try/catch把MmException的错误码打到日志里不要只留一句“录音失败”。5.2 现象录出来的 WAV 有爆音、断续像被“掐断”现象录音文件能播但每隔几秒出现“咔”的一声或者人声断成一小段一小段。机器负载高时更明显换到另一台电脑又好了。原因BufferMilliseconds太小、NumberOfBuffers太少采集回调来不及处理时驱动层就丢数据。另一个常见原因是DataAvailable回调里做了重活比如更新 UI、写日志、发网络请求把回调线程拖慢。还有可能是采样率或位深设备不支持驱动内部在做格式转换转换失败时表现为杂音。解决先把BufferMilliseconds从 20 调到 50NumberOfBuffers保持 3再测。回调里只做writer.WriteUI 更新用DispatcherTimer或Dispatcher.InvokeAsync异步做。格式先用 44100Hz、16bit、单声道做兼容性测试确认稳定后再降到 16000Hz。如果只有蓝牙耳机出问题检查是不是走了免提通道蓝牙 HFP 会把音质降到 8k 或 16k换有线麦克风对比一下就能定位。5.3 现象播放正常但录进去的人声很小或全是底噪现象同一台机器用系统录音机声音正常WPF 程序录出来声音小得像蚊子或者全是“沙沙”底噪。把音量拉满后底噪更大人声还是不清楚。原因WaveInEvent拿的是设备原始输入系统录音机可能开了自动增益或降噪。麦克风阵列设备还有可能默认只开一个通道或者把增益设得很低。另一个原因是采样率设得太高设备不支持驱动做了劣质重采样。还有一种是选错了设备选到了“立体声混音”而不是麦克风。解决先在系统声音设置里看输入电平确认麦克风本身有信号。代码里把设备名称显示清楚避免选到立体声混音。语音场景用 16000Hz 或 44100Hz、16bit、单声道不要一上来就 48000本文还有配套的精品资源点击获取