NAduio 自制音乐播放器以及简陋的可视化音频

NAduio 自制音乐播放器

因为网上关于 NAudio 的教程真没多少,源代码的注解也不够,所以就自己研究了

NAudio:https://github.com/naudio/NAudio

使用 WPF MVVM
先用 NuGet 安装几个库:

  • NAudio:音频库
  • Prism.Core:MVVM 需要的库
  • System.Windows.Interactivity.WPF:用于 MVVM 绑定事件的库

代码

先把界面仍出来,不懂 MVVM 和 Interactivity 可以自行查找资料,重点不在这说明

<Window x:Class="Music_Program.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Music_Program"
        mc:Ignorable="d"
        xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity"
        Title="MainWindow" Height="450" Width="800">
    <Grid Background="DarkGray">
        <Grid.RowDefinitions>
            <RowDefinition Height="30"></RowDefinition>
            <RowDefinition Height="200"></RowDefinition>
            <RowDefinition Height="Auto"></RowDefinition>
            <RowDefinition></RowDefinition>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
        </Grid.ColumnDefinitions>
        <Button Grid.Row="0" Grid.Column="0" Command="{Binding OpenMusicFileCommand}">打开文件</Button>
        <Button Grid.Row="0" Grid.Column="1" Command="{Binding PlayMusicCommand}">播放</Button>
        <Button Grid.Row="0" Grid.Column="2" Command="{Binding PauseMusicCommand}">暂停</Button>
        <Button Grid.Row="0" Grid.Column="3" Command="{Binding StopMusicCommand}">停止</Button>
        <Slider Grid.Row="1" Grid.Column="0" Orientation="Vertical" HorizontalAlignment="Center" Minimum="0" Maximum="100" Value="{Binding Volume}">
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="ValueChanged">
                    <!--<i:InvokeCommandAction Command="{Binding RelativeSource={RelativeSource AncestorType=Window},Path=DataContext.AdjustVolumeCommand}" 
                                           CommandParameter="{Binding RelativeSource={RelativeSource Mode=FindAncestor,AncestorLevel=1,AncestorType={x:Type Slider}}}">
                    </i:InvokeCommandAction>-->
                    <i:InvokeCommandAction Command="{Binding AdjustVolumeCommand}" >
                    </i:InvokeCommandAction>
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </Slider>
        <Label Grid.Row="2" Grid.Column="0" HorizontalAlignment="Center">音量</Label>
        <TextBox Grid.Row="3" Grid.Column="0" TextWrapping="Wrap" Text="{Binding FilePath}"></TextBox>
        <Slider Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Grid.ColumnSpan="3" VerticalAlignment="Center" Minimum="0" Maximum="100" Value="{Binding Progress}">
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="PreviewMouseLeftButtonUp">
                    <i:InvokeCommandAction Command="{Binding AdjustProgressBarCommand}">
                    </i:InvokeCommandAction>
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </Slider>
        <Label Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="3" HorizontalAlignment="Center">进度</Label>
    </Grid>
</Window>

然后是 ViewModel

public class MusicViewModel : BindableBase
{
    //输出设备
    private WaveOutEvent _outputDevice;

    public WaveOutEvent OutputDevice
    {
        get { return _outputDevice; }
        set
        {
            _outputDevice = value;
            this.RaisePropertyChanged(nameof(OutputDevice));
        }
    }

    //要播放的音频文件
    private AudioFileReader _audioFile;

    public AudioFileReader AudioFile
    {
        get { return _audioFile; }
        set
        {
            _audioFile = value;
            this.RaisePropertyChanged(nameof(AudioFile));
        }
    }


    //音量
    private int _volume;

    public int Volume
    {
        get { return _volume; }
        set
        {
            _volume = value;
            this.RaisePropertyChanged(nameof(Volume));
        }
    }

    //音频进度
    private int _progress;

    public int Progress
    {
        get { return _progress; }
        set
        {
            _progress = value;
            this.RaisePropertyChanged(nameof(Progress));
        }
    }


    //文件路径
    private string _filePath;

    public string FilePath
    {
        get { return _filePath; }
        set
        {
            _filePath = value;
            this.RaisePropertyChanged(nameof(FilePath));
        }
    }

