diff --git a/roadscore/prototype/prepared_clock.py b/roadscore/prototype/prepared_clock.py index 403b12342d..0c2e4e3aff 100644 --- a/roadscore/prototype/prepared_clock.py +++ b/roadscore/prototype/prepared_clock.py @@ -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 0self.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} diff --git a/roadscore/prototype/test_prepared_clock.py b/roadscore/prototype/test_prepared_clock.py index bb180ddc2a..9e6430368a 100644 --- a/roadscore/prototype/test_prepared_clock.py +++ b/roadscore/prototype/test_prepared_clock.py @@ -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)