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        let max_abs = self.capture_frame_samples(left, right);
145        let no_signal = max_abs < self.no_signal_threshold;
146        self.update_history();
147        let trigger_start = self.resolve_trigger_start(no_signal);
148        self.copy_aligned_window(trigger_start);
149        self.publish_frame(no_signal);
150    }
151
152    fn capture_frame_samples(&mut self, left: &[f32], right: &[f32]) -> f32 {
153        // Downsample or upsample source block into fixed 1024-point frame.
154        let left_len = left.len();
155        let right_len = right.len();
156        let mut max_abs = 0.0_f32;
157
158        for index in 0..OSCILLOSCOPE_FRAME_POINTS {
159            let source_l = index * left_len / OSCILLOSCOPE_FRAME_POINTS;
160            let source_r = index * right_len / OSCILLOSCOPE_FRAME_POINTS;
161
162            let l = left[source_l.min(left_len - 1)];
163            let r = right[source_r.min(right_len - 1)];
164
165            self.frame_l[index] = l;
166            self.frame_r[index] = r;
167            max_abs = max_abs.max(l.abs()).max(r.abs());
168        }
169
170        max_abs
171    }
172
173    fn update_history(&mut self) {
174        // Keep a rolling three-frame history so the trigger-aligned frame can
175        // always be extracted as a contiguous 1024-sample window without
176        // wrapping or synthetic tail padding.
177        self.history_l
178            .copy_within(OSCILLOSCOPE_FRAME_POINTS..OSCILLOSCOPE_HISTORY_POINTS, 0);
179        self.history_r
180            .copy_within(OSCILLOSCOPE_FRAME_POINTS..OSCILLOSCOPE_HISTORY_POINTS, 0);
181        self.history_l[OSCILLOSCOPE_HISTORY_TAIL_START..].copy_from_slice(&self.frame_l);
182        self.history_r[OSCILLOSCOPE_HISTORY_TAIL_START..].copy_from_slice(&self.frame_r);
183
184        self.history_frames_filled =
185            (self.history_frames_filled + 1).min(OSCILLOSCOPE_HISTORY_FRAMES);
186    }
187
188    fn minimum_trigger_start(&self) -> Option<usize> {
189        match self.history_frames_filled {
190            0 | 1 => None,
191            // Avoid index 1024 during startup while oldest history is still zero-filled.
192            2 => Some(OSCILLOSCOPE_FRAME_POINTS + 1),
193            _ => Some(1),
194        }
195    }
196
197    fn resolve_trigger_start(&self, no_signal: bool) -> usize {
198        if no_signal {
199            return OSCILLOSCOPE_HISTORY_TAIL_START;
200        }
201
202        if let Some(min_start) = self.minimum_trigger_start() {
203            self.find_rising_zero_crossing_in_history(min_start)
204                .unwrap_or(OSCILLOSCOPE_HISTORY_TAIL_START)
205        } else {
206            OSCILLOSCOPE_HISTORY_TAIL_START
207        }
208    }
209
210    fn copy_aligned_window(&mut self, trigger_start: usize) {
211        let end = trigger_start + OSCILLOSCOPE_FRAME_POINTS;
212        self.aligned_l
213            .copy_from_slice(&self.history_l[trigger_start..end]);
214        self.aligned_r
215            .copy_from_slice(&self.history_r[trigger_start..end]);
216    }
217
218    fn publish_frame(&mut self, no_signal: bool) {
219        let frame = OscilloscopeFrameSnapshot {
220            points_l: self.aligned_l,
221            points_r: self.aligned_r,
222            sample_rate: self.sample_rate,
223            timestamp: self.timestamp,
224            no_signal,
225            trigger_mode: OscilloscopeTriggerMode::RisingZeroCrossing,
226        };
227
228        self.timestamp = self.timestamp.wrapping_add(1);
229
230        if let Some(output) = self.output.as_mut() {
231            output.push(frame);
232        }
233    }
234
235    fn find_rising_zero_crossing_in_history(&self, min_start: usize) -> Option<usize> {
236        // Search only starts that can provide a full 1024-point window.
237        // With 3-frame history this allows deterministic trigger lock even
238        // when low frequencies do not provide a crossing in the oldest frame.
239        let max_start = OSCILLOSCOPE_HISTORY_POINTS - OSCILLOSCOPE_FRAME_POINTS;
240        let preferred_start = (min_start + max_start) / 2;
241        let mut best_index: Option<usize> = None;
242        let mut best_distance = usize::MAX;
243
244        for index in min_start..=max_start {
245            let prev = self.history_l[index - 1];
246            let current = self.history_l[index];
247            if prev <= 0.0 && current > 0.0 {
248                let distance = index.abs_diff(preferred_start);
249                let prefer_candidate = distance < best_distance
250                    || (distance == best_distance
251                        && best_index.is_none_or(|existing| index > existing));
252                if prefer_candidate {
253                    best_index = Some(index);
254                    best_distance = distance;
255                }
256            }
257        }
258
259        best_index
260    }
261}
262
263impl Processor for OscilloscopeTap {
264    type Params = ();
265
266    fn set_sample_rate(&mut self, sample_rate: f32) {
267        self.set_sample_rate_hz(sample_rate);
268    }
269
270    fn process(
271        &mut self,
272        buffer: &mut [&mut [f32]],
273        _transport: &Transport,
274        _params: &Self::Params,
275    ) {
276        if buffer.is_empty() {
277            return;
278        }
279
280        let left = &*buffer[0];
281        let right = if buffer.len() > 1 { &*buffer[1] } else { left };
282
283        // Observation-only capture. Audio data is never modified.
284        self.capture_stereo(left, right);
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn passthrough_invariance() {
294        let mut tap = OscilloscopeTap::new();
295
296        let mut left = [0.25_f32, -0.1, 0.4, -0.3];
297        let mut right = [-0.2_f32, 0.5, -0.4, 0.1];
298        let expected_left = left;
299        let expected_right = right;
300        let mut buffer = [&mut left[..], &mut right[..]];
301
302        tap.process(&mut buffer, &Transport::default(), &());
303
304        assert_eq!(left, expected_left);
305        assert_eq!(right, expected_right);
306    }
307
308    #[test]
309    fn frame_length_is_1024() {
310        let (producer, mut consumer) = create_oscilloscope_channel(8);
311        let mut tap = OscilloscopeTap::with_output(producer);
312
313        let left = [0.5_f32; 64];
314        let right = [0.25_f32; 64];
315        tap.capture_stereo(&left, &right);
316
317        let frame = consumer.read_latest().expect("frame should exist");
318        assert_eq!(frame.points_l.len(), OSCILLOSCOPE_FRAME_POINTS);
319        assert_eq!(frame.points_r.len(), OSCILLOSCOPE_FRAME_POINTS);
320    }
321
322    #[test]
323    fn trigger_alignment_rising_zero_crossing() {
324        let (producer, mut consumer) = create_oscilloscope_channel(8);
325        let mut tap = OscilloscopeTap::with_output(producer);
326
327        let mut left = [0.0_f32; 128];
328        let mut right = [0.0_f32; 128];
329
330        for i in 0..128 {
331            let phase = (i as f32 / 128.0) * std::f32::consts::TAU;
332            left[i] = phase.sin();
333            right[i] = left[i];
334        }
335
336        tap.capture_stereo(&left, &right);
337
338        let frame = consumer.read_latest().expect("frame should exist");
339        let first = frame.points_l[0];
340        let second = frame.points_l[1];
341
342        assert!(first <= 0.05, "expected start near zero, got {first}");
343        assert!(second >= first, "expected rising edge at frame start");
344    }
345
346    #[test]
347    fn trigger_alignment_uses_contiguous_window_across_frame_boundary() {
348        let (producer, mut consumer) = create_oscilloscope_channel(8);
349        let mut tap = OscilloscopeTap::with_output(producer);
350
351        let mut left_prev = [0.5_f32; OSCILLOSCOPE_FRAME_POINTS];
352        let mut right_prev = [0.25_f32; OSCILLOSCOPE_FRAME_POINTS];
353        left_prev[1000] = -0.1;
354        left_prev[1001] = 0.1;
355        right_prev[1000] = -0.3;
356        right_prev[1001] = -0.2;
357
358        let mut left_curr = [0.0_f32; OSCILLOSCOPE_FRAME_POINTS];
359        let mut right_curr = [0.0_f32; OSCILLOSCOPE_FRAME_POINTS];
360        for index in 0..OSCILLOSCOPE_FRAME_POINTS {
361            left_curr[index] = -1.0 + (2.0 * index as f32 / OSCILLOSCOPE_FRAME_POINTS as f32);
362            right_curr[index] = 1.0 - (2.0 * index as f32 / OSCILLOSCOPE_FRAME_POINTS as f32);
363        }
364
365        tap.capture_stereo(&left_prev, &right_prev);
366        let _first = consumer.read_latest().expect("first frame should exist");
367
368        tap.capture_stereo(&left_curr, &right_curr);
369        let second = consumer.read_latest().expect("second frame should exist");
370
371        // Frame starts at the trigger crossing in the previous frame.
372        assert!((second.points_l[0] - left_prev[1001]).abs() < f32::EPSILON);
373
374        // The window remains contiguous through the boundary into current data.
375        assert!((second.points_l[23] - left_curr[0]).abs() < f32::EPSILON);
376        assert!((second.points_r[23] - right_curr[0]).abs() < f32::EPSILON);
377
378        // Right edge is true continuation, not a padded flat tail.
379        assert!((second.points_l[1023] - left_curr[1000]).abs() < f32::EPSILON);
380        assert!((second.points_r[1023] - right_curr[1000]).abs() < f32::EPSILON);
381    }
382
383    #[test]
384    fn trigger_alignment_finds_low_frequency_crossings_beyond_oldest_frame() {
385        let (producer, mut consumer) = create_oscilloscope_channel(8);
386        let mut tap = OscilloscopeTap::with_output(producer);
387
388        let mut left_prev = [-0.5_f32; OSCILLOSCOPE_FRAME_POINTS];
389        let mut right_prev = [-0.25_f32; OSCILLOSCOPE_FRAME_POINTS];
390        left_prev[200] = 0.5;
391        right_prev[200] = 0.25;
392
393        let left_curr = [-0.5_f32; OSCILLOSCOPE_FRAME_POINTS];
394        let right_curr = [-0.25_f32; OSCILLOSCOPE_FRAME_POINTS];
395
396        tap.capture_stereo(&left_prev, &right_prev);
397        let _first = consumer.read_latest().expect("first frame should exist");
398
399        tap.capture_stereo(&left_curr, &right_curr);
400        let second = consumer.read_latest().expect("second frame should exist");
401
402        // Crossing in the middle history frame at index 1024 + 200 = 1224
403        // should be selected. The previous two-frame implementation searched
404        // only up to index 1024 and would miss this crossing.
405        assert!(
406            (second.points_l[0] - left_prev[200]).abs() < f32::EPSILON,
407            "expected left start {}, got {}",
408            left_prev[200],
409            second.points_l[0]
410        );
411        assert!(
412            (second.points_r[0] - right_prev[200]).abs() < f32::EPSILON,
413            "expected right start {}, got {}",
414            right_prev[200],
415            second.points_r[0]
416        );
417
418        // Window remains contiguous into current frame data.
419        assert!((second.points_l[824] - left_curr[0]).abs() < f32::EPSILON);
420        assert!((second.points_r[824] - right_curr[0]).abs() < f32::EPSILON);
421    }
422
423    #[test]
424    fn no_signal_detection() {
425        let (producer, mut consumer) = create_oscilloscope_channel(8);
426        let mut tap = OscilloscopeTap::with_output(producer);
427
428        let left = [1e-6_f32; 128];
429        let right = [1e-6_f32; 128];
430        tap.capture_stereo(&left, &right);
431
432        let frame = consumer.read_latest().expect("frame should exist");
433        assert!(frame.no_signal);
434    }
435
436    #[test]
437    fn stereo_capture_integrity() {
438        let (producer, mut consumer) = create_oscilloscope_channel(8);
439        let mut tap = OscilloscopeTap::with_output(producer);
440
441        let left = [0.75_f32; 128];
442        let right = [-0.25_f32; 128];
443        tap.capture_stereo(&left, &right);
444
445        let frame = consumer.read_latest().expect("frame should exist");
446        assert!(
447            frame
448                .points_l
449                .iter()
450                .all(|v| (*v - 0.75).abs() < f32::EPSILON)
451        );
452        assert!(
453            frame
454                .points_r
455                .iter()
456                .all(|v| (*v + 0.25).abs() < f32::EPSILON)
457        );
458    }
459}