    //打开音乐文件
    public DelegateCommand OpenMusicFileCommand { get; set; }
    //播放音乐
    public DelegateCommand PlayMusicCommand { get; set; }
    //暂停音乐
    public DelegateCommand PauseMusicCommand { get; set; }
    //停止音乐
    public DelegateCommand StopMusicCommand { get; set; }
    //调节音量
    public DelegateCommand AdjustVolumeCommand { get; set; }
    //调节进度条
    public DelegateCommand AdjustProgressBarCommand { get; set; }

    public MusicViewModel()
    {
        this.Volume = 0;
        this.Progress = 0;
        this.FilePath = "";

        this.OpenMusicFileCommand = new DelegateCommand(this.OpenMusicFileCommandExecute);
        this.PlayMusicCommand = new DelegateCommand(this.PlayMusicCommandExecute);
        this.PauseMusicCommand = new DelegateCommand(this.PauseMusicCommandExecute);
        this.StopMusicCommand = new DelegateCommand(this.StopMusicCommandExecute);
        this.AdjustVolumeCommand = new DelegateCommand(this.AdjustVolumeCommandExecute);
        this.AdjustProgressBarCommand = new DelegateCommand(this.AdjustProgressBarCommandExecute);

        //使用 Timer 去设置进度条
        Timer timer = new Timer();
        timer.Interval = 1000;
        timer.Tick += new EventHandler((sender, args) =>
        {
            if (null != this.OutputDevice && null != this.AudioFile)
            {
                float position = this.AudioFile.Position;
                this.Progress = (int)((position / this.AudioFile.Length) * 100);
            }
        });
        timer.Start();
    }

    private void OpenMusicFileCommandExecute()
    {
        OpenFileDialog openFileDialog = new OpenFileDialog();
        openFileDialog.InitialDirectory = @"c:\";
        openFileDialog.Filter = "所有文件|*.*";
        openFileDialog.RestoreDirectory = false;

        if (openFileDialog.ShowDialog() == DialogResult.OK)
        {
            this.FilePath = Path.GetFullPath(openFileDialog.FileName);
        }
    }

    private void PlayMusicCommandExecute()
    {
        if (null == this.OutputDevice)
        {
            this.OutputDevice = new WaveOutEvent();
            //播放结束事件,就是些清理工作
            this.OutputDevice.PlaybackStopped += ((sender, args) =>
            {
                this.OutputDevice.Dispose();
                this.OutputDevice = null;
                this.AudioFile.Dispose();
                this.AudioFile = null;
                this.Progress = 0;
            });
        }
        if (null == this.AudioFile)
        {
            this.AudioFile = new AudioFileReader(this.FilePath);
            //将音频文件绑定至输出设备
            this.OutputDevice.Init(this.AudioFile);
        }
        //开始播放
        this.OutputDevice.Play();
        //初始化音量,WaveOutEvent 的 Volume 为 0 到 1
        this.Volume = (int)(this.OutputDevice.Volume * 100);
    }

    private void PauseMusicCommandExecute()
    {
        this.OutputDevice?.Pause();
    }

    private void StopMusicCommandExecute()
    {
        //Stop()函数会触发PlaybackStopped事件
        this.OutputDevice?.Stop();
    }

    private void AdjustVolumeCommandExecute()
    {
        if (null != this.OutputDevice && null != this.AudioFile)
        {
            this.OutputDevice.Volume = this.Volume / 100F;
        }
    }

    private void AdjustProgressBarCommandExecute()
    {
        if (null != this.OutputDevice && null != this.AudioFile)
        {
            this.AudioFile.Position = (long)((this.Progress / 100F) * this.AudioFile.Length);
        }
    }

}

最后绑定 ViewModel

public MainWindow()
{
    InitializeComponent();

    this.DataContext = new MusicViewModel();
}

讲解

因为上面的代码比较简单,我就只讲一下 NAudio 里的东西了
需要播放音乐,最基本的就是以下两个类:

  • WaveOutEvent:用于播放、暂停、停止音乐
  • AudioFileReader:读取音乐文件

WaveOutEvent

常用属性:

  • public float Volume:音量,介于 0 到 1 之间
  • public WaveFormat OutputWaveFormat:包含各种音频数据,本案例用不上
  • publicPlaybackState PlaybackState:播放状态

常用方法:

  • public void Init(IWaveProvider waveProvider):初始化 WaveOut 设备
  • public void Play():开始播放来自 WaveStream 的音频
  • public void Pause():暂停音频
  • public void Stop():停止并重置 WaveOut 设备

AudioFileReader

常用属性:

  • public string FileName:音频文件名称,可以算是路径
  • public override long Length:音频流的长度
  • public override long Position:当前播放位于的音频流的位置

效果图


很简陋,但是能用

NAidio 实现可视化音频

这里开始就很难了,网上的相关资料更是少
毕竟这玩意儿涉及到了数学知识,这可就给爷整傻了,所以我也花了挺多时间的
写几个可以参考的文章

NAudio可视化音频参考:https://www.cnblogs.com/slimenull/p/14749373.html

音频以及傅里叶变换的基础知识:https://zhuanlan.zhihu.com/p/19763358

首先,我们来新建一个用户控件,因为 WPF 的 ProgressBar 可以纵向,所以我就直接用了

<UserControl x:Class="Music_Program.Views.SpectrumView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:Music_Program.Views"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
        </Grid.ColumnDefinitions>
        <ProgressBar Grid.Column="0" Minimum="0.00" Maximum="100.00" Value="{Binding Value[0]}" Orientation="Vertical"></ProgressBar>
        <ProgressBar Grid.Column="1" Minimum="0.00" Maximum="100.00" Value="{Binding Value[1]}" Orientation="Vertical"></ProgressBar>
        <ProgressBar Grid.Column="2" Minimum="0.00" Maximum="100.00" Value="{Binding Value[2]}" Orientation="Vertical"></ProgressBar>
        <ProgressBar Grid.Column="3" Minimum="0.00" Maximum="100.00" Value="{Binding Value[3]}" Orientation="Vertical"></ProgressBar>
        <ProgressBar Grid.Column="4" Minimum="0.00" Maximum="100.00" Value="{Binding Value[4]}" Orientation="Vertical"></ProgressBar>
        <ProgressBar Grid.Column="5" Minimum="0.00" Maximum="100.00" Value="{Binding Value[5]}" Orientation="Vertical"></ProgressBar>
        <ProgressBar Grid.Column="6" Minimum="0.00" Maximum="100.00" Value="{Binding Value[6]}" Orientation="Vertical"></ProgressBar>
        <ProgressBar Grid.Column="7" Minimum="0.00" Maximum="100.00" Value="{Binding Value[7]}" Orientation="Vertical"></ProgressBar>
        <ProgressBar Grid.Column="8" Minimum="0.00" Maximum="100.00" Value="{Binding Value[8]}" Orientation="Vertical"></ProgressBar>
        <ProgressBar Grid.Column="9" Minimum="0.00" Maximum="100.00" Value="{Binding Value[9]}" Orientation="Vertical"></ProgressBar>
    </Grid>
</UserControl>
/// <summary>
/// SpectrumView.xaml 的交互逻辑
/// </summary>
public partial class SpectrumView : UserControl
{
    public SpectrumViewModel SpectrumViewModel{ get; set; }

    public SpectrumView()
    {
        InitializeComponent();

        this.SpectrumViewModel = new SpectrumViewModel();
        this.DataContext = SpectrumViewModel;
    }

    public void Init(ref WaveOutEvent outputDevice, ref AudioFileReader audioFile)
    {
        this.SpectrumViewModel.InitializeAudioInfo(ref outputDevice, ref audioFile);
    }

}

然后稍微修改一下播放器的界面
使用方法是先打开音频文件,点击播放,再点击初始化,用户控件就开始工作了

