driftcheck-audio 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
driftcheck/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """driftcheck: do timed labels still line up with their audio?"""
2
+ from ._version import __version__
3
+ from .activity import DriftcheckError
4
+ from .checks import FileResult, Finding, check_file
5
+ from .labels import Segment, read_labels
6
+ from .scan import Report, run
7
+
8
+ __all__ = ["__version__", "DriftcheckError", "FileResult", "Finding", "check_file", "Segment", "read_labels", "Report", "run"]
driftcheck/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
driftcheck/activity.py ADDED
@@ -0,0 +1,129 @@
1
+ """Where is there sound? A plain energy-based activity detector (no model, no training).
2
+
3
+ Audio is cut into 10 ms frames, the energy of each frame is measured in the speech band (100 Hz to 4 kHz), and a
4
+ frame counts as "active" when it is clearly louder than the quiet parts of the same file. Short gaps are filled
5
+ and tiny blips are removed. This is deliberately simple. It works when speech stands out from the background;
6
+ it does not work on music or on constant loud noise, and it says so (``Activity.reliable``).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import soundfile as sf
15
+ from scipy import signal
16
+
17
+ FRAME_S = 0.01 # 10 ms frames
18
+ CONTRAST_DB = 12.0 # needs at least this much difference between loud and quiet parts to trust the result
19
+ THRESHOLD_FRACTION = 0.3 # a frame is active above floor + 30% of the way up to the loud level
20
+ FILL_GAP_S = 0.25 # silence shorter than this inside speech is filled in
21
+ MIN_RUN_S = 0.10 # active runs shorter than this are dropped
22
+
23
+
24
+ class DriftcheckError(ValueError):
25
+ """A problem the user can fix (bad input or option). The CLI shows it without a traceback."""
26
+
27
+
28
+ @dataclass
29
+ class Envelope:
30
+ db: np.ndarray # energy per frame in dB (relative, not calibrated)
31
+ hop_s: float # real length of one frame in seconds (sr and frame size rarely divide exactly)
32
+ duration_s: float
33
+
34
+
35
+ @dataclass
36
+ class Activity:
37
+ active: np.ndarray # bool per frame
38
+ hop_s: float
39
+ duration_s: float
40
+ reliable: bool
41
+ floor_db: float
42
+ top_db: float
43
+ reason: str = ""
44
+
45
+ @property
46
+ def n(self) -> int:
47
+ return len(self.active)
48
+
49
+
50
+ def _sos(sr: int):
51
+ hi = min(4000.0, 0.45 * sr)
52
+ lo = min(100.0, 0.5 * hi)
53
+ return signal.butter(4, [lo, hi], "band", fs=sr, output="sos")
54
+
55
+
56
+ def envelope_from_array(x: np.ndarray, sr: int) -> Envelope:
57
+ """x: mono float signal."""
58
+ hop = max(int(round(sr * FRAME_S)), 1)
59
+ nf = len(x) // hop
60
+ if nf < 1:
61
+ return Envelope(np.zeros(0), hop / sr, len(x) / sr)
62
+ y = signal.sosfilt(_sos(sr), np.asarray(x, dtype=np.float64)[: nf * hop])
63
+ e = (y.reshape(nf, hop) ** 2).mean(axis=1)
64
+ return Envelope(10.0 * np.log10(np.maximum(e, 1e-14)), hop / sr, len(x) / sr)
65
+
66
+
67
+ def envelope_from_file(path: Path, block_s: float = 60.0) -> Envelope:
68
+ """Read a file block by block (any length, constant memory) and measure its envelope."""
69
+ try:
70
+ f = sf.SoundFile(str(path))
71
+ except Exception as e:
72
+ raise DriftcheckError(f"cannot read {Path(path).name}: {e}") from None
73
+ with f:
74
+ sr = f.samplerate
75
+ total = f.frames
76
+ hop = max(int(round(sr * FRAME_S)), 1)
77
+ block = hop * max(int(block_s * sr / hop), 1)
78
+ sos = _sos(sr)
79
+ zi = np.zeros((sos.shape[0], 2))
80
+ parts, carry = [], np.zeros(0)
81
+ while True:
82
+ a = f.read(block, dtype="float32", always_2d=True)
83
+ if len(a) == 0:
84
+ break
85
+ x = a.mean(axis=1).astype(np.float64)
86
+ y, zi = signal.sosfilt(sos, x, zi=zi)
87
+ y = np.concatenate([carry, y])
88
+ nf = len(y) // hop
89
+ if nf:
90
+ parts.append((y[: nf * hop].reshape(nf, hop) ** 2).mean(axis=1))
91
+ carry = y[nf * hop:]
92
+ e = np.concatenate(parts) if parts else np.zeros(0)
93
+ return Envelope(10.0 * np.log10(np.maximum(e, 1e-14)), hop / sr, total / sr)
94
+
95
+
96
+ def _runs(b: np.ndarray):
97
+ """(start, end) index pairs of the True runs in a bool array (end exclusive)."""
98
+ if len(b) == 0:
99
+ return []
100
+ d = np.diff(np.concatenate([[0], b.astype(np.int8), [0]]))
101
+ return list(zip(np.flatnonzero(d == 1), np.flatnonzero(d == -1)))
102
+
103
+
104
+ def clean_runs(active: np.ndarray, hop_s: float, fill_gap_s: float = FILL_GAP_S, min_run_s: float = MIN_RUN_S) -> np.ndarray:
105
+ """Fill short gaps between active runs, then drop short active runs."""
106
+ out = active.copy()
107
+ gap = int(round(fill_gap_s / hop_s))
108
+ for s, e in _runs(~active):
109
+ if s > 0 and e < len(active) and e - s <= gap: # a gap with sound on both sides
110
+ out[s:e] = True
111
+ mn = int(round(min_run_s / hop_s))
112
+ for s, e in _runs(out):
113
+ if e - s < mn:
114
+ out[s:e] = False
115
+ return out
116
+
117
+
118
+ def detect_activity(env: Envelope) -> Activity:
119
+ n = len(env.db)
120
+ if n < 20:
121
+ return Activity(np.zeros(n, bool), env.hop_s, env.duration_s, False, 0.0, 0.0, "audio is too short")
122
+ floor = float(np.percentile(env.db, 10))
123
+ top = float(np.percentile(env.db, 95))
124
+ if top - floor < CONTRAST_DB:
125
+ return Activity(np.zeros(n, bool), env.hop_s, env.duration_s, False, floor, top,
126
+ "no clear difference between loud and quiet parts (silent file, constant noise, or sound all the way through)")
127
+ thr = floor + THRESHOLD_FRACTION * (top - floor)
128
+ act = clean_runs(env.db > thr, env.hop_s)
129
+ return Activity(act, env.hop_s, env.duration_s, True, floor, top)
driftcheck/align.py ADDED
@@ -0,0 +1,246 @@
1
+ """How far are the labels from the audio, and does the gap change over time?
2
+
3
+ Two on/off signals are compared, both at 10 ms steps:
4
+ * the label signal: ON where some label says there is speech,
5
+ * the audio signal: ON where the audio has sound (see activity.py).
6
+ We slide one against the other and see where they agree best. Agreement is counted +1 when both are ON or both
7
+ are OFF, -1 when they differ, and 0 where one of them has no information (shifted off the end).
8
+
9
+ Sign convention (used everywhere): **offset = the number of seconds to ADD to the label times to match the
10
+ audio.** A positive offset means the labels are early; the sound happens later than the labels say.
11
+
12
+ For long files this is done in windows (default 60 s each), giving an offset over time. A straight line through
13
+ those points is a steady drift (for example a subtitle file made for a different frame rate); a sudden step is a
14
+ jump (for example a cut or an inserted ad).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ import numpy as np
21
+ from scipy import signal, stats
22
+
23
+ from .activity import Activity
24
+ from .labels import Segment
25
+
26
+ MIN_SCORE = 0.3 # agreement needed at the best position to trust it
27
+ NULL_MARGIN = 0.1 # ...and it must beat the best score that unrelated made-up labels reach by this much
28
+ N_NULL = 60 # number of made-up unrelated label layouts tried for a whole file
29
+ N_NULL_WINDOW = 30 # ...and for each window of a long file
30
+ AMBIGUOUS_RATIO = 0.85 # another peak this close to the best one (far away from it) makes the answer ambiguous
31
+ EXCLUDE_S = 0.5 # "far away" for the rule above
32
+ MIN_WINDOWS = 4 # fewer good windows than this: no drift is reported
33
+
34
+
35
+ def label_indicator(segments: list[Segment], n: int, hop_s: float) -> np.ndarray:
36
+ """Bool per audio frame: True where a label covers the frame's centre."""
37
+ out = np.zeros(n, bool)
38
+ for s in segments:
39
+ if s.end <= s.start:
40
+ continue
41
+ i0 = int(np.ceil(s.start / hop_s - 0.5))
42
+ i1 = int(np.ceil(s.end / hop_s - 0.5))
43
+ i0, i1 = max(i0, 0), min(i1, n)
44
+ if i1 > i0:
45
+ out[i0:i1] = True
46
+ return out
47
+
48
+
49
+ def _pm(b: np.ndarray) -> np.ndarray:
50
+ return np.where(b, 1.0, -1.0)
51
+
52
+
53
+ def lag_scores(xw: np.ndarray, y: np.ndarray, w0: int, max_lag: int, center: int = 0) -> np.ndarray:
54
+ """Agreement between the label window `xw` (+-1, starting at audio frame w0) and the audio signal y (+-1)
55
+ shifted by each lag in -max_lag..max_lag.
56
+
57
+ Lag L means: label frame t is compared with audio frame t+L. Audio outside the file counts as 0 (no
58
+ information). Result has length 2*max_lag+1, divided by the window size. With `center`, the lags examined are
59
+ center-max_lag..center+max_lag instead.
60
+ """
61
+ n = len(y)
62
+ w1 = w0 + len(xw)
63
+ lo, hi = w0 + center - max_lag, w1 + center + max_lag
64
+ seg = np.zeros(hi - lo)
65
+ a, b = max(lo, 0), min(hi, n)
66
+ seg[a - lo:b - lo] = y[a:b]
67
+ return signal.fftconvolve(seg, xw[::-1], mode="valid") / max(len(xw), 1)
68
+
69
+
70
+ def _runs(b: np.ndarray):
71
+ """(start, end) pairs of the True runs in a bool array (end exclusive)."""
72
+ d = np.diff(np.concatenate([[0], b.astype(np.int8), [0]]))
73
+ return list(zip(np.flatnonzero(d == 1), np.flatnonzero(d == -1)))
74
+
75
+
76
+ def null_layouts(lab: np.ndarray, k: int, rng) -> list[np.ndarray]:
77
+ """`k` made-up label layouts with the same ingredients as the real one but no link to the audio.
78
+
79
+ The lengths of the labelled stretches and the lengths of the gaps between them are each shuffled, and the
80
+ result is moved to a random place. These show how well UNRELATED labels of this kind can agree with this audio
81
+ by coincidence, which is what a real measurement has to beat.
82
+ """
83
+ n = len(lab)
84
+ runs = _runs(lab)
85
+ if not runs:
86
+ return []
87
+ lens = np.array([e - s for s, e in runs])
88
+ gaps = np.array([runs[0][0]] + [runs[i + 1][0] - runs[i][1] for i in range(len(runs) - 1)])
89
+ out = []
90
+ for _ in range(k):
91
+ L, G = rng.permutation(lens), rng.permutation(gaps)
92
+ z = np.zeros(n, bool)
93
+ t = 0
94
+ for g, ln in zip(G, L):
95
+ t += int(g)
96
+ z[t:min(t + int(ln), n)] = True
97
+ t += int(ln)
98
+ if t >= n:
99
+ break
100
+ out.append(np.roll(z, int(rng.randint(0, n))))
101
+ return out
102
+
103
+
104
+ @dataclass
105
+ class Peak:
106
+ offset_s: float | None
107
+ score: float
108
+ confident: bool
109
+ reason: str = ""
110
+
111
+
112
+ def pick_peak(scores: np.ndarray, max_lag: int, hop_s: float, x_transitions: int, y_transitions: int,
113
+ null_max: float | None = None, center: int = 0) -> Peak:
114
+ """Choose the best lag and decide whether to trust it."""
115
+ k = int(np.argmax(scores))
116
+ best = float(scores[k])
117
+ delta = 0.0
118
+ if 0 < k < len(scores) - 1:
119
+ a, b, c = scores[k - 1], scores[k], scores[k + 1]
120
+ den = a - 2 * b + c
121
+ if den < -1e-12:
122
+ delta = float(np.clip(0.5 * (a - c) / den, -0.5, 0.5))
123
+ offset = (k - max_lag + delta + center) * hop_s
124
+ if x_transitions < 1:
125
+ return Peak(None, best, False, "the labels cover all of this stretch or none of it, so there is nothing to line up")
126
+ if y_transitions < 1:
127
+ return Peak(None, best, False, "the audio has no change between sound and silence here")
128
+ if best < MIN_SCORE or (null_max is not None and best < null_max + NULL_MARGIN):
129
+ return Peak(offset, best, False, "labels and audio do not agree better than unrelated labels would (wrong labels for this audio?)")
130
+ ex = max(int(round(EXCLUDE_S / hop_s)), 1)
131
+ pk, _ = signal.find_peaks(scores)
132
+ for j in pk:
133
+ if abs(int(j) - k) > ex and scores[j] >= AMBIGUOUS_RATIO * best:
134
+ return Peak(offset, best, False, "more than one position fits equally well (repetitive pattern)")
135
+ return Peak(offset, best, True)
136
+
137
+
138
+ def _transitions(b: np.ndarray) -> int:
139
+ return int(np.count_nonzero(np.diff(b.astype(np.int8))))
140
+
141
+
142
+ def _peak_for(x: np.ndarray, y: np.ndarray, lab: np.ndarray, act_bits: np.ndarray, w0: int, w1: int,
143
+ max_lag: int, hop_s: float, nulls: list[np.ndarray], center: int = 0) -> Peak:
144
+ M = int(max(min(max_lag, (w1 - w0 - 8) // 4), 1))
145
+ xw = x[w0:w1]
146
+ sc = lag_scores(xw, y, w0, M, center)
147
+ nm = None
148
+ if nulls:
149
+ nm = max(float(lag_scores(_pm(z[w0:w1]), y, w0, M, center).max()) for z in nulls)
150
+ return pick_peak(sc, M, hop_s, _transitions(lab[w0:w1]), _transitions(act_bits[w0:w1]), nm, center)
151
+
152
+
153
+ def estimate_offset(act: Activity, segments: list[Segment], max_offset_s: float = 5.0, seed: int = 0) -> Peak:
154
+ """One offset for the whole file."""
155
+ if not act.reliable:
156
+ return Peak(None, 0.0, False, act.reason)
157
+ n = act.n
158
+ lab = label_indicator(segments, n, act.hop_s)
159
+ M = max(int(round(max_offset_s / act.hop_s)), 1)
160
+ return _peak_for(_pm(lab), _pm(act.active), lab, act.active, 0, n, M, act.hop_s,
161
+ null_layouts(lab, N_NULL, np.random.RandomState(seed)))
162
+
163
+
164
+ @dataclass
165
+ class WindowOffset:
166
+ start_s: float
167
+ end_s: float
168
+ offset_s: float | None
169
+ score: float
170
+ confident: bool
171
+ reason: str = ""
172
+
173
+
174
+ @dataclass
175
+ class Drift:
176
+ windows: list[WindowOffset]
177
+ n_good: int = 0
178
+ slope_s_per_min: float | None = None
179
+ offset_start_s: float | None = None
180
+ offset_end_s: float | None = None
181
+ offset_median_s: float | None = None
182
+ scatter_s: float | None = None
183
+ jumps: list[dict] = field(default_factory=list)
184
+
185
+
186
+ def estimate_drift(act: Activity, segments: list[Segment], window_s: float = 60.0, max_offset_s: float = 5.0,
187
+ jump_s: float = 0.3, seed: int = 0) -> Drift:
188
+ """Offset in consecutive windows of `window_s`, then a straight line through them and a search for jumps."""
189
+ if not act.reliable:
190
+ return Drift(windows=[])
191
+ n = act.n
192
+ hop = act.hop_s
193
+ lab = label_indicator(segments, n, hop)
194
+ x, y = _pm(lab), _pm(act.active)
195
+ M = max(int(round(max_offset_s / hop)), 1)
196
+ w = max(int(round(window_s / hop)), 10)
197
+ nulls = null_layouts(lab, N_NULL_WINDOW, np.random.RandomState(seed))
198
+ wins: list[WindowOffset] = []
199
+ seen: list[tuple[float, float]] = [] # (window centre time, offset) of the good windows so far
200
+ for w0 in range(0, n - w // 2, w):
201
+ w1 = min(w0 + w, n)
202
+ if w1 - w0 < w // 2:
203
+ break
204
+ # Each window searches around where the previous good windows say the offset should be now, so a steady
205
+ # drift (which can add up to many seconds) is followed instead of being lost beyond the search range.
206
+ tc = 0.5 * (w0 + w1) * hop
207
+ centre = 0.0
208
+ if seen:
209
+ centre = seen[-1][1]
210
+ if len(seen) >= 2:
211
+ (t1, o1), (t2, o2) = seen[-2], seen[-1]
212
+ centre = o2 + (o2 - o1) / (t2 - t1) * (tc - t2)
213
+ pk = _peak_for(x, y, lab, act.active, w0, w1, M, hop, nulls, int(round(centre / hop)))
214
+ if pk.confident:
215
+ seen.append((tc, pk.offset_s))
216
+ wins.append(WindowOffset(round(w0 * hop, 3), round(w1 * hop, 3), None if pk.offset_s is None else round(pk.offset_s, 4),
217
+ round(pk.score, 3), pk.confident, pk.reason))
218
+ d = Drift(windows=wins)
219
+ good = [(0.5 * (v.start_s + v.end_s), v.offset_s) for v in wins if v.confident]
220
+ d.n_good = len(good)
221
+ if good:
222
+ d.offset_median_s = float(np.median([g[1] for g in good]))
223
+ if len(good) < MIN_WINDOWS:
224
+ return d
225
+ t = np.array([g[0] for g in good])
226
+ o = np.array([g[1] for g in good])
227
+ # jumps first: a step between neighbouring good windows that the steady drift cannot explain
228
+ dt = np.diff(t)
229
+ adj = np.diff(o) / dt # drift between neighbours, seconds per second
230
+ typical = float(np.median(adj))
231
+ steps = np.diff(o) - typical * dt # what is left over at each step
232
+ noise = max(float(1.4826 * np.median(np.abs(steps - np.median(steps)))), 0.02)
233
+ jump_at = np.zeros(len(o), float) # size of the jump just before each good window
234
+ for i, st in enumerate(steps):
235
+ if abs(st) >= jump_s and abs(st) >= 4 * noise:
236
+ d.jumps.append({"at_s": float(0.5 * (t[i] + t[i + 1])), "step_s": float(st)})
237
+ jump_at[i + 1] = st
238
+ o_adj = o - np.cumsum(jump_at) # offsets with the jumps taken out
239
+ slope, icpt, _, _ = stats.theilslopes(o_adj, t)
240
+ res = o_adj - (icpt + slope * t)
241
+ d.scatter_s = float(1.4826 * np.median(np.abs(res - np.median(res))))
242
+ d.slope_s_per_min = float(slope * 60.0)
243
+ d.offset_start_s = float(icpt)
244
+ t_end = min(act.duration_s, max(sg.end for sg in segments)) # the end of the labels, in label time
245
+ d.offset_end_s = float(icpt + slope * t_end + jump_at.sum())
246
+ return d
driftcheck/checks.py ADDED
@@ -0,0 +1,250 @@
1
+ """The per-file checks: put the labels next to the audio and list what does not fit."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ from scipy import ndimage
10
+
11
+ from .activity import Activity, DriftcheckError, detect_activity, envelope_from_file, _runs
12
+ from .align import MIN_WINDOWS, Drift, estimate_drift, estimate_offset, label_indicator
13
+ from .labels import Segment
14
+
15
+ # Judgement defaults, not standards. Change them with --warn / --fail.
16
+ WARN_S = 0.15 # an offset this big is noticeable in subtitles and starts to hurt alignment-based training
17
+ FAIL_S = 0.5
18
+ UNLABELLED_WARN, UNLABELLED_FAIL = 0.10, 0.40 # share of the speech that no label covers
19
+ SILENT_WARN, SILENT_FAIL = 0.10, 0.40 # share of the labelled time that is silent
20
+ PAD_S = 0.15 # labels are given this much slack on each side before saying "speech outside labels"
21
+ MIN_UNLABELLED_S = 0.5 # speech outside the labels shorter than this is ignored
22
+ MIN_SILENT_LABEL_S = 1.0 # shorter labels are not judged for silence
23
+ SILENT_ACTIVE_SHARE = 0.2 # a label with less than this share of sound counts as silent
24
+ RATE_RATIO = 3.0 # characters per second more than this many times the usual (or less than 1/RATE_RATIO^1.33)
25
+ RATE_ABS_MAX = 35.0 # characters (without spaces) per second nobody can speak
26
+
27
+ # Pairs of frame rates that are commonly mixed up: label times written for one, audio/video made at the other.
28
+ FRAME_RATE_RATIOS = {
29
+ "25 vs 23.976 fps": 25 / 23.976, "23.976 vs 25 fps": 23.976 / 25,
30
+ "25 vs 24 fps": 25 / 24, "24 vs 25 fps": 24 / 25,
31
+ "30 vs 29.97 fps": 30 / 29.97, "29.97 vs 30 fps": 29.97 / 30,
32
+ "24 vs 23.976 fps": 24 / 23.976, "23.976 vs 24 fps": 23.976 / 24,
33
+ }
34
+
35
+
36
+ @dataclass
37
+ class Finding:
38
+ level: str # INFO, WARN or FAIL
39
+ code: str
40
+ message: str
41
+ at_s: float | None = None
42
+
43
+
44
+ @dataclass
45
+ class FileResult:
46
+ name: str
47
+ duration_s: float | None
48
+ n_labels: int
49
+ findings: list[Finding] = field(default_factory=list)
50
+ measurable: bool = False
51
+ offset_s: float | None = None # best single number (the middle of the file when it drifts)
52
+ offset_start_s: float | None = None
53
+ offset_end_s: float | None = None
54
+ drift_s_per_min: float | None = None
55
+ score: float | None = None
56
+ windows: list = field(default_factory=list)
57
+ jumps: list = field(default_factory=list)
58
+ unlabelled_speech_share: float | None = None
59
+ silent_label_share: float | None = None
60
+ suggested_scale: float | None = None
61
+
62
+ def verdict(self) -> str:
63
+ lv = {f.level for f in self.findings}
64
+ return "FAIL" if "FAIL" in lv else "WARN" if "WARN" in lv else "PASS"
65
+
66
+
67
+ def _level(x: float, warn: float, fail: float) -> str | None:
68
+ return "FAIL" if x >= fail else "WARN" if x >= warn else None
69
+
70
+
71
+ def _ms(x: float) -> str:
72
+ return f"{x * 1000:+.0f} ms" if abs(x) < 1 else f"{x:+.2f} s"
73
+
74
+
75
+ def _chars(text: str) -> int:
76
+ return len(re.sub(r"\s+", "", text))
77
+
78
+
79
+ def chars_per_second(segs: list[Segment], min_s: float = 1.0) -> list[float]:
80
+ return [_chars(s.text) / s.duration for s in segs if s.text and s.duration >= min_s]
81
+
82
+
83
+ def _structure_checks(segs: list[Segment], duration: float | None, F: list[Finding]):
84
+ bad = [s for s in segs if s.end <= s.start]
85
+ if bad:
86
+ F.append(Finding("FAIL", "bad-length", f"{len(bad)} label(s) end before they start or have zero length "
87
+ f"(first at {bad[0].start:.2f} s)", bad[0].start))
88
+ if any(b.start < a.start - 1e-9 for a, b in zip(segs, segs[1:])):
89
+ F.append(Finding("WARN", "out-of-order", "labels are not in time order"))
90
+ ordered = sorted((s for s in segs if s.end > s.start), key=lambda s: s.start)
91
+ ov = [(a, b) for a, b in zip(ordered, ordered[1:]) if a.end - b.start > 0.5]
92
+ if ov:
93
+ F.append(Finding("WARN", "overlap", f"{len(ov)} label(s) overlap the one before by more than 0.5 s "
94
+ f"(first at {ov[0][1].start:.2f} s)", ov[0][1].start))
95
+ if duration is not None:
96
+ after = [s for s in segs if s.start >= duration]
97
+ if after:
98
+ F.append(Finding("FAIL", "after-end", f"{len(after)} label(s) start after the audio has ended "
99
+ f"({duration:.1f} s); first at {after[0].start:.1f} s", after[0].start))
100
+ else:
101
+ past = [s for s in segs if s.end > duration + 0.5]
102
+ if past:
103
+ F.append(Finding("WARN", "past-end", f"{len(past)} label(s) run past the end of the audio ({duration:.1f} s)",
104
+ past[0].start))
105
+ texts = [bool(s.text.strip()) for s in segs]
106
+ if any(texts) and not all(texts):
107
+ F.append(Finding("WARN", "empty-text", f"{texts.count(False)} label(s) have no text"))
108
+
109
+
110
+ def _rate_checks(segs: list[Segment], ref_rate: float | None, F: list[Finding]):
111
+ rates = [(_chars(s.text) / s.duration, s) for s in segs if s.text and s.duration >= 1.0]
112
+ if not rates:
113
+ return
114
+ ref = ref_rate if ref_rate else (float(np.median([r for r, _ in rates])) if len(rates) >= 8 else None)
115
+ fast = [(r, s) for r, s in rates if r > RATE_ABS_MAX or (ref and r > RATE_RATIO * ref)]
116
+ slow = [(r, s) for r, s in rates if ref and r < ref / RATE_RATIO ** 1.33]
117
+ if fast:
118
+ r, s = max(fast, key=lambda t: t[0])
119
+ F.append(Finding("WARN", "too-fast", f"{len(fast)} label(s) hold more text than anyone speaks in that time "
120
+ f"(fastest: {r:.0f} characters per second at {s.start:.1f} s): the text may belong to a longer stretch", s.start))
121
+ if slow:
122
+ r, s = min(slow, key=lambda t: t[0])
123
+ F.append(Finding("WARN", "too-slow", f"{len(slow)} label(s) hold very little text for their length "
124
+ f"(slowest: {r:.1f} characters per second at {s.start:.1f} s): the label may cover more than the text", s.start))
125
+
126
+
127
+ def _timing(act: Activity, segs: list[Segment], res: FileResult, F: list[Finding], *, warn_s, fail_s, window_s, max_offset_s):
128
+ long_file = act.duration_s >= 2 * window_s
129
+ drift: Drift | None = estimate_drift(act, segs, window_s, max_offset_s) if long_file else None
130
+ if drift is not None and drift.n_good >= MIN_WINDOWS:
131
+ res.windows = [vars(w) for w in drift.windows]
132
+ res.jumps = drift.jumps
133
+ res.score = float(np.median([w.score for w in drift.windows if w.confident]))
134
+ total = drift.offset_end_s - drift.offset_start_s - sum(j["step_s"] for j in drift.jumps)
135
+ steady = abs(total) >= warn_s and abs(total) >= 3 * drift.scatter_s
136
+ res.offset_start_s, res.offset_end_s = drift.offset_start_s, drift.offset_end_s
137
+ res.measurable = True
138
+ if steady:
139
+ res.drift_s_per_min = drift.slope_s_per_min
140
+ res.offset_s = 0.5 * (drift.offset_start_s + drift.offset_end_s)
141
+ ratio = 1.0 + drift.slope_s_per_min / 60.0
142
+ res.suggested_scale = ratio
143
+ worst = max(abs(drift.offset_start_s), abs(drift.offset_end_s))
144
+ lvl = _level(worst, warn_s, fail_s) or "WARN"
145
+ hint = next((k for k, v in FRAME_RATE_RATIOS.items() if abs(ratio - v) < 0.0004), None)
146
+ F.append(Finding(lvl, "drift",
147
+ f"the labels drift against the audio: offset is {_ms(drift.offset_start_s)} at the start and "
148
+ f"{_ms(drift.offset_end_s)} at the end ({drift.slope_s_per_min * 1000:+.0f} ms per minute). "
149
+ f"Multiplying the label times by about {ratio:.5f} would remove the drift"
150
+ + (f"; that is the ratio of {hint}, a common frame-rate mix-up" if hint else "")))
151
+ else:
152
+ res.offset_s = drift.offset_median_s
153
+ res.drift_s_per_min = drift.slope_s_per_min
154
+ lvl = _level(abs(res.offset_s), warn_s, fail_s)
155
+ F.append(Finding(lvl or "INFO", "offset",
156
+ f"labels are {_ms(res.offset_s)} from the audio (add this to the label times to line up)"
157
+ if lvl else f"labels line up with the audio within {1000 * max(abs(res.offset_s), 0.01):.0f} ms "
158
+ f"and do not drift (steady over {drift.n_good} windows)"))
159
+ for j in drift.jumps:
160
+ F.append(Finding("FAIL" if abs(j["step_s"]) >= fail_s else "WARN", "jump",
161
+ f"the offset suddenly changes by {_ms(j['step_s'])} near {j['at_s']:.0f} s "
162
+ f"(a cut, an inserted section, or labels from two different versions)", j["at_s"]))
163
+ return
164
+ # not enough good windows (short file, or windows that could not be measured): one offset for the whole file
165
+ pk = estimate_offset(act, segs, max_offset_s)
166
+ res.score = pk.score
167
+ if drift is not None:
168
+ res.windows = [vars(w) for w in drift.windows]
169
+ if pk.confident:
170
+ res.measurable = True
171
+ res.offset_s = pk.offset_s
172
+ lvl = _level(abs(pk.offset_s), warn_s, fail_s)
173
+ msg = (f"labels are {_ms(pk.offset_s)} from the audio (add this to the label times to line up)" if lvl else
174
+ f"labels line up with the audio within {1000 * max(abs(pk.offset_s), 0.01):.0f} ms")
175
+ if int(act.duration_s // window_s) < MIN_WINDOWS:
176
+ note = f" (drift is not measured on this file: it needs at least {MIN_WINDOWS} windows of {window_s:g} s)"
177
+ else:
178
+ note = " (drift could not be measured: too few good windows)"
179
+ F.append(Finding(lvl or "INFO", "offset", msg + note))
180
+ else:
181
+ F.append(Finding("INFO", "offset-unmeasurable", f"offset could not be measured: {pk.reason}"))
182
+
183
+
184
+ def _coverage(act: Activity, segs: list[Segment], res: FileResult, F: list[Finding], offset_note: bool):
185
+ hop = act.hop_s
186
+ lab = label_indicator(segs, act.n, hop)
187
+ pad = max(int(round(PAD_S / hop)), 1)
188
+ dil = ndimage.binary_dilation(lab, iterations=pad) if lab.any() else lab
189
+ total_active = float(act.active.sum()) * hop
190
+ # speech outside the labels
191
+ runs = [(s, e) for s, e in _runs(act.active & ~dil) if (e - s) * hop >= MIN_UNLABELLED_S]
192
+ un = sum(e - s for s, e in runs) * hop
193
+ if total_active > 0:
194
+ res.unlabelled_speech_share = un / total_active
195
+ lvl = _level(res.unlabelled_speech_share, UNLABELLED_WARN, UNLABELLED_FAIL)
196
+ if lvl:
197
+ s0, e0 = max(runs, key=lambda r: r[1] - r[0])
198
+ F.append(Finding(lvl, "unlabelled-speech",
199
+ f"{100 * res.unlabelled_speech_share:.0f}% of the sound ({un:.0f} s in {len(runs)} stretches) is outside every label; "
200
+ f"longest: {(e0 - s0) * hop:.1f} s at {s0 * hop:.1f} s"
201
+ + ("; this may be a result of the offset above" if offset_note else ""), s0 * hop))
202
+ # labels over silence
203
+ sil, tot = 0.0, 0.0
204
+ first = None
205
+ for sg in segs:
206
+ d = sg.end - sg.start
207
+ if d < MIN_SILENT_LABEL_S:
208
+ continue
209
+ i0, i1 = max(int(round(sg.start / hop)), 0), min(int(round(sg.end / hop)), act.n)
210
+ if i1 - i0 < 10:
211
+ continue
212
+ tot += d
213
+ if act.active[i0:i1].mean() < SILENT_ACTIVE_SHARE:
214
+ sil += d
215
+ first = first if first is not None else sg.start
216
+ if tot > 0:
217
+ res.silent_label_share = sil / tot
218
+ lvl = _level(res.silent_label_share, SILENT_WARN, SILENT_FAIL)
219
+ if lvl:
220
+ F.append(Finding(lvl, "silent-labels",
221
+ f"{100 * res.silent_label_share:.0f}% of the labelled time ({sil:.0f} s) has almost no sound in the audio; "
222
+ f"first at {first:.1f} s"
223
+ + ("; this may be a result of the offset above" if offset_note else ""), first))
224
+
225
+
226
+ def check_file(name: str, audio_path: Path | None, segs: list[Segment], *, warn_s: float = WARN_S, fail_s: float = FAIL_S,
227
+ window_s: float = 60.0, max_offset_s: float = 5.0, ref_rate: float | None = None) -> FileResult:
228
+ """Check one recording's labels against its audio. audio_path=None runs only the checks that need no audio."""
229
+ F: list[Finding] = []
230
+ act = None
231
+ duration = None
232
+ if audio_path is not None:
233
+ env = envelope_from_file(audio_path)
234
+ duration = env.duration_s
235
+ act = detect_activity(env)
236
+ res = FileResult(name, duration, len(segs), F)
237
+ _structure_checks(segs, duration, F)
238
+ _rate_checks(segs, ref_rate, F)
239
+ if act is None:
240
+ return res
241
+ if not act.reliable:
242
+ F.append(Finding("INFO", "audio-unreliable", f"timing was not checked: {act.reason}"))
243
+ return res
244
+ valid = [s for s in segs if s.end > s.start]
245
+ if not valid:
246
+ return res
247
+ _timing(act, valid, res, F, warn_s=warn_s, fail_s=fail_s, window_s=window_s, max_offset_s=max_offset_s)
248
+ off_flag = any(f.code in ("offset", "drift", "jump") and f.level in ("WARN", "FAIL") for f in F)
249
+ _coverage(act, valid, res, F, off_flag)
250
+ return res