diff --git a/.agents/qa.md b/.agents/qa.md index ee4e7150..caaacd98 100644 --- a/.agents/qa.md +++ b/.agents/qa.md @@ -284,7 +284,7 @@ these. Use it when you can state what the *spec* got wrong. ### loopback IR testing -see docs/runbooks/loopback-ir.md +see docs/runbooks/rig-testing.md (7b without hardware, 7c through a rig) ### `requires-rig` — you set it, and you clear it on a passing record diff --git a/.agents/rig.md b/.agents/rig.md index 95e894a0..d4466c2b 100644 --- a/.agents/rig.md +++ b/.agents/rig.md @@ -2,16 +2,13 @@ ## identity Rig agent for `ac` repo (github.com/mkovero/ac). -Job: hardware-in-the-loop verification session against a real rig (default -192.168.9.25 — RME Babyface Pro, -speaker on ADAT1/AS1 (playback_5) out, mic on AN1 (capture_1), -electrical loopback reference out AN2 (playback_2) and coming in IN4 (capture_4). -Normally not connected but reserved: -Loopback through master converter out ADAT3 (playback_7) and coming in IN3 (capture_3). -Master analogue section loopback with converter out AS1 (playback_5) and coming in AN2 (capture_2). -If you need these two loopbacks be clear to prompt operator -for required cabling. -Produce a +Job: hardware-in-the-loop verification session against a real rig — by +default `pupu` (RME Fireface 400, Genelec 1083 on AN1, mic on +IN1, electrical reference loopback AN2 → IN2). Rig facts: +`docs/rigs/.md`. **Procedure: `docs/runbooks/rig-testing.md`** — build, +ship, pre-flight and every test run through its scripts in `scripts/rig/`. +If a session needs cabling other than the rig profile's, prompt the operator +for it and probe it before measuring. Produce a measurement record with confounds stated. **Permitted, and expected, to decline to conclude** when the data does not support a pass/fail score — the two rig sessions that did this are the good examples this role is @@ -43,10 +40,11 @@ rig session is what moves a criterion from `derived` or `assumed` to `measured` in that sense. Existing session records to read before a session, for what already -survived contact with this rig and what didn't: +survived contact with a rig and what didn't: - `work/rig/rig-session-2-results.md`, `rig-session-3-results.md`, `rig-session-results.md`, `rig-verify-125-results.md` — completed - sessions, historical. + sessions, historical, measured on the Babyface setup at 192.168.9.25 + (`docs/superseded/rig-babyface-audio.md`). - `$AC_HOME/rig-verify-queue.md` — the live queue of what still needs the rig; read the "rig's own defects" section at its top before anything else, and the "rig state left behind" section for what condition the @@ -64,34 +62,21 @@ survived contact with this rig and what didn't: ## what you must do ### step 1 — pre-flight -- Verify the installed build by **sha256**, never by size or mtime alone. - Both have already produced a false pass on this rig: a build that matched +- Build and ship with `scripts/rig/build-portable.sh` and + `scripts/rig/ship.sh `, then run `scripts/rig/preflight.sh + ` and keep its output for the record. +- Verify the build under test by **sha256**, never by size or mtime alone. + Both have already produced a false pass on a rig: a build that matched size and mtime and was a different binary by hash (`rig-session-2-results.md`). - `install.sh` prints sha256 for all three binaries — read that output. -- Confirm the interface clock is `AutoSync` (`numid=320 = 0`) and record why: - the external master clocks the card over ADAT, and ADAT carries the - stimulus leg (`playback_5`). Setting it to `Internal` silently breaks the - speaker path rather than erroring. -- Set without confirming (at rig): - amixer -c0 cset numid=1 0 # dont monitor mic -> AN1 - amixer -c0 cset numid=14 0 # dont monitor mic -> AN2 - amixer -c0 cset numid=301 36 # mic input gain (36=max) - amixer -c0 cset numid=295 46341 # playback_7 output level - amixer -c0 cset numid=293 16384 # playback_5 output level - amixer -c0 cset numid=294 16384 # playback_6 output level - amixer -c0 cset numid=308 0 # IN4/capture_4 level (no gain) - amixer -c0 cset numid=307 0 # IN3/capture_3 level (no gain) - amixer -c0 cset numid=302 1 # AN1/capture_1 mic input 48V on - amixer -c0 cset numid=305 0 # AN2/capture_2 mic input 48V off - amixer -c0 cset numid=289 16384 # AN1/playback_1 output level - amixer -c0 cset numid=290 16384 # AN2/playback_2 output level - + `ship.sh` verifies the hashes on the rig and prints them — read that output. +- Confirm the interface clock and mixer baseline match the rig profile + (`preflight.sh` checks the profile's ALSA values) and record the clock + source and why. - Record what is physically connected — every leg, reference and measurement, by output/input index, not by what a handoff document says it should be. Let operator know what is your idea of the outputs/inputs today. - Stop the daemon before installing a build over it. `install -m 755` over a running `ac-daemon` may fail `Text file busy`, or may succeed and leave an - ambiguous state — see `$AC_HOME/rig-verify-queue.md` for whether this has - been settled on the current build. Stop first regardless of the answer. + ambiguous state — `ship.sh --install` stops it first. Stop first regardless. ### step 2 — emission consent No drive/emission proceeds without consent: the operator's **standing @@ -103,6 +88,7 @@ level). Consent is needed before this session's first stimulus command — `set_ `sweep_frequency`, `calibrate`, `transfer_stream` with drive, `probe`, `test_hardware` and `test_dut` all put a signal on a physical output. Do not read this list as narrower than the code: anything that can play is covered. +The emitting scripts take the consent as `--consent ""` and print it. See hard constraints below for the ceiling and how it is enforced. Record what was consented to (ceiling, duration if bounded) in the resulting file. @@ -121,15 +107,16 @@ Execute the queued block(s) or ad-hoc procedure as directed. For each run: record that plainly rather than omitting the block. ### step 4 — write the record -Write to `work/rig/{session-name}-results.md` (see "where records live" -below for whether that's a new file). Required content: +Write to `work/rig/{session-name}-results.md`, starting from +`scripts/rig/record-template.md` (see "where records live" below for whether +that's a new file). Required content: - **build under test** — sha256-verified, git ref if known. - **drive level** — what was consented to, and its provenance (standing −40 dBFS ceiling, or a recorded exception — see hard constraints), plus the level each emitting run requested and the level its reply reported. - **what is physically connected** — every leg, confirmed this session. -- **clock state** — `AutoSync`, and the reason, restated even when +- **clock state** — the clock source, and the reason, restated even when unchanged from a previous session (this file is read independently of that one). - **per-run results** — what was verified, what a pass looked like, what @@ -203,8 +190,9 @@ Interlocks. A session may not proceed past these — not guidance, blocking: in-session. This role produces evidence, not patches. - **No automated enforcement of any of the above.** These interlocks are not machine-checked in `ac-daemon` or `ac-cli` — enforcing them there is - explicitly out of scope for this role. Reading this file is what - enforces it; know that going in. + explicitly out of scope for this role. The `scripts/rig/` consent and + level checks are conveniences, not the interlock. Reading this file is + what enforces it; know that going in. ## pipeline mode @@ -232,7 +220,7 @@ check** (architect or triage). ## where records live -`work/rig/` holds two different kinds of file, with different expiry: +`work/rig/` holds the session-result files: - **Session-result files** (`rig-session-N-results.md`, `rig-verify-NNN-results.md`, and similar) are historical evidence and do @@ -240,8 +228,8 @@ check** (architect or triage). says so in prose against the earlier finding — `rig-verify-queue.md`'s own "session 3 supersedes" note is the pattern — rather than deleting or rewriting the earlier file. -- **`rig-verify-queue.md`** is a live queue, not historical evidence. It - expires per item: each queued block gets marked executed, with a pointer - to the session-result file that ran it, as soon as that happens (the - existing "Executed, session N" annotations on several blocks are the - pattern to follow). An item with no such annotation is still open. +- **`$AC_HOME/rig-verify-queue.md`** is a live queue, not historical + evidence. It expires per item: each queued block gets marked executed, + with a pointer to the session-result file that ran it, as soon as that + happens (the existing "Executed, session N" annotations on several blocks + are the pattern to follow). An item with no such annotation is still open. diff --git a/README.md b/README.md index 5c120fe6..c1b93c1e 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,9 @@ cargo build --release Toolchain is pinned to Rust 1.95.0 via `rust-toolchain.toml`. `ac monitor` and `ac transfer` launch `ac-view`, which must be on `$PATH` -or in the same directory as `ac`. Note that `install.sh` currently -installs only `ac` and `ac-daemon` — install `ac-view` alongside them if -you want the GUI outside a dev tree. +or in the same directory as `ac`. `ac-rs/install.sh` builds for the host +CPU and installs `ac`, `ac-daemon` and `ac-view` into `/usr/local/bin`. To +put a build on a test rig instead, see `docs/runbooks/rig-testing.md`. ## Audio backend diff --git a/TESTING.md b/TESTING.md index dc7dc72b..c1849d62 100644 --- a/TESTING.md +++ b/TESTING.md @@ -58,7 +58,7 @@ mtime: the convenient reading is not the evidence, and it fails quietly. Among the `#[ignore]`'d, `it_loopback_ir` drives a Farina sweep through real JACK port-to-port loopback and is run manually after starting `jackd -d dummy` -— see ARCHITECTURE.md → "Loopback IR runbook". The rest need real hardware, a +— see `docs/runbooks/rig-testing.md` (7b; 7c for real hardware). The rest need real hardware, a live daemon, or a real GPU adapter. No JACK daemon or audio hardware required for the default suite — pytest spawns `ac-daemon --fake-audio` (synthetic sine + 1% 2nd harmonic) on free ports and connects via ZMQ. diff --git a/ac-rs/crates/ac-daemon/examples/ir_probe.rs b/ac-rs/crates/ac-daemon/examples/ir_probe.rs index 1fc11933..86940ba2 100644 --- a/ac-rs/crates/ac-daemon/examples/ir_probe.rs +++ b/ac-rs/crates/ac-daemon/examples/ir_probe.rs @@ -90,6 +90,30 @@ fn parse_args() -> Args { a } +/// The level the daemon says it applied, read from the `plot_ir` +/// acknowledgement. The request alone is not the record: a daemon from +/// before #459 clamps a typed level through `drive_max_dbfs` and +/// acknowledges the clamped value. An acknowledgement without a finite +/// `level_dbfs` is refused rather than reported as if it echoed the request. +pub(crate) fn acknowledged_level(ack: &Value) -> Result { + match ack.get("level_dbfs").and_then(Value::as_f64) { + Some(v) if v.is_finite() => Ok(v), + _ => Err(format!( + "plot_ir acknowledgement carries no finite level_dbfs: {ack}" + )), + } +} + +/// Record line carrying both levels, marked when they disagree. +pub(crate) fn level_line(requested: f64, acknowledged: f64) -> String { + let mark = if acknowledged == requested { + "" + } else { + " ← DIFFERS from the request" + }; + format!("level: requested {requested} dBFS, acknowledged {acknowledged} dBFS{mark}") +} + fn main() { let a = parse_args(); let ctx = zmq::Context::new(); @@ -125,7 +149,11 @@ fn main() { if ack["ok"] != json!(true) { panic!("plot_ir rejected: {ack}"); } - eprintln!("plot_ir accepted at {} dBFS, waiting…", a.level_dbfs); + let acked_dbfs = acknowledged_level(&ack).unwrap_or_else(|e| panic!("{e}")); + eprintln!( + "plot_ir accepted: requested {} dBFS, acknowledged {acked_dbfs} dBFS, waiting…", + a.level_dbfs + ); let deadline = Instant::now() + Duration::from_secs(a.timeout_s); let frame = loop { @@ -186,9 +214,10 @@ fn main() { println!("--- ir_probe record ---"); println!("sample_rate: {sr} Hz"); println!( - "sweep: {} Hz – {} Hz, {} s, {} dBFS, {} harm, {} tail", - a.f1, a.f2, a.duration, a.level_dbfs, a.n_harmonics, a.tail_s + "sweep: {} Hz – {} Hz, {} s, {} dBFS acknowledged, {} harm, {} tail", + a.f1, a.f2, a.duration, acked_dbfs, a.n_harmonics, a.tail_s ); + println!("{}", level_line(a.level_dbfs, acked_dbfs)); println!( "window: requested {}, ir len {}{}", a.window_len, diff --git a/ac-rs/crates/ac-daemon/tests/it_loopback_ir.rs b/ac-rs/crates/ac-daemon/tests/it_loopback_ir.rs index 38af5284..5c8fb1d3 100644 --- a/ac-rs/crates/ac-daemon/tests/it_loopback_ir.rs +++ b/ac-rs/crates/ac-daemon/tests/it_loopback_ir.rs @@ -6,8 +6,8 @@ //! pre-impulse floor. //! //! This test is `#[ignore]`'d so it does not run as part of `cargo test`. -//! It needs a live JACK server. See `ARCHITECTURE.md` → "Testing strategy" -//! → "Loopback IR runbook" for invocation. +//! It needs a live JACK server. See `docs/runbooks/rig-testing.md` (7b, and +//! 7c for real hardware) for invocation. //! //! The internal loopback works because both the daemon's output and input //! ports are registered under the same JACK client (`ac-daemon`). Setting @@ -22,12 +22,13 @@ //! hardware, set both of these to real JACK port names: //! //! ```text -//! AC_LOOPBACK_OUT="Babyface Pro Pro:playback_2" # daemon's out connects here -//! AC_LOOPBACK_IN="Babyface Pro Pro:capture_4" # daemon's in connects here +//! AC_LOOPBACK_OUT="system:playback_2" # daemon's out connects here (pupu: AN2) +//! AC_LOOPBACK_IN="system:capture_2" # daemon's in connects here (pupu: IN2) //! ``` //! -//! Unset, both default to the self-loop, so the `jackd -d dummy` runbook in -//! `ARCHITECTURE.md` is unchanged. +//! Unset, both default to the self-loop, so the `jackd -d dummy` route is +//! unchanged. On a rig, `scripts/rig/run-loopback-ir.sh` sets all three from +//! the rig profile. //! //! Setting them puts a stimulus on physical outputs, which is behind the //! rig's standing drive-level policy. So when `AC_LOOPBACK_OUT` is set, @@ -80,7 +81,7 @@ const RIG_DRIVE_CEILING_DBFS: f64 = -40.0; /// being placed by `MAX_ROUND_TRIP_S`, and a peak pinned at that edge — /// exactly what a too-short window produces — passes the position check /// as if it were a plausible round trip. 2.0 s (the reference rig's own -/// runbook duration, ARCHITECTURE.md's "Loopback IR runbook") fixes this, +/// runbook duration, `docs/runbooks/rig-testing.md`) fixes this, /// but the two runnable sample rates hit different limits and so land at /// different margins: at 48 kHz the harmonic gap is still the smaller /// number (11536 samples), giving a half-window of ~120 ms — 2.00× @@ -94,7 +95,7 @@ const DEFAULT_DURATION_S: f64 = 2.0; /// Maximum acceptable round-trip latency, in seconds. #277 measured /// 43.75 ms on the reference rig (Babyface Pro leg, 96 kHz, 2.0 s sweep — -/// ARCHITECTURE.md's "Loopback IR runbook"). This is that figure with +/// `docs/runbooks/rig-testing.md`). This is that figure with /// ~37% headroom for rig-to-rig jitter, not a bound fitted to one run. /// If a chain ever needs more than this, the number moves and this /// comment's citation moves with it — it must never grow silently. @@ -456,7 +457,7 @@ impl Client { } #[test] -#[ignore = "needs a live JACK server — see ARCHITECTURE.md"] +#[ignore = "needs a live JACK server — see docs/runbooks/rig-testing.md"] fn loopback_ir_recovers_sharp_peak() { let routing = Routing::from_env(); let chain = routing.describe(); @@ -669,6 +670,57 @@ fn real_port_route_refuses_a_level_above_the_rig_limit() { ); } +/// `ir_probe` is the headless `plot_ir` client the rig scripts record from +/// (`scripts/rig/lib/acoustic_ir_remote.sh`). Examples' own unit tests do +/// not run under `cargo test`, so its acknowledgement handling is compiled +/// in here and tested against the case the record must not hide: a daemon +/// that acknowledges a level other than the one requested. +#[path = "../examples/ir_probe.rs"] +#[allow(dead_code)] +mod ir_probe; + +#[cfg(test)] +mod ir_probe_ack_tests { + use super::ir_probe::{acknowledged_level, level_line}; + use serde_json::json; + + /// A pre-#459 daemon clamping a typed −50 to −60 acknowledges −60. + /// Reporting the request would record −50; the record must carry −60. + #[test] + fn clamped_acknowledgement_is_recorded_not_the_request() { + let requested = -50.0; + let ack = json!({"ok": true, "level_dbfs": -60.0}); + let acked = acknowledged_level(&ack).expect("ack carries a level"); + assert_eq!(acked, -60.0); + assert_ne!(acked, requested); + + let line = level_line(requested, acked); + assert!(line.contains("requested -50 dBFS"), "{line}"); + assert!(line.contains("acknowledged -60 dBFS"), "{line}"); + assert!(line.contains("DIFFERS"), "{line}"); + } + + #[test] + fn matching_acknowledgement_is_not_marked() { + let line = level_line(-40.0, -40.0); + assert!(line.contains("acknowledged -40 dBFS"), "{line}"); + assert!(!line.contains("DIFFERS"), "{line}"); + } + + /// An acknowledgement without the field, or with a non-number, must be + /// refused — not reported as though it echoed the request. + #[test] + fn acknowledgement_without_a_level_is_refused() { + for ack in [ + json!({"ok": true}), + json!({"ok": true, "level_dbfs": null}), + json!({"ok": true, "level_dbfs": "-50"}), + ] { + assert!(acknowledged_level(&ack).is_err(), "accepted {ack}"); + } + } +} + /// Plain unit tests over the round-trip-latency bound math, not `#[ignore]`d /// — no JACK server needed, so these run under plain `cargo test` and catch /// a regression to #361's failure mode without a rig. @@ -741,7 +793,7 @@ mod round_trip_bound_tests { /// `DEFAULT_DURATION_S` must genuinely bind `MAX_ROUND_TRIP_S` at the /// sample rates this repo can actually exercise the loopback test at: /// `jackd -d dummy` self-loop configs (48 kHz) and the rig's own - /// Babyface Pro leg (96 kHz — ARCHITECTURE.md's "Loopback IR runbook"). + /// Babyface Pro leg (96 kHz — `docs/runbooks/rig-testing.md`). /// Regression guard for #361's acceptance criterion: `hi_bound` must /// not saturate at any configuration that actually runs. #[test] diff --git a/docs/rigs/pupu.md b/docs/rigs/pupu.md new file mode 100644 index 00000000..32bd5083 --- /dev/null +++ b/docs/rigs/pupu.md @@ -0,0 +1,189 @@ +# Rig profile: `pupu` — dedicated audio HIL rig + +Facts about this machine only. **How to test on it** is +`docs/runbooks/rig-testing.md`; the machine-readable form of this page is +`scripts/rig/hosts/pupu.env` — change both together. + +Last verified: 2026-09-14. + +## Access + +- Address, user and SSH key are private and not in this repo. The scripts + read them from `$AC_HOME/rig-hosts/pupu.access.env` (`RIG_HOST`, + `RIG_USER`, `RIG_SSH_KEY`). +- Never build here; ship binaries built on the development host. + +## Physical setup + +| FF400 port | JACK port | `ac` index | connected to | +|---|---|---|---| +| AN1 out | `system:playback_1` | output 0 | Genelec 1083 | +| AN2 out | `system:playback_2` | output 1 | cable → IN2 (reference loopback) | +| IN1 (mic pre, phantom **on**) | `system:capture_1` | input 0 | measurement mic | +| IN2 (mic pre, phantom **off**) | `system:capture_2` | input 1 | loopback from AN2 | + +JACK port names and `ac` indices here assume the analog-first order — see +"JACK port order"; it moves. + +- Mic static at **1 m** on the speaker axis; tape marks on the floor at + **2 m** and **3 m** for manual moves. Put it back at 1 m after a move and + state the position in the run record. +- The 1083 has intermittent distortion, obvious by ear when present. A run + nobody listened to needs a THD check before it is trusted. +- Never plug a line output into IN1 while its phantom is on. +- Verified by emission 2026-09-14 (1 kHz, −60 dBFS, 3 s per output): AN1 → + mic −64.7 dBFS, IN2 −139.0 dBFS; AN2 → IN2 −58.3 dBFS, mic −101.6 dBFS + (leakage). The operator's stated layout was the reverse of the cabling + once already — probe after any cable work. + +## Audio system + +- RME Fireface 400 over FireWire (TI XIO2213, shared IRQ 18), ALSA card + `Fireface400`, driver `snd_fireface`, clock Internal. +- JACK: system unit `jack-ac.service`, drop-in + `/etc/systemd/system/jack-ac.service.d/override.conf`: + + ``` + ExecStart=/usr/bin/jackd -R -P 80 -S -n default -t 2000 -d alsa -d hw:Fireface400 -r 96000 -p 256 -n 3 + ``` + + **96 kHz, 256 frames, 3 periods, synchronous mode.** Keep `-S` (see JACK + stability). jack2 1.9.22, kernel 7.2.4-rt3 (PREEMPT_RT), i5-2415M, no CPU + governor / C-state / IRQ tuning needed. +- FF400 mixer/control service: user unit `snd-fireface-ctl.service` + (enabled), bundle `~/ac-test/ff400-control-v0.2.0`. +- **Baseline:** +4 dBu line out / line in / headphone level; phantom IN1 on, + IN2 off; mic gain IN1 20 dB, IN2 0 dB; line gains 0 dB; unity output + volume; identity stream routing; every analog/S/PDIF/ADAT source gain 0 (no + hardware monitor path); metering on. +- **A power cycle resets the FF400 to driver defaults** (line out *High*, + line in *Low*, phantom off, gains 0). **So did a `jack-ac.service` restart** + on 2026-09-15: a −60 dBFS tone on output 1 read IN2 −51.5 dBFS (baseline + −57.5) and the mic channel went to a dead −106 dBFS floor, while + `amixer cget` still returned the baseline values, so preflight's ALSA rows + passed. One observation: treat every JACK restart as a possible reset + until shown otherwise. +- **Restore with toggle writes.** Writing a value the driver already caches + does not reach the device, so set a different value first, then the + baseline — gains before phantom: + + ```sh + C=Fireface400 + amixer -c $C cset numid=93 1; amixer -c $C cset numid=94 1; amixer -c $C cset numid=89 1 + amixer -c $C cset numid=93 2; amixer -c $C cset numid=94 2; amixer -c $C cset numid=89 2 + amixer -c $C cset numid=81 0,0; amixer -c $C cset numid=90 off,off; sleep 1 + amixer -c $C cset numid=90 on,off; sleep 2 + amixer -c $C cset numid=81 20,0; amixer -c $C cset numid=102 on + ``` + + Verify by emission, not by readback: `probe-outputs.sh pupu --level -60 + --outputs 1` must read IN2 ≈ −57.5 dBFS and IN1 room noise (≈ −74 dBFS rms). + + Do **not** use `ff400-card1.sh` / `scripts/ff400.sh`: it forces phantom + off, and its `jack_alias` table is ADAT-first, which is wrong for this + driver. + +### JACK port order + +**Not stable.** `snd_fireface` sometimes puts the ADAT/S/PDIF block before +the analog block and sometimes after it; the operator treats this as expected +for now (issue #444). The table above and `scripts/rig/hosts/pupu.env` assume +**analog first** — `capture_1..8` / `playback_1..8` = AN1..AN8 — which is what +2026-09-14 measured (silent 18-channel `jack_rec` at 48 kHz, and the FF400 +`meter:analog-output` / `meter:stream-input` controls during a tone). The +port count depends on the rate — 18 capture ports at 48 kHz, 14 at 96 kHz — +so a moved block does not land at a fixed offset. + +Silent check: with nothing on ADAT/S/PDIF, those captures read **exact +digital zero** and analog inputs never do, so the live channels show where +the analog block sits. `preflight.sh` reports it and every emitting script +refuses to run when the block is not at `capture_1..8`; the playback order has +matched the capture order whenever both were checked, which is an +observation, not a guarantee. If the block has moved, do not quietly edit the +profile to chase it — record it and settle the ports with the operator. JACK +aliases are not evidence — the old `FF400:capture_ADAT1` aliases were written +by the helper script itself. + +## `ac` configuration + +- `~/.config/ac/config.json`: `output_channel 0`, `input_channel 0`, + `reference_output_channel 1`, `reference_channel 1`, and no + `drive_max_dbfs` key — #459 retired it, and while it is present every + emitting command refuses (`preflight.sh` FAILs on it). No calibration; + levels are dBFS only. +- **Ceilings (nominal dBFS): −40 standing, −50 on anything that drives the + speaker** (operator, 2026-09-14). The daemon has no configurable ceiling: + since #459 it refuses only above full scale and never clamps. Both limits + are enforced only by `scripts/rig/` (`RIG_DRIVE_CEILING_DBFS`, + `RIG_SPEAKER_CEILING_DBFS`) and, for manual commands, by whoever runs + them. Emission rules: `.agents/rig.md` hard constraints. +- Level expectation (1 kHz, mic at 1 m, IN1 gain 20 dB): mic reads about + 4 dB under the drive level, ~1:1 from −54 to −40 dBFS (measured at + 48 kHz); a −60 dBFS check at 96 kHz read −63.7 dBFS. +- **A −40 dBFS Farina sweep through the 1083 is loud** — louder than a 1 kHz + tone at the same nominal level, because the sweep dwells in the low + octaves (woofer, room modes) and in 2–5 kHz. The nominal level is the sweep + amplitude (RMS 3 dB lower). Hence the −50 dBFS speaker ceiling — + expect ~10 dB less acoustic SNR than the 25.6 dB measured at −40 (below), + marginal on a noisy day. + +## Measured on this setup (2026-09-14, build 0891cf92, 96 kHz / 256 / 3 / `-S`) + +Taken while verifying `scripts/rig/`; one run each, so a first reading, not a +repeatability claim. Absolute offsets only compare within one JACK client's +lifetime — a restarted client can land a period away. + +| run | chain | level | peak offset from window centre | SNR | +|---|---|---|---|---| +| `run-loopback-ir.sh --route ref` | AN2 → IN2 cable | −40 dBFS | +1727 smp = **+17.99 ms** (round trip) | 32.8 dB | +| `acoustic-ir.sh` | AN1 → 1083 → mic at 1 m | −40 dBFS (before the −50 speaker ceiling) | +2200 smp = +22.92 ms | 25.6 dB | + +Acoustic onset at 50 / 25 / 10 / 5 % of peak: +21.98 / +21.69 / +19.66 / ++18.79 ms. Do not subtract the loopback's 17.99 ms from these: τ is per +channel pair, and the loopback (AN2 → IN2) is not the acoustic pair +(AN1 → IN1). The 5–10 % onsets sit earlier than 1 m of flight allows, so +they are reading pre-ringing or noise, not the arrival. + +Wiring probe the same session (1 kHz, −60 dBFS): AN1 → mic −65.7 dBFS, IN2 +−133.1 dBFS; AN2 → IN2 −57.5 dBFS, mic −106.0 dBFS. + +## Background noise + +Varies a lot between sessions — snapshot at the start of each +(`scripts/rig/noise-snapshot.sh pupu`). Reference points: + +- 2026-09-14 18:44, operator present, 96 kHz: −52.6 dBFS broadband, loudest + band 63 Hz at −55.3 dBFS, 1 kHz −72.4 dBFS. +- 2026-09-14 18:53, 96 kHz: −67.1 dBFS broadband, loudest band 63 Hz at + −71.3 dBFS, 1 kHz −80.3 dBFS — 15 dB quieter within ten minutes, which is + why a snapshot belongs to every session. +- Broadband low-frequency (peak 63–250 Hz), not mains hum; the FF400's own + analog floor is ~−108 dBFS, so the mic floor is acoustic. Below ~250 Hz a + −40 dBFS sweep has little margin. + +## JACK stability (2026-09-14) + +Loaded 60 s runs, capture-only `ac monitor 0-7 --tui`: + +| rate | period | n | mode | tuning | steady xruns | +|---|---|---|---|---|---| +| 48000 | 256 | 3 | async | none | 1726 | +| 48000 | 256 | 3 | async | none, load = plain `jack_rec` | 284 | +| 96000 | 1024 | 3 | async | none | 1419 | +| 96000 | 1024 | 3 | async | IRQ 90, `performance`, C3+ off | 905 | +| 96000 | 1024 / 256 | 3 | `-S` | IRQ 90, `performance`, C3+ off | 0 | +| 96000 | 256 | 3 | `-S` | none | 0 (also over a 300 s soak) | + +Async mode fails regardless of period length, so it is not a CPU budget: +the log is `ProcessGraphAsyncMaster: Process error` plus `client … was not +finished`, even for trivial `jack_rec`, with no ALSA overruns — consistent +with irregular wake-ups from the FireWire stack (isochronous work runs in the +non-RT `firewire-isoc-card0` workqueue). The `ac-daemon` RT callback is lock- +and allocation-free. + +## Open items + +- Quiet-period noise snapshot (operator absent) not yet taken. +- Level ramp not redone at 96 kHz; no long play-and-capture soak yet. +- Mic model, the speaker reference point the 1/2/3 m marks are measured + from, and mic height are not recorded. diff --git a/docs/runbooks/loopback-ir.md b/docs/runbooks/loopback-ir.md deleted file mode 100644 index 8edfcc33..00000000 --- a/docs/runbooks/loopback-ir.md +++ /dev/null @@ -1,55 +0,0 @@ -### Loopback IR runbook - -`plot_ir`'s real-audio path (`JackEngine::play_and_capture`) is exercised -by an `#[ignore]`'d integration test that needs a live JACK server. It is -not run in `cargo test`; invoke it manually after starting JACK: - -```bash -# 1. Start JACK. The dummy driver works — no hardware needed. -jackd -d dummy -r 48000 -p 1024 & - -# 2. Run the loopback test. -cargo test -p ac-daemon --test it_loopback_ir -- --ignored -``` - -The test pre-writes a config with `output_port = "ac-daemon:in"` and -`input_port = "ac-daemon:out"`, so the daemon self-connects its own -JACK output to its own input — no `jack_connect` and no system audio -devices required. It then runs a 2.0 s exponential sweep, deconvolves, -and asserts the recovered linear IR has a dominant peak at least 25 dB -above the pre-impulse floor, at or after the gate centre and within a -60 ms round-trip margin of it — both derived from measurement, not round -numbers; see `it_loopback_ir.rs`'s `MAX_ROUND_TRIP_S`/`SNR_FLOOR_DB` -comments and #341. The 2.0 s duration is itself load-bearing, not just a -default value: shorter sweeps shrink the window the round-trip bound is -measured against, and below ~1.0 s that window can no longer hold -`MAX_ROUND_TRIP_S` at all (#361). - -**That default routes through no converter.** The self-loop stays inside -the daemon's own JACK client, so it exercises the ring and the -deconvolution, not an interface. To put the sweep through real hardware, -name real ports: - -```bash -AC_LOOPBACK_OUT="Babyface Pro Pro:playback_2" \ -AC_LOOPBACK_IN="Babyface Pro Pro:capture_4" \ -AC_LOOPBACK_LEVEL_DBFS=-40 \ - cargo test -p ac-daemon --test it_loopback_ir -- --ignored --nocapture -``` - -Both port variables must be set together — half a route is a route -through the wrong thing. `AC_LOOPBACK_LEVEL_DBFS` is **mandatory** -whenever they are set: naming real ports means driving real outputs. The -test refuses a value above the rig's standing −40 dBFS limit before it -starts the daemon. The daemon independently refuses anything above its -full-scale (0 dBFS) product maximum; it never clamps. Unset, all three default -to the self-loop at −40 dBFS and the dummy invocation above is unchanged. - -`--nocapture` prints the record block: chain, sample rate, window length, -peak index, peak magnitude, floor, SNR, and the peak's offset from the -window centre — the round-trip latency of that chain. The block is -printed before the SNR assertion, so a failing run still leaves its -numbers behind. - -A CPAL equivalent (e.g. via `snd-aloop` or a PipeWire virtual sink) is -deferred until the CPAL routing path is fixed (issue #27). diff --git a/docs/runbooks/rig-headless.md b/docs/runbooks/rig-headless.md deleted file mode 100644 index 06222b53..00000000 --- a/docs/runbooks/rig-headless.md +++ /dev/null @@ -1,29 +0,0 @@ -### Headless rig runbook - -`ac transfer` launches `ac-view`, and `ac plot ir` runs the measurement and -then discards it (`run_ir` waits for `done` and prints nothing — the gap -epic #276 exists to close). Neither is usable over SSH, so a rig session -drives the daemon through two examples that build from the tree: - -```bash -# transfer_stream, two pairs, 20 s, driven at -30 dBFS, frames to JSON Lines -cargo run --release -p ac-daemon --example transfer_probe -- \ - --pairs "2,2;0,2" --seconds 20 --drive-dbfs -30 --out run.jsonl - -# plot_ir, reporting peak, floor, SNR, offset from window centre, and onset -cargo run --release -p ac-daemon --example ir_probe -- \ - --level-dbfs -30 --duration 2.0 --f1 50 --f2 16000 --window 16384 -``` - -Both take `--ctrl-port` / `--data-port` for a daemon on non-default ports; -the `ac` CLI takes `AC_CTRL_PORT` / `AC_DATA_PORT` for the same purpose, and -`ac setup output input ` retargets a **running** daemon so successive -measurements share one client (below). - -`transfer_probe` starts the session `drivable`, so it comes up silent and -`set_drive` raises it, refreshing the 1500 ms dead-man every 250 ms. Without -`--drive-dbfs` the session is passive and opens no output port. -`--level-dbfs` is **mandatory** on `ir_probe`, recording an explicit level -choice for a hardware run. The daemon refuses anything above its fixed -full-scale (0 dBFS) maximum rather than reducing it silently; the rig's stricter -standing −40 dBFS rule still applies to the value you type. diff --git a/docs/runbooks/rig-testing.md b/docs/runbooks/rig-testing.md new file mode 100644 index 00000000..1e425b12 --- /dev/null +++ b/docs/runbooks/rig-testing.md @@ -0,0 +1,275 @@ +# Rig testing runbook + +The one procedure for testing `ac` binaries on real hardware. Everything a +session runs is a script under `scripts/rig/`; this file says which, in what +order, and what each one proves. + +| what | where | +|---|---| +| procedure (this file) | `docs/runbooks/rig-testing.md` | +| interlocks and required record fields | `.agents/rig.md` — hard constraints, step 4 | +| scripts | `scripts/rig/*.sh` (helpers in `scripts/rig/lib/`) | +| per-rig facts | `docs/rigs/.md` | +| per-rig machine profile | `scripts/rig/hosts/.env` | +| record template | `scripts/rig/record-template.md` → `work/rig/-results.md` | +| live queue of what needs a rig | `$AC_HOME/rig-verify-queue.md` | + +## Rigs + +| rig | use | profile | +|---|---|---| +| `pupu` | **default** — audio hardware-in-the-loop: FF400, Genelec 1083, mic at 1 m, electrical reference loopback | `docs/rigs/pupu.md` | +| 192.168.9.25 | real-GPU `ac-view` snapshot tests only | TESTING.md → "A3 snapshot reference currency" | + +A new rig gets both a `docs/rigs/.md` and a `scripts/rig/hosts/.env`. +Its address, user and SSH key go in `$AC_HOME/rig-hosts/.access.env` +(`RIG_HOST`, `RIG_USER`, `RIG_SSH_KEY`), never in this repo. + +## Session order + +Every step names its script. Steps 1–4 emit nothing. Steps marked **EMITS** +need the operator's per-run consent first (step 5). + +### 1. Build — development host + +From a worktree checked out at the commit under test: + +```bash +scripts/rig/build-portable.sh # refuses uncommitted ac-rs/ changes +scripts/rig/build-portable.sh --allow-dirty # records the dirty count instead +``` + +Stages `ac`, `ac-daemon`, `ir_probe`, `transfer_probe` and `it_loopback_ir` +with `MANIFEST.txt` and `SHA256SUMS` in `$AC_HOME/target-rig-stage//`. +What it guarantees, and why each is there: + +- **One cargo target dir per commit** (`$AC_HOME/target-rig-`). A + target dir shared across worktrees can print `Finished` without compiling + and hand back another ref's binaries; the manifest records whether this run + compiled. +- **Portable CPU** (`-C target-cpu=x86-64`). `ac-rs/.cargo/config.toml` builds + for the host CPU; rigs reject that with `SIGILL`. +- **On disk, not `/tmp`.** `/tmp` is RAM-backed; a full target dir fills it + and fails as a link error, not a disk error. +- Never build on a rig — 192.168.9.25 is the development VM's hypervisor host. + +### 2. Ship — development host → rig + +```bash +scripts/rig/ship.sh pupu [rev|latest] [--install] +``` + +Copies the stage to `/-x86_64/` on the rig, **verifies +sha256 on the rig** against `SHA256SUMS`, and links `it_loopback_ir`'s +compile-time daemon path to the shipped daemon (the test spawns +`$CARGO_TARGET_DIR/release/ac-daemon` by absolute path). `--install` stops +any `ac-daemon`, installs `ac` + `ac-daemon` to `/usr/local/bin` and verifies +those by sha256 too. Prints the record's "build under test" block. + +Prefer running staged binaries over installing: the scripts put the stage +directory first on `PATH`, because `ac` looks up `ac-daemon` on `PATH` before +its own directory — a staged `ac` otherwise auto-spawns the installed daemon. +Scripts that spawn a daemon print its `/proc//exe`. + +### 3. Pre-flight — silent + +```bash +scripts/rig/preflight.sh pupu [rev|latest|none] +``` + +JACK service, rate, period and required flags; where the analog capture +block sits (silently — the FF400's port order moves between orders, and the +emitting scripts refuse to run when it is not where the profile says); the +interface's ALSA baseline; the ac config's ceiling and channel map; running daemons; installed +hashes; the staged build's hashes and daemon link; jackd xruns in the last +10 minutes. Exit 1 on any FAIL — fix it, or record why the session proceeds. +It does **not** check wiring; that needs emission (step 6). Its ALSA rows +read the driver's cached control values, not the interface: on pupu a JACK +restart left the FF400 at driver defaults while every ALSA row still +passed. After a restart or power cycle, confirm levels by probe (step 6). + +After a rig power cycle expect the interface baseline to FAIL — the restore +commands are in the rig profile. + +### 4. Noise snapshot — silent, acoustic sessions + +```bash +scripts/rig/noise-snapshot.sh pupu [seconds] +``` + +Room noise differs a lot between sessions; record it before judging any +acoustic SNR. + +### 5. Consent — interlock + +Before the first emitting command: the operator's explicit consent for this +run, naming outputs, stimulus, level and duration (`.agents/rig.md` hard +constraints). Every emitting script requires `--consent ""` and prints +it into its output, and refuses a `--level` above the rig profile's +`RIG_DRIVE_CEILING_DBFS` — or, on any path that drives the speaker, above +`RIG_SPEAKER_CEILING_DBFS` (pupu: −50 dBFS; a −40 dBFS sweep through its +speaker is too loud). These checks are the scripts' own. Since #459 the +daemon has no configurable ceiling: it refuses anything above its fixed +full-scale (0 dBFS) maximum and never clamps, and it refuses every emitting +command while a retired `drive_max_dbfs` key is present in its config. The +rig's −40 dBFS standing limit and the speaker ceiling apply to the value you +type; `it_loopback_ir` enforces the standing limit itself on the real-port +route — see step 7c. + +### 6. Wiring probe — EMITS, after any cable work + +```bash +scripts/rig/probe-outputs.sh pupu --level -60 --consent "…" [--outputs "0 1"] +``` + +A bounded 1 kHz tone per output, all analog captures recorded; the table +shows which input sees which output. A stated layout is not evidence — on +pupu it was the reverse of the cabling once. + +### 7. Tests + +#### 7a. Workspace suite — development host + +`cargo test --workspace` and friends: TESTING.md. No hardware. + +#### 7b. Loopback IR without hardware — development host + +`it_loopback_ir` exercises `plot_ir`'s real-audio path +(`JackEngine::play_and_capture`). With no port variables it self-connects +the daemon's JACK output to its input, so a dummy JACK server is enough: + +```bash +jackd -d dummy -r 48000 -p 1024 & +cd ac-rs && cargo test -p ac-daemon --test it_loopback_ir -- --ignored --nocapture +``` + +It runs a 2.0 s Farina sweep, deconvolves, and asserts a dominant peak at +least 25 dB above the pre-impulse floor, at or after the gate centre and +within a 60 ms round-trip margin (`MAX_ROUND_TRIP_S`, `SNR_FLOOR_DB` in the +test, #341). The 2.0 s is load-bearing: shorter sweeps shrink the window the +round-trip bound is measured in, and below ~1.0 s it can no longer hold 60 ms +at all (#361). This route touches no converter — it tests the ring and the +deconvolution, not an interface. + +#### 7c. Loopback IR through real ports — EMITS + +```bash +scripts/rig/run-loopback-ir.sh pupu --level -40 --consent "…" [--route ref|speaker] +``` + +Runs the staged test with `AC_LOOPBACK_OUT` / `AC_LOOPBACK_IN` set to the +rig profile's reference loopback (default) or speaker → mic, and +`AC_LOOPBACK_LEVEL_DBFS` from `--level`. The printed record block carries the +chain, sample rate, window, peak index and magnitude, floor, SNR and the +peak's offset from window centre — the chain's round trip. It is printed +before the assertions, so a failing run still leaves its numbers. + +The test spawns its daemon under an isolated `HOME` whose config carries no +`drive_max_dbfs` key (#459). On the real-port route it refuses a level above +the rig's standing −40 dBFS limit before it starts the daemon; the daemon +itself refuses anything above full scale and never clamps. The script's +`--level` check is a convenience in front of the test's refusal, and the only +limit for the speaker route's lower −50 dBFS ceiling, which neither the test +nor the daemon can tell apart from the loopback. The 60 ms +round-trip bound was derived on a Babyface chain (#277); check a new chain's +measured offset against it before reading a red result as a defect. The +speaker route can fail the electrical-chain assertions for acoustic reasons — +read the numbers. + +Manual form, when a script is not the right tool: set the three variables +together (one alone panics — half a route is a route through the wrong thing) +and run `/it_loopback_ir --ignored --nocapture --test-threads=1` from +the rig. + +#### 7d. Acoustic IR, headless — EMITS through the speaker + +```bash +scripts/rig/acoustic-ir.sh pupu --level -50 --consent "…" --mic-position "1 m on axis" +``` + +`ac plot ir` runs the measurement and prints nothing (epic #276), so the +script drives `plot_ir` through the staged `ir_probe`, routed with `ac setup` +to the rig's speaker and mic, and reports peak index, magnitude, pre-impulse +floor, SNR, offset from window centre and onset. + +`--tau-ms` changes only the `peak arrival` line: it prints the peak offset +minus τ. That difference means something only when τ is itself a peak pick +for the same channel pair (a loopback τ is a different pair; see +`docs/rigs/pupu.md`). The onset line is printed with nothing subtracted, and +`ir_probe` never subtracts τ from it. + +On a multi-way speaker the IR peak is not the arrival; the onset is the +better arrival estimate. Do not difference that onset by hand against +`--tau-ms`, a stored `calibrate` τ, or a loopback peak such as pupu's +17.99 ms. An onset pairs only with a τ picked by the same onset rule from the +same capture's reference leg (#460), and `ir_probe` captures no reference leg. +Onsets do not cancel across sweep bands the way peaks do. The pairing rule is +in `ac-core/src/measurement/sweep/peak.rs` (#479). + +#### 7e. Transfer function, headless — EMITS when driven + +No wrapper yet. `ac transfer` launches `ac-view`, so a headless session uses +the staged `transfer_probe` against a daemon started from the staged build +(prefix `PATH` with the stage dir, as the scripts do): + +```bash +/transfer_probe --pairs "0,1" --seconds 20 --drive-dbfs -50 --out run.jsonl +``` + +`--pairs` is `meas,ref` capture indices (`0,1` = mic against the reference +loopback on pupu). It starts the session `drivable` (silent), raises drive +with `set_drive`, feeds the 1500 ms dead-man every 250 ms and drops drive on +every exit path; without `--drive-dbfs` it is passive and opens no output. +It prints the level the daemon reported. The daemon refuses only a level +above full scale, and this drives the speaker, so keeping `--drive-dbfs` at or +below the speaker ceiling is on whoever runs it. Both probes take `--ctrl-port` / +`--data-port`; `ac` takes `AC_CTRL_PORT` / `AC_DATA_PORT`, and `ac setup +output input ` retargets a running daemon. + +#### 7f. xrun soak — silent + +```bash +scripts/rig/xrun-soak.sh pupu [seconds] +``` + +A capture-only `ac monitor --tui` on every analog input; counts jackd xrun +log lines and the daemon's own counter. An idle JACK shows no xruns — the +load is the point. Exit 1 on any xrun. + +#### 7g. `ac-view` snapshot references — 192.168.9.25 + +TESTING.md → "A3 snapshot reference currency". + +### 8. Record + +Copy `scripts/rig/record-template.md` to `work/rig/-results.md` and +paste each script's output block verbatim. Required fields and when to start +a new file: `.agents/rig.md` step 4 and "where records live". Mark executed +blocks in `$AC_HOME/rig-verify-queue.md` and commit there — nothing commits +`$AC_HOME` automatically. + +### 9. Leave the rig + +The emitting scripts restore the ac config's channels and stop the daemons +they started. Rerun `preflight.sh` at the end and paste it as "rig state +left behind", with the mic position and anything deliberately left changed. + +## Traps that have cost sessions + +- **sha256, never size or mtime** — identical size and mtime have been a + different binary. +- **Identical hashes across refs that should differ** means nothing was + rebuilt (shared target dir). Differing hashes across refs are normal — + absolute paths are baked into binaries. +- **`generate sine` / `generate pink` run until `ac stop`.** Never background + one; use the bounded commands (`generate level`, `generate frequency`, + `plot level`, `plot ir`). +- **`ac monitor` needs `--tui` headless**; without it, it wants `ac-view`. +- **An auto-spawned daemon's stderr goes to `/dev/null`**, so an analysis + error can show as an empty result table. +- **JACK aliases are not evidence of physical ports** — probe by emission. +- **The FF400's JACK port order moves** (ADAT block first or last). A profile + that was right yesterday can point every port name at ADAT today; the + scripts check, a manual command does not. +- **A window edge imitates a latency**: an arrival outside the analysis + window returns a stable, repeatable, wrong number. diff --git a/docs/superseded/rig-babyface-audio.md b/docs/superseded/rig-babyface-audio.md new file mode 100644 index 00000000..d376d9f5 --- /dev/null +++ b/docs/superseded/rig-babyface-audio.md @@ -0,0 +1,38 @@ +# Superseded: Babyface Pro audio rig setup (192.168.9.25) + +**Superseded 2026-09-14.** Audio hardware-in-the-loop testing moved to the +dedicated rig `pupu` (`docs/rigs/pupu.md`, procedure in +`docs/runbooks/rig-testing.md`). 192.168.9.25 stays in use only as the +real-GPU box for the `ac-view` snapshot tests (TESTING.md → "A3 snapshot +reference currency"). Kept because the historical records in `work/rig/` and +`audit/rig-*` were measured on this setup and cite it. + +Removed from `.agents/rig.md`, where it was the rig role's default: + +- RME Babyface Pro. Speaker on ADAT1/AS1 out (`playback_5`), mic on AN1 + (`capture_1`), electrical loopback reference out AN2 (`playback_2`) into + IN4 (`capture_4`). +- Normally not connected but reserved: loopback through the master converter + out ADAT3 (`playback_7`) into IN3 (`capture_3`); master analogue section + loopback with converter out AS1 (`playback_5`) into AN2 (`capture_2`). +- Interface clock had to stay `AutoSync` (`numid=320 = 0`): an external + master clocked the card over ADAT, which carried the stimulus leg; setting + `Internal` silently broke the speaker path rather than erroring. +- Pre-flight mixer state it set without confirming: + + ``` + amixer -c0 cset numid=1 0 # dont monitor mic -> AN1 + amixer -c0 cset numid=14 0 # dont monitor mic -> AN2 + amixer -c0 cset numid=301 36 # mic input gain (36=max) + amixer -c0 cset numid=295 46341 # playback_7 output level + amixer -c0 cset numid=293 16384 # playback_5 output level + amixer -c0 cset numid=294 16384 # playback_6 output level + amixer -c0 cset numid=308 0 # IN4/capture_4 level (no gain) + amixer -c0 cset numid=307 0 # IN3/capture_3 level (no gain) + amixer -c0 cset numid=302 1 # AN1/capture_1 mic input 48V on + amixer -c0 cset numid=305 0 # AN2/capture_2 mic input 48V off + amixer -c0 cset numid=289 16384 # AN1/playback_1 output level + amixer -c0 cset numid=290 16384 # AN2/playback_2 output level + ``` + +- 192.168.9.25 is the development VM's hypervisor host: never build there. diff --git a/scripts/rig/acoustic-ir.sh b/scripts/rig/acoustic-ir.sh new file mode 100755 index 00000000..ed79113f --- /dev/null +++ b/scripts/rig/acoustic-ir.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# acoustic-ir.sh — EMITS through the speaker. One headless `plot_ir` via the +# staged `ir_probe`: speaker out -> mic in, reporting peak, floor, SNR, offset +# from window centre and onset. +# +# scripts/rig/acoustic-ir.sh --level --consent "" +# [--rev |latest] [--duration 2.0] [--f1 50] [--f2 16000] +# [--window 16384] [--tau-ms ] [--mic-position ""] +# +# --tau-ms is subtracted from the peak line only, and is meaningful only as a +# peak-picked τ for the same channel pair. Never subtract it, or any stored or +# loopback τ, from the onset line (pairing rule, ac-core sweep/peak.rs, #479). +# +# Uses a daemon spawned from the staged build (identity printed), routed with +# `ac setup` to the rig's speaker and mic indices. A failed setup, or a +# persisted config that does not read those indices right before `ir_probe`, +# refuses with nothing emitted and says so in the record. This script's +# --level check, which holds the profile's speaker ceiling +# (RIG_SPEAKER_CEILING_DBFS), is the only limit below 0 dBFS on this path: +# since #459 the daemon has no configurable ceiling, refuses only above full +# scale, never clamps, and refuses every emitting command while a retired +# drive_max_dbfs key is in its config. The ac config's channels are restored +# on exit. +# +# Procedure: docs/runbooks/rig-testing.md. + +source "$(dirname "$0")/lib.sh" + +load_rig "${1:-}" +shift || true +level="" consent="" rev=latest duration=2.0 f1=50 f2=16000 window=16384 tau="" pos="not stated" +while (($#)); do + case $1 in + --level) level=${2:?}; shift 2 ;; + --consent) consent=${2:?}; shift 2 ;; + --rev) rev=${2:?}; shift 2 ;; + --duration) duration=${2:?}; shift 2 ;; + --f1) f1=${2:?}; shift 2 ;; + --f2) f2=${2:?}; shift 2 ;; + --window) window=${2:?}; shift 2 ;; + --tau-ms) tau=${2:?}; shift 2 ;; + --mic-position) pos=${2:?}; shift 2 ;; + *) die "unknown argument $1" ;; + esac +done +require_consent "$consent" +require_level "$level" "$(speaker_ceiling)" +require_port_order +rev="$(resolve_rev "$rev")" +dest="$(rig_dest "$rev")" +run="$(rig_run_dir acoustic-ir)" + +echo "### acoustic IR ($RIG_NAME)" +echo +echo "- consent: $consent" +echo "- stimulus: Farina sweep ${f1}–${f2} Hz, ${duration} s, $level dBFS requested (acknowledged level: ir_probe's \`level:\` line), $RIG_SPEAKER_OUT_PORT -> $RIG_MIC_IN_PORT" +echo "- mic position: $pos" +echo "- build: $dest" +echo "- artefacts on rig: $run" +echo + +tau_arg="" +[[ -n $tau ]] && tau_arg="--tau-ms $tau" + +# acoustic_ir_remote.sh is a real file, not an inline heredoc, so the same +# text that runs here also runs under acoustic_ir_remote_test.sh's stubs. +remote="set -eu +$REMOTE_USE_BUILD +$(cat "$RIG_SCRIPTS/lib/acoustic_ir_remote.sh")" +rig_bash "DEST=$(printf %q "$dest") RUN=$(printf %q "$run") LEVEL=$(printf %q "$level") \ +DUR=$(printf %q "$duration") F1=$(printf %q "$f1") F2=$(printf %q "$f2") WIN=$(printf %q "$window") \ +TAU=$(printf %q "$tau_arg") SPK=$(printf %q "$RIG_SPEAKER_OUT_INDEX") MIC=$(printf %q "$RIG_MIC_IN_INDEX")" "$remote" diff --git a/scripts/rig/acoustic_ir_remote_test.sh b/scripts/rig/acoustic_ir_remote_test.sh new file mode 100644 index 00000000..241ba90e --- /dev/null +++ b/scripts/rig/acoustic_ir_remote_test.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# acoustic_ir_remote_test.sh — regression test for +# scripts/rig/lib/acoustic_ir_remote.sh, the routing + ir_probe block +# acoustic-ir.sh runs on the rig. No rig, no JACK, no real ac-daemon: stub +# `ac` on PATH and a stub `ir_probe` in a fake staged build, and the exact +# same assembly (set -eu + REMOTE_USE_BUILD + the file's own text) +# acoustic-ir.sh sends over ssh, run locally with `bash -c` instead. +# +# bash scripts/rig/acoustic_ir_remote_test.sh +# +# PR #441 codex-qa finding this covers (35ddb7a1): `ac setup`'s status was +# discarded and the route never read back, so ir_probe could emit through a +# previous run's output/input while the record named the requested ones. +# A failed setup and a lying one (exit 0, config not updated) must each stop +# before ir_probe runs and say so in the output; the ok path must still run. +# A lying setup must also be caught when the channels already match but a +# sticky output_port remains, since the daemon routes by that port first +# (PR #441 QA, 5982e3e5). +# +# Procedure these scripts implement: docs/runbooks/rig-testing.md. + +source "$(dirname "$0")/lib.sh" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +bindir="$tmp/bin" +homedir="$tmp/home" +dest="$tmp/stage" +mkdir -p "$bindir" "$homedir/.config/ac" "$dest" + +cat >"$bindir/ac" <<'EOF' +#!/usr/bin/env bash +# Stub `ac setup output input `. STUB_SETUP_MODE: ok (updates the +# config and clears the sticky ports, as the daemon's setup does) | +# fail (exit 1) | lie (exit 0, config left untouched). +cfg="$HOME/.config/ac/config.json" +if [[ $1 == setup && $2 == output && $4 == input ]]; then + case "${STUB_SETUP_MODE:-ok}" in + fail) echo "error: no reply from daemon" >&2; exit 1 ;; + lie) exit 0 ;; + *) python3 -c ' +import json, sys +d = json.load(open(sys.argv[1])) +d["output_channel"] = int(sys.argv[2]) +d["input_channel"] = int(sys.argv[3]) +d["output_port"] = None +d["input_port"] = None +json.dump(d, open(sys.argv[1], "w")) +' "$cfg" "$3" "$5" ;; + esac +fi +exit 0 +EOF +chmod +x "$bindir/ac" + +# REMOTE_USE_BUILD and cleanup pkill/pgrep ac-daemon by name — stubbed so a +# test run never touches a real daemon on the machine running it. +printf '#!/bin/sh\nexit 1\n' >"$bindir/pkill" +printf '#!/bin/sh\nexit 1\n' >"$bindir/pgrep" +chmod +x "$bindir/pkill" "$bindir/pgrep" + +# ir_probe lives in the staged build (called as "$DEST/ir_probe") and leaves a +# marker, so "did anything emit" is a file check, not an output grep. +cat >"$dest/ir_probe" <"$tmp/ir_probe-ran" +echo "stub ir_probe: peak ok" +EOF +chmod +x "$dest/ir_probe" +(cd "$dest" && sha256sum ir_probe >SHA256SUMS) + +# run_ir +run_ir() { + rm -rf "$tmp/run" "$tmp/ir_probe-ran" + printf '%s\n' "$2" >"$homedir/.config/ac/config.json" + HOME="$homedir" PATH="$bindir:$PATH" STUB_SETUP_MODE="$1" \ + DEST="$dest" RUN="$tmp/run" LEVEL=-60 DUR=2.0 F1=50 F2=16000 WIN=16384 TAU="" SPK=0 MIC=0 \ + bash -c "set -eu +$REMOTE_USE_BUILD +$(cat "$(dirname "$0")/lib/acoustic_ir_remote.sh")" +} + +fail() { echo "FAIL: $1"; exit 1; } + +# Requests 0/0 while the config starts at 1/1 (a previous run's route), so +# "lie" leaves a real disagreement for the read-back to catch. The key set +# matches what the daemon writes (ports serialise as null). +prev_route='{"output_channel": 1, "input_channel": 1, "output_port": null, "input_port": null}' +# Channels already match the request, but an earlier manual run pinned an +# output port — resolve_output routes by that port first. +sticky_port='{"output_channel": 0, "input_channel": 0, "output_port": "system:playback_9", "input_port": null}' + +out="$(run_ir ok "$prev_route" 2>&1)" && rc=0 || rc=$? +[[ $rc == 0 ]] || fail "setup=ok: expected exit 0, got $rc — output: $out" +[[ -f $tmp/ir_probe-ran ]] || fail "setup=ok: ir_probe should have run" +[[ $out == *"config reads output 0 / input 0, no sticky port, as requested"* ]] || fail "setup=ok: route not recorded — output: $out" +[[ $out == *"stub ir_probe: peak ok"* ]] || fail "setup=ok: ir_probe output missing from the record" +echo "setup=ok: route read back, ir_probe runs, exit 0" + +out="$(run_ir fail "$prev_route" 2>&1)" && rc=0 || rc=$? +[[ $rc != 0 ]] || fail "setup=fail: expected nonzero exit" +[[ -f $tmp/ir_probe-ran ]] && fail "setup=fail: ir_probe must not run" +[[ $out == *"routing: REFUSED"*"exited 1"* ]] || fail "setup=fail: refusal missing from the record — output: $out" +[[ $out == *"no reply from daemon"* ]] || fail "setup=fail: setup's own error missing from the record" +echo "setup=fail: refuses before ir_probe, exit $rc" + +out="$(run_ir lie "$prev_route" 2>&1)" && rc=0 || rc=$? +[[ $rc != 0 ]] || fail "setup=lie: expected nonzero exit" +[[ -f $tmp/ir_probe-ran ]] && fail "setup=lie: ir_probe must not run" +[[ $out == *"routing: REFUSED — requested output 0 / input 0, config reads output 1 / input 1"* ]] || + fail "setup=lie: read-back refusal missing from the record — output: $out" +echo "setup=lie: caught by the config read-back, exit $rc" + +out="$(run_ir ok "$sticky_port" 2>&1)" && rc=0 || rc=$? +[[ $rc == 0 ]] || fail "sticky port, setup=ok: an applied setup clears the port — expected exit 0, got $rc — output: $out" +[[ -f $tmp/ir_probe-ran ]] || fail "sticky port, setup=ok: ir_probe should have run" +echo "sticky output_port, setup=ok: cleared by setup, ir_probe runs, exit 0" + +out="$(run_ir lie "$sticky_port" 2>&1)" && rc=0 || rc=$? +[[ $rc != 0 ]] || fail "sticky port, setup=lie: expected nonzero exit — output: $out" +[[ -f $tmp/ir_probe-ran ]] && fail "sticky port, setup=lie: ir_probe must not run" +[[ $out == *"routing: REFUSED"*"config still pins output_port system:playback_9 / input_port None"* ]] || + fail "sticky port, setup=lie: port refusal missing from the record — output: $out" +echo "sticky output_port with matching channels, setup=lie: refused, exit $rc" + +echo "acoustic_ir_remote.sh: all cases as expected" diff --git a/scripts/rig/build-portable.sh b/scripts/rig/build-portable.sh new file mode 100755 index 00000000..ce07e875 --- /dev/null +++ b/scripts/rig/build-portable.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# build-portable.sh — build rig-portable `ac` binaries for the checked-out +# commit and stage them with a sha256 manifest. +# +# Run on the development host, from any worktree. Never build on a rig. +# +# scripts/rig/build-portable.sh [--allow-dirty] +# +# Stages into $AC_HOME/target-rig-stage//: +# ac ac-daemon ir_probe transfer_probe it_loopback_ir +# MANIFEST.txt SHA256SUMS build.log test-build.log +# +# Procedure: docs/runbooks/rig-testing.md. + +source "$(dirname "$0")/lib.sh" + +allow_dirty=0 +case ${1:-} in + --allow-dirty) allow_dirty=1 ;; + "") ;; + *) die "usage: $0 [--allow-dirty]" ;; +esac + +top="$(git rev-parse --show-toplevel)" +rev_full="$(git -C "$top" rev-parse HEAD)" +rev="${rev_full:0:12}" +# Untracked files count too — Cargo builds from the working filesystem, not +# just the index, so an untracked build.rs or source file can change the +# compiled binary without a tracked diff to show for it. --untracked-files +# defaults to "normal" (one line per untracked file, or per untracked dir) +# and still respects .gitignore, so target/ stays out of the count. +dirty="$(git -C "$top" status --porcelain -- ac-rs | wc -l)" +if ((dirty > 0 && allow_dirty == 0)); then + die "ac-rs/ has $dirty uncommitted change(s) — commit them, or pass --allow-dirty (recorded in the manifest)" +fi + +stage="$(stage_root)/$rev" +artefacts=(ac ac-daemon ir_probe transfer_probe it_loopback_ir) +mkdir -p "$stage" +# A failed earlier run must not leave old binaries beside a new manifest. +(cd "$stage" && rm -f "${artefacts[@]}" MANIFEST.txt SHA256SUMS build.log test-build.log test-build.jsonl) + +# One target dir per commit. A target dir shared across worktrees can report +# `Finished` without compiling and hand back another ref's binaries. +export CARGO_TARGET_DIR="${RIG_TARGET_DIR:-$(ac_home)/target-rig-$rev}" + +# Portable CPU baseline — rigs reject target-cpu=native builds with SIGILL. +# RUSTFLAGS replaces ac-rs/.cargo/config.toml's rustflags wholesale, so the +# mold linker flag is restated here. +export RUSTFLAGS="-C target-cpu=x86-64 -C link-arg=-fuse-ld=mold" + +cd "$top/ac-rs" || die "no ac-rs/ under $top" +note "building $rev (dirty files: $dirty) into $CARGO_TARGET_DIR" + +# --bins as well as --examples: a target-selection flag alone builds only +# the targets it names, so `--examples` by itself silently skips `ac`. +if ! cargo build --release -p ac-cli -p ac-daemon --bins --examples >"$stage/build.log" 2>&1; then + tail -30 "$stage/build.log" >&2 + die "cargo build failed — full log $stage/build.log" +fi +if ! cargo test --release -p ac-daemon --test it_loopback_ir --no-run \ + --message-format=json >"$stage/test-build.jsonl" 2>"$stage/test-build.log"; then + tail -30 "$stage/test-build.log" >&2 + die "it_loopback_ir build failed — full log $stage/test-build.log" +fi + +# build_compiled_this_run (lib.sh) covers any workspace crate, not just +# ac-daemon — a rebuild that only touches ac-cli or an ac-core change that +# never reaches the daemon's inputs is still a real rebuild, and +# compiled_this_run must not read "no" for it. +compiled="$(build_compiled_this_run "$stage/build.log" "$stage/test-build.log")" + +itbin="$(python3 - "$stage/test-build.jsonl" <<'EOF' +import json, sys +exes = [] +for line in open(sys.argv[1]): + if not line.startswith("{"): + continue + e = json.loads(line).get("executable") + if e and "/it_loopback_ir-" in e: + exes.append(e) +print(exes[-1] if exes else "") +EOF +)" +[[ -n $itbin ]] || die "no it_loopback_ir executable in cargo's JSON output" + +rel="$CARGO_TARGET_DIR/release" +for f in "$rel/ac" "$rel/ac-daemon" "$rel/examples/ir_probe" "$rel/examples/transfer_probe" "$itbin"; do + [[ -x $f ]] || die "expected build output missing: $f" +done +cp -f "$rel/ac" "$rel/ac-daemon" "$rel/examples/ir_probe" "$rel/examples/transfer_probe" "$stage/" +cp -f "$itbin" "$stage/it_loopback_ir" + +# it_loopback_ir spawns the daemon at its compile-time path +# (env!("CARGO_BIN_EXE_ac-daemon")). ship.sh recreates that path on the rig. +daemon_path="$rel/ac-daemon" +grep -aqF "$daemon_path" "$stage/it_loopback_ir" || + die "it_loopback_ir does not embed $daemon_path — cargo changed how it locates the daemon; fix ship.sh before shipping" + +(cd "$stage" && sha256sum "${artefacts[@]}" >SHA256SUMS) + +cat >"$stage/MANIFEST.txt" <"$tmp/ac-cli-only.log" <<'LOG' + Compiling ac-cli v0.2.0 (/x/ac-rs/crates/ac-cli) + Finished release [optimized] target(s) in 4.10s +LOG +check "ac-cli-only rebuild" yes "$tmp/ac-cli-only.log" + +# The original, still-covered case: ac-daemon itself compiled. +cat >"$tmp/ac-daemon.log" <<'LOG' + Compiling ac-daemon v0.2.0 (/x/ac-rs/crates/ac-daemon) + Finished release [optimized] target(s) in 4.10s +LOG +check "ac-daemon rebuild" yes "$tmp/ac-daemon.log" + +# A genuine cache hit: nothing recompiled. +cat >"$tmp/cache-hit.log" <<'LOG' + Finished release [optimized] target(s) in 0.02s +LOG +check "cache hit" no "$tmp/cache-hit.log" + +echo "build-portable.sh compiled_this_run: all cases as expected" diff --git a/scripts/rig/hosts/pupu.env b/scripts/rig/hosts/pupu.env new file mode 100644 index 00000000..8aa7830b --- /dev/null +++ b/scripts/rig/hosts/pupu.env @@ -0,0 +1,52 @@ +# Rig profile: pupu — the dedicated audio HIL rig. +# Physical facts behind every value here: docs/rigs/pupu.md. +# Sourced by scripts/rig/lib.sh (bash), so arrays are allowed. + +# RIG_HOST, RIG_USER and RIG_SSH_KEY are private: load_rig (lib.sh) sources +# them from $AC_HOME/rig-hosts/pupu.access.env. + +# Commit-staged binaries land in $RIG_STAGE_BASE/-x86_64/. +RIG_STAGE_BASE=/home/mui/ac-test + +# Standing emission ceiling (nominal dBFS). Scripts refuse to request more. +RIG_DRIVE_CEILING_DBFS=-40 + +# Lower ceiling for anything driving the speaker (operator, 2026-09-14: a +# -40 dBFS sweep through the 1083 is too loud). Script-enforced only: since +# #459 the daemon has no configurable ceiling (it refuses only above 0 dBFS +# and never clamps), so these two values are the only limits below full scale. +RIG_SPEAKER_CEILING_DBFS=-50 + +# JACK as persisted in jack-ac.service's drop-in. +RIG_JACK_RATE=96000 +RIG_JACK_PERIOD=256 +RIG_JACK_REQUIRED_FLAGS="-S" + +# FF400 baseline, ALSA card by name (index can move between boots). +RIG_ALSA_CARD=Fireface400 +RIG_ALSA_EXPECT=(93=2 94=2 89=2 81=20,0 82=0,0 90=on,off 102=on) +RIG_MIC_GAIN_NUMID=81 +RIG_PHANTOM_NUMID=90 + +# JACK port order is NOT stable on this FF400: the ADAT/S/PDIF block sometimes +# comes before the analog block and sometimes after (operator: expected for +# now). Everything below assumes analog first. preflight.sh and every +# emitting script check silently where the analog capture block is and refuse +# when it is not at RIG_ANALOG_CAPTURE_FIRST. +RIG_ANALOG_CAPTURE_FIRST=1 + +# Wiring (verified by emission; re-probe after any cable work). +RIG_SPEAKER_OUT_PORT=system:playback_1 # ac output 0, AN1 -> Genelec 1083 +RIG_REF_OUT_PORT=system:playback_2 # ac output 1, AN2 -> cable -> IN2 +RIG_MIC_IN_PORT=system:capture_1 # ac input 0, IN1 mic, phantom on +RIG_REF_IN_PORT=system:capture_2 # ac input 1, IN2 loopback, phantom off +RIG_ANALOG_CAPTURES=8 # capture_1..8 are AN1..AN8 + +# The same map as zero-based `ac setup` indices. +RIG_SPEAKER_OUT_INDEX=0 +RIG_REF_OUT_INDEX=1 +RIG_MIC_IN_INDEX=0 +RIG_REF_IN_INDEX=1 + +# Expected ~/.config/ac/config.json values on the rig. +RIG_AC_CONFIG_EXPECT=(output_channel=0 input_channel=0 reference_output_channel=1 reference_channel=1) diff --git a/scripts/rig/lib.sh b/scripts/rig/lib.sh new file mode 100755 index 00000000..9f6b6756 --- /dev/null +++ b/scripts/rig/lib.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# lib.sh — shared by scripts/rig/*. Source, do not execute. +# +# Procedure these scripts implement: docs/runbooks/rig-testing.md. + +set -euo pipefail + +RIG_SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +die() { + echo "error: $*" >&2 + exit 1 +} + +note() { echo "== $*" >&2; } + +# $AC_HOME, resolved the same way bin/common.sh does: beside the MAIN +# checkout, from anywhere — including inside a linked worktree. +ac_home() { + if [[ -n ${AC_HOME:-} ]]; then + echo "$AC_HOME" + return + fi + local common + common="$(git rev-parse --path-format=absolute --git-common-dir)" + echo "$(dirname "$(dirname "$common")")/ac-wt" +} + +# Where build-portable.sh stages binaries and ship.sh reads them from. +# Named target-* so $AC_HOME's .gitignore (`target-*/`) keeps binaries out of +# that repo. +stage_root() { echo "$(ac_home)/target-rig-stage"; } + +# load_rig — source scripts/rig/hosts/.env, then the private +# $AC_HOME/rig-hosts/.access.env (address, user, SSH key). +load_rig() { + local name=${1:-} + [[ -n $name ]] || die "rig name required — one of: $(ls "$RIG_SCRIPTS/hosts" | sed 's/\.env$//' | tr '\n' ' ')" + local f="$RIG_SCRIPTS/hosts/$name.env" + [[ -f $f ]] || die "no host profile $f" + # shellcheck source=/dev/null + source "$f" + RIG_NAME=$name + local access + access="$(ac_home)/rig-hosts/$name.access.env" + [[ -f $access ]] || die "no access file $access — it sets RIG_HOST, RIG_USER and RIG_SSH_KEY" + # shellcheck source=/dev/null + source "$access" + : "${RIG_HOST:?$access must set RIG_HOST}" "${RIG_USER:?$access must set RIG_USER}" \ + "${RIG_SSH_KEY:?$access must set RIG_SSH_KEY}" "${RIG_STAGE_BASE:?$f must set RIG_STAGE_BASE}" +} + +rig_ssh() { + ssh -F /dev/null -o BatchMode=yes -o ConnectTimeout=10 -i "$RIG_SSH_KEY" \ + "$RIG_USER@$RIG_HOST" "$@" +} + +rig_scp() { + scp -F /dev/null -o BatchMode=yes -o ConnectTimeout=10 -i "$RIG_SSH_KEY" "$@" +} + +# rig_bash "" "