diff --git a/CHANGELOG.md b/CHANGELOG.md index 345a9c846..702300a98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,14 @@ hashes are given where a change is easiest to understand by reading the commit. frames moved and the guest rate, host underruns, frames the host dropped and where the output goes. Underruns now count only while an output runs. It replaces `hal2 status`. +- `IRIS_AUDIO_DUMP=FILE` records what every audio card plays, at the card's own + rate before resampling, with or without a sound device (a developer + environment variable; the layout is in `FEATURES.md`). +- Compatibility: the developer environment variables `IRIS_HAL2_CAPTURE`, + `IRIS_O2_AUDIO_CAPTURE`, `IRIS_OCTANE_AUDIO_CAPTURE`, `IRIS_OCTANE_AUDIO_WAV` + and `IRIS_O2_AUDIO_DUMP` are gone. Use + `IRIS_AUDIO_DUMP` for the card's own samples, or `[audio] file` for a WAV of + what the host plays. The O2's dump keeps its layout. ### Every machine has a built-in PROM diff --git a/FEATURES.md b/FEATURES.md index cae18977b..4f243b307 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -674,6 +674,7 @@ and offline-tool controls are included so they are not mistaken for GUI settings | `IRIS_BREAK_CPU` | 1 stops CPU after exception handling for diagnostic inspection. | [src/cpu/mips_exec.rs](src/cpu/mips_exec.rs) | | `IRIS_CHD_DIFF_DIR` | Redirect CHD diff sidecars; GUI App Store sets a writable container directory. | [src/chd_disk.rs](src/chd_disk.rs) | | `IRIS_CONST_DUMP` | Presence prints selected disassembly lines in constant-dedup tests. | [src/cpu/jitv2/mod.rs](src/cpu/jitv2/mod.rs) | +| `IRIS_AUDIO_DUMP` | File to record what every audio card plays, at the card's own rate before resampling, with or without a host sound device (HAL2, RAD1, the O2's MACE audio, the Tezro's ICE1724). Records are the rate and frame count (u32 each) then left, right i16 pairs; the O2 puts its output number (u8) in front. The layout is in the module's header. | [src/dev/audio_dump.rs](src/dev/audio_dump.rs) | | `IRIS_CONST_MODE` | Select the opt-in constant-dedup codegen test shape. | [src/cpu/jitv2/mod.rs](src/cpu/jitv2/mod.rs) | | `IRIS_CORPUS_DIR` | Corpus directory consumed by opt-in JIT corpus tests. | [src/cpu/jitv2/mod.rs](src/cpu/jitv2/mod.rs) | | `IRIS_CRASH_DIAG` | off disables Windows crash handler installation. | [src/crash_diag.rs](src/crash_diag.rs) | @@ -683,7 +684,6 @@ and offline-tool controls are included so they are not mistaken for GUI settings | `IRIS_GUI_GL` | 1 selects the GUI GL capture path. | [iris-gui/src/framebuffer.rs](iris-gui/src/framebuffer.rs) | | `IRIS_GPRCACHE` | Guest registers a JIT v2 function with a loop keeps in host registers, 0 to 31 (`j2 gprcache`; default 6, 10 on AArch64). | [src/cpu/jitv2/codegen.rs](src/cpu/jitv2/codegen.rs) | | `IRIS_GPRCACHE_EVERYWHERE` | Presence caches registers in functions without a loop too. For test runs: slower to compile, no faster to run. | [src/cpu/jitv2/codegen.rs](src/cpu/jitv2/codegen.rs) | -| `IRIS_HAL2_CAPTURE` | HAL2 host audio capture output path. | [src/dev/hal2.rs](src/dev/hal2.rs) | | `IRIS_HOSTGL_CALLS` | Substring filter for HostGL call logging (bounded log). | [iris-hostgl/src/service.rs](iris-hostgl/src/service.rs) | | `IRIS_HOSTGL_INLINE` | 1 runs the HostGL service on the CPU thread instead of its own (for comparison; the CPU stalls while a buffer replays). | [iris-hostgl/src/lib.rs](iris-hostgl/src/lib.rs) | | `IRIS_HOSTGL_NOSURFACE` | 1 disables CGL IOSurface presentation. | [iris-hostgl/src/backend/cgl.rs](iris-hostgl/src/backend/cgl.rs) | @@ -715,8 +715,6 @@ and offline-tool controls are included so they are not mistaken for GUI settings | `IRIS_MGRAS_REC` | Record IMPACT bus operations from power-on to this path. | [src/dev/mgras/mod.rs](src/dev/mgras/mod.rs) | | `IRIS_O2_TRACE` | Trace the O2's rendering engine, GBE and CRIME from power-on to this path (`gputrace` builds). | [src/dev/o2gfx_trace.rs](src/dev/o2gfx_trace.rs) | | `IRIS_MGRAS_TRACE` | Trace IMPACT accesses from power-on to this path (`gputrace` builds). | [src/dev/mgras/mod.rs](src/dev/mgras/mod.rs) | -| `IRIS_O2_AUDIO_CAPTURE` | O2 host audio capture output path (what the host plays). | [src/dev/mace_audio.rs](src/dev/mace_audio.rs) | -| `IRIS_O2_AUDIO_DUMP` | File to record the O2's audio output at the guest's rate, before resampling. | [src/dev/mace_audio.rs](src/dev/mace_audio.rs) | | `IRIS_O2_AUDIO_TRACE` | Any presence traces every O2 audio register access and codec operation on stderr. | [src/dev/mace_audio.rs](src/dev/mace_audio.rs) | | `IRIS_ODSY_BACKEND` | `inline` keeps the VPro fragment pipeline's shading and back end (depth, stencil, blend and stores) on the raster thread, with no worker pool. | [src/dev/odyssey/pipe/ring.rs](src/dev/odyssey/pipe/ring.rs) | | `IRIS_ODSY_PIPE` | `old` draws VPro GL primitives through the previous rasteriser instead of the fragment pipeline that reads the board's own state words. | [src/dev/odyssey/pipe/mod.rs](src/dev/odyssey/pipe/mod.rs) | diff --git a/clanker/INDEX.md b/clanker/INDEX.md index b3e55f016..98d048bba 100644 --- a/clanker/INDEX.md +++ b/clanker/INDEX.md @@ -112,7 +112,8 @@ Non-graphics devices: MC/MEMCFG and RAM banks, HPC3, VDMA, EEPROMs and NVRAM, PS | [virtio-9p-design.md](devices/virtio-9p-design.md) | design | virtio-9p: 9P2000.L server over a host directory; why every path is walked with `O_NOFOLLOW`, what a fid is, open items | | [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 | | [embedded-prom-compression.md](devices/embedded-prom-compression.md) | measure | Which compression the built-in PROMs use: deflate per PROM against LZMA2, zstd and dictionaries, sizes, decode times and git cost; why deflate won | -| [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 | +| [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 what the card played (`IRIS_AUDIO_DUMP`) | +| [boot-chimes.md](devices/boot-chimes.md) | howto | What each machine's PROM plays at power-on (Indy, Indigo2, Octane, O2), and how to check it sample for sample with `IRIS_AUDIO_DUMP` | | [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 index 2de7a3f39..e1ed6eb01 100644 --- a/clanker/devices/audio-test-suite.md +++ b/clanker/devices/audio-test-suite.md @@ -1,6 +1,6 @@ # 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 +**Keywords:** audio,rad1,octane,o2,mace,sample rate,alSetParams,playaiff,sfplay,audiotest,sweep,capture,IRIS_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 @@ -46,23 +46,21 @@ 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. +to a file with `IRIS_AUDIO_DUMP=FILE`, what the card played at the guest's own +rate, before resampling, as records of rate (u32) and pair count (u32), then +the pairs, little-endian. The O2 puts the output number (u8) in front of +every record. It needs no host sound device. `check.py` measures each case +at its records' rate. (A WAV from `[audio] file` works too, at the stream's +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 +IRIS_AUDIO_DUMP=/path/cap iris --config octane.toml & # or 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 +python3 test/audiotest/check.py /path/cap /path/out/marks.txt # --o2 for the O2 ``` -`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 +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'`). @@ -126,9 +124,8 @@ and MACE only moved that pointer on the service thread's passes, about 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. +Indy and Indigo2 (HAL2): not covered by the sweep. `IRIS_AUDIO_DUMP` records +codec A at its own rate, in the same record layout minus the output number. ## Gotchas diff --git a/clanker/devices/boot-chimes.md b/clanker/devices/boot-chimes.md new file mode 100644 index 000000000..94ba886cc --- /dev/null +++ b/clanker/devices/boot-chimes.md @@ -0,0 +1,69 @@ +# Boot chimes: what each machine's PROM plays, and how to check it + +**Keywords:** boot chime,startup tune,power-on tune,hal2,hpc3,rad1,mace,ice1724,ip22,ip24,ip28,ip30,ip32,IRIS_AUDIO_DUMP + +iris carries no chime samples. Each PROM builds or decodes its tune in RAM with +its own code and starts the audio hardware playing it, so the sound is right +exactly when the CPU, FPU, DMA and audio models are. Nothing in iris picks or +substitutes a tune: another PROM revision plays another tune, and `prom = ""` +selects one. A chime is a cheap, disk-free end-to-end check of those models. + +| Machine | PROM | Tune | Rate | +|---|---|---|---| +| Indy IP24 | 070-9101-011 (embedded) | stored 4-bit ADPCM | 44.1 kHz | +| Indigo2 IP22 | 070-1367-012 (embedded) | synthesized plucked strings | 44.1 kHz | +| Indigo2 Impact IP28 | 070-1477-002 (embedded) | the same strings, R10000 build | 44.1 kHz | +| Octane IP30 | 4.17 (embedded) | stored 4-bit ADPCM | 32 kHz | +| O2 IP32 | 4.18 (embedded) | synthesized plucked strings | 34 kHz | + +## Running a check + +Run the machine headless with `IRIS_AUDIO_DUMP=` set, wait for the tune, +and stop it: + +```sh +IRIS_AUDIO_DUMP=chime.dump iris --headless --config indigo2.toml +``` + +The file holds what the audio card played, at the card's own rate before +resampling, with or without a host sound device: records of the rate (u32) and +the frame count (u32), then that many left, right pairs of little-endian i16. +The O2 has two outputs and puts the output number (u8) in front of each record. +The ICE1724 records the front pair of its mix. Each card records where it makes +its frames: HAL2 before its output gain, RAD1 after its mute words, MACE after +the DAC gain. (`[audio] file` writes a WAV of what the host plays instead.) + +What the dump asserts is the guest's work: the samples are what the PROM +computed and handed to the card, in order, at the rate it programmed. A tune +is therefore checked by comparing the dump with an independent computation of +the same tune. It does not exercise the host audio device or the resampler. + +Requirements: + +- The Indy and Indigo2 need audio on: `no_audio` removes HAL2, so nothing plays + and nothing is recorded. +- The IP28 needs `banks = [512, 512, 0, 0]` to chime. +- The Octane run needs the config's SCSI image files to exist. The O2 needs a + flash file (a new one is fine). +- A blank Indy or Indigo2 settings block is seeded with the factory defaults, so + the first boot chimes (see [nvram-persistence.md](nvram-persistence.md)). +- The IP22 tune takes the longest to start, on the order of a minute or more on + the interpreter. + +## What the repository itself verifies + +The recorder and each card's hook have unit tests that assert the exact bytes +recorded: `cargo test -p iris --lib -- audio_dump hal2 rad1 mace_audio ice1724 +host_pcm`. `test/audiotest/check.py` reads the same dump (`--o2` for the O2's +output byte) to judge the audio sweep; see [audio-test-suite.md](audio-test-suite.md). +The repository holds no model of any PROM tune. + +## IP35 + +The IP35 PROM does not touch the ICE1724 on iris's Tezro model (observed in a +headless run). + +## Other PROM revisions + +Set `prom` to another revision and its chime plays; an older 5.1 IP22 PROM +plays a stored ADPCM tune where the embedded 5.3 one synthesizes. diff --git a/clanker/devices/ip32-o2-bringup-log.md b/clanker/devices/ip32-o2-bringup-log.md index c66ae797a..2533ed0ee 100644 --- a/clanker/devices/ip32-o2-bringup-log.md +++ b/clanker/devices/ip32-o2-bringup-log.md @@ -50,8 +50,7 @@ Diagnostics that remain, all off unless set: | `IRIS_IP32_RE_TRACE=N` | keep the last N rendering-engine register writes; save with the control port's `retrace` | | `IRIS_IP32_MEC_TRACE` | Ethernet frames sent and received, and why a received one was dropped | | `IRIS_O2_AUDIO_TRACE` | audio output activity, and each time a ring ran dry between sounds | -| `IRIS_O2_AUDIO_DUMP=file` | every sample each output plays, at the guest's rate, before resampling | -| `IRIS_O2_AUDIO_CAPTURE=file` | what the host plays, after resampling and mixing (raw 16-bit stereo) | +| `IRIS_AUDIO_DUMP=file` | every sample each output plays, at the guest's rate, before resampling | ## The assessment (2026-09-19) @@ -1902,6 +1901,6 @@ was wrong, and what it turned out to be: Instruments added for it: the control port's `pixel X Y` (one scanned-out point: frame-buffer word, DID, mode-table entry, colour map, overlay) and `unmapped`; `IRIS_O2_FRAG_PROBE=x,y` (every triangle fragment at a pixel, -with texture coordinates, LOD and level); `IRIS_O2_AUDIO_DUMP` (each +with texture coordinates, LOD and level); `IRIS_AUDIO_DUMP` (each output's samples at the guest's rate); and the "ring ran dry" count in the audio trace. diff --git a/clanker/hostgl/host-calls-hold-the-cpu-thread.md b/clanker/hostgl/host-calls-hold-the-cpu-thread.md index 47f39e9e8..0578fc7f6 100644 --- a/clanker/hostgl/host-calls-hold-the-cpu-thread.md +++ b/clanker/hostgl/host-calls-hold-the-cpu-thread.md @@ -4,7 +4,7 @@ **Symptom.** Sound crackles under a busy host-GL program (a demo's loud bass notes, Quake), while the same program's sound alone plays clean. HAL2 -captures (`IRIS_HAL2_CAPTURE`) show short runs of silence, ~20 a second. +captures of the host stream show short runs of silence, ~20 a second. **Cause.** A host call (syscall 3000) is trapped in `exec_syscall` and answered on the thread that runs the emulated CPU. Until it returns the diff --git a/docs/indigo2-ip22.md b/docs/indigo2-ip22.md index 628f4bceb..303b77a67 100644 --- a/docs/indigo2-ip22.md +++ b/docs/indigo2-ip22.md @@ -13,7 +13,9 @@ No separate build is required — the same `iris` / `iris-gui` binary supports I - **PROM:** IRIS tries `prom` from the config, then `070-1367-012.bin` in the working directory, then falls back to an embedded Indigo2 PROM - (`data/proms/ip22.deflate`) — not the Indy one. + (`data/proms/ip22.deflate`) — not the Indy one. The boot chime comes from the PROM, + so setting `prom` to another revision plays that revision's tune: an older 5.1 + PROM plays a stored tune, not the embedded PROM's synthesized one. - **NVRAM and MAC:** the Indigo2 keeps its PROM environment and Ethernet address in a 93CS56 serial EEPROM, not the Indy's DS1386. It is loaded from `nveeprom` (default `nveeprom.bin`, `--nveeprom`) and written back with the monitor's diff --git a/src/dev/audio_dump.rs b/src/dev/audio_dump.rs new file mode 100644 index 000000000..5dada0fbc --- /dev/null +++ b/src/dev/audio_dump.rs @@ -0,0 +1,158 @@ +//! One recorder for what every audio card plays, set by +//! `IRIS_AUDIO_DUMP=`. +//! +//! The file is a run of records, each a sample rate and a frame count (u32 +//! each) followed by that many left, right pairs of i16, all little-endian. +//! Frames are recorded as the card produces them, at its own rate and before +//! any resampling or host mixing, with or without a host sound device, so a +//! muted run can be checked sample for sample against what the guest +//! computed. A new record starts every time the card plays a burst, so a rate +//! change shows up as a record with a different rate. The O2 has two outputs +//! and prefixes each record with the output number (u8); the other cards have +//! one stream and no prefix. The ICE1724 records the front pair of its mix. +//! For a WAV of what the host plays, use `[audio] file` instead. +//! +//! Each card records where it makes its frames, which is not the same point +//! everywhere: HAL2 records what it reads from DMA, before its output gain; +//! RAD1 records after its mute words (a muted card records silence); MACE +//! records after the DAC gain. +//! +//! A card holds an `Option>` built once when it is created, so +//! a run without the variable pays one branch per burst and nothing else. +//! Cards in one process share one file, so a second card does not truncate +//! the first one's records; records of two cards then interleave and the +//! format does not say which card wrote which. + +use parking_lot::Mutex; +use std::ffi::OsStr; +use std::io::{BufWriter, Write}; +use std::sync::{Arc, OnceLock}; + +/// The environment variable that names the dump file. +pub const ENV_VAR: &str = "IRIS_AUDIO_DUMP"; + +pub struct AudioDump { + out: Mutex>, +} + +impl AudioDump { + /// The dump `IRIS_AUDIO_DUMP` asks for, if it is set and the file opens. + /// A file that cannot be created is reported once and the recorder stays + /// off for the process: the path will not get better, and retrying per + /// card would only repeat the message. + pub fn from_env() -> Option> { + static SHARED: OnceLock>> = OnceLock::new(); + SHARED + .get_or_init(|| { + let path = std::env::var_os(ENV_VAR); + let dump = Self::from_var(path.as_deref()); + if let (None, Some(p)) = (&dump, &path) { + eprintln!("{ENV_VAR}: cannot create {}: audio is not recorded", p.to_string_lossy()); + } + dump.map(Arc::new) + }) + .clone() + } + + fn from_var(path: Option<&OsStr>) -> Option { + Self::create(path?) + } + + pub fn create(path: &OsStr) -> Option { + Some(Self { out: Mutex::new(BufWriter::new(std::fs::File::create(path).ok()?)) }) + } + + /// Append one record of `frames` played at `rate` Hz; `channel` is the + /// O2's output number and `None` everywhere else. An empty burst leaves + /// no record. Every record is flushed, so a killed run keeps its tune. + pub fn record(&self, channel: Option, rate: u32, frames: impl ExactSizeIterator) { + if frames.len() == 0 { + return; + } + let mut rec = Vec::with_capacity(9 + frames.len() * 4); + rec.extend(channel); + rec.extend_from_slice(&rate.to_le_bytes()); + rec.extend_from_slice(&(frames.len() as u32).to_le_bytes()); + for (l, r) in frames { + rec.extend_from_slice(&l.to_le_bytes()); + rec.extend_from_slice(&r.to_le_bytes()); + } + let mut out = self.out.lock(); + let _ = out.write_all(&rec); + let _ = out.flush(); + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + pub(crate) fn temp(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("iris-audio-dump-{}-{name}", std::process::id())) + } + + /// The records of a dump file without output numbers, as (rate, frames). + pub(crate) fn records(bytes: &[u8]) -> Vec<(u32, Vec<(i16, i16)>)> { + let mut out = Vec::new(); + let mut at = 0; + while at < bytes.len() { + let rate = u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()); + let n = u32::from_le_bytes(bytes[at + 4..at + 8].try_into().unwrap()) as usize; + at += 8; + let frames = bytes[at..at + n * 4] + .chunks_exact(4) + .map(|f| (i16::from_le_bytes([f[0], f[1]]), i16::from_le_bytes([f[2], f[3]]))) + .collect(); + at += n * 4; + out.push((rate, frames)); + } + out + } + + fn record(rate: u32, pairs: &[(i16, i16)]) -> Vec { + let mut v = rate.to_le_bytes().to_vec(); + v.extend_from_slice(&(pairs.len() as u32).to_le_bytes()); + for (l, r) in pairs { + v.extend_from_slice(&l.to_le_bytes()); + v.extend_from_slice(&r.to_le_bytes()); + } + v + } + + /// Each burst is its own record at its own rate, an empty burst leaves + /// none, and the bytes are on disk while the dump is still open. + #[test] + fn a_rate_change_starts_a_new_record_and_every_record_is_flushed() { + let path = temp("rate"); + let dump = AudioDump::create(path.as_os_str()).unwrap(); + dump.record(None, 44100, [(1, -2), (300, -400)].into_iter()); + dump.record(None, 44100, std::iter::empty()); + dump.record(None, 32000, [(i16::MIN, i16::MAX)].into_iter()); + let bytes = std::fs::read(&path).unwrap(); + let _ = std::fs::remove_file(&path); + let mut want = record(44100, &[(1, -2), (300, -400)]); + want.extend(record(32000, &[(i16::MIN, i16::MAX)])); + assert_eq!(bytes, want); + assert_eq!(records(&bytes).len(), 2); + } + + /// The O2's two outputs share a file, told apart by a leading byte. + #[test] + fn a_channel_number_prefixes_the_record() { + let path = temp("channel"); + let dump = AudioDump::create(path.as_os_str()).unwrap(); + dump.record(Some(1), 8000, [(5, 6)].into_iter()); + let bytes = std::fs::read(&path).unwrap(); + let _ = std::fs::remove_file(&path); + let mut want = vec![1]; + want.extend(record(8000, &[(5, 6)])); + assert_eq!(bytes, want); + } + + #[test] + fn without_the_variable_there_is_no_recorder_and_no_file() { + assert!(AudioDump::from_var(None).is_none()); + let missing = temp("no-such-dir").join("x"); + assert!(AudioDump::from_var(Some(missing.as_os_str())).is_none()); + } +} diff --git a/src/dev/hal2.rs b/src/dev/hal2.rs index 33c75a556..facd79a75 100644 --- a/src/dev/hal2.rs +++ b/src/dev/hal2.rs @@ -6,6 +6,7 @@ use std::time::{Duration, Instant}; use std::io::Write; use crate::traits::{BusRead8, BusRead16, BusRead32, BusRead64, BUS_OK, BUS_ERR, Device, DmaClient}; use crate::config::{AudioConfig, AudioHost}; +use crate::dev::audio_dump::AudioDump; use crate::hptimer::{TimerManager, TimerId, TimerReturn}; use crate::host_audio::{HostOut as AudioOut, Meter, Resampler}; @@ -381,6 +382,9 @@ pub struct Hal2 { meter: Arc, // [audio]: which host to play through, and its buffer size. audio: AudioConfig, + /// IRIS_AUDIO_DUMP=: every frame codec A plays, at the codec's + /// own rate, before resampling (see `audio_dump`). + dump: Option>, } // ─── cpal helpers ───────────────────────────────────────────────────────────── @@ -392,7 +396,7 @@ fn prebuf_samples(rate: u32) -> usize { /// The host output, through the shared `host_audio` (ring sized for HAL2's /// prebuffer). fn open_persistent_output(audio: &AudioConfig, meter: Arc) -> Option { - crate::host_audio::open_output(audio, meter, |rate| prebuf_samples(rate) * RING_BUF_MULTIPLIER, "HAL2", "IRIS_HAL2_CAPTURE") + crate::host_audio::open_output(audio, meter, |rate| prebuf_samples(rate) * RING_BUF_MULTIPLIER, "HAL2") } // ─── impl Hal2 ──────────────────────────────────────────────────────────────── @@ -432,6 +436,7 @@ impl Hal2 { ar_state: Arc::new(Mutex::new(AesRxState { loopback: std::collections::VecDeque::new(), timer_id: None })), meter: Meter::new("HAL2", &["analog in", "analog out"]), audio, + dump: crate::dev::audio_dump::AudioDump::from_env(), } } @@ -515,6 +520,7 @@ impl Hal2 { let dma_client = self.dma_clients[dma_ch].clone(); let state = self.state.clone(); let ca_state = self.ca_state.clone(); + let dump = self.dump.clone(); let mut pacer = Pacer::new(pitch_rate); let meter = self.meter.clone(); @@ -543,7 +549,7 @@ impl Hal2 { let stream_rate = match st.out.as_ref() { Some(o) => o.stream_rate, None => { - drain_without_host(&dma_client, mode, due, &meter); + drain_without_host(&dma_client, mode, due, &meter, dump.as_deref(), pitch_rate); return TimerReturn::Continue; } }; @@ -556,11 +562,15 @@ impl Hal2 { } let mut got = 0; + let mut played = dump.as_ref().map(|_| Vec::with_capacity(due as usize)); for _ in 0..due { let frame = read_frame_from(&dma_client, mode); match frame { Some((l, r)) => { got += 1; + if let Some(p) = played.as_mut() { + p.push((l, r)); + } st.dry = 0; if !st.nonzero_seen && (l != 0 || r != 0) { dlog_dev!(LogModule::Hal2, "HAL2: Codec A first non-zero: l={} r={}", l, r); @@ -606,6 +616,9 @@ impl Hal2 { } } + if let (Some(d), Some(p)) = (dump.as_ref(), played) { + d.record(None, pitch_rate, p.into_iter()); + } meter.moved(1, got); TimerReturn::Continue @@ -1122,8 +1135,16 @@ impl Hal2 { /// With no host output, still read what the channel has so the guest's DMA /// completes, and count it: the card is playing while the channel has frames. -fn drain_without_host(client: &Arc, mode: usize, due: u64, meter: &Meter) { - let got = (0..due).filter(|_| read_frame_from(client, mode).is_some()).count() as u64; +fn drain_without_host(client: &Arc, mode: usize, due: u64, meter: &Meter, dump: Option<&AudioDump>, rate: u32) { + let frames = (0..due).filter_map(|_| read_frame_from(client, mode)); + let got = match dump { + Some(d) => { + let played: Vec<_> = frames.collect(); + d.record(None, rate, played.iter().copied()); + played.len() as u64 + } + None => frames.count() as u64, + }; meter.moved(1, got); if got > 0 { meter.started(); @@ -1462,13 +1483,48 @@ mod host_tests { let dma: Arc = Arc::new(Looping { words: vec![0x1234; 8], at: Default::default(), late: AtomicBool::new(false) }); let client: Arc = dma.clone(); let meter = Meter::new("test", &["analog in", "analog out"]); - drain_without_host(&client, MODE_STEREO, 4, &meter); + drain_without_host(&client, MODE_STEREO, 4, &meter, None, 44100); assert_eq!((meter.frames(1), meter.playing()), (4, true)); dma.late.store(true, Ordering::Relaxed); - drain_without_host(&client, MODE_STEREO, 4, &meter); + drain_without_host(&client, MODE_STEREO, 4, &meter, None, 44100); assert_eq!((meter.frames(1), meter.playing()), (4, false)); } + /// With `IRIS_AUDIO_DUMP` set, codec A records the frames it reads from + /// its DMA channel at the codec's rate, host stream or not: here none is + /// open, and the file holds the buffer sample for sample. + #[test] + fn the_dump_holds_what_codec_a_played_with_no_host_stream() { + use crate::dev::audio_dump::{tests::{records, temp}, AudioDump}; + let tone = tones(512, 48000, 5.0 * 48000.0 / 512.0, 10.0 * 48000.0 / 512.0, 16000.0); + let dma = Arc::new(Looping { words: tone.iter().map(|&v| u32::from(v as u16)).collect(), at: Default::default(), late: AtomicBool::new(false) }); + let path = temp("hal2"); + let mut h = Hal2::new(vec![dma.clone()]); + h.dump = AudioDump::create(path.as_os_str()).map(Arc::new); + h.set_timer_manager(Arc::new(TimerManager::new())); + { + let mut s = h.state.lock(); + s.bres_clock_rate[0] = 48000; + s.codeca_ctrl[0] = (1 << CTRL1_CLOCK_SHIFT | (MODE_STEREO as u16) << CTRL1_MODE_SHIFT) as u16; + s.dma_enable = DMA_EN_CODECA; + } + h.apply_dma_enable(0, DMA_EN_CODECA); + // Wait for a few buffers' worth, not a fixed time: 8 bytes of record + // header and 4 of sample per frame. + let deadline = Instant::now() + Duration::from_secs(10); + while std::fs::metadata(&path).map_or(0, |m| m.len()) < 4 * 2 * 512 + 64 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + Device::stop(&h); + let recs = records(&std::fs::read(&path).unwrap()); + let _ = std::fs::remove_file(&path); + assert!(!recs.is_empty() && recs.iter().all(|(rate, _)| *rate == 48000)); + let played: Vec = recs.iter().flat_map(|(_, f)| f.iter().flat_map(|&(l, r)| [l, r])).collect(); + assert!(played.len() > 2 * 512, "{} samples", played.len()); + let want: Vec = tone.iter().copied().cycle().take(played.len()).collect(); + assert_eq!(played, want); + } + /// Codec A playing a 440 Hz tone on the left and 880 Hz on the right, /// its HPC3 channel running out for 0.3 s (a chain that ended, which the /// channel reports as nothing to read) so the host stream runs dry, then diff --git a/src/dev/host_pcm.rs b/src/dev/host_pcm.rs index 1b476e3ab..a8c9bf316 100644 --- a/src/dev/host_pcm.rs +++ b/src/dev/host_pcm.rs @@ -1,5 +1,5 @@ //! What a PCI audio card with a host-timed sample clock shares with the -//! others: the host output (a stereo [`Sink`] with a WAV capture of it, and +//! others: the host output (a stereo [`Sink`], and //! the multichannel [`PcmOut`] with its [`Downmix`]), and the clock that //! stands still while the machine is stopped. The host streams themselves //! are in [`crate::host_audio`]. @@ -13,25 +13,24 @@ use std::time::{Duration, Instant}; use parking_lot::Mutex; -use crate::host_audio::{FrameResampler, HostOut, Resampler, WavWriter}; +use crate::dev::audio_dump::AudioDump; +use crate::host_audio::{FrameResampler, HostOut, Resampler}; /// Words a host frame carries: the mixer takes two channels. pub const PCM_CHANNELS: usize = 2; -/// The WAV capture's rate. -const WAV_RATE: u32 = 44100; /// Most of the card's clock held back for hold-ups, and the share of an /// interval won back per on-time completion (see [`CardClock::absorb_stall`]). const STALL_MAX_NS: i64 = 200_000_000; const STALL_REPAY_DIV: i64 = 32; /// Where decoded output goes: the host stream (through the shared resampler), -/// an optional 44.1 kHz WAV file of what the card played, and for tests a -/// plain buffer of the card's own frames. +/// an optional recording of what the card played (`IRIS_AUDIO_DUMP`), and for +/// tests a plain buffer of the card's own frames. pub struct Sink { pub host: Option<(HostOut, Resampler)>, /// When the host stream was last tried and failed to open. pub host_failed_at: Option, - pub wav: Option, + pub dump: Option>, pub tap: Option>>>, } @@ -50,7 +49,7 @@ impl Sink { } /// Play stereo frames the card produced at `rate` Hz. A muted output - /// still moves the ring; the host and the WAV get silence. + /// still moves the ring; the host and the dump get silence. pub fn play(&mut self, pcm: &[i16], rate: u32, muted: bool) { let silence; let host_pcm = if muted { @@ -67,8 +66,8 @@ impl Sink { out.push(rs, p[0], p[1]); } } - if let Some(w) = self.wav.as_mut() { - w.play(host_pcm, rate); + if let Some(d) = self.dump.as_ref() { + d.record(None, rate, host_pcm.chunks_exact(PCM_CHANNELS).map(|p| (p[0], p[1]))); } if let Some(t) = self.tap.as_ref() { t.lock().extend_from_slice(pcm); @@ -76,45 +75,6 @@ impl Sink { } } -/// 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, set by the card that owns the sink. -pub struct Wav { - out: WavWriter, - rs: Resampler, -} - -impl Wav { - pub fn create(path: &std::ffi::OsStr) -> Option { - let out = WavWriter::create(std::path::Path::new(path), WAV_RATE, PCM_CHANNELS as u16).ok()?; - Some(Self { out, rs: Resampler::new(WAV_RATE, WAV_RATE) }) - } - - /// 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); - } - - pub fn append(&mut self, pcm: &[i16]) { - let _ = self.out.append(pcm); - } -} - // ---- multichannel output ---- // Speaker positions, numbered in the order of the usual host layouts. @@ -207,6 +167,9 @@ pub struct PcmOut { pub host: Option<(HostOut, FrameResampler)>, /// When the host stream was last tried and failed to open. pub host_failed_at: Option, + /// `IRIS_AUDIO_DUMP`: the front pair of each mixed frame at the card's + /// rate, before resampling. + pub dump: Option>, /// For tests: the mixed frames at the card's rate, as the host stream /// would get them, with `tap_channels` to a frame when there is no /// host stream. @@ -231,6 +194,7 @@ impl PcmOut { Self { host, host_failed_at, + dump: AudioDump::from_env(), #[cfg(test)] tap: None, #[cfg(test)] @@ -310,6 +274,9 @@ impl PcmOut { if spdif_on { self.spdif.drain(..n * 2); } + if let Some(d) = self.dump.as_ref() { + d.record(None, rate, self.mixed.chunks_exact(chans).map(|f| (f[0], f[1]))); + } #[cfg(test)] if let Some(t) = self.tap.as_ref() { t.lock().extend_from_slice(&self.mixed); @@ -577,4 +544,20 @@ mod tests { // 0xc000 does not fit in 16 bits: it clips, it does not wrap. assert_eq!(*tap.lock(), [0x7fff, i16::MIN]); } + + /// The dump holds the front pair of what the card mixed, at the card's + /// rate, whatever width the host stream has. + #[test] + fn the_dump_holds_the_front_pair_of_the_mix() { + use crate::dev::audio_dump::{tests::{records, temp}, AudioDump}; + let path = temp("pcmout"); + let mut out = PcmOut::new(None, None); + out.dump = AudioDump::create(path.as_os_str()).map(Arc::new); + out.tap_channels = 8; + out.analog_stage(8).extend([100, -200, 7, 7, 7, 7, 7, 7, 300, -400, 7, 7, 7, 7, 7, 7]); + out.flush(44100, true, false); + let recs = records(&std::fs::read(&path).unwrap()); + let _ = std::fs::remove_file(&path); + assert_eq!(recs, [(44100, vec![(100, -200), (300, -400)])]); + } } diff --git a/src/dev/ice1724.rs b/src/dev/ice1724.rs index b42e4ee36..365b97a3d 100644 --- a/src/dev/ice1724.rs +++ b/src/dev/ice1724.rs @@ -918,7 +918,7 @@ impl Inner { } } let ring = |rate: u32| rate as usize * 2 / 5; - let host = crate::host_audio::open_output_channels(&cfg, MAX_HOST_CHANNELS, self.meter.clone(), ring, "ICE1724 audio", "IRIS_ICE1724_AUDIO_CAPTURE"); + let host = crate::host_audio::open_output_channels(&cfg, MAX_HOST_CHANNELS, self.meter.clone(), ring, "ICE1724 audio"); crate::dlog!(LogModule::Ice1724, "ice1724: host audio {}", if host.is_some() { "open" } else { "failed to open" }); let host = host.map(|out| { let rs = FrameResampler::new(rate, out.stream_rate, out.channels); diff --git a/src/dev/ice1724/tests.rs b/src/dev/ice1724/tests.rs index 4162db8ad..99ab91997 100644 --- a/src/dev/ice1724/tests.rs +++ b/src/dev/ice1724/tests.rs @@ -843,6 +843,32 @@ fn the_host_ring_gets_whole_frames_at_the_stream_rate() { } } +/// With `IRIS_AUDIO_DUMP` set the card records the front pair of each frame +/// it plays, at the card's rate and with the host stream's width whatever it is. +#[test] +fn the_dump_holds_the_front_pair_at_the_card_rate() { + use crate::dev::audio_dump::{tests::{records, temp}, AudioDump}; + for (host_rate, channels) in [(48000, 8), (96000, 2)] { + let path = temp("ice1724"); + let r = rig(Variant::Dm8); + let _c = detached_host(&r, host_rate, channels); + r.sink.lock().as_mut().unwrap().dump = AudioDump::create(path.as_os_str()).map(Arc::new); + fill_slot_numbers(&r, 8); + mt_wl(&r, 0x10, BUFFER as u32); + mt_ww(&r, 0x14, 959); + mt_wb(&r, 0x18, 0x01); + step(&r, 1_000_000); + let recs = records(&std::fs::read(&path).unwrap()); + let _ = std::fs::remove_file(&path); + assert!(!recs.is_empty() && recs.iter().all(|(rate, _)| *rate == 48000), "{host_rate} Hz host"); + let frames: Vec<(i16, i16)> = recs.into_iter().flat_map(|(_, f)| f).collect(); + assert_eq!(frames.len(), 48); + if channels == 8 { + assert!(frames.iter().all(|&f| f == (0x100, 0x200)), "{:?}", &frames[..2]); + } + } +} + #[test] fn a_full_host_ring_counts_dropped_frames() { let r = rig(Variant::Dm8); diff --git a/src/dev/mace_audio.rs b/src/dev/mace_audio.rs index 041b834ef..46a276620 100644 --- a/src/dev/mace_audio.rs +++ b/src/dev/mace_audio.rs @@ -294,9 +294,9 @@ pub struct MaceAudio { gaps: [AtomicU64; 2], gap_items: [AtomicU64; 2], last_report: Mutex>, - /// IRIS_O2_AUDIO_DUMP=: every sample each output plays, at the + /// IRIS_AUDIO_DUMP=: every sample each output plays, at the /// guest's rate, before resampling (see `dump`). - dump: Mutex>>, + dump: Option>, /// Its bits of MACE's ISA status (0..7). irq: std::sync::OnceLock>, } @@ -319,11 +319,7 @@ impl MaceAudio { gaps: [AtomicU64::new(0), AtomicU64::new(0)], gap_items: [AtomicU64::new(0), AtomicU64::new(0)], last_report: Mutex::new(None), - dump: Mutex::new( - std::env::var_os("IRIS_O2_AUDIO_DUMP") - .and_then(|p| std::fs::File::create(p).ok()) - .map(std::io::BufWriter::new), - ), + dump: crate::dev::audio_dump::AudioDump::from_env(), irq: std::sync::OnceLock::new(), } } @@ -605,28 +601,14 @@ impl MaceAudio { } } - /// Append one record per output that played: the output (u8), its rate - /// and pair count (u32 each), then the pairs, all little-endian. Works - /// with or without host output, so a muted run can still be checked. + /// Append one record per output that played, tagged with the output + /// number (see `audio_dump`). Works with or without host output, so a + /// muted run can still be checked. fn dump(&self, pcm: &[Vec<(i16, i16)>; 2], rates: &[u32; 2]) { - use std::io::Write; - let mut dump = self.dump.lock(); - let Some(w) = dump.as_mut() else { return }; + let Some(dump) = self.dump.as_ref() else { return }; for out in 0..2 { - if pcm[out].is_empty() { - continue; - } - let mut rec = Vec::with_capacity(9 + pcm[out].len() * 4); - rec.push(out as u8); - rec.extend_from_slice(&rates[out].to_le_bytes()); - rec.extend_from_slice(&(pcm[out].len() as u32).to_le_bytes()); - for &(l, r) in &pcm[out] { - rec.extend_from_slice(&l.to_le_bytes()); - rec.extend_from_slice(&r.to_le_bytes()); - } - let _ = w.write_all(&rec); + dump.record(Some(out as u8), rates[out], pcm[out].iter().copied()); } - let _ = w.flush(); } /// Resample each channel to the host rate, mix and push. @@ -635,7 +617,7 @@ impl MaceAudio { if player.is_none() && !self.tried_open.swap(true, Ordering::Relaxed) { if let Some(cfg) = self.config.lock().clone() { let ring = |rate: u32| rate as usize * 2 / 5; // 200 ms of stereo - *player = crate::host_audio::open_output(&cfg, self.meter.clone(), ring, "O2 audio", "IRIS_O2_AUDIO_CAPTURE") + *player = crate::host_audio::open_output(&cfg, self.meter.clone(), ring, "O2 audio") .map(|out| Player { out, rs: [None, None] }); } } @@ -834,6 +816,30 @@ mod tests { assert_eq!(std::iter::from_fn(|| c.pop().ok()).collect::>(), [i16::MAX, i16::MIN]); } + /// The dump keeps the layout it has always had on the O2: a record per + /// output that played, the output number ahead of the rate and count. + #[test] + fn the_dump_records_each_output_that_played() { + let path = std::env::temp_dir().join(format!("iris-mace-dump-{}.bin", std::process::id())); + let mut a = MaceAudio::new(); + a.dump = crate::dev::audio_dump::AudioDump::create(path.as_os_str()).map(Arc::new); + a.dump(&[vec![(1, -2)], vec![]], &[48000, 8000]); + a.dump(&[vec![], vec![(3, 4), (5, 6)]], &[48000, 8000]); + let bytes = std::fs::read(&path).unwrap(); + let _ = std::fs::remove_file(&path); + let mut want = Vec::new(); + for (out, rate, pairs) in [(0u8, 48000u32, vec![(1i16, -2i16)]), (1, 8000, vec![(3, 4), (5, 6)])] { + want.push(out); + want.extend_from_slice(&rate.to_le_bytes()); + want.extend_from_slice(&(pairs.len() as u32).to_le_bytes()); + for (l, r) in pairs { + want.extend_from_slice(&l.to_le_bytes()); + want.extend_from_slice(&r.to_le_bytes()); + } + } + assert_eq!(bytes, want); + } + #[test] fn save_load_round_trip() { use crate::traits::Saveable; diff --git a/src/dev/mod.rs b/src/dev/mod.rs index 252754ac3..8aa4414e3 100644 --- a/src/dev/mod.rs +++ b/src/dev/mod.rs @@ -2,6 +2,7 @@ //! IMPACT); the rest of the system's devices sit here as single files. pub mod aic7880; +pub mod audio_dump; pub mod crime_mem; pub mod o2gfx; pub mod o2gfx_trace; diff --git a/src/dev/rad1.rs b/src/dev/rad1.rs index 914c1c0f6..98fc705ee 100644 --- a/src/dev/rad1.rs +++ b/src/dev/rad1.rs @@ -75,7 +75,7 @@ use parking_lot::Mutex; use std::time::{Duration, Instant}; use crate::config::AudioConfig; -use crate::dev::host_pcm::{CardClock, Sink, Wav, PCM_CHANNELS}; +use crate::dev::host_pcm::{CardClock, Sink, PCM_CHANNELS}; use crate::devlog::LogModule; use crate::host_audio::{HostOut, Meter, Resampler}; use crate::hptimer::{TimerId, TimerManager, TimerReturn}; @@ -669,7 +669,7 @@ pub struct Inner { /// The host stream: 200 ms of stereo ring at the stream's rate. fn open_host_output(card: &Inner, cfg: &AudioConfig) -> Option { let ring = |rate: u32| rate as usize * 2 / 5; - crate::host_audio::open_output(cfg, card.meter.clone(), ring, "Octane audio", "IRIS_OCTANE_AUDIO_CAPTURE") + crate::host_audio::open_output(cfg, card.meter.clone(), ring, "Octane audio") } impl Deref for Inner { @@ -815,8 +815,8 @@ impl Inner { sk.host_failed_at = failed_at; } None => { - let wav = std::env::var_os("IRIS_OCTANE_AUDIO_WAV").and_then(|p| Wav::create(&p)); - *sink = Some(Sink { host, host_failed_at: failed_at, wav, tap: None }); + let dump = crate::dev::audio_dump::AudioDump::from_env(); + *sink = Some(Sink { host, host_failed_at: failed_at, dump, tap: None }); } } } diff --git a/src/dev/rad1/tests.rs b/src/dev/rad1/tests.rs index be0f655b2..6177cc3a3 100644 --- a/src/dev/rad1/tests.rs +++ b/src/dev/rad1/tests.rs @@ -514,18 +514,20 @@ fn reset_returns_the_generators_to_the_default_rate() { } #[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, host_failed_at: None, wav: Wav::create(path.as_os_str()), tap: None }; - sink.play(&[0i16; 2 * 2205], 22050, false); - sink.play(&[0i16; 2 * 4410], 44100, false); - 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); +fn the_dump_records_each_burst_at_the_card_rate() { + let path = crate::dev::audio_dump::tests::temp("rad1"); + let dump = crate::dev::audio_dump::AudioDump::create(path.as_os_str()).map(Arc::new); + let mut sink = Sink { host: None, host_failed_at: None, dump, tap: None }; + sink.play(&[1, -2, 300, -400], 44100, false); + sink.play(&[i16::MIN, i16::MAX], 32000, false); + // A muted card plays silence, and the dump records what is played. + sink.play(&[5, 6], 32000, true); + let recs = crate::dev::audio_dump::tests::records(&std::fs::read(&path).unwrap()); + let _ = std::fs::remove_file(&path); + assert_eq!( + recs, + [(44100, vec![(1, -2), (300, -400)]), (32000, vec![(i16::MIN, i16::MAX)]), (32000, vec![(0, 0)])] + ); } #[test] @@ -851,7 +853,7 @@ fn host_frames_encode_into_ring_frames() { fn output_ring_reaches_the_sink() { let (r, ram) = dma_card(); let tap = Arc::new(parking_lot::Mutex::new(Vec::new())); - *r.sink.lock() = Some(Sink { host: None, host_failed_at: None, wav: None, tap: Some(tap.clone()) }); + *r.sink.lock() = Some(Sink { host: None, host_failed_at: None, dump: None, tap: Some(tap.clone()) }); // A 512-frame ring (4 KiB), each frame left = n, right = -n. { let mut m = ram.0.lock().unwrap(); @@ -889,7 +891,7 @@ fn output_ring_reaches_the_sink() { fn input_ring_is_not_played() { let (r, _ram) = dma_card(); let tap = Arc::new(parking_lot::Mutex::new(Vec::new())); - *r.sink.lock() = Some(Sink { host: None, host_failed_at: None, wav: None, tap: Some(tap.clone()) }); + *r.sink.lock() = Some(Sink { host: None, host_failed_at: None, dump: None, tap: Some(tap.clone()) }); program(&r, &RING_INPUT, 0x11_0000, 0xffff_f000); wr(&r, 0x4c, 1); at(&r, 1_000_000); @@ -897,23 +899,6 @@ fn input_ring_is_not_played() { assert!(tap.lock().is_empty()); } -#[test] -fn wav_file_has_a_valid_header_and_data() { - let dir = std::env::temp_dir().join(format!("rad1-wav-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("o.wav"); - let mut w = Wav::create(path.as_os_str()).unwrap(); - w.append(&[1, -1, 2, -2]); - let b = std::fs::read(&path).unwrap(); - assert_eq!(&b[..4], b"RIFF"); - assert_eq!(u32::from_le_bytes(b[4..8].try_into().unwrap()), 36 + 8); - assert_eq!(u32::from_le_bytes(b[24..28].try_into().unwrap()), 44100); - assert_eq!(u32::from_le_bytes(b[40..44].try_into().unwrap()), 8); - assert_eq!(i16::from_le_bytes(b[46..48].try_into().unwrap()), -1); - assert_eq!(b.len(), 52); - let _ = std::fs::remove_dir_all(&dir); -} - // Cases from the QEMU PCM conversion unit test: two frames with opposite-sign // channels expose bit position, sign, channel and stride errors. @@ -995,7 +980,7 @@ fn a_recorded_adat_frame_plays_back_unchanged() { #[test] fn a_failed_host_open_is_retried_after_a_wait() { let now = Instant::now(); - let mut sink = Sink { host: None, host_failed_at: None, wav: None, tap: None }; + let mut sink = Sink { host: None, host_failed_at: None, dump: None, tap: None }; assert!(sink.host_retry_due(now), "never tried"); sink.host_failed_at = Some(now); assert!(!sink.host_retry_due(now), "just failed"); @@ -1006,7 +991,7 @@ const SEC: i64 = 1_000_000_000; fn tapped(r: &Rad1) -> Arc>> { let tap = Arc::new(parking_lot::Mutex::new(Vec::new())); - *r.sink.lock() = Some(Sink { host: None, host_failed_at: None, wav: None, tap: Some(tap.clone()) }); + *r.sink.lock() = Some(Sink { host: None, host_failed_at: None, dump: None, tap: Some(tap.clone()) }); tap } @@ -1123,27 +1108,28 @@ fn a_link_loads_the_descriptor_ram_entry_it_names() { #[test] fn the_relay_and_dac_mute_words_silence_the_output_but_not_the_transfer() { - let path = std::env::temp_dir().join(format!("rad1-mute-{}.wav", std::process::id())); + use crate::dev::audio_dump::{tests::{records, temp}, AudioDump}; + let path = temp("rad1-mute"); // The relay (0x88) and the DAC (0x8c): the word that mutes, the word that releases. for (reg, mute, release) in [(0x88, 2, 3), (0x8c, 3, 2)] { let (r, ram, tap) = tune_card(); ram.0.lock().unwrap()[0x10_0000..0x10_1000].fill(0x55); - r.sink.lock().as_mut().unwrap().wav = Wav::create(path.as_os_str()); + r.sink.lock().as_mut().unwrap().dump = AudioDump::create(path.as_os_str()).map(Arc::new); program(&r, &RING_OUTPUT, 0x10_0000, 0xffff_f008); wr(&r, 0x58, 0x2000_0001); - let wav_at = |ms: i64| { + let played_at = |ms: i64| { at(&r, ms * SEC / 1000); rd(&r, 0x58); - std::fs::read(&path).unwrap() + records(&std::fs::read(&path).unwrap()).into_iter().flat_map(|(_, f)| f).collect::>() }; - let a = wav_at(10); - assert!(a[44..].iter().any(|&b| b != 0), "not muted at power-on"); + let a = played_at(10); + assert!(a.iter().any(|&f| f != (0, 0)), "not muted at power-on"); wr(&r, reg, mute); - let b = wav_at(20); - assert!(b.len() > a.len() && b[a.len()..].iter().all(|&x| x == 0), "{reg:#x} silences"); + let b = played_at(20); + assert!(b.len() > a.len() && b[a.len()..].iter().all(|&f| f == (0, 0)), "{reg:#x} silences"); wr(&r, reg, release); - let c = wav_at(30); - assert!(c[b.len()..].iter().any(|&x| x != 0), "{reg:#x} releases"); + let c = played_at(30); + assert!(c[b.len()..].iter().any(|&f| f != (0, 0)), "{reg:#x} releases"); assert!(r.state.lock().chan[CH_OUT].running && tap.lock().len() > 2 * 441 * 2); } let _ = std::fs::remove_file(&path); @@ -1484,7 +1470,7 @@ fn the_mediaio_card_plays_through_its_0x88_and_0x8c_writes() { // A 44.1 kHz host stream, so the ring's samples arrive unresampled. let (p, mut host) = rtrb::RingBuffer::new(44_100); let host_out = (HostOut::detached(r.meter.clone(), 44_100, p), Resampler::new(44_100, 44_100)); - *r.sink.lock() = Some(Sink { host: Some(host_out), host_failed_at: None, wav: None, tap: None }); + *r.sink.lock() = Some(Sink { host: Some(host_out), host_failed_at: None, dump: None, tap: None }); program(&r, &RING_OUTPUT, 0x10_0000, 0xffff_f008); wr(&r, 0x58, 0x2000_0001); let writes = [(0x8c, 3), (0x8c, 2), (0x8c, 3), (0x88, 2), (0x88, 3), (0x88, 2)]; @@ -1713,7 +1699,7 @@ fn audio_status_names_the_mediaio_card_and_its_host_stream() { let r = Rad1::mediaio(); let (prod, _cons) = rtrb::RingBuffer::::new(64); let out = HostOut::detached(r.meter.clone(), 48000, prod); - *r.inner.sink.lock() = Some(Sink { host: Some((out, Resampler::new(44100, 48000))), host_failed_at: None, wav: None, tap: None }); + *r.inner.sink.lock() = Some(Sink { host: Some((out, Resampler::new(44100, 48000))), host_failed_at: None, dump: None, tap: None }); *r.inner.audio_cfg.lock() = Some(AudioConfig::default()); let text = r.audio_report().unwrap(); assert!(text.starts_with("audio: RAD1 (MediaIO)\n"), "{text}"); @@ -1728,7 +1714,7 @@ fn playing_card() -> (Rad1, Box u64>) { r.armed.store(true, Ordering::SeqCst); let (prod, cons) = rtrb::RingBuffer::::new(4096); let out = HostOut::detached(r.meter.clone(), 44100, prod); - *r.sink.lock() = Some(Sink { host: Some((out, Resampler::new(44100, 44100))), host_failed_at: None, wav: None, tap: None }); + *r.sink.lock() = Some(Sink { host: Some((out, Resampler::new(44100, 44100))), host_failed_at: None, dump: None, tap: None }); let (meter, cons, _ram) = (r.meter.clone(), std::sync::Mutex::new(cons), ram); // Drain what the card wrote, then let the host find one frame of silence. let underrun = Box::new(move || { diff --git a/src/host_audio.rs b/src/host_audio.rs index 2853166e0..ca4fa6bf4 100644 --- a/src/host_audio.rs +++ b/src/host_audio.rs @@ -337,17 +337,14 @@ fn output_buffer(audio: &AudioConfig, host: cpal::HostId) -> cpal::BufferSize { /// Open a persistent stereo i16 cpal output stream, trying PREFERRED_RATES in /// order, with a ring of `ring_samples(rate)` samples. `who` prefixes the -/// messages; the variable `capture_var`, if set to a file, also records what -/// the host plays (raw interleaved 16-bit little-endian stereo at the stream -/// rate). The stream plays silence when the ring buffer is empty. +/// messages. The stream plays silence when the ring buffer is empty. pub fn open_output( audio: &AudioConfig, meter: Arc, ring_samples: impl Fn(u32) -> usize, who: &str, - capture_var: &str, ) -> Option { - open_output_channels(audio, 2, meter, ring_samples, who, capture_var) + open_output_channels(audio, 2, meter, ring_samples, who) } /// The standard speaker counts a stream is opened with: 7.1, 5.1, quad and @@ -385,7 +382,6 @@ pub fn open_output_channels( meter: Arc, ring_samples: impl Fn(u32) -> usize, who: &str, - capture_var: &str, ) -> Option { if let Some(path) = &audio.file { return open_file_output(path, meter, ring_samples, who); @@ -424,19 +420,8 @@ pub fn open_output_channels( let (producer, stream) = { let (p, mut c) = RingBuffer::::new(ring_size); let meter_cb = meter.clone(); - // `capture_var`=: also write what the host plays -- the - // end of the whole chain, to check or listen to offline. - let mut capture = std::env::var_os(capture_var) - .and_then(|p| std::fs::File::create(p).ok()) - .map(std::io::BufWriter::new); let data_fn = move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { - let missed = pop_frames(&mut c, usize::from(channels), data, |v| { - if let Some(w) = capture.as_mut() { - use std::io::Write; - let _ = w.write_all(&v.to_le_bytes()); - } - v as f32 / 32768.0 - }); + let missed = pop_frames(&mut c, usize::from(channels), data, |v| v as f32 / 32768.0); count_underrun(missed, &meter_cb); }; match device.build_output_stream(config, data_fn, err_fn.clone(), None) { @@ -1431,7 +1416,7 @@ mod tests { let (cfg, path) = file_config("file-output"); let meter = playing_meter(); let start = Instant::now(); - let mut out = open_output(&cfg, meter.clone(), |r| r as usize * 2, "test", "IRIS_TEST_CAPTURE") + let mut out = open_output(&cfg, meter.clone(), |r| r as usize * 2, "test") .expect("a file output opens without a sound card"); assert_eq!(out.stream_rate, FILE_RATE, "the file rate"); // A quarter second of tone, as a device would push it. @@ -1460,7 +1445,7 @@ mod tests { #[test] fn an_unwritable_file_opens_no_output() { let cfg = AudioConfig { file: Some("/nonexistent-dir/x.wav".into()), ..AudioConfig::default() }; - assert!(open_output(&cfg, playing_meter(), |r| r as usize, "test", "IRIS_TEST_CAPTURE").is_none()); + assert!(open_output(&cfg, playing_meter(), |r| r as usize, "test").is_none()); } /// A card that asks for eight channels still gets a stereo file, and @@ -1468,7 +1453,7 @@ mod tests { #[test] fn a_file_output_asked_for_eight_channels_is_stereo() { let (cfg, path) = file_config("file-wide"); - let out = open_output_channels(&cfg, 8, playing_meter(), |r| r as usize, "test", "IRIS_TEST_CAPTURE") + let out = open_output_channels(&cfg, 8, playing_meter(), |r| r as usize, "test") .expect("a file output opens without a sound card"); assert_eq!(out.channels, 2); drop(out); @@ -1502,7 +1487,7 @@ mod tests { const PAUSE_IS_STALL: bool = true; fn build(cfg: &AudioConfig) -> Self { let meter = Meter::new("test", &[]); - let out = open_output(cfg, meter.clone(), |r| r as usize * 2 / 5, "test", "IRIS_TEST_CAPTURE").expect("the output opens"); + let out = open_output(cfg, meter.clone(), |r| r as usize * 2 / 5, "test").expect("the output opens"); // One second holds whole cycles of both at any rate. let tone = tones(out.stream_rate as usize, out.stream_rate, 440.0, 880.0, 16000.0); Self { rate: out.stream_rate, out: std::sync::Mutex::new(Some(out)), meter, tone, at: Default::default(), pushed: Default::default(), running: AtomicBool::new(false) } diff --git a/test/audiotest/check.py b/test/audiotest/check.py index 580016a1e..91a88c6ae 100755 --- a/test/audiotest/check.py +++ b/test/audiotest/check.py @@ -1,19 +1,19 @@ #!/usr/bin/env python3 -"""check.py CAPTURE MARKS: judge the emulator's capture of an audio sweep. +"""check.py [--o2] 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): +CAPTURE is what the card played, as IRIS_AUDIO_DUMP records it: records of +rate:u32, pairs:u32, then pairs x (l:i16, r:i16), little-endian, at the +guest's own rate before resampling. The O2 has two outputs and puts an output +number (u8) in front of every record: pass --o2 for its files. 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. - 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. +A WAV, such as `[audio] file` writes, is also accepted (told apart by the first +four bytes); its marks are byte offsets into its data chunk and it is measured +at the rate in its header. 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 @@ -32,7 +32,7 @@ import struct import sys -WAV_RATE = 44100 +WAV_RATE = 44100 # a WAV's rate when its header is not read EXPECT_S, LEN_TOL = 2.0, 0.05 EXPECT_HZ, HZ_TOL = 1000.0, 0.03 @@ -54,16 +54,19 @@ def measure(raw, rate=WAV_RATE): return secs, crossings / 2 / secs, peak -def dump_case(data, a, b): +def dump_case(data, a, b, o2): """(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 diff --git a/test/audiotest/host.sh b/test/audiotest/host.sh index 0f8827702..d677dd83a 100755 --- a/test/audiotest/host.sh +++ b/test/audiotest/host.sh @@ -3,10 +3,10 @@ # # 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. +# file in its environment: IRIS_AUDIO_DUMP=CAPTURE. 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 (check.py --o2 for the O2's dump). # SKIP_BUILD=1 uses an audiotest already in /usr/tmp/at, for a guest without a # compiler. # @@ -28,7 +28,7 @@ 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. +# A WAV's marks skip its 44-byte header; a 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))