Add web mixer with native input volume and output selection
This commit is contained in:
+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()
|
||||
Reference in New Issue
Block a user