Crossfade bounded Bluetooth DAC estimate reversals in saved playback

This commit is contained in:
firestar5683
2026-09-20 11:54:23 -07:00
parent 562833fe5f
commit 1ccc3d7be2
2 changed files with 35 additions and 8 deletions
+12 -8
View File
@@ -12,21 +12,24 @@ class PreparedClock:
"""One audio-callback owner. Read snapshot outside the callback for evidence.
expected_frame comes from the existing original-model/DAC mapping. Small
scheduling jitter keeps contiguous PCM. A bounded forward discontinuity skips
the material whose presentation time has passed, blending old and new source
for 40 ms without inserting samples. Explicit seeks may move either direction;
scheduling jitter keeps contiguous PCM. Bounded DAC estimate changes blend
old and new source for 40 ms without inserting samples. A short backwards
output-delay correction is distinct from rewinding the route/model clock,
which the caller separately guards. Explicit seeks may move either direction;
the caller must reset presentation state outside the callback when seeking.
"""
def __init__(self, rate=48000, *, threshold_ms=50., crossfade_ms=40., maximum_forward_seconds=5.):
def __init__(self, rate=48000, *, threshold_ms=50., crossfade_ms=40., maximum_forward_seconds=5., maximum_backward_seconds=.25):
if type(rate) is not int or rate <= 0:raise ValueError('Invalid sample rate')
values=(threshold_ms,crossfade_ms,maximum_forward_seconds)
values=(threshold_ms,crossfade_ms,maximum_forward_seconds,maximum_backward_seconds)
if any(type(v) not in (int,float) or not math.isfinite(v) for v in values):raise ValueError('Invalid clock recovery limits')
if not 0<threshold_ms<=100 or not 1<=crossfade_ms<=40 or maximum_forward_seconds<=threshold_ms/1000:
if (not 0<threshold_ms<=100 or not 1<=crossfade_ms<=40 or maximum_forward_seconds<=threshold_ms/1000
or not threshold_ms/1000<maximum_backward_seconds<=.25):
raise ValueError('Clock recovery limits are out of bounds')
self.rate=rate
self.threshold=round(rate*threshold_ms/1000)
self.fade_frames=max(1,round(rate*crossfade_ms/1000))
self.maximum_forward=round(rate*maximum_forward_seconds)
self.maximum_backward=round(rate*maximum_backward_seconds)
self.position=None
self.fade_from=None
self.fade_done=0
@@ -58,10 +61,10 @@ class PreparedClock:
correction=None
if not initial and (explicit_seek or abs(delta)>self.threshold):
if not explicit_seek:
if delta<0:raise ClockDiscontinuity('Prepared audio clock moved backwards without an explicit seek')
if delta < -self.maximum_backward:raise ClockDiscontinuity('Prepared audio clock moved backwards beyond the output-delay recovery bound')
if delta>self.maximum_forward:raise ClockDiscontinuity('Prepared audio clock discontinuity exceeds the recovery bound')
if self.fade_from is not None:raise ClockDiscontinuity('Prepared audio clock changed again during recovery')
kind='explicit-seek' if explicit_seek else 'forward-clock-recovery'
kind='explicit-seek' if explicit_seek else 'backward-dac-recovery' if delta<0 else 'forward-clock-recovery'
correction={'kind':kind,'from_frame':previous,'to_frame':expected_frame,'delta_frames':delta,'crossfade_frames':self.fade_frames}
self.events.append(correction)
self.corrections+=not explicit_seek
@@ -92,4 +95,5 @@ class PreparedClock:
return {'next_source_frame':self.position,'corrections':self.corrections,'explicit_seeks':self.explicit_seeks,
'max_pre_error_seconds':self.max_pre_error/self.rate,'max_post_error_seconds':self.max_post_error/self.rate,
'crossfade_ms':1000*self.fade_frames/self.rate,'recovery_in_progress':self.fade_from is not None,
'maximum_backward_recovery_seconds':self.maximum_backward/self.rate,
'events':list(self.events),'added_delay_samples':0,'resampling':False}
@@ -39,6 +39,29 @@ class PreparedClockTests(unittest.TestCase):
for target in (0,position+6*48000):
with self.assertRaises(ClockDiscontinuity):clock.render(self.core,target,960)
self.assertEqual(clock.position,position)
def test_measured_65ms_bluetooth_delay_correction_crossfades(self):
# Saved0217 failed with next8757465 vs expected8754316: 3149 frames,
# or65.60ms backwards in the estimated DAC clock, not in model messages.
clock=PreparedClock();clock.render(self.core,96000,960)
previous=clock.position;target=previous-3149
first,info=clock.render(self.core,target,960)
second,last=clock.render(self.core,target+960,960)
self.assertEqual(info['correction']['kind'],'backward-dac-recovery')
self.assertEqual(info['correction']['delta_frames'],-3149)
self.assertEqual(first.shape,(960,2));self.assertEqual(second.shape,(960,2))
self.assertLess(np.max(abs(first[0]-self.core[previous])),.001)
np.testing.assert_allclose(second[-1],self.core[target+1919],atol=3e-8)
self.assertEqual(last['post_error_frames'],0)
self.assertEqual(clock.explicit_seeks,0);self.assertEqual(clock.corrections,1)
self.assertIsNone(clock.fade_from)
def test_reverse_clock_recovery_has_a_separate_small_bound(self):
clock=PreparedClock();clock.render(self.core,96000,960)
before=clock.position
with self.assertRaises(ClockDiscontinuity):clock.render(self.core,before-12001,960)
self.assertEqual(clock.position,before)
with self.assertRaises(ValueError):PreparedClock(maximum_backward_seconds=1.)
def test_explicit_seek_can_rebase_backwards(self):
clock=PreparedClock();clock.render(self.core,96000,960)
_,info=clock.render(self.core,4800,960,explicit_seek=True)