Re-evaluate Session Musician: stacked stem-note draft + melody contour with honest confidence bands - #22
Conversation
…lody contour with honest confidence bands
Replaces the per-mode toggle with two vertically stacked blocks rendered
simultaneously: a "Stem note draft" (Block A — torchcrepe on Demucs stems) and
a "Melody contour" (Block B — Essentia full-mix melody). Each block owns its
own Preview / Download with distinct filenames so Ableton drag-and-drop is
unambiguous. A single shared usePreviewController prevents the two previews
from playing at once.
Confidence renders as a "label · NN%" pill plus a one-sentence plain-language
guidance line (Solid scaffold / Workable draft / Rough sketch / Unreliable).
The pill replaces the bare percentage and tells the producer what to do with
the output at each tier without hiding the raw number.
Block A's render state is precedence-driven (renderState.ts):
1. pitchNoteMode === 'off' → absent (stale cached notes can't sneak back in)
2. requested but transcriptionDetail missing → requested-but-unavailable
3. transcriptionDetail missing, not requested → absent
4. transcriptionDetail.notes empty → ran-with-no-result
5. non-torchcrepe method → legacy (wins over full-mix fallback)
6. fullMixFallback === true → full-mix-fallback
7. otherwise → stem-aware
Each non-nominal state replaces the band pill with a stronger notice and hides
controls that don't apply (stem filter / confidence slider). The "What stays
useful" footer surfaces pitch range, top-played notes, and average note
duration even when individual notes are unreliable.
The Gemini stem listening notes section moves into a new
StemListeningNotesPanel and renders adjacent to Session Musician inside a
local space-y-4 wrapper so the two panels read as a paired suite.
hasStemListeningNotesContent now powers the StickyNav inclusion, the render
gate, and the off-state cross-link copy in one place. The
section-stem-summary StickyNav anchor stays put; only the label changes
("Stem Summary" → "Stem Notes").
Off-state banner has three variants depending on what's available: melody
present, no melody + listening notes present, or neither. All variants
qualify melody as "when available" because analyze.py, analyze_fast.py, and
analyze_rhythm.py all clear melodyDetail in real code paths.
App toggle copy tightened to match the new panel structure without
overpromising.
Pure helpers extracted to services/sessionMusician/: confidenceBand,
noteConversion, stemListeningNotes, renderState. Shared React pieces in
components/sessionMusician/: PianoRollCanvas, QuantizeControls,
ConfidenceBandBadge, MidiControlsRow, usePreviewController, plus the two
blocks. Existing SessionMusicianPanel helper exports
(filterNotesByConfidence, formatFilteredNoteCount, formatVibratoConfidence,
deriveTranscriptionProvenance, MIDI_DOWNLOAD_FILE_NAME) remain for backward
compatibility.
Tests: rewrote sessionMusicianPanel.test.ts (dropped the brittle useState
call-count mocking pattern) and added confidenceBand, noteConversion,
stemListeningNotes, renderState, and stemListeningNotesPanel unit suites.
Extended upload-phase1-midi.spec.ts with shared-preview mutual-exclusion,
per-block downloads, and slider-filtered-to-zero. Three e2e tests now assert
track-analysis-stems.mid (Block A's per-block filename).
No backend changes — every field used here already existed in the contract.
Plan: .claude/plans/re-evaluate-asa-s-session-musician-sharded-balloon.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
slittycode
left a comment
There was a problem hiding this comment.
Verdict: APPROVE (posted as COMMENT — can't approve own PR)
Summary
Decomposes a 700+ line monolith into focused block components, replaces the per-mode toggle with two simultaneously rendered blocks, and adds an honest confidence ladder with plain-language guidance. Phase 1 data is read-only throughout. All 331 unit tests pass.
Findings
Worth considering (neither is blocking):
MelodyContourBlock.tsx:100–102 — Melody pitch range renders as raw MIDI integers ("MIDI range: 48 – 79"). Block A shows note names ("Range: C3 – G5") via pitchRange.minName/maxName. MelodyDetail.pitchRange has no name fields, so the fix is calling midiToNoteName() (already available via noteConversion.ts). The label correctly says "MIDI range:" so it isn't lying, just inconsistent with Block A.
QuantizeControls.tsx:30 — Grid buttons are missing type="button". Every other interactive button in the component tree (MidiControlsRow, SessionMusicianPanel, NoteDraftBlock stem filter) has it. Won't cause real problems in this SPA but breaks internal consistency.
Test results
331 passed / 0 failed. New suites: renderState (16), confidenceBand (18), noteConversion (16), stemListeningNotes (7), stemListeningNotesPanel (7). sessionMusicianPanel.test.ts covers the six Block A render states, Block B melody states, off-state banner variants, and simultaneous rendering. Smoke suite extended with shared-preview mutual-exclusion, per-block filenames, and slider-filtered-to-zero.
Phase boundary check
Clean. types.ts is not in this diff. Phase 1 fields are read by NoteDraftBlock, MelodyContourBlock, and the renderState/noteConversion helpers but never written. Confidence band and display note conversions produce new local objects; they don't mutate phase1. hasStemListeningNotesContent reads the Gemini envelope only to gate UI visibility, not to override any measurement.
Generated by Claude Code
PR #22 shipped a four-band confidence ladder (Solid scaffold / Workable draft / Rough sketch / Unreliable) but the PILL_CLASSES map collapsed them into only two tones — accent for solid + workable, warning for rough + unreliable. A producer couldn't visually distinguish Solid from Workable, or Rough from Unreliable, at a glance. Map the four bands to a green → orange → amber → red traffic-light ladder so the visual hierarchy matches the producer's mental model ("best to worst at a glance"): Solid scaffold (≥80%) → success (#00ff9d green) Workable draft (50-79%) → accent (#ff8800 orange) Rough sketch (25-49%) → warning (#ffb800 amber) Unreliable (<25%) → error (#ff3333 red) All four CSS variables already exist in apps/ui/src/index.css and are the canonical severity vocabulary across MeasurementPrimitives, DiagnosticLog, and FileUpload (success = ready, accent = active, warning = caution, error = critical). No theme additions; no new shades. Opacity tuning unified at /30 border, /10 bg, text-{color} to match MeasurementPrimitives.BADGE_TONE_CLASSES. Color carries the differentiation now — the previous per-band opacity wobble didn't communicate anything. NoteDraftBlock's overrideTone="rough" path (used for full-mix-fallback and legacy render states) now renders amber, which is the right tone for "treat with caution, not unreliable junk." No wiring change needed. Tests: new tests/services/confidenceBandBadge.test.ts renders all four confidence values and asserts each emits the expected color tokens. A pairwise-distinct assertion survives future opacity tweaks. Two override-path tests lock in the full-mix-fallback / legacy visual (high-confidence input with overrideTone="rough" produces warning tokens, not green; low-confidence input stays warning, not red). No existing tests touched — confidenceBand asserts id/label/percent (not classes); panel + smoke tests assert on text. Targets PR #22 (claude/thirsty-easley-faeaea) as the base. Plan in ~/.claude/plans/re-evaluate-asa-s-session-musician-sharded-balloon.md ("Follow-up plan #1" section). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…27) PR #22 shipped a four-band confidence ladder (Solid scaffold / Workable draft / Rough sketch / Unreliable) but the PILL_CLASSES map collapsed them into only two tones — accent for solid + workable, warning for rough + unreliable. A producer couldn't visually distinguish Solid from Workable, or Rough from Unreliable, at a glance. Map the four bands to a green → orange → amber → red traffic-light ladder so the visual hierarchy matches the producer's mental model ("best to worst at a glance"): Solid scaffold (≥80%) → success (#00ff9d green) Workable draft (50-79%) → accent (#ff8800 orange) Rough sketch (25-49%) → warning (#ffb800 amber) Unreliable (<25%) → error (#ff3333 red) All four CSS variables already exist in apps/ui/src/index.css and are the canonical severity vocabulary across MeasurementPrimitives, DiagnosticLog, and FileUpload (success = ready, accent = active, warning = caution, error = critical). No theme additions; no new shades. Opacity tuning unified at /30 border, /10 bg, text-{color} to match MeasurementPrimitives.BADGE_TONE_CLASSES. Color carries the differentiation now — the previous per-band opacity wobble didn't communicate anything. NoteDraftBlock's overrideTone="rough" path (used for full-mix-fallback and legacy render states) now renders amber, which is the right tone for "treat with caution, not unreliable junk." No wiring change needed. Tests: new tests/services/confidenceBandBadge.test.ts renders all four confidence values and asserts each emits the expected color tokens. A pairwise-distinct assertion survives future opacity tweaks. Two override-path tests lock in the full-mix-fallback / legacy visual (high-confidence input with overrideTone="rough" produces warning tokens, not green; low-confidence input stays warning, not red). No existing tests touched — confidenceBand asserts id/label/percent (not classes); panel + smoke tests assert on text. Targets PR #22 (claude/thirsty-easley-faeaea) as the base. Plan in ~/.claude/plans/re-evaluate-asa-s-session-musician-sharded-balloon.md ("Follow-up plan #1" section). Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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<string, number>` 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 <noreply@anthropic.com>
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<string, number>` 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 <noreply@anthropic.com>
When a cached run shows up with `transcriptionMethod: 'basic-pitch'` (or any non-torchcrepe method), the Stem note draft block enters its `legacy` render state and shows the existing "Re-analyze for current stem-aware quality" notice. PR #22 left the notice as just words — this adds the actual button. Behaviour - Block A renders a "Re-analyze with stem-aware pipeline" button only in the `legacy` render state AND only when App passes a callback (i.e. when a source File is loaded and no analysis is currently in flight). - Clicking the button calls back up through SessionMusicianPanel → AnalysisResults → App, which fires `handleStartAnalysis` with an explicit `pitchNoteRequested: true` override. The File state (`audioFile`) is retained in App across analyses, so no re-upload is required; the existing bytes are re-posted to `POST /api/analysis-runs` with stem-aware mode on. - The UI toggle "STEM PITCH/NOTE TRANSLATION" is also updated to reflect the override so subsequent manual Run Analysis clicks behave consistently. Investigation - `analysis_runtime.create_run` stores the artifact bytes anew on every run — there's no artifact-reuse API today. But because `audioFile: File` is retained in App state after analysis completes, re-running just means re-POSTing the same File, which is what the producer expects. No backend changes needed. Wiring - New optional `onReanalyzeWithStemAware?: () => void` prop on `SessionMusicianPanel` and `NoteDraftBlock`. AnalysisResults threads it through. App constructs the handler as `audioFile && !isAnalyzing ? () => handleStartAnalysis({ pitchNoteRequested: true }) : undefined`, so the button is only rendered (and clickable) when a re-run is actually viable. - `handleStartAnalysis` gains a typed `overrides` param. The override is read in the call to `analyzeAudio`, and a matching `setPitchNoteTranslationRequested` keeps the toggle visually in sync. Tests - Vitest (`sessionMusicianPanel.test.ts`): added five static-markup cases covering button visibility — legacy + callback → button renders, legacy + no callback → no button, stem-aware → no button, full-mix-fallback → no button, ran-with-no-result / requested-but-unavailable → no button. - Playwright smoke (`upload-phase1-midi.spec.ts`): stubs a run that returns `transcriptionMethod: 'basic-pitch'`. After the page renders the legacy state, asserts the button is visible, clicks it, and confirms a second POST to `/api/analysis-runs` fires with `pitch_note_mode=stem_notes`. Targets PR #22 (claude/thirsty-easley-faeaea) as the base; will retarget to main once 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 <noreply@anthropic.com>
When a cached run shows up with `transcriptionMethod: 'basic-pitch'` (or any non-torchcrepe method), the Stem note draft block enters its `legacy` render state and shows the existing "Re-analyze for current stem-aware quality" notice. PR #22 left the notice as just words — this adds the actual button. Behaviour - Block A renders a "Re-analyze with stem-aware pipeline" button only in the `legacy` render state AND only when App passes a callback (i.e. when a source File is loaded and no analysis is currently in flight). - Clicking the button calls back up through SessionMusicianPanel → AnalysisResults → App, which fires `handleStartAnalysis` with an explicit `pitchNoteRequested: true` override. The File state (`audioFile`) is retained in App across analyses, so no re-upload is required; the existing bytes are re-posted to `POST /api/analysis-runs` with stem-aware mode on. - The UI toggle "STEM PITCH/NOTE TRANSLATION" is also updated to reflect the override so subsequent manual Run Analysis clicks behave consistently. Investigation - `analysis_runtime.create_run` stores the artifact bytes anew on every run — there's no artifact-reuse API today. But because `audioFile: File` is retained in App state after analysis completes, re-running just means re-POSTing the same File, which is what the producer expects. No backend changes needed. Wiring - New optional `onReanalyzeWithStemAware?: () => void` prop on `SessionMusicianPanel` and `NoteDraftBlock`. AnalysisResults threads it through. App constructs the handler as `audioFile && !isAnalyzing ? () => handleStartAnalysis({ pitchNoteRequested: true }) : undefined`, so the button is only rendered (and clickable) when a re-run is actually viable. - `handleStartAnalysis` gains a typed `overrides` param. The override is read in the call to `analyzeAudio`, and a matching `setPitchNoteTranslationRequested` keeps the toggle visually in sync. Tests - Vitest (`sessionMusicianPanel.test.ts`): added five static-markup cases covering button visibility — legacy + callback → button renders, legacy + no callback → no button, stem-aware → no button, full-mix-fallback → no button, ran-with-no-result / requested-but-unavailable → no button. - Playwright smoke (`upload-phase1-midi.spec.ts`): stubs a run that returns `transcriptionMethod: 'basic-pitch'`. After the page renders the legacy state, asserts the button is visible, clicks it, and confirms a second POST to `/api/analysis-runs` fires with `pitch_note_mode=stem_notes`. Targets PR #22 (claude/thirsty-easley-faeaea) as the base; will retarget to main once 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 <noreply@anthropic.com>
Summary
Re-shapes the Session Musician panel so its pitch/note output is honest about what the field can and can't do. The architecture docs are clear: polyphonic full-mix audio-to-MIDI for dense electronic music is unsolved, and what we ship today is best-effort monophonic stem extraction + a Gemini stem listening read. The UI now reflects that without paternalism — the producer gets every signal that exists, each labelled with what it is and what to do with it.
What changed
One panel, two stacked blocks. The old PITCH/NOTE ↔ MELODY toggle is gone. Block A (
Stem note draft) and Block B (Melody contour) both render simultaneously when their data exists. Each block has its own Preview and Download with distinct filenames (track-analysis-stems.mid,track-analysis-melody.mid) so Ableton drag-and-drop is unambiguous.One shared preview controller.
usePreviewController()is called exactly once inSessionMusicianPanel; the returnedPreviewControlleris passed down to both<MidiControlsRow>instances. Starting a preview on Block B automatically stops Block A — one remote control, not two.Confidence becomes plain-language guidance. A four-band ladder replaces the bare percentage:
Pill text format:
Workable draft · 72%— label tells you what to do, number lets you audit why.Block A render-state precedence (load-bearing). Defined in
services/sessionMusician/renderState.ts:pitchNoteMode === 'off'→absent(stale cached notes can't sneak back onto the screen)transcriptionDetailmissing →requested-but-unavailableabsentnotes.length === 0→ran-with-no-resultlegacy(wins over fallback — re-analyze is the actionable fix)fullMixFallback === true→full-mix-fallbackstem-awareEach non-nominal state replaces the band pill with a stronger notice and hides controls that don't apply (stem filter, confidence slider).
"What stays useful" footer. Even at Unreliable, three signals carry: pitch range (tells you MIDI track range / synth tuning), top-played notes (scale hint), average note length (rhythmic feel). Rendered as chips below the piano roll across all data-bearing states.
Stem listening notes (Gemini) moves to its own component.
StemListeningNotesPanellifts the JSX out ofAnalysisResultsand renders adjacent to Session Musician inside a localspace-y-4wrapper so the two panels read as a paired suite.hasStemListeningNotesContentnow powers the StickyNav inclusion, the render gate, and the off-state cross-link copy in one place. Renamed in the nav from "Stem Summary" to "Stem Notes."Off-state has three variants based on data availability — melody present, no melody + listening notes present, or neither. All variants qualify melody as "when available" because
melodyDetailisNoneon three real code paths (analyze.py:1189,analyze_fast.py:141,analyze_rhythm.py:438).App toggle copy tightened to match the new panel structure without overpromising what stays available when stem mode is off.
File map
Pure helpers (
apps/ui/src/services/sessionMusician/):confidenceBand.ts— band ladder + pill formatternoteConversion.ts— transcription/melody → display-note convertersstemListeningNotes.ts—hasStemListeningNotesContenthelper used in three placesrenderState.ts—deriveNoteDraftRenderState+isLegacyTranscriptionMethodShared React pieces (
apps/ui/src/components/sessionMusician/):PianoRollCanvas.tsx,QuantizeControls.tsx,ConfidenceBandBadge.tsx,MidiControlsRow.tsx,usePreviewController.tsBlock components in the same directory:
NoteDraftBlock.tsx(Block A),MelodyContourBlock.tsx(Block B)Heavy edits:
SessionMusicianPanel.tsx— drops the toggle /sourceModestate, orchestrates the two blocks, owns the preview controller and off-state bannerAnalysisResults.tsx— wraps the two panels insection-musician-suite, replaces the inline stem-summary JSX with<StemListeningNotesPanel>, renames the nav labelLight edits:
App.tsx— toggle description copytrack-analysis-stems.midfilename for the Block A downloadTests
sessionMusicianPanel.test.ts(dropped the brittleuseStatecall-count mocking pattern). Added new suites:confidenceBand,noteConversion,stemListeningNotes,renderState,stemListeningNotesPanel.upload-phase1-midi.spec.tswith shared-preview mutual-exclusion, per-block downloads with distinct filenames, and slider-filtered-to-zero hiding the Download.Verify
cd apps/ui npm run verifyPlan
Full design rationale, render-state matrix, UI copy library, and decision history live in
.claude/plans/re-evaluate-asa-s-session-musician-sharded-balloon.md.🤖 Generated with Claude Code