Files
pyxis/lib/lxst_audio/lxst_audio.cpp
torlando-techandClaude Opus 4.7 12fd28b67c test(lxst): bidirectional audio content-fidelity validation
Extends the LXST harness from frame-flow only (#62) and decoder QoS
only (#63) to a full content-fidelity test: a known 1kHz sine wave
flows in BOTH directions through the Codec2-3200 round-trip, and
the harness asserts the decoded RMS at each end matches expected
energy within tolerance (after Codec2's lossy speech-codec behavior).

Firmware additions (under PYXIS_TEST_HOOKS):

I2SCapture
  setInjectSine(enabled, freq=1000, amp=0.5)
    Replaces mic input with a phase-continuous synthesized sine.
    Bypasses ES7210 capture and the voice filter chain so the
    encoder sees pure samples — peer's decoded RMS validates that
    pyxis's TX path delivers content.

I2SPlayback
  pcmSampleCount(), pcmSumSquares()
    Decoded-PCM energy accumulators, fed from each successful
    Codec2 decode. uint64 sumsq holds ~2³⁴ frames before overflow,
    far longer than any test call.

LXSTAudio + UIManager (test-only)
  captureSetInjectSine, playbackPcmSampleCount, playbackPcmSumSquares
  test_call_set_inject_sine, test_call_pcm_sample_count,
  test_call_pcm_sum_squares

Serial T: hooks (main.cpp)
  T:CALL_INJECT <on|off> [freq] [amp_pct]
    Drive the capture-side injection from the harness.
  T:CALL_QOS now also returns pcm_n + pcm_ss
    Harness divides + sqrts to RMS for content validation.

Validated with /tmp/lxst_call_harness.py + /tmp/lxst_call_bot.py
(scripts vault-local per the no-PII rule):

  pyxis_rms = 6363, bot_rms = 1930, decode_fail = 0
  PASS: bidirectional audio + content-fidelity validated

The empirical RMS floor is 800 (pycodec2 self-test on 1kHz amp 0.5
yields ~1400; pyxis decoder hits ~6300; bot decoder hits ~1900 —
all far above the ~5-50 silence floor). Codec2 is a speech codec
so pure-tone round-trip is naturally lossy; the test gates on
"audio bytes carry actual content energy", not lossless round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 03:11:09 -04:00

384 lines
12 KiB
C++

// Copyright (c) 2024 LXST contributors
// SPDX-License-Identifier: MPL-2.0
#include "lxst_audio.h"
#ifdef ARDUINO
#include <esp_log.h>
#include <esp_system.h>
#include <Hardware/TDeck/Config.h>
#include <Arduino.h>
#include <driver/i2s.h>
#include "es7210.h"
#include "i2s_capture.h"
#include "i2s_playback.h"
#include "codec_wrapper.h"
#include "Tone.h"
using namespace Hardware::TDeck;
static const char* TAG = "LXST:Audio";
LXSTAudio::LXSTAudio() = default;
LXSTAudio::~LXSTAudio() {
deinit();
}
bool LXSTAudio::init(int codec2Mode, uint8_t micGain) {
if (initialized_) {
ESP_LOGW(TAG, "Already initialized");
return true;
}
codec2Mode_ = codec2Mode;
Serial.printf("[AUDIO] Init starting (heap=%lu)...\n", (unsigned long)esp_get_free_heap_size());
// Initialize ES7210 using LilyGO library — exact same calls as their Microphone example
// SLAVE mode: ES7210 receives MCLK/BCLK/LRCK from ESP32 I2S master
Serial.println("[AUDIO] Initializing ES7210 via LilyGO library...");
{
audio_hal_codec_config_t cfg = {};
cfg.adc_input = AUDIO_HAL_ADC_INPUT_ALL;
cfg.codec_mode = AUDIO_HAL_CODEC_MODE_ENCODE;
cfg.i2s_iface.mode = AUDIO_HAL_MODE_SLAVE;
cfg.i2s_iface.fmt = AUDIO_HAL_I2S_NORMAL;
cfg.i2s_iface.samples = AUDIO_HAL_08K_SAMPLES;
cfg.i2s_iface.bits = AUDIO_HAL_BIT_LENGTH_16BITS;
uint32_t ret_val = ESP_OK;
ret_val |= es7210_adc_init(&Wire, &cfg);
ret_val |= es7210_adc_config_i2s(cfg.codec_mode, &cfg.i2s_iface);
ret_val |= es7210_adc_set_gain(
(es7210_input_mics_t)(ES7210_INPUT_MIC1 | ES7210_INPUT_MIC2 |
ES7210_INPUT_MIC3 | ES7210_INPUT_MIC4),
(es7210_gain_value_t)micGain);
ret_val |= es7210_adc_ctrl_state(cfg.codec_mode, AUDIO_HAL_CTRL_START);
if (ret_val != ESP_OK) {
Serial.printf("[AUDIO] ES7210 init warning: ret=%lu\n", (unsigned long)ret_val);
}
}
// Verify ES7210 configuration by reading back key registers
{
int reg00 = es7210_read_reg(ES7210_RESET_REG00);
int reg01 = es7210_read_reg(ES7210_CLOCK_OFF_REG01);
int reg06 = es7210_read_reg(ES7210_POWER_DOWN_REG06);
int reg08 = es7210_read_reg(ES7210_MODE_CONFIG_REG08);
int reg43 = es7210_read_reg(ES7210_MIC1_GAIN_REG43);
int reg47 = es7210_read_reg(ES7210_MIC1_POWER_REG47);
int reg4b = es7210_read_reg(ES7210_MIC12_POWER_REG4B);
Serial.printf("[AUDIO] ES7210 regs: R00=0x%02X R01=0x%02X R06=0x%02X R08=0x%02X "
"GAIN1=0x%02X PWR1=0x%02X PWR12=0x%02X\n",
reg00, reg01, reg06, reg08, reg43, reg47, reg4b);
// Expected: R00=0x41 (normal), R06=0x00 (powered up), R08=0x00 (slave mode)
// PWR1=0x00, PWR12=0x00 (mics powered on)
if (reg06 != 0x00) {
Serial.printf("[AUDIO] WARNING: ES7210 POWER_DOWN=0x%02X (expected 0x00)\n", reg06);
}
if (reg47 != 0x00 || reg4b != 0x00) {
Serial.printf("[AUDIO] WARNING: ES7210 mic power not active! R47=0x%02X R4B=0x%02X\n", reg47, reg4b);
}
}
Serial.println("[AUDIO] ES7210 initialized OK");
// I2S capture init
capture_ = new I2SCapture();
if (!capture_->init()) {
Serial.println("[AUDIO] I2S capture init FAILED");
delete capture_;
capture_ = nullptr;
return false;
}
Serial.println("[AUDIO] I2S capture initialized (MCLK now running)");
// Re-issue ES7210 start with clocks now running.
// In slave mode, the ES7210 needs MCLK/BCLK/LRCK from the ESP32 I2S master
// to properly start its ADC — the initial start above ran before clocks were
// available. This second call ensures the ADC powers up correctly.
{
audio_hal_codec_config_t cfg2 = {};
cfg2.codec_mode = AUDIO_HAL_CODEC_MODE_ENCODE;
es7210_adc_ctrl_state(cfg2.codec_mode, AUDIO_HAL_CTRL_START);
Serial.println("[AUDIO] ES7210 re-started with I2S clocks active");
}
// Create separate Codec2 instances for encode and decode to avoid mutex
// contention during full-duplex calls (capture task + main thread decode)
encodeCodec_ = new Codec2Wrapper();
if (!encodeCodec_->create(codec2Mode)) {
Serial.println("[AUDIO] Codec2 encoder create FAILED");
delete capture_;
capture_ = nullptr;
delete encodeCodec_;
encodeCodec_ = nullptr;
return false;
}
Serial.printf("[AUDIO] Codec2 encoder created (heap=%lu)\n", (unsigned long)esp_get_free_heap_size());
decodeCodec_ = new Codec2Wrapper();
if (!decodeCodec_->create(codec2Mode)) {
Serial.println("[AUDIO] Codec2 decoder create FAILED");
delete capture_;
capture_ = nullptr;
encodeCodec_->destroy();
delete encodeCodec_;
encodeCodec_ = nullptr;
delete decodeCodec_;
decodeCodec_ = nullptr;
return false;
}
Serial.printf("[AUDIO] Codec2 decoder created (heap=%lu)\n", (unsigned long)esp_get_free_heap_size());
// Configure encoder on capture side (owns its codec instance)
if (!capture_->configureEncoder(encodeCodec_, true)) {
Serial.println("[AUDIO] Capture encoder config FAILED");
delete capture_;
capture_ = nullptr;
encodeCodec_->destroy();
delete encodeCodec_;
encodeCodec_ = nullptr;
decodeCodec_->destroy();
delete decodeCodec_;
decodeCodec_ = nullptr;
return false;
}
// Create playback engine with its own codec instance
playback_ = new I2SPlayback();
if (!playback_->configureDecoder(decodeCodec_)) {
ESP_LOGE(TAG, "Playback decoder config failed");
delete capture_;
capture_ = nullptr;
delete playback_;
playback_ = nullptr;
encodeCodec_->destroy();
delete encodeCodec_;
encodeCodec_ = nullptr;
decodeCodec_->destroy();
delete decodeCodec_;
decodeCodec_ = nullptr;
return false;
}
initialized_ = true;
state_ = State::IDLE;
ESP_LOGI(TAG, "LXST Audio initialized: Codec2 mode %d (heap=%lu)",
codec2Mode, (unsigned long)esp_get_free_heap_size());
return true;
}
void LXSTAudio::deinit() {
stopCapture();
stopPlayback();
if (capture_) {
capture_->releaseBuffers();
delete capture_;
capture_ = nullptr;
}
if (playback_) {
playback_->releaseBuffers();
delete playback_;
playback_ = nullptr;
}
if (encodeCodec_) {
encodeCodec_->destroy();
delete encodeCodec_;
encodeCodec_ = nullptr;
}
if (decodeCodec_) {
decodeCodec_->destroy();
delete decodeCodec_;
decodeCodec_ = nullptr;
}
initialized_ = false;
state_ = State::IDLE;
ESP_LOGI(TAG, "LXST Audio deinitialized");
}
bool LXSTAudio::startCapture() {
if (!initialized_ || !capture_) return false;
// Half-duplex: stop playback first
if (state_ == State::PLAYING) {
stopPlayback();
}
if (state_ == State::CAPTURING || state_ == State::FULL_DUPLEX) return true;
if (!capture_->start()) {
ESP_LOGE(TAG, "Failed to start capture");
return false;
}
state_ = State::CAPTURING;
ESP_LOGI(TAG, "Capture started (TX mode)");
return true;
}
void LXSTAudio::stopCapture() {
if (!capture_) return;
if (state_ != State::CAPTURING && state_ != State::FULL_DUPLEX) return;
capture_->stop();
// Re-init capture I2S for next use
capture_->init();
capture_->configureEncoder(encodeCodec_, true);
if (state_ == State::FULL_DUPLEX) {
state_ = State::PLAYING;
ESP_LOGI(TAG, "Capture stopped (still playing)");
} else {
state_ = State::IDLE;
ESP_LOGI(TAG, "Capture stopped");
}
}
bool LXSTAudio::startPlayback() {
if (!initialized_ || !playback_) return false;
// Half-duplex: stop capture first
if (state_ == State::CAPTURING) {
stopCapture();
}
if (state_ == State::PLAYING || state_ == State::FULL_DUPLEX) return true;
// Release I2S_NUM_0 from tone generator
Notification::tone_deinit();
if (!playback_->start()) {
ESP_LOGE(TAG, "Failed to start playback");
return false;
}
state_ = State::PLAYING;
ESP_LOGI(TAG, "Playback started (RX mode)");
return true;
}
void LXSTAudio::stopPlayback() {
if (!playback_) return;
if (state_ != State::PLAYING && state_ != State::FULL_DUPLEX) return;
playback_->stop();
// Re-configure decoder for next use
playback_->configureDecoder(decodeCodec_);
if (state_ == State::FULL_DUPLEX) {
state_ = State::CAPTURING;
ESP_LOGI(TAG, "Playback stopped (still capturing)");
} else {
state_ = State::IDLE;
ESP_LOGI(TAG, "Playback stopped");
}
// Tone.cpp will re-init its I2S on next tone_play() call
// (tone_init() checks _initialized flag)
}
bool LXSTAudio::startFullDuplex() {
if (!initialized_ || !capture_ || !playback_) return false;
if (state_ == State::FULL_DUPLEX) return true;
ESP_LOGI(TAG, "Starting full-duplex (heap=%lu)...", (unsigned long)esp_get_free_heap_size());
// Release I2S_NUM_0 from tone generator
Notification::tone_deinit();
// Start playback (speaker) first — I2S_NUM_0
if (!playback_->isPlaying()) {
ESP_LOGI(TAG, "Starting playback (heap=%lu)...", (unsigned long)esp_get_free_heap_size());
if (!playback_->start()) {
ESP_LOGE(TAG, "Failed to start playback for full-duplex");
return false;
}
ESP_LOGI(TAG, "Playback started (heap=%lu)", (unsigned long)esp_get_free_heap_size());
}
// Start capture (mic) — I2S_NUM_1
if (!capture_->isCapturing()) {
ESP_LOGI(TAG, "Starting capture (heap=%lu)...", (unsigned long)esp_get_free_heap_size());
if (!capture_->start()) {
ESP_LOGE(TAG, "Failed to start capture for full-duplex");
playback_->stop();
playback_->configureDecoder(decodeCodec_);
return false;
}
ESP_LOGI(TAG, "Capture started (heap=%lu)", (unsigned long)esp_get_free_heap_size());
}
state_ = State::FULL_DUPLEX;
ESP_LOGI(TAG, "Full-duplex started (TX+RX, heap=%lu)", (unsigned long)esp_get_free_heap_size());
return true;
}
bool LXSTAudio::readEncodedPacket(uint8_t* dest, int maxLength, int* actualLength) {
if (!capture_ || !isCapturing()) return false;
return capture_->readEncodedPacket(dest, maxLength, actualLength);
}
bool LXSTAudio::writeEncodedPacket(const uint8_t* data, int length) {
if (!playback_ || !isPlaying()) return false;
return playback_->writeEncodedPacket(data, length);
}
void LXSTAudio::setCaptureMute(bool muted) {
if (capture_) capture_->setMute(muted);
}
void LXSTAudio::setPlaybackMute(bool muted) {
if (playback_) playback_->setMute(muted);
}
int LXSTAudio::capturePacketsAvailable() const {
if (!capture_) return 0;
return capture_->availablePackets();
}
int LXSTAudio::playbackFramesBuffered() const {
if (!playback_) return 0;
return playback_->bufferedFrames();
}
uint32_t LXSTAudio::playbackDecodeOk() const {
if (!playback_) return 0;
return playback_->decodeOkCount();
}
uint32_t LXSTAudio::playbackDecodeFail() const {
if (!playback_) return 0;
return playback_->decodeFailCount();
}
void LXSTAudio::playbackResetCounters() {
if (playback_) playback_->resetCounters();
}
uint32_t LXSTAudio::playbackPcmSampleCount() const {
if (!playback_) return 0;
return playback_->pcmSampleCount();
}
uint64_t LXSTAudio::playbackPcmSumSquares() const {
if (!playback_) return 0;
return playback_->pcmSumSquares();
}
void LXSTAudio::captureSetInjectSine(bool enabled, int freq, float amp) {
if (capture_) capture_->setInjectSine(enabled, freq, amp);
}
#endif // ARDUINO