From 5814e26a531c41c56bdcc99fd46b1edf5ffda735 Mon Sep 17 00:00:00 2001 From: Danny Date: Tue, 20 Oct 2026 11:44:29 -0700 Subject: [PATCH] Spotify: Advertise across different networks better, Swap Devices More Seemlessly --- .gitattributes | 1 + .../starpilot/galaxy/tests/test_spotify.mjs | 2 + openpilot/starpilot/galaxy/web/js/spotify.js | 20 +- openpilot/starpilot/soloist/README.md | 66 +- openpilot/starpilot/soloist/backend.py | 53 +- openpilot/starpilot/soloist/client.py | 22 +- .../soloist/pulseaudio/PACKAGES.sha512 | 1 + .../starpilot/soloist/pulseaudio/README.md | 6 +- .../starpilot/soloist/pulseaudio/SHA256SUMS | 1 + .../starpilot/soloist/pulseaudio/bin/pactl | Bin 0 -> 133304 bytes .../licenses/pulseaudio-utils.copyright | 605 ++++++++++++++++++ .../starpilot/soloist/pulseaudio/update.sh | 10 +- openpilot/starpilot/soloist/supervisor.py | 105 ++- .../starpilot/soloist/tests/test_connect.py | 200 ++++++ .../starpilot/soloist/tests/test_runtime.py | 68 +- 15 files changed, 1121 insertions(+), 39 deletions(-) create mode 100755 openpilot/starpilot/soloist/pulseaudio/bin/pactl create mode 100644 openpilot/starpilot/soloist/pulseaudio/licenses/pulseaudio-utils.copyright create mode 100644 openpilot/starpilot/soloist/tests/test_connect.py diff --git a/.gitattributes b/.gitattributes index bf941bbe75..211a32d9e8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,6 +15,7 @@ openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js -text *.apk -text openpilot/starpilot/galaxy/bin/hev-socks5-tunnel_linux_arm64 -text openpilot/starpilot/soloist/pulseaudio/bin/pulseaudio -text +openpilot/starpilot/soloist/pulseaudio/bin/pactl -text openpilot/selfdrive/car/tests/test_models_segs.txt -text openpilot/common/hardware/comma/updater -text diff --git a/openpilot/starpilot/galaxy/tests/test_spotify.mjs b/openpilot/starpilot/galaxy/tests/test_spotify.mjs index 373af12237..b458c415a1 100644 --- a/openpilot/starpilot/galaxy/tests/test_spotify.mjs +++ b/openpilot/starpilot/galaxy/tests/test_spotify.mjs @@ -38,6 +38,8 @@ assert.equal(serviceLabel({ enabled: false }), "Off") assert.equal(serviceLabel({ enabled: true, service: { state: "waiting_output", message: "Waiting for Car to connect over Bluetooth." } }), "Waiting for Car to connect over Bluetooth.") assert.match(serviceLabel({ enabled: true, service: { state: "waiting", message: "" } }), /pick this device/) +assert.match(serviceLabel({ enabled: true, service: { state: "waiting", message: "", outputWaiting: "Car" } }), + /pick this device.*Audio waits for Car\./) assert.equal(serviceLabel({ enabled: true, service: { state: "ready", player: { status: "paused" } } }), "Paused") assert.equal(positionNow({ ms: 1000, at: 10_000, speed: 1 }, 5000, 12_000), 3000) diff --git a/openpilot/starpilot/galaxy/web/js/spotify.js b/openpilot/starpilot/galaxy/web/js/spotify.js index f6c5b957b9..6c702e8537 100644 --- a/openpilot/starpilot/galaxy/web/js/spotify.js +++ b/openpilot/starpilot/galaxy/web/js/spotify.js @@ -99,7 +99,8 @@ export function serviceLabel(setup) { const service = setup.service if (!service) return "Starting…" if (service.state === "ready" && service.player?.status === "paused") return "Paused" - return service.message && !["ready", "playing", "waiting"].includes(service.state) ? service.message : STATE_TEXT[service.state] || "Starting…" + const text = service.message && !["ready", "playing", "waiting"].includes(service.state) ? service.message : STATE_TEXT[service.state] || "Starting…" + return service.outputWaiting ? `${text} Audio waits for ${service.outputWaiting}.` : text } // Where the track is now, from Soloist's anchor and the browser time elapsed since it. @@ -479,8 +480,11 @@ export const SpotifyPage = { - +
+ + Takes over from the phone or speaker Spotify is playing on now. Music plays through {{ outputName }}. You might need to confirm the switch in the Spotify app. +
@@ -571,7 +575,7 @@ export const SpotifyPage = {

{{ statusText }}

-

The first time, your phone has to be on the same Wi‑Fi or hotspot as your comma. After that, Spotify remembers it.

+

Pair once on the same Wi‑Fi or hotspot. After pairing, keep Spotify enabled and both devices online; your comma can use cellular or a phone hotspot. Use the same Spotify account on your phone.

@@ -601,11 +605,17 @@ export const SpotifyPage = {
Spotify doesn’t show my comma
    -
  1. Make sure step 4 says Ready to pair.
  2. +
  3. Keep Spotify enabled in step 4. A disconnected Bluetooth speaker can wait while you pair; music will be silent until it connects.
  4. Put your phone on the same Wi‑Fi or hotspot as your comma. Some hotspots and guest networks block devices from seeing each other.
  5. +
  6. For cellular or different networks after pairing, turn off Local device visibility in Spotify → Settings → Apps and devices. Both devices need internet access.
  7. +
  8. On iPhone, allow Spotify’s Local Network permission in iPhone Settings. If your phone’s hotspot blocks initial pairing, pair once on another shared Wi‑Fi network.
  9. +
  10. If it still does not appear, turn Spotify off and back on here. This reconnects using your saved account; you do not need to clear the account or cache.
  11. In Spotify, tap the device icon and look for {{ setup?.preferences.deviceName || 'StarPilot' }}.
+
Can I use the comma hotspot or wireless Android Auto? +

The comma hotspot can provide the local connection for pairing, but the comma also needs internet access to stream. A wireless Android Auto connection alone does not guarantee Spotify discovery or internet access. After pairing, Spotify can use the saved account over the comma’s cellular connection.

+
Does it use my data plan?

Yes, when your comma streams over cellular. Music usually uses about 40–150 MB an hour depending on quality. Songs you’ve played recently come from the cache instead.

diff --git a/openpilot/starpilot/soloist/README.md b/openpilot/starpilot/soloist/README.md index a5723c8b39..70c18e9939 100644 --- a/openpilot/starpilot/soloist/README.md +++ b/openpilot/starpilot/soloist/README.md @@ -4,7 +4,8 @@ Galaxy → Spotify and Phone Audio supports two mutually exclusive music sources Spotify Connect (Soloist) and a paired phone's Bluetooth A2DP audio. Both use the private PulseAudio output, either Android Auto's PCM media channel or an existing BlueALSA Bluetooth output. Phone mode needs neither Soloist nor a Spotify API key. -The feature's existing enable flag starts `spotifyd` for either source. +The feature's existing enable flag starts `spotifyd` for either source; with the phone source it +never launches Soloist, and switching to it stops a running Soloist. ## Phone setup @@ -82,3 +83,66 @@ artist and progress of Spotify or of the phone's Bluetooth player, with a backgr supervisor publishes the snapshot in its status file; the car screen reads it once a second only while the card is enabled, and the card stays hidden when nothing is playing or an alert is up. Phone metadata comes from the phone's Bluetooth media player (BlueZ `MediaPlayer1`), so it depends on the phone sharing it. + +## Spotify Connect on hotspots and cellular + +Enable Spotify and pair once from Spotify's device picker on a shared local network +(home Wi-Fi, a phone hotspot, or the comma's Galaxy hotspot). Both local discovery +and device-to-device traffic must be allowed. A hotspot-hosting phone may restrict +this traffic; if initial pairing fails, use another shared Wi-Fi network. Wireless +Android Auto by itself is not a promise of local discovery or an internet uplink. + +Soloist keeps its device identity and Connect session in `state/` across restarts, +output changes, network handoffs and cache clearing. After pairing, keep both devices +online and use the same Spotify account. On the phone, disable Spotify's **Local +device visibility** under Settings → Apps and devices to allow devices on other +networks. On iPhone also grant Spotify **Local Network** access for local pairing. +The comma needs its own usable internet connection (cellular or tethered internet +is fine); the local Galaxy hotspot alone does not supply one. + +The supervisor has no Wi-Fi, unmetered-network or ignition requirement, and it never +restarts Soloist because the network changed. Soloist follows network changes itself: +it binds one mDNS socket on `0.0.0.0:5353` and answers on interfaces that appear after +it starts, because the device's avahi-daemon joins the mDNS group on every interface. +Checked on the device 2026-10-20 with network namespaces: a running Soloist answered +`_spotify-connect._tcp` queries on a link created after launch within ten seconds. + +A disconnected selected Bluetooth speaker no longer prevents Soloist from starting. +It uses a private silent sink so the device can be paired and remain online; Galaxy +still shows the pairing state and adds that audio is waiting for the speaker. The +supervisor checks the selected speaker every twenty seconds in the background, so that +periodic check never blocks Galaxy commands. After two checks in a row agree that availability +changed, it uses the vendored `pactl` to load the speaker (or silent) sink into the running audio +server and move Soloist's stream onto it, so Soloist keeps its session, any pairing in progress, +and playback. Soloist plays to the server's default sink rather than a named one, so later streams +follow too. Only if `pactl` is missing or the switch fails does it restart the audio/player with +the saved session. +It never takes over playback from another Spotify device automatically. + +The local API is polled for authentication state every fifteen seconds. A response +missing for forty-five seconds reconnects that socket; two minutes without a working +API restarts Soloist without clearing the account. A build that never answers at all +counts as a failed start, so repeated silence still rolls back to the previous build. +Loss of a previously observed login gets the same two-minute grace period, or ten +minutes if it was playing just before (cached music can outlast a coverage gap); an +initial pairing can wait indefinitely. A live API/login is not proof of internet reachability. If discovery still +fails, switch Spotify off/on in Galaxy to refresh it without resetting the account. + +These behaviors cannot override phone hotspot isolation, Spotify app permissions, +cloud availability, expired Soloist builds, or a sleeping/powered-off comma. +The optional control WebSocket stays on loopback; exposing it or the Galaxy tunnel +is not how Spotify Connect discovers a device. + +References checked 2026-10-20: +- https://developer.spotify.com/documentation/soloist/concepts/authentication +- https://developer.spotify.com/documentation/soloist/tutorials/getting-started +- https://developer.spotify.com/documentation/soloist/reference/websocket-api +- https://support.spotify.com/us/article/spotify-connect/ + +Hardware acceptance (not covered by unit tests): pair from Android and iPhone on +home Wi-Fi and each phone's hotspot; pair through the Galaxy hotspot with a separate +comma internet uplink; move a paired comma from Wi-Fi to cellular and IPv6 tethering; +control it from a phone on a different network with Local device visibility off; +restart the comma and confirm the saved account returns; connect/disconnect the +selected Bluetooth speaker; verify availability after a tunnel/coverage outage. +Confirm another Spotify player is never activated or interrupted by background recovery. diff --git a/openpilot/starpilot/soloist/backend.py b/openpilot/starpilot/soloist/backend.py index c1317a68a9..f1bb7253f4 100644 --- a/openpilot/starpilot/soloist/backend.py +++ b/openpilot/starpilot/soloist/backend.py @@ -17,6 +17,8 @@ soundd and the warning speaker are untouched. Outputs: from __future__ import annotations import os +import subprocess +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path @@ -26,6 +28,7 @@ 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. @@ -40,6 +43,10 @@ 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): @@ -66,7 +73,17 @@ def android_auto_pipe(layout: Layout | None = None) -> Path: return runtime_paths(layout or Layout.default())['android_auto'] -def server_launch(layout: Layout, prefs: Preferences, *, alsa_base: Path = SYSTEM_ALSA_CONFIG, root: Path = SERVER_ROOT) -> Launch: +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 == 'android_auto': + return 'module-pipe-sink', (f'file={paths["android_auto"]} 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]) @@ -77,16 +94,13 @@ def server_launch(layout: Layout, prefs: Preferences, *, alsa_base: Path = SYSTE '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']) - sink = f'module-alsa-sink device={PCM_NAME} sink_name={SINK_NAME} channels=2 tsched=0' elif prefs.output == 'android_auto': paths['android_auto'].unlink(missing_ok=True) - sink = (f'module-pipe-sink file={paths["android_auto"]} sink_name={SINK_NAME} format=s16le rate={PIPE_RATE} ' + - f'channels={PIPE_CHANNELS} use_system_clock_for_timing=yes') - else: - sink = f'module-null-sink sink_name={SINK_NAME}' + 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"}', @@ -103,5 +117,30 @@ def player_launch(layout: Layout, prefs: Preferences, api_key: str) -> Launch: '--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"]}', 'PULSE_SINK': SINK_NAME} + '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]) diff --git a/openpilot/starpilot/soloist/client.py b/openpilot/starpilot/soloist/client.py index c9facd7bbd..a60c7effde 100644 --- a/openpilot/starpilot/soloist/client.py +++ b/openpilot/starpilot/soloist/client.py @@ -20,6 +20,8 @@ ARTWORK_HOSTS = ('i.scdn.co', 'mosaic.scdn.co', 'image-cdn-ak.spotifycdn.com', ' STATUSES = ('idle', 'playing', 'paused', 'buffering') REPEATS = ('off', 'context', 'track') ACTIONS = ('play', 'pause', 'skip_next', 'skip_prev', 'seek', 'shuffle', 'set_repeat') +AUTH_POLL_SECONDS = 15.0 +AUTH_TIMEOUT_SECONDS = 45.0 def _text(value: Any) -> str: @@ -161,8 +163,10 @@ def _connect(url: str): class PlayerClient: - def __init__(self, data_dir: Path, *, connect: Callable[[str], Any] = _connect, on_change: Callable[[], None] = lambda: None): + def __init__(self, data_dir: Path, *, connect: Callable[[str], Any] = _connect, on_change: Callable[[], None] = lambda: None, + clock: Callable[[], float] = time.monotonic): self.data_dir, self.connect, self.on_change = data_dir, connect, on_change + self.clock = clock self.state = PlayerState() self.lock = threading.Lock() self.outbox: queue.Queue[dict] = queue.Queue(maxsize=16) @@ -227,12 +231,20 @@ class PlayerClient: connection.settimeout(0.25) with self.lock: self.state.reset() - self.state.value['connected'] = True while not self.outbox.empty(): # never replay commands from before a reconnect self.outbox.get_nowait() connection.send(json.dumps({'type': 'command', 'command': 'get_auth_state'})) + pending_auth = self.clock() + next_auth = pending_auth + AUTH_POLL_SECONDS asked_state = False while not self.stop_event.is_set(): + now = self.clock() + if pending_auth is not None and now - pending_auth >= AUTH_TIMEOUT_SECONDS: + raise TimeoutError('Soloist did not answer its local health check') + if pending_auth is None and now >= next_auth: + connection.send(json.dumps({'type': 'command', 'command': 'get_auth_state'})) + pending_auth = now + next_auth = now + AUTH_POLL_SECONDS try: while True: connection.send(json.dumps(self.outbox.get_nowait())) @@ -254,10 +266,16 @@ class PlayerClient: continue if not isinstance(message, dict): continue + if message.get('type') == 'auth_state': + pending_auth = None with self.lock: self.state.apply(message) + if message.get('type') == 'auth_state': + self.state.value['connected'] = True logged_in = self.state.value['loggedIn'] if logged_in and not asked_state: asked_state = True connection.send(json.dumps({'type': 'command', 'command': 'get_state'})) + elif not logged_in: + asked_state = False self.on_change() diff --git a/openpilot/starpilot/soloist/pulseaudio/PACKAGES.sha512 b/openpilot/starpilot/soloist/pulseaudio/PACKAGES.sha512 index a268b37a1e..ecd51b95e5 100644 --- a/openpilot/starpilot/soloist/pulseaudio/PACKAGES.sha512 +++ b/openpilot/starpilot/soloist/pulseaudio/PACKAGES.sha512 @@ -3,4 +3,5 @@ cdb454b2f42c494dd19e9d097f0f6c3d57befe8e75c1a40c436959c219c6cf2885841f621dfe4050 95e377f3c58f78d65a2e3bf151d6d47d9b6c66dc9a0810babbcd0a47d3a02f16d4522110f8ea92946ef35d004029dc19859a1c61192683377c233d9c5c608740 libsoxr0_0.1.3-4build3_arm64.deb 422d97994b50973e5c4a2b07f7dede00ce15cb9a58fddb6d3e2d530686bb9cc01ee971de5a7b66a4c3ada15f735e6b4405fe22738e230b099bfdf1deb2ab0f24 libspeexdsp1_1.2.1-1ubuntu3_arm64.deb 68345e041b86ca08a6b3be7a8f55abcda1a18537bc15b1f83be781638e53117380c93e52af506ba5aa2b5fc425f4d3dde623b86c3419d9ee3686512e81b8334c libtdb1_1.4.10-1build1_arm64.deb +b871ab5448683f6bb57f898b857fa17a0a1f13b9011b7a0e73dc6f6fc22e47f90880eef99f2f9f94bccc24c8aa2b2f9c9d4d90658e0a4eb446e6964276b98f01 pulseaudio-utils_1%3a16.1+dfsg1-2ubuntu10.1_arm64.deb 7fb6688c36b2d1de13d2f6ac0e45487ee61850fab983a0ee2112630d8e98c9f185ff246ae6e7d753a511bcea36936d48b7954ecdfdf040c91f8cf1661256d72b pulseaudio_1%3a16.1+dfsg1-2ubuntu10.1_arm64.deb diff --git a/openpilot/starpilot/soloist/pulseaudio/README.md b/openpilot/starpilot/soloist/pulseaudio/README.md index 9b5d97970c..a66d3ca5a8 100644 --- a/openpilot/starpilot/soloist/pulseaudio/README.md +++ b/openpilot/starpilot/soloist/pulseaudio/README.md @@ -2,10 +2,12 @@ Spotify Soloist plays only through PulseAudio or PipeWire, and AGNOS ships PulseAudio's client library without a server. This directory is the minimal server needed to carry Soloist's audio to one output: -the `pulseaudio` daemon, its private libraries, and four modules (native protocol, ALSA sink for +the `pulseaudio` daemon, `pactl` (to switch between the speaker and the silent sink without +restarting Soloist), their private libraries, and four modules (native protocol, ALSA sink for BlueALSA, null sink, and the real-time pipe sink used for Android Auto). -- Source: Ubuntu 24.04 (noble) arm64 binary packages, `pulseaudio 1:16.1+dfsg1-2ubuntu10.1` and the +- Source: Ubuntu 24.04 (noble) arm64 binary packages, `pulseaudio` and `pulseaudio-utils` + `1:16.1+dfsg1-2ubuntu10.1`, and the libraries listed in `PACKAGES.sha512` (SHA-512 of each `.deb` as published by Ubuntu). - The comma's own `libpulse0`/`libpulsecommon-16.1.so` is byte-identical to that Ubuntu build, which is why only these files are needed. Regenerate with `./update.sh`; `SHA256SUMS` lists the extracted files. diff --git a/openpilot/starpilot/soloist/pulseaudio/SHA256SUMS b/openpilot/starpilot/soloist/pulseaudio/SHA256SUMS index 18163fbae9..8d688439d2 100644 --- a/openpilot/starpilot/soloist/pulseaudio/SHA256SUMS +++ b/openpilot/starpilot/soloist/pulseaudio/SHA256SUMS @@ -1,3 +1,4 @@ +b3b6955f8437125be03061b575757b255da00f0ac21d5079ee83257bac54326e bin/pactl c08fc5b6e751bb83af5fc65c052a317d88ed8d506c2ca53a813bfc0a22703c78 bin/pulseaudio b51278275776288a97699c06f0d377adba603b5e83edac1c7e9bf1891bc41e3e lib/libalsa-util.so 0ad17dfc2ef3f6e322934da1cec6193bfd44ba89c75d77d1e409973cf7f11401 lib/libltdl.so.7 diff --git a/openpilot/starpilot/soloist/pulseaudio/bin/pactl b/openpilot/starpilot/soloist/pulseaudio/bin/pactl new file mode 100755 index 0000000000000000000000000000000000000000..d8bb3255577ee8e24456d92c68f22ce76345888d GIT binary patch literal 133304 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 +Files-Excluded: src/modules/rtp/rfc* + +Files: * +Copyright: + Copyright (C) 2004-2009 Lennart Poettering + Copyright (C) 2006-2007 Pierre Ossman for Cendio AB +License: LGPL-2.1+ + The upstream license clarifies pretty well that the sources of pulseaudio are + LGPL (please see LGPL license grant below), but that some parts will be + effectively GPL since they rely on GPL libraries, quoting the upstream + LICENSE: + . + """All PulseAudio source files are licensed under the GNU Lesser General + Public License. (see file LGPL for details) + . + However, the server side links to the GPL-only library 'libsamplerate' + which practically downgrades the license of the server part to GPL (see + file GPL for details), exercising section 3 of the LGPL. + . + Hence you should treat the client library ('libpulse') of PulseAudio as + being LGPL licensed and the server part ('libpulsecore') as being GPL + licensed. Since the PulseAudio daemon and the modules link to + 'libpulsecore' they are of course also GPL licensed. + . + -- Lennart Poettering, April 20th, 2006.""" + . + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/pulsecore/g711.c +Copyright: + Copyright (C) Sun Microsystems, Inc +License: other + This source code is a product of Sun Microsystems, Inc. and is provided + for unrestricted use. Users may copy or modify this source code without + charge. + . + SUN SOURCE CODE IS PROVIDED AS IS WITH NO WARRANTIES OF ANY KIND INCLUDING + THE WARRANTIES OF DESIGN, MERCHANTIBILITY AND FITNESS FOR A PARTICULAR + PURPOSE, OR ARISING FROM A COURSE OF DEALING, USAGE OR TRADE PRACTICE. + . + Sun source code is provided with no support and without any obligation on + the part of Sun Microsystems, Inc. to assist in its use, correction, + modification or enhancement. + . + SUN MICROSYSTEMS, INC. SHALL HAVE NO LIABILITY WITH RESPECT TO THE + INFRINGEMENT OF COPYRIGHTS, TRADE SECRETS OR ANY PATENTS BY THIS SOFTWARE + OR ANY PART THEREOF. + . + In no event will Sun Microsystems, Inc. be liable for any lost revenue + or profits or other special, indirect and consequential damages, even if + Sun has been advised of the possibility of such damages. + +Files: src/pulsecore/g711.h +Copyright: + Copyright (C) 2001 Chris Bagwell +License: other + Permission to use, copy, modify, and distribute this software and its + documentation for any purpose and without fee is hereby granted, provided + that the above copyright notice appear in all copies and that both that + copyright notice and this permission notice appear in supporting + documentation. This software is provided "as is" without express or + implied warranty. + +Files: src/pulsecore/poll.* +Copyright: + Copyright 2006 Pierre Ossman for Cendio AB + Copyright (C)1994,96,97,98,99,2000,2001,2004 Free Software Foundation, Inc. +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/pulse/utf8.c +Copyright: + Copyright (C) 1999 Tom Tromey + Copyright (C) 2000 Red Hat, Inc. +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/bluetooth/ipc.* src/modules/bluetooth/rtp.* +Copyright: + Copyright (C) 2004-2009 Marcel Holtmann +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/bluetooth/module-bluetooth-device.c + src/modules/bluetooth/module-bluetooth-discover.c + src/modules/bluetooth/bluetooth-util.* +Copyright: + Copyright (C) 2008-2009 Joao Paulo Rechi Vita +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2. + +Files: src/modules/bluetooth/proximity-helper.c +Copyright: + Copyright (C) 2000-2001 Qualcomm Incorporated + Copyright (C) 2002-2003 Maxim Krasnyansky + Copyright (C) 2002-2007 Marcel Holtmann +License: GPL-2+ + On Debian systems, the complete text of the GPL-2 can be found in + /usr/share/common-licenses/GPL-2. + +Files: src/modules/bluetooth/sbc* +Copyright: + Copyright (C) 2004-2009 Marcel Holtmann + Copyright (C) 2004-2005 Henryk Ploetz + Copyright (C) 2005-2006 Brad Midgley +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/dbus/iface-client.c + src/modules/dbus/iface-stream.c: +Copyright: + Copyright 2009 Tanu Kaskinen + Copyright 2009 Vincent Filali-Ansary +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/dbus/module-dbus-protocol.c +Copyright: + Copyright 2009 Tanu Kaskinen + Copyright 2006 Lennart Poettering + Copyright 2006 Shams E. King +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/dbus/* +Copyright: + Copyright 2009 Tanu Kaskinen +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/echo-cancel/adrian*.* +Copyright: + Copyright (C) DFS Deutsche Flugsicherung (2004). All Rights Reserved. +License: other + You are allowed to use this source code in any open source or closed + source software you want. You are allowed to use the algorithms for a + hardware solution. You are allowed to modify the source code. + You are not allowed to remove the name of the author from this memo or + from the source code files. You are not allowed to monopolize the + source code or the algorithms behind the source code as your + intellectual property. This source code is free of royalty and comes + with no warranty. + +Files: src/modules/echo-cancel/echo-cancel.h +Copyright: + Copyright 2010 Arun Raghavan +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/echo-cancel/module-echo-cancel.c +Copyright: + Copyright 2010 Wim Taymans +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/echo-cancel/speex.c +Copyright: + Copyright 2010 Wim Taymans + Contributor: Arun Raghavan +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/jack/module-jackdbus-detect.c +Copyright: + Written by David Henningsson + Copyright 2010 Canonical Ltd. +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/module-device-manager.* +Copyright: + Copyright 2006-2008 Lennart Poettering + Copyright (C) 2009 Colin Guthrie +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/module-equalizer-sink.c +Copyright: + Copyright 2004-2008 Lennart Poettering + Copyright 2009 Jason Newton +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/pulsecore/dbus-*.* src/modules/module-hal-detect.c +Copyright: + Copyright 2006 Lennart Poettering + Copyright 2006 Shams E. King +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/ladspa.h +Copyright: + Copyright (C) 2000-2002 Richard W.E. Furse, Paul Barton-Davis, Stefan + Westerfeld. +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/module-always-sink.c src/modules/rtp/rtsp_client.* +Copyright: + Copyright (C) 2008 Colin Guthrie +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/module-detect.c +Copyright: + Copyright 2006 Lennart Poettering + Copyright 2006 Pierre Ossman for Cendio AB + Copyright 2006 Diego Petteno +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/roap/*roap* src/modules/rtp/headerlist.* +Copyright: + Copyright 2005-2007 Lennart Poettering + Copyright (C) 2008 Colin Guthrie +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/roap/base64.* +Copyright: + Copyright (C) 2008 Colin Guthrie + Copyright (C) Kungliga Tekniska Hogskolan +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/module-device-manager.c +Copyright: + Copyright 2006-2008 Lennart Poettering + Copyright 2009 Colin Guthrie +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/module-filter-apply*.c + src/modules/module-filter-heuristics*.c +Copyright: + Copyright 2011 Colin Guthrie +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/module-solaris.c +Copyright: + Copyright 2006 Lennart Poettering + Copyright 2006-2007 Pierre Ossman for Cendio AB + Copyright 2009 Finn Thain +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/module-switch-on-connect.c +Copyright: + Copyright 2006 Lennart Poettering + Copyright 2009 Canonical Ltd +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/module-virtual-sink.c + src/modules/module-virtual-source.c +Copyright: + Copyright 2010 Intel Corporation + Contributor: Pierre-Louis Bossart +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/modules/reserve.* +Copyright: + Copyright 2009 (C) Lennart Poettering +License: other + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation files + (the "Software"), to deal in the Software without restriction, + including without limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the Software, + and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + . + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + +Files: src/pulsecore/atomic.h +Copyright: + Copyright (C) 2006-2008 Lennart Poettering + Copyright (C) 2008 Nokia Corporation +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/pulsecore/core-util.c +Copyright: + Copyright (C) 2004-2006 Lennart Poettering + Copyright (C) 2004 Joe Marcus Clarke + Copyright (C) 2006-2007 Pierre Ossman for Cendio AB +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/pulse/ext-device-manager.* +Copyright: + Copyright (C) 2008 Lennart Poettering + Copyright (C) 2009 Colin Guthrie +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/pulsecore/ffmpeg/avcodec.h +Copyright: + Copyright (c) 2001 Fabrice Bellard +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/pulsecore/ffmpeg/resample2.c +Copyright: + Copyright (c) 2004 Michael Niedermayer +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/pulsecore/socket-util.c +Copyright: + Copyright (C) 2004-2006 Lennart Poettering + Copyright (C) 2004 Joe Marcus Clarke + Copyright (C) 2006-2007 Pierre Ossman for Cendio AB +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: man/xmltoman +Copyright: + Copyright (C) 2000-2002 Oliver Kurth + Copyright (C) 2003 Lennart Poettering +License: LGPL-2+ + On Debian systems, the complete text of the LGPL-2 can be found in + /usr/share/common-licenses/LGPL-2. + +Files: po/ca.po +Copyright: + Copyright (C) 2008 Xavier Conde Rueda + Copyright (C) 2009 Agustí Grau , 2009. + Copyright (C) Judith Pintó Subirada + Copyright (C) 2009 Josep Torné Llavall +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/cs.po +Copyright: + Copyright (C) 2008,2009 Petr Kovar +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/de.po +Copyright: + Copyright (C) 2008,2009 Fabian Affolter + Copyright (C) 2008,2009 Micha Pietsch +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/de_CH.po +Copyright: + Copyright (C) 2008, 2009 Fabian Affolter + Copyright (C) 2008, 2009 Micha Pietsch +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/el.po +Copyright: + Copyright (C) 2008 Dimitris Glezos + Copyright (C) 2009 Thalia Papoutsaki +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/es.po +Copyright: + Copyright (C) 2009 Domingo Becker + Copyright (C) 2008 Hector Daniel Cabrera + Copyright (C) 2009 Fernando Gonzalez Blanco +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/fi.po +Copyright: + Copyright (C) 2009 Timo Jyrinki + Copyright (C) 2009 Ville-Pekka Vainio +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/fr.po +Copyright: + Copyright (C) 2008 Robert-Andre Mauchin + Copyright (C) 2008 Michael Ughetto + Copyright (C) 2008 Pablo Martin-Gomez + Copyright (C) 2009 Corentin Perard +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/gu.po +Copyright: + Copyright (C) 2009 Sweta Kothari +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/hi.po +Copyright: + Copyright (C) 2009 Rajesh Ranjan +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/it.po +Copyright: + Copyright (C) 2008,2009 Luca Ferretti + Copyright (C) 2009 Milo Casagrande + Copyright (C) 2009 ario_santagiuliana + Copyright (C) 2009 Milo Casagrande +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/ja.po +Copyright: + Copyright (C) 2009 Hyu_gabaru Ryu_ichi +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/kn.po +Copyright: + Copyright (C) 2009 Shankar Prasad +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/mr.po +Copyright: + Copyright (C) 2009 Sandeep Shedmake + Copyright (C) 2009 Sandeep Shedmake +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/nl.po +Copyright: + Copyright (C) 2009 Geert Warrink + Copyright (C) 2009 Reinout van Schouwen +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/or.po +Copyright: + Copyright (C) 2009 Manoj Kumar Giri +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/pa.po +Copyright: + Copyright (C) 2009 Amanpreet Singh Alam + Copyright (C) 2009 Jaswinder Singh + Copyright (C) 2009 A S Alam +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/pl.po +Copyright: + Copyright (C) 2008 Piotr Drag +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/pt_BR.po +Copyright: + Copyright (C) 2008 Fabian Affolter +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/sr.po po/sr@latin.po +Copyright: + Copyright (C) 2009 Igor Miletic (Игор Милетић) , 2009. + Copyright (C) 2009 Miloš Komarčević , 2009. +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/sv.po +Copyright: + Copyright (C) 2008 Daniel Nylander +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/ta.po +Copyright: + Copyright (C) 2009 I. Felix +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/te.po +Copyright: + Copyright (C) 2009 Krishna Babu K +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/uk.po +Copyright: + Copyright (C) 2009 Yuri Chornoivan +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: po/zh_CN.po +Copyright: + Copyright (C) 2008 闫丰刚 (sainry) +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: debian/* +Copyright: + Copyright 2006-2009 Sjoerd Simons + Copyright 2006-2008 CJ van den Berg +License: GPL-2+ + On Debian systems, the complete text of the GPL-2 can be found in + /usr/share/common-licenses/GPL-2. + +Files: src/pulsecore/cpu-* src/pulsecore/remap* + src/pulsecore/svolume_mmx.c + src/pulsecore/svolume_arm.c + src/pulsecore/svolume_sse.c +Copyright: + Copyright 2004-2006 Lennart Poettering + Copyright 2009 Wim Taymans +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/pulsecore/sconv_sse.c +Copyright: + Copyright 2004-2006 Lennart Poettering + Copyright 2006 Pierre Ossman for Cendio AB +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/pulsecore/usergroup.* +Copyright: + Copyright 2009 Ted Percival +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. + +Files: src/utils/qpaeq +Copyright: 2009 Jason Newton +License: LGPL-2.1+ + On Debian systems, the complete text of the LGPL-2.1 can be found in + /usr/share/common-licenses/LGPL-2.1. diff --git a/openpilot/starpilot/soloist/pulseaudio/update.sh b/openpilot/starpilot/soloist/pulseaudio/update.sh index 811c59813f..3e2372543d 100755 --- a/openpilot/starpilot/soloist/pulseaudio/update.sh +++ b/openpilot/starpilot/soloist/pulseaudio/update.sh @@ -8,16 +8,16 @@ docker run --rm --platform linux/arm64 -v "$PWD:/out" ubuntu:24.04 bash -euc ' apt-get -qq update >/dev/null DEBIAN_FRONTEND=noninteractive apt-get -qq install -y --no-install-recommends libpulse0 libasound2t64 libsndfile1 libwrap0 \ libx11-xcb1 libgomp1 libasyncns0 libsystemd0 libudev1 libdbus-1-3 >/dev/null - cd /tmp && apt-get download pulseaudio libltdl7 liborc-0.4-0t64 libsoxr0 libspeexdsp1 libtdb1 >/dev/null + cd /tmp && apt-get download pulseaudio pulseaudio-utils libltdl7 liborc-0.4-0t64 libsoxr0 libspeexdsp1 libtdb1 >/dev/null sha512sum *.deb > /out/PACKAGES.sha512 for deb in *.deb; do dpkg-deb -x "$deb" root; done R=/tmp/root; M=$R/usr/lib/pulse-16.1+dfsg1/modules rm -rf /out/bin /out/lib /out/modules /out/licenses && mkdir -p /out/bin /out/lib /out/modules /out/licenses - cp $R/usr/bin/pulseaudio /out/bin/ + cp $R/usr/bin/pulseaudio $R/usr/bin/pactl /out/bin/ for m in module-native-protocol-unix module-alsa-sink module-null-sink module-pipe-sink; do cp $M/$m.so /out/modules/; done export LD_LIBRARY_PATH=$R/usr/lib/aarch64-linux-gnu:$R/usr/lib/aarch64-linux-gnu/pulseaudio:$M - for f in /out/bin/pulseaudio /out/modules/*.so; do ldd "$f"; done | awk "/=>/{print \$3}" | sort -u | grep "^$R" | + for f in /out/bin/* /out/modules/*.so; do ldd "$f"; done | awk "/=>/{print \$3}" | sort -u | grep "^$R" | while read -r lib; do cp -L "$lib" /out/lib/; done - for p in pulseaudio libltdl7 liborc-0.4-0t64 libsoxr0 libspeexdsp1 libtdb1; do cp $R/usr/share/doc/$p/copyright /out/licenses/$p.copyright; done - chmod 755 /out/bin/pulseaudio && chmod 644 /out/lib/* /out/modules/* /out/licenses/* + for p in pulseaudio pulseaudio-utils libltdl7 liborc-0.4-0t64 libsoxr0 libspeexdsp1 libtdb1; do cp -L $R/usr/share/doc/$p/copyright /out/licenses/$p.copyright; done + chmod 755 /out/bin/* && chmod 644 /out/lib/* /out/modules/* /out/licenses/* cd /out && sha256sum bin/* lib/* modules/* > SHA256SUMS' diff --git a/openpilot/starpilot/soloist/supervisor.py b/openpilot/starpilot/soloist/supervisor.py index 69750acf1c..239157fd1f 100644 --- a/openpilot/starpilot/soloist/supervisor.py +++ b/openpilot/starpilot/soloist/supervisor.py @@ -31,6 +31,8 @@ STOP_SECONDS = 5.0 OUTPUT_RETRY_SECONDS = 20.0 PAIRING_PROFILE_SECONDS = 180.0 # the pairing window plus time for the phone to save the comma's services MAX_FAST_FAILURES = 5 +UNRESPONSIVE_SECONDS = 120.0 +PLAYING_GRACE_SECONDS = 600.0 LOG_LINES = 200 EXPIRES_IN = re.compile(r'client expires in (\d+) days?') @@ -128,6 +130,20 @@ class Supervisor: self.phone_track = None self.phone_profile = False self.pairing_profile_until = 0.0 + self.output_available = True + self.output_check_at = 0.0 + self.output_lock = threading.Lock() + self.output_seen: tuple[int, bool] | None = None + self.output_generation = 0 + self.output_checking = False + self.output_flips = 0 + self.waiting_for = '' + self.run_async: Callable[[Callable[[], None]], None] = lambda fn: threading.Thread( + target=fn, name='spotify_output_check', daemon=True).start() + self.unresponsive_since: float | None = None + self.authenticated_once = False + self.answered = False + self.last_playing = False # ---- status --------------------------------------------------------------------------------------- def _set(self, state: str, message: str = '') -> None: @@ -146,7 +162,7 @@ class Supervisor: with self.log_lock: log = list(self.log)[-20:] return {'state': state, 'message': self.message, 'daysLeft': self.days_left, 'player': player, - 'nowPlaying': now_playing, 'log': log} + 'nowPlaying': now_playing, 'outputWaiting': self.waiting_for if self.player is not None else '', 'log': log} def publish(self) -> None: try: @@ -177,6 +193,9 @@ class Supervisor: return process def stop_children(self, *, release_phone: bool = True) -> None: + self.unresponsive_since = None + self.output_generation += 1 + self.output_seen, self.output_flips, self.waiting_for = None, 0, '' if self.client is not None: self.client.stop() self.client = None @@ -190,6 +209,45 @@ class Supervisor: if self.clock() >= self.pairing_profile_until: self._release_profile() + def _output_ready(self, address: str) -> bool: + with self.output_lock: + return self.output.ready(address) + + def _output_changed(self, now: float, address: str) -> bool: + seen, self.output_seen = self.output_seen, None + if seen is not None and seen[0] == self.output_generation: + self.output_flips = self.output_flips + 1 if seen[1] != self.output_available else 0 + if self.output_flips >= 2: + return True + if now >= self.output_check_at and not self.output_checking: + self.output_check_at = now + OUTPUT_RETRY_SECONDS + self.output_checking = True + generation = self.output_generation + + def check() -> None: + try: + self.output_seen = (generation, self._output_ready(address)) + finally: + self.output_checking = False + self.run_async(check) + return False + + def _switch_output(self, prefs: preferences.Preferences) -> bool: + """Move audio to or from the speaker inside the running server, so Soloist keeps its session.""" + speaker = not self.output_available + if not backend.can_switch(): + return False + try: + backend.switch_output(self.layout, speaker) + except (OSError, subprocess.SubprocessError) as error: + with self.log_lock: + self.log.append(f'audio: could not switch output: {error}') + return False + self.output_available, self.output_flips = speaker, 0 + self.waiting_for = '' if speaker else prefs.output_name or 'your Bluetooth speaker' + self.publish() + return True + def _release_profile(self) -> None: if self.phone_profile: phone_audio.profile('release') @@ -225,7 +283,7 @@ class Supervisor: self._set('waiting_phone', f'Waiting for {prefs.phone_name or "your phone"}. Select the comma as its Bluetooth audio output.') self.retry_at = now + 5 return - if prefs.output == 'bluetooth' and not self.output.ready(prefs.output_address): + if prefs.output == 'bluetooth' and not self._output_ready(prefs.output_address): self.stop_children(release_phone=False) self._set('waiting_output', f'Waiting for {prefs.output_name or "your speaker"} to connect.') self.retry_at = now + OUTPUT_RETRY_SECONDS @@ -295,19 +353,23 @@ class Supervisor: if self.release_target != target: self.release_target, self.fast_failures, self.retry_at = target, 0, 0.0 if self.player is not None: + if prefs.output == 'bluetooth' and self._output_changed(now, prefs.output_address): + if not self._switch_output(prefs): + self.stop_children() + self.retry_at = 0.0 + return self._watch(now, target) return if now < self.retry_at: return - if prefs.output == 'bluetooth' and not self.output.ready(prefs.output_address): - name = prefs.output_name or 'your Bluetooth speaker' - self._set('waiting_output', f'Waiting for {name} to connect over Bluetooth.') - self.retry_at = now + OUTPUT_RETRY_SECONDS - return + self.output_available = prefs.output != 'bluetooth' or self._output_ready(prefs.output_address) + self.output_check_at = now + OUTPUT_RETRY_SECONDS + self.waiting_for = '' if self.output_available else prefs.output_name or 'your Bluetooth speaker' + # A private null sink keeps Connect available until the speaker returns; never the warning speaker. self.secret = key self._set('starting', 'Starting Spotify…') try: - self.server = self._start(backend.server_launch(self.layout, prefs), 'audio') + self.server = self._start(backend.server_launch(self.layout, prefs, silent=not self.output_available), 'audio') deadline = now + 5.0 socket_path = backend.runtime_paths(self.layout)['socket'] while not socket_path.exists() and self.server.poll() is None and self.clock() < deadline: @@ -319,7 +381,7 @@ class Supervisor: self.stop_children() self._fail(now, f'Could not start Spotify: {error}') return - self.launched, self.started_at = launch_key, now + self.launched, self.started_at, self.answered, self.last_playing = launch_key, now, False, False self.client = self.make_client(self.layout.state) self.client.on_change = self.publish self.client.start() @@ -329,7 +391,29 @@ class Supervisor: code = self.player.poll() if self.player is not None else None server_dead = self.server is not None and self.server.poll() is not None if code is None and not server_dead: - if now - self.started_at > 60: + snapshot = self.client.snapshot() if self.client is not None else {} + connected = bool(snapshot.get('connected')) + self.answered = self.answered or connected + if snapshot.get('loggedIn'): + self.authenticated_once = True + self.last_playing = bool(snapshot.get('active') and snapshot.get('status') in ('playing', 'buffering')) + lost_login = connected and self.authenticated_once and not snapshot.get('loggedIn') + # Logout clears the playback state, so cached music from before a coverage gap gets longer. + limit = PLAYING_GRACE_SECONDS if lost_login and self.last_playing else UNRESPONSIVE_SECONDS + if self.client is not None and (not connected or lost_login): + if self.unresponsive_since is None: + self.unresponsive_since = now + elif now - self.unresponsive_since >= limit: + self.stop_children() + if not self.answered: + self._fail(now, 'Spotify didn’t respond. Retrying…') + return + self.retry_at = now + 10 + self._set('retrying', 'Reconnecting Spotify with your saved account…') + return + else: + self.unresponsive_since = None + if self.answered and now - self.started_at > 60: self.fast_failures = 0 return self.stop_children() @@ -393,6 +477,7 @@ class Supervisor: if command == 'reset_session': with self.lock: self.stop_children() + self.authenticated_once = False shutil.rmtree(self.layout.state, ignore_errors=True) shutil.rmtree(self.layout.cache, ignore_errors=True) return {'ok': True} diff --git a/openpilot/starpilot/soloist/tests/test_connect.py b/openpilot/starpilot/soloist/tests/test_connect.py new file mode 100644 index 0000000000..79c09e5aa8 --- /dev/null +++ b/openpilot/starpilot/soloist/tests/test_connect.py @@ -0,0 +1,200 @@ +import json + +import pytest + +from openpilot.starpilot.soloist import client, supervisor +from openpilot.starpilot.soloist.tests.test_runtime import make_supervisor, ready_layout +from openpilot.starpilot.soloist.paths import Layout + + +def test_local_api_failure_recovers_without_rollback_or_erasing_login(tmp_path, monkeypatch): + layout = Layout(tmp_path / 'soloist') + ready_layout(layout, monkeypatch) + monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None) + spawned = [] + sup, now = make_supervisor(layout, spawned) + sup.tick() + sup.tick() # the default fake answered once + (layout.state / 'account').write_text('saved') + sup.client.snapshot = lambda: {'connected': False} + sup.tick() + now[0] += 121 + sup.tick() + assert sup.player is None and sup.fast_failures == 0 + now[0] += 11 + sup.tick() + assert len(spawned) == 4 + assert (layout.state / 'account').read_text() == 'saved' + + +def test_auth_heartbeat_refreshes_state_after_relogin(tmp_path): + now = [0.0] + player = client.PlayerClient(tmp_path, clock=lambda: now[0]) + events = [True, False, True] + class Connection: + sent = [] + def settimeout(self, _timeout): pass + def send(self, message): self.sent.append(json.loads(message)['command']) + def recv(self): + if not events: + player.stop_event.set() + return '' + now[0] += 16 + return json.dumps({'type': 'auth_state', 'logged_in': events.pop(0)}) + connection = Connection() + player._session(connection) + assert connection.sent.count('get_auth_state') >= 3 + assert connection.sent.count('get_state') == 2 + + +def test_unresponsive_socket_is_not_a_healthy_player(tmp_path): + now = [0.0] + player = client.PlayerClient(tmp_path, clock=lambda: now[0]) + class Connection: + def settimeout(self, _timeout): pass + def send(self, _message): pass + def recv(self): + now[0] += 16 + raise TimeoutError() + with pytest.raises(TimeoutError, match='health check'): + player._session(Connection()) + assert not player.snapshot()['connected'] + + +def test_lost_login_recovers_but_first_pairing_waits_without_restart(tmp_path, monkeypatch): + layout = Layout(tmp_path / 'soloist') + ready_layout(layout, monkeypatch) + monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None) + spawned = [] + sup, now = make_supervisor(layout, spawned) + sup.tick() + for _ in range(5): + now[0] += 200 + sup.tick() + assert len(spawned) == 2 # unlimited time for first pairing + sup.client.snapshot = lambda: {'connected': True, 'loggedIn': True} + sup.tick() + sup.client.snapshot = lambda: {'connected': True, 'loggedIn': False} + sup.tick() + now[0] += 121 + sup.tick() + assert sup.player is None + now[0] += 11 + sup.tick() + assert len(spawned) == 4 + sup.handle({'command': 'reset_session'}) + assert not sup.authenticated_once + + +def test_lost_login_while_playing_gets_a_longer_grace(tmp_path, monkeypatch): + layout = Layout(tmp_path / 'soloist') + ready_layout(layout, monkeypatch) + monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None) + spawned = [] + sup, now = make_supervisor(layout, spawned) + sup.tick() + state = client.PlayerState() + state.apply({'type': 'auth_state', 'logged_in': True, 'is_active': True}) + state.apply({'type': 'playback_changed', 'status': 'playing'}) + state.value['connected'] = True + sup.client.snapshot = lambda: dict(state.value) + sup.tick() + state.apply({'type': 'auth_state', 'logged_in': False}) # the real client clears playback on logout + for _ in range(5): + now[0] += 100 + sup.tick() + assert sup.player is not None and len(spawned) == 2 + now[0] += supervisor.PLAYING_GRACE_SECONDS + sup.tick() + assert sup.player is None + + +def test_silent_build_counts_toward_rollback(tmp_path, monkeypatch): + layout = Layout(tmp_path / 'soloist') + ready_layout(layout, monkeypatch) + monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None) + rolled = [] + monkeypatch.setattr(supervisor.package, 'rollback', lambda _l: rolled.append(True) or True) + spawned = [] + sup, now = make_supervisor(layout, spawned) + def silent(): return {'connected': False} + for _ in range(supervisor.MAX_FAST_FAILURES): + sup.tick() + sup.client.snapshot = silent + sup.tick() + now[0] += supervisor.UNRESPONSIVE_SECONDS + 1 + sup.tick() + assert sup.player is None + now[0] += 120 + assert rolled == [True] + + +def test_speaker_check_runs_off_the_tick(tmp_path, monkeypatch): + layout = Layout(tmp_path / 'soloist') + ready_layout(layout, monkeypatch) + supervisor.preferences.save(supervisor.preferences.Preferences(output_address='AA:BB:CC:DD:EE:FF', output_name='Car'), layout) + monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None) + class Output: + calls = 0 + def ready(self, _address): + self.calls += 1 + return self.calls % 2 == 0 # flaps on every check + spawned, queued = [], [] + sup, now = make_supervisor(layout, spawned) + sup.output = Output() + sup.tick() + sup.run_async = queued.append + for _ in range(6): + now[0] += supervisor.OUTPUT_RETRY_SECONDS + 1 + sup.tick() + assert len(queued) <= 1 # the tick only queued the blocking check + if queued: + queued.pop()() + assert len(spawned) == 2 + + +def test_phone_source_never_runs_soloist(tmp_path, monkeypatch): + layout = Layout(tmp_path / 'soloist') + ready_layout(layout, monkeypatch) + monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None) + spawned = [] + sup, now = make_supervisor(layout, spawned) + sup.tick() + client = sup.client + assert any('soloist' in process.argv[0] for process in spawned) + supervisor.preferences.save(supervisor.preferences.Preferences(source='phone', output='test'), layout) + spawned.clear() + for _ in range(5): + now[0] += 60 + sup.tick() + assert client.stopped and sup.client is None + assert not any('soloist' in process.argv[0] for process in spawned) + assert sup.status()['state'] == 'no_phone' + + +def test_phone_page_never_shows_the_spotify_speaker(tmp_path, monkeypatch): + layout = Layout(tmp_path / 'soloist') + ready_layout(layout, monkeypatch) + monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None) + monkeypatch.setattr(supervisor.phone_audio, 'profile', lambda _action: None) + supervisor.preferences.save(supervisor.preferences.Preferences(output_address='AA:BB:CC:DD:EE:FF', output_name='Car'), layout) + class Output: + connected = False + def ready(self, _address): return self.connected + class Phone: + def powered(self): return True + def ready(self, _address, _now): return True + def track(self, _address): return None + def close(self): pass + spawned = [] + sup, now = make_supervisor(layout, spawned) + sup.output, sup.phone = Output(), Phone() + sup.tick() + assert sup.status()['outputWaiting'] == 'Car' + sup.output.connected = True + supervisor.preferences.save(supervisor.preferences.Preferences(source='phone', phone_address='11:22:33:44:55:66', + output_address='AA:BB:CC:DD:EE:FF', output_name='Car'), layout) + now[0] += 1 + sup.tick() + assert sup.player is not None and 'soloist' not in sup.player.argv[0] + assert sup.status()['outputWaiting'] == '' diff --git a/openpilot/starpilot/soloist/tests/test_runtime.py b/openpilot/starpilot/soloist/tests/test_runtime.py index 323c91a8fe..4dda16e195 100644 --- a/openpilot/starpilot/soloist/tests/test_runtime.py +++ b/openpilot/starpilot/soloist/tests/test_runtime.py @@ -1,4 +1,5 @@ import os +import subprocess from pathlib import Path import pytest @@ -65,7 +66,10 @@ def test_launch_commands_isolate_the_server_and_player(layout): assert (layout.runtime / 'alsa.conf').stat().st_mode & 0o777 == 0o600 test = backend.server_launch(layout, preferences.Preferences(output='test')) assert any(arg.startswith('module-null-sink') for arg in test.argv) and 'ALSA_CONFIG_PATH' not in test.env + silent = backend.server_launch(layout, prefs, silent=True, alsa_base=Path('/usr/share/alsa/alsa.conf')) + assert 'module-null-sink sink_name=spotify_silent' in silent.argv and 'ALSA_CONFIG_PATH' in silent.env player = backend.player_launch(layout, prefs, 'sk_live_0123456789abcdef') + assert 'PULSE_SINK' not in player.env # follows the server's default sink, so outputs can switch live assert player.argv[player.argv.index('--ws') + 1] == '127.0.0.1:0' assert player.argv[player.argv.index('--cache-size') + 1] == '256' assert player.env['PULSE_SERVER'] == f'unix:{layout.runtime}/pulse/native' @@ -179,6 +183,7 @@ def make_supervisor(layout, spawned): return process now = [100.0] sup = supervisor.Supervisor(layout, spawn=spawn, clock=lambda: now[0], make_client=FakeClient) # ty: ignore[invalid-argument-type] # test double + sup.run_async = lambda fn: fn() return sup, now @@ -247,25 +252,74 @@ def test_supervisor_backs_off_and_rolls_back_a_failing_build(layout, monkeypatch assert rolled == [True] -def test_supervisor_waits_for_the_selected_bluetooth_output(layout, monkeypatch): +def _speaker_supervisor(layout, monkeypatch, *, can_switch): ready_layout(layout, monkeypatch) preferences.save(preferences.Preferences(output_address='AA:BB:CC:DD:EE:FF', output_name='Car'), layout) class Output: connected = False def ready(self, address): return self.connected - output = Output() + switched = [] + monkeypatch.setattr(supervisor.backend, 'can_switch', lambda: can_switch) + monkeypatch.setattr(supervisor.backend, 'switch_output', lambda _layout, speaker: switched.append(speaker)) + monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None) spawned = [] sup, now = make_supervisor(layout, spawned) - sup.output = output + sup.output = Output() sup.tick() - assert sup.status()['state'] == 'waiting_output' and 'Car' in sup.status()['message'] and spawned == [] - output.connected = True - now[0] += supervisor.OUTPUT_RETRY_SECONDS + 1 + assert sup.status()['state'] == 'waiting' and sup.status()['outputWaiting'] == 'Car' + assert len(spawned) == 2 and sup.client.started + assert 'module-null-sink sink_name=spotify_silent' in spawned[0].argv + sup.output.connected = True + for _ in range(2): # one check is not enough; the speaker has to stay connected + now[0] += supervisor.OUTPUT_RETRY_SECONDS + 1 + sup.tick() + assert len(spawned) == 2 and switched == [] sup.tick() - assert len(spawned) == 2 + return sup, now, spawned, switched +def test_speaker_returning_switches_audio_without_restarting_soloist(layout, monkeypatch): + sup, now, spawned, switched = _speaker_supervisor(layout, monkeypatch, can_switch=True) + client = sup.client + assert switched == [True] and len(spawned) == 2 and sup.client is client and not client.stopped + assert sup.status()['outputWaiting'] == '' + sup.output.connected = False + for _ in range(3): + now[0] += supervisor.OUTPUT_RETRY_SECONDS + 1 + sup.tick() + assert switched == [True, False] and len(spawned) == 2 + assert sup.status()['outputWaiting'] == 'Car' + + +def test_speaker_switch_falls_back_to_a_restart(layout, monkeypatch): + sup, _now, spawned, switched = _speaker_supervisor(layout, monkeypatch, can_switch=False) + (layout.state / 'saved-session').write_text('retained') + sup.tick() + assert switched == [] and len(spawned) == 4 + assert any(arg.startswith('module-alsa-sink') for arg in spawned[2].argv) + assert (layout.state / 'saved-session').read_text() == 'retained' + assert sup.status()['outputWaiting'] == '' + + +def test_switch_output_moves_streams_and_drops_the_old_sink(layout): + calls = [] + outputs = {('list', 'short', 'sink-inputs'): '7\t1\t3\tprotocol-native.c\ts16le 2ch 44100Hz\n', + ('list', 'short', 'modules'): ('0\tmodule-native-protocol-unix\tsocket=/x\n' + + '1\tmodule-null-sink\tsink_name=spotify_silent\n' + + '2\tmodule-alsa-sink\tdevice=starpilot_spotify sink_name=spotify channels=2 tsched=0\n')} + def run(argv, **kwargs): + calls.append(tuple(argv[1:])) + assert kwargs['env']['PULSE_SERVER'].startswith('unix:') and kwargs['timeout'] and kwargs['check'] + return subprocess.CompletedProcess(argv, 0, outputs.get(tuple(argv[1:]), '')) + backend.switch_output(layout, True, run=run) + assert calls == [('load-module', 'module-alsa-sink', 'device=starpilot_spotify', 'sink_name=spotify', 'channels=2', 'tsched=0'), + ('set-default-sink', 'spotify'), ('list', 'short', 'sink-inputs'), ('move-sink-input', '7', 'spotify'), + ('list', 'short', 'modules'), ('unload-module', '1')] + calls.clear() + backend.switch_output(layout, False, run=run) + assert calls[0] == ('load-module', 'module-null-sink', 'sink_name=spotify_silent') and calls[-1] == ('unload-module', '2') + def test_control_socket_round_trip(layout, tmp_path): import threading from openpilot.starpilot.galaxy.spotify_setup import SpotifyClient