| ESP32 A2DP Bluetooth Receiver with Volume Control |
Background
Regarding A2DP Bluetooth receivers using ESP32, many people have likely tried the ESP-IDF environment sample before, just as I have, and this may already be considered a classic topic. However, on GitHub, I found that pschatzmann has been advancing a class library project at a considerable pace, especially since 2021. Since it has become much easier to use in the Arduino environment compared to when it was just an ESP-IDF sample program, I had been forking the repository and creating some samples to experiment with. Recently, my Volume curve improvements were incorporated into the original pschatzmann repository, and samples for output stage customization such as 32bit DAC output were provided, so I would like to introduce it here again.
ESP32-A2DP Library
The ESP32-A2DP library is available at pschatzmann/ESP32-A2DP. What is most impressive is its simplicity — a Bluetooth receiver with a 16bit Stereo I2S DAC connected (bt_music_receiver_simple) can be achieved with simply the following code alone.
#include "BluetoothA2DPSink.h"
BluetoothA2DPSink a2dp_sink;
void setup() {
a2dp_sink.start("MyMusic");
}
void loop() {
}
Installing the Library in Arduino
The ESP32-A2DP library cannot be installed from Arduino's "Manage Libraries", so launch Git Bash and install it as follows.
$ cd ~/Documents/Arduino ## or cd ~/OneDrive/Documents/Arduino $ git clone pschatzmann/ESP32-A2DP.git
Connection to I2S DAC
A Node32s board (ESP32-DevKitC) was used as the ESP32 board. PCM5102 is used as the 32bit I2S DAC. In the project's default settings, the connection to the I2S DAC is made using the following pins.
A TPA6132 was directly connected after the PCM5102 as a headphone amplifier. Since the TPA6132 is a type of amplifier that generates negative voltage internally, similar to the PCM5102, it has excellent compatibility when combined with the PCM5102. It was built so that it can be used as a single unit by bonding it together with the PCM5102 as shown in the photo below.
| PCM5102+TPA6132 |
Full Utilization of 32bit DAC
bt_music_receiver_simple project, even when using a 32bit I2S DAC such as PCM5102, audio is output as-is, but the I2S output is in 16bit mode. Additionally, for I2S DACs that only accept 24bit or 32bit mode, the bt_music_receiver_32bit project is provided, but since only 16bit zero-padded data is output to I2S, the data bit precision remains equivalent to 16bit.
For reference, the code corresponding to this section, with unnecessary parts removed for clarity, is shown below. (BluetoothA2DPSink.cpp, around line 954)
The i2s_write() function sends in 16bit mode, and the i2s_write_expand() function zero-pads 16bit data and sends it in 32bit.
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");
}
}
}
Even in this state, since the original signal is 16bit, there is no problem at all when used at full volume. However, if volume is applied and the level is reduced, the output bit precision degrades from 16bit. For example, at a volume of 1/256, only an effective bit precision equivalent to 8bit is obtained. Therefore, for full 32bit utilization, the new examples/bt_music_receiver_32bit_ext project is provided.
The main Arduino code is largely unchanged, but the difference is that an instance of the BluetoothA2DPSink32 class is used instead of an instance of the BluetoothA2DPSink class.
#include "BluetoothA2DPSink32.h"
BluetoothA2DPSink32 a2dp_sink; // Subclass of BluetoothA2DPSink
void setup() {
a2dp_sink.set_bits_per_sample(32);
a2dp_sink.start("Hifi32bit");
}
void loop() {
}
The BluetoothA2DPSink32 class inherits from the BluetoothA2DPSink class, and the override of the audio_data_callback function is defined as follows.
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;
}
}
};
As shown above, when applying volume, the data is expanded from 16bit to 32bit and sent as-is as 32bit I2S data, so data is output from the DAC without losing the original 16bit precision.
Adding Volume Functionality
Now let's try a project that actually adds volume functionality and utilizes 32bit full-range output. Since the ESP32-A2DP project now supports volume functionality by default with 128 input steps and a maximum multiplier (volumeFactor maximum) of 4096, a project was created to operate this as-is using a rotary encoder.
bt_music_receiver_32bit_ext_volume
For reference, the volume curve is shaped close to an A-curve as shown below (Default). An alternative curve called SimpleExponential is also provided, but it appears to have practical issues such as sections where the gain output remains at 0 at low volume input values.
| Volume Setting Curve |
The connection to the rotary encoder is as follows. The ENCODER_A and ENCODER_B terminals should be pulled up to 3.3V with 10Kohm. The push button of the rotary encoder (if present) is not used this time.
AiEsp32RotaryEncoder is used as the rotary encoder library. Search for "AiEsp32RotaryEncoder" in Arduino's library manager and install version 1.2.0 or later.
The volume application section is as follows. Within Arduino's loop() function, the volume value is set on the a2dp_sink instance when the rotary encoder setting value changes.
// volume control
if (rotaryEncoder.encoderChanged()) {
int volume = rotaryEncoder.readEncoder();
a2dp_sink.set_volume(volume);
}
Customizing Volume Characteristics
Regarding ESP32-A2DP's volume characteristics and data computation, the functionality is implemented in a VolumeControl class separate from BluetoothA2DPSink, and it is possible to specify the VolumeControl class to actually use from outside. This means it can be flexibly customized according to the application, not limited to the default characteristics of 128 input steps and maximum multiplier (ScaleFactor) of 4096. As an example, a sample defining and using a class with 101 input steps (0 ~ 100) and maximum multiplier (ScaleFactor) of 65538 was created as follows.
bt_music_receiver_32bit_ext_volume_exp
Changing the volume input range may be necessary when linking the Bluetooth receiver's volume with external controls. The reason for setting the maximum multiplier to 65538 is that if the original audio signal 16bitSigned is restricted to -32767 ~ 32767 (i.e., -32768 is not used and -32767 is used instead), 32bitSigned can be used in the range of -2147483646 (0x8000_0002) ~ 2147483646 (0x7FFF_FFFE), which gets closer to the 32bit full range compared to using a maximum multiplier of 65535 or 65536.
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;
// (middle portion omitted)
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;
}
// (remainder omitted)
When defined as above, the volume characteristics are as follows.
| Volume Characteristics Curve After Customization |

No comments:
Post a Comment