<Window x:Class="Music_Program.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Music_Program"
        mc:Ignorable="d"
        xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity"
        xmlns:views="clr-namespace:Music_Program.Views"
        Title="MainWindow" Height="450" Width="800">
    <Grid Background="DarkGray">
        <Grid.RowDefinitions>
            <RowDefinition Height="30"></RowDefinition>
            <RowDefinition Height="200"></RowDefinition>
            <RowDefinition Height="Auto"></RowDefinition>
            <RowDefinition></RowDefinition>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
        </Grid.ColumnDefinitions>
        <Button Grid.Row="0" Grid.Column="0" Command="{Binding OpenMusicFileCommand}">打开文件</Button>
        <Button Grid.Row="0" Grid.Column="1" Width="100" HorizontalAlignment="Left" Command="{Binding PlayMusicCommand}">播放</Button>
        <Button Grid.Row="0" Grid.Column="1" Width="100" HorizontalAlignment="Right"  Click="Button_Click">初始化</Button>
        <Button Grid.Row="0" Grid.Column="2" Command="{Binding PauseMusicCommand}">暂停</Button>
        <Button Grid.Row="0" Grid.Column="3" Command="{Binding StopMusicCommand}">停止</Button>
        <Slider Grid.Row="1" Grid.Column="0" Orientation="Vertical" HorizontalAlignment="Center" Minimum="0" Maximum="100" Value="{Binding Volume}">
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="ValueChanged">
                    <!--<i:InvokeCommandAction Command="{Binding RelativeSource={RelativeSource AncestorType=Window},Path=DataContext.AdjustVolumeCommand}" 
                                           CommandParameter="{Binding RelativeSource={RelativeSource Mode=FindAncestor,AncestorLevel=1,AncestorType={x:Type Slider}}}">
                    </i:InvokeCommandAction>-->
                    <i:InvokeCommandAction Command="{Binding AdjustVolumeCommand}" >
                    </i:InvokeCommandAction>
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </Slider>
        <Label Grid.Row="2" Grid.Column="0" HorizontalAlignment="Center">音量</Label>
        <TextBox Grid.Row="3" Grid.Column="0" TextWrapping="Wrap" Text="{Binding FilePath}"></TextBox>
        <Slider Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Grid.ColumnSpan="3" VerticalAlignment="Center" Minimum="0" Maximum="100" Value="{Binding Progress}">
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="PreviewMouseLeftButtonUp">
                    <i:InvokeCommandAction Command="{Binding AdjustProgressBarCommand}">
                    </i:InvokeCommandAction>
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </Slider>
        <Label Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="3" HorizontalAlignment="Center">进度</Label>
        <views:SpectrumView x:Name="SpectrumView" Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="3">

        </views:SpectrumView>
    </Grid>
</Window>
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
    public MusicViewModel MusicViewModel { get; set; }
    public MainWindow()
    {
        InitializeComponent();

        this.MusicViewModel = new MusicViewModel();
        this.DataContext = this.MusicViewModel;
    }

    private void Button_Click(object sender, RoutedEventArgs e)
    {
        WaveOutEvent outputDevice = this.MusicViewModel.OutputDevice;
        AudioFileReader audioFile = this.MusicViewModel.AudioFile;
        this.SpectrumView.Init(ref outputDevice, ref audioFile);
    }
}

ViewModel

public class SpectrumViewModel : BindableBase
{
    //输出设备
    public WaveOutEvent OutputDevice { get; set; }
    //音频文件
    public AudioFileReader AudioFile { get; set; }

    public SpectrumViewModel()
    {
        this.Value = new double[10];
        this.SampleArray = new float[1024];

        Timer timer = new Timer();
        timer.Interval = 25;
        timer.Tick += new EventHandler((sender, args) =>
        {
            if (null != this.OutputDevice && null != this.AudioFile && PlaybackState.Playing == this.OutputDevice.PlaybackState)
            {
                this.GetSampleArray();
                this.Foo();
            }
        });
        timer.Start();
    }

    #region 音频文件的信息

    /// <summary>
    /// 每个单声道数据样本的位数,例如 16位,24位,32位
    /// </summary>
    public int BitsPerSample { get; set; }

    /// <summary>
    /// 采样率,例如 44.1Khz ,就是 44100
    /// </summary>
    public int SampleRate { get; set; }

    /// <summary>
    /// 通道数,例如 2
    /// </summary>
    public int ChannelCount { get; set; }

    /// <summary>
    /// 初始化输出设备和音频文件
    /// </summary>
    /// <param name="outputDevice">输出设备</param>
    /// <param name="audioFile">音频文件</param>
    public void InitializeAudioInfo(ref WaveOutEvent outputDevice, ref AudioFileReader audioFile)
    {
        this.OutputDevice = outputDevice;
        this.AudioFile = audioFile;

        //因为我们读取音频文件,所以信息数据以音频信息为准
        this.BitsPerSample = this.AudioFile.WaveFormat.BitsPerSample;
        this.SampleRate = this.AudioFile.WaveFormat.SampleRate;
        this.ChannelCount = this.AudioFile.WaveFormat.Channels;
    }

    #endregion


    #region 获取音频采样信息

    /// <summary>
    /// 音频采样数据
    /// </summary>
    public float[] SampleArray { get; set; }

