mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-04 05:13:46 +08:00
Connect Galaxy live RoadScore controls to automatic playback
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user