Skip to main content

max / audiofiles

15.9 KB · 531 lines History Blame Raw
1 //! Device plugin runtime for audiofiles.
2 //!
3 //! Loads TOML manifests (bundled + user) describing hardware sampler constraints,
4 //! and optionally runs Rhai scripts for custom export logic.
5 //!
6 //! ## Why Rhai
7 //!
8 //! - **Sandboxed:** No filesystem, network, or FFI access from scripts. Unlike Lua or
9 //! Python plugins, a malicious Rhai script cannot escape the sandbox.
10 //! - **Resource-capped:** Configurable operation count, expression depth, and array size
11 //! limits prevent infinite loops and memory exhaustion.
12 //! - **Rust-native:** Compiles as a regular Rust crate — no external interpreter binary,
13 //! no C bindings, no build-time code generation.
14 //!
15 //! # Architecture
16 //!
17 //! - `manifest` — TOML parsing into `DeviceProfile`
18 //! - `engine` — sandboxed Rhai engine setup
19 //! - `types` — Rhai-compatible wrapper types
20 //! - `host_api` — functions registered into Rhai
21 //! - `hooks` — compile and run Rhai scripts
22 //! - `loader` — filesystem discovery of user plugins
23 //! - `registry` — plugin storage and lookup
24 //! - `bundled` — embedded bundled plugin manifests
25
26 pub mod bundled;
27 pub mod engine;
28 pub mod error;
29 pub mod hooks;
30 pub mod host_api;
31 pub mod loader;
32 pub mod manifest;
33 pub mod registry;
34 pub mod types;
35
36 use std::path::Path;
37
38 use tracing::instrument;
39
40 use error::PluginError;
41 use hooks::CompiledHooks;
42 use manifest::manifest_to_profile;
43 use registry::{LoadedPlugin, PluginRegistry};
44
45 /// Create a fully loaded plugin registry with bundled + user plugins.
46 ///
47 /// User plugins are loaded from `~/.config/audiofiles/plugins/user/`.
48 /// User plugins with the same device name override bundled ones.
49 #[instrument(skip_all)]
50 pub fn create_registry() -> Result<PluginRegistry, PluginError> {
51 let mut registry = PluginRegistry::new();
52
53 // Load bundled plugins first
54 bundled::load_bundled(&mut registry)?;
55
56 // Load user plugins (override bundled if same name)
57 if let Some(config_dir) = dirs::config_dir() {
58 let user_plugins_dir = config_dir.join("audiofiles").join("plugins").join("user");
59 load_user_plugins(&mut registry, &user_plugins_dir)?;
60 }
61
62 Ok(registry)
63 }
64
65 /// Load user plugins from a directory into the registry.
66 #[instrument(skip_all, fields(dir = ?dir))]
67 pub fn load_user_plugins(
68 registry: &mut PluginRegistry,
69 dir: &Path,
70 ) -> Result<(), PluginError> {
71 let discovered = loader::discover_plugins(dir);
72
73 for plugin in discovered {
74 let profile = manifest_to_profile(&plugin.manifest)?;
75
76 // Compile any hook scripts
77 let hooks = compile_plugin_hooks(registry.engine(), &plugin)?;
78
79 registry.insert(LoadedPlugin {
80 profile,
81 hooks,
82 bundled: false,
83 });
84 }
85
86 Ok(())
87 }
88
89 /// Compile Rhai hook scripts for a discovered plugin.
90 #[instrument(skip_all)]
91 fn compile_plugin_hooks(
92 engine: &rhai::Engine,
93 plugin: &loader::DiscoveredPlugin,
94 ) -> Result<CompiledHooks, PluginError> {
95 let hooks_section = &plugin.manifest.hooks;
96
97 let compile_if_present = |path: &Option<String>| -> Result<Option<rhai::AST>, PluginError> {
98 match path {
99 Some(rel_path) => {
100 let source = loader::load_hook_script(&plugin.plugin_dir, rel_path)?;
101 let ast = hooks::compile_hook(engine, &source)?;
102 Ok(Some(ast))
103 }
104 None => Ok(None),
105 }
106 };
107
108 match hooks_section {
109 Some(hooks) => Ok(CompiledHooks {
110 validate_sample: compile_if_present(&hooks.validate_sample)?,
111 transform_filename: compile_if_present(&hooks.transform_filename)?,
112 pre_export: compile_if_present(&hooks.pre_export)?,
113 post_export: compile_if_present(&hooks.post_export)?,
114 }),
115 None => Ok(CompiledHooks {
116 validate_sample: None,
117 transform_filename: None,
118 pre_export: None,
119 post_export: None,
120 }),
121 }
122 }
123
124 #[cfg(test)]
125 mod tests {
126 use super::*;
127 use crate::hooks::{run_validate_sample, run_transform_filename};
128 use crate::types::{RhaiSampleInfo, RhaiExportContext};
129
130 #[test]
131 fn load_user_plugins_from_empty_dir() {
132 let dir = tempfile::tempdir().unwrap();
133 let mut registry = PluginRegistry::new();
134 load_user_plugins(&mut registry, dir.path()).unwrap();
135 assert!(registry.is_empty());
136 }
137
138 #[test]
139 fn load_user_plugin_with_hooks() {
140 let dir = tempfile::tempdir().unwrap();
141 let plugin_dir = dir.path().join("my-sampler");
142 std::fs::create_dir(&plugin_dir).unwrap();
143 let hooks_dir = plugin_dir.join("hooks");
144 std::fs::create_dir(&hooks_dir).unwrap();
145
146 std::fs::write(
147 plugin_dir.join("manifest.toml"),
148 r#"
149 [device]
150 name = "Test Sampler"
151 manufacturer = "Test Co"
152 version = "1.0"
153
154 [audio]
155 formats = ["wav"]
156 sample_rates = [44100]
157 bit_depths = [16]
158 channels = "mono"
159
160 [hooks]
161 validate_sample = "hooks/validate.rhai"
162 "#,
163 )
164 .unwrap();
165
166 std::fs::write(
167 hooks_dir.join("validate.rhai"),
168 "info.channels == 1",
169 )
170 .unwrap();
171
172 let mut registry = PluginRegistry::new();
173 load_user_plugins(&mut registry, dir.path()).unwrap();
174
175 assert_eq!(registry.len(), 1);
176 let plugin = registry.get("Test Sampler").unwrap();
177 assert!(plugin.hooks.validate_sample.is_some());
178 assert!(plugin.hooks.transform_filename.is_none());
179 }
180
181 #[test]
182 fn nonexistent_user_dir_is_ok() {
183 let mut registry = PluginRegistry::new();
184 let result = load_user_plugins(&mut registry, Path::new("/nonexistent/path"));
185 assert!(result.is_ok());
186 assert!(registry.is_empty());
187 }
188
189 // ── Full plugin lifecycle (Item 32) ──
190
191 fn sample_info(rate: u32, channels: u16) -> RhaiSampleInfo {
192 RhaiSampleInfo {
193 hash: "abc123def456".to_string(),
194 name: "kick_drum.wav".to_string(),
195 extension: "wav".to_string(),
196 sample_rate: rate,
197 bit_depth: 16,
198 channels,
199 duration: 0.5,
200 file_size: 44100,
201 }
202 }
203
204 fn export_ctx() -> RhaiExportContext {
205 RhaiExportContext {
206 device_name: "Test Device".to_string(),
207 destination: "/tmp/export".to_string(),
208 filename: "kick_drum".to_string(),
209 extension: "wav".to_string(),
210 index: 3,
211 total: 20,
212 }
213 }
214
215 /// Create a temp plugin directory with manifest and hook scripts.
216 /// Returns the temp dir (must be kept alive for the duration of the test).
217 fn create_test_plugin(
218 dir: &std::path::Path,
219 name: &str,
220 validate_script: &str,
221 transform_script: &str,
222 ) {
223 let plugin_dir = dir.join(name);
224 std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap();
225
226 std::fs::write(
227 plugin_dir.join("manifest.toml"),
228 format!(
229 r#"
230 [device]
231 name = "{name}"
232 manufacturer = "Test Co"
233 version = "1.0"
234
235 [audio]
236 formats = ["wav"]
237 sample_rates = [44100, 48000]
238 bit_depths = [16, 24]
239 channels = "mono"
240
241 [hooks]
242 validate_sample = "hooks/validate.rhai"
243 transform_filename = "hooks/transform.rhai"
244 "#
245 ),
246 )
247 .unwrap();
248
249 std::fs::write(
250 plugin_dir.join("hooks").join("validate.rhai"),
251 validate_script,
252 )
253 .unwrap();
254
255 std::fs::write(
256 plugin_dir.join("hooks").join("transform.rhai"),
257 transform_script,
258 )
259 .unwrap();
260 }
261
262 #[test]
263 fn full_plugin_lifecycle() {
264 // 1. Create temp dir with a plugin: manifest + two hook scripts
265 let dir = tempfile::tempdir().unwrap();
266 create_test_plugin(
267 dir.path(),
268 "Lifecycle Sampler",
269 // validate: accept mono 44100 or 48000
270 "info.channels == 1 && (info.sample_rate == 44100 || info.sample_rate == 48000)",
271 // transform: uppercase + zero-padded index
272 r#"to_upper(name) + "_" + format_index(ctx.index, 3)"#,
273 );
274
275 // 2. Discover plugins from temp dir
276 let discovered = loader::discover_plugins(dir.path());
277 assert_eq!(discovered.len(), 1);
278 assert_eq!(discovered[0].manifest.device.name, "Lifecycle Sampler");
279
280 // 3. Load into registry (manifest -> profile + compile hooks)
281 let mut registry = PluginRegistry::new();
282 load_user_plugins(&mut registry, dir.path()).unwrap();
283
284 assert_eq!(registry.len(), 1);
285 let plugin = registry.get("Lifecycle Sampler").unwrap();
286 assert!(!plugin.bundled);
287 assert_eq!(plugin.profile.name, "Lifecycle Sampler");
288 assert!(plugin.hooks.validate_sample.is_some());
289 assert!(plugin.hooks.transform_filename.is_some());
290 assert!(plugin.hooks.pre_export.is_none());
291 assert!(plugin.hooks.post_export.is_none());
292
293 // 4. Run validate_sample -- mono 44100 should pass
294 let valid = run_validate_sample(
295 registry.engine(),
296 plugin.hooks.validate_sample.as_ref().unwrap(),
297 sample_info(44100, 1),
298 ).unwrap();
299 assert!(valid);
300
301 // 5. Run validate_sample -- stereo should fail
302 let invalid = run_validate_sample(
303 registry.engine(),
304 plugin.hooks.validate_sample.as_ref().unwrap(),
305 sample_info(44100, 2),
306 ).unwrap();
307 assert!(!invalid);
308
309 // 6. Run transform_filename
310 let transformed = run_transform_filename(
311 registry.engine(),
312 plugin.hooks.transform_filename.as_ref().unwrap(),
313 "kick_drum".to_string(),
314 export_ctx(),
315 ).unwrap();
316 assert_eq!(transformed, "KICK_DRUM_003");
317 }
318
319 #[test]
320 fn validate_sample_returns_true_for_matching_criteria() {
321 let dir = tempfile::tempdir().unwrap();
322 let plugin_dir = dir.path().join("validator");
323 std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap();
324
325 std::fs::write(
326 plugin_dir.join("manifest.toml"),
327 r#"
328 [device]
329 name = "Validator"
330 manufacturer = "Test"
331 version = "1.0"
332
333 [audio]
334 formats = ["wav"]
335 sample_rates = [44100]
336 bit_depths = [16]
337 channels = "both"
338
339 [hooks]
340 validate_sample = "hooks/validate.rhai"
341 "#,
342 ).unwrap();
343
344 // Script checks: sample rate is 44100, bit depth is 16, file size under 1MB
345 std::fs::write(
346 plugin_dir.join("hooks").join("validate.rhai"),
347 "info.sample_rate == 44100 && info.bit_depth == 16 && info.file_size < 1048576",
348 ).unwrap();
349
350 let mut registry = PluginRegistry::new();
351 load_user_plugins(&mut registry, dir.path()).unwrap();
352 let plugin = registry.get("Validator").unwrap();
353 let ast = plugin.hooks.validate_sample.as_ref().unwrap();
354
355 // Matching sample -- should return true
356 let result = run_validate_sample(
357 registry.engine(),
358 ast,
359 sample_info(44100, 1),
360 ).unwrap();
361 assert!(result, "expected true for matching sample");
362
363 // Wrong sample rate -- should return false
364 let result = run_validate_sample(
365 registry.engine(),
366 ast,
367 sample_info(48000, 1),
368 ).unwrap();
369 assert!(!result, "expected false for non-matching sample rate");
370 }
371
372 #[test]
373 fn transform_filename_renames_with_context() {
374 let dir = tempfile::tempdir().unwrap();
375 let plugin_dir = dir.path().join("renamer");
376 std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap();
377
378 std::fs::write(
379 plugin_dir.join("manifest.toml"),
380 r#"
381 [device]
382 name = "Renamer"
383 manufacturer = "Test"
384 version = "1.0"
385
386 [audio]
387 formats = ["wav"]
388 sample_rates = [44100]
389 bit_depths = [16]
390 channels = "both"
391
392 [hooks]
393 transform_filename = "hooks/transform.rhai"
394 "#,
395 ).unwrap();
396
397 // Script: lowercase name + device abbreviation + padded index
398 std::fs::write(
399 plugin_dir.join("hooks").join("transform.rhai"),
400 r#"let stem = to_lower(name); stem + "_" + truncate(ctx.device_name, 3) + format_index(ctx.index, 4)"#,
401 ).unwrap();
402
403 let mut registry = PluginRegistry::new();
404 load_user_plugins(&mut registry, dir.path()).unwrap();
405 let plugin = registry.get("Renamer").unwrap();
406 let ast = plugin.hooks.transform_filename.as_ref().unwrap();
407
408 let result = run_transform_filename(
409 registry.engine(),
410 ast,
411 "KICK_DRUM".to_string(),
412 export_ctx(),
413 ).unwrap();
414 assert_eq!(result, "kick_drum_Tes0003");
415
416 // Different name and index
417 let mut ctx2 = export_ctx();
418 ctx2.index = 0;
419 let result = run_transform_filename(
420 registry.engine(),
421 ast,
422 "HiHat".to_string(),
423 ctx2,
424 ).unwrap();
425 assert_eq!(result, "hihat_Tes0000");
426 }
427
428 #[test]
429 fn lifecycle_with_all_four_hooks() {
430 let dir = tempfile::tempdir().unwrap();
431 let plugin_dir = dir.path().join("full-hooks");
432 std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap();
433
434 std::fs::write(
435 plugin_dir.join("manifest.toml"),
436 r#"
437 [device]
438 name = "Full Hooks Device"
439 manufacturer = "Test"
440 version = "1.0"
441
442 [audio]
443 formats = ["wav", "aiff"]
444 sample_rates = [44100, 48000]
445 bit_depths = [16, 24]
446 channels = "both"
447
448 [naming]
449 case = "upper"
450 separator = "_"
451 max_length = 16
452 strip_special = true
453
454 [limits]
455 max_file_size_bytes = 134217728
456
457 [hooks]
458 validate_sample = "hooks/validate.rhai"
459 transform_filename = "hooks/transform.rhai"
460 pre_export = "hooks/pre_export.rhai"
461 post_export = "hooks/post_export.rhai"
462 "#,
463 ).unwrap();
464
465 std::fs::write(
466 plugin_dir.join("hooks").join("validate.rhai"),
467 "info.duration < 30.0",
468 ).unwrap();
469 std::fs::write(
470 plugin_dir.join("hooks").join("transform.rhai"),
471 r#"to_upper(name)"#,
472 ).unwrap();
473 std::fs::write(
474 plugin_dir.join("hooks").join("pre_export.rhai"),
475 r#"let x = ctx.total; x"#,
476 ).unwrap();
477 std::fs::write(
478 plugin_dir.join("hooks").join("post_export.rhai"),
479 r#"let done = ctx.index == ctx.total - 1; done"#,
480 ).unwrap();
481
482 let mut registry = PluginRegistry::new();
483 load_user_plugins(&mut registry, dir.path()).unwrap();
484 let plugin = registry.get("Full Hooks Device").unwrap();
485
486 // All four hooks should be compiled
487 assert!(plugin.hooks.validate_sample.is_some());
488 assert!(plugin.hooks.transform_filename.is_some());
489 assert!(plugin.hooks.pre_export.is_some());
490 assert!(plugin.hooks.post_export.is_some());
491 assert!(!plugin.hooks.is_empty());
492
493 // Profile should have naming and limits from manifest
494 assert!(plugin.profile.naming.is_some());
495 assert!(plugin.profile.limits.is_some());
496 assert_eq!(plugin.profile.audio.formats.len(), 2);
497 assert_eq!(plugin.profile.audio.sample_rates, vec![44100, 48000]);
498
499 // Run validate: 0.5s duration passes, but we can test it
500 let valid = run_validate_sample(
501 registry.engine(),
502 plugin.hooks.validate_sample.as_ref().unwrap(),
503 sample_info(44100, 1),
504 ).unwrap();
505 assert!(valid);
506
507 // Run transform
508 let name = run_transform_filename(
509 registry.engine(),
510 plugin.hooks.transform_filename.as_ref().unwrap(),
511 "kick".to_string(),
512 export_ctx(),
513 ).unwrap();
514 assert_eq!(name, "KICK");
515
516 // Run pre_export (side-effect hook, should not error)
517 hooks::run_pre_export(
518 registry.engine(),
519 plugin.hooks.pre_export.as_ref().unwrap(),
520 export_ctx(),
521 ).unwrap();
522
523 // Run post_export (side-effect hook, should not error)
524 hooks::run_post_export(
525 registry.engine(),
526 plugin.hooks.post_export.as_ref().unwrap(),
527 export_ctx(),
528 ).unwrap();
529 }
530 }
531