LoginSignup
156
92

More than 3 years have passed since last update.

M5Stackの限界に挑戦~高音質スピーカー再生

Last updated at Posted at 2020-07-23

0. はじめに

M5Stack のスピーカーは低音質で有名です。私も M5Stack 入手直後、サンプルスケッチで Wave File をスピーカーで再生してみて、前評判通りの低音質にがっかりしました。そこで、この音質問題に取り組まれた先駆者様の知恵を参考にしつつ、高音質化の限界1に挑戦しました。この記事では、そのプロセス、ソフトウェア実装例、改善効果をレポートします。

先駆者様

 文献1 : Tw_Mhage 様 M5Stackのスピーカーの音質が悪い原因と対策
 文献2 : N.Yamazaki 様 M5Stackの音量を抵抗1つで調節する - N.Yamazaki's blog
 文献3 : macsbug 様 M5Stack speaker noise reduction
 他

1. 概要

M5Stack のスピーカーの低音質原因は、文献1で以下のように分析されています。

1.アンプのゲインが大きすぎて音が割れている
2.自身のディジタルノイズが重畳されている
3.音声出力の振幅の分解能が低い

文献1 / 文献2では、それぞれソフト/ハードによる「音割れ対策」、文献3では、ハードによる「ノイズ対策」が提案されています。

本記事は、ソフトによる 「ノイズ対策」「分解能対策」を提案し、高音質化を目指します。

progress_overview.png

2. 対策詳細

2.1 音割れ対策

文献1 のおさらいです。スピーカー駆動アンプ (NS4150B) のゲインが約3倍2であり、音割れ(過増幅歪)を起こします。DACからアンプへの入力振幅が 3V の場合、アンプは本来 3V × 3倍 ⁼ 9V を出力すべきですが、実際の出力はアンプの電源電圧範囲 3.3Vに制限されます。出力波形はピークが飽和してしまい、拡声器を通したような歪んだ音になります。

DACの出力振幅を、あらかじめ 1/3(=アンプゲインの逆数)に絞り、アンプの飽和を対策します。

効果確認

詳細な原因・対策方法は、文献1で説明されています。ここでは追試にとどめます。

ソフトウェアで 8bit 振幅のノコギリ波を生成し、これを1倍または1/3倍にして DAC1 で出力します3。DAC1 出力(青)とアンプ出力(赤)を、同時にオシロスコープで観測します。アンプ出力は、PWM波形であるため、外付けローパスフィルタ(LPF)でアナログ振幅に変換して観測します。

Distortion_eva_topology.png
DAC_x1p00_x0p33_AMP.png

DACフルスケール出力(左) :ノコギリ波を DAC フルスケールいっぱい (255LSBp-p) で出力した結果です。アンプ出力(赤)は、DAC出力中央 1/3 は増幅動作が保たれています。その外側、DAC出力上側・下側1/3では飽和しています。

DAC1/3出力(右):ノコギリ波を DAC フルスケールの1/3 (85LSBp-p) に絞って出力した結果です。アンプ出力は、全範囲で増幅動作が保たれ、飽和が対策できています。4

2.2 DACノイズ対策

M5Stack の回路は、CPU・GPIO・DAC などの電源が蜜結合になっています。CPU・GPIOが動作すると、電源電圧にノイズが生じ、DAC 出力に伝搬してしまいます。結果的にスピーカー再生音にノイズが乗ります。

先の音割れ対策では、DACの出力振幅を1/3に絞りました。これをDACフルスケールの中央1/3で出力するのではなく、電源ノイズの影響が小さい下1/3で出力します。これにより、DACに現れる電源ノイズを1/3に減らせます。

電源と DAC の関係

M5Stack 回路図ESP32 マニュアル から類推した M5Stack の DAC と電源の関係図を示します。5

DAC_Power_Topology.png

ESP32 には、複数の電源端子 (VDDxxx_yyy) があります。ここで関係するのは、CPU系の VDD3P3_CPUと、RTC系の VDD3P3_RTC の 2つです。 ESP32 Technical Reference Manual 29.5 DAC を意訳引用します。

抵抗ストリング型の 8-bit DAC である
基準電圧は VDD3P3_RTC **である
・DAC 出力算出式 : DACn_OUT = **VDD3P3_RTC
x PDACn_DAC/256 [V] ...…式(1)

この DAC は、 VDD3P3_RTC の電源電圧を抵抗で分圧し、選択出力する方式といえます。

M5Stack の回路は、VDD3P3_RTCVDD3P3_CPU が共用電源となっています。共用ということは、CPU や GPIO の動作負荷で生じた電源ノイズが、DAC にも伝搬するということです。VDD3P3_RTC の電圧を、電源ノイズ混じりの (3.3 + N) [V] として、式 (1) に代入します。

 DACn_OUT = (3.3+N) x PDACn_DAC/256
= PDACn_DAC x 3.3/256 + PDACn_DAC x N/256 [V] ....式(2)

DAC出力に現れる電源ノイズの大きさは、DAC出力レベルに比例し、255で最大、128で半分、0で最小6となります。

ノイズ低減アイディア

さて、音割れ対策では、DACの出力振幅を1/3に絞りました。これを DAC フルスケールの中央1/3で出力するのではなく、電源ノイズが小さい下1/3で出力したらどうでしょう。式(2)で考えた場合、

 DAC中央1/3の中心出力 (PDACn_DAC = 128 ) : DACn_OUT = 1.65 + 0.5N
 DAC下側1/3の中心出力 (PDACn_DAC = 43 ) : DACn_OUT = 0.55 + 0.168N

 DAC下側出力と DAC中央出力のノイズ比 : 0.168N / 0.5N = 1/3

となり、電源ノイズの影響を1/3に抑えることが出来そうです。

DAC出力の下側1/3は、音割れ対策前に飽和を起こしていた領域でした。利用できるのでしょうか?大丈夫です。M5Stack の回路図によれば、DACとアンプはコンデンサ C43 を介して接続されています。DAC出力のDC(平均電圧)は、コンデンサのDC カット効果により通過しません。アンプ側では電源中点 (1.65V) にバイアスされます。DACの出力振幅がフルスケール1/3の場合、出力位置は下1/3でも問題ないということです。

