diff --git a/src/dev/hal2.rs b/src/dev/hal2.rs index cc01cad47..d97908970 100644 --- a/src/dev/hal2.rs +++ b/src/dev/hal2.rs @@ -260,8 +260,8 @@ impl CodecAState { fn push_to_ring(&mut self, samples: &[i16]) { if let Some(rs) = &mut self.resampler { if let Some(o) = &mut self.out { - for chunk in samples.chunks_exact(2) { - o.push(rs, chunk[0], chunk[1]); + for frame in samples.chunks_exact(2) { + o.push(rs, frame); } } } @@ -557,7 +557,7 @@ impl Hal2 { // (Re)build resampler if codec rate changed. // Use codec B rate as the declared input rate (experiment). if st.resampler.as_ref().map_or(true, |r| r.in_rate != pitch_rate) { - st.resampler = Some(Resampler::new(pitch_rate, stream_rate)); + st.resampler = Some(Resampler::new(pitch_rate, stream_rate, 2)); dlog_dev!(LogModule::Hal2, "HAL2: Codec A resampler {}Hz (pitch={}) → {}Hz", rate, pitch_rate, stream_rate); } @@ -1242,16 +1242,67 @@ mod tests { assert_eq!(bres_rate(48000, 0, 0u16.wrapping_sub(4).wrapping_sub(1)), 0); } - fn resample(in_rate: u32, out_rate: u32, n_frames: usize) -> usize { - let (mut prod, mut cons) = RingBuffer::::new(n_frames * 4 + 16); - let mut r = Resampler::new(in_rate, out_rate); - for i in 0..n_frames { - r.push(i as i16, i as i16, &mut prod); + /// What reaches the host ring from codec A, a frame at a time as the + /// timer pass pushes it and in the burst that flushes the prebuffer, is + /// fixed. + #[test] + fn codec_a_output_is_fixed() { + use crate::host_audio::test_signal::*; + for (mode, name) in [(Push::One, "hal2-one"), (Push::Burst, "hal2-burst")] { + for (tight, run) in [(false, "free"), (true, "tight")] { + let mut g = Golden::new(&format!("{name}/{run}")); + let (slots, every) = golden_ring(tight, mode); + for (inr, outr) in golden_pairs() { + for sig in golden_signals(inr) { + let (p, mut c) = RingBuffer::::new(slots); + let out = AudioOut::detached(Meter::new("test", &[]), outr, p); + let meter = out.meter.clone(); + let mut st = CodecAState::new(); + st.out = Some(out); + st.resampler = Some(Resampler::new(inr, outr, 2)); + for (i, chunk) in golden_chunks(&sig, mode).iter().enumerate() { + let pcm: Vec = chunk.iter().flat_map(|&(l, r)| [l, r]).collect(); + st.push_to_ring(&pcm); + if i % every == 0 { + g.samples(&golden_drain(&mut c)); + } + } + g.samples(&golden_drain(&mut c)); + g.dropped(meter.dropped()); + } + } + g.check(); + } + } + } + + /// Cost of one frame through codec A's push path, as the timer pass pays + /// it (a frame at a time). Run in a release build: + /// `cargo test --release -p iris --lib -- --ignored hal2_push_cost --nocapture` + #[test] + #[ignore] + fn hal2_push_cost() { + const FRAMES: usize = 10_000_000; + const BATCH: usize = 512; + for (inr, outr) in [(44100u32, 48000u32), (11025, 48000), (8000, 48000)] { + let (p, mut c) = RingBuffer::::new(1 << 16); + let out = AudioOut::detached(Meter::new("bench", &[]), outr, p); + let mut st = CodecAState::new(); + st.out = Some(out); + st.resampler = Some(Resampler::new(inr, outr, 2)); + let mut spent = std::time::Duration::ZERO; + let mut x = 1i16; + for _ in 0..FRAMES / BATCH { + let t = std::time::Instant::now(); + for _ in 0..BATCH { + x = x.wrapping_mul(75).wrapping_add(74); + st.push_to_ring(std::hint::black_box(&[x, !x])); + } + spent += t.elapsed(); + while c.pop().is_ok() {} + } + println!("BENCH hal2 push {inr}->{outr}: {:.2} ns per frame", spent.as_nanos() as f64 / FRAMES as f64); } - drop(prod); - let mut count = 0; - while cons.pop().is_ok() { count += 1; } - count / 2 // stereo pairs → frames } /// `audio status` reports codec A's frames, rate and the host stream. @@ -1305,33 +1356,6 @@ mod tests { assert_eq!(underrun(&h, &mut cons), 1, "disabled"); } - #[test] - fn resampler_passthrough() { - // 1:1 — every input frame produces exactly one output frame - assert_eq!(resample(44100, 44100, 1000), 1000); - } - - #[test] - fn resampler_downsample_2x() { - // 44100 → 22050: every 2 inputs → 1 output, so 1000 in → 500 out - let out = resample(44100, 22050, 1000); - assert_eq!(out, 500, "44100→22050: expected 500 frames, got {}", out); - } - - #[test] - fn resampler_upsample_2x() { - // 22050 → 44100: every input → 2 outputs, so 1000 in → 2000 out - let out = resample(22050, 44100, 1000); - assert_eq!(out, 2000, "22050→44100: expected 2000 frames, got {}", out); - } - - #[test] - fn resampler_upsample_44100_to_48000() { - // 44100 → 48000: ratio ~1.0884, so 44100 in → 48000 out (over one second of audio) - let out = resample(44100, 48000, 44100); - assert_eq!(out, 48000, "44100→48000: expected 48000 frames, got {}", out); - } - /// A late wake must not move a burst: after a 10 ms stall at 11025 Hz /// the next call moves at most 1.25 periods' worth, and the backlog is /// still delivered over the following calls. @@ -1347,44 +1371,6 @@ mod tests { assert!(total >= 110, "backlog not delivered: {}", total); } - /// A 1 kHz tone at 11025 Hz, resampled to 48 kHz, against the ideal - /// sine at the best-fitting delay. Repeating samples (the old resampler) - /// misses by about a fifth of the amplitude; interpolation must be close. - #[test] - fn resampler_11025_to_48000_follows_the_waveform() { - let (inr, outr, f, amp) = (11025u32, 48000u32, 1000.0f64, 16000.0f64); - let n = 11025; - let (mut prod, mut cons) = RingBuffer::::new(n * 12); - let mut r = Resampler::new(inr, outr); - for k in 0..n { - let v = (amp * (2.0 * std::f64::consts::PI * f * k as f64 / inr as f64).sin()) as i16; - r.push(v, v, &mut prod); - } - drop(prod); - let mut out = Vec::new(); - while let Ok(v) = cons.pop() { out.push(v as f64); } - let left: Vec = out.iter().step_by(2).copied().collect(); - let body = &left[1000..left.len() - 1000]; - let mut best = f64::MAX; - for step in 0..400 { - let delay = step as f64 * 0.01; // input samples - let mut err = 0.0; - for (j, v) in body.iter().enumerate() { - let t = (j + 1000) as f64 / outr as f64 - delay / inr as f64; - let want = amp * (2.0 * std::f64::consts::PI * f * t).sin(); - err += (v - want) * (v - want); - } - best = best.min((err / body.len() as f64).sqrt() / amp); - } - assert!(best < 0.03, "RMS error {:.3} of the amplitude", best); - } - - #[test] - fn resampler_downsample_48000_to_44100() { - // 48000 → 44100: ratio ~0.919, so 48000 in → 44100 out - let out = resample(48000, 44100, 48000); - assert_eq!(out, 44100, "48000→44100: expected 44100 frames, got {}", out); - } } /// The host end of codec A, as the Indy runs it. diff --git a/src/dev/host_pcm.rs b/src/dev/host_pcm.rs index a8c9bf316..c69188ff7 100644 --- a/src/dev/host_pcm.rs +++ b/src/dev/host_pcm.rs @@ -14,7 +14,7 @@ use std::time::{Duration, Instant}; use parking_lot::Mutex; use crate::dev::audio_dump::AudioDump; -use crate::host_audio::{FrameResampler, HostOut, Resampler}; +use crate::host_audio::{HostOut, Resampler}; /// Words a host frame carries: the mixer takes two channels. pub const PCM_CHANNELS: usize = 2; @@ -60,10 +60,10 @@ impl Sink { }; if let Some((out, rs)) = self.host.as_mut() { if rs.in_rate != rate { - *rs = Resampler::new(rate, out.stream_rate); + *rs = Resampler::new(rate, out.stream_rate, PCM_CHANNELS); } for p in host_pcm.chunks_exact(PCM_CHANNELS) { - out.push(rs, p[0], p[1]); + out.push(rs, p); } } if let Some(d) = self.dump.as_ref() { @@ -145,7 +145,7 @@ impl Downmix { #[inline] pub fn mix(&self, src: &[i16], dst: &mut [i16]) { // Unscaled folds can sum past what 32 bits hold in Q15. - let mut acc = [1i64 << 14; FrameResampler::MAX_CHANNELS]; + let mut acc = [1i64 << 14; Resampler::MAX_CHANNELS]; for &(d, s, g) in &self.terms { acc[d as usize] += i64::from(src[s as usize]) * i64::from(g); } @@ -164,7 +164,7 @@ impl Downmix { /// host's channel count ([`Downmix`]) and the S/PDIF pair adds to the front /// pair. Nothing allocates once the buffers have grown to a tick's worth. pub struct PcmOut { - pub host: Option<(HostOut, FrameResampler)>, + pub host: Option<(HostOut, Resampler)>, /// When the host stream was last tried and failed to open. pub host_failed_at: Option, /// `IRIS_AUDIO_DUMP`: the front pair of each mixed frame at the card's @@ -182,7 +182,6 @@ pub struct PcmOut { spdif: Vec, mix: Downmix, mixed: Vec, - resampled: Vec, } /// Most a stream may wait in staging for the other, in frames: the engines @@ -190,7 +189,7 @@ pub struct PcmOut { const STAGE_MAX_FRAMES: usize = 1 << 14; impl PcmOut { - pub fn new(host: Option<(HostOut, FrameResampler)>, host_failed_at: Option) -> Self { + pub fn new(host: Option<(HostOut, Resampler)>, host_failed_at: Option) -> Self { Self { host, host_failed_at, @@ -204,7 +203,6 @@ impl PcmOut { spdif: Vec::new(), mix: Downmix::new(2, 2), mixed: Vec::new(), - resampled: Vec::new(), } } @@ -283,17 +281,12 @@ impl PcmOut { } if let Some((out, rs)) = self.host.as_mut() { if rs.in_rate != rate { - *rs = FrameResampler::new(rate, out.stream_rate, chans); + *rs = Resampler::new(rate, out.stream_rate, chans); } - self.resampled.clear(); + // A frame the ring has no room for is dropped whole, or the + // channels of every later frame would shift. for f in self.mixed.chunks_exact(chans) { - rs.push(f, &mut self.resampled); - } - // A frame the ring has no room for is dropped whole, or - // the channels of every later frame would shift; one that - // fits is published whole. - for f in self.resampled.chunks_exact(chans) { - out.push_frame(f); + rs.push(f, |o| out.push_frame(o)); } } } @@ -560,4 +553,75 @@ mod tests { let _ = std::fs::remove_file(&path); assert_eq!(recs, [(44100, vec![(100, -200), (300, -400)])]); } + + /// What reaches the host ring from the stereo `Sink` (RAD1) is fixed. + #[test] + fn the_sink_output_is_fixed() { + use crate::host_audio::test_signal::*; + for (tight, run) in [(false, "free"), (true, "tight")] { + let mut g = Golden::new(&format!("rad1-sink/{run}")); + let (slots, every) = golden_ring(tight, Push::Burst); + for (inr, outr) in golden_pairs() { + for sig in golden_signals(inr) { + let (p, mut c) = rtrb::RingBuffer::::new(slots); + let out = HostOut::detached(crate::host_audio::Meter::new("test", &[]), outr, p); + let meter = out.meter.clone(); + let mut sink = Sink { host: Some((out, Resampler::new(inr, outr, PCM_CHANNELS))), host_failed_at: None, dump: None, tap: None }; + for (i, chunk) in golden_chunks(&sig, Push::Burst).iter().enumerate() { + let pcm: Vec = chunk.iter().flat_map(|&(l, r)| [l, r]).collect(); + sink.play(&pcm, inr, false); + if i % every == 0 { + g.samples(&golden_drain(&mut c)); + } + } + g.samples(&golden_drain(&mut c)); + g.dropped(meter.dropped()); + } + } + g.check(); + } + } + + /// What reaches the host ring from the multichannel `PcmOut` (ICE1724), + /// stereo with the S/PDIF pair mixed in and the full eight channels. + #[test] + fn the_multichannel_output_is_fixed() { + use crate::host_audio::test_signal::*; + for (chans, name) in [(2, "ice1724-2ch"), (8, "ice1724-8ch")] { + for (tight, run) in [(false, "free"), (true, "tight")] { + let mut g = Golden::new(&format!("{name}/{run}")); + let (slots, every) = golden_ring(tight, Push::Burst); + // Room for a burst's worth of eight-channel frames. + let slots = if tight { slots } else { slots * 8 }; + for (inr, outr) in golden_pairs() { + for sig in golden_signals(inr) { + let (p, mut c) = rtrb::RingBuffer::::new(slots); + let out = HostOut::detached_channels(crate::host_audio::Meter::new("test", &[]), outr, chans, p); + let meter = out.meter.clone(); + let mut pcm = PcmOut::new(Some((out, Resampler::new(inr, outr, chans))), None); + pcm.dump = None; + for (i, chunk) in golden_chunks(&sig, Push::Burst).iter().enumerate() { + let stage = pcm.analog_stage(8); + for &(l, r) in chunk.iter() { + stage.extend_from_slice(&[l, r, r, l, l / 2, r / 2, l.saturating_neg(), r.saturating_neg()]); + } + if chans == 2 { + let spdif = pcm.spdif_stage(); + for &(l, r) in chunk.iter() { + spdif.extend_from_slice(&[r / 3, l / 3]); + } + } + pcm.flush(inr, true, chans == 2); + if i % every == 0 { + g.samples(&golden_drain(&mut c)); + } + } + g.samples(&golden_drain(&mut c)); + g.dropped(meter.dropped()); + } + } + g.check(); + } + } + } } diff --git a/src/dev/ice1724.rs b/src/dev/ice1724.rs index 365b97a3d..2afea378e 100644 --- a/src/dev/ice1724.rs +++ b/src/dev/ice1724.rs @@ -148,7 +148,7 @@ use parking_lot::{Condvar, Mutex, MutexGuard}; use crate::config::AudioConfig; use crate::dev::host_pcm::{CardClock, PcmOut}; use crate::devlog::LogModule; -use crate::host_audio::{FrameResampler, Meter}; +use crate::host_audio::{Meter, Resampler}; use crate::hptimer::{TimerId, TimerManager, TimerReturn}; use crate::pci::{Bar, PciConfig, PciConfigPolicy, PciFunction, PciFunctionState, PciRegisterPolicy, PCI_COMMAND_IO, PCI_INTA}; use crate::snapshot::{field, field_bool, field_u32, field_u64, field_u8, field_u8s, load_u16_slice, StateTable}; @@ -921,7 +921,7 @@ impl Inner { let host = crate::host_audio::open_output_channels(&cfg, MAX_HOST_CHANNELS, self.meter.clone(), ring, "ICE1724 audio"); crate::dlog!(LogModule::Ice1724, "ice1724: host audio {}", if host.is_some() { "open" } else { "failed to open" }); let host = host.map(|out| { - let rs = FrameResampler::new(rate, out.stream_rate, out.channels); + let rs = Resampler::new(rate, out.stream_rate, out.channels); (out, rs) }); let failed_at = host.is_none().then_some(now); diff --git a/src/dev/ice1724/tests.rs b/src/dev/ice1724/tests.rs index 99ab91997..8052ca38f 100644 --- a/src/dev/ice1724/tests.rs +++ b/src/dev/ice1724/tests.rs @@ -17,7 +17,7 @@ use crate::dev::host_pcm::test_time::{FakeNs, TestTime}; use crate::dev::host_pcm::PcmOut; use crate::host_audio::test_signal::{a_tone_survives_a_pause, channel, peak, Backend, CardUnderTest, Heard}; use crate::host_audio::Meter; -use crate::host_audio::{FrameResampler, HostOut}; +use crate::host_audio::{HostOut, Resampler}; use crate::irq::LevelProbe; use crate::pci::{PciDma, PciDmaStatus, PCI_COMMAND_IO}; @@ -819,7 +819,7 @@ fn a_stopped_engine_adds_nothing() { fn detached_host(r: &Rig, rate: u32, channels: usize) -> rtrb::Consumer { let (p, c) = rtrb::RingBuffer::::new(1 << 16); let out = HostOut::detached_channels(r.meter.clone(), rate, channels, p); - let rs = FrameResampler::new(48000, rate, channels); + let rs = Resampler::new(48000, rate, channels); *r.sink.lock() = Some(PcmOut::new(Some((out, rs)), None)); c } @@ -874,7 +874,7 @@ fn a_full_host_ring_counts_dropped_frames() { let r = rig(Variant::Dm8); let (p, _c) = rtrb::RingBuffer::::new(20); let out = HostOut::detached_channels(r.meter.clone(), 48000, 8, p); - *r.sink.lock() = Some(PcmOut::new(Some((out, FrameResampler::new(48000, 48000, 8))), None)); + *r.sink.lock() = Some(PcmOut::new(Some((out, Resampler::new(48000, 48000, 8))), None)); fill_slot_numbers(&r, 8); mt_wl(&r, 0x10, BUFFER as u32); mt_ww(&r, 0x14, 959); @@ -1511,7 +1511,7 @@ fn audio_status_names_the_host_stream_and_underruns_count_only_while_playing() { let r = rig(Variant::Dm8); let (prod, mut cons) = rtrb::RingBuffer::::new(64); let out = HostOut::detached(r.meter.clone(), 48000, prod); - let rs = FrameResampler::new(48000, 48000, 2); + let rs = Resampler::new(48000, 48000, 2); *r.sink.lock() = Some(PcmOut::new(Some((out, rs)), None)); let text = r.c.audio_report().unwrap(); assert!(text.contains("host output: test at 48000 Hz, 2 channels"), "{text}"); @@ -1539,7 +1539,7 @@ fn pausing_the_machine_ends_playing() { let r = rig(Variant::Dm8); let (prod, mut cons) = rtrb::RingBuffer::::new(64); let out = HostOut::detached(r.meter.clone(), 48000, prod); - *r.sink.lock() = Some(PcmOut::new(Some((out, FrameResampler::new(48000, 48000, 2))), None)); + *r.sink.lock() = Some(PcmOut::new(Some((out, Resampler::new(48000, 48000, 2))), None)); fill_playback_buffer(&r); program_playback(&r); mt_wb(&r, 0x18, 0x01); diff --git a/src/dev/mace_audio.rs b/src/dev/mace_audio.rs index 68bea0937..548b8e24f 100644 --- a/src/dev/mace_audio.rs +++ b/src/dev/mace_audio.rs @@ -628,23 +628,24 @@ impl MaceAudio { if pcm[out].is_empty() { continue; } - let rs = p.rs[out].get_or_insert_with(|| Resampler::new(rates[out], host)); + let rs = p.rs[out].get_or_insert_with(|| Resampler::new(rates[out], host, 2)); if rs.in_rate != rates[out] { - *rs = Resampler::new(rates[out], host); - } - // The resampler writes into a ring; take its output back out. - let (mut prod, mut cons) = rtrb::RingBuffer::::new(pcm[out].len() * 2 * (host as usize / rates[out] as usize + 2) + 8); - for &(l, r) in &pcm[out] { - let _ = rs.push(l, r, &mut prod); // the scratch ring has room: nothing is refused + *rs = Resampler::new(rates[out], host, 2); } + // Outputs are added by position: where one is shorter than the + // other the longer plays alone. let mut k = 0; - while let Ok(v) = cons.pop() { - if k < mixed.len() { - mixed[k] += v as i32; - } else { - mixed.push(v as i32); - } - k += 1; + for &(l, r) in &pcm[out] { + rs.push(&[l, r], |f| { + for &v in f { + if k < mixed.len() { + mixed[k] += v as i32; + } else { + mixed.push(v as i32); + } + k += 1; + } + }); } } push_mixed(&mut p.out, &mixed); @@ -803,6 +804,50 @@ mod tests { a.read(MACE_AUDIO + 0x18) as u16 } + /// What reaches the host ring from MACE's two outputs at different + /// rates, mixed by position, is fixed. Passes where one output has + /// nothing, and outputs of different lengths, are part of it. + #[test] + fn the_two_outputs_mix_to_a_fixed_output() { + use crate::host_audio::test_signal::*; + const BURSTS: [usize; 6] = [480, 1, 37, 1024, 2, 255]; + for (tight, run) in [(false, "free"), (true, "tight")] { + let mut g = Golden::new(&format!("mace/{run}")); + let (slots, every) = golden_ring(tight, Push::Burst); + for (inr, outr) in golden_pairs() { + let other = golden_other_rate(inr); + for (s0, s1) in golden_signals(inr).into_iter().zip(golden_signals(other)) { + let (p, mut c) = rtrb::RingBuffer::::new(slots); + let out = HostOut::detached(Meter::new("test", &[]), outr, p); + let meter = out.meter.clone(); + let a = MaceAudio::new(); + *a.player.lock() = Some(Player { out, rs: [None, None] }); + let (mut at0, mut at1) = (0, 0); + for (i, &n) in BURSTS.iter().cycle().enumerate() { + if at0 >= s0.len() && at1 >= s1.len() { + break; + } + let n1 = n * other as usize / inr as usize + 1; + let take = |s: &[(i16, i16)], at: &mut usize, n: usize| { + let v = s[(*at).min(s.len())..(*at + n).min(s.len())].to_vec(); + *at += n; + v + }; + let p0 = if i % 7 == 5 { vec![] } else { take(&s0, &mut at0, n) }; + let p1 = if i % 5 == 3 { vec![] } else { take(&s1, &mut at1, n1) }; + a.play(&[p0, p1], &[inr, other]); + if i % every == 0 { + g.samples(&golden_drain(&mut c)); + } + } + g.samples(&golden_drain(&mut c)); + g.dropped(meter.dropped()); + } + } + g.check(); + } + } + #[test] fn a_pair_that_does_not_fit_is_dropped_whole() { // Three slots: the first pair fits and leaves one, which the left diff --git a/src/dev/rad1.rs b/src/dev/rad1.rs index 98fc705ee..60923c26e 100644 --- a/src/dev/rad1.rs +++ b/src/dev/rad1.rs @@ -805,7 +805,7 @@ impl Inner { let host = (self.open_host)(self, &cfg); crate::dlog!(LogModule::Rad1, "rad1: host audio open took {:.1} ms ({})", now.elapsed().as_secs_f64() * 1e3, if host.is_some() { "open" } else { "failed" }); let host = host.map(|out| { - let rs = Resampler::new(FREQ_ANALOG as u32, out.stream_rate); + let rs = Resampler::new(FREQ_ANALOG as u32, out.stream_rate, 2); (out, rs) }); let failed_at = host.is_none().then_some(now); diff --git a/src/dev/rad1/tests.rs b/src/dev/rad1/tests.rs index 6177cc3a3..0000a6578 100644 --- a/src/dev/rad1/tests.rs +++ b/src/dev/rad1/tests.rs @@ -1469,7 +1469,7 @@ fn the_mediaio_card_plays_through_its_0x88_and_0x8c_writes() { r.state().connect_dma(ram.clone()); // A 44.1 kHz host stream, so the ring's samples arrive unresampled. let (p, mut host) = rtrb::RingBuffer::new(44_100); - let host_out = (HostOut::detached(r.meter.clone(), 44_100, p), Resampler::new(44_100, 44_100)); + let host_out = (HostOut::detached(r.meter.clone(), 44_100, p), Resampler::new(44_100, 44_100, 2)); *r.sink.lock() = Some(Sink { host: Some(host_out), host_failed_at: None, dump: None, tap: None }); program(&r, &RING_OUTPUT, 0x10_0000, 0xffff_f008); wr(&r, 0x58, 0x2000_0001); @@ -1699,7 +1699,7 @@ fn audio_status_names_the_mediaio_card_and_its_host_stream() { let r = Rad1::mediaio(); let (prod, _cons) = rtrb::RingBuffer::::new(64); let out = HostOut::detached(r.meter.clone(), 48000, prod); - *r.inner.sink.lock() = Some(Sink { host: Some((out, Resampler::new(44100, 48000))), host_failed_at: None, dump: None, tap: None }); + *r.inner.sink.lock() = Some(Sink { host: Some((out, Resampler::new(44100, 48000, 2))), host_failed_at: None, dump: None, tap: None }); *r.inner.audio_cfg.lock() = Some(AudioConfig::default()); let text = r.audio_report().unwrap(); assert!(text.starts_with("audio: RAD1 (MediaIO)\n"), "{text}"); @@ -1714,7 +1714,7 @@ fn playing_card() -> (Rad1, Box u64>) { r.armed.store(true, Ordering::SeqCst); let (prod, cons) = rtrb::RingBuffer::::new(4096); let out = HostOut::detached(r.meter.clone(), 44100, prod); - *r.sink.lock() = Some(Sink { host: Some((out, Resampler::new(44100, 44100))), host_failed_at: None, dump: None, tap: None }); + *r.sink.lock() = Some(Sink { host: Some((out, Resampler::new(44100, 44100, 2))), host_failed_at: None, dump: None, tap: None }); let (meter, cons, _ram) = (r.meter.clone(), std::sync::Mutex::new(cons), ram); // Drain what the card wrote, then let the host find one frame of silence. let underrun = Box::new(move || { diff --git a/src/host_audio.rs b/src/host_audio.rs index 69622af3a..1bae0e880 100644 --- a/src/host_audio.rs +++ b/src/host_audio.rs @@ -78,7 +78,7 @@ impl Meter { } } -/// A host output stream: i16 stereo pairs pushed into `producer` play at +/// A host output stream: i16 frames pushed into `producer` play at /// `stream_rate`, and the stream plays silence when the ring is empty. pub struct HostOut { pub stream_rate: u32, @@ -98,12 +98,15 @@ pub struct HostOut { } impl HostOut { - /// Push one input pair through `rs`, counting the frames the ring refused. - pub fn push(&mut self, rs: &mut Resampler, l: i16, r: i16) { + /// Push one input frame through `rs`, counting the frames the ring refused. + pub fn push(&mut self, rs: &mut Resampler, frame: &[i16]) { self.meter.started(); - let refused = rs.push(l, r, &mut self.producer); + let mut refused = 0u64; + // A frame the ring has no room for is dropped whole, or the channels + // of every later frame would shift. + rs.push(frame, |f| refused += u64::from(!push_frame(&mut self.producer, f))); if refused != 0 { - self.meter.dropped.fetch_add(refused as u64, Ordering::Relaxed); + self.meter.dropped.fetch_add(refused, Ordering::Relaxed); } } @@ -142,10 +145,8 @@ impl HostOut { unsafe impl Send for HostOut {} unsafe impl Sync for HostOut {} -// Simple skip/repeat resampler using a fixed-point accumulator. -// Produces output at `out_rate` from input at `in_rate`. -// Call `push_sample` for every input sample pair; it pushes 0, 1, or 2 pairs to the ring. -/// Converts the codec's rate to the host stream's by Catmull-Rom interpolation. +/// Converts a device's rate to the host stream's by Catmull-Rom +/// interpolation, for frames of up to [`Resampler::MAX_CHANNELS`] channels. /// /// It used to repeat the last sample (zero-order hold). From 44.1 or 48 kHz /// to 48 kHz that is nearly harmless, but Quake plays at 11025 Hz, where each @@ -156,42 +157,52 @@ unsafe impl Sync for HostOut {} pub struct Resampler { pub in_rate: u32, pub out_rate: u32, + channels: usize, // Position of the next output between h[1] and h[2], in 1/out_rate // input-sample units: an exact rational step, so no drift. acc: u64, // The last four input frames, oldest first; output is interpolated // between h[1] and h[2] (two input samples of latency). - h: [[f32; 2]; 4], + h: [[f32; Self::MAX_CHANNELS]; 4], } impl Resampler { - pub fn new(in_rate: u32, out_rate: u32) -> Self { - Self { in_rate, out_rate, acc: 0, h: [[0.0; 2]; 4] } + pub const MAX_CHANNELS: usize = 8; + + pub fn new(in_rate: u32, out_rate: u32, channels: usize) -> Self { + debug_assert!((1..=Self::MAX_CHANNELS).contains(&channels)); + Self { in_rate, out_rate, channels, acc: 0, h: [[0.0; Self::MAX_CHANNELS]; 4] } } pub fn passthrough(&self) -> bool { self.in_rate == self.out_rate } - /// Push one input stereo pair; emits the output pairs it completes. A - /// pair the ring has no room for is dropped whole, or the channels of - /// every later pair would swap. Returns how many pairs were dropped. - pub fn push(&mut self, l: i16, r: i16, prod: &mut Producer) -> usize { + /// Push one input frame; calls `emit` with each output frame it + /// completes, in order. The caller decides what a frame it has no room + /// for costs. + #[inline] + pub fn push(&mut self, frame: &[i16], mut emit: impl FnMut(&[i16])) { + let ch = self.channels; + debug_assert_eq!(frame.len(), ch); if self.passthrough() { - return usize::from(!push_frame(prod, &[l, r])); + emit(frame); + return; + } + for (c, &v) in frame.iter().enumerate() { + let h = &mut self.h; + (h[0][c], h[1][c], h[2][c], h[3][c]) = (h[1][c], h[2][c], h[3][c], v as f32); } - let mut dropped = 0; - self.h = [self.h[1], self.h[2], self.h[3], [l as f32, r as f32]]; let out = self.out_rate as u64; + let mut at = [0i16; Self::MAX_CHANNELS]; while self.acc < out { let t = self.acc as f32 / out as f32; - let [l, r] = [0, 1].map(|c| { + for (c, o) in at[..ch].iter_mut().enumerate() { let v = catmull_rom(self.h[0][c], self.h[1][c], self.h[2][c], self.h[3][c], t); - v.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16 - }); - dropped += usize::from(!push_frame(prod, &[l, r])); + *o = v.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16; + } + emit(&at[..ch]); self.acc += self.in_rate as u64; } self.acc -= out; - dropped } } @@ -240,54 +251,6 @@ pub fn pop_frames(c: &mut Consumer, channels: usize, out: &mut [T], mut missed } -/// [`Resampler`] for frames of up to [`FrameResampler::MAX_CHANNELS`] -/// channels, appending to a vector instead of a ring so the caller can mix -/// before it writes. The same Catmull-Rom interpolation and exact rational -/// step. -pub struct FrameResampler { - pub in_rate: u32, - pub out_rate: u32, - channels: usize, - acc: u64, - h: [[f32; Self::MAX_CHANNELS]; 4], -} - -impl FrameResampler { - pub const MAX_CHANNELS: usize = 8; - - pub fn new(in_rate: u32, out_rate: u32, channels: usize) -> Self { - debug_assert!((1..=Self::MAX_CHANNELS).contains(&channels)); - Self { in_rate, out_rate, channels, acc: 0, h: [[0.0; Self::MAX_CHANNELS]; 4] } - } - - pub fn passthrough(&self) -> bool { self.in_rate == self.out_rate } - - /// Push one input frame; appends the output frames it completes. - pub fn push(&mut self, frame: &[i16], out: &mut Vec) { - let ch = self.channels; - debug_assert_eq!(frame.len(), ch); - if self.passthrough() { - out.extend_from_slice(frame); - return; - } - let mut last = [0.0; Self::MAX_CHANNELS]; - for (d, &v) in last.iter_mut().zip(frame) { - *d = v as f32; - } - self.h = [self.h[1], self.h[2], self.h[3], last]; - let outr = self.out_rate as u64; - while self.acc < outr { - let t = self.acc as f32 / outr as f32; - for c in 0..ch { - let v = catmull_rom(self.h[0][c], self.h[1][c], self.h[2][c], self.h[3][c], t); - out.push(v.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16); - } - self.acc += self.in_rate as u64; - } - self.acc -= outr; - } -} - /// The Catmull-Rom spline through p1 and p2 at t in [0, 1). fn catmull_rom(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 { let t2 = t * t; @@ -1132,6 +1095,144 @@ pub(crate) mod test_signal { (0..frames).flat_map(|n| [at(left, n), at(right, n)]).collect() } + // ---- fixed output of the resamplers ---- + // + // Every card's host output goes through one Catmull-Rom resampler, and + // the interpolation, the exact rational step and what a refused frame + // does to the state are part of what a guest hears. These run it on + // fixed signals at the cards' own rates and push sizes, and compare a + // hash of what reaches the host ring with a recorded one. On a + // mismatch the failure prints the new hash. + + /// Guest rates the cards play at, and the host rates they play to. + const GOLDEN_RATES: [u32; 6] = [8000, 11025, 22050, 32000, 44100, 48000]; + const GOLDEN_HOSTS: [u32; 2] = [48000, 44100]; + + /// Every guest rate to every host rate. + pub(crate) fn golden_pairs() -> Vec<(u32, u32)> { + GOLDEN_RATES.iter().flat_map(|&i| GOLDEN_HOSTS.iter().map(move |&o| (i, o))).collect() + } + + /// A guest rate other than `rate`, for a card's second output. + pub(crate) fn golden_other_rate(rate: u32) -> u32 { + let i = GOLDEN_RATES.iter().position(|&r| r == rate).unwrap(); + GOLDEN_RATES[(i + 1) % GOLDEN_RATES.len()] + } + + /// Three stereo signals at `rate`: two tones, a full-scale square pair, + /// and fixed pseudo-random noise over the whole 16-bit range. + pub(crate) fn golden_signals(rate: u32) -> [Vec<(i16, i16)>; 3] { + const FRAMES: usize = 2400; + let tone = tones(FRAMES, rate, 1000.0, 440.0, 12000.0); + let tone: Vec<(i16, i16)> = tone.chunks_exact(2).map(|f| (f[0], f[1])).collect(); + let half = (rate as usize / 700).max(2); + let square: Vec<(i16, i16)> = (0..FRAMES) + .map(|n| { + let hi = (n / half) % 2 == 0; + if hi { (i16::MAX, i16::MIN) } else { (i16::MIN, i16::MAX) } + }) + .collect(); + let mut x = 0x2545_f491_4f6c_dd1du64; + let mut next = move || { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + (x >> 24) as i16 + }; + let noise: Vec<(i16, i16)> = (0..FRAMES).map(|_| (next(), next())).collect(); + [tone, square, noise] + } + + /// How a card hands frames over: HAL2 one at a time, the others in + /// bursts of every size a timer pass produces. + #[derive(Clone, Copy)] + pub(crate) enum Push { + One, + Burst, + } + + /// The signal's frames in the chunks `mode` pushes. + pub(crate) fn golden_chunks(sig: &[(i16, i16)], mode: Push) -> Vec<&[(i16, i16)]> { + const BURSTS: [usize; 6] = [480, 1, 37, 1024, 2, 255]; + match mode { + Push::One => sig.chunks(1).collect(), + Push::Burst => { + let mut left = sig; + let mut chunks = Vec::new(); + for n in BURSTS.iter().cycle() { + if left.is_empty() { + break; + } + let (a, b) = left.split_at((*n).min(left.len())); + chunks.push(a); + left = b; + } + chunks + } + } + } + + /// The ring a golden run plays into: roomy, or so small that bursts and + /// long runs of single frames are refused and the resampler's state has + /// to carry on past the refusals. Returns the slots and how many chunks + /// go in between drains. + pub(crate) fn golden_ring(tight: bool, mode: Push) -> (usize, usize) { + match (tight, mode) { + (false, _) => (1 << 16, 1), + (true, Push::One) => (41, 40), + (true, Push::Burst) => (41, 1), + } + } + + pub(crate) fn golden_drain(c: &mut rtrb::Consumer) -> Vec { + std::iter::from_fn(|| c.pop().ok()).collect() + } + + /// The expected hash of each run: blake3 over the samples that reached + /// the ring, little-endian, then the count of frames the ring refused. + const GOLDEN: &[(&str, &str)] = &[ + ("hal2-burst/free", "b73d8d57bf0cdc4c127e3d92a45f28f7154aa5e6cab39e119a366cded97db91b"), + ("hal2-burst/tight", "0e3876237d2111978c9622aa80a129603045ef343e2e82e5ca522ef448d96021"), + ("hal2-one/free", "b73d8d57bf0cdc4c127e3d92a45f28f7154aa5e6cab39e119a366cded97db91b"), + ("hal2-one/tight", "306c922fa5761d2f6bd1497eee9692818ed7b98b69c47f3bf8e45ea886a47ecf"), + ("ice1724-2ch/free", "069713fd9182a83f7f665a33c71f1601d7e510e82e62abfef880b3bcadfbc0ce"), + ("ice1724-2ch/tight", "c4b80bbdad6ec28ab8b2bb7eab1a92e11ac802e3fe1977d6948322ba9d3e6467"), + ("ice1724-8ch/free", "dbf99ff8637721c3612f77bc25a6f7c3e2d2714719cb8a5cb65126cfc5f68199"), + ("ice1724-8ch/tight", "8798890bbb7f62ca812c39b5839597fff317a0afbc9c9f77a1e536fe4cf2720f"), + ("mace/free", "d05b164c161074315cf40e3341e1d860cd98cca12dfcc54b33f917c46dad153a"), + ("mace/tight", "07354a70346446581d190a906e78d1ac3efb7aa84390537dde4cb7c6ca00ab13"), + ("rad1-sink/free", "b73d8d57bf0cdc4c127e3d92a45f28f7154aa5e6cab39e119a366cded97db91b"), + ("rad1-sink/tight", "0e3876237d2111978c9622aa80a129603045ef343e2e82e5ca522ef448d96021"), + ]; + + /// What reached the host ring over a whole fixed-output run. + pub(crate) struct Golden { + name: String, + hasher: blake3::Hasher, + } + + impl Golden { + pub(crate) fn new(name: &str) -> Self { + Self { name: name.to_string(), hasher: blake3::Hasher::new() } + } + + pub(crate) fn samples(&mut self, v: &[i16]) { + for s in v { + self.hasher.update(&s.to_le_bytes()); + } + } + + pub(crate) fn dropped(&mut self, n: u64) { + self.hasher.update(&n.to_le_bytes()); + } + + pub(crate) fn check(self) { + let got = self.hasher.finalize().to_hex().to_string(); + let want = GOLDEN.iter().find(|(n, _)| *n == self.name).map(|(_, h)| *h); + assert_eq!(Some(got.as_str()), want, "{}: the output changed", self.name); + } + } + /// A WAV's rate, channels and samples. Only the 16-bit PCM this crate /// writes. pub(crate) fn read_wav(path: &Path) -> (u32, usize, Vec) { @@ -1250,16 +1351,18 @@ mod tests { fn a_full_ring_drops_whole_pairs_and_keeps_the_channels() { for (in_rate, out_rate) in [(44100, 44100), (22050, 44100)] { // Odd capacity: the ring fills with one slot left over. - let (mut p, mut c) = RingBuffer::::new(5); - let mut rs = Resampler::new(in_rate, out_rate); + let (p, mut c) = RingBuffer::::new(5); + let mut out = HostOut::detached(playing_meter(), out_rate, p); + let mut rs = Resampler::new(in_rate, out_rate, 2); for _ in 0..2 { for _ in 0..8 { - rs.push(1, -1, &mut p); + out.push(&mut rs, &[1, -1]); } let got = drain(&mut c); assert!(got.len() >= 2 && got.len() % 2 == 0, "{in_rate}: whole pairs only, got {got:?}"); assert!(got.chunks(2).all(|q| q == [0, 0] || q[0] > 0 && q[1] < 0), "{in_rate}: channels swapped in {got:?}"); } + assert!(out.meter.dropped() > 0, "{in_rate}: the refusals are counted"); } } @@ -1271,32 +1374,85 @@ mod tests { assert!(at(2, PREFERRED_RATES[0]) < at(8, PREFERRED_RATES[1])); } + /// Every output frame `rs` makes from `frames`, flattened. + fn resample_all(rs: &mut Resampler, frames: impl IntoIterator>) -> Vec { + let mut out = Vec::new(); + for f in frames { + rs.push(&f, |o| out.extend_from_slice(o)); + } + out + } + + /// Output frames from `n_frames` stereo input frames. + fn resample(in_rate: u32, out_rate: u32, n_frames: usize) -> usize { + let mut rs = Resampler::new(in_rate, out_rate, 2); + resample_all(&mut rs, (0..n_frames).map(|i| vec![i as i16, i as i16])).len() / 2 + } + + #[test] + fn resampler_passthrough() { + // 1:1 — every input frame produces exactly one output frame + assert_eq!(resample(44100, 44100, 1000), 1000); + } + + #[test] + fn resampler_downsample_2x() { + // 44100 → 22050: every 2 inputs → 1 output, so 1000 in → 500 out + assert_eq!(resample(44100, 22050, 1000), 500); + } + #[test] - fn the_frame_resampler_matches_the_stereo_one_on_two_channels() { - for (inr, outr) in [(44100u32, 48000u32), (48000, 44100), (11025, 44100), (8000, 48000)] { - let (mut prod, mut cons) = RingBuffer::::new(1 << 16); - let mut stereo = Resampler::new(inr, outr); - let mut frames = FrameResampler::new(inr, outr, 2); - let mut want = Vec::new(); - for i in 0..400i32 { - let (l, r) = ((i * 97 % 20000 - 10000) as i16, (i * 53 % 30000 - 15000) as i16); - stereo.push(l, r, &mut prod); - frames.push(&[l, r], &mut want); + fn resampler_upsample_2x() { + // 22050 → 44100: every input → 2 outputs, so 1000 in → 2000 out + assert_eq!(resample(22050, 44100, 1000), 2000); + } + + #[test] + fn resampler_upsample_44100_to_48000() { + // 44100 → 48000 over one second of audio + assert_eq!(resample(44100, 48000, 44100), 48000); + } + + #[test] + fn resampler_downsample_48000_to_44100() { + // 48000 → 44100 over one second of audio + assert_eq!(resample(48000, 44100, 48000), 44100); + } + + /// A 1 kHz tone at 11025 Hz, resampled to 48 kHz, against the ideal + /// sine at the best-fitting delay. Repeating samples (the old resampler) + /// misses by about a fifth of the amplitude; interpolation must be close. + #[test] + fn resampler_11025_to_48000_follows_the_waveform() { + let (inr, outr, f, amp) = (11025u32, 48000u32, 1000.0f64, 16000.0f64); + let n = 11025; + let mut rs = Resampler::new(inr, outr, 2); + let out = resample_all(&mut rs, (0..n).map(|k| { + let v = (amp * (2.0 * std::f64::consts::PI * f * k as f64 / inr as f64).sin()) as i16; + vec![v, v] + })); + let left: Vec = out.iter().step_by(2).map(|&v| v as f64).collect(); + let body = &left[1000..left.len() - 1000]; + let mut best = f64::MAX; + for step in 0..400 { + let delay = step as f64 * 0.01; // input samples + let mut err = 0.0; + for (j, v) in body.iter().enumerate() { + let t = (j + 1000) as f64 / outr as f64 - delay / inr as f64; + let want = amp * (2.0 * std::f64::consts::PI * f * t).sin(); + err += (v - want) * (v - want); } - let got: Vec = std::iter::from_fn(|| cons.pop().ok()).collect(); - assert_eq!(got, want, "{inr}->{outr}"); + best = best.min((err / body.len() as f64).sqrt() / amp); } + assert!(best < 0.03, "RMS error {:.3} of the amplitude", best); } #[test] - fn frames_keep_their_channels_through_the_frame_resampler() { + fn frames_keep_their_channels_through_the_resampler() { for (inr, outr, per_in) in [(48000u32, 48000u32, 1usize), (24000, 48000, 2), (48000, 24000, 0)] { - let mut rs = FrameResampler::new(inr, outr, 6); - let mut out = Vec::new(); - let frame = [100i16, -200, 300, -400, 500, -600]; - for _ in 0..64 { - rs.push(&frame, &mut out); - } + let mut rs = Resampler::new(inr, outr, 6); + let frame = vec![100i16, -200, 300, -400, 500, -600]; + let out = resample_all(&mut rs, (0..64).map(|_| frame.clone())); assert_eq!(out.len() % 6, 0); let frames = out.len() / 6; if per_in > 0 {