Add Slice 5 ADAU1701 runtime audio control (firmware 0.5.0).

Safeload mixer/EQ/master on the ADAU1701, persist AudioProfile in NVS,
expose /api/audio routes and web UI controls, with host tests and manual sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 16:47:58 +02:00
co-authored by Cursor
parent 4b931b0aad
commit ab3651e678
58 changed files with 3191 additions and 41 deletions
@@ -0,0 +1,29 @@
/**
* @file AudioProfile.cpp
* @brief AudioProfile factory defaults.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#include "core/AudioProfile.hpp"
namespace core {
AudioProfile AudioProfile::factoryDefault() noexcept
{
const GainDb unity = GainDb::zero();
return AudioProfile{
.mixer = MixerState::factoryDefault(),
.eq = EqProfile::factoryDefault(),
.masterLeft = unity,
.masterRight = unity,
};
}
} // namespace core
@@ -0,0 +1,227 @@
/**
* @file AudioProfileJson.cpp
* @brief AudioProfileJson implementation.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#include "core/AudioProfileJson.hpp"
#include <cstdlib>
#include <sstream>
namespace core {
namespace {
[[nodiscard]] bool extractJsonFloat(std::string_view json,
std::string_view key,
float& out)
{
const std::string needle =
std::string("\"") + std::string(key) + "\":";
const std::size_t start = json.find(needle);
if (start == std::string_view::npos) {
return false;
}
const std::size_t valueStart = start + needle.size();
char* end = nullptr;
out = std::strtof(json.data() + valueStart, &end);
return end != json.data() + valueStart;
}
[[nodiscard]] bool extractJsonUint(std::string_view json,
std::string_view key,
unsigned long& out)
{
const std::string needle =
std::string("\"") + std::string(key) + "\":";
const std::size_t start = json.find(needle);
if (start == std::string_view::npos) {
return false;
}
const std::size_t valueStart = start + needle.size();
char* end = nullptr;
out = std::strtoul(json.data() + valueStart, &end, 10);
return end != json.data() + valueStart;
}
[[nodiscard]] std::expected<GainDb, ParseError> parseGainField(
std::string_view json, std::string_view key)
{
float db = 0.0F;
if (!extractJsonFloat(json, key, db)) {
return std::unexpected(ParseError::MissingField);
}
return GainDb::tryFromDb(db);
}
[[nodiscard]] std::expected<MixerState, ParseError> parseMixerJson(
std::string_view json)
{
const std::size_t mixerStart = json.find("\"mixer\"");
if (mixerStart == std::string_view::npos) {
return std::unexpected(ParseError::MissingField);
}
const std::string_view mixer = json.substr(mixerStart);
const auto si4684Left = parseGainField(mixer, "si4684_left_db");
const auto si4684Right = parseGainField(mixer, "si4684_right_db");
const auto esp32Left = parseGainField(mixer, "esp32_left_db");
const auto esp32Right = parseGainField(mixer, "esp32_right_db");
const auto mixLeft = parseGainField(mixer, "mix_left_db");
const auto mixRight = parseGainField(mixer, "mix_right_db");
if (!si4684Left || !si4684Right || !esp32Left || !esp32Right || !mixLeft
|| !mixRight) {
return std::unexpected(ParseError::MissingField);
}
return MixerState{
.si4684Left = *si4684Left,
.si4684Right = *si4684Right,
.esp32Left = *esp32Left,
.esp32Right = *esp32Right,
.mixLeft = *mixLeft,
.mixRight = *mixRight,
};
}
[[nodiscard]] std::expected<EqProfile, ParseError> parseEqJson(
std::string_view json)
{
EqProfile profile = EqProfile::factoryDefault();
const std::size_t eqStart = json.find("\"eq\"");
if (eqStart == std::string_view::npos) {
return std::unexpected(ParseError::MissingField);
}
std::string_view rest = json.substr(eqStart);
for (std::uint8_t band = 0; band < EqBandIndex::kBandCount; ++band) {
const std::size_t objStart = rest.find('{');
if (objStart == std::string_view::npos) {
return std::unexpected(ParseError::MissingField);
}
const std::size_t objEnd = rest.find('}', objStart);
if (objEnd == std::string_view::npos) {
return std::unexpected(ParseError::MissingField);
}
const std::string_view obj = rest.substr(objStart, objEnd - objStart + 1U);
float gainDb = 0.0F;
unsigned long centerHz = 0U;
float q = 0.0F;
if (!extractJsonFloat(obj, "gain_db", gainDb)
|| !extractJsonUint(obj, "center_hz", centerHz)
|| !extractJsonFloat(obj, "q", q)) {
return std::unexpected(ParseError::MissingField);
}
if (q < 0.2F || q > 10.0F) {
return std::unexpected(ParseError::MissingField);
}
const auto gain = GainDb::tryFromDb(gainDb);
const auto center = FrequencyHz::tryFromHz(
static_cast<std::uint32_t>(centerHz));
const auto index = EqBandIndex::tryFromIndex(band);
if (!gain || !center || !index) {
return std::unexpected(ParseError::MissingField);
}
profile.setBand(*index, EqBandSettings{
.gain = *gain,
.center = *center,
.q = q,
});
rest = rest.substr(objEnd + 1U);
}
return profile;
}
} // namespace
std::string serializeAudioProfileJson(const AudioProfile& profile)
{
std::ostringstream out;
const auto& m = profile.mixer;
out << "{\"mixer\":{"
<< "\"si4684_left_db\":" << m.si4684Left.value() << ','
<< "\"si4684_right_db\":" << m.si4684Right.value() << ','
<< "\"esp32_left_db\":" << m.esp32Left.value() << ','
<< "\"esp32_right_db\":" << m.esp32Right.value() << ','
<< "\"mix_left_db\":" << m.mixLeft.value() << ','
<< "\"mix_right_db\":" << m.mixRight.value() << "},"
<< "\"master\":{"
<< "\"left_db\":" << profile.masterLeft.value() << ','
<< "\"right_db\":" << profile.masterRight.value() << "},"
<< "\"eq\":[";
const auto& bands = profile.eq.bands();
for (std::size_t i = 0; i < bands.size(); ++i) {
if (i > 0U) {
out << ',';
}
const auto& b = bands[i];
out << "{\"gain_db\":" << b.gain.value()
<< ",\"center_hz\":" << b.center.value() << ",\"q\":" << b.q
<< '}';
}
out << "]}";
return out.str();
}
std::expected<AudioProfile, ParseError> parseAudioProfileJson(
std::string_view json)
{
if (json.find('{') == std::string_view::npos) {
return std::unexpected(ParseError::InvalidJson);
}
const auto mixer = parseMixerJson(json);
if (!mixer) {
return std::unexpected(mixer.error());
}
const auto eq = parseEqJson(json);
if (!eq) {
return std::unexpected(eq.error());
}
const std::size_t masterStart = json.find("\"master\"");
if (masterStart == std::string_view::npos) {
return std::unexpected(ParseError::MissingField);
}
const std::string_view master = json.substr(masterStart);
const auto masterLeft = parseGainField(master, "left_db");
const auto masterRight = parseGainField(master, "right_db");
if (!masterLeft || !masterRight) {
return std::unexpected(ParseError::MissingField);
}
return AudioProfile{
.mixer = *mixer,
.eq = *eq,
.masterLeft = *masterLeft,
.masterRight = *masterRight,
};
}
std::string serializeAudioSavedJson()
{
return R"({"status":"saved"})";
}
std::string serializeAudioErrorJson(std::string_view reason)
{
std::ostringstream out;
out << R"({"status":"error","reason":")" << reason << R"("})";
return out.str();
}
} // namespace core
@@ -0,0 +1,30 @@
/**
* @file BiquadCoefficients.cpp
* @brief BiquadCoefficients fixpoint conversion.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#include "core/BiquadCoefficients.hpp"
#include "core/BiquadDesign.hpp"
namespace core {
std::array<std::int32_t, 5> BiquadCoefficients::toFixpoint823() const noexcept
{
return {
floatToFixpoint823(b0),
floatToFixpoint823(b1),
floatToFixpoint823(b2),
floatToFixpoint823(a0),
floatToFixpoint823(a1),
};
}
} // namespace core
@@ -0,0 +1,94 @@
/**
* @file BiquadDesign.cpp
* @brief Biquad coefficient design and ADAU fixpoint helpers.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#include "core/BiquadDesign.hpp"
#include <cmath>
#include <numbers>
namespace core {
namespace {
constexpr float kPi = std::numbers::pi_v<float>;
[[nodiscard]] float clampQ(float q) noexcept
{
if (q < 0.2F) {
return 0.2F;
}
if (q > 10.0F) {
return 10.0F;
}
return q;
}
} // namespace
std::int32_t floatToFixpoint823(float value) noexcept
{
const double scaled =
static_cast<double>(value) * static_cast<double>(1U << 23);
const auto rounded = static_cast<std::int64_t>(std::llround(scaled));
return static_cast<std::int32_t>(rounded);
}
std::int32_t gainDbToLinearFixpoint(GainDb gain) noexcept
{
return floatToFixpoint823(gain.linear());
}
BiquadCoefficients designFlatEq() noexcept
{
return BiquadCoefficients{
.b0 = 1.0F,
.b1 = 0.0F,
.b2 = 0.0F,
.a0 = 0.0F,
.a1 = 0.0F,
};
}
BiquadCoefficients designPeakingEq(FrequencyHz center, GainDb gain,
float q) noexcept
{
if (std::fabs(gain.value()) < 0.001F) {
return designFlatEq();
}
const float qClamped = clampQ(q);
const float fs = static_cast<float>(kAdauSampleRateHz);
const float f0 = static_cast<float>(center.value());
const float a = std::pow(10.0F, gain.value() / 40.0F);
const float omega = 2.0F * kPi * f0 / fs;
const float sinOmega = std::sin(omega);
const float cosOmega = std::cos(omega);
const float alpha = sinOmega / (2.0F * qClamped);
const float b0 = 1.0F + alpha * a;
const float b1 = -2.0F * cosOmega;
const float b2 = 1.0F - alpha * a;
const float a0 = 1.0F + alpha / a;
const float a1 = -2.0F * cosOmega;
const float a2 = 1.0F - alpha / a;
return BiquadCoefficients{
.b0 = b0 / a0,
.b1 = b1 / a0,
.b2 = b2 / a0,
.a0 = a1 / a0,
.a1 = a2 / a0,
};
}
} // namespace core
@@ -0,0 +1,37 @@
/**
* @file EqBandIndex.cpp
* @brief EqBandIndex implementation.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#include "core/EqBandIndex.hpp"
namespace core {
EqBandIndex::EqBandIndex(std::uint8_t index) noexcept
: index_(index)
{
}
std::expected<EqBandIndex, ParseError> EqBandIndex::tryFromIndex(
std::uint32_t index) noexcept
{
if (index >= kBandCount) {
return std::unexpected(ParseError::MissingField);
}
return EqBandIndex(static_cast<std::uint8_t>(index));
}
std::uint8_t EqBandIndex::value() const noexcept
{
return index_;
}
} // namespace core
@@ -0,0 +1,71 @@
/**
* @file EqProfile.cpp
* @brief EqProfile implementation.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#include "core/EqProfile.hpp"
namespace core {
namespace {
constexpr std::array<std::uint32_t, EqBandIndex::kBandCount> kDefaultCenters{
40U, 100U, 250U, 1000U, 4000U, 12000U};
[[nodiscard]] std::array<EqBandSettings, EqBandIndex::kBandCount>
makeDefaultBands() noexcept
{
return std::array<EqBandSettings, EqBandIndex::kBandCount>{
EqBandSettings{GainDb::zero(), *FrequencyHz::tryFromHz(kDefaultCenters[0]),
1.414F},
EqBandSettings{GainDb::zero(), *FrequencyHz::tryFromHz(kDefaultCenters[1]),
1.414F},
EqBandSettings{GainDb::zero(), *FrequencyHz::tryFromHz(kDefaultCenters[2]),
1.414F},
EqBandSettings{GainDb::zero(), *FrequencyHz::tryFromHz(kDefaultCenters[3]),
1.414F},
EqBandSettings{GainDb::zero(), *FrequencyHz::tryFromHz(kDefaultCenters[4]),
1.414F},
EqBandSettings{GainDb::zero(), *FrequencyHz::tryFromHz(kDefaultCenters[5]),
1.414F},
};
}
} // namespace
EqProfile::EqProfile(
std::array<EqBandSettings, EqBandIndex::kBandCount> bands) noexcept
: bands_(bands)
{
}
EqProfile EqProfile::factoryDefault() noexcept
{
return EqProfile(makeDefaultBands());
}
const EqBandSettings& EqProfile::band(EqBandIndex index) const noexcept
{
return bands_[index.value()];
}
void EqProfile::setBand(EqBandIndex index, EqBandSettings settings) noexcept
{
bands_[index.value()] = settings;
}
const std::array<EqBandSettings, EqBandIndex::kBandCount>& EqProfile::bands()
const noexcept
{
return bands_;
}
} // namespace core
@@ -0,0 +1,37 @@
/**
* @file FrequencyHz.cpp
* @brief FrequencyHz implementation.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#include "core/FrequencyHz.hpp"
namespace core {
FrequencyHz::FrequencyHz(std::uint32_t hz) noexcept
: hz_(hz)
{
}
std::expected<FrequencyHz, ParseError> FrequencyHz::tryFromHz(
std::uint32_t hz) noexcept
{
if (hz < kMinHz || hz > kMaxHz) {
return std::unexpected(ParseError::MissingField);
}
return FrequencyHz(hz);
}
std::uint32_t FrequencyHz::value() const noexcept
{
return hz_;
}
} // namespace core
+46
View File
@@ -0,0 +1,46 @@
/**
* @file GainDb.cpp
* @brief GainDb implementation.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#include "core/GainDb.hpp"
namespace core {
GainDb::GainDb(float db) noexcept
: db_(db)
{
}
std::expected<GainDb, ParseError> GainDb::tryFromDb(float db) noexcept
{
if (db < kMinDb || db > kMaxDb) {
return std::unexpected(ParseError::MissingField);
}
return GainDb(db);
}
GainDb GainDb::zero() noexcept
{
return GainDb(0.0F);
}
float GainDb::value() const noexcept
{
return db_;
}
float GainDb::linear() const noexcept
{
return std::pow(10.0F, db_ / 20.0F);
}
} // namespace core
@@ -0,0 +1,31 @@
/**
* @file MixerState.cpp
* @brief MixerState factory defaults.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#include "core/MixerState.hpp"
namespace core {
MixerState MixerState::factoryDefault() noexcept
{
const GainDb unity = GainDb::zero();
return MixerState{
.si4684Left = unity,
.si4684Right = unity,
.esp32Left = unity,
.esp32Right = unity,
.mixLeft = unity,
.mixRight = unity,
};
}
} // namespace core