Add web mixer with native input volume and output selection
This commit is contained in:
@@ -17,15 +17,58 @@ Mac/iPhone의 AirPlay 2 ── Shairport Sync ─┐
|
||||
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**.
|
||||
- 수신과 송신은 A2DP 음악 프로필을 사용합니다. 기준 보드에서는 SBC로 연결됐습니다.
|
||||
- SBC 송신 품질은 medium입니다. 기준 보드에서 high로 동시 수신·송신 시 끊겨
|
||||
전송량을 줄였습니다. 송신 버퍼는 500 ms이며 안정성은 청취 검증이 필요합니다.
|
||||
- DAC 출력은 44.1 kHz / 스테레오 / 16비트, 블루투스용 가상 믹서는 48 kHz입니다.
|
||||
- 각 입력을 6 dB 낮춰 두 입력을 합칠 여유를 둡니다. 세 입력 이상은 각 기기 볼륨을
|
||||
추가로 낮추세요. 지연 없는 실시간 녹음/악기 모니터링용 구성은 아닙니다.
|
||||
- 각 입력을 3 dB 낮춥니다. 여러 입력의 큰 소리가 겹쳐 찢어지면 입력 볼륨을 낮추세요. 지연 없는 실시간 녹음/악기 모니터링용 구성은 아닙니다.
|
||||
- 각 송신 기기의 볼륨으로 믹스 비율을 바꿉니다.
|
||||
- 출력은 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. 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
|
||||
@@ -175,6 +224,9 @@ apt로 추가한 의존 패키지와 블루투스 페어링 기록은 유지합
|
||||
|---|---|
|
||||
| `install.sh` | 새 보드 설치, 빌드, 서비스 설정, 실패 시 복구 |
|
||||
| `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/check-build.sh` | 서비스 변경 없이 고정 소스 빌드 확인 |
|
||||
| `sources/*.bundle`, `manifest.json` | 공식 Git 소스, 릴리스 태그, 커밋, SHA-256 |
|
||||
|
||||
+32
-1
@@ -69,7 +69,7 @@ cat /sys/module/aic8800_fdrv/parameters/ps_on
|
||||
|
||||
## 3개 이상 입력을 더하면 찢어짐
|
||||
|
||||
각 입력을 6 dB 낮춘 것은 두 입력 합산 기준입니다. 입력 수가 늘어나면
|
||||
각 입력의 공통 감쇠는 3 dB입니다. 입력 수가 늘어나면
|
||||
각 기기의 볼륨을 더 낮춰 합산 클리핑을 피해야 합니다. 최대 안정적인
|
||||
블루투스 동시 입력 수는 보드 컨트롤러/드라이버와 실제 재생으로 검증해야 합니다.
|
||||
|
||||
@@ -87,3 +87,34 @@ cat /sys/module/aic8800_fdrv/parameters/ps_on
|
||||
```bash
|
||||
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
@@ -3,12 +3,16 @@
|
||||
## 완료
|
||||
|
||||
- 입력값 검증, 연결 없는 출력 전환 중단, 서비스 실패 시 설정 복구,
|
||||
설치 백업 복구/심볼릭 링크 보존, 등록 장비 재페어링 방지 등 **자동 검사 11개 통과**.
|
||||
설치 백업 복구/심볼릭 링크 보존, 등록 장비 재페어링 방지,
|
||||
입력 PCM 제한, 출력 볼륨 변경 거부, HTTP 토큰/동일 출처 검사,
|
||||
활성 AirPlay 속성 파싱, 영구 등록 실패 처리 등 **자동 검사 23개 통과**.
|
||||
- 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`
|
||||
- Shairport Sync 웹 버전: `5.5.2-dirty-AirPlay2-smi10-OpenSSL-Avahi-ALSA-soxr-metadata-dbus-sysconfdir:/etc`
|
||||
- 포함된 네이티브 볼륨 패치를 적용한 실제 보드 빌드 및 설치 성공.
|
||||
`dirty`는 해당 패치를 적용했기 때문에 표시됩니다.
|
||||
- NQPTP: 1.2.8 / 공유 메모리 규격 10.
|
||||
- 기존 얕은 Git 복제에서 만든 bundle의 누락 이력을 발견하고,
|
||||
완전한 이력이 있는 독립 bundle로 교체함. 최종 SHA-256은 manifest에 기록.
|
||||
@@ -17,6 +21,29 @@
|
||||
- 최종 보드 설정: Bluetooth 출력, Bose QC Headphones.
|
||||
- 보스 PLAYBACK(SBC 48 kHz)과 아이폰 CAPTURE(SBC 44.1 kHz) 동시 연결 확인.
|
||||
- 기존 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
@@ -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.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']:
|
||||
for source in manifest['sources'] + manifest.get('patches', []):
|
||||
p=base/'sources'/source['file']
|
||||
assert hashlib.sha256(p.read_bytes()).hexdigest()==source['sha256'], 'Source checksum mismatch: '+p.name
|
||||
PY
|
||||
@@ -72,7 +72,7 @@ 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 \
|
||||
libglib2.0-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; }
|
||||
@@ -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"
|
||||
git apply "$PACKAGE_DIR/sources/shairport-native-volume.patch"
|
||||
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
|
||||
--with-systemdsystemunitdir=/etc/systemd/system --with-airplay-2 --with-dbus-interface
|
||||
make -j "$JOBS"
|
||||
)
|
||||
# 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
|
||||
fi
|
||||
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'
|
||||
radxa-audio init --name "$AIRPLAY_NAME" --dac "$DAC_PCM"
|
||||
radxa-audio status
|
||||
bash "$PACKAGE_DIR/scripts/install-web.sh"
|
||||
trap - EXIT
|
||||
echo "설치 완료. 복구: sudo python3 $PACKAGE_DIR/scripts/backup.py restore $BACKUP_DIR"
|
||||
if (( WIFI5 )); then echo 'Wi-Fi 5 설정 적용을 위해 편할 때 재부팅하세요. USB 네트워크 주소 설정은 변경하지 않았습니다.'; fi
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- USB DAC: Realtek USB2.0 Audio, ALSA `hw:CARD=Audio,DEV=0`.
|
||||
- 블루투스 출력: Bose QC Headphones (사용자 모델 QC SC).
|
||||
- 출력 장비 주소: `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` 설정을 재부팅 후 확인함.
|
||||
- USB 관리 주소 10.55.0.1/24는 별도 gadget/systemd-networkd 설정이며,
|
||||
이 오디오 설치 자료가 생성하거나 변경하지 않음.
|
||||
@@ -14,7 +17,9 @@
|
||||
최초 AirPlay → DAC 및 AirPlay + iPhone Bluetooth → DAC 믹스 재생은
|
||||
사용자가 정상 청취를 확인했습니다. Bose 헤드폰은 페어링/신뢰 등록 및
|
||||
A2DP SBC 48 kHz 출력 연결과 가상 믹서 전송까지 확인했습니다.
|
||||
헤드폰에서 실제 두 소리를 청취했다는 확인은 아직 받지 못했습니다.
|
||||
사용자는 이후 iPhone → 보스 경로의 소리 끊김을 보고했습니다. 수신 패킷 손실과
|
||||
버퍼 오류를 확인했습니다. 500 ms 버퍼와 SBC medium 송신 설정을 적용했으나,
|
||||
보스 출력 연결이 실패하여 경로 복구와 청취 검증이 필요합니다.
|
||||
|
||||
기존 설정 백업은 보드의 `/root/audio-mixer-backup-20261012-162401/`와
|
||||
`/root/radxa-airplay2-backup-20261011-193842/`에 있습니다.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
+14
-5
@@ -8,10 +8,18 @@ import subprocess
|
||||
import sys
|
||||
|
||||
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 = [
|
||||
'/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/radxa-audio/mixer.json', '/etc/radxa-audio/asound-dac.conf',
|
||||
'/etc/radxa-audio/input-memory.json',
|
||||
'/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',
|
||||
@@ -48,9 +56,9 @@ def snapshot(directory):
|
||||
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):
|
||||
if not set(manifest['files']).issubset(PATHS) or not set(manifest['units']).issubset(UNITS):
|
||||
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():
|
||||
target = Path(name)
|
||||
if existed:
|
||||
@@ -60,11 +68,12 @@ def restore(directory):
|
||||
else:
|
||||
target.unlink(missing_ok=True)
|
||||
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)
|
||||
# Bluetooth and Avahi may have been started by apt during installation.
|
||||
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)
|
||||
if state['active'] and result.returncode:
|
||||
print(result.stdout, file=sys.stderr)
|
||||
|
||||
@@ -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"
|
||||
git apply "$PACKAGE_DIR/sources/shairport-native-volume.patch"
|
||||
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
|
||||
--with-systemdsystemunitdir=/etc/systemd/system --with-airplay-2 --with-dbus-interface
|
||||
make -j 1
|
||||
./shairport-sync -V
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
Executable
+28
@@ -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
@@ -66,7 +66,7 @@ def save(path, text, mode=0o644):
|
||||
|
||||
|
||||
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 {{
|
||||
type dmix
|
||||
ipc_key 553001
|
||||
@@ -87,7 +87,7 @@ pcm.radxa_dmix {{
|
||||
pcm.radxa_mix {{
|
||||
type plug
|
||||
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]
|
||||
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]
|
||||
Requires=bluealsa.service
|
||||
@@ -143,7 +143,7 @@ StartLimitIntervalSec=0
|
||||
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
|
||||
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
|
||||
RestartSec=5
|
||||
TimeoutStartSec=30
|
||||
@@ -199,12 +199,16 @@ def check_dac(pcm):
|
||||
raise ValueError("DAC 출력 장치가 없습니다. aplay -l에서 카드 ID를 확인해 주세요.")
|
||||
|
||||
|
||||
def apply(c, initial=False):
|
||||
def apply(c, initial=False, reconfigure=False):
|
||||
root()
|
||||
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"])
|
||||
if c['output'] == 'bluetooth':
|
||||
if c['output'] == 'bluetooth' and not reconfigure:
|
||||
# Preflight before changing a working DAC route.
|
||||
run('modprobe', 'snd-aloop')
|
||||
result = run('bluetoothctl', '--timeout', '10', 'connect',
|
||||
@@ -229,6 +233,8 @@ def apply(c, initial=False):
|
||||
run('systemctl', 'enable', 'avahi-daemon', 'bluetooth', 'nqptp',
|
||||
'shairport-sync', 'bluealsa', 'bluealsa-aplay')
|
||||
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', 'restart', 'bluealsa')
|
||||
if c['output'] == 'bluetooth':
|
||||
@@ -238,12 +244,12 @@ def apply(c, initial=False):
|
||||
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':
|
||||
if c['output'] == 'bluetooth' and not reconfigure:
|
||||
required.append(BT_SERVICE)
|
||||
if not all(active(u) for u in required):
|
||||
raise ValueError('서비스 시작에 실패했습니다.')
|
||||
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'])
|
||||
except Exception:
|
||||
run('systemctl', 'stop', *units, check=False)
|
||||
@@ -257,12 +263,12 @@ def apply(c, initial=False):
|
||||
raise
|
||||
|
||||
|
||||
def pair(addr, output=False):
|
||||
def pair(addr, output=False, bond=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:
|
||||
if 'Paired: yes' not in info or (bond and 'Bonded: yes' not in info):
|
||||
# Outgoing pairing agent lives only for this operation.
|
||||
controller = run('bluetoothctl', 'show', capture=True).stdout
|
||||
was_pairable = 'Pairable: yes' in controller
|
||||
@@ -275,6 +281,8 @@ def pair(addr, output=False):
|
||||
info = run('bluetoothctl', 'info', addr, capture=True).stdout
|
||||
if 'Paired: yes' not in info:
|
||||
raise ValueError('페어링되지 않았습니다. 장비를 페어링 모드로 켜 주세요.')
|
||||
if bond and 'Bonded: 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)
|
||||
@@ -292,16 +300,27 @@ def main():
|
||||
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'); 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('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('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')
|
||||
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()
|
||||
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':
|
||||
run('bluetoothctl', 'devices'); run('aplay', '-l'); run('bluealsa-aplay', '-l')
|
||||
elif a.cmd == 'status':
|
||||
@@ -318,7 +337,7 @@ def main():
|
||||
run('bluetoothctl', 'scan', 'off', check=False)
|
||||
run('bluetoothctl', 'devices')
|
||||
elif a.cmd == 'pair':
|
||||
pair(a.address, a.output)
|
||||
pair(a.address, a.output, a.bond)
|
||||
elif a.cmd == 'pair-input':
|
||||
root()
|
||||
if not 15 <= a.seconds <= 900: raise ValueError('페어링 시간: 15~900초')
|
||||
@@ -338,12 +357,15 @@ def main():
|
||||
c = load()
|
||||
if a.cmd == 'output':
|
||||
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 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)
|
||||
apply(c, reconfigure=a.cmd == 'apply')
|
||||
|
||||
|
||||
def interactive_pair(seconds):
|
||||
@@ -381,7 +403,15 @@ def interactive_pair(seconds):
|
||||
|
||||
if __name__ == '__main__':
|
||||
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:
|
||||
print('오류:', e, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
Executable
+144
@@ -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()
|
||||
@@ -16,5 +16,12 @@
|
||||
"file": "nqptp-1.2.8.bundle",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -40,7 +40,7 @@ class ManagerTests(unittest.TestCase):
|
||||
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('ttable { 0.0 0.707945784 1.1 0.707945784 }',f['/etc/asound.conf'])
|
||||
self.assertIn('rate 44100',f['/etc/asound.conf'])
|
||||
|
||||
def test_output_device_is_not_hardcoded(self):
|
||||
@@ -51,6 +51,8 @@ class ManagerTests(unittest.TestCase):
|
||||
self.assertNotIn('E4:58',unit)
|
||||
self.assertIn('Loopback', 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):
|
||||
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.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):
|
||||
c=self.config()
|
||||
with patch.object(m, 'root'), patch.object(m, 'run') as run, \
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user