Add an optional GPU-model-ready chime

(cherry picked from commit 0a212734c1)
This commit is contained in:
AngusBell97
2026-09-10 21:17:29 +01:00
committed by firestar5683
parent 202ea33690
commit 46596218ba
6 changed files with 135 additions and 0 deletions
+1
View File
@@ -352,6 +352,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"DrivingModelVersion", {PERSISTENT, STRING, "v15", "v15", 1}},
{"DynamicPathWidth", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}},
{"DynamicPedalsOnUI", {PERSISTENT, BOOL, "1", "0", 1, SETTINGS_SIMPLE}},
{"GpuModelReadySound", {PERSISTENT, BOOL, "1", "1", 2, SETTINGS_SIMPLE}},
{"EngageVolume", {PERSISTENT, INT, "101", "101", 2, SETTINGS_SIMPLE}},
{"EVTuning", {PERSISTENT, BOOL, "0", "0", 3}},
{"Fahrenheit", {PERSISTENT, BOOL, "0", "0", 3}},
Binary file not shown.
+36
View File
@@ -42,6 +42,8 @@ StarPilotAudibleAlert = custom.StarPilotCarControl.HUDControl.AudibleAlert
STARPILOT_CUSTOM_ALERT_OFFSET = 1000
STARPILOT_CUSTOM_ALERT_START = int(StarPilotAudibleAlert.angry)
TURN_STEERING_LIMIT_ALERT_SUFFIX = "steersaturated"
GPU_MODEL_READY_ALERT = 2000
# Keep carState out of this list; C4's onroad stack is near msgq's 15-reader limit.
SOUNDD_SERVICES = ('selfdriveState', 'soundPressure', 'starpilotSelfdriveState', 'starpilotPlan')
@@ -67,6 +69,7 @@ def should_mute_turn_steering_limit_alert(alert_type: str, v_ego: float, mute_be
sound_list: dict[int, tuple[str, int | None, float]] = {
GPU_MODEL_READY_ALERT: ("model_ready.wav", 1, MAX_VOLUME),
# AudibleAlert, file name, play count (none for infinite)
AudibleAlert.engage: ("engage.wav", 1, MAX_VOLUME),
AudibleAlert.disengage: ("disengage.wav", 1, MAX_VOLUME),
@@ -124,6 +127,11 @@ class Soundd:
self.spl_filter_weighted = FirstOrderFilter(0, 2.5, FILTER_DT, initialized=False)
self.params_memory = Params(memory=True)
from openpilot.starpilot.common.gpu_model_ready_sound import GpuModelReadyChime
self.model_ready_chime = GpuModelReadyChime()
self.model_ready_params = Params(return_defaults=True)
self.model_ready_last_check = 0.0
self.model_ready_pending = False
self.starpilot_toggles = get_starpilot_toggles()
@@ -325,6 +333,33 @@ class Soundd:
self.update_alert(AudibleAlert.none)
self.selfdrive_timeout_alert = False
def update_model_ready_sound(self, sm):
# A one-shot ending exactly on a callback boundary must release its slot.
if self.current_alert == GPU_MODEL_READY_ALERT:
loaded = self.loaded_sounds.get(GPU_MODEL_READY_ALERT)
if loaded is None or self.current_sound_frame >= len(loaded):
self.current_alert = AudibleAlert.none
self.current_sound_frame = 0
now = time.monotonic()
if now - self.model_ready_last_check >= 0.25:
self.model_ready_last_check = now
try:
self.model_ready_pending = self.model_ready_chime.update(
active=self.model_ready_params.get_bool("UsbGpuActive"),
loading=self.model_ready_params.get_bool("UsbGpuLoading"),
onroad=self.model_ready_params.get_bool("IsOnroad"),
enabled=self.model_ready_params.get_bool("GpuModelReadySound"), now=now)
except Exception:
self.model_ready_chime.consume()
self.model_ready_pending = False
# Run after stock/custom alert selection: warnings and timeout alerts always win.
if (self.model_ready_pending and self.current_alert == AudibleAlert.none
and not self.selfdrive_timeout_alert and sm.valid["selfdriveState"] and sm.alive["selfdriveState"]):
self.model_ready_chime.consume()
self.model_ready_pending = False
self.current_alert_type = ""
self.update_alert(GPU_MODEL_READY_ALERT)
def get_volume_override(self):
if self.current_alert_type.startswith("belowSteerSpeed/"):
return self.starpilot_toggles.below_steer_speed_volume / 100.0
@@ -391,6 +426,7 @@ class Soundd:
self.current_volume = 0.0
self.get_audible_alert(sm)
self.update_model_ready_sound(sm)
if self.current_alert != AudibleAlert.none:
v_ego = max(float(getattr(sm["starpilotSelfdriveState"], "vEgo", 0.0)), 0.0)
@@ -0,0 +1,70 @@
import ast
from pathlib import Path
from types import SimpleNamespace
import wave
import pytest
from openpilot.starpilot.common.gpu_model_ready_sound import GpuModelReadyChime
def update(chime, now=0, **kwargs):
return chime.update(**dict(active=False, loading=False, onroad=True, enabled=True, now=now) | kwargs)
def test_ready_once_after_success_not_during_loading_or_failure():
chime = GpuModelReadyChime()
assert not update(chime, loading=True)
assert not update(chime, 1, active=True, loading=True)
assert update(chime, 2, active=True)
chime.consume()
assert not update(chime, 3, active=True)
assert not update(chime, 4, loading=True)
assert not update(chime, 5)
def test_restart_with_already_ready_model_does_not_chime():
assert not update(GpuModelReadyChime(), active=True)
@pytest.mark.parametrize("cancel", [dict(enabled=False), dict(onroad=False), dict(active=False), dict(loading=True)])
def test_cancel_pending_on_disabled_offroad_fallback_or_reload(cancel):
chime = GpuModelReadyChime()
update(chime)
assert update(chime, 1, active=True)
assert not update(chime, 2, **(dict(active=True) | cancel))
def test_pending_notification_expires_without_late_chime():
chime = GpuModelReadyChime()
update(chime)
assert update(chime, 1, active=True)
assert not update(chime, 6, active=True)
assert not update(chime, 7, active=True)
def test_enabling_option_after_load_does_not_replay():
chime = GpuModelReadyChime()
update(chime, enabled=False)
assert not update(chime, 1, active=True, enabled=False)
assert not update(chime, 2, active=True, enabled=True)
@pytest.mark.parametrize("alert,timeout,valid,alive,expected", [(1,False,True,True,False),(0,True,True,True,False),(0,False,False,True,False),(0,False,True,False,False),(0,False,True,True,True)])
def test_soundd_never_replaces_existing_or_timeout_alerts(alert,timeout,valid,alive,expected):
source=(Path(__file__).parents[1]/'soundd.py').read_text()
cls=next(n for n in ast.parse(source).body if isinstance(n,ast.ClassDef) and n.name=='Soundd')
method=next(n for n in cls.body if isinstance(n,ast.FunctionDef) and n.name=='update_model_ready_sound')
scope={'time':SimpleNamespace(monotonic=lambda:1.1),'AudibleAlert':SimpleNamespace(none=0),'GPU_MODEL_READY_ALERT':2000}
exec(compile(ast.Module(body=[method],type_ignores=[]),'<soundd method>','exec'),scope)
calls=[]
sound=SimpleNamespace(model_ready_last_check=1.0,model_ready_pending=True,model_ready_chime=GpuModelReadyChime(),current_alert=alert,selfdrive_timeout_alert=timeout,update_alert=calls.append)
sm=SimpleNamespace(valid={'selfdriveState':valid},alive={'selfdriveState':alive})
scope['update_model_ready_sound'](sound,sm)
assert calls == ([2000] if expected else [])
def test_notification_asset_matches_soundd_audio_format():
with wave.open(str(Path(__file__).parents[2]/'assets/sounds/model_ready.wav')) as audio:
assert (audio.getnchannels(),audio.getsampwidth(),audio.getframerate()) == (1,2,48000)
assert 0 < audio.getnframes() < 48000
@@ -3355,6 +3355,14 @@
"data_type": "bool",
"ui_type": "toggle",
"settings_tier": "simple"
},
{
"key": "GpuModelReadySound",
"label": "GPU Model Ready Sound",
"description": "Play a short chime when the external GPU model finishes loading. Driving alerts take priority.",
"data_type": "bool",
"ui_type": "toggle",
"settings_tier": "simple"
}
]
},
+20
View File
@@ -0,0 +1,20 @@
"""Optional notification state; never supersedes driving alerts or starts inference."""
class GpuModelReadyChime:
def __init__(self):
self.ready = None
self.pending_until = None
def update(self, *, active, loading, onroad, enabled, now):
ready = bool(onroad and active and not loading)
# A soundd restart with an already-loaded model is not a loading event.
if self.ready is False and ready and enabled:
self.pending_until = now + 5.0
self.ready = ready
if not enabled or not ready or (self.pending_until is not None and now >= self.pending_until):
self.pending_until = None
return self.pending_until is not None
def consume(self):
self.pending_until = None