Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/skills/mixing-audio/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ that spread but does not remove it. Scale your threshold to `window_s`, or pass
`window_s` to put every clip on one scale. `aligned_spans` is NOT adapted — there `window_s` is boundary resolution, which
is the caller's to choose.

**An explicit `window_s` a clip cannot hold raises `WindowTooWideForClip`** (0.0.53, issue #43). The bound is `clip_duration / 1.5` — `clip_duration / (1 + MAX_SUPPORT_OVERLAP)` — and **it does not involve `hop_s`**: the analysis grid always appends a tail window one window's length from the clip's end, so a second look exists or not regardless of the hop. (The intuitive `duration - hop_s` is wrong both ways: `window_s=11, hop_s=1` on a 15 s clip satisfies it and still returns `support=None`; `window_s=6, hop_s=12` violates it and measures `support=1.0`.) Above the bound you used to get one whole-clip correlation and a `support=None` that looked exactly like a clip too short to support at any window. The error is importable from `mixing` or `mixing.errors`, subclasses `ValueError`, and carries `clip_index`, `clip_duration_s`, `window_s`, `hop_s` and `max_window_s` — the largest window that still leaves a second look, computed in samples so a retry at exactly that value is guaranteed to measure. This is the explicit path only: `window_s=None` still fits the window to each clip and answers every clip, including one that lands on a single window at the floor.

**Reading `window_s` is necessary and, since 0.0.49, no longer sufficient.** A rule of the
form *"below the default window, treat the value as unmeasured"* was a complete defence
while `support` was an argmax headcount, because the window was the only axis that moved
Expand Down
5 changes: 5 additions & 0 deletions mixing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@
"dub_video_from_srt": "mixing.dubbing",
"text_to_speech": "mixing.dubbing",
"translate_srt": "mixing.dubbing",
# typed errors (pure)
"MixingError": "mixing.errors",
"WindowTooWideForClip": "mixing.errors",
# srt / timeline (pure)
"Cue": "mixing.srt",
"parse_srt": "mixing.srt",
Expand All @@ -120,6 +123,7 @@
"chapters",
"srt",
"egress",
"errors",
"util",
)

Expand Down Expand Up @@ -177,6 +181,7 @@
transcribe,
)
from mixing.dubbing import dub_video_from_srt, text_to_speech, translate_srt # noqa: F401
from mixing.errors import MixingError, WindowTooWideForClip # noqa: F401


def __getattr__(name: str):
Expand Down
3 changes: 3 additions & 0 deletions mixing/audio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
segment_by_speech_music,
)
from .beats import beat_grid, BeatGrid
from ..errors import MixingError, WindowTooWideForClip

