diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 0d77f70e..244c8af1 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -1018,7 +1018,7 @@ export default function App() {

STEM PITCH/NOTE TRANSLATION

- Optional. Slower and heavier. Turns on the extra stem-based note draft. The separate melody guide may still appear when measurement can produce one. + Optional. Slower and heavier. Turns on the stem-aware note draft (Demucs + torchcrepe on bass and lead). When off, the measurement-layer melody contour and Gemini stem listening notes can still appear when those stages run.

diff --git a/apps/ui/src/components/AnalysisResults.tsx b/apps/ui/src/components/AnalysisResults.tsx index c6658bae..47f9ecb9 100644 --- a/apps/ui/src/components/AnalysisResults.tsx +++ b/apps/ui/src/components/AnalysisResults.tsx @@ -26,6 +26,8 @@ import { downloadFile, generateMarkdown } from '../utils/exportUtils'; import { INTERPRETATION_LABEL } from '../services/phaseLabels'; import { MeasurementDashboard } from './MeasurementDashboard'; import { SessionMusicianPanel } from './SessionMusicianPanel'; +import { StemListeningNotesPanel } from './StemListeningNotesPanel'; +import { hasStemListeningNotesContent } from '../services/sessionMusician'; import { AccentMetricCard, MetricBar, @@ -485,9 +487,7 @@ export function AnalysisResults({ : phase2StatusMessage ? 'disabled' : 'pending'; - const stemSummaryStems = Array.isArray(stemSummary?.stems) ? stemSummary.stems : []; - const stemSummaryFlags = Array.isArray(stemSummary?.uncertaintyFlags) ? stemSummary.uncertaintyFlags : []; - const hasStemSummaryContent = stemSummaryStems.length > 0; + const hasStemSummaryContent = hasStemListeningNotesContent(stemSummary); const warpTargets = warpGuide ? [ { label: 'Full Track', target: warpGuide.fullTrack }, @@ -537,7 +537,7 @@ export function AnalysisResults({ audioObservations ? { id: 'section-audio-observations', label: 'Audio' } : null, arrangement ? { id: 'section-arrangement', label: 'Arrangement' } : null, { id: 'section-session', label: 'Session' }, - hasStemSummaryContent ? { id: 'section-stem-summary', label: 'Stem Summary' } : null, + hasStemSummaryContent ? { id: 'section-stem-summary', label: 'Stem Notes' } : null, sonicCards.length > 0 ? { id: 'section-sonic-elements', label: 'Sonic' } : null, mixGroups.length > 0 ? { id: 'section-mix-chain', label: 'Mix Chain' } : null, patchCards.length > 0 ? { id: 'section-patches', label: 'Patches' } : null, @@ -1540,148 +1540,26 @@ export function AnalysisResults({ )} -
- -
- - {hasStemSummaryContent && ( -
- - BEST EFFORT - - } + {/* Pair the note-draft panel and the Gemini stem listening notes visually. + The parent motion container uses space-y-12; this local space-y-4 wrapper + tightens the gap between Session Musician and Stem Notes so they read + as a paired suite. The StickyNav anchor IDs stay on the inner divs. */} +
+
+ +
-
-

- What this is for -

-

- This is a plain-language musical summary of the separated stems. It is useful for understanding the role of the bass and upper material, not for exact MIDI truth. -

- {stemSummary?.summary && ( -

- {truncateAtSentenceBoundary(stemSummary.summary, 320)} -

- )} -
- - {stemSummaryFlags.length > 0 && ( -
- {stemSummaryFlags.map((flag, index) => ( - - {flag} - - ))} -
- )} - -
- {stemSummaryStems.map((stem) => ( -
-
-
-

- {stem.label} -

-

- {truncateAtSentenceBoundary(stem.summary, 220)} -

-
- - {stem.stem} - -
- -
- {stem.bars.map((bar, index) => ( -
-
- - Bars {bar.barStart}-{bar.barEnd} - - - {bar.uncertaintyLevel} certainty - -
- {bar.noteHypotheses.length > 0 && ( -

- Notes: {bar.noteHypotheses.join(', ')} -

- )} - {bar.scaleDegreeHypotheses.length > 0 && ( -

- Scale degrees: {bar.scaleDegreeHypotheses.join(', ')} -

- )} -

- Rhythm: {truncateAtSentenceBoundary(bar.rhythmicPattern, 180)} -

-

- Uncertainty: {truncateAtSentenceBoundary(bar.uncertaintyReason, 180)} -

-
- ))} -
- -
-

- Global pattern -

-
-

- Bass role: {truncateAtSentenceBoundary(stem.globalPatterns.bassRole, 180)} -

-

- Musical role: {truncateAtSentenceBoundary(stem.globalPatterns.melodicRole, 180)} -

-

- Movement: {truncateAtSentenceBoundary(stem.globalPatterns.pumpingOrModulation, 180)} -

-

- Synthesis: {truncateAtSentenceBoundary(stem.globalPatterns.synthesisCharacter, 180)} -

-

- Vocal presence: {truncateAtSentenceBoundary(stem.globalPatterns.vocalPresence, 180)} -

-

- Bass character: {truncateAtSentenceBoundary(stem.globalPatterns.bassCharacter, 180)} -

-
-
- - {stem.uncertaintyFlags.length > 0 && ( -
- {stem.uncertaintyFlags.map((flag, index) => ( - - {flag} - - ))} -
- )} -
- ))} + {hasStemSummaryContent && ( +
+
-
- )} + )} + {sonicCards.length > 0 && (
diff --git a/apps/ui/src/components/SessionMusicianPanel.tsx b/apps/ui/src/components/SessionMusicianPanel.tsx index edb105bc..7a07cbb2 100644 --- a/apps/ui/src/components/SessionMusicianPanel.tsx +++ b/apps/ui/src/components/SessionMusicianPanel.tsx @@ -1,43 +1,37 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - ChevronDown, - ChevronUp, - Download, - Grid3X3, - Info, - Music2, - Play, - SlidersHorizontal, - Square, -} from 'lucide-react'; -import { Phase1Result } from '../types'; -import { downloadMidiFile } from '../services/midi/midiExport'; -import { previewNotes, PreviewHandle } from '../services/midi/midiPreview'; -import { gridLabel, quantizeNotes } from '../services/midi/quantization'; -import { MidiDisplayNote, QuantizeGrid, QuantizeOptions } from '../services/midi/types'; +// Session Musician panel — orchestrates two stacked pitch reads (Block A: +// stem-aware note draft, Block B: measurement-layer melody contour) plus the +// off-state banner. The actual content rendering lives in the block +// components in ./sessionMusician/; this file owns the panel shell, the +// shared preview controller, and the off-state copy. + +import React, { useMemo, useState } from 'react'; +import { ChevronDown, ChevronUp, Music2 } from 'lucide-react'; +import { Phase1Result, TranscriptionDetail } from '../types'; import { formatDisplayText, getTextRoleClassName } from '../utils/displayText'; +import { MelodyContourBlock } from './sessionMusician/MelodyContourBlock'; +import { NoteDraftBlock } from './sessionMusician/NoteDraftBlock'; +import { + deriveNoteDraftRenderState, + isLegacyTranscriptionMethod, + type PitchNoteMode, +} from '../services/sessionMusician/renderState'; +import { usePreviewController } from './sessionMusician/usePreviewController'; +import type { MidiDisplayNote } from '../services/midi/types'; + +// --------------------------------------------------------------------------- +// Backwards-compatible exports. +// +// These helpers were exported from the old panel; tests and external callers +// still import them. The values and behaviour are preserved exactly — only +// the panel implementation around them has been re-organised. +// --------------------------------------------------------------------------- -const GRID_OPTIONS: QuantizeGrid[] = ['off', '1/4', '1/8', '1/16', '1/32']; -const PIANO_ROLL_HEIGHT = 240; -const KEY_WIDTH = 40; export const MIDI_DOWNLOAD_FILE_NAME = 'track-analysis.mid'; -const NOTE_COLORS = { - fill: '#ff8800', - fillHigh: '#ffb14d', - fillLow: '#664526', - stroke: '#e67e22', - grid: '#262626', - text: '#9ca3af', - bg: '#101010', -}; - -const LOW_CONFIDENCE_TITLE = "Low confidence — treat this as approximate."; -const LOW_MELODY_CONFIDENCE_THRESHOLD = 0.2; -const LOW_TRANSCRIPTION_CONFIDENCE_THRESHOLD = 0.15; -type SessionMusicianSource = 'pitchNote' | 'melodyGuide' | 'none'; - -export function filterNotesByConfidence(notes: MidiDisplayNote[], confidenceThreshold: number): MidiDisplayNote[] { +export function filterNotesByConfidence( + notes: MidiDisplayNote[], + confidenceThreshold: number, +): MidiDisplayNote[] { return notes.filter((note) => note.confidence >= confidenceThreshold); } @@ -60,17 +54,15 @@ export function formatVibratoConfidence(confidence: number, vibratoPresent: bool return String(rounded); } +type LegacySessionMusicianSource = 'pitchNote' | 'melodyGuide' | 'optedOut' | 'none'; + export function deriveTranscriptionProvenance( - activeSource: SessionMusicianSource, - transcriptionDetail: Phase1Result['transcriptionDetail'] | null | undefined, + activeSource: LegacySessionMusicianSource | string, + transcriptionDetail: TranscriptionDetail | null | undefined, ): { transcriptionPathLabel: string | null; stemSourcesLabel: string | null } { if (activeSource !== 'pitchNote' || !transcriptionDetail) { - return { - transcriptionPathLabel: null, - stemSourcesLabel: null, - }; + return { transcriptionPathLabel: null, stemSourcesLabel: null }; } - return { transcriptionPathLabel: transcriptionDetail.stemSeparationUsed ? 'STEM-AWARE' : 'FULL MIX', stemSourcesLabel: @@ -80,311 +72,78 @@ export function deriveTranscriptionProvenance( }; } -function formatPitchNoteMethodLabel(transcriptionMethod: string | null | undefined): string { - const normalized = (transcriptionMethod ?? '').trim().toLowerCase(); - if (normalized === 'torchcrepe-viterbi' || normalized === 'torchcrepe') { - return 'TORCHCREPE'; - } - // Defensive fallback for historical analysis data produced before torchcrepe migration - if (normalized === 'basic-pitch' || normalized === 'basic_pitch' || normalized === 'basic-pitch-legacy') { - return 'BASIC PITCH (LEGACY)'; - } - if (!normalized) { - return 'PITCH/NOTE EXTRACTION'; - } - return transcriptionMethod!.replace(/[_-]+/g, ' ').toUpperCase(); -} - -function midiToNoteName(midi: number): string { - const names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] as const; - const clamped = Math.max(0, Math.min(127, Math.round(midi))); - const octave = Math.floor(clamped / 12) - 1; - return `${names[clamped % 12]}${octave}`; -} - -function drawPianoRoll(canvas: HTMLCanvasElement, notes: MidiDisplayNote[], duration: number) { - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const dpr = window.devicePixelRatio || 1; - const rect = canvas.getBoundingClientRect(); - canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; - ctx.scale(dpr, dpr); - - const width = rect.width; - const height = rect.height; - - ctx.fillStyle = NOTE_COLORS.bg; - ctx.fillRect(0, 0, width, height); - - if (!notes.length) { - ctx.fillStyle = NOTE_COLORS.text; - ctx.font = '12px ui-monospace, monospace'; - ctx.textAlign = 'center'; - ctx.fillText('No notes detected', width / 2, height / 2); - return; - } - - const midiValues = notes.map((note) => note.midi); - const minMidi = Math.max(0, Math.min(...midiValues) - 2); - const maxMidi = Math.min(127, Math.max(...midiValues) + 2); - const range = Math.max(1, maxMidi - minMidi); - const plotX = KEY_WIDTH; - const plotWidth = width - KEY_WIDTH; - const noteHeight = Math.max(3, height / range); - - ctx.font = '9px ui-monospace, monospace'; - ctx.textAlign = 'right'; - for (let midi = minMidi; midi <= maxMidi; midi += 1) { - const y = height - ((midi - minMidi) / range) * height; - ctx.strokeStyle = midi % 12 === 0 ? '#424242' : NOTE_COLORS.grid; - ctx.lineWidth = midi % 12 === 0 ? 1 : 0.5; - ctx.beginPath(); - ctx.moveTo(plotX, y); - ctx.lineTo(width, y); - ctx.stroke(); +// Re-exports so existing imports (e.g. tests) can grab everything from the +// panel module path. +export { deriveNoteDraftRenderState, isLegacyTranscriptionMethod }; - if (midi % 12 === 0 || range <= 24) { - ctx.fillStyle = NOTE_COLORS.text; - ctx.fillText(midiToNoteName(midi), KEY_WIDTH - 4, y + 3); - } - } - - const secondsStep = duration > 30 ? 5 : duration > 10 ? 2 : 1; - for (let sec = 0; sec <= duration; sec += secondsStep) { - const x = plotX + (sec / duration) * plotWidth; - ctx.strokeStyle = NOTE_COLORS.grid; - ctx.lineWidth = 0.5; - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, height); - ctx.stroke(); +// --------------------------------------------------------------------------- +// Off-state banner copy +// --------------------------------------------------------------------------- - ctx.fillStyle = '#5f5f5f'; - ctx.font = '8px ui-monospace, monospace'; - ctx.textAlign = 'center'; - ctx.fillText(`${sec}s`, x, height - 2); - } +const OFF_BANNER_WITH_MELODY = + "Stem pitch/note translation is off. You're seeing the measurement-layer melody contour below. Re-enable the Stem Pitch/Note Translation toggle in the request panel to attempt a stem-aware note draft. The stem listening notes below still describe each stem in plain language when interpretation runs."; - for (const note of notes) { - const x = plotX + (note.startTime / duration) * plotWidth; - const widthPx = Math.max(2, (note.duration / duration) * plotWidth); - const y = height - ((note.midi - minMidi) / range) * height - noteHeight / 2; +const OFF_BANNER_NO_MELODY_WITH_LISTENING = + 'Session Musician is off. You still get BPM, key, structure, and Phase 2 device recommendations. The measurement-layer melody contour appears when available. Re-enable the Stem Pitch/Note Translation toggle to attempt a stem-aware note draft. The stem listening notes below describe each stem in plain language.'; - const alpha = 0.4 + note.confidence * 0.6; - ctx.globalAlpha = alpha; - ctx.fillStyle = note.confidence > 0.7 ? NOTE_COLORS.fillHigh : note.confidence > 0.3 ? NOTE_COLORS.fill : NOTE_COLORS.fillLow; - ctx.fillRect(x, y, widthPx, Math.max(2, noteHeight - 1)); +const OFF_BANNER_NO_MELODY_NO_LISTENING = + 'Session Musician is off. You still get BPM, key, structure, and Phase 2 device recommendations. The measurement-layer melody contour appears when available. Re-enable the Stem Pitch/Note Translation toggle to attempt a stem-aware note draft.'; - ctx.globalAlpha = 1; - ctx.strokeStyle = NOTE_COLORS.stroke; - ctx.lineWidth = 0.5; - ctx.strokeRect(x, y, widthPx, Math.max(2, noteHeight - 1)); - } -} +// --------------------------------------------------------------------------- +// Panel +// --------------------------------------------------------------------------- interface SessionMusicianPanelProps { phase1: Phase1Result; sourceFileName?: string | null; - pitchNoteMode?: 'stem_notes' | 'off' | null; + pitchNoteMode?: PitchNoteMode; + /** Set by AnalysisResults when the Gemini stem listening notes section will render. */ + hasStemListeningNotes?: boolean; } -export function SessionMusicianPanel({ phase1, sourceFileName, pitchNoteMode = null }: SessionMusicianPanelProps) { - const melodyDetail = phase1.melodyDetail; +export function SessionMusicianPanel({ + phase1, + pitchNoteMode = null, + hasStemListeningNotes = false, +}: SessionMusicianPanelProps) { + const melodyDetail = phase1.melodyDetail ?? null; const transcriptionDetail = phase1.transcriptionDetail ?? null; - const canvasRef = useRef(null); - const previewRef = useRef(null); - const [isPreviewing, setIsPreviewing] = useState(false); const [expanded, setExpanded] = useState(true); - const [sourceMode, setSourceMode] = useState<'pitchNote' | 'melodyGuide'>('pitchNote'); - const [confidenceThreshold, setConfidenceThreshold] = useState(0.2); - const [quantizeOptions, setQuantizeOptions] = useState({ grid: 'off', swing: 0 }); - const [activeStemFilter, setActiveStemFilter] = useState(null); - const hasTranscription = !!transcriptionDetail?.noteCount && transcriptionDetail.noteCount > 0; - const hasMelody = !!melodyDetail?.notes?.length; - const melodyGuideOnly = !hasTranscription && hasMelody; - const canToggle = hasTranscription && hasMelody; - const userOptedOutOfPitchNote = pitchNoteMode === 'off'; - const activeSource = userOptedOutOfPitchNote - ? 'optedOut' - : canToggle - ? sourceMode - : hasTranscription - ? 'pitchNote' - : hasMelody - ? 'melodyGuide' - : 'none'; + const controller = usePreviewController(); - useEffect(() => { - if (activeSource !== 'pitchNote') { - setActiveStemFilter(null); - } - }, [activeSource]); - - const activeNotes = useMemo(() => { - if (activeSource === 'pitchNote' && transcriptionDetail?.notes?.length) { - let notes = transcriptionDetail.notes; - if (activeStemFilter) { - notes = notes.filter((note) => note.stemSource === activeStemFilter); - } - return notes.map((note) => ({ - midi: note.pitchMidi, - name: note.pitchName, - startTime: note.onsetSeconds, - duration: note.durationSeconds, - velocity: 90, - confidence: note.confidence, - })); - } - - if (activeSource === 'melodyGuide' && melodyDetail?.notes?.length) { - return melodyDetail.notes.map((note) => ({ - midi: note.midi, - name: midiToNoteName(note.midi), - startTime: note.onset, - duration: note.duration, - velocity: 90, - confidence: melodyDetail.pitchConfidence, - })); - } + const isOptedOut = pitchNoteMode === 'off'; - return []; - }, [activeSource, activeStemFilter, melodyDetail, transcriptionDetail]); - - const filteredNotes = useMemo(() => { - if (activeSource === 'pitchNote') { - return filterNotesByConfidence(activeNotes, confidenceThreshold); - } - return activeNotes; - }, [activeNotes, activeSource, confidenceThreshold]); - - const displayNotes = useMemo( - () => quantizeNotes(filteredNotes, phase1.bpm || 120, quantizeOptions), - [filteredNotes, phase1.bpm, quantizeOptions], + const renderState = useMemo( + () => deriveNoteDraftRenderState(transcriptionDetail, pitchNoteMode), + [transcriptionDetail, pitchNoteMode], ); - const duration = useMemo(() => { - if (!displayNotes.length) return Math.max(1, phase1.durationSeconds || 1); - return Math.max(...displayNotes.map((note) => note.startTime + note.duration), phase1.durationSeconds || 1, 1); - }, [displayNotes, phase1.durationSeconds]); - - useEffect(() => { - if (!canvasRef.current || activeSource === 'none' || !expanded) return; - drawPianoRoll(canvasRef.current, displayNotes, duration); - }, [activeSource, displayNotes, duration, expanded]); - - useEffect(() => { - if (!canvasRef.current || activeSource === 'none' || !expanded) return; - const canvas = canvasRef.current; - const observer = new ResizeObserver(() => { - drawPianoRoll(canvas, displayNotes, duration); - }); - observer.observe(canvas); - return () => observer.disconnect(); - }, [activeSource, displayNotes, duration, expanded]); + const hasMelodyNotes = (melodyDetail?.notes?.length ?? 0) > 0; + const showNoteDraftBlock = renderState !== 'absent'; + const showMelodyBlock = !!melodyDetail; - useEffect(() => () => previewRef.current?.stop(), []); + const offBannerCopy = useMemo(() => { + if (!isOptedOut) return null; + if (hasMelodyNotes) return OFF_BANNER_WITH_MELODY; + if (hasStemListeningNotes) return OFF_BANNER_NO_MELODY_WITH_LISTENING; + return OFF_BANNER_NO_MELODY_NO_LISTENING; + }, [hasMelodyNotes, hasStemListeningNotes, isOptedOut]); - const handlePreview = useCallback(() => { - if (isPreviewing) { - previewRef.current?.stop(); - previewRef.current = null; - setIsPreviewing(false); - return; + const panelSummary = useMemo(() => { + if (isOptedOut) { + return 'Pitch/note translation is off'; } - - if (!displayNotes.length) return; - - const handle = previewNotes(displayNotes, () => { - setIsPreviewing(false); - previewRef.current = null; - }); - previewRef.current = handle; - setIsPreviewing(true); - }, [displayNotes, isPreviewing]); - - const handleDownload = useCallback(() => { - if (!displayNotes.length) return; - downloadMidiFile(displayNotes, phase1.bpm, MIDI_DOWNLOAD_FILE_NAME); - }, [displayNotes, phase1.bpm]); - - const stats = useMemo(() => { - if (activeSource === 'none' || !activeNotes.length) return null; - const midiValues = displayNotes.map((note) => note.midi); - const minMidi = midiValues.length ? Math.min(...midiValues) : null; - const maxMidi = midiValues.length ? Math.max(...midiValues) : null; - const avgConfidence = Math.round( - (activeSource === 'pitchNote' - ? transcriptionDetail?.averageConfidence ?? 0 - : melodyDetail?.pitchConfidence ?? 0) * 100, - ); - const totalDuration = displayNotes.reduce((sum, note) => sum + note.duration, 0).toFixed(1); - const countThreshold = activeSource === 'pitchNote' ? confidenceThreshold : 0; - - return { - countLabel: formatFilteredNoteCount(filteredNotes.length, activeNotes.length, countThreshold), - range: minMidi === null || maxMidi === null ? 'n/a' : `${midiToNoteName(minMidi)} - ${midiToNoteName(maxMidi)}`, - avgConfidence, - totalDuration, - trackDuration: phase1.durationSeconds.toFixed(1), - }; - }, [activeNotes.length, activeSource, confidenceThreshold, displayNotes, filteredNotes, melodyDetail, phase1.durationSeconds, transcriptionDetail]); - const hasSourceNotes = activeNotes.length > 0; - const hasNotes = displayNotes.length > 0; - const confidenceThresholdPercent = Math.round(confidenceThreshold * 100); - const dominantNoteNames = - activeSource === 'pitchNote' - ? transcriptionDetail?.dominantPitches.map((note) => note.pitchName) ?? [] - : melodyDetail?.dominantNotes.map((note) => midiToNoteName(note)) ?? []; - const rangeLabel = - activeSource === 'pitchNote' - ? transcriptionDetail?.pitchRange.minName && transcriptionDetail?.pitchRange.maxName - ? `${transcriptionDetail.pitchRange.minName} - ${transcriptionDetail.pitchRange.maxName}` - : 'n/a' - : melodyDetail?.pitchRange.min === null || melodyDetail?.pitchRange.max === null || !melodyDetail?.pitchRange - ? 'n/a' - : `${midiToNoteName(melodyDetail.pitchRange.min)} - ${midiToNoteName(melodyDetail.pitchRange.max)}`; - const confidencePercent = - activeSource === 'pitchNote' - ? Math.round((transcriptionDetail?.averageConfidence ?? 0) * 100) - : activeSource === 'melodyGuide' - ? Math.round((melodyDetail?.pitchConfidence ?? 0) * 100) - : 0; - const isDraft = - activeSource === 'pitchNote' - ? (transcriptionDetail?.averageConfidence ?? 0) < LOW_TRANSCRIPTION_CONFIDENCE_THRESHOLD - : activeSource === 'melodyGuide' - ? (melodyDetail?.pitchConfidence ?? 0) < LOW_MELODY_CONFIDENCE_THRESHOLD - : false; - const sourceBadgeLabel = - activeSource === 'pitchNote' - ? `PITCH/NOTE: ${formatPitchNoteMethodLabel(transcriptionDetail?.transcriptionMethod)}` - : activeSource === 'melodyGuide' - ? 'MELODY GUIDE: ESSENTIA' - : null; - const { transcriptionPathLabel, stemSourcesLabel } = deriveTranscriptionProvenance(activeSource, transcriptionDetail); - const melodyIsApproximate = - !!melodyDetail && (melodyDetail.pitchConfidence ?? 1) <= LOW_MELODY_CONFIDENCE_THRESHOLD; - const melodyMetadataSummary = melodyDetail - ? { - midiFile: melodyDetail.midiFile ? 'available' : 'none', - source: melodyDetail.sourceSeparated ? 'separated' : 'full mix', - vibrato: melodyDetail.vibratoPresent - ? `present (${melodyDetail.vibratoRate.toFixed(1)} Hz / ${melodyDetail.vibratoExtent.toFixed(2)} cents / ${formatVibratoConfidence(melodyDetail.vibratoConfidence, true)}%)` - : `not detected (${Math.round(melodyDetail.vibratoConfidence * 100)}%)`, - } - : null; - const previewLabel = activeSource === 'melodyGuide' ? 'Preview melody' : 'Preview'; - const downloadLabel = activeSource === 'melodyGuide' ? 'Download melody .mid' : 'Download .mid'; - const panelSummaryTitle = userOptedOutOfPitchNote - ? 'Pitch/note translation is off' - : melodyGuideOnly - ? 'Measurement-layer melody guide' - : 'Pitch detection and melody guide'; - const panelSummaryBody = userOptedOutOfPitchNote - ? 'Re-enable the Stem Pitch/Note Translation toggle to use the melody guide preview and MIDI export.' - : 'Draft notes for MIDI cleanup'; + if (showNoteDraftBlock && showMelodyBlock) { + return 'Two reads of the pitched material in this track. Use either, neither, or both.'; + } + if (showNoteDraftBlock) { + return "Stem-aware note draft. Designed for cleanup in Ableton's piano roll, not as exact note truth."; + } + if (showMelodyBlock) { + return 'Measurement-layer melody contour. Tracks the loudest pitched line across the full mix.'; + } + return 'No pitched material detected. Check the stem listening notes below.'; + }, [isOptedOut, showMelodyBlock, showNoteDraftBlock]); return (
@@ -393,19 +152,12 @@ export function SessionMusicianPanel({ phase1, sourceFileName, pitchNoteMode = n data-text-role="section-title" className={[getTextRoleClassName('section-title'), 'flex items-center'].join(' ')} > - + {formatDisplayText('Session Musician', 'title')} - {melodyIsApproximate && ( - - ⚠ - - )} - PITCH & MELODY + + PITCH & MELODY +
@@ -414,290 +166,70 @@ export function SessionMusicianPanel({ phase1, sourceFileName, pitchNoteMode = n
-
-

- {panelSummaryTitle} -

-

- {panelSummaryBody} -

-
-
- -
- - - {canToggle && ( -
- - -
- )} - -
- - - {melodyGuideOnly && !userOptedOutOfPitchNote && ( -
-

- Stem pitch/note extraction is off. -

-

- This panel is showing the measurement-layer melody guide instead, so preview and MIDI export still work, but this is not the stem note draft. + {panelSummary}

- )} + + - {userOptedOutOfPitchNote && ( -
+ {offBannerCopy && ( +

- Pitch/note translation is off. + Pitch/note translation is off

-

- Re-enable the Stem Pitch/Note Translation toggle in the request panel to preview and export the melody guide. +

+ {offBannerCopy}

)} {expanded && ( <> - {activeSource === 'none' && ( -
+ {showNoteDraftBlock && ( + + )} + {showMelodyBlock && ( + + )} + {!showNoteDraftBlock && !showMelodyBlock && !offBannerCopy && ( +

PITCH & MELODY UNAVAILABLE

-

- Run with pitch/note translation enabled, or ensure melodyDetail is present in the DSP payload for a melody guide +

+ Neither stem-aware transcription nor a measurement-layer melody contour were produced for this run. Check the stem listening notes if interpretation ran.

)} - - {(activeSource === 'pitchNote' || activeSource === 'melodyGuide') && ( - <> - {stats && ( -
- {stats.countLabel} - | - Range: {stats.range} - | - Confidence: {stats.avgConfidence}% - | - Total note time: {stats.totalDuration}s - | - Track duration: {stats.trackDuration}s - {sourceBadgeLabel && ( - <> - | - {sourceBadgeLabel} - - )} -
- )} - - {transcriptionPathLabel && ( -
- - {transcriptionPathLabel} - - {stemSourcesLabel && ( -
- STEMS: - {transcriptionDetail!.stemsTranscribed.map((stem) => ( - - ))} - {activeStemFilter && ( - - )} -
- )} - {transcriptionDetail?.fullMixFallback && ( - - FULL MIX — quality limited - - )} -
- )} - - {activeSource === 'melodyGuide' && melodyMetadataSummary && ( -
- Melody MIDI: {melodyMetadataSummary.midiFile} - | - Melody source: {melodyMetadataSummary.source} - | - Vibrato: {melodyMetadataSummary.vibrato} -
- )} - - {!stats && ( -
- - {activeSource === 'pitchNote' ? transcriptionDetail?.noteCount ?? 0 : melodyDetail?.noteCount ?? 0} notes - - Range: {rangeLabel} - Confidence: {confidencePercent}% - {sourceBadgeLabel && ( - {sourceBadgeLabel} - )} - {isDraft && ( - - Draft transcription - - )} -
- )} - - {dominantNoteNames.length > 0 && ( -
- {dominantNoteNames.map((name, idx) => ( - - {name} - - ))} -
- )} - -
- -
- -
-
- - Quantize -
- -
- {GRID_OPTIONS.map((grid) => ( - - ))} -
- -
- CONFIDENCE - setConfidenceThreshold(Number(event.target.value))} - disabled={!hasSourceNotes || activeSource === 'melodyGuide'} - className="w-20 h-1 accent-accent disabled:opacity-30" - /> - {confidenceThresholdPercent}% -
- -
- - Swing - - setQuantizeOptions((prev) => ({ - ...prev, - swing: Number(event.target.value), - })) - } - disabled={quantizeOptions.grid === 'off' || !hasNotes} - className="w-20 h-1 accent-accent disabled:opacity-30" - /> - {quantizeOptions.swing}% -
-
- -
- - - {activeSource === 'pitchNote' - ? `${formatPitchNoteMethodLabel(transcriptionDetail?.transcriptionMethod)} pitch detection. Adjust quantize before preview/export. Adjust confidence threshold to filter noise before export.` - : 'Essentia melody guide. Adjust quantize before preview/export. Per-note confidence not available in melody-guide mode.'} - {isDraft ? ' Confidence is low, so treat this clip as a draft scaffold.' : ''} - -
- - )} )}
diff --git a/apps/ui/src/components/StemListeningNotesPanel.tsx b/apps/ui/src/components/StemListeningNotesPanel.tsx new file mode 100644 index 00000000..96857f66 --- /dev/null +++ b/apps/ui/src/components/StemListeningNotesPanel.tsx @@ -0,0 +1,191 @@ +// Plain-language read of the separated stems from Gemini. +// +// This panel lives adjacent to and stays useful even +// when the pitch/note draft above is rough or unreliable. It used to live +// inline inside AnalysisResults; extracted here so the file is auditable on +// its own and the visibility helper can be reused. +// +// Notice that the section ID `section-stem-summary` is intentionally NOT on +// this component — it's owned by the wrapper
in AnalysisResults so the +// StickyNav anchor stays in one place. We only carry the test ID. + +import React from 'react'; +import type { StemSummaryResult } from '../types'; +import { truncateAtSentenceBoundary } from './analysisResultsViewModel'; +import { + formatDisplayText, + getTextRoleClassName, + type TextRole, +} from '../utils/displayText'; +import { hasStemListeningNotesContent } from '../services/sessionMusician/stemListeningNotes'; + +function textRoleClassName(role: TextRole, className = ''): string { + return [getTextRoleClassName(role), className].filter(Boolean).join(' '); +} + +interface StemListeningNotesPanelProps { + stemSummary: StemSummaryResult | null | undefined; +} + +export function StemListeningNotesPanel({ stemSummary }: StemListeningNotesPanelProps) { + if (!hasStemListeningNotesContent(stemSummary)) return null; + + // hasStemListeningNotesContent guarantees stemSummary is non-null. + const summary = stemSummary as StemSummaryResult; + const stems = Array.isArray(summary.stems) ? summary.stems : []; + const uncertaintyFlags = Array.isArray(summary.uncertaintyFlags) + ? summary.uncertaintyFlags + : []; + const topSummary = typeof summary.summary === 'string' ? summary.summary.trim() : ''; + + return ( +
+
+

+ + {formatDisplayText('Stem listening notes', 'title')} +

+ + BEST EFFORT + +
+ +
+

+ What this is for +

+

+ Plain-language descriptions of the bass and lead stems from Gemini. Reading these is the path that stays useful even when the note draft above is rough. +

+ {topSummary && ( +

+ {truncateAtSentenceBoundary(topSummary, 320)} +

+ )} +
+ + {uncertaintyFlags.length > 0 && ( +
+ {uncertaintyFlags.map((flag, index) => ( + + {flag} + + ))} +
+ )} + + {stems.length > 0 && ( +
+ {stems.map((stem) => ( +
+
+
+

+ {stem.label} +

+

+ {truncateAtSentenceBoundary(stem.summary, 220)} +

+
+ + {stem.stem} + +
+ +
+ {stem.bars.map((bar, index) => ( +
+
+ + Bars {bar.barStart}-{bar.barEnd} + + + {bar.uncertaintyLevel} certainty + +
+ {bar.noteHypotheses.length > 0 && ( +

+ Notes: {bar.noteHypotheses.join(', ')} +

+ )} + {bar.scaleDegreeHypotheses.length > 0 && ( +

+ Scale degrees: {bar.scaleDegreeHypotheses.join(', ')} +

+ )} +

+ Rhythm: {truncateAtSentenceBoundary(bar.rhythmicPattern, 180)} +

+

+ Uncertainty: {truncateAtSentenceBoundary(bar.uncertaintyReason, 180)} +

+
+ ))} +
+ +
+

+ Global pattern +

+
+

+ Bass role: {truncateAtSentenceBoundary(stem.globalPatterns.bassRole, 180)} +

+

+ Musical role: {truncateAtSentenceBoundary(stem.globalPatterns.melodicRole, 180)} +

+

+ Movement: {truncateAtSentenceBoundary(stem.globalPatterns.pumpingOrModulation, 180)} +

+

+ Synthesis: {truncateAtSentenceBoundary(stem.globalPatterns.synthesisCharacter, 180)} +

+

+ Vocal presence: {truncateAtSentenceBoundary(stem.globalPatterns.vocalPresence, 180)} +

+

+ Bass character: {truncateAtSentenceBoundary(stem.globalPatterns.bassCharacter, 180)} +

+
+
+ + {stem.uncertaintyFlags.length > 0 && ( +
+ {stem.uncertaintyFlags.map((flag, index) => ( + + {flag} + + ))} +
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/ui/src/components/sessionMusician/ConfidenceBandBadge.tsx b/apps/ui/src/components/sessionMusician/ConfidenceBandBadge.tsx new file mode 100644 index 00000000..fc5af7b5 --- /dev/null +++ b/apps/ui/src/components/sessionMusician/ConfidenceBandBadge.tsx @@ -0,0 +1,57 @@ +// Confidence band pill ("Workable draft · 72%") plus a one-sentence +// plain-language copy line beneath it. The pill and copy can be overridden +// when a render state needs custom framing (e.g. full-mix fallback, legacy +// run) — see NoteDraftBlock for the overrides. + +import React from 'react'; +import { + formatBandPillLabel, + getConfidenceBand, + type ConfidenceBand, +} from '../../services/sessionMusician/confidenceBand'; + +const PILL_CLASSES: Record = { + solid: 'border-accent/40 text-accent bg-accent/10', + workable: 'border-accent/30 text-accent bg-accent/5', + rough: 'border-warning/30 text-warning bg-warning/10', + unreliable: 'border-warning/40 text-warning bg-warning/15', +}; + +interface ConfidenceBandBadgeProps { + confidence: number; + /** When set, replaces the pill text entirely (used for fallback / legacy states). */ + overrideLabel?: string | null; + /** When set, replaces the band copy entirely. */ + overrideCopy?: string | null; + /** Optional tone override for the pill colors when overrideLabel is set. */ + overrideTone?: ConfidenceBand['id']; + /** Optional testid for jumping to it from tests. */ + testId?: string; +} + +export function ConfidenceBandBadge({ + confidence, + overrideLabel, + overrideCopy, + overrideTone, + testId, +}: ConfidenceBandBadgeProps) { + const band = getConfidenceBand(confidence); + const pillText = overrideLabel ?? formatBandPillLabel(band, confidence); + const copyText = overrideCopy ?? band.copy; + const toneId = overrideTone ?? band.id; + const pillClass = PILL_CLASSES[toneId]; + + return ( +
+ + {pillText} + +

+ {copyText} +

+
+ ); +} diff --git a/apps/ui/src/components/sessionMusician/MelodyContourBlock.tsx b/apps/ui/src/components/sessionMusician/MelodyContourBlock.tsx new file mode 100644 index 00000000..0d8017e6 --- /dev/null +++ b/apps/ui/src/components/sessionMusician/MelodyContourBlock.tsx @@ -0,0 +1,160 @@ +// Block B — Melody contour. +// +// Renders the measurement-layer Essentia melody when available. Has only two +// content-bearing render states (present / ran-with-no-result), no confidence +// slider (per-note confidence isn't available from PredominantPitchMelodia), +// and a separate MIDI filename so Ableton drag-and-drop is unambiguous. + +import React, { useMemo, useState } from 'react'; +import type { MelodyDetail } from '../../types'; +import type { QuantizeOptions } from '../../services/midi/types'; +import { quantizeNotes } from '../../services/midi/quantization'; +import { melodyNotesToDisplayNotes } from '../../services/sessionMusician/noteConversion'; +import { ConfidenceBandBadge } from './ConfidenceBandBadge'; +import { MidiControlsRow } from './MidiControlsRow'; +import { PianoRollCanvas } from './PianoRollCanvas'; +import { QuantizeControls } from './QuantizeControls'; +import type { PreviewController } from './usePreviewController'; + +export const MIDI_DOWNLOAD_FILE_NAME_MELODY = 'track-analysis-melody.mid'; + +const PIANO_ROLL_HEIGHT_MELODY = 180; + +const SUBTITLE = + 'The loudest pitched line in the full mix. Usually the vocal or lead synth, but it can drift between instruments without warning.'; + +const RAN_WITH_NO_RESULT_NOTICE = + "Melody extraction ran but didn't find a pitched line. This is normal for purely percussive tracks."; + +function formatVibratoConfidencePercent(confidence: number, present: boolean): string { + const rounded = Math.round(confidence * 100); + if (present && rounded === 0) return '< 1'; + return String(rounded); +} + +interface MelodyContourBlockProps { + melodyDetail: MelodyDetail | null | undefined; + controller: PreviewController; + bpm: number; + trackDurationSeconds: number; +} + +export function MelodyContourBlock({ + melodyDetail, + controller, + bpm, + trackDurationSeconds, +}: MelodyContourBlockProps) { + const [quantize, setQuantize] = useState({ grid: 'off', swing: 0 }); + + const displayNotesSource = useMemo( + () => melodyNotesToDisplayNotes(melodyDetail), + [melodyDetail], + ); + + const displayNotes = useMemo( + () => quantizeNotes(displayNotesSource, bpm || 120, quantize), + [bpm, displayNotesSource, quantize], + ); + + if (!melodyDetail) return null; + + const hasNotes = (melodyDetail.notes?.length ?? 0) > 0; + + const headerBlock = ( +
+

+ Melody contour +

+

{SUBTITLE}

+
+ ); + + if (!hasNotes) { + return ( +
+ {headerBlock} +

+ {RAN_WITH_NO_RESULT_NOTICE} +

+
+ ); + } + + const piano = + trackDurationSeconds && trackDurationSeconds > 0 + ? trackDurationSeconds + : Math.max( + 1, + ...displayNotes.map((note) => note.startTime + note.duration), + 1, + ); + + const noteCount = melodyDetail.notes.length; + const rangeText = + typeof melodyDetail.pitchRange?.min === 'number' && + typeof melodyDetail.pitchRange?.max === 'number' + ? `${melodyDetail.pitchRange.min} – ${melodyDetail.pitchRange.max}` + : 'n/a'; + + const vibratoLabel = melodyDetail.vibratoPresent + ? `present (${melodyDetail.vibratoRate.toFixed(1)} Hz / ${melodyDetail.vibratoExtent.toFixed(2)} cents / ${formatVibratoConfidencePercent(melodyDetail.vibratoConfidence, true)}%)` + : `not detected (${Math.round(melodyDetail.vibratoConfidence * 100)}%)`; + + return ( +
+ {headerBlock} + + + +
+ {noteCount} NOTES + | + MIDI range: {rangeText} + | + ESSENTIA MELODY + | + {melodyDetail.sourceSeparated ? 'SOURCE-SEPARATED' : 'FULL MIX'} +
+ + + + + + + +
+

+ Melody metadata +

+

+ Melody MIDI: {melodyDetail.midiFile ? 'available' : 'none'} · Vibrato: {vibratoLabel} +

+
+
+ ); +} diff --git a/apps/ui/src/components/sessionMusician/MidiControlsRow.tsx b/apps/ui/src/components/sessionMusician/MidiControlsRow.tsx new file mode 100644 index 00000000..8d637d03 --- /dev/null +++ b/apps/ui/src/components/sessionMusician/MidiControlsRow.tsx @@ -0,0 +1,91 @@ +// Inline Preview + Download buttons for a single Session Musician block. +// +// The component reads its play/stop state from the shared PreviewController +// (created once in SessionMusicianPanel) so that pressing Preview on one +// block automatically stops the other. It does NOT call usePreviewController +// internally. + +import React, { useCallback } from 'react'; +import { Download, Play, Square } from 'lucide-react'; +import { downloadMidiFile } from '../../services/midi/midiExport'; +import type { MidiDisplayNote } from '../../services/midi/types'; +import type { PreviewController, PreviewId } from './usePreviewController'; + +interface MidiControlsRowProps { + notes: MidiDisplayNote[]; + bpm: number; + previewId: PreviewId; + previewLabel: string; + downloadLabel: string; + downloadFilename: string; + controller: PreviewController; + /** Hides the Download button entirely (used when we have no notes to export). */ + hideDownload?: boolean; + /** Hint shown on hover. */ + downloadTitle?: string; + /** Optional aria-label override for the Download button. */ + downloadAriaLabel?: string; +} + +export function MidiControlsRow({ + notes, + bpm, + previewId, + previewLabel, + downloadLabel, + downloadFilename, + controller, + hideDownload = false, + downloadTitle, + downloadAriaLabel, +}: MidiControlsRowProps) { + const hasNotes = notes.length > 0; + const isThisActive = controller.activePreviewId === previewId; + const disabled = !hasNotes; + + const handlePreview = useCallback(() => { + if (isThisActive) { + controller.stop(); + return; + } + controller.start(previewId, notes); + }, [controller, isThisActive, notes, previewId]); + + const handleDownload = useCallback(() => { + if (!notes.length) return; + downloadMidiFile(notes, bpm, downloadFilename); + }, [bpm, downloadFilename, notes]); + + const previewTestId = `midi-preview-${previewId}`; + const downloadTestId = `midi-download-${previewId}`; + + return ( +
+ + {!hideDownload && ( + + )} +
+ ); +} diff --git a/apps/ui/src/components/sessionMusician/NoteDraftBlock.tsx b/apps/ui/src/components/sessionMusician/NoteDraftBlock.tsx new file mode 100644 index 00000000..5f0fa60d --- /dev/null +++ b/apps/ui/src/components/sessionMusician/NoteDraftBlock.tsx @@ -0,0 +1,318 @@ +// Block A — Stem note draft. +// +// One component, six render states; the state matrix is driven by +// deriveNoteDraftRenderState(). Each render state has its own header subtitle, +// optional notice copy, and decides whether the piano roll, stem filter, +// confidence slider, and Download button appear. See the plan in +// .claude/plans/re-evaluate-asa-s-session-musician-* and the explanatory copy +// at the top of services/sessionMusician/renderState.ts for the load-bearing +// precedence rules. + +import React, { useMemo, useState } from 'react'; +import type { TranscriptionDetail } from '../../types'; +import type { QuantizeOptions } from '../../services/midi/types'; +import { quantizeNotes } from '../../services/midi/quantization'; +import { + deriveNoteDraftRenderState, + type NoteDraftRenderState, + type PitchNoteMode, +} from '../../services/sessionMusician/renderState'; +import { transcriptionNotesToDisplayNotes } from '../../services/sessionMusician/noteConversion'; +import { ConfidenceBandBadge } from './ConfidenceBandBadge'; +import { MidiControlsRow } from './MidiControlsRow'; +import { PianoRollCanvas } from './PianoRollCanvas'; +import { QuantizeControls } from './QuantizeControls'; +import type { PreviewController } from './usePreviewController'; + +export const MIDI_DOWNLOAD_FILE_NAME_STEMS = 'track-analysis-stems.mid'; + +const DEFAULT_CONFIDENCE_THRESHOLD = 0.2; + +const SUBTITLE_BY_STATE: Record = { + 'stem-aware': 'Bass and lead, transcribed from Demucs-separated stems.', + 'full-mix-fallback': "Full-mix fallback — stem separation didn't run cleanly.", + legacy: 'Legacy run — produced before the current torchcrepe pipeline.', + 'ran-with-no-result': "Ran but didn't find a pitched line.", + 'requested-but-unavailable': + "Requested, but the stage didn't produce output.", + absent: '', +}; + +const NOTICE_BY_STATE: Partial> = { + 'full-mix-fallback': + 'Pitch was tracked across the entire mix instead of stems, so notes drift between instruments. Treat the pitch range and most-frequent notes as scale hints. The piano roll below is approximate.', + legacy: + 'Re-analyze for current stem-aware quality. The notes below are kept for archival. Pitch range and most-played notes are still informative.', + 'ran-with-no-result': + "Pitch/note translation ran but didn't find a pitched line in the stems. Check the stem listening notes below for what the stems actually contain.", + 'requested-but-unavailable': + 'Stem note draft was requested but the pitch/note translation stage produced no output. The stage may have failed or the audio may not have a pitched line in the stems. Check the stem listening notes below, or re-run.', +}; + +const STAYS_USEFUL_HEADLINE = + 'Even at low confidence, these signals stay useful:'; +const STAYS_USEFUL_DETAIL = + 'Pitch range tells you how to set up your MIDI track and synth range. The most-played notes hint at the scale to play along in. Note durations are still a rhythmic grid.'; + +function formatPitchNoteMethodLabel(method: string | null | undefined): string { + const normalized = (method ?? '').trim().toLowerCase(); + if (normalized === 'torchcrepe-viterbi' || normalized === 'torchcrepe') { + return 'TORCHCREPE'; + } + if ( + normalized === 'basic-pitch' || + normalized === 'basic_pitch' || + normalized === 'basic-pitch-legacy' + ) { + return 'BASIC PITCH (LEGACY)'; + } + if (!normalized) return 'PITCH/NOTE EXTRACTION'; + return (method ?? '').replace(/[_-]+/g, ' ').toUpperCase(); +} + +function averageNoteDuration(transcription: TranscriptionDetail): string { + const total = transcription.notes.reduce((sum, note) => sum + note.durationSeconds, 0); + if (transcription.notes.length === 0) return '0.00s'; + return `${(total / transcription.notes.length).toFixed(2)}s`; +} + +interface NoteDraftBlockProps { + transcriptionDetail: TranscriptionDetail | null | undefined; + pitchNoteMode: PitchNoteMode; + controller: PreviewController; + bpm: number; + /** Used to scale the piano-roll x-axis; defaults to last-note-end when missing. */ + trackDurationSeconds: number; +} + +export function NoteDraftBlock({ + transcriptionDetail, + pitchNoteMode, + controller, + bpm, + trackDurationSeconds, +}: NoteDraftBlockProps) { + const renderState = useMemo( + () => deriveNoteDraftRenderState(transcriptionDetail, pitchNoteMode), + [transcriptionDetail, pitchNoteMode], + ); + + const [stemFilter, setStemFilter] = useState(null); + const [confidenceThreshold, setConfidenceThreshold] = useState(DEFAULT_CONFIDENCE_THRESHOLD); + const [quantize, setQuantize] = useState({ grid: 'off', swing: 0 }); + + const sourceNotes = useMemo( + () => transcriptionNotesToDisplayNotes(transcriptionDetail, stemFilter), + [stemFilter, transcriptionDetail], + ); + + // Confidence slider only applies in stem-aware mode; otherwise pass through. + const filteredNotes = useMemo(() => { + if (renderState !== 'stem-aware') return sourceNotes; + return sourceNotes.filter((note) => note.confidence >= confidenceThreshold); + }, [confidenceThreshold, renderState, sourceNotes]); + + const displayNotes = useMemo( + () => quantizeNotes(filteredNotes, bpm || 120, quantize), + [bpm, filteredNotes, quantize], + ); + + if (renderState === 'absent') return null; + + const subtitle = SUBTITLE_BY_STATE[renderState]; + const notice = NOTICE_BY_STATE[renderState]; + const headerBlock = ( +
+

+ Stem note draft +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+ ); + + // Non-content states: render header + notice only. + if (renderState === 'requested-but-unavailable' || renderState === 'ran-with-no-result') { + return ( +
+ {headerBlock} + {notice && ( +

{notice}

+ )} +
+ ); + } + + // From here on we know transcriptionDetail is populated with at least one note. + if (!transcriptionDetail) return null; + + const filteredCount = displayNotes.length; + const sourceCount = sourceNotes.length; + const showFilterRatio = renderState === 'stem-aware' && confidenceThreshold > 0; + const countLabel = showFilterRatio + ? `${filteredCount} / ${sourceCount} NOTES` + : `${sourceCount} NOTES`; + + const methodLabel = formatPitchNoteMethodLabel(transcriptionDetail.transcriptionMethod); + const dominantNames = transcriptionDetail.dominantPitches + .map((entry) => entry.pitchName) + .slice(0, 4); + const rangeText = + transcriptionDetail.pitchRange.minName && transcriptionDetail.pitchRange.maxName + ? `${transcriptionDetail.pitchRange.minName} – ${transcriptionDetail.pitchRange.maxName}` + : 'n/a'; + const avgLength = averageNoteDuration(transcriptionDetail); + + const piano = trackDurationSeconds && trackDurationSeconds > 0 ? trackDurationSeconds : Math.max( + 1, + ...displayNotes.map((note) => note.startTime + note.duration), + 1, + ); + + const isOverride = renderState === 'full-mix-fallback' || renderState === 'legacy'; + const bandOverrideLabel = + renderState === 'full-mix-fallback' + ? 'Full-mix fallback' + : renderState === 'legacy' + ? 'Legacy run' + : undefined; + const bandOverrideCopy = isOverride ? (notice ?? null) : undefined; + const bandOverrideTone = isOverride ? ('rough' as const) : undefined; + + // 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 = + renderState === 'stem-aware' && sourceCount > 0 && filteredCount === 0; + + return ( +
+ {headerBlock} + + + +
+ {countLabel} + | + Range: {rangeText} + | + {methodLabel} + | + + {transcriptionDetail.stemSeparationUsed && !transcriptionDetail.fullMixFallback + ? 'STEM-AWARE' + : 'FULL MIX'} + +
+ + {renderState === 'stem-aware' && transcriptionDetail.stemsTranscribed.length > 0 && ( +
+ Stems: + {transcriptionDetail.stemsTranscribed.map((stem) => ( + + ))} + {stemFilter && ( + + )} +
+ )} + + + + {renderState === 'stem-aware' && ( +
+ Confidence + setConfidenceThreshold(Number(event.target.value))} + className="w-24 h-1 accent-accent" + /> + + {Math.round(confidenceThreshold * 100)}% + +
+ )} + + {sliderEmptiedNotes && ( +

+ Confidence slider filtered every note. Lower the threshold to see and export anything. +

+ )} + + + + + +
+

+ {STAYS_USEFUL_HEADLINE} +

+

+ {STAYS_USEFUL_DETAIL} +

+
+ + Range: {rangeText} + + {dominantNames.length > 0 && ( + + Most-played: {dominantNames.join(', ')} + + )} + + Avg note length: {avgLength} + +
+
+
+ ); +} diff --git a/apps/ui/src/components/sessionMusician/PianoRollCanvas.tsx b/apps/ui/src/components/sessionMusician/PianoRollCanvas.tsx new file mode 100644 index 00000000..85c02077 --- /dev/null +++ b/apps/ui/src/components/sessionMusician/PianoRollCanvas.tsx @@ -0,0 +1,149 @@ +// Presentational piano-roll renderer shared by the two Session Musician blocks. +// Owns the canvas, the ResizeObserver, and the drawing logic — but no note +// data, no quantize, no MIDI export. It just paints what it's given. + +import React, { useEffect, useRef } from 'react'; +import type { MidiDisplayNote } from '../../services/midi/types'; +import { midiToNoteName } from '../../services/sessionMusician/noteConversion'; + +const NOTE_COLORS = { + fill: '#ff8800', + fillHigh: '#ffb14d', + fillLow: '#664526', + stroke: '#e67e22', + grid: '#262626', + text: '#9ca3af', + bg: '#101010', +}; + +const KEY_WIDTH = 40; +const DEFAULT_HEIGHT = 240; + +function drawPianoRoll(canvas: HTMLCanvasElement, notes: MidiDisplayNote[], duration: number) { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + + const width = rect.width; + const height = rect.height; + + ctx.fillStyle = NOTE_COLORS.bg; + ctx.fillRect(0, 0, width, height); + + if (!notes.length) { + ctx.fillStyle = NOTE_COLORS.text; + ctx.font = '12px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.fillText('No notes detected', width / 2, height / 2); + return; + } + + const midiValues = notes.map((note) => note.midi); + const minMidi = Math.max(0, Math.min(...midiValues) - 2); + const maxMidi = Math.min(127, Math.max(...midiValues) + 2); + const range = Math.max(1, maxMidi - minMidi); + const plotX = KEY_WIDTH; + const plotWidth = width - KEY_WIDTH; + const noteHeight = Math.max(3, height / range); + + ctx.font = '9px ui-monospace, monospace'; + ctx.textAlign = 'right'; + for (let midi = minMidi; midi <= maxMidi; midi += 1) { + const y = height - ((midi - minMidi) / range) * height; + ctx.strokeStyle = midi % 12 === 0 ? '#424242' : NOTE_COLORS.grid; + ctx.lineWidth = midi % 12 === 0 ? 1 : 0.5; + ctx.beginPath(); + ctx.moveTo(plotX, y); + ctx.lineTo(width, y); + ctx.stroke(); + + if (midi % 12 === 0 || range <= 24) { + ctx.fillStyle = NOTE_COLORS.text; + ctx.fillText(midiToNoteName(midi), KEY_WIDTH - 4, y + 3); + } + } + + const secondsStep = duration > 30 ? 5 : duration > 10 ? 2 : 1; + for (let sec = 0; sec <= duration; sec += secondsStep) { + const x = plotX + (sec / duration) * plotWidth; + ctx.strokeStyle = NOTE_COLORS.grid; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); + + ctx.fillStyle = '#5f5f5f'; + ctx.font = '8px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.fillText(`${sec}s`, x, height - 2); + } + + for (const note of notes) { + const x = plotX + (note.startTime / duration) * plotWidth; + const widthPx = Math.max(2, (note.duration / duration) * plotWidth); + const y = height - ((note.midi - minMidi) / range) * height - noteHeight / 2; + + const alpha = 0.4 + note.confidence * 0.6; + ctx.globalAlpha = alpha; + ctx.fillStyle = + note.confidence > 0.7 + ? NOTE_COLORS.fillHigh + : note.confidence > 0.3 + ? NOTE_COLORS.fill + : NOTE_COLORS.fillLow; + ctx.fillRect(x, y, widthPx, Math.max(2, noteHeight - 1)); + + ctx.globalAlpha = 1; + ctx.strokeStyle = NOTE_COLORS.stroke; + ctx.lineWidth = 0.5; + ctx.strokeRect(x, y, widthPx, Math.max(2, noteHeight - 1)); + } +} + +interface PianoRollCanvasProps { + notes: MidiDisplayNote[]; + duration: number; + height?: number; + testId?: string; +} + +export function PianoRollCanvas({ + notes, + duration, + height = DEFAULT_HEIGHT, + testId, +}: PianoRollCanvasProps) { + const canvasRef = useRef(null); + + useEffect(() => { + if (!canvasRef.current) return; + drawPianoRoll(canvasRef.current, notes, duration); + }, [notes, duration]); + + useEffect(() => { + if (!canvasRef.current) return; + const canvas = canvasRef.current; + const observer = new ResizeObserver(() => { + drawPianoRoll(canvas, notes, duration); + }); + observer.observe(canvas); + return () => observer.disconnect(); + }, [notes, duration]); + + return ( +
+ +
+ ); +} diff --git a/apps/ui/src/components/sessionMusician/QuantizeControls.tsx b/apps/ui/src/components/sessionMusician/QuantizeControls.tsx new file mode 100644 index 00000000..3adab12d --- /dev/null +++ b/apps/ui/src/components/sessionMusician/QuantizeControls.tsx @@ -0,0 +1,62 @@ +// Quantize grid + swing slider — shared between the stem-note draft block and +// the melody contour block. Each block owns its own QuantizeOptions state so +// the two grids are independent. This component is purely controlled. + +import React from 'react'; +import { Grid3X3, SlidersHorizontal } from 'lucide-react'; +import { gridLabel } from '../../services/midi/quantization'; +import type { QuantizeGrid, QuantizeOptions } from '../../services/midi/types'; + +const GRID_OPTIONS: QuantizeGrid[] = ['off', '1/4', '1/8', '1/16', '1/32']; + +interface QuantizeControlsProps { + value: QuantizeOptions; + onChange: (next: QuantizeOptions) => void; + disabled?: boolean; +} + +export function QuantizeControls({ value, onChange, disabled = false }: QuantizeControlsProps) { + const swingDisabled = disabled || value.grid === 'off'; + return ( +
+
+ + Quantize +
+ +
+ {GRID_OPTIONS.map((grid) => ( + + ))} +
+ +
+ + Swing + onChange({ ...value, swing: Number(event.target.value) })} + disabled={swingDisabled} + className="w-20 h-1 accent-accent disabled:opacity-30" + /> + + {value.swing}% + +
+
+ ); +} diff --git a/apps/ui/src/components/sessionMusician/usePreviewController.ts b/apps/ui/src/components/sessionMusician/usePreviewController.ts new file mode 100644 index 00000000..85948def --- /dev/null +++ b/apps/ui/src/components/sessionMusician/usePreviewController.ts @@ -0,0 +1,69 @@ +// Single shared MIDI preview controller for the Session Musician panel. +// +// One remote control, not two: the hook is called ONCE in SessionMusicianPanel +// and the returned PreviewController is passed down to both MidiControlsRow +// instances (stems + melody). Starting a preview on one block automatically +// stops the other; closing the panel or unmounting tears everything down. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { previewNotes, type PreviewHandle } from '../../services/midi/midiPreview'; +import type { MidiDisplayNote } from '../../services/midi/types'; + +export type PreviewId = 'stems' | 'melody'; + +export interface PreviewController { + activePreviewId: PreviewId | null; + isPlaying: boolean; + start(id: PreviewId, notes: MidiDisplayNote[], onEnd?: () => void): void; + stop(): void; +} + +export function usePreviewController(): PreviewController { + const handleRef = useRef(null); + const [activePreviewId, setActivePreviewId] = useState(null); + + const stop = useCallback(() => { + handleRef.current?.stop(); + handleRef.current = null; + setActivePreviewId(null); + }, []); + + const start = useCallback( + (id: PreviewId, notes: MidiDisplayNote[], onEnd?: () => void) => { + // Always stop the previous preview before starting a new one. This is + // what prevents two simultaneous previews from playing if the user + // clicks Block B while Block A is still playing. + handleRef.current?.stop(); + handleRef.current = null; + + if (!notes.length) { + setActivePreviewId(null); + return; + } + + const handle = previewNotes(notes, () => { + handleRef.current = null; + setActivePreviewId(null); + onEnd?.(); + }); + handleRef.current = handle; + setActivePreviewId(id); + }, + [], + ); + + // Tear down on unmount so an in-progress preview doesn't leak past the panel. + useEffect(() => { + return () => { + handleRef.current?.stop(); + handleRef.current = null; + }; + }, []); + + return { + activePreviewId, + isPlaying: activePreviewId !== null, + start, + stop, + }; +} diff --git a/apps/ui/src/services/sessionMusician/confidenceBand.ts b/apps/ui/src/services/sessionMusician/confidenceBand.ts new file mode 100644 index 00000000..8d2ea0ec --- /dev/null +++ b/apps/ui/src/services/sessionMusician/confidenceBand.ts @@ -0,0 +1,60 @@ +// Confidence-band semantics for the Session Musician panel. +// +// The four bands translate a raw 0-1 confidence number into producer action: +// what the label is, what to do with the data, and the audit-friendly percent. +// Both the stem note draft (Block A) and the melody contour (Block B) use the +// same ladder; the block's subtitle establishes which signal the band applies +// to. + +export type ConfidenceBandId = 'solid' | 'workable' | 'rough' | 'unreliable'; + +export interface ConfidenceBand { + id: ConfidenceBandId; + label: string; + copy: string; + minInclusive: number; +} + +// Sorted descending by minInclusive so the first match wins. The last entry +// has minInclusive: 0 so any finite number lands somewhere. +const BANDS: readonly ConfidenceBand[] = [ + { + id: 'solid', + label: 'Solid scaffold', + copy: "Notes look reliable. Expect light cleanup in Ableton's piano roll.", + minInclusive: 0.8, + }, + { + id: 'workable', + label: 'Workable draft', + copy: 'Most notes are in the right ballpark. Plan to redraw the trickiest bars.', + minInclusive: 0.5, + }, + { + id: 'rough', + label: 'Rough sketch', + copy: 'Pitch tracking caught the general shape. Treat this as a rhythm grid more than note truth.', + minInclusive: 0.25, + }, + { + id: 'unreliable', + label: 'Unreliable', + copy: "The pitch detector wasn't confident. Use the pitch range and most-played notes as scale hints — don't trust specific notes.", + minInclusive: 0, + }, +]; + +export function getConfidenceBand(confidence: number): ConfidenceBand { + const safe = Number.isFinite(confidence) ? confidence : 0; + for (const band of BANDS) { + if (safe >= band.minInclusive) return band; + } + // Defensive fallback. Unreachable because the last band has minInclusive: 0. + return BANDS[BANDS.length - 1]; +} + +export function formatBandPillLabel(band: ConfidenceBand, confidence: number): string { + const safe = Number.isFinite(confidence) ? confidence : 0; + const percent = Math.max(0, Math.min(100, Math.round(safe * 100))); + return `${band.label} · ${percent}%`; +} diff --git a/apps/ui/src/services/sessionMusician/index.ts b/apps/ui/src/services/sessionMusician/index.ts new file mode 100644 index 00000000..81049486 --- /dev/null +++ b/apps/ui/src/services/sessionMusician/index.ts @@ -0,0 +1,18 @@ +export { + formatBandPillLabel, + getConfidenceBand, + type ConfidenceBand, + type ConfidenceBandId, +} from './confidenceBand'; +export { + melodyNotesToDisplayNotes, + midiToNoteName, + transcriptionNotesToDisplayNotes, +} from './noteConversion'; +export { hasStemListeningNotesContent } from './stemListeningNotes'; +export { + deriveNoteDraftRenderState, + isLegacyTranscriptionMethod, + type NoteDraftRenderState, + type PitchNoteMode, +} from './renderState'; diff --git a/apps/ui/src/services/sessionMusician/noteConversion.ts b/apps/ui/src/services/sessionMusician/noteConversion.ts new file mode 100644 index 00000000..0883d324 --- /dev/null +++ b/apps/ui/src/services/sessionMusician/noteConversion.ts @@ -0,0 +1,51 @@ +// Pure converters from backend pitch/note shapes to the panel's display note. +// +// These were previously inlined in SessionMusicianPanel.tsx and duplicated +// per-source. Extracting them lets the two new block components share a single +// implementation and lets unit tests assert the conversion contract directly. + +import type { MelodyDetail, TranscriptionDetail } from '../../types'; +import type { MidiDisplayNote } from '../midi/types'; + +const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] as const; + +export function midiToNoteName(midi: number): string { + const clamped = Math.max(0, Math.min(127, Math.round(midi))); + const octave = Math.floor(clamped / 12) - 1; + return `${NOTE_NAMES[clamped % 12]}${octave}`; +} + +export function transcriptionNotesToDisplayNotes( + transcriptionDetail: TranscriptionDetail | null | undefined, + activeStemFilter: string | null = null, +): MidiDisplayNote[] { + if (!transcriptionDetail?.notes?.length) return []; + const filtered = activeStemFilter + ? transcriptionDetail.notes.filter((note) => note.stemSource === activeStemFilter) + : transcriptionDetail.notes; + return filtered.map((note) => ({ + midi: note.pitchMidi, + name: note.pitchName, + startTime: note.onsetSeconds, + duration: note.durationSeconds, + velocity: 90, + confidence: note.confidence, + })); +} + +export function melodyNotesToDisplayNotes( + melodyDetail: MelodyDetail | null | undefined, +): MidiDisplayNote[] { + if (!melodyDetail?.notes?.length) return []; + // Per-note confidence isn't available from Essentia's PredominantPitchMelodia; + // the scalar pitchConfidence applies to the whole contour, so every emitted + // display note carries it. + return melodyDetail.notes.map((note) => ({ + midi: note.midi, + name: midiToNoteName(note.midi), + startTime: note.onset, + duration: note.duration, + velocity: 90, + confidence: melodyDetail.pitchConfidence, + })); +} diff --git a/apps/ui/src/services/sessionMusician/renderState.ts b/apps/ui/src/services/sessionMusician/renderState.ts new file mode 100644 index 00000000..089731ca --- /dev/null +++ b/apps/ui/src/services/sessionMusician/renderState.ts @@ -0,0 +1,73 @@ +// Render-state derivation for the Session Musician note-draft block (Block A). +// +// The precedence-ordered table here is load-bearing. The first rule that +// matches wins — in particular: +// - Rule 1: an explicit user opt-out trumps every cached transcription. +// Stale notes must never sneak back onto the screen after the toggle is +// turned off. +// - Rule 5: a legacy method (anything not torchcrepe) wins over the +// full-mix-fallback flag. Re-analyzing the audio is the actionable fix, +// so the "legacy run" framing should override the fallback framing even +// when both apply. +// +// The function is pure so it can be unit-tested as a matrix. + +import type { TranscriptionDetail } from '../../types'; + +export type PitchNoteMode = 'stem_notes' | 'off' | null; + +export type NoteDraftRenderState = + | 'stem-aware' + | 'full-mix-fallback' + | 'legacy' + | 'ran-with-no-result' + | 'requested-but-unavailable' + | 'absent'; + +const TORCHCREPE_METHODS: ReadonlySet = new Set([ + 'torchcrepe', + 'torchcrepe-viterbi', +]); + +export function isLegacyTranscriptionMethod( + method: string | null | undefined, +): boolean { + if (typeof method !== 'string') return false; + const normalized = method.trim().toLowerCase(); + if (!normalized) return false; + return !TORCHCREPE_METHODS.has(normalized); +} + +export function deriveNoteDraftRenderState( + transcriptionDetail: TranscriptionDetail | null | undefined, + pitchNoteMode: PitchNoteMode, +): NoteDraftRenderState { + // Rule 1: explicit opt-out wins, regardless of any stale cached data. + if (pitchNoteMode === 'off') return 'absent'; + + // Rules 2 & 3: missing payload. + if (!transcriptionDetail) { + return pitchNoteMode === 'stem_notes' ? 'requested-but-unavailable' : 'absent'; + } + + // Rule 4: payload arrived but contains no actual notes. Use the array length + // — not the cosmetic noteCount field — because only real notes can be drawn + // or exported. + if (!Array.isArray(transcriptionDetail.notes) || transcriptionDetail.notes.length === 0) { + return 'ran-with-no-result'; + } + + // Rule 5: legacy method wins over fallback. A re-analyze action is what the + // user actually needs, and "Legacy run" copy is more useful than "Fallback". + if (isLegacyTranscriptionMethod(transcriptionDetail.transcriptionMethod)) { + return 'legacy'; + } + + // Rule 6: stem separation didn't run cleanly; pitch came from the full mix. + if (transcriptionDetail.fullMixFallback === true) { + return 'full-mix-fallback'; + } + + // Rule 7: nominal stem-aware torchcrepe output. + return 'stem-aware'; +} diff --git a/apps/ui/src/services/sessionMusician/stemListeningNotes.ts b/apps/ui/src/services/sessionMusician/stemListeningNotes.ts new file mode 100644 index 00000000..f22fae5d --- /dev/null +++ b/apps/ui/src/services/sessionMusician/stemListeningNotes.ts @@ -0,0 +1,31 @@ +// Centralised visibility check for the Gemini stem listening notes section. +// +// Used by AnalysisResults.tsx for three things at once: +// 1. Whether to include the "Stem Notes" entry in the StickyNav +// 2. Whether to render +// 3. Whether to pass hasStemListeningNotes={true} to +// so the off-state banner can cross-link to the section +// +// The render gate counts a Gemini envelope as having content when ANY of the +// three populatable fields is non-empty — Gemini sometimes returns a top-line +// summary without per-stem cards, or only uncertainty flags, and the panel +// should still surface what's there. + +import type { StemSummaryResult } from '../../types'; + +export function hasStemListeningNotesContent( + stemSummary: StemSummaryResult | null | undefined, +): boolean { + if (!stemSummary) return false; + if (Array.isArray(stemSummary.stems) && stemSummary.stems.length > 0) return true; + if (typeof stemSummary.summary === 'string' && stemSummary.summary.trim().length > 0) { + return true; + } + if ( + Array.isArray(stemSummary.uncertaintyFlags) && + stemSummary.uncertaintyFlags.length > 0 + ) { + return true; + } + return false; +} diff --git a/apps/ui/tests/e2e/analysis-runs-integration.spec.ts b/apps/ui/tests/e2e/analysis-runs-integration.spec.ts index 5466ef49..9be92bd5 100644 --- a/apps/ui/tests/e2e/analysis-runs-integration.spec.ts +++ b/apps/ui/tests/e2e/analysis-runs-integration.spec.ts @@ -69,7 +69,10 @@ test('local integration flow uses canonical analysis-runs routes without Gemini expect(artifactKinds).toContain('spectrogram_mel'); expect(artifactKinds).toContain('spectral_time_series'); + // The stem-aware download is named track-analysis-stems.mid since the + // per-block downloads landed; Block B's "Download melody .mid" doesn't match + // the /Download \.mid/i regex. const midiArtifact = await downloadBinaryArtifact(page, /Download \.mid/i); - expect(midiArtifact.download.suggestedFilename()).toBe('track-analysis.mid'); + expect(midiArtifact.download.suggestedFilename()).toBe('track-analysis-stems.mid'); expect(midiArtifact.sizeBytes).toBeGreaterThan(0); }); diff --git a/apps/ui/tests/e2e/phase1-exports.spec.ts b/apps/ui/tests/e2e/phase1-exports.spec.ts index 237159c8..eef204be 100644 --- a/apps/ui/tests/e2e/phase1-exports.spec.ts +++ b/apps/ui/tests/e2e/phase1-exports.spec.ts @@ -63,8 +63,11 @@ test('live golden path uploads the external track and reviews the full analysis expect(markdownArtifact.text).toContain('## Phase 1 Metadata'); expect(markdownArtifact.text).toContain('## Phase 2'); + // /Download \.mid/i matches Block A only (Block B's button reads + // "Download melody .mid"). The stem-aware export filename changed when + // per-block downloads were introduced. const midiArtifact = await downloadBinaryArtifact(page, /Download \.mid/i); - expect(midiArtifact.download.suggestedFilename()).toBe('track-analysis.mid'); + expect(midiArtifact.download.suggestedFilename()).toBe('track-analysis-stems.mid'); expect(midiArtifact.sizeBytes).toBeGreaterThan(0); const latestSnapshot = await fetchAnalysisRun(runId); diff --git a/apps/ui/tests/e2e/session-musician.spec.ts b/apps/ui/tests/e2e/session-musician.spec.ts index 583e1839..3e596ace 100644 --- a/apps/ui/tests/e2e/session-musician.spec.ts +++ b/apps/ui/tests/e2e/session-musician.spec.ts @@ -64,7 +64,9 @@ test('live artifact review generates spectral enhancements and keeps Session Mus expect(enhancementKinds).toContain('onset_strength'); expect(enhancementKinds).toContain('spectrogram_onset'); + // /Download \.mid/i matches the Block A "Download .mid" button (stem-aware). + // Block B's "Download melody .mid" doesn't match the regex. const midiArtifact = await downloadBinaryArtifact(page, /Download \.mid/i); - expect(midiArtifact.download.suggestedFilename()).toBe('track-analysis.mid'); + expect(midiArtifact.download.suggestedFilename()).toBe('track-analysis-stems.mid'); expect(midiArtifact.sizeBytes).toBeGreaterThan(0); }); diff --git a/apps/ui/tests/services/analysisResultsUi.test.ts b/apps/ui/tests/services/analysisResultsUi.test.ts index 24bbf119..f60a7d45 100644 --- a/apps/ui/tests/services/analysisResultsUi.test.ts +++ b/apps/ui/tests/services/analysisResultsUi.test.ts @@ -539,7 +539,7 @@ describe('AnalysisResults UI wiring', () => { expect(html).toContain('Draft — AI interpretation is incomplete or unavailable.'); }); - it('renders stem summary cards next to Session Musician with plain-language labels', () => { + it('renders stem listening notes adjacent to Session Musician with plain-language labels', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults as React.ComponentType>, { phase1: { @@ -552,8 +552,13 @@ describe('AnalysisResults UI wiring', () => { }), ); - expect(html).toContain('Draft notes for MIDI cleanup'); - expect(html).toContain('AI stem summary for musical understanding'); + // The two panels are paired inside a local wrapper. + expect(html).toContain('id="section-musician-suite"'); + expect(html).toContain('id="section-session"'); + expect(html).toContain('id="section-stem-summary"'); + // Stem listening notes panel renders with its new header. + expect(html).toContain('data-testid="stem-listening-notes-panel"'); + expect(html).toContain('Stem Listening Notes'); expect(html).toContain('Bass stem'); expect(html).toContain('Musical stem'); expect(html).toContain('Upper melodic detail is approximate.'); @@ -1221,7 +1226,7 @@ describe('AnalysisResults UI wiring', () => { expect(MIDI_DOWNLOAD_FILE_NAME).toBe('track-analysis.mid'); }); - it('renders pitch/note unavailable state when melodyDetail is missing', () => { + it('renders the pitch & melody unavailable state when neither source is present', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { phase1: baseMeasurement, @@ -1231,10 +1236,11 @@ describe('AnalysisResults UI wiring', () => { ); expect(html).toContain('PITCH & MELODY UNAVAILABLE'); - expect(html).toContain('Run with pitch/note translation enabled, or ensure melodyDetail is present in the DSP payload for a melody guide'); + expect(html).toContain('Neither stem-aware transcription nor a measurement-layer melody contour'); + expect(html).toContain('data-testid="session-musician-no-data"'); }); - it('shows the pitch/note toggle state by default when both sources are available', () => { + it('renders both Block A (stem note draft) and Block B (melody contour) simultaneously when both sources are available', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { phase1: { @@ -1259,23 +1265,23 @@ describe('AnalysisResults UI wiring', () => { }), ); - expect(html).toContain('PITCH/NOTE'); - expect(html).toContain('MELODY'); - expect(html).toContain('TORCHCREPE pitch detection'); - expect(html).toContain('Range: C3 - G4'); - expect(html).toContain('Confidence: 83%'); - expect(html.match(/Range: C3 - G4/g)?.length ?? 0).toBe(1); - expect(html.match(/Confidence: 83%/g)?.length ?? 0).toBe(1); - expect(html).toContain('2 / 2 NOTES'); - expect(html).toContain('CONFIDENCE'); - expect(html).toContain('20%'); - expect(html).toContain('PITCH/NOTE: TORCHCREPE'); + // Both blocks and both downloads render at once — no toggle. + expect(html).toContain('data-testid="note-draft-block"'); + expect(html).toContain('data-testid="melody-contour-block"'); + expect(html).toContain('data-testid="midi-download-stems"'); + expect(html).toContain('data-testid="midi-download-melody"'); + // Stem-aware specifics + expect(html).toContain('data-render-state="stem-aware"'); + expect(html).toContain('TORCHCREPE'); expect(html).toContain('STEM-AWARE'); - expect(html).toContain('Adjust confidence threshold to filter noise before export.'); + // Band pill uses "label · NN%" format + expect(html).toContain('Solid scaffold · 83%'); + // Melody block uses its Unreliable band copy at 20% confidence + expect(html).toContain('Unreliable · 20%'); expect(html).not.toContain('PITCH & MELODY UNAVAILABLE'); }); - it('shows Essentia source badges when only melodyDetail is available', () => { + it('shows the Essentia melody block when only melodyDetail is available', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { phase1: { @@ -1303,10 +1309,15 @@ describe('AnalysisResults UI wiring', () => { }), ); - expect(html).not.toContain('PITCH/NOTE: TORCHCREPE'); - expect(html).not.toContain('TORCHCREPE pitch detection'); - expect(html).toContain('MELODY GUIDE: ESSENTIA'); - expect(html).toContain('Essentia melody guide.'); + // No stem note draft (no transcriptionDetail) + expect(html).not.toContain('data-testid="note-draft-block"'); + expect(html).not.toContain('data-testid="midi-download-stems"'); + // Melody contour block renders with the right metadata + expect(html).toContain('data-testid="melody-contour-block"'); + expect(html).toContain('data-testid="midi-download-melody"'); + expect(html).toContain('ESSENTIA MELODY'); + expect(html).toContain('SOURCE-SEPARATED'); + expect(html).toContain('Workable draft · 72%'); }); it('renders full-mix provenance when transcription did not use Demucs stems', () => { diff --git a/apps/ui/tests/services/confidenceBand.test.ts b/apps/ui/tests/services/confidenceBand.test.ts new file mode 100644 index 00000000..0a735f96 --- /dev/null +++ b/apps/ui/tests/services/confidenceBand.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatBandPillLabel, + getConfidenceBand, +} from '../../src/services/sessionMusician/confidenceBand'; + +describe('getConfidenceBand', () => { + it('returns unreliable for 0.0', () => { + expect(getConfidenceBand(0).id).toBe('unreliable'); + }); + + it('returns unreliable just below the rough threshold (0.249)', () => { + expect(getConfidenceBand(0.249).id).toBe('unreliable'); + }); + + it('returns rough at the inclusive 0.25 threshold', () => { + expect(getConfidenceBand(0.25).id).toBe('rough'); + }); + + it('returns rough just below the workable threshold (0.499)', () => { + expect(getConfidenceBand(0.499).id).toBe('rough'); + }); + + it('returns workable at the inclusive 0.50 threshold', () => { + expect(getConfidenceBand(0.5).id).toBe('workable'); + }); + + it('returns workable just below the solid threshold (0.799)', () => { + expect(getConfidenceBand(0.799).id).toBe('workable'); + }); + + it('returns solid at the inclusive 0.80 threshold', () => { + expect(getConfidenceBand(0.8).id).toBe('solid'); + }); + + it('returns solid for 1.0', () => { + expect(getConfidenceBand(1).id).toBe('solid'); + }); + + it('treats NaN as unreliable rather than throwing', () => { + expect(getConfidenceBand(Number.NaN).id).toBe('unreliable'); + }); + + it('returns the producer-facing label for the solid band', () => { + const band = getConfidenceBand(0.9); + expect(band.label).toBe('Solid scaffold'); + expect(band.copy).toContain('reliable'); + }); + + it('returns the producer-facing label for the workable band', () => { + const band = getConfidenceBand(0.6); + expect(band.label).toBe('Workable draft'); + expect(band.copy).toContain('right ballpark'); + }); + + it('returns the producer-facing label for the rough band', () => { + const band = getConfidenceBand(0.3); + expect(band.label).toBe('Rough sketch'); + expect(band.copy).toContain('rhythm grid'); + }); + + it('returns the producer-facing label for the unreliable band', () => { + const band = getConfidenceBand(0.1); + expect(band.label).toBe('Unreliable'); + expect(band.copy).toContain('scale hints'); + }); +}); + +describe('formatBandPillLabel', () => { + it('joins label and integer percent with a middle dot', () => { + const band = getConfidenceBand(0.72); + expect(formatBandPillLabel(band, 0.72)).toBe('Workable draft · 72%'); + }); + + it('rounds the percent to the nearest integer', () => { + const band = getConfidenceBand(0.876); + expect(formatBandPillLabel(band, 0.876)).toBe('Solid scaffold · 88%'); + }); + + it('clamps confidence values above 1 to 100%', () => { + const band = getConfidenceBand(0.9); + expect(formatBandPillLabel(band, 1.5)).toBe('Solid scaffold · 100%'); + }); + + it('clamps negative confidence values to 0%', () => { + const band = getConfidenceBand(0); + expect(formatBandPillLabel(band, -0.4)).toBe('Unreliable · 0%'); + }); + + it('treats NaN as 0%', () => { + const band = getConfidenceBand(0); + expect(formatBandPillLabel(band, Number.NaN)).toBe('Unreliable · 0%'); + }); +}); diff --git a/apps/ui/tests/services/noteConversion.test.ts b/apps/ui/tests/services/noteConversion.test.ts new file mode 100644 index 00000000..4d4feca8 --- /dev/null +++ b/apps/ui/tests/services/noteConversion.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; + +import { + melodyNotesToDisplayNotes, + midiToNoteName, + transcriptionNotesToDisplayNotes, +} from '../../src/services/sessionMusician/noteConversion'; +import type { MelodyDetail, TranscriptionDetail } from '../../src/types'; + +const transcription: TranscriptionDetail = { + transcriptionMethod: 'torchcrepe-viterbi', + noteCount: 3, + averageConfidence: 0.7, + stemSeparationUsed: true, + fullMixFallback: false, + stemsTranscribed: ['bass', 'other'], + dominantPitches: [], + pitchRange: { minMidi: 40, maxMidi: 72, minName: 'E2', maxName: 'C5' }, + notes: [ + { + pitchMidi: 40, + pitchName: 'E2', + onsetSeconds: 0, + durationSeconds: 0.5, + confidence: 0.92, + stemSource: 'bass', + }, + { + pitchMidi: 60, + pitchName: 'C4', + onsetSeconds: 0.5, + durationSeconds: 0.4, + confidence: 0.55, + stemSource: 'other', + }, + { + pitchMidi: 72, + pitchName: 'C5', + onsetSeconds: 1.0, + durationSeconds: 0.6, + confidence: 0.18, + stemSource: 'other', + }, + ], +}; + +describe('transcriptionNotesToDisplayNotes', () => { + it('returns an empty array when transcriptionDetail is null', () => { + expect(transcriptionNotesToDisplayNotes(null)).toEqual([]); + }); + + it('returns an empty array when transcriptionDetail is undefined', () => { + expect(transcriptionNotesToDisplayNotes(undefined)).toEqual([]); + }); + + it('returns an empty array when the notes array is empty', () => { + expect( + transcriptionNotesToDisplayNotes({ ...transcription, notes: [] }), + ).toEqual([]); + }); + + it('maps every note to the display shape when no filter is applied', () => { + const display = transcriptionNotesToDisplayNotes(transcription); + expect(display).toHaveLength(3); + expect(display[0]).toEqual({ + midi: 40, + name: 'E2', + startTime: 0, + duration: 0.5, + velocity: 90, + confidence: 0.92, + }); + }); + + it('filters by stem source when an active filter is passed', () => { + const display = transcriptionNotesToDisplayNotes(transcription, 'bass'); + expect(display).toHaveLength(1); + expect(display[0].midi).toBe(40); + }); + + it('returns an empty array when the filter matches no stem', () => { + expect(transcriptionNotesToDisplayNotes(transcription, 'drums')).toEqual([]); + }); + + it('treats a null filter the same as no filter', () => { + expect(transcriptionNotesToDisplayNotes(transcription, null)).toHaveLength(3); + }); +}); + +const melody: MelodyDetail = { + noteCount: 2, + notes: [ + { midi: 60, onset: 0.1, duration: 0.3 }, + { midi: 64, onset: 0.5, duration: 0.4 }, + ], + dominantNotes: [60, 64], + pitchRange: { min: 60, max: 64 }, + pitchConfidence: 0.42, + midiFile: null, + sourceSeparated: false, + vibratoPresent: false, + vibratoExtent: 0, + vibratoRate: 0, + vibratoConfidence: 0, +}; + +describe('melodyNotesToDisplayNotes', () => { + it('returns an empty array when melodyDetail is null', () => { + expect(melodyNotesToDisplayNotes(null)).toEqual([]); + }); + + it('returns an empty array when melodyDetail has no notes', () => { + expect(melodyNotesToDisplayNotes({ ...melody, notes: [] })).toEqual([]); + }); + + it('applies the scalar pitchConfidence to every emitted note', () => { + const display = melodyNotesToDisplayNotes(melody); + expect(display).toHaveLength(2); + expect(display.every((note) => note.confidence === 0.42)).toBe(true); + }); + + it('derives note names from MIDI numbers', () => { + const display = melodyNotesToDisplayNotes(melody); + expect(display[0].name).toBe('C4'); + expect(display[1].name).toBe('E4'); + }); +}); + +describe('midiToNoteName', () => { + it('handles middle C', () => { + expect(midiToNoteName(60)).toBe('C4'); + }); + + it('handles A4', () => { + expect(midiToNoteName(69)).toBe('A4'); + }); + + it('clamps below zero to C-1', () => { + expect(midiToNoteName(-5)).toBe('C-1'); + }); + + it('clamps above 127 to G9', () => { + expect(midiToNoteName(200)).toBe('G9'); + }); + + it('rounds non-integer values', () => { + expect(midiToNoteName(60.4)).toBe('C4'); + expect(midiToNoteName(60.6)).toBe('C#4'); + }); +}); diff --git a/apps/ui/tests/services/renderState.test.ts b/apps/ui/tests/services/renderState.test.ts new file mode 100644 index 00000000..e4671e6e --- /dev/null +++ b/apps/ui/tests/services/renderState.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { + deriveNoteDraftRenderState, + isLegacyTranscriptionMethod, +} from '../../src/services/sessionMusician/renderState'; +import type { TranscriptionDetail, TranscriptionNote } from '../../src/types'; + +const baseNote: TranscriptionNote = { + pitchMidi: 60, + pitchName: 'C4', + onsetSeconds: 0, + durationSeconds: 0.4, + confidence: 0.7, + stemSource: 'bass', +}; + +const torchcrepe = (overrides: Partial = {}): TranscriptionDetail => ({ + transcriptionMethod: 'torchcrepe-viterbi', + noteCount: 1, + averageConfidence: 0.7, + stemSeparationUsed: true, + fullMixFallback: false, + stemsTranscribed: ['bass'], + dominantPitches: [], + pitchRange: { minMidi: 60, maxMidi: 60, minName: 'C4', maxName: 'C4' }, + notes: [baseNote], + ...overrides, +}); + +describe('isLegacyTranscriptionMethod', () => { + it('returns false for torchcrepe-viterbi', () => { + expect(isLegacyTranscriptionMethod('torchcrepe-viterbi')).toBe(false); + }); + + it('returns false for the bare torchcrepe alias', () => { + expect(isLegacyTranscriptionMethod('torchcrepe')).toBe(false); + }); + + it('is case-insensitive', () => { + expect(isLegacyTranscriptionMethod('Torchcrepe-Viterbi')).toBe(false); + }); + + it('returns true for basic-pitch', () => { + expect(isLegacyTranscriptionMethod('basic-pitch')).toBe(true); + }); + + it('returns true for basic-pitch-legacy', () => { + expect(isLegacyTranscriptionMethod('basic-pitch-legacy')).toBe(true); + }); + + it('returns true for arbitrary unknown methods', () => { + expect(isLegacyTranscriptionMethod('penn')).toBe(true); + }); + + it('returns false for null / undefined / empty', () => { + expect(isLegacyTranscriptionMethod(null)).toBe(false); + expect(isLegacyTranscriptionMethod(undefined)).toBe(false); + expect(isLegacyTranscriptionMethod('')).toBe(false); + expect(isLegacyTranscriptionMethod(' ')).toBe(false); + }); +}); + +describe('deriveNoteDraftRenderState — precedence', () => { + it('returns absent when pitchNoteMode is off, even with stale notes', () => { + expect(deriveNoteDraftRenderState(torchcrepe(), 'off')).toBe('absent'); + }); + + it('returns requested-but-unavailable when stem_notes is requested but no payload arrives', () => { + expect(deriveNoteDraftRenderState(null, 'stem_notes')).toBe('requested-but-unavailable'); + expect(deriveNoteDraftRenderState(undefined, 'stem_notes')).toBe('requested-but-unavailable'); + }); + + it('returns absent when no payload and no explicit request', () => { + expect(deriveNoteDraftRenderState(null, null)).toBe('absent'); + }); + + it('returns ran-with-no-result when payload arrives with an empty notes array', () => { + expect( + deriveNoteDraftRenderState(torchcrepe({ notes: [] }), 'stem_notes'), + ).toBe('ran-with-no-result'); + }); + + it('returns ran-with-no-result even when noteCount is non-zero but notes is empty', () => { + // Defensive — a backend that reports noteCount: 5 but ships notes: [] is + // still un-renderable. + expect( + deriveNoteDraftRenderState(torchcrepe({ notes: [], noteCount: 5 }), 'stem_notes'), + ).toBe('ran-with-no-result'); + }); + + it('returns legacy when the method is basic-pitch even if fullMixFallback is also true', () => { + expect( + deriveNoteDraftRenderState( + torchcrepe({ transcriptionMethod: 'basic-pitch', fullMixFallback: true }), + 'stem_notes', + ), + ).toBe('legacy'); + }); + + it('returns full-mix-fallback when torchcrepe data has fullMixFallback === true', () => { + expect( + deriveNoteDraftRenderState( + torchcrepe({ fullMixFallback: true }), + 'stem_notes', + ), + ).toBe('full-mix-fallback'); + }); + + it('returns stem-aware for nominal torchcrepe data', () => { + expect(deriveNoteDraftRenderState(torchcrepe(), 'stem_notes')).toBe('stem-aware'); + }); + + it('returns stem-aware when pitchNoteMode is null but the data is valid (legacy snapshot)', () => { + expect(deriveNoteDraftRenderState(torchcrepe(), null)).toBe('stem-aware'); + }); +}); diff --git a/apps/ui/tests/services/sessionMusicianPanel.test.ts b/apps/ui/tests/services/sessionMusicianPanel.test.ts index fd3de5d8..7b6bc104 100644 --- a/apps/ui/tests/services/sessionMusicianPanel.test.ts +++ b/apps/ui/tests/services/sessionMusicianPanel.test.ts @@ -1,15 +1,18 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { afterEach, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { + deriveNoteDraftRenderState, deriveTranscriptionProvenance, filterNotesByConfidence, formatFilteredNoteCount, formatVibratoConfidence, + isLegacyTranscriptionMethod, + MIDI_DOWNLOAD_FILE_NAME, SessionMusicianPanel, } from '../../src/components/SessionMusicianPanel'; -import { MeasurementResult, TranscriptionDetail } from '../../src/types'; +import type { MeasurementResult, MelodyDetail, TranscriptionDetail } from '../../src/types'; const baseMeasurement: MeasurementResult = { bpm: 128, @@ -33,557 +36,394 @@ const baseMeasurement: MeasurementResult = { }, }; +const stemAwareTranscription = (overrides: Partial = {}): TranscriptionDetail => ({ + transcriptionMethod: 'torchcrepe-viterbi', + noteCount: 2, + averageConfidence: 0.72, + stemSeparationUsed: true, + fullMixFallback: false, + stemsTranscribed: ['bass', 'other'], + dominantPitches: [ + { pitchMidi: 48, pitchName: 'C3', count: 2 }, + { pitchMidi: 60, pitchName: 'C4', count: 1 }, + ], + pitchRange: { minMidi: 48, maxMidi: 60, minName: 'C3', maxName: 'C4' }, + notes: [ + { + pitchMidi: 48, + pitchName: 'C3', + onsetSeconds: 0, + durationSeconds: 0.5, + confidence: 0.92, + stemSource: 'bass', + }, + { + pitchMidi: 60, + pitchName: 'C4', + onsetSeconds: 0.6, + durationSeconds: 0.4, + confidence: 0.65, + stemSource: 'other', + }, + ], + ...overrides, +}); + +const melodyContour = (overrides: Partial = {}): MelodyDetail => ({ + noteCount: 3, + notes: [ + { midi: 60, onset: 0.2, duration: 0.3 }, + { midi: 64, onset: 0.8, duration: 0.2 }, + { midi: 67, onset: 1.2, duration: 0.4 }, + ], + dominantNotes: [60, 64, 67], + pitchRange: { min: 60, max: 67 }, + pitchConfidence: 0.72, + midiFile: null, + sourceSeparated: false, + vibratoPresent: false, + vibratoExtent: 0, + vibratoRate: 0, + vibratoConfidence: 0, + ...overrides, +}); + afterEach(() => { - vi.doUnmock('react'); - vi.resetModules(); vi.restoreAllMocks(); }); -describe('SessionMusicianPanel confidence helpers', () => { - it('shows the melody low-confidence warning at the inclusive 0.20 threshold', () => { - const html = renderToStaticMarkup( - React.createElement(SessionMusicianPanel, { - phase1: { - ...baseMeasurement, - melodyDetail: { - noteCount: 1, - notes: [{ midi: 60, onset: 0.2, duration: 0.3 }], - dominantNotes: [60], - pitchRange: { min: 60, max: 60 }, - pitchConfidence: 0.2, - midiFile: null, - sourceSeparated: false, - vibratoPresent: false, - vibratoExtent: 0, - vibratoRate: 0, - vibratoConfidence: 0.1, - }, - }, - }), +// --------------------------------------------------------------------------- +// Backwards-compat pure helpers (the ones still exported from the panel) +// --------------------------------------------------------------------------- + +describe('SessionMusicianPanel backwards-compat helpers', () => { + it('exports the legacy MIDI download filename for old tests', () => { + expect(MIDI_DOWNLOAD_FILE_NAME).toBe('track-analysis.mid'); + }); + + it('filters notes at or above the confidence threshold', () => { + const filtered = filterNotesByConfidence( + [ + { midi: 48, name: 'C3', startTime: 0.1, duration: 0.4, velocity: 90, confidence: 0.19 }, + { midi: 55, name: 'G3', startTime: 0.6, duration: 0.3, velocity: 90, confidence: 0.2 }, + { midi: 60, name: 'C4', startTime: 0.9, duration: 0.25, velocity: 90, confidence: 0.8 }, + ], + 0.2, ); + expect(filtered).toHaveLength(2); + expect(filtered.map((note) => note.midi)).toEqual([55, 60]); + }); + + it('formats note counts without a filtered prefix when threshold is zero', () => { + expect(formatFilteredNoteCount(3, 3, 0)).toBe('3 NOTES'); + }); + + it('formats filtered note counts when the threshold is active', () => { + expect(formatFilteredNoteCount(2, 3, 0.2)).toBe('2 / 3 NOTES'); + }); + + it('formats present vibrato confidence below 1 percent as less-than-one', () => { + expect(formatVibratoConfidence(0.004, true)).toBe('< 1'); + }); + + it('formats present vibrato confidence normally when it rounds above zero', () => { + expect(formatVibratoConfidence(0.15, true)).toBe('15'); + }); + + it('formats not-detected vibrato confidence as zero without the present guard', () => { + expect(formatVibratoConfidence(0, false)).toBe('0'); + }); + + it('re-exports deriveTranscriptionProvenance for the legacy activeSource shape', () => { + const provenance = deriveTranscriptionProvenance('pitchNote', stemAwareTranscription()); + expect(provenance).toEqual({ + transcriptionPathLabel: 'STEM-AWARE', + stemSourcesLabel: 'bass, other', + }); + expect( + deriveTranscriptionProvenance('melodyGuide', stemAwareTranscription()), + ).toEqual({ transcriptionPathLabel: null, stemSourcesLabel: null }); + }); - expect(html).toContain('Session Musician'); - expect(html).toContain('data-text-role="section-title"'); - expect(html).toContain('title="Low confidence — treat this as approximate."'); - expect(html).toContain('⚠'); + it('re-exports renderState helpers from the panel module path', () => { + expect(typeof deriveNoteDraftRenderState).toBe('function'); + expect(typeof isLegacyTranscriptionMethod).toBe('function'); }); +}); + +// --------------------------------------------------------------------------- +// Block-A render-state matrix via the panel's static markup +// --------------------------------------------------------------------------- - it('does not show the melody low-confidence warning above the threshold', () => { +describe('SessionMusicianPanel — Block A render states', () => { + it('renders the stem-aware draft with piano roll, stem filter, slider, and Download', () => { const html = renderToStaticMarkup( React.createElement(SessionMusicianPanel, { - phase1: { - ...baseMeasurement, - melodyDetail: { - noteCount: 1, - notes: [{ midi: 60, onset: 0.2, duration: 0.3 }], - dominantNotes: [60], - pitchRange: { min: 60, max: 60 }, - pitchConfidence: 0.21, - midiFile: null, - sourceSeparated: false, - vibratoPresent: false, - vibratoExtent: 0, - vibratoRate: 0, - vibratoConfidence: 0.1, - }, - }, + phase1: { ...baseMeasurement, transcriptionDetail: stemAwareTranscription() }, + pitchNoteMode: 'stem_notes', }), ); - - expect(html).not.toContain('title="Low confidence — treat this as approximate."'); + expect(html).toContain('data-render-state="stem-aware"'); + expect(html).toContain('data-testid="note-draft-piano-roll"'); + expect(html).toContain('data-testid="midi-download-stems"'); + expect(html).toContain('data-testid="midi-preview-stems"'); + expect(html).toContain('Workable draft · 72%'); + // Stem filter buttons render + expect(html).toMatch(/>bassother { + it('renders the full-mix-fallback override, hides stem filter and slider, keeps Download', () => { + const transcriptionDetail = stemAwareTranscription({ + stemSeparationUsed: false, + fullMixFallback: true, + stemsTranscribed: ['full_mix'], + notes: stemAwareTranscription().notes.map((note) => ({ ...note, stemSource: 'full_mix' })), + }); const html = renderToStaticMarkup( React.createElement(SessionMusicianPanel, { - phase1: { - ...baseMeasurement, - melodyDetail: { - noteCount: 3, - notes: [ - { midi: 60, onset: 0.2, duration: 0.3 }, - { midi: 64, onset: 0.8, duration: 0.2 }, - { midi: 67, onset: 1.2, duration: 0.4 }, - ], - dominantNotes: [60, 64, 67], - pitchRange: { min: 60, max: 67 }, - pitchConfidence: 0.72, - midiFile: null, - sourceSeparated: false, - vibratoPresent: false, - vibratoExtent: 0, - vibratoRate: 0, - vibratoConfidence: 0.1, - }, - }, + phase1: { ...baseMeasurement, transcriptionDetail }, + pitchNoteMode: 'stem_notes', }), ); - - expect(html).toContain('3 NOTES'); - expect(html).not.toContain('3 / 3 NOTES'); - expect(html).toContain('Per-note confidence not available in melody-guide mode'); - expect(html).toMatch(/CONFIDENCE<\/span>]*disabled=""/); + expect(html).toContain('data-render-state="full-mix-fallback"'); + expect(html).toContain('Full-mix fallback'); + expect(html).toContain('drift between instruments'); + expect(html).toContain('data-testid="note-draft-piano-roll"'); + expect(html).toContain('data-testid="midi-download-stems"'); + // No stem filter buttons (only full_mix would appear, and we hide the row) + expect(html).not.toMatch(/Stems:/); + // The confidence slider (label "CONFIDENCE") is hidden in fallback mode. + // The Swing slider in QuantizeControls is still present, so checking for + // any type="range" would be a false positive — anchor on the unique label. + expect(html).not.toContain('>Confidence<'); }); - it('explains when only the melody guide is available and labels the export accordingly', () => { + it('routes basic-pitch (legacy) data to the legacy render state even when fullMixFallback is also true', () => { + const transcriptionDetail = stemAwareTranscription({ + transcriptionMethod: 'basic-pitch', + fullMixFallback: true, + stemSeparationUsed: false, + }); const html = renderToStaticMarkup( React.createElement(SessionMusicianPanel, { - phase1: { - ...baseMeasurement, - melodyDetail: { - noteCount: 3, - notes: [ - { midi: 60, onset: 0.2, duration: 0.3 }, - { midi: 64, onset: 0.8, duration: 0.2 }, - { midi: 67, onset: 1.2, duration: 0.4 }, - ], - dominantNotes: [60, 64, 67], - pitchRange: { min: 60, max: 67 }, - pitchConfidence: 0.72, - midiFile: '/tmp/melody-guide.mid', - sourceSeparated: true, - vibratoPresent: false, - vibratoExtent: 0, - vibratoRate: 0, - vibratoConfidence: 0.1, - }, - }, + phase1: { ...baseMeasurement, transcriptionDetail }, + pitchNoteMode: 'stem_notes', }), ); - - expect(html).toContain('Stem pitch/note extraction is off.'); - expect(html).toContain('showing the measurement-layer melody guide instead'); - expect(html).toContain('Download melody .mid'); + expect(html).toContain('data-render-state="legacy"'); + expect(html).toContain('Legacy run'); + expect(html).toContain('Re-analyze for current stem-aware quality'); + // Confirm precedence on the helper directly too: + expect(deriveNoteDraftRenderState(transcriptionDetail, 'stem_notes')).toBe('legacy'); }); - it('shows the melody-guide-only banner even when the panel is collapsed', async () => { - vi.resetModules(); - vi.doMock('react', async () => { - const actual = await vi.importActual('react'); - let useStateCallCount = 0; - return { - ...actual, - default: actual, - useState(initialState: T | (() => T)) { - useStateCallCount += 1; - if (useStateCallCount === 2) { - // force expanded = false (call 2 in SessionMusicianPanel.tsx) - return actual.useState(false as T); - } - return actual.useState(initialState); - }, - }; - }); - - const { SessionMusicianPanel: CollapsedPanel } = await import('../../src/components/SessionMusicianPanel'); - + it('shows requested-but-unavailable notice when stem_notes is on but transcription is missing', () => { const html = renderToStaticMarkup( - React.createElement(CollapsedPanel, { - phase1: { - ...baseMeasurement, - melodyDetail: { - noteCount: 3, - notes: [ - { midi: 60, onset: 0.2, duration: 0.3 }, - { midi: 64, onset: 0.8, duration: 0.2 }, - { midi: 67, onset: 1.2, duration: 0.4 }, - ], - dominantNotes: [60, 64, 67], - pitchRange: { min: 60, max: 67 }, - pitchConfidence: 0.72, - midiFile: '/tmp/melody-guide.mid', - sourceSeparated: true, - vibratoPresent: false, - vibratoExtent: 0, - vibratoRate: 0, - vibratoConfidence: 0.1, - }, - }, + React.createElement(SessionMusicianPanel, { + phase1: { ...baseMeasurement }, + pitchNoteMode: 'stem_notes', }), ); - - // Banner must be visible even in collapsed state - expect(html).toContain('Stem pitch/note extraction is off.'); - expect(html).toContain('showing the measurement-layer melody guide instead'); - // Piano roll canvas and quantize controls must not render (collapsed) - expect(html).not.toContain(' { + it('does not render Block A when pitchNoteMode is off, even with stale transcription data', () => { const html = renderToStaticMarkup( React.createElement(SessionMusicianPanel, { - phase1: { - ...baseMeasurement, - melodyDetail: { - noteCount: 3, - notes: [ - { midi: 60, onset: 0.2, duration: 0.3 }, - { midi: 64, onset: 0.8, duration: 0.2 }, - { midi: 67, onset: 1.2, duration: 0.4 }, - ], - dominantNotes: [60, 64, 67], - pitchRange: { min: 60, max: 67 }, - pitchConfidence: 0.72, - midiFile: '/tmp/melody-guide.mid', - sourceSeparated: true, - vibratoPresent: false, - vibratoExtent: 0, - vibratoRate: 0, - vibratoConfidence: 0.1, - }, - }, + phase1: { ...baseMeasurement, transcriptionDetail: stemAwareTranscription() }, pitchNoteMode: 'off', }), ); + expect(html).not.toContain('data-testid="note-draft-block"'); + expect(html).not.toContain('data-testid="note-draft-piano-roll"'); + expect(html).not.toContain('data-testid="midi-download-stems"'); + // Off-state banner is the only thing rendered above where the block would be + expect(html).toContain('data-testid="session-musician-off-banner"'); + }); - // Opted-out banner is the primary cue - expect(html).toContain('Pitch/note translation is off.'); - expect(html).toContain('Re-enable the Stem Pitch/Note Translation toggle'); - // Summary copy reflects opted-out state - expect(html).toContain('Pitch/note translation is off'); - // Preview and Download buttons are disabled - expect(html).toMatch(/disabled=""[^>]*>(?:<[^>]*>)*(?:Stop|Preview melody|Preview)/); - expect(html).toMatch(/disabled=""[^>]*>(?:<[^>]*>)*(?:Download)/); - // Piano roll canvas is absent - expect(html).not.toContain(' { + const transcriptionDetail = stemAwareTranscription({ notes: [], noteCount: 0 }); + const html = renderToStaticMarkup( + React.createElement(SessionMusicianPanel, { + phase1: { ...baseMeasurement, transcriptionDetail }, + pitchNoteMode: 'stem_notes', + }), + ); + expect(html).toContain('data-render-state="ran-with-no-result"'); + expect(html).toContain('didn't find a pitched line'); + expect(html).not.toContain('data-testid="note-draft-piano-roll"'); + expect(html).not.toContain('data-testid="midi-download-stems"'); }); - it('filters notes at or above the confidence threshold', () => { - const filtered = filterNotesByConfidence( - [ - { - midi: 48, - name: 'C3', - startTime: 0.1, - duration: 0.4, - velocity: 90, - confidence: 0.19, - }, - { - midi: 55, - name: 'G3', - startTime: 0.6, - duration: 0.3, - velocity: 90, - confidence: 0.2, - }, - { - midi: 60, - name: 'C4', - startTime: 0.9, - duration: 0.25, - velocity: 90, - confidence: 0.8, + it('renders the Unreliable band pill when stem-aware confidence is below 25%', () => { + const html = renderToStaticMarkup( + React.createElement(SessionMusicianPanel, { + phase1: { + ...baseMeasurement, + transcriptionDetail: stemAwareTranscription({ averageConfidence: 0.12 }), }, - ], - 0.2, + pitchNoteMode: 'stem_notes', + }), ); - - expect(filtered).toHaveLength(2); - expect(filtered.map((note) => note.midi)).toEqual([55, 60]); + expect(html).toContain('Unreliable · 12%'); + expect(html).toContain("don't trust specific notes"); + // Piano roll + Download still render — we badge, we don't hide + expect(html).toContain('data-testid="note-draft-piano-roll"'); + expect(html).toContain('data-testid="midi-download-stems"'); }); +}); - it('formats the filtered count using the number of notes that pass the confidence filter', () => { - const activeNotes = [ - { - midi: 48, - name: 'C3', - startTime: 0.1, - duration: 0.4, - velocity: 90, - confidence: 0.19, - }, - { - midi: 55, - name: 'G3', - startTime: 0.6, - duration: 0.3, - velocity: 90, - confidence: 0.2, - }, - { - midi: 60, - name: 'C4', - startTime: 0.9, - duration: 0.25, - velocity: 90, - confidence: 0.8, - }, - ]; - const filteredNotes = filterNotesByConfidence(activeNotes, 0.2); - - expect(filteredNotes).toHaveLength(2); - expect(formatFilteredNoteCount(filteredNotes.length, activeNotes.length, 0.2)).toBe('2 / 3 NOTES'); - }); +// --------------------------------------------------------------------------- +// Block-B (melody contour) render states +// --------------------------------------------------------------------------- - it('formats filtered note counts when the threshold is active', () => { - expect(formatFilteredNoteCount(2, 3, 0.2)).toBe('2 / 3 NOTES'); +describe('SessionMusicianPanel — Block B (melody contour) render states', () => { + it('renders the melody block with its own Preview/Download and band pill', () => { + const html = renderToStaticMarkup( + React.createElement(SessionMusicianPanel, { + phase1: { ...baseMeasurement, melodyDetail: melodyContour({ pitchConfidence: 0.62 }) }, + }), + ); + expect(html).toContain('data-testid="melody-contour-block"'); + expect(html).toContain('data-testid="melody-contour-piano-roll"'); + expect(html).toContain('data-testid="midi-download-melody"'); + expect(html).toContain('Workable draft · 62%'); + expect(html).toContain('Download melody .mid'); + // No per-note confidence slider in melody mode + expect(html).not.toMatch(/Confidence<\/span> { - expect(formatFilteredNoteCount(3, 3, 0)).toBe('3 NOTES'); + it('renders ran-with-no-result for empty melody notes', () => { + const html = renderToStaticMarkup( + React.createElement(SessionMusicianPanel, { + phase1: { ...baseMeasurement, melodyDetail: melodyContour({ noteCount: 0, notes: [] }) }, + }), + ); + expect(html).toContain('data-render-state="ran-with-no-result"'); + expect(html).toContain('Melody extraction ran but'); + expect(html).not.toContain('data-testid="melody-contour-piano-roll"'); + expect(html).not.toContain('data-testid="midi-download-melody"'); }); - it('formats present vibrato confidence below 1 percent as less than 1', () => { - expect(formatVibratoConfidence(0.004, true)).toBe('< 1'); + it('renders Rough band copy when melody confidence is between 25 and 50 percent', () => { + const html = renderToStaticMarkup( + React.createElement(SessionMusicianPanel, { + phase1: { ...baseMeasurement, melodyDetail: melodyContour({ pitchConfidence: 0.3 }) }, + }), + ); + expect(html).toContain('Rough sketch · 30%'); + expect(html).toContain('data-testid="midi-download-melody"'); }); +}); - it('formats present vibrato confidence normally when it rounds above zero', () => { - expect(formatVibratoConfidence(0.15, true)).toBe('15'); - }); +// --------------------------------------------------------------------------- +// Off-state banner copy and visibility +// --------------------------------------------------------------------------- - it('formats not-detected vibrato confidence as zero without the present guard', () => { - expect(formatVibratoConfidence(0, false)).toBe('0'); +describe('SessionMusicianPanel — off-state banner', () => { + it('renders the with-melody banner when opted out and melody is present', () => { + const html = renderToStaticMarkup( + React.createElement(SessionMusicianPanel, { + phase1: { ...baseMeasurement, melodyDetail: melodyContour() }, + pitchNoteMode: 'off', + hasStemListeningNotes: true, + }), + ); + expect(html).toContain('data-testid="session-musician-off-banner"'); + expect(html).toContain("You're seeing the measurement-layer melody contour below"); + expect(html).toContain('stem listening notes below'); + // Melody block still renders + expect(html).toContain('data-testid="melody-contour-block"'); }); - it('derives transcription provenance only for the active pitch-note source', () => { - const mixedSourceTranscriptionDetail: TranscriptionDetail = { - transcriptionMethod: 'torchcrepe-viterbi', - noteCount: 4, - averageConfidence: 0.83, - stemSeparationUsed: true, - fullMixFallback: false, - stemsTranscribed: ['bass', 'other'], - dominantPitches: [ - { pitchMidi: 48, pitchName: 'C3', count: 2 }, - { pitchMidi: 60, pitchName: 'C4', count: 2 }, - ], - pitchRange: { - minMidi: 48, - maxMidi: 60, - minName: 'C3', - maxName: 'C4', - }, - notes: [ - { - pitchMidi: 48, - pitchName: 'C3', - onsetSeconds: 0, - durationSeconds: 0.5, - confidence: 0.92, - stemSource: 'bass', - }, - ], - }; - - expect(deriveTranscriptionProvenance('pitchNote', mixedSourceTranscriptionDetail)).toEqual({ - transcriptionPathLabel: 'STEM-AWARE', - stemSourcesLabel: 'bass, other', - }); - expect(deriveTranscriptionProvenance('melodyGuide', mixedSourceTranscriptionDetail)).toEqual({ - transcriptionPathLabel: null, - stemSourcesLabel: null, - }); - expect(deriveTranscriptionProvenance('none', mixedSourceTranscriptionDetail)).toEqual({ - transcriptionPathLabel: null, - stemSourcesLabel: null, - }); - expect( - deriveTranscriptionProvenance('pitchNote', { - ...mixedSourceTranscriptionDetail, - stemSeparationUsed: false, - stemsTranscribed: ['full_mix'], + it('renders the no-melody-with-listening banner when opted out, no melody, listening notes present', () => { + const html = renderToStaticMarkup( + React.createElement(SessionMusicianPanel, { + phase1: { ...baseMeasurement }, + pitchNoteMode: 'off', + hasStemListeningNotes: true, }), - ).toEqual({ - transcriptionPathLabel: 'FULL MIX', - stemSourcesLabel: null, - }); + ); + expect(html).toContain('data-testid="session-musician-off-banner"'); + expect(html).toContain('appears when available'); + expect(html).toContain('stem listening notes below describe each stem'); + expect(html).not.toContain('data-testid="melody-contour-block"'); + expect(html).not.toContain('data-testid="note-draft-block"'); }); - it('shows a quality-limited badge for pitch-note full-mix fallback results', () => { + it('renders the no-melody-no-listening banner when opted out and nothing else is available', () => { const html = renderToStaticMarkup( React.createElement(SessionMusicianPanel, { - phase1: { - ...baseMeasurement, - transcriptionDetail: { - transcriptionMethod: 'torchcrepe-viterbi', - noteCount: 2, - averageConfidence: 0.42, - stemSeparationUsed: false, - fullMixFallback: true, - stemsTranscribed: ['full_mix'], - dominantPitches: [{ pitchMidi: 48, pitchName: 'C3', count: 2 }], - pitchRange: { - minMidi: 48, - maxMidi: 52, - minName: 'C3', - maxName: 'E3', - }, - notes: [ - { - pitchMidi: 48, - pitchName: 'C3', - onsetSeconds: 0.1, - durationSeconds: 0.4, - confidence: 0.48, - stemSource: 'full_mix', - }, - { - pitchMidi: 52, - pitchName: 'E3', - onsetSeconds: 0.8, - durationSeconds: 0.2, - confidence: 0.36, - stemSource: 'full_mix', - }, - ], - }, - }, + phase1: { ...baseMeasurement }, + pitchNoteMode: 'off', + hasStemListeningNotes: false, }), ); - - expect(html).toContain('FULL MIX'); - expect(html).toContain('FULL MIX — quality limited'); + expect(html).toContain('data-testid="session-musician-off-banner"'); + expect(html).toContain('appears when available'); + // Should NOT include the cross-link sentence + expect(html).not.toContain('stem listening notes below describe each stem'); }); - it('hides pitch-note provenance badges and updates helper copy in melody-guide mixed-source mode', async () => { - vi.resetModules(); - vi.doMock('react', async () => { - const actual = await vi.importActual('react'); - let useStateCallCount = 0; - - return { - ...actual, - default: actual, - useState(initialState: T | (() => T)) { - useStateCallCount += 1; - if (useStateCallCount === 3) { - return actual.useState('melodyGuide' as T); - } - return actual.useState(initialState); - }, - }; - }); - - const { SessionMusicianPanel: MockedSessionMusicianPanel } = await import('../../src/components/SessionMusicianPanel'); - + it('does not render the off banner when pitchNoteMode is null (legacy snapshot)', () => { const html = renderToStaticMarkup( - React.createElement(MockedSessionMusicianPanel, { - phase1: { - ...baseMeasurement, - transcriptionDetail: { - transcriptionMethod: 'torchcrepe-viterbi', - noteCount: 4, - averageConfidence: 0.83, - stemSeparationUsed: true, - fullMixFallback: false, - stemsTranscribed: ['bass', 'other'], - dominantPitches: [ - { pitchMidi: 48, pitchName: 'C3', count: 2 }, - { pitchMidi: 60, pitchName: 'C4', count: 2 }, - ], - pitchRange: { - minMidi: 48, - maxMidi: 60, - minName: 'C3', - maxName: 'C4', - }, - notes: [ - { - pitchMidi: 48, - pitchName: 'C3', - onsetSeconds: 0, - durationSeconds: 0.5, - confidence: 0.92, - stemSource: 'bass', - }, - ], - }, - melodyDetail: { - noteCount: 3, - notes: [ - { midi: 60, onset: 0.2, duration: 0.3 }, - { midi: 64, onset: 0.8, duration: 0.2 }, - { midi: 67, onset: 1.2, duration: 0.4 }, - ], - dominantNotes: [60, 64, 67], - pitchRange: { min: 60, max: 67 }, - pitchConfidence: 0.72, - midiFile: null, - sourceSeparated: false, - vibratoPresent: false, - vibratoExtent: 0, - vibratoRate: 0, - vibratoConfidence: 0.1, - }, - }, + React.createElement(SessionMusicianPanel, { + phase1: { ...baseMeasurement, transcriptionDetail: stemAwareTranscription() }, + pitchNoteMode: null, }), ); - - expect(html).toContain('MELODY GUIDE: ESSENTIA'); - expect(html).not.toContain('STEM-AWARE'); - expect(html).not.toContain('STEMS: bass, other'); - expect(html).toContain('Per-note confidence not available in melody-guide mode'); - expect(html).not.toContain('Adjust confidence threshold to filter noise before export.'); - expect(html).toContain('Essentia melody guide. Adjust quantize before preview/export.'); + expect(html).not.toContain('data-testid="session-musician-off-banner"'); + // Inferred from data — stem-aware block renders + expect(html).toContain('data-render-state="stem-aware"'); }); +}); + +// --------------------------------------------------------------------------- +// Simultaneous rendering of both blocks +// --------------------------------------------------------------------------- - it('shows total note time separately from track duration and surfaces melody provenance fields', () => { +describe('SessionMusicianPanel — simultaneous rendering', () => { + it('renders both Block A and Block B at once when both data sources are present', () => { const html = renderToStaticMarkup( React.createElement(SessionMusicianPanel, { phase1: { ...baseMeasurement, - durationSeconds: 12, - melodyDetail: { - noteCount: 3, - notes: [ - { midi: 60, onset: 0.2, duration: 0.3 }, - { midi: 64, onset: 0.8, duration: 0.2 }, - { midi: 67, onset: 1.2, duration: 0.4 }, - ], - dominantNotes: [60, 64, 67], - pitchRange: { min: 60, max: 67 }, - pitchConfidence: 0.72, - midiFile: '/tmp/melody-guide.mid', - sourceSeparated: true, - vibratoPresent: true, - vibratoExtent: 0.38, - vibratoRate: 5.2, - vibratoConfidence: 0.81, - }, + transcriptionDetail: stemAwareTranscription(), + melodyDetail: melodyContour(), }, + pitchNoteMode: 'stem_notes', }), ); - - expect(html).toContain('Total note time: 0.9s'); - expect(html).toContain('Track duration: 12.0s'); - expect(html).toContain('Melody MIDI: available'); - expect(html).toContain('Melody source: separated'); - expect(html).toContain('Vibrato: present'); - expect(html).toContain('5.2 Hz'); - expect(html).toContain('0.38 cents'); - expect(html).toContain('81%'); + expect(html).toContain('data-testid="note-draft-block"'); + expect(html).toContain('data-testid="melody-contour-block"'); + expect(html).toContain('data-testid="midi-download-stems"'); + expect(html).toContain('data-testid="midi-download-melody"'); + // No source-mode toggle remains + expect(html).not.toContain('PITCH/NOTE'); + expect(html).not.toContain('MELODY'); + // Panel summary describes both + expect(html).toContain('Two reads of the pitched material'); }); - it('renders present vibrato metadata with cents and less-than-one-percent confidence when needed', () => { + it('renders the no-data message when neither block has content and we are not opted out', () => { const html = renderToStaticMarkup( React.createElement(SessionMusicianPanel, { - phase1: { - ...baseMeasurement, - melodyDetail: { - noteCount: 1, - notes: [{ midi: 60, onset: 0.2, duration: 0.3 }], - dominantNotes: [60], - pitchRange: { min: 60, max: 60 }, - pitchConfidence: 0.72, - midiFile: null, - sourceSeparated: true, - vibratoPresent: true, - vibratoExtent: 0.38, - vibratoRate: 5.2, - vibratoConfidence: 0.004, - }, - }, + phase1: { ...baseMeasurement }, + pitchNoteMode: null, }), ); - - expect(html).toContain('Vibrato: present'); - expect(html).toContain('5.2 Hz'); - expect(html).toContain('0.38 cents'); - expect(html).toContain('< 1%'); + expect(html).toContain('data-testid="session-musician-no-data"'); + expect(html).toContain('PITCH & MELODY UNAVAILABLE'); }); }); diff --git a/apps/ui/tests/services/stemListeningNotes.test.ts b/apps/ui/tests/services/stemListeningNotes.test.ts new file mode 100644 index 00000000..e77e94b3 --- /dev/null +++ b/apps/ui/tests/services/stemListeningNotes.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { hasStemListeningNotesContent } from '../../src/services/sessionMusician/stemListeningNotes'; +import type { StemSummaryResult } from '../../src/types'; + +const emptyEnvelope: StemSummaryResult = { + summary: '', + stems: [], + uncertaintyFlags: [], +}; + +describe('hasStemListeningNotesContent', () => { + it('returns false for null', () => { + expect(hasStemListeningNotesContent(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(hasStemListeningNotesContent(undefined)).toBe(false); + }); + + it('returns false for a fully empty envelope', () => { + expect(hasStemListeningNotesContent(emptyEnvelope)).toBe(false); + }); + + it('returns false when the summary string is only whitespace', () => { + expect( + hasStemListeningNotesContent({ ...emptyEnvelope, summary: ' ' }), + ).toBe(false); + }); + + it('returns true when the summary string has real content', () => { + expect( + hasStemListeningNotesContent({ ...emptyEnvelope, summary: 'Bass plays root notes.' }), + ).toBe(true); + }); + + it('returns true when any stems are present', () => { + const envelope: StemSummaryResult = { + summary: '', + uncertaintyFlags: [], + stems: [ + { + stem: 'bass', + label: 'Bass', + summary: 'Bass walks through scale degrees.', + bars: [], + globalPatterns: { + bassRole: '', + melodicRole: '', + pumpingOrModulation: '', + synthesisCharacter: '', + vocalPresence: '', + bassCharacter: '', + }, + uncertaintyFlags: [], + }, + ], + }; + expect(hasStemListeningNotesContent(envelope)).toBe(true); + }); + + it('returns true when only uncertainty flags are present', () => { + expect( + hasStemListeningNotesContent({ + ...emptyEnvelope, + uncertaintyFlags: ['Pitch detection low confidence'], + }), + ).toBe(true); + }); +}); diff --git a/apps/ui/tests/services/stemListeningNotesPanel.test.ts b/apps/ui/tests/services/stemListeningNotesPanel.test.ts new file mode 100644 index 00000000..f570fe97 --- /dev/null +++ b/apps/ui/tests/services/stemListeningNotesPanel.test.ts @@ -0,0 +1,127 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { StemListeningNotesPanel } from '../../src/components/StemListeningNotesPanel'; +import type { StemSummaryResult, StemSummaryStem } from '../../src/types'; + +const stemCard = (overrides: Partial = {}): StemSummaryStem => ({ + stem: 'bass', + label: 'Bass stem', + summary: 'Walks the root and fifth across the verse.', + bars: [ + { + barStart: 1, + barEnd: 4, + startTime: 0, + endTime: 8, + noteHypotheses: ['A2', 'E3'], + scaleDegreeHypotheses: ['1', '5'], + rhythmicPattern: 'Quarter notes on the downbeats.', + uncertaintyLevel: 'LOW', + uncertaintyReason: 'Clean stem; root motion is unambiguous.', + }, + ], + globalPatterns: { + bassRole: 'Foundational root motion.', + melodicRole: 'Supports the lead from below.', + pumpingOrModulation: 'Steady; no sidechain duck visible.', + synthesisCharacter: 'Plucky, short-decay analog tone.', + vocalPresence: 'No vocal bleed.', + bassCharacter: 'Tight and present.', + }, + uncertaintyFlags: [], + ...overrides, +}); + +describe('StemListeningNotesPanel', () => { + it('renders nothing when stemSummary is null', () => { + const html = renderToStaticMarkup( + React.createElement(StemListeningNotesPanel, { stemSummary: null }), + ); + expect(html).toBe(''); + }); + + it('renders nothing when stemSummary is undefined', () => { + const html = renderToStaticMarkup( + React.createElement(StemListeningNotesPanel, { stemSummary: undefined }), + ); + expect(html).toBe(''); + }); + + it('renders nothing for a fully empty envelope', () => { + const empty: StemSummaryResult = { summary: '', stems: [], uncertaintyFlags: [] }; + const html = renderToStaticMarkup( + React.createElement(StemListeningNotesPanel, { stemSummary: empty }), + ); + expect(html).toBe(''); + }); + + it('renders the panel when only the top-line summary string is set', () => { + const summaryOnly: StemSummaryResult = { + summary: 'Bass anchors the harmony; lead arpeggio outlines the chord changes.', + stems: [], + uncertaintyFlags: [], + }; + const html = renderToStaticMarkup( + React.createElement(StemListeningNotesPanel, { stemSummary: summaryOnly }), + ); + expect(html).toContain('data-testid="stem-listening-notes-panel"'); + // formatDisplayText('Stem listening notes', 'title') renders title case. + expect(html).toContain('Stem Listening Notes'); + expect(html).toContain('Bass anchors the harmony'); + // No per-stem cards + expect(html).not.toContain('Global pattern'); + }); + + it('renders the panel when only uncertaintyFlags are present', () => { + const flagsOnly: StemSummaryResult = { + summary: '', + stems: [], + uncertaintyFlags: ['Pitch detection low confidence', 'Dense polyphony in bars 5-8'], + }; + const html = renderToStaticMarkup( + React.createElement(StemListeningNotesPanel, { stemSummary: flagsOnly }), + ); + expect(html).toContain('data-testid="stem-listening-notes-panel"'); + expect(html).toContain('Pitch detection low confidence'); + expect(html).toContain('Dense polyphony in bars 5-8'); + }); + + it('renders all per-stem cards when stems are populated', () => { + const fullEnvelope: StemSummaryResult = { + summary: 'Track-level summary text.', + uncertaintyFlags: ['Some global uncertainty'], + stems: [ + stemCard(), + stemCard({ + stem: 'other', + label: 'Other (lead)', + summary: 'Pad and arpeggio interlocked.', + }), + ], + }; + const html = renderToStaticMarkup( + React.createElement(StemListeningNotesPanel, { stemSummary: fullEnvelope }), + ); + expect(html).toContain('Bass stem'); + expect(html).toContain('Other (lead)'); + expect(html).toContain('Bars 1-4'); + expect(html).toContain('Notes: A2, E3'); + expect(html).toContain('Scale degrees: 1, 5'); + expect(html).toContain('Foundational root motion'); + expect(html).toContain('Some global uncertainty'); + }); + + it('does not carry the section-stem-summary anchor ID (that lives on the wrapper div)', () => { + const fullEnvelope: StemSummaryResult = { + summary: 'x', + uncertaintyFlags: [], + stems: [stemCard()], + }; + const html = renderToStaticMarkup( + React.createElement(StemListeningNotesPanel, { stemSummary: fullEnvelope }), + ); + expect(html).not.toContain('id="section-stem-summary"'); + }); +}); diff --git a/apps/ui/tests/smoke/upload-phase1-midi.spec.ts b/apps/ui/tests/smoke/upload-phase1-midi.spec.ts index ca48beb3..32f18d12 100644 --- a/apps/ui/tests/smoke/upload-phase1-midi.spec.ts +++ b/apps/ui/tests/smoke/upload-phase1-midi.spec.ts @@ -81,7 +81,7 @@ async function pressSliderKey(locator: import('@playwright/test').Locator, key: } } -test('phase1 dual-source session musician panel toggles between pitch-note and melody-guide views', async ({ page }) => { +test('phase1 dual-source session musician panel renders both blocks simultaneously', async ({ page }) => { await stubGeminiPhase2(page); await page.route('**/api/analysis-runs/estimate', async (route) => { const body = route.request().postData() ?? ''; @@ -357,72 +357,81 @@ test('phase1 dual-source session musician panel toggles between pitch-note and m await expect(page.getByText('Analysis Results')).toBeVisible(); await expect(panel.getByRole('heading', { name: /SESSION MUSICIAN/i }).first()).toBeVisible(); - await expect(panel.getByText('Pitch detection and melody guide')).toBeVisible(); - await expect(panel.getByRole('button', { name: 'PITCH/NOTE' })).toBeVisible(); - await expect(panel.getByRole('button', { name: 'MELODY' })).toBeVisible(); - await expect(panel.getByText('PITCH/NOTE: TORCHCREPE').first()).toBeVisible(); - await expect(panel.getByText('Range: C3 - G4')).toHaveCount(1); - await expect(panel.getByText('Confidence: 83%')).toHaveCount(1); - await expect(panel.getByText('2 / 2 NOTES')).toBeVisible(); - await expect(panel.getByText('STEM-AWARE')).toBeVisible(); - await expect(panel.getByText('STEMS:')).toBeVisible(); - await expect(panel.getByRole('button', { name: 'bass' })).toBeVisible(); - await expect(panel.getByRole('button', { name: 'other' })).toBeVisible(); - await expect(panel.getByText(/TORCHCREPE pitch detection/)).toBeVisible(); - const previewButton = panel.getByRole('button', { name: /Preview/i }); - const downloadButton = panel.getByRole('button', { name: /Download \.mid/i }); - await expect(previewButton).toBeVisible(); - await expect(previewButton).toBeEnabled(); - await expect(downloadButton).toBeVisible(); - await expect(downloadButton).toBeEnabled(); - const sliders = panel.locator('input[type="range"]'); - const confidenceSlider = sliders.nth(0); - const swingSlider = sliders.nth(1); - await expect(confidenceSlider).toBeEnabled(); - await expect(swingSlider).toBeDisabled(); - - await pressSliderKey(confidenceSlider, 'End', 1); - await pressSliderKey(confidenceSlider, 'ArrowLeft', 4); - await expect(confidenceSlider).toHaveValue('0.8'); - await expect(panel.getByText('80%')).toBeVisible(); - await expect(panel.getByText('1 / 2 NOTES')).toBeVisible(); - - await panel.getByRole('button', { name: '1/16 note' }).click(); - await expect(swingSlider).toBeEnabled(); - await pressSliderKey(swingSlider, 'ArrowRight', 30); - await expect(swingSlider).toHaveValue('30'); - - const downloadPromise = page.waitForEvent('download'); - await downloadButton.click(); - const download = await downloadPromise; - expect(download.suggestedFilename()).toBe('track-analysis.mid'); - - await panel.getByRole('button', { name: 'MELODY' }).click(); - await expect(panel.getByText('MELODY GUIDE: ESSENTIA').first()).toBeVisible(); - await expect(panel.getByText(/Essentia melody guide\./)).toBeVisible(); - await expect(panel.getByText('STEM-AWARE')).toHaveCount(0); - await expect(panel.getByText('STEMS:')).toHaveCount(0); - await expect(panel.getByText('3 NOTES')).toBeVisible(); - await expect(panel.getByText('3 / 3 NOTES')).toHaveCount(0); - await expect(panel.getByText('Per-note confidence not available in melody-guide mode')).toBeVisible(); - await expect(panel.getByText('Adjust confidence threshold to filter noise before export.')).toHaveCount(0); - await expect(confidenceSlider).toBeDisabled(); - await expect(confidenceSlider).toHaveValue('0.8'); - - await panel.getByRole('button', { name: 'PITCH/NOTE' }).click(); - await expect(panel.getByText('PITCH/NOTE: TORCHCREPE').first()).toBeVisible(); - await expect(panel.getByText(/TORCHCREPE pitch detection/)).toBeVisible(); - await expect(panel.getByText('STEM-AWARE')).toBeVisible(); - await expect(panel.getByText('STEMS:')).toBeVisible(); - await expect(panel.getByText('1 / 2 NOTES')).toBeVisible(); - await expect(confidenceSlider).toBeEnabled(); - await expect(confidenceSlider).toHaveValue('0.8'); - await panel.getByRole('button', { name: /Collapse session musician panel/i }).click(); - await expect(panel.getByRole('button', { name: '1/16 note' })).toHaveCount(0); + // Both blocks render at once — no toggle. + const noteDraft = panel.getByTestId('note-draft-block'); + const melodyContour = panel.getByTestId('melody-contour-block'); + await expect(noteDraft).toBeVisible(); + await expect(melodyContour).toBeVisible(); + await expect(panel.getByRole('button', { name: /^PITCH\/NOTE$/ })).toHaveCount(0); + await expect(panel.getByRole('button', { name: /^MELODY$/ })).toHaveCount(0); + + // Confidence band pills show "label · NN%" with the matching tier copy. + await expect(noteDraft.getByText('Solid scaffold · 83%')).toBeVisible(); + await expect(melodyContour.getByText('Workable draft · 72%')).toBeVisible(); + + // Stem note draft has stem filter + per-note confidence slider; melody does not. + await expect(noteDraft.getByRole('button', { name: 'bass' })).toBeVisible(); + await expect(noteDraft.getByRole('button', { name: 'other' })).toBeVisible(); + await expect(noteDraft.locator('input[type="range"]')).toHaveCount(2); // confidence + swing + await expect(melodyContour.locator('input[type="range"]')).toHaveCount(1); // swing only + + // 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'); + const melodyPreview = melodyContour.getByTestId('midi-preview-melody'); + const melodyDownload = melodyContour.getByTestId('midi-download-melody'); + await expect(stemsPreview).toBeEnabled(); + await expect(stemsDownload).toBeEnabled(); + await expect(melodyPreview).toBeEnabled(); + await expect(melodyDownload).toBeEnabled(); + + // Quantize controls still work inside Block A. + await noteDraft.getByRole('button', { name: '1/16 note' }).click(); + const stemsSwing = noteDraft.locator('input[type="range"]').nth(1); + await expect(stemsSwing).toBeEnabled(); + await pressSliderKey(stemsSwing, 'ArrowRight', 30); + await expect(stemsSwing).toHaveValue('30'); + + // Stem-note Download produces the per-block filename. + const stemsDownloadPromise = page.waitForEvent('download'); + await stemsDownload.click(); + const stemsFile = await stemsDownloadPromise; + expect(stemsFile.suggestedFilename()).toBe('track-analysis-stems.mid'); + // Melody Download produces its own filename. + const melodyDownloadPromise = page.waitForEvent('download'); + await melodyDownload.click(); + const melodyFile = await melodyDownloadPromise; + expect(melodyFile.suggestedFilename()).toBe('track-analysis-melody.mid'); + + // Shared preview controller: only one preview is active at a time. Starting + // melody preview while stems is playing flips the stems button back to + // "Preview" and the melody button shows "Stop". + await stemsPreview.click(); + await expect(stemsPreview).toContainText(/Stop/); + await melodyPreview.click(); + await expect(melodyPreview).toContainText(/Stop/); + await expect(stemsPreview).not.toContainText(/Stop/); + await melodyPreview.click(); // stop melody so the test ends cleanly + await expect(melodyPreview).not.toContainText(/Stop/); + + // Slider-filtered-to-zero: drag the confidence slider above the highest + // per-note confidence (0.92) so every note is filtered. Download hides; + // the explanatory note appears; the piano roll remains. + const stemsConfidenceSlider = noteDraft.locator('input[type="range"]').nth(0); + await stemsConfidenceSlider.focus(); + await pressSliderKey(stemsConfidenceSlider, 'End', 1); + await expect(stemsConfidenceSlider).toHaveValue('1'); + await expect(noteDraft.getByText(/Confidence slider filtered every note/i)).toBeVisible(); + await expect(noteDraft.getByTestId('midi-download-stems')).toHaveCount(0); + await expect(noteDraft.getByTestId('note-draft-piano-roll')).toBeVisible(); + + // Collapse hides the blocks; expand restores them. + await panel.getByRole('button', { name: /Collapse session musician panel/i }).click(); + await expect(noteDraft.getByRole('button', { name: '1/16 note' })).toHaveCount(0); await panel.getByRole('button', { name: /Expand session musician panel/i }).click(); - await expect(panel.getByRole('button', { name: '1/16 note' })).toBeVisible(); + await expect(noteDraft.getByRole('button', { name: '1/16 note' })).toBeVisible(); }); test('missing melodyDetail shows MIDI unavailable state', async ({ page }) => { @@ -608,11 +617,15 @@ test('missing melodyDetail shows MIDI unavailable state', async ({ page }) => { await page.getByRole('button', { name: /Run Analysis/i }).click(); const panel = page.locator('section').filter({ hasText: /SESSION MUSICIAN/i }).first(); - // With pitchNoteMode='off', the panel shows opted-out state even when melodyDetail is absent. - // "PITCH & MELODY UNAVAILABLE" no longer renders; the opted-out banner is the primary cue. - await expect(panel.locator('p').filter({ hasText: /Pitch\/note translation is off/i }).first()).toBeVisible(); - await expect(panel.getByRole('button', { name: /Preview/i })).toBeDisabled(); - await expect(panel.getByRole('button', { name: /Download \.mid/i })).toBeDisabled(); + // pitchNoteMode='off' + no melodyDetail: the off-state banner is the only thing rendered. + // Neither block renders, so the Preview / Download buttons are not in the DOM at all. + await expect(panel.getByTestId('session-musician-off-banner')).toBeVisible(); + await expect(panel.getByTestId('note-draft-block')).toHaveCount(0); + await expect(panel.getByTestId('melody-contour-block')).toHaveCount(0); + await expect(panel.getByTestId('midi-preview-stems')).toHaveCount(0); + await expect(panel.getByTestId('midi-download-stems')).toHaveCount(0); + await expect(panel.getByTestId('midi-preview-melody')).toHaveCount(0); + await expect(panel.getByTestId('midi-download-melody')).toHaveCount(0); }); test('pitch/note off with melody present shows opted-out state', async ({ page }) => { @@ -781,11 +794,21 @@ test('pitch/note off with melody present shows opted-out state', async ({ page } const panel = page.locator('section').filter({ hasText: /SESSION MUSICIAN/i }).first(); // Opted-out banner is the primary disclosure - await expect(panel.locator('p').filter({ hasText: /Pitch\/note translation is off/i }).first()).toBeVisible(); - await expect(panel.locator('p').filter({ hasText: /Re-enable the Stem Pitch\/Note Translation toggle/i }).first()).toBeVisible(); - // Preview and Download are disabled - await expect(panel.getByRole('button', { name: /Preview/i })).toBeDisabled(); - await expect(panel.getByRole('button', { name: /Download \.mid/i })).toBeDisabled(); - // Quantize controls are absent (not rendered for opted-out state) - await expect(panel.getByRole('button', { name: /1\/16 note/i })).toHaveCount(0); + await expect(panel.getByTestId('session-musician-off-banner')).toBeVisible(); + await expect( + panel.locator('p').filter({ hasText: /Re-enable the Stem Pitch\/Note Translation toggle/i }).first(), + ).toBeVisible(); + + // Block A (stem note draft) is absent because pitchNoteMode='off'. + await expect(panel.getByTestId('note-draft-block')).toHaveCount(0); + await expect(panel.getByTestId('midi-preview-stems')).toHaveCount(0); + await expect(panel.getByTestId('midi-download-stems')).toHaveCount(0); + + // Block B (melody contour) still renders normally, with its own controls. + const melodyContour = panel.getByTestId('melody-contour-block'); + await expect(melodyContour).toBeVisible(); + await expect(melodyContour.getByTestId('midi-preview-melody')).toBeEnabled(); + await expect(melodyContour.getByTestId('midi-download-melody')).toBeEnabled(); + // Block B owns its own quantize controls. + await expect(melodyContour.getByRole('button', { name: '1/16 note' })).toBeVisible(); });