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// This module is conditionally exported from its parent via `#[cfg(feature = "cli")]`.
7// Avoid duplicating crate-level cfg attributes here which cause lints.
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    if let Some(data) = registry.get_sample(uri) {
278        return Some(data);
279    }
280
281    // Fallback: generate synthetic drum samples
282    generate_synthetic_sample(uri)
283}
284
285/// Register a sample into the global registry with the given URI.
286pub fn register_sample(uri: &str, data: SampleData) {
287    let mut registry = SAMPLE_REGISTRY.lock().unwrap();
288    registry.register_sample(uri.to_string(), data);
289}
290
291/// Convenience: load a WAV file at `path` and register it under an absolute path string URI.
292/// Returns the URI used (absolute path) on success.
293pub fn register_sample_from_path(path: &std::path::Path) -> Result<String, anyhow::Error> {
294    let abs = if path.is_absolute() {
295        path.to_path_buf()
296    } else {
297        std::env::current_dir()?.join(path)
298    };
299    let abs_norm = abs.canonicalize().unwrap_or(abs);
300    let uri = abs_norm.to_string_lossy().to_string();
301
302    // Load audio file using generic loader (WAV parser first, then fall back to rodio)
303    match load_audio_file(&abs_norm) {
304        Ok(data) => {
305            register_sample(&uri, data);
306            Ok(uri)
307        }
308        Err(e) => Err(e),
309    }
310}
311
312/// Get registry statistics (banks, total samples, loaded samples)
313pub fn get_stats() -> (usize, usize, usize) {
314    let registry = SAMPLE_REGISTRY.lock().unwrap();
315    registry.stats()
316}
317
318/// Auto-discover and load banks from standard locations
319pub fn auto_load_banks() -> Result<()> {
320    let mut possible_paths = Vec::new();
321
322    // 1. Current directory + addons/banks
323    if let Ok(cwd) = std::env::current_dir() {
324        possible_paths.push(cwd.join("addons").join("banks"));
325        possible_paths.push(cwd.join(".deva").join("banks"));
326    }
327
328    // 2. Home directory + .deva/banks
329    if let Some(home_dir) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
330        let home_path = PathBuf::from(home_dir);
331        possible_paths.push(home_path.join(".deva").join("banks"));
332    }
333
334    // 3. Parent directories (useful for monorepo structures)
335    if let Ok(cwd) = std::env::current_dir() {
336        let mut current = cwd.as_path();
337        for _ in 0..3 {
338            if let Some(parent) = current.parent() {
339                possible_paths.push(parent.join("addons").join("banks"));
340                possible_paths.push(parent.join("static").join("addons").join("banks"));
341                current = parent;
342            }
343        }
344    }
345
346    for base_path in possible_paths {
347        if !base_path.exists() {
348            continue;
349        }
350
351        // Look for bank directories (publisher/bankname)
352        if let Ok(publishers) = fs::read_dir(&base_path) {
353            for publisher_entry in publishers.filter_map(Result::ok) {
354                let publisher_path = publisher_entry.path();
355                if !publisher_path.is_dir() {
356                    continue;
357                }
358
359                if let Ok(banks) = fs::read_dir(&publisher_path) {
360                    for bank_entry in banks.filter_map(Result::ok) {
361                        let bank_path = bank_entry.path();
362                        if !bank_path.is_dir() {
363                            continue;
364                        }
365
366                        // Try to load this bank
367                        if let Err(e) = load_bank_from_directory(&bank_path) {
368                            eprintln!("Failed to load bank from {:?}: {}", bank_path, e);
369                        }
370                    }
371                }
372            }
373        }
374    }
375
376    Ok(())
377}
378
379/// Generate synthetic drum sounds as fallback when bank samples aren't available
380fn generate_synthetic_sample(uri: &str) -> Option<SampleData> {
381    // Parse URI: devalang://bank/{bank_id}/{trigger_name}
382    if !uri.starts_with("devalang://bank/") {
383        return None;
384    }
385
386    let path = &uri["devalang://bank/".len()..];
387    let parts: Vec<&str> = path.split('/').collect();
388    if parts.len() < 2 {
389        return None;
390    }
391
392    let drum_type = parts[parts.len() - 1]; // e.g., "kick"
393    let sample_rate = 44100;
394
395    // Determine duration and generate appropriate sound
396    let (duration_ms, samples) = match drum_type {
397        "kick" => (500, generate_kick(sample_rate, 500)),
398        "snare" => (200, generate_snare(sample_rate, 200)),
399        "hihat" | "hi-hat" => (150, generate_hihat(sample_rate, 150)),
400        "clap" => (200, generate_clap(sample_rate, 200)),
401        "tom" | "tom-high" => (300, generate_tom(sample_rate, 300, 250.0)),
402        "tom-mid" => (350, generate_tom(sample_rate, 350, 180.0)),
403        "tom-low" => (400, generate_tom(sample_rate, 400, 120.0)),
404        "perc" | "percussion" => (100, generate_hihat(sample_rate, 100)),
405        "cowbell" => (150, generate_cowbell(sample_rate, 150)),
406        "cymbal" => (250, generate_cymbal(sample_rate, 250)),
407        _ => {
408            eprintln!(
409                "[SAMPLES] Unknown drum type: {}, using kick fallback",
410                drum_type
411            );
412            (500, generate_kick(sample_rate, 500))
413        }
414    };
415
416    eprintln!(
417        "[SAMPLES] Generated synthetic drum: {} (duration: {}ms, samples: {})",
418        drum_type,
419        duration_ms,
420        samples.len()
421    );
422
423    Some(SampleData {
424        samples,
425        sample_rate,
426    })
427}
428
429/// Generate a synthetic kick drum
430fn generate_kick(sample_rate: u32, duration_ms: u32) -> Vec<f32> {
431    let num_samples = ((duration_ms as f32 / 1000.0) * sample_rate as f32) as usize;
432    let mut samples = Vec::with_capacity(num_samples);
433
434    for i in 0..num_samples {
435        let t = i as f32 / sample_rate as f32;
436        let progress = t / (duration_ms as f32 / 1000.0);
437
438        // Pitch envelope: starts high and sweeps down
439        let pitch_start = 150.0;
440        let pitch_end = 50.0;
441        let pitch = pitch_start + (pitch_end - pitch_start) * progress;
442        let phase = 2.0 * std::f32::consts::PI * pitch * t;
443
444        // Amplitude envelope: quick decay
445        let amp = (1.0 - progress * progress).max(0.0);
446
447        // Basic sine wave with slight distortion
448        let sample = (phase.sin() * amp * 0.7).tanh();
449        samples.push(sample);
450    }
451
452    samples
453}
454
455/// Generate a synthetic snare drum
456fn generate_snare(sample_rate: u32, duration_ms: u32) -> Vec<f32> {
457    let num_samples = ((duration_ms as f32 / 1000.0) * sample_rate as f32) as usize;
458    let mut samples = Vec::with_capacity(num_samples);
459
460    for i in 0..num_samples {
461        let t = i as f32 / sample_rate as f32;
462        let progress = t / (duration_ms as f32 / 1000.0);
463
464        let amp = (1.0 - progress * 3.0).max(0.0);
465
466        let pitch = 200.0;
467        let phase = 2.0 * std::f32::consts::PI * pitch * t;
468        let pitched = phase.sin() * 0.3;
469
470        let seed = (i as u32).wrapping_mul(12345);
471        let random = ((seed >> 16) & 0x7fff) as f32 / 32768.0;
472        let noise = (random * 2.0 - 1.0) * 0.7;
473
474        let sample = (pitched + noise) * amp;
475        samples.push(sample.clamp(-1.0, 1.0));
476    }
477
478    samples
479}
480
481/// Generate a synthetic hi-hat
482fn generate_hihat(sample_rate: u32, duration_ms: u32) -> Vec<f32> {
483    let num_samples = ((duration_ms as f32 / 1000.0) * sample_rate as f32) as usize;
484    let mut samples = Vec::with_capacity(num_samples);
485
486    for i in 0..num_samples {
487        let t = i as f32 / sample_rate as f32;
488        let progress = t / (duration_ms as f32 / 1000.0);
489
490        let amp = (1.0 - progress * 6.0).max(0.0);
491
492        let seed = (i as u32).wrapping_mul(65537);
493        let random = ((seed >> 16) & 0x7fff) as f32 / 32768.0;
494        let noise = random * 2.0 - 1.0;
495
496        let sample = noise * amp * 0.5;
497        samples.push(sample.clamp(-1.0, 1.0));
498    }
499
500    samples
501}
502
503/// Generate a synthetic clap
504fn generate_clap(sample_rate: u32, duration_ms: u32) -> Vec<f32> {
505    let num_samples = ((duration_ms as f32 / 1000.0) * sample_rate as f32) as usize;
506    let mut samples = Vec::with_capacity(num_samples);
507
508    for i in 0..num_samples {
509        let t = i as f32 / sample_rate as f32;
510        let progress = t / (duration_ms as f32 / 1000.0);
511
512        let amp = if progress < 0.2 {
513            1.0 - (progress / 0.2) * 0.5
514        } else {
515            (0.5 - (progress - 0.2) * 0.4).max(0.0)
516        };
517
518        let pitch1 = 300.0;
519        let pitch2 = 100.0;
520        let phase1 = 2.0 * std::f32::consts::PI * pitch1 * t;
521        let phase2 = 2.0 * std::f32::consts::PI * pitch2 * t;
522
523        let pitched = phase1.sin() * 0.2 + phase2.sin() * 0.3;
524
525        let seed = (i as u32).wrapping_mul(12345);
526        let random = ((seed >> 16) & 0x7fff) as f32 / 32768.0;
527        let noise = (random * 2.0 - 1.0) * 0.5;
528
529        let sample = (pitched + noise) * amp;
530        samples.push(sample.clamp(-1.0, 1.0));
531    }
532
533    samples
534}
535
536/// Generate a synthetic tom (tuned drum)
537fn generate_tom(sample_rate: u32, duration_ms: u32, pitch: f32) -> Vec<f32> {
538    let num_samples = ((duration_ms as f32 / 1000.0) * sample_rate as f32) as usize;
539    let mut samples = Vec::with_capacity(num_samples);
540
541    for i in 0..num_samples {
542        let t = i as f32 / sample_rate as f32;
543        let progress = t / (duration_ms as f32 / 1000.0);
544
545        let pitch_start = pitch * 1.5;
546        let pitch_end = pitch * 0.5;
547        let current_pitch = pitch_start + (pitch_end - pitch_start) * progress;
548        let phase = 2.0 * std::f32::consts::PI * current_pitch * t;
549
550        let amp = (1.0 - progress * progress * 2.0).max(0.0);
551
552        let sample = phase.sin() * amp * 0.7;
553        samples.push(sample);
554    }
555
556    samples
557}
558
559/// Generate a synthetic cowbell
560fn generate_cowbell(sample_rate: u32, duration_ms: u32) -> Vec<f32> {
561    let num_samples = ((duration_ms as f32 / 1000.0) * sample_rate as f32) as usize;
562    let mut samples = Vec::with_capacity(num_samples);
563
564    for i in 0..num_samples {
565        let t = i as f32 / sample_rate as f32;
566        let progress = t / (duration_ms as f32 / 1000.0);
567
568        let freq1 = 540.0;
569        let freq2 = 810.0;
570        let freq3 = 1200.0;
571
572        let phase1 = 2.0 * std::f32::consts::PI * freq1 * t;
573        let phase2 = 2.0 * std::f32::consts::PI * freq2 * t;
574        let phase3 = 2.0 * std::f32::consts::PI * freq3 * t;
575
576        let amp = (1.0 - progress * 2.0).max(0.0);
577
578        let pitched = phase1.sin() * 0.3 + phase2.sin() * 0.25 + phase3.sin() * 0.2;
579        let sample = pitched * amp * 0.7;
580        samples.push(sample.clamp(-1.0, 1.0));
581    }
582
583    samples
584}
585
586/// Generate a synthetic cymbal crash
587fn generate_cymbal(sample_rate: u32, duration_ms: u32) -> Vec<f32> {
588    let num_samples = ((duration_ms as f32 / 1000.0) * sample_rate as f32) as usize;
589    let mut samples = Vec::with_capacity(num_samples);
590
591    for i in 0..num_samples {
592        let t = i as f32 / sample_rate as f32;
593        let progress = t / (duration_ms as f32 / 1000.0);
594
595        let seed1 = (i as u32).wrapping_mul(12345);
596        let seed2 = (i as u32).wrapping_mul(54321);
597
598        let random1 = ((seed1 >> 16) & 0x7fff) as f32 / 32768.0;
599        let random2 = ((seed2 >> 16) & 0x7fff) as f32 / 32768.0;
600
601        let noise = (random1 * 2.0 - 1.0) * 0.4 + (random2 * 2.0 - 1.0) * 0.3;
602
603        let freq1 = 8000.0;
604        let freq2 = 6000.0;
605        let phase1 = 2.0 * std::f32::consts::PI * freq1 * t;
606        let phase2 = 2.0 * std::f32::consts::PI * freq2 * t;
607
608        let pitched = phase1.sin() * 0.1 + phase2.sin() * 0.1;
609
610        let amp = (1.0 - progress * 0.7).max(0.0);
611
612        let sample = (noise + pitched) * amp * 0.6;
613        samples.push(sample.clamp(-1.0, 1.0));
614    }
615
616    samples
617}