devalang_wasm/engine/audio/samples/
mod.rs

1//! Sample loading and management for native builds
2//!
3//! This module provides functionality to load sample banks from disk (TOML manifests + WAV files)
4//! and manage a registry of loaded samples for use in audio rendering.
5
6#![cfg(feature = "cli")]
7
8use anyhow::{Context, Result};
9use once_cell::sync::Lazy;
10use serde::Deserialize;
11use std::collections::HashMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15
16/// Global sample registry for native builds
17static SAMPLE_REGISTRY: Lazy<Arc<Mutex<SampleRegistry>>> =
18    Lazy::new(|| Arc::new(Mutex::new(SampleRegistry::new())));
19
20/// Bank manifest structure (from bank.toml)
21#[derive(Debug, Deserialize)]
22struct BankManifest {
23    bank: BankInfo,
24    triggers: Vec<TriggerInfo>,
25}
26
27#[derive(Debug, Deserialize)]
28struct BankInfo {
29    name: String,
30    publisher: String,
31    audio_path: String,
32    #[allow(dead_code)]
33    description: Option<String>,
34    #[allow(dead_code)]
35    version: Option<String>,
36    #[allow(dead_code)]
37    access: Option<String>,
38}
39
40#[derive(Debug, Deserialize)]
41struct TriggerInfo {
42    name: String,
43    path: String,
44}
45
46/// Sample data (mono f32 PCM)
47#[derive(Clone, Debug)]
48pub struct SampleData {
49    pub samples: Vec<f32>,
50    pub sample_rate: u32,
51}
52
53/// Bank metadata for lazy loading
54#[derive(Debug, Clone)]
55pub struct BankMetadata {
56    bank_id: String,
57    bank_path: PathBuf,
58    audio_path: String,
59    triggers: HashMap<String, String>, // trigger_name -> file_path
60}
61
62/// Sample registry for managing loaded samples with lazy loading
63#[derive(Debug)]
64pub struct SampleRegistry {
65    samples: HashMap<String, SampleData>,  // Loaded samples cache
66    banks: HashMap<String, BankMetadata>,  // Bank metadata for lazy loading
67    loaded_samples: HashMap<String, bool>, // Track which samples are loaded
68}
69
70impl SampleRegistry {
71    fn new() -> Self {
72        Self {
73            samples: HashMap::new(),
74            banks: HashMap::new(),
75            loaded_samples: HashMap::new(),
76        }
77    }
78
79    /// Register a sample with URI and PCM data (eager loading)
80    pub fn register_sample(&mut self, uri: String, data: SampleData) {
81        self.samples.insert(uri.clone(), data);
82        self.loaded_samples.insert(uri, true);
83    }
84
85    /// Register bank metadata for lazy loading
86    pub fn register_bank_metadata(&mut self, metadata: BankMetadata) {
87        self.banks.insert(metadata.bank_id.clone(), metadata);
88    }
89
90    /// Get sample data by URI (lazy load if needed)
91    pub fn get_sample(&mut self, uri: &str) -> Option<SampleData> {
92        // If already loaded, return from cache
93        if let Some(data) = self.samples.get(uri) {
94            return Some(data.clone());
95        }
96
97        // Try lazy loading
98        if !self.loaded_samples.contains_key(uri) {
99            if let Some(data) = self.try_lazy_load(uri) {
100                self.samples.insert(uri.to_string(), data.clone());
101                self.loaded_samples.insert(uri.to_string(), true);
102                return Some(data);
103            }
104            // Mark as attempted (failed to load)
105            self.loaded_samples.insert(uri.to_string(), false);
106        }
107
108        None
109    }
110
111    /// Try to lazy load a sample from bank metadata
112    fn try_lazy_load(&self, uri: &str) -> Option<SampleData> {
113        // Parse URI: devalang://bank/{bank_id}/{trigger_name}
114        if !uri.starts_with("devalang://bank/") {
115            return None;
116        }
117
118        let path = &uri["devalang://bank/".len()..];
119        let parts: Vec<&str> = path.split('/').collect();
120        if parts.len() != 2 {
121            return None;
122        }
123
124        let bank_id = parts[0];
125        let trigger_name = parts[1];
126
127        // Find bank metadata
128        let bank_meta = self.banks.get(bank_id)?;
129
130        // Find trigger file path
131        let file_relative_path = bank_meta.triggers.get(trigger_name)?;
132
133        // Construct full path
134        let audio_dir = bank_meta.bank_path.join(&bank_meta.audio_path);
135        let wav_path = audio_dir.join(file_relative_path);
136
137        // Load WAV file
138        match load_wav_file(&wav_path) {
139            Ok(data) => {
140                // Lazy loaded sample
141                Some(data)
142            }
143            Err(e) => {
144                eprintln!("Failed to lazy load {:?}: {}", wav_path, e);
145                None
146            }
147        }
148    }
149
150    /// Check if bank is registered
151    pub fn has_bank(&self, bank_id: &str) -> bool {
152        self.banks.contains_key(bank_id)
153    }
154
155    /// Get statistics
156    pub fn stats(&self) -> (usize, usize, usize) {
157        let total_banks = self.banks.len();
158        let total_samples: usize = self.banks.values().map(|b| b.triggers.len()).sum();
159        let loaded_samples = self.samples.len();
160        (total_banks, total_samples, loaded_samples)
161    }
162}
163
164/// Load a bank from a directory containing bank.toml and audio files
165/// Uses lazy loading: only metadata is loaded initially, samples are loaded on demand
166pub fn load_bank_from_directory(bank_path: &Path) -> Result<String> {
167    let manifest_path = bank_path.join("bank.toml");
168    if !manifest_path.exists() {
169        anyhow::bail!("bank.toml not found in {:?}", bank_path);
170    }
171
172    let manifest_content = fs::read_to_string(&manifest_path)
173        .with_context(|| format!("Failed to read {:?}", manifest_path))?;
174
175    let manifest: BankManifest = toml::from_str(&manifest_content)
176        .with_context(|| format!("Failed to parse {:?}", manifest_path))?;
177
178    let bank_id = format!("{}.{}", manifest.bank.publisher, manifest.bank.name);
179
180    // Build trigger map: trigger_name -> file_path
181    let mut triggers = HashMap::new();
182    for trigger in &manifest.triggers {
183        // Clean up trigger path (remove leading ./)
184        let clean_path = trigger.path.trim_start_matches("./").to_string();
185        triggers.insert(trigger.name.clone(), clean_path);
186    }
187
188    // Create bank metadata for lazy loading
189    let metadata = BankMetadata {
190        bank_id: bank_id.clone(),
191        bank_path: bank_path.to_path_buf(),
192        audio_path: manifest.bank.audio_path.clone(),
193        triggers: triggers.clone(),
194    };
195
196    // Register bank metadata
197    let mut registry = SAMPLE_REGISTRY.lock().unwrap();
198    registry.register_bank_metadata(metadata);
199
200    // Bank registered
201
202    Ok(bank_id)
203}
204
205/// Load WAV file and convert to mono f32 PCM
206fn load_wav_file(path: &Path) -> Result<SampleData> {
207    let bytes = fs::read(path)?;
208
209    // Use the common WAV parser
210    let parser_result = crate::utils::wav_parser::parse_wav_generic(&bytes)
211        .map_err(|e| anyhow::anyhow!("WAV parse error: {}", e))?;
212
213    let (_channels, sample_rate, mono_i16) = parser_result;
214
215    // Convert i16 to f32 normalized [-1.0, 1.0]
216    let samples: Vec<f32> = mono_i16.iter().map(|&s| s as f32 / 32768.0).collect();
217
218    Ok(SampleData {
219        samples,
220        sample_rate,
221    })
222}
223
224/// Attempt to load an audio file in any supported format.
225/// First try the existing WAV parser, then fall back to `rodio::Decoder` which
226/// supports MP3/FLAC/OGG and other formats when the CLI feature enables `rodio`.
227fn load_audio_file(path: &Path) -> Result<SampleData> {
228    // Try WAV parser first (fast, native implementation)
229    if let Ok(data) = load_wav_file(path) {
230        return Ok(data);
231    }
232
233    // Fallback: use rodio decoder (requires the `cli` feature which enables `rodio`)
234    // This handles mp3, flac, ogg, and many container formats via Symphonia/rodio.
235    use rodio::Decoder;
236    use rodio::Source;
237    use std::fs::File;
238    use std::io::BufReader; // bring trait methods (sample_rate, channels, convert_samples) into scope
239
240    let file = File::open(path).with_context(|| format!("Failed to open {:?}", path))?;
241    let reader = BufReader::new(file);
242
243    let decoder = Decoder::new(reader).map_err(|e| anyhow::anyhow!("rodio decode error: {}", e))?;
244
245    let sample_rate = decoder.sample_rate();
246    let channels = decoder.channels();
247
248    // Convert all samples to f32 then to mono if needed.
249    let samples_f32: Vec<f32> = decoder.convert_samples::<f32>().collect();
250
251    let mono_f32 = if channels > 1 {
252        let ch = channels as usize;
253        let frames = samples_f32.len() / ch;
254        let mut mono = Vec::with_capacity(frames);
255        for f in 0..frames {
256            let mut acc = 0.0f32;
257            for c in 0..ch {
258                acc += samples_f32[f * ch + c];
259            }
260            mono.push(acc / ch as f32);
261        }
262        mono
263    } else {
264        samples_f32
265    };
266
267    // Keep samples as f32 (normalized) to match SampleData type
268    Ok(SampleData {
269        samples: mono_f32,
270        sample_rate,
271    })
272}
273
274/// Get sample from global registry (with lazy loading)
275pub fn get_sample(uri: &str) -> Option<SampleData> {
276    let mut registry = SAMPLE_REGISTRY.lock().unwrap();
277    registry.get_sample(uri)
278}
279
280/// Register a sample into the global registry with the given URI.
281pub fn register_sample(uri: &str, data: SampleData) {
282    let mut registry = SAMPLE_REGISTRY.lock().unwrap();
283    registry.register_sample(uri.to_string(), data);
284}
285
286/// Convenience: load a WAV file at `path` and register it under an absolute path string URI.
287/// Returns the URI used (absolute path) on success.
288pub fn register_sample_from_path(path: &std::path::Path) -> Result<String, anyhow::Error> {
289    let abs = if path.is_absolute() {
290        path.to_path_buf()
291    } else {
292        std::env::current_dir()?.join(path)
293    };
294    let abs_norm = abs.canonicalize().unwrap_or(abs);
295    let uri = abs_norm.to_string_lossy().to_string();
296
297    // Load audio file using generic loader (WAV parser first, then fall back to rodio)
298    match load_audio_file(&abs_norm) {
299        Ok(data) => {
300            register_sample(&uri, data);
301            Ok(uri)
302        }
303        Err(e) => Err(e),
304    }
305}
306
307/// Get registry statistics (banks, total samples, loaded samples)
308pub fn get_stats() -> (usize, usize, usize) {
309    let registry = SAMPLE_REGISTRY.lock().unwrap();
310    registry.stats()
311}
312
313/// Auto-discover and load banks from standard locations
314pub fn auto_load_banks() -> Result<()> {
315    let mut possible_paths = Vec::new();
316
317    // 1. Current directory + addons/banks
318    if let Ok(cwd) = std::env::current_dir() {
319        possible_paths.push(cwd.join("addons").join("banks"));
320        possible_paths.push(cwd.join(".deva").join("banks"));
321    }
322
323    // 2. Home directory + .deva/banks
324    if let Some(home_dir) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
325        let home_path = PathBuf::from(home_dir);
326        possible_paths.push(home_path.join(".deva").join("banks"));
327    }
328
329    // 3. Parent directories (useful for monorepo structures)
330    if let Ok(cwd) = std::env::current_dir() {
331        let mut current = cwd.as_path();
332        for _ in 0..3 {
333            if let Some(parent) = current.parent() {
334                possible_paths.push(parent.join("addons").join("banks"));
335                possible_paths.push(parent.join("static").join("addons").join("banks"));
336                current = parent;
337            }
338        }
339    }
340
341    for base_path in possible_paths {
342        if !base_path.exists() {
343            continue;
344        }
345
346        // Look for bank directories (publisher/bankname)
347        if let Ok(publishers) = fs::read_dir(&base_path) {
348            for publisher_entry in publishers.filter_map(Result::ok) {
349                let publisher_path = publisher_entry.path();
350                if !publisher_path.is_dir() {
351                    continue;
352                }
353
354                if let Ok(banks) = fs::read_dir(&publisher_path) {
355                    for bank_entry in banks.filter_map(Result::ok) {
356                        let bank_path = bank_entry.path();
357                        if !bank_path.is_dir() {
358                            continue;
359                        }
360
361                        // Try to load this bank
362                        if let Err(e) = load_bank_from_directory(&bank_path) {
363                            eprintln!("Failed to load bank from {:?}: {}", bank_path, e);
364                        }
365                    }
366                }
367            }
368        }
369    }
370
371    Ok(())
372}