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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ hashes are given where a change is easiest to understand by reading the commit.

### Build / features

- **`[audio] host` picks the host audio system; on Linux, PulseAudio on
request.** `host = "pulseaudio"` (or `--audio-host pulseaudio`) plays
through cpal's own PulseAudio client, which PipeWire answers too through
pipewire-pulse; the client is pure Rust, so the build links nothing new.
The default is unchanged: ALSA on Linux. On some setups cpal's ALSA
backend spins a whole core (a Debian 12 arm64 VM, through PipeWire's ALSA
plugin and the card's own device alike: the poll descriptor stays ready
while there is no room to write), starving the emulator into a stream of
underruns, while the PulseAudio client used 0.01 s of CPU in 5 s. See
`rules/testing/cpal-alsa-busy-poll.md`. `[audio] cpal_buffer_frames`,
which was parsed but never reached HAL2, now sets the host buffer size; a
size the device refuses falls back to the host's default. Unset,
PulseAudio gets 512 frames (two of them, ~21 ms at 48 kHz) instead of its
own default target of about two seconds, which made IRIX's sound arrive
that late.
- **`gpujit` turns on every graphics JIT** (`rex-jit` and `gr4-jit`) and is
the recommended build: `--features lightning,gpujit`. The GUI and the
release binaries now carry it, so IMPACT gets its raster JIT out of the
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,10 @@ iris-hostgl = { path = "iris-hostgl", optional = true }
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
# stays ALSA even though this feature makes cpal's default_host() prefer it.
cpal = { version = "0.18", features = ["pulseaudio"] }
nokhwa = { version = "0.10" }
v4l = { version = "0.14", features = ["v4l2-sys"] }

Expand Down
6 changes: 4 additions & 2 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,9 @@ Omit `[nfs]` to disable the share; the default forward list is empty.

| Key | Default | Values and behavior |
|---|---|---|
| `audio.prebuf_ms` | `20` | HAL2 prebuffer duration in milliseconds. |
| `audio.cpal_buffer_frames` | `host default when unset` | Requested host cpal buffer size in frames. |
| `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.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]

Expand Down Expand Up @@ -337,6 +338,7 @@ iris-gui does not parse this standalone option set; use its named-machine UI.
| `--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>` | Host audio system for HAL2: `default`, `alsa` or `pulseaudio` (overrides `[audio] host`) |
| `--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) or "pcap" (bridge onto a real host interface; requires --features pcap). |
Expand Down
4 changes: 3 additions & 1 deletion HELP.md
Original file line number Diff line number Diff line change
Expand Up @@ -537,8 +537,9 @@ 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
# cpal_buffer_frames = 512
# cpal_buffer_frames = 512 # unset: the host's default (PulseAudio: 512)

# [ultra64] # N64 dev board
# enabled = true
Expand Down Expand Up @@ -612,6 +613,7 @@ 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)

