From 8e5066135466e2a0f24b00e5d90d24e43d90cc5f Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Wed, 13 May 2026 08:17:53 +1200 Subject: [PATCH] feat: per-stem confidence in Session Musician note draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bass and lead stems often have very different transcription confidence — bass might be Solid (0.85) while other is Rough (0.4). Collapsing them into one combined band hides that. When the producer toggles the BASS / OTHER button in Block A, the confidence pill now reflects that stem's mean confidence; the "All" state keeps showing the overall. Backend - analyze_transcription.py: new pure helper `_per_stem_average_confidence` computes the mean confidence per `stemSource` over the surviving post-dedup, post-cap notes. `TorchcrepeBackend.transcribe` emits the new `perStemAverageConfidence: Record` field on both the populated and empty result paths. Returns `{}` when `fullMixFallback` is true so the UI doesn't show a per-stem band where there's no meaningful separation to report. - JSON_SCHEMA.md: documents the new field. - analyze.py: re-exports the helper so test_analyze can reach it via the same module-loading pattern as the other transcription helpers. - No changes to server.py or analysis_runtime — the field flows through the existing transcriptionDetail passthrough automatically (confirmed by the new round-trip server test). Frontend - measurement.ts: adds optional `perStemAverageConfidence` to TranscriptionDetail. Optional because legacy snapshots and the empty-notes path may omit it. - renderState.ts: new pure helper `selectNoteDraftBandConfidence` picks the right confidence value based on (transcriptionDetail, stemFilter, renderState). Falls back to the overall when stem filter is null, the per-stem field is missing, the selected stem key isn't keyed, or the value is non-finite. Returns the overall in full-mix-fallback / legacy render states since those replace the band copy entirely. - NoteDraftBlock.tsx: uses the helper to drive the band confidence prop. - analysisRunsClient.ts and backendPhase1Client.ts: both rebuild transcriptionDetail from an explicit field list and were silently dropping the new field. Added `parsePerStemAverageConfidence` to each — clamps to 0-1 in the legacy path and skips non-finite entries in both. Tests - test_analyze.py: pure-helper coverage for `_per_stem_average_confidence` (groups means by stem, handles empty notes, skips missing/empty/null stemSource, tolerates non-numeric confidence values). - test_server.py: end-to-end round-trip — subprocess emits perStemAverageConfidence, runtime persists it, run snapshot returns it unchanged. Catches any future whitelisting in the worker path. - renderState.test.ts: `selectNoteDraftBandConfidence` matrix (overall fallback, bass stem, other stem, missing field, missing key, non-finite value, plus the two override render states). - upload-phase1-midi.spec.ts: stubs `perStemAverageConfidence: {bass: 0.92, other: 0.32}` and clicks BASS → OTHER → All, asserting the pill text changes "Solid scaffold · 92%" → "Rough sketch · 32%" → "Solid scaffold · 83%" (the overall). Targets PR #22 (claude/thirsty-easley-faeaea) as the base; will retarget to main after that lands. Plan in ~/.claude/plans/re-evaluate-asa-s-session-musician-sharded-balloon.md ("Out-of-scope follow-ups" section). Co-Authored-By: Claude Opus 4.7 --- apps/backend/JSON_SCHEMA.md | 1 + apps/backend/analyze.py | 1 + apps/backend/analyze_transcription.py | 36 +++++++ apps/backend/tests/test_analyze.py | 42 ++++++++ apps/backend/tests/test_server.py | 99 +++++++++++++++++++ .../sessionMusician/NoteDraftBlock.tsx | 11 ++- apps/ui/src/services/analysisRunsClient.ts | 11 +++ apps/ui/src/services/backendPhase1Client.ts | 13 +++ apps/ui/src/services/sessionMusician/index.ts | 1 + .../services/sessionMusician/renderState.ts | 31 ++++++ apps/ui/src/types/measurement.ts | 7 ++ apps/ui/tests/services/renderState.test.ts | 54 ++++++++++ .../ui/tests/smoke/upload-phase1-midi.spec.ts | 12 +++ 13 files changed, 318 insertions(+), 1 deletion(-) diff --git a/apps/backend/JSON_SCHEMA.md b/apps/backend/JSON_SCHEMA.md index 0a4cbb8e..24666af2 100644 --- a/apps/backend/JSON_SCHEMA.md +++ b/apps/backend/JSON_SCHEMA.md @@ -418,6 +418,7 @@ Implementation notes: | `transcriptionDetail.stemSeparationUsed` | `bool` | Whether transcription ran on separated Demucs stems instead of the full mix. | boolean | `true` means the merged result came from one or more stems such as `bass` and `other`. | | `transcriptionDetail.fullMixFallback` | `bool` | `true` when the transcription ran on the full mix because usable stems were unavailable. | boolean | Treat `true` as a quality warning on dense material; downstream UX should inform rather than block. | | `transcriptionDetail.stemsTranscribed` | `string[]` | Ordered list of audio sources transcribed for this result. | source labels | Use to distinguish full-mix fallback from stem-based transcription. | +| `transcriptionDetail.perStemAverageConfidence` | `object` | Mean confidence per stem source, computed over the retained post-dedup, post-cap notes whose `stemSource` matches each key. Empty (`{}`) when `fullMixFallback` is `true`; otherwise contains one entry per stem with at least one surviving note (e.g. `{"bass": 0.85, "other": 0.32}`). | mapping of stem name to 0.0-1.0 confidence | Lets the UI show a separate confidence band when the producer toggles the stem filter — a Solid bass stem shouldn't be hidden behind a Rough lead stem (or vice versa). | | `transcriptionDetail.notes` | `array` | Retained note events sorted by onset time after merge, deduplication, and capping. | list of note objects | Combined note timeline from stem-based or full-mix transcription, bounded for UI and export use. | | `transcriptionDetail.notes[].pitchMidi` | `int` | MIDI note number for the event. | 0-127 | Directly usable in piano-roll or MIDI regeneration workflows. | | `transcriptionDetail.notes[].pitchName` | `string` | Note name for the event. | note label | Human-readable pitch name for summaries and prompts. | diff --git a/apps/backend/analyze.py b/apps/backend/analyze.py index be7d7a2e..a0dc8035 100644 --- a/apps/backend/analyze.py +++ b/apps/backend/analyze.py @@ -178,6 +178,7 @@ _notes_overlap_for_dedup, _select_transcription_winner, _deduplicate_transcription_notes, + _per_stem_average_confidence, _extract_contour_notes, TORCHCREPE_SAMPLE_RATE, TORCHCREPE_HOP_LENGTH, diff --git a/apps/backend/analyze_transcription.py b/apps/backend/analyze_transcription.py index 63d5078d..87343b35 100644 --- a/apps/backend/analyze_transcription.py +++ b/apps/backend/analyze_transcription.py @@ -149,6 +149,34 @@ def _select_transcription_winner(note: dict, candidate: dict, prefer_confidence_ return note if float(note.get("onsetSeconds", 0.0)) <= float(candidate.get("onsetSeconds", 0.0)) else candidate +def _per_stem_average_confidence(notes: list[dict]) -> dict[str, float]: + """Mean confidence per stemSource for the notes that survived dedup + cap. + + Each transcription note carries the stem it came from ("bass", "other", or + "full_mix"). Returning per-stem averages lets the UI show a different + confidence band when the producer toggles the stem filter — a bass stem + that tracked well shouldn't be hidden behind a noisy lead stem's + confidence (or vice versa). + """ + if not notes: + return {} + by_stem: dict[str, list[float]] = {} + for note in notes: + stem = note.get("stemSource") + if not isinstance(stem, str) or not stem: + continue + try: + conf = float(note.get("confidence", 0.0)) + except (TypeError, ValueError): + continue + by_stem.setdefault(stem, []).append(conf) + return { + stem: round(float(np.mean(np.asarray(confidences, dtype=np.float64))), 4) + for stem, confidences in by_stem.items() + if confidences + } + + def _deduplicate_transcription_notes(notes: list[dict]) -> list[dict]: if len(notes) <= 1: return [dict(note) for note in notes] @@ -541,6 +569,7 @@ def transcribe( "stemSeparationUsed": stem_separation_used, "fullMixFallback": full_mix_fallback, "stemsTranscribed": stems_transcribed, + "perStemAverageConfidence": {}, "notes": [], } } @@ -561,6 +590,12 @@ def transcribe( average_confidence = round( float(np.mean(np.asarray(confidence_values, dtype=np.float64))), 4 ) + # Empty in full-mix fallback so the UI doesn't show a per-stem + # band when there's no meaningful separation to report. The + # frontend falls back to averageConfidence in that case. + per_stem_confidence = ( + {} if full_mix_fallback else _per_stem_average_confidence(notes) + ) return { "transcriptionDetail": { @@ -577,6 +612,7 @@ def transcribe( "stemSeparationUsed": stem_separation_used, "fullMixFallback": full_mix_fallback, "stemsTranscribed": stems_transcribed, + "perStemAverageConfidence": per_stem_confidence, "notes": notes, } } diff --git a/apps/backend/tests/test_analyze.py b/apps/backend/tests/test_analyze.py index e33d96b8..23db3785 100644 --- a/apps/backend/tests/test_analyze.py +++ b/apps/backend/tests/test_analyze.py @@ -493,6 +493,48 @@ def test_deduplicate_transcription_notes_keeps_higher_confidence_for_near_duplic self.assertEqual(deduplicated[0]["confidence"], 0.81) self.assertEqual(deduplicated[0]["durationSeconds"], 0.25) + def test_per_stem_average_confidence_groups_means_by_stem_source(self) -> None: + notes = [ + {"pitchMidi": 48, "confidence": 0.9, "stemSource": "bass"}, + {"pitchMidi": 50, "confidence": 0.8, "stemSource": "bass"}, + {"pitchMidi": 64, "confidence": 0.4, "stemSource": "other"}, + {"pitchMidi": 67, "confidence": 0.2, "stemSource": "other"}, + ] + + means = self.analyze._per_stem_average_confidence(notes) + + self.assertEqual(set(means.keys()), {"bass", "other"}) + self.assertAlmostEqual(means["bass"], 0.85, places=4) + self.assertAlmostEqual(means["other"], 0.3, places=4) + + def test_per_stem_average_confidence_returns_empty_for_empty_notes(self) -> None: + self.assertEqual(self.analyze._per_stem_average_confidence([]), {}) + + def test_per_stem_average_confidence_skips_notes_with_missing_stem_source(self) -> None: + notes = [ + {"pitchMidi": 48, "confidence": 0.9, "stemSource": "bass"}, + {"pitchMidi": 60, "confidence": 0.7}, # missing stemSource entirely + {"pitchMidi": 64, "confidence": 0.5, "stemSource": ""}, # empty string + {"pitchMidi": 65, "confidence": 0.4, "stemSource": None}, # explicit null + ] + + means = self.analyze._per_stem_average_confidence(notes) + + self.assertEqual(set(means.keys()), {"bass"}) + self.assertAlmostEqual(means["bass"], 0.9, places=4) + + def test_per_stem_average_confidence_tolerates_invalid_confidence_values(self) -> None: + notes = [ + {"pitchMidi": 48, "confidence": 0.9, "stemSource": "bass"}, + {"pitchMidi": 50, "confidence": "not a number", "stemSource": "bass"}, + {"pitchMidi": 52, "confidence": None, "stemSource": "bass"}, + ] + + means = self.analyze._per_stem_average_confidence(notes) + + # The two malformed entries are skipped; only the 0.9 contributes. + self.assertAlmostEqual(means["bass"], 0.9, places=4) + class AnalyzeTextureCharacterTests(unittest.TestCase): diff --git a/apps/backend/tests/test_server.py b/apps/backend/tests/test_server.py index f162e670..bd849ffe 100644 --- a/apps/backend/tests/test_server.py +++ b/apps/backend/tests/test_server.py @@ -3196,6 +3196,105 @@ def fake_subprocess_run(command, **kwargs): stem_kinds = [artifact["kind"] for artifact in snapshot["artifacts"]["stems"]] self.assertEqual(stem_kinds, ["stem_bass", "stem_other"]) + def test_pitch_note_worker_passes_per_stem_confidence_through_to_run_snapshot(self) -> None: + """A subprocess that emits perStemAverageConfidence must round-trip into the run snapshot.""" + from analysis_runtime import AnalysisRuntime + + mock_result = json.dumps( + { + "transcriptionDetail": { + "transcriptionMethod": "torchcrepe-viterbi", + "noteCount": 2, + "averageConfidence": 0.6, + "dominantPitches": [], + "pitchRange": { + "minMidi": 48, + "maxMidi": 64, + "minName": "C3", + "maxName": "E4", + }, + "stemSeparationUsed": True, + "fullMixFallback": False, + "stemsTranscribed": ["bass", "other"], + "perStemAverageConfidence": {"bass": 0.85, "other": 0.32}, + "notes": [ + { + "pitchMidi": 48, + "pitchName": "C3", + "onsetSeconds": 0.0, + "durationSeconds": 0.5, + "confidence": 0.85, + "stemSource": "bass", + }, + { + "pitchMidi": 64, + "pitchName": "E4", + "onsetSeconds": 0.6, + "durationSeconds": 0.3, + "confidence": 0.32, + "stemSource": "other", + }, + ], + } + } + ) + + with tempfile.TemporaryDirectory(prefix="asa_pitch_note_per_stem_") as temp_dir: + runtime = AnalysisRuntime(Path(temp_dir) / "runtime") + created = runtime.create_run( + filename="track.mp3", + content=b"fake-audio", + mime_type="audio/mpeg", + pitch_note_mode="stem_notes", + pitch_note_backend="torchcrepe-viterbi", + interpretation_mode="off", + interpretation_profile="producer_summary", + interpretation_model=None, + ) + attempt_id = runtime.create_pitch_note_attempt( + created["runId"], + backend_id="torchcrepe-viterbi", + mode="stem_notes", + status="queued", + ) + runtime.reserve_pitch_note_attempt(attempt_id) + + def fake_subprocess_run(command, **kwargs): + stem_output_dir = None + if "--stem-output-dir" in command: + idx = command.index("--stem-output-dir") + stem_output_dir = command[idx + 1] + if stem_output_dir: + Path(stem_output_dir).mkdir(parents=True, exist_ok=True) + (Path(stem_output_dir) / "bass.wav").write_bytes(b"bass-audio") + (Path(stem_output_dir) / "other.wav").write_bytes(b"other-audio") + return subprocess.CompletedProcess( + args=command, + returncode=0, + stdout=mock_result, + stderr="", + ) + + with patch.object(server.subprocess, "run", side_effect=fake_subprocess_run): + server._execute_pitch_note_attempt( + runtime, + { + "attemptId": attempt_id, + "runId": created["runId"], + "backendId": "torchcrepe-viterbi", + "mode": "stem_notes", + }, + ) + + snapshot = runtime.get_run(created["runId"]) + pitch_note_result = snapshot["stages"]["pitchNoteTranslation"]["result"] + self.assertIsNotNone(pitch_note_result) + # The new field round-trips end-to-end with no whitelist trimming. + self.assertEqual( + pitch_note_result["perStemAverageConfidence"], + {"bass": 0.85, "other": 0.32}, + ) + def test_openapi_schema_exposes_phase2_route(self) -> None: spec = server.app.openapi() self.assertIn("/api/phase2", spec["paths"]) diff --git a/apps/ui/src/components/sessionMusician/NoteDraftBlock.tsx b/apps/ui/src/components/sessionMusician/NoteDraftBlock.tsx index 5f0fa60d..4d0df783 100644 --- a/apps/ui/src/components/sessionMusician/NoteDraftBlock.tsx +++ b/apps/ui/src/components/sessionMusician/NoteDraftBlock.tsx @@ -14,6 +14,7 @@ import type { QuantizeOptions } from '../../services/midi/types'; import { quantizeNotes } from '../../services/midi/quantization'; import { deriveNoteDraftRenderState, + selectNoteDraftBandConfidence, type NoteDraftRenderState, type PitchNoteMode, } from '../../services/sessionMusician/renderState'; @@ -186,6 +187,14 @@ export function NoteDraftBlock({ const bandOverrideCopy = isOverride ? (notice ?? null) : undefined; const bandOverrideTone = isOverride ? ('rough' as const) : undefined; + // Per-stem band confidence selection — see services/sessionMusician/renderState.ts + // for the precedence and fallback rules. + const bandConfidence = selectNoteDraftBandConfidence( + transcriptionDetail, + stemFilter, + renderState, + ); + // Slider filtered everything out — keep the piano roll rendered (it shows // "no notes" gracefully) but hide the Download since there's nothing to ship. const sliderEmptiedNotes = @@ -200,7 +209,7 @@ export function NoteDraftBlock({ {headerBlock} String(entry)) : [], + perStemAverageConfidence: parsePerStemAverageConfidence(result.perStemAverageConfidence), dominantPitches: Array.isArray(result.dominantPitches) ? result.dominantPitches.map((entry) => expectRecord(entry, 'dominant pitch') as PitchNoteTranslationStageSnapshot['result']['dominantPitches'][number]) : [], @@ -611,6 +612,16 @@ function parsePitchNoteResult(value: unknown): PitchNoteTranslationStageSnapshot }; } +function parsePerStemAverageConfidence(value: unknown): Record | undefined { + if (typeof value !== 'object' || value == null || Array.isArray(value)) return undefined; + const result: Record = {}; + for (const [key, raw] of Object.entries(value as Record)) { + if (typeof raw !== 'number' || !Number.isFinite(raw)) continue; + result[key] = raw; + } + return result; +} + function parseNullableRecord(value: unknown): Record | null { if (value == null) { return null; diff --git a/apps/ui/src/services/backendPhase1Client.ts b/apps/ui/src/services/backendPhase1Client.ts index e07f5608..c77d7128 100644 --- a/apps/ui/src/services/backendPhase1Client.ts +++ b/apps/ui/src/services/backendPhase1Client.ts @@ -975,12 +975,25 @@ function parseOptionalTranscriptionDetail( raw.stemSeparationUsed === false, ), stemsTranscribed: parseTranscribedStems(raw.stemsTranscribed), + perStemAverageConfidence: parsePerStemAverageConfidence(raw.perStemAverageConfidence), dominantPitches, pitchRange, notes, }; } +function parsePerStemAverageConfidence(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const result: Record = {}; + for (const [key, raw] of Object.entries(value)) { + if (typeof raw !== "number" || !Number.isFinite(raw)) continue; + // Clamp to 0-1 just like averageConfidence does — defensive against + // backend payload bugs. + result[key] = clamp01(raw); + } + return result; +} + function parseMelodyNotes(value: unknown): NonNullable["notes"] { if (!Array.isArray(value)) return []; diff --git a/apps/ui/src/services/sessionMusician/index.ts b/apps/ui/src/services/sessionMusician/index.ts index 81049486..71f48e84 100644 --- a/apps/ui/src/services/sessionMusician/index.ts +++ b/apps/ui/src/services/sessionMusician/index.ts @@ -13,6 +13,7 @@ export { hasStemListeningNotesContent } from './stemListeningNotes'; export { deriveNoteDraftRenderState, isLegacyTranscriptionMethod, + selectNoteDraftBandConfidence, type NoteDraftRenderState, type PitchNoteMode, } from './renderState'; diff --git a/apps/ui/src/services/sessionMusician/renderState.ts b/apps/ui/src/services/sessionMusician/renderState.ts index 089731ca..7fe26432 100644 --- a/apps/ui/src/services/sessionMusician/renderState.ts +++ b/apps/ui/src/services/sessionMusician/renderState.ts @@ -38,6 +38,37 @@ export function isLegacyTranscriptionMethod( return !TORCHCREPE_METHODS.has(normalized); } +/** + * Pick the confidence value the Block A band pill should display. + * + * In the nominal stem-aware state, toggling the stem filter to BASS or OTHER + * should surface that stem's average confidence — otherwise a Solid bass stem + * (0.85) is hidden behind a Rough lead stem (0.32). Falls back to the overall + * `averageConfidence` when: + * - the panel is in a non-stem-aware render state (fallback / legacy), where + * the override notice replaces the band copy anyway, + * - no stem filter is active (All button), + * - the per-stem field is missing (legacy snapshots produced before the + * backend started emitting it), + * - or the selected stem key isn't keyed in the per-stem map for some reason. + * + * Extracted to a pure helper so the wiring is unit-testable without mounting + * the component — apps/ui's Vitest config runs in node, no DOM, no clicks. + */ +export function selectNoteDraftBandConfidence( + transcriptionDetail: TranscriptionDetail, + stemFilter: string | null, + renderState: NoteDraftRenderState, +): number { + if (renderState === 'stem-aware' && stemFilter) { + const perStem = transcriptionDetail.perStemAverageConfidence?.[stemFilter]; + if (typeof perStem === 'number' && Number.isFinite(perStem)) { + return perStem; + } + } + return transcriptionDetail.averageConfidence; +} + export function deriveNoteDraftRenderState( transcriptionDetail: TranscriptionDetail | null | undefined, pitchNoteMode: PitchNoteMode, diff --git a/apps/ui/src/types/measurement.ts b/apps/ui/src/types/measurement.ts index b5f327ae..7bae263f 100644 --- a/apps/ui/src/types/measurement.ts +++ b/apps/ui/src/types/measurement.ts @@ -39,6 +39,13 @@ export interface TranscriptionDetail { stemSeparationUsed: boolean; fullMixFallback: boolean; stemsTranscribed: string[]; + /** + * Mean confidence per stem source, computed over the post-dedup, post-cap notes. + * Empty `{}` when `fullMixFallback` is true; otherwise contains one entry per + * stem with at least one surviving note (e.g. `{"bass": 0.85, "other": 0.32}`). + * Optional because legacy snapshots and the empty-notes path may omit it. + */ + perStemAverageConfidence?: Record; dominantPitches: Array<{ pitchMidi: number; pitchName: string; diff --git a/apps/ui/tests/services/renderState.test.ts b/apps/ui/tests/services/renderState.test.ts index e4671e6e..ede499ea 100644 --- a/apps/ui/tests/services/renderState.test.ts +++ b/apps/ui/tests/services/renderState.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { deriveNoteDraftRenderState, isLegacyTranscriptionMethod, + selectNoteDraftBandConfidence, } from '../../src/services/sessionMusician/renderState'; import type { TranscriptionDetail, TranscriptionNote } from '../../src/types'; @@ -115,3 +116,56 @@ describe('deriveNoteDraftRenderState — precedence', () => { expect(deriveNoteDraftRenderState(torchcrepe(), null)).toBe('stem-aware'); }); }); + +describe('selectNoteDraftBandConfidence — per-stem confidence wiring', () => { + const withPerStem = (overrides: Partial = {}): TranscriptionDetail => + torchcrepe({ + averageConfidence: 0.6, + perStemAverageConfidence: { bass: 0.85, other: 0.3 }, + ...overrides, + }); + + it('uses the overall averageConfidence when no stem filter is active', () => { + const detail = withPerStem(); + expect(selectNoteDraftBandConfidence(detail, null, 'stem-aware')).toBe(0.6); + }); + + it("uses the BASS stem's average when the stem filter is set to bass", () => { + const detail = withPerStem(); + expect(selectNoteDraftBandConfidence(detail, 'bass', 'stem-aware')).toBe(0.85); + }); + + it("uses the OTHER stem's average when the stem filter is set to other", () => { + const detail = withPerStem(); + expect(selectNoteDraftBandConfidence(detail, 'other', 'stem-aware')).toBe(0.3); + }); + + it('falls back to the overall when the stem filter is set but per-stem field is missing', () => { + // Legacy snapshot from before the backend started emitting the field. + const detail = torchcrepe({ averageConfidence: 0.6 }); + expect(selectNoteDraftBandConfidence(detail, 'bass', 'stem-aware')).toBe(0.6); + }); + + it('falls back to the overall when the selected stem key is missing from the per-stem map', () => { + const detail = withPerStem({ perStemAverageConfidence: { bass: 0.85 } }); + expect(selectNoteDraftBandConfidence(detail, 'other', 'stem-aware')).toBe(0.6); + }); + + it('falls back to the overall when the per-stem entry is not a finite number', () => { + const detail = withPerStem({ + // @ts-expect-error — defensive against malformed runtime payloads + perStemAverageConfidence: { bass: 'not a number' }, + }); + expect(selectNoteDraftBandConfidence(detail, 'bass', 'stem-aware')).toBe(0.6); + }); + + it('ignores per-stem values in full-mix-fallback render state (the band is overridden anyway)', () => { + const detail = withPerStem(); + expect(selectNoteDraftBandConfidence(detail, 'bass', 'full-mix-fallback')).toBe(0.6); + }); + + it('ignores per-stem values in legacy render state', () => { + const detail = withPerStem(); + expect(selectNoteDraftBandConfidence(detail, 'bass', 'legacy')).toBe(0.6); + }); +}); diff --git a/apps/ui/tests/smoke/upload-phase1-midi.spec.ts b/apps/ui/tests/smoke/upload-phase1-midi.spec.ts index 32f18d12..29798f44 100644 --- a/apps/ui/tests/smoke/upload-phase1-midi.spec.ts +++ b/apps/ui/tests/smoke/upload-phase1-midi.spec.ts @@ -278,6 +278,7 @@ test('phase1 dual-source session musician panel renders both blocks simultaneous stemSeparationUsed: true, fullMixFallback: false, stemsTranscribed: ['bass', 'other'], + perStemAverageConfidence: { bass: 0.92, other: 0.32 }, dominantPitches: [ { pitchMidi: 48, pitchName: 'C3', count: 4 }, { pitchMidi: 55, pitchName: 'G3', count: 3 }, @@ -376,6 +377,17 @@ test('phase1 dual-source session musician panel renders both blocks simultaneous await expect(noteDraft.locator('input[type="range"]')).toHaveCount(2); // confidence + swing await expect(melodyContour.locator('input[type="range"]')).toHaveCount(1); // swing only + // Per-stem confidence: toggling the stem filter swaps the band pill to that + // stem's average (bass=0.92, other=0.32). Clicking again deselects and + // returns to the overall (0.83). The stub above sets perStemAverageConfidence + // so the bass stem is Solid and the lead stem is Rough. + await noteDraft.getByRole('button', { name: 'bass' }).click(); + await expect(noteDraft.getByText('Solid scaffold · 92%')).toBeVisible(); + await noteDraft.getByRole('button', { name: 'other' }).click(); + await expect(noteDraft.getByText('Rough sketch · 32%')).toBeVisible(); + await noteDraft.getByRole('button', { name: 'All' }).click(); + await expect(noteDraft.getByText('Solid scaffold · 83%')).toBeVisible(); + // Both blocks expose their own MIDI controls with distinct test IDs. const stemsPreview = noteDraft.getByTestId('midi-preview-stems'); const stemsDownload = noteDraft.getByTestId('midi-download-stems');