Skip to main content

ferraus_plugins/
resolve.rs

1//! The sound resolver: turns an instrument name (`piano`, `strings`, `plugin:"Surge XT"`,
2//! `sf2:/path/gm.sf2`) into a concrete sound, in the PRD's order:
3//!
4//! 1. an explicit source (`plugin:`, `sf2:`, `sfz:`, `ir:`),
5//! 2. an alias from `<config>/sounds/aliases.toml` (`[aliases] piano = "<id or source>"`),
6//! 3. an installed or scanned sound whose name, id, or tags match.
7//!
8//! General MIDI program names (`gm:48`) are left to the caller (`ferraus-synth`), which
9//! gets `Ok(None)` when nothing here matches. Sounds come from the installed sounds folder,
10//! `--sound-path` folders (CLAP/VST3 bundles, `.sf2`, `.sfz` libraries, impulse responses),
11//! and the plugin scan cache.
12
13use crate::api::PluginRef;
14use crate::client::PluginFormat;
15use crate::scan::{self, ScanCache, ScanOptions};
16use ferraus_core::{Error, ErrorKind, Result};
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19use std::path::{Path, PathBuf};
20use std::time::Duration;
21
22/// Kind of sound source.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
24#[serde(rename_all = "lowercase")]
25pub enum SoundKind {
26    Clap,
27    Vst3,
28    Sf2,
29    Sfz,
30    Ir,
31}
32
33impl SoundKind {
34    pub fn as_str(self) -> &'static str {
35        match self {
36            SoundKind::Clap => "clap",
37            SoundKind::Vst3 => "vst3",
38            SoundKind::Sf2 => "sf2",
39            SoundKind::Sfz => "sfz",
40            SoundKind::Ir => "ir",
41        }
42    }
43
44    pub fn parse(s: &str) -> Option<SoundKind> {
45        match s.trim().to_ascii_lowercase().as_str() {
46            "clap" => Some(SoundKind::Clap),
47            "vst3" => Some(SoundKind::Vst3),
48            "sf2" | "soundfont" => Some(SoundKind::Sf2),
49            "sfz" => Some(SoundKind::Sfz),
50            "ir" | "impulse" => Some(SoundKind::Ir),
51            _ => None,
52        }
53    }
54
55    pub fn is_plugin(self) -> bool {
56        matches!(self, SoundKind::Clap | SoundKind::Vst3)
57    }
58}
59
60/// One usable sound.
61#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
62pub struct Sound {
63    /// Stable identifier: the plugin id, or a slug of the file name.
64    pub id: String,
65    pub name: String,
66    pub kind: SoundKind,
67    /// The bundle, `.sf2`, `.sfz`, or IR file.
68    pub path: PathBuf,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub plugin_id: Option<String>,
71    /// Plays notes (instrument plugins, SoundFonts, SFZ); IRs and effects do not.
72    pub instrument: bool,
73    /// One of keys, strings, guitar, bass, drums, synth, pad, brass, winds, vocal, fx.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub category: Option<String>,
76    #[serde(default)]
77    pub tags: Vec<String>,
78    /// `installed`, `sound-path`, or `plugin-scan`.
79    pub origin: String,
80    /// The registry entry this came from, when installed from the registry.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub registry_id: Option<String>,
83}
84
85impl Sound {
86    /// The explicit source string for this sound (`sf2:/path`, `plugin:/path#id`), usable
87    /// anywhere an instrument name is accepted.
88    pub fn source(&self) -> String {
89        match self.kind {
90            SoundKind::Clap | SoundKind::Vst3 => match &self.plugin_id {
91                Some(id) => format!("plugin:{}#{id}", self.path.display()),
92                None => format!("plugin:{}", self.path.display()),
93            },
94            k => format!("{}:{}", k.as_str(), self.path.display()),
95        }
96    }
97
98    /// For plugin sounds, the reference to hand to [`crate::render_instrument`].
99    pub fn plugin_ref(&self) -> Option<PluginRef> {
100        let format = match self.kind {
101            SoundKind::Clap => PluginFormat::Clap,
102            SoundKind::Vst3 => PluginFormat::Vst3,
103            _ => return None,
104        };
105        Some(PluginRef {
106            format,
107            path: self.path.clone(),
108            plugin_id: self.plugin_id.clone(),
109            name: self.name.clone(),
110            vendor: String::new(),
111            version: String::new(),
112        })
113    }
114}
115
116/// Where the resolver and the sounds registry keep their files. [`SoundsEnv::from_env`]
117/// uses the standard ferraus folders; tests build one on a temp dir.
118#[derive(Clone, Debug)]
119pub struct SoundsEnv {
120    pub config_dir: PathBuf,
121    /// `<data>/sounds`: installed SoundFonts, SFZ libraries, IRs, and `installed.json`.
122    pub sounds_dir: PathBuf,
123    pub cache_dir: PathBuf,
124    pub clap_dir: PathBuf,
125    pub vst3_dir: PathBuf,
126}
127
128impl SoundsEnv {
129    pub fn from_env() -> SoundsEnv {
130        SoundsEnv {
131            config_dir: ferraus_core::paths::config_dir(),
132            sounds_dir: ferraus_core::paths::sounds_dir(),
133            cache_dir: ferraus_core::paths::cache_dir(),
134            clap_dir: scan::user_plugin_dir(PluginFormat::Clap),
135            vst3_dir: scan::user_plugin_dir(PluginFormat::Vst3),
136        }
137    }
138
139    /// Everything under one root (for tests and sandboxes).
140    pub fn under(root: &Path) -> SoundsEnv {
141        SoundsEnv {
142            config_dir: root.join("config"),
143            sounds_dir: root.join("data").join("sounds"),
144            cache_dir: root.join("cache"),
145            clap_dir: root.join("plugins").join("clap"),
146            vst3_dir: root.join("plugins").join("vst3"),
147        }
148    }
149
150    pub fn aliases_file(&self) -> PathBuf {
151        self.config_dir.join("sounds").join("aliases.toml")
152    }
153
154    pub fn installed_file(&self) -> PathBuf {
155        self.sounds_dir.join("installed.json")
156    }
157
158    pub fn scan_cache(&self) -> PathBuf {
159        self.cache_dir.join("plugins").join("scan.json")
160    }
161
162    pub fn index_dir(&self) -> PathBuf {
163        self.cache_dir.join("sounds").join("index")
164    }
165
166    pub fn downloads_dir(&self) -> PathBuf {
167        self.cache_dir.join("sounds").join("downloads")
168    }
169
170    pub fn plugin_dir(&self, f: PluginFormat) -> &Path {
171        match f {
172            PluginFormat::Clap => &self.clap_dir,
173            PluginFormat::Vst3 => &self.vst3_dir,
174        }
175    }
176}
177
178// ---------------------------------------------------------------------------------------
179// categories
180
181const CATEGORY_WORDS: &[(&str, &[&str])] = &[
182    (
183        "drums",
184        &[
185            "drum",
186            "drums",
187            "kit",
188            "percussion",
189            "perc",
190            "snare",
191            "kick",
192            "hihat",
193            "hi-hat",
194            "cymbal",
195            "timpani",
196            "tom",
197            "808",
198            "909",
199            "conga",
200            "bongo",
201        ],
202    ),
203    (
204        "keys",
205        &[
206            "piano",
207            "keys",
208            "keyboard",
209            "rhodes",
210            "wurli",
211            "epiano",
212            "e-piano",
213            "electric-piano",
214            "organ",
215            "harpsichord",
216            "clavinet",
217            "celesta",
218            "grand",
219            "upright",
220            "dx7",
221        ],
222    ),
223    ("bass", &["bass", "contrabass", "upright-bass"]),
224    ("guitar", &["guitar", "nylon", "acoustic-guitar", "strat", "les paul", "banjo", "ukulele", "mandolin"]),
225    (
226        "strings",
227        &["strings", "string", "violin", "viola", "cello", "ensemble", "orchestra", "harp", "pizzicato", "fiddle"],
228    ),
229    ("brass", &["brass", "trumpet", "trombone", "horn", "tuba", "cornet", "flugelhorn"]),
230    (
231        "winds",
232        &[
233            "flute",
234            "clarinet",
235            "oboe",
236            "bassoon",
237            "sax",
238            "saxophone",
239            "woodwind",
240            "woodwinds",
241            "winds",
242            "piccolo",
243            "recorder",
244            "pan flute",
245        ],
246    ),
247    ("vocal", &["vocal", "voice", "choir", "vox", "aah", "ooh", "singer"]),
248    ("pad", &["pad", "pads", "atmosphere", "ambient", "texture", "drone"]),
249    (
250        "synth",
251        &["synth", "synthesizer", "lead", "wavetable", "fm", "analog", "modular", "polysynth", "monosynth", "arp"],
252    ),
253    (
254        "fx",
255        &[
256            "fx",
257            "effect",
258            "audio-effect",
259            "reverb",
260            "delay",
261            "impulse",
262            "ir",
263            "chorus",
264            "distortion",
265            "compressor",
266            "eq",
267            "filter",
268            "sfx",
269        ],
270    ),
271];
272
273/// The category ferraus infers from a name, tags, or path text (first match wins in the
274/// order drums, keys, guitar, bass, strings, brass, winds, vocal, pad, synth, fx).
275pub fn infer_category(text: &str) -> Option<&'static str> {
276    let lower = text.to_ascii_lowercase();
277    let words: Vec<&str> =
278        lower.split(|c: char| !(c.is_alphanumeric() || c == '-')).filter(|w| !w.is_empty()).collect();
279    for (cat, keys) in CATEGORY_WORDS {
280        for k in *keys {
281            if k.contains(' ') {
282                if lower.contains(k) {
283                    return Some(cat);
284                }
285            } else if words.iter().any(|w| w == k || (k.len() >= 5 && w.starts_with(k))) {
286                return Some(cat);
287            }
288        }
289    }
290    None
291}
292
293fn slug(s: &str) -> String {
294    let mut out = String::new();
295    for c in s.chars() {
296        if c.is_alphanumeric() {
297            out.extend(c.to_lowercase());
298        } else if !out.ends_with('-') && !out.is_empty() {
299            out.push('-');
300        }
301    }
302    out.trim_end_matches('-').to_string()
303}
304
305fn words_of(p: &Path, root: &Path) -> Vec<String> {
306    let rel = p.strip_prefix(root).unwrap_or(p);
307    let mut tags = Vec::new();
308    for c in rel.components() {
309        let s = c.as_os_str().to_string_lossy();
310        let s = s.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or_else(|| s.to_string());
311        for w in s.split(|c: char| !c.is_alphanumeric()).filter(|w| w.len() > 1) {
312            let w = w.to_ascii_lowercase();
313            if !tags.contains(&w) {
314                tags.push(w);
315            }
316        }
317    }
318    tags
319}
320
321fn is_ir_context(p: &Path, root: &Path) -> bool {
322    let rel = p.strip_prefix(root).unwrap_or(p);
323    let mut texts: Vec<String> =
324        rel.components().map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase()).collect();
325    if let Some(n) = root.file_name() {
326        texts.push(n.to_string_lossy().to_ascii_lowercase());
327    }
328    texts.iter().any(|t| {
329        t.split(|c: char| !c.is_alphanumeric()).any(|w| {
330            matches!(
331                w,
332                "ir" | "irs" | "impulse" | "impulses" | "reverb" | "cab" | "cabinet" | "convolution" | "rir" | "rirs"
333            )
334        })
335    })
336}
337
338fn has_ext(p: &Path, exts: &[&str]) -> bool {
339    p.extension().map(|e| exts.contains(&e.to_string_lossy().to_ascii_lowercase().as_str())).unwrap_or(false)
340}
341
342/// Files (not plugins) under `root`: `.sf2`, `.sfz`, and IR audio files. Samples inside a
343/// folder that holds `.sfz` files belong to that library and are not listed as IRs.
344pub fn discover_files(root: &Path, origin: &str) -> Vec<Sound> {
345    discover_files_with(root, origin, false)
346}
347
348/// Like [`discover_files`]; with `all_ir`, every audio file outside SFZ libraries is an
349/// impulse response (used for installed IR packs).
350pub fn discover_files_with(root: &Path, origin: &str, all_ir: bool) -> Vec<Sound> {
351    let mut out = Vec::new();
352    if root.is_file() {
353        if let Some(s) = file_sound(root, root.parent().unwrap_or(root), origin, true) {
354            out.push(s);
355        }
356        return out;
357    }
358    walk_files(root, root, origin, 0, false, all_ir, &mut out);
359    out
360}
361
362fn file_sound(p: &Path, root: &Path, origin: &str, ir_ok: bool) -> Option<Sound> {
363    let kind = if has_ext(p, &["sf2", "sf3"]) {
364        SoundKind::Sf2
365    } else if has_ext(p, &["sfz"]) {
366        SoundKind::Sfz
367    } else if ir_ok && has_ext(p, &["wav", "aif", "aiff", "flac"]) {
368        SoundKind::Ir
369    } else {
370        return None;
371    };
372    let name = p.file_stem()?.to_string_lossy().into_owned();
373    let mut tags = words_of(p, root);
374    if let Some(rn) = root.file_name() {
375        for w in rn.to_string_lossy().split(|c: char| !c.is_alphanumeric()).filter(|w| w.len() > 1) {
376            let w = w.to_ascii_lowercase();
377            if !tags.contains(&w) {
378                tags.push(w);
379            }
380        }
381    }
382    let category = if kind == SoundKind::Ir {
383        Some("fx".to_string())
384    } else {
385        infer_category(&format!("{name} {}", tags.join(" "))).map(|s| s.to_string())
386    };
387    Some(Sound {
388        id: slug(&name),
389        name,
390        kind,
391        path: p.to_path_buf(),
392        plugin_id: None,
393        instrument: kind != SoundKind::Ir,
394        category,
395        tags,
396        origin: origin.into(),
397        registry_id: None,
398    })
399}
400
401fn walk_files(dir: &Path, root: &Path, origin: &str, depth: usize, in_sfz: bool, all_ir: bool, out: &mut Vec<Sound>) {
402    if depth > 8 || out.len() > 20000 {
403        return;
404    }
405    let Ok(rd) = std::fs::read_dir(dir) else {
406        return;
407    };
408    let mut entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
409    entries.sort();
410    let here_sfz = in_sfz || entries.iter().any(|p| p.is_file() && has_ext(p, &["sfz"]));
411    for p in &entries {
412        if p.is_dir() {
413            if PluginFormat::from_path(p).is_some() || has_ext(p, &["component", "app", "lv2"]) {
414                continue;
415            }
416            walk_files(p, root, origin, depth + 1, here_sfz, all_ir, out);
417        } else {
418            let ir_ok = !here_sfz && (all_ir || is_ir_context(p, root));
419            if let Some(s) = file_sound(p, root, origin, ir_ok) {
420                out.push(s);
421            }
422        }
423    }
424}
425
426/// Everything in one folder: sound files plus plugins (probed in a host subprocess, cached).
427pub fn discover_folder(env: &SoundsEnv, dir: &Path, watchdog: Duration) -> Result<Vec<Sound>> {
428    if !dir.exists() {
429        return Err(Error::new(
430            ErrorKind::InputMissing,
431            "input_not_found",
432            format!("folder not found: {}", dir.display()),
433        ));
434    }
435    let mut out = discover_files(dir, "sound-path");
436    let opts = ScanOptions {
437        extra: vec![dir.to_path_buf()],
438        standard: false,
439        watchdog,
440        cache_path: env.scan_cache(),
441        ..Default::default()
442    };
443    let report = scan::scan(&opts)?;
444    out.extend(report.plugins.iter().map(|p| plugin_sound(p, "sound-path")));
445    Ok(out)
446}
447
448fn plugin_sound(p: &scan::ScannedPlugin, origin: &str) -> Sound {
449    Sound {
450        id: if p.id.is_empty() { slug(&p.name) } else { p.id.clone() },
451        name: p.name.clone(),
452        kind: match p.format {
453            PluginFormat::Clap => SoundKind::Clap,
454            PluginFormat::Vst3 => SoundKind::Vst3,
455        },
456        path: p.path.clone(),
457        plugin_id: if p.id.is_empty() { None } else { Some(p.id.clone()) },
458        instrument: p.is_instrument(),
459        category: p.category.clone(),
460        tags: p.features.clone(),
461        origin: origin.into(),
462        registry_id: None,
463    }
464}
465
466/// A record in `installed.json`.
467#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
468pub struct InstalledRecord {
469    pub id: String,
470    pub name: String,
471    #[serde(rename = "type")]
472    pub kind: SoundKind,
473    #[serde(default)]
474    pub version: String,
475    #[serde(default)]
476    pub license: String,
477    /// Files and folders this install created (removed by `sounds remove`).
478    pub paths: Vec<PathBuf>,
479    #[serde(default)]
480    pub tags: Vec<String>,
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub category: Option<String>,
483    /// `registry` or `user` (`sounds install --from`).
484    pub source: String,
485    #[serde(default)]
486    pub installed_at: u64,
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub sha256: Option<String>,
489}
490
491pub fn read_installed(env: &SoundsEnv) -> Vec<InstalledRecord> {
492    std::fs::read(env.installed_file()).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
493}
494
495pub fn write_installed(env: &SoundsEnv, recs: &[InstalledRecord]) -> Result<()> {
496    std::fs::create_dir_all(&env.sounds_dir)?;
497    let f = env.installed_file();
498    let tmp = f.with_extension("json.part");
499    std::fs::write(&tmp, serde_json::to_vec_pretty(recs)?)?;
500    std::fs::rename(&tmp, &f)?;
501    Ok(())
502}
503
504/// Every sound ferraus can use without a download: installed sounds (with their registry
505/// tags), `sound_paths` folders, and plugins in the scan cache.
506pub fn discover(env: &SoundsEnv, sound_paths: &[PathBuf], watchdog: Duration) -> Result<Vec<Sound>> {
507    let installed = read_installed(env);
508    let mut out: Vec<Sound> = Vec::new();
509    for rec in &installed {
510        for p in &rec.paths {
511            if rec.kind.is_plugin() {
512                continue;
513            }
514            for mut s in discover_files_with(p, "installed", rec.kind == SoundKind::Ir) {
515                s.registry_id = Some(rec.id.clone());
516                for t in &rec.tags {
517                    if !s.tags.contains(t) {
518                        s.tags.push(t.clone());
519                    }
520                }
521                if rec.category.is_some() && s.kind != SoundKind::Ir {
522                    s.category = rec.category.clone();
523                }
524                // a single-sound install answers to the registry id and name too
525                if rec.paths.len() == 1 && discover_files_with(p, "installed", rec.kind == SoundKind::Ir).len() == 1 {
526                    s.id = rec.id.clone();
527                    s.name = rec.name.clone();
528                }
529                out.push(s);
530            }
531        }
532    }
533    // the sounds folder itself (files copied there by hand)
534    for s in discover_files(&env.sounds_dir, "installed") {
535        if !out.iter().any(|o| o.path == s.path) {
536            out.push(s);
537        }
538    }
539    for d in sound_paths {
540        if d.exists() {
541            for s in discover_files(d, "sound-path") {
542                if !out.iter().any(|o| o.path == s.path) {
543                    out.push(s);
544                }
545            }
546        }
547    }
548    // plugins: sound paths and the per-user install folders are scanned (cached by mtime);
549    // the standard folders come from the last full `plugin scan`.
550    let mut dirs: Vec<PathBuf> = sound_paths.iter().filter(|d| d.exists()).cloned().collect();
551    for d in [&env.clap_dir, &env.vst3_dir] {
552        if d.exists() {
553            dirs.push(d.clone());
554        }
555    }
556    if !dirs.is_empty() {
557        let opts =
558            ScanOptions { extra: dirs, standard: false, watchdog, cache_path: env.scan_cache(), ..Default::default() };
559        for p in scan::scan(&opts)?.plugins {
560            let s = plugin_sound(&p, "sound-path");
561            if !out.iter().any(|o| o.path == s.path && o.plugin_id == s.plugin_id) {
562                out.push(s);
563            }
564        }
565    }
566    for p in ScanCache::load(&env.scan_cache()).plugins() {
567        let s = plugin_sound(&p, "plugin-scan");
568        if !out.iter().any(|o| o.path == s.path && o.plugin_id == s.plugin_id) {
569            out.push(s);
570        }
571    }
572    for s in out.iter_mut() {
573        if let Some(rec) = installed.iter().find(|r| r.paths.iter().any(|p| s.path.starts_with(p))) {
574            s.registry_id = Some(rec.id.clone());
575        }
576    }
577    Ok(out)
578}
579
580// ---------------------------------------------------------------------------------------
581// aliases
582
583#[derive(Serialize, Deserialize, Default)]
584struct AliasFile {
585    #[serde(default)]
586    aliases: BTreeMap<String, String>,
587}
588
589/// `[aliases]` from `<config>/sounds/aliases.toml`.
590pub fn read_aliases(env: &SoundsEnv) -> BTreeMap<String, String> {
591    std::fs::read_to_string(env.aliases_file())
592        .ok()
593        .and_then(|t| toml::from_str::<AliasFile>(&t).ok())
594        .map(|f| f.aliases)
595        .unwrap_or_default()
596}
597
598pub fn write_aliases(env: &SoundsEnv, aliases: &BTreeMap<String, String>) -> Result<()> {
599    let f = env.aliases_file();
600    if let Some(p) = f.parent() {
601        std::fs::create_dir_all(p)?;
602    }
603    let body = toml::to_string(&AliasFile { aliases: aliases.clone() })
604        .map_err(|e| Error::internal(format!("cannot write aliases: {e}")))?;
605    let text = format!(
606        "# Instrument aliases for ferraus (`ferraus sounds alias name=<id or source>`).\n# Values are sound ids, registry ids, or explicit sources such as plugin:\"Surge XT\" or sf2:/path/gm.sf2.\n{body}"
607    );
608    std::fs::write(&f, text)?;
609    Ok(())
610}
611
612// ---------------------------------------------------------------------------------------
613// resolution
614
615/// How a query was resolved.
616#[derive(Clone, Debug, Serialize, PartialEq)]
617pub struct Resolved {
618    pub query: String,
619    /// `explicit`, `alias`, `name`, or `tag`.
620    pub via: String,
621    #[serde(skip_serializing_if = "Option::is_none")]
622    pub alias_target: Option<String>,
623    pub sound: Sound,
624}
625
626fn explicit(env: &SoundsEnv, q: &str, sound_paths: &[PathBuf], watchdog: Duration) -> Result<Option<Sound>> {
627    let Some((scheme, rest)) = q.split_once(':') else {
628        return Ok(None);
629    };
630    let rest = rest.trim().trim_matches('"');
631    let kind = match scheme.to_ascii_lowercase().as_str() {
632        "plugin" => {
633            let r = scan::resolve_plugin_with(rest, sound_paths, watchdog, &env.scan_cache())?;
634            let p = scan::ScanCache::load(&env.scan_cache())
635                .plugins()
636                .into_iter()
637                .find(|p| p.path == r.path && Some(&p.id) == r.plugin_id.as_ref());
638            return Ok(Some(match p {
639                Some(p) => plugin_sound(&p, "explicit"),
640                None => Sound {
641                    id: r.plugin_id.clone().unwrap_or_else(|| slug(&r.name)),
642                    name: r.name.clone(),
643                    kind: if r.format == PluginFormat::Clap { SoundKind::Clap } else { SoundKind::Vst3 },
644                    path: r.path.clone(),
645                    plugin_id: r.plugin_id.clone(),
646                    instrument: true,
647                    category: None,
648                    tags: Vec::new(),
649                    origin: "explicit".into(),
650                    registry_id: None,
651                },
652            }));
653        }
654        "sf2" => SoundKind::Sf2,
655        "sfz" => SoundKind::Sfz,
656        "ir" => SoundKind::Ir,
657        _ => return Ok(None),
658    };
659    let p = PathBuf::from(rest);
660    if !p.is_file() {
661        return Err(Error::new(
662            ErrorKind::InputMissing,
663            "input_not_found",
664            format!("{} file not found: {}", kind.as_str(), p.display()),
665        ));
666    }
667    let name = p.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
668    Ok(Some(Sound {
669        id: slug(&name),
670        category: infer_category(&name).map(|s| s.to_string()),
671        name,
672        kind,
673        path: p,
674        plugin_id: None,
675        instrument: kind != SoundKind::Ir,
676        tags: Vec::new(),
677        origin: "explicit".into(),
678        registry_id: None,
679    }))
680}
681
682fn norm(s: &str) -> String {
683    s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
684}
685
686fn candidates_error(q: &str, hits: &[&Sound]) -> Error {
687    let list: Vec<String> = hits
688        .iter()
689        .take(10)
690        .map(|s| format!("{} [{}] → --instrument '{}'", s.name, s.kind.as_str(), s.source()))
691        .collect();
692    let hint = format!("ferraus sounds alias {}={}", slug(q), hits[0].id);
693    Error::new(
694        ErrorKind::Usage,
695        "sound_ambiguous",
696        format!(
697            "'{q}' matches {} sounds:\n  {}\nPick one with an explicit source or set an alias.",
698            hits.len(),
699            list.join("\n  ")
700        ),
701    )
702    .with_hint(hint)
703}
704
705/// [`resolve_sound`] with the standard ferraus folders and the default watchdog: the entry
706/// point for `render --instrument <name>` (pass `ctx.globals.sound_paths`).
707pub fn resolve_instrument(query: &str, sound_paths: &[PathBuf]) -> Result<Option<Resolved>> {
708    resolve_sound(&SoundsEnv::from_env(), query, sound_paths, crate::client::default_watchdog())
709}
710
711/// [`discover`] with the standard ferraus folders and the default watchdog.
712pub fn discover_sounds(sound_paths: &[PathBuf]) -> Result<Vec<Sound>> {
713    discover(&SoundsEnv::from_env(), sound_paths, crate::client::default_watchdog())
714}
715
716/// Resolve an instrument (or IR) name. `Ok(None)` when nothing matches, so callers can
717/// fall back to General MIDI names; an ambiguous name is an error listing candidates.
718pub fn resolve_sound(
719    env: &SoundsEnv,
720    query: &str,
721    sound_paths: &[PathBuf],
722    watchdog: Duration,
723) -> Result<Option<Resolved>> {
724    resolve_inner(env, query, sound_paths, watchdog, 0)
725}
726
727fn resolve_inner(
728    env: &SoundsEnv,
729    query: &str,
730    sound_paths: &[PathBuf],
731    watchdog: Duration,
732    depth: usize,
733) -> Result<Option<Resolved>> {
734    let q = query.trim();
735    if q.is_empty() {
736        return Err(Error::usage("empty instrument name"));
737    }
738    if let Some(s) = explicit(env, q, sound_paths, watchdog)? {
739        return Ok(Some(Resolved { query: q.into(), via: "explicit".into(), alias_target: None, sound: s }));
740    }
741    if depth == 0 {
742        let aliases = read_aliases(env);
743        let target = aliases.get(q).or_else(|| aliases.iter().find(|(k, _)| k.eq_ignore_ascii_case(q)).map(|(_, v)| v));
744        if let Some(t) = target {
745            if let Some(mut r) = resolve_inner(env, t, sound_paths, watchdog, depth + 1)? {
746                r.alias_target = Some(t.clone());
747                r.query = q.into();
748                r.via = "alias".into();
749                return Ok(Some(r));
750            }
751            return Err(Error::new(
752                ErrorKind::Usage,
753                "alias_unresolved",
754                format!("alias '{q}' points to '{t}', which is not installed or found"),
755            )
756            .with_hint(format!("ferraus sounds install {t}")));
757        }
758    }
759    let all = discover(env, sound_paths, watchdog)?;
760    let nq = norm(q);
761    let exact: Vec<&Sound> = all
762        .iter()
763        .filter(|s| {
764            s.id.eq_ignore_ascii_case(q)
765                || s.name.eq_ignore_ascii_case(q)
766                || norm(&s.name) == nq
767                || s.registry_id.as_deref().map(|r| r.eq_ignore_ascii_case(q)).unwrap_or(false)
768        })
769        .collect();
770    let pick = |hits: Vec<&Sound>, via: &str| -> Result<Option<Resolved>> {
771        // prefer things that play notes
772        let inst: Vec<&Sound> = hits.iter().copied().filter(|s| s.instrument).collect();
773        let hits = if inst.is_empty() { hits } else { inst };
774        match hits.len() {
775            0 => Ok(None),
776            1 => Ok(Some(Resolved { query: q.into(), via: via.into(), alias_target: None, sound: hits[0].clone() })),
777            _ => {
778                // the same sound from one install (several .sfz variants) still counts as ambiguous
779                Err(candidates_error(q, &hits))
780            }
781        }
782    };
783    if !exact.is_empty() {
784        return pick(exact, "name");
785    }
786    let words: Vec<String> = q
787        .to_ascii_lowercase()
788        .split(|c: char| !c.is_alphanumeric())
789        .filter(|w| !w.is_empty())
790        .map(|w| w.to_string())
791        .collect();
792    let tagged: Vec<&Sound> = all
793        .iter()
794        .filter(|s| {
795            words.iter().all(|w| {
796                s.tags.iter().any(|t| t == w)
797                    || s.category.as_deref() == Some(w.as_str())
798                    || s.name.to_ascii_lowercase().split(|c: char| !c.is_alphanumeric()).any(|n| n == w)
799            })
800        })
801        .collect();
802    pick(tagged, "tag")
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808
809    #[test]
810    fn categories() {
811        assert_eq!(infer_category("Salamander Grand Piano"), Some("keys"));
812        assert_eq!(infer_category("Nylon Guitar"), Some("guitar"));
813        assert_eq!(infer_category("Violin Section"), Some("strings"));
814        assert_eq!(infer_category("TR-808 kit"), Some("drums"));
815        assert_eq!(infer_category("Warm Pad"), Some("pad"));
816        assert_eq!(infer_category("Surge XT synthesizer"), Some("synth"));
817        assert_eq!(infer_category("Hall reverb IR"), Some("fx"));
818        assert_eq!(infer_category("zzz"), None);
819    }
820
821    #[test]
822    fn sfz_samples_are_not_irs() {
823        let d = ferraus_core::testing::TempDir::new("sfzlib");
824        let lib = d.path("Grand Piano");
825        std::fs::create_dir_all(lib.join("samples")).unwrap();
826        std::fs::write(lib.join("Grand Piano.sfz"), "<region> sample=samples/c4.wav").unwrap();
827        std::fs::write(lib.join("samples/c4.wav"), b"RIFF").unwrap();
828        std::fs::create_dir_all(d.path("irs")).unwrap();
829        std::fs::write(d.path("irs/hall.wav"), b"RIFF").unwrap();
830        let s = discover_files(&d.0, "sound-path");
831        let kinds: Vec<(SoundKind, String)> = s.iter().map(|s| (s.kind, s.name.clone())).collect();
832        assert!(kinds.contains(&(SoundKind::Sfz, "Grand Piano".into())));
833        assert!(kinds.contains(&(SoundKind::Ir, "hall".into())));
834        assert_eq!(s.len(), 2, "{kinds:?}");
835    }
836
837    #[test]
838    fn aliases_roundtrip() {
839        let d = ferraus_core::testing::TempDir::new("alias");
840        let env = SoundsEnv::under(&d.0);
841        let mut a = BTreeMap::new();
842        a.insert("piano".to_string(), "salamander-grand-piano".to_string());
843        write_aliases(&env, &a).unwrap();
844        assert_eq!(read_aliases(&env), a);
845        let text = std::fs::read_to_string(env.aliases_file()).unwrap();
846        assert!(text.contains("[aliases]"));
847    }
848}