非職業的技師の覚え書き

JK1EJPの技術的検討事項を中心に記録を残します。

Teensy(14)Keiths' SDRのOutputオブジェクト

Keiths' SDRのRx信号処理連鎖ブロック図

Keiths' SDR(K7MDL局Mike OM版)のRx信号処理連鎖をコードから読み解いて抜き出したもの(α版)を下記に再録します。

Block diagram (alpha version) of the Rx signal processing chain and the Output object under consideration here.

Rx信号処理連鎖の調査も佳境です。今回は、朱色点線内★でマーキングしたOutputオブジェクトを調べました。

前段のAm1_LおよびAm1_Rの調査も行いましが、モノラル信号を2分岐してスカラー倍しているだけです。左右で音量バランスを調整したい場合には有用です。出力段で16bit整数に丸めることを考えれば、スカラー倍のゲインはできるだけ大きくして、アナログAFアンプで音量を調整した方が良いように思います。有限bit長の制限から、SDRでは飽和に対してゲインを上手く調整する必要が生じるかもしれません。

Outputオブジェクト(AudioOutputI2S_F32)

端的に言えば、Inputオブジェクトと逆の処理を行います。ディジタル信号処理したオーディオ信号をコーディックのDACによってアナログ信号に変換するオブジェクトです。

MPUの負荷の時間的偏在によってオーディオ信号のストリームが途切れることを防ぐためか、数々の工夫が見られます。しかし、その設計思想が明示されていないため、解読が難しいオブジェクトでした。

ひとまず、解釈した結果を暫定版として記すことにします。暫定版のデータフローを下記に示します。

Data flow to output audio data (tentative version).

コンストラク

Outputオブジェクト宣言時に、暗黙のうちに実行されるコンストラクタの中でbegin()関数をコールして、ハードウェアの初期化を行っています。MCU(i.MX RT1060)のハードウェア・モジュールを下記に示します。

Hardware module of MCU (i.MX RT1060).

この中で特に重要なモジュールはeDMA(enhanced Direct Memory Access)と思います。ソフトウェアの世界とハードウェアの世界の境界を跨ぐ橋渡しの役割をeDMAが担っています。Rx信号処理アプリが出力したオーディオデータのコーディックへの転送を、CPUの負荷に関係なく独立にeDMAが実行します。

コンストラクタの中のbegin()関数では、以下の順番で初期化を実行しています。

eDMA(1)

最初に、DMAChannelオブジェクトのbegin()関数をコールして、eDMAにチャネルの割付を行っています。

ハードウェアであるeDMAモジュールのTCD(Transfer Control Descriptor)レジスタと対になるソフトウェア側のTCD構造体を用いてレジスタの設定を行っていますが、その設定は複雑なため追跡は断念しました。元々はTeensy販売元のPJRC社が開発したコードを踏襲していると思います。

コーディックに転送する出力データバッファi2s_tx_buffer(16bit整数x2、128ワード)のRAM上のアドレスをTCDに設定しています。出力データバッファi2s_tx_bufferは、Rx信号処理アプリが宣言したRAM上の変数です。アプリの変数の任意のアドレスをTCDに設定することで、アプリのデータをコーディックに転送することが可能になります。この設定が、ソフトウェアとハードウェアの境界の橋渡しになります。

I2S/SAI

Outputオブジェクトのconfig_i2s()関数をコールして、MCUのI2S/SAI(Inter-IC Sound / Synchronous Audio Interface)モジュールの設定を行っています。この関数はサンプリング周波数96kHzを引数に取り、コーディックに供給するClockを生成するMCUのPLLの設定を行っています。I2S/SAIのレジスタ設定も複雑なため追跡を断念しました。非同期通信を行っているらしいことはコメントから分かりました。

確か、Inputオブジェクトのコンストラクタでも同じconfig_i2s()関数をコールしていたはずです・・・? メモリ割り付けを行っていないなら、同じ関数を2回コールしても副作用はないと思いますが、分かり難い印象は残ります。

eDMA(2)

eDMAを起動するハードウェア・イベントとして「DMAMUX_SOURCE_SAI1_TX」(=20)を設定しています。MCU(i.MX RT1060)のデータシートと照合すると、Channel 20番はSAI1(Synchronous Audio Interface 1)モジュールの「SAI TX FIFO DMA Request」に割り付けられています。

DMA転送の要求はコーディックから出されると思っていたのですが、特に該当する信号線は見当たりませんでした。そこで、SAI1(SAI TX FIFO > コーディック)が送信の対象とするSAI TX FIFOバッファが空になると、自動的にeDMA(RAM上のi2s_tx_buffer  > SAI TX FIFO)を起動しているのではないかと推定しています。

NVIC

元々PJRC社が開発したAudioStreamクラスのupdate_setup()関数をコールして、割込コントローラNVIC(Nested Vectored Interrupt Controller)に、Rx信号処理連鎖の更新処理関数software_isr()をソフトウェア割込ベクタIRQ_SOFTWAREに設定しています。

eDMA(3)

