製作モジュール一覧

2026年8月16日日曜日

 

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構成で測りました。

条件AverageMaxDeadline超過
9 Voice Sine約424,534 cycles448,349 cycles0
11 Voice Sine約500,839 cycles536,504 cycles15,892回
11 Voice Saw約199,000 cycles199,040 cycles0

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 で測ると、

条件AverageMax
11 Voice Sine / Debug約500,839536,504
11 Voice Sine / ReleasePerf約305,747313,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で同じものを作る場合は、

  1. Projectを右クリック

  2. Build Configurations → Manage...

  3. Releaseを複製して ReleasePerf を作成

  4. C/C++ Build → Settings

  5. C CompilerのOptimizationを -Os

  6. Debug informationを -g3

  7. ReleasePerf用の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測定がこちらです。

VoiceWaveMax cycles時間Overrun
1Sine69,122約0.407ms0
3Sine158,926約0.935ms0
5Sine236,074約1.389ms0
7Sine315,203約1.854ms0
9Sine392,026約2.306ms0
11Sine461,587約2.715ms146
11Triangle123,145約0.724ms0

条件は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要素

  • float

  • Flashへ配置

  • 隣接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を追加していきます。

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のサイクルカウンタを使って、音声バッファの生成にかかる時間を実際に測ってみることにします。

 参考リンク


2026年7月15日水曜日

製作モジュール紹介サイトを公開しました

 


これまでBlogに掲載してきたDIYシンセ・モジュールを、見通しよくまとめるためのサイト「PNPN Manufactory」を公開しました。

現在はNuVCO、ArLFO、TLF01、Dual OTA VCAを掲載しています。今後も順次追加していきます。

製作過程や実験の詳しい記録はこれまでどおりこのBlogに掲載し、完成したものや開発中のモジュールはPNPN Manufactoryに整理していきます。

[製作モジュールを見る]

https://pnpn-mfg.com/modules/