From 359cb9fda62f40d3781b0a8bf63b764378df45f8 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:26:19 -0600 Subject: [PATCH] Add offroad alert volume previews to Galaxy --- openpilot/starpilot/audio/preview.py | 114 ++++++++++++++++++ .../starpilot/audio/tests/test_preview.py | 98 +++++++++++++++ openpilot/starpilot/galaxy/server.py | 13 +- openpilot/starpilot/galaxy/settings.py | 3 +- .../galaxy/tests/test_settings_controls.mjs | 23 ++++ openpilot/starpilot/galaxy/web/css/galaxy.css | 9 ++ .../galaxy/web/js/galaxy-setting-row.js | 21 +++- openpilot/starpilot/galaxy/web/js/settings.js | 21 +++- prebuilt.json | 2 +- 9 files changed, 290 insertions(+), 14 deletions(-) create mode 100644 openpilot/starpilot/audio/preview.py create mode 100644 openpilot/starpilot/audio/tests/test_preview.py diff --git a/openpilot/starpilot/audio/preview.py b/openpilot/starpilot/audio/preview.py new file mode 100644 index 0000000000..e0762f6274 --- /dev/null +++ b/openpilot/starpilot/audio/preview.py @@ -0,0 +1,114 @@ +"""Bounded, offroad-only previews through the normal sound owner.""" + +import fcntl +import os +from pathlib import Path +import stat +import subprocess +import sys +import tempfile +import threading +import time + +from openpilot.starpilot.audio.alert_volume import SPECS + +ALERTS = { + 'WarningImmediateVolume': 'warningImmediate', 'WarningSoftVolume': 'warningSoft', + 'RefuseVolume': 'refuse', 'PromptDistractedVolume': 'promptDistracted', + 'EngageVolume': 'engage', 'DisengageVolume': 'disengage', 'PromptVolume': 'prompt', +} + + +class AlertPreviewError(Exception): + def __init__(self, message, status=409): + super().__init__(message) + self.status = status + + +class AlertPreview: + def __init__(self, parked, runner=subprocess.run): + self.parked, self.runner = parked, runner + self.lock = threading.Lock() + + def play(self, payload): + if type(payload) is not dict or set(payload) != {'key'} or type(payload['key']) is not str or payload['key'] not in SPECS: + raise AlertPreviewError('Choose an alert volume to preview.', 400) + if not self.parked(): + raise AlertPreviewError('Put the comma offroad before testing an alert.') + if not self.lock.acquire(blocking=False): + raise AlertPreviewError('An alert preview is already playing.') + try: + result = self.runner([sys.executable, '-m', 'openpilot.starpilot.audio.preview', payload['key']], + cwd=Path(__file__).resolve().parents[3], stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=8) + if result.returncode == 2: + raise AlertPreviewError('Preview stopped. Keep the comma offroad and close Driver Camera.') + if result.returncode: + raise AlertPreviewError('The comma could not play this alert. Try again.', 503) + return {'played': True} + except subprocess.TimeoutExpired as error: + raise AlertPreviewError('The preview timed out. Try again.', 503) from error + finally: + self.lock.release() + + +def play_saved_alert(key, *, params, context, sound, audio, alert, now=time.monotonic, sleep=time.sleep): + if key not in ALERTS or not context.offroad() or params.get_bool('IsDriverViewEnabled'): + raise AlertPreviewError('Offroad audio is unavailable.') + context.evidence_wait_ms = 0 + sound.refresh_saved_volumes(now()) + sound.update_alert(alert) + samples = sound.loaded_sounds[alert] + duration = 2.0 if ALERTS[key] in ('warningSoft', 'warningImmediate', 'promptDistracted') else min(3.0, len(samples) / 48000) + if not context.offroad(): + raise AlertPreviewError('Offroad audio is unavailable.') + stream = sound.get_stream(audio) + if not context.offroad() or params.get_bool('IsDriverViewEnabled'): + stream.close() + raise AlertPreviewError('Preview stopped before playback.') + with stream: + deadline = now() + duration + while now() < deadline: + if not context.offroad() or params.get_bool('IsDriverViewEnabled'): + raise AlertPreviewError('Preview stopped.') + sound.update_critical_sound(now()) + if sound.native_audio is not None: + sound.native_audio.publish() + sleep(.05) + + +def main(): + if len(sys.argv) != 2 or sys.argv[1] not in ALERTS: + return 2 + from openpilot.common.params import Params + from openpilot.starpilot.galaxy.settings import LiveContextSource + params = Params() + context = LiveContextSource(params) + path = Path(tempfile.gettempdir()) / f'starpilot-alert-preview-{os.getuid()}.lock' + descriptor = None + try: + if not context.offroad() or params.get_bool('IsDriverViewEnabled'): + return 2 + descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid(): + return 2 + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + from openpilot.selfdrive.ui.soundd import Soundd, AudibleAlert + from openpilot.system import micd + import sounddevice + micd.patch_sounddevice(sounddevice) + key = sys.argv[1] + play_saved_alert(key, params=params, context=context, sound=Soundd(params), audio=sounddevice, + alert=getattr(AudibleAlert, ALERTS[key])) + return 0 + except (AlertPreviewError, BlockingIOError): + return 2 + finally: + context.close() + if descriptor is not None: + os.close(descriptor) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/openpilot/starpilot/audio/tests/test_preview.py b/openpilot/starpilot/audio/tests/test_preview.py new file mode 100644 index 0000000000..67c2d2535d --- /dev/null +++ b/openpilot/starpilot/audio/tests/test_preview.py @@ -0,0 +1,98 @@ +from contextlib import contextmanager +from types import SimpleNamespace +import subprocess +import unittest +from unittest.mock import Mock + +from openpilot.starpilot.audio.alert_volume import SPECS +from openpilot.starpilot.audio.preview import ALERTS, AlertPreview, AlertPreviewError, play_saved_alert + + +class PreviewTests(unittest.TestCase): + def test_all_volume_rows_have_a_real_alert(self): + self.assertEqual(set(ALERTS), set(SPECS)) + + def test_saved_key_only_and_offroad_authorization(self): + runner = Mock(return_value=SimpleNamespace(returncode=0)) + preview = AlertPreview(lambda: False, runner) + for body in ({}, {'key': []}, {'key': 'bad'}, {'key': 'EngageVolume', 'volume': 100}): + with self.assertRaises(AlertPreviewError): + preview.play(body) + with self.assertRaises(AlertPreviewError): + preview.play({'key': 'EngageVolume'}) + runner.assert_not_called() + + def test_preview_uses_fixed_argv_and_reaps_with_bounded_run(self): + runner = Mock(return_value=SimpleNamespace(returncode=0)) + preview = AlertPreview(lambda: True, runner) + for key in SPECS: + self.assertEqual(preview.play({'key': key}), {'played': True}) + args, kwargs = runner.call_args + self.assertEqual(args[0][-2:], ['openpilot.starpilot.audio.preview', key]) + self.assertEqual(kwargs['timeout'], 8) + self.assertIs(kwargs['stdin'], subprocess.DEVNULL) + self.assertTrue(preview.lock.acquire(blocking=False)) + with self.assertRaises(AlertPreviewError): + preview.play({'key': 'EngageVolume'}) + preview.lock.release() + + def test_errors_release_preview_ownership(self): + for outcome in (SimpleNamespace(returncode=2), SimpleNamespace(returncode=1), subprocess.TimeoutExpired('preview', 8)): + runner = Mock(side_effect=outcome) if isinstance(outcome, Exception) else Mock(return_value=outcome) + preview = AlertPreview(lambda: True, runner) + with self.assertRaises(AlertPreviewError): + preview.play({'key': 'EngageVolume'}) + self.assertTrue(preview.lock.acquire(blocking=False)) + preview.lock.release() + + def test_worker_uses_normal_sound_stream_and_stops_when_offroad_evidence_is_lost(self): + for revoked in (False, True): + self._worker_stream_case(revoked) + + def _worker_stream_case(self, revoked): + clock = [0.0] + stream = [] + context = SimpleNamespace(offroad=lambda: not revoked or clock[0] < .1, evidence_wait_ms=750) + params = SimpleNamespace(get_bool=lambda _: False) + + @contextmanager + def audio_stream(_): + stream.append('opened') + try: + yield + finally: + stream.append('closed') + + sound = SimpleNamespace(refresh_saved_volumes=Mock(), update_alert=Mock(), + loaded_sounds={7: [0] * 48000}, get_stream=audio_stream, update_critical_sound=Mock(), native_audio=None) + def sleep(seconds): + clock[0] += seconds + def call(): + return play_saved_alert('EngageVolume', params=params, context=context, sound=sound, audio=object(), + alert=7, now=lambda: clock[0], sleep=sleep) + if revoked: + with self.assertRaises(AlertPreviewError): + call() + else: + call() + self.assertEqual(stream, ['opened', 'closed']) + sound.update_alert.assert_called_once_with(7) + sound.refresh_saved_volumes.assert_called_once() + self.assertLessEqual(clock[0], 1.05) + + def test_driver_camera_prevents_any_audio_stream(self): + sound = SimpleNamespace(get_stream=Mock()) + with self.assertRaises(AlertPreviewError): + play_saved_alert('EngageVolume', params=SimpleNamespace(get_bool=lambda _: True), + context=SimpleNamespace(offroad=lambda: True), sound=sound, audio=None, alert=1) + sound.get_stream.assert_not_called() + + def test_state_is_rechecked_after_initializing_the_audio_stream(self): + permissions = iter((True, True, False)) + stream = Mock() + sound = SimpleNamespace(refresh_saved_volumes=Mock(), update_alert=Mock(), + loaded_sounds={7: [0] * 48000}, get_stream=Mock(return_value=stream)) + with self.assertRaises(AlertPreviewError): + play_saved_alert('EngageVolume', params=SimpleNamespace(get_bool=lambda _: False), + context=SimpleNamespace(offroad=lambda: next(permissions)), sound=sound, audio=None, alert=7) + stream.close.assert_called_once() diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py index 8f20a0f878..00fc7b7e58 100644 --- a/openpilot/starpilot/galaxy/server.py +++ b/openpilot/starpilot/galaxy/server.py @@ -17,6 +17,7 @@ from urllib.parse import parse_qs, quote, unquote, urlsplit from openpilot.common.swaglog import cloudlog from openpilot.starpilot.galaxy.batch_routes import change_drive_state, delete_records, non_default_settings, preview_layout, preview_settings_recovery +from openpilot.starpilot.audio.preview import AlertPreview, AlertPreviewError from openpilot.starpilot.galaxy.access import AccessStatus, default_owner from openpilot.starpilot.galaxy.auth import LocalSessions from openpilot.starpilot.galaxy.http_server import (_LocalHTTPHandler, _LocalHTTPServer, allowed_authority, @@ -99,7 +100,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe starport_client=None, navigation=None, offline_maps=None, drive_state=None, cloud_provider=None, cloud_offroad=None, projection_layout=None, local_access=None, tmux_live=None, starport_logs=None, starport_log_sender=None, hotspot=None, toyota_tools=None, replay_client=None, replay_root=None, - spotify_setup=None, companion=None, phone_location=None, reverse_proxy=None, local_hostnames=None): + spotify_setup=None, companion=None, phone_location=None, reverse_proxy=None, local_hostnames=None, sound_preview=None): from openpilot.starpilot.galaxy.local_hostnames import LocalHostnames hostname_source = local_hostnames if local_hostnames is not None else LocalHostnames(clock=clock) from openpilot.starpilot.galaxy.companion import CompanionRegistry, APK_PATH @@ -252,6 +253,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe favorites_source = favorites favorites_lock = threading.Lock() sound_source = sounds + alert_preview = sound_preview or AlertPreview(parked=parked) sounds_lock = threading.Lock() def drive_owner(): @@ -1413,7 +1415,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe return try: result = sound_owner().snapshot() - except SoundDownloadError as error: + except (SoundDownloadError, AlertPreviewError) as error: self.json(error.status, {'error': str(error)}) except (OSError, ValueError, RuntimeError): self.json(503, {'error': 'Sound packs are unavailable'}) @@ -1680,7 +1682,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe '/api/models/active', '/api/models/preferences', '/api/models/download', '/api/models/download_all', '/api/models/cancel', '/api/models/delete', '/api/models/refresh_manifest', '/api/models/jetlink', '/api/models/laboratory', '/api/models/laboratory/download', '/api/models/laboratory/delete', - '/api/sounds/download', '/api/sounds/cancel', '/api/software/action', '/api/drives/ignore', + '/api/sounds/preview', '/api/sounds/download', '/api/sounds/cancel', '/api/software/action', '/api/drives/ignore', '/api/recordings/delete-videos', '/api/sentry/notifications', '/api/sentry/events/delete', '/api/crash-reports/delete', '/api/navigation/search', '/api/navigation/action', '/api/navigation/offline', '/api/drive-state/action', '/api/toyota-tools/action', '/api/vehicle-selection/preview', '/api/vehicle-selection/confirm'): @@ -2269,8 +2271,9 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe if self.settings_session() != identity: self.json(401, {'error': 'Sign in to Galaxy'}) return - result = sound_owner().action(path.rsplit('/', 1)[-1], payload) - except SoundDownloadError as error: + result = (alert_preview.play(payload) if path == '/api/sounds/preview' else + sound_owner().action(path.rsplit('/', 1)[-1], payload)) + except (SoundDownloadError, AlertPreviewError) as error: self.json(error.status, {'error': str(error)}) except (OSError, ValueError, RuntimeError): self.json(503, {'error': 'Sound packs are unavailable'}) diff --git a/openpilot/starpilot/galaxy/settings.py b/openpilot/starpilot/galaxy/settings.py index 01b33e477c..a1a5cc628f 100644 --- a/openpilot/starpilot/galaxy/settings.py +++ b/openpilot/starpilot/galaxy/settings.py @@ -394,7 +394,8 @@ def _projection(row: FeatureRow) -> dict: "repairValue": row.repair_value, "defaultValue": row.default_value, "resetAvailable": row_default(row) is not None, "displayScale": "lane-strength" if row.key == "LaneCenteringStrength" else None, **({"coordinate": row.key.removeprefix("torque:surface:")} if row.key.startswith("torque:surface:") and row.key != "torque:surface:reset" else {}), - "heading": is_heading(row.key)} + "heading": is_heading(row.key), + **({"previewSound": row.key} if row.key in SOUND_SPECS else {})} def _question(row: FeatureRow, request: FeatureSettingsRequest) -> str: diff --git a/openpilot/starpilot/galaxy/tests/test_settings_controls.mjs b/openpilot/starpilot/galaxy/tests/test_settings_controls.mjs index f05c83dee6..851bfc8a64 100644 --- a/openpilot/starpilot/galaxy/tests/test_settings_controls.mjs +++ b/openpilot/starpilot/galaxy/tests/test_settings_controls.mjs @@ -239,3 +239,26 @@ for (const row of [{ value: "30", minimum: 1, maximum: 30, step: 1, unit: "h" }, assert.match(template, /presentedRow\.reason && !row\.heading/) } console.log("Settings controls: section headings render as labels only") + + +const previewVolume = control({ ...numeric, previewSound: "EngageVolume" }) +let finishPreview +const playedKeys = [] +previewVolume.vm.testAlert = key => { playedKeys.push(key); return new Promise(resolve => { finishPreview = resolve }) } +const playing = previewVolume.vm.previewSound() +assert.equal(previewVolume.vm.testingSound, true) +assert.deepEqual(playedKeys, ["EngageVolume"]) +await previewVolume.vm.previewSound() +assert.equal(playedKeys.length, 1) +finishPreview() +await playing +assert.equal(previewVolume.vm.testingSound, false) +assert.deepEqual(previewVolume.commits, []) +previewVolume.vm.testAlert = async () => { throw new Error("Keep the comma offroad") } +await previewVolume.vm.previewSound() +assert.equal(previewVolume.vm.soundError, "Keep the comma offroad") +assert.equal(previewVolume.vm.testingSound, false) +previewVolume.vm.disabled = true +previewVolume.vm.testAlert = () => { throw new Error("disabled preview must not run") } +await previewVolume.vm.previewSound() +assert.equal(previewVolume.vm.soundError, "Keep the comma offroad") diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index b3bbb581cb..d1b51a8333 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -1892,3 +1892,12 @@ progress { max-width: 100%; accent-color: var(--primary); } .gx-guided__link:hover:not(:disabled) { text-decoration: underline; } .gx-guided__link:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } .gx-guided__link:disabled { opacity: .5; cursor: default; } + +.gx-slider-actions { display: inline-flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; margin-left: auto; } +.gx-alert-preview { min-width: 118px; min-height: 44px; gap: 8px; white-space: nowrap; } +.gx-alert-preview[aria-busy="true"] .bi { animation: gx-preview-pulse .7s ease-in-out infinite alternate; } +.gx-alert-preview-note { margin: 0; font-size: var(--fs-xs); color: var(--on-surface-variant); line-height: 1.4; } +@keyframes gx-preview-pulse { to { opacity: .35; } } +@media (prefers-reduced-motion: reduce) { .gx-alert-preview[aria-busy="true"] .bi { animation: none; } } +.gx-slider-header--preview { align-items: center; flex-wrap: wrap; } +.gx-slider-header .gx-slider-actions .gx-setting-default { margin: 0; } diff --git a/openpilot/starpilot/galaxy/web/js/galaxy-setting-row.js b/openpilot/starpilot/galaxy/web/js/galaxy-setting-row.js index 69c1b1b894..b2fb237a33 100644 --- a/openpilot/starpilot/galaxy/web/js/galaxy-setting-row.js +++ b/openpilot/starpilot/galaxy/web/js/galaxy-setting-row.js @@ -67,9 +67,10 @@ export const GalaxySettingRow = { busy: { type: Boolean, default: false }, feedback: { type: String, default: "saved" }, saveValue: { type: Function, required: true }, + testAlert: { type: Function, default: null }, }, emits: ["open", "review", "reset-default"], - data() { return { preview: undefined, interacting: false, fineScrub: null, isFineScrubbing: false, updating: false } }, + data() { return { preview: undefined, interacting: false, fineScrub: null, isFineScrubbing: false, updating: false, testingSound: false, soundError: "" } }, computed: { presentedRow() { return presentedSetting(this.row) }, control() { return settingControl(this.row) }, @@ -94,6 +95,14 @@ export const GalaxySettingRow = { }, }, methods: { + async previewSound() { + if (this.locked || this.testingSound || !this.testAlert || !this.row.previewSound) return + this.testingSound = true + this.soundError = "" + try { await this.testAlert(this.row.previewSound) } + catch (error) { this.soundError = error?.message || "The alert could not be played." } + finally { this.testingSound = false } + }, async commit(value) { if (this.locked) return if (String(value) === String(this.presentedRow.value)) { this.preview = undefined; return } @@ -231,17 +240,23 @@ export const GalaxySettingRow = {
-
{{ displayValue }} +
{{ displayValue }} {{ isFineScrubbing ? 'Fine scrubbing' : 'Hold to fine scrub' }} + +
+ @click="$emit('reset-default', index)">Default
+

{{ soundError || 'Playing on your comma…' }}

{{ bounds.min }} to {{ bounds.numericMax ?? bounds.max }} {{ presentedRow.unit }}Step: {{ bounds.step }} {{ presentedRow.unit }}
diff --git a/openpilot/starpilot/galaxy/web/js/settings.js b/openpilot/starpilot/galaxy/web/js/settings.js index 3c0577c9e7..89b6fc873e 100644 --- a/openpilot/starpilot/galaxy/web/js/settings.js +++ b/openpilot/starpilot/galaxy/web/js/settings.js @@ -1,3 +1,4 @@ +import { requestJson } from "./startup.js" import { GxDialog } from "./dialog.js" import { GxState } from "./state.js" import { connectionError } from "./polling.js" @@ -269,13 +270,13 @@ export const SettingsPage = { const navigation = route.view const navigationKey = `${props.initialPage}:${props.initialSection || ""}` const saved = navigation.settings?.key === navigationKey ? navigation.settings : null - const state = reactive({ status: "idle", data: null, pending: null, error: "", query: "", searchOpen: false, section: "lateral", layoutOpen: false, layoutPanel: "widgets", favoritesOpen: false, soundChoicesDirty: false, parents: [], developerOpen: props.initialSection === "developer", panelDirection: 1 }) + const state = reactive({ status: "idle", data: null, pending: null, error: "", query: "", searchOpen: false, section: "lateral", layoutOpen: false, layoutPanel: "widgets", favoritesOpen: false, soundChoicesDirty: false, testingSound: false, parents: [], developerOpen: props.initialSection === "developer", panelDirection: 1 }) if (saved) Object.assign(state, { section: saved.section, query: saved.query, searchOpen: saved.searchOpen, parents: saved.parents.slice(), developerOpen: saved.developerOpen }) const feed = new SettingsFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized }) return { state, feed, sections: SETTINGS_SECTIONS, navigation, navigationKey, restoredPage: saved?.page } }, computed: { - busy() { return settingsBusy(this.state) }, + busy() { return settingsBusy(this.state) || !!this.state.testingSound }, activeSection() { if (this.state.developerOpen) return this.sections.at(-1) const page = (this.state.data?.page || "hub").split("/")[0] @@ -335,6 +336,18 @@ export const SettingsPage = { this.feed.stop() }, methods: { + async testAlert(key) { + if (this.busy) return + this.state.testingSound = true + try { + await requestJson("./api/sounds/preview", { timeout: 10000, request: { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }), + } }) + } catch (error) { + if (error?.status === 401) this.unauthorized() + throw error + } finally { this.state.testingSound = false } + }, open(page) { if (this.busy) return this.$emit?.("nested") @@ -426,7 +439,7 @@ export const SettingsPage = {

Choose developer driving options and cloud services.

Choose when to show the camera and adjust its saved crop. Use the crop editor for a live preview.

-

Adjust alert and chime volumes. Immediate warnings keep their safety volume ramp.

+

Adjust alert and chime volumes. Test alert plays on your comma while offroad; Auto previews a quiet-cabin starting level. Immediate warnings keep their safety volume ramp.

Adjust brightness and screen timing when custom display settings are on.

Configure lane-change assistance, signal timing, steering pace and following gap.

Choose when to switch between Chill and Experimental. Applies on supported vehicles when StarPilot controls acceleration and braking.

@@ -464,7 +477,7 @@ export const SettingsPage = {
No matching settings.
diff --git a/prebuilt.json b/prebuilt.json index 9a9a0d06ec..fcda3bb8ca 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "4c66eab4bc2ebf4cfba3329df36fac04a20c572295a7da22b03616013b3bd5d9", "schema_version": 1} +{"protected_inventory_sha256": "0a96cfa499ada974607b1255975f78be11f09ce5f3d1904e2a27598ca08e3d07", "schema_version": 1}