commit ceef64dfed3cff7b8638d66ce1c04b2bbf8ae1c3 Author: jsahn Date: Mon Oct 12 17:16:51 2026 +0900 Add reproducible Radxa AirPlay and Bluetooth mixer setup diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc241f4 --- /dev/null +++ b/README.md @@ -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 비밀번호나 블루투스 링크 키를 포함하지 않았습니다. diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md new file mode 100644 index 0000000..a51bdfa --- /dev/null +++ b/TROUBLESHOOTING.md @@ -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 +``` diff --git a/VALIDATION.md b/VALIDATION.md new file mode 100644 index 0000000..f8007a1 --- /dev/null +++ b/VALIDATION.md @@ -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 단독 → 두 입력 믹스 → 재부팅 후 +재연결 순서로 직접 들어 확인해 주세요. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..29d7e6f --- /dev/null +++ b/install.sh @@ -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 diff --git a/reference/CURRENT-BOARD.md b/reference/CURRENT-BOARD.md new file mode 100644 index 0000000..d0d680c --- /dev/null +++ b/reference/CURRENT-BOARD.md @@ -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 링크 키는 패키지에 복사하지 않았습니다. diff --git a/scripts/backup.py b/scripts/backup.py new file mode 100755 index 0000000..4faee47 --- /dev/null +++ b/scripts/backup.py @@ -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])) diff --git a/scripts/check-build.sh b/scripts/check-build.sh new file mode 100755 index 0000000..71863f4 --- /dev/null +++ b/scripts/check-build.sh @@ -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" diff --git a/scripts/radxa-audio-output b/scripts/radxa-audio-output new file mode 100755 index 0000000..21dc710 --- /dev/null +++ b/scripts/radxa-audio-output @@ -0,0 +1,3 @@ +#!/bin/bash +set -euo pipefail +exec /usr/local/bin/radxa-audio output "$@" diff --git a/scripts/radxa-audio.py b/scripts/radxa-audio.py new file mode 100755 index 0000000..aaad4d4 --- /dev/null +++ b/scripts/radxa-audio.py @@ -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) diff --git a/sources/manifest.json b/sources/manifest.json new file mode 100644 index 0000000..f8805f8 --- /dev/null +++ b/sources/manifest.json @@ -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" + } + ] +} diff --git a/sources/nqptp-1.2.8.bundle b/sources/nqptp-1.2.8.bundle new file mode 100644 index 0000000..8e907f8 Binary files /dev/null and b/sources/nqptp-1.2.8.bundle differ diff --git a/sources/shairport-sync-5.5.2.bundle b/sources/shairport-sync-5.5.2.bundle new file mode 100644 index 0000000..1ac5f79 Binary files /dev/null and b/sources/shairport-sync-5.5.2.bundle differ diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 0000000..1df919c --- /dev/null +++ b/tests/test_backup.py @@ -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() diff --git a/tests/test_manager.py b/tests/test_manager.py new file mode 100644 index 0000000..d128d64 --- /dev/null +++ b/tests/test_manager.py @@ -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()