@@ -166,6 +166,8 @@ def __init__(
166166 self ._volume : int = 100 # 0-100 range
167167 self ._muted : bool = False
168168
169+ self ._output_latency_us : int = 0
170+
169171 # Partial chunk tracking (to avoid discarding partial chunks)
170172 self ._current_chunk : _QueuedChunk | None = None
171173 self ._current_chunk_offset = 0
@@ -206,6 +208,7 @@ def __init__(
206208 self ._first_server_timestamp_us : int | None = None
207209 self ._early_start_suspect : bool = False
208210 self ._has_reanchored : bool = False
211+ self ._force_reanchor : bool = True
209212
210213 # Low-overhead drift/sync correction scheduling (sample drop/insert)
211214 self ._insert_every_n_frames : int = 0
@@ -252,13 +255,15 @@ def set_format(self, audio_format: AudioFormat, device: AudioDevice) -> None:
252255 latency = "high" ,
253256 device = device .device_id ,
254257 )
258+ self ._output_latency_us = int (self ._stream .latency * self ._MICROSECONDS_PER_SECOND )
255259 logger .info (
256- "Audio stream configured: codec=%s, sample_rate=%d, channels=%d, bit_depth=%d, blocksize=%d, latency=high, device=%s" ,
260+ "Audio stream configured: codec=%s, sample_rate=%d, channels=%d, bit_depth=%d, blocksize=%d, latency=high, output_latency=%.1f ms, device=%s" ,
257261 audio_format .codec .value ,
258262 pcm_format .sample_rate ,
259263 pcm_format .channels ,
260264 pcm_format .bit_depth ,
261265 self ._BLOCKSIZE ,
266+ self ._output_latency_us / 1000.0 ,
262267 device .device_id ,
263268 )
264269
@@ -340,6 +345,7 @@ def clear(self) -> None:
340345 self ._first_server_timestamp_us = None
341346 self ._early_start_suspect = False
342347 self ._has_reanchored = False
348+ self ._force_reanchor = True
343349 self ._insert_every_n_frames = 0
344350 self ._drop_every_n_frames = 0
345351 self ._frames_until_next_insert = 0
@@ -389,6 +395,25 @@ def _audio_callback( # noqa: PLR0915
389395
390396 # Capture exact DAC output time and update playback position
391397 self ._update_playback_position_from_dac (time )
398+
399+ # Reanchor: snap read cursor to DAC-derived server time so the
400+ # cursor tracks actual playback position, not bytes-read position.
401+ if (
402+ self ._playback_state == PlaybackState .PLAYING
403+ and self ._last_known_playback_position_us > 0
404+ and self ._server_ts_cursor_us > 0
405+ and self ._force_reanchor
406+ ):
407+ self ._server_ts_cursor_us = self ._last_known_playback_position_us
408+ self ._server_ts_cursor_remainder = 0
409+ self ._force_reanchor = False
410+ self ._insert_every_n_frames = 0
411+ self ._drop_every_n_frames = 0
412+ self ._frames_until_next_insert = 0
413+ self ._frames_until_next_drop = 0
414+ self ._sync_error_filter .reset ()
415+ self ._sync_error_filtered_us = 0.0
416+
392417 bytes_written = 0
393418
394419 try :
@@ -772,6 +797,7 @@ def get_timing_metrics(self) -> dict[str, float]:
772797 "playback_position_us" : float (self ._get_current_playback_position_us ()),
773798 "buffered_audio_us" : float (self ._queued_duration_us ),
774799 "dac_samples_recorded" : len (self ._dac_loop_calibrations ),
800+ "output_latency_us" : float (self ._output_latency_us ),
775801 }
776802
777803 def _log_chunk_timing (self , _server_timestamp_us : int ) -> None :
@@ -1004,22 +1030,16 @@ def _update_correction_schedule(self, error_us: int) -> None:
10041030 self ._drop_every_n_frames = 0
10051031 return
10061032
1007- # Re-anchor only if error is very large and cooldown has elapsed
1033+ # Re-anchor if error is very large and cooldown has elapsed.
10081034 now_loop_us = self ._now_us ()
10091035 if (
10101036 abs_err > self ._REANCHOR_THRESHOLD_US
10111037 and self ._playback_state == PlaybackState .PLAYING
10121038 and now_loop_us - self ._last_reanchor_loop_time_us > self ._REANCHOR_COOLDOWN_US
10131039 ):
1014- logger .info ("Sync error %.1f ms too large; re-anchoring" , abs_err / 1000.0 )
1015- # Reset cadence
1016- self ._insert_every_n_frames = 0
1017- self ._drop_every_n_frames = 0
1018- self ._frames_until_next_insert = 0
1019- self ._frames_until_next_drop = 0
1040+ logger .info ("Sync error %.1f ms too large; scheduling reanchor" , abs_err / 1000.0 )
10201041 self ._last_reanchor_loop_time_us = now_loop_us
1021- # Re-anchor on next chunk boundary by clearing queue
1022- self .clear ()
1042+ self ._force_reanchor = True
10231043 return
10241044
10251045 # Simple proportional control: correction rate proportional to error
0 commit comments