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
+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)