Line data Source code
1 : /**
2 : * @file preset_json.cpp
3 : * @brief Preset serialization / deserialization using nlohmann/json.
4 : *
5 : * This replaces the previous hand-rolled string-manipulation parser with a
6 : * proper, well-tested JSON library (nlohmann/json v3.11+).
7 : *
8 : * Design goals
9 : * ------------
10 : * 1. **Drop-in replacement** – the on-disk JSON format is unchanged; existing
11 : * preset files load without modification.
12 : * 2. **Standard C++17 interface** – nlohmann ADL hooks (to_json / from_json)
13 : * make PresetData and EffectData first-class nlohmann types, so callers
14 : * can write `nlohmann::json j = preset;` directly.
15 : * 3. **Robust error handling** – every parse operation is wrapped in
16 : * try/catch; on failure from_json_ext logs to std::cerr and returns false
17 : * without mutating the output parameter.
18 : * 4. **Preserves midi_mappings and metadata** – nothing that the old parser
19 : * supported is dropped.
20 : */
21 :
22 : #include "preset_json.h"
23 : #include "midi/midi_manager.h"
24 :
25 : #include <ctime>
26 : #include <stdexcept>
27 : #include <iostream>
28 : #include <sstream>
29 :
30 : namespace Amplitron {
31 :
32 : namespace {
33 :
34 : using OrderedJson = nlohmann::ordered_json;
35 :
36 : // These ordered helpers are used by to_json_ext/from_json_ext so that the
37 : // preset string/file round-trip preserves effect parameter insertion order.
38 : // nlohmann::json stores object keys in sorted order by default, which breaks
39 : // tests and callers that rely on the original fx.params sequence.
40 51 : void to_ordered_json(OrderedJson &j, const PresetData::EffectData &fx) {
41 51 : OrderedJson params_obj = OrderedJson::object();
42 168 : for (const auto &[name, value] : fx.params) {
43 195 : params_obj[name] = value;
44 : }
45 :
46 51 : j = OrderedJson::object();
47 51 : j["type"] = fx.type;
48 51 : j["enabled"] = fx.enabled;
49 51 : j["mix"] = fx.mix;
50 51 : j["params"] = std::move(params_obj);
51 :
52 51 : if (!fx.metadata.empty()) {
53 12 : OrderedJson metadata_obj = OrderedJson::object();
54 24 : for (const auto &[key, value] : fx.metadata) {
55 16 : metadata_obj[key] = value;
56 : }
57 12 : j["metadata"] = std::move(metadata_obj);
58 12 : }
59 51 : }
60 :
61 153 : void to_ordered_json(OrderedJson &j, const PresetData::NodeData &node) {
62 153 : OrderedJson params_obj = OrderedJson::object();
63 474 : for (const auto &[name, value] : node.params) {
64 535 : params_obj[name] = value;
65 : }
66 :
67 153 : j = OrderedJson::object();
68 153 : j["id"] = node.id;
69 153 : j["type"] = node.type;
70 765 : j["position"] = {{"x", node.x}, {"y", node.y}};
71 153 : j["enabled"] = node.enabled;
72 153 : j["mix"] = node.mix;
73 153 : if (node.num_inputs > 0) {
74 12 : j["num_inputs"] = node.num_inputs;
75 4 : }
76 153 : if (!node.params.empty()) {
77 99 : j["params"] = std::move(params_obj);
78 33 : }
79 153 : if (!node.metadata.empty()) {
80 12 : OrderedJson metadata_obj = OrderedJson::object();
81 24 : for (const auto &[key, value] : node.metadata) {
82 16 : metadata_obj[key] = value;
83 : }
84 12 : j["metadata"] = std::move(metadata_obj);
85 12 : }
86 765 : }
87 :
88 171 : void from_ordered_json(const OrderedJson &j, PresetData::NodeData &node) {
89 171 : node.id = j.value("id", std::string{});
90 171 : node.type = j.value("type", std::string{});
91 171 : node.enabled = j.value("enabled", true);
92 171 : node.mix = j.value("mix", 1.0f);
93 171 : node.num_inputs = j.value("num_inputs", 0);
94 :
95 171 : if (j.contains("position") && j["position"].is_object()) {
96 102 : node.x = j["position"].value("x", 0.0f);
97 102 : node.y = j["position"].value("y", 0.0f);
98 34 : }
99 :
100 171 : node.params.clear();
101 171 : node.metadata.clear();
102 :
103 171 : if (j.contains("params") && j["params"].is_object()) {
104 380 : for (auto it = j["params"].begin(); it != j["params"].end(); ++it) {
105 288 : if (it.value().is_number()) {
106 213 : node.params.emplace_back(it.key(), it.value().get<float>());
107 71 : }
108 72 : }
109 23 : }
110 :
111 171 : if (j.contains("metadata") && j["metadata"].is_object()) {
112 44 : for (auto it = j["metadata"].begin(); it != j["metadata"].end(); ++it) {
113 18 : if (it.value().is_string()) {
114 15 : node.metadata[it.key()] = it.value().get<std::string>();
115 5 : }
116 6 : }
117 5 : }
118 171 : }
119 :
120 78 : void to_ordered_json(OrderedJson &j, const PresetData::LinkData &link) {
121 78 : j = OrderedJson::object();
122 78 : j["src_pin"] = link.src_pin;
123 78 : j["dst_pin"] = link.dst_pin;
124 78 : }
125 :
126 75 : void from_ordered_json(const OrderedJson &j, PresetData::LinkData &link) {
127 75 : link.src_pin = j.value("src_pin", std::string{});
128 75 : link.dst_pin = j.value("dst_pin", std::string{});
129 75 : }
130 :
131 366 : void from_ordered_json(const OrderedJson &j, PresetData::EffectData &fx) {
132 366 : fx.type = j.value("type", std::string{});
133 366 : fx.enabled = j.value("enabled", false);
134 366 : fx.mix = j.value("mix", 1.0f);
135 :
136 366 : fx.params.clear();
137 366 : fx.metadata.clear();
138 :
139 366 : if (j.contains("params") && j["params"].is_object()) {
140 2220 : for (auto it = j["params"].begin(); it != j["params"].end(); ++it) {
141 1744 : if (it.value().is_number()) {
142 1302 : fx.params.emplace_back(it.key(), it.value().get<float>());
143 434 : }
144 436 : }
145 119 : }
146 :
147 366 : if (j.contains("metadata") && j["metadata"].is_object()) {
148 60 : for (auto it = j["metadata"].begin(); it != j["metadata"].end(); ++it) {
149 24 : if (it.value().is_string()) {
150 18 : fx.metadata[it.key()] = it.value().get<std::string>();
151 6 : }
152 8 : }
153 7 : }
154 366 : }
155 :
156 24 : void to_ordered_json_midi(OrderedJson &j, const MidiMapping &m) {
157 24 : j = OrderedJson::object();
158 24 : j["cc"] = m.cc_number;
159 24 : j["channel"] = m.midi_channel;
160 24 : j["target"] = static_cast<int>(m.target_type);
161 24 : j["mode"] = static_cast<int>(m.mode);
162 24 : j["effect"] = m.effect_name;
163 24 : j["param"] = m.param_name;
164 24 : }
165 :
166 24 : void from_ordered_json_midi(const OrderedJson &j, MidiMapping &m) {
167 24 : m.cc_number = j.value("cc", 0);
168 24 : m.midi_channel = j.value("channel", -1);
169 24 : m.target_type = static_cast<MidiTargetType>(j.value("target", 0));
170 24 : m.mode = static_cast<MidiMappingMode>(j.value("mode", 0));
171 24 : m.effect_name = j.value("effect", std::string{});
172 24 : m.param_name = j.value("param", std::string{});
173 24 : }
174 :
175 123 : void to_ordered_json(OrderedJson &j, const PresetData &preset) {
176 : // Generate an ISO-8601 timestamp
177 123 : std::time_t now = std::time(nullptr);
178 123 : char timebuf[64] = {};
179 123 : std::tm tm_info{};
180 : #ifdef _WIN32
181 41 : localtime_s(&tm_info, &now);
182 : #else
183 82 : localtime_r(&now, &tm_info);
184 : #endif
185 123 : std::strftime(timebuf, sizeof(timebuf), "%Y-%m-%dT%H:%M:%S", &tm_info);
186 :
187 123 : OrderedJson effects_arr = OrderedJson::array();
188 174 : for (const auto &fx : preset.effects) {
189 51 : OrderedJson jfx;
190 51 : to_ordered_json(jfx, fx);
191 51 : effects_arr.push_back(std::move(jfx));
192 51 : }
193 :
194 123 : OrderedJson midi_arr = OrderedJson::array();
195 147 : for (const auto &m : preset.midi_mappings) {
196 24 : OrderedJson jm;
197 24 : to_ordered_json_midi(jm, m);
198 24 : midi_arr.push_back(std::move(jm));
199 24 : }
200 :
201 123 : OrderedJson nodes_arr = OrderedJson::array();
202 276 : for (const auto &node : preset.nodes) {
203 153 : OrderedJson jn;
204 153 : to_ordered_json(jn, node);
205 153 : nodes_arr.push_back(std::move(jn));
206 153 : }
207 :
208 123 : OrderedJson links_arr = OrderedJson::array();
209 201 : for (const auto &link : preset.links) {
210 78 : OrderedJson jl;
211 78 : to_ordered_json(jl, link);
212 78 : links_arr.push_back(std::move(jl));
213 78 : }
214 :
215 123 : j = OrderedJson::object();
216 123 : j["format_version"] = 2; // Increased format version for graph presets
217 123 : j["routing"] = preset.routing;
218 123 : j["name"] = preset.name;
219 123 : j["description"] = preset.description;
220 123 : j["saved_at"] = timebuf;
221 123 : j["input_gain"] = preset.input_gain;
222 123 : j["output_gain"] = preset.output_gain;
223 :
224 123 : if (preset.routing == "linear") {
225 51 : j["effects"] = std::move(effects_arr);
226 17 : } else {
227 72 : j["nodes"] = std::move(nodes_arr);
228 72 : j["links"] = std::move(links_arr);
229 : }
230 :
231 123 : j["midi_mappings"] = std::move(midi_arr);
232 123 : }
233 :
234 168 : void from_ordered_json(const OrderedJson &j, PresetData &preset) {
235 168 : preset.name = j.value("name", std::string{});
236 168 : preset.description = j.value("description", std::string{});
237 168 : preset.routing = j.value("routing", std::string{"linear"});
238 168 : preset.input_gain = j.value("input_gain", 0.7f);
239 168 : preset.output_gain = j.value("output_gain", 0.8f);
240 :
241 168 : if (preset.routing == "graph") {
242 87 : if (!j.contains("nodes") || !j["nodes"].is_array()) {
243 6 : throw std::invalid_argument("Malformed graph preset: missing or invalid 'nodes' array");
244 : }
245 81 : if (!j.contains("links") || !j["links"].is_array()) {
246 6 : throw std::invalid_argument("Malformed graph preset: missing or invalid 'links' array");
247 : }
248 25 : }
249 :
250 156 : preset.effects.clear();
251 156 : preset.nodes.clear();
252 156 : preset.links.clear();
253 156 : preset.midi_mappings.clear();
254 :
255 156 : if (j.contains("effects") && j["effects"].is_array()) {
256 470 : for (const auto &jfx : j["effects"]) {
257 366 : PresetData::EffectData fx;
258 366 : from_ordered_json(jfx, fx);
259 366 : if (!fx.type.empty()) {
260 363 : preset.effects.push_back(std::move(fx));
261 121 : }
262 366 : }
263 26 : }
264 :
265 156 : if (j.contains("nodes") && j["nodes"].is_array()) {
266 275 : for (const auto &jn : j["nodes"]) {
267 171 : PresetData::NodeData node;
268 171 : from_ordered_json(jn, node);
269 171 : if (!node.id.empty() && !node.type.empty()) {
270 165 : preset.nodes.push_back(std::move(node));
271 55 : }
272 171 : }
273 26 : }
274 :
275 156 : if (j.contains("links") && j["links"].is_array()) {
276 179 : for (const auto &jl : j["links"]) {
277 75 : PresetData::LinkData link;
278 75 : from_ordered_json(jl, link);
279 75 : if (!link.src_pin.empty() && !link.dst_pin.empty()) {
280 71 : preset.links.push_back(std::move(link));
281 23 : }
282 75 : }
283 26 : }
284 :
285 156 : if (j.contains("midi_mappings") && j["midi_mappings"].is_array()) {
286 128 : for (const auto &jm : j["midi_mappings"]) {
287 24 : MidiMapping m;
288 24 : from_ordered_json_midi(jm, m);
289 24 : preset.midi_mappings.push_back(m);
290 24 : }
291 26 : }
292 156 : }
293 :
294 : } // namespace
295 :
296 : // ============================================================
297 : // ADL hook: EffectData ←→ nlohmann::json
298 : // ============================================================
299 :
300 12 : void to_json(nlohmann::json &j, const PresetData::EffectData &fx) {
301 : // Build the flat params object: { "Drive": 2.0, "Tone": 0.6, ... }
302 12 : nlohmann::json params_obj = nlohmann::json::object();
303 33 : for (const auto &[name, value] : fx.params) {
304 35 : params_obj[name] = value;
305 : }
306 :
307 24 : j = {
308 12 : {"type", fx.type},
309 12 : {"enabled", fx.enabled},
310 12 : {"mix", fx.mix},
311 4 : {"params", params_obj},
312 104 : };
313 :
314 : // Optional metadata sub-object (e.g. IR cabinet file path)
315 12 : if (!fx.metadata.empty()) {
316 3 : j["metadata"] = fx.metadata;
317 1 : }
318 92 : }
319 :
320 12 : void from_json(const nlohmann::json &j, PresetData::EffectData &fx) {
321 12 : fx.type = j.value("type", std::string{});
322 12 : fx.enabled = j.value("enabled", false);
323 12 : fx.mix = j.value("mix", 1.0f);
324 :
325 : // Clear before repopulating so reusing an object never carries stale data.
326 12 : fx.params.clear();
327 12 : fx.metadata.clear();
328 :
329 12 : if (j.contains("params") && j["params"].is_object()) {
330 26 : for (const auto &[key, val] : j["params"].items()) {
331 20 : if (val.is_number()) {
332 20 : fx.params.push_back({key, val.get<float>()});
333 4 : }
334 6 : }
335 2 : }
336 :
337 12 : if (j.contains("metadata") && j["metadata"].is_object()) {
338 18 : for (const auto &[key, val] : j["metadata"].items()) {
339 9 : if (val.is_string()) {
340 6 : fx.metadata[key] = val.get<std::string>();
341 2 : }
342 6 : }
343 2 : }
344 12 : }
345 :
346 : // ============================================================
347 : // ADL hook: MidiMapping ←→ nlohmann::json
348 : // ============================================================
349 :
350 0 : static void to_json_midi(nlohmann::json &j, const MidiMapping &m) {
351 0 : j = {
352 0 : {"cc", m.cc_number},
353 0 : {"channel", m.midi_channel},
354 0 : {"target", static_cast<int>(m.target_type)},
355 0 : {"mode", static_cast<int>(m.mode)},
356 0 : {"effect", m.effect_name},
357 0 : {"param", m.param_name},
358 0 : };
359 0 : }
360 :
361 3 : static void from_json_midi(const nlohmann::json &j, MidiMapping &m) {
362 3 : m.cc_number = j.value("cc", 0);
363 3 : m.midi_channel = j.value("channel", -1);
364 3 : m.target_type = static_cast<MidiTargetType>(j.value("target", 0));
365 3 : m.mode = static_cast<MidiMappingMode>(j.value("mode", 0));
366 3 : m.effect_name = j.value("effect", std::string{});
367 3 : m.param_name = j.value("param", std::string{});
368 3 : }
369 :
370 : // ============================================================
371 : // ADL hook: PresetData ←→ nlohmann::json
372 : // ============================================================
373 :
374 3 : void to_json(nlohmann::json &j, const PresetData &preset) {
375 : // Generate an ISO-8601 timestamp
376 3 : std::time_t now = std::time(nullptr);
377 3 : char timebuf[64] = {};
378 3 : std::tm tm_info{};
379 : #ifdef _WIN32
380 1 : localtime_s(&tm_info, &now);
381 : #else
382 2 : localtime_r(&now, &tm_info);
383 : #endif
384 3 : std::strftime(timebuf, sizeof(timebuf), "%Y-%m-%dT%H:%M:%S", &tm_info);
385 :
386 : // Build effects array using the EffectData ADL hook
387 3 : nlohmann::json effects_arr = nlohmann::json::array();
388 6 : for (const auto &fx : preset.effects) {
389 3 : nlohmann::json jfx;
390 3 : to_json(jfx, fx);
391 3 : effects_arr.push_back(std::move(jfx));
392 3 : }
393 :
394 : // Build midi_mappings array
395 3 : nlohmann::json midi_arr = nlohmann::json::array();
396 3 : for (const auto &m : preset.midi_mappings) {
397 0 : nlohmann::json jm;
398 0 : to_json_midi(jm, m);
399 0 : midi_arr.push_back(std::move(jm));
400 0 : }
401 :
402 : // Build nodes and links arrays
403 3 : nlohmann::json nodes_arr = nlohmann::json::array();
404 3 : for (const auto &node : preset.nodes) {
405 0 : nlohmann::json jn = {{"id", node.id},
406 0 : {"type", node.type},
407 0 : {"position", {{"x", node.x}, {"y", node.y}}},
408 0 : {"enabled", node.enabled},
409 0 : {"mix", node.mix}};
410 0 : if (node.num_inputs > 0)
411 0 : jn["num_inputs"] = node.num_inputs;
412 0 : nlohmann::json params_obj = nlohmann::json::object();
413 0 : for (const auto &[name, value] : node.params) {
414 0 : params_obj[name] = value;
415 : }
416 0 : if (!node.params.empty())
417 0 : jn["params"] = params_obj;
418 0 : if (!node.metadata.empty())
419 0 : jn["metadata"] = node.metadata;
420 0 : nodes_arr.push_back(std::move(jn));
421 0 : }
422 :
423 3 : nlohmann::json links_arr = nlohmann::json::array();
424 3 : for (const auto &link : preset.links) {
425 0 : links_arr.push_back({{"src_pin", link.src_pin}, {"dst_pin", link.dst_pin}});
426 : }
427 :
428 10 : j = {
429 2 : {"format_version", 2},
430 3 : {"routing", preset.routing},
431 3 : {"name", preset.name},
432 3 : {"description", preset.description},
433 1 : {"saved_at", timebuf},
434 3 : {"input_gain", preset.input_gain},
435 3 : {"output_gain", preset.output_gain},
436 2 : {"midi_mappings", std::move(midi_arr)},
437 50 : };
438 :
439 3 : if (preset.routing == "linear") {
440 3 : j["effects"] = std::move(effects_arr);
441 1 : } else {
442 0 : j["nodes"] = std::move(nodes_arr);
443 0 : j["links"] = std::move(links_arr);
444 : }
445 39 : }
446 :
447 24 : void from_json(const nlohmann::json &j, PresetData &preset) {
448 24 : preset.name = j.value("name", std::string{});
449 24 : preset.description = j.value("description", std::string{});
450 24 : preset.routing = j.value("routing", std::string{"linear"});
451 24 : preset.input_gain = j.value("input_gain", 0.7f);
452 24 : preset.output_gain = j.value("output_gain", 0.8f);
453 :
454 24 : if (preset.routing == "graph") {
455 18 : if (!j.contains("nodes") || !j["nodes"].is_array()) {
456 6 : throw std::invalid_argument("Malformed graph preset: missing or invalid 'nodes' array");
457 : }
458 12 : if (!j.contains("links") || !j["links"].is_array()) {
459 6 : throw std::invalid_argument("Malformed graph preset: missing or invalid 'links' array");
460 : }
461 2 : }
462 :
463 : // Clear before repopulating so parsing into a non-empty PresetData never
464 : // duplicates/retains old entries.
465 12 : preset.effects.clear();
466 12 : preset.nodes.clear();
467 12 : preset.links.clear();
468 12 : preset.midi_mappings.clear();
469 :
470 12 : if (j.contains("effects") && j["effects"].is_array()) {
471 11 : for (const auto &jfx : j["effects"]) {
472 6 : PresetData::EffectData fx;
473 6 : from_json(jfx, fx);
474 6 : if (!fx.type.empty()) {
475 3 : preset.effects.push_back(std::move(fx));
476 1 : }
477 6 : }
478 1 : }
479 :
480 12 : if (j.contains("nodes") && j["nodes"].is_array()) {
481 30 : for (const auto &jn : j["nodes"]) {
482 15 : PresetData::NodeData node;
483 15 : node.id = jn.value("id", std::string{});
484 15 : node.type = jn.value("type", std::string{});
485 15 : node.enabled = jn.value("enabled", true);
486 15 : node.mix = jn.value("mix", 1.0f);
487 15 : node.num_inputs = jn.value("num_inputs", 0);
488 15 : if (jn.contains("position") && jn["position"].is_object()) {
489 0 : node.x = jn["position"].value("x", 0.0f);
490 0 : node.y = jn["position"].value("y", 0.0f);
491 0 : }
492 15 : if (jn.contains("params") && jn["params"].is_object()) {
493 9 : for (const auto &[key, val] : jn["params"].items()) {
494 8 : if (val.is_number())
495 5 : node.params.push_back({key, val.get<float>()});
496 3 : }
497 1 : }
498 15 : if (jn.contains("metadata") && jn["metadata"].is_object()) {
499 9 : for (const auto &[key, val] : jn["metadata"].items()) {
500 6 : if (val.is_string())
501 3 : node.metadata[key] = val.get<std::string>();
502 3 : }
503 1 : }
504 15 : if (!node.id.empty() && !node.type.empty()) {
505 9 : preset.nodes.push_back(std::move(node));
506 3 : }
507 15 : }
508 3 : }
509 :
510 12 : if (j.contains("links") && j["links"].is_array()) {
511 24 : for (const auto &jl : j["links"]) {
512 9 : PresetData::LinkData link;
513 9 : link.src_pin = jl.value("src_pin", std::string{});
514 9 : link.dst_pin = jl.value("dst_pin", std::string{});
515 9 : if (!link.src_pin.empty() && !link.dst_pin.empty()) {
516 5 : preset.links.push_back(std::move(link));
517 1 : }
518 9 : }
519 3 : }
520 :
521 12 : if (j.contains("midi_mappings") && j["midi_mappings"].is_array()) {
522 8 : for (const auto &jm : j["midi_mappings"]) {
523 3 : MidiMapping m;
524 3 : from_json_midi(jm, m);
525 3 : preset.midi_mappings.push_back(m);
526 3 : }
527 1 : }
528 12 : }
529 :
530 : // ============================================================
531 : // Public helpers used by PresetManager
532 : // ============================================================
533 :
534 123 : std::string to_json_ext(const PresetData &preset) {
535 123 : OrderedJson j;
536 123 : to_ordered_json(j, preset);
537 205 : return j.dump(4) + "\n";
538 123 : }
539 :
540 180 : bool from_json_ext(const std::string &json_str, PresetData &preset) {
541 : // Deserialize into a temporary so that a mid-way exception never leaves
542 : // `preset` in a partially-mutated state.
543 60 : try {
544 188 : OrderedJson j = OrderedJson::parse(json_str);
545 168 : PresetData tmp;
546 168 : from_ordered_json(j, tmp);
547 156 : preset = std::move(tmp);
548 156 : return true;
549 192 : } catch (const nlohmann::json::exception &e) {
550 12 : std::cerr << "[preset_json] JSON parse error: " << e.what() << std::endl;
551 12 : return false;
552 20 : } catch (const std::exception &e) {
553 12 : std::cerr << "[preset_json] Error: " << e.what() << std::endl;
554 12 : return false;
555 16 : }
556 68 : }
557 :
558 : } // namespace Amplitron
|