DMAChannelオブジェクトのattachInterrupt()関数を用いて、DMA割込に割込サービスルーチンAudioOutputI2S_F32::isr()を割り当てています。eDMAの転送(RAM上のi2s_tx_buffer > SAI TX FIFO)が完了すると、割込が自動的に発生し、Outputオブジェクトのisr()を実行します。アプリが管理する出力データバッファi2s_tx_bufferを前半と後半に分け、それぞれの転送完了で割込を発生させているようです。次のDMA転送データをデータバッファi2s_tx_bufferに準備するのが、この割込サービスルーチンisr()の役割です。isr()の実行内容の詳細は、節を変えて記します。

コーディックはマスタのMCUに対してスレーブの位置付けになっています。MCUのSAI送信(SAI TX FIFO > コーディック)完了(①)からプルされてeDMA転送(RAM上のi2s_tx_buffer > SAI TX FIFO)が起動(②)し、eDMA転送完了(③)からプルされて割込サービスルーチン(RAM i2s_tx_bufferへの次回出力データの充填)が起動(④~⑧)する複雑な流れが見えてきました。

Diagram of the relationship between hardware and software involved in LR audio signal output.

ソフトウェアによる明示的な制御とハードウェアによる暗黙的な制御が絡み合っているため、全体の流れが分かり難くなっています。MCUのデータシートの理解が必要になりますが、データシートは可能なオプションの列挙に等しく、特定の機能の実現方法を理解するようには書かれていません。MCUが複雑で実施できるオプションが多過ぎることが理由の1つかと思います。SDRをコーディングしたOM諸氏も、Teensy開発元PJRC社のコードを可能な限り踏襲して拡張しているようです。

DMA割込サービスルーチンAudioOutputI2S_F32::isr()

  1. eDAMのTCDが指し示すメモリ上の現時点のDMA転送データのアドレス(SADDR:Source Address)を取得します。
  2. 取得したSADDRから、DMA転送用の出力データバッファi2s_tx_bufferの前半を転送中かどうかを確認します。
  3. 現時点で後半を転送中であれば、前回のデータブロックを転送途中と判定し、前半の先頭アドレスを次回充填先アドレスに設定する処理のみを行います。
  4. 現時点で前半を転送中であれば、前回の前半と後半のデータブロックを全て転送完了したと判定し、後半の先頭アドレスを次回充填先アドレスに設定します。
    続けて、update_all()関数をコールして割込コントローラNVICに対してソフトウェア割込ベクタIRQ_SOFTWAREを保留(PENDING)に設定します。この処理はInputオブジェクトでも実施していました。追跡できていませんが、InputとOutputの両方が完了するまで、更新処理を保留(PENDING)にしているのかもしれません。
  5. DMA転送用出力データバッファi2s_tx_bufferの次回充填先アドレスに出力データを充填します。この時に、32bit浮動小数点データを16bit整数データに型変換し、LチャンネルとRチャンネルのデータを合わせて32bitバッファに充填します。
  6. CPUのデータキャッシュをフラッシュして、キャッシュの内容をメモリに書き込みます。DMA転送の対象はメモリだからです。
  7. 出力データの前半をDMA転送用出力データバッファi2s_tx_bufferに充填したかどうかを調べます。
  8. 出力データの前半の充填が完了しているなら、出力データの後半の充填を次回に備えて設定します。
  9. 出力データの後半の充填が完了しているなら、次回に備えて、予備の2ndデータブロックblock_left_2ndとblock_right_2nd(のアドレス)を出力データblock_left_1stとblock_right_1st(のアドレス)に複写し、2ndデータブロック(のアドレス)をNULLに設定します。
    このような複雑な構成にした設計思想は不明ですが、データブロックを1stと2ndの2段構成にするのは、MPUの負荷の時間的偏在によってオーディオ信号のストリームが途切れることを防ぐためと推測しています。

update()関数による更新処理

上記DMA割込サービスルーチンのステップ4において、前半と後半のデータブロックを全て転送完了した場合は、更新処理を実行するsoftware_isr()関数を割り付けたソフトウェア割込ベクタIRQ_SOFTWAREを保留設定していました。保留が解ければ、全てのRx信号処理連鎖が更新され、Outputオブジェクトも更新されます。その更新内容を定義しているのが、このupdate()関数になります。

update()関数では、Rx信号処理連鎖の前段(オーディオ・ゲイン処理)の処理結果のデータブロックをQueueから取得します。LチャンネルとRチャンネルのデータを別々に処理します。16bit整数のスケール変換を行い、Double bufferの2ndデータブロックに格納します。スケール変換を行っただけで、型は32bit浮動小数点です。

update()関数の役割はここまでです。Double bufferから先のデータ転送は、前述したDMA割込サービスルーチンAudioOutputI2S_F32::isr()の役割になります。

感想

以上、Outputオブジェクトの全貌を理解するには、MCUのデータシートのレジスタ設定に精通する必要があります。SDRの改良や実験を行う上で、当面、それは必須ではないと思います。

RX信号処理連鎖のInputオブジェクトとOutputオブジェクトはファームウェアと見做し、その他をアプリケーション・ソフトウェアと見做して、信号処理アプリの改良実験に専念するのが良いかと思います。

 

RX信号処理連鎖の調査は今回で終了です。次回は、"Twin Peaks"問題の調査結果をまとめたいと思います。