Skip to main content

wavecraft_processors/
oscilloscope.rs

1//! Oscilloscope tap processor and lock-free frame transport.
2//!
3//! The oscilloscope tap is observation-only: it never modifies audio samples.
4
5use wavecraft_dsp::{Processor, Transport};
6use wavecraft_protocol::{OscilloscopeFrame, OscilloscopeTriggerMode};
7
8/// Number of points per oscilloscope frame.
9pub const OSCILLOSCOPE_FRAME_POINTS: usize = 1024;
10const OSCILLOSCOPE_HISTORY_FRAMES: usize = 3;
11const OSCILLOSCOPE_HISTORY_POINTS: usize = OSCILLOSCOPE_FRAME_POINTS * OSCILLOSCOPE_HISTORY_FRAMES;
12const OSCILLOSCOPE_HISTORY_TAIL_START: usize =
13    OSCILLOSCOPE_FRAME_POINTS * (OSCILLOSCOPE_HISTORY_FRAMES - 1);
14const DEFAULT_NO_SIGNAL_THRESHOLD: f32 = 1e-4;
15
16/// Internal snapshot format with fixed-size arrays (no heap allocations).
17#[derive(Clone)]
18pub struct OscilloscopeFrameSnapshot {
19    pub points_l: [f32; OSCILLOSCOPE_FRAME_POINTS],
20    pub points_r: [f32; OSCILLOSCOPE_FRAME_POINTS],
21    pub sample_rate: f32,
22    pub timestamp: u64,
23    pub no_signal: bool,
24    pub trigger_mode: OscilloscopeTriggerMode,
25}
26
27impl OscilloscopeFrameSnapshot {
28    /// Convert fixed-size snapshot into IPC frame payload.
29    pub fn to_protocol_frame(&self) -> OscilloscopeFrame {
30        OscilloscopeFrame {
31            points_l: self.points_l.to_vec(),
32            points_r: self.points_r.to_vec(),
33            sample_rate: self.sample_rate,
34            timestamp: self.timestamp,
35            no_signal: self.no_signal,
36            trigger_mode: self.trigger_mode,
37        }
38    }
39}
40
41/// Producer side of oscilloscope frame channel.
42pub struct OscilloscopeFrameProducer {
43    producer: rtrb::Producer<OscilloscopeFrameSnapshot>,
44}
45
46impl OscilloscopeFrameProducer {
47    /// Push the latest frame. If the channel is full, the frame is dropped.
48    pub fn push(&mut self, frame: OscilloscopeFrameSnapshot) {
49        let _ = self.producer.push(frame);
50    }
51}
52
53/// Consumer side of oscilloscope frame channel.
54pub struct OscilloscopeFrameConsumer {
55    consumer: rtrb::Consumer<OscilloscopeFrameSnapshot>,
56}
57
58impl OscilloscopeFrameConsumer {
59    /// Read and return the most recent available frame.
60    pub fn read_latest(&mut self) -> Option<OscilloscopeFrameSnapshot> {
61        let mut latest = None;
62        while let Ok(frame) = self.consumer.pop() {
63            latest = Some(frame);
64        }
65        latest
66    }
67}
68
69/// Create a lock-free oscilloscope frame channel.
70pub fn create_oscilloscope_channel(
71    capacity: usize,
72) -> (OscilloscopeFrameProducer, OscilloscopeFrameConsumer) {
73    let (producer, consumer) = rtrb::RingBuffer::new(capacity);
74    (
75        OscilloscopeFrameProducer { producer },
76        OscilloscopeFrameConsumer { consumer },
77    )
78}
79
80/// Observation-only oscilloscope tap processor.
81pub struct OscilloscopeTap {
82    sample_rate: f32,
83    frame_l: [f32; OSCILLOSCOPE_FRAME_POINTS],
84    frame_r: [f32; OSCILLOSCOPE_FRAME_POINTS],
85    history_l: [f32; OSCILLOSCOPE_HISTORY_POINTS],
86    history_r: [f32; OSCILLOSCOPE_HISTORY_POINTS],
87    aligned_l: [f32; OSCILLOSCOPE_FRAME_POINTS],
88    aligned_r: [f32; OSCILLOSCOPE_FRAME_POINTS],
89    history_frames_filled: usize,
90    timestamp: u64,
91    no_signal_threshold: f32,
92    output: Option<OscilloscopeFrameProducer>,
93}
94
95impl Default for OscilloscopeTap {
96    fn default() -> Self {
97        Self {
98            sample_rate: 44_100.0,
99            frame_l: [0.0; OSCILLOSCOPE_FRAME_POINTS],
100            frame_r: [0.0; OSCILLOSCOPE_FRAME_POINTS],
101            history_l: [0.0; OSCILLOSCOPE_HISTORY_POINTS],
102            history_r: [0.0; OSCILLOSCOPE_HISTORY_POINTS],
103            aligned_l: [0.0; OSCILLOSCOPE_FRAME_POINTS],
104            aligned_r: [0.0; OSCILLOSCOPE_FRAME_POINTS],
105            history_frames_filled: 0,
106            timestamp: 0,
107            no_signal_threshold: DEFAULT_NO_SIGNAL_THRESHOLD,
108            output: None,
109        }
110    }
111}
112
113impl OscilloscopeTap {
114    /// Create a new oscilloscope tap without an output channel.
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    /// Create a new oscilloscope tap with frame output channel.
120    pub fn with_output(output: OscilloscopeFrameProducer) -> Self {
121        Self {
122            output: Some(output),
123            ..Self::default()
124        }
125    }
126
127    /// Attach or replace the output channel.
128    pub fn set_output(&mut self, output: OscilloscopeFrameProducer) {
129        self.output = Some(output);
130    }
131
132    /// Set sample rate used in frame metadata.
133    pub fn set_sample_rate_hz(&mut self, sample_rate: f32) {
134        self.sample_rate = sample_rate;
135    }
136
137    /// Capture and publish a frame from stereo slices.
138    pub fn capture_stereo(&mut self, left: &[f32], right: &[f32]) {
139        if left.is_empty() {
140            return;
141        }
142
143        let right = if right.is_empty() { left } else { right };
144
145        // Downsample or upsample source block into fixed 1024-point frame.
146        let left_len = left.len();
147        let right_len = right.len();
148        let mut max_abs = 0.0_f32;
149
150        for index in 0..OSCILLOSCOPE_FRAME_POINTS {
151            let source_l = index * left_len / OSCILLOSCOPE_FRAME_POINTS;
152            let source_r = index * right_len / OSCILLOSCOPE_FRAME_POINTS;
153
154            let l = left[source_l.min(left_len - 1)];
155            let r = right[source_r.min(right_len - 1)];
156
157            self.frame_l[index] = l;
158            self.frame_r[index] = r;
159            max_abs = max_abs.max(l.abs()).max(r.abs());
160        }
161
162        let no_signal = max_abs < self.no_signal_threshold;
163
164        // Keep a rolling three-frame history so the trigger-aligned frame can
165        // always be extracted as a contiguous 1024-sample window without
166        // wrapping or synthetic tail padding.
167        self.history_l
168            .copy_within(OSCILLOSCOPE_FRAME_POINTS..OSCILLOSCOPE_HISTORY_POINTS, 0);
169        self.history_r
170            .copy_within(OSCILLOSCOPE_FRAME_POINTS..OSCILLOSCOPE_HISTORY_POINTS, 0);
171        self.history_l[OSCILLOSCOPE_HISTORY_TAIL_START..].copy_from_slice(&self.frame_l);
172        self.history_r[OSCILLOSCOPE_HISTORY_TAIL_START..].copy_from_slice(&self.frame_r);
173
174        self.history_frames_filled =
175            (self.history_frames_filled + 1).min(OSCILLOSCOPE_HISTORY_FRAMES);
176
177        let min_trigger_start = match self.history_frames_filled {
178            0 | 1 => None,
179            // Avoid index 1024 during startup while oldest history is still zero-filled.
180            2 => Some(OSCILLOSCOPE_FRAME_POINTS + 1),
181            _ => Some(1),
182        };
183
184        let trigger_start = if no_signal {
185            OSCILLOSCOPE_HISTORY_TAIL_START
186        } else if let Some(min_start) = min_trigger_start {
187            self.find_rising_zero_crossing_in_history(min_start)
188                .unwrap_or(OSCILLOSCOPE_HISTORY_TAIL_START)
189        } else {
190            OSCILLOSCOPE_HISTORY_TAIL_START
191        };
192
193        let end = trigger_start + OSCILLOSCOPE_FRAME_POINTS;
194        self.aligned_l
195            .copy_from_slice(&self.history_l[trigger_start..end]);
196        self.aligned_r
197            .copy_from_slice(&self.history_r[trigger_start..end]);
198
199        let frame = OscilloscopeFrameSnapshot {
200            points_l: self.aligned_l,
201            points_r: self.aligned_r,
202            sample_rate: self.sample_rate,
203            timestamp: self.timestamp,
204            no_signal,
205            trigger_mode: OscilloscopeTriggerMode::RisingZeroCrossing,
206        };
207
208        self.timestamp = self.timestamp.wrapping_add(1);
209
210        if let Some(output) = self.output.as_mut() {
211            output.push(frame);
212        }
213    }
214
215    fn find_rising_zero_crossing_in_history(&self, min_start: usize) -> Option<usize> {
216        // Search only starts that can provide a full 1024-point window.
217        // With 3-frame history this allows deterministic trigger lock even
218        // when low frequencies do not provide a crossing in the oldest frame.
219        let max_start = OSCILLOSCOPE_HISTORY_POINTS - OSCILLOSCOPE_FRAME_POINTS;
220        let preferred_start = (min_start + max_start) / 2;
221        let mut best_index: Option<usize> = None;
222        let mut best_distance = usize::MAX;
223
224        for index in min_start..=max_start {
225            let prev = self.history_l[index - 1];
226            let current = self.history_l[index];
227            if prev <= 0.0 && current > 0.0 {
228                let distance = index.abs_diff(preferred_start);
229                let prefer_candidate = distance < best_distance
230                    || (distance == best_distance
231                        && best_index.is_none_or(|existing| index > existing));
232                if prefer_candidate {
233                    best_index = Some(index);
234                    best_distance = distance;
235                }
236            }
237        }
238
239        best_index
240    }
241}
242
243impl Processor for OscilloscopeTap {
244    type Params = ();
245
246    fn set_sample_rate(&mut self, sample_rate: f32) {
247        self.set_sample_rate_hz(sample_rate);
248    }
249
250    fn process(
251        &mut self,
252        buffer: &mut [&mut [f32]],
253        _transport: &Transport,
254        _params: &Self::Params,
255    ) {
256        if buffer.is_empty() {
257            return;
258        }
259
260        let left = &*buffer[0];
261        let right = if buffer.len() > 1 { &*buffer[1] } else { left };
262
263        // Observation-only capture. Audio data is never modified.
264        self.capture_stereo(left, right);
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn passthrough_invariance() {
274        let mut tap = OscilloscopeTap::new();
275
276        let mut left = [0.25_f32, -0.1, 0.4, -0.3];
277        let mut right = [-0.2_f32, 0.5, -0.4, 0.1];
278        let expected_left = left;
279        let expected_right = right;
280        let mut buffer = [&mut left[..], &mut right[..]];
281
282        tap.process(&mut buffer, &Transport::default(), &());
283
284        assert_eq!(left, expected_left);
285        assert_eq!(right, expected_right);
286    }
287
288    #[test]
289    fn frame_length_is_1024() {
290        let (producer, mut consumer) = create_oscilloscope_channel(8);
291        let mut tap = OscilloscopeTap::with_output(producer);
292
293        let left = [0.5_f32; 64];
294        let right = [0.25_f32; 64];
295        tap.capture_stereo(&left, &right);
296
297        let frame = consumer.read_latest().expect("frame should exist");
298        assert_eq!(frame.points_l.len(), OSCILLOSCOPE_FRAME_POINTS);
299        assert_eq!(frame.points_r.len(), OSCILLOSCOPE_FRAME_POINTS);
300    }
301
302    #[test]
303    fn trigger_alignment_rising_zero_crossing() {
304        let (producer, mut consumer) = create_oscilloscope_channel(8);
305        let mut tap = OscilloscopeTap::with_output(producer);
306
307        let mut left = [0.0_f32; 128];
308        let mut right = [0.0_f32; 128];
309
310        for i in 0..128 {
311            let phase = (i as f32 / 128.0) * std::f32::consts::TAU;
312            left[i] = phase.sin();
313            right[i] = left[i];
314        }
315
316        tap.capture_stereo(&left, &right);
317
318        let frame = consumer.read_latest().expect("frame should exist");
319        let first = frame.points_l[0];
320        let second = frame.points_l[1];
321
322        assert!(first <= 0.05, "expected start near zero, got {first}");
323        assert!(second >= first, "expected rising edge at frame start");
324    }
325
326    #[test]
327    fn trigger_alignment_uses_contiguous_window_across_frame_boundary() {
328        let (producer, mut consumer) = create_oscilloscope_channel(8);
329        let mut tap = OscilloscopeTap::with_output(producer);
330
331        let mut left_prev = [0.5_f32; OSCILLOSCOPE_FRAME_POINTS];
332        let mut right_prev = [0.25_f32; OSCILLOSCOPE_FRAME_POINTS];
333        left_prev[1000] = -0.1;
334        left_prev[1001] = 0.1;
335        right_prev[1000] = -0.3;
336        right_prev[1001] = -0.2;
337
338        let mut left_curr = [0.0_f32; OSCILLOSCOPE_FRAME_POINTS];
339        let mut right_curr = [0.0_f32; OSCILLOSCOPE_FRAME_POINTS];
340        for index in 0..OSCILLOSCOPE_FRAME_POINTS {
341            left_curr[index] = -1.0 + (2.0 * index as f32 / OSCILLOSCOPE_FRAME_POINTS as f32);
342            right_curr[index] = 1.0 - (2.0 * index as f32 / OSCILLOSCOPE_FRAME_POINTS as f32);
343        }
344
345        tap.capture_stereo(&left_prev, &right_prev);
346        let _first = consumer.read_latest().expect("first frame should exist");
347
348        tap.capture_stereo(&left_curr, &right_curr);
349        let second = consumer.read_latest().expect("second frame should exist");
350
351        // Frame starts at the trigger crossing in the previous frame.
352        assert!((second.points_l[0] - left_prev[1001]).abs() < f32::EPSILON);
353
354        // The window remains contiguous through the boundary into current data.
355        assert!((second.points_l[23] - left_curr[0]).abs() < f32::EPSILON);
356        assert!((second.points_r[23] - right_curr[0]).abs() < f32::EPSILON);
357
358        // Right edge is true continuation, not a padded flat tail.
359        assert!((second.points_l[1023] - left_curr[1000]).abs() < f32::EPSILON);
360        assert!((second.points_r[1023] - right_curr[1000]).abs() < f32::EPSILON);
361    }
362
363    #[test]
364    fn trigger_alignment_finds_low_frequency_crossings_beyond_oldest_frame() {
365        let (producer, mut consumer) = create_oscilloscope_channel(8);
366        let mut tap = OscilloscopeTap::with_output(producer);
367
368        let mut left_prev = [-0.5_f32; OSCILLOSCOPE_FRAME_POINTS];
369        let mut right_prev = [-0.25_f32; OSCILLOSCOPE_FRAME_POINTS];
370        left_prev[200] = 0.5;
371        right_prev[200] = 0.25;
372
373        let left_curr = [-0.5_f32; OSCILLOSCOPE_FRAME_POINTS];
374        let right_curr = [-0.25_f32; OSCILLOSCOPE_FRAME_POINTS];
375
376        tap.capture_stereo(&left_prev, &right_prev);
377        let _first = consumer.read_latest().expect("first frame should exist");
378
379        tap.capture_stereo(&left_curr, &right_curr);
380        let second = consumer.read_latest().expect("second frame should exist");
381
382        // Crossing in the middle history frame at index 1024 + 200 = 1224
383        // should be selected. The previous two-frame implementation searched
384        // only up to index 1024 and would miss this crossing.
385        assert!(
386            (second.points_l[0] - left_prev[200]).abs() < f32::EPSILON,
387            "expected left start {}, got {}",
388            left_prev[200],
389            second.points_l[0]
390        );
391        assert!(
392            (second.points_r[0] - right_prev[200]).abs() < f32::EPSILON,
393            "expected right start {}, got {}",
394            right_prev[200],
395            second.points_r[0]
396        );
397
398        // Window remains contiguous into current frame data.
399        assert!((second.points_l[824] - left_curr[0]).abs() < f32::EPSILON);
400        assert!((second.points_r[824] - right_curr[0]).abs() < f32::EPSILON);
401    }
402
403    #[test]
404    fn no_signal_detection() {
405        let (producer, mut consumer) = create_oscilloscope_channel(8);
406        let mut tap = OscilloscopeTap::with_output(producer);
407
408        let left = [1e-6_f32; 128];
409        let right = [1e-6_f32; 128];
410        tap.capture_stereo(&left, &right);
411
412        let frame = consumer.read_latest().expect("frame should exist");
413        assert!(frame.no_signal);
414    }
415
416    #[test]
417    fn stereo_capture_integrity() {
418        let (producer, mut consumer) = create_oscilloscope_channel(8);
419        let mut tap = OscilloscopeTap::with_output(producer);
420
421        let left = [0.75_f32; 128];
422        let right = [-0.25_f32; 128];
423        tap.capture_stereo(&left, &right);
424
425        let frame = consumer.read_latest().expect("frame should exist");
426        assert!(
427            frame
428                .points_l
429                .iter()
430                .all(|v| (*v - 0.75).abs() < f32::EPSILON)
431        );
432        assert!(
433            frame
434                .points_r
435                .iter()
436                .all(|v| (*v + 0.25).abs() < f32::EPSILON)
437        );
438    }
439}