Fixing the audio looping and pause narration issue
This commit is contained in:
@@ -429,6 +429,14 @@ class AudioEvent:
|
|||||||
# position (seconds) into the source stream to begin at — the loop phase for
|
# position (seconds) into the source stream to begin at — the loop phase for
|
||||||
# looping music, or a linear seek for one-shots. 0.0 = play from the start (v1).
|
# looping music, or a linear seek for one-shots. 0.0 = play from the start (v1).
|
||||||
src_offset: float = 0.0
|
src_offset: float = 0.0
|
||||||
|
# Loop phase for the CROSSFADE loop path specifically. That stream is periodic
|
||||||
|
# with period (duration - overlap), not `duration`, so its seam-resume phase is
|
||||||
|
# `elapsed % (duration - overlap)` — a different modulus than src_offset. Only
|
||||||
|
# set for looping clips that define an overlap; 0.0 otherwise.
|
||||||
|
crossfade_offset: float = 0.0
|
||||||
|
# Explicit stop time (output timeline) from an [end:handle] marker; None means
|
||||||
|
# play to the render/window end (loop) or the clip's natural length (one-shot).
|
||||||
|
end_time: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
+65
-23
@@ -78,6 +78,7 @@ def _build_crossfade_loop_filter(
|
|||||||
needed_duration: float,
|
needed_duration: float,
|
||||||
volume: float,
|
volume: float,
|
||||||
delay_ms: int,
|
delay_ms: int,
|
||||||
|
start_offset: float = 0.0,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Build FFmpeg filter chain for crossfade looping.
|
Build FFmpeg filter chain for crossfade looping.
|
||||||
@@ -85,6 +86,12 @@ def _build_crossfade_loop_filter(
|
|||||||
Creates a seamless loop by overlapping copies of the audio with fade in/out.
|
Creates a seamless loop by overlapping copies of the audio with fade in/out.
|
||||||
Each loop iteration crossfades with the next for `overlap` seconds.
|
Each loop iteration crossfades with the next for `overlap` seconds.
|
||||||
|
|
||||||
|
The crossfaded stream is periodic with period ``loop_len = audio_duration -
|
||||||
|
overlap``. ``start_offset`` seeks into that continuous stream, so a chunk
|
||||||
|
that begins mid-loop (e.g. the second half of a partial/chunked render)
|
||||||
|
resumes at the correct loop phase instead of restarting from the top. This
|
||||||
|
is what keeps background music seamless across chunk seams.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_label: Input stream label (e.g., "[0:a]")
|
input_label: Input stream label (e.g., "[0:a]")
|
||||||
output_label: Output stream label (e.g., "[aud0]")
|
output_label: Output stream label (e.g., "[aud0]")
|
||||||
@@ -93,6 +100,7 @@ def _build_crossfade_loop_filter(
|
|||||||
needed_duration: Total duration needed
|
needed_duration: Total duration needed
|
||||||
volume: Volume multiplier
|
volume: Volume multiplier
|
||||||
delay_ms: Initial delay in milliseconds
|
delay_ms: Initial delay in milliseconds
|
||||||
|
start_offset: Phase (seconds) into the crossfade loop stream to start at
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of filter strings to append to the filter_complex
|
List of filter strings to append to the filter_complex
|
||||||
@@ -100,8 +108,12 @@ def _build_crossfade_loop_filter(
|
|||||||
filters = []
|
filters = []
|
||||||
loop_len = audio_duration - overlap
|
loop_len = audio_duration - overlap
|
||||||
|
|
||||||
|
# Build the crossfade stream from phase 0, long enough to cover the phase we
|
||||||
|
# seek past plus the duration we actually need, then trim [start_offset ...].
|
||||||
|
build_duration = start_offset + needed_duration
|
||||||
|
|
||||||
# Calculate number of loop iterations needed (add 1 extra for safety)
|
# Calculate number of loop iterations needed (add 1 extra for safety)
|
||||||
n_loops = math.ceil(needed_duration / loop_len) + 1
|
n_loops = math.ceil(build_duration / loop_len) + 1
|
||||||
|
|
||||||
# Limit to reasonable number of loops to avoid filter complexity explosion
|
# Limit to reasonable number of loops to avoid filter complexity explosion
|
||||||
n_loops = min(n_loops, 100)
|
n_loops = min(n_loops, 100)
|
||||||
@@ -109,7 +121,7 @@ def _build_crossfade_loop_filter(
|
|||||||
if n_loops <= 1:
|
if n_loops <= 1:
|
||||||
# Single play, no looping needed
|
# Single play, no looping needed
|
||||||
filters.append(
|
filters.append(
|
||||||
f"{input_label}atrim=0:{needed_duration:.3f},"
|
f"{input_label}atrim={start_offset:.3f}:{start_offset + needed_duration:.3f},"
|
||||||
f"asetpts=PTS-STARTPTS,"
|
f"asetpts=PTS-STARTPTS,"
|
||||||
f"adelay={delay_ms}|{delay_ms},"
|
f"adelay={delay_ms}|{delay_ms},"
|
||||||
f"volume={volume:.2f}{output_label}"
|
f"volume={volume:.2f}{output_label}"
|
||||||
@@ -120,15 +132,16 @@ def _build_crossfade_loop_filter(
|
|||||||
split_labels = [f"[xfloop_{output_label[1:-1]}_{i}]" for i in range(n_loops)]
|
split_labels = [f"[xfloop_{output_label[1:-1]}_{i}]" for i in range(n_loops)]
|
||||||
filters.append(f"{input_label}asplit={n_loops}{''.join(split_labels)}")
|
filters.append(f"{input_label}asplit={n_loops}{''.join(split_labels)}")
|
||||||
|
|
||||||
# Process each copy with appropriate delay and fades
|
# Process each copy with appropriate delay and fades. Copies are laid out in
|
||||||
|
# loop time (no output delay yet); the output delay/phase-trim is applied
|
||||||
|
# once after mixing so the phase seek is straightforward.
|
||||||
mix_labels = []
|
mix_labels = []
|
||||||
for i in range(n_loops):
|
for i in range(n_loops):
|
||||||
copy_label = split_labels[i]
|
copy_label = split_labels[i]
|
||||||
out_label = f"[xfl_{output_label[1:-1]}_{i}]"
|
out_label = f"[xfl_{output_label[1:-1]}_{i}]"
|
||||||
mix_labels.append(out_label)
|
mix_labels.append(out_label)
|
||||||
|
|
||||||
loop_delay = i * loop_len
|
loop_delay_ms = int(i * loop_len * 1000)
|
||||||
total_delay_ms = delay_ms + int(loop_delay * 1000)
|
|
||||||
|
|
||||||
# Build filter chain for this copy
|
# Build filter chain for this copy
|
||||||
chain_parts = []
|
chain_parts = []
|
||||||
@@ -143,17 +156,20 @@ def _build_crossfade_loop_filter(
|
|||||||
if fade_out_start > 0:
|
if fade_out_start > 0:
|
||||||
chain_parts.append(f"afade=t=out:st={fade_out_start:.3f}:d={overlap:.3f}")
|
chain_parts.append(f"afade=t=out:st={fade_out_start:.3f}:d={overlap:.3f}")
|
||||||
|
|
||||||
chain_parts.append(f"adelay={total_delay_ms}|{total_delay_ms}")
|
if loop_delay_ms > 0:
|
||||||
chain_parts.append(f"volume={volume:.2f}")
|
chain_parts.append(f"adelay={loop_delay_ms}|{loop_delay_ms}")
|
||||||
|
|
||||||
filter_chain = ",".join(chain_parts)
|
filter_chain = ",".join(chain_parts)
|
||||||
filters.append(f"{copy_label}{filter_chain}{out_label}")
|
filters.append(f"{copy_label}{filter_chain}{out_label}")
|
||||||
|
|
||||||
# Mix all copies together, then trim to needed duration
|
# Mix all copies into the continuous loop stream, seek to the loop phase
|
||||||
|
# (start_offset), apply volume, then the output delay.
|
||||||
filters.append(
|
filters.append(
|
||||||
f"{''.join(mix_labels)}amix=inputs={n_loops}:duration=longest:normalize=0,"
|
f"{''.join(mix_labels)}amix=inputs={n_loops}:duration=longest:normalize=0,"
|
||||||
f"atrim=0:{needed_duration + delay_ms/1000:.3f},"
|
f"atrim={start_offset:.3f}:{start_offset + needed_duration:.3f},"
|
||||||
f"asetpts=PTS-STARTPTS{output_label}"
|
f"asetpts=PTS-STARTPTS,"
|
||||||
|
f"volume={volume:.2f},"
|
||||||
|
f"adelay={delay_ms}|{delay_ms}{output_label}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return filters
|
return filters
|
||||||
@@ -1487,11 +1503,16 @@ def build_filter_complex(
|
|||||||
for i, event in enumerate(plan.audio_events):
|
for i, event in enumerate(plan.audio_events):
|
||||||
audio_idx = audio_inputs[event.audio_id]
|
audio_idx = audio_inputs[event.audio_id]
|
||||||
volume = event.audio_def.volume
|
volume = event.audio_def.volume
|
||||||
|
# An [end:handle] marker caps this clip's stop time; otherwise a loop
|
||||||
|
# fills to the render/window end and a one-shot plays its natural length.
|
||||||
|
_clip_end = getattr(event, "end_time", None)
|
||||||
|
|
||||||
if event.audio_def.loop:
|
if event.audio_def.loop:
|
||||||
# Looping audio: loop source, then trim/segment
|
# Looping audio: loop source, then trim/segment. Stop at the end
|
||||||
# Stop at narration end if there's an outro
|
# marker if set, else at narration end / outro.
|
||||||
loop_end_time = audio_end_time
|
loop_end_time = (
|
||||||
|
audio_end_time if _clip_end is None else min(audio_end_time, _clip_end)
|
||||||
|
)
|
||||||
remaining = loop_end_time - event.start_time
|
remaining = loop_end_time - event.start_time
|
||||||
|
|
||||||
if plan.narration_pauses and not event.audio_def.ignore_pauses:
|
if plan.narration_pauses and not event.audio_def.ignore_pauses:
|
||||||
@@ -1557,6 +1578,10 @@ def build_filter_complex(
|
|||||||
needed_duration=remaining,
|
needed_duration=remaining,
|
||||||
volume=volume,
|
volume=volume,
|
||||||
delay_ms=delay_ms,
|
delay_ms=delay_ms,
|
||||||
|
# Chunking v2: resume at the loop phase so background
|
||||||
|
# music continues across chunk seams instead of
|
||||||
|
# restarting from the top.
|
||||||
|
start_offset=getattr(event, "crossfade_offset", 0.0),
|
||||||
)
|
)
|
||||||
filters.extend(crossfade_filters)
|
filters.extend(crossfade_filters)
|
||||||
else:
|
else:
|
||||||
@@ -1593,7 +1618,14 @@ def build_filter_complex(
|
|||||||
)
|
)
|
||||||
if not relevant_pauses:
|
if not relevant_pauses:
|
||||||
delay_ms = int(event.start_time * 1000)
|
delay_ms = int(event.start_time * 1000)
|
||||||
_seek = f"atrim={_off:.3f},asetpts=PTS-STARTPTS," if _off > 0 else ""
|
if _clip_end is not None:
|
||||||
|
# [end:handle] → play only up to the stop time, then trim.
|
||||||
|
_dur = max(0.0, _clip_end - event.start_time)
|
||||||
|
_seek = f"atrim={_off:.3f}:{_off + _dur:.3f},asetpts=PTS-STARTPTS,"
|
||||||
|
elif _off > 0:
|
||||||
|
_seek = f"atrim={_off:.3f},asetpts=PTS-STARTPTS,"
|
||||||
|
else:
|
||||||
|
_seek = ""
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[{audio_idx}:a]{_seek}adelay={delay_ms}|{delay_ms},volume={volume:.2f}[{label}]"
|
f"[{audio_idx}:a]{_seek}adelay={delay_ms}|{delay_ms},volume={volume:.2f}[{label}]"
|
||||||
)
|
)
|
||||||
@@ -1601,11 +1633,15 @@ def build_filter_complex(
|
|||||||
else:
|
else:
|
||||||
# Play [seg_start, pause) of source, freeze during the pause,
|
# Play [seg_start, pause) of source, freeze during the pause,
|
||||||
# then resume — source position (src_pos) never advances across
|
# then resume — source position (src_pos) never advances across
|
||||||
# the gap. Final segment runs to the source's natural end.
|
# the gap. Final segment runs to the [end:handle] stop (if set),
|
||||||
|
# otherwise the source's natural end.
|
||||||
|
_end = _clip_end if _clip_end is not None else float("inf")
|
||||||
src_pos = _off
|
src_pos = _off
|
||||||
seg_start = event.start_time
|
seg_start = event.start_time
|
||||||
seg_count = 0
|
seg_count = 0
|
||||||
for pause in relevant_pauses:
|
for pause in relevant_pauses:
|
||||||
|
if pause.output_time >= _end:
|
||||||
|
break
|
||||||
if pause.output_time > seg_start:
|
if pause.output_time > seg_start:
|
||||||
seg_dur = pause.output_time - seg_start
|
seg_dur = pause.output_time - seg_start
|
||||||
seg_label = f"{label}_seg{seg_count}"
|
seg_label = f"{label}_seg{seg_count}"
|
||||||
@@ -1619,14 +1655,20 @@ def build_filter_complex(
|
|||||||
src_pos += seg_dur
|
src_pos += seg_dur
|
||||||
seg_count += 1
|
seg_count += 1
|
||||||
seg_start = pause.output_time + pause.duration
|
seg_start = pause.output_time + pause.duration
|
||||||
seg_label = f"{label}_seg{seg_count}"
|
if seg_start < _end:
|
||||||
d_ms = int(seg_start * 1000)
|
seg_label = f"{label}_seg{seg_count}"
|
||||||
filters.append(
|
d_ms = int(seg_start * 1000)
|
||||||
f"[{audio_idx}:a]atrim={src_pos:.3f},"
|
_atrim = (
|
||||||
f"asetpts=PTS-STARTPTS,adelay={d_ms}|{d_ms},"
|
f"atrim={src_pos:.3f}:{src_pos + (_end - seg_start):.3f}"
|
||||||
f"volume={volume:.2f}[{seg_label}]"
|
if _clip_end is not None
|
||||||
)
|
else f"atrim={src_pos:.3f}"
|
||||||
audio_labels_to_mix.append(f"[{seg_label}]")
|
)
|
||||||
|
filters.append(
|
||||||
|
f"[{audio_idx}:a]{_atrim},"
|
||||||
|
f"asetpts=PTS-STARTPTS,adelay={d_ms}|{d_ms},"
|
||||||
|
f"volume={volume:.2f}[{seg_label}]"
|
||||||
|
)
|
||||||
|
audio_labels_to_mix.append(f"[{seg_label}]")
|
||||||
|
|
||||||
# Extract and mix audio from triggered video events
|
# Extract and mix audio from triggered video events
|
||||||
_have_audio = video_events_with_audio or set()
|
_have_audio = video_events_with_audio or set()
|
||||||
|
|||||||
+35
-7
@@ -964,6 +964,8 @@ def build_render_plan(
|
|||||||
event.end_time -= time_offset
|
event.end_time -= time_offset
|
||||||
for event in audio_events:
|
for event in audio_events:
|
||||||
event.start_time = max(0, event.start_time - time_offset)
|
event.start_time = max(0, event.start_time - time_offset)
|
||||||
|
if event.end_time is not None:
|
||||||
|
event.end_time = max(0.0, event.end_time - time_offset)
|
||||||
for event in camera_events:
|
for event in camera_events:
|
||||||
event.time -= time_offset
|
event.time -= time_offset
|
||||||
|
|
||||||
@@ -1015,6 +1017,8 @@ def build_render_plan(
|
|||||||
for aud_event in audio_events:
|
for aud_event in audio_events:
|
||||||
if aud_event.start_time > narration_time:
|
if aud_event.start_time > narration_time:
|
||||||
aud_event.start_time += pause_duration
|
aud_event.start_time += pause_duration
|
||||||
|
if aud_event.end_time is not None and aud_event.end_time > narration_time:
|
||||||
|
aud_event.end_time += pause_duration
|
||||||
|
|
||||||
for cam_event in camera_events:
|
for cam_event in camera_events:
|
||||||
if cam_event.time > narration_time:
|
if cam_event.time > narration_time:
|
||||||
@@ -1438,9 +1442,12 @@ def _extract_video_events(
|
|||||||
if vt > start_time:
|
if vt > start_time:
|
||||||
end_time = vt
|
end_time = vt
|
||||||
break
|
break
|
||||||
# A pause-narration video must stay for at least the pause it holds.
|
# A pause-narration cutscene fills EXACTLY the freeze it creates (its
|
||||||
|
# content length == pause_narration), so it ends when the freeze ends —
|
||||||
|
# not stretched to the next video, which would keep it overlaying the
|
||||||
|
# resumed narration afterwards.
|
||||||
if pause_narration:
|
if pause_narration:
|
||||||
end_time = max(end_time, start_time + pause_narration)
|
end_time = start_time + pause_narration
|
||||||
elif end_on in ("next_slide", "slide"):
|
elif end_on in ("next_slide", "slide"):
|
||||||
# End at next slide marker ("slide" is a recognised alias for "next_slide")
|
# End at next slide marker ("slide" is a recognised alias for "next_slide")
|
||||||
end_time = total_duration
|
end_time = total_duration
|
||||||
@@ -1448,10 +1455,9 @@ def _extract_video_events(
|
|||||||
if slide_time > start_time:
|
if slide_time > start_time:
|
||||||
end_time = slide_time
|
end_time = slide_time
|
||||||
break
|
break
|
||||||
# pause_narration videos must stay visible for the full pause duration —
|
# pause_narration cutscene: end exactly with the freeze (see above).
|
||||||
# the narration is held for that long, so the overlay should match.
|
|
||||||
if pause_narration:
|
if pause_narration:
|
||||||
end_time = max(end_time, start_time + pause_narration)
|
end_time = start_time + pause_narration
|
||||||
elif end_on == "end_marker":
|
elif end_on == "end_marker":
|
||||||
# Explicit end: stop at the first [end:handle] placed after this clip
|
# Explicit end: stop at the first [end:handle] placed after this clip
|
||||||
# starts (so the same handle can be reused in different sections).
|
# starts (so the same handle can be reused in different sections).
|
||||||
@@ -1471,7 +1477,7 @@ def _extract_video_events(
|
|||||||
f"after it — ending at the next video instead."
|
f"after it — ending at the next video instead."
|
||||||
)
|
)
|
||||||
if pause_narration:
|
if pause_narration:
|
||||||
end_time = max(end_time, start_time + pause_narration)
|
end_time = start_time + pause_narration
|
||||||
else:
|
else:
|
||||||
# end_on None ([narration:] with no explicit end) — runs to end.
|
# end_on None ([narration:] with no explicit end) — runs to end.
|
||||||
end_time = total_duration
|
end_time = total_duration
|
||||||
@@ -1525,6 +1531,15 @@ def _extract_audio_events(
|
|||||||
range_start, range_end = time_range if time_range else (0.0, float("inf"))
|
range_start, range_end = time_range if time_range else (0.0, float("inf"))
|
||||||
events: list[AudioEvent] = []
|
events: list[AudioEvent] = []
|
||||||
|
|
||||||
|
# [end:handle] markers stop an audio clip early (parallel to the video end_marker,
|
||||||
|
# but audio opts in automatically — there is no per-clip end_on to set).
|
||||||
|
audio_end_markers: dict[str, list[float]] = {}
|
||||||
|
for timing in marker_timings:
|
||||||
|
if timing.timestamp is not None and timing.timestamp >= 0 and timing.marker_id.startswith("end:"):
|
||||||
|
audio_end_markers.setdefault(timing.marker_id[4:].lower(), []).append(timing.timestamp)
|
||||||
|
for _h in audio_end_markers:
|
||||||
|
audio_end_markers[_h].sort()
|
||||||
|
|
||||||
for timing in marker_timings:
|
for timing in marker_timings:
|
||||||
if timing.timestamp < 0:
|
if timing.timestamp < 0:
|
||||||
continue
|
continue
|
||||||
@@ -1538,8 +1553,13 @@ def _extract_audio_events(
|
|||||||
if audio_id is not None and audio_id in audio:
|
if audio_id is not None and audio_id in audio:
|
||||||
adef = audio[audio_id]
|
adef = audio[audio_id]
|
||||||
astart = max(0, timing.timestamp - AUDIO_OFFSET_SECONDS)
|
astart = max(0, timing.timestamp - AUDIO_OFFSET_SECONDS)
|
||||||
|
# Explicit stop from the first [end:audio_id] placed after this clip starts.
|
||||||
|
_ends = [t for t in audio_end_markers.get(audio_id.lower(), ()) if t > timing.timestamp]
|
||||||
|
clip_end = _ends[0] if _ends else None
|
||||||
# Effective end of this clip on the output timeline.
|
# Effective end of this clip on the output timeline.
|
||||||
if adef.loop:
|
if clip_end is not None:
|
||||||
|
aend = clip_end
|
||||||
|
elif adef.loop:
|
||||||
aend = range_end # a loop fills to the window/render end
|
aend = range_end # a loop fills to the window/render end
|
||||||
elif adef.duration is not None:
|
elif adef.duration is not None:
|
||||||
aend = astart + adef.duration
|
aend = astart + adef.duration
|
||||||
@@ -1552,10 +1572,16 @@ def _extract_audio_events(
|
|||||||
if aend <= range_start or astart >= range_end:
|
if aend <= range_start or astart >= range_end:
|
||||||
continue
|
continue
|
||||||
src_offset = 0.0
|
src_offset = 0.0
|
||||||
|
crossfade_offset = 0.0
|
||||||
if astart < range_start:
|
if astart < range_start:
|
||||||
into = range_start - astart
|
into = range_start - astart
|
||||||
if adef.loop and adef.duration:
|
if adef.loop and adef.duration:
|
||||||
src_offset = into % adef.duration
|
src_offset = into % adef.duration
|
||||||
|
# The crossfade loop stream repeats every (duration - overlap),
|
||||||
|
# so it resumes at a different phase than the hard aloop path.
|
||||||
|
if adef.overlap:
|
||||||
|
loop_len = max(1e-6, adef.duration - adef.overlap)
|
||||||
|
crossfade_offset = into % loop_len
|
||||||
else:
|
else:
|
||||||
src_offset = into
|
src_offset = into
|
||||||
astart = range_start
|
astart = range_start
|
||||||
@@ -1565,6 +1591,8 @@ def _extract_audio_events(
|
|||||||
start_time=astart,
|
start_time=astart,
|
||||||
audio_def=adef,
|
audio_def=adef,
|
||||||
src_offset=src_offset,
|
src_offset=src_offset,
|
||||||
|
crossfade_offset=crossfade_offset,
|
||||||
|
end_time=clip_end,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -145,13 +145,14 @@ def validate_project(
|
|||||||
if marker in ("pause", "stop"):
|
if marker in ("pause", "stop"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Explicit end markers: [end:handle] stops a video started with end_on=end_marker.
|
# Explicit end markers: [end:handle] stops a video (end_on=end_marker) OR an
|
||||||
|
# audio clip. Valid if the handle is defined in either videos.json or audio.json.
|
||||||
if marker.startswith("end:"):
|
if marker.startswith("end:"):
|
||||||
handle = marker[4:].lower()
|
handle = marker[4:].lower()
|
||||||
if handle not in videos:
|
if handle not in videos and handle not in (audio or {}):
|
||||||
warnings.append(
|
warnings.append(
|
||||||
ValidationIssue(
|
ValidationIssue(
|
||||||
f"[{marker}] ends a video, but '{handle}' isn't defined in videos.json.",
|
f"[{marker}] ends a clip, but '{handle}' isn't defined in videos.json or audio.json.",
|
||||||
project_path / "manuscript.txt",
|
project_path / "manuscript.txt",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -71,6 +71,27 @@ def test_audio():
|
|||||||
check("full render includes music with no seek", "music" in full and full["music"].src_offset == 0.0)
|
check("full render includes music with no seek", "music" in full and full["music"].src_offset == 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_crossfade_phase():
|
||||||
|
print("crossfade loop phase:")
|
||||||
|
# Looping pad with a 15s crossfade overlap: the crossfade stream repeats every
|
||||||
|
# (duration - overlap) = 60 - 15 = 45s, so a chunk starting at into=300 resumes
|
||||||
|
# at crossfade phase 300 % 45 = 30, while the hard-loop phase is 300 % 60 = 60→0.
|
||||||
|
audio = {"pad": AudioDefinition(file="pad.wav", loop=True, duration=60.0, overlap=15.0)}
|
||||||
|
markers = [MarkerTiming(marker_id="Apad", timestamp=0.0, context="", confidence=1.0)]
|
||||||
|
evs = {e.audio_id: e for e in _extract_audio_events(markers, audio, time_range=(300.0, 600.0))}
|
||||||
|
check("looping pad with overlap is included", "pad" in evs)
|
||||||
|
if "pad" in evs:
|
||||||
|
p = evs["pad"]
|
||||||
|
check("crossfade seeks to loop_len phase 30.0",
|
||||||
|
abs(p.crossfade_offset - 30.0) < 1e-6, f"crossfade_offset={p.crossfade_offset}")
|
||||||
|
check("src_offset still uses full-duration phase 0.0",
|
||||||
|
abs(p.src_offset - 0.0) < 1e-6, f"src_offset={p.src_offset}")
|
||||||
|
# Full render: no phase seek on either.
|
||||||
|
full = {e.audio_id: e for e in _extract_audio_events(markers, audio, time_range=None)}
|
||||||
|
check("full render pad has no crossfade seek",
|
||||||
|
"pad" in full and full["pad"].crossfade_offset == 0.0)
|
||||||
|
|
||||||
|
|
||||||
# ── video ────────────────────────────────────────────────────────────────────
|
# ── video ────────────────────────────────────────────────────────────────────
|
||||||
def test_video():
|
def test_video():
|
||||||
print("video events:")
|
print("video events:")
|
||||||
@@ -115,6 +136,7 @@ def test_video():
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
test_audio()
|
test_audio()
|
||||||
|
test_crossfade_phase()
|
||||||
test_video()
|
test_video()
|
||||||
print()
|
print()
|
||||||
if _fails:
|
if _fails:
|
||||||
|
|||||||
Reference in New Issue
Block a user