diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b6fdb4ca..36bf94764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -150,6 +150,10 @@ 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. `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 @@ -244,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/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..2de7a3f39 --- /dev/null +++ b/clanker/devices/audio-test-suite.md @@ -0,0 +1,157 @@ +# 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`), 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 +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/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"); 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/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(); 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 } 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