新版FM音源プログラム (22)

オリジナルの OPL3 (YMF262) および FM 音源 + ウェーブテーブル 音源である OPL4 (YMF278B) では、FM 合成された音の出力先を CHA/CHB/CHC/CHD の 4 系統の出力チャネルの中から任意の組み合わせで選べるようになっています。
ただし、DAC (YMF262 の場合は専用 DAC YAC512、YMF278B の場合は汎用のステレオ・ディジタル・オーディオ DAC) ひとつ当たり出力は 2 チャネル分しかないので、4 チャネル出力を得るためには DAC を 2 個使いする必要があります。
後継のチップとなる、低電圧版 OPL3 (OPL3-L, YMF289B) では、出力チャネルは 2 チャネルに削減されていて、もとの CHA/CHB がそれぞれ CHL/CHR と改名されています。
OPL4 (YMF278B) のデータシートから FM 音源部のブロック・ダイアグラムを下に引用します。

「DO0」ピンが FM 音源出力専用の (DAC への) ディジタル出力で、CHC/CHD 出力先に相当します。
「DO2」ピンは FM 音源出力とウェーブテーブル音源出力とがディジタル的にミックスされた出力で、FM 音源側では CHA/CHB 出力に相当します。
ブロック・ダイアグラムで「ACCUMULATOR」と名付けられているブロックが、各オペレータ出力を「拾い集め」て「加算」して最終的なサウンド出力を作成している部分です。
FM 音源レジスタとして、アレイ 0/1 空間それぞれにアドレス C0h 〜 C8h の 9 個のレジスタが割り当てられています。

CHA/CHB/CHC/CHD の各ビットがそれぞれの出力チャネルへ出力するかどうかを選択するビットとなっています。
1 チャネルあたり 1 ビットなので、出力するか (ON)、しないか (OFF) の 2 択となっていて、2 チャネル・ステレオとして考えると「出力なし」、「左」、「中央」、「右」の選択肢しかなく、「ステレオ・パンポット」のように任意の位置に「定位」させるようにはなっていません。
前回までの記事では、オペレータ (スロット) 本体の計算だけで、この「アキュムレータ」機能については検討していませんでした。
そこで、今回の記事ではアキュムレータ部分の実現について考えます。
計算結果を「拾い集める」ことは計算そのものには関係しないので、なるべく所要クロック数を減らすために、積和演算命令が豊富な「DSP 拡張」(ARMv7E-M) を持つ Cortex-M4 を主な対象とします。 Cortex-M0 や Cortex-M3 については後で考えることにします。
Cortex-M4 では、基本的な 32 ビット演算のほかに数は多くないのですが、ひとつの 32 ビット・レジスタに複数のデータをパッキングし、同時に演算を行なう、SIMD (Single Instruction Multiple Data) 命令があります。
その中のひとつが、「SMLAD」(Signed MuLtiply and Accumulate Dual) 命令で、ニモニックは

  SMLAD   Rd, Rn, Rm, Ra

と表記されます。
第 2 オペランドレジスタ Rn と第 3 オペランドレジスタ Rm には、それぞれ符号付き 16 ビット整数 (ハーフワード) が 2 個ずつパックされて渡されます。
Rn, Rm の上位ハーフワードどうしの符号付き乗算 (16 ビット × 16 ビット = 32 ビット) と、下位ハーフワードどうしの符号付き乗算 (16 ビット × 16 ビット = 32 ビット) が同時に行なわれ、両者の結果が加算され、さらに第 4 オペランドレジスタ Ra の内容と足されて第 1 オペランドのディスティネーション・レジスタ Rd に格納されます。
R = Rd = Ra として同じレジスタを指定すれば、そのレジスタ R は「アキュムレータ」として機能することになります。 動作を図示すると下のようになります。

オペレータ (スロット) 出力は (符号付きで) 13 ビット幅なので、オペレータ 1 個分の出力を 16 ビットのハーフワード中に格納することができます。、
オペレータ (スロット) 0 の出力と、オペレータ 1 の出力とを 32 ビット・データにパックしておけば、SMLAD 命令ひとつの実行でオペレータ出力 2 つ分を「拾い集める」ことができます。
オペレータ出力と乗算される数を「0」とすれば「接続なし」となり、乗算される数を「1」とすれば「接続あり」となりますが、乗算結果は 32 ビット幅で保持されるので、乗算される数を 8 ビット程度の数とすれば、「パンポット」も実現できます。
もちろん、すべてのオペレータ出力を加算した後で結果を補正する必要があります。
オペレータのプロパティの構造体「op_prop[]」中に下のように L_vol / R_vol メンバを定義します。

オペレータ出力 0/1 も上の図のように「パック」します。
Cortex-M4DSP 拡張命令は CMSIS のイントリンシック関数 (intrinsics) として定義されているので、アセンブリ言語で表現しなくても C プログラムとして記述できます。
「アキュムレータ」機能のみをループにしたものを下に示します。

#define LR_VOL_SHIFT (7)

int32_t acc_slot(op_prop_t *o_p, int num_slot)
{
  int32_t  acc_L;   // L ch accumulator (32 bit)
  int32_t  acc_R;   // R ch accumulator (32 bit)
  uint32_t L_vol;   // op0_vol:op1_vol
  uint32_t R_vol;   // op0_vol:op1_vol
  uint32_t op01_out;// op0_out:op1_out

  acc_L = 0;
  acc_R = 0;
  do {
    op01_out = o_p[1].op_out;
    L_vol    = o_p[1].L_vol;
    R_vol    = o_p[1].R_vol;
    acc_L = __SMLAD(op01_out, L_vol, acc_L);
    acc_R = __SMLAD(op01_out, R_vol, acc_R);
    o_p += 2;      // point to next slot pair
    num_slot -= 2; // decrement loop counter
  } while (num_slot);
  acc_L = __SSAT_ASR(acc_L, 16, LR_VOL_SHIFT);
  acc_R = __SSAT_ASR(acc_R, 16, LR_VOL_SHIFT);
  return( __PKHBT(acc_R, acc_L, 16));
} // int32_t acc_slot()

「PKHBT」は、ふたつの「ハーフワード」をひとつの「ワード」にパックする命令で、DSP 拡張のない Cortem-M3 の「BFI」(Bit Field Insert) 命令でも同様のことができますが、イントリンシックとしては定義されていないので、__PKHBT() 関数を使っています。
「SSAT」(Signed SATulate) は、指定のビット幅におさまるように (符号付き) 「飽和演算」する命令です。
たとえば、16 ビット幅を指定した場合には、-32768 より小さい値は -32768 に置き換え、+32767 より大きい値は +32767 に置き換えます。
「シフト付きオペランド」を取れるので、シフトと併用することができますが、イントリンシック関数の __SSAT() ではシフトのない場合しかサポートしていないので、__SSAT_ASR() という関数を下のように定義して使っています。

#if defined(__CC_ARM) // armcc

  #define __SSAT_ASR(reg,imm,asr) __SSAT(((reg) >> (asr)),(imm))

#elif defined(__GNUC__) // gcc

  #define __SSAT_ASR(ARG1,ARG2,ARG3) \
  ({                          \
    uint32_t __RES, __ARG1 = (ARG1); \
    __ASM ("ssat %0, %1, %2, asr %3" : "=r" (__RES) :  "I" (ARG2), "r" (__ARG1), "I" (ARG3) ); \
    __RES; \
   })

#endif

armcc では、__SSAT() の引数にシフトの記述を加えれば SSAT のシフト・オペランドとして使ってくれるのに対し、gcc では、そのままでは独立したシフト命令にコンパイルされるので、__SSAT_ASR() というインライン関数を定義しています。
armcc でコンパイルした結果を逆アセンブルしたものを下に示します。

08000fb4 <acc_slot>:
 8000fb4:   b570         push    {r4, r5, r6, lr}
 8000fb6:   2200         movs    r2, #0
 8000fb8:   ea4f 0302    mov.w   r3, r2
 8000fbc:   e9d0 6510    ldrd    r6, r5, [r0, #64] ; 0x40
 8000fc0:   6b84         ldr     r4, [r0, #56]   ; 0x38
 8000fc2:   fb24 2206    smlad   r2, r4, r6, r2
 8000fc6:   fb24 3305    smlad   r3, r4, r5, r3
 8000fca:   3048         adds    r0, #72         ; 0x48
 8000fcc:   1e89         subs    r1, r1, #2
 8000fce:   d1f5         bne.n   8000fbc <acc_slot+0x8>
 8000fd0:   f322 10cf    ssat    r0, #15, r2, asr #7
 8000fd4:   f323 11cf    ssat    r1, #15, r3, asr #7
 8000fd8:   eac1 4000    pkhbt   r0, r1, r0, lsl #16
 8000fdc:   bd70         pop     {r4, r5, r6, pc}

SSAT 命令の逆アセンブル結果で、出力ビット幅のオペランドが「#15」と表示されていますが、これは命令にエンコードされているフィールドの値 (0 〜 31 の範囲) がそのまま出力されているもので、アセンブリ言語のソースとしては「#16」と指定する必要があります。
ldrd 命令でレジスタ 2 つに値をロードしていますが、続く ldr とはパイプライン処理化されないので、レジスタ 3 つ分のロードを ldrd + ldr で実行する場合には 3 + 2 = 5 サイクル必要になります。
普通に連続した ldr 3 個で実行する場合には、2 + 1 + 1 = 4 サイクルとなり、それに比べると、このコンパイル結果では 1 サイクル損しています。
ループ 1 回あたりの実行サイクル数は実測で 11 サイクルとなり、計算上の

ldrd — 3
ldr — 2
smlad — 1
smlad — 1
adds — 1
subs — 1
bne — 2

の和 11 サイクルと一致しています。