|
| 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