    /// <summary>
    /// 从音频文件获取采样数据
    /// </summary>
    private async void GetSampleArray()
    {
        //这个 1024 应该要根据音频文件动态设置(貌似一秒钟的音频需要的数组大小就是采样率,比如48000、96000等等),这里为了方便,所以写死
        await Task.Run(() => { this.AudioFile.Read(this.SampleArray, 0, 1024); });
        //这里的处理不太好,因为这个 Read() 貌似会改变 Position 的位置,导致输出的声音出现卡顿
        //而且会抛异常,所以我扔到另外的线程去了
        //你可以尝试把读取音频文件换成录制音频输出,这样应该就不会有卡顿了
    }


    #endregion

    #region 获取频域数据
    /// <summary>
    /// 采样数据的对象锁,防止未分离左右通道就进入下一次采样
    /// </summary>
    private object _sampleLock = new object();

    /// <summary>
    /// 处理数据,不知道叫啥名,皆可Foo
    /// </summary>
    public async void Foo()
    {
        await Task.Run(() =>
        {
            #region 分离左右通道

            //假设 SampleArray 中已经有数据
            float[][] chanelSampleArray;
            lock (this._sampleLock)//防止未分离完左右通道就进入下一次调用 SampleArray
            {
                chanelSampleArray = Enumerable
                    .Range(0, ChannelCount)//分离通道
                    .Select(chanel => Enumerable//对每个通过的数据进行处理
                        .Range(0, this.SampleArray.Length / this.ChannelCount)//每个通道的数组长度
                        .Select(i => this.SampleArray[chanel + i * this.ChannelCount])//左右左右,这样读取
                        .ToArray())
                    .ToArray();
            }

            #endregion

            #region 合并左右通道并取平均值

            float[] chanelAverageSample = Enumerable
                .Range(0, chanelSampleArray[0].Length)
                .Select(index => Enumerable//每次读取一个左右数据合并、取平均值
                    .Range(0, this.ChannelCount)
                    .Select(chanel => chanelSampleArray[chanel][index])
                    .Average())
                .ToArray();

            #endregion

            #region 傅里叶变换
            //NAudio 提供了快速傅里叶变换的方法, 通过傅里叶变换, 可以将时域数据转换为频域数据
            // 取对数并向上取整
            int log = (int)Math.Ceiling(Math.Log(chanelAverageSample.Length, 2));
            //对于快速傅里叶变换算法, 需要数据长度为 2 的 n 次方
            int length = (int)Math.Pow(2, log);
            float[] filledSample = new float[length];
            //拷贝到新数组
            Array.Copy(chanelAverageSample, filledSample, chanelAverageSample.Length);
            //将采样转化为复数
            Complex[] complexArray = filledSample
                .Select((value, index) => new Complex() { X = value })
                .ToArray();
            //进行傅里叶变换
            FastFourierTransform.FFT(false, log, complexArray);

            #endregion

            #region 提取需要的频域信息

            Complex[] halfComeplexArray = complexArray
                .Take(complexArray.Length / 2)//数据是左右对称的,所以只取一半
                .ToArray();

            //这个已经是频域数据了
            double[] resultArray = complexArray
                .Select(value => Math.Sqrt(value.X * value.X + value.Y * value.Y))//复数取模
                .ToArray();

            //我们取 最小频率 ~ 20000Hz
            //对于变换结果, 每两个数据之间所差的频率计算公式为 采样率/采样数, 那么我们要取的个数也可以由 20000 / (采样率 / 采样数) 来得出
            //当然,因为我这里并没有指定频率与幅值,所以顺便取几个数就行,若有需要可以再去细分各个频率的幅值
            int count = 20000 / (this.SampleRate / length);
            double[] finalData = resultArray.Take(count).ToArray();

            #endregion

            #region 设置绑定数据

            this.Value = finalData.Take(10).ToArray();
            this.RaisePropertyChanged(nameof(this.Value));

            #endregion
        });
    }

    #endregion

    /// <summary>
    /// 频域数据
    /// </summary>
    public double[] Value { get; set; }
}

具体的注解我也写的比较清楚了,傅里叶变换那里涉及到数学知识我就不懂了,所以我也是照抄的

效果

后续补充

我闲着没事又查了查资料,大概理解了频域是怎么从时域里算出来的。

https://www.bilibili.com/video/BV1pW411J7s8
上面这个视频讲得挺详细的。数学,令人敬畏。

NAduio 自制音乐播放器以及简陋的可视化音频 结束

不得不说,Unity 和 虚幻引擎的音频可视化插件是真的好用,foobar 也很厉害,反正我写不出来 😦

posted @ 2021-07-25 17:28  .NET好耶  阅读(1074)  评论(0编辑  收藏  举报