feat: rate based sync
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
__pycache__
|
||||
.mypy_cache
|
||||
39
README.md
39
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
|
||||
|
||||
|
||||
11
example.py
11
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)
|
||||
|
||||
101
test_sync.py
Normal file
101
test_sync.py
Normal 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())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
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")
|
||||
|
||||
# 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
|
||||
# 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)
|
||||
|
||||
Reference in New Issue
Block a user