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        // Once the stream underruns, consuming the first small refill
269        // immediately leaves it permanently balanced on empty: the GUI and
270        // device produce/consume at the same average rate, so ordinary timer
271        // jitter becomes a train of audible gaps. Hold silence until the
272        // original safety lead has been rebuilt, then resume continuously.
273        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            // The emulator mixer has already converted guest sounds to the
294            // 22 kHz Sound Manager stream. Preserve those samples during
295            // host-device upsampling instead of smoothing classic 8-bit
296            // effect edges a second time. Sound Manager exposes both linear
297            // interpolation and drop-sample conversion as rate-conversion
298            // modes. Sound 1994, 2-91 to 2-92.
299            [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    /// Create a new cpal audio backend using the device's preferred output format.
408    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            // Keep a small lead over the device callback so minor host jitter
423            // does not translate into audible underruns.
424            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            // Start at silence (0x80 = 0.0 in f32 mapping).
432            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        // 90ms × 22050 Hz = 1984 samples
549        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}