| """Soundcheck CLI entry point. |
| |
| Subcommands (built incrementally): |
| score <path> — analyze one file and print Track-shape JSON (step 4 ✓). |
| ingest … — build the corpus (step 7, added later). |
| eval … — measure the scorer against a golden set (step 11, later). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import sys |
| from pathlib import Path |
|
|
| from . import __version__, clap_engine, config |
| from .librosa_engine import analyze_array |
| from .scoring import compute_report |
|
|
|
|
| def _analyze_with_genre(path: Path) -> tuple[dict, list[tuple[str, float]]]: |
| """Single decode pass shared by librosa + CLAP. Returns (analysis_dict, genres).""" |
| import librosa |
| import soundfile as sf |
|
|
| try: |
| duration_full = float(sf.info(str(path)).duration) |
| except Exception: |
| duration_full = None |
|
|
| y, sr = librosa.load(str(path), sr=config.ANALYSIS_SR, mono=False) |
| analysis = analyze_array(y, sr, duration_override=duration_full) |
|
|
| mono = librosa.to_mono(y) if y.ndim > 1 else y |
| cap_n = int(config.CLIP_CAP_S * sr) |
| if mono.shape[-1] > cap_n: |
| mono = mono[:cap_n] |
|
|
| clap_engine.load() |
| emb = clap_engine.encode_audio(mono, sr) |
| genres = clap_engine.top_genres(emb) |
|
|
| return analysis, genres |
|
|
|
|
| def _track_from_analysis( |
| path: Path, |
| analysis: dict, |
| source: str, |
| *, |
| genres: list[tuple[str, float]] | None = None, |
| ) -> dict: |
| """Wrap the librosa + CLAP + scoring output in the Track shape the UI consumes.""" |
| report = compute_report(analysis["raw"]) |
| g = genres or [] |
| return { |
| "id": f"{source}-{path.stem}" if source != "upload" else "upload", |
| "title": path.stem, |
| "genre": g[0][0] if g else None, |
| "genres": [{"label": lbl, "score": float(s)} for lbl, s in g], |
| "durationSec": analysis["durationSec"], |
| "source": source, |
| "waveform": analysis["waveform"], |
| "problems": analysis["problems"], |
| **report, |
| } |
|
|
|
|
| def cmd_score(args: argparse.Namespace) -> int: |
| path = Path(args.path) |
| if not path.exists(): |
| print(f"score: file not found: {path}", file=sys.stderr) |
| return 1 |
| analysis, genres = _analyze_with_genre(path) |
| track = _track_from_analysis(path, analysis, source="local", genres=genres) |
| json.dump(track, sys.stdout, indent=2, ensure_ascii=False) |
| sys.stdout.write("\n") |
| return 0 |
|
|
|
|
| def _process_source(src: dict): |
| """Analyze one source descriptor → (Track-shape dict, embedding). None on failure.""" |
| import librosa |
| import numpy as np |
| import soundfile as sf |
|
|
| path = Path(src["local_path"]) |
| if not path.exists(): |
| print(f"[ingest] missing audio: {path}", file=sys.stderr) |
| return None |
|
|
| try: |
| duration_full = float(sf.info(str(path)).duration) |
| except Exception: |
| duration_full = src.get("duration") |
|
|
| try: |
| y, sr = librosa.load(str(path), sr=config.ANALYSIS_SR, mono=False) |
| except Exception as e: |
| print(f"[ingest] decode failed for {path.name}: {e}", file=sys.stderr) |
| return None |
|
|
| analysis = analyze_array(y, sr, duration_override=duration_full) |
|
|
| mono = librosa.to_mono(y) if y.ndim > 1 else y |
| cap_n = int(config.CLIP_CAP_S * sr) |
| if mono.shape[-1] > cap_n: |
| mono = mono[:cap_n] |
|
|
| emb = clap_engine.encode_audio(mono, sr) |
| genres = clap_engine.top_genres(emb) |
| report = compute_report(analysis["raw"]) |
|
|
| title = path.stem |
| if src.get("source") == "sonics": |
| bits = [] |
| if src.get("source_genre"): |
| bits.append(str(src["source_genre"]).title()) |
| if src.get("mood"): |
| bits.append(str(src["mood"]).title()) |
| if bits: |
| title = " · ".join(bits) |
|
|
| track = { |
| "id": src["id"], |
| "title": title, |
| "genre": genres[0][0] if genres else None, |
| "genres": [{"label": lbl, "score": float(s)} for lbl, s in genres], |
| "durationSec": analysis["durationSec"], |
| "source": "corpus", |
| "waveform": analysis["waveform"], |
| "problems": analysis["problems"], |
| **report, |
| } |
| if "attribution" in src: |
| track["attribution"] = src["attribution"] |
| if src.get("source_kind"): |
| track["sourceKind"] = src["source_kind"] |
| if src.get("source_genre"): |
| track["sourceGenre"] = src["source_genre"] |
| if src.get("corruption_kind"): |
| track["corruptionKind"] = src["corruption_kind"] |
|
|
| return track, emb |
|
|
|
|
| def cmd_ingest(args: argparse.Namespace) -> int: |
| """SONICS + own + corruptions → corpus.json + embeddings.npy + corpus_stats.json + examples.json.""" |
| import random |
|
|
| import numpy as np |
| from tqdm import tqdm |
|
|
| from . import corpus_io, corruptions, sonics |
|
|
| out_dir = Path(args.out).resolve() |
| out_dir.mkdir(parents=True, exist_ok=True) |
| corpus_dir = Path(args.corpus_dir).resolve() |
| corpus_dir.mkdir(parents=True, exist_ok=True) |
|
|
| existing_ids = corpus_io.read_existing_track_ids(out_dir) |
| if existing_ids: |
| print( |
| f"[ingest] resuming: {len(existing_ids)} tracks already in {out_dir / 'corpus.json'}" |
| ) |
|
|
| |
| sonics_dir = (corpus_dir / "audio" / "sonics").resolve() |
| sonics_picks: list[dict] = [] |
| if args.sonics_n > 0: |
| |
| if args.sonics_parts.strip().lower() == "all": |
| parts = tuple(range(1, 11)) |
| else: |
| parts = tuple( |
| int(p) for p in args.sonics_parts.split(",") if p.strip() |
| ) |
| print(f"[ingest] SONICS: requesting {args.sonics_n} picks, downloading parts {parts}") |
| sonics_picks = sonics.download_subsample( |
| args.sonics_n, sonics_dir, seed=args.seed, parts=parts |
| ) |
| print(f"[ingest] SONICS: {len(sonics_picks)} tracks") |
|
|
| |
| own_picks: list[dict] = [] |
| if args.own_dir: |
| own_dir = Path(args.own_dir).resolve() |
| if own_dir.exists(): |
| AUDIO_EXTS = {".mp3", ".wav", ".flac", ".m4a", ".ogg"} |
| for p in sorted(own_dir.iterdir()): |
| if p.suffix.lower() in AUDIO_EXTS: |
| own_picks.append({ |
| "id": f"own-{p.stem}", |
| "source": "own", |
| "local_path": str(p), |
| }) |
| print(f"[ingest] own: {len(own_picks)} tracks at {own_dir}") |
| else: |
| print(f"[ingest] own_dir not found ({own_dir}) — SONICS-only run") |
|
|
| |
| print("[ingest] loading CLAP …") |
| clap_engine.load() |
|
|
| new_tracks: list[dict] = [] |
| new_embeddings: list = [] |
| source_pool = sonics_picks + own_picks |
|
|
| for src in tqdm(source_pool, desc="Analyze SONICS+own", unit="track"): |
| if src["id"] in existing_ids: |
| continue |
| result = _process_source(src) |
| if result is None: |
| continue |
| track, emb = result |
| new_tracks.append(track) |
| new_embeddings.append(emb) |
|
|
| |
| if args.corruption_subset > 0: |
| clean_pairs = [ |
| (src, t) |
| for src, t in zip(source_pool, new_tracks) |
| if t.get("verdict") == "keep" |
| ] |
| if clean_pairs: |
| rng = random.Random(args.seed + 1) |
| n_corrupt = min(args.corruption_subset, len(clean_pairs)) |
| seeds = rng.sample(clean_pairs, n_corrupt) |
| corruption_dir = (corpus_dir / "audio" / "corruptions").resolve() |
| corruption_dir.mkdir(parents=True, exist_ok=True) |
|
|
| for src, _ in tqdm( |
| seeds, desc="Synthesize+analyze corruptions", unit="seed" |
| ): |
| src_path = Path(src["local_path"]) |
| for kind in corruptions.KINDS: |
| corrupted_id = f"{src['id']}__{kind}" |
| if corrupted_id in existing_ids: |
| continue |
| out_path = corruption_dir / f"{src_path.stem}__{kind}.wav" |
| cseed = corruptions.seed_from_id(corrupted_id) |
| try: |
| corruptions.corrupt_file(src_path, out_path, kind, seed=cseed) |
| except Exception as e: |
| print( |
| f"[ingest] {kind} corruption of {src_path.stem} failed: {e}", |
| file=sys.stderr, |
| ) |
| continue |
| cs = { |
| "id": corrupted_id, |
| "source": "corruption", |
| "local_path": str(out_path), |
| "corruption_kind": kind, |
| "source_id": src["id"], |
| } |
| result = _process_source(cs) |
| if result is None: |
| continue |
| track, emb = result |
| new_tracks.append(track) |
| new_embeddings.append(emb) |
|
|
| |
| if existing_ids and (out_dir / "corpus.json").exists(): |
| existing_payload = json.loads((out_dir / "corpus.json").read_text()) |
| all_tracks = existing_payload.get("tracks", []) + new_tracks |
| existing_emb_path = out_dir / "embeddings.npy" |
| existing_emb = ( |
| np.load(existing_emb_path) |
| if existing_emb_path.exists() |
| else np.zeros((0, config.CLAP_EMBED_DIM), dtype=np.float32) |
| ) |
| if new_embeddings: |
| embeddings = np.vstack([existing_emb, np.stack(new_embeddings)]) |
| else: |
| embeddings = existing_emb |
| else: |
| all_tracks = new_tracks |
| embeddings = ( |
| np.stack(new_embeddings) |
| if new_embeddings |
| else np.zeros((0, config.CLAP_EMBED_DIM), dtype=np.float32) |
| ) |
|
|
| |
| stats = corpus_io.write_corpus(out_dir, all_tracks, embeddings) |
|
|
| print() |
| print(f"Ingest complete → {out_dir}") |
| print(f" Total tracks: {stats['total']}") |
| print(f" Kept: {stats['kept']} ({stats['keepPct']}%)") |
| print(f" Dropped: {stats['dropped']}") |
| if stats["byMode"]: |
| print(f" By failure mode: {stats['byMode']}") |
| print(f" Embeddings: {embeddings.shape}") |
| return 0 |
|
|
|
|
| |
|
|
| _MODE_LABELS = { |
| "silence": "Silence", |
| "clipping": "Clipping", |
| "noise": "Noise", |
| "truncation": "Truncation", |
| "channel": "Dead channel", |
| "duration": "Duration", |
| } |
|
|
| _CORRUPTION_TO_MODE = { |
| "silence": "silence", |
| "clip": "clipping", |
| "truncate": "truncation", |
| "noise": "noise", |
| "dead_channel": "channel", |
| } |
|
|
|
|
| def _eval_scaffold(args: argparse.Namespace) -> int: |
| """Emit a golden.json template pre-populated with the engine's predictions. |
| |
| Stratified pick (~30% corruptions covering all 5 critical modes + ~70% |
| SONICS/own). Default labels = predictions, so labelling is "agree or |
| correct" rather than "from scratch". |
| """ |
| import random |
| from datetime import datetime, timezone |
|
|
| out_path = Path(args.golden).resolve() |
| out_path.parent.mkdir(parents=True, exist_ok=True) |
| corpus_path = Path(args.corpus).resolve() |
| if not corpus_path.exists(): |
| print(f"eval scaffold: corpus.json not found at {corpus_path}", file=sys.stderr) |
| return 1 |
|
|
| corpus = json.loads(corpus_path.read_text()) |
| tracks = corpus.get("tracks", []) |
| if not tracks: |
| print("eval scaffold: corpus.json has no tracks", file=sys.stderr) |
| return 1 |
|
|
| rng = random.Random(args.seed) |
| n_target = min(args.n, len(tracks)) |
|
|
| corruption_tracks = [t for t in tracks if t.get("corruptionKind")] |
| other_tracks = [t for t in tracks if not t.get("corruptionKind")] |
| n_corrupt_target = min(round(n_target * 0.3), len(corruption_tracks)) |
|
|
| |
| by_kind: dict[str, list[dict]] = {} |
| for t in corruption_tracks: |
| by_kind.setdefault(t["corruptionKind"], []).append(t) |
| for ks in by_kind.values(): |
| rng.shuffle(ks) |
|
|
| corruption_picks: list[dict] = [] |
| if by_kind: |
| per_kind = max(1, n_corrupt_target // max(1, len(by_kind))) |
| for kind, ts in by_kind.items(): |
| corruption_picks.extend(ts[:per_kind]) |
| corruption_picks = corruption_picks[:n_corrupt_target] |
|
|
| n_other_target = n_target - len(corruption_picks) |
| other_picks = ( |
| rng.sample(other_tracks, min(n_other_target, len(other_tracks))) |
| if other_tracks |
| else [] |
| ) |
|
|
| picked = corruption_picks + other_picks |
| rng.shuffle(picked) |
|
|
| rows = [] |
| for t in picked: |
| pred_verdict = t["verdict"] |
| pred_primary = t.get("primaryFail") |
| |
| |
| if t.get("corruptionKind"): |
| label_verdict = "drop" |
| label_primary = _CORRUPTION_TO_MODE.get(t["corruptionKind"]) |
| else: |
| label_verdict = pred_verdict |
| label_primary = pred_primary |
| rows.append({ |
| "id": t["id"], |
| "title": t.get("title", t["id"]), |
| "predicted_verdict": pred_verdict, |
| "predicted_primary_fail": pred_primary, |
| "label_verdict": label_verdict, |
| "label_primary_fail": label_primary, |
| }) |
|
|
| payload = { |
| "version": __version__, |
| "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), |
| "n": len(rows), |
| "instructions": ( |
| "For each row, leave `label_*` as-is to AGREE with the prediction, " |
| "or change to CORRECT it. Then run `python -m backend.cli eval`." |
| ), |
| "rows": rows, |
| } |
| out_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False)) |
| print(f"Wrote {len(rows)}-row golden-set template → {out_path}") |
| print("Edit `label_verdict` / `label_primary_fail` per row, then:") |
| print(" python -m backend.cli eval") |
| return 0 |
|
|
|
|
| def _eval_run(args: argparse.Namespace) -> int: |
| """Read labeled golden.json + corpus.json; write eval.json (page-shaped).""" |
| from collections import Counter |
| from datetime import datetime, timezone |
|
|
| golden_path = Path(args.golden).resolve() |
| if not golden_path.exists(): |
| print(f"eval: golden.json not found at {golden_path}", file=sys.stderr) |
| print(" run `python -m backend.cli eval scaffold` first", file=sys.stderr) |
| return 1 |
|
|
| out_path = Path(args.out).resolve() |
| out_path.parent.mkdir(parents=True, exist_ok=True) |
|
|
| rows = json.loads(golden_path.read_text()).get("rows", []) |
| if not rows: |
| print("eval: golden.json has no rows", file=sys.stderr) |
| return 1 |
|
|
| |
| kg = sum(1 for r in rows if r["predicted_verdict"] == "keep" and r["label_verdict"] == "keep") |
| kb = sum(1 for r in rows if r["predicted_verdict"] == "keep" and r["label_verdict"] == "drop") |
| dg = sum(1 for r in rows if r["predicted_verdict"] == "drop" and r["label_verdict"] == "keep") |
| db = sum(1 for r in rows if r["predicted_verdict"] == "drop" and r["label_verdict"] == "drop") |
| labeled_good = kg + dg |
| labeled_bad = kb + db |
| total = labeled_good + labeled_bad |
|
|
| keep_total = kg + kb |
| keep_precision = kg / keep_total if keep_total > 0 else 1.0 |
|
|
| |
| by_mode: list[dict] = [] |
| for mid, mlabel in _MODE_LABELS.items(): |
| tp = sum(1 for r in rows if r["predicted_primary_fail"] == mid and r["label_primary_fail"] == mid) |
| fp = sum(1 for r in rows if r["predicted_primary_fail"] == mid and r["label_primary_fail"] != mid) |
| fn = sum(1 for r in rows if r["predicted_primary_fail"] != mid and r["label_primary_fail"] == mid) |
| n_labeled = sum(1 for r in rows if r["label_primary_fail"] == mid) |
| if n_labeled == 0: |
| continue |
| prec = tp / (tp + fp) if (tp + fp) > 0 else 1.0 |
| rec = tp / (tp + fn) if (tp + fn) > 0 else 1.0 |
| by_mode.append({ |
| "label": mlabel, |
| "n": n_labeled, |
| "precision": round(prec, 4), |
| "recall": round(rec, 4), |
| }) |
|
|
| payload = { |
| "goldenSet": total, |
| "labeledGood": labeled_good, |
| "labeledBad": labeled_bad, |
| "confusion": {"keptGood": kg, "keptBad": kb, "droppedGood": dg, "droppedBad": db}, |
| "byMode": by_mode, |
| "version": __version__, |
| "snapshot": datetime.now(timezone.utc).strftime("%Y-%m-%d"), |
| } |
| out_path.write_text(json.dumps(payload, indent=2)) |
|
|
| print(f"Eval complete → {out_path}") |
| print(f" Golden set: {total} tracks ({labeled_good} good · {labeled_bad} broken)") |
| print(f" Confusion: kept {kg} good + {kb} broken · dropped {dg} good + {db} broken") |
| print(f" Keep-precision: {keep_precision:.3f} (target: ≥ 0.90)") |
| if keep_precision < 0.90 and kb > 0: |
| slipped = Counter( |
| r.get("label_primary_fail") |
| for r in rows |
| if r["predicted_verdict"] == "keep" and r["label_verdict"] == "drop" |
| ) |
| print(f" ⚠ {kb} broken tracks slipped into KEEP. Most common labeled modes:") |
| for m, c in slipped.most_common(3): |
| print(f" {m}: {c}") |
| print(" Consider tightening the relevant thresholds in backend/backend/signals.py.") |
| return 0 |
|
|
|
|
| def cmd_eval(args: argparse.Namespace) -> int: |
| return _eval_scaffold(args) if args.action == "scaffold" else _eval_run(args) |
|
|
|
|
| def main(argv: list[str] | None = None) -> int: |
| parser = argparse.ArgumentParser( |
| prog="python -m backend.cli", |
| description=f"Soundcheck CLI (v{__version__})", |
| ) |
| sub = parser.add_subparsers(dest="cmd", required=True) |
|
|
| p_score = sub.add_parser("score", help="Score one audio file → Track-shape JSON") |
| p_score.add_argument("path", help="path to an audio file") |
| p_score.set_defaults(func=cmd_score) |
|
|
| p_ing = sub.add_parser( |
| "ingest", |
| help="Build the static corpus (SONICS + own + corruptions → corpus.json + embeddings.npy)", |
| ) |
| p_ing.add_argument("--sonics-n", type=int, default=300, help="how many SONICS tracks to sample") |
| p_ing.add_argument( |
| "--sonics-parts", |
| default="1", |
| help=( |
| "Which SONICS zip parts to download (1–10). Comma-separated, e.g. " |
| "'1,2,3' or 'all'. Each part is ~3.5 GB; one part yields ~10%% of " |
| "the requested --sonics-n picks. Use 'all' for full coverage (~35 GB)." |
| ), |
| ) |
| p_ing.add_argument( |
| "--own-dir", |
| default="../corpus/audio/own", |
| help="dir with your own tracks (mp3/wav/etc.); empty/missing → SONICS-only", |
| ) |
| p_ing.add_argument( |
| "--corruption-subset", |
| type=int, |
| default=60, |
| help="how many clean tracks to corrupt (5 variants each)", |
| ) |
| p_ing.add_argument( |
| "--out", |
| default="../quality-scorer/public/corpus", |
| help="output dir for corpus.json + embeddings.npy", |
| ) |
| p_ing.add_argument( |
| "--corpus-dir", |
| default="../corpus", |
| help="working dir for downloaded audio + synthesized corruptions", |
| ) |
| p_ing.add_argument("--seed", type=int, default=42) |
| p_ing.set_defaults(func=cmd_ingest) |
|
|
| p_eval = sub.add_parser( |
| "eval", |
| help="Golden-set evaluation: `eval scaffold` then `eval` (run).", |
| ) |
| p_eval.add_argument( |
| "action", |
| nargs="?", |
| default="run", |
| choices=["run", "scaffold"], |
| help="`scaffold` to emit a golden.json template, default `run` to compute metrics", |
| ) |
| p_eval.add_argument("--n", type=int, default=120, help="(scaffold) target golden-set size") |
| p_eval.add_argument( |
| "--corpus", |
| default="../quality-scorer/public/corpus/corpus.json", |
| ) |
| p_eval.add_argument( |
| "--golden", |
| default="../corpus/data/golden.json", |
| help="path to golden.json (read for run, written for scaffold)", |
| ) |
| p_eval.add_argument( |
| "--out", |
| default="../quality-scorer/public/corpus/eval.json", |
| help="(run) eval.json output", |
| ) |
| p_eval.add_argument("--seed", type=int, default=42) |
| p_eval.set_defaults(func=cmd_eval) |
|
|
| args = parser.parse_args(argv) |
| return args.func(args) |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|