aether_nodes/scope.rs
1//! ScopeNode — feeds audio into a lock-free SPSC ring buffer for visualisation.
2//!
3//! The ring is sized to 512 samples (8 × 64-sample frames).
4//! Passes input through to output unchanged (unity gain).
5//! If the ring is full, samples are dropped silently.
6//! No allocation, no locks in the hot path.
7
8use aether_core::{node::DspNode, param::ParamBlock, BUFFER_SIZE, MAX_INPUTS};
9use ringbuf::{traits::Producer, HeapProd};
10
11pub struct ScopeNode {
12 producer: HeapProd<f32>,
13}
14
15impl ScopeNode {
16 pub fn new(producer: HeapProd<f32>) -> Self {
17 Self { producer }
18 }
19}
20
21impl DspNode for ScopeNode {
22 fn process(
23 &mut self,
24 inputs: &[Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS],
25 output: &mut [f32; BUFFER_SIZE],
26 _params: &mut ParamBlock,
27 _sample_rate: f32,
28 ) {
29 let silence = [0.0f32; BUFFER_SIZE];
30 let input = inputs[0].unwrap_or(&silence);
31
32 // Attempt to push; silently drop if ring is full.
33 let _ = self.producer.push_slice(input);
34
35 // Pass-through: copy input to output.
36 output.copy_from_slice(input);
37 }
38
39 fn type_name(&self) -> &'static str {
40 "ScopeNode"
41 }
42}
43
44#[cfg(test)]
45mod tests {
46 use super::*;
47 use proptest::prelude::*;
48 use ringbuf::HeapRb;
49
50 // Property 9
51 proptest! {
52 /// **Validates: Requirements 4.4**
53 ///
54 /// Feature: aether-engine-upgrades, Property 9: ScopeNode pass-through
55 ///
56 /// Property 9: ScopeNode pass-through.
57 ///
58 /// For any input buffer passed to `ScopeNode::process()`, the output buffer
59 /// SHALL be identical to the input buffer (unity gain pass-through).
60 #[test]
61 fn prop_scope_node_pass_through(
62 input_samples in prop::array::uniform64(-1.0f32..=1.0f32),
63 ) {
64 // Create a ring buffer with sufficient capacity
65 let ring = HeapRb::<f32>::new(BUFFER_SIZE * 2);
66 let (producer, _consumer) = ring.split();
67
68 // Create ScopeNode
69 let mut node = ScopeNode::new(producer);
70
71 // Prepare input and output buffers
72 let input_buffer: [f32; BUFFER_SIZE] = input_samples;
73 let mut output_buffer = [0.0f32; BUFFER_SIZE];
74 let mut params = ParamBlock::default();
75
76 // Create inputs array with our test input
77 let inputs: [Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS] = {
78 let mut arr: [Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS] = [None; MAX_INPUTS];
79 arr[0] = Some(&input_buffer);
80 arr
81 };
82
83 // Process
84 node.process(&inputs, &mut output_buffer, &mut params, 48000.0);
85
86 // Assert: output buffer equals input buffer exactly
87 prop_assert_eq!(output_buffer, input_buffer);
88 }
89 }
90
91 // Property 10
92 proptest! {
93 /// **Validates: Requirements 4.7**
94 ///
95 /// Feature: aether-engine-upgrades, Property 10: Scope frame serialization
96 ///
97 /// Property 10: Scope frame serialization.
98 ///
99 /// For any array of 64 f32 samples serialized as a binary scope frame,
100 /// the resulting byte slice SHALL be exactly 256 bytes, and decoding it
101 /// as [f32; 64] little-endian SHALL recover the original values exactly.
102 #[test]
103 fn prop_scope_frame_serialization(
104 samples in prop::array::uniform64(-1.0f32..=1.0f32),
105 ) {
106 // Serialize: 64 f32 values to 256 bytes (64 × 4 bytes each)
107 let mut serialized = Vec::with_capacity(256);
108 for &sample in &samples {
109 serialized.extend_from_slice(&sample.to_le_bytes());
110 }
111
112 // Assert: byte length is exactly 256
113 prop_assert_eq!(serialized.len(), 256);
114
115 // Deserialize: 256 bytes back to [f32; 64]
116 let mut deserialized = [0.0f32; 64];
117 for (i, chunk) in serialized.chunks_exact(4).enumerate() {
118 let bytes: [u8; 4] = chunk.try_into().unwrap();
119 deserialized[i] = f32::from_le_bytes(bytes);
120 }
121
122 // Assert: round-trip equality
123 prop_assert_eq!(deserialized, samples);
124 }
125 }
126}