Add reproducible Radxa AirPlay and Bluetooth mixer setup
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,195 @@
|
||||
# Radxa 무선 오디오 믹서
|
||||
|
||||
AirPlay 2와 블루투스 오디오 입력을 동시에 섞어 USB DAC 또는 블루투스
|
||||
헤드폰·스피커 한 대로 출력하는 설치 자료입니다.
|
||||
|
||||
검증 기준은 **Radxa Zero 3W / RAM 1 GB / Armbian 26.8.3 / Debian 13 trixie /
|
||||
커널 6.1.115-vendor-rk35xx**입니다. 다른 Radxa도 같은 OS와 오디오·블루투스
|
||||
지원이 있으면 이 구성으로 시도할 수 있지만, 모든 보드의 무선 드라이버를
|
||||
검증한 것은 아닙니다. 설치기는 Debian 13 외의 OS에서는 작업을 중단합니다.
|
||||
|
||||
## 구성
|
||||
|
||||
```text
|
||||
Mac/iPhone의 AirPlay 2 ── Shairport Sync ─┐
|
||||
├─ ALSA 스테레오 믹서 ─ USB DAC
|
||||
휴대폰 등의 Bluetooth ── BlueALSA ───────┘ 또는
|
||||
Loopback → Bluetooth 헤드폰/스피커
|
||||
```
|
||||
|
||||
- AirPlay 2: Shairport Sync **5.5.2**, NQPTP **1.2.8**, 공유 메모리 규격 smi10.
|
||||
- Bluetooth: Debian의 BlueALSA 패키지. 기준 보드 버전 **4.3.1**.
|
||||
- 수신과 송신은 A2DP 음악 프로필을 사용합니다. 기준 보드에서는 SBC로 연결됐습니다.
|
||||
- DAC 출력은 44.1 kHz / 스테레오 / 16비트, 블루투스용 가상 믹서는 48 kHz입니다.
|
||||
- 각 입력을 6 dB 낮춰 두 입력을 합칠 여유를 둡니다. 세 입력 이상은 각 기기 볼륨을
|
||||
추가로 낮추세요. 지연 없는 실시간 녹음/악기 모니터링용 구성은 아닙니다.
|
||||
- 각 송신 기기의 볼륨으로 믹스 비율을 바꿉니다.
|
||||
- 출력은 DAC 또는 블루투스 중 하나를 선택합니다. 양쪽 동시 출력은 구현하지 않았습니다.
|
||||
|
||||
## 1. 새 보드 준비
|
||||
|
||||
1. Debian 13 기반 Armbian을 설치하고 인터넷에 연결합니다.
|
||||
2. USB DAC를 꽂고 `aplay -l`에서 카드 이름을 확인합니다.
|
||||
3. 이 폴더 전체를 보드에 복사합니다. `sources/`도 필요합니다.
|
||||
|
||||
맥에서 실행할 예:
|
||||
|
||||
```bash
|
||||
scp -r /Users/jsahn/Personal_works/Radxa_MIXER root@새보드IP:/root/
|
||||
ssh root@새보드IP
|
||||
```
|
||||
|
||||
그다음 보드에서:
|
||||
|
||||
```bash
|
||||
cd /root/Radxa_MIXER
|
||||
aplay -l
|
||||
sudo bash install.sh --name "Radxa Desk" --dac hw:CARD=Audio,DEV=0
|
||||
```
|
||||
|
||||
`Audio`는 현재 Realtek USB DAC의 ALSA 카드 ID입니다. 새 DAC가 `Device`라면
|
||||
`--dac hw:CARD=Device,DEV=0`으로 바꾸세요. `card 1` 같은 숫자는 부팅마다 바뀔 수
|
||||
있으므로 이름을 사용합니다. 새 보드의 장치 이름은 기존 `Radxa Audio`와 구별해 주세요.
|
||||
|
||||
**동일한 AIC 드라이버의 Zero 3W**에서 이전처럼 AirPlay가 끊긴다면:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh --name "Radxa Desk" --dac hw:CARD=Audio,DEV=0 --wifi5
|
||||
```
|
||||
|
||||
`--wifi5`는 `aic8800_fdrv`의 `ps_on=0 he_on=0`을 부팅 설정으로 저장합니다.
|
||||
다른 드라이버에는 적용하지 않으며, 지원 옵션이 없으면 설치를 중단합니다.
|
||||
설정 적용은 다음 재부팅부터입니다. 설치 중 자동으로 재부팅하지 않습니다.
|
||||
|
||||
설치에는 apt 인터넷 연결이 필요합니다. 공식 소스는 이 폴더에 포함돼 있어
|
||||
GitHub에 접속할 필요가 없습니다. 1 GB 보드의 빌드 메모리를 고려해 기본은
|
||||
`make -j 1`이며 몇 분 이상 걸릴 수 있습니다. OS 전체 업그레이드는 하지 않습니다.
|
||||
|
||||
기본 출력은 **DAC**입니다. Wi-Fi 비밀번호, USB gadget 주소 `10.55.0.1`,
|
||||
netplan/NetworkManager 설정은 이 설치기가 변경하지 않습니다.
|
||||
|
||||
## 2. AirPlay 사용
|
||||
|
||||
MacBook 또는 아이폰의 출력에서 설치할 때 정한 이름을 선택합니다.
|
||||
Mac과 Radxa는 Bonjour/mDNS가 통하는 같은 네트워크에 있어야 합니다.
|
||||
게스트 Wi-Fi의 기기 격리 기능이 켜져 있으면 발견되지 않을 수 있습니다.
|
||||
|
||||
## 3. 휴대폰을 블루투스 입력으로 연결
|
||||
|
||||
보드의 SSH 터미널에서:
|
||||
|
||||
```bash
|
||||
sudo radxa-audio pair-input --seconds 120
|
||||
```
|
||||
|
||||
- 터미널을 열어 둔 채 휴대폰 Bluetooth 설정에서 `Radxa Desk Bluetooth`를 선택합니다.
|
||||
- 터미널에 본인 휴대폰의 오디오 서비스 승인 질문이 나오면 `yes`를 입력합니다.
|
||||
- 완료되면 `quit`을 입력합니다. 검색/페어링 모드는 종료 시 자동으로 닫힙니다.
|
||||
- 휴대폰을 신뢰 장치로 등록해, 다음 연결에서 같은 승인 대기가 생기지 않도록 합니다.
|
||||
|
||||
```bash
|
||||
sudo radxa-audio devices
|
||||
sudo radxa-audio pair 휴대폰MAC주소
|
||||
```
|
||||
|
||||
이미 페어링돼 있으면 `pair` 명령은 해당 상태를 확인하고 신뢰 등록 및 오디오
|
||||
연결을 진행합니다. 휴대폰의 출력 목록에서 `Radxa Desk Bluetooth`를 선택하세요.
|
||||
|
||||
## 4. 블루투스 출력 장비 연결 및 변경
|
||||
|
||||
보스 QC SC는 전원 스위치를 Bluetooth 표시 쪽으로 **약 3초** 유지해
|
||||
`Ready to connect` 안내와 파란 LED 점멸이 나오도록 합니다.
|
||||
다른 장비는 제조사의 페어링 모드를 켜세요.
|
||||
|
||||
```bash
|
||||
sudo radxa-audio scan --seconds 30
|
||||
sudo radxa-audio pair AA:BB:CC:DD:EE:FF --output
|
||||
```
|
||||
|
||||
`AA:BB:CC:DD:EE:FF`를 검색 결과의 **헤드폰/스피커 주소**로 바꿉니다.
|
||||
이 명령은 페어링 → 신뢰 등록 → 음악 프로필 연결 → 출력 전환을 진행합니다.
|
||||
이후 다른 헤드폰으로 바꾸려면 새 헤드폰을 검색하고 같은 명령을 실행하면 됩니다.
|
||||
|
||||
이미 등록한 장비로 전환:
|
||||
|
||||
```bash
|
||||
sudo radxa-audio output bluetooth AA:BB:CC:DD:EE:FF
|
||||
```
|
||||
|
||||
마지막으로 선택한 블루투스 장비를 다시 사용:
|
||||
|
||||
```bash
|
||||
sudo radxa-audio output bluetooth
|
||||
```
|
||||
|
||||
DAC로 복귀:
|
||||
|
||||
```bash
|
||||
sudo radxa-audio output dac
|
||||
```
|
||||
|
||||
출력 전환 후 AirPlay를 다시 선택해야 할 수 있습니다. 블루투스 출력 장비가
|
||||
연결되지 않으면 **현재 DAC 출력을 바꾸기 전에 오류로 중단**합니다.
|
||||
서비스 시작 중 실패하면 변경 직전 설정으로 복구합니다.
|
||||
|
||||
헤드폰을 믹서 출력으로 시험할 때는 헤드폰을 휴대폰이나 Mac에 직접 연결한
|
||||
경로 대신 Radxa 경로를 사용하세요. 헤드폰의 멀티포인트 자동 전환이 끼어들면
|
||||
믹서의 소리가 아닌 다른 기기의 소리를 들을 수 있습니다.
|
||||
|
||||
## 5. 일상 관리
|
||||
|
||||
```bash
|
||||
sudo radxa-audio status # 현재 출력/서비스/오디오 연결
|
||||
sudo radxa-audio devices # Bluetooth 목록 + DAC 목록
|
||||
sudo radxa-audio name "Radxa Living Room" # AirPlay/블루투스 표시 이름
|
||||
sudo radxa-audio dac hw:CARD=Device,DEV=0 # 다른 DAC로 설정
|
||||
sudo radxa-audio apply # 저장한 설정 다시 적용
|
||||
```
|
||||
|
||||
이전의 `radxa-audio-output dac` / `bose` 명령도 호환 래퍼로 제공합니다.
|
||||
`bose`는 마지막으로 설정한 블루투스 출력 장비를 뜻하며, 보스 주소가 고정돼 있지 않습니다.
|
||||
|
||||
관리 설정: `/etc/radxa-audio/mixer.json`.
|
||||
관리 명령은 여기에 저장한 값을 기준으로 ALSA/systemd 설정을 생성합니다.
|
||||
`/etc/asound.conf`나 서비스 파일을 직접 편집하면 다음 관리 명령에서 덮어쓸 수 있으므로,
|
||||
이름·DAC·출력 변경은 위 명령을 사용하세요.
|
||||
|
||||
## 6. 백업과 복구
|
||||
|
||||
설치 전 기존 파일과 서비스 상태는 `/var/backups/radxa-audio-install/날짜-번호/`에
|
||||
저장됩니다. 설치 실패 시 이 상태로 자동 복구를 시도합니다.
|
||||
|
||||
설치 완료 후에도 원래 상태로 복구할 수 있습니다:
|
||||
|
||||
```bash
|
||||
sudo python3 scripts/backup.py restore /var/backups/radxa-audio-install/실제백업이름
|
||||
```
|
||||
|
||||
복구는 설치기가 관리하는 설정·로컬 바이너리·서비스 상태를 대상으로 합니다.
|
||||
apt로 추가한 의존 패키지와 블루투스 페어링 기록은 유지합니다.
|
||||
운영 중 장비 변경 전 설정 백업은 `/var/backups/radxa-audio/`에 별도로 저장됩니다.
|
||||
|
||||
## 파일 안내
|
||||
|
||||
| 파일 | 용도 |
|
||||
|---|---|
|
||||
| `install.sh` | 새 보드 설치, 빌드, 서비스 설정, 실패 시 복구 |
|
||||
| `scripts/radxa-audio.py` | 페어링/출력/이름/DAC 관리 |
|
||||
| `scripts/backup.py` | 설치 전 백업/복원 |
|
||||
| `scripts/check-build.sh` | 서비스 변경 없이 고정 소스 빌드 확인 |
|
||||
| `sources/*.bundle`, `manifest.json` | 공식 Git 소스, 릴리스 태그, 커밋, SHA-256 |
|
||||
| `tests/test_manager.py` | 입력 검증, 안전한 전환, 실패 복구 검사 |
|
||||
| `TROUBLESHOOTING.md` | 검색/연결/무음/끊김 문제 해결 |
|
||||
| `VALIDATION.md` | 실제 검증 범위와 남은 검증 |
|
||||
| `reference/` | 기존 보드 설정 참고. 새 보드에 그대로 복사하지 않습니다. |
|
||||
|
||||
## 원본 프로젝트
|
||||
|
||||
- [Shairport Sync](https://github.com/mikebrady/shairport-sync)
|
||||
- [NQPTP](https://github.com/mikebrady/nqptp)
|
||||
- [BlueALSA](https://github.com/arkq/bluez-alsa)
|
||||
- [BlueALSA와 dmix/Loopback 구성](https://github.com/arkq/bluez-alsa/wiki/Using-bluealsa-with-dmix)
|
||||
- [Bose QC SC 페어링 안내](https://www.bose.com/p/headphones/bose-quietcomfort-sc-headphones/QCSC-HEADPHONEARN.html)
|
||||
|
||||
Git bundle 안에 각 원본 프로젝트의 라이선스 파일도 포함됩니다.
|
||||
이 설치 자료에는 개인 Wi-Fi 비밀번호나 블루투스 링크 키를 포함하지 않았습니다.
|
||||
@@ -0,0 +1,89 @@
|
||||
# 문제 해결
|
||||
|
||||
## Bluetooth 이름이 안 보임
|
||||
|
||||
새 휴대폰을 입력으로 등록하려면 `sudo radxa-audio pair-input`을 실행해 둡니다.
|
||||
검색 창은 기본 120초이며, 종료되면 다시 숨겨집니다. 이미 신뢰 등록한 장비는
|
||||
검색 모드를 계속 켜 두지 않아도 다시 연결할 수 있습니다.
|
||||
|
||||
헤드폰을 출력으로 연결하려면 헤드폰 자체의 페어링 모드를 켠 뒤
|
||||
`sudo radxa-audio scan`을 실행합니다. 헤드폰의 BLE 주소가 아니라
|
||||
`Bose QC Headphones`처럼 음악 장비로 표시되는 주소를 사용합니다.
|
||||
|
||||
## 휴대폰에 ‘연결됨’이지만 출력 선택에 없음
|
||||
|
||||
페어링만 됐고 A2DP 오디오 승인/연결이 안 된 경우가 있습니다:
|
||||
|
||||
```bash
|
||||
sudo radxa-audio devices
|
||||
sudo radxa-audio pair 휴대폰MAC주소
|
||||
sudo bluealsa-aplay -l
|
||||
```
|
||||
|
||||
`List of CAPTURE Bluetooth Devices`에 휴대폰과 `A2DP (SBC)`가 있으면
|
||||
Radxa가 휴대폰 소리를 받을 경로가 잡힌 상태입니다. 아이폰에서 출력 목록을
|
||||
닫았다가 다시 열어 선택합니다.
|
||||
|
||||
`List of PLAYBACK Bluetooth Devices`는 Radxa가 소리를 보내는 헤드폰/스피커입니다.
|
||||
이 두 목록은 입력과 출력을 구별하는 중요한 확인 지점입니다.
|
||||
|
||||
## 헤드폰에서 소리가 안 남
|
||||
|
||||
```bash
|
||||
sudo radxa-audio status
|
||||
sudo journalctl -u radxa-bt-output -u bluealsa -u bluealsa-aplay -n 60 --no-pager
|
||||
```
|
||||
|
||||
- 출력 설정이 `bluetooth`인지 확인합니다.
|
||||
- 헤드폰이 켜져 있고 PLAYBACK 목록에 나타나는지 확인합니다.
|
||||
- 휴대폰은 헤드폰에 직접 보내는 대신 Radxa Bluetooth를 선택합니다.
|
||||
- MacBook은 Radxa AirPlay를 다시 선택합니다.
|
||||
- 블루투스 출력은 헤드폰 버튼으로도 볼륨을 조절합니다.
|
||||
- 문제를 분리하려면 `sudo radxa-audio output dac`으로 돌아가 같은 입력이 들리는지 확인합니다.
|
||||
|
||||
## AirPlay 무음/끊김
|
||||
|
||||
```bash
|
||||
sudo journalctl -u shairport-sync -u nqptp -n 60 --no-pager
|
||||
ping -c 20 공유기IP
|
||||
aplay -l
|
||||
```
|
||||
|
||||
기준 Zero 3W에서는 AIC 드라이버의 Wi-Fi 6(HE)을 끄고 전원 절약을 끈 뒤
|
||||
끊김이 해결됐습니다. Wi-Fi 6 표준 자체가 원인이라고 단정한 것은 아닙니다.
|
||||
드라이버/공유기 조합의 호환 문제로 보고 동일 드라이버에만 옵션을 적용합니다.
|
||||
|
||||
`--wifi5`로 설치했다면 재부팅 후 다음 값이 `N`인지 확인합니다:
|
||||
|
||||
```bash
|
||||
cat /sys/module/aic8800_fdrv/parameters/he_on
|
||||
cat /sys/module/aic8800_fdrv/parameters/ps_on
|
||||
```
|
||||
|
||||
다른 무선 드라이버에는 이 경로/옵션이 없습니다.
|
||||
|
||||
## 같은 소리를 헤드폰과 스피커에서 듣는데 시차가 남
|
||||
|
||||
블루투스 인코딩/무선 전송에는 지연이 추가됩니다. 이 자료는 DAC 또는
|
||||
블루투스 중 하나를 출력으로 선택하며, 두 출력의 동기 재생은 지원하지 않습니다.
|
||||
|
||||
## 3개 이상 입력을 더하면 찢어짐
|
||||
|
||||
각 입력을 6 dB 낮춘 것은 두 입력 합산 기준입니다. 입력 수가 늘어나면
|
||||
각 기기의 볼륨을 더 낮춰 합산 클리핑을 피해야 합니다. 최대 안정적인
|
||||
블루투스 동시 입력 수는 보드 컨트롤러/드라이버와 실제 재생으로 검증해야 합니다.
|
||||
|
||||
## 설치 실패
|
||||
|
||||
오류 메시지와 설치 전 백업 경로를 확인합니다. `apt-get update`의 저장소
|
||||
버전 확인 요청이나 네트워크 오류부터 해결한 뒤 재실행합니다.
|
||||
|
||||
`modinfo snd-aloop` 실패는 해당 Armbian 커널에 ALSA Loopback 모듈이 없는 경우입니다.
|
||||
커널에 모듈이 준비되기 전에는 블루투스 믹스 출력 설치를 진행하지 않습니다.
|
||||
|
||||
소스/서비스를 바꾸지 않고 빌드만 확인하려면, 필요한 개발 패키지가 설치된
|
||||
보드에서 다음을 실행합니다:
|
||||
|
||||
```bash
|
||||
bash scripts/check-build.sh
|
||||
```
|
||||
@@ -0,0 +1,38 @@
|
||||
# 검증 기록 — 2026-10-12
|
||||
|
||||
## 완료
|
||||
|
||||
- 입력값 검증, 연결 없는 출력 전환 중단, 서비스 실패 시 설정 복구,
|
||||
설치 백업 복구/심볼릭 링크 보존, 등록 장비 재페어링 방지 등 **자동 검사 11개 통과**.
|
||||
- Bash 문법 검사와 ShellCheck 통과.
|
||||
- Python 문법 확인: macOS Python 3.14, 보드의 Debian Python에서 확인.
|
||||
- systemd의 새 `radxa-bt-output.service` 검증 통과.
|
||||
- 배포할 Git bundle에서 **새 작업 디렉토리로 복제하고 실제 보드에서 컴파일 성공**.
|
||||
- Shairport Sync: `5.5.2-AirPlay2-smi10-OpenSSL-Avahi-ALSA-soxr-sysconfdir:/etc`
|
||||
- NQPTP: 1.2.8 / 공유 메모리 규격 10.
|
||||
- 기존 얕은 Git 복제에서 만든 bundle의 누락 이력을 발견하고,
|
||||
완전한 이력이 있는 독립 bundle로 교체함. 최종 SHA-256은 manifest에 기록.
|
||||
- 기존 보드에 새 관리 명령을 설치하고 기존 이름/보스 출력 설정으로 이전 성공.
|
||||
- `radxa-audio output dac` → `output bluetooth` 왕복 확인 및 각 경로 무음 재생 성공.
|
||||
- 최종 보드 설정: Bluetooth 출력, Bose QC Headphones.
|
||||
- 보스 PLAYBACK(SBC 48 kHz)과 아이폰 CAPTURE(SBC 44.1 kHz) 동시 연결 확인.
|
||||
- 기존 Wi-Fi 안정화 옵션 `he_on=N`, `ps_on=N` 유지 확인.
|
||||
|
||||
## 실제 청취로 확인한 범위
|
||||
|
||||
사용자가 AirPlay 재생, AirPlay + 아이폰 Bluetooth 입력의 DAC 믹스 재생을
|
||||
정상 청취했다고 확인했습니다. 헤드폰의 실제 믹스 청취 확인은 아직 받지
|
||||
못했으며, 송신 프로필 연결 및 가상 믹서의 데이터 전달까지 검증했습니다.
|
||||
|
||||
## 아직 검증하지 않은 범위
|
||||
|
||||
- 새 SD 카드/새 OS에서 `install.sh` 전체를 처음부터 실행하는 시험.
|
||||
- 다른 Radxa 모델의 무선 드라이버와 블루투스 컨트롤러.
|
||||
- 여러 Bluetooth 입력 기기의 동시 음악 재생 한계.
|
||||
- 새 범용 출력 서비스의 재부팅/헤드폰 전원 껐다 켜기 이후 자동 재연결.
|
||||
- 패키지 저장소가 향후 바뀐 환경. AirPlay 소스는 고정했지만 apt 의존 패키지는
|
||||
Debian 13 저장소가 제공하는 버전을 사용합니다.
|
||||
|
||||
새 보드 설치 시 서비스가 모두 실행되는 것만으로 음질 검증을 마쳤다고
|
||||
보지 말고, AirPlay 단독 → Bluetooth 단독 → 두 입력 믹스 → 재부팅 후
|
||||
재연결 순서로 직접 들어 확인해 주세요.
|
||||
Executable
+157
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
# Run on the Radxa itself: sudo bash install.sh --name "Radxa Desk"
|
||||
set -euo pipefail
|
||||
PACKAGE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
AIRPLAY_NAME='Radxa Audio'
|
||||
DAC_PCM='hw:CARD=Audio,DEV=0'
|
||||
WIFI5=0
|
||||
JOBS=1
|
||||
usage() {
|
||||
cat <<'HELP'
|
||||
Usage: sudo bash install.sh [--name NAME] [--dac hw:CARD=Audio,DEV=0] [--wifi5] [--jobs 1|2]
|
||||
Default output is USB DAC. Pair a headphone after installation to select Bluetooth output.
|
||||
--wifi5 only applies to aic8800_fdrv with ps_on and he_on module parameters.
|
||||
HELP
|
||||
}
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--name) AIRPLAY_NAME=${2:?Missing name}; shift 2 ;;
|
||||
--dac) DAC_PCM=${2:?Missing DAC PCM}; shift 2 ;;
|
||||
--wifi5) WIFI5=1; shift ;;
|
||||
--jobs) JOBS=${2:?Missing jobs}; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[[ $EUID == 0 ]] || { echo 'sudo로 실행해 주세요.' >&2; exit 1; }
|
||||
[[ "$JOBS" == 1 || "$JOBS" == 2 ]] || { echo '--jobs는 1 또는 2입니다.' >&2; exit 1; }
|
||||
[[ $(uname -s) == Linux ]] || { echo 'Radxa의 Linux에서 실행해 주세요.' >&2; exit 1; }
|
||||
# This release is deliberately restricted to the package environment we inspected.
|
||||
# shellcheck source=/dev/null
|
||||
. /etc/os-release
|
||||
[[ ${ID:-} == debian && ${VERSION_ID:-} == 13 ]] || {
|
||||
echo '이 설치 자료는 Debian 13 기반 Armbian용입니다. 다른 OS에서는 패키지 검증이 필요합니다.' >&2; exit 1;
|
||||
}
|
||||
command -v python3 >/dev/null
|
||||
command -v systemctl >/dev/null
|
||||
systemctl --version >/dev/null
|
||||
python3 - "$PACKAGE_DIR" "$AIRPLAY_NAME" "$DAC_PCM" <<'PY'
|
||||
import hashlib, importlib.util, json, pathlib, sys
|
||||
base=pathlib.Path(sys.argv[1])
|
||||
spec=importlib.util.spec_from_file_location('manager',base/'scripts/radxa-audio.py')
|
||||
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
||||
m.validate(dict(name=sys.argv[2],dac_pcm=sys.argv[3],output='dac',bluetooth_device=''))
|
||||
manifest=json.loads((base/'sources/manifest.json').read_text())
|
||||
for source in manifest['sources']:
|
||||
p=base/'sources'/source['file']
|
||||
assert hashlib.sha256(p.read_bytes()).hexdigest()==source['sha256'], 'Source checksum mismatch: '+p.name
|
||||
PY
|
||||
if (( WIFI5 )); then
|
||||
modinfo aic8800_fdrv >/dev/null
|
||||
modinfo -p aic8800_fdrv | grep -q '^ps_on:'
|
||||
modinfo -p aic8800_fdrv | grep -q '^he_on:'
|
||||
fi
|
||||
STAMP=$(date +%Y%m%d-%H%M%S)-$$
|
||||
BACKUP_DIR=/var/backups/radxa-audio-install/$STAMP
|
||||
python3 "$PACKAGE_DIR/scripts/backup.py" save "$BACKUP_DIR"
|
||||
echo "기존 설정 백업: $BACKUP_DIR"
|
||||
failed() {
|
||||
local result=$?
|
||||
trap - EXIT
|
||||
if (( result != 0 )); then
|
||||
echo '설치 실패: 서비스와 관리 대상 설정을 복구합니다.' >&2
|
||||
python3 "$PACKAGE_DIR/scripts/backup.py" restore "$BACKUP_DIR" || {
|
||||
echo "자동 복구 실패. sudo python3 scripts/backup.py restore $BACKUP_DIR 로 확인해 주세요." >&2;
|
||||
}
|
||||
fi
|
||||
exit "$result"
|
||||
}
|
||||
trap failed EXIT
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
build-essential git autoconf automake libtool pkg-config xmltoman \
|
||||
libpopt-dev libconfig-dev libssl-dev libsoxr-dev libasound2-dev \
|
||||
libavahi-client-dev libavcodec-dev libavformat-dev libavutil-dev libswresample-dev uuid-dev \
|
||||
libplist-dev libsodium-dev libgcrypt20-dev avahi-daemon \
|
||||
bluez bluez-alsa-utils libasound2-plugin-bluez alsa-utils python3
|
||||
# Verify hardware and installed-package capabilities before switching outputs.
|
||||
[[ -d /sys/class/bluetooth/hci0 ]] || { echo '블루투스 컨트롤러가 없습니다.' >&2; exit 1; }
|
||||
modinfo snd-aloop >/dev/null
|
||||
bluealsa --help | grep -q a2dp-sink
|
||||
bluealsa --help | grep -q a2dp-source
|
||||
bluealsa-aplay --help | grep -q -- --volume
|
||||
runuser -u root -- aplay -D "$DAC_PCM" -t raw -f S16_LE -r 44100 -c 2 -d 1 /dev/zero
|
||||
BUILD_DIR=$(mktemp -d /var/tmp/radxa-audio-build.XXXXXX)
|
||||
echo "빌드 디렉토리: $BUILD_DIR"
|
||||
git clone --quiet --branch 1.2.8 "$PACKAGE_DIR/sources/nqptp-1.2.8.bundle" "$BUILD_DIR/nqptp"
|
||||
git clone --quiet --branch 5.5.2 "$PACKAGE_DIR/sources/shairport-sync-5.5.2.bundle" "$BUILD_DIR/shairport-sync"
|
||||
[[ $(git -C "$BUILD_DIR/nqptp" rev-parse HEAD) == c925f27c1fd12e4033ac477e5a405969b0b0260b ]]
|
||||
[[ $(git -C "$BUILD_DIR/shairport-sync" rev-parse HEAD) == 7bad231c18368dbd26f298577f6210e36e4b0797 ]]
|
||||
(
|
||||
cd "$BUILD_DIR/nqptp"
|
||||
autoreconf -fi
|
||||
./configure --prefix=/usr/local --with-systemd-startup --with-systemdsystemunitdir=/etc/systemd/system
|
||||
make -j "$JOBS"
|
||||
)
|
||||
(
|
||||
cd "$BUILD_DIR/shairport-sync"
|
||||
autoreconf -fi
|
||||
./configure --prefix=/usr/local --sysconfdir=/etc --with-alsa --with-soxr \
|
||||
--with-avahi --with-ssl=openssl --with-systemd-startup \
|
||||
--with-systemdsystemunitdir=/etc/systemd/system --with-airplay-2
|
||||
make -j "$JOBS"
|
||||
)
|
||||
# Finish both builds before stopping the existing receiver.
|
||||
systemctl stop shairport-sync nqptp bluealsa-aplay radxa-bt-output radxa-bose-output 2>/dev/null || true
|
||||
make -C "$BUILD_DIR/nqptp" install
|
||||
make -C "$BUILD_DIR/shairport-sync" install
|
||||
getent group shairport-sync >/dev/null || groupadd --system shairport-sync
|
||||
id shairport-sync >/dev/null 2>&1 || useradd --system --gid shairport-sync --no-create-home --shell /usr/sbin/nologin shairport-sync
|
||||
usermod -a -G audio shairport-sync
|
||||
install -m 755 "$PACKAGE_DIR/scripts/radxa-audio.py" /usr/local/bin/radxa-audio
|
||||
install -m 755 "$PACKAGE_DIR/scripts/radxa-audio-output" /usr/local/bin/radxa-audio-output
|
||||
cat > /etc/systemd/system/nqptp.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=NQPTP AirPlay 2 clock service
|
||||
Wants=network-online.target
|
||||
After=network.target network-online.target
|
||||
Before=shairport-sync.service
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/nqptp
|
||||
DynamicUser=yes
|
||||
LimitRTPRIO=6
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
cat > /etc/systemd/system/shairport-sync.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=Radxa AirPlay 2 audio receiver
|
||||
Wants=network-online.target nqptp.service
|
||||
After=network-online.target avahi-daemon.service nqptp.service sound.target
|
||||
Requires=avahi-daemon.service
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/shairport-sync
|
||||
User=shairport-sync
|
||||
Group=shairport-sync
|
||||
LimitRTPRIO=5
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
if (( WIFI5 )); then
|
||||
mkdir -p /etc/modprobe.d
|
||||
printf '%s\n' '# AIC driver compatibility settings; effective after reboot.' \
|
||||
'options aic8800_fdrv ps_on=0 he_on=0' > /etc/modprobe.d/90-radxa-airplay-wifi.conf
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
/usr/local/bin/shairport-sync -V | grep -q '5.5.2-AirPlay2.*smi10'
|
||||
/usr/local/bin/nqptp -V | grep -q '1.2.8.*10'
|
||||
radxa-audio init --name "$AIRPLAY_NAME" --dac "$DAC_PCM"
|
||||
radxa-audio status
|
||||
trap - EXIT
|
||||
echo "설치 완료. 복구: sudo python3 $PACKAGE_DIR/scripts/backup.py restore $BACKUP_DIR"
|
||||
if (( WIFI5 )); then echo 'Wi-Fi 5 설정 적용을 위해 편할 때 재부팅하세요. USB 네트워크 주소 설정은 변경하지 않았습니다.'; fi
|
||||
@@ -0,0 +1,26 @@
|
||||
# 기존 보드 참고 (2026-10-12)
|
||||
|
||||
- 보드: Radxa Zero 3W, RAM 1 GB, eMMC 없음.
|
||||
- 시스템: Armbian 26.8.3, Debian trixie, 커널 6.1.115-vendor-rk35xx.
|
||||
- AirPlay 표시 이름: Radxa Audio.
|
||||
- Bluetooth 표시 이름: Radxa Audio Bluetooth.
|
||||
- USB DAC: Realtek USB2.0 Audio, ALSA `hw:CARD=Audio,DEV=0`.
|
||||
- 블루투스 출력: Bose QC Headphones (사용자 모델 QC SC).
|
||||
- 출력 장비 주소: `E4:58:BC:C3:3B:F8` (기존 장비 참고만; 새 설치 기본값이 아님).
|
||||
- Wi-Fi 드라이버: aic8800_fdrv; `ps_on=0 he_on=0` 설정을 재부팅 후 확인함.
|
||||
- USB 관리 주소 10.55.0.1/24는 별도 gadget/systemd-networkd 설정이며,
|
||||
이 오디오 설치 자료가 생성하거나 변경하지 않음.
|
||||
|
||||
최초 AirPlay → DAC 및 AirPlay + iPhone Bluetooth → DAC 믹스 재생은
|
||||
사용자가 정상 청취를 확인했습니다. Bose 헤드폰은 페어링/신뢰 등록 및
|
||||
A2DP SBC 48 kHz 출력 연결과 가상 믹서 전송까지 확인했습니다.
|
||||
헤드폰에서 실제 두 소리를 청취했다는 확인은 아직 받지 못했습니다.
|
||||
|
||||
기존 설정 백업은 보드의 `/root/audio-mixer-backup-20261012-162401/`와
|
||||
`/root/radxa-airplay2-backup-20261011-193842/`에 있습니다.
|
||||
새 관리 명령 전환 전 백업:
|
||||
`/var/backups/radxa-audio-install/manager-migration-20261012-170529/`.
|
||||
기존 `radxa-bose-output.service`는 비활성화하고, 범용
|
||||
`radxa-bt-output.service`를 사용합니다.
|
||||
|
||||
개인 Wi-Fi 설정, SSH 인증 정보, Bluetooth 링크 키는 패키지에 복사하지 않았습니다.
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Snapshot/restore mixer-managed files and service states, not apt packages."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
UNITS = ['shairport-sync', 'nqptp', 'bluealsa', 'bluealsa-aplay',
|
||||
'radxa-bt-output', 'radxa-bose-output', 'avahi-daemon', 'bluetooth']
|
||||
PATHS = [
|
||||
'/etc/asound.conf', '/etc/shairport-sync.conf',
|
||||
'/etc/radxa-audio/mixer.json', '/etc/radxa-audio/asound-dac.conf',
|
||||
'/etc/radxa-audio/asound-bluetooth.conf',
|
||||
'/etc/systemd/system/shairport-sync.service', '/etc/systemd/system/nqptp.service',
|
||||
'/etc/systemd/system/bluealsa.service.d/10-radxa-sink.conf',
|
||||
'/etc/systemd/system/bluealsa-aplay.service.d/10-radxa-mixer.conf',
|
||||
'/etc/systemd/system/radxa-bt-output.service',
|
||||
'/etc/modules-load.d/radxa-audio-loopback.conf',
|
||||
'/etc/modprobe.d/radxa-audio-loopback.conf',
|
||||
'/etc/modprobe.d/90-radxa-airplay-wifi.conf',
|
||||
'/usr/local/bin/shairport-sync', '/usr/local/bin/nqptp',
|
||||
'/usr/local/bin/radxa-audio', '/usr/local/bin/radxa-audio-output',
|
||||
]
|
||||
|
||||
|
||||
def ctl(*args):
|
||||
return subprocess.run(['systemctl', *args], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True)
|
||||
|
||||
|
||||
def snapshot(directory):
|
||||
directory.mkdir(parents=True, exist_ok=False, mode=0o700)
|
||||
files = {}
|
||||
for name in PATHS:
|
||||
path = Path(name)
|
||||
files[name] = os.path.lexists(path)
|
||||
if files[name]:
|
||||
target = directory / 'files' / name.lstrip('/')
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, target, follow_symlinks=False)
|
||||
states = {u: {'active': ctl('is-active', '--quiet', u).returncode == 0,
|
||||
'enabled': ctl('is-enabled', '--quiet', u).returncode == 0} for u in UNITS}
|
||||
(directory / 'manifest.json').write_text(json.dumps({'files': files, 'units': states}, indent=2))
|
||||
|
||||
|
||||
def restore(directory):
|
||||
manifest = json.loads((directory / 'manifest.json').read_text())
|
||||
# Only restore allowlisted paths from our own snapshot.
|
||||
if set(manifest['files']) != set(PATHS) or set(manifest['units']) != set(UNITS):
|
||||
raise ValueError('알 수 없는 백업 형식입니다.')
|
||||
for unit in UNITS[:6]: ctl('stop', unit)
|
||||
for name, existed in manifest['files'].items():
|
||||
target = Path(name)
|
||||
if existed:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.unlink(missing_ok=True)
|
||||
shutil.copy2(directory / 'files' / name.lstrip('/'), target, follow_symlinks=False)
|
||||
else:
|
||||
target.unlink(missing_ok=True)
|
||||
ctl('daemon-reload')
|
||||
for unit, state in manifest['units'].items():
|
||||
ctl('enable' if state['enabled'] else 'disable', unit)
|
||||
# Bluetooth and Avahi may have been started by apt during installation.
|
||||
for unit in reversed(UNITS):
|
||||
state = manifest['units'][unit]
|
||||
result = ctl('restart' if state['active'] else 'stop', unit)
|
||||
if state['active'] and result.returncode:
|
||||
print(result.stdout, file=sys.stderr)
|
||||
raise ValueError('서비스 복원에 실패했습니다: ' + unit)
|
||||
print('복구 완료:', directory)
|
||||
print('apt로 설치한 의존 패키지는 유지합니다. 블루투스 페어링 기록은 삭제하지 않습니다.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if os.geteuid() != 0: sys.exit('sudo로 실행해 주세요.')
|
||||
if len(sys.argv) != 3 or sys.argv[1] not in ('save', 'restore'):
|
||||
sys.exit('Usage: backup.py save|restore DIRECTORY')
|
||||
if sys.argv[1] == 'save': snapshot(Path(sys.argv[2]))
|
||||
else: restore(Path(sys.argv[2]))
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# Compile the pinned sources without installing or stopping audio services.
|
||||
set -euo pipefail
|
||||
PACKAGE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
BUILD_DIR=$(mktemp -d /tmp/radxa-build-check.XXXXXX)
|
||||
echo "Build check directory: $BUILD_DIR"
|
||||
git clone -q --branch 1.2.8 "$PACKAGE_DIR/sources/nqptp-1.2.8.bundle" "$BUILD_DIR/nqptp"
|
||||
git clone -q --branch 5.5.2 "$PACKAGE_DIR/sources/shairport-sync-5.5.2.bundle" "$BUILD_DIR/shairport-sync"
|
||||
(
|
||||
cd "$BUILD_DIR/nqptp"
|
||||
autoreconf -fi
|
||||
./configure --prefix=/usr/local --with-systemd-startup --with-systemdsystemunitdir=/etc/systemd/system
|
||||
make -j 1
|
||||
./nqptp -V
|
||||
)
|
||||
(
|
||||
cd "$BUILD_DIR/shairport-sync"
|
||||
autoreconf -fi
|
||||
./configure --prefix=/usr/local --sysconfdir=/etc --with-alsa --with-soxr \
|
||||
--with-avahi --with-ssl=openssl --with-systemd-startup \
|
||||
--with-systemdsystemunitdir=/etc/systemd/system --with-airplay-2
|
||||
make -j 1
|
||||
./shairport-sync -V
|
||||
)
|
||||
echo "Sources compiled successfully; no installed services were modified. Build tree: $BUILD_DIR"
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
exec /usr/local/bin/radxa-audio output "$@"
|
||||
Executable
+390
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Radxa audio mixer manager. Debian 13 / systemd; Python standard library only."""
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
CONFIG = Path('/etc/radxa-audio/mixer.json')
|
||||
BT_SERVICE = 'radxa-bt-output.service'
|
||||
MAC_RE = re.compile(r'(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\Z')
|
||||
PCM_RE = re.compile(r'hw:CARD=[A-Za-z0-9_-]+,DEV=[0-9]+(?:,SUBDEV=[0-9]+)?\Z')
|
||||
|
||||
|
||||
def run(*args, check=True, capture=False, timeout=40):
|
||||
return subprocess.run(args, check=check, text=True,
|
||||
stdout=subprocess.PIPE if capture else None,
|
||||
stderr=subprocess.STDOUT if capture else None, timeout=timeout)
|
||||
|
||||
|
||||
def root():
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError('sudo로 실행해 주세요.')
|
||||
|
||||
|
||||
def validate(c):
|
||||
if not isinstance(c.get('name'), str) or not c['name'].strip() or len(c['name'].encode()) > 80:
|
||||
raise ValueError('AirPlay 이름은 1~80바이트여야 합니다.')
|
||||
if any(ord(ch) < 32 for ch in c['name']):
|
||||
raise ValueError('이름에는 제어 문자를 사용할 수 없습니다.')
|
||||
if not PCM_RE.fullmatch(c.get('dac_pcm', '')):
|
||||
raise ValueError('DAC 형식 예: hw:CARD=Audio,DEV=0')
|
||||
if c.get('output') not in ('dac', 'bluetooth'):
|
||||
raise ValueError('output은 dac 또는 bluetooth여야 합니다.')
|
||||
addr = c.get('bluetooth_device', '')
|
||||
if addr and not MAC_RE.fullmatch(addr):
|
||||
raise ValueError('블루투스 주소 형식이 잘못됐습니다.')
|
||||
if c['output'] == 'bluetooth' and not addr:
|
||||
raise ValueError('먼저 블루투스 출력 장비 주소를 설정해 주세요.')
|
||||
return c
|
||||
|
||||
|
||||
def load():
|
||||
if not CONFIG.exists():
|
||||
raise ValueError('설치되지 않았습니다. install.sh를 먼저 실행해 주세요.')
|
||||
return validate(json.loads(CONFIG.read_text()))
|
||||
|
||||
|
||||
def save(path, text, mode=0o644):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp = tempfile.mkstemp(dir=path.parent, prefix='.radxa-')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
f.write(text)
|
||||
os.chmod(temp, mode)
|
||||
os.replace(temp, path)
|
||||
finally:
|
||||
if os.path.exists(temp):
|
||||
os.unlink(temp)
|
||||
|
||||
|
||||
def mixer(pcm, rate):
|
||||
return f'''# Managed by radxa-audio; each input has 6 dB of headroom.
|
||||
pcm.radxa_dmix {{
|
||||
type dmix
|
||||
ipc_key 553001
|
||||
ipc_key_add_uid false
|
||||
ipc_perm 0660
|
||||
ipc_gid audio
|
||||
slave {{
|
||||
pcm "{pcm}"
|
||||
format S16_LE
|
||||
rate {rate}
|
||||
channels 2
|
||||
period_time 0
|
||||
period_size {rate // 100}
|
||||
buffer_size {rate // 2}
|
||||
}}
|
||||
bindings {{ 0 0 1 1 }}
|
||||
}}
|
||||
pcm.radxa_mix {{
|
||||
type plug
|
||||
slave.pcm "radxa_dmix"
|
||||
ttable {{ 0.0 0.5 1.1 0.5 }}
|
||||
}}
|
||||
'''
|
||||
|
||||
|
||||
def templates(c):
|
||||
validate(c)
|
||||
dac = mixer(c['dac_pcm'], 44100)
|
||||
bt = mixer('hw:CARD=Loopback,DEV=0,SUBDEV=0', 48000)
|
||||
files = {
|
||||
'/etc/radxa-audio/asound-dac.conf': dac,
|
||||
'/etc/radxa-audio/asound-bluetooth.conf': bt,
|
||||
'/etc/asound.conf': dac if c['output'] == 'dac' else bt,
|
||||
str(CONFIG): json.dumps(c, ensure_ascii=False, indent=2) + '\n',
|
||||
'/etc/shairport-sync.conf': f'''general = {{
|
||||
name = {json.dumps(c['name'], ensure_ascii=False)};
|
||||
service_type = "airplay2";
|
||||
output_backend = "alsa";
|
||||
audio_backend_buffer_desired_length_in_seconds = 0.5;
|
||||
}};
|
||||
alsa = {{
|
||||
output_device = "radxa_mix";
|
||||
output_format = "S16_LE";
|
||||
}};
|
||||
diagnostics = {{ statistics = "yes"; log_verbosity = 1; }};
|
||||
''',
|
||||
'/etc/systemd/system/bluealsa.service.d/10-radxa-sink.conf': '''[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/bluealsa -S -p a2dp-sink -p a2dp-source --initial-volume=50
|
||||
''',
|
||||
'/etc/systemd/system/bluealsa-aplay.service.d/10-radxa-mixer.conf': '''[Unit]
|
||||
Requires=bluealsa.service
|
||||
After=bluealsa.service sound.target
|
||||
[Service]
|
||||
User=shairport-sync
|
||||
Group=audio
|
||||
PrivateUsers=false
|
||||
RemoveIPC=false
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/bluealsa-aplay -S --pcm=radxa_mix --volume=software --pcm-period-time=10000 --pcm-buffer-time=500000
|
||||
''',
|
||||
'/etc/modules-load.d/radxa-audio-loopback.conf': 'snd-aloop\n',
|
||||
'/etc/modprobe.d/radxa-audio-loopback.conf': 'options snd-aloop pcm_substreams=1\n',
|
||||
}
|
||||
if c.get('bluetooth_device'):
|
||||
addr = c['bluetooth_device'].upper()
|
||||
files['/etc/systemd/system/' + BT_SERVICE] = f'''[Unit]
|
||||
Description=Radxa mixed audio to Bluetooth headphones or speaker
|
||||
Requires=bluealsa.service
|
||||
After=bluealsa.service sound.target systemd-modules-load.service
|
||||
PartOf=bluealsa.service
|
||||
StartLimitIntervalSec=0
|
||||
[Service]
|
||||
User=shairport-sync
|
||||
Group=audio
|
||||
ExecStartPre=-/usr/bin/bluetoothctl --timeout 8 connect {addr} a2dp-sink
|
||||
ExecStart=/usr/bin/alsaloop -C hw:Loopback,1,0 -P bluealsa:DEV={addr},PROFILE=a2dp -c 2 -r 48000 -f S16_LE -t 200000 -S 4 -A 2
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStartSec=30
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
'''
|
||||
return files
|
||||
|
||||
|
||||
def snapshot(paths):
|
||||
stamp = datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f')
|
||||
directory = Path('/var/backups/radxa-audio') / stamp
|
||||
directory.mkdir(parents=True, mode=0o700)
|
||||
records = {}
|
||||
for name in paths:
|
||||
path = Path(name)
|
||||
records[name] = os.path.lexists(path)
|
||||
if records[name]:
|
||||
dest = directory / name.lstrip('/')
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, dest, follow_symlinks=False)
|
||||
save(directory / 'files.json', json.dumps(records, indent=2) + '\n', 0o600)
|
||||
return directory
|
||||
|
||||
|
||||
def restore_files(directory):
|
||||
records = json.loads((directory / 'files.json').read_text())
|
||||
for name, existed in records.items():
|
||||
path = Path(name)
|
||||
if existed:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.unlink(missing_ok=True)
|
||||
shutil.copy2(directory / name.lstrip('/'), path, follow_symlinks=False)
|
||||
else:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def active(unit):
|
||||
return run('systemctl', 'is-active', '--quiet', unit, check=False, capture=True).returncode == 0
|
||||
|
||||
|
||||
def enabled(unit):
|
||||
return run('systemctl', 'is-enabled', '--quiet', unit, check=False, capture=True).returncode == 0
|
||||
|
||||
|
||||
def restart_inputs():
|
||||
run('systemctl', 'restart', 'shairport-sync', 'bluealsa-aplay')
|
||||
|
||||
|
||||
def check_dac(pcm):
|
||||
parts = dict(item.split("=", 1) for item in pcm.removeprefix("hw:").split(","))
|
||||
if not (Path("/proc/asound") / parts["CARD"] / ("pcm" + parts["DEV"] + "p")).exists():
|
||||
raise ValueError("DAC 출력 장치가 없습니다. aplay -l에서 카드 ID를 확인해 주세요.")
|
||||
|
||||
|
||||
def apply(c, initial=False):
|
||||
root()
|
||||
files = templates(c)
|
||||
if c["output"] == "dac":
|
||||
check_dac(c["dac_pcm"])
|
||||
if c['output'] == 'bluetooth':
|
||||
# Preflight before changing a working DAC route.
|
||||
run('modprobe', 'snd-aloop')
|
||||
result = run('bluetoothctl', '--timeout', '10', 'connect',
|
||||
c['bluetooth_device'], 'a2dp-sink', check=False, capture=True)
|
||||
pcm_list = run('bluealsa-aplay', '-l', capture=True).stdout
|
||||
playback = pcm_list.split('**** List of CAPTURE')[0]
|
||||
if c['bluetooth_device'].upper() not in playback.upper():
|
||||
raise ValueError('출력 장비의 A2DP 연결이 없습니다. 전원을 켜고 pair 명령을 먼저 실행해 주세요.\n' + result.stdout)
|
||||
units = ['shairport-sync', 'bluealsa-aplay', BT_SERVICE, 'radxa-bose-output.service']
|
||||
states = {u: (active(u), enabled(u)) for u in units}
|
||||
backup = snapshot(files)
|
||||
print('설정 백업:', backup, flush=True)
|
||||
try:
|
||||
stopping = [u for u in units if states[u][0]]
|
||||
if stopping:
|
||||
run('systemctl', 'stop', *stopping)
|
||||
for path, text in files.items():
|
||||
save(path, text)
|
||||
run('systemctl', 'daemon-reload')
|
||||
run('systemctl', 'disable', 'radxa-bose-output.service', check=False, capture=True)
|
||||
if initial:
|
||||
run('systemctl', 'enable', 'avahi-daemon', 'bluetooth', 'nqptp',
|
||||
'shairport-sync', 'bluealsa', 'bluealsa-aplay')
|
||||
run('systemctl', 'restart', 'bluetooth')
|
||||
run('systemctl', 'start', 'avahi-daemon', 'nqptp')
|
||||
run('systemctl', 'restart', 'bluealsa')
|
||||
if c['output'] == 'bluetooth':
|
||||
run('systemctl', 'enable', BT_SERVICE)
|
||||
run('systemctl', 'start', BT_SERVICE)
|
||||
else:
|
||||
run('systemctl', 'disable', BT_SERVICE, check=False, capture=True)
|
||||
run('systemctl', 'start', 'shairport-sync', 'bluealsa-aplay')
|
||||
required = ['shairport-sync', 'bluealsa-aplay']
|
||||
if c['output'] == 'bluetooth':
|
||||
required.append(BT_SERVICE)
|
||||
if not all(active(u) for u in required):
|
||||
raise ValueError('서비스 시작에 실패했습니다.')
|
||||
alias = c['name'] + ' Bluetooth'
|
||||
run('bluetoothctl', 'system-alias', alias)
|
||||
print('출력:', c['output'], c.get('bluetooth_device', '') if c['output'] == 'bluetooth' else c['dac_pcm'])
|
||||
except Exception:
|
||||
run('systemctl', 'stop', *units, check=False)
|
||||
restore_files(backup)
|
||||
run('systemctl', 'daemon-reload', check=False)
|
||||
for u, (was_active, was_enabled) in states.items():
|
||||
run('systemctl', 'enable' if was_enabled else 'disable', u, check=False, capture=True)
|
||||
if was_active:
|
||||
run('systemctl', 'start', u, check=False)
|
||||
print('설정을 이전 상태로 복구했습니다.', file=sys.stderr)
|
||||
raise
|
||||
|
||||
|
||||
def pair(addr, output=False):
|
||||
root()
|
||||
if not MAC_RE.fullmatch(addr):
|
||||
raise ValueError('장비 주소 형식이 잘못됐습니다.')
|
||||
info = run('bluetoothctl', 'info', addr, check=False, capture=True).stdout
|
||||
if 'Paired: 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')
|
||||
try:
|
||||
run('bluetoothctl', '--agent', 'NoInputNoOutput', '--timeout', '25', 'pair', addr, check=False)
|
||||
finally:
|
||||
if not was_pairable:
|
||||
run('bluetoothctl', 'pairable', 'off', check=False)
|
||||
info = run('bluetoothctl', 'info', addr, capture=True).stdout
|
||||
if 'Paired: yes' not in info:
|
||||
raise ValueError('페어링되지 않았습니다. 장비를 페어링 모드로 켜 주세요.')
|
||||
run('bluetoothctl', 'trust', addr)
|
||||
run('bluetoothctl', '--timeout', '10', 'connect', addr,
|
||||
'a2dp-sink' if output else 'a2dp-source', check=False)
|
||||
if output:
|
||||
c = load()
|
||||
c['bluetooth_device'] = addr.upper()
|
||||
c['output'] = 'bluetooth'
|
||||
apply(c)
|
||||
print('페어링/신뢰 등록 완료:', addr)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description='Radxa AirPlay + Bluetooth 믹서 관리')
|
||||
sub = p.add_subparsers(dest='cmd', required=True)
|
||||
sub.add_parser('status')
|
||||
sub.add_parser('devices')
|
||||
q = sub.add_parser('scan'); q.add_argument('--seconds', type=int, default=30)
|
||||
q = sub.add_parser('pair'); q.add_argument('address'); q.add_argument('--output', action='store_true')
|
||||
q = sub.add_parser('pair-input'); q.add_argument('--seconds', type=int, default=120)
|
||||
q = sub.add_parser('output'); q.add_argument('mode', choices=['dac', 'bluetooth', 'bose']); q.add_argument('address', nargs='?')
|
||||
q = sub.add_parser('name'); q.add_argument('name')
|
||||
q = sub.add_parser('dac'); q.add_argument('pcm')
|
||||
sub.add_parser('apply')
|
||||
q = sub.add_parser('init'); q.add_argument('--name', required=True); q.add_argument('--dac', default='hw:CARD=Audio,DEV=0')
|
||||
q.add_argument('--adopt', action='store_true'); q.add_argument('--address', default='')
|
||||
q.add_argument('--output', choices=['dac', 'bluetooth'], default='dac')
|
||||
a = p.parse_args()
|
||||
if a.cmd == 'devices':
|
||||
run('bluetoothctl', 'devices'); run('aplay', '-l'); run('bluealsa-aplay', '-l')
|
||||
elif a.cmd == 'status':
|
||||
print(json.dumps(load(), ensure_ascii=False, indent=2), flush=True)
|
||||
run('systemctl', 'is-active', 'shairport-sync', 'nqptp', 'bluealsa', 'bluealsa-aplay', BT_SERVICE, check=False)
|
||||
run('bluealsa-aplay', '-l')
|
||||
elif a.cmd == 'scan':
|
||||
root()
|
||||
if not 5 <= a.seconds <= 120: raise ValueError('검색 시간: 5~120초')
|
||||
# Keep the discovery client alive; stop discovery on timeout/interrupt.
|
||||
try:
|
||||
run('bluetoothctl', '--timeout', str(a.seconds), 'scan', 'bredr', check=False, timeout=a.seconds + 10)
|
||||
finally:
|
||||
run('bluetoothctl', 'scan', 'off', check=False)
|
||||
run('bluetoothctl', 'devices')
|
||||
elif a.cmd == 'pair':
|
||||
pair(a.address, a.output)
|
||||
elif a.cmd == 'pair-input':
|
||||
root()
|
||||
if not 15 <= a.seconds <= 900: raise ValueError('페어링 시간: 15~900초')
|
||||
run('bluetoothctl', 'discoverable-timeout', str(a.seconds))
|
||||
# Interactive agent: approve the intended phone's service with "yes".
|
||||
print('휴대폰에서 연결하세요. 서비스 승인 질문에는 yes를 입력하고, 완료 후 quit을 입력하세요.', flush=True)
|
||||
try:
|
||||
# Relay a PTY so service approval answers still come from the operator.
|
||||
interactive_pair(a.seconds)
|
||||
finally:
|
||||
run('bluetoothctl', 'discoverable', 'off', check=False)
|
||||
run('bluetoothctl', 'pairable', 'off', check=False)
|
||||
print('서비스를 승인한 휴대폰은 sudo bluetoothctl trust 주소 로 신뢰 등록해 주세요.')
|
||||
elif a.cmd == 'init':
|
||||
apply(dict(name=a.name, dac_pcm=a.dac, output=a.output, bluetooth_device=a.address), initial=not a.adopt)
|
||||
else:
|
||||
c = load()
|
||||
if a.cmd == 'output':
|
||||
c['output'] = 'bluetooth' if a.mode in ('bluetooth', 'bose') else 'dac'
|
||||
if a.address:
|
||||
if c['output'] != 'bluetooth': raise ValueError('장비 주소는 bluetooth 출력에서만 지정합니다.')
|
||||
c['bluetooth_device'] = a.address.upper()
|
||||
elif a.cmd == 'name': c['name'] = a.name
|
||||
elif a.cmd == 'dac': c['dac_pcm'] = a.pcm
|
||||
apply(c)
|
||||
|
||||
|
||||
def interactive_pair(seconds):
|
||||
import pty
|
||||
import select
|
||||
import time
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.execvp('bluetoothctl', ['bluetoothctl', '--agent', 'NoInputNoOutput'])
|
||||
end = time.monotonic() + seconds
|
||||
# Do not send asynchronous agent registration commands in a single batch.
|
||||
queue = ['default-agent', 'pairable on', 'discoverable on']
|
||||
next_command = time.monotonic() + 1.5
|
||||
try:
|
||||
while time.monotonic() < end:
|
||||
ready, _, _ = select.select([fd, sys.stdin], [], [], 0.25)
|
||||
if fd in ready:
|
||||
try: data = os.read(fd, 8192)
|
||||
except OSError: break
|
||||
if not data: break
|
||||
os.write(sys.stdout.fileno(), data)
|
||||
if sys.stdin in ready:
|
||||
data = os.read(sys.stdin.fileno(), 8192)
|
||||
if not data: break
|
||||
os.write(fd, data)
|
||||
if queue and time.monotonic() >= next_command:
|
||||
os.write(fd, (queue.pop(0) + '\n').encode())
|
||||
next_command = time.monotonic() + 1.5
|
||||
finally:
|
||||
try: os.write(fd, b'quit\n')
|
||||
except OSError: pass
|
||||
os.close(fd)
|
||||
os.waitpid(pid, 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (ValueError, subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e:
|
||||
print('오류:', e, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print('\n중단했습니다.', file=sys.stderr)
|
||||
sys.exit(130)
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"name": "shairport-sync",
|
||||
"version": "5.5.2",
|
||||
"commit": "7bad231c18368dbd26f298577f6210e36e4b0797",
|
||||
"url": "https://github.com/mikebrady/shairport-sync",
|
||||
"file": "shairport-sync-5.5.2.bundle",
|
||||
"sha256": "6b852cea503300c90c0f08fed501b47367bf96097d3e169d5a43f910f40897bc"
|
||||
},
|
||||
{
|
||||
"name": "nqptp",
|
||||
"version": "1.2.8",
|
||||
"commit": "c925f27c1fd12e4033ac477e5a405969b0b0260b",
|
||||
"url": "https://github.com/mikebrady/nqptp",
|
||||
"file": "nqptp-1.2.8.bundle",
|
||||
"sha256": "0efab7d2e295981243cbd61c0d5cb940ec775ad517b02f23a748f1f6ab6a9095"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,46 @@
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec=importlib.util.spec_from_file_location('backup',Path(__file__).parents[1]/'scripts/backup.py')
|
||||
b=importlib.util.module_from_spec(spec); spec.loader.exec_module(b)
|
||||
|
||||
|
||||
class BackupTests(unittest.TestCase):
|
||||
def test_restores_original_file_and_removes_new_file(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
base=Path(d); original=base/'old.conf'; new=base/'new.conf'
|
||||
original.write_text('original')
|
||||
def ctl(*args): return subprocess.CompletedProcess(args, 1, stdout='')
|
||||
with patch.object(b,'PATHS',[str(original),str(new)]), patch.object(b,'ctl',side_effect=ctl):
|
||||
b.snapshot(base/'backup')
|
||||
original.write_text('changed'); new.write_text('new')
|
||||
b.restore(base/'backup')
|
||||
self.assertEqual(original.read_text(),'original')
|
||||
self.assertFalse(new.exists())
|
||||
|
||||
def test_preserves_symlinks(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
base=Path(d); link=base/'service'; original=base/'target'
|
||||
original.write_text('original'); link.symlink_to(original)
|
||||
def ctl(*args): return subprocess.CompletedProcess(args, 1, stdout='')
|
||||
with patch.object(b,'PATHS',[str(link)]), patch.object(b,'ctl',side_effect=ctl):
|
||||
b.snapshot(base/'backup')
|
||||
link.unlink(); link.write_text('replaced')
|
||||
b.restore(base/'backup')
|
||||
self.assertTrue(link.is_symlink())
|
||||
self.assertEqual(link.read_text(),'original')
|
||||
|
||||
def test_rejects_snapshot_with_unexpected_file(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
base=Path(d)
|
||||
(base/'manifest.json').write_text(json.dumps({'files':{'/unexpected':True},'units':{}}))
|
||||
with patch.object(b,'ctl') as ctl, self.assertRaises(ValueError): b.restore(base)
|
||||
ctl.assert_not_called()
|
||||
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
@@ -0,0 +1,79 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec=importlib.util.spec_from_file_location('manager',Path(__file__).parents[1]/'scripts/radxa-audio.py')
|
||||
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
||||
|
||||
|
||||
class ManagerTests(unittest.TestCase):
|
||||
def config(self, **extra):
|
||||
return dict(dict(name='Radxa Desk', dac_pcm='hw:CARD=Audio,DEV=0',
|
||||
output='dac', bluetooth_device=''), **extra) if not extra else {
|
||||
**self.config(), **extra}
|
||||
|
||||
def test_rejects_unsafe_hardware_and_addresses(self):
|
||||
for key, value in [('dac_pcm', 'hw:CARD=Audio"; include "evil'),
|
||||
('bluetooth_device', 'AA:BB:CC:DD:EE:FF;reboot'),
|
||||
('name', 'line\nbreak')]:
|
||||
with self.subTest(key=key), self.assertRaises(ValueError):
|
||||
m.templates(self.config(**{key:value}))
|
||||
|
||||
def test_bluetooth_requires_address_before_any_action(self):
|
||||
with patch.object(m, 'root'), patch.object(m, 'run') as run:
|
||||
with self.assertRaises(ValueError): m.apply(self.config(output='bluetooth'))
|
||||
run.assert_not_called()
|
||||
|
||||
def test_unavailable_headphone_does_not_stop_working_inputs(self):
|
||||
def fake(*args, **kwargs):
|
||||
return subprocess.CompletedProcess(args, 0, stdout='no device')
|
||||
with patch.object(m, 'root'), patch.object(m, 'run', side_effect=fake) as run, patch.object(m, 'snapshot') as snapshot:
|
||||
with self.assertRaises(ValueError):
|
||||
m.apply(self.config(output='bluetooth',bluetooth_device='AA:BB:CC:DD:EE:FF'))
|
||||
snapshot.assert_not_called()
|
||||
self.assertFalse(any('stop' in c.args for c in run.call_args_list))
|
||||
|
||||
def test_two_stereo_inputs_share_same_mixer(self):
|
||||
f=m.templates(self.config())
|
||||
self.assertIn('"radxa_mix"',f['/etc/shairport-sync.conf'])
|
||||
self.assertIn('--pcm=radxa_mix',f['/etc/systemd/system/bluealsa-aplay.service.d/10-radxa-mixer.conf'])
|
||||
self.assertNotIn('--single-audio', ''.join(f.values()))
|
||||
self.assertIn('ttable { 0.0 0.5 1.1 0.5 }',f['/etc/asound.conf'])
|
||||
self.assertIn('rate 44100',f['/etc/asound.conf'])
|
||||
|
||||
def test_output_device_is_not_hardcoded(self):
|
||||
address='AA:BB:CC:DD:EE:FF'
|
||||
f=m.templates(self.config(output='bluetooth',bluetooth_device=address))
|
||||
unit=f['/etc/systemd/system/radxa-bt-output.service']
|
||||
self.assertIn('DEV='+address,unit)
|
||||
self.assertNotIn('E4:58',unit)
|
||||
self.assertIn('Loopback', f['/etc/asound.conf'])
|
||||
self.assertIn('rate 48000',f['/etc/asound.conf'])
|
||||
|
||||
def test_name_quotes_are_escaped(self):
|
||||
f=m.templates(self.config(name='Desk "Two"'))
|
||||
self.assertIn('name = "Desk \\"Two\\"";',f['/etc/shairport-sync.conf'])
|
||||
|
||||
def test_pair_skips_repairing_registered_device(self):
|
||||
def fake(*args, **kwargs):
|
||||
return subprocess.CompletedProcess(args, 0, stdout='Paired: yes')
|
||||
with patch.object(m, 'root'), patch.object(m, 'run', side_effect=fake) as run:
|
||||
m.pair('AA:BB:CC:DD:EE:FF')
|
||||
self.assertFalse(any('pair' in call.args for call in run.call_args_list))
|
||||
self.assertTrue(any('trust' in call.args for call in run.call_args_list))
|
||||
|
||||
def test_failed_service_restores_configuration(self):
|
||||
c=self.config()
|
||||
with patch.object(m, 'root'), patch.object(m, 'run') as run, \
|
||||
patch.object(m, 'snapshot', return_value=Path('/mock-backup')), \
|
||||
patch.object(m, 'restore_files') as restore, \
|
||||
patch.object(m, 'save'), patch.object(m, 'check_dac'), patch.object(m, 'active', return_value=False), \
|
||||
patch.object(m, 'enabled', return_value=False):
|
||||
with self.assertRaises(ValueError): m.apply(c)
|
||||
restore.assert_called_once_with(Path('/mock-backup'))
|
||||
self.assertTrue(any(c.args[:2]==('systemctl','daemon-reload') for c in run.call_args_list))
|
||||
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
Reference in New Issue
Block a user