Fix root cause of audio profile never persisting; document ADAU1701 registers

Root cause (found via SigmaStudio firmware analysis requested this
session): NVS was initialized AFTER HardwareBootstrap::boot(), which
internally calls AudioService::loadAndApply() to restore the saved
mixer/EQ/master-volume profile. Every nvs_open() inside
NvsAudioProfileStore::hasProfile()/loadProfile() failed with
ESP_ERR_NVS_NOT_INITIALIZED (0x1101), silently swallowed as "no saved
profile" -- the audio profile was never actually restored on any boot,
regardless of how many times it was saved via PUT /api/audio/profile.
Fixed by moving secure_store::initEncryptedStorage() to the top of
app_main(), before HardwareBootstrap::boot() (nvs_flash_init() has no
hardware dependency, so this is safe).

Second, related bug: HardwareBootstrap::boot() called
AudioService::applyRadioFirstMix() unconditionally right after
loadAndApply(), discarding any just-restored mixer/master values on
every boot. loadAndApply() now returns whether it actually restored a
profile from NVS; the radio-first fallback only applies when nothing
was saved.

Verified live: a distinct mixer+master+5-EQ-band test pattern now
survives a full reboot exactly as saved (previously always reset to
factory default). DAB/FM/BT unaffected.

Also: added a "locked" flag per EQ band in the audio profile JSON --
band 0 is always locked (fixed high-pass, Adau1701Driver::applyEq()
never safeloads it) and bands 1-2/3-5 are locked whenever
bass_level/stereo_level is active (core::applyEnhancementsToEq()
overwrites them with formula-derived values). This was previously
undiscoverable from the API -- GET echoed back the stored, inert value
with no indication it wasn't what was actually playing.

Full register-by-register analysis of the compiled SigmaStudio program
(signal chain, all 74 Parameter RAM addresses grouped by function, HTTP
API mapping, every endpoint tested live) in
docs/adau1701-sigmastudio-analysis.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 15:52:38 +02:00
co-authored by Claude Sonnet 5
parent a7a5311c2c
commit af51948912
7 changed files with 287 additions and 18 deletions
@@ -220,6 +220,24 @@ std::string serializeAudioProfileJson(const AudioProfile& profile)
<< "\"right_db\":" << profile.masterRight.value() << "},"
<< "\"eq\":[";
// Per-band "locked" flag, added 2026-08-24: band 0 is a fixed high-pass
// Adau1701Driver::applyEq() never safeloads (SigmaStudio band 1, ST0 in
// the compiled program -- whatever gain_db/center_hz/q is stored/sent
// for it has zero audible effect, always). Bands 1-2 are overwritten by
// core::applyEnhancementsToEq() with formula-derived values whenever
// bass_level > 0; bands 3-5 likewise whenever stereo_level > 0 -- a
// manual edit to those bands is silently inaudible while the
// corresponding enhancement is active. This was previously
// undiscoverable from the API response (GET echoed back the stored,
// inert value with no indication it wasn't what was actually playing);
// "locked":true now tells a client to grey out that slider instead of
// letting the user "fix" a value that can't take effect.
const bool bassLocks = profile.enhancements.bass.value() > 0U;
const bool stereoLocks = profile.enhancements.stereo.value() > 0U;
const bool locked[EqBandIndex::kBandCount] = {
true, bassLocks, bassLocks, stereoLocks, stereoLocks, stereoLocks,
};
const auto& bands = profile.eq.bands();
for (std::size_t i = 0; i < bands.size(); ++i) {
if (i > 0U) {
@@ -228,6 +246,7 @@ std::string serializeAudioProfileJson(const AudioProfile& profile)
const auto& b = bands[i];
out << "{\"gain_db\":" << b.gain.value()
<< ",\"center_hz\":" << b.center.value() << ",\"q\":" << b.q
<< ",\"locked\":" << (locked[i] ? "true" : "false")
<< '}';
}
out << "],\"enhancements\":{"
@@ -37,7 +37,14 @@ constexpr char kProfileKey[] = "audio_profile";
bool NvsAudioProfileStore::hasProfile() const
{
nvs_handle_t handle = 0;
if (nvs_open(kNamespace, NVS_READONLY, &handle) != ESP_OK) {
const esp_err_t openErr = nvs_open(kNamespace, NVS_READONLY, &handle);
if (openErr != ESP_OK) {
// ESP_ERR_NVS_NOT_INITIALIZED (0x1101) here means this was called
// before secure_store::initEncryptedStorage() -- see 2026-08-24 fix
// in main.cpp's app_main() boot order (NVS must init before
// AudioService::loadAndApply(), which calls this).
ESP_LOGW(kTag, "hasProfile: nvs_open failed (0x%x)",
static_cast<unsigned>(openErr));
return false;
}
@@ -58,16 +58,25 @@ public:
* @brief loadAndApply — restore saved profile or factory default.
*
* @dname loadAndApply
* @return Ok on success, or DspError from IDsp.
* @return true if a saved profile was restored from the store, false
* if none was found and AudioProfile::factoryDefault() was
* applied instead; DspError from IDsp on safeload failure.
* @pubstate updates profile_ and safeloads the ADAU1701.
*
* Call once after ADAU1701 boot. When no profile is stored, applies
* AudioProfile::factoryDefault() without writing NVS.
* Call once after NVS init (and ADAU1701 boot). Requires NVS to already
* be initialized -- see the 2026-08-24 fix in main.cpp's app_main(),
* which moved secure_store::initEncryptedStorage() ahead of
* HardwareBootstrap::boot() specifically so this call can see a
* previously-saved profile instead of always silently falling back to
* default. Callers that want a sensible mixer/master fallback when
* nothing was saved (see HardwareBootstrap::boot()'s
* applyRadioFirstMix() call) should check the returned bool rather than
* unconditionally overwriting whatever this restored.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, core::DspError> loadAndApply();
[[nodiscard]] std::expected<bool, core::DspError> loadAndApply();
/**
* @brief currentProfile — read the in-memory profile snapshot.
@@ -39,18 +39,26 @@ AudioService::AudioService(core::IDsp& dsp, core::IAudioProfileStore* store)
{
}
std::expected<void, core::DspError> AudioService::loadAndApply()
std::expected<bool, core::DspError> AudioService::loadAndApply()
{
bool restored = false;
if (store_ != nullptr && store_->hasProfile()) {
if (auto loaded = store_->loadProfile(); loaded) {
profile_ = *loaded;
restored = true;
ESP_LOGI(kTag, "audio profile loaded from NVS");
} else {
ESP_LOGW(kTag, "audio profile present but failed to load — "
"using factory default");
}
} else {
ESP_LOGI(kTag, "no saved audio profile — using factory default");
}
if (auto applied = applyProfileToDsp(profile_); !applied) {
return std::unexpected(core::DspError::SafeloadFailed);
}
return {};
return restored;
}
const core::AudioProfile& AudioService::currentProfile() const noexcept