作者:周杰
我们每天都在用音乐播放器,但你是否想过自己动手实现一个?今天介绍下古茗自研的一个播放器的实现,以及音频播放的基本原理。
为什么选择 NAudio
首先说下为啥用NAudio,我们端上当前技术栈C#用的比较多,而且门店都是Windows收银机。于是调研了一些C#库或技术,最终选了NAudio:
技术选型对比
| 技术/框架 | 优点 | 缺点 |
|---|---|---|
| NAudio | 开源,文档较详细,使用简单,除了播放外,还能对音频做很多处理,比如音频合成、拼接、转换,有自定义需求扩展也方便 star:6k | 作者已停止维护 |
| LibVLCSharp | 能实现常规播放需求 | 当时测的时候发现分片音频拼接播放不太友好,分片间的时间间隔控制不好;文档复杂 |
| WindowsMediaPlayer | Windows原生 | API过于简单,控制能力不够强 |
| SDL2 + FFmpeg | 跨端,使用广泛且成熟 | 封装性较强,C#只能通过DLL方式引入,使用复杂度高。文档不太友好 |
总结:NAudio对C#生态最友好,功能完整,文档完善,是我们的最佳选择。
快速开始
引入NAudio
新建项目后,引入NAudio有两种方式:
- 通过NuGet包管理器 :直接搜索"NAudio"安装最新包即可

