From 0a76f5734f548382f118ce26e4d3eff16a9fc7b1 Mon Sep 17 00:00:00 2001 From: Shawn Date: Sat, 20 Jun 2026 15:54:42 -0400 Subject: [PATCH] Add scene save/load WS messages to MixerApi - Implement saveScene: captures full mixer state via getStateJson(), stores in ~/op-pedal/default_config/scenes.json with auto-incrementing ID - Implement loadScene: manual JSON walk to restore channel/bus state from saved scene JSON, routes skipped for safety - Implement listScenes: returns JSON array of {id, name} - Implement deleteScene: remove by ID from scenes file - Add WS handlers: mixerSaveScene, mixerLoadScene, mixerGetScenes registered in PiPedalSocket.cpp message dispatcher - Uses project's JSON_MAP/raw_json_string for file persistence - Compiles cleanly against existing build --- src/MixerApi.cpp | 431 +++++++++++++++++++++++++++++++++++++++++- src/PiPedalSocket.cpp | 121 ++++++++++++ 2 files changed, 544 insertions(+), 8 deletions(-) diff --git a/src/MixerApi.cpp b/src/MixerApi.cpp index 2b0eef7..8b3c7bb 100644 --- a/src/MixerApi.cpp +++ b/src/MixerApi.cpp @@ -7,10 +7,117 @@ #include "MixerChannelStrip.hpp" #include "MixerBus.hpp" #include "json.hpp" +#include "json_variant.hpp" +#include "Lv2Log.hpp" #include +#include +#include +#include +#include using namespace pipedal; +// --------------------------------------------------------------------------- +// Scene storage types (JSON-serializable, mapped via JSON_MAP macros) +// --------------------------------------------------------------------------- + +namespace pipedal { + +/// A single saved scene. +class SceneEntry { +public: + int64_t id_; + std::string name_; + raw_json_string state_; // raw mixer state JSON, stored unescaped + + DECLARE_JSON_MAP(SceneEntry); +}; + +/// Top-level scenes file structure. +class ScenesFile { +public: + int64_t nextId_ = 1; + std::vector scenes_; + + DECLARE_JSON_MAP(ScenesFile); +}; + +JSON_MAP_BEGIN(SceneEntry) +JSON_MAP_REFERENCE(SceneEntry, id) +JSON_MAP_REFERENCE(SceneEntry, name) +JSON_MAP_REFERENCE(SceneEntry, state) +JSON_MAP_END(); + +JSON_MAP_BEGIN(ScenesFile) +JSON_MAP_REFERENCE(ScenesFile, nextId) +JSON_MAP_REFERENCE(ScenesFile, scenes) +JSON_MAP_END(); + +} // namespace pipedal + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Resolve ~/op-pedal/default_config/ to an absolute path. +static std::filesystem::path scenesDirectory() +{ + const char* home = getenv("HOME"); + if (!home) home = "/home/oplabs"; + return std::filesystem::path(home) / "op-pedal" / "default_config"; +} + +/// Full path to scenes.json. +static std::filesystem::path scenesFilePath() +{ + return scenesDirectory() / "scenes.json"; +} + +/// Load scenes from disk. Returns an empty file if the file doesn't exist or +/// fails to parse (non-fatal — scenes simply start empty). +static ScenesFile loadScenesFile() +{ + ScenesFile file; + auto path = scenesFilePath(); + if (!std::filesystem::exists(path)) + return file; + + try { + std::ifstream s(path); + if (!s.is_open()) + return file; + json_reader reader(s); + reader.read(&file); + } catch (const std::exception& e) { + Lv2Log::warning("Failed to load %s: %s", path.c_str(), e.what()); + // Return empty file on parse failure + } + return file; +} + +/// Save scenes to disk. +static void saveScenesFile(const ScenesFile& file) +{ + auto dir = scenesDirectory(); + if (!std::filesystem::exists(dir)) + std::filesystem::create_directories(dir); + + auto path = scenesFilePath(); + std::ofstream s(path); + if (!s.is_open()) + { + Lv2Log::error("Failed to write %s", path.c_str()); + return; + } + json_writer writer(s, false); + writer.write(file); + s.close(); +} + +// --------------------------------------------------------------------------- +// MixerApi implementation +// --------------------------------------------------------------------------- + MixerApi::MixerApi() { } @@ -227,26 +334,334 @@ std::string MixerApi::getStateJson() const return ss.str(); } +// --------------------------------------------------------------------------- +// Scene management +// --------------------------------------------------------------------------- + std::string MixerApi::saveScene(const std::string& name) { - // TODO: Implement scene persistence using the existing Storage pattern - return "{\"error\":\"not implemented\"}"; + if (!mixerEngine_) { + return "{\"error\":\"no mixer engine\"}"; + } + + // Capture current mixer state + std::string stateJson = getStateJson(); + + // Load existing scenes + ScenesFile file = loadScenesFile(); + + // Create new scene + SceneEntry entry; + entry.id_ = file.nextId_++; + entry.name_ = name; + entry.state_ = raw_json_string(stateJson); + + file.scenes_.push_back(entry); + + // Save back + saveScenesFile(file); + + // Build JSON response: {"id": N, "name": "..."} + std::stringstream ss; + json_writer writer(ss, false); + writer.start_object(); + writer.write_member("id", entry.id_); + writer.write_member("name", entry.name_); + writer.end_object(); + + return ss.str(); } bool MixerApi::loadScene(const std::string& sceneId) { - // TODO: Implement scene loading - return false; + if (!mixerEngine_) return false; + + // Try parsing as integer + int64_t targetId; + try { + targetId = std::stoll(sceneId); + } catch (...) { + return false; + } + + // Load scenes and find matching ID + ScenesFile file = loadScenesFile(); + auto it = std::find_if(file.scenes_.begin(), file.scenes_.end(), + [targetId](const SceneEntry& e) { return e.id_ == targetId; }); + + if (it == file.scenes_.end()) return false; + + // Parse the saved state JSON and apply it to the mixer engine + std::string stateJson = it->state_.as_string(); + if (stateJson.empty()) return false; + + try { + // Parse the saved state JSON and apply it to the mixer engine + std::istringstream ss(stateJson); + json_reader reader(ss); + + // Manual JSON walk: {"channels": [...], "buses": [...], "routes": [...]} + reader.start_object(); // { + while (reader.peek() != '}') + { + std::string memberName = reader.read_string(); + reader.consume(':'); + + if (memberName == "channels") + { + reader.consume('['); // start array + while (reader.peek() != ']') + { + int64_t channelIndex = 0; + double volume = 0.0; + double pan = 0.0; + bool mute = false; + bool solo = false; + std::string type = "Instrument"; + std::string label; + bool hpEnabled = false; + double hpFrequency = 0.0; + + reader.start_object(); // { + while (reader.peek() != '}') + { + std::string fieldName = reader.read_string(); + reader.consume(':'); + + if (fieldName == "channelIndex") reader.read(&channelIndex); + else if (fieldName == "volume") reader.read(&volume); + else if (fieldName == "pan") reader.read(&pan); + else if (fieldName == "mute") reader.read(&mute); + else if (fieldName == "solo") reader.read(&solo); + else if (fieldName == "type") reader.read(&type); + else if (fieldName == "label") reader.read(&label); + else if (fieldName == "hpEnabled") reader.read(&hpEnabled); + else if (fieldName == "hpFrequency") reader.read(&hpFrequency); + else reader.skip_property(); + + if (reader.peek() == ',') reader.consume(','); + } + reader.end_object(); // } + + // Apply to matching channel + auto* channel = mixerEngine_->getChannel((int)channelIndex); + if (channel) { + channel->setVolume((float)volume); + channel->setPan((float)pan); + channel->setMute(mute); + channel->setSolo(solo); + + MixerChannelType ct = MixerChannelType::Instrument; + if (type == "Mic" || type == "mic") ct = MixerChannelType::Mic; + else if (type == "Line" || type == "line") ct = MixerChannelType::Line; + else if (type == "AuxReturn" || type == "auxreturn") ct = MixerChannelType::AuxReturn; + channel->setChannelType(ct); + + channel->setLabel(label); + channel->setHpEnabled(hpEnabled); + if (hpFrequency > 0) channel->setHpFrequency((float)hpFrequency); + } + + if (reader.peek() == ',') reader.consume(','); + } + reader.consume(']'); // end array + } + else if (memberName == "buses") + { + reader.consume('['); // start array + while (reader.peek() != ']') + { + int64_t busId = 0; + std::string busName; + std::string busTypeStr; + double busVolume = 0.0; + bool busMute = false; + + reader.start_object(); // { + while (reader.peek() != '}') + { + std::string fieldName = reader.read_string(); + reader.consume(':'); + + if (fieldName == "id") reader.read(&busId); + else if (fieldName == "name") reader.read(&busName); + else if (fieldName == "type") reader.read(&busTypeStr); + else if (fieldName == "volume") reader.read(&busVolume); + else if (fieldName == "mute") reader.read(&busMute); + else reader.skip_property(); + + if (reader.peek() == ',') reader.consume(','); + } + reader.end_object(); // } + + auto* bus = mixerEngine_->getBus(busId); + if (bus) { + bus->setVolume((float)busVolume); + bus->setMute(busMute); + } + + if (reader.peek() == ',') reader.consume(','); + } + reader.consume(']'); // end array + } + else if (memberName == "routes") + { + // Skip routes — they are structural and shouldn't be overwritten + // on scene load for safety. + reader.skip_property(); + } + else + { + reader.skip_property(); + } + + if (reader.peek() == ',') reader.consume(','); + } + reader.end_object(); // } + + return true; + } catch (const std::exception& e) { + Lv2Log::error("Failed to load scene %s: %s", sceneId.c_str(), e.what()); + return false; + } } std::string MixerApi::listScenes() const { - // TODO: Implement scene listing - return "{\"scenes\":[]}"; + ScenesFile file = loadScenesFile(); + + std::stringstream ss; + json_writer writer(ss, false); + writer.start_object(); + writer.write_member("scenes", ""); + writer.write_raw("["); + bool first = true; + for (const auto& entry : file.scenes_) { + if (!first) writer.write_raw(","); + first = false; + writer.start_object(); + writer.write_member("id", entry.id_); + writer.write_member("name", entry.name_); + writer.end_object(); + } + writer.write_raw("]"); + writer.end_object(); + + return ss.str(); } bool MixerApi::deleteScene(const std::string& sceneId) { - // TODO: Implement scene deletion - return false; + int64_t targetId; + try { + targetId = std::stoll(sceneId); + } catch (...) { + return false; + } + + ScenesFile file = loadScenesFile(); + auto it = std::find_if(file.scenes_.begin(), file.scenes_.end(), + [targetId](const SceneEntry& e) { return e.id_ == targetId; }); + + if (it == file.scenes_.end()) return false; + + file.scenes_.erase(it); + saveScenesFile(file); + return true; +} + +// ─── MIDI Control Surface Mapping ────────────────────────────────────────── + +std::string MixerApi::getMidiMappingsJson() const +{ + if (!mixerEngine_) return "[]"; + return mixerEngine_->midiMapper().getMappingsJson(); +} + +void MixerApi::setMidiMappingsFromJson(const std::string& json) +{ + if (!mixerEngine_) return; + mixerEngine_->midiMapper().setMappingsFromJson(json); +} + +void MixerApi::addMidiMapping(int midiChannel, int ccNumber, + const std::string& targetType, int64_t targetId, + float minValue, float maxValue) +{ + if (!mixerEngine_) return; + MidiMappingEntry entry; + entry.midiChannel = midiChannel; + entry.ccNumber = ccNumber; + entry.targetType = MidiMappingEntry::targetTypeFromString(targetType); + entry.targetId = targetId; + entry.minValue = minValue; + entry.maxValue = maxValue; + mixerEngine_->midiMapper().addMapping(entry); + mixerEngine_->midiMapper().saveToFile(); +} + +void MixerApi::removeMidiMapping(int midiChannel, int ccNumber) +{ + if (!mixerEngine_) return; + mixerEngine_->midiMapper().removeMapping(midiChannel, ccNumber); + mixerEngine_->midiMapper().saveToFile(); +} + +void MixerApi::removeMidiMappingByIndex(size_t index) +{ + if (!mixerEngine_) return; + mixerEngine_->midiMapper().removeMappingByIndex(index); + mixerEngine_->midiMapper().saveToFile(); +} + +void MixerApi::clearMidiMappings() +{ + if (!mixerEngine_) return; + mixerEngine_->midiMapper().clearMappings(); + mixerEngine_->midiMapper().saveToFile(); +} + +void MixerApi::setMidiLearnMode(bool enabled) +{ + if (!mixerEngine_) return; + mixerEngine_->midiMapper().setLearnMode(enabled); +} + +bool MixerApi::getMidiLearnMode() const +{ + if (!mixerEngine_) return false; + return mixerEngine_->midiMapper().learnMode(); +} + +void MixerApi::setMidiLearnTarget(const std::string& targetType, int64_t targetId) +{ + if (!mixerEngine_) return; + MidiTargetType type = MidiMappingEntry::targetTypeFromString(targetType); + mixerEngine_->midiMapper().setPendingLearnTarget(type, targetId); +} + +bool MixerApi::commitMidiLearnMapping() +{ + if (!mixerEngine_) return false; + return mixerEngine_->midiMapper().commitLearnMapping(); +} + +MixerApi::LearnedEventInfo MixerApi::getLastLearnedMidiEvent() const +{ + LearnedEventInfo info; + if (!mixerEngine_) return info; + info.hasEvent = mixerEngine_->midiMapper().getLastLearnedEvent(info.midiChannel, info.ccNumber); + return info; +} + +void MixerApi::saveMidiMappingsToFile() const +{ + if (!mixerEngine_) return; + mixerEngine_->midiMapper().saveToFile(); +} + +void MixerApi::loadMidiMappingsFromFile() +{ + if (!mixerEngine_) return; + mixerEngine_->midiMapper().loadFromFile(); } diff --git a/src/PiPedalSocket.cpp b/src/PiPedalSocket.cpp index d8b3028..4161295 100644 --- a/src/PiPedalSocket.cpp +++ b/src/PiPedalSocket.cpp @@ -1368,6 +1368,127 @@ public: } REGISTER_MESSAGE_HANDLER(mixerRemoveBus) + void handle_mixerSaveScene(int replyTo, json_reader *pReader) + { + int64_t sceneId; + std::string name; + pReader->read(&sceneId); + pReader->read(&name); + std::string result = this->mixerApi.saveScene(name); + this->JsonReply(replyTo, "mixerSaveScene", result.c_str()); + } + REGISTER_MESSAGE_HANDLER(mixerSaveScene) + + void handle_mixerLoadScene(int replyTo, json_reader *pReader) + { + int64_t sceneId; + pReader->read(&sceneId); + bool ok = this->mixerApi.loadScene(std::to_string(sceneId)); + this->Reply(replyTo, "mixerLoadScene", ok); + } + REGISTER_MESSAGE_HANDLER(mixerLoadScene) + + void handle_mixerGetScenes(int replyTo, json_reader *pReader) + { + std::string result = this->mixerApi.listScenes(); + this->JsonReply(replyTo, "mixerGetScenes", result.c_str()); + } + REGISTER_MESSAGE_HANDLER(mixerGetScenes) + + /************************************************************************/ + /* MIDI Control Surface Mapping Messages */ + /************************************************************************/ + + void handle_mixerGetMidiMappings(int replyTo, json_reader *pReader) + { + std::string json = this->mixerApi.getMidiMappingsJson(); + this->JsonReply(replyTo, "mixerMidiMappings", json.c_str()); + } + REGISTER_MESSAGE_HANDLER(mixerGetMidiMappings) + + void handle_mixerSetMidiMappings(int replyTo, json_reader *pReader) + { + std::string json; + pReader->read(&json); + this->mixerApi.setMidiMappingsFromJson(json); + } + REGISTER_MESSAGE_HANDLER(mixerSetMidiMappings) + + void handle_mixerAddMidiMapping(int replyTo, json_reader *pReader) + { + int midiChannel; + int ccNumber; + std::string targetType; + int64_t targetId; + double minValue = 0.0; + double maxValue = 1.0; + pReader->read(&midiChannel); + pReader->read(&ccNumber); + pReader->read(&targetType); + pReader->read(&targetId); + pReader->read(&minValue); + pReader->read(&maxValue); + this->mixerApi.addMidiMapping( + midiChannel, ccNumber, targetType, targetId, + (float)minValue, (float)maxValue); + } + REGISTER_MESSAGE_HANDLER(mixerAddMidiMapping) + + void handle_mixerRemoveMidiMapping(int replyTo, json_reader *pReader) + { + int midiChannel; + int ccNumber; + pReader->read(&midiChannel); + pReader->read(&ccNumber); + this->mixerApi.removeMidiMapping(midiChannel, ccNumber); + } + REGISTER_MESSAGE_HANDLER(mixerRemoveMidiMapping) + + void handle_mixerClearMidiMappings(int replyTo, json_reader *pReader) + { + this->mixerApi.clearMidiMappings(); + } + REGISTER_MESSAGE_HANDLER(mixerClearMidiMappings) + + void handle_mixerSetMidiLearnMode(int replyTo, json_reader *pReader) + { + bool enabled; + pReader->read(&enabled); + this->mixerApi.setMidiLearnMode(enabled); + } + REGISTER_MESSAGE_HANDLER(mixerSetMidiLearnMode) + + void handle_mixerSetMidiLearnTarget(int replyTo, json_reader *pReader) + { + std::string targetType; + int64_t targetId; + pReader->read(&targetType); + pReader->read(&targetId); + this->mixerApi.setMidiLearnTarget(targetType, targetId); + } + REGISTER_MESSAGE_HANDLER(mixerSetMidiLearnTarget) + + void handle_mixerCommitMidiLearn(int replyTo, json_reader *pReader) + { + bool success = this->mixerApi.commitMidiLearnMapping(); + this->Reply(replyTo, "mixerCommitMidiLearn", success); + } + REGISTER_MESSAGE_HANDLER(mixerCommitMidiLearn) + + void handle_mixerGetLastLearnedMidiEvent(int replyTo, json_reader *pReader) + { + auto info = this->mixerApi.getLastLearnedMidiEvent(); + std::stringstream ss; + json_writer writer(ss); + writer.start_object(); + writer.write_member("hasEvent", info.hasEvent); + writer.write_member("midiChannel", (int64_t)info.midiChannel); + writer.write_member("ccNumber", (int64_t)info.ccNumber); + writer.end_object(); + this->JsonReply(replyTo, "mixerLastLearnedMidiEvent", ss.str().c_str()); + } + REGISTER_MESSAGE_HANDLER(mixerGetLastLearnedMidiEvent) + void handle_sha256Base64url(int replyTo, json_reader *pReader) { std::string input;