diff --git a/apps/ui/src/components/analysisResultsViewModel.ts b/apps/ui/src/components/analysisResultsViewModel.ts index 31259601..8e4a04c2 100644 --- a/apps/ui/src/components/analysisResultsViewModel.ts +++ b/apps/ui/src/components/analysisResultsViewModel.ts @@ -1,17 +1,10 @@ import { AbletonRecommendation, Phase1Result, Phase2Result } from "../types"; import { type ConfidenceBand, + getConfidenceBand, toConfidenceBand, } from "../services/sessionMusician/confidenceBand"; -// Audit Finding #4: the three-level `ConfidenceLevel` vocabulary stays alive -// for the melody-insights key/value detail rows in Sonic Element cards -// (rendered as plain text like "High (78%)", not as a colored pill). The -// confidence-badge pill path migrated to the canonical four-band ladder -// via `ConfidenceBand` below. Follow-up: migrate the melody rows too once -// producers see the new bands in context. -export type ConfidenceLevel = "High" | "Moderate" | "Low"; - export interface ConfidenceBadgeViewModel { label: string; /** @@ -213,7 +206,6 @@ export interface MelodyInsightsViewModel { dominantNotes: string[]; rangeLabel: string; confidence: number; - confidenceLabel: ConfidenceLevel; isDraft: boolean; source: MelodyInsightsSource; } @@ -473,14 +465,12 @@ export function buildMelodyInsights(phase1: Phase1Result): MelodyInsightsViewMod ? `${transcriptionDetail.pitchRange.minName as string} - ${transcriptionDetail.pitchRange.maxName as string}` : "n/a"; const confidence = Math.max(0, Math.min(1, transcriptionDetail.averageConfidence ?? 0)); - const confidenceLabel: ConfidenceLevel = confidence >= 0.8 ? "High" : confidence >= 0.5 ? "Moderate" : "Low"; return { noteCount, dominantNotes, rangeLabel, confidence, - confidenceLabel, isDraft: confidence < LOW_TRANSCRIPTION_CONFIDENCE_THRESHOLD, source: "transcription", }; @@ -496,14 +486,12 @@ export function buildMelodyInsights(phase1: Phase1Result): MelodyInsightsViewMod ? `${midiToNoteName(detail.pitchRange.min as number)} - ${midiToNoteName(detail.pitchRange.max as number)}` : "n/a"; const confidence = Math.max(0, Math.min(1, detail.pitchConfidence ?? 0)); - const confidenceLabel: ConfidenceLevel = confidence >= 0.8 ? "High" : confidence >= 0.5 ? "Moderate" : "Low"; return { noteCount, dominantNotes, rangeLabel, confidence, - confidenceLabel, isDraft: confidence < LOW_MELODY_CONFIDENCE_THRESHOLD, source: "melody", }; @@ -581,7 +569,7 @@ function getSonicMeasurements( melodyInsights.source === "transcription" ? "Transcription" : "Melody Confidence", - value: `${melodyInsights.confidenceLabel} (${Math.round(melodyInsights.confidence * 100)}%)`, + value: `${getConfidenceBand(melodyInsights.confidence).label} (${Math.round(melodyInsights.confidence * 100)}%)`, }, ] : []), @@ -1109,7 +1097,7 @@ function buildMelodyPatchParameters(insights: MelodyInsightsViewModel): ChainPar { label: noteLabel, value: `${insights.noteCount}` }, { label: "Note Range", value: insights.rangeLabel }, { label: "Dominant Notes", value: insights.dominantNotes.slice(0, 3).join(", ") || "n/a" }, - { label: confidenceLabel, value: `${Math.round(insights.confidence * 100)}% (${insights.confidenceLabel})` }, + { label: confidenceLabel, value: `${Math.round(insights.confidence * 100)}% (${getConfidenceBand(insights.confidence).label})` }, ]; } diff --git a/apps/ui/src/services/mixDoctor.ts b/apps/ui/src/services/mixDoctor.ts index 9e6c1624..41d47add 100644 --- a/apps/ui/src/services/mixDoctor.ts +++ b/apps/ui/src/services/mixDoctor.ts @@ -99,6 +99,16 @@ export function resolveMixDoctorGenreId(phase1: Phase1Result): string { return DEFAULT_GENRE_ID; } +// Audit 2026-05-16 §4 (boundary review): the truePeak − lufsIntegrated branch +// below is *not* a competing source of truth with Phase 1. The backend's +// analyze_plr (apps/backend/analyze_core.py) computes +// `round(true_peak − lufs_integrated, 2)` — byte-identical to the fallback +// here — and emits `plr` in both --fast (analyze.py:1385) and full +// (analyze.py:1775) paths. The fallback only fires when `phase1.plr` is +// null/undefined, which today means: a legacy snapshot replayed from runtime +// SQLite that predates the field. When it fires it lands on the same number +// the backend would have. Result is consumed locally for advisory scoring; +// never written back into the Phase 1 snapshot. function estimatePlr(phase1: Phase1Result): number | null { const explicit = asFiniteNumber(phase1.plr); if (explicit !== null) return roundTo(explicit, 2); diff --git a/apps/ui/tests/services/analysisResultsViewModel.test.ts b/apps/ui/tests/services/analysisResultsViewModel.test.ts index 80c9ad54..53da2cc2 100644 --- a/apps/ui/tests/services/analysisResultsViewModel.test.ts +++ b/apps/ui/tests/services/analysisResultsViewModel.test.ts @@ -9,6 +9,7 @@ import { truncateAtSentenceBoundary, truncateBySentenceCount, } from '../../src/components/analysisResultsViewModel'; +import { getConfidenceBand } from '../../src/services/sessionMusician/confidenceBand'; import { MeasurementResult, Phase2Result, TranscriptionDetail } from '../../src/types'; const measurement: MeasurementResult = { @@ -747,7 +748,43 @@ describe('analysisResultsViewModel helpers', () => { expect(insights?.noteCount).toBe(6); expect(insights?.rangeLabel).toBe('C3 - G5'); expect(insights?.dominantNotes).toEqual(['C3', 'G3']); - expect(insights?.confidenceLabel).toBe('High'); expect(insights?.isDraft).toBe(false); + expect(insights?.confidence).toBeCloseTo(0.83); + expect(getConfidenceBand(insights!.confidence).label).toBe('Solid scaffold'); + }); + + // PR #60: cover the two render sites that interpolate the canonical band + // label, not just the upstream getConfidenceBand mapping. A regression in + // either string-interpolation site would otherwise hide until manual QA. + it('renders the canonical band label inside the Sonic Element melody row (transcription path)', () => { + const sonicCards = buildSonicElementCards( + { ...measurement, transcriptionDetail: pitchNote }, + { + kick: 'Kick.', + bass: 'Bass.', + melodicArp: 'Arp.', + grooveAndTiming: 'Groove.', + effectsAndTexture: 'Fx.', + widthAndStereo: 'Width.', + harmonicContent: 'Harmony.', + }, + ); + const melodic = sonicCards.find((card) => card.id === 'melodicArp'); + const confidenceRow = melodic?.measurements.find((m) => m.label === 'Transcription'); + expect(confidenceRow?.value).toBe('Solid scaffold (83%)'); + }); + + it('renders the canonical band label inside the MIDI Clip Guide patch parameters (melody fallback path)', () => { + // Phase 2 with no MIDI-focused recommendations forces the synthetic + // "MIDI Clip Guide" card, which calls buildMelodyPatchParameters with + // the melodyDetail-derived insights (pitchConfidence: 0.72 → workable). + const phase2 = { + mixAndMasterChain: [], + synthAndDrumPatches: [], + } as unknown as Phase2Result; + const cards = buildPatchCards(measurement, phase2); + const guide = cards.find((c) => c.device === 'MIDI Clip Guide'); + const confidenceRow = guide?.parameters.find((p) => p.label === 'Melody Confidence'); + expect(confidenceRow?.value).toBe('72% (Workable draft)'); }); }); diff --git a/audits/nightly-2026-05-16.md b/audits/nightly-2026-05-16.md index 9b352528..d764014a 100644 --- a/audits/nightly-2026-05-16.md +++ b/audits/nightly-2026-05-16.md @@ -160,6 +160,16 @@ and the safer-but-stricter reading would still flag this. emit `plr`, or keep the fallback and document it as an intentional derived display rather than a Phase 1 override. +**Resolution (post-merge follow-up):** keep the fallback, document why. +Verified `analyze_plr` in [apps/backend/analyze_core.py:624-635](../apps/backend/analyze_core.py) computes +`round(true_peak − lufs_integrated, 2)` — byte-identical to the UI fallback — +and that `plr` is emitted in both `--fast` ([analyze.py:1385](../apps/backend/analyze.py)) +and full ([analyze.py:1775](../apps/backend/analyze.py)) paths. The fallback fires only +when `phase1.plr` is null/undefined (legacy snapshots replayed from runtime +SQLite that predate the field) and lands on the same number the backend would +have. Documented inline above `estimatePlr` so the next audit doesn't re-flag +this. See [apps/ui/src/services/mixDoctor.ts:102](../apps/ui/src/services/mixDoctor.ts). + --- ### What was *not* found