Commit f11bef94 by PLN (Algolia)

feat(douanier): /loudness, /spectrum (FFT-as-a-service), /naming (#41)

Three more torch-free, cached building blocks → 11 engines total.

- POST /loudness → BS.1770 integrated LUFS (pyloudnorm) + sample/true-peak (4×
  oversample) + crest + the gain to hit each delivery target (-14 streaming, -9
  club, reference_postprod_master). The mastering numbers PLN + hexa gate on.
  engines/loudness.py; pyloudnorm added to requirements-deploy (pure-python, tiny).
- POST /spectrum → FFT-AS-A-SERVICE (PLN's ask): a downsampled, render-ready
  spectrogram bands×frames, 0..1 normalized; mel (perceptual, for visuals) or
  log-linear; frames=1 collapses to a single averaged FFT spectrum. Bounded
  payload so it caches cheaply. signal.spectrum().
- POST /naming → convention-compliant sample name (NN_role_character) from the
  MEASURED role + character, never the file name. naming.py vendored from the
  Foundry (drift-guarded); character_of() derives the adjective from features.

Validated in the torch-free container (LUFS -19.1 w/ gain +5.1/+10.1; mel band
centers; "07_melodic_warm" lint-clean). 58/58 tests (test_extra.py: engines +
endpoints + naming drift guard + character_of). Image now serves emotion/features/
samples/grade/onsets/waveform/analyze/loudness/spectrum/naming + separate-503.
parent 8c2be606
...@@ -59,6 +59,9 @@ Endpoints (internal root paths shown; public = `/audio/v1` + path): ...@@ -59,6 +59,9 @@ Endpoints (internal root paths shown; public = `/audio/v1` + path):
- `POST /onsets` — audio → onset hit times + tempo + onset rate (rhythmic sync / slicing) (scope `onsets`) - `POST /onsets` — audio → onset hit times + tempo + onset rate (rhythmic sync / slicing) (scope `onsets`)
- `POST /waveform` — audio → render-ready `[min,max]` peaks + 0..1 RMS envelope; `?bins=` ≤4000 (scope `waveform`) - `POST /waveform` — audio → render-ready `[min,max]` peaks + 0..1 RMS envelope; `?bins=` ≤4000 (scope `waveform`)
- `POST /analyze` — one call → emotion + features + sample role (fewer round-trips) (scope `analyze`) - `POST /analyze` — one call → emotion + features + sample role (fewer round-trips) (scope `analyze`)
- `POST /loudness` — audio → BS.1770 integrated LUFS + sample/true peak + crest + gain-to-target (-14/-9) (scope `loudness`)
- `POST /spectrum`**FFT-as-a-service**: downsampled spectrogram `bands`×`frames`, 0..1; `?mel=` `?bands=` `?frames=` (`frames=1` = avg spectrum) (scope `spectrum`)
- `POST /naming` — sample → convention-compliant name (NN_role_character) from MEASURED role+character; `?index=` (scope `naming`)
- `POST /separate` — stem separation; **503 `compute_unavailable`** until a GPU runner (scope `separate`) - `POST /separate` — stem separation; **503 `compute_unavailable`** until a GPU runner (scope `separate`)
All three analyze routes are **torch-free** (librosa) and **content-addressed All three analyze routes are **torch-free** (librosa) and **content-addressed
......
...@@ -19,7 +19,9 @@ import auth ...@@ -19,7 +19,9 @@ import auth
import cache import cache
import config import config
from engines import ears, feats, signal from engines import ears, feats, signal
from engines import grade as grade_eng # VENDORED copy of the Foundry grader (see engines/grade.py) from engines import loudness as loudness_eng
from engines import grade as grade_eng # VENDORED copy of the Foundry grader (see engines/grade.py)
from engines import naming as naming_eng # VENDORED copy of the Foundry namer (see engines/naming.py)
app = FastAPI( app = FastAPI(
title="Douanier — the `audio` sub-API of the nech.pl platform", title="Douanier — the `audio` sub-API of the nech.pl platform",
...@@ -106,6 +108,9 @@ def healthz(): ...@@ -106,6 +108,9 @@ def healthz():
"onsets": {"available": ok, "hint": "onset times + tempo (librosa, no torch)"}, "onsets": {"available": ok, "hint": "onset times + tempo (librosa, no torch)"},
"waveform": {"available": ok, "hint": "render-ready peak/RMS envelope (no torch)"}, "waveform": {"available": ok, "hint": "render-ready peak/RMS envelope (no torch)"},
"analyze": {"available": ok, "hint": "composite emotion+features+sample in one call"}, "analyze": {"available": ok, "hint": "composite emotion+features+sample in one call"},
"loudness": {"available": True, "hint": "BS.1770 LUFS + peak/true-peak (pyloudnorm, no torch)"},
"spectrum": {"available": ok, "hint": "FFT-as-a-service: downsampled spectrogram (no torch)"},
"naming": {"available": ok, "hint": "convention-compliant sample namer (no torch)"},
"separate": {"available": False, "hint": "no CPU path on erable — GPU runner pending"}, "separate": {"available": False, "hint": "no CPU path on erable — GPU runner pending"},
}, },
} }
...@@ -267,6 +272,50 @@ async def grade(response: Response, file: UploadFile = File(...), role: str = "" ...@@ -267,6 +272,50 @@ async def grade(response: Response, file: UploadFile = File(...), role: str = ""
return {"filename": file.filename, "content_id": cid, "grade": result} return {"filename": file.filename, "content_id": cid, "grade": result}
@v1.post("/loudness")
async def loudness(response: Response, file: UploadFile = File(...),
_p: auth.Principal = Depends(require_scope("loudness"))):
"""Upload audio → BS.1770 integrated LUFS + sample/true peak + crest + the
gain (dB) to hit each delivery target (-14 streaming, -9 club). Torch-free, cached."""
data = await _read_upload(file)
cid, result = _cached("loudness", {"v": 1}, data, file.filename,
loudness_eng.loudness, response)
return {"filename": file.filename, "content_id": cid, **result}
@v1.post("/spectrum")
async def spectrum(response: Response, file: UploadFile = File(...),
bands: int = 64, frames: int = 200, mel: bool = True,
_p: auth.Principal = Depends(require_scope("spectrum"))):
"""FFT-as-a-service: a downsampled, render-ready spectrogram (`bands`×`frames`,
0..1). `mel=` perceptual vs log-linear; `frames=1` → a single averaged FFT
spectrum. Bounded payload → caches cheaply. Torch-free."""
data = await _read_upload(file)
params = {"bands": bands, "frames": frames, "mel": mel, "v": 1}
cid, result = _cached("spectrum", params, data, file.filename,
lambda p: signal.spectrum(p, bands=bands, frames=frames, mel=mel),
response)
return {"filename": file.filename, "content_id": cid, **result}
@v1.post("/naming")
async def naming(response: Response, file: UploadFile = File(...), index: int = 1,
_p: auth.Principal = Depends(require_scope("naming"))):
"""Upload a sample → a convention-compliant name (NN_role_character) derived
from the MEASURED role + character, not the file name. Torch-free, cached."""
data = await _read_upload(file)
def _compute(p):
prof = feats.sample_profile(p)
ff = feats.features(p, rhythm=False)["features"]
character = feats.character_of(ff, prof)
suggested = naming_eng.suggest_name(index, prof["role"], character=character)
return {"suggested_name": suggested, "role": prof["role"], "character": character,
"stem": naming_eng.stem_of(prof["role"]), "lint": naming_eng.lint(suggested)}
cid, result = _cached("naming", {"index": index, "v": 1}, data, file.filename, _compute, response)
return {"filename": file.filename, "content_id": cid, **result}
# ── #37 separation seam — no CPU path on erable; env-selected GPU backend later ── # ── #37 separation seam — no CPU path on erable; env-selected GPU backend later ──
@v1.post("/separate") @v1.post("/separate")
async def separate(_p: auth.Principal = Depends(require_scope("separate"))): async def separate(_p: auth.Principal = Depends(require_scope("separate"))):
......
...@@ -200,6 +200,26 @@ def _time_activity(y, sr): ...@@ -200,6 +200,26 @@ def _time_activity(y, sr):
return float(np.mean(rms > thresh)) return float(np.mean(rms > thresh))
def character_of(f: dict, prof: dict | None = None) -> str:
"""One descriptor adjective from the measured features — feeds /naming. Honest
to the spectrum, not the file name: noisy/bright/deep/punch/dark/warm/tone."""
cen = f.get("spectral_centroid", prof["centroid"] if prof else 0.0)
if f.get("spectral_flatness", 0.0) > 0.4 or f.get("zcr", 0.0) > 0.18:
return "noisy"
if f.get("sustain_ratio", 1.0) < 0.3 and f.get("log_attack_time", 0.0) < -1.5:
return "punch"
if cen >= 3000:
return "bright"
if cen < 250:
return "deep"
mode = f.get("key_mode")
if mode == "min":
return "dark"
if mode == "maj":
return "warm"
return "tone"
def sample_profile(audio_path, name="", max_seconds=MAX_S) -> dict: def sample_profile(audio_path, name="", max_seconds=MAX_S) -> dict:
"""Per-sample EDA the way mastering needs it: measured spectrum → role, never """Per-sample EDA the way mastering needs it: measured spectrum → role, never
inferred from the name. `name` (optional) only disambiguates breaks/drums.""" inferred from the name. `name` (optional) only disambiguates breaks/drums."""
......
"""Loudness engine — BS.1770 integrated LUFS + peak/true-peak. CPU, NO torch.
The mastering numbers PLN + hexa actually gate on (reference_postprod_master:
-14 LUFS streaming, -9 LUFS club). pyloudnorm does the K-weighted BS.1770
integrated loudness; true-peak is a cheap 4× oversample (catches inter-sample
peaks a raw sample-peak misses). Returns gain-to-target so a caller knows exactly
how much to push for each delivery target.
"""
import numpy as np
_EPS = 1e-12
TARGETS = {"streaming": -14.0, "club": -9.0} # LUFS, reference_postprod_master
def _db(x):
return float(20.0 * np.log10(max(abs(float(x)), _EPS)))
def _true_peak_dbfs(mono, sr):
"""4× oversample → max |sample| in dBFS (approximates inter-sample true peak)."""
try:
from scipy.signal import resample_poly
up = resample_poly(mono, 4, 1)
return _db(np.max(np.abs(up)))
except Exception:
return _db(np.max(np.abs(mono)))
def loudness(audio_path, max_seconds=180.0) -> dict:
"""Integrated LUFS, sample/true peak (dBFS), crest, + gain-to-target."""
import soundfile as sf
data, sr = sf.read(str(audio_path), dtype="float64")
if data.shape[0] < sr // 2: # pyloudnorm needs ≥ ~0.4 s for a gated block
raise ValueError("clip too short for integrated loudness (need ≥ 0.5 s)")
if max_seconds:
data = data[: int(sr * max_seconds)]
mono = data if data.ndim == 1 else data.mean(axis=1)
rms_db = _db(np.sqrt(np.mean(mono ** 2) + _EPS))
peak_db = _db(np.max(np.abs(mono)))
tp_db = _true_peak_dbfs(mono, sr)
lufs = None
try:
import pyloudnorm as pyln
lufs = float(pyln.Meter(sr).integrated_loudness(data))
except Exception:
pass
gain = ({k: round(t - lufs, 2) for k, t in TARGETS.items()} if lufs is not None
and np.isfinite(lufs) else None)
return {
"lufs_integrated": round(lufs, 2) if lufs is not None and np.isfinite(lufs) else None,
"peak_dbfs": round(peak_db, 2),
"true_peak_dbfs": round(tp_db, 2),
"rms_dbfs": round(rms_db, 2),
"crest_db": round(peak_db - rms_db, 2),
"gain_to_target_db": gain, # add this many dB to hit each LUFS target
"channels": 1 if data.ndim == 1 else data.shape[1],
"sr": int(sr),
"engine": "light",
}
"""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())
...@@ -61,3 +61,42 @@ def waveform(audio_path, bins=800, max_seconds=MAX_S) -> dict: ...@@ -61,3 +61,42 @@ def waveform(audio_path, bins=800, max_seconds=MAX_S) -> dict:
"sr": SR, "sr": SR,
"engine": "light", "engine": "light",
} }
def spectrum(audio_path, bands=64, frames=200, mel=True, max_seconds=MAX_S) -> dict:
"""FFT-as-a-service: a downsampled, render-ready spectrogram — `bands` ×
`frames`, 0..1 normalized (from dB, ref=peak). mel=True uses perceptual mel
bands (good for visuals); mel=False uses log-spaced linear bins. `frames=1`
collapses to a single time-averaged FFT magnitude spectrum. Bounded payload,
so it caches cheaply. Torch-free."""
import librosa
y = _load(audio_path, max_seconds)
bands = max(1, min(int(bands), 256))
frames = max(1, min(int(frames), 1000))
if mel:
S = librosa.feature.melspectrogram(y=y, sr=SR, n_mels=bands)
band_hz = [round(float(f), 1) for f in librosa.mel_frequencies(n_mels=bands, fmax=SR / 2)]
else:
mag = np.abs(librosa.stft(y, n_fft=2048, hop_length=512)) ** 2
freqs = librosa.fft_frequencies(sr=SR, n_fft=2048)
edges = np.logspace(np.log10(20), np.log10(SR / 2), bands + 1)
S = np.stack([mag[(freqs >= edges[i]) & (freqs < edges[i + 1])].sum(axis=0)
for i in range(bands)])
band_hz = [round(float(np.sqrt(edges[i] * edges[i + 1])), 1) for i in range(bands)]
Sdb = librosa.power_to_db(S + 1e-12, ref=np.max) # [-80..0] dB
# downsample time → `frames` columns by averaging within each block
cols = Sdb.shape[1]
edges = np.linspace(0, cols, frames + 1).astype(int)
blocks = [Sdb[:, edges[i]:edges[i + 1]].mean(axis=1) if edges[i + 1] > edges[i]
else Sdb[:, min(edges[i], cols - 1)] for i in range(frames)]
spec = (np.clip(np.stack(blocks, axis=1), -80, 0) + 80) / 80.0 # bands×frames, 0..1
return {
"bands": bands,
"frames": frames,
"mel": bool(mel),
"band_hz": band_hz,
"spec": [[round(float(v), 4) for v in row] for row in spec], # [band][frame]
"duration_s": round(len(y) / SR, 3),
"sr": SR,
"engine": "light",
}
...@@ -17,3 +17,4 @@ python-multipart>=0.0.9 # UploadFile / multipart form parsing ...@@ -17,3 +17,4 @@ python-multipart>=0.0.9 # UploadFile / multipart form parsing
numpy>=1.26,<2.3 numpy>=1.26,<2.3
librosa>=0.11 # spectral/chroma/tempo features (pulls scipy, numba, sklearn) librosa>=0.11 # spectral/chroma/tempo features (pulls scipy, numba, sklearn)
soundfile>=0.12 # content_id PCM decode + librosa wav/flac backend soundfile>=0.12 # content_id PCM decode + librosa wav/flac backend
pyloudnorm>=0.1 # BS.1770 integrated loudness (LUFS) for /loudness — pure-python, tiny
"""/loudness, /spectrum, /naming (#41) — engines + endpoints + naming drift guard.
Torch-free, real DSP on synth clips. The naming engine is a vendored copy of the
Foundry namer; test_naming_vendored_copy_has_not_drifted cross-checks it.
cd armada/api && ~/.virtualenvs/douanier/bin/python -m pytest tests/test_extra.py -q
"""
import importlib.util
import io
from pathlib import Path
import numpy as np
import pytest
import soundfile as sf
from fastapi.testclient import TestClient
import app as APP
import config
from engines import feats, loudness, signal
from engines import naming as vendored_naming
client = TestClient(APP.app)
SR = 22050
def _wav_bytes(freqs=(220, 440), secs=2.0, sr=SR, amp=0.4):
t = np.linspace(0, secs, int(sr * secs), endpoint=False)
y = (amp * sum(np.sin(2 * np.pi * f * t) for f in freqs) / len(freqs)).astype("float32")
buf = io.BytesIO()
sf.write(buf, y, sr, format="WAV")
return buf.getvalue()
def _tmp(freqs=(220, 440), secs=2.0, amp=0.4):
p = Path(__import__("tempfile").mktemp(suffix=".wav"))
p.write_bytes(_wav_bytes(freqs, secs, amp=amp))
return str(p)
GW = lambda scope: {"X-Tenant": "hexa", "X-Scopes": scope}
# ── loudness ──────────────────────────────────────────────────────────────────
def test_loudness_engine_measures_lufs():
r = loudness.loudness(_tmp(amp=0.3))
assert r["lufs_integrated"] is not None and r["lufs_integrated"] < 0
assert r["peak_dbfs"] <= 0.5 and r["true_peak_dbfs"] >= r["peak_dbfs"] - 1.0
assert set(r["gain_to_target_db"]) == {"streaming", "club"}
def test_loudness_endpoint():
r = client.post("/loudness", files={"file": ("c.wav", _wav_bytes(), "audio/wav")},
headers=GW("loudness"))
assert r.status_code == 200 and "lufs_integrated" in r.json()
# ── spectrum (FFT-as-a-service) ─────────────────────────────────────────────────
def test_spectrum_shape_and_range():
r = signal.spectrum(_tmp(), bands=32, frames=50, mel=True)
assert r["bands"] == 32 and r["frames"] == 50
assert len(r["spec"]) == 32 and len(r["spec"][0]) == 50
flat = [v for row in r["spec"] for v in row]
assert min(flat) >= 0.0 and max(flat) <= 1.0 # normalized 0..1
assert len(r["band_hz"]) == 32
def test_spectrum_single_frame_is_avg_spectrum():
r = signal.spectrum(_tmp(), bands=16, frames=1, mel=False)
assert r["frames"] == 1 and len(r["spec"]) == 16 and len(r["spec"][0]) == 1
def test_spectrum_endpoint():
r = client.post("/spectrum?bands=24&frames=40", files={"file": ("c.wav", _wav_bytes(), "audio/wav")},
headers=GW("spectrum"))
assert r.status_code == 200
body = r.json()
assert body["bands"] == 24 and len(body["spec"]) == 24
# ── naming ──────────────────────────────────────────────────────────────────
def test_naming_endpoint_is_convention_clean():
# a 60 Hz sine → role bass; name must lint clean (NN_role_character)
r = client.post("/naming?index=3", files={"file": ("c.wav", _wav_bytes(freqs=(60,)), "audio/wav")},
headers=GW("naming"))
assert r.status_code == 200
body = r.json()
assert body["role"] == "bass" and body["suggested_name"].startswith("03_")
assert body["lint"] == [] # convention-compliant
def test_character_of_is_measured():
bright = feats.character_of({"spectral_centroid": 5000, "spectral_flatness": 0.1, "zcr": 0.05})
deep = feats.character_of({"spectral_centroid": 100, "spectral_flatness": 0.1, "zcr": 0.05})
assert bright == "bright" and deep == "deep"
def _load_foundry_naming():
p = Path(config.FOUNDRY) / "engine" / "naming.py"
if not p.exists():
return None
spec = importlib.util.spec_from_file_location("_foundry_naming", p)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_naming_vendored_copy_has_not_drifted():
canonical = _load_foundry_naming()
if canonical is None:
pytest.skip("Foundry not on disk — drift guard runs in-repo only")
for idx, role, char in [(1, "bass", "deep"), (12, "melodic", "bright"), (3, "drums", None)]:
assert (vendored_naming.suggest_name(idx, role, character=char)
== canonical.suggest_name(idx, role, character=char))
assert vendored_naming.lint("03_bass_deep") == canonical.lint("03_bass_deep")
assert vendored_naming.ROLE_SYNONYMS == canonical.ROLE_SYNONYMS
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