M5_CORE_AMP_wz_DCCut.png

効果確認

GPIO に疑似負荷を与え、その電源ノイズが DAC 出力に現れるようにします。そのうえで、DACフルスケールの上・中・下1/3出力による電源ノイズの差・飽和の有無を測定します。

GPIO197 (緑): 疑似負荷信号です。0.5ms 周期のパルス(緑)を出力します。
1kΩ : 疑似負荷です。GPIO19 = H の時、3.3V / 1kΩ = 3.3mA の電流が流れます。
DAC1出力 (青): 振幅 85LSB の Sin波を、DAC フルスケールの上・中・下 各1/3で出力します。電源ノイズの電圧依存を確認します。
アンプ出力(赤): LPF 経由で測定します。飽和の有無を確認します。

DAC_Noise_Eva_Topology.png
DAC_Noise_Sin_H_C_L_WC.png

疑似負荷出力(緑):このパルスのタイミングで、DAC1出力にノイズが生じています。
DAC1出力(青):DAC出力の上→中→下 の順に、ノイズが減少しています。
アンプ出力(赤):DAC出力の上/中/下によらず、飽和は発生していません。

DACの出力範囲を下1/3とすれば、電源ノイズが低減可能なことが判りました。

これは対処療法です。電源ノイズは1/3になるだけで、完全にはなくせません8。しかし、1/3 は $20log(1/3) = -9.5dB$ に相当し、聴感上も十分にノイズ低減効果を感じることができます。

2.3 分解能対策

CD (コンパクトディスク) などの音源は、16bit(96dB) の分解能があります。これに対し、M5Stack / ESP32 の内蔵 DAC は、8bit(48dB) の分解能しかありません。16bit 音源の下位 8bit を切り捨て、8bit DAC で再生すると、量子化ノイズが増加し、アナログ電話のような音質になります。

先の2つの対策では、DAC の出力振幅を 1/3 に絞りました。8bit 分解能の 1/3 しか利用できないので、実質分解能は $log_2(2^8/3) = 6.4bit$ 相当、ダイナミックレンジは $20log_{10}(2^8/3) = 38.6dB$​ になります。CDの 16bit(96dB)​ には程遠い分解能です。

そこで、音声信号の オーバーサンプリングとマルチビットΔΣ(デルタシグマ)変調処理により、この 6.4bit を超える実質分解能を実現します。

ΔΣ変調とは

ΔΣ変調は、入力の振幅情報を、時間軸の密度情報に変換します。限られた出力分解能で、それを超える実質分解能が得られる技術です。以下に、一般的な「ワンビットΔΣ変調」と、今回利用する「マルチビットΔΣ変調」の構成例・出力例を示します。

DeltaSigma_Overview_OneBit_vs_MultiBit.png

ワンビットΔΣ変調

PDM (Pulse Density Modulation) や DSD (Direct Stream Digital) の説明でよく見かける図です。入力と前回出力との差分(Δ)を、繰り越し(Σ)、量子化器 (Quantizer) で2値化して出力します。その出力は、次回の入力との差分演算に再利用します。これにより、入出力の誤差を常に打ち消すような負帰還がかかります。結果的に、入力の振幅情報は、時間軸を使った2値 = ワンビット の密度情報に変換された出力となります。9 10

マルチビットΔΣ変調

ワンビットΔΣ変調との違いは、 Quantizer が多値出力であること、最終出力が低ビットDAC出力であることです。低ビットDAC出力の各階調で、ワンビットΔΣ変調を行うような動作となります。11

変調の動作例

「整数出力のみが許されたシステムで、何としても 365.25 という実数相当を表現せよ」という例題を、ΔΣ変調で解決する方法を考えます。先のマルチビットΔΣ変調のブロック図に、例題の要素を当てはめます。入力は 実数 365.25 固定12、ΔΣは実数演算、Quantizer は実数→整数変換手段となります。

ブロック図・タイミングチャート
DeltaSigma_365_366.png

C言語実装例

DeltaSigmaExample.c
void main(void){
    float fin = 365.25;                     // 実数入力
    float fdelta;                           // Delta(Δ)
    float fsigma = 366;                     // Sigma(Σ) & 初期値
    int   iqtout = 366;                     // 整数(量子化)出力 & 初期値
    printf("Num, fin   , fdelta, fsigma, iqtout\n");

    for (int i = 1; i <= 8; i ++){
        fdelta  = fin - (float)iqtout;      // 入出力差分(Δ)
        fsigma += fdelta;                   // 入出力差分の繰越(Σ)
        iqtout  = (int)fsigma;              // 量子化
        printf("%3d, %6.2f, %6.2f, %6.2f, %3d \n",
                i,   fin,   fdelta,fsigma,iqtout);
    }
}

実行結果

Num, fin   , fdelta, fsigma, iqtout
  1, 365.25,  -0.75, 365.25, 365 
  2, 365.25,   0.25, 365.50, 365 
  3, 365.25,   0.25, 365.75, 365 
  4, 365.25,   0.25, 366.00, 366 
  5, 365.25,  -0.75, 365.25, 365 
  6, 365.25,   0.25, 365.50, 365 
  7, 365.25,   0.25, 365.75, 365 
  8, 365.25,   0.25, 366.00, 366 

実行結果の fsigma をみると、入力 fin と、前回の出力 iqout の差分 fdelta を、順次繰り越していく様子が判ります。

fsigma を整数化した iqtout をみます。1~3回目は365を出力します。4回目は繰り越し誤差をまとめた366、いわば「うるうデータ」を出力します。以降、4回で1巡する周期出力となります。出力を短周期でみると、「うるうデータ」の発生で凸凹しますが、長周期でみれば、平均365.25となります。無事、ΔΣ変調により、整数出力と時間軸を使って、実数相当が表現できました。13

量子化ノイズと周期ノイズ