- 去 GitHub下载源码放到项目中 - 推荐这种,因为后续如果库出现问题方便维护
要实现的功能
一个最简单的播放器需要哪些功能?
- 音频列表、播放、暂停
- 上一首/下一首
- 单曲循环/顺序播放
- 播放进度显示
- 音量调节
其中核心要实现的技术点是:播放、暂停、音量调节、播放进度、播放结束事件监听。其他都是常规逻辑实现。
核心实现解析
下面结合详细解释来看各个功能如何实现:
播放功能
csharp
var audioFile = new AudioFileReader("test.mp3");
var outputDevice = new WaveOutEvent();
outputDevice.Init(audioFile);
outputDevice.Play();
代码解释:
AudioFileReader:相当于一个音频文件读取器,帮你从MP3等文件中读取音频数据WaveOutEvent:相当于一个播放设备,负责把音频数据发送到声卡Init():初始化播放设备,告诉它要播放什么音频Play():开始播放
暂停功能
csharp
outputDevice.Pause();
代码解释:
Pause():暂停播放,音频数据仍然在内存中,随时可以恢复播放
音量调节
csharp
audioFile.Volume = 0.5f; // 0.5表示50%音量
代码解释:
Volume:控制音量的属性,1.0表示最大音量,0.0表示静音
播放进度追踪
csharp
Task.Run(async () =>
{
while (currentOutputDevice?.PlaybackState == PlaybackState.Playing)
{
// 获取当前播放进度
var currentTime = currentAudioFile?.CurrentTime.TotalSeconds ?? 0;
triggerPlayProgressEvent((int)currentTime);
await Task.Delay(1000); // 每秒更新一次
}
});
代码解释:
- 起个线程,每秒获取一次当前播放进度
播放结束事件监听
csharp
outputDevice.PlaybackStopped += (object sender, StoppedEventArgs args) =>
{
// 这里有点需要注意,虽然是结束才触发,但分为自然播放结束和切歌
// 切歌时上一首歌也是播放结束的,所以如果这里要触发事件,需要考虑这两种情况
};
代码解释:
PlaybackStopped:播放停止事件(自然结束或切歌)
完整播放器实现
下面是一个简单的播放器类demo:
csharp
public class AudioPlayer : IDisposable
{
private AudioFileReader audioFile; // 音频文件读取器
private WaveOutEvent outputDevice; // 播放设备
private bool isPlaying = false; // 是否正在播放
private Timer progressTimer; // 进度更新定时器
// 事件:进度改变和播放结束
public event EventHandler<int> ProgressChanged; // 进度改变时触发
public event EventHandler PlaybackEnded; // 播放结束时触发
public AudioPlayer()
{
// 构造函数:初始化播放设备
outputDevice = new WaveOutEvent();
// 订阅播放结束事件
outputDevice.PlaybackStopped += OnPlaybackStopped;
}
public void Play(string filePath)
{
try
{
// 停止当前播放,避免多个音频同时播放
Stop();
// 加载新的音频文件
audioFile = new AudioFileReader(filePath);
outputDevice.Init(audioFile);
// 开始播放
outputDevice.Play();
isPlaying = true;
// 启动进度定时器,每秒更新一次进度
StartProgressTimer();
}
catch (Exception ex)
{
// 如果播放失败,显示错误信息
Console.WriteLine($"播放失败: {ex.Message}");
}
}
public void Pause()
{
// 只有正在播放时才暂停
if (isPlaying && outputDevice?.PlaybackState == PlaybackState.Playing)
{
outputDevice.Pause();
StopProgressTimer(); // 停止进度更新
}
}
public void Resume()
{
// 只有暂停时才恢复播放
if (isPlaying && outputDevice?.PlaybackState == PlaybackState.Paused)
{
outputDevice.Play();
StartProgressTimer(); // 重新启动进度更新
}
}
public void Stop()
{
outputDevice?.Stop();
audioFile?.Dispose(); // 释放音频文件资源
audioFile = null;
isPlaying = false;
StopProgressTimer();
}
public void SetVolume(float volume)
{
// 限制音量范围在0.0到1.0之间
if (audioFile != null)
{
audioFile.Volume = Math.Clamp(volume, 0f, 1f);
}
}
public TimeSpan GetCurrentTime()
{
// 获取当前播放时间
return audioFile?.CurrentTime ?? TimeSpan.Zero;
}
public TimeSpan GetTotalTime()
{
// 获取音频总长度
return audioFile?.TotalTime ?? TimeSpan.Zero;
}
// 启动进度更新定时器
private void StartProgressTimer()
{
progressTimer = new Timer(UpdateProgress, null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
}
// 停止进度更新定时器
private void StopProgressTimer()
{
progressTimer?.Dispose();
progressTimer = null;
}
// 更新播放进度的私有方法
private void UpdateProgress(object state)
{
// 只有正在播放时才更新进度
if (isPlaying && audioFile != null)
{
// 将当前时间转换为秒数
int currentSeconds = (int)audioFile.CurrentTime.TotalSeconds;
// 触发进度改变事件,通知界面更新
ProgressChanged?.Invoke(this, currentSeconds);
}
}
// 处理播放结束事件的私有方法
private void OnPlaybackStopped(object sender, StoppedEventArgs e)
{
isPlaying = false;
StopProgressTimer();
// 触发播放结束事件
PlaybackEnded?.Invoke(this, EventArgs.Empty);
}
// 释放资源,防止内存泄漏
public void Dispose()
{
Stop();
outputDevice?.Dispose();
}
}
使用示例
csharp
// 创建播放器实例
var player = new AudioPlayer();
// 订阅事件:当进度改变时显示
player.ProgressChanged += (sender, seconds) =>
{
Console.WriteLine($"当前进度: {TimeSpan.FromSeconds(seconds)}");
};
// 订阅事件:播放结束时显示
player.PlaybackEnded += (sender, e) =>
{
Console.WriteLine("播放结束");
};
// 播放音乐
player.Play("background.mp3");
// 调节音量到70%
player.SetVolume(0.7f);
// 暂停2秒然后恢复
player.Pause();
Thread.Sleep(2000);
player.Resume();
// 5秒后停止播放
Thread.Sleep(5000);
player.Stop();
NAudio内部实现原理
在了解实现原理之前,我们先提出一个问题:我们知道音频播放一般基于音频文件比如MP3,那么这些文件里存的到底是什么?
音频基础概念
声音的本质
声音的本质是一种能量波,由振动而产生的能量波。想象一下,吉他拨弦为什么会发出声音?因为琴弦振动,这种振动以波的形式传播到我们耳朵里。
而波形可以用如下曲线demo表示

声音的属性:
- 音调 :声音频率的高低,表示人的听觉分辨一个声音的调子高低的程度。音调主要由声音的频率(波形密度)决定
- 音量 :由"振幅"(amplitude)和人离声源的距离决定,振幅越大响度越大(波形峰值高度)
- 音色:又称声音的品质,波形决定了声音的音色
PCM数据是什么?
通过对波形的有规律的采样形成PCM数据(也就是采样数据)。

麦克风的作用:麦克风会把声音的振动波转成电压信号交给处理设备
量化的意思:把取到的点,换算成一个计算机能表示的最接近的值
通过采样后,得到的数据就是PCM。PCM有两个重要属性:
采样率
采样的频率
- 44.1kHz采样率 = 每秒钟对模拟声波进行44,100次采样
采样位数
通过采样位数,规定把振幅划分成多少个等级。分的等级越多,最终量化的值就跟曲线越拟合。
比如下图:分8个等级,最终采样点跟曲线并不完全拟合

常用的采样位数有:
- 8bit (1字节) - 能记录256个数
- 16bit (2字节) - 能记录65,536个数,这是CD标准(通常都是这种)
- 32bit (4字节) - 能记录4,294,967,296个数
其实这里能分到的等级,就是相应位数能表示的数值范围,比如16bit=0~65535
两种PCM数据
PCM有两种类型:整型、浮点(float)。一般来说音频文件存储整型数据,音频软件内处理的时候先转成Float再处理,原因是整型数据处理时数据容易溢出、浮点精度更高。
NAudio内部对这两种PCM数据分别有个基类:
csharp
// 浮点基类 - 用于处理精度要求高的音频效果
public interface ISampleProvider
{
WaveFormat WaveFormat { get; }
int Read(float[] buffer, int offset, int count); // 读取浮点数据
}
// 整型基类 - 用于读取音频文件
public interface IWaveProvider
{
WaveFormat WaveFormat { get; }
int Read(byte[] buffer, int offset, int count); // 读取整型数据
}
Read方法的作用:约定了音频流式读取的规范,每个实现类都要实现这个方法
buffer:代表要用来播放的数据offset:代表要把数据放入buffer的起始位置,比如从buffer第第50位开始插值count:代表需要的样本数返回值:代表实际读到的样本数
播放流程详细解析
读取音频文件
回顾之前的播放代码,我们用的是AudioFileReader类来读取文件,这个类初始化后主要做了三件事:
- 生成一个整型PCM流
- 对流数据做处理,转成浮点数据
- 实现流读取函数
csharp
// 第一步:生成一个音频流,MediaFoundationReader提供的是整型PCM
readerStream = new MediaFoundationReader(fileName);
// 第二步:把readerStream转为ISampleProvider(浮点型)
sampleChannel = new SampleChannel(readerStream, false);
// 第三步:实现流读取函数
public int Read(float[] buffer, int offset, int count)
{
lock (lockObject)
{
return sampleChannel.Read(buffer, offset, count);
}
}
MediaFoundation读取机制
如下是MediaFoundationReader的核心代码(简化版):
csharp
public override int Read(byte[] buffer, int offset, int count) {
// 调用windows api创建reader
MFCreateSourceReaderFromURL(file, null, out pReader)
int bytesWritten = 0;
// 如果已经从文件读取的样本数据缓存还没用完,就继续从缓存里读取用于播放
if (decoderOutputCount > 0)
{
bytesWritten += ReadFromDecoderBuffer(buffer, offset, count - bytesWritten);
}
while (bytesWritten < count)
{
// 从文件读采样数据
pReader.ReadSample(MediaFoundationInterop.MF_SOURCE_READER_FIRST_AUDIO_STREAM, 0,
out int actualStreamIndex, out MF_SOURCE_READER_FLAG dwFlags, out ulong timestamp, out IMFSample pSample);
// 把采样数据转换为c#结构化对象
pSample.ConvertToContiguousBuffer(out IMFMediaBuffer pBuffer);
// 缓存到decoderOutputBuffer
pBuffer.Lock(out IntPtr pAudioData, out int pcbMaxLength, out int cbBuffer);
EnsureBuffer(cbBuffer);
Marshal.Copy(pAudioData, decoderOutputBuffer, 0, cbBuffer);
// 从decoderOutputBuffer里读取数据放入buffer
bytesWritten += ReadFromDecoderBuffer(buffer, offset + bytesWritten, count - bytesWritten);
......
}
这里用的是Windows的MediaFoundation系列API,总体流程是:
MFCreateSourceReaderFromURL→ 创建读对象pReaderpReader.ReadSample→ 使用读对象读sample数据(整型PCM)pSample.ConvertToContiguousBuffer→ 转为C#对象
浮点转换过程
SampleChannel类主要做两件事:
- 判断传入的readerStream类型和采样位数,统一转成ISampleProvider对象
csharp
if (waveProvider.WaveFormat.Encoding == WaveFormatEncoding.Pcm)
{
if (waveProvider.WaveFormat.BitsPerSample == 16)
{
sampleProvider = new Pcm16BitToSampleProvider(waveProvider); // 16位整型转浮点
}
// ...其他位数处理
}
- 添加音量调节能力的支持
csharp
volumeProvider = new VolumeSampleProvider(sampleProvider);
- 实现Read
csharp
public int Read(float[] buffer, int offset, int sampleCount)
{
return volumeProvider.Read(buffer, offset, sampleCount);
}
Pcm16BitToSampleProvider解析
核心是Read实现:
csharp
public class Pcm16BitToSampleProvider : SampleProviderConverterBase
{
public Pcm16BitToSampleProvider(IWaveProvider source)
: base(source)
{
}
public override int Read(float[] buffer, int offset, int count)
{
int sourceBytesRequired = count * 2;
EnsureSourceBuffer(sourceBytesRequired);
int bytesRead = source.Read(sourceBuffer, 0, sourceBytesRequired);
int outIndex = offset;
for(int n = 0; n < bytesRead; n+=2)
{
buffer[outIndex++] = BitConverter.ToInt16(sourceBuffer, n) / 32768f;
}
return bytesRead / 2;
}
}
这里Read方法的逻辑是:
- 需要count个样本,从buffer的offset位置开始写入buffer
- 16位pcm,每个样本占2个字节,所以实际读取的字节长度是:
count * 2 - 从
IWaveProvider source读取count * 2个字节的数据 - 同理,因为每个样本2个字节,所以每2个字节除32768f得到浮点数据
- 除32768f的原因是:16-bit 有符号整数的取值范围是-32768 ~ +32767,取两者的最大值32768,丢失的正值的1精度直接忽略
这样就对一个整型pcm实现了浮点式读取
添加音量调节能力的支持,下面讲音频处理的时候统一讲
播放逻辑
通过上面的步骤,已经实现了从文件读整型PCM,并转为floatPCM,接下来开始播放:
csharp
// 新建播放对象
var outputDevice = new WaveOutEvent();
// 初始化
outputDevice.Init(audioFile);
// 播放
outputDevice.Play();
新建对象基本没干啥,主要看初始化、播放两步
初始化播放对象
简化版代码如下:
csharp
public void Init(IWaveProvider waveProvider)
{
// 初始化一个事件对象,这个事件传入waveOutOpen,当前缓冲区播放完后,系统会触发这个事件
callbackEvent = new AutoResetEvent(false);
waveStream = waveProvider;
// 缓冲区大小 = 预计的播放延迟 / 缓冲区数量 (这里分母+NumberOfBuffers - 1的做法是一种向上取整逻辑)
int bufferSize = waveProvider.WaveFormat.ConvertLatencyToByteSize((DesiredLatency + NumberOfBuffers - 1) / NumberOfBuffers);
// 调用系统api打开播放设备,获取设备句柄
MmResult result = waveOutOpen(out hWaveOut, (IntPtr)DeviceNumber, waveStream.WaveFormat, callbackEvent.SafeWaitHandle.DangerousGetHandle(), IntPtr.Zero, WaveInterop.WaveInOutOpenFlags.CallbackEvent);
// 初始化缓冲区
buffers = new WaveOutBuffer[NumberOfBuffers];
playbackState = PlaybackState.Stopped;
for (var n = 0; n < NumberOfBuffers; n++)
{
buffers[n] = new WaveOutBuffer(hWaveOut, bufferSize, waveStream, waveOutLock);
}
}
这里主要做两件事:
- 调用waveOutOpen打开设备句柄
- 初始化NumberOfBuffers个播放缓冲区(通常2个)
这里有个DesiredLatency,表示预计播放延迟,默认300ms,那么缓冲区大小默认就是150ms
缓冲区初始化
看下缓冲区初始化代码:
csharp
public WaveOutBuffer(IntPtr hWaveOut, Int32 bufferSize, IWaveProvider bufferFillStream, object waveOutLock)
{
// 1、生成一个缓冲区
buffer = new byte[bufferSize];
hBuffer = GCHandle.Alloc(buffer, GCHandleType.Pinned);
// 2、把缓冲区绑定到一个WaveHeader对象上
header = new WaveHeader();
hHeader = GCHandle.Alloc(header, GCHandleType.Pinned);
header.dataBuffer = hBuffer.AddrOfPinnedObject();
header.bufferLength = bufferSize;
header.loops = 1;
hThis = GCHandle.Alloc(this);
header.userData = (IntPtr)hThis;
// 3、把WaveHeader对象跟设备句柄关联
waveOutPrepareHeader(hWaveOut, header, Marshal.SizeOf(header));
}
这里总结就是:
生成缓冲区,调用waveOutPrepareHeader把设备句柄跟缓冲区关联
双缓冲机制
上面提到一般是2个缓冲区,那为什么要用2个缓冲区?因为可以保证音频播放的连续性:
plain
第一次播放 → 两个缓冲区都被填满,开始播放
第一个缓冲区播放完(150ms后)→ 立即填充新数据,继续播放第二个缓冲区
第二个缓冲区播放完(150ms后)→ 立即填充新数据,继续播放第一个缓冲区
这样循环往复,保证没有间隙
播放线程逻辑
csharp
private void DoPlayback()
{
while (playbackState != PlaybackState.Stopped)
{
// 等待缓冲区播放结束或超时(这里正常是等待150ms,也就是一个缓冲区的时长)
callbackEvent.WaitOne(DesiredLatency); // 300ms
// 遍历每个缓冲区,填充新数据
foreach (var buffer in buffers)
{
if (!buffer.inqueue) buffer.OnDone();
}
}
// 播放完后触发播放结束事件(上面这个循环条件,暂停时的状态不是Stopped,所以这里没问题)
RaisePlaybackStoppedEvent();
}
// 缓冲区的OnDone实现 - 在缓冲区类里
public bool OnDone()
{
// 调用音频流的Read方法,把数据读入缓冲区 - 上面提到过,是ISampleProvider/IWaveProvider对象的Read方法
waveStream.Read(buffer, 0, buffer.Length);
// 把数据写入音频设备
// - 这里通过上面缓冲区构造函数类可知:buffer的指针已绑定到header上
// - 所以这里就是把buffer写入播放设备
waveOutWrite(hWaveOut, header, Marshal.SizeOf(header))
}
这里的waveStream就是前面初始化的MediaFoundationReader对象,每次调用waveStream.Read,就会从原文件读取数据转为pcm,存入缓冲区(双缓冲中的某一个)
结束播放
可以看出,播放过程中初始化了一些东西,但是如果一首歌播放结束了,这些东西就应该销毁,销毁代码如下:
csharp
protected void Dispose(bool disposing)
{
// 重置设备
waveOutReset(hWaveOut);
// 销毁缓冲区
foreach (var buffer in buffers)
{
buffer.Dispose();
}
// 关闭设备句柄
// - 这里重置和关闭,都是对系统分配的句柄本身的操作,是系统分配给当前进程的一个句柄,
// - 并不影响其他程序,因为其他程序也会获得独立的音频设备句柄
waveOutClose(hWaveOut);
}
// 缓冲区对象Dispose实现
protected void Dispose(bool disposing)
{
// 释放缓冲区
waveOutUnprepareHeader(hWaveOut, header, Marshal.SizeOf(header));
}
再加上前面的读音频文件,这里补全下完整一首歌播放的流程:
plain
┌─────────────────────────┐
│ MFCreateSourceReaderFromURL(file, null, out pReader) │
│ ← 打开音频文件,创建 Media Foundation 源读取器 │
└───────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────┐
│ pReader.ReadSample(...) │ ← 从文件读取并解码音频样本 (IMFSample)
└───────────────┬─────────┘
│
▼
┌─────────────────────────┐
│ pSample.ConvertToContiguousBuffer(out pBuffer) │
│ ← 将样本数据合并为连续内存块 (IMFMediaBuffer) │
└───────────────┬─────────┘
│
▼
┌─────────────────────────┐
│ 转为浮点采样格式(如 IEEE float 32-bit) │
│ ← 便于后续波形混音 / 音量控制 / 可视化 │
└───────────────┬─────────┘
│
▼
┌─────────────────────────┐
│ waveOutOpen │ ← 打开音频设备 (获得 hWaveOut)
└───────────────┬─────────┘
│
▼
┌─────────────────────────┐
│ 填写 WaveHeader 结构 │ ← 指定缓冲区地址、长度等
│ (lpData, dwBufferLength) │
└───────────────┬─────────┘
│
▼
┌─────────────────────────┐
│ waveOutPrepareHeader │ ← 准备缓冲区,登记到系统,绑定设备句柄
└───────────────┬─────────┘
│
▼
┌─────────────────────────┐
│ waveOutWrite │ ← 发送缓冲区到声卡播放
└───────────────┬─────────┘
│
▼
(双缓冲轮流播放中...)
│
▼
┌─────────────────────────┐
│ 播放完成回调
└───────────────┬─────────┘
│
▼
┌─────────────────────────┐
│ waveOutReset │ ← 强制停止播放、清空缓冲队列
│ │ 所有未播放完的缓冲触发 WOM_DONE
└───────────────┬─────────┘
│
▼
┌─────────────────────────┐
│ waveOutUnprepareHeader │ ← 解除缓冲区准备状态,释放资源
└───────────────┬─────────┘
│
▼
(可重复使用缓冲区再次播放或退出)
│
▼
┌─────────────────────────┐
│ waveOutClose │ ← 关闭句柄
└─────────────────────────┘
音频效果处理原理
NAudio实现各种音频效果,本质是在Read方法中处理音频数据。
这里举三个例子说明下,相信看完例子后,大家会完全懂得NAudio处理音频效果的逻辑
音量控制
上面有提到:在SampleChannel构造函数里有行代码:volumeProvider = new VolumeSampleProvider(sampleProvider); ,这里看下他是咋提供音量控制能力的,核心代码如下:
csharp
public int Read(float[] buffer, int offset, int sampleCount)
{
// 先从源读取数据
int samplesRead = source.Read(buffer, offset, sampleCount);
// 如果音量不是100%,对每个样本应用音量
if (Volume != 1f)
{
for (int n = 0; n < sampleCount; n++)
{
// 每个样本乘以音量系数
buffer[offset + n] *= Volume;
}
}
return samplesRead;
}
其实很简单,就是给每个样本乘以音量数值,相当于控制了波形曲线的振幅
单声道转双声道
这里先介绍下双声道数据的结构:
| buffer 索引 | 含义 | 帧索引 |
|---|---|---|
| 0 | 左声道采样 1 | 1 |
| 1 | 右声道采样 1 | |
| 2 | 左声道采样 2 | 2 |
| 3 | 右声道采样 2 | |
| ... | ... |
也就是说双声道一帧2个样本,而单声道是一帧一个样本,那最终设备咋区分单双声道呢?前面声明ISampleProvider、IWaveProvider接口的时候,里面有个WaveFormat属性,在调用waveOutOpen打开设备句柄时,需要传入WaveFormat,WaveFormat里有个属性会表示声道数
现在我们看下MonoToStereoSampleProvider(单转双)的Read方法:
csharp
public int Read(float[] buffer, int offset, int count)
{
// 双声道需要count个样本,但单声道只需要count/2个
var sourceSamplesRequired = count / 2;
var sourceSamplesRead = source.Read(sourceBuffer, 0, sourceSamplesRequired);
for (var n = 0; n < sourceSamplesRead; n++)
{
// 每个单声道样本复制到左右两个声道
buffer[outIndex++] = sourceBuffer[n] * LeftVolume; // 左声道
buffer[outIndex++] = sourceBuffer[n] * RightVolume; // 右声道
}
return sourceSamplesRead * 2;
}
分析下逻辑:
- 主程序需要count个样本,用单声道数据填充双声道音频,只需要count/2个单声道数据即可
- 从原本的流读取count/2个样本
- 遍历这些样本,每个值*左声道音量作为左声道值,*右声道音量作为右声道值
单双声道读取时同样读取count个样本,双声道播放时,播放设备根据WaveFormat的描述,知道是双声道,就会一半做左声道播放,一半做右声道播放,而单声道的就只作为一个声道数据播放
淡入效果
csharp
public int Read(float[] buffer, int offset, int count)
{
int sourceSamplesRead = source.Read(buffer, offset, count);
lock (lockObject)
{
FadeIn(buffer, offset, sourceSamplesRead);
}
return sourceSamplesRead;
}
private void FadeIn(float[] buffer, int offset, int sourceSamplesRead)
{
while (sample < sourceSamplesRead)
{
// 计算当前淡入的系数(0到1之间)
float multiplier = (fadeSamplePosition / (float)fadeSampleCount);
for (int ch = 0; ch < source.WaveFormat.Channels; ch++)
{
// 每个样本乘以淡入系数
buffer[offset + sample++] *= multiplier;
}
fadeSamplePosition++;
}
}
这里分析下淡入效果的逻辑:
- 从源音频流读入数据
- 遍历读到的样本,对每个声道乘一个系数,这个系数 = 当前位置 / 需要处理淡入效果的样本的总数
- 这个总数的计算方式是:
fadeSampleCount = 淡入效果时长 * 采样率
同理,淡出或者静音效果大家应该能想到怎么实现
所以本质上,NAudio可以通过处理音频数据从而得到不同的效果
NAudio用到的其他音频技术
Windows上主流的三种音频播放技术:
plain
+------------------+ +----------------------+ +------------------------+
| WinMM | | DirectSound | | WASAPI |
| (Windows Multimedia) | (DirectX 音频组件) | (Windows Core Audio) |
| 最古老,简单,延迟高 | 曾用于游戏,已废弃 | 现代 Windows 主流音频 API |
| | 已被替代 | 支持低延迟/独占模式 |
+------------------+ +----------------------+ +------------------------+
| | |
| | |
+-----------------------+-----------------------------+
|
Windows 音频子系统(Core Audio)
上面的WaveOutEvent就是WinMM,NAudio里分别封装了这三种技术。NAudio比较好的一点是:这三种技术封装后,API结构是完全一样的,所以如果只是使用,其实就只有一套API。
比如用WASAPI播放音频,代码如下:
csharp
var audioFile = new AudioFileReader("test.mp3");
// 跟WinMM调用的唯一区别就是这行,类名从WaveOutEvent改成WasapiOut
var outputDevice = new WasapiOut();
outputDevice.Init(audioFile);
outputDevice.Play();
我们优先使用WinMM技术,然后使用WASAPI兜底。主要考虑门店大部分是Win7,目前来说比较古老了,所以就用WinMM。WinMM虽然古老,但Windows新系统上也一直存在这个技术,并没有被放弃。
总结
本文介绍了古茗播放器的核心技术点及其实现原理。所用到的NAudio实际是对WinMM、WASAPI、DirectSound等Windows技术的封装,本文主要介绍了用WinMM技术播放音频的流程。
核心思想是:音频播放 = 文件读取 + 数据转换 + 缓冲管理 + 设备输出
- 文件读取:使用MediaFoundation读取音频文件,获得整型PCM数据
- 数据转换:将整型PCM转换为浮点PCM,便于后续处理
- 缓冲管理:使用双缓冲机制保证播放连续性
- 设备输出:通过Windows音频API发送到声卡播放
理解了这些基本原理,你就可以方便地扩展更多音频功能,比如音频合成、混音、音效处理等。关键是要记住:音频处理本质上就是数据的读取、处理和输出。