diff --git a/roadscore/prototype/app.py b/roadscore/prototype/app.py index 461f8b3a75..7272f8c7ae 100644 --- a/roadscore/prototype/app.py +++ b/roadscore/prototype/app.py @@ -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 diff --git a/roadscore/prototype/live_owned_processes.py b/roadscore/prototype/live_owned_processes.py index 0b0ad0768a..40b7fa727b 100644 --- a/roadscore/prototype/live_owned_processes.py +++ b/roadscore/prototype/live_owned_processes.py @@ -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) diff --git a/roadscore/prototype/live_target_adapter.py b/roadscore/prototype/live_target_adapter.py index 9e89278281..1576a294d0 100644 --- a/roadscore/prototype/live_target_adapter.py +++ b/roadscore/prototype/live_target_adapter.py @@ -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') diff --git a/roadscore/prototype/live_target_daemon.py b/roadscore/prototype/live_target_daemon.py index 6d32d103f6..0c9901b7db 100644 --- a/roadscore/prototype/live_target_daemon.py +++ b/roadscore/prototype/live_target_daemon.py @@ -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': diff --git a/roadscore/prototype/test_live_target_adapter.py b/roadscore/prototype/test_live_target_adapter.py index bb74d767f7..a90445f85d 100644 --- a/roadscore/prototype/test_live_target_adapter.py +++ b/roadscore/prototype/test_live_target_adapter.py @@ -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' diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/RoadScore.js b/starpilot/system/the_galaxy/assets/mobile/js/views/RoadScore.js index 548fda7b1b..4bb7693ed2 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/views/RoadScore.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/RoadScore.js @@ -127,36 +127,18 @@ export const RoadScore = { template: `

RoadScore

-

Music shaped by the road.

+

Music shaped by the road. Plays through the system’s default audio output.

RoadScore
{{ status.live?.state || 'UNAVAILABLE' }}
-

{{status.live?.reason || 'Live driving readiness has not been confirmed.'}}

+

{{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.')}}

-
-

Replay simulation changes displayed engagement, turn signals and music. It does not engage or control the vehicle.

-

{{status.demo?.available ? (status.demo.mode === 'engaged' ? 'Simulated engage' : status.demo.mode === 'disengaged' ? 'Simulated disengage' : 'Using recorded engagement') : 'Replay unavailable'}}

-
- - - -
-

{{status.demo?.signal_mode === 'recorded' ? 'Using recorded signals' : 'Simulated signals: ' + (status.demo?.signal_mode || 'recorded')}}

-
- - - - -
-

{{status.demo?.reason}}

-
{{ status.state || 'UNAVAILABLE' }}
{{ status.composer ? status.composer.toUpperCase() : 'Composer not connected' }}{{ status.backend ? ' · ' + status.backend : '' }}{{ status.composer && status.profile ? ' · ' + status.profile.toUpperCase() : '' }}
-
- -

{{ status.locked ? 'Controls unlock when the output service confirms playback and judging are idle.' : 'Use the normal RoadScore launcher to prepare or change styles.' }}

+
+

Choose a style before enabling. RoadScore prepares and starts automatically when ready; stop it before changing styles.

{{status.output?.name || 'Output unavailable'}}{{status.output?.connected ? 'Connected' : 'Not verified'}}
diff --git a/starpilot/system/the_galaxy/roadscore.py b/starpilot/system/the_galaxy/roadscore.py index d546548f30..e64c9a8d08 100644 --- a/starpilot/system/the_galaxy/roadscore.py +++ b/starpilot/system/the_galaxy/roadscore.py @@ -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: diff --git a/starpilot/system/the_galaxy/tests/test_roadscore_live.py b/starpilot/system/the_galaxy/tests/test_roadscore_live.py index d3310eeff0..97a38ee1e7 100644 --- a/starpilot/system/the_galaxy/tests/test_roadscore_live.py +++ b/starpilot/system/the_galaxy/tests/test_roadscore_live.py @@ -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'}