実数を整数化したり、情報のビット数を減らしたりすると、元情報と誤差を生じます。これを量子化誤差、量子化ノイズなどといいます。先の例では、元の 365.25 が 365 になって、0.25 の誤差 = ノイズを生じている状態です。ΔΣ変調は、この誤差を繰り越し、365, 365, 365, 366という凸凹な周期出力に変換しました。凸凹ということは、ノイズです。これは、量子化ノイズを、元情報にはない凸凹な周期ノイズに変換することを意味します。不思議なことですが、この周期ノイズが、量子化ノイズを減らすのです。先の DAC ノイズ対策とは対照的です。

音質改善目的でΔΣ変調を使う場合、この周期ノイズが音として聞こえるようでは困ります。解決には、次の技術を使います。

高次ΔΣ変調とオーバーサンプリング

ΔΣ変調による量子化ノイズ改善と、ノイズに変換される周波数との関係を示します14。システムのサンプリング周波数を fs とします。

DeltaSigma_NoiseShaping_vs_OSR.png

ΔΣ変調は、fs / 6 より低域の量子化ノイズが、fs / 6 より高域に移動し、低域の実質分解能が改善する特性をもちます。これをノイズシェーピング効果といいます。先の例では、0.25の誤差を集めて365、366 の周期ノイズとし、実質 365.25 が表現できた状態です。また、ΔΣ変調の次数 (段数) が大きいほど、ノイズシェーピング効果も高くなります。

fs = 44.1kHz の音源に、直接ΔΣ変調を行なうと、fs / 6 = 7.35kHz つまり可聴帯域内 ( < 20kHz )でノイズが増加してしまいます。また、7.35kHz以下で、十分な量子化ノイズ低減効果が得られません。

そこで、あらかじめ音源をオーバーサンプリングして、fs を高くし、fs/6 が可聴帯域外 ( > 20kHz )となるようにします。元の fs に対する オーバーサンプリング fs の倍率を、OSR (オーバーサンプリングレート)といいます。

OSR ≧ 4 とすれば、4 x 44.1kHz/6 = 29.4kHz となり、ノイズ増加は可聴帯域外になります。また、3次以上のΔΣ変調を行なえば、8kHz付近までは -31dB (5.1bit相当) と、十分な量子化ノイズ低減効果が得られます。M5Stack の実質分解能 6.4bit と合わせて11.5bit 相当の分解能を持てることになります。

効果確認

16bit 信号を 8bit 化する際の「ΔΣなし」「1次ΔΣ」「3次ΔΣ」の効果を、表計算ソフトでシミュレーションした結果を示します。

 共通条件:fs = 176.4kHz (OSR=4)
 グラフ青:16bit 1016LSBp-p (-36.2dB) 1kHz Sin波形入力
 グラフ赤:各処理の 8bit DAC 出力
 グラフ緑:各処理の DAC出力に可聴帯域の 20kHz LPF を通した結果15

DeltaSigma_Sim_Order_0_1_3.png

ΔΣなし:16bit 入力の下位 8bit を単純に切り捨てた例です。M5Stack / ESP32の 8bit DAC で、16bit 音源の上位 8bit だけを再生する状態にあたります。8bit DAC出力は、階段状になってしまいます。LPF 出力も、階段状のままであり、もはや元のSin波形の再現は難しいようです。

1次ΔΣ:8bit DAC出力の各諧調で、入力レベルに応じた密度変調が行われています。LPF 出力は「ΔΣなし」に比べ、かなり元のSin波形に近づきました。しかし、若干のノイズがあり、効果が不十分です。

3次ΔΣ:{}内部のΔΣ処理を3回繰り返したものです。8bit DAC出力は、高次ΔΣ変調のノイズシェーピング効果により、高周波の頻度が多くなり、その振幅も増えています。LPF出力は、「1次ΔΣ」に比べてノイズも減り、かなり元のSin波形に近づきました。

マルチビットΔΣ変調で、低分解能 DAC でそれを超える実質分解能が得られることが判りました。 M5Stack / ESP32 のように、低 bit DAC しかないシステムには格好の技術といえるでしょう。16

3. ソフトウェア実装例

文末にソースコード例を掲載しました。M5Stack FIREM5Stack Basic17 で動作確認済みです。

3.1 ソフトウェアと周辺ブロック図

Software_BlockDiagram.png

3.2 ソースコード解説

基本構造は文献1を参照しました。各対策処理のご本尊は、Filter_Process 関数内にあります。

音割れ・DACノイズ対策処理

一般的な PCM 音源データは、符号付き整数( int )型 です。ESP32 の 8bit DAC データ形式は 符号なし整数( unsigned )型 です。このため、各データの符号 bit を反転して符号付き→符号なし 変換を行います。次に、ステレオのL/Rデータを平均してモノラル化します。これを float 型に変換し、 Gain / Offset 処理をかけ、DACレンジの中央 1/3 や下1/3で出せるようにします。

uint32_t ud = *(pti32++);                     // i16 stereo をu32で読出
ud ^= 0x80008000;                             // i16 stereo -> u16 stereo
ud = ((ud & 0xffff) + (ud >> 16)) >> 1;       // Stereo -> Mono (u16L+u16R)/2
float fd = (float)ud, fin;                    // 後工程用float変換
                                              // 音割れ/DACノイズ対策処理
fd    = fc[menu[1].now].gain * fd             // Mul. Gain
      + fc[menu[1].now].offset;               // Add. Offset
...

オーバーサンプリング処理

1データ(サンプル)の入力に対し、データ数を OSR 倍にして出力をします。

1つの入力データ fd を、OSR 回のループの間、保持 (0次ホールド)します。データ数が OSR倍になりますが、これが可聴帯域内となるようLPF処理をします。オーディオ処理では、位相特性の良い FIR型 LPF が好まれますが、今回は係数と演算回数が少ない Biquad IIR型 LPFを使用しました。

