From cc96be16f65b085f7eba9c2af742319d48403e42 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:03:27 -0700 Subject: [PATCH] Give prepared native Bluetooth playback more output headroom --- roadscore/prototype/mac_showcase.py | 30 +++++++++++- .../prototype/native_prepared_showcase.py | 2 +- .../prototype/test_mac_showcase_latency.py | 46 +++++++++++++++++++ .../test_native_prepared_showcase.py | 6 +++ 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 roadscore/prototype/test_mac_showcase_latency.py diff --git a/roadscore/prototype/mac_showcase.py b/roadscore/prototype/mac_showcase.py index 78e7b7e525..785fb42643 100644 --- a/roadscore/prototype/mac_showcase.py +++ b/roadscore/prototype/mac_showcase.py @@ -3,6 +3,7 @@ import argparse import importlib.util import hashlib import json +import math import os from pathlib import Path import signal @@ -181,7 +182,29 @@ def control_server(project, out, shared, port, forwarder=None, *, follow_peer=Fa return server +def output_latency_seconds(value): + try:seconds=float(value) + except (TypeError,ValueError):raise argparse.ArgumentTypeError('Output latency must be 0.02–0.5 seconds') from None + if isinstance(value,bool) or not math.isfinite(seconds) or not .02<=seconds<=.5: + raise argparse.ArgumentTypeError('Output latency must be 0.02–0.5 seconds') + return seconds + + +def native_bluetooth_latency(a, environ=None): + value=a.output_latency + if value is None:return None + env=os.environ if environ is None else environ + from bluetooth_output import ADDRESS, PCM_NAME + identity=a.output_identity or '' + if (not a.audio_worker or not a.no_control_server or env.get('OPENPILOT_PREFIX')!='roadscore_replay' + or env.get('ROADSCORE_PREPARED_SHOWCASE')!='1' or a.audio_device!=PCM_NAME + or not identity.startswith('bluealsa:') or not ADDRESS.fullmatch(identity.removeprefix('bluealsa:'))): + raise ValueError('Output latency override requires the prepared native Bluetooth worker') + return output_latency_seconds(value) + + def audio_worker(a): + requested_output_latency=native_bluetooth_latency(a) import numpy as np import sounddevice as sd from cereal import messaging @@ -287,7 +310,7 @@ def audio_worker(a): if not a.no_control_server: server = control_server(a.project_root,a.out,shared,a.port,forwarder,follow_peer=forwarder is not None) write_json(a.out/'controls.json',{'url':f'http://127.0.0.1:{server.server_port}/'}) - with sd.OutputStream(device=a.audio_device,samplerate=rate,channels=2,blocksize=960,dtype='float32',callback=callback) as stream: + with sd.OutputStream(device=a.audio_device,samplerate=rate,channels=2,blocksize=960,dtype='float32',latency=requested_output_latency,callback=callback) as stream: output_latency=float(stream.latency) calibration_deadline=time.monotonic()+2. while len(clock_observations)<20: @@ -377,7 +400,7 @@ def audio_worker(a): finally: trace.close() if sync_trace is not None:sync_trace.close() - write_json(a.out/'prepared_summary.json',dict(generation_invoked=False,source=str(a.score_archive),first_source_frame=first_frame,last_source_frame=position,sample_rate=rate,portaudio_flags=flags,max_clock_error_seconds=max_drift,prepared_clock=playback_clock.snapshot(),stream_clock_bridge=bridge.snapshot() if bridge else None,callback_errors=errors,clock_errors=clock_errors,callback_timing=callback_timing,output_latency_seconds=output_latency,source_clock=source_clock,failure=failure,archived_tail=meta['archived_tail'],muted=a.muted,session_id=session,manual_scope='isolated replay display and presentation only')) + write_json(a.out/'prepared_summary.json',dict(generation_invoked=False,source=str(a.score_archive),first_source_frame=first_frame,last_source_frame=position,sample_rate=rate,portaudio_flags=flags,max_clock_error_seconds=max_drift,prepared_clock=playback_clock.snapshot(),stream_clock_bridge=bridge.snapshot() if bridge else None,callback_errors=errors,clock_errors=clock_errors,callback_timing=callback_timing,requested_output_latency_seconds=requested_output_latency,output_latency_seconds=output_latency,source_clock=source_clock,failure=failure,archived_tail=meta['archived_tail'],muted=a.muted,session_id=session,manual_scope='isolated replay display and presentation only')) if errors:raise RuntimeError(errors[0]) if abs(playback_clock.snapshot()['current_post_error_seconds'])>.05:raise RuntimeError('Prepared audio clock drift remained above 50 ms after recovery') @@ -412,6 +435,7 @@ def parser(): p.add_argument('--no-control-server',action='store_true',help=argparse.SUPPRESS) p.add_argument('--presentation-root',type=Path,help=argparse.SUPPRESS) p.add_argument('--output-identity',help=argparse.SUPPRESS) + p.add_argument('--output-latency',type=output_latency_seconds,help=argparse.SUPPRESS) p.add_argument('--hold-start',action='store_true',help=argparse.SUPPRESS) p.add_argument('--fullscreen',action='store_true',help='Show the native Mac UI fullscreen; F/F11 toggles, Escape returns to its window') p.add_argument('--follow-playhead',action='store_true',help=argparse.SUPPRESS) @@ -430,6 +454,8 @@ def parser(): def main(): a=parser().parse_args() + if a.output_latency is not None and not a.audio_worker: + raise ValueError('Output latency override is private to the prepared native Bluetooth worker') if a.audio_worker: if a.no_control_server and a.paired_comma:raise SystemExit('Paired controls require the local control server') if a.port is None:a.port=0 diff --git a/roadscore/prototype/native_prepared_showcase.py b/roadscore/prototype/native_prepared_showcase.py index a9a9156f71..f0961e7d5b 100644 --- a/roadscore/prototype/native_prepared_showcase.py +++ b/roadscore/prototype/native_prepared_showcase.py @@ -95,7 +95,7 @@ def worker_arguments(project, root, out, archive, route, muted, curve_plan=None, if muted:args.append('--muted') if curve_plan is not None:args += ['--curve-plan',str(curve_plan)] if output.get('bluetooth_selected'): - args += ['--audio-device',output['pcm_name'],'--output-identity',output['output_identity']] + args += ['--audio-device',output['pcm_name'],'--output-identity',output['output_identity'],'--output-latency','0.25'] return args diff --git a/roadscore/prototype/test_mac_showcase_latency.py b/roadscore/prototype/test_mac_showcase_latency.py new file mode 100644 index 0000000000..7f47932e62 --- /dev/null +++ b/roadscore/prototype/test_mac_showcase_latency.py @@ -0,0 +1,46 @@ +import argparse +import unittest + +from mac_showcase import native_bluetooth_latency, output_latency_seconds, parser + + +class NativeBluetoothLatencyTests(unittest.TestCase): + def setUp(self): + self.env={'OPENPILOT_PREFIX':'roadscore_replay','ROADSCORE_PREPARED_SHOWCASE':'1'} + self.args=parser().parse_args(['--audio-worker','--no-control-server', + '--audio-device','roadscore_bluetooth', + '--output-identity','bluealsa:AA:BB:CC:DD:EE:FF', + '--output-latency','0.25']) + + def test_default_leaves_host_latency_unchanged(self): + args=parser().parse_args([]) + self.assertIsNone(args.output_latency) + self.assertIsNone(native_bluetooth_latency(args,{})) + self.args.output_latency=None + self.assertIsNone(native_bluetooth_latency(self.args,self.env)) + + def test_native_selected_bluetooth_accepts_bounded_numeric_latency(self): + self.assertEqual(native_bluetooth_latency(self.args,self.env),.25) + for value in ('.02','.25','.5'): + self.assertEqual(output_latency_seconds(value),float(value)) + + def test_invalid_latency_is_rejected_before_any_audio_opens(self): + for value in ('nan','inf','-inf','0','-1','.019','.501','text',True,None): + with self.subTest(value=value),self.assertRaises(argparse.ArgumentTypeError): + output_latency_seconds(value) + + def test_override_cannot_change_mac_system_or_unowned_output(self): + for field,value in [('audio_worker',False),('no_control_server',False), + ('audio_device',None),('audio_device','default'), + ('output_identity','system'),('output_identity','bluealsa:invalid')]: + old=getattr(self.args,field);setattr(self.args,field,value) + with self.subTest(field=field,value=value),self.assertRaises(ValueError): + native_bluetooth_latency(self.args,self.env) + setattr(self.args,field,old) + for env in ({},{**self.env,'OPENPILOT_PREFIX':'roadscore-showcase-mac'}, + {**self.env,'ROADSCORE_PREPARED_SHOWCASE':'0'}): + with self.subTest(env=env),self.assertRaises(ValueError): + native_bluetooth_latency(self.args,env) + + +if __name__=='__main__':unittest.main() diff --git a/roadscore/prototype/test_native_prepared_showcase.py b/roadscore/prototype/test_native_prepared_showcase.py index 871594d8a8..529ab5b660 100644 --- a/roadscore/prototype/test_native_prepared_showcase.py +++ b/roadscore/prototype/test_native_prepared_showcase.py @@ -61,8 +61,14 @@ class NativePreparedTests(unittest.TestCase): args=worker_arguments(Path(self.tmp.name),self.root,self.out,Path('/archive'),'fixture',False,output={'bluetooth_selected':True,'pcm_name':'roadscore_bluetooth','output_identity':'bluealsa:AA:BB'}) self.assertIn('--audio-worker',args);self.assertIn('--no-control-server',args) self.assertIn('--presentation-root',args);self.assertIn('bluealsa:AA:BB',args) + self.assertEqual(args[args.index('--output-latency')+1],'0.25') self.assertTrue(args[1].endswith('/mac_showcase.py')) self.assertFalse(any('ace_worker' in item or 'power_worker' in item for item in args)) + def test_system_output_keeps_its_existing_latency_default(self): + for output in (None, {'bluetooth_selected':False}): + args=worker_arguments(Path(self.tmp.name),self.root,self.out,Path('/archive'),'fixture',False,output=output) + self.assertNotIn('--output-latency',args) + self.assertNotIn('--output-identity',args) def test_continuous_capture_is_absent_even_with_inherited_mirror_directory(self): from screen_mirror import ScreenMirror inherited={'ROADSCORE_MIRROR_DIR':'/old/session/mirror'}