From ee7b573d34ad7aa338bdbb42af1aa702b95673eb Mon Sep 17 00:00:00 2001 From: Markus Kovero Date: Mon, 14 Sep 2026 22:57:29 +0000 Subject: [PATCH 1/3] fix: ff400.sh stops aliasing unstable FF400 port order, no phantom write closes #444 snd_fireface's ADAT/analog block order is not stable across boots, so ff400.sh's hardcoded 18-port alias table mislabels every analog port as ADAT (or vice versa) on the boots where its assumed order is wrong. Per architect option A: stop publishing channel-name aliases entirely, clear any FF400:-prefixed alias an earlier run left behind, and leave jackd's own alsa_pcm:* alias untouched. Also drop the generic phantom-power write (numid=90); that belongs to a rig profile, not a generic init. Co-Authored-By: Claude Sonnet 5 --- scripts/ff400.sh | 140 ++++++++++++++++++++++++++--------- scripts/ff400_test.sh | 167 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 35 deletions(-) create mode 100755 scripts/ff400_test.sh diff --git a/scripts/ff400.sh b/scripts/ff400.sh index eb7de200..d1253c0a 100755 --- a/scripts/ff400.sh +++ b/scripts/ff400.sh @@ -6,6 +6,12 @@ # on this card's 0–65536 / -90..+6 dB scale). Analog inputs/ADAT inputs are # muted in the DSP mixer (hardware loopback = 0). # +# This script sets no JACK channel-name aliases and does not change phantom +# power. snd_fireface's ADAT/analog block order is not stable across boots +# (see issue #444), so a fixed alias table would sometimes label an analog +# port as ADAT or vice versa. It only clears any `FF400:`-prefixed alias +# left over from an earlier run of an older version of this script. +# # Usage: # ./ff400.sh — apply defaults # ./ff400.sh show — print current relevant settings @@ -16,6 +22,74 @@ set -e CARD=0 +# ── ALSA control width helpers ─────────────────────────────────────────────── +# The FF400's array controls (output-volume, stream-source-gain) carry a +# values= list whose length follows the driver's live port count, which is +# not fixed: 18 at 48 kHz, 14 at 96 kHz on this card (issue #444). Read the +# width from the control itself instead of assuming a literal count. +_ctl_width() { + amixer -c "$CARD" cget numid="$1" 2>/dev/null \ + | grep ': values=' | sed 's/.*values=//' | awk -F, '{print NF}' +} + +# ── JACK alias cleanup ─────────────────────────────────────────────────────── +# This script publishes no channel-name aliases: on snd_fireface the block +# order is not determined here (issue #444), and a wrong alias looks like +# driver output. It only removes any `FF400:`-prefixed alias a previous run +# of this script may have left on a capture/playback port; any other alias +# (e.g. jackd's own `alsa_pcm:...`) is left untouched. +clear_ff400_aliases() { + if ! jack_lsp &>/dev/null; then + echo " JACK aliases: could not reach a JACK server; did not check aliases" + echo " JACK aliases: check: jack_lsp from the user jackd runs as; ss -xlp | grep jack" + return + fi + + local port="" line stale=0 + while IFS= read -r line; do + case "$line" in + " "*) + local alias="${line# }" + case "$port" in + system:capture_*|system:playback_*) + case "$alias" in + FF400:*) + if ! jack_alias -u "$port" "$alias" 2>/dev/null; then + echo " JACK aliases: could not unalias ${alias} on ${port}" >&2 + stale=1 + fi + ;; + esac + ;; + esac + ;; + *) + port="$line" + ;; + esac + done < <(jack_lsp -A) + + local remaining + remaining=$(jack_lsp -A | awk ' + /^ / { if ($0 ~ /^ FF400:/ && port ~ /^system:(capture|playback)_/) print port ": " $0; next } + { port = $0 } + ') + if [[ -n "$remaining" ]]; then + echo " JACK aliases: FF400: alias still present after clearing:" >&2 + echo "$remaining" >&2 + exit 1 + fi + if [[ $stale -ne 0 ]]; then + exit 1 + fi + + echo " JACK aliases: cleared any FF400: alias left by an earlier run" + echo " JACK aliases: none set here; block order is not determined by this script — to find it:" + echo " silent capture: unconnected ADAT/S/PDIF inputs read exact digital zero" + echo " drive a tone, watch meter:stream-input / meter:analog-output" + echo " scripts/rig/preflight.sh's port-order row, where scripts/rig/ exists" +} + # ── Level mode ──────────────────────────────────────────────────────────────── # line-output-level / headphone-output-level / line-input-level # Item #0 'High' Item #1 '-10dBV' Item #2 '+4dBu' @@ -38,21 +112,18 @@ case "${MODE,,}" in printf " %-26s %s\n" "line-input-gain:" "$(_int 82) dB" printf " %-26s %s\n" "line-3/4-inst:" "$(_bool 91)" printf " %-26s %s\n" "line-3/4-pad:" "$(_bool 92)" - printf " %-26s %s\n" "mic-1/2-powering:" "$(_bool 90)" - # FF400 hardware output order (ALSA/JACK, 0-indexed) — confirmed empirically: - # 0-7 : ADAT 1–8 - # 8-9 : SPDIF L/R - # 10-17: Analog AN1–AN8 (rear line outputs) - CHNAME=(ADAT1 ADAT2 ADAT3 ADAT4 ADAT5 ADAT6 ADAT7 ADAT8 SPDIF-L SPDIF-R AN1 AN2 AN3 AN4 AN5 AN6 AN7 AN8) + printf " %-26s %s\n" "mic-1/2-powering (driver cache, not set by this script):" "$(_bool 90)" echo "" - echo " stream-source-gain diagonal (JACK ch → hw output):" + echo " stream-source-gain diagonal (JACK ch → hw output), by index only:" + echo " (this script does not know which name belongs to which index — see JACK aliases below)" for i in $(seq 63 80); do idx=$((i - 63)) diag=$(amixer -c $CARD cget numid=$i 2>/dev/null \ | grep ': values' \ | sed 's/.*values=//' \ - | python3 -c "import sys; v=sys.stdin.read().strip().split(','); print(v[$idx])") - printf " ch%02d %-10s %s\n" $idx "${CHNAME[$idx]}" "$diag" + | python3 -c "import sys; v=sys.stdin.read().strip().split(','); print(v[$idx])" 2>/dev/null) + [[ -n "$diag" ]] || continue + printf " ch%02d %s\n" $idx "$diag" done exit 0 ;; @@ -67,6 +138,11 @@ esac echo "=== Fireface 400 init (card $CARD, level mode: $LEVEL_NAME) ===" +# ── Clear any FF400: JACK alias left by an earlier run ─────────────────────── +# Runs first: with set -e, a failing mixer write below must not leave a wrong +# alias in place (issue #444). +clear_ff400_aliases + # ── Ensure snd-fireface-ctl service is running (bridges ALSA → FireWire hw) ── #systemctl --user restart snd-fireface-ctl.service #sleep 1 @@ -87,25 +163,36 @@ echo " mic-input-gain: 0 dB" echo " line-input-gain: 0 dB" # ── Input mode ──────────────────────────────────────────────────────────────── +# Phantom power (numid=90, mic-1/2-powering) is not touched here: it belongs +# to the rig profile, not to this generic init (issue #444). amixer -c $CARD cset numid=91 off,off >/dev/null # line-3/4-inst → off amixer -c $CARD cset numid=92 off,off >/dev/null # line-3/4-pad → off -amixer -c $CARD cset numid=90 off,off >/dev/null # mic-1/2-powering (phantom) → off echo " line-3/4 inst/pad: off" -echo " phantom power: off" -# ── Output volume (numid 8, 18 channels, unity = 32768 = 0 dB) ─────────────── -amixer -c $CARD cset numid=8 \ - 32768,32768,32768,32768,32768,32768,32768,32768,32768,\ - 32768,32768,32768,32768,32768,32768,32768,32768,32768 >/dev/null -echo " output-volume: unity (32768) × 18" +# ── Output volume (numid 8, unity = 32768 = 0 dB) ──────────────────────────── +vol_width=$(_ctl_width 8) +vol_vals=$(python3 -c "print(','.join(['32768'] * $vol_width))") +amixer -c $CARD cset numid=8 "$vol_vals" >/dev/null +echo " output-volume: unity (32768) × $vol_width" # ── PCM stream → hardware output routing (identity, 32768 = 0 dB) ──────────── # numid 63..80 = mixer:stream-source-gain index 0..17 -# Each row is 18 values; set position [N] = 32768, rest = 0 +# Each row's value-list width follows the control's live width, not a +# literal count (issue #444); index i is only set when it falls inside that +# width. echo " stream routing: identity @ 0 dB (32768)" for i in $(seq 0 17); do numid=$((63 + i)) - vals=$(python3 -c "v=[0]*18; v[$i]=32768; print(','.join(map(str,v)))") + width=$(_ctl_width "$numid") + [[ -n "$width" && "$width" -gt 0 ]] || continue + vals=$(python3 -c " +n=$width +i=$i +v=[0]*n +if i < n: + v[i]=32768 +print(','.join(map(str,v))) +") amixer -c $CARD cset numid=$numid "$vals" >/dev/null done @@ -118,22 +205,5 @@ for numid in $(seq 9 26) $(seq 45 62); do amixer -c $CARD cset numid=$numid "0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0" >/dev/null done -# ── JACK port aliases ───────────────────────────────────────────────────────── -# Sets human-readable aliases on all 18 playback and capture ports. -# Aliases show up in QjackCtl, Carla, Catia etc. but jack_lsp still shows -# the real name. Safe to run even if JACK is not running (fails silently). -CHNAME=(ADAT1 ADAT2 ADAT3 ADAT4 ADAT5 ADAT6 ADAT7 ADAT8 SPDIF-L SPDIF-R AN1 AN2 AN3 AN4 AN5 AN6 AN7 AN8) -if jack_lsp &>/dev/null; then - echo " JACK aliases: setting..." - for i in $(seq 0 17); do - n=$((i + 1)) - jack_alias "system:playback_${n}" "FF400:playback_${CHNAME[$i]}" 2>/dev/null || true - jack_alias "system:capture_${n}" "FF400:capture_${CHNAME[$i]}" 2>/dev/null || true - done - echo " JACK aliases: done" -else - echo " JACK aliases: skipped (JACK not running)" -fi - echo "" echo "Done. Run ./ff400.sh show to verify." diff --git a/scripts/ff400_test.sh b/scripts/ff400_test.sh new file mode 100755 index 00000000..4576d05b --- /dev/null +++ b/scripts/ff400_test.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# ff400_test.sh — rig-free regression test for ff400.sh's JACK alias +# handling (issue #444). Stubs jack_lsp / jack_alias / amixer on PATH; no +# FF400 hardware or real JACK server needed. +# +# bash scripts/ff400_test.sh + +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +FF400="$HERE/ff400.sh" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +FAILED=0 +fail() { echo "FAIL: $1"; FAILED=1; } + +# ── stub tools ──────────────────────────────────────────────────────────── +# STUBDIR/jack_lsp, /jack_alias mutate JACK_STATE, a directory with one file +# per port holding that port's current aliases (one per line, in the order +# they were set — file line 1 stands in for jackd's own alsa_pcm alias). +# amixer answers `cget numid=8` and `cget numid=63..80` with a values= list +# AMIXER_WIDTH long, everything else with a single value; `cset` always +# succeeds. All three log every invocation. + +write_stubs() { + local bindir="$1" + mkdir -p "$bindir" + + cat > "$bindir/amixer" <<'EOF' +#!/usr/bin/env bash +echo "$*" >> "${AMIXER_LOG:?}" +case "$*" in + *"cget numid="*) + numid=$(echo "$*" | grep -oE 'numid=[0-9]+' | grep -oE '[0-9]+') + if [[ "$numid" == 8 || ( "$numid" -ge 63 && "$numid" -le 80 ) ]]; then + n="${AMIXER_WIDTH:-18}" + vals=$(python3 -c "print(','.join(['0']*int(\"$n\")))") + echo ": values=$vals" + else + echo ": values=0" + fi + exit 0 + ;; + *"cset"*) + exit 0 + ;; +esac +exit 0 +EOF + + cat > "$bindir/jack_lsp" <<'EOF' +#!/usr/bin/env bash +[[ -e "${JACK_DOWN:?}" ]] && exit 1 +state="${JACK_STATE:?}" +if [[ "${1:-}" == "-A" ]]; then + for f in "$state"/*; do + [[ -e "$f" ]] || continue + echo "$(basename "$f")" + while IFS= read -r a; do + [[ -n "$a" ]] && echo " $a" + done < "$f" + done +else + for f in "$state"/*; do + [[ -e "$f" ]] || continue + basename "$f" + done +fi +exit 0 +EOF + + cat > "$bindir/jack_alias" <<'EOF' +#!/usr/bin/env bash +echo "$*" >> "${ALIAS_LOG:?}" +state="${JACK_STATE:?}" +if [[ "${1:-}" == "-u" ]]; then + [[ -z "${FORCE_UNALIAS_FAIL:-}" ]] || exit 1 + port="$2"; alias_name="$3" + f="$state/$port" + [[ -e "$f" ]] || exit 1 + grep -qxF "$alias_name" "$f" || exit 1 + grep -vxF "$alias_name" "$f" > "$f.tmp" && mv "$f.tmp" "$f" + exit 0 +else + port="$1"; alias_name="$2" + echo "$alias_name" >> "$state/$port" + exit 0 +fi +EOF + + chmod +x "$bindir"/amixer "$bindir"/jack_lsp "$bindir"/jack_alias +} + +STUBDIR="$WORK/bin" +write_stubs "$STUBDIR" +export PATH="$STUBDIR:$PATH" + +STATE="$WORK/state" +export JACK_STATE="$STATE" +export JACK_DOWN="$WORK/jack_down" # file exists => jack_lsp fails +export AMIXER_LOG="$WORK/amixer.log" +export ALIAS_LOG="$WORK/alias.log" + +reset_state() { + # $1 = capture/playback port count for this run + rm -rf "$STATE"; mkdir -p "$STATE" + rm -f "$JACK_DOWN" + : > "$AMIXER_LOG"; : > "$ALIAS_LOG" + unset FORCE_UNALIAS_FAIL + export AMIXER_WIDTH="$1" + for i in $(seq 1 "$1"); do + echo "alsa_pcm:hw:Card:out$i" > "$STATE/system:capture_$i" + echo "alsa_pcm:hw:Card:in$i" > "$STATE/system:playback_$i" + done +} + +plant_ff400_alias() { + echo "FF400:capture_ADAT1" >> "$STATE/system:capture_1" +} + +# ── (a),(b),(c),(d): plant an old-table alias, run, check the cleanup ────── +reset_state 18 +plant_ff400_alias +out="$(bash "$FF400" 2>&1)"; rc=$? +[[ $rc -eq 0 ]] || fail "(a) script exited $rc on a clean alias-clearing run: $out" +grep -qF "FF400:" "$STATE/system:capture_1" && fail "(a) FF400: alias on system:capture_1 survived the run" +grep -qxF "alsa_pcm:hw:Card:out1" "$STATE/system:capture_1" \ + || fail "(b) non-FF400: alias on system:capture_1 was removed" +grep -vE '^-u ' "$ALIAS_LOG" | grep -q . && fail "(c) jack_alias called without -u: $(cat "$ALIAS_LOG")" +grep -q 'cset numid=90' "$AMIXER_LOG" && fail "(d) phantom power (numid=90) was written" + +# ── (e): same checks at 14 capture ports (96 kHz) and 18 (48 kHz) ───────── +for n in 14 18; do + reset_state "$n" + plant_ff400_alias + out="$(bash "$FF400" 2>&1)"; rc=$? + [[ $rc -eq 0 ]] || fail "(e) script exited $rc with $n ports: $out" + grep -qF "FF400:" "$STATE/system:capture_1" && fail "(e) FF400: alias survived with $n ports" + volwidth=$(grep -oE '^-c 0 cset numid=8 [0-9,]+' "$AMIXER_LOG" | tail -1 | awk '{print $NF}' | awk -F, '{print NF}') + [[ "$volwidth" == "$n" ]] || fail "(e) output-volume width was $volwidth, want $n ports" +done + +# ── (f): JACK unreachable — must not claim "no aliases" ──────────────────── +reset_state 18 +: > "$JACK_DOWN" +out="$(bash "$FF400" 2>&1)"; rc=$? +[[ $rc -eq 0 ]] || fail "(f) script exited $rc when JACK was unreachable: $out" +echo "$out" | grep -qF "could not reach a JACK server" \ + || fail "(f) unreachable JACK did not print the 'could not reach' line: $out" +echo "$out" | grep -qi "no alias" && fail "(f) unreachable JACK was reported as 'no aliases' instead of unreachable" +[[ -s "$ALIAS_LOG" ]] && fail "(f) jack_alias was called while JACK was unreachable" +rm -f "$JACK_DOWN" + +# ── (g): an unalias failure must exit non-zero ───────────────────────────── +reset_state 18 +plant_ff400_alias +export FORCE_UNALIAS_FAIL=1 +out="$(bash "$FF400" 2>&1)"; rc=$? +unset FORCE_UNALIAS_FAIL +[[ $rc -ne 0 ]] || fail "(g) script exited 0 despite a failing unalias: $out" + +if [[ $FAILED -ne 0 ]]; then + echo "ff400.sh alias handling: FAILED" + exit 1 +fi +echo "ff400.sh alias handling: all cases as expected" From 019621bed9bcb3adf97ec3fdf46beaa6474d5b24 Mon Sep 17 00:00:00 2001 From: Markus Kovero Date: Mon, 14 Sep 2026 23:42:50 +0000 Subject: [PATCH 2/3] fix: read mixer control widths for every array family, not just numid=8/63..80 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revised per architect re-entry on issue #444 (comment edited after this branch's original commit). Upstream snd-firewire-ctl-services source fixes the FF400's array-control widths at 18 (output-volume, stream-source-gain) and 8 (analog-source-gain, adat-source-gain) — none of them follow sample rate or JACK port count, which is a separate kernel-side quantity. The script now reads and validates every one of those widths (numid=8, 9..26, 45..62, 63..80) before its first mixer write, exits non-zero naming the numid on an unreadable or too-narrow one, and writes exactly the width each control reports — replacing the literal 18-value string previously sent to the 8-wide analog/adat-source-gain controls. ff400_test.sh: decouples control-width stubbing from JACK port count (case (e) now asserts mixer writes are identical at 14 vs 18 ports, not that a width tracks port count); adds case (h) (every cset's value count matches the width amixer reported, for all four control families) and case (i) (an unreadable control width aborts before any cset, naming the numid). Co-Authored-By: Claude Sonnet 5 --- scripts/ff400.sh | 80 ++++++++++++++++++++++++++++--------- scripts/ff400_test.sh | 92 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 139 insertions(+), 33 deletions(-) diff --git a/scripts/ff400.sh b/scripts/ff400.sh index d1253c0a..4c7ae7db 100755 --- a/scripts/ff400.sh +++ b/scripts/ff400.sh @@ -23,15 +23,54 @@ set -e CARD=0 # ── ALSA control width helpers ─────────────────────────────────────────────── -# The FF400's array controls (output-volume, stream-source-gain) carry a -# values= list whose length follows the driver's live port count, which is -# not fixed: 18 at 48 kHz, 14 at 96 kHz on this card (issue #444). Read the -# width from the control itself instead of assuming a literal count. +# The FF400's array controls (output-volume, analog-source-gain, +# adat-source-gain, stream-source-gain) each carry a values= list whose +# length is fixed by the userspace snd-firewire-ctl-services model +# (runtime/fireface/src/former_ctls.rs, protocols/fireface/src/former/ff400.rs +# upstream) — it does NOT follow sample rate or the JACK port count from +# issue #444 (that's a separate, kernel-side quantity: pcm_capture_channels +# in sound/firewire/fireface/ff.c). Read the width from the control itself +# rather than trusting a literal, since the ctl-service version actually +# installed has not been checked against upstream. _ctl_width() { amixer -c "$CARD" cget numid="$1" 2>/dev/null \ | grep ': values=' | sed 's/.*values=//' | awk -F, '{print NF}' } +# Read and validate every array control's width before any mixer write +# (issue #444). A missing or non-numeric width, or — for stream-source-gain +# — a width too narrow for the row's own index, means the numid layout this +# script assumes does not match the running ctl-service: exit non-zero, +# name the numid, and write nothing. +declare -A CTL_WIDTH +check_ctl_width() { + local numid="$1" name="$2" width + width=$(_ctl_width "$numid") + if [[ -z "$width" || ! "$width" =~ ^[0-9]+$ || "$width" -eq 0 ]]; then + echo " mixer widths: could not read value count for numid=$numid ($name); nothing written" >&2 + exit 1 + fi + CTL_WIDTH[$numid]="$width" +} +check_all_ctl_widths() { + local numid i + check_ctl_width 8 "output-volume" + for numid in $(seq 9 26); do + check_ctl_width "$numid" "analog-source-gain" + done + for numid in $(seq 45 62); do + check_ctl_width "$numid" "adat-source-gain" + done + for i in $(seq 0 17); do + numid=$((63 + i)) + check_ctl_width "$numid" "stream-source-gain" + if [[ "${CTL_WIDTH[$numid]}" -le "$i" ]]; then + echo " mixer widths: numid=$numid (stream-source-gain) reports width ${CTL_WIDTH[$numid]}, too narrow for index $i; nothing written" >&2 + exit 1 + fi + done +} + # ── JACK alias cleanup ─────────────────────────────────────────────────────── # This script publishes no channel-name aliases: on snd_fireface the block # order is not determined here (issue #444), and a wrong alias looks like @@ -122,8 +161,11 @@ case "${MODE,,}" in | grep ': values' \ | sed 's/.*values=//' \ | python3 -c "import sys; v=sys.stdin.read().strip().split(','); print(v[$idx])" 2>/dev/null) - [[ -n "$diag" ]] || continue - printf " ch%02d %s\n" $idx "$diag" + if [[ -n "$diag" ]]; then + printf " ch%02d %s\n" $idx "$diag" + else + printf " ch%02d %s\n" $idx "(could not be read)" + fi done exit 0 ;; @@ -143,6 +185,9 @@ echo "=== Fireface 400 init (card $CARD, level mode: $LEVEL_NAME) ===" # alias in place (issue #444). clear_ff400_aliases +# ── Validate every array control's width before any mixer write ───────────── +check_all_ctl_widths + # ── Ensure snd-fireface-ctl service is running (bridges ALSA → FireWire hw) ── #systemctl --user restart snd-fireface-ctl.service #sleep 1 @@ -170,27 +215,23 @@ amixer -c $CARD cset numid=92 off,off >/dev/null # line-3/4-pad → off echo " line-3/4 inst/pad: off" # ── Output volume (numid 8, unity = 32768 = 0 dB) ──────────────────────────── -vol_width=$(_ctl_width 8) -vol_vals=$(python3 -c "print(','.join(['32768'] * $vol_width))") +vol_vals=$(python3 -c "print(','.join(['32768'] * ${CTL_WIDTH[8]}))") amixer -c $CARD cset numid=8 "$vol_vals" >/dev/null -echo " output-volume: unity (32768) × $vol_width" +echo " output-volume: unity (32768) × ${CTL_WIDTH[8]}" # ── PCM stream → hardware output routing (identity, 32768 = 0 dB) ──────────── -# numid 63..80 = mixer:stream-source-gain index 0..17 -# Each row's value-list width follows the control's live width, not a -# literal count (issue #444); index i is only set when it falls inside that -# width. +# numid 63..80 = mixer:stream-source-gain index 0..17. Width is fixed by the +# ctl-service model (see _ctl_width above), validated for every index by +# check_all_ctl_widths before this loop runs, so index i is always in range. echo " stream routing: identity @ 0 dB (32768)" for i in $(seq 0 17); do numid=$((63 + i)) - width=$(_ctl_width "$numid") - [[ -n "$width" && "$width" -gt 0 ]] || continue + width="${CTL_WIDTH[$numid]}" vals=$(python3 -c " n=$width i=$i v=[0]*n -if i < n: - v[i]=32768 +v[i]=32768 print(','.join(map(str,v))) ") amixer -c $CARD cset numid=$numid "$vals" >/dev/null @@ -200,9 +241,12 @@ done # (analog-source-gain and adat-source-gain all 0 — no hardware loopback) # numid 9..26 = mixer:analog-source-gain index 0..17 # numid 45..62 = mixer:adat-source-gain index 0..17 +# Each row's width is fixed by the ctl-service model (8, not 18 — see +# _ctl_width above) and validated by check_all_ctl_widths before this loop. echo " analog/adat loopback: muted" for numid in $(seq 9 26) $(seq 45 62); do - amixer -c $CARD cset numid=$numid "0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0" >/dev/null + zeros=$(python3 -c "print(','.join(['0'] * ${CTL_WIDTH[$numid]}))") + amixer -c $CARD cset numid=$numid "$zeros" >/dev/null done echo "" diff --git a/scripts/ff400_test.sh b/scripts/ff400_test.sh index 4576d05b..e39cddce 100755 --- a/scripts/ff400_test.sh +++ b/scripts/ff400_test.sh @@ -19,9 +19,19 @@ fail() { echo "FAIL: $1"; FAILED=1; } # STUBDIR/jack_lsp, /jack_alias mutate JACK_STATE, a directory with one file # per port holding that port's current aliases (one per line, in the order # they were set — file line 1 stands in for jackd's own alsa_pcm alias). -# amixer answers `cget numid=8` and `cget numid=63..80` with a values= list -# AMIXER_WIDTH long, everything else with a single value; `cset` always -# succeeds. All three log every invocation. +# +# amixer's `cget numid=N` answers with a values= list whose width is fixed +# per control family, matching the upstream ctl-service model (architect, +# issue #444 re-entry): numid=8 → WIDTH_8 (default 18), numid 9..26 → +# WIDTH_ANALOG (default 8), numid 45..62 → WIDTH_ADAT (default 8), numid +# 63..80 → WIDTH_STREAM (default 18). These widths do NOT follow JACK port +# count — reset_state's port count and the mixer widths are independent +# knobs, on purpose (case (e)). FAIL_NUMID, if set, makes that one numid's +# cget answer with no `values=` line at all, simulating an unreadable +# control. `cset` always succeeds (real amixer never rejects a count +# mismatch — see architect's evidence — so this stub doesn't simulate one; +# case (h) checks the *sent* width against the reported one instead). All +# three log every invocation. write_stubs() { local bindir="$1" @@ -33,13 +43,22 @@ echo "$*" >> "${AMIXER_LOG:?}" case "$*" in *"cget numid="*) numid=$(echo "$*" | grep -oE 'numid=[0-9]+' | grep -oE '[0-9]+') - if [[ "$numid" == 8 || ( "$numid" -ge 63 && "$numid" -le 80 ) ]]; then - n="${AMIXER_WIDTH:-18}" - vals=$(python3 -c "print(','.join(['0']*int(\"$n\")))") - echo ": values=$vals" + if [[ -n "${FAIL_NUMID:-}" && "$numid" == "$FAIL_NUMID" ]]; then + exit 0 # no values= line at all: simulates an unreadable control + fi + if [[ "$numid" == 8 ]]; then + n="${WIDTH_8:-18}" + elif [[ "$numid" -ge 9 && "$numid" -le 26 ]]; then + n="${WIDTH_ANALOG:-8}" + elif [[ "$numid" -ge 45 && "$numid" -le 62 ]]; then + n="${WIDTH_ADAT:-8}" + elif [[ "$numid" -ge 63 && "$numid" -le 80 ]]; then + n="${WIDTH_STREAM:-18}" else - echo ": values=0" + n=1 fi + vals=$(python3 -c "print(','.join(['0']*int(\"$n\")))") + echo ": values=$vals" exit 0 ;; *"cset"*) @@ -103,12 +122,13 @@ export AMIXER_LOG="$WORK/amixer.log" export ALIAS_LOG="$WORK/alias.log" reset_state() { - # $1 = capture/playback port count for this run + # $1 = capture/playback port count for this run. Mixer control widths + # are a separate knob (WIDTH_8/WIDTH_ANALOG/WIDTH_ADAT/WIDTH_STREAM, + # FAIL_NUMID) — reset_state does not touch them, on purpose (case (e)). rm -rf "$STATE"; mkdir -p "$STATE" rm -f "$JACK_DOWN" : > "$AMIXER_LOG"; : > "$ALIAS_LOG" unset FORCE_UNALIAS_FAIL - export AMIXER_WIDTH="$1" for i in $(seq 1 "$1"); do echo "alsa_pcm:hw:Card:out$i" > "$STATE/system:capture_$i" echo "alsa_pcm:hw:Card:in$i" > "$STATE/system:playback_$i" @@ -130,17 +150,59 @@ grep -qxF "alsa_pcm:hw:Card:out1" "$STATE/system:capture_1" \ grep -vE '^-u ' "$ALIAS_LOG" | grep -q . && fail "(c) jack_alias called without -u: $(cat "$ALIAS_LOG")" grep -q 'cset numid=90' "$AMIXER_LOG" && fail "(d) phantom power (numid=90) was written" -# ── (e): same checks at 14 capture ports (96 kHz) and 18 (48 kHz) ───────── +# ── (e): mixer writes are unchanged across 14 and 18 JACK ports ─────────── +# Control width must not follow JACK port count (architect, re-entry): with +# the mixer widths held fixed, the alias result and every cset the script +# issues must be identical whether JACK exposes 14 or 18 ports. +prev_csets="" for n in 14 18; do reset_state "$n" plant_ff400_alias out="$(bash "$FF400" 2>&1)"; rc=$? - [[ $rc -eq 0 ]] || fail "(e) script exited $rc with $n ports: $out" - grep -qF "FF400:" "$STATE/system:capture_1" && fail "(e) FF400: alias survived with $n ports" - volwidth=$(grep -oE '^-c 0 cset numid=8 [0-9,]+' "$AMIXER_LOG" | tail -1 | awk '{print $NF}' | awk -F, '{print NF}') - [[ "$volwidth" == "$n" ]] || fail "(e) output-volume width was $volwidth, want $n ports" + [[ $rc -eq 0 ]] || fail "(e) script exited $rc with $n JACK ports: $out" + grep -qF "FF400:" "$STATE/system:capture_1" && fail "(e) FF400: alias survived with $n JACK ports" + csets="$(grep 'cset numid=' "$AMIXER_LOG")" + if [[ -n "$prev_csets" ]]; then + [[ "$csets" == "$prev_csets" ]] \ + || fail "(e) mixer writes changed between 14 and 18 JACK ports (control width must not follow port count)" + fi + prev_csets="$csets" done +# ── (h): every cset's value count matches the width amixer reported ─────── +# Must go red against an unrevised ff400.sh that still sends 18 values to +# an 8-wide analog-source-gain/adat-source-gain control (correctness issue +# 1, PR #450 qa review). +expected_width() { + local numid="$1" + if [[ "$numid" == 8 ]]; then echo "${WIDTH_8:-18}" + elif [[ "$numid" -ge 9 && "$numid" -le 26 ]]; then echo "${WIDTH_ANALOG:-8}" + elif [[ "$numid" -ge 45 && "$numid" -le 62 ]]; then echo "${WIDTH_ADAT:-8}" + elif [[ "$numid" -ge 63 && "$numid" -le 80 ]]; then echo "${WIDTH_STREAM:-18}" + fi +} +reset_state 18 +plant_ff400_alias +out="$(bash "$FF400" 2>&1)"; rc=$? +[[ $rc -eq 0 ]] || fail "(h) script exited $rc with default control widths: $out" +while IFS= read -r line; do + numid=$(echo "$line" | grep -oE 'numid=[0-9]+' | head -1 | grep -oE '[0-9]+') + want="$(expected_width "$numid")" + [[ -n "$want" ]] || continue + got=$(echo "$line" | awk '{print $NF}' | awk -F, '{print NF}') + [[ "$got" == "$want" ]] \ + || fail "(h) numid=$numid cset sent $got values, amixer reported width $want" +done < <(grep 'cset numid=' "$AMIXER_LOG") + +# ── (i): an unreadable control width aborts before any cset ──────────────── +reset_state 18 +export FAIL_NUMID=70 # inside 63..80 (stream-source-gain) +out="$(bash "$FF400" 2>&1)"; rc=$? +unset FAIL_NUMID +[[ $rc -ne 0 ]] || fail "(i) script exited 0 despite an unreadable numid=70 width: $out" +echo "$out" | grep -q '\bnumid=70\b' || fail "(i) failure message did not name numid=70: $out" +grep -q 'cset' "$AMIXER_LOG" && fail "(i) a cset was issued despite an unreadable control width: $(cat "$AMIXER_LOG")" + # ── (f): JACK unreachable — must not claim "no aliases" ──────────────────── reset_state 18 : > "$JACK_DOWN" From 20a787b56f7c337b6f31cee1d80ccc024ad62137 Mon Sep 17 00:00:00 2001 From: Markus Kovero Date: Tue, 15 Sep 2026 08:47:08 +0000 Subject: [PATCH 3/3] fix: cover ff400.sh show + masked jack_lsp -A failures (codex-qa PR #450) show exited before clear_ff400_aliases ran, so a stale FF400: alias survived `./ff400.sh show` with no cleanup or guidance printed. clear_ff400_aliases also trusted jack_lsp -A's success without checking it: the enumeration read it via process substitution (invisible to set -e) and the verify pass piped it into awk without pipefail (awk's own exit status masked jack_lsp's), so either call failing after the plain reachability probe passed could still print "cleared"/"none set here" having never actually looked. Also guard show's per-channel diagonal extraction so an unreadable row (empty amixer output, python IndexError) no longer aborts the whole listing under set -e instead of printing "(could not be read)" and continuing. Adds test cases (j)-(m) to ff400_test.sh, each confirmed red against 019621be (this branch's prior tip) and green here. Co-Authored-By: Claude Sonnet 5 --- scripts/ff400.sh | 26 +++++++++++++--- scripts/ff400_test.sh | 70 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/scripts/ff400.sh b/scripts/ff400.sh index 4c7ae7db..7a9525d3 100755 --- a/scripts/ff400.sh +++ b/scripts/ff400.sh @@ -84,6 +84,17 @@ clear_ff400_aliases() { return fi + # jack_lsp -A's exit status must be checked explicitly, both here and + # below: a process substitution's failure isn't seen by `set -e`, and a + # pipe into awk without pipefail hides the exit status of jack_lsp behind + # awk's own success. Either masked failure would let this function print + # "cleared" / "none set here" while never having actually looked. + local lsp_out + if ! lsp_out=$(jack_lsp -A); then + echo " JACK aliases: jack_lsp -A failed after the reachability check; did not clear or confirm any alias" >&2 + exit 1 + fi + local port="" line stale=0 while IFS= read -r line; do case "$line" in @@ -106,13 +117,18 @@ clear_ff400_aliases() { port="$line" ;; esac - done < <(jack_lsp -A) + done <<< "$lsp_out" + local verify_out + if ! verify_out=$(jack_lsp -A); then + echo " JACK aliases: jack_lsp -A failed during verification; did not confirm any alias was cleared" >&2 + exit 1 + fi local remaining - remaining=$(jack_lsp -A | awk ' + remaining=$(awk ' /^ / { if ($0 ~ /^ FF400:/ && port ~ /^system:(capture|playback)_/) print port ": " $0; next } { port = $0 } - ') + ' <<< "$verify_out") if [[ -n "$remaining" ]]; then echo " JACK aliases: FF400: alias still present after clearing:" >&2 echo "$remaining" >&2 @@ -160,13 +176,15 @@ case "${MODE,,}" in diag=$(amixer -c $CARD cget numid=$i 2>/dev/null \ | grep ': values' \ | sed 's/.*values=//' \ - | python3 -c "import sys; v=sys.stdin.read().strip().split(','); print(v[$idx])" 2>/dev/null) + | python3 -c "import sys; v=sys.stdin.read().strip().split(','); print(v[$idx])" 2>/dev/null) || true if [[ -n "$diag" ]]; then printf " ch%02d %s\n" $idx "$diag" else printf " ch%02d %s\n" $idx "(could not be read)" fi done + echo "" + clear_ff400_aliases exit 0 ;; +4dbu|+4) LEVEL_IDX=2 ; LEVEL_NAME="+4 dBu" ;; diff --git a/scripts/ff400_test.sh b/scripts/ff400_test.sh index e39cddce..52a19ae8 100755 --- a/scripts/ff400_test.sh +++ b/scripts/ff400_test.sh @@ -32,6 +32,13 @@ fail() { echo "FAIL: $1"; FAILED=1; } # mismatch — see architect's evidence — so this stub doesn't simulate one; # case (h) checks the *sent* width against the reported one instead). All # three log every invocation. +# +# jack_lsp's `-A` calls are counted in JACK_A_CALLS: when JACK_A_DOWN is +# set, the Nth `-A` call fails once N exceeds JACK_A_DOWN_AFTER (default 0, +# i.e. the very first `-A` call fails) — this simulates JACK going away +# *after* the plain reachability probe (`jack_lsp` with no args) already +# succeeded, which a masked pipe/process-substitution failure would miss +# (codex-qa, PR #450 re-review). write_stubs() { local bindir="$1" @@ -73,6 +80,14 @@ EOF [[ -e "${JACK_DOWN:?}" ]] && exit 1 state="${JACK_STATE:?}" if [[ "${1:-}" == "-A" ]]; then + if [[ -n "${JACK_A_DOWN:-}" ]]; then + calls="${JACK_A_CALLS:?}" + n=0 + [[ -e "$calls" ]] && n=$(cat "$calls") + n=$((n + 1)) + echo "$n" > "$calls" + [[ "$n" -gt "${JACK_A_DOWN_AFTER:-0}" ]] && exit 1 + fi for f in "$state"/*; do [[ -e "$f" ]] || continue echo "$(basename "$f")" @@ -118,6 +133,7 @@ export PATH="$STUBDIR:$PATH" STATE="$WORK/state" export JACK_STATE="$STATE" export JACK_DOWN="$WORK/jack_down" # file exists => jack_lsp fails +export JACK_A_CALLS="$WORK/jack_a_calls" # jack_lsp -A call counter, see JACK_A_DOWN export AMIXER_LOG="$WORK/amixer.log" export ALIAS_LOG="$WORK/alias.log" @@ -126,9 +142,9 @@ reset_state() { # are a separate knob (WIDTH_8/WIDTH_ANALOG/WIDTH_ADAT/WIDTH_STREAM, # FAIL_NUMID) — reset_state does not touch them, on purpose (case (e)). rm -rf "$STATE"; mkdir -p "$STATE" - rm -f "$JACK_DOWN" + rm -f "$JACK_DOWN" "$JACK_A_CALLS" : > "$AMIXER_LOG"; : > "$ALIAS_LOG" - unset FORCE_UNALIAS_FAIL + unset FORCE_UNALIAS_FAIL JACK_A_DOWN JACK_A_DOWN_AFTER for i in $(seq 1 "$1"); do echo "alsa_pcm:hw:Card:out$i" > "$STATE/system:capture_$i" echo "alsa_pcm:hw:Card:in$i" > "$STATE/system:playback_$i" @@ -222,6 +238,56 @@ out="$(bash "$FF400" 2>&1)"; rc=$? unset FORCE_UNALIAS_FAIL [[ $rc -ne 0 ]] || fail "(g) script exited 0 despite a failing unalias: $out" +# ── (j): `show` also clears a stale FF400: alias and prints guidance ─────── +# Must go red against a `show` branch that exits before clear_ff400_aliases +# runs (codex-qa major finding, PR #450 re-review). +reset_state 18 +plant_ff400_alias +out="$(bash "$FF400" show 2>&1)"; rc=$? +[[ $rc -eq 0 ]] || fail "(j) show exited $rc: $out" +grep -qF "FF400:" "$STATE/system:capture_1" && fail "(j) show did not clear the planted FF400: alias" +echo "$out" | grep -qi "none set here" \ + || fail "(j) show did not print the no-alias/where-to-look guidance: $out" + +# ── (k): jack_lsp -A failing right after the reachability probe must not +# claim aliases were cleared or confirmed absent (codex-qa major finding: +# the initial enumeration ran inside a process substitution whose failure +# `set -e` never saw) ──────────────────────────────────────────────────── +reset_state 18 +plant_ff400_alias +export JACK_A_DOWN=1 JACK_A_DOWN_AFTER=0 # fail on the 1st -A call (enumeration) +out="$(bash "$FF400" 2>&1)"; rc=$? +unset JACK_A_DOWN JACK_A_DOWN_AFTER +[[ $rc -ne 0 ]] || fail "(k) script exited 0 despite jack_lsp -A failing during enumeration: $out" +echo "$out" | grep -qi "cleared any FF400" && fail "(k) claimed aliases cleared despite jack_lsp -A failing: $out" +echo "$out" | grep -qi "none set here" && fail "(k) claimed no aliases set despite jack_lsp -A failing: $out" + +# ── (m): jack_lsp -A failing on the post-clear verify pass (enumeration +# itself succeeded) must not claim aliases were confirmed cleared (codex-qa +# major finding: the verify pipe into awk masked jack_lsp's exit status +# without pipefail) ───────────────────────────────────────────────────── +reset_state 18 +plant_ff400_alias +export JACK_A_DOWN=1 JACK_A_DOWN_AFTER=1 # 1st -A (enumeration) ok, 2nd (verify) fails +out="$(bash "$FF400" 2>&1)"; rc=$? +unset JACK_A_DOWN JACK_A_DOWN_AFTER +[[ $rc -ne 0 ]] || fail "(m) script exited 0 despite jack_lsp -A failing during verification: $out" +echo "$out" | grep -qi "cleared any FF400" && fail "(m) claimed aliases cleared despite the verify jack_lsp -A failing: $out" + +# ── (l): `show` must not abort on an unreadable stream-source-gain row ───── +# numid=64 is ch01 of the diagonal; FAIL_NUMID makes it answer with no +# `values=` line, which previously made an unguarded `$(...)` assignment +# trip `set -e` mid-loop (codex-qa minor finding, PR #450 re-review). +reset_state 18 +export FAIL_NUMID=64 +out="$(bash "$FF400" show 2>&1)"; rc=$? +unset FAIL_NUMID +[[ $rc -eq 0 ]] || fail "(l) show exited $rc on an unreadable numid=64 diagonal row: $out" +echo "$out" | grep -q 'ch01 (could not be read)' \ + || fail "(l) show did not report ch01 as unreadable: $out" +echo "$out" | grep -q 'ch02 ' \ + || fail "(l) show aborted before printing ch02 after an unreadable row: $out" + if [[ $FAILED -ne 0 ]]; then echo "ff400.sh alias handling: FAILED" exit 1