C#
音楽
音声処理
NAudio

C#でSoundCloudライクな波形を表示する音楽プレーヤを作ってみる

こんなものを作ります

sc.png

ドラッグでシークできるようになっています。
今回は凝ったことはしないのでWinFormsを使います。

C#で音声ファイルを扱う

自分でゴリゴリ書くこともできますが、簡単に扱えるように設計された素晴らしいライブラリが存在するのでそちらを使います。

NAudio
NAudio.WaveFormRenderer

準備

NAudioのインストール

パッケージマネージャーコンソールで

Install-Package NAudio -Version 1.8.4

と入力するか管理画面から検索してインストールします。

NAudio.WaveFormRendererのインストール

波形処理部分が分離されているので別途インストールします。こちらはNuGetが準備されていないので、自分のパソコンに落としてWaveFormRendererLibフォルダの中身をプロジェクトに追加します。

UI部分

sc2.png

・ playButton という名のPictureBox
・ pictureBox1 黒い波形を表示する
・ pictureBox2 オレンジの波形を表示する。pictureBox1と同じサイズにして、上に重ねておく
・label1 タイトル
・label2 再生位置の表示
・label3 総再生時間の表示
・openToolStripMenuItem 音声ファイルを開くのに使用
・Timer1 pictureBox2の幅を再生位置に応じて変更

黒とオレンジの波形を2つ準備します。そして再生位置に合わせてオレンジの波形を重ねていきます。
なお幅を変更しても画像がスケーリングされないようにpictureBox1,2のBackgrounImageLayoutはNoneにしておいてください。

コード

オブジェクトの破棄や例外処理は実装していません。

Form1.cs
public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();

            //イベントの設定 共通にしたいので手動で
            pictureBox1.MouseDown += PictureBox_MouseDown;
            pictureBox2.MouseDown += PictureBox_MouseDown;
            pictureBox1.MouseMove += PictureBox_MouseMove;
            pictureBox2.MouseMove += PictureBox_MouseMove;
            pictureBox1.MouseUp += PictureBox_MouseUp;
            pictureBox2.MouseUp += PictureBox_MouseUp;
        }

        WaveOutEvent outputDevice;
        AudioFileReader audioFile;
        string filePath;//音声ファイルのパス
        int bytePerSec;//一秒あたりのバイト数
        int length;//曲の長さ(秒)
        int position;//再生位置(秒)
        bool mouseDownFlag;//ドラッグ時に使うフラグ MouseDown中にtrue

        private void openToolStripMenuItem_Click(object sender, EventArgs e)
        {
            if (openFileDialog1.ShowDialog() == DialogResult.OK)
            {
                filePath = openFileDialog1.FileName;

                //黒い波形の設定
                var soundCloudDarkBlocks = new SoundCloudBlockWaveFormSettings(Color.FromArgb(52, 52, 52), Color.FromArgb(55, 55, 55), Color.FromArgb(154, 154, 154),
                    Color.FromArgb(204, 204, 204));//バーの色の設定

                soundCloudDarkBlocks.Width = pictureBox1.Width;//生成する画像の幅
                soundCloudDarkBlocks.TopHeight = pictureBox1.Height / 4 * 3;//上に伸びるバーの高さ
                soundCloudDarkBlocks.BottomHeight = pictureBox1.Height / 4;//下に伸びるバーの長さ
                soundCloudDarkBlocks.BackgroundColor = Color.Transparent;//生成される画像の背景色 今回は透明
                soundCloudDarkBlocks.PixelsPerPeak = 2;//バーの幅
                //soundCloudDarkBlocks.SpacerPixels = 1; バーの間に挟まる細いバーの幅


                //オレンジの波形の設定
                var soundCloudOrangeBlocks = new SoundCloudBlockWaveFormSettings(Color.FromArgb(255, 76, 0), Color.FromArgb(255, 52, 2), Color.FromArgb(255, 171, 141),
                     Color.FromArgb(255, 213, 199));

                soundCloudOrangeBlocks.Width = pictureBox1.Width;
                soundCloudOrangeBlocks.TopHeight = pictureBox1.Height / 4 * 3;
                soundCloudOrangeBlocks.BottomHeight = pictureBox1.Height / 4;
                soundCloudOrangeBlocks.BackgroundColor = Color.Transparent;
                soundCloudOrangeBlocks.PixelsPerPeak = 2;


                var renderer = new WaveFormRenderer(); //波形レンダラの生成
                var averagePeakProvider = new AveragePeakProvider(3); //波形レンダラ内部で使用されるもの

                //レンダリングした画像をPictureBoxに設定
                pictureBox1.BackgroundImage = renderer.Render(filePath, averagePeakProvider, soundCloudDarkBlocks);
                pictureBox2.BackgroundImage = renderer.Render(filePath, averagePeakProvider, soundCloudOrangeBlocks);
                pictureBox2.Width = 0;

                label1.Text = Path.GetFileName(filePath);

                outputDevice = new WaveOutEvent();
                audioFile = new AudioFileReader(filePath);
                outputDevice.Init(audioFile);
                playButton.BackgroundImage = Properties.Resources.play;
            }
        }

        private void playButton_Click(object sender, EventArgs e)
        {
            switch (outputDevice.PlaybackState)
            {
                case PlaybackState.Stopped://ファイルが読み込まれてまだ一度も再生されていない場合

                    //必要な値を求める
                    bytePerSec = audioFile.WaveFormat.BitsPerSample / 8 * audioFile.WaveFormat.SampleRate * audioFile.WaveFormat.Channels;
                    length = (int)audioFile.Length / bytePerSec;

                    label3.Text = new TimeSpan(0, 0, length).ToString();
                    timer1.Enabled = true;

                    outputDevice.Play();
                    playButton.BackgroundImage = Properties.Resources.pause;
                    break;
                case PlaybackState.Paused://一時停止時の場合
                    outputDevice.Play();
                    playButton.BackgroundImage = Properties.Resources.pause;
                    break;
                case PlaybackState.Playing://再生中の場合
                    outputDevice.Pause();
                    playButton.BackgroundImage = Properties.Resources.play;
                    break;
            }

        }

    private void timer1_Tick(object sender, EventArgs e)
        {
            //再生位置(秒)を計算して表示
            position = (int)audioFile.Position / bytePerSec;
            label2.Text = new TimeSpan(0, 0, position).ToString();

            if (!mouseDownFlag)//ドラッグ時に幅を変更するとチカチカするのを防止
                //再生位置からオレンジ波形をすすめる
                pictureBox2.Width = (int)(((double)position / length) * pictureBox1.Width);
        }

        private void PictureBox_MouseDown(object sender, MouseEventArgs e)
        {
            if (audioFile == null) return;
            mouseDownFlag = true;//ドラッグ時のフラグをtrueに
        }

        private void PictureBox_MouseMove(object sender, MouseEventArgs e)
        {
            if (mouseDownFlag) pictureBox2.Width = e.X;//ドラッグ中にオレンジの波形の幅を変更
        }

        private void PictureBox_MouseUp(object sender, MouseEventArgs e)
        {
            if (!mouseDownFlag) return;
            mouseDownFlag = false;

            //ドラッグが終了した場所から曲の再生位置を計算して設定
            audioFile.Position = (int)(((double)e.X / pictureBox1.Width) * audioFile.Length);
        }
    }

処理の流れ

ファイルを選択した後、オレンジと黒の波形をレンダリングしpictureBoxに画像を設定します。
その後、Timerを使って、pictureBox2の幅を変化させ、左からオレンジの波形を再生位置まで重ねます。
また、pictureBox1,2には共通のMouseDown,Move,Upイベントを設定し、そこでシーク処理をします。

レンダリングの設定

//黒い波形の設定
var soundCloudDarkBlocks = new SoundCloudBlockWaveFormSettings(Color.FromArgb(52, 52, 52), Color.FromArgb(55, 55, 55), Color.FromArgb(154, 154, 154),
        Color.FromArgb(204, 204, 204));//バーの色の設定

soundCloudDarkBlocks.Width = pictureBox1.Width;//生成する画像の幅
soundCloudDarkBlocks.TopHeight = pictureBox1.Height / 4 * 3;//上に伸びるバーの高さ
soundCloudDarkBlocks.BottomHeight = pictureBox1.Height / 4;//下に伸びるバーの長さ
soundCloudDarkBlocks.BackgroundColor = Color.Transparent;//生成される画像の背景色 今回は透明
soundCloudDarkBlocks.PixelsPerPeak = 2;//バーの幅
//soundCloudDarkBlocks.SpacerPixels = 1; バーの間に挟まる細いバーの幅

レンダラに渡す設定を生成します。
ライブラリにはいくつかのテンプレートがあり、今回はSoundCloudBlockWaveFormを使用しています。ほかのテンプレートはGitHubのREADMEを参照してください。また、自分で作ることもできます(後述)。

レンダリングする

var renderer = new WaveFormRenderer(); //波形レンダラの生成
var averagePeakProvider = new AveragePeakProvider(3); //波形レンダラ内部で使用されるもの

//レンダリングした画像をPictureBoxに設定
pictureBox1.BackgroundImage = renderer.Render(filePath, averagePeakProvider, soundCloudDarkBlocks);
pictureBox2.BackgroundImage = renderer.Render(filePath, averagePeakProvider, soundCloudOrangeBlocks);
pictureBox2.Width = 0;

WaveFormRendererは名前の通り波形画像を生成するクラスです。Renderメソッドを実行するとImageを返します。

PeakProviderは音声ファイルから、時間毎に描画するバーの高さを計算するクラスです。
今回はAveragePeakProviderを使用しています。コンストラクタの引数には倍率を指定します。

再生ボタンを押したときの処理

private void playButton_Click(object sender, EventArgs e){
      switch (outputDevice.PlaybackState)
      {
          case PlaybackState.Stopped://ファイルが読み込まれてまだ一度も再生されていない場合

              //必要な値を求める
                bytePerSec = audioFile.WaveFormat.BitsPerSample / 8 * audioFile.WaveFormat.SampleRate * audioFile.WaveFormat.Channels;
                length = (int)audioFile.Length / bytePerSec;

                label3.Text = new TimeSpan(0, 0, length).ToString();
                timer1.Enabled = true;

                outputDevice.Play();
                playButton.BackgroundImage = Properties.Resources.pause;
                break;
           case PlaybackState.Paused://一時停止時の場合
                outputDevice.Play();
                playButton.BackgroundImage = Properties.Resources.pause;
                break;
           case PlaybackState.Playing://再生中の場合
                outputDevice.Pause();
                playButton.BackgroundImage = Properties.Resources.play;
                break;
       }
}

プレーヤーの状態によって分岐処理をしています。

PlaybackState.Stopped (初回再生時)

今後必要になる値を求めておきます。
今回使用するライブラリには、曲の長さを取得したりシークをする際などに秒単位は使えず、バイト単位でしか操作できないっぽいので、まず1秒あたりのバイト数を求めておきます。音声ファイルの仕組みがわかっていると理解は容易かと思います。

bytePerSec = audioFile.WaveFormat.BitsPerSample / 8 * audioFile.WaveFormat.SampleRate * audioFile.WaveFormat.Channels;

具体的には
audioFile.WaveFormat.BitsPerSample / 8 で1サンプルあたりのバイト数を
それに1秒あたりのサンプル数とチャンネル数をかけて算出しています。

length = (int)audioFile.Length / bytePerSec;

曲の長さ(秒)を計算します。全体の長さを1秒あたりのバイト数で割ります。

PlaybackState.Paused PlaybackState.Playing

再生、一時停止とボタンの画像を差し替えています。

Timerでの処理

private void timer1_Tick(object sender, EventArgs e)
{
       //再生位置(秒)を計算して表示
       position = (int)audioFile.Position / bytePerSec;
       label2.Text = new TimeSpan(0, 0, position).ToString();

       if (!mouseDownFlag)//ドラッグ時に幅を変更するとチカチカするのを防止
           //再生位置からオレンジ波形をすすめる
           pictureBox2.Width = (int)(((double)position / length) * pictureBox1.Width);
}

現在の再生位置はストリームの位置(バイト単位)で取得されるので秒数に変換したのち、表示しています。
そして、再生位置までオレンジ波形を上から重ねます。
ドラッグしてシークできるようにしてあるので、ドラッグ中は変更しないようにします。

ドラッグしてシークする処理

private void PictureBox_MouseDown(object sender, MouseEventArgs e)
{
    if (audioFile == null) return;
    mouseDownFlag = true;//ドラッグ時のフラグをtrueに
}

private void PictureBox_MouseMove(object sender, MouseEventArgs e)
{
    if (mouseDownFlag) pictureBox2.Width = e.X;//ドラッグ中にオレンジの波形の幅を変更
}

private void PictureBox_MouseUp(object sender, MouseEventArgs e)
{
    if (!mouseDownFlag) return;
    mouseDownFlag = false;

    //ドラッグが終了した場所から曲の再生位置を計算して設定
    audioFile.Position = (int)(((double)e.X / pictureBox1.Width) * audioFile.Length);
}

MouseMoveでフラグがtrueのときのみオレンジ波形の幅を変更しています。
MouseUpでカーソルの座標から曲の位置を計算してシークしています。

完成品

sc3.gif
本家のようなホバー時のエフェクトは実装していませんがそれっぽいものができました。
画像を生成する処理が重いため、音声ファイル読み込み後に少し固まります。別スレッドで処理するといいかもしれません。

描画の内部処理を紐解いてみる

本題から外れますが、今後カスタマイズするかもしれないのでメモとして残しておきます。
sc4.png

① PeakProviderを初期化します。その際にファイル名から生成したISampleProvider(音声ファイルのストリーム)を渡します。
また何バイトごとにストリームを処理するかの値も渡します。
(何秒を1ブロックとしてバーの高さを計算するのかの値を渡します。)
② 描画処理の本体があるstaticメソッドを呼びます。①で生成したPeakProviderとレンダリング設定を渡します。
③ GetNextPeakを呼び出します。
④ ②で指定した範囲の音声ストリームの音の強さを処理して、上に伸びるバーと下に伸びるバーの長さをそれぞれ求めます。
作品で使用したAveragePeakProviderは範囲内の音の強さを平均化してバーの長さとしています。
⑤ 値を返します。
⑥ 返された値を使用してバーを描画します。

①でしらっと音声のストリームを渡しています。当たり前ですが、このストリームはデコード済みのものです。NAudioのAudioFileReaderは自前のデコーダやWindowsのMediaFoundationを利用して圧縮音源をデコードしてくれるすぐれものです。

⑥をカスタマイズすることで自分で描画をすることができます。

感想

音声処理は、OpenCV用いての画像処理といった直感的なものに比べ、目に見えにくくとっつきにくい部分があると思います。しかし、内部の処理を覗いてみるとこちらも直感的に操作できることが分かった(気がした)ので、音声処理に挑戦する良いきっかけになったと思います。

あと初投稿なので記事を書く大変さもわかりました。
では、最後までご覧下さりありがとうございました。