1pub trait AudioBackend {
8 fn queue_samples(&mut self, samples: &[u8]);
11
12 fn queue_stereo_samples(&mut self, samples: &[u8]) {
18 if samples.is_empty() {
19 return;
20 }
21 let mut mono = Vec::with_capacity(samples.len() / 2);
22 for frame in samples.chunks_exact(2) {
23 let left = frame[0] as i32 - 0x80;
24 let right = frame[1] as i32 - 0x80;
25 mono.push(((left + right) / 2 + 0x80).clamp(0, 255) as u8);
26 }
27 self.queue_samples(&mono);
28 }
29
30 fn stop(&mut self);
32}
33
34pub struct NullAudioBackend;
36
37impl AudioBackend for NullAudioBackend {
38 fn queue_samples(&mut self, _samples: &[u8]) {}
39 fn queue_stereo_samples(&mut self, _samples: &[u8]) {}
40 fn stop(&mut self) {}
41}
42
43#[doc(hidden)]
47pub fn host_audio_probe_output_stereo_u8(samples: &[u8], device_sample_rate: u32) -> Vec<u8> {
48 if samples.is_empty() || device_sample_rate == 0 {
49 return Vec::new();
50 }
51
52 let frames = samples
53 .chunks_exact(2)
54 .map(|frame| [frame[0], frame[1]])
55 .collect::<Vec<_>>();
56 let step = crate::sound::OUTPUT_RATE as f32 / device_sample_rate as f32;
57 let mut output = Vec::new();
58 let mut source_phase = 0.0f32;
59 let mut idx = 0usize;
60
61 while let Some(&first) = frames.get(idx) {
62 let frame = if step < 1.0 {
63 first
64 } else {
65 let second = frames.get(idx + 1).copied().unwrap_or(first);
66 [
67 interpolate_u8_for_host_rate(first[0], second[0], source_phase),
68 interpolate_u8_for_host_rate(first[1], second[1], source_phase),
69 ]
70 };
71 output.extend_from_slice(&frame);
72
73 source_phase += step;
74 while source_phase >= 1.0 {
75 idx += 1;
76 source_phase -= 1.0;
77 if idx >= frames.len() {
78 break;
79 }
80 }
81 }
82
83 output
84}
85
86fn interpolate_u8_for_host_rate(first: u8, second: u8, phase: f32) -> u8 {
87 let first = first as f32;
88 let second = second as f32;
89 (first + (second - first) * phase).round().clamp(0.0, 255.0) as u8
90}
91
92#[cfg(feature = "gui")]
93const HOST_AUDIO_PREFILL_MSEC: usize = 90;
94
95#[cfg(feature = "gui")]
99const HOST_AUDIO_MAX_BUFFER_SECS: f32 = 0.25;
100
101#[cfg(feature = "gui")]
102const HOST_AUDIO_ACTIVE_FRAME_ENERGY: f32 = 4.0;
103
104#[cfg(feature = "gui")]
105const HOST_AUDIO_TRANSIENT_PRESERVE_RATIO: f32 = 1.25;
106
107#[cfg(feature = "gui")]
108const HOST_AUDIO_TRANSIENT_PEAK_RATIO: f32 = 1.10;
109
110#[cfg(feature = "gui")]
111const HOST_AUDIO_TRANSIENT_PRESERVE_BUDGET_FRAMES: usize = 64;
112
113#[cfg(feature = "gui")]
114fn host_audio_prefill_samples() -> usize {
115 (crate::sound::OUTPUT_RATE as usize * HOST_AUDIO_PREFILL_MSEC) / 1000
116}
117
118#[cfg(feature = "gui")]
119fn host_audio_max_buffered_samples() -> usize {
120 (crate::sound::OUTPUT_RATE as f32 * HOST_AUDIO_MAX_BUFFER_SECS) as usize
121}
122
123#[cfg(feature = "gui")]
124static TRACE_AUDIO: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
125
126#[cfg(feature = "gui")]
127fn trace_audio_enabled() -> bool {
128 *TRACE_AUDIO.get_or_init(|| std::env::var_os("SYSTEMLESS_TRACE_AUDIO").is_some())
129}
130
131#[cfg(feature = "gui")]
133pub struct CpalAudioBackend {
134 state: std::sync::Arc<std::sync::Mutex<SharedAudioState>>,
136 _stream: cpal::Stream,
138}
139
140#[cfg(feature = "gui")]
141struct SharedAudioState {
142 buffer: std::collections::VecDeque<[u8; 2]>,
143 source_phase: f32,
144 last_frame: [f32; 2],
147 underrun_samples: u32,
150 transient_preserved_frames: usize,
154}
155
156#[cfg(feature = "gui")]
157#[derive(Clone, Copy, Debug, PartialEq)]
158struct FrameEnergyStats {
159 mean: f32,
160 peak: f32,
161}
162
163#[cfg(feature = "gui")]
164impl SharedAudioState {
165 fn queue_frames(&mut self, frames: &[[u8; 2]], trace_added_frames: usize) {
166 if frames.is_empty() {
167 return;
168 }
169
170 let max_buffered = host_audio_max_buffered_samples();
175 let mut incoming_start = 0usize;
176 while self.buffer.len() + frames.len().saturating_sub(incoming_start) > max_buffered {
177 let overflow =
178 self.buffer.len() + frames.len().saturating_sub(incoming_start) - max_buffered;
179 let front_count = overflow.min(self.buffer.len());
180 let incoming_count = overflow.min(frames.len().saturating_sub(incoming_start));
181 let front_energy = Self::frame_energy_stats(self.buffer.iter().take(front_count));
182 let incoming_energy = Self::frame_energy_stats(
183 frames[incoming_start..incoming_start + incoming_count].iter(),
184 );
185 let transient_budget_available = self
186 .transient_preserved_frames
187 .saturating_add(incoming_count)
188 <= HOST_AUDIO_TRANSIENT_PRESERVE_BUDGET_FRAMES;
189 let preserve_front = front_count > 0
190 && incoming_count > 0
191 && transient_budget_available
192 && ((front_energy.mean >= HOST_AUDIO_ACTIVE_FRAME_ENERGY
193 && front_energy.mean
194 > incoming_energy.mean * HOST_AUDIO_TRANSIENT_PRESERVE_RATIO)
195 || (front_energy.peak >= HOST_AUDIO_ACTIVE_FRAME_ENERGY
196 && front_energy.peak
197 > incoming_energy.peak * HOST_AUDIO_TRANSIENT_PEAK_RATIO));
198
199 if preserve_front {
200 self.transient_preserved_frames += incoming_count;
201 incoming_start += incoming_count;
202 if trace_audio_enabled() {
203 eprintln!(
204 "[AUDIO] overflow: dropped {} incoming frames (buffer={}, adding={}, front_mean={:.1}, incoming_mean={:.1}, front_peak={:.1}, incoming_peak={:.1})",
205 incoming_count,
206 self.buffer.len(),
207 trace_added_frames,
208 front_energy.mean,
209 incoming_energy.mean,
210 front_energy.peak,
211 incoming_energy.peak
212 );
213 }
214 continue;
215 }
216
217 let drain_count = overflow.min(self.buffer.len());
218 if drain_count > 0 {
219 self.buffer.drain(..drain_count);
220 }
221 self.transient_preserved_frames = 0;
222 if drain_count < overflow {
223 incoming_start += overflow - drain_count;
224 }
225 if trace_audio_enabled() {
226 eprintln!(
227 "[AUDIO] overflow: dropped {} frames (buffer was {}, adding {})",
228 drain_count,
229 self.buffer.len() + drain_count,
230 trace_added_frames
231 );
232 }
233 }
234 self.buffer.extend(frames[incoming_start..].iter().copied());
235 }
236
237 fn frame_energy(frame: &[u8; 2]) -> f32 {
238 ((frame[0] as i16 - 0x80).abs() + (frame[1] as i16 - 0x80).abs()) as f32 * 0.5
239 }
240
241 fn frame_energy_stats<'a>(frames: impl Iterator<Item = &'a [u8; 2]>) -> FrameEnergyStats {
242 let mut total = 0.0f32;
243 let mut peak = 0.0f32;
244 let mut count = 0usize;
245 for frame in frames {
246 let energy = Self::frame_energy(frame);
247 total += energy;
248 peak = peak.max(energy);
249 count += 1;
250 }
251 let mean = if count == 0 {
252 0.0
253 } else {
254 total / count as f32
255 };
256 FrameEnergyStats { mean, peak }
257 }
258
259 fn next_frame(&mut self, device_sample_rate: u32) -> [f32; 2] {
260 if self.buffer.is_empty() {
261 self.underrun_samples = self.underrun_samples.saturating_add(1);
262 self.source_phase = 0.0;
263 self.last_frame = [0.0, 0.0];
264 self.transient_preserved_frames = 0;
265 return [0.0, 0.0];
266 }
267
268 if self.underrun_samples > 0 && self.buffer.len() < host_audio_prefill_samples() {
274 self.underrun_samples = self.underrun_samples.saturating_add(1);
275 self.source_phase = 0.0;
276 self.last_frame = [0.0, 0.0];
277 return [0.0, 0.0];
278 }
279
280 if self.underrun_samples > 0 {
281 if trace_audio_enabled() {
282 eprintln!(
283 "[AUDIO] underrun ended after {} samples",
284 self.underrun_samples
285 );
286 }
287 self.underrun_samples = 0;
288 }
289
290 let first = *self.buffer.front().unwrap();
291 let step = crate::sound::OUTPUT_RATE as f32 / device_sample_rate as f32;
292 let frame = if step < 1.0 {
293 [Self::u8_to_f32(first[0]), Self::u8_to_f32(first[1])]
300 } else {
301 let second = self.buffer.get(1).copied().unwrap_or(first);
302 [
303 Self::interpolate_sample(first[0], second[0], self.source_phase),
304 Self::interpolate_sample(first[1], second[1], self.source_phase),
305 ]
306 };
307 self.last_frame = frame;
308
309 self.source_phase += step;
310 let mut consumed_frames = false;
311 while self.source_phase >= 1.0 {
312 if self.buffer.pop_front().is_none() {
313 break;
314 }
315 consumed_frames = true;
316 self.source_phase -= 1.0;
317 if self.buffer.is_empty() {
318 break;
319 }
320 }
321 if consumed_frames {
322 self.transient_preserved_frames = 0;
323 }
324
325 frame
326 }
327
328 fn interpolate_sample(first: u8, second: u8, phase: f32) -> f32 {
329 let first = Self::u8_to_f32(first);
330 let second = Self::u8_to_f32(second);
331 first + (second - first) * phase
332 }
333
334 fn u8_to_f32(sample: u8) -> f32 {
335 (sample as f32 - 128.0) / 128.0
336 }
337}
338
339#[cfg(feature = "gui")]
340fn fill_output_f32(
341 data: &mut [f32],
342 channels: usize,
343 state: &std::sync::Arc<std::sync::Mutex<SharedAudioState>>,
344 device_sample_rate: u32,
345) {
346 let mut shared = state.lock().unwrap();
347 for frame in data.chunks_mut(channels) {
348 let sample = shared.next_frame(device_sample_rate);
349 if channels == 1 {
350 frame[0] = (sample[0] + sample[1]) * 0.5;
351 } else {
352 for (idx, channel) in frame.iter_mut().enumerate() {
353 *channel = sample[idx % 2];
354 }
355 }
356 }
357}
358
359#[cfg(feature = "gui")]
360fn fill_output_i16(
361 data: &mut [i16],
362 channels: usize,
363 state: &std::sync::Arc<std::sync::Mutex<SharedAudioState>>,
364 device_sample_rate: u32,
365) {
366 let mut shared = state.lock().unwrap();
367 for frame in data.chunks_mut(channels) {
368 let sample = shared.next_frame(device_sample_rate);
369 if channels == 1 {
370 frame[0] = (((sample[0] + sample[1]) * 0.5) * i16::MAX as f32)
371 .clamp(i16::MIN as f32, i16::MAX as f32) as i16;
372 } else {
373 for (idx, channel) in frame.iter_mut().enumerate() {
374 let source = sample[idx % 2];
375 *channel =
376 (source * i16::MAX as f32).clamp(i16::MIN as f32, i16::MAX as f32) as i16;
377 }
378 }
379 }
380}
381
382#[cfg(feature = "gui")]
383fn fill_output_u16(
384 data: &mut [u16],
385 channels: usize,
386 state: &std::sync::Arc<std::sync::Mutex<SharedAudioState>>,
387 device_sample_rate: u32,
388) {
389 let mut shared = state.lock().unwrap();
390 for frame in data.chunks_mut(channels) {
391 let sample = shared.next_frame(device_sample_rate);
392 if channels == 1 {
393 frame[0] = ((((sample[0] + sample[1]) * 0.5) * 0.5 + 0.5) * u16::MAX as f32)
394 .clamp(0.0, u16::MAX as f32) as u16;
395 } else {
396 for (idx, channel) in frame.iter_mut().enumerate() {
397 let source = sample[idx % 2];
398 *channel =
399 ((source * 0.5 + 0.5) * u16::MAX as f32).clamp(0.0, u16::MAX as f32) as u16;
400 }
401 }
402 }
403}
404
405#[cfg(feature = "gui")]
406impl CpalAudioBackend {
407 pub fn new() -> Option<Self> {
409 use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
410
411 let host = cpal::default_host();
412 let device = host.default_output_device()?;
413
414 let supported_config = device.default_output_config().ok()?;
415 let sample_format = supported_config.sample_format();
416 let config = supported_config.config();
417 let channels = config.channels as usize;
418 let device_sample_rate = config.sample_rate.0;
419 let prefill_samples = host_audio_prefill_samples();
420
421 let state = std::sync::Arc::new(std::sync::Mutex::new(SharedAudioState {
422 buffer: {
425 let mut buffer =
426 std::collections::VecDeque::with_capacity(crate::sound::OUTPUT_RATE as usize);
427 buffer.extend(std::iter::repeat_n([0x80, 0x80], prefill_samples));
428 buffer
429 },
430 source_phase: 0.0,
431 last_frame: [0.0, 0.0],
433 underrun_samples: 0,
434 transient_preserved_frames: 0,
435 }));
436
437 let err_fn = |err| {
438 eprintln!("[AUDIO] cpal stream error: {}", err);
439 };
440 let stream = match sample_format {
441 cpal::SampleFormat::F32 => {
442 let state_clone = state.clone();
443 device
444 .build_output_stream(
445 &config,
446 move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
447 fill_output_f32(data, channels, &state_clone, device_sample_rate);
448 },
449 err_fn,
450 None,
451 )
452 .ok()?
453 }
454 cpal::SampleFormat::I16 => {
455 let state_clone = state.clone();
456 device
457 .build_output_stream(
458 &config,
459 move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
460 fill_output_i16(data, channels, &state_clone, device_sample_rate);
461 },
462 err_fn,
463 None,
464 )
465 .ok()?
466 }
467 cpal::SampleFormat::U16 => {
468 let state_clone = state.clone();
469 device
470 .build_output_stream(
471 &config,
472 move |data: &mut [u16], _: &cpal::OutputCallbackInfo| {
473 fill_output_u16(data, channels, &state_clone, device_sample_rate);
474 },
475 err_fn,
476 None,
477 )
478 .ok()?
479 }
480 _ => return None,
481 };
482
483 stream.play().ok()?;
484
485 if trace_audio_enabled() {
486 eprintln!(
487 "[AUDIO] cpal backend started: {} Hz {}ch {:?}, prefill={} samples",
488 device_sample_rate, channels, sample_format, prefill_samples
489 );
490 }
491
492 Some(Self {
493 state,
494 _stream: stream,
495 })
496 }
497}
498
499#[cfg(feature = "gui")]
500impl AudioBackend for CpalAudioBackend {
501 fn queue_samples(&mut self, samples: &[u8]) {
502 if samples.is_empty() {
503 return;
504 }
505 let frames = samples
506 .iter()
507 .copied()
508 .map(|sample| [sample, sample])
509 .collect::<Vec<_>>();
510 self.queue_frames(&frames, samples.len());
511 }
512
513 fn queue_stereo_samples(&mut self, samples: &[u8]) {
514 if samples.is_empty() {
515 return;
516 }
517 let frames = samples
518 .chunks_exact(2)
519 .map(|frame| [frame[0], frame[1]])
520 .collect::<Vec<_>>();
521 self.queue_frames(&frames, samples.len() / 2);
522 }
523
524 fn stop(&mut self) {
525 let mut shared = self.state.lock().unwrap();
526 shared.buffer.clear();
527 shared.source_phase = 0.0;
528 shared.last_frame = [0.0, 0.0];
529 shared.underrun_samples = 0;
530 shared.transient_preserved_frames = 0;
531 }
532}
533
534#[cfg(feature = "gui")]
535impl CpalAudioBackend {
536 fn queue_frames(&mut self, frames: &[[u8; 2]], trace_added_frames: usize) {
537 let mut shared = self.state.lock().unwrap();
538 shared.queue_frames(frames, trace_added_frames);
539 }
540}
541
542#[cfg(all(test, feature = "gui"))]
543mod tests {
544 use super::*;
545
546 #[test]
547 fn host_audio_prefill_matches_90ms_target() {
548 assert_eq!(host_audio_prefill_samples(), 1984);
550 }
551
552 #[test]
553 fn host_audio_max_buffer_matches_latency_cap() {
554 assert_eq!(host_audio_max_buffered_samples(), 5512);
555 assert!(host_audio_max_buffered_samples() > host_audio_prefill_samples());
556 }
557
558 #[test]
559 fn host_audio_queue_cap_preserves_new_frames() {
560 let max_buffered = host_audio_max_buffered_samples();
561 let mut state = SharedAudioState {
562 buffer: std::collections::VecDeque::from(vec![[0x80, 0x80]; max_buffered - 2]),
563 source_phase: 0.0,
564 last_frame: [0.0, 0.0],
565 underrun_samples: 0,
566 transient_preserved_frames: 0,
567 };
568 let new_frames = [[0x90, 0x91], [0xA0, 0xA1], [0xB0, 0xB1], [0xC0, 0xC1]];
569
570 state.queue_frames(&new_frames, new_frames.len());
571
572 assert_eq!(state.buffer.len(), max_buffered);
573 assert_eq!(
574 state
575 .buffer
576 .iter()
577 .rev()
578 .take(new_frames.len())
579 .copied()
580 .collect::<Vec<_>>()
581 .into_iter()
582 .rev()
583 .collect::<Vec<_>>(),
584 new_frames
585 );
586 }
587
588 #[test]
589 fn host_audio_queue_cap_preserves_strong_queued_transient() {
590 let max_buffered = host_audio_max_buffered_samples();
591 let transient = [[0xF0, 0xF0]; 4];
592 let mut queued = transient.to_vec();
593 queued.extend(std::iter::repeat_n(
594 [0x80, 0x80],
595 max_buffered - queued.len(),
596 ));
597 let mut state = SharedAudioState {
598 buffer: std::collections::VecDeque::from(queued),
599 source_phase: 0.0,
600 last_frame: [0.0, 0.0],
601 underrun_samples: 0,
602 transient_preserved_frames: 0,
603 };
604 let weaker_tail = [[0x84, 0x84]; 4];
605
606 state.queue_frames(&weaker_tail, weaker_tail.len());
607
608 assert_eq!(state.buffer.len(), max_buffered);
609 assert_eq!(
610 state
611 .buffer
612 .iter()
613 .take(transient.len())
614 .copied()
615 .collect::<Vec<_>>(),
616 transient,
617 "a queued click/effect transient should not be discarded by weaker later samples"
618 );
619 assert!(
620 !state.buffer.iter().any(|frame| weaker_tail.contains(frame)),
621 "weaker overflow tail should be dropped before a stronger queued transient"
622 );
623 }
624
625 #[test]
626 fn host_audio_queue_cap_preserves_short_peak_transient() {
627 let max_buffered = host_audio_max_buffered_samples();
628 let mut queued = vec![[0x80, 0x80]; max_buffered];
629 queued[0] = [0xF0, 0xF0];
630 let mut state = SharedAudioState {
631 buffer: std::collections::VecDeque::from(queued),
632 source_phase: 0.0,
633 last_frame: [0.0, 0.0],
634 underrun_samples: 0,
635 transient_preserved_frames: 0,
636 };
637 let weaker_tail = [[0x86, 0x86]; 32];
638
639 state.queue_frames(&weaker_tail, weaker_tail.len());
640
641 assert_eq!(state.buffer.len(), max_buffered);
642 assert_eq!(
643 state.buffer.front().copied(),
644 Some([0xF0, 0xF0]),
645 "single-frame click peaks should survive host queue overflow even when their overflow-window mean energy is low"
646 );
647 assert!(
648 !state.buffer.iter().any(|frame| weaker_tail.contains(frame)),
649 "weaker incoming frames should be dropped before a queued click peak"
650 );
651 }
652
653 #[test]
654 fn host_audio_queue_cap_bounds_transient_preservation_when_host_lags() {
655 let max_buffered = host_audio_max_buffered_samples();
656 let mut queued = vec![[0x80, 0x80]; max_buffered];
657 queued[0] = [0xF0, 0xF0];
658 let mut state = SharedAudioState {
659 buffer: std::collections::VecDeque::from(queued),
660 source_phase: 0.0,
661 last_frame: [0.0, 0.0],
662 underrun_samples: 0,
663 transient_preserved_frames: 0,
664 };
665 let weaker_tail = [[0x86, 0x86]; 32];
666
667 state.queue_frames(&weaker_tail, weaker_tail.len());
668 state.queue_frames(&weaker_tail, weaker_tail.len());
669 assert_eq!(
670 state.buffer.front().copied(),
671 Some([0xF0, 0xF0]),
672 "short overflow bursts should still protect the leading click"
673 );
674
675 state.queue_frames(&weaker_tail, weaker_tail.len());
676
677 assert_eq!(state.buffer.len(), max_buffered);
678 assert_ne!(
679 state.buffer.front().copied(),
680 Some([0xF0, 0xF0]),
681 "transient preservation must be bounded so stale queue head audio cannot starve newer samples"
682 );
683 assert!(
684 state
685 .buffer
686 .iter()
687 .rev()
688 .take(weaker_tail.len())
689 .any(|frame| weaker_tail.contains(frame)),
690 "once the transient budget is spent, current incoming audio should be admitted"
691 );
692 }
693
694 #[test]
695 fn host_audio_upsampling_preserves_queued_sample_edges() {
696 let mut state = SharedAudioState {
697 buffer: std::collections::VecDeque::from(vec![
698 [0x90, 0x91],
699 [0x90, 0x91],
700 [0xA0, 0xA1],
701 [0xA0, 0xA1],
702 ]),
703 source_phase: 0.0,
704 last_frame: [0.0, 0.0],
705 underrun_samples: 0,
706 transient_preserved_frames: 0,
707 };
708
709 let output = (0..8)
710 .map(|_| state.next_frame(crate::sound::OUTPUT_RATE * 2))
711 .collect::<Vec<_>>();
712
713 let held_first = [
714 SharedAudioState::u8_to_f32(0x90),
715 SharedAudioState::u8_to_f32(0x91),
716 ];
717 let held_second = [
718 SharedAudioState::u8_to_f32(0xA0),
719 SharedAudioState::u8_to_f32(0xA1),
720 ];
721 assert_eq!(
722 &output[..4],
723 &[held_first, held_first, held_first, held_first],
724 "host upsampling must not insert a linear midpoint before a low-rate effect edge"
725 );
726 assert_eq!(
727 &output[4..],
728 &[held_second, held_second, held_second, held_second],
729 "host upsampling must hold the next queued sample after the edge"
730 );
731 }
732
733 #[test]
734 fn host_audio_underrun_outputs_silence_without_smearing_last_frame() {
735 let mut state = SharedAudioState {
736 buffer: std::collections::VecDeque::new(),
737 source_phase: 0.75,
738 last_frame: [1.0, -0.5],
739 underrun_samples: 0,
740 transient_preserved_frames: 0,
741 };
742
743 let first = state.next_frame(44_100);
744 assert_eq!(first, [0.0, 0.0]);
745 assert_eq!(state.last_frame, [0.0, 0.0]);
746 assert_eq!(state.source_phase, 0.0);
747 assert!(state.underrun_samples > 0);
748 }
749
750 #[test]
751 fn host_audio_resume_after_underrun_rebuilds_lead_then_starts_at_next_frame() {
752 let mut state = SharedAudioState {
753 buffer: std::collections::VecDeque::new(),
754 source_phase: 0.75,
755 last_frame: [1.0, 1.0],
756 underrun_samples: 0,
757 transient_preserved_frames: 0,
758 };
759
760 assert_eq!(state.next_frame(44_100), [0.0, 0.0]);
761 state.queue_frames(&[[0x90, 0x91], [0xA0, 0xA1]], 2);
762 assert_eq!(
763 state.next_frame(44_100),
764 [0.0, 0.0],
765 "a short refill must be held instead of immediately underrunning again"
766 );
767 let remaining = host_audio_prefill_samples() - 2;
768 state.queue_frames(&vec![[0x80, 0x80]; remaining], remaining);
769
770 let first = state.next_frame(crate::sound::OUTPUT_RATE * 2);
771 assert_eq!(
772 first,
773 [
774 SharedAudioState::u8_to_f32(0x90),
775 SharedAudioState::u8_to_f32(0x91)
776 ],
777 "after rebuilding its safety lead, playback should restart on the first queued effect sample"
778 );
779 assert_eq!(
780 state.underrun_samples, 0,
781 "successful playback must leave underrun recovery mode"
782 );
783 }
784}