Add reproducible Radxa AirPlay and Bluetooth mixer setup

This commit is contained in:
jsahn
2026-10-12 17:16:51 +09:00
commit ceef64dfed
15 changed files with 1151 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+195
View File
@@ -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 비밀번호나 블루투스 링크 키를 포함하지 않았습니다.
+89
View File
@@ -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
```
+38
View File
@@ -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
View File
@@ -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
+26
View File
@@ -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 링크 키는 패키지에 복사하지 않았습니다.
+81
View File
@@ -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]))
+25
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
set -euo pipefail
exec /usr/local/bin/radxa-audio output "$@"
+390
View File
@@ -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)
+20
View File
@@ -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.
+46
View File
@@ -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()
+79
View File
@@ -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()