File size: 12,203 Bytes
f960c3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
"""Pre-flight checks for the acoustic-space dataset. Run before every training run.

Every check here exists because something slipped past a weaker one:

  grid        a source missing from one space silently shrinks that space's
              coverage; the manifest still builds and training still runs
  duration    a failed rsync left a file TRUNCATED, not missing. Presence
              checks passed. Only a duration sweep caught it, and preprocessing
              silently skipped the item (71 of 72) while reporting exit 0
  integrity   the same failure again, 2026-08-10, in the place this script did
              not look: an interrupted write left a TARGET holding 0.54s of
              audio under a header still declaring 6.000s. Byte-for-byte it was
              9% present. Every header-based reader called it healthy; only the
              VAE refused it, months of "mysterious VAE skip" later. Bytes are
              the only honest witness, and the BUILT dataset needs checking,
              not just the slices -- the corruption was written by the copy step
  format      mixed sample rate / channel count reaches the VAE as garbage
  level       if one source is much louder than the rest, the model can learn
              loudness as a cue for space instead of learning the space
  depth       how far each render sits from its dry source. Catches a space
              that was rendered near-dry by mistake -- the failure mode that
              would teach contradictory things under one caption

Exit code is non-zero if anything fails, so it can gate a pipeline.

  python verify_dataset.py <sliced_dir> [--expect-seconds 6.0] [--manifest path]
                                        [--dataset-dir <.../audio>]

Pass --dataset-dir to also byte-check the built references/ and targets/. Do it
on every run: the slices can be perfect while the copy of them is not.
"""

import argparse
import subprocess
import sys
import wave
from pathlib import Path

import numpy as np

# Spaces that add an environmental bed rather than pure reverb. Level variation
# across sources is DELIBERATE here (it stops the model memorising one exact
# ambient recording), so depth outliers are not flagged as errors.
AMBIENCE_SPACES = {"outdoor_day_birds", "outdoor_night"}


def probe_duration(p: Path) -> float:
    r = subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration",
         "-of", "default=nw=1:nk=1", str(p)],
        capture_output=True, text=True,
    )
    try:
        return float(r.stdout.strip())
    except ValueError:
        return -1.0


def probe_format(p: Path) -> tuple:
    r = subprocess.run(
        ["ffprobe", "-v", "error", "-select_streams", "a",
         "-show_entries", "stream=sample_rate,channels", "-of", "csv=p=0", str(p)],
        capture_output=True, text=True,
    )
    parts = r.stdout.strip().split(",")
    try:
        return int(parts[0]), int(parts[1])
    except (IndexError, ValueError):
        return (0, 0)


def probe_integrity(p: Path) -> tuple:
    """Compare the frame count a WAV declares against the audio data really there.

    A truncated WAV keeps its original header, so anything reading length from
    metadata -- wave.getnframes(), soundfile.info(), most "duration" fields --
    reports the ORIGINAL length for a file that is mostly gone. ffprobe's
    format=duration is the exception, deriving duration from real size.

    Returns (ok, real_bytes, expected_bytes, real_seconds).
    """
    try:
        with wave.open(str(p), "rb") as w:
            n, ch, sw, sr = w.getnframes(), w.getnchannels(), w.getsampwidth(), w.getframerate()
            real = len(w.readframes(n))
    except Exception:
        return False, 0, 0, 0.0
    expected = n * ch * sw
    bps = ch * sw * sr
    # tolerate a padding byte, not a missing chunk
    return real >= expected - 4, real, expected, (real / bps if bps else 0.0)


def load_mono(p: Path, sr: int = 24000) -> np.ndarray:
    tmp = Path(f"/tmp/_vd_{p.parent.name[:12]}_{p.stem[:16]}.wav")
    subprocess.run(
        ["ffmpeg", "-y", "-v", "error", "-i", str(p), "-ac", "1", "-ar", str(sr), str(tmp)],
        capture_output=True,
    )
    try:
        with wave.open(str(tmp)) as w:
            return np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(float) / 32768.0
    except Exception:
        return np.array([])


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("sliced_dir")
    ap.add_argument("--expect-seconds", type=float, default=6.0)
    ap.add_argument("--tolerance", type=float, default=0.05)
    ap.add_argument("--manifest", default=None)
    ap.add_argument("--dataset-dir", default=None,
                    help="built dataset audio/ dir; byte-checks references/ and targets/ too")
    ap.add_argument("--allow-longer", action="store_true",
                    help="permit renders longer than expected (cathedral tails); still flags SHORT files")
    args = ap.parse_args()

    D = Path(args.sliced_dir)
    if not (D / "dry").is_dir():
        print(f"no dry/ directory under {D}")
        return 2

    spaces = sorted(d.name for d in D.iterdir() if d.is_dir() and d.name != "dry")
    sources = sorted(p.stem for p in (D / "dry").glob("*.wav"))
    fails = []

    print(f"sources: {len(sources)}   spaces: {len(spaces)}")
    print()

    # --- 1. grid completeness -------------------------------------------------
    missing = [(s, sp) for s in sources for sp in spaces if not (D / sp / f"{s}.wav").exists()]
    print(f"[grid]     {'FAIL' if missing else 'ok  '}  {len(sources)*len(spaces)} expected, {len(missing)} missing")
    for s, sp in missing[:10]:
        print(f"             missing: {sp}/{s}.wav")
    if missing:
        fails.append("grid")

    # --- 2. duration ----------------------------------------------------------
    bad_dur = []
    for p in sorted(D.rglob("*.wav")):
        d = probe_duration(p)
        short = d < args.expect_seconds - args.tolerance
        long_ = d > args.expect_seconds + args.tolerance
        if short or (long_ and not args.allow_longer):
            bad_dur.append((p.relative_to(D), d))
    print(f"[duration] {'FAIL' if bad_dur else 'ok  '}  expecting {args.expect_seconds}s"
          f"{' (longer allowed)' if args.allow_longer else ''}, {len(bad_dur)} wrong")
    for rel, d in bad_dur[:10]:
        print(f"             {rel}  {d:.2f}s")
    if bad_dur:
        fails.append("duration")

    # --- 3. byte-level integrity ----------------------------------------------
    # The duration check above would catch truncation, but only where it is
    # pointed, and it was only ever pointed at the slices. The one corruption
    # this project has had was written into the BUILT dataset by the copy step,
    # which nothing verified at all.
    roots = [("sliced", D)]
    if args.dataset_dir:
        ds = Path(args.dataset_dir)
        for sub in ("references", "targets"):
            if (ds / sub).is_dir():
                roots.append((sub, ds / sub))
            else:
                print(f"[integrity] warning: no {sub}/ under {ds}")

    truncated, n_checked = [], 0
    for label, root in roots:
        for p in sorted(root.rglob("*.wav")):
            n_checked += 1
            ok, real, expected, secs = probe_integrity(p)
            if not ok:
                truncated.append((label, p.name, real, expected, secs))
    print(f"[integrity] {'FAIL' if truncated else 'ok  '}  {n_checked} files byte-checked"
          f"{'' if args.dataset_dir else ' (slices only -- pass --dataset-dir)'}"
          f", {len(truncated)} truncated")
    for label, name, real, expected, secs in truncated[:10]:
        pct = 100.0 * real / expected if expected else 0.0
        print(f"             {label}/{name}")
        print(f"               {real:,} of {expected:,} bytes ({pct:.1f}%) = {secs:.2f}s of real audio")
    if truncated:
        fails.append("integrity")

    # --- 4. format ------------------------------------------------------------
    fmts = {}
    for p in sorted(D.rglob("*.wav")):
        fmts.setdefault(probe_format(p), []).append(p.relative_to(D))
    print(f"[format]   {'FAIL' if len(fmts) > 1 else 'ok  '}  {len(fmts)} distinct (sample_rate, channels)")
    for f, ps in fmts.items():
        print(f"             {f}: {len(ps)} files" + (f"  e.g. {ps[0]}" if len(fmts) > 1 else ""))
    if len(fmts) > 1:
        fails.append("format")

    # --- 5. dry source levels -------------------------------------------------
    levels = {}
    for s in sources:
        x = load_mono(D / "dry" / f"{s}.wav")
        if x.size:
            levels[s] = 20 * np.log10(np.sqrt((x ** 2).mean()) + 1e-12)
    if levels:
        lo, hi = min(levels.values()), max(levels.values())
        spread = hi - lo
        # a wide spread is fine when it is transient material (claps): check peaks too
        print(f"[level]    ok    dry RMS spread {spread:.1f} dB  "
              f"({min(levels, key=levels.get)} {lo:.1f} .. {max(levels, key=levels.get)} {hi:.1f})")
        if spread > 15:
            print("             note: >15 dB spread. Fine for sparse transients (claps sit low on RMS,")
            print("             high on peak); worth checking if it is a genuinely quiet recording.")

    # --- 6. processing depth --------------------------------------------------
    print("[depth]    rel_diff = RMS(wet-dry)/RMS(dry) per space, across sources")
    for sp in spaces:
        vals = []
        for s in sources:
            wp, dp = D / sp / f"{s}.wav", D / "dry" / f"{s}.wav"
            if not wp.exists():
                continue
            w, d = load_mono(wp), load_mono(dp)
            if not w.size or not d.size:
                continue
            n = min(len(w), len(d))
            vals.append(float(np.sqrt(((w[:n] - d[:n]) ** 2).mean()) / (np.sqrt((d[:n] ** 2).mean()) + 1e-12)))
        if not vals:
            continue
        med, lo, hi = float(np.median(vals)), min(vals), max(vals)
        note = ""
        if sp in AMBIENCE_SPACES:
            note = "  (ambience bed - level variation is deliberate)"
        elif lo < 0.25 * med:
            note = "  <- OUTLIER: some source rendered near-dry"
            fails.append(f"depth:{sp}")
        print(f"             {sp:24} median {med:.3f}  range {lo:.3f}-{hi:.3f}{note}")

    # --- 7. manifest ----------------------------------------------------------
    # Two manifest shapes reach this script: the PAIRS grid (id / reference_audio
    # / audio / split) and the SLICES report (output / source_id / environment).
    # Reading pair columns off a slices.csv raised KeyError and exited 1 *after*
    # every check had already passed -- a green run reported as a failure.
    if args.manifest:
        import csv
        rows = list(csv.DictReader(open(args.manifest)))
        base = Path(args.manifest).parent
        cols = set(rows[0]) if rows else set()
        print()
        if {"reference_audio", "audio"} <= cols:
            path_cols, id_col = ("reference_audio", "audio"), "id"
        elif "output" in cols:
            path_cols, id_col = ("output",), "output"
        else:
            path_cols, id_col = (), ""
            print(f"[manifest] skip  {len(rows)} rows, unrecognised columns: {sorted(cols)}")
        if path_cols:
            broken = [r.get(id_col, "?") for r in rows for k in path_cols
                      if not (base / r[k]).resolve().exists()]
            n_train = sum(1 for r in rows if r.get("split") == "train")
            print(f"[manifest] {'FAIL' if broken else 'ok  '}  {len(rows)} rows, {n_train} train, "
                  f"{len(broken)} broken paths, {n_train // max(len(spaces),1)} train sources/space")
            for b in broken[:6]:
                print(f"             broken: {b}")
            if broken:
                fails.append("manifest")

    print()
    if fails:
        print(f"FAILED: {', '.join(sorted(set(fails)))}")
        return 1
    print("all checks passed - safe to preprocess")
    return 0


if __name__ == "__main__":
    sys.exit(main())