mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-04 13:24:13 +08:00
Recover bounded prepared audio clock stalls with a short crossfade
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user