Skip to content

Commit ec5c98b

Browse files
authored
Merge pull request #534 from sensein/feat/canary-chunking-standalone
feat(canary): split long audio at pauses to avoid the ~40s truncation
2 parents bd6b60d + 198ef70 commit ec5c98b

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)