C#
WPF
音楽
音声処理
NAudio

C#で音声波形を表示する音楽プレーヤーを作る


こんなものを作ります

AudioSpectrum

音楽を再生すると同時に、その音声波形を表示します。


C#での図形の描画

WPF(Windows Presentation Foundation)によりアプリケーションを作成しました。


WPFとは

MSDNでは、以下のように説明されています。

Windows Presentation Foundation (WPF) は、デスクトップクライアントアプリケーションを作成する UI フレームワークです。WPF の開発プラットフォームは、アプリケーション モデル、リソース、コントロール、グラフィックス、レイアウト、データバインディング、ドキュメント、セキュリティなどのさまざまなアプリケーション開発機能の一式をサポートします。

(https://docs.microsoft.com/ja-jp/visualstudio/designers/getting-started-with-wpf)


コードビハインド

WPFでは、XAMLで記述したGUIに対して、C#などのプログラミング言語を用いてイベント処理を記述することができます。このように、デザイン部分とコーディング部分を別々のファイルに分けることをコードビハインドといいます。


WPFアプリケーション in Visual Studio

C#では、一般的に、Main()メソッドがエントリポイントとなり、アプリケーション起動時に呼ばれます。しかし、Visual Studioで作成したWPFアプリケーションではこのMain()メソッドがありません。

実は、WPFアプリケーションでは、Application.xaml(またはApp.xaml)ファイルをプロジェクトに含めている場合、ビルド時に、Main()メソッドが自動生成されます。

簡単に言えば、そのMain()メソッド内に、MainWindow.xamlを実行するように書いてあるため、MainWindow.xamlが自動で実行されるわけです。


NAudioライブラリ

NAudioライブラリとは、主に音声データを扱うために設計されたライブラリです。

GitHub - NAudio


NAudioのインストール


GUI

ソリューション エクスプローラーで対象のプロジェクトの[参照]を右クリックし、[NuGet パッケージの管理]を選択します。そして[参照]を選択し、NAudioと検索します。


CLI

メニューの【ツール → NuGet パッケージ マネージャー → パッケージ マネージャー コンソール】からコンソールを開き、

PM> Install-Package NAudio

とすると、インストールできます。


コード

まず、要所の説明をし、そのあとでコード全体を示します。

一応、MainWindow.xamlも載せておきます。

コードにコメントとして説明を書いているため、詳細の説明は省略させていただきます。


イベント

WPFでは、イベントイベントハンドラを頻繁に使うので、コードの説明に入る前に、ここで、イベントへのイベントハンドラの登録の仕方の説明をします。

イベントハンドラとは、イベントが発生したときに実行される処理のことをいいます。

詳細の説明を知りたい方は、イベントハンドラデリゲートラムダ式などでググってみてください。

イベントハンドラは、以下の様に登録します。

namespace Event

{
/// <summary>
/// MainWindow.xaml の相互作用ロジック
/// </summary>
public partial class MainWindow : Window
{
/// <summary>
/// コンストラクタ
/// </summary>
public MainWindow()
{
InitializeComponent();

// イベントハンドラの登録
// 以降すべて同じ意味

this.イベント変数 += new EventHandler( this.Func_Event );

this.イベント変数 += this.Func_Event;

this.イベント変数 += delegate( object sender, EventArgs e )
{
処理A;
}

this.イベント変数 += ( sender, e ) => { 処理A; }

// 処理Aが1文のとき
this.イベント変数 += ( sender, e ) => 処理A;
}

/// <summary>
/// 処理Aを行うイベントハンドラ
/// </summary>
/// <param name="sender">イベント送信元</param>
/// <param name="e">イベント引数</param>
private void Func_Event( object sender, EventArgs e )
{
処理A;
}
}
}


使用するライブラリ

using System;

using System.Windows;
using System.Windows.Media;
using System.Windows.Shapes;
using System.Windows.Threading;

using NAudio.Wave;
using NAudio.Dsp;

Nugetなどで取得します。


タイマーの生成


// タイマーの生成
timer = new DispatcherTimer(DispatcherPriority.Normal);
// Tickの発生間隔の設定
timer.Interval = new TimeSpan(reciprocal_of_FPS);
// タイマーイベントの登録
timer.Tick += new EventHandler(timer_Tick);

        /// <summary>

/// Timer.Tickが発生したときのイベントハンドラ
/// </summary>
/// <param name="sender">イベント送信元</param>
/// <param name="e">イベント引数</param>
private void timer_Tick(object sender, EventArgs e)
{
// この中の処理はメインスレッドで行われる

// 再生位置 (秒)を計算
playPosition_s = (int)audioStream.Position / bytePerSec;

// 音声波形表示を描画する配列のオフセット(インデックス)を計算
drawPosition = (int)(((double)audioStream.Position / (double)audioStream.Length) * result.GetLength(0));
Make_AudioSpectrum();
}

タイマーの機能は、System.Timers.Timerクラスで実装されています。しかし、System.Timers.TimerクラスはUIスレッドとは独立したスレッドで実行されるため、イベントハンドラからUIを操作することができません。スレッド上のタスク管理のためにDispatcherというクラスが用意されており、WPFUIにはDispatcherクラスのオブジェクトが関連づけられています。このDispatcherオブジェクトと連携してUIを操作できるようにしたタイマーDispatcherTimerクラスです。

ここでは、オブジェクトを作成する際に、タイマーの優先順位として、Dispatcher.Normalを指定し、イベントハンドラとしてtimer_Tick()メソッドを登録しています。


音楽の再生


// 再生するファイル名
fileName = "music\\sample.wav";

// ファイル名の拡張子によって、異なるストリームを生成
audioStream = new AudioFileReader(fileName);

            // コンストラクタを呼んだ際に、Positionが最後尾に移動したため、0に戻す

audioStream.Position = 0;

            // プレーヤーの生成

outputDevice = new WaveOutEvent();
// 音楽ストリームの入力
outputDevice.Init(audioStream);

            // 音楽の再生 (おそらく非同期処理)

outputDevice.Play();

再生は現在のストリームの位置から開始されるため、Play()メソッドで必ずしも最初から再生されるわけではありません。コンストラクタにより、オブジェクトを生成した際、Positionはストリームの終端に移動したため、audioStream.Position = 0;というように位置を先頭に戻す必要があります。

また、このPlay()メソッドは、非同期処理であるため、Play()メソッドを呼び出した後も、すぐに次のコードが実行されます。つまり、音楽を再生しながら別の処理を行うことができます。


ハミング窓と高速フーリエ変換

        /// <summary>

/// 音楽の波形データにハミング窓をかけ、高速フーリエ変換する
/// </summary>
/// <returns>フーリエ変換後の音楽データ</returns>
private float[,] FFT_HammingWindow_ver1()
{
// 波形データを配列samplesに格納
float[] samples = new float[audioStream.Length / audioStream.BlockAlign * audioStream.WaveFormat.Channels];
audioStream.Read(samples, 0, samples.Length);

//1サンプルのデータ数
int fftLength = 256;
//1サンプルごとに実行するためのイテレータ用変数
int fftPos = 0;

// フーリエ変換後の音楽データを格納する配列 (標本化定理より、半分は冗長)
float[,] result = new float[samples.Length / fftLength, fftLength / 2];

// 波形データにハミング窓をかけたデータを格納する配列
Complex[] buffer = new Complex[fftLength];
for (int i = 0; i < samples.Length; i++)
{
// ハミング窓をかける
buffer[fftPos].X = (float)(samples[i] * FastFourierTransform.HammingWindow(fftPos, fftLength));
buffer[fftPos].Y = 0.0f;
fftPos++;

// 1サンプル分のデータが溜まったとき
if (fftLength <= fftPos)
{
fftPos = 0;

// サンプル数の対数をとる (高速フーリエ変換に使用)
int m = (int)Math.Log(fftLength, 2.0);
// 高速フーリエ変換
FastFourierTransform.FFT(true, m, buffer);

for (int k = 0; k < result.GetLength(1); k++)
{
// 複素数の大きさを計算
double diagonal = Math.Sqrt(buffer[k].X * buffer[k].X + buffer[k].Y * buffer[k].Y);
// デシベルの値を計算
double intensityDB = 10.0 * Math.Log10(diagonal);

const double minDB = -60.0;

// 音の大きさを百分率に変換
double percent = (intensityDB < minDB) ? 1.0 : intensityDB / minDB;
// 結果を代入
result[i / fftLength, k] = (float)diagonal;
}
}
}

return result;
}

ハミング窓(Hamming Window)のについての詳しい説明は、ここでは省きます。簡単に言えば、要するに信号の一部を切り出すために、もとの関数にかける両端がなめらかに絞られた関数です。

次に、高速フーリエ変換(Fast Fourier Transform:FFT)の説明です。

高速フーリエ変換とは、離散フーリエ変換(Discreate Fourier Transform:DFT)を計算機上で高速に計算するアルゴリズムです。

つまり、計算の結果は普通の離散フーリエ変換と変わりません。よって、ここでは、本筋からずれることを避けるため、離散フーリエ変換の式のみを紹介します。高速フーリエ変換の詳細を知りたい方は、ぜひググってみてください。

複素関数f(x)(N次)離散フーリエ変換である複素関数F(t)は以下で定義されます。

F(t)=\sum_{x=0}^{N-1}f(x)e^{-i\frac{2\pi tx}{N}}

高速フーリエ変換は、NAudio.Dsp.FastFourierTransform.FFT ()メソッドを使えばできます。NAudio、とても優秀ですね!

次に、引数について説明します。

FastFourierTransform.FFT (bool forward, int m, Complex[] data)




forward



おそらく、trueで高速フーリエ変換、falseで逆高速フーリエ変換なので、今回はtrue



m



サンプル数が2のm乗の時のmの値。



data



変換したいデータを入れます。結果もこの配列に入ります。





離散フーリエ変換後のデータは、以下のように扱います。配列の要素のインデックスをkとすると、

f=\frac{k×f_{sampling}}{N_{sampling}}

が成り立ち、その要素は、周波数成分fの情報を持ちます。

ただし、標本化定理(サンプリング定理)より、標本化周波数(サンプリング周波数)f_sampleのとき、f_sample / 2(ナイキスト周波数)以下の原信号が復元可能なので、k < N / 2となります。

また、その複素数の大きさは振幅の半分に相当します。

その計算のあとはデシベルの値を計算し、大きさを百分率で表します。計算方法は上記のコードの通りなので、説明は省略します。


音声波形表示の描画

            // 音声波形表示に使用するLine(バー)の配列を確保 (この時点では、コンストラクタは呼び出されていない)

bar = new Line[result.GetLength(1)];
for (int i = 0; i < result.GetLength(1); i++)
{
bar[i] = new Line(); // 各要素のコンストラクタを明示的に呼び出す
}
// Line(バー)に使用するブラシ
brush = new SolidColorBrush(Color.FromArgb(128, 61, 221, 200));

            // 1秒あたりのバイト数を計算

bytePerSec = (audioStream.WaveFormat.BitsPerSample / 8) * audioStream.WaveFormat.SampleRate * audioStream.WaveFormat.Channels;
// 音楽の長さ (秒)を計算
musicLength_s = (int)audioStream.Length / bytePerSec;

            // 再生位置 (秒)を計算

playPosition_s = (int)audioStream.Position / bytePerSec;

// 音声波形表示を描画する配列のオフセット(インデックス)を計算
drawPosition = (int)(((double)audioStream.Position / (double)audioStream.Length) * result.GetLength(0));

        /// <summary>

/// 音声波形表示を描画
/// </summary>
private void Make_AudioSpectrum()
{
// 描画済みのLine(バー)がある場合
if (barDrawn)
{
for (int j = 0; j < result.GetLength(1); j++)
{
// 画面からLine(バー)を削除
grid.Children.Remove(bar[j]);
}
}

if (drawPosition >= result.GetLength(0)) // マネージリソース(Line bar[])の解放は自動でガベージコレクションが行う
return;

for (int j = 0; j < result.GetLength(1);)
{
// 描画する方法 (Brush)を設定
bar[j].Stroke = brush; // System.Windows.Media.Brushes.LightBlue;
// (親要素内に作成されるときに適用される)水平方向の配置特性を、(親要素のレイアウトのスロットの)左側に設定
bar[j].HorizontalAlignment = HorizontalAlignment.Left;
// (親要素内に作成されるときに適用される)垂直方向の配置特性を、(親要素のレイアウトのスロットの)中央に設定
bar[j].VerticalAlignment = VerticalAlignment.Center;

// 始点のx座標を設定
bar[j].X1 = j * 7 + 32;
// 終点のx座標を設定
bar[j].X2 = j * 7 + 32;

// 始点のy座標を設定
bar[j].Y1 = 0;
// 終点のy座標を設定 (result[,]は、0 ~ 1の値)
bar[j].Y2 = 7700 * result[drawPosition, j];
// 長さが400より大きい場合は長さを400にする
if (bar[j].Y2 >= 400)
bar[j].Y2 = 400;
// 幅を設定
bar[j].StrokeThickness = 5;

// 画面にLine(バー)を追加
grid.Children.Add(bar[j]);
j += 1;
}
// 描画済みにする
barDrawn = true;

}

描画済みのバーがある場合はそれを削除し、高速フーリエ変換後の配列のデータのうち、drawPositionの要素を描画します。


コード全体


MainWindow.xaml.cs


MainWindow.xaml.cs

namespace AudioSpectrum_ver._2

{
/// <summary>
/// MainWindow.xaml の相互作用ロジック
/// </summary>
public partial class MainWindow : Window
{
/// <summary>
/// 60(fps)の逆数 (100ns)
/// </summary>
private readonly long reciprocal_of_FPS = 167000;

/// <summary>
/// 音楽プレーヤー
/// </summary>
private WaveOutEvent outputDevice;

/// <summary>
/// フーリエ変換前の音楽データ
/// </summary>
private AudioFileReader audioStream;

/// <summary>
/// フーリエ変換後の音楽データ
/// </summary>
private float[,] result;

/// <summary>
/// タイマー割込みに使用するタイマー
/// </summary>
private DispatcherTimer timer = null;

/// <summary>
/// 再生する音楽ファイルのパス
/// </summary>
private string fileName;

/// <summary>
/// 音声波形表示に使用するLine(バー)
/// </summary>
private Line[] bar;

/// <summary>
/// 音声波形表示のLine(バー)に使用するブラシ
/// </summary>
private Brush brush;

/// <summary>
/// 1秒当たりのバイト数
/// </summary>
private int bytePerSec;

/// <summary>
/// 音楽の長さ (秒)
/// </summary>
private int musicLength_s;

/// <summary>
/// 再生位置 (秒)
/// </summary>
private int playPosition_s;

/// <summary>
/// 音声波形表示位置
/// </summary>
private int drawPosition;

/// <summary>
/// 描画済みのLine(バー)があるかを示すフラグ (生成済み = true, 未生成 = false)
/// </summary>
private bool barDrawn = false;

/// <summary>
/// コンストラクタ
/// </summary>
public MainWindow()
{
InitializeComponent();

// ウィンドウをマウスのドラッグで移動できるようにする
this.MouseLeftButtonDown += (sender, e) => this.DragMove();

// Loaded(要素のレイアウトやレンダリングが完了し、操作を受け入れる準備が整ったときに発生)イベントの登録
this.Loaded += new RoutedEventHandler(MainWindow_Loaded);
}

/// <summary>
/// MainWindowの初期化が終わったとき (Loadedが発生したとき) のイベントハンドラ
/// </summary>
/// <param name="sender">イベント送信元</param>
/// <param name="e">イベント引数</param>
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
// タイマーの生成
timer = new DispatcherTimer(DispatcherPriority.Normal);
// Tickの発生間隔の設定
timer.Interval = new TimeSpan(reciprocal_of_FPS);
// タイマーイベントの登録
timer.Tick += new EventHandler(timer_Tick);

// 再生するファイル名
fileName = "music\\sample.wav";

// ファイル名の拡張子によって、異なるストリームを生成
audioStream = new AudioFileReader(fileName);

// ハミング窓をかけ、高速フーリエ変換を行ったデータを配列resultに格納
result = FFT_HammingWindow_ver1();

// 音声波形表示に使用するLine(バー)の配列を確保 (この時点では、コンストラクタは呼び出されていない)
bar = new Line[result.GetLength(1)];
for (int i = 0; i < result.GetLength(1); i++)
{
bar[i] = new Line(); // 各要素のコンストラクタを明示的に呼び出す
}
// Line(バー)に使用するブラシ
brush = new SolidColorBrush(Color.FromArgb(128, 61, 221, 200));

// コンストラクタを呼んだ際に、Positionが最後尾に移動したため、0に戻す
audioStream.Position = 0;

// プレーヤーの生成
outputDevice = new WaveOutEvent();
// 音楽ストリームの入力
outputDevice.Init(audioStream);

// 1秒あたりのバイト数を計算
bytePerSec = (audioStream.WaveFormat.BitsPerSample / 8) * audioStream.WaveFormat.SampleRate * audioStream.WaveFormat.Channels;
// 音楽の長さ (秒)を計算
musicLength_s = (int)audioStream.Length / bytePerSec;

// 音楽の再生 (おそらく非同期処理)
outputDevice.Play();

// タイマーの実行開始
timer.Start();
}

/// <summary>
/// Timer.Tickが発生したときのイベントハンドラ
/// </summary>
/// <param name="sender">イベント送信元</param>
/// <param name="e">イベント引数</param>
private void timer_Tick(object sender, EventArgs e)
{
// この中の処理はメインスレッドで行われる

// 再生位置 (秒)を計算
playPosition_s = (int)audioStream.Position / bytePerSec;

// 音声波形表示を描画する配列のオフセット(インデックス)を計算
drawPosition = (int)(((double)audioStream.Position / (double)audioStream.Length) * result.GetLength(0));
Make_AudioSpectrum();
}

/// <summary>
/// 音楽の波形データにハミング窓をかけ、高速フーリエ変換する
/// </summary>
/// <returns>フーリエ変換後の音楽データ</returns>
private float[,] FFT_HammingWindow_ver1()
{
// 波形データを配列samplesに格納
float[] samples = new float[audioStream.Length / audioStream.BlockAlign * audioStream.WaveFormat.Channels];
audioStream.Read(samples, 0, samples.Length);

//1サンプルのデータ数
int fftLength = 256;
//1サンプルごとに実行するためのイテレータ用変数
int fftPos = 0;

// フーリエ変換後の音楽データを格納する配列
float[,] result = new float[samples.Length / fftLength, fftLength / 2];

// 波形データにハミング窓をかけたデータを格納する配列
Complex[] buffer = new Complex[fftLength];
for (int i = 0; i < samples.Length; i++)
{
// ハミング窓をかける
buffer[fftPos].X = (float)(samples[i] * FastFourierTransform.HammingWindow(fftPos, fftLength));
buffer[fftPos].Y = 0.0f;
fftPos++;

// 1サンプル分のデータが溜まったとき
if (fftLength <= fftPos)
{
fftPos = 0;

// サンプル数の対数をとる (高速フーリエ変換に使用)
int m = (int)Math.Log(fftLength, 2.0);
// 高速フーリエ変換
FastFourierTransform.FFT(true, m, buffer);

for (int k = 0; k < result.GetLength(1); k++)
{
// 複素数の大きさを計算
double diagonal = Math.Sqrt(buffer[k].X * buffer[k].X + buffer[k].Y * buffer[k].Y);
double intensityDB = 10.0 * Math.Log10(diagonal);

const double minDB = -60.0;

// 音の大きさを百分率に変換
double percent = (intensityDB < minDB) ? 1.0 : intensityDB / minDB;
// 結果を代入
result[i / fftLength, k] = (float)diagonal;
}
}
}

return result;
}

/// <summary>
/// 音声波形表示を描画
/// </summary>
private void Make_AudioSpectrum()
{
// 描画済みのLine(バー)がある場合
if (barDrawn)
{
for (int j = 0; j < result.GetLength(1); j++)
{
// 画面からLine(バー)を削除
grid.Children.Remove(bar[j]);
}
}

if (drawPosition >= result.GetLength(0)) // マネージリソース(Line bar[])の解放は自動でガベージコレクションが行う
return;

for (int j = 0; j < result.GetLength(1);)
{
// 描画する方法 (Brush)を設定
bar[j].Stroke = brush; // System.Windows.Media.Brushes.LightBlue;
// (親要素内に作成されるときに適用される)水平方向の配置特性を、(親要素のレイアウトのスロットの)左側に設定
bar[j].HorizontalAlignment = HorizontalAlignment.Left;
// (親要素内に作成されるときに適用される)垂直方向の配置特性を、(親要素のレイアウトのスロットの)中央に設定
bar[j].VerticalAlignment = VerticalAlignment.Center;

// 始点のx座標を設定
bar[j].X1 = j * 7 + 32;
// 終点のx座標を設定
bar[j].X2 = j * 7 + 32;

// 始点のy座標を設定
bar[j].Y1 = 0;
// 終点のy座標を設定 (result[,]は、0 ~ 1の値)
bar[j].Y2 = 7700 * result[drawPosition, j];
// 長さが400より大きい場合は長さを400にする
if (bar[j].Y2 >= 400)
bar[j].Y2 = 400;
// 幅を設定
bar[j].StrokeThickness = 5;

// 画面にLine(バー)を追加
grid.Children.Add(bar[j]);
j += 1;
}
// 描画済みにする
barDrawn = true;

}

/// <summary>
/// コンテキストメニューのExitが押されたときのイベントハンドラ
/// </summary>
/// <param name="sender">イベント送信元</param>
/// <param name="e">イベント引数</param>
private void Quit_Clicked(object sender, RoutedEventArgs e)
{
Close();
}
}
}



