ESP32 Bluetoothレシーバ + 32bit DAC (ESP32-A2DPライブラリ活用)

2021年10月23日土曜日

ESP32 I2S DAC

t f B! P L
ボリューム付き ESP2 A2DP Bluetoothレシーバ

背景

ESP32を使用したA2DP Bluetoothレシーバに関しては、私と同様、以前にESP-IDF環境のサンプルを試したことのある方も多いはずで、もはや古典的な部類に入るようなネタかもしれません。しかし、GitHub上にて、pschatzmannという方がクラスライブラリ化のプロジェクトを特に2021年になってからかなりの勢いで進めておられるのを見つけました。ESP-IDFのサンプルプログラムであった以前に比べてArduino環境においても格段に扱いやすくなっていることから、当方でもリポジトリをForkしていくつかのサンプルを作成しながら遊んでいました。最近になってVolumeカーブの改善を本家pschatzmannさんのリポジトリに取り込んでいただいたり、32bitDAC出力等、目的に応じた出力段のカスタマイズ例のサンプルが提供されたため、あらためてここで紹介したいと思います。

ESP32-A2DPライブラリ

ESP32-A2DPライブラリはpschatzmann/ESP32-A2DPにあります。なんといっても素晴らしいのはその手軽さで、16bit Stereo I2C DACを接続したBluetoothレシーバ (bt_music_receiver_simple) は単純に以下のコードのみで実現できてしまいます。

#include "BluetoothA2DPSink.h"

BluetoothA2DPSink a2dp_sink;

void setup() {
  a2dp_sink.start("MyMusic");  
}

void loop() {
}

Arduinoへのライブラリのインストール

ESP32-A2DPライブラリはArduinoの「ライブラリを管理」からはインストールすることはできないので、Git Bashを立ち上げて以下のようにしてインストールします。

$ cd ~/ドキュメント/Arduino   ## または cd ~/OneDrive/ドキュメント/Arduino
$ git clone pschatzmann/ESP32-A2DP.git

I2S DACとの接続

ESP32ボードはNode32sボード (ESP32-DevKitC) を使用しました。32bit I2S DACはPCM5102を使用します。プロジェクトのデフォルトの設定ではI2S DACとの接続は以下のピンにて行います。


Pin NamePCM5102 Board
IO26BCK
IO25LRCK
IO22DIN
5VVIN
GNDGND

PCM5102の後段にはヘッドフォンアンプとしてTPA6132を直接接続しました。TPA6132はPCM5102と同様に内部で負電源を生成するタイプのアンプなのでPCM5102と組み合わせるには非常に相性が良いです。以下の写真のようにPCM5102と張り合わせて一つのユニットとして使用できるように製作しました。

PCM5102+TPA6132

32bit DACのフル活用

bt_music_receiver_simpleプロジェクトではPCM5102などの32bit I2S DACを使用した場合でも、そのままの状態で音が出ますが、I2Sは16bitモードとして出力されています。また、24bitモードや32bitモードしか受け付けないI2S DAC向けにbt_music_receiver_32bitプロジェクトが用意されていますが、I2S出力には16bitかさ上げしたデータが出力されるのみなので、データのビット精度は16bit相当のままです。

参考までにこの部分に該当するコードを理解に不必要な部分を削除して抜き出したものが以下になります。(BluetoothA2DPSink.cppの954行目付近)

16bitモードでの送出がi2s_write()関数、16bitかさ上げして32bitで送出するのがi2s_write_expand()関数です。

void BluetoothA2DPSink::audio_data_callback(const uint8_t *data, uint32_t len) {
    // adjust the volume
    volume_control()->update_audio_data((Frame*)data, len/4, s_volume, mono_downmix, is_volume_used);

    if (is_i2s_output) {
        // special case for internal DAC output, the incomming PCM buffer needs 
        // to be converted from signed 16bit to unsigned
        int16_t* data16 = (int16_t*) data;

        size_t i2s_bytes_written;
        if (i2s_config.bits_per_sample==I2S_BITS_PER_SAMPLE_16BIT){
            // standard logic with 16 bits
            if (i2s_write(i2s_port,(void*) data, len, &i2s_bytes_written, portMAX_DELAY)!=ESP_OK){
                ESP_LOGE(BT_AV_TAG, "i2s_write has failed");    
            }
        } else {
            if (i2s_config.bits_per_sample>16){
                if (i2s_write_expand(i2s_port,(void*) data, len, I2S_BITS_PER_SAMPLE_16BIT, i2s_config.bits_per_sample, &i2s_bytes_written, portMAX_DELAY) != ESP_OK){
                    ESP_LOGE(BT_AV_TAG, "i2s_write has failed");    
                }
            }
        }
        if (i2s_bytes_written<len){
            ESP_LOGE(BT_AV_TAG, "Timeout: not all bytes were written to I2S");
        }
    }
}

