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