Radxa Mixer
연결 중…
입력 믹서
입력별 볼륨 · 음소거AirPlay는 기존 볼륨을 조절합니다. 출력 장비의 볼륨은 변경하지 않습니다.
출력 장비
확인 중새 출력 장비는 페어링 모드로 켜 주세요.
출력 전환 중에는 잠시 소리가 멈출 수 있습니다. 전환 후 AirPlay를 다시 선택해 주세요.
diff --git a/README.md b/README.md index fc241f4..a1ee264 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index a51bdfa..6249b12 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -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 초기화 서비스로 +풀리지 않으면 재부팅 후 다시 확인합니다. 페어링 기록을 먼저 삭제하지 마세요. diff --git a/VALIDATION.md b/VALIDATION.md index f8007a1..c1aa781 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -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`인 임시 연결입니다. 영구 등록 시도가 +인증 실패하여 재부팅 후 보스 등록 유지가 검증됐다고 주장하지 않습니다. +이미 영구 등록된 휴대폰 등록은 재부팅 후 유지됐습니다. ## 실제 청취로 확인한 범위 diff --git a/install.sh b/install.sh index 29d7e6f..8c1c628 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/reference/CURRENT-BOARD.md b/reference/CURRENT-BOARD.md index d0d680c..8f18f74 100644 --- a/reference/CURRENT-BOARD.md +++ b/reference/CURRENT-BOARD.md @@ -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/`에 있습니다. diff --git a/reference/web-mixer.jpg b/reference/web-mixer.jpg new file mode 100644 index 0000000..df14477 Binary files /dev/null and b/reference/web-mixer.jpg differ diff --git a/scripts/backup.py b/scripts/backup.py index 4faee47..0b5b5d5 100755 --- a/scripts/backup.py +++ b/scripts/backup.py @@ -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) diff --git a/scripts/check-build.sh b/scripts/check-build.sh index 71863f4..acb5d26 100755 --- a/scripts/check-build.sh +++ b/scripts/check-build.sh @@ -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 ) diff --git a/scripts/input_controls.py b/scripts/input_controls.py new file mode 100644 index 0000000..0ae4547 --- /dev/null +++ b/scripts/input_controls.py @@ -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} diff --git a/scripts/install-web.sh b/scripts/install-web.sh new file mode 100755 index 0000000..8e4b3d5 --- /dev/null +++ b/scripts/install-web.sh @@ -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' diff --git a/scripts/radxa-audio.py b/scripts/radxa-audio.py index aaad4d4..240e22c 100755 --- a/scripts/radxa-audio.py +++ b/scripts/radxa-audio.py @@ -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) diff --git a/scripts/web/app.js b/scripts/web/app.js new file mode 100644 index 0000000..ae4840e --- /dev/null +++ b/scripts/web/app.js @@ -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); diff --git a/scripts/web/index.html b/scripts/web/index.html new file mode 100644 index 0000000..0658f0d --- /dev/null +++ b/scripts/web/index.html @@ -0,0 +1,6 @@ +
연결 중…
AirPlay는 기존 볼륨을 조절합니다. 출력 장비의 볼륨은 변경하지 않습니다.
새 출력 장비는 페어링 모드로 켜 주세요.
출력 전환 중에는 잠시 소리가 멈출 수 있습니다. 전환 후 AirPlay를 다시 선택해 주세요.