Skip to main content

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
198        .lock()
199        .map_err(|_| anyhow::anyhow!("Sample registry lock poisoned"))?;
200    registry.register_bank_metadata(metadata);
201
202    // Bank registered
203
204    Ok(bank_id)
205}
206
207/// Load WAV file and convert to mono f32 PCM
208fn load_wav_file(path: &Path) -> Result<SampleData> {
209    let bytes = fs::read(path)?;
210
211    // Use the common WAV parser
212    let parser_result = crate::utils::wav_parser::parse_wav_generic(&bytes)
213        .map_err(|e| anyhow::anyhow!("WAV parse error: {}", e))?;
214
215    let (_channels, sample_rate, mono_i16) = parser_result;
216
217    // Convert i16 to f32 normalized [-1.0, 1.0]
218    let samples: Vec<f32> = mono_i16.iter().map(|&s| s as f32 / 32768.0).collect();
219
220    Ok(SampleData {
221        samples,
222        sample_rate,
223    })
224}
225
226/// Attempt to load an audio file in any supported format.
227/// First try the existing WAV parser, then fall back to `rodio::Decoder` which
228/// supports MP3/FLAC/OGG and other formats when the CLI feature enables `rodio`.
229fn load_audio_file(path: &Path) -> Result<SampleData> {
230    // Try WAV parser first (fast, native implementation)
231    if let Ok(data) = load_wav_file(path) {
232        return Ok(data);
233    }
234
235    // Fallback: use rodio decoder (requires the `cli` feature which enables `rodio`)
236    // This handles mp3, flac, ogg, and many container formats via Symphonia/rodio.
237    use rodio::Decoder;
238    use rodio::Source;
239    use std::fs::File;
240    use std::io::BufReader; // bring trait methods (sample_rate, channels, convert_samples) into scope
241
242    let file = File::open(path).with_context(|| format!("Failed to open {:?}", path))?;
243    let reader = BufReader::new(file);
244
245    let decoder = Decoder::new(reader).map_err(|e| anyhow::anyhow!("rodio decode error: {}", e))?;
246
247    let sample_rate = decoder.sample_rate();
248    let channels = decoder.channels();
249
250    // Convert all samples to f32 then to mono if needed.
251    let samples_f32: Vec<f32> = decoder.convert_samples::<f32>().collect();
252
253    let mono_f32 = if channels > 1 {
254        let ch = channels as usize;
255        let frames = samples_f32.len() / ch;
256        let mut mono = Vec::with_capacity(frames);
257        for f in 0..frames {
258            let mut acc = 0.0f32;
259            for c in 0..ch {
260                acc += samples_f32[f * ch + c];
261            }
262            mono.push(acc / ch as f32);
263        }
264        mono
265    } else {
266        samples_f32
267    };
268
269    // Keep samples as f32 (normalized) to match SampleData type
270    Ok(SampleData {
271        samples: mono_f32,
272        sample_rate,
273    })
274}
275
276/// Get sample from global registry (with lazy loading)
277pub fn get_sample(uri: &str) -> Option<SampleData> {
278    if let Ok(mut registry) = SAMPLE_REGISTRY.lock() {
279        if let Some(data) = registry.get_sample(uri) {
280            return Some(data);
281        }
282    }
283
284    // Fallback: generate synthetic drum samples
285    generate_synthetic_sample(uri)
286}
287
288/// Register a sample into the global registry with the given URI.
289pub fn register_sample(uri: &str, data: SampleData) {
290    if let Ok(mut registry) = SAMPLE_REGISTRY.lock() {
291        registry.register_sample(uri.to_string(), data);
292    }
293}
294
295/// Convenience: load a WAV file at `path` and register it under an absolute path string URI.
296/// Returns the URI used (absolute path) on success.
297pub fn register_sample_from_path(path: &std::path::Path) -> Result<String, anyhow::Error> {
298    let abs = if path.is_absolute() {
299        path.to_path_buf()
300    } else {
301        std::env::current_dir()?.join(path)
302    };
303    let abs_norm = abs.canonicalize().unwrap_or(abs);
304    let uri = abs_norm.to_string_lossy().to_string();
305
306    // Load audio file using generic loader (WAV parser first, then fall back to rodio)
307    match load_audio_file(&abs_norm) {
308        Ok(data) => {
309            register_sample(&uri, data);
310            Ok(uri)
311        }
312        Err(e) => Err(e),
313    }
314}
315
316/// Get registry statistics (banks, total samples, loaded samples)
317pub fn get_stats() -> (usize, usize, usize) {
318    SAMPLE_REGISTRY
319        .lock()
320        .map(|registry| registry.stats())
321        .unwrap_or((0, 0, 0))
322}
323
324/// Auto-discover and load banks from standard locations
325pub fn auto_load_banks() -> Result<()> {
326    let mut possible_paths = Vec::new();
327
328    // 1. Current directory + addons/banks
329    if let Ok(cwd) = std::env::current_dir() {
330        possible_paths.push(cwd.join("addons").join("banks"));
331        possible_paths.push(cwd.join(".deva").join("banks"));
332    }
333
334    // 2. Home directory + .deva/banks
335    if let Some(home_dir) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
336        let home_path = PathBuf::from(home_dir);
337        possible_paths.push(home_path.join(".deva").join("banks"));
338    }
339
340    // 3. Parent directories (useful for monorepo structures)
341    if let Ok(cwd) = std::env::current_dir() {
342        let mut current = cwd.as_path();
343        for _ in 0..3 {
344            if let Some(parent) = current.parent() {
345                possible_paths.push(parent.join("addons").join("banks"));
346                possible_paths.push(parent.join("static").join("addons").join("banks"));
347                current = parent;
348            }
349        }
350    }
351
352    for base_path in possible_paths {
353        if !base_path.exists() {
354            continue;
355        }
356
357        // Look for bank directories (publisher/bankname)
358        if let Ok(publishers) = fs::read_dir(&base_path) {
359            for publisher_entry in publishers.filter_map(Result::ok) {
360                let publisher_path = publisher_entry.path();
361                if !publisher_path.is_dir() {
362                    continue;
363                }
364
365                if let Ok(banks) = fs::read_dir(&publisher_path) {
366                    for bank_entry in banks.filter_map(Result::ok) {
367                        let bank_path = bank_entry.path();
368                        if !bank_path.is_dir() {
369                            continue;
370                        }
371
372                        // Try to load this bank
373                        if let Err(e) = load_bank_from_directory(&bank_path) {
374                            eprintln!("Failed to load bank from {:?}: {}", bank_path, e);
375                        }
376                    }
377                }
378            }
379        }
380    }
381
382    Ok(())
383}
384
385/// Generate synthetic drum sounds as fallback when bank samples aren't available
386fn generate_synthetic_sample(uri: &str) -> Option<SampleData> {
387    // Parse URI: devalang://bank/{bank_id}/{trigger_name}
388    if !uri.starts_with("devalang://bank/") {
389        return None;
390    }
391
392    let path = &uri["devalang://bank/".len()..];
393    let parts: Vec<&str> = path.split('/').collect();
394    if parts.len() < 2 {
395        return None;
396    }
397
398    let drum_type = parts[parts.len() - 1]; // e.g., "kick"
399    let sample_rate = 44100;
400
401    // Determine duration and generate appropriate sound
402    let (duration_ms, samples) = match drum_type {
403        "kick" => (500, generate_kick(sample_rate, 500)),
404        "snare" => (200, generate_snare(sample_rate, 200)),
405        "hihat" | "hi-hat" => (150, generate_hihat(sample_rate, 150)),
406        "clap" => (200, generate_clap(sample_rate, 200)),
407        "tom" | "tom-high" => (300, generate_tom(sample_rate, 300, 250.0)),
408        "tom-mid" => (350, generate_tom(sample_rate, 350, 180.0)),
409        "tom-low" => (400, generate_tom(sample_rate, 400, 120.0)),
410        "perc" | "percussion" => (100, generate_hihat(sample_rate, 100)),
411        "cowbell" => (150, generate_cowbell(sample_rate, 150)),
412        "cymbal" => (250, generate_cymbal(sample_rate, 250)),
413        _ => {
414            eprintln!(
415                "[SAMPLES] Unknown drum type: {}, using kick fallback",
416                drum_type
417            );
418            (500, generate_kick(sample_rate, 500))
419        }
420    };
421
422    eprintln!(
423        "[SAMPLES] Generated synthetic drum: {} (duration: {}ms, samples: {})",
424        drum_type,
425        duration_ms,
426        samples.len()
427    );
428
429    Some(SampleData {
430        samples,
431        sample_rate,
432    })
433}
434
435/// Generate a synthetic kick drum
436fn generate_kick(sample_rate: u32, duration_ms: u32) -> Vec<f32> {
437    let num_samples = ((duration_ms as f32 / 1000.0) * sample_rate as f32) as usize;
438    let mut samples = Vec::with_capacity(num_samples);
439
440    for i in 0..num_samples {
441        let t = i as f32 / sample_rate as f32;
442        let progress = t / (duration_ms as f32 / 1000.0);
443
444        // Pitch envelope: starts high and sweeps down
445        let pitch_start = 150.0;
446        let pitch_end = 50.0;
447        let pitch = pitch_start + (pitch_end - pitch_start) * progress;
448        let phase = 2.0 * std::f32::consts::PI * pitch * t;
449
450        // Amplitude envelope: quick decay
451        let amp = (1.0 - progress * progress).max(0.0);
452
453        // Basic sine wave with slight distortion
454        let sample = (phase.sin() * amp * 0.7).tanh();
455        samples.push(sample);
456    }
457
458    samples
459}
460
461/// Generate a synthetic snare drum
462fn generate_snare(sample_rate: u32, duration_ms: u32) -> Vec<f32> {
463    let num_samples = ((duration_ms as f32 / 1000.0) * sample_rate as f32) as usize;
464    let mut samples = Vec::with_capacity(num_samples);
465
466    for i in 0..num_samples {
467        let t = i as f32 / sample_rate as f32;
468        let progress = t / (duration_ms as f32 / 1000.0);
469
470        let amp = (1.0 - progress * 3.0).max(0.0);
471
472        let pitch = 200.0;
473        let phase = 2.0 * std::f32::consts::PI * pitch * t;
474        let pitched = phase.sin() * 0.3;
475
476        let seed = (i as u32).wrapping_mul(12345);
477        let random = ((seed >> 16) & 0x7fff) as f32 / 32768.0;
478        let noise = (random * 2.0 - 1.0) * 0.7;
479
480        let sample = (pitched + noise) * amp;
481        samples.push(sample.clamp(-1.0, 1.0));
482    }
483
484    samples
485}
486
487/// Generate a synthetic hi-hat
488fn generate_hihat(sample_rate: u32, duration_ms: u32) -> Vec<f32> {
489    let num_samples = ((duration_ms as f32 / 1000.0) * sample_rate as f32) as usize;
490    let mut samples = Vec::with_capacity(num_samples);
491
492    for i in 0..num_samples {
493        let t = i as f32 / sample_rate as f32;
494        let progress = t / (duration_ms as f32 / 1000.0);
495
496        let amp = (1.0 - progress * 6.0).max(0.0);
497
498        let seed = (i as u32).wrapping_mul(65537);
499        let random = ((seed >> 16) & 0x7fff) as f32 / 32768.0;
500        let noise = random * 2.0 - 1.0;
501
502        let sample = noise * amp * 0.5;
503        samples.push(sample.clamp(-1.0, 1.0));
504    }
505
506    samples
507}
508
509/// Generate a synthetic clap
510fn generate_clap(sample_rate: u32, duration_ms: u32) -> Vec<f32> {
511    let num_samples = ((duration_ms as f32 / 1000.0) * sample_rate as f32) as usize;
512    let mut samples = Vec::with_capacity(num_samples);
513
514    for i in 0..num_samples {
515        let t = i as f32 / sample_rate as f32;
516        let progress = t / (duration_ms as f32 / 1000.0);
517
518        let amp = if progress < 0.2 {
519            1.0 - (progress / 0.2) * 0.5
520        } else {
521            (0.5 - (progress - 0.2) * 0.4).max(0.0)
522        };
523
524        let pitch1 = 300.0;
525        let pitch2 = 100.0;
526        let phase1 = 2.0 * std::f32::consts::PI * pitch1 * t;
527        let phase2 = 2.0 * std::f32::consts::PI * pitch2 * t;
528
529        let pitched = phase1.sin() * 0.2 + phase2.sin() * 0.3;
530
531        let seed = (i as u32).wrapping_mul(12345);
532        let random = ((seed >> 16) & 0x7fff) as f32 / 32768.0;
533        let noise = (random * 2.0 - 1.0) * 0.5;
534
535        let sample = (pitched + noise) * amp;
536        samples.push(sample.clamp(-1.0, 1.0));
537    }
538
539    samples
540}
541
542/// Generate a synthetic tom (tuned drum)
543fn generate_tom(sample_rate: u32, duration_ms: u32, pitch: f32) -> Vec<f32> {
544    let num_samples = ((duration_ms as f32 / 1000.0) * sample_rate as f32) as usize;
545    let mut samples = Vec::with_capacity(num_samples);
546
547    for i in 0..num_samples {
548        let t = i as f32 / sample_rate as f32;
549        let progress = t / (duration_ms as f32 / 1000.0);
550
551        let pitch_start = pitch * 1.5;
552        let pitch_end = pitch * 0.5;
553        let current_pitch = pitch_start + (pitch_end - pitch_start) * progress;
554        let phase = 2.0 * std::f32::consts::PI * current_pitch * t;
555
556        let amp = (1.0 - progress * progress * 2.0).max(0.0);
557
558        let sample = phase.sin() * amp * 0.7;
559        samples.push(sample);
560    }
561
562    samples
563}
564
565/// Generate a synthetic cowbell
566fn generate_cowbell(sample_rate: u32, duration_ms: u32) -> Vec<f32> {
567    let num_samples = ((duration_ms as f32 / 1000.0) * sample_rate as f32) as usize;
568    let mut samples = Vec::with_capacity(num_samples);
569
570    for i in 0..num_samples {
571        let t = i as f32 / sample_rate as f32;
572        let progress = t / (duration_ms as f32 / 1000.0);
573
574        let freq1 = 540.0;
575        let freq2 = 810.0;
576        let freq3 = 1200.0;
577
578        let phase1 = 2.0 * std::f32::consts::PI * freq1 * t;
579        let phase2 = 2.0 * std::f32::consts::PI * freq2 * t;
580        let phase3 = 2.0 * std::f32::consts::PI * freq3 * t;
581
582        let amp = (1.0 - progress * 2.0).max(0.0);
583
584        let pitched = phase1.sin() * 0.3 + phase2.sin() * 0.25 + phase3.sin() * 0.2;
585        let sample = pitched * amp * 0.7;
586        samples.push(sample.clamp(-1.0, 1.0));
587    }
588
589    samples
590}
591
592/// Generate a synthetic cymbal crash
593fn generate_cymbal(sample_rate: u32, duration_ms: u32) -> Vec<f32> {
594    let num_samples = ((duration_ms as f32 / 1000.0) * sample_rate as f32) as usize;
595    let mut samples = Vec::with_capacity(num_samples);
596
597    for i in 0..num_samples {
598        let t = i as f32 / sample_rate as f32;
599        let progress = t / (duration_ms as f32 / 1000.0);
600
601        let seed1 = (i as u32).wrapping_mul(12345);
602        let seed2 = (i as u32).wrapping_mul(54321);
603
604        let random1 = ((seed1 >> 16) & 0x7fff) as f32 / 32768.0;
605        let random2 = ((seed2 >> 16) & 0x7fff) as f32 / 32768.0;
606
607        let noise = (random1 * 2.0 - 1.0) * 0.4 + (random2 * 2.0 - 1.0) * 0.3;
608
609        let freq1 = 8000.0;
610        let freq2 = 6000.0;
611        let phase1 = 2.0 * std::f32::consts::PI * freq1 * t;
612        let phase2 = 2.0 * std::f32::consts::PI * freq2 * t;
613
614        let pitched = phase1.sin() * 0.1 + phase2.sin() * 0.1;
615
616        let amp = (1.0 - progress * 0.7).max(0.0);
617
618        let sample = (noise + pitched) * amp * 0.6;
619        samples.push(sample.clamp(-1.0, 1.0));
620    }
621
622    samples
623}