diff --git a/roadscore/prototype/prepared_clock.py b/roadscore/prototype/prepared_clock.py new file mode 100644 index 0000000000..403b12342d --- /dev/null +++ b/roadscore/prototype/prepared_clock.py @@ -0,0 +1,95 @@ +"""Bounded prepared-PCM clock recovery; no I/O, regeneration, or resampling.""" +from collections import deque +import math +import numpy as np + + +class ClockDiscontinuity(RuntimeError): + pass + + +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; + 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.): + if type(rate) is not int or rate <= 0:raise ValueError('Invalid sample rate') + values=(threshold_ms,crossfade_ms,maximum_forward_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 0low:result[low:high]=core[start+low:start+high] + return result + + def render(self,core,expected_frame,frames,*,explicit_seek=False): + if type(expected_frame) is not int or type(frames) is not int or frames<=0: + raise ValueError('Clock frames must be finite integers') + if not isinstance(core,np.ndarray) or core.ndim!=2 or core.dtype!=np.float32: + raise ValueError('Expected prepared float32 PCM') + if type(explicit_seek) is not bool:raise ValueError('Seek authorization must be explicit') + if expected_frame < -2*self.rate or expected_frame > len(core)+self.rate: + raise ClockDiscontinuity('Expected playhead is outside the prepared recording') + initial=self.position is None + 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 + 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_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' + 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) + faded=0 + if self.fade_from is not None: + faded=min(frames,self.fade_frames-self.fade_done) + old=self._slice(core,self.fade_from,faded) + alpha=(np.arange(1,faded+1,dtype=np.float32)+self.fade_done)/self.fade_frames + result[:faded]=old+(result[:faded]-old)*alpha[:,None] + self.fade_done+=faded + self.fade_from+=faded + if self.fade_done>=self.fade_frames:self.fade_from=None + self.position+=frames + post_error=expected_frame-start + 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} + + def snapshot(self): + 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, + '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 new file mode 100644 index 0000000000..bb180ddc2a --- /dev/null +++ b/roadscore/prototype/test_prepared_clock.py @@ -0,0 +1,62 @@ +import unittest +import numpy as np +from prepared_clock import PreparedClock,ClockDiscontinuity + + +class PreparedClockTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + t=np.arange(480000,dtype=np.float32)/48000 + cls.core=np.column_stack((.5*np.sin(t*2100),.4*np.cos(t*1900))).astype(np.float32) + def test_contiguous_samples_and_small_jitter_are_unchanged(self): + clock=PreparedClock() + a,one=clock.render(self.core,0,960) + b,two=clock.render(self.core,1000,960) + np.testing.assert_array_equal(a,self.core[:960]);np.testing.assert_array_equal(b,self.core[960:1920]) + self.assertEqual(two['post_error_frames'],40);self.assertEqual(clock.corrections,0) + def test_measured_1884ms_stall_recovers_without_extra_samples(self): + clock=PreparedClock();clock.render(self.core,0,960) + before=self.core.copy();target=960+round(1.884*48000) + first,info=clock.render(self.core,target,960) + second,last=clock.render(self.core,target+960,960) + self.assertEqual(first.shape,(960,2));self.assertEqual(second.shape,(960,2)) + self.assertEqual(info['correction']['delta_frames'],90432) + self.assertEqual(clock.position,target+1920);self.assertIsNone(clock.fade_from) + self.assertLess(np.max(abs(first[0]-self.core[960])),.001) + np.testing.assert_allclose(second[-1],self.core[target+1919],atol=3e-8) + self.assertEqual(last['post_error_frames'],0) + self.assertEqual(clock.snapshot()['max_pre_error_seconds'],1.884) + self.assertEqual(clock.snapshot()['max_post_error_seconds'],0) + np.testing.assert_array_equal(self.core,before) + def test_crossfade_independent_of_callback_block_split(self): + a=PreparedClock();b=PreparedClock();a.render(self.core,0,960);b.render(self.core,0,960) + whole,_=a.render(self.core,96000,2400) + parts=[b.render(self.core,96000+i,480)[0] for i in range(0,2400,480)] + np.testing.assert_array_equal(whole,np.concatenate(parts)) + def test_backward_or_absurd_jump_is_not_silently_accepted(self): + clock=PreparedClock();clock.render(self.core,48000,960) + position=clock.position + for target in (0,position+6*48000): + with self.assertRaises(ClockDiscontinuity):clock.render(self.core,target,960) + self.assertEqual(clock.position,position) + 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) + 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_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))) + np.testing.assert_array_equal(first[480:],self.core[:480]) + other=PreparedClock();last,_=other.render(self.core,len(self.core)-480,960) + np.testing.assert_array_equal(last[:480],self.core[-480:]);np.testing.assert_array_equal(last[480:],np.zeros((480,2))) + def test_invalid_clock_and_source_are_rejected(self): + clock=PreparedClock() + for target in (float('nan'),True,12.5): + with self.assertRaises(ValueError):clock.render(self.core,target,960) + with self.assertRaises(ClockDiscontinuity):clock.render(self.core,len(self.core)+48001,960) + +if __name__=='__main__':unittest.main()