用 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发送到声卡播放

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

相关推荐
passerby60612 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了3 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅3 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅3 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅3 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment3 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅4 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊4 小时前
jwt介绍
前端
爱敲代码的小鱼4 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
Cobyte4 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc