製作モジュール一覧

2026年8月1日土曜日

NuVCOを11 Voice化したら波形が崩れた ― リングバッファとFPUを過信した話

前回の記事「NuVCO開発スタート ― STM32でデジタルVCOを作る」では、NuVCOを作り始めた経緯と、STM32G431、PCM5102Aを使ってデジタルVCOとして音を出すところまでを紹介しました。

今回は、その後にVoice数を増やしていったときの話です。

NuVCOでは、CPUで複数サンプルをまとめて計算し、DMAを使ってPCM5102Aへ連続転送しています。さらにSTM32G431にはFPUがあるため、サイン波も波形テーブルを使わず、sinf()でリアルタイムに計算していました。

この構成なら、かなり余裕があると思っていました。

実際、9 Voiceまでは動きました。しかし、サイン波を11 Voiceにすると波形が崩れ、POTにも反応しなくなりました。

1サンプルずつ処理する方法から、リングバッファへ


マイコンで波形を作ること自体は、今回が初めてではありません。

10年程前にはPSoCを使い、整数演算によるDDSを実装していました。位相を整数で加算し、その上位ビットを使って波形テーブルを参照する、よくある方式です。タイマー割り込みのたびに次の1サンプルを計算し、DACへ出力する。これまでの私にとって、マイコンで波形を作るとは、おおむねそのようなものでした。

PCM5102Aも今回初めて使ったわけではなく、以前から何度か使用しています。I2Sでデータを送れば、比較的簡単にきれいな音が出せる、使い慣れたオーディオDACです。

ただし、今回のNuVCOでは、これまでと少し違う方法を試しました。

従来のように、1サンプルを計算するたびにCPUが出力処理まで行うのではなく、複数のサンプルをバッファへまとめて生成し、そのバッファをDMAでPCM5102Aへ転送する方法です。

CPUで複数サンプルをまとめて計算し、DMAにまとめて送らせる。この構成を自分で実装するのは、今回が初めてでした。


NuVCO Proto-Aとブレッドボードによる実験風景。左側は測定に使用したAnalog Discovery 2です。

DMAが送っている間に、次の音を作る


現在のNuVCOでは、CPUが生成した音声データを、1サンプルずつPCM5102Aへ送り出しているわけではありません。

CPUは、これから再生する128 stereo frames分のデータを、まとめて音声バッファへ書き込みます。1 stereo frameは、左チャンネルと右チャンネルのサンプルを組み合わせたものです。

実際のI2S転送はDMAが担当します。DMAはCPUとは別にバッファからデータを読み出し、PCM5102Aへ順番に送り続けます。

音声バッファ全体には256 stereo frames分の領域があり、前半と後半の各128 stereo framesに分かれています。

  • DMAが前半を送信している間、CPUは次に必要なデータを準備する
  • 前半の送信が終わると、DMAは後半へ移る
  • CPUは、送信の終わった前半へ新しいデータを書き込む
  • 後半の送信が終わると、DMAは再び前半へ戻る
  • CPUは、送信の終わった後半へ新しいデータを書き込む

この動作を繰り返すことで、PCM5102Aへの出力を止めることなく、次の音声データを作り続けられます。

概念的にはリングバッファです。実装をもう少し正確に表すなら、Circular ModeのDMAでバッファの前半と後半を繰り返し使用する、ダブルバッファ相当の構成になっています。


DMAが一方の領域を送信している間に、CPUが空いた領域へ次の128 stereo framesを書き込みます。

NuVCOのサンプルレートは48kHzです。

1サンプルずつ処理する場合、約20.8マイクロ秒ごとに次のデータを用意し、その都度転送処理を行うことになります。

1 ÷ 48,000 ≒ 20.8マイクロ秒

一方、現在のNuVCOは、1回のコールバックで128 stereo framesをまとめて生成します。そのため、CPUは次の128 framesを約2.667ミリ秒以内に用意すればよいことになります。

128 ÷ 48,000 ≒ 2.667ミリ秒

もちろん、必要な波形サンプルの総数が減るわけではありません。48kHzであれば、最終的には1秒間に48,000 frames分を計算する必要があります。

それでも、I2Sへの細かな転送処理をDMAに任せ、128 frames単位でまとめて生成することで、CPUが転送のたびに呼び出される負担を減らせます。

CPUクロックそのものが速くなったわけではありませんが、波形生成へ使えるCPUサイクルには余裕ができました。感覚的には、リングバッファとDMAによって処理時間を稼いだ、という表現が近いと思います。

ここまでは、かなりうまくいきました。

マイコンで浮動小数点数を使うことへの抵抗


整数DDSは以前から使ってきましたが、マイコンのリアルタイム処理で浮動小数点数を多用することには、今でも少し抵抗があります。

浮動小数点演算は重い。マイコンでは、できるだけ整数演算や固定小数点演算を使う。そのような考え方が身についているからです。

ただし、NuVCOで使用しているSTM32G431は170MHzで動作し、単精度浮動小数点演算をハードウェアで処理するFPUを搭載しています。

余談ですが、私が最初に買ったDOS/V機はGateway2000のIntel 486DX / 100MHzでした。それまで使っていたNEC PC-9801 386 + FPU(コプロセッサ)よりずいぶん速く天国のように思えた記憶があります。

これだけの性能があり、しかもFPUまで載っているのなら、昔ながらの整数DDSにしなくても、浮動小数点数を使った素直な実装でかなりのところまで行けるのではないか。

そう考えました。

そこでNuVCOのサイン波は、最初から波形テーブルを参照するのではなく、浮動小数点数で位相を扱い、サンプルを生成するたびにsinf()で値を求めていました。

もちろん、FPUにサイン関数を直接計算する命令があるわけではありません。sinf()の内部では、数学ライブラリによるいくつもの演算が行われます。

それでもSTM32G431では、数Voice程度なら、48kHzのリアルタイム処理中に毎回sinf()を呼んでも実際に問題なく音が出ました。

一般的な小規模マイコンで同じことをすれば、かなり厳しいはずです。FPU付きのSTM32G431だからこそ可能になった、少々ぜいたくな実装です。

10年以上前から整数DDSを使ってきた身としては、波形テーブルを用意せず、その場で浮動小数点数のサイン波を計算しても普通に鳴ることには、少し感心しました。

DMAと音声バッファで転送処理を効率化し、FPUを使って波形をリアルタイムに計算する。

これならVoice数をさらに増やしても動くのではないか。

どこまで動くかとにかくやってみよう!という気持ちになりました。

最大11 Voiceまで増やしてみる


NuVCOにはDetune機能があり、少しずつ周波数をずらした複数のVoiceを重ねて出力できます。

当初は3 Voiceでしたが、その後、1、2、3、5、7、9、11 Voiceを切り替えられるように拡張しました。

Voice数を増やせば音の厚みも増します。その一方で、1 frameを生成するために必要な波形計算の回数も増えていきます。

1 Voiceなら、1 frameにつき1 Voice分です。11 Voiceなら、同じ時間内に11 Voice分の波形を計算して加算しなければなりません。

三角波、ノコギリ波、矩形波は、11 Voiceでも動作しました。サイン波も9 Voiceまでは動きました。

STM32G431は、思っていた以上に頑張ってくれます。


421Hz、Saw 100%、Voice数:9、Detune:+001で正常に動作している出力波形。わずかに周波数をずらした9本のノコギリ波を重ねているため、単独のノコギリ波とは異なる細かな段差が見えます。

ところが、サイン波を11 Voiceに切り替えると、様子がおかしくなりました。

オシロスコープに表示される波形が崩れ、各POTを回しても正常に反応しなくなります。

単に音が少し悪くなった、という程度ではありません。音声出力だけでなく、NuVCO全体の操作までまともに動かなくなりました。

最初は、11 Voice分の加算で振幅が大きくなりすぎたのか、どこかの変数がオーバーフローしたのか、それともDMAの使い方に問題があるのかと考えました。

しかし、サイン波以外は11 Voiceでも動きます。サイン波も9 Voiceまでは動きます。

サイン波だけが、11 Voiceにしたときに破綻する。

そうなると、怪しいのはsinf()の計算時間です。

2.667ミリ秒は「余裕」ではなく「締切」だった


リングバッファとDMAを使ったことで、1サンプルごとに転送処理を行う場合よりもCPU時間には余裕ができました。

しかし、CPUが好きなときに、好きなだけ時間をかけて音声データを作れるわけではありません。

DMAが現在の128 framesを送り終えるまでに、CPUは次の128 framesを完成させておく必要があります。

その時間が約2.667ミリ秒です。

もし計算が2.667ミリ秒を超えれば、DMAが次に送るべき音声データの準備が間に合いません。再生がこちらの計算を待ってくれることもありません。

DMAによって締切がなくなったのではなく、128 framesごとに約2.667ミリ秒という、はっきりした締切ができたわけです。

さらに、音声データの生成はDMAのHalf CompleteやFull Completeのコールバックから呼び出されています。ここでCPUを長時間占有すれば、メインループ側の処理も実行されにくくなります。

音声処理が間に合わなくなった結果、POTの読み取りや操作への反応まで止まったように見えたのではないか。

この時点では、まだ推測です。

FPUがあるから大丈夫だろう、という見込みは、完全に外れたわけではありません。数Voiceなら、本当にリアルタイムでサイン波を計算できた。

リングバッファとDMAによる効率化も、きちんと効果がありました。

しかし、どこまでも行けるわけではなかった。

リングバッファとDMAによって稼いだはずのCPU時間を、11 Voice分のsinf()が使い切ってしまった可能性があります。

では、実際にはどのくらい時間がかかっていたのでしょうか。

正常に動いた9 Voiceと、破綻した11 Voiceの間には、どのくらいの差があったのでしょうか。

次は感覚や予想ではなく、STM32G431のサイクルカウンタを使って、音声バッファの生成にかかる時間を実際に測ってみることにします。

 参考リンク