Unity には、既存の mp3, ogg 等のアセットから音声を読み込む機能だけでなく、自前で作った音声波形を鳴らす機能も存在します。
目次
- まえがき (対象読者など)
- 事前準備 - Unity プロジェクト環境
- 簡単な例 - レトロゲームで多用される「矩形波」を鳴らす
- 「音の基礎」の説明
- 「音の基礎」をふまえたうえでソースコードの解説
- 複雑な例 - 「ドレミファソラシド」を鳴らす方法
- 実際に矩形波で「ドレミファソラシド」を鳴らす
- 生成した波形を音声ファイルとして出力する
- おわりに
- 主な更新履歴
まえがき (対象読者など)
Unity に限らず、ゲームを作るとき、ほとんどの場合は、BGM や効果音等の素材を mp3 や ogg 等のファイルとして用意すると思います。
しかし、中には、ゼロから自前で電子音などを合成したいと考える方も居られると思います。
本記事では、Unity でのゲームプログラミング経験者向けに、C# Script 経由で波形データを書き換える API と、実際にゼロから電子的に音を合成する例を、音や波形の基礎と一緒に (なるべく難しい数学を使わずに) 説明します。
- 実践的なサウンドプログラミングに生かせる高度な理論やテクニックについては、カテゴリ「信号処理」の方で拡充してく予定です。
事前準備 - Unity プロジェクト環境
別記事「Unity でプログラミングのみ (C# Script のみ) でゲームを作る方法」のC# Script の作成と Main Camera への割当まで済ませることで、本記事の C# Script を実行できる状態になります。
簡単な例 - レトロゲームで多用される「矩形波」を鳴らす
理論面や処理の解説は後回しにして、まずは実際に音が鳴るコードを先に紹介します。
以下のスクリプトを、事前準備で作成した C# Script の Start() メソッドの中に追加すれば、実行して音が鳴るのを確かめることができます。
// 波形を作成 (2 秒間で 96000 サンプル) float[] waveform = new float[96000]; for (int sample = 0; sample < 96000; sample++) { waveform[sample] = (sample % 110 < 55) ? +1.0f : -1.0f; } // 波形を AudioClip オブジェクトに格納 (1 秒間あたり 48000 サンプル) AudioClip audioClip = AudioClip.Create("TestAudioClip", 96000, 1, 48000, false); audioClip.SetData(waveform, 0); // AudioClip を AudioSource オブジェクトで再生 AudioSource audioSrc = new GameObject("TestAudioSrc").AddComponent<AudioSource>(); audioSrc.clip = audioClip; audioSrc.Play();
「音の基礎」の説明
ゲームプログラミング経験者の方でも、「音の中身」までは扱った経験が無い方もおられると思います。
そこで、前節のソースコードの解説の前に、「音の基礎」について、なるべく難しい数学を使わずに説明していきます。
しかし、読者の中にはサウンドプログラミング経験者も居ると思いますので、既にご存じの方は本節をスキップして下さい。
そもそも「音」とは? 「波形」って何?
空気の「振動」を人間の耳で感じ取ったものです。
- 振動速度が遅ければ「低い音」に聞こえますし、早ければ「高い音」に聞こえます。
- 振幅が小さければ「小音量」になりますし、大きければ「大音量」になります。
- その振動の様子を、2 次元のグラフで表したものが波形です。
上記の「波形」は、縦方向が振幅 (=振動の振れ幅) で、横方向が時間です。人間の耳には以下のように聞こえます。
- ①の波形は、④よりも振動が遅いので「低い音」に聞こえます。
- ②の波形は、①よりも振幅が小さいので「小音量」に聞こえます。
- ただし、①と②は振動の速さが同じなので、同じ高さの音になります。
- ③の波形は、何も振動していないので「無音」になります。
- ④の波形は、①よりも振動が早いので「高い音」に聞こえます。
音声の「波形」をどうやってデータとして表すの?
ゲーム作者なら馴染みの深い「画像」と比較すると分かりやすいです。
ゲーム作者であれば、2 次元である画像データは、碁盤目に区切られている 1 つ 1 つのデータのことを「ドット」または「ピクセル」と言うことはご存じかと思います。
1 次元の音声データの場合、画像と異なり「時刻」毎に区切られています。これら 1 つ 1 つのことを「サンプル」と言います。
画像の各ピクセルが明るさ (=輝度) の情報を持つのと同様に、音声の各サンプルは「振幅」の情報を持ちます。
音声データの解像度 = サンプリングレート
画像の世界だと、例えばフル HD のモニタの解像度は 1920x1080 ピクセルですが、4K (UHD) のモニタの解像度は 3840x2160 ピクセルです。4K の方が解像度が高く、ドットがきめ細かいことは言うまでもないでしょう。
音声データにおいて、画像の解像度に相当する概念が「サンプリングレート」で、1 秒間あたりのサンプル数 (つまり Hz) で表します。画像と同様、細かいほど音質が上がると考えて下さい。(ただし普通の人間の耳の可聴域だと 40000 Hz 以上あれば十分ですが…)
単位が Hz となることから、「サンプリング周波数」と呼ばれることも多いです。
- 昔のデジタル電話 (ISDN 回線) の場合
- 1 秒間あたり 8000 サンプルを記録 → サンプリングレート 8000 Hz
- 音楽 CD 規格の場合
- 1 秒間あたり 44100 サンプルを記録 → サンプリングレート 44100 Hz
- DAT (デジタル・オーディオ・テープ) 規格の場合
- 1 秒間あたり 48000 サンプルを記録 → サンプリングレート 48000 Hz
一昔前は音楽 CD と同じ 44100 Hz が優勢だった時代がありましたが、最近ではゲームの世界でも動画の世界でも 48000 Hz が一般的になってきているため、データ量や計算量を節約しなければならない場合を除いては 48000 Hz を選択するのが最も無難でしょう。
おまけ:音声以外の分野のサンプリングレート
例えば、無線通信のように高速・広帯域なデジタル伝送が求められる世界だと、サンプリングレートがえげつないくらいに高いです。以下は携帯電話 (スマホ) 通信の場合の一例です。
- 3G (第 3 世代) 携帯電話 (3GPP TS 25.211) : 3.84 MHz (3,840,000 Hz)
- 4G (第 4 世代) 携帯電話 (3GPP TS 36.211) : 30.72 MHz (30,720,000 Hz)
- 5G (第 5 世代) 携帯電話 (3GPP TS 38.211) : 1966.08 MHz (1,966,080,000 Hz)
あくまでも余談の豆知識程度ですが、雑学程度に知っておいて損は無いでしょう。
「音の基礎」をふまえたうえでソースコードの解説
それでは、前節の「音の基礎」をふまえて、ソースコードの中身の処理を見ていきましょう。
「波形を作成」の箇所
// 波形を作成 (2 秒間で 96000 サンプル) float[] waveform = new float[96000]; for (int sample = 0; sample < 96000; sample++) { waveform[sample] = (sample % 110 < 55) ? +1.0f : -1.0f; }
前節の DAT 規格のサンプリングレート 48000 Hz を想定して、2 秒間の長さ (つまり 48000 * 2 = 96000 サンプル) の波形を作ろうとしています。
「振動」していないと、人間の耳には音として聞こえませんから、とりあえず +1 と -1 を行ったり来たりするようにしています。
このように、単純に +1 と -1 を繰り返すだけの波形のことを「矩形波」(または方形波) と言います。難しいアルゴリズムを使わなくても生成できることから、レトロゲーム機の時代から使用されているパターンの 1 つで、まさに典型的な機械音っぽく聞こえる波形です。(ピアノやオルガン、人の声などの「リアルな音」はもっと複雑な波形です)
さて、55 サンプル (1 周期で 110 サンプル) としたのには、1 つ理由があります。それは、音楽の世界における「ラ」の音階の高さの音 (=440 Hz) に近づけるためです。
440 Hz とは、1 秒間に 440 回振動する「周波数」のことを意味します。
1 秒間あたり 48000 サンプルであることを考えると、その中で 440 回行ったり来たりするためには、440 で割れば OK です。
48000 / 440 = 約 109 ですが、109 だと奇数になってしまいますから、110 として +1 が 55 サンプル、-1 が 55 サンプル、と 2 つに綺麗に分割できるようにしました。
「波形を AudioClip オブジェクトに格納」の箇所
// 波形を AudioClip オブジェクトに格納 (1 秒間あたり 48000 サンプル) AudioClip audioClip = AudioClip.Create("TestAudioClip", 96000, 1, 48000, false);
別記事「Unity でプログラミングのみ (C# Script のみ) でゲームを作る方法」と同様に、AudioClip オブジェクトを使用します。
ただし、アセットから読み込むのではなく、ゼロから AudioClip を作る必要があるため、Create() メソッドを呼び出します。
- 第 1 引数 (name)
- オブジェクトに割り当てる名前を指定します。(内部処理用なので基本的には何でも OK です)
- 第 2 引数 (lengthSamples)
- サンプル数です。今回の用途では作った波形と同じ長さにします。
- 第 3 引数 (channels)
- モノラルなら 1、ステレオなら 2 を指定します。
- 本記事ではモノラルなので 1 を指定します。(ステレオの場合、波形の渡し方が複雑になります)
- 第 4 引数 (frequency)
- サンプリングレートを指定します。本記事の例では 48000 です。
- 第 5 引数 (stream)
- ストリーミング (リアルタイムな波形生成) をするか否かを指定します。
- 本記事では false を指定します。
- true の場合の説明は割愛します。(コールバック関数をもちいてリアルタイムに波形を書き換えていくイメージになります。非常に長い波形を少ない消費メモリで再生したい場合などに使います。)
audioClip.SetData(waveform, 0);
AudioClip オブジェクトを Create() で作ったあとは、上記のように SetData() メソッドで、あらかじめ作っておいた波形データを設定します。
第 2 引数を 0 より大きな値に設定することで、AudioClip の途中のサンプルから書き込みを開始することもできますが、通常の用途では先頭 (=0) から書き込みすることが多いでしょう。
「AudioClip を AudioSource オブジェクトで再生」の箇所
別記事「Unity でプログラミングのみ (C# Script のみ) でゲームを作る方法」と同様に、AudioSource オブジェクトで AudioClip の再生を行います。
// AudioClip を AudioSource オブジェクトで再生 AudioSource audioSrc = new GameObject("TestAudioSrc").AddComponent<AudioSource>(); audioSrc.clip = audioClip; audioSrc.Play();
つまり、再生方法は、ファイルから読み込んだ AudioClip の場合とまったく同じです。(もちろん、上記の記事にある PlayOneShot() の方を使用して再生することもできます)
複雑な例 - 「ドレミファソラシド」を鳴らす方法
本記事のサムネイルにもあるとおり、「ドレミファソラシド」を鳴らしていきます。
ただし、周波数 (振動の速さ) を変えるだけでは物足りないので、振幅 (音の大きさ) も変えながら鳴らしていきます。
まず先に音階 (ド、レ、ミ、…) に関する規格と理論の説明を行ってから、実際に動くコードを示し、最後にコードの説明 (API も含む) という流れで進めていきます。
「ラ」の音の周波数について
前述の説明の中で、「ラ」の音が 440 Hz と説明しましたが、これは ISO 16:1975 として定められている立派な国際規格です。(実は 19 世紀頃までは世界各国で周波数がバラバラだったらしいです…)
また、別の規格では第 4 オクターブのラ (A4) が 440 Hz と定められています。
1 オクターブ違うと、周波数が半分になったり 2 倍になったりすることは、音にあまり詳しくない方でもご存じの方は多いと思います。
- 2 オクターブ低い「ラ」(A2) は 440 Hz / 4 = 110 Hz
- 1 オクターブ低い「ラ」(A3) は 440 Hz / 2 = 220 Hz
- 1 オクターブ高い「ラ」(A5) は 440 Hz * 2 = 880 Hz
- 2 オクターブ高い「ラ」(A6) は 440 Hz * 4 = 1760 Hz
「ラ」以外の音の周波数について
現代の西洋音楽で使われている音階は、「平均律」と呼ばれる、オクターブを数学的に 12 等分する方法で周波数を決めています。(古くは、オクターブを 12 分割するきっかけになった「ピタゴラス音律」や、和音の綺麗さを追求した「純正律」が使われていた時期もあります)
簡単に言うと、「2 倍」や「1/2 倍」という倍率を 12 等分することを意味します。
数学的には、「2 の 1/12 乗」単位 (=「2 の 12 乗根」単位) で分割することで、「ラ」の音を基準に周波数を計算することができます。
数学が苦手な方は難しくてちんぷんかんぷんだと思われるかもしれませんが、例えば C# では Math.Pow() を呼び出せば簡単に計算できますので、「そういうものなんだ」と思って下さい。
この計算を C# の式に置き換えた場合、以下のようになります。
440 * System.Math.Pow(2, x / 12.0)
x には、上記画像中にある「ラ (A4) 基準の半音数の差分」の値が入りますので、この式だけでピアノの鍵盤上にあるすべての音を網羅できます。
(注:上記の表は主に第 4 オクターブの音しか示していませんが、この表が上にも下にもずっと続いているのをイメージして下さい)
おまけ:音楽に詳しい方への補足
上記の数式中の分母である 12 を、1200 に変えることで、「半音」単位から「セント」単位に変えることができます。細かな周波数の調律などへの応用を考えている方にも役に立つと思いますので、この式は覚えておいて損はありません。
実際に矩形波で「ドレミファソラシド」を鳴らす
前説で説明したことをふまえて、実際にコード化してみます。
各音階 (ドレミファソラシド) の矩形波の周期、振幅計算は CalcSquareWave() メソッドとして切り出ししています。
using UnityEngine; public class NewBehaviourScript : MonoBehaviour { const int SampRate = 48000; const int WaveformLen = SampRate * 2; // 2 sec. void Start() { int[] toneFreq = new int[] { (int)(440 * System.Math.Pow(2, -9 / 12.0)), // Do (C4) (int)(440 * System.Math.Pow(2, -7 / 12.0)), // Re (D4) (int)(440 * System.Math.Pow(2, -5 / 12.0)), // Mi (E4) (int)(440 * System.Math.Pow(2, -4 / 12.0)), // Fa (F4) (int)(440 * System.Math.Pow(2, -2 / 12.0)), // So (G4) (int)(440 * System.Math.Pow(2, +0 / 12.0)), // La (A4 = 440 Hz) (int)(440 * System.Math.Pow(2, +2 / 12.0)), // Si (B4) (int)(440 * System.Math.Pow(2, +3 / 12.0)), // Do (C5) }; int toneNum = toneFreq.Length; int toneLen = WaveformLen / toneNum; float[] waveform = new float[WaveformLen]; for (int toneID = 0; toneID < toneNum; toneID++) { for (int sampleInTone = 0; sampleInTone < toneLen; sampleInTone++) { int sample = toneID * toneLen + sampleInTone; waveform[sample] = CalcSquareWave( toneFreq[toneID], SampRate, sampleInTone, toneLen); } } AudioClip audioClip = AudioClip.Create( "TestAudioClip", WaveformLen, 1, SampRate, false); audioClip.SetData(waveform, 0); AudioSource audioSrc = new GameObject("TestAudioSrc").AddComponent<AudioSource>(); audioSrc.clip = audioClip; audioSrc.Play(); } float CalcSquareWave(int freq, int samplingRateHz, int sampleInTone, int toneLen) { int cycle = samplingRateHz / freq; int cycleHalf = cycle / 2; return ((sampleInTone % cycle < cycleHalf) ? 1.0f : -1.0f) * (toneLen - sampleInTone) / toneLen; } }
処理内容の解説
大雑把には、以下のように時間とともに減衰する (音量が小さくなる) 矩形波を、周波数 (=音階) を変えながら 8 回繰り返しています。
ただし、上記はあくまでもイメージで、実際には 1 秒間あたり数百回の速さで振動するため、実際には以下の画像のように 8 回それぞれの中身は「細かすぎて変化している様子が分からない」状態になっています。(サンプル同士の間隔が狭すぎて、あたかも塗りつぶされているように見える状態)
実際のコード上では、前述のとおり CalcSquareWave() メソッドで、この形状の波形の計算を行っています。
float CalcSquareWave(int freq, int samplingRateHz, int sampleInTone, int toneLen) { int cycle = samplingRateHz / freq; int cycleHalf = cycle / 2; return ((sampleInTone % cycle < cycleHalf) ? 1.0f : -1.0f) * (toneLen - sampleInTone) / toneLen; }
上記コードのうち、「時間とともに減衰する (音量が小さくなる)」計算を行っている個所は、(toneLen - sampleInTone) / toneLen を掛け算しているところです。
sampleInTone は、最初のグラフのように 0~11999 (2 [秒] / 8 [音階] = 0.25 [秒] のサンプル範囲に相当) の間で変化し、toneLen は 12000 固定なので、1 から始まって 0 に限りなく近づくまで直線的に減っていきます。
ちなみに、音楽の専門用語では、この掛け算している係数の部分 (音の大きさを変化させている部分) をエンベロープと言います。
エンベロープの形状によって、音の印象がだいぶ変わる (何も変化しなければオルガン風、高速で減衰させればギター風) ので、工夫の余地が色々あって面白いところでもあります。
生成した波形を音声ファイルとして出力する
実は、Unity には、標準では音声ファイルを「出力」する API が用意されていません。
そのため、もし、自前で生成した波形をファイルに出力したい場合は
- アセットストア等で公開されている、他の人が作ったスクリプトを使用する
- ゼロから自分で自作する
等で対応するしかありません。
無圧縮の WAV ファイルなら容易に実装可能
無圧縮の WAV ファイルで OK であれば、ファイルの先頭につけるヘッダー情報もシンプル (最短 44 バイトだけで済む) なため、比較的少ない行数で「自作」可能です。
参考までに、筆者が自作したコード (関数) は以下になります。
(WAV ファイルのヘッダー情報の構成など、中身についての説明は割愛します)
void SaveToWavFile(string path, int samplingRateHz, float[] waveform) { using (var stream = System.IO.File.Open( path, System.IO.FileMode.Create, System.IO.FileAccess.Write)) { using (var writer = new System.IO.BinaryWriter(stream)) { writer.Write(new byte[] { (byte)'R', (byte)'I', (byte)'F', (byte)'F' }); writer.Write(waveform.Length * 2 + 36); // 2 [bytes/sample] + Header Size writer.Write(new byte[] { (byte)'W', (byte)'A', (byte)'V', (byte)'E' }); writer.Write(new byte[] { (byte)'f', (byte)'m', (byte)'t', (byte)' ' }); writer.Write(0x10); // 10 00 00 00 writer.Write((short)0x01); // 01 00 writer.Write((short)0x01); // 01 00 writer.Write(samplingRateHz); writer.Write(samplingRateHz); writer.Write((short)0x02); // 02 00 writer.Write((short)0x10); // 10 00 writer.Write(new byte[] { (byte)'d', (byte)'a', (byte)'t', (byte)'a' }); writer.Write(waveform.Length * 2); // 2 [bytes/sample] foreach (var sample in waveform) { writer.Write((short)(sample * short.MaxValue)); } } } }
関数の呼び出し方は以下です。
Start() メソッドの最後の方に追加すると、実際に動かすことができます。
(例として、プロジェクトの Assets フォルダの直下に DoReMi.wav として保存します)
SaveToWavFile(Application.dataPath + @"\DoReMi.wav", SampRate, waveform);
実行後、Unity Editor を一旦最小化して元に戻すと、Assets フォルダに書き出された DoReMi.wav が認識されるはずです。
また、Windows に標準で搭載されているメディアプレイヤー等でも再生可能です。
(筆者は Windows 11 のメディアプレイヤーで正常に再生できることを確認)
おわりに
直接「ドット」や「ピクセル」として視覚化される画像データと比べて、音声データはあまり直感的ではないので難しいと思います。
しかし、デジタルである以上は、音声の「サンプル」データも「ドット」や「ピクセル」と似ているところも多く、実際に画像処理でもちいられるテクニックの一部は音声処理に適用することも可能です。(どちらも大きな専門分野としてはデジタル信号処理に属しています)
- 応用編に興味のある方は、カテゴリ「信号処理」の方をご確認ください。
ゲームの花形は「画像」の方なので、「音」は裏方に回りやすく、ネット上の情報も (画像に比べると) 少なめですが、本記事が「音については良く分からない」と思っている方へのサウンドプログラミングへの第一歩になっていただけると、筆者としてもうれしいです。
主な更新履歴
- 2023-03-19
- 初版
- 2023-03-29
- カテゴリ「信号処理」へのリンクを追加。