diff --git a/openpilot/selfdrive/carrot/server/features/__init__.py b/openpilot/selfdrive/carrot/server/features/__init__.py index 1644d819cc..8eb49944c4 100644 --- a/openpilot/selfdrive/carrot/server/features/__init__.py +++ b/openpilot/selfdrive/carrot/server/features/__init__.py @@ -18,6 +18,7 @@ tools, vision_diag, vision_test, + web_sound, web_settings, ws, youtube_live, @@ -45,3 +46,4 @@ def register_all(app: web.Application) -> None: youtube_live.register(app) vision_test.register(app) vision_diag.register(app) + web_sound.register(app) diff --git a/openpilot/selfdrive/carrot/server/features/static.py b/openpilot/selfdrive/carrot/server/features/static.py index 547ca0f832..eb911547cc 100644 --- a/openpilot/selfdrive/carrot/server/features/static.py +++ b/openpilot/selfdrive/carrot/server/features/static.py @@ -27,13 +27,19 @@ def _load_device_languages() -> list: def _build_bootstrap_payload() -> dict: try: - device_values = get_param_values(["LanguageSetting"], {"LanguageSetting": ""}) + device_values = get_param_values( + ["LanguageSetting", "SoundLanguageSetting"], + {"LanguageSetting": "", "SoundLanguageSetting": "auto"}, + ) device_language = device_values.get("LanguageSetting", "") + sound_language = device_values.get("SoundLanguageSetting", "auto") except Exception: device_language = "" + sound_language = "auto" return { "webSettings": read_web_settings(), "deviceLanguage": device_language, + "soundLanguage": sound_language, "deviceLanguages": _load_device_languages(), } diff --git a/openpilot/selfdrive/carrot/server/features/web_sound.py b/openpilot/selfdrive/carrot/server/features/web_sound.py new file mode 100644 index 0000000000..3f7467a971 --- /dev/null +++ b/openpilot/selfdrive/carrot/server/features/web_sound.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import asyncio +import time + +from aiohttp import WSMsgType, web + +from openpilot.cereal import car, messaging +from openpilot.common.params import Params +from openpilot.selfdrive.car.openpilot_toggle import CruiseMainOpenpilotToggle + + +AudibleAlert = car.CarControl.HUDControl.AudibleAlert +ButtonType = car.CarState.ButtonEvent.Type +SELFDRIVE_STATE_TIMEOUT = 5.0 + + +def _enum_raw(value, default: int = 0) -> int: + try: + return int(getattr(value, "raw", value)) + except (TypeError, ValueError): + return default + + +def _message_valid(sm, service: str) -> bool: + try: + return bool(sm.valid[service]) + except Exception: + return False + + +def _param_percent(params: Params, key: str) -> float: + try: + return max(0.0, min(2.0, float(params.get_int(key)) / 100.0)) + except Exception: + return 1.0 + + +def _param_string(value) -> str: + if isinstance(value, (bytes, bytearray, memoryview)): + return bytes(value).decode("utf-8", errors="replace") + return str(value or "") + + +def _sound_directory(params: Params) -> str: + try: + sound_language = _param_string(params.get("SoundLanguageSetting", return_default=True)).strip() + if not sound_language or sound_language.lower() == "auto": + sound_language = _param_string(params.get("LanguageSetting", return_default=True)).strip() or "en" + except Exception: + sound_language = "en" + normalized = sound_language.replace("_", "-").lower() + if normalized.startswith("main-"): + normalized = normalized[5:] + if normalized == "ko" or normalized.startswith("ko-"): + return "sounds" + if normalized in ("zh-chs", "zh-hans") or normalized.startswith("zh"): + return "sounds_chs" + return "sounds_eng" + + +def _is_tizi() -> bool: + try: + from openpilot.system.hardware import HARDWARE + return HARDWARE.get_device_type() == "tizi" + except Exception: + return False + + +async def _send_sound_states(ws: web.WebSocketResponse) -> None: + sm = messaging.SubMaster(["selfdriveState", "carrotMan", "carState"]) + params = Params() + cruise_main_toggle = CruiseMainOpenpilotToggle(ButtonType.mainCruise) + last_signature = None + prompt_sequence = 0 + sequence = 0 + next_param_read = 0.0 + volume = 1.0 + engage_volume = 1.0 + sound_directory = "sounds_eng" + emitted_countdown = 100 + tizi = _is_tizi() + + while not ws.closed: + sm.update(0) + now = time.monotonic() + + enabled = bool(sm["selfdriveState"].enabled) if _message_valid(sm, "selfdriveState") else False + alert = _enum_raw(sm["selfdriveState"].alertSound) if _message_valid(sm, "selfdriveState") else 0 + + try: + missing_for = now - float(sm.recv_time["selfdriveState"]) + except Exception: + missing_for = 0.0 + if missing_for > SELFDRIVE_STATE_TIMEOUT: + if enabled and missing_for < SELFDRIVE_STATE_TIMEOUT + 10.0: + alert = _enum_raw(AudibleAlert.warningImmediate) + else: + alert = _enum_raw(AudibleAlert.none) + + countdown = 100 + if _message_valid(sm, "carrotMan"): + try: + countdown = int(sm["carrotMan"].leftSec) + except (TypeError, ValueError): + countdown = 100 + if last_signature is None or sm.updated["selfdriveState"]: + emitted_countdown = countdown + + button_events = sm["carState"].buttonEvents if _message_valid(sm, "carState") else () + if cruise_main_toggle.update(button_events, enabled, now=now): + prompt_sequence += 1 + alert = _enum_raw(AudibleAlert.prompt) + + if now >= next_param_read: + volume = _param_percent(params, "SoundVolumeAdjust") + engage_volume = _param_percent(params, "SoundVolumeAdjustEngage") + sound_directory = _sound_directory(params) + next_param_read = now + 1.0 + + signature = (alert, emitted_countdown, prompt_sequence, volume, engage_volume, sound_directory, tizi) + if signature != last_signature: + sequence += 1 + await ws.send_json({ + "type": "soundState", + "sequence": sequence, + "alert": alert, + "countdown": emitted_countdown, + "promptSequence": prompt_sequence, + "volume": volume, + "engageVolume": engage_volume, + "soundDirectory": sound_directory, + "tizi": tizi, + }) + last_signature = signature + + await asyncio.sleep(0.05) + + +async def ws_web_sound(request: web.Request) -> web.WebSocketResponse: + ws = web.WebSocketResponse(heartbeat=20, max_msg_size=64 * 1024, compress=False) + await ws.prepare(request) + sender = asyncio.create_task(_send_sound_states(ws)) + try: + async for msg in ws: + if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.ERROR): + break + finally: + sender.cancel() + try: + await sender + except asyncio.CancelledError: + pass + except Exception: + pass + return ws + + +def register(app: web.Application) -> None: + app.router.add_get("/ws/web_sound", ws_web_sound) diff --git a/openpilot/selfdrive/carrot/web/assets/alert_camera.svg b/openpilot/selfdrive/carrot/web/assets/alert_camera.svg new file mode 100644 index 0000000000..9ca3cc9cbf --- /dev/null +++ b/openpilot/selfdrive/carrot/web/assets/alert_camera.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1086279a32f49f68de1db4e89a93aa2aae779395cfb908e5f6a0cbf2bf7dae6 +size 989 diff --git a/openpilot/selfdrive/carrot/web/assets/alert_police.svg b/openpilot/selfdrive/carrot/web/assets/alert_police.svg new file mode 100644 index 0000000000..96fdd53484 --- /dev/null +++ b/openpilot/selfdrive/carrot/web/assets/alert_police.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0dea6745d5cd5cf8206ced256310fed587555b3309d80cad46a4c066a931401 +size 707 diff --git a/openpilot/selfdrive/carrot/web/css/components/mini_hud.css b/openpilot/selfdrive/carrot/web/css/components/mini_hud.css index e3b9d7787b..17e9f3cd11 100644 --- a/openpilot/selfdrive/carrot/web/css/components/mini_hud.css +++ b/openpilot/selfdrive/carrot/web/css/components/mini_hud.css @@ -19,23 +19,46 @@ --mini-set-font: 60px; --mini-speed-scale-x: 1; --mini-set-scale-x: 1; - --mini-limit-size: 240px; - --mini-limit-font: 96px; + --mini-speed-slot-width: 260px; + --mini-set-slot-width: 112px; + --mini-speed-gap: 12px; + --mini-gear-left: 68%; + --mini-gear-bottom: var(--mini-set-font); + --mini-gear-font: 18px; + --mini-gear-height: 31px; + --mini-limit-size: 260px; + --mini-limit-font: 104px; + --mini-limit-caption-font: 34px; --mini-limit-label-font: 34px; --mini-badge-font: 46px; --mini-alert-font: 34px; - --mini-mode-font: 62px; - --mini-gear-font: 136px; + --mini-alert-label-font: calc(var(--mini-alert-font) * 0.46); + --mini-alert-label-width: calc(var(--mini-alert-font) * 2.24); + --mini-alert-row-radius: calc(9px * var(--mini-ui-scale)); + --mini-alert-row-border: rgba(98, 245, 167, 0.20); + --mini-alert-row-bg: rgba(31, 82, 58, 0.30); + --mini-alert-label-bg: rgba(98, 245, 167, 0.105); + --mini-detail-height: 220px; + --mini-mode-font: 48px; + --mini-mode-size: 160px; --mini-radius: calc(20px * var(--mini-ui-scale)); --mini-padding: calc(12px * var(--mini-ui-scale)); --mini-section-gap: calc(10px * var(--mini-ui-scale)); --mini-band-height: calc(54px * var(--mini-ui-scale)); - --mini-panel-top: rgba(13, 56, 38, 1); - --mini-panel-mid: rgba(18, 65, 44, 1); - --mini-panel-bottom: rgba(8, 31, 21, 1); - --mini-panel-border: rgba(255, 255, 255, 0.18); - --mini-text-shadow-strong: 0 1.4px 3.6px rgba(0, 0, 0, 0.94), 0 0 1.2px rgba(0, 0, 0, 0.62); + --mini-chip-radius: calc(12px * var(--mini-ui-scale)); /* app --r-md look for the detail chips */ + --mini-panel-top: #0b3726; + --mini-panel-mid: #0f402d; + --mini-panel-bottom: #07140f; + --mini-panel-border: rgba(255, 255, 255, 0.16); + --mini-surface: rgba(255, 255, 255, 0.06); + --mini-surface-strong: rgba(255, 255, 255, 0.10); + --mini-text: #f7fff9; + --mini-muted: rgba(247, 255, 249, 0.66); + --mini-accent: #35f2a0; + --mini-warning: #ffd24a; + --mini-danger: #ff304f; + --mini-text-shadow-strong: 0 1px 2.4px rgba(0, 0, 0, 0.72); position: fixed; inset: 0; @@ -51,7 +74,7 @@ linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.01) 38%, transparent 72%), linear-gradient(145deg, var(--mini-panel-top), var(--mini-panel-mid) 58%, var(--mini-panel-bottom)); box-shadow: 0 12px 20px rgba(0, 0, 0, 0.54); - color: #fff; + color: var(--mini-text); isolation: isolate; contain: strict; user-select: none; @@ -103,7 +126,7 @@ justify-content: center; padding: 0 calc(var(--mini-section-gap) * 0.24); overflow: hidden; - color: #fff; + color: var(--mini-text); font-size: var(--mini-top-font); font-weight: 950; line-height: 1; @@ -116,6 +139,26 @@ border-left: 1px solid rgba(255, 255, 255, 0.24); } +.carrot-mini-hud__cpu { + min-width: 0; + max-width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + line-height: inherit; + letter-spacing: inherit; + text-shadow: inherit; + white-space: nowrap; + cursor: pointer; +} + +.carrot-mini-hud__cpu:focus-visible { + outline: max(1px, calc(2px * var(--mini-ui-scale))) solid rgba(247, 255, 249, 0.78); + outline-offset: calc(3px * var(--mini-ui-scale)); +} + /* ── Main region: limit | detail, or stock ───────────────────── */ .carrot-mini-hud__main { position: relative; @@ -123,7 +166,7 @@ min-height: 0; display: grid; grid-template-columns: minmax(0, 48fr) minmax(0, 52fr); - column-gap: calc(var(--mini-section-gap) * 1.10); + column-gap: calc(var(--mini-section-gap) * 1.28); align-items: center; padding: 0 calc(var(--mini-padding) * 0.18); } @@ -207,158 +250,367 @@ display: none !important; } +/* ── US MUTCD rectangular sign (imperial) — same CSS technique as the circle, + only the shape/colors differ; toggled by root[data-limit-style="us"]. ── */ +.carrot-mini-hud__limit-us-caption { + display: none; +} + +.carrot-mini-hud[data-limit-style="us"] .carrot-mini-hud__limit-sign { + width: calc(var(--mini-limit-size) * 0.82); + height: var(--mini-limit-size); + border-radius: calc(var(--mini-limit-size) * 0.09); + border: max(3px, calc(var(--mini-limit-size) * 0.052)) solid #050807; + background: #ffffff; + gap: calc(var(--mini-limit-size) * 0.015); + padding: calc(var(--mini-limit-size) * 0.055) 0; + box-shadow: + 0 14px 30px rgba(0, 0, 0, 0.40), + inset 0 0 0 2px rgba(0, 0, 0, 0.05); +} + +.carrot-mini-hud[data-limit-style="us"] .carrot-mini-hud__limit-us-caption { + display: flex; + flex-direction: column; + align-items: center; + gap: calc(var(--mini-limit-size) * 0.006); + color: #050807; + font-size: var(--mini-limit-caption-font); + font-weight: 900; + line-height: 0.98; + letter-spacing: 0.01em; + white-space: nowrap; +} + +.carrot-mini-hud[data-limit-style="us"] .carrot-mini-hud__limit-sign > span { + line-height: 0.86; +} + +/* US sign carries no Korean SDI decorations */ +.carrot-mini-hud[data-limit-style="us"] .carrot-mini-hud__limit-badge, +.carrot-mini-hud[data-limit-style="us"] .carrot-mini-hud__limit-sign > small { + display: none !important; +} + +/* ── View-test badge (mini_hud_demo.js, ?mhud_test=1 only) ── */ +.carrot-mini-hud .carrot-mini-hud__demo-tag { + position: absolute; /* beats the `.carrot-mini-hud > *` position:relative */ + top: 3px; + left: 3px; + z-index: 20; + padding: 1px 6px; + border-radius: 5px; + background: rgba(0, 0, 0, 0.58); + color: #8fe3ff; + font: 800 10px/1.25 system-ui, -apple-system, "Segoe UI", sans-serif; + letter-spacing: 0.02em; + white-space: nowrap; + pointer-events: none; +} + +.carrot-mini-hud .carrot-mini-hud__demo-tag[hidden] { + display: none !important; +} + /* ── Detail (alert) zone: countdown / distance / type ────────── */ .carrot-mini-hud__alert { grid-column: 2; grid-row: 1; justify-self: stretch; - align-self: stretch; + align-self: center; min-width: 0; + height: var(--mini-detail-height); min-height: 0; - display: flex; - flex-direction: column; - justify-content: center; - gap: calc(var(--mini-section-gap) * 1.90); + max-height: 100%; + display: grid; + grid-template-rows: repeat(4, minmax(0, 1fr)); + align-content: stretch; + align-items: stretch; + gap: calc(var(--mini-section-gap) * 0.52); padding: - 0 - calc(var(--mini-padding) * 0.06) - 0 - clamp(6px, calc(var(--mini-padding) * 1.05), 24px); + calc(var(--mini-section-gap) * 0.18) + calc(var(--mini-padding) * 0.34) + calc(var(--mini-section-gap) * 0.18) + calc(var(--mini-padding) * 0.10); } -.carrot-mini-hud__alert > * { - min-width: 0; - color: #fff; - font-size: var(--mini-alert-font); - font-weight: 950; - line-height: 0.92; - letter-spacing: 0; - text-shadow: var(--mini-text-shadow-strong); - font-variant-numeric: tabular-nums; - white-space: nowrap; +.carrot-mini-hud__limit-visual { + position: relative; + width: max-content; + height: max-content; + display: grid; + place-items: center; } -.carrot-mini-hud__alert > strong { - color: #ff6a76; +.carrot-mini-hud__alert-badge { + --mini-alert-badge-glow: rgba(53, 242, 160, 0.48); + position: absolute; + top: calc(var(--mini-limit-size) * -0.075); + right: calc(var(--mini-limit-size) * -0.09); + z-index: 3; + width: calc(var(--mini-limit-size) * 0.47); + height: calc(var(--mini-limit-size) * 0.47); + object-fit: contain; + filter: + drop-shadow(1.5px 0 0 rgba(255, 255, 255, 0.98)) + drop-shadow(-1.5px 0 0 rgba(255, 255, 255, 0.98)) + drop-shadow(0 1.5px 0 rgba(255, 255, 255, 0.98)) + drop-shadow(0 -1.5px 0 rgba(255, 255, 255, 0.98)) + drop-shadow(1px 1px 0 rgba(255, 255, 255, 0.90)) + drop-shadow(-1px -1px 0 rgba(255, 255, 255, 0.90)) + drop-shadow(0 calc(8px * var(--mini-ui-scale)) calc(9px * var(--mini-ui-scale)) rgba(0, 0, 0, 0.76)) + drop-shadow(0 0 calc(12px * var(--mini-ui-scale)) var(--mini-alert-badge-glow)); + animation: carrot-mini-hud-alert-badge-in 180ms ease-out; +} + +.carrot-mini-hud__alert-badge[data-kind="police"] { + --mini-alert-badge-glow: rgba(78, 153, 255, 0.58); +} + +.carrot-mini-hud[data-limit-style="us"] .carrot-mini-hud__alert-badge { + top: calc(var(--mini-limit-size) * -0.10); + right: calc(var(--mini-limit-size) * -0.17); +} + +.carrot-mini-hud__alert-badge[hidden] { + display: none !important; } -.carrot-mini-hud[data-alert-kind="police"] .carrot-mini-hud__alert > strong, -.carrot-mini-hud[data-alert-kind="bump"] .carrot-mini-hud__alert > strong { - color: #ffc24b; +@keyframes carrot-mini-hud-alert-badge-in { + from { opacity: 0; transform: scale(0.76); } + to { opacity: 1; transform: scale(1); } } -/* ── Stock zone (source = STOCK): drive mode + gear ──────────── */ -.carrot-mini-hud__stock { - grid-column: 1 / -1; +.carrot-mini-hud__stock-mode { + grid-column: 1; grid-row: 1; align-self: stretch; min-width: 0; min-height: 0; - display: grid; - grid-template-columns: minmax(0, 48fr) minmax(0, 52fr); + display: flex; align-items: center; + justify-content: center; } -.carrot-mini-hud__stock[hidden], -.carrot-mini-hud__limit[hidden], -.carrot-mini-hud__alert[hidden] { +.carrot-mini-hud__stock-mode[hidden] { display: none !important; } -.carrot-mini-hud__drive-mode, -.carrot-mini-hud__gear { +.carrot-mini-hud__drive-mode { + --mini-mode-accent: #eaf4ef; + --mini-mode-fill: #050a08; + position: relative; + overflow: visible; + width: var(--mini-mode-size); min-width: 0; + height: auto; + min-height: 0; display: flex; align-items: center; justify-content: center; + padding: calc(var(--mini-mode-font) * 0.12) 0; } -.carrot-mini-hud__drive-mode { - width: 92%; - justify-self: center; - min-height: calc(var(--mini-mode-font) * 1.62); - padding: 0 calc(var(--mini-mode-font) * 0.18); - border: max(1px, calc(2px * var(--mini-ui-scale))) solid rgba(255, 255, 255, 0.28); - border-radius: 999px; - background: rgba(255, 255, 255, 0.035); - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.10); +.carrot-mini-hud__drive-mode > span { + max-width: none; + overflow: visible; + color: var(--mini-mode-fill); + font-family: inherit; + font-size: var(--mini-mode-font); + font-stretch: condensed; + font-weight: 950; + line-height: 0.94; + letter-spacing: 0; + -webkit-text-stroke: max(2px, calc(2.6px * var(--mini-ui-scale))) rgba(255, 255, 255, 0.98); + paint-order: stroke fill; + text-shadow: 0 calc(2px * var(--mini-ui-scale)) calc(4px * var(--mini-ui-scale)) rgba(0, 0, 0, 0.72); + white-space: nowrap; +} + +.carrot-mini-hud__drive-mode.is-demo-interactive { + cursor: pointer; + touch-action: manipulation; +} + +.carrot-mini-hud__drive-mode.is-demo-interactive:active { + transform: translateY(calc(1px * var(--mini-ui-scale))); +} + +.carrot-mini-hud__drive-mode.is-demo-interactive:focus-visible { + outline: max(1px, calc(2px * var(--mini-ui-scale))) solid var(--mini-mode-accent); + outline-offset: calc(3px * var(--mini-ui-scale)); } .carrot-mini-hud__drive-mode[data-mini-hud-drive-kind="eco"] { - border-color: rgba(73, 221, 130, 0.42); + --mini-mode-accent: #49dd82; } .carrot-mini-hud__drive-mode[data-mini-hud-drive-kind="safe"] { - border-color: rgba(120, 213, 238, 0.42); + --mini-mode-accent: #78d5ee; } .carrot-mini-hud__drive-mode[data-mini-hud-drive-kind="sport"] { - border-color: rgba(255, 102, 115, 0.42); + --mini-mode-accent: #ff6673; } -.carrot-mini-hud__drive-mode > span { - max-width: 100%; - overflow: hidden; - color: #fff; - font-family: "Roboto Condensed", "Arial Narrow", "Noto Sans Condensed", "sans-serif-condensed", sans-serif; - font-size: var(--mini-mode-font); - font-stretch: condensed; +.carrot-mini-hud__alert > * { + min-width: 0; + color: var(--mini-text); + font-size: var(--mini-alert-font); font-weight: 950; - line-height: 0.94; + line-height: 1; letter-spacing: 0; - text-overflow: clip; - text-shadow: var(--mini-text-shadow-strong); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.64); + font-variant-numeric: tabular-nums; white-space: nowrap; } -.carrot-mini-hud__drive-mode[data-mini-hud-drive-kind="eco"] > span { - color: #49dd82; +.carrot-mini-hud__alert > strong { + color: var(--mini-accent); } -.carrot-mini-hud__drive-mode[data-mini-hud-drive-kind="safe"] > span { - color: #78d5ee; +.carrot-mini-hud[data-alert-kind="police"] .carrot-mini-hud__alert > strong, +.carrot-mini-hud[data-alert-kind="bump"] .carrot-mini-hud__alert > strong { + color: var(--mini-warning); } -.carrot-mini-hud__drive-mode[data-mini-hud-drive-kind="sport"] > span { - color: #ff6673; +/* ── Detail chips (countdown / distance / temp) ─────────────────────────────── + Rounded-rect chips borrowing the app's --r-md / .chip structure (translucent + hairline border + faint fill), kept in the minihud green palette, no icons. + label = small + dim, value = prominent. */ +.carrot-mini-hud__chip { + width: 100%; + height: 100%; + min-height: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: calc(var(--mini-alert-font) * 0.26); + padding: 0; + border: 1px solid var(--mini-alert-row-border); + border-radius: var(--mini-alert-row-radius); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.052), rgba(255, 255, 255, 0.022)), + var(--mini-alert-row-bg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.052); + line-height: 1; } -.carrot-mini-hud__gear { - align-items: baseline; - gap: calc(4px * var(--mini-ui-scale)); - white-space: nowrap; +.carrot-mini-hud__chip + .carrot-mini-hud__chip { + padding-top: 0; + border-top: 0; } -.carrot-mini-hud__gear small { - color: rgba(255, 255, 255, 0.58); - font-size: calc(var(--mini-gear-font) * 0.34); - font-weight: 900; - line-height: 1; - text-shadow: var(--mini-text-shadow-strong); +.carrot-mini-hud__chip[hidden] { + display: none !important; } -.carrot-mini-hud__gear small[hidden] { - display: none !important; +.carrot-mini-hud__chip[data-mini-hud-empty="1"] > b { + opacity: 0.48; } -.carrot-mini-hud__gear strong { - color: #fff; - font-size: var(--mini-gear-font); +.carrot-mini-hud__chip-label { + align-self: stretch; + width: var(--mini-alert-label-width); + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 calc(var(--mini-alert-font) * 0.16); + border-right: 1px solid rgba(98, 245, 167, 0.16); + border-radius: + calc(var(--mini-alert-row-radius) - 1px) + 0 + 0 + calc(var(--mini-alert-row-radius) - 1px); + background: var(--mini-alert-label-bg); + color: rgba(247, 255, 249, 0.96); + font-size: var(--mini-alert-label-font); font-weight: 950; - line-height: 0.9; letter-spacing: 0; + text-transform: uppercase; + line-height: 1.05; +} + +.carrot-mini-hud__chip > b { + min-width: 0; + justify-self: start; + text-align: left; + padding-right: calc(var(--mini-alert-font) * 0.30); + font-weight: 950; font-variant-numeric: tabular-nums; - text-shadow: var(--mini-text-shadow-strong); +} + +.carrot-mini-hud__gap-value { + width: 100%; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: calc(var(--mini-alert-font) * 0.18); +} + +.carrot-mini-hud__gap-signal { + width: 100%; + min-width: 0; + height: calc(var(--mini-alert-font) * 0.34); + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: stretch; + gap: calc(var(--mini-alert-font) * 0.085); +} + +.carrot-mini-hud__gap-signal > i { + width: 100%; + height: 100%; + border-radius: calc(var(--mini-alert-font) * 0.10); + background: rgba(247, 255, 249, 0.22); + box-shadow: inset 0 0 0 1px rgba(247, 255, 249, 0.09); +} + +.carrot-mini-hud__gap-signal[data-level="1"] > i:nth-child(-n+1), +.carrot-mini-hud__gap-signal[data-level="2"] > i:nth-child(-n+2), +.carrot-mini-hud__gap-signal[data-level="3"] > i:nth-child(-n+3), +.carrot-mini-hud__gap-signal[data-level="4"] > i:nth-child(-n+4) { + background: var(--mini-accent); + box-shadow: 0 0 calc(5px * var(--mini-ui-scale)) rgba(53, 242, 160, 0.32); +} + +/* temp chip carries the accel/decel color; its rounded shape comes from .chip. */ +.carrot-mini-hud__temp { + /* Wins over the reddish `.carrot-mini-hud__alert > strong` and the police/bump + amber rules above, which target the same element slot. */ + color: var(--mini-accent) !important; +} + +.carrot-mini-hud__temp[data-mini-hud-temp-decel="1"] { + color: var(--mini-warning) !important; +} + +.carrot-mini-hud[data-source="nav"] [data-mini-hud-temp-zone] { + grid-row: 3; +} + +.carrot-mini-hud[data-source="nav"] [data-mini-hud-gap-zone] { + grid-row: 4; +} + +/* Shared visibility rules for the main-band zones. */ +.carrot-mini-hud__limit[hidden], +.carrot-mini-hud__alert[hidden] { + display: none !important; } /* ── Speed row: current speed (70%) | set speed (30%) ────────── */ .carrot-mini-hud__speed-row { + position: relative; flex: 0 0 34%; min-height: 0; display: grid; - grid-template-columns: minmax(0, 70fr) minmax(0, 30fr); + grid-template-columns: + minmax(0, var(--mini-speed-slot-width)) + minmax(0, var(--mini-set-slot-width)); + justify-content: center; align-items: end; - column-gap: 0; + column-gap: var(--mini-speed-gap); margin-bottom: calc(var(--mini-section-gap) * 0.26); } @@ -377,27 +629,69 @@ } .carrot-mini-hud__speed { - justify-content: flex-start; + justify-content: center; padding: calc(var(--mini-padding) * 0.18) + 0 calc(var(--mini-padding) * 0.10) - calc(var(--mini-padding) * 0.10) - calc(var(--mini-padding) * 0.16); + 0; color: #fff; font-size: var(--mini-speed-font); line-height: 0.84; + text-align: center; } .carrot-mini-hud__set-speed { - justify-content: flex-end; + justify-content: center; padding: calc(var(--mini-padding) * 0.18) - calc(var(--mini-padding) * 0.16) + 0 calc(var(--mini-padding) * 0.10) - calc(var(--mini-padding) * 0.10); + 0; color: #f4f7fb; font-size: var(--mini-set-font); line-height: 0.92; + text-align: center; +} + +.carrot-mini-hud__gear-badge { + position: absolute; + left: var(--mini-gear-left); + right: auto; + bottom: var(--mini-gear-bottom); + min-width: calc(var(--mini-gear-font) * 2.6); + height: var(--mini-gear-height); + display: inline-flex; + align-items: center; + justify-content: center; + gap: calc(3px * var(--mini-ui-scale)); + padding: 0 calc(var(--mini-gear-font) * 0.40); + border: max(1px, calc(1.5px * var(--mini-ui-scale))) solid rgba(247, 255, 249, 0.34); + border-radius: calc(var(--mini-gear-font) * 0.43); + background: rgba(5, 17, 12, 0.90); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 calc(3px * var(--mini-ui-scale)) calc(7px * var(--mini-ui-scale)) rgba(0, 0, 0, 0.46); + color: rgba(247, 255, 249, 0.86); + font-size: var(--mini-gear-font); + font-weight: 900; + line-height: 1; + text-shadow: var(--mini-text-shadow-strong); + white-space: nowrap; +} + +.carrot-mini-hud__gear-badge[hidden] { + display: none !important; +} + +.carrot-mini-hud__gear-badge > strong { + color: #ffffff; + font-size: inherit; + font-variant-numeric: tabular-nums; +} + +.carrot-mini-hud__gear-badge[data-mini-hud-gear-known="0"] { + opacity: 0.64; } .carrot-mini-hud__speed > span, @@ -416,12 +710,6 @@ transform: scaleX(var(--mini-set-scale-x)); } -.carrot-mini-hud.is-three-digit .carrot-mini-hud__speed, -.carrot-mini-hud.is-three-digit .carrot-mini-hud__set-speed { - justify-content: center; - text-align: center; -} - .carrot-mini-hud.is-three-digit .carrot-mini-hud__speed > span, .carrot-mini-hud.is-three-digit .carrot-mini-hud__set-speed > span { font-family: "Roboto Condensed", "Arial Narrow", "Noto Sans Condensed", "sans-serif-condensed", sans-serif; diff --git a/openpilot/selfdrive/carrot/web/css/pages/drive.css b/openpilot/selfdrive/carrot/web/css/pages/drive.css index ba1520f2f9..e1ffed922e 100644 --- a/openpilot/selfdrive/carrot/web/css/pages/drive.css +++ b/openpilot/selfdrive/carrot/web/css/pages/drive.css @@ -1182,6 +1182,76 @@ body[data-page="carrot"] #driveHudCard.driveHudCard--loading { color: var(--md-primary); } +.carrot-stage__controlBtn--sound { + min-width: 68px; +} + +.carrot-stage__controlBtn--sound.is-active { + border-color: color-mix(in srgb, #49dd82 72%, rgba(255, 255, 255, 0.22)); + background: color-mix(in srgb, #49dd82 18%, var(--md-surface-cont-h)); + color: #b9ffd2; + box-shadow: 0 0 0 1px rgba(73, 221, 130, 0.12), 0 0 14px rgba(73, 221, 130, 0.12), 0 8px 22px rgba(0, 0, 0, 0.24); +} + +.app-dialog--web-sound .app-dialog__sheet { + width: min(calc(100vw - 32px), 430px); +} + +.app-dialog--web-sound .app-dialog__body { + white-space: normal; +} + +.web-sound-dialog { + display: grid; + gap: 18px; +} + +.web-sound-dialog__description { + margin: 0; + color: var(--md-on-surface-var); + font-size: 14px; + line-height: 1.55; +} + +.web-sound-dialog__toggle { + min-height: 58px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 0 4px; + color: var(--md-on-surface); + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.web-sound-dialog__volume { + display: grid; + gap: 10px; + padding: 2px 4px 0; + color: var(--md-on-surface); + font-size: 14px; + font-weight: 800; +} + +.web-sound-dialog__volume-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.web-sound-dialog__volume-head output { + color: var(--md-primary); + font-variant-numeric: tabular-nums; +} + +.web-sound-dialog__volume-slider { + width: 100%; + accent-color: var(--md-primary); +} + .carrot-stage__controlBtn--record { min-width: 74px; } diff --git a/openpilot/selfdrive/carrot/web/index.html b/openpilot/selfdrive/carrot/web/index.html index 37816d7682..789fe40245 100644 --- a/openpilot/selfdrive/carrot/web/index.html +++ b/openpilot/selfdrive/carrot/web/index.html @@ -132,9 +132,9 @@ - + - + @@ -527,7 +527,8 @@

Home

waiting stream...
LD:- LT:- SR:-
-
+
+
@@ -541,7 +542,7 @@

Home

@@ -780,9 +808,9 @@

Home

- - - + + + @@ -808,6 +836,7 @@

Home

+ @@ -817,9 +846,10 @@

Home

- - - + + + + diff --git a/openpilot/selfdrive/carrot/web/js/realtime/mini_hud.js b/openpilot/selfdrive/carrot/web/js/realtime/mini_hud.js index 1bbc79e8b0..b52961c87b 100644 --- a/openpilot/selfdrive/carrot/web/js/realtime/mini_hud.js +++ b/openpilot/selfdrive/carrot/web/js/realtime/mini_hud.js @@ -11,14 +11,26 @@ limit: root.querySelector("[data-mini-hud-limit]"), limitLabel: root.querySelector("[data-mini-hud-limit-label]"), limitBadge: root.querySelector("[data-mini-hud-limit-badge]"), + alertBadge: root.querySelector("[data-mini-hud-alert-badge]"), + stockMode: root.querySelector("[data-mini-hud-stock-mode]"), + driveMode: root.querySelector("[data-mini-hud-drive-mode]"), + driveModeFrame: root.querySelector(".carrot-mini-hud__drive-mode"), alertZone: root.querySelector("[data-mini-hud-alert-zone]"), + countdownZone: root.querySelector("[data-mini-hud-countdown-zone]"), + countdownLabel: root.querySelector("[data-mini-hud-countdown-zone] .carrot-mini-hud__chip-label"), countdown: root.querySelector("[data-mini-hud-countdown]"), + distanceZone: root.querySelector("[data-mini-hud-distance-zone]"), + distanceLabel: root.querySelector("[data-mini-hud-distance-zone] .carrot-mini-hud__chip-label"), distance: root.querySelector("[data-mini-hud-distance]"), - alert: root.querySelector("[data-mini-hud-alert]"), - stockZone: root.querySelector("[data-mini-hud-stock-zone]"), - driveMode: root.querySelector("[data-mini-hud-drive-mode]"), - driveModeFrame: root.querySelector(".carrot-mini-hud__drive-mode"), - gearDrive: root.querySelector(".carrot-mini-hud__gear small"), + gapZone: root.querySelector("[data-mini-hud-gap-zone]"), + gapLabel: root.querySelector("[data-mini-hud-gap-zone] .carrot-mini-hud__chip-label"), + gap: root.querySelector("[data-mini-hud-gap]"), + gapSignal: root.querySelector("[data-mini-hud-gap-signal]"), + tempZone: root.querySelector("[data-mini-hud-temp-zone]"), + tempLabel: root.querySelector("[data-mini-hud-temp-label]"), + tempSpeed: root.querySelector("[data-mini-hud-temp-speed]"), + gearBadge: root.querySelector("[data-mini-hud-gear-badge]"), + gearLabel: root.querySelector("[data-mini-hud-gear-label]"), gear: root.querySelector("[data-mini-hud-gear]"), speed: root.querySelector("[data-mini-hud-speed]"), setSpeed: root.querySelector("[data-mini-hud-set-speed]"), @@ -29,9 +41,44 @@ const canvas = document.createElement("canvas"); const context = canvas.getContext("2d"); + const TEMPERATURE_UNIT_KEY = "carrot.miniHud.temperatureUnit.v1"; let latestModel = null; let layoutRaf = 0; let resizeObserver = null; + let temperatureUnit = loadTemperatureUnit(); + let detailLabelsExpanded = null; + + function loadTemperatureUnit() { + try { + return localStorage.getItem(TEMPERATURE_UNIT_KEY) === "f" ? "f" : "c"; + } catch (_) { + return "c"; + } + } + + function saveTemperatureUnit() { + try { + localStorage.setItem(TEMPERATURE_UNIT_KEY, temperatureUnit); + } catch (_) { + // Storage can be unavailable in private or restricted browser contexts. + } + } + + function cpuTemperatureText(celsius) { + if (celsius == null || celsius === "") return `CPU:--°${temperatureUnit.toUpperCase()}`; + const value = Number(celsius); + if (!Number.isFinite(value)) return `CPU:--°${temperatureUnit.toUpperCase()}`; + const display = temperatureUnit === "f" ? (value * 9 / 5) + 32 : value; + return `CPU:${Math.round(display)}°${temperatureUnit.toUpperCase()}`; + } + + function syncCpuTemperature() { + setText(elements.cpu, cpuTemperatureText(latestModel?.cpu)); + if (!elements.cpu) return; + const current = temperatureUnit === "f" ? "Fahrenheit" : "Celsius"; + const next = temperatureUnit === "f" ? "Celsius" : "Fahrenheit"; + elements.cpu.setAttribute("aria-label", `CPU temperature in ${current}. Activate to use ${next}.`); + } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); @@ -47,6 +94,39 @@ if (element) element.hidden = Boolean(hidden); } + function setRowEmpty(element, empty) { + if (element) element.dataset.miniHudEmpty = empty ? "1" : "0"; + } + + function shortLabel(value, fallback = "", maxLength = 5) { + const label = String(value || fallback || "").trim().toUpperCase(); + return label.slice(0, Math.max(1, maxLength)); + } + + function syncDetailLabels(model, maxLength = 3) { + const expanded = maxLength > 1; + setText(elements.countdownLabel, expanded ? "TIM" : "T"); + setText(elements.distanceLabel, expanded ? "DST" : "D"); + setText(elements.gapLabel, expanded ? "GAP" : "G"); + const tempSource = model?.source === "stock" ? "TMP" : model?.temp?.label; + setText(elements.tempLabel, shortLabel( + tempSource, + model?.alert?.name || model?.source || "SRC", + maxLength, + )); + } + + function resolveDetailLabelLength(layoutScale, detailWidth) { + if (detailLabelsExpanded == null) { + detailLabelsExpanded = layoutScale >= 0.63 && detailWidth >= 108; + } else if (detailLabelsExpanded) { + if (layoutScale <= 0.57 || detailWidth <= 94) detailLabelsExpanded = false; + } else if (layoutScale >= 0.68 && detailWidth >= 118) { + detailLabelsExpanded = true; + } + return detailLabelsExpanded ? 3 : 1; + } + function measure(text, size, family = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif') { if (!context) return String(text).length * size * 0.62; context.font = `900 ${size}px ${family}`; @@ -65,6 +145,16 @@ return inner > 4 ? inner : fallback; } + function contentHeight(element, fallback) { + if (!element) return fallback; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + const padding = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0); + const inner = rect.height - padding; + // Same degenerate-frame guard as contentWidth (see above). + return inner > 4 ? inner : fallback; + } + function render(model) { if (!model) return; latestModel = model; @@ -74,10 +164,11 @@ const alertVisible = !stock && Boolean(model.alert?.visible); root.dataset.source = model.source; + root.dataset.limitStyle = model.limitStyle || "kr"; root.dataset.alertKind = model.alert?.kind || "none"; root.classList.toggle("is-three-digit", String(model.speed).length >= 3 || String(model.setSpeed).length >= 3); - setText(elements.cpu, `CPU:${model.cpu == null ? "--" : model.cpu}°`); + syncCpuTemperature(); setText(elements.source, model.source.toUpperCase()); setText(elements.speed, model.speed); setText(elements.setSpeed, model.setSpeed); @@ -89,18 +180,47 @@ setText(elements.limitBadge, model.alert?.badge || ""); setHidden(elements.limitBadge, !model.alert?.badge); - setHidden(elements.alertZone, !alertVisible); - setText(elements.countdown, model.alert?.countdown || ""); - setHidden(elements.countdown, !model.alert?.countdown); - setText(elements.distance, model.alert?.distance || ""); - setHidden(elements.distance, !model.alert?.distance); - setText(elements.alert, model.alert?.name || ""); + const alertBadgeKind = limitVisible && alertVisible && ["camera", "police"].includes(model.alert?.kind) + ? model.alert.kind + : ""; + if (elements.alertBadge && alertBadgeKind) { + const source = `/assets/alert_${alertBadgeKind}.svg`; + if (elements.alertBadge.getAttribute("src") !== source) elements.alertBadge.setAttribute("src", source); + elements.alertBadge.dataset.kind = alertBadgeKind; + } + setHidden(elements.alertBadge, !alertBadgeKind); - setHidden(elements.stockZone, !stock); + setHidden(elements.stockMode, !stock); setText(elements.driveMode, model.driveMode?.name || "NORMAL"); if (elements.driveModeFrame) elements.driveModeFrame.dataset.miniHudDriveKind = model.driveMode?.kind || "normal"; - setText(elements.gear, model.gearStep || "--"); - setHidden(elements.gearDrive, model.gearStep == null); + + const tempVisible = !stock && Boolean(model.temp?.visible); + setHidden(elements.alertZone, false); + syncDetailLabels(model, detailLabelsExpanded === false ? 1 : 3); + setText(elements.countdown, model.alert?.countdown || "--"); + setHidden(elements.countdownZone, false); + setRowEmpty(elements.countdownZone, !model.alert?.countdown); + setText(elements.distance, model.alert?.distance || "--"); + setHidden(elements.distanceZone, false); + setRowEmpty(elements.distanceZone, !model.alert?.distance); + setText(elements.gap, model.gap || "--"); + if (elements.gapSignal) elements.gapSignal.dataset.level = String(clamp(Number(model.gap) || 0, 0, 4)); + setHidden(elements.gapZone, false); + setRowEmpty(elements.gapZone, !model.gap); + setText(elements.tempSpeed, tempVisible ? model.temp?.speed : "--"); + setHidden(elements.tempZone, false); + setRowEmpty(elements.tempZone, !tempVisible); + if (elements.tempZone) elements.tempZone.dataset.miniHudTempDecel = model.temp?.decel ? "1" : "0"; + + const gearStep = model.gearStep == null ? "" : String(model.gearStep); + const gearLabel = String(model.gear || (gearStep ? "D" : "")).trim().toUpperCase(); + const gearKnown = Boolean(gearLabel || gearStep); + setText(elements.gearLabel, gearLabel); + setHidden(elements.gearLabel, !gearLabel); + setText(elements.gear, gearStep || (gearKnown ? "" : "--")); + setHidden(elements.gear, gearKnown && !gearStep); + setHidden(elements.gearBadge, false); + if (elements.gearBadge) elements.gearBadge.dataset.miniHudGearKnown = gearKnown ? "1" : "0"; scheduleLayout(); } @@ -117,13 +237,17 @@ return; } const width = Math.max(1, rect.width); - // Width-referenced scale, exactly like the preview (reference width 404). - const layoutScale = clamp(width / 404, 0.46, 1); + // Scale the whole compact surface from both axes. Short multi-window layouts + // must shrink the same tokens as narrow layouts instead of overflowing. + const layoutScale = clamp(Math.min(width / 404, rect.height / 650), 0.46, 1); + const style = root.style; + style.setProperty("--mini-ui-scale", layoutScale.toFixed(4)); + style.setProperty("--mini-detail-height", `${(220 * layoutScale).toFixed(1)}px`); const topCells = root.querySelectorAll(".carrot-mini-hud__top-cell"); const tempCellWidth = contentWidth(topCells[0], width * 0.50); const sourceCellWidth = contentWidth(topCells[1], width * 0.50); - const tempText = elements.cpu?.textContent || "CPU:--°"; + const tempText = elements.cpu?.textContent || "CPU:--°C"; const sourceText = elements.source?.textContent || "STOCK"; const tempUnit = Math.max(1, measure(tempText, 100) / 100); const sourceUnit = Math.max(1, measure(sourceText, 100) / 100); @@ -132,69 +256,110 @@ const speedText = elements.speed?.textContent || "--"; const cruiseText = elements.setSpeed?.textContent || "--"; - const speedColWidth = contentWidth(elements.speedCell, width * 0.70); - const cruiseColWidth = contentWidth(elements.setSpeedCell, width * 0.30); - const baseSpeedUnit = Math.max(1, measure("72", 100) / 100); - const baseCruiseUnit = Math.max(1, measure("80", 100) / 100); - const speedSize = Math.max(1, Math.min(220, speedColWidth / baseSpeedUnit)); - const cruiseSize = Math.max(1, Math.min(104, cruiseColWidth / baseCruiseUnit)); + const speedRowWidth = contentWidth(elements.speedCell?.parentElement, width); + const speedGap = clamp(width * 0.030, 8 * layoutScale, 18 * layoutScale); + const speedGroupWidth = Math.max(1, Math.min(speedRowWidth * 0.96, width * 0.94)); + const speedGroupLeft = Math.max(0, (speedRowWidth - speedGroupWidth) * 0.5); + // Also bound by the speed-ROW height so digits don't overflow (and clip + // against the fixed-inset root) on short viewports. Measure the row, not the + // cell: the cell is `align-items:end` grid content whose height IS the glyph + // line-box, so feeding it back into the font size ping-pongs the size. The + // row is a fixed 34% flex-basis and stays stable regardless of font. + const speedRowHeight = contentHeight(elements.speedCell?.parentElement, rect.height * 0.30); const speedFamily = getComputedStyle(elements.speed).fontFamily; const cruiseFamily = getComputedStyle(elements.setSpeed).fontFamily; + const baseSpeedSlotText = "88"; + const baseCruiseSlotText = "88"; + const baseSpeedUnit = Math.max(1, measure(baseSpeedSlotText, 100, speedFamily) / 100); + const baseCruiseUnit = Math.max(1, measure(baseCruiseSlotText, 100, cruiseFamily) / 100); + const stableCruiseSize = Math.max(1, Math.min( + 124, + ((speedGroupWidth - speedGap) * 0.32) / baseCruiseUnit, + speedRowHeight * 0.66, + )); + const gearBottom = stableCruiseSize * 0.92; + const gearFont = stableCruiseSize * 0.30; + const gearHeight = stableCruiseSize * 0.52; + const cruiseShare = 0.32; + const speedShare = 1 - cruiseShare; + const speedMaxWidth = Math.max(1, (speedGroupWidth - speedGap) * speedShare); + const cruiseMaxWidth = Math.max(1, (speedGroupWidth - speedGap) * cruiseShare); + const gearLeft = speedGroupLeft + speedMaxWidth + speedGap; + const speedSize = Math.max(1, Math.min(240, speedMaxWidth / baseSpeedUnit, speedRowHeight * 1.10)); + const cruiseSize = Math.max(1, Math.min(124, cruiseMaxWidth / baseCruiseUnit, speedRowHeight * 0.66)); const speedRendered = Math.max(1, measure(speedText, speedSize, speedFamily)); const cruiseRendered = Math.max(1, measure(cruiseText, cruiseSize, cruiseFamily)); - const speedScaleX = speedText.length > 2 ? Math.min(1, speedColWidth * 0.98 / speedRendered) : 1; - const cruiseScaleX = cruiseText.length > 2 ? Math.min(1, cruiseColWidth * 0.98 / cruiseRendered) : 1; + const speedColWidth = speedMaxWidth; + const cruiseColWidth = cruiseMaxWidth; + const speedScaleX = speedText.length > 2 ? Math.min(1, speedMaxWidth * 0.98 / speedRendered) : 1; + const cruiseScaleX = cruiseText.length > 2 ? Math.min(1, cruiseMaxWidth * 0.98 / cruiseRendered) : 1; // Limit / detail / stock fonts are measured from their own zone rects, exactly // like the preview's syncHudScale (not the whole main region). const limitRect = elements.limitZone?.getBoundingClientRect?.(); + const mainRect = elements.main?.getBoundingClientRect?.(); const limitAreaWidth = limitRect?.width || width * 0.52; - const limitAreaHeight = limitRect?.height || width * 0.55; - const limitSize = Math.max(42, Math.min(220, limitAreaWidth * 0.90, limitAreaHeight * 0.86)); + const limitAreaHeight = limitRect?.height || mainRect?.height || width * 0.55; + const limitSize = Math.max(46, Math.min(250, limitAreaWidth * 0.98, limitAreaHeight * 0.94)); const limitInnerWidth = Math.max(1, limitSize * 0.66); const limitNumUnit = Math.max(1, measure(elements.limit?.textContent || "110", 100) / 100); const limitLabelUnit = Math.max(1, measure(elements.limitLabel?.textContent || "", 100) / 100); const limitBadgeUnit = Math.max(1, measure(elements.limitBadge?.textContent || "", 100) / 100); - const limitFont = Math.max(14, Math.min(72, limitSize * 0.40, limitInnerWidth / limitNumUnit)); - const limitLabelFont = Math.max(7, Math.min(26, limitSize * 0.142, limitInnerWidth / limitLabelUnit)); - const badgeFont = Math.max(10, Math.min(34, limitSize * 0.19, (limitSize * 0.80) / limitBadgeUnit)); + const limitFont = Math.max(14, Math.min(78, limitSize * 0.40, limitInnerWidth / limitNumUnit)); + const limitLabelFont = Math.max(7, Math.min(28, limitSize * 0.142, limitInnerWidth / limitLabelUnit)); + const badgeFont = Math.max(10, Math.min(38, limitSize * 0.19, (limitSize * 0.80) / limitBadgeUnit)); + + // US MUTCD rectangle: the number fills a wider/taller area than the circle, + // with a stacked SPEED/LIMIT caption above it (sized off the same limitSize). + const isUsLimit = root.dataset.limitStyle === "us"; + const usInnerWidth = Math.max(1, limitSize * 0.82 * 0.82); + const usCaptionUnit = Math.max(1, measure("LIMIT", 100) / 100); + const limitNumberFont = isUsLimit + ? Math.max(16, Math.min(108, limitSize * 0.52, usInnerWidth / limitNumUnit)) + : limitFont; + const limitCaptionFont = Math.max(7, Math.min(34, limitSize * 0.145, usInnerWidth / usCaptionUnit)); const detailRect = elements.alertZone?.getBoundingClientRect?.(); - const detailWidth = detailRect?.width || width * 0.48; - const detailHeight = detailRect?.height || width * 0.45; - const detailUnit = Math.max( - measure(elements.countdown?.textContent || "", 100) / 100, - measure(elements.distance?.textContent || "", 100) / 100, - measure(elements.alert?.textContent || "", 100) / 100, - 1, - ); - const detailFont = Math.max(1, Math.min(46, detailWidth * 0.88 / detailUnit, detailHeight * 0.165)); - - const stockRect = elements.stockZone?.getBoundingClientRect?.(); - const stockWidth = stockRect?.width || width; - const stockHeight = stockRect?.height || width * 0.60; - const modeUnit = Math.max(1, measure("NORMAL", 100, getComputedStyle(elements.driveMode).fontFamily) / 100); - const modeFrameWidth = stockWidth * 0.48 * 0.92; - const modeFont = Math.max(1, Math.min(80, modeFrameWidth / (modeUnit + 0.36), stockHeight * 0.28)); - const gearUnit = Math.max(1, measure(elements.gear?.textContent || "4", 100) / 100); - const gearDriveUnit = Math.max(0, measure("D", 100) * 0.34 / 100); - const gearWidth = Math.max(1, stockWidth * 0.52 * 0.88 - 4 * layoutScale); - const gearFont = Math.max(1, Math.min(180, gearWidth / (gearUnit + gearDriveUnit), stockHeight * 0.50)); + const detailWidth = contentWidth(elements.alertZone, width * 0.48); + const detailHeight = contentHeight(elements.alertZone, detailRect?.height || width * 0.45); + const labelLength = resolveDetailLabelLength(layoutScale, detailWidth); + syncDetailLabels(latestModel, labelLength); + // Keep the detail area as fixed rows; empty rows stay in the scale budget. + const chipCount = 4; + const detailLabelUnit = 2.24; + const detailValueUnit = 3.40; + const detailLineUnit = detailValueUnit + detailLabelUnit + 0.72; + const detailFontH = (detailHeight / chipCount) * 0.76; + const detailFontW = (detailWidth * 0.90) / detailLineUnit; + const detailFont = Math.max(16 * layoutScale, Math.min(78, detailFontW, detailFontH)); + + const modeFamily = getComputedStyle(elements.driveMode).fontFamily; + const modeUnit = Math.max(1, measure("NORMAL", 100, modeFamily) / 100); + const modeSize = limitSize; + const modeFont = Math.max(1, Math.min(72, modeSize * 0.80 / modeUnit, modeSize * 0.25)); - const style = root.style; - style.setProperty("--mini-ui-scale", layoutScale.toFixed(4)); style.setProperty("--mini-top-font", `${Math.min(tempFont, sourceFont).toFixed(1)}px`); + style.setProperty("--mini-speed-slot-width", `${speedColWidth.toFixed(1)}px`); + style.setProperty("--mini-set-slot-width", `${cruiseColWidth.toFixed(1)}px`); + style.setProperty("--mini-speed-gap", `${speedGap.toFixed(1)}px`); + style.setProperty("--mini-gear-left", `${gearLeft.toFixed(1)}px`); + style.setProperty("--mini-gear-bottom", `${gearBottom.toFixed(1)}px`); + style.setProperty("--mini-gear-font", `${gearFont.toFixed(1)}px`); + style.setProperty("--mini-gear-height", `${gearHeight.toFixed(1)}px`); style.setProperty("--mini-speed-font", `${speedSize.toFixed(1)}px`); style.setProperty("--mini-set-font", `${cruiseSize.toFixed(1)}px`); style.setProperty("--mini-speed-scale-x", speedScaleX.toFixed(4)); style.setProperty("--mini-set-scale-x", cruiseScaleX.toFixed(4)); style.setProperty("--mini-limit-size", `${limitSize.toFixed(1)}px`); - style.setProperty("--mini-limit-font", `${limitFont.toFixed(1)}px`); + style.setProperty("--mini-limit-font", `${limitNumberFont.toFixed(1)}px`); + style.setProperty("--mini-limit-caption-font", `${limitCaptionFont.toFixed(1)}px`); style.setProperty("--mini-limit-label-font", `${limitLabelFont.toFixed(1)}px`); style.setProperty("--mini-badge-font", `${badgeFont.toFixed(1)}px`); style.setProperty("--mini-alert-font", `${detailFont.toFixed(1)}px`); + style.setProperty("--mini-alert-label-font", `${(detailFont * 0.46).toFixed(1)}px`); + style.setProperty("--mini-alert-label-width", `${(detailFont * detailLabelUnit).toFixed(1)}px`); + style.setProperty("--mini-mode-size", `${modeSize.toFixed(1)}px`); style.setProperty("--mini-mode-font", `${modeFont.toFixed(1)}px`); - style.setProperty("--mini-gear-font", `${gearFont.toFixed(1)}px`); } function scheduleLayout() { @@ -204,6 +369,13 @@ function init() { window.CarrotMiniHudMode?.bind?.(); + elements.cpu?.addEventListener("click", (event) => { + event.stopPropagation(); + temperatureUnit = temperatureUnit === "c" ? "f" : "c"; + saveTemperatureUnit(); + syncCpuTemperature(); + scheduleLayout(); + }); const onResize = () => scheduleLayout(); window.addEventListener("resize", onResize, { passive: true }); window.addEventListener("orientationchange", onResize, { passive: true }); diff --git a/openpilot/selfdrive/carrot/web/js/realtime/mini_hud_demo.js b/openpilot/selfdrive/carrot/web/js/realtime/mini_hud_demo.js new file mode 100644 index 0000000000..352ad76faf --- /dev/null +++ b/openpilot/selfdrive/carrot/web/js/realtime/mini_hud_demo.js @@ -0,0 +1,261 @@ +"use strict"; + +// Compact-HUD view tester. Active only with ?mhud_test=1 in the URL (which also +// makes mini_hud_mode force the HUD on). It renders mock *models* straight into +// CarrotMiniHud so the layout/styling can be checked with no vehicle connected — +// tap the HUD to step to the next scenario; after a short idle it clears back to +// a plain STOCK view. Without the param the guard below no-ops immediately. +(function () { + if (!new URLSearchParams(window.location.search).has("mhud_test")) return; + + // Base model matching CarrotMiniHudModel.build()'s output shape; scenarios + // override only the fields they exercise. + function model(over) { + return Object.assign({ + source: "nav", + isMetric: true, + limitStyle: "kr", + cpu: 58, + speed: "0", + setSpeed: "0", + roadLimit: "--", + gap: "3", + temp: { visible: false, label: "", speed: "", decel: false }, + alert: { visible: false, name: "", kind: "none", distance: "", countdown: "", badge: "", section: false }, + driveMode: { kind: "normal", name: "NORMAL" }, + gear: "D", + gearStep: null, + }, over || {}); + } + + const STOCK = model({ + source: "stock", cpu: 55, speed: "48", setSpeed: "60", gap: "2", gearStep: 4, + }); + + const SCENARIOS = [ + { name: "STOCK", model: STOCK }, + { name: "LIVE / 순정", live: true, model: STOCK }, + { + name: "NAV road (KR)", + model: model({ + speed: "77", setSpeed: "80", roadLimit: "70", gap: "3", gearStep: 6, + temp: { visible: true, label: "road", speed: "80", decel: false }, + }), + }, + { + name: "CAM alert", + model: model({ + speed: "72", setSpeed: "80", roadLimit: "70", gap: "3", + alert: { visible: true, name: "CAM", kind: "camera", distance: "320m", countdown: "8s", badge: "", section: false }, + temp: { visible: true, label: "cam", speed: "70", decel: true }, + }), + }, + { + name: "POLICE", + model: model({ + speed: "64", setSpeed: "80", roadLimit: "70", + alert: { visible: true, name: "POLICE", kind: "police", distance: "150m", countdown: "", badge: "", section: false }, + temp: { visible: true, label: "police", speed: "70", decel: true }, + }), + }, + { + name: "SECTION", + model: model({ + speed: "78", setSpeed: "90", roadLimit: "80", gap: "4", + alert: { visible: true, name: "SECTION", kind: "section", distance: "1.2km", countdown: "", badge: "1.2km", section: true }, + temp: { visible: true, label: "section", speed: "80", decel: true }, + }), + }, + { + name: "BUMP", + model: model({ + speed: "40", setSpeed: "60", roadLimit: "50", + alert: { visible: true, name: "BUMP", kind: "bump", distance: "60m", countdown: "", badge: "", section: false }, + temp: { visible: true, label: "bump", speed: "30", decel: true }, + }), + }, + { + name: "temp decel", + model: model({ + speed: "58", setSpeed: "60", roadLimit: "60", + temp: { visible: true, label: "vturn", speed: "45", decel: true }, + }), + }, + { + name: "US limit", + model: model({ + isMetric: false, limitStyle: "us", cpu: 60, speed: "65", setSpeed: "70", roadLimit: "55", + temp: { visible: true, label: "road", speed: "70", decel: false }, + }), + }, + { + name: "3-digit", + model: model({ + speed: "105", setSpeed: "120", roadLimit: "110", gap: "2", + temp: { visible: true, label: "road", speed: "120", decel: false }, + }), + }, + { + name: "s / m / road (all 3)", + model: model({ + speed: "82", setSpeed: "90", roadLimit: "80", gap: "3", + alert: { visible: true, name: "CAM", kind: "camera", distance: "480m", countdown: "12s", badge: "", section: false }, + temp: { visible: true, label: "road", speed: "90", decel: false }, + }), + }, + { + name: "ATC turn", + model: model({ + speed: "60", setSpeed: "80", roadLimit: "70", + temp: { visible: true, label: "atc", speed: "45", decel: true }, + }), + }, + { + name: "route curve", + model: model({ + speed: "72", setSpeed: "90", roadLimit: "90", + temp: { visible: true, label: "route", speed: "58", decel: true }, + }), + }, + { + name: "gas override", + model: model({ + speed: "95", setSpeed: "80", roadLimit: "80", + temp: { visible: true, label: "gas", speed: "95", decel: false }, + }), + }, + { + name: "HDA", + model: model({ + speed: "98", setSpeed: "100", roadLimit: "100", + temp: { visible: true, label: "hda", speed: "100", decel: false }, + }), + }, + { + name: "road only (no temp)", + model: model({ + speed: "55", setSpeed: "60", roadLimit: "60", + }), + }, + { + name: "US · s/m/road", + model: model({ + isMetric: false, limitStyle: "us", speed: "62", setSpeed: "70", roadLimit: "55", + alert: { visible: true, name: "CAM", kind: "camera", distance: "0.3mi", countdown: "9s", badge: "", section: false }, + temp: { visible: true, label: "road", speed: "70", decel: false }, + }), + }, + { + name: "US 3-digit", + model: model({ + isMetric: false, limitStyle: "us", speed: "105", setSpeed: "110", roadLimit: "75", + temp: { visible: true, label: "road", speed: "110", decel: false }, + }), + }, + ]; + + let tag = null; + let idx = 0; // 0 = STOCK, shown at init/idle; first tap advances to scenario 1 + let showing = false; // a mock scenario is on screen → drop live HUD updates + let currentModel = STOCK; + const MODE_PREVIEWS = [ + { kind: "normal", name: "NORMAL" }, + { kind: "eco", name: "ECO" }, + { kind: "safe", name: "SAFE" }, + { kind: "sport", name: "SPORT" }, + ]; + + function render(m) { + currentModel = m; + window.CarrotMiniHud?.render?.(m); + } + + function cycleDriveMode(event) { + event.preventDefault(); + event.stopPropagation(); + const currentKind = String(currentModel?.driveMode?.kind || "normal").toLowerCase(); + const currentIndex = MODE_PREVIEWS.findIndex((mode) => mode.kind === currentKind); + const nextMode = MODE_PREVIEWS[(currentIndex + 1) % MODE_PREVIEWS.length]; + showing = true; + render(Object.assign({}, currentModel, { source: "stock", driveMode: nextMode })); + const t = ensureTag(); + if (t) { + t.hidden = false; + t.textContent = `MODE - ${nextMode.name}`; + } + } + + function ensureTag() { + const root = document.getElementById("carrotMiniHud"); + if (!root) return null; + if (!tag || !tag.isConnected) { + tag = document.createElement("div"); + tag.className = "carrot-mini-hud__demo-tag"; + tag.hidden = true; + root.appendChild(tag); + } + return tag; + } + + // Idle timeout — release the surface back to the live feed and show plain + // STOCK (option 가). With no vehicle connected the STOCK render just stays. + function stop() { + idx = 0; + showing = false; + render(STOCK); + const t = ensureTag(); + if (t) t.hidden = true; + } + + function step() { + idx = (idx + 1) % SCENARIOS.length; + const scenario = SCENARIOS[idx]; + showing = !scenario.live; + render(scenario.model); + const t = ensureTag(); + if (t) { + t.hidden = false; + t.textContent = `TEST ${idx + 1}/${SCENARIOS.length} · ${scenario.name}`; + } + if (t) t.textContent = `${scenario.live ? "LIVE" : "TEST"} ${idx + 1}/${SCENARIOS.length} - ${scenario.name}`; + } + + function init() { + if (!window.CarrotMiniHud) { + setTimeout(init, 60); + return; + } + // Take ownership of the surface: while a mock scenario is on screen, drop the + // live HUD feed (vision_raw calls CarrotMiniHud.update ~10Hz) so it can't + // overwrite the mock with the parked/STOCK live state. + const liveUpdate = window.CarrotMiniHud.update; + if (typeof liveUpdate === "function" && !window.CarrotMiniHud.__demoWrapped) { + window.CarrotMiniHud.update = function (m) { + if (showing) return; + return liveUpdate.call(this, m); + }; + window.CarrotMiniHud.__demoWrapped = true; + } + render(STOCK); + const root = document.getElementById("carrotMiniHud"); + if (root) root.addEventListener("click", step, { passive: true }); + const driveMode = root?.querySelector(".carrot-mini-hud__drive-mode"); + if (driveMode) { + driveMode.classList.add("is-demo-interactive"); + driveMode.setAttribute("role", "button"); + driveMode.setAttribute("tabindex", "0"); + driveMode.setAttribute("aria-label", "Preview next drive mode"); + driveMode.addEventListener("click", cycleDriveMode); + driveMode.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") cycleDriveMode(event); + }); + } + console.log("[mini_hud] view-test mode ready — tap the HUD to step scenarios"); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/openpilot/selfdrive/carrot/web/js/realtime/mini_hud_mode.js b/openpilot/selfdrive/carrot/web/js/realtime/mini_hud_mode.js index 692d660668..c41d4cd1e9 100644 --- a/openpilot/selfdrive/carrot/web/js/realtime/mini_hud_mode.js +++ b/openpilot/selfdrive/carrot/web/js/realtime/mini_hud_mode.js @@ -3,7 +3,7 @@ (function () { const ENTER_MAX_WIDTH = 450; const EXIT_MIN_WIDTH = 520; - const MIN_HEIGHT = 160; + const MIN_HEIGHT = 96; const COMPACT_MAX_HEIGHT = 760; const LARGE_CANVAS_MIN = 700; const PARAM = "mini_hud"; @@ -15,7 +15,11 @@ let lastDecisionLogKey = ""; function readRequestMode() { - const value = new URLSearchParams(window.location.search).get(PARAM); + const search = new URLSearchParams(window.location.search); + // View-test mode (mini_hud_demo.js) always forces the HUD on so mock + // scenarios render on any viewport, even without a vehicle connected. + if (search.has("mhud_test")) return "force"; + const value = search.get(PARAM); if (value === "0" || value === "off") return "off"; if (value === "1" || value === "force") return "force"; if (value === "auto") return "auto"; diff --git a/openpilot/selfdrive/carrot/web/js/realtime/mini_hud_model.js b/openpilot/selfdrive/carrot/web/js/realtime/mini_hud_model.js index b4b4834357..b4b5c2fdeb 100644 --- a/openpilot/selfdrive/carrot/web/js/realtime/mini_hud_model.js +++ b/openpilot/selfdrive/carrot/web/js/realtime/mini_hud_model.js @@ -38,6 +38,19 @@ return `${(value / 1000).toFixed(value >= 10000 ? 1 : 2)}km`; } + function displayGap(value) { + const number = integer(value); + return number != null && number >= 1 && number <= 9 ? String(number) : ""; + } + + function displayGear(value) { + const gear = String(value || "").trim().toUpperCase(); + if (!gear || gear === "UNKNOWN") return ""; + const aliases = { PARK: "P", REVERSE: "R", NEUTRAL: "N", DRIVE: "D", SPORT: "S", LOW: "L" }; + if (aliases[gear]) return aliases[gear]; + return gear.slice(0, 2); + } + function alertDescriptor(type) { if (type === 100) return { name: "POLICE", kind: "police" }; if (type === 22) return { name: "BUMP", kind: "bump" }; @@ -47,9 +60,11 @@ } function sourceMode(carrotMan) { + // Waze cannot be reliably distinguished from other nav providers in normal + // driving (CarrotMan carries no provider field; only xSpdType 100/101 and + // desiredSource "waze"/"police" hint at it, and only during an active alert). + // So the compact HUD merges every non-stock nav source into a single "nav". const type = integer(carrotMan?.xSpdType, -1); - const desiredSource = String(carrotMan?.desiredSource || "").trim().toLowerCase(); - if (WAZE_TYPES.has(type) || desiredSource === "police" || desiredSource === "waze") return "waze"; if (type >= 0) return "nav"; if (integer(carrotMan?.activeCarrot, 0) > 1) return "nav"; return "stock"; @@ -75,13 +90,29 @@ const alertVisible = source !== "stock" && alertType >= 0 && ((alertDistance ?? 0) > 0 || WAZE_TYPES.has(alertType)); const gearStep = integer(payload?.gearStep); + // Active speed-control source line ("road 55" etc). We read desiredSource + // straight from carrotMan (not payload.temp, which blanks the label while + // decelerating) so the compact HUD always shows the winning source label. + const desiredSpeed = finite(carrotMan?.desiredSpeed); + const vSetKph = finite(payload?.vSetKph); + const tempVisible = source !== "stock" && desiredSpeed != null && desiredSpeed > 0; + return { source, isMetric, + // metric → Korean/Vienna red circle, imperial → US MUTCD rectangle. + limitStyle: isMetric ? "kr" : "us", cpu: integer(payload?.cpuTempC), speed: displaySpeed(payload?.vEgoKph, isMetric), setSpeed: displaySpeed(payload?.vSetKph, isMetric), roadLimit: displayLimit(roadLimitKph, isMetric), + gap: displayGap(payload?.tfGap ?? payload?.tfBars), + temp: { + visible: tempVisible, + label: tempVisible ? String(carrotMan?.desiredSource || "").trim() : "", + speed: tempVisible ? displaySpeed(desiredSpeed, isMetric) : "", + decel: tempVisible && vSetKph != null && desiredSpeed < vSetKph, + }, alert: { visible: alertVisible, name: alertVisible ? alert.name : "", @@ -92,9 +123,10 @@ section: alertVisible && alert.kind === "section", }, driveMode: driveMode(payload), + gear: displayGear(payload?.gear), gearStep: gearStep != null && gearStep >= 1 && gearStep <= 7 ? gearStep : null, }; } - window.CarrotMiniHudModel = { build, displayDistance, displaySpeed }; + window.CarrotMiniHudModel = { build, displayDistance, displaySpeed, displayGap, displayGear }; })(); diff --git a/openpilot/selfdrive/carrot/web/js/realtime/web_sound.js b/openpilot/selfdrive/carrot/web/js/realtime/web_sound.js new file mode 100644 index 0000000000..c58cbd8c83 --- /dev/null +++ b/openpilot/selfdrive/carrot/web/js/realtime/web_sound.js @@ -0,0 +1,458 @@ +"use strict"; + +(function () { + const STORAGE_KEY = "carrot.webSound.enabled.v1"; + const VOLUME_STORAGE_KEY = "carrot.webSound.volume.v1"; + const RECONNECT_MS = 1500; + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + const button = document.getElementById("btnWebSound"); + + const SOUND_MAP = Object.freeze({ + 1: { file: "engage.wav", once: true, engageVolume: true }, + 2: { file: "disengage.wav", once: true, engageVolume: true }, + 3: { file: "refuse.wav", once: true }, + 4: { file: "warning_soft.wav" }, + 5: { file: "warning_immediate.wav" }, + 6: { file: "prompt.wav", once: true }, + 7: { file: "prompt.wav" }, + 8: { file: "prompt_distracted.wav" }, + 9: { file: "audio_turn.wav" }, + 10: { file: "tici_engaged.wav" }, + 11: { file: "tici_disengaged.wav" }, + 12: { file: "traffic_sign_green.wav" }, + 13: { file: "traffic_sign_changed.wav" }, + 14: { file: "audio_lane_change.wav" }, + 15: { file: "audio_stopping.wav" }, + 16: { file: "audio_auto_hold.wav" }, + 17: { file: "audio_engage.wav" }, + 18: { file: "audio_disengage.wav" }, + 19: { file: "audio_traffic_error.wav" }, + 20: { file: "audio_car_watchout.wav" }, + 21: { file: "audio_speed_down.wav" }, + 22: { file: "audio_stopstop.wav" }, + 23: { file: "reverse_gear.wav", once: true, engageVolume: true }, + 24: { file: "audio_1.wav" }, + 25: { file: "audio_2.wav" }, + 26: { file: "audio_3.wav" }, + 27: { file: "audio_4.wav" }, + 28: { file: "audio_5.wav" }, + 29: { file: "audio_6.wav" }, + 30: { file: "audio_7.wav" }, + 31: { file: "audio_8.wav" }, + 32: { file: "audio_9.wav" }, + 33: { file: "audio_10.wav" }, + }); + + let enabled = loadEnabled(); + let webVolume = loadWebVolume(); + let context = null; + let socket = null; + let reconnectTimer = null; + let current = null; + let pending = null; + let playRequest = 0; + let lastAlert = null; + let lastCountdown = null; + let volume = 1; + let engageVolume = 1; + let tizi = false; + let activeSoundDirectory = bootstrapSoundDirectory(); + const bufferCache = new Map(); + + function loadEnabled() { + try { + return localStorage.getItem(STORAGE_KEY) === "1"; + } catch (_) { + return false; + } + } + + function saveEnabled(value) { + try { + localStorage.setItem(STORAGE_KEY, value ? "1" : "0"); + } catch (_) {} + } + + function loadWebVolume() { + try { + const stored = localStorage.getItem(VOLUME_STORAGE_KEY); + if (stored == null || stored === "") return 1; + const value = Number(stored); + return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 1; + } catch (_) { + return 1; + } + } + + function setWebVolume(value) { + webVolume = Math.max(0, Math.min(1, Number(value) || 0)); + try { localStorage.setItem(VOLUME_STORAGE_KEY, String(webVolume)); } catch (_) {} + if (current?.gain && context) { + const spec = SOUND_MAP[current.alert]; + const configuredGain = spec?.engageVolume ? volume * engageVolume : volume; + current.gain.gain.setValueAtTime(Math.max(0, Math.min(4, configuredGain * webVolume)), context.currentTime); + } + return webVolume; + } + + function bootstrapSoundDirectory() { + const configured = String(window.__CARROT_BOOTSTRAP__?.soundLanguage || "auto").trim(); + const device = String(window.__CARROT_BOOTSTRAP__?.deviceLanguage || "en").trim(); + let normalized = (configured && configured.toLowerCase() !== "auto" ? configured : device) + .replaceAll("_", "-") + .toLowerCase(); + if (normalized.startsWith("main-")) normalized = normalized.slice(5); + if (normalized === "ko" || normalized.startsWith("ko-")) return "sounds"; + if (normalized === "zh-chs" || normalized === "zh-hans" || normalized.startsWith("zh")) return "sounds_chs"; + return "sounds_eng"; + } + + function soundDirectory() { + return activeSoundDirectory; + } + + function setButtonState() { + if (!button) return; + button.classList.toggle("is-active", enabled); + button.textContent = getUIText("web_sound_button", "Sound"); + const label = enabled + ? getUIText("web_sound_enabled", "Web sound on") + : getUIText("web_sound_disabled", "Web sound off"); + button.setAttribute("aria-label", label); + button.title = label; + } + + function ensureContext() { + if (!context && AudioContextClass) context = new AudioContextClass(); + return context; + } + + async function unlockAudio() { + const audioContext = ensureContext(); + if (!audioContext) throw new Error(getUIText("web_sound_unsupported", "Audio playback is not supported by this browser.")); + if (audioContext.state === "suspended") await audioContext.resume(); + return audioContext.state === "running"; + } + + async function loadBuffer(file) { + const audioContext = ensureContext(); + if (!audioContext) throw new Error("AudioContext unavailable"); + const key = `${soundDirectory()}/${file}`; + if (!bufferCache.has(key)) { + bufferCache.set(key, (async () => { + const response = await fetch(`/sound-assets/${encodeURIComponent(soundDirectory())}/${encodeURIComponent(file)}`, { cache: "force-cache" }); + if (!response.ok) throw new Error(`sound asset HTTP ${response.status}`); + return audioContext.decodeAudioData(await response.arrayBuffer()); + })().catch((error) => { + bufferCache.delete(key); + throw error; + })); + } + return bufferCache.get(key); + } + + function preloadSounds() { + const files = new Set(Object.values(SOUND_MAP).map((spec) => spec.file)); + if (tizi) files.add("engage_tizi.wav"); + if (tizi) files.add("disengage_tizi.wav"); + return Promise.allSettled(Array.from(files, (file) => loadBuffer(file))); + } + + function stopCurrent(immediate = true) { + if (immediate) { + playRequest += 1; + pending = null; + } else if (pending) { + pending.forceOneShot = true; + } + if (!current) return; + if (!immediate) { + if (current.loop) { + current.source.loop = false; + current.loop = false; + current.released = true; + } + return; + } + try { current.source.stop(); } catch (_) {} + current = null; + } + + async function playAlert(alert, options = {}) { + if (!enabled) return; + const spec = SOUND_MAP[Number(alert)]; + if (!spec) return; + const oneShot = Boolean(options.oneShot || spec.once); + const request = ++playRequest; + pending = { request, alert: Number(alert), oneShot, forceOneShot: false }; + try { + await unlockAudio(); + const file = tizi && Number(alert) === 1 + ? "engage_tizi.wav" + : tizi && Number(alert) === 2 + ? "disengage_tizi.wav" + : spec.file; + const buffer = await loadBuffer(file); + if (!enabled || request !== playRequest) return; + const resolvedOneShot = oneShot || Boolean(pending?.request === request && pending.forceOneShot); + + stopCurrent(true); + const source = context.createBufferSource(); + const gain = context.createGain(); + source.buffer = buffer; + source.loop = !resolvedOneShot; + const configuredGain = spec.engageVolume ? volume * engageVolume : volume; + gain.gain.value = Math.max(0, Math.min(4, configuredGain * webVolume)); + source.connect(gain); + gain.connect(context.destination); + current = { source, gain, alert: Number(alert), loop: source.loop, released: false }; + source.onended = () => { + if (current?.source === source) current = null; + }; + source.start(); + return current; + } catch (error) { + console.warn("[web sound] playback failed", error); + return null; + } finally { + if (pending?.request === request) pending = null; + } + } + + function countdownAlert(countdown) { + if (countdown === 0) return 11; + if (countdown === 11) return 8; + if (countdown >= 1 && countdown <= 10) return 23 + countdown; + return 0; + } + + function handleSoundState(state) { + if (!enabled || state?.type !== "soundState") return; + volume = Number.isFinite(Number(state.volume)) ? Number(state.volume) : 1; + engageVolume = Number.isFinite(Number(state.engageVolume)) ? Number(state.engageVolume) : 1; + tizi = Boolean(state.tizi); + const previousSoundDirectory = activeSoundDirectory; + if (["sounds", "sounds_eng", "sounds_chs"].includes(state.soundDirectory)) { + activeSoundDirectory = state.soundDirectory; + } + const directoryChanged = previousSoundDirectory !== activeSoundDirectory; + + const alert = Number(state.alert) || 0; + const countdown = Number.isFinite(Number(state.countdown)) ? Number(state.countdown) : 100; + const alertChanged = alert !== lastAlert; + if (alertChanged) { + if (alert > 0) { + const sameCurrentAlert = current?.alert === alert && !current.released; + const samePendingAlert = pending?.alert === alert; + if (!sameCurrentAlert && !samePendingAlert) void playAlert(alert); + } else if (lastAlert > 0) { + stopCurrent(false); + } + lastAlert = alert; + } + + if (alert === 0 && countdown !== lastCountdown) { + const derivedAlert = countdownAlert(countdown); + const initialZero = lastCountdown == null && countdown === 0; + if (derivedAlert > 0 && !initialZero) void playAlert(derivedAlert, { oneShot: true }); + lastCountdown = countdown; + } + if (directoryChanged && context?.state === "running") { + void preloadSounds(); + if (!alertChanged && current?.alert > 0) { + void playAlert(current.alert, { oneShot: !current.loop }); + } + } + } + + function socketUrl() { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${location.host}/ws/web_sound`; + } + + function clearReconnect() { + if (!reconnectTimer) return; + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + function scheduleReconnect() { + clearReconnect(); + if (!enabled) return; + reconnectTimer = window.setTimeout(connect, RECONNECT_MS); + } + + function connect() { + clearReconnect(); + if (!enabled || socket || !location.host) return; + lastAlert = null; + lastCountdown = null; + const ws = new WebSocket(socketUrl()); + socket = ws; + ws.onmessage = (event) => { + try { handleSoundState(JSON.parse(event.data)); } catch (_) {} + }; + ws.onerror = () => { + try { ws.close(); } catch (_) {} + }; + ws.onclose = () => { + if (socket === ws) socket = null; + scheduleReconnect(); + }; + } + + function disconnect() { + clearReconnect(); + const ws = socket; + socket = null; + if (ws) { + ws.onclose = null; + try { ws.close(); } catch (_) {} + } + lastAlert = null; + lastCountdown = null; + } + + async function setEnabled(value, fromGesture = false) { + enabled = Boolean(value); + saveEnabled(enabled); + setButtonState(); + if (!enabled) { + disconnect(); + stopCurrent(true); + return false; + } + if (fromGesture) { + await unlockAudio(); + void preloadSounds(); + } + connect(); + return true; + } + + function dialogHtml() { + return ` +
+

${getUIText( + "web_sound_description", + "This feature plays driving alerts on the connected phone or browser for clone devices that cannot play sound.", + )}

+ + +
`; + } + + function openDialog() { + const dialogPromise = appAlert("", { + title: getUIText("web_sound_title", "Web sound"), + html: true, + messageHtml: dialogHtml(), + confirmLabel: getUIText("close", "Close"), + }); + if (typeof appDialog !== "undefined" && appDialog) appDialog.classList.add("app-dialog--web-sound"); + window.setTimeout(() => { + const input = document.querySelector("[data-web-sound-toggle]"); + const volumeInput = document.querySelector("[data-web-sound-volume]"); + const volumeValue = document.querySelector("[data-web-sound-volume-value]"); + input?.addEventListener("change", () => { + setEnabled(input.checked, true).catch((error) => { + input.checked = false; + void setEnabled(false); + if (typeof showAppToast === "function") showAppToast(error?.message || String(error), { tone: "error" }); + }); + }); + volumeInput?.addEventListener("input", () => { + const percent = Math.max(0, Math.min(100, Number(volumeInput.value) || 0)); + setWebVolume(percent / 100); + if (volumeValue) volumeValue.textContent = `${percent}%`; + }); + }, 0); + dialogPromise.finally(() => { + if (typeof appDialog !== "undefined" && appDialog) appDialog.classList.remove("app-dialog--web-sound"); + }); + } + + async function testAlert(alert = 1) { + if (!enabled) throw new Error(getUIText("web_sound_disabled", "Web sound off")); + const value = Number(alert); + if (!SOUND_MAP[value]) throw new Error(`Unknown AudibleAlert: ${alert}`); + await unlockAudio(); + const playback = await playAlert(value, { oneShot: true }); + if (!playback) throw new Error(`Failed to play AudibleAlert: ${alert}`); + if (playback?.source) { + await new Promise((resolve) => { + const timeout = window.setTimeout(resolve, Math.max(250, playback.source.buffer.duration * 1000 + 150)); + playback.source.addEventListener("ended", () => { + clearTimeout(timeout); + resolve(); + }, { once: true }); + }); + } + return { alert: value, file: SOUND_MAP[value].file, soundDirectory: soundDirectory() }; + } + + async function testCountdown(countdown = 5) { + const value = Number(countdown); + const alert = countdownAlert(value); + if (!alert) throw new Error(`Unsupported countdown: ${countdown}`); + return testAlert(alert); + } + + function status() { + return { + enabled, + audioState: context?.state || "not-created", + socketState: socket ? socket.readyState : WebSocket.CLOSED, + connected: socket?.readyState === WebSocket.OPEN, + currentAlert: current?.alert ?? null, + pendingAlert: pending?.alert ?? null, + soundDirectory: soundDirectory(), + volume, + engageVolume, + webVolume, + }; + } + + button?.addEventListener("click", openDialog); + document.addEventListener("pointerdown", () => { + if (!enabled || context?.state === "running") return; + void unlockAudio() + .then(() => { + void preloadSounds(); + if (!current && lastAlert > 0) void playAlert(lastAlert); + }) + .catch(() => {}); + }, { capture: true }); + window.addEventListener("carrot:languagechange", setButtonState); + window.addEventListener("online", () => { if (enabled) connect(); }); + window.addEventListener("beforeunload", disconnect); + + setButtonState(); + if (enabled) connect(); + + window.CarrotWebSound = Object.freeze({ + isEnabled: () => enabled, + setEnabled, + openDialog, + status, + setVolume: setWebVolume, + stop: () => { + stopCurrent(true); + return status(); + }, + test: testAlert, + testCountdown, + }); +})(); diff --git a/openpilot/selfdrive/carrot/web/js/translations/en.js b/openpilot/selfdrive/carrot/web/js/translations/en.js index 6672235315..cf625ec77d 100644 --- a/openpilot/selfdrive/carrot/web/js/translations/en.js +++ b/openpilot/selfdrive/carrot/web/js/translations/en.js @@ -293,6 +293,14 @@ window.CarrotTranslations.register("en", { web_kmap_map_type_hybrid: "Hybrid", web_nav_hud_enabled: "Nav HUD", web_nav_hud_enabled_desc: "Show the small turn-by-turn card at the top of Carrot Vision.", + web_sound_button: "Sound", + web_sound_title: "Web sound", + web_sound_description: "This feature is for clone devices that cannot play sound. Comma alerts are played through the connected phone or another device's browser.", + web_sound_toggle: "Play sounds in this browser", + web_sound_volume: "Web volume", + web_sound_enabled: "Web sound on", + web_sound_disabled: "Web sound off", + web_sound_unsupported: "Audio playback is not supported by this browser.", tools_notifications: "Notifications", tools_notifications_other: "Other", tools_notifications_empty: "No notifications", diff --git a/openpilot/selfdrive/carrot/web/js/translations/ko.js b/openpilot/selfdrive/carrot/web/js/translations/ko.js index f832e27439..f517977394 100644 --- a/openpilot/selfdrive/carrot/web/js/translations/ko.js +++ b/openpilot/selfdrive/carrot/web/js/translations/ko.js @@ -293,6 +293,14 @@ window.CarrotTranslations.register("ko", { web_kmap_map_type_hybrid: "하이브리드", web_nav_hud_enabled: "내비 HUD", web_nav_hud_enabled_desc: "당근비전 상단에 작은 길안내 카드를 표시합니다.", + web_sound_button: "소리", + web_sound_title: "웹 소리 재생", + web_sound_description: "이 기능은 소리 재생이 불가능한 클론 기기를 위한 기능입니다. 연결된 휴대폰이나 다른 기기의 브라우저에서 콤마 경고음을 재생합니다.", + web_sound_toggle: "이 브라우저에서 소리 재생", + web_sound_volume: "웹 볼륨", + web_sound_enabled: "웹 소리 켜짐", + web_sound_disabled: "웹 소리 꺼짐", + web_sound_unsupported: "이 브라우저에서는 소리를 재생할 수 없습니다.", tools_notifications: "알림", tools_notifications_other: "기타", tools_notifications_empty: "알림이 없습니다", diff --git a/openpilot/selfdrive/carrot/web/js/translations/zh.js b/openpilot/selfdrive/carrot/web/js/translations/zh.js index 260cbc4d83..11dffed335 100644 --- a/openpilot/selfdrive/carrot/web/js/translations/zh.js +++ b/openpilot/selfdrive/carrot/web/js/translations/zh.js @@ -293,6 +293,14 @@ window.CarrotTranslations.register("zh", { web_kmap_map_type_hybrid: "混合", web_nav_hud_enabled: "导航 HUD", web_nav_hud_enabled_desc: "在 Carrot Vision 顶部显示小型转向提示卡。", + web_sound_button: "声音", + web_sound_title: "网页声音", + web_sound_description: "此功能适用于无法播放声音的克隆设备。Comma 提示音将通过已连接手机或其他设备的浏览器播放。", + web_sound_toggle: "在此浏览器中播放声音", + web_sound_volume: "网页音量", + web_sound_enabled: "网页声音已开启", + web_sound_disabled: "网页声音已关闭", + web_sound_unsupported: "此浏览器不支持音频播放。", tools_notifications: "通知", tools_notifications_other: "其他", tools_notifications_empty: "暂无通知",