mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-06 20:43:46 +08:00
149 lines
7.2 KiB
Python
149 lines
7.2 KiB
Python
"""A private PulseAudio server that carries Soloist's audio to one selected output.
|
|
|
|
AGNOS ships PulseAudio's client library but no server, and Soloist only plays through PulseAudio or
|
|
PipeWire. The matching Ubuntu 24.04 server (the same 16.1+dfsg1-2ubuntu10.1 build as the comma's own
|
|
libpulsecommon) is vendored in ./pulseaudio; see its README for provenance and licenses.
|
|
|
|
The server never becomes the system's audio: it runs with no default script, owns one sink, and is
|
|
reachable only through a socket in this feature's private run directory. The global ALSA default,
|
|
soundd and the warning speaker are untouched. Outputs:
|
|
|
|
- bluetooth: the comma's existing BlueALSA A2DP source, through a process-local ALSA configuration.
|
|
That approach is adapted from firestar5683's RoadScore Bluetooth output
|
|
(firestar5683/StarPilot@249eb88b3a397db847e4a595d7e23b7e85d329e5, roadscore/prototype/bluetooth_output.py).
|
|
- starport: a real-time pipe of 48 kHz stereo PCM that starportd sends on the car's media channel.
|
|
- test: a null sink, to check Spotify without a speaker.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from openpilot.starpilot.soloist import bluealsa_runtime
|
|
from openpilot.starpilot.soloist.paths import Layout, private_dir
|
|
from openpilot.starpilot.soloist.preferences import ADDRESS, Preferences
|
|
|
|
SERVER_ROOT = Path(__file__).resolve().parent / 'pulseaudio'
|
|
PCM_NAME = 'starpilot_spotify'
|
|
SINK_NAME = 'spotify'
|
|
SILENT_SINK = 'spotify_silent'
|
|
SYSTEM_ALSA_CONFIG = Path('/usr/share/alsa/alsa.conf')
|
|
# The Android Auto media channel format every head unit must offer; the pipe runs on the system clock,
|
|
# so it produces exactly real-time audio (silence when paused) whether or not anyone reads it.
|
|
PIPE_RATE, PIPE_CHANNELS, PIPE_BYTES_PER_FRAME = 48000, 2, 4
|
|
|
|
|
|
class BackendUnavailable(RuntimeError):
|
|
pass
|
|
|
|
|
|
def bundle_ready(root: Path = SERVER_ROOT) -> bool:
|
|
return os.access(root / 'bin' / 'pulseaudio', os.X_OK) and (root / 'modules').is_dir()
|
|
|
|
|
|
def can_switch(root: Path = SERVER_ROOT) -> bool:
|
|
return os.access(root / 'bin' / 'pactl', os.X_OK)
|
|
|
|
|
|
def alsa_config(address: str, base: Path = SYSTEM_ALSA_CONFIG) -> str:
|
|
"""Process-local ALSA config: the system configuration plus one named BlueALSA A2DP PCM."""
|
|
if not ADDRESS.fullmatch(address):
|
|
raise BackendUnavailable('Choose a Bluetooth speaker or car first')
|
|
if any(character in str(base) for character in '\n\r<>"'):
|
|
raise BackendUnavailable('Invalid system ALSA configuration path')
|
|
return (f'<{base}>\n' + bluealsa_runtime.alsa_plugins() +
|
|
f'\npcm.{PCM_NAME} {{\n type bluealsa\n device "{address}"\n profile "a2dp"\n volume "100+"\n softvol "true"\n' +
|
|
' hint { show on description "StarPilot Spotify output" }\n}\n')
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Launch:
|
|
argv: list[str]
|
|
env: dict[str, str]
|
|
|
|
|
|
def runtime_paths(layout: Layout) -> dict[str, Path]:
|
|
run = layout.runtime
|
|
return {'run': run, 'home': run / 'home', 'pulse': run / 'pulse', 'socket': run / 'pulse' / 'native', 'alsa': run / 'alsa.conf',
|
|
'starport': run / 'starport.pcm'}
|
|
|
|
|
|
def starport_pipe(layout: Layout | None = None) -> Path:
|
|
return runtime_paths(layout or Layout.default())['starport']
|
|
|
|
|
|
def _sink(output: str, paths: dict[str, Path]) -> tuple[str, str]:
|
|
if output == 'bluetooth':
|
|
return 'module-alsa-sink', f'device={PCM_NAME} sink_name={SINK_NAME} channels=2 tsched=0'
|
|
if output == 'starport':
|
|
return 'module-pipe-sink', (f'file={paths["starport"]} sink_name={SINK_NAME} format=s16le rate={PIPE_RATE} ' +
|
|
f'channels={PIPE_CHANNELS} use_system_clock_for_timing=yes')
|
|
return 'module-null-sink', f'sink_name={SILENT_SINK if output == "silent" else SINK_NAME}'
|
|
|
|
|
|
def server_launch(layout: Layout, prefs: Preferences, *, silent: bool = False, alsa_base: Path = SYSTEM_ALSA_CONFIG,
|
|
root: Path = SERVER_ROOT) -> Launch:
|
|
paths = runtime_paths(layout)
|
|
for key in ('run', 'home', 'pulse'):
|
|
private_dir(paths[key])
|
|
paths['socket'].unlink(missing_ok=True)
|
|
env = {
|
|
'HOME': str(paths['home']), 'XDG_RUNTIME_DIR': str(paths['run']), 'XDG_CONFIG_HOME': str(paths['home']),
|
|
'PULSE_RUNTIME_PATH': str(paths['pulse']), 'PULSE_STATE_PATH': str(paths['pulse']),
|
|
'LD_LIBRARY_PATH': str(root / 'lib'), 'PATH': '/usr/bin:/bin',
|
|
}
|
|
if prefs.output == 'bluetooth':
|
|
# Written even when starting silent, so the speaker can be added to the running server later.
|
|
paths['alsa'].write_text(alsa_config(prefs.output_address, alsa_base))
|
|
os.chmod(paths['alsa'], 0o600)
|
|
env['ALSA_CONFIG_PATH'] = str(paths['alsa'])
|
|
elif prefs.output == 'starport':
|
|
paths['starport'].unlink(missing_ok=True)
|
|
sink = ' '.join(_sink('silent' if silent else prefs.output, paths))
|
|
argv = [str(root / 'bin' / 'pulseaudio'), '--daemonize=no', '--system=no', '--use-pid-file=no', '-n',
|
|
'--exit-idle-time=-1', '--realtime=no', '--high-priority=no', '--disallow-exit', '--log-target=stderr',
|
|
'--log-level=notice', f'--dl-search-path={root / "modules"}',
|
|
'-L', f'module-native-protocol-unix socket={paths["socket"]} auth-anonymous=1', '-L', sink]
|
|
return Launch(argv, env)
|
|
|
|
|
|
def player_launch(layout: Layout, prefs: Preferences, api_key: str) -> Launch:
|
|
paths = runtime_paths(layout)
|
|
private_dir(layout.state)
|
|
private_dir(layout.cache)
|
|
# The key must be an argument: Soloist documents no file, fd or environment alternative.
|
|
argv = [str(layout.current / 'soloist'), '--device-name', prefs.device_name, '--api-key', api_key,
|
|
'--data-dir', str(layout.state), '--cache-dir', str(layout.cache), '--cache-size', str(prefs.cache_mb),
|
|
'--initial-volume', str(prefs.initial_volume), '--ws', '127.0.0.1:0']
|
|
env = {'HOME': str(paths['home']), 'XDG_RUNTIME_DIR': str(paths['run']), 'PATH': '/usr/bin:/bin',
|
|
'PULSE_SERVER': f'unix:{paths["socket"]}'}
|
|
return Launch(argv, env)
|
|
|
|
|
|
def _pactl(layout: Layout, root: Path, run: Callable[..., subprocess.CompletedProcess], *args: str) -> str:
|
|
paths = runtime_paths(layout)
|
|
env = {'HOME': str(paths['home']), 'XDG_RUNTIME_DIR': str(paths['run']), 'PATH': '/usr/bin:/bin',
|
|
'PULSE_SERVER': f'unix:{paths["socket"]}'}
|
|
return run([str(root / 'bin' / 'pactl'), *args], env=env, capture_output=True, text=True, timeout=5, check=True).stdout
|
|
|
|
|
|
def switch_output(layout: Layout, speaker: bool, *, root: Path = SERVER_ROOT,
|
|
run: Callable[..., subprocess.CompletedProcess] = subprocess.run) -> None:
|
|
"""Move Soloist between the speaker and the silent sink inside the running server, without restarting it."""
|
|
new, old = (SINK_NAME, SILENT_SINK) if speaker else (SILENT_SINK, SINK_NAME)
|
|
module, args = _sink('bluetooth' if speaker else 'silent', runtime_paths(layout))
|
|
|
|
def pactl(*args: str) -> str:
|
|
return _pactl(layout, root, run, *args)
|
|
pactl('load-module', module, *args.split())
|
|
pactl('set-default-sink', new)
|
|
for line in pactl('list', 'short', 'sink-inputs').splitlines():
|
|
if line.strip():
|
|
pactl('move-sink-input', line.split()[0], new)
|
|
for line in pactl('list', 'short', 'modules').splitlines():
|
|
if f'sink_name={old}' in line.split():
|
|
pactl('unload-module', line.split()[0])
|