From 030a4a88c8bbf2288edbaa906e58de056e2f08dd Mon Sep 17 00:00:00 2001 From: Markus Kovero Date: Mon, 14 Sep 2026 19:05:03 +0000 Subject: [PATCH 01/18] docs(rig): one runbook and scripts for testing binaries on a rig Rig testing was described in six places that disagreed: .agents/rig.md hardcoded the Babyface box, both runbooks said plot_ir ignores drive_max_dbfs (untrue since #360), and TESTING.md and it_loopback_ir.rs pointed at an ARCHITECTURE.md section that no longer exists. No script covered building portable binaries, shipping them, or checking a rig. - docs/runbooks/rig-testing.md: the one procedure; absorbs loopback-ir.md and rig-headless.md. - scripts/rig/: build-portable, ship (sha256 verified on the rig), preflight, noise-snapshot, probe-outputs, run-loopback-ir, acoustic-ir, xrun-soak and a record template; per-rig profile in hosts/pupu.env, including a -50 dBFS ceiling for anything that drives the speaker. - docs/rigs/pupu.md: facts for the dedicated rig, now the default. The Babyface audio setup moves to docs/superseded/; 192.168.9.25 stays for the ac-view GPU snapshot tests. - .agents/rig.md runs pre-flight through the scripts; qa.md, TESTING.md and README point at the runbook. - it_loopback_ir.rs, ir_probe.rs: fix the stale pointers and the drive-ceiling text (comments and panic messages only). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TmxMmDBeKtES1YyvE5dyDK --- .agents/qa.md | 2 +- .agents/rig.md | 80 +++--- README.md | 6 +- TESTING.md | 2 +- ac-rs/crates/ac-daemon/examples/ir_probe.rs | 9 +- .../crates/ac-daemon/tests/it_loopback_ir.rs | 31 ++- docs/rigs/pupu.md | 159 +++++++++++ docs/runbooks/loopback-ir.md | 55 ---- docs/runbooks/rig-headless.md | 28 -- docs/runbooks/rig-testing.md | 249 ++++++++++++++++++ docs/superseded/rig-babyface-audio.md | 38 +++ scripts/rig/acoustic-ir.sh | 84 ++++++ scripts/rig/build-portable.sh | 111 ++++++++ scripts/rig/hosts/pupu.env | 44 ++++ scripts/rig/lib.sh | 131 +++++++++ scripts/rig/lib/bands.py | 52 ++++ scripts/rig/lib/chan_levels.py | 47 ++++ scripts/rig/noise-snapshot.sh | 33 +++ scripts/rig/preflight.sh | 100 +++++++ scripts/rig/probe-outputs.sh | 86 ++++++ scripts/rig/record-template.md | 59 +++++ scripts/rig/run-loopback-ir.sh | 77 ++++++ scripts/rig/ship.sh | 72 +++++ scripts/rig/xrun-soak.sh | 53 ++++ 24 files changed, 1455 insertions(+), 153 deletions(-) create mode 100644 docs/rigs/pupu.md delete mode 100644 docs/runbooks/loopback-ir.md delete mode 100644 docs/runbooks/rig-headless.md create mode 100644 docs/runbooks/rig-testing.md create mode 100644 docs/superseded/rig-babyface-audio.md create mode 100755 scripts/rig/acoustic-ir.sh create mode 100755 scripts/rig/build-portable.sh create mode 100644 scripts/rig/hosts/pupu.env create mode 100755 scripts/rig/lib.sh create mode 100644 scripts/rig/lib/bands.py create mode 100644 scripts/rig/lib/chan_levels.py create mode 100755 scripts/rig/noise-snapshot.sh create mode 100755 scripts/rig/preflight.sh create mode 100755 scripts/rig/probe-outputs.sh create mode 100644 scripts/rig/record-template.md create mode 100755 scripts/rig/run-loopback-ir.sh create mode 100755 scripts/rig/ship.sh create mode 100755 scripts/rig/xrun-soak.sh diff --git a/.agents/qa.md b/.agents/qa.md index 75e15852..17dbf280 100644 --- a/.agents/qa.md +++ b/.agents/qa.md @@ -241,7 +241,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, only a human clear it diff --git a/.agents/rig.md b/.agents/rig.md index 2e886f91..d1c7c77d 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 @@ -37,10 +34,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 @@ -58,34 +56,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 — obtain emission consent No drive/emission proceeds without **explicit per-run operator consent**, @@ -94,6 +79,7 @@ obtained before this session's first stimulus command — `set_drive on`, `sweep_frequency`, or `calibrate` all put a signal on a physical output (#360 closed the gap where `plot_ir` and `calibrate` did not honour `drive_max_dbfs`; do not read this list as still narrower than the code). +The emitting scripts take the consent as `--consent ""` and print it. See hard constraints below for the ceiling and its exception mechanism. Record what was consented to (ceiling, duration if bounded) in the resulting file. @@ -112,14 +98,15 @@ 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). - **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 @@ -171,12 +158,13 @@ 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. ## 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 @@ -184,8 +172,8 @@ Interlocks. A session may not proceed past these — not guidance, blocking: 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 b949afa7..5b40853b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -50,7 +50,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 035af377..0f6f56e1 100644 --- a/ac-rs/crates/ac-daemon/examples/ir_probe.rs +++ b/ac-rs/crates/ac-daemon/examples/ir_probe.rs @@ -15,9 +15,8 @@ //! ir_probe --level-dbfs -30 --duration 2.0 --f2 16000 --window 16384 //! ``` //! -//! `--level-dbfs` is required. `plot_ir` does not apply the config's -//! `drive_max_dbfs` ceiling — only `set_drive` does — so the value passed -//! here is the only limit on what reaches the interface. +//! `--level-dbfs` is required, so the record names the level requested. +//! `plot_ir` also clamps it to the running daemon's `drive_max_dbfs` (#360). use std::time::{Duration, Instant}; @@ -84,8 +83,8 @@ fn parse_args() -> Args { } assert!( a.level_dbfs.is_finite(), - "--level-dbfs is required: plot_ir does not apply drive_max_dbfs, so \ - this value is the only ceiling on what reaches the interface" + "--level-dbfs is required: name the level for the record (the daemon \ + still clamps it to its drive_max_dbfs)" ); a } 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 0a2c246d..d74efb02 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, @@ -37,8 +38,9 @@ //! ceiling the same way `set_drive` always has (`handlers/mod.rs`'s //! `apply_drive_ceiling`) — so this value is a request, not the only thing //! bounding what reaches the converter, but it should still be set -//! explicitly here rather than relying on whatever ceiling happens to be -//! configured on the box this runs against. +//! explicitly: this test's own daemon runs under an isolated `HOME` whose +//! config sets no `drive_max_dbfs`, so only the default ceiling (−10 dBFS) +//! applies to it, never the rig's own. use std::env; use std::fs; @@ -76,7 +78,7 @@ const SELF_LOOP_LEVEL_DBFS: f64 = -6.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× @@ -90,7 +92,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. @@ -205,8 +207,9 @@ impl Routing { // Real ports means real emission. Refuse to pick the level. let level_dbfs = level_from_env().expect( "AC_LOOPBACK_OUT/IN name real JACK ports, so this run drives \ - hardware: set AC_LOOPBACK_LEVEL_DBFS explicitly. plot_ir does \ - not apply drive_max_dbfs, so this value is the only ceiling.", + hardware: set AC_LOOPBACK_LEVEL_DBFS explicitly. This test's daemon \ + runs under an isolated HOME, so only the default drive_max_dbfs \ + (-10 dBFS) clamps it, not the rig's ceiling.", ); Self { output_port, @@ -426,7 +429,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(); @@ -668,7 +671,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..c89cd89c --- /dev/null +++ b/docs/rigs/pupu.md @@ -0,0 +1,159 @@ +# 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 | + +- 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); JACK restarts and rate changes do + not. Restore — gains before phantom: + + ```sh + C=Fireface400 + 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 on,off; sleep 2 + amixer -c $C cset numid=81 20,0; amixer -c $C cset numid=102 on + ``` + + 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 + +`snd_fireface` exposes **analog first**: `capture_1..8` / `playback_1..8` = +AN1..AN8, then S/PDIF and ADAT (`capture_9..18` read exact digital zero). +Measured with a silent 18-channel `jack_rec` and the FF400 +`meter:analog-output` / `meter:stream-input` controls during a tone. 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`, `drive_max_dbfs + -40.0`. No calibration; levels are dBFS only. +- **Ceilings (nominal dBFS): −40 standing, −50 on anything that drives the + speaker** (operator, 2026-09-14). The daemon's `drive_max_dbfs` is −40 and + cannot tell outputs apart, so the −50 speaker ceiling is enforced by + `scripts/rig/` (`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 c3c9d138..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, and -`plot_ir` does not apply the config's `drive_max_dbfs` ceiling (only -`set_drive` does), so that value is the only limit on what reaches the -converter. Unset, all three default to the self-loop at −6 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). \ No newline at end of file diff --git a/docs/runbooks/rig-headless.md b/docs/runbooks/rig-headless.md deleted file mode 100644 index 02de4848..00000000 --- a/docs/runbooks/rig-headless.md +++ /dev/null @@ -1,28 +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`: `plot_ir` does not apply the -config's `drive_max_dbfs` ceiling — only `set_drive` does — so that value is -the only limit on what reaches the interface. \ No newline at end of file diff --git a/docs/runbooks/rig-testing.md b/docs/runbooks/rig-testing.md new file mode 100644 index 00000000..44205d99 --- /dev/null +++ b/docs/runbooks/rig-testing.md @@ -0,0 +1,249 @@ +# 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; 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). + +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). The speaker ceiling is script-only: the daemon's +`drive_max_dbfs` cannot tell outputs apart. These checks are the scripts' +own; the daemon's `drive_max_dbfs` clamp still applies to a daemon started +from the rig's config (`plot_ir` and friends clamp since #360), but not to one started under +an isolated `HOME` — 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 sets no +`drive_max_dbfs`, so that daemon's default ceiling (−10 dBFS) is all it +enforces: the script's `--level` check holds the rig ceiling. 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` subtracts a known +interface round trip so the arrival compares with a `transfer_stream` delay. +The IR peak is not the arrival on a multi-way speaker; use the onset. + +#### 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 applied (clamped) level — clamped only to the daemon's +`drive_max_dbfs`, 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. +- **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..e4536267 --- /dev/null +++ b/scripts/rig/acoustic-ir.sh @@ -0,0 +1,84 @@ +#!/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 ""] +# +# Uses a daemon spawned from the staged build (identity printed), routed with +# `ac setup` to the rig's speaker and mic indices; that daemon's config +# ceiling applies as well as this script's --level check, which holds the +# profile's speaker ceiling (RIG_SPEAKER_CEILING_DBFS). 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)" +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 nominal, $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" + +remote="$(cat <&2; exit 1; } +mkdir -p "\$RUN" && cd "\$RUN" +cfg="\$HOME/.config/ac/config.json" +orig_out="\$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["output_channel"])' "\$cfg")" +orig_in="\$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["input_channel"])' "\$cfg")" +cleanup() { + ac setup output "\$orig_out" input "\$orig_in" >/dev/null 2>&1 + pkill -x ac-daemon 2>/dev/null +} +trap cleanup EXIT +ac setup output "\$SPK" input "\$MIC" >setup.log 2>&1 +echo "- daemon executable: \$(daemon_identity)" +echo "- drive_max_dbfs in that daemon's config: \$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("drive_max_dbfs"))' "\$cfg")" +"\$DEST/ir_probe" --level-dbfs "\$LEVEL" --duration "\$DUR" --f1 "\$F1" --f2 "\$F2" --window "\$WIN" \$TAU >run.log 2>&1 +status=\$? +echo +echo '\`\`\`' +cat run.log +echo '\`\`\`' +echo +echo "ir_probe exit status: \$status" +exit \$status +REMOTE +)" +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/build-portable.sh b/scripts/rig/build-portable.sh new file mode 100755 index 00000000..e3dbcbff --- /dev/null +++ b/scripts/rig/build-portable.sh @@ -0,0 +1,111 @@ +#!/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}" +dirty="$(git -C "$top" status --porcelain --untracked-files=no -- 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 + +compiled=no +grep -qE '^\s*Compiling ac-daemon ' "$stage/build.log" "$stage/test-build.log" && compiled=yes + +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" <-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: the +# daemon's drive_max_dbfs cannot tell outputs apart and stays at -40. +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 + +# 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..4ca04b51 --- /dev/null +++ b/scripts/rig/lib.sh @@ -0,0 +1,131 @@ +#!/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 "" "