简介:面向 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。
| 采集方式 | 底层 | 典型延迟 | 适合场景 | 主要限制 |
|---|---|---|---|---|
| WaveInEvent | WinMM waveIn | 30 到 100ms | 语音备忘、会议记录、兼容老设备 | 格式协商弱,蓝牙免提通道容易降质 |
| WasapiCapture 共享模式 | Core Audio | 10 到 40ms | 实时监听、低延迟采集 | 格式受系统混音格式约束 |
| WasapiCapture 独占模式 | Core Audio | 更低 | 对延迟极敏感的专业采集 | 占用设备,采样率必须匹配 |
| MediaFoundationReader | Media 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 IReadOnlyList<AudioDevice> GetCaptureDevices() { var list = new List<AudioDevice>(); 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()返回IReadOnlyList,UI 绑定后不要再去改原列表。默认设备我一般取列表第一项,并在界面上显示“系统默认采集设备”的提示;如果列表为空,按钮直接禁用,提示去系统设置里检查麦克风权限,而不是等用户点了开始再抛异常。设备热插拔后列表会变,可以在窗口激活时重新拉一次,或者监听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。反过来先释放 reader,output 还在读,会抛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, Action<double> 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,要先判断再除,否则会得到NaN,Slider 直接不显示。停止播放后记得_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 调到 50,NumberOfBuffers保持 3,再测。回调里只做writer.Write,UI 更新用DispatcherTimer或Dispatcher.InvokeAsync异步做。格式先用 44100Hz、16bit、单声道做兼容性测试,确认稳定后再降到 16000Hz。如果只有蓝牙耳机出问题,检查是不是走了免提通道,蓝牙 HFP 会把音质降到 8k 或 16k,换有线麦克风对比一下就能定位。
5.3 现象:播放正常,但录进去的人声很小或全是底噪
现象:同一台机器,用系统录音机声音正常,WPF 程序录出来声音小得像蚊子,或者全是“沙沙”底噪。把音量拉满后底噪更大,人声还是不清楚。
原因:WaveInEvent拿的是设备原始输入,系统录音机可能开了自动增益或降噪。麦克风阵列设备还有可能默认只开一个通道,或者把增益设得很低。另一个原因是采样率设得太高,设备不支持,驱动做了劣质重采样。还有一种是选错了设备,选到了“立体声混音”而不是麦克风。
解决:先在系统声音设置里看输入电平,确认麦克风本身有信号。代码里把设备名称显示清楚,避免选到立体声混音。语音场景用 16000Hz 或 44100Hz、16bit、单声道,不要一上来就 48000
本文还有配套的精品资源,点击获取