Add live VU-meter readback via ADAU1701 Data Capture Register

Implements GET /api/audio/levels, reading all six 1×RTA level-detector
cells on demand (no background polling, no caching -- only runs when
called, per explicit request this session).

The read mechanism is the ADAU1701's documented Data Capture Register
(address 2074/0x081A, datasheet Rev.0 pp.30,36: write a (program-step,
register-select) pair to configure what the register mirrors, then
read back a 3-byte 5.19 twos-complement value). The per-meter program-
step indices are taken verbatim from SigmaStudio's own compiler output
(Firmware/ADAU1701-Firmware/IC 1_DigiRadioFinale/net_list_out2/
trap.dat), not invented -- the datasheet explicitly says these indices
must come from that compiler-generated file.

Confirmed live: all six points return distinct, plausible dBFS values,
and the output meters track a master-volume change.

Also folds two pieces of live user feedback into the app brief: the
volume slider should reach +12 dB (the firmware's real ceiling), not
stop at 0 dB, and the new levels endpoint is what a "VU meter" UI
section should poll instead of faking one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 22:33:09 +02:00
co-authored by Claude Sonnet 5
parent 3e5cfe814d
commit 5d7d4919a0
14 changed files with 506 additions and 89 deletions
@@ -0,0 +1,42 @@
/**
* @file AudioLevels.hpp
* @brief Snapshot of the six ADAU1701 Data Capture level meters.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-08-25
*/
#pragma once
namespace core {
/**
* @brief AudioLevels — one on-demand read of all six 1×RTA meters.
*
* @dname AudioLevels
* @return n/a (type)
* @pubstate Read-only snapshot; not part of AudioProfile, never persisted.
*
* Each field is in dBFS, read live from the ADAU1701 Data Capture Register
* (address 2074, MAC_out tap) at the program-step index SigmaStudio's
* compiler assigned to that 1×RTA cell (see
* Firmware/ADAU1701-Firmware/IC 1_DigiRadioFinale/net_list_out2/trap.dat).
* Captured only when requested by an API call -- no background polling.
*
* @author Michele Bigi
* @date 2026-08-25
*/
struct AudioLevels {
float radioInLeftDb; ///< 1×RTA1, post-compressor Si4684 L.
float radioInRightDb; ///< 1×RTA2, post-compressor Si4684 R.
float bluetoothInLeftDb; ///< 1×RTA3, ESP32 L.
float bluetoothInRightDb; ///< 1×RTA4, ESP32 R.
float outputLeftDb; ///< 1×RTA1_2, post Bass Boost1, L.
float outputRightDb; ///< 1×RTA2_2, post Bass Boost1, R.
};
} // namespace core
@@ -12,6 +12,7 @@
*/
#pragma once
#include "core/AudioLevels.hpp"
#include "core/AudioProfile.hpp"
#include "core/ParseError.hpp"
@@ -104,4 +105,17 @@ namespace core {
[[nodiscard]] std::expected<bool, ParseError> parseBeepEnabledJson(
std::string_view json);
/**
* @brief serializeAudioLevelsJson — serialise a level-meter snapshot.
*
* @dname serializeAudioLevelsJson
* @param levels On-demand read from IDsp::readLevels().
* @return JSON object string for GET /api/audio/levels.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-25
*/
[[nodiscard]] std::string serializeAudioLevelsJson(const AudioLevels& levels);
} // namespace core
@@ -13,6 +13,7 @@
#pragma once
#include "core/ActiveSource.hpp"
#include "core/AudioLevels.hpp"
#include "core/AudioProfile.hpp"
#include "core/DspError.hpp"
#include "core/EnhanceLevel.hpp"
@@ -183,6 +184,20 @@ public:
*/
[[nodiscard]] virtual std::expected<void, DspError> writeRawParam(
unsigned address, float value) = 0;
/**
* @brief readLevels — on-demand read of all six 1×RTA level meters.
*
* @dname readLevels
* @return AudioLevels snapshot, or DspError.
* @pubstate Reads live from the ADAU1701; not part of AudioProfile,
* never persisted. Only runs when called, no background
* polling.
*
* @author Michele Bigi
* @date 2026-08-25
*/
[[nodiscard]] virtual std::expected<AudioLevels, DspError> readLevels() = 0;
};
} // namespace core
@@ -338,4 +338,16 @@ std::expected<bool, ParseError> parseBeepEnabledJson(std::string_view json)
return enabled;
}
std::string serializeAudioLevelsJson(const AudioLevels& levels)
{
std::ostringstream out;
out << "{\"radio_in_left_db\":" << levels.radioInLeftDb
<< ",\"radio_in_right_db\":" << levels.radioInRightDb
<< ",\"bluetooth_in_left_db\":" << levels.bluetoothInLeftDb
<< ",\"bluetooth_in_right_db\":" << levels.bluetoothInRightDb
<< ",\"output_left_db\":" << levels.outputLeftDb
<< ",\"output_right_db\":" << levels.outputRightDb << "}";
return out.str();
}
} // namespace core
@@ -82,6 +82,12 @@ public:
return {};
}
[[nodiscard]] std::expected<core::AudioLevels, core::DspError>
readLevels() override
{
return core::AudioLevels{};
}
[[nodiscard]] std::expected<void, core::DspError> writeRawParam(
unsigned, float) override
{
@@ -14,6 +14,7 @@
#include "adau1701/Adau1701Error.hpp"
#include "core/AudioLevels.hpp"
#include "core/AudioProfile.hpp"
#include "core/EnhanceLevel.hpp"
#include "core/EqBandIndex.hpp"
@@ -269,6 +270,21 @@ public:
[[nodiscard]] std::expected<void, Adau1701Error> writeRawParam(
unsigned address, float value);
/**
* @brief readLevels — on-demand read of all six 1×RTA level meters.
*
* @dname readLevels
* @return AudioLevels snapshot, or Adau1701Error.
* @pubstate Reads the ADAU1701 Data Capture Register (address 2074) six
* times in sequence, reconfiguring it for each meter's
* program-step/register-select pair before each read; no
* background polling, only runs when called.
*
* @author Michele Bigi
* @date 2026-08-25
*/
[[nodiscard]] std::expected<core::AudioLevels, Adau1701Error> readLevels();
private:
[[nodiscard]] std::expected<void, Adau1701Error> ensureBooted() const;
[[nodiscard]] std::expected<void, Adau1701Error> safeloadGain(
@@ -277,6 +293,8 @@ private:
unsigned paramAddr, std::int32_t fixpoint);
[[nodiscard]] std::expected<void, Adau1701Error> replayProgram(
const core::DspProgram& program);
[[nodiscard]] std::expected<float, Adau1701Error> readCaptureDb(
unsigned progCount, unsigned regSel);
Adau1701Pins pins_;
core::IDspProgramSource& programSource_;
@@ -70,6 +70,9 @@ public:
[[nodiscard]] std::expected<void, core::DspError> writeRawParam(
unsigned address, float value) override;
[[nodiscard]] std::expected<core::AudioLevels, core::DspError>
readLevels() override;
private:
[[nodiscard]] static core::DspError mapError(Adau1701Error error) noexcept;
@@ -18,6 +18,8 @@
#include "core/BiquadDesign.hpp"
#include "core/DspProgram.hpp"
#include <cmath>
#include "DigiRadio_IC_1_PARAM.h"
#include "SigmaStudioFW.h"
@@ -559,6 +561,121 @@ namespace adau1701
return safeloadFixpoint(address, core::floatToFixpoint823(value));
}
namespace
{
// ADAU1701 Data Capture Register, address 2074 (0x081A) -- see
// datasheet Rev.0 "2074 TO 2075 (0x081A TO 0x081B)--DATA CAPTURE
// REGISTERS" (p.36) and Table 32's register-map bit layout (p.30):
// D[11:2]=PC[9:0] (program-step index), D[1:0]=RS[1:0] (register
// select). Reading the same address afterward returns a 3-byte,
// 24-bit two's-complement 5.19 value (5.23 with the 4 LSBs
// truncated, per the datasheet). The per-meter (progCount, regSel)
// pairs below are read verbatim from SigmaStudio's own compiler
// output (Firmware/ADAU1701-Firmware/IC 1_DigiRadioFinale/
// net_list_out2/trap.dat), not invented.
constexpr unsigned kDataCaptureAddr = 0x081AU;
constexpr unsigned kRegSelMacOut = 2U; // "mult_out" in trap.dat
struct MeterPoint
{
unsigned progCount;
};
constexpr MeterPoint kRadioInLeft{221}; // SingleBandLevelDet1
constexpr MeterPoint kRadioInRight{254}; // SingleBandLevelDet2
constexpr MeterPoint kBluetoothInLeft{155}; // SingleBandLevelDet3
constexpr MeterPoint kBluetoothInRight{188}; // SingleBandLevelDet4
constexpr MeterPoint kOutputLeft{717}; // SingleBandLevelDet5
constexpr MeterPoint kOutputRight{684}; // SingleBandLevelDet6
} // namespace
std::expected<float, Adau1701Error> Adau1701Driver::readCaptureDb(
unsigned progCount, unsigned regSel)
{
const unsigned char deviceAddr =
static_cast<unsigned char>(pins_.i2cAddr7 << 1);
const std::uint16_t config = static_cast<std::uint16_t>(
((progCount & 0x3FFU) << 2) | (regSel & 0x3U));
unsigned char configBytes[2U] = {
static_cast<unsigned char>((config >> 8) & 0xFFU),
static_cast<unsigned char>(config & 0xFFU),
};
if (SIGMA_WRITE_REGISTER_BLOCK(deviceAddr, kDataCaptureAddr, 2U,
configBytes) != 0)
{
return std::unexpected(Adau1701Error::SafeloadFailed);
}
unsigned char raw[3U] = {0U, 0U, 0U};
if (sigma_i2c_read(kDataCaptureAddr, raw, sizeof(raw)) != 0)
{
return std::unexpected(Adau1701Error::SafeloadFailed);
}
std::int32_t value = (static_cast<std::int32_t>(raw[0]) << 16)
| (static_cast<std::int32_t>(raw[1]) << 8)
| static_cast<std::int32_t>(raw[2]);
if ((value & (1 << 23)) != 0)
{
value -= (1 << 24);
}
const float linear =
static_cast<float>(value) / static_cast<float>(1 << 19);
constexpr float kFloorDb = -96.0F;
if (std::fabs(linear) < 1e-5F)
{
return kFloorDb;
}
const float db = 20.0F * std::log10(std::fabs(linear));
return db < kFloorDb ? kFloorDb : db;
}
std::expected<core::AudioLevels, Adau1701Error> Adau1701Driver::readLevels()
{
if (auto ready = ensureBooted(); !ready)
{
return std::unexpected(ready.error());
}
core::AudioLevels levels{};
auto read = [this](MeterPoint point,
float &out) -> std::expected<void, Adau1701Error>
{
const auto db = readCaptureDb(point.progCount, kRegSelMacOut);
if (!db)
{
return std::unexpected(db.error());
}
out = *db;
return {};
};
if (auto r = read(kRadioInLeft, levels.radioInLeftDb); !r)
{
return std::unexpected(r.error());
}
if (auto r = read(kRadioInRight, levels.radioInRightDb); !r)
{
return std::unexpected(r.error());
}
if (auto r = read(kBluetoothInLeft, levels.bluetoothInLeftDb); !r)
{
return std::unexpected(r.error());
}
if (auto r = read(kBluetoothInRight, levels.bluetoothInRightDb); !r)
{
return std::unexpected(r.error());
}
if (auto r = read(kOutputLeft, levels.outputLeftDb); !r)
{
return std::unexpected(r.error());
}
if (auto r = read(kOutputRight, levels.outputRightDb); !r)
{
return std::unexpected(r.error());
}
return levels;
}
std::expected<void, Adau1701Error> Adau1701Driver::applyEq(
const core::EqProfile &eq)
{
@@ -115,4 +115,13 @@ std::expected<void, core::DspError> Adau1701Dsp::writeRawParam(
return {};
}
std::expected<core::AudioLevels, core::DspError> Adau1701Dsp::readLevels()
{
auto result = driver_.readLevels();
if (!result) {
return std::unexpected(mapError(result.error()));
}
return *result;
}
} // namespace adau1701
@@ -971,6 +971,39 @@ esp_err_t audioBeepPostHandler(httpd_req_t* req)
return httpd_resp_send(req, json.c_str(), json.size());
}
/**
* @brief audioLevelsGetHandler — serve GET /api/audio/levels as JSON.
*
* @dname audioLevelsGetHandler
* @param req HTTP request handle from esp_http_server.
* @return ESP_OK on success, or an esp_err_t error code.
* @pubstate Reads all six 1×RTA level meters live on every call -- no
* background polling, no caching.
*
* @author Michele Bigi
* @date 2026-08-25
*/
esp_err_t audioLevelsGetHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->audio == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
const auto levels = ctx->audio->readLevels();
if (!levels) {
const std::string json = core::serializeAudioErrorJson("dsp_failed");
httpd_resp_set_status(req, "500 Internal Server Error");
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
const std::string json = core::serializeAudioLevelsJson(*levels);
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
/**
* @brief dspParamsGetHandler — serve GET /api/dsp/params as JSON.
*
@@ -2252,6 +2285,14 @@ std::expected<void, NetError> SetupWebServer::start(
};
httpd_register_uri_handler(server_, &audioBeepUri);
const httpd_uri_t audioLevelsUri = {
.uri = "/api/audio/levels",
.method = HTTP_GET,
.handler = audioLevelsGetHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &audioLevelsUri);
const httpd_uri_t dspParamsUri = {
.uri = "/api/dsp/params",
.method = HTTP_GET,
@@ -13,6 +13,7 @@
#pragma once
#include "core/ActiveSource.hpp"
#include "core/AudioLevels.hpp"
#include "core/AudioProfile.hpp"
#include "core/DspError.hpp"
#include "core/EnhanceLevel.hpp"
@@ -229,6 +230,20 @@ public:
[[nodiscard]] std::expected<void, core::DspError> writeRawParam(
unsigned address, float value);
/**
* @brief readLevels — on-demand read of all six level meters.
*
* @dname readLevels
* @return AudioLevels snapshot, or DspError.
* @pubstate live-only: not part of AudioProfile, never persisted, does
* not touch profile_.
*
* @author Michele Bigi
* @date 2026-08-25
*/
[[nodiscard]] std::expected<core::AudioLevels, core::DspError>
readLevels();
private:
[[nodiscard]] std::expected<void, core::StoreError> persistProfile() const;
@@ -193,4 +193,9 @@ std::expected<void, core::DspError> AudioService::writeRawParam(
return dsp_.writeRawParam(address, value);
}
std::expected<core::AudioLevels, core::DspError> AudioService::readLevels()
{
return dsp_.readLevels();
}
} // namespace audio