From 2deecbe21fdf387b6d45e133c49fb70902f5fd38 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:00:46 -0700 Subject: [PATCH] Finish prepared audio crossfade before bounded DAC correction --- roadscore/prototype/prepared_clock.py | 26 ++++++----- roadscore/prototype/test_prepared_clock.py | 54 ++++++++++++++++++++-- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/roadscore/prototype/prepared_clock.py b/roadscore/prototype/prepared_clock.py index 0c2e4e3aff..67cc23fe02 100644 --- a/roadscore/prototype/prepared_clock.py +++ b/roadscore/prototype/prepared_clock.py @@ -58,20 +58,24 @@ class PreparedClock: previous=expected_frame if initial else self.position delta=expected_frame-previous self.max_pre_error=max(self.max_pre_error,abs(delta)) - correction=None + correction=None;deferred=False if not initial and (explicit_seek or abs(delta)>self.threshold): if not 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 '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 - self.explicit_seeks+=explicit_seek - self.fade_from=previous - self.fade_done=0 - self.position=expected_frame + # Finish the existing blend before correcting another bounded DAC + # estimate change. Do not queue this target: the next callback after + # the fade must use its newest estimate. Bounds still apply immediately. + deferred=self.fade_from is not None + if not deferred: + 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 + self.explicit_seeks+=explicit_seek + self.fade_from=previous + self.fade_done=0 + self.position=expected_frame elif initial:self.position=expected_frame start=self.position result=self._slice(core,start,frames) @@ -89,7 +93,7 @@ class PreparedClock: self.max_post_error=max(self.max_post_error,abs(post_error)) return result,{'source_frame':start,'next_source_frame':self.position,'expected_frame':expected_frame, 'pre_error_frames':delta,'post_error_frames':post_error,'crossfade_frames_rendered':faded, - 'correction':correction} + 'correction':correction,'correction_deferred':deferred} def snapshot(self): return {'next_source_frame':self.position,'corrections':self.corrections,'explicit_seeks':self.explicit_seeks, diff --git a/roadscore/prototype/test_prepared_clock.py b/roadscore/prototype/test_prepared_clock.py index 9e6430368a..355f914021 100644 --- a/roadscore/prototype/test_prepared_clock.py +++ b/roadscore/prototype/test_prepared_clock.py @@ -67,9 +67,57 @@ class PreparedClockTests(unittest.TestCase): _,info=clock.render(self.core,4800,960,explicit_seek=True) self.assertEqual(info['correction']['kind'],'explicit-seek');self.assertEqual(clock.position,5760) self.assertEqual(clock.explicit_seeks,1);self.assertEqual(clock.corrections,0) - def test_repeated_clock_instability_during_fade_is_rejected(self): - clock=PreparedClock();clock.render(self.core,0,960);clock.render(self.core,48000,960) - with self.assertRaises(ClockDiscontinuity):clock.render(self.core,96000,960) + def test_bounded_dac_bounce_finishes_blend_before_latest_correction(self): + before=self.core.copy() + for delta in (-3149,3149): + with self.subTest(delta=delta): + clock=PreparedClock();reference=PreparedClock() + for item in (clock,reference):item.render(self.core,96000,960) + previous=clock.position;target=previous+delta + first,_=clock.render(self.core,target,960) + reference.render(self.core,target,960) + # DAC estimate returns to its old trajectory midway through the fade. + second,deferred=clock.render(self.core,previous+960,960) + uninterrupted,_=reference.render(self.core,target+960,960) + np.testing.assert_array_equal(second,uninterrupted) + self.assertTrue(deferred['correction_deferred']);self.assertIsNone(deferred['correction']) + self.assertEqual(deferred['post_error_frames'],-delta) + self.assertEqual(clock.corrections,1);self.assertIsNone(clock.fade_from) + # Use the newest estimate, rather than the older deferred target. + latest=previous+1920+137 + third,resumed=clock.render(self.core,latest,960) + fourth,last=clock.render(self.core,latest+960,960) + self.assertEqual(resumed['correction']['to_frame'],latest) + self.assertFalse(resumed['correction_deferred']) + self.assertLess(np.max(abs(third[0]-self.core[target+1920])),.001) + np.testing.assert_allclose(fourth[-1],self.core[latest+1919],atol=3e-8) + self.assertEqual(np.concatenate((first,second,third,fourth)).shape,(3840,2)) + self.assertEqual(clock.position,latest+1920);self.assertEqual(clock.corrections,2) + self.assertEqual(last['post_error_frames'],0);self.assertEqual(clock.explicit_seeks,0) + np.testing.assert_array_equal(self.core,before) + + def test_transient_deferred_estimate_is_not_applied_later(self): + clock=PreparedClock();clock.render(self.core,96000,960) + target=clock.position-3149 + clock.render(self.core,target,960) + _,deferred=clock.render(self.core,target+960+3149,960) + self.assertTrue(deferred['correction_deferred']) + pcm,settled=clock.render(self.core,target+1920,960) + self.assertIsNone(settled['correction']);self.assertEqual(clock.corrections,1) + np.testing.assert_array_equal(pcm,self.core[target+1920:target+2880]) + + def test_out_of_bound_clock_change_during_fade_is_still_rejected(self): + clock=PreparedClock();clock.render(self.core,96000,960);clock.render(self.core,100800,960) + position=clock.position;fade=(clock.fade_from,clock.fade_done) + for target in (position-12001,position+5*48000+1): + with self.assertRaises(ClockDiscontinuity):clock.render(self.core,target,960) + self.assertEqual(clock.position,position);self.assertEqual((clock.fade_from,clock.fade_done),fade) + + def test_explicit_seek_during_fade_is_not_deferred(self): + clock=PreparedClock();clock.render(self.core,96000,960);clock.render(self.core,100800,960) + _,info=clock.render(self.core,4800,960,explicit_seek=True) + self.assertEqual(info['correction']['kind'],'explicit-seek');self.assertFalse(info['correction_deferred']) + self.assertEqual(clock.position,5760);self.assertEqual(clock.explicit_seeks,1) def test_original_negative_audio_origin_and_tail_are_zero_padded(self): clock=PreparedClock();first,_=clock.render(self.core,-480,960) np.testing.assert_array_equal(first[:480],np.zeros((480,2)))