この状態でも元の信号が16bitなのでフルボリュームでの使用では全く問題がないのですが、仮にボリュームを適用して音量を絞っていく場合は出力のビット精度が16bitから落ちていくことになります。例えばボリュームを1/256に絞った状態では実質8bit相当のビット精度しか得られないことになります。そこで新たに32bitフル活用のためにexamples/bt_music_receiver_32bit_extプロジェクトが提供されています。

Arduinoメインのコードはほぼ変わりませんが、BluetoothA2DPSinkクラスのインスタンスの代わりにBluetoothA2DPSink32クラスのインスタンスを使用している点が異なります。

#include "BluetoothA2DPSink32.h"

BluetoothA2DPSink32 a2dp_sink; // Subclass of BluetoothA2DPSink

void setup() {
  a2dp_sink.set_bits_per_sample(32);  
  a2dp_sink.start("Hifi32bit");  
}
void loop() {
}

そしてBluetoothA2DPSink32クラスはBluetoothA2DPSinkクラスを継承する形でaudio_data_callback関数のオーバーライドが以下のように定義されています。

class BluetoothA2DPSink32 : public BluetoothA2DPSink {
    protected:
        void audio_data_callback(const uint8_t *data, uint32_t len) {
            ESP_LOGD(BT_AV_TAG, "%s", __PRETTY_FUNCTION__);
            Frame* frame = (Frame*) data;  // convert to array of frames
            static constexpr int blk_size = 128;
            static uint32_t data32[blk_size/2];
            uint32_t rest_len = len;
            int32_t volumeFactor = 0x1000;
            
            if (is_volume_used) {
                volumeFactor = volume_control()->get_volume_factor(s_volume);
            }

            while (rest_len>0) {
                uint32_t blk_len = (rest_len>=blk_size) ? blk_size : rest_len;
                for (int i=0; i<blk_len/4; i++) {
                    int32_t pcmLeft = frame->channel1;
                    int32_t pcmRight = frame->channel2;
                    pcmLeft = pcmLeft * volumeFactor * 16;
                    pcmRight = pcmRight * volumeFactor * 16;
                    data32[i*2+0] = pcmLeft;
                    data32[i*2+1] = pcmRight;
                    frame++;
                }
                
                size_t i2s_bytes_written;
                if (i2s_write(i2s_port,(void*) data32, blk_len*2, &i2s_bytes_written, portMAX_DELAY)!=ESP_OK){
                    ESP_LOGE(BT_AV_TAG, "i2s_write has failed");
                }

                if (i2s_bytes_written<blk_len*2){
                    ESP_LOGE(BT_AV_TAG, "Timeout: not all bytes were written to I2S");
                }
                rest_len -= blk_len;
            }
        }
};

上記のようにボリュームを適用する際に16bitから32bitへの拡張を行いそのデータをそのままの形で32bit I2Sデータとして送出するので、もともとの16bit精度を失うことなくDACからデータが出力されることになります。

ボリューム機能追加

では、実際にボリューム機能を追加して、32bitフルレンジ出力を活用するプロジェクトを試してみます。ESP32-A2DPプロジェクト側で、入力 128段階 乗数最大値(volumeFactorの最大値) 4096のボリューム機能がデフォルトでサポートされましたので、これをそのままの形でロータリーエンコーダを使って操作するプロジェクトを作成しました。


bt_music_receiver_32bit_ext_volume


参考までにボリュームのカーブは以下のようなAカーブに近い形のものです (Default)。また代替のカーブとしてSimpleExponentialも用意されていますが、ボリューム入力値が小さいところでゲイン出力に0が続く部分があるなど実使用には難がありそうです。

ボリューム設定のカーブ

