Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions clanker/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
157 changes: 157 additions & 0 deletions clanker/devices/audio-test-suite.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 17 additions & 2 deletions src/dev/ioc3_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
19 changes: 14 additions & 5 deletions src/dev/mace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading