背景
前回の投稿で
- モノラルオーディオファイル再生
- 内蔵DACを使い2CH同時再生
- WAVファイルのストレージはUSBメモリ
- メモリからDACへの転送はDMAを使用
の実現をしました。
前職ではタイマー割り込みを使ってDACの出力レジスタに直接オーディオデータをライトしていましたが、DMAを使うことでソフトウェアの介在を減らすことに成功しました。
今回、お客様からの依頼でステレオ再生が必要になりました。
内蔵DACは2CHあるので、ハード的にはステレオ再生が可能です。
方針
ハードウェア構成は変わることなく、
- WAVファイルのストレージはUSBメモリ
- メモリからDACへの転送はDMAを使用
- ステレオ再生1CH(1曲)
- 内蔵DAC1をL、内蔵DAC2をRに割り当て
- 再生ファイル名をOLEDに表示
という感じ。
今回は再生デバイスっぽくするため、読み込んだWAVファイルを利用して、
- 再生時間、残り時間をOLEDに表示
- 再生バーをOLEDに表示
- L/Rの音量をバーで表示
も実現します!
OLEDの表示イメージはこんな感じです。
デモ動画をYouTubeに公開しました。
ステレオ再生
WAVファイル
WAVファイルのdataチャンクには
| L | R | L | R | L | R | L | R | L | R | L | R | L | R |
|---|
という感じでデータが並んでいます。
今回使用するWAVデータのフォーマットは16bit整数です。
L、Rともにsigned short型のデータです。
内蔵DACで使用するため、unsigned shortの値に直さなくてはいけないのは、モノラル再生の時と同じです。
signed shortの無音が0に対し、unsigned shortの無音は0x8000になるよう変換します。
内蔵DAC
モノラル再生の時はDAC1とDAC2を別々に扱っていました。
今回はステレオ再生なので、DAC1とDAC2に同時に音声データをライトします。
この同時にライトというのが肝です。
別々にライトしたとしてもns、数μsの遅延なら人間の耳にはわからないんですが、割り込みなどで待たされるとどうなるんだろう...などという心配が頭をよぎります。
DACのレジスタ群を見渡してみると、DHR12LDとDHR12RDというものが存在します。

これはDAC1とDAC2の両方に同時にライトできる32ビットのレジスタです!
まさにステレオ再生のためにあるような感じですね!
今回は、16bitのWAVデータをそのままライトするのが便利なので、DHR12LD(左詰め)を使います。
このレジスタにライトすれば、WAVデータの上位12ビットがDACに出力されます。
これは便利!
なんですが、HALではこのレジスタを簡単には扱えないことがわかりました。
CubeMXの設定
試行錯誤の末、DMAとトリガー(TIM)の指定はDAC1だけでいいことがわかりました。
まず、44.1KHzのタイミングを作るタイマーはTIM6を選びました。
トリガーイベントを出すためだけに使うので、単機能のタイマーがバランス的にはいいと思います。
トリガーのイベントをUpdate Eventに設定します。

DACのトリガー指定はこんな感じです。
DAC1だけ指定すればOKです。

次にDMAとの連携設定ですが、これもDAC1だけ設定すればOKです。
転送データのサイズはWord(32bit)を指定します。