for (int j = 0; j < OSR; j ++) {          ////// xOSR Over Sampling Loop
  z[2] = z[1];                                // Biquad z2 Shift
  z[1] = z[0];                                // Biquad z1 Shift
  z[0] = lpf.k *fd -lpf.a1*z[1] -lpf.a2*z[2]; // Biquad z0 Update
  fin  = z[0]      +lpf.b1*z[1] +lpf.b2*z[2]; // Biquad result
  ...

ΔΣ変調処理

ΔΣ変調の動作例では、float型の処理を例示しました。ここでは、DACデータ設定との親和性から、int32_t型でΔΣ変調処理をします。

for( int k ...) {} のループ内部が、k次のΔΣ変調処理です。入出力の差( iin - iqtout ) を、isigma[] に繰り越し加算しています。ESP32 は、I2S 経由で 8bit DAC にデータを渡す際、16bit データとする必要があります。上位 8bit だけが DAC に設定され、下位 8bit は無視されます。これに合わせて、Quantizer 処理は、入力データの下位 8bitをマスク(ゼロデータ化)しています。

  int32_t iin = (int32_t)(fin + 0.5);         // float -> i32 data
  for (int k = 0; k < dso; k++ ) {          //// xOrder DeltaSigma Loop(dso=0:Thru)
    isigma[k] += (iin - iqtout);              // Delta-Sigma Core
    iin = isigma[k];                          // Update Output (iin)
  }                                         //// Delta-Sigma Loop End
  iqtout = iin & 0xffffff00;                  // Quantize(8bDACで無視される下位8bitをMask)

  ud = (uint32_t)constrain(iqtout, 0, 65535); // Clip to u16 size
  ud |= (ud << 16);                           // u16 Mono → Dual u16 Mono
  *(pto32++) = ud;                            // DAC出力バッファに書込
}                                         ////// OverSampling Loop End

3.3 UI仕様

処理選択 UI (ユーザー・インターフェイス)を付け、各対策前後の効果を比較できるようにしました。M5Stack のA/B/Cボタンで処理を選択できます。

Source : 音源選択(ボタンA)

Zero

無音です。起動時はこれが選択されます。再生停止やDACノイズ観測に使用します。

-39dB Sin / -26dB Sin

小振幅 1kHz Sin波形のテストトーンです。-39dB Sin は、Gain = 1/3(-9.5dB) のとき、8bit分解能 (-48dB) を下回る信号となります。ΔΣ変調で、これが再生可能かを確認できます。
-26dB Sin は、Gain = 1/3 のとき、8bit 分解能で 4LSBの振幅となります。ΔΣ変調後の波形・ノイズシェーピング特性観測や音質確認に利用します。

Decay Sin

振幅が指数的に減衰する 1kHz Sin波形のテストトーンです。0.25秒で振幅が半減(1bit分)し、4秒間で16bitから0bit振幅まで減衰します。2秒目に 8bit 未満の分解能となります。これが ΔΣ変調で再生可能か確認します。

Wave file

SD カードのルートフォルダにある sample.wav ファイル を再生します。16bit 44.1kHz stereo 専用です。

DAC Lvl. : DACレベル選択(ボタンB)

Thru (1/1)

音源の振幅を絞らずに出力します。音割れ対策とノイズ対策をする前の状態です。

Mid 1/3

音源の振幅を1/3に絞り、DAC中央1/3で出力します。音割れ対策のみの処理です。

Low 1/3

音源の振幅を1/3に絞り、DAC下1/3で出力します。音割れとノイズ対策の処理です。

DS Order : ΔΣ次数選択(ボタンC)

Thru

オーバーサンプリング処理のみです。ΔΣ変調処理をしません。

1st / 2nd / 3rd / 4th DS

1~4次 ΔΣ変調処理を選択します。

なお、オーバーサンプリングは OSR = 4 で常時処理されるようにしています。OSR は UIでは変更できませんが、ソースコード冒頭の #define OSR 4 を1~8の任意値に変更可能です。

4. 効果確認

 ソースコード例を使用して、各対策前後の出力を測定し、その効果を確認します。測定トポロジーは、ソフトウェアと周辺ブロック図 を参照願います。

4.1 効果全貌

アンプ出力を、外付けLPF 経由で PC の Audio LINE に入力し、音声編集ソフトAudacity で振幅を測定します。音源は Decay Sin を使い、大振幅での飽和状態、小振幅でのDACノイズ限界や分解能限界を確認します。左から「対策なし」「音割れ対策」「DACノイズ対策」「分解能対策」と3つの対策を重ねていった結果です18。上段は振幅をリニアに、下段は振幅を対数表示(㏈)したものです。横軸は「秒」です。

eva_ac_decay_step_by_step.png

対策なし : 開始直後の大振幅において、波形ピークが飽和しています。音源が小振幅になるにつれ、振幅表現が乏しく、段差状になっています。やがて音源の振幅が 8bitを下回ると、分解能限界をむかえ、ばっさり出力がなくなっています。DAC ノイズフロアも高いです。

音割れ対策 : 音源の振幅をあらかじめ1/3にすることで、波形ピークの飽和が改善されました。しかし分解能が犠牲(8bit -> 6.4bit)となり、「対策なし」よりも早い段階で分解能限界をむかえています。DAC ノイズフロアも高いままです。

DACノイズ対策 : DAC下1/3で出力することで、DACノイズフロアが -9dB 下がりました。ほぼ理論値(-9.5dB)通りです。このあたりが、M5Stack の DAC ノイズ改善限界と思われます。

分解能対策 : マルチビットΔΣ変調により、分解能は -50dB 付近の DAC ノイズフロアを超え、Audacity の対数表示限界の -60dB 付近 (10bit 相当) まで改善しました。このあたりが、 M5Stack DAC 分解能改善の限界と思われます。

4.2 DACノイズ対策効果

周波数軸(スペクトラム)で、DACノイズ対策効果を確認します。DAC1 出力を PC の Audio LINE に入力し、 WaveSpectra で信号の振幅・周波数特性を観測します。上段は時間軸、下段は周波数軸です。確認を容易にするため、周波数軸は+40dBシフトしています。音源は、Zero (無音)です。

eva_ws_zero_x0p33_mid_to_low.png

Mid 1/3 (対策前):DAC中央1/3で「無音」を出力した結果です。「無音」であるはずですが、ノコギリ波状の3~4kHzのノイズが確認できます。周波数軸では、50Hz、3~4kHz付近、44.1kHzにピークが見られます。

Low 1/3 (対策後) : DAC下側1/3で「無音」を出力した結果です。Mid 1/3 に比べ、ノイズ振幅が減衰しています。周波数軸では、帯域全体でノイズが約9dB下がっています。これは DACノイズ対策 で解説したノイズ低減効果そのもので、聴感上もはっきりと効果が判ります。

