#!/bin/bash # Auto-route iPhone HFP calls (the bluez nodes exist only during a live call): # uplink (what far-end hears) <- micgate.out (GATED mic only -> no self-echo) # 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. # # 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() { pw-link -i 2>/dev/null | grep -E "^$1"; } ports_out() { pw-link -o 2>/dev/null | grep -E "^$1"; } # 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 } ' } 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[@]} -eq 0 ]; then log_state="" sleep 1 continue fi 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 # 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 CH+=("!no-micgate-uplink-keeping-callrec_mix") fi done # --- 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 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