Skip to main content

max / audiofiles

Record FSL10K's real per-sound licence mix instead of claiming CC-BY 4.0 The registry entry called the whole Freesound Loop Dataset CC-BY 4.0. Measured over its metadata.json, 9,493 sounds carry four licences: 3,230 CC0, 4,827 CC-BY 3.0, 1,215 CC-BY-NC 3.0, 221 Sampling+ 1.0. 1,436 of them cannot feed a derived work that ships, and fsl10k is a default dataset. Nothing shipped wrong. The training layout is built from reverb-drums only, and the bundled .afcl layer that would have carried it was retired on 2026-08-08. corpus.py now measures the mix per fetch and records it in MANIFEST.json under datasets[].per_sound_licenses, so anything that later selects sounds out of this corpus filters per sound rather than trusting one dataset-level label. An unrecognised licence URL counts as cannot-ship and is named in the summary. Two stale claims went with it: the module docstring said only CC-BY datasets run by default, and the training_source comment named an .afcl generator that no longer exists.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-08 20:46 UTC
Signed with PGP, not checked
Commit: 5911c600a89fd44e57b4e78603d5d72d809d68cf
Parent: 64f589e
2 files changed, +128 insertions, -18 deletions
M scripts/corpus.py +116 -15
@@ -7,9 +7,13 @@
7 7 That tree is gitignored and has to be rebuilt per machine. This script fetches
8 8 the source datasets and maps them into that layout.
9 9
10 - Only CC-BY datasets are enabled by default. The large ones are opt-in because
11 - they are tens of gigabytes and, for NSynth, 16 kHz mono, which skews per-file
12 - decode timings away from what a real 44.1/48 kHz library costs.
10 + The large datasets are opt-in because they are tens of gigabytes and, for
11 + NSynth, 16 kHz mono, which skews per-file decode timings away from what a real
12 + 44.1/48 kHz library costs.
13 +
14 + Nothing here is licence-clean as a whole. Two of the five mix per-sound
15 + licences, including CC-BY-NC, so the registry's `license` field describes the
16 + mix rather than claiming one label. See summarize_fsl10k_licenses.
13 17
14 18 ./scripts/corpus.py --list
15 19 ./scripts/corpus.py --dest /media/max/T9/af-corpus
@@ -34,10 +38,18 @@
34 38
35 39 # Dataset registry.
36 40 #
37 - # `default` marks the ones that run without --datasets. The two defaults are
38 - # both CC-BY 4.0, which matters here: docs/ml_classifier.md keeps a trained
39 - # model out of the binary specifically to avoid training-data copyright
40 - # surface, so anything that could feed a shipped model needs a clean license.
41 + # `default` marks the ones that run without --datasets. It is a size-and-time
42 + # judgement and says nothing about licence: fsl10k is a default and a sixth of
43 + # it is CC-BY-NC. The two questions were conflated here until 2026-08-08, when
44 + # the entry still claimed a flat "CC-BY 4.0" for a corpus with four licences in
45 + # it.
46 + #
47 + # Nothing derived from any of this ships today. The bundled `.afcl` layer that
48 + # would have was retired on 2026-08-08 (docs/ml_classifier.md, "What ships in
49 + # the binary, and under what licence"), so every dataset here is measurement
50 + # material only, which the NC clips are fine for. The licence is recorded
51 + # accurately anyway: that position has already flipped once, and reconstructing
52 + # provenance after the fact is what this file exists to avoid.
41 53 DATASETS = {
42 54 "reverb-drums": {
43 55 "default": True,
@@ -54,14 +66,17 @@
54 66 "default": True,
55 67 "size": "8.8 GB",
56 68 "count": "9,455 loops",
57 - "license": "CC-BY 4.0",
69 + "license": "per sound: CC0, CC-BY, CC-BY-NC, Sampling+",
58 70 "url": "https://zenodo.org/api/records/3967852/files/FSL10K.zip/content",
59 71 "archive": "FSL10K.zip",
60 72 "extra": {
61 73 "annotations.zip": "https://zenodo.org/api/records/3967852/files/annotations.zip/content"
62 74 },
63 75 "desc": "Freesound Loop Dataset. The only source here with ground-truth "
64 - "tempo and key, so it is what BPM/key accuracy can be scored against.",
76 + "tempo and key, so it is what BPM/key accuracy can be scored against. "
77 + "Zenodo publishes it as CC-BY; the sounds are not. 1,436 of 9,493 are "
78 + "NC or Sampling+: fine for measurement, not for anything that ships. "
79 + "Select on metadata.json, never on this entry.",
65 80 },
66 81 "nsynth": {
67 82 "default": False,
@@ -95,6 +110,73 @@
95 110 },
96 111 }
97 112
113 + # Freesound licence URLs as they appear in FSL10K's metadata.json, mapped to a
114 + # short name and whether a derived work under that licence could ship.
115 + #
116 + # Sampling+ is counted as cannot-ship rather than argued about: it permits
117 + # sampling into a new work but bars advertising use and verbatim
118 + # redistribution, which is ambiguous for a corpus-derived artifact and worth
119 + # 221 sounds out of 9,493.
120 + FSL10K_LICENSES = {
121 + "http://creativecommons.org/publicdomain/zero/1.0/": ("CC0 1.0", True),
122 + "http://creativecommons.org/licenses/by/3.0/": ("CC-BY 3.0", True),
123 + "http://creativecommons.org/licenses/by-nc/3.0/": ("CC-BY-NC 3.0", False),
124 + "http://creativecommons.org/licenses/sampling+/1.0/": ("Sampling+ 1.0", False),
125 + }
126 +
127 +
128 + def summarize_fsl10k_licenses(raw: Path) -> dict | None:
129 + """Count FSL10K's per-sound licences off its metadata.json.
130 +
131 + The dataset-level label cannot answer the licence question for this corpus
132 + and the registry no longer pretends it can, so the real mix is measured on
133 + every run and recorded in the manifest. Anything that later selects sounds
134 + out of FSL10K reads this, or reads metadata.json itself; the point is that
135 + it does not read a single label off the registry entry.
136 +
137 + An unrecognised licence URL counts as cannot-ship and is named in the
138 + result. Defaulting the other way would let a new Freesound licence into a
139 + shippable set silently, which is the exact failure this function exists to
140 + close.
141 +
142 + Returns None when the metadata is not there -- --no-build runs and partial
143 + extractions are normal, and a missing count is better than a wrong one.
144 + """
145 + meta = raw / "metadata.json"
146 + if not meta.is_file():
147 + return None
148 + try:
149 + sounds = json.loads(meta.read_text())
150 + except (OSError, json.JSONDecodeError) as e:
151 + print(f" warning: cannot read {meta} ({e}); licence mix not recorded")
152 + return None
153 + if not isinstance(sounds, dict):
154 + return None
155 +
156 + counts: dict[str, int] = {}
157 + unrecognised: dict[str, int] = {}
158 + shippable = 0
159 + for sound in sounds.values():
160 + url = sound.get("license") if isinstance(sound, dict) else None
161 + name, ok = FSL10K_LICENSES.get(url, (None, False))
162 + if name is None:
163 + name = str(url)
164 + unrecognised[name] = unrecognised.get(name, 0) + 1
165 + counts[name] = counts.get(name, 0) + 1
166 + shippable += ok
167 +
168 + summary = {
169 + "total": len(sounds),
170 + "by_license": dict(sorted(counts.items())),
171 + "shippable": shippable,
172 + "excluded": len(sounds) - shippable,
173 + "source": "metadata.json, per sound",
174 + }
175 + if unrecognised:
176 + summary["unrecognised"] = dict(sorted(unrecognised.items()))
177 + return summary
178 +
179 +
98 180 # Filename/dirname keyword -> bench training class.
99 181 #
100 182 # Ordered, first match wins. Order is load-bearing: "loop" has to be tested
@@ -371,7 +453,7 @@
371 453 def main() -> None:
372 454 ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
373 455 ap.add_argument("--dest", type=Path, default=Path("/media/max/T9/af-corpus"))
374 - ap.add_argument("--datasets", help="comma-separated; default = the CC-BY defaults")
456 + ap.add_argument("--datasets", help="comma-separated; default = the small ones")
375 457 ap.add_argument("--list", action="store_true", help="show the registry and exit")
376 458 ap.add_argument("--no-build", action="store_true", help="fetch and extract only")
377 459 ap.add_argument("--formats-count", type=int, default=120,
@@ -379,11 +461,11 @@
379 461 args = ap.parse_args()
380 462
381 463 if args.list:
382 - print(f"{'dataset':<14} {'size':>8} {'default':<8} {'license':<38} count")
464 + print(f"{'dataset':<14} {'size':>8} {'default':<8} {'license':<46} count")
383 465 for name, d in DATASETS.items():
384 466 print(
385 467 f"{name:<14} {d['size']:>8} {str(d['default']):<8} "
386 - f"{d['license']:<38} {d['count']}"
468 + f"{d['license']:<46} {d['count']}"
387 469 )
388 470 print(f"{'':>14} {d['desc']}")
389 471 return
@@ -419,7 +501,23 @@
419 501 download(extra_url, extra_path)
420 502 if extra_path.suffix.lower() in (".zip", ".7z", ".gz"):
421 503 extract(extra_path, raw / name, marker=raw / name / Path(extra_name).stem)
422 - fetched.append({"name": name, "license": d["license"], "source": d["url"]})
504 +
505 + entry = {"name": name, "license": d["license"], "source": d["url"]}
506 + # FSL10K is the one dataset here shipping a per-sound licence field, so
507 + # it is the one where the manifest can carry something better than the
508 + # registry's prose. Measured rather than copied: the registry entry is
509 + # what was wrong in the first place.
510 + if name == "fsl10k":
511 + summary = summarize_fsl10k_licenses(raw / name)
512 + if summary is not None:
513 + entry["per_sound_licenses"] = summary
514 + print(
515 + f" licences: {summary['shippable']} shippable, "
516 + f"{summary['excluded']} excluded, of {summary['total']}"
517 + )
518 + for lic, n in summary["by_license"].items():
519 + print(f" {lic:<16} {n}")
520 + fetched.append(entry)
423 521
424 522 manifest["datasets"] = merge_datasets(manifest.get("datasets", []), fetched)
425 523
@@ -430,8 +528,11 @@
430 528 print(f" {k:<14} {v}")
431 529 manifest["training_counts"] = counts
432 530 # Names the dataset the labels came from, which is what anything
433 - # shipping those labels has to credit. The bundled .afcl generator
434 - # reads this key and refuses to build without it.
531 + # shipping those labels would have to credit. Nothing reads it as of
532 + # 2026-08-08: the bundled .afcl generator that required it was retired
533 + # with the layer itself. Kept because it costs a line and the credit is
534 + # unreconstructable once the corpus is rebuilt with other datasets in
535 + # it, which is how it got lost the first time (see load_manifest).
435 536 manifest["training_source"] = TRAINING_DATASET
436 537
437 538 # Runs off the built layout, not off a dataset, so it is gated on the loops
@@ -5,9 +5,18 @@
5 5 //! carry a third, accuracy of the single sample-class label, until that label was
6 6 //! retired; see `docs/ml_classifier.md`.)
7 7 //!
8 - //! Ground truth comes from the Freesound Loop Dataset (CC-BY 4.0), which is the
9 - //! only corpus here carrying expert tempo and key annotations. Fetch it with
10 - //! `scripts/corpus.py --datasets fsl10k`, then point this at the extracted root:
8 + //! Ground truth comes from the Freesound Loop Dataset, which is the only corpus
9 + //! here carrying expert tempo and key annotations. Zenodo publishes FSL10K as
10 + //! CC-BY, but its sounds carry four different Freesound licences and 1,436 of
11 + //! the 9,493 are CC-BY-NC or Sampling+. That is fine for what happens here --
12 + //! this mode decodes audio, scores a number, and emits nothing derived from the
13 + //! samples -- and it is not fine for anything that ships. Anything that selects
14 + //! sounds out of this corpus rather than measuring over it reads the per-sound
15 + //! licence from `metadata.json`; `scripts/corpus.py` records the mix in the
16 + //! corpus MANIFEST.json.
17 + //!
18 + //! Fetch it with `scripts/corpus.py --datasets fsl10k`, then point this at the
19 + //! extracted root:
11 20 //!
12 21 //! AF_BENCH_FSL10K=/media/max/T9/af-corpus/_raw/fsl10k \
13 22 //! cargo run --release -p audiofiles-bench -- accuracy