参考:ノイズ要因

50Hz : ソフトウェア処理周期のノイズです。44.1kHz の音源を、882 サンプル単位でバッファ処理していますが、この周期が 44100 / 882 = 50[Hz] となります。サンプル単位数を変えると、この周波数も変わります。聴感上認知しにくいよう、なるべく低い周波数にするのが良いようです。

3~4kHz 付近 : 周期変動を持つ下降ノコギリ波形であること、USB給電をすると止まることから、昇圧DCDCコンバータ由来のノイズと思われます。詳細解析には至っておりません。聴感上は「ギー」という不快な音に聞こえます。

44.1kHz : LCDバックライトPWMの周期ノイズです。setup関数内で ledcWriteTone(7, SRC_FS ); として音源の fs と同じ周波数を指定し、可聴帯域外に移動しています。本対策は、文献3経由で lovyan03 さんの対策案を参考にしました。

4.3 分解能対策効果

ΔΣ変調の効果を周波数軸で確認します。測定条件は前項と同じです。音源は-26dB Sinです。

eva_ws_26dBSin_thru_to_3rdDS.png

ΔΣ変調なし(左):基本波1kHz の奇数倍の量子化ノイズが発生しています。

3次ΔΣ変調(右):ノイズシェーピング効果により、量子化ノイズ成分が可聴帯域(20kHz)より高域に移動しています。ノイズまみれの波形からは想像しづらいですが、聴感上、時報音のような「澄んだ」音になります。

4.4 まとめ

最後に「DACノイズ対策」と「分解能対策」効果を、アンプ出力波形( LPF 経由)で確認します。音源は-26dB Sinです。

eva_ws_26dBSin_mid_low_3rdDS.png

「対策なし」:DACの分解能に迫る大きさの電源ノイズが生じています。

「DACノイズ対策」:電源ノイズが小さくなり、DACの分解能があらわになりました。

「分解能対策」:量子化ノイズが減り、きれいな Sin 波形、きれいな再生音になりました。

以上、DAC下1/3出力による「DACノイズ対策」と、マルチビットΔΣ変調による「分解能対策」の効果が確認できました。

課題

LCD 描画時や SD カード上の Wav File 再生時、小さく「ギョロギョロ」という音が聞こえます。これは SPI バス動作時のノイズなのですが、DAC ノイズ対策でも思ったほどに低減できませんでした。これは、文献3で分析されているように、SPI系GPIOから DAC1への輻射が関係しているかもしれません。DAC1 の出力インピーダンスが高く、ノイズを拾いやすい可能性があります。他にも、「ΔΣアイドルトーン19」「高OSRでノイズ増加20」などの課題があり、別の機会に調査したいと思います。

参考:対策処理のCPU占有率

Filter_Process 関数を micros 関数で挟み込み、対策処理全体のCPU占有率を確認しました。CPU 240MHz の場合、最大17.2%と軽負荷です。CPU 80MHz の場合は、50%を超えてしまう場合があり、他のアプリケーション・タスクとの共存には配慮が必要です。ΔΣ次数を減らす、オーバーサンプリングのLPFフィルタ処理を省略する等の工夫が必要でしょう。

cpu_load.png

5. おわりに

M5Stack は、本当に素敵なハードウェアです。私もいっぺんに虜(とりこ)になってしまいました。しかし、今回の DAC やスピーカーのように、回路デザインにクセがあって、なかなか思い通りに動いてくれないところもあります。そこがまた愛しく、工夫のし甲斐があるところです。今回は、ハードに手を加えず、ソフトの創意工夫だけで、どこまでスピーカー音質の限界性能を引き出せるか挑戦してみました。

ご興味ある方は、ぜひ耳馴染んだ音源で、Wave file 再生をしてみてください21。そして、ご自身の耳で対策前後の音質を比較してみてください。主観ですが、スピーカー音質は「玩具レベル」から「小型FMラジオ(ちょっとノイズ混じり)」くらいに改善できたのではないかと思います。残ったノイズ等は、まだ改善の余地があるかもしれません。

この記事が何かのお役に立てば幸いです。

なお、M5Stack / ESP32 で「DAC下1/3出力でノイズを低減する」「マルチビットΔΣで分解能を改善する」前例を調査しましたが、今のところ見つかっておりません。もし、前例をご存じでしたらお知らせください。

参考文献

Philips TDA1540 16-BIT D/A conversion system (Philips, 1984) ~ DutchAudioClassics
CD (コンパクトディスク) 黎明期の1984年。まだ半導体プロセスが未熟で、CD規格にフィットする 16bit DAC の製造が簡単ではなかった頃の論文。14bit DAC と ノイズシェーピング処理 IC の組み合わせで、16bitを超える性能を得るというもの。このチップセットを搭載したCDプレーヤー Marantz CD-34 の音質は、現代でも通用するほど素晴らしく、今でもそこそこの価格で取引されていたり、専門の修理職人が活躍されたりしています。私も CD-34 オーナーです。

限界性能への挑戦と音質へのこだわり:河合 一 様 ~ 日本テキサスインスツルメンツ株式会社  
マルチビットΔΣ DACの説明があります。最近の Audio DAC には積極活用されているようです。

「ΔΣ変調」の解説(1) ~ しなぷすのハード製作記
少し詳しいΔΣ変調 ~ electric ホロン様
線形・非線形ロバスト制御理論を用いたデルタシグマ変調器の設計手法 : 喜田 健司様 ~ 九州大学学術情報リポジトリ
ΔΣ変調、ノイズシェーピング効果の原理・効果を深く知りたい人向け。喜田様の論文は高次ΔΣ変調の理解と実現に役立ちました。

