summaryrefslogtreecommitdiff
path: root/cs2pov/comms.py
diff options
context:
space:
mode:
Diffstat (limited to 'cs2pov/comms.py')
-rw-r--r--cs2pov/comms.py97
1 files changed, 86 insertions, 11 deletions
diff --git a/cs2pov/comms.py b/cs2pov/comms.py
index 7d96e20..8fb1698 100644
--- a/cs2pov/comms.py
+++ b/cs2pov/comms.py
@@ -5,13 +5,16 @@ import tempfile
from pathlib import Path
from typing import Optional
-from .preprocessor import AliveSegment, DemoTimeline
+from .preprocessor import AliveSegment, DemoTimeline, RoundBoundary
def compute_comms_segments(
alive_segments: list[AliveSegment],
+ rounds: list[RoundBoundary],
round1_freeze_end_time: float,
r1_sync_time: float = 0.0,
+ tick_nav: bool = False,
+ keep_durations: Optional[list[float]] = None,
) -> list[tuple[float, float]]:
"""Map alive segments from demo time to comms audio time.
@@ -19,18 +22,48 @@ def compute_comms_segments(
r1_sync_time is the timestamp in the comms audio where round 1 starts.
Segments before comms t=0 are clamped or dropped.
+ When keep_durations is provided (from trim's actual keep segments), those
+ durations override the computed end times. This ensures comms segments
+ match the exact durations in the trimmed video.
+
+ When tick_nav=True and keep_durations is not available, segment end times
+ are extended to match the actual navigation behavior as a fallback.
+
Args:
alive_segments: Alive segments from DemoTimeline
+ rounds: Round boundaries from DemoTimeline
round1_freeze_end_time: Demo time (seconds) of round 1 freeze end
r1_sync_time: Seconds into comms audio where round 1 begins
+ tick_nav: Whether tick-nav mode was used (extends segment boundaries)
+ keep_durations: Durations from trim's actual keep segments (overrides end time calc)
Returns:
List of (start, end) tuples in comms audio time (seconds)
"""
segments = []
- for seg in alive_segments:
+ for i, seg in enumerate(alive_segments):
comms_start = seg.start_time - round1_freeze_end_time + r1_sync_time
- comms_end = seg.end_time - round1_freeze_end_time + r1_sync_time
+
+ if keep_durations and i < len(keep_durations):
+ # Use trim's actual duration — guarantees sync with trimmed video
+ comms_end = comms_start + keep_durations[i]
+ else:
+ # Fallback: compute end from demo timeline
+ end_time = seg.end_time
+
+ if tick_nav:
+ # Match navigation.py segment duration extensions
+ if seg.reason_ended == "round_end":
+ next_seg = alive_segments[i + 1] if i + 1 < len(alive_segments) else None
+ if next_seg:
+ for r in rounds:
+ if r.round_num == next_seg.round_num and r.prestart_time is not None:
+ end_time = r.prestart_time - 0.2
+ break
+ elif seg.reason_ended == "death":
+ end_time += 0.9
+
+ comms_end = end_time - round1_freeze_end_time + r1_sync_time
if comms_end <= 0:
continue
@@ -174,6 +207,7 @@ def overlay_comms_on_video(
output_path: Path,
game_volume: float = 1.0,
comms_volume: float = 1.0,
+ tempo: Optional[float] = None,
verbose: bool = False,
) -> bool:
"""Mix comms audio onto video's existing game audio using FFmpeg amix.
@@ -187,6 +221,7 @@ def overlay_comms_on_video(
output_path: Output video path
game_volume: Volume multiplier for game audio (default 1.0)
comms_volume: Volume multiplier for comms audio (default 1.0)
+ tempo: Optional atempo correction factor (e.g. 1.005 to speed up slightly)
verbose: Print debug info
Returns:
@@ -194,11 +229,16 @@ def overlay_comms_on_video(
"""
has_audio = _has_audio_stream(video_path)
+ # Build comms filter chain: volume → optional atempo
+ comms_filters = f"volume={comms_volume}"
+ if tempo is not None:
+ comms_filters += f",atempo={tempo}"
+
if has_audio:
# Mix game audio + comms audio
filter_complex = (
f"[0:a]volume={game_volume}[game];"
- f"[1:a]volume={comms_volume}[comms];"
+ f"[1:a]{comms_filters}[comms];"
f"[game][comms]amix=inputs=2:duration=first:dropout_transition=0[aout]"
)
cmd = [
@@ -214,13 +254,14 @@ def overlay_comms_on_video(
str(output_path),
]
else:
- # No game audio — use comms as sole audio track
+ # No game audio — apply filters to comms and use as sole audio
cmd = [
"ffmpeg", "-y",
"-i", str(video_path),
"-i", str(comms_audio_path),
+ "-filter_complex", f"[1:a]{comms_filters}[aout]",
"-map", "0:v",
- "-map", "1:a",
+ "-map", "[aout]",
"-c:v", "copy",
"-c:a", "aac",
"-b:a", "192k",
@@ -229,7 +270,8 @@ def overlay_comms_on_video(
if verbose:
mode = "mixing with game audio" if has_audio else "adding as sole audio"
- print(f" [Comms] Overlaying comms ({mode})")
+ tempo_info = f", atempo={tempo:.6f}" if tempo else ""
+ print(f" [Comms] Overlaying comms ({mode}{tempo_info})")
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
@@ -255,12 +297,15 @@ def apply_comms_overlay(
comms_volume: float = 1.0,
is_trimmed: bool = True,
verbose: bool = False,
+ tick_nav: bool = False,
+ keep_segments: Optional[list[tuple[float, float]]] = None,
) -> bool:
"""High-level entry point: trim comms to match alive segments, then overlay onto video.
If the video is trimmed and a timeline is provided, the comms audio is cut
- to match the same alive segments so it stays in sync. Otherwise, the comms
- are overlaid directly with an offset seek.
+ to match the same alive segments so it stays in sync. When keep_segments
+ is provided (from trim's actual output), those durations are used instead
+ of recomputing from the timeline, preventing audio drift.
Args:
video_path: Input video (trimmed or raw)
@@ -272,6 +317,8 @@ def apply_comms_overlay(
comms_volume: Comms audio volume (default 1.0)
is_trimmed: Whether the video has been trimmed
verbose: Print debug info
+ tick_nav: Whether tick-nav mode was used (fallback for segment extensions)
+ keep_segments: Actual keep segments from trim (overrides duration calculation)
Returns:
True on success
@@ -283,9 +330,17 @@ def apply_comms_overlay(
print(" Warning: No round data found, using demo start as reference")
round1_time = 0.0
+ # Extract durations from trim's keep segments if available
+ keep_durations = None
+ if keep_segments:
+ keep_durations = [end - start for start, end in keep_segments]
+ if verbose:
+ print(f" [Comms] Using {len(keep_durations)} keep segment durations from trim")
+
# Compute which parts of comms audio correspond to alive segments
comms_segments = compute_comms_segments(
- timeline.alive_segments, round1_time, r1_sync_time
+ timeline.alive_segments, timeline.rounds, round1_time,
+ r1_sync_time, tick_nav=tick_nav, keep_durations=keep_durations,
)
if not comms_segments:
@@ -303,10 +358,30 @@ def apply_comms_overlay(
print(" Error: Failed to extract comms segments")
return False
+ # Compute atempo correction for residual drift
+ tempo = None
+ try:
+ from .trim import get_video_duration
+ comms_dur = get_video_duration(trimmed_comms)
+ video_dur = get_video_duration(video_path)
+ if comms_dur > 0 and video_dur > 0 and abs(comms_dur - video_dur) > 1.0:
+ tempo = comms_dur / video_dur
+ if verbose:
+ print(f" [Comms] Atempo correction: {tempo:.6f} "
+ f"(comms={comms_dur:.2f}s, video={video_dur:.2f}s, "
+ f"drift={comms_dur - video_dur:+.2f}s)")
+ elif verbose:
+ print(f" [Comms] No atempo needed "
+ f"(comms={comms_dur:.2f}s, video={video_dur:.2f}s, "
+ f"drift={comms_dur - video_dur:+.2f}s)")
+ except Exception as e:
+ if verbose:
+ print(f" [Comms] Could not compute atempo: {e}")
+
# Overlay trimmed comms onto trimmed video
return overlay_comms_on_video(
video_path, trimmed_comms, output_path,
- game_volume, comms_volume, verbose,
+ game_volume, comms_volume, tempo, verbose,
)
else:
# Untrimmed or no timeline — overlay directly with offset