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