Add stereo and bass enhance APIs via PEQ overlay.

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 <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 17:00:12 +02:00
co-authored by Cursor
parent 6c350f5c48
commit fa532e068f
22 changed files with 826 additions and 19 deletions
+3
View File
@@ -17,6 +17,9 @@ idf_component_register(
"src/BiquadDesign.cpp" "src/BiquadDesign.cpp"
"src/MixerState.cpp" "src/MixerState.cpp"
"src/EqProfile.cpp" "src/EqProfile.cpp"
"src/EnhanceLevel.cpp"
"src/AudioEnhancements.cpp"
"src/EnhancementsDesign.cpp"
"src/AudioProfile.cpp" "src/AudioProfile.cpp"
"src/AudioProfileJson.cpp" "src/AudioProfileJson.cpp"
INCLUDE_DIRS "include" INCLUDE_DIRS "include"
@@ -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 (0100 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 35).
EnhanceLevel bass; ///< Bass emphasis (PEQ bands 12).
/**
* @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
@@ -12,6 +12,7 @@
*/ */
#pragma once #pragma once
#include "core/AudioEnhancements.hpp"
#include "core/EqProfile.hpp" #include "core/EqProfile.hpp"
#include "core/GainDb.hpp" #include "core/GainDb.hpp"
#include "core/MixerState.hpp" #include "core/MixerState.hpp"
@@ -32,8 +33,9 @@ namespace core {
struct AudioProfile { struct AudioProfile {
MixerState mixer; ///< Input and stereo-mixer gains. MixerState mixer; ///< Input and stereo-mixer gains.
EqProfile eq; ///< Six-band parametric EQ. EqProfile eq; ///< Six-band parametric EQ.
GainDb masterLeft; ///< Multiple 1 master volume, left. GainDb masterLeft; ///< Multiple 1 master volume, left.
GainDb masterRight; ///< Multiple 1 master volume, right. 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). * @brief factoryDefault — factory-flat audio path (0 dB everywhere).
@@ -15,6 +15,8 @@
#include "core/AudioProfile.hpp" #include "core/AudioProfile.hpp"
#include "core/ParseError.hpp" #include "core/ParseError.hpp"
#include "core/EnhanceLevel.hpp"
#include <expected> #include <expected>
#include <string> #include <string>
#include <string_view> #include <string_view>
@@ -74,4 +76,18 @@ namespace core {
*/ */
[[nodiscard]] std::string serializeAudioErrorJson(std::string_view reason); [[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<EnhanceLevel, ParseError> parseEnhanceLevelJson(
std::string_view json);
} // namespace core } // namespace core
@@ -0,0 +1,93 @@
/**
* @file EnhanceLevel.hpp
* @brief Strong type for 0100 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 <cstdint>
#include <expected>
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<EnhanceLevel, ParseError> 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
@@ -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 35): slight 1\,kHz cut plus 3/8\,kHz lift for depth.
* Bass (bands 12): 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
@@ -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
@@ -23,6 +23,7 @@ AudioProfile AudioProfile::factoryDefault() noexcept
.eq = EqProfile::factoryDefault(), .eq = EqProfile::factoryDefault(),
.masterLeft = unity, .masterLeft = unity,
.masterRight = unity, .masterRight = unity,
.enhancements = AudioEnhancements::factoryDefault(),
}; };
} }
@@ -13,6 +13,8 @@
#include "core/AudioProfileJson.hpp" #include "core/AudioProfileJson.hpp"
#include "core/EnhanceLevel.hpp"
#include <cstdlib> #include <cstdlib>
#include <sstream> #include <sstream>
@@ -144,6 +146,34 @@ namespace {
return profile; return profile;
} }
[[nodiscard]] std::expected<AudioEnhancements, ParseError> 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 } // namespace
std::string serializeAudioProfileJson(const AudioProfile& profile) std::string serializeAudioProfileJson(const AudioProfile& profile)
@@ -172,7 +202,10 @@ std::string serializeAudioProfileJson(const AudioProfile& profile)
<< ",\"center_hz\":" << b.center.value() << ",\"q\":" << b.q << ",\"center_hz\":" << b.center.value() << ",\"q\":" << b.q
<< '}'; << '}';
} }
out << "]}"; out << "],\"enhancements\":{"
<< "\"stereo_level\":" << static_cast<unsigned>(profile.enhancements.stereo.value())
<< ",\"bass_level\":" << static_cast<unsigned>(profile.enhancements.bass.value())
<< "}}";
return out.str(); return out.str();
} }
@@ -204,11 +237,17 @@ std::expected<AudioProfile, ParseError> parseAudioProfileJson(
return std::unexpected(ParseError::MissingField); return std::unexpected(ParseError::MissingField);
} }
const auto enhancements = parseEnhancementsJson(json);
if (!enhancements) {
return std::unexpected(enhancements.error());
}
return AudioProfile{ return AudioProfile{
.mixer = *mixer, .mixer = *mixer,
.eq = *eq, .eq = *eq,
.masterLeft = *masterLeft, .masterLeft = *masterLeft,
.masterRight = *masterRight, .masterRight = *masterRight,
.enhancements = *enhancements,
}; };
} }
@@ -224,4 +263,18 @@ std::string serializeAudioErrorJson(std::string_view reason)
return out.str(); return out.str();
} }
std::expected<EnhanceLevel, ParseError> 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 } // namespace core
@@ -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, ParseError> EnhanceLevel::tryFromLevel(
std::uint32_t level) noexcept
{
if (level > kMax) {
return std::unexpected(ParseError::MissingField);
}
return EnhanceLevel(static_cast<std::uint8_t>(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<float>(level_) / static_cast<float>(kMax);
}
} // namespace core
@@ -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
@@ -29,6 +29,9 @@ add_library(digiradio_core STATIC
"${CORE_SRC_DIR}/BiquadDesign.cpp" "${CORE_SRC_DIR}/BiquadDesign.cpp"
"${CORE_SRC_DIR}/MixerState.cpp" "${CORE_SRC_DIR}/MixerState.cpp"
"${CORE_SRC_DIR}/EqProfile.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}/AudioProfile.cpp"
"${CORE_SRC_DIR}/AudioProfileJson.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) add_executable(audio_profile_json_test audio_profile_json_test.cpp)
target_link_libraries(audio_profile_json_test PRIVATE digiradio_core) target_link_libraries(audio_profile_json_test PRIVATE digiradio_core)
add_test(NAME audio_profile_json_test COMMAND audio_profile_json_test) 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)
@@ -13,6 +13,7 @@
#include "core/AudioProfile.hpp" #include "core/AudioProfile.hpp"
#include "core/AudioProfileJson.hpp" #include "core/AudioProfileJson.hpp"
#include "core/EnhanceLevel.hpp"
#include "core/EqBandIndex.hpp" #include "core/EqBandIndex.hpp"
#include "core/FrequencyHz.hpp" #include "core/FrequencyHz.hpp"
#include "core/GainDb.hpp" #include "core/GainDb.hpp"
@@ -52,6 +53,22 @@ namespace {
std::cerr << "round-trip mismatch\n"; std::cerr << "round-trip mismatch\n";
return EXIT_FAILURE; 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; return EXIT_SUCCESS;
} }
@@ -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 <cmath>
#include <cstdlib>
#include <iostream>
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;
}
+84 -3
View File
@@ -25,6 +25,7 @@
#include "core/HealthStatusJson.hpp" #include "core/HealthStatusJson.hpp"
#include "core/ParseError.hpp" #include "core/ParseError.hpp"
#include "core/SeekDirection.hpp" #include "core/SeekDirection.hpp"
#include "core/StoreError.hpp"
#include "core/TunerJson.hpp" #include "core/TunerJson.hpp"
#include "core/WifiProvisionJson.hpp" #include "core/WifiProvisionJson.hpp"
#include "tuner/TunerService.hpp" #include "tuner/TunerService.hpp"
@@ -129,7 +130,8 @@ void rebootTask(void* arg)
return "tuner_error"; return "tuner_error";
} }
[[nodiscard]] bool readRequestBody(httpd_req_t* req, std::array<char, 512>& body) template <std::size_t N>
[[nodiscard]] bool readRequestBodyImpl(httpd_req_t* req, std::array<char, N>& body)
{ {
int received = 0; int received = 0;
while (received < static_cast<int>(body.size()) - 1) { while (received < static_cast<int>(body.size()) - 1) {
@@ -144,6 +146,12 @@ void rebootTask(void* arg)
return received > 0; return received > 0;
} }
template <std::size_t N>
[[nodiscard]] bool readRequestBody(httpd_req_t* req, std::array<char, N>& body)
{
return readRequestBodyImpl(req, body);
}
/** /**
* @brief healthGetHandler — serve GET /api/health as JSON. * @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<char, 512> 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<void, core::StoreError> 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. * @param req HTTP request handle from esp_http_server.
* @return ESP_OK on success, or an esp_err_t error code. * @return ESP_OK on success, or an esp_err_t error code.
* @pubstate none; reads embedded www/index.html.gz blob. * @pubstate none; reads embedded www/index.html.gz blob.
@@ -726,6 +791,22 @@ std::expected<void, NetError> SetupWebServer::start(
}; };
httpd_register_uri_handler(server_, &audioResetUri); 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"); ESP_LOGI(kTag, "HTTP server listening on port 80");
return {}; return {};
} }
+44
View File
@@ -207,6 +207,10 @@
<input id="si4684-db" type="range" min="-60" max="12" step="0.5" value="0"> <input id="si4684-db" type="range" min="-60" max="12" step="0.5" value="0">
<label for="esp32-db">ESP32 path (dB)</label> <label for="esp32-db">ESP32 path (dB)</label>
<input id="esp32-db" type="range" min="-60" max="12" step="0.5" value="0"> <input id="esp32-db" type="range" min="-60" max="12" step="0.5" value="0">
<label for="stereo-level">Stereo depth (0100)</label>
<input id="stereo-level" type="range" min="0" max="100" step="1" value="0">
<label for="bass-level">Bass enhance (0100)</label>
<input id="bass-level" type="range" min="0" max="100" step="1" value="0">
<div class="row"> <div class="row">
<button type="button" id="save-audio">Save profile</button> <button type="button" id="save-audio">Save profile</button>
<button type="button" class="secondary" id="reset-audio">Reset flat</button> <button type="button" class="secondary" id="reset-audio">Reset flat</button>
@@ -426,6 +430,10 @@
document.getElementById("master-db").value = d.master.left_db; document.getElementById("master-db").value = d.master.left_db;
document.getElementById("si4684-db").value = d.mixer.si4684_left_db; document.getElementById("si4684-db").value = d.mixer.si4684_left_db;
document.getElementById("esp32-db").value = d.mixer.esp32_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); showMsg(msg, "", true);
}) })
.catch(function () { showMsg(msg, "Audio profile load failed.", false); }); .catch(function () { showMsg(msg, "Audio profile load failed.", false); });
@@ -446,6 +454,13 @@
audioProfile.mixer.si4684_right_db = si4684; audioProfile.mixer.si4684_right_db = si4684;
audioProfile.mixer.esp32_left_db = esp32; audioProfile.mixer.esp32_left_db = esp32;
audioProfile.mixer.esp32_right_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", { fetch("/api/audio/profile", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -462,6 +477,35 @@
.catch(function () { showMsg(msg, "Save request failed.", false); }); .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 () { document.getElementById("reset-audio").addEventListener("click", function () {
var msg = document.getElementById("audio-msg"); var msg = document.getElementById("audio-msg");
fetch("/api/audio/reset", { method: "POST" }) fetch("/api/audio/reset", { method: "POST" })
Binary file not shown.
@@ -14,6 +14,8 @@
#include "core/AudioProfile.hpp" #include "core/AudioProfile.hpp"
#include "core/DspError.hpp" #include "core/DspError.hpp"
#include "core/EnhanceLevel.hpp"
#include "core/EnhancementsDesign.hpp"
#include "core/EqBandIndex.hpp" #include "core/EqBandIndex.hpp"
#include "core/FrequencyHz.hpp" #include "core/FrequencyHz.hpp"
#include "core/GainDb.hpp" #include "core/GainDb.hpp"
@@ -147,9 +149,45 @@ public:
core::EqBandIndex band, core::GainDb gain, core::FrequencyHz center, core::EqBandIndex band, core::GainDb gain, core::FrequencyHz center,
float q, bool persist); float q, bool persist);
/**
* @brief setStereoEnhance — adjust stereo depth overlay (PEQ bands 35).
*
* @dname setStereoEnhance
* @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.stereo and safeloads effective EQ.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, core::StoreError> setStereoEnhance(
core::EnhanceLevel level, bool persist);
/**
* @brief setBassEnhance — adjust bass emphasis overlay (PEQ bands 12).
*
* @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<void, core::StoreError> setBassEnhance(
core::EnhanceLevel level, bool persist);
private: private:
[[nodiscard]] std::expected<void, core::StoreError> persistProfile() const; [[nodiscard]] std::expected<void, core::StoreError> persistProfile() const;
[[nodiscard]] std::expected<void, core::StoreError> applyProfileToDsp(
const core::AudioProfile& profile);
[[nodiscard]] std::expected<void, core::StoreError> applyEffectiveEq(
bool persist);
core::IDsp& dsp_; core::IDsp& dsp_;
core::IAudioProfileStore* store_; core::IAudioProfileStore* store_;
core::AudioProfile profile_; core::AudioProfile profile_;
@@ -15,6 +15,19 @@
namespace audio { 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) AudioService::AudioService(core::IDsp& dsp, core::IAudioProfileStore* store)
: dsp_(dsp) : dsp_(dsp)
, store_(store) , store_(store)
@@ -30,8 +43,8 @@ std::expected<void, core::DspError> AudioService::loadAndApply()
} }
} }
if (auto applied = dsp_.applyProfile(profile_); !applied) { if (auto applied = applyProfileToDsp(profile_); !applied) {
return applied; return std::unexpected(core::DspError::SafeloadFailed);
} }
return {}; return {};
} }
@@ -49,11 +62,35 @@ std::expected<void, core::StoreError> AudioService::persistProfile() const
return store_->saveProfile(profile_); return store_->saveProfile(profile_);
} }
std::expected<void, core::StoreError> 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<void, core::StoreError> 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<void, core::StoreError> AudioService::applyProfile( std::expected<void, core::StoreError> AudioService::applyProfile(
const core::AudioProfile& profile, bool persist) const core::AudioProfile& profile, bool persist)
{ {
if (auto applied = dsp_.applyProfile(profile); !applied) { if (auto applied = applyProfileToDsp(profile); !applied) {
return std::unexpected(core::StoreError::IoFailed); return applied;
} }
profile_ = profile; profile_ = profile;
if (persist) { if (persist) {
@@ -101,18 +138,26 @@ std::expected<void, core::StoreError> AudioService::setEqBand(
core::EqBandIndex band, core::GainDb gain, core::FrequencyHz center, float q, core::EqBandIndex band, core::GainDb gain, core::FrequencyHz center, float q,
bool persist) bool persist)
{ {
if (auto applied = dsp_.setEqBand(band, gain, center, q); !applied) {
return std::unexpected(core::StoreError::IoFailed);
}
profile_.eq.setBand(band, core::EqBandSettings{ profile_.eq.setBand(band, core::EqBandSettings{
.gain = gain, .gain = gain,
.center = center, .center = center,
.q = q, .q = q,
}); });
if (persist) { return applyEffectiveEq(persist);
return persistProfile(); }
}
return {}; std::expected<void, core::StoreError> AudioService::setStereoEnhance(
core::EnhanceLevel level, bool persist)
{
profile_.enhancements.stereo = level;
return applyEffectiveEq(persist);
}
std::expected<void, core::StoreError> AudioService::setBassEnhance(
core::EnhanceLevel level, bool persist)
{
profile_.enhancements.bass = level;
return applyEffectiveEq(persist);
} }
} // namespace audio } // namespace audio
+29 -1
View File
@@ -184,9 +184,11 @@ The handler reads \texttt{audio::AudioService::currentProfile()}.
{"mixer":{"si4684_left_db":0,"si4684_right_db":0,"esp32_left_db":0, {"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}, "esp32_right_db":0,"mix_left_db":0,"mix_right_db":0},
"master":{"left_db":0,"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} \end{drcode}
Six EQ bands are always present (\texttt{eq} array length~6). 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} \end{drnote}
HTTP status: \textbf{200 OK}; \textbf{503} when the audio service is 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} \texttt{\{"status":"saved"\}}. HTTP status: \textbf{200 OK}; \textbf{500}
on apply/persist failure. 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} \section{Boot and network state machine}
\label{sec:api-boot-flow} \label{sec:api-boot-flow}
+7 -1
View File
@@ -201,7 +201,13 @@ Constructed once in \texttt{HardwareBootstrap} alongside the driver.
Application service for ADAU1701 mixer, EQ, and master volume. Holds a Application service for ADAU1701 mixer, EQ, and master volume. Holds a
reference to \texttt{core::IDsp}, tracks the in-memory \texttt{AudioProfile}, reference to \texttt{core::IDsp}, tracks the in-memory \texttt{AudioProfile},
loads from \texttt{IAudioProfileStore} after boot, and applies changes via 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} \section{NvsAudioProfileStore}\label{cls:NvsAudioProfileStore}
\texttt{IAudioProfileStore} implementation storing serialised \texttt{IAudioProfileStore} implementation storing serialised
+20
View File
@@ -213,6 +213,26 @@ SigmaStudio export (Section~\ref{sec:ss-export}).
\label{tab:ss-runtime} \label{tab:ss-runtime}
\end{table} \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] \begin{drcaution}[Use safeload for live changes]
All runtime updates to the volume, source levels, and EQ bands must go All runtime updates to the volume, source levels, and EQ bands must go
through the ADAU1701 safeload mechanism. Writing parameter cells directly through the ADAU1701 safeload mechanism. Writing parameter cells directly