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
|
- 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
|
||||||
|
|
||||||
|
|||||||
11
example.py
11
example.py
@@ -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
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.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
|
||||||
|
if self._is_playing:
|
||||||
|
self.player.pause()
|
||||||
|
self._is_playing = False
|
||||||
|
print("Paused playback")
|
||||||
|
|
||||||
# Sync time position
|
# Set exact time when paused
|
||||||
current_time = self.player.get_time() / 1000.0 # Convert to seconds
|
seek_time = int(state.currentTime * 1000)
|
||||||
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
|
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user