Line data Source code
1 : #include "gui/views/gui_presets.h"
2 :
3 : #include <imgui.h>
4 :
5 : #include <algorithm>
6 : #include <cstdio>
7 : #include <cstring>
8 :
9 : #include "audio/effects/amp_cab/cabinet_sim.h"
10 : #include "gui/commands/command.h"
11 : #include "gui/pedalboard/pedal_board.h"
12 : #include "gui/theme/theme.h"
13 : #include "preset_json.h"
14 :
15 : #ifdef __EMSCRIPTEN__
16 : #include <emscripten.h>
17 :
18 : extern "C" {
19 : EMSCRIPTEN_KEEPALIVE void load_preset_callback(uintptr_t gui_ptr, const char* path) {
20 : if (gui_ptr && path) {
21 : auto* gui = reinterpret_cast<Amplitron::GuiPresets*>(gui_ptr);
22 : gui->load_preset_by_path(path);
23 : }
24 : }
25 : }
26 : #endif
27 :
28 : namespace Amplitron {
29 :
30 : /**
31 : * @brief Capture the current engine state into a PresetData snapshot.
32 : * @param engine The audio engine whose current setting should be captured.
33 : * @return PresetData representing the live engine configuration.
34 : */
35 156 : static PresetData capture_current_state(IAudioEngine& engine) {
36 156 : PresetData preset;
37 156 : preset.input_gain = engine.get_input_gain();
38 156 : preset.output_gain = engine.get_output_gain();
39 :
40 417 : for (auto& fx : engine.effects()) {
41 261 : PresetData::EffectData fd;
42 261 : fd.type = fx->name();
43 261 : fd.enabled = fx->is_enabled();
44 261 : fd.mix = fx->get_mix();
45 1275 : for (auto& p : fx->params()) {
46 1352 : fd.params.push_back({p.name, p.value});
47 : }
48 :
49 261 : if (std::strcmp(fx->name(), "Cabinet") == 0) {
50 0 : auto* cab = dynamic_cast<CabinetSim*>(fx.get());
51 0 : if (cab && cab->has_ir()) {
52 0 : fd.metadata["ir_path"] = cab->ir_path();
53 0 : }
54 0 : }
55 :
56 261 : preset.effects.push_back(std::move(fd));
57 261 : }
58 :
59 156 : return preset;
60 52 : }
61 :
62 : /**
63 : * @brief Compare two effect snapshots for exact equality.
64 : * @param a First effect snapshot.
65 : * @param b Second effect snapshot.
66 : * @return true if the effect data are identical.
67 : */
68 6 : static bool equal_effect_data(const PresetData::EffectData& a, const PresetData::EffectData& b) {
69 6 : if (a.type != b.type || a.enabled != b.enabled || a.mix != b.mix) return false;
70 6 : if (a.params.size() != b.params.size()) return false;
71 6 : if (a.metadata != b.metadata) return false;
72 24 : for (size_t i = 0; i < a.params.size(); ++i) {
73 18 : if (a.params[i] != b.params[i]) return false;
74 6 : }
75 4 : return true;
76 2 : }
77 :
78 : /**
79 : * @brief Compare two preset snapshots for exact equality.
80 : * @param a First preset snapshot.
81 : * @param b Second preset snapshot.
82 : * @return true if the preset snapshots are identical.
83 : */
84 24 : static bool equal_preset_data(const PresetData& a, const PresetData& b) {
85 24 : if (a.input_gain != b.input_gain || a.output_gain != b.output_gain) return false;
86 24 : if (a.effects.size() != b.effects.size()) return false;
87 21 : for (size_t i = 0; i < a.effects.size(); ++i) {
88 6 : if (!equal_effect_data(a.effects[i], b.effects[i])) return false;
89 2 : }
90 10 : return true;
91 8 : }
92 :
93 140 : GuiPresets::GuiPresets(IAudioEngine& engine, CommandHistory& history, IPresetManager& presets)
94 112 : : engine_(engine), history_(history), presets_(presets) {
95 84 : mark_clean();
96 84 : }
97 :
98 24 : bool GuiPresets::is_dirty() const {
99 24 : if (!saved_state_valid_) return false;
100 24 : return !equal_preset_data(saved_state_, capture_current_state(engine_));
101 8 : }
102 :
103 33 : std::string GuiPresets::current_preset_name() const {
104 33 : if (preset_name_buf_[0] != '\0') {
105 44 : return std::string(preset_name_buf_);
106 : }
107 0 : return "Untitled";
108 11 : }
109 :
110 129 : void GuiPresets::mark_clean() {
111 129 : saved_state_ = capture_current_state(engine_);
112 129 : saved_state_valid_ = true;
113 129 : }
114 :
115 60 : std::string GuiPresets::preset_name_from_path(const std::string& filepath) const {
116 60 : size_t slash = filepath.find_last_of("/\\");
117 60 : std::string name = (slash == std::string::npos) ? filepath : filepath.substr(slash + 1);
118 60 : if (name.size() > 5 && name.substr(name.size() - 5) == ".json") {
119 60 : name = name.substr(0, name.size() - 5);
120 20 : }
121 60 : return name;
122 20 : }
123 :
124 12 : std::string GuiPresets::preset_path_from_name(const std::string& preset_name) const {
125 12 : std::string filename = preset_name;
126 204 : for (char& c : filename) {
127 192 : if (c == ' ') c = '_';
128 252 : if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' ||
129 120 : c == '>' || c == '|') {
130 12 : c = '_';
131 4 : }
132 : }
133 12 : if (filename.empty()) return "";
134 20 : return presets_.get_presets_directory() + "/" + filename + ".json";
135 12 : }
136 :
137 33 : void GuiPresets::refresh_presets(bool preserve_selection) {
138 33 : std::string selected_path;
139 34 : if (preserve_selection && selected_preset_index_ >= 0 &&
140 3 : selected_preset_index_ < static_cast<int>(preset_files_.size())) {
141 3 : selected_path = preset_files_[selected_preset_index_];
142 1 : }
143 :
144 33 : preset_files_ = presets_.list_presets();
145 33 : std::sort(preset_files_.begin(), preset_files_.end());
146 :
147 33 : selected_preset_index_ = -1;
148 33 : if (!selected_path.empty()) {
149 3 : for (int i = 0; i < static_cast<int>(preset_files_.size()); ++i) {
150 3 : if (preset_files_[i] == selected_path) {
151 3 : selected_preset_index_ = i;
152 3 : break;
153 : }
154 0 : }
155 1 : }
156 33 : if (selected_preset_index_ < 0 && !preset_files_.empty()) {
157 30 : selected_preset_index_ = 0;
158 10 : }
159 :
160 44 : if (selected_preset_index_ >= 0 &&
161 33 : selected_preset_index_ < static_cast<int>(preset_files_.size())) {
162 33 : std::snprintf(preset_name_buf_, sizeof(preset_name_buf_), "%s",
163 55 : preset_name_from_path(preset_files_[selected_preset_index_]).c_str());
164 11 : }
165 33 : }
166 :
167 15 : bool GuiPresets::save_named_preset(const std::string& preset_name, const std::string& description) {
168 15 : if (preset_name.empty()) {
169 3 : preset_status_msg_ = "Error: Preset name cannot be empty.";
170 3 : return false;
171 : }
172 :
173 12 : std::string path = preset_path_from_name(preset_name);
174 12 : if (path.empty()) {
175 0 : preset_status_msg_ = "Error: Invalid preset name.";
176 0 : return false;
177 : }
178 :
179 16 : if (presets_.save_preset(
180 4 : path, preset_name, description, engine_,
181 20 : midi_manager_ ? midi_manager_->mappings() : std::vector<MidiMapping>())) {
182 12 : preset_status_msg_ = "Saved: " + preset_name;
183 12 : refresh_presets(true);
184 88 : for (int i = 0; i < static_cast<int>(preset_files_.size()); ++i) {
185 84 : if (preset_files_[i] == path) {
186 8 : selected_preset_index_ = i;
187 8 : break;
188 : }
189 24 : }
190 12 : if (pedal_board_) pedal_board_->rebuild_widgets();
191 12 : mark_clean();
192 :
193 : #ifdef __EMSCRIPTEN__
194 : std::string json_content = serialise_current_preset_to_json();
195 : EM_ASM(
196 : {
197 : var filename = UTF8ToString($0);
198 : var content = UTF8ToString($1);
199 : var blob = new Blob([content], {
200 : type:
201 : "application/json"
202 : });
203 : var url = URL.createObjectURL(blob);
204 : var a = document.createElement("a");
205 : a.href = url;
206 : a.download = filename;
207 : document.body.appendChild(a);
208 : a.click();
209 : document.body.removeChild(a);
210 : URL.revokeObjectURL(url);
211 : },
212 : (preset_name + ".json").c_str(), json_content.c_str());
213 : #endif
214 :
215 8 : return true;
216 : }
217 :
218 0 : preset_status_msg_ = "Error: " + presets_.get_last_error();
219 0 : return false;
220 13 : }
221 :
222 30 : bool GuiPresets::load_preset_by_index(int index) {
223 30 : if (index < 0 || index >= static_cast<int>(preset_files_.size())) {
224 6 : preset_status_msg_ = "Error: No preset selected.";
225 6 : return false;
226 : }
227 :
228 24 : const std::string& path = preset_files_[index];
229 24 : std::vector<LoadPresetCommand::EffectSnapshot> before_state;
230 225 : for (auto& fx : engine_.effects()) {
231 201 : LoadPresetCommand::EffectSnapshot snap;
232 201 : snap.effect = fx;
233 201 : snap.enabled = fx->is_enabled();
234 201 : snap.mix = fx->get_mix();
235 1002 : for (auto& p : fx->params()) snap.param_values.push_back(p.value);
236 201 : before_state.push_back(std::move(snap));
237 201 : }
238 24 : float before_in = engine_.get_input_gain();
239 24 : float before_out = engine_.get_output_gain();
240 :
241 24 : if (presets_.load_preset(path, engine_, midi_manager_)) {
242 24 : std::vector<LoadPresetCommand::EffectSnapshot> after_state;
243 255 : for (auto& fx : engine_.effects()) {
244 231 : LoadPresetCommand::EffectSnapshot snap;
245 231 : snap.effect = fx;
246 231 : snap.enabled = fx->is_enabled();
247 231 : snap.mix = fx->get_mix();
248 1155 : for (auto& p : fx->params()) snap.param_values.push_back(p.value);
249 231 : after_state.push_back(std::move(snap));
250 231 : }
251 24 : float after_in = engine_.get_input_gain();
252 24 : float after_out = engine_.get_output_gain();
253 :
254 24 : history_.clear();
255 24 : auto cmd = std::make_unique<LoadPresetCommand>(engine_, std::move(before_state), before_in,
256 8 : before_out, std::move(after_state), after_in,
257 16 : after_out);
258 24 : history_.push_executed(std::move(cmd));
259 :
260 24 : selected_preset_index_ = index;
261 24 : std::string display = preset_name_from_path(path);
262 24 : std::snprintf(preset_name_buf_, sizeof(preset_name_buf_), "%s", display.c_str());
263 24 : preset_status_msg_ = "Loaded: " + display;
264 24 : if (pedal_board_) pedal_board_->rebuild_widgets();
265 24 : mark_clean();
266 24 : return true;
267 24 : }
268 :
269 0 : preset_status_msg_ = "Error: " + presets_.get_last_error();
270 0 : return false;
271 26 : }
272 :
273 3 : bool GuiPresets::load_preset_by_path(const std::string& path) {
274 3 : refresh_presets(false);
275 3 : int found_idx = -1;
276 21 : for (int i = 0; i < static_cast<int>(preset_files_.size()); ++i) {
277 18 : if (preset_files_[i] == path) {
278 0 : found_idx = i;
279 0 : break;
280 : }
281 6 : }
282 3 : if (found_idx != -1) {
283 0 : return load_preset_by_index(found_idx);
284 : }
285 3 : preset_status_msg_ = "Error: Preset not found after upload.";
286 3 : return false;
287 1 : }
288 :
289 9 : bool GuiPresets::delete_preset_by_index(int index) {
290 9 : if (index < 0 || index >= static_cast<int>(preset_files_.size())) {
291 6 : preset_status_msg_ = "Error: No preset selected.";
292 6 : return false;
293 : }
294 :
295 3 : std::string path = preset_files_[index];
296 3 : std::string display = preset_name_from_path(path);
297 3 : if (presets_.delete_preset(path)) {
298 3 : preset_status_msg_ = "Deleted: " + display;
299 3 : refresh_presets(false);
300 2 : return true;
301 : }
302 :
303 1 : preset_status_msg_ = "Error: Could not delete preset file.";
304 0 : return false;
305 5 : }
306 :
307 6 : void GuiPresets::ensure_factory_presets() {
308 8 : if (factory_presets_initialized_) return;
309 3 : factory_presets_initialized_ = true;
310 :
311 3 : if (!presets_.list_presets().empty()) return;
312 :
313 0 : std::vector<PresetData> factory_presets;
314 :
315 0 : PresetData clean;
316 0 : clean.name = "Clean";
317 0 : clean.description = "Low gain, slight reverb, flat EQ";
318 0 : clean.input_gain = 0.6f;
319 0 : clean.output_gain = 0.85f;
320 0 : clean.effects.push_back({"Compressor", true, 0.25f, {}, {}});
321 0 : clean.effects.push_back({"Equalizer", true, 1.0f, {}, {}});
322 0 : clean.effects.push_back({"Reverb", true, 0.2f, {}, {}});
323 0 : clean.effects.push_back({"Cabinet", true, 1.0f, {}, {}});
324 0 : factory_presets.push_back(clean);
325 :
326 0 : PresetData crunch;
327 0 : crunch.name = "Crunch";
328 0 : crunch.description = "Mild overdrive with mid-forward response";
329 0 : crunch.input_gain = 0.85f;
330 0 : crunch.output_gain = 0.9f;
331 0 : crunch.effects.push_back({"Noise Gate", true, 0.35f, {}, {}});
332 0 : crunch.effects.push_back({"Overdrive", true, 0.55f, {}, {}});
333 0 : crunch.effects.push_back({"Equalizer", true, 1.0f, {}, {}});
334 0 : crunch.effects.push_back({"Cabinet", true, 1.0f, {}, {}});
335 0 : factory_presets.push_back(crunch);
336 :
337 0 : PresetData metal;
338 0 : metal.name = "Metal";
339 0 : metal.description = "High distortion with scooped mids and tight cabinet";
340 0 : metal.input_gain = 1.15f;
341 0 : metal.output_gain = 0.75f;
342 0 : metal.effects.push_back({"Noise Gate", true, 0.85f, {}, {}});
343 0 : metal.effects.push_back({"Distortion", true, 0.9f, {}, {}});
344 0 : metal.effects.push_back({"Equalizer", true, 1.0f, {}, {}});
345 0 : metal.effects.push_back({"Cabinet", true, 1.0f, {}, {}});
346 0 : factory_presets.push_back(metal);
347 :
348 0 : PresetData jazz;
349 0 : jazz.name = "Jazz";
350 0 : jazz.description = "Clean, warm tone with light compression";
351 0 : jazz.input_gain = 0.55f;
352 0 : jazz.output_gain = 0.9f;
353 0 : jazz.effects.push_back({"Compressor", true, 0.4f, {}, {}});
354 0 : jazz.effects.push_back({"Equalizer", true, 1.0f, {}, {}});
355 0 : jazz.effects.push_back({"Reverb", true, 0.12f, {}, {}});
356 0 : jazz.effects.push_back({"Cabinet", true, 1.0f, {}, {}});
357 0 : factory_presets.push_back(jazz);
358 :
359 0 : for (const auto& preset : factory_presets) {
360 0 : presets_.save_preset_data(preset_path_from_name(preset.name), preset);
361 : }
362 2 : }
363 :
364 3 : void GuiPresets::begin_new_preset() {
365 3 : selected_preset_index_ = -1;
366 3 : preset_name_buf_[0] = '\0';
367 3 : preset_desc_buf_[0] = '\0';
368 3 : preset_dialog_is_new_ = true;
369 3 : mark_clean();
370 3 : }
371 :
372 3 : void GuiPresets::begin_save_preset() { preset_dialog_is_new_ = false; }
373 :
374 3 : void GuiPresets::render_save_popup(bool& show) {
375 3 : ImGui::SetNextWindowSize(ImVec2(420, 250), ImGuiCond_FirstUseEver);
376 3 : if (!ImGui::Begin("Save Preset", &show)) {
377 0 : ImGui::End();
378 0 : return;
379 : }
380 :
381 3 : ImGui::Text("Save current pedal configuration as a preset.");
382 3 : ImGui::Spacing();
383 :
384 3 : ImGui::Text("Preset Name:");
385 3 : ImGui::SetNextItemWidth(-1);
386 3 : ImGui::InputText("##preset_name", preset_name_buf_, sizeof(preset_name_buf_));
387 :
388 3 : ImGui::Spacing();
389 3 : ImGui::Text("Description (optional):");
390 3 : ImGui::SetNextItemWidth(-1);
391 4 : ImGui::InputTextMultiline("##preset_desc", preset_desc_buf_, sizeof(preset_desc_buf_),
392 2 : ImVec2(-1, 60));
393 :
394 3 : ImGui::Spacing();
395 3 : if (preset_dialog_is_new_) {
396 0 : if (ImGui::Button("Save", ImVec2(100, 30))) {
397 0 : if (save_named_preset(std::string(preset_name_buf_), std::string(preset_desc_buf_))) {
398 0 : show = false;
399 0 : }
400 0 : }
401 0 : ImGui::SameLine();
402 0 : if (ImGui::Button("Don't Save", ImVec2(100, 30))) {
403 0 : show = false;
404 0 : }
405 0 : ImGui::SameLine();
406 0 : if (ImGui::Button("Cancel", ImVec2(100, 30))) {
407 0 : show = false;
408 0 : }
409 0 : } else {
410 3 : if (ImGui::Button("Save", ImVec2(120, 30))) {
411 0 : if (save_named_preset(std::string(preset_name_buf_), std::string(preset_desc_buf_))) {
412 0 : show = false;
413 0 : }
414 0 : }
415 3 : ImGui::SameLine();
416 3 : if (ImGui::Button("Cancel", ImVec2(120, 30))) {
417 0 : show = false;
418 0 : }
419 : }
420 :
421 3 : if (!preset_status_msg_.empty()) {
422 0 : ImGui::Spacing();
423 0 : ImGui::TextWrapped("%s", preset_status_msg_.c_str());
424 0 : }
425 :
426 3 : ImGui::End();
427 1 : }
428 :
429 3 : void GuiPresets::render_load_popup(bool& show) {
430 3 : ImGui::SetNextWindowSize(ImVec2(500, 400), ImGuiCond_FirstUseEver);
431 3 : if (!ImGui::Begin("Load Preset", &show)) {
432 0 : ImGui::End();
433 0 : return;
434 : }
435 :
436 3 : ImGui::Text("Select a preset to load:");
437 3 : ImGui::Spacing();
438 :
439 3 : if (ImGui::Button("Refresh List")) {
440 0 : refresh_presets(true);
441 0 : }
442 :
443 : #ifdef __EMSCRIPTEN__
444 : ImGui::SameLine();
445 : if (ImGui::Button("Upload from Computer...")) {
446 : EM_ASM(
447 : {
448 : var gui_ptr = $0;
449 : var input = document.createElement('input');
450 : input.type = 'file';
451 : input.accept = '.json';
452 : input.onchange = function(e) {
453 : var file = e.target.files[0];
454 : var reader = new FileReader();
455 : reader.onload = function(re) {
456 : var content = re.target.result;
457 : var path = "presets/" + file.name;
458 : FS.writeFile(path, content);
459 : Module.ccall('load_preset_callback', 'v', [ 'number', 'string' ],
460 : [ gui_ptr, path ]);
461 : };
462 : reader.readAsText(file);
463 : };
464 : input.click();
465 : },
466 : (uintptr_t)this);
467 : show = false;
468 : }
469 : #endif
470 :
471 3 : ImGui::Spacing();
472 3 : ImGui::BeginChild("PresetList", ImVec2(0, -70), true);
473 :
474 3 : if (preset_files_.empty()) {
475 4 : ImGui::TextColored(
476 2 : ImVec4(0.6f, 0.6f, 0.6f, 1.0f),
477 : "No presets found in '%s/' folder.\nSave a preset first, or place .json files there.",
478 5 : presets_.get_presets_directory().c_str());
479 1 : }
480 :
481 3 : for (int i = 0; i < static_cast<int>(preset_files_.size()); ++i) {
482 0 : auto& filepath = preset_files_[i];
483 0 : std::string display = preset_name_from_path(filepath);
484 :
485 0 : bool is_selected = (i == selected_preset_index_);
486 0 : if (ImGui::Selectable(display.c_str(), is_selected)) {
487 0 : if (load_preset_by_index(i)) {
488 0 : show = false;
489 0 : }
490 0 : }
491 0 : }
492 3 : ImGui::EndChild();
493 :
494 3 : ImGui::Spacing();
495 3 : if (ImGui::Button("Cancel", ImVec2(120, 30))) {
496 0 : show = false;
497 0 : }
498 :
499 3 : if (!preset_status_msg_.empty()) {
500 0 : ImGui::SameLine();
501 0 : ImGui::TextWrapped("%s", preset_status_msg_.c_str());
502 0 : }
503 :
504 3 : ImGui::End();
505 1 : }
506 :
507 3 : std::string GuiPresets::serialise_current_preset_to_json() const {
508 3 : PresetData preset = capture_current_state(engine_);
509 3 : preset.name = current_preset_name();
510 3 : if (midi_manager_) {
511 0 : preset.midi_mappings = midi_manager_->mappings();
512 0 : }
513 5 : return to_json_ext(preset);
514 3 : }
515 :
516 : } // namespace Amplitron
|