Fix Bluetooth bonding and record verified headphone relay fix

One-shot bluetoothctl pairing exited before SSP finished, leaving the
headphone paired but not bonded, so it was lost on reboot. Pair inside a
single bluetoothctl session with a persistent agent, and clear temporary
pairings first. Document that SBC medium + 500 ms buffer removed the
iPhone -> Radxa -> headphone dropouts, verified by listening and logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jsahn
2026-10-12 19:01:36 +09:00
co-authored by Claude Opus 5.5
parent 5fbece12bb
commit 432eca1755
6 changed files with 59 additions and 14 deletions
+2 -1
View File
@@ -21,7 +21,7 @@ Mac/iPhone의 AirPlay 2 ── Shairport Sync ─┐
- Bluetooth: Debian의 BlueALSA 패키지. 기준 보드 버전 **4.3.1**. - Bluetooth: Debian의 BlueALSA 패키지. 기준 보드 버전 **4.3.1**.
- 수신과 송신은 A2DP 음악 프로필을 사용합니다. 기준 보드에서는 SBC로 연결됐습니다. - 수신과 송신은 A2DP 음악 프로필을 사용합니다. 기준 보드에서는 SBC로 연결됐습니다.
- SBC 송신 품질은 medium입니다. 기준 보드에서 high로 동시 수신·송신 시 끊겨 - SBC 송신 품질은 medium입니다. 기준 보드에서 high로 동시 수신·송신 시 끊겨
전송량을 줄였습니다. 송신 버퍼는 500 ms이며 안정성은 청취 검증이 필요합니다. 전송량을 줄였습니다. 송신 버퍼는 500 ms이며, 이 조합에서 끊김 없는 청취를 확인했습니다.
- DAC 출력은 44.1 kHz / 스테레오 / 16비트, 블루투스용 가상 믹서는 48 kHz입니다. - DAC 출력은 44.1 kHz / 스테레오 / 16비트, 블루투스용 가상 믹서는 48 kHz입니다.
- 각 입력을 3 dB 낮춥니다. 여러 입력의 큰 소리가 겹쳐 찢어지면 입력 볼륨을 낮추세요. 지연 없는 실시간 녹음/악기 모니터링용 구성은 아닙니다. - 각 입력을 3 dB 낮춥니다. 여러 입력의 큰 소리가 겹쳐 찢어지면 입력 볼륨을 낮추세요. 지연 없는 실시간 녹음/악기 모니터링용 구성은 아닙니다.
- 각 송신 기기의 볼륨으로 믹스 비율을 바꿉니다. - 각 송신 기기의 볼륨으로 믹스 비율을 바꿉니다.
@@ -157,6 +157,7 @@ sudo radxa-audio pair AA:BB:CC:DD:EE:FF --output
재부팅 후 등록을 유지하려면 장비를 페어링 모드로 켠 뒤 재부팅 후 등록을 유지하려면 장비를 페어링 모드로 켠 뒤
`sudo radxa-audio pair 주소 --output --bond`를 사용하세요. 웹의 새 장비 등록도 `sudo radxa-audio pair 주소 --output --bond`를 사용하세요. 웹의 새 장비 등록도
영구 등록 성공을 확인하며, 인증 실패는 성공으로 표시하지 않습니다. 영구 등록 성공을 확인하며, 인증 실패는 성공으로 표시하지 않습니다.
임시 등록만 남은 장비는 기록을 지운 뒤 하나의 bluetoothctl 세션에서 다시 페어링합니다.
이미 영구 등록된 장비는 다시 페어링하지 않습니다. 이미 영구 등록된 장비는 다시 페어링하지 않습니다.
이미 등록한 장비로 전환: 이미 등록한 장비로 전환:
+7 -2
View File
@@ -109,8 +109,13 @@ sudo journalctl -u radxa-mixer-web -n 40 --no-pager
에서 `Missing RTP packets`는 입력 수신 패킷 손실, `underrun`/`overrun`은 에서 `Missing RTP packets`는 입력 수신 패킷 손실, `underrun`/`overrun`은
오디오 버퍼 오류를 뜻합니다. 수신 손실은 버퍼만 늘려 복구되지 않습니다. 오디오 버퍼 오류를 뜻합니다. 수신 손실은 버퍼만 늘려 복구되지 않습니다.
같은 입력을 DAC로 보낼 때와 Bluetooth 출력으로 보낼 때를 비교하세요. 같은 입력을 DAC로 보낼 때와 Bluetooth 출력으로 보낼 때를 비교하세요.
현재 송신 버퍼는 500 ms이며 지연이 추가됩니다. 같은 컨트롤러의 동시 기준 보드에서는 버퍼만 500 ms로 늘려서는 끊김이 남았고, 송신 SBC 품질을
수신·송신 안정성은 실제 청취 검증이 필요합니다. high에서 medium으로 낮춘 뒤(`bluealsa --sbc-quality=medium`) 해결됐습니다.
같은 컨트롤러로 수신과 송신을 동시에 하므로 송신 전송량이 수신 손실에 영향을 줍니다.
현재 송신 버퍼는 500 ms이며 그만큼 지연이 추가됩니다.
청취 확인 시에는 `bluealsa-aplay -l`의 PLAYBACK 목록에 헤드폰이 있는지 먼저 봅니다.
없으면 헤드폰이 휴대폰에 직접 연결돼 Radxa를 거치지 않은 소리일 수 있습니다.
## Bluetooth 컨트롤러 점검 ## Bluetooth 컨트롤러 점검
+16 -4
View File
@@ -41,15 +41,27 @@ medium과 500 ms 버퍼를 저장했고 출력 등록 복구 후 다시 검증
확인했습니다. Bluetooth 서비스 재시작만으로 복구되지 않아 보드를 재부팅했습니다. 확인했습니다. Bluetooth 서비스 재시작만으로 복구되지 않아 보드를 재부팅했습니다.
SBC 품질 변경으로 컨트롤러 오류가 해결됐다고 보지는 않습니다. SBC 품질 변경으로 컨트롤러 오류가 해결됐다고 보지는 않습니다.
보스는 현재 `Paired: yes / Bonded: no`인 임시 연결입니다. 영구 등록 시도가 재부팅 후 Wi-Fi/USB 관리 연결이 돌아오지 않아 전원을 재연결했고,
인증 실패하여 재부팅 후 보스 등록 유지가 검증됐다고 주장하지 않습니다. 컨트롤러는 정상 초기화됐습니다. 500 ms 버퍼와 SBC medium 설정은 유지됐습니다.
임시 등록이던 보스는 재부팅으로 등록이 사라졌습니다.
일회성 `bluetoothctl --agent NoInputNoOutput pair`는 인증 완료 전에 종료돼
`Bonded: no`로 남았던 것으로 보고, 하나의 bluetoothctl 세션에서
에이전트 등록 → 검색 → 페어링을 진행하도록 바꿨습니다. 이 방식으로 보스가
`Paired/Bonded/Trusted: yes`로 영구 등록됐습니다.
2026-10-12 19:00 전후로 iPhone → Radxa Bluetooth → 보스 경로(PCM 연결을
`bluealsa-aplay -l`로 확인)에서 사용자가 정상 청취를 확인했습니다. 해당 구간
로그에서 `Missing RTP packets`, underrun/overrun, HCI 시간 초과는 없었습니다.
따라서 SBC medium + 500 ms 버퍼 조합으로 끊김이 해소된 것으로 판단합니다.
장시간 재생 안정성은 아직 확인하지 않았습니다.
이미 영구 등록된 휴대폰 등록은 재부팅 후 유지됐습니다. 이미 영구 등록된 휴대폰 등록은 재부팅 후 유지됐습니다.
## 실제 청취로 확인한 범위 ## 실제 청취로 확인한 범위
사용자가 AirPlay 재생, AirPlay + 아이폰 Bluetooth 입력의 DAC 믹스 재생을 사용자가 AirPlay 재생, AirPlay + 아이폰 Bluetooth 입력의 DAC 믹스 재생을
정상 청취했다고 확인했습니다. 헤드폰의 실제 믹스 청취 확인은 아직 받지 정상 청취했다고 확인했습니다. iPhone Bluetooth 입력 → 보스 헤드폰 출력도
못했으며, 송신 프로필 연결 및 가상 믹서의 데이터 전달까지 검증했습니다. SBC medium / 500 ms 버퍼 설정에서 끊김 없이 청취했다고 확인했습니다.
## 아직 검증하지 않은 범위 ## 아직 검증하지 않은 범위
+3 -3
View File
@@ -9,7 +9,7 @@
- 출력 장비 주소: `E4:58:BC:C3:3B:F8` (기존 장비 참고만; 새 설치 기본값이 아님). - 출력 장비 주소: `E4:58:BC:C3:3B:F8` (기존 장비 참고만; 새 설치 기본값이 아님).
- 웹 믹서: `http://192.168.10.83:8088`, `radxa-mixer-web.service` 자동 시작. - 웹 믹서: `http://192.168.10.83:8088`, `radxa-mixer-web.service` 자동 시작.
- 공통 감쇠 −3 dB. AirPlay는 기존 네이티브 볼륨만 제어. - 공통 감쇠 −3 dB. AirPlay는 기존 네이티브 볼륨만 제어.
- 보스는 현재 임시 등록(`Bonded: no`)이며 영구 등록은 추가 확인 필요. - 보스는 영구 등록(`Bonded: yes`, Trusted) 상태.
- Wi-Fi 드라이버: aic8800_fdrv; `ps_on=0 he_on=0` 설정을 재부팅 후 확인함. - Wi-Fi 드라이버: aic8800_fdrv; `ps_on=0 he_on=0` 설정을 재부팅 후 확인함.
- USB 관리 주소 10.55.0.1/24는 별도 gadget/systemd-networkd 설정이며, - USB 관리 주소 10.55.0.1/24는 별도 gadget/systemd-networkd 설정이며,
이 오디오 설치 자료가 생성하거나 변경하지 않음. 이 오디오 설치 자료가 생성하거나 변경하지 않음.
@@ -18,8 +18,8 @@
사용자가 정상 청취를 확인했습니다. Bose 헤드폰은 페어링/신뢰 등록 및 사용자가 정상 청취를 확인했습니다. Bose 헤드폰은 페어링/신뢰 등록 및
A2DP SBC 48 kHz 출력 연결과 가상 믹서 전송까지 확인했습니다. A2DP SBC 48 kHz 출력 연결과 가상 믹서 전송까지 확인했습니다.
사용자는 이후 iPhone → 보스 경로의 소리 끊김을 보고했습니다. 수신 패킷 손실과 사용자는 이후 iPhone → 보스 경로의 소리 끊김을 보고했습니다. 수신 패킷 손실과
버퍼 오류를 확인했습니다. 500 ms 버퍼와 SBC medium 송신 설정을 적용했으나, 버퍼 오류를 확인했습니다. 500 ms 버퍼와 SBC medium 송신 설정을 적용하고
보스 출력 연결이 실패하여 경로 복구와 청취 검증이 필요합니다. 보스를 영구 등록한 뒤, iPhone → Radxa → 보스 경로의 정상 청취를 확인했습니다.
기존 설정 백업은 보드의 `/root/audio-mixer-backup-20261012-162401/`와 기존 설정 백업은 보드의 `/root/audio-mixer-backup-20261012-162401/`와
`/root/radxa-airplay2-backup-20261011-193842/`에 있습니다. `/root/radxa-airplay2-backup-20261011-193842/`에 있습니다.
+27 -3
View File
@@ -269,12 +269,13 @@ def pair(addr, output=False, bond=False):
raise ValueError('장비 주소 형식이 잘못됐습니다.') raise ValueError('장비 주소 형식이 잘못됐습니다.')
info = run('bluetoothctl', 'info', addr, check=False, capture=True).stdout info = run('bluetoothctl', 'info', addr, check=False, capture=True).stdout
if 'Paired: yes' not in info or (bond and 'Bonded: yes' not in info): if 'Paired: yes' not in info or (bond and 'Bonded: yes' not in info):
# Outgoing pairing agent lives only for this operation.
controller = run('bluetoothctl', 'show', capture=True).stdout controller = run('bluetoothctl', 'show', capture=True).stdout
was_pairable = 'Pairable: yes' in controller was_pairable = 'Pairable: yes' in controller
run('bluetoothctl', 'pairable', 'on') if 'Paired: yes' in info:
# A temporary (unbonded) pairing blocks a new link key; start clean.
run('bluetoothctl', 'remove', addr, check=False, capture=True)
try: try:
run('bluetoothctl', '--agent', 'NoInputNoOutput', '--timeout', '25', 'pair', addr, check=False) agent_pair(addr)
finally: finally:
if not was_pairable: if not was_pairable:
run('bluetoothctl', 'pairable', 'off', check=False) run('bluetoothctl', 'pairable', 'off', check=False)
@@ -294,6 +295,29 @@ def pair(addr, output=False, bond=False):
print('페어링/신뢰 등록 완료:', addr) print('페어링/신뢰 등록 완료:', addr)
def agent_pair(addr, scan_seconds=12, pair_seconds=20):
"""Pair inside one bluetoothctl session so the agent stays registered.
A one-shot `bluetoothctl --agent ... pair` can exit before Secure Simple
Pairing finishes, which leaves the device paired but not bonded.
"""
import time
proc = subprocess.Popen(['bluetoothctl'], stdin=subprocess.PIPE, text=True)
steps = [('agent NoInputNoOutput', 1), ('default-agent', 1), ('pairable on', 1),
('scan on', scan_seconds), ('scan off', 1), ('pair ' + addr, pair_seconds)]
try:
for command, wait in steps:
proc.stdin.write(command + '\n')
proc.stdin.flush()
time.sleep(wait)
proc.stdin.write('quit\n')
proc.stdin.flush()
proc.wait(timeout=10)
except (OSError, subprocess.TimeoutExpired):
proc.kill()
proc.wait()
def main(): def main():
p = argparse.ArgumentParser(description='Radxa AirPlay + Bluetooth 믹서 관리') p = argparse.ArgumentParser(description='Radxa AirPlay + Bluetooth 믹서 관리')
sub = p.add_subparsers(dest='cmd', required=True) sub = p.add_subparsers(dest='cmd', required=True)
+4 -1
View File
@@ -69,8 +69,11 @@ class ManagerTests(unittest.TestCase):
def test_bond_request_must_not_report_temporary_pair_as_success(self): def test_bond_request_must_not_report_temporary_pair_as_success(self):
def fake(*args, **kwargs): def fake(*args, **kwargs):
return subprocess.CompletedProcess(args,0,stdout='Paired: yes\nBonded: no') return subprocess.CompletedProcess(args,0,stdout='Paired: yes\nBonded: no')
with patch.object(m,'root'),patch.object(m,'run',side_effect=fake) as run: with patch.object(m,'root'),patch.object(m,'run',side_effect=fake) as run, \
patch.object(m,'agent_pair') as agent:
with self.assertRaises(ValueError):m.pair('AA:BB:CC:DD:EE:FF',output=True,bond=True) with self.assertRaises(ValueError):m.pair('AA:BB:CC:DD:EE:FF',output=True,bond=True)
agent.assert_called_once_with('AA:BB:CC:DD:EE:FF')
self.assertTrue(any('remove' in call.args for call in run.call_args_list))
self.assertFalse(any('trust' in call.args for call in run.call_args_list)) self.assertFalse(any('trust' in call.args for call in run.call_args_list))
def test_bond_request_keeps_existing_bond(self): def test_bond_request_keeps_existing_bond(self):