devalang_core/core/audio/engine/sample/
insert.rs

1use crate::config::ops::load_config;
2use devalang_types::Value;
3use devalang_types::VariableTable;
4use devalang_utils::path::normalize_path;
5use rodio::{Decoder, Source};
6use std::path::PathBuf;
7use std::{collections::HashMap, fs::File, io::BufReader, path::Path};
8
9pub fn insert_sample_impl(
10    engine: &mut crate::core::audio::engine::driver::AudioEngine,
11    filepath: &str,
12    time_secs: f32,
13    dur_sec: f32,
14    effects: Option<HashMap<String, Value>>,
15    variable_table: &VariableTable,
16) {
17    if filepath.is_empty() {
18        eprintln!("❌ Empty file path provided for audio sample.");
19        return;
20    }
21
22    let module_root = Path::new(&engine.module_name);
23    let root = match devalang_utils::path::get_project_root() {
24        Ok(p) => p,
25        Err(_) => std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
26    };
27    let resolved_path: String;
28
29    let mut var_path = filepath.to_string();
30    if let Some(Value::String(variable_path)) = variable_table.variables.get(filepath) {
31        var_path = variable_path.clone();
32    } else if let Some(Value::Sample(sample_path)) = variable_table.variables.get(filepath) {
33        var_path = sample_path.clone();
34    }
35
36    if var_path.starts_with("devalang://") {
37        let path_after_protocol = var_path.replace("devalang://", "");
38        let parts: Vec<&str> = path_after_protocol.split('/').collect();
39
40        if parts.len() < 3 {
41            eprintln!(
42                "❌ Invalid devalang:// path format. Expected devalang://<type>/<author>.<bank>/<entity>"
43            );
44            return;
45        }
46
47        let obj_type = parts[0];
48        let bank_name = parts[1];
49        // Rejoin the remainder as the entity path so bank entries can contain
50        // nested paths like "subdir/sample.wav" or plain names.
51        let entity_name = parts[2..].join("/");
52
53        let deva_dir = match devalang_utils::path::get_deva_dir() {
54            Ok(dir) => dir,
55            Err(e) => {
56                eprintln!("❌ {}", e);
57                return;
58            }
59        };
60        let subdir = match obj_type {
61            "bank" => "banks",
62            "plugin" => "plugins",
63            "preset" => "presets",
64            "template" => "templates",
65            other => other,
66        };
67
68        // Try both plural and singular folder names (some installs use 'bank' instead of 'banks')
69        let singular = if subdir.ends_with('s') {
70            &subdir[..subdir.len() - 1]
71        } else {
72            subdir
73        };
74
75        // Build a list of candidate addon roots to support different layouts:
76        // - legacy flat: .deva/<subdir>/<publisher>.<name>
77        // - nested: .deva/<subdir>/<publisher>/<name>
78        // Test both plural and singular folder names.
79        let mut candidate_roots: Vec<PathBuf> = Vec::new();
80        for sd in &[subdir, singular] {
81            let base = deva_dir.join(sd).join(bank_name);
82            candidate_roots.push(base.clone());
83
84            if bank_name.contains('.') {
85                let mut it = bank_name.splitn(2, '.');
86                let pubr = it.next().unwrap_or("");
87                let nm = it.next().unwrap_or("");
88                candidate_roots.push(deva_dir.join(sd).join(pubr).join(nm));
89            }
90        }
91
92        // If none of the candidate roots yields the asset, we will also
93        // try to lookup referenced addons from the project config.
94
95        // Helper to resolve audio path for a given addon root
96        let resolve_from_root = |root: &PathBuf| -> Option<String> {
97            // Determine audio dir: prefer audioPath in bank.toml, else audio/
98            let mut audio_dir = root.join("audio");
99            let bank_toml = root.join("bank.toml");
100            if bank_toml.exists() {
101                if let Ok(content) = std::fs::read_to_string(&bank_toml) {
102                    if let Ok(parsed) = toml::from_str::<toml::Value>(&content) {
103                        if let Some(ap) = parsed
104                            .get("audioPath")
105                            .or_else(|| parsed.get("audio_path"))
106                            .and_then(|v| v.as_str())
107                        {
108                            let ap_norm = ap.replace("\\", "/");
109                            audio_dir = root.join(ap_norm);
110                        }
111                    }
112                }
113            }
114
115            let candidate = audio_dir.join(&entity_name);
116            if candidate.exists() {
117                return Some(candidate.to_string_lossy().to_string());
118            }
119
120            let has_extension = std::path::Path::new(&entity_name).extension().is_some();
121            if !has_extension {
122                let wav_candidate = audio_dir.join(format!("{}.wav", entity_name));
123                if wav_candidate.exists() {
124                    return Some(wav_candidate.to_string_lossy().to_string());
125                }
126
127                let legacy_candidate = root.join(format!("{}.wav", entity_name));
128                if legacy_candidate.exists() {
129                    return Some(legacy_candidate.to_string_lossy().to_string());
130                }
131            } else {
132                let legacy_candidate = root.join(&entity_name);
133                if legacy_candidate.exists() {
134                    return Some(legacy_candidate.to_string_lossy().to_string());
135                }
136            }
137
138            None
139        };
140
141        let mut found: Option<String> = None;
142        for root in &candidate_roots {
143            if let Some(p) = resolve_from_root(root) {
144                found = Some(p);
145                break;
146            }
147        }
148
149        // If not found in typical layouts, try to find the addon referenced in the project config
150        if found.is_none() {
151            if let Ok(config_path) = devalang_utils::path::get_devalang_config_path() {
152                if let Some(cfg) = load_config(Some(&config_path)) {
153                    // Scan banks or plugins depending on obj_type
154                    if obj_type == "bank" {
155                        if let Some(banks) = cfg.banks {
156                            for b in banks {
157                                if let Some(name_in_path) = b.path.strip_prefix("devalang://bank/")
158                                {
159                                    // match by exact, suffix, or dot notation
160                                    if name_in_path == bank_name
161                                        || name_in_path.ends_with(bank_name)
162                                    {
163                                        let root = deva_dir.join(subdir).join(name_in_path);
164                                        if let Some(p) = resolve_from_root(&root) {
165                                            found = Some(p);
166                                            break;
167                                        }
168                                        // try nested layout
169                                        if name_in_path.contains('.') {
170                                            let mut it = name_in_path.splitn(2, '.');
171                                            let pubr = it.next().unwrap_or("");
172                                            let nm = it.next().unwrap_or("");
173                                            let root2 = deva_dir.join(subdir).join(pubr).join(nm);
174                                            if let Some(p) = resolve_from_root(&root2) {
175                                                found = Some(p);
176                                                break;
177                                            }
178                                        }
179                                    }
180                                }
181                            }
182                        }
183                    } else if obj_type == "plugin" {
184                        if let Some(plugins) = cfg.plugins {
185                            for p in plugins {
186                                if let Some(name_in_path) =
187                                    p.path.strip_prefix("devalang://plugin/")
188                                {
189                                    if name_in_path == bank_name
190                                        || name_in_path.ends_with(bank_name)
191                                    {
192                                        let root = deva_dir.join(subdir).join(name_in_path);
193                                        if let Some(path_found) = resolve_from_root(&root) {
194                                            found = Some(path_found);
195                                            break;
196                                        }
197                                        if name_in_path.contains('.') {
198                                            let mut it = name_in_path.splitn(2, '.');
199                                            let pubr = it.next().unwrap_or("");
200                                            let nm = it.next().unwrap_or("");
201                                            let root2 = deva_dir.join(subdir).join(pubr).join(nm);
202                                            if let Some(path_found) = resolve_from_root(&root2) {
203                                                found = Some(path_found);
204                                                break;
205                                            }
206                                        }
207                                    }
208                                }
209                            }
210                        }
211                    }
212                }
213            }
214        }
215
216        if let Some(p) = found {
217            resolved_path = p;
218        } else {
219            // Not found; fallback to legacy candidate for error message
220            let legacy = deva_dir
221                .join(subdir)
222                .join(bank_name)
223                .join(format!("{}.wav", entity_name));
224            resolved_path = legacy.to_string_lossy().to_string();
225        }
226    } else {
227        let entry_dir = module_root.parent().unwrap_or(&root);
228        let absolute_path = root.join(entry_dir).join(&var_path);
229
230        resolved_path = normalize_path(absolute_path.to_string_lossy().to_string());
231    }
232
233    if !Path::new(&resolved_path).exists() {
234        eprintln!("❌ Unknown trigger or missing audio file: {}", filepath);
235        return;
236    }
237
238    let file = match File::open(&resolved_path) {
239        Ok(f) => BufReader::new(f),
240        Err(e) => {
241            eprintln!("❌ Failed to open audio file {}: {}", resolved_path, e);
242            return;
243        }
244    };
245
246    let decoder = match Decoder::new(file) {
247        Ok(d) => d,
248        Err(e) => {
249            eprintln!("❌ Failed to decode audio file {}: {}", resolved_path, e);
250            return;
251        }
252    };
253
254    // Read frames from decoder and convert to mono if needed.
255    let sample_rate = engine.sample_rate as f32;
256    let channels = engine.channels as usize;
257
258    let max_frames = (dur_sec * sample_rate) as usize;
259    let dec_channels = decoder.channels() as usize;
260    let max_raw_samples = max_frames.saturating_mul(dec_channels.max(1));
261    let raw_samples: Vec<i16> = decoder.convert_samples().take(max_raw_samples).collect();
262
263    // Convert interleaved channels to mono by averaging channels per frame.
264    // Apply a small RMS-preserving scale so mono level is similar to mixed stereo.
265    let actual_frames = if dec_channels > 0 {
266        raw_samples.len() / dec_channels
267    } else {
268        0
269    };
270    let mut samples: Vec<i16> = Vec::with_capacity(actual_frames);
271    let rms_scale = (dec_channels as f32).sqrt();
272    for frame in 0..actual_frames {
273        let mut sum: i32 = 0;
274        for ch in 0..dec_channels {
275            sum += raw_samples[frame * dec_channels + ch] as i32;
276        }
277        if dec_channels > 0 {
278            let avg = (sum / (dec_channels as i32)) as f32;
279            let scaled = (avg * rms_scale).clamp(i16::MIN as f32, i16::MAX as f32) as i16;
280            samples.push(scaled);
281        } else {
282            samples.push(0);
283        }
284    }
285
286    if samples.is_empty() {
287        eprintln!("❌ No samples read from {}", resolved_path);
288        return;
289    }
290
291    let offset = (time_secs * sample_rate * (channels as f32)) as usize;
292    let required_len = offset + samples.len() * (channels as usize);
293    if engine.buffer.len() < required_len {
294        engine.buffer.resize(required_len, 0);
295    }
296
297    crate::core::audio::engine::sample::padding::pad_samples_impl(
298        engine, &samples, time_secs, effects,
299    );
300}