From 657547b159bdfa3b4509e0e5aeb2ed95637e7c6c Mon Sep 17 00:00:00 2001
From: slitty-codes
Date: Thu, 14 May 2026 13:54:33 +1200
Subject: [PATCH 1/2] audit: chain-of-custody, recommendations-first IA,
applied tracker, grammar post-process
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implements the design audit's prescription end-to-end (findings #1–#15 + N1/N2/N7/N9/N10
+ follow-ups). The product's chain-of-custody promise — every Phase 2 recommendation
traces back to the Phase 1 measurement that justifies it — is now visually first-class
on every card, not a 9px monospace footnote.
What ships:
* Chain-of-custody (findings #2 + #3). New CitationBlock primitive renders a structured
"GROUNDED IN" block above every Mix Chain / Patches / Sonic Element card with
humanized labels (FIELD_LABELS map, ~50 entries + humanizeFieldPath fallback) and a
ConfidenceBandBadge pill computed from the worst confidence among cited fields.
Also retires GroundingBadgeList at the Track Layout site; segmentIndexes ride
through a new extraRows prop.
* Recommendations-first IA (#1). MeasurementDashboard moved to the bottom of the
results scroll; StickyNav's 9 measurement pills collapse to one trailing
"Measurements" entry. Producers hit Style → Sonic → Mix Chain → Patches → Session
before the measurement evidence.
* Header polish (#7, #9, #11). CPU meter removed (browser-tab CPU is misleading
during backend analysis), "Local DSP Engine v1.6.0" eyebrow removed (resolves
mobile 3-line wrap), Dense DAW Lab demoted from accent chip to quiet text link.
* Engineering vocab cleanup (#8 + N3/N4/N5/N8). New userLabels.ts service translates
field paths to producer-readable labels at every render. Button labels renamed
(Download data / Download report). FAMILY: NATIVE chip dropped from meta-badge rows.
workflowStage prettified at the view-model layer ("Sound design" not "SOUND_DESIGN").
AI Interpretation gated copy reworded ("AI interpretation isn't configured…" not
"Developer kill-switch is off").
* Applied-recommendations tracker (#14 + #15). Per-card checkbox affordance + section-
header "N of M applied" chip + localStorage persistence keyed by audio content
SHA256. Producer can rename their file without losing their progress.
* Idle value-prop panel (#5). Replaces the 200px "NO SIGNAL DETECTED" canvas with
a producer-readable explanation of what ASA does, with honest pacing copy
(4–5 min Phase 2 wait, not 2–5 min).
* Patches group structure. Mirrors Mix Chain's emoji-eyebrow grouping (Drums / Bass /
Synth / Master) so producers can jump to the bass patch without scanning 8 cards.
* Input Source collapse (N9). Post-analysis, the Input Source panel collapses to a
compact summary with filename + duration + "Analyze new file" + "Adjust settings".
Frees the top of the page for the results the user came for.
* AnalysisStatusPanel primary readout (#6). Stage diagnostic message promoted from
9px footnote to the visual focus of the progress card. Pre-existing tone-aware
fill (running / success / failed) preserved.
* Phase 2 failure mode (N1). Header subtitle derives from interpretation stage status
(no more "PHASE COMPLETE" while INTERPRET still RUNNING/FAILED). StickyNav Phase 2
pills render disabled with hover-reason when sections didn't populate. Retry button
gated on error.retryable; non-retryable failures surface the error code inline.
* Misc audit follow-ups: BPM reconciled across exec card + Core Metrics tile (N2);
Signal Monitor STANDBY canvas hidden when audio isn't playing, freeing ~160px (N7);
StickyNav label "Device Chain" → "Sections" (N10, less ambiguous with Ableton's own
effects-routing meaning); BASS group icon swapped from 🫧 → Lucide AudioWaveform
(#13); toggle helper paragraphs switched from all-caps mono walls to sans-serif
sentence case (#4 revised).
* Phase 2 grammar post-process (audit final round). The prompt instruction added
earlier didn't take — Gemini still emits "by recreates / by absorbs / by shapes"
3rd-person singular forms after "by" in role/reason text. Server-side
_apply_phase2_grammar_fixes rewrites these to gerunds in-place on
mixAndMasterChain[].reason, abletonRecommendations[].{reason,advancedTip}, and
secretSauce.workflowSteps[].{instruction,measurementJustification}. Conservative
regex (\bby \w{4,}s\b) + denylist guards against plural-noun false positives.
Test coverage:
* UI: 46 test files / 540 tests pass (was 39/422 before the audit). New service tests:
userLabels (21), phase1Picker (25), citationBlock (12), appliedRecommendations (16),
formatTrackDuration (12), interpretationSubtitle (10), workflowStagePrettifier (8),
analysisStatusProgress (6), idleValuePropPanel + phase2NavReason. New DOM tests in
analysisResultsUi.test.ts cover Track Layout citation, applied-checkbox flow, mix-
chain citation rendering.
* Backend: 16 new unit tests in test_phase2_grammar_fix.py cover _to_gerund,
_fix_by_gerund_in_text, _apply_phase2_grammar_fixes including the actual
screenshot corpus (recreates → recreating, shapes → shaping, matches → matching,
etc.). Full suite: 463 of 463 ASA tests pass; 1 unrelated pre-existing failure
in tests.test_url_ingest predates this branch.
Visual verification: Playwright capture pass against a real 126s track confirmed
every surface (15 screenshots in /tmp/asa-shots-audit-final/). Phase 2 returned in
220s; localStorage round-trip verified on applied-checkbox toggles.
Documented limitations:
* The gerund rule is algorithmic — verbs requiring consonant doubling (control →
controlling, submit → submitting) degrade to "controling" / "submiting". Still
better than "by controls". Drop a hand-mapped exception into the module if
observed in real output.
Co-Authored-By: Claude Opus 4.7
---
apps/backend/prompts/phase2_system.txt | 1 +
apps/backend/server_phase2.py | 119 ++++
apps/backend/tests/test_phase2_grammar_fix.py | 262 ++++++++
apps/ui/src/App.tsx | 164 ++++-
apps/ui/src/components/AnalysisResults.tsx | 629 +++++++++++++-----
.../ui/src/components/AnalysisStatusPanel.tsx | 163 ++++-
apps/ui/src/components/CitationBlock.tsx | 149 +++++
apps/ui/src/components/IdleValuePropPanel.tsx | 110 +++
.../src/components/MeasurementDashboard.tsx | 7 +-
apps/ui/src/components/RetroVisualizer.tsx | 27 +-
apps/ui/src/components/StickyNav.tsx | 35 +-
.../components/analysisResultsViewModel.ts | 159 ++++-
.../ui/src/services/appliedRecommendations.ts | 161 +++++
apps/ui/src/services/phase1Picker.ts | 182 +++++
apps/ui/src/services/phase2Validator.ts | 7 +-
apps/ui/src/services/userLabels.ts | 127 ++++
.../e2e/analysis-runs-integration.spec.ts | 3 +-
.../tests/services/analysisResultsUi.test.ts | 314 ++++++++-
.../services/analysisResultsViewModel.test.ts | 82 ++-
.../services/analysisStatusProgress.test.ts | 172 +++++
.../services/appliedRecommendations.test.ts | 165 +++++
apps/ui/tests/services/citationBlock.test.ts | 230 +++++++
.../services/formatTrackDuration.test.ts | 37 ++
.../tests/services/idleValuePropPanel.test.ts | 50 ++
.../services/interpretationSubtitle.test.ts | 47 ++
apps/ui/tests/services/phase1Picker.test.ts | 198 ++++++
.../ui/tests/services/phase2NavReason.test.ts | 35 +
apps/ui/tests/services/userLabels.test.ts | 66 ++
.../services/workflowStagePrettifier.test.ts | 35 +
.../ui/tests/smoke/phase2-degradation.spec.ts | 6 +-
apps/ui/tests/smoke/ui-details.spec.ts | 172 +----
31 files changed, 3511 insertions(+), 403 deletions(-)
create mode 100644 apps/backend/tests/test_phase2_grammar_fix.py
create mode 100644 apps/ui/src/components/CitationBlock.tsx
create mode 100644 apps/ui/src/components/IdleValuePropPanel.tsx
create mode 100644 apps/ui/src/services/appliedRecommendations.ts
create mode 100644 apps/ui/src/services/phase1Picker.ts
create mode 100644 apps/ui/src/services/userLabels.ts
create mode 100644 apps/ui/tests/services/analysisStatusProgress.test.ts
create mode 100644 apps/ui/tests/services/appliedRecommendations.test.ts
create mode 100644 apps/ui/tests/services/citationBlock.test.ts
create mode 100644 apps/ui/tests/services/formatTrackDuration.test.ts
create mode 100644 apps/ui/tests/services/idleValuePropPanel.test.ts
create mode 100644 apps/ui/tests/services/interpretationSubtitle.test.ts
create mode 100644 apps/ui/tests/services/phase1Picker.test.ts
create mode 100644 apps/ui/tests/services/phase2NavReason.test.ts
create mode 100644 apps/ui/tests/services/userLabels.test.ts
create mode 100644 apps/ui/tests/services/workflowStagePrettifier.test.ts
diff --git a/apps/backend/prompts/phase2_system.txt b/apps/backend/prompts/phase2_system.txt
index 61b09bc6..5e6028b8 100644
--- a/apps/backend/prompts/phase2_system.txt
+++ b/apps/backend/prompts/phase2_system.txt
@@ -388,6 +388,7 @@ mixAndMasterChain
- reason
- phase1Fields (see CITATION CONTRACT)
- Keep the chain practical and grounded. Do not invent unsupported device names or parameter labels.
+- The reason field must be a complete, grammatical sentence. When using "by" before a verb, use the gerund (-ing) form. Write "Shapes drum impact by recreating the measured THD of 0.29" — NOT "by recreate the high measured harmonic distortion". Same rule for "controls bass energy by synthesizing", "tightens groove by sidechaining", "ensures translation by limiting". Never emit "by ".
secretSauce
- title: a track-specific named technique
diff --git a/apps/backend/server_phase2.py b/apps/backend/server_phase2.py
index fd729d3d..f9ae3429 100644
--- a/apps/backend/server_phase2.py
+++ b/apps/backend/server_phase2.py
@@ -2,6 +2,7 @@
import json
import mimetypes
+import re
from math import isfinite
from pathlib import Path
from typing import Any, Callable
@@ -940,6 +941,117 @@ def _resolve_interpretation_profile_config(profile_id: str) -> dict[str, Any]:
)
+### -----------------------------------------------------------------------
+### Phase 2 grammar post-process: rewrite "by <3rd-person-singular-verb>" to
+### "by ". Gemini consistently emits the wrong form in role/reason
+### text (e.g., "Shapes drum impact by recreates the harmonic distortion"
+### instead of "...by recreating the harmonic distortion"). The audit's
+### final round prescribed a server-side post-process; the prompt nudge in
+### phase2_system.txt did not take.
+###
+### Conservative: only rewrites when the word after "by" is at least 4
+### characters and lowercase a-z, and is NOT in the small denylist of common
+### plural nouns that legitimately appear after "by" in technical prose.
+### -----------------------------------------------------------------------
+
+_PHASE2_BY_VERB_RE = re.compile(r"\bby ([a-z]{4,})s\b")
+
+_PHASE2_BY_NOUN_DENYLIST = frozenset(
+ {
+ # Plural nouns / measure words that legitimately appear after "by" in
+ # technical writing. Anything in this set is left as-is.
+ "tones", "notes", "lines", "sides", "modes", "kinds", "codes",
+ "cases", "rates", "types", "pairs", "rules", "bands", "parts",
+ "tools", "forms", "works", "phases", "stages", "features",
+ "systems", "classes", "numbers", "measures", "reasons", "sources",
+ "targets", "units", "tracks", "cycles", "beats", "hits",
+ "samples", "patterns", "pieces", "levels", "values", "amounts",
+ "degrees", "effects", "paths", "fields", "frames", "styles",
+ "genres", "others", "ranges", "drums", "stems", "voices",
+ "channels", "tracks", "groups", "buses", "passes", "blocks",
+ "chords", "scales", "octaves", "intervals", "harmonics",
+ }
+)
+
+
+def _to_gerund(verb_3sg: str) -> str:
+ """3rd-person singular → gerund (best effort, no dictionary lookup).
+
+ "matches" → "matching" (strip -es, +ing)
+ "shapes" → "shaping" (strip -s, drop terminal -e, +ing)
+ "recreates" → "recreating" (same)
+ "absorbs" → "absorbing" (strip -s, +ing)
+ """
+ if verb_3sg.endswith(("ches", "shes", "sses", "tches", "xes", "zzes")):
+ stem = verb_3sg[:-2]
+ else:
+ stem = verb_3sg[:-1]
+ if stem.endswith("e") and not stem.endswith(("ee", "oe", "ye", "ie")):
+ stem = stem[:-1]
+ return stem + "ing"
+
+
+def _fix_by_gerund_in_text(text: str) -> str:
+ """Rewrite "by " patterns inside a single string.
+
+ Empty/non-string inputs pass through unchanged. The regex is bounded to
+ lowercase a-z words 4+ chars long, and the denylist guards against
+ common plural-noun false positives.
+ """
+ if not isinstance(text, str) or not text:
+ return text
+
+ def _repl(match: re.Match) -> str:
+ word_with_s = match.group(1) + "s"
+ if word_with_s in _PHASE2_BY_NOUN_DENYLIST:
+ return match.group(0)
+ return f"by {_to_gerund(word_with_s)}"
+
+ return _PHASE2_BY_VERB_RE.sub(_repl, text)
+
+
+def _fix_grammar_in_record(record: Any, fields: tuple[str, ...]) -> Any:
+ """Apply `_fix_by_gerund_in_text` to specific string fields of a record.
+ Returns the record unchanged if it isn't a dict or has none of the fields.
+ """
+ if not isinstance(record, dict):
+ return record
+ updated = False
+ for field in fields:
+ original = record.get(field)
+ if isinstance(original, str):
+ fixed = _fix_by_gerund_in_text(original)
+ if fixed != original:
+ record[field] = fixed
+ updated = True
+ return record if updated or True else record # always return; updated flag unused
+
+
+def _apply_phase2_grammar_fixes(normalized: dict[str, Any]) -> None:
+ """Walk the relevant Phase 2 free-text fields and rewrite "by s"
+ to "by ing" in-place. Targets:
+ - abletonRecommendations[].{reason, advancedTip}
+ - mixAndMasterChain[].reason
+ - secretSauce.workflowSteps[].{measurementJustification, instruction}
+ """
+ recs = normalized.get("abletonRecommendations")
+ if isinstance(recs, list):
+ for item in recs:
+ _fix_grammar_in_record(item, ("reason", "advancedTip"))
+
+ mix_chain = normalized.get("mixAndMasterChain")
+ if isinstance(mix_chain, list):
+ for item in mix_chain:
+ _fix_grammar_in_record(item, ("reason",))
+
+ secret_sauce = normalized.get("secretSauce")
+ if isinstance(secret_sauce, dict):
+ workflow_steps = secret_sauce.get("workflowSteps")
+ if isinstance(workflow_steps, list):
+ for item in workflow_steps:
+ _fix_grammar_in_record(item, ("measurementJustification", "instruction"))
+
+
def _is_str(v: Any) -> bool:
return isinstance(v, str)
@@ -1753,6 +1865,13 @@ def _normalize_and_salvage_phase2_result(
emptied_required_arrays.add(array_path)
warnings.extend(item_warnings)
+ # Audit final round: rewrite Gemini's "by recreates / by shapes / by
+ # generates / ..." → "by recreating / by shaping / by generating / ...".
+ # The prompt instruction added earlier didn't take; this is the salvage
+ # post-process. Runs in-place on `normalized`; no validation warnings
+ # emitted because grammar repair is not a contract failure.
+ _apply_phase2_grammar_fixes(normalized)
+
return normalized, warnings, emptied_required_arrays
diff --git a/apps/backend/tests/test_phase2_grammar_fix.py b/apps/backend/tests/test_phase2_grammar_fix.py
new file mode 100644
index 00000000..4f00a369
--- /dev/null
+++ b/apps/backend/tests/test_phase2_grammar_fix.py
@@ -0,0 +1,262 @@
+"""Locks in the Phase 2 gerund-fix post-process (audit final round).
+
+Gemini consistently emits 3rd-person singular forms after "by" in role/reason
+text:
+ "Shapes drum impact by recreates the harmonic distortion"
+ "Controls bass energy by shapes the synthesized sub bass"
+ "Supports melodic clarity by matches the highest confidence"
+
+The audit's stated fix path was a server-side post-process after the prompt
+instruction didn't take. This test suite documents the conversion rules and
+guards against regressions.
+"""
+import unittest
+
+from server_phase2 import (
+ _apply_phase2_grammar_fixes,
+ _fix_by_gerund_in_text,
+ _to_gerund,
+)
+
+
+class ToGerundTests(unittest.TestCase):
+ """Stem-extraction + gerund-formation rules. No regex, just orthography."""
+
+ def test_simple_consonant_stem(self):
+ # absorbs → absorb → absorbing
+ self.assertEqual(_to_gerund("absorbs"), "absorbing")
+ self.assertEqual(_to_gerund("restricts"), "restricting")
+ self.assertEqual(_to_gerund("adds"), "adding")
+ # NOTE: verbs requiring consonant-doubling (control → controlling,
+ # submit → submitting, run → running) are NOT handled algorithmically.
+ # The English doubling rule needs stress detection. If Gemini emits
+ # these in "by s" form, the gerund will read "controling" /
+ # "submiting" — still better than the "by controls" original. If this
+ # bites the producer-facing output, add a small exception map at the
+ # top of this module rather than try to implement orthographic rules.
+
+ def test_silent_e_dropped(self):
+ # shapes → shape → shap + ing → shaping
+ self.assertEqual(_to_gerund("shapes"), "shaping")
+ self.assertEqual(_to_gerund("recreates"), "recreating")
+ self.assertEqual(_to_gerund("generates"), "generating")
+ self.assertEqual(_to_gerund("provides"), "providing")
+ self.assertEqual(_to_gerund("ensures"), "ensuring")
+ self.assertEqual(_to_gerund("balances"), "balancing")
+
+ def test_sibilant_endings(self):
+ # matches (V-tch + es) → match + ing → matching
+ self.assertEqual(_to_gerund("matches"), "matching")
+ # passes (V-ss + es) → pass + ing → passing
+ self.assertEqual(_to_gerund("passes"), "passing")
+ # boxes (V-x + es) → box + ing → boxing
+ self.assertEqual(_to_gerund("boxes"), "boxing")
+ # crushes (V-sh + es) → crush + ing → crushing
+ self.assertEqual(_to_gerund("crushes"), "crushing")
+ # catches (V-tch + es) → catch + ing → catching
+ self.assertEqual(_to_gerund("catches"), "catching")
+
+
+class FixByGerundInTextTests(unittest.TestCase):
+ """End-to-end string rewrites — the kind that come out of Gemini."""
+
+ def test_actual_screenshot_corpus(self):
+ # From `/tmp/asa-shots-audit-final/14-mix-chain-viewport.png`:
+ cases = [
+ (
+ "Shapes drum impact by recreates the aggressive harmonic character.",
+ "Shapes drum impact by recreating the aggressive harmonic character.",
+ ),
+ (
+ "Shapes drum impact by absorbs harsh transients.",
+ "Shapes drum impact by absorbing harsh transients.",
+ ),
+ (
+ "Controls bass energy by shapes the synthesized sub bass envelope.",
+ "Controls bass energy by shaping the synthesized sub bass envelope.",
+ ),
+ (
+ "Controls bass energy by ensures the sub frequencies remain present.",
+ "Controls bass energy by ensuring the sub frequencies remain present.",
+ ),
+ (
+ "Supports melodic clarity by matches the highest confidence range.",
+ "Supports melodic clarity by matching the highest confidence range.",
+ ),
+ (
+ "Supports melodic clarity by generates the thick, detuned pad.",
+ "Supports melodic clarity by generating the thick, detuned pad.",
+ ),
+ (
+ "Balances the center band by provides the ambient tail.",
+ "Balances the center band by providing the ambient tail.",
+ ),
+ (
+ "Finalizes the master bus by restricts the output ceiling.",
+ "Finalizes the master bus by restricting the output ceiling.",
+ ),
+ ]
+ for original, expected in cases:
+ with self.subTest(original=original):
+ self.assertEqual(_fix_by_gerund_in_text(original), expected)
+
+ def test_does_not_touch_correct_gerund(self):
+ # Already-correct text passes through unchanged.
+ self.assertEqual(
+ _fix_by_gerund_in_text("Shapes drum impact by recreating the THD."),
+ "Shapes drum impact by recreating the THD.",
+ )
+
+ def test_preserves_plural_nouns(self):
+ # Plural nouns after "by" must not be misinterpreted as verbs.
+ denylist_cases = [
+ "Triggered by samples played in sequence.",
+ "Filtered by bands at 200 Hz and 2 kHz.",
+ "Routed by buses sharing the same return.",
+ "Quantized by beats per measure.",
+ "Driven by levels above -10 dB.",
+ ]
+ for case in denylist_cases:
+ with self.subTest(case=case):
+ self.assertEqual(_fix_by_gerund_in_text(case), case)
+
+ def test_short_words_not_rewritten(self):
+ # Words shorter than 4 chars after "by" stay intact (avoids
+ # converting "by ads", "by ANNs", etc.).
+ self.assertEqual(
+ _fix_by_gerund_in_text("Compressed by ads on the radio."),
+ "Compressed by ads on the radio.",
+ )
+
+ def test_empty_and_non_string_input(self):
+ self.assertEqual(_fix_by_gerund_in_text(""), "")
+ self.assertEqual(_fix_by_gerund_in_text(None), None)
+ self.assertEqual(_fix_by_gerund_in_text(42), 42)
+
+ def test_capitalization_at_sentence_start_unaffected(self):
+ # Regex only matches lowercase "by" — initial "By" remains as-is.
+ # This is acceptable: producer-facing role/reason fields don't start
+ # with "By" in practice (sentences begin with "Shapes...", "Controls...").
+ self.assertEqual(
+ _fix_by_gerund_in_text("By recreates the THD"),
+ "By recreates the THD",
+ )
+
+ def test_multiple_rewrites_in_one_string(self):
+ original = (
+ "Shapes drum impact by recreates the THD and balances the mix "
+ "by limits the dynamics."
+ )
+ expected = (
+ "Shapes drum impact by recreating the THD and balances the mix "
+ "by limiting the dynamics."
+ )
+ self.assertEqual(_fix_by_gerund_in_text(original), expected)
+
+
+class ApplyPhase2GrammarFixesTests(unittest.TestCase):
+ """The wiring that walks the Phase 2 record and applies the fix in-place."""
+
+ def test_rewrites_mix_chain_reasons(self):
+ normalized = {
+ "mixAndMasterChain": [
+ {
+ "order": 1,
+ "device": "Drum Buss",
+ "reason": "Shapes drum impact by recreates the THD.",
+ },
+ {
+ "order": 2,
+ "device": "Limiter",
+ "reason": "Finalizes loudness by restricts peaks.",
+ },
+ ]
+ }
+ _apply_phase2_grammar_fixes(normalized)
+ self.assertEqual(
+ normalized["mixAndMasterChain"][0]["reason"],
+ "Shapes drum impact by recreating the THD.",
+ )
+ self.assertEqual(
+ normalized["mixAndMasterChain"][1]["reason"],
+ "Finalizes loudness by restricting peaks.",
+ )
+
+ def test_rewrites_ableton_recommendations_reason_and_advanced_tip(self):
+ normalized = {
+ "abletonRecommendations": [
+ {
+ "device": "Operator",
+ "reason": "Controls bass energy by shapes the sub.",
+ "advancedTip": "Modulate the coarse by adjusts the macro.",
+ }
+ ]
+ }
+ _apply_phase2_grammar_fixes(normalized)
+ rec = normalized["abletonRecommendations"][0]
+ self.assertEqual(rec["reason"], "Controls bass energy by shaping the sub.")
+ self.assertEqual(rec["advancedTip"], "Modulate the coarse by adjusting the macro.")
+
+ def test_rewrites_secret_sauce_workflow_step_fields(self):
+ normalized = {
+ "secretSauce": {
+ "title": "Subby psytrance bass",
+ "workflowSteps": [
+ {
+ "step": 1,
+ "instruction": "Carve the room by removes the wash.",
+ "measurementJustification": "Sub-bass mono ensures stability by tightens correlation.",
+ }
+ ],
+ }
+ }
+ _apply_phase2_grammar_fixes(normalized)
+ step = normalized["secretSauce"]["workflowSteps"][0]
+ self.assertEqual(step["instruction"], "Carve the room by removing the wash.")
+ self.assertEqual(
+ step["measurementJustification"],
+ "Sub-bass mono ensures stability by tightening correlation.",
+ )
+
+ def test_no_op_on_empty_normalized(self):
+ normalized = {}
+ # Must not raise.
+ _apply_phase2_grammar_fixes(normalized)
+ self.assertEqual(normalized, {})
+
+ def test_no_op_on_already_correct_text(self):
+ normalized = {
+ "mixAndMasterChain": [
+ {
+ "order": 1,
+ "device": "Glue Compressor",
+ "reason": "Shapes drum impact by recreating the THD measurement.",
+ }
+ ]
+ }
+ original_reason = normalized["mixAndMasterChain"][0]["reason"]
+ _apply_phase2_grammar_fixes(normalized)
+ self.assertEqual(
+ normalized["mixAndMasterChain"][0]["reason"], original_reason
+ )
+
+ def test_handles_missing_inner_fields_gracefully(self):
+ normalized = {
+ "mixAndMasterChain": [
+ {"order": 1, "device": "Drum Buss"}, # no reason key
+ "not-a-dict",
+ None,
+ ],
+ "abletonRecommendations": [
+ {"device": "Operator", "reason": None}, # reason is None
+ ],
+ "secretSauce": {
+ "workflowSteps": "not-a-list",
+ },
+ }
+ # Must not raise.
+ _apply_phase2_grammar_fixes(normalized)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx
index 19c34121..d46cdc87 100644
--- a/apps/ui/src/App.tsx
+++ b/apps/ui/src/App.tsx
@@ -6,12 +6,16 @@ import { AnalysisStatusPanel } from './components/AnalysisStatusPanel';
import { DiagnosticLog } from './components/DiagnosticLog';
import { FileUpload } from './components/FileUpload';
import { WaveformPlayer } from './components/WaveformPlayer';
-import { IdleSignalMonitor } from './components/IdleSignalMonitor';
-import { useCpuMeter } from './hooks/useCpuMeter';
+// Audit Finding #5: IdleValuePropPanel now occupies the Signal Monitor area
+// when no file is selected. It tells the producer what ASA does and what to
+// expect in 30s / 5min. The legacy IdleSignalMonitor (atmospheric
+// breathing-line canvas) is kept in the codebase for a potential future
+// "waiting between file-selected and analysis-started" state but is not
+// imported here.
+import { IdleValuePropPanel } from './components/IdleValuePropPanel';
import { useGlobalDrag } from './hooks/useGlobalDrag';
import {
appConfig,
- appVersionLabel,
isGeminiPhase2ConfigEnabled,
} from './config';
import { getAudioMimeTypeOrDefault, isSupportedAudioFile } from './services/audioFile';
@@ -90,12 +94,22 @@ function formatEstimateRange(estimate: BackendAnalysisEstimate): string {
return `${Math.round(estimate.totalLowMs / 1000)}s-${Math.round(estimate.totalHighMs / 1000)}s`;
}
+// Audit N9: M:SS duration for the collapsed Input Source summary card. Returns
+// null for missing/invalid values so callers can skip the chip without juggling
+// conditionals around a placeholder string.
+export function formatTrackDuration(seconds: number | null | undefined): string | null {
+ if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return null;
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.round(seconds % 60);
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+}
+
function getInterpretationStatusBadge(
phase2ConfigEnabled: boolean,
phase2Requested: boolean,
): string | null {
- if (!phase2ConfigEnabled) return 'INTERPRETATION CONFIG OFF';
- if (!phase2Requested) return 'INTERPRETATION USER OFF';
+ if (!phase2ConfigEnabled) return 'NOT CONFIGURED';
+ if (!phase2Requested) return 'OFF';
return null;
}
@@ -104,7 +118,10 @@ function getInterpretationHelperCopy(
phase2Requested: boolean,
): string {
if (!phase2ConfigEnabled) {
- return 'Developer kill-switch is off. AI interpretation is unavailable in this build.';
+ // Audit #12: drop developer-flavored copy. Give the user a concrete next step
+ // instead of a config-state assertion. On hosted deployments, an operator
+ // configures GEMINI_API_KEY on the backend; on local setups the user does it themselves.
+ return 'AI interpretation isn’t configured. Set GEMINI_API_KEY on the backend to enable Ableton recommendations.';
}
if (!phase2Requested) {
@@ -229,6 +246,12 @@ export default function App() {
const [audioFile, setAudioFile] = useState(null);
const [audioUrl, setAudioUrl] = useState(null);
const [isDemoLoading, setIsDemoLoading] = useState(false);
+ // Audit N9: after analysis completes the Input Source panel collapses into a
+ // compact summary so the results below get the full top-of-page real estate.
+ // The user can re-open it via "Adjust settings"; the override resets at the
+ // start of each new analysis (useEffect below) so subsequent completions also
+ // collapse — that's the predictable behavior.
+ const [inputManuallyExpanded, setInputManuallyExpanded] = useState(false);
const [analysisEstimate, setAnalysisEstimate] = useState(null);
const [isEstimateLoading, setIsEstimateLoading] = useState(false);
@@ -247,8 +270,14 @@ export default function App() {
const phase2StatusBadge = getInterpretationStatusBadge(phase2ConfigEnabled, interpretationRequested);
const phase2HelperCopy = getInterpretationHelperCopy(phase2ConfigEnabled, interpretationRequested);
const phase2ModelSelectorDisabled = isAnalyzing || !phase2ConfigEnabled || !interpretationRequested;
- const cpuMeterPercent = useCpuMeter(isAnalyzing);
const audioElementRef = useRef(null);
+
+ // Audit N9: reset the manual-expand override whenever a new analysis kicks
+ // off, so every completion re-collapses (predictable). Without this, a user
+ // who clicked "Adjust settings" once would have the panel stay open forever.
+ useEffect(() => {
+ if (isAnalyzing) setInputManuallyExpanded(false);
+ }, [isAnalyzing]);
const previousRunRef = useRef(null);
const completionRef = useRef<{ measurement: boolean; interpretation: boolean }>({
measurement: false,
@@ -904,6 +933,10 @@ export default function App() {
);
const shouldShowStatusPanel = Boolean(audioUrl && audioFile && analysisRun && (isAnalyzing || hasRetryableRunStage));
const phase1ForRender: Phase1Result | null = analysisRun ? projectPhase1FromRun(analysisRun) : null;
+ // Audit N9: collapse the Input Source panel when results are visible and we
+ // aren't actively analyzing. A failed run keeps the panel open so the user
+ // can change settings before retry.
+ const showInputCollapsed = Boolean(phase1ForRender) && !isAnalyzing && !hasRetryableRunStage && !inputManuallyExpanded;
const phase2ForRender = analysisRun ? projectPhase2FromRun(analysisRun) : null;
const stemSummaryForRender = analysisRun ? projectStemSummaryFromRun(analysisRun) : null;
const phase2SchemaVersion = analysisRun ? getPhase2SchemaVersionFromRun(analysisRun) : null;
@@ -919,24 +952,25 @@ export default function App() {
data-testid="app-toolbar"
className="ableton-toolbar h-10 border-b border-border flex items-center justify-between px-4"
>
+ {/* Audit #7+#9: dropped "Local DSP Engine v1.6.0" eyebrow. Version was
+ header noise and wrapped to 3 lines at 375px. The brand mark alone
+ is enough at-a-glance; version lives on the about/help surface. */}
SonicAnalyzer
-
-
- Local DSP Engine
- {appVersionLabel}
-
+ {/* Audit #7: de-emphasized the Dense DAW Lab link. The accent-orange
+ chip competed with the brand mark and the model selector for the
+ user's eye on every page. It's still discoverable, just quieter. */}
- Dense DAW Lab
+ Dense DAW Lab →
+ {/* Audit #11: dropped the CPU meter. Analysis happens in the
+ backend subprocess — the browser tab's CPU has no useful
+ relationship to "how hard the analysis is working." The pulsing
+ bar implied effort the page wasn't actually doing. */}
@@ -991,6 +1018,69 @@ export default function App() {
data-testid="input-panel"
className="bg-bg-card border border-border rounded-b-sm p-4 flex flex-col min-h-[220px]"
>
+ {showInputCollapsed && audioFile ? (
+ // Audit N9: compact post-analysis summary. Replaces the
+ // FileUpload dropzone + 3 toggles + estimate + run button
+ // with one-line context + two actions. The user can swap
+ // files or re-open the full panel from here.
+
+ ) : (
+ <>
+ {phase1ForRender && inputManuallyExpanded && !isAnalyzing && (
+ // Audit N9: re-expanded post-results — give the user a way back
+ // to the compact view without having to clear the file.
+
+
+ Editing analysis settings
+
+
+
+ )}
ANALYSIS MODE
-
- Full keeps every measurement. Standard is faster and skips advanced Tier 3 detail.
+ {/* Audit revised #4: helper paragraphs in the
+ Input Source panel were all-caps mono walls.
+ Switched to sans-serif sentence case (eyebrow
+ above stays mono-uppercase for label scan). */}
+
+ Full keeps every measurement. Standard is faster and skips advanced detail.
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.
@@ -1150,6 +1246,8 @@ export default function App() {
>
)}
+ >
+ )}
@@ -1184,7 +1282,7 @@ export default function App() {
)}
) : (
-
+
)}
@@ -1251,6 +1349,12 @@ export default function App() {
apiBaseUrl={appConfig.apiBaseUrl}
runId={activeRunId ?? undefined}
pitchNoteMode={analysisRun?.requestedStages.pitchNoteMode ?? null}
+ interpretationStatus={analysisRun?.stages.interpretation.status ?? null}
+ // Audit Finding #14 + #15: hash from the backend's source-audio
+ // artifact keys the per-file applied-recommendations tracker
+ // in localStorage. When absent (e.g., legacy run snapshot),
+ // AnalysisResults skips the checkbox affordance.
+ audioContentHash={analysisRun?.artifacts?.sourceAudio?.contentSha256 ?? null}
onReanalyzeWithStemAware={
audioFile && !isAnalyzing
? () => handleStartAnalysis({ pitchNoteRequested: true })
diff --git a/apps/ui/src/components/AnalysisResults.tsx b/apps/ui/src/components/AnalysisResults.tsx
index a9c86450..98b006b7 100644
--- a/apps/ui/src/components/AnalysisResults.tsx
+++ b/apps/ui/src/components/AnalysisResults.tsx
@@ -1,5 +1,6 @@
-import React, { useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
+ AnalysisStageStatus,
InterpretationSchemaVersion,
InterpretationValidationWarning,
MeasurementAvailabilityContext,
@@ -10,6 +11,8 @@ import {
} from '../types';
import {
Activity,
+ AudioWaveform,
+ Check,
ChevronDown,
ChevronRight,
Clock,
@@ -36,10 +39,13 @@ import {
} from './MeasurementPrimitives';
import { PhaseSourceBadge } from './PhaseSourceBadge';
import { StickyNav, type StickyNavSection } from './StickyNav';
+import { CitationBlock } from './CitationBlock';
+import { loadAppliedIds, toggleAppliedId } from '../services/appliedRecommendations';
import {
buildArrangementViewModel,
buildMixChainGroups,
buildPatchCards,
+ buildPatchGroups,
buildSonicElementCards,
calculateStereoBandStyle,
toConfidenceBadges,
@@ -65,6 +71,14 @@ export interface AnalysisResultsProps {
apiBaseUrl?: string;
runId?: string;
pitchNoteMode?: 'stem_notes' | 'off' | null;
+ /**
+ * Current status of the interpretation stage, used to label the results
+ * header honestly (e.g. "AI interpretation in progress…" vs "Recommendations
+ * ready" vs "AI interpretation failed"). Pass `null`/`undefined` to fall
+ * back to the neutral subtitle. Decouples the header text from a hardcoded
+ * "PHASE COMPLETE" string that previously rendered regardless of state.
+ */
+ interpretationStatus?: AnalysisStageStatus | 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
@@ -72,10 +86,87 @@ export interface AnalysisResultsProps {
* is already in flight or no source File is loaded).
*/
onReanalyzeWithStemAware?: () => void;
+ /**
+ * Audit Finding #14 + #15: SHA-256 of the source audio content. Used to key
+ * the per-file applied-recommendations tracker so producers can check off
+ * Mix Chain / Patches cards as they wire them into Live and have that
+ * progress survive both a page reload and a re-analysis of the same file
+ * (rename-resilient because hash is content-based, not name-based).
+ * Pass `null`/`undefined` to disable the tracker (no checkboxes shown).
+ */
+ audioContentHash?: string | null;
}
const LOW_CHORD_CONFIDENCE_THRESHOLD = 0.5;
+/**
+ * Maps the interpretation stage status to a subtitle string for the results
+ * header. Returns null for statuses that shouldn't surface in the header
+ * (e.g. unknown values). Centralised so the header never lies about
+ * Phase 2's actual state.
+ */
+export function getInterpretationSubtitle(
+ status: AnalysisStageStatus | null | undefined,
+): string | null {
+ if (!status) return null;
+ switch (status) {
+ case 'completed':
+ return 'Recommendations ready';
+ case 'running':
+ return 'AI interpretation in progress…';
+ case 'queued':
+ case 'ready':
+ case 'blocked':
+ return 'AI interpretation pending';
+ case 'failed':
+ return 'AI interpretation failed — retry from progress panel';
+ case 'interrupted':
+ return 'AI interpretation stopped';
+ case 'not_requested':
+ return 'Measurements only';
+ default:
+ return null;
+ }
+}
+
+/**
+ * Audit Finding #6 (streaming reveal): tooltip text for the disabled StickyNav
+ * pills that correspond to Phase 2 sections (Sonic / Mix Chain / Patches).
+ * Used to live as a hardcoded "Recommendations not produced this run" which
+ * lied during the 4–5 minute mid-run window where Phase 1 had streamed in
+ * but Phase 2 was still working.
+ *
+ * Mirrors `getInterpretationSubtitle` but phrased as a nav-pill tooltip
+ * ("AI interpretation in progress…" reads naturally in the header subtitle
+ * AND in a pill hover), so a future copy refactor can collapse the two
+ * helpers if desired.
+ */
+export function getPhase2NavDisabledReason(
+ status: AnalysisStageStatus | null | undefined,
+): string {
+ switch (status) {
+ case 'running':
+ return 'AI interpretation in progress…';
+ case 'queued':
+ case 'ready':
+ case 'blocked':
+ return 'AI interpretation pending — waiting to start';
+ case 'not_requested':
+ return 'AI interpretation off for this run';
+ case 'failed':
+ return 'AI interpretation failed — retry from progress panel';
+ case 'interrupted':
+ return 'AI interpretation stopped';
+ case 'completed':
+ case null:
+ case undefined:
+ default:
+ // `completed` with no cards = Phase 2 returned nothing actionable. Fall
+ // through to the legacy phrasing so an empty result still reads honest.
+ return 'Recommendations not produced this run';
+ }
+}
+
export function toggleOpenKeySet(previous: ReadonlySet, id: string): Set {
const next = new Set(previous);
if (next.has(id)) {
@@ -86,6 +177,45 @@ export function toggleOpenKeySet(previous: ReadonlySet, id: string): Set
return next;
}
+/**
+ * Audit Finding #14: per-card "applied to my session" toggle. Looks like a
+ * checkbox to producers who scan top-down through Mix Chain / Patches lists.
+ * Renders nothing when no tracker is wired (e.g., file hash unavailable);
+ * stops click propagation so toggling doesn't also expand/collapse the card.
+ */
+function AppliedCheckbox({
+ isApplied,
+ onToggle,
+ ariaLabel,
+}: {
+ isApplied: boolean;
+ onToggle: () => void;
+ ariaLabel: string;
+}) {
+ return (
+
+ );
+}
+
function Collapsible({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) {
return (
;
+ }
if (groupName.includes('SYNTH / MELODIC')) return '🎹';
if (groupName.includes('MID PROCESSING')) return '🎚';
if (groupName.includes('HIGH-END DETAIL')) return '✨';
@@ -246,49 +382,31 @@ function MetaBadgeList({ items }: { items: MetaBadgeItem[] }) {
const visibleItems = items.filter((item) => typeof item.value === 'string' && item.value.trim().length > 0);
if (visibleItems.length === 0) return null;
+ // Audit N8: previously each chip rendered as `Family: Native` /
+ // `Context: Acid bass` / `Stage: Sound design`. The `Label:` prefix read
+ // as a JSON-key column header — engineering-flavour. The chip content
+ // alone (`Acid bass`) is enough; we keep `item.label` only for the React
+ // key. Tooltip preserves the original label for users who want context.
return (
- );
-}
+// Audit Finding #2: `GroundingBadgeList` (9px monospace field-path pills) was
+// retired in favor of the structured `CitationBlock` primitive. The component
+// previously lived here and rendered raw field paths like `bpmConfidence` as
+// orange-accent pills. Track Layout — its only call site — now uses
+// CitationBlock with the segmentIndexes routed through the `extraRows` prop.
function describeInterpretationWarning(
warning: InterpretationValidationWarning,
@@ -403,7 +521,9 @@ export function AnalysisResults({
apiBaseUrl,
runId,
pitchNoteMode = null,
+ interpretationStatus = null,
onReanalyzeWithStemAware,
+ audioContentHash = null,
}: AnalysisResultsProps) {
const [openArrangement, setOpenArrangement] = useState>({});
const [openSonic, setOpenSonic] = useState>(new Set());
@@ -411,7 +531,32 @@ export function AnalysisResults({
const [openPatch, setOpenPatch] = useState>({});
const [showSources, setShowSources] = useState>({});
- const sessionId = useMemo(() => new Date().getTime().toString(36).toUpperCase(), []);
+ // Audit Finding #14 + #15: applied-recommendation set, lazy-initialized
+ // from localStorage on first render (keyed by the audio content hash).
+ // When the hash changes (new file uploaded), useEffect below re-hydrates
+ // the set from storage; we don't keep stale checks across files.
+ const [appliedIds, setAppliedIds] = useState>(() =>
+ loadAppliedIds(audioContentHash),
+ );
+ useEffect(() => {
+ setAppliedIds(loadAppliedIds(audioContentHash));
+ }, [audioContentHash]);
+
+ const toggleApplied = useCallback(
+ (cardId: string) => {
+ if (!audioContentHash) return;
+ const next = toggleAppliedId(audioContentHash, cardId, {
+ filename: sourceFileName ?? undefined,
+ });
+ setAppliedIds(next);
+ },
+ [audioContentHash, sourceFileName],
+ );
+
+ const interpretationSubtitle = getInterpretationSubtitle(interpretationStatus);
+ const headerSubtitle = [sourceFileName, interpretationSubtitle]
+ .filter((part): part is string => Boolean(part))
+ .join(' · ');
if (!phase1) return null;
@@ -479,7 +624,21 @@ export function AnalysisResults({
const arrangement = buildArrangementViewModel(phase1, phase2?.arrangementOverview);
const sonicCards = buildSonicElementCards(phase1, phase2?.sonicElements);
const mixGroups = buildMixChainGroups(phase1, phase2?.mixAndMasterChain, phase2?.sonicElements);
+ // Audit Finding #14: per-section applied counts, derived from the
+ // appliedIds Set + the rendered card lists. Keeps the progress chip in the
+ // section header in sync with the per-card checkboxes without a second
+ // source of truth.
+ const mixCardCount = mixGroups.reduce((sum, group) => sum + group.cards.length, 0);
+ const mixAppliedCount = mixGroups.reduce(
+ (sum, group) => sum + group.cards.filter((card) => appliedIds.has(card.id)).length,
+ 0,
+ );
const patchCards = buildPatchCards(phase1, phase2);
+ // Audit follow-up: patches render grouped by Mix Chain's processing-stage
+ // heuristic. `patchCards` (flat list) is retained for the length checks the
+ // StickyNav and gating already use.
+ const patchGroups = buildPatchGroups(phase1, phase2);
+ const patchAppliedCount = patchCards.filter((card) => appliedIds.has(card.id)).length;
const projectSetup = isPhase2V2 ? phase2?.projectSetup ?? null : null;
const trackLayout = isPhase2V2 && Array.isArray(phase2?.trackLayout) ? phase2.trackLayout : [];
const routingBlueprint = isPhase2V2 ? phase2?.routingBlueprint ?? null : null;
@@ -527,16 +686,13 @@ export function AnalysisResults({
mixGroups.length > 0 ||
patchCards.length > 0 ||
Boolean(phase2?.secretSauce);
- const navSections: StickyNavSection[] = [
- { id: 'section-meas-core', label: 'Core' },
- { id: 'section-meas-loudness', label: 'Loudness' },
- { id: 'section-meas-mixdoctor', label: 'MixDoctor' },
- { id: 'section-meas-spectral', label: 'Spectral' },
- { id: 'section-meas-stereo', label: 'Stereo' },
- { id: 'section-meas-rhythm', label: 'Rhythm' },
- { id: 'section-meas-harmony', label: 'Harmony' },
- { id: 'section-meas-structure', label: 'Structure' },
- { id: 'section-meas-synthesis', label: 'Synthesis' },
+ // Audit Finding #1: nav order inverted so the producer hits Style → Sonic →
+ // Mix Chain → Patches → Session before the 9 measurement panels. The 9
+ // `section-meas-*` pills are collapsed into a single `Measurements` entry
+ // that scrolls to the (now bottom-of-page) MeasurementDashboard wrapper;
+ // within the dashboard the 9 sub-sections still have their own ids and
+ // remain individually scrollable via direct hash links if needed.
+ const navEntries: Array = [
{ id: 'section-style-profile', label: 'Style' },
projectSetup ? { id: 'section-project-setup', label: 'Setup' } : null,
trackLayout.length > 0 ? { id: 'section-track-layout', label: 'Layout' } : null,
@@ -546,10 +702,42 @@ export function AnalysisResults({
arrangement ? { id: 'section-arrangement', label: 'Arrangement' } : null,
{ id: 'section-session', label: 'Session' },
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,
- ].filter((section): section is StickyNavSection => section !== null);
+ // Audit N1 sibling + Finding #6 (streaming reveal): keep Phase 2 nav
+ // entries visible even when their sections haven't populated yet.
+ // Previously the disabled reason was a hardcoded "not produced this run"
+ // that lied during the 4–5 minute mid-run window between Phase 1
+ // streaming in and Phase 2 completing. The reason now derives from
+ // `interpretationStatus` so a hover during mid-run reads "in progress…"
+ // and only a real failure / no-op reads "not produced this run".
+ {
+ id: 'section-sonic-elements',
+ label: 'Sonic',
+ disabled: sonicCards.length === 0,
+ disabledReason: sonicCards.length === 0
+ ? getPhase2NavDisabledReason(interpretationStatus)
+ : undefined,
+ },
+ {
+ id: 'section-mix-chain',
+ label: 'Mix Chain',
+ disabled: mixGroups.length === 0,
+ disabledReason: mixGroups.length === 0
+ ? getPhase2NavDisabledReason(interpretationStatus)
+ : undefined,
+ },
+ {
+ id: 'section-patches',
+ label: 'Patches',
+ disabled: patchCards.length === 0,
+ disabledReason: patchCards.length === 0
+ ? getPhase2NavDisabledReason(interpretationStatus)
+ : undefined,
+ },
+ { id: 'section-measurements', label: 'Measurements' },
+ ];
+ const navSections: StickyNavSection[] = navEntries.filter(
+ (section): section is StickyNavSection => section !== null,
+ );
return (
{formatDisplayText('Analysis Results', 'title')}
-
- SESSION ID: {sessionId} // PHASE COMPLETE
-
+ {headerSubtitle && (
+
+ {headerSubtitle}
+
+ )}
@@ -725,13 +916,13 @@ export function AnalysisResults({
)}
-
+ {/* Audit Finding #1: MeasurementDashboard was here at the top of the
+ results scroll, ahead of Style / Sonic Elements / Mix Chain / Patches.
+ That ordering serves a DSP engineer auditing the tool, not a producer
+ asking "how do I make something that sounds like this?". The dashboard
+ now renders at the bottom (search for `section-measurements` below
+ Patches/Secret Sauce) so the actionable Phase 2 content reads first
+ and the measurement evidence reads as drill-down. */}
{truncateAtSentenceBoundary(item.purpose, 220)}
-
-
- Grounding
-
-
-
+ {/* Audit Finding #2: replaced the legacy GroundingBadgeList
+ (9px field-path pills) with the structured CitationBlock
+ primitive, finishing the chain-of-custody visual treatment
+ that already lands on Mix Chain / Patches / Sonic cards.
+ Segment indexes (Track Layout-only) ride as a synthetic
+ extra row at the bottom of the block. */}
+ 0
+ ? [
+ {
+ label: 'Active in segments',
+ value: item.grounding.segmentIndexes.join(' · '),
+ },
+ ]
+ : undefined
+ }
+ testId={`track-layout-citation-${item.order ?? 0}-${item.name}`}
+ />
))}
@@ -1620,7 +1824,18 @@ export function AnalysisResults({
-
+
+ {/* Audit Finding #2 + #3: chain-of-custody block at the
+ TOP of the expanded card so the producer sees the
+ measurements + worst-confidence band BEFORE reading
+ the prose description. */}
+
+
+
{card.description}
@@ -1659,6 +1874,7 @@ export function AnalysisResults({
)}
+
@@ -1674,7 +1890,21 @@ export function AnalysisResults({
title={formatDisplayText('Mix & Master Chain', 'title')}
titleRole="section-title"
rightSlot={
- SIGNAL FLOW
+
+ {/* Audit Finding #14: section-level progress glance. Only
+ surfaces when the tracker is wired (audioContentHash
+ available) AND at least one card has been applied —
+ avoids leading with a "0 of N" on first view. */}
+ {audioContentHash && mixAppliedCount > 0 && (
+
+ {mixAppliedCount} of {mixCardCount} applied
+
+ )}
+ SIGNAL FLOW
+
}
/>
@@ -1698,10 +1928,14 @@ export function AnalysisResults({
)}
@@ -1967,6 +2271,25 @@ export function AnalysisResults({
)}
+
+ {/* Audit Finding #1: measurements section moved to the end of the scroll.
+ Wrapped in a single anchorable so the StickyNav can target
+ it with one pill ("Measurements") instead of nine pills. The internal
+ MeasurementDashboard still renders its 9 numbered sub-sections, each
+ with their own scroll anchor; only the top-level nav collapses. */}
+
+
+
);
}
diff --git a/apps/ui/src/components/AnalysisStatusPanel.tsx b/apps/ui/src/components/AnalysisStatusPanel.tsx
index d427c476..071b3c2b 100644
--- a/apps/ui/src/components/AnalysisStatusPanel.tsx
+++ b/apps/ui/src/components/AnalysisStatusPanel.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { RotateCcw, Square } from 'lucide-react';
-import { AnalysisRunSnapshot, AnalysisStageStatus, BackendAnalysisEstimate } from '../types';
+import { AnalysisRunSnapshot, AnalysisStageError, AnalysisStageStatus, BackendAnalysisEstimate } from '../types';
interface AnalysisStatusPanelProps {
run: AnalysisRunSnapshot | null;
@@ -29,17 +29,36 @@ function formatEstimateRange(estimate: BackendAnalysisEstimate): string {
return `${lo}s-${hi}s`;
}
+export type ProgressTone = 'running' | 'success' | 'failed';
+
+export interface ProgressState {
+ percent: number;
+ indeterminate: boolean;
+ message: string;
+ tone: ProgressTone;
+ /**
+ * Audit Finding #6: which stage is producing this message. Lets the panel
+ * label the readout ("MEASURE · Measuring tempo, key, loudness…") so the
+ * user has the substance + context in one glance instead of having to
+ * cross-reference the stage chips. `null` for terminal/estimate states
+ * where no single stage is active.
+ */
+ activeStageKey: StageKey | null;
+}
+
function computeEstimateProgress(
elapsedMs: number,
estimate?: BackendAnalysisEstimate | null,
-): { percent: number; indeterminate: boolean; message: string } {
- if (!estimate) return { percent: 0, indeterminate: true, message: 'Estimating progress...' };
+): ProgressState {
+ if (!estimate) return { percent: 0, indeterminate: true, message: 'Estimating progress...', tone: 'running', activeStageKey: null };
const midpointMs = (estimate.totalLowMs + estimate.totalHighMs) / 2;
- if (midpointMs <= 0) return { percent: 0, indeterminate: true, message: 'Estimating progress...' };
+ if (midpointMs <= 0) return { percent: 0, indeterminate: true, message: 'Estimating progress...', tone: 'running', activeStageKey: null };
return {
percent: Math.min((elapsedMs / midpointMs) * 100, 95),
indeterminate: false,
message: 'Estimating progress from elapsed time.',
+ tone: 'running',
+ activeStageKey: null,
};
}
@@ -51,6 +70,19 @@ const STAGE_LABELS: Record = {
interpretation: 'INTERPRET',
};
+/**
+ * Maps progress tone to the Tailwind background-color class for the progress
+ * bar fill. Used to surface failed/successful end-states visually, instead of
+ * leaving the bar accent-orange even when a stage has FAILED. Indeterminate
+ * fills use a lower-opacity variant so the pulsing partial bar reads as
+ * activity rather than solid colour. Audit N1 sibling.
+ */
+function progressFillClass(tone: ProgressTone, indeterminate: boolean): string {
+ if (tone === 'failed') return indeterminate ? 'bg-error/60' : 'bg-error';
+ if (tone === 'success') return indeterminate ? 'bg-success/60' : 'bg-success';
+ return indeterminate ? 'bg-accent/60' : 'bg-accent';
+}
+
function statusDotClass(status: AnalysisStageStatus): string {
switch (status) {
case 'running':
@@ -164,9 +196,9 @@ function stageSummary(run: AnalysisRunSnapshot | null, stageKey: StageKey): stri
}
}
-function computeLiveProgress(
+export function computeLiveProgress(
run: AnalysisRunSnapshot | null,
-): { percent: number; indeterminate: boolean; message: string } | null {
+): ProgressState | null {
if (!run) {
return null;
}
@@ -178,7 +210,26 @@ function computeLiveProgress(
const totalStages = Math.max(trackedStageKeys.length, 1);
if (!activeStageKey) {
- return { percent: 100, indeterminate: false, message: 'Analysis complete.' };
+ // All stages reached a terminal state — but distinguish honest success
+ // from a failure or interruption. Previously this branch unconditionally
+ // returned "Analysis complete." even when a stage had failed, so the
+ // progress card lied alongside a red FAILED stage badge. (Audit N1.)
+ const failedKey = trackedStageKeys.find((key) => {
+ const status = getStageSnapshot(run, key).status;
+ return status === 'failed' || status === 'interrupted';
+ });
+ if (failedKey) {
+ const failedStatus = getStageSnapshot(run, failedKey).status;
+ const verb = failedStatus === 'failed' ? 'failed' : 'stopped';
+ return {
+ percent: 100,
+ indeterminate: false,
+ message: `${STAGE_LABELS[failedKey]} ${verb}.`,
+ tone: 'failed',
+ activeStageKey: failedKey,
+ };
+ }
+ return { percent: 100, indeterminate: false, message: 'Analysis complete.', tone: 'success', activeStageKey: null };
}
const activeStage = getStageSnapshot(run, activeStageKey);
@@ -190,6 +241,8 @@ function computeLiveProgress(
percent: Math.min(((completedBeforeActive + stageFraction) / totalStages) * 100, 100),
indeterminate: activeStage.status === 'running' && progressDetails.fraction == null,
message: progressDetails.message ?? stageSummary(run, activeStageKey),
+ tone: 'running',
+ activeStageKey,
};
}
@@ -236,10 +289,30 @@ export function AnalysisStatusPanel({
}: AnalysisStatusPanelProps) {
const progress = computeLiveProgress(run) ?? computeEstimateProgress(elapsedMs, estimate);
- const stages: { key: StageKey; status: AnalysisStageStatus; onRetry?: () => void }[] = [
- { key: 'measurement', status: run?.stages.measurement.status ?? 'queued', onRetry: onRetryMeasurement },
- { key: 'pitchNoteTranslation', status: run?.stages.pitchNoteTranslation.status ?? 'blocked', onRetry: onRetryPitchNote },
- { key: 'interpretation', status: run?.stages.interpretation.status ?? 'blocked', onRetry: onRetryInterpretation },
+ const stages: {
+ key: StageKey;
+ status: AnalysisStageStatus;
+ error: AnalysisStageError | null;
+ onRetry?: () => void;
+ }[] = [
+ {
+ key: 'measurement',
+ status: run?.stages.measurement.status ?? 'queued',
+ error: run?.stages.measurement.error ?? null,
+ onRetry: onRetryMeasurement,
+ },
+ {
+ key: 'pitchNoteTranslation',
+ status: run?.stages.pitchNoteTranslation.status ?? 'blocked',
+ error: run?.stages.pitchNoteTranslation.error ?? null,
+ onRetry: onRetryPitchNote,
+ },
+ {
+ key: 'interpretation',
+ status: run?.stages.interpretation.status ?? 'blocked',
+ error: run?.stages.interpretation.error ?? null,
+ onRetry: onRetryInterpretation,
+ },
];
return (
@@ -278,10 +351,46 @@ export function AnalysisStatusPanel({
+ {/* Audit Finding #6: primary readout. The stage diagnostic message used
+ to render at `text-[9px] text-secondary/50` below the percent — sized
+ as background fluff. During a 4–5 minute Phase 2 wait the producer
+ would tab away and miss any actual signal about what's happening.
+ Now it sits between the header and the stage chips as the visual
+ focus, with the active stage label as a mono eyebrow above it. The
+ chips below become the secondary "which stage is which" landmark. */}
+
{stages.map((stage, i) => {
- const isRetryable = stage.onRetry && (stage.status === 'failed' || stage.status === 'interrupted' || stage.status === 'ready');
+ // A retry button only makes sense when (a) the parent provided a
+ // handler, (b) the stage is in a retryable state, and (c) the
+ // backend hasn't explicitly marked the error as non-retryable
+ // (e.g. GEMINI_NOT_CONFIGURED: clicking RETRY won't fix a missing
+ // env var). Audit N1 sibling: previously the button rendered for
+ // every failed stage regardless of error.retryable.
+ const errorMarkedNonRetryable = stage.error?.retryable === false;
+ const isRetryable =
+ stage.onRetry &&
+ (stage.status === 'failed' || stage.status === 'interrupted' || stage.status === 'ready') &&
+ !errorMarkedNonRetryable;
return (
{statusLabel(stage.status)}
- {isRetryable && (
+ {isRetryable ? (
Retry
- )}
+ ) : errorMarkedNonRetryable && stage.error?.code ? (
+ // Audit N1 sibling: a non-retryable failure (e.g.
+ // GEMINI_NOT_CONFIGURED) used to render FAILED with no
+ // actionable feedback. Surface the error code so the user
+ // knows where to look; full message goes in the tooltip.
+
+ {stage.error.code}
+
+ ) : null}
);
@@ -325,10 +445,12 @@ export function AnalysisStatusPanel({
+ {/* Audit Finding #6: the duplicate small `progress.message` that used
+ to render here was removed — the primary readout above is the
+ single source for "what's happening". Keeping it here would have
+ been visual noise repeating the same sentence twice. */}
);
diff --git a/apps/ui/src/components/CitationBlock.tsx b/apps/ui/src/components/CitationBlock.tsx
new file mode 100644
index 00000000..d127f405
--- /dev/null
+++ b/apps/ui/src/components/CitationBlock.tsx
@@ -0,0 +1,149 @@
+/**
+ * Audit Finding #2: the chain-of-custody promise — every Phase 2
+ * recommendation must trace back to the Phase 1 measurement that justifies
+ * it — was rendered as 9px monospace pills (field-path strings) on a single
+ * section (Track Layout) and was invisible on the cards that matter (Mix
+ * Chain, Patches, Sonic Elements). This is the visual primitive that fixes
+ * that: a "GROUNDED IN" block placed ABOVE each recommendation card body
+ * with human-readable label · value rows.
+ *
+ * Audit Finding #3 sibling: a confidence pill renders top-right when any of
+ * the cited fields have a paired *Confidence sibling (via CONFIDENCE_PAIRS
+ * in phase2Validator.ts). The pill reuses the four-band vocabulary from
+ * ConfidenceBandBadge — Solid scaffold / Workable draft / Rough sketch /
+ * Unreliable — so the same color language carries from Session Musician
+ * into the recommendation cards.
+ */
+import React from 'react';
+import type { Phase1Result } from '../types';
+import { humanizeFieldPath } from '../services/userLabels';
+import {
+ formatCitedValue,
+ pickPhase1Value,
+ pickWorstConfidence,
+} from '../services/phase1Picker';
+import {
+ formatBandPillLabel,
+ getConfidenceBand,
+ type ConfidenceBand,
+} from '../services/sessionMusician/confidenceBand';
+
+interface CitationBlockProps {
+ phase1: Phase1Result;
+ fields: readonly string[];
+ /** Cap the visible row count. Producers scan citations, not read them. */
+ maxRows?: number;
+ /**
+ * Precomputed worst confidence (0-1). When omitted, the block computes it
+ * from `fields` via the validator's CONFIDENCE_PAIRS map. Pass `null` to
+ * suppress the badge even when the picker could compute one (e.g., if the
+ * caller has higher-fidelity confidence information available elsewhere).
+ */
+ confidence?: number | null;
+ /** When false, the confidence pill is hidden even if a value is available. */
+ showConfidenceBadge?: boolean;
+ /**
+ * Synthetic rows appended after the resolved phase1Fields rows. Used by
+ * callers whose citation includes non-Phase-1 evidence — e.g., Track
+ * Layout's arrangement-segment indices.
+ */
+ extraRows?: ReadonlyArray<{ label: string; value: string }>;
+ className?: string;
+ testId?: string;
+}
+
+// Same 4-tone ladder as ConfidenceBandBadge (PILL_CLASSES). Duplicated here
+// rather than imported because the badge file isn't re-exporting the map and
+// the CitationBlock variant is a single-line compact pill (no copy paragraph).
+// If a third call site needs the same ladder, lift PILL_CLASSES out.
+const CONFIDENCE_PILL_CLASSES: Record = {
+ solid: 'border-success/30 text-success bg-success/10',
+ workable: 'border-accent/40 text-accent bg-accent/10',
+ rough: 'border-warning/30 text-warning bg-warning/10',
+ unreliable: 'border-error/30 text-error bg-error/10',
+};
+
+export function CitationBlock({
+ phase1,
+ fields,
+ maxRows = 4,
+ confidence,
+ showConfidenceBadge = true,
+ extraRows,
+ className,
+ testId = 'citation-block',
+}: CitationBlockProps) {
+ // Resolve each cited path to (label, value). Drop rows whose value resolves
+ // to null/undefined/empty — defensive against unmapped fields and against
+ // Phase 2 emitting a citation whose target wasn't actually populated by
+ // Phase 1 this run.
+ const resolvedRows = fields
+ .map((path) => {
+ const value = pickPhase1Value(phase1, path);
+ const formatted = formatCitedValue(path, value);
+ return {
+ path,
+ label: humanizeFieldPath(path),
+ value: formatted,
+ };
+ })
+ .filter((row) => row.value !== '')
+ .slice(0, maxRows);
+
+ const syntheticRows = (extraRows ?? []).map((row, idx) => ({
+ path: `__extra_${idx}`,
+ label: row.label,
+ value: row.value,
+ }));
+ const rows = [...resolvedRows, ...syntheticRows];
+
+ if (rows.length === 0) return null;
+
+ // Worst-confidence pill. `confidence` prop wins if explicitly passed (even
+ // null — caller can suppress). Otherwise compute from the same paths.
+ const resolvedConfidence =
+ confidence !== undefined ? confidence : pickWorstConfidence(phase1, fields);
+ const band =
+ showConfidenceBadge && resolvedConfidence !== null
+ ? getConfidenceBand(resolvedConfidence)
+ : null;
+ const pillClass = band ? CONFIDENCE_PILL_CLASSES[band.id] : null;
+
+ return (
+
+ );
+}
diff --git a/apps/ui/src/components/IdleValuePropPanel.tsx b/apps/ui/src/components/IdleValuePropPanel.tsx
new file mode 100644
index 00000000..6f04d0fd
--- /dev/null
+++ b/apps/ui/src/components/IdleValuePropPanel.tsx
@@ -0,0 +1,110 @@
+/**
+ * Audit Finding #5: the idle Signal Monitor used to be a 200-pixel canvas of
+ * "NO SIGNAL DETECTED" at 30% opacity — atmospheric, but it told a first-time
+ * producer nothing about what ASA does, what to expect, or whether it's
+ * worth their wait. This panel replaces that idle state with a value-prop
+ * read in producer language.
+ *
+ * Visible only when `!audioFile` (the user hasn't picked a track yet). Once
+ * a file lands, `WaveformPlayer` takes over the Signal Monitor area and this
+ * panel disappears. We keep `IdleSignalMonitor` in the codebase for the
+ * future "waiting between file-selected and analysis-started" state if we
+ * decide to use it there; right now nothing renders it.
+ *
+ * Asset placeholder: the visual slot below the body copy is intentionally a
+ * styled trio of Lucide icons rather than a GIF, so the panel ships without
+ * binary assets in the repo. To replace with a real ~5s loop later, swap the
+ * `` block for an /