diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d907342 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +.mypy_cache \ No newline at end of file diff --git a/README.md b/README.md index 95a8702..dde8c5f 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,11 @@ A Python video player using VLC SDK with synchan server timecode synchronization - VLC-based video playback with full media support - Real-time synchronization with synchan server - 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 -- Volume and fullscreen controls +- Volume, playback rate, and fullscreen controls ## 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 - `set_fullscreen(fullscreen: bool)`: Toggle fullscreen - `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 - `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 #### Properties @@ -128,12 +133,32 @@ Data class containing synchronization state: ## 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 -2. **Time Position**: Video seeks to match synchan timecode -3. **Threshold**: Only seeks if time difference exceeds 100ms -4. **Reconnection**: Automatically attempts to reconnect on connection loss +1. **Latency Compensation**: Server time is adjusted by network latency +2. **Smart Seeking**: Large time differences (>1s) trigger immediate seeking +3. **Playback Rate Adjustment**: Small differences are corrected by adjusting playback speed +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 diff --git a/example.py b/example.py index 924fdf1..472d4c1 100644 --- a/example.py +++ b/example.py @@ -30,7 +30,8 @@ def main(): def on_state_change(state): print(f"Synchan update: playing={state.playing}, " 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 @@ -52,6 +53,8 @@ def main(): print("- Press 's' to stop") print("- Press 'f' to toggle fullscreen") 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 'j' to seek forward 10s") print("- Press 'k' to seek backward 10s") @@ -84,6 +87,12 @@ def main(): new_vol = min(100, current_vol + 10) player.set_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': current_pos = player.get_position() player.seek(current_pos + 10) diff --git a/test_sync.py b/test_sync.py new file mode 100644 index 0000000..fa798de --- /dev/null +++ b/test_sync.py @@ -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()) diff --git a/vlchan/__pycache__/__init__.cpython-313.pyc b/vlchan/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 2c89cee..0000000 Binary files a/vlchan/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/vlchan/__pycache__/player.cpython-313.pyc b/vlchan/__pycache__/player.cpython-313.pyc deleted file mode 100644 index 1e33070..0000000 Binary files a/vlchan/__pycache__/player.cpython-313.pyc and /dev/null differ diff --git a/vlchan/__pycache__/synchan.cpython-313.pyc b/vlchan/__pycache__/synchan.cpython-313.pyc deleted file mode 100644 index 723b938..0000000 Binary files a/vlchan/__pycache__/synchan.cpython-313.pyc and /dev/null differ diff --git a/vlchan/player.py b/vlchan/player.py index b157347..7a85536 100644 --- a/vlchan/player.py +++ b/vlchan/player.py @@ -36,6 +36,10 @@ class VLChanPlayer: self.last_sync_time = 0 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 self.on_state_change: Optional[Callable[[SynchanState], None]] = None @@ -77,24 +81,51 @@ class VLChanPlayer: if self.on_state_change: self.on_state_change(state) - # Sync playback state - if state.playing != self._is_playing: - if state.playing: - self.play() + # Get current local time + local_time = self.player.get_time() / 1000.0 # Convert to seconds + + # 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: - self.pause() - self._is_playing = state.playing - - # Sync time position - current_time = self.player.get_time() / 1000.0 # Convert to seconds - time_diff = abs(current_time - state.currentTime) - - if time_diff > self.sync_threshold: - # Seek to match synchan time - seek_time = int(state.currentTime * 1000) # Convert to milliseconds + print(f"Adjusted playback rate to {new_rate:.3f} (diff: {diff:.3f}s)") + else: + # Pause and set exact time + if self._is_playing: + self.player.pause() + self._is_playing = False + print("Paused playback") + + # Set exact time when paused + seek_time = int(state.currentTime * 1000) self.player.set_time(seek_time) - self.last_sync_time = time.time() - print(f"Synced to time: {state.currentTime:.2f}s (diff: {time_diff:.2f}s)") + print(f"Set paused time to {state.currentTime:.2f}s") def _handle_synchan_error(self, error): """Handle synchan connection errors.""" @@ -106,6 +137,24 @@ class VLChanPlayer: """Handle synchan connection completion.""" 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): """Start video playback.""" if self.player.get_media(): @@ -137,6 +186,17 @@ class VLChanPlayer: """Get current playback position in seconds.""" 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: """Get video duration in seconds.""" return self.player.get_length() / 1000.0 @@ -153,6 +213,14 @@ class VLChanPlayer: """Get current volume (0-100).""" 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): """Set fullscreen mode.""" self.player.set_fullscreen(fullscreen)