Skip to main content

quiver/
io.rs

1//! External I/O Integration
2//!
3//! This module provides components for bridging the patch graph with
4//! external systems: MIDI controllers, audio interfaces, etc.
5
6use crate::port::{GraphModule, PortDef, PortSpec, PortValues, SignalKind};
7use alloc::sync::Arc;
8use alloc::vec;
9use alloc::vec::Vec;
10use core::sync::atomic::Ordering;
11// `AtomicU64` from `portable-atomic`, not `core`: the latter is absent on
12// targets with `max_atomic_width < 64` (e.g. `thumbv7em-none-eabihf`).
13use portable_atomic::AtomicU64;
14
15/// Atomic f64 for lock-free communication between threads
16///
17/// Uses AtomicU64 internally since there's no native AtomicF64.
18/// Suitable for real-time audio thread communication.
19#[derive(Debug)]
20pub struct AtomicF64(AtomicU64);
21
22impl AtomicF64 {
23    /// Create a new atomic f64 with the given initial value
24    pub fn new(value: f64) -> Self {
25        Self(AtomicU64::new(value.to_bits()))
26    }
27
28    /// Get the current value
29    pub fn get(&self) -> f64 {
30        f64::from_bits(self.0.load(Ordering::Relaxed))
31    }
32
33    /// Set a new value
34    pub fn set(&self, value: f64) {
35        self.0.store(value.to_bits(), Ordering::Relaxed);
36    }
37
38    /// Load with specified ordering
39    pub fn load(&self, ordering: Ordering) -> f64 {
40        f64::from_bits(self.0.load(ordering))
41    }
42
43    /// Store with specified ordering
44    pub fn store(&self, value: f64, ordering: Ordering) {
45        self.0.store(value.to_bits(), ordering);
46    }
47}
48
49impl Default for AtomicF64 {
50    fn default() -> Self {
51        Self::new(0.0)
52    }
53}
54
55impl Clone for AtomicF64 {
56    fn clone(&self) -> Self {
57        Self::new(self.get())
58    }
59}
60
61/// A note event (pitch + gate) published atomically in a single 64-bit word.
62///
63/// MIDI note-on must hand a *coherent* (pitch, gate) pair from the MIDI thread
64/// to the audio thread. Writing pitch and gate as two independent atomics with
65/// `Relaxed` ordering lets the audio thread observe a new gate paired with a
66/// stale pitch (a wrong-pitch transient on note changes). Packing both into one
67/// `AtomicU64` and reading it in a single load removes the tear for consumers of
68/// this type (i.e. [`snapshot`](Self::snapshot) / [`MidiState::note_snapshot`]):
69///
70/// - the high 32 bits hold `pitch` (V/Oct) as `f32` bits,
71/// - the low 32 bits hold `gate` (volts) as `f32` bits.
72///
73/// Writers use [`publish`](Self::publish) (`Ordering::Release`); readers use
74/// [`snapshot`](Self::snapshot) (`Ordering::Acquire`). The release/acquire pair
75/// establishes a happens-before edge so a reader that observes a gate value also
76/// observes the matching pitch published in the same word.
77#[derive(Debug)]
78pub struct AtomicNote(AtomicU64);
79
80impl AtomicNote {
81    /// Create a new packed note event.
82    pub fn new(pitch: f64, gate: f64) -> Self {
83        Self(AtomicU64::new(Self::pack(pitch, gate)))
84    }
85
86    #[inline]
87    fn pack(pitch: f64, gate: f64) -> u64 {
88        (((pitch as f32).to_bits() as u64) << 32) | ((gate as f32).to_bits() as u64)
89    }
90
91    #[inline]
92    fn unpack(bits: u64) -> (f64, f64) {
93        let pitch = f32::from_bits((bits >> 32) as u32) as f64;
94        let gate = f32::from_bits(bits as u32) as f64;
95        (pitch, gate)
96    }
97
98    /// Publish a coherent `(pitch, gate)` pair with `Release` ordering.
99    #[inline]
100    pub fn publish(&self, pitch: f64, gate: f64) {
101        self.0.store(Self::pack(pitch, gate), Ordering::Release);
102    }
103
104    /// Load a coherent `(pitch, gate)` snapshot with `Acquire` ordering.
105    ///
106    /// The returned pair is never torn: pitch and gate always come from the same
107    /// [`publish`](Self::publish) call.
108    #[inline]
109    pub fn snapshot(&self) -> (f64, f64) {
110        Self::unpack(self.0.load(Ordering::Acquire))
111    }
112}
113
114impl Default for AtomicNote {
115    fn default() -> Self {
116        Self::new(0.0, 0.0)
117    }
118}
119
120impl Clone for AtomicNote {
121    fn clone(&self) -> Self {
122        let (pitch, gate) = self.snapshot();
123        Self::new(pitch, gate)
124    }
125}
126
127/// External input source - reads from an atomic value set by another thread
128///
129/// This module allows values from external sources (MIDI, OSC, GUI, etc.)
130/// to be brought into the patch graph in a lock-free manner.
131pub struct ExternalInput {
132    value: Arc<AtomicF64>,
133    spec: PortSpec,
134}
135
136impl ExternalInput {
137    /// Create a new external input with the specified signal kind
138    pub fn new(value: Arc<AtomicF64>, kind: SignalKind) -> Self {
139        Self {
140            value,
141            spec: PortSpec {
142                inputs: vec![],
143                outputs: vec![PortDef::new(0, "out", kind)],
144            },
145        }
146    }
147
148    /// Create for pitch CV (V/Oct)
149    pub fn voct(value: Arc<AtomicF64>) -> Self {
150        Self::new(value, SignalKind::VoltPerOctave)
151    }
152
153    /// Create for gate signals
154    pub fn gate(value: Arc<AtomicF64>) -> Self {
155        Self::new(value, SignalKind::Gate)
156    }
157
158    /// Create for unipolar CV
159    pub fn cv(value: Arc<AtomicF64>) -> Self {
160        Self::new(value, SignalKind::CvUnipolar)
161    }
162
163    /// Create for bipolar CV
164    pub fn cv_bipolar(value: Arc<AtomicF64>) -> Self {
165        Self::new(value, SignalKind::CvBipolar)
166    }
167
168    /// Create for trigger signals
169    pub fn trigger(value: Arc<AtomicF64>) -> Self {
170        Self::new(value, SignalKind::Trigger)
171    }
172
173    /// Create for audio input
174    pub fn audio(value: Arc<AtomicF64>) -> Self {
175        Self::new(value, SignalKind::Audio)
176    }
177
178    /// Get a reference to the underlying atomic value
179    pub fn value_ref(&self) -> &Arc<AtomicF64> {
180        &self.value
181    }
182}
183
184impl GraphModule for ExternalInput {
185    fn port_spec(&self) -> &PortSpec {
186        &self.spec
187    }
188
189    fn tick(&mut self, _inputs: &PortValues, outputs: &mut PortValues) {
190        outputs.set(0, self.value.get());
191    }
192
193    fn reset(&mut self) {}
194
195    fn set_sample_rate(&mut self, _: f64) {}
196
197    fn type_id(&self) -> &'static str {
198        "external_input"
199    }
200}
201
202/// MIDI state that can be updated from a MIDI thread
203///
204/// This structure holds atomic values for common MIDI controllers.
205/// Update from a MIDI callback thread, read from the audio thread.
206#[derive(Debug)]
207pub struct MidiState {
208    /// Pitch in V/Oct (0V = C4, MIDI note 60).
209    ///
210    /// Read per-field with `Relaxed` ([`AtomicF64::get`]). This is a convenience
211    /// mirror: reading `pitch` and [`gate`](Self::gate) as two separate atomics
212    /// is **torn-capable** — across a note change a reader can observe a new gate
213    /// paired with the previous pitch. For a coherent `(pitch, gate)` pair use
214    /// [`note_snapshot`](Self::note_snapshot), which reads the packed
215    /// [`AtomicNote`] and never tears.
216    pub pitch: Arc<AtomicF64>,
217
218    /// Gate signal (0 or 5V).
219    ///
220    /// A `Relaxed` convenience mirror; see [`pitch`](Self::pitch) for why a
221    /// `(pitch, gate)` pair read from these two fields is torn-capable and why
222    /// [`note_snapshot`](Self::note_snapshot) is the coherent alternative.
223    pub gate: Arc<AtomicF64>,
224
225    /// Velocity (0-10V)
226    pub velocity: Arc<AtomicF64>,
227
228    /// Mod wheel (0-10V)
229    pub mod_wheel: Arc<AtomicF64>,
230
231    /// Pitch bend (±semitones as V/Oct)
232    pub pitch_bend: Arc<AtomicF64>,
233
234    /// Channel aftertouch (0-10V)
235    pub aftertouch: Arc<AtomicF64>,
236
237    /// Sustain pedal (0 or 5V)
238    pub sustain: Arc<AtomicF64>,
239
240    /// Expression pedal (0-10V)
241    pub expression: Arc<AtomicF64>,
242
243    /// Coherent, torn-free (pitch, gate) note event.
244    ///
245    /// Published atomically on every note-on/off so the audio thread never sees
246    /// a new gate paired with a stale pitch. Prefer [`MidiState::note_snapshot`]
247    /// over reading `pitch`/`gate` separately when a coherent pair matters.
248    pub note: Arc<AtomicNote>,
249
250    // Internal state for note handling
251    held_notes: Vec<u8>,
252}
253
254impl MidiState {
255    /// Create a new MIDI state with all values at zero
256    pub fn new() -> Self {
257        Self {
258            pitch: Arc::new(AtomicF64::new(0.0)),
259            gate: Arc::new(AtomicF64::new(0.0)),
260            velocity: Arc::new(AtomicF64::new(0.0)),
261            mod_wheel: Arc::new(AtomicF64::new(0.0)),
262            pitch_bend: Arc::new(AtomicF64::new(0.0)),
263            aftertouch: Arc::new(AtomicF64::new(0.0)),
264            sustain: Arc::new(AtomicF64::new(0.0)),
265            expression: Arc::new(AtomicF64::new(10.0)),
266            note: Arc::new(AtomicNote::new(0.0, 0.0)),
267            held_notes: Vec::new(),
268        }
269    }
270
271    /// Process a MIDI message (3-byte format)
272    ///
273    /// Call this from your MIDI callback to update the state.
274    pub fn handle_message(&mut self, msg: &[u8]) {
275        if msg.is_empty() {
276            return;
277        }
278
279        let status = msg[0] & 0xF0;
280        let _channel = msg[0] & 0x0F;
281
282        match (status, msg.len()) {
283            // Note On (with velocity > 0)
284            (0x90, 3) if msg[2] > 0 => {
285                let note = msg[1];
286                let vel = msg[2];
287                let voct = Self::note_to_voct(note);
288
289                self.held_notes.push(note);
290                // The separate `pitch`/`gate` atomics are convenience mirrors read
291                // per-field with `Relaxed` (see `AtomicF64::get`); across two words
292                // they are inherently torn-capable, so the ordering here cannot make
293                // a `(pitch, gate)` pair read from them coherent. Callers needing a
294                // torn-free pair must use `note_snapshot()` (the packed `note` word
295                // published below), which is the sole coherence guarantee.
296                self.pitch.set(voct);
297                self.velocity.set(vel as f64 / 127.0 * 10.0);
298                self.gate.set(5.0);
299                // Publish the coherent (pitch, gate) pair in a single word.
300                self.note.publish(voct, 5.0);
301            }
302
303            // Note Off (or Note On with velocity 0)
304            (0x80, 3) | (0x90, 3) => {
305                let note = msg[1];
306                self.held_notes.retain(|&n| n != note);
307
308                if self.held_notes.is_empty() {
309                    self.gate.set(0.0);
310                    // Gate closes; keep the last pitch in the coherent word.
311                    self.note.publish(self.pitch.get(), 0.0);
312                } else {
313                    // Legato: switch to last held note (gate stays high).
314                    let last = *self.held_notes.last().unwrap();
315                    let voct = Self::note_to_voct(last);
316                    self.pitch.set(voct);
317                    self.note.publish(voct, 5.0);
318                }
319            }
320
321            // Control Change
322            (0xB0, 3) => {
323                let cc = msg[1];
324                let value = msg[2];
325                let v = value as f64 / 127.0 * 10.0;
326
327                match cc {
328                    1 => self.mod_wheel.set(v),                                  // Mod wheel
329                    11 => self.expression.set(v),                                // Expression
330                    64 => self.sustain.set(if value >= 64 { 5.0 } else { 0.0 }), // Sustain
331                    _ => {}
332                }
333            }
334
335            // Pitch Bend
336            (0xE0, 3) => {
337                let lsb = msg[1] as u16;
338                let msb = msg[2] as u16;
339                let bend_raw = lsb | (msb << 7);
340                // ±2 semitones = ±2/12 V
341                let bend = (bend_raw as f64 - 8192.0) / 8192.0 * (2.0 / 12.0);
342                self.pitch_bend.set(bend);
343            }
344
345            // Channel Aftertouch
346            (0xD0, 2) => {
347                let pressure = msg[1];
348                self.aftertouch.set(pressure as f64 / 127.0 * 10.0);
349            }
350
351            // Polyphonic Aftertouch (we'll treat it as channel AT for mono)
352            (0xA0, 3) => {
353                let pressure = msg[2];
354                self.aftertouch.set(pressure as f64 / 127.0 * 10.0);
355            }
356
357            _ => {}
358        }
359    }
360
361    /// Convert MIDI note number to V/Oct
362    ///
363    /// 0V = C4 = MIDI note 60
364    fn note_to_voct(note: u8) -> f64 {
365        (note as f64 - 60.0) / 12.0
366    }
367
368    /// Get a coherent, torn-free `(pitch_voct, gate)` snapshot of the last note
369    /// event.
370    ///
371    /// Reads the packed [`AtomicNote`] with `Acquire` ordering, so the pitch and
372    /// gate always originate from the same note-on/off — never a new gate paired
373    /// with a stale pitch. Prefer this over reading `pitch`/`gate` separately on
374    /// the audio thread.
375    pub fn note_snapshot(&self) -> (f64, f64) {
376        self.note.snapshot()
377    }
378
379    /// Get all held notes
380    pub fn held_notes(&self) -> &[u8] {
381        &self.held_notes
382    }
383
384    /// Check if any notes are currently held
385    pub fn notes_active(&self) -> bool {
386        !self.held_notes.is_empty()
387    }
388
389    /// Reset all state
390    pub fn reset(&mut self) {
391        self.pitch.set(0.0);
392        self.gate.set(0.0);
393        self.velocity.set(0.0);
394        self.mod_wheel.set(0.0);
395        self.pitch_bend.set(0.0);
396        self.aftertouch.set(0.0);
397        self.sustain.set(0.0);
398        self.expression.set(10.0);
399        self.note.publish(0.0, 0.0);
400        self.held_notes.clear();
401    }
402
403    /// All notes off
404    pub fn all_notes_off(&mut self) {
405        self.held_notes.clear();
406        self.gate.set(0.0);
407        self.note.publish(self.pitch.get(), 0.0);
408    }
409}
410
411impl Default for MidiState {
412    fn default() -> Self {
413        Self::new()
414    }
415}
416
417impl Clone for MidiState {
418    fn clone(&self) -> Self {
419        Self {
420            pitch: Arc::new(AtomicF64::new(self.pitch.get())),
421            gate: Arc::new(AtomicF64::new(self.gate.get())),
422            velocity: Arc::new(AtomicF64::new(self.velocity.get())),
423            mod_wheel: Arc::new(AtomicF64::new(self.mod_wheel.get())),
424            pitch_bend: Arc::new(AtomicF64::new(self.pitch_bend.get())),
425            aftertouch: Arc::new(AtomicF64::new(self.aftertouch.get())),
426            sustain: Arc::new(AtomicF64::new(self.sustain.get())),
427            expression: Arc::new(AtomicF64::new(self.expression.get())),
428            note: Arc::new((*self.note).clone()),
429            held_notes: self.held_notes.clone(),
430        }
431    }
432}
433
434/// External output - writes to an atomic value for reading by another thread
435///
436/// Useful for sending CV values out to external systems.
437pub struct ExternalOutput {
438    value: Arc<AtomicF64>,
439    spec: PortSpec,
440}
441
442impl ExternalOutput {
443    pub fn new(value: Arc<AtomicF64>, kind: SignalKind) -> Self {
444        Self {
445            value,
446            spec: PortSpec {
447                inputs: vec![PortDef::new(0, "in", kind)],
448                outputs: vec![],
449            },
450        }
451    }
452
453    pub fn value_ref(&self) -> &Arc<AtomicF64> {
454        &self.value
455    }
456}
457
458impl GraphModule for ExternalOutput {
459    fn port_spec(&self) -> &PortSpec {
460        &self.spec
461    }
462
463    fn tick(&mut self, inputs: &PortValues, _outputs: &mut PortValues) {
464        let value = inputs.get_or(0, 0.0);
465        self.value.set(value);
466    }
467
468    fn reset(&mut self) {
469        self.value.set(0.0);
470    }
471
472    fn set_sample_rate(&mut self, _: f64) {}
473
474    fn type_id(&self) -> &'static str {
475        "external_output"
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn test_atomic_f64() {
485        let a = AtomicF64::new(3.5);
486        assert!((a.get() - 3.5).abs() < 0.001);
487
488        a.set(2.5);
489        assert!((a.get() - 2.5).abs() < 0.001);
490    }
491
492    #[test]
493    #[cfg(feature = "std")]
494    fn test_atomic_f64_thread_safe() {
495        let a = Arc::new(AtomicF64::new(0.0));
496        let a2 = Arc::clone(&a);
497
498        std::thread::spawn(move || {
499            a2.set(42.0);
500        })
501        .join()
502        .unwrap();
503
504        assert!((a.get() - 42.0).abs() < 0.001);
505    }
506
507    #[test]
508    fn test_external_input() {
509        let value = Arc::new(AtomicF64::new(5.0));
510        let mut input = ExternalInput::voct(value.clone());
511
512        let inputs = PortValues::new();
513        let mut outputs = PortValues::new();
514
515        input.tick(&inputs, &mut outputs);
516        assert!((outputs.get(0).unwrap() - 5.0).abs() < 0.001);
517
518        // Update from "external thread"
519        value.set(10.0);
520        input.tick(&inputs, &mut outputs);
521        assert!((outputs.get(0).unwrap() - 10.0).abs() < 0.001);
522    }
523
524    #[test]
525    fn test_midi_state_note_on_off() {
526        let mut midi = MidiState::new();
527
528        // Note on: C4 (note 60) with velocity 100
529        midi.handle_message(&[0x90, 60, 100]);
530        assert!((midi.pitch.get() - 0.0).abs() < 0.001); // C4 = 0V
531        assert!((midi.gate.get() - 5.0).abs() < 0.001);
532        assert!(midi.velocity.get() > 0.0);
533
534        // Note on: C5 (note 72)
535        midi.handle_message(&[0x90, 72, 100]);
536        assert!((midi.pitch.get() - 1.0).abs() < 0.001); // C5 = 1V
537
538        // Note off: C5
539        midi.handle_message(&[0x80, 72, 0]);
540        // Should return to C4 (legato)
541        assert!((midi.pitch.get() - 0.0).abs() < 0.001);
542        assert!((midi.gate.get() - 5.0).abs() < 0.001); // Still held
543
544        // Note off: C4
545        midi.handle_message(&[0x80, 60, 0]);
546        assert!((midi.gate.get() - 0.0).abs() < 0.001); // Gate off
547    }
548
549    #[test]
550    fn test_midi_state_pitch_bend() {
551        let mut midi = MidiState::new();
552
553        // Center (no bend)
554        midi.handle_message(&[0xE0, 0, 64]);
555        assert!(midi.pitch_bend.get().abs() < 0.01);
556
557        // Full up (should be ~+2 semitones = +1/6 V)
558        midi.handle_message(&[0xE0, 127, 127]);
559        assert!(midi.pitch_bend.get() > 0.1);
560
561        // Full down (should be ~-2 semitones = -1/6 V)
562        midi.handle_message(&[0xE0, 0, 0]);
563        assert!(midi.pitch_bend.get() < -0.1);
564    }
565
566    #[test]
567    fn test_midi_state_cc() {
568        let mut midi = MidiState::new();
569
570        // Mod wheel
571        midi.handle_message(&[0xB0, 1, 127]);
572        assert!((midi.mod_wheel.get() - 10.0).abs() < 0.01);
573
574        // Sustain pedal on
575        midi.handle_message(&[0xB0, 64, 127]);
576        assert!((midi.sustain.get() - 5.0).abs() < 0.01);
577
578        // Sustain pedal off
579        midi.handle_message(&[0xB0, 64, 0]);
580        assert!((midi.sustain.get() - 0.0).abs() < 0.01);
581    }
582
583    #[test]
584    fn test_external_output() {
585        let value = Arc::new(AtomicF64::new(0.0));
586        let mut output = ExternalOutput::new(value.clone(), SignalKind::CvUnipolar);
587
588        let mut inputs = PortValues::new();
589        inputs.set(0, 7.5);
590
591        output.tick(&inputs, &mut PortValues::new());
592        assert!((value.get() - 7.5).abs() < 0.001);
593    }
594
595    #[test]
596    fn test_atomic_f64_load_store() {
597        use core::sync::atomic::Ordering;
598        let a = AtomicF64::new(1.0);
599        assert!((a.load(Ordering::SeqCst) - 1.0).abs() < 0.001);
600
601        a.store(99.0, Ordering::SeqCst);
602        assert!((a.load(Ordering::SeqCst) - 99.0).abs() < 0.001);
603    }
604
605    #[test]
606    fn test_external_input_constructors() {
607        let value = Arc::new(AtomicF64::new(0.0));
608
609        let gate = ExternalInput::gate(value.clone());
610        assert!(gate.spec.outputs[0].kind == SignalKind::Gate);
611
612        let cv = ExternalInput::cv(value.clone());
613        assert!(cv.spec.outputs[0].kind == SignalKind::CvUnipolar);
614
615        let cv_bi = ExternalInput::cv_bipolar(value.clone());
616        assert!(cv_bi.spec.outputs[0].kind == SignalKind::CvBipolar);
617
618        let trigger = ExternalInput::trigger(value.clone());
619        assert!(trigger.spec.outputs[0].kind == SignalKind::Trigger);
620
621        let audio = ExternalInput::audio(value.clone());
622        assert!(audio.spec.outputs[0].kind == SignalKind::Audio);
623    }
624
625    #[test]
626    fn test_external_input_value_ref() {
627        let value = Arc::new(AtomicF64::new(42.0));
628        let input = ExternalInput::voct(value.clone());
629        assert!((input.value_ref().get() - 42.0).abs() < 0.001);
630    }
631
632    #[test]
633    fn test_external_input_reset_set_sample_rate() {
634        let value = Arc::new(AtomicF64::new(5.0));
635        let mut input = ExternalInput::voct(value.clone());
636
637        input.reset();
638        input.set_sample_rate(48000.0);
639        assert_eq!(input.type_id(), "external_input");
640    }
641
642    #[test]
643    fn test_external_output_reset_type_id() {
644        let value = Arc::new(AtomicF64::new(5.0));
645        let mut output = ExternalOutput::new(value.clone(), SignalKind::Audio);
646
647        output.reset();
648        assert!((value.get() - 0.0).abs() < 0.001);
649
650        output.set_sample_rate(48000.0);
651        assert_eq!(output.type_id(), "external_output");
652        assert!(output.value_ref().get().abs() < 0.001);
653    }
654
655    #[test]
656    fn test_midi_state_default() {
657        let midi = MidiState::default();
658        assert!(midi.pitch.get().abs() < 0.001);
659    }
660
661    #[test]
662    fn test_midi_state_clone() {
663        let mut midi = MidiState::new();
664        midi.handle_message(&[0x90, 60, 100]);
665
666        let cloned = midi.clone();
667        assert!((cloned.pitch.get() - midi.pitch.get()).abs() < 0.001);
668    }
669
670    #[test]
671    fn test_midi_state_reset() {
672        let mut midi = MidiState::new();
673        midi.handle_message(&[0x90, 60, 100]);
674        midi.handle_message(&[0xB0, 1, 127]);
675
676        midi.reset();
677        assert!(midi.pitch.get().abs() < 0.001);
678        assert!(midi.gate.get().abs() < 0.001);
679        assert!(midi.held_notes.is_empty());
680    }
681
682    #[test]
683    fn test_midi_state_all_notes_off() {
684        let mut midi = MidiState::new();
685        midi.handle_message(&[0x90, 60, 100]);
686        midi.handle_message(&[0x90, 62, 100]);
687
688        assert!(midi.notes_active());
689
690        midi.all_notes_off();
691        assert!(!midi.notes_active());
692        assert!(midi.gate.get().abs() < 0.001);
693    }
694
695    #[test]
696    fn test_midi_state_held_notes() {
697        let mut midi = MidiState::new();
698        midi.handle_message(&[0x90, 60, 100]);
699        midi.handle_message(&[0x90, 62, 100]);
700
701        assert_eq!(midi.held_notes(), &[60, 62]);
702    }
703
704    #[test]
705    fn test_midi_state_channel_aftertouch() {
706        let mut midi = MidiState::new();
707        midi.handle_message(&[0xD0, 100]);
708        assert!(midi.aftertouch.get() > 0.0);
709    }
710
711    #[test]
712    fn test_midi_state_poly_aftertouch() {
713        let mut midi = MidiState::new();
714        midi.handle_message(&[0xA0, 60, 100]);
715        assert!(midi.aftertouch.get() > 0.0);
716    }
717
718    #[test]
719    fn test_midi_state_expression() {
720        let mut midi = MidiState::new();
721        midi.handle_message(&[0xB0, 11, 100]);
722        assert!(midi.expression.get() > 0.0);
723    }
724
725    #[test]
726    fn test_midi_state_note_on_with_zero_velocity() {
727        let mut midi = MidiState::new();
728        midi.handle_message(&[0x90, 60, 100]);
729        assert!(midi.gate.get() > 0.0);
730
731        // Note on with velocity 0 = note off
732        midi.handle_message(&[0x90, 60, 0]);
733        assert!(midi.gate.get().abs() < 0.001);
734    }
735
736    // ---- Q102: coherent, torn-free note publication ----
737    #[test]
738    fn test_atomic_note_pack_roundtrip() {
739        let note = AtomicNote::new(0.0, 0.0);
740
741        note.publish(0.75, 5.0);
742        let (p, g) = note.snapshot();
743        assert!((p - 0.75).abs() < 1e-6);
744        assert!((g - 5.0).abs() < 1e-6);
745
746        note.publish(-1.25, 0.0);
747        let (p, g) = note.snapshot();
748        assert!((p + 1.25).abs() < 1e-6);
749        assert_eq!(g, 0.0);
750
751        // Default and Clone preserve the packed pair.
752        assert_eq!(AtomicNote::default().snapshot(), (0.0, 0.0));
753        assert_eq!(note.clone().snapshot(), note.snapshot());
754    }
755
756    #[test]
757    fn test_midi_state_note_snapshot_coherent() {
758        let mut midi = MidiState::new();
759
760        // Note on: C5 (note 72) -> 1V, gate 5V, published together.
761        midi.handle_message(&[0x90, 72, 100]);
762        let (pitch, gate) = midi.note_snapshot();
763        assert!((pitch - 1.0).abs() < 1e-6);
764        assert!((gate - 5.0).abs() < 1e-6);
765
766        // Note off -> gate closes, pitch retained coherently.
767        midi.handle_message(&[0x80, 72, 0]);
768        let (_pitch, gate) = midi.note_snapshot();
769        assert!(gate.abs() < 1e-6);
770    }
771
772    // Regression: `note_snapshot` is the coherent `(pitch, gate)` read across a
773    // note change (legato). The separate `pitch`/`gate` fields are Relaxed
774    // convenience mirrors and are torn-capable across two words by design; the
775    // packed `note` word is the only guaranteed-coherent pair. This asserts the
776    // snapshot always pairs the *current* pitch with the *current* gate, never a
777    // mixed pair, through a held-note switch.
778    #[test]
779    fn test_midi_state_legato_snapshot_stays_coherent() {
780        let mut midi = MidiState::new();
781
782        // Hold C4 (0V), then legato to C5 (1V) without releasing C4.
783        midi.handle_message(&[0x90, 60, 100]);
784        let (p, g) = midi.note_snapshot();
785        assert!((p - 0.0).abs() < 1e-6 && (g - 5.0).abs() < 1e-6);
786
787        midi.handle_message(&[0x90, 72, 100]);
788        let (p, g) = midi.note_snapshot();
789        assert!(
790            (p - 1.0).abs() < 1e-6 && (g - 5.0).abs() < 1e-6,
791            "legato pitch change must pair the new pitch with a held gate, got ({p}, {g})"
792        );
793
794        // Release the newer note: gate stays high, pitch falls back to C4 (still
795        // held) as a coherent pair.
796        midi.handle_message(&[0x80, 72, 0]);
797        let (p, g) = midi.note_snapshot();
798        assert!(
799            (p - 0.0).abs() < 1e-6 && (g - 5.0).abs() < 1e-6,
800            "after releasing the top note the held note's pitch pairs with gate 5V, got ({p}, {g})"
801        );
802
803        // Release the last held note: gate closes coherently.
804        midi.handle_message(&[0x80, 60, 0]);
805        let (_p, g) = midi.note_snapshot();
806        assert!(g.abs() < 1e-6, "last note off closes the gate");
807    }
808
809    #[test]
810    #[cfg(feature = "std")]
811    fn test_atomic_note_no_tearing_across_threads() {
812        let note = Arc::new(AtomicNote::new(0.0, 0.0));
813        let writer_note = Arc::clone(&note);
814
815        // Writer alternates between two coherent (pitch, gate) states.
816        let writer = std::thread::spawn(move || {
817            for i in 0..200_000u32 {
818                if i % 2 == 0 {
819                    writer_note.publish(1.0, 5.0);
820                } else {
821                    writer_note.publish(0.0, 0.0);
822                }
823            }
824        });
825
826        // A single AtomicU64 can never expose a mixed pair.
827        for _ in 0..200_000 {
828            let (p, g) = note.snapshot();
829            let coherent = ((p - 1.0).abs() < 1e-6 && (g - 5.0).abs() < 1e-6)
830                || (p.abs() < 1e-6 && g.abs() < 1e-6);
831            assert!(coherent, "torn note snapshot observed: ({p}, {g})");
832        }
833
834        writer.join().unwrap();
835    }
836}