Add Slice 5 ADAU1701 runtime audio control (firmware 0.5.0).
Safeload mixer/EQ/master on the ADAU1701, persist AudioProfile in NVS, expose /api/audio routes and web UI controls, with host tests and manual sync. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,6 +10,15 @@ idf_component_register(
|
||||
"src/EmbeddedBlobReader.cpp"
|
||||
"src/TunerJson.cpp"
|
||||
"src/FrequencyKHz.cpp"
|
||||
"src/GainDb.cpp"
|
||||
"src/FrequencyHz.cpp"
|
||||
"src/EqBandIndex.cpp"
|
||||
"src/BiquadCoefficients.cpp"
|
||||
"src/BiquadDesign.cpp"
|
||||
"src/MixerState.cpp"
|
||||
"src/EqProfile.cpp"
|
||||
"src/AudioProfile.cpp"
|
||||
"src/AudioProfileJson.cpp"
|
||||
INCLUDE_DIRS "include"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @file AudioProfile.hpp
|
||||
* @brief Combined mixer, EQ, and master volume snapshot.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "core/EqProfile.hpp"
|
||||
#include "core/GainDb.hpp"
|
||||
#include "core/MixerState.hpp"
|
||||
|
||||
namespace core {
|
||||
|
||||
/**
|
||||
* @brief AudioProfile — full ADAU1701 user configuration snapshot.
|
||||
*
|
||||
* @dname AudioProfile
|
||||
* @return n/a (type)
|
||||
* @pubstate Plain aggregate persisted by IAudioProfileStore and applied by
|
||||
* AudioService after boot.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
struct AudioProfile {
|
||||
MixerState mixer; ///< Input and stereo-mixer gains.
|
||||
EqProfile eq; ///< Six-band parametric EQ.
|
||||
GainDb masterLeft; ///< Multiple 1 master volume, left.
|
||||
GainDb masterRight; ///< Multiple 1 master volume, right.
|
||||
|
||||
/**
|
||||
* @brief factoryDefault — factory-flat audio path (0 dB everywhere).
|
||||
*
|
||||
* @dname factoryDefault
|
||||
* @return AudioProfile suitable for first boot and reset.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] static AudioProfile factoryDefault() noexcept;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @file AudioProfileJson.hpp
|
||||
* @brief JSON parse/serialise for audio profile API (pure core).
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "core/AudioProfile.hpp"
|
||||
#include "core/ParseError.hpp"
|
||||
|
||||
#include <expected>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace core {
|
||||
|
||||
/**
|
||||
* @brief serializeAudioProfileJson — serialise profile for GET /api/audio.
|
||||
*
|
||||
* @dname serializeAudioProfileJson
|
||||
* @param profile Domain snapshot from AudioService.
|
||||
* @return JSON object string for the HTTP response body.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::string serializeAudioProfileJson(
|
||||
const AudioProfile& profile);
|
||||
|
||||
/**
|
||||
* @brief parseAudioProfileJson — parse PUT /api/audio/profile body.
|
||||
*
|
||||
* @dname parseAudioProfileJson
|
||||
* @param json Untrusted request body.
|
||||
* @return AudioProfile on success, or ParseError.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<AudioProfile, ParseError> parseAudioProfileJson(
|
||||
std::string_view json);
|
||||
|
||||
/**
|
||||
* @brief serializeAudioSavedJson — success response after profile apply.
|
||||
*
|
||||
* @dname serializeAudioSavedJson
|
||||
* @return JSON object \texttt{\{"status":"saved"\}}.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::string serializeAudioSavedJson();
|
||||
|
||||
/**
|
||||
* @brief serializeAudioErrorJson — error response for audio routes.
|
||||
*
|
||||
* @dname serializeAudioErrorJson
|
||||
* @param reason Safe token (never includes secrets).
|
||||
* @return JSON object with status and reason fields.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::string serializeAudioErrorJson(std::string_view reason);
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file BiquadCoefficients.hpp
|
||||
* @brief Float and fixpoint biquad coefficients for ADAU1701 PEQ.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
namespace core {
|
||||
|
||||
/**
|
||||
* @brief BiquadCoefficients — five PEQ coefficients (b0..a1).
|
||||
*
|
||||
* @dname BiquadCoefficients
|
||||
* @return n/a (type)
|
||||
* @pubstate Plain value type matching SigmaStudio Param EQ cell order.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
struct BiquadCoefficients {
|
||||
float b0; ///< Numerator b0.
|
||||
float b1; ///< Numerator b1.
|
||||
float b2; ///< Numerator b2.
|
||||
float a0; ///< Denominator a0 (ADI stores feedback terms).
|
||||
float a1; ///< Denominator a1.
|
||||
|
||||
/**
|
||||
* @brief toFixpoint823 — convert each coefficient to 8.23 fixpoint.
|
||||
*
|
||||
* @dname toFixpoint823
|
||||
* @return Array of five 32-bit fixpoint words for safeload.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::array<std::int32_t, 5> toFixpoint823() const noexcept;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @file BiquadDesign.hpp
|
||||
* @brief Host-testable biquad coefficient design for ADAU1701 PEQ.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "core/BiquadCoefficients.hpp"
|
||||
#include "core/FrequencyHz.hpp"
|
||||
#include "core/GainDb.hpp"
|
||||
|
||||
namespace core {
|
||||
|
||||
/** Sample rate of the DigiRadio SigmaStudio project (Hz). */
|
||||
inline constexpr std::uint32_t kAdauSampleRateHz = 48000U;
|
||||
|
||||
/**
|
||||
* @brief designPeakingEq — compute PEQ coefficients for one band.
|
||||
*
|
||||
* @dname designPeakingEq
|
||||
* @param center Validated centre frequency.
|
||||
* @param gain Band gain in dB (0 dB yields flat response).
|
||||
* @param q Quality factor (typically 0.5..10).
|
||||
* @return BiquadCoefficients in SigmaStudio Param EQ order.
|
||||
* @pubstate none
|
||||
*
|
||||
* Uses the Robert Bristow-Johnson peaking EQ formulae at kAdauSampleRateHz.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] BiquadCoefficients designPeakingEq(FrequencyHz center, GainDb gain,
|
||||
float q) noexcept;
|
||||
|
||||
/**
|
||||
* @brief designFlatEq — unity-gain bypass coefficients for a PEQ band.
|
||||
*
|
||||
* @dname designFlatEq
|
||||
* @return Identity biquad (b0=1, others 0) as in SigmaStudio defaults.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] BiquadCoefficients designFlatEq() noexcept;
|
||||
|
||||
/**
|
||||
* @brief gainDbToLinearFixpoint — map dB to ADAU 8.23 linear gain word.
|
||||
*
|
||||
* @dname gainDbToLinearFixpoint
|
||||
* @param gain Validated gain in dB.
|
||||
* @return 32-bit fixpoint suitable for safeload (0 dB = 0x00800000).
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::int32_t gainDbToLinearFixpoint(GainDb gain) noexcept;
|
||||
|
||||
/**
|
||||
* @brief floatToFixpoint823 — convert a float to ADAU 8.23 fixpoint.
|
||||
*
|
||||
* @dname floatToFixpoint823
|
||||
* @param value Floating value (typically -16..+16 for filter coeffs).
|
||||
* @return Rounded 32-bit fixpoint word.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::int32_t floatToFixpoint823(float value) noexcept;
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @file DspError.hpp
|
||||
* @brief Typed errors for DSP control operations.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
namespace core {
|
||||
|
||||
/**
|
||||
* @brief DspError — failure causes for IDsp operations.
|
||||
*
|
||||
* @dname DspError
|
||||
* @return n/a (type)
|
||||
* @pubstate n/a
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
enum class DspError {
|
||||
NotBooted,
|
||||
SafeloadFailed,
|
||||
InvalidParameter,
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @file EqBandIndex.hpp
|
||||
* @brief Strong index for ADAU1701 Param EQ1 bands (0–5).
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "core/ParseError.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
|
||||
namespace core {
|
||||
|
||||
/**
|
||||
* @brief EqBandIndex — validated index into the 6-band Param EQ1 module.
|
||||
*
|
||||
* @dname EqBandIndex
|
||||
* @return n/a (type)
|
||||
* @pubstate Owns index_ in 0..kBandCount-1 after construction.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
class EqBandIndex {
|
||||
public:
|
||||
/** Number of parametric EQ bands in the SigmaStudio export. */
|
||||
static constexpr std::uint8_t kBandCount = 6U;
|
||||
|
||||
/**
|
||||
* @brief tryFromIndex — validate a band index at the boundary.
|
||||
*
|
||||
* @dname tryFromIndex
|
||||
* @param index Untrusted band index from JSON input.
|
||||
* @return EqBandIndex on success, or ParseError::MissingField.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] static std::expected<EqBandIndex, ParseError> tryFromIndex(
|
||||
std::uint32_t index) noexcept;
|
||||
|
||||
/**
|
||||
* @brief value — read the stored band index.
|
||||
*
|
||||
* @dname value
|
||||
* @return Band index 0..5.
|
||||
* @pubstate reads index_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::uint8_t value() const noexcept;
|
||||
|
||||
private:
|
||||
explicit EqBandIndex(std::uint8_t index) noexcept;
|
||||
|
||||
std::uint8_t index_;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @file EqBandSettings.hpp
|
||||
* @brief Per-band EQ settings for EqProfile.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "core/FrequencyHz.hpp"
|
||||
#include "core/GainDb.hpp"
|
||||
|
||||
namespace core {
|
||||
|
||||
/**
|
||||
* @brief EqBandSettings — gain, centre frequency, and Q for one PEQ band.
|
||||
*
|
||||
* @dname EqBandSettings
|
||||
* @return n/a (type)
|
||||
* @pubstate Plain value type; q in 0.2..10.0 when validated via EqProfile.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
struct EqBandSettings {
|
||||
GainDb gain; ///< Band gain in dB.
|
||||
FrequencyHz center; ///< Centre frequency in Hz.
|
||||
float q; ///< Quality factor.
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @file EqProfile.hpp
|
||||
* @brief Six-band parametric EQ profile for the ADAU1701.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "core/EqBandIndex.hpp"
|
||||
#include "core/EqBandSettings.hpp"
|
||||
#include "core/FrequencyHz.hpp"
|
||||
#include "core/GainDb.hpp"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace core {
|
||||
|
||||
/**
|
||||
* @brief EqProfile — six PEQ bands persisted and applied via AudioService.
|
||||
*
|
||||
* @dname EqProfile
|
||||
* @return n/a (type)
|
||||
* @pubstate Owns bands_ array; default centres match typical listening curve.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
class EqProfile {
|
||||
public:
|
||||
/**
|
||||
* @brief factoryDefault — flat EQ at standard centre frequencies.
|
||||
*
|
||||
* @dname factoryDefault
|
||||
* @return EqProfile with 0 dB on all bands.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] static EqProfile factoryDefault() noexcept;
|
||||
|
||||
/**
|
||||
* @brief band — read settings for one PEQ band.
|
||||
*
|
||||
* @dname band
|
||||
* @param index Band index 0..5.
|
||||
* @return Const reference to band settings.
|
||||
* @pubstate reads bands_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] const EqBandSettings& band(EqBandIndex index) const noexcept;
|
||||
|
||||
/**
|
||||
* @brief setBand — replace settings for one PEQ band.
|
||||
*
|
||||
* @dname setBand
|
||||
* @param index Band index 0..5.
|
||||
* @param settings New gain, centre, and Q.
|
||||
* @pubstate writes bands_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
void setBand(EqBandIndex index, EqBandSettings settings) noexcept;
|
||||
|
||||
/**
|
||||
* @brief bands — access the full band array.
|
||||
*
|
||||
* @dname bands
|
||||
* @return Const reference to all six band settings.
|
||||
* @pubstate reads bands_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] const std::array<EqBandSettings, EqBandIndex::kBandCount>&
|
||||
bands() const noexcept;
|
||||
|
||||
private:
|
||||
explicit EqProfile(std::array<EqBandSettings, EqBandIndex::kBandCount> bands)
|
||||
noexcept;
|
||||
|
||||
std::array<EqBandSettings, EqBandIndex::kBandCount> bands_;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file FrequencyHz.hpp
|
||||
* @brief Strong type for audio centre frequencies (EQ bands).
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "core/ParseError.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
|
||||
namespace core {
|
||||
|
||||
/**
|
||||
* @brief FrequencyHz — validated centre frequency for parametric EQ.
|
||||
*
|
||||
* @dname FrequencyHz
|
||||
* @return n/a (type)
|
||||
* @pubstate Owns hz_ within 20..20\,000 Hz (48 kHz SigmaStudio project).
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
class FrequencyHz {
|
||||
public:
|
||||
/** Minimum EQ centre frequency (Hz). */
|
||||
static constexpr std::uint32_t kMinHz = 20U;
|
||||
/** Maximum EQ centre frequency (Hz). */
|
||||
static constexpr std::uint32_t kMaxHz = 20000U;
|
||||
|
||||
/**
|
||||
* @brief tryFromHz — validate a centre frequency at the boundary.
|
||||
*
|
||||
* @dname tryFromHz
|
||||
* @param hz Untrusted frequency in hertz.
|
||||
* @return FrequencyHz on success, or ParseError::MissingField.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] static std::expected<FrequencyHz, ParseError> tryFromHz(
|
||||
std::uint32_t hz) noexcept;
|
||||
|
||||
/**
|
||||
* @brief value — read the stored frequency in hertz.
|
||||
*
|
||||
* @dname value
|
||||
* @return Validated centre frequency in Hz.
|
||||
* @pubstate reads hz_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::uint32_t value() const noexcept;
|
||||
|
||||
private:
|
||||
explicit FrequencyHz(std::uint32_t hz) noexcept;
|
||||
|
||||
std::uint32_t hz_;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @file GainDb.hpp
|
||||
* @brief Strong type for decibel gain/attenuation values.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "core/ParseError.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <expected>
|
||||
|
||||
namespace core {
|
||||
|
||||
/**
|
||||
* @brief GainDb — validated gain in decibels for DSP volume and EQ.
|
||||
*
|
||||
* @dname GainDb
|
||||
* @return n/a (type)
|
||||
* @pubstate Owns db_ within kMinDb..kMaxDb after construction.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
class GainDb {
|
||||
public:
|
||||
/** Minimum attenuation (effectively muted on the linear path). */
|
||||
static constexpr float kMinDb = -96.0F;
|
||||
/** Maximum boost allowed on the DSP path. */
|
||||
static constexpr float kMaxDb = 12.0F;
|
||||
|
||||
/**
|
||||
* @brief tryFromDb — validate a gain value at the HTTP boundary.
|
||||
*
|
||||
* @dname tryFromDb
|
||||
* @param db Untrusted gain in decibels.
|
||||
* @return GainDb on success, or ParseError::MissingField.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] static std::expected<GainDb, ParseError> tryFromDb(
|
||||
float db) noexcept;
|
||||
|
||||
/**
|
||||
* @brief zero — unity gain (0 dB).
|
||||
*
|
||||
* @dname zero
|
||||
* @return GainDb at 0 dB.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] static GainDb zero() noexcept;
|
||||
|
||||
/**
|
||||
* @brief value — read the stored gain in decibels.
|
||||
*
|
||||
* @dname value
|
||||
* @return Validated gain in dB.
|
||||
* @pubstate reads db_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] float value() const noexcept;
|
||||
|
||||
/**
|
||||
* @brief linear — convert to linear amplitude (not dB).
|
||||
*
|
||||
* @dname linear
|
||||
* @return Linear gain factor (1.0 at 0 dB).
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] float linear() const noexcept;
|
||||
|
||||
private:
|
||||
explicit GainDb(float db) noexcept;
|
||||
|
||||
float db_;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @file IAudioProfileStore.hpp
|
||||
* @brief Persistence boundary for user audio configuration.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "core/AudioProfile.hpp"
|
||||
#include "core/StoreError.hpp"
|
||||
|
||||
#include <expected>
|
||||
|
||||
namespace core {
|
||||
|
||||
/**
|
||||
* @brief IAudioProfileStore — load/save AudioProfile in NVS (non-secret).
|
||||
*
|
||||
* @dname IAudioProfileStore
|
||||
* @return n/a (type)
|
||||
* @pubstate Shell implementations own NVS handles; core uses fakes in tests.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
class IAudioProfileStore {
|
||||
public:
|
||||
virtual ~IAudioProfileStore() = default;
|
||||
|
||||
/**
|
||||
* @brief hasProfile — check whether a saved profile exists.
|
||||
*
|
||||
* @dname hasProfile
|
||||
* @return true when loadProfile would succeed.
|
||||
* @pubstate reads backing storage via implementation.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] virtual bool hasProfile() const = 0;
|
||||
|
||||
/**
|
||||
* @brief saveProfile — persist a validated audio profile.
|
||||
*
|
||||
* @dname saveProfile
|
||||
* @param profile User mixer/EQ/master snapshot.
|
||||
* @return Ok on success, or StoreError::IoFailed.
|
||||
* @pubstate writes backing storage via implementation.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] virtual std::expected<void, StoreError> saveProfile(
|
||||
const AudioProfile& profile) = 0;
|
||||
|
||||
/**
|
||||
* @brief loadProfile — read the stored audio profile.
|
||||
*
|
||||
* @dname loadProfile
|
||||
* @return AudioProfile on success, or StoreError::NotFound / IoFailed.
|
||||
* @pubstate reads backing storage via implementation.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] virtual std::expected<AudioProfile, StoreError> loadProfile()
|
||||
const = 0;
|
||||
|
||||
/**
|
||||
* @brief clearProfile — erase the stored audio profile.
|
||||
*
|
||||
* @dname clearProfile
|
||||
* @return Ok on success, or StoreError::IoFailed.
|
||||
* @pubstate clears backing storage via implementation.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] virtual std::expected<void, StoreError> clearProfile() = 0;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @file IDsp.hpp
|
||||
* @brief Abstract ADAU1701 DSP control boundary (host-testable).
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "core/AudioProfile.hpp"
|
||||
#include "core/DspError.hpp"
|
||||
#include "core/EqBandIndex.hpp"
|
||||
#include "core/FrequencyHz.hpp"
|
||||
#include "core/GainDb.hpp"
|
||||
#include "core/MixSource.hpp"
|
||||
#include "core/MixerState.hpp"
|
||||
#include "core/EqProfile.hpp"
|
||||
|
||||
#include <expected>
|
||||
|
||||
namespace core {
|
||||
|
||||
/**
|
||||
* @brief IDsp — hardware abstraction for ADAU1701 runtime control.
|
||||
*
|
||||
* @dname IDsp
|
||||
* @return n/a (type)
|
||||
* @pubstate Implemented by adau1701::Adau1701Dsp on device; fakes in tests.
|
||||
*
|
||||
* All parameter updates use safeload on the implementation side.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
class IDsp {
|
||||
public:
|
||||
virtual ~IDsp() = default;
|
||||
|
||||
/**
|
||||
* @brief applyProfile — safeload mixer, EQ, and master from a snapshot.
|
||||
*
|
||||
* @dname applyProfile
|
||||
* @param profile Validated user configuration.
|
||||
* @return Ok on success, or DspError.
|
||||
* @pubstate writes ADAU1701 parameter RAM via safeload.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] virtual std::expected<void, DspError> applyProfile(
|
||||
const AudioProfile& profile) = 0;
|
||||
|
||||
/**
|
||||
* @brief applyMixer — safeload input and stereo-mixer gains.
|
||||
*
|
||||
* @dname applyMixer
|
||||
* @param mixer Per-source and St Mixer1 levels.
|
||||
* @return Ok on success, or DspError.
|
||||
* @pubstate writes ADAU1701 parameter RAM via safeload.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] virtual std::expected<void, DspError> applyMixer(
|
||||
const MixerState& mixer) = 0;
|
||||
|
||||
/**
|
||||
* @brief applyEq — safeload all six PEQ bands.
|
||||
*
|
||||
* @dname applyEq
|
||||
* @param eq Six-band parametric EQ settings.
|
||||
* @return Ok on success, or DspError.
|
||||
* @pubstate writes ADAU1701 parameter RAM via safeload.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] virtual std::expected<void, DspError> applyEq(
|
||||
const EqProfile& eq) = 0;
|
||||
|
||||
/**
|
||||
* @brief setInputVolume — safeload one input path (Si4684 or ESP32).
|
||||
*
|
||||
* @dname setInputVolume
|
||||
* @param source Tuner or ESP32 I2S path.
|
||||
* @param left Left channel gain.
|
||||
* @param right Right channel gain.
|
||||
* @return Ok on success, or DspError.
|
||||
* @pubstate writes ADAU1701 parameter RAM via safeload.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] virtual std::expected<void, DspError> setInputVolume(
|
||||
MixSource source, GainDb left, GainDb right) = 0;
|
||||
|
||||
/**
|
||||
* @brief setMasterVolume — safeload Multiple 1 master output gain.
|
||||
*
|
||||
* @dname setMasterVolume
|
||||
* @param left Left master gain.
|
||||
* @param right Right master gain.
|
||||
* @return Ok on success, or DspError.
|
||||
* @pubstate writes ADAU1701 parameter RAM via safeload.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] virtual std::expected<void, DspError> setMasterVolume(
|
||||
GainDb left, GainDb right) = 0;
|
||||
|
||||
/**
|
||||
* @brief setEqBand — design and safeload one PEQ band.
|
||||
*
|
||||
* @dname setEqBand
|
||||
* @param band Band index 0..5.
|
||||
* @param gain Band gain in dB.
|
||||
* @param center Centre frequency.
|
||||
* @param q Quality factor.
|
||||
* @return Ok on success, or DspError.
|
||||
* @pubstate writes five coefficients via a single safeload transfer.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] virtual std::expected<void, DspError> setEqBand(
|
||||
EqBandIndex band, GainDb gain, FrequencyHz center, float q) = 0;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file MixSource.hpp
|
||||
* @brief ADAU1701 input path selector for per-source volume control.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
namespace core {
|
||||
|
||||
/**
|
||||
* @brief MixSource — Si4684 tuner or ESP32 I2S input on the ADAU1701.
|
||||
*
|
||||
* @dname MixSource
|
||||
* @return n/a (type)
|
||||
* @pubstate n/a
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
enum class MixSource {
|
||||
Si4684,
|
||||
Esp32,
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @file MixerState.hpp
|
||||
* @brief ADAU1701 input and stereo-mixer gain snapshot.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "core/GainDb.hpp"
|
||||
|
||||
namespace core {
|
||||
|
||||
/**
|
||||
* @brief MixerState — per-source and St Mixer1 levels for the ADAU1701.
|
||||
*
|
||||
* @dname MixerState
|
||||
* @return n/a (type)
|
||||
* @pubstate Immutable snapshot applied by AudioService via IDsp.
|
||||
*
|
||||
* Maps to SigmaStudio modules Si4674, ESP32, and St Mixer1.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
struct MixerState {
|
||||
GainDb si4684Left; ///< Si4674 volume control, left channel.
|
||||
GainDb si4684Right; ///< Si4674 volume control, right channel.
|
||||
GainDb esp32Left; ///< ESP32 volume control, left channel.
|
||||
GainDb esp32Right; ///< ESP32 volume control, right channel.
|
||||
GainDb mixLeft; ///< St Mixer1 left blend level.
|
||||
GainDb mixRight; ///< St Mixer1 right blend level.
|
||||
|
||||
/**
|
||||
* @brief factoryDefault — unity gains on all paths (radio-first mix).
|
||||
*
|
||||
* @dname factoryDefault
|
||||
* @return MixerState with 0 dB on every control.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] static MixerState factoryDefault() noexcept;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file AudioProfile.cpp
|
||||
* @brief AudioProfile factory defaults.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
|
||||
#include "core/AudioProfile.hpp"
|
||||
|
||||
namespace core {
|
||||
|
||||
AudioProfile AudioProfile::factoryDefault() noexcept
|
||||
{
|
||||
const GainDb unity = GainDb::zero();
|
||||
return AudioProfile{
|
||||
.mixer = MixerState::factoryDefault(),
|
||||
.eq = EqProfile::factoryDefault(),
|
||||
.masterLeft = unity,
|
||||
.masterRight = unity,
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @file AudioProfileJson.cpp
|
||||
* @brief AudioProfileJson implementation.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
|
||||
#include "core/AudioProfileJson.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <sstream>
|
||||
|
||||
namespace core {
|
||||
|
||||
namespace {
|
||||
|
||||
[[nodiscard]] bool extractJsonFloat(std::string_view json,
|
||||
std::string_view key,
|
||||
float& out)
|
||||
{
|
||||
const std::string needle =
|
||||
std::string("\"") + std::string(key) + "\":";
|
||||
const std::size_t start = json.find(needle);
|
||||
if (start == std::string_view::npos) {
|
||||
return false;
|
||||
}
|
||||
const std::size_t valueStart = start + needle.size();
|
||||
char* end = nullptr;
|
||||
out = std::strtof(json.data() + valueStart, &end);
|
||||
return end != json.data() + valueStart;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool extractJsonUint(std::string_view json,
|
||||
std::string_view key,
|
||||
unsigned long& out)
|
||||
{
|
||||
const std::string needle =
|
||||
std::string("\"") + std::string(key) + "\":";
|
||||
const std::size_t start = json.find(needle);
|
||||
if (start == std::string_view::npos) {
|
||||
return false;
|
||||
}
|
||||
const std::size_t valueStart = start + needle.size();
|
||||
char* end = nullptr;
|
||||
out = std::strtoul(json.data() + valueStart, &end, 10);
|
||||
return end != json.data() + valueStart;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::expected<GainDb, ParseError> parseGainField(
|
||||
std::string_view json, std::string_view key)
|
||||
{
|
||||
float db = 0.0F;
|
||||
if (!extractJsonFloat(json, key, db)) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
return GainDb::tryFromDb(db);
|
||||
}
|
||||
|
||||
[[nodiscard]] std::expected<MixerState, ParseError> parseMixerJson(
|
||||
std::string_view json)
|
||||
{
|
||||
const std::size_t mixerStart = json.find("\"mixer\"");
|
||||
if (mixerStart == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
const std::string_view mixer = json.substr(mixerStart);
|
||||
|
||||
const auto si4684Left = parseGainField(mixer, "si4684_left_db");
|
||||
const auto si4684Right = parseGainField(mixer, "si4684_right_db");
|
||||
const auto esp32Left = parseGainField(mixer, "esp32_left_db");
|
||||
const auto esp32Right = parseGainField(mixer, "esp32_right_db");
|
||||
const auto mixLeft = parseGainField(mixer, "mix_left_db");
|
||||
const auto mixRight = parseGainField(mixer, "mix_right_db");
|
||||
|
||||
if (!si4684Left || !si4684Right || !esp32Left || !esp32Right || !mixLeft
|
||||
|| !mixRight) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
|
||||
return MixerState{
|
||||
.si4684Left = *si4684Left,
|
||||
.si4684Right = *si4684Right,
|
||||
.esp32Left = *esp32Left,
|
||||
.esp32Right = *esp32Right,
|
||||
.mixLeft = *mixLeft,
|
||||
.mixRight = *mixRight,
|
||||
};
|
||||
}
|
||||
|
||||
[[nodiscard]] std::expected<EqProfile, ParseError> parseEqJson(
|
||||
std::string_view json)
|
||||
{
|
||||
EqProfile profile = EqProfile::factoryDefault();
|
||||
const std::size_t eqStart = json.find("\"eq\"");
|
||||
if (eqStart == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
|
||||
std::string_view rest = json.substr(eqStart);
|
||||
for (std::uint8_t band = 0; band < EqBandIndex::kBandCount; ++band) {
|
||||
const std::size_t objStart = rest.find('{');
|
||||
if (objStart == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
const std::size_t objEnd = rest.find('}', objStart);
|
||||
if (objEnd == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
const std::string_view obj = rest.substr(objStart, objEnd - objStart + 1U);
|
||||
|
||||
float gainDb = 0.0F;
|
||||
unsigned long centerHz = 0U;
|
||||
float q = 0.0F;
|
||||
if (!extractJsonFloat(obj, "gain_db", gainDb)
|
||||
|| !extractJsonUint(obj, "center_hz", centerHz)
|
||||
|| !extractJsonFloat(obj, "q", q)) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
if (q < 0.2F || q > 10.0F) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
|
||||
const auto gain = GainDb::tryFromDb(gainDb);
|
||||
const auto center = FrequencyHz::tryFromHz(
|
||||
static_cast<std::uint32_t>(centerHz));
|
||||
const auto index = EqBandIndex::tryFromIndex(band);
|
||||
if (!gain || !center || !index) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
|
||||
profile.setBand(*index, EqBandSettings{
|
||||
.gain = *gain,
|
||||
.center = *center,
|
||||
.q = q,
|
||||
});
|
||||
rest = rest.substr(objEnd + 1U);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string serializeAudioProfileJson(const AudioProfile& profile)
|
||||
{
|
||||
std::ostringstream out;
|
||||
const auto& m = profile.mixer;
|
||||
out << "{\"mixer\":{"
|
||||
<< "\"si4684_left_db\":" << m.si4684Left.value() << ','
|
||||
<< "\"si4684_right_db\":" << m.si4684Right.value() << ','
|
||||
<< "\"esp32_left_db\":" << m.esp32Left.value() << ','
|
||||
<< "\"esp32_right_db\":" << m.esp32Right.value() << ','
|
||||
<< "\"mix_left_db\":" << m.mixLeft.value() << ','
|
||||
<< "\"mix_right_db\":" << m.mixRight.value() << "},"
|
||||
<< "\"master\":{"
|
||||
<< "\"left_db\":" << profile.masterLeft.value() << ','
|
||||
<< "\"right_db\":" << profile.masterRight.value() << "},"
|
||||
<< "\"eq\":[";
|
||||
|
||||
const auto& bands = profile.eq.bands();
|
||||
for (std::size_t i = 0; i < bands.size(); ++i) {
|
||||
if (i > 0U) {
|
||||
out << ',';
|
||||
}
|
||||
const auto& b = bands[i];
|
||||
out << "{\"gain_db\":" << b.gain.value()
|
||||
<< ",\"center_hz\":" << b.center.value() << ",\"q\":" << b.q
|
||||
<< '}';
|
||||
}
|
||||
out << "]}";
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::expected<AudioProfile, ParseError> parseAudioProfileJson(
|
||||
std::string_view json)
|
||||
{
|
||||
if (json.find('{') == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
|
||||
const auto mixer = parseMixerJson(json);
|
||||
if (!mixer) {
|
||||
return std::unexpected(mixer.error());
|
||||
}
|
||||
|
||||
const auto eq = parseEqJson(json);
|
||||
if (!eq) {
|
||||
return std::unexpected(eq.error());
|
||||
}
|
||||
|
||||
const std::size_t masterStart = json.find("\"master\"");
|
||||
if (masterStart == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
const std::string_view master = json.substr(masterStart);
|
||||
const auto masterLeft = parseGainField(master, "left_db");
|
||||
const auto masterRight = parseGainField(master, "right_db");
|
||||
if (!masterLeft || !masterRight) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
|
||||
return AudioProfile{
|
||||
.mixer = *mixer,
|
||||
.eq = *eq,
|
||||
.masterLeft = *masterLeft,
|
||||
.masterRight = *masterRight,
|
||||
};
|
||||
}
|
||||
|
||||
std::string serializeAudioSavedJson()
|
||||
{
|
||||
return R"({"status":"saved"})";
|
||||
}
|
||||
|
||||
std::string serializeAudioErrorJson(std::string_view reason)
|
||||
{
|
||||
std::ostringstream out;
|
||||
out << R"({"status":"error","reason":")" << reason << R"("})";
|
||||
return out.str();
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @file BiquadCoefficients.cpp
|
||||
* @brief BiquadCoefficients fixpoint conversion.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
|
||||
#include "core/BiquadCoefficients.hpp"
|
||||
#include "core/BiquadDesign.hpp"
|
||||
|
||||
namespace core {
|
||||
|
||||
std::array<std::int32_t, 5> BiquadCoefficients::toFixpoint823() const noexcept
|
||||
{
|
||||
return {
|
||||
floatToFixpoint823(b0),
|
||||
floatToFixpoint823(b1),
|
||||
floatToFixpoint823(b2),
|
||||
floatToFixpoint823(a0),
|
||||
floatToFixpoint823(a1),
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @file BiquadDesign.cpp
|
||||
* @brief Biquad coefficient design and ADAU fixpoint helpers.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
|
||||
#include "core/BiquadDesign.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
|
||||
namespace core {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr float kPi = std::numbers::pi_v<float>;
|
||||
|
||||
[[nodiscard]] float clampQ(float q) noexcept
|
||||
{
|
||||
if (q < 0.2F) {
|
||||
return 0.2F;
|
||||
}
|
||||
if (q > 10.0F) {
|
||||
return 10.0F;
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::int32_t floatToFixpoint823(float value) noexcept
|
||||
{
|
||||
const double scaled =
|
||||
static_cast<double>(value) * static_cast<double>(1U << 23);
|
||||
const auto rounded = static_cast<std::int64_t>(std::llround(scaled));
|
||||
return static_cast<std::int32_t>(rounded);
|
||||
}
|
||||
|
||||
std::int32_t gainDbToLinearFixpoint(GainDb gain) noexcept
|
||||
{
|
||||
return floatToFixpoint823(gain.linear());
|
||||
}
|
||||
|
||||
BiquadCoefficients designFlatEq() noexcept
|
||||
{
|
||||
return BiquadCoefficients{
|
||||
.b0 = 1.0F,
|
||||
.b1 = 0.0F,
|
||||
.b2 = 0.0F,
|
||||
.a0 = 0.0F,
|
||||
.a1 = 0.0F,
|
||||
};
|
||||
}
|
||||
|
||||
BiquadCoefficients designPeakingEq(FrequencyHz center, GainDb gain,
|
||||
float q) noexcept
|
||||
{
|
||||
if (std::fabs(gain.value()) < 0.001F) {
|
||||
return designFlatEq();
|
||||
}
|
||||
|
||||
const float qClamped = clampQ(q);
|
||||
const float fs = static_cast<float>(kAdauSampleRateHz);
|
||||
const float f0 = static_cast<float>(center.value());
|
||||
const float a = std::pow(10.0F, gain.value() / 40.0F);
|
||||
const float omega = 2.0F * kPi * f0 / fs;
|
||||
const float sinOmega = std::sin(omega);
|
||||
const float cosOmega = std::cos(omega);
|
||||
const float alpha = sinOmega / (2.0F * qClamped);
|
||||
|
||||
const float b0 = 1.0F + alpha * a;
|
||||
const float b1 = -2.0F * cosOmega;
|
||||
const float b2 = 1.0F - alpha * a;
|
||||
const float a0 = 1.0F + alpha / a;
|
||||
const float a1 = -2.0F * cosOmega;
|
||||
const float a2 = 1.0F - alpha / a;
|
||||
|
||||
return BiquadCoefficients{
|
||||
.b0 = b0 / a0,
|
||||
.b1 = b1 / a0,
|
||||
.b2 = b2 / a0,
|
||||
.a0 = a1 / a0,
|
||||
.a1 = a2 / a0,
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @file EqBandIndex.cpp
|
||||
* @brief EqBandIndex implementation.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
|
||||
#include "core/EqBandIndex.hpp"
|
||||
|
||||
namespace core {
|
||||
|
||||
EqBandIndex::EqBandIndex(std::uint8_t index) noexcept
|
||||
: index_(index)
|
||||
{
|
||||
}
|
||||
|
||||
std::expected<EqBandIndex, ParseError> EqBandIndex::tryFromIndex(
|
||||
std::uint32_t index) noexcept
|
||||
{
|
||||
if (index >= kBandCount) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
return EqBandIndex(static_cast<std::uint8_t>(index));
|
||||
}
|
||||
|
||||
std::uint8_t EqBandIndex::value() const noexcept
|
||||
{
|
||||
return index_;
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file EqProfile.cpp
|
||||
* @brief EqProfile implementation.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
|
||||
#include "core/EqProfile.hpp"
|
||||
|
||||
namespace core {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::array<std::uint32_t, EqBandIndex::kBandCount> kDefaultCenters{
|
||||
40U, 100U, 250U, 1000U, 4000U, 12000U};
|
||||
|
||||
[[nodiscard]] std::array<EqBandSettings, EqBandIndex::kBandCount>
|
||||
makeDefaultBands() noexcept
|
||||
{
|
||||
return std::array<EqBandSettings, EqBandIndex::kBandCount>{
|
||||
EqBandSettings{GainDb::zero(), *FrequencyHz::tryFromHz(kDefaultCenters[0]),
|
||||
1.414F},
|
||||
EqBandSettings{GainDb::zero(), *FrequencyHz::tryFromHz(kDefaultCenters[1]),
|
||||
1.414F},
|
||||
EqBandSettings{GainDb::zero(), *FrequencyHz::tryFromHz(kDefaultCenters[2]),
|
||||
1.414F},
|
||||
EqBandSettings{GainDb::zero(), *FrequencyHz::tryFromHz(kDefaultCenters[3]),
|
||||
1.414F},
|
||||
EqBandSettings{GainDb::zero(), *FrequencyHz::tryFromHz(kDefaultCenters[4]),
|
||||
1.414F},
|
||||
EqBandSettings{GainDb::zero(), *FrequencyHz::tryFromHz(kDefaultCenters[5]),
|
||||
1.414F},
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
EqProfile::EqProfile(
|
||||
std::array<EqBandSettings, EqBandIndex::kBandCount> bands) noexcept
|
||||
: bands_(bands)
|
||||
{
|
||||
}
|
||||
|
||||
EqProfile EqProfile::factoryDefault() noexcept
|
||||
{
|
||||
return EqProfile(makeDefaultBands());
|
||||
}
|
||||
|
||||
const EqBandSettings& EqProfile::band(EqBandIndex index) const noexcept
|
||||
{
|
||||
return bands_[index.value()];
|
||||
}
|
||||
|
||||
void EqProfile::setBand(EqBandIndex index, EqBandSettings settings) noexcept
|
||||
{
|
||||
bands_[index.value()] = settings;
|
||||
}
|
||||
|
||||
const std::array<EqBandSettings, EqBandIndex::kBandCount>& EqProfile::bands()
|
||||
const noexcept
|
||||
{
|
||||
return bands_;
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @file FrequencyHz.cpp
|
||||
* @brief FrequencyHz implementation.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
|
||||
#include "core/FrequencyHz.hpp"
|
||||
|
||||
namespace core {
|
||||
|
||||
FrequencyHz::FrequencyHz(std::uint32_t hz) noexcept
|
||||
: hz_(hz)
|
||||
{
|
||||
}
|
||||
|
||||
std::expected<FrequencyHz, ParseError> FrequencyHz::tryFromHz(
|
||||
std::uint32_t hz) noexcept
|
||||
{
|
||||
if (hz < kMinHz || hz > kMaxHz) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
return FrequencyHz(hz);
|
||||
}
|
||||
|
||||
std::uint32_t FrequencyHz::value() const noexcept
|
||||
{
|
||||
return hz_;
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @file GainDb.cpp
|
||||
* @brief GainDb implementation.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
|
||||
#include "core/GainDb.hpp"
|
||||
|
||||
namespace core {
|
||||
|
||||
GainDb::GainDb(float db) noexcept
|
||||
: db_(db)
|
||||
{
|
||||
}
|
||||
|
||||
std::expected<GainDb, ParseError> GainDb::tryFromDb(float db) noexcept
|
||||
{
|
||||
if (db < kMinDb || db > kMaxDb) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
return GainDb(db);
|
||||
}
|
||||
|
||||
GainDb GainDb::zero() noexcept
|
||||
{
|
||||
return GainDb(0.0F);
|
||||
}
|
||||
|
||||
float GainDb::value() const noexcept
|
||||
{
|
||||
return db_;
|
||||
}
|
||||
|
||||
float GainDb::linear() const noexcept
|
||||
{
|
||||
return std::pow(10.0F, db_ / 20.0F);
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @file MixerState.cpp
|
||||
* @brief MixerState factory defaults.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
|
||||
#include "core/MixerState.hpp"
|
||||
|
||||
namespace core {
|
||||
|
||||
MixerState MixerState::factoryDefault() noexcept
|
||||
{
|
||||
const GainDb unity = GainDb::zero();
|
||||
return MixerState{
|
||||
.si4684Left = unity,
|
||||
.si4684Right = unity,
|
||||
.esp32Left = unity,
|
||||
.esp32Right = unity,
|
||||
.mixLeft = unity,
|
||||
.mixRight = unity,
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
@@ -22,6 +22,15 @@ add_library(digiradio_core STATIC
|
||||
"${CORE_SRC_DIR}/EmbeddedBlobReader.cpp"
|
||||
"${CORE_SRC_DIR}/TunerJson.cpp"
|
||||
"${CORE_SRC_DIR}/FrequencyKHz.cpp"
|
||||
"${CORE_SRC_DIR}/GainDb.cpp"
|
||||
"${CORE_SRC_DIR}/FrequencyHz.cpp"
|
||||
"${CORE_SRC_DIR}/EqBandIndex.cpp"
|
||||
"${CORE_SRC_DIR}/BiquadCoefficients.cpp"
|
||||
"${CORE_SRC_DIR}/BiquadDesign.cpp"
|
||||
"${CORE_SRC_DIR}/MixerState.cpp"
|
||||
"${CORE_SRC_DIR}/EqProfile.cpp"
|
||||
"${CORE_SRC_DIR}/AudioProfile.cpp"
|
||||
"${CORE_SRC_DIR}/AudioProfileJson.cpp"
|
||||
)
|
||||
target_include_directories(digiradio_core PUBLIC
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../include"
|
||||
@@ -46,3 +55,11 @@ add_test(NAME tuner_json_test COMMAND tuner_json_test)
|
||||
add_executable(frequency_khz_test frequency_khz_test.cpp)
|
||||
target_link_libraries(frequency_khz_test PRIVATE digiradio_core)
|
||||
add_test(NAME frequency_khz_test COMMAND frequency_khz_test)
|
||||
|
||||
add_executable(biquad_design_test biquad_design_test.cpp)
|
||||
target_link_libraries(biquad_design_test PRIVATE digiradio_core)
|
||||
add_test(NAME biquad_design_test COMMAND biquad_design_test)
|
||||
|
||||
add_executable(audio_profile_json_test audio_profile_json_test.cpp)
|
||||
target_link_libraries(audio_profile_json_test PRIVATE digiradio_core)
|
||||
add_test(NAME audio_profile_json_test COMMAND audio_profile_json_test)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @file audio_profile_json_test.cpp
|
||||
* @brief Host tests for AudioProfile JSON round-trip.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
|
||||
#include "core/AudioProfile.hpp"
|
||||
#include "core/AudioProfileJson.hpp"
|
||||
#include "core/EqBandIndex.hpp"
|
||||
#include "core/FrequencyHz.hpp"
|
||||
#include "core/GainDb.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
[[nodiscard]] int runRoundTripTest()
|
||||
{
|
||||
core::AudioProfile profile = core::AudioProfile::factoryDefault();
|
||||
const auto band = core::EqBandIndex::tryFromIndex(2U);
|
||||
const auto gain = core::GainDb::tryFromDb(3.0F);
|
||||
const auto center = core::FrequencyHz::tryFromHz(500U);
|
||||
if (!band || !gain || !center) {
|
||||
std::cerr << "test setup failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
profile.eq.setBand(*band,
|
||||
core::EqBandSettings{
|
||||
.gain = *gain,
|
||||
.center = *center,
|
||||
.q = 2.0F,
|
||||
});
|
||||
|
||||
const std::string json = core::serializeAudioProfileJson(profile);
|
||||
const auto parsed = core::parseAudioProfileJson(json);
|
||||
if (!parsed) {
|
||||
std::cerr << "parse failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
const auto& b = parsed->eq.band(*band);
|
||||
if (b.gain.value() != 3.0F || b.center.value() != 500U
|
||||
|| b.q != 2.0F) {
|
||||
std::cerr << "round-trip mismatch\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
{
|
||||
if (runRoundTripTest() != EXIT_SUCCESS) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @file biquad_design_test.cpp
|
||||
* @brief Host tests for ADAU biquad design and fixpoint conversion.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
|
||||
#include "core/BiquadDesign.hpp"
|
||||
#include "core/FrequencyHz.hpp"
|
||||
#include "core/GainDb.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
[[nodiscard]] int runUnityGainFixpointTest()
|
||||
{
|
||||
const auto gain = core::GainDb::zero();
|
||||
const std::int32_t fix = core::gainDbToLinearFixpoint(gain);
|
||||
if (fix != 0x00800000) {
|
||||
std::cerr << "expected 0 dB fixpoint 0x00800000, got 0x"
|
||||
<< std::hex << fix << std::dec << '\n';
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
[[nodiscard]] int runFlatBiquadTest()
|
||||
{
|
||||
const core::BiquadCoefficients flat = core::designFlatEq();
|
||||
if (std::fabs(flat.b0 - 1.0F) > 0.001F || std::fabs(flat.b1) > 0.001F
|
||||
|| std::fabs(flat.b2) > 0.001F || std::fabs(flat.a0) > 0.001F
|
||||
|| std::fabs(flat.a1) > 0.001F) {
|
||||
std::cerr << "flat biquad coefficients unexpected\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
const auto center = core::FrequencyHz::tryFromHz(1000U);
|
||||
const auto zero = core::GainDb::zero();
|
||||
const core::BiquadCoefficients peaking =
|
||||
core::designPeakingEq(*center, zero, 1.414F);
|
||||
if (std::fabs(peaking.b0 - 1.0F) > 0.001F) {
|
||||
std::cerr << "0 dB peaking should match flat b0\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
{
|
||||
if (runUnityGainFixpointTest() != EXIT_SUCCESS) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (runFlatBiquadTest() != EXIT_SUCCESS) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
Reference in New Issue
Block a user