From f3cc96b0a7389bccbf044b271e27e1eef4d201ef Mon Sep 17 00:00:00 2001 From: royjr Date: Mon, 7 Sep 2026 01:50:58 -0400 Subject: [PATCH] init --- openpilot/selfdrive/ui/soundd.py | 4 +- .../sunnypilot/selfdrive/ui/custom_audio.py | 121 ++++++++++++++++++ .../selfdrive/ui/docs/custom_audio.md | 19 +++ openpilot/sunnypilot/selfdrive/ui/soundd.py | 24 ++++ 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 openpilot/sunnypilot/selfdrive/ui/custom_audio.py create mode 100644 openpilot/sunnypilot/selfdrive/ui/docs/custom_audio.md create mode 100644 openpilot/sunnypilot/selfdrive/ui/soundd.py diff --git a/openpilot/selfdrive/ui/soundd.py b/openpilot/selfdrive/ui/soundd.py index a61e26b0b6..c2bc96b9c1 100644 --- a/openpilot/selfdrive/ui/soundd.py +++ b/openpilot/selfdrive/ui/soundd.py @@ -211,7 +211,9 @@ class Soundd(QuietMode): def main(): - s = Soundd() + from openpilot.sunnypilot.selfdrive.ui.soundd import SounddSP + + s = SounddSP() s.soundd_thread() diff --git a/openpilot/sunnypilot/selfdrive/ui/custom_audio.py b/openpilot/sunnypilot/selfdrive/ui/custom_audio.py new file mode 100644 index 0000000000..1654ded544 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/ui/custom_audio.py @@ -0,0 +1,121 @@ +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +import time +import wave + +import numpy as np + +from openpilot.common.swaglog import cloudlog + +AUDIO_DIR = Path("/data/media/0/custom_audio") +SAMPLE_RATE = 48000 +MAX_SECONDS = 300 +GEAR_TIMEOUT = 0.5 +RESUME_FRAMES = int(0.15 * SAMPLE_RATE) + + +class GearTransition: + def __init__(self): + self.previous = None + self.last_time = None + + def update(self, gear, timestamp, valid=True): + if not valid or gear == "unknown": + self.previous = self.last_time = None + return None + if self.last_time is None or not 0 < timestamp - self.last_time <= GEAR_TIMEOUT: + self.previous = None + transition = gear if self.previous is not None and gear != self.previous else None + self.previous = gear + self.last_time = timestamp + return transition + + +def load_audio(filename): + path = AUDIO_DIR / filename + if not path.is_file(): + return None + with wave.open(str(path), "rb") as wav: + if (wav.getnchannels(), wav.getsampwidth(), wav.getframerate(), wav.getcomptype()) != (1, 2, SAMPLE_RATE, "NONE"): + raise ValueError("Custom audio requires 48 kHz mono 16-bit PCM WAV") + count = wav.getnframes() + if not 0 < count <= MAX_SECONDS * SAMPLE_RATE: + raise ValueError("Custom audio must be between 0 and 300 seconds") + raw = wav.readframes(count) + if len(raw) != count * 2: + raise ValueError("Truncated custom audio") + return np.frombuffer(raw, dtype=" GEAR_TIMEOUT: + self.transition.update(None, now, False) + for msg in messages: + timestamp = msg.logMonoTime / 1e9 + valid = msg.valid and msg.carState.canValid and msg.carState.gearShifter != "unknown" and 0 <= now - timestamp <= GEAR_TIMEOUT + transition = self.transition.update(str(msg.carState.gearShifter), timestamp, valid) + if not valid: + event = None + elif transition: + event = transition + + if event: + self.player.command = None + if self.pending: + self.pending.cancel() + self.pending = self.loader.submit(load_audio, f"{event}.wav") + + if self.pending and self.pending.done(): + try: + samples = self.pending.result() + if samples is not None: + self.player.command = samples + except Exception: + cloudlog.exception("Unable to load custom audio") + self.pending = None + diff --git a/openpilot/sunnypilot/selfdrive/ui/docs/custom_audio.md b/openpilot/sunnypilot/selfdrive/ui/docs/custom_audio.md new file mode 100644 index 0000000000..5586019ad4 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/ui/docs/custom_audio.md @@ -0,0 +1,19 @@ +# Custom Audio + +Put short **48 kHz, mono, 16-bit PCM WAV** files in `/data/media/0/custom_audio/` (maximum five minutes each). + +Each gear change plays `.wav`: `park.wav`, `drive.wav`, `sport.wav`, `reverse.wav`, `neutral.wav`, `low.wav`, `brake.wav`, `eco.wav`, or `manumatic.wav`. + +- Entering a gear starts its audio from the beginning at full volume, with no fade-in. +- Changing gears cuts the previous audio without a fade-out. If the new file is missing or invalid, playback stays silent. +- Returning to a previous gear restarts its audio from the beginning. Staying in the same gear does not replay it. +- Safety alerts pause custom audio immediately. After the alert, it resumes from its saved position with a **150 ms fade-in**. Audio triggered during an alert waits, then fades in. +- Startup and recovery from unknown/invalid gear data or data gaps establish a silent baseline. + +No settings or dismiss control. Replace or remove files to change future playback. + +Convert files with: + +```sh +ffmpeg -i input_audio -ar 48000 -ac 1 -c:a pcm_s16le drive.wav +``` diff --git a/openpilot/sunnypilot/selfdrive/ui/soundd.py b/openpilot/sunnypilot/selfdrive/ui/soundd.py new file mode 100644 index 0000000000..e75d8f45e0 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/ui/soundd.py @@ -0,0 +1,24 @@ +import numpy as np + +from openpilot.cereal import messaging +from openpilot.selfdrive.ui.soundd import AudibleAlert, Soundd +from openpilot.sunnypilot.selfdrive.ui.custom_audio import CustomAudio + + +class SounddSP(Soundd): + def __init__(self): + super().__init__() + self.custom_audio = CustomAudio() + self.gear_sock = messaging.sub_sock("carState", conflate=False) + + def callback(self, data_out: np.ndarray, frames: int, time, status) -> None: + alert_active = self.current_alert != AudibleAlert.none + super().callback(data_out, frames, time, status) + alert_active = alert_active or self.current_alert != AudibleAlert.none or bool(np.any(data_out[:frames, 0])) + custom_data = self.custom_audio.player.render(frames, alert_active) + if not alert_active: + data_out[:frames, 0] = custom_data + + def get_audible_alert(self, sm): + super().get_audible_alert(sm) + self.custom_audio.update(messaging.drain_sock(self.gear_sock, wait_for_one=False))