diff --git a/CHANGELOG.md b/CHANGELOG.md index 49e87f6d2..dd259b879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ hashes are given where a change is easiest to understand by reading the commit. ## November 2026 +### One key for the audio chip, one for where it plays + +- New `[machine] audio = true|false` (`--audio`): the Indy and Indigo2 have HAL2 + unless it is `false`, and the Tezro has its ICE1724 only if it is `true`. +- New `[audio] output = "default" | "alsa" | "pulseaudio" | "none" | { file = "out.wav" }` + (`--audio-output`) says where a present card plays, on every machine. +- Compatibility: `no_audio`, `[audio] host`, `[audio] file`, `[audio] prebuf_ms`, + `[tezro] audio`, `--noaudio` and `--audio-host` still work, print a line + naming their replacement, and leave every guest's hardware as it was. A + misspelt key in `[audio]` is now an error. + ### Serial logs append on every machine - New `serial_log_overwrite` (`--serial-log-overwrite`) starts `serial_log`, `serial_log_a` and `serial_log_b` over each run instead of adding to them. @@ -45,14 +56,13 @@ hashes are given where a change is easiest to understand by reading the commit. ### Tezro: optional PCI audio -- `[tezro] audio = true` adds an ICE1724 audio controller behind the IO9's PCI - bridge. It plays through `[audio]`, or silently with `no_audio`, and is off - by default. See the [Tezro guide](docs/tezro-ip53.md). +- `[machine] audio = true` adds an ICE1724 audio controller behind the IO9's PCI + bridge. It plays through `[audio]` and is off by default. See the [Tezro guide](docs/tezro-ip53.md). ### Host audio -- `[audio] file = "out.wav"` plays every machine's sound into a WAV file in - real time instead of a sound card. +- `[audio] output = { file = "out.wav" }` plays every machine's sound into a WAV + file in real time instead of a sound card. - The monitor's `audio status` reports every audio card (HAL2, MACE audio, RAD1 on the Octane and Onyx2, the Tezro's ICE1724) in one layout: per output the frames moved and the guest rate, host underruns, frames the host dropped and @@ -64,8 +74,8 @@ hashes are given where a change is easiest to understand by reading the commit. - 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. + `IRIS_AUDIO_DUMP` for the card's own samples, or `[audio] output = { 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/Cargo.toml b/Cargo.toml index 6150809b4..7e925f3ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -351,7 +351,7 @@ nokhwa = { version = "0.10", features = ["input-avfoundation"] } [target.'cfg(target_os = "linux")'.dependencies] # `pulseaudio`: cpal's own PulseAudio client (pure Rust, links nothing), for -# `[audio] host = "pulseaudio"`. HAL2 picks the host itself, so the default +# `[audio] output = "pulseaudio"`. The cards pick the host through iris, so the default # stays ALSA even though this feature makes cpal's default_host() prefer it. cpal = { version = "0.18", features = ["pulseaudio"] } nokhwa = { version = "0.10" } diff --git a/FEATURES.md b/FEATURES.md index d44c2ab72..8f45f2816 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -151,7 +151,6 @@ the first `[section]`, because TOML tables continue until the next header. | `banks` | `[128,128,0,0]` | RAM bank sizes in MB, up to eight entries (every machine today has four; the rest must be 0). On the O2 each entry is a DIMM pair's kit (32, 64, 128 or 256), largest first. On the Octane each entry is a pair of DIMMs, 64, 128, 256, 512, 1024 or 2048, filled from bank 0, 8192 in all. See profile constraints below. | | `scale` | `1` | Standalone window scale: 1–4. GUI uses its separate vm_scale. | | `headless` | `false` | Disable the standalone window and graphics; the O2 then boots on its serial console. Audio is independent. | -| `no_audio` | `false` | Disable audio emulation (HAL2, MACE or RAD). | | `gdb_port` | `unset` | Start the GDB TCP stub; debugging is unavailable in lightning builds. | | `monitor_port` | `8888 when unset` | Monitor listener on loopback. | | `serial_port_a` | `8880 when unset` | SCC channel A listener (tty2); the O2's first serial port, its console when headless. | @@ -184,6 +183,7 @@ the first `[section]`, because TOML tables continue until the next header. | `machine.cpu` | the machine's first | A family (`r4400`, `r5000`, `r10000`, `r12000`, `r14000`) or a part (`r14000_600`); independent of build features. Indy and `indigo2_ip22`: r4400, r5000; `indigo2_ip28`: r10000; O2: r5000, r10000, r12000; Octane: r10000, r12000, r14000; Origin 200, Origin 2000 and Onyx2: r10000 (`r10000_180`), r12000 (`r12000_270`); Fuel: r14000 (`r14000_500`); Tezro: r16000 (a bare `r16000` is `r16000_700`; `r16000_800`, `r16000_900` and `r16000_1000` are the other parts it ships with); Origin 3200: r12000 (`r12000_400`, with 8 MiB of secondary cache). The first listed is the default. | | `machine.cpus` | `1`; `4` on the Tezro | Processors fitted. 2 on the Octane; see [its guide](docs/octane-ip30-multiprocessor.md). 1, 2 or 4 on the Tezro, each run on its own host thread; one selects the one-processor board with one PIC, two or four the board with two PICs. | | `machine.emu_id` | `0` | 0 to 4095; only the Fuel and Tezro take it, and other machines refuse a nonzero one. Mints the machine's system id (`sysinfo -s`, `lmhostid`) in iris's layout. When nonzero it gives the station address `08:00:69:e2:`, which is the Fuel's id. The Tezro's L1 system serial is `LEMU20`, whose fold is the Tezro's id. 0 is the generic id. See [the Fuel guide](docs/fuel-ip34.md) and [the Tezro guide](docs/tezro-ip53.md). | +| `machine.audio` | `true` on the Indy and Indigo2, `false` on the Tezro | Whether the machine has its audio chip. Only those machines take it; every other one refuses it with an error naming the machine (on the O2, Octane and Onyx2 the card is part of the board: use `audio.output = "none"` to silence it). On the Indy and Indigo2, `false` leaves HAL2 out: the guest sees no audio hardware. On the Tezro, `true` adds an ICE1724 on the IO9's PCI bridge (see the [Tezro guide](docs/tezro-ip53.md)); off, that bus is empty. | ### [origin200], [origin2000] and [onyx2] @@ -234,7 +234,6 @@ Tezro-specific settings apply when `machine.profile = "tezro_ip53"`. | `l1_port` | `8882` | Host TCP port for the L1 console. | | `l1_log` | unset | Optional serial log path for L1 output when an IOC4 port is selected as console. | | `serial_port_c`, `serial_port_d` | `8883`, `8884` | Host TCP ports of IOC4 ports 2 and 3; ports 0 and 1 use `serial_port_a` and `serial_port_b`. | -| `audio` | `false` | Add the ICE1724 audio controller on the IO9's PCI bridge. Off, the bridge's bus is empty. On, it plays to the host as `[audio]` sets, or silently with `no_audio`. See the [Tezro guide](docs/tezro-ip53.md). | The machine runs the 700 MHz R16000 (`r16000_700`, the default), or the 800, 900 or 1000 MHz one (`r16000_800`, `r16000_900`, `r16000_1000`, with 4, 8 and 16 MiB @@ -359,9 +358,7 @@ Omit `[nfs]` to disable the share; the default forward list is empty. | Key | Default | Values and behavior | |---|---|---| -| `audio.host` | `"default"` | Host audio system: `"default"` (CoreAudio on macOS, WASAPI on Windows, ALSA on Linux), `"alsa"`, or `"pulseaudio"` (Linux: a PulseAudio server or PipeWire's pipewire-pulse, through cpal's own client; ALSA if none answers). `--audio-host` overrides. | -| `audio.prebuf_ms` | `20` | HAL2 prebuffer duration in milliseconds. Not used yet: HAL2 prebuffers 20 ms. | -| `audio.file` | `unset` | Play into this WAV file instead of a sound card: 48 kHz 16-bit stereo, written in real time as the host stream would play it, silence included. For headless runs and for checking what a guest played. HAL2, O2 audio and Octane RAD1. | +| `audio.output` | `"default"` | Where a present audio card plays, on every machine that has one: `"default"` (CoreAudio on macOS, WASAPI on Windows, ALSA on Linux), `"alsa"`, `"pulseaudio"` (Linux: a PulseAudio server or PipeWire's pipewire-pulse, through cpal's own client; ALSA if none answers), `"none"` (the card runs and counts, and no host stream is opened), or `{ file = "out.wav" }` (a WAV file instead of a sound card: 48 kHz 16-bit stereo, written in real time as the host stream would play it, silence included, for headless runs and for checking what a guest played). `--audio-output default\|alsa\|pulseaudio\|none\|file:PATH` overrides. | | `audio.cpal_buffer_frames` | `host default when unset` | Host buffer size in frames (a period; PulseAudio buffers two). Unset: the host's default, except PulseAudio's (about 2 s), which gets 512. A size the device refuses falls back to the default. | ### [jitv2] @@ -590,9 +587,11 @@ iris-gui does not parse this standalone option set; use its named-machine UI. | `--cdrom5-extra` | Additional ISO images for CD-ROM ID 5 (can be specified multiple times) | | `--cdrom6-extra` | Additional ISO images for CD-ROM ID 6 (can be specified multiple times) | | `--2x` | 2× window scaling for HiDPI/4K monitors | -| `--headless` | Run headless: no window, no REX3 graphics (audio unaffected; use --noaudio to disable) | -| `--noaudio` | Disable audio emulation (no HAL2); graphics still works | -| `--audio-host ` | Host audio system for HAL2: `default`, `alsa` or `pulseaudio` (overrides `[audio] host`) | +| `--headless` | Run headless: no window, no REX3 graphics (audio unaffected; see `--audio` and `--audio-output`) | +| `--audio ` | Sets `machine.audio`: whether the machine has its audio chip. `false` leaves HAL2 out of an Indy or Indigo2, `true` adds the Tezro's ICE1724. Applied after `--machine`; an error on any other machine. | +| `--audio-output ` | Sets `audio.output`: `default`, `alsa`, `pulseaudio`, `none` or `file:PATH`. | +| `--noaudio` | Deprecated: `--audio false` on the Indy and Indigo2, `--audio-output none` on every other machine. | +| `--audio-host ` | Deprecated: `--audio-output`. | | `--nfs-dir` | Enable NFS share: path to the directory to export (enables NFS) | | `--nat-subnet` | NAT subnet in CIDR notation (e.g. 192.168.5.0/24). Gateway gets .1, guest (IRIX) gets .2. Default: 192.168.0.0/24. | | `--net-mode` | Networking backend: "nat" (default, software gateway), "pcap" (bridge onto a real host interface; requires --features pcap), "stream" (a private segment; needs `network.stream`) or "off". | diff --git a/HELP.md b/HELP.md index 1f6a871cc..fd25d7074 100644 --- a/HELP.md +++ b/HELP.md @@ -617,7 +617,6 @@ banks = [128, 128, 0, 0] scale = 1 # window scale, kept across guest mode changes; --2x overrides headless = false # no window, no graphics; the O2 boots on its serial console (audio unaffected) -no_audio = false # no HAL2 / cpal (graphics unaffected) lock_aspect_ratio = true # false = free resize with letterboxing # renderer = "gl" # "wgpu": draw the window through wgpu (builds with the wgpu feature) mouse_scroll_pixels_per_line = 40 @@ -726,11 +725,9 @@ source = "off" # "off" (default) | "test_pattern" | "black" | "came standard = "ntsc" # or "pal" camera_index = 0 # host camera, source = "camera" only -[audio] -# host = "default" # Linux: "alsa" (the default) or "pulseaudio" (PulseAudio or PipeWire) -prebuf_ms = 20 +[audio] # where a present audio card plays; [machine] audio = false leaves HAL2 out of an Indy or Indigo2 +# output = "default" # "alsa", "pulseaudio" (PulseAudio or PipeWire), "none", or { file = "out.wav" } # cpal_buffer_frames = 512 # unset: the host's default (PulseAudio: 512) -# file = "out.wav" # play into a WAV file instead of a sound card # [ultra64] # N64 dev board # enabled = true @@ -787,8 +784,7 @@ or touching the sound device. It exits 0 and prints `iris: FILE: ok (machine)` if a run from the command line would start, and otherwise exits 1 with one `Configuration error:` line per problem on stderr; a missing disk image is one, because a command-line run stops on it, and so is a missing `prom` file you named. -A `Warning:` line is for something a run carries on past, such as a Tezro's audio -card with `no_audio` set. Other options (`--scsi1`, `--cpu` and so on) apply first, +Other options (`--scsi1`, `--cpu` and so on) apply first, as they do in a run. It checks that the file parses and has no unknown keys; that the settings agree @@ -832,8 +828,11 @@ Storage Display and audio --2x 2× window scaling --headless No window, no REX3 graphics (audio unaffected) - --noaudio Disable HAL2 audio (graphics unaffected) - --audio-host HOST Host audio for HAL2: default, alsa or pulseaudio (Linux) + --audio BOOL Whether the machine has its audio chip: false leaves HAL2 out of + an Indy or Indigo2, true adds the Tezro's ICE1724 ([machine] audio) + --audio-output OUTPUT Where a present card plays: default, alsa, pulseaudio, none or + file:PATH ([audio] output) + (--noaudio and --audio-host are deprecated spellings of the two above) Networking --nat-subnet NAT subnet, e.g. 192.168.5.0/24 @@ -895,7 +894,7 @@ iris --config /opt/iris/my_machine.toml iris --headless # Headless with audio also disabled (lightest possible server mode) -iris --headless --noaudio +iris --headless --audio false # Network-boot from the PROM: then `boot -f bootp()/unix` at the PROM monitor iris --tftp-dir ./tftpboot diff --git a/TESTING.md b/TESTING.md index e37d75498..2ff784dc8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -134,12 +134,12 @@ A sound card can break anywhere between guest memory and the host's speakers, and a CI runner has no speakers. Two backends hear what a card sends to the host: -- **`[audio] file`**: the card's host ring played into a WAV file in real +- **A file output** (`[audio] output = { file = ... }`): the card's host ring played into a WAV file in real time, silent where the card was late. It needs no sound card. It cannot see cpal, the operating system's sound server or its mixer. - **The real device**: the card plays into the host's default output and the test records it back from a loopback. `IRIS_AUDIO_LOOPBACK` names it - as `:`: the `[audio] host` the card plays + as `:`: the `[audio] output` the card plays through (`alsa`, `pulseaudio` or `default`) and what hears the default output. On Linux that is a PulseAudio source, recorded with `parec`. On Windows it is `default-render`, cpal's WASAPI loopback of the default diff --git a/bench/run/bare.toml b/bench/run/bare.toml index b55908a63..8f6bcfa48 100644 --- a/bench/run/bare.toml +++ b/bench/run/bare.toml @@ -11,9 +11,9 @@ banks = [128, 128, 0, 0] headless = true -no_audio = true [machine] profile = "indy_ip24" +audio = false [scsi] diff --git a/bench/run/run-local.sh b/bench/run/run-local.sh index 98f7880a4..9df9a5b98 100755 --- a/bench/run/run-local.sh +++ b/bench/run/run-local.sh @@ -29,7 +29,7 @@ timeout "$TIMEOUT" "$IRIS" \ --config run/bare.toml \ --load-elf "$ELF" \ --test-device \ - --headless --noaudio \ + --headless --audio false \ "$@" 2>&1 | tee "$LOG" rc=${PIPESTATUS[0]} diff --git a/bench/run/run-prom.sh b/bench/run/run-prom.sh index cbe844718..f9329ad32 100755 --- a/bench/run/run-prom.sh +++ b/bench/run/run-prom.sh @@ -30,11 +30,13 @@ cat > build/prom.toml <<'TOML' # `boot -f dksc(0,2,8)irisbench` reads naturally. banks = [128, 128, 0, 0] headless = true -no_audio = true [machine] profile = "indy_ip24" +[audio] +output = "none" + [scsi] [scsi.2] @@ -44,7 +46,7 @@ TOML rm -f "$SOCK" "$LOG" "$IRIS" --config build/prom.toml --ci --ci-socket "$SOCK" \ - --test-device --headless --noaudio --serial-log "$LOG" --serial-log-overwrite \ + --test-device --headless --audio-output none --serial-log "$LOG" --serial-log-overwrite \ > build/prom-stdout.log 2>&1 & IRIS_PID=$! trap 'kill $IRIS_PID 2>/dev/null' EXIT diff --git a/clanker/INDEX.md b/clanker/INDEX.md index 8d0b2ee3a..333c8ac3b 100644 --- a/clanker/INDEX.md +++ b/clanker/INDEX.md @@ -114,7 +114,7 @@ Non-graphics devices: MC/MEMCFG and RAM banks, HPC3, VDMA, EEPROMs and NVRAM, PS | [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 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"` | +| [cpal-alsa-busy-poll.md](devices/cpal-alsa-busy-poll.md) | rule | cpal's ALSA backend can spin a core: `[audio] output = "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 | | [extended-ram-memcfg.md](devices/extended-ram-memcfg.md) | rule | Extended RAM — MEMCFG synthesis for himem banks | diff --git a/clanker/devices/audio-test-suite.md b/clanker/devices/audio-test-suite.md index e1ed6eb01..41040f9da 100644 --- a/clanker/devices/audio-test-suite.md +++ b/clanker/devices/audio-test-suite.md @@ -50,7 +50,7 @@ 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 +at its records' rate. (A WAV from `[audio] output = { file = ... }` works too, at the stream's rate.) ```sh diff --git a/clanker/devices/boot-chimes.md b/clanker/devices/boot-chimes.md index 94ba886cc..9d58d40d0 100644 --- a/clanker/devices/boot-chimes.md +++ b/clanker/devices/boot-chimes.md @@ -31,7 +31,7 @@ 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.) +the DAC gain. (`[audio] output = { 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 @@ -40,8 +40,8 @@ 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 Indy and Indigo2 need audio on: `[machine] audio = false` 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). diff --git a/clanker/devices/cpal-alsa-busy-poll.md b/clanker/devices/cpal-alsa-busy-poll.md index 904e5f045..5b99e694b 100644 --- a/clanker/devices/cpal-alsa-busy-poll.md +++ b/clanker/devices/cpal-alsa-busy-poll.md @@ -1,4 +1,4 @@ -# cpal's ALSA backend can spin a core: `[audio] host = "pulseaudio"` +# cpal's ALSA backend can spin a core: `[audio] output = "pulseaudio"` **Keywords:** hal2,audio,alsa,cpal,pulseaudio,pipewire,busy-poll,xrun,underrun,linux,red led @@ -14,7 +14,7 @@ loops, about five million times a second. Seen through PipeWire's ALSA plugin with the same period and buffer sizes sleeps normally, so it is cpal's loop, not the driver. Not reproduced yet on bare metal; it may need the VM's timing. -**Workaround.** `[audio] host = "pulseaudio"` (or `--audio-host pulseaudio`): +**Workaround.** `[audio] output = "pulseaudio"` (or `--audio-output pulseaudio`): cpal's own PulseAudio client (pure Rust, the `pulseaudio` feature) talks to a PulseAudio server or PipeWire's pipewire-pulse over the socket. The same stream used 0.01 s of CPU in 5 s. The default stays ALSA until the bug is diff --git a/clanker/irix/irix-install.md b/clanker/irix/irix-install.md index 0d04f96f6..d9c78cfa9 100644 --- a/clanker/irix/irix-install.md +++ b/clanker/irix/irix-install.md @@ -102,7 +102,6 @@ in cycle order: ```toml headless = false -no_audio = false banks = [128, 128, 0, 0] # Per-config NVRAM so the 5.3 and 6.5 PROM env don't collide. nvram = "nvram-irix65.bin" @@ -143,7 +142,6 @@ after install: ```toml headless = false -no_audio = false banks = [128, 128, 0, 0] # Per-config NVRAM so the 5.3 and 6.5 PROM env don't collide. nvram = "nvram-irix53.bin" diff --git a/cpu-tests/run/bare.toml b/cpu-tests/run/bare.toml index 891d99997..2cd757102 100644 --- a/cpu-tests/run/bare.toml +++ b/cpu-tests/run/bare.toml @@ -9,10 +9,10 @@ banks = [128, 128, 0, 0] headless = true -no_audio = true [machine] profile = "indy_ip24" +audio = false # No SCSI at all. The `scsi` key must be present-but-empty: when it is # missing entirely, serde falls back to default_scsi(), which attaches diff --git a/cpu-tests/run/boot.toml b/cpu-tests/run/boot.toml index fea7a2f33..eac69117c 100644 --- a/cpu-tests/run/boot.toml +++ b/cpu-tests/run/boot.toml @@ -6,11 +6,13 @@ banks = [128, 128, 0, 0] headless = true -no_audio = true [machine] profile = "indy_ip24" +[audio] +output = "none" + [scsi] [scsi.2] diff --git a/cpu-tests/run/run-local.sh b/cpu-tests/run/run-local.sh index 04f0d7ec4..bbd54e8e5 100755 --- a/cpu-tests/run/run-local.sh +++ b/cpu-tests/run/run-local.sh @@ -23,7 +23,7 @@ DUMP="${DUMP:-build/dump.json}" mkdir -p "$(dirname "$LOG")" rm -f "$LOG" "$DUMP" -# --headless: no window. --noaudio: no cpal device in CI. +# --headless: no window. --audio false: no HAL2, so no cpal device in CI. # --test-device: PUTC/DUMP/EXIT, and the exit code we propagate. # --config run/bare.toml: a bare-metal binary touches no disk, and bare.toml's # present-but-empty [scsi] keeps SCSI out. Without it the default config @@ -39,7 +39,7 @@ timeout "$TIMEOUT" "$IRIS" \ --config run/bare.toml \ --load-elf "$ELF" \ --test-device --test-device-dump "$DUMP" \ - --headless --noaudio \ + --headless --audio false \ "$@" 2>&1 | tee "$LOG" rc=${PIPESTATUS[0]} diff --git a/cpu-tests/run/run-prom.sh b/cpu-tests/run/run-prom.sh index 650f5e874..0621d706f 100755 --- a/cpu-tests/run/run-prom.sh +++ b/cpu-tests/run/run-prom.sh @@ -30,7 +30,7 @@ LOG="build/prom.log" rm -f "$SOCK" "$LOG" "$IRIS" --config run/boot.toml --ci --ci-socket "$SOCK" \ - --test-device --headless --noaudio --serial-log "$LOG" \ + --test-device --headless --audio-output none --serial-log "$LOG" \ > build/prom-stdout.log 2>&1 & IRIS_PID=$! trap 'kill $IRIS_PID 2>/dev/null' EXIT diff --git a/debug.md b/debug.md index 226c84681..e895179e5 100644 --- a/debug.md +++ b/debug.md @@ -568,7 +568,7 @@ to learn the cursor's hotspot exactly. | `rtc r ` / `rtc w ` | Read / write NVRAM bytes | | `rtc debug ` | RTC trace **[DEV]** | | `pit status` / `pit debug ` | 8254 timer state / trace **[DEV]** | -| `audio status` / `audio` | The machine's audio card (HAL2, MACE audio, RAD1, the Tezro's ICE1724), one layout on every card: for each output (`analog in`, `analog out`, `S/PDIF out`) the frames it has moved since the machine started and its guest rate (`off` while idle), the frames the host stream played as silence while the card was playing (underruns), the frames the host ring refused, and the host output (device and API, `file out.wav` or `none (no_audio)`). Counts are as of the card's last timer pass and are this run's, not saved in snapshots. HAL2 counts the frames its DMA channel supplies; MACE audio keeps its DAC clock running while the guest holds the device, so its moved count follows time, not sound. An Indy or Indigo2 with `no_audio` has no HAL2 and says so | +| `audio status` / `audio` | The machine's audio card (HAL2, MACE audio, RAD1, the Tezro's ICE1724), one layout on every card: for each output (`analog in`, `analog out`, `S/PDIF out`) the frames it has moved since the machine started and its guest rate (`off` while idle), the frames the host stream played as silence while the card was playing (underruns), the frames the host ring refused, and the host output (device and API, `file out.wav` or `none (output = "none")`). Counts are as of the card's last timer pass and are this run's, not saved in snapshots. HAL2 counts the frames its DMA channel supplies; MACE audio keeps its DAC clock running while the guest holds the device, so its moved count follows time, not sound. An Indy or Indigo2 with `[machine] audio = false` has no HAL2 and says so | | `ps2 status` / `ps2 debug ` | PS/2 controller state / keyboard and mouse trace | | `ps2 type ` / `ps2 enter` / `ps2 esc` | Type text / press Enter / press Esc (Esc stops PROM autoboot). On the Origin 3200 and Onyx 3000 these, `ps2 key` and `ps2 mouse` go to the I-brick's USB keyboard and mouse, and `ps2 status` still shows the unused PS/2 side | | `ps2 key ` | Hold or release a modifier | diff --git a/docs/fuel-ip34.md b/docs/fuel-ip34.md index 182b01967..05bb4e305 100644 --- a/docs/fuel-ip34.md +++ b/docs/fuel-ip34.md @@ -22,7 +22,7 @@ from the repository root. The sample selects `machine.profile = "fuel_ip34"`. The machine always runs its one R14000 (`r14000_500`); `machine.cpu` may name it or be left out, and any other value is refused. The sample sets `banks = [256, 256]`, `headless = true`, -`no_audio = true` and serial ports 18880 and 18881. The `[fuel]` table +and serial ports 18880 and 18881. The `[fuel]` table selects the boot-flash backing file, the console (`l1`, `ioc3a` or `ioc3b`) and the L1's TCP port and log; [FEATURES.md](../FEATURES.md#fuel) lists its keys. diff --git a/docs/octane-ip30.md b/docs/octane-ip30.md index a344f65b6..9a2abe000 100644 --- a/docs/octane-ip30.md +++ b/docs/octane-ip30.md @@ -17,7 +17,7 @@ IMPACT board fitted (the default) and a blank NVRAM, the PROM comes up on the graphics console and IRIX starts X to the login screen and a desktop. The guest pings the NAT gateway, fetches files from its TFTP server and accepts telnet logins through a port forward. Sound played in IRIX (for -example `playaiff`) comes out of the host's audio device; `no_audio` turns it +example `playaiff`) comes out of the host's audio device; `[audio] output = "none"` turns it off. `reboot` comes back to the login prompt. jitv2 builds run it too. iris-gui runs the Octane as well: choose "SGI Octane (IP30)" as the platform. diff --git a/docs/tezro-ip53.md b/docs/tezro-ip53.md index c12d62925..c0347fee6 100644 --- a/docs/tezro-ip53.md +++ b/docs/tezro-ip53.md @@ -80,7 +80,7 @@ The IO9's PCI bus, head 0 of the PIC at widget 15, holds the IOC4 at device ISP12160 at device 3 and the BCM5701 at device 4. Every Tezro capture that prints widget 15 shows the TI bridge there, and no Origin 350 or Onyx 350 IO9 capture does; why is not known. Its secondary bus is empty unless -`[tezro] audio = true` adds the audio controller, and only its type 1 header +`[machine] audio = true` adds the audio controller, and only its type 1 header is modelled, with the PCI2050's wide windows: 32-bit I/O addressing and stored upper 32 bits of the prefetchable base and limit (the window itself is 32-bit; one above 4 GiB is stored and never forwarded). IRIX lists it as an @@ -97,8 +97,8 @@ recipe. That is a stated model choice, not Tezro evidence. Its two I/O BARs need the bridge's I/O window, which firmware opens. The controller's interrupt is masked at reset; its INTA is rotated by the bridge to INTC of the bridge's slot, which the PIC wires to input 1 (a model choice; the board's -wiring is unknown). It plays to the host stream as `[audio]` sets, or silently -with `no_audio`. +wiring is unknown). It plays to the host stream as `[audio]` sets, or +silently with `output = "none"`. Memory comes from `banks`: one 512 or 1024 MiB bank per locational bank, from bank 0 with no gaps, up to eight. The IP53 board takes 512 MB and 1 GB DIMMs diff --git a/fuel.toml.example b/fuel.toml.example index 3949b401b..feef4f3b5 100644 --- a/fuel.toml.example +++ b/fuel.toml.example @@ -10,7 +10,6 @@ nvram = "ignore/fuel/nvram.bin" banks = [256, 256] headless = true -no_audio = true monitor_port = 18888 serial_port_a = 18880 serial_port_b = 18881 diff --git a/ip28.toml.example b/ip28.toml.example index 6d4156d71..276c44397 100644 --- a/ip28.toml.example +++ b/ip28.toml.example @@ -1,7 +1,6 @@ # Indigo2 IMPACT R10000 (IP28). Embedded PROM included; supply your own IRIX disk. # See docs/indigo2-ip28.md. Paths are relative to the working directory. headless = false -no_audio = false banks = [512, 512, 0, 0] prom = "" # optional external path; falls back to 070-1477-002.bin, then embedded IP28 PROM nveeprom = "ip28/nveeprom-ip28.bin" diff --git a/iris-gui/locales/en/config.ftl b/iris-gui/locales/en/config.ftl index c51df09ba..8839be8db 100644 --- a/iris-gui/locales/en/config.ftl +++ b/iris-gui/locales/en/config.ftl @@ -150,16 +150,16 @@ config-scale = Window scale # $scale is a whole number, such as 2 for double size. config-scale-factor = { $scale }× config-headless = No graphics (headless) -config-no-audio = No audio +config-sound-card = Sound card config-keyboard = Keyboard config-keyboard-tip = The physical shape of your keyboard. Set the language layout in the PROM. config-audio = Audio config-audio-subtitle = -config-audio-host = Host audio -config-audio-host-default = Default (ALSA) -config-audio-host-alsa = ALSA -config-audio-host-pulse = PulseAudio / PipeWire -config-audio-prebuffer = Pre-buffer (ms) +config-audio-output = Output +config-audio-output-default = Default (ALSA) +config-audio-output-alsa = ALSA +config-audio-output-pulse = PulseAudio / PipeWire +config-audio-output-none = None (silent) config-audio-host-buffer = Host buffer (frames) config-audio-rate-subtitle = diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index 8a7c2c65c..e758ab83c 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -2,7 +2,7 @@ use egui::{Color32, ComboBox, DragValue, Grid, RichText, ScrollArea, TextEdit, U use iris::build_features; use iris::cpu::parts::Part; use std::path::Path; -use iris::config::{AudioHost, CacheModel, +use iris::config::{AudioOutput, CacheModel, format_unix_utc, ForwardBind, ForwardProto, GraphicsBoard, KeyboardLayout, MachineConfig, MachineProfile, NetMode, NfsConfig, PortForwardConfig, RtcOffset, ScsiDeviceConfig, ScsiKind, VinoSource, VinoStandard, VALID_BANK_SIZES, @@ -215,13 +215,26 @@ fn show_general(ui: &mut Ui, cfg: &mut MachineConfig, mem_ctx: MemoryUiContext) ui.heading(t!("config-tab-general")); ui.label(t!("config-platform", platform = cfg.machine.profile.label())); let profile_before = cfg.machine.profile; - ComboBox::from_id_salt("machine_profile") - .selected_text(cfg.machine.profile.label()) - .show_ui(ui, |ui| { - for p in MachineProfile::ALL { - ui.selectable_value(&mut cfg.machine.profile, p, p.label()); + ui.horizontal(|ui| { + ComboBox::from_id_salt("machine_profile") + .selected_text(cfg.machine.profile.label()) + .show_ui(ui, |ui| { + for p in MachineProfile::ALL { + ui.selectable_value(&mut cfg.machine.profile, p, p.label()); + } + }); + // Only the Indy and Indigo2 can go without their audio chip; the + // key is refused on the others, so a new profile starts without it. + if cfg.machine.profile != profile_before { + cfg.machine.audio = None; + } + if cfg.machine.profile.has_hal2() { + let mut on = cfg.machine.has_audio(); + if ui.checkbox(&mut on, t!("config-sound-card")).changed() { + cfg.machine.audio = Some(on); } - }); + } + }); let octane = cfg.machine.profile == MachineProfile::OctaneIp30; let o2 = cfg.machine.profile == MachineProfile::O2Ip32; if cfg.machine.profile == MachineProfile::OctaneIp30 && profile_before != MachineProfile::OctaneIp30 { @@ -819,10 +832,6 @@ fn show_display(ui: &mut Ui, cfg: &mut MachineConfig, running: bool) { ui.checkbox(&mut cfg.headless, ""); ui.end_row(); - ui.label(t!("config-no-audio")); - ui.checkbox(&mut cfg.no_audio, ""); - ui.end_row(); - ui.label(t!("config-keyboard")) .tip(t!("config-keyboard-tip")); ui.add_enabled_ui(!running, |ui| { @@ -835,29 +844,35 @@ fn show_display(ui: &mut Ui, cfg: &mut MachineConfig, running: bool) { }); ui.end_row(); }); - if !cfg.no_audio { + if cfg.machine.has_audio() { ui.separator(); ui.heading(t!("config-audio")); subtitle(ui, t!("config-audio-subtitle")); Grid::new("audio_grid").num_columns(2).striped(true).show(ui, |ui| { - if cfg!(target_os = "linux") { - ui.label(t!("config-audio-host")); - let label = |h: AudioHost| match h { - AudioHost::Default => t!("config-audio-host-default"), - AudioHost::Alsa => t!("config-audio-host-alsa"), - AudioHost::Pulseaudio => t!("config-audio-host-pulse"), - }; - ComboBox::from_id_salt("audio_host") - .selected_text(label(cfg.audio.host)) - .show_ui(ui, |ui| { - for h in [AudioHost::Default, AudioHost::Alsa, AudioHost::Pulseaudio] { - ui.selectable_value(&mut cfg.audio.host, h, label(h)); - } - }); - ui.end_row(); + ui.label(t!("config-audio-output")); + let label = |o: &AudioOutput| match o { + AudioOutput::Default => t!("config-audio-output-default"), + AudioOutput::Alsa => t!("config-audio-output-alsa"), + AudioOutput::Pulseaudio => t!("config-audio-output-pulse"), + AudioOutput::None => t!("config-audio-output-none"), + AudioOutput::File(path) => path.display().to_string(), + }; + let mut output = cfg.audio.output().clone(); + ComboBox::from_id_salt("audio_output") + .selected_text(label(&output)) + .show_ui(ui, |ui| { + let hosts = if cfg!(target_os = "linux") { + &[AudioOutput::Default, AudioOutput::Alsa, AudioOutput::Pulseaudio, AudioOutput::None][..] + } else { + &[AudioOutput::Default, AudioOutput::None] + }; + for o in hosts { + ui.selectable_value(&mut output, o.clone(), label(o)); + } + }); + if output != *cfg.audio.output() { + cfg.audio.output = Some(output); } - ui.label(t!("config-audio-prebuffer")); - ui.add(DragValue::new(&mut cfg.audio.prebuf_ms).range(5..=200)); ui.end_row(); ui.label(t!("config-audio-host-buffer")); let mut frames = cfg.audio.cpal_buffer_frames.unwrap_or(0); diff --git a/iris-irix53.toml b/iris-irix53.toml index edccc9926..6e6462bc6 100644 --- a/iris-irix53.toml +++ b/iris-irix53.toml @@ -4,7 +4,6 @@ scale = 2 headless = false -no_audio = false banks = [128, 128, 0, 0] # Per-config NVRAM so the 5.3 and 6.5 PROM env don't collide. nvram = "nvram-irix53.bin" diff --git a/iris-irix65.toml b/iris-irix65.toml index 796909029..1b74f6d8f 100644 --- a/iris-irix65.toml +++ b/iris-irix65.toml @@ -3,7 +3,6 @@ # PROM is loaded from the embedded image (prom.bin not required). headless = false -no_audio = false banks = [128, 128, 0, 0] # Per-config NVRAM so the 5.3 and 6.5 PROM env don't collide. nvram = "nvram-irix65.bin" diff --git a/iris.toml b/iris.toml index 291402ca3..5b8f4cf04 100644 --- a/iris.toml +++ b/iris.toml @@ -6,10 +6,6 @@ # Can also be set with the --headless command-line flag. headless = false -# Disable audio emulation (no HAL2). Independent of graphics/headless. -# Can also be set with --noaudio. -no_audio = false - # PROM ROM image: an override. Every machine has an embedded PROM that runs # when this is left as is, and "(embedded)" selects it outright. A file named here # must load. With this left as is, a machine's release file name (the O2's @@ -216,9 +212,11 @@ mode = "nat" # ── Machine ─────────────────────────────────────────────────────────────────── # profile: "indy_ip24" (default) or "indigo2_ip22" (same as --ip22) # cpu: "r4400" (default) or "r5000" (same as --cpu). Both are in every build. +# audio: false leaves HAL2 out of an Indy or Indigo2 (same as --audio false). #[machine] #profile = "indy_ip24" #cpu = "r4400" +#audio = true # Per-config NVRAM files, so two installs don't overwrite each other's PROM env. # These are top-level keys: put them near the top of the file, above any @@ -252,10 +250,11 @@ mode = "nat" #minutes = -4 #seconds = -27 -# HAL2 audio output buffering. +# Where the audio card's sound goes, and how the host stream is buffered. Whether +# the machine has an audio chip is `audio` in [machine] (the Indy and Indigo2 +# only; --audio false leaves HAL2 out). #[audio] -#host = "default" # Linux: "alsa" (default) or "pulseaudio" (PulseAudio or PipeWire) -#prebuf_ms = 20 +#output = "default" # "alsa", "pulseaudio" (PulseAudio or PipeWire), "none", or { file = "out.wav" } #cpal_buffer_frames = 512 # unset: the host's default (PulseAudio: 512) # Host thread pinning. diff --git a/irix-install/iris-indigo2-smoke-ci.toml b/irix-install/iris-indigo2-smoke-ci.toml index 345c4f1eb..e652b3d55 100644 --- a/irix-install/iris-indigo2-smoke-ci.toml +++ b/irix-install/iris-indigo2-smoke-ci.toml @@ -6,7 +6,6 @@ headless = true ci = true ci_socket = "127.0.0.1:19851" -no_audio = false banks = [128, 128, 0, 0] nvram = "irix-install/nvram-irix65.bin" scale = 1 @@ -21,7 +20,6 @@ heads = 1 thread_affinity = false [audio] -prebuf_ms = 20 [scsi.1] path = "irix-install/scsi1.raw" diff --git a/irix-install/iris-install-wsl.toml b/irix-install/iris-install-wsl.toml index cdb5c7fe2..f31a14d4d 100644 --- a/irix-install/iris-install-wsl.toml +++ b/irix-install/iris-install-wsl.toml @@ -1,6 +1,5 @@ # WSL/Linux paths (Windows C: drive via /mnt/c) headless = false -no_audio = false banks = [128, 128, 0, 0] nvram = "irix-install/nvram-irix65.bin" diff --git a/irix-install/iris-install.toml b/irix-install/iris-install.toml index de7bfadbf..17ac254e3 100644 --- a/irix-install/iris-install.toml +++ b/irix-install/iris-install.toml @@ -4,7 +4,6 @@ # cargo +nightly-x86_64-pc-windows-msvc run --release --bin iris --features lightning,rex-jit -- --config irix-install/iris-install.toml headless = false -no_audio = false banks = [128, 128, 0, 0] nvram = "irix-install/nvram-irix65.bin" diff --git a/irix-install/iris-smoke-ci.toml b/irix-install/iris-smoke-ci.toml index 0ced8b72f..166db3da9 100644 --- a/irix-install/iris-smoke-ci.toml +++ b/irix-install/iris-smoke-ci.toml @@ -2,7 +2,6 @@ headless = true ci = true ci_socket = "127.0.0.1:19851" -no_audio = false banks = [128, 128, 0, 0] nvram = "irix-install/nvram-irix65.bin" scale = 1 @@ -14,7 +13,6 @@ profile = "indy_ip24" thread_affinity = false [audio] -prebuf_ms = 20 [scsi.1] path = "irix-install/scsi1.raw" diff --git a/irix-install/iris-windows-384.toml b/irix-install/iris-windows-384.toml index 821759539..5bafcc2cc 100644 --- a/irix-install/iris-windows-384.toml +++ b/irix-install/iris-windows-384.toml @@ -3,7 +3,6 @@ # Stop iris and cold-start after switching from another banks layout. headless = false -no_audio = false banks = [128, 128, 64, 64] nvram = "irix-install/nvram-irix65.bin" scale = 1 @@ -15,7 +14,6 @@ profile = "indy_ip24" thread_affinity = false [audio] -prebuf_ms = 40 [scsi.1] path = "irix-install/scsi1.raw" diff --git a/irix-install/iris-windows-safe.toml b/irix-install/iris-windows-safe.toml index c9527aa5e..cfa5b1bb9 100644 --- a/irix-install/iris-windows-safe.toml +++ b/irix-install/iris-windows-safe.toml @@ -2,7 +2,6 @@ # Same hardware as iris-windows.toml; use that file for max Full-tier performance. headless = false -no_audio = false banks = [128, 128, 0, 0] nvram = "irix-install/nvram-irix65.bin" scale = 1 @@ -14,7 +13,6 @@ profile = "indy_ip24" thread_affinity = false [audio] -prebuf_ms = 40 [scsi.1] path = "irix-install/scsi1.raw" diff --git a/irix-install/iris-windows.toml b/irix-install/iris-windows.toml index 1baefae91..e69a6b3a4 100644 --- a/irix-install/iris-windows.toml +++ b/irix-install/iris-windows.toml @@ -11,7 +11,6 @@ # clanker/cpu/r5k-l1i-cache-bugs.md headless = false -no_audio = false banks = [128, 128, 0, 0] nvram = "irix-install/nvram-irix65.bin" scale = 1 @@ -31,8 +30,7 @@ thread_affinity = false # headless = true [audio] -# Match IRIX BRES master (44100 Hz). Bump prebuf if you hear tail scratch on Windows. -prebuf_ms = 40 +# Match IRIX BRES master (44100 Hz). # cpal_buffer_frames = 512 [scsi.1] diff --git a/irix-install/iris-wsl.toml b/irix-install/iris-wsl.toml index 1e02facaf..6530f76ca 100644 --- a/irix-install/iris-wsl.toml +++ b/irix-install/iris-wsl.toml @@ -3,7 +3,6 @@ # ISO paths use /mnt/c so CDs stay on the Windows Downloads folder. headless = false -no_audio = false banks = [128, 128, 0, 0] nvram = "irix-install/nvram-irix65.bin" scale = 2 diff --git a/onyx2.toml.example b/onyx2.toml.example index 81ff23b9c..85e078255 100644 --- a/onyx2.toml.example +++ b/onyx2.toml.example @@ -10,12 +10,15 @@ nvram = "ignore/onyx2/nvram.bin" # One bank of a DIMM pair per node, in MB: 64, 128, 256 or 512 each. banks = [128, 0, 0, 0] headless = true -no_audio = true monitor_port = 18988 # The IOC3's first port is the console. serial_port_a = 18980 serial_port_b = 18981 +# The MediaIO's audio card plays nowhere. +[audio] +output = "none" + [machine] profile = "onyx2_ip27" # r10000 (180 MHz, 1 MiB secondary cache) or r12000 (270 MHz, 4 MiB). diff --git a/origin200.toml.example b/origin200.toml.example index d9d2c481b..a5e3609b6 100644 --- a/origin200.toml.example +++ b/origin200.toml.example @@ -10,7 +10,6 @@ nvram = "ignore/origin200/nvram.bin" # One bank of a DIMM pair, in MB: 64, 128, 256 or 512 each, filled from bank 0. banks = [64, 0, 0, 0] headless = true -no_audio = true monitor_port = 18988 # The IOC3's first port is the console. serial_port_a = 18980 diff --git a/origin2000.toml.example b/origin2000.toml.example index abc35208c..801ac7c6c 100644 --- a/origin2000.toml.example +++ b/origin2000.toml.example @@ -10,7 +10,6 @@ nvram = "ignore/origin2000/nvram.bin" # One bank of a DIMM pair per node, in MB: 64, 128, 256 or 512 each. banks = [128, 0, 0, 0] headless = true -no_audio = true monitor_port = 18988 # The IOC3's first port is the console. serial_port_a = 18980 diff --git a/origin3200.toml.example b/origin3200.toml.example index 1c05ab3e8..ac9ecb68e 100644 --- a/origin3200.toml.example +++ b/origin3200.toml.example @@ -8,7 +8,6 @@ prom = "ignore/origin3200/ip35prom.payload.bin" nvram = "ignore/origin3200/nvram.bin" # banks left unset: two 256 MiB banks in each C-brick. headless = true -no_audio = true monitor_port = 18888 serial_port_a = 18880 serial_port_b = 18881 diff --git a/src/bench_runner.rs b/src/bench_runner.rs index f7671f36c..96f204e46 100644 --- a/src/bench_runner.rs +++ b/src/bench_runner.rs @@ -328,9 +328,9 @@ pub fn bench_config(banks: [u32; 4], cpu: &Part) -> MachineConfig { let mut cfg = MachineConfig { banks: crate::config::banks_from4(banks), headless: true, - no_audio: true, ..Default::default() }; + cfg.machine.audio = Some(false); // `MachineConfig::default` attaches scsi1.raw, which makes startup fatal // when the file is absent — and here it always is. Same reason // bench/run/bare.toml carries a present-but-empty `[scsi]`. @@ -549,7 +549,7 @@ mod tests { fn the_bench_machine_has_no_disks() { let cfg = bench_config(BENCH_BANKS, &parts::R4400_150); assert!(cfg.scsi.is_empty(), "a bench machine must not try to open a disk image"); - assert!(cfg.headless && cfg.no_audio); + assert!(cfg.headless && !cfg.machine.has_audio()); } #[test] diff --git a/src/bin/iris_bench.rs b/src/bin/iris_bench.rs index 1b8cbf8fc..6251688d6 100644 --- a/src/bin/iris_bench.rs +++ b/src/bin/iris_bench.rs @@ -119,7 +119,7 @@ fn run_guest( .arg("--load-elf").arg(&elf) .arg("--test-device") .arg("--headless") - .arg("--noaudio"); + .args(["--audio", "false"]); for e in extra { cmd.arg(e); } let started = Instant::now(); diff --git a/src/config.rs b/src/config.rs index 2dd453d9a..5105cc3bb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -906,6 +906,12 @@ impl MachineProfile { } } + /// The profiles whose audio is HAL2 behind the HPC3, which `[machine] + /// audio = false` removes. + pub fn has_hal2(self) -> bool { + matches!(self, Self::IndyIp24 | Self::Indigo2Ip22 | Self::Indigo2Ip28) + } + /// The three SN0 machines, which share the Hub node, the module controller /// and the IP27 PROM. pub fn is_ip27(self) -> bool { @@ -955,6 +961,19 @@ impl MachineProfile { } } + /// Whether this profile takes `[machine] audio`, and why not if it does + /// not. + pub fn accepts_audio_key(self) -> Result<(), String> { + match self { + Self::O2Ip32 | Self::OctaneIp30 | Self::Onyx2Ip27 => Err(format!( + "[machine] audio: the {}'s audio is part of its board; use [audio] output = \"none\" to silence it", + self.label() + )), + p if p.has_hal2() || p == Self::TezroIp53 => Ok(()), + _ => Err(format!("[machine] audio: the {} has no audio chip", self.label())), + } + } + /// Whether this profile can have `cpus` processors, and why not if it /// cannot. pub fn accepts_cpus(self, cpus: u8) -> Result<(), String> { @@ -1474,6 +1493,13 @@ pub struct MachineSection { /// [`crate::identity`]. #[serde(default, skip_serializing_if = "is_zero_u32")] pub emu_id: u32, + /// Whether the machine has its audio chip: read it with + /// [`MachineSection::has_audio`]. Only the Indy and Indigo2 (HAL2, there + /// unless removed) and the Tezro (an ICE1724, absent unless added) take + /// it; on every other machine the chip is part of the board, or there is + /// none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audio: Option, } fn is_zero_u32(v: &u32) -> bool { @@ -1490,11 +1516,23 @@ impl MachineSection { _ => 1, }) } + + /// Whether the machine has an audio chip: `audio` as set on the machines + /// that take it, else what the profile has. + pub fn has_audio(&self) -> bool { + use MachineProfile::*; + match self.profile { + IndyIp24 | Indigo2Ip22 | Indigo2Ip28 => self.audio.unwrap_or(true), + TezroIp53 => self.audio.unwrap_or(false), + O2Ip32 | OctaneIp30 | Onyx2Ip27 => true, + _ => false, + } + } } impl Default for MachineSection { fn default() -> Self { - Self { profile: MachineProfile::default(), cpu: None, cache: CacheModel::default(), cpus: None, emu_id: 0 } + Self { profile: MachineProfile::default(), cpu: None, cache: CacheModel::default(), cpus: None, emu_id: 0, audio: None } } } @@ -1997,11 +2035,11 @@ impl Default for PerfConfig { } } -/// Which host audio system HAL2 plays through. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, clap::ValueEnum)] +/// Where a present audio card's sound goes (`[audio] output`). +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] -pub enum AudioHost { - /// The platform's usual one: CoreAudio on macOS, WASAPI on Windows, ALSA +pub enum AudioOutput { + /// The platform's usual host: CoreAudio on macOS, WASAPI on Windows, ALSA /// on Linux. #[default] Default, @@ -2010,31 +2048,65 @@ pub enum AudioHost { /// A PulseAudio server, or PipeWire's pipewire-pulse (Linux). Falls back /// to ALSA when none answers. Pulseaudio, + /// Nowhere: the card runs and counts, and no host stream is opened. + None, + /// A WAV file instead of a sound card: 48 kHz 16-bit stereo, written as + /// the host stream would play it, in real time. + File(std::path::PathBuf), } -/// HAL2 / cpal audio output tuning. -#[derive(Debug, Clone, Serialize, Deserialize)] +impl std::str::FromStr for AudioOutput { + type Err = String; + + /// The command-line spelling: `default`, `alsa`, `pulseaudio`, `none` or + /// `file:PATH` (split once, so `file:C:\x.wav` keeps its drive). + fn from_str(s: &str) -> Result { + match s { + "default" => Ok(Self::Default), + "alsa" => Ok(Self::Alsa), + "pulseaudio" => Ok(Self::Pulseaudio), + "none" => Ok(Self::None), + _ => match s.strip_prefix("file:") { + Some(path) if !path.is_empty() => Ok(Self::File(path.into())), + _ => Err(format!("`{s}`: expected default, alsa, pulseaudio, none or file:PATH")), + }, + } + } +} + +/// Where a present audio card plays, and how its host stream is buffered. +/// Whether a machine has a card is `[machine] audio`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AudioConfig { - /// Host audio system to play through. Default: the platform's usual one. - #[serde(default)] - pub host: AudioHost, - /// Pre-buffer duration (ms) before feeding the cpal ring. Default 20. - #[serde(default = "default_audio_prebuf_ms")] - pub prebuf_ms: u64, + /// Where the sound goes: read it with [`AudioConfig::output`]. Unset is + /// the platform's usual host. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, /// Fixed cpal buffer size in frames (stereo pairs). Unset = host default. #[serde(default, skip_serializing_if = "Option::is_none")] pub cpal_buffer_frames: Option, - /// Play into this WAV file instead of a sound card: 48 kHz 16-bit - /// stereo, written as the host stream would play it, in real time. + + /// Deprecated: `output = "alsa"` and the like. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, + /// Deprecated: `output = { file = ... }`. #[serde(default, skip_serializing_if = "Option::is_none")] pub file: Option, + /// Deprecated and ignored: nothing read it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prebuf_ms: Option, } -fn default_audio_prebuf_ms() -> u64 { 20 } +impl AudioConfig { + /// Where the sound goes, or the platform's usual host when unset. + pub fn output(&self) -> &AudioOutput { + self.output.as_ref().unwrap_or(&AudioOutput::Default) + } -impl Default for AudioConfig { - fn default() -> Self { - Self { host: AudioHost::Default, prebuf_ms: default_audio_prebuf_ms(), cpal_buffer_frames: None, file: None } + /// Whether any host stream is wanted: everything but `output = "none"`. + pub fn plays(&self) -> bool { + *self.output() != AudioOutput::None } } @@ -2330,11 +2402,9 @@ pub struct TezroSection { pub serial_port_c: Option, #[serde(default)] pub serial_port_d: Option, - /// Add the audio controller (IC Ensemble ICE1724) behind the IO9's PCI - /// bridge. Off by default: the bridge's bus is then empty. `no_audio` - /// keeps the controller but gives it no host output. - #[serde(default)] - pub audio: bool, + /// Deprecated: `[machine] audio`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audio: Option, } impl Default for TezroSection { @@ -2346,7 +2416,7 @@ impl Default for TezroSection { l1_log: None, serial_port_c: None, serial_port_d: None, - audio: false, + audio: None, } } } @@ -2360,7 +2430,6 @@ impl TezroSection { && self.l1_log.is_none() && self.serial_port_c.is_none() && self.serial_port_d.is_none() - && !self.audio } } @@ -2424,14 +2493,20 @@ pub struct MachineConfig { #[serde(default = "default_scale")] pub scale: u32, - /// Run without graphics (no window, no REX3). Use no_audio to also disable HAL2. + /// Run without graphics (no window, no REX3). Audio is unaffected: see + /// `[machine] audio` and `[audio] output`. /// Useful for headless/server/CI environments. #[serde(default)] pub headless: bool, - /// Disable audio emulation (no HAL2). Independent of headless/graphics. - #[serde(default)] - pub no_audio: bool, + /// Deprecated: `[machine] audio = false` on the Indy and Indigo2, which + /// removes HAL2, and `[audio] output = "none"` elsewhere. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub no_audio: Option, + /// Whether a `no_audio = true` was folded and nothing in the file + /// overrode it: `--machine` re-folds it for the machine it picks. + #[serde(skip)] + pub silenced: bool, /// If Some(port), start the GDB RSP stub on that TCP port. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -2854,7 +2929,8 @@ impl Default for MachineConfig { nfs: None, port_forward: vec![], headless: false, - no_audio: false, + no_audio: None, + silenced: false, gdb_port: None, monitor_port: None, serial_port_a: None, @@ -3304,6 +3380,89 @@ impl MachineConfig { moved.join(", ") ); } + self.fold_audio_keys(); + } + + /// Move the audio keys older versions wrote to `[machine] audio` and + /// `[audio] output`, saying so once. A new key that is set wins over the + /// old ones, and among the old `no_audio` wins over `file`, which wins + /// over `host`: the order they took effect in. + fn fold_audio_keys(&mut self) { + let mut said: Vec = Vec::new(); + let (audio_set, output_set) = (self.machine.audio.is_some(), self.audio.output.is_some()); + if let Some(line) = self.fold_tezro_audio(audio_set) { + said.push(line); + } + match self.no_audio.take() { + Some(false) => said.push("no_audio = false dropped".into()), + Some(true) if self.machine.profile.has_hal2() && audio_set => said.push("no_audio = true ignored: [machine] audio is set".into()), + Some(true) if !self.machine.profile.has_hal2() && output_set => said.push("no_audio = true ignored: [audio] output is set".into()), + Some(true) => { + self.silenced = true; + self.apply_silenced(); + said.push(if self.machine.profile.has_hal2() { + "no_audio = true -> [machine] audio = false".into() + } else { + "no_audio = true -> [audio] output = \"none\"".into() + }); + } + None => {} + } + for (key, old) in [("[audio] file", self.audio.file.take().map(AudioOutput::File)), ("[audio] host", self.audio.host.take())] { + let Some(old) = old else { continue }; + if self.silenced { + said.push(format!("{key} dropped: no_audio is set")); + } else if self.audio.output.is_some() { + said.push(format!("{key} ignored: [audio] output is set")); + } else { + self.audio.output = Some(old); + said.push(format!("{key} -> [audio] output")); + } + } + if self.audio.prebuf_ms.take().is_some() { + said.push("[audio] prebuf_ms dropped: nothing read it".into()); + } + if !said.is_empty() { + eprintln!("iris: deprecated audio keys folded ({}); move them in the config to silence this", said.join("; ")); + } + } + + /// `[tezro] audio` becomes `[machine] audio` once the machine is a Tezro: + /// run when a file loads and again when `--machine` picks one, since the + /// key is only read there. Says what it did, if anything. + pub fn fold_tezro_audio(&mut self, audio_set: bool) -> Option { + if self.machine.profile != MachineProfile::TezroIp53 { + return None; + } + let on = self.tezro.audio.take()?; + if audio_set { + return Some("[tezro] audio ignored: [machine] audio is set".into()); + } + self.machine.audio = Some(on); + Some("[tezro] audio -> [machine] audio".into()) + } + + /// A folded `no_audio = true`, for the machine chosen: no HAL2 on the + /// Indy and Indigo2, a silent card on every other machine. Run again + /// when `--machine` changes which machine that is. + pub fn apply_silenced(&mut self) { + if !self.silenced { + return; + } + if self.machine.profile.has_hal2() { + self.machine.audio = Some(false); + } else { + if self.machine.profile != MachineProfile::TezroIp53 { + self.machine.audio = None; + } + self.audio.output = Some(AudioOutput::None); + } + } + + /// The audio card's `[audio]` settings, or `None` on a machine without + /// one. + pub fn audio_card(&self) -> Option { + self.machine.has_audio().then(|| self.audio.clone()) } /// The O2's memory as the kit in each DIMM pair from pair A: `banks` with @@ -3722,6 +3881,9 @@ impl MachineConfig { if emu_id != 0 && !matches!(self.machine.profile, MachineProfile::FuelIp34 | MachineProfile::TezroIp53) { return Err(format!("machine identity: emu_id = {emu_id} is not taken by this machine; only the Fuel and Tezro take it")); } + if self.machine.audio.is_some() { + self.machine.profile.accepts_audio_key()?; + } for (id, dev) in &self.scsi { // A CD-ROM may legitimately start with an empty tray (no path, no // discs) and have media loaded at runtime; any discs list is valid @@ -3991,17 +4153,29 @@ pub struct Cli { #[arg(long = "2x", default_value_t = false)] pub scale2x: bool, - /// Run headless: no window, no REX3 graphics (audio unaffected; use --noaudio to disable) + /// Run headless: no window, no REX3 graphics (audio unaffected; see --audio and --audio-output) #[arg(long, default_value_t = false)] pub headless: bool, - /// Disable audio emulation (no HAL2); graphics still works - #[arg(long = "noaudio", default_value_t = false)] + /// Whether the machine has its audio chip (sets [machine] audio): the + /// Indy and Indigo2 have HAL2 unless false, the Tezro an ICE1724 only if + /// true. No other machine takes it. + #[arg(long = "audio", value_name = "BOOL")] + pub audio: Option, + + /// Where a present audio card plays (sets [audio] output): default, alsa, + /// pulseaudio, none or file:PATH + #[arg(long = "audio-output", value_name = "OUTPUT")] + pub audio_output: Option, + + /// Deprecated: `--audio false` on the Indy and Indigo2, `--audio-output + /// none` elsewhere + #[arg(long = "noaudio", default_value_t = false, conflicts_with_all = ["audio", "audio_output"])] pub no_audio: bool, - /// Host audio system for HAL2: default, alsa or pulseaudio (overrides [audio] host) - #[arg(long = "audio-host", value_enum, value_name = "HOST")] - pub audio_host: Option, + /// Deprecated: --audio-output + #[arg(long = "audio-host", value_name = "HOST", conflicts_with = "audio_output")] + pub audio_host: Option, /// Enable NFS share: path to the directory to export (enables NFS) #[arg(long = "nfs-dir", value_name = "DIR")] @@ -4218,6 +4392,11 @@ impl Cli { }); } if self.ip22 { cfg.machine.profile = MachineProfile::Indigo2Ip22; } + // What the file folded for its own machine means the same on the one chosen. + cfg.apply_silenced(); + if let Some(line) = cfg.fold_tezro_audio(cfg.machine.audio.is_some()) { + eprintln!("iris: deprecated audio keys folded ({line}); move them in the config to silence this"); + } if let Some(p) = &self.prom { cfg.prom = p.clone(); } if let Some(p) = &self.nvram { cfg.nvram = p.clone(); } if let Some(p) = &self.nveeprom { cfg.nveeprom = p.clone(); } @@ -4267,8 +4446,18 @@ impl Cli { if let Some(r) = self.renderer { cfg.renderer = r; } if self.scale2x { cfg.scale = 2; } if self.headless { cfg.headless = true; } - if self.no_audio { cfg.no_audio = true; } - if let Some(h) = self.audio_host { cfg.audio.host = h; } + if let Some(on) = self.audio { cfg.machine.audio = Some(on); } + if let Some(o) = &self.audio_output { cfg.audio.output = Some(o.clone()); } + // `--noaudio` last: it won over `--audio-host` when both were given. + if let Some(h) = &self.audio_host { + cfg.audio.output = Some(h.clone()); + eprintln!("iris: --audio-host is deprecated; use --audio-output"); + } + if self.no_audio { + cfg.silenced = true; + cfg.apply_silenced(); + eprintln!("iris: --noaudio is deprecated; use {}", if cfg.machine.profile.has_hal2() { "--audio false" } else { "--audio-output none" }); + } if self.no_scsi_deferred_int { cfg.scsi_deferred_int = false; } if self.ci { cfg.ci = true; } @@ -4642,23 +4831,45 @@ mod export_tests { assert!(!GraphicsBoard::V12.supports(MachineProfile::Indigo2Ip22)); } + /// Every `[audio] output` spelling reads, writes back and reads again; + /// toml accepts `{ file = ... }` for the file variant, a misspelt key is + /// an error, and the platform's host is what an unset one means. #[test] - fn audio_host_parses_and_defaults() { + fn audio_output_parses_round_trips_and_defaults() { let cfg: MachineConfig = toml::from_str("").expect("parse"); - assert_eq!(cfg.audio.host, AudioHost::Default); - let cfg: MachineConfig = toml::from_str(r#" - [audio] - host = "pulseaudio" - cpal_buffer_frames = 256 - "#).expect("parse"); - assert_eq!(cfg.audio.host, AudioHost::Pulseaudio); - assert_eq!(cfg.audio.cpal_buffer_frames, Some(256)); - let back: MachineConfig = toml::from_str(&toml::to_string_pretty(&cfg).expect("serialize")).expect("deserialize"); - assert_eq!(back.audio.host, AudioHost::Pulseaudio); - assert!(toml::from_str::("[audio]\nhost = \"oss\"").is_err()); - assert_eq!(cfg.audio.file, None); - let cfg: MachineConfig = toml::from_str("[audio]\nfile = \"out.wav\"").expect("parse"); - assert_eq!(cfg.audio.file.as_deref(), Some(std::path::Path::new("out.wav"))); + assert_eq!(*cfg.audio.output(), AudioOutput::Default); + assert!(cfg.audio.plays()); + for (text, want) in [ + ("output = \"default\"", AudioOutput::Default), + ("output = \"alsa\"", AudioOutput::Alsa), + ("output = \"pulseaudio\"", AudioOutput::Pulseaudio), + ("output = \"none\"", AudioOutput::None), + ("output = { file = \"out.wav\" }", AudioOutput::File("out.wav".into())), + ] { + let cfg = MachineConfig::parse_toml(&format!("[audio]\n{text}\ncpal_buffer_frames = 256\n")).expect(text); + assert_eq!(*cfg.audio.output(), want, "{text}"); + assert_eq!(cfg.audio.cpal_buffer_frames, Some(256)); + let back = toml::to_string_pretty(&cfg).expect("serialize"); + let back = MachineConfig::parse_toml(&back).unwrap_or_else(|e| panic!("{e}\n{back}")); + assert_eq!(*back.audio.output(), want, "written back: {text}"); + assert_eq!(back.audio.plays(), want != AudioOutput::None); + } + assert!(MachineConfig::parse_toml("[audio]\noutput = \"oss\"").is_err()); + // The only config table that once ignored a typo. + let e = MachineConfig::parse_toml("[audio]\noutpt = \"none\"").unwrap_err().to_string(); + assert!(e.contains("outpt"), "{e}"); + // A default file leaves no `[audio]` output behind. + assert!(!toml::to_string(&MachineConfig::default()).unwrap().contains("output")); + } + + /// The command-line spelling of an output. + #[test] + fn audio_output_parses_from_the_command_line() { + assert_eq!("none".parse::(), Ok(AudioOutput::None)); + assert_eq!("alsa".parse::(), Ok(AudioOutput::Alsa)); + assert_eq!(r"file:C:\x.wav".parse::(), Ok(AudioOutput::File(r"C:\x.wav".into())), "split once, so a drive survives"); + assert!("file:".parse::().is_err()); + assert!("oss".parse::().is_err()); } #[test] @@ -5265,6 +5476,176 @@ mod export_tests { assert_eq!(cfg.prom, "mine.bin"); } + fn audio_cfg(text: &str) -> MachineConfig { + MachineConfig::parse_toml(text).unwrap_or_else(|e| panic!("{e}\n{text}")) + } + + /// Which profiles have an audio chip when `[machine] audio` is not set. + #[test] + fn the_audio_chip_follows_the_profile_unless_the_key_says_otherwise() { + for (name, has, takes_key) in [ + ("indy_ip24", true, true), ("indigo2_ip22", true, true), ("indigo2_ip28", true, true), + ("o2_ip32", true, false), ("octane_ip30", true, false), ("onyx2_ip27", true, false), + ("origin200_ip29", false, false), ("origin2000_ip27", false, false), ("fuel_ip34", false, false), + ("tezro_ip53", false, true), ("origin3200_ip35", false, false), ("onyx3000_ip35", false, false), + ] { + let cfg = audio_cfg(&format!("[machine]\nprofile = \"{name}\"\n")); + assert_eq!(cfg.machine.has_audio(), has, "{name}"); + assert_eq!(cfg.audio_card().is_some(), has, "{name}"); + for on in [true, false] { + let mut cfg = cfg.clone(); + cfg.machine.audio = Some(on); + assert_eq!(cfg.machine.profile.accepts_audio_key().is_ok(), takes_key, "{name} audio = {on}"); + if takes_key { + assert_eq!(cfg.machine.has_audio(), on, "{name} audio = {on}"); + } + } + } + } + + /// A machine that cannot take the key says why, naming itself and, where + /// the chip is part of the board, the key that silences it. + #[test] + fn machine_audio_is_refused_where_there_is_nothing_to_choose() { + for (name, says) in [ + ("octane_ip30", "SGI Octane (IP30)'s audio is part of its board; use [audio] output = \"none\""), + ("o2_ip32", "SGI O2 (IP32)'s audio is part of its board; use [audio] output = \"none\""), + ("onyx2_ip27", "SGI Onyx2 (IP27)'s audio is part of its board; use [audio] output = \"none\""), + ("fuel_ip34", "the SGI Fuel (IP34) has no audio chip"), + ("origin2000_ip27", "the SGI Origin 2000 (IP27) has no audio chip"), + ] { + let e = audio_cfg(&format!("[machine]\nprofile = \"{name}\"\naudio = true\n")).machine.profile.accepts_audio_key().unwrap_err(); + assert!(e.starts_with("[machine] audio: ") && e.contains(says), "{name}: {e}"); + } + } + + /// `validate()` refuses `[machine] audio` on a machine with nothing to + /// choose, naming it, and takes it on the ones that do (the other + /// profiles' messages are in `accepts_audio_key`'s test). + #[test] + fn validate_refuses_machine_audio_where_there_is_no_choice() { + let audio_cfg_for = |profile: &str, extra: &str| audio_cfg(&format!("[machine]\nprofile = \"{profile}\"\n{extra}")); + let e = audio_cfg_for("octane_ip30", "audio = false\n").validate().unwrap_err(); + assert!(e.starts_with("[machine] audio: the SGI Octane (IP30)"), "{e}"); + assert!(audio_cfg_for("tezro_ip53", "audio = true\n").validate().is_ok()); + assert!(audio_cfg_for("indy_ip24", "audio = false\n").validate().is_ok()); + } + + /// Every old audio key lands on the key it stood for, nothing old is + /// written back, and a key from this version wins over an old one. + #[test] + fn deprecated_audio_keys_fold_into_machine_audio_and_audio_output() { + let output = |c: &MachineConfig| c.audio.output.clone(); + // `no_audio` took HAL2 off the Indy and Indigo2 and only silenced the rest. + for profile in ["indy_ip24", "indigo2_ip22", "indigo2_ip28"] { + let cfg = audio_cfg(&format!("no_audio = true\n[machine]\nprofile = \"{profile}\"\n")); + assert_eq!((cfg.machine.audio, output(&cfg)), (Some(false), None), "{profile}"); + assert!(!cfg.machine.has_audio()); + } + assert!(!audio_cfg("no_audio = true").machine.has_audio(), "the default profile is the Indy"); + for profile in ["o2_ip32", "octane_ip30", "onyx2_ip27"] { + let cfg = audio_cfg(&format!("no_audio = true\n[machine]\nprofile = \"{profile}\"\n")); + assert_eq!((cfg.machine.audio, output(&cfg)), (None, Some(AudioOutput::None)), "{profile}"); + assert!(cfg.machine.has_audio() && !cfg.audio.plays(), "{profile}: the card stays, silent"); + } + let cfg = audio_cfg("no_audio = true\n[machine]\nprofile = \"fuel_ip34\"\n"); + assert_eq!((cfg.machine.audio, output(&cfg)), (None, Some(AudioOutput::None))); + assert!(!cfg.machine.has_audio()); + // The Tezro's card is there only if added, and the old key gave it no host output. + let cfg = audio_cfg("no_audio = true\n[machine]\nprofile = \"tezro_ip53\"\n"); + assert_eq!((cfg.machine.has_audio(), output(&cfg)), (false, Some(AudioOutput::None))); + let cfg = audio_cfg("no_audio = true\n[machine]\nprofile = \"tezro_ip53\"\n[tezro]\naudio = true\n"); + assert_eq!((cfg.machine.audio, output(&cfg)), (Some(true), Some(AudioOutput::None))); + assert!(cfg.machine.has_audio() && !cfg.audio.plays()); + // `no_audio = false` was the default. + let cfg = audio_cfg("no_audio = false"); + assert_eq!((cfg.machine.audio, output(&cfg)), (None, None)); + + // `[audio] host` and `file` became `output`; `file` won over `host`. + assert_eq!(output(&audio_cfg("[audio]\nhost = \"alsa\"")), Some(AudioOutput::Alsa)); + assert_eq!(output(&audio_cfg("[audio]\nfile = \"x.wav\"")), Some(AudioOutput::File("x.wav".into()))); + assert_eq!(output(&audio_cfg("[audio]\nhost = \"alsa\"\nfile = \"x.wav\"")), Some(AudioOutput::File("x.wav".into()))); + // `no_audio` won over both: the card never got `[audio]`. + let cfg = audio_cfg("no_audio = true\n[audio]\nhost = \"alsa\"\nfile = \"x.wav\"\n[machine]\nprofile = \"octane_ip30\"\n"); + assert_eq!(output(&cfg), Some(AudioOutput::None)); + let cfg = audio_cfg("no_audio = true\n[audio]\nfile = \"x.wav\"\n"); + assert_eq!((cfg.machine.audio, output(&cfg)), (Some(false), None)); + // `prebuf_ms` was read by nothing. + let cfg = audio_cfg("[audio]\nprebuf_ms = 40\ncpal_buffer_frames = 128\n"); + assert_eq!((cfg.audio.prebuf_ms, cfg.audio.cpal_buffer_frames), (None, Some(128))); + // `[tezro] audio` moved to `[machine] audio`, on a Tezro only. + let cfg = audio_cfg("[machine]\nprofile = \"tezro_ip53\"\n[tezro]\naudio = true\n"); + assert_eq!((cfg.machine.audio, cfg.tezro.audio), (Some(true), None)); + assert!(cfg.machine.has_audio()); + let cfg = audio_cfg("[machine]\nprofile = \"octane_ip30\"\n[tezro]\naudio = true\n"); + assert_eq!(cfg.machine.audio, None, "a leftover [tezro] table never refused an Octane"); + assert!(cfg.validate().err().is_none_or(|e| !e.contains("[machine] audio"))); + + // A new key that is set wins, whichever old key disagrees. + let cfg = audio_cfg("no_audio = true\n[machine]\naudio = true\n"); + assert!(cfg.machine.has_audio()); + assert_eq!(output(&cfg), None); + let cfg = audio_cfg("no_audio = true\n[audio]\noutput = \"alsa\"\n[machine]\nprofile = \"octane_ip30\"\n"); + assert_eq!(output(&cfg), Some(AudioOutput::Alsa)); + let cfg = audio_cfg("[audio]\noutput = \"none\"\nhost = \"alsa\"\nfile = \"x.wav\"\n"); + assert_eq!(output(&cfg), Some(AudioOutput::None)); + let cfg = audio_cfg("[machine]\nprofile = \"tezro_ip53\"\naudio = false\n[tezro]\naudio = true\n"); + assert_eq!(cfg.machine.audio, Some(false)); + + // Nothing old is written back. + let cfg = audio_cfg("no_audio = true\n[audio]\nhost = \"alsa\"\nfile = \"x.wav\"\nprebuf_ms = 20\n[tezro]\naudio = true\n"); + let written = toml::to_string_pretty(&cfg).unwrap(); + for gone in ["no_audio", "host =", "x.wav", "prebuf_ms", "silenced"] { + assert!(!written.contains(gone), "{gone} written back:\n{written}"); + } + assert!(!written.contains("[tezro]"), "{written}"); + // And a file written back means what it did. + let again = audio_cfg(&written); + assert_eq!((again.machine.audio, output(&again)), (cfg.machine.audio, output(&cfg))); + } + + /// The command-line audio options, applied after `--machine`: `--audio` + /// sets the machine's key, `--audio-output` the output, and the old + /// `--noaudio` and `--audio-host` mean what they meant. + #[test] + fn audio_flags_set_the_keys_and_the_old_ones_fold() { + let run = |args: &[&str], cfg: MachineConfig| Cli::try_parse_from(["iris"].iter().chain(args)).expect("flags").apply(cfg); + let cfg = run(&["--audio", "false", "--machine", "indigo2_ip28"], MachineConfig::default()); + assert_eq!((cfg.machine.profile, cfg.machine.audio), (MachineProfile::Indigo2Ip28, Some(false))); + let cfg = run(&["--audio", "true", "--machine", "tezro_ip53"], MachineConfig::default()); + assert!(cfg.machine.has_audio()); + let cfg = run(&["--audio", "true", "--machine", "octane_ip30"], MachineConfig::default()); + assert!(cfg.machine.audio.is_some() && cfg.machine.profile.accepts_audio_key().is_err(), "refused on a machine with no key"); + let cfg = run(&[r"--audio-output", r"file:C:\x.wav"], MachineConfig::default()); + assert_eq!(cfg.audio.output, Some(AudioOutput::File(r"C:\x.wav".into()))); + // Deprecated spellings: HAL2 off on the Indy, a silent card elsewhere. + let cfg = run(&["--noaudio"], MachineConfig::default()); + assert_eq!((cfg.machine.audio, cfg.audio.output.clone()), (Some(false), None)); + let cfg = run(&["--noaudio", "--machine", "o2_ip32"], MachineConfig::default()); + assert_eq!((cfg.machine.audio, cfg.audio.output.clone()), (None, Some(AudioOutput::None))); + let cfg = run(&["--audio-host", "pulseaudio"], MachineConfig::default()); + assert_eq!(cfg.audio.output, Some(AudioOutput::Pulseaudio)); + // An alias with its own replacement is a parse error. + for pair in [&["--noaudio", "--audio", "false"][..], &["--noaudio", "--audio-output", "none"], &["--audio-host", "alsa", "--audio-output", "none"]] { + assert!(Cli::try_parse_from(["iris"].iter().chain(pair)).is_err(), "{pair:?}"); + } + assert!(Cli::try_parse_from(["iris", "--audio", "maybe"]).is_err()); + assert!(Cli::try_parse_from(["iris", "--audio-output", "oss"]).is_err()); + // The old pair still means silence: `--noaudio` won over `--audio-host`. + let cfg = run(&["--noaudio", "--audio-host", "alsa", "--machine", "o2_ip32"], MachineConfig::default()); + assert_eq!(cfg.audio.output, Some(AudioOutput::None)); + // A file's `[tezro] audio` and `no_audio` follow `--machine`. + let cfg = run(&["--machine", "tezro_ip53"], audio_cfg("[tezro]\naudio = true\n")); + assert_eq!(cfg.machine.audio, Some(true)); + // A file's `no_audio` follows `--machine`: the same silence on the machine chosen. + let file = audio_cfg("no_audio = true\n"); + let cfg = run(&["--machine", "octane_ip30"], file.clone()); + assert_eq!((cfg.machine.audio, cfg.audio.output.clone()), (None, Some(AudioOutput::None))); + let file = audio_cfg("no_audio = true\n[machine]\nprofile = \"o2_ip32\"\n"); + let cfg = run(&["--machine", "indy_ip24"], file); + assert_eq!(cfg.machine.audio, Some(false)); + } + /// `[scsi.17]` is id 1 on controller 1, so one id can be on both /// controllers; `controller = 1` on a low key still works; a key that /// names a controller cannot be contradicted, two tables cannot name the @@ -5771,16 +6152,15 @@ mod tezro_config_tests { cfg } - /// The audio controller is off unless `[tezro] audio` adds it, and a + /// The audio controller is off unless `[machine] audio` adds it, and a /// config that adds it is written back. #[test] fn tezro_audio_is_opt_in_and_survives_a_save() { - assert!(!tezro().tezro.audio); - let on = MachineConfig::parse_toml("[machine]\nprofile = \"tezro_ip53\"\n[tezro]\naudio = true\n").unwrap(); - assert!(on.tezro.audio && !on.tezro.is_default()); - assert!(TezroSection::default().is_default()); + assert!(!tezro().machine.has_audio()); + let on = MachineConfig::parse_toml("[machine]\nprofile = \"tezro_ip53\"\naudio = true\n").unwrap(); + assert!(on.machine.has_audio()); let text = toml::to_string(&on).unwrap(); - assert!(MachineConfig::parse_toml(&text).unwrap().tezro.audio, "{text}"); + assert!(MachineConfig::parse_toml(&text).unwrap().machine.has_audio(), "{text}"); } /// Left at the generic default, `banks` gives the Tezro its 4 GiB layout; diff --git a/src/dev/audio_dump.rs b/src/dev/audio_dump.rs index 5dada0fbc..c04da5f15 100644 --- a/src/dev/audio_dump.rs +++ b/src/dev/audio_dump.rs @@ -10,7 +10,7 @@ //! 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. +//! For a WAV of what the host plays, use `[audio] output = { 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; diff --git a/src/dev/hal2.rs b/src/dev/hal2.rs index d97908970..7dd688f2d 100644 --- a/src/dev/hal2.rs +++ b/src/dev/hal2.rs @@ -5,7 +5,7 @@ use crate::devlog::LogModule; 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::config::AudioConfig; use crate::dev::audio_dump::AudioDump; use crate::hptimer::{TimerManager, TimerId, TimerReturn}; use crate::host_audio::{HostOut as AudioOut, Meter, Resampler}; @@ -142,9 +142,6 @@ fn scale_sample(sample: i16, gain_q15_mdac: i64) -> i16 { // This gives the CPU time to fill its circular DMA buffer before we start draining it, // preventing initial underrun. const PREBUF_MS: u64 = 20; -// Ring buffer capacity as a multiple of PREBUF_MS. Must absorb OS scheduling jitter. -// Expressed as a multiplier of PREBUF_MS stereo samples. -const RING_BUF_MULTIPLIER: usize = 16; // Consecutive dry reads before giving up prebuf and opening stream anyway. const DRY_LIMIT: u32 = 100; @@ -393,10 +390,9 @@ fn prebuf_samples(rate: u32) -> usize { (rate as usize * 2 * PREBUF_MS as usize) / 1000 } -/// The host output, through the shared `host_audio` (ring sized for HAL2's -/// prebuffer). +/// The host output, through the shared `host_audio`. fn open_persistent_output(audio: &AudioConfig, meter: Arc) -> Option { - crate::host_audio::open_output(audio, meter, |rate| prebuf_samples(rate) * RING_BUF_MULTIPLIER, "HAL2") + crate::host_audio::open_output(audio, meter, "HAL2") } // ─── impl Hal2 ──────────────────────────────────────────────────────────────── @@ -1189,7 +1185,7 @@ impl Device for Hal2 { fn start(&self) { // Open persistent audio output once. Codec A timer will push into it. let audio = open_persistent_output(&self.audio, self.meter.clone()); - if audio.is_none() { + if audio.is_none() && self.audio.plays() { eprintln!("HAL2: no audio output available"); } self.ca_state.lock().out = audio; @@ -1221,7 +1217,7 @@ impl Device for Hal2 { let rate = |(_, clk, _): (usize, usize, usize), armed: bool| armed.then(|| s.bres_rate(clk)).filter(|&r| r > 0); [rate(s.codecb_cfg(), armed_b), rate(s.codeca_cfg(), armed_a && self.meter.playing())] }; - Some(self.meter.report(&rates, self.ca_state.lock().out.as_ref(), true)) + Some(self.meter.report(&rates, self.ca_state.lock().out.as_ref(), self.audio.plays())) } } @@ -1336,6 +1332,20 @@ mod tests { ); } + /// A HAL2 with `[audio] output = "none"` opens no host stream, says why in + /// its report and does not complain that none is available. + #[test] + fn output_none_opens_no_stream_and_the_report_says_so() { + let none = AudioConfig { output: Some(crate::config::AudioOutput::None), ..AudioConfig::default() }; + let h = Hal2::with_audio(vec![], none); + h.start(); + assert!(h.ca_state.lock().out.is_none()); + assert!(h.audio_report().unwrap().ends_with("host output: none (output = \"none\")\n")); + // A card that plays but has no stream open is a different state. + let playing = Hal2::new(vec![]); + assert!(playing.audio_report().unwrap().ends_with("host output: none (not open)\n")); + } + /// Disabling codec A's DMA ends the card's playing: the host's silence /// after a sound is not an underrun, and the next frame starts it again. #[test] @@ -1406,7 +1416,7 @@ mod host_tests { /// The whole way from the DMA channel to the host as a running machine /// takes it: codec A on BRES1 at 48 kHz, its recurring timer pacing the /// channel by the host's clock, the prebuffer, and the host output - /// `Device::start` opens -- an `[audio] file`, so no sound card -- + /// `Device::start` opens -- a file output, so no sound card -- /// drained in real time. At 48 kHz nothing is resampled: what the file /// holds, less the underrun silence, is the buffer sample for sample /// with each channel on its own side. diff --git a/src/dev/host_pcm.rs b/src/dev/host_pcm.rs index c69188ff7..2d56a029f 100644 --- a/src/dev/host_pcm.rs +++ b/src/dev/host_pcm.rs @@ -9,12 +9,12 @@ use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Instant; use parking_lot::Mutex; use crate::dev::audio_dump::AudioDump; -use crate::host_audio::{HostOut, Resampler}; +use crate::host_audio::{HostLink, Resampler}; /// Words a host frame carries: the mixer takes two channels. pub const PCM_CHANNELS: usize = 2; @@ -27,25 +27,15 @@ const STALL_REPAY_DIV: i64 = 32; /// 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 host: HostLink, pub dump: Option>, pub tap: Option>>>, } -/// How long to wait before trying to open the host stream again. -pub const HOST_RETRY: Duration = Duration::from_secs(1); - -/// Whether a host stream wanted but not open (`closed`) is due another try. -fn retry_due(closed: bool, failed_at: Option, now: Instant) -> bool { - closed && failed_at.is_none_or(|t| now.duration_since(t) >= HOST_RETRY) -} - impl Sink { - /// Whether the host stream, wanted but not open, is due another try. - pub fn host_retry_due(&self, now: Instant) -> bool { - retry_due(self.host.is_none(), self.host_failed_at, now) + /// A sink with no host stream, recording as `IRIS_AUDIO_DUMP` asks. + pub fn new() -> Self { + Self { host: HostLink::default(), dump: AudioDump::from_env(), tap: None } } /// Play stereo frames the card produced at `rate` Hz. A muted output @@ -58,7 +48,7 @@ impl Sink { } else { pcm }; - if let Some((out, rs)) = self.host.as_mut() { + if let Some((out, rs)) = self.host.stream.as_mut() { if rs.in_rate != rate { *rs = Resampler::new(rate, out.stream_rate, PCM_CHANNELS); } @@ -164,9 +154,7 @@ impl Downmix { /// host's channel count ([`Downmix`]) and the S/PDIF pair adds to the front /// pair. Nothing allocates once the buffers have grown to a tick's worth. pub struct PcmOut { - pub host: Option<(HostOut, Resampler)>, - /// When the host stream was last tried and failed to open. - pub host_failed_at: Option, + pub host: HostLink, /// `IRIS_AUDIO_DUMP`: the front pair of each mixed frame at the card's /// rate, before resampling. pub dump: Option>, @@ -189,10 +177,9 @@ pub struct PcmOut { const STAGE_MAX_FRAMES: usize = 1 << 14; impl PcmOut { - pub fn new(host: Option<(HostOut, Resampler)>, host_failed_at: Option) -> Self { + pub fn new(host: HostLink) -> Self { Self { host, - host_failed_at, dump: AudioDump::from_env(), #[cfg(test)] tap: None, @@ -206,11 +193,6 @@ impl PcmOut { } } - /// Whether the host stream, wanted but not open, is due another try. - pub fn host_retry_due(&self, now: Instant) -> bool { - retry_due(self.host.is_none(), self.host_failed_at, now) - } - /// Where the interleaved engine puts its samples, `slots` to a frame. /// Frames staged at another width are dropped. pub fn analog_stage(&mut self, slots: usize) -> &mut Vec { @@ -247,7 +229,7 @@ impl PcmOut { let no_host_channels = self.tap_channels; #[cfg(not(test))] let no_host_channels = PCM_CHANNELS; - let chans = self.host.as_ref().map_or(no_host_channels, |(out, _)| out.channels); + let chans = self.host.out().map_or(no_host_channels, |out| out.channels); if n > 0 { if self.mix.src != slots || self.mix.dst != chans { self.mix = Downmix::new(slots, chans); @@ -279,7 +261,7 @@ impl PcmOut { if let Some(t) = self.tap.as_ref() { t.lock().extend_from_slice(&self.mixed); } - if let Some((out, rs)) = self.host.as_mut() { + if let Some((out, rs)) = self.host.stream.as_mut() { if rs.in_rate != rate { *rs = Resampler::new(rate, out.stream_rate, chans); } @@ -458,6 +440,7 @@ pub(crate) mod test_time { #[cfg(test)] mod tests { use super::*; + use crate::host_audio::HostOut; /// One frame through the matrix, as unit gains times `v` on `slot`. fn mix_one(src: usize, dst: usize, slot: usize, v: i16) -> Vec { @@ -529,7 +512,7 @@ mod tests { #[test] fn spdif_on_top_of_the_analog_pair_saturates() { let tap = Arc::new(Mutex::new(Vec::new())); - let mut out = PcmOut::new(None, None); + let mut out = PcmOut::new(HostLink::default()); out.tap = Some(tap.clone()); out.analog_stage(2).extend([0x6000, -0x6000]); out.spdif_stage().extend([0x6000, -0x6000]); @@ -544,7 +527,7 @@ mod tests { 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); + let mut out = PcmOut::new(HostLink::default()); 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]); @@ -566,7 +549,7 @@ mod tests { let (p, mut c) = rtrb::RingBuffer::::new(slots); let out = HostOut::detached(crate::host_audio::Meter::new("test", &[]), outr, p); let meter = out.meter.clone(); - let mut sink = Sink { host: Some((out, Resampler::new(inr, outr, PCM_CHANNELS))), host_failed_at: None, dump: None, tap: None }; + let mut sink = Sink { host: HostLink::with_stream(out, Resampler::new(inr, outr, PCM_CHANNELS)), dump: None, tap: None }; for (i, chunk) in golden_chunks(&sig, Push::Burst).iter().enumerate() { let pcm: Vec = chunk.iter().flat_map(|&(l, r)| [l, r]).collect(); sink.play(&pcm, inr, false); @@ -598,7 +581,7 @@ mod tests { let (p, mut c) = rtrb::RingBuffer::::new(slots); let out = HostOut::detached_channels(crate::host_audio::Meter::new("test", &[]), outr, chans, p); let meter = out.meter.clone(); - let mut pcm = PcmOut::new(Some((out, Resampler::new(inr, outr, chans))), None); + let mut pcm = PcmOut::new(HostLink::with_stream(out, Resampler::new(inr, outr, chans))); pcm.dump = None; for (i, chunk) in golden_chunks(&sig, Push::Burst).iter().enumerate() { let stage = pcm.analog_stage(8); diff --git a/src/dev/hpc3.rs b/src/dev/hpc3.rs index 6713e6721..c1535caba 100644 --- a/src/dev/hpc3.rs +++ b/src/dev/hpc3.rs @@ -1204,12 +1204,12 @@ impl Hpc3 { } } - /// `no_audio` skips HAL2 audio init (used by `--noaudio` and also by full - /// `--headless`, which can't run audio in CI). + /// `audio` is HAL2's `[audio]` settings, or `None` for a machine without + /// HAL2 (`[machine] audio = false`). /// `nvram_path` is the on-disk NVRAM file (loaded at startup, default save /// target for `iris-ci rtc-save`); `rtc_offset` shifts the RTC's /// host-time seed (`[rtc_offset]`). - pub fn with_net(eeprom: Arc>, ioc: Ioc, guinness: bool, heartbeat: Arc, net: NetworkConfig, no_audio: bool, audio: AudioConfig, nvram_path: String, rtc_offset: RtcOffset, scsi_deferred_int: bool, scsi_buses: Vec) -> Self { + pub fn with_net(eeprom: Arc>, ioc: Ioc, guinness: bool, heartbeat: Arc, net: NetworkConfig, audio: Option, nvram_path: String, rtc_offset: RtcOffset, scsi_deferred_int: bool, scsi_buses: Vec) -> Self { let mut scsi_buses = scsi_buses.into_iter(); let gateway_cfg = GatewayConfig::from_network(&net); let rtc = Arc::new(Ds1x86::new(8192, nvram_path, rtc_offset)); @@ -1310,7 +1310,7 @@ impl Hpc3 { None }; - let hal2 = if no_audio { None } else { Some(Arc::new(Hal2::with_audio(dma_clients[0..8].to_vec(), audio))) }; + let hal2 = audio.map(|audio| Arc::new(Hal2::with_audio(dma_clients[0..8].to_vec(), audio))); Self { state, @@ -1459,7 +1459,7 @@ impl Device for Hpc3 { fn audio_report(&self) -> Option { match &self.hal2 { Some(hal2) => hal2.audio_report(), - None => Some("audio: HAL2 absent: no_audio is set\n".to_string()), + None => Some("audio: HAL2 absent: [machine] audio is false\n".to_string()), } } @@ -2472,7 +2472,7 @@ mod tests { let ee = Arc::new(Mutex::new(Eeprom93c56::with_path(LogModule::Nveeprom, nveeprom.clone()))); let hpc = Hpc3::with_net( ee.clone(), Ioc::new(true), false, Arc::new(AtomicU64::new(0)), - NetworkConfig::default(), true, AudioConfig::default(), nvram.clone(), + NetworkConfig::default(), None, nvram.clone(), RtcOffset::default(), true, Hpc3::new_scsi_buses(false), ); hpc.rtc.write8(500, 0x5a); @@ -2488,18 +2488,19 @@ mod tests { } /// `audio status` reaches HAL2 through the HPC3 when the machine has one, - /// and is not a command when `no_audio` leaves the HPC3 without it. + /// and is not a command when `[machine] audio = false` leaves the HPC3 + /// without it. #[test] fn audio_status_is_routed_to_hal2_only_when_the_machine_has_it() { - let hpc = |no_audio| Hpc3::with_net( + let hpc = |audio| Hpc3::with_net( Arc::new(Mutex::new(Eeprom93c56::new())), Ioc::new(true), true, Arc::new(AtomicU64::new(0)), - NetworkConfig::default(), no_audio, AudioConfig::default(), String::new(), + NetworkConfig::default(), audio, String::new(), RtcOffset::default(), true, Hpc3::new_scsi_buses(true), ); - let with_hal2 = hpc(false); + let with_hal2 = hpc(Some(AudioConfig::default())); let text = with_hal2.audio_report().expect("the HPC3 reports its HAL2"); assert!(text.starts_with("audio: HAL2\n"), "{text}"); - assert_eq!(hpc(true).audio_report().as_deref(), Some("audio: HAL2 absent: no_audio is set\n")); + assert_eq!(hpc(None).audio_report().as_deref(), Some("audio: HAL2 absent: [machine] audio is false\n")); } /// One channel per ops group: PBUS, SCSI, enet RX, enet TX. All twelve go @@ -2514,8 +2515,7 @@ mod tests { true, Arc::new(AtomicU64::new(0)), NetworkConfig::default(), - true, - AudioConfig::default(), + None, // Empty path: nothing on disk to load, and nothing written back. String::new(), RtcOffset::default(), @@ -2676,8 +2676,7 @@ mod enet_rx_chain_tests { true, Arc::new(AtomicU64::new(0)), NetworkConfig::default(), - true, - AudioConfig::default(), + None, String::new(), RtcOffset::default(), true, diff --git a/src/dev/ice1724.rs b/src/dev/ice1724.rs index 2afea378e..d1e503720 100644 --- a/src/dev/ice1724.rs +++ b/src/dev/ice1724.rs @@ -148,7 +148,7 @@ use parking_lot::{Condvar, Mutex, MutexGuard}; use crate::config::AudioConfig; use crate::dev::host_pcm::{CardClock, PcmOut}; use crate::devlog::LogModule; -use crate::host_audio::{Meter, Resampler}; +use crate::host_audio::{HostLink, Meter, Resampler}; use crate::hptimer::{TimerId, TimerManager, TimerReturn}; use crate::pci::{Bar, PciConfig, PciConfigPolicy, PciFunction, PciFunctionState, PciRegisterPolicy, PCI_COMMAND_IO, PCI_INTA}; use crate::snapshot::{field, field_bool, field_u32, field_u64, field_u8, field_u8s, load_u16_slice, StateTable}; @@ -677,8 +677,7 @@ pub struct Inner { /// cards; reached through `Deref`. clock: CardClock, timers: OnceLock>, - /// `[audio]` settings once host output is enabled. - audio_cfg: Mutex>, + /// Where the output goes, once the card is enabled. sink: Mutex>, /// What the engines have moved and the host stream has done. meter: Arc, @@ -731,7 +730,6 @@ impl Inner { idle: Condvar::new(), clock, timers: OnceLock::new(), - audio_cfg: Mutex::new(None), sink: Mutex::new(None), meter: Meter::new(if variant == Variant::Dm8 { "ICE1724 (DM8)" } else { "ICE1724 (AC'97)" }, &["analog in", "analog out", "S/PDIF out"]), unexpected: AtomicU64::new(0), @@ -787,7 +785,7 @@ impl Inner { /// Play the output engines through the host with `audio`'s settings. The /// host stream opens when the machine starts, before the guest runs. pub fn enable_output(&self, audio: AudioConfig) { - *self.audio_cfg.lock() = Some(audio); + self.sink.lock().get_or_insert_with(|| PcmOut::new(HostLink::default())).host.enable(audio); } /// Give the card the machine's timers. Called before `Device::start`. @@ -906,38 +904,18 @@ impl Inner { /// Open the host stream when output is enabled and none is open. `force` /// skips the wait between tries. fn open_sink(&self, force: bool) { - let Some(cfg) = self.audio_cfg.lock().clone() else { return }; - // Read before the sink lock: engines take the state lock, then the - // sink's. - let rate = self.state.lock().rate; let mut sink = self.sink.lock(); - let now = Instant::now(); - if let Some(sk) = sink.as_ref() { - if !(sk.host_retry_due(now) || force && sk.host.is_none()) { - return; - } - } - 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"); - crate::dlog!(LogModule::Ice1724, "ice1724: host audio {}", if host.is_some() { "open" } else { "failed to open" }); - let host = host.map(|out| { - let rs = Resampler::new(rate, out.stream_rate, out.channels); - (out, rs) - }); - let failed_at = host.is_none().then_some(now); - match sink.as_mut() { - Some(sk) => { - sk.host = host; - sk.host_failed_at = failed_at; - } - None => *sink = Some(PcmOut::new(host, failed_at)), + let Some(sk) = sink.as_mut() else { return }; + let open = |cfg: &AudioConfig| crate::host_audio::open_output_channels(cfg, MAX_HOST_CHANNELS, self.meter.clone(), "ICE1724 audio"); + if let Some(opened) = sk.host.open(Instant::now(), force, open, Resampler::following) { + crate::dlog!(LogModule::Ice1724, "ice1724: host audio {}", if opened { "open" } else { "failed to open" }); } } /// Retry a failed host stream open from the timer, before it takes the /// state lock. fn reopen_sink_if_due(&self) { - let due = self.sink.lock().as_ref().is_some_and(|sk| sk.host_retry_due(Instant::now())); + let due = self.sink.lock().as_ref().is_some_and(|sk| sk.host.retry_due(Instant::now())); if due { self.open_sink(false); } @@ -1693,8 +1671,8 @@ impl Device for Ice1724 { [ENG_RDMA0, ENG_PDMAI, ENG_PDMA4].map(|e| s.eng[e].active.then_some(s.rate)) }; let sink = i.sink.lock(); - let host = sink.as_ref().and_then(|s| s.host.as_ref()).map(|(out, _)| out); - Some(i.meter.report(&rates, host, i.audio_cfg.lock().is_some())) + let link = sink.as_ref().map(|s| &s.host); + Some(i.meter.report(&rates, link.and_then(|l| l.out()), link.is_some_and(|l| l.wanted()))) } } diff --git a/src/dev/ice1724/tests.rs b/src/dev/ice1724/tests.rs index 8052ca38f..927325912 100644 --- a/src/dev/ice1724/tests.rs +++ b/src/dev/ice1724/tests.rs @@ -657,7 +657,7 @@ fn the_timer_deadline_is_the_frame_that_ends_the_interval() { /// Collect the frames the card hands the host, with `channels` to a frame. fn tapped(r: &Rig, channels: usize) -> Arc>> { let tap = Arc::new(Mutex::new(Vec::new())); - let mut out = PcmOut::new(None, None); + let mut out = PcmOut::new(HostLink::default()); out.tap = Some(tap.clone()); out.tap_channels = channels; *r.sink.lock() = Some(out); @@ -820,7 +820,7 @@ fn detached_host(r: &Rig, rate: u32, channels: usize) -> rtrb::Consumer { let (p, c) = rtrb::RingBuffer::::new(1 << 16); let out = HostOut::detached_channels(r.meter.clone(), rate, channels, p); let rs = Resampler::new(48000, rate, channels); - *r.sink.lock() = Some(PcmOut::new(Some((out, rs)), None)); + *r.sink.lock() = Some(PcmOut::new(HostLink::with_stream(out, rs))); c } @@ -874,7 +874,7 @@ fn a_full_host_ring_counts_dropped_frames() { let r = rig(Variant::Dm8); let (p, _c) = rtrb::RingBuffer::::new(20); let out = HostOut::detached_channels(r.meter.clone(), 48000, 8, p); - *r.sink.lock() = Some(PcmOut::new(Some((out, Resampler::new(48000, 48000, 8))), None)); + *r.sink.lock() = Some(PcmOut::new(HostLink::with_stream(out, Resampler::new(48000, 48000, 8)))); fill_slot_numbers(&r, 8); mt_wl(&r, 0x10, BUFFER as u32); mt_ww(&r, 0x14, 959); @@ -1500,10 +1500,24 @@ fn audio_status_counts_the_frames_the_engines_moved() { \x20 S/PDIF out: 0 frames moved, guest rate off\n\ \x20 host underruns: 0 frames\n\ \x20 dropped by the host: 0 frames\n\ - \x20 host output: none (no_audio)\n" + \x20 host output: none (output = \"none\")\n" ); } +/// An enabled card says why it has no host stream: none was asked for +/// (`output = "none"`, which never opens one) or none is open yet. +#[test] +fn audio_status_tells_output_none_from_a_stream_not_yet_open() { + let r = rig(Variant::Dm8); + r.enable_output(crate::config::AudioConfig { output: Some(crate::config::AudioOutput::None), ..Default::default() }); + r.inner.open_sink(true); + assert!(r.audio_report().unwrap().ends_with("host output: none (output = \"none\")\n")); + let r = rig(Variant::Dm8); + r.enable_output(crate::config::AudioConfig { output: Some(crate::config::AudioOutput::File("/nonexistent-dir/x.wav".into())), ..Default::default() }); + r.inner.open_sink(true); + assert!(r.audio_report().unwrap().ends_with("host output: none (not open)\n"), "a file that cannot be written"); +} + /// With a host stream open the status names it, and the card counts underruns /// only from the first frame its engine writes until the engine stops. #[test] @@ -1512,7 +1526,7 @@ fn audio_status_names_the_host_stream_and_underruns_count_only_while_playing() { let (prod, mut cons) = rtrb::RingBuffer::::new(64); let out = HostOut::detached(r.meter.clone(), 48000, prod); let rs = Resampler::new(48000, 48000, 2); - *r.sink.lock() = Some(PcmOut::new(Some((out, rs)), None)); + *r.sink.lock() = Some(PcmOut::new(HostLink::with_stream(out, rs))); let text = r.c.audio_report().unwrap(); assert!(text.contains("host output: test at 48000 Hz, 2 channels"), "{text}"); let silence = |c: &mut rtrb::Consumer| crate::host_audio::pop_frames(c, 2, &mut [0i16; 8], |v| v); @@ -1539,7 +1553,7 @@ fn pausing_the_machine_ends_playing() { let r = rig(Variant::Dm8); let (prod, mut cons) = rtrb::RingBuffer::::new(64); let out = HostOut::detached(r.meter.clone(), 48000, prod); - *r.sink.lock() = Some(PcmOut::new(Some((out, Resampler::new(48000, 48000, 2))), None)); + *r.sink.lock() = Some(PcmOut::new(HostLink::with_stream(out, Resampler::new(48000, 48000, 2)))); fill_playback_buffer(&r); program_playback(&r); mt_wb(&r, 0x18, 0x01); @@ -1758,7 +1772,7 @@ fn a_big_endian_slot_word_through_a_real_pic_slot_plays_its_sample() { pic.attach(0, 2, c.clone(), Function::default()); map(&c); let tap = Arc::new(Mutex::new(Vec::new())); - let mut out = PcmOut::new(None, None); + let mut out = PcmOut::new(HostLink::default()); out.tap = Some(tap.clone()); // Every slot unfolded, as it was read. out.tap_channels = 8; @@ -1787,7 +1801,7 @@ fn a_big_endian_slot_word_through_a_real_pic_slot_plays_its_sample() { /// must carry: a card that read on would play the ring's previous lap /// again. The tone is in slots 0 and 1, which the host gets at unity as its /// front pair, and it drifts in pitch so that no frame is the same as one a -/// lap earlier; with an `[audio] file` the frames heard must follow one +/// lap earlier; with a file output the frames heard must follow one /// another in the guest's order, none replayed. struct Tezro { c: Ice1724, diff --git a/src/dev/mace_audio.rs b/src/dev/mace_audio.rs index 548b8e24f..99e8d5727 100644 --- a/src/dev/mace_audio.rs +++ b/src/dev/mace_audio.rs @@ -36,7 +36,7 @@ use parking_lot::Mutex; use crate::config::AudioConfig; use crate::dev::crime_mem::O2Memory; use crate::traits::BusDevice; -use crate::host_audio::{HostOut, Meter, Resampler}; +use crate::host_audio::{HostLink, HostOut, Meter, Resampler}; /// Where the audio registers sit, and their 16 KB block. pub const MACE_AUDIO: u32 = 0x1f30_0000; @@ -271,17 +271,13 @@ pub struct AudioIo<'a> { pub ust: u32, } -/// The host side: the stream, and a resampler per output channel. -struct Player { - out: HostOut, - rs: [Option; 2], -} +/// The host side: the stream, and a resampler per output channel. It tries +/// to open once, when the first sound plays. +type Player = HostLink<[Option; 2]>; pub struct MaceAudio { st: Mutex, - player: Mutex>, - config: Mutex>, - tried_open: AtomicBool, + player: Mutex, /// What the outputs have played (silence for a dry ring included) and /// the host stream has done. meter: Arc, @@ -311,9 +307,7 @@ impl MaceAudio { pub fn new() -> Self { Self { st: Mutex::new(State::new()), - player: Mutex::new(None), - config: Mutex::new(None), - tried_open: AtomicBool::new(false), + player: Mutex::new(Player::default()), meter: Meter::new("MACE audio", &["analog in", "analog out 1", "analog out 2"]), heard: [AtomicU64::new(0), AtomicU64::new(0)], gaps: [AtomicU64::new(0), AtomicU64::new(0)], @@ -325,9 +319,9 @@ impl MaceAudio { } /// Play through the host with `audio`'s settings. Without this (tests, - /// `no_audio`) the channels still run and count, silently. + /// `[audio] output = "none"`) the channels still run and count, silently. pub fn enable_output(&self, audio: AudioConfig) { - *self.config.lock() = Some(audio); + self.player.lock().enable(audio); } pub fn contains(addr: u32) -> bool { @@ -614,21 +608,18 @@ impl MaceAudio { /// Resample each channel to the host rate, mix and push. fn play(&self, pcm: &[Vec<(i16, i16)>; 2], rates: &[u32; 2]) { let mut player = self.player.lock(); - 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") - .map(|out| Player { out, rs: [None, None] }); - } + if !player.tried() { + let open = |cfg: &AudioConfig| crate::host_audio::open_output(cfg, self.meter.clone(), "O2 audio"); + player.open(Instant::now(), false, open, |_| [None, None]); } - let Some(p) = player.as_mut() else { return }; - let host = p.out.stream_rate; + let Some((out, rs)) = player.stream.as_mut() else { return }; + let host = out.stream_rate; let mut mixed: Vec = Vec::new(); for out in 0..2 { if pcm[out].is_empty() { continue; } - let rs = p.rs[out].get_or_insert_with(|| Resampler::new(rates[out], host, 2)); + let rs = rs[out].get_or_insert_with(|| Resampler::new(rates[out], host, 2)); if rs.in_rate != rates[out] { *rs = Resampler::new(rates[out], host, 2); } @@ -648,7 +639,7 @@ impl MaceAudio { }); } } - push_mixed(&mut p.out, &mixed); + push_mixed(out, &mixed); } } @@ -772,7 +763,7 @@ impl crate::traits::Device for MaceAudio { [on(0, st.adc_rate()), on(1, st.dac_rate(1)), on(2, st.dac_rate(2))] }; let player = self.player.lock(); - Some(self.meter.report(&rates, player.as_ref().map(|p| &p.out), self.config.lock().is_some())) + Some(self.meter.report(&rates, player.out(), player.wanted())) } fn register_commands(&self) -> Vec<(String, String)> { @@ -821,7 +812,7 @@ mod tests { let out = HostOut::detached(Meter::new("test", &[]), outr, p); let meter = out.meter.clone(); let a = MaceAudio::new(); - *a.player.lock() = Some(Player { out, rs: [None, None] }); + a.player.lock().stream = Some((out, [None, None])); let (mut at0, mut at1) = (0, 0); for (i, &n) in BURSTS.iter().cycle().enumerate() { if at0 >= s0.len() && at1 >= s1.len() { @@ -1066,13 +1057,29 @@ mod tests { \x20 analog out 2: 0 frames moved, guest rate off\n\ \x20 host underruns: 0 frames\n\ \x20 dropped by the host: 0 frames\n\ - \x20 host output: none (no_audio)\n" + \x20 host output: none (output = \"none\")\n" ); use crate::traits::Device; assert!(a.execute_command("audio", &["codec"], Box::new(std::io::sink())).is_ok()); assert!(a.execute_command("audio", &["status"], Box::new(std::io::sink())).is_err(), "status is the monitor's"); } + /// An enabled card says why it has no host stream: none was asked for + /// (`output = "none"`, which never opens one) or none is open yet. + #[test] + fn audio_status_tells_output_none_from_a_stream_not_yet_open() { + use crate::traits::Device; + let a = MaceAudio::new(); + a.enable_output(AudioConfig { output: Some(crate::config::AudioOutput::None), ..Default::default() }); + a.play(&[vec![(1, 1)], vec![]], &[8000, 8000]); + assert!(a.audio_report().unwrap().ends_with("host output: none (output = \"none\")\n")); + let a = MaceAudio::new(); + a.enable_output(AudioConfig { output: Some(crate::config::AudioOutput::File("/nonexistent-dir/x.wav".into())), ..Default::default() }); + a.play(&[vec![(1, 1)], vec![]], &[8000, 8000]); + assert!(a.player.lock().tried()); + assert!(a.audio_report().unwrap().ends_with("host output: none (not open)\n"), "a file that cannot be written"); + } + /// 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 @@ -1125,7 +1132,7 @@ mod tests { /// The whole way from guest memory to the host as the O2 runs it: an /// output ring the guest keeps full, played by the machine's service /// pass on the host's clock, into the host output `enable_output` - /// opens -- an `[audio] file`, so no sound card -- drained in real time. + /// opens -- a file output, so no sound card -- drained in real time. /// At 48 kHz nothing is resampled: what the file holds, less the /// underrun silence, is the ring sample for sample with each channel on /// its own side. The DAC mute bits silence what follows. @@ -1170,7 +1177,7 @@ mod tests { let wall = started.elapsed().as_secs_f64(); let moved = a.msc_ust(1) >> 32; // Dropping the output completes the file. - a.player.lock().take(); + a.player.lock().stream.take(); let (rate, channels, pcm) = read_wav(&path.0); assert_eq!((rate, channels), (48000, 2)); // Half at least: a timer held up by a busy host holds the clock back. @@ -1248,7 +1255,7 @@ mod tests { &self.a.meter } fn finish(&self) { - self.a.player.lock().take(); + self.a.player.lock().stream.take(); } } diff --git a/src/dev/rad1.rs b/src/dev/rad1.rs index 60923c26e..46a775319 100644 --- a/src/dev/rad1.rs +++ b/src/dev/rad1.rs @@ -649,9 +649,9 @@ pub struct Inner { /// shared with other audio cards; reached through `Deref`. clock: CardClock, timers: OnceLock>, - /// `[audio]` settings once host output is enabled; `None` (tests, - /// `no_audio`) keeps the output channel running silently. - audio_cfg: Mutex>, + /// Where the output goes, once the card is enabled. Without a host + /// stream wanted (tests, `[audio] output = "none"`) the output channel + /// runs silently. sink: Mutex>, /// Opens the host stream; tests put their own ring behind it. open_host: fn(&Inner, &AudioConfig) -> Option, @@ -666,10 +666,9 @@ pub struct Inner { me: Weak, } -/// The host stream: 200 ms of stereo ring at the stream's rate. +/// The host stream. 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") + crate::host_audio::open_output(cfg, card.meter.clone(), "Octane audio") } impl Deref for Inner { @@ -694,7 +693,6 @@ impl Inner { state: Mutex::new(State::new(0, ust_free_running)), clock, timers: OnceLock::new(), - audio_cfg: Mutex::new(None), sink: Mutex::new(None), open_host: open_host_output, meter: Meter::new(if ust_free_running { "RAD1 (MediaIO)" } else { "RAD1" }, &CHAN_NAMES[..CH_AES_SUB]), @@ -716,7 +714,7 @@ impl Inner { /// host drains its 200 ms ring are dropped. A failed open is retried /// when the output starts and then once a second. pub fn enable_output(&self, audio: AudioConfig) { - *self.audio_cfg.lock() = Some(audio); + self.sink.lock().get_or_insert_with(Sink::new).host.enable(audio); let mut s = self.state.lock(); self.arm_audio(&mut s); } @@ -737,7 +735,7 @@ impl Inner { if !(s.chan[CH_OUT].running && self.armed.load(Ordering::SeqCst)) { self.meter.stopped(); } - let want = s.chan[CH_OUT].running && self.audio_cfg.lock().is_some(); + let want = s.chan[CH_OUT].running && self.sink.lock().as_ref().is_some_and(|sk| sk.host.wanted()); match (want, s.audio_timer) { (true, None) => { let Some(tm) = self.timers_live() else { return }; @@ -773,7 +771,7 @@ impl Inner { self.sync_chan(&mut s, CH_OUT, now); } if s.chan[CH_OUT].running { - if self.sink.lock().as_ref().is_some_and(|sk| sk.host_retry_due(Instant::now())) { + if self.sink.lock().as_ref().is_some_and(|sk| sk.host.retry_due(Instant::now())) { self.open_sink(false); } TimerReturn::Continue @@ -789,35 +787,15 @@ impl Inner { let _ = self.timers.set(tm); } - /// Open the output sink when host output is enabled, and retry a failed - /// host stream open so an early sound is not lost for the run. `force` - /// skips the wait between tries. + /// Open the host stream when output is enabled, and retry a failed open + /// so an early sound is not lost for the run. `force` skips the wait + /// between tries. fn open_sink(&self, force: bool) { let mut sink = self.sink.lock(); - let Some(cfg) = self.audio_cfg.lock().clone() else { - return; - }; + let Some(sk) = sink.as_mut() else { return }; let now = Instant::now(); - match sink.as_mut() { - Some(sk) if !(sk.host_retry_due(now) || force && sk.host.is_none()) => return, - _ => {} - } - let host = (self.open_host)(self, &cfg); - crate::dlog!(LogModule::Rad1, "rad1: host audio open took {:.1} ms ({})", now.elapsed().as_secs_f64() * 1e3, if host.is_some() { "open" } else { "failed" }); - let host = host.map(|out| { - let rs = Resampler::new(FREQ_ANALOG as u32, out.stream_rate, 2); - (out, rs) - }); - let failed_at = host.is_none().then_some(now); - match sink.as_mut() { - Some(sk) => { - sk.host = host; - sk.host_failed_at = failed_at; - } - None => { - let dump = crate::dev::audio_dump::AudioDump::from_env(); - *sink = Some(Sink { host, host_failed_at: failed_at, dump, tap: None }); - } + if let Some(opened) = sk.host.open(now, force, |cfg| (self.open_host)(self, cfg), Resampler::following) { + crate::dlog!(LogModule::Rad1, "rad1: host audio open took {:.1} ms ({})", now.elapsed().as_secs_f64() * 1e3, if opened { "open" } else { "failed" }); } } @@ -1454,8 +1432,8 @@ impl Device for Rad1 { [CH_IN, CH_OUT].map(|idx| s.chan[idx].running.then(|| s.rate(idx).rounded_hz())) }; let sink = i.sink.lock(); - let host = sink.as_ref().and_then(|sk| sk.host.as_ref()).map(|(out, _)| out); - Some(i.meter.report(&rates, host, i.audio_cfg.lock().is_some())) + let link = sink.as_ref().map(|sk| &sk.host); + Some(i.meter.report(&rates, link.and_then(|l| l.out()), link.is_some_and(|l| l.wanted()))) } } diff --git a/src/dev/rad1/tests.rs b/src/dev/rad1/tests.rs index 0000a6578..cc994ffba 100644 --- a/src/dev/rad1/tests.rs +++ b/src/dev/rad1/tests.rs @@ -4,8 +4,8 @@ use super::*; use crate::dev::host_pcm::test_time::{FakeNs, TestTime}; -use crate::dev::host_pcm::HOST_RETRY; use crate::host_audio::test_signal::{a_tone_survives_a_pause, tones, CardUnderTest}; +use crate::host_audio::{HostLink, HOST_RETRY}; use crate::hptimer::TimerManager; use crate::pci::PciDma; use crate::traits::Device; @@ -517,7 +517,7 @@ fn reset_returns_the_generators_to_the_default_rate() { 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 }; + let mut sink = Sink { host: HostLink::default(), 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. @@ -853,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, dump: None, tap: Some(tap.clone()) }); + *r.sink.lock() = Some(Sink { host: HostLink::default(), 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(); @@ -891,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, dump: None, tap: Some(tap.clone()) }); + *r.sink.lock() = Some(Sink { host: HostLink::default(), dump: None, tap: Some(tap.clone()) }); program(&r, &RING_INPUT, 0x11_0000, 0xffff_f000); wr(&r, 0x4c, 1); at(&r, 1_000_000); @@ -980,18 +980,19 @@ 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, 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"); - assert!(sink.host_retry_due(now + HOST_RETRY), "due again later"); + let mut sink = Sink::new(); + sink.host.enable(crate::config::AudioConfig::default()); + assert!(sink.host.retry_due(now), "never tried"); + assert_eq!(sink.host.open(now, false, |_| None, Resampler::following), Some(false)); + assert!(!sink.host.retry_due(now), "just failed"); + assert!(sink.host.retry_due(now + HOST_RETRY), "due again later"); } 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, dump: None, tap: Some(tap.clone()) }); + *r.sink.lock() = Some(Sink { host: HostLink::default(), dump: None, tap: Some(tap.clone()) }); tap } @@ -1150,6 +1151,39 @@ fn slow_host(card: &Inner, _: &crate::config::AudioConfig) -> Option { Some(HostOut::detached(card.meter.clone(), 32_000, p)) } +static REFUSED_OPENS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// A host that refuses every open, counting the tries. +fn refusing_host(_: &Inner, _: &crate::config::AudioConfig) -> Option { + REFUSED_OPENS.fetch_add(1, Ordering::SeqCst); + None +} + +/// With `[audio] output = "none"` the card is enabled but never asks the host +/// for a stream, at start or on a later try; with any other output it does, +/// and a forced try asks again. +#[test] +fn output_none_never_opens_a_host_stream() { + let card = || Rad1 { inner: Arc::new_cyclic(|me| Inner { open_host: refusing_host, ..Inner::new(me.clone(), TestTime::clock(), RAD1_REVISION) }) }; + let none = crate::config::AudioConfig { output: Some(crate::config::AudioOutput::None), ..Default::default() }; + let r = card(); + r.enable_output(none); + Device::start(&r); + r.inner.open_sink(true); + r.inner.open_sink(false); + assert_eq!(REFUSED_OPENS.load(Ordering::SeqCst), 0, "no try for a stream nobody wants"); + assert!(r.audio_report().unwrap().ends_with("host output: none (output = \"none\")\n")); + let r = card(); + r.enable_output(crate::config::AudioConfig::default()); + Device::start(&r); + assert_eq!(REFUSED_OPENS.load(Ordering::SeqCst), 1, "the start tries once"); + r.inner.open_sink(false); + assert_eq!(REFUSED_OPENS.load(Ordering::SeqCst), 1, "not again before the wait"); + r.inner.open_sink(true); + assert_eq!(REFUSED_OPENS.load(Ordering::SeqCst), 2, "a forced try skips the wait"); + assert!(r.audio_report().unwrap().ends_with("host output: none (not open)\n")); +} + /// A slow host open is over when the machine starts, before the guest /// plays: the PROM's boot tune reaches the host whole. Opened at the tune's /// start instead, the frames due by the first look overflow the 200 ms ring @@ -1469,8 +1503,8 @@ fn the_mediaio_card_plays_through_its_0x88_and_0x8c_writes() { r.state().connect_dma(ram.clone()); // A 44.1 kHz host stream, so the ring's samples arrive unresampled. let (p, mut host) = rtrb::RingBuffer::new(44_100); - let host_out = (HostOut::detached(r.meter.clone(), 44_100, p), Resampler::new(44_100, 44_100, 2)); - *r.sink.lock() = Some(Sink { host: Some(host_out), host_failed_at: None, dump: None, tap: None }); + let host_out = HostOut::detached(r.meter.clone(), 44_100, p); + *r.sink.lock() = Some(Sink { host: HostLink::with_stream(host_out, Resampler::new(44_100, 44_100, 2)), 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)]; @@ -1487,7 +1521,7 @@ fn the_mediaio_card_plays_through_its_0x88_and_0x8c_writes() { /// The whole way from guest memory to the host as a running machine takes /// it: the card on the host's clock with the machine's timers, its output /// pump moving the ring with no register access, the host output opened by -/// `enable_output` -- an `[audio] file`, so no sound card -- and resampled +/// `enable_output` -- a file output, so no sound card -- and resampled /// from the card's 44.1 kHz to the file's 48 kHz in real time. The file /// holds the guest's two tones on their own channels at full level; the /// DAC mute word silences what follows while the transfer runs on. @@ -1680,7 +1714,7 @@ fn audio_status_counts_output_frames_across_transfers() { \x20 analog out: 441 frames moved, guest rate 44100 Hz\n\ \x20 host underruns: 0 frames\n\ \x20 dropped by the host: 0 frames\n\ - \x20 host output: none (no_audio)\n" + \x20 host output: none (output = \"none\")\n" ); at(&r, 20_000_000); assert_eq!(r.audio_report().unwrap(), text, "a query does not advance the card"); @@ -1699,8 +1733,10 @@ 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, 2))), host_failed_at: None, dump: None, tap: None }); - *r.inner.audio_cfg.lock() = Some(AudioConfig::default()); + let mut sink = Sink::new(); + sink.host.enable(AudioConfig::default()); + sink.host.stream = Some((out, Resampler::new(44100, 48000, 2))); + *r.inner.sink.lock() = Some(sink); let text = r.audio_report().unwrap(); assert!(text.starts_with("audio: RAD1 (MediaIO)\n"), "{text}"); assert!(text.contains("host output: test at 48000 Hz, 2 channels"), "{text}"); @@ -1714,7 +1750,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, 2))), host_failed_at: None, dump: None, tap: None }); + *r.sink.lock() = Some(Sink { host: HostLink::with_stream(out, Resampler::new(44100, 44100, 2)), 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 1bae0e880..e06bc62b5 100644 --- a/src/host_audio.rs +++ b/src/host_audio.rs @@ -7,7 +7,7 @@ use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::{Duration, Instant}; -use crate::config::{AudioConfig, AudioHost}; +use crate::config::{AudioConfig, AudioOutput}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use rtrb::{Consumer, Producer, RingBuffer}; @@ -17,6 +17,17 @@ const PREFERRED_RATES: &[u32] = &[48000, 44100, 22050]; /// cpal_buffer_frames` says otherwise (see `output_buffer`). #[cfg(target_os = "linux")] const PULSE_FRAMES: u32 = 512; +/// What every card's host ring holds, in milliseconds of the stream's rate. +/// The ring only caps the audio queued ahead of the host, so its size +/// matters only after a host stall at least this long. +pub const HOST_RING_MS: usize = 200; +/// How long to wait before opening again a host stream that failed. +pub const HOST_RETRY: Duration = Duration::from_secs(1); + +/// The ring's size for a stereo stream at `rate`, in samples. +fn ring_samples(rate: u32) -> usize { + rate as usize * 2 * HOST_RING_MS / 1000 +} /// What a card's output has done, counted the same way for every card from /// the start of the machine, with or without a host stream open: frames each @@ -57,8 +68,8 @@ impl Meter { /// What `audio status` prints: the counts, the guest's rate for each /// output (`rates`, `None` while off) and where the output goes. `out` is - /// the open host stream, `wanted` whether host output is on (`no_audio` - /// is not set). + /// the open host stream, `wanted` whether host output is on (`[audio] + /// output` is not `"none"`). pub fn report(&self, rates: &[Option], out: Option<&HostOut>, wanted: bool) -> String { use std::fmt::Write; let frames = |n: u64| format!("{n} frame{}", if n == 1 { "" } else { "s" }); @@ -71,7 +82,7 @@ impl Meter { match (out, wanted) { (Some(o), _) => writeln!(s, " host output: {} at {} Hz, {} channels", o.target, o.stream_rate, o.channels), (None, true) => writeln!(s, " host output: none (not open)"), - (None, false) => writeln!(s, " host output: none (no_audio)"), + (None, false) => writeln!(s, " host output: none (output = \"none\")"), } .unwrap(); s @@ -89,9 +100,9 @@ pub struct HostOut { /// The card's counters: the stream counts underruns, [`HostOut::push`] /// refusals. pub meter: Arc, - /// What plays the ring: the host device and API, or the `[audio] file`. + /// What plays the ring: the host device and API, or the file output. pub target: String, - // What plays the ring -- a host stream or an `[audio] file` -- dropped + // What plays the ring -- a host stream or a file output -- dropped // with the HostOut. Neither for a detached one. _stream: Option, _file: Option, @@ -206,6 +217,91 @@ impl Resampler { } } +impl Resampler { + /// A resampler for `out`'s stream that changes nothing until the card + /// plays at a rate of its own: the card rebuilds it when its rate differs. + pub fn following(out: &HostOut) -> Self { + Self::new(out.stream_rate, out.stream_rate, out.channels) + } +} + +/// One card's link to its host stream: the `[audio]` settings once the +/// card is enabled, the open stream with whatever the card resamples it +/// with (`R`), and when a failed open may be tried again. Cards own the +/// link under the lock that guards their sink, and hold no copy of the +/// settings, so with `output = "none"` the link never opens a stream and +/// never retries one. +pub struct HostLink { + audio: Option, + /// The open stream and its resampler; for a card to play through. + pub stream: Option<(HostOut, R)>, + failed_at: Option, +} + +impl Default for HostLink { + fn default() -> Self { + Self { audio: None, stream: None, failed_at: None } + } +} + +impl HostLink { + /// A link already holding `stream`, for tests that supply their own. + #[cfg(test)] + pub fn with_stream(out: HostOut, rs: R) -> Self { + Self { stream: Some((out, rs)), ..Self::default() } + } + + /// Play through the host with `audio`'s settings. Without this (tests) + /// the card runs and counts, and nothing is opened. + pub fn enable(&mut self, audio: AudioConfig) { + self.audio = Some(audio); + } + + /// Whether a host stream is wanted: the card is enabled and `[audio] + /// output` is not `"none"`. + pub fn wanted(&self) -> bool { + self.audio.as_ref().is_some_and(AudioConfig::plays) + } + + /// The open stream, for the card's report. + pub fn out(&self) -> Option<&HostOut> { + self.stream.as_ref().map(|(out, _)| out) + } + + /// Whether a stream was wanted and failed to open, or is open: a card + /// that tries once asks this before calling [`HostLink::open`]. + pub fn tried(&self) -> bool { + self.stream.is_some() || self.failed_at.is_some() + } + + /// Whether a wanted stream that is not open is due another try. + pub fn retry_due(&self, now: Instant) -> bool { + self.wanted() && self.stream.is_none() && self.failed_at.is_none_or(|t| now.duration_since(t) >= HOST_RETRY) + } + + /// Open the stream with `open` unless one is open, none is wanted, or a + /// recent try failed (`force` skips that wait). `resampler` builds what + /// the card resamples the new stream with. Returns whether it opened, or + /// `None` when it did not try. + pub fn open( + &mut self, + now: Instant, + force: bool, + open: impl FnOnce(&AudioConfig) -> Option, + resampler: impl FnOnce(&HostOut) -> R, + ) -> Option { + if !(self.retry_due(now) || force && self.wanted() && self.stream.is_none()) { + return None; + } + self.stream = open(self.audio.as_ref()?).map(|out| { + let rs = resampler(&out); + (out, rs) + }); + self.failed_at = self.stream.is_none().then_some(now); + Some(self.stream.is_some()) + } +} + /// Push a whole frame, or none of it if the ring lacks room, and publish it /// with one commit so the consumer never sees part of a frame. Returns /// whether it fit. @@ -262,16 +358,16 @@ fn catmull_rom(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 { } -/// The host `[audio] host` asks for. cpal's `pulseaudio` feature makes its +/// The host `[audio] output` asks for. cpal's `pulseaudio` feature makes its /// own `default_host()` prefer PulseAudio whenever a server runs, so the /// default is chosen here instead: the platform's usual host (ALSA on Linux). -fn output_host(want: AudioHost) -> cpal::Host { +fn output_host(want: &AudioOutput) -> cpal::Host { #[cfg(target_os = "linux")] { - if want == AudioHost::Pulseaudio { + if *want == AudioOutput::Pulseaudio { match cpal::host_from_id(cpal::HostId::PulseAudio) { Ok(h) => return h, - Err(e) => eprintln!("audio: [audio] host = \"pulseaudio\", but no PulseAudio server answers ({e}); using ALSA"), + Err(e) => eprintln!("audio: [audio] output = \"pulseaudio\", but no PulseAudio server answers ({e}); using ALSA"), } } if let Ok(h) = cpal::host_from_id(cpal::HostId::Alsa) { @@ -279,8 +375,8 @@ fn output_host(want: AudioHost) -> cpal::Host { } } #[cfg(not(target_os = "linux"))] - if want != AudioHost::Default { - eprintln!("audio: [audio] host = {want:?} is for Linux; using this platform's default"); + if *want != AudioOutput::Default { + eprintln!("audio: [audio] output = {want:?} is for Linux; using this platform's default"); } cpal::default_host() } @@ -299,15 +395,10 @@ 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 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, -) -> Option { - open_output_channels(audio, 2, meter, ring_samples, who) +/// order, with a ring of [`HOST_RING_MS`]. `who` prefixes the messages. The +/// stream plays silence when the ring buffer is empty. +pub fn open_output(audio: &AudioConfig, meter: Arc, who: &str) -> Option { + open_output_channels(audio, 2, meter, who) } /// The standard speaker counts a stream is opened with: 7.1, 5.1, quad and @@ -335,21 +426,20 @@ pub(crate) fn count_underrun(missed: usize, meter: &Meter) { /// `max_channels` (2 to 8). Of the layouts 7.1, 5.1, quad and stereo the /// largest the device reports and `max_channels` allow is tried first, then /// the smaller ones. The ring and the stream carry interleaved frames of -/// [`HostOut::channels`] samples; `ring_samples` is the ring's size as for a -/// stereo stream, and a wider one gets proportionally more. `[audio] file` -/// is always a stereo file, whatever the card asks for: [`HostOut::channels`] -/// says so and the card mixes down to it. +/// [`HostOut::channels`] samples; a wider stream's ring is proportionally +/// larger. A file output is always a stereo file, whatever the card asks for: +/// [`HostOut::channels`] says so and the card mixes down to it. pub fn open_output_channels( audio: &AudioConfig, max_channels: usize, meter: Arc, - ring_samples: impl Fn(u32) -> usize, who: &str, ) -> Option { - if let Some(path) = &audio.file { - return open_file_output(path, meter, ring_samples, who); - } - let host = output_host(audio.host); + let host = match audio.output() { + AudioOutput::None => return None, + AudioOutput::File(path) => return open_file_output(path, meter, who), + want => output_host(want), + }; let device = host.default_output_device()?; let device_max = if max_channels > 2 { device @@ -508,13 +598,13 @@ impl Drop for FilePlayer { } } -/// `[audio] file`: a stereo output at [`FILE_RATE`] that a thread plays -/// into the WAV at `path` in real time. Lets a machine with no sound card, -/// or a test, hear exactly what the host stream would have played. +/// A file output (`[audio] output = { file = ... }`): a stereo output at +/// [`FILE_RATE`] that a thread plays into the WAV at `path` in real time. Lets +/// a machine with no sound card, or a test, hear exactly what the host stream +/// would have played. fn open_file_output( path: &Path, meter: Arc, - ring_samples: impl Fn(u32) -> usize, who: &str, ) -> Option { const CHANNELS: usize = 2; @@ -572,7 +662,7 @@ fn open_file_output( #[cfg(test)] pub(crate) mod test_signal { use std::path::{Path, PathBuf}; - use crate::config::AudioConfig; + use crate::config::{AudioConfig, AudioOutput}; /// A test's WAV file, removed when the test ends, passed or not. pub(crate) struct TempWav(pub(crate) PathBuf); @@ -587,11 +677,11 @@ pub(crate) mod test_signal { /// the file. pub(crate) fn file_config(name: &str) -> (AudioConfig, TempWav) { let path = std::env::temp_dir().join(format!("iris-{name}-{}.wav", std::process::id())); - (AudioConfig { file: Some(path.clone()), ..AudioConfig::default() }, TempWav(path)) + (AudioConfig { output: Some(AudioOutput::File(path.clone())), ..AudioConfig::default() }, TempWav(path)) } /// The variable naming the loopback a real-device test records from: - /// `:`. The host is the `[audio] host` the + /// `:`. The host is the `[audio] output` the /// card plays through (`alsa`, `pulseaudio` or `default`); the source is /// the PulseAudio source that hears the default output, such as a null /// sink's monitor: `alsa:iris_sink.monitor`. @@ -599,7 +689,7 @@ pub(crate) mod test_signal { /// Where a test hears what a card sent to the host. pub(crate) enum Backend { - /// An `[audio] file` of the test's own. Needs no sound card. + /// A file output of the test's own. Needs no sound card. File, /// The host's real default output, recorded back through the /// loopback [`LOOPBACK_VAR`] names: cpal, the operating system's @@ -653,20 +743,17 @@ pub(crate) mod test_signal { #[cfg(any(target_os = "linux", windows, target_os = "macos"))] fn loopback(spec: &str) -> Self { - use crate::config::AudioHost; static ONE_AT_A_TIME: std::sync::Mutex<()> = std::sync::Mutex::new(()); let serial = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); let (host, source) = spec .split_once(':') .unwrap_or_else(|| panic!("{LOOPBACK_VAR}={spec}: want :")); - let host = match host { - "default" => AudioHost::Default, - "alsa" => AudioHost::Alsa, - "pulseaudio" => AudioHost::Pulseaudio, + let host = match host.parse() { + Ok(h @ (AudioOutput::Default | AudioOutput::Alsa | AudioOutput::Pulseaudio)) => h, _ => panic!("{LOOPBACK_VAR}={spec}: the output host is default, alsa or pulseaudio"), }; let rec = loopback::Recorder::open(source).unwrap_or_else(|e| panic!("{LOOPBACK_VAR}={spec}: {e}")); - Self { cfg: AudioConfig { host, ..AudioConfig::default() }, ear: Ear::Loopback(rec, serial) } + Self { cfg: AudioConfig { output: Some(host), ..AudioConfig::default() }, ear: Ear::Loopback(rec, serial) } } #[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] @@ -1009,7 +1096,7 @@ pub(crate) mod test_signal { /// stream runs dry, then playing again. Both sides of the gap must be the /// tone with each channel on its own side, as long as the card moved; /// the gap counts as underruns if the pause is a stall and not if the - /// output is stopped. On an `[audio] file` this runs everywhere; on the + /// output is stopped. On a file output this runs everywhere; on the /// real device it checks cpal and the host's sound server too. pub(crate) fn a_tone_survives_a_pause(backend: Backend) { let exact = matches!(backend, Backend::File); @@ -1320,7 +1407,38 @@ mod tests { \x20 host output: test at 48000 Hz, 2 channels\n" ); assert!(meter.report(&[None, None], None, true).ends_with("host output: none (not open)\n")); - assert!(meter.report(&[None, None], None, false).ends_with("host output: none (no_audio)\n")); + assert!(meter.report(&[None, None], None, false).ends_with("host output: none (output = \"none\")\n")); + } + + /// A link opens a wanted stream once, tries again only after the wait (or + /// when forced), and for `output = "none"` or an unenabled card never + /// calls the opener. + #[test] + fn a_link_opens_a_wanted_stream_and_retries_after_a_wait() { + let now = Instant::now(); + let open_with = |link: &mut HostLink, at: Instant, force: bool, ok: bool| { + let mut calls = 0; + let r = link.open(at, force, |_| { calls += 1; ok.then(|| HostOut::detached(playing_meter(), 48000, RingBuffer::new(8).0)) }, Resampler::following); + (r, calls) + }; + let mut link = HostLink::default(); + assert!(!link.wanted() && !link.retry_due(now)); + assert_eq!(open_with(&mut link, now, true, true), (None, 0), "not enabled"); + link.enable(AudioConfig { output: Some(AudioOutput::None), ..AudioConfig::default() }); + assert!(!link.wanted() && !link.retry_due(now)); + assert_eq!(open_with(&mut link, now, true, true), (None, 0), "output none, even forced"); + link.enable(AudioConfig::default()); + assert!(link.wanted() && link.retry_due(now) && !link.tried(), "never tried"); + assert_eq!(open_with(&mut link, now, false, false), (Some(false), 1)); + assert!(link.tried() && link.out().is_none()); + assert!(!link.retry_due(now), "just failed"); + assert_eq!(open_with(&mut link, now, false, true), (None, 0), "not before the wait"); + assert!(link.retry_due(now + HOST_RETRY), "due again later"); + assert_eq!(open_with(&mut link, now, true, true), (Some(true), 1), "forced, inside the wait"); + assert!(link.out().is_some() && !link.retry_due(now + 10 * HOST_RETRY), "open: nothing to retry"); + assert_eq!(open_with(&mut link, now + HOST_RETRY, true, true), (None, 0), "never reopens an open stream"); + let (out, rs) = link.stream.as_ref().unwrap(); + assert_eq!((rs.in_rate, rs.out_rate, out.channels), (48000, 48000, 2), "the resampler follows the stream until the card plays"); } /// Silence from the host while no output runs is not an underrun. @@ -1500,7 +1618,7 @@ mod tests { assert_eq!(out, [1, 2, 3, 4]); } - /// `[audio] file` plays the ring in real time: what was pushed comes out + /// A file output plays the ring in real time: what was pushed comes out /// in the WAV in order, sample for sample and channel for channel, the /// file grows with the host's clock whether or not anything is pushed, /// and the silence after the end counts as underruns. @@ -1509,11 +1627,12 @@ 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") + let mut out = open_output(&cfg, meter.clone(), "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. - let sent = tones(FILE_RATE as usize / 4, FILE_RATE, 440.0, 880.0, 12000.0); + // A tenth of a second of tone, as a device would push it: the ring holds + // 200 ms. + let sent = tones(FILE_RATE as usize / 10, FILE_RATE, 440.0, 880.0, 12000.0); for f in sent.chunks_exact(2) { assert!(push_frame(&mut out.producer, f)); } @@ -1537,8 +1656,8 @@ mod tests { /// refuses does. #[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").is_none()); + let cfg = AudioConfig { output: Some(AudioOutput::File("/nonexistent-dir/x.wav".into())), ..AudioConfig::default() }; + assert!(open_output(&cfg, playing_meter(), "test").is_none()); } /// A card that asks for eight channels still gets a stereo file, and @@ -1546,7 +1665,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") + let out = open_output_channels(&cfg, 8, playing_meter(), "test") .expect("a file output opens without a sound card"); assert_eq!(out.channels, 2); drop(out); @@ -1580,7 +1699,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").expect("the output opens"); + let out = open_output(cfg, meter.clone(), "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/src/ip27_deskside.rs b/src/ip27_deskside.rs index 32a016c9e..b3c543599 100644 --- a/src/ip27_deskside.rs +++ b/src/ip27_deskside.rs @@ -295,9 +295,7 @@ impl Deskside { io.monitor.push(mio.ps2()); let rad = Arc::new(Rad1::mediaio()); rad.set_timer_manager(node.timers.clone()); - if !cfg.no_audio { - rad.enable_output(cfg.audio.clone()); - } + rad.enable_output(cfg.audio.clone()); pci.attach(DevFn { device: media.rad1_device, function: 0 }, rad.clone()); rad.register_locks(); io.devices.push(rad.clone()); diff --git a/src/ip30_machine.rs b/src/ip30_machine.rs index a1be720e4..99b2cc04a 100644 --- a/src/ip30_machine.rs +++ b/src/ip30_machine.rs @@ -431,9 +431,7 @@ impl OctaneMachine { })); let rad = Arc::new(Rad1::new()); rad.set_timer_manager(timer_manager.clone()); - if !cfg.no_audio { - rad.enable_output(cfg.audio.clone()); - } + rad.enable_output(cfg.audio.clone()); pci.attach(slot(3), rad.clone()); let virtio = cfg.virtio_serial.enabled.then(|| { let dev = Arc::new(crate::dev::virtio::pci::VirtioPci::new( @@ -1684,15 +1682,15 @@ mod snapshot_tests { /// The monitor answers `audio status` on the Octane for RAD1, which the /// machine registers with it, and says there is no host output with - /// `no_audio` set. + /// `[audio] output = "none"`. #[test] fn the_monitor_answers_audio_status_with_the_rad1_card() { std::thread::Builder::new().stack_size(64 << 20).spawn(|| { let dir = scratch("audio-cmd"); let prom = empty_prom(&dir); - let m = machine_with(&prom, &dir, |cfg| cfg.no_audio = true); + let m = machine_with(&prom, &dir, |cfg| cfg.audio.output = Some(crate::config::AudioOutput::None)); let text = m.monitor.audio_status(); - assert!(text.starts_with("audio: RAD1\n") && text.ends_with("host output: none (no_audio)\n"), "{text}"); + assert!(text.starts_with("audio: RAD1\n") && text.ends_with("host output: none (output = \"none\")\n"), "{text}"); }).unwrap().join().unwrap(); } diff --git a/src/ip32_machine.rs b/src/ip32_machine.rs index 89a370b31..6266248e3 100644 --- a/src/ip32_machine.rs +++ b/src/ip32_machine.rs @@ -206,10 +206,7 @@ impl O2Machine { rtc_offset: cfg.rtc_offset, }; let bus = Ip32Bus::with_options(mem, prom, opts); - // MACE audio plays through the host unless audio is off. - if !cfg.no_audio { - bus.mace.audio.enable_output(cfg.audio.clone()); - } + bus.mace.audio.enable_output(cfg.audio.clone()); if let Some(d) = attached.daynaports.first() { return Err(format!("{}: kind = \"daynaport\" is not available on the O2", crate::config::scsi_label(d.controller, d.id))); } diff --git a/src/ip53_machine.rs b/src/ip53_machine.rs index ec21d96ff..acc3b70d9 100644 --- a/src/ip53_machine.rs +++ b/src/ip53_machine.rs @@ -50,7 +50,7 @@ //! INTA is what an Origin 350 under OpenBSD reports (established as //! software expectation); the board wiring itself, and INTB and INTD, are //! unknown. -//! - The audio controller, off unless `[tezro] audio` adds it, is the +//! - The audio controller, off unless `[machine] audio = true` adds it, is the //! ICE1724 with the DM8's converters and configuration EEPROM, at device 2 //! of the TI bridge's secondary bus ([`AUDIO_DEVICE`]). Where it sits is //! inferred by exclusion from the IRIX hardware listings of Tezro towers, @@ -545,11 +545,7 @@ impl TezroMachine { v12: cfg.graphics_board() == GraphicsBoard::V12, graphics_pool: (cfg.graphics.threads, cfg.graphics.tile), graphics_raster: (cfg.graphics.raster, cfg.graphics.gpu_backend.as_str()), - audio: match (cfg.tezro.audio, cfg.no_audio) { - (false, _) => TezroAudio::Absent, - (true, true) => TezroAudio::Silent, - (true, false) => TezroAudio::Host(cfg.audio.clone()), - }, + audio: cfg.audio_card(), })?); let timers = Arc::new(TimerManager::new()); board.ioc4.set_timer_manager(timers.clone()); @@ -957,25 +953,9 @@ pub(crate) struct TezroBoardConfig<'a> { pub graphics_pool: (usize, u32), /// Which unit draws and, for the GPU, the wgpu backend (`[graphics]`). pub graphics_raster: (crate::config::RasterUnit, &'a str), - /// The audio controller behind the TI bridge. - pub audio: TezroAudio, -} - -/// Said at startup and by `--validate` when the audio card is added with -/// `no_audio` set: the guest sees the card and nothing is played. -pub(crate) const AUDIO_WITH_NO_AUDIO: &str = - "[tezro] audio is set but no_audio is too: the audio card is added and plays to nowhere"; - -/// Whether the audio controller is present, and whether it plays to the host. -#[derive(Default)] -pub(crate) enum TezroAudio { - /// Absent: the bridge's secondary bus is empty. - #[default] - Absent, - /// Present, with no host output. - Silent, - /// Present, playing to the host as `[audio]` says. - Host(AudioConfig), + /// The audio controller behind the TI bridge and how it plays, or `None` + /// for an empty secondary bus. + pub audio: Option, } /// The Tezro's devices around its Bedrock node. @@ -1067,7 +1047,7 @@ impl TezroBoard { v12: false, graphics_pool: (2, 32), graphics_raster: (crate::config::RasterUnit::Cpu, "auto"), - audio: TezroAudio::Absent, + audio: None, }) } @@ -1159,19 +1139,12 @@ impl TezroBoard { TI_BRIDGE_REVISION, ); io9.attach(0, TI_BRIDGE_DEVICE, ti.clone(), Function::default()); - let audio = match audio { - TezroAudio::Absent => None, - on => { - let card = Arc::new(Ice1724::new(Variant::Dm8)); - if let TezroAudio::Host(host) = on { - card.enable_output(host); - } else { - eprintln!("iris: {AUDIO_WITH_NO_AUDIO}"); - } - ti.secondary().attach(DevFn { device: AUDIO_DEVICE, function: 0 }, card.clone()); - Some(card) - } - }; + let audio = audio.map(|audio| { + let card = Arc::new(Ice1724::new(Variant::Dm8)); + card.enable_output(audio); + ti.secondary().attach(DevFn { device: AUDIO_DEVICE, function: 0 }, card.clone()); + card + }); io9.attach(0, SCSI_DEVICE, scsi.clone(), Function::default()); io9.attach(0, ETH_DEVICE, eth.clone(), Function { self_ordered_pio: false, req64: true }); Ok(Self { diff --git a/src/ip53_machine/tests.rs b/src/ip53_machine/tests.rs index 397d730e5..fea48ef73 100644 --- a/src/ip53_machine/tests.rs +++ b/src/ip53_machine/tests.rs @@ -173,7 +173,7 @@ fn the_system_serial_moves_and_the_board_serial_stays() { v12: false, graphics_pool: (2, 32), graphics_raster: (crate::config::RasterUnit::Cpu, "auto"), - audio: TezroAudio::Absent, + audio: None, }) .unwrap() }; @@ -206,7 +206,7 @@ fn board_with_chip(cpus: usize, chip: &'static TezroChip) -> TezroBoard { v12: false, graphics_pool: (2, 32), graphics_raster: (crate::config::RasterUnit::Cpu, "auto"), - audio: TezroAudio::Absent, + audio: None, }) .unwrap() } @@ -263,7 +263,7 @@ fn the_io9_has_a_ti_bridge_with_an_empty_bus_at_device_2() { } } -/// With `[tezro] audio`, the ICE1724 answers at device 2 of the TI bridge's +/// With `[machine] audio = true`, the ICE1724 answers at device 2 of the TI bridge's /// secondary bus and nowhere else on it, and its reset state is the DM8 /// board's; without it the bus stays empty. #[test] @@ -291,7 +291,7 @@ fn the_audio_controller_is_the_only_function_behind_the_bridge_when_audio_is_on( assert!(io9.config_write(DevFn { device: TI_BRIDGE_DEVICE, function: 0 }, 0x18, 4, 0x0001_0100)); io9.find_bus(1).expect("the bridge's secondary bus") }; - let silent = with_audio(TezroAudio::Silent); + let silent = with_audio(Some(AudioConfig::default())); assert!(silent.audio.is_some()); let bus = behind(&silent); for device in 0..32 { @@ -302,7 +302,7 @@ fn the_audio_controller_is_the_only_function_behind_the_bridge_when_audio_is_on( assert_eq!(bus.config_read(at, 8, 4), Some(0x0401_0001), "an audio device, revision 1"); assert_eq!(bus.config_read(at, 0x2c, 4), Some(0x3630_1412), "the DM8 board's subsystem pair"); assert_eq!(bus.config_read(at, 0x3c, 4).map(|v| v >> 8 & 0xff), Some(1), "INTA"); - let absent = with_audio(TezroAudio::Absent); + let absent = with_audio(None); assert!(absent.audio.is_none()); assert_eq!(behind(&absent).config_read(at, 0, 4), Some(u32::MAX)); // The monitor has an audio report with the card, and only then. @@ -462,7 +462,7 @@ fn board_with_clock(cpus: usize) -> (TezroBoard, Arc) { v12: false, graphics_pool: (2, 32), graphics_raster: (crate::config::RasterUnit::Cpu, "auto"), - audio: TezroAudio::Absent, + audio: None, }) .unwrap(); (board, now) @@ -616,7 +616,7 @@ fn the_board_refuses_a_population_it_cannot_fit() { v12: false, graphics_pool: (2, 32), graphics_raster: (crate::config::RasterUnit::Cpu, "auto"), - audio: TezroAudio::Absent, + audio: None, }); assert!(refused.err().is_some_and(|e| e.contains("fitted with 2, 4 processors only"))); } @@ -637,7 +637,7 @@ fn board_with(chip: &Part) -> TezroBoard { v12: false, graphics_pool: (2, 32), graphics_raster: (crate::config::RasterUnit::Cpu, "auto"), - audio: TezroAudio::Absent, + audio: None, }) .unwrap() } @@ -819,7 +819,7 @@ fn a_store_through_the_ioc4_nvram_window_reaches_the_nvram_file() { v12: false, graphics_pool: (2, 32), graphics_raster: (crate::config::RasterUnit::Cpu, "auto"), - audio: TezroAudio::Absent, + audio: None, }) .unwrap(); // IOC4's BAR at PCI memory 0x200000, as the PROM maps it. @@ -869,7 +869,7 @@ fn board_with_v12(cpus: usize) -> TezroBoard { v12: true, graphics_pool: (2, 32), graphics_raster: (crate::config::RasterUnit::Cpu, "auto"), - audio: TezroAudio::Absent, + audio: None, }) .unwrap() } diff --git a/src/iris_ci_boot.rs b/src/iris_ci_boot.rs index c3c8400d5..672962c48 100644 --- a/src/iris_ci_boot.rs +++ b/src/iris_ci_boot.rs @@ -190,11 +190,16 @@ fn config(disk: &Path, socket: &str, machine: &str) -> String { scsi1.insert("overlay".into(), true.into()); let mut scsi = toml::Table::new(); scsi.insert("1".into(), scsi1.into()); + // Every card stays on the machine and plays nowhere. Leaving HAL2 out of an + // Indy or Indigo2 would make the guest kernel's HAL2 driver find no device, + // and IRIX would rebuild its kernel on every boot. let mut m = toml::Table::new(); m.insert("profile".into(), machine.into()); let mut t = toml::Table::new(); + let mut audio = toml::Table::new(); + audio.insert("output".into(), "none".into()); + t.insert("audio".into(), audio.into()); t.insert("machine".into(), m.into()); - t.insert("no_audio".into(), true.into()); t.insert("nvram".into(), "nvram.bin".into()); t.insert("serial_log".into(), "serial.log".into()); t.insert("serial_log_overwrite".into(), true.into()); @@ -643,6 +648,11 @@ mod tests { assert_eq!(t["ci_socket"].as_str(), Some("127.0.0.1:19851")); assert_eq!(t["machine"]["profile"].as_str(), Some("indy_ip24")); assert!(!t.contains_key("prom")); + // HAL2 stays on the Indy so the guest kernel sees its sound device; the + // sound goes nowhere, so the runner opens no host stream. + assert_eq!(t["audio"]["output"].as_str(), Some("none")); + assert!(!t["machine"].as_table().unwrap().contains_key("audio")); + assert!(!t.contains_key("no_audio")); } #[test] @@ -656,6 +666,9 @@ mod tests { let t: toml::Table = toml::from_str(&config(Path::new("d.chd"), "s", "o2_ip32")).unwrap(); assert_eq!(t["machine"]["profile"].as_str(), Some("o2_ip32")); assert!(!t.contains_key("prom")); + // The O2's card is part of the board: it stays and plays nowhere. + assert_eq!(t["audio"]["output"].as_str(), Some("none")); + assert!(!t["machine"].as_table().unwrap().contains_key("audio")); } /// The built-in file: the same seeded variables as before, a serial and a diff --git a/src/machine.rs b/src/machine.rs index 0c48768c0..1a52f846b 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -505,7 +505,7 @@ impl Machine { std::process::exit(1); } }; - let hpc3 = Hpc3::with_net(eeprom_hpc3.clone(), ioc.clone(), guinness, heartbeat.clone(), cfg.network(), cfg.no_audio, cfg.audio.clone(), cfg.nvram.clone(), cfg.rtc_offset, cfg.scsi_deferred_int, scsi_buses); + let hpc3 = Hpc3::with_net(eeprom_hpc3.clone(), ioc.clone(), guinness, heartbeat.clone(), cfg.network(), cfg.audio_card(), cfg.nvram.clone(), cfg.rtc_offset, cfg.scsi_deferred_int, scsi_buses); hpc3.set_timer_manager(timer_manager.clone()); // Backdoor-inject the configured (or default) Ethernet station diff --git a/src/validate_config.rs b/src/validate_config.rs index 992d5a299..7f679dfe3 100644 --- a/src/validate_config.rs +++ b/src/validate_config.rs @@ -39,16 +39,6 @@ fn missing_parent(what: &str, path: &str, problems: &mut Vec) { } } -/// What the machine would warn about and carry on: the Tezro's audio card added -/// with `no_audio` set. -pub fn warnings(cfg: &MachineConfig) -> Vec { - let mut warnings = Vec::new(); - if cfg.machine.profile == MachineProfile::TezroIp53 && cfg.tezro.audio && cfg.no_audio { - warnings.push(crate::ip53_machine::AUDIO_WITH_NO_AUDIO.to_string()); - } - warnings -} - /// Everything wrong with `cfg`, one message each; empty when it would run. pub fn problems(cfg: &MachineConfig) -> Vec { let mut problems = Vec::new(); @@ -127,9 +117,6 @@ pub fn problems(cfg: &MachineConfig) -> Vec { /// Run the check for `iris --validate`: print the problems, or that the /// configuration is fine. Returns the process exit status. pub fn report(path: &str, cfg: &MachineConfig) -> i32 { - for w in warnings(cfg) { - eprintln!("Warning: {w}"); - } let problems = problems(cfg); if problems.is_empty() { println!("iris: {path}: ok ({})", cfg.machine.profile.label()); @@ -253,20 +240,6 @@ mod tests { assert!(problems(&cfg).is_empty(), "{:?}", problems(&cfg)); } - /// The Tezro's audio card with `no_audio` set is present and silent; that - /// is worth saying, and not an error. - #[test] - fn the_tezro_audio_card_with_no_audio_is_a_warning() { - let mut cfg = MachineConfig::default(); - cfg.machine.profile = MachineProfile::TezroIp53; - cfg.tezro.audio = true; - assert!(warnings(&cfg).is_empty()); - cfg.no_audio = true; - assert!(warnings(&cfg).iter().any(|w| w.contains("plays to nowhere")), "{:?}", warnings(&cfg)); - cfg.tezro.audio = false; - assert!(warnings(&cfg).is_empty()); - } - /// A scratch volume only needs its directory and an empty CD-ROM tray is /// fine; a PROM that is named and missing is a problem on an Octane. #[test] diff --git a/test/audiotest/check.py b/test/audiotest/check.py index 91a88c6ae..7d246dcd3 100755 --- a/test/audiotest/check.py +++ b/test/audiotest/check.py @@ -11,7 +11,7 @@ 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 +A WAV, such as `[audio] output = { 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. diff --git a/tests/common/cputest.rs b/tests/common/cputest.rs index 684bc73fe..9ba1fcb09 100644 --- a/tests/common/cputest.rs +++ b/tests/common/cputest.rs @@ -222,7 +222,7 @@ pub fn run_guest(cpu: &str, cache: &str, elf: &Path) -> Result .arg("--load-elf").arg(elf) .arg("--test-device") .arg("--test-device-dump").arg(dir.join("dump.json")) - .args(["--headless", "--noaudio", "--cpu", cpu, "--cache", cache]); + .args(["--headless", "--audio", "false", "--cpu", cpu, "--cache", cache]); let out = run(cmd, timeout(TIMEOUT_S), &log)?; Ok(GuestRun { tap: parse_tap(&out.stdout), crash: out.crash(), stdout: out.stdout, log }) } diff --git a/tests/monitor_ready.rs b/tests/monitor_ready.rs index e51a46284..f0e2bc506 100644 --- a/tests/monitor_ready.rs +++ b/tests/monitor_ready.rs @@ -59,7 +59,6 @@ fn temp_dir(name: &str) -> PathBuf { fn write_config(dir: &Path, monitor: u16, serial_a: u16, serial_b: u16, ci_socket: &Path) { let text = format!( "headless = true\n\ - no_audio = true\n\ monitor_port = {monitor}\n\ serial_port_a = {serial_a}\n\ serial_port_b = {serial_b}\n\ @@ -67,7 +66,11 @@ fn write_config(dir: &Path, monitor: u16, serial_a: u16, serial_b: u16, ci_socke \n\ # No disk: the embedded PROM is enough for this test, and the default\n\ # scsi1.raw would otherwise abort startup when it is absent.\n\ - scsi = {{}}\n", + scsi = {{}}\n\ + \n\ + # Keep the card the PROM probes; send its sound nowhere.\n\ + [audio]\n\ + output = \"none\"\n", ci_socket.display() ); std::fs::write(dir.join("iris.toml"), text).expect("write test config"); diff --git a/tests/snapshot_schemas.rs b/tests/snapshot_schemas.rs index d9080c25b..5e14cb244 100644 --- a/tests/snapshot_schemas.rs +++ b/tests/snapshot_schemas.rs @@ -136,9 +136,11 @@ fn loads(fixture: &str, profile: &str, prom_bytes: usize, banks: &str) { std::fs::write( dir.join("iris.toml"), format!( - "headless = true\nno_audio = true\nprom = \"prom.bin\"\nbanks = {banks}\n\ + "headless = true\nprom = \"prom.bin\"\nbanks = {banks}\n\ monitor_port = 0\nserial_port_a = 0\nserial_port_b = 0\nscsi = {{}}\n\ - [machine]\nprofile = \"{profile}\"\ncache = \"{cache}\"\n{}", + [machine]\nprofile = \"{profile}\"\ncache = \"{cache}\"\n{}{}", + // The Indy goes without its HAL2, the others' cards play nowhere. + if profile == "indy_ip24" { "audio = false\n" } else { "[audio]\noutput = \"none\"\n" }, // Only the Octane has a graphics board to leave out. if profile == "octane_ip30" { "[graphics]\nboard = \"none\"\n" } else { "" } ), diff --git a/tezro.toml.example b/tezro.toml.example index 62c53ee32..f5ee0ac12 100644 --- a/tezro.toml.example +++ b/tezro.toml.example @@ -9,7 +9,6 @@ nvram = "ignore/tezro/nvram.bin" # banks left unset: 4 GiB as 1024, 1024 and four 512 MiB banks. headless = true -no_audio = true monitor_port = 18888 serial_port_a = 18880 serial_port_b = 18881 @@ -22,6 +21,8 @@ profile = "tezro_ip53" cpu = "r16000_700" # Processors fitted: 1, 2 or 4 (the default). Each runs on its own host thread. cpus = 4 +# Add the ICE1724 audio controller behind the IO9's PCI bridge (default false). +# audio = true # 0 to 4095: the system serial the L1 answers with, LEMU20, whose fold # is IRIX's host id (emu_id 42 prints d5200420). 0, the default, is generic. # emu_id = 42 @@ -33,8 +34,6 @@ board = "none" flash = "ignore/tezro/flash.bin" console = "l1" l1_port = 18882 -# Add the ICE1724 audio controller behind the IO9's PCI bridge (default false). -# audio = true [network] # Disconnected cable. For NAT, use mode = "nat". diff --git a/wsl/README.md b/wsl/README.md index ade615e74..df5163b75 100644 --- a/wsl/README.md +++ b/wsl/README.md @@ -36,7 +36,7 @@ These settings are part of this repository and share the core configuration sche | **Premiere GUI** | `cargo build -p iris-gui --features premiere` — embedded `lightning` + `idle-pause`; `run-iris-gui-premiere.bat` | | **GUI prefs** | Debug tab settings (`gui_gl_capture`, `no_idle`, `debug_log`) persisted in the machine's own TOML; **File → Prepare for premiere…** flushes it to disk and reports the path to run the CLI `--config` against, from that machine's folder | | **Idle refresh** | Status-bar-only heartbeat skips full compositor + partial egui upload — [clanker/perf/gui-idle-refresh.md](../clanker/perf/gui-idle-refresh.md) | -| **Audio** | HAL2 wall-clock pacing/resampling; Display tab `[audio]` prebuf / cpal buffer | +| **Audio** | HAL2 wall-clock pacing/resampling; Display tab `[audio]` output / cpal buffer | | **jitv2 (experimental)** | `--features jitv2` — physical-page region compiler, auto-enabled at runtime once compiled in (no env-var toggle); use `jitv2_lockstep` for diagnostics or `jitv2_opcodefusion` separately (they conflict); `[jitv2] threads` configures the pool | See [HELP.md](../HELP.md) for monitor commands, serial ports, NVRAM, etc.