| 17 |
17 |
|
Datasets land in <dest>/_downloads (archives), <dest>/_raw (extracted), and
|
| 18 |
18 |
|
<dest>/samples (the layout the bench reads). Point the bench at it with
|
| 19 |
19 |
|
AF_BENCH_CORPUS=<dest>/samples.
|
|
20 |
+ |
|
|
21 |
+ |
Every source dataset is wav-only, so the per-format decode section of the bench
|
|
22 |
+ |
gets its non-wav files from a transcode step (needs ffmpeg on PATH) rather than
|
|
23 |
+ |
from a download. See build_formats.
|
| 20 |
24 |
|
"""
|
| 21 |
25 |
|
|
| 22 |
26 |
|
import argparse
|
| 237 |
241 |
|
return counts
|
| 238 |
242 |
|
|
| 239 |
243 |
|
|
|
244 |
+ |
# Encoder settings per format, as ffmpeg output arguments.
|
|
245 |
+ |
#
|
|
246 |
+ |
# Every arm is re-encoded, including wav. Copying the source wav instead would
|
|
247 |
+ |
# leave that one row measuring a different signal shape from the other three,
|
|
248 |
+ |
# since the source packs mix sample rates, bit depths, and channel counts. All
|
|
249 |
+ |
# four are normalized to the same 44.1 kHz stereo PCM so the only thing that
|
|
250 |
+ |
# differs between rows is the container and codec.
|
|
251 |
+ |
FORMAT_ENCODERS = {
|
|
252 |
+ |
"wav": ["-c:a", "pcm_s16le"],
|
|
253 |
+ |
"aiff": ["-c:a", "pcm_s16be"],
|
|
254 |
+ |
"flac": ["-c:a", "flac"],
|
|
255 |
+ |
# Lossy by nature, so its decoded output is not bit-identical to the other
|
|
256 |
+ |
# three. That is inherent to comparing codecs and does not affect the
|
|
257 |
+ |
# timing, which is what this corpus exists to measure.
|
|
258 |
+ |
"mp3": ["-c:a", "libmp3lame", "-b:a", "320k"],
|
|
259 |
+ |
}
|
|
260 |
+ |
|
|
261 |
+ |
|
|
262 |
+ |
def build_formats(samples: Path, count: int) -> dict[str, int]:
|
|
263 |
+ |
"""Transcode a fixed subset of loops into samples/test-suite/formats/<ext>/.
|
|
264 |
+ |
|
|
265 |
+ |
Section 2 of the bench times decode per format, which needs the same audio
|
|
266 |
+ |
in every format. Nothing in the source datasets provides that: every corpus
|
|
267 |
+ |
file, across all three datasets, is wav. Pointing the section at unrelated
|
|
268 |
+ |
per-format files instead would measure content rather than codec.
|
|
269 |
+ |
|
|
270 |
+ |
Sourced from the loops rather than the one-shots because the one-shots run
|
|
271 |
+ |
about 0.2s, short enough that open-and-probe overhead dominates and the
|
|
272 |
+ |
codec difference disappears into it. Loops run about 2.5s.
|
|
273 |
+ |
"""
|
|
274 |
+ |
src_dir = samples / "test-suite" / "genres" / "loops"
|
|
275 |
+ |
sources = sorted(p for p in src_dir.glob("*.wav") if p.is_file())
|
|
276 |
+ |
if not sources:
|
|
277 |
+ |
print(f" no loops under {src_dir}, skipping")
|
|
278 |
+ |
return {}
|
|
279 |
+ |
|
|
280 |
+ |
# Stride rather than head: the flattened names sort by pack, so the first N
|
|
281 |
+ |
# would all come from one drum machine and share its recording character.
|
|
282 |
+ |
if len(sources) > count:
|
|
283 |
+ |
stride = len(sources) / count
|
|
284 |
+ |
sources = [sources[int(i * stride)] for i in range(count)]
|
|
285 |
+ |
|
|
286 |
+ |
out_root = samples / "test-suite" / "formats"
|
|
287 |
+ |
for ext in FORMAT_ENCODERS:
|
|
288 |
+ |
(out_root / ext).mkdir(parents=True, exist_ok=True)
|
|
289 |
+ |
|
|
290 |
+ |
counts = {ext: 0 for ext in FORMAT_ENCODERS}
|
|
291 |
+ |
failed = 0
|
|
292 |
+ |
for src in sources:
|
|
293 |
+ |
outputs = {ext: out_root / ext / f"{src.stem}.{ext}" for ext in FORMAT_ENCODERS}
|
|
294 |
+ |
if all(p.exists() for p in outputs.values()):
|
|
295 |
+ |
for ext in FORMAT_ENCODERS:
|
|
296 |
+ |
counts[ext] += 1
|
|
297 |
+ |
continue
|
|
298 |
+ |
|
|
299 |
+ |
written: list[Path] = []
|
|
300 |
+ |
for ext, enc in FORMAT_ENCODERS.items():
|
|
301 |
+ |
dest = outputs[ext]
|
|
302 |
+ |
cmd = ["ffmpeg", "-v", "error", "-y", "-i", str(src),
|
|
303 |
+ |
"-ac", "2", "-ar", "44100", *enc, str(dest)]
|
|
304 |
+ |
try:
|
|
305 |
+ |
subprocess.run(cmd, check=True)
|
|
306 |
+ |
except subprocess.CalledProcessError:
|
|
307 |
+ |
break
|
|
308 |
+ |
written.append(dest)
|
|
309 |
+ |
else:
|
|
310 |
+ |
for ext in FORMAT_ENCODERS:
|
|
311 |
+ |
counts[ext] += 1
|
|
312 |
+ |
continue
|
|
313 |
+ |
|
|
314 |
+ |
# All-or-nothing. A file present in three formats but not the fourth
|
|
315 |
+ |
# would silently change which audio each row averages over, so the
|
|
316 |
+ |
# per-format means would no longer be comparable.
|
|
317 |
+ |
for p in written:
|
|
318 |
+ |
p.unlink(missing_ok=True)
|
|
319 |
+ |
failed += 1
|
|
320 |
+ |
|
|
321 |
+ |
if failed:
|
|
322 |
+ |
print(f" dropped {failed} source file(s) that would not encode to all formats")
|
|
323 |
+ |
return counts
|
|
324 |
+ |
|
|
325 |
+ |
|
| 240 |
326 |
|
def main() -> None:
|
| 241 |
327 |
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 242 |
328 |
|
ap.add_argument("--dest", type=Path, default=Path("/media/max/T9/af-corpus"))
|
| 243 |
329 |
|
ap.add_argument("--datasets", help="comma-separated; default = the CC-BY defaults")
|
| 244 |
330 |
|
ap.add_argument("--list", action="store_true", help="show the registry and exit")
|
| 245 |
331 |
|
ap.add_argument("--no-build", action="store_true", help="fetch and extract only")
|
|
332 |
+ |
ap.add_argument("--formats-count", type=int, default=120,
|
|
333 |
+ |
help="loops to transcode into each format for the decode bench")
|
| 246 |
334 |
|
args = ap.parse_args()
|
| 247 |
335 |
|
|
| 248 |
336 |
|
if args.list:
|
| 294 |
382 |
|
print(f" {k:<14} {v}")
|
| 295 |
383 |
|
manifest["training_counts"] = counts
|
| 296 |
384 |
|
|
|
385 |
+ |
# Runs off the built layout, not off a dataset, so it is gated on the loops
|
|
386 |
+ |
# existing rather than on which datasets were requested.
|
|
387 |
+ |
if not args.no_build and (samples / "test-suite" / "genres" / "loops").exists():
|
|
388 |
+ |
print("\n=== building format corpus ===")
|
|
389 |
+ |
if shutil.which("ffmpeg") is None:
|
|
390 |
+ |
print(" ffmpeg not found, skipping (section 2 of the bench will report no files)")
|
|
391 |
+ |
else:
|
|
392 |
+ |
fmt_counts = build_formats(samples, args.formats_count)
|
|
393 |
+ |
for k, v in fmt_counts.items():
|
|
394 |
+ |
print(f" {k:<14} {v}")
|
|
395 |
+ |
# Recorded as derived so the license question does not come up
|
|
396 |
+ |
# again: these are transcodes of corpus files, not a new dataset.
|
|
397 |
+ |
manifest["format_counts"] = fmt_counts
|
|
398 |
+ |
manifest["format_corpus"] = "transcoded from test-suite/genres/loops"
|
|
399 |
+ |
|
| 297 |
400 |
|
# Provenance matters here: the corpus mixes licenses, and anything that
|
| 298 |
401 |
|
# feeds a model needs that recorded rather than reconstructed later.
|
| 299 |
402 |
|
(args.dest / "MANIFEST.json").write_text(json.dumps(manifest, indent=2))
|