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 +8 -0
- driftcheck/_version.py +1 -0
- driftcheck/activity.py +129 -0
- driftcheck/align.py +246 -0
- driftcheck/checks.py +250 -0
- driftcheck/cli.py +61 -0
- driftcheck/labels.py +177 -0
- driftcheck/report.py +93 -0
- driftcheck/scan.py +147 -0
- driftcheck_audio-0.1.0.dist-info/METADATA +155 -0
- driftcheck_audio-0.1.0.dist-info/RECORD +15 -0
- driftcheck_audio-0.1.0.dist-info/WHEEL +5 -0
- driftcheck_audio-0.1.0.dist-info/entry_points.txt +2 -0
- driftcheck_audio-0.1.0.dist-info/licenses/LICENSE +29 -0
- driftcheck_audio-0.1.0.dist-info/top_level.txt +1 -0
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
|