階段グラフのつくりかた(カクカクな ~ おっ
Excel で DAC 出力をそれっぽく描く方法の参考。誤差表示を使うというのが目からウロコ。

利用ツール

Audacity
Windows用オーディオ編集ツール(フリーソフト)。秀逸です。

WaveSpectra - efu's page
Windows用オーディオアナライザ(フリーソフト)。秀逸です。

Converting Analog into Digital (IIR) Filters - Manually or by AnaDigFilt.exe - - The Electronics Section of Beis.de
オーバーサンプリング用 18-22kHz IIR LPF 設計に利用。

RLCローパス・フィルタツール - OKAWA Erectric Design
D級アンプのPWM出力をアナログ振幅に変換するための外付けRLC LPF設計に利用。

SideBB for M5Stack ~ スイッチサイエンス
M5Stack の信号計測や外付けRLC LPFの構築に利用。ちょっとした実験にとても便利です。

ソースコード例

IDE : Arduino IDE 1.8.13
ターゲットボード : M5Stack Basic17 / M5Stack FIRE

M5Stack_HiFi_Speaker_Estim.ino
// M5Stack_HiFi_Speaker_Estim.ino geachlab 2020
#define SD_WAV  "/sample.wav" // SDCard上のWaveFile定義.44.1kHz 16bit Stereo専用
#define SRC_FS  44100         // Source Sampling Rate [Hz]
#define OSR     4             // Over Sampling Rate (1~8) 
#define DAC_FS  (OSR *SRC_FS) // DAC Sampling Rate [Hz]
#define SPF     882           // Sample per Frame (Buffer処理単位)
#define DSOMAX  4             // Max. of DeltaSigma Order
#define SIN_FRQ 1000.0        // 1kHz
#define SINTBLMAX (SRC_FS /4) // 44100/100*25 = 11025 (.25s分。半減単位)
#define DECAY_MAX 16          // Sin振幅を半減させる回数 (16*.25s = 4sec) 

#include <M5Stack.h>
#include "driver/i2s.h"

char src_buf[4*SPF    ]={0};  // 16bit/spl*stereo*spl/Frame
char dac_buf[4*SPF*OSR]={0};  // 16bit/spl*stereo*spl/Frame *OverSamplRate

struct {                  ////// Biquad Filter Coef.
  const float k, a1, a2, b1, b2; // b0 is fixed to '1', b1 & b2 MUST BE normalized
} lpf = {                   //// OSR Val.
#if   OSR >= 8                // fc = 0.05fs
  .k = +0.0208307252, .a1 = -1.552247273, .a2 = +0.635570174,
#elif OSR >= 6                // fc = 0.067fs
  .k = +0.0347860374, .a1 = -1.407505344, .a2 = +0.546649494,
#elif OSR >= 4                // fc = 0.1fs
  .k = +0.0697808942, .a1 = -1.126428906, .a2 = +0.405552483,
#elif OSR >= 2                // fc = 0.2fs
  .k = +0.2132071969, .a1 = -0.339151185, .a2 = +0.191979973,
#else // OSR < 2              // fc = 0.4fs (18kHz at 44k1, 20k at 48k)
  .k = +0.6632098902, .a1 = +1.209579277, .a2 = +0.443260284,
#endif
  .b1 = +2, .b2 = +1,         // Fixed (b0,b1,b2)=(+1,+2,+1)
};

// UIメニュー定義
struct {
  const char list[6][9]; int max, old, now;
} menu[3] = { //list0      list1      list2      list3      list4       max old now
  {{"Source  ","Zero    ","-39dBSin","-26dBSin","DecaySin","Wavefile"},  5, -1,  0},
  {{"DAC Lvl.","Thru    ","Mid 1/3 ","Low 1/3 ","RESERVED","RESERVED"},  3, -1,  0},
  {{"DS Order","Thru    ","1st DS  ","2nd DS  ","3rd DS  ","4th DS  "},  5, -1,  0},
};

// Source Fillサブメニュー定義
typedef enum {
  Init = -1, Zero = 0, Sin = 1, Decay = 2, Wav = 3
} FillType;

// DAC Lvl.用 Gain/Offset定義
struct {
  const float gain, offset;
} fc[3] = {                       // MenuMode Gain 8bit切上補正 純オフセット
  { +1.000000000,   +128.00000 }, // Thru     1/1  +128
  { +0.333333333, +21973.33333 }, // Mid 1/3  1/3  +128       (1-1/3)*(2^15)
  { +0.333333333,   +213.33333 }, // Low 1/3  1/3  +128       +256/3
};

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 = DAC_FS,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT, // 内部DACは上位8bitが再生対象
    .channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT, // ステレオ。左右データ書込必要
    .communication_format = I2S_COMM_FORMAT_I2S_MSB,
    .intr_alloc_flags = 0,
    .dma_buf_count = 16,      // 現物合わせ
    .dma_buf_len = SPF,       // 1024以下。今回はサンプルフレーム幅に合わせた
    .use_apll = false         // 75kHz以上(OSR>=2)でtrue指定すると正常動作しない
  };
  i2s_driver_install( I2S_NUM_0, &i2s_config, 0, NULL );
  i2s_set_pin( I2S_NUM_0, NULL );
}

void menu_print(void) {
  M5.Lcd.setTextSize(2);                            // Set Textsize for Menu
  for ( int i = 0; i < 3; i++ ) {                   // Main loop
    if ( menu[i].now != menu[i].old ) {             // When menu Update
      menu[i].old = menu[i].now;                    // Update menu_old
      for ( int j = 0; j <= menu[i].max; j++ ) {    // Sub loop
        if (( j == 0 ) || ( menu[i].now == j - 1))  // When Reference / Marked Menu
          M5.Lcd.setTextColor(BLACK, WHITE);        //   Inverted Text Color
        else                                        // When Unmarked Menu
          M5.Lcd.setTextColor(WHITE, BLACK);        //   Normal Text Color
        M5.Lcd.setCursor(i *9 *12 +4, (14 -j) *16); // Set location, Text Size:12x16
        M5.Lcd.print(menu[i].list[j]);              // print Marked/Unmarked Menu
      }
    }
  }
}

void menu_init(void) {
  M5.Lcd.clear(BLACK);
  M5.Lcd.setBrightness(255);
  M5.Lcd.setTextColor(YELLOW);
  M5.Lcd.setTextSize(2);
  M5.Lcd.setCursor(0, 6);
  M5.Lcd.println("M5Stack HiFi Playback Demo");
  menu_print();
}

