diff --git a/common/params_keys.h b/common/params_keys.h index dcd46dbd6a..4a9fd1c775 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -352,6 +352,7 @@ inline static std::unordered_map 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}}, diff --git a/selfdrive/assets/sounds/model_ready.wav b/selfdrive/assets/sounds/model_ready.wav new file mode 100644 index 0000000000..b0592f6573 Binary files /dev/null and b/selfdrive/assets/sounds/model_ready.wav differ diff --git a/selfdrive/ui/soundd.py b/selfdrive/ui/soundd.py index 360c6f77df..e2ede4fdf4 100644 --- a/selfdrive/ui/soundd.py +++ b/selfdrive/ui/soundd.py @@ -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) diff --git a/selfdrive/ui/tests/test_gpu_model_ready_sound.py b/selfdrive/ui/tests/test_gpu_model_ready_sound.py new file mode 100644 index 0000000000..1911ea5ab3 --- /dev/null +++ b/selfdrive/ui/tests/test_gpu_model_ready_sound.py @@ -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=[]),'','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 diff --git a/starpilot/common/assets/device_settings_layout.json b/starpilot/common/assets/device_settings_layout.json index 27ddec6a37..a1fba4454c 100644 --- a/starpilot/common/assets/device_settings_layout.json +++ b/starpilot/common/assets/device_settings_layout.json @@ -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" } ] }, diff --git a/starpilot/common/gpu_model_ready_sound.py b/starpilot/common/gpu_model_ready_sound.py new file mode 100644 index 0000000000..3e4d8803d4 --- /dev/null +++ b/starpilot/common/gpu_model_ready_sound.py @@ -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