mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-09-09 20:13:43 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6b16842f4 | |||
| 4bac4c4be9 | |||
| 215cee1d7d | |||
| cb707bef83 | |||
| 0dc286ff8f | |||
| 3615762b6d | |||
| 890c4ef336 | |||
| 8579eaf57f | |||
| 9e1a22b813 | |||
| 48aab1bc1d | |||
| 67436ab555 | |||
| 356bd1a96e |
@@ -0,0 +1,82 @@
|
||||
name: Test Models Compatibility With Tinygrad Changes
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'tinygrad_repo'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'tinygrad_repo'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
generate-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
models: ${{ steps.set-matrix.outputs.models }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Fetch and Parse json
|
||||
id: set-matrix
|
||||
run: |
|
||||
python3 -c '
|
||||
import json, urllib.request, os, re
|
||||
|
||||
with open("openpilot/sunnypilot/models/fetcher.py", "r") as f:
|
||||
urls = re.findall(r"MODEL_URL(?:_CHESTNUT)?\s*=\s*[\"'"'"']([^\"'"'"']+)[\"'"'"']", f.read())
|
||||
|
||||
artifacts = []
|
||||
for url in urls:
|
||||
data = json.loads(urllib.request.urlopen(url).read())
|
||||
for bundle in data.get("bundles", []):
|
||||
for model in bundle.get("models", []):
|
||||
if "artifact" in model:
|
||||
artifacts.append(model["artifact"])
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
|
||||
f.write(f"models={json.dumps(artifacts)}\n")
|
||||
'
|
||||
|
||||
test-model:
|
||||
name: Test ${{ matrix.artifact.file_name }}
|
||||
needs: generate-matrix
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/commaai/openpilot-base:latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
artifact: ${{ fromJson(needs.generate-matrix.outputs.models) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Download Model Chunks in Parallel
|
||||
run: |
|
||||
mkdir -p /tmp/model_chunks
|
||||
echo '${{ toJson(matrix.artifact.chunks) }}' > chunks.json
|
||||
|
||||
BASE_URL="${{ matrix.artifact.download_uri.url }}"
|
||||
export BASE_DIR=$(dirname "$BASE_URL")
|
||||
|
||||
python3 -c '
|
||||
import json, os
|
||||
with open("chunks.json") as f:
|
||||
chunks = json.load(f)
|
||||
manifest_path = f"/tmp/model_chunks/${{ matrix.artifact.file_name }}.chunkmanifest"
|
||||
with open(manifest_path, "w") as f:
|
||||
f.write(str(len(chunks)))
|
||||
base_dir = os.environ["BASE_DIR"]
|
||||
with open("/tmp/curl_config.txt", "w") as f:
|
||||
for c in chunks:
|
||||
fn = c["file_name"]
|
||||
f.write(f"url = \"{base_dir}/{fn}\"\noutput = \"/tmp/model_chunks/{fn}\"\n")
|
||||
'
|
||||
curl -Z --parallel-immediate --parallel-max 16 -s -S -f -L -K /tmp/curl_config.txt
|
||||
|
||||
- name: Run Model Compatibility Test
|
||||
env:
|
||||
MODEL_BASE_NAME: ${{ matrix.artifact.file_name }}
|
||||
MODEL_CHUNK_DIR: "/tmp/model_chunks"
|
||||
PYTHONPATH: ".:./tinygrad_repo"
|
||||
run: |
|
||||
python3 -m pytest openpilot/sunnypilot/modeld_v2/tests/test_models.py
|
||||
+1
-1
Submodule opendbc_repo updated: f9f1223de9...f95f996f59
@@ -211,9 +211,7 @@ class Soundd(QuietMode):
|
||||
|
||||
|
||||
def main():
|
||||
from openpilot.sunnypilot.selfdrive.ui.soundd import SounddSP
|
||||
|
||||
s = SounddSP()
|
||||
s = Soundd()
|
||||
s.soundd_thread()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import io
|
||||
import struct
|
||||
import pickle
|
||||
import inspect
|
||||
import importlib
|
||||
import enum
|
||||
|
||||
|
||||
def _pad_args(func, args, kwargs):
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
except Exception:
|
||||
return args, kwargs
|
||||
params = list(sig.parameters.values())
|
||||
if inspect.isfunction(func) and params and params[0].name in ('cls', 'self'):
|
||||
params = params[1:]
|
||||
|
||||
new_args = list(args)
|
||||
has_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params)
|
||||
if len(new_args) > len(params) and not has_varargs:
|
||||
new_args = new_args[:len(params)]
|
||||
|
||||
for i in range(len(new_args), len(params)):
|
||||
param = params[i]
|
||||
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
||||
continue
|
||||
val = param.default if param.default is not inspect.Parameter.empty else None
|
||||
new_args.append(val)
|
||||
return new_args, kwargs
|
||||
|
||||
|
||||
def _enum_factory(enum_class):
|
||||
def factory(*args, **kwargs):
|
||||
try:
|
||||
return enum_class(*args, **kwargs)
|
||||
# OptOps and UOp objects in the .pkl are left over from the compilation phase,
|
||||
# reassignment does nothing because they aren't tied to the execution graph
|
||||
# It never executes or evaluates the UOp nodes again.
|
||||
except ValueError:
|
||||
return list(enum_class)[0]
|
||||
factory.__name__ = enum_class.__name__
|
||||
factory.__module__ = enum_class.__module__
|
||||
return factory
|
||||
|
||||
|
||||
def _dynamic_factory(real_class):
|
||||
if isinstance(real_class, type) and issubclass(real_class, enum.Enum):
|
||||
return _enum_factory(real_class)
|
||||
|
||||
def factory(*args, **kwargs):
|
||||
try:
|
||||
return real_class(*args, **kwargs)
|
||||
except TypeError:
|
||||
new_args, new_kwargs = _pad_args(real_class, args, kwargs)
|
||||
return real_class(*new_args, **new_kwargs)
|
||||
|
||||
class DynamicMeta(type(real_class)):
|
||||
def __call__(cls, *args, **kwargs):
|
||||
return factory(*args, **kwargs)
|
||||
|
||||
class DynamicProxy(real_class, metaclass=DynamicMeta):
|
||||
__slots__ = ()
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
return factory(*args, **kwargs)
|
||||
|
||||
DynamicProxy.__name__ = real_class.__name__
|
||||
DynamicProxy.__module__ = real_class.__module__
|
||||
return DynamicProxy
|
||||
|
||||
|
||||
class DynamicTinygradUnpickler(pickle.Unpickler):
|
||||
def find_class(self, module, name):
|
||||
if module == "tinygrad.ops":
|
||||
try:
|
||||
importlib.import_module("tinygrad.uops")
|
||||
module = "tinygrad.uops"
|
||||
except ImportError:
|
||||
pass
|
||||
real_class = getattr(importlib.import_module(module), name)
|
||||
if module.startswith("tinygrad"):
|
||||
return _dynamic_factory(real_class)
|
||||
return real_class
|
||||
|
||||
|
||||
def load_oob(f):
|
||||
opcodes = f.read(struct.unpack('<q', f.read(8))[0])
|
||||
def buffers():
|
||||
while (h := f.read(8)):
|
||||
pb = pickle.PickleBuffer(bytearray(struct.unpack('<q', h)[0]))
|
||||
f.readinto(pb)
|
||||
yield pb
|
||||
return DynamicTinygradUnpickler(io.BytesIO(opcodes), buffers=buffers()).load()
|
||||
@@ -17,7 +17,7 @@ from tinygrad.tensor import Tensor
|
||||
|
||||
import openpilot.cereal.messaging as messaging
|
||||
from openpilot.common.hardware import COMMA_HARDWARE
|
||||
from openpilot.selfdrive.modeld.helpers import chestnut_present, load_oob
|
||||
from openpilot.selfdrive.modeld.helpers import chestnut_present
|
||||
from openpilot.cereal import log
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.cereal.services import SERVICE_LIST
|
||||
@@ -52,6 +52,7 @@ from openpilot.sunnypilot.modeld_v2.compile_modeld import (derive_frame_skip, ma
|
||||
WARP_INPUTS, POLICY_INPUTS)
|
||||
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
|
||||
from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import load_oob
|
||||
from openpilot.sunnypilot.models.helpers import get_active_bundle
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from openpilot.common.file_chunker import open_file_chunked
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import load_oob
|
||||
from tinygrad.device import Device
|
||||
|
||||
|
||||
class TestLegacyModels(unittest.TestCase):
|
||||
def test_legacy_model_load(self):
|
||||
base_name = os.environ.get("MODEL_BASE_NAME")
|
||||
if not base_name:
|
||||
raise unittest.SkipTest("MODEL_BASE_NAME env var not set, skipping integration test.")
|
||||
chunk_dir = os.environ.get("MODEL_CHUNK_DIR", "/tmp/model_chunks")
|
||||
base_path = os.path.join(chunk_dir, base_name)
|
||||
|
||||
try:
|
||||
f = open_file_chunked(base_path)
|
||||
except Exception as error:
|
||||
self.fail(f"Failed to open chunked file {base_path}: {error}")
|
||||
self.addCleanup(f.close)
|
||||
|
||||
real_getitem = Device.__class__.__getitem__
|
||||
|
||||
def safe_getitem(device_self, ix):
|
||||
if ix == "QCOM" and not os.path.exists("/dev/kgsl-3d0"):
|
||||
return real_getitem(device_self, "CPU")
|
||||
if ix == "AMD" and not os.path.exists("/dev/kfd"):
|
||||
return real_getitem(device_self, "CPU")
|
||||
return real_getitem(device_self, ix)
|
||||
|
||||
with patch.object(Device.__class__, "__getitem__", safe_getitem):
|
||||
obj = load_oob(f)
|
||||
|
||||
assert isinstance(obj, dict), "Parsed object is not a dictionary"
|
||||
assert "metadata" in obj, "Metadata key is missing"
|
||||
@@ -1,24 +0,0 @@
|
||||
import requests
|
||||
|
||||
from openpilot.sunnypilot.models.tinygrad_ref import get_tinygrad_ref
|
||||
from openpilot.sunnypilot.models.fetcher import ModelFetcher
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
|
||||
def fetch_tinygrad_ref():
|
||||
response = requests.get(ModelFetcher.MODEL_URL, timeout=10)
|
||||
response.raise_for_status()
|
||||
json_data = response.json()
|
||||
return json_data.get("tinygrad_ref")
|
||||
|
||||
|
||||
class TestTinygradRef(OpenpilotTestCase):
|
||||
def test_tinygrad_ref(self):
|
||||
current_ref = get_tinygrad_ref()
|
||||
remote_ref = fetch_tinygrad_ref()
|
||||
assert remote_ref == current_ref, (
|
||||
f"""tinygrad_repo ref does not match remote tinygrad_ref of current compiled driving models json.
|
||||
Current: {current_ref}
|
||||
Remote: {remote_ref}
|
||||
Please run build-all workflow to update models."""
|
||||
)
|
||||
print("tinygrad_repo ref matches current compiled driving models json ref.")
|
||||
@@ -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))
|
||||
+1
-1
Submodule tinygrad_repo updated: e837e367aa...f6fc4e3f2c
Reference in New Issue
Block a user