From c09827bf86e304688b80d8a4575a776bbf92bd03 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Thu, 30 Jul 2026 15:58:07 +0200 Subject: [PATCH] =?UTF-8?q?fix(iphone-bt):=20v3=20=E2=80=94=20pw-link=20-l?= =?UTF-8?q?=20filtert=20nicht,=20v2=20machte=20den=20Uplink=20stumm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testanruf 13:51 loggte nur "-callrec_mix", kein +micgate und kein +far-end: v2 hat WirePlumbers Link getrennt und dann nichts verlinkt, der Uplink hatte keine Quelle und die Gegenseite hörte Stille. Ursache: `pw-link -l ` filtert nicht. Mit einem Node-Namen, der nicht als Port auflösbar ist, kommt der ganze Graph zurück — und der enthält immer micgate.out und callmix. Also war jedes "ist das schon verlinkt?"-grep wahr und nur das Trennen lief. Derselbe Ausdruck stand schon in v1, dort aber hinter dem Einmal-Guard, der nie erreicht wurde. v3: - links_of PORT parst die Peers eines exakten Ports aus einem Graph-Dump (awk), kein grep mehr über die gesamte Ausgabe. - Erst verlinken, dann trennen: callrec_mix->Uplink fliegt nur raus, wenn micgate.out am Uplink-Port bestätigt anliegt. Sonst bleibt der WirePlumber-Link und es wird !no-micgate-uplink-keeping-callrec_mix geloggt. Ein Echo ist schlecht, unhörbar sein ist schlimmer. - Log-Zustand als Array statt String: " " hätte -n passiert und bei fertig geroutetem Anruf jede Sekunde eine Journal-Zeile geschrieben. Getestet: links_of gegen den echten Graph (verlinkt/unverlinkt/nicht existent); Entscheidungslogik gegen einen synthetischen Mono-HFP-Graph — Default-Zustand, fertig geroutet (No-op), micgate nicht bestätigt (behält callrec_mix). Gegenprobe am echten Anruf steht noch aus. --- app/aime-iphone-route.sh | 139 +++++++++++++++++++++++---------------- docs/06-iphone-bt.md | 21 +++++- docs/08-changelog.md | 48 +++++++++++++- 3 files changed, 149 insertions(+), 59 deletions(-) diff --git a/app/aime-iphone-route.sh b/app/aime-iphone-route.sh index e391e93..f8b04b9 100644 --- a/app/aime-iphone-route.sh +++ b/app/aime-iphone-route.sh @@ -4,79 +4,108 @@ # far-end voice (bluez_input) -> callmix (recorded digitally, clean) # and remove WirePlumber's default callrec_mix->uplink auto-link (that mix carries # the ungated mic AND the far-end, i.e. a digital echo loop = far-end hears itself). +# Far-end -> headset earpiece is WirePlumber's own default link, NOT ours; callmix +# is a pure recording sink with no output. # -# v2 (2026-07-30): two fixes after a call recorded near-end only + far-end doubling -# 1. every link is verified on EVERY pass, not once per call. The old one-shot -# guard ("micgate.out already linked -> skip") meant: if the far-end node -# appeared a tick later than the uplink node (WirePlumber does not create the -# HFP nodes atomically), far-end->callmix was never made for the whole call, -# and a callrec_mix->uplink link added mid-call was never removed. -# 2. port names are discovered instead of hardcoded. HFP/mSBC is MONO, so the -# ports are playback_MONO/capture_MONO, not input_FL/input_FR. The old -# hardcoded FL/FR grep never matched a mono node at all. +# v3 (2026-07-30): `pw-link -l ` DOES NOT FILTER. Given a node name it cannot +# resolve as a port it prints the ENTIRE graph, which always contains micgate.out +# and callmix -> every "is it already linked?" grep returned true, so v2 removed +# callrec_mix->uplink and then linked nothing: the far end heard silence. +# Link state is now parsed per PORT out of one graph dump (links_of). +# v2 (2026-07-30): port names are discovered, not hardcoded (HFP/mSBC is MONO, so +# the ports are playback_MONO/capture_MONO, not input_FL/input_FR -- the old +# hardcoded grep never matched at all), and every link is verified on EVERY pass. +# The old one-shot guard meant that if the far-end node appeared a tick later than +# the uplink node (WirePlumber does not create the HFP nodes atomically), the +# far-end was never linked for the whole call. export XDG_RUNTIME_DIR=/run/user/1000 MAC=B4_96_A5_42_BD_D6 -# ports_in/ports_out: list "node:port" for nodes whose name matches $1 ports_in() { pw-link -i 2>/dev/null | grep -E "^$1"; } ports_out() { pw-link -o 2>/dev/null | grep -E "^$1"; } -# link_all SRC_PORTS DST_PORTS -- exactly one link per DESTINATION port, cycling -# the sources. So a mono source feeds both destination channels, and a stereo -# source feeding a mono HFP port sends FL only (linking both would sum them into -# the same port = +6 dB on the uplink). -link_all() { - local -n _src=$1 _dst=$2 - local mode=$3 i n=${#_src[@]} m=${#_dst[@]} - [ "$n" -eq 0 ] || [ "$m" -eq 0 ] && return - for ((i=0; i/dev/null - done +# links_of PORT -- the peers linked to exactly this port, one per line, read from +# $GRAPH. pw-link -l prints a port header at column 0 followed by indented +# "|-> peer" / "|<- peer" lines, so match the header exactly and strip the arrows. +links_of() { + printf '%s\n' "$GRAPH" | awk -v p="$1" ' + /^[^ \t]/ { inblk = ($0 == p); next } + inblk { sub(/^[ \t]*\|[-<>]+[ \t]*/, ""); print } + ' } -log_once="" +has_peer() { # has_peer REGEX PEER... -> 0 if any peer matches + local re=$1; shift + printf '%s\n' "$@" | grep -qE "$re" +} + +log_state="" while true; do mapfile -t UP < <(ports_in "bluez_output\.${MAC}") - if [ ${#UP[@]} -gt 0 ]; then - B=${UP[0]%%:*} - LINKS=$(pw-link -l "$B" 2>/dev/null) - CHANGED="" + if [ ${#UP[@]} -eq 0 ]; then + log_state="" + sleep 1 + continue + fi - # 1) drop WirePlumber's ungated-mix -> uplink link (echo source). Checked every - # pass because WP can (re-)add it at any point during the call. - if echo "$LINKS" | grep -q 'callrec_mix'; then - mapfile -t MIX < <(ports_out 'callrec_mix') - link_all MIX UP -d - CHANGED="$CHANGED -callrec_mix" + GRAPH=$(pw-link -l 2>/dev/null) + CH=() + mapfile -t GATE < <(ports_out 'micgate\.out') + + # --- uplink: exactly one gated-mic source per uplink port, no ungated mix --- + for ((i = 0; i < ${#UP[@]}; i++)); do + port=${UP[i]} + mapfile -t peers < <(links_of "$port") + + # ADD BEFORE REMOVE. One link per uplink port: a stereo gate into a mono HFP + # port sends FL only, linking both would sum them into the same port (+6 dB). + if [ ${#GATE[@]} -gt 0 ] && ! has_peer '^micgate\.out:' "${peers[@]}"; then + pw-link "${GATE[i % ${#GATE[@]}]}" "$port" 2>/dev/null && CH+=(+micgate) + GRAPH=$(pw-link -l 2>/dev/null) + mapfile -t peers < <(links_of "$port") fi - # 2) ensure gated mic -> uplink - if ! echo "$LINKS" | grep -q 'micgate\.out'; then - mapfile -t GATE < <(ports_out 'micgate\.out') - link_all GATE UP "" - CHANGED="$CHANGED +micgate" - fi - - # 3) ensure far-end -> record mix. Retried every pass: this node often shows up - # later than the uplink node. - mapfile -t FAR < <(ports_out "bluez_input\.${MAC}") - if [ ${#FAR[@]} -gt 0 ]; then - I=${FAR[0]%%:*} - if ! pw-link -l "$I" 2>/dev/null | grep -q 'callmix'; then - mapfile -t REC < <(ports_in 'callmix') - link_all FAR REC "" - CHANGED="$CHANGED +far-end" - fi + # WirePlumber's callrec_mix -> uplink is the echo path. Drop every instance; + # re-checked each pass because WP can add it at any point during the call. + # SAFETY INVARIANT: only ever remove it once the gated mic is CONFIRMED linked. + # v2 removed it and then linked nothing (the state check was broken) -> the + # uplink had no source at all and the far end heard pure silence. An echo is + # bad; being inaudible is worse. + if has_peer '^micgate\.out:' "${peers[@]}"; then + for pe in "${peers[@]}"; do + case $pe in + callrec_mix:*) pw-link -d "$pe" "$port" 2>/dev/null && CH+=(-callrec_mix) ;; + esac + done else - CHANGED="$CHANGED (no-far-end-node-yet)" + CH+=("!no-micgate-uplink-keeping-callrec_mix") fi + done - if [ -n "$CHANGED" ] && [ "$CHANGED" != "$log_once" ]; then - logger -t aime-iphone-route "iPhone call $B:$CHANGED" - log_once="$CHANGED" - fi + # --- far-end into the recording mix (this node often appears after the uplink) --- + mapfile -t FAR < <(ports_out "bluez_input\.${MAC}") + if [ ${#FAR[@]} -eq 0 ]; then + CH+=("(no-far-end-node-yet)") else - log_once="" + mapfile -t REC < <(ports_in 'callmix') + for ((j = 0; j < ${#REC[@]}; j++)); do + rport=${REC[j]} + mapfile -t rpeers < <(links_of "$rport") + if ! has_peer "^bluez_input\.${MAC}" "${rpeers[@]}"; then + pw-link "${FAR[j % ${#FAR[@]}]}" "$rport" 2>/dev/null && CH+=(+far-end) + fi + done + fi + + # collapse repeats (one entry per uplink/callmix channel) and log only on change. + # An ARRAY, not a string: a whitespace-only string passes -n and would log on + # every single pass once the call is fully routed. + if [ ${#CH[@]} -gt 0 ]; then + msg=$(printf '%s\n' "${CH[@]}" | awk '!seen[$0]++' | tr '\n' ' ') + if [ "$msg" != "$log_state" ]; then + logger -t aime-iphone-route "iPhone call ${UP[0]%%:*}: $msg" + log_state="$msg" + fi fi sleep 1 done diff --git a/docs/06-iphone-bt.md b/docs/06-iphone-bt.md index eb591e3..461f3ba 100644 --- a/docs/06-iphone-bt.md +++ b/docs/06-iphone-bt.md @@ -58,8 +58,8 @@ sequenceDiagram BZ->>PW: Gegenseite → Headset-Hörer (WirePlumber-Default) loop jede Sekunde, solange Uplink-Node existiert W->>PW: Portnamen ermitteln (HFP = mono, playback_MONO/capture_MONO) - W->>PW: callrec_mix → Uplink trennen, falls vorhanden (Echo-Quelle) W->>PW: Uplink ← micgate.out verlinken, falls fehlt + W->>PW: nur wenn micgate BESTÄTIGT anliegt: callrec_mix → Uplink trennen W->>PW: Gegenseite → callmix verlinken, falls fehlt (Node kommt später!) end IP->>BZ: Anruf endet @@ -72,8 +72,23 @@ sequenceDiagram - **Jeder Link wird in JEDEM Durchlauf geprüft, nicht einmal pro Anruf** — siehe Changelog 2026-07-30. - **Portnamen werden ermittelt, nicht hartcodiert.** HFP/mSBC ist mono, die Ports heißen `playback_MONO`/`capture_MONO`, nicht `input_FL`/`input_FR`. -- Kontrolle nach einem Anruf: `journalctl -t aime-iphone-route`. **Keine Zeile = der Watcher hat nichts - getan** und die Aufnahme enthält nur die Nahseite. +- **Erst verlinken, dann trennen.** `callrec_mix → Uplink` fliegt nur raus, wenn `micgate.out` am + Uplink-Port bestätigt anliegt — sonst bleibt der Uplink ohne Quelle und die Gegenseite hört Stille. +- ⚠️ **`pw-link -l ` filtert NICHT.** Bei einem Namen, der nicht als *Port* auflösbar ist, kommt der + **gesamte Graph** zurück. Jedes `grep` darauf ist immer wahr. Link-Zustand deshalb nur per `links_of` + (exakter Port-Header aus einem Graph-Dump) prüfen, nie per `grep` über die ganze Ausgabe. + +## Kontrolle nach einem Anruf + +`journalctl -t aime-iphone-route` — die eine Zeile sagt alles: + +| Ausgabe | Bedeutung | +|---|---| +| `+micgate -callrec_mix +far-end` | korrekt geroutet | +| keine Zeile | Watcher hat die HFP-Nodes nicht gesehen → Aufnahme hat nur die Nahseite | +| nur `-callrec_mix` | Uplink ohne Quelle → **Gegenseite hört nichts** (v2-Bug, behoben) | +| `!no-micgate-uplink-keeping-callrec_mix` | Uplink hörbar, aber mit Echo — micgate-Link ging nicht | +| `(no-far-end-node-yet)` | unkritisch, wenn eine spätere Zeile `+far-end` zeigt | ## Offen diff --git a/docs/08-changelog.md b/docs/08-changelog.md index ee749c9..8387ae7 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -3,7 +3,53 @@ Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum, Was, Warum, betroffene Dateien/Backups). -## 2026-07-30 — iPhone-Anruf: Gegenseite fehlt in der Aufnahme + Gegenseite hört sich doppelt +## 2026-07-30 (nachmittags) — v2 machte den Uplink stumm, v3 behebt es + +**Symptom (Nutzer, Testanruf 13:51):** „Die Gegenseite hat mich nicht mehr gehört." Regression aus v2 vom +selben Tag. Das Journal zeigte den Beweis direkt: + +``` +iPhone call bluez_output.B4_96_A5_42_BD_D6.1: -callrec_mix +``` + +Nur `-callrec_mix`, kein `+micgate`, kein `+far-end` — v2 hat WirePlumbers Link **getrennt** und dann +**nichts** verlinkt. Uplink ohne Quelle = die Gegenseite hört absolute Stille. Immerhin: v2 hat die +HFP-Nodes erstmals überhaupt erkannt, der Portnamen-Fix von vormittags greift also. + +**Ursache: `pw-link -l ` filtert nicht.** Reproduziert auf dem Board: mit einem Node-Namen, der nicht +als *Port* auflösbar ist, gibt `pw-link -l` den **gesamten Graph** aus (sogar `pw-link -l micgate.out` +liefert zusätzlich `micgate:playback_MONO`). Der Graph enthält immer `micgate.out` und `callmix` → + +- `grep -q micgate.out` → wahr → „schon verlinkt" → **micgate-Link übersprungen** +- `grep -q callmix` → wahr → **Gegenseiten-Link übersprungen** +- `grep -q callrec_mix` → wahr → das Trennen war die einzige Aktion, die je lief + +Derselbe Ausdruck steckte schon in v1, dort aber hinter dem Einmal-Guard, der ohnehin nie erreicht wurde. + +**Fix `aime-iphone-route.sh` v3 (`.bak-v2`):** + +- Neue `links_of PORT` parst die Peers **eines exakten Ports** aus einem einzigen Graph-Dump (awk: Header + auf Spalte 0 exakt vergleichen, eingerückte `|->`/`|<-`-Zeilen als Peers). Kein `grep` mehr über alles. +- **Sicherheitsbedingung: erst verlinken, dann trennen.** `callrec_mix → Uplink` wird nur entfernt, wenn + `micgate.out` am Uplink-Port **bestätigt** anliegt (Graph danach neu gelesen). Sonst bleibt der + WirePlumber-Link stehen und es wird `!no-micgate-uplink-keeping-callrec_mix` geloggt. Ein Echo ist + schlecht, unhörbar sein ist schlimmer. +- Log-Zustand als **Array** statt String: `CHANGED=" "` (nur Leerzeichen) hätte `[ -n ]` passiert und bei + fertig geroutetem Anruf jede Sekunde eine Journal-Zeile geschrieben. + +**Verifiziert:** `links_of` gegen den echten Graph (verlinkter Port, unverlinkter Port, nicht existierender +Port — jeweils exakt). Entscheidungslogik gegen einen synthetischen **Mono**-HFP-Graph mit gestubbtem +`pw-link`: Pass 1 (WirePlumber-Default aktiv) → `+micgate` zuerst, dann **beide** `callrec_mix`-Links weg, +Gegenseite in **beide** callmix-Kanäle, und **genau ein** micgate-Link in den Mono-Port (zwei wären +6 dB); +Pass 2 (fertig geroutet) → vollständiger No-op, keine Log-Zeile; Pass 3 (micgate nicht bestätigt) → +`callrec_mix` bleibt stehen, kein Trennen. Nach dem Neustart Graph unverändert bei 32 Links, Script idle +ohne Anruf. + +**Noch offen:** Gegenprobe am echten Anruf. Erwartet wird eine Zeile mit `+micgate`, `-callrec_mix` und +`+far-end`. Steht dort `!no-micgate-uplink-keeping-callrec_mix`, ist der Uplink hörbar aber mit Echo, und +der micgate-Link braucht eine eigene Analyse. + +## 2026-07-30 (vormittags) — iPhone-Anruf: Gegenseite fehlt in der Aufnahme + Gegenseite hört sich doppelt **Ausgangslage (Nutzer):** Ein BT-Telefonat wurde aufgenommen, in der Aufnahme ist nur die Nahseite zu hören; die Gegenseite berichtete zusätzlich, sich selbst stark doppelt zu hören. Der Pfad hatte am