M5Stackのスピーカーの音質は悪いです。原因は3つあります。
- アンプのゲインが大きすぎて音が割れている
- 自身のディジタルノイズが重畳されている
- 音声出力の振幅の分解能が低い
1は音量を調整することで対策可能です。2はCPUの動作クロックを下げることでマシになります。3はどうしようもありませんが、1,2と比べればそこまで気になる問題ではありません。ここでは主に1について取り上げます。
#回路について
M5StackはESP32を搭載し、そのDAコンバータ出力をオーディオアンプで増幅し、スピーカーに入力しています。
M5Stackの公式サイトの回路図ではM5Stackに内蔵されているスピーカーのオーディオアンプはNS4148となっています。しかし実物はNS4150Bです。VersionはBASICとGRAYとFIREで確認済みです。また回路図上のPGNDというラインは回路図中でも接続先がなく、実物はオープンになっています。
実物に使われているオーディオアンプ NS4150Bの推奨回路図は以下です。M5Stackの回路と異なります。しかしM5Stackの回路を改造してこの推奨回路図に合わせてもゲインやノイズはほとんど変わらないことを確認済みです。Webで入手できるデータシートには詳しいことが書かれていないので実験で確かめています。
#D級オーディオアンプ
NS4150BはD級オーディオアンプです。D級オーディオアンプの出力はPWMです。つまり音の大きさは電圧の振幅ではなくONとなっている時間の割合で表されます。このPWM周波数は可聴周波数帯域よりも高周波です。しかし接続したスピーカーは実質 物理的なローパスフィルターとなり、可聴周波数帯域の音が鳴ります。
しかしそのままではオシロスコープでゲインを調査することが難しいため、測定のために電気的なローパスフィルターを追加しました。800Hz正弦波を入力して、フィルタ通過後の出力が同じ800Hz正弦波になることを確認しています。
#オーディオアンプのゲイン
オーディオアンプに800Hz正弦波を入力したときの出力波形をオシロスコープで確認し、その振幅の関係を調べました。
グラフの傾き、つまりゲインは2.94でした。出力は±1.6Vでクリップされます。なので音が割れないようにするためにはDAC出力の振幅を1.6V/2.94 = 0.54V以内に抑えればいいことになります。
さらにゲインの周波数依存性を確認しました。入力は振幅0.25Vの正弦波。低音域を除けばゲインはだいたい安定していることが分かりました。高音域の若干の減衰は測定のために外付けしたLPF(fc = 16kHz)によるものなので無視します。
#DACの出力分解能
ESP32のDAC出力は8bitで基準電圧は3.3Vです。DACの出力は1.65±1.65Vまで振れるのですが、上記の制限により音が割れないようにするためには1.65±0.54Vに抑えなければなりません。このことにより、元々8bitしかない分解能がさらに少なくなり、実質6.4bit程度になります。
#I2Sについて
ESP32に搭載されているDACの入力はI2Sです。I2SとはInter-IC Soundの略でデジタル音声データをシリアル転送するための規格です。ESP32はI2S出力モジュールも搭載しているのでそれを中でつなぐことになります。これはソフトウェアで出来ます。Arduinoの場合、該当部分のコードは以下のようになります。I2Sの初期化、データ書き込み、終了のみのシンプルな実装です。
実際のDACの分解能は8bitなのですが、i2s_config.bit_per_sampleは16bitにしておく必要があります。driver/i2sライブラリのバグなのか8bitだと上手く動きません。I2S_Write()のdataは16bitデータ配列をリトルエンディアンで格納します。こうしておくとDACはこの16bitデータの上位8bitを符号なし8bitデータと解釈して出力します。
I2S出力モジュールには送信用バッファがあり、そこに入れておけば自動で音声のサンプルレートにタイミングを合わせて送信してくれます。I2S_Write()は送信用バッファに空きが出来るまで待機します。そのためI2S_Write()を呼ぶタイミングに神経質になる必要はありません。バッファが完全に空になる前に次のデータをセットすればOKです。このため音声再生は他の処理と併用することができます。
#pragma once
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/i2s.h"
#include "esp_system.h"
void I2S_Init();
void I2S_Write(char* data, int numData);
void I2S_Stop();
#include "I2S.h"
void I2S_Init() {
i2s_config_t i2s_config = {
.mode = (i2s_mode_t)(I2S_MODE_DAC_BUILT_IN | I2S_MODE_MASTER | I2S_MODE_TX),
.sample_rate = 44100, // SAMPLE_RATE 8000 未満は正常動作しない
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT, // 8bitは正常動作しない
.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT, // ステレオ。左右のデータ書き込みが必要
// I2S_CHANNEL_FMT_ALL_RIGHTは左右のデータ書き込みが必要
// I2S_CHANNEL_FMT_ONLY_RIGHTは右のデータ書き込みだけで良い
.communication_format = (i2s_comm_format_t)(I2S_COMM_FORMAT_I2S_MSB),
.intr_alloc_flags = 0,
.dma_buf_count = 16, // 次のデータ書き込みまでにバッファを使い切ってしまう場合は増やす
.dma_buf_len = 60,
.use_apll = false
};
i2s_driver_install((i2s_port_t)0, &i2s_config, 0, NULL);
i2s_set_pin((i2s_port_t)0, NULL);
}
void I2S_Write(char* data, int numData) {
i2s_write_bytes((i2s_port_t)0, (const char *)data, numData, portMAX_DELAY);
}
void I2S_Stop() {
i2s_stop((i2s_port_t)0);
}
例えばSDカード中のwavファイルを再生するならコードは以下のようになります。音が割れないようにするために振幅を0.2倍にしています。また16bitのwavデータは符号付きなのでオフセットさせて符号なしにしています。
ここではwavファイルの形式は44100Hz, 16bit, stereo, linear PCMで決め打ちしています。
#include <M5Stack.h>
#include "I2S.h"
char data[800];
void Adjust(float gain) {
for (int i = 0; i < sizeof(data)/2; ++i) {
int16_t d = data[2*i + 1] << 8 | data[2*i];
d *= gain; // amplify
uint16_t ud = d + 0x8000; // offset
data[2*i + 1] = (ud >> 8) & 0xFF;
data[2*i] = ud & 0xFF;
}
}
void setup() {
M5.begin();
File file = SD.open("/10000.wav"); // 44100Hz, 16bit, stereo, linear PCM
file.seek(0x2C);
I2S_Init();
while (file.readBytes(data, sizeof(data))) {
Adjust(0.2f);
I2S_Write(data, sizeof(data));
}
file.close();
I2S_Stop();
}
void loop() {
}
#外付けI2S入力アンプを使用する場合
結論としてはM5StackのスピーカーはDACの出力の振幅を0.54V以内に抑えさえすれば、だいたい問題なく使えます。低音や高分解能の音が欲しい場合は内部アンプを無効化してI2Sアンプを外付けすれば良いです。M5Stackの中の基板のT1ジャンパーをショートさせれば無効化することができます。