背景
前職で
- STM32F407/479
- C言語
- FatFs
- SDカード/USBメモリ
- 内蔵DAC
- D級アンプ
を使って、音声案内をする組み込みシステムを作りました。
その時はリアルタイムOSを使っておらず、さらにDMAも使いこなせていなかったので、44.1KHzのタイマー割り込みで内蔵DACに音声データを直接書き込んでいました。
昨今、DMAを使わないと実現できない開発が増え、そういう中でSTM32F4のDMAの使い方がだんだんわかってきました。
他社マイコンのDMAに慣れすぎていて、STM32F4のDMAはとっつきにくかったのですが、CubeMXを使えば設定も簡単です。
そうしているうちに、
- DAC
- DMA
- 転送トリガー用のタイマー
- リアルタイムOSのタスク/キュー
を連動させれば、もっとシンプルにオーディオデータを送れるのでは?...と気づきました。
方針
制御ブロック図
ハードウェアは前職の時と同様で、外部に用意するのは
- USBメモリ
- D級アンプとスピーカー
- 再生命令を出すもの
です。
再生命令はUSB CDCを利用し、ターミナルでコマンドを送ります。
ついでに、このターミナルでUSBメモリのファイルブラウズもできるので、USBメモリ内の曲を選んで再生させることができます。
音声データ
Windowsで使われるwavデータを使い、データの形式は
- 44.1KHz
- モノラル
- 16bit整数
です。
音声案内であれば44.1KHzである必要はないですね。
人間の声だけであれば8KHzくらいで十分でしょう。
デバッグに音楽を流すので44.1KHzにしていますが、実際はスピーカーが貧弱なので、あまり意味はないですが...
私は敬虔なMacユーザーなので、音声データはAIFFを扱う方がいいと思ったのですが、マイコンがARMコアでリトルエンディアンなので、WAVの方が扱いやすいです。
AIFFだとバイトスワップが必要になるので、ここはARMコアで扱いやすい方がいいと考え、苦渋の決断です🤣
D級アンプとスピーカー
もうずいぶん昔ですが、秋月電子で手に入れたものです。
あっ、まだ売ってる。
100円スピーカーは結構重宝します。
マンション内での開発なので、ゲインは基板を改造して思いっきり下げています。
結構大きな音出せるんですよね...
リアルタイムOS
FreeRTOSを使います。
STM32CubeIDEでは簡単にFreeRTOSを組み込めるのと、資料もネット上や雑誌など豊富にありますからね。
今までは自作のスケジューラを使っていましたが、STM32F407は内蔵SRAMが128バイトあるので、フットプリントの小さい自作のスケジューラじゃなくてもメモリ容量は十分足ります。
2チャンネル同時再生
WAVデータをモノラルにした理由はここにあります。
音声案内を2つできるようにします。
そのためには、2つあるDACを独立して使う必要があり、音声データをモノラルにすれば、2つのDACに別々の音声データを送れます。
FreeRTOSのタスクは1つだけ
DAC(オーディオ)は2チャンネル同時再生をサポートしますが、データの資源となるUSBメモリは一つしかありません。
単純に考えればセマフォを使う手もありますが、私の方針としてセマフォは使わず、ロードした音声データの情報をキューに入れてもらって、1つのタスクで制御することにします。
なので、キューを待つタスク1つだけを作ります。
開発言語はC++
最近はこれ一択。
いろんなものをCからC++へ移植しているところです。
オーディオを2チャンネル扱うので、クラス化してインスタンスを2つ作るというのは自然な流れです。
コンパイラはC++17をサポートしたものを利用します。
DACの設定
DACを有効化
まずはDACを有効にすることと、転送のトリガーにタイマーを指定します。
タイマーは空いていればどれでもOKです。
今回はTimer 5とTimer 6を使います。
DMAとの連携
DAC OUT 1と2の両方にDMAと連携させます。
転送のモードはCirclar、アドレスはペリフェラル(DAC/転送先)が固定、メモリ(転送元)はインクリメントします。
タイマーの設定
今回は44.1KHzのオーディオデータを扱います。
44.1KHzのタイミングでトリガーを出力する設定はこちら。
Trigger Output (TRGO) ParametersにUpdateEventを指定します。
タイマーのカウント値の計算はこちら。
\begin {align}
ARR &= \frac{1}{44.1KHz}\div\frac{1}{84MHz}-1\\
&= 1904.761\cdots - 1\\
&\fallingdotseq 1905-1\\
&=1904
\end {align}
C++でコーディング
まずはヘッダです。
オーディオ再生のためのC++クラスはLAudioLiteという名前にしました。
再生のために最低限必要な機能を定義しました。
USBメモリからのリードにかかる時間やリード間隔を考慮し、DMAのメモリサイズは4096ワード(8192バイト)にしました。
実際はこの半分のサイズを交互に読み出す仕組みです。
#ifndef LAUDIOLITE_HPP
#define LAUDIOLITE_HPP
#define DEFAULT_SAMPLING_FREQ 44100
#define WAVE_SAMPLES 4096
#define HALF_LOAD_BYTES WAVE_SAMPLES
#define HALF_LOAD_WORDS (WAVE_SAMPLES >> 1)
#define HALF_POSITION (WAVE_SAMPLES >> 1)
#define AUDIO_OFFSET_LEVEL 0x8000
#define TASK_PRIORITY 26 //defaulTaskより上、最優先
#define LOAD_NEXT_DATA_QUEUE_LENGTH 64
#include "main.h"
#include "ff.h"
#ifdef __cplusplus
/*==============================================================================
==============================================================================*/
typedef struct LOADNEXTDATAINFO {
void* audioInstance;
short* aiffBufferPtr;
}LOADNEXTDATAINFO;
/*==============================================================================
WAVファイルのヘッダ
==============================================================================*/
struct WAVHEADER {
char chunkID[4]; //RIFF
unsigned long chunkSize; //Total Size - 8(except chunkID, chunkSize)
char fmt[4]; //WAVE
char fmtID[4]; //fmt
unsigned long fmtSize; //linear PCM 16
unsigned short audioFormat; //linear PCM 1 (0x0100)
unsigned short numberOfCh; //1 = mono/2 = stereo
unsigned long samplingFreq; //8KHz = 0x401f0000, 44.1KHz = 0x44ac0000
unsigned long averageOf1sec; //1秒あたりのバイト数の平均
unsigned short blockSize;
unsigned short sampleBit; //8bit = 0x0800, 16bit = 0x1000
unsigned long subChunkID; //0x64617461 fix
unsigned long subChunkSize; //波形データのバイト数
};
typedef struct WAVHEADER WAVHEADER;
/*==============================================================================
==============================================================================*/
class LAudio {
public:
LAudio(TIM_HandleTypeDef* htim, DMA_HandleTypeDef* hdma, int dacCh);
void begin();
void play(char* filename);
void stop();
void getQueueInfo(LOADNEXTDATAINFO* container, bool isHalf1);
private:
TIM_HandleTypeDef* _htim;
DMA_HandleTypeDef* _hdma;
int _dacCh;
WAVHEADER _header;
FIL _fp; //再生ファイルのオブジェクト
short _dac_buffer[WAVE_SAMPLES]; //DMA用メモリ
void _loadInitialWave(char* filename);
static void _loadNextWave(void* pvParameters);
};
#endif //__cplusplus
#endif //LAUDIOLITE_HPP
コンストラクタ
htim(タイマ)、hdma(DMA)、dacCh(DACのCH)を引数として受け取り、インスタンス内に保持します。
void begin()
FreeRTOSのタスク生成など、クラスを使い始めるための準備をします。
組み込み系で利用されるもので、C++のコンストラクタで解決できない初期化をbegin()で実装するという習慣です。
Arduinoフレームワークでも使われていますね。
void play(char* filename)
filenameの音声ファイルを再生します。
filenameが有効かどうかは
- playを呼ぶ時に判断
- playの中で判断
システムに合わせて選べばいいと思います。
私はオーディオ関連の処理をシンプルにする目的で前者を採用しています。
void stop()
再生を停止します。
void getQueueInfo(LOADNEXTDATAINFO* container, bool isHalf1)
DMAの転送完了コールバックの中で、次にどのようなデータをDMAのメモリに読み出すか?
コールバックの種類が
- 前半転送終了
- 後半転送終了
の二つあります。
前者ならisHalf1がtrue, 後者ならfalseを指定します。
これでDMAのメモリの書き込む先のアドレスと、オーディオ再生のインスタンスのポインタが構造体に書き込まれます。
これをキューで送信します。
void _loadInitialWave(char* filename)
play()を呼ばれた時、最初のデータを読み込んでDMA用メモリに書き込むために用意します。
こちらでも特別なことはせず、USBメモリから読み込んでキューで送信します。
_loadNextWave(void* pvParameters)
FreeRTOSのキュー待ちタスクです。
- 音声データを書き込むDMAメモリのポインタ
- オーディオ再生のインスタンス
を使ってDMAメモリに音声データを書き込みます。
音声データの処理
wavのデータフォーマット
16bitの整数型データを対象にします。
-32768〜32767の値を持っています。
STM32F407のDACは12bitで0〜4096までの値なので、そのままDACに転送というわけにはいきません。
前職ではこんなふうにしていました。
int dacData = wavData;
dacData += 0x8000;//0〜65535に変換
dacData >>= 4; //12bitに変換
DAC->DHR12R1; //DACレジスタにライト
ところが、DACのレジスタには左詰めという仕様が存在することを知りました!
なので、こう書けばOKということがわかりました。
int dacData = wavData;
dacData += 0x8000;//0〜65535に変換
DAC->DHR12L1; //DACレジスタに左詰めでライト
ちょうど下位4ビットがなくなってくれます。
あとは、単純にデータを持ち上げるだけなら、このように書くのがカッコイイかも。
int dacData = wavData;
dacData ^= 0x8000;//0〜65535に変換, 0x8000持ち上げるのと同じ効果
DAC->DHR12L1; //DACレジスタに左詰めでライト
実際はDACのレジスタに転送せず、DAC用メモリへ書き込みます。
DMA転送開始時に
HAL_DAC_Start_DMA(&hdac, _dacCh, (uint32_t*)_dac_buffer, WAVE_SAMPLES, DAC_ALIGN_12B_L);
と指定すれば左詰めで転送されます。
cppソースコード
このページの最後に転載します。
最低限の機能なので、とても短くすることができました。
改良すべき点
今回掲載したものは最低限必要な機能です。
実際の音声再生としては、下記の改良が必要です。
- 再生開始時にポップノイズが発生する
- DMAの停止処理のタイミングが不適切
- 再生終了のタイミングがわからない
- 音量調整があるといい
再生開始時にポップノイズが発生する
オーディオアンプは入力段にDCカップリング用のコンデンサがあります。
オーディオデータをいきなりDACに転送するとポップノイズが発生します。
DCカップリングコンデンサが機能して微分回路になってます。
そのため、急峻な変化がD級アンプに伝わります。
これは停止時や、再生中に違う音声へ切り替えた際にも発生する可能性があります。
そこで、私はフェードイン/フェードアウトする処理を入れて、ポップノイズが出ないようにしました。
音声データの最初と最後は無音の場合が多く、短い期間にフェードイン/フェードアウトさせれば実害なないでしょう。
この辺は多少ハードウェアの知識と、サンプリングに関する知識が必要ですよ。
DMAの停止処理のタイミングが不適切
掲載しているソースでは、最後にUSBメモリから読み込んだデータが再生されません。
FatFsで最後のデータを読み込んだということはわかりますが、その時点でDMAを停止させると、そのデータが再生される前にDMAが止まります。
ポップノイズの件と同じく、最後のデータは無音の場合が多く、実質問題はなくて気づかれないでしょう。
エンジニア的にモヤモヤする場合は工夫が必要ですね。
私は
- 最後のデータ読み出した
- 再生を止めるべきか
という二段階で判断しています。
さらに、最後はフェードアウトでDACの値を0にしています。
ここはエンジニア的なこだわり😁
無音をハーフレベルの0x8000とするのもこだわりとしてはいいと思いますし、正負の値の場合の0と合いますから、考え方としては良いでしょう。
しかし、独自で作成するWAVデータとの整合が取れないことも多いです。
なので、内蔵DACを使う、そしてフェードイン/アウトをすることも考えると、無音は0とするのは割と理にかなっていると思います。
再生終了のタイミングがわからない
これはなくてもいいんでしょうが、連続再生などをする時には必要です。
適切なタイミングにコールバックを実行するようにすればいいでしょう。
私は適切にDMAを止めるタイミングでコールバックが登録されていれば実行するようにしました。
デモ動画
こちらにデモ動画を投稿しました。
今回掲載しているクラスはLAudioLiteというものですが、私のクラスはLAudioという改良された、さらに機能が追加されているものです。
オーディオをやられている方はすでにこのくらいの機能はお持ちでしょう...
この記事が「お手軽にオーディオをやってみたい」という方の参考になれば幸いです。
cppソースコード
#include "LAudioLite.hpp"
#include "FreeRTOS.h"
#include "queue.h"
#include "timers.h"
QueueHandle_t xLoadNextDataQueue;
void* audio1;
void* audio2;
extern DAC_HandleTypeDef hdac;
LAudio::LAudio(TIM_HandleTypeDef* htim, DMA_HandleTypeDef* hdma, int dacCh) :
_htim(htim),
_hdma(hdma),
_dacCh(dacCh)
{
if(_dacCh == DAC_CHANNEL_1) {
audio1 = this;
} else {
audio2 = this;
}
}
void LAudio::begin() {
if(xLoadNextDataQueue == NULL) { //セマフォを使わず、キュー・タスクを一つだけ生成して、このタスクだけが資源を利用する
xLoadNextDataQueue = xQueueCreate(LOAD_NEXT_DATA_QUEUE_LENGTH, sizeof(LOADNEXTDATAINFO));
xTaskCreate(_loadNextWave, "_loadNextWave", 256, NULL, TASK_PRIORITY, NULL);
}
HAL_TIM_Base_Start(_htim);
}
void LAudio::_loadInitialWave(char* filename) {
UINT bytesOfRead;
/*--------------------------------------------------------------------------
リードするファイルを指定、オープン
--------------------------------------------------------------------------*/
f_open(&_fp, filename, FA_READ);
f_read(&_fp, &_header, sizeof(WAVHEADER), &bytesOfRead);
/*--------------------------------------------------------------------------
再生データ読込み
--------------------------------------------------------------------------*/
LOADNEXTDATAINFO item;
getQueueInfo(&item, true); //前半
xQueueSend(xLoadNextDataQueue, &item, 0);
getQueueInfo(&item, false); //後半
xQueueSend(xLoadNextDataQueue, &item, 0);
}
void LAudio::play(char* filename) {
HAL_DMA_Abort(_hdma);
_loadInitialWave(filename);
HAL_DAC_Start_DMA(&hdac, _dacCh, (uint32_t*)_dac_buffer, WAVE_SAMPLES, DAC_ALIGN_12B_L);
}
void LAudio::stop()
{
HAL_DMA_Abort(_hdma);
}
void LAudio::getQueueInfo(LOADNEXTDATAINFO* container, bool isHalf1) {
container->audioInstance = this;
if(isHalf1) {
container->aiffBufferPtr = _dac_buffer;
} else {
container->aiffBufferPtr = &_dac_buffer[HALF_POSITION];
}
}
void LAudio::_loadNextWave(void* pvParameters) {
LOADNEXTDATAINFO item;
for(;;) {
if(xQueueReceive(xLoadNextDataQueue, &item, portMAX_DELAY) == pdPASS) {
LAudio* instance = static_cast<LAudio*>(item.audioInstance);
UINT bytesOfRead;
f_read(&instance->_fp, (char *)item.aiffBufferPtr, HALF_LOAD_BYTES, &bytesOfRead); //前半にロード
int samples = (int)bytesOfRead >> 1;
for(int i = 0; i < samples; i++) {
item.aiffBufferPtr[i] ^= AUDIO_OFFSET_LEVEL; //持ち上げてマイナスの値をなくす
}
if(bytesOfRead < HALF_LOAD_BYTES) {
HAL_DMA_Abort(instance->_hdma); //ここでDMAを止めると、最後のデータは再生されずに終わってしまうので、工夫してください
}
}
}
}
/*==============================================================================
STのDAC/DMAのコールバック関数
==============================================================================*/
void HAL_DAC_ConvHalfCpltCallbackCh1(DAC_HandleTypeDef *hdac) {
LAudio* instance = static_cast<LAudio*>(audio1);
LOADNEXTDATAINFO item;
instance->getQueueInfo(&item, true);
xQueueSendFromISR(xLoadNextDataQueue, &item, NULL); //最高の優先順位を与えているので、コールバックを抜ければ確実にキューを実行してくれる
}
void HAL_DAC_ConvCpltCallbackCh1(DAC_HandleTypeDef *hdac) {
LAudio* instance = static_cast<LAudio*>(audio1);
LOADNEXTDATAINFO item;
instance->getQueueInfo(&item, false);
xQueueSendFromISR(xLoadNextDataQueue, &item, NULL);
}
void HAL_DACEx_ConvHalfCpltCallbackCh2(DAC_HandleTypeDef *hdac) {
LAudio* instance = static_cast<LAudio*>(audio2);
LOADNEXTDATAINFO item;
instance->getQueueInfo(&item, true);
xQueueSendFromISR(xLoadNextDataQueue, &item, NULL);
}
void HAL_DACEx_ConvCpltCallbackCh2(DAC_HandleTypeDef *hdac) {
LAudio* instance = static_cast<LAudio*>(audio2);
LOADNEXTDATAINFO item;
instance->getQueueInfo(&item, false);
xQueueSendFromISR(xLoadNextDataQueue, &item, NULL);
}




