Skip to main content

systemless/
audio.rs

1//! Host audio output backends.
2//!
3//! Provides a trait for audio output and implementations for native (cpal)
4//! and null (headless/test) backends.
5
6/// Trait for pushing mixed PCM samples to the host audio device.
7pub trait AudioBackend {
8    /// Queue unsigned 8-bit mono PCM samples (silence = 0x80) at 22050 Hz.
9    /// An empty slice signals no audio this frame.
10    fn queue_samples(&mut self, samples: &[u8]);
11
12    /// Queue interleaved unsigned 8-bit stereo PCM samples
13    /// (left, right, left, right; silence = 0x80) at 22050 Hz.
14    ///
15    /// Backends that only support the historical mono path can rely on this
16    /// default downmix.
17    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    /// Stop audio output and release resources.
31    fn stop(&mut self);
32}
33
34/// No-op backend for tests, headless mode, and scripted harnesses.
35pub 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/// Diagnostic helper for tests that need to validate the stream shape after
44/// the host-output rate conversion policy. Input and output are interleaved
45/// unsigned 8-bit stereo frames.
46#[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/// Maximum buffered audio in seconds. Larger = more latency but more
96/// resilience to host scheduling jitter; smaller = lower latency but
97/// more underruns under load.
98#[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/// cpal-based audio backend for native GUI mode.
132#[cfg(feature = "gui")]
133pub struct CpalAudioBackend {
134    /// Shared source buffer and resampler state between the emulator and cpal callback.
135    state: std::sync::Arc<std::sync::Mutex<SharedAudioState>>,
136    /// The cpal output stream — kept alive to maintain audio playback.
137    _stream: cpal::Stream,
138}
139
140#[cfg(feature = "gui")]
141struct SharedAudioState {
142    buffer: std::collections::VecDeque<[u8; 2]>,
143    source_phase: f32,
144    /// Last non-underrun frame emitted. Kept for diagnostics and reset on
145    /// underrun so sparse effects cannot smear into an artificial tail.
146    last_frame: [f32; 2],
147    /// Number of consecutive callback frames emitted from an empty buffer
148    /// since the last successful sample. Used to log underruns.
149    underrun_samples: u32,
150    /// Incoming frames dropped to keep a queued transient at the head of a
151    /// full buffer. Bounded so stale audio cannot starve newer samples when
152    /// the GUI briefly outruns the host device.
153    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        // Cap ring buffer to avoid unbounded growth / latency. Preserve a
171        // queued active transient over weaker incoming samples so short
172        // effects are not aged out by later music/silence when the GUI briefly
173        // produces audio faster than the host device consumes it.
174        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 && trace_audio_enabled() {
269            eprintln!(
270                "[AUDIO] underrun ended after {} samples",
271                self.underrun_samples
272            );
273            self.underrun_samples = 0;
274        }
275
276        let first = *self.buffer.front().unwrap();
277        let step = crate::sound::OUTPUT_RATE as f32 / device_sample_rate as f32;
278        let frame = if step < 1.0 {
279            // The emulator mixer has already converted guest sounds to the
280            // 22 kHz Sound Manager stream. Preserve those samples during
281            // host-device upsampling instead of smoothing classic 8-bit
282            // effect edges a second time. Sound Manager exposes both linear
283            // interpolation and drop-sample conversion as rate-conversion
284            // modes. Sound 1994, 2-91 to 2-92.
285            [Self::u8_to_f32(first[0]), Self::u8_to_f32(first[1])]
286        } else {
287            let second = self.buffer.get(1).copied().unwrap_or(first);
288            [
289                Self::interpolate_sample(first[0], second[0], self.source_phase),
290                Self::interpolate_sample(first[1], second[1], self.source_phase),
291            ]
292        };
293        self.last_frame = frame;
294
295        self.source_phase += step;
296        let mut consumed_frames = false;
297        while self.source_phase >= 1.0 {
298            if self.buffer.pop_front().is_none() {
299                break;
300            }
301            consumed_frames = true;
302            self.source_phase -= 1.0;
303            if self.buffer.is_empty() {
304                break;
305            }
306        }
307        if consumed_frames {
308            self.transient_preserved_frames = 0;
309        }
310
311        frame
312    }
313
314    fn interpolate_sample(first: u8, second: u8, phase: f32) -> f32 {
315        let first = Self::u8_to_f32(first);
316        let second = Self::u8_to_f32(second);
317        first + (second - first) * phase
318    }
319
320    fn u8_to_f32(sample: u8) -> f32 {
321        (sample as f32 - 128.0) / 128.0
322    }
323}
324
325#[cfg(feature = "gui")]
326fn fill_output_f32(
327    data: &mut [f32],
328    channels: usize,
329    state: &std::sync::Arc<std::sync::Mutex<SharedAudioState>>,
330    device_sample_rate: u32,
331) {
332    let mut shared = state.lock().unwrap();
333    for frame in data.chunks_mut(channels) {
334        let sample = shared.next_frame(device_sample_rate);
335        if channels == 1 {
336            frame[0] = (sample[0] + sample[1]) * 0.5;
337        } else {
338            for (idx, channel) in frame.iter_mut().enumerate() {
339                *channel = sample[idx % 2];
340            }
341        }
342    }
343}
344
345#[cfg(feature = "gui")]
346fn fill_output_i16(
347    data: &mut [i16],
348    channels: usize,
349    state: &std::sync::Arc<std::sync::Mutex<SharedAudioState>>,
350    device_sample_rate: u32,
351) {
352    let mut shared = state.lock().unwrap();
353    for frame in data.chunks_mut(channels) {
354        let sample = shared.next_frame(device_sample_rate);
355        if channels == 1 {
356            frame[0] = (((sample[0] + sample[1]) * 0.5) * i16::MAX as f32)
357                .clamp(i16::MIN as f32, i16::MAX as f32) as i16;
358        } else {
359            for (idx, channel) in frame.iter_mut().enumerate() {
360                let source = sample[idx % 2];
361                *channel =
362                    (source * i16::MAX as f32).clamp(i16::MIN as f32, i16::MAX as f32) as i16;
363            }
364        }
365    }
366}
367
368#[cfg(feature = "gui")]
369fn fill_output_u16(
370    data: &mut [u16],
371    channels: usize,
372    state: &std::sync::Arc<std::sync::Mutex<SharedAudioState>>,
373    device_sample_rate: u32,
374) {
375    let mut shared = state.lock().unwrap();
376    for frame in data.chunks_mut(channels) {
377        let sample = shared.next_frame(device_sample_rate);
378        if channels == 1 {
379            frame[0] = ((((sample[0] + sample[1]) * 0.5) * 0.5 + 0.5) * u16::MAX as f32)
380                .clamp(0.0, u16::MAX as f32) as u16;
381        } else {
382            for (idx, channel) in frame.iter_mut().enumerate() {
383                let source = sample[idx % 2];
384                *channel =
385                    ((source * 0.5 + 0.5) * u16::MAX as f32).clamp(0.0, u16::MAX as f32) as u16;
386            }
387        }
388    }
389}
390
391#[cfg(feature = "gui")]
392impl CpalAudioBackend {
393    /// Create a new cpal audio backend using the device's preferred output format.
394    pub fn new() -> Option<Self> {
395        use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
396
397        let host = cpal::default_host();
398        let device = host.default_output_device()?;
399
400        let supported_config = device.default_output_config().ok()?;
401        let sample_format = supported_config.sample_format();
402        let config = supported_config.config();
403        let channels = config.channels as usize;
404        let device_sample_rate = config.sample_rate.0;
405        let prefill_samples = host_audio_prefill_samples();
406
407        let state = std::sync::Arc::new(std::sync::Mutex::new(SharedAudioState {
408            // Keep a small lead over the device callback so minor host jitter
409            // does not translate into audible underruns.
410            buffer: {
411                let mut buffer =
412                    std::collections::VecDeque::with_capacity(crate::sound::OUTPUT_RATE as usize);
413                buffer.extend(std::iter::repeat_n([0x80, 0x80], prefill_samples));
414                buffer
415            },
416            source_phase: 0.0,
417            // Start at silence (0x80 = 0.0 in f32 mapping).
418            last_frame: [0.0, 0.0],
419            underrun_samples: 0,
420            transient_preserved_frames: 0,
421        }));
422
423        let err_fn = |err| {
424            eprintln!("[AUDIO] cpal stream error: {}", err);
425        };
426        let stream = match sample_format {
427            cpal::SampleFormat::F32 => {
428                let state_clone = state.clone();
429                device
430                    .build_output_stream(
431                        &config,
432                        move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
433                            fill_output_f32(data, channels, &state_clone, device_sample_rate);
434                        },
435                        err_fn,
436                        None,
437                    )
438                    .ok()?
439            }
440            cpal::SampleFormat::I16 => {
441                let state_clone = state.clone();
442                device
443                    .build_output_stream(
444                        &config,
445                        move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
446                            fill_output_i16(data, channels, &state_clone, device_sample_rate);
447                        },
448                        err_fn,
449                        None,
450                    )
451                    .ok()?
452            }
453            cpal::SampleFormat::U16 => {
454                let state_clone = state.clone();
455                device
456                    .build_output_stream(
457                        &config,
458                        move |data: &mut [u16], _: &cpal::OutputCallbackInfo| {
459                            fill_output_u16(data, channels, &state_clone, device_sample_rate);
460                        },
461                        err_fn,
462                        None,
463                    )
464                    .ok()?
465            }
466            _ => return None,
467        };
468
469        stream.play().ok()?;
470
471        if trace_audio_enabled() {
472            eprintln!(
473                "[AUDIO] cpal backend started: {} Hz {}ch {:?}, prefill={} samples",
474                device_sample_rate, channels, sample_format, prefill_samples
475            );
476        }
477
478        Some(Self {
479            state,
480            _stream: stream,
481        })
482    }
483}
484
485#[cfg(feature = "gui")]
486impl AudioBackend for CpalAudioBackend {
487    fn queue_samples(&mut self, samples: &[u8]) {
488        if samples.is_empty() {
489            return;
490        }
491        let frames = samples
492            .iter()
493            .copied()
494            .map(|sample| [sample, sample])
495            .collect::<Vec<_>>();
496        self.queue_frames(&frames, samples.len());
497    }
498
499    fn queue_stereo_samples(&mut self, samples: &[u8]) {
500        if samples.is_empty() {
501            return;
502        }
503        let frames = samples
504            .chunks_exact(2)
505            .map(|frame| [frame[0], frame[1]])
506            .collect::<Vec<_>>();
507        self.queue_frames(&frames, samples.len() / 2);
508    }
509
510    fn stop(&mut self) {
511        let mut shared = self.state.lock().unwrap();
512        shared.buffer.clear();
513        shared.source_phase = 0.0;
514        shared.last_frame = [0.0, 0.0];
515        shared.underrun_samples = 0;
516        shared.transient_preserved_frames = 0;
517    }
518}
519
520#[cfg(feature = "gui")]
521impl CpalAudioBackend {
522    fn queue_frames(&mut self, frames: &[[u8; 2]], trace_added_frames: usize) {
523        let mut shared = self.state.lock().unwrap();
524        shared.queue_frames(frames, trace_added_frames);
525    }
526}
527
528#[cfg(all(test, feature = "gui"))]
529mod tests {
530    use super::*;
531
532    #[test]
533    fn host_audio_prefill_matches_90ms_target() {
534        // 90ms × 22050 Hz = 1984 samples
535        assert_eq!(host_audio_prefill_samples(), 1984);
536    }
537
538    #[test]
539    fn host_audio_max_buffer_matches_latency_cap() {
540        assert_eq!(host_audio_max_buffered_samples(), 5512);
541        assert!(host_audio_max_buffered_samples() > host_audio_prefill_samples());
542    }
543
544    #[test]
545    fn host_audio_queue_cap_preserves_new_frames() {
546        let max_buffered = host_audio_max_buffered_samples();
547        let mut state = SharedAudioState {
548            buffer: std::collections::VecDeque::from(vec![[0x80, 0x80]; max_buffered - 2]),
549            source_phase: 0.0,
550            last_frame: [0.0, 0.0],
551            underrun_samples: 0,
552            transient_preserved_frames: 0,
553        };
554        let new_frames = [[0x90, 0x91], [0xA0, 0xA1], [0xB0, 0xB1], [0xC0, 0xC1]];
555
556        state.queue_frames(&new_frames, new_frames.len());
557
558        assert_eq!(state.buffer.len(), max_buffered);
559        assert_eq!(
560            state
561                .buffer
562                .iter()
563                .rev()
564                .take(new_frames.len())
565                .copied()
566                .collect::<Vec<_>>()
567                .into_iter()
568                .rev()
569                .collect::<Vec<_>>(),
570            new_frames
571        );
572    }
573
574    #[test]
575    fn host_audio_queue_cap_preserves_strong_queued_transient() {
576        let max_buffered = host_audio_max_buffered_samples();
577        let transient = [[0xF0, 0xF0]; 4];
578        let mut queued = transient.to_vec();
579        queued.extend(std::iter::repeat_n(
580            [0x80, 0x80],
581            max_buffered - queued.len(),
582        ));
583        let mut state = SharedAudioState {
584            buffer: std::collections::VecDeque::from(queued),
585            source_phase: 0.0,
586            last_frame: [0.0, 0.0],
587            underrun_samples: 0,
588            transient_preserved_frames: 0,
589        };
590        let weaker_tail = [[0x84, 0x84]; 4];
591
592        state.queue_frames(&weaker_tail, weaker_tail.len());
593
594        assert_eq!(state.buffer.len(), max_buffered);
595        assert_eq!(
596            state
597                .buffer
598                .iter()
599                .take(transient.len())
600                .copied()
601                .collect::<Vec<_>>(),
602            transient,
603            "a queued click/effect transient should not be discarded by weaker later samples"
604        );
605        assert!(
606            !state.buffer.iter().any(|frame| weaker_tail.contains(frame)),
607            "weaker overflow tail should be dropped before a stronger queued transient"
608        );
609    }
610
611    #[test]
612    fn host_audio_queue_cap_preserves_short_peak_transient() {
613        let max_buffered = host_audio_max_buffered_samples();
614        let mut queued = vec![[0x80, 0x80]; max_buffered];
615        queued[0] = [0xF0, 0xF0];
616        let mut state = SharedAudioState {
617            buffer: std::collections::VecDeque::from(queued),
618            source_phase: 0.0,
619            last_frame: [0.0, 0.0],
620            underrun_samples: 0,
621            transient_preserved_frames: 0,
622        };
623        let weaker_tail = [[0x86, 0x86]; 32];
624
625        state.queue_frames(&weaker_tail, weaker_tail.len());
626
627        assert_eq!(state.buffer.len(), max_buffered);
628        assert_eq!(
629            state.buffer.front().copied(),
630            Some([0xF0, 0xF0]),
631            "single-frame click peaks should survive host queue overflow even when their overflow-window mean energy is low"
632        );
633        assert!(
634            !state.buffer.iter().any(|frame| weaker_tail.contains(frame)),
635            "weaker incoming frames should be dropped before a queued click peak"
636        );
637    }
638
639    #[test]
640    fn host_audio_queue_cap_bounds_transient_preservation_when_host_lags() {
641        let max_buffered = host_audio_max_buffered_samples();
642        let mut queued = vec![[0x80, 0x80]; max_buffered];
643        queued[0] = [0xF0, 0xF0];
644        let mut state = SharedAudioState {
645            buffer: std::collections::VecDeque::from(queued),
646            source_phase: 0.0,
647            last_frame: [0.0, 0.0],
648            underrun_samples: 0,
649            transient_preserved_frames: 0,
650        };
651        let weaker_tail = [[0x86, 0x86]; 32];
652
653        state.queue_frames(&weaker_tail, weaker_tail.len());
654        state.queue_frames(&weaker_tail, weaker_tail.len());
655        assert_eq!(
656            state.buffer.front().copied(),
657            Some([0xF0, 0xF0]),
658            "short overflow bursts should still protect the leading click"
659        );
660
661        state.queue_frames(&weaker_tail, weaker_tail.len());
662
663        assert_eq!(state.buffer.len(), max_buffered);
664        assert_ne!(
665            state.buffer.front().copied(),
666            Some([0xF0, 0xF0]),
667            "transient preservation must be bounded so stale queue head audio cannot starve newer samples"
668        );
669        assert!(
670            state
671                .buffer
672                .iter()
673                .rev()
674                .take(weaker_tail.len())
675                .any(|frame| weaker_tail.contains(frame)),
676            "once the transient budget is spent, current incoming audio should be admitted"
677        );
678    }
679
680    #[test]
681    fn host_audio_upsampling_preserves_queued_sample_edges() {
682        let mut state = SharedAudioState {
683            buffer: std::collections::VecDeque::from(vec![
684                [0x90, 0x91],
685                [0x90, 0x91],
686                [0xA0, 0xA1],
687                [0xA0, 0xA1],
688            ]),
689            source_phase: 0.0,
690            last_frame: [0.0, 0.0],
691            underrun_samples: 0,
692            transient_preserved_frames: 0,
693        };
694
695        let output = (0..8)
696            .map(|_| state.next_frame(crate::sound::OUTPUT_RATE * 2))
697            .collect::<Vec<_>>();
698
699        let held_first = [
700            SharedAudioState::u8_to_f32(0x90),
701            SharedAudioState::u8_to_f32(0x91),
702        ];
703        let held_second = [
704            SharedAudioState::u8_to_f32(0xA0),
705            SharedAudioState::u8_to_f32(0xA1),
706        ];
707        assert_eq!(
708            &output[..4],
709            &[held_first, held_first, held_first, held_first],
710            "host upsampling must not insert a linear midpoint before a low-rate effect edge"
711        );
712        assert_eq!(
713            &output[4..],
714            &[held_second, held_second, held_second, held_second],
715            "host upsampling must hold the next queued sample after the edge"
716        );
717    }
718
719    #[test]
720    fn host_audio_underrun_outputs_silence_without_smearing_last_frame() {
721        let mut state = SharedAudioState {
722            buffer: std::collections::VecDeque::new(),
723            source_phase: 0.75,
724            last_frame: [1.0, -0.5],
725            underrun_samples: 0,
726            transient_preserved_frames: 0,
727        };
728
729        let first = state.next_frame(44_100);
730        assert_eq!(first, [0.0, 0.0]);
731        assert_eq!(state.last_frame, [0.0, 0.0]);
732        assert_eq!(state.source_phase, 0.0);
733        assert!(state.underrun_samples > 0);
734    }
735
736    #[test]
737    fn host_audio_resume_after_underrun_starts_at_next_queued_frame() {
738        let mut state = SharedAudioState {
739            buffer: std::collections::VecDeque::new(),
740            source_phase: 0.75,
741            last_frame: [1.0, 1.0],
742            underrun_samples: 0,
743            transient_preserved_frames: 0,
744        };
745
746        assert_eq!(state.next_frame(44_100), [0.0, 0.0]);
747        state.queue_frames(&[[0x90, 0x91], [0xA0, 0xA1]], 2);
748
749        let first = state.next_frame(crate::sound::OUTPUT_RATE * 2);
750        assert_eq!(
751            first,
752            [
753                SharedAudioState::u8_to_f32(0x90),
754                SharedAudioState::u8_to_f32(0x91)
755            ],
756            "after a starvation gap, host playback should restart on the first queued effect sample"
757        );
758    }
759}