feat: rate based sync

This commit is contained in:
Justin Lin
2025-10-09 16:42:35 +11:00
parent ca0a880c80
commit d335245eb0
8 changed files with 229 additions and 24 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
__pycache__
.mypy_cache

View File

@@ -7,9 +7,11 @@ A Python video player using VLC SDK with synchan server timecode synchronization
- VLC-based video playback with full media support - VLC-based video playback with full media support
- Real-time synchronization with synchan server - Real-time synchronization with synchan server
- Reactive programming with RxPY - Reactive programming with RxPY
- Automatic timecode correction - **Advanced synchronization with latency compensation**
- **Playback rate adjustment for smooth sync**
- Automatic timecode correction with smart seeking
- Playback state synchronization - Playback state synchronization
- Volume and fullscreen controls - Volume, playback rate, and fullscreen controls
## Installation ## Installation
@@ -110,8 +112,11 @@ poetry run python -m vlchan.player video.mp4 http://192.168.1.100:3000
- `get_volume() -> int`: Get current volume - `get_volume() -> int`: Get current volume
- `set_fullscreen(fullscreen: bool)`: Toggle fullscreen - `set_fullscreen(fullscreen: bool)`: Toggle fullscreen
- `get_position() -> float`: Get current position in seconds - `get_position() -> float`: Get current position in seconds
- `get_compensated_position(latency: float) -> float`: Get latency-compensated position
- `get_duration() -> float`: Get video duration in seconds - `get_duration() -> float`: Get video duration in seconds
- `is_playing() -> bool`: Check if playing - `is_playing() -> bool`: Check if playing
- `get_rate() -> float`: Get current playback rate
- `set_rate(rate: float)`: Set playback rate (1.0 = normal speed)
- `cleanup()`: Clean up resources - `cleanup()`: Clean up resources
#### Properties #### Properties
@@ -128,12 +133,32 @@ Data class containing synchronization state:
## Synchronization ## Synchronization
The player automatically synchronizes with the synchan server: The player uses advanced synchronization logic similar to the synchan VideoPlayer component:
1. **Playback State**: Play/pause commands are synchronized 1. **Latency Compensation**: Server time is adjusted by network latency
2. **Time Position**: Video seeks to match synchan timecode 2. **Smart Seeking**: Large time differences (>1s) trigger immediate seeking
3. **Threshold**: Only seeks if time difference exceeds 100ms 3. **Playback Rate Adjustment**: Small differences are corrected by adjusting playback speed
4. **Reconnection**: Automatically attempts to reconnect on connection loss 4. **Rate Formula**: `rate = 1 + min(max(diff * 0.5, -0.05), 0.05)`
5. **Playback State**: Play/pause commands are synchronized
6. **Reconnection**: Automatically attempts to reconnect on connection loss
### Synchronization Algorithm
```python
# Apply latency compensation
compensated_time = server_time + latency / 1000
# Calculate difference
diff = compensated_time - local_time
# Large difference: seek immediately
if diff > 1.0:
player.seek(compensated_time)
# Small difference: adjust playback rate
rate = 1 + min(max(diff * 0.5, -0.05), 0.05)
player.set_rate(rate)
```
## Requirements ## Requirements

View File

@@ -30,7 +30,8 @@ def main():
def on_state_change(state): def on_state_change(state):
print(f"Synchan update: playing={state.playing}, " print(f"Synchan update: playing={state.playing}, "
f"time={state.currentTime:.2f}s, " f"time={state.currentTime:.2f}s, "
f"duration={state.duration:.2f}s") f"duration={state.duration:.2f}s, "
f"latency={state.latency:.0f}ms")
player.on_state_change = on_state_change player.on_state_change = on_state_change
@@ -52,6 +53,8 @@ def main():
print("- Press 's' to stop") print("- Press 's' to stop")
print("- Press 'f' to toggle fullscreen") print("- Press 'f' to toggle fullscreen")
print("- Press 'v' to change volume") print("- Press 'v' to change volume")
print("- Press 'r' to show current rate")
print("- Press 'l' to show latency info")
print("- Press 'q' to quit") print("- Press 'q' to quit")
print("- Press 'j' to seek forward 10s") print("- Press 'j' to seek forward 10s")
print("- Press 'k' to seek backward 10s") print("- Press 'k' to seek backward 10s")
@@ -84,6 +87,12 @@ def main():
new_vol = min(100, current_vol + 10) new_vol = min(100, current_vol + 10)
player.set_volume(new_vol) player.set_volume(new_vol)
print(f"Volume: {new_vol}") print(f"Volume: {new_vol}")
elif key == 'r':
current_rate = player.get_rate()
print(f"Current playback rate: {current_rate:.3f}")
elif key == 'l':
pos = player.get_position()
print(f"Position: {pos:.2f}s")
elif key == 'j': elif key == 'j':
current_pos = player.get_position() current_pos = player.get_position()
player.seek(current_pos + 10) player.seek(current_pos + 10)

101
test_sync.py Normal file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Test script for VLChan synchronization logic."""
import sys
from vlchan import VLChanPlayer
def test_playback_rate_calculation():
"""Test the playback rate calculation logic."""
print("Testing playback rate calculation...")
# Create player instance to access the method
player = VLChanPlayer()
# Test cases from the TypeScript implementation
test_cases = [
(0.0, 1.0), # No difference
(0.1, 1.05), # Small positive difference
(-0.1, 0.95), # Small negative difference
(0.2, 1.05), # Large positive difference (capped)
(-0.2, 0.95), # Large negative difference (capped)
(1.0, 1.05), # Very large positive difference (capped)
(-1.0, 0.95), # Very large negative difference (capped)
]
for diff, expected in test_cases:
result = player._get_playback_rate(diff)
print(f" diff={diff:6.1f}s -> rate={result:.3f} (expected {expected:.3f})")
assert abs(result - expected) < 0.001, f"Expected {expected}, got {result}"
print("✓ Playback rate calculation tests passed")
player.cleanup()
def test_latency_compensation():
"""Test latency compensation logic."""
print("\nTesting latency compensation...")
player = VLChanPlayer()
# Test latency compensation
test_cases = [
(100.0, 10.0, 10.1), # 100ms latency, 10s position -> 10.1s compensated
(50.0, 5.5, 5.55), # 50ms latency, 5.5s position -> 5.55s compensated
(0.0, 3.0, 3.0), # No latency
]
for latency, position, expected in test_cases:
# Mock the position
player.player.set_time(int(position * 1000))
result = player.get_compensated_position(latency)
print(f" latency={latency:4.0f}ms, pos={position:.1f}s -> {result:.3f}s (expected {expected:.1f}s)")
assert abs(result - expected) < 0.01, f"Expected {expected}, got {result}"
print("✓ Latency compensation tests passed")
player.cleanup()
def test_sync_threshold_logic():
"""Test the synchronization threshold logic."""
print("\nTesting sync threshold logic...")
player = VLChanPlayer()
# Test cases for seeking vs rate adjustment
test_cases = [
(0.5, False, "Small diff should not seek"),
(1.5, True, "Large diff should seek"),
(0.05, False, "Very small diff should not seek"),
(2.0, True, "Very large diff should seek"),
]
for diff, should_seek, description in test_cases:
seek_condition = diff > 1.0
print(f" diff={diff:.2f}s -> seek={seek_condition} ({description})")
assert seek_condition == should_seek, f"Expected {should_seek}, got {seek_condition}"
print("✓ Sync threshold logic tests passed")
def main():
"""Run all synchronization tests."""
print("VLChan Synchronization Test Suite")
print("=" * 40)
try:
test_playback_rate_calculation()
test_latency_compensation()
test_sync_threshold_logic()
print("\n" + "=" * 40)
print("✓ All synchronization tests passed!")
return 0
except Exception as e:
print(f"\n✗ Test failed: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -36,6 +36,10 @@ class VLChanPlayer:
self.last_sync_time = 0 self.last_sync_time = 0
self.sync_threshold = 0.1 # 100ms threshold for sync self.sync_threshold = 0.1 # 100ms threshold for sync
# Playback rate adjustment constants (from VideoPlayer.tsx)
self.rate_k = 0.5 # K constant
self.rate_range = 0.05 # Range limit
# Callbacks # Callbacks
self.on_state_change: Optional[Callable[[SynchanState], None]] = None self.on_state_change: Optional[Callable[[SynchanState], None]] = None
@@ -77,24 +81,51 @@ class VLChanPlayer:
if self.on_state_change: if self.on_state_change:
self.on_state_change(state) self.on_state_change(state)
# Sync playback state # Get current local time
if state.playing != self._is_playing: local_time = self.player.get_time() / 1000.0 # Convert to seconds
if state.playing:
self.play() # Apply latency compensation (similar to VideoPlayer.tsx)
# currentTime = newState.currentTime + newState.latency / 1000
compensated_time = state.currentTime + state.latency / 1000
print(f"Control: playing={state.playing}, server_time={state.currentTime:.2f}s, "
f"latency={state.latency:.0f}ms, compensated={compensated_time:.2f}s, "
f"local={local_time:.2f}s")
if state.playing:
# Calculate time difference
diff = compensated_time - local_time
# If difference is large (>1s), seek to correct position
if diff > 1.0:
print(f"Large diff detected ({diff:.2f}s), seeking to {compensated_time:.2f}s")
seek_time = int(compensated_time * 1000) # Convert to milliseconds
self.player.set_time(seek_time)
local_time = compensated_time # Update local time after seek
diff = 0 # Reset diff after seek
# Calculate playback rate adjustment (similar to getPlaybackRate)
new_rate = self._get_playback_rate(diff)
# Apply playback rate and play
self.player.set_rate(new_rate)
if not self._is_playing:
self.player.play()
self._is_playing = True
print(f"Started playback with rate {new_rate:.3f}")
else: else:
self.pause() print(f"Adjusted playback rate to {new_rate:.3f} (diff: {diff:.3f}s)")
self._is_playing = state.playing else:
# Pause and set exact time
# Sync time position if self._is_playing:
current_time = self.player.get_time() / 1000.0 # Convert to seconds self.player.pause()
time_diff = abs(current_time - state.currentTime) self._is_playing = False
print("Paused playback")
if time_diff > self.sync_threshold:
# Seek to match synchan time # Set exact time when paused
seek_time = int(state.currentTime * 1000) # Convert to milliseconds seek_time = int(state.currentTime * 1000)
self.player.set_time(seek_time) self.player.set_time(seek_time)
self.last_sync_time = time.time() print(f"Set paused time to {state.currentTime:.2f}s")
print(f"Synced to time: {state.currentTime:.2f}s (diff: {time_diff:.2f}s)")
def _handle_synchan_error(self, error): def _handle_synchan_error(self, error):
"""Handle synchan connection errors.""" """Handle synchan connection errors."""
@@ -106,6 +137,24 @@ class VLChanPlayer:
"""Handle synchan connection completion.""" """Handle synchan connection completion."""
print("Synchan connection completed") print("Synchan connection completed")
def _get_playback_rate(self, diff: float) -> float:
"""Calculate playback rate adjustment based on time difference.
This implements the same logic as getPlaybackRate in VideoPlayer.tsx:
return 1 + Math.min(Math.max(diff * 0.5, -range), range);
Args:
diff: Time difference in seconds (positive = ahead, negative = behind)
Returns:
Adjusted playback rate (1.0 = normal speed)
"""
# Apply the same formula: 1 + min(max(diff * K, -range), range)
rate_adjustment = diff * self.rate_k
rate_adjustment = max(rate_adjustment, -self.rate_range)
rate_adjustment = min(rate_adjustment, self.rate_range)
return 1.0 + rate_adjustment
def play(self): def play(self):
"""Start video playback.""" """Start video playback."""
if self.player.get_media(): if self.player.get_media():
@@ -137,6 +186,17 @@ class VLChanPlayer:
"""Get current playback position in seconds.""" """Get current playback position in seconds."""
return self.player.get_time() / 1000.0 return self.player.get_time() / 1000.0
def get_compensated_position(self, latency: float) -> float:
"""Get latency-compensated position for synchronization.
Args:
latency: Network latency in milliseconds
Returns:
Position adjusted for latency
"""
return self.get_position() + latency / 1000.0
def get_duration(self) -> float: def get_duration(self) -> float:
"""Get video duration in seconds.""" """Get video duration in seconds."""
return self.player.get_length() / 1000.0 return self.player.get_length() / 1000.0
@@ -153,6 +213,14 @@ class VLChanPlayer:
"""Get current volume (0-100).""" """Get current volume (0-100)."""
return self.player.audio_get_volume() return self.player.audio_get_volume()
def get_rate(self) -> float:
"""Get current playback rate."""
return self.player.get_rate()
def set_rate(self, rate: float):
"""Set playback rate (1.0 = normal speed)."""
self.player.set_rate(rate)
def set_fullscreen(self, fullscreen: bool): def set_fullscreen(self, fullscreen: bool):
"""Set fullscreen mode.""" """Set fullscreen mode."""
self.player.set_fullscreen(fullscreen) self.player.set_fullscreen(fullscreen)