CPLD でオーディオレベルメーターを実装
はじめに
- オーディオレベルメーターを CPLD で実装してみました。
- ハードウェアが出てきますが、メインは verilog に関してです。
- デジタルオーディオ(S/PDIF等)に関する多少の知識を前提としています。
- 著者の本業はソフトウェア開発です。ハードウェアは趣味で、業務経験ほぼ無しの HDL 初心者です。
動機
- アナログ信号を入力する方式のレベルメーターモジュールが販売されているのを見かける。
- 自宅では PC とヘッドホンの接続に光デジタルを使用しているので、デジタル入力のものを探すがなかなか見つからない。
- 光デジタルからの変換は専用 IC を使用すれば簡単にできる…LED を沢山並べて光らせるだけのはず。
- なんだかできそう。作ってみよう。
...といったところです。
レベルメーターが無性に欲しくなった、という訳ではありません。
仕様
当初の目標は「光デジタル(S/PDIF)を入力してレベルメーターを光らせるもの」でしたが、環境の事情があって、以下の様になっています。
- 入力信号は4線式(MCLK, LRCLK, SCLK, SDATA)のシリアルオーディオ信号。
- サンプリングレートは 44.1KHz, 量子化ビット数は 16 を想定。
- オーディオプレイヤーのソフトを実装したマイコンから出力。
- CPLD のデバイス依存 Primitive は使用しない(top モジュールは除く)。
- レベルメータは LED を使用。チャンネルあたり32個で計64個。
- チャンネル独立のピークホールド表示有り。
- LED の左チャンネルは出力時に左右反転して、「左チャンネルの最大~最小間32個、右チャンネルの最小~最大間32個」の順とする。
- 中央(32,33個目)は無音として、左右に広がる様な感じになります。
- テスト用に入力シリアルオーディオからエンコードした S/PDIF も出力する。
回路
実際に動作させた回路や基板は全て自作です。
記事のタイトルから離れてしまうため、以下に重要な使用部品について書いておきます。
- CPLD は Lattice Semiconductor MachXO2 1200HC (QFN32 パッケージ)。
- 詳細は未確認ですが、基板は SWITCH SCIENCE 扱いの TinyFPGA AX2 Boardと大体同じです。
- LED ドライバは STMicroelectronics STP16CPC26 を4個デイジーチェーン接続。
- STP16CP26 は 16ch の定電流 LED ドライバです。他社同等品もあります(TI TLC5928 等)。
- MachXO2 とは最初(最も左の) LED ドライバとのみ接続します。
全体図
ブロック(四角)は全て独立した基板です。それぞれの間はブレッドボード上でジャンプワイヤー、ピンヘッダ/ソケット等で配線。
16ch LED Driver 基板
16ch LED Driver と 16 個の面実装 LED を水平方向へ並べたものです。一枚の幅はコネクタ部も含み 81.28mm (2.54 * 2 * 16)で、基板同士を連結した際、LED に隙間ができない様に部品を配置しています。今回はこれを4枚デイジーチェーンしています。
実装
- HDL は Verilog を使用。
- 開発の大部分は Visual Studio Code (+ 拡張機能 WaveTrace)と Icarus Verilog を使用しました。
- 統合開発環境である Lattice Diamond 上へ移したのは、top モジュールを実装できる状態になってからです。
- 一旦動作した後も、デバッグや手直しの際の確認は Icarus Verilog を使用しています。
- デバイス依存 Primitive を使用していないため Icarus Verilog で何も困りませんでした。
- Lattice Diamond にも付属のシミュレーター(modelSim)がありますが、正直スキル不足で使い方が分からないです…。
- モジュール間の接続には Valid and Ready ハンドシェイクを用いました。
- ikwzm さんの記事、VALID 信号と READY 信号によるパイプライン制御がとても参考になります。
- どう使われているかは個人的にあまり重要ではなく、モジュールの接続方法として参考にしました。
チャンネル単位の処理
シリアルオーディオ信号から符号付き 32bit PCM が得られた後、単一チャンネル内での処理の流れは以下の通りです。
- 符号付き 32bit PCM を符号無し 16bit へ変換(後述)。
- 符号無し 16bit 値をバッファリング。変化量を求める(詳しくは後述)。
- 変化量を LED の位置を示す値(LED 数が 32個ならば 0-31)へ変換。
- ピークホールドを加えて LED の点灯配列へ変換。
符号付き 32bit PCM を符号無し 16bit へ変換
シリアルオーディオ信号をデコードするモジュール(serial_audio_decoder)からの PCM 値出力は 32bit となっています。レベルメーターでは後の処理を実装しやすい様、簡単な変換を行います。
- チャンネルあたり 32 個の LED であれば、16bit 程度で十分なため、下位 16bit を切り捨てます。
- 最上位ビットを反転して符号無し16bitへ変換します。
元の値 | 変換後の値 |
---|---|
-32768 (0x8000) | 0 (0x0000) |
-32767 (0x8001) | 1 (0x0001) |
-32766 (0x8002) | 2 (0x0002) |
: | : |
-1 (0xFFFF) | 32767 (0x7FFF) |
0 (0x0000) | 32768 (0x8000) |
1 (0x0001) | 32769 (0x8001) |
: | : |
32765 (0x7FFD) | 65533 (0xFFFD) |
32766 (0x7FFE) | 65534 (0xFFFE) |
32767 (0x7FFF) | 65535 (0xFFFF) |
レベルメーターの表示アルゴリズム
通常のレベルメーターはその(音が出た)瞬間の音の大きさを表示します。最大の音が入力されると、UVメーターの様なものであれば右へ振り切る、バー形状であれば全点灯する、といった感じです。PCM 値はその瞬間のアナログレベルを示します。だからといって、単純に「PCM 値の上位 5bit のみを使う(32個LEDの場合)」といった方法では、見た目に面白い(レベルメーターらしい)表示にはなりません。いくつか考えなければならない事があります。
"聞こえる"音の大きさ
(この手の知識がある方にとっては当然、あるいは間違っている事かも知れませんが…)。
PCM 値を音の大きさとして扱う…というのが簡単な方法です。0 が無音なので、正の最大値 32767、負の最小値 -32768 は一番大きな音と考える事ができます。更に負の値の符号を取ってしまえば後の処理は簡単になります。アナログ信号をデジタルで録音する際には 0db を超えられないため、レコーダーのレベルメーターは、その目的にも合った表示です。
今回も全く同じに作ったところ、うまく動作している様に見えました。しかし、この方法では聞こえる音と表示が違うのでは? という疑問が出てきます。例えば、周期が極端に長い矩形波の様に一定レベルが連続する音を考えてみます。矩形波が振幅が最大(0db)とすると 16bit PCM 値としては値は最大の 32767 または -32768 となり、先の方法で表示すると最大値を示します。以下はそのイメージ図です。上段は PCM 値、下段はレベル表示を示します。
しかし、試しに波形を生成して聞いてみると、一定レベルが連続している範囲では何も聞こえません(実際、この様な表示になる市販のディジタルレコーダーがありました)。音が聞こえるのは波形が変化した瞬間だけです。図にすると以下になります。
この事から"聞こえる"音の大きさは、その瞬間の PCM 値ではなく「以前の値からの変化量」で考えた方が良い気がします。
「以前の値」とは言っても、矩形波の様に一つ前と後で急激に変化する波形が、通常の音楽に含まれる事は稀なので、簡単に一つ前との比較だけにしてしまうと変化量が極端に小さくなってしまいます。このため、適当な数の PCM をバッファリングしてその中から最小値と最大値を決定する事にします。実際の実装では最初のバッファ(1次)内で探した最大最小値のペアを更に(2次)バッファリングしています。2次バッファ内から最小最大値を探して、その差を変化量としました。
バッファを二段構成とした理由は、1次だけで沢山の値をバッファしてしまうと表示までの遅延が目立つのではないか…? という懸念があったためです。2次バッファでは初回はバッファを一杯にしますが、それ以後は最も古い値を一つだけ書き換えます。二段構成にする事で、遅延は初回を除けば1次バッファの分(+α)だけになります。最終的は表示遅延は2つのバッファ量によって変わりますが、それぞれ 16, 32 サンプルあたりが適当な値の様です。1次=2次=16サンプルならば総サンプル数は 256 個です。もし1次だけで 256 サンプルをバッファリングした場合、サンプリング周波数が 44100Hz とすれば、5ms (1/200秒)程になります。実際に見ると、分かる様な分からない様な…といった感じでした。過剰な実装だったかも知れません。
今更になってしまいますが、この方法でのレベルメーターが表示するピークは、必ずしも PCM の最大値を示さないため、録音時の目安としては役立ちません。
LED 位置への変換
解説できる程詳しくないので…簡単に書くと PCM (実際は過去のPCMも含めて求めた差)そのままの値(の上位ビットだけ使う等)ではなく、デシベルへ変換する必要があります。実際はデシベル値そのものは必要なく、並んだ LED それぞれについて「値が xxxx 以上で点灯」というテーブルがあれば十分です。テーブルは ROM 等で実装します。
このテーブルの内容によって、同じオーディオデータであっても動きが大きく変わります。通常は -20db あたりをレベルメーターの半分の位置にしているものが多い様です。音楽ジャンルによって変えたり、自動適応する様にしても面白いと思います。例えば、リズム感の強い音楽では 0 - -20db の範囲を広く(LED割り当て数を多く)した方が動きが派手になります。逆に小さい音が多いオーケストラ等では 0 - -20db を狭くして、-20db 以下の分解能を増やすと、小さな音が見やすくなります。今回作成した物では -20db の位置は最低レベルから 1/4 位の所に(リズム感が強い音楽向き)していています。
簡略ブロック図
CPLD (MachXO2) 内の簡略ブロック図を示します。
※ この図は主にブロック同士の接続、情報の流れを示したもので、各 Block は実際のモジュールとは対応しません。
Block 名 | 説明 |
---|---|
Serial Audio Decoder | I2S または Left justified (左詰め)のシリアルオーディオ信号を受け取り、パラレル 32bit 符号付き PCM へ変換します。片チャンネルずつの出力となるため、左右を示す Channel 出力があります。 |
S/PDIF Transmitter | Serial Audio Decoder から受け取った、32bit 符号付き PCM のうち上位 24bit と Channel を S/PDIF へエンコードして出力します。マスタークロックとして 256fs を受け取ります(必要なのは 128fs なので内部で分周します)。 |
Left (Right) | Serial Audio Decoder から受け取った、32bit 符号付き PCM のうち上位 16bit をレベルメーター(のLED配列)へ変換します。ピークホールド等も合わせた 32個分の LED に対応する配列を出力します。 |
Combine | Left および Right の待ち合わせを行い、受け取った LED 配列を連結して 64 個分の配列にします。 |
Shift & Dynamic control | 受け取った64個分の配列を上位ビットから、ダイナミック制御を加えて STP16CPC26 へ出力します。ここで行っているダイナミック制御とは 1 つ置きに LED を強制消灯させる処理です。 |
Internal OSC 20MHz | MachXO2 の内蔵オシレーターです。Left (Right) の処理クロックとして使用します。Left (Right) の処理に大体 100 - 200 クロック掛かるとすると、サンプリングレートが 44100Hz ならば、最低でも 4MHz 以上にする必要があります。余裕を見込んで適当に 20MHz としました。 |
Module 接続図
階層毎の Module 同士の接続図と、それぞれの Module の内容です。
Module 同士の接続は一部を除き Valid and Ready ハンドシェイクを使用しています。
信号名の命名は以下の通りにしています(例外あり)。
信号名 | I/O | 意味 |
---|---|---|
clkXXXX | input | クロック入力 |
reset | input | リセット入力(High Active) |
i_valid | input | 上流側 Module データ有効 |
i_ready | output | 自身(Module)がデータを受け入れた(受け入れる) |
i_***** | input | i_valid == true の時に有効な転送データ |
o_valid | output | 下流側 Module への出力データ有効 |
o_ready | input | 下流側 Module が出力データを受け入れた(受け入れる) |
o_***** | output | o_valid == true の時に有効な転送データ |
その他 | --- | Valid and Ready とは無関係な信号 (STP16CPC26 信号等) |
Top 層
モジュール名 | 内容 |
---|---|
serial_audio_decoder | シリアルオーディオ(lrclk, sclk, sdin)信号を変換して 32bit の PCM を出力します。出力はチャンネル単位です。出力チャンネルを示す o_is_left があります。処理の詳細は記事のタイトルから離れてしまうため省略します。 |
data_flow_fork | serial_audio_decoder からの valid と ready 信号を受け取り、後述の spdif_transmitter および audio_level_meter へ分配します。分配先の両方が情報を受け取るまで、i_ready == 0 です。このモジュールは ikwzm さんの記事「VALID 信号と READY 信号によるデータフロー制御 (Fork 編)」を参考にそのまま実装したものです。 |
spdif_transmitter | serial_audio_decoder から 32bit PCM のうち上位 24bit を受け取り、S/PDIF へ変換します。S/PDIF エンコードのため 256fs のクロックが入力されます。32bit ではなく 24bit なのは、S/PDIF で伝送できる LPCM が最大 24bit であるためです。処理の詳細は記事のタイトルから離れてしまうため省略します。後述する dual_clock_buffer が入っています。 |
audio_level_meter | data_flow_fork から 32bit PCM のうち上位 16bit を受け取り、レベルメーターへ変換(内容は後述)して、16ch LED ドライバ STP16CPC26 用の制御信号を生成します。動作クロックの約 20MHz は MachXO2 の内蔵オシレーター(OSCH)から入力されます。 |
OSCH | MachXO2 の内蔵オシレーターです。周波数は約 20MHz です。 |
audio_level_meter 層
モジュール名 | 内容 |
---|---|
dual_clock_buffer | クロックが非同期のモジュール同士の valid と ready および情報(16bit PCM および o_is_left)を転送します。入力側クロック(i_clk)と出力側クロック(o_clk)の両方を接続します。i_clk は serial_audio_decoder の動作クロック(16bitフォーマットであれば32fs)が、o_clk は audio_level_meter 以降の動作クロックである 20MHz を接続しています。このモジュールが必要な理由等は後述します。 |
dataflow_branch | o_is_left の内容によって、audio_level_meter_channel の左用または右用のインスタンスへデータフローを分岐します。これも ikwzm さんの記事 VALID 信号と READY 信号によるデータフロー制御 (Fork 編)」を参考に実装したものです。 |
audio_level_meter_channel | 受け取った 16bit PCM からレベルメーター 32個分の LED に対応する配列を出力します。左右それぞれインスタンス化しています。 |
dataflow_join | 左右の audio_level_meter インスタンスからの出力を待ち合わせ、それぞれのチャンネルの 32個分の LED 配列を組み合わせて 64 個にします。これも ikwzm さんの記事 VALID 信号と READY 信号によるデータフロー制御 (Join 編)」を参考に実装したものです。 |
stp16cpc26 | 64bit の配列を受け取り、LED ドライバ STP16CPC26 用の制御信号を出力します。モジュールのパラメータとしてビット幅があります。今回は STP16CPC26 は 4個をデイジーチェーン接続するため、ビット幅 = 64 を指定してインスタンス化します。 |
audio_level_meter_channel 層
モジュール名 | 内容 |
---|---|
section_min_max | 数個の 16bit PCM 値の中から最小および最大の値(以後 min-max 値と記述)を出力するモジュールです。入力 PCM はまず最初に符号無し値へ変換します(図の Inverter)。結果の min-max 値も符号無しです。 |
section_min_max_buffer | section_min_max から min-max 値を受け取りバッファリングします。バッファが一杯になったら、バッファリングした全ての min-max 値から min-max 値を探して、min と max の差(max - min)を出力します。既にバッファが一杯の時は最も古い min-max 値ペアへ上書きします。図にはありませんが、内部で min-max 値ベアの配列を持ちます。 |
pcm_to_position | section_min_max_buffer から受け取った min と max 差を、テーブルでレベルメーター(LED)に対応する位置へ変換します。出力は 0 - 31 の値です。このモジュールの入力は PCM 値ではないのですが、当初は PCM 値をそのまま入力していた名残です。 |
posotion_to_array | pcm_to_position から受け取った値を元に LED 点灯用の 32bit 長配列を出力します。ピークホールドの更新、ホールド時間の処理も行います。 |
Module 補足情報
いくつかの Module の補足情報です
dual_clock_buffer
serial_audio_decoder から出力された audio[31:0] を(fork の後で)受けている、S/PDIF encoder および audio_level_meter の両方の前(またはその最初)で使用しています。
valid and ready ハンドシェイクではモジュール間を2つの信号で接続、および参照するため、動作クロックは同じである事が前提です。クロックが異なると、互いの信号変化を正しく認識できません。dual_clock_buffer の役割は、異なった CLK 間のモジュールで正しくデータを引き渡せる様にする事です。
実際の例です。出力側である serial_audio_decoder の駆動クロックは入力シリアルオーディオの SCLK は 32fs で、約 1.4MHz (44.1KHz / 16bit の場合)です。一方の受け取り側のうち一つである audio_level_meter のクロックは 20MHz です。このため、serial_audio_decoder は 1.4MHz で i_valid を、audio_level_meter の i_ready は 20MHz で制御します。
この条件で問題が発生するケースを書くと以下の様になります(CLK の比率は図の作成の都合で変えています)。
- audio_level_meter は i_ready を High にしている。
- serial_audio_decoder はオーディオデータのデコードが完了して i_valid を High にする。
- audio_level_meter は i_valid の High を認識してデータを受け取り、i_ready を一旦 Low にする。
- serial_audio_decoder は(まだ次のクロックではないため) i_valid を High にしたまま。
- audio_level_meter は次のデータを受け取れるため i_ready を High にする。
- audio_level_meter は i_valid の High を認識して i_ready を Low にする。
この様に serial_audio_decoder は i_ready の変化を認識できないため i_valid を low にしません。結果、audio_level_meter は「データが有効」と認識し、誤ったデータを取り込んでしまいます。dual_clock_buffer は i_valid と i_ready を相手のクロックに合わせて制御する事で、有効データの有無を正しく認識できる様にします。大抵の CPLD, FPGA に用意されているデュアルクロックFIFO(MachXO2 では FIFO_DC)でも代用できますが、このケースの様に、受取側のクロックが高くブロックもしない場合は、複数のデータを溜め込む必要が無いため勿体ないです。
single_port_ram
section_min_max_buffer が最大最小値のペアを保持するバッファとして使用します。中身は単なる Register 配列です。MachoXO2 では SLICE を使用した Distributed RAM、SLICE 不足となった場合は Block RAM のどちらも使用できる様に実装しています。リードデータにレジスタバッファが入っているため正直使い難いのですが、これを外すと SLICE が不足しても Block RAM になりません。
Block RAM を module 内に入れてしまう実装はメモリの使用効率の点で良くありません。Block RAM を何らかのバスを通して module 間で共有する方法も考えましたが、非常に手間が掛かりそうなので諦めました。結局、現在のバッファサイズでは Distributed RAM で足りている様です。
audio_level_meter_channel
audio_level_meter_cahnnel ではチャンネル独立の処理を行います。serial_audio_deocder から左右交互に出力されるため、回路としては1つだけでも十分なのですが、左専用および右専用として 2 つのインスタンスが存在しています(dataflow_branch で分岐します)。
チャンネルが持つ情報(min-max 値バッファやピーク等)はチャンネルプライベートなものです。当初はそれぞれのモジュールで左右どちらかであるかを示す情報を受け取って、モジュール内で左右両方のレジスタを保持していました。しかし、これは if 文が多くなるため、多数のセレクタが生成されてしまいます。配列化等、色々と試したのですが、チャンネル専用のインスタンスを持った方が回路は簡単に、規模も小さくなる様でした。
成果物
今後の予定
- 上の動画を見てもらえると分かりますが、ピーク音が発生した時の LED 点灯時間が非常に短く見え難いです。残光処理を追加してみたらどうかと考えています。64個もあると、単純にレジスタで…という訳には行かず、なかなか面倒そうです。