Add web mixer with native input volume and output selection

This commit is contained in:
jsahn
2026-10-12 18:45:43 +09:00
parent ceef64dfed
commit 5fbece12bb
19 changed files with 696 additions and 34 deletions
+14 -5
View File
@@ -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)
+2 -1
View File
@@ -15,10 +15,11 @@ git clone -q --branch 5.5.2 "$PACKAGE_DIR/sources/shairport-sync-5.5.2.bundle" "
)
(
cd "$BUILD_DIR/shairport-sync"
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
)
+167
View File
@@ -0,0 +1,167 @@
"""Native input volume controls. Never writes a DAC/headphone output volume."""
import json
import math
from pathlib import Path
import re
import subprocess
import tempfile
import os
CONFIG = Path('/etc/radxa-audio/mixer.json')
MEMORY = Path('/etc/radxa-audio/input-memory.json')
SPS = 'org.gnome.ShairportSync'
SPS_PATH = '/org/gnome/ShairportSync'
MAC = re.compile(r'(?:[0-9A-F]{2}:){5}[0-9A-F]{2}\Z')
def command(*args):
p = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=12)
if p.returncode:
raise ValueError(p.stderr.strip() or p.stdout.strip() or '장치 제어에 실패했습니다.')
return p.stdout
def bus(*args):
result = json.loads(command('busctl', '--system', '--json=short', *args))
# Method replies contain a tuple; properties contain their direct value.
return result['data'][0] if args[0] == 'call' else result['data']
def objects(service, path):
raw = bus('call', service, path, 'org.freedesktop.DBus.ObjectManager', 'GetManagedObjects')
return {p: {i: {k: v['data'] for k, v in props.items()} for i, props in interfaces.items()}
for p, interfaces in raw.items()}
def pcms():
return {p: interfaces['org.bluealsa.PCM1'] for p, interfaces in objects('org.bluealsa', '/org/bluealsa').items()
if 'org.bluealsa.PCM1' in interfaces}
def is_input(props):
return props.get('Mode') == 'source' and props.get('Transport') == 'A2DP-sink'
def memory():
try: return json.loads(MEMORY.read_text())
except FileNotFoundError: return {}
def remember(key, percent):
data = memory(); data[key] = percent
MEMORY.parent.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(dir=MEMORY.parent)
try:
with os.fdopen(fd, 'w') as f: json.dump(data, f)
os.chmod(name, 0o644); os.replace(name, MEMORY)
finally:
if os.path.exists(name): os.unlink(name)
def address(device_path):
match = re.search(r'/dev_([0-9A-F_]{17})$', device_path)
if not match: raise ValueError('장비 주소를 읽을 수 없습니다.')
return match[1].replace('_', ':')
def airplay():
props = bus('call', SPS, SPS_PATH, 'org.freedesktop.DBus.Properties', 'GetAll', 's', SPS)
props = {k: v['data'] for k,v in props.items()}
value = props['Volume']
if props.get('Active'):
try:
value = bus('get-property', SPS, SPS_PATH, SPS+'.RemoteControl', 'AirplayVolume')
except (ValueError, subprocess.TimeoutExpired):
pass
muted = value <= -100
percent = round((value + 30) / 30 * 100) if not muted else memory().get('airplay', 75)
return {'id':'airplay', 'name':'AirPlay', 'kind':'airplay', 'volume':max(0,min(100,percent)),
'muted':muted, 'playing':bool(props.get('Active')), 'available':True}
def decode_volume(value):
levels = [(value >> 8) & 127, value & 127]
return round(sum(levels) / 254 * 100), bool(value & 0x8080)
def encode_volume(percent, muted):
byte = round(percent * 127 / 100) | (128 if muted else 0)
return (byte << 8) | byte
def inputs():
result = []
try: result.append(airplay())
except (ValueError, KeyError, subprocess.TimeoutExpired):
result.append({'id':'airplay','name':'AirPlay','kind':'airplay','volume':0,
'muted':False,'playing':False,'available':False})
try:
devices = objects('org.bluez', '/')
for path, props in pcms().items():
if not is_input(props): continue
dev = props['Device']; key = address(dev)
d = devices.get(dev, {}).get('org.bluez.Device1', {})
volume, muted = decode_volume(int(props['Volume']))
result.append({'id':key,'name':d.get('Alias', key),'kind':'bluetooth','volume':volume,
'muted':muted,'playing':bool(props.get('Running')),'available':True})
except (ValueError, KeyError, subprocess.TimeoutExpired): pass
return result
def set_input(key, volume=None, mute=None):
if volume is not None and (type(volume) is not int or not 0 <= volume <= 100):
raise ValueError('입력 볼륨은 0~100의 정수여야 합니다.')
if mute is not None and type(mute) is not bool: raise ValueError('음소거 값이 잘못됐습니다.')
if key == 'airplay':
current = airplay()
percent = current['volume'] if volume is None else volume
muted = current['muted'] if mute is None else mute
if volume is not None: muted = percent == 0
if mute is True: remember(key, current['volume'])
if not muted and percent: remember(key, percent)
value = -144.0 if muted or percent == 0 else -30.0 + percent * 0.3
command('busctl','--system','--','set-property',SPS,SPS_PATH,SPS,'Volume','d',str(value))
return
key = str(key).upper()
if not MAC.fullmatch(key): raise ValueError('입력 장비 주소가 잘못됐습니다.')
matches = [(p, v) for p,v in pcms().items() if is_input(v) and address(v['Device']) == key]
if not matches: raise ValueError('이 장비는 연결된 Bluetooth 입력이 아닙니다.')
for path, props in matches:
old, old_mute = decode_volume(int(props['Volume']))
percent = old if volume is None else volume
muted = old_mute if mute is None else mute
if volume is not None: muted = percent == 0
# Only the validated source PCM is written; output PCMs cannot pass this guard.
command('busctl','--system','--','set-property','org.bluealsa',path,'org.bluealsa.PCM1','SoftVolume','b','true')
command('busctl','--system','--','set-property','org.bluealsa',path,'org.bluealsa.PCM1','Volume','q',
str(encode_volume(percent, muted)))
def devices():
result = []
for path, interfaces in objects('org.bluez', '/').items():
props = interfaces.get('org.bluez.Device1')
if not props: continue
result.append({'address': props['Address'],'name': props.get('Alias',props['Address']),
'bonded': bool(props.get('Bonded')), 'paired': bool(props.get('Paired')),'connected':bool(props.get('Connected')),
'output': '0000110b-0000-1000-8000-00805f9b34fb' in props.get('UUIDs',[])})
return result
def dacs():
result = []
for line in command('aplay','-l').splitlines():
match = re.match(r'card \d+: ([\w-]+) \[([^]]+)\], device (\d+): ([^[]+)',line)
if match and match[1] != 'Loopback':
result.append({'pcm':f'hw:CARD={match[1]},DEV={match[3]}','name':match[2]+' · '+match[4].strip()})
return result
def state():
config = json.loads(CONFIG.read_text())
issues = []
try: bluetooth = devices()
except Exception as e: bluetooth=[]; issues.append(str(e))
try: dac = dacs()
except Exception as e: dac=[]; issues.append(str(e))
return {'config':config,'inputs':inputs(),'bluetooth':bluetooth,'dacs':dac,'issues':issues}
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# Install/update web tools on a board that already has Shairport Sync with D-Bus.
set -euo pipefail
PACKAGE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
[[ $EUID == 0 ]] || { echo 'sudo로 실행해 주세요.' >&2; exit 1; }
/usr/local/bin/shairport-sync -V | grep -qi dbus || { echo '먼저 AirPlay D-Bus 지원 빌드를 설치해 주세요.' >&2; exit 1; }
install -d -m 755 /usr/local/lib/radxa-mixer/web
install -m 755 "$PACKAGE_DIR/scripts/radxa-audio.py" /usr/local/bin/radxa-audio
install -m 755 "$PACKAGE_DIR/scripts/web_server.py" /usr/local/lib/radxa-mixer/web_server.py
install -m 644 "$PACKAGE_DIR/scripts/input_controls.py" /usr/local/lib/radxa-mixer/input_controls.py
install -m 644 "$PACKAGE_DIR/scripts/web/"* /usr/local/lib/radxa-mixer/web/
cat > /etc/systemd/system/radxa-mixer-web.service <<'UNIT'
[Unit]
Description=Radxa wireless audio mixer web interface
After=network.target bluetooth.service shairport-sync.service bluealsa.service
[Service]
ExecStart=/usr/bin/python3 /usr/local/lib/radxa-mixer/web_server.py --port 8088
Restart=on-failure
RestartSec=3
UMask=0022
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now radxa-mixer-web
systemctl restart radxa-mixer-web
echo '웹 믹서: http://보드IP:8088'
+46 -16
View File
@@ -66,7 +66,7 @@ def save(path, text, mode=0o644):
def mixer(pcm, rate):
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)
+11
View File
@@ -0,0 +1,11 @@
let state, token, pending=false, tab='dac', refreshTimer;
const $=id=>document.getElementById(id);
function notice(message,error=false){$('notice').textContent=message;$('notice').classList.toggle('error',error)}
function node(tag,text,cls){const el=document.createElement(tag);if(text!==undefined)el.textContent=text;if(cls)el.className=cls;return el}
function selectOptions(el,items,value){const old=el.value;el.replaceChildren();for(const item of items){const op=node('option',item.name);op.value=item.value;el.append(op)}el.value=items.some(x=>x.value===old)?old:value;if(!el.value&&items.length)el.value=items[0].value}
function showTab(next){tab=next;$('dacPanel').hidden=tab!=='dac';$('btPanel').hidden=tab!=='bluetooth';$('tabDac').classList.toggle('selected',tab==='dac');$('tabBt').classList.toggle('selected',tab==='bluetooth')}
function renderInputs(items){const active=document.activeElement;if(active&&active.type==='range'&&!pending)return;$('inputs').replaceChildren();for(const item of items){const card=node('div',undefined,'input'),top=node('div',undefined,'inputTop'),title=node('div');title.append(node('h3',item.name),node('span',item.kind==='airplay'?'AIRPLAY 2':'BLUETOOTH','kind'));top.append(title,node('span',!item.available?'제어 준비 중':item.playing?'재생 중':'대기 중','playState'));const value=node('div',String(item.volume),'volumeValue');value.append(node('small','%'));const range=node('input');range.type='range';range.min=0;range.max=100;range.step=1;range.value=item.volume;range.disabled=!item.available||pending;range.setAttribute('aria-label',item.name+' 입력 볼륨');range.oninput=()=>{value.firstChild.textContent=range.value};range.onchange=()=>action({action:'volume',input:item.id,volume:Number(range.value)},'입력 볼륨을 변경했습니다.');const marks=node('div',undefined,'rangeLabels');marks.append(node('span','0'),node('span','100'));const mute=node('button',item.muted?'음소거 해제':'음소거',item.muted?'mute on':'mute');mute.disabled=!item.available||pending;mute.onclick=()=>action({action:'mute',input:item.id,mute:!item.muted},item.muted?'음소거를 해제했습니다.':'입력을 음소거했습니다.');card.append(top,value,range,marks,mute);$('inputs').append(card)}}
async function refresh(initial=false){try{const r=await fetch('/api/state');const data=await r.json();if(!r.ok)throw Error(data.error);state=data;token=data.csrf;$('deviceName').textContent=data.config.name;renderInputs(data.inputs);selectOptions($('dacSelect'),data.dacs.map(d=>({name:d.name,value:d.pcm})),data.config.dac_pcm);selectOptions($('btSelect'),data.bluetooth.filter(d=>d.output&&(d.paired||d.address===data.config.bluetooth_device)).map(d=>({name:d.name+(!d.bonded?' · 등록 필요':d.connected?' · 연결됨':''),value:d.address})),data.config.bluetooth_device);$('outputState').textContent=data.config.output==='dac'?'현재 DAC 출력':'현재 Bluetooth 출력';if(initial)showTab(data.config.output);$('useDac').disabled=pending||!data.dacs.length;$('useBt').disabled=pending||!$('btSelect').options.length;$('scan').disabled=pending;if(initial)notice('믹서에 연결됐습니다. 입력별 볼륨을 조절해 보세요.')}catch(e){notice(e.message,true)}}
async function action(payload,success){if(pending)return;pending=true;notice(payload.action==='scan'?'주변 장비를 검색하고 있습니다…':'설정을 적용하고 있습니다…');document.querySelectorAll('button,input,select').forEach(el=>el.disabled=true);try{const r=await fetch('/api/action',{method:'POST',headers:{'Content-Type':'application/json','X-Mixer-Token':token},body:JSON.stringify(payload)});const data=await r.json();if(!r.ok)throw Error(data.error);for(;;){await new Promise(r=>setTimeout(r,500));const response=await fetch('/api/jobs/'+data.job);const job=await response.json();if(!response.ok)throw Error(job.error||'작업 상태를 읽을 수 없습니다.');if(job.done){if(!job.ok)throw Error(job.message);break}}notice(success);await refresh();if(payload.action==='scan')renderDiscovered()}catch(e){notice(e.message,true)}finally{pending=false;document.querySelectorAll('button,input,select').forEach(el=>el.disabled=false);await refresh()}}
function renderDiscovered(){$('discovered').replaceChildren();for(const dev of state.bluetooth){const row=node('div',undefined,'discoveredRow'),name=node('div');name.append(node('span',dev.name),node('small',dev.address));const btn=node('button','연결하여 출력','quiet');btn.onclick=()=>action({action:'pair-output',address:dev.address},'Bluetooth 출력 장비를 연결했습니다.');row.append(name,btn);$('discovered').append(row)}}
$('refresh').onclick=()=>refresh();$('tabDac').onclick=()=>showTab('dac');$('tabBt').onclick=()=>showTab('bluetooth');$('useDac').onclick=()=>action({action:'output',mode:'dac',pcm:$('dacSelect').value},'DAC 출력을 선택했습니다.');$('useBt').onclick=()=>{const addr=$('btSelect').value,dev=state.bluetooth.find(d=>d.address===addr);action(dev&&!dev.bonded?{action:'pair-output',address:addr}:{action:'output',mode:'bluetooth',address:addr},'Bluetooth 출력을 선택했습니다.')};$('scan').onclick=()=>action({action:'scan'},'검색 완료. 새 장비를 연결해 주세요.');refresh(true);refreshTimer=setInterval(()=>{if(!pending)refresh()},4000);
+6
View File
@@ -0,0 +1,6 @@
<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Radxa Mixer</title><link rel="stylesheet" href="/style.css"></head><body>
<main><header><div><span class="eyebrow">WIRELESS AUDIO</span><h1>Radxa Mixer<span class="dot"></span></h1><p id="deviceName">연결 중…</p></div><button id="refresh" class="quiet">새로고침 ↻</button></header>
<div id="notice" class="notice" role="status" aria-live="polite">믹서에 연결하고 있습니다.</div>
<section><div class="sectionTitle"><h2>입력 믹서</h2><span>입력별 볼륨 · 음소거</span></div><div id="inputs" class="inputs"></div><p class="hint">AirPlay는 기존 볼륨을 조절합니다. 출력 장비의 볼륨은 변경하지 않습니다.</p></section>
<section><div class="sectionTitle"><h2>출력 장비</h2><span id="outputState">확인 중</span></div><div class="tabs"><button id="tabDac">DAC</button><button id="tabBt">Bluetooth</button></div><div id="dacPanel"><label for="dacSelect">오디오 장치</label><div class="choose"><select id="dacSelect"></select><button id="useDac">출력 선택</button></div></div><div id="btPanel" hidden><label for="btSelect">등록한 헤드폰·스피커</label><div class="choose"><select id="btSelect"></select><button id="useBt">출력 선택</button></div><div class="scanRow"><p>새 출력 장비는 페어링 모드로 켜 주세요.</p><button id="scan" class="quiet">장비 검색</button></div><div id="discovered"></div></div><p class="hint">출력 전환 중에는 잠시 소리가 멈출 수 있습니다. 전환 후 AirPlay를 다시 선택해 주세요.</p></section>
<footer>RADXA MIXER <span>공통 감쇠 −3 dB · 스테레오</span></footer></main><script src="/app.js"></script></body></html>
+1
View File
@@ -0,0 +1 @@
:root{color-scheme:dark;--bg:#0b1116;--panel:#141d24;--line:#28343e;--text:#ebf3f7;--muted:#8ea3b3;--accent:#72e4c1}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 70% 0,#17352e55,transparent 55%),var(--bg);color:var(--text);font:15px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:960px;margin:0 auto;padding:44px 24px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:26px}.eyebrow{font-size:11px;font-weight:700;letter-spacing:.18em;color:var(--accent)}h1{font-size:36px;letter-spacing:-1px;margin:8px 0}header p{color:var(--muted);margin:0}.dot{display:inline-block;width:9px;height:9px;background:var(--accent);border-radius:50%;margin-left:12px;vertical-align:middle}.notice{padding:13px 18px;background:#182d27;color:#b1ecda;border:1px solid #285844;border-radius:12px;margin-bottom:24px;white-space:pre-line}.notice.error{background:#332124;border-color:#6f3943;color:#ffc3cc}section{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:26px;margin:22px 0}.sectionTitle{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px}h2{font-size:19px;letter-spacing:-.4px;margin:0}.sectionTitle span,.hint{color:var(--muted);font-size:12px}.inputs{display:grid;grid-template-columns:repeat(auto-fit,minmax(235px,1fr));gap:16px}.input{padding:20px;border:1px solid var(--line);border-radius:14px;background:#0c151c}.inputTop{display:flex;justify-content:space-between;gap:10px;align-items:start}.input h3{font-size:16px;margin:0 0 6px;overflow-wrap:anywhere}.kind{color:var(--muted);font-size:11px}.playState{font-size:11px;color:var(--accent)}.volumeValue{font-size:42px;font-weight:600;letter-spacing:-2px;margin:24px 0 14px}.volumeValue small{font-size:16px;color:var(--muted);margin-left:6px;letter-spacing:0}input[type=range]{width:100%;accent-color:var(--accent);cursor:pointer;margin:8px 0 16px}.rangeLabels{display:flex;justify-content:space-between;color:var(--muted);font-size:11px}button,select{font:inherit;border:1px solid var(--line);border-radius:9px;padding:11px 16px}button{background:var(--accent);color:#08241c;font-weight:600;cursor:pointer;transition:background .15s}button:hover{filter:brightness(1.12)}button:disabled{opacity:.4;cursor:wait}.quiet,.mute{background:transparent;color:var(--text)}.mute{width:100%;margin-top:15px}.mute.on{background:#403028;border-color:#715141;color:#ffcf9e}.tabs{display:flex;gap:8px;margin-bottom:24px}.tabs button{background:#0c151c;color:var(--muted);min-width:120px}.tabs button.selected{border-color:var(--accent);color:var(--accent);background:#17332c}label{display:block;font-size:12px;color:var(--muted);margin:12px 0}.choose{display:flex;gap:12px}select{background:#0c151c;color:var(--text);flex:1;min-width:0}.scanRow{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-top:22px}.scanRow p{font-size:12px;color:var(--muted)}.discoveredRow{border-top:1px solid var(--line);padding:14px 0;display:flex;align-items:center;justify-content:space-between;gap:12px}.discoveredRow small{display:block;color:var(--muted);margin-top:4px}.hint{line-height:1.7;margin-bottom:0}footer{display:flex;justify-content:space-between;margin-top:32px;font-size:10px;letter-spacing:.1em;color:var(--muted)}[hidden]{display:none!important}@media(max-width:600px){main{padding:26px 16px}h1{font-size:29px}section{padding:20px}.sectionTitle{align-items:start;gap:10px}.sectionTitle span{text-align:right}.choose{flex-direction:column}.scanRow{align-items:start}.inputs{grid-template-columns:1fr}header{gap:12px}header button{font-size:12px;padding:9px}}
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""LAN mixer UI; commands are allowlisted and volume endpoints are input-only."""
import argparse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import ipaddress
import json
from pathlib import Path
import secrets
import socket
import subprocess
import threading
import time
from urllib.parse import urlsplit
import input_controls as audio
ROOT = Path(__file__).resolve().parent / 'web'
TOKEN = secrets.token_urlsafe(32)
JOBS = {}
JOB_LOCK = threading.Lock()
BUSY = False
def validate_action(data):
if not isinstance(data, dict): raise ValueError('요청 형식이 잘못됐습니다.')
action=data.get('action')
if action == 'volume':
key=data.get('input'); level=data.get('volume')
if key!='airplay' and (not isinstance(key,str) or not audio.MAC.fullmatch(key.upper())):
raise ValueError('입력 장비 주소가 잘못됐습니다.')
if type(level) is not int or not 0<=level<=100: raise ValueError('볼륨 범위: 0~100')
return ['volume',key,str(level)]
if action == 'mute':
key=data.get('input'); mute=data.get('mute')
if key!='airplay' and (not isinstance(key,str) or not audio.MAC.fullmatch(key.upper())):
raise ValueError('입력 장비 주소가 잘못됐습니다.')
if type(mute) is not bool: raise ValueError('음소거 값이 잘못됐습니다.')
return ['mute',key,'on' if mute else 'off']
if action == 'output':
mode=data.get('mode')
if mode=='dac':
pcm=data.get('pcm')
if not isinstance(pcm,str) or not __import__('re').fullmatch(r'hw:CARD=[A-Za-z0-9_-]+,DEV=[0-9]+',pcm):
raise ValueError('DAC 주소가 잘못됐습니다.')
return ['output','dac','--pcm',pcm]
if mode=='bluetooth':
addr=data.get('address')
if not isinstance(addr,str) or not audio.MAC.fullmatch(addr.upper()): raise ValueError('장비 주소가 잘못됐습니다.')
return ['output','bluetooth',addr.upper()]
raise ValueError('출력 종류가 잘못됐습니다.')
if action=='scan': return ['scan','--seconds','15']
if action=='pair-output':
addr=data.get('address')
if not isinstance(addr,str) or not audio.MAC.fullmatch(addr.upper()): raise ValueError('장비 주소가 잘못됐습니다.')
return ['pair',addr.upper(),'--output','--bond']
raise ValueError('지원하지 않는 작업입니다.')
def launch(args):
global BUSY
with JOB_LOCK:
if BUSY: raise ValueError('진행 중인 작업이 있습니다. 완료 후 다시 시도해 주세요.')
BUSY=True; key=secrets.token_hex(12)
JOBS[key]={'done':False,'ok':False,'message':'처리 중입니다.'}
if len(JOBS)>100:
for old in list(JOBS)[:-50]: JOBS.pop(old,None)
def work():
global BUSY
try:
# DAC selection must switch to DAC mode in the same serialized command.
p=subprocess.run(['/usr/local/bin/radxa-audio',*args],capture_output=True,text=True,timeout=180)
result={'done':True,'ok':p.returncode==0,'message':p.stdout[-2000:] if p.returncode==0 else (p.stderr+p.stdout)[-2000:]}
except Exception as e: result={'done':True,'ok':False,'message':str(e)}
with JOB_LOCK: JOBS[key]=result; BUSY=False
threading.Thread(target=work,daemon=True).start()
return key
def allowed_host(host):
try:
name=urlsplit('//'+host).hostname
if not name: return False
if name in ('localhost',socket.gethostname(),socket.gethostname()+'.local'): return True
addr=ipaddress.ip_address(name)
ips={'127.0.0.1','::1'}
data=json.loads(subprocess.check_output(['ip','-j','address'],text=True,timeout=3))
ips.update(a['local'] for link in data for a in link.get('addr_info',[]))
return str(addr) in ips
except (ValueError, OSError, subprocess.SubprocessError): return False
class Handler(BaseHTTPRequestHandler):
def reply(self,status,data,ctype='application/json; charset=utf-8'):
body=json.dumps(data,ensure_ascii=False).encode() if isinstance(data,(dict,list)) else data
self.send_response(status)
self.send_header('Content-Type',ctype);self.send_header('Content-Length',str(len(body)))
self.send_header('Cache-Control','no-store');self.send_header('X-Content-Type-Options','nosniff')
self.send_header('Content-Security-Policy',"default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'")
self.end_headers(); self.wfile.write(body)
def do_GET(self):
if not allowed_host(self.headers.get('Host','')): return self.reply(403,{'error':'접속 주소가 허용되지 않습니다.'})
path=urlsplit(self.path).path
try:
if path=='/api/state':
state=audio.state();state['csrf']=TOKEN;state['busy']=BUSY
return self.reply(200,state)
if path.startswith('/api/jobs/'):
with JOB_LOCK: job=JOBS.get(path.rsplit('/',1)[-1])
return self.reply(200 if job else 404,job or {'error':'작업을 찾을 수 없습니다.'})
files={'/':'index.html','/app.js':'app.js','/style.css':'style.css'}
if path not in files: return self.reply(404,{'error':'페이지가 없습니다.'})
mime={'/':'text/html; charset=utf-8','/app.js':'text/javascript; charset=utf-8','/style.css':'text/css; charset=utf-8'}[path]
self.reply(200,(ROOT/files[path]).read_bytes(),mime)
except Exception as e: self.reply(503,{'error':str(e)})
def do_POST(self):
host=self.headers.get('Host','')
origin=self.headers.get('Origin')
if not allowed_host(host) or (origin and origin!='http://'+host):
return self.reply(403,{'error':'동일한 접속 화면에서 요청해 주세요.'})
if not secrets.compare_digest(self.headers.get('X-Mixer-Token',''),TOKEN):
return self.reply(403,{'error':'화면을 새로고침해 주세요.'})
if urlsplit(self.path).path!='/api/action': return self.reply(404,{'error':'없는 API입니다.'})
try:
if not self.headers.get('Content-Type','').startswith('application/json'): raise ValueError('JSON 요청이 필요합니다.')
size=int(self.headers.get('Content-Length','0'))
if not 0<size<=4096: raise ValueError('요청 크기가 잘못됐습니다.')
args=validate_action(json.loads(self.rfile.read(size)))
self.reply(202,{'job':launch(args)})
except (ValueError,TypeError,json.JSONDecodeError) as e: self.reply(400,{'error':str(e)})
def log_message(self,format,*args): pass
def main():
parser=argparse.ArgumentParser();parser.add_argument('--host',default='0.0.0.0');parser.add_argument('--port',type=int,default=8088)
args=parser.parse_args()
server=ThreadingHTTPServer((args.host,args.port),Handler)
server.daemon_threads=True
print(f'Radxa Mixer listening on {args.host}:{args.port}',flush=True)
server.serve_forever()
if __name__=='__main__':main()