Calibrate native output clock from advancing silent pre-roll callbacks

This commit is contained in:
firestar5683
2026-09-20 11:28:35 -07:00
parent 9f58b3acdd
commit 2bbee419f2
2 changed files with 42 additions and 0 deletions
@@ -7,6 +7,34 @@ class StreamClockBridge:
def __init__(self, offset, uncertainty):
self.offset = offset
self.uncertainty = uncertainty
self.source = 'bracketed-stream-time'
self.offset_spread = None
@classmethod
def from_callbacks(cls, observations):
"""Freeze one offset from silent pre-roll, before any route is released.
Some ALSA streams report zero from stream.time until their first callback,
then return the latest callback timestamp. Callback entry is later than its
timestamp: the minimum offset minimizes that nonnegative scheduling bias.
It does not measure Bluetooth acoustic latency.
"""
if len(observations)<8:raise ValueError('Output clock needs more silent callbacks')
offsets=[];previous=None
for wall,current,dac in observations:
if (not all(type(value) in (int,float) and math.isfinite(value) for value in (wall,current,dac))
or current<=0 or dac<=0 or not -.02<=dac-current<=5):
raise ValueError('Invalid callback clock sample')
if previous is not None and (wall<previous[0] or current<=previous[1] or dac<=previous[2]):
raise ValueError('Output callback clock did not advance')
previous=(wall,current,dac)
offsets.append(wall-current)
if observations[-1][1]-observations[0][1]<.1:
raise ValueError('Output callback clock observation was too short')
bridge=cls(min(offsets),None)
bridge.source='silent-callback-minimum-entry-offset'
bridge.offset_spread=max(offsets)-min(offsets)
return bridge
@classmethod
def measure(cls, stream_time, *, clock=time.monotonic, samples=5):
@@ -33,4 +61,5 @@ class StreamClockBridge:
def snapshot(self):
return {'portaudio_to_monotonic_seconds':self.offset,
'measurement_uncertainty_seconds':self.uncertainty,
'calibration_source':self.source,'callback_offset_spread_seconds':self.offset_spread,
'mapping':'fixed-stream-clock-offset'}
@@ -34,5 +34,18 @@ class StreamClockBridgeTests(unittest.TestCase):
def test_invalid_dac_timestamp_is_rejected(self):
with self.assertRaises(ValueError):StreamClockBridge(0.,0.).dac_wall(float('nan'))
def test_silent_callbacks_calibrate_once_without_get_stream_time(self):
delays=[.002,.008,.003,.0003,.004,.006,.001,.002]
observations=[(100.+i*.02+delay,500.+i*.02,500.+i*.02+.04) for i,delay in enumerate(delays)]
bridge=StreamClockBridge.from_callbacks(observations)
self.assertAlmostEqual(bridge.offset,-399.9997)
self.assertAlmostEqual(bridge.dac_wall(501.04),101.0403)
self.assertAlmostEqual(bridge.offset_spread,.0077)
self.assertIsNone(bridge.uncertainty)
def test_uninitialized_or_nonadvancing_callback_clock_is_not_used(self):
for samples in ([(1.,0.,0.)]*8,[(1.+i*.02,500.,500.04) for i in range(8)],[]):
with self.assertRaises(ValueError):StreamClockBridge.from_callbacks(samples)
if __name__=='__main__':unittest.main()