Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/backend/JSON_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 64 additions & 5 deletions apps/backend/analyze_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)},
Expand All @@ -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",
}


Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down
90 changes: 90 additions & 0 deletions apps/backend/tests/test_analyze_detection_genre.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
78 changes: 78 additions & 0 deletions apps/ui/src/data/genreProfiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
},
];

/**
Expand Down Expand Up @@ -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];
}
Expand Down
Loading
Loading