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
1 change: 1 addition & 0 deletions apps/backend/JSON_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ Implementation notes:
| `transcriptionDetail.stemSeparationUsed` | `bool` | Whether transcription ran on separated Demucs stems instead of the full mix. | boolean | `true` means the merged result came from one or more stems such as `bass` and `other`. |
| `transcriptionDetail.fullMixFallback` | `bool` | `true` when the transcription ran on the full mix because usable stems were unavailable. | boolean | Treat `true` as a quality warning on dense material; downstream UX should inform rather than block. |
| `transcriptionDetail.stemsTranscribed` | `string[]` | Ordered list of audio sources transcribed for this result. | source labels | Use to distinguish full-mix fallback from stem-based transcription. |
| `transcriptionDetail.perStemAverageConfidence` | `object` | Mean confidence per stem source, computed over the retained post-dedup, post-cap notes whose `stemSource` matches each key. Empty (`{}`) when `fullMixFallback` is `true`; otherwise contains one entry per stem with at least one surviving note (e.g. `{"bass": 0.85, "other": 0.32}`). | mapping of stem name to 0.0-1.0 confidence | Lets the UI show a separate confidence band when the producer toggles the stem filter — a Solid bass stem shouldn't be hidden behind a Rough lead stem (or vice versa). |
| `transcriptionDetail.notes` | `array<object>` | Retained note events sorted by onset time after merge, deduplication, and capping. | list of note objects | Combined note timeline from stem-based or full-mix transcription, bounded for UI and export use. |
| `transcriptionDetail.notes[].pitchMidi` | `int` | MIDI note number for the event. | 0-127 | Directly usable in piano-roll or MIDI regeneration workflows. |
| `transcriptionDetail.notes[].pitchName` | `string` | Note name for the event. | note label | Human-readable pitch name for summaries and prompts. |
Expand Down
1 change: 1 addition & 0 deletions apps/backend/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@
_notes_overlap_for_dedup,
_select_transcription_winner,
_deduplicate_transcription_notes,
_per_stem_average_confidence,
_extract_contour_notes,
TORCHCREPE_SAMPLE_RATE,
TORCHCREPE_HOP_LENGTH,
Expand Down
36 changes: 36 additions & 0 deletions apps/backend/analyze_transcription.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,34 @@ def _select_transcription_winner(note: dict, candidate: dict, prefer_confidence_
return note if float(note.get("onsetSeconds", 0.0)) <= float(candidate.get("onsetSeconds", 0.0)) else candidate


def _per_stem_average_confidence(notes: list[dict]) -> dict[str, float]:
"""Mean confidence per stemSource for the notes that survived dedup + cap.

Each transcription note carries the stem it came from ("bass", "other", or
"full_mix"). Returning per-stem averages lets the UI show a different
confidence band when the producer toggles the stem filter — a bass stem
that tracked well shouldn't be hidden behind a noisy lead stem's
confidence (or vice versa).
"""
if not notes:
return {}
by_stem: dict[str, list[float]] = {}
for note in notes:
stem = note.get("stemSource")
if not isinstance(stem, str) or not stem:
continue
try:
conf = float(note.get("confidence", 0.0))
except (TypeError, ValueError):
continue
by_stem.setdefault(stem, []).append(conf)
return {
stem: round(float(np.mean(np.asarray(confidences, dtype=np.float64))), 4)
for stem, confidences in by_stem.items()
if confidences
}


def _deduplicate_transcription_notes(notes: list[dict]) -> list[dict]:
if len(notes) <= 1:
return [dict(note) for note in notes]
Expand Down Expand Up @@ -541,6 +569,7 @@ def transcribe(
"stemSeparationUsed": stem_separation_used,
"fullMixFallback": full_mix_fallback,
"stemsTranscribed": stems_transcribed,
"perStemAverageConfidence": {},
"notes": [],
}
}
Expand All @@ -561,6 +590,12 @@ def transcribe(
average_confidence = round(
float(np.mean(np.asarray(confidence_values, dtype=np.float64))), 4
)
# Empty in full-mix fallback so the UI doesn't show a per-stem
# band when there's no meaningful separation to report. The
# frontend falls back to averageConfidence in that case.
per_stem_confidence = (
{} if full_mix_fallback else _per_stem_average_confidence(notes)
)

return {
"transcriptionDetail": {
Expand All @@ -577,6 +612,7 @@ def transcribe(
"stemSeparationUsed": stem_separation_used,
"fullMixFallback": full_mix_fallback,
"stemsTranscribed": stems_transcribed,
"perStemAverageConfidence": per_stem_confidence,
"notes": notes,
}
}
Expand Down
42 changes: 42 additions & 0 deletions apps/backend/tests/test_analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,48 @@ def test_deduplicate_transcription_notes_keeps_higher_confidence_for_near_duplic
self.assertEqual(deduplicated[0]["confidence"], 0.81)
self.assertEqual(deduplicated[0]["durationSeconds"], 0.25)

def test_per_stem_average_confidence_groups_means_by_stem_source(self) -> None:
notes = [
{"pitchMidi": 48, "confidence": 0.9, "stemSource": "bass"},
{"pitchMidi": 50, "confidence": 0.8, "stemSource": "bass"},
{"pitchMidi": 64, "confidence": 0.4, "stemSource": "other"},
{"pitchMidi": 67, "confidence": 0.2, "stemSource": "other"},
]

means = self.analyze._per_stem_average_confidence(notes)

self.assertEqual(set(means.keys()), {"bass", "other"})
self.assertAlmostEqual(means["bass"], 0.85, places=4)
self.assertAlmostEqual(means["other"], 0.3, places=4)

def test_per_stem_average_confidence_returns_empty_for_empty_notes(self) -> None:
self.assertEqual(self.analyze._per_stem_average_confidence([]), {})

def test_per_stem_average_confidence_skips_notes_with_missing_stem_source(self) -> None:
notes = [
{"pitchMidi": 48, "confidence": 0.9, "stemSource": "bass"},
{"pitchMidi": 60, "confidence": 0.7}, # missing stemSource entirely
{"pitchMidi": 64, "confidence": 0.5, "stemSource": ""}, # empty string
{"pitchMidi": 65, "confidence": 0.4, "stemSource": None}, # explicit null
]

means = self.analyze._per_stem_average_confidence(notes)

self.assertEqual(set(means.keys()), {"bass"})
self.assertAlmostEqual(means["bass"], 0.9, places=4)

def test_per_stem_average_confidence_tolerates_invalid_confidence_values(self) -> None:
notes = [
{"pitchMidi": 48, "confidence": 0.9, "stemSource": "bass"},
{"pitchMidi": 50, "confidence": "not a number", "stemSource": "bass"},
{"pitchMidi": 52, "confidence": None, "stemSource": "bass"},
]

means = self.analyze._per_stem_average_confidence(notes)

# The two malformed entries are skipped; only the 0.9 contributes.
self.assertAlmostEqual(means["bass"], 0.9, places=4)



class AnalyzeTextureCharacterTests(unittest.TestCase):
Expand Down
99 changes: 99 additions & 0 deletions apps/backend/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3196,6 +3196,105 @@ def fake_subprocess_run(command, **kwargs):
stem_kinds = [artifact["kind"] for artifact in snapshot["artifacts"]["stems"]]
self.assertEqual(stem_kinds, ["stem_bass", "stem_other"])

def test_pitch_note_worker_passes_per_stem_confidence_through_to_run_snapshot(self) -> None:
"""A subprocess that emits perStemAverageConfidence must round-trip into the run snapshot."""
from analysis_runtime import AnalysisRuntime

mock_result = json.dumps(
{
"transcriptionDetail": {
"transcriptionMethod": "torchcrepe-viterbi",
"noteCount": 2,
"averageConfidence": 0.6,
"dominantPitches": [],
"pitchRange": {
"minMidi": 48,
"maxMidi": 64,
"minName": "C3",
"maxName": "E4",
},
"stemSeparationUsed": True,
"fullMixFallback": False,
"stemsTranscribed": ["bass", "other"],
"perStemAverageConfidence": {"bass": 0.85, "other": 0.32},
"notes": [
{
"pitchMidi": 48,
"pitchName": "C3",
"onsetSeconds": 0.0,
"durationSeconds": 0.5,
"confidence": 0.85,
"stemSource": "bass",
},
{
"pitchMidi": 64,
"pitchName": "E4",
"onsetSeconds": 0.6,
"durationSeconds": 0.3,
"confidence": 0.32,
"stemSource": "other",
},
],
}
}
)

with tempfile.TemporaryDirectory(prefix="asa_pitch_note_per_stem_") as temp_dir:
runtime = AnalysisRuntime(Path(temp_dir) / "runtime")
created = runtime.create_run(
filename="track.mp3",
content=b"fake-audio",
mime_type="audio/mpeg",
pitch_note_mode="stem_notes",
pitch_note_backend="torchcrepe-viterbi",
interpretation_mode="off",
interpretation_profile="producer_summary",
interpretation_model=None,
)
attempt_id = runtime.create_pitch_note_attempt(
created["runId"],
backend_id="torchcrepe-viterbi",
mode="stem_notes",
status="queued",
)
runtime.reserve_pitch_note_attempt(attempt_id)

def fake_subprocess_run(command, **kwargs):
stem_output_dir = None
if "--stem-output-dir" in command:
idx = command.index("--stem-output-dir")
stem_output_dir = command[idx + 1]
if stem_output_dir:
Path(stem_output_dir).mkdir(parents=True, exist_ok=True)
(Path(stem_output_dir) / "bass.wav").write_bytes(b"bass-audio")
(Path(stem_output_dir) / "other.wav").write_bytes(b"other-audio")
return subprocess.CompletedProcess(
args=command,
returncode=0,
stdout=mock_result,
stderr="",
)

with patch.object(server.subprocess, "run", side_effect=fake_subprocess_run):
server._execute_pitch_note_attempt(
runtime,
{
"attemptId": attempt_id,
"runId": created["runId"],
"backendId": "torchcrepe-viterbi",
"mode": "stem_notes",
},
)

snapshot = runtime.get_run(created["runId"])
pitch_note_result = snapshot["stages"]["pitchNoteTranslation"]["result"]
self.assertIsNotNone(pitch_note_result)
# The new field round-trips end-to-end with no whitelist trimming.
self.assertEqual(
pitch_note_result["perStemAverageConfidence"],
{"bass": 0.85, "other": 0.32},
)

def test_openapi_schema_exposes_phase2_route(self) -> None:
spec = server.app.openapi()
self.assertIn("/api/phase2", spec["paths"])
Expand Down
11 changes: 10 additions & 1 deletion apps/ui/src/components/sessionMusician/NoteDraftBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { QuantizeOptions } from '../../services/midi/types';
import { quantizeNotes } from '../../services/midi/quantization';
import {
deriveNoteDraftRenderState,
selectNoteDraftBandConfidence,
type NoteDraftRenderState,
type PitchNoteMode,
} from '../../services/sessionMusician/renderState';
Expand Down Expand Up @@ -186,6 +187,14 @@ export function NoteDraftBlock({
const bandOverrideCopy = isOverride ? (notice ?? null) : undefined;
const bandOverrideTone = isOverride ? ('rough' as const) : undefined;

// Per-stem band confidence selection — see services/sessionMusician/renderState.ts
// for the precedence and fallback rules.
const bandConfidence = selectNoteDraftBandConfidence(
transcriptionDetail,
stemFilter,
renderState,
);

// Slider filtered everything out — keep the piano roll rendered (it shows
// "no notes" gracefully) but hide the Download since there's nothing to ship.
const sliderEmptiedNotes =
Expand All @@ -200,7 +209,7 @@ export function NoteDraftBlock({
{headerBlock}

<ConfidenceBandBadge
confidence={transcriptionDetail.averageConfidence}
confidence={bandConfidence}
overrideLabel={bandOverrideLabel ?? null}
overrideCopy={bandOverrideCopy ?? null}
overrideTone={bandOverrideTone}
Expand Down
11 changes: 11 additions & 0 deletions apps/ui/src/services/analysisRunsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,7 @@ function parsePitchNoteResult(value: unknown): PitchNoteTranslationStageSnapshot
stemsTranscribed: Array.isArray(result.stemsTranscribed)
? result.stemsTranscribed.map((entry) => String(entry))
: [],
perStemAverageConfidence: parsePerStemAverageConfidence(result.perStemAverageConfidence),
dominantPitches: Array.isArray(result.dominantPitches)
? result.dominantPitches.map((entry) => expectRecord(entry, 'dominant pitch') as PitchNoteTranslationStageSnapshot['result']['dominantPitches'][number])
: [],
Expand All @@ -611,6 +612,16 @@ function parsePitchNoteResult(value: unknown): PitchNoteTranslationStageSnapshot
};
}

function parsePerStemAverageConfidence(value: unknown): Record<string, number> | undefined {
if (typeof value !== 'object' || value == null || Array.isArray(value)) return undefined;
const result: Record<string, number> = {};
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
if (typeof raw !== 'number' || !Number.isFinite(raw)) continue;
result[key] = raw;
}
return result;
}

function parseNullableRecord(value: unknown): Record<string, unknown> | null {
if (value == null) {
return null;
Expand Down
13 changes: 13 additions & 0 deletions apps/ui/src/services/backendPhase1Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,12 +975,25 @@ function parseOptionalTranscriptionDetail(
raw.stemSeparationUsed === false,
),
stemsTranscribed: parseTranscribedStems(raw.stemsTranscribed),
perStemAverageConfidence: parsePerStemAverageConfidence(raw.perStemAverageConfidence),
dominantPitches,
pitchRange,
notes,
};
}

function parsePerStemAverageConfidence(value: unknown): Record<string, number> | undefined {
if (!isRecord(value)) return undefined;
const result: Record<string, number> = {};
for (const [key, raw] of Object.entries(value)) {
if (typeof raw !== "number" || !Number.isFinite(raw)) continue;
// Clamp to 0-1 just like averageConfidence does — defensive against
// backend payload bugs.
result[key] = clamp01(raw);
}
return result;
}

function parseMelodyNotes(value: unknown): NonNullable<Phase1Result["melodyDetail"]>["notes"] {
if (!Array.isArray(value)) return [];

Expand Down
1 change: 1 addition & 0 deletions apps/ui/src/services/sessionMusician/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export { hasStemListeningNotesContent } from './stemListeningNotes';
export {
deriveNoteDraftRenderState,
isLegacyTranscriptionMethod,
selectNoteDraftBandConfidence,
type NoteDraftRenderState,
type PitchNoteMode,
} from './renderState';
31 changes: 31 additions & 0 deletions apps/ui/src/services/sessionMusician/renderState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,37 @@ export function isLegacyTranscriptionMethod(
return !TORCHCREPE_METHODS.has(normalized);
}

/**
* Pick the confidence value the Block A band pill should display.
*
* In the nominal stem-aware state, toggling the stem filter to BASS or OTHER
* should surface that stem's average confidence — otherwise a Solid bass stem
* (0.85) is hidden behind a Rough lead stem (0.32). Falls back to the overall
* `averageConfidence` when:
* - the panel is in a non-stem-aware render state (fallback / legacy), where
* the override notice replaces the band copy anyway,
* - no stem filter is active (All button),
* - the per-stem field is missing (legacy snapshots produced before the
* backend started emitting it),
* - or the selected stem key isn't keyed in the per-stem map for some reason.
*
* Extracted to a pure helper so the wiring is unit-testable without mounting
* the component — apps/ui's Vitest config runs in node, no DOM, no clicks.
*/
export function selectNoteDraftBandConfidence(
transcriptionDetail: TranscriptionDetail,
stemFilter: string | null,
renderState: NoteDraftRenderState,
): number {
if (renderState === 'stem-aware' && stemFilter) {
const perStem = transcriptionDetail.perStemAverageConfidence?.[stemFilter];
if (typeof perStem === 'number' && Number.isFinite(perStem)) {
return perStem;
}
}
return transcriptionDetail.averageConfidence;
}

export function deriveNoteDraftRenderState(
transcriptionDetail: TranscriptionDetail | null | undefined,
pitchNoteMode: PitchNoteMode,
Expand Down
7 changes: 7 additions & 0 deletions apps/ui/src/types/measurement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export interface TranscriptionDetail {
stemSeparationUsed: boolean;
fullMixFallback: boolean;
stemsTranscribed: string[];
/**
* Mean confidence per stem source, computed over the post-dedup, post-cap notes.
* Empty `{}` when `fullMixFallback` is true; otherwise contains one entry per
* stem with at least one surviving note (e.g. `{"bass": 0.85, "other": 0.32}`).
* Optional because legacy snapshots and the empty-notes path may omit it.
*/
perStemAverageConfidence?: Record<string, number>;
dominantPitches: Array<{
pitchMidi: number;
pitchName: string;
Expand Down
Loading