Preserve archived replay origins, audio tails and capture boundaries

This commit is contained in:
firestar5683
2026-09-19 16:22:38 -07:00
parent 97aada4e01
commit 083314d54d
6 changed files with 99 additions and 16 deletions
+9 -6
View File
@@ -19,7 +19,7 @@ def audio_alignment(run, video_origin):
if origin is None:
raise ValueError('Stored audio has no DAC timeline')
score = Path(summary['score']) / 'score.flac'
start = summary['source_frame_start'] / 48000 + video_origin - origin
start = summary['source_frame_start'] / summary.get('sample_rate',48000) + video_origin - origin
kind = 'stored score DAC origin'
if not score.is_file():
raise FileNotFoundError(score)
@@ -29,11 +29,14 @@ def audio_alignment(run, video_origin):
def video_frames(run, frames):
"""Trim preparation using the recorded host capture interval, never a manual offset."""
fresh = Path(run) / 'host_audio_summary.json'
if not fresh.exists():
return list(range(len(frames)))
summary = json.loads(fresh.read_text())
start = summary['first_host_dac_wall']
end = start + summary['host_frames'] / 48000
if fresh.exists():
summary = json.loads(fresh.read_text())
start = summary['first_host_dac_wall']
end = start + summary['host_frames'] / 48000
else:
summary = json.loads((Path(run) / 'stored_summary.json').read_text())
start = summary['first_dac_wall']
end = start + (summary['source_frame_end'] - summary['source_frame_start']) / summary.get('sample_rate',48000)
selected = [i for i, frame in enumerate(frames) if start <= frame['wall'] <= end]
if len(selected) < 2:
raise ValueError('Insufficient UI/audio timeline overlap')
+16 -8
View File
@@ -15,7 +15,8 @@ R=Path(__file__).resolve().parents[1]
native=Path('/TICI').exists()
def interrupt(*_):raise KeyboardInterrupt
signal.signal(signal.SIGTERM,interrupt)
p=argparse.ArgumentParser();p.add_argument('--roadscore-seed',type=seed_argument,help='Reproduce an ACE session; normal launches choose a fresh seed');p.add_argument('--render-mode',choices=['current','gold-core'],default=None,help='ACE rendering mode');p.add_argument('--roadscore-presentation',choices=['conservative-v1','off','frozen']);p.add_argument('route',nargs='?');p.add_argument('--routeid');p.add_argument('--roadscore',action='store_true',required=True);p.add_argument('--replay',action='store_true',help='Play recorded final score without Chestnut');p.add_argument('--start',type=int,default=None);p.add_argument('--duration',type=float,default=float('inf'),help='Optional duration limit; normally replay to route EOF');p.add_argument('--audible',action='store_true',help='Compatibility flag; output is audible by default outside automated sessions');p.add_argument('--muted',action='store_true');p.add_argument('--no-overlay',action='store_true');p.add_argument('--capture-ui',action='store_true',help='Record the normal UI internally without speaker output');p.add_argument('--audio-device',default=None,help='Development host output device; default is the system output');p.add_argument('--transport-only',action='store_true');p.add_argument('--headless',action='store_true');p.add_argument('--runtime',type=Path,default=Path('/data/openpilot') if native else Path(os.environ.get('ROADSCORE_RUNTIME','/Users/dominickthompson/starpilot/.host_runtime/darwin/worktree')));p.add_argument('--bench',default=device_target());p.add_argument('--composer',choices=['sa3','ace'],default=choice(),help='ACE Prism is the event default; SA3 is an explicit fallback');p.add_argument('--profile',choices=['prism','aurora'],default='prism');a=p.parse_args()
p=argparse.ArgumentParser();p.add_argument('--roadscore-seed',type=seed_argument,help='Reproduce an ACE session; normal launches choose a fresh seed');p.add_argument('--render-mode',choices=['current','gold-core'],default=None,help='ACE rendering mode');p.add_argument('--roadscore-presentation',choices=['conservative-v1','off','frozen']);p.add_argument('route',nargs='?');p.add_argument('--routeid');p.add_argument('--roadscore',action='store_true',required=True);p.add_argument('--replay',action='store_true',help='Play recorded final score without Chestnut');p.add_argument('--score-archive',type=Path,help='Imported local archive for --replay');p.add_argument('--start',type=int,default=None);p.add_argument('--duration',type=float,default=float('inf'),help='Optional duration limit; normally replay to route EOF');p.add_argument('--audible',action='store_true',help='Compatibility flag; output is audible by default outside automated sessions');p.add_argument('--muted',action='store_true');p.add_argument('--no-overlay',action='store_true');p.add_argument('--capture-ui',action='store_true',help='Record the normal UI internally without speaker output');p.add_argument('--audio-device',default=None,help='Development host output device; default is the system output');p.add_argument('--transport-only',action='store_true');p.add_argument('--headless',action='store_true');p.add_argument('--runtime',type=Path,default=Path('/data/openpilot') if native else Path(os.environ.get('ROADSCORE_RUNTIME','/Users/dominickthompson/starpilot/.host_runtime/darwin/worktree')));p.add_argument('--bench',default=device_target());p.add_argument('--composer',choices=['sa3','ace'],default=choice(),help='ACE Prism is the event default; SA3 is an explicit fallback');p.add_argument('--profile',choices=['prism','aurora'],default='prism');a=p.parse_args()
explicit_start=a.start
if a.render_mode=='gold-core' and a.composer!='ace':raise SystemExit('Gold core requires ACE')
if a.roadscore_seed is not None and (a.replay or a.composer!='ace'):raise SystemExit('--roadscore-seed applies only to fresh ACE generation')
session=None
@@ -71,14 +72,18 @@ env['PYTHONPATH']+=':'+('/data/roadscore-feasibility/venv/lib/python3.12/site-pa
from route_library import local_source
local=local_source(a.routeid)
services='roadEncodeIdx,wideRoadEncodeIdx,driverEncodeIdx,modelV2,controlsState,onroadEvents,liveCalibration,radarState,deviceState,pandaStates,carParams,driverMonitoringState,carState,driverStateV2,roadCameraState,wideRoadCameraState,managerState,selfdriveState,longitudinalPlan,gpsLocationExternal,mapdOut,carOutput,carControl,liveParameters,starpilotCarState,starpilotPlan,starpilotRadarState,starpilotSelfdriveState,liveTracks,liveDelay,liveTorqueParameters,navInstruction,navRoute,livePose'
score=None;stored_limit=None
if a.score_archive and not a.replay:p.error('--score-archive requires --replay')
if a.replay:
if not native and not local:raise SystemExit('Archived Mac replay requires a local route cache; no network fetch is started')
from score_archive import latest
from stored_replay_policy import replay_archive
try:score=a.score_archive.resolve() if a.score_archive else latest(a.routeid)
except FileNotFoundError:raise SystemExit('No stored RoadScore exists for this route. Generate a score first.')
a.start,stored_limit=replay_archive(score,a.routeid,explicit_start)
args=[a.routeid,'--allow',services,'--start',str(a.start),'--no-loop','--headless','--cache','2']
if local:args+=['--data_dir',str(local)]
if native:args+=['--no-hw-decoder']
score=None
if a.replay:
from score_archive import latest
try:score=latest(a.routeid)
except FileNotFoundError:raise SystemExit('No stored RoadScore exists for this route. Generate a score first.')
display=None;children=[];named_children={};failure=None;logs=[];launch_started=time.monotonic()
print(('Preparing stored score replay; no generation. ' if a.replay else ('Preparing ACE replay; first preparation may take 10–15 minutes. ' if a.composer=='ace' else 'Preparing RoadScore replay; cold preparation can take 2–3 minutes. '))+('Host speaker enabled.' if a.audible else 'Muted host capture.'),flush=True)
def launch(cmd,name,**kw):
@@ -141,11 +146,14 @@ try:
except OSError:pass
try:
native_state=json.loads(state_path.read_text())
if end_watch.observe(native_state,(out/'replay.log').read_text(),time.monotonic()):end_reason='native final segment exhausted';break
if end_watch.observe(native_state,(out/'replay.log').read_text(),time.monotonic()):
end_reason='native final segment exhausted'
if not a.replay:break
except (FileNotFoundError,json.JSONDecodeError):pass
if player.poll() is not None and time.monotonic()-started>2:
if player.returncode:raise RuntimeError('Native replay failed; see replay.log')
break
if not a.replay:break
if a.replay and time.monotonic()-started>stored_limit:raise TimeoutError('Stored score did not finish within its recorded extent')
if audio_host is not None and audio_host.poll() is not None:raise RuntimeError('Host PCM stream stopped; see host_audio.log')
if not a.headless and ui.poll() is not None:raise RuntimeError('Existing normal UI exited; see ui.log')
if time.monotonic()-started>a.duration+90:raise TimeoutError('No progressing replay messages')
+8 -1
View File
@@ -5,9 +5,13 @@ from route_library import ROOT,identity
def prefer_new_score(new,old):
def coverage(m):return (bool(m.get('full_route')) and m.get('replay_start_seconds',0)==0,m.get('replay_start_seconds',0)==0,float(m.get('audio_seconds',0)))
if old.get('complete') is True and new.get('complete') is False:return False
if coverage(new)[0] and coverage(old)[0]:return True
return coverage(new)>=coverage(old)
def recording_complete(blocks, launch, bridge):
return bool(blocks) and launch.get('end_reason')=='native final segment exhausted' and not bridge.get('failure')
def archive(route,run,start=0):
dongle,name=identity(route);parent=ROOT/'routes'/dongle/name/'roadscore';parent.mkdir(parents=True,exist_ok=True)
dest=parent/run.name;dest.mkdir(exist_ok=False)
@@ -28,7 +32,10 @@ def archive(route,run,start=0):
if (run/'runtime_manifest.json').exists():
manifest=json.loads((run/'runtime_manifest.json').read_text());meta['source_identity']=manifest.get('source_identity',{'note':'See runtime manifest; source hash unavailable in this older run'});meta['backend']=manifest.get('backend',meta['backend']);meta['composer']=manifest.get('composer','sa3');meta['profile']=manifest.get('ace_initial_provenance',{}).get('prepared_profile')
launch=json.loads((run/'launch.json').read_text()) if (run/'launch.json').exists() else {}
meta['full_route']=launch.get('end_reason')=='native final segment exhausted' and start==0
bridge=json.loads((run/'bridge.json').read_text()) if (run/'bridge.json').exists() else {}
meta['complete']=recording_complete(blocks,launch,bridge)
meta['completion_reason']='native EOF with captured audio' if meta['complete'] else 'partial or unverified end of recording'
meta['full_route']=meta['complete'] and start==0
meta['audio_seconds']=blocks[-1]['audio_s']+.1 if blocks else 0
meta['output_underruns']=audio_stats.get('portaudio_flags',0)
meta['capture_timing_clean']=not any(audio_stats.get(k,0) for k in ['portaudio_flags','starved_callbacks','late_frames'])
@@ -0,0 +1,25 @@
"""Validate imported PCM extent without loading a composer or contacting a device."""
import json
import math
from pathlib import Path
def replay_archive(score, route, explicit_start=None):
import soundfile as sf
score = Path(score)
meta = json.loads((score / 'metadata.json').read_text())
if meta.get('route') != route:
raise ValueError('Stored score belongs to a different route')
if meta.get('first_model_ns') is None:
raise ValueError('Stored score lacks its measured route clock')
offset = meta.get('audio_file_start_relative_first_model')
if not isinstance(offset, (int, float)) or not math.isfinite(offset):
raise ValueError('Stored score lacks its measured audio clock')
info = sf.info(score / 'score.flac')
if info.frames <= 0:
raise ValueError('Stored score is empty')
start = meta.get('replay_start_seconds', 0) if explicit_start is None else explicit_start
if not isinstance(start, int) or start < 0:
raise ValueError('Stored replay start must be a nonnegative integer')
# Allow replay startup plus the entire captured tail, including positive DAC delay.
return start, info.duration + abs(offset) + max(0, meta.get('replay_start_seconds',0) - start) + 90
+1 -1
View File
@@ -53,6 +53,6 @@ def main():
tmp=a.out/'roadscore_status.tmp';tmp.write_text(json.dumps(state));tmp.replace(a.out/'roadscore_status.json');last_status=time.monotonic()
if started and time.monotonic()-started>=a.duration:break
finally:
(a.out/'stored_summary.json').write_text(json.dumps({'score':str(a.score),'first_dac_wall':first_dac_wall,'frames_presented':count,'portaudio_flags':flags,'muted':not a.audible,'first_model_ns':initial[0] if initial else None,'generation_invoked':False,'source_frame_start':first_frame,'source_frame_end':last_frame,'contiguous_samples_verified':first_frame is not None and digest.hexdigest()==hashlib.sha256(audio[first_frame:last_frame].tobytes()).hexdigest(),'max_clock_alignment_error_seconds':max_error,'synchronization':'original logMonoTime plus recorded host DAC/sample origin'},indent=2))
(a.out/'stored_summary.json').write_text(json.dumps({'score':str(a.score),'first_dac_wall':first_dac_wall,'sample_rate':rate,'frames_presented':count,'portaudio_flags':flags,'muted':not a.audible,'first_model_ns':initial[0] if initial else None,'generation_invoked':False,'source_frame_start':first_frame,'source_frame_end':last_frame,'contiguous_samples_verified':first_frame is not None and digest.hexdigest()==hashlib.sha256(audio[first_frame:last_frame].tobytes()).hexdigest(),'max_clock_alignment_error_seconds':max_error,'synchronization':'original logMonoTime plus recorded host DAC/sample origin'},indent=2))
if max_error>.05:raise RuntimeError('Stored-score audio clock drift exceeded 50ms')
if __name__=='__main__':main()
@@ -0,0 +1,40 @@
import json
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from stored_replay_policy import replay_archive
from score_archive import recording_complete, prefer_new_score
from capture_alignment import video_frames
class StoredReplayTests(unittest.TestCase):
def test_archive_offset_and_explicit_seek(self):
with tempfile.TemporaryDirectory() as d:
p=Path(d)
meta={'route':'route','first_model_ns':123,'audio_file_start_relative_first_model':.25,'replay_start_seconds':149}
(p/'metadata.json').write_text(json.dumps(meta))
fake=SimpleNamespace(info=lambda _:SimpleNamespace(frames=14400000,duration=300))
with patch.dict('sys.modules',soundfile=fake):
self.assertEqual(replay_archive(p,'route'),(149,390.25))
self.assertEqual(replay_archive(p,'route',160),(160,390.25))
self.assertEqual(replay_archive(p,'route',0),(0,539.25))
with self.assertRaisesRegex(ValueError,'different route'):replay_archive(p,'other')
meta['audio_file_start_relative_first_model']=None
(p/'metadata.json').write_text(json.dumps(meta))
with self.assertRaisesRegex(ValueError,'audio clock'):replay_archive(p,'route')
def test_partial_does_not_replace_complete_excerpt(self):
self.assertFalse(prefer_new_score({'complete':False,'audio_seconds':400},{'complete':True,'replay_start_seconds':149,'audio_seconds':300}))
def test_partial_audio_is_not_complete(self):
eof={'end_reason':'native final segment exhausted'}
self.assertTrue(recording_complete([{}],eof,{}))
self.assertFalse(recording_complete([],eof,{}))
self.assertFalse(recording_complete([{}],{'end_reason':'requested duration'},{}))
self.assertFalse(recording_complete([{}],eof,{'failure':'clock lost'}))
def test_stored_video_trims_startup_and_preserves_tail(self):
with tempfile.TemporaryDirectory() as d:
p=Path(d)
(p/'stored_summary.json').write_text(json.dumps({'first_dac_wall':200,'source_frame_start':44100,'source_frame_end':132300,'sample_rate':44100}))
self.assertEqual(video_frames(p,[{'wall':w} for w in [10,199,200,201,202,203]]),[2,3,4])
with self.assertRaises(ValueError):video_frames(p,[{'wall':10}])
if __name__=='__main__':unittest.main()