From ab3651e678b7bec59c003fb576e6f544a23588d5 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Mon, 6 Jul 2026 16:47:58 +0200 Subject: [PATCH] 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 --- Software/Firmware/ADAU1701-Firmware/README.md | 8 +- .../ADAU1701-Firmware/SigmaStudioFW.h | 29 +++ Software/components/core/CMakeLists.txt | 9 + .../core/include/core/AudioProfile.hpp | 51 ++++ .../core/include/core/AudioProfileJson.hpp | 77 ++++++ .../core/include/core/BiquadCoefficients.hpp | 50 ++++ .../core/include/core/BiquadDesign.hpp | 80 ++++++ .../components/core/include/core/DspError.hpp | 33 +++ .../core/include/core/EqBandIndex.hpp | 69 ++++++ .../core/include/core/EqBandSettings.hpp | 36 +++ .../core/include/core/EqProfile.hpp | 94 ++++++++ .../core/include/core/FrequencyHz.hpp | 71 ++++++ .../components/core/include/core/GainDb.hpp | 95 ++++++++ .../core/include/core/IAudioProfileStore.hpp | 88 +++++++ .../components/core/include/core/IDsp.hpp | 135 +++++++++++ .../core/include/core/MixSource.hpp | 32 +++ .../core/include/core/MixerState.hpp | 52 ++++ Software/components/core/src/AudioProfile.cpp | 29 +++ .../components/core/src/AudioProfileJson.cpp | 227 ++++++++++++++++++ .../core/src/BiquadCoefficients.cpp | 30 +++ Software/components/core/src/BiquadDesign.cpp | 94 ++++++++ Software/components/core/src/EqBandIndex.cpp | 37 +++ Software/components/core/src/EqProfile.cpp | 71 ++++++ Software/components/core/src/FrequencyHz.cpp | 37 +++ Software/components/core/src/GainDb.cpp | 46 ++++ Software/components/core/src/MixerState.cpp | 31 +++ Software/components/core/test/CMakeLists.txt | 17 ++ .../core/test/audio_profile_json_test.cpp | 66 +++++ .../core/test/biquad_design_test.cpp | 68 ++++++ .../drivers/adau1701/CMakeLists.txt | 3 +- .../include/adau1701/Adau1701Driver.hpp | 113 ++++++++- .../adau1701/include/adau1701/Adau1701Dsp.hpp | 70 ++++++ .../include/adau1701/Adau1701Error.hpp | 2 + .../include/adau1701/Adau1701ParamMap.hpp | 77 ++++++ .../drivers/adau1701/src/Adau1701Driver.cpp | 140 +++++++++++ .../drivers/adau1701/src/Adau1701Dsp.cpp | 90 +++++++ .../drivers/adau1701/src/SigmaStudioFW.c | 75 ++++++ Software/components/net/CMakeLists.txt | 2 +- .../net/include/net/NetBootstrap.hpp | 8 +- .../net/include/net/SetupWebServer.hpp | 16 +- Software/components/net/src/NetBootstrap.cpp | 20 +- .../components/net/src/SetupWebServer.cpp | 152 +++++++++++- Software/components/net/www/index.html | 81 +++++++ Software/components/net/www/index.html.gz | Bin 3436 -> 3943 bytes .../components/secure_store/CMakeLists.txt | 4 +- .../secure_store/NvsAudioProfileStore.hpp | 42 ++++ .../secure_store/src/NvsAudioProfileStore.cpp | 111 +++++++++ .../components/services/audio/CMakeLists.txt | 8 + .../audio/include/audio/AudioService.hpp | 158 ++++++++++++ .../services/audio/src/AudioService.cpp | 118 +++++++++ Software/docs/manual/ch-api.tex | 55 ++++- Software/docs/manual/ch-classes.tex | 60 ++++- Software/docs/manual/ch-firmware.tex | 3 + Software/instructions.md | 22 +- Software/main/CMakeLists.txt | 2 +- Software/main/hardware_bootstrap.cpp | 15 ++ Software/main/hardware_bootstrap.hpp | 18 ++ Software/main/main.cpp | 5 +- 58 files changed, 3191 insertions(+), 41 deletions(-) create mode 100644 Software/components/core/include/core/AudioProfile.hpp create mode 100644 Software/components/core/include/core/AudioProfileJson.hpp create mode 100644 Software/components/core/include/core/BiquadCoefficients.hpp create mode 100644 Software/components/core/include/core/BiquadDesign.hpp create mode 100644 Software/components/core/include/core/DspError.hpp create mode 100644 Software/components/core/include/core/EqBandIndex.hpp create mode 100644 Software/components/core/include/core/EqBandSettings.hpp create mode 100644 Software/components/core/include/core/EqProfile.hpp create mode 100644 Software/components/core/include/core/FrequencyHz.hpp create mode 100644 Software/components/core/include/core/GainDb.hpp create mode 100644 Software/components/core/include/core/IAudioProfileStore.hpp create mode 100644 Software/components/core/include/core/IDsp.hpp create mode 100644 Software/components/core/include/core/MixSource.hpp create mode 100644 Software/components/core/include/core/MixerState.hpp create mode 100644 Software/components/core/src/AudioProfile.cpp create mode 100644 Software/components/core/src/AudioProfileJson.cpp create mode 100644 Software/components/core/src/BiquadCoefficients.cpp create mode 100644 Software/components/core/src/BiquadDesign.cpp create mode 100644 Software/components/core/src/EqBandIndex.cpp create mode 100644 Software/components/core/src/EqProfile.cpp create mode 100644 Software/components/core/src/FrequencyHz.cpp create mode 100644 Software/components/core/src/GainDb.cpp create mode 100644 Software/components/core/src/MixerState.cpp create mode 100644 Software/components/core/test/audio_profile_json_test.cpp create mode 100644 Software/components/core/test/biquad_design_test.cpp create mode 100644 Software/components/drivers/adau1701/include/adau1701/Adau1701Dsp.hpp create mode 100644 Software/components/drivers/adau1701/include/adau1701/Adau1701ParamMap.hpp create mode 100644 Software/components/drivers/adau1701/src/Adau1701Dsp.cpp create mode 100644 Software/components/secure_store/include/secure_store/NvsAudioProfileStore.hpp create mode 100644 Software/components/secure_store/src/NvsAudioProfileStore.cpp create mode 100644 Software/components/services/audio/CMakeLists.txt create mode 100644 Software/components/services/audio/include/audio/AudioService.hpp create mode 100644 Software/components/services/audio/src/AudioService.cpp diff --git a/Software/Firmware/ADAU1701-Firmware/README.md b/Software/Firmware/ADAU1701-Firmware/README.md index e811d4e..f366b04 100644 --- a/Software/Firmware/ADAU1701-Firmware/README.md +++ b/Software/Firmware/ADAU1701-Firmware/README.md @@ -5,9 +5,13 @@ SigmaStudio project **DigiRadio**, IC1 = ADAU1701. Key files: - `DigiRadio_IC_1.h` — program/param RAM data + `default_download_IC_1()` - `DigiRadio_IC_1_REG.h` — register map -- `DigiRadio_IC_1_PARAM.h` — parameter handles (safeload, Slice 5+) +- `DigiRadio_IC_1_PARAM.h` — parameter handles (safeload addresses) The ESP32 replays `default_download_IC_1()` over I2C on every boot via -`adau1701::Adau1701Driver` (see `components/drivers/adau1701/`). +`adau1701::Adau1701Driver`. Runtime mixer/EQ/master changes use the +ADAU1701 safeload mechanism (`sigma_safeload_param` / +`sigma_safeload_block` in `SigmaStudioFW.c`). I2C address: 7-bit `0x34` (ADDR0=ADDR1=GND), matching `board_pins.hpp`. + +Sample rate: **48 kHz** (see `DigiRadio_NetList.xml`). diff --git a/Software/Firmware/ADAU1701-Firmware/SigmaStudioFW.h b/Software/Firmware/ADAU1701-Firmware/SigmaStudioFW.h index decace9..bf7af11 100644 --- a/Software/Firmware/ADAU1701-Firmware/SigmaStudioFW.h +++ b/Software/Firmware/ADAU1701-Firmware/SigmaStudioFW.h @@ -49,6 +49,35 @@ void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress, */ void adau1701_run_default_download(void); +/** Safeload data register base (0x0810..0x0814). */ +#define ADAU1701_SAFELOAD_DATA_BASE 0x0810U +/** Safeload address register base (0x0815..0x0819). */ +#define ADAU1701_SAFELOAD_ADDR_BASE 0x0815U +/** DSP core control register (IST bit triggers safeload). */ +#define ADAU1701_CORE_CONTROL_REG 0x081CU +/** IST bit value OR-ed into core control to commit safeload. */ +#define ADAU1701_CORE_IST_TRIGGER 0x003CU + +/** + * @brief Safeload one 8.23 fixpoint parameter (click-free update). + * + * @param paramAddr Parameter RAM address from DigiRadio_IC_1_PARAM.h. + * @param fixpoint 32-bit SigmaStudio fixpoint value. + * @return 0 on success, non-zero on I2C failure. + */ +int sigma_safeload_param(unsigned int paramAddr, int fixpoint); + +/** + * @brief Safeload up to five parameters in one IST transfer (e.g. biquad). + * + * @param count Number of pairs (1..5). + * @param paramAddrs Parameter RAM addresses. + * @param fixpoints 32-bit fixpoint values. + * @return 0 on success, non-zero on I2C failure. + */ +int sigma_safeload_block(unsigned char count, const unsigned int* paramAddrs, + const int* fixpoints); + #ifdef __cplusplus } #endif diff --git a/Software/components/core/CMakeLists.txt b/Software/components/core/CMakeLists.txt index 0b7299a..d80ca82 100644 --- a/Software/components/core/CMakeLists.txt +++ b/Software/components/core/CMakeLists.txt @@ -10,6 +10,15 @@ idf_component_register( "src/EmbeddedBlobReader.cpp" "src/TunerJson.cpp" "src/FrequencyKHz.cpp" + "src/GainDb.cpp" + "src/FrequencyHz.cpp" + "src/EqBandIndex.cpp" + "src/BiquadCoefficients.cpp" + "src/BiquadDesign.cpp" + "src/MixerState.cpp" + "src/EqProfile.cpp" + "src/AudioProfile.cpp" + "src/AudioProfileJson.cpp" INCLUDE_DIRS "include" ) diff --git a/Software/components/core/include/core/AudioProfile.hpp b/Software/components/core/include/core/AudioProfile.hpp new file mode 100644 index 0000000..11137a6 --- /dev/null +++ b/Software/components/core/include/core/AudioProfile.hpp @@ -0,0 +1,51 @@ +/** + * @file AudioProfile.hpp + * @brief Combined mixer, EQ, and master volume snapshot. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/EqProfile.hpp" +#include "core/GainDb.hpp" +#include "core/MixerState.hpp" + +namespace core { + +/** + * @brief AudioProfile — full ADAU1701 user configuration snapshot. + * + * @dname AudioProfile + * @return n/a (type) + * @pubstate Plain aggregate persisted by IAudioProfileStore and applied by + * AudioService after boot. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +struct AudioProfile { + MixerState mixer; ///< Input and stereo-mixer gains. + EqProfile eq; ///< Six-band parametric EQ. + GainDb masterLeft; ///< Multiple 1 master volume, left. + GainDb masterRight; ///< Multiple 1 master volume, right. + + /** + * @brief factoryDefault — factory-flat audio path (0 dB everywhere). + * + * @dname factoryDefault + * @return AudioProfile suitable for first boot and reset. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static AudioProfile factoryDefault() noexcept; +}; + +} // namespace core diff --git a/Software/components/core/include/core/AudioProfileJson.hpp b/Software/components/core/include/core/AudioProfileJson.hpp new file mode 100644 index 0000000..5ff776f --- /dev/null +++ b/Software/components/core/include/core/AudioProfileJson.hpp @@ -0,0 +1,77 @@ +/** + * @file AudioProfileJson.hpp + * @brief JSON parse/serialise for audio profile API (pure core). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/AudioProfile.hpp" +#include "core/ParseError.hpp" + +#include +#include +#include + +namespace core { + +/** + * @brief serializeAudioProfileJson — serialise profile for GET /api/audio. + * + * @dname serializeAudioProfileJson + * @param profile Domain snapshot from AudioService. + * @return JSON object string for the HTTP response body. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::string serializeAudioProfileJson( + const AudioProfile& profile); + +/** + * @brief parseAudioProfileJson — parse PUT /api/audio/profile body. + * + * @dname parseAudioProfileJson + * @param json Untrusted request body. + * @return AudioProfile on success, or ParseError. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::expected parseAudioProfileJson( + std::string_view json); + +/** + * @brief serializeAudioSavedJson — success response after profile apply. + * + * @dname serializeAudioSavedJson + * @return JSON object \texttt{\{"status":"saved"\}}. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::string serializeAudioSavedJson(); + +/** + * @brief serializeAudioErrorJson — error response for audio routes. + * + * @dname serializeAudioErrorJson + * @param reason Safe token (never includes secrets). + * @return JSON object with status and reason fields. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::string serializeAudioErrorJson(std::string_view reason); + +} // namespace core diff --git a/Software/components/core/include/core/BiquadCoefficients.hpp b/Software/components/core/include/core/BiquadCoefficients.hpp new file mode 100644 index 0000000..42c1feb --- /dev/null +++ b/Software/components/core/include/core/BiquadCoefficients.hpp @@ -0,0 +1,50 @@ +/** + * @file BiquadCoefficients.hpp + * @brief Float and fixpoint biquad coefficients for ADAU1701 PEQ. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include +#include + +namespace core { + +/** + * @brief BiquadCoefficients — five PEQ coefficients (b0..a1). + * + * @dname BiquadCoefficients + * @return n/a (type) + * @pubstate Plain value type matching SigmaStudio Param EQ cell order. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +struct BiquadCoefficients { + float b0; ///< Numerator b0. + float b1; ///< Numerator b1. + float b2; ///< Numerator b2. + float a0; ///< Denominator a0 (ADI stores feedback terms). + float a1; ///< Denominator a1. + + /** + * @brief toFixpoint823 — convert each coefficient to 8.23 fixpoint. + * + * @dname toFixpoint823 + * @return Array of five 32-bit fixpoint words for safeload. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::array toFixpoint823() const noexcept; +}; + +} // namespace core diff --git a/Software/components/core/include/core/BiquadDesign.hpp b/Software/components/core/include/core/BiquadDesign.hpp new file mode 100644 index 0000000..9a100fc --- /dev/null +++ b/Software/components/core/include/core/BiquadDesign.hpp @@ -0,0 +1,80 @@ +/** + * @file BiquadDesign.hpp + * @brief Host-testable biquad coefficient design for ADAU1701 PEQ. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/BiquadCoefficients.hpp" +#include "core/FrequencyHz.hpp" +#include "core/GainDb.hpp" + +namespace core { + +/** Sample rate of the DigiRadio SigmaStudio project (Hz). */ +inline constexpr std::uint32_t kAdauSampleRateHz = 48000U; + +/** + * @brief designPeakingEq — compute PEQ coefficients for one band. + * + * @dname designPeakingEq + * @param center Validated centre frequency. + * @param gain Band gain in dB (0 dB yields flat response). + * @param q Quality factor (typically 0.5..10). + * @return BiquadCoefficients in SigmaStudio Param EQ order. + * @pubstate none + * + * Uses the Robert Bristow-Johnson peaking EQ formulae at kAdauSampleRateHz. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] BiquadCoefficients designPeakingEq(FrequencyHz center, GainDb gain, + float q) noexcept; + +/** + * @brief designFlatEq — unity-gain bypass coefficients for a PEQ band. + * + * @dname designFlatEq + * @return Identity biquad (b0=1, others 0) as in SigmaStudio defaults. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] BiquadCoefficients designFlatEq() noexcept; + +/** + * @brief gainDbToLinearFixpoint — map dB to ADAU 8.23 linear gain word. + * + * @dname gainDbToLinearFixpoint + * @param gain Validated gain in dB. + * @return 32-bit fixpoint suitable for safeload (0 dB = 0x00800000). + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::int32_t gainDbToLinearFixpoint(GainDb gain) noexcept; + +/** + * @brief floatToFixpoint823 — convert a float to ADAU 8.23 fixpoint. + * + * @dname floatToFixpoint823 + * @param value Floating value (typically -16..+16 for filter coeffs). + * @return Rounded 32-bit fixpoint word. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::int32_t floatToFixpoint823(float value) noexcept; + +} // namespace core diff --git a/Software/components/core/include/core/DspError.hpp b/Software/components/core/include/core/DspError.hpp new file mode 100644 index 0000000..b1b9bdb --- /dev/null +++ b/Software/components/core/include/core/DspError.hpp @@ -0,0 +1,33 @@ +/** + * @file DspError.hpp + * @brief Typed errors for DSP control operations. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +namespace core { + +/** + * @brief DspError — failure causes for IDsp operations. + * + * @dname DspError + * @return n/a (type) + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-06 + */ +enum class DspError { + NotBooted, + SafeloadFailed, + InvalidParameter, +}; + +} // namespace core diff --git a/Software/components/core/include/core/EqBandIndex.hpp b/Software/components/core/include/core/EqBandIndex.hpp new file mode 100644 index 0000000..cbcfd55 --- /dev/null +++ b/Software/components/core/include/core/EqBandIndex.hpp @@ -0,0 +1,69 @@ +/** + * @file EqBandIndex.hpp + * @brief Strong index for ADAU1701 Param EQ1 bands (0–5). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/ParseError.hpp" + +#include +#include + +namespace core { + +/** + * @brief EqBandIndex — validated index into the 6-band Param EQ1 module. + * + * @dname EqBandIndex + * @return n/a (type) + * @pubstate Owns index_ in 0..kBandCount-1 after construction. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class EqBandIndex { +public: + /** Number of parametric EQ bands in the SigmaStudio export. */ + static constexpr std::uint8_t kBandCount = 6U; + + /** + * @brief tryFromIndex — validate a band index at the boundary. + * + * @dname tryFromIndex + * @param index Untrusted band index from JSON input. + * @return EqBandIndex on success, or ParseError::MissingField. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static std::expected tryFromIndex( + std::uint32_t index) noexcept; + + /** + * @brief value — read the stored band index. + * + * @dname value + * @return Band index 0..5. + * @pubstate reads index_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::uint8_t value() const noexcept; + +private: + explicit EqBandIndex(std::uint8_t index) noexcept; + + std::uint8_t index_; +}; + +} // namespace core diff --git a/Software/components/core/include/core/EqBandSettings.hpp b/Software/components/core/include/core/EqBandSettings.hpp new file mode 100644 index 0000000..98c1330 --- /dev/null +++ b/Software/components/core/include/core/EqBandSettings.hpp @@ -0,0 +1,36 @@ +/** + * @file EqBandSettings.hpp + * @brief Per-band EQ settings for EqProfile. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/FrequencyHz.hpp" +#include "core/GainDb.hpp" + +namespace core { + +/** + * @brief EqBandSettings — gain, centre frequency, and Q for one PEQ band. + * + * @dname EqBandSettings + * @return n/a (type) + * @pubstate Plain value type; q in 0.2..10.0 when validated via EqProfile. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +struct EqBandSettings { + GainDb gain; ///< Band gain in dB. + FrequencyHz center; ///< Centre frequency in Hz. + float q; ///< Quality factor. +}; + +} // namespace core diff --git a/Software/components/core/include/core/EqProfile.hpp b/Software/components/core/include/core/EqProfile.hpp new file mode 100644 index 0000000..cde4800 --- /dev/null +++ b/Software/components/core/include/core/EqProfile.hpp @@ -0,0 +1,94 @@ +/** + * @file EqProfile.hpp + * @brief Six-band parametric EQ profile for the ADAU1701. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/EqBandIndex.hpp" +#include "core/EqBandSettings.hpp" +#include "core/FrequencyHz.hpp" +#include "core/GainDb.hpp" + +#include + +namespace core { + +/** + * @brief EqProfile — six PEQ bands persisted and applied via AudioService. + * + * @dname EqProfile + * @return n/a (type) + * @pubstate Owns bands_ array; default centres match typical listening curve. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class EqProfile { +public: + /** + * @brief factoryDefault — flat EQ at standard centre frequencies. + * + * @dname factoryDefault + * @return EqProfile with 0 dB on all bands. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static EqProfile factoryDefault() noexcept; + + /** + * @brief band — read settings for one PEQ band. + * + * @dname band + * @param index Band index 0..5. + * @return Const reference to band settings. + * @pubstate reads bands_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] const EqBandSettings& band(EqBandIndex index) const noexcept; + + /** + * @brief setBand — replace settings for one PEQ band. + * + * @dname setBand + * @param index Band index 0..5. + * @param settings New gain, centre, and Q. + * @pubstate writes bands_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + void setBand(EqBandIndex index, EqBandSettings settings) noexcept; + + /** + * @brief bands — access the full band array. + * + * @dname bands + * @return Const reference to all six band settings. + * @pubstate reads bands_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] const std::array& + bands() const noexcept; + +private: + explicit EqProfile(std::array bands) + noexcept; + + std::array bands_; +}; + +} // namespace core diff --git a/Software/components/core/include/core/FrequencyHz.hpp b/Software/components/core/include/core/FrequencyHz.hpp new file mode 100644 index 0000000..1aae9c6 --- /dev/null +++ b/Software/components/core/include/core/FrequencyHz.hpp @@ -0,0 +1,71 @@ +/** + * @file FrequencyHz.hpp + * @brief Strong type for audio centre frequencies (EQ bands). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/ParseError.hpp" + +#include +#include + +namespace core { + +/** + * @brief FrequencyHz — validated centre frequency for parametric EQ. + * + * @dname FrequencyHz + * @return n/a (type) + * @pubstate Owns hz_ within 20..20\,000 Hz (48 kHz SigmaStudio project). + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class FrequencyHz { +public: + /** Minimum EQ centre frequency (Hz). */ + static constexpr std::uint32_t kMinHz = 20U; + /** Maximum EQ centre frequency (Hz). */ + static constexpr std::uint32_t kMaxHz = 20000U; + + /** + * @brief tryFromHz — validate a centre frequency at the boundary. + * + * @dname tryFromHz + * @param hz Untrusted frequency in hertz. + * @return FrequencyHz on success, or ParseError::MissingField. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static std::expected tryFromHz( + std::uint32_t hz) noexcept; + + /** + * @brief value — read the stored frequency in hertz. + * + * @dname value + * @return Validated centre frequency in Hz. + * @pubstate reads hz_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::uint32_t value() const noexcept; + +private: + explicit FrequencyHz(std::uint32_t hz) noexcept; + + std::uint32_t hz_; +}; + +} // namespace core diff --git a/Software/components/core/include/core/GainDb.hpp b/Software/components/core/include/core/GainDb.hpp new file mode 100644 index 0000000..2ba7ba2 --- /dev/null +++ b/Software/components/core/include/core/GainDb.hpp @@ -0,0 +1,95 @@ +/** + * @file GainDb.hpp + * @brief Strong type for decibel gain/attenuation values. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/ParseError.hpp" + +#include +#include + +namespace core { + +/** + * @brief GainDb — validated gain in decibels for DSP volume and EQ. + * + * @dname GainDb + * @return n/a (type) + * @pubstate Owns db_ within kMinDb..kMaxDb after construction. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class GainDb { +public: + /** Minimum attenuation (effectively muted on the linear path). */ + static constexpr float kMinDb = -96.0F; + /** Maximum boost allowed on the DSP path. */ + static constexpr float kMaxDb = 12.0F; + + /** + * @brief tryFromDb — validate a gain value at the HTTP boundary. + * + * @dname tryFromDb + * @param db Untrusted gain in decibels. + * @return GainDb on success, or ParseError::MissingField. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static std::expected tryFromDb( + float db) noexcept; + + /** + * @brief zero — unity gain (0 dB). + * + * @dname zero + * @return GainDb at 0 dB. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static GainDb zero() noexcept; + + /** + * @brief value — read the stored gain in decibels. + * + * @dname value + * @return Validated gain in dB. + * @pubstate reads db_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] float value() const noexcept; + + /** + * @brief linear — convert to linear amplitude (not dB). + * + * @dname linear + * @return Linear gain factor (1.0 at 0 dB). + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] float linear() const noexcept; + +private: + explicit GainDb(float db) noexcept; + + float db_; +}; + +} // namespace core diff --git a/Software/components/core/include/core/IAudioProfileStore.hpp b/Software/components/core/include/core/IAudioProfileStore.hpp new file mode 100644 index 0000000..1ed7a98 --- /dev/null +++ b/Software/components/core/include/core/IAudioProfileStore.hpp @@ -0,0 +1,88 @@ +/** + * @file IAudioProfileStore.hpp + * @brief Persistence boundary for user audio configuration. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/AudioProfile.hpp" +#include "core/StoreError.hpp" + +#include + +namespace core { + +/** + * @brief IAudioProfileStore — load/save AudioProfile in NVS (non-secret). + * + * @dname IAudioProfileStore + * @return n/a (type) + * @pubstate Shell implementations own NVS handles; core uses fakes in tests. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class IAudioProfileStore { +public: + virtual ~IAudioProfileStore() = default; + + /** + * @brief hasProfile — check whether a saved profile exists. + * + * @dname hasProfile + * @return true when loadProfile would succeed. + * @pubstate reads backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual bool hasProfile() const = 0; + + /** + * @brief saveProfile — persist a validated audio profile. + * + * @dname saveProfile + * @param profile User mixer/EQ/master snapshot. + * @return Ok on success, or StoreError::IoFailed. + * @pubstate writes backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected saveProfile( + const AudioProfile& profile) = 0; + + /** + * @brief loadProfile — read the stored audio profile. + * + * @dname loadProfile + * @return AudioProfile on success, or StoreError::NotFound / IoFailed. + * @pubstate reads backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected loadProfile() + const = 0; + + /** + * @brief clearProfile — erase the stored audio profile. + * + * @dname clearProfile + * @return Ok on success, or StoreError::IoFailed. + * @pubstate clears backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected clearProfile() = 0; +}; + +} // namespace core diff --git a/Software/components/core/include/core/IDsp.hpp b/Software/components/core/include/core/IDsp.hpp new file mode 100644 index 0000000..33c7d4f --- /dev/null +++ b/Software/components/core/include/core/IDsp.hpp @@ -0,0 +1,135 @@ +/** + * @file IDsp.hpp + * @brief Abstract ADAU1701 DSP control boundary (host-testable). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/AudioProfile.hpp" +#include "core/DspError.hpp" +#include "core/EqBandIndex.hpp" +#include "core/FrequencyHz.hpp" +#include "core/GainDb.hpp" +#include "core/MixSource.hpp" +#include "core/MixerState.hpp" +#include "core/EqProfile.hpp" + +#include + +namespace core { + +/** + * @brief IDsp — hardware abstraction for ADAU1701 runtime control. + * + * @dname IDsp + * @return n/a (type) + * @pubstate Implemented by adau1701::Adau1701Dsp on device; fakes in tests. + * + * All parameter updates use safeload on the implementation side. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class IDsp { +public: + virtual ~IDsp() = default; + + /** + * @brief applyProfile — safeload mixer, EQ, and master from a snapshot. + * + * @dname applyProfile + * @param profile Validated user configuration. + * @return Ok on success, or DspError. + * @pubstate writes ADAU1701 parameter RAM via safeload. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected applyProfile( + const AudioProfile& profile) = 0; + + /** + * @brief applyMixer — safeload input and stereo-mixer gains. + * + * @dname applyMixer + * @param mixer Per-source and St Mixer1 levels. + * @return Ok on success, or DspError. + * @pubstate writes ADAU1701 parameter RAM via safeload. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected applyMixer( + const MixerState& mixer) = 0; + + /** + * @brief applyEq — safeload all six PEQ bands. + * + * @dname applyEq + * @param eq Six-band parametric EQ settings. + * @return Ok on success, or DspError. + * @pubstate writes ADAU1701 parameter RAM via safeload. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected applyEq( + const EqProfile& eq) = 0; + + /** + * @brief setInputVolume — safeload one input path (Si4684 or ESP32). + * + * @dname setInputVolume + * @param source Tuner or ESP32 I2S path. + * @param left Left channel gain. + * @param right Right channel gain. + * @return Ok on success, or DspError. + * @pubstate writes ADAU1701 parameter RAM via safeload. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected setInputVolume( + MixSource source, GainDb left, GainDb right) = 0; + + /** + * @brief setMasterVolume — safeload Multiple 1 master output gain. + * + * @dname setMasterVolume + * @param left Left master gain. + * @param right Right master gain. + * @return Ok on success, or DspError. + * @pubstate writes ADAU1701 parameter RAM via safeload. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected setMasterVolume( + GainDb left, GainDb right) = 0; + + /** + * @brief setEqBand — design and safeload one PEQ band. + * + * @dname setEqBand + * @param band Band index 0..5. + * @param gain Band gain in dB. + * @param center Centre frequency. + * @param q Quality factor. + * @return Ok on success, or DspError. + * @pubstate writes five coefficients via a single safeload transfer. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected setEqBand( + EqBandIndex band, GainDb gain, FrequencyHz center, float q) = 0; +}; + +} // namespace core diff --git a/Software/components/core/include/core/MixSource.hpp b/Software/components/core/include/core/MixSource.hpp new file mode 100644 index 0000000..1b2618a --- /dev/null +++ b/Software/components/core/include/core/MixSource.hpp @@ -0,0 +1,32 @@ +/** + * @file MixSource.hpp + * @brief ADAU1701 input path selector for per-source volume control. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +namespace core { + +/** + * @brief MixSource — Si4684 tuner or ESP32 I2S input on the ADAU1701. + * + * @dname MixSource + * @return n/a (type) + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-06 + */ +enum class MixSource { + Si4684, + Esp32, +}; + +} // namespace core diff --git a/Software/components/core/include/core/MixerState.hpp b/Software/components/core/include/core/MixerState.hpp new file mode 100644 index 0000000..8a9449b --- /dev/null +++ b/Software/components/core/include/core/MixerState.hpp @@ -0,0 +1,52 @@ +/** + * @file MixerState.hpp + * @brief ADAU1701 input and stereo-mixer gain snapshot. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/GainDb.hpp" + +namespace core { + +/** + * @brief MixerState — per-source and St Mixer1 levels for the ADAU1701. + * + * @dname MixerState + * @return n/a (type) + * @pubstate Immutable snapshot applied by AudioService via IDsp. + * + * Maps to SigmaStudio modules Si4674, ESP32, and St Mixer1. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +struct MixerState { + GainDb si4684Left; ///< Si4674 volume control, left channel. + GainDb si4684Right; ///< Si4674 volume control, right channel. + GainDb esp32Left; ///< ESP32 volume control, left channel. + GainDb esp32Right; ///< ESP32 volume control, right channel. + GainDb mixLeft; ///< St Mixer1 left blend level. + GainDb mixRight; ///< St Mixer1 right blend level. + + /** + * @brief factoryDefault — unity gains on all paths (radio-first mix). + * + * @dname factoryDefault + * @return MixerState with 0 dB on every control. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static MixerState factoryDefault() noexcept; +}; + +} // namespace core diff --git a/Software/components/core/src/AudioProfile.cpp b/Software/components/core/src/AudioProfile.cpp new file mode 100644 index 0000000..8a28508 --- /dev/null +++ b/Software/components/core/src/AudioProfile.cpp @@ -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 diff --git a/Software/components/core/src/AudioProfileJson.cpp b/Software/components/core/src/AudioProfileJson.cpp new file mode 100644 index 0000000..c86fb62 --- /dev/null +++ b/Software/components/core/src/AudioProfileJson.cpp @@ -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 +#include + +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 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 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 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(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 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 diff --git a/Software/components/core/src/BiquadCoefficients.cpp b/Software/components/core/src/BiquadCoefficients.cpp new file mode 100644 index 0000000..78a5d3b --- /dev/null +++ b/Software/components/core/src/BiquadCoefficients.cpp @@ -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 BiquadCoefficients::toFixpoint823() const noexcept +{ + return { + floatToFixpoint823(b0), + floatToFixpoint823(b1), + floatToFixpoint823(b2), + floatToFixpoint823(a0), + floatToFixpoint823(a1), + }; +} + +} // namespace core diff --git a/Software/components/core/src/BiquadDesign.cpp b/Software/components/core/src/BiquadDesign.cpp new file mode 100644 index 0000000..3fd63fc --- /dev/null +++ b/Software/components/core/src/BiquadDesign.cpp @@ -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 +#include + +namespace core { + +namespace { + +constexpr float kPi = std::numbers::pi_v; + +[[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(value) * static_cast(1U << 23); + const auto rounded = static_cast(std::llround(scaled)); + return static_cast(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(kAdauSampleRateHz); + const float f0 = static_cast(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 diff --git a/Software/components/core/src/EqBandIndex.cpp b/Software/components/core/src/EqBandIndex.cpp new file mode 100644 index 0000000..3802ec9 --- /dev/null +++ b/Software/components/core/src/EqBandIndex.cpp @@ -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::tryFromIndex( + std::uint32_t index) noexcept +{ + if (index >= kBandCount) { + return std::unexpected(ParseError::MissingField); + } + return EqBandIndex(static_cast(index)); +} + +std::uint8_t EqBandIndex::value() const noexcept +{ + return index_; +} + +} // namespace core diff --git a/Software/components/core/src/EqProfile.cpp b/Software/components/core/src/EqProfile.cpp new file mode 100644 index 0000000..dc4ad69 --- /dev/null +++ b/Software/components/core/src/EqProfile.cpp @@ -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 kDefaultCenters{ + 40U, 100U, 250U, 1000U, 4000U, 12000U}; + +[[nodiscard]] std::array +makeDefaultBands() noexcept +{ + return std::array{ + 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 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& EqProfile::bands() + const noexcept +{ + return bands_; +} + +} // namespace core diff --git a/Software/components/core/src/FrequencyHz.cpp b/Software/components/core/src/FrequencyHz.cpp new file mode 100644 index 0000000..3e85c1c --- /dev/null +++ b/Software/components/core/src/FrequencyHz.cpp @@ -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::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 diff --git a/Software/components/core/src/GainDb.cpp b/Software/components/core/src/GainDb.cpp new file mode 100644 index 0000000..30aa2bb --- /dev/null +++ b/Software/components/core/src/GainDb.cpp @@ -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::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 diff --git a/Software/components/core/src/MixerState.cpp b/Software/components/core/src/MixerState.cpp new file mode 100644 index 0000000..37990e3 --- /dev/null +++ b/Software/components/core/src/MixerState.cpp @@ -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 diff --git a/Software/components/core/test/CMakeLists.txt b/Software/components/core/test/CMakeLists.txt index 0f725a7..63382fb 100644 --- a/Software/components/core/test/CMakeLists.txt +++ b/Software/components/core/test/CMakeLists.txt @@ -22,6 +22,15 @@ add_library(digiradio_core STATIC "${CORE_SRC_DIR}/EmbeddedBlobReader.cpp" "${CORE_SRC_DIR}/TunerJson.cpp" "${CORE_SRC_DIR}/FrequencyKHz.cpp" + "${CORE_SRC_DIR}/GainDb.cpp" + "${CORE_SRC_DIR}/FrequencyHz.cpp" + "${CORE_SRC_DIR}/EqBandIndex.cpp" + "${CORE_SRC_DIR}/BiquadCoefficients.cpp" + "${CORE_SRC_DIR}/BiquadDesign.cpp" + "${CORE_SRC_DIR}/MixerState.cpp" + "${CORE_SRC_DIR}/EqProfile.cpp" + "${CORE_SRC_DIR}/AudioProfile.cpp" + "${CORE_SRC_DIR}/AudioProfileJson.cpp" ) target_include_directories(digiradio_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/../include" @@ -46,3 +55,11 @@ add_test(NAME tuner_json_test COMMAND tuner_json_test) add_executable(frequency_khz_test frequency_khz_test.cpp) target_link_libraries(frequency_khz_test PRIVATE digiradio_core) add_test(NAME frequency_khz_test COMMAND frequency_khz_test) + +add_executable(biquad_design_test biquad_design_test.cpp) +target_link_libraries(biquad_design_test PRIVATE digiradio_core) +add_test(NAME biquad_design_test COMMAND biquad_design_test) + +add_executable(audio_profile_json_test audio_profile_json_test.cpp) +target_link_libraries(audio_profile_json_test PRIVATE digiradio_core) +add_test(NAME audio_profile_json_test COMMAND audio_profile_json_test) diff --git a/Software/components/core/test/audio_profile_json_test.cpp b/Software/components/core/test/audio_profile_json_test.cpp new file mode 100644 index 0000000..fb4ffab --- /dev/null +++ b/Software/components/core/test/audio_profile_json_test.cpp @@ -0,0 +1,66 @@ +/** + * @file audio_profile_json_test.cpp + * @brief Host tests for AudioProfile JSON round-trip. + * + * 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" +#include "core/AudioProfileJson.hpp" +#include "core/EqBandIndex.hpp" +#include "core/FrequencyHz.hpp" +#include "core/GainDb.hpp" + +#include +#include + +namespace { + +[[nodiscard]] int runRoundTripTest() +{ + core::AudioProfile profile = core::AudioProfile::factoryDefault(); + const auto band = core::EqBandIndex::tryFromIndex(2U); + const auto gain = core::GainDb::tryFromDb(3.0F); + const auto center = core::FrequencyHz::tryFromHz(500U); + if (!band || !gain || !center) { + std::cerr << "test setup failed\n"; + return EXIT_FAILURE; + } + profile.eq.setBand(*band, + core::EqBandSettings{ + .gain = *gain, + .center = *center, + .q = 2.0F, + }); + + const std::string json = core::serializeAudioProfileJson(profile); + const auto parsed = core::parseAudioProfileJson(json); + if (!parsed) { + std::cerr << "parse failed\n"; + return EXIT_FAILURE; + } + + const auto& b = parsed->eq.band(*band); + if (b.gain.value() != 3.0F || b.center.value() != 500U + || b.q != 2.0F) { + std::cerr << "round-trip mismatch\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +} // namespace + +int main() +{ + if (runRoundTripTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} diff --git a/Software/components/core/test/biquad_design_test.cpp b/Software/components/core/test/biquad_design_test.cpp new file mode 100644 index 0000000..dd771cb --- /dev/null +++ b/Software/components/core/test/biquad_design_test.cpp @@ -0,0 +1,68 @@ +/** + * @file biquad_design_test.cpp + * @brief Host tests for ADAU biquad design and 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/BiquadDesign.hpp" +#include "core/FrequencyHz.hpp" +#include "core/GainDb.hpp" + +#include +#include +#include + +namespace { + +[[nodiscard]] int runUnityGainFixpointTest() +{ + const auto gain = core::GainDb::zero(); + const std::int32_t fix = core::gainDbToLinearFixpoint(gain); + if (fix != 0x00800000) { + std::cerr << "expected 0 dB fixpoint 0x00800000, got 0x" + << std::hex << fix << std::dec << '\n'; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runFlatBiquadTest() +{ + const core::BiquadCoefficients flat = core::designFlatEq(); + if (std::fabs(flat.b0 - 1.0F) > 0.001F || std::fabs(flat.b1) > 0.001F + || std::fabs(flat.b2) > 0.001F || std::fabs(flat.a0) > 0.001F + || std::fabs(flat.a1) > 0.001F) { + std::cerr << "flat biquad coefficients unexpected\n"; + return EXIT_FAILURE; + } + + const auto center = core::FrequencyHz::tryFromHz(1000U); + const auto zero = core::GainDb::zero(); + const core::BiquadCoefficients peaking = + core::designPeakingEq(*center, zero, 1.414F); + if (std::fabs(peaking.b0 - 1.0F) > 0.001F) { + std::cerr << "0 dB peaking should match flat b0\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +} // namespace + +int main() +{ + if (runUnityGainFixpointTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runFlatBiquadTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} diff --git a/Software/components/drivers/adau1701/CMakeLists.txt b/Software/components/drivers/adau1701/CMakeLists.txt index aa1d7fb..51c2aad 100644 --- a/Software/components/drivers/adau1701/CMakeLists.txt +++ b/Software/components/drivers/adau1701/CMakeLists.txt @@ -3,12 +3,13 @@ set(ADAU_FW_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../Firmware/ADAU1701-Firmware") idf_component_register( SRCS "src/Adau1701Driver.cpp" + "src/Adau1701Dsp.cpp" "src/SigmaStudioFW.c" "src/adau1701_program.c" INCLUDE_DIRS "include" "${ADAU_FW_DIR}" - REQUIRES driver esp_driver_gpio esp_driver_i2c + REQUIRES core driver esp_driver_gpio esp_driver_i2c ) target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/drivers/adau1701/include/adau1701/Adau1701Driver.hpp b/Software/components/drivers/adau1701/include/adau1701/Adau1701Driver.hpp index 38b3201..9209cc5 100644 --- a/Software/components/drivers/adau1701/include/adau1701/Adau1701Driver.hpp +++ b/Software/components/drivers/adau1701/include/adau1701/Adau1701Driver.hpp @@ -1,6 +1,6 @@ /** * @file Adau1701Driver.hpp - * @brief ADAU1701 SigmaDSP driver — RAM boot on every power-up. + * @brief ADAU1701 SigmaDSP driver — RAM boot and runtime safeload control. * * DigiRadio firmware — https://github.com/manvalan/DigiRadio * @@ -14,6 +14,15 @@ #include "adau1701/Adau1701Error.hpp" +#include "core/AudioProfile.hpp" +#include "core/EqBandIndex.hpp" +#include "core/EqProfile.hpp" +#include "core/FrequencyHz.hpp" +#include "core/GainDb.hpp" +#include "core/MixSource.hpp" +#include "core/MixerState.hpp" + +#include #include namespace adau1701 { @@ -36,7 +45,7 @@ struct Adau1701Pins { }; /** - * @brief Adau1701Driver — owns I2C + reset and loads SigmaStudio RAM. + * @brief Adau1701Driver — owns I2C + reset, boot, and safeload runtime. * * @dname Adau1701Driver * @param pins Board wiring for I2C and RESET#. @@ -45,7 +54,8 @@ struct Adau1701Pins { * successful default_download replay. * * Writes the SigmaStudio export from Firmware/ADAU1701-Firmware on every - * boot (no EEPROM self-boot on DigiRadio). + * boot (no EEPROM self-boot on DigiRadio). Runtime EQ and mixer changes + * use the ADAU1701 safeload mechanism. * * @author Michele Bigi * @date 2026-07-06 @@ -102,7 +112,104 @@ public: */ [[nodiscard]] bool isBooted() const noexcept; + /** + * @brief applyProfile — safeload mixer, EQ, and master from snapshot. + * + * @dname applyProfile + * @param profile User audio configuration. + * @return Ok on success, or Adau1701Error. + * @pubstate writes parameter RAM via safeload. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected applyProfile( + const core::AudioProfile& profile); + + /** + * @brief applyMixer — safeload input and stereo-mixer gains. + * + * @dname applyMixer + * @param mixer Per-source and St Mixer1 levels. + * @return Ok on success, or Adau1701Error. + * @pubstate writes parameter RAM via safeload. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected applyMixer( + const core::MixerState& mixer); + + /** + * @brief applyEq — safeload all six PEQ bands. + * + * @dname applyEq + * @param eq Six-band parametric EQ settings. + * @return Ok on success, or Adau1701Error. + * @pubstate writes parameter RAM via safeload. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected applyEq( + const core::EqProfile& eq); + + /** + * @brief setInputVolume — safeload one input path gain. + * + * @dname setInputVolume + * @param source Si4684 or ESP32 I2S path. + * @param left Left channel gain. + * @param right Right channel gain. + * @return Ok on success, or Adau1701Error. + * @pubstate writes parameter RAM via safeload. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected setInputVolume( + core::MixSource source, core::GainDb left, core::GainDb right); + + /** + * @brief setMasterVolume — safeload Multiple 1 master output gain. + * + * @dname setMasterVolume + * @param left Left master gain. + * @param right Right master gain. + * @return Ok on success, or Adau1701Error. + * @pubstate writes parameter RAM via safeload. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected setMasterVolume( + core::GainDb left, core::GainDb right); + + /** + * @brief setEqBand — design and safeload one PEQ band. + * + * @dname setEqBand + * @param band Band index 0..5. + * @param gain Band gain in dB. + * @param center Centre frequency. + * @param q Quality factor. + * @return Ok on success, or Adau1701Error. + * @pubstate writes five coefficients in one safeload transfer. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected setEqBand( + core::EqBandIndex band, core::GainDb gain, core::FrequencyHz center, + float q); + private: + [[nodiscard]] std::expected ensureBooted() const; + [[nodiscard]] std::expected safeloadGain( + unsigned paramAddr, core::GainDb gain); + [[nodiscard]] std::expected safeloadFixpoint( + unsigned paramAddr, std::int32_t fixpoint); + Adau1701Pins pins_; bool booted_; void* i2cBus_; diff --git a/Software/components/drivers/adau1701/include/adau1701/Adau1701Dsp.hpp b/Software/components/drivers/adau1701/include/adau1701/Adau1701Dsp.hpp new file mode 100644 index 0000000..8010271 --- /dev/null +++ b/Software/components/drivers/adau1701/include/adau1701/Adau1701Dsp.hpp @@ -0,0 +1,70 @@ +/** + * @file Adau1701Dsp.hpp + * @brief core::IDsp adapter over Adau1701Driver. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "adau1701/Adau1701Driver.hpp" + +#include "core/IDsp.hpp" + +namespace adau1701 { + +/** + * @brief Adau1701Dsp — maps core::IDsp to Adau1701Driver safeload API. + * + * @dname Adau1701Dsp + * @return n/a (type) + * @pubstate Borrows Adau1701Driver for the process lifetime. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class Adau1701Dsp final : public core::IDsp { +public: + /** + * @brief Adau1701Dsp — bind to the board driver instance. + * + * @dname Adau1701Dsp + * @param driver Booted ADAU1701 driver (must outlive this adapter). + * @pubstate stores driver reference. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + explicit Adau1701Dsp(Adau1701Driver& driver); + + [[nodiscard]] std::expected applyProfile( + const core::AudioProfile& profile) override; + + [[nodiscard]] std::expected applyMixer( + const core::MixerState& mixer) override; + + [[nodiscard]] std::expected applyEq( + const core::EqProfile& eq) override; + + [[nodiscard]] std::expected setInputVolume( + core::MixSource source, core::GainDb left, core::GainDb right) override; + + [[nodiscard]] std::expected setMasterVolume( + core::GainDb left, core::GainDb right) override; + + [[nodiscard]] std::expected setEqBand( + core::EqBandIndex band, core::GainDb gain, core::FrequencyHz center, + float q) override; + +private: + [[nodiscard]] static core::DspError mapError(Adau1701Error error) noexcept; + + Adau1701Driver& driver_; +}; + +} // namespace adau1701 diff --git a/Software/components/drivers/adau1701/include/adau1701/Adau1701Error.hpp b/Software/components/drivers/adau1701/include/adau1701/Adau1701Error.hpp index 6632c31..4c5da0f 100644 --- a/Software/components/drivers/adau1701/include/adau1701/Adau1701Error.hpp +++ b/Software/components/drivers/adau1701/include/adau1701/Adau1701Error.hpp @@ -28,6 +28,8 @@ enum class Adau1701Error { I2cInitFailed, ResetFailed, DownloadFailed, + NotBooted, + SafeloadFailed, }; } // namespace adau1701 diff --git a/Software/components/drivers/adau1701/include/adau1701/Adau1701ParamMap.hpp b/Software/components/drivers/adau1701/include/adau1701/Adau1701ParamMap.hpp new file mode 100644 index 0000000..23ff4b7 --- /dev/null +++ b/Software/components/drivers/adau1701/include/adau1701/Adau1701ParamMap.hpp @@ -0,0 +1,77 @@ +/** + * @file Adau1701ParamMap.hpp + * @brief SigmaStudio parameter RAM addresses for DigiRadio runtime control. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "DigiRadio_IC_1_PARAM.h" + +#include "core/EqBandIndex.hpp" +#include "core/MixSource.hpp" + +#include + +namespace adau1701 { + +/** + * @brief paramAddrEqBandBase — first coefficient address for a PEQ band. + * + * @dname paramAddrEqBandBase + * @param band Band index 0..5. + * @return ADDR_PARAMEQ1_STn_B0 from the SigmaStudio export. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] inline unsigned paramAddrEqBandBase(std::uint8_t band) noexcept +{ + return static_cast(ADDR_PARAMEQ1_ST0_B0) + + static_cast(band) * 5U; +} + +/** + * @brief paramAddrInputLeft — left volume address for an input source. + * + * @dname paramAddrInputLeft + * @param source Si4684 or ESP32 path. + * @return Parameter RAM address for the left channel gain cell. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] inline unsigned paramAddrInputLeft(core::MixSource source) noexcept +{ + return source == core::MixSource::Si4684 + ? static_cast(ADDR_SI4674) + : static_cast(ADDR_ESP32); +} + +/** + * @brief paramAddrInputRight — right volume address for an input source. + * + * @dname paramAddrInputRight + * @param source Si4684 or ESP32 path. + * @return Parameter RAM address for the right channel gain cell. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] inline unsigned paramAddrInputRight(core::MixSource source) noexcept +{ + return source == core::MixSource::Si4684 + ? static_cast(ADDR_SI4674_1) + : static_cast(ADDR_ESP32_1); +} + +} // namespace adau1701 diff --git a/Software/components/drivers/adau1701/src/Adau1701Driver.cpp b/Software/components/drivers/adau1701/src/Adau1701Driver.cpp index d238c32..838745c 100644 --- a/Software/components/drivers/adau1701/src/Adau1701Driver.cpp +++ b/Software/components/drivers/adau1701/src/Adau1701Driver.cpp @@ -13,6 +13,11 @@ #include "adau1701/Adau1701Driver.hpp" +#include "adau1701/Adau1701ParamMap.hpp" + +#include "core/BiquadDesign.hpp" + +#include "DigiRadio_IC_1_PARAM.h" #include "SigmaStudioFW.h" #include "driver/gpio.h" @@ -115,4 +120,139 @@ bool Adau1701Driver::isBooted() const noexcept return booted_; } +std::expected Adau1701Driver::ensureBooted() const +{ + if (!booted_) { + return std::unexpected(Adau1701Error::NotBooted); + } + return {}; +} + +std::expected Adau1701Driver::safeloadFixpoint( + unsigned paramAddr, std::int32_t fixpoint) +{ + if (sigma_safeload_param(paramAddr, fixpoint) != 0) { + return std::unexpected(Adau1701Error::SafeloadFailed); + } + return {}; +} + +std::expected Adau1701Driver::safeloadGain( + unsigned paramAddr, core::GainDb gain) +{ + return safeloadFixpoint(paramAddr, core::gainDbToLinearFixpoint(gain)); +} + +std::expected Adau1701Driver::setInputVolume( + core::MixSource source, core::GainDb left, core::GainDb right) +{ + if (auto ready = ensureBooted(); !ready) { + return ready; + } + if (auto result = safeloadGain(paramAddrInputLeft(source), left); !result) { + return result; + } + return safeloadGain(paramAddrInputRight(source), right); +} + +std::expected Adau1701Driver::setMasterVolume( + core::GainDb left, core::GainDb right) +{ + if (auto ready = ensureBooted(); !ready) { + return ready; + } + if (auto result = safeloadGain(static_cast(ADDR_MULTIPLE1), left); + !result) { + return result; + } + return safeloadGain(static_cast(ADDR_MULTIPLE1_1), right); +} + +std::expected Adau1701Driver::applyMixer( + const core::MixerState& mixer) +{ + if (auto ready = ensureBooted(); !ready) { + return ready; + } + if (auto result = setInputVolume(core::MixSource::Si4684, mixer.si4684Left, + mixer.si4684Right); + !result) { + return result; + } + if (auto result = setInputVolume(core::MixSource::Esp32, mixer.esp32Left, + mixer.esp32Right); + !result) { + return result; + } + if (auto result = + safeloadGain(static_cast(ADDR_STMIXER1_ST0_VOLUME), + mixer.mixLeft); + !result) { + return result; + } + return safeloadGain(static_cast(ADDR_STMIXER1_ST1_VOLUME), + mixer.mixRight); +} + +std::expected Adau1701Driver::setEqBand( + core::EqBandIndex band, core::GainDb gain, core::FrequencyHz center, float q) +{ + if (auto ready = ensureBooted(); !ready) { + return ready; + } + + const core::BiquadCoefficients coeffs = + core::designPeakingEq(center, gain, q); + const auto fixpoints = coeffs.toFixpoint823(); + const unsigned baseAddr = paramAddrEqBandBase(band.value()); + + unsigned addrs[5U]; + int values[5U]; + for (unsigned i = 0U; i < 5U; ++i) { + addrs[i] = baseAddr + i; + values[i] = fixpoints[i]; + } + + if (sigma_safeload_block(5U, addrs, values) != 0) { + return std::unexpected(Adau1701Error::SafeloadFailed); + } + return {}; +} + +std::expected Adau1701Driver::applyEq( + const core::EqProfile& eq) +{ + if (auto ready = ensureBooted(); !ready) { + return ready; + } + + for (std::uint8_t i = 0; i < core::EqBandIndex::kBandCount; ++i) { + const auto index = core::EqBandIndex::tryFromIndex(i); + if (!index) { + return std::unexpected(Adau1701Error::SafeloadFailed); + } + const core::EqBandSettings& band = eq.band(*index); + if (auto result = setEqBand(*index, band.gain, band.center, band.q); + !result) { + return result; + } + } + return {}; +} + +std::expected Adau1701Driver::applyProfile( + const core::AudioProfile& profile) +{ + if (auto ready = ensureBooted(); !ready) { + return ready; + } + if (auto result = applyMixer(profile.mixer); !result) { + return result; + } + if (auto result = applyEq(profile.eq); !result) { + return result; + } + return setMasterVolume(profile.masterLeft, profile.masterRight); +} + } // namespace adau1701 diff --git a/Software/components/drivers/adau1701/src/Adau1701Dsp.cpp b/Software/components/drivers/adau1701/src/Adau1701Dsp.cpp new file mode 100644 index 0000000..8a29a8d --- /dev/null +++ b/Software/components/drivers/adau1701/src/Adau1701Dsp.cpp @@ -0,0 +1,90 @@ +/** + * @file Adau1701Dsp.cpp + * @brief Adau1701Dsp 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 "adau1701/Adau1701Dsp.hpp" + +namespace adau1701 { + +Adau1701Dsp::Adau1701Dsp(Adau1701Driver& driver) + : driver_(driver) +{ +} + +core::DspError Adau1701Dsp::mapError(Adau1701Error error) noexcept +{ + switch (error) { + case Adau1701Error::NotBooted: + return core::DspError::NotBooted; + case Adau1701Error::SafeloadFailed: + return core::DspError::SafeloadFailed; + default: + return core::DspError::SafeloadFailed; + } +} + +std::expected Adau1701Dsp::applyProfile( + const core::AudioProfile& profile) +{ + if (auto result = driver_.applyProfile(profile); !result) { + return std::unexpected(mapError(result.error())); + } + return {}; +} + +std::expected Adau1701Dsp::applyMixer( + const core::MixerState& mixer) +{ + if (auto result = driver_.applyMixer(mixer); !result) { + return std::unexpected(mapError(result.error())); + } + return {}; +} + +std::expected Adau1701Dsp::applyEq( + const core::EqProfile& eq) +{ + if (auto result = driver_.applyEq(eq); !result) { + return std::unexpected(mapError(result.error())); + } + return {}; +} + +std::expected Adau1701Dsp::setInputVolume( + core::MixSource source, core::GainDb left, core::GainDb right) +{ + if (auto result = driver_.setInputVolume(source, left, right); !result) { + return std::unexpected(mapError(result.error())); + } + return {}; +} + +std::expected Adau1701Dsp::setMasterVolume( + core::GainDb left, core::GainDb right) +{ + if (auto result = driver_.setMasterVolume(left, right); !result) { + return std::unexpected(mapError(result.error())); + } + return {}; +} + +std::expected Adau1701Dsp::setEqBand( + core::EqBandIndex band, core::GainDb gain, core::FrequencyHz center, + float q) +{ + if (auto result = driver_.setEqBand(band, gain, center, q); !result) { + return std::unexpected(mapError(result.error())); + } + return {}; +} + +} // namespace adau1701 diff --git a/Software/components/drivers/adau1701/src/SigmaStudioFW.c b/Software/components/drivers/adau1701/src/SigmaStudioFW.c index 0a88dff..26d50a1 100644 --- a/Software/components/drivers/adau1701/src/SigmaStudioFW.c +++ b/Software/components/drivers/adau1701/src/SigmaStudioFW.c @@ -58,3 +58,78 @@ void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress, remaining = (unsigned char)(remaining - chunk); } } + +static int sigma_i2c_write(unsigned int reg, const unsigned char* data, + unsigned char length) +{ + if (s_dev == NULL || data == NULL || length == 0U) { + return -1; + } + + unsigned char buf[2U + 4U]; + if ((size_t)length + 2U > sizeof(buf)) { + return -1; + } + buf[0] = (unsigned char)((reg >> 8) & 0xFFU); + buf[1] = (unsigned char)(reg & 0xFFU); + memcpy(buf + 2U, data, length); + const esp_err_t err = + i2c_master_transmit(s_dev, buf, (size_t)(2U + length), 1000); + return err == ESP_OK ? 0 : -1; +} + +static int sigma_write_fixpoint_reg(unsigned int reg, int fixpoint) +{ + unsigned char payload[4U]; + payload[0] = 0U; + payload[1] = (unsigned char)((fixpoint >> 16) & 0xFFU); + payload[2] = (unsigned char)((fixpoint >> 8) & 0xFFU); + payload[3] = (unsigned char)(fixpoint & 0xFFU); + return sigma_i2c_write(reg, payload, 4U); +} + +static int sigma_write_param_addr(unsigned int reg, unsigned int paramAddr) +{ + unsigned char payload[2U]; + payload[0] = (unsigned char)((paramAddr >> 8) & 0xFFU); + payload[1] = (unsigned char)(paramAddr & 0xFFU); + return sigma_i2c_write(reg, payload, 2U); +} + +static int sigma_trigger_safeload(void) +{ + unsigned char payload[2U]; + payload[0] = (unsigned char)((ADAU1701_CORE_IST_TRIGGER >> 8) & 0xFFU); + payload[1] = (unsigned char)(ADAU1701_CORE_IST_TRIGGER & 0xFFU); + return sigma_i2c_write(ADAU1701_CORE_CONTROL_REG, payload, 2U); +} + +int sigma_safeload_param(unsigned int paramAddr, int fixpoint) +{ + const unsigned int addrs[1U] = {paramAddr}; + const int values[1U] = {fixpoint}; + return sigma_safeload_block(1U, addrs, values); +} + +int sigma_safeload_block(unsigned char count, const unsigned int* paramAddrs, + const int* fixpoints) +{ + if (count == 0U || count > 5U || paramAddrs == NULL || fixpoints == NULL) { + return -1; + } + + for (unsigned char i = 0U; i < count; ++i) { + const unsigned int dataReg = + ADAU1701_SAFELOAD_DATA_BASE + (unsigned int)i; + const unsigned int addrReg = + ADAU1701_SAFELOAD_ADDR_BASE + (unsigned int)i; + if (sigma_write_fixpoint_reg(dataReg, fixpoints[i]) != 0) { + return -1; + } + if (sigma_write_param_addr(addrReg, paramAddrs[i]) != 0) { + return -1; + } + } + + return sigma_trigger_safeload(); +} diff --git a/Software/components/net/CMakeLists.txt b/Software/components/net/CMakeLists.txt index 9c4a292..6e67f3d 100644 --- a/Software/components/net/CMakeLists.txt +++ b/Software/components/net/CMakeLists.txt @@ -7,7 +7,7 @@ idf_component_register( "src/NetBootstrap.cpp" INCLUDE_DIRS "include" EMBED_FILES "www/index.html.gz" - REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server tuner + REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server tuner audio ) target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/net/include/net/NetBootstrap.hpp b/Software/components/net/include/net/NetBootstrap.hpp index b05f72b..ad19e24 100644 --- a/Software/components/net/include/net/NetBootstrap.hpp +++ b/Software/components/net/include/net/NetBootstrap.hpp @@ -27,6 +27,10 @@ #include #include +namespace audio { +class AudioService; +} // namespace audio + namespace tuner { class TunerService; } // namespace tuner @@ -52,6 +56,7 @@ public: * @dname start * @param store Secure store consulted for saved STA credentials. * @param tuner Tuner service exposed by the HTTP API. + * @param audio Audio service exposed by the HTTP API. * @return NetBootstrap on success, or a NetError. * @pubstate none * @@ -59,7 +64,8 @@ public: * @date 2026-07-06 */ [[nodiscard]] static std::expected - start(core::ISecureStore& store, tuner::TunerService& tuner); + start(core::ISecureStore& store, tuner::TunerService& tuner, + audio::AudioService& audio); NetBootstrap(const NetBootstrap&) = delete; NetBootstrap& operator=(const NetBootstrap&) = delete; diff --git a/Software/components/net/include/net/SetupWebServer.hpp b/Software/components/net/include/net/SetupWebServer.hpp index 0cfe632..3619b48 100644 --- a/Software/components/net/include/net/SetupWebServer.hpp +++ b/Software/components/net/include/net/SetupWebServer.hpp @@ -25,6 +25,10 @@ struct httpd_req; +namespace audio { +class AudioService; +} // namespace audio + namespace tuner { class TunerService; } // namespace tuner @@ -45,8 +49,9 @@ namespace net { * @date 2026-07-06 */ struct HttpRouteContext { - core::ISecureStore* store; ///< Secure store for Wi-Fi provisioning. - tuner::TunerService* tuner; ///< Tuner service for tuner REST routes. + core::ISecureStore* store; ///< Secure store for Wi-Fi provisioning. + tuner::TunerService* tuner; ///< Tuner service for tuner REST routes. + audio::AudioService* audio; ///< Audio service for ADAU1701 REST routes. }; /** @@ -119,21 +124,24 @@ public: * @param store Secure store for POST /api/wifi persistence. * @param netState Active network phase exposed to handlers. * @param tuner Tuner service for the tuner REST routes. + * @param audio Audio service for the audio REST routes. * @return Ok on success, or NetError::HttpServerStartFailed. - * @pubstate writes server_, store_, netState_, and tuner_ on success. + * @pubstate writes server_, store_, netState_, tuner_, and audio_ on success. * * @author Michele Bigi * @date 2026-07-06 */ [[nodiscard]] std::expected start(core::ISecureStore& store, NetState netState, - tuner::TunerService& tuner); + tuner::TunerService& tuner, + audio::AudioService& audio); private: httpd_handle* server_; core::ISecureStore* store_; NetState netState_; tuner::TunerService* tuner_; + audio::AudioService* audio_; HttpRouteContext routeContext_; }; diff --git a/Software/components/net/src/NetBootstrap.cpp b/Software/components/net/src/NetBootstrap.cpp index 03b5029..f240f35 100644 --- a/Software/components/net/src/NetBootstrap.cpp +++ b/Software/components/net/src/NetBootstrap.cpp @@ -23,6 +23,7 @@ #include "esp_netif.h" #include "esp_wifi.h" #include "nvs_flash.h" +#include "audio/AudioService.hpp" #include "tuner/TunerService.hpp" namespace net { @@ -98,7 +99,8 @@ constexpr char kTag[] = "NetBootstrap"; * @date 2026-07-06 */ [[nodiscard]] std::expected -startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner) +startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner, + audio::AudioService& audio) { esp_netif_create_default_wifi_ap(); @@ -108,7 +110,8 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner) } SetupWebServer webServer; - if (auto webResult = webServer.start(store, NetState::SoftApSetup, tuner); + if (auto webResult = + webServer.start(store, NetState::SoftApSetup, tuner, audio); !webResult) { return std::unexpected(webResult.error()); } @@ -130,7 +133,8 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner) * @date 2026-07-06 */ [[nodiscard]] std::expected -startStaMode(core::ISecureStore& store, tuner::TunerService& tuner) +startStaMode(core::ISecureStore& store, tuner::TunerService& tuner, + audio::AudioService& audio) { auto credsResult = store.loadWifiCredentials(); if (!credsResult) { @@ -146,7 +150,8 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner) } SetupWebServer webServer; - if (auto webResult = webServer.start(store, NetState::StaConnected, tuner); + if (auto webResult = + webServer.start(store, NetState::StaConnected, tuner, audio); !webResult) { return std::unexpected(webResult.error()); } @@ -159,7 +164,8 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner) } // namespace std::expected -NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner) +NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner, + audio::AudioService& audio) { if (auto platform = initPlatform(); !platform) { return std::unexpected(platform.error()); @@ -170,14 +176,14 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner) } if (store.hasWifiCredentials()) { - auto staResult = startStaMode(store, tuner); + auto staResult = startStaMode(store, tuner, audio); if (staResult) { return staResult; } ESP_LOGW(kTag, "STA join failed — falling back to setup SoftAP"); } - return startSetupMode(store, tuner); + return startSetupMode(store, tuner, audio); } NetBootstrap::NetBootstrap(std::optional softAp, diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp index 2806520..8f27df9 100644 --- a/Software/components/net/src/SetupWebServer.cpp +++ b/Software/components/net/src/SetupWebServer.cpp @@ -18,6 +18,8 @@ #include "net/SetupWebServer.hpp" +#include "core/AudioProfile.hpp" +#include "core/AudioProfileJson.hpp" #include "core/FirmwareVersion.hpp" #include "core/HealthStatus.hpp" #include "core/HealthStatusJson.hpp" @@ -26,6 +28,7 @@ #include "core/TunerJson.hpp" #include "core/WifiProvisionJson.hpp" #include "tuner/TunerService.hpp" +#include "audio/AudioService.hpp" #include "esp_http_server.h" #include "esp_log.h" @@ -40,7 +43,7 @@ namespace net { namespace { constexpr char kTag[] = "SetupWebServer"; -constexpr char kFirmwareVersion[] = "0.4.0"; +constexpr char kFirmwareVersion[] = "0.5.0"; constexpr unsigned kRebootDelaySec = 3; extern const uint8_t www_index_html_gz_start[] asm( @@ -363,6 +366,109 @@ esp_err_t tunerSeekPostHandler(httpd_req_t* req) return httpd_resp_send(req, json.c_str(), json.size()); } +/** + * @brief audioProfileGetHandler — serve GET /api/audio/profile JSON. + * + * @dname audioProfileGetHandler + * @param req HTTP request handle from esp_http_server. + * @return ESP_OK on success, or an esp_err_t error code. + * @pubstate reads route context audio service snapshot. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +esp_err_t audioProfileGetHandler(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 std::string json = + core::serializeAudioProfileJson(ctx->audio->currentProfile()); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); +} + +/** + * @brief audioProfilePutHandler — accept PUT /api/audio/profile JSON. + * + * @dname audioProfilePutHandler + * @param req HTTP request handle from esp_http_server. + * @return ESP_OK on success, or an esp_err_t error code. + * @pubstate parses profile, safeloads ADAU1701, persists to NVS. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +esp_err_t audioProfilePutHandler(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); + } + + std::array body{}; + if (!readRequestBody(req, body)) { + httpd_resp_set_status(req, "400 Bad Request"); + return httpd_resp_send(req, nullptr, 0); + } + + const auto parsed = + core::parseAudioProfileJson(std::string_view(body.data())); + if (!parsed) { + const std::string json = + core::serializeAudioErrorJson(parseErrorToken(parsed.error())); + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); + } + + if (auto applied = ctx->audio->applyProfile(*parsed, true); !applied) { + const std::string json = core::serializeAudioErrorJson("store_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::serializeAudioSavedJson(); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); +} + +/** + * @brief audioResetPostHandler — restore factory-flat profile. + * + * @dname audioResetPostHandler + * @param req HTTP request handle from esp_http_server. + * @return ESP_OK on success, or an esp_err_t error code. + * @pubstate applies AudioProfile::factoryDefault() and persists to NVS. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +esp_err_t audioResetPostHandler(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 core::AudioProfile defaults = core::AudioProfile::factoryDefault(); + if (auto applied = ctx->audio->applyProfile(defaults, true); !applied) { + const std::string json = core::serializeAudioErrorJson("store_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::serializeAudioSavedJson(); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); +} + /** * @brief indexGetHandler — serve gzipped setup page from flash. * @@ -450,7 +556,8 @@ SetupWebServer::SetupWebServer() , store_(nullptr) , netState_(NetState::Uninitialized) , tuner_(nullptr) - , routeContext_{nullptr, nullptr} + , audio_(nullptr) + , routeContext_{nullptr, nullptr, nullptr} { } @@ -459,13 +566,15 @@ SetupWebServer::SetupWebServer(SetupWebServer&& other) noexcept , store_(other.store_) , netState_(other.netState_) , tuner_(other.tuner_) + , audio_(other.audio_) , routeContext_(other.routeContext_) { other.server_ = nullptr; other.store_ = nullptr; other.netState_ = NetState::Uninitialized; other.tuner_ = nullptr; - other.routeContext_ = {nullptr, nullptr}; + other.audio_ = nullptr; + other.routeContext_ = {nullptr, nullptr, nullptr}; } SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept @@ -478,12 +587,14 @@ SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept store_ = other.store_; netState_ = other.netState_; tuner_ = other.tuner_; + audio_ = other.audio_; routeContext_ = other.routeContext_; other.server_ = nullptr; other.store_ = nullptr; other.netState_ = NetState::Uninitialized; other.tuner_ = nullptr; - other.routeContext_ = {nullptr, nullptr}; + other.audio_ = nullptr; + other.routeContext_ = {nullptr, nullptr, nullptr}; } return *this; } @@ -494,13 +605,14 @@ SetupWebServer::~SetupWebServer() httpd_stop(server_); server_ = nullptr; } - routeContext_ = {nullptr, nullptr}; + routeContext_ = {nullptr, nullptr, nullptr}; } std::expected SetupWebServer::start( core::ISecureStore& store, NetState netState, - tuner::TunerService& tuner) + tuner::TunerService& tuner, + audio::AudioService& audio) { if (server_ != nullptr) { return {}; @@ -509,8 +621,10 @@ std::expected SetupWebServer::start( store_ = &store; netState_ = netState; tuner_ = &tuner; + audio_ = &audio; routeContext_.store = &store; routeContext_.tuner = &tuner; + routeContext_.audio = &audio; httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = 80; @@ -518,7 +632,7 @@ std::expected SetupWebServer::start( if (httpd_start(&server_, &config) != ESP_OK) { ESP_LOGE(kTag, "httpd_start failed"); - routeContext_ = {nullptr, nullptr}; + routeContext_ = {nullptr, nullptr, nullptr}; return std::unexpected(NetError::HttpServerStartFailed); } @@ -588,6 +702,30 @@ std::expected SetupWebServer::start( }; httpd_register_uri_handler(server_, &tunerSeekUri); + const httpd_uri_t audioProfileGetUri = { + .uri = "/api/audio/profile", + .method = HTTP_GET, + .handler = audioProfileGetHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &audioProfileGetUri); + + const httpd_uri_t audioProfilePutUri = { + .uri = "/api/audio/profile", + .method = HTTP_PUT, + .handler = audioProfilePutHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &audioProfilePutUri); + + const httpd_uri_t audioResetUri = { + .uri = "/api/audio/reset", + .method = HTTP_POST, + .handler = audioResetPostHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &audioResetUri); + ESP_LOGI(kTag, "HTTP server listening on port 80"); return {}; } diff --git a/Software/components/net/www/index.html b/Software/components/net/www/index.html index c4f45c6..3119cf8 100644 --- a/Software/components/net/www/index.html +++ b/Software/components/net/www/index.html @@ -61,6 +61,7 @@ color: var(--text); font: inherit; } + input[type="range"] { padding: 0; } button { width: 100%; padding: var(--space-2); @@ -196,6 +197,22 @@

