デチューン・LFO 等の実装
OPL3 では F-NUMBER (以下 fnum) 及び BLOCK (以下 blk) という2つの値を使って、以下の式のように出力される音の周波数 $f$ を指定します($n_f$ は fnum の値、$n_b$ は blk の値、$f_M$ はマスタクロック 14.32MHz です)。
\[ n_f = \frac{2^{19} \cdot f}{\frac{f_M}{288} \cdot 2^{n_b - 1}} \]
音楽における音程が対数的に等間隔であるのと同様に、fnum も対数的に等間隔になります。 よって、ドライバを実装するにあたり、音程の変化に関係する機能(デチューンや LFO)で指定する数値をそのまま fnum の変化量としてしまうと、音の高さによってデチューンや LFO の効きが異なってしまうことになります。 この挙動は好ましくないと思ったため、ドライバに対数で音程を計算する処理を追加することにしました。 そして、そのために、半音より細かく対数的に等間隔な音階を導入することにし、その音階では1オクターブを1024段階に分割することに決めました。
音程の計算単位として1オクターブ1024段階の音階を採用したのは、当時を振り返ると、多分、cent の単位では1オクターブ1200段階となるところ、1200に一番近い2の冪が1024だからという理由で適当に決めたように思います。 そして、「1024段階の音程」などと毎度書くのも面倒なので、全く個人的にこの1024段階の音程を cent のもじりで「sent」と呼んでいて、ソースコードのコメント等でも使っています。 この記事でもこれ以降 sent と書きます。
n2kd では v1.0a 時点で以下のような設計となっています。
- LFO やポルタメントの変化量については sent 単位で計算を行う。
- デチューンを fnum に直接作用するものと sent 単位で作用する細かいものの2種類に分ける。
- n2kc の MML では、細かくないデチューンは
D命令で、細かい命令はDF命令でそれぞれ指定します。
- n2kc の MML では、細かくないデチューンは
そのため、LFO などで音程の変化量を指定する際、変化量が 85≃(1024 / 12) 程度になると大体1半音音程が上下するようになっています。 また、デチューンを2つに分けたのは、半音単位の fnum の間隔が1024より小さい場合があるためです。 sent 単位の細かいデチューンだけだと、指定する値によっては発音される音程が変化しない場合が出てきます。 同じ動きをする複数パートに対して違うデチューンを指定して音に厚みを出そうとしたときなどに、この挙動は問題があります。 一方、前述のように fnum 単位のデチューンだけだと音程的に等間隔な変化になりません。 色々考えた結果、処理の簡単さもあり、デチューンを2つに分ける方針に着地しました。
上記の設計に基づき、OPL3 の fnum のレジスタに値を書き込むまでの音程の計算は細かい挙動を除くと以下のように行われます。
- LFO、ポルタメント、細かいデチューンによる sent 単位の音程の変化量 $d$ を計算する。
- $d = 0$ の場合は発音命令から対応する音程の fnum をテーブル引きする。
- $d \neq 0$ の場合……
- 発音命令から対応する音程の sent 単位での位置 $x$ をテーブル引きする。
- sent 単位で $x + d$ を発音すべき音程の位置として fnum に変換する。
- 得られた fnum の値に細かくない方のデチューンの値を加えて、OPL3 に書き込む fnum の値とする。
sent 単位の音程を fnum に変換する部分は、sent の1024段階を32段階×32段階に分割して、各32段階の中での対数的増分を計算する方法を使っています。 1024要素のテーブル引きにすれば増分計算などせず一発ですが、sent は1024段階で、また fnum の表現には2バイト必要なので、単純な実装ではテーブルの大きさは 2KiB にもなります。 n2kd は DOS 用のドライバなのでメモリ節約のために単純なテーブル引きは採用しませんでした。
n2kd の fnum の計算は C から上の C までの間の1オクターブの範囲で行っているので、基準となる低い方の C に対応した fnum と求める fnum をそれぞれ $n_{f_0}$ 及び $n_{f_i} \ (0 \le i \lt 1024)$ とすると、以下のように 32段階×32段階に分けられます。
\begin{align} n_{f_i} &= n_{f_0} \cdot r^i \notag \\ &= n_{f_0} \cdot r^{32a + b} \ (i = 32a + b, 0 \le a,b \lt 32) \notag \\ &= n_{f_0} \cdot r^{32a} \cdot r^b \notag \\ \end{align}
$n_{f_0} \times r^{32a}$ の部分をテーブル $A$ に、$r^b$ の部分をテーブル $B$ に格納しておき、$n_{f_i} = n_{f_{32a+b}} = A[a] \cdot B[b]$ で求めることができます。 メモリ使用量は、実装ではテーブル $A$ と $B$ ともに1要素2バイトになっているので、テーブル部分は合計128バイト、これに加えて追加の計算命令が幾らか必要ですが、2KiB と比べればずっと少ないメモリで済みます。
上述の sent 単位の音程から fnum への変換の計算は整数演算で行っています。 実験してみたところ、事前にテーブルの値をいい感じにシフトしておくなどして、整数演算のみで必要な精度で fnum を求められることが分かったのは幸いでした。 浮動小数点数でやってしまえるなら話が早かったのですが、浮動小数点演算のためにコプロ必須とすると V30 ~ 486SX くらいの世代の PC で動かすのにコプロを用意しなくてはならず、動作環境の準備のハードルが高くなってしまいます。 実際 Nx587 は言わずもがな1 8087 から 80487 までのコプロは自分自身1個も持っていません。 浮動小数点演算を整数命令でエミュレーションするのも、音程の計算は LFO やポルタメントが有効な場合高頻度で行われるので、あまり現実的ではありません。
-
じゃあ Nx586 を持っているのかというと持っていないです。機会があれば使ってみたいですね……。 ↩