音楽用シンセサイザーや、デジタル無線通信の発振源などに使われるダイレクト・デジタル・シンセサイザー (DDS) あるいは数値制御発振器 (NCO) のアルゴリズムをもちいて、単一の音源 (ピアノ等の単音波形) から異なる音階 (周波数) を合成する方法とその原理を説明していきます。
サウンドプログラミングに興味のあるゲーム開発者を主要読者に想定していますが、DDS/NCO について理解を深めたいと考えている電子回路設計者の方にも役に立つと思います。
目次
- まえがき (対象読者など)
- 事前準備 - Unity プロジェクト環境
- 事前準備 - 使用素材
- 最初の例 - ピアノの単音から「ドレミファソラシド」を合成
- DDS/NCO 方式によるソフトウェア音源の原理
- 応用 - 「猫ふんじゃった」を演奏する
- 無線通信など高周波系 (RF) 分野で使われる NCOM との関係ついて
- おわりに
まえがき (対象読者など)
本記事は、「ゲーム開発」カテゴリで公開している「Unity の C# Script で「無」から音を産み出す方法」の直接の応用編です。
サウンドプログラミングに興味があり、例えばソフト音源を自前で作って、自作ゲームに搭載したいと考えている開発者を主要読者として想定しています。
Unity 以外の環境でも使えるデジタル信号処理 (音声処理) を主体とした内容なので、記事カテゴリも「信号処理」にしました。
また、本記事で紹介する DDS/NCO は、オーディオ以外の分野 (特に無線通信などデジタル変復調を必要とする分野) でも使われる波形合成技術ですので、その点についても補足します。
Unity や C# の経験が無くてもソースコードが読めるよう、平易な文法を使うなどの配慮をしていますが、最低限、何らかのプログラミング経験があることが前提です。
事前準備 - Unity プロジェクト環境
別記事「Unity でプログラミングのみ (C# Script のみ) でゲームを作る方法」の C# Script の作成と Main Camera への割当まで済ませることで、本記事の実装例 (C# Script) を Unity で実行できる状態になります。
また、前述のとおり、もう 1 つの記事「Unity の C# Script で「無」から音を産み出す方法」をひととおり試していただくと、本記事の内容を理解しやすくなると思います。
事前準備 - 使用素材
本記事の C# Script では、以下の音声ファイル (ピアノの「ラ」の音) を読み込んで使用しますので、実際に動かしてみたい方はダウンロードして下さい。
- Piano_A4.wav (88,244 bytes)
- フリーのソフト MIDI 音源「TiMidity」のピアノの A4 (440 Hz) を録音したものです。
また、処理内容の説明時に、以下のテキストファイル (上記 wav ファイルに含まれるサンプルデータを出力したもの) も使用します。
- Piano_A4.txt (525,970 bytes)
wav ファイルの保存場所について
音声ファイル (Piano_A4.wav) は、Unity プロジェクトの Assets → Resources の中に置いて下さい。(Resources フォルダは別途作成する必要があります)
テキストファイル (Piano_A4.txt) は、説明用に使用するだけですので、場所は任意です。
最初の例 - ピアノの単音から「ドレミファソラシド」を合成
理論面や処理の解説は後回しにして、まずは実際に単音波形から音階を合成して鳴らすコードを先に紹介します。
以下のスクリプトを事前準備で用意した C# Script に貼り付けることで、実行して音が鳴るのを確かめられます。
using UnityEngine; public class NewBehaviourScript : MonoBehaviour { const int SampRate = 44100; // 音楽 CD と同じサンプリングレート const int WaveToneLen = SampRate * 2; // 2 秒 const int WaveBGMLen = SampRate * 8; // 8 秒 const double OriginalToneHz = 440; // 入力音源は A4 (440 Hz) float[] _waveTone = new float[WaveToneLen]; float[] _waveBGM = new float[WaveBGMLen]; void Start() { AudioClip clipTone = Resources.Load<AudioClip>("Piano_A4"); clipTone.GetData(_waveTone, 0); // ↓↓↓ 楽譜定義 ここから ↓↓↓ SetTone(1, (int)(SampRate * 0.0), (int)(SampRate * 0.5), -9); SetTone(1, (int)(SampRate * 0.5), (int)(SampRate * 0.5), -7); SetTone(1, (int)(SampRate * 1.0), (int)(SampRate * 0.5), -5); SetTone(1, (int)(SampRate * 1.5), (int)(SampRate * 0.5), -4); SetTone(1, (int)(SampRate * 2.0), (int)(SampRate * 0.5), -2); SetTone(1, (int)(SampRate * 2.5), (int)(SampRate * 0.5), 0); SetTone(1, (int)(SampRate * 3.0), (int)(SampRate * 0.5), 2); SetTone(1, (int)(SampRate * 3.5), (int)(SampRate * 0.5), 3); // ↑↑↑ 楽譜定義 ここまで ↑↑↑ AudioClip clipBGM = AudioClip.Create( "clipBGM", WaveBGMLen, 1, SampRate, false); clipBGM.SetData(_waveBGM, 0); AudioSource audioSrc = new GameObject("音楽再生用").AddComponent<AudioSource>(); audioSrc.clip = clipBGM; audioSrc.Play(); } // DDS/NCO 方式で音声合成する関数 void SetTone(float volume, int sampleStart, int sampleLen, int toneKeyID) { // 指定されたキー番号の音階の周波数を計算 (平均律) double targetToneHz = 440 * System.Math.Pow(2, toneKeyID / 12.0); // 再生したい周波数と、オリジナル音源の周波数から、再生速度を計算 double tonePlaySpeed = targetToneHz / OriginalToneHz; // 指定された音量、開始位置、長さ、再生速度で音声波形を合成 for (int sampOffset = 0; sampOffset < sampleLen; sampOffset++) { int sampInTone = (int)(sampOffset * tonePlaySpeed); if (sampInTone < WaveToneLen) // ← 音源の終端に達したら無音化 { // 2 和音以上にも対応できるよう、「足し算」で処理する。 _waveBGM[sampleStart + sampOffset] += volume * _waveTone[sampInTone]; } } } }
DDS/NCO 方式によるソフトウェア音源の原理
本記事の肝となる部分が、上記ソースコードの SetTone() 関数です。それぞれの SetTone() 呼び出しが個々の「音符」になっているイメージです。
この SetTone() の中身が、これから説明するダイレクト・デジタル・シンセサイザー (DDS) あるいは数値制御発振器 (NCO) と呼ばれる仕組みで、異なる周波数 (音階) を合成しています。
周波数を変える = 再生速度を変える
DDS/NCO の特徴は、任意のデジタル波形 (例:ピアノの「ラ」の音) を、再生速度を変えて再生することで、異なる周波数 (音階) の波形を出力できることです。
通常は、DDS も NCO も電子回路 (デジタル回路) のことを指しますが、ソフトウェア音源という言葉があることからも分かるとおり、C# などのプログラムとして実装することもできます。
上記の例だと、真ん中の緑色の波形がオリジナル (合成元) とした場合、半速なら 1 オクターブ低い音 (220 Hz) になり、倍速なら 1 オクターブ高い音 (880 Hz)になります。
つまり、原理的には、音楽や動画などを早回しで再生したり、ゆっくり再生することで、音声の高さを上下させるのと同じです。
なお、再生速度を変えるため、長さ (再生時間) が変わってしまう欠点もあります。
対処方法としては、ピアノやギターように減衰がある音源の場合は複数音域分の音源 (例:低音域用、中音域用、高音域用) を用意したり、オルガンやトランペットのように「ずっと鳴り続ける」タイプの音源であればループ再生で処置することになります。
再生速度を半速・倍速にするには
例として、今回使用するピアノの「ラ」の波形 (Piano_A4.txt) の場合は、以下のようなアルゴリズムで半速・倍速化を行います。
(注:Unity の内部処理上は、下記の各サンプルの振幅値が 32768 で除算されて ±1 に収まるようにスケーリングされます)
- 555, 555, 4693, 4693, 8373, 8373, … のように、同じサンプルを 2 回繰り返せば、再生速度が半分になる。(= 1 オクターブ低い 220 Hz の「ラ」の音になる)
- 555, 8373, 12139, … のように、1 サンプルずつ間引けば、再生速度が倍速になる。(= 1 オクターブ高い 880 Hz の「ラ」の音になる)
ただし、上記はあくまでも「最も単純な方法」です。
(画像の拡大・縮小処理における「ニアレストネイバー法」に類似した方法です)
もう少し処理内容を工夫すると、(計算コストを犠牲に) 音質を強化することもできます。例えば 220 Hz の「ラ」の音を得るときに
- 555, (555 + 4693) / 2, 4693, (4693 + 8373) / 2, 8373, …
のように線形補間する方法 (画像の拡大・縮小における「バイリニア法」に相当) でも、比較的少ない計算コストで音質を上げることができます。
(さらに音質にこだわるのなら、Sinc 関数等で作ったLPF (ローパスフィルター) 係数で補間する方法もありますが、信号処理的に難しい内容になりますので、本記事では割愛します)
再生速度をさらに細かく制御するには
半速、倍速ができるだけだと、1 オクターブ単位でしか制御できませんので、「ラ」の音であることには変わりません。
他の音階も含めて、あらゆる周波数で汎用的に鳴らすためには、より細かな周波数比を意識して、同じサンプルを繰り返したり、逆に間引いたりする間隔を調整する必要があります。
具体的に、「ラ」の音源 (440 Hz) から、「ド」の音を合成 (約 262 Hz) する場合の例を見てみましょう。
- ポイントは、「ド」と「ラ」の周波数比が約 0.595 (= 262 / 440) であることです。
つまり、参照するサンプル番号を 0.595 ずつ増やしていき、切り捨てた番号位置のサンプル値を採用すれば、「ド」の高さに相当する周波数の波形が得られます。
もちろん、より真面目に処理するなら、端数の値も使用してバイリニアや LPF 等で補間する方法もありますが、本記事では最も単純に処理できる切り捨て法で実装しています。
補足 1:DDS と NCO の違いについて
厳密には、DDS とは、NCO と DAC (デジタル・アナログ変換器) を組み合わせたものです。
(さらに言ってしまえば、DDS はアナログ出力を安定化するためのシステムも含みます)
端的に言えば、スピーカーやヘッドホンなども含めれば DDS、含めなければ NCO です。
本記事の内容は、デジタル処理の部分だけですので、厳密には NCO の方が正確です。
また、DDS も NCO も、但し書きが無ければ「任意周波数の正弦波を出力する」システムを指すことが多いため、「任意波形を出力する NCO」と表現するのが最も正確です。
補足 2:デジタル回路 (IC, FPGA, ASIC 等) で実装する場合
各種 IC や、FPGA, ASIC 等のロジック回路として実装する場合は、カウンター回路 (専門的には位相アキュムレーターと呼ばれます) で波形メモリ (RAM/ROM) のアドレスをカウントアップする速度を調整することで、周波数の変更を実現しています。
- 参考 : DDS(ディジタル直接合成発振器) - 株式会社エヌエフ回路設計ブロック
- ちなみに、この記事に書かれている「ファンクションジェネレーター」とは、例えばファミコン音源にも搭載されている「矩形波」や「三角波」など、比較的シンプルな関数で表すことのできる波形を電気的に出力する装置のことです。
NCO にあたる部分は、波形メモリとカウンターだけのシンプルな組み合わせだけで実装できることもあり、実際に音楽用のシンセサイザーや無線通信機器などで周波数の異なる任意波形 (正弦波も含む) を発生させる必要がある事例に応用されています。
各音階 (ド、レ、ミ…) の周波数の計算方法について
別記事「Unity の C# Script で「無」から音を産み出す方法」の説明のとおり、440 Hz の「ラ」の音 (A4) からの半音数の差分 (本記事では「キー番号」) が分かれば、各音階の周波数を計算できます。
今回のソースコードでは、以下の個所で周波数計算を行っています。
// 指定されたキー番号の音階の周波数を計算 (平均律) double targetToneHz = 440 * System.Math.Pow(2, toneKeyID / 12.0);
「ド、レ、ミ、ファ、ソ、ラ、シ、ド」を順番に鳴らすためには、以下の楽譜 (ピアノロール) のように、toneKeyID = -9, -7, -5, -4, -2, 0, 2, 3 の順で鳴らせば OK です。
各音符の「黒」は鳴り始めのタイミングで、「グレー」は継続の意味です。
(なぜこういうルールにしたのかは、次の「猫ふんじゃった」の譜面からお察しください…)
ソースコードでは、以下の個所に相当します。
(0.5 秒間隔で順番に配置しているところもポイントです)
// ↓↓↓ 楽譜定義 ここから ↓↓↓ SetTone(1, (int)(SampRate * 0.0), (int)(SampRate * 0.5), -9); SetTone(1, (int)(SampRate * 0.5), (int)(SampRate * 0.5), -7); SetTone(1, (int)(SampRate * 1.0), (int)(SampRate * 0.5), -5); SetTone(1, (int)(SampRate * 1.5), (int)(SampRate * 0.5), -4); SetTone(1, (int)(SampRate * 2.0), (int)(SampRate * 0.5), -2); SetTone(1, (int)(SampRate * 2.5), (int)(SampRate * 0.5), 0); SetTone(1, (int)(SampRate * 3.0), (int)(SampRate * 0.5), 2); SetTone(1, (int)(SampRate * 3.5), (int)(SampRate * 0.5), 3); // ↑↑↑ 楽譜定義 ここまで ↑↑↑
応用 - 「猫ふんじゃった」を演奏する
ここまで来れば、既に「猫ふんじゃった」を演奏することはできるようになったも同然です。
なぜならば、「ド、レ、ミ、ファ、ソ、ラ、シ、ド」というシーケンス情報 (=楽譜) を、「猫ふんじゃった」の譜面に合わせて変えてあげれば OK だからです。
「猫ふんじゃった」の楽譜 (今回のソースコードのキー番号に対応させたもの) は以下のとおりです。
つまり、上記を見ながら「楽譜定義」の部分を差し替えるだけです。
// ↓↓↓ 楽譜定義 ここから ↓↓↓ SetTone(0.5f, (int)(SampRate * 0.0), (int)(SampRate * 0.25), -6); SetTone(0.5f, (int)(SampRate * 0.25), (int)(SampRate * 0.25), -8); SetTone(0.5f, (int)(SampRate * 0.5), (int)(SampRate * 0.5), -15); SetTone(0.5f, (int)(SampRate * 1.0), (int)(SampRate * 0.5), -11); SetTone(0.5f, (int)(SampRate * 1.0), (int)(SampRate * 0.5), -3); SetTone(0.5f, (int)(SampRate * 1.5), (int)(SampRate * 0.5), -11); SetTone(0.5f, (int)(SampRate * 1.5), (int)(SampRate * 0.5), -3); SetTone(0.5f, (int)(SampRate * 2.0), (int)(SampRate * 0.25), -6); SetTone(0.5f, (int)(SampRate * 2.25), (int)(SampRate * 0.25), -8); SetTone(0.5f, (int)(SampRate * 2.5), (int)(SampRate * 0.5), -15); SetTone(0.5f, (int)(SampRate * 3.0), (int)(SampRate * 0.5), -11); SetTone(0.5f, (int)(SampRate * 3.0), (int)(SampRate * 0.5), -3); SetTone(0.5f, (int)(SampRate * 3.5), (int)(SampRate * 0.5), -11); SetTone(0.5f, (int)(SampRate * 3.5), (int)(SampRate * 0.5), -3); SetTone(0.5f, (int)(SampRate * 4.0), (int)(SampRate * 0.25), -6); SetTone(0.5f, (int)(SampRate * 4.25), (int)(SampRate * 0.25), -8); SetTone(0.5f, (int)(SampRate * 4.5), (int)(SampRate * 0.5), -15); SetTone(0.5f, (int)(SampRate * 5.0), (int)(SampRate * 0.5), -11); SetTone(0.5f, (int)(SampRate * 5.0), (int)(SampRate * 0.5), -3); SetTone(0.5f, (int)(SampRate * 5.5), (int)(SampRate * 0.5), -18); SetTone(0.5f, (int)(SampRate * 6.0), (int)(SampRate * 0.5), -11); SetTone(0.5f, (int)(SampRate * 6.0), (int)(SampRate * 0.5), -3); SetTone(0.5f, (int)(SampRate * 6.5), (int)(SampRate * 0.5), -20); SetTone(0.5f, (int)(SampRate * 7.0), (int)(SampRate * 0.5), -10); SetTone(0.5f, (int)(SampRate * 7.0), (int)(SampRate * 0.5), -4); SetTone(0.5f, (int)(SampRate * 7.5), (int)(SampRate * 0.5), -10); SetTone(0.5f, (int)(SampRate * 7.5), (int)(SampRate * 0.5), -4); // ↑↑↑ 楽譜定義 ここまで ↑↑↑
実際に差し替えてみて、「猫ふんじゃった」の音楽が流れることが確認できれば成功です。
他にも、好きな音楽の楽譜を持っているのであれば、色々と試してみるのも面白いでしょう。
「和音」を鳴らす場合の補足と注意点について
今回の「猫ふんじゃった」の楽譜には、2 和音を同時に鳴らす個所があります。
和音の鳴らし方そのものは、2 つの (異なる周波数の) 波形を単純に足し算するだけなので、特別難しいことはありません。
なので、ソースコード上は += で処理すれば和音が鳴らせる状態になります。
// 2 和音以上にも対応できるよう、「足し算」で処理する。 _waveBGM[sampleStart + sampOffset] += volume * _waveTone[sampInTone];
しかし、2 つ以上の和音が重なった場合に、「振幅が大きくなる」ことに注意が必要です。
Unity では、波形の振幅を ±1 に収める必要があります (±1 を超えると、打ち切りにより「歪んだ状態」になる) ので、1 つ 1 つの音符の音量を 0.5 倍にすることで、2 和音重なったときに振幅が ±1 を超えないように予防しています。
ただし、実際のところは、多少歪んだ程度では人間の耳には分からないことも多いですし、同時に鳴らす和音数が多いほど (互いに周期が異なるため) 振幅の和も分散しやすくなるので、実際には「1 / 和音数」よりも大きめの音量に設定することもあります。
無線通信など高周波系 (RF) 分野で使われる NCOM との関係ついて
(本節はオーディオ系とは一切関係がありませんので、興味のない方は読み飛ばして下さい)
NCOM という言葉がエンジニアの口から出てきた場合、おそらくそのエンジニアは、ほぼ無線系のエンジニアだと思って間違いないでしょう。
NCO ほどは一般的な概念ではありませんが、NCOM とは Numerically Controlled Oscillator/Modulator の略で、NCO の正弦波出力を、さらに入力された波形データに対して乗算 (=変調・復調) する機能もセットにしたものを指します。
大雑把に絵であらわすと、以下のようなイメージです。
実際に IC チップとして製品化されている例として、以下のようなものがあります。
詳細な説明は、数学的に難しくなる (三角関数の積和公式や、オイラーの公式に対する理解が必要) ため割愛しますが、正弦波が持つ「入力された信号の周波数を移動することができる性質」を利用したシステムが NCOM です。
例えば 100 MHz の正弦波を乗算した場合は、実数信号であれば ±100 MHz の両方にシフト (それぞれの振幅は半分 = -6 dB になる) しますし、複素数信号 (いわゆる IQ 信号) であれば +100 MHz の方向にのみシフトすることができます。
ちなみに、上記の HSP45116A は、複素数信号用の IC チップになっているため、Cos 波と Sin 波の両方を生成して複素乗算するタイプの NCOM です。
おわりに
別記事「Unity の C# Script で「無」から音を産み出す方法」と同様、なるべく難しい数式を使わずに、具体例を挙げて音声合成 (異なる音階 = 周波数の波形を合成) する方法を説明しました。(補足関連では数学用語を結構出してしまいましたが…)
また、本記事での説明のとおり、DDS/NCO 自体がオーディオ以外の分野 (特に無線通信分野) にも応用されているため、そちらの分野の方にもなるべくイメージがしやすいように心がけたつもりです。
しかし、現時点では、まだ図が少なめで、文章量が多いため、まだまだ改善すべき点はあると感じています。
本記事が、実際にソフトウェア音源を自作したいと考えているゲームプログラマーの方や、一般的に難しい数式だらけで説明されることの多い DDS/NCO の波形合成方法の原理をイメージ的に理解したいと考えているエンジニアの方にとって、役に立っていただければ幸いです。