From d335245eb06061e010c980d4011f6deef1a8873c Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Thu, 9 Oct 2025 16:42:35 +1100 Subject: [PATCH] feat: rate based sync --- .gitignore | 2 + README.md | 39 ++++++-- example.py | 11 ++- test_sync.py | 101 ++++++++++++++++++++ vlchan/__pycache__/__init__.cpython-313.pyc | Bin 370 -> 0 bytes vlchan/__pycache__/player.cpython-313.pyc | Bin 10265 -> 0 bytes vlchan/__pycache__/synchan.cpython-313.pyc | Bin 5988 -> 0 bytes vlchan/player.py | 100 +++++++++++++++---- 8 files changed, 229 insertions(+), 24 deletions(-) create mode 100644 .gitignore create mode 100644 test_sync.py delete mode 100644 vlchan/__pycache__/__init__.cpython-313.pyc delete mode 100644 vlchan/__pycache__/player.cpython-313.pyc delete mode 100644 vlchan/__pycache__/synchan.cpython-313.pyc 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 2c89cee76d5fceb48fe4751274bdd64929116572..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 370 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 diff --git a/vlchan/__pycache__/synchan.cpython-313.pyc b/vlchan/__pycache__/synchan.cpython-313.pyc deleted file mode 100644 index 723b9386143faa6ede402b8d8105379622f1a926..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5988 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 QLgh>GKuscIAJgZ50ICZm`~Uy| 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)