Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 32 additions & 11 deletions sendspin/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ class AudioPlayer:
"""Maximum DAC-to-loop time ratio to prevent wild extrapolation."""

# Sync error correction: playback speed adjustment range
_MAX_SPEED_CORRECTION: Final[float] = 0.04
"""Maximum playback speed deviation for sync correction (0.04 = ±4% speed variation)."""
_MAX_SPEED_CORRECTION: Final[float] = 0.002
"""Maximum playback speed deviation for sync correction (0.002 = ±0.2% speed variation)."""

# Sync error correction: secondary thresholds (rarely need adjustment)
_CORRECTION_DEADBAND_US: Final[int] = 2_000
Expand All @@ -133,8 +133,10 @@ class AudioPlayer:
"""Minimum threshold for updating start time to avoid churn (5ms)."""

# Sync correction planning
_CORRECTION_TARGET_SECONDS: Final[float] = 2.0
"""Target window to fix sync error through micro-corrections (2 seconds)."""
_CORRECTION_TARGET_SECONDS: Final[float] = 8.0
"""Target window to fix sync error through micro-corrections (8 seconds)."""
_CORRECTION_START_GRACE_US: Final[int] = 750_000
"""Delay sync corrections after startup so DAC/time-sync estimates can settle."""

def __init__(
self,
Expand Down Expand Up @@ -211,6 +213,7 @@ def __init__(
# Scheduled start anchoring
self._scheduled_start_loop_time_us: int | None = None
self._scheduled_start_dac_time_us: int | None = None
self._playback_started_loop_time_us: int = 0

# Server timeline cursor for the next input frame to be consumed
self._server_ts_cursor_us: int = 0
Expand Down Expand Up @@ -384,6 +387,7 @@ def clear(self) -> None:
self._last_dac_calibration_time_us = 0
self._scheduled_start_loop_time_us = None
self._scheduled_start_dac_time_us = None
self._playback_started_loop_time_us = 0
self._server_ts_cursor_us = 0
self._server_ts_cursor_remainder = 0
self._first_server_timestamp_us = None
Expand Down Expand Up @@ -531,15 +535,19 @@ def _audio_callback( # noqa: PLR0915
# Handle correction event if at boundary
if frames_remaining > 0:
if drop_counter <= 0 and drop_every_n > 0:
# Drop frame: read EXTRA frame to advance cursor faster
_ = self._read_one_input_frame() # Read frame we're replacing
_ = self._read_one_input_frame() # Read frame we're DROPPING
# Drop one input frame, then output the following frame. This
# advances the source cursor by two frames while rendering one,
# avoiding the old duplicate-then-skip artifact.
_ = self._read_one_input_frame()
replacement_frame = self._read_one_input_frame()
if replacement_frame is None:
replacement_frame = self._last_output_frame
drop_counter = drop_every_n
self._frames_dropped_since_log += 1
# Output last frame instead (don't output either frame we read)
output_buffer[bytes_written : bytes_written + frame_size] = (
self._last_output_frame
replacement_frame
)
self._last_output_frame = replacement_frame
bytes_written += frame_size
frames_remaining -= 1
insert_counter -= 1
Expand Down Expand Up @@ -1035,14 +1043,20 @@ def _handle_start_gating(
// self._MICROSECONDS_PER_SECOND
)
self._skip_input_frames(frames_to_drop)
self._playback_state = PlaybackState.PLAYING
self._set_playing()

# If we've reached/overrun the scheduled time, arm playback
if current_time_us >= target_time_us:
self._playback_state = PlaybackState.PLAYING
self._set_playing()

return bytes_written

def _set_playing(self) -> None:
"""Transition to PLAYING and start the correction grace window once."""
if self._playback_state != PlaybackState.PLAYING:
self._playback_started_loop_time_us = self._now_us()
self._playback_state = PlaybackState.PLAYING

def _update_correction_schedule(self, error_us: int) -> None:
"""Plan occasional sample drop/insert to correct sync error.

Expand All @@ -1062,6 +1076,13 @@ def _update_correction_schedule(self, error_us: int) -> None:

abs_err = abs(self._sync_error_filtered_us)

if self._playback_started_loop_time_us:
since_start_us = self._now_us() - self._playback_started_loop_time_us
if since_start_us < self._CORRECTION_START_GRACE_US:
self._insert_every_n_frames = 0
self._drop_every_n_frames = 0
return

# Do nothing within deadband
if abs_err <= self._CORRECTION_DEADBAND_US:
self._insert_every_n_frames = 0
Expand Down
63 changes: 63 additions & 0 deletions tests/test_audio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

from types import SimpleNamespace

from sendspin.audio import AudioPlayer, PlaybackState, _QueuedChunk


class _NoCallbackStatus:
input_underflow = False
output_underflow = False

def __bool__(self) -> bool:
return False


def test_drop_correction_discards_one_frame_without_repeating_previous() -> None:
now_us = 0

def now() -> int:
return now_us

player = AudioPlayer(lambda ts: ts, lambda ts: ts, now_us=now)
player._format = SimpleNamespace( # noqa: SLF001
sample_rate=48_000,
channels=1,
bit_depth=16,
frame_size=2,
)
player._playback_state = PlaybackState.PLAYING # noqa: SLF001
player._drop_every_n_frames = 1 # noqa: SLF001
player._frames_until_next_drop = 1 # noqa: SLF001
player._queue.put( # noqa: SLF001
_QueuedChunk(
server_timestamp_us=0,
audio_data=b"\x01\x00\x02\x00\x03\x00",
)
)

out = bytearray(4)
player._audio_callback( # noqa: SLF001
memoryview(out),
frames=2,
time=SimpleNamespace(outputBufferDacTime=0.0),
status=_NoCallbackStatus(),
)

assert bytes(out) == b"\x01\x00\x03\x00"


def test_sync_correction_waits_for_startup_grace_period() -> None:
now_us = 1_000_000

def now() -> int:
return now_us

player = AudioPlayer(lambda ts: ts, lambda ts: ts, now_us=now)
player._format = SimpleNamespace(sample_rate=48_000) # noqa: SLF001
player._playback_started_loop_time_us = now_us # noqa: SLF001

player._update_correction_schedule(50_000) # noqa: SLF001

assert player._drop_every_n_frames == 0 # noqa: SLF001
assert player._insert_every_n_frames == 0 # noqa: SLF001