Compare commits

..

12 Commits

Author SHA1 Message Date
James Vecellio-Grant d6b16842f4 Update test_models.py 2026-09-09 00:55:01 -07:00
James Vecellio-Grant 4bac4c4be9 License 2026-09-09 00:54:42 -07:00
discountchubbs 215cee1d7d make therapy therpeutic, or ... you know, just combine the urls 2026-09-08 20:02:36 -07:00
discountchubbs cb707bef83 I think this is the latest? 2026-09-08 19:50:21 -07:00
discountchubbs 0dc286ff8f loop in the 🌰 (start with one random sample to see what gh action says) 2026-09-08 19:49:42 -07:00
discountchubbs 3615762b6d we introduced oob in v19 whoops 2026-09-08 19:33:19 -07:00
discountchubbs 890c4ef336 hehe. revert me after the tests run 2026-09-08 19:28:11 -07:00
discountchubbs 8579eaf57f just the pointer 2026-09-08 17:21:34 -07:00
discountchubbs 9e1a22b813 simply simply 2026-09-08 17:15:42 -07:00
discountchubbs 48aab1bc1d fuck that 2026-09-08 17:14:10 -07:00
discountchubbs 67436ab555 keep forgettng they removed my bae, pytest 2026-09-08 17:08:28 -07:00
discountchubbs 356bd1a96e models: test tinygrad concurrency 2026-09-08 15:07:04 -07:00
11 changed files with 230 additions and 202 deletions
+82
View File
@@ -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 -3
View File
@@ -211,9 +211,7 @@ class Soundd(QuietMode):
def main():
from openpilot.sunnypilot.selfdrive.ui.soundd import SounddSP
s = SounddSP()
s = Soundd()
s.soundd_thread()
+100
View File
@@ -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()
+2 -1
View File
@@ -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))