Files
Radxa_MIXER/scripts/radxa-audio.py
T

391 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""Radxa audio mixer manager. Debian 13 / systemd; Python standard library only."""
import argparse
import datetime
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
CONFIG = Path('/etc/radxa-audio/mixer.json')
BT_SERVICE = 'radxa-bt-output.service'
MAC_RE = re.compile(r'(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\Z')
PCM_RE = re.compile(r'hw:CARD=[A-Za-z0-9_-]+,DEV=[0-9]+(?:,SUBDEV=[0-9]+)?\Z')
def run(*args, check=True, capture=False, timeout=40):
return subprocess.run(args, check=check, text=True,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.STDOUT if capture else None, timeout=timeout)
def root():
if os.geteuid() != 0:
raise ValueError('sudo로 실행해 주세요.')
def validate(c):
if not isinstance(c.get('name'), str) or not c['name'].strip() or len(c['name'].encode()) > 80:
raise ValueError('AirPlay 이름은 1~80바이트여야 합니다.')
if any(ord(ch) < 32 for ch in c['name']):
raise ValueError('이름에는 제어 문자를 사용할 수 없습니다.')
if not PCM_RE.fullmatch(c.get('dac_pcm', '')):
raise ValueError('DAC 형식 예: hw:CARD=Audio,DEV=0')
if c.get('output') not in ('dac', 'bluetooth'):
raise ValueError('output은 dac 또는 bluetooth여야 합니다.')
addr = c.get('bluetooth_device', '')
if addr and not MAC_RE.fullmatch(addr):
raise ValueError('블루투스 주소 형식이 잘못됐습니다.')
if c['output'] == 'bluetooth' and not addr:
raise ValueError('먼저 블루투스 출력 장비 주소를 설정해 주세요.')
return c
def load():
if not CONFIG.exists():
raise ValueError('설치되지 않았습니다. install.sh를 먼저 실행해 주세요.')
return validate(json.loads(CONFIG.read_text()))
def save(path, text, mode=0o644):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp = tempfile.mkstemp(dir=path.parent, prefix='.radxa-')
try:
with os.fdopen(fd, 'w') as f:
f.write(text)
os.chmod(temp, mode)
os.replace(temp, path)
finally:
if os.path.exists(temp):
os.unlink(temp)
def mixer(pcm, rate):
return f'''# Managed by radxa-audio; each input has 6 dB of headroom.
pcm.radxa_dmix {{
type dmix
ipc_key 553001
ipc_key_add_uid false
ipc_perm 0660
ipc_gid audio
slave {{
pcm "{pcm}"
format S16_LE
rate {rate}
channels 2
period_time 0
period_size {rate // 100}
buffer_size {rate // 2}
}}
bindings {{ 0 0 1 1 }}
}}
pcm.radxa_mix {{
type plug
slave.pcm "radxa_dmix"
ttable {{ 0.0 0.5 1.1 0.5 }}
}}
'''
def templates(c):
validate(c)
dac = mixer(c['dac_pcm'], 44100)
bt = mixer('hw:CARD=Loopback,DEV=0,SUBDEV=0', 48000)
files = {
'/etc/radxa-audio/asound-dac.conf': dac,
'/etc/radxa-audio/asound-bluetooth.conf': bt,
'/etc/asound.conf': dac if c['output'] == 'dac' else bt,
str(CONFIG): json.dumps(c, ensure_ascii=False, indent=2) + '\n',
'/etc/shairport-sync.conf': f'''general = {{
name = {json.dumps(c['name'], ensure_ascii=False)};
service_type = "airplay2";
output_backend = "alsa";
audio_backend_buffer_desired_length_in_seconds = 0.5;
}};
alsa = {{
output_device = "radxa_mix";
output_format = "S16_LE";
}};
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
''',
'/etc/systemd/system/bluealsa-aplay.service.d/10-radxa-mixer.conf': '''[Unit]
Requires=bluealsa.service
After=bluealsa.service sound.target
[Service]
User=shairport-sync
Group=audio
PrivateUsers=false
RemoveIPC=false
ExecStart=
ExecStart=/usr/bin/bluealsa-aplay -S --pcm=radxa_mix --volume=software --pcm-period-time=10000 --pcm-buffer-time=500000
''',
'/etc/modules-load.d/radxa-audio-loopback.conf': 'snd-aloop\n',
'/etc/modprobe.d/radxa-audio-loopback.conf': 'options snd-aloop pcm_substreams=1\n',
}
if c.get('bluetooth_device'):
addr = c['bluetooth_device'].upper()
files['/etc/systemd/system/' + BT_SERVICE] = f'''[Unit]
Description=Radxa mixed audio to Bluetooth headphones or speaker
Requires=bluealsa.service
After=bluealsa.service sound.target systemd-modules-load.service
PartOf=bluealsa.service
StartLimitIntervalSec=0
[Service]
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
Restart=always
RestartSec=5
TimeoutStartSec=30
[Install]
WantedBy=multi-user.target
'''
return files
def snapshot(paths):
stamp = datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f')
directory = Path('/var/backups/radxa-audio') / stamp
directory.mkdir(parents=True, mode=0o700)
records = {}
for name in paths:
path = Path(name)
records[name] = os.path.lexists(path)
if records[name]:
dest = directory / name.lstrip('/')
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, dest, follow_symlinks=False)
save(directory / 'files.json', json.dumps(records, indent=2) + '\n', 0o600)
return directory
def restore_files(directory):
records = json.loads((directory / 'files.json').read_text())
for name, existed in records.items():
path = Path(name)
if existed:
path.parent.mkdir(parents=True, exist_ok=True)
path.unlink(missing_ok=True)
shutil.copy2(directory / name.lstrip('/'), path, follow_symlinks=False)
else:
path.unlink(missing_ok=True)
def active(unit):
return run('systemctl', 'is-active', '--quiet', unit, check=False, capture=True).returncode == 0
def enabled(unit):
return run('systemctl', 'is-enabled', '--quiet', unit, check=False, capture=True).returncode == 0
def restart_inputs():
run('systemctl', 'restart', 'shairport-sync', 'bluealsa-aplay')
def check_dac(pcm):
parts = dict(item.split("=", 1) for item in pcm.removeprefix("hw:").split(","))
if not (Path("/proc/asound") / parts["CARD"] / ("pcm" + parts["DEV"] + "p")).exists():
raise ValueError("DAC 출력 장치가 없습니다. aplay -l에서 카드 ID를 확인해 주세요.")
def apply(c, initial=False):
root()
files = templates(c)
if c["output"] == "dac":
check_dac(c["dac_pcm"])
if c['output'] == 'bluetooth':
# Preflight before changing a working DAC route.
run('modprobe', 'snd-aloop')
result = run('bluetoothctl', '--timeout', '10', 'connect',
c['bluetooth_device'], 'a2dp-sink', check=False, capture=True)
pcm_list = run('bluealsa-aplay', '-l', capture=True).stdout
playback = pcm_list.split('**** List of CAPTURE')[0]
if c['bluetooth_device'].upper() not in playback.upper():
raise ValueError('출력 장비의 A2DP 연결이 없습니다. 전원을 켜고 pair 명령을 먼저 실행해 주세요.\n' + result.stdout)
units = ['shairport-sync', 'bluealsa-aplay', BT_SERVICE, 'radxa-bose-output.service']
states = {u: (active(u), enabled(u)) for u in units}
backup = snapshot(files)
print('설정 백업:', backup, flush=True)
try:
stopping = [u for u in units if states[u][0]]
if stopping:
run('systemctl', 'stop', *stopping)
for path, text in files.items():
save(path, text)
run('systemctl', 'daemon-reload')
run('systemctl', 'disable', 'radxa-bose-output.service', check=False, capture=True)
if initial:
run('systemctl', 'enable', 'avahi-daemon', 'bluetooth', 'nqptp',
'shairport-sync', 'bluealsa', 'bluealsa-aplay')
run('systemctl', 'restart', 'bluetooth')
run('systemctl', 'start', 'avahi-daemon', 'nqptp')
run('systemctl', 'restart', 'bluealsa')
if c['output'] == 'bluetooth':
run('systemctl', 'enable', BT_SERVICE)
run('systemctl', 'start', BT_SERVICE)
else:
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':
required.append(BT_SERVICE)
if not all(active(u) for u in required):
raise ValueError('서비스 시작에 실패했습니다.')
alias = c['name'] + ' Bluetooth'
run('bluetoothctl', 'system-alias', alias)
print('출력:', c['output'], c.get('bluetooth_device', '') if c['output'] == 'bluetooth' else c['dac_pcm'])
except Exception:
run('systemctl', 'stop', *units, check=False)
restore_files(backup)
run('systemctl', 'daemon-reload', check=False)
for u, (was_active, was_enabled) in states.items():
run('systemctl', 'enable' if was_enabled else 'disable', u, check=False, capture=True)
if was_active:
run('systemctl', 'start', u, check=False)
print('설정을 이전 상태로 복구했습니다.', file=sys.stderr)
raise
def pair(addr, output=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:
# Outgoing pairing agent lives only for this operation.
controller = run('bluetoothctl', 'show', capture=True).stdout
was_pairable = 'Pairable: yes' in controller
run('bluetoothctl', 'pairable', 'on')
try:
run('bluetoothctl', '--agent', 'NoInputNoOutput', '--timeout', '25', 'pair', addr, check=False)
finally:
if not was_pairable:
run('bluetoothctl', 'pairable', 'off', check=False)
info = run('bluetoothctl', 'info', addr, capture=True).stdout
if 'Paired: 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)
if output:
c = load()
c['bluetooth_device'] = addr.upper()
c['output'] = 'bluetooth'
apply(c)
print('페어링/신뢰 등록 완료:', addr)
def main():
p = argparse.ArgumentParser(description='Radxa AirPlay + Bluetooth 믹서 관리')
sub = p.add_subparsers(dest='cmd', required=True)
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-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('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')
a = p.parse_args()
if a.cmd == 'devices':
run('bluetoothctl', 'devices'); run('aplay', '-l'); run('bluealsa-aplay', '-l')
elif a.cmd == 'status':
print(json.dumps(load(), ensure_ascii=False, indent=2), flush=True)
run('systemctl', 'is-active', 'shairport-sync', 'nqptp', 'bluealsa', 'bluealsa-aplay', BT_SERVICE, check=False)
run('bluealsa-aplay', '-l')
elif a.cmd == 'scan':
root()
if not 5 <= a.seconds <= 120: raise ValueError('검색 시간: 5~120초')
# Keep the discovery client alive; stop discovery on timeout/interrupt.
try:
run('bluetoothctl', '--timeout', str(a.seconds), 'scan', 'bredr', check=False, timeout=a.seconds + 10)
finally:
run('bluetoothctl', 'scan', 'off', check=False)
run('bluetoothctl', 'devices')
elif a.cmd == 'pair':
pair(a.address, a.output)
elif a.cmd == 'pair-input':
root()
if not 15 <= a.seconds <= 900: raise ValueError('페어링 시간: 15~900초')
run('bluetoothctl', 'discoverable-timeout', str(a.seconds))
# Interactive agent: approve the intended phone's service with "yes".
print('휴대폰에서 연결하세요. 서비스 승인 질문에는 yes를 입력하고, 완료 후 quit을 입력하세요.', flush=True)
try:
# Relay a PTY so service approval answers still come from the operator.
interactive_pair(a.seconds)
finally:
run('bluetoothctl', 'discoverable', 'off', check=False)
run('bluetoothctl', 'pairable', 'off', check=False)
print('서비스를 승인한 휴대폰은 sudo bluetoothctl trust 주소 로 신뢰 등록해 주세요.')
elif a.cmd == 'init':
apply(dict(name=a.name, dac_pcm=a.dac, output=a.output, bluetooth_device=a.address), initial=not a.adopt)
else:
c = load()
if a.cmd == 'output':
c['output'] = 'bluetooth' if a.mode in ('bluetooth', 'bose') else 'dac'
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)
def interactive_pair(seconds):
import pty
import select
import time
pid, fd = pty.fork()
if pid == 0:
os.execvp('bluetoothctl', ['bluetoothctl', '--agent', 'NoInputNoOutput'])
end = time.monotonic() + seconds
# Do not send asynchronous agent registration commands in a single batch.
queue = ['default-agent', 'pairable on', 'discoverable on']
next_command = time.monotonic() + 1.5
try:
while time.monotonic() < end:
ready, _, _ = select.select([fd, sys.stdin], [], [], 0.25)
if fd in ready:
try: data = os.read(fd, 8192)
except OSError: break
if not data: break
os.write(sys.stdout.fileno(), data)
if sys.stdin in ready:
data = os.read(sys.stdin.fileno(), 8192)
if not data: break
os.write(fd, data)
if queue and time.monotonic() >= next_command:
os.write(fd, (queue.pop(0) + '\n').encode())
next_command = time.monotonic() + 1.5
finally:
try: os.write(fd, b'quit\n')
except OSError: pass
os.close(fd)
os.waitpid(pid, 0)
if __name__ == '__main__':
try:
main()
except (ValueError, subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e:
print('오류:', e, file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
print('\n중단했습니다.', file=sys.stderr)
sys.exit(130)