HALでは32bitレジスタが指定されない
CubeMXでDMAの転送サイズをWord(32bit)に指定しましたが、HALではこのサイズは単純な転送サイズとしてしか判断していません。
転送のデスティネーションレジスタはどこに指定される?
いろいろ気になります...
HALのDAC転送の関数はこのように定義されています。
HAL_StatusTypeDef HAL_DAC_Start_DMA(DAC_HandleTypeDef *hdac, uint32_t Channel, const uint32_t *pData, uint32_t Length, uint32_t Alignment);
最後の引数で右詰め、左詰めを指定するAlignmentはあります。
ただ、CubeMXで転送サイズを32bitにしても、HALは転送のデスティネーションをDHR12LDに指定してくれず、DHL12L1というDAC1の左詰めレジスタが指定されます。
転送デスティネーションを書き換えるにはどうする?
HALを改造するのは得策ではありません。
CubeMXでジェネレートするたびに書き換わっちゃいますから。
そこで行き着いたのは
- ひとまずDMAを走らせる
- DMAのレジスタを操作して、すぐ止める
- デスティネーション指定だけ書き換える
- DMAのレジスタを操作して、走らせる
という方法です。
HALで止めたり走らせたりすると、また転送デスティネーションがDHL12L1に書き換えられてしまいます。
具体的にはこんな感じです。
HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL1, (uint32_t*)dac_buffer, 4096, DAC_ALIGN_12B_L);
CLEAR_BIT(hdma->Instance->CR, DMA_SxCR_EN); // DMAを止める
while (READ_BIT(hdma->Instance->CR, DMA_SxCR_EN) != RESET); // 停止を待つ
hdma->Instance->PAR = (uint32_t)&(hdac.Instance->DHR12LD); // レジスタを指定し直す
SET_BIT(hdma->Instance->CR, DMA_SxCR_EN); // もう一度走らせる
DMA停止前に転送がされてしまう可能性があるので、転送ソースの音声バッファは無音データでフィルしておきます。
これでDAC1とDAC2にWAVデータがライトされます!
これでステレオのオーディオ再生ができます。
付加価値をつける
WAVファイルの情報を使えば、オーディオプレーヤーに必要な機能の実装もできます。
総再生時間
WAVファイルのヘッダ情報を利用します。
必要な情報は
- サンプリング周波数
- チャンネル数(モノラル?ステレオ?)
- データバイト数
です。
これらが分かれば、総再生時間を計算できます。
モノラル総再生時間[秒]=(データバイト数\div2)\times\frac{1}{サンプリング周波数}
ステレオ総再生時間[秒]=(データバイト数\div4)\times\frac{1}{サンプリング周波数}
あとは、秒を時間:分:秒に変換すればいいですね。
再生経過時間
DMAで転送されたデータ数を利用します。
DMAの転送完了割り込みのタイミングで、転送したサンプル数を加算していけばいいです。
今回、DMA転送回数は4096回を指定しました。
HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL1, (uint32_t*)dac_buffer, 4096, DAC_ALIGN_12B_L);
DMAはピンポンバッファでの動作を考慮して、半分の転送でコールバックが呼ばれるようになっていますので、2048回転送するごとに再生サンプル数(=2048)を加算していきます。
再生経過時間は先ほどの総合再生時間と同じように計算できます。
再生経過時間[秒]=再生サンプル数\times\frac{1}{サンプリング周波数}
再生経過時間は掛け算、割り算もあるので、できればDMAのコールバック関数の中ではやりたくありません。
DMAのコールバック関数は割り込み処理の中で実行されているからです。
オーディオプレーヤーの再生時間は1秒が最小単位の場合が多いです。
であれば、DMAのコールバックの中で時間を計算する必要はなく、DMAのコールバックでは再生サンプル数の加算だけを行うようにします。
FreeRTOSを組み込んでいるので、100msecごとに再生サンプル数から再生時間を計算するタスクを生成し、秒の数値が変わっていれば登録されているコールバックを呼び出すような処理にしました。
これなら、1秒ごとにコールバックが呼び出され、OLEDの表示を1秒ごとに更新できますし、更新遅れは100msecに抑えられます。
この辺は仕様に合わせて調整すればいいと思います。
再生バー
現在の再生位置がどの辺にあるかを知るのに便利です。
これは先ほどのそう再生時間と再生経過時間が分かれば計算できます。
私は先ほどの再生経過時間を計算するタスクの中でこれも計算しています。
再生時間が描画更新されるタイミングで、この再生バーの描画も更新しています。
音量レベルメーター
音声の大きさに合わせて動くあのバーです!
あくまでも音量の目安と考え、かなり簡単な処理にしました。
WAVファイルを読み出した時、読み出したデータの最大値を保持し、それをレベルメーターの値にするのがわかりやすいと思いました。
今回もボリュームの値をA/Dで読み込み、それを元にWAVデータの大きさを変換して音量を変えることができるようにしているので、それも考慮した最大値を保持します。
音声データはプラスとマイナスがあるので、最大値は絶対値を求めてからチェックしています。
このレベルメーターの値はメモリ上に保持しておきます。
DMAの半分転送完了コールバックの際にはすでに最大値の抽出は終わってるので、半分転送完了コールバックで登録されたコールバックを実行してレベルメータの値を渡すようにしています。
OLEDへの書き換えが頻繁になるので、レベルメータの値を受けるコールバック側でいい感じに調整します。
例えば、音量が上がったときはレベルがすぐに追従するけど、下がるときはゆっくりのような制御も、メイン側でやる感じです。
オーディオ再生のクラスでは素直に値を送るので、受ける側で加工してね...というコンセプトです。
モノラルオーディオデータにも対応
モノラルファイルが選ばれた場合でも動作するようにしました。
WAVファイルには片方のチャンネルのデータしか入っていません。
そこで、DAC1とDAC2に同じデータをライトすればいいですね。
DMAの動作を変更せずに動かすため、モノラルデータの場合も、ステレオデータと同じようなデータ構造にしてあげればいいです。
ちょっとした工夫で実現できますよ。
最後に
モノラル再生をやっていたおかげで、オーディオファイルのことはわかってましたし、モノラル再生のソースの大半が再利用できました。
また、WAVファイル情報をうまく利用すれば、オーディオプレーヤーで必要な付加機能も実装できることがよくわかりました。
こちらにデモ動画を投稿しました。
オーディオステレオ再生を実現したい方々の参考になれば幸いです。
