rustriff_lib/commands/effect_commands/
cabinet_ir.rs1use crate::domain::channel::Channel;
2use crate::domain::dto::effect::ir_profile_dto::IrProfileDto;
3use crate::services::audio_service::AudioService;
4use crate::services::file_service::FileService;
5use std::collections::HashSet;
6use std::sync::Mutex;
7use tracing::{info, warn};
8
9#[tauri::command]
23pub fn get_all_ir_profiles(
24 file_service: tauri::State<FileService>,
25 audio_service: tauri::State<Mutex<AudioService>>,
26) -> Result<Vec<IrProfileDto>, String> {
27 let used_profiles = used_ir_profiles(&audio_service).map_err(|err| {
28 warn!("get_all_ir_profiles failed while reading used profiles: {err}");
29 err
30 })?;
31 let mut profiles = file_service.get_all_ir_profiles().map_err(|err| {
32 warn!("get_all_ir_profiles failed while reading profile inventory: {err}");
33 err
34 })?;
35
36 mark_profiles_in_use(&mut profiles, &used_profiles);
37
38 Ok(profiles)
39}
40
41#[tauri::command]
56pub fn upload_ir_profile(
57 file_service: tauri::State<FileService>,
58 file_name: String,
59 file_bytes: Vec<u8>,
60) -> Result<String, String> {
61 info!(
62 "Uploading custom IR profile '{}' ({} bytes)",
63 file_name,
64 file_bytes.len()
65 );
66 file_service
67 .save_custom_ir_profile(&file_name, &file_bytes)
68 .map_err(|err| {
69 warn!("upload_ir_profile failed for '{}': {err}", file_name);
70 err
71 })
72}
73
74#[tauri::command]
85pub fn remove_ir_profile(
86 file_service: tauri::State<FileService>,
87 audio_service: tauri::State<Mutex<AudioService>>,
88 file_name: String,
89) -> Result<(), String> {
90 let profiles = file_service.get_all_ir_profiles().map_err(|err| {
91 warn!("remove_ir_profile failed while reading profile inventory: {err}");
92 err
93 })?;
94
95 let used_profiles = used_ir_profiles(&audio_service).map_err(|err| {
96 warn!("remove_ir_profile failed while checking chain usage: {err}");
97 err
98 })?;
99 ensure_profile_can_be_removed(&profiles, &file_name, &used_profiles)?;
100
101 file_service
102 .remove_custom_ir_profile(&file_name)
103 .map_err(|err| {
104 warn!("remove_ir_profile failed for '{}': {err}", file_name);
105 err
106 })
107}
108
109fn mark_profiles_in_use(profiles: &mut [IrProfileDto], used_profiles: &HashSet<String>) {
110 for profile in profiles {
111 profile.is_in_use = used_profiles.contains(&profile.file_name);
112 }
113}
114
115fn ensure_profile_can_be_removed(
116 profiles: &[IrProfileDto],
117 file_name: &str,
118 used_profiles: &HashSet<String>,
119) -> Result<(), String> {
120 let profile = profiles
121 .iter()
122 .find(|p| p.file_name == file_name)
123 .ok_or_else(|| format!("IR profile '{}' not found", file_name))?;
124
125 if !profile.is_custom {
126 return Err("Default IR profiles cannot be removed".to_string());
127 }
128
129 if used_profiles.contains(file_name) {
130 return Err(format!(
131 "IR profile '{}' is currently used by an effect chain",
132 file_name
133 ));
134 }
135
136 Ok(())
137}
138
139fn used_ir_profiles(
145 audio_service: &tauri::State<Mutex<AudioService>>,
146) -> Result<HashSet<String>, String> {
147 let service = audio_service
148 .lock()
149 .map_err(|_| "Failed to lock audio service".to_string())?;
150 collect_used_ir_profiles(service.channels())
151}
152
153fn collect_used_ir_profiles(channels: &[Channel]) -> Result<HashSet<String>, String> {
154 let mut used = HashSet::new();
155 for channel in channels.iter() {
156 used.extend(channel.used_cabinet_ir_profiles());
157 }
158
159 Ok(used)
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::services::audio_service::AudioService;
166 use crate::services::effects::cabinet::cabinet::Cabinet;
167 use crate::tests::mock::make_mock_handler;
168 use std::sync::Arc;
169
170 fn profile(file_name: &str, is_custom: bool) -> IrProfileDto {
171 IrProfileDto {
172 file_name: file_name.to_string(),
173 label: file_name.to_string(),
174 is_custom,
175 is_in_use: false,
176 }
177 }
178
179 #[cfg(test)]
180 mod success_path {
181 use super::*;
182
183 #[test]
184 fn collect_used_ir_profiles_deduplicates_cabinet_profiles_across_channels() {
185 let mut service = AudioService::new_with_handler(Arc::new(make_mock_handler()));
186
187 service.channels_mut()[0].add_effect_to_chain(Box::new(Cabinet::new(
188 0,
189 "Cab A".to_string(),
190 true,
191 "#111111".to_string(),
192 "Reverb-oxford-lean.wav".to_string(),
193 48_000,
194 )));
195
196 let second_channel_id = service.add_channel("Lead".to_string());
197 let second_channel = service
198 .channels_mut()
199 .iter_mut()
200 .find(|channel| channel.id() == second_channel_id)
201 .expect("second channel should exist");
202 second_channel.add_effect_to_chain(Box::new(Cabinet::new(
203 0,
204 "Cab B".to_string(),
205 true,
206 "#222222".to_string(),
207 "Reverb-oxford-lean.wav".to_string(),
208 48_000,
209 )));
210 second_channel.add_effect_to_chain(Box::new(Cabinet::new(
211 1,
212 "Cab C".to_string(),
213 true,
214 "#333333".to_string(),
215 "Vox-ac30.wav".to_string(),
216 48_000,
217 )));
218
219 let used = collect_used_ir_profiles(service.channels())
220 .expect("used IR profile discovery should succeed");
221
222 assert_eq!(used.len(), 2);
223 assert!(used.contains("Reverb-oxford-lean.wav"));
224 assert!(used.contains("Vox-ac30.wav"));
225 }
226
227 #[test]
228 fn mark_profiles_in_use_sets_flags_from_used_set() {
229 let mut profiles = vec![
230 profile("Vox-ac30.wav", false),
231 profile("custom-room.wav", true),
232 ];
233 let used_profiles = HashSet::from(["custom-room.wav".to_string()]);
234
235 mark_profiles_in_use(&mut profiles, &used_profiles);
236
237 assert!(!profiles[0].is_in_use);
238 assert!(profiles[1].is_in_use);
239 }
240
241 #[test]
242 fn ensure_profile_can_be_removed_accepts_unused_custom_profile() {
243 let profiles = vec![profile("custom-room.wav", true)];
244
245 ensure_profile_can_be_removed(&profiles, "custom-room.wav", &HashSet::new())
246 .expect("unused custom profile should be removable");
247 }
248
249 #[test]
250 fn collect_used_ir_profiles_reflects_restored_chain_metadata() {
251 let mut service = AudioService::new_with_handler(Arc::new(make_mock_handler()));
252
253 service.channels_mut()[0].add_effect_to_chain(Box::new(Cabinet::new(
254 0,
255 "Cab A".to_string(),
256 true,
257 "#111111".to_string(),
258 "Vox-ac30.wav".to_string(),
259 48_000,
260 )));
261
262 service.channels_mut()[0].restore_effect_chain(vec![Box::new(Cabinet::new(
263 7,
264 "Cab B".to_string(),
265 true,
266 "#222222".to_string(),
267 "Reverb-oxford-lean.wav".to_string(),
268 48_000,
269 ))]);
270
271 let used = collect_used_ir_profiles(service.channels())
272 .expect("restored chain usage discovery should succeed");
273
274 assert_eq!(used.len(), 1);
275 assert!(used.contains("Reverb-oxford-lean.wav"));
276 assert!(!used.contains("Vox-ac30.wav"));
277 }
278 }
279
280 #[cfg(test)]
281 mod failure_path {
282 use super::*;
283
284 #[test]
285 fn ensure_profile_can_be_removed_rejects_missing_profile() {
286 let profiles = vec![profile("Vox-ac30.wav", false)];
287
288 let err = ensure_profile_can_be_removed(&profiles, "missing.wav", &HashSet::new())
289 .expect_err("missing profile should fail");
290 assert!(err.contains("not found"));
291 }
292
293 #[test]
294 fn ensure_profile_can_be_removed_rejects_default_profile() {
295 let profiles = vec![profile("Vox-ac30.wav", false)];
296
297 assert_eq!(
298 ensure_profile_can_be_removed(&profiles, "Vox-ac30.wav", &HashSet::new())
299 .expect_err("default profile should be protected"),
300 "Default IR profiles cannot be removed"
301 );
302 }
303
304 #[test]
305 fn ensure_profile_can_be_removed_rejects_in_use_custom_profile() {
306 let profiles = vec![profile("custom-room.wav", true)];
307 let used_profiles = HashSet::from(["custom-room.wav".to_string()]);
308
309 let err = ensure_profile_can_be_removed(&profiles, "custom-room.wav", &used_profiles)
310 .expect_err("in-use profile should be protected");
311 assert!(err.contains("currently used by an effect chain"));
312 }
313 }
314}