mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-09-08 00:53:56 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d4ec2d5b1 | |||
| 741d9f7604 | |||
| b7dd946fa2 | |||
| d44645fc53 | |||
| 2f2692d515 | |||
| d7aa0f5002 | |||
| a763c93496 | |||
| cf0c41af96 | |||
| 34e35d49e1 | |||
| a5ec1b3f16 | |||
| b7c40b4c44 | |||
| 4033119fa0 | |||
| 7bb32de4b8 | |||
| 7419b2a0b0 | |||
| aa8a190f5a |
+1
-1
Submodule opendbc_repo updated: f9f1223de9...f95f996f59
@@ -139,6 +139,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"ChestnutActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
|
||||
{"ChestnutLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
|
||||
{"ChestnutModelError", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
|
||||
{"AuxPowerSave", {PERSISTENT | BACKUP, BOOL}},
|
||||
{"Version", {PERSISTENT, STRING}},
|
||||
|
||||
// --- sunnypilot params --- //
|
||||
|
||||
@@ -211,9 +211,7 @@ class Soundd(QuietMode):
|
||||
|
||||
|
||||
def main():
|
||||
from openpilot.sunnypilot.selfdrive.ui.soundd import SounddSP
|
||||
|
||||
s = SounddSP()
|
||||
s = Soundd()
|
||||
s.soundd_thread()
|
||||
|
||||
|
||||
|
||||
@@ -225,6 +225,7 @@ class UIState(UIStateSP):
|
||||
ChestnutState.UNCOMPILED if detected else ChestnutState.DISCONNECTED)
|
||||
return
|
||||
|
||||
self.chestnut_present = self.chestnut_present or detected
|
||||
model_seen = self.sm.recv_frame["modelV2"] > self.started_frame
|
||||
if not self.chestnut_present:
|
||||
self.chestnut_state = ChestnutState.DISCONNECTED
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
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
|
||||
DUCK_FRAMES = int(0.05 * SAMPLE_RATE)
|
||||
RESTORE_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 lower_loader_priority():
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
tid = threading.get_native_id()
|
||||
os.sched_setscheduler(tid, os.SCHED_OTHER, os.sched_param(0))
|
||||
os.setpriority(os.PRIO_PROCESS, tid, max(10, os.getpriority(os.PRIO_PROCESS, tid)))
|
||||
except OSError:
|
||||
cloudlog.exception("Unable to lower custom audio loader priority")
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
out = np.zeros(frames, dtype=np.float32)
|
||||
if self.position == len(self.samples):
|
||||
return out
|
||||
n = min(frames, len(self.samples) - self.position)
|
||||
step = -0.5 / DUCK_FRAMES if alert_active else 0.5 / RESTORE_FRAMES
|
||||
gains = np.clip(self.gain + np.arange(n) * step, 0.5, 1.0)
|
||||
out[:n] = self.samples[self.position:self.position + n] * gains
|
||||
self.position += n
|
||||
self.gain = float(np.clip(self.gain + n * step, 0.5, 1.0))
|
||||
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", initializer=lower_loader_priority)
|
||||
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
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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 25% 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.
|
||||
- While any alert sounds, custom audio keeps playing and fades to **half its normal level (12.5%) over 50 ms**. Afterward it fades back to 25% over **150 ms**.
|
||||
- Alert volume is unchanged. Custom audio may be reduced further to prevent clipping when both sounds are loud.
|
||||
- Startup and recovery from unknown/invalid gear data or data gaps establish a silent baseline.
|
||||
|
||||
WAV loading runs on a low-priority worker thread on Linux (nice +10). Playback shares the existing safety-alert output callback; its priority is unchanged.
|
||||
|
||||
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
|
||||
```
|
||||
@@ -1,25 +0,0 @@
|
||||
import numpy as np
|
||||
|
||||
from openpilot.cereal import messaging
|
||||
from openpilot.selfdrive.ui.soundd import 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:
|
||||
super().callback(data_out, frames, time, status)
|
||||
alert_data = data_out[:frames, 0]
|
||||
alert_peak = float(np.max(np.abs(alert_data)))
|
||||
custom_data = self.custom_audio.player.render(frames, alert_peak > 0) * 0.25
|
||||
custom_peak = float(np.max(np.abs(custom_data)))
|
||||
gain = min(1.0, max(0.0, 1.0 - alert_peak) / max(custom_peak, 1e-9))
|
||||
alert_data += custom_data * gain
|
||||
|
||||
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))
|
||||
@@ -1675,6 +1675,12 @@
|
||||
"widget": "toggle",
|
||||
"title": "Onroad Uploads"
|
||||
},
|
||||
{
|
||||
"key": "AuxPowerSave",
|
||||
"widget": "toggle",
|
||||
"title": "Disable Aux Port When Offroad",
|
||||
"description": "Power off the aux USB-C port while offroad to save power. It powers back on automatically when you go onroad."
|
||||
},
|
||||
{
|
||||
"key": "MaxTimeOffroad",
|
||||
"widget": "option",
|
||||
|
||||
@@ -30,6 +30,10 @@ sections:
|
||||
- key: OnroadUploads
|
||||
widget: toggle
|
||||
title: Onroad Uploads
|
||||
- key: AuxPowerSave
|
||||
widget: toggle
|
||||
title: Disable Aux Port When Offroad
|
||||
description: Power off the aux USB-C port while offroad to save power. It powers back on automatically when you go onroad.
|
||||
- key: MaxTimeOffroad
|
||||
widget: option
|
||||
title: Max Time Offroad
|
||||
|
||||
@@ -21,6 +21,7 @@ from openpilot.common.hardware import HARDWARE, COMMA_HARDWARE
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.common.git import get_short_branch
|
||||
from openpilot.common.hardware.usb import CHESTNUT_FW_VERSION, CHESTNUT_USB_PRODUCT, get_usb_state, get_usb_topology, is_chestnut_usb_id, set_usb_state
|
||||
from openpilot.system.hardware.chestnut.flash import VBUS_PATH
|
||||
from openpilot.common.linux import LinuxSystemStats
|
||||
from openpilot.system.loggerd.config import get_available_percent
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
@@ -50,6 +51,10 @@ class Chestnut:
|
||||
self.last_attempt = 0.
|
||||
self.flashed = False
|
||||
self.mismatch = False
|
||||
self.vbus_on = None
|
||||
self.params = Params()
|
||||
self.powersave = False
|
||||
self.last_offroad = None
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
@@ -61,9 +66,19 @@ class Chestnut:
|
||||
cloudlog.event("chestnut flash done", returncode=ret.returncode, output=ret.stdout[-1000:], error=ret.returncode != 0)
|
||||
self.flashed = ret.returncode == 0
|
||||
|
||||
def set_vbus(self, on: bool) -> None:
|
||||
if on == self.vbus_on:
|
||||
return
|
||||
subprocess.run(["sudo", "tee", VBUS_PATH], input=b"1" if on else b"0", stdout=subprocess.DEVNULL, check=False)
|
||||
self.vbus_on = on
|
||||
|
||||
def update(self, offroad: bool, usb_state: list[dict]) -> None:
|
||||
self.mismatch = any(is_chestnut_usb_id(d["vendorId"], d["productId"], include_bootloader=True) and
|
||||
d["product"] != CHESTNUT_USB_PRODUCT for d in usb_state)
|
||||
if offroad != self.last_offroad:
|
||||
self.powersave = self.params.get_bool("AuxPowerSave")
|
||||
self.last_offroad = offroad
|
||||
self.set_vbus((not offroad or self.mismatch) or not self.powersave)
|
||||
if not self.mismatch:
|
||||
self.flashed = False
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user