Skip to main content

sim_lib_plugin_wasm/
processor.rs

1//! Wasmtime-backed audio plugin processor.
2
3#[cfg(feature = "wasm-plugin")]
4use wasmtime::{
5    Caller, Config, Engine, Linker, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc,
6};
7
8use sim_kernel::{Error, Result};
9#[cfg(feature = "wasm-plugin")]
10use sim_lib_audio_graph_core::{PortDecl, PortDir, PortMedia, PrepareConfig, ProcessBlock};
11use sim_lib_plugin_core::PluginDescriptor;
12#[cfg(feature = "wasm-plugin")]
13use sim_lib_plugin_core::{
14    ParameterDescriptor, PluginFormat, PluginId, PluginInstance, PluginState,
15};
16
17use crate::WasmResourceLimits;
18#[cfg(feature = "wasm-plugin")]
19use crate::abi::{
20    EXPORT_MANIFEST_PTR, EXPORT_PREPARE, EXPORT_PROCESS, EXPORT_RESET, IMPORT_AUDIO_READ,
21    IMPORT_AUDIO_WRITE, IMPORT_FRAME_COUNT, IMPORT_MODULE, IMPORT_PARAM_GET, WasmAudioManifest,
22};
23
24#[cfg(feature = "wasm-plugin")]
25const LOAD_FUEL: u64 = 10_000_000;
26
27#[cfg(feature = "wasm-plugin")]
28#[derive(Debug)]
29struct HostAudio {
30    frame_count: u32,
31    audio_in: Vec<Vec<f32>>,
32    audio_out: Vec<Vec<f32>>,
33    params: Vec<f64>,
34    store_limits: StoreLimits,
35}
36
37#[cfg(feature = "wasm-plugin")]
38impl HostAudio {
39    fn new(limits: WasmResourceLimits) -> Self {
40        Self {
41            frame_count: 0,
42            audio_in: Vec::new(),
43            audio_out: Vec::new(),
44            params: Vec::new(),
45            store_limits: StoreLimitsBuilder::new()
46                .memory_size(limits.max_memory_bytes())
47                .trap_on_grow_failure(true)
48                .build(),
49        }
50    }
51}
52
53/// WebAssembly-backed plugin instance exposed through the shared plugin API.
54pub struct WasmPluginProcessor {
55    #[cfg(feature = "wasm-plugin")]
56    store: Store<HostAudio>,
57    #[cfg(feature = "wasm-plugin")]
58    fn_prepare: TypedFunc<(f64, u32), ()>,
59    #[cfg(feature = "wasm-plugin")]
60    fn_reset: TypedFunc<(), ()>,
61    #[cfg(feature = "wasm-plugin")]
62    fn_process: TypedFunc<(), i32>,
63    descriptor: PluginDescriptor,
64    #[cfg(feature = "wasm-plugin")]
65    state: PluginState,
66    #[cfg(feature = "wasm-plugin")]
67    limits: WasmResourceLimits,
68}
69
70impl WasmPluginProcessor {
71    /// Returns this plugin's descriptor.
72    pub fn descriptor(&self) -> &PluginDescriptor {
73        &self.descriptor
74    }
75
76    /// Sets one host-side parameter value.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error when `id` is outside the manifest-declared parameter
81    /// range, or when the crate is built without the `wasm-plugin` feature.
82    pub fn set_param(&mut self, id: u32, value: f64) -> Result<()> {
83        self.set_param_inner(id, value)
84    }
85
86    #[cfg(feature = "wasm-plugin")]
87    fn set_param_inner(&mut self, id: u32, value: f64) -> Result<()> {
88        let Some(slot) = self.store.data_mut().params.get_mut(id as usize) else {
89            return Err(Error::Eval(format!("wasm plugin parameter {id} is absent")));
90        };
91        *slot = value;
92        self.state.set_param(id, value);
93        Ok(())
94    }
95
96    #[cfg(not(feature = "wasm-plugin"))]
97    fn set_param_inner(&mut self, _id: u32, _value: f64) -> Result<()> {
98        Err(Error::Eval(
99            "wasm plugin runtime feature is not enabled".to_owned(),
100        ))
101    }
102}
103
104#[cfg(feature = "wasm-plugin")]
105impl WasmPluginProcessor {
106    /// Instantiates a wasm audio plugin from module bytes.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error when the module is invalid, missing required exports, or
111    /// declares an invalid plugin descriptor.
112    pub fn from_bytes(wasm: &[u8]) -> Result<Self> {
113        Self::from_bytes_with_limits(wasm, WasmResourceLimits::default())
114    }
115
116    /// Instantiates a wasm audio plugin from module bytes with resource limits.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error when the module is invalid, missing required exports,
121    /// exceeds memory limits, or declares an invalid plugin descriptor.
122    pub fn from_bytes_with_limits(wasm: &[u8], limits: WasmResourceLimits) -> Result<Self> {
123        let mut config = Config::new();
124        config.consume_fuel(true);
125        let engine = Engine::new(&config)
126            .map_err(|err| Error::Eval(format!("wasm engine init failed: {err}")))?;
127        let module = Module::new(&engine, wasm)
128            .map_err(|err| Error::Eval(format!("wasm module invalid: {err}")))?;
129        let linker = build_audio_linker(&engine)?;
130        let host = HostAudio::new(limits);
131        let mut store = Store::new(&engine, host);
132        store.limiter(|host| &mut host.store_limits);
133        refill_fuel(&mut store, LOAD_FUEL)?;
134        let instance = linker
135            .instantiate(&mut store, &module)
136            .map_err(|err| Error::Eval(format!("wasm instantiate failed: {err}")))?;
137        refill_fuel(&mut store, LOAD_FUEL)?;
138
139        let manifest_ptr_fn: TypedFunc<(), u32> = instance
140            .get_typed_func(&mut store, EXPORT_MANIFEST_PTR)
141            .map_err(|err| Error::Eval(format!("missing {EXPORT_MANIFEST_PTR}: {err}")))?;
142        let ptr = manifest_ptr_fn
143            .call(&mut store, ())
144            .map_err(|err| Error::Eval(format!("{EXPORT_MANIFEST_PTR} trapped: {err}")))?
145            as usize;
146
147        let memory = instance
148            .get_memory(&mut store, "memory")
149            .ok_or_else(|| Error::Eval("wasm plugin has no exported memory".to_owned()))?;
150        let mem_data = memory.data(&store);
151        let raw_bytes = mem_data
152            .get(ptr..ptr + WasmAudioManifest::SIZE)
153            .ok_or_else(|| Error::Eval("manifest pointer is out of bounds".to_owned()))?;
154        let manifest = WasmAudioManifest::from_bytes(raw_bytes)?;
155        let descriptor = descriptor_from_manifest(&manifest)?;
156        store.data_mut().params = vec![1.0; manifest.param_count as usize];
157
158        let fn_prepare = instance
159            .get_typed_func::<(f64, u32), ()>(&mut store, EXPORT_PREPARE)
160            .map_err(|err| Error::Eval(format!("missing {EXPORT_PREPARE}: {err}")))?;
161        let fn_reset = instance
162            .get_typed_func::<(), ()>(&mut store, EXPORT_RESET)
163            .map_err(|err| Error::Eval(format!("missing {EXPORT_RESET}: {err}")))?;
164        let fn_process = instance
165            .get_typed_func::<(), i32>(&mut store, EXPORT_PROCESS)
166            .map_err(|err| Error::Eval(format!("missing {EXPORT_PROCESS}: {err}")))?;
167
168        Ok(Self {
169            store,
170            fn_prepare,
171            fn_reset,
172            fn_process,
173            descriptor,
174            state: PluginState::new(),
175            limits,
176        })
177    }
178
179    /// Processes one block and reports wasm traps as eval errors.
180    ///
181    /// On error, the output lanes in `block` are silenced before returning.
182    ///
183    /// # Errors
184    ///
185    /// Returns an eval error when the plugin returns a nonzero status or traps
186    /// during `sim_audio_process`, including fuel exhaustion.
187    pub fn process_checked(&mut self, block: &mut ProcessBlock<'_>) -> Result<()> {
188        let frames = block.frames as usize;
189        {
190            let host = self.store.data_mut();
191            host.frame_count = block.frames;
192            for (ch, input) in block.in_audio.iter().enumerate() {
193                if let Some(lane) = host.audio_in.get_mut(ch)
194                    && lane.len() >= frames
195                    && input.len() >= frames
196                {
197                    lane[..frames].copy_from_slice(&input[..frames]);
198                }
199            }
200            for lane in &mut host.audio_out {
201                if lane.len() >= frames {
202                    lane[..frames].fill(0.0);
203                }
204            }
205        }
206
207        refill_fuel(&mut self.store, self.limits.fuel_per_process)?;
208        match self.fn_process.call(&mut self.store, ()) {
209            Ok(0) => {
210                let host = self.store.data();
211                for (ch, output) in block.out_audio.iter_mut().enumerate() {
212                    if let Some(lane) = host.audio_out.get(ch)
213                        && lane.len() >= frames
214                        && output.len() >= frames
215                    {
216                        output[..frames].copy_from_slice(&lane[..frames]);
217                    }
218                }
219                Ok(())
220            }
221            Ok(code) => {
222                silence_block(block, frames);
223                Err(Error::Eval(format!(
224                    "wasm plugin process returned status {code}"
225                )))
226            }
227            Err(err) => {
228                silence_block(block, frames);
229                Err(Error::Eval(format!("wasm plugin process trapped: {err}")))
230            }
231        }
232    }
233}
234
235#[cfg(not(feature = "wasm-plugin"))]
236impl WasmPluginProcessor {
237    /// Reports that the wasm runtime feature is disabled.
238    ///
239    /// # Errors
240    ///
241    /// Always returns an eval error because no runtime is available.
242    pub fn from_bytes(wasm: &[u8]) -> Result<Self> {
243        Self::from_bytes_with_limits(wasm, WasmResourceLimits::default())
244    }
245
246    /// Reports that the wasm runtime feature is disabled.
247    ///
248    /// # Errors
249    ///
250    /// Always returns an eval error because no runtime is available.
251    pub fn from_bytes_with_limits(_wasm: &[u8], _limits: WasmResourceLimits) -> Result<Self> {
252        Err(Error::Eval(
253            "wasm plugin runtime feature is not enabled".to_owned(),
254        ))
255    }
256}
257
258#[cfg(feature = "wasm-plugin")]
259fn descriptor_from_manifest(manifest: &WasmAudioManifest) -> Result<PluginDescriptor> {
260    let plugin_id = PluginId::new(PluginFormat::Wasm, manifest.stable_id_str().to_owned())?;
261    let mut descriptor = PluginDescriptor::new(
262        plugin_id,
263        manifest.name_str().to_owned(),
264        manifest.vendor_str().to_owned(),
265        "0.1.0".to_owned(),
266    )?;
267    if manifest.audio_in_channels > 0 {
268        descriptor.ports.push(PortDecl::new(
269            "audio-in",
270            PortMedia::Audio,
271            PortDir::In,
272            manifest.audio_in_channels,
273        ));
274    }
275    if manifest.audio_out_channels > 0 {
276        descriptor.ports.push(PortDecl::new(
277            "audio-out",
278            PortMedia::Audio,
279            PortDir::Out,
280            manifest.audio_out_channels,
281        ));
282    }
283    for id in 0..u32::from(manifest.param_count) {
284        descriptor.parameters.push(ParameterDescriptor::new(
285            id,
286            format!("param-{id}"),
287            format!("Param {id}"),
288            0.0,
289            1.0,
290            1.0,
291        )?);
292    }
293    Ok(descriptor)
294}
295
296#[cfg(feature = "wasm-plugin")]
297fn build_audio_linker(engine: &Engine) -> Result<Linker<HostAudio>> {
298    let mut linker = Linker::new(engine);
299    linker
300        .func_wrap(
301            IMPORT_MODULE,
302            IMPORT_FRAME_COUNT,
303            |caller: Caller<'_, HostAudio>| caller.data().frame_count,
304        )
305        .map_err(|err| Error::Eval(err.to_string()))?;
306    linker
307        .func_wrap(
308            IMPORT_MODULE,
309            IMPORT_AUDIO_READ,
310            |caller: Caller<'_, HostAudio>, ch: u32, frame: u32| -> f32 {
311                caller
312                    .data()
313                    .audio_in
314                    .get(ch as usize)
315                    .and_then(|lane| lane.get(frame as usize))
316                    .copied()
317                    .unwrap_or(0.0)
318            },
319        )
320        .map_err(|err| Error::Eval(err.to_string()))?;
321    linker
322        .func_wrap(
323            IMPORT_MODULE,
324            IMPORT_AUDIO_WRITE,
325            |mut caller: Caller<'_, HostAudio>, ch: u32, frame: u32, value: f32| {
326                if let Some(lane) = caller.data_mut().audio_out.get_mut(ch as usize)
327                    && let Some(sample) = lane.get_mut(frame as usize)
328                {
329                    *sample = value;
330                }
331            },
332        )
333        .map_err(|err| Error::Eval(err.to_string()))?;
334    linker
335        .func_wrap(
336            IMPORT_MODULE,
337            IMPORT_PARAM_GET,
338            |caller: Caller<'_, HostAudio>, id: u32| -> f64 {
339                caller
340                    .data()
341                    .params
342                    .get(id as usize)
343                    .copied()
344                    .unwrap_or(1.0)
345            },
346        )
347        .map_err(|err| Error::Eval(err.to_string()))?;
348    Ok(linker)
349}
350
351#[cfg(feature = "wasm-plugin")]
352fn refill_fuel(store: &mut Store<HostAudio>, fuel: u64) -> Result<()> {
353    store
354        .set_fuel(fuel)
355        .map_err(|err| Error::Eval(format!("wasm fuel refill failed: {err}")))
356}
357
358#[cfg(feature = "wasm-plugin")]
359fn silence_block(block: &mut ProcessBlock<'_>, frames: usize) {
360    for output in block.out_audio.iter_mut() {
361        if output.len() >= frames {
362            output[..frames].fill(0.0);
363        }
364    }
365}
366
367#[cfg(feature = "wasm-plugin")]
368impl PluginInstance for WasmPluginProcessor {
369    fn descriptor(&self) -> &PluginDescriptor {
370        &self.descriptor
371    }
372
373    fn state(&self) -> PluginState {
374        self.state.clone()
375    }
376
377    fn set_state(&mut self, state: PluginState) {
378        for (&id, &value) in state.params() {
379            let _ = self.set_param(id, value);
380        }
381        self.state = state;
382    }
383
384    fn prepare(&mut self, cfg: PrepareConfig) {
385        let _ = refill_fuel(&mut self.store, LOAD_FUEL);
386        let _ = self.fn_prepare.call(
387            &mut self.store,
388            (f64::from(cfg.sample_rate_hz), cfg.max_block_frames),
389        );
390        let ch_in = self
391            .descriptor
392            .ports
393            .iter()
394            .filter(|port| port.media == PortMedia::Audio && port.dir == PortDir::In)
395            .map(|port| port.channels as usize)
396            .sum::<usize>();
397        let ch_out = self
398            .descriptor
399            .ports
400            .iter()
401            .filter(|port| port.media == PortMedia::Audio && port.dir == PortDir::Out)
402            .map(|port| port.channels as usize)
403            .sum::<usize>();
404        let frames = cfg.max_block_frames as usize;
405        self.store.data_mut().audio_in = vec![vec![0.0; frames]; ch_in];
406        self.store.data_mut().audio_out = vec![vec![0.0; frames]; ch_out];
407    }
408
409    fn reset(&mut self) {
410        let _ = refill_fuel(&mut self.store, LOAD_FUEL);
411        let _ = self.fn_reset.call(&mut self.store, ());
412    }
413
414    fn process(&mut self, block: &mut ProcessBlock<'_>) {
415        let _ = self.process_checked(block);
416    }
417}