NuVCO 11 Voice Sineはなぜ壊れたのか ― Cycle Counterで処理時間を測る
前回は、STM32G431でNuVCOを11 Voice化したとき、Sineだけ波形が破綻した話を書きました。
前回の記事:
https://dad8893.blogspot.com/2026/08/nuvco11-voice-fpu.html
Triangle、Saw、Squareは11 Voiceでも動くのに、Sineだけは9 Voiceまでは正常で、11 Voiceにすると波形が崩れます。
当初はSineの生成にC標準ライブラリの sinf() を使っていました。
static float AppWaveform_GenerateSine(float phase)
{
return sinf(phase * two_pi);
}
11 Voiceでは、この sinf() の計算が間に合っていないのではないか。
前回の記事ではそこまで書きました。
sinf() が重いならLUT化すれば良い、というのはすぐ思いつきます。しかし今回は、まずCycle Counterで実際の処理時間を測り、コンパイラの最適化でどこまで行けるか試しました。
Release相当の最適化を使うと一度は11 Voice Sineも正常に動作します。しかし、その後機能を追加していくと再び処理時間の限界に達しました。そこで最後に、SineをLUT化しています。
今回は、この 測定 → 最適化 → 再び限界 → LUT化 という流れを振り返ります。
2.667msという締切
NuVCOのPCM5102Aへのオーディオ出力は、公称48kHzです。
DMAでは256 frames分のバッファを用意し、その半分である128 framesごとに次のデータを生成しています。
つまり128 framesを再生する時間は、
128 / 48000 = 0.002666...
約 2.667ms です。
STM32G431は170MHzで動作しているので、CPU cycleにすると、
170,000,000 × 128 / 48,000
= 約453,333 cycles
となります。
この 453,333 cycles が重要です。
最初は「2.667msくらい時間がある」と考えていましたが、リアルタイムのオーディオ処理では、これは余裕時間ではありません。
次のDMA転送に間に合わせるための締切です。
128 framesの生成にこれ以上かかると、次に送るべきオーディオデータの準備が間に合わなくなります。
Cortex-M4のCycle Counterで測ってみる
STM32G431のCortex-M4には、DWT(Data Watchpoint and Trace)のCycle Counterがあります。
CPUが何cycle動いたかを数えるカウンタです。
初期化は簡単に書けばこのようなコードです。
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
DWT->CYCCNT = 0;
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
初期化したら、測りたい処理の前後で DWT->CYCCNT を読みます。
start = DWT->CYCCNT;
/* 128 framesを生成 */
elapsed = DWT->CYCCNT - start;
NuVCOではDMAのhalf/full callbackから呼ばれる128-frame生成処理の前後を測定しました。
さらに、
最後のcycle数
最小値
最大値
累積cycle数
測定回数
453,333 cyclesを超えた回数
を変数に保存するようにしました。
Cycle Counterの詳しい使い方はAIに質問してみましょう。
STM32CubeIDEのLive Expressionsで見る
測定値を見るのにはSTM32CubeIDEの Live Expressions を使いました。
Live Expressionsは最初は驚くと思います。MCUを動作させたまま、その内部にある変数をPCでほぼリアルタイムに観察出来ます。
NuVCOでは、
app_vco_active_voice_count
app_audio_perf_max_cycles
app_audio_perf_deadline_cycles
app_audio_perf_overrun_count
app_audio_perf_measurement_count
などを表示しておけば、音を出したままVoice数、最大処理時間、deadline、deadline超過回数などを確認できます。
今回のキャプチャ画面では、
Voice 11
Max cycles 168,563
Deadline cycles 453,333
Overrun 0
Measurement count 40,202
となっています。
現在のLUT版では、11 Voice Sineでもdeadlineまでかなり余裕があることが分かります。
※ この画像は、
sinf()でサイン波を生成していたときのLive Expressions画面ではありません。サイン波生成にLUTを使った場合の測定値です。
Debug版で測る
まずSTM32CubeIDEの通常のDebug構成で測りました。
| 条件 | Average | Max | Deadline超過 |
|---|---|---|---|
| 9 Voice Sine | 約424,534 cycles | 448,349 cycles | 0 |
| 11 Voice Sine | 約500,839 cycles | 536,504 cycles | 15,892回 |
| 11 Voice Saw | 約199,000 cycles | 199,040 cycles | 0 |
11 Voice Sineは23,378回測定して、15,892回がdeadlineを超えています。
約68%です。
時間に直すと、
11 Voice Sine Average
500,839 / 170MHz ≒ 2.946ms
11 Voice Sine Max
536,504 / 170MHz ≒ 3.156ms
です。
締切は約2.667msですから、最大値だけがたまたま超えたわけではありません。
平均値の時点ですでに間に合っていません。
一方、9 Voice Sineの最大値は448,349 cycles、約2.637ms。
締切の453,333 cyclesまで、わずか約29μsしかありません。
9 Voiceで動いていたのも、かなりぎりぎりだったことが分かりました。
同じ11 VoiceでもSawは軽い
コンピューターにとって、Sineは計算量が多いですが、Sawはかなり簡単な計算で済みます。
Debug構成でも11 Voice Sawは約199,000 cycles、時間にすると約1.17msでした。
同じ11 Voiceなのに、Sineとは大きな差があります。
当時のSine生成では、1 Voiceにつき1 sampleごとに sinf() を1回呼んでいました。
128 frames × 11 Voiceなので、半バッファを1回生成する間に、
1,408回の sinf()を呼ぶ計算になります。
STM32G431には単精度FPUがありますが、sinf() は「FPUのSIN命令を1回実行すれば終わり」という処理ではありません。
各sample、各Voiceで sinf() を大量に呼び出すSine生成処理が、リアルタイムオーディオの締切を超えていました。
DebugとReleaseでは速さがかなり違う
ここでもうひとつ重要なことが分かりました。
STM32CubeIDEのDebug構成では、C Compilerの最適化が、
-O0
になっています。
一方、NuVCOのRelease構成では、
-Os
です。
つまりDebugとReleaseでは、単に「デバッグ情報が入っているかどうか」だけでなく、コンパイラの最適化条件そのものが違います。
性能を測るときには、この差を無視できません。
実際、初期の11 Voice SineをRelease相当の -Os で測ると、
| 条件 | Average | Max |
|---|---|---|
| 11 Voice Sine / Debug | 約500,839 | 536,504 |
| 11 Voice Sine / ReleasePerf | 約305,747 | 313,841 |
まで減りました。
Debug版だけを見て、「STM32G431では11 Voice Sineは無理」と判断するのは早かったわけです。
Releaseに近い性能のままDebugする「ReleasePerf」
しかし普通のRelease構成ではデバッグ情報がありません。
Cycle Counterの値をLive Expressionsから眺めたいので、NuVCOでは ReleasePerf というBuild Configurationを追加しました。
内容はシンプルです。
Base : Release
C optimization : -Os
C debug information: -g3
FPU : fpv4-sp-d16
Float ABI : hard
つまり、
Releaseの最適化を維持したまま、Cのデバッグ情報を残した性能測定用構成
です。
STM32CubeIDEで同じものを作る場合は、
Projectを右クリック
Build Configurations → Manage...Releaseを複製して
ReleasePerfを作成C/C++ Build → SettingsC CompilerのOptimizationを
-OsDebug informationを
-g3ReleasePerf用のELFを指定してDebug
という流れです。
NuVCOでは、
ReleasePerf/NuVCO-G431KB.elf
をデバッガから使っています。
これで、Releaseに近い最適化条件のコードを、ST-LinkとLive Expressionsを使いながら観察できます。
最適化したら解決……ではなかった
初期の11 Voice Sineは、ReleasePerfにすると最大313,841 cyclesまで下がり、453,333 cyclesの締切内に収まりました。
この時点では一度問題が解決しています。
しかし、その後NuVCOに機能を追加していくと、再びSineが限界へ近づいていきました。
Sine LUT化直前のReleasePerf測定がこちらです。
| Voice | Wave | Max cycles | 時間 | Overrun |
|---|---|---|---|---|
| 1 | Sine | 69,122 | 約0.407ms | 0 |
| 3 | Sine | 158,926 | 約0.935ms | 0 |
| 5 | Sine | 236,074 | 約1.389ms | 0 |
| 7 | Sine | 315,203 | 約1.854ms | 0 |
| 9 | Sine | 392,026 | 約2.306ms | 0 |
| 11 | Sine | 461,587 | 約2.715ms | 146 |
| 11 | Triangle | 123,145 | 約0.724ms | 0 |
条件はReleasePerf -Os、55Hz付近です。
Voice数を増やしていくと処理時間も増え、
9 Voiceまではdeadline内、11 Voiceで453,333 cyclesを超えました。
同じ11 VoiceでもTriangleは123,145 cyclesしか使っていません。
前回の記事で感じていた、
「11 Voice Sineだけ何かがおかしい」
が、cycle数としてはっきり見えました。
リアルタイム処理では、
一度性能測定して動いたから大丈夫
ではありません。
機能を追加すれば、それまであった処理時間の余裕を少しずつ削っていきます。
機能追加後にもう一度測る必要があります。
SineをLUT化する
そこで、整数DDSのころから使い慣れたSineのLUT化を行いました。
もちろん今回は整数の波形テーブルではなく、単精度浮動小数点数の波形テーブルです。足りない部分は計算で線形補間するという、少し贅沢なLUT化です。
NuVCOでは、
full-cycle 256分割
wrap用を含め257要素
floatFlashへ配置
隣接2点を線形補間
という方法にしました。
LUT化後の11 Voice Sineは、当時の測定で、
Max cycles: 168,004
Overrun: 0
でした。
時間にすると、
168,004 / 170MHz ≒ 0.988ms
です。
LUT化直前の、
461,587 cycles ≒ 2.715ms
から、
168,004 cycles ≒ 0.988ms
まで減りました。
締切2.667msに対して、約1.68msの余裕があります。
今回、現在のFirmwareでもう一度Live Expressionsを表示してみたところ、11 Voice Sineで最大168,563 cyclesでした。
過去に記録していた168,004 cyclesとほぼ同じです。
LUT化で正常に動くようになっただけでなく、処理時間にかなり余裕を作ることができました。
実際に波形はどう壊れたのか
これは当時Analog Discovery 2で観測した11 Voice Sineの破綻波形です。
正弦波そのものは生成されていますが、波形途中に大きな不連続が現れています。
この状態では周期的な異音が発生し、Tune / Wave / Detune POTも反応しなくなりました。
デバッガを切断してLive Expressionsを表示しない状態でも11 Voice Sineの破綻は再現しました。したがって、Live Expressionsを表示していたこと自体が原因ではありません。
※ CH2に見える高周波成分は、11 Voice Sineの処理破綻そのものとは別の現象です。撮影時、オシロ測定点と並列になっているオーディオ出力をBehringer UMC404HDの入力へ接続しており、その状態で発生していました。今回注目しているのは、波形途中に現れる大きな不連続です。
今のNuVCO実験環境
現在はNuVCO Proto基板にNucleo-G431KBとPCM5102Aを載せ、ブレッドボード上にOLED、MCP23017、ロータリーエンコーダなどを接続して実験しています。
写真は今回のCycle Counter測定当時そのものではなく、現在の実験環境です。
機能を増やしていくにつれて、最初のPOTとUSERスイッチだけでは操作が難しくなってきました。
このUIの話は次回以降に書こうと思います。
Cycle Counterで壊れた理由が見えた
今回Cycle Counterを使ってみて良かったのは、
「たぶん
sinf()が重い」
という推測を、
「128 framesを作るのに2.667ms以上かかっている」
という数字で確認できたことです。
11 Voice Sineでは締切を超える。
11 Voice SawやTriangleなら余裕がある。
Release最適化をすると速くなる。
それでも機能追加を続けると、また締切に近づく。
SineをLUT化すると大きく余裕が増える。
同じcycle数という尺度で比較すると、何が起きていたのかが分かりやすくなりました。
組み込みのリアルタイム処理では、「動いた/動かなかった」だけを見ていても原因が分からないことがあります。
今回のNuVCOでは、2.667msという締切を意識してCycle Counterで測ることで、11 Voice Sineが壊れた理由を数字で確認できました。
そしてもうひとつ、今回覚えておきたいのがBuild Configurationです。
STM32CubeIDEではDebugとReleaseだけを使う必要はありません。
Releaseをベースに最適化を残したBuild Configurationを作れば、実際の動作速度に近い状態でST-LinkとLive Expressionsを使って性能を観察できます。
今回作った ReleasePerf は、今後STM32で処理時間を調べるときにも使えそうです。
次は、11 Voice化やMorphing、Detuneなど実験したいことが増えた結果、POTとUSERスイッチだけでは操作しづらくなってきた話を書こうと思っています。
NuVCOを「VCOを作るプロジェクト」から、いろいろな音作りを実験できるVCOへ広げるため、OLEDやロータリーエンコーダを使ったUIを追加していきます。




.png)

.png)