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:
@@ -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 (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
|
||||||
@@ -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"
|
||||||
@@ -34,6 +35,7 @@ struct AudioProfile {
|
|||||||
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 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 <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 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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (0–100)</label>
|
||||||
|
<input id="stereo-level" type="range" min="0" max="100" step="1" value="0">
|
||||||
|
<label for="bass-level">Bass enhance (0–100)</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 3–5).
|
||||||
|
*
|
||||||
|
* @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 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<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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user