void menu_update(void) {
  M5.update();                              // ボタン操作情報の更新
  if        (M5.BtnA.wasReleased()) {       // Aボタン短押し : Menu0 Up
    if (++menu[0].now >= menu[0].max) menu[0].now = 0;
  } else if (M5.BtnA.wasReleasefor(500)) {  // Aボタン長押し : Menu0 Down
    if (--menu[0].now < 0) menu[0].now = menu[0].max - 1;
  } else if (M5.BtnB.wasReleased()) {       // Bボタン短押し : Menu1 Up
    if (++menu[1].now >= menu[1].max) menu[1].now = 0;
  } else if (M5.BtnB.wasReleasefor(500)) {  // Bボタン長押し : Menu1 Down
    if (--menu[1].now < 0) menu[1].now = menu[1].max - 1;
  } else if (M5.BtnC.wasReleased()) {       // Cボタン短押し : Menu2 Up
    if (++menu[2].now >= menu[2].max) menu[2].now = 0;
  } else if (M5.BtnC.wasReleasefor(500)) {  // Cボタン長押し : Menu2 Down
    if (--menu[2].now < 0) menu[2].now = menu[2].max - 1;
  } else return;  // ボタン操作がなかったときは menu表示をせずに終了
  menu_print();   // ボタン操作があったときのみ menu表示を更新
}

static float flat_sin_tbl[SINTBLMAX];
static float decaysin_tbl[SINTBLMAX];

size_t fill_data(int type, float dB) {
  static int32_t ct = 0, ct2 = 0;
  static File wav;
  if ( type == Init ) {
    wav = SD.open(SD_WAV);
    wav.seek(0x2C);  // Skip wav header
    for ( int32_t i = 0; i < SINTBLMAX; i ++ ) {
      flat_sin_tbl[i]
        = sin(2.0 * PI * i * SIN_FRQ / SRC_FS); //Sine wave, -1~+1
      decaysin_tbl[i]
        = flat_sin_tbl[i]
          * pow(0.5, (float)i / SINTBLMAX);     // x 減衰音 tbl長で振幅半減
    }
    return 0;
  }
  int32_t *pto32 = (int32_t *)src_buf;
  dB = constrain( dB, -96, 0);                  // Clip to -96~0
  float f;
  float g  = 32767.0 * pow(10, dB / 20);        // for flat gain
  float gd = g * pow(0.5, (float)ct2);          // for decay gain
  size_t r_size = sizeof(src_buf);
  if ( type == Wav ) {
    r_size = wav.readBytes(src_buf, sizeof(src_buf));
    if ( r_size != sizeof(src_buf) ) {
      wav.seek(0x2C); // rewind to start point
    }
  } else {
    for (int i = 0; i < (sizeof(src_buf) >> 2); i++) {
      switch ( type ) {
        case Decay: f = gd * decaysin_tbl[ct];  break;
        case Sin  : f = g  * flat_sin_tbl[ct];  break;
        case Zero :
        default   : f = 0;                      break;
      }
      if (++ct >= SINTBLMAX) {
        ct = 0;
        if (++ct2 >= DECAY_MAX) ct2 = 0;
        gd = g * pow(0.5, (float)ct2);          // update decay gain
      }
      int32_t id = (int32_t)(f + 0.5);          // f32->i32 & Round Up
      id = constrain( id, -32768, +32767);      // Clip to i16 Range
      *(pto32++) = (id << 16) | (id & 0xffff);  // L&R Dual Mono Data
    }
  }
  return r_size;
}

//////////////////////////////////////////////////// Gain/Offset/OverSampling/DeltaSigma
void Filter_Process( size_t r_size ) {
  static float    z[3] = { 32768.0 };             // Biquad IIR LPF z work
  static int32_t  iqtout = 0;                     // int Quantizer Output
  static int32_t  isigma[DSOMAX] = {0};           // int Sigma[Order] Data;
  uint32_t *pti32 = (uint32_t *)src_buf;          // 演算と高速化都合でi16 x2をu32で読出
  uint32_t *pto32 = (uint32_t *)dac_buf;          // 高速化都合でu16 x2をu32で書込
  int dso = menu[2].now;                          // DeltaSigma Order(次数)取得

  for (int i = 0; i < (r_size >> 2); i++) { //////// Word Loop
    uint32_t ud = *(pti32++);                     // i16 stereo をu32で読出
    ud ^= 0x80008000;                             // i16 stereo-> u16 stereo 変換
    ud = ((ud & 0xffff) + (ud >> 16)) >> 1;       // Stereo->Mono (u16L+u16R)/2
    float fd = (float)ud, fin;                    // 後工程用float変換
                                                  // 過増幅歪/DACノイズ対策処理
    fd    = fc[menu[1].now].gain * fd             // Mul. Gain
          + fc[menu[1].now].offset;               // Add. Offset

    for (int j = 0; j < OSR; j ++) {          ////// xOSR Over Sampling Loop
      z[2] = z[1];                                // Biquad z2 Shift
      z[1] = z[0];                                // Biquad z1 Shift
      z[0] = lpf.k *fd -lpf.a1*z[1] -lpf.a2*z[2]; // Biquad z0 Update
      fin  = z[0]      +lpf.b1*z[1] +lpf.b2*z[2]; // Biquad result

      int32_t iin = (int32_t)(fin + 0.5);         // float -> i32 data
      for (int k = 0; k < dso; k++ ) {          //// xOrder DeltaSigma Loop(dso=0:Thru)
        isigma[k] += (iin - iqtout);              // Delta-Sigma Core
        iin = isigma[k];                          // Update Output (iin)
      }                                         //// Delta-Sigma Loop End
      iqtout = iin & 0xffffff00;                  // Quantize(8bDACで無視される下位8bitをMask)

      ud = (uint32_t)constrain(iqtout, 0, 65535); // Clip to u16 size
      ud |= (ud << 16);                           // u16 Mono → Dual u16 Mono
      *(pto32++) = ud;                            // DAC出力バッファに書込
    }                                         ////// OverSampling Loop End
  }                                         //////// Word Loop End
}