+ +
+

Audio

+

ADAU1701 mixer and master volume (safeload at runtime).

+ + + + + + +
+ + +
+

+
diff --git a/Software/components/net/www/index.html.gz b/Software/components/net/www/index.html.gz index 94aae6d55885efc729f95d8c57e31b12645876f1..eb4bb3de1884c4723b950fc452aba44a07508240 100644 GIT binary patch literal 3943 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 Og8u@LrCWbEH~;`<-lePn diff --git a/Software/components/secure_store/CMakeLists.txt b/Software/components/secure_store/CMakeLists.txt index bcb8ab6..86e5045 100644 --- a/Software/components/secure_store/CMakeLists.txt +++ b/Software/components/secure_store/CMakeLists.txt @@ -1,5 +1,7 @@ idf_component_register( - SRCS "src/NvsSecureStore.cpp" + SRCS + "src/NvsSecureStore.cpp" + "src/NvsAudioProfileStore.cpp" INCLUDE_DIRS "include" REQUIRES core nvs_flash ) diff --git a/Software/components/secure_store/include/secure_store/NvsAudioProfileStore.hpp b/Software/components/secure_store/include/secure_store/NvsAudioProfileStore.hpp new file mode 100644 index 0000000..c31ee97 --- /dev/null +++ b/Software/components/secure_store/include/secure_store/NvsAudioProfileStore.hpp @@ -0,0 +1,42 @@ +/** + * @file NvsAudioProfileStore.hpp + * @brief NVS-backed IAudioProfileStore for user audio settings. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/IAudioProfileStore.hpp" + +namespace secure_store { + +/** + * @brief NvsAudioProfileStore — persists AudioProfile JSON in NVS. + * + * @dname NvsAudioProfileStore + * @return n/a (type) + * @pubstate Uses namespace digiradio, key audio_profile_json. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class NvsAudioProfileStore final : public core::IAudioProfileStore { +public: + [[nodiscard]] bool hasProfile() const override; + + [[nodiscard]] std::expected saveProfile( + const core::AudioProfile& profile) override; + + [[nodiscard]] std::expected + loadProfile() const override; + + [[nodiscard]] std::expected clearProfile() override; +}; + +} // namespace secure_store diff --git a/Software/components/secure_store/src/NvsAudioProfileStore.cpp b/Software/components/secure_store/src/NvsAudioProfileStore.cpp new file mode 100644 index 0000000..33aafd1 --- /dev/null +++ b/Software/components/secure_store/src/NvsAudioProfileStore.cpp @@ -0,0 +1,111 @@ +/** + * @file NvsAudioProfileStore.cpp + * @brief NvsAudioProfileStore 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 "secure_store/NvsAudioProfileStore.hpp" + +#include "core/AudioProfileJson.hpp" + +#include "nvs.h" + +#include +#include + +namespace secure_store { + +namespace { +constexpr char kNamespace[] = "digiradio"; +constexpr char kProfileKey[] = "audio_profile_json"; +} // namespace + +bool NvsAudioProfileStore::hasProfile() const +{ + nvs_handle_t handle = 0; + if (nvs_open(kNamespace, NVS_READONLY, &handle) != ESP_OK) { + return false; + } + + std::size_t len = 0; + const esp_err_t err = nvs_get_str(handle, kProfileKey, nullptr, &len); + nvs_close(handle); + return err == ESP_OK && len > 1U; +} + +std::expected NvsAudioProfileStore::saveProfile( + const core::AudioProfile& profile) +{ + const std::string json = core::serializeAudioProfileJson(profile); + + nvs_handle_t handle = 0; + if (nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) { + return std::unexpected(core::StoreError::IoFailed); + } + + esp_err_t err = nvs_set_str(handle, kProfileKey, json.c_str()); + if (err == ESP_OK) { + err = nvs_commit(handle); + } + nvs_close(handle); + + if (err != ESP_OK) { + return std::unexpected(core::StoreError::IoFailed); + } + return {}; +} + +std::expected +NvsAudioProfileStore::loadProfile() const +{ + nvs_handle_t handle = 0; + if (nvs_open(kNamespace, NVS_READONLY, &handle) != ESP_OK) { + return std::unexpected(core::StoreError::NotFound); + } + + std::size_t len = 0; + if (nvs_get_str(handle, kProfileKey, nullptr, &len) != ESP_OK || len == 0U) { + nvs_close(handle); + return std::unexpected(core::StoreError::NotFound); + } + + std::vector buf(len); + if (nvs_get_str(handle, kProfileKey, buf.data(), &len) != ESP_OK) { + nvs_close(handle); + return std::unexpected(core::StoreError::IoFailed); + } + nvs_close(handle); + + if (auto parsed = core::parseAudioProfileJson(buf.data()); parsed) { + return *parsed; + } + return std::unexpected(core::StoreError::InvalidData); +} + +std::expected NvsAudioProfileStore::clearProfile() +{ + nvs_handle_t handle = 0; + if (nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) { + return std::unexpected(core::StoreError::IoFailed); + } + + esp_err_t err = nvs_erase_key(handle, kProfileKey); + if (err == ESP_OK || err == ESP_ERR_NVS_NOT_FOUND) { + err = nvs_commit(handle); + } + nvs_close(handle); + + if (err != ESP_OK) { + return std::unexpected(core::StoreError::IoFailed); + } + return {}; +} + +} // namespace secure_store diff --git a/Software/components/services/audio/CMakeLists.txt b/Software/components/services/audio/CMakeLists.txt new file mode 100644 index 0000000..674ce2d --- /dev/null +++ b/Software/components/services/audio/CMakeLists.txt @@ -0,0 +1,8 @@ +idf_component_register( + SRCS + "src/AudioService.cpp" + INCLUDE_DIRS "include" + REQUIRES core +) + +target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/services/audio/include/audio/AudioService.hpp b/Software/components/services/audio/include/audio/AudioService.hpp new file mode 100644 index 0000000..3074fa1 --- /dev/null +++ b/Software/components/services/audio/include/audio/AudioService.hpp @@ -0,0 +1,158 @@ +/** + * @file AudioService.hpp + * @brief Application service for ADAU1701 mixer/EQ/master configuration. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/AudioProfile.hpp" +#include "core/DspError.hpp" +#include "core/EqBandIndex.hpp" +#include "core/FrequencyHz.hpp" +#include "core/GainDb.hpp" +#include "core/IAudioProfileStore.hpp" +#include "core/IDsp.hpp" +#include "core/MixSource.hpp" +#include "core/StoreError.hpp" + +#include + +namespace audio { + +/** + * @brief AudioService — intent-level audio path control for HTTP/UI. + * + * @dname AudioService + * @return n/a (type) + * @pubstate Borrows core::IDsp; optionally persists via IAudioProfileStore. + * Tracks the last applied profile in RAM. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class AudioService { +public: + /** + * @brief AudioService — bind DSP and optional profile store. + * + * @dname AudioService + * @param dsp ADAU1701 adapter (must outlive this service). + * @param store Optional NVS store; nullptr skips persistence. + * @pubstate initialises profile_ to factoryDefault(). + * + * @author Michele Bigi + * @date 2026-07-06 + */ + AudioService(core::IDsp& dsp, core::IAudioProfileStore* store); + + /** + * @brief loadAndApply — restore saved profile or factory default. + * + * @dname loadAndApply + * @return Ok on success, or DspError from IDsp. + * @pubstate updates profile_ and safeloads the ADAU1701. + * + * Call once after ADAU1701 boot. When no profile is stored, applies + * AudioProfile::factoryDefault() without writing NVS. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected loadAndApply(); + + /** + * @brief currentProfile — read the in-memory profile snapshot. + * + * @dname currentProfile + * @return Last applied or pending profile. + * @pubstate reads profile_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] const core::AudioProfile& currentProfile() const noexcept; + + /** + * @brief applyProfile — safeload a full profile and optionally save. + * + * @dname applyProfile + * @param profile Validated user configuration. + * @param persist When true and store is set, write NVS. + * @return Ok on success, DspError, or StoreError::IoFailed. + * @pubstate updates profile_; may persist via store_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected applyProfile( + const core::AudioProfile& profile, bool persist); + + /** + * @brief setInputVolume — update one input path and apply live. + * + * @dname setInputVolume + * @param source Si4684 or ESP32 path. + * @param left Left gain. + * @param right Right gain. + * @param persist When true and store is set, write NVS. + * @return Ok on success, or StoreError wrapping DspError/IoFailed. + * @pubstate updates profile_.mixer and safeloads. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected setInputVolume( + core::MixSource source, core::GainDb left, core::GainDb right, + bool persist); + + /** + * @brief setMasterVolume — update master output and apply live. + * + * @dname setMasterVolume + * @param left Left master gain. + * @param right Right master gain. + * @param persist When true and store is set, write NVS. + * @return Ok on success, or StoreError. + * @pubstate updates profile_ master fields and safeloads. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected setMasterVolume( + core::GainDb left, core::GainDb right, bool persist); + + /** + * @brief setEqBand — update one PEQ band and apply live. + * + * @dname setEqBand + * @param band Band index 0..5. + * @param gain Band gain in dB. + * @param center Centre frequency. + * @param q Quality factor. + * @param persist When true and store is set, write NVS. + * @return Ok on success, or StoreError. + * @pubstate updates profile_.eq and safeloads. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected setEqBand( + core::EqBandIndex band, core::GainDb gain, core::FrequencyHz center, + float q, bool persist); + +private: + [[nodiscard]] std::expected persistProfile() const; + + core::IDsp& dsp_; + core::IAudioProfileStore* store_; + core::AudioProfile profile_; +}; + +} // namespace audio diff --git a/Software/components/services/audio/src/AudioService.cpp b/Software/components/services/audio/src/AudioService.cpp new file mode 100644 index 0000000..89508d6 --- /dev/null +++ b/Software/components/services/audio/src/AudioService.cpp @@ -0,0 +1,118 @@ +/** + * @file AudioService.cpp + * @brief AudioService 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 "audio/AudioService.hpp" + +namespace audio { + +AudioService::AudioService(core::IDsp& dsp, core::IAudioProfileStore* store) + : dsp_(dsp) + , store_(store) + , profile_(core::AudioProfile::factoryDefault()) +{ +} + +std::expected AudioService::loadAndApply() +{ + if (store_ != nullptr && store_->hasProfile()) { + if (auto loaded = store_->loadProfile(); loaded) { + profile_ = *loaded; + } + } + + if (auto applied = dsp_.applyProfile(profile_); !applied) { + return applied; + } + return {}; +} + +const core::AudioProfile& AudioService::currentProfile() const noexcept +{ + return profile_; +} + +std::expected AudioService::persistProfile() const +{ + if (store_ == nullptr) { + return {}; + } + return store_->saveProfile(profile_); +} + +std::expected AudioService::applyProfile( + const core::AudioProfile& profile, bool persist) +{ + if (auto applied = dsp_.applyProfile(profile); !applied) { + return std::unexpected(core::StoreError::IoFailed); + } + profile_ = profile; + if (persist) { + return persistProfile(); + } + return {}; +} + +std::expected AudioService::setInputVolume( + core::MixSource source, core::GainDb left, core::GainDb right, bool persist) +{ + if (auto applied = dsp_.setInputVolume(source, left, right); !applied) { + return std::unexpected(core::StoreError::IoFailed); + } + + if (source == core::MixSource::Si4684) { + profile_.mixer.si4684Left = left; + profile_.mixer.si4684Right = right; + } else { + profile_.mixer.esp32Left = left; + profile_.mixer.esp32Right = right; + } + + if (persist) { + return persistProfile(); + } + return {}; +} + +std::expected AudioService::setMasterVolume( + core::GainDb left, core::GainDb right, bool persist) +{ + if (auto applied = dsp_.setMasterVolume(left, right); !applied) { + return std::unexpected(core::StoreError::IoFailed); + } + profile_.masterLeft = left; + profile_.masterRight = right; + if (persist) { + return persistProfile(); + } + return {}; +} + +std::expected AudioService::setEqBand( + core::EqBandIndex band, core::GainDb gain, core::FrequencyHz center, float q, + bool persist) +{ + if (auto applied = dsp_.setEqBand(band, gain, center, q); !applied) { + return std::unexpected(core::StoreError::IoFailed); + } + profile_.eq.setBand(band, core::EqBandSettings{ + .gain = gain, + .center = center, + .q = q, + }); + if (persist) { + return persistProfile(); + } + return {}; +} + +} // namespace audio diff --git a/Software/docs/manual/ch-api.tex b/Software/docs/manual/ch-api.tex index ced397a..c7b4fd8 100644 --- a/Software/docs/manual/ch-api.tex +++ b/Software/docs/manual/ch-api.tex @@ -6,7 +6,7 @@ implemented in \texttt{SetupWebServer}. Request bodies are parsed into domain types in the pure core (\texttt{components/core}) before any persistence or driver call. Exact C++ signatures live in the generated Doxygen output under \texttt{docs/api/}; this chapter documents the -wire protocol and behaviour as shipped in firmware~0.4.0 (Slices~1--4). +wire protocol and behaviour as shipped in firmware~0.5.0 (Slices~1--5). \section{Transport and reachability} @@ -36,7 +36,7 @@ Returns a health-check DTO serialised by \begin{drnote}[Response schema] \begin{drcode}[JSON] -{"status":"ok","fw":"0.4.0"} +{"status":"ok","fw":"0.5.0"} \end{drcode} \begin{itemize} \item \texttt{status} --- coarse indicator; \texttt{ok} when the @@ -172,10 +172,55 @@ Seeks FM upward (no request body). Returns \texttt{\{"frequency\_khz":...\}} on success. HTTP status: \textbf{200 OK}; \textbf{409} on seek failure. +\subsection{\texttt{GET /api/audio/profile}} +\label{sec:api-audio-profile-get} + +Returns the current ADAU1701 audio snapshot serialised by +\texttt{core::serializeAudioProfileJson()} from \texttt{core::AudioProfile}. +The handler reads \texttt{audio::AudioService::currentProfile()}. + +\begin{drnote}[Response schema (excerpt)] +\begin{drcode}[JSON] +{"mixer":{"si4684_left_db":0,"si4684_right_db":0,"esp32_left_db":0, + "esp32_right_db":0,"mix_left_db":0,"mix_right_db":0}, + "master":{"left_db":0,"right_db":0}, + "eq":[{"gain_db":0,"center_hz":40,"q":1.414}, ...]} +\end{drcode} +Six EQ bands are always present (\texttt{eq} array length~6). +\end{drnote} + +HTTP status: \textbf{200 OK}; \textbf{503} when the audio service is +unavailable. + +\subsection{\texttt{PUT /api/audio/profile}} +\label{sec:api-audio-profile-put} + +Applies a full audio profile. Body parsed by +\texttt{core::parseAudioProfileJson()}; on success +\texttt{audio::AudioService::applyProfile(..., persist=true)} safeloads +the ADAU1701 and writes NVS key \texttt{audio\_profile\_json}. + +\begin{drnote}[Success response] +\begin{drcode}[JSON] +{"status":"saved"} +\end{drcode} +\end{drnote} + +HTTP status: \textbf{200 OK}; \textbf{400} for parse/validation failures; +\textbf{500} when safeload or NVS persistence fails. + +\subsection{\texttt{POST /api/audio/reset}} +\label{sec:api-audio-reset} + +Restores \texttt{AudioProfile::factoryDefault()} (flat EQ, 0\,dB gains), +applies it to the DSP, and persists to NVS. Success response: +\texttt{\{"status":"saved"\}}. HTTP status: \textbf{200 OK}; \textbf{500} +on apply/persist failure. + \section{Boot and network state machine} \label{sec:api-boot-flow} -At boot, \texttt{net::NetBootstrap::start(store, tuner)} consults +At boot, \texttt{net::NetBootstrap::start(store, tuner, audio)} consults \texttt{ISecureStore::hasWifiCredentials()}: \begin{enumerate} @@ -193,7 +238,9 @@ This explicit \texttt{enum class NetState} replaces ad-hoc flags; see \label{sec:api-storage} Wi-Fi credentials are stored in NVS namespace \texttt{digiradio}, keys -\texttt{wifi\_ssid} and \texttt{wifi\_pwd}. Passwords are wrapped in +\texttt{wifi\_ssid} and \texttt{wifi\_pwd}. Audio profiles (non-secret) use +the same namespace, key \texttt{audio\_profile\_json}, via +\texttt{secure\_store::NvsAudioProfileStore}. Passwords are wrapped in \texttt{core::Secret} in RAM and are never logged or returned by the API. \begin{drcaution}[Encryption at rest] diff --git a/Software/docs/manual/ch-classes.tex b/Software/docs/manual/ch-classes.tex index ae82a99..ab23ea7 100644 --- a/Software/docs/manual/ch-classes.tex +++ b/Software/docs/manual/ch-classes.tex @@ -56,16 +56,17 @@ configured SoftAP. Imperative shell; no business logic. \section{SetupWebServer}\label{cls:SetupWebServer} Minimal HTTP server: gzipped setup UI, \texttt{GET /api/health}, -\texttt{POST /api/wifi}, and tuner routes (\texttt{/api/tuner/*}). -JSON parsing and serialisation delegate to the pure core; credentials -persist via \texttt{ISecureStore}; tuner operations via -\texttt{tuner::TunerService}. +\texttt{POST /api/wifi}, tuner routes (\texttt{/api/tuner/*}), and audio +routes (\texttt{/api/audio/*}). JSON parsing and serialisation delegate to +the pure core; credentials persist via \texttt{ISecureStore}; tuner via +\texttt{tuner::TunerService}; audio via \texttt{audio::AudioService}. \section{NetBootstrap}\label{cls:NetBootstrap} Owns network resources for setup or STA mode. \texttt{start(store, tuner)} initialises the platform, joins stored Wi-Fi when credentials exist, or falls back to the \texttt{DigiRadio-setup} SoftAP. Must outlive \texttt{app\_main} for the process lifetime. +\texttt{start(store, tuner, audio)} also wires the audio REST routes. % ------------------------------------------------------------------ % Domain core + secure store (Slice 2) @@ -135,10 +136,47 @@ DTOs. RAII I2C driver for the ADAU1701 SigmaDSP. \texttt{boot()} asserts RESET\#, initialises the shared I2C bus, and replays the SigmaStudio export from \texttt{Firmware/ADAU1701-Firmware/} on every power-up (no EEPROM self-boot -on DigiRadio). +on DigiRadio). Runtime mixer, EQ, and master volume updates use the +ADAU1701 safeload mechanism via \texttt{applyProfile()} and related methods. + +\section{Adau1701Dsp}\label{cls:Adau1701Dsp} +\texttt{core::IDsp} adapter over \texttt{Adau1701Driver}. Maps domain-level +audio control to safeload I2C transactions without exposing parameter RAM +addresses to services. % ------------------------------------------------------------------ -% Application services (Slice 4) +% Domain core — audio (Slice 5) +% ------------------------------------------------------------------ + +\section{GainDb}\label{cls:GainDb} +Validated decibel gain/attenuation ($-96$\,dB to $+12$\,dB). Used for +input volumes, master level, and EQ band gain. Converted to ADAU 8.23 +fixpoint via \texttt{gainDbToLinearFixpoint()} before safeload. + +\section{FrequencyHz}\label{cls:FrequencyHz} +Validated PEQ centre frequency (20--20\,000\,Hz) for the 48\,kHz +SigmaStudio project. Parsed at the HTTP boundary before coefficient design. + +\section{EqBandIndex}\label{cls:EqBandIndex} +Strong index into the six-band \texttt{Param EQ1} module (0--5). Maps to +parameter RAM via \texttt{adau1701::paramAddrEqBandBase()}. + +\section{EqProfile}\label{cls:EqProfile} +Six-band parametric EQ snapshot with default centre frequencies +(40\,Hz--12\,kHz). Designed in the pure core; coefficients safeloaded by +\texttt{Adau1701Driver::applyEq()}. + +\section{IDsp}\label{cls:IDsp} +Abstract ADAU1701 control boundary. Defines \texttt{applyProfile()}, +\texttt{setInputVolume()}, \texttt{setEqBand()}, and related intent-level +operations without ESP-IDF types. + +\section{IAudioProfileStore}\label{cls:IAudioProfileStore} +Persistence boundary for \texttt{AudioProfile} (mixer, EQ, master). Device +implementation: \texttt{NvsAudioProfileStore}; host tests use fakes. + +% ------------------------------------------------------------------ +% Application services (Slice 4–5) % ------------------------------------------------------------------ \section{ITuner}\label{cls:ITuner} @@ -156,3 +194,13 @@ target and volume, and maps driver failures to \texttt{core::TunerError}. \texttt{ITuner} adapter over \texttt{Si4684Driver}. Translates domain calls into SPI commands and maps \texttt{Si4684Error} to \texttt{TunerError}. Constructed once in \texttt{HardwareBootstrap} alongside the driver. + +\section{AudioService}\label{cls:AudioService} +Application service for ADAU1701 mixer, EQ, and master volume. Holds a +reference to \texttt{core::IDsp}, tracks the in-memory \texttt{AudioProfile}, +loads from \texttt{IAudioProfileStore} after boot, and applies changes via +safeload. Exposed on \texttt{/api/audio/*} and the web UI Audio section. + +\section{NvsAudioProfileStore}\label{cls:NvsAudioProfileStore} +\texttt{IAudioProfileStore} implementation storing serialised +\texttt{AudioProfile} JSON in NVS namespace \texttt{digiradio}. diff --git a/Software/docs/manual/ch-firmware.tex b/Software/docs/manual/ch-firmware.tex index b27277e..ac43d3f 100644 --- a/Software/docs/manual/ch-firmware.tex +++ b/Software/docs/manual/ch-firmware.tex @@ -194,6 +194,9 @@ bring-up: (\texttt{DigiRadio\_IC\_1.h}) replayed through \texttt{SIGMA\_WRITE\_REGISTER\_BLOCK} after hardware reset. The DSP program lives in RAM only; download runs on every boot. + \item \textbf{Audio profile}: \texttt{audio::AudioService::loadAndApply()} + restores the saved \texttt{core::AudioProfile} from NVS (or factory + defaults) via ADAU1701 safeload before network bring-up. \end{enumerate} If either driver returns an error, the firmware logs the failure and stops diff --git a/Software/instructions.md b/Software/instructions.md index fe228c8..7725320 100644 --- a/Software/instructions.md +++ b/Software/instructions.md @@ -49,7 +49,7 @@ Repository: https://github.com/manvalan/DigiRadio 3. **Companion-chip boot** — done (Slice 3): Si4684 DAB + ADAU1701 RAM load. 4. Station/frequency list model + persistence + UI. 5. Si4684 tuning: RSQ, station list, DAB properties. -6. ADAU1701 runtime: safeload EQ + input mixer. +6. **ADAU1701 runtime** — done (Slice 5): safeload EQ + input mixer + HTTP. 7. FSC-BT1035 driver: AT init (incl. `AT+AUXCFG=1`), audio out. 8. Integration: TunerService + AudioService end to end. @@ -119,3 +119,23 @@ Acceptance criteria: - [x] ADAU1701 reset + I2C + `default_download_IC_1()` replay. - [x] Host test for `EmbeddedBlobReader`; manual sync green. - [ ] Device flash verified (requires ESP-IDF toolchain on build host). + +## Slice 5 — ADAU1701 runtime + audio API (complete) + +Goal: safeload mixer/EQ/master on the ADAU1701 at runtime; persist user +profiles in NVS; expose REST and web UI controls. + +Build on Slice 3–4: +- Pure core: `GainDb`, `EqProfile`, `AudioProfile`, `IDsp`, biquad design, + `parseAudioProfileJson` / `serializeAudioProfileJson`. +- Driver: `sigma_safeload_*`, extended `Adau1701Driver`, `Adau1701Dsp`. +- Service: `audio::AudioService`, `secure_store::NvsAudioProfileStore`. +- HTTP: `GET/PUT /api/audio/profile`, `POST /api/audio/reset`; Audio + section in the web UI. Firmware **0.5.0**. + +Acceptance criteria: +- [x] Safeload volume/mixer/EQ without direct param RAM writes during audio. +- [x] Profile load/apply after ADAU boot; NVS round-trip via JSON. +- [x] Host tests for biquad fixpoint and audio profile JSON. +- [x] Doxygen green; manual sync green; `ch-api.tex` documents audio routes. +- [ ] Device flash verified on hardware. diff --git a/Software/main/CMakeLists.txt b/Software/main/CMakeLists.txt index 99f84ef..efa14df 100644 --- a/Software/main/CMakeLists.txt +++ b/Software/main/CMakeLists.txt @@ -3,5 +3,5 @@ idf_component_register( "main.cpp" "hardware_bootstrap.cpp" INCLUDE_DIRS "." - REQUIRES core net secure_store adau1701 si4684 tuner + REQUIRES core net secure_store adau1701 si4684 tuner audio ) diff --git a/Software/main/hardware_bootstrap.cpp b/Software/main/hardware_bootstrap.cpp index e24b056..5cd5b7b 100644 --- a/Software/main/hardware_bootstrap.cpp +++ b/Software/main/hardware_bootstrap.cpp @@ -14,7 +14,10 @@ #include "hardware_bootstrap.hpp" #include "adau1701/Adau1701Driver.hpp" +#include "adau1701/Adau1701Dsp.hpp" +#include "audio/AudioService.hpp" #include "board_pins.hpp" +#include "secure_store/NvsAudioProfileStore.hpp" #include "si4684/Si4684Band.hpp" #include "si4684/Si4684Driver.hpp" #include "si4684/Si4684EmbeddedImages.hpp" @@ -51,6 +54,9 @@ adau1701::Adau1701Driver gAdau1701( .resetGpio = board::pins::Adau1701Reset, .i2cAddr7 = board::pins::Adau1701Addr, }); +adau1701::Adau1701Dsp gAdau1701Dsp(gAdau1701); +secure_store::NvsAudioProfileStore gAudioStore; +audio::AudioService gAudioService(gAdau1701Dsp, &gAudioStore); bool gReady = false; } // namespace @@ -74,6 +80,10 @@ std::expected HardwareBootstrap::boot() } } + if (auto audioResult = gAudioService.loadAndApply(); !audioResult) { + ESP_LOGW(kTag, "ADAU1701 profile apply failed"); + } + gReady = true; ESP_LOGI(kTag, "companion chips ready"); return {}; @@ -84,4 +94,9 @@ si4684::Si4684Tuner& HardwareBootstrap::si4684Tuner() return gSi4684Tuner; } +audio::AudioService& HardwareBootstrap::audioService() +{ + return gAudioService; +} + } // namespace hardware diff --git a/Software/main/hardware_bootstrap.hpp b/Software/main/hardware_bootstrap.hpp index dfcd099..720051d 100644 --- a/Software/main/hardware_bootstrap.hpp +++ b/Software/main/hardware_bootstrap.hpp @@ -14,6 +14,10 @@ #include +namespace audio { +class AudioService; +} // namespace audio + namespace si4684 { class Si4684Tuner; } // namespace si4684 @@ -75,6 +79,20 @@ public: * @date 2026-07-06 */ [[nodiscard]] static si4684::Si4684Tuner& si4684Tuner(); + + /** + * @brief audioService — borrow the audio orchestration service after boot. + * + * @dname audioService + * @return Reference to the static AudioService instance. + * @pubstate reads static storage initialised by boot(). + * + * Valid only after a successful boot() call in the same process. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static audio::AudioService& audioService(); }; } // namespace hardware diff --git a/Software/main/main.cpp b/Software/main/main.cpp index 0508c9a..c285eb9 100644 --- a/Software/main/main.cpp +++ b/Software/main/main.cpp @@ -47,7 +47,7 @@ void heartbeatTask(void* arg) */ extern "C" void app_main() { - ESP_LOGI(kTag, "DigiRadio firmware boot — Slice 4"); + ESP_LOGI(kTag, "DigiRadio firmware boot — Slice 5"); auto hwResult = hardware::HardwareBootstrap::boot(); if (!hwResult) { @@ -60,7 +60,8 @@ extern "C" void app_main() static secure_store::NvsSecureStore store; - auto netResult = net::NetBootstrap::start(store, tunerService); + auto netResult = net::NetBootstrap::start( + store, tunerService, hardware::HardwareBootstrap::audioService()); if (!netResult) { ESP_LOGE(kTag, "network bootstrap failed"); return;