Add web mixer with native input volume and output selection

This commit is contained in:
jsahn
2026-10-12 18:45:43 +09:00
parent ceef64dfed
commit 5fbece12bb
19 changed files with 696 additions and 34 deletions
+55 -3
View File
@@ -17,15 +17,58 @@ Mac/iPhone의 AirPlay 2 ── Shairport Sync ─┐
Loopback → Bluetooth 헤드폰/스피커 Loopback → Bluetooth 헤드폰/스피커
``` ```
- AirPlay 2: Shairport Sync **5.5.2**, NQPTP **1.2.8**, 공유 메모리 규격 smi10. - AirPlay 2: Shairport Sync **5.5.2**, NQPTP **1.2.8**, 공유 메모리 규격 smi10. 웹 제어용 D-Bus 지원과 네이티브 볼륨 패치를 포함합니다.
- Bluetooth: Debian의 BlueALSA 패키지. 기준 보드 버전 **4.3.1**. - Bluetooth: Debian의 BlueALSA 패키지. 기준 보드 버전 **4.3.1**.
- 수신과 송신은 A2DP 음악 프로필을 사용합니다. 기준 보드에서는 SBC로 연결됐습니다. - 수신과 송신은 A2DP 음악 프로필을 사용합니다. 기준 보드에서는 SBC로 연결됐습니다.
- SBC 송신 품질은 medium입니다. 기준 보드에서 high로 동시 수신·송신 시 끊겨
전송량을 줄였습니다. 송신 버퍼는 500 ms이며 안정성은 청취 검증이 필요합니다.
- DAC 출력은 44.1 kHz / 스테레오 / 16비트, 블루투스용 가상 믹서는 48 kHz입니다. - DAC 출력은 44.1 kHz / 스테레오 / 16비트, 블루투스용 가상 믹서는 48 kHz입니다.
- 각 입력을 6 dB 낮춰 두 입력을 합칠 여유를 둡니다. 세 입력 이상은 각 기기 볼륨을 - 각 입력을 3 dB 낮춥니다. 여러 입력의 큰 소리가 겹쳐 찢어지면 입력 볼륨을 낮추세요. 지연 없는 실시간 녹음/악기 모니터링용 구성은 아닙니다.
추가로 낮추세요. 지연 없는 실시간 녹음/악기 모니터링용 구성은 아닙니다.
- 각 송신 기기의 볼륨으로 믹스 비율을 바꿉니다. - 각 송신 기기의 볼륨으로 믹스 비율을 바꿉니다.
- 출력은 DAC 또는 블루투스 중 하나를 선택합니다. 양쪽 동시 출력은 구현하지 않았습니다. - 출력은 DAC 또는 블루투스 중 하나를 선택합니다. 양쪽 동시 출력은 구현하지 않았습니다.
## 웹 믹서
설치 후 브라우저에서 **`http://보드IP:8088`**을 여세요.
현재 보드는 `http://192.168.10.83:8088`이며, USB 관리 연결에서는
주소가 유지돼 있다면 `http://10.55.0.1:8088`을 사용합니다.
- AirPlay 및 연결된 Bluetooth 입력별 볼륨·음소거.
- AirPlay는 Shairport Sync의 **기존 네이티브 볼륨**을 사용합니다.
별도 ALSA softvol이나 추가 감쇠 단계는 만들지 않았습니다.
- Bluetooth 볼륨 API는 A2DP 입력 PCM만 허용하며 DAC/헤드폰 출력 볼륨을 거부합니다.
- DAC 선택, 등록한 Bluetooth 헤드폰·스피커 선택, 새 출력 장비 검색·페어링.
- 출력 전환은 비동기로 실행하고 결과를 화면에 표시합니다.
- LAN용 화면입니다. 인터넷 포트 개방은 하지 않습니다.
같은 네트워크의 접속자는 화면에서 제어할 수 있으며 별도 로그인은 없습니다.
상태 변경에는 요청 토큰과 동일 출처 검증을 사용합니다.
- 새 휴대폰을 Bluetooth 입력으로 페어링하는 절차는 아래의 기존 CLI를 사용합니다.
- 웹 서버는 `radxa-mixer-web.service`로 자동 시작합니다.
명령어도 계속 사용할 수 있습니다:
```bash
sudo radxa-audio inputs
sudo radxa-audio volume airplay 60
sudo radxa-audio mute airplay on
sudo radxa-audio mute airplay off
sudo radxa-audio volume AA:BB:CC:DD:EE:FF 70
sudo radxa-audio mute AA:BB:CC:DD:EE:FF on
sudo radxa-audio output dac --pcm hw:CARD=Audio,DEV=0
```
AirPlay의 볼륨은 원래 프로토콜 범위 −30~0 및 음소거 −144를 0~100으로 표시합니다.
Bluetooth는 원래 입력 PCM의 볼륨을 사용합니다. 송신 기기의 볼륨 변경도 같은 입력에
적용됩니다. 출력 장비 볼륨과 이퀄라이저는 웹 화면에 제공하지 않습니다.
`sudo radxa-audio apply`는 출력 장비가 꺼져 있어도 **이미 저장한 출력 경로**를
다시 적용할 수 있습니다. 새 출력 장비 선택에는 여전히 연결 확인이 필요합니다.
기존 보드에서 웹 기능을 추가하려면 전체 `install.sh`로 D-Bus 빌드를 설치하거나,
이미 D-Bus 지원 바이너리가 설치돼 있다면 `sudo bash scripts/install-web.sh` 후
`sudo radxa-audio apply`를 실행합니다. 전체 설치는 기본 DAC 출력으로 설정하므로
이후 원하는 Bluetooth 출력을 다시 선택하세요.
## 1. 새 보드 준비 ## 1. 새 보드 준비
1. Debian 13 기반 Armbian을 설치하고 인터넷에 연결합니다. 1. Debian 13 기반 Armbian을 설치하고 인터넷에 연결합니다.
@@ -110,6 +153,12 @@ sudo radxa-audio pair AA:BB:CC:DD:EE:FF --output
이 명령은 페어링 → 신뢰 등록 → 음악 프로필 연결 → 출력 전환을 진행합니다. 이 명령은 페어링 → 신뢰 등록 → 음악 프로필 연결 → 출력 전환을 진행합니다.
이후 다른 헤드폰으로 바꾸려면 새 헤드폰을 검색하고 같은 명령을 실행하면 됩니다. 이후 다른 헤드폰으로 바꾸려면 새 헤드폰을 검색하고 같은 명령을 실행하면 됩니다.
`bluetoothctl info 주소`에서 `Paired: yes`여도 `Bonded: no`이면 임시 연결입니다.
재부팅 후 등록을 유지하려면 장비를 페어링 모드로 켠 뒤
`sudo radxa-audio pair 주소 --output --bond`를 사용하세요. 웹의 새 장비 등록도
영구 등록 성공을 확인하며, 인증 실패는 성공으로 표시하지 않습니다.
이미 영구 등록된 장비는 다시 페어링하지 않습니다.
이미 등록한 장비로 전환: 이미 등록한 장비로 전환:
```bash ```bash
@@ -175,6 +224,9 @@ apt로 추가한 의존 패키지와 블루투스 페어링 기록은 유지합
|---|---| |---|---|
| `install.sh` | 새 보드 설치, 빌드, 서비스 설정, 실패 시 복구 | | `install.sh` | 새 보드 설치, 빌드, 서비스 설정, 실패 시 복구 |
| `scripts/radxa-audio.py` | 페어링/출력/이름/DAC 관리 | | `scripts/radxa-audio.py` | 페어링/출력/이름/DAC 관리 |
| `scripts/web_server.py`, `scripts/input_controls.py`, `scripts/web/` | 웹 화면/API 및 입력 전용 볼륨 제어 |
| `scripts/install-web.sh` | 웹 서비스 설치/갱신 |
| `sources/shairport-native-volume.patch` | 기존 AirPlay 볼륨을 현재 세션에 적용 |
| `scripts/backup.py` | 설치 전 백업/복원 | | `scripts/backup.py` | 설치 전 백업/복원 |
| `scripts/check-build.sh` | 서비스 변경 없이 고정 소스 빌드 확인 | | `scripts/check-build.sh` | 서비스 변경 없이 고정 소스 빌드 확인 |
| `sources/*.bundle`, `manifest.json` | 공식 Git 소스, 릴리스 태그, 커밋, SHA-256 | | `sources/*.bundle`, `manifest.json` | 공식 Git 소스, 릴리스 태그, 커밋, SHA-256 |
+32 -1
View File
@@ -69,7 +69,7 @@ cat /sys/module/aic8800_fdrv/parameters/ps_on
## 3개 이상 입력을 더하면 찢어짐 ## 3개 이상 입력을 더하면 찢어짐
각 입력을 6 dB 낮춘 것은 두 입력 합산 기준입니다. 입력 수가 늘어나면 각 입력의 공통 감쇠는 3 dB입니다. 입력 수가 늘어나면
각 기기의 볼륨을 더 낮춰 합산 클리핑을 피해야 합니다. 최대 안정적인 각 기기의 볼륨을 더 낮춰 합산 클리핑을 피해야 합니다. 최대 안정적인
블루투스 동시 입력 수는 보드 컨트롤러/드라이버와 실제 재생으로 검증해야 합니다. 블루투스 동시 입력 수는 보드 컨트롤러/드라이버와 실제 재생으로 검증해야 합니다.
@@ -87,3 +87,34 @@ cat /sys/module/aic8800_fdrv/parameters/ps_on
```bash ```bash
bash scripts/check-build.sh bash scripts/check-build.sh
``` ```
## 웹 화면에 접속되지 않음
```bash
sudo systemctl status radxa-mixer-web --no-pager
sudo journalctl -u radxa-mixer-web -n 40 --no-pager
```
주소는 HTTP의 8088 포트입니다. 출력 장비가 꺼져 있어도 화면은 접속 가능해야 합니다.
브라우저를 새로고침하면 재시작 후 바뀐 요청 토큰이 갱신됩니다.
## AirPlay 입력이 ‘제어 준비 중’으로 표시됨
`shairport-sync -V`에 `dbus`가 있는지 확인합니다. D-Bus 기능과 이 패키지의
네이티브 볼륨 패치가 없는 기존 바이너리는 웹 볼륨 제어를 지원하지 않습니다.
## Bluetooth 입력을 헤드폰으로 다시 보낼 때 끊김
`journalctl -u bluealsa -u bluealsa-aplay -u radxa-bt-output --since "5 min ago"`
에서 `Missing RTP packets`는 입력 수신 패킷 손실, `underrun`/`overrun`은
오디오 버퍼 오류를 뜻합니다. 수신 손실은 버퍼만 늘려 복구되지 않습니다.
같은 입력을 DAC로 보낼 때와 Bluetooth 출력으로 보낼 때를 비교하세요.
현재 송신 버퍼는 500 ms이며 지연이 추가됩니다. 같은 컨트롤러의 동시
수신·송신 안정성은 실제 청취 검증이 필요합니다.
## Bluetooth 컨트롤러 점검
기준 AIC 보드에서 데몬/하드웨어 초기화 중 UART 컨트롤러가 오류 상태에 들어가
`/sys/class/bluetooth/hci0`는 있어도 `bluetoothctl list`가 비는 경우를 확인했습니다.
`hciconfig -a`, `btmgmt info`, `dmesg`로 확인하고, Bluetooth 초기화 서비스로
풀리지 않으면 재부팅 후 다시 확인합니다. 페어링 기록을 먼저 삭제하지 마세요.
+29 -2
View File
@@ -3,12 +3,16 @@
## 완료 ## 완료
- 입력값 검증, 연결 없는 출력 전환 중단, 서비스 실패 시 설정 복구, - 입력값 검증, 연결 없는 출력 전환 중단, 서비스 실패 시 설정 복구,
설치 백업 복구/심볼릭 링크 보존, 등록 장비 재페어링 방지 등 **자동 검사 11개 통과**. 설치 백업 복구/심볼릭 링크 보존, 등록 장비 재페어링 방지,
입력 PCM 제한, 출력 볼륨 변경 거부, HTTP 토큰/동일 출처 검사,
활성 AirPlay 속성 파싱, 영구 등록 실패 처리 등 **자동 검사 23개 통과**.
- Bash 문법 검사와 ShellCheck 통과. - Bash 문법 검사와 ShellCheck 통과.
- Python 문법 확인: macOS Python 3.14, 보드의 Debian Python에서 확인. - Python 문법 확인: macOS Python 3.14, 보드의 Debian Python에서 확인.
- systemd의 새 `radxa-bt-output.service` 검증 통과. - systemd의 새 `radxa-bt-output.service` 검증 통과.
- 배포할 Git bundle에서 **새 작업 디렉토리로 복제하고 실제 보드에서 컴파일 성공**. - 배포할 Git bundle에서 **새 작업 디렉토리로 복제하고 실제 보드에서 컴파일 성공**.
- Shairport Sync: `5.5.2-AirPlay2-smi10-OpenSSL-Avahi-ALSA-soxr-sysconfdir:/etc` - Shairport Sync 웹 버전: `5.5.2-dirty-AirPlay2-smi10-OpenSSL-Avahi-ALSA-soxr-metadata-dbus-sysconfdir:/etc`
- 포함된 네이티브 볼륨 패치를 적용한 실제 보드 빌드 및 설치 성공.
`dirty`는 해당 패치를 적용했기 때문에 표시됩니다.
- NQPTP: 1.2.8 / 공유 메모리 규격 10. - NQPTP: 1.2.8 / 공유 메모리 규격 10.
- 기존 얕은 Git 복제에서 만든 bundle의 누락 이력을 발견하고, - 기존 얕은 Git 복제에서 만든 bundle의 누락 이력을 발견하고,
완전한 이력이 있는 독립 bundle로 교체함. 최종 SHA-256은 manifest에 기록. 완전한 이력이 있는 독립 bundle로 교체함. 최종 SHA-256은 manifest에 기록.
@@ -17,6 +21,29 @@
- 최종 보드 설정: Bluetooth 출력, Bose QC Headphones. - 최종 보드 설정: Bluetooth 출력, Bose QC Headphones.
- 보스 PLAYBACK(SBC 48 kHz)과 아이폰 CAPTURE(SBC 44.1 kHz) 동시 연결 확인. - 보스 PLAYBACK(SBC 48 kHz)과 아이폰 CAPTURE(SBC 44.1 kHz) 동시 연결 확인.
- 기존 Wi-Fi 안정화 옵션 `he_on=N`, `ps_on=N` 유지 확인. - 기존 Wi-Fi 안정화 옵션 `he_on=N`, `ps_on=N` 유지 확인.
- 웹 서비스 자동 시작, 브라우저의 AirPlay/휴대폰 입력 볼륨과 음소거,
DAC 선택을 실제 API와 장치 속성으로 확인.
- 공통 감쇠 −3 dB, 명령행 제어 유지, JavaScript/Bash 문법 검사 통과.
## Bluetooth 끊김 조사
사용자가 iPhone Bluetooth → 보스 헤드폰 경로의 끊김을 보고했습니다.
iPhone 수신 RTP 패킷 손실과 입력 재생 underrun이 반복됐고,
헤드폰 출력 alsaloop에서 capture overrun도 한 번 확인됐습니다.
Wi-Fi는 5 GHz, CPU 부하는 낮았으며 원인은 아직 확정하지 않았습니다.
출력 버퍼를 200 ms에서 500 ms로 늘려도 사용자가 끊김을 확인했습니다.
송신을 멈춘 약 18초 동안에는 입력 패킷 손실이 없었고, 송신 재개 후 다시 발생했습니다.
SBC 송신 품질을 high에서 medium으로 줄인 뒤 사용자는 정상 청취라고 답했지만,
같은 시점에 Radxa의 보스 출력 PCM이 없고 연결 재시도 중인 것을 발견했습니다.
직접 연결 경로의 소리일 수 있어 이 응답만으로 해결됐다고 판단하지 않습니다.
medium과 500 ms 버퍼를 저장했고 출력 등록 복구 후 다시 검증해야 합니다.
이후 컨트롤러에서 HCI 명령 `0x0c1a`/`0x0408` 시간 초과와 BlueZ `NoReply`를
확인했습니다. Bluetooth 서비스 재시작만으로 복구되지 않아 보드를 재부팅했습니다.
SBC 품질 변경으로 컨트롤러 오류가 해결됐다고 보지는 않습니다.
보스는 현재 `Paired: yes / Bonded: no`인 임시 연결입니다. 영구 등록 시도가
인증 실패하여 재부팅 후 보스 등록 유지가 검증됐다고 주장하지 않습니다.
이미 영구 등록된 휴대폰 등록은 재부팅 후 유지됐습니다.
## 실제 청취로 확인한 범위 ## 실제 청취로 확인한 범위
+6 -4
View File
@@ -42,7 +42,7 @@ spec=importlib.util.spec_from_file_location('manager',base/'scripts/radxa-audio.
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m) 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='')) 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()) manifest=json.loads((base/'sources/manifest.json').read_text())
for source in manifest['sources']: for source in manifest['sources'] + manifest.get('patches', []):
p=base/'sources'/source['file'] p=base/'sources'/source['file']
assert hashlib.sha256(p.read_bytes()).hexdigest()==source['sha256'], 'Source checksum mismatch: '+p.name assert hashlib.sha256(p.read_bytes()).hexdigest()==source['sha256'], 'Source checksum mismatch: '+p.name
PY PY
@@ -72,7 +72,7 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y \
build-essential git autoconf automake libtool pkg-config xmltoman \ build-essential git autoconf automake libtool pkg-config xmltoman \
libpopt-dev libconfig-dev libssl-dev libsoxr-dev libasound2-dev \ libpopt-dev libconfig-dev libssl-dev libsoxr-dev libasound2-dev \
libavahi-client-dev libavcodec-dev libavformat-dev libavutil-dev libswresample-dev uuid-dev \ libavahi-client-dev libavcodec-dev libavformat-dev libavutil-dev libswresample-dev uuid-dev \
libplist-dev libsodium-dev libgcrypt20-dev avahi-daemon \ libglib2.0-dev libplist-dev libsodium-dev libgcrypt20-dev avahi-daemon \
bluez bluez-alsa-utils libasound2-plugin-bluez alsa-utils python3 bluez bluez-alsa-utils libasound2-plugin-bluez alsa-utils python3
# Verify hardware and installed-package capabilities before switching outputs. # Verify hardware and installed-package capabilities before switching outputs.
[[ -d /sys/class/bluetooth/hci0 ]] || { echo '블루투스 컨트롤러가 없습니다.' >&2; exit 1; } [[ -d /sys/class/bluetooth/hci0 ]] || { echo '블루투스 컨트롤러가 없습니다.' >&2; exit 1; }
@@ -95,10 +95,11 @@ git clone --quiet --branch 5.5.2 "$PACKAGE_DIR/sources/shairport-sync-5.5.2.bund
) )
( (
cd "$BUILD_DIR/shairport-sync" cd "$BUILD_DIR/shairport-sync"
git apply "$PACKAGE_DIR/sources/shairport-native-volume.patch"
autoreconf -fi autoreconf -fi
./configure --prefix=/usr/local --sysconfdir=/etc --with-alsa --with-soxr \ ./configure --prefix=/usr/local --sysconfdir=/etc --with-alsa --with-soxr \
--with-avahi --with-ssl=openssl --with-systemd-startup \ --with-avahi --with-ssl=openssl --with-systemd-startup \
--with-systemdsystemunitdir=/etc/systemd/system --with-airplay-2 --with-systemdsystemunitdir=/etc/systemd/system --with-airplay-2 --with-dbus-interface
make -j "$JOBS" make -j "$JOBS"
) )
# Finish both builds before stopping the existing receiver. # Finish both builds before stopping the existing receiver.
@@ -148,10 +149,11 @@ if (( WIFI5 )); then
'options aic8800_fdrv ps_on=0 he_on=0' > /etc/modprobe.d/90-radxa-airplay-wifi.conf 'options aic8800_fdrv ps_on=0 he_on=0' > /etc/modprobe.d/90-radxa-airplay-wifi.conf
fi fi
systemctl daemon-reload systemctl daemon-reload
/usr/local/bin/shairport-sync -V | grep -q '5.5.2-AirPlay2.*smi10' /usr/local/bin/shairport-sync -V | grep -q '5.5.2.*AirPlay2.*smi10.*dbus'
/usr/local/bin/nqptp -V | grep -q '1.2.8.*10' /usr/local/bin/nqptp -V | grep -q '1.2.8.*10'
radxa-audio init --name "$AIRPLAY_NAME" --dac "$DAC_PCM" radxa-audio init --name "$AIRPLAY_NAME" --dac "$DAC_PCM"
radxa-audio status radxa-audio status
bash "$PACKAGE_DIR/scripts/install-web.sh"
trap - EXIT trap - EXIT
echo "설치 완료. 복구: sudo python3 $PACKAGE_DIR/scripts/backup.py restore $BACKUP_DIR" echo "설치 완료. 복구: sudo python3 $PACKAGE_DIR/scripts/backup.py restore $BACKUP_DIR"
if (( WIFI5 )); then echo 'Wi-Fi 5 설정 적용을 위해 편할 때 재부팅하세요. USB 네트워크 주소 설정은 변경하지 않았습니다.'; fi if (( WIFI5 )); then echo 'Wi-Fi 5 설정 적용을 위해 편할 때 재부팅하세요. USB 네트워크 주소 설정은 변경하지 않았습니다.'; fi
+6 -1
View File
@@ -7,6 +7,9 @@
- USB DAC: Realtek USB2.0 Audio, ALSA `hw:CARD=Audio,DEV=0`. - USB DAC: Realtek USB2.0 Audio, ALSA `hw:CARD=Audio,DEV=0`.
- 블루투스 출력: Bose QC Headphones (사용자 모델 QC SC). - 블루투스 출력: Bose QC Headphones (사용자 모델 QC SC).
- 출력 장비 주소: `E4:58:BC:C3:3B:F8` (기존 장비 참고만; 새 설치 기본값이 아님). - 출력 장비 주소: `E4:58:BC:C3:3B:F8` (기존 장비 참고만; 새 설치 기본값이 아님).
- 웹 믹서: `http://192.168.10.83:8088`, `radxa-mixer-web.service` 자동 시작.
- 공통 감쇠 −3 dB. AirPlay는 기존 네이티브 볼륨만 제어.
- 보스는 현재 임시 등록(`Bonded: no`)이며 영구 등록은 추가 확인 필요.
- Wi-Fi 드라이버: aic8800_fdrv; `ps_on=0 he_on=0` 설정을 재부팅 후 확인함. - Wi-Fi 드라이버: aic8800_fdrv; `ps_on=0 he_on=0` 설정을 재부팅 후 확인함.
- USB 관리 주소 10.55.0.1/24는 별도 gadget/systemd-networkd 설정이며, - USB 관리 주소 10.55.0.1/24는 별도 gadget/systemd-networkd 설정이며,
이 오디오 설치 자료가 생성하거나 변경하지 않음. 이 오디오 설치 자료가 생성하거나 변경하지 않음.
@@ -14,7 +17,9 @@
최초 AirPlay → DAC 및 AirPlay + iPhone Bluetooth → DAC 믹스 재생은 최초 AirPlay → DAC 및 AirPlay + iPhone Bluetooth → DAC 믹스 재생은
사용자가 정상 청취를 확인했습니다. Bose 헤드폰은 페어링/신뢰 등록 및 사용자가 정상 청취를 확인했습니다. Bose 헤드폰은 페어링/신뢰 등록 및
A2DP SBC 48 kHz 출력 연결과 가상 믹서 전송까지 확인했습니다. A2DP SBC 48 kHz 출력 연결과 가상 믹서 전송까지 확인했습니다.
헤드폰에서 실제 두 소리를 청취했다는 확인은 아직 받지 못했습니다. 사용자는 이후 iPhone → 보스 경로의 소리 끊김을 보고했습니다. 수신 패킷 손실과
버퍼 오류를 확인했습니다. 500 ms 버퍼와 SBC medium 송신 설정을 적용했으나,
보스 출력 연결이 실패하여 경로 복구와 청취 검증이 필요합니다.
기존 설정 백업은 보드의 `/root/audio-mixer-backup-20261012-162401/`와 기존 설정 백업은 보드의 `/root/audio-mixer-backup-20261012-162401/`와
`/root/radxa-airplay2-backup-20261011-193842/`에 있습니다. `/root/radxa-airplay2-backup-20261011-193842/`에 있습니다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+14 -5
View File
@@ -8,10 +8,18 @@ import subprocess
import sys import sys
UNITS = ['shairport-sync', 'nqptp', 'bluealsa', 'bluealsa-aplay', UNITS = ['shairport-sync', 'nqptp', 'bluealsa', 'bluealsa-aplay',
'radxa-bt-output', 'radxa-bose-output', 'avahi-daemon', 'bluetooth'] 'radxa-bt-output', 'radxa-bose-output', 'radxa-mixer-web', 'avahi-daemon', 'bluetooth']
PATHS = [ PATHS = [
'/etc/systemd/system/radxa-mixer-web.service',
'/etc/dbus-1/system.d/shairport-sync-dbus.conf',
'/usr/local/lib/radxa-mixer/input_controls.py',
'/usr/local/lib/radxa-mixer/web_server.py',
'/usr/local/lib/radxa-mixer/web/index.html',
'/usr/local/lib/radxa-mixer/web/app.js',
'/usr/local/lib/radxa-mixer/web/style.css',
'/etc/asound.conf', '/etc/shairport-sync.conf', '/etc/asound.conf', '/etc/shairport-sync.conf',
'/etc/radxa-audio/mixer.json', '/etc/radxa-audio/asound-dac.conf', '/etc/radxa-audio/mixer.json', '/etc/radxa-audio/asound-dac.conf',
'/etc/radxa-audio/input-memory.json',
'/etc/radxa-audio/asound-bluetooth.conf', '/etc/radxa-audio/asound-bluetooth.conf',
'/etc/systemd/system/shairport-sync.service', '/etc/systemd/system/nqptp.service', '/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.service.d/10-radxa-sink.conf',
@@ -48,9 +56,9 @@ def snapshot(directory):
def restore(directory): def restore(directory):
manifest = json.loads((directory / 'manifest.json').read_text()) manifest = json.loads((directory / 'manifest.json').read_text())
# Only restore allowlisted paths from our own snapshot. # Only restore allowlisted paths from our own snapshot.
if set(manifest['files']) != set(PATHS) or set(manifest['units']) != set(UNITS): if not set(manifest['files']).issubset(PATHS) or not set(manifest['units']).issubset(UNITS):
raise ValueError('알 수 없는 백업 형식입니다.') raise ValueError('알 수 없는 백업 형식입니다.')
for unit in UNITS[:6]: ctl('stop', unit) for unit in UNITS[:7]: ctl('stop', unit)
for name, existed in manifest['files'].items(): for name, existed in manifest['files'].items():
target = Path(name) target = Path(name)
if existed: if existed:
@@ -60,11 +68,12 @@ def restore(directory):
else: else:
target.unlink(missing_ok=True) target.unlink(missing_ok=True)
ctl('daemon-reload') ctl('daemon-reload')
for unit, state in manifest['units'].items(): for unit in UNITS:
state = manifest['units'].get(unit, {'active': False, 'enabled': False})
ctl('enable' if state['enabled'] else 'disable', unit) ctl('enable' if state['enabled'] else 'disable', unit)
# Bluetooth and Avahi may have been started by apt during installation. # Bluetooth and Avahi may have been started by apt during installation.
for unit in reversed(UNITS): for unit in reversed(UNITS):
state = manifest['units'][unit] state = manifest['units'].get(unit, {'active': False, 'enabled': False})
result = ctl('restart' if state['active'] else 'stop', unit) result = ctl('restart' if state['active'] else 'stop', unit)
if state['active'] and result.returncode: if state['active'] and result.returncode:
print(result.stdout, file=sys.stderr) print(result.stdout, file=sys.stderr)
+2 -1
View File
@@ -15,10 +15,11 @@ git clone -q --branch 5.5.2 "$PACKAGE_DIR/sources/shairport-sync-5.5.2.bundle" "
) )
( (
cd "$BUILD_DIR/shairport-sync" cd "$BUILD_DIR/shairport-sync"
git apply "$PACKAGE_DIR/sources/shairport-native-volume.patch"
autoreconf -fi autoreconf -fi
./configure --prefix=/usr/local --sysconfdir=/etc --with-alsa --with-soxr \ ./configure --prefix=/usr/local --sysconfdir=/etc --with-alsa --with-soxr \
--with-avahi --with-ssl=openssl --with-systemd-startup \ --with-avahi --with-ssl=openssl --with-systemd-startup \
--with-systemdsystemunitdir=/etc/systemd/system --with-airplay-2 --with-systemdsystemunitdir=/etc/systemd/system --with-airplay-2 --with-dbus-interface
make -j 1 make -j 1
./shairport-sync -V ./shairport-sync -V
) )
+167
View File
@@ -0,0 +1,167 @@
"""Native input volume controls. Never writes a DAC/headphone output volume."""
import json
import math
from pathlib import Path
import re
import subprocess
import tempfile
import os
CONFIG = Path('/etc/radxa-audio/mixer.json')
MEMORY = Path('/etc/radxa-audio/input-memory.json')
SPS = 'org.gnome.ShairportSync'
SPS_PATH = '/org/gnome/ShairportSync'
MAC = re.compile(r'(?:[0-9A-F]{2}:){5}[0-9A-F]{2}\Z')
def command(*args):
p = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=12)
if p.returncode:
raise ValueError(p.stderr.strip() or p.stdout.strip() or '장치 제어에 실패했습니다.')
return p.stdout
def bus(*args):
result = json.loads(command('busctl', '--system', '--json=short', *args))
# Method replies contain a tuple; properties contain their direct value.
return result['data'][0] if args[0] == 'call' else result['data']
def objects(service, path):
raw = bus('call', service, path, 'org.freedesktop.DBus.ObjectManager', 'GetManagedObjects')
return {p: {i: {k: v['data'] for k, v in props.items()} for i, props in interfaces.items()}
for p, interfaces in raw.items()}
def pcms():
return {p: interfaces['org.bluealsa.PCM1'] for p, interfaces in objects('org.bluealsa', '/org/bluealsa').items()
if 'org.bluealsa.PCM1' in interfaces}
def is_input(props):
return props.get('Mode') == 'source' and props.get('Transport') == 'A2DP-sink'
def memory():
try: return json.loads(MEMORY.read_text())
except FileNotFoundError: return {}
def remember(key, percent):
data = memory(); data[key] = percent
MEMORY.parent.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(dir=MEMORY.parent)
try:
with os.fdopen(fd, 'w') as f: json.dump(data, f)
os.chmod(name, 0o644); os.replace(name, MEMORY)
finally:
if os.path.exists(name): os.unlink(name)
def address(device_path):
match = re.search(r'/dev_([0-9A-F_]{17})$', device_path)
if not match: raise ValueError('장비 주소를 읽을 수 없습니다.')
return match[1].replace('_', ':')
def airplay():
props = bus('call', SPS, SPS_PATH, 'org.freedesktop.DBus.Properties', 'GetAll', 's', SPS)
props = {k: v['data'] for k,v in props.items()}
value = props['Volume']
if props.get('Active'):
try:
value = bus('get-property', SPS, SPS_PATH, SPS+'.RemoteControl', 'AirplayVolume')
except (ValueError, subprocess.TimeoutExpired):
pass
muted = value <= -100
percent = round((value + 30) / 30 * 100) if not muted else memory().get('airplay', 75)
return {'id':'airplay', 'name':'AirPlay', 'kind':'airplay', 'volume':max(0,min(100,percent)),
'muted':muted, 'playing':bool(props.get('Active')), 'available':True}
def decode_volume(value):
levels = [(value >> 8) & 127, value & 127]
return round(sum(levels) / 254 * 100), bool(value & 0x8080)
def encode_volume(percent, muted):
byte = round(percent * 127 / 100) | (128 if muted else 0)
return (byte << 8) | byte
def inputs():
result = []
try: result.append(airplay())
except (ValueError, KeyError, subprocess.TimeoutExpired):
result.append({'id':'airplay','name':'AirPlay','kind':'airplay','volume':0,
'muted':False,'playing':False,'available':False})
try:
devices = objects('org.bluez', '/')
for path, props in pcms().items():
if not is_input(props): continue
dev = props['Device']; key = address(dev)
d = devices.get(dev, {}).get('org.bluez.Device1', {})
volume, muted = decode_volume(int(props['Volume']))
result.append({'id':key,'name':d.get('Alias', key),'kind':'bluetooth','volume':volume,
'muted':muted,'playing':bool(props.get('Running')),'available':True})
except (ValueError, KeyError, subprocess.TimeoutExpired): pass
return result
def set_input(key, volume=None, mute=None):
if volume is not None and (type(volume) is not int or not 0 <= volume <= 100):
raise ValueError('입력 볼륨은 0~100의 정수여야 합니다.')
if mute is not None and type(mute) is not bool: raise ValueError('음소거 값이 잘못됐습니다.')
if key == 'airplay':
current = airplay()
percent = current['volume'] if volume is None else volume
muted = current['muted'] if mute is None else mute
if volume is not None: muted = percent == 0
if mute is True: remember(key, current['volume'])
if not muted and percent: remember(key, percent)
value = -144.0 if muted or percent == 0 else -30.0 + percent * 0.3
command('busctl','--system','--','set-property',SPS,SPS_PATH,SPS,'Volume','d',str(value))
return
key = str(key).upper()
if not MAC.fullmatch(key): raise ValueError('입력 장비 주소가 잘못됐습니다.')
matches = [(p, v) for p,v in pcms().items() if is_input(v) and address(v['Device']) == key]
if not matches: raise ValueError('이 장비는 연결된 Bluetooth 입력이 아닙니다.')
for path, props in matches:
old, old_mute = decode_volume(int(props['Volume']))
percent = old if volume is None else volume
muted = old_mute if mute is None else mute
if volume is not None: muted = percent == 0
# Only the validated source PCM is written; output PCMs cannot pass this guard.
command('busctl','--system','--','set-property','org.bluealsa',path,'org.bluealsa.PCM1','SoftVolume','b','true')
command('busctl','--system','--','set-property','org.bluealsa',path,'org.bluealsa.PCM1','Volume','q',
str(encode_volume(percent, muted)))
def devices():
result = []
for path, interfaces in objects('org.bluez', '/').items():
props = interfaces.get('org.bluez.Device1')
if not props: continue
result.append({'address': props['Address'],'name': props.get('Alias',props['Address']),
'bonded': bool(props.get('Bonded')), 'paired': bool(props.get('Paired')),'connected':bool(props.get('Connected')),
'output': '0000110b-0000-1000-8000-00805f9b34fb' in props.get('UUIDs',[])})
return result
def dacs():
result = []
for line in command('aplay','-l').splitlines():
match = re.match(r'card \d+: ([\w-]+) \[([^]]+)\], device (\d+): ([^[]+)',line)
if match and match[1] != 'Loopback':
result.append({'pcm':f'hw:CARD={match[1]},DEV={match[3]}','name':match[2]+' · '+match[4].strip()})
return result
def state():
config = json.loads(CONFIG.read_text())
issues = []
try: bluetooth = devices()
except Exception as e: bluetooth=[]; issues.append(str(e))
try: dac = dacs()
except Exception as e: dac=[]; issues.append(str(e))
return {'config':config,'inputs':inputs(),'bluetooth':bluetooth,'dacs':dac,'issues':issues}
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# Install/update web tools on a board that already has Shairport Sync with D-Bus.
set -euo pipefail
PACKAGE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
[[ $EUID == 0 ]] || { echo 'sudo로 실행해 주세요.' >&2; exit 1; }
/usr/local/bin/shairport-sync -V | grep -qi dbus || { echo '먼저 AirPlay D-Bus 지원 빌드를 설치해 주세요.' >&2; exit 1; }
install -d -m 755 /usr/local/lib/radxa-mixer/web
install -m 755 "$PACKAGE_DIR/scripts/radxa-audio.py" /usr/local/bin/radxa-audio
install -m 755 "$PACKAGE_DIR/scripts/web_server.py" /usr/local/lib/radxa-mixer/web_server.py
install -m 644 "$PACKAGE_DIR/scripts/input_controls.py" /usr/local/lib/radxa-mixer/input_controls.py
install -m 644 "$PACKAGE_DIR/scripts/web/"* /usr/local/lib/radxa-mixer/web/
cat > /etc/systemd/system/radxa-mixer-web.service <<'UNIT'
[Unit]
Description=Radxa wireless audio mixer web interface
After=network.target bluetooth.service shairport-sync.service bluealsa.service
[Service]
ExecStart=/usr/bin/python3 /usr/local/lib/radxa-mixer/web_server.py --port 8088
Restart=on-failure
RestartSec=3
UMask=0022
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now radxa-mixer-web
systemctl restart radxa-mixer-web
echo '웹 믹서: http://보드IP:8088'
+46 -16
View File
@@ -66,7 +66,7 @@ def save(path, text, mode=0o644):
def mixer(pcm, rate): def mixer(pcm, rate):
return f'''# Managed by radxa-audio; each input has 6 dB of headroom. return f'''# Managed by radxa-audio; each input is attenuated by 3 dB.
pcm.radxa_dmix {{ pcm.radxa_dmix {{
type dmix type dmix
ipc_key 553001 ipc_key 553001
@@ -87,7 +87,7 @@ pcm.radxa_dmix {{
pcm.radxa_mix {{ pcm.radxa_mix {{
type plug type plug
slave.pcm "radxa_dmix" slave.pcm "radxa_dmix"
ttable {{ 0.0 0.5 1.1 0.5 }} ttable {{ 0.0 0.707945784 1.1 0.707945784 }}
}} }}
''' '''
@@ -115,7 +115,7 @@ diagnostics = {{ statistics = "yes"; log_verbosity = 1; }};
''', ''',
'/etc/systemd/system/bluealsa.service.d/10-radxa-sink.conf': '''[Service] '/etc/systemd/system/bluealsa.service.d/10-radxa-sink.conf': '''[Service]
ExecStart= ExecStart=
ExecStart=/usr/bin/bluealsa -S -p a2dp-sink -p a2dp-source --initial-volume=50 ExecStart=/usr/bin/bluealsa -S -p a2dp-sink -p a2dp-source --initial-volume=50 --sbc-quality=medium
''', ''',
'/etc/systemd/system/bluealsa-aplay.service.d/10-radxa-mixer.conf': '''[Unit] '/etc/systemd/system/bluealsa-aplay.service.d/10-radxa-mixer.conf': '''[Unit]
Requires=bluealsa.service Requires=bluealsa.service
@@ -143,7 +143,7 @@ StartLimitIntervalSec=0
User=shairport-sync User=shairport-sync
Group=audio Group=audio
ExecStartPre=-/usr/bin/bluetoothctl --timeout 8 connect {addr} a2dp-sink 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 ExecStart=/usr/bin/alsaloop -C hw:Loopback,1,0 -P bluealsa:DEV={addr},PROFILE=a2dp -c 2 -r 48000 -f S16_LE -t 500000 -S 4 -A 2
Restart=always Restart=always
RestartSec=5 RestartSec=5
TimeoutStartSec=30 TimeoutStartSec=30
@@ -199,12 +199,16 @@ def check_dac(pcm):
raise ValueError("DAC 출력 장치가 없습니다. aplay -l에서 카드 ID를 확인해 주세요.") raise ValueError("DAC 출력 장치가 없습니다. aplay -l에서 카드 ID를 확인해 주세요.")
def apply(c, initial=False): def apply(c, initial=False, reconfigure=False):
root() root()
files = templates(c) files = templates(c)
if c["output"] == "dac": if reconfigure:
previous = load()
if any(c.get(k) != previous.get(k) for k in ('output', 'dac_pcm', 'bluetooth_device')):
raise ValueError('현재 출력 경로를 바꾸는 작업은 연결 확인이 필요합니다.')
if c["output"] == "dac" and not reconfigure:
check_dac(c["dac_pcm"]) check_dac(c["dac_pcm"])
if c['output'] == 'bluetooth': if c['output'] == 'bluetooth' and not reconfigure:
# Preflight before changing a working DAC route. # Preflight before changing a working DAC route.
run('modprobe', 'snd-aloop') run('modprobe', 'snd-aloop')
result = run('bluetoothctl', '--timeout', '10', 'connect', result = run('bluetoothctl', '--timeout', '10', 'connect',
@@ -229,6 +233,8 @@ def apply(c, initial=False):
run('systemctl', 'enable', 'avahi-daemon', 'bluetooth', 'nqptp', run('systemctl', 'enable', 'avahi-daemon', 'bluetooth', 'nqptp',
'shairport-sync', 'bluealsa', 'bluealsa-aplay') 'shairport-sync', 'bluealsa', 'bluealsa-aplay')
run('systemctl', 'restart', 'bluetooth') run('systemctl', 'restart', 'bluetooth')
if run('systemctl', 'cat', 'aic-bluetooth.service', check=False, capture=True).returncode == 0:
run('systemctl', 'restart', 'aic-bluetooth')
run('systemctl', 'start', 'avahi-daemon', 'nqptp') run('systemctl', 'start', 'avahi-daemon', 'nqptp')
run('systemctl', 'restart', 'bluealsa') run('systemctl', 'restart', 'bluealsa')
if c['output'] == 'bluetooth': if c['output'] == 'bluetooth':
@@ -238,12 +244,12 @@ def apply(c, initial=False):
run('systemctl', 'disable', BT_SERVICE, check=False, capture=True) run('systemctl', 'disable', BT_SERVICE, check=False, capture=True)
run('systemctl', 'start', 'shairport-sync', 'bluealsa-aplay') run('systemctl', 'start', 'shairport-sync', 'bluealsa-aplay')
required = ['shairport-sync', 'bluealsa-aplay'] required = ['shairport-sync', 'bluealsa-aplay']
if c['output'] == 'bluetooth': if c['output'] == 'bluetooth' and not reconfigure:
required.append(BT_SERVICE) required.append(BT_SERVICE)
if not all(active(u) for u in required): if not all(active(u) for u in required):
raise ValueError('서비스 시작에 실패했습니다.') raise ValueError('서비스 시작에 실패했습니다.')
alias = c['name'] + ' Bluetooth' alias = c['name'] + ' Bluetooth'
run('bluetoothctl', 'system-alias', alias) run('bluetoothctl', 'system-alias', alias, check=not reconfigure, capture=reconfigure)
print('출력:', c['output'], c.get('bluetooth_device', '') if c['output'] == 'bluetooth' else c['dac_pcm']) print('출력:', c['output'], c.get('bluetooth_device', '') if c['output'] == 'bluetooth' else c['dac_pcm'])
except Exception: except Exception:
run('systemctl', 'stop', *units, check=False) run('systemctl', 'stop', *units, check=False)
@@ -257,12 +263,12 @@ def apply(c, initial=False):
raise raise
def pair(addr, output=False): def pair(addr, output=False, bond=False):
root() root()
if not MAC_RE.fullmatch(addr): if not MAC_RE.fullmatch(addr):
raise ValueError('장비 주소 형식이 잘못됐습니다.') raise ValueError('장비 주소 형식이 잘못됐습니다.')
info = run('bluetoothctl', 'info', addr, check=False, capture=True).stdout info = run('bluetoothctl', 'info', addr, check=False, capture=True).stdout
if 'Paired: yes' not in info: if 'Paired: yes' not in info or (bond and 'Bonded: yes' not in info):
# Outgoing pairing agent lives only for this operation. # Outgoing pairing agent lives only for this operation.
controller = run('bluetoothctl', 'show', capture=True).stdout controller = run('bluetoothctl', 'show', capture=True).stdout
was_pairable = 'Pairable: yes' in controller was_pairable = 'Pairable: yes' in controller
@@ -275,6 +281,8 @@ def pair(addr, output=False):
info = run('bluetoothctl', 'info', addr, capture=True).stdout info = run('bluetoothctl', 'info', addr, capture=True).stdout
if 'Paired: yes' not in info: if 'Paired: yes' not in info:
raise ValueError('페어링되지 않았습니다. 장비를 페어링 모드로 켜 주세요.') raise ValueError('페어링되지 않았습니다. 장비를 페어링 모드로 켜 주세요.')
if bond and 'Bonded: yes' not in info:
raise ValueError('영구 등록되지 않았습니다. 장비를 페어링 모드로 다시 켠 뒤 재시도해 주세요.')
run('bluetoothctl', 'trust', addr) run('bluetoothctl', 'trust', addr)
run('bluetoothctl', '--timeout', '10', 'connect', addr, run('bluetoothctl', '--timeout', '10', 'connect', addr,
'a2dp-sink' if output else 'a2dp-source', check=False) 'a2dp-sink' if output else 'a2dp-source', check=False)
@@ -292,16 +300,27 @@ def main():
sub.add_parser('status') sub.add_parser('status')
sub.add_parser('devices') sub.add_parser('devices')
q = sub.add_parser('scan'); q.add_argument('--seconds', type=int, default=30) 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'); q.add_argument('address'); q.add_argument('--output', action='store_true'); q.add_argument('--bond', action='store_true')
q = sub.add_parser('pair-input'); q.add_argument('--seconds', type=int, default=120) 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('output'); q.add_argument('mode', choices=['dac', 'bluetooth', 'bose']); q.add_argument('address', nargs='?'); q.add_argument('--pcm')
q = sub.add_parser('name'); q.add_argument('name') q = sub.add_parser('name'); q.add_argument('name')
q = sub.add_parser('dac'); q.add_argument('pcm') q = sub.add_parser('dac'); q.add_argument('pcm')
sub.add_parser('apply') 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 = 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('--adopt', action='store_true'); q.add_argument('--address', default='')
q.add_argument('--output', choices=['dac', 'bluetooth'], default='dac') q.add_argument('--output', choices=['dac', 'bluetooth'], default='dac')
sub.add_parser('inputs')
q = sub.add_parser('volume'); q.add_argument('input'); q.add_argument('percent', type=int)
q = sub.add_parser('mute'); q.add_argument('input'); q.add_argument('state', choices=['on', 'off'])
a = p.parse_args() a = p.parse_args()
if a.cmd in ('inputs', 'volume', 'mute'):
sys.path.insert(0, str(Path(__file__).parent))
sys.path.insert(0, '/usr/local/lib/radxa-mixer')
import input_controls
if a.cmd == 'inputs': print(json.dumps(input_controls.inputs(), ensure_ascii=False, indent=2))
elif a.cmd == 'volume': root(); input_controls.set_input(a.input, volume=a.percent)
else: root(); input_controls.set_input(a.input, mute=a.state == 'on')
return
if a.cmd == 'devices': if a.cmd == 'devices':
run('bluetoothctl', 'devices'); run('aplay', '-l'); run('bluealsa-aplay', '-l') run('bluetoothctl', 'devices'); run('aplay', '-l'); run('bluealsa-aplay', '-l')
elif a.cmd == 'status': elif a.cmd == 'status':
@@ -318,7 +337,7 @@ def main():
run('bluetoothctl', 'scan', 'off', check=False) run('bluetoothctl', 'scan', 'off', check=False)
run('bluetoothctl', 'devices') run('bluetoothctl', 'devices')
elif a.cmd == 'pair': elif a.cmd == 'pair':
pair(a.address, a.output) pair(a.address, a.output, a.bond)
elif a.cmd == 'pair-input': elif a.cmd == 'pair-input':
root() root()
if not 15 <= a.seconds <= 900: raise ValueError('페어링 시간: 15~900초') if not 15 <= a.seconds <= 900: raise ValueError('페어링 시간: 15~900초')
@@ -338,12 +357,15 @@ def main():
c = load() c = load()
if a.cmd == 'output': if a.cmd == 'output':
c['output'] = 'bluetooth' if a.mode in ('bluetooth', 'bose') else 'dac' c['output'] = 'bluetooth' if a.mode in ('bluetooth', 'bose') else 'dac'
if a.pcm:
if c['output'] != 'dac': raise ValueError('--pcm은 DAC 출력에서만 지정합니다.')
c['dac_pcm'] = a.pcm
if a.address: if a.address:
if c['output'] != 'bluetooth': raise ValueError('장비 주소는 bluetooth 출력에서만 지정합니다.') if c['output'] != 'bluetooth': raise ValueError('장비 주소는 bluetooth 출력에서만 지정합니다.')
c['bluetooth_device'] = a.address.upper() c['bluetooth_device'] = a.address.upper()
elif a.cmd == 'name': c['name'] = a.name elif a.cmd == 'name': c['name'] = a.name
elif a.cmd == 'dac': c['dac_pcm'] = a.pcm elif a.cmd == 'dac': c['dac_pcm'] = a.pcm
apply(c) apply(c, reconfigure=a.cmd == 'apply')
def interactive_pair(seconds): def interactive_pair(seconds):
@@ -381,7 +403,15 @@ def interactive_pair(seconds):
if __name__ == '__main__': if __name__ == '__main__':
try: try:
main() import fcntl
if len(sys.argv) > 1 and sys.argv[1] not in ('status', 'devices', 'inputs', '-h', '--help'):
root()
Path('/run/lock').mkdir(parents=True, exist_ok=True)
with open('/run/lock/radxa-audio.lock', 'w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
main()
else:
main()
except (ValueError, subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e: except (ValueError, subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e:
print('오류:', e, file=sys.stderr) print('오류:', e, file=sys.stderr)
sys.exit(1) sys.exit(1)
+11
View File
@@ -0,0 +1,11 @@
let state, token, pending=false, tab='dac', refreshTimer;
const $=id=>document.getElementById(id);
function notice(message,error=false){$('notice').textContent=message;$('notice').classList.toggle('error',error)}
function node(tag,text,cls){const el=document.createElement(tag);if(text!==undefined)el.textContent=text;if(cls)el.className=cls;return el}
function selectOptions(el,items,value){const old=el.value;el.replaceChildren();for(const item of items){const op=node('option',item.name);op.value=item.value;el.append(op)}el.value=items.some(x=>x.value===old)?old:value;if(!el.value&&items.length)el.value=items[0].value}
function showTab(next){tab=next;$('dacPanel').hidden=tab!=='dac';$('btPanel').hidden=tab!=='bluetooth';$('tabDac').classList.toggle('selected',tab==='dac');$('tabBt').classList.toggle('selected',tab==='bluetooth')}
function renderInputs(items){const active=document.activeElement;if(active&&active.type==='range'&&!pending)return;$('inputs').replaceChildren();for(const item of items){const card=node('div',undefined,'input'),top=node('div',undefined,'inputTop'),title=node('div');title.append(node('h3',item.name),node('span',item.kind==='airplay'?'AIRPLAY 2':'BLUETOOTH','kind'));top.append(title,node('span',!item.available?'제어 준비 중':item.playing?'재생 중':'대기 중','playState'));const value=node('div',String(item.volume),'volumeValue');value.append(node('small','%'));const range=node('input');range.type='range';range.min=0;range.max=100;range.step=1;range.value=item.volume;range.disabled=!item.available||pending;range.setAttribute('aria-label',item.name+' 입력 볼륨');range.oninput=()=>{value.firstChild.textContent=range.value};range.onchange=()=>action({action:'volume',input:item.id,volume:Number(range.value)},'입력 볼륨을 변경했습니다.');const marks=node('div',undefined,'rangeLabels');marks.append(node('span','0'),node('span','100'));const mute=node('button',item.muted?'음소거 해제':'음소거',item.muted?'mute on':'mute');mute.disabled=!item.available||pending;mute.onclick=()=>action({action:'mute',input:item.id,mute:!item.muted},item.muted?'음소거를 해제했습니다.':'입력을 음소거했습니다.');card.append(top,value,range,marks,mute);$('inputs').append(card)}}
async function refresh(initial=false){try{const r=await fetch('/api/state');const data=await r.json();if(!r.ok)throw Error(data.error);state=data;token=data.csrf;$('deviceName').textContent=data.config.name;renderInputs(data.inputs);selectOptions($('dacSelect'),data.dacs.map(d=>({name:d.name,value:d.pcm})),data.config.dac_pcm);selectOptions($('btSelect'),data.bluetooth.filter(d=>d.output&&(d.paired||d.address===data.config.bluetooth_device)).map(d=>({name:d.name+(!d.bonded?' · 등록 필요':d.connected?' · 연결됨':''),value:d.address})),data.config.bluetooth_device);$('outputState').textContent=data.config.output==='dac'?'현재 DAC 출력':'현재 Bluetooth 출력';if(initial)showTab(data.config.output);$('useDac').disabled=pending||!data.dacs.length;$('useBt').disabled=pending||!$('btSelect').options.length;$('scan').disabled=pending;if(initial)notice('믹서에 연결됐습니다. 입력별 볼륨을 조절해 보세요.')}catch(e){notice(e.message,true)}}
async function action(payload,success){if(pending)return;pending=true;notice(payload.action==='scan'?'주변 장비를 검색하고 있습니다…':'설정을 적용하고 있습니다…');document.querySelectorAll('button,input,select').forEach(el=>el.disabled=true);try{const r=await fetch('/api/action',{method:'POST',headers:{'Content-Type':'application/json','X-Mixer-Token':token},body:JSON.stringify(payload)});const data=await r.json();if(!r.ok)throw Error(data.error);for(;;){await new Promise(r=>setTimeout(r,500));const response=await fetch('/api/jobs/'+data.job);const job=await response.json();if(!response.ok)throw Error(job.error||'작업 상태를 읽을 수 없습니다.');if(job.done){if(!job.ok)throw Error(job.message);break}}notice(success);await refresh();if(payload.action==='scan')renderDiscovered()}catch(e){notice(e.message,true)}finally{pending=false;document.querySelectorAll('button,input,select').forEach(el=>el.disabled=false);await refresh()}}
function renderDiscovered(){$('discovered').replaceChildren();for(const dev of state.bluetooth){const row=node('div',undefined,'discoveredRow'),name=node('div');name.append(node('span',dev.name),node('small',dev.address));const btn=node('button','연결하여 출력','quiet');btn.onclick=()=>action({action:'pair-output',address:dev.address},'Bluetooth 출력 장비를 연결했습니다.');row.append(name,btn);$('discovered').append(row)}}
$('refresh').onclick=()=>refresh();$('tabDac').onclick=()=>showTab('dac');$('tabBt').onclick=()=>showTab('bluetooth');$('useDac').onclick=()=>action({action:'output',mode:'dac',pcm:$('dacSelect').value},'DAC 출력을 선택했습니다.');$('useBt').onclick=()=>{const addr=$('btSelect').value,dev=state.bluetooth.find(d=>d.address===addr);action(dev&&!dev.bonded?{action:'pair-output',address:addr}:{action:'output',mode:'bluetooth',address:addr},'Bluetooth 출력을 선택했습니다.')};$('scan').onclick=()=>action({action:'scan'},'검색 완료. 새 장비를 연결해 주세요.');refresh(true);refreshTimer=setInterval(()=>{if(!pending)refresh()},4000);
+6
View File
@@ -0,0 +1,6 @@
<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Radxa Mixer</title><link rel="stylesheet" href="/style.css"></head><body>
<main><header><div><span class="eyebrow">WIRELESS AUDIO</span><h1>Radxa Mixer<span class="dot"></span></h1><p id="deviceName">연결 중…</p></div><button id="refresh" class="quiet">새로고침 ↻</button></header>
<div id="notice" class="notice" role="status" aria-live="polite">믹서에 연결하고 있습니다.</div>
<section><div class="sectionTitle"><h2>입력 믹서</h2><span>입력별 볼륨 · 음소거</span></div><div id="inputs" class="inputs"></div><p class="hint">AirPlay는 기존 볼륨을 조절합니다. 출력 장비의 볼륨은 변경하지 않습니다.</p></section>
<section><div class="sectionTitle"><h2>출력 장비</h2><span id="outputState">확인 중</span></div><div class="tabs"><button id="tabDac">DAC</button><button id="tabBt">Bluetooth</button></div><div id="dacPanel"><label for="dacSelect">오디오 장치</label><div class="choose"><select id="dacSelect"></select><button id="useDac">출력 선택</button></div></div><div id="btPanel" hidden><label for="btSelect">등록한 헤드폰·스피커</label><div class="choose"><select id="btSelect"></select><button id="useBt">출력 선택</button></div><div class="scanRow"><p>새 출력 장비는 페어링 모드로 켜 주세요.</p><button id="scan" class="quiet">장비 검색</button></div><div id="discovered"></div></div><p class="hint">출력 전환 중에는 잠시 소리가 멈출 수 있습니다. 전환 후 AirPlay를 다시 선택해 주세요.</p></section>
<footer>RADXA MIXER <span>공통 감쇠 −3 dB · 스테레오</span></footer></main><script src="/app.js"></script></body></html>
+1
View File
@@ -0,0 +1 @@
:root{color-scheme:dark;--bg:#0b1116;--panel:#141d24;--line:#28343e;--text:#ebf3f7;--muted:#8ea3b3;--accent:#72e4c1}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 70% 0,#17352e55,transparent 55%),var(--bg);color:var(--text);font:15px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:960px;margin:0 auto;padding:44px 24px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:26px}.eyebrow{font-size:11px;font-weight:700;letter-spacing:.18em;color:var(--accent)}h1{font-size:36px;letter-spacing:-1px;margin:8px 0}header p{color:var(--muted);margin:0}.dot{display:inline-block;width:9px;height:9px;background:var(--accent);border-radius:50%;margin-left:12px;vertical-align:middle}.notice{padding:13px 18px;background:#182d27;color:#b1ecda;border:1px solid #285844;border-radius:12px;margin-bottom:24px;white-space:pre-line}.notice.error{background:#332124;border-color:#6f3943;color:#ffc3cc}section{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:26px;margin:22px 0}.sectionTitle{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px}h2{font-size:19px;letter-spacing:-.4px;margin:0}.sectionTitle span,.hint{color:var(--muted);font-size:12px}.inputs{display:grid;grid-template-columns:repeat(auto-fit,minmax(235px,1fr));gap:16px}.input{padding:20px;border:1px solid var(--line);border-radius:14px;background:#0c151c}.inputTop{display:flex;justify-content:space-between;gap:10px;align-items:start}.input h3{font-size:16px;margin:0 0 6px;overflow-wrap:anywhere}.kind{color:var(--muted);font-size:11px}.playState{font-size:11px;color:var(--accent)}.volumeValue{font-size:42px;font-weight:600;letter-spacing:-2px;margin:24px 0 14px}.volumeValue small{font-size:16px;color:var(--muted);margin-left:6px;letter-spacing:0}input[type=range]{width:100%;accent-color:var(--accent);cursor:pointer;margin:8px 0 16px}.rangeLabels{display:flex;justify-content:space-between;color:var(--muted);font-size:11px}button,select{font:inherit;border:1px solid var(--line);border-radius:9px;padding:11px 16px}button{background:var(--accent);color:#08241c;font-weight:600;cursor:pointer;transition:background .15s}button:hover{filter:brightness(1.12)}button:disabled{opacity:.4;cursor:wait}.quiet,.mute{background:transparent;color:var(--text)}.mute{width:100%;margin-top:15px}.mute.on{background:#403028;border-color:#715141;color:#ffcf9e}.tabs{display:flex;gap:8px;margin-bottom:24px}.tabs button{background:#0c151c;color:var(--muted);min-width:120px}.tabs button.selected{border-color:var(--accent);color:var(--accent);background:#17332c}label{display:block;font-size:12px;color:var(--muted);margin:12px 0}.choose{display:flex;gap:12px}select{background:#0c151c;color:var(--text);flex:1;min-width:0}.scanRow{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-top:22px}.scanRow p{font-size:12px;color:var(--muted)}.discoveredRow{border-top:1px solid var(--line);padding:14px 0;display:flex;align-items:center;justify-content:space-between;gap:12px}.discoveredRow small{display:block;color:var(--muted);margin-top:4px}.hint{line-height:1.7;margin-bottom:0}footer{display:flex;justify-content:space-between;margin-top:32px;font-size:10px;letter-spacing:.1em;color:var(--muted)}[hidden]{display:none!important}@media(max-width:600px){main{padding:26px 16px}h1{font-size:29px}section{padding:20px}.sectionTitle{align-items:start;gap:10px}.sectionTitle span{text-align:right}.choose{flex-direction:column}.scanRow{align-items:start}.inputs{grid-template-columns:1fr}header{gap:12px}header button{font-size:12px;padding:9px}}
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""LAN mixer UI; commands are allowlisted and volume endpoints are input-only."""
import argparse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import ipaddress
import json
from pathlib import Path
import secrets
import socket
import subprocess
import threading
import time
from urllib.parse import urlsplit
import input_controls as audio
ROOT = Path(__file__).resolve().parent / 'web'
TOKEN = secrets.token_urlsafe(32)
JOBS = {}
JOB_LOCK = threading.Lock()
BUSY = False
def validate_action(data):
if not isinstance(data, dict): raise ValueError('요청 형식이 잘못됐습니다.')
action=data.get('action')
if action == 'volume':
key=data.get('input'); level=data.get('volume')
if key!='airplay' and (not isinstance(key,str) or not audio.MAC.fullmatch(key.upper())):
raise ValueError('입력 장비 주소가 잘못됐습니다.')
if type(level) is not int or not 0<=level<=100: raise ValueError('볼륨 범위: 0~100')
return ['volume',key,str(level)]
if action == 'mute':
key=data.get('input'); mute=data.get('mute')
if key!='airplay' and (not isinstance(key,str) or not audio.MAC.fullmatch(key.upper())):
raise ValueError('입력 장비 주소가 잘못됐습니다.')
if type(mute) is not bool: raise ValueError('음소거 값이 잘못됐습니다.')
return ['mute',key,'on' if mute else 'off']
if action == 'output':
mode=data.get('mode')
if mode=='dac':
pcm=data.get('pcm')
if not isinstance(pcm,str) or not __import__('re').fullmatch(r'hw:CARD=[A-Za-z0-9_-]+,DEV=[0-9]+',pcm):
raise ValueError('DAC 주소가 잘못됐습니다.')
return ['output','dac','--pcm',pcm]
if mode=='bluetooth':
addr=data.get('address')
if not isinstance(addr,str) or not audio.MAC.fullmatch(addr.upper()): raise ValueError('장비 주소가 잘못됐습니다.')
return ['output','bluetooth',addr.upper()]
raise ValueError('출력 종류가 잘못됐습니다.')
if action=='scan': return ['scan','--seconds','15']
if action=='pair-output':
addr=data.get('address')
if not isinstance(addr,str) or not audio.MAC.fullmatch(addr.upper()): raise ValueError('장비 주소가 잘못됐습니다.')
return ['pair',addr.upper(),'--output','--bond']
raise ValueError('지원하지 않는 작업입니다.')
def launch(args):
global BUSY
with JOB_LOCK:
if BUSY: raise ValueError('진행 중인 작업이 있습니다. 완료 후 다시 시도해 주세요.')
BUSY=True; key=secrets.token_hex(12)
JOBS[key]={'done':False,'ok':False,'message':'처리 중입니다.'}
if len(JOBS)>100:
for old in list(JOBS)[:-50]: JOBS.pop(old,None)
def work():
global BUSY
try:
# DAC selection must switch to DAC mode in the same serialized command.
p=subprocess.run(['/usr/local/bin/radxa-audio',*args],capture_output=True,text=True,timeout=180)
result={'done':True,'ok':p.returncode==0,'message':p.stdout[-2000:] if p.returncode==0 else (p.stderr+p.stdout)[-2000:]}
except Exception as e: result={'done':True,'ok':False,'message':str(e)}
with JOB_LOCK: JOBS[key]=result; BUSY=False
threading.Thread(target=work,daemon=True).start()
return key
def allowed_host(host):
try:
name=urlsplit('//'+host).hostname
if not name: return False
if name in ('localhost',socket.gethostname(),socket.gethostname()+'.local'): return True
addr=ipaddress.ip_address(name)
ips={'127.0.0.1','::1'}
data=json.loads(subprocess.check_output(['ip','-j','address'],text=True,timeout=3))
ips.update(a['local'] for link in data for a in link.get('addr_info',[]))
return str(addr) in ips
except (ValueError, OSError, subprocess.SubprocessError): return False
class Handler(BaseHTTPRequestHandler):
def reply(self,status,data,ctype='application/json; charset=utf-8'):
body=json.dumps(data,ensure_ascii=False).encode() if isinstance(data,(dict,list)) else data
self.send_response(status)
self.send_header('Content-Type',ctype);self.send_header('Content-Length',str(len(body)))
self.send_header('Cache-Control','no-store');self.send_header('X-Content-Type-Options','nosniff')
self.send_header('Content-Security-Policy',"default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'")
self.end_headers(); self.wfile.write(body)
def do_GET(self):
if not allowed_host(self.headers.get('Host','')): return self.reply(403,{'error':'접속 주소가 허용되지 않습니다.'})
path=urlsplit(self.path).path
try:
if path=='/api/state':
state=audio.state();state['csrf']=TOKEN;state['busy']=BUSY
return self.reply(200,state)
if path.startswith('/api/jobs/'):
with JOB_LOCK: job=JOBS.get(path.rsplit('/',1)[-1])
return self.reply(200 if job else 404,job or {'error':'작업을 찾을 수 없습니다.'})
files={'/':'index.html','/app.js':'app.js','/style.css':'style.css'}
if path not in files: return self.reply(404,{'error':'페이지가 없습니다.'})
mime={'/':'text/html; charset=utf-8','/app.js':'text/javascript; charset=utf-8','/style.css':'text/css; charset=utf-8'}[path]
self.reply(200,(ROOT/files[path]).read_bytes(),mime)
except Exception as e: self.reply(503,{'error':str(e)})
def do_POST(self):
host=self.headers.get('Host','')
origin=self.headers.get('Origin')
if not allowed_host(host) or (origin and origin!='http://'+host):
return self.reply(403,{'error':'동일한 접속 화면에서 요청해 주세요.'})
if not secrets.compare_digest(self.headers.get('X-Mixer-Token',''),TOKEN):
return self.reply(403,{'error':'화면을 새로고침해 주세요.'})
if urlsplit(self.path).path!='/api/action': return self.reply(404,{'error':'없는 API입니다.'})
try:
if not self.headers.get('Content-Type','').startswith('application/json'): raise ValueError('JSON 요청이 필요합니다.')
size=int(self.headers.get('Content-Length','0'))
if not 0<size<=4096: raise ValueError('요청 크기가 잘못됐습니다.')
args=validate_action(json.loads(self.rfile.read(size)))
self.reply(202,{'job':launch(args)})
except (ValueError,TypeError,json.JSONDecodeError) as e: self.reply(400,{'error':str(e)})
def log_message(self,format,*args): pass
def main():
parser=argparse.ArgumentParser();parser.add_argument('--host',default='0.0.0.0');parser.add_argument('--port',type=int,default=8088)
args=parser.parse_args()
server=ThreadingHTTPServer((args.host,args.port),Handler)
server.daemon_threads=True
print(f'Radxa Mixer listening on {args.host}:{args.port}',flush=True)
server.serve_forever()
if __name__=='__main__':main()
+7
View File
@@ -16,5 +16,12 @@
"file": "nqptp-1.2.8.bundle", "file": "nqptp-1.2.8.bundle",
"sha256": "0efab7d2e295981243cbd61c0d5cb940ec775ad517b02f23a748f1f6ab6a9095" "sha256": "0efab7d2e295981243cbd61c0d5cb940ec775ad517b02f23a748f1f6ab6a9095"
} }
],
"patches": [
{
"file": "shairport-native-volume.patch",
"sha256": "e0bc0e11ea0ed3816ba0f1a0762bdf40083ef1c4c71f481656dc7ba465ca0cee",
"purpose": "Apply the existing native AirPlay Volume property to the active session; no new gain stage."
}
] ]
} }
+37
View File
@@ -0,0 +1,37 @@
--- a/dbus-service.c
+++ b/dbus-service.c
@@ -715,6 +715,7 @@
if (((iv >= -30.0) && (iv <= 0.0)) || (iv == -144.0)) {
debug(2, ">> set volume to %7.4f.", iv);
config.airplay_volume = iv;
+ rtsp_set_native_volume(iv);
} else {
debug(1, ">> invalid volume: %f. Ignored.", iv);
shairport_sync_set_volume(skeleton, config.airplay_volume);
--- a/rtsp.h
+++ b/rtsp.h
@@ -31,6 +31,7 @@
// this will release the play lock only if the conn has it or if the conn is NULL
void stop_play(); // stop and drop a playing connection
+void rtsp_set_native_volume(double volume);
#ifdef CONFIG_AIRPLAY_2
ssize_t read_encrypted(int fd, pair_cipher_bundle *ctx, void *buf, size_t count);
--- a/rtsp.c
+++ b/rtsp.c
@@ -278,6 +278,14 @@
// other devices.
// If a conn has play lock, kill the connection completely.
+
+/* Update the existing player gain, without an additional gain stage. */
+void rtsp_set_native_volume(double volume) {
+ pthread_rwlock_rdlock(&principal_conn_lock);
+ if (principal_conn != NULL)
+ player_volume_without_notification(volume, principal_conn);
+ pthread_rwlock_unlock(&principal_conn_lock);
+}
void stop_play() {
pthread_rwlock_wrlock(&principal_conn_lock);
+25 -1
View File
@@ -40,7 +40,7 @@ class ManagerTests(unittest.TestCase):
self.assertIn('"radxa_mix"',f['/etc/shairport-sync.conf']) 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.assertIn('--pcm=radxa_mix',f['/etc/systemd/system/bluealsa-aplay.service.d/10-radxa-mixer.conf'])
self.assertNotIn('--single-audio', ''.join(f.values())) self.assertNotIn('--single-audio', ''.join(f.values()))
self.assertIn('ttable { 0.0 0.5 1.1 0.5 }',f['/etc/asound.conf']) self.assertIn('ttable { 0.0 0.707945784 1.1 0.707945784 }',f['/etc/asound.conf'])
self.assertIn('rate 44100',f['/etc/asound.conf']) self.assertIn('rate 44100',f['/etc/asound.conf'])
def test_output_device_is_not_hardcoded(self): def test_output_device_is_not_hardcoded(self):
@@ -51,6 +51,8 @@ class ManagerTests(unittest.TestCase):
self.assertNotIn('E4:58',unit) self.assertNotIn('E4:58',unit)
self.assertIn('Loopback', f['/etc/asound.conf']) self.assertIn('Loopback', f['/etc/asound.conf'])
self.assertIn('rate 48000',f['/etc/asound.conf']) self.assertIn('rate 48000',f['/etc/asound.conf'])
self.assertIn('--sbc-quality=medium',f['/etc/systemd/system/bluealsa.service.d/10-radxa-sink.conf'])
self.assertIn('-t 500000',unit)
def test_name_quotes_are_escaped(self): def test_name_quotes_are_escaped(self):
f=m.templates(self.config(name='Desk "Two"')) f=m.templates(self.config(name='Desk "Two"'))
@@ -64,6 +66,28 @@ class ManagerTests(unittest.TestCase):
self.assertFalse(any('pair' in call.args for call in run.call_args_list)) 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)) self.assertTrue(any('trust' in call.args for call in run.call_args_list))
def test_bond_request_must_not_report_temporary_pair_as_success(self):
def fake(*args, **kwargs):
return subprocess.CompletedProcess(args,0,stdout='Paired: yes\nBonded: no')
with patch.object(m,'root'),patch.object(m,'run',side_effect=fake) as run:
with self.assertRaises(ValueError):m.pair('AA:BB:CC:DD:EE:FF',output=True,bond=True)
self.assertFalse(any('trust' in call.args for call in run.call_args_list))
def test_bond_request_keeps_existing_bond(self):
def fake(*args, **kwargs):
return subprocess.CompletedProcess(args,0,stdout='Paired: yes\nBonded: yes')
with patch.object(m,'root'),patch.object(m,'run',side_effect=fake) as run:
m.pair('AA:BB:CC:DD:EE:FF',bond=True)
self.assertFalse(any('pair' in call.args for call in run.call_args_list))
def test_reconfigure_does_not_require_headphone_to_be_on(self):
c=self.config(output='bluetooth',bluetooth_device='AA:BB:CC:DD:EE:FF')
with patch.object(m,'root'), patch.object(m,'run') as run, patch.object(m,'load',return_value=c), \
patch.object(m,'snapshot',return_value=Path('/mock')), patch.object(m,'save'), \
patch.object(m,'active',return_value=True),patch.object(m,'enabled',return_value=True):
m.apply(c,reconfigure=True)
self.assertFalse(any('connect' in call.args for call in run.call_args_list))
def test_failed_service_restores_configuration(self): def test_failed_service_restores_configuration(self):
c=self.config() c=self.config()
with patch.object(m, 'root'), patch.object(m, 'run') as run, \ with patch.object(m, 'root'), patch.object(m, 'run') as run, \
+80
View File
@@ -0,0 +1,80 @@
import importlib.util
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0,str(Path(__file__).parents[1]/'scripts'))
import input_controls as a
import web_server as w
class InputTests(unittest.TestCase):
def test_bus_property_scalar_and_method_tuple(self):
with patch.object(a,'command',return_value='{"type":"d","data":-15.0}'):
self.assertEqual(a.bus('get-property','service','path','interface','Volume'),-15.0)
with patch.object(a,'command',return_value='{"type":"a{sv}","data":[{"Volume":-15.0}]}'):
self.assertEqual(a.bus('call','service','path','interface','GetAll'),{'Volume':-15.0})
def test_headphone_volume_write_is_rejected(self):
props={'Device':'/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF','Mode':'sink','Transport':'A2DP-source','Volume':0x7f7f}
with patch.object(a,'pcms',return_value={'/output':props}), patch.object(a,'command') as command:
with self.assertRaises(ValueError):a.set_input('AA:BB:CC:DD:EE:FF',volume=20)
command.assert_not_called()
def test_bluetooth_only_writes_input_pcm(self):
source={'Device':'/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF','Mode':'source','Transport':'A2DP-sink','Volume':0x7f7f}
sink={**source,'Mode':'sink','Transport':'A2DP-source'}
with patch.object(a,'pcms',return_value={'/input':source,'/output':sink}),patch.object(a,'command') as command:
a.set_input('AA:BB:CC:DD:EE:FF',volume=40)
self.assertEqual(command.call_count,2)
self.assertTrue(all('/input' in call.args for call in command.call_args_list))
self.assertTrue(all('/output' not in call.args for call in command.call_args_list))
def test_airplay_uses_existing_native_volume(self):
with patch.object(a,'airplay',return_value={'volume':80,'muted':False}),patch.object(a,'remember'),patch.object(a,'command') as command:
a.set_input('airplay',volume=50)
self.assertEqual(command.call_args.args,('busctl','--system','--','set-property',a.SPS,a.SPS_PATH,a.SPS,'Volume','d','-15.0'))
a.set_input('airplay',mute=True)
self.assertEqual(command.call_args.args[-1],'-144.0')
def test_volume_validation_rejects_outside_range_and_bool(self):
for level in (-1,101,True,'50'):
with self.subTest(level=level), self.assertRaises(ValueError): a.set_input('airplay',volume=level)
def test_packed_bluetooth_stereo_mute(self):
value=a.encode_volume(60,True)
level,muted=a.decode_volume(value)
self.assertEqual(level,60);self.assertTrue(muted)
self.assertEqual((value>>8)&255,value&255)
class WebTests(unittest.TestCase):
def test_dac_selection_is_one_atomic_cli_command(self):
self.assertEqual(w.validate_action({'action':'output','mode':'dac','pcm':'hw:CARD=Audio,DEV=0'}),
['output','dac','--pcm','hw:CARD=Audio,DEV=0'])
def test_no_output_volume_api(self):
for data in [{'action':'output-volume','volume':20},{'action':'volume','input':'dac','volume':20},
{'action':'volume','input':'airplay','volume':101},
{'action':'output','mode':'bluetooth','address':'AA:BB:CC:DD:EE:FF;reboot'}]:
with self.subTest(data=data),self.assertRaises(ValueError):w.validate_action(data)
def test_http_requires_token_and_same_origin(self):
import threading,json,urllib.request,urllib.error
server=w.ThreadingHTTPServer(('127.0.0.1',0),w.Handler)
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
url='http://127.0.0.1:'+str(server.server_address[1])+'/api/action'
try:
with patch.object(w,'allowed_host',return_value=True),patch.object(w,'launch',return_value='mockjob'):
body=json.dumps({'action':'scan'}).encode()
for headers in [{'Content-Type':'application/json'}, {'Content-Type':'application/json','X-Mixer-Token':w.TOKEN,'Origin':'http://other.site'}]:
req=urllib.request.Request(url,body,headers=headers)
with self.assertRaises(urllib.error.HTTPError) as error:urllib.request.urlopen(req,timeout=5)
self.assertEqual(error.exception.code,403);error.exception.close()
req=urllib.request.Request(url,body,headers={'Content-Type':'application/json','X-Mixer-Token':w.TOKEN})
with urllib.request.urlopen(req,timeout=5) as response:self.assertEqual(response.status,202)
finally:server.shutdown();server.server_close();thread.join()
if __name__=='__main__':unittest.main()