void setup() {
  M5.begin();
//setCpuFrequencyMhz(80);     // 80MHzのほうがDACノイズ低減に有利。ただしBasicでSDカード再生に失敗する場合あり
  ledcWriteTone(7, SRC_FS );  // LCDバックライトのPWM周期をSRC_FSと同じにして可聴帯域外とする
  menu_init();
  i2s_init();
  fill_data( Init, 0 );
}

void loop() {
  size_t r_size = 0;
  for ( int i = 0; i < (SRC_FS / SPF); i++ ) {
    menu_update();                                    // ボタン操作読取&メニュー更新
    switch (menu[0].now) {                          //// 音源選択
      case 4 : r_size = fill_data(Wav,     0); break; // Wav File出力
      case 3 : r_size = fill_data(Decay,   0); break; // 減衰Sin 出力
      case 2 : r_size = fill_data(Sin,   -26); break; // 1/3出力時8bitで4LSB程度
      case 1 : r_size = fill_data(Sin,   -39); break; // 1/3出力時8bitで1LSB未満
      case 0 :
      default: r_size = fill_data(Zero,    0); break; // ゼロ出力
    }
    Filter_Process( r_size );
    i2s_write_bytes( I2S_NUM_0, (char *)dac_buf, r_size * OSR, portMAX_DELAY );
  }
}

注釈


  1. 当方調べ 

  2. アンプのゲインは、アンプの製造バラつき・動作温度などのアナログ要素で変化することがあります。 

  3. ノコギリ波は、非常に大きく耳障りな音が鳴ります。同じ実験をされる方は、周囲へのご迷惑・過負荷によるスピーカーの焼損にご注意ください。 

  4. アンプ出力が緩やかなカーブを描いているのは、DAC~アンプ間のコンデンサ : C43 の充放電特性によるものと思われます。 

  5. あくまでも類推した概念図であり、M5Stack や ESP32 の構造を正確に表したものとは限りません。 

  6. M5Stack では dacWrite(25, 0) と記述し、DAC出力を最小レベルにしてスピーカーノイズを低減する方法が知られていますが、まさにこの原理を利用したものといえるでしょう。 

  7. GPIO18~23 は VDD3P3_CPU に所属する端子です。これらの GPIO は、M5Stack 内部で LCD・SDカードのアクセスに利用されています。 

  8. 残念ながらM5Stack 回路デザインの問題です。CPU や GPIO が動作する限り、DACノイズが不可避な構造です。さらに、電源がバッテリ~5V昇圧DCDCコンバータ~3.3V降圧DCDCコンバータ型と2段構成で、そもそも電源がノイジーです。負荷電流の変化によって、DCDCコンバータの充放電周期が変調され、広帯域なノイズとなる課題もあります。せめて VDD3P3_CPU と VDD3P3_RTC が別電源であったならば…と思いますが、コスト・サイズ・歴史的背景等、様々な理由あっての現行デザインなのでしょう。黙って使いこなす他ありません。 

  9. 説明図はデジタル入力~アナログ出力を前提としています。構成によっては入力、ΔΣ 手段、 Quantizer がアナログ構成であったり、2値出力をアナログ信号に戻す 1bit DAC が含まれる場合があります。 

  10. 高い実質分解能を得る場合、高い fs (サンプリング周波数) が必要になります。DSD の場合、元の音源の 64倍以上の fs が採用されています。また、高次のΔΣ変調は発振しやすいため、配慮が必要です。信号の再現性は処理クロック=時間の正確さに依存します。 

  11. ワンビットΔΣ変調に比べ、fs (サンプリング周波数) を低くできたり、高次のΔΣ変調でも発振しにくい特徴があります。再現性は処理クロックの正確さと出力手段の DACのリニアリティに依存します。 

  12. この例では、単純化のために固定入力で説明しています。音声信号のような可変入力であっても、同様に処理が可能です。 

  13. ΔΣの仕組みを直感的に理解いただくため、あえて「うるう年」の概念に寄せた例としました。実際の地球の平均周回日数は、365.242189...日/年です。グレゴリオ暦の「うるう年」は、社会システムに馴染みやすい規則(西暦が400で割り切れる、または4で割り切れて100で割り切れない年) となっています。ここで扱うΔΣ変調の考え方とは違います。 

  14. 効果説明のために、8bit DAC の既存Dレンジ/bit数を正領域にシフトしています。 

  15. LPFは波形観測のための手段であって、実際の信号処理には実装されません。 

  16. ΔΣ変調は大変奥が深く、私も理論を未消化のまま紹介しています。間違い等ありましたら、ご指摘願います。また、ESP32 の I2S内部には、ワンビットΔΣ変調を行う PDM が搭載されています。M5Stack のスピーカー音質改善に利用できるかは未検討です。出力が0V - 3.3V であるため、M5Stack ではアンプの飽和領域で利用することになります。 

  17. M5Stack Basic で、まれに SDカード の wave file 再生が不安定になる場合があります。SDカードにも相性があるようですが、再現性に乏しく究明できておりません。FIREは問題ないようです。CPU周波数は、240MHzよりも80MHzのほうがDACノイズ低減に有利ですが、確率的に 80MHzで SDカードが不安定になりやすいようで、この対策のためsetCpuFrequencyMhz(80);はコメントアウトし、240MHz 動作としています。 

  18. 各対策の測定波形を編集したものです。ソースコード例には、対策処理を自動的に切り替える機能はありません。 

  19.  音源を有音から無音(ゼロデータ)に切り替えた場合、ΔΣ変調ループ内の残データによってアイドルトーンが発生する場合があります。微小なノイズデータを与え続けることで、アイドルトーンを「散らす」ことが出来ます。今回は未対策です。 

  20. ΔΣの特性上、OSR は高いほうが有利です。調子に乗って OSR=8 (DAC_FS=352.8kHz) を試しました。動きますが、却って可聴帯域のノイズが増加しました。原因は未特定ですが、M5Stack のD級アンプのキャリアが400kHz付近であり、DAC_FSの352.8kHzと近く、折り返しが発生している可能性があります。   

  21. 静かめで、減衰音が多いピアノソロ曲などで効果がわかりやすいと思います。 

156
92
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
156
92