用 NAudio 做一个音频播放器及原理

作者:周杰

我们每天都在用音乐播放器,但你是否想过自己动手实现一个?今天介绍下古茗自研的一个播放器的实现,以及音频播放的基本原理。

为什么选择 NAudio

首先说下为啥用NAudio,我们端上当前技术栈C#用的比较多,而且门店都是Windows收银机。于是调研了一些C#库或技术,最终选了NAudio:

技术选型对比

技术/框架 优点 缺点
NAudio 开源,文档较详细,使用简单,除了播放外,还能对音频做很多处理,比如音频合成、拼接、转换,有自定义需求扩展也方便 star:6k 作者已停止维护
LibVLCSharp 能实现常规播放需求 当时测的时候发现分片音频拼接播放不太友好,分片间的时间间隔控制不好;文档复杂
WindowsMediaPlayer Windows原生 API过于简单,控制能力不够强
SDL2 + FFmpeg 跨端,使用广泛且成熟 封装性较强,C#只能通过DLL方式引入,使用复杂度高。文档不太友好

总结:NAudio对C#生态最友好,功能完整,文档完善,是我们的最佳选择。

快速开始

引入NAudio

新建项目后,引入NAudio有两种方式:

  1. 通过NuGet包管理器 :直接搜索"NAudio"安装最新包即可
  2. 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类来读取文件,这个类初始化后主要做了三件事:

  1. 生成一个整型PCM流
  2. 对流数据做处理,转成浮点数据
  3. 实现流读取函数
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,总体流程是:

  1. MFCreateSourceReaderFromURL → 创建读对象pReader
  2. pReader.ReadSample → 使用读对象读sample数据(整型PCM)
  3. pSample.ConvertToContiguousBuffer → 转为C#对象

浮点转换过程

SampleChannel类主要做两件事:

  1. 判断传入的readerStream类型和采样位数,统一转成ISampleProvider对象
csharp 复制代码
if (waveProvider.WaveFormat.Encoding == WaveFormatEncoding.Pcm)
{
    if (waveProvider.WaveFormat.BitsPerSample == 16)
    {
        sampleProvider = new Pcm16BitToSampleProvider(waveProvider);  // 16位整型转浮点
    }
    // ...其他位数处理
}
  1. 添加音量调节能力的支持
csharp 复制代码
volumeProvider = new VolumeSampleProvider(sampleProvider);
  1. 实现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方法的逻辑是:

  1. 需要count个样本,从buffer的offset位置开始写入buffer
  2. 16位pcm,每个样本占2个字节,所以实际读取的字节长度是:count * 2
  3. IWaveProvider source读取count * 2个字节的数据
  4. 同理,因为每个样本2个字节,所以每2个字节除32768f得到浮点数据
  5. 除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);
    }
}

这里主要做两件事:

  1. 调用waveOutOpen打开设备句柄
  2. 初始化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个样本,而单声道是一帧一个样本,那最终设备咋区分单双声道呢?前面声明ISampleProviderIWaveProvider接口的时候,里面有个WaveFormat属性,在调用waveOutOpen打开设备句柄时,需要传入WaveFormatWaveFormat里有个属性会表示声道数

现在我们看下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;
}

分析下逻辑:

  1. 主程序需要count个样本,用单声道数据填充双声道音频,只需要count/2个单声道数据即可
  2. 从原本的流读取count/2个样本
  3. 遍历这些样本,每个值*左声道音量作为左声道值,*右声道音量作为右声道值

单双声道读取时同样读取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++;
    }
}

这里分析下淡入效果的逻辑:

  1. 从源音频流读入数据
  2. 遍历读到的样本,对每个声道乘一个系数,这个系数 = 当前位置 / 需要处理淡入效果的样本的总数
  3. 这个总数的计算方式是: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发送到声卡播放

理解了这些基本原理,你就可以方便地扩展更多音频功能,比如音频合成、混音、音效处理等。关键是要记住:音频处理本质上就是数据的读取、处理和输出

相关推荐
wei yun liang2 小时前
4.数据类型
前端·javascript·css3
奥升新能源平台2 小时前
奥升充电平台OCPP协议解析
前端
JinSo6 小时前
我的2025年度总结:EasyEditor
前端·程序员
喝拿铁写前端10 小时前
前端开发者使用 AI 的能力层级——从表面使用到工程化能力的真正分水岭
前端·人工智能·程序员
wuhen_n10 小时前
LeetCode -- 15. 三数之和(中等)
前端·javascript·算法·leetcode
七月shi人11 小时前
AI浪潮下,前端路在何方
前端·人工智能·ai编程
非凡ghost11 小时前
MusicPlayer2(本地音乐播放器)
前端·windows·学习·软件需求
脾气有点小暴11 小时前
scroll-view分页加载
前端·javascript·uni-app