From d8534a0eb1f107bb938d80a9eae5b1b83dbca2eb Mon Sep 17 00:00:00 2001 From: Danny Date: Tue, 10 Nov 2026 12:58:26 -0800 Subject: [PATCH] Spotify: Audio Routing And Resampling Quality --- openpilot/starpilot/bluetooth/owner.py | 3 +- .../starpilot/bluetooth/tests/test_owner.py | 10 ++++++ openpilot/starpilot/galaxy/spotify_setup.py | 3 +- .../starpilot/galaxy/tests/test_spotify.mjs | 14 ++++++++ .../galaxy/tests/test_spotify_setup.py | 4 +-- openpilot/starpilot/galaxy/web/js/spotify.js | 33 +++++++++++-------- openpilot/starpilot/soloist/backend.py | 9 ++--- openpilot/starpilot/soloist/supervisor.py | 2 +- .../starpilot/soloist/tests/test_runtime.py | 4 +++ openpilot/starpilot/spot_monitor/runtime.py | 1 + 10 files changed, 61 insertions(+), 22 deletions(-) diff --git a/openpilot/starpilot/bluetooth/owner.py b/openpilot/starpilot/bluetooth/owner.py index 22616ee532..f69b39ecc4 100644 --- a/openpilot/starpilot/bluetooth/owner.py +++ b/openpilot/starpilot/bluetooth/owner.py @@ -253,7 +253,8 @@ class BlueZ: f"Bluetooth Device · {address[-5:]}" if relevant else address) devices.append({'path': path, 'address': address, 'name': str(name)[:80], 'paired': props.get('Paired') is True, 'connected': props.get('Connected') is True, - 'trusted': props.get('Trusted') is True}) + 'trusted': props.get('Trusted') is True, + 'audioSink': '0000110b-0000-1000-8000-00805f9b34fb' in profiles}) return devices def snapshot(self) -> dict: diff --git a/openpilot/starpilot/bluetooth/tests/test_owner.py b/openpilot/starpilot/bluetooth/tests/test_owner.py index 89a9771601..21529218fa 100644 --- a/openpilot/starpilot/bluetooth/tests/test_owner.py +++ b/openpilot/starpilot/bluetooth/tests/test_owner.py @@ -855,3 +855,13 @@ class BluetoothHeadUnitDiscoveryTest(unittest.TestCase): self.assertEqual({d['address'] for d in found}, {f'11:22:33:44:55:{i:02X}' for i in range(5)}) self.assertTrue(all(d['name'] for d in found)) self.assertTrue(all(not d['paired'] for d in found)) + + +class BluetoothAudioOutputTest(unittest.TestCase): + def test_only_audio_sink_profile_can_receive_music(self): + profiles = ['0000110B-0000-1000-8000-00805F9B34FB', '0000110a-0000-1000-8000-00805f9b34fb', + '4de17a00-52cb-11e6-bdf4-0800200c9a66', None] + objects = {f'/org/bluez/hci0/dev_{i}': {'org.bluez.Device1': { + 'Address': f'11:22:33:44:55:0{i}', 'Paired': True, 'UUIDs': [profile] if profile else [], + }} for i, profile in enumerate(profiles)} + self.assertEqual([device['audioSink'] for device in BlueZ._devices(objects)], [True, False, False, False]) diff --git a/openpilot/starpilot/galaxy/spotify_setup.py b/openpilot/starpilot/galaxy/spotify_setup.py index aee132a8cd..168288e2c8 100644 --- a/openpilot/starpilot/galaxy/spotify_setup.py +++ b/openpilot/starpilot/galaxy/spotify_setup.py @@ -90,7 +90,8 @@ class SpotifySetup: self._require_session(session) prefs = preferences.load(self.layout) try: - devices = [{'address': d['address'], 'name': d['name'], 'connected': d.get('connected') is True} + devices = [{'address': d['address'], 'name': d['name'], 'connected': d.get('connected') is True, + 'audioSink': d.get('audioSink') is True} for d in self.paired_devices(session) if d.get('paired')] except Exception: devices = [] diff --git a/openpilot/starpilot/galaxy/tests/test_spotify.mjs b/openpilot/starpilot/galaxy/tests/test_spotify.mjs index 5d48c2e04c..af12c1f505 100644 --- a/openpilot/starpilot/galaxy/tests/test_spotify.mjs +++ b/openpilot/starpilot/galaxy/tests/test_spotify.mjs @@ -144,3 +144,17 @@ assert.doesNotMatch(SpotifyPage.template, /Pause smoothing[^>]*parked/) } assert.equal(serviceLabel({ enabled: true, service: { state: "ready", player: { status: "paused" }, outputWaiting: "Car" } }), "Paused Audio waits for Car.") + +// Audio outputs require the receiving-music profile; the phone source list stays separate. +{ + const devices = [ + { address: "AA:BB:CC:DD:EE:01", name: "Car", connected: true, audioSink: true }, + { address: "AA:BB:CC:DD:EE:02", name: "Speaker", connected: false, audioSink: true }, + { address: "AA:BB:CC:DD:EE:03", name: "Phone", connected: true, audioSink: false }, + { address: "AA:BB:CC:DD:EE:04", name: "Radar detector", connected: true }, + ] + const deviceChoices = SpotifyPage.computed.deviceChoices.call({ setup: { devices } }) + const outputs = SpotifyPage.computed.outputChoices.call({ deviceChoices }) + assert.deepEqual(outputs.map(choice => choice.label), ["Car", "Speaker (Not Connected)"]) + assert.equal(deviceChoices.length, 4) +} diff --git a/openpilot/starpilot/galaxy/tests/test_spotify_setup.py b/openpilot/starpilot/galaxy/tests/test_spotify_setup.py index 6d257ac9f2..aeda176465 100644 --- a/openpilot/starpilot/galaxy/tests/test_spotify_setup.py +++ b/openpilot/starpilot/galaxy/tests/test_spotify_setup.py @@ -13,7 +13,7 @@ from openpilot.starpilot.soloist import credentials, preferences from openpilot.starpilot.soloist.paths import Layout SESSION = ('token', 1) -DEVICES = [{'address': 'AA:BB:CC:DD:EE:FF', 'name': 'My Car', 'paired': True, 'connected': True}, +DEVICES = [{'address': 'AA:BB:CC:DD:EE:FF', 'name': 'My Car', 'paired': True, 'connected': True, 'audioSink': True}, {'address': '11:22:33:44:55:66', 'name': 'Nearby', 'paired': False, 'connected': False}] @@ -58,7 +58,7 @@ def test_status_never_contains_the_key(owner): setup.set_key(SESSION, 'sk_live_0123456789abcdef') status = setup.status(SESSION) assert status['keyPresent'] is True and 'sk_live' not in json.dumps(status) - assert status['devices'] == [{'address': 'AA:BB:CC:DD:EE:FF', 'name': 'My Car', 'connected': True}] + assert status['devices'] == [{'address': 'AA:BB:CC:DD:EE:FF', 'name': 'My Car', 'connected': True, 'audioSink': True}] assert status['links']['download'].startswith('https://developer.spotify.com/') with pytest.raises(SetupRejected): setup.set_key(SESSION, 'bad key') diff --git a/openpilot/starpilot/galaxy/web/js/spotify.js b/openpilot/starpilot/galaxy/web/js/spotify.js index 063519870f..6f4ed70aac 100644 --- a/openpilot/starpilot/galaxy/web/js/spotify.js +++ b/openpilot/starpilot/galaxy/web/js/spotify.js @@ -346,8 +346,11 @@ export const SpotifyPage = { keyValid() { return /^[A-Za-z0-9._~+/=-]{16,256}$/.test(this.keyValue.trim()) }, nameValid() { return /^[^\x00-\x1f\x7f]{1,40}$/.test(this.nameValue.trim()) }, outputChoices() { + return this.deviceChoices.filter((choice) => choice.audioSink) + }, + deviceChoices() { const devices = this.setup?.devices || [] - return devices.map((device) => ({ value: device.address, label: device.name + (device.connected ? "" : " (Not Connected)") })) + return devices.map((device) => ({ value: device.address, audioSink: device.audioSink === true, label: device.name + (device.connected ? "" : " (Not Connected)") })) }, }, methods: { @@ -441,12 +444,13 @@ export const SpotifyPage = { +

