diff --git a/Software/components/core/CMakeLists.txt b/Software/components/core/CMakeLists.txt index e34179c..4a9dc52 100644 --- a/Software/components/core/CMakeLists.txt +++ b/Software/components/core/CMakeLists.txt @@ -12,6 +12,7 @@ idf_component_register( "src/WifiScanJson.cpp" "src/EmbeddedBlobReader.cpp" "src/TunerJson.cpp" + "src/DspParamJson.cpp" "src/FrequencyKHz.cpp" "src/GainDb.cpp" "src/FrequencyHz.cpp" diff --git a/Software/components/core/include/core/DspParamJson.hpp b/Software/components/core/include/core/DspParamJson.hpp new file mode 100644 index 0000000..5362833 --- /dev/null +++ b/Software/components/core/include/core/DspParamJson.hpp @@ -0,0 +1,95 @@ +/** + * @file DspParamJson.hpp + * @brief JSON parse/serialise for generic ADAU1701 parameter access (pure core). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-08-18 + */ +#pragma once + +#include "core/ParseError.hpp" + +#include +#include +#include +#include + +namespace core { + +/** + * @brief DspParamWriteRequest — parsed PUT /api/dsp/param body. + * + * @dname DspParamWriteRequest + * @return n/a (type) + * @pubstate Plain DTO filled by parseDspParamWriteJson at the HTTP boundary. + * + * @author Michele Bigi + * @date 2026-08-18 + */ +struct DspParamWriteRequest { + std::string name; ///< Cell name, looked up against the driver's table. + float value; ///< SigmaStudio floating coefficient to write. +}; + +/** + * @brief DspParamInfo — one discoverable Parameter RAM cell. + * + * @dname DspParamInfo + * @return n/a (type) + * @pubstate Plain DTO; caller supplies the table (core stays hardware-agnostic). + * + * @author Michele Bigi + * @date 2026-08-18 + */ +struct DspParamInfo { + std::string_view name; ///< Cell name as exported by SigmaStudio. + unsigned address; ///< Parameter RAM address for safeload writes. +}; + +/** + * @brief parseDspParamWriteJson — validate PUT /api/dsp/param body. + * + * @dname parseDspParamWriteJson + * @param json Untrusted request body from the HTTP handler. + * @return DspParamWriteRequest on success, or a ParseError. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-08-18 + */ +[[nodiscard]] std::expected +parseDspParamWriteJson(std::string_view json); + +/** + * @brief serializeDspParamListJson — serialise the discoverable cell list. + * + * @dname serializeDspParamListJson + * @param params Every named cell in the compiled DSP program. + * @return JSON object with a `params` array for the HTTP response body. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-08-18 + */ +[[nodiscard]] std::string serializeDspParamListJson( + const std::vector& params); + +/** + * @brief serializeDspParamErrorJson — serialise a DSP param API error. + * + * @dname serializeDspParamErrorJson + * @param reason Short machine-readable cause (never a secret). + * @return JSON object string with status error and reason fields. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-08-18 + */ +[[nodiscard]] std::string serializeDspParamErrorJson(const char* reason); + +} // namespace core diff --git a/Software/components/core/include/core/IDsp.hpp b/Software/components/core/include/core/IDsp.hpp index a48b774..6400805 100644 --- a/Software/components/core/include/core/IDsp.hpp +++ b/Software/components/core/include/core/IDsp.hpp @@ -145,6 +145,24 @@ public: */ [[nodiscard]] virtual std::expected setBeepEnabled( bool enabled) = 0; + + /** + * @brief writeRawParam — safeload any named Parameter RAM cell. + * + * @dname writeRawParam + * @param address Parameter RAM address. + * @param value Coefficient in the SigmaStudio floating + * convention; converted to ADAU 8.23 fixpoint. + * @return Ok on success, or DspError. + * @pubstate writes ADAU1701 parameter RAM via safeload. Not part of + * AudioProfile — live-only, never persisted. No domain + * validation; see Adau1701Driver::writeRawParam(). + * + * @author Michele Bigi + * @date 2026-08-18 + */ + [[nodiscard]] virtual std::expected writeRawParam( + unsigned address, float value) = 0; }; } // namespace core diff --git a/Software/components/core/src/DspParamJson.cpp b/Software/components/core/src/DspParamJson.cpp new file mode 100644 index 0000000..4868f6c --- /dev/null +++ b/Software/components/core/src/DspParamJson.cpp @@ -0,0 +1,100 @@ +/** + * @file DspParamJson.cpp + * @brief DspParamJson implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-08-18 + */ + +#include "core/DspParamJson.hpp" + +#include +#include + +namespace core { + +namespace { + +[[nodiscard]] std::string_view extractJsonString(std::string_view json, + std::string_view key) +{ + const std::string needle = + std::string("\"") + std::string(key) + "\":\""; + const std::size_t start = json.find(needle); + if (start == std::string_view::npos) { + return {}; + } + const std::size_t valueStart = start + needle.size(); + const std::size_t valueEnd = json.find('"', valueStart); + if (valueEnd == std::string_view::npos) { + return {}; + } + return json.substr(valueStart, valueEnd - valueStart); +} + +[[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; +} + +} // namespace + +std::expected parseDspParamWriteJson( + std::string_view json) +{ + if (json.find('{') == std::string_view::npos) { + return std::unexpected(ParseError::InvalidJson); + } + + const std::string_view name = extractJsonString(json, "name"); + if (name.empty()) { + return std::unexpected(ParseError::MissingField); + } + + float value = 0.0F; + if (!extractJsonFloat(json, "value", value)) { + return std::unexpected(ParseError::MissingField); + } + + DspParamWriteRequest req; + req.name.assign(name.begin(), name.end()); + req.value = value; + return req; +} + +std::string serializeDspParamListJson(const std::vector& params) +{ + std::ostringstream out; + out << "{\"params\":["; + for (std::size_t i = 0; i < params.size(); ++i) { + if (i > 0U) { + out << ','; + } + out << "{\"name\":\"" << params[i].name << "\",\"address\":" + << params[i].address << "}"; + } + out << "]}"; + return out.str(); +} + +std::string serializeDspParamErrorJson(const char* reason) +{ + return std::string("{\"status\":\"error\",\"reason\":\"") + reason + + "\"}"; +} + +} // namespace core diff --git a/Software/components/core/test/CMakeLists.txt b/Software/components/core/test/CMakeLists.txt index a74f55c..32a576c 100644 --- a/Software/components/core/test/CMakeLists.txt +++ b/Software/components/core/test/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(digiradio_core STATIC "${CORE_SRC_DIR}/WifiScanJson.cpp" "${CORE_SRC_DIR}/EmbeddedBlobReader.cpp" "${CORE_SRC_DIR}/TunerJson.cpp" + "${CORE_SRC_DIR}/DspParamJson.cpp" "${CORE_SRC_DIR}/FrequencyKHz.cpp" "${CORE_SRC_DIR}/GainDb.cpp" "${CORE_SRC_DIR}/FrequencyHz.cpp" @@ -89,6 +90,10 @@ add_executable(tuner_json_test tuner_json_test.cpp) target_link_libraries(tuner_json_test PRIVATE digiradio_core) add_test(NAME tuner_json_test COMMAND tuner_json_test) +add_executable(dsp_param_json_test dsp_param_json_test.cpp) +target_link_libraries(dsp_param_json_test PRIVATE digiradio_core) +add_test(NAME dsp_param_json_test COMMAND dsp_param_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) diff --git a/Software/components/core/test/dsp_param_json_test.cpp b/Software/components/core/test/dsp_param_json_test.cpp new file mode 100644 index 0000000..ebb2e19 --- /dev/null +++ b/Software/components/core/test/dsp_param_json_test.cpp @@ -0,0 +1,129 @@ +/** + * @file dsp_param_json_test.cpp + * @brief Host tests for DspParamJson parse/serialise. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-08-18 + */ + +#include "core/DspParamJson.hpp" + +#include +#include + +namespace { + +bool expectEqual(const std::string& actual, const std::string& expected) +{ + if (actual != expected) { + std::cerr << "expected: " << expected << "\n actual: " << actual + << "\n"; + return false; + } + return true; +} + +[[nodiscard]] int runParseWriteTest() +{ + const auto parsed = core::parseDspParamWriteJson( + R"({"name":"BEEP1_KICK","value":1.0})"); + if (!parsed) { + std::cerr << "expected parse success\n"; + return EXIT_FAILURE; + } + if (parsed->name != "BEEP1_KICK" || parsed->value != 1.0F) { + std::cerr << "unexpected parsed fields\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runParseNegativeValueTest() +{ + const auto parsed = core::parseDspParamWriteJson( + R"({"name":"LIMITER1_THRESHOLD","value":-0.5})"); + if (!parsed || parsed->value != -0.5F) { + std::cerr << "expected negative value to parse\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runParseMissingFieldTest() +{ + const auto parsed = core::parseDspParamWriteJson(R"({"name":"X"})"); + if (parsed) { + std::cerr << "expected missing value to fail\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runParseInvalidJsonTest() +{ + const auto parsed = core::parseDspParamWriteJson("not json"); + if (parsed) { + std::cerr << "expected invalid json to fail\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runSerialiseListTest() +{ + const std::vector params = { + {"BEEP1_ENABLE", 0}, + {"BEEP1_KICK", 1}, + }; + const std::string json = core::serializeDspParamListJson(params); + if (!expectEqual( + json, + R"({"params":[{"name":"BEEP1_ENABLE","address":0},{"name":"BEEP1_KICK","address":1}]})")) { + return EXIT_FAILURE; + } + + const std::string emptyJson = core::serializeDspParamListJson({}); + if (!expectEqual(emptyJson, R"({"params":[]})")) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runSerialiseErrorTest() +{ + const std::string json = core::serializeDspParamErrorJson("not_found"); + if (!expectEqual(json, R"({"status":"error","reason":"not_found"})")) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +} // namespace + +int main() +{ + if (runParseWriteTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runParseNegativeValueTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runParseMissingFieldTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runParseInvalidJsonTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runSerialiseListTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runSerialiseErrorTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} diff --git a/Software/components/core/test/integration_service_test.cpp b/Software/components/core/test/integration_service_test.cpp index e8e7275..d98445b 100644 --- a/Software/components/core/test/integration_service_test.cpp +++ b/Software/components/core/test/integration_service_test.cpp @@ -75,6 +75,12 @@ public: { return {}; } + + [[nodiscard]] std::expected writeRawParam( + unsigned, float) override + { + return {}; + } }; class TrackingTuner final : public core::ITuner { diff --git a/Software/components/drivers/adau1701/include/adau1701/Adau1701Driver.hpp b/Software/components/drivers/adau1701/include/adau1701/Adau1701Driver.hpp index a94d7a5..57f3753 100644 --- a/Software/components/drivers/adau1701/include/adau1701/Adau1701Driver.hpp +++ b/Software/components/drivers/adau1701/include/adau1701/Adau1701Driver.hpp @@ -234,6 +234,28 @@ public: [[nodiscard]] std::expected setBeepEnabled( bool enabled); + /** + * @brief writeRawParam — safeload any named Parameter RAM cell. + * + * @dname writeRawParam + * @param address Parameter RAM address (see Adau1701ParamTable.hpp). + * @param value Coefficient in the SigmaStudio floating convention + * (e.g. -1.0..1.0 for volume/coefficient cells); + * converted to ADAU 8.23 fixpoint before the write. + * @return Ok on success, or Adau1701Error. + * @pubstate writes parameter RAM via safeload. No domain validation — + * this is deliberately as permissive as SigmaStudio's own + * Remote Connection (see SigmaStudioTcpServer), for callers + * that need cells the curated applyMixer/applyEq/setEqBand + * API doesn't expose (Beep1 frequency, limiter thresholds, + * raw PEQ coefficients, etc.). + * + * @author Michele Bigi + * @date 2026-08-18 + */ + [[nodiscard]] std::expected writeRawParam( + unsigned address, float value); + private: [[nodiscard]] std::expected ensureBooted() const; [[nodiscard]] std::expected safeloadGain( diff --git a/Software/components/drivers/adau1701/include/adau1701/Adau1701Dsp.hpp b/Software/components/drivers/adau1701/include/adau1701/Adau1701Dsp.hpp index fcd363f..722ab8e 100644 --- a/Software/components/drivers/adau1701/include/adau1701/Adau1701Dsp.hpp +++ b/Software/components/drivers/adau1701/include/adau1701/Adau1701Dsp.hpp @@ -64,6 +64,9 @@ public: [[nodiscard]] std::expected setBeepEnabled( bool enabled) override; + [[nodiscard]] std::expected writeRawParam( + unsigned address, float value) override; + private: [[nodiscard]] static core::DspError mapError(Adau1701Error error) noexcept; diff --git a/Software/components/drivers/adau1701/include/adau1701/Adau1701ParamTable.hpp b/Software/components/drivers/adau1701/include/adau1701/Adau1701ParamTable.hpp new file mode 100644 index 0000000..6263ce7 --- /dev/null +++ b/Software/components/drivers/adau1701/include/adau1701/Adau1701ParamTable.hpp @@ -0,0 +1,143 @@ +/** + * @file Adau1701ParamTable.hpp + * @brief Name -> parameter RAM address table for the whole DSP program. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Generated from Firmware/ADAU1701-Firmware/DigiRadio_IC_1_PARAM.h (the + * SigmaStudio export). Regenerate by re-running the extraction over that + * file if the SigmaStudio project's cell list changes; entries here must + * stay in sync with the ADDR_* constants used by + * components/drivers/adau1701/src/Adau1701Driver.cpp's replayProgram(). + * + * @author Michele Bigi + * @date 2026-08-18 + */ +#pragma once + +#include +#include +#include +#include + +namespace adau1701 { + +/** One addressable Parameter RAM cell from the compiled SigmaStudio graph. */ +struct Adau1701ParamEntry { + std::string_view name; ///< Cell name as exported by SigmaStudio. + unsigned address; ///< Parameter RAM address for safeload writes. +}; + +/** + * @brief kAdau1701ParamTable — every named Parameter RAM cell. + * + * @dname kAdau1701ParamTable + * @return n/a (data) + * @pubstate Read-only; see file header for regeneration instructions. + * + * @author Michele Bigi + * @date 2026-08-18 + */ +inline constexpr std::array kAdau1701ParamTable = {{ + {"BEEP1_ENABLE", 0}, + {"BEEP1_KICK", 1}, + {"BEEP1_BEEP_FREQ", 2}, + {"SI4674", 3}, + {"SI4674_1", 4}, + {"ESP32", 5}, + {"ESP32_1", 6}, + {"SINGLE1", 7}, + {"SSPLITTER1", 8}, + {"STMIXER1_ST0_VOLUME", 9}, + {"STMIXER1_ST1_VOLUME", 10}, + {"STMIXER1_ST2_VOLUME", 11}, + {"PARAMEQ1_ST0_B0", 12}, + {"PARAMEQ1_ST0_B1", 13}, + {"PARAMEQ1_ST0_B2", 14}, + {"PARAMEQ1_ST0_A0", 15}, + {"PARAMEQ1_ST0_A1", 16}, + {"PARAMEQ1_ST1_B0", 17}, + {"PARAMEQ1_ST1_B1", 18}, + {"PARAMEQ1_ST1_B2", 19}, + {"PARAMEQ1_ST1_A0", 20}, + {"PARAMEQ1_ST1_A1", 21}, + {"PARAMEQ1_ST2_B0", 22}, + {"PARAMEQ1_ST2_B1", 23}, + {"PARAMEQ1_ST2_B2", 24}, + {"PARAMEQ1_ST2_A0", 25}, + {"PARAMEQ1_ST2_A1", 26}, + {"PARAMEQ1_ST3_B0", 27}, + {"PARAMEQ1_ST3_B1", 28}, + {"PARAMEQ1_ST3_B2", 29}, + {"PARAMEQ1_ST3_A0", 30}, + {"PARAMEQ1_ST3_A1", 31}, + {"PARAMEQ1_ST4_B0", 32}, + {"PARAMEQ1_ST4_B1", 33}, + {"PARAMEQ1_ST4_B2", 34}, + {"PARAMEQ1_ST4_A0", 35}, + {"PARAMEQ1_ST4_A1", 36}, + {"PARAMEQ1_ST5_B0", 37}, + {"PARAMEQ1_ST5_B1", 38}, + {"PARAMEQ1_ST5_B2", 39}, + {"PARAMEQ1_ST5_A0", 40}, + {"PARAMEQ1_ST5_A1", 41}, + {"MULTIPLE1", 42}, + {"MULTIPLE1_1", 43}, + {"LIMITER1_S1", 44}, + {"LIMITER1_INT1", 45}, + {"LIMITER1_S2", 46}, + {"LIMITER1_INT2", 47}, + {"LIMITER1_S3", 48}, + {"LIMITER1_INT3", 49}, + {"LIMITER1_S4", 50}, + {"LIMITER1_INT4", 51}, + {"LIMITER1_C1", 52}, + {"LIMITER1_C2", 53}, + {"LIMITER1_C3", 54}, + {"LIMITER1_THRESHOLD", 55}, + {"LIMITER1_RMS", 56}, + {"LIMITER1_DECAY", 57}, + {"LIMITER1_DECAYCOMPLEMENT", 58}, + {"LIMITER2_S1", 59}, + {"LIMITER2_INT1", 60}, + {"LIMITER2_S2", 61}, + {"LIMITER2_INT2", 62}, + {"LIMITER2_S3", 63}, + {"LIMITER2_INT3", 64}, + {"LIMITER2_S4", 65}, + {"LIMITER2_INT4", 66}, + {"LIMITER2_C1", 67}, + {"LIMITER2_C2", 68}, + {"LIMITER2_C3", 69}, + {"LIMITER2_THRESHOLD", 70}, + {"LIMITER2_RMS", 71}, + {"LIMITER2_DECAY", 72}, + {"LIMITER2_DECAYCOMPLEMENT", 73}, +}}; + +/** + * @brief findAdau1701ParamAddress — look up a cell's RAM address by name. + * + * @dname findAdau1701ParamAddress + * @param name Cell name, case-sensitive, matching kAdau1701ParamTable. + * @return The address if found, or std::nullopt. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-08-18 + */ +[[nodiscard]] inline std::optional findAdau1701ParamAddress( + std::string_view name) noexcept +{ + for (const auto& entry : kAdau1701ParamTable) { + if (entry.name == name) { + return entry.address; + } + } + return std::nullopt; +} + +} // namespace adau1701 diff --git a/Software/components/drivers/adau1701/src/Adau1701Driver.cpp b/Software/components/drivers/adau1701/src/Adau1701Driver.cpp index 12a9dff..e4d2b6f 100644 --- a/Software/components/drivers/adau1701/src/Adau1701Driver.cpp +++ b/Software/components/drivers/adau1701/src/Adau1701Driver.cpp @@ -345,6 +345,16 @@ namespace adau1701 return safeloadFixpoint(static_cast(ADDR_BEEP1_KICK), value); } + std::expected Adau1701Driver::writeRawParam( + unsigned address, float value) + { + if (auto ready = ensureBooted(); !ready) + { + return ready; + } + return safeloadFixpoint(address, core::floatToFixpoint823(value)); + } + std::expected Adau1701Driver::applyEq( const core::EqProfile &eq) { diff --git a/Software/components/drivers/adau1701/src/Adau1701Dsp.cpp b/Software/components/drivers/adau1701/src/Adau1701Dsp.cpp index c570867..ad850bf 100644 --- a/Software/components/drivers/adau1701/src/Adau1701Dsp.cpp +++ b/Software/components/drivers/adau1701/src/Adau1701Dsp.cpp @@ -97,4 +97,13 @@ std::expected Adau1701Dsp::setBeepEnabled(bool enabled) return {}; } +std::expected Adau1701Dsp::writeRawParam( + unsigned address, float value) +{ + if (auto result = driver_.writeRawParam(address, value); !result) { + return std::unexpected(mapError(result.error())); + } + return {}; +} + } // namespace adau1701 diff --git a/Software/components/services/audio/include/audio/AudioService.hpp b/Software/components/services/audio/include/audio/AudioService.hpp index d378b68..5922416 100644 --- a/Software/components/services/audio/include/audio/AudioService.hpp +++ b/Software/components/services/audio/include/audio/AudioService.hpp @@ -208,6 +208,22 @@ public: [[nodiscard]] std::expected setBeepEnabled( bool enabled); + /** + * @brief writeRawParam — safeload any named ADAU1701 Parameter RAM cell. + * + * @dname writeRawParam + * @param address Parameter RAM address. + * @param value Coefficient in the SigmaStudio floating convention. + * @return Ok on success, or DspError. + * @pubstate live-only: not part of AudioProfile, never persisted, does + * not touch profile_. No domain validation. + * + * @author Michele Bigi + * @date 2026-08-18 + */ + [[nodiscard]] std::expected writeRawParam( + unsigned address, float value); + private: [[nodiscard]] std::expected persistProfile() const; diff --git a/Software/components/services/audio/src/AudioService.cpp b/Software/components/services/audio/src/AudioService.cpp index b41fa3e..617132f 100644 --- a/Software/components/services/audio/src/AudioService.cpp +++ b/Software/components/services/audio/src/AudioService.cpp @@ -193,4 +193,10 @@ std::expected AudioService::setBeepEnabled(bool enabled) return dsp_.setBeepEnabled(enabled); } +std::expected AudioService::writeRawParam( + unsigned address, float value) +{ + return dsp_.writeRawParam(address, value); +} + } // namespace audio