From 4ec293911f3166ed2e3cc5938ed4959f9dfbf167 Mon Sep 17 00:00:00 2001 From: James Braid Date: Thu, 29 Oct 2026 10:14:16 -0700 Subject: [PATCH 1/4] rad1: run the analog channels at the clock generators' rates The card ran its analog input and output at a fixed 44.1 kHz and stored the clock generator words without reading them. IRIX sets the output rate through those words for every program that plays sound, and keeps the last rate when the program exits. A 48 kHz sound, the rate IRIX boots with, played 9% slow and flat; an 11025 or 22050 Hz game played wrong, and so did every sound after it until something set 44.1 kHz again. That is techomancer/iris#354. The decode follows the public Linux RAD1 driver: 0xa0 selects generators from bit 28 and holds the divisor less one, 0xa4 holds the remainder and its denominator, and each generator's word in 0xa8..0xb4 picks the 176.4 or 192 kHz master. Generator 0 clocks the analog input and 1 the output. A rate change on a running channel takes effect from the write. The WAV capture stays 44.1 kHz, converted from the card's rate, and the host stream's resampler follows the rate. The tests hold the words IRIX 6.5.30 writes for 8 to 48 kHz. A 2 s 1 kHz tone at each of 7 rates, mono and stereo, 8 and 16 bit, now plays for 2.0 s at 1000 Hz through playaiff and sfplay; before, only 44.1 kHz did. --- CHANGELOG.md | 3 + src/dev/rad1.rs | 164 ++++++++++++++++++++++++++++++++++++++---- src/dev/rad1/tests.rs | 107 +++++++++++++++++++++++++++ 3 files changed, 260 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b6fdb4ca..2d0b60793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -150,6 +150,9 @@ hashes are given where a change is easiest to understand by reading the commit. produced. (`src/ci.rs`, `src/gfx_display.rs`) ### SGI Octane (IP30) +- **Octane audio plays at the rate the guest asks for.** RAD audio ran at + 44.1 kHz whatever IRIX programmed, so games and other non-44.1 kHz sounds + played at the wrong speed. ([#354](https://github.com/techomancer/iris/issues/354)) - **IMPACT on the Octane passes the GL pixel and state suites.** On IRIX 6.5.22 pixtest went from 52 to 366 passing cases (the two left fail on the Indigo2 too) and glstate from 393 to 394. The Octane's microcode keeps the diff --git a/src/dev/rad1.rs b/src/dev/rad1.rs index f9e1e4af3..d7d4cf2ee 100644 --- a/src/dev/rad1.rs +++ b/src/dev/rad1.rs @@ -17,6 +17,9 @@ //! store what was written and return it. No reset values or masks are //! established beyond zero; the few writes the model refuses (see //! [`Rad1::write`]) are refused because no supported guest makes them. +//! * The analog input and output run at the rates their clock generators +//! (0 and 1) are programmed for; see [`Rate`]. Until a guest programs +//! one, its rate is 44.1 kHz: the power-on rate is unknown. //! * The engines move frames at the sample rate. Frames are moved when //! something looks (a position or status read, a status completion) and, //! while the analog output runs with host audio enabled, by a 10 ms timer, @@ -67,6 +70,10 @@ const REG_AES_INPUT_CTRL: u32 = 0x48; const REG_ANALOG_INPUT_CTRL: u32 = 0x4c; const REG_ANALOG_OUT_EN: u32 = 0x58; const REG_STATUS_DMA_CTRL: u32 = 0x5c; +const REG_CLOCKGEN_CTRL: u32 = 0xa0; +const REG_CLOCKGEN_REM: u32 = 0xa4; +/// Four master-clock selects, one per clock generator, generator 3 first. +const REG_CLOCKGEN_MUX_FIRST: u32 = 0xa8; const POS_FIRST: u32 = 0x04; const POS_LAST: u32 = 0x2c; @@ -113,7 +120,10 @@ const STATUS_STOP: u32 = 0x1fffc; const UST_NS_PER_TICK: i64 = 960; const NS_PER_SEC: u64 = 1_000_000_000; +/// The rate of a clock generator no guest has programmed. const FREQ_ANALOG: u64 = 44100; +/// The WAV capture's rate. +const WAV_RATE: u32 = 44100; const FREQ_AES_SUB: u64 = 48000 / 192; /// Ring bytes moved per DMA access; a whole number of frames of any type. const CHUNK_BYTES: u64 = 1024; @@ -180,9 +190,68 @@ fn pcm_to_frames(pcm: &[i16], ring: &mut [u8], words_per_frame: usize) { } } +/// Clock generators: 0 clocks the analog input, 1 the analog output, 2 the +/// AES3 output. Generator 3 is not modelled. +const NUM_CG: usize = 4; +/// The control word's generator selects, one bit each from bit 28. +const CG_SELECT_SHIFT: u32 = 28; +/// The control word's divisor less one; bits 9 and 10 above it are flags. +const CG_DIV_MASK: u32 = 0x1ff; +/// The two master clocks a generator divides, each four times its family's +/// base rate. +const MASTER_44K: u64 = 176_400; +const MASTER_48K: u64 = 192_000; + +/// A sample rate, `num / den` frames per second. +/// +/// A generator divides its master clock by `div + rem / den`. The decode is +/// the public Linux RAD1 driver's (Stanislaw Skowronek's `snd_rad1_set_cg`): +/// the control word at 0xa0 holds the generator select from bit 28 and +/// `div - 1` in its low bits, the word at 0xa4 holds `rem << 16 | (0x10000 - +/// den)`, and the generator's word in 0xa8..0xb4 picks the master: bit 0 set +/// for 192 kHz, clear for 176.4 kHz. IRIX 6.5.30 programs its rates from 8 +/// to 48 kHz with words of the same form, all from the 192 kHz master (the +/// tests hold the words it writes). What the flag bits 9 and 10 and the +/// other select values do is unknown; they are ignored. The new rate takes +/// effect at the control word. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Rate { + num: u64, + den: u64, +} + +impl Rate { + const fn hz(hz: u64) -> Self { + Self { num: hz, den: 1 } + } + + fn from_clockgen(ctrl: u32, rem_word: u32, mux: u32) -> Self { + let master = if mux & 1 != 0 { MASTER_48K } else { MASTER_44K }; + let div = u64::from(ctrl & CG_DIV_MASK) + 1; + let den = 0x1_0000 - u64::from(rem_word & 0xffff); + let rem = u64::from(rem_word >> 16); + Self { num: master * den, den: div * den + rem } + } + + /// Whole frames the clock has produced in `ns`. + fn frames_in(self, ns: i64) -> u64 { + (ns as u128 * u128::from(self.num) / (u128::from(self.den) * u128::from(NS_PER_SEC))) as u64 + } + + /// When frame `frames` ends, in ns from the clock's start. + fn ns_for(self, frames: u64) -> i64 { + (u128::from(frames) * u128::from(self.den) * u128::from(NS_PER_SEC)).div_ceil(u128::from(self.num)) as i64 + } + + /// The nearest whole rate, for the host stream. + fn rounded_hz(self) -> u32 { + ((self.num + self.den / 2) / self.den) as u32 + } +} + /// Where decoded output goes: the host stream (through the shared resampler), -/// an optional WAV file of the 44.1 kHz stream the card produced, and for -/// tests a plain buffer. +/// an optional 44.1 kHz WAV file of what the card played, and for tests a +/// plain buffer of the card's own frames. struct Sink { host: Option<(HostOut, Resampler)>, wav: Option, @@ -190,14 +259,18 @@ struct Sink { } impl Sink { - fn play(&mut self, pcm: &[i16]) { + /// Play stereo frames the card produced at `rate` Hz. + fn play(&mut self, pcm: &[i16], rate: u32) { if let Some((out, rs)) = self.host.as_mut() { + if rs.in_rate != rate { + *rs = Resampler::new(rate, out.stream_rate); + } for p in pcm.chunks_exact(PCM_CHANNELS) { rs.push(p[0], p[1], &mut out.producer); } } if let Some(w) = self.wav.as_mut() { - w.append(pcm); + w.play(pcm, rate); } if let Some(t) = self.tap.as_ref() { t.lock().extend_from_slice(pcm); @@ -205,23 +278,46 @@ impl Sink { } } -/// A 44.1 kHz stereo 16-bit WAV, its sizes rewritten after every append so a -/// file from a killed run still plays. A debugging aid: `IRIS_OCTANE_AUDIO_WAV`. +/// A 44.1 kHz stereo 16-bit WAV of the output, converted from the card's +/// rate, its sizes rewritten after every append so a file from a killed run +/// still plays. A debugging aid: `IRIS_OCTANE_AUDIO_WAV`. struct Wav { file: std::fs::File, data_bytes: u32, + rs: Resampler, } impl Wav { fn create(path: &std::ffi::OsStr) -> Option { - let mut w = Self { file: std::fs::File::create(path).ok()?, data_bytes: 0 }; + let file = std::fs::File::create(path).ok()?; + let mut w = Self { file, data_bytes: 0, rs: Resampler::new(WAV_RATE, WAV_RATE) }; w.header().ok()?; Some(w) } + /// Append frames the card produced at `rate` Hz. + fn play(&mut self, pcm: &[i16], rate: u32) { + if self.rs.in_rate != rate { + self.rs = Resampler::new(rate, WAV_RATE); + } + if self.rs.passthrough() { + self.append(pcm); + return; + } + // The resampler writes into a ring; take its output back out. + let frames = pcm.len() / PCM_CHANNELS; + let room = (frames * WAV_RATE as usize / rate.max(1) as usize + 2) * PCM_CHANNELS; + let (mut prod, mut cons) = rtrb::RingBuffer::::new(room); + for p in pcm.chunks_exact(PCM_CHANNELS) { + self.rs.push(p[0], p[1], &mut prod); + } + let out: Vec = std::iter::from_fn(|| cons.pop().ok()).collect(); + self.append(&out); + } + fn header(&mut self) -> std::io::Result<()> { use std::io::{Seek, SeekFrom, Write}; - let rate = FREQ_ANALOG as u32; + let rate = WAV_RATE; let mut h = Vec::with_capacity(44); h.extend_from_slice(b"RIFF"); h.extend_from_slice(&(36 + self.data_bytes).to_le_bytes()); @@ -266,6 +362,9 @@ struct ChanInfo { frame_bytes: u64, enable_bit: u32, output: bool, + /// The clock generator setting the rate; with none, `freq` is the + /// fixed rate in Hz. + clockgen: Option, freq: u64, /// Position register A and the shift of its frame count. pos_a: u32, @@ -286,17 +385,17 @@ const SLOT_PTR_UNUSED: [u64; 5] = [0x00, 0x08, 0x18, 0x28, 0x30]; const CHAN_INFO: [ChanInfo; NUM_CH] = [ ChanInfo { name: "analog input", ring: RING_INPUT, tag: 0, frame_bytes: 8, enable_bit: ENABLE_BIT, - output: false, freq: FREQ_ANALOG, pos_a: 0x14, pos_a_shift: 18, pos_b: 0x18, + output: false, clockgen: Some(0), freq: 0, pos_a: 0x14, pos_a_shift: 18, pos_b: 0x18, pos_b_shift: 18, pos_b_parent: false, slot_ptr: 0x10, }, ChanInfo { name: "analog output", ring: RING_OUTPUT, tag: 1, frame_bytes: 8, enable_bit: ENABLE_BIT, - output: true, freq: FREQ_ANALOG, pos_a: 0x2c, pos_a_shift: 18, pos_b: 0x18, + output: true, clockgen: Some(1), freq: 0, pos_a: 0x2c, pos_a_shift: 18, pos_b: 0x18, pos_b_shift: 4, pos_b_parent: false, slot_ptr: 0x38, }, ChanInfo { name: "AES3-input subcode", ring: RING_AES_SUB, tag: 6, frame_bytes: 256, - enable_bit: SUBCODE_ENABLE_BIT, output: false, freq: FREQ_AES_SUB, pos_a: 0x10, + enable_bit: SUBCODE_ENABLE_BIT, output: false, clockgen: None, freq: FREQ_AES_SUB, pos_a: 0x10, pos_a_shift: 4, pos_b: 0x10, pos_b_shift: 18, pos_b_parent: true, slot_ptr: 0x20, }, ]; @@ -321,6 +420,8 @@ struct State { status_dma_ctrl: u32, status_running: bool, chan: [Chan; NUM_CH], + /// Each clock generator's rate. + clock: [Rate; NUM_CG], ust_count: u32, ust_phase: i64, ust_enabled: bool, @@ -351,6 +452,7 @@ impl State { status_dma_ctrl: 0, status_running: false, chan: [Chan::default(); NUM_CH], + clock: [Rate::hz(FREQ_ANALOG); NUM_CG], ust_count: 0, ust_phase: 0, ust_enabled: false, @@ -376,6 +478,14 @@ impl State { self.ust_count.wrapping_add(ticks.div_euclid(UST_NS_PER_TICK) as u32) } + /// A channel's sample rate. + fn rate(&self, idx: usize) -> Rate { + match CHAN_INFO[idx].clockgen { + Some(cg) => self.clock[cg], + None => Rate::hz(CHAN_INFO[idx].freq), + } + } + fn pos(&mut self, reg: u32) -> &mut u32 { &mut self.sample_pos[((reg - POS_FIRST) / 4) as usize] } @@ -395,7 +505,7 @@ impl State { let c = self.chan[idx]; Self::add_field(self.pos(info.pos_a), info.pos_a_shift, frames); if info.pos_a_shift == 18 { - let boundary = c.start_ns + (u128::from(c.frames) * u128::from(NS_PER_SEC)).div_ceil(u128::from(info.freq)) as i64; + let boundary = c.start_ns + self.rate(idx).ns_for(c.frames); let ust = self.ust_at(boundary); let a = self.pos(info.pos_a); *a = (*a & !UST_LATCH_MASK) | (ust & UST_LATCH_MASK); @@ -828,6 +938,9 @@ impl Inner { self.enable_write(s, CH_AES_SUB, val, now); } s.startup[i] = val; + if off == REG_CLOCKGEN_CTRL { + self.clockgen_write(s, val, now); + } return Some(()); } } @@ -882,6 +995,29 @@ impl Inner { None } + /// A clock generator control word: each generator it selects takes the + /// rate the remainder and master-select words describe. A channel that + /// runs from one is brought up to `now` at the old rate first, and counts + /// on from there at the new one. + fn clockgen_write(&self, s: &mut State, ctrl: u32, now: i64) { + let rem = s.startup[Self::startup_index(REG_CLOCKGEN_REM).unwrap()]; + for cg in (0..NUM_CG).filter(|cg| ctrl & (1 << (CG_SELECT_SHIFT + *cg as u32)) != 0) { + let mux_reg = REG_CLOCKGEN_MUX_FIRST + 4 * (NUM_CG - 1 - cg) as u32; + let mux = s.startup[Self::startup_index(mux_reg).unwrap()]; + let rate = Rate::from_clockgen(ctrl, rem, mux); + for idx in (0..NUM_CH).filter(|&i| CHAN_INFO[i].clockgen == Some(cg)) { + self.sync_chan(s, idx, now); + let c = &mut s.chan[idx]; + if c.running { + c.start_ns = now; + c.frames = 0; + } + } + crate::dlog!(LogModule::Rad1, "rad1: clock generator {cg} at {:.3} Hz", rate.num as f64 / rate.den as f64); + s.clock[cg] = rate; + } + } + /// The status-DMA control word: stop, one-shot start, or ring start. fn status_ctrl_write(&self, s: &mut State, val: u32, now: i64) -> Option<()> { if val == STATUS_STOP { @@ -1132,7 +1268,7 @@ impl Inner { let samples = &mut pcm[..n as usize * PCM_CHANNELS]; frames_to_pcm(buf, samples, ANALOG_WORDS); if let Some(sink) = self.sink.lock().as_mut() { - sink.play(samples); + sink.play(samples, s.rate(idx).rounded_hz()); } } let c = &mut s.chan[idx]; @@ -1154,7 +1290,7 @@ impl Inner { if !c.running || now <= c.start_ns { return; } - let due = ((now - c.start_ns) as u128 * u128::from(CHAN_INFO[idx].freq) / u128::from(NS_PER_SEC)) as u64; + let due = s.rate(idx).frames_in(now - c.start_ns); if due > c.frames && !self.chan_move(s, idx, due - c.frames) { s.chan[idx].running = false; } diff --git a/src/dev/rad1/tests.rs b/src/dev/rad1/tests.rs index 07b61d288..3858a2282 100644 --- a/src/dev/rad1/tests.rs +++ b/src/dev/rad1/tests.rs @@ -439,6 +439,113 @@ fn input_ring_records_silence() { assert_eq!(word(&ram, 0x11_0000 + 352), 0xaaaa_aaaa); } +/// Program clock generator `cg` as both drivers do: hold it in reset, pick +/// its master clock, write the remainder word, then the control word that +/// names it, and release it. +fn set_clock(r: &Rad1, cg: u32, mux: u32, rem: u32, ictl_low: u32) { + wr(r, 0x80, 0xffe2_0000 | (0x100 << cg)); + wr(r, 0xa8 + 4 * (3 - cg), mux); + wr(r, 0xa4, rem); + wr(r, 0xa0, (0x1000_0000 << cg) | ictl_low); + wr(r, 0x80, 0xffe2_0000); +} + +/// Output frames counted after `ns` of a fresh output channel. +fn output_frames_after(r: &Rad1, ns: i64) -> u32 { + program(r, &RING_OUTPUT, 0x10_0000, 0xffff_f008); + wr(r, 0x58, 0x2000_0001); + at(r, ns); + rd(r, 0x2c) >> 18 +} + +// The words IRIX 6.5.30 writes to the output's generator (1) for each rate, +// captured from the guest; all of them use the 192 kHz master (select 1). +const IRIX_DTOA_WORDS: [(u32, u32, u32); 7] = [ + (48000, 0x0000_ffff, 0x603), + (44100, 0x0034_ff6d, 0x603), + (32000, 0x0000_ffff, 0x605), + (22050, 0x0068_ff6d, 0x207), + (16000, 0x0000_ffff, 0x60b), + (11025, 0x003d_ff6d, 0x610), + (8000, 0x0000_ffff, 0x617), +]; + +#[test] +fn the_output_runs_at_its_clock_generator_rate() { + for (hz, rem, ictl) in IRIX_DTOA_WORDS { + let (r, _ram) = dma_card(); + start_ust(&r); + set_clock(&r, 1, 1, rem, ictl); + // 100 ms is a whole number of frames at every rate in the table. + assert_eq!(output_frames_after(&r, 100_000_000), hz / 10, "{hz} Hz"); + } +} + +#[test] +fn the_44k_master_divides_from_176400() { + // The Linux driver's choice for 44.1 kHz rates: master 0, no remainder. + let (r, _ram) = dma_card(); + set_clock(&r, 1, 0, 0x0000_ffff, 0x607); + assert_eq!(output_frames_after(&r, 100_000_000), 2205); +} + +#[test] +fn generator_0_clocks_the_input_and_not_the_output() { + let (r, _ram) = dma_card(); + set_clock(&r, 0, 1, 0x0000_ffff, 0x617); + program(&r, &RING_INPUT, 0x11_0000, 0xffff_f000); + wr(&r, 0x4c, 1); + assert_eq!(output_frames_after(&r, 100_000_000), 4410, "output keeps 44.1 kHz"); + assert_eq!(rd(&r, 0x14) >> 18, 800, "input at 8 kHz"); +} + +#[test] +fn a_control_word_naming_no_generator_changes_no_rate() { + let (r, _ram) = dma_card(); + set_clock(&r, 1, 1, 0x0000_ffff, 0x617); + // IRIX writes these between rate changes; no generator bit is set. + wr(&r, 0xa4, 0x0068_ff6d); + wr(&r, 0xa0, 0x0200_0004); + assert_eq!(rd(&r, 0xa0), 0x0200_0004, "still stored"); + assert_eq!(output_frames_after(&r, 100_000_000), 800); +} + +#[test] +fn a_rate_change_while_running_takes_effect_from_then() { + let (r, _ram) = dma_card(); + start_ust(&r); + assert_eq!(output_frames_after(&r, 100_000_000), 4410); + set_clock(&r, 1, 1, 0x0068_ff6d, 0x207); + at(&r, 200_000_000); + assert_eq!(rd(&r, 0x2c) >> 18, 4410 + 2205); + // A's UST stamp is the last frame's boundary at the new rate. + let boundary = 100_000_000 + (2205u64 * 1_000_000_000).div_ceil(22050) as i64; + assert_eq!(rd(&r, 0x2c) & 0x3ffff, (boundary / 960) as u32 & 0x3ffff); +} + +#[test] +fn reset_returns_the_generators_to_the_default_rate() { + let (r, _ram) = dma_card(); + set_clock(&r, 1, 1, 0x0000_ffff, 0x617); + r.reset(); + assert_eq!(output_frames_after(&r, 100_000_000), 4410); +} + +#[test] +fn the_wav_capture_stays_at_44100_whatever_the_card_rate() { + let dir = std::env::temp_dir().join(format!("rad1-wav-rate-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("o.wav"); + let mut sink = Sink { host: None, wav: Wav::create(path.as_os_str()), tap: None }; + sink.play(&[0i16; 2 * 2205], 22050); + sink.play(&[0i16; 2 * 4410], 44100); + let b = std::fs::read(&path).unwrap(); + assert_eq!(u32::from_le_bytes(b[24..28].try_into().unwrap()), 44100); + let frames = u32::from_le_bytes(b[40..44].try_into().unwrap()) / 4; + assert!((8818..=8822).contains(&frames), "{frames} frames for 200 ms"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn aes_subcode_runs_at_250_hz_without_a_position_latch() { let (r, ram) = dma_card(); From 734d73159973086f289d8b7791391c26e9d9f14a Mon Sep 17 00:00:00 2001 From: James Braid Date: Thu, 29 Oct 2026 18:29:06 +0000 Subject: [PATCH 2/4] test: an audio suite that sweeps rates and formats The rate fix was checked by hand with a handful of files. Add the suite the GL tests have: test/audiotest plays a 1 kHz, 2.0 s tone in the guest at 7 rates, mono and stereo, 8 and 16 bit, through the audio library (audiotest, built with MIPSpro) and through playaiff and sfplay on AIFFs that audiotest writes itself, so no audio file is committed or made on the host. Two sticky-rate cases play 22050 Hz and then 44100 Hz. host.sh drives a booted Octane or O2 and notes the capture's size around each case; check.py slices IRIS_OCTANE_AUDIO_WAV, or the O2's guest-rate IRIS_O2_AUDIO_DUMP, and passes a case when its length is within 5% of 2.0 s and its pitch within 3% of 1000 Hz. Measured on IRIX 6.5.30: on the Octane 86 of 86 cases pass with the rate fix, and 72 fail on upstream b188bb9 (everything but 44.1 kHz). On the O2, 62 of 86 pass; the 24 at 8000 and 11025 Hz play at the right rate but with extra samples in the tone (pitch 1036 to 1166 Hz). The Indy and Indigo2 are not covered: HAL2 has no guest-rate capture yet. --- CHANGELOG.md | 3 +- clanker/INDEX.md | 1 + clanker/devices/audio-test-suite.md | 152 ++++++++++++++++++++++++++++ test/audiotest/audiotest.c | 126 +++++++++++++++++++++++ test/audiotest/check.py | 104 +++++++++++++++++++ test/audiotest/host.sh | 43 ++++++++ test/audiotest/irix.sh | 7 ++ test/audiotest/sweep.sh | 73 +++++++++++++ 8 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 clanker/devices/audio-test-suite.md create mode 100644 test/audiotest/audiotest.c create mode 100755 test/audiotest/check.py create mode 100755 test/audiotest/host.sh create mode 100755 test/audiotest/irix.sh create mode 100755 test/audiotest/sweep.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d0b60793..33ba30edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,7 +152,8 @@ hashes are given where a change is easiest to understand by reading the commit. - **Octane audio plays at the rate the guest asks for.** RAD audio ran at 44.1 kHz whatever IRIX programmed, so games and other non-44.1 kHz sounds - played at the wrong speed. ([#354](https://github.com/techomancer/iris/issues/354)) + played at the wrong speed. `test/audiotest` sweeps rates and formats. + ([#354](https://github.com/techomancer/iris/issues/354)) - **IMPACT on the Octane passes the GL pixel and state suites.** On IRIX 6.5.22 pixtest went from 52 to 366 passing cases (the two left fail on the Indigo2 too) and glstate from 393 to 394. The Octane's microcode keeps the diff --git a/clanker/INDEX.md b/clanker/INDEX.md index dab49f69e..1eac0ee0c 100644 --- a/clanker/INDEX.md +++ b/clanker/INDEX.md @@ -99,6 +99,7 @@ Non-graphics devices: MC/MEMCFG and RAM banks, HPC3, VDMA, EEPROMs and NVRAM, PS | Note | Kind | Title | |---|---|---| | [empty-gio-slot-reads-bus-error.md](devices/empty-gio-slot-reads-bus-error.md) | rule | Reads of an empty GIO slot end in a bus error (ABORT_EN); all ones made the PROM poll a phantom Newport 200 000 times headless | +| [audio-test-suite.md](devices/audio-test-suite.md) | howto | The audio test suite (`test/audiotest`): rates x channels x widths through the audio library, playaiff and sfplay, checked against the Octane's capture | | [cpal-alsa-busy-poll.md](devices/cpal-alsa-busy-poll.md) | rule | cpal's ALSA backend can spin a core: `[audio] host = "pulseaudio"` | | [decode-table-is-host-mapped.md](devices/decode-table-is-host-mapped.md) | rule | The physical decode table is a sparse host mapping over 40 bits: change it only through `fill`, set the default first, compare entries with `entry_eq` | | [mc-decodes-32-physical-bits.md](devices/mc-decodes-32-physical-bits.md) | rule | Indy/Indigo2/O2 decode 32 physical bits; IRIX PTE software bits reach PA[35:32]; fold with `MipsExecutor::phys_mask`, never decode them | diff --git a/clanker/devices/audio-test-suite.md b/clanker/devices/audio-test-suite.md new file mode 100644 index 000000000..0856d8cb8 --- /dev/null +++ b/clanker/devices/audio-test-suite.md @@ -0,0 +1,152 @@ +# The audio test suite (`test/audiotest`): rates x channels x widths + +**Keywords:** audio,rad1,octane,o2,mace,sample rate,alSetParams,playaiff,sfplay,audiotest,sweep,capture,IRIS_OCTANE_AUDIO_WAV,IRIS_O2_AUDIO_DUMP + +A guest program that plays a tone through the IRIX audio library, a sweep +that runs it and the stock players over every rate and format, and a host +checker that measures the emulator's capture of the output. It runs on the +Octane and the O2. It exists because +RAD1 once ignored the rate IRIX programmed +([#354](https://github.com/techomancer/iris/issues/354)); a game at 22050 Hz +played at the wrong speed, and so did every sound after it. + +| File | Where | What | +|---|---|---| +| `audiotest.c` | guest | `audiotest -r RATE -c 1\|2 -b 8\|16 [-s SECS] [-f HZ] [-n]` plays a sine through `alSetParams(AL_RATE)` and an `ALport`. `-w FILE` writes the same tone as an AIFF instead, so no audio file is committed or made on the host. `-n` leaves the device rate alone | +| `irix.sh` | guest | builds it with MIPSpro `cc -mips3 -n32 -laudio -lm` | +| `sweep.sh` | guest | `cases`, `prep NAME`, `play NAME`, `all` (see its header) | +| `host.sh` | host | copies the test in, builds it, and plays each case, noting the capture's size before and after | +| `check.py` | host | slices the capture (Octane WAV or O2 dump) and prints PASS or FAIL per case | + +## Cases + +86, each a 1 kHz tone for 2.0 s at 8000, 11025, 16000, 22050, 32000, 44100 +and 48000 Hz, mono and stereo, 8 and 16 bit: + +- `gen_*`: `audiotest` through the audio library (28). +- `playaiff_*` and `sfplay_*`: the same tone as an AIFF through the stock + players (28 each). +- `sticky_lib`, `sticky_aiff`: a 22050 Hz tone first (not measured), then + 44100 Hz. IRIX leaves the last rate set when a program exits, so this checks + that the second program's rate wins, through the library and through + `playaiff`. + +## Tolerances + +Fixed in `check.py`. A case passes when its slice has sound, its length is +within 5% of 2.0 s and its pitch is within 3% of 1000 Hz. Length is the span +above 1/8 of the slice's peak; pitch is the left channel's zero crossings over +that span. Playing at the wrong rate scales both by the same ratio, so one +failing number is enough. The fixed Octane runs 1.997 to 2.093 s and 995 to +1000 Hz; the nearest unfixed failure is 2.177 s and 919 Hz (48 kHz). + +## Running it + +Both machines need an IRIX 6.5 disk with the compiler and audio libraries +(`/usr/bin/cc`, `/usr/include/dmedia/audio.h`) and a config with the control +socket on (`ci = true`, `ci_socket`), plus a scratch volume for `iris-ci put` +(`[scsi.N]` with `scratch = true`). The emulator records the card's output +to a file: + +- Octane: `IRIS_OCTANE_AUDIO_WAV=FILE` writes a 44.1 kHz stereo 16-bit WAV. +- O2: `IRIS_O2_AUDIO_DUMP=FILE` writes what MACE audio played at the guest's + own rate, before resampling, as records of output (u8), rate (u32) and + pair count (u32), then the pairs, little-endian. It needs no host sound + device. `check.py` measures each case at its records' rate. + +```sh +IRIS_OCTANE_AUDIO_WAV=/path/cap iris --config octane.toml & # or +IRIS_O2_AUDIO_DUMP=/path/cap iris --config o2.toml & # then: iris-ci start +sh test/audiotest/host.sh /path/ci.sock /path/cap /path/out [EGREP_PATTERN] +python3 test/audiotest/check.py /path/cap /path/out/marks.txt +``` + +`check.py` tells the two formats apart by the first four bytes. The O2 needs +`overlay = true` on a disk in a writable directory (the disk image must be +inside the run directory, e.g. hard-linked as `disk.raw`) and `ci = true` +to run headless. A full run takes about 4 minutes on the O2 and tens of +minutes on the Octane. The pattern picks cases (`'^gen_'`, `'sticky'`). +`check.py`'s exit status is the number of failed cases. When iris runs in a container, its +paths are not the shell's: either mount the run directory at the same path +inside the container, or set `PUT="cmd"` so that `host.sh` calls +`cmd HOSTFILE GUESTPATH` to copy the tarball in. For a guest with no +compiler, build `audiotest` elsewhere (it is MIPS III n32, so any 6.5 box +will do), put it in `/usr/tmp/at` and run with `SKIP_BUILD=1`. + +## Results + +Octane (RAD1), IRIX 6.5.30 (`si-octane` disk), 2026-10-29. Stereo 16-bit +`audiotest` shown; the other widths and channel counts, `playaiff` and `sfplay` +agree with it row for row. + +| Rate | Upstream b188bb9 (length s, pitch Hz) | With the rate fix | +|---|---|---| +| 8000 | 11.174, 5430 FAIL | 2.000, 1000 ok | +| 11025 | 15.203, 3973 FAIL | 2.011, 995 ok | +| 16000 | 0.772, 2756 FAIL | 2.000, 1000 ok | +| 22050 | 1.000, 2000 FAIL | 2.000, 1000 ok | +| 32000 | 1.451, 1378 FAIL | 2.016, 1000 ok | +| 44100 | 2.000, 1000 ok | 2.023, 1000 ok | +| 48000 | 2.177, 919 FAIL | 2.000, 1000 ok | + +| Octane | Pass | Fail | +|---|---|---| +| With the fix (4ec2939) | 86 of 86 | 0 | +| Upstream b188bb9 | 14 of 86 | 72 | + +The upstream passes are all of 44.1 kHz (12) and both `sticky_*` cases. The +sticky cases cannot fail there: with no rate handling, every rate is 44.1 kHz. +They guard against the fix keeping a stale rate. + +O2 (MACE audio), IRIX 6.5.30 (`o2-irix6530-20261028` disk, `uname -R` +`6.5 6.5.30m`), build 4ec2939 (the rate fix; it does not touch the O2 code). 62 of 86 pass. Every failure is 8000 or 11025 Hz, and +all three paths agree: + +| Rate | Length s | Pitch Hz | Verdict | +|---|---|---|---| +| 8000 | 2.080 to 2.093 | 1045 to 1166 | FAIL | +| 11025 | 2.071 to 2.076 | 1036 to 1092 | FAIL | +| 16000 | 2.009 to 2.016 | 1001 to 1008 | ok | +| 22050 | 2.004 to 2.014 | 1000 to 1004 | ok | +| 32000 | 2.004 to 2.011 | 1001 to 1005 | ok | +| 44100 | 2.005 to 2.010 | 1001 to 1006 | ok | +| 48000 | 2.006 to 2.011 | 1001 to 1006 | ok | + +The ranges cover `gen`, `playaiff` and `sfplay`, mono and stereo, 8 and 16 +bit; both `sticky_*` cases pass (2.005 and 2.008 s, 1002 and 1004 Hz). The +rate the guest programs is right everywhere (the records carry 8000 and +11025, and the lengths are within 5%). The pitch fails because the stream has +extra samples in it: at 8 kHz about one in 33 is an inserted zero in the +middle of the sine, which makes extra zero crossings (period lengths of 3 and +6 where it should be 8). It is not yet known whether MACE's ring read, the +guest's feeding of the ring or IRIX's own sample conversion inserts them. + +Indy and Indigo2 (HAL2): not covered. HAL2 has only the host-side capture +(`IRIS_HAL2_CAPTURE`, raw stereo 16-bit at the host stream's rate, and only +with a host output device). A guest-rate dump like the O2's is planned. + + +## Gotchas + +- `cc` prints a MIPSpro license-password notice on this disk and builds + anyway. `irix.sh` succeeds; check that `audiotest` exists. +- The guest's `/tmp` is cleared on boot. The sweep keeps its files in + `/usr/tmp`; stage them again on a fresh boot (`host.sh` does). +- IRIX `grep` has no `\|`; use `egrep`. +- The root shell is csh. `$?` is an error there; use `$status`, or run a + `sh` script. +- A command line longer than about 250 characters never reaches the shell + and `iris-ci run` times out. Put long scripts in a file. +- iris creates the capture file on the first sound, so it can be missing + before the first case; `host.sh` counts that as size 0. The O2's dump + already holds the boot chime (22 MB at 34 and 48 kHz) by login; marks are + offsets, so it does not matter. +- The O2 dump's cases are the records that start inside the case's span, in + the group (output, rate) with the most non-zero samples. A program that + sets the rate after it starts leaves records at the old rate: the idle + channel's silence, 0.4 s of it at 48 kHz before the first case, which + outnumbers an 8 kHz tone. +- `iris-ci put` fails with "scratch volume not configured" if the config + has no scratch disk. +- `playaiff` and `sfplay` read the AIFF's rate and set it themselves; only + `audiotest -n` plays at whatever the device was left on. diff --git a/test/audiotest/audiotest.c b/test/audiotest/audiotest.c new file mode 100644 index 000000000..52f5d9644 --- /dev/null +++ b/test/audiotest/audiotest.c @@ -0,0 +1,126 @@ +/* + * audiotest: play, or write to an AIFF, a sine tone at a chosen rate, channel + * count and sample width. Build on IRIX with irix.sh (MIPSpro cc, -laudio). + * + * audiotest [-r RATE] [-c 1|2] [-b 8|16] [-s SECONDS] [-f HZ] [-n] [-w FILE.aiff] + * + * Plays through the audio library the way a game does: alSetParams sets the + * output rate on the default output device, then a port writes the frames. + * -n leaves the device rate alone (what a program that never sets one hears). + * -w writes the same tone as an AIFF instead, for playaiff and sfplay. + * Prints the rate the device reports after the set. + */ +#include +#include +#include +#include +#include +#include + +#define CHUNK 2048 + +static void put32(unsigned char *p, unsigned v) { p[0] = v >> 24; p[1] = v >> 16; p[2] = v >> 8; p[3] = v; } +static void put16(unsigned char *p, unsigned v) { p[0] = v >> 8; p[1] = v; } + +/* The sample rate as AIFF stores it: an IEEE 754 80-bit extended float. */ +static void put_ext80(unsigned char *p, unsigned rate) +{ + int e = 0; + unsigned m = rate; + while (!(m & 0x80000000u)) { m <<= 1; e++; } + put16(p, 16383 + 31 - e); + put32(p + 2, m); + put32(p + 6, 0); +} + +static void tone(signed char *b, int bits, int ch, int n, int *at, double rate, double hz) +{ + int i, c; + for (i = 0; i < n; i++, (*at)++) { + double v = 0.5 * sin(2 * M_PI * hz * (*at) / rate); + for (c = 0; c < ch; c++) { + if (bits == 8) b[i * ch + c] = (signed char)(v * 127); + else ((short *)b)[i * ch + c] = (short)(v * 32767); + } + } +} + +static int write_aiff(const char *path, int rate, int ch, int bits, int frames, double hz) +{ + FILE *f = fopen(path, "wb"); + unsigned char h[54]; + static signed char b[CHUNK * 2 * 2]; + int at = 0, n, bytes = frames * ch * bits / 8; + if (!f) { perror(path); return 1; } + memcpy(h, "FORM", 4); + memcpy(h + 8, "AIFFCOMM", 8); put32(h + 16, 18); + put16(h + 20, ch); put32(h + 22, frames); put16(h + 26, bits); put_ext80(h + 28, rate); + memcpy(h + 38, "SSND", 4); put32(h + 42, 8 + bytes); put32(h + 46, 0); put32(h + 50, 0); + /* FORM's size is everything after its own 8-byte header. */ + put32(h + 4, 4 + 8 + 18 + 8 + 8 + bytes); + fwrite(h, 1, 54, f); + while (at < frames) { + n = frames - at < CHUNK ? frames - at : CHUNK; + tone(b, bits, ch, n, &at, rate, hz); + fwrite(b, ch * bits / 8, n, f); + } + return fclose(f) != 0; +} + +int main(int argc, char **argv) +{ + int rate = 44100, ch = 2, bits = 16, set_rate = 1, o, frames, at = 0, n; + double secs = 2.0, hz = 1000.0; + const char *aiff = 0; + static signed char b[CHUNK * 2 * 2]; + ALconfig cfg; + ALport port; + ALpv pv; + + while ((o = getopt(argc, argv, "r:c:b:s:f:nw:")) != -1) { + switch (o) { + case 'r': rate = atoi(optarg); break; + case 'c': ch = atoi(optarg); break; + case 'b': bits = atoi(optarg); break; + case 's': secs = atof(optarg); break; + case 'f': hz = atof(optarg); break; + case 'n': set_rate = 0; break; + case 'w': aiff = optarg; break; + default: + fprintf(stderr, "usage: audiotest [-r rate] [-c 1|2] [-b 8|16] [-s secs] [-f hz] [-n] [-w file.aiff]\n"); + return 2; + } + } + if ((ch != 1 && ch != 2) || (bits != 8 && bits != 16) || rate <= 0) { + fprintf(stderr, "audiotest: bad format\n"); + return 2; + } + frames = (int)(secs * rate); + if (aiff) return write_aiff(aiff, rate, ch, bits, frames, hz); + + if (set_rate) { + pv.param = AL_RATE; + pv.value.ll = alDoubleToFixed((double)rate); + if (alSetParams(AL_DEFAULT_OUTPUT, &pv, 1) < 0) { fprintf(stderr, "audiotest: alSetParams failed\n"); return 1; } + } + pv.param = AL_RATE; + if (alGetParams(AL_DEFAULT_OUTPUT, &pv, 1) < 0) { fprintf(stderr, "audiotest: alGetParams failed\n"); return 1; } + printf("rate %.0f\n", alFixedToDouble(pv.value.ll)); + + cfg = alNewConfig(); + alSetChannels(cfg, ch); + alSetSampFmt(cfg, AL_SAMPFMT_TWOSCOMP); + alSetWidth(cfg, bits == 8 ? AL_SAMPLE_8 : AL_SAMPLE_16); + port = alOpenPort("audiotest", "w", cfg); + if (!port) { fprintf(stderr, "audiotest: alOpenPort failed\n"); return 1; } + while (at < frames) { + n = frames - at < CHUNK ? frames - at : CHUNK; + tone(b, bits, ch, n, &at, (double)rate, hz); + alWriteFrames(port, b, n); + } + while (alGetFilled(port) > 0) + sginap(5); + alClosePort(port); + alFreeConfig(cfg); + return 0; +} diff --git a/test/audiotest/check.py b/test/audiotest/check.py new file mode 100755 index 000000000..580016a1e --- /dev/null +++ b/test/audiotest/check.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""check.py CAPTURE MARKS: judge the emulator's capture of an audio sweep. + +CAPTURE is the card's capture, in either of two forms (told apart by the first +four bytes): + + Octane a WAV, 44.1 kHz stereo 16-bit (IRIS_OCTANE_AUDIO_WAV). MARKS holds + byte offsets into its data chunk. + O2 a dump of what MACE audio played at the guest's own rate + (IRIS_O2_AUDIO_DUMP): records of out:u8, rate:u32, pairs:u32, then + pairs x (l:i16, r:i16), little-endian. MARKS holds byte offsets into + the file. A case is the records that start inside its span; they + are joined per (output, rate) and the group with the most sound + (non-zero samples) is measured at its own rate. A program that + sets the rate after it starts leaves records at the old rate: + silence from the idle channel, which can outnumber an 8 kHz tone. + +MARKS has one line per case, written by host.sh: "NAME START END", the offsets +before and after that case played. Each slice must hold one tone, so a case +passes when + + its length is within 5% of 2.0 s (a wrong playback rate scales it), and + its pitch is within 3% of 1000 Hz (the same error scales the pitch), and + it is not silent. + +Length is the span of samples above 1/8 of the slice's peak; pitch is the +left channel's zero crossings over that span. Prints one line per case, +"PASS name" or "FAIL name: length, pitch", then the counts; the exit status is +the number of failures. Standard library only. +""" +import array +import struct +import sys + +WAV_RATE = 44100 +EXPECT_S, LEN_TOL = 2.0, 0.05 +EXPECT_HZ, HZ_TOL = 1000.0, 0.03 + + +def measure(raw, rate=WAV_RATE): + """(seconds, hz, peak) of the tone in a 16-bit stereo slice at RATE.""" + raw = raw[: len(raw) // 4 * 4] + s = array.array("h", raw) + if sys.byteorder == "big": + s.byteswap() + left = s[0::2] + peak = max((abs(x) for x in left), default=0) + if peak == 0: + return 0.0, 0.0, 0 + loud = [i for i, x in enumerate(left) if abs(x) > peak / 8] + seg = left[loud[0] : loud[-1] + 1] + crossings = sum(1 for i in range(1, len(seg)) if (seg[i - 1] < 0) != (seg[i] < 0)) + secs = len(seg) / rate + return secs, crossings / 2 / secs, peak + + +def dump_case(data, a, b): + """(raw samples, rate) for the dump records starting in [a, b).""" + outs = {} # (output, rate) -> [sample bytes] + pos = 0 + while pos + 9 <= len(data): + out, rate, pairs = struct.unpack_from(" 0 + and abs(secs - EXPECT_S) <= EXPECT_S * LEN_TOL + and abs(hz - EXPECT_HZ) <= EXPECT_HZ * HZ_TOL + ) + cases += 1 + if ok: + print(f"PASS {name}") + else: + fails += 1 + print(f"FAIL {name}: {secs:.3f} s, {hz:.0f} Hz" + ("" if peak > 0 else ", silent")) + print(f"{cases - fails} passed, {fails} failed") + sys.exit(min(fails, 255)) + + +main() diff --git a/test/audiotest/host.sh b/test/audiotest/host.sh new file mode 100755 index 000000000..0f8827702 --- /dev/null +++ b/test/audiotest/host.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# host.sh SOCKET CAPTURE OUT [PATTERN]: run the audio sweep in a booted guest. +# +# The guest is an Octane or an O2 with a MIPSpro compiler, logged in or +# loginable, with iris started with the control socket SOCKET and the capture +# file in its environment: IRIS_OCTANE_AUDIO_WAV=CAPTURE on the Octane, +# IRIS_O2_AUDIO_DUMP=CAPTURE on the O2. This copies the test in, builds it, +# plays each case from sweep.sh (only those matching the egrep PATTERN, if +# given), and writes OUT/marks.txt. Then run check.py CAPTURE OUT/marks.txt. +# SKIP_BUILD=1 uses an audiotest already in /usr/tmp/at, for a guest without a +# compiler. +# +# iris-ci put reads the file in iris's own filesystem. When iris runs somewhere +# whose paths differ from this shell's, set PUT to a command run as +# "$PUT HOSTFILE GUESTPATH". +set -e +SOCK=$1 CAP=$2 OUT=$3 PATTERN=${4:-.} +D=$(cd "$(dirname "$0")" && pwd) +ci() { iris-ci --socket "$SOCK" "$@"; } +put() { if [ -n "$PUT" ]; then $PUT "$1" "$2"; else ci put -q --to "$2" "$1"; fi; } +mkdir -p "$OUT" +tar cf "$OUT/audiotest.tar" -C "$D" audiotest.c irix.sh sweep.sh +ci login >/dev/null +put "$OUT/audiotest.tar" /usr/tmp/audiotest.tar +if [ -n "$SKIP_BUILD" ]; then + ci run 'mkdir -p /usr/tmp/at; cd /usr/tmp/at; tar xf ../audiotest.tar' >/dev/null +else + ci run --timeout 300 'rm -rf /usr/tmp/at; mkdir /usr/tmp/at; cd /usr/tmp/at; tar xf ../audiotest.tar; sh irix.sh' >/dev/null +fi +# iris creates the capture when the card first plays, so it may not exist yet. +# A WAV's marks skip its 44-byte header; the O2's dump has none. +size() { + if [ ! -f "$CAP" ]; then echo 0 + elif [ "$(head -c 4 "$CAP" | tr -d '\000')" = RIFF ]; then echo $(($(stat -c %s "$CAP") - 44)) + else stat -c %s "$CAP"; fi +} +: > "$OUT/marks.txt" +for c in $(ci run 'cd /usr/tmp/at; sh sweep.sh cases' | grep -E '^[a-z]+_' | tr -d '\r' | grep -E "$PATTERN"); do + ci run --timeout 60 "cd /usr/tmp/at; sh sweep.sh prep $c" >/dev/null + a=$(size) + ci run --timeout 60 "cd /usr/tmp/at; sh sweep.sh play $c" >/dev/null + echo "$c $a $(size)" >> "$OUT/marks.txt" +done diff --git a/test/audiotest/irix.sh b/test/audiotest/irix.sh new file mode 100755 index 000000000..e35ee1d81 --- /dev/null +++ b/test/audiotest/irix.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Build the audio test on IRIX with MIPSpro cc (stock IRIX has no bash). +# Usage: sh irix.sh + +echo "cc -o audiotest audiotest.c" +cc -o audiotest audiotest.c -mips3 -n32 -laudio -lm || exit 1 +echo "Done. Run sh sweep.sh all" diff --git a/test/audiotest/sweep.sh b/test/audiotest/sweep.sh new file mode 100755 index 000000000..8b09da19e --- /dev/null +++ b/test/audiotest/sweep.sh @@ -0,0 +1,73 @@ +#!/bin/sh +# sweep.sh: run in the guest, in the directory holding audiotest (irix.sh +# builds it). Plays 1 kHz, 2.0 s tones at each rate x channels x width through +# three paths, and a sticky-rate pair. Captured by the emulator; check.py on +# the host judges the capture. +# +# sh sweep.sh cases list the cases, one name per line +# sh sweep.sh prep NAME what must happen before NAME's timed play +# sh sweep.sh play NAME play NAME (this is the part that gets timed) +# sh sweep.sh all prep and play every case, for listening by ear +# +# Case names (RATE_Cc_Bb is e.g. 22050_2c_16b): +# gen_RATE_Cc_Bb audiotest plays through the audio library +# playaiff_RATE_Cc_Bb playaiff plays an AIFF audiotest wrote +# sfplay_RATE_Cc_Bb sfplay plays the same AIFF +# sticky_lib a 22050 Hz tone first, then 44100 Hz through the library +# sticky_aiff a 22050 Hz tone first, then a 44100 Hz AIFF in playaiff +# +# The AIFFs are written by audiotest on first use, so nothing needs staging +# beyond audiotest and this script. They go under /usr/tmp: IRIX clears /tmp +# on boot. +D=${AIFFDIR:-/usr/tmp/audiotest.aiff} +RATES="8000 11025 16000 22050 32000 44100 48000" + +aiff() { # RATE CH BITS: print the AIFF's path, writing it if absent + f=$D/t$1_$2c_$3.aiff + mkdir -p $D + [ -f $f ] || ./audiotest -r $1 -c $2 -b $3 -w $f || exit 1 + echo $f +} + +# fields RATE CH BITS out of a name like gen_22050_2c_16b +fields() { + echo $1 | sed 's/^[a-z]*_\([0-9]*\)_\([12]\)c_\([0-9]*\)b$/\1 \2 \3/' +} + +case $1 in +cases) + for r in $RATES; do for c in 1 2; do for b in 8 16; do + echo gen_${r}_${c}c_${b}b + done; done; done + for p in playaiff sfplay; do + for r in $RATES; do for c in 1 2; do for b in 8 16; do + echo ${p}_${r}_${c}c_${b}b + done; done; done + done + echo sticky_lib + echo sticky_aiff + ;; +prep) + case $2 in + sticky_*) ./audiotest -r 22050 -c 2 -b 16 ;; + playaiff_*|sfplay_*) set -- x `fields $2`; aiff $2 $3 $4 >/dev/null ;; + esac + ;; +play) + case $2 in + gen_*) set -- x `fields $2`; ./audiotest -r $2 -c $3 -b $4 ;; + playaiff_*) set -- x `fields $2`; playaiff `aiff $2 $3 $4` ;; + sfplay_*) set -- x `fields $2`; sfplay `aiff $2 $3 $4` ;; + sticky_lib) ./audiotest -r 44100 -c 2 -b 16 ;; + sticky_aiff) playaiff `aiff 44100 2 16` ;; + *) echo "sweep.sh: unknown case $2" >&2; exit 2 ;; + esac + ;; +all) + for c in `sh $0 cases`; do + echo "== $c"; sh $0 prep $c; sh $0 play $c + done + ;; +*) + sed -n 2,10p $0; exit 2 ;; +esac From b068a1a5db3426275e38cb699e24bcb6b1f98ae0 Mon Sep 17 00:00:00 2001 From: James Braid Date: Thu, 29 Oct 2026 14:28:53 -0700 Subject: [PATCH 3/4] mace_audio: bring the rings up to date on a register read O2 sound at 8000 and 11025 Hz played sharp and about 4% long through the audio library, playaiff and sfplay alike. The guest-rate dump showed the right rate but zeros inserted mid-tone, one in about 33 at 8 kHz. IRIX feeds an output ring by reading MACE's status register about once a millisecond and setting the write pointer about 2 ms past the read pointer it finds: 17 frames at 8 kHz, 16 after the block rounding. The engine only moved the read pointer on the service thread's passes, and since #343 those come about every 2.1 ms on a quiet machine. IRIX then computed its lead from a pointer up to 2 ms stale, a pass wanted 16 or 17 frames where 16 or fewer were queued, and each missing frame played as a zero. At 16 kHz and up the lead rounds to enough spare frames to cover the pass. A read of the audio registers now moves the rings up to now first, as the ISA block's reads already do for the serial DMA. What the outputs play there is kept until the service thread's pass, which dumps and plays it in order, so a capture write that stalls that thread (up to a second on a network filesystem) no longer holds the rings back. The O2 audio suite now passes 86 of 86 cases, 8 kHz at 2.000 s and 1000 Hz. --- CHANGELOG.md | 4 ++ clanker/devices/audio-test-suite.md | 47 ++++++++------- src/dev/mace.rs | 19 ++++-- src/dev/mace_audio.rs | 91 ++++++++++++++++++++++++++--- src/ip32/tests.rs | 23 ++++++++ 5 files changed, 149 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ba30edc..36bf94764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -248,6 +248,10 @@ hashes are given where a change is easiest to understand by reading the commit. ### SGI O2 (IP32) +- **O2 audio at 8 and 11 kHz no longer plays sharp.** A read of MACE's + audio registers now brings the rings up to date, so IRIX, which keeps + only about 2 ms of sound queued, no longer lets them run dry between + service passes. - **The O2 is on `Physical`.** Its bus is the same decode table and memory window as the Indy's: CRIME's banks are RAM banks a bank-control write remaps, CRIME, GBE, VICE, MACE and the flash are table entries, and diff --git a/clanker/devices/audio-test-suite.md b/clanker/devices/audio-test-suite.md index 0856d8cb8..2de7a3f39 100644 --- a/clanker/devices/audio-test-suite.md +++ b/clanker/devices/audio-test-suite.md @@ -99,27 +99,32 @@ sticky cases cannot fail there: with no rate handling, every rate is 44.1 kHz. They guard against the fix keeping a stale rate. O2 (MACE audio), IRIX 6.5.30 (`o2-irix6530-20261028` disk, `uname -R` -`6.5 6.5.30m`), build 4ec2939 (the rate fix; it does not touch the O2 code). 62 of 86 pass. Every failure is 8000 or 11025 Hz, and -all three paths agree: - -| Rate | Length s | Pitch Hz | Verdict | -|---|---|---|---| -| 8000 | 2.080 to 2.093 | 1045 to 1166 | FAIL | -| 11025 | 2.071 to 2.076 | 1036 to 1092 | FAIL | -| 16000 | 2.009 to 2.016 | 1001 to 1008 | ok | -| 22050 | 2.004 to 2.014 | 1000 to 1004 | ok | -| 32000 | 2.004 to 2.011 | 1001 to 1005 | ok | -| 44100 | 2.005 to 2.010 | 1001 to 1006 | ok | -| 48000 | 2.006 to 2.011 | 1001 to 1006 | ok | - -The ranges cover `gen`, `playaiff` and `sfplay`, mono and stereo, 8 and 16 -bit; both `sticky_*` cases pass (2.005 and 2.008 s, 1002 and 1004 Hz). The -rate the guest programs is right everywhere (the records carry 8000 and -11025, and the lengths are within 5%). The pitch fails because the stream has -extra samples in it: at 8 kHz about one in 33 is an inserted zero in the -middle of the sine, which makes extra zero crossings (period lengths of 3 and -6 where it should be 8). It is not yet known whether MACE's ring read, the -guest's feeding of the ring or IRIX's own sample conversion inserts them. +`6.5 6.5.30m`), 2026-10-29. Ranges cover `gen`, `playaiff` and `sfplay`, +mono and stereo, 8 and 16 bit. + +| Rate | Before the MACE fix (length s, pitch Hz) | With it | +|---|---|---| +| 8000 | 2.080 to 2.093, 1045 to 1166 FAIL | 2.000 to 2.003, 999 to 1000 ok | +| 11025 | 2.071 to 2.076, 1036 to 1092 FAIL | 2.000, 1000 ok | +| 16000 | 2.009 to 2.016, 1001 to 1008 ok | 2.000, 1000 ok | +| 22050 | 2.004 to 2.014, 1000 to 1004 ok | 1.998 to 2.001, 1000 ok | +| 32000 | 2.004 to 2.011, 1001 to 1005 ok | 1.998 to 2.000, 1000 ok | +| 44100 | 2.005 to 2.010, 1001 to 1006 ok | 2.000 to 2.004, 998 to 1000 ok | +| 48000 | 2.006 to 2.011, 1001 to 1006 ok | 1.999 to 2.001, 999 to 1000 ok | + +| O2 | Pass | Fail | +|---|---|---| +| With the MACE fix | 86 of 86 | 0 | +| Before (4ec2939) | 62 of 86 | 24 | + +Before the fix every 8000 and 11025 Hz case failed through all three paths. +The rate was right, but the stream had zeros inserted in the middle of the +sine (about one in 33 at 8 kHz), which added zero crossings. IRIX keeps an +output ring about 2 ms ahead of the read pointer it reads each millisecond, +and MACE only moved that pointer on the service thread's passes, about +2.1 ms apart on a quiet machine. A pass then wanted 16 or 17 frames at +8 kHz where 16 or fewer were queued, and played a zero for each missing +one. Register reads now bring the rings up to date first. Indy and Indigo2 (HAL2): not covered. HAL2 has only the host-side capture (`IRIS_HAL2_CAPTURE`, raw stereo 16-bit at the host stream's rate, and only diff --git a/src/dev/mace.rs b/src/dev/mace.rs index 802baca58..866ee9f05 100644 --- a/src/dev/mace.rs +++ b/src/dev/mace.rs @@ -103,11 +103,7 @@ impl Mace { self.mec.poll(); self.com0.pump(); self.com1.pump(); - self.audio.poll(&AudioIo { - ram: &self.ram, - ring_base: (self.regs.cell(MACE_ISA_RING_BASE) & sdma::RING_BASE_MASK) as u32, - ust: self.ust.counter() as u32, - }); + self.audio.poll(&self.audio_io()); if self.sdma.any_owned() { self.sdma.poll(&self.sdma_io()); } @@ -122,6 +118,15 @@ impl Mace { ] } + /// The audio engine's view of the machine. + fn audio_io(&self) -> AudioIo<'_> { + AudioIo { + ram: &self.ram, + ring_base: (self.regs.cell(MACE_ISA_RING_BASE) & sdma::RING_BASE_MASK) as u32, + ust: self.ust.counter() as u32, + } + } + /// The engines' view of the machine. fn sdma_io(&self) -> SdmaIo<'_> { SdmaIo { @@ -329,6 +334,7 @@ impl BusDevice for Mace { return self.rtc.read8(addr); } if MaceAudio::contains(a) { + self.audio.catch_up(&self.audio_io()); return BusDevice::read8(&self.audio, addr); } if Self::in_sdma(a) { @@ -362,6 +368,7 @@ impl BusDevice for Mace { return self.rtc.read16(addr); } if MaceAudio::contains(a) { + self.audio.catch_up(&self.audio_io()); return BusDevice::read16(&self.audio, addr); } if Self::in_sdma(a) { @@ -405,6 +412,7 @@ impl BusDevice for Mace { return self.rtc.read32(addr); } if MaceAudio::contains(a) { + self.audio.catch_up(&self.audio_io()); return BusDevice::read32(&self.audio, addr); } if Self::in_sdma(a) { @@ -449,6 +457,7 @@ impl BusDevice for Mace { return self.rtc.read64(addr); } if MaceAudio::contains(a) { + self.audio.catch_up(&self.audio_io()); return BusDevice::read64(&self.audio, addr); } if Self::in_sdma(a) { diff --git a/src/dev/mace_audio.rs b/src/dev/mace_audio.rs index 9b2db1b64..6192a4fcb 100644 --- a/src/dev/mace_audio.rs +++ b/src/dev/mace_audio.rs @@ -163,6 +163,9 @@ struct State { status_in: u64, ch: [Chan; 3], codec: [u16; 32], + /// What the outputs played since the service thread last took it: per + /// catch-up, each output's pairs and rate. + played: Vec<([Vec<(i16, i16)>; 2], [u32; 2])>, } impl State { @@ -177,7 +180,7 @@ impl State { for r in ad1843::CLOCK_RATE { codec[r] = 48000; } - Self { ctl: 1, codec_ctl: 1 << 16, mask: 0, status_in: 0, ch: [Chan::RESET; 3], codec } + Self { ctl: 1, codec_ctl: 1 << 16, mask: 0, status_in: 0, ch: [Chan::RESET; 3], codec, played: Vec::new() } } fn codec_read(&self, reg: usize) -> u16 { @@ -389,8 +392,8 @@ impl MaceAudio { let was_reset = st.ctl & 1 != 0; st.ctl = (val & 1) | (st.ctl & val & (3 << 23)); if st.ctl & 1 != 0 && !was_reset { - let keep = st.ch; - *st = State { ch: keep, ..State::new() }; + let (ch, played) = (st.ch, std::mem::take(&mut st.played)); + *st = State { ch, played, ..State::new() }; } } 0x08 => { @@ -471,8 +474,31 @@ impl MaceAudio { /// Play what is due on each output channel since the last call, from /// its ring to the host. Called from the machine's service thread. pub fn poll(&self, io: &AudioIo) { + self.poll_at(io, Instant::now()); + } + + fn poll_at(&self, io: &AudioIo, now: Instant) { + self.catch_up_at(io, now); + let played = std::mem::take(&mut self.st.lock().played); + for (pcm, rates) in &played { + self.dump(pcm, rates); + self.play(pcm, rates); + } + } + + /// Move each channel's ring up to now, keeping what the outputs played + /// for the next [`Self::poll`]. A read of the registers comes here + /// first, so software sees the pointers as they stand, not as of the + /// last service pass up to 2 ms ago: IRIX reads the read pointer about + /// once a millisecond and keeps its write pointer only about 2 ms + /// ahead of it, so a pass that found the ring that stale played it dry + /// and filled the tone with zeros. + pub fn catch_up(&self, io: &AudioIo) { + self.catch_up_at(io, Instant::now()); + } + + fn catch_up_at(&self, io: &AudioIo, now: Instant) { let mut st = self.st.lock(); - let now = Instant::now(); let mut pcm: [Vec<(i16, i16)>; 2] = [Vec::new(), Vec::new()]; let mut rates = [0u32; 2]; let ram = &*io.ram; @@ -547,6 +573,9 @@ impl MaceAudio { pcm[out].push(item); } } + if pcm.iter().any(|p| !p.is_empty()) { + st.played.push((pcm, rates)); + } drop(st); // The pointers moved: a ring may have crossed its threshold. self.update_irq(); @@ -565,11 +594,6 @@ impl MaceAudio { } } } - if pcm.iter().all(|p| p.is_empty()) { - return; - } - self.dump(&pcm, &rates); - self.play(&pcm, &rates); } /// Append one record per output that played: the output (u8), its rate @@ -920,4 +944,53 @@ mod tests { let mu = a.msc_ust(1); assert!((mu >> 32) as u32 > 0 && mu as u32 == 7, "MSC counts in the high word, UST stamped in the low"); } + + /// IRIX feeds an output ring by reading the read pointer about once a + /// millisecond and setting the write pointer 17 frames past it at + /// 8 kHz, which the block pointer rounds down to 16 (2 ms). The service + /// thread passes about every 2.1 ms when the machine is quiet. Reading + /// a pointer last moved by such a pass, IRIX fell behind: a pass wanted + /// 16 or 17 frames, sometimes found fewer and played zeros in the middle + /// of the tone, so 8 and 11 kHz played sharp and long. A register read + /// brings the ring up to date first, and it never runs dry. + #[test] + fn a_register_read_catches_the_ring_up_so_irix_keeps_ahead() { + let us = std::time::Duration::from_micros; + let run = |catch_up: bool| { + let ram = O2Memory::single(8 << 20); + let a = MaceAudio::new(); + a.write(MACE_AUDIO, 0); + write_codec(&a, 28, 0x0800); + write_codec(&a, 17, 8000); + write_codec(&a, 15, 1 << 8); + write_codec(&a, 27, 1 << 8); + let ring_base = 0x0010_0000u32; + for i in 0..1024u32 { + let o = ram.dma_offset(ring_base | 0x1000 | i * 4).unwrap(); + ram.put_be(o, 4, 1000 << 8); + } + a.write(MACE_AUDIO + 0x40, CTRL_ENABLE); + let io = AudioIo { ram: &ram, ring_base, ust: 0 }; + let t0 = Instant::now(); + for step in 0..4000u64 { + let t = t0 + us(step * 50); + if step % 20 == 0 { + if catch_up { + a.catch_up_at(&io, t); + } + let rptr = a.read(MACE_AUDIO + 0x48); + a.write(MACE_AUDIO + 0x50, rptr + 17 * 8); + } + if step % 42 == 0 { + a.poll_at(&io, t); + } + } + (a.gaps[0].load(Ordering::Relaxed), a.msc_ust(1) >> 32) + }; + let (gaps, _) = run(false); + assert!(gaps > 0, "a pointer only as fresh as the last pass let the ring run dry"); + let (gaps, frames) = run(true); + assert_eq!(gaps, 0, "the ring never ran dry"); + assert!((1590..=1600).contains(&frames), "200 ms at 8 kHz played, got {frames}"); + } } diff --git a/src/ip32/tests.rs b/src/ip32/tests.rs index f41a14811..14c7669b9 100644 --- a/src/ip32/tests.rs +++ b/src/ip32/tests.rs @@ -384,6 +384,29 @@ fn sdma_bus() -> std::sync::Arc { bus } +/// IRIX paces its audio by MACE's read pointer: it reads it about once a +/// millisecond and writes about 2 ms of sound past it. The pointer it reads +/// is where the engine is, not where the last service pass (up to 2 ms ago +/// on a quiet machine) left it; otherwise the next pass ran the ring dry. +#[test] +fn an_audio_register_read_sees_the_ring_as_it_stands() { + use crate::dev::mace_audio::MACE_AUDIO; + let bus = sdma_bus(); + let a = MACE_AUDIO as u64; + let codec = |reg: u64, v: u64| bus.write64(a + 8, reg << 17 | v); + bus.write64(a, 0); + codec(28, 0x0800); // clock 1 on, converters up + codec(17, 8000); + codec(15, 1 << 8); // DAC1 from clock 1 + codec(27, 1 << 8); // DAC1 on + bus.write64(a + 0x40, 0x200); // output 1 running + bus.write64(a + 0x50, 0xfe0); // a full ring + bus.service(); + std::thread::sleep(std::time::Duration::from_millis(10)); + let rptr = bus.read64(a + 0x48).data; + assert!(rptr >= 0x280, "10 ms at 8 kHz is 20 blocks with no service pass between, read {rptr:#x}"); +} + fn sdma_reg(p: u32, reg: u32) -> u64 { (MACE_SDMA + p * 0x4000 + reg * 8) as u64 } From 42d5f4870f06ca81098d058f50d8868497894930 Mon Sep 17 00:00:00 2001 From: James Braid Date: Wed, 28 Oct 2026 23:23:14 -0700 Subject: [PATCH 4/4] ioc3: hold the receive timer off while the test reads the held entry host_input_arrives_in_the_receive_ring_after_the_timer failed once with "two held in the packer". The test rig never starts the chip, so no receive thread or timer is involved: service() starts the 32 ms receive timer from the wall clock, and every register access first expires any deadline already past. A test thread descheduled for over 32 ms between service() and the RX_PRODUCE read made that read post the entry itself, so the packer was already empty when the test looked. A 33 ms pause at that point fails the test every time. The model is right here. The guest sees the same thing on a late read, and the partial entry still reaches the ring. The test assumed it would run inside the timer's window. The test now checks that the deadline is 32 ms after the character, holds it off while it reads the held count and the arm, then restores it and sleeps until it passes. That also replaces the fixed 60 ms sleep. --- src/dev/ioc3_tests.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/dev/ioc3_tests.rs b/src/dev/ioc3_tests.rs index 49efbd2e2..5cd8e1299 100644 --- a/src/dev/ioc3_tests.rs +++ b/src/dev/ioc3_tests.rs @@ -713,15 +713,30 @@ fn host_input_arrives_in_the_receive_ring_after_the_timer() { let r = Rig::new(); r.aim_engines(); r.w32(reg::SDMA_RING_LO, 0x2000 | 1); - // 0x200 ticks of 62.5 us: 32 ms, well clear of a slow test host. + // 0x200 ticks of 62.5 us: 32 ms. r.w32(A + sdma_off::RX_TIMER, 0x200); r.w32(A + sdma_off::RX_CONSUME, ARM_RX_TIMER); r.w32(A, sdma_ctl::ENABLE | 4); r.wire[0].inp.lock().unwrap().extend(b"hi"); + let before = Instant::now(); r.c.service(); + let after = Instant::now(); + // Every register access first expires a deadline already past, so a + // test thread descheduled past it would see the entry posted before it + // looked. Hold the timer off while looking, then let it run out. + let wait = Duration::from_nanos(0x200 * TIMER_TICK_NS); + let due = { + let mut st = r.c.sh.st.lock(); + let port = &mut st.sdma.port[0]; + let due = port.rx_deadline.expect("the first character started the timer"); + assert!(due >= before + wait && due <= after + wait, "the reload's 32 ms from the character"); + port.rx_deadline = Some(Instant::now() + Duration::from_secs(3600)); + due + }; assert_eq!(r.r32(A + sdma_off::RX_PRODUCE) >> HELD_SHIFT & 7, 2, "two held in the packer"); assert_eq!(r.r32(A + sdma_off::RX_CONSUME) & ARM_RX_TIMER, ARM_RX_TIMER); - std::thread::sleep(Duration::from_millis(60)); + r.c.sh.st.lock().sdma.port[0].rx_deadline = Some(due); + std::thread::sleep(due.saturating_duration_since(Instant::now())); r.c.service(); assert_eq!(r.r32(A + sdma_off::RX_PRODUCE), 8, "posted when the timer ran out"); assert_eq!(&get(&r, 0x3000, 2), b"hi");