From c7863cc1d022c27282741fe660aad78309dac6ee Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:14:13 -0700 Subject: [PATCH] Keep native replay display visible through audio drain --- roadscore/prototype/native_receiver.sh | 24 ++++++++--- roadscore/prototype/normal_onroad.py | 1 + roadscore/prototype/normal_ui_audit.py | 8 ++++ roadscore/prototype/replay_display_hold.py | 43 +++++++++++++++++++ .../prototype/test_replay_display_hold.py | 31 +++++++++++++ 5 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 roadscore/prototype/replay_display_hold.py create mode 100644 roadscore/prototype/test_replay_display_hold.py diff --git a/roadscore/prototype/native_receiver.sh b/roadscore/prototype/native_receiver.sh index 3eaa79a608..6da87a3cfb 100755 --- a/roadscore/prototype/native_receiver.sh +++ b/roadscore/prototype/native_receiver.sh @@ -83,9 +83,23 @@ done bridge_status=0 wait "$bridge_pid" || bridge_status=$? bridge_pid="" -for attempt in $(seq 1 8); do - kill -0 "$audio_pid" 2>/dev/null || break - sleep 1 -done - +# EOF closes the audio stream; keep the replay display alive while it drains. +# Never silently turn a slow/failed audio shutdown into a successful archive. +if [ -n "$audio_pid" ]; then + for attempt in $(seq 1 150); do + kill -0 "$audio_pid" 2>/dev/null || break + sleep .2 + done + if kill -0 "$audio_pid" 2>/dev/null; then + echo 'RoadScore audio did not finish within 30 seconds after replay EOF' + exit 1 + fi + audio_status=0 + wait "$audio_pid" || audio_status=$? + audio_pid="" + if [ "$audio_status" != 0 ]; then + cat results/current/app.log + exit "$audio_status" + fi +fi exit "$bridge_status" diff --git a/roadscore/prototype/normal_onroad.py b/roadscore/prototype/normal_onroad.py index b885f05bd4..e97fedd966 100644 --- a/roadscore/prototype/normal_onroad.py +++ b/roadscore/prototype/normal_onroad.py @@ -61,6 +61,7 @@ env['ROADSCORE_COMPOSITION_POLICY']=composition_policy env['ROADSCORE_COMPOSER']=a.composer env['ROADSCORE_ACE_PROFILE']=a.profile if native:env.setdefault('ROADSCORE_REPLAY_PRIME','1') +if native and not a.replay:env['ROADSCORE_AUDIO_DRAIN_FILE']=str(R/'results/current/audio_drained.json') env['ROADSCORE_OVERLAY_CAPTURE']=str(out/'overlay.png') env['ROADSCORE_STATUS_FILE']=str(out/'roadscore_status.json') initial_display={'readiness':'Preparing','composer':a.composer,'profile':a.profile if a.composer=='ace' else None,'render_mode':a.render_mode,'style':a.profile.title() if a.composer=='ace' else settings.style} diff --git a/roadscore/prototype/normal_ui_audit.py b/roadscore/prototype/normal_ui_audit.py index be7611d6e6..d2fded8711 100644 --- a/roadscore/prototype/normal_ui_audit.py +++ b/roadscore/prototype/normal_ui_audit.py @@ -3,6 +3,14 @@ import os,json,time,runpy from pathlib import Path from openpilot.selfdrive.ui.ui_state import UIState, device from preparing_awake import PreparationWake +from replay_display_hold import ReplayDisplayHold +display_hold=ReplayDisplayHold(os.environ.get("ROADSCORE_AUDIO_DRAIN_FILE"), enabled=os.environ.get("OPENPILOT_PREFIX")=="roadscore_replay") +original_state=UIState._update_state +def replay_state(self,*args,**kwargs): + result=original_state(self,*args,**kwargs) + self.started=display_hold.apply(self.started) + return result +UIState._update_state=replay_state preparation_wake=PreparationWake(os.environ.get("ROADSCORE_STATUS_FILE"),Path("/TICI").exists()) from openpilot.selfdrive.ui.onroad.cameraview import CameraView from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer diff --git a/roadscore/prototype/replay_display_hold.py b/roadscore/prototype/replay_display_hold.py new file mode 100644 index 0000000000..e1b4f7f86a --- /dev/null +++ b/roadscore/prototype/replay_display_hold.py @@ -0,0 +1,43 @@ +"""Keep only the replay presentation visible while its final audio drains.""" +import json +import math +from pathlib import Path +import time + + +class ReplayDisplayHold: + def __init__(self, marker=None, enabled=False, clock=time.monotonic, maximum_hold=20.): + self.marker = Path(marker) if marker else None + self.enabled = bool(enabled and marker) + self.clock = clock + self.started_at = clock() + self.maximum_hold = maximum_hold + self.seen_onroad = False + self.holding_since = None + self.released = False + + def apply(self, started): + if not self.enabled or self.released: + return started + now = self.clock() + try: + value = json.loads(self.marker.read_text()) + wall = value.get('wall') + if (isinstance(wall, (int, float)) and math.isfinite(wall) + and self.started_at <= wall <= now and type(value.get('drained')) is bool): + self.released = True + return started + except (OSError, ValueError, TypeError): + pass + if started: + self.seen_onroad = True + self.holding_since = None + return True + if not self.seen_onroad: + return False + if self.holding_since is None: + self.holding_since = now + if now - self.holding_since >= self.maximum_hold: + self.released = True + return False + return True diff --git a/roadscore/prototype/test_replay_display_hold.py b/roadscore/prototype/test_replay_display_hold.py new file mode 100644 index 0000000000..1832e808a2 --- /dev/null +++ b/roadscore/prototype/test_replay_display_hold.py @@ -0,0 +1,31 @@ +import json +from pathlib import Path +import tempfile +import unittest +from replay_display_hold import ReplayDisplayHold + +class DisplayHoldTests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup) + self.path=Path(self.tmp.name)/'audio_drained.json';self.now=100. + self.hold=ReplayDisplayHold(self.path,True,lambda:self.now) + def marker(self,wall,drained=True):self.path.write_text(json.dumps(dict(wall=wall,drained=drained))) + def test_actual_onroad_required(self): + self.assertFalse(self.hold.apply(False));self.assertTrue(self.hold.apply(True)) + self.now+=1;self.assertTrue(self.hold.apply(False)) + self.now+=2;self.marker(self.now);self.assertFalse(self.hold.apply(False)) + def test_stale_marker_ignored_and_timeout_bounded(self): + self.marker(99);self.hold.apply(True);self.assertTrue(self.hold.apply(False)) + self.now+=20;self.assertFalse(self.hold.apply(False)) + def test_failure_releases_and_cannot_relatch(self): + self.hold.apply(True);self.marker(100,False) + self.assertFalse(self.hold.apply(False));self.assertTrue(self.hold.apply(True));self.assertFalse(self.hold.apply(False)) + def test_disabled_never_holds(self): + hold=ReplayDisplayHold(self.path,False,lambda:self.now) + self.assertTrue(hold.apply(True));self.assertFalse(hold.apply(False)) + def test_partial_and_future_marker_do_not_release(self): + self.hold.apply(True);self.path.write_text('{') + self.assertTrue(self.hold.apply(False));self.marker(200) + self.assertTrue(self.hold.apply(False)) + +if __name__=='__main__':unittest.main()