Spotify streams on the comma. Your phone controls playback in Spotify; the comma sends the music to the audio output you choose below.

Put the car in Park or use offroad mode to change the phone. @@ -562,21 +568,22 @@ export const SpotifyPage = {
  • -

    Where Music Plays

    Through Android Auto, or your car or a speaker over Bluetooth. Alerts keep playing from the comma itself.

    +

    Where Music Plays

    Send music from the comma to your car speakers through StarPort (Android Auto), or to a Bluetooth car stereo or speaker. Alerts keep playing from the comma itself.

    -
  • @@ -638,7 +645,7 @@ export const SpotifyPage = {

    Music plays through your car or speaker; alerts keep playing from the comma’s own speaker. Keep the music at a level where you can still hear the comma.

    Can I use it with Android Auto? -

    Yes. In step 3 choose Android Auto, and music plays through the car as Android Auto audio alongside the StarPilot screen. It needs Android Auto to be connected, so if it isn’t, music has nowhere to play.

    +

    Yes. Under Send audio to, choose Car speakers via StarPort (Android Auto), and music plays through the car as Android Auto audio alongside the StarPilot screen. It needs Android Auto to be connected, so if it isn’t, music has nowhere to play.

    More help from Spotify

    Spotify’s Soloist getting-started guide

    diff --git a/openpilot/starpilot/soloist/backend.py b/openpilot/starpilot/soloist/backend.py index 176dee47ae..b6a4839c4e 100644 --- a/openpilot/starpilot/soloist/backend.py +++ b/openpilot/starpilot/soloist/backend.py @@ -73,7 +73,7 @@ class Launch: 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'} + 'starport': run / 'starport.pcm', 'daemon': run / 'daemon.conf'} def starport_pipe(layout: Layout | None = None) -> Path: @@ -98,8 +98,9 @@ def server_launch(layout: Layout, prefs: Preferences, *, silent: bool = False, a 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': library_path(root), 'PATH': '/usr/bin:/bin', + 'LD_LIBRARY_PATH': library_path(root), 'PATH': '/usr/bin:/bin', 'PULSE_CONFIG': str(paths['daemon']), } + paths['daemon'].write_text('nice-level = -5\n') 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)) @@ -109,8 +110,8 @@ def server_launch(layout: Layout, prefs: Preferences, *, silent: bool = False, a 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"}', + '--exit-idle-time=-1', '--realtime=no', '--high-priority=yes', '--disallow-exit', '--log-target=stderr', + '--log-level=notice', '--resample-method=speex-float-3', f'--dl-search-path={root / "modules"}', '-L', f'module-native-protocol-unix socket={paths["socket"]} auth-anonymous=1', '-L', sink] return Launch(argv, env) diff --git a/openpilot/starpilot/soloist/supervisor.py b/openpilot/starpilot/soloist/supervisor.py index c1d3226d40..5dce632ef4 100644 --- a/openpilot/starpilot/soloist/supervisor.py +++ b/openpilot/starpilot/soloist/supervisor.py @@ -602,7 +602,7 @@ def parked() -> bool: def main() -> None: - os.nice(10) # music yields to everything driving-related; children inherit this + os.nice(5) # music yields to driving-related work; the audio server sets its own priority supervisor = Supervisor() reap_orphans(supervisor.layout) legacy_profile = Path('/run/systemd/system/bluealsa.service.d/90-starpilot-phone.conf') diff --git a/openpilot/starpilot/soloist/tests/test_runtime.py b/openpilot/starpilot/soloist/tests/test_runtime.py index fca5934f54..b001ba797a 100644 --- a/openpilot/starpilot/soloist/tests/test_runtime.py +++ b/openpilot/starpilot/soloist/tests/test_runtime.py @@ -61,6 +61,10 @@ def test_launch_commands_isolate_the_server_and_player(layout): prefs = preferences.Preferences(output_address='AA:BB:CC:DD:EE:FF', initial_volume=60) server = backend.server_launch(layout, prefs, alsa_base=Path('/usr/share/alsa/alsa.conf')) assert '-n' in server.argv and '--system=no' in server.argv + assert '--realtime=no' in server.argv and '--high-priority=yes' in server.argv + assert '--resample-method=speex-float-3' in server.argv + assert (layout.runtime / 'daemon.conf').read_text() == 'nice-level = -5\n' + assert server.env['PULSE_CONFIG'] == str(layout.runtime / 'daemon.conf') assert any(arg.startswith('module-alsa-sink device=starpilot_spotify') for arg in server.argv) assert server.env['ALSA_CONFIG_PATH'].startswith(str(layout.runtime)) assert (layout.runtime / 'alsa.conf').stat().st_mode & 0o777 == 0o600 diff --git a/openpilot/starpilot/spot_monitor/runtime.py b/openpilot/starpilot/spot_monitor/runtime.py index 9a8f9ec8ac..e3f55e1fc8 100644 --- a/openpilot/starpilot/spot_monitor/runtime.py +++ b/openpilot/starpilot/spot_monitor/runtime.py @@ -374,6 +374,7 @@ class VASMRuntime: def main() -> None: + os.nice(max(0, 10 - os.getpriority(os.PRIO_PROCESS, 0))) from openpilot.cereal import messaging from openpilot.cereal.visionipc import VisionStreamType from msgq.visionipc import VisionIpcClient