Connect Galaxy live RoadScore controls to automatic playback

This commit is contained in:
firestar5683
2026-09-22 21:46:19 -05:00
parent 1dfbcffe82
commit f7d982fbb4
8 changed files with 72 additions and 31 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ run=root/'results/current';run.mkdir(parents=True,exist_ok=True)
presentation_session_id=f'{os.getpid()}-{time.monotonic_ns()}'
demo_engagement=DemoEngagement(run,presentation_session_id,a.input)
output_metadata={'bluetooth_selected':False,'muted':a.mute}
if Path('/TICI').exists() and not a.mute and os.environ.get('ROADSCORE_PCM_RETURN')!='1':
if Path('/TICI').exists() and not a.mute and os.environ.get('ROADSCORE_PCM_RETURN')!='1' and os.environ.get('ROADSCORE_OUTPUT_POLICY')!='system-default':
from bluetooth_output import prepare_output
output_metadata.update(prepare_output(run))
import sounddevice as sd
+3 -2
View File
@@ -33,9 +33,9 @@ class OwnedLiveProcesses:
with (generated/'gpu.lock').open('a') as check:
fcntl.flock(check,fcntl.LOCK_EX|fcntl.LOCK_NB)
config=read(generated/'live_config.json')
profile=config.get('profile','prism')
profile=read(generated/'operator_settings.json').get('profile',config.get('profile','prism'))
if profile not in ('prism','aurora'):raise ValueError('Unsupported live profile')
bank=Path(config.get('plan_bank','/data/roadscore-event-assets/conditioning/current'))
bank=Path(config.get('plan_banks',{}).get(profile,config.get('plan_bank','/data/roadscore-event-assets/conditioning/current')))
from cached_composition import validate_bank,digest
validate_bank(bank,profile=profile)
self.bank_hash=digest(bank/'bank.json')
@@ -85,6 +85,7 @@ class OwnedLiveProcesses:
env=self.env.copy();env['ROADSCORE_FORCE_MUTE']='0' if audible else '1'
env['PYTHONPATH']=':'.join(['/data/openpilot',str(self.root/'prototype'),'/data/roadscore-feasibility/venv/lib/python3.12/site-packages'])
env['ROADSCORE_LIVE_SESSION_ID']=session_id
env['ROADSCORE_OUTPUT_POLICY']='system-default'
log=(self.folder/'app.log').open('ab');self.logs.append(log)
self.app_started=self.clock()
self.app=self.popen(['/usr/local/venv/bin/python','-u',str(self.root/'prototype/app.py'),'--root',str(self.root),'--input','live']+(['--audible'] if audible else []),cwd='/data/openpilot',env=env,stdout=log,stderr=log,stdin=subprocess.DEVNULL,start_new_session=True)
+1 -1
View File
@@ -62,7 +62,7 @@ class TargetAdapter:
return self.collector.collect()
def enable(self,observation=None,authorization=None):
self._ensure();return self._call('enable')
self._ensure();return self._call('enable',auto_play=True)
def prepare_diagnostic(self):
self._ensure();return self._call('diagnostic')
+8 -3
View File
@@ -18,7 +18,7 @@ from live_owned_processes import OwnedLiveProcesses
class Engine:
def __init__(self,root,collector,owned=None,*,clock=time.monotonic):
self.root=Path(root);self.collector=collector;self.owned=owned or OwnedLiveProcesses(root)
self.clock=clock;self.diagnostic=False;self.audible=False;self.stop_event=threading.Event();self.commands=queue.Queue()
self.clock=clock;self.auto_play=False;self.diagnostic=False;self.audible=False;self.stop_event=threading.Event();self.commands=queue.Queue()
self.owned.cancel_check=self.stop_event.is_set
self.supervisor=LiveSupervisor(self._prepare,self._app,self.owned.stop_owned,clock=clock)
self.observation=None;self.authorization=None;self.health_error='No live observations yet';self.started=None;self.last_recorded=0.;self.diagnostic_ready_at=None
@@ -43,6 +43,7 @@ class Engine:
def status(self):
return {**self.supervisor.status(),'available':True,'diagnostic':self.diagnostic,'health_error':self.health_error,
'parked':bool(self.observation is not None and self.observation.parked and 0<=self.clock()-self.observation.monotonic<=1.),
'can_prepare_diagnostic':not self.supervisor.enabled and self.diagnostic_ok(),
'metrics':self.collector.last_snapshot}
@@ -56,6 +57,7 @@ class Engine:
if action not in ('enable','diagnostic','driver_ready'):raise ValueError('Unknown live action')
if self.stop_event.is_set():raise RuntimeError('Owned live processes are stopping')
self.refresh()
if action=='enable' and type(payload.get('auto_play',False)) is not bool:raise ValueError('auto_play must be boolean')
if action=='driver_ready':
if type(payload.get('audible',False)) is not bool:raise ValueError('audible must be boolean')
if self.diagnostic:raise RuntimeError('Diagnostic preparation cannot start playback or authorize driving')
@@ -65,12 +67,13 @@ class Engine:
if action=='enable' and self.diagnostic and self.supervisor.state=='READY':
reason=blocked_reason(self.observation,self.authorization,self.clock(),require_parked=True)
if reason:raise RuntimeError(reason)
self.diagnostic=False;self.owned.diagnostic=False;self.supervisor.reason='Verified live preparation; explicit current driver-ready confirmation required'
self.auto_play=payload.get('auto_play',False);self.audible=self.auto_play
self.diagnostic=False;self.owned.diagnostic=False;self.supervisor.reason=('Starting prepared music' if self.auto_play else 'Verified live preparation; explicit current driver-ready confirmation required')
if getattr(self.owned,'folder',None) is not None:
(self.owned.folder/'production_authorization.json').write_text(json.dumps({'wall':time.time(),'session_id':self.supervisor.session_id,'authorization':asdict(self.authorization)}))
return self.status()
raise RuntimeError('A live session already exists; stop it before changing mode')
self.diagnostic=action=='diagnostic';self.audible=False;self.diagnostic_ready_at=None
self.diagnostic=action=='diagnostic';self.auto_play=action=='enable' and payload.get('auto_play',False);self.audible=self.auto_play;self.diagnostic_ready_at=None
if self.diagnostic:
if not self.diagnostic_ok():raise RuntimeError('Fresh healthy parked diagnostic preflight is required')
# This authorizes only muted worker preparation, not production capability.
@@ -106,6 +109,8 @@ class Engine:
if self.supervisor.state=='LIVE' and not health['app_ready']:
self.supervisor.stop('Live app status is stale or playback has stopped');return
self.supervisor.tick(self.observation,self.authorization,**health)
if self.auto_play and self.supervisor.state=='READY' and not self.stop_event.is_set():
self.supervisor.confirm_driver_ready(self.supervisor.session_id,self.observation,self.authorization)
if self.supervisor.state=='STARTING' and self.clock()-getattr(self.owned,'app_started',self.clock())>30:
self.supervisor.stop('Live app failed to establish current input/playback readiness')
if self.started is not None and self.clock()-self.started>1500 and self.supervisor.state=='PREPARING':
@@ -142,3 +142,33 @@ def test_ready_file_alone_does_not_claim_live_playback(tmp_path):
assert owned.health()['app_ready']
(current/'status.json').write_text(json.dumps({'route':'live','command_wall':owned.clock()-3,'elapsed':1}))
assert not owned.health()['app_ready']
def test_auto_play_starts_audibly_only_after_accepted_buffer(engine):
engine.owned.app_started=10.
engine.owned.health.return_value['accepted_ready']=False
engine.command({'command':'enable','auto_play':True})
engine.tick()
engine.owned.start_app.assert_not_called()
engine.owned.health.return_value['accepted_ready']=True
engine.tick()
engine.owned.start_app.assert_called_once_with(engine.supervisor.session_id,audible=True)
assert engine.supervisor.state=='STARTING'
engine.owned.health.return_value['app_ready']=True
engine.tick()
assert engine.supervisor.state=='LIVE'
def test_auto_play_does_not_bypass_parked_health(engine):
engine.command({'command':'enable','auto_play':True})
engine.collector.observation=replace(GOOD,parked=False)
engine.tick()
engine.owned.start_app.assert_not_called()
assert not engine.supervisor.enabled
def test_diagnostic_ignores_auto_play_request(engine):
engine.command({'command':'diagnostic','auto_play':True})
engine.tick()
engine.owned.start_app.assert_not_called()
assert engine.supervisor.state=='READY'
@@ -127,36 +127,18 @@ export const RoadScore = {
template: `
<div class="gx-view">
<h2 style="margin-top:0"><i class="bi bi-music-note-beamed"></i> RoadScore</h2>
<p style="color:var(--text-muted)">Music shaped by the road.</p>
<p style="color:var(--text-muted)">Music shaped by the road. Plays through the system’s default audio output.</p>
<GalaxySection title="Live music" icon="bi-music-note-beamed" :collapsible="false"><div style="padding:16px">
<div class="gx-row"><div><strong>RoadScore</strong><div class="gx-row__desc">{{ status.live?.state || 'UNAVAILABLE' }}</div></div>
<label class="gx-switch"><input type="checkbox" aria-label="Enable live RoadScore" :checked="status.live?.enabled === true" :disabled="!status.live?.available || (!status.live?.enabled && (!status.live?.can_enable || liveStarting))" @change="setLive($event.target.checked, $event)"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label>
</div>
<p class="gx-row__desc">{{status.live?.reason || 'Live driving readiness has not been confirmed.'}}</p>
<p class="gx-row__desc">{{status.live?.reason || (status.live?.state === 'LIVE' ? 'Music is reacting to live road signals.' : status.live?.can_enable ? 'Ready to enable.' : 'Live readiness has not been confirmed.')}}</p>
<button class="gx-btn" @click="setLive(false)">Stop RoadScore</button>
</div></GalaxySection>
<GalaxySection title="Replay simulation" icon="bi-play-circle" :collapsible="false"><div style="padding:16px">
<p class="gx-row__desc">Replay simulation changes displayed engagement, turn signals and music. It does not engage or control the vehicle.</p>
<p><strong>{{status.demo?.available ? (status.demo.mode === 'engaged' ? 'Simulated engage' : status.demo.mode === 'disengaged' ? 'Simulated disengage' : 'Using recorded engagement') : 'Replay unavailable'}}</strong></p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="gx-btn" :disabled="busy || !status.demo?.available" @click="action('demo_engagement', {session_id:status.demo.session_id, mode:'engaged'})">Simulate engage</button>
<button class="gx-btn" :disabled="busy || !status.demo?.available" @click="action('demo_engagement', {session_id:status.demo.session_id, mode:'disengaged'})">Simulate disengage</button>
<button class="gx-btn" :disabled="busy || !status.demo?.available" @click="action('demo_engagement', {session_id:status.demo.session_id, mode:'recorded'})">Recorded engagement</button>
</div>
<p><strong>{{status.demo?.signal_mode === 'recorded' ? 'Using recorded signals' : 'Simulated signals: ' + (status.demo?.signal_mode || 'recorded')}}</strong></p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="gx-btn" :disabled="busy || !status.demo?.available" @click="action('demo_signal', {session_id:status.demo.session_id, signal_mode:'left'})">Left signal</button>
<button class="gx-btn" :disabled="busy || !status.demo?.available" @click="action('demo_signal', {session_id:status.demo.session_id, signal_mode:'right'})">Right signal</button>
<button class="gx-btn" :disabled="busy || !status.demo?.available" @click="action('demo_signal', {session_id:status.demo.session_id, signal_mode:'off'})">Signals off</button>
<button class="gx-btn" :disabled="busy || !status.demo?.available" @click="action('demo_signal', {session_id:status.demo.session_id, signal_mode:'recorded'})">Use recorded signals</button>
</div>
<p v-if="!status.demo?.available" class="gx-row__desc">{{status.demo?.reason}}</p>
</div></GalaxySection>
<GalaxySection title="Composer" icon="bi-music-note-beamed" :collapsible="false"><div style="padding:16px">
<div class="gx-row"><div><strong>{{ status.state || 'UNAVAILABLE' }}</strong><div class="gx-row__desc">{{ status.composer ? status.composer.toUpperCase() : 'Composer not connected' }}{{ status.backend ? ' · ' + status.backend : '' }}{{ status.composer && status.profile ? ' · ' + status.profile.toUpperCase() : '' }}</div></div></div>
<div class="gx-row"><label for="roadscore-style">Next style</label><select class="gx-field" id="roadscore-style" v-model="profile" :disabled="busy || !status.can_edit" @change="action('settings', {profile})"><option v-for="p in status.profiles || []" :value="p.id">{{p.name}}</option></select></div>
<button class="gx-btn" :disabled="busy || !status.can_prepare" @click="action('prepare')">{{status.preparing ? 'Preparing…' : 'Prepare composer'}}</button>
<p class="gx-row__desc">{{ status.locked ? 'Controls unlock when the output service confirms playback and judging are idle.' : 'Use the normal RoadScore launcher to prepare or change styles.' }}</p>
<div class="gx-row"><label for="roadscore-style">Style</label><select class="gx-field" id="roadscore-style" v-model="profile" :disabled="busy || !status.can_edit" @change="action('settings', {profile})"><option v-for="p in status.profiles || []" :value="p.id">{{p.name}}</option></select></div>
<p class="gx-row__desc">Choose a style before enabling. RoadScore prepares and starts automatically when ready; stop it before changing styles.</p>
</div></GalaxySection>
<GalaxySection title="Audio output" icon="bi-speaker" :collapsible="false"><div style="padding:16px">
<div class="gx-row"><strong>{{status.output?.name || 'Output unavailable'}}</strong><span>{{status.output?.connected ? 'Connected' : 'Not verified'}}</span></div>
+14 -2
View File
@@ -221,14 +221,15 @@ class Operator:
if not offroad:demo.update(available=False,reason='Replay simulation requires the vehicle to be offroad.')
prepared_demo = demo['available'] and demo.get('compute') == 'prepared-core'
if prepared_demo and demo.get('readiness') in STATES:state = demo['readiness']
return dict(available=self.device and self.root.exists(), state=state, profiles=PROFILES, live=self.live_status(), demo=demo,
live_status=self.live_status()
return dict(available=self.device and self.root.exists(), state=state, profiles=PROFILES, live=live_status, demo=demo,
profile=worker.get('profile') if live else settings.get('profile', 'prism'),
selected_profile=settings.get('profile', 'prism'),
composer='ace' if live or prepared_demo else None, backend='Prepared local audio' if prepared_demo else 'Chestnut' if live else None,
generation_seed=worker.get('generation_seed') if live else None,
offroad=bool(offroad), locked=bool(locked), preparing=self.preparing,
can_prepare=False,
can_edit=False, can_calibrate=offroad and not locked and output.get('calibration', False),
can_edit=bool((offroad or live_status.get('parked') is True) and live_status.get('enabled') is False), can_calibrate=offroad and not locked and output.get('calibration', False),
can_adjust=offroad and not locked and not output.get('calibrating', False) and output.get('timing_compensation', False),
calibrating=output.get('calibrating', False), session_muted=output.get('session_muted', True), output=output.get('output'), latency_ms=output.get('latency_ms'), error=self.error or output.get('error'))
@@ -253,6 +254,17 @@ class Operator:
if status.get('available') is not True or status.get('can_enable') is not True:
raise ValueError(status.get('reason') or 'Live driving readiness has not been confirmed')
return controller.set_enabled(data['enabled'])
if action == 'settings' and isinstance(data,dict) and set(data) == {'profile'}:
data=validate_settings(data)
with self.lock:
live_status=self.live_status()
if (not offroad and live_status.get('parked') is not True) or live_status.get('enabled') is not False:
raise ValueError('Stop RoadScore and park before changing style')
path=self.root/'generated/operator_settings.json'
previous=read_json(path);previous.update(data)
path.parent.mkdir(parents=True,exist_ok=True)
temporary=path.with_suffix('.tmp');temporary.write_text(json.dumps(previous));temporary.replace(path)
return {'ok':True}
if not offroad and action != 'calibration_cancel':
raise ValueError('RoadScore controls are available while parked')
with self.lock:
@@ -18,6 +18,17 @@ class LiveTests(unittest.TestCase):
self.operator.live_owner = self.controller
self.operator.target = Mock(side_effect=AssertionError('Bench/output owner must not be controlled'))
def test_style_persists_without_output_or_bench_actions(self):
self.operator.operate('settings', {'profile':'aurora'}, True)
settings=module.read_json(Path(self.folder.name)/'generated/operator_settings.json')
self.assertEqual(settings['profile'],'aurora')
self.operator.target.assert_not_called()
self.controller.status.return_value['enabled']=True
with self.assertRaises(ValueError):self.operator.operate('settings', {'profile':'prism'}, True)
def test_unimplemented_style_is_rejected(self):
with self.assertRaises(ValueError):self.operator.operate('settings', {'profile':'fake'}, True)
def test_off_is_allowed_onroad_without_authorization_or_status(self):
self.controller.status.side_effect = RuntimeError('Health snapshot unavailable')
self.controller.set_enabled.return_value = {'enabled': False, 'state': 'STOPPING'}