Skip to main content

maolan_engine/plugins/vst3/
host.rs

1use super::default_vst3_search_roots;
2use super::interfaces::PluginFactory;
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
7pub struct Vst3PluginInfo {
8    pub id: String, // FUID as string
9    pub name: String,
10    pub vendor: String,
11    pub path: String, // Path to .vst3 bundle
12    pub category: String,
13    pub version: String,
14    pub audio_inputs: usize,
15    pub audio_outputs: usize,
16    pub has_midi_input: bool,
17    pub has_midi_output: bool,
18}
19
20pub struct Vst3Host {
21    plugins: Vec<Vst3PluginInfo>,
22}
23
24impl Default for Vst3Host {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl Vst3Host {
31    pub fn new() -> Self {
32        Self {
33            plugins: Vec::new(),
34        }
35    }
36
37    pub fn list_plugins(&mut self) -> Vec<Vst3PluginInfo> {
38        let mut roots = default_vst3_search_roots();
39
40        // Add paths from VST3_PATH environment variable
41        if let Ok(extra) = std::env::var("VST3_PATH") {
42            for p in std::env::split_paths(&extra) {
43                if !p.as_os_str().is_empty() {
44                    roots.push(p);
45                }
46            }
47        }
48
49        let mut out = Vec::new();
50        for root in roots {
51            collect_vst3_plugins(&root, &mut out);
52        }
53
54        // Sort by name
55        out.sort_by_key(|a| a.name.to_lowercase());
56
57        // Deduplicate by path
58        out.dedup_by(|a, b| a.path.eq_ignore_ascii_case(&b.path));
59
60        self.plugins = out.clone();
61        out
62    }
63
64    pub fn get_plugin_info(&self, path: &str) -> Option<&Vst3PluginInfo> {
65        self.plugins.iter().find(|p| p.path == path)
66    }
67}
68
69fn collect_vst3_plugins(root: &Path, out: &mut Vec<Vst3PluginInfo>) {
70    let Ok(entries) = std::fs::read_dir(root) else {
71        return;
72    };
73    for entry in entries.flatten() {
74        let path = entry.path();
75        let Ok(ft) = entry.file_type() else {
76            continue;
77        };
78        if ft.is_symlink() {
79            continue;
80        }
81
82        if !ft.is_dir() {
83            continue;
84        }
85
86        // Check if this is a .vst3 bundle
87        if path
88            .extension()
89            .is_some_and(|ext| ext.eq_ignore_ascii_case("vst3"))
90        {
91            if let Some(info) = scan_vst3_bundle(&path) {
92                out.push(info);
93            }
94        } else {
95            // Recurse into subdirectories
96            collect_vst3_plugins(&path, out);
97        }
98    }
99}
100
101fn scan_vst3_bundle(bundle_path: &Path) -> Option<Vst3PluginInfo> {
102    let factory = PluginFactory::from_module(bundle_path).ok()?;
103    let class_count = factory.count_classes();
104    if class_count == 0 {
105        return None;
106    }
107
108    let factory_info = factory.get_factory_info();
109
110    // Prefer Audio Module classes, matching rust-vst3-host behavior
111    let mut audio_module_index = None;
112    let mut fallback_index = None;
113    for index in 0..class_count {
114        let Some(class_info) = factory.get_class_info(index) else {
115            continue;
116        };
117        if fallback_index.is_none() {
118            fallback_index = Some(index);
119        }
120        if class_info.category.contains("Audio Module") {
121            audio_module_index = Some(index);
122            break;
123        }
124    }
125
126    let target_index = audio_module_index.or(fallback_index)?;
127    let class_info = factory.get_class_info(target_index)?;
128
129    let Ok(mut instance) = factory.create_instance(&class_info.cid) else {
130        return Some(class_info_to_plugin_info(
131            &class_info,
132            bundle_path,
133            None,
134            factory_info.as_ref(),
135        ));
136    };
137    let capabilities = match instance.initialize(&factory) {
138        Ok(()) => {
139            let (audio_inputs, audio_outputs) = instance.main_audio_channel_counts();
140            let (midi_inputs, midi_outputs) = instance.event_bus_counts();
141            let _ = instance.terminate();
142            Some((
143                audio_inputs,
144                audio_outputs,
145                midi_inputs > 0,
146                midi_outputs > 0,
147            ))
148        }
149        Err(_) => None,
150    };
151    Some(class_info_to_plugin_info(
152        &class_info,
153        bundle_path,
154        capabilities,
155        factory_info.as_ref(),
156    ))
157}
158
159fn class_info_to_plugin_info(
160    class_info: &super::interfaces::ClassInfo,
161    bundle_path: &Path,
162    capabilities: Option<(usize, usize, bool, bool)>,
163    factory_info: Option<&super::interfaces::FactoryInfo>,
164) -> Vst3PluginInfo {
165    let (audio_inputs, audio_outputs, has_midi_input, has_midi_output) =
166        capabilities.unwrap_or((0, 0, false, false));
167
168    Vst3PluginInfo {
169        id: tuid_to_string(&class_info.cid),
170        name: class_info.name.clone(),
171        vendor: factory_info.map(|f| f.vendor.clone()).unwrap_or_default(),
172        path: bundle_path.to_string_lossy().to_string(),
173        category: class_info.category.clone(),
174        version: String::new(),
175        audio_inputs,
176        audio_outputs,
177        has_midi_input,
178        has_midi_output,
179    }
180}
181
182fn tuid_to_string(tuid: &[i8; 16]) -> String {
183    // Convert TUID to hexadecimal string
184    tuid.iter()
185        .map(|&b| format!("{:02X}", b as u8))
186        .collect::<Vec<_>>()
187        .join("")
188}
189
190// Standalone function for backward compatibility with old API
191pub fn list_plugins() -> Vec<Vst3PluginInfo> {
192    let mut host = Vst3Host::new();
193    host.list_plugins()
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::plugins::vst3::interfaces::ClassInfo;
200    use std::path::PathBuf;
201
202    #[test]
203    fn class_info_to_plugin_info_uses_capabilities_when_present() {
204        let class_info = ClassInfo {
205            name: "Synth".to_string(),
206            category: "Instrument".to_string(),
207            cid: [0x12_i8; 16],
208        };
209
210        let info = class_info_to_plugin_info(
211            &class_info,
212            Path::new("/tmp/Test.vst3"),
213            Some((2, 4, true, false)),
214            None,
215        );
216
217        assert_eq!(info.id, "12121212121212121212121212121212");
218        assert_eq!(info.name, "Synth");
219        assert_eq!(info.category, "Instrument");
220        assert_eq!(info.path, "/tmp/Test.vst3");
221        assert_eq!(info.audio_inputs, 2);
222        assert_eq!(info.audio_outputs, 4);
223        assert!(info.has_midi_input);
224        assert!(!info.has_midi_output);
225    }
226
227    #[test]
228    fn class_info_to_plugin_info_defaults_capabilities_when_missing() {
229        let class_info = ClassInfo {
230            name: "Fx".to_string(),
231            category: "Audio Module".to_string(),
232            cid: [0; 16],
233        };
234
235        let info = class_info_to_plugin_info(&class_info, Path::new("/tmp/Fx.vst3"), None, None);
236
237        assert_eq!(info.audio_inputs, 0);
238        assert_eq!(info.audio_outputs, 0);
239        assert!(!info.has_midi_input);
240        assert!(!info.has_midi_output);
241    }
242
243    #[test]
244    fn tuid_to_string_formats_bytes_as_uppercase_hex() {
245        let tuid = [
246            0x00_i8, 0x01, 0x23, 0x45, 0x67, 0x7F, -0x80, -0x01, 0x10, 0x20, 0x30, 0x40, 0x50,
247            0x60, 0x70, 0x7E,
248        ];
249
250        assert_eq!(tuid_to_string(&tuid), "00012345677F80FF102030405060707E");
251    }
252
253    #[test]
254    fn get_plugin_info_returns_cached_plugin_by_exact_path() {
255        let mut host = Vst3Host::new();
256        host.plugins = vec![
257            Vst3PluginInfo {
258                id: "id-1".to_string(),
259                name: "First".to_string(),
260                vendor: String::new(),
261                path: "/tmp/First.vst3".to_string(),
262                category: "Instrument".to_string(),
263                version: String::new(),
264                audio_inputs: 2,
265                audio_outputs: 2,
266                has_midi_input: true,
267                has_midi_output: false,
268            },
269            Vst3PluginInfo {
270                id: "id-2".to_string(),
271                name: "Second".to_string(),
272                vendor: String::new(),
273                path: "/tmp/Second.vst3".to_string(),
274                category: "Fx".to_string(),
275                version: String::new(),
276                audio_inputs: 2,
277                audio_outputs: 2,
278                has_midi_input: false,
279                has_midi_output: false,
280            },
281        ];
282
283        let found = host
284            .get_plugin_info("/tmp/Second.vst3")
285            .map(|p| p.name.clone());
286        let missing = host.get_plugin_info(&PathBuf::from("/tmp/missing.vst3").to_string_lossy());
287
288        assert_eq!(found.as_deref(), Some("Second"));
289        assert!(missing.is_none());
290    }
291}