Commit 146070b5 by PLN (Algolia)

feat(foundry): loop naming convention — engine/naming.py, derived from the corpus (#13)

Recovered the ParVagues loop naming scheme empirically from 1690 existing loops,
rather than inventing one: NN_<role>[_<character>]. The corpus EDA that grounds it
(reproducible via `naming.analyze_corpus`, parsers-over-copy): 46% carry a 2-digit
NN_ index (the hand-cut-kit signature, vs keyed/tempo packs like 120g/80c), 74%
underscore-joined, only 16% repeat the kit name (the folder already carries it), and
the role tokens cluster on voice/vocals, keys, guitar, bass, brass, synth.

naming.py provides:
- suggest_name(index, role, character=, section=) → NN_role[_section][_character],
  slugged + zero-padded + de-duped against names already taken in the kit.
- stem_of(role) → maps a role/character word back to its stem family, so a name
  stays honest to what was actually separated (never infer role FROM the name —
  the finder knows the stem; the name reflects it).
- lint(name) → flags drift (no index, spaces, uppercase, odd chars).
- analyze_corpus() → re-derives the stats above from the live Samples tree.

Wired as export_take's default namer (replaces the inline f"{i:02d}_{name}"), so the
GUI Forge button and CLI export both land convention-compliant names; the GUI's
per-stem name field still overrides. CLI: python3 -m engine.naming --suggest 3 synth dark
| --lint <name> | --analyze. 6 tests; full foundry suite green.
parent c0fd5d42
......@@ -289,7 +289,8 @@ def export_take(take: Take, kit: str, workspace: Path, catch: Catch, *,
if peak_norm: # B10: off by default
pk = np.max(np.abs(clip)) + 1e-9
clip = clip * (10 ** (-1.0 / 20) / pk)
nm = (names or {}).get(sl.name) or f"{i:02d}_{sl.name}"
from . import naming
nm = (names or {}).get(sl.name) or naming.suggest_name(i, sl.name) # NN_role (#13)
dest = out_dir / f"{nm}.wav"
sf.write(str(dest), clip, EXPORT_SR if sr == EXPORT_SR else sr,
subtype="PCM_24") # B8
......
"""naming — derive + apply the ParVagues loop naming convention (task #13).
Convention recovered empirically from the Samples corpus (1690 loops, 2026-06-23):
NN_<role>[_<character>] e.g. 00_crime_synth0, 03_keys_brass2_MINOR
• NN — a 2-digit index ordering loops within a kit (46% of the corpus carry it;
it's the hand-cut-kit signature, vs keyed/tempo packs like `120g`/`80c`).
• role — what it is, from the STEM not a guess (drums/bass/other/vocals, refined
to the character tokens PLN actually uses: synth, keys, guitar, brass,
voice…). Never infer role from the name — the finder knows the stem.
• character — optional human descriptor (dark, intro, MINOR, explose…); a section
tag (chorus/drop) is a good default when known.
• underscore-joined (74%), lowercase, the kit name NOT repeated (only 16% do — the
folder already carries it).
`suggest_name()` is what the GUI Forge button / export_take fill in; `lint()` flags
names that drift from the convention; `analyze_corpus()` reproduces the stats above
(parsers-over-copy — re-derive, don't trust this comment).
"""
from __future__ import annotations
import re
from collections import Counter
from pathlib import Path
from typing import Optional
# Canonical roles (the four stems) + the finer character tokens seen in the corpus,
# each mapped to its stem so a suggested name stays honest to what was separated.
ROLE_SYNONYMS = {
"drums": "drums", "drum": "drums", "perc": "drums", "beat": "drums", "break": "drums",
"bass": "bass", "sub": "bass", "808": "bass",
"vocals": "vocals", "vocal": "vocals", "voice": "vocals", "vox": "vocals",
"synth": "other", "keys": "other", "key": "other", "piano": "other",
"organ": "other", "guitar": "other", "brass": "other", "pad": "other",
"lead": "other", "chord": "other", "other": "other",
}
STEMS = ("drums", "bass", "other", "vocals")
def slug_token(s: str) -> str:
"""Lowercase, ascii-ish, strip to a single naming token."""
s = re.sub(r"[^A-Za-z0-9]+", "", s.strip().lower())
return s
def stem_of(role: str) -> str:
"""Map a role/character word to its stem family (honest to separation)."""
return ROLE_SYNONYMS.get(slug_token(role), "other")
def suggest_name(index: int, role: str, *, character: Optional[str] = None,
section: Optional[str] = None, taken: Optional[set] = None) -> str:
"""Build a convention-compliant name: NN_<role>[_<section>][_<character>].
`role` is the stem/character word (kept as given if it's already a known token,
else the stem family). De-duplicates against `taken` by bumping a suffix.
"""
parts = [f"{int(index):02d}", slug_token(role) or "loop"]
if section:
parts.append(slug_token(section))
if character:
parts.append(slug_token(character))
name = "_".join(parts)
if taken:
base, k = name, 1
while name in taken:
name = f"{base}_{k}"
k += 1
return name
def lint(name: str) -> list[str]:
"""Flag drift from the convention. Empty list = clean."""
issues = []
stem = name[:-4] if name.lower().endswith(".wav") else name
if not re.match(r"^\d{2}_", stem):
issues.append("no NN_ index prefix")
if " " in stem:
issues.append("contains spaces")
if stem != stem.lower():
issues.append("not lowercase")
if re.search(r"[^a-z0-9_]", stem.lower()):
issues.append("non [a-z0-9_] characters")
return issues
def analyze_corpus(samples_root: str | Path) -> dict:
"""Re-derive the naming stats from the live corpus (depth-1 loops per kit)."""
root = Path(samples_root)
names = []
for kit in root.iterdir():
if not kit.is_dir():
continue
for f in kit.iterdir():
if f.is_file() and f.suffix.lower() == ".wav":
names.append((kit.name, f.stem))
n = len(names) or 1
indexed = sum(1 for _, x in names if re.match(r"^\d{1,2}[_-]", x))
under = sum(1 for _, x in names if "_" in x)
kit_in = sum(1 for k, x in names if k.lower() in x.lower())
toks: Counter = Counter()
for _, x in names:
body = re.sub(r"^\d{1,2}[_-]", "", x)
for t in re.split(r"[_-]", body):
t = re.sub(r"\d+$", "", t.lower())
if len(t) >= 2:
toks[t] += 1
return {
"n_loops": len(names),
"pct_indexed": round(100 * indexed / n, 1),
"pct_underscore": round(100 * under / n, 1),
"pct_repeats_kit": round(100 * kit_in / n, 1),
"top_role_tokens": toks.most_common(20),
}
def _main(argv=None) -> int:
import argparse
import json
import os
ap = argparse.ArgumentParser(description="ParVagues loop naming convention")
ap.add_argument("--analyze", metavar="SAMPLES_ROOT",
default=os.environ.get("FOUNDRY_SAMPLES",
str(Path.home() / "Work/Sound/Samples")))
ap.add_argument("--suggest", nargs="+", metavar="INDEX ROLE [CHAR]",
help="e.g. --suggest 3 synth dark")
ap.add_argument("--lint", metavar="NAME")
a = ap.parse_args(argv)
if a.suggest:
i, role = a.suggest[0], a.suggest[1]
char = a.suggest[2] if len(a.suggest) > 2 else None
print(suggest_name(int(i), role, character=char))
elif a.lint:
iss = lint(a.lint)
print("clean" if not iss else "⚠ " + ", ".join(iss))
else:
print(json.dumps(analyze_corpus(a.analyze), indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(_main())
"""Tests for the loop naming convention (engine/naming.py)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from engine import naming as N
def test_suggest_basic_shape():
assert N.suggest_name(0, "synth") == "00_synth"
assert N.suggest_name(3, "bass", character="dark") == "03_bass_dark"
assert N.suggest_name(12, "keys", section="chorus") == "12_keys_chorus"
assert N.suggest_name(1, "vocals", section="drop", character="wail") == "01_vocals_drop_wail"
def test_suggest_slugs_and_pads():
assert N.suggest_name(5, "Brass Lead!") == "05_brasslead" # slugged, 2-digit
assert N.suggest_name(7, "KEYS") == "07_keys"
def test_suggest_dedupes_against_taken():
taken = {"02_synth"}
assert N.suggest_name(2, "synth", taken=taken) == "02_synth_1"
assert N.suggest_name(2, "synth", taken={"02_synth", "02_synth_1"}) == "02_synth_2"
def test_stem_of_maps_character_to_stem():
assert N.stem_of("voice") == "vocals"
assert N.stem_of("piano") == "other"
assert N.stem_of("808") == "bass"
assert N.stem_of("break") == "drums"
assert N.stem_of("wat") == "other" # unknown → other
def test_lint_flags_drift():
assert N.lint("03_keys_brass2") == []
assert "no NN_ index prefix" in N.lint("crime_synth")
assert "contains spaces" in N.lint("00 synth loop")
assert "not lowercase" in N.lint("00_Synth")
def test_lint_ignores_wav_suffix():
assert N.lint("00_synth.wav") == []
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment