From fa532e068f3f48cd7a8749eafdd99462159beaf7 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Mon, 6 Jul 2026 17:00:12 +0200 Subject: [PATCH] Add stereo and bass enhance APIs via PEQ overlay. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose POST /api/audio/stereo-enhance and bass-enhance with 0–100 levels, persist enhancements in AudioProfile, and document the virtual EQ mapping in the manual and SigmaStudio chapter. Co-authored-by: Cursor --- Software/components/core/CMakeLists.txt | 3 + .../core/include/core/AudioEnhancements.hpp | 47 +++++++ .../core/include/core/AudioProfile.hpp | 6 +- .../core/include/core/AudioProfileJson.hpp | 16 +++ .../core/include/core/EnhanceLevel.hpp | 93 +++++++++++++ .../core/include/core/EnhancementsDesign.hpp | 39 ++++++ .../components/core/src/AudioEnhancements.cpp | 26 ++++ Software/components/core/src/AudioProfile.cpp | 1 + .../components/core/src/AudioProfileJson.cpp | 55 +++++++- Software/components/core/src/EnhanceLevel.cpp | 47 +++++++ .../core/src/EnhancementsDesign.cpp | 70 ++++++++++ Software/components/core/test/CMakeLists.txt | 7 + .../core/test/audio_profile_json_test.cpp | 17 +++ .../core/test/enhancements_design_test.cpp | 124 ++++++++++++++++++ .../components/net/src/SetupWebServer.cpp | 87 +++++++++++- Software/components/net/www/index.html | 44 +++++++ Software/components/net/www/index.html.gz | Bin 3943 -> 4194 bytes .../audio/include/audio/AudioService.hpp | 38 ++++++ .../services/audio/src/AudioService.cpp | 67 ++++++++-- Software/docs/manual/ch-api.tex | 30 ++++- Software/docs/manual/ch-classes.tex | 8 +- Software/docs/manual/ch-sigmastudio.tex | 20 +++ 22 files changed, 826 insertions(+), 19 deletions(-) create mode 100644 Software/components/core/include/core/AudioEnhancements.hpp create mode 100644 Software/components/core/include/core/EnhanceLevel.hpp create mode 100644 Software/components/core/include/core/EnhancementsDesign.hpp create mode 100644 Software/components/core/src/AudioEnhancements.cpp create mode 100644 Software/components/core/src/EnhanceLevel.cpp create mode 100644 Software/components/core/src/EnhancementsDesign.cpp create mode 100644 Software/components/core/test/enhancements_design_test.cpp diff --git a/Software/components/core/CMakeLists.txt b/Software/components/core/CMakeLists.txt index d80ca82..1794cec 100644 --- a/Software/components/core/CMakeLists.txt +++ b/Software/components/core/CMakeLists.txt @@ -17,6 +17,9 @@ idf_component_register( "src/BiquadDesign.cpp" "src/MixerState.cpp" "src/EqProfile.cpp" + "src/EnhanceLevel.cpp" + "src/AudioEnhancements.cpp" + "src/EnhancementsDesign.cpp" "src/AudioProfile.cpp" "src/AudioProfileJson.cpp" INCLUDE_DIRS "include" diff --git a/Software/components/core/include/core/AudioEnhancements.hpp b/Software/components/core/include/core/AudioEnhancements.hpp new file mode 100644 index 0000000..8900773 --- /dev/null +++ b/Software/components/core/include/core/AudioEnhancements.hpp @@ -0,0 +1,47 @@ +/** + * @file AudioEnhancements.hpp + * @brief Stereo width and bass boost enhancement levels. + * + * 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/EnhanceLevel.hpp" + +namespace core { + +/** + * @brief AudioEnhancements — psychoacoustic EQ overlays (0–100 each). + * + * @dname AudioEnhancements + * @return n/a (type) + * @pubstate Mapped to PEQ bands at runtime (Chapter~\ref{ch:sigmastudio}). + * No dedicated SigmaStudio blocks; see \texttt{applyEnhancementsToEq}. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +struct AudioEnhancements { + EnhanceLevel stereo; ///< Stereo depth / presence (PEQ bands 3–5). + EnhanceLevel bass; ///< Bass emphasis (PEQ bands 1–2). + + /** + * @brief factoryDefault — both enhancements off. + * + * @dname factoryDefault + * @return AudioEnhancements at level 0. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static AudioEnhancements factoryDefault() noexcept; +}; + +} // namespace core diff --git a/Software/components/core/include/core/AudioProfile.hpp b/Software/components/core/include/core/AudioProfile.hpp index 11137a6..1260b2f 100644 --- a/Software/components/core/include/core/AudioProfile.hpp +++ b/Software/components/core/include/core/AudioProfile.hpp @@ -12,6 +12,7 @@ */ #pragma once +#include "core/AudioEnhancements.hpp" #include "core/EqProfile.hpp" #include "core/GainDb.hpp" #include "core/MixerState.hpp" @@ -32,8 +33,9 @@ namespace core { 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. + GainDb masterLeft; ///< Multiple 1 master volume, left. + GainDb masterRight; ///< Multiple 1 master volume, right. + AudioEnhancements enhancements; ///< Stereo depth and bass boost (PEQ overlay). /** * @brief factoryDefault — factory-flat audio path (0 dB everywhere). diff --git a/Software/components/core/include/core/AudioProfileJson.hpp b/Software/components/core/include/core/AudioProfileJson.hpp index 5ff776f..a45a901 100644 --- a/Software/components/core/include/core/AudioProfileJson.hpp +++ b/Software/components/core/include/core/AudioProfileJson.hpp @@ -15,6 +15,8 @@ #include "core/AudioProfile.hpp" #include "core/ParseError.hpp" +#include "core/EnhanceLevel.hpp" + #include #include #include @@ -74,4 +76,18 @@ namespace core { */ [[nodiscard]] std::string serializeAudioErrorJson(std::string_view reason); +/** + * @brief parseEnhanceLevelJson — parse POST body \texttt{\{"level":0..100\}}. + * + * @dname parseEnhanceLevelJson + * @param json Untrusted request body. + * @return EnhanceLevel on success, or ParseError. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::expected parseEnhanceLevelJson( + std::string_view json); + } // namespace core diff --git a/Software/components/core/include/core/EnhanceLevel.hpp b/Software/components/core/include/core/EnhanceLevel.hpp new file mode 100644 index 0000000..42535e6 --- /dev/null +++ b/Software/components/core/include/core/EnhanceLevel.hpp @@ -0,0 +1,93 @@ +/** + * @file EnhanceLevel.hpp + * @brief Strong type for 0–100 enhancement intensity (stereo / bass). + * + * 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 EnhanceLevel — validated enhancement intensity (0 = off, 100 = max). + * + * @dname EnhanceLevel + * @return n/a (type) + * @pubstate Owns level_ in 0..100 after construction. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class EnhanceLevel { +public: + /** Maximum enhancement intensity. */ + static constexpr std::uint8_t kMax = 100U; + + /** + * @brief tryFromLevel — validate an enhancement level at the boundary. + * + * @dname tryFromLevel + * @param level Untrusted 0..100 value from JSON. + * @return EnhanceLevel on success, or ParseError::MissingField. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static std::expected tryFromLevel( + std::uint32_t level) noexcept; + + /** + * @brief zero — enhancement disabled. + * + * @dname zero + * @return EnhanceLevel at 0. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static EnhanceLevel zero() noexcept; + + /** + * @brief value — read the stored level. + * + * @dname value + * @return Level 0..100. + * @pubstate reads level_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::uint8_t value() const noexcept; + + /** + * @brief fraction — normalised intensity in 0.0..1.0. + * + * @dname fraction + * @return level / 100 as float. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] float fraction() const noexcept; + +private: + explicit EnhanceLevel(std::uint8_t level) noexcept; + + std::uint8_t level_; +}; + +} // namespace core diff --git a/Software/components/core/include/core/EnhancementsDesign.hpp b/Software/components/core/include/core/EnhancementsDesign.hpp new file mode 100644 index 0000000..ec8f1d8 --- /dev/null +++ b/Software/components/core/include/core/EnhancementsDesign.hpp @@ -0,0 +1,39 @@ +/** + * @file EnhancementsDesign.hpp + * @brief Map enhancement levels onto Param EQ1 bands (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/AudioEnhancements.hpp" +#include "core/EqProfile.hpp" + +namespace core { + +/** + * @brief applyEnhancementsToEq — merge enhancement overlays into an EQ profile. + * + * @dname applyEnhancementsToEq + * @param base User EQ settings (bands not touched stay unchanged). + * @param enhancements Stereo and bass levels (0 = use base band only). + * @return EqProfile with affected PEQ bands updated for safeload. + * @pubstate none + * + * Stereo (bands 3–5): slight 1\,kHz cut plus 3/8\,kHz lift for depth. + * Bass (bands 1–2): 100\,Hz and 400\,Hz peaking boost. + * Band 0 (high-pass) is never modified. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] EqProfile applyEnhancementsToEq( + const EqProfile& base, const AudioEnhancements& enhancements) noexcept; + +} // namespace core diff --git a/Software/components/core/src/AudioEnhancements.cpp b/Software/components/core/src/AudioEnhancements.cpp new file mode 100644 index 0000000..a9bfb2e --- /dev/null +++ b/Software/components/core/src/AudioEnhancements.cpp @@ -0,0 +1,26 @@ +/** + * @file AudioEnhancements.cpp + * @brief AudioEnhancements 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/AudioEnhancements.hpp" + +namespace core { + +AudioEnhancements AudioEnhancements::factoryDefault() noexcept +{ + return AudioEnhancements{ + .stereo = EnhanceLevel::zero(), + .bass = EnhanceLevel::zero(), + }; +} + +} // namespace core diff --git a/Software/components/core/src/AudioProfile.cpp b/Software/components/core/src/AudioProfile.cpp index 8a28508..2407d1e 100644 --- a/Software/components/core/src/AudioProfile.cpp +++ b/Software/components/core/src/AudioProfile.cpp @@ -23,6 +23,7 @@ AudioProfile AudioProfile::factoryDefault() noexcept .eq = EqProfile::factoryDefault(), .masterLeft = unity, .masterRight = unity, + .enhancements = AudioEnhancements::factoryDefault(), }; } diff --git a/Software/components/core/src/AudioProfileJson.cpp b/Software/components/core/src/AudioProfileJson.cpp index c86fb62..e9de512 100644 --- a/Software/components/core/src/AudioProfileJson.cpp +++ b/Software/components/core/src/AudioProfileJson.cpp @@ -13,6 +13,8 @@ #include "core/AudioProfileJson.hpp" +#include "core/EnhanceLevel.hpp" + #include #include @@ -144,6 +146,34 @@ namespace { return profile; } +[[nodiscard]] std::expected parseEnhancementsJson( + std::string_view json) +{ + const std::size_t enhStart = json.find("\"enhancements\""); + if (enhStart == std::string_view::npos) { + return AudioEnhancements::factoryDefault(); + } + + const std::string_view enh = json.substr(enhStart); + unsigned long stereoLevel = 0U; + unsigned long bassLevel = 0U; + if (!extractJsonUint(enh, "stereo_level", stereoLevel) + || !extractJsonUint(enh, "bass_level", bassLevel)) { + return std::unexpected(ParseError::MissingField); + } + + const auto stereo = EnhanceLevel::tryFromLevel(stereoLevel); + const auto bass = EnhanceLevel::tryFromLevel(bassLevel); + if (!stereo || !bass) { + return std::unexpected(ParseError::MissingField); + } + + return AudioEnhancements{ + .stereo = *stereo, + .bass = *bass, + }; +} + } // namespace std::string serializeAudioProfileJson(const AudioProfile& profile) @@ -172,7 +202,10 @@ std::string serializeAudioProfileJson(const AudioProfile& profile) << ",\"center_hz\":" << b.center.value() << ",\"q\":" << b.q << '}'; } - out << "]}"; + out << "],\"enhancements\":{" + << "\"stereo_level\":" << static_cast(profile.enhancements.stereo.value()) + << ",\"bass_level\":" << static_cast(profile.enhancements.bass.value()) + << "}}"; return out.str(); } @@ -204,11 +237,17 @@ std::expected parseAudioProfileJson( return std::unexpected(ParseError::MissingField); } + const auto enhancements = parseEnhancementsJson(json); + if (!enhancements) { + return std::unexpected(enhancements.error()); + } + return AudioProfile{ .mixer = *mixer, .eq = *eq, .masterLeft = *masterLeft, .masterRight = *masterRight, + .enhancements = *enhancements, }; } @@ -224,4 +263,18 @@ std::string serializeAudioErrorJson(std::string_view reason) return out.str(); } +std::expected parseEnhanceLevelJson( + std::string_view json) +{ + if (json.find('{') == std::string_view::npos) { + return std::unexpected(ParseError::InvalidJson); + } + + unsigned long level = 0U; + if (!extractJsonUint(json, "level", level)) { + return std::unexpected(ParseError::MissingField); + } + return EnhanceLevel::tryFromLevel(level); +} + } // namespace core diff --git a/Software/components/core/src/EnhanceLevel.cpp b/Software/components/core/src/EnhanceLevel.cpp new file mode 100644 index 0000000..1092307 --- /dev/null +++ b/Software/components/core/src/EnhanceLevel.cpp @@ -0,0 +1,47 @@ +/** + * @file EnhanceLevel.cpp + * @brief EnhanceLevel 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/EnhanceLevel.hpp" + +namespace core { + +EnhanceLevel::EnhanceLevel(std::uint8_t level) noexcept + : level_(level) +{ +} + +std::expected EnhanceLevel::tryFromLevel( + std::uint32_t level) noexcept +{ + if (level > kMax) { + return std::unexpected(ParseError::MissingField); + } + return EnhanceLevel(static_cast(level)); +} + +EnhanceLevel EnhanceLevel::zero() noexcept +{ + return EnhanceLevel(0U); +} + +std::uint8_t EnhanceLevel::value() const noexcept +{ + return level_; +} + +float EnhanceLevel::fraction() const noexcept +{ + return static_cast(level_) / static_cast(kMax); +} + +} // namespace core diff --git a/Software/components/core/src/EnhancementsDesign.cpp b/Software/components/core/src/EnhancementsDesign.cpp new file mode 100644 index 0000000..a3f37dc --- /dev/null +++ b/Software/components/core/src/EnhancementsDesign.cpp @@ -0,0 +1,70 @@ +/** + * @file EnhancementsDesign.cpp + * @brief Enhancement-to-EQ mapping 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/EnhancementsDesign.hpp" + +#include "core/EqBandIndex.hpp" +#include "core/FrequencyHz.hpp" +#include "core/GainDb.hpp" + +namespace core { + +namespace { + +void setBand(EqProfile& profile, std::uint8_t index, GainDb gain, + FrequencyHz center, float q) noexcept +{ + const auto band = EqBandIndex::tryFromIndex(index); + if (!band) { + return; + } + profile.setBand(*band, EqBandSettings{ + .gain = gain, + .center = center, + .q = q, + }); +} + +[[nodiscard]] GainDb gainFromDb(float db) noexcept +{ + return *GainDb::tryFromDb(db); +} + +} // namespace + +EqProfile applyEnhancementsToEq(const EqProfile& base, + const AudioEnhancements& enhancements) noexcept +{ + EqProfile profile = base; + + if (enhancements.bass.value() > 0U) { + const float t = enhancements.bass.fraction(); + setBand(profile, 1U, gainFromDb(9.0F * t), + *FrequencyHz::tryFromHz(100U), 0.9F); + setBand(profile, 2U, gainFromDb(3.0F * t), + *FrequencyHz::tryFromHz(400U), 1.0F); + } + + if (enhancements.stereo.value() > 0U) { + const float t = enhancements.stereo.fraction(); + setBand(profile, 3U, gainFromDb(-1.5F * t), + *FrequencyHz::tryFromHz(1000U), 1.0F); + setBand(profile, 4U, gainFromDb(2.0F * t), + *FrequencyHz::tryFromHz(3000U), 1.0F); + setBand(profile, 5U, gainFromDb(4.0F * t), + *FrequencyHz::tryFromHz(8000U), 1.0F); + } + + return profile; +} + +} // namespace core diff --git a/Software/components/core/test/CMakeLists.txt b/Software/components/core/test/CMakeLists.txt index 63382fb..dd1406a 100644 --- a/Software/components/core/test/CMakeLists.txt +++ b/Software/components/core/test/CMakeLists.txt @@ -29,6 +29,9 @@ add_library(digiradio_core STATIC "${CORE_SRC_DIR}/BiquadDesign.cpp" "${CORE_SRC_DIR}/MixerState.cpp" "${CORE_SRC_DIR}/EqProfile.cpp" + "${CORE_SRC_DIR}/EnhanceLevel.cpp" + "${CORE_SRC_DIR}/AudioEnhancements.cpp" + "${CORE_SRC_DIR}/EnhancementsDesign.cpp" "${CORE_SRC_DIR}/AudioProfile.cpp" "${CORE_SRC_DIR}/AudioProfileJson.cpp" ) @@ -63,3 +66,7 @@ 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) + +add_executable(enhancements_design_test enhancements_design_test.cpp) +target_link_libraries(enhancements_design_test PRIVATE digiradio_core) +add_test(NAME enhancements_design_test COMMAND enhancements_design_test) diff --git a/Software/components/core/test/audio_profile_json_test.cpp b/Software/components/core/test/audio_profile_json_test.cpp index fb4ffab..6c47f45 100644 --- a/Software/components/core/test/audio_profile_json_test.cpp +++ b/Software/components/core/test/audio_profile_json_test.cpp @@ -13,6 +13,7 @@ #include "core/AudioProfile.hpp" #include "core/AudioProfileJson.hpp" +#include "core/EnhanceLevel.hpp" #include "core/EqBandIndex.hpp" #include "core/FrequencyHz.hpp" #include "core/GainDb.hpp" @@ -52,6 +53,22 @@ namespace { std::cerr << "round-trip mismatch\n"; return EXIT_FAILURE; } + + if (parsed->enhancements.stereo.value() != 0U + || parsed->enhancements.bass.value() != 0U) { + std::cerr << "default enhancements mismatch\n"; + return EXIT_FAILURE; + } + + profile.enhancements.stereo = *core::EnhanceLevel::tryFromLevel(40U); + profile.enhancements.bass = *core::EnhanceLevel::tryFromLevel(75U); + const std::string json2 = core::serializeAudioProfileJson(profile); + const auto parsed2 = core::parseAudioProfileJson(json2); + if (!parsed2 || parsed2->enhancements.stereo.value() != 40U + || parsed2->enhancements.bass.value() != 75U) { + std::cerr << "enhancements round-trip mismatch\n"; + return EXIT_FAILURE; + } return EXIT_SUCCESS; } diff --git a/Software/components/core/test/enhancements_design_test.cpp b/Software/components/core/test/enhancements_design_test.cpp new file mode 100644 index 0000000..3da3559 --- /dev/null +++ b/Software/components/core/test/enhancements_design_test.cpp @@ -0,0 +1,124 @@ +/** + * @file enhancements_design_test.cpp + * @brief Host tests for enhancement-to-EQ mapping. + * + * 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/AudioEnhancements.hpp" +#include "core/EnhanceLevel.hpp" +#include "core/EnhancementsDesign.hpp" +#include "core/EqBandIndex.hpp" +#include "core/EqProfile.hpp" + +#include +#include +#include + +namespace { + +[[nodiscard]] bool nearlyEqual(float a, float b) noexcept +{ + return std::fabs(a - b) < 0.01F; +} + +[[nodiscard]] int runBassMappingTest() +{ + const auto level = core::EnhanceLevel::tryFromLevel(100U); + if (!level) { + std::cerr << "level setup failed\n"; + return EXIT_FAILURE; + } + + core::AudioEnhancements enhancements = core::AudioEnhancements::factoryDefault(); + enhancements.bass = *level; + + const core::EqProfile base = core::EqProfile::factoryDefault(); + const core::EqProfile effective = + core::applyEnhancementsToEq(base, enhancements); + + const auto band1 = core::EqBandIndex::tryFromIndex(1U); + const auto band2 = core::EqBandIndex::tryFromIndex(2U); + if (!band1 || !band2) { + return EXIT_FAILURE; + } + + if (!nearlyEqual(effective.band(*band1).gain.value(), 9.0F) + || !nearlyEqual(effective.band(*band2).gain.value(), 3.0F)) { + std::cerr << "bass mapping mismatch\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runStereoMappingTest() +{ + const auto level = core::EnhanceLevel::tryFromLevel(50U); + if (!level) { + std::cerr << "level setup failed\n"; + return EXIT_FAILURE; + } + + core::AudioEnhancements enhancements = core::AudioEnhancements::factoryDefault(); + enhancements.stereo = *level; + + const core::EqProfile base = core::EqProfile::factoryDefault(); + const core::EqProfile effective = + core::applyEnhancementsToEq(base, enhancements); + + const auto band3 = core::EqBandIndex::tryFromIndex(3U); + const auto band4 = core::EqBandIndex::tryFromIndex(4U); + const auto band5 = core::EqBandIndex::tryFromIndex(5U); + if (!band3 || !band4 || !band5) { + return EXIT_FAILURE; + } + + if (!nearlyEqual(effective.band(*band3).gain.value(), -0.75F) + || !nearlyEqual(effective.band(*band4).gain.value(), 1.0F) + || !nearlyEqual(effective.band(*band5).gain.value(), 2.0F)) { + std::cerr << "stereo mapping mismatch\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runOffLeavesBaseTest() +{ + const core::EqProfile base = core::EqProfile::factoryDefault(); + const core::AudioEnhancements off = core::AudioEnhancements::factoryDefault(); + const core::EqProfile effective = core::applyEnhancementsToEq(base, off); + + const auto band1 = core::EqBandIndex::tryFromIndex(1U); + if (!band1) { + return EXIT_FAILURE; + } + + if (!nearlyEqual(effective.band(*band1).gain.value(), + base.band(*band1).gain.value())) { + std::cerr << "off should leave base unchanged\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +} // namespace + +int main() +{ + if (runBassMappingTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runStereoMappingTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runOffLeavesBaseTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp index 8f27df9..7cbb1ec 100644 --- a/Software/components/net/src/SetupWebServer.cpp +++ b/Software/components/net/src/SetupWebServer.cpp @@ -25,6 +25,7 @@ #include "core/HealthStatusJson.hpp" #include "core/ParseError.hpp" #include "core/SeekDirection.hpp" +#include "core/StoreError.hpp" #include "core/TunerJson.hpp" #include "core/WifiProvisionJson.hpp" #include "tuner/TunerService.hpp" @@ -129,7 +130,8 @@ void rebootTask(void* arg) return "tuner_error"; } -[[nodiscard]] bool readRequestBody(httpd_req_t* req, std::array& body) +template +[[nodiscard]] bool readRequestBodyImpl(httpd_req_t* req, std::array& body) { int received = 0; while (received < static_cast(body.size()) - 1) { @@ -144,6 +146,12 @@ void rebootTask(void* arg) return received > 0; } +template +[[nodiscard]] bool readRequestBody(httpd_req_t* req, std::array& body) +{ + return readRequestBodyImpl(req, body); +} + /** * @brief healthGetHandler — serve GET /api/health as JSON. * @@ -470,9 +478,66 @@ esp_err_t audioResetPostHandler(httpd_req_t* req) } /** - * @brief indexGetHandler — serve gzipped setup page from flash. + * @brief audioEnhancePostHandler — apply stereo or bass enhancement level. * - * @dname indexGetHandler + * @dname audioEnhancePostHandler + * @param req HTTP request handle from esp_http_server. + * @return ESP_OK on success, or an esp_err_t error code. + * @pubstate parses level, updates AudioService, persists to NVS. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +esp_err_t audioEnhancePostHandler(httpd_req_t* req, bool stereo) +{ + 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::parseEnhanceLevelJson(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()); + } + + const std::expected applied = stereo + ? ctx->audio->setStereoEnhance(*parsed, true) + : ctx->audio->setBassEnhance(*parsed, true); + if (!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()); +} + +esp_err_t audioStereoEnhancePostHandler(httpd_req_t* req) +{ + return audioEnhancePostHandler(req, true); +} + +esp_err_t audioBassEnhancePostHandler(httpd_req_t* req) +{ + return audioEnhancePostHandler(req, false); +} + +/** + * @brief indexGetHandler — serve gzipped setup page from flash. * @param req HTTP request handle from esp_http_server. * @return ESP_OK on success, or an esp_err_t error code. * @pubstate none; reads embedded www/index.html.gz blob. @@ -726,6 +791,22 @@ std::expected SetupWebServer::start( }; httpd_register_uri_handler(server_, &audioResetUri); + const httpd_uri_t audioStereoEnhanceUri = { + .uri = "/api/audio/stereo-enhance", + .method = HTTP_POST, + .handler = audioStereoEnhancePostHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &audioStereoEnhanceUri); + + const httpd_uri_t audioBassEnhanceUri = { + .uri = "/api/audio/bass-enhance", + .method = HTTP_POST, + .handler = audioBassEnhancePostHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &audioBassEnhanceUri); + 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 3119cf8..3162dde 100644 --- a/Software/components/net/www/index.html +++ b/Software/components/net/www/index.html @@ -207,6 +207,10 @@ + + + +
@@ -426,6 +430,10 @@ document.getElementById("master-db").value = d.master.left_db; document.getElementById("si4684-db").value = d.mixer.si4684_left_db; document.getElementById("esp32-db").value = d.mixer.esp32_left_db; + if (d.enhancements) { + document.getElementById("stereo-level").value = d.enhancements.stereo_level; + document.getElementById("bass-level").value = d.enhancements.bass_level; + } showMsg(msg, "", true); }) .catch(function () { showMsg(msg, "Audio profile load failed.", false); }); @@ -446,6 +454,13 @@ audioProfile.mixer.si4684_right_db = si4684; audioProfile.mixer.esp32_left_db = esp32; audioProfile.mixer.esp32_right_db = esp32; + if (!audioProfile.enhancements) { + audioProfile.enhancements = { stereo_level: 0, bass_level: 0 }; + } + audioProfile.enhancements.stereo_level = + parseInt(document.getElementById("stereo-level").value, 10); + audioProfile.enhancements.bass_level = + parseInt(document.getElementById("bass-level").value, 10); fetch("/api/audio/profile", { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -462,6 +477,35 @@ .catch(function () { showMsg(msg, "Save request failed.", false); }); }); + function postEnhance(url, level, msg) { + fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ level: level }) + }) + .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + .then(function (res) { + if (res.ok && res.d.status === "saved") { + showMsg(msg, "Enhancement applied.", true); + } else { + showMsg(msg, "Enhance failed: " + (res.d.reason || "unknown"), false); + } + }) + .catch(function () { showMsg(msg, "Enhance request failed.", false); }); + } + + document.getElementById("stereo-level").addEventListener("change", function () { + var msg = document.getElementById("audio-msg"); + var level = parseInt(document.getElementById("stereo-level").value, 10); + postEnhance("/api/audio/stereo-enhance", level, msg); + }); + + document.getElementById("bass-level").addEventListener("change", function () { + var msg = document.getElementById("audio-msg"); + var level = parseInt(document.getElementById("bass-level").value, 10); + postEnhance("/api/audio/bass-enhance", level, msg); + }); + document.getElementById("reset-audio").addEventListener("click", function () { var msg = document.getElementById("audio-msg"); fetch("/api/audio/reset", { method: "POST" }) diff --git a/Software/components/net/www/index.html.gz b/Software/components/net/www/index.html.gz index eb4bb3de1884c4723b950fc452aba44a07508240..fb5718e78a9db9a83fb6d4d88c7ecb566728c045 100644 GIT binary patch literal 4194 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 setStereoEnhance( + core::EnhanceLevel level, bool persist); + + /** + * @brief setBassEnhance — adjust bass emphasis overlay (PEQ bands 1–2). + * + * @dname setBassEnhance + * @param level Intensity 0..100 (0 = off). + * @param persist When true and store is set, write NVS. + * @return Ok on success, or StoreError. + * @pubstate updates profile_.enhancements.bass and safeloads effective EQ. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected setBassEnhance( + core::EnhanceLevel level, bool persist); + private: [[nodiscard]] std::expected persistProfile() const; + [[nodiscard]] std::expected applyProfileToDsp( + const core::AudioProfile& profile); + + [[nodiscard]] std::expected applyEffectiveEq( + bool persist); + core::IDsp& dsp_; core::IAudioProfileStore* store_; core::AudioProfile profile_; diff --git a/Software/components/services/audio/src/AudioService.cpp b/Software/components/services/audio/src/AudioService.cpp index 89508d6..fd4b394 100644 --- a/Software/components/services/audio/src/AudioService.cpp +++ b/Software/components/services/audio/src/AudioService.cpp @@ -15,6 +15,19 @@ namespace audio { +namespace { + +[[nodiscard]] core::AudioProfile profileForHardware( + const core::AudioProfile& profile) noexcept +{ + core::AudioProfile hardware = profile; + hardware.eq = + core::applyEnhancementsToEq(profile.eq, profile.enhancements); + return hardware; +} + +} // namespace + AudioService::AudioService(core::IDsp& dsp, core::IAudioProfileStore* store) : dsp_(dsp) , store_(store) @@ -30,8 +43,8 @@ std::expected AudioService::loadAndApply() } } - if (auto applied = dsp_.applyProfile(profile_); !applied) { - return applied; + if (auto applied = applyProfileToDsp(profile_); !applied) { + return std::unexpected(core::DspError::SafeloadFailed); } return {}; } @@ -49,11 +62,35 @@ std::expected AudioService::persistProfile() const return store_->saveProfile(profile_); } +std::expected AudioService::applyProfileToDsp( + const core::AudioProfile& profile) +{ + const core::AudioProfile hardware = profileForHardware(profile); + if (auto applied = dsp_.applyProfile(hardware); !applied) { + return std::unexpected(core::StoreError::IoFailed); + } + return {}; +} + +std::expected AudioService::applyEffectiveEq( + bool persist) +{ + const core::EqProfile effective = core::applyEnhancementsToEq( + profile_.eq, profile_.enhancements); + if (auto applied = dsp_.applyEq(effective); !applied) { + return std::unexpected(core::StoreError::IoFailed); + } + if (persist) { + return persistProfile(); + } + return {}; +} + std::expected AudioService::applyProfile( const core::AudioProfile& profile, bool persist) { - if (auto applied = dsp_.applyProfile(profile); !applied) { - return std::unexpected(core::StoreError::IoFailed); + if (auto applied = applyProfileToDsp(profile); !applied) { + return applied; } profile_ = profile; if (persist) { @@ -101,18 +138,26 @@ 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 {}; + return applyEffectiveEq(persist); +} + +std::expected AudioService::setStereoEnhance( + core::EnhanceLevel level, bool persist) +{ + profile_.enhancements.stereo = level; + return applyEffectiveEq(persist); +} + +std::expected AudioService::setBassEnhance( + core::EnhanceLevel level, bool persist) +{ + profile_.enhancements.bass = level; + return applyEffectiveEq(persist); } } // namespace audio diff --git a/Software/docs/manual/ch-api.tex b/Software/docs/manual/ch-api.tex index c7b4fd8..2fa4702 100644 --- a/Software/docs/manual/ch-api.tex +++ b/Software/docs/manual/ch-api.tex @@ -184,9 +184,11 @@ The handler reads \texttt{audio::AudioService::currentProfile()}. {"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}, ...]} + "eq":[{"gain_db":0,"center_hz":40,"q":1.414}, ...], + "enhancements":{"stereo_level":0,"bass_level":0}} \end{drcode} Six EQ bands are always present (\texttt{eq} array length~6). +Enhancement levels are 0--100; at 0 the base EQ band settings apply. \end{drnote} HTTP status: \textbf{200 OK}; \textbf{503} when the audio service is @@ -217,6 +219,32 @@ 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. +\subsection{\texttt{POST /api/audio/stereo-enhance}} +\label{sec:api-audio-stereo-enhance} + +Adjusts stereo depth via a psychoacoustic PEQ overlay on bands 3--5 +(1\,kHz / 3\,kHz / 8\,kHz). This is \emph{not} true M/S widening---there +is no dedicated SigmaStudio widener block; see +Section~\ref{sec:ss-enhancements}. + +\begin{drnote}[Request body] +\begin{drcode}[JSON] +{"level":50} +\end{drcode} +\texttt{level} is an integer 0--100 (0 = off, 100 = maximum). +\end{drnote} + +Success response: \texttt{\{"status":"saved"\}}. HTTP status: +\textbf{200 OK}; \textbf{400} for invalid JSON or level; \textbf{500} +when safeload or NVS persistence fails. + +\subsection{\texttt{POST /api/audio/bass-enhance}} +\label{sec:api-audio-bass-enhance} + +Adjusts bass emphasis via a PEQ overlay on bands 1--2 (100\,Hz / +400\,Hz). Request and response schema match +\texttt{POST /api/audio/stereo-enhance} (Section~\ref{sec:api-audio-stereo-enhance}). + \section{Boot and network state machine} \label{sec:api-boot-flow} diff --git a/Software/docs/manual/ch-classes.tex b/Software/docs/manual/ch-classes.tex index b646137..f20a7a7 100644 --- a/Software/docs/manual/ch-classes.tex +++ b/Software/docs/manual/ch-classes.tex @@ -201,7 +201,13 @@ Constructed once in \texttt{HardwareBootstrap} alongside the driver. 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. +safeload. Stereo depth and bass enhance levels are merged into the effective +EQ via \texttt{core::applyEnhancementsToEq()}. Exposed on +\texttt{/api/audio/*} and the web UI Audio section. + +\section{EnhanceLevel}\label{cls:EnhanceLevel} +Strong type for 0--100 enhancement intensity (stereo depth and bass boost). +Validated at the HTTP boundary by \texttt{core::parseEnhanceLevelJson()}. \section{NvsAudioProfileStore}\label{cls:NvsAudioProfileStore} \texttt{IAudioProfileStore} implementation storing serialised diff --git a/Software/docs/manual/ch-sigmastudio.tex b/Software/docs/manual/ch-sigmastudio.tex index 7775805..4d5caba 100644 --- a/Software/docs/manual/ch-sigmastudio.tex +++ b/Software/docs/manual/ch-sigmastudio.tex @@ -213,6 +213,26 @@ SigmaStudio export (Section~\ref{sec:ss-export}). \label{tab:ss-runtime} \end{table} +\section{Virtual enhancements (stereo depth and bass boost)} +\label{sec:ss-enhancements} + +The SigmaStudio export does not include dedicated stereo widener or bass +boost blocks. Firmware~0.5.0 maps enhancement levels (0--100) onto the +existing Param EQ1 bands at runtime: + +\begin{itemize} + \item \textbf{Bass enhance} --- peaking boost at 100\,Hz (+9\,dB max) + and 400\,Hz (+3\,dB max). + \item \textbf{Stereo enhance} --- slight 1\,kHz cut, plus 3\,kHz and + 8\,kHz lift for a wider, more present image. This is a + psychoacoustic curve, not mid/side processing. +\end{itemize} + +Enhancement levels are stored in \texttt{AudioProfile::enhancements} and +applied by \texttt{core::applyEnhancementsToEq()} before safeload. Base EQ +band settings in NVS are preserved; overlays replace affected bands only +while the corresponding level is greater than zero. + \begin{drcaution}[Use safeload for live changes] All runtime updates to the volume, source levels, and EQ bands must go through the ADAU1701 safeload mechanism. Writing parameter cells directly