ロータリーエンコーダとの接続は以下の通りです。ENCODER_AおよびENCODER_B端子は10Kohmで3.3Vからプルアップしておきます。またロータリーエンコーダのプッシュボタン(がある場合)は今回使用しません。


Pin NameRotary Encoder
IO21ENCODER_A
IO32ENCODER_B
GNDGND

ロータリーエンコーダライブラリとしてはAiEsp32RotaryEncoderを使用しています。Arduinoのライブラリマネージャから"AiEsp32RotaryEncoder"で検索して1.2.0以上をインストールしてください。

ボリュームを適用する部分は以下のようになります。Arduinoのloop()関数内にて、ロータリエンコーダ設定値に変更がある場合にa2dp_sinkインスタンスに対してボリューム値を設定しています。

  // volume control
  if (rotaryEncoder.encoderChanged()) {
    int volume = rotaryEncoder.readEncoder();
    a2dp_sink.set_volume(volume);
  }

ボリューム特性のカスタマイズ

ESP32-A2DPのボリューム特性及びデータとの演算に関しては、BluetoothA2DPSinkとは別のVolumeControlクラスに機能実装されており、実際に使用するVolumeControlクラスを外部から別途指定することが可能になっています。つまりデフォルトの入力 128段階 乗数最大値(ScaleFactor) 4096の特性のみならず、使用するアプリケーションに応じて柔軟にカスタマイズして使用することが可能となっています。例として入力 101段階 (0 ~ 100) 乗数最大値(ScaleFactor) 65538 のクラスを定義して使用するサンプルは以下のように作成してみました。


bt_music_receiver_32bit_ext_volume_exp


ボリューム入力範囲の変更はBluetoothレシーバ機能のボリュームを外部と連携させたい場合に必要になることがあります。また、乗数最大値を65538にする理由は 元のオーディオ信号を16bitSignedを -32767 ~ 32767に制限する前提を設ければ(つまり-32768は使用せず-32767とする)、32bitSignedを-2147483646 (0x8000_0002) ~ 2147483646 (0x7FFF_FFFE)のレンジで使用できることになり、乗数最大値65535や65536を使用する場合に比べて32bitフルレンジにより近づけることができるからです。

class VolumeControlExpand : public VolumeControl {
    public:
        virtual uint8_t get_volume_input_max() = 0;
        virtual void update_audio_data(Frame* frame, Frame32* frame32, uint16_t frameCount, uint8_t volume, bool mono_downmix, bool is_volume_used) = 0;
};

class VolumeControlExpandDefault : public VolumeControlExpand {
    private:
        static constexpr double BASE = 1.3; // define curve characteristiscs (base)
        static constexpr double BITS = 16;  // define curve characteristiscs (power)
        static constexpr double VOLUME_ZERO_OFS = pow(BASE, -BITS); // offset value to set 0 to voumeFactor when volume 0
        static constexpr int32_t DAC_ZERO = 1; // to avoid pop noise caused by auto-mute function of DAC

    public:
        static constexpr double VOLUME_INPUT_MAX = 100;       
        static constexpr double VOLUME_FACTOR_MAX = pow(2.0, BITS) + 2;
        
        // 中略
        
        int32_t get_volume_factor(uint8_t volume) {
            double volumeFactorFloat = (pow(BASE, volume * BITS / VOLUME_INPUT_MAX - BITS) - VOLUME_ZERO_OFS) * VOLUME_FACTOR_MAX / (1.0 - VOLUME_ZERO_OFS);
            int32_t volumeFactor = volumeFactorFloat;
            if (volumeFactor > VOLUME_FACTOR_MAX) {
                volumeFactor = VOLUME_FACTOR_MAX;
            }
            return volumeFactor;
        }
        
        // 後略

上記のように定義した場合にボリューム特性は以下のようになります。

カスタマイズ適用後のボリューム特性カーブ

自己紹介

自分の写真
電子工作&プログラミング、オーディオ・音楽

注目の投稿

Raspberry Pi Pico Wで電波時計を合わせる (JJY標準電波エミュレータ)

Raspberry Pi Pico Wのアプリケーションとして 最少の周辺部品で電波時計むけJJYエミュレータ(時刻合わせ用)を製作しました。 ※2023年6月6日: ソースコード修正の内容を反映させました。 時刻合わせ風景 概要 電波時計は電波が届くところで使...

QooQ