Anchor output DAC time to a measured stable stream clock

This commit is contained in:
firestar5683
2026-09-20 11:22:59 -07:00
parent fb43bca7fb
commit c9f102ee47
2 changed files with 74 additions and 0 deletions
@@ -0,0 +1,36 @@
"""Map PortAudio timestamps to monotonic time once per output stream."""
import math
import time
class StreamClockBridge:
def __init__(self, offset, uncertainty):
self.offset = offset
self.uncertainty = uncertainty
@classmethod
def measure(cls, stream_time, *, clock=time.monotonic, samples=5):
"""Call outside the audio callback, while it is still awaiting route input."""
candidates = []
for _ in range(samples):
before = clock()
source = stream_time()
after = clock()
if (not all(type(value) in (int, float) and math.isfinite(value) for value in (before,source,after))
or source <= 0 or after < before):
raise ValueError('Invalid output stream clock sample')
candidates.append((after-before, (before+after)/2-source))
if not candidates:raise ValueError('No output stream clock samples')
width, offset = min(candidates)
if width > .05:raise ValueError('Output stream clock measurement is too uncertain')
return cls(offset,width/2)
def dac_wall(self, output_buffer_dac_time):
if type(output_buffer_dac_time) not in (int,float) or not math.isfinite(output_buffer_dac_time):
raise ValueError('Invalid output DAC timestamp')
return output_buffer_dac_time+self.offset
def snapshot(self):
return {'portaudio_to_monotonic_seconds':self.offset,
'measurement_uncertainty_seconds':self.uncertainty,
'mapping':'fixed-stream-clock-offset'}
@@ -0,0 +1,38 @@
import unittest
from stream_clock_bridge import StreamClockBridge
class StreamClockBridgeTests(unittest.TestCase):
def test_uses_narrowest_bracket_and_supports_distinct_clock_origins(self):
walls=iter([100.,100.02,101.,101.002,102.,102.01])
source=iter([500.01,501.001,502.005])
bridge=StreamClockBridge.measure(lambda:next(source),clock=lambda:next(walls),samples=3)
self.assertAlmostEqual(bridge.offset,-400.)
self.assertAlmostEqual(bridge.uncertainty,.001)
self.assertAlmostEqual(bridge.dac_wall(505.02),105.02)
def test_delayed_callback_then_burst_cannot_shift_stream_clock_mapping(self):
# The old conversion added callback-entry delay to the DAC timestamp.
# A delayed callback followed by prompt callbacks looked like a seek back.
bridge=StreamClockBridge(-400.,.0001)
driver_current=[500.,500.02,500.04,500.06]
entered=[100.,100.08,100.04,100.06]
dac=[stamp+.056 for stamp in driver_current]
old=[now+d-current for now,d,current in zip(entered,dac,driver_current)]
self.assertLess(old[2],old[1])
mapped=[bridge.dac_wall(stamp) for stamp in dac]
for a,b in zip(mapped,mapped[1:]):self.assertAlmostEqual(b-a,.02)
def test_invalid_samples_and_uncertain_bracket_fail_closed(self):
for source in (0.,float('nan'),float('inf')):
with self.assertRaises(ValueError):StreamClockBridge.measure(lambda:source,clock=lambda:1.,samples=1)
walls=iter([1.,1.1])
with self.assertRaises(ValueError):StreamClockBridge.measure(lambda:10.,clock=lambda:next(walls),samples=1)
with self.assertRaises(ValueError):StreamClockBridge.measure(lambda:10.,samples=0)
def test_invalid_dac_timestamp_is_rejected(self):
with self.assertRaises(ValueError):StreamClockBridge(0.,0.).dac_wall(float('nan'))
if __name__=='__main__':unittest.main()