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
@@ -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(),
.masterLeft = unity,
.masterRight = unity,
.enhancements = AudioEnhancements::factoryDefault(),
};
}
@@ -13,6 +13,8 @@
#include "core/AudioProfileJson.hpp"
#include "core/EnhanceLevel.hpp"
#include <cstdlib>
#include <sstream>
@@ -144,6 +146,34 @@ namespace {
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
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<unsigned>(profile.enhancements.stereo.value())
<< ",\"bass_level\":" << static_cast<unsigned>(profile.enhancements.bass.value())
<< "}}";
return out.str();
}
@@ -204,11 +237,17 @@ std::expected<AudioProfile, ParseError> 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<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
@@ -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