Skip to content

Commit 198ef70

Browse files
wilke0818claude
andcommitted
feat(canary): split long audio at pauses to avoid the ~40s truncation
Canary-Qwen silently truncates audio past its ~40 s / 1024-token window, so multi-minute recordings lose most of their transcript (validated: mean word recovery vs Whisper falls from ~1.0 to ~0.28). Add a general pause-aware segmentation utility (`segment_audios_at_pauses`, pure torch) that splits over-window audio at silences so no word is cut, and wire Canary-Qwen to chunk (default `greedy`), transcribe per chunk, and regroup transcripts in time order into one ScriptLine per input. Short clips yield a single chunk, so their behavior is unchanged. Also forward the requested model revision to the worker so it loads the pinned snapshot rather than defaulting to "main". This is the Canary long-audio fix from #528, rebased directly onto alpha and decoupled from the offline-loading work in #527 (which it was originally stacked on). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bd6b60d commit 198ef70

5 files changed

Lines changed: 529 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
""".. include:: ./doc.md""" # noqa: D415
22

33
from .preprocessing import * # noqa: F403
4+
from .silence_segmentation import ( # noqa: F401
5+
SegmentStrategy,
6+
pause_aware_boundaries,
7+
segment_audios_at_pauses,
8+
)
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
"""Pause-aware segmentation: split audio into bounded chunks at silences.
2+
3+
A general utility for any task that must feed a model audio no longer than some
4+
window (e.g. ASR backends with a fixed encoder/context limit such as Canary-Qwen
5+
or Granite): it splits audio that exceeds ``max_segment_s`` into ``<=
6+
max_segment_s`` pieces, choosing cut points **at pauses** (minimum short-time RMS
7+
energy) so no word is cut. Pure ``torch`` — no model or external dependency.
8+
9+
Two strategies:
10+
11+
* ``"greedy"`` — from the previous cut, take the *farthest* pause within
12+
``max_segment_s``. Minimizes the number of segments, but pins cuts near the cap
13+
so cut quality is whatever pause happens to sit there.
14+
* ``"dp"`` — optimal segmentation: globally minimize
15+
``sum(cut_badness) + cut_penalty * n_cuts`` subject to every segment
16+
``<= max_segment_s``. Finds the deepest pauses anywhere and trades an extra
17+
segment for a much cleaner cut when it is worth it (``cut_penalty`` is the dial).
18+
19+
Both fall back to a forced minimum-energy cut across any pause-less run longer
20+
than ``max_segment_s``. ``"none"`` disables splitting (single full-length span).
21+
"""
22+
23+
from __future__ import annotations
24+
25+
from typing import List, Literal, Tuple
26+
27+
import torch
28+
29+
from senselab.audio.data_structures import Audio
30+
from senselab.audio.tasks.preprocessing.preprocessing import extract_segments
31+
32+
SegmentStrategy = Literal["none", "greedy", "dp"]
33+
34+
# Documented defaults (override per call):
35+
DEFAULT_MIN_PAUSE_S = 0.20 # minimum silence duration to qualify as a pause candidate
36+
DEFAULT_SILENCE_PERCENTILE = 15.0 # frames below this energy percentile are "quiet" (adaptive)
37+
DEFAULT_CUT_PENALTY = 0.2 # DP per-cut penalty in normalized-badness units [0, 1]
38+
DEFAULT_ENERGY_FRAME_S = 0.02 # short-time RMS frame size used to locate cut points
39+
40+
# Tolerance for the ``<= max_segment_s`` feasibility comparisons. Forced-cut
41+
# positions are built by repeated addition of ``max_segment_s`` (see ``_dp``),
42+
# so a span that is exactly ``max_segment_s`` long can read as e.g.
43+
# ``0.30000000000000004`` and spuriously fail an exact ``> max_segment_s``
44+
# guard — severing the DP chain and collapsing the output to one oversized span.
45+
# 1e-6 s (well below one sample at any real rate, well above float-accumulation
46+
# error over thousands of frames) absorbs the rounding without moving any real
47+
# boundary.
48+
_FEASIBILITY_EPS = 1e-6
49+
50+
51+
def _rms_envelope(audio: Audio, frame_s: float) -> Tuple[torch.Tensor, int, int, int]:
52+
"""Return (per-frame RMS, hop samples, sampling_rate, n_samples)."""
53+
sr = int(audio.sampling_rate)
54+
mono = audio.waveform.mean(dim=0)
55+
n_samples = int(mono.shape[0])
56+
hop = max(1, int(round(frame_s * sr)))
57+
n_frames = n_samples // hop
58+
if n_frames < 1:
59+
return torch.zeros(0), hop, sr, n_samples
60+
rms = mono[: n_frames * hop].reshape(n_frames, hop).pow(2).mean(dim=1).sqrt()
61+
return rms, hop, sr, n_samples
62+
63+
64+
def _normalized(rms: torch.Tensor) -> torch.Tensor:
65+
"""Map RMS to [0, 1] badness via the 95th percentile (speech~1, silence~0)."""
66+
if rms.numel() == 0:
67+
return rms
68+
ref = float(torch.quantile(rms, 0.95).item()) or float(rms.max().item()) or 1.0
69+
return (rms / ref).clamp(0.0, 1.0)
70+
71+
72+
def _frame_time(frame_idx: int, hop: int, sr: int) -> float:
73+
"""Center time (seconds) of a frame."""
74+
return (frame_idx * hop + hop / 2) / sr
75+
76+
77+
def _pause_candidates(
78+
rms: torch.Tensor, hop: int, sr: int, min_pause_s: float, silence_percentile: float
79+
) -> List[Tuple[float, float]]:
80+
"""Return ``(time_s, badness)`` at the deepest frame of each detected pause."""
81+
if rms.numel() == 0:
82+
return []
83+
norm = _normalized(rms)
84+
thr = float(torch.quantile(rms, silence_percentile / 100.0).item())
85+
# Materialize the mask to a Python list once: element-by-element ``bool(quiet[i])``
86+
# on a tensor is very slow over the many frames of a long recording.
87+
quiet = (rms <= thr).tolist()
88+
min_frames = max(1, int(round(min_pause_s * sr / hop)))
89+
out: List[Tuple[float, float]] = []
90+
n = len(quiet)
91+
i = 0
92+
while i < n:
93+
if not quiet[i]:
94+
i += 1
95+
continue
96+
j = i
97+
while j < n and quiet[j]:
98+
j += 1
99+
if (j - i) >= min_frames:
100+
k = i + int(rms[i:j].argmin().item())
101+
out.append((_frame_time(k, hop, sr), float(norm[k].item())))
102+
i = j
103+
return out
104+
105+
106+
def _forced_cut(rms: torch.Tensor, hop: int, sr: int, lo_s: float, hi_s: float) -> Tuple[float, float]:
107+
"""Quietest ``(time_s, badness)`` in ``[lo_s, hi_s]`` — fallback when no pause exists."""
108+
norm = _normalized(rms)
109+
lo_f = max(0, int(lo_s * sr / hop))
110+
hi_f = min(rms.shape[0], max(lo_f + 1, int(hi_s * sr / hop)))
111+
seg = rms[lo_f:hi_f]
112+
if seg.numel() == 0:
113+
return hi_s, 1.0
114+
k = lo_f + int(seg.argmin().item())
115+
return _frame_time(k, hop, sr), float(norm[k].item())
116+
117+
118+
def _spans_from_cuts(cuts: List[float], duration: float) -> List[Tuple[float, float]]:
119+
pts = sorted({0.0, *[c for c in cuts if 0.0 < c < duration], duration})
120+
return [(pts[k], pts[k + 1]) for k in range(len(pts) - 1)]
121+
122+
123+
def _greedy(
124+
rms: torch.Tensor, hop: int, sr: int, duration: float, max_seg: float, cand_times: List[float]
125+
) -> List[float]:
126+
cuts: List[float] = []
127+
prev = 0.0
128+
while duration - prev > max_seg:
129+
feasible = [t for t in cand_times if prev < t <= prev + max_seg]
130+
if feasible:
131+
cut = max(feasible)
132+
else:
133+
cut, _ = _forced_cut(rms, hop, sr, prev + 0.5 * max_seg, prev + max_seg)
134+
cut = min(max(cut, prev + 1.0), prev + max_seg)
135+
cuts.append(cut)
136+
prev = cut
137+
return cuts
138+
139+
140+
def _dp(
141+
rms: torch.Tensor,
142+
hop: int,
143+
sr: int,
144+
duration: float,
145+
max_seg: float,
146+
candidates: List[Tuple[float, float]],
147+
cut_penalty: float,
148+
) -> List[float]:
149+
# Candidate positions: start, pauses, end. Insert forced cuts so no two
150+
# consecutive candidates are farther than max_seg apart (guarantees feasibility).
151+
raw = [(0.0, 0.0)] + [(t, b) for t, b in candidates if 0.0 < t < duration] + [(duration, 0.0)]
152+
raw.sort()
153+
pts: List[Tuple[float, float]] = [raw[0]]
154+
for idx in range(1, len(raw)):
155+
while raw[idx][0] - pts[-1][0] > max_seg + _FEASIBILITY_EPS:
156+
ft, fb = _forced_cut(rms, hop, sr, pts[-1][0] + 0.5 * max_seg, pts[-1][0] + max_seg)
157+
ft = min(max(ft, pts[-1][0] + 1.0), pts[-1][0] + max_seg)
158+
pts.append((ft, fb))
159+
pts.append(raw[idx])
160+
161+
m = len(pts)
162+
inf = float("inf")
163+
cost = [inf] * m
164+
back = [-1] * m
165+
cost[0] = 0.0
166+
for i in range(1, m):
167+
ti, bi = pts[i]
168+
is_end = i == m - 1
169+
for j in range(i - 1, -1, -1):
170+
if ti - pts[j][0] > max_seg + _FEASIBILITY_EPS:
171+
break
172+
add = 0.0 if is_end else (bi + cut_penalty) # endpoint is not a cut
173+
c = cost[j] + add
174+
if c < cost[i]:
175+
cost[i] = c
176+
back[i] = j
177+
178+
cuts: List[float] = []
179+
i = m - 1
180+
while i > 0:
181+
cuts.append(pts[i][0])
182+
i = back[i]
183+
return cuts
184+
185+
186+
def pause_aware_boundaries(
187+
audio: Audio,
188+
max_segment_s: float,
189+
strategy: SegmentStrategy = "dp",
190+
*,
191+
min_pause_s: float = DEFAULT_MIN_PAUSE_S,
192+
silence_percentile: float = DEFAULT_SILENCE_PERCENTILE,
193+
cut_penalty: float = DEFAULT_CUT_PENALTY,
194+
energy_frame_s: float = DEFAULT_ENERGY_FRAME_S,
195+
) -> List[Tuple[float, float]]:
196+
"""Return ``(start, end)`` second-spans tiling ``audio`` with each span <= ``max_segment_s``.
197+
198+
Cuts are placed at pauses (see module docstring). Audio at or under
199+
``max_segment_s`` (or ``strategy="none"``) returns a single full-length span.
200+
"""
201+
if max_segment_s <= 0:
202+
raise ValueError(f"max_segment_s must be strictly positive, got {max_segment_s!r}.")
203+
if min_pause_s <= 0:
204+
raise ValueError(f"min_pause_s must be strictly positive, got {min_pause_s!r}.")
205+
if energy_frame_s <= 0:
206+
raise ValueError(f"energy_frame_s must be strictly positive, got {energy_frame_s!r}.")
207+
if not 0.0 <= silence_percentile <= 100.0:
208+
raise ValueError(f"silence_percentile must be in [0, 100], got {silence_percentile!r}.")
209+
if cut_penalty < 0:
210+
raise ValueError(f"cut_penalty must be non-negative, got {cut_penalty!r}.")
211+
212+
duration = audio.waveform.shape[1] / audio.sampling_rate
213+
if strategy == "none":
214+
return [(0.0, duration)]
215+
if strategy not in ("greedy", "dp"):
216+
raise ValueError(f"Unknown segmentation strategy {strategy!r}; expected 'none', 'greedy', or 'dp'.")
217+
218+
rms, hop, sr, n = _rms_envelope(audio, energy_frame_s)
219+
if duration <= max_segment_s or rms.numel() == 0:
220+
return [(0.0, duration)]
221+
candidates = _pause_candidates(rms, hop, sr, min_pause_s, silence_percentile)
222+
223+
if strategy == "greedy":
224+
cuts = _greedy(rms, hop, sr, duration, max_segment_s, [t for t, _ in candidates])
225+
else:
226+
cuts = _dp(rms, hop, sr, duration, max_segment_s, candidates, cut_penalty)
227+
return _spans_from_cuts(cuts, duration)
228+
229+
230+
def segment_audios_at_pauses(
231+
audios: List[Audio],
232+
max_segment_s: float,
233+
strategy: SegmentStrategy = "dp",
234+
*,
235+
min_pause_s: float = DEFAULT_MIN_PAUSE_S,
236+
silence_percentile: float = DEFAULT_SILENCE_PERCENTILE,
237+
cut_penalty: float = DEFAULT_CUT_PENALTY,
238+
energy_frame_s: float = DEFAULT_ENERGY_FRAME_S,
239+
) -> List[List[Audio]]:
240+
"""Split each input audio at pauses into ``<= max_segment_s`` sub-Audios.
241+
242+
Returns, per input audio, the list of its sub-Audios in time order. An audio
243+
that needs no splitting is returned as a single-element list holding the
244+
original object (no copy).
245+
"""
246+
out: List[List[Audio]] = []
247+
for audio in audios:
248+
spans = pause_aware_boundaries(
249+
audio,
250+
max_segment_s,
251+
strategy,
252+
min_pause_s=min_pause_s,
253+
silence_percentile=silence_percentile,
254+
cut_penalty=cut_penalty,
255+
energy_frame_s=energy_frame_s,
256+
)
257+
out.append([audio] if len(spans) == 1 else extract_segments([(audio, spans)])[0])
258+
return out

0 commit comments

Comments
 (0)