This commit is contained in:
royjr
2026-09-07 01:50:58 -04:00
parent 12f2c30c14
commit f3cc96b0a7
4 changed files with 167 additions and 1 deletions
+3 -1
View File
@@ -211,7 +211,9 @@ class Soundd(QuietMode):
def main():
s = Soundd()
from openpilot.sunnypilot.selfdrive.ui.soundd import SounddSP
s = SounddSP()
s.soundd_thread()
@@ -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="<i2").astype(np.float32) / 32768
class AudioPlayer:
def __init__(self):
self.command = None
self.seen_command = None
self.samples = np.empty(0, dtype=np.float32)
self.position = 0
self.gain = 1.0
self.paused = False
def render(self, frames, alert_active):
command = self.command
if command is not self.seen_command:
self.seen_command = command
self.samples = command if command is not None else np.empty(0, dtype=np.float32)
self.position = 0
self.gain = 1.0
self.paused = False
out = np.zeros(frames, dtype=np.float32)
if self.position == len(self.samples):
return out
if alert_active:
self.paused = True
self.gain = 0.0
return out
if self.paused:
self.paused = False
self.gain = 0.0
n = min(frames, len(self.samples) - self.position)
gains = np.minimum(1, self.gain + np.arange(n) / RESUME_FRAMES)
out[:n] = self.samples[self.position:self.position + n] * gains
self.position += n
if n:
self.gain = float(gains[-1])
return out
class CustomAudio:
def __init__(self):
self.player = AudioPlayer()
self.transition = GearTransition()
self.loader = ThreadPoolExecutor(max_workers=1, thread_name_prefix="custom-audio-source")
self.pending = None
def update(self, messages):
now = time.monotonic()
event = None
if self.transition.last_time is not None and now - self.transition.last_time > 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
@@ -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 `<gear>.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
```
@@ -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))