Networking
--nat-subnet <CIDR> NAT subnet, e.g. 192.168.5.0/24
Expand Down
7 changes: 5 additions & 2 deletions docs/hal2.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,11 @@ avoid advancing into descriptors the guest has not prepared.
- BRES rate is derived from its selected master, increment, and modulus.
Codec CLKID is the generator number 1–3, not a zero-based index.
- Codec A keeps a host cpal stream open across enable/disable cycles and
resamples guest-rate stereo samples to the host output rate. `[audio]`
selects prebuffering and host buffer size.
resamples guest-rate stereo samples to the host output rate. `[audio]
host` picks the host audio system (on Linux ALSA by default, or cpal's own
PulseAudio client, which PipeWire answers too), and `cpal_buffer_frames`
the host buffer size; PulseAudio, whose default target is about two
seconds, gets 512 frames unless told otherwise.
- Mono is duplicated to L/R. Stereo consumes interleaved pairs. Single-stream
quad consumes all four samples but discards the rear pair.
- Codec B is an input DMA silence writer, not a second host output stream.
Expand Down
18 changes: 17 additions & 1 deletion iris-gui/src/config_ui.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use egui::{Color32, ComboBox, DragValue, Grid, RichText, ScrollArea, TextEdit, Ui};
use iris::build_features;
use std::path::Path;
use iris::config::{
use iris::config::{AudioHost,
format_unix_utc, CpuModel, ForwardBind, ForwardProto, GraphicsBoard, KeyboardLayout,
MachineConfig, MachineProfile, NetMode, NfsConfig, PortForwardConfig, RtcOffset,
ScsiDeviceConfig, ScsiKind, VinoSource, VinoStandard, VALID_BANK_SIZES,
Expand Down Expand Up @@ -711,6 +711,22 @@ fn show_display(ui: &mut Ui, cfg: &mut MachineConfig, running: bool) {
.small(),
);
Grid::new("audio_grid").num_columns(2).striped(true).show(ui, |ui| {
if cfg!(target_os = "linux") {
ui.label("Host audio");
let label = |h: AudioHost| match h {
AudioHost::Default => "Default (ALSA)",
AudioHost::Alsa => "ALSA",
AudioHost::Pulseaudio => "PulseAudio / PipeWire",
};
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("Pre-buffer (ms)");
ui.add(DragValue::new(&mut cfg.audio.prebuf_ms).range(5..=200));
ui.end_row();
Expand Down
3 changes: 2 additions & 1 deletion iris.toml
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,9 @@ mode = "nat"

# HAL2 audio output buffering.
#[audio]
#host = "default" # Linux: "alsa" (default) or "pulseaudio" (PulseAudio or PipeWire)
#prebuf_ms = 20
#cpal_buffer_frames = 512
#cpal_buffer_frames = 512 # unset: the host's default (PulseAudio: 512)

# Host thread pinning.
#[perf]
Expand Down
32 changes: 32 additions & 0 deletions rules/testing/cpal-alsa-busy-poll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# cpal's ALSA backend can spin a core: `[audio] host = "pulseaudio"`

**Keywords:** hal2,audio,alsa,cpal,pulseaudio,pipewire,busy-poll,xrun,underrun,linux,red led

**Symptom.** On Linux, HAL2 logs a steady stream of xruns / underruns and the
guest runs slowly; `top -H` shows cpal's ALSA worker thread at 100% of a core.
Once (Debian 12 arm64, Parallels VM, 4 vCPUs) the IP28 sat in its power-on
diagnostics with the red LED lit because the emulator was starved.

**Cause (cpal 0.18).** The PCM's poll descriptor stays readable while `avail`
is below `avail_min`, so cpal's worker returns from `poll()` immediately and
loops, about five million times a second. Seen through PipeWire's ALSA plugin
(`default`) and through the card's own device (`hw`/`plughw`) alike. `aplay`
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`):
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
confirmed on real hardware and reported upstream; then ALSA could become the
fallback only.

**Buffer size.** Left to itself, PulseAudio (and pipewire-pulse) targets about
two seconds of buffering, and IRIX's sound came that late. HAL2 asks for a
fixed period of 512 frames there (cpal makes the target two periods, ~21 ms
at 48 kHz, with adjust-latency) unless `cpal_buffer_frames` says otherwise.

**Picking the host.** With the `pulseaudio` feature compiled in, cpal's own
`default_host()` prefers PulseAudio whenever its socket exists, so HAL2 must
not call it on Linux: `output_host()` in `src/dev/hal2.rs` asks for ALSA by id
unless PulseAudio was requested.
41 changes: 40 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -967,9 +967,27 @@ impl Default for PerfConfig {
}
}

/// Which host audio system HAL2 plays through.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum AudioHost {
/// The platform's usual one: CoreAudio on macOS, WASAPI on Windows, ALSA
/// on Linux.
#[default]
Default,
/// ALSA (Linux).
Alsa,
/// A PulseAudio server, or PipeWire's pipewire-pulse (Linux). Falls back
/// to ALSA when none answers.
Pulseaudio,
}

/// HAL2 / cpal audio output tuning.
#[derive(Debug, Clone, Serialize, Deserialize)]
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,
Expand All @@ -982,7 +1000,7 @@ fn default_audio_prebuf_ms() -> u64 { 20 }

impl Default for AudioConfig {
fn default() -> Self {
Self { prebuf_ms: default_audio_prebuf_ms(), cpal_buffer_frames: None }
Self { host: AudioHost::Default, prebuf_ms: default_audio_prebuf_ms(), cpal_buffer_frames: None }
}
}

Expand Down Expand Up @@ -1651,6 +1669,10 @@ pub struct Cli {
#[arg(long = "noaudio", default_value_t = false)]
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<AudioHost>,

/// Enable NFS share: path to the directory to export (enables NFS)
#[arg(long = "nfs-dir", value_name = "DIR")]
pub nfs_dir: Option<String>,
Expand Down Expand Up @@ -1827,6 +1849,7 @@ impl Cli {
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 self.no_scsi_deferred_int { cfg.scsi_deferred_int = false; }
if self.ci { cfg.ci = true; }
Expand Down Expand Up @@ -1959,6 +1982,22 @@ mod export_tests {
println!("--- exported toml ---\n{s}");
}

#[test]
fn audio_host_parses_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::<MachineConfig>("[audio]\nhost = \"oss\"").is_err());
}

#[test]
fn scsi_kind_daynaport_parses_and_round_trips() {
let cfg: MachineConfig = toml::from_str(r#"
Expand Down
Loading
Loading