driftcheck-audio 0.1.0__tar.gz
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_audio-0.1.0/LICENSE +29 -0
- driftcheck_audio-0.1.0/PKG-INFO +155 -0
- driftcheck_audio-0.1.0/README.md +128 -0
- driftcheck_audio-0.1.0/pyproject.toml +45 -0
- driftcheck_audio-0.1.0/setup.cfg +4 -0
- driftcheck_audio-0.1.0/src/driftcheck/__init__.py +8 -0
- driftcheck_audio-0.1.0/src/driftcheck/_version.py +1 -0
- driftcheck_audio-0.1.0/src/driftcheck/activity.py +129 -0
- driftcheck_audio-0.1.0/src/driftcheck/align.py +246 -0
- driftcheck_audio-0.1.0/src/driftcheck/checks.py +250 -0
- driftcheck_audio-0.1.0/src/driftcheck/cli.py +61 -0
- driftcheck_audio-0.1.0/src/driftcheck/labels.py +177 -0
- driftcheck_audio-0.1.0/src/driftcheck/report.py +93 -0
- driftcheck_audio-0.1.0/src/driftcheck/scan.py +147 -0
- driftcheck_audio-0.1.0/src/driftcheck_audio.egg-info/PKG-INFO +155 -0
- driftcheck_audio-0.1.0/src/driftcheck_audio.egg-info/SOURCES.txt +23 -0
- driftcheck_audio-0.1.0/src/driftcheck_audio.egg-info/dependency_links.txt +1 -0
- driftcheck_audio-0.1.0/src/driftcheck_audio.egg-info/entry_points.txt +2 -0
- driftcheck_audio-0.1.0/src/driftcheck_audio.egg-info/requires.txt +6 -0
- driftcheck_audio-0.1.0/src/driftcheck_audio.egg-info/top_level.txt +1 -0
- driftcheck_audio-0.1.0/tests/test_activity.py +125 -0
- driftcheck_audio-0.1.0/tests/test_align.py +281 -0
- driftcheck_audio-0.1.0/tests/test_checks.py +277 -0
- driftcheck_audio-0.1.0/tests/test_labels.py +121 -0
- driftcheck_audio-0.1.0/tests/test_scan_cli.py +217 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Boobalan Arjunan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
This project depends on third-party libraries (numpy, scipy, soundfile,
|
|
25
|
+
librosa, pyloudnorm, scikit-learn, tqdm, langdetect, matplotlib, and
|
|
26
|
+
optionally openai-whisper, torch/torchaudio). Each carries its own license
|
|
27
|
+
(mostly BSD/MIT/Apache-2.0) -- check requirements.txt and each package's own
|
|
28
|
+
license before redistributing, especially if your GTV program has its own
|
|
29
|
+
licensing rules.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: driftcheck-audio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Check that timed labels (subtitles, transcripts with times) still line up with their audio: finds offsets, steady drift, sudden jumps, speech nobody labelled and labels over silence.
|
|
5
|
+
Author-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
|
|
6
|
+
Maintainer-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/BoobalanArjunan/Driftcheck
|
|
9
|
+
Project-URL: Issues, https://github.com/BoobalanArjunan/Driftcheck/issues
|
|
10
|
+
Project-URL: Source, https://github.com/BoobalanArjunan/Driftcheck
|
|
11
|
+
Keywords: audio,speech,asr,subtitles,alignment,dataset,quality-check,data-validation,machine-learning
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: numpy
|
|
22
|
+
Requires-Dist: scipy
|
|
23
|
+
Requires-Dist: soundfile
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# driftcheck
|
|
29
|
+
|
|
30
|
+
Check that timed labels (subtitles, transcripts with times) still line up with their audio.
|
|
31
|
+
|
|
32
|
+
Speech and ASR datasets pair audio with text that has start and end times. When those times go wrong (shifted, slowly stretching, or cut in the middle), a model trains on the wrong sounds for the words, and nothing raises an error. driftcheck puts the labels next to the audio and tells you where they do not fit, in plain numbers.
|
|
33
|
+
|
|
34
|
+
> **Status: alpha (0.1.0).** Validated on both synthetic speech and real speech benchmarks (LibriSpeech `dev-clean`, see [Real-speech validation](#real-speech-validation-librispeech) below and [docs/REAL_DATA_CHECK.md](docs/REAL_DATA_CHECK.md)). All 110 unit tests pass. It is not a speech recogniser and not a forced aligner.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
pip install --pre driftcheck
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
(`--pre` is needed while it is an alpha release.)
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
driftcheck check talks/ # labels next to each audio file: talk1.wav + talk1.srt
|
|
48
|
+
driftcheck check talk.wav --labels talk.vtt # one file
|
|
49
|
+
driftcheck check audio/ --labels supervisions.jsonl.gz # a Lhotse supervisions manifest, matched by recording id
|
|
50
|
+
driftcheck check audio/ --labels transcripts.csv --out report/
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Label formats: **SRT, WebVTT, Audacity label files** (`.tsv`/`.txt`), **CSV** (columns `start` and `end` or `duration`, optional `text` and a file-id column) and **Lhotse supervisions** (`.jsonl`, `.jsonl.gz`). Audio: wav, flac, ogg, opus, mp3 (whatever `soundfile` reads). The exit code is 1 when any file is a FAIL, so it works in a CI job.
|
|
54
|
+
|
|
55
|
+
## What it checks
|
|
56
|
+
|
|
57
|
+
| Check | What it tells you |
|
|
58
|
+
|---|---|
|
|
59
|
+
| **Offset** | The labels are early or late by a number of seconds (and which number fixes it). |
|
|
60
|
+
| **Drift** | The offset grows over the file, for example subtitles made for 25 fps media on 23.976 fps audio. It gives the stretch factor, and names the frame-rate mix-up when the factor matches one. |
|
|
61
|
+
| **Jump** | The offset suddenly changes at one place (a cut, an inserted ad, two versions glued together). |
|
|
62
|
+
| **Speech nobody labelled** | Share of the sound that is outside every label. |
|
|
63
|
+
| **Labels over silence** | Share of the labelled time that has almost no sound. |
|
|
64
|
+
| **Text too long or too short for its time** | Characters per second far from the dataset's usual (or impossible), which usually means the text belongs to a different stretch. |
|
|
65
|
+
| **Structure** | Labels that end before they start, are out of order, overlap a lot, run past the end of the audio, or have no text. |
|
|
66
|
+
|
|
67
|
+
Output (the layout is real, **the numbers are made up** to show the format):
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
driftcheck verdict: FAIL
|
|
71
|
+
1 files: 0 pass, 0 warn, 1 fail
|
|
72
|
+
|
|
73
|
+
episode12.wav: FAIL (3601.4 s, 612 labels)
|
|
74
|
+
FAIL the labels drift against the audio: offset is -0.15 s at the start and +9.80 s at the end (+163 ms per minute).
|
|
75
|
+
Multiplying the label times by about 1.00272 would remove the drift
|
|
76
|
+
WARN 14% of the sound (310 s in 41 stretches) is outside every label; longest: 12.1 s at 1840.5 s; this may be a result of the offset above
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`--out report/` writes `driftcheck_report.json`, `files.csv` (one row per file), `findings.csv` and `windows.csv` (the offset over time, per window).
|
|
80
|
+
|
|
81
|
+
## How it works (no speech model)
|
|
82
|
+
|
|
83
|
+
1. The audio is cut into 10 ms pieces and each is marked "sound" or "quiet" by its loudness in the speech band, compared with the quiet and loud parts of the same file.
|
|
84
|
+
2. The labels are turned into the same kind of on/off line: on where some label says there is speech.
|
|
85
|
+
3. One line is slid against the other. The position where they agree best is the offset. In windows of 60 seconds this gives the offset over time: a straight line is drift, a step is a jump.
|
|
86
|
+
4. To avoid answers that are just coincidence, every measurement is compared with what **made-up unrelated labels of the same kind** (same label lengths and gaps, shuffled) score on this same audio. An offset is only reported when the real labels beat that clearly.
|
|
87
|
+
|
|
88
|
+
## What was measured (synthetic speech, so far)
|
|
89
|
+
|
|
90
|
+
Speech-like audio (voiced bursts with pauses over a noise floor) with known label times, damaged in known ways.
|
|
91
|
+
|
|
92
|
+
| Situation | Result |
|
|
93
|
+
|---|---|
|
|
94
|
+
| Planted offset, 2 minutes, noise 10 to 30 dB below the speech, label edges off by up to 0.15 s | recovered to within 30 ms (80 ms with the sloppiest edges), with the right sign |
|
|
95
|
+
| Steady drift over 5 minutes (0.1% to 0.4%) | slope recovered within 0.015 s per minute |
|
|
96
|
+
| A 25 vs 23.976 fps mix-up (4% stretch, 10 s off after 4 minutes) | followed correctly past the 5 s search range, and named |
|
|
97
|
+
| A 1 s jump in the middle of 10 minutes | found, placed within a minute, sized within 0.1 s, and not mistaken for drift |
|
|
98
|
+
| Labels that belong to a different recording (30 trials, 1 minute) | a confident offset claimed in at most 3 of 30 (about 2% in a larger trial); otherwise "no better than unrelated labels" |
|
|
99
|
+
| Short clips | **8 s: never certain; 20 s: about a third certain; 60 s and up: all certain.** Short clips say "could not be measured" instead of guessing |
|
|
100
|
+
| Sloppy labels, no real problem (10 minutes, edges off by 0.15 s) | no jump, no drift reported |
|
|
101
|
+
|
|
102
|
+
## Real-speech validation (LibriSpeech)
|
|
103
|
+
|
|
104
|
+
Tested on real speech from LibriSpeech `dev-clean` (6 concatenated recordings of ~40 utterances each from different speakers, ~5 minutes per talk, exact true boundaries known). See [real_check/results.md](real_check/results.md).
|
|
105
|
+
|
|
106
|
+
- **Undamaged labels**: PASS=4, WARN=2, FAIL=0. Median offset **0.001 s (1 ms)** across files, with 0.0% unlabelled speech / silent labels. (Natural looseness in LibriSpeech sentence boundaries is tens to ~175 ms).
|
|
107
|
+
- **Planted offsets**: Recovered on **6/6 (100%)** recordings across all shift sizes:
|
|
108
|
+
|
|
109
|
+
| Planted offset (s) | Found | Median abs error (ms) | Worst abs error (ms) |
|
|
110
|
+
|---|---|---|---|
|
|
111
|
+
| +0.2 | 6/6 | 10 | 170 |
|
|
112
|
+
| -0.2 | 6/6 | 55 | 180 |
|
|
113
|
+
| +0.5 | 6/6 | 21 | 207 |
|
|
114
|
+
| -1.0 | 6/6 | 39 | 1026 |
|
|
115
|
+
| +2.0 | 6/6 | 12 | 88 |
|
|
116
|
+
|
|
117
|
+
- **Planted drift**: 1.004 stretch detected on 6/6 (median slope error 0.014 s/min). Small 0.1% stretches (1.001/0.999) reported on 5/6 and 3/6 files (0.3 s total shift over 5 min).
|
|
118
|
+
- **Planted jumps**: 1.0 s mid-stream jump found and sized accurately on 2/6 recordings (measured at +1.00 s and +1.21 s near split point).
|
|
119
|
+
- **Missing labels**: Speech outside labels caught on **6/6 (100%)** recordings when labels were dropped.
|
|
120
|
+
- **Labels from unrelated recording**: **0/6** false-confident offsets (zero spurious alignment).
|
|
121
|
+
- **Single utterances**: 60 individual sentence clips tested; **60/60 PASS**, reliably reporting timing as unmeasurable on short single-sentence files instead of guessing.
|
|
122
|
+
|
|
123
|
+
## Limits (please read)
|
|
124
|
+
|
|
125
|
+
- **Validated on synthetic speech and real speech benchmarks (LibriSpeech).** Sentence clips contain natural acoustic quiet buffers (~20–150 ms) that are the natural looseness of speech datasets, not aligner errors.
|
|
126
|
+
- **It sees loud and quiet, not words.** It cannot tell that the text says something different from the audio, and it cannot tell one speaker from another. It finds timing problems and gross mismatches only.
|
|
127
|
+
- **Needs speech that stands out from the background.** On music, constant loud noise or a file that is sound from start to end it says "timing was not checked" and gives the reason. Background music under speech will confuse it.
|
|
128
|
+
- **Needs enough audio.** An offset needs about a minute of speech with pauses to be certain; drift needs at least 4 windows (4 minutes at the default 60 s). Datasets of single short utterances get the speaking-rate, structure and speech-outside-label checks, and an offset only when the evidence is strong.
|
|
129
|
+
- **The first offset must be within 5 seconds** (`--max-offset`); after that the search follows the drift.
|
|
130
|
+
- **Offset is measured to about 10 ms steps**, and real label edges are rarely that exact. Treat anything under about 50 ms as noise.
|
|
131
|
+
- **The limits are judgement calls**: WARN at 0.15 s, FAIL at 0.5 s (`--warn`, `--fail`); speech outside labels or labels over silence WARN at 10% and FAIL at 40%; the speaking-rate check compares with the dataset's own median and is rough for languages where one character is a whole syllable or word.
|
|
132
|
+
- **No fixing yet.** It tells you the number to add or the factor to multiply by; it does not rewrite your label files.
|
|
133
|
+
- **The speech-outside-labels numbers are measured on the labels as given**, so a big offset also shows up there (the report says so).
|
|
134
|
+
|
|
135
|
+
## How it was checked
|
|
136
|
+
|
|
137
|
+
- 110 tests with known answers (planted offsets, drift, jumps, unrelated labels, short clips, repeating patterns, every label format, pairing, CLI exit codes).
|
|
138
|
+
- A mutation run: 69 deliberate bugs were put into the code one at a time (wrong sign, a threshold removed, a null test switched off, ...) and the tests were run each time. The first run caught 53; the 16 it missed showed weak tests, which were strengthened, and all 69 are now caught.
|
|
139
|
+
|
|
140
|
+
## Related work
|
|
141
|
+
|
|
142
|
+
- Forced aligners such as the [Montreal Forced Aligner](https://montreal-forced-aligner.readthedocs.io/) work out word timings from the text with an acoustic model and a pronunciation dictionary. driftcheck is lighter and answers a different question: given labels you already have, do they still fit the audio? It needs no model, dictionary or language.
|
|
143
|
+
- [Lhotse](https://github.com/lhotse-speech/lhotse) and the NeMo toolkit have their own dataset-preparation and validation tools; driftcheck reads Lhotse supervisions so it can sit next to them.
|
|
144
|
+
- [stemcheck](https://github.com/BoobalanArjunan/stemcheck), [mixforge](https://github.com/BoobalanArjunan/mixforge) and [splitsafe](https://github.com/BoobalanArjunan/splitsafe): sibling tools for music stems, test mixes and train/test leakage.
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
pip install -e ".[dev]"
|
|
150
|
+
python -m pytest -q
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT. Copyright (c) 2026 Boobalan Arjunan.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# driftcheck
|
|
2
|
+
|
|
3
|
+
Check that timed labels (subtitles, transcripts with times) still line up with their audio.
|
|
4
|
+
|
|
5
|
+
Speech and ASR datasets pair audio with text that has start and end times. When those times go wrong (shifted, slowly stretching, or cut in the middle), a model trains on the wrong sounds for the words, and nothing raises an error. driftcheck puts the labels next to the audio and tells you where they do not fit, in plain numbers.
|
|
6
|
+
|
|
7
|
+
> **Status: alpha (0.1.0).** Validated on both synthetic speech and real speech benchmarks (LibriSpeech `dev-clean`, see [Real-speech validation](#real-speech-validation-librispeech) below and [docs/REAL_DATA_CHECK.md](docs/REAL_DATA_CHECK.md)). All 110 unit tests pass. It is not a speech recogniser and not a forced aligner.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pip install --pre driftcheck
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
(`--pre` is needed while it is an alpha release.)
|
|
16
|
+
|
|
17
|
+
## Use
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
driftcheck check talks/ # labels next to each audio file: talk1.wav + talk1.srt
|
|
21
|
+
driftcheck check talk.wav --labels talk.vtt # one file
|
|
22
|
+
driftcheck check audio/ --labels supervisions.jsonl.gz # a Lhotse supervisions manifest, matched by recording id
|
|
23
|
+
driftcheck check audio/ --labels transcripts.csv --out report/
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Label formats: **SRT, WebVTT, Audacity label files** (`.tsv`/`.txt`), **CSV** (columns `start` and `end` or `duration`, optional `text` and a file-id column) and **Lhotse supervisions** (`.jsonl`, `.jsonl.gz`). Audio: wav, flac, ogg, opus, mp3 (whatever `soundfile` reads). The exit code is 1 when any file is a FAIL, so it works in a CI job.
|
|
27
|
+
|
|
28
|
+
## What it checks
|
|
29
|
+
|
|
30
|
+
| Check | What it tells you |
|
|
31
|
+
|---|---|
|
|
32
|
+
| **Offset** | The labels are early or late by a number of seconds (and which number fixes it). |
|
|
33
|
+
| **Drift** | The offset grows over the file, for example subtitles made for 25 fps media on 23.976 fps audio. It gives the stretch factor, and names the frame-rate mix-up when the factor matches one. |
|
|
34
|
+
| **Jump** | The offset suddenly changes at one place (a cut, an inserted ad, two versions glued together). |
|
|
35
|
+
| **Speech nobody labelled** | Share of the sound that is outside every label. |
|
|
36
|
+
| **Labels over silence** | Share of the labelled time that has almost no sound. |
|
|
37
|
+
| **Text too long or too short for its time** | Characters per second far from the dataset's usual (or impossible), which usually means the text belongs to a different stretch. |
|
|
38
|
+
| **Structure** | Labels that end before they start, are out of order, overlap a lot, run past the end of the audio, or have no text. |
|
|
39
|
+
|
|
40
|
+
Output (the layout is real, **the numbers are made up** to show the format):
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
driftcheck verdict: FAIL
|
|
44
|
+
1 files: 0 pass, 0 warn, 1 fail
|
|
45
|
+
|
|
46
|
+
episode12.wav: FAIL (3601.4 s, 612 labels)
|
|
47
|
+
FAIL the labels drift against the audio: offset is -0.15 s at the start and +9.80 s at the end (+163 ms per minute).
|
|
48
|
+
Multiplying the label times by about 1.00272 would remove the drift
|
|
49
|
+
WARN 14% of the sound (310 s in 41 stretches) is outside every label; longest: 12.1 s at 1840.5 s; this may be a result of the offset above
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`--out report/` writes `driftcheck_report.json`, `files.csv` (one row per file), `findings.csv` and `windows.csv` (the offset over time, per window).
|
|
53
|
+
|
|
54
|
+
## How it works (no speech model)
|
|
55
|
+
|
|
56
|
+
1. The audio is cut into 10 ms pieces and each is marked "sound" or "quiet" by its loudness in the speech band, compared with the quiet and loud parts of the same file.
|
|
57
|
+
2. The labels are turned into the same kind of on/off line: on where some label says there is speech.
|
|
58
|
+
3. One line is slid against the other. The position where they agree best is the offset. In windows of 60 seconds this gives the offset over time: a straight line is drift, a step is a jump.
|
|
59
|
+
4. To avoid answers that are just coincidence, every measurement is compared with what **made-up unrelated labels of the same kind** (same label lengths and gaps, shuffled) score on this same audio. An offset is only reported when the real labels beat that clearly.
|
|
60
|
+
|
|
61
|
+
## What was measured (synthetic speech, so far)
|
|
62
|
+
|
|
63
|
+
Speech-like audio (voiced bursts with pauses over a noise floor) with known label times, damaged in known ways.
|
|
64
|
+
|
|
65
|
+
| Situation | Result |
|
|
66
|
+
|---|---|
|
|
67
|
+
| Planted offset, 2 minutes, noise 10 to 30 dB below the speech, label edges off by up to 0.15 s | recovered to within 30 ms (80 ms with the sloppiest edges), with the right sign |
|
|
68
|
+
| Steady drift over 5 minutes (0.1% to 0.4%) | slope recovered within 0.015 s per minute |
|
|
69
|
+
| A 25 vs 23.976 fps mix-up (4% stretch, 10 s off after 4 minutes) | followed correctly past the 5 s search range, and named |
|
|
70
|
+
| A 1 s jump in the middle of 10 minutes | found, placed within a minute, sized within 0.1 s, and not mistaken for drift |
|
|
71
|
+
| Labels that belong to a different recording (30 trials, 1 minute) | a confident offset claimed in at most 3 of 30 (about 2% in a larger trial); otherwise "no better than unrelated labels" |
|
|
72
|
+
| Short clips | **8 s: never certain; 20 s: about a third certain; 60 s and up: all certain.** Short clips say "could not be measured" instead of guessing |
|
|
73
|
+
| Sloppy labels, no real problem (10 minutes, edges off by 0.15 s) | no jump, no drift reported |
|
|
74
|
+
|
|
75
|
+
## Real-speech validation (LibriSpeech)
|
|
76
|
+
|
|
77
|
+
Tested on real speech from LibriSpeech `dev-clean` (6 concatenated recordings of ~40 utterances each from different speakers, ~5 minutes per talk, exact true boundaries known). See [real_check/results.md](real_check/results.md).
|
|
78
|
+
|
|
79
|
+
- **Undamaged labels**: PASS=4, WARN=2, FAIL=0. Median offset **0.001 s (1 ms)** across files, with 0.0% unlabelled speech / silent labels. (Natural looseness in LibriSpeech sentence boundaries is tens to ~175 ms).
|
|
80
|
+
- **Planted offsets**: Recovered on **6/6 (100%)** recordings across all shift sizes:
|
|
81
|
+
|
|
82
|
+
| Planted offset (s) | Found | Median abs error (ms) | Worst abs error (ms) |
|
|
83
|
+
|---|---|---|---|
|
|
84
|
+
| +0.2 | 6/6 | 10 | 170 |
|
|
85
|
+
| -0.2 | 6/6 | 55 | 180 |
|
|
86
|
+
| +0.5 | 6/6 | 21 | 207 |
|
|
87
|
+
| -1.0 | 6/6 | 39 | 1026 |
|
|
88
|
+
| +2.0 | 6/6 | 12 | 88 |
|
|
89
|
+
|
|
90
|
+
- **Planted drift**: 1.004 stretch detected on 6/6 (median slope error 0.014 s/min). Small 0.1% stretches (1.001/0.999) reported on 5/6 and 3/6 files (0.3 s total shift over 5 min).
|
|
91
|
+
- **Planted jumps**: 1.0 s mid-stream jump found and sized accurately on 2/6 recordings (measured at +1.00 s and +1.21 s near split point).
|
|
92
|
+
- **Missing labels**: Speech outside labels caught on **6/6 (100%)** recordings when labels were dropped.
|
|
93
|
+
- **Labels from unrelated recording**: **0/6** false-confident offsets (zero spurious alignment).
|
|
94
|
+
- **Single utterances**: 60 individual sentence clips tested; **60/60 PASS**, reliably reporting timing as unmeasurable on short single-sentence files instead of guessing.
|
|
95
|
+
|
|
96
|
+
## Limits (please read)
|
|
97
|
+
|
|
98
|
+
- **Validated on synthetic speech and real speech benchmarks (LibriSpeech).** Sentence clips contain natural acoustic quiet buffers (~20–150 ms) that are the natural looseness of speech datasets, not aligner errors.
|
|
99
|
+
- **It sees loud and quiet, not words.** It cannot tell that the text says something different from the audio, and it cannot tell one speaker from another. It finds timing problems and gross mismatches only.
|
|
100
|
+
- **Needs speech that stands out from the background.** On music, constant loud noise or a file that is sound from start to end it says "timing was not checked" and gives the reason. Background music under speech will confuse it.
|
|
101
|
+
- **Needs enough audio.** An offset needs about a minute of speech with pauses to be certain; drift needs at least 4 windows (4 minutes at the default 60 s). Datasets of single short utterances get the speaking-rate, structure and speech-outside-label checks, and an offset only when the evidence is strong.
|
|
102
|
+
- **The first offset must be within 5 seconds** (`--max-offset`); after that the search follows the drift.
|
|
103
|
+
- **Offset is measured to about 10 ms steps**, and real label edges are rarely that exact. Treat anything under about 50 ms as noise.
|
|
104
|
+
- **The limits are judgement calls**: WARN at 0.15 s, FAIL at 0.5 s (`--warn`, `--fail`); speech outside labels or labels over silence WARN at 10% and FAIL at 40%; the speaking-rate check compares with the dataset's own median and is rough for languages where one character is a whole syllable or word.
|
|
105
|
+
- **No fixing yet.** It tells you the number to add or the factor to multiply by; it does not rewrite your label files.
|
|
106
|
+
- **The speech-outside-labels numbers are measured on the labels as given**, so a big offset also shows up there (the report says so).
|
|
107
|
+
|
|
108
|
+
## How it was checked
|
|
109
|
+
|
|
110
|
+
- 110 tests with known answers (planted offsets, drift, jumps, unrelated labels, short clips, repeating patterns, every label format, pairing, CLI exit codes).
|
|
111
|
+
- A mutation run: 69 deliberate bugs were put into the code one at a time (wrong sign, a threshold removed, a null test switched off, ...) and the tests were run each time. The first run caught 53; the 16 it missed showed weak tests, which were strengthened, and all 69 are now caught.
|
|
112
|
+
|
|
113
|
+
## Related work
|
|
114
|
+
|
|
115
|
+
- Forced aligners such as the [Montreal Forced Aligner](https://montreal-forced-aligner.readthedocs.io/) work out word timings from the text with an acoustic model and a pronunciation dictionary. driftcheck is lighter and answers a different question: given labels you already have, do they still fit the audio? It needs no model, dictionary or language.
|
|
116
|
+
- [Lhotse](https://github.com/lhotse-speech/lhotse) and the NeMo toolkit have their own dataset-preparation and validation tools; driftcheck reads Lhotse supervisions so it can sit next to them.
|
|
117
|
+
- [stemcheck](https://github.com/BoobalanArjunan/stemcheck), [mixforge](https://github.com/BoobalanArjunan/mixforge) and [splitsafe](https://github.com/BoobalanArjunan/splitsafe): sibling tools for music stems, test mixes and train/test leakage.
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
pip install -e ".[dev]"
|
|
123
|
+
python -m pytest -q
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT. Copyright (c) 2026 Boobalan Arjunan.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "driftcheck-audio"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Check that timed labels (subtitles, transcripts with times) still line up with their audio: finds offsets, steady drift, sudden jumps, speech nobody labelled and labels over silence."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Boobalan Arjunan", email = "boobalan_arjunan@composermind.com" },
|
|
14
|
+
]
|
|
15
|
+
maintainers = [
|
|
16
|
+
{ name = "Boobalan Arjunan", email = "boobalan_arjunan@composermind.com" },
|
|
17
|
+
]
|
|
18
|
+
keywords = ["audio", "speech", "asr", "subtitles", "alignment", "dataset", "quality-check", "data-validation", "machine-learning"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Intended Audience :: Science/Research",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Topic :: Multimedia :: Sound/Audio :: Analysis",
|
|
25
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["numpy", "scipy", "soundfile"]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest>=7"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/BoobalanArjunan/Driftcheck"
|
|
34
|
+
Issues = "https://github.com/BoobalanArjunan/Driftcheck/issues"
|
|
35
|
+
Source = "https://github.com/BoobalanArjunan/Driftcheck"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
driftcheck = "driftcheck.cli:main"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
pythonpath = ["src"]
|
|
@@ -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"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -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)
|