diff --git a/README.md b/README.md index a1ee264..4f2bbed 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Mac/iPhone의 AirPlay 2 ── Shairport Sync ─┐ - Bluetooth: Debian의 BlueALSA 패키지. 기준 보드 버전 **4.3.1**. - 수신과 송신은 A2DP 음악 프로필을 사용합니다. 기준 보드에서는 SBC로 연결됐습니다. - SBC 송신 품질은 medium입니다. 기준 보드에서 high로 동시 수신·송신 시 끊겨 - 전송량을 줄였습니다. 송신 버퍼는 500 ms이며 안정성은 청취 검증이 필요합니다. + 전송량을 줄였습니다. 송신 버퍼는 500 ms이며, 이 조합에서 끊김 없는 청취를 확인했습니다. - DAC 출력은 44.1 kHz / 스테레오 / 16비트, 블루투스용 가상 믹서는 48 kHz입니다. - 각 입력을 3 dB 낮춥니다. 여러 입력의 큰 소리가 겹쳐 찢어지면 입력 볼륨을 낮추세요. 지연 없는 실시간 녹음/악기 모니터링용 구성은 아닙니다. - 각 송신 기기의 볼륨으로 믹스 비율을 바꿉니다. @@ -157,6 +157,7 @@ sudo radxa-audio pair AA:BB:CC:DD:EE:FF --output 재부팅 후 등록을 유지하려면 장비를 페어링 모드로 켠 뒤 `sudo radxa-audio pair 주소 --output --bond`를 사용하세요. 웹의 새 장비 등록도 영구 등록 성공을 확인하며, 인증 실패는 성공으로 표시하지 않습니다. +임시 등록만 남은 장비는 기록을 지운 뒤 하나의 bluetoothctl 세션에서 다시 페어링합니다. 이미 영구 등록된 장비는 다시 페어링하지 않습니다. 이미 등록한 장비로 전환: diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 6249b12..15e5feb 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -109,8 +109,13 @@ sudo journalctl -u radxa-mixer-web -n 40 --no-pager 에서 `Missing RTP packets`는 입력 수신 패킷 손실, `underrun`/`overrun`은 오디오 버퍼 오류를 뜻합니다. 수신 손실은 버퍼만 늘려 복구되지 않습니다. 같은 입력을 DAC로 보낼 때와 Bluetooth 출력으로 보낼 때를 비교하세요. -현재 송신 버퍼는 500 ms이며 지연이 추가됩니다. 같은 컨트롤러의 동시 -수신·송신 안정성은 실제 청취 검증이 필요합니다. +기준 보드에서는 버퍼만 500 ms로 늘려서는 끊김이 남았고, 송신 SBC 품질을 +high에서 medium으로 낮춘 뒤(`bluealsa --sbc-quality=medium`) 해결됐습니다. +같은 컨트롤러로 수신과 송신을 동시에 하므로 송신 전송량이 수신 손실에 영향을 줍니다. +현재 송신 버퍼는 500 ms이며 그만큼 지연이 추가됩니다. + +청취 확인 시에는 `bluealsa-aplay -l`의 PLAYBACK 목록에 헤드폰이 있는지 먼저 봅니다. +없으면 헤드폰이 휴대폰에 직접 연결돼 Radxa를 거치지 않은 소리일 수 있습니다. ## Bluetooth 컨트롤러 점검 diff --git a/VALIDATION.md b/VALIDATION.md index c1aa781..9bea725 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -41,15 +41,27 @@ medium과 500 ms 버퍼를 저장했고 출력 등록 복구 후 다시 검증 확인했습니다. Bluetooth 서비스 재시작만으로 복구되지 않아 보드를 재부팅했습니다. 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 믹스 재생을 -정상 청취했다고 확인했습니다. 헤드폰의 실제 믹스 청취 확인은 아직 받지 -못했으며, 송신 프로필 연결 및 가상 믹서의 데이터 전달까지 검증했습니다. +정상 청취했다고 확인했습니다. iPhone Bluetooth 입력 → 보스 헤드폰 출력도 +SBC medium / 500 ms 버퍼 설정에서 끊김 없이 청취했다고 확인했습니다. ## 아직 검증하지 않은 범위 diff --git a/reference/CURRENT-BOARD.md b/reference/CURRENT-BOARD.md index 8f18f74..f5fbbd7 100644 --- a/reference/CURRENT-BOARD.md +++ b/reference/CURRENT-BOARD.md @@ -9,7 +9,7 @@ - 출력 장비 주소: `E4:58:BC:C3:3B:F8` (기존 장비 참고만; 새 설치 기본값이 아님). - 웹 믹서: `http://192.168.10.83:8088`, `radxa-mixer-web.service` 자동 시작. - 공통 감쇠 −3 dB. AirPlay는 기존 네이티브 볼륨만 제어. -- 보스는 현재 임시 등록(`Bonded: no`)이며 영구 등록은 추가 확인 필요. +- 보스는 영구 등록(`Bonded: yes`, Trusted) 상태. - Wi-Fi 드라이버: aic8800_fdrv; `ps_on=0 he_on=0` 설정을 재부팅 후 확인함. - USB 관리 주소 10.55.0.1/24는 별도 gadget/systemd-networkd 설정이며, 이 오디오 설치 자료가 생성하거나 변경하지 않음. @@ -18,8 +18,8 @@ 사용자가 정상 청취를 확인했습니다. Bose 헤드폰은 페어링/신뢰 등록 및 A2DP SBC 48 kHz 출력 연결과 가상 믹서 전송까지 확인했습니다. 사용자는 이후 iPhone → 보스 경로의 소리 끊김을 보고했습니다. 수신 패킷 손실과 -버퍼 오류를 확인했습니다. 500 ms 버퍼와 SBC medium 송신 설정을 적용했으나, -보스 출력 연결이 실패하여 경로 복구와 청취 검증이 필요합니다. +버퍼 오류를 확인했습니다. 500 ms 버퍼와 SBC medium 송신 설정을 적용하고 +보스를 영구 등록한 뒤, iPhone → Radxa → 보스 경로의 정상 청취를 확인했습니다. 기존 설정 백업은 보드의 `/root/audio-mixer-backup-20261012-162401/`와 `/root/radxa-airplay2-backup-20261011-193842/`에 있습니다. diff --git a/scripts/radxa-audio.py b/scripts/radxa-audio.py index 240e22c..04695d1 100755 --- a/scripts/radxa-audio.py +++ b/scripts/radxa-audio.py @@ -269,12 +269,13 @@ def pair(addr, output=False, bond=False): raise ValueError('장비 주소 형식이 잘못됐습니다.') info = run('bluetoothctl', 'info', addr, check=False, capture=True).stdout 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 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: - run('bluetoothctl', '--agent', 'NoInputNoOutput', '--timeout', '25', 'pair', addr, check=False) + agent_pair(addr) finally: if not was_pairable: run('bluetoothctl', 'pairable', 'off', check=False) @@ -294,6 +295,29 @@ def pair(addr, output=False, bond=False): 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(): p = argparse.ArgumentParser(description='Radxa AirPlay + Bluetooth 믹서 관리') sub = p.add_subparsers(dest='cmd', required=True) diff --git a/tests/test_manager.py b/tests/test_manager.py index f63dcff..2eada4b 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -69,8 +69,11 @@ class ManagerTests(unittest.TestCase): def test_bond_request_must_not_report_temporary_pair_as_success(self): def fake(*args, **kwargs): 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) + 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)) def test_bond_request_keeps_existing_bond(self):