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