1use 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#[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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
62pub struct Sound {
63 pub id: String,
65 pub name: String,
66 pub kind: SoundKind,
67 pub path: PathBuf,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub plugin_id: Option<String>,
71 pub instrument: bool,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub category: Option<String>,
76 #[serde(default)]
77 pub tags: Vec<String>,
78 pub origin: String,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub registry_id: Option<String>,
83}
84
85impl Sound {
86 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 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#[derive(Clone, Debug)]
119pub struct SoundsEnv {
120 pub config_dir: PathBuf,
121 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 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
178const 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
273pub 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
342pub fn discover_files(root: &Path, origin: &str) -> Vec<Sound> {
345 discover_files_with(root, origin, false)
346}
347
348pub 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
426pub 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#[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 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 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
504pub 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 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 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 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#[derive(Serialize, Deserialize, Default)]
584struct AliasFile {
585 #[serde(default)]
586 aliases: BTreeMap<String, String>,
587}
588
589pub 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#[derive(Clone, Debug, Serialize, PartialEq)]
617pub struct Resolved {
618 pub query: String,
619 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
705pub 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
711pub fn discover_sounds(sound_paths: &[PathBuf]) -> Result<Vec<Sound>> {
713 discover(&SoundsEnv::from_env(), sound_paths, crate::client::default_watchdog())
714}
715
716pub 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 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 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}