Skip to main content

quiver/wasm/
engine.rs

1//! QuiverEngine - Main WASM interface for Quiver audio engine
2
3use crate::graph::{CableId, NodeId, Patch};
4use crate::io::{AtomicF64, ExternalInput};
5use crate::observer::{StateObserver, SubscriptionTarget};
6use crate::port::{ports_compatible, SignalColors, SignalKind};
7use crate::serialize::{ModuleRegistry, PatchDef};
8use alloc::boxed::Box;
9use alloc::format;
10use alloc::string::{String, ToString};
11use alloc::sync::Arc;
12use alloc::vec::Vec;
13use wasm_bindgen::prelude::*;
14
15/// Well-known module names for the engine-owned MIDI CV sources (see
16/// [`QuiverEngine::add_midi_inputs`]). Cable from these to drive audio from MIDI.
17pub const MIDI_VOCT_MODULE: &str = "midi_voct";
18pub const MIDI_GATE_MODULE: &str = "midi_gate";
19pub const MIDI_VELOCITY_MODULE: &str = "midi_velocity";
20pub const MIDI_MOD_MODULE: &str = "midi_mod";
21pub const MIDI_BEND_MODULE: &str = "midi_bend";
22
23/// Shared atomic handles for the engine-owned MIDI CV sources.
24///
25/// Each handle is a clone of the `Arc<AtomicF64>` held inside an [`ExternalInput`]
26/// module injected into the patch by [`QuiverEngine::add_midi_inputs`]. Writing to
27/// a handle (from `midi_note_on`, `midi_cc`, ...) changes the value the matching
28/// in-patch module outputs on the next tick, so MIDI actually affects audio once
29/// the user cables the source into their graph.
30struct MidiInputs {
31    /// Pitch as V/Oct (0V = C4 / MIDI note 60).
32    voct: Arc<AtomicF64>,
33    /// Gate: 5.0 while a note is held, 0.0 otherwise.
34    gate: Arc<AtomicF64>,
35    /// Note velocity normalized to 0..1.
36    velocity: Arc<AtomicF64>,
37    /// Mod wheel (CC1) normalized to 0..1.
38    modulation: Arc<AtomicF64>,
39    /// Pitch bend as a V/Oct offset (±2 semitones at full deflection).
40    bend: Arc<AtomicF64>,
41}
42
43impl MidiInputs {
44    fn new() -> Self {
45        Self {
46            voct: Arc::new(AtomicF64::new(0.0)),
47            gate: Arc::new(AtomicF64::new(0.0)),
48            velocity: Arc::new(AtomicF64::new(0.0)),
49            modulation: Arc::new(AtomicF64::new(0.0)),
50            bend: Arc::new(AtomicF64::new(0.0)),
51        }
52    }
53}
54
55/// Main WASM interface for Quiver audio engine
56#[wasm_bindgen]
57pub struct QuiverEngine {
58    patch: Patch,
59    registry: ModuleRegistry,
60    observer: StateObserver,
61    sample_rate: f64,
62
63    // Preallocated block-processing buffers (Q093). Reused across `process_block`
64    // calls and grown on demand (never shrunk), so steady-state rendering does no
65    // per-block heap or per-sample JS allocation.
66    block_left: Vec<f64>,
67    block_right: Vec<f64>,
68    block_interleaved: Vec<f32>,
69
70    // Observer decimation (Q093): collect observer state once every `observer_interval`
71    // blocks instead of on every render quantum. `observer_countdown` counts blocks
72    // down to the next collection (0 = collect on this block).
73    observer_interval: u32,
74    observer_countdown: u32,
75
76    // Engine-owned MIDI CV source handles, shared with in-patch ExternalInput
77    // modules created by `add_midi_inputs` (Q096).
78    midi: MidiInputs,
79
80    // MIDI state mirrored for the scalar getters (`midi_note`, `midi_velocity`, ...).
81    midi_note: Option<f64>,
82    midi_velocity: Option<f64>,
83    midi_gate: bool,
84    midi_cc_values: [f64; 128],
85    midi_pitch_bend_value: f64,
86
87    // Currently-held MIDI notes as a `(note, velocity)` stack, ordered oldest ->
88    // newest. Drives the shared monophonic `midi_*` CV sources with **last-note
89    // priority** (legato): the most recently pressed still-held note sounds, and the
90    // gate stays open until the last held note is released. Without this, releasing
91    // one note of an overlapping pair (a chord or legato line) would drop the shared
92    // gate and prematurely release every cabled envelope.
93    held_notes: Vec<(u8, u8)>,
94}
95
96#[wasm_bindgen]
97impl QuiverEngine {
98    /// Create a new Quiver engine
99    #[wasm_bindgen(constructor)]
100    pub fn new(sample_rate: f64) -> Self {
101        // Initialize panic hook for better error messages
102        console_error_panic_hook::set_once();
103
104        Self {
105            patch: Patch::new(sample_rate),
106            registry: ModuleRegistry::new(),
107            observer: StateObserver::new(),
108            sample_rate,
109            block_left: Vec::new(),
110            block_right: Vec::new(),
111            block_interleaved: Vec::new(),
112            // Default: collect observer state every 8th block. UIs poll at ~display
113            // rate, so per-block collection is wasted work; raise/lower with
114            // `set_observer_interval`.
115            observer_interval: 8,
116            observer_countdown: 0,
117            midi: MidiInputs::new(),
118            midi_note: None,
119            midi_velocity: None,
120            midi_gate: false,
121            midi_cc_values: [0.0; 128],
122            midi_pitch_bend_value: 0.0,
123            held_notes: Vec::new(),
124        }
125    }
126
127    /// Get the sample rate
128    #[wasm_bindgen(getter)]
129    pub fn sample_rate(&self) -> f64 {
130        self.sample_rate
131    }
132
133    // =========================================================================
134    // Catalog API
135    // =========================================================================
136
137    /// Get the full module catalog
138    pub fn get_catalog(&self) -> Result<JsValue, JsValue> {
139        let catalog = self.registry.catalog();
140        serde_wasm_bindgen::to_value(&catalog).map_err(|e| JsValue::from_str(&e.to_string()))
141    }
142
143    /// Search modules by query string
144    pub fn search_modules(&self, query: &str) -> Result<JsValue, JsValue> {
145        let results = self.registry.search(query);
146        serde_wasm_bindgen::to_value(&results).map_err(|e| JsValue::from_str(&e.to_string()))
147    }
148
149    /// Get modules by category
150    pub fn get_modules_by_category(&self, category: &str) -> Result<JsValue, JsValue> {
151        let results = self.registry.by_category(category);
152        serde_wasm_bindgen::to_value(&results).map_err(|e| JsValue::from_str(&e.to_string()))
153    }
154
155    /// Get all categories
156    pub fn get_categories(&self) -> Result<JsValue, JsValue> {
157        let categories = self.registry.categories();
158        serde_wasm_bindgen::to_value(&categories).map_err(|e| JsValue::from_str(&e.to_string()))
159    }
160
161    // =========================================================================
162    // Signal Semantics API
163    // =========================================================================
164
165    /// Get default signal colors
166    pub fn get_signal_colors(&self) -> Result<JsValue, JsValue> {
167        let colors = SignalColors::default();
168        serde_wasm_bindgen::to_value(&colors).map_err(|e| JsValue::from_str(&e.to_string()))
169    }
170
171    /// Check port compatibility between two signal kinds
172    pub fn check_compatibility(&self, from: &str, to: &str) -> Result<JsValue, JsValue> {
173        let from_kind = parse_signal_kind(from)?;
174        let to_kind = parse_signal_kind(to)?;
175        let compat = ports_compatible(from_kind, to_kind);
176        serde_wasm_bindgen::to_value(&compat).map_err(|e| JsValue::from_str(&e.to_string()))
177    }
178
179    // =========================================================================
180    // Patch Operations
181    // =========================================================================
182
183    /// Load a patch from JSON
184    pub fn load_patch(&mut self, patch_json: JsValue) -> Result<(), JsValue> {
185        let patch_def: PatchDef = serde_wasm_bindgen::from_value(patch_json)
186            .map_err(|e| JsValue::from_str(&e.to_string()))?;
187
188        self.patch = Patch::from_def(&patch_def, &self.registry, self.sample_rate)
189            .map_err(|e| JsValue::from_str(&format!("{:?}", e)))?;
190
191        Ok(())
192    }
193
194    /// Save the current patch to JSON
195    pub fn save_patch(&self, name: &str) -> Result<JsValue, JsValue> {
196        let patch_def = self.patch.to_def(name);
197        serde_wasm_bindgen::to_value(&patch_def).map_err(|e| JsValue::from_str(&e.to_string()))
198    }
199
200    /// Validate a patch definition
201    pub fn validate_patch(&self, patch_json: JsValue) -> Result<JsValue, JsValue> {
202        let patch_def: PatchDef = serde_wasm_bindgen::from_value(patch_json)
203            .map_err(|e| JsValue::from_str(&e.to_string()))?;
204
205        let result = patch_def.validate_with_registry(&self.registry);
206        serde_wasm_bindgen::to_value(&result).map_err(|e| JsValue::from_str(&e.to_string()))
207    }
208
209    /// Clear the current patch
210    pub fn clear_patch(&mut self) {
211        self.patch = Patch::new(self.sample_rate);
212    }
213
214    // =========================================================================
215    // Module Operations
216    // =========================================================================
217
218    /// Add a module to the patch
219    pub fn add_module(&mut self, type_id: &str, name: &str) -> Result<(), JsValue> {
220        let module = self
221            .registry
222            .instantiate(type_id, self.sample_rate)
223            .ok_or_else(|| JsValue::from_str(&format!("Unknown module type: {}", type_id)))?;
224
225        self.patch.add_boxed(name, module);
226        Ok(())
227    }
228
229    /// Remove a module from the patch
230    pub fn remove_module(&mut self, name: &str) -> Result<(), JsValue> {
231        let node_id = self
232            .get_node_id_by_name(name)
233            .ok_or_else(|| JsValue::from_str(&format!("Unknown module: {}", name)))?;
234
235        self.patch
236            .remove(node_id)
237            .map_err(|e| JsValue::from_str(&format!("{:?}", e)))
238    }
239
240    /// Set module position for UI layout
241    pub fn set_module_position(&mut self, name: &str, x: f32, y: f32) -> Result<(), JsValue> {
242        let node_id = self
243            .get_node_id_by_name(name)
244            .ok_or_else(|| JsValue::from_str(&format!("Unknown module: {}", name)))?;
245
246        self.patch.set_position(node_id, (x, y));
247        Ok(())
248    }
249
250    /// Get module position
251    pub fn get_module_position(&self, name: &str) -> Result<JsValue, JsValue> {
252        let node_id = self
253            .get_node_id_by_name(name)
254            .ok_or_else(|| JsValue::from_str(&format!("Unknown module: {}", name)))?;
255
256        let position = self.patch.get_position(node_id);
257        serde_wasm_bindgen::to_value(&position).map_err(|e| JsValue::from_str(&e.to_string()))
258    }
259
260    /// Get the number of modules in the patch
261    pub fn module_count(&self) -> usize {
262        self.patch.node_count()
263    }
264
265    /// Get the number of cables in the patch
266    pub fn cable_count(&self) -> usize {
267        self.patch.cable_count()
268    }
269
270    /// Set the output module (required for audio output)
271    ///
272    /// The specified module's outputs will be read as the patch's stereo output.
273    /// Port 0 is left channel, port 1 is right channel.
274    pub fn set_output(&mut self, name: &str) -> Result<(), JsValue> {
275        let node_id = self
276            .get_node_id_by_name(name)
277            .ok_or_else(|| JsValue::from_str(&format!("Unknown module: {}", name)))?;
278
279        self.patch.set_output(node_id);
280        Ok(())
281    }
282
283    // =========================================================================
284    // Cable Operations
285    // =========================================================================
286
287    /// Connect two ports (format: "module.port").
288    ///
289    /// Returns the new cable's stable [`CableId`](crate::graph::CableId) as a number.
290    /// Hold onto it and pass it to [`disconnect_cable`](Self::disconnect_cable) to
291    /// remove exactly this connection later, even after other cables change.
292    pub fn connect(&mut self, from: &str, to: &str) -> Result<usize, JsValue> {
293        let (from_ref, to_ref) = self.resolve_ports(from, to)?;
294        self.patch
295            .connect(from_ref, to_ref)
296            .map_err(|e| JsValue::from_str(&format!("{:?}", e)))
297    }
298
299    /// Connect with attenuation. Returns the new cable's stable `CableId`.
300    pub fn connect_attenuated(
301        &mut self,
302        from: &str,
303        to: &str,
304        attenuation: f64,
305    ) -> Result<usize, JsValue> {
306        let (from_ref, to_ref) = self.resolve_ports(from, to)?;
307        self.patch
308            .connect_attenuated(from_ref, to_ref, attenuation)
309            .map_err(|e| JsValue::from_str(&format!("{:?}", e)))
310    }
311
312    /// Connect with full modulation (attenuation and offset).
313    /// Returns the new cable's stable `CableId`.
314    pub fn connect_modulated(
315        &mut self,
316        from: &str,
317        to: &str,
318        attenuation: f64,
319        offset: f64,
320    ) -> Result<usize, JsValue> {
321        let (from_ref, to_ref) = self.resolve_ports(from, to)?;
322        self.patch
323            .connect_modulated(from_ref, to_ref, attenuation, offset)
324            .map_err(|e| JsValue::from_str(&format!("{:?}", e)))
325    }
326
327    /// Disconnect a cable by its stable [`CableId`](crate::graph::CableId).
328    ///
329    /// This is the id returned by [`connect`](Self::connect) and friends. It stays
330    /// valid regardless of how many other cables have been removed since.
331    pub fn disconnect_cable(&mut self, cable_id: usize) -> Result<(), JsValue> {
332        self.patch
333            .disconnect(cable_id as CableId)
334            .map_err(|e| JsValue::from_str(&format!("{:?}", e)))
335    }
336
337    /// Disconnect the cable at the given position in the cable list.
338    ///
339    /// Convenience for callers that track cables positionally. Resolves the position
340    /// to the cable's stable [`CableId`](crate::graph::CableId) and removes it, so the
341    /// underlying removal is id-based (never off-by-one after prior removals).
342    pub fn disconnect_by_index(&mut self, cable_index: usize) -> Result<(), JsValue> {
343        let cable_id = self
344            .patch
345            .cables()
346            .get(cable_index)
347            .map(|c| c.id)
348            .ok_or_else(|| JsValue::from_str(&format!("Invalid cable index: {}", cable_index)))?;
349        self.patch
350            .disconnect(cable_id)
351            .map_err(|e| JsValue::from_str(&format!("{:?}", e)))
352    }
353
354    /// Disconnect two ports (format: "module.port")
355    pub fn disconnect(&mut self, from: &str, to: &str) -> Result<(), JsValue> {
356        let (from_module, from_port) = parse_port_ref(from)?;
357        let (to_module, to_port) = parse_port_ref(to)?;
358
359        let from_handle = self
360            .patch
361            .get_handle_by_name(from_module)
362            .ok_or_else(|| JsValue::from_str(&format!("Unknown module: {}", from_module)))?;
363        let to_handle = self
364            .patch
365            .get_handle_by_name(to_module)
366            .ok_or_else(|| JsValue::from_str(&format!("Unknown module: {}", to_module)))?;
367
368        self.patch
369            .disconnect_ports(from_handle.out(from_port), to_handle.in_(to_port))
370            .map_err(|e| JsValue::from_str(&format!("{:?}", e)))
371    }
372
373    /// Get all module names in the patch
374    pub fn get_module_names(&self) -> Result<JsValue, JsValue> {
375        let names = self.patch.module_names();
376        serde_wasm_bindgen::to_value(&names).map_err(|e| JsValue::from_str(&e.to_string()))
377    }
378
379    // =========================================================================
380    // Parameter Operations
381    // =========================================================================
382
383    /// Get parameters for a module
384    ///
385    /// Note: This returns metadata about the module's type from the registry,
386    /// not the current parameter values. Use get_param for values.
387    pub fn get_params(&self, node_name: &str) -> Result<JsValue, JsValue> {
388        // Find the module to get its type
389        let type_id = self
390            .patch
391            .nodes()
392            .find(|(_, name, _)| *name == node_name)
393            .map(|(_, _, module)| module.type_id())
394            .ok_or_else(|| JsValue::from_str(&format!("Unknown module: {}", node_name)))?;
395
396        // Get metadata from registry which includes port spec with param info
397        let metadata = self
398            .registry
399            .get_metadata(type_id)
400            .ok_or_else(|| JsValue::from_str(&format!("Unknown module type: {}", type_id)))?;
401
402        // Return the port spec which contains param definitions
403        serde_wasm_bindgen::to_value(&metadata.port_spec)
404            .map_err(|e| JsValue::from_str(&e.to_string()))
405    }
406
407    /// Set a parameter value by numeric index
408    pub fn set_param(
409        &mut self,
410        node_name: &str,
411        param_index: u32,
412        value: f64,
413    ) -> Result<(), JsValue> {
414        let node_id = self
415            .get_node_id_by_name(node_name)
416            .ok_or_else(|| JsValue::from_str(&format!("Unknown module: {}", node_name)))?;
417
418        self.patch.set_param(node_id, param_index, value);
419        Ok(())
420    }
421
422    /// Get a parameter value
423    pub fn get_param(&self, node_name: &str, param_index: u32) -> Result<f64, JsValue> {
424        let node_id = self
425            .get_node_id_by_name(node_name)
426            .ok_or_else(|| JsValue::from_str(&format!("Unknown module: {}", node_name)))?;
427
428        self.patch
429            .get_param(node_id, param_index)
430            .ok_or_else(|| JsValue::from_str(&format!("Param {} not found", param_index)))
431    }
432
433    /// Set a parameter value by name
434    ///
435    /// This is a convenience method that looks up the parameter index by name.
436    pub fn set_param_by_name(
437        &mut self,
438        node_name: &str,
439        param_name: &str,
440        value: f64,
441    ) -> Result<(), JsValue> {
442        // Find the module and get its param definitions
443        let param_id = self
444            .patch
445            .nodes()
446            .find(|(_, name, _)| *name == node_name)
447            .and_then(|(_, _, module)| {
448                module
449                    .params()
450                    .iter()
451                    .find(|p| p.name == param_name)
452                    .map(|p| p.id)
453            })
454            .ok_or_else(|| {
455                JsValue::from_str(&format!(
456                    "Unknown parameter '{}' on module '{}'",
457                    param_name, node_name
458                ))
459            })?;
460
461        // Set the parameter
462        let node_id = self
463            .get_node_id_by_name(node_name)
464            .ok_or_else(|| JsValue::from_str(&format!("Unknown module: {}", node_name)))?;
465        self.patch.set_param(node_id, param_id, value);
466        Ok(())
467    }
468
469    // =========================================================================
470    // Real-Time Bridge API
471    // =========================================================================
472
473    /// Subscribe to real-time value updates
474    pub fn subscribe(&mut self, targets: JsValue) -> Result<(), JsValue> {
475        let targets: Vec<SubscriptionTarget> = serde_wasm_bindgen::from_value(targets)
476            .map_err(|e| JsValue::from_str(&e.to_string()))?;
477
478        self.observer.add_subscriptions(targets);
479        Ok(())
480    }
481
482    /// Unsubscribe from real-time value updates
483    pub fn unsubscribe(&mut self, target_ids: JsValue) -> Result<(), JsValue> {
484        let ids: Vec<String> = serde_wasm_bindgen::from_value(target_ids)
485            .map_err(|e| JsValue::from_str(&e.to_string()))?;
486
487        self.observer.remove_subscriptions(&ids);
488        Ok(())
489    }
490
491    /// Clear all subscriptions
492    pub fn clear_subscriptions(&mut self) {
493        self.observer.clear_subscriptions();
494    }
495
496    /// Poll for pending updates (called from requestAnimationFrame)
497    pub fn poll_updates(&mut self) -> Result<JsValue, JsValue> {
498        let updates = self.observer.drain_updates();
499        serde_wasm_bindgen::to_value(&updates).map_err(|e| JsValue::from_str(&e.to_string()))
500    }
501
502    /// Get the number of pending updates
503    pub fn pending_update_count(&self) -> usize {
504        self.observer.pending_count()
505    }
506
507    // =========================================================================
508    // Audio Processing
509    // =========================================================================
510
511    /// Process a single sample and return stereo output as a `Float64Array`
512    /// `[left, right]`.
513    pub fn tick(&mut self) -> Box<[f64]> {
514        let (left, right) = self.patch.tick();
515        Box::new([left, right])
516    }
517
518    /// Process a block of `num_samples` frames and return the interleaved stereo
519    /// result as a `Float32Array` of length `num_samples * 2` (`[l0, r0, l1, r1, ...]`).
520    ///
521    /// # Zero-allocation
522    ///
523    /// The engine keeps preallocated, reused L/R and interleaved buffers (grown on
524    /// demand). Rendering uses the allocation-free [`Patch::tick_block`], so a
525    /// steady-state render quantum performs no per-sample or per-block heap
526    /// allocation. Output is safety-clamped to ±10V to prevent speaker/hearing
527    /// damage from runaway signals.
528    ///
529    /// # Ownership rule (important)
530    ///
531    /// The returned `Float32Array` is a **view into WASM linear memory**, valid only
532    /// until the next call into this engine (which reuses/grows the buffer) or
533    /// `free`. Read it immediately — e.g. copy into your own array with
534    /// `Array.from(...)` or `myBuffer.set(...)` — before calling any other engine
535    /// method. Do not retain the returned object.
536    pub fn process_block(&mut self, num_samples: usize) -> js_sys::Float32Array {
537        const SAFETY_LIMIT: f64 = 10.0; // Max output voltage
538
539        // Grow (never shrink) the reused buffers to fit this block.
540        if self.block_left.len() < num_samples {
541            self.block_left.resize(num_samples, 0.0);
542            self.block_right.resize(num_samples, 0.0);
543        }
544        let interleaved_len = num_samples * 2;
545        if self.block_interleaved.len() < interleaved_len {
546            self.block_interleaved.resize(interleaved_len, 0.0);
547        }
548
549        // Allocation-free block render into the reused L/R slices.
550        self.patch.tick_block(
551            &mut self.block_left[..num_samples],
552            &mut self.block_right[..num_samples],
553        );
554
555        // Interleave + safety-clamp into the reused f32 buffer.
556        for i in 0..num_samples {
557            let left = self.block_left[i].clamp(-SAFETY_LIMIT, SAFETY_LIMIT) as f32;
558            let right = self.block_right[i].clamp(-SAFETY_LIMIT, SAFETY_LIMIT) as f32;
559            self.block_interleaved[i * 2] = left;
560            self.block_interleaved[i * 2 + 1] = right;
561        }
562
563        // Decimated observer collection (Q093): collect only every `observer_interval`
564        // blocks via a countdown (avoids per-sample work; cheap no-op with no
565        // subscriptions). Countdown-based rather than modulo to stay MSRV-friendly.
566        if self.observer_interval <= 1 || self.observer_countdown == 0 {
567            self.observer.collect_from_patch(&self.patch);
568            self.observer_countdown = self.observer_interval.saturating_sub(1);
569        } else {
570            self.observer_countdown -= 1;
571        }
572
573        // SAFETY: `Float32Array::view` returns a view into WASM memory backed by
574        // `block_interleaved`. It is valid until the next engine call reuses/grows
575        // the buffer (documented ownership rule above); callers read it synchronously.
576        unsafe { js_sys::Float32Array::view(&self.block_interleaved[..interleaved_len]) }
577    }
578
579    /// Set how often the state observer collects values, in blocks.
580    ///
581    /// `1` collects on every [`process_block`](Self::process_block); higher values
582    /// decimate collection (default `8`). Clamped to a minimum of 1.
583    pub fn set_observer_interval(&mut self, blocks: u32) {
584        self.observer_interval = blocks.max(1);
585        // Collect promptly under the new cadence.
586        self.observer_countdown = 0;
587    }
588
589    /// Reset all module state
590    pub fn reset(&mut self) {
591        self.patch.reset();
592    }
593
594    /// Compile the patch (required after adding/removing modules or cables)
595    pub fn compile(&mut self) -> Result<(), JsValue> {
596        self.patch
597            .compile()
598            .map_err(|e| JsValue::from_str(&format!("{:?}", e)))
599    }
600
601    // =========================================================================
602    // MIDI Support for Worklet Integration
603    // =========================================================================
604
605    /// Inject the engine-owned MIDI CV source modules into the current patch.
606    ///
607    /// Adds five [`ExternalInput`](crate::io::ExternalInput) modules the user can
608    /// cable from to make MIDI actually drive audio:
609    ///
610    /// | Module name      | Signal          | Fed by                         |
611    /// |------------------|-----------------|--------------------------------|
612    /// | `midi_voct`      | V/Oct           | `midi_note_on` (pitch)         |
613    /// | `midi_gate`      | Gate (0/5V)     | `midi_note_on` / `midi_note_off` |
614    /// | `midi_velocity`  | CV unipolar 0–1 | `midi_note_on` (velocity)      |
615    /// | `midi_mod`       | CV unipolar 0–1 | `midi_cc(1, ...)` (mod wheel)  |
616    /// | `midi_bend`      | CV bipolar V/Oct| `midi_pitch_bend`              |
617    ///
618    /// Each exposes a single `out` port (e.g. cable `midi_voct.out` -> `vco.voct`).
619    /// Idempotent: modules already present (by name) are left untouched, so it is
620    /// safe to call after building or loading a patch. Marks the patch dirty.
621    ///
622    /// Note: these modules are engine-managed and are not in the module registry, so
623    /// a patch saved while they are present cannot be re-instantiated by
624    /// `load_patch` on a fresh engine — call `add_midi_inputs()` again after loading.
625    pub fn add_midi_inputs(&mut self) {
626        if self.patch.get_node_id_by_name(MIDI_VOCT_MODULE).is_none() {
627            self.patch.add_boxed(
628                MIDI_VOCT_MODULE,
629                Box::new(ExternalInput::voct(Arc::clone(&self.midi.voct))),
630            );
631        }
632        if self.patch.get_node_id_by_name(MIDI_GATE_MODULE).is_none() {
633            self.patch.add_boxed(
634                MIDI_GATE_MODULE,
635                Box::new(ExternalInput::gate(Arc::clone(&self.midi.gate))),
636            );
637        }
638        if self
639            .patch
640            .get_node_id_by_name(MIDI_VELOCITY_MODULE)
641            .is_none()
642        {
643            self.patch.add_boxed(
644                MIDI_VELOCITY_MODULE,
645                Box::new(ExternalInput::cv(Arc::clone(&self.midi.velocity))),
646            );
647        }
648        if self.patch.get_node_id_by_name(MIDI_MOD_MODULE).is_none() {
649            self.patch.add_boxed(
650                MIDI_MOD_MODULE,
651                Box::new(ExternalInput::cv(Arc::clone(&self.midi.modulation))),
652            );
653        }
654        if self.patch.get_node_id_by_name(MIDI_BEND_MODULE).is_none() {
655            self.patch.add_boxed(
656                MIDI_BEND_MODULE,
657                Box::new(ExternalInput::cv_bipolar(Arc::clone(&self.midi.bend))),
658            );
659        }
660    }
661
662    /// Handle a MIDI Note On message.
663    ///
664    /// Updates both the scalar getters and the shared `midi_voct` / `midi_gate` /
665    /// `midi_velocity` CV sources (see [`add_midi_inputs`](Self::add_midi_inputs)),
666    /// so a cabled patch responds on the next processed sample.
667    ///
668    /// The shared CV sources are monophonic, so overlapping notes follow **last-note
669    /// priority**: the newly pressed note becomes the sounding note and is pushed onto
670    /// the held-note stack (see [`midi_note_off`](Self::midi_note_off)).
671    pub fn midi_note_on(&mut self, note: u8, velocity: u8) -> Result<(), JsValue> {
672        // Convert MIDI note to V/Oct (0V = C4, 1V = C5).
673        let v_oct = Self::note_to_voct(note);
674        // Convert velocity to 0-1 range.
675        let vel = velocity as f64 / 127.0;
676
677        // Last-note priority: move this note to the top of the held-note stack,
678        // dropping any earlier still-tracked press of the same note so a later
679        // note-off removes the correct entry (and re-presses don't stack duplicates).
680        self.held_notes.retain(|&(n, _)| n != note);
681        self.held_notes.push((note, velocity));
682
683        self.midi_note = Some(v_oct);
684        self.midi_velocity = Some(vel);
685        self.midi_gate = true;
686
687        // Drive the in-patch CV sources.
688        self.midi.voct.set(v_oct);
689        self.midi.velocity.set(vel);
690        self.midi.gate.set(5.0);
691
692        Ok(())
693    }
694
695    /// Handle a MIDI Note Off message.
696    ///
697    /// The `midi_*` CV sources are monophonic and shared, so releasing a note only
698    /// closes the gate when it is the **last** held note. With overlapping notes (a
699    /// chord, or legato where the next note-on precedes the previous note-off),
700    /// releasing an inner note keeps the gate open and re-points pitch/velocity to the
701    /// most recently pressed note still held (**last-note priority**). This preserves
702    /// the documented "Gate: 5.0 while a note is held" contract instead of dropping the
703    /// gate — and prematurely releasing every cabled envelope — on the first release.
704    pub fn midi_note_off(&mut self, note: u8, _velocity: u8) -> Result<(), JsValue> {
705        // Remove the released note from the held-note stack.
706        self.held_notes.retain(|&(n, _)| n != note);
707
708        match self.held_notes.last().copied() {
709            // Another note is still held: keep the gate open and revert to it.
710            Some((held_note, held_velocity)) => {
711                let v_oct = Self::note_to_voct(held_note);
712                let vel = held_velocity as f64 / 127.0;
713
714                self.midi_note = Some(v_oct);
715                self.midi_velocity = Some(vel);
716                self.midi_gate = true;
717
718                self.midi.voct.set(v_oct);
719                self.midi.velocity.set(vel);
720                // Gate is already high, but set it explicitly so state is coherent
721                // even if this note-off arrives before any note-on was tracked.
722                self.midi.gate.set(5.0);
723            }
724            // Last held note released: close the gate.
725            None => {
726                self.midi_gate = false;
727                self.midi.gate.set(0.0);
728            }
729        }
730
731        Ok(())
732    }
733
734    /// Convert a MIDI note number to V/Oct (0V = C4 / MIDI note 60, 1V = C5).
735    fn note_to_voct(note: u8) -> f64 {
736        (note as f64 - 60.0) / 12.0
737    }
738
739    /// Get the current MIDI note as V/Oct (for connecting to VCO)
740    #[wasm_bindgen(getter)]
741    pub fn midi_note(&self) -> f64 {
742        self.midi_note.unwrap_or(0.0)
743    }
744
745    /// Get the current MIDI velocity (0-1)
746    #[wasm_bindgen(getter)]
747    pub fn midi_velocity(&self) -> f64 {
748        self.midi_velocity.unwrap_or(0.0)
749    }
750
751    /// Get the current MIDI gate state
752    #[wasm_bindgen(getter)]
753    pub fn midi_gate(&self) -> bool {
754        self.midi_gate
755    }
756
757    /// Handle a MIDI Control Change message.
758    ///
759    /// All CCs are stored for retrieval via [`get_midi_cc`](Self::get_midi_cc). CC1
760    /// (mod wheel) additionally drives the shared `midi_mod` CV source.
761    pub fn midi_cc(&mut self, cc: u8, value: u8) -> Result<(), JsValue> {
762        let normalized = value as f64 / 127.0;
763        self.midi_cc_values[cc as usize] = normalized;
764        if cc == 1 {
765            self.midi.modulation.set(normalized);
766        }
767        Ok(())
768    }
769
770    /// Get a MIDI CC value (0-1 normalized)
771    pub fn get_midi_cc(&self, cc: u8) -> f64 {
772        self.midi_cc_values.get(cc as usize).copied().unwrap_or(0.0)
773    }
774
775    /// Handle a MIDI Pitch Bend message (`value` in -1..1).
776    ///
777    /// Drives the shared `midi_bend` CV source as a V/Oct offset of ±2 semitones at
778    /// full deflection. The [`pitch_bend`](Self::pitch_bend) getter still returns the
779    /// raw -1..1 value.
780    pub fn midi_pitch_bend(&mut self, value: f64) -> Result<(), JsValue> {
781        self.midi_pitch_bend_value = value;
782        // ±2 semitones = ±(2/12) V.
783        self.midi.bend.set(value * (2.0 / 12.0));
784        Ok(())
785    }
786
787    /// Get the current pitch bend value (-1 to 1)
788    #[wasm_bindgen(getter)]
789    pub fn pitch_bend(&self) -> f64 {
790        self.midi_pitch_bend_value
791    }
792
793    // =========================================================================
794    // Port Information
795    // =========================================================================
796
797    /// Get port specification for a module type
798    pub fn get_port_spec(&self, type_id: &str) -> Result<JsValue, JsValue> {
799        let metadata = self
800            .registry
801            .get_metadata(type_id)
802            .ok_or_else(|| JsValue::from_str(&format!("Unknown module type: {}", type_id)))?;
803
804        serde_wasm_bindgen::to_value(&metadata.port_spec)
805            .map_err(|e| JsValue::from_str(&e.to_string()))
806    }
807
808    // =========================================================================
809    // Helper Methods (non-WASM)
810    // =========================================================================
811
812    /// Get NodeId by module name (delegates to Patch)
813    fn get_node_id_by_name(&self, name: &str) -> Option<NodeId> {
814        self.patch.get_node_id_by_name(name)
815    }
816
817    /// Resolve two `"module.port"` references into concrete [`PortRef`]s.
818    ///
819    /// Uses the fallible [`NodeHandle::output`](crate::graph::NodeHandle::output) /
820    /// [`input`](crate::graph::NodeHandle::input) so a bad port name yields a clean
821    /// `JsValue` error (listing the valid ports) instead of a panic/`unreachable`.
822    fn resolve_ports(
823        &self,
824        from: &str,
825        to: &str,
826    ) -> Result<(crate::graph::PortRef, crate::graph::PortRef), JsValue> {
827        let (from_module, from_port) = parse_port_ref(from)?;
828        let (to_module, to_port) = parse_port_ref(to)?;
829
830        let from_handle = self
831            .patch
832            .get_handle_by_name(from_module)
833            .ok_or_else(|| JsValue::from_str(&format!("Unknown module: {}", from_module)))?;
834        let to_handle = self
835            .patch
836            .get_handle_by_name(to_module)
837            .ok_or_else(|| JsValue::from_str(&format!("Unknown module: {}", to_module)))?;
838
839        let from_ref = from_handle
840            .output(from_port)
841            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
842        let to_ref = to_handle
843            .input(to_port)
844            .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
845        Ok((from_ref, to_ref))
846    }
847}
848
849// Helper functions
850
851fn parse_port_ref(s: &str) -> Result<(&str, &str), JsValue> {
852    let parts: Vec<&str> = s.splitn(2, '.').collect();
853    if parts.len() != 2 {
854        return Err(JsValue::from_str(&format!(
855            "Invalid port reference: {} (expected 'module.port')",
856            s
857        )));
858    }
859    Ok((parts[0], parts[1]))
860}
861
862fn parse_signal_kind(s: &str) -> Result<SignalKind, JsValue> {
863    match s {
864        "audio" => Ok(SignalKind::Audio),
865        "cv_bipolar" => Ok(SignalKind::CvBipolar),
866        "cv_unipolar" => Ok(SignalKind::CvUnipolar),
867        "volt_per_octave" => Ok(SignalKind::VoltPerOctave),
868        "gate" => Ok(SignalKind::Gate),
869        "trigger" => Ok(SignalKind::Trigger),
870        "clock" => Ok(SignalKind::Clock),
871        _ => Err(JsValue::from_str(&format!("Unknown signal kind: {}", s))),
872    }
873}
874
875// Native host-side tests for the Rust glue behind the wasm-bindgen surface
876// (Q164). These run under plain `cargo test --features wasm` on the host — no
877// browser required — and cover parameter marshaling, state bookkeeping, the
878// audio pipeline, MIDI state, and error mapping.
879//
880// IMPORTANT: on a non-wasm target, wasm-bindgen's JS intrinsics are stubbed to
881// abort the process (a non-unwinding SIGABRT), so any method that constructs a
882// `JsValue` — every error branch, and every `-> JsValue`/`-> Result<JsValue,_>`
883// method — cannot be exercised host-side and would kill the test binary. These
884// tests therefore drive only success paths + plain-Rust getters, plus the
885// `QuiverError` conversions (which are pure Rust). That is the full set of
886// Rust-side behavior observable without a JS runtime.
887#[cfg(all(test, feature = "wasm"))]
888mod native_tests {
889    use super::*;
890    use crate::graph::PatchError;
891    use crate::wasm::QuiverError;
892
893    #[test]
894    fn new_engine_reports_sample_rate_and_empty_patch() {
895        let engine = QuiverEngine::new(48_000.0);
896        assert_eq!(engine.sample_rate(), 48_000.0);
897        assert_eq!(engine.module_count(), 0);
898        assert_eq!(engine.cable_count(), 0);
899        assert_eq!(engine.pending_update_count(), 0);
900    }
901
902    #[test]
903    fn add_module_updates_count_and_clear_resets() {
904        let mut engine = QuiverEngine::new(44_100.0);
905        assert!(engine.add_module("vco", "osc").is_ok());
906        assert!(engine.add_module("stereo_output", "out").is_ok());
907        assert_eq!(engine.module_count(), 2);
908        engine.clear_patch();
909        assert_eq!(engine.module_count(), 0);
910        assert_eq!(engine.cable_count(), 0);
911    }
912
913    #[test]
914    fn connect_returns_cable_id_and_updates_cable_count() {
915        let mut engine = QuiverEngine::new(44_100.0);
916        engine.add_module("vco", "osc").unwrap();
917        engine.add_module("stereo_output", "out").unwrap();
918        let id = engine
919            .connect("osc.saw", "out.left")
920            .expect("valid connection");
921        assert_eq!(id, 0, "first cable id should be 0");
922        assert_eq!(engine.cable_count(), 1);
923    }
924
925    #[test]
926    fn connect_then_disconnect_round_trips() {
927        let mut engine = QuiverEngine::new(44_100.0);
928        engine.add_module("vco", "osc").unwrap();
929        engine.add_module("stereo_output", "out").unwrap();
930        let id = engine.connect("osc.saw", "out.left").ok().unwrap();
931        assert_eq!(engine.cable_count(), 1);
932        assert!(engine.disconnect_cable(id).is_ok());
933        assert_eq!(engine.cable_count(), 0);
934    }
935
936    #[test]
937    fn attenuated_and_modulated_connections_succeed() {
938        let mut engine = QuiverEngine::new(44_100.0);
939        engine.add_module("lfo", "lfo").unwrap();
940        engine.add_module("svf", "flt").unwrap();
941        assert!(engine.connect_attenuated("lfo.sin", "flt.fm", 0.5).is_ok());
942        assert!(engine
943            .connect_modulated("lfo.tri", "flt.cutoff", 0.5, 0.1)
944            .is_ok());
945        assert_eq!(engine.cable_count(), 2);
946    }
947
948    #[test]
949    fn compile_and_tick_produce_audio() {
950        let mut engine = QuiverEngine::new(44_100.0);
951        engine.add_module("vco", "osc").unwrap();
952        engine.add_module("stereo_output", "out").unwrap();
953        engine.connect("osc.saw", "out.left").ok().unwrap();
954        engine.connect("osc.saw", "out.right").ok().unwrap();
955        engine.set_output("out").unwrap();
956        assert!(engine.compile().is_ok());
957
958        let mut nonzero = 0;
959        for _ in 0..2000 {
960            let frame = engine.tick();
961            assert_eq!(frame.len(), 2, "tick must return a stereo frame");
962            if frame[0].abs() > 1e-9 {
963                nonzero += 1;
964            }
965        }
966        assert!(nonzero > 1000, "compiled VCO patch should produce audio");
967    }
968
969    #[test]
970    fn set_param_on_valid_module_succeeds() {
971        // Success path only: reading back / bad ids route through JsValue and
972        // cannot be exercised host-side.
973        let mut engine = QuiverEngine::new(44_100.0);
974        engine.add_module("vco", "osc").unwrap();
975        assert!(engine.set_param("osc", 0, 1.0).is_ok());
976    }
977
978    #[test]
979    fn midi_state_round_trips() {
980        let mut engine = QuiverEngine::new(44_100.0);
981        engine.add_midi_inputs();
982        assert_eq!(engine.module_count(), 5, "add_midi_inputs adds 5 modules");
983
984        assert!(engine.midi_note_on(60, 100).is_ok());
985        assert_eq!(engine.midi_note(), 0.0, "note 60 (C4) maps to 0V");
986        assert!(engine.midi_velocity() > 0.0);
987        assert!(engine.midi_gate());
988
989        assert!(engine.midi_note_off(60, 0).is_ok());
990        assert!(!engine.midi_gate());
991
992        assert!(engine.midi_cc(1, 127).is_ok());
993        assert!((engine.get_midi_cc(1) - 1.0).abs() < 1e-9);
994
995        assert!(engine.midi_pitch_bend(0.5).is_ok());
996        assert_eq!(engine.pitch_bend(), 0.5);
997    }
998
999    #[test]
1000    fn note_off_keeps_gate_while_another_note_is_held() {
1001        // Overlapping notes (chord / legato): pressing 60 then 64 sounds 64; releasing
1002        // 60 (an inner note) must NOT drop the shared gate — 64 is still held.
1003        let mut engine = QuiverEngine::new(44_100.0);
1004        engine.midi_note_on(60, 100).unwrap();
1005        engine.midi_note_on(64, 100).unwrap();
1006
1007        engine.midi_note_off(60, 0).unwrap();
1008        assert!(engine.midi_gate(), "gate stays open while note 64 is held");
1009        assert!(
1010            (engine.midi_note() - (64.0 - 60.0) / 12.0).abs() < 1e-9,
1011            "pitch tracks the still-held note 64"
1012        );
1013
1014        // Releasing the last held note finally closes the gate.
1015        engine.midi_note_off(64, 0).unwrap();
1016        assert!(!engine.midi_gate(), "gate closes once no notes remain");
1017    }
1018
1019    #[test]
1020    fn note_off_last_note_priority_reverts_pitch_on_top_release() {
1021        // Pressing 60 then 67 sounds 67; releasing the sounding (top) note reverts to
1022        // the most recent still-held note (60) with the gate still open.
1023        let mut engine = QuiverEngine::new(44_100.0);
1024        engine.midi_note_on(60, 100).unwrap();
1025        engine.midi_note_on(67, 100).unwrap();
1026        assert!((engine.midi_note() - (67.0 - 60.0) / 12.0).abs() < 1e-9);
1027
1028        engine.midi_note_off(67, 0).unwrap();
1029        assert!(engine.midi_gate(), "gate stays open, 60 still held");
1030        assert!(
1031            engine.midi_note().abs() < 1e-9,
1032            "pitch reverts to note 60 (0V) under last-note priority"
1033        );
1034
1035        engine.midi_note_off(60, 0).unwrap();
1036        assert!(!engine.midi_gate());
1037    }
1038
1039    #[test]
1040    fn note_off_single_note_and_stray_release_close_the_gate() {
1041        // A single note round-trips to gate-off, and a stray note-off with nothing
1042        // held leaves the gate closed (matches the original monophonic behavior).
1043        let mut engine = QuiverEngine::new(44_100.0);
1044        engine.midi_note_on(62, 100).unwrap();
1045        assert!(engine.midi_gate());
1046        engine.midi_note_off(62, 0).unwrap();
1047        assert!(!engine.midi_gate());
1048
1049        engine.midi_note_off(90, 0).unwrap();
1050        assert!(!engine.midi_gate(), "stray note-off keeps the gate closed");
1051    }
1052
1053    #[test]
1054    fn note_on_dedupes_repeated_note_so_one_release_clears_it() {
1055        // Re-pressing an already-held note must not stack duplicates, so a single
1056        // note-off fully releases it and closes the gate.
1057        let mut engine = QuiverEngine::new(44_100.0);
1058        engine.midi_note_on(60, 100).unwrap();
1059        engine.midi_note_on(60, 110).unwrap();
1060        engine.midi_note_off(60, 0).unwrap();
1061        assert!(
1062            !engine.midi_gate(),
1063            "one release clears a re-pressed note (no duplicate stack entry)"
1064        );
1065    }
1066
1067    #[test]
1068    fn reset_and_clear_subscriptions_do_not_panic() {
1069        let mut engine = QuiverEngine::new(44_100.0);
1070        engine.add_module("vco", "osc").unwrap();
1071        engine.reset();
1072        engine.clear_subscriptions();
1073        engine.set_observer_interval(4);
1074        assert_eq!(engine.pending_update_count(), 0);
1075    }
1076
1077    #[test]
1078    fn quiver_error_maps_from_patch_error_and_strings() {
1079        // QuiverError conversions are pure Rust (Debug formatting), host-safe.
1080        assert_eq!(QuiverError::from("boom").message(), "boom");
1081        assert_eq!(
1082            QuiverError::from(alloc::string::String::from("halp")).message(),
1083            "halp"
1084        );
1085        assert_eq!(
1086            QuiverError::from(PatchError::InvalidCable).message(),
1087            "InvalidCable"
1088        );
1089    }
1090}