MainWindow.xaml

最後に、MainWindow.xamlも載せておきます。


MainWindow.xaml

<Window x:Class="AudioSpectrum_ver._2.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:AudioSpectrum_ver._2"
mc:Ignorable="d"
Title="MainWindow" Height="540" Width="960"
AllowsTransparency="True"
Background="#80000024"
WindowStyle="None"
ResizeMode="CanResizeWithGrip"
>
<Window.ContextMenu>
<ContextMenu>
<MenuItem Header="Exit" Click="Quit_Clicked"/>
</ContextMenu>
</Window.ContextMenu>
<Grid Name="grid">
</Grid>
</Window>


レイヤードウィンドウ

        AllowsTransparency="True" 

Background="#80000024"
WindowStyle="None"

レイヤードウィンドウとは、非矩形ウィンドウ、アルファブレンド効果を使用するウィンドウのことです。要するにウィンドウを透明、もしくは半透明にする機能です。

        AllowsTransparency="True" 

WindowStyle="None"

この記述を追加することで、描画がレイヤードウィンドウ上で行われます。

さらに、

Background="#80000024"

というようにARGBの順番でBackgroundの値を指定することで、半透明

Background="TransParent"

とすることで透明にすることができます。(デバッグ中半透明にするのが良いと思います。)


感想

AviUtlという無料の動画編集ソフトで、音声波形表示のスクリプト(言語はlua)を遊びで作ったことが、Windowsアプリケーションとして、音声波形表示のアプリケーションを作ることのきっかけになりました。

実は、私は、C#でコードを書くのはこれが初めてで、なので当然WPFも初めてでした。そんな中、NAudioという日本語で説明されたサイトが少ないライブラリを用いらなければならなかったので、最初は不安でしたが、C++ではよくコードを書いていることもあり、なんとかここまで来ることができました。

次は音声波形の表示方法をアレンジしたいと思います。

余談ですが、これが初投稿で、記事を書く大変さも知ることができました(+o+)笑

では、最後までご覧くださりありがとうございました