Seek local demo video and music together from timeline slider

This commit is contained in:
firestar5683
2026-09-22 21:09:34 -05:00
parent 407f783a38
commit 1dfbcffe82
3 changed files with 58 additions and 5 deletions
+9 -1
View File
@@ -2,11 +2,17 @@
<style>body{background:#101215;color:#eee;font:20px -apple-system,system-ui;margin:35px auto;max-width:760px;padding:15px}h1{font-size:34px;margin-bottom:8px}p{color:#aaa;line-height:1.5}section{margin-top:30px}button{font:inherit;background:#282d34;color:white;border:1px solid #454d59;border-radius:12px;padding:18px;margin:6px;cursor:pointer}button.on{background:#245e95;border-color:#78c7ff}button:disabled{opacity:.4;cursor:default}small{color:#aaa}#error{color:#ffba94}</style>
<h1>RoadScore demo controls</h1><p>Prepared Prism music · live presentation controls<br>These controls simulate the replay display and music only.</p>
<p id="scope" aria-live="polite">Checking control targets…</p>
<section><label for="timeline">Timeline <span id="timeLabel">0:00</span></label><input id="timeline" type="range" min="0" max="300" step="0.1" value="0" style="display:block;width:100%;margin:20px 0" aria-label="Demo timeline"></section>
<section><button data-field="mode" data-value="engaged">Simulate engage</button><button data-field="mode" data-value="disengaged">Simulate disengage</button><button data-field="mode" data-value="recorded">Recorded engagement</button></section>
<section><button data-field="signal_mode" data-value="left">Left signal</button><button data-field="signal_mode" data-value="right">Right signal</button><button data-field="signal_mode" data-value="off">Signals off</button><button data-field="signal_mode" data-value="recorded">Recorded signals</button></section>
<p id="state">Waiting for replay…</p><p id="targets" aria-live="polite">Mac waiting; comma not paired</p><p id="following" aria-live="polite"></p><p id="error"></p><small>Saved Prism music with real-time replay reactions. No vehicle control.</small>
<script>
let session;const buttons=[...document.querySelectorAll('button')];
let session, dragging=false;
const timeline=document.querySelector('#timeline');
const stamp=n=>Math.floor(n/60)+':'+String(Math.floor(n%60)).padStart(2,'0');
timeline.oninput=()=>{dragging=true;document.querySelector('#timeLabel').textContent=stamp(+timeline.value)};
timeline.onchange=async()=>{try{const r=await fetch('/control',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session_id:session,seek_seconds:+timeline.value})});const j=await r.json();if(!r.ok)throw Error(j.error);document.querySelector('#error').textContent=''}catch(e){document.querySelector('#error').textContent=e.message}finally{dragging=false}};
const buttons=[...document.querySelectorAll('button')];
buttons.forEach(b=>b.onclick=async()=>{try{const r=await fetch('/control',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session_id:session,[b.dataset.field]:b.dataset.value})});const j=await r.json();if(!r.ok)throw Error(j.error);document.querySelector('#error').textContent=''}catch(e){document.querySelector('#error').textContent=e.message}});
function commaStatus(paired){
if(!paired?.enabled)return 'not paired';
@@ -25,6 +31,8 @@ function commaStatus(paired){
async function refresh(){
try{
const r=await fetch('/status');if(!r.ok)throw Error('Status unavailable');const j=await r.json();session=j.demo.session_id;
timeline.disabled=!j.demo.available||!j.seek_enabled;timeline.max=Math.max(0,(j.duration_s||1)-1);
if(!dragging&&!j.seeking){timeline.value=j.audio_s||0;document.querySelector('#timeLabel').textContent=stamp(j.audio_s||0)+' / '+stamp(j.duration_s||0)}
// Peer readiness never disables the independent local controls.
buttons.forEach(b=>{b.disabled=!j.demo.available;b.classList.toggle('on',j.demo[b.dataset.field]===b.dataset.value)});
document.querySelector('#state').textContent=j.state+' · '+Math.max(0,j.audio_s||0).toFixed(1)+' s · '+(j.muted?'MUTED':'system audio output');
+37 -4
View File
@@ -151,6 +151,16 @@ def control_server(project, out, shared, port, forwarder=None, *, follow_peer=Fa
length = int(self.headers.get('Content-Length', '0'))
if not 0 < length <= 1024:raise ValueError('Invalid control size')
data = json.loads(self.rfile.read(length))
if 'seek_seconds' in data:
demo = operator.demo_status()
target = data['seek_seconds']
if (forwarder is not None or not shared.get('seek_enabled') or not demo['available']
or data.get('session_id') != demo['session_id']):
raise ValueError('Seeking requires the current local replay session')
if type(target) not in (int, float) or not math.isfinite(target) or not 0 <= target <= shared['duration_s']-1:
raise ValueError('Seek outside the saved demo')
shared['seek_request'] = (float(target), time.monotonic())
self.send_json({'seek_seconds':target});return
field = 'signal_mode' if 'signal_mode' in data else 'mode'
future = None
with control_lock:
@@ -258,7 +268,8 @@ def audio_worker(a):
source_clock = dict(last_drift_seconds=0.,max_abs_drift_seconds=0.)
output_latency = None
failure = None
shared = dict(state='PREPARING',audio_s=0.,muted=a.muted)
shared = dict(state='PREPARING',audio_s=0.,muted=a.muted,duration_s=len(audio)/rate,seek_enabled=not a.paired_comma, seeking=False)
pending_seek = None
def callback(out, n, ti, status):
nonlocal position,rendered,done,flags,max_drift,first_frame,callback_revision,last_callback_wall
out.fill(0)
@@ -266,7 +277,7 @@ def audio_worker(a):
if len(clock_observations)<24:
clock_observations.append((float(time.monotonic()),float(ti.currentTime),float(ti.outputBufferDacTime)))
return
if anchor is None or done:return
if anchor is None or done or shared['seeking']:return
now=time.monotonic()
gap=now-last_callback_wall if last_callback_wall is not None else 0.
last_callback_wall=now
@@ -276,7 +287,7 @@ def audio_worker(a):
try:
dac=bridge.dac_wall(float(ti.outputBufferDacTime))
expected=initial_frame(meta,active_anchor[0],dac-active_anchor[1],rate)
explicit_seek=sync is not None and active_anchor[2]!=callback_revision
explicit_seek=active_anchor[2]!=callback_revision
chunk,clock_info=playback_clock.render(audio,expected,n,explicit_seek=explicit_seek)
callback_revision=active_anchor[2]
start=clock_info['source_frame'];position=clock_info['next_source_frame']
@@ -324,6 +335,17 @@ def audio_worker(a):
(a.out/'prepared_ready').write_text('ready')
while not done:
sm.update(50);now=time.monotonic();controls.poll()
request = shared.pop('seek_request', None)
if request is not None and anchor is not None:
target, _ = request
transport = json.loads(replay_state_path.read_text())
current_audio = (sm.logMonoTime['modelV2']-meta['first_model_ns'])/1e9-meta['audio_offset']
seek_to = transport['cur_sec'] + target-current_audio
shared['seeking'] = True
pending_seek = (target, now+12.)
write_json(replay_command_path, {'seek':seek_to, 'play':True})
if pending_seek is not None and now > pending_seek[1]:
raise TimeoutError('Replay did not acknowledge the timeline seek')
tail.observe(int(sm.logMonoTime['modelV2']),now,position)
for name in received:
if sm.updated[name]:received[name]=now
@@ -335,6 +357,17 @@ def audio_worker(a):
if sm.updated['modelV2']:
mono=sm.logMonoTime['modelV2']
if anchor is None:anchor=(mono,now,0);started=now
if pending_seek is not None:
at = (mono-meta['first_model_ns'])/1e9-meta['audio_offset']
if replay_command_path.exists() or abs(at-pending_seek[0]) > 1.5:
continue
processor=PreparedPresentation(a.score_archive,rate)
conductor=Conductor(handoff=True)
tail=RecordedTail(meta['archived_tail'])
rendered=None
anchor=(mono,now,anchor[2]+1)
pending_seek=None
shared['seeking']=False
drift=(mono-anchor[0])/1e9-(now-anchor[1])
if sync is not None and sync.consume_reanchor(drift,now):
# This mode is always muted. Reset presentation filters outside the
@@ -383,7 +416,7 @@ def audio_worker(a):
write_json(a.out/'status.json',snapshot)
trace.write(json.dumps({**snapshot,'audio_s':shared['audio_s']})+'\n');last_status=now
if started and now-started>=a.duration:break
if (started and now-received['modelV2']>2
if (started and pending_seek is None and now-received['modelV2']>2
and ((position or 0)/rate < len(audio)/rate-2 or tail.terminal_wall is not None)
and not tail.allows_stale(int(sm.logMonoTime['modelV2']),now)):
raise RuntimeError('Replay model stream stopped before prepared audio ended')
@@ -45,6 +45,18 @@ class ControlTests(unittest.TestCase):
self.server = None
self.addCleanup(self.stop)
def test_local_timeline_bounds_and_session(self):
shared = dict(state='READY', audio_s=1., muted=True, seek_enabled=True, duration_s=301.7)
self.server = control_server(self.project, self.out, shared, 0)
for target in (-1, 301.7, True, '20'):
status, _ = self.request('POST', '/control', {'session_id':'mac-session', 'seek_seconds':target})
self.assertEqual(status, 400)
status, _ = self.request('POST', '/control', {'session_id':'stale', 'seek_seconds':20})
self.assertEqual(status, 400)
status, _ = self.request('POST', '/control', {'session_id':'mac-session', 'seek_seconds':20})
self.assertEqual(status, 200)
self.assertEqual(shared['seek_request'][0], 20)
def write_state(self):
self.state['command_wall'] = time.monotonic()
(self.out/'status.json').write_text(json.dumps(self.state))