Recover bounded prepared audio clock stalls with a short crossfade

This commit is contained in:
firestar5683
2026-09-20 10:59:38 -07:00
parent 93a5378533
commit 357db29eb4
2 changed files with 157 additions and 0 deletions
+95
View File
@@ -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 0<threshold_ms<=100 or not 1<=crossfade_ms<=40 or maximum_forward_seconds<=threshold_ms/1000:
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.position=None
self.fade_from=None
self.fade_done=0
self.corrections=0
self.explicit_seeks=0
self.max_pre_error=0
self.max_post_error=0
self.events=deque(maxlen=64)
@staticmethod
def _slice(core,start,frames):
result=np.zeros((frames,core.shape[1]),dtype=np.float32)
low=max(0,-start);high=min(frames,len(core)-start)
if high>low: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}
@@ -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()