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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions apps/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -473,14 +473,30 @@ export default function App() {
return currentLogs;
}, []);

const handleStartAnalysis = async () => {
// Overrides let the Session Musician panel re-trigger analysis for a legacy
// (non-torchcrepe) snapshot with the stem-aware pipeline forced on, without
// waiting for the React state of the toggle to flush before kicking off
// the run. The current toggle is also updated so the UI stays consistent.
type StartAnalysisOverrides = {
pitchNoteRequested?: boolean;
};

const handleStartAnalysis = async (overrides: StartAnalysisOverrides = {}) => {
if (!audioFile) return;

const activeFile = audioFile;
const activeModel = selectedModel;
const activeEstimate = analysisEstimate;
const activeTimeoutMs = deriveAnalyzeTimeoutMs(activeEstimate?.totalHighMs);
const audioMetadata = buildAudioMetadata(activeFile);
const activePitchNoteRequested =
overrides.pitchNoteRequested ?? pitchNoteTranslationRequested;
if (
overrides.pitchNoteRequested !== undefined &&
overrides.pitchNoteRequested !== pitchNoteTranslationRequested
) {
setPitchNoteTranslationRequested(overrides.pitchNoteRequested);
}

startRenderBenchmarkCycle(window);
ignoredRunIdsRef.current.clear();
Expand Down Expand Up @@ -609,7 +625,7 @@ export default function App() {
},
{
analysisMode,
pitchNoteRequested: pitchNoteTranslationRequested,
pitchNoteRequested: activePitchNoteRequested,
timeoutMs: activeTimeoutMs,
signal: ac.signal,
interpretationRequested,
Expand Down Expand Up @@ -1231,6 +1247,11 @@ export default function App() {
apiBaseUrl={appConfig.apiBaseUrl}
runId={activeRunId ?? undefined}
pitchNoteMode={analysisRun?.requestedStages.pitchNoteMode ?? null}
onReanalyzeWithStemAware={
audioFile && !isAnalyzing
? () => handleStartAnalysis({ pitchNoteRequested: true })
: undefined
}
/>
</Suspense>
) : null}
Expand Down
9 changes: 9 additions & 0 deletions apps/ui/src/components/AnalysisResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ export interface AnalysisResultsProps {
apiBaseUrl?: string;
runId?: string;
pitchNoteMode?: 'stem_notes' | 'off' | null;
/**
* Click handler for the "Re-analyze with stem-aware pipeline" button that
* Block A renders in its legacy render state. App owns the run-creation
* primitives; pass `undefined` to hide the button (e.g. while an analysis
* is already in flight or no source File is loaded).
*/
onReanalyzeWithStemAware?: () => void;
}

const LOW_CHORD_CONFIDENCE_THRESHOLD = 0.5;
Expand Down Expand Up @@ -396,6 +403,7 @@ export function AnalysisResults({
apiBaseUrl,
runId,
pitchNoteMode = null,
onReanalyzeWithStemAware,
}: AnalysisResultsProps) {
const [openArrangement, setOpenArrangement] = useState<Record<string, boolean>>({});
const [openSonic, setOpenSonic] = useState<Set<string>>(new Set());
Expand Down Expand Up @@ -1551,6 +1559,7 @@ export function AnalysisResults({
sourceFileName={sourceFileName}
pitchNoteMode={pitchNoteMode}
hasStemListeningNotes={hasStemSummaryContent}
onReanalyzeWithStemAware={onReanalyzeWithStemAware}
/>
</div>

Expand Down
8 changes: 8 additions & 0 deletions apps/ui/src/components/SessionMusicianPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,19 @@ interface SessionMusicianPanelProps {
pitchNoteMode?: PitchNoteMode;
/** Set by AnalysisResults when the Gemini stem listening notes section will render. */
hasStemListeningNotes?: boolean;
/**
* Optional callback for the "Re-analyze with stem-aware pipeline" button
* that NoteDraftBlock renders only in the `legacy` render state. App owns
* the run-creation primitives; passing `undefined` hides the button.
*/
onReanalyzeWithStemAware?: () => void;
}

export function SessionMusicianPanel({
phase1,
pitchNoteMode = null,
hasStemListeningNotes = false,
onReanalyzeWithStemAware,
}: SessionMusicianPanelProps) {
const melodyDetail = phase1.melodyDetail ?? null;
const transcriptionDetail = phase1.transcriptionDetail ?? null;
Expand Down Expand Up @@ -207,6 +214,7 @@ export function SessionMusicianPanel({
controller={controller}
bpm={phase1.bpm}
trackDurationSeconds={phase1.durationSeconds}
onReanalyzeWithStemAware={onReanalyzeWithStemAware}
/>
)}
{showMelodyBlock && (
Expand Down
20 changes: 20 additions & 0 deletions apps/ui/src/components/sessionMusician/NoteDraftBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// precedence rules.

import React, { useMemo, useState } from 'react';
import { RotateCw } from 'lucide-react';
import type { TranscriptionDetail } from '../../types';
import type { QuantizeOptions } from '../../services/midi/types';
import { quantizeNotes } from '../../services/midi/quantization';
Expand Down Expand Up @@ -83,6 +84,12 @@ interface NoteDraftBlockProps {
bpm: number;
/** Used to scale the piano-roll x-axis; defaults to last-note-end when missing. */
trackDurationSeconds: number;
/**
* When set AND the render state is `legacy`, the block renders a button
* that re-runs analysis on the current source file with the stem-aware
* torchcrepe pipeline forced on. Hidden in all other render states.
*/
onReanalyzeWithStemAware?: () => void;
}

export function NoteDraftBlock({
Expand All @@ -91,6 +98,7 @@ export function NoteDraftBlock({
controller,
bpm,
trackDurationSeconds,
onReanalyzeWithStemAware,
}: NoteDraftBlockProps) {
const renderState = useMemo(
() => deriveNoteDraftRenderState(transcriptionDetail, pitchNoteMode),
Expand Down Expand Up @@ -207,6 +215,18 @@ export function NoteDraftBlock({
testId="note-draft-band"
/>

{renderState === 'legacy' && onReanalyzeWithStemAware && (
<button
type="button"
onClick={onReanalyzeWithStemAware}
data-testid="note-draft-reanalyze"
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent/10 border border-accent/40 text-accent text-xs font-mono uppercase rounded-sm hover:bg-accent/20 transition-colors w-fit"
>
<RotateCw className="w-3.5 h-3.5" />
Re-analyze with stem-aware pipeline
</button>
)}

<div className="flex flex-wrap items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-text-secondary">
<span>{countLabel}</span>
<span className="opacity-50">|</span>
Expand Down
88 changes: 88 additions & 0 deletions apps/ui/tests/services/sessionMusicianPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,94 @@ describe('SessionMusicianPanel — Block A render states', () => {
expect(deriveNoteDraftRenderState(transcriptionDetail, 'stem_notes')).toBe('legacy');
});

it('renders the Re-analyze CTA in legacy state when the callback is provided', () => {
const transcriptionDetail = stemAwareTranscription({
transcriptionMethod: 'basic-pitch',
});
const html = renderToStaticMarkup(
React.createElement(SessionMusicianPanel, {
phase1: { ...baseMeasurement, transcriptionDetail },
pitchNoteMode: 'stem_notes',
onReanalyzeWithStemAware: () => undefined,
}),
);
expect(html).toContain('data-render-state="legacy"');
expect(html).toContain('data-testid="note-draft-reanalyze"');
expect(html).toContain('Re-analyze with stem-aware pipeline');
});

it('omits the Re-analyze CTA in legacy state when the callback is absent', () => {
const transcriptionDetail = stemAwareTranscription({
transcriptionMethod: 'basic-pitch',
});
const html = renderToStaticMarkup(
React.createElement(SessionMusicianPanel, {
phase1: { ...baseMeasurement, transcriptionDetail },
pitchNoteMode: 'stem_notes',
}),
);
expect(html).toContain('data-render-state="legacy"');
expect(html).not.toContain('data-testid="note-draft-reanalyze"');
});

it('does not render the Re-analyze CTA in the stem-aware render state', () => {
const html = renderToStaticMarkup(
React.createElement(SessionMusicianPanel, {
phase1: { ...baseMeasurement, transcriptionDetail: stemAwareTranscription() },
pitchNoteMode: 'stem_notes',
onReanalyzeWithStemAware: () => undefined,
}),
);
expect(html).toContain('data-render-state="stem-aware"');
expect(html).not.toContain('data-testid="note-draft-reanalyze"');
});

it('does not render the Re-analyze CTA in the full-mix-fallback render state', () => {
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, transcriptionDetail },
pitchNoteMode: 'stem_notes',
onReanalyzeWithStemAware: () => undefined,
}),
);
expect(html).toContain('data-render-state="full-mix-fallback"');
expect(html).not.toContain('data-testid="note-draft-reanalyze"');
});

it('does not render the Re-analyze CTA in ran-with-no-result or requested-but-unavailable states', () => {
const ranHtml = renderToStaticMarkup(
React.createElement(SessionMusicianPanel, {
phase1: {
...baseMeasurement,
transcriptionDetail: stemAwareTranscription({ notes: [], noteCount: 0 }),
},
pitchNoteMode: 'stem_notes',
onReanalyzeWithStemAware: () => undefined,
}),
);
expect(ranHtml).toContain('data-render-state="ran-with-no-result"');
expect(ranHtml).not.toContain('data-testid="note-draft-reanalyze"');

const requestedHtml = renderToStaticMarkup(
React.createElement(SessionMusicianPanel, {
phase1: { ...baseMeasurement },
pitchNoteMode: 'stem_notes',
onReanalyzeWithStemAware: () => undefined,
}),
);
expect(requestedHtml).toContain('data-render-state="requested-but-unavailable"');
expect(requestedHtml).not.toContain('data-testid="note-draft-reanalyze"');
});

it('shows requested-but-unavailable notice when stem_notes is on but transcription is missing', () => {
const html = renderToStaticMarkup(
React.createElement(SessionMusicianPanel, {
Expand Down
Loading