__all__ = [
"Audio",
Expand Down Expand Up @@ -93,6 +94,8 @@
"ClipAlignment",
"onset_envelope",
"ALIGNMENT_FEATURES",
"MixingError",
"WindowTooWideForClip",
"Segment",
"find_segments",
"extract_segments",
Expand Down
117 changes: 115 additions & 2 deletions mixing/audio/audio_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from ..util import require_package, AudioTimeUnit, to_seconds, get_path_from_clipboard
from .audio_util import AudioSource, _normalize_audio_source
from ..egress import Output, deliver, is_sink, resolve_output_path
from ..errors import WindowTooWideForClip

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -2127,12 +2128,79 @@ class ClipAlignment:
hop_s: "float | None" = None


def _window_samples(seconds: float, sample_rate: int) -> int:
"""Samples in a window of ``seconds`` — :func:`_window_offsets`' own rounding.

Duplicated deliberately rather than shared: the guard must decide on the SAME
integer the grid will be built from, and a guard that rounds even slightly
differently is a guard that disagrees with the thing it guards.
"""
return max(1, int(round(seconds * sample_rate)))


def _max_supportable_window_s(
clip_duration_s: float,
sample_rate: int,
*,
max_overlap: float = MAX_SUPPORT_OVERLAP,
) -> float:
"""The widest window at which a clip of this length still holds a SECOND look.

Roughly ``clip_duration_s / (1 + max_overlap)``, and it does NOT depend on ``hop_s`` —
which is the whole subtlety. A hop-based bound (``duration - hop``) is wrong in both
directions: at ``window=11, hop=1`` on a 15 s clip it accepts a call that returns
``support=None``, and at ``window=6, hop=12`` it refuses one that measures
``support=1.0``.

The reason the hop drops out is :func:`_window_offsets`. Its regular grid stops at the
last start that fits, and it then APPENDS a tail window starting at
``len(clip) - n_win`` whenever the grid did not already reach the end — so whatever
the hop, the last window begins exactly one window's length from the clip's end.
:func:`_independent_windows` keeps a second window once it has moved on by
``1 - max_overlap`` of a window, so a quorum exists iff
``clip_duration - window >= max_overlap * window``. The hop decides how MANY looks
there are and how finely a boundary is placed, never whether a second independent one
can exist at all.

**Computed in SAMPLES, which is not a detail.** The grid rounds the window to whole
samples before any of this happens, and at the seconds bound that rounding can go the
wrong way by half a sample: on a 10 s clip at 16 kHz the exact ``10 / 1.5`` rounds to
106667 samples where 106666 is the most that fits, so a caller retrying at the
ceiling this function is FOR would land straight back in the exception. Taking the
floor in samples makes the bound admissible by construction wherever one exists,
which is the only thing that makes it worth reporting. The exception is a degenerate
clip of a sample or two, which holds no second look at ANY window: the result is
floored at one sample there and is a lower bound rather than a promise.
"""
clip_samples = int(round(clip_duration_s * sample_rate))
max_window_samples = int(clip_samples / (1.0 + max_overlap))
return max(1, max_window_samples) / sample_rate


def _holds_a_second_look(
clip_duration_s: float,
window_s: float,
sample_rate: int,
*,
max_overlap: float = MAX_SUPPORT_OVERLAP,
) -> bool:
"""Whether this window leaves the clip a second INDEPENDENT look.

:func:`_independent_windows`' own test, on the two windows that always exist: the one
at the start and the one ending at the clip's end.
"""
clip_samples = int(round(clip_duration_s * sample_rate))
window_samples = _window_samples(window_s, sample_rate)
return clip_samples - window_samples >= max_overlap * window_samples


def _clip_window_and_hop(
clip_duration_s: float,
sample_rate: int,
*,
window_s: "float | None",
hop_s: "float | None",
clip_index: "int | None" = None,
default_window_s: float = SPAN_WINDOW_S,
target_windows: int = MIN_WINDOWS_FOR_SUPPORT_TARGET,
min_frames: int = ADAPTIVE_WINDOW_MIN_FRAMES,
Expand Down Expand Up @@ -2173,12 +2241,34 @@ def _clip_window_and_hop(
real alignments read 0.45/0.64/0.73 at 20 s and 0.19/0.16/0.23 at 5 s. A short clip had
no support at all before, so nothing is being reinterpreted; but a caller gating on
support must know that the number arrives on the clip's scale, not the default's.

**An EXPLICIT window too wide for the clip is refused, not honored** (issue #43).
Above :func:`_max_supportable_window_s` the clip cannot hold two INDEPENDENT windows
(:func:`_holds_a_second_look`),
so the vote the caller asked for cannot be held and ``support`` comes back ``None`` —
which reads exactly like a clip too short to support at any window. Rather than
degrade silently, this raises :class:`~mixing.errors.WindowTooWideForClip`, naming the
clip, its duration and the largest window that would still hold a quorum. Only an
explicit window is refused: the ``None`` path above fits the window to the clip and,
at the floor, may legitimately land on a single window, which is the honest answer for
a clip that short and not a caller's mistake.
"""
explicit_window = window_s is not None
if window_s is None:
floor_s = min_frames * envelope_hop / sample_rate
window_s = min(default_window_s, max(floor_s, clip_duration_s / target_windows))
if hop_s is None:
hop_s = window_s * hop_ratio
if explicit_window and not _holds_a_second_look(
clip_duration_s, window_s, sample_rate
):
raise WindowTooWideForClip(
clip_index=clip_index,
clip_duration_s=clip_duration_s,
window_s=window_s,
hop_s=hop_s,
max_window_s=_max_supportable_window_s(clip_duration_s, sample_rate),
)
return float(window_s), float(hop_s)


Expand Down Expand Up @@ -2259,8 +2349,21 @@ def align_clips_to_reference(
the scale its ``support`` is on. A clip of three default windows or
more is measured at :data:`SPAN_WINDOW_S` exactly, so nothing about a long
clip's answer moves. Pass a number to fix the window yourself — an explicit
value is never overridden, and it is how the pre-adaptation answer for a short
clip is reproduced.
value is never overridden.

**An explicit window a clip cannot hold is REFUSED** with
:class:`~mixing.errors.WindowTooWideForClip` (issue #43). The bound is
``clip_duration / (1 + MAX_SUPPORT_OVERLAP)`` and does NOT involve ``hop_s``
(:func:`_max_supportable_window_s`): above it no second INDEPENDENT window
exists, so the vote asked for cannot be held and the result would be one
whole-clip correlation reporting ``support=None`` — the same thing a clip too
short to support at any window reports, with nothing to tell the two apart.
The error names the clip, its duration and the largest window that still
leaves a second look — measurable for any clip long enough to be worth
aligning, though a degenerate clip of a sample or two has no such window at
all. To measure such a clip, pass that window, pass ``window_s=None`` to fit
the window to each clip, or pass ``consensus=False`` to ask for the single
correlation outright.
hop_s: Step between those windows. Ignored when ``consensus`` is False. ``None``
(the default) is :data:`ADAPTIVE_HOP_RATIO` of whatever window is in force —
half of it, the default pair's own ratio — so an adapted grid keeps the
Expand All @@ -2282,6 +2385,15 @@ def align_clips_to_reference(

Returns:
A list of :class:`ClipAlignment`, in input order (minus dropped clips).

Raises:
ValueError: ``feature`` is not one of :data:`ALIGNMENT_FEATURES`, or
``near_tie_ratio`` is negative.
~mixing.errors.WindowTooWideForClip: ``consensus`` is on and an EXPLICIT
``window_s`` is wider than some clip can hold a second independent look at
(issue #43). The error names that clip, its duration and the largest window
that would work; it subclasses ``ValueError``. Never raised on the
``window_s=None`` path, which fits the window to each clip.
"""
if feature not in ALIGNMENT_FEATURES:
raise ValueError(
Expand All @@ -2306,6 +2418,7 @@ def align_clips_to_reference(
sample_rate,
window_s=window_s,
hop_s=hop_s,
clip_index=i,
)
offset_s, coeff, support, margin = _consensus_alignment(
ref,
Expand Down
93 changes: 93 additions & 0 deletions mixing/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Typed errors `mixing` raises — the home for exceptions a caller may want to catch.

Pure stdlib, imported by the lazy facade without pulling any backend, so a consumer
can ``from mixing import WindowTooWideForClip`` in a light module and still catch what
a heavy one raises.

The convention: an error class carries the MEASURED numbers as attributes, not only in
its message, so a caller can act on them (retry at the largest valid window, say) rather
than parse prose.
"""

import math

__all__ = ["MixingError", "WindowTooWideForClip"]

#: Decimal places the remedy in a :class:`WindowTooWideForClip` message is stated to.
#: The number is rounded DOWN to them, so what the message tells a caller to pass is
#: always inside the bound rather than one rounding step outside it.
_ADVICE_DECIMALS = 6


class MixingError(Exception):
"""Base for every error `mixing` raises on its own behalf."""


class WindowTooWideForClip(MixingError, ValueError):
"""An explicit analysis window leaves a clip no second, independent look.

Raised by :func:`mixing.audio.align_clips_to_reference` when the caller passes a
``window_s`` wider than :attr:`max_window_s` — the length above which the clip holds
no second INDEPENDENT window, so the consensus vote has nothing to arbitrate and
``support`` comes back ``None``.

Why an error and not a clamp or a silent single window: the caller asked for a
windowed vote and would otherwise receive a single whole-clip correlation whose
only signal is the ABSENCE of a support number — indistinguishable from a clip
that is genuinely too short to support at any window (issue #43). Clamping would
be worse still: an explicit ``window_s`` is the caller saying what a window means
for their material, and silently measuring at a different one makes ``support``
incomparable across a set for a reason nothing reports.

It subclasses :class:`ValueError` because it is an argument that cannot be honored,
so existing ``except ValueError`` handlers around alignment keep working.

Attributes:
clip_index: Position of the offending clip in the ``clips`` sequence, or
``None`` when the caller did not identify one.
clip_duration_s: The clip's duration in seconds.
window_s: The window that was asked for.
hop_s: The hop in force — the caller's, or the one derived from ``window_s``.
Reported because it is part of the grid that was asked for; it is NOT part
of the bound, which does not depend on it (see
:func:`~mixing.audio.audio_ops._max_supportable_window_s`).
max_window_s: The largest window that still leaves this clip a second,
independent look. The bound is inclusive, so for any clip long enough to be
worth aligning a retry at exactly this window measures — which is what makes
it worth reporting. A degenerate clip of a sample or two has no such window
at all; the value is floored at one sample there and is a lower bound rather
than a promise.
"""

def __init__(
self,
*,
clip_index: "int | None",
clip_duration_s: float,
window_s: float,
hop_s: float,
max_window_s: float,
) -> None:
self.clip_index = clip_index
self.clip_duration_s = float(clip_duration_s)
self.window_s = float(window_s)
self.hop_s = float(hop_s)
self.max_window_s = float(max_window_s)
who = "clip" if clip_index is None else f"clip {clip_index}"
# Rounded DOWN, never to nearest: a ceiling printed even one ULP above the real
# one sends the caller straight back into this exception, which is the one
# remedy the message must not suggest.
advice = math.floor(self.max_window_s * 10**_ADVICE_DECIMALS) / (
10**_ADVICE_DECIMALS
)
super().__init__(
f"window_s={self.window_s:.3f} is wider than {who} "
f"({self.clip_duration_s:.3f} s) allows: no second INDEPENDENT window fits, "
f"so the consensus vote has nothing to arbitrate and support would be None "
f"for a reason nothing reports — pass window_s <= "
f"{advice:.{_ADVICE_DECIMALS}f}, or window_s=None to fit the window to each "
f"clip. "
f"(hop_s={self.hop_s:.3f} does not enter this bound: the analysis grid "
f"always ends with a window one window's length from the clip's end, "
f"whatever the hop.)"
)
Loading
Loading