1use wavecraft_dsp::{Processor, Transport};
6use wavecraft_protocol::{OscilloscopeFrame, OscilloscopeTriggerMode};
7
8pub 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#[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 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
41pub struct OscilloscopeFrameProducer {
43 producer: rtrb::Producer<OscilloscopeFrameSnapshot>,
44}
45
46impl OscilloscopeFrameProducer {
47 pub fn push(&mut self, frame: OscilloscopeFrameSnapshot) {
49 let _ = self.producer.push(frame);
50 }
51}
52
53pub struct OscilloscopeFrameConsumer {
55 consumer: rtrb::Consumer<OscilloscopeFrameSnapshot>,
56}
57
58impl OscilloscopeFrameConsumer {
59 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
69pub 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
80pub 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 pub fn new() -> Self {
116 Self::default()
117 }
118
119 pub fn with_output(output: OscilloscopeFrameProducer) -> Self {
121 Self {
122 output: Some(output),
123 ..Self::default()
124 }
125 }
126
127 pub fn set_output(&mut self, output: OscilloscopeFrameProducer) {
129 self.output = Some(output);
130 }
131
132 pub fn set_sample_rate_hz(&mut self, sample_rate: f32) {
134 self.sample_rate = sample_rate;
135 }
136
137 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 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 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 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 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 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 assert!((second.points_l[0] - left_prev[1001]).abs() < f32::EPSILON);
373
374 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 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 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 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}