diff --git a/apps/backend/JSON_SCHEMA.md b/apps/backend/JSON_SCHEMA.md index 7df58dbc..638a852f 100644 --- a/apps/backend/JSON_SCHEMA.md +++ b/apps/backend/JSON_SCHEMA.md @@ -832,7 +832,7 @@ Type: `object \| null` | `genreDetail.genre` | `string` | Predicted primary genre from heuristic signature matching. | label | Treat as context only — Phase 2 invariant says genre is short context, not the main blueprint. | | `genreDetail.confidence` | `float` | Match-score against the predicted genre signature. | 0.0-1.0 | Genre detection on electronic music is noisy; hedge when below 0.7. | | `genreDetail.secondaryGenre` | `string` | Second-best signature match. | label | Use to triangulate when primary is borderline — e.g. "psytrance + acid-techno" both indicates Goa-style content. | -| `genreDetail.genreFamily` | `string` | Coarse parent family (`trance`, `house`, `hip-hop`, etc.). | label | More stable than the specific genre; safer to anchor recommendations against the family. | +| `genreDetail.genreFamily` | `string` | Coarse parent family. Closed set mirrored by the UI (`types/measurement.ts` + `backendPhase1Client.ts` validFamilies): `house`, `techno`, `dnb`, `ambient`, `trance`, `dubstep`, `breaks`, `garage`, `hardcore`, `trap`, `electro`, `downtempo`, `other` (PR-G7 added the last five before `other`). | label | More stable than the specific genre; safer to anchor recommendations against the family. Adding a family requires touching both UI sites plus `_GENRE_FAMILY_MAP` — see the map's comment in analyze_detection.py. | | `genreDetail.topScores` | `array<{genre, score}>` | Top-5 candidate scores. | (label, 0.0-1.0) | Surface as alternates when primary confidence is below 0.7. | ## Danceability diff --git a/apps/backend/analyze_detection.py b/apps/backend/analyze_detection.py index 8a4a19ab..040ce22f 100644 --- a/apps/backend/analyze_detection.py +++ b/apps/backend/analyze_detection.py @@ -1051,17 +1051,47 @@ def analyze_supersaw_detail( {"id": "detroit-techno", "bpm": (125, 135), "subBassDb": (-22, -10), "crestFactor": (7, 14), "onsetDensity": (4, 8), "spectralCentroid": (1800, 4500), "sidechainStrength": (0.2, 0.45), "bassDecay": (0.4, 0.75)}, # TRANCE & PROGRESSIVE {"id": "trance", "bpm": (136, 142), "subBassDb": (-20, -8), "crestFactor": (6, 12), "onsetDensity": (4, 8), "spectralCentroid": (2000, 5000), "sidechainStrength": (0.25, 0.55), "bassDecay": (0.35, 0.65)}, - {"id": "psytrance", "bpm": (140, 148), "subBassDb": (-18, -6), "crestFactor": (5, 11), "onsetDensity": (7, 14), "spectralCentroid": (2200, 5500), "sidechainStrength": (0.35, 0.65), "bassDecay": (0.3, 0.6)}, + # Retuned PR-G7 from GiantSteps psy-trance n=10 (old windows never fired + # on real audio — measured centroid 860-1910 vs (2200, 5500), rolling + # bass decay 60-190 ms vs (0.3, 0.6)). + {"id": "psytrance", "bpm": (138, 150), "subBassDb": (-11.5, -6.5), "crestFactor": (9.5, 13.5), "onsetDensity": (4, 7), "spectralCentroid": (850, 2400), "sidechainStrength": (0.26, 0.37), "bassDecay": (0.05, 0.22)}, # BASS MUSIC - {"id": "dubstep", "bpm": (138, 145), "subBassDb": (-18, -4), "crestFactor": (7, 14), "onsetDensity": (3, 7), "spectralCentroid": (1200, 3500), "sidechainStrength": (0.2, 0.5), "bassDecay": (0.6, 1.2)}, + # Retuned PR-G7 from GiantSteps dubstep n=10 (old windows never fired — + # the assumed 0.6-1.2 s wobble decay measures 0.11-0.40 s on real clips). + {"id": "dubstep", "bpm": (137, 146), "subBassDb": (-10.5, -4), "crestFactor": (7.5, 12), "onsetDensity": (3.2, 5.5), "spectralCentroid": (900, 2900), "sidechainStrength": (0.24, 0.37), "bassDecay": (0.1, 0.45)}, {"id": "bass-house", "bpm": (124, 130), "subBassDb": (-18, -6), "crestFactor": (5, 10), "onsetDensity": (5, 9), "spectralCentroid": (2000, 4800), "sidechainStrength": (0.4, 0.7), "bassDecay": (0.25, 0.5)}, # D&B & BREAKS {"id": "drum-bass", "bpm": (168, 180), "subBassDb": (-18, -6), "crestFactor": (6, 13), "onsetDensity": (8, 18), "spectralCentroid": (2000, 5000), "sidechainStrength": (0.3, 0.6), "bassDecay": (0.3, 0.6)}, {"id": "neurofunk", "bpm": (170, 180), "subBassDb": (-16, -4), "crestFactor": (5, 11), "onsetDensity": (9, 20), "spectralCentroid": (2200, 5500), "sidechainStrength": (0.25, 0.55), "bassDecay": (0.25, 0.55)}, - {"id": "breaks", "bpm": (125, 135), "subBassDb": (-22, -10), "crestFactor": (7, 14), "onsetDensity": (5, 10), "spectralCentroid": (2200, 5200), "sidechainStrength": (0.25, 0.55), "bassDecay": (0.3, 0.6)}, + # Retuned PR-G7 from GiantSteps breaks n=15 (was reading acid-techno on + # 8/15 real clips): measured centroid runs 1300-2230 vs the old + # (2200, 5200) window, sidechain 0.22-0.34 vs (0.25, 0.55). + {"id": "breaks", "bpm": (124, 141), "subBassDb": (-14.5, -7.5), "crestFactor": (9.5, 14.5), "onsetDensity": (4, 6.6), "spectralCentroid": (1300, 2500), "sidechainStrength": (0.2, 0.36), "bassDecay": (0.08, 0.48)}, # UK BASS / GARAGE {"id": "uk-garage", "bpm": (128, 136), "subBassDb": (-20, -8), "crestFactor": (6, 12), "onsetDensity": (5, 10), "spectralCentroid": (2200, 5000), "sidechainStrength": (0.35, 0.65), "bassDecay": (0.25, 0.5)}, {"id": "bassline", "bpm": (130, 138), "subBassDb": (-18, -6), "crestFactor": (5, 11), "onsetDensity": (6, 12), "spectralCentroid": (2500, 5500), "sidechainStrength": (0.4, 0.7), "bassDecay": (0.2, 0.45)}, + # PR-G7 knowledge-based (no local reference audio on 2015 Beatport — + # see audits/genre-coverage-2026-07-13.md): rhythm/character ranges from + # standard genre facts, same provenance as the original table. Real-audio + # refinement is a named follow-up. two-step's LOW sidechain range is the + # audit's uk-garage fix: classic 2-step has a broken kick, not 4x4 pumping. + {"id": "two-step", "bpm": (128, 140), "subBassDb": (-16, -6), "crestFactor": (7, 12), "onsetDensity": (4, 9), "spectralCentroid": (1400, 3600), "sidechainStrength": (0.1, 0.35), "bassDecay": (0.2, 0.5)}, + {"id": "speed-garage", "bpm": (128, 140), "subBassDb": (-12, -4), "crestFactor": (6, 10), "onsetDensity": (4, 9), "spectralCentroid": (1500, 3600), "sidechainStrength": (0.3, 0.6), "bassDecay": (0.3, 0.7)}, + # TRAP / HALFTIME / FOOTWORK (knowledge-based, PR-G7) + {"id": "trap", "bpm": (130, 170), "subBassDb": (-12, -3), "crestFactor": (7, 12), "onsetDensity": (2, 6), "spectralCentroid": (900, 2600), "sidechainStrength": (0.1, 0.35), "bassDecay": (0.3, 0.9)}, + {"id": "footwork", "bpm": (152, 168), "subBassDb": (-14, -5), "crestFactor": (8, 14), "onsetDensity": (3, 8), "spectralCentroid": (800, 2400), "sidechainStrength": (0.1, 0.3), "bassDecay": (0.2, 0.6)}, + {"id": "halftime-dnb", "bpm": (165, 180), "subBassDb": (-12, -4), "crestFactor": (8, 13), "onsetDensity": (2, 5), "spectralCentroid": (1000, 2800), "sidechainStrength": (0.1, 0.3), "bassDecay": (0.3, 0.8)}, + # HARD DANCE (PR-G7, measured: GiantSteps hard-dance n=11 / hardcore n=17 + # via the octave-corrected tempo view — every hardcore clip ships a + # halved bpm, so the bpm windows key on the TRUE tempo and rely on the + # octave-aware scoring below. Spectral windows widened on the bright + # side: LOFI previews under-read the top octave.) + {"id": "hardstyle", "bpm": (140, 155), "subBassDb": (-11.5, -6), "crestFactor": (8.5, 12), "onsetDensity": (2.4, 5.2), "spectralCentroid": (1600, 3200), "sidechainStrength": (0.12, 0.36), "bassDecay": (0.1, 0.32), "kickDistortion": (0.55, 0.8)}, + {"id": "gabber", "bpm": (155, 200), "subBassDb": (-12, -5.5), "crestFactor": (7.5, 12), "onsetDensity": (2.8, 5.2), "spectralCentroid": (1850, 3400), "sidechainStrength": (0.12, 0.35), "bassDecay": (0.08, 0.38), "kickDistortion": (0.5, 0.78)}, + # ELECTRO / DOWNTEMPO (PR-G7, measured: GiantSteps electro-house n=15, + # chill-out n=15; same LOFI bright-side widening.) + {"id": "electro", "bpm": (122, 134), "subBassDb": (-10.5, -6), "crestFactor": (8.5, 11.5), "onsetDensity": (2.5, 5.5), "spectralCentroid": (1250, 2800), "sidechainStrength": (0.15, 0.4), "bassDecay": (0.1, 0.28)}, + {"id": "downtempo", "bpm": (78, 130), "subBassDb": (-21, -7), "crestFactor": (11, 16.5), "onsetDensity": (2.5, 6), "spectralCentroid": (600, 2000), "sidechainStrength": (0.2, 0.42), "bassDecay": (0.08, 0.45)}, # LEGACY / BROAD GENRES {"id": "edm", "bpm": (120, 135), "subBassDb": (-16, -8), "crestFactor": (5, 9), "onsetDensity": (4, 10), "spectralCentroid": (1500, 4000), "sidechainStrength": (0.3, 0.6), "bassDecay": (0.25, 0.5)}, {"id": "hiphop", "bpm": (70, 110), "subBassDb": (-16, -4), "crestFactor": (7, 11), "onsetDensity": (2, 7), "spectralCentroid": (800, 2500), "sidechainStrength": (0.1, 0.4), "bassDecay": (0.3, 0.6)}, @@ -1083,11 +1113,21 @@ def analyze_supersaw_detail( "driving-techno": "techno", "industrial-techno": "techno", "hard-techno": "techno", "acid-techno": "techno", "detroit-techno": "techno", "dub-techno": "techno", "ambient-techno": "techno", - "drum-bass": "dnb", "neurofunk": "dnb", "dnb": "dnb", + "drum-bass": "dnb", "neurofunk": "dnb", "dnb": "dnb", "halftime-dnb": "dnb", "ambient": "ambient", "ambient-drone": "ambient", "trance": "trance", "psytrance": "trance", "dubstep": "dubstep", "breaks": "breaks", + # PR-G7: families for the previously unmapped/missing genres. The UI + # mirrors this union in types/measurement.ts + backendPhase1Client.ts + # (validFamilies) and mix-doctor resolves family -> profile fallback + # via mapLegacyToEnhanced in genreProfiles.ts — all four move together. + "uk-garage": "garage", "bassline": "garage", "garage": "garage", + "speed-garage": "garage", "two-step": "garage", + "hardstyle": "hardcore", "gabber": "hardcore", + "trap": "trap", "footwork": "trap", + "electro": "electro", + "downtempo": "downtempo", } @@ -1146,6 +1186,22 @@ def analyze_genre_detail(result: dict) -> dict: return {"genreDetail": None} bpm = float(_bpm_raw) if _bpm_raw is not None else 120.0 + # PR-G7: octave-aware bpm axis. When the surfacing-only octave + # evidence (PR-G3) contests the shipped bpm, score each signature's + # bpm window against BOTH hypotheses and keep the better, with the + # alternate discounted 10% so the shipped value wins ties. Measured + # motivation: every GiantSteps hardcore clip ships a halved bpm + # (84-128 for true 156-190) — a gabber signature keyed on the true + # tempo can never fire on the shipped value alone. Never touches + # `bpm` itself (invariant #1). + octave_evidence = result.get("bpmOctaveEvidence") or {} + _alt_bpm_raw = octave_evidence.get("preferredBpm") + alt_bpm = ( + float(_alt_bpm_raw) + if isinstance(_alt_bpm_raw, (int, float)) + and octave_evidence.get("supportsShipped") is False + else None + ) crest_factor = float(_crest_raw) if _crest_raw is not None else 10.0 sub_bass_db = float(_sub_raw) if _sub_raw is not None else -25.0 spectral_centroid = float(_cent_raw) if _cent_raw is not None else 2000.0 @@ -1168,8 +1224,11 @@ def analyze_genre_detail(result: dict) -> dict: scores: list[tuple[str, float]] = [] for sig in _GENRE_SIGNATURES: + bpm_score = _genre_range_score(bpm, *sig["bpm"]) + if alt_bpm is not None: + bpm_score = max(bpm_score, 0.9 * _genre_range_score(alt_bpm, *sig["bpm"])) raw: dict[str, float] = { - "bpm": _genre_range_score(bpm, *sig["bpm"]), + "bpm": bpm_score, "subBassDb": _genre_range_score(sub_bass_db, *sig["subBassDb"], 1.5), "crestFactor": _genre_range_score(crest_factor, *sig["crestFactor"], 1.5), "onsetDensity": _genre_range_score(onset_density, *sig["onsetDensity"], 1.5), diff --git a/apps/backend/tests/test_analyze_detection_genre.py b/apps/backend/tests/test_analyze_detection_genre.py index 01fec948..6ee76d0f 100644 --- a/apps/backend/tests/test_analyze_detection_genre.py +++ b/apps/backend/tests/test_analyze_detection_genre.py @@ -160,6 +160,96 @@ def test_dnb_signature_resolves_to_dnb_family(self): self.assertIsNotNone(result["genreDetail"]) self.assertEqual(result["genreDetail"]["genreFamily"], "dnb") + def test_downtempo_signature_resolves_to_downtempo_family(self): + # Measured chill-out profile (PR-G7): slow, dark, very dynamic, + # little pumping. + result = analyze_detection.analyze_genre_detail(_phase1_result( + bpm=100.0, + crestFactor=14.5, + spectralBalance={"subBass": -13.0}, + spectralDetail={"spectralCentroid": 900.0}, + rhythmDetail={"onsetRate": 3.5}, + sidechainDetail={"pumpingStrength": 0.3}, + bassDetail={"averageDecayMs": 200.0}, + )) + self.assertIsNotNone(result["genreDetail"]) + self.assertEqual(result["genreDetail"]["genreFamily"], "downtempo") + + def test_trap_signature_resolves_to_trap_family(self): + # Knowledge-based (PR-G7): notated 150 (above dubstep's 138-145 + # window — a 140 vector with these features is genuinely ambiguous + # with dubstep even to a human), sub-dominant 808, sparse onsets, + # minimal pumping. + result = analyze_detection.analyze_genre_detail(_phase1_result( + bpm=150.0, + crestFactor=9.0, + spectralBalance={"subBass": -5.0}, + spectralDetail={"spectralCentroid": 1300.0}, + rhythmDetail={"onsetRate": 2.5}, + sidechainDetail={"pumpingStrength": 0.15}, + bassDetail={"averageDecayMs": 450.0}, + )) + self.assertIsNotNone(result["genreDetail"]) + self.assertEqual(result["genreDetail"]["genreFamily"], "trap") + + def test_two_step_low_sidechain_resolves_to_garage_family(self): + # The audit's uk-garage fix: classic 2-step has a broken kick and + # little sidechain pumping — the old table's only garage entries + # required 0.35+ pumping and scored AGAINST this material. + result = analyze_detection.analyze_genre_detail(_phase1_result( + bpm=134.0, + crestFactor=9.0, + spectralBalance={"subBass": -10.0}, + spectralDetail={"spectralCentroid": 2200.0}, + rhythmDetail={"onsetRate": 6.0}, + sidechainDetail={"pumpingStrength": 0.2}, + bassDetail={"averageDecayMs": 350.0}, + )) + self.assertIsNotNone(result["genreDetail"]) + self.assertEqual(result["genreDetail"]["genreFamily"], "garage") + + def test_gabber_fires_via_octave_evidence_on_halved_bpm(self): + # The measured hardcore failure: EVERY GiantSteps hardcore clip + # ships a halved bpm (e.g. 92 for a true 184). The gabber signature + # keys on the true tempo and must fire through the PR-G3 octave + # evidence's preferred bpm. + features = dict( + crestFactor=9.2, + spectralBalance={"subBass": -8.0}, + spectralDetail={"spectralCentroid": 2400.0}, + rhythmDetail={"onsetRate": 4.0}, + sidechainDetail={"pumpingStrength": 0.24}, + bassDetail={"averageDecayMs": 190.0}, + kickDetail={"thd": 0.68}, + ) + with_evidence = analyze_detection.analyze_genre_detail(_phase1_result( + bpm=92.0, + bpmOctaveEvidence={"preferredBpm": 184.0, "supportsShipped": False}, + **features, + )) + self.assertIsNotNone(with_evidence["genreDetail"]) + self.assertEqual(with_evidence["genreDetail"]["genreFamily"], "hardcore") + + def test_octave_evidence_that_supports_shipped_changes_nothing(self): + features = dict( + bpm=128.0, + crestFactor=6.5, + spectralBalance={"subBass": -12.0}, + spectralDetail={"spectralCentroid": 2500.0}, + rhythmDetail={"onsetRate": 6.0}, + sidechainDetail={"pumpingStrength": 0.45}, + bassDetail={"averageDecayMs": 600.0}, + ) + plain = analyze_detection.analyze_genre_detail(_phase1_result(**features)) + supported = analyze_detection.analyze_genre_detail(_phase1_result( + bpmOctaveEvidence={"preferredBpm": 128.0, "supportsShipped": True}, + **features, + )) + self.assertEqual(plain["genreDetail"]["genre"], supported["genreDetail"]["genre"]) + self.assertEqual( + plain["genreDetail"]["confidence"], supported["genreDetail"]["confidence"] + ) + class OutputShapeTests(unittest.TestCase): """The classifier's response shape is part of the Phase 1 → UI contract.""" diff --git a/apps/ui/src/data/genreProfiles.ts b/apps/ui/src/data/genreProfiles.ts index b85f8bba..39337152 100644 --- a/apps/ui/src/data/genreProfiles.ts +++ b/apps/ui/src/data/genreProfiles.ts @@ -625,6 +625,77 @@ export const GENRE_PROFILES: GenreProfile[] = [ Brilliance: { minDb: -30, maxDb: -18, optimalDb: -24 }, }, }, + + // ═══════════════════════════════════════════════════════════════════════════════ + // MEASURED PROFILES (PR-G7) — targets sourced from GiantSteps reference + // clips (p25/p75 band ranges, p50 optimal; audits/genre-coverage-2026-07-13.md + // program). Caveat: sources are LOFI previews, which under-read the top + // octave — Highs/Brilliance maxDb are widened ~+3 dB to compensate. + // ═══════════════════════════════════════════════════════════════════════════════ + { + id: 'electro', + name: 'Electro / Electro House', + targetCrestFactorRange: [8.5, 11.5], + targetPlrRange: [7, 10.5], + targetLufsRange: [-10, -5], + spectralTargets: { + 'Sub Bass': { minDb: -9.5, maxDb: -7, optimalDb: -8 }, + 'Low Bass': { minDb: -12, maxDb: -9, optimalDb: -11 }, + 'Low Mids': { minDb: -19, maxDb: -16, optimalDb: -17 }, + Mids: { minDb: -15.5, maxDb: -13, optimalDb: -15 }, + 'Upper Mids': { minDb: -20, maxDb: -17, optimalDb: -19 }, + Highs: { minDb: -23.5, maxDb: -16.5, optimalDb: -21 }, + Brilliance: { minDb: -28.5, maxDb: -22, optimalDb: -26 }, + }, + }, + { + id: 'downtempo', + name: 'Downtempo / Chill Out', + targetCrestFactorRange: [12, 16], + targetPlrRange: [11.5, 14.5], + targetLufsRange: [-15, -10], + spectralTargets: { + 'Sub Bass': { minDb: -18, maxDb: -9.5, optimalDb: -13 }, + 'Low Bass': { minDb: -17, maxDb: -13.5, optimalDb: -16 }, + 'Low Mids': { minDb: -22, maxDb: -17.5, optimalDb: -20 }, + Mids: { minDb: -20.5, maxDb: -15, optimalDb: -19.5 }, + 'Upper Mids': { minDb: -31, maxDb: -23.5, optimalDb: -28.5 }, + Highs: { minDb: -36, maxDb: -26, optimalDb: -34 }, + Brilliance: { minDb: -43, maxDb: -29.5, optimalDb: -38.5 }, + }, + }, + { + id: 'hardstyle', + name: 'Hardstyle / Hard Dance', + targetCrestFactorRange: [8, 12], + targetPlrRange: [6.5, 10.5], + targetLufsRange: [-9.5, -5.5], + spectralTargets: { + 'Sub Bass': { minDb: -10.5, maxDb: -7, optimalDb: -9.5 }, + 'Low Bass': { minDb: -13.5, maxDb: -11.5, optimalDb: -12.5 }, + 'Low Mids': { minDb: -19, maxDb: -16, optimalDb: -18 }, + Mids: { minDb: -16, maxDb: -12.5, optimalDb: -14.5 }, + 'Upper Mids': { minDb: -19, maxDb: -16.5, optimalDb: -18.5 }, + Highs: { minDb: -22, maxDb: -16, optimalDb: -20.5 }, + Brilliance: { minDb: -28, maxDb: -21.5, optimalDb: -25 }, + }, + }, + { + id: 'gabber', + name: 'Gabber / Hardcore', + targetCrestFactorRange: [7.5, 12], + targetPlrRange: [6, 10], + targetLufsRange: [-8.5, -4], + spectralTargets: { + 'Sub Bass': { minDb: -9, maxDb: -7, optimalDb: -8.5 }, + 'Low Bass': { minDb: -11.5, maxDb: -8, optimalDb: -9 }, + 'Low Mids': { minDb: -18, maxDb: -14.5, optimalDb: -16 }, + Mids: { minDb: -15, maxDb: -12, optimalDb: -13.5 }, + 'Upper Mids': { minDb: -17, maxDb: -15, optimalDb: -16.5 }, + Highs: { minDb: -20, maxDb: -13.5, optimalDb: -17 }, + Brilliance: { minDb: -25.5, maxDb: -17.5, optimalDb: -23.5 }, + }, + }, ]; /** @@ -654,6 +725,13 @@ export function mapLegacyToEnhanced(legacyId: string): string[] { ambient: ['ambient-drone', 'ambient-techno', 'dub-techno'], dnb: ['drum-bass', 'neurofunk'], garage: ['uk-garage', 'bassline'], + // PR-G7 family fallbacks. trap has no measured profile yet (no + // reference audio on 2015 Beatport) — hiphop is its honest lineage + // fallback rather than an invented target set. + hardcore: ['gabber', 'hardstyle'], + trap: ['hiphop'], + electro: ['electro'], + downtempo: ['downtempo'], }; return mapping[legacyId] || [legacyId]; } diff --git a/apps/ui/src/services/backendPhase1Client.ts b/apps/ui/src/services/backendPhase1Client.ts index c8b05815..eac895fd 100644 --- a/apps/ui/src/services/backendPhase1Client.ts +++ b/apps/ui/src/services/backendPhase1Client.ts @@ -1136,7 +1136,11 @@ function parseOptionalGenreDetail(value: unknown): GenreDetail | null { if (!isRecord(value)) return null; const confidence = toNumber(value.confidence); if (confidence === null || typeof value.genre !== "string") return null; - const validFamilies = ["house", "techno", "dnb", "ambient", "trance", "dubstep", "breaks", "other"] as const; + const validFamilies = [ + "house", "techno", "dnb", "ambient", "trance", "dubstep", "breaks", + "garage", "hardcore", "trap", "electro", "downtempo", + "other", + ] as const; const genreFamily = validFamilies.includes(value.genreFamily as typeof validFamilies[number]) ? (value.genreFamily as typeof validFamilies[number]) : "other"; diff --git a/apps/ui/src/types/measurement.ts b/apps/ui/src/types/measurement.ts index 6d0c91a7..70675f6d 100644 --- a/apps/ui/src/types/measurement.ts +++ b/apps/ui/src/types/measurement.ts @@ -649,7 +649,10 @@ export interface GenreDetail { genre: string; confidence: number; secondaryGenre: string | null; - genreFamily: "house" | "techno" | "dnb" | "ambient" | "trance" | "dubstep" | "breaks" | "other"; + genreFamily: + | "house" | "techno" | "dnb" | "ambient" | "trance" | "dubstep" | "breaks" + | "garage" | "hardcore" | "trap" | "electro" | "downtempo" + | "other"; topScores: Array<{ genre: string; score: number }>; } diff --git a/audits/genre-target-sourcing-2026-07-13.md b/audits/genre-target-sourcing-2026-07-13.md new file mode 100644 index 00000000..69e00a90 --- /dev/null +++ b/audits/genre-target-sourcing-2026-07-13.md @@ -0,0 +1,86 @@ +# Genre-target sourcing — measured signature/profile ranges from GiantSteps (2026-07-13) + +Provenance record for the PR-G7 classifier and mix-doctor changes +(`plans/genre-generalization-program.md`). Every measured number in the +new/retuned `_GENRE_SIGNATURES` entries and `genreProfiles.ts` profiles +traces to this run. + +## Method + +93 GiantSteps preview clips (locally staged corpus, see +`tests/fixtures/giantsteps/`), sampled per Beatport genre label, each run +through the full Phase 1 pipeline (`analyze.py --yes`, post-PR-G5 main). +Extracted per clip: the seven classifier feature axes (`bpm`, `subBass`, +`crestFactor`, `onsetRate`, `spectralCentroid`, `pumpingStrength`, +`averageDecayMs`) plus `rt60`, `kickThd`, `lufsIntegrated`, `plr`, the +seven spectral-balance bands, and `bpmOctaveEvidence.preferredBpm`. +Signature windows use ~p10–p90; profile band targets use p25/p75 with p50 +optimal. Samples: hardcore-hard-techno n=17, electro-house n=15, breaks +n=15, chill-out n=15, hard-dance n=11, dubstep n=10 (control), psy-trance +n=10 (control). + +**Caveats, in honesty order:** + +1. **LOFI previews.** GiantSteps audio is low-bitrate; the top octave + under-reads. New signature `spectralCentroid` windows and profile + Highs/Brilliance `maxDb` are widened on the bright side (~+3 dB / + +300–600 Hz). Re-source against full-quality references when a licensed + corpus exists. +2. **2-minute previews**, not full arrangements — intro/outro-weighted + sections are under-represented. +3. **rt60 reads 1.6–3.0 s on nearly every full mix** (detector saturates); + excluded from all new signatures. +4. **Measured `pumpingStrength` runs 0.15–0.42 across every genre**, far + below the 0.35–0.75 windows many ORIGINAL table entries assume. Only + the three measured controls were retuned here; a full-table + recalibration against real audio is a named follow-up. + +## Headline findings + +1. **The tempo-octave error is universal on hardcore**: all 17 + hardcore-hard-techno clips ship a halved/wrong-ratio `bpm` (84–128 for + true 156–190). PR-G3's `bpmOctaveEvidence.preferredBpm` recovers the + true octave on 16/17. This forced the classifier's octave-aware bpm + axis: a gabber signature keyed on the true tempo can never fire on the + shipped value alone. psy-trance and dubstep show the same halving on + ~half their clips. +2. **Three control signatures never fired on real audio**: measured breaks + (centroid 1300–2230 vs the table's 2200–5200 window), dubstep (bass + decay 0.11–0.40 s vs the assumed 0.6–1.2 s wobble), psy-trance + (centroid 860–1910 vs 2200–5500). All three retuned to measured ranges. + +## Classifier replay — real-clip reads, old table vs new + +Replayed the 93 measured feature sets through `analyze_genre_detail` +(dict-driven, no re-analysis): + +| Beatport label | Old top read | New top read | +|---|---|---| +| breaks (15) | acid-techno 8 | **breaks 7** | +| chill-out (15) | hiphop 5 / acid-techno 4 | **downtempo 12** | +| dubstep (10) | hiphop 4 / trance 4 | **dubstep 4** | +| electro-house (15) | acid-techno 8 | **electro 8** | +| hard-dance (11) | hiphop 3 / techno 2 | **hardstyle 6** (+gabber 1) | +| hardcore-hard-techno (17) | hiphop 8 | **gabber 10** | +| psy-trance (10) | acid-techno 5 / hiphop 4 | **psytrance 4** | + +Correct-family top-1 went from ~0/93 to 51/93. Remaining scatter is +honest: LOFI previews, heuristic features, and closely related families +(breaks/electro/two-step share the 125–140 BPM low-pumping space). + +## Knowledge-based entries (no local reference audio) + +`trap`, `footwork`, `two-step`, `speed-garage`, `halftime-dnb` carry +rhythm/character windows from standard genre facts — the same provenance +as the original 34-entry table — and are flagged in-line. 2015 Beatport +(the GiantSteps source) has no garage/trap/footwork sections, so these +wait on a future reference corpus for measured refinement. They get **no +mix-doctor profiles** (targets are not invented); their families fall +back via `mapLegacyToEnhanced` (trap → hiphop lineage). + +## Reproduction + +The batch/aggregation scripts are session scratch; method above is +sufficient to rebuild them. Raw percentiles for every genre/feature are +preserved in the PR-G7 description. Corpus staging: see +`plans/owner-actions-accuracy-program.md` (GiantSteps fetch, 2026-07-05).