From 467497686a75258c6a2a17dadcbf336b4c1c2231 Mon Sep 17 00:00:00 2001 From: assiduous repetition Date: Thu, 14 May 2026 15:02:08 +1200 Subject: [PATCH 1/8] =?UTF-8?q?feat(api):=20Track=203.4=20=E2=80=94=20addi?= =?UTF-8?q?tive=20publicStatus=20field=20on=20every=20stage=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth and final piece of Track 3 from the merged external-repo review. Additive over the existing 8-state stage status — does not change or remove anything. What's new: - apps/backend/stage_status.py (new) — pure module with the canonical 8 -> 5 mapping table, the to_public_status() helper, and PUBLIC_STATUS_VALUES exported for type generation / docs. Mapping (internal -> public): queued -> queued running -> running blocked -> queued (transient scheduling state) ready -> queued (scheduled but not yet running) completed -> completed failed -> failed interrupted -> interrupted not_requested -> null (stage not in pipeline) - apps/backend/server_phase1.py — _normalize_run_snapshot now also annotates each stage with publicStatus via the new _annotate_public_status helper. The original status field is preserved untouched. - apps/backend/tests/test_stage_status.py (new, 16 tests) — pins every cell of the mapping, the public-value set, defensive cases (None, unknown, empty, case-sensitive), and a completeness check that fails if the set of internal states drifts without an explicit mapping update. - apps/backend/tests/test_server.py (+1 test) — integration test that GET /api/analysis-runs/{run_id} returns publicStatus on all three stages with the correct values for a fresh run. - apps/ui/src/types/backend.ts — new PublicStageStatus type (the 5-value union). publicStatus: PublicStageStatus | null added to MeasurementStageSnapshot, PitchNoteTranslationStageSnapshot, InterpretationStageSnapshot. attempt summaries unchanged (publicStatus is only on top-level stages). - apps/ui/src/services/analysisRunsClient.ts — new parsePublicStageStatus helper + PUBLIC_STAGE_STATUSES set; threaded into the three stage parsers. The helper accepts null and missing values without throwing so legacy snapshots that pre-date the field continue to deserialize. - docs/adr/0001-phase1-json-schema-v1.md — documents the additive field, the mapping table, and the rationale (internal scheduling states are scheduling concerns the client doesn't need to act on). Out of scope (deferred): - Removing publicStatus duplication from the internal status field later. The additive approach was the explicit choice; collapsing to one field would be a breaking change requiring a v2 schema bump. - Frontend UI that reads publicStatus. Existing UI continues to use the 8-state status field; publicStatus is the additive surface for future external consumers and any new UI views that prefer the smaller vocabulary. This closes Track 3 (all four pieces shipped): - 3.1 URL ingestion (#36, merged) - 3.2 X-Admin-Key on DELETE (#37, merged) - 3.3 GET /source-audio re-serve (#40, merged) - 3.4 publicStatus additive (this PR) https://claude.ai/code/session_01Uh2eNT1oqjixcox8UX3BTg Co-authored-by: Claude --- apps/backend/server_phase1.py | 48 +++++-- apps/backend/stage_status.py | 88 +++++++++++++ apps/backend/tests/test_server.py | 42 +++++++ apps/backend/tests/test_stage_status.py | 140 +++++++++++++++++++++ apps/ui/src/services/analysisRunsClient.ts | 30 +++++ apps/ui/src/types/backend.ts | 30 +++++ docs/adr/0001-phase1-json-schema-v1.md | 11 ++ 7 files changed, 380 insertions(+), 9 deletions(-) create mode 100644 apps/backend/stage_status.py create mode 100644 apps/backend/tests/test_stage_status.py diff --git a/apps/backend/server_phase1.py b/apps/backend/server_phase1.py index 401aa3f9..dee30da3 100644 --- a/apps/backend/server_phase1.py +++ b/apps/backend/server_phase1.py @@ -9,6 +9,7 @@ from analysis_runtime import AnalysisRuntime from server_upload import ERROR_PHASE_LOCAL_DSP +from stage_status import to_public_status # ── Constants ──────────────────────────────────────────────────────────────── @@ -226,6 +227,30 @@ def _build_phase1(payload: dict[str, Any]) -> dict[str, Any]: } +def _annotate_public_status(stages: dict[str, Any]) -> dict[str, Any]: + """Attach ``publicStatus`` to every stage whose ``status`` is set. + + Returns a shallow-copy of ``stages`` with each stage dict updated. + The original ``status`` field is preserved untouched; ``publicStatus`` + is the additive 5-state collapse documented in + :mod:`stage_status`. + + Defensive against non-dict stage entries (e.g. nulls in legacy + snapshots) — those pass through unchanged. + """ + annotated: dict[str, Any] = {} + for stage_name, stage_value in stages.items(): + if not isinstance(stage_value, dict): + annotated[stage_name] = stage_value + continue + stage_copy = dict(stage_value) + # `status` may legitimately be absent on legacy/partial snapshots; + # to_public_status(None) yields None which we expose as null. + stage_copy["publicStatus"] = to_public_status(stage_copy.get("status")) + annotated[stage_name] = stage_copy + return annotated + + def _normalize_run_snapshot( snapshot: dict[str, Any], runtime: AnalysisRuntime | None = None ) -> dict[str, Any]: @@ -236,22 +261,27 @@ def _normalize_run_snapshot( _build_phase1 produces for the legacy /api/analyze endpoint — notably, top-level stereoWidth/stereoCorrelation extracted from stereoDetail. + Each stage in ``stages`` is also annotated with a ``publicStatus`` field + that collapses the 8 internal stage statuses to 5 public ones (see + :mod:`stage_status`). The original ``status`` field is preserved; this + is purely additive. + When *runtime* is provided, spectral visualization artifacts (spectrogram PNGs and time-series JSON) are attached under ``artifacts.spectral``. """ stages = snapshot.get("stages") if not isinstance(stages, dict): return snapshot - measurement = stages.get("measurement") - if not isinstance(measurement, dict): - return snapshot - raw_result = measurement.get("result") - if not isinstance(raw_result, dict): - return snapshot snapshot = dict(snapshot) - snapshot["stages"] = dict(stages) - snapshot["stages"]["measurement"] = dict(measurement) - snapshot["stages"]["measurement"]["result"] = _build_phase1(raw_result) + snapshot["stages"] = _annotate_public_status(stages) + + measurement = snapshot["stages"].get("measurement") + if isinstance(measurement, dict): + raw_result = measurement.get("result") + if isinstance(raw_result, dict): + # Reuse the dict we already copied in _annotate_public_status; + # no need to clone twice. + measurement["result"] = _build_phase1(raw_result) if runtime is not None: run_id = snapshot.get("runId") diff --git a/apps/backend/stage_status.py b/apps/backend/stage_status.py new file mode 100644 index 00000000..eea88e0c --- /dev/null +++ b/apps/backend/stage_status.py @@ -0,0 +1,88 @@ +"""Public-facing collapse of the internal stage-status state machine. + +ASA's run-state machine has eight internal statuses +(``queued``, ``running``, ``blocked``, ``ready``, ``completed``, +``failed``, ``interrupted``, ``not_requested``). Most of these are +runtime-scheduling concerns the client does not need to act on: + +- ``blocked`` and ``ready`` are transient states between "waiting on + a dependency" and "scheduled to run." Both look identical to a + client polling the snapshot — there's nothing to display or do + differently. +- ``not_requested`` means the caller did not ask for this stage to + run (e.g. ``pitch_note_mode="off"``). Conceptually distinct from + "queued"; the stage is simply absent from the requested pipeline. + +This module exposes the five-state vocabulary intended for external +consumers and the mapping from the internal eight to that public +five. The internal ``status`` field on each stage stays untouched; +this is an *additive* collapse — the route layer attaches a parallel +``publicStatus`` field next to ``status`` on each stage. + +Mapping: + +============== =============================== +Internal Public (publicStatus) +============== =============================== +queued queued +running running +blocked queued (transient internal) +ready queued (scheduled, not yet running) +completed completed +failed failed +interrupted interrupted +not_requested None (not in the pipeline) +============== =============================== + +A ``None`` mapping is exposed in JSON as ``"publicStatus": null``. +This is deliberate — explicit ``null`` is easier for clients than +checking key presence, especially in strongly-typed languages where +optional vs missing are different shapes. + +See ``docs/adr/0001-phase1-json-schema-v1.md`` for the schema-version +treatment of this field. +""" + +from __future__ import annotations + +from typing import Final + + +PUBLIC_STATUS_VALUES: Final[frozenset[str]] = frozenset( + {"queued", "running", "completed", "failed", "interrupted"} +) + + +_INTERNAL_TO_PUBLIC: Final[dict[str, str | None]] = { + "queued": "queued", + "running": "running", + "blocked": "queued", + "ready": "queued", + "completed": "completed", + "failed": "failed", + "interrupted": "interrupted", + "not_requested": None, +} + + +def to_public_status(internal_status: str | None) -> str | None: + """Map an internal stage status to its public-facing equivalent. + + Returns ``None`` when: + - ``internal_status`` is ``None`` (stage exists but no status set) + - ``internal_status`` is ``"not_requested"`` (stage is not in the pipeline) + - ``internal_status`` is an unrecognized value (defensive — should not + happen with the current state machine, but a forwards-compat + guard against accidental internal-only additions leaking out) + """ + if internal_status is None: + return None + return _INTERNAL_TO_PUBLIC.get(internal_status) + + +def public_status_values() -> frozenset[str]: + """The complete set of non-null public status values. + + Useful for type generation, validation, and documentation. + """ + return PUBLIC_STATUS_VALUES diff --git a/apps/backend/tests/test_server.py b/apps/backend/tests/test_server.py index 90154dd9..3b46e449 100644 --- a/apps/backend/tests/test_server.py +++ b/apps/backend/tests/test_server.py @@ -1289,6 +1289,48 @@ def test_get_analysis_run_returns_persisted_stage_snapshot(self) -> None: self.assertEqual(payload["runId"], created["runId"]) self.assertIn("artifacts", payload) + def test_get_analysis_run_attaches_public_status_to_every_stage(self) -> None: + """Track 3.4: every stage in the response carries publicStatus + alongside the internal status. Pitch-note and interpretation + stages here are not_requested (publicStatus must be null); + measurement stage is queued (publicStatus must be queued). + """ + from analysis_runtime import AnalysisRuntime + + with tempfile.TemporaryDirectory(prefix="asa_server_runtime_") 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="off", + pitch_note_backend="auto", + interpretation_mode="off", + interpretation_profile="producer_summary", + interpretation_model=None, + ) + with patch.object(server, "get_analysis_runtime", return_value=runtime): + response = asyncio.run(server.get_analysis_run(created["runId"])) + + payload = self._decode_json_response(response) + stages = payload["stages"] + + # measurement starts queued; publicStatus should match + self.assertEqual(stages["measurement"]["status"], "queued") + self.assertEqual(stages["measurement"]["publicStatus"], "queued") + + # not_requested stages must surface publicStatus: null so a + # client can distinguish "you didn't ask for this" from + # "queued and waiting" without checking a specific string. + self.assertEqual( + stages["pitchNoteTranslation"]["status"], "not_requested" + ) + self.assertIsNone(stages["pitchNoteTranslation"]["publicStatus"]) + self.assertEqual( + stages["interpretation"]["status"], "not_requested" + ) + self.assertIsNone(stages["interpretation"]["publicStatus"]) + def test_interrupt_analysis_run_marks_stages_interrupted_and_reports_terminated_children(self) -> None: from analysis_runtime import AnalysisRuntime diff --git a/apps/backend/tests/test_stage_status.py b/apps/backend/tests/test_stage_status.py new file mode 100644 index 00000000..5d182f4c --- /dev/null +++ b/apps/backend/tests/test_stage_status.py @@ -0,0 +1,140 @@ +"""Unit tests for stage_status — the 8 → 5 public-status collapse. + +The collapse is the documented contract for ``publicStatus`` on every +stage object in the run snapshot. Tests here pin the mapping cell-by-cell +so a quiet edit to the lookup table is caught. +""" + +import sys +import unittest +from pathlib import Path + + +_BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(_BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(_BACKEND_ROOT)) + + +import stage_status # noqa: E402 — load after sys.path is set + + +class PublicStatusValuesTests(unittest.TestCase): + """The five public-facing values, exhaustively.""" + + def test_exact_five_values(self): + self.assertEqual( + stage_status.PUBLIC_STATUS_VALUES, + frozenset({"queued", "running", "completed", "failed", "interrupted"}), + ) + + def test_helper_returns_same_set(self): + self.assertEqual( + stage_status.public_status_values(), + stage_status.PUBLIC_STATUS_VALUES, + ) + + +class ToPublicStatusTests(unittest.TestCase): + """Cell-by-cell mapping pins for the eight internal states.""" + + def test_queued_maps_to_queued(self): + self.assertEqual(stage_status.to_public_status("queued"), "queued") + + def test_running_maps_to_running(self): + self.assertEqual(stage_status.to_public_status("running"), "running") + + def test_blocked_maps_to_queued(self): + """``blocked`` is transient internal scheduling; clients see it + the same as ``queued``.""" + self.assertEqual(stage_status.to_public_status("blocked"), "queued") + + def test_ready_maps_to_queued(self): + """``ready`` means scheduled but not yet started; same as queued + from the client's perspective.""" + self.assertEqual(stage_status.to_public_status("ready"), "queued") + + def test_completed_maps_to_completed(self): + self.assertEqual(stage_status.to_public_status("completed"), "completed") + + def test_failed_maps_to_failed(self): + self.assertEqual(stage_status.to_public_status("failed"), "failed") + + def test_interrupted_maps_to_interrupted(self): + self.assertEqual( + stage_status.to_public_status("interrupted"), "interrupted" + ) + + def test_not_requested_maps_to_none(self): + """``not_requested`` means the caller didn't ask for this stage; + conceptually distinct from queued. Exposed as ``null`` in JSON.""" + self.assertIsNone(stage_status.to_public_status("not_requested")) + + +class ToPublicStatusDefensiveTests(unittest.TestCase): + """Defensive cases: nones, unknowns, and the empty string.""" + + def test_none_returns_none(self): + self.assertIsNone(stage_status.to_public_status(None)) + + def test_unknown_internal_status_returns_none(self): + """A new internal-only state added without a public mapping + should fall through as None rather than leak through. This is + the forwards-compat guard.""" + self.assertIsNone(stage_status.to_public_status("does_not_exist")) + + def test_empty_string_returns_none(self): + self.assertIsNone(stage_status.to_public_status("")) + + def test_case_sensitive(self): + """The internal vocabulary is lowercase. Uppercase shouldn't + match — the mapping table is case-sensitive by design.""" + self.assertIsNone(stage_status.to_public_status("QUEUED")) + self.assertIsNone(stage_status.to_public_status("Running")) + + +class MappingCompletenessTests(unittest.TestCase): + """Pins the set of internal states the mapping covers. + + If a new internal status is added without a corresponding entry + here, this test fails. If an old one is removed, this test fails. + Either way, the developer must make a deliberate choice about the + public mapping rather than silently drift. + """ + + EXPECTED_INTERNAL_STATES = frozenset( + { + "queued", + "running", + "blocked", + "ready", + "completed", + "failed", + "interrupted", + "not_requested", + } + ) + + def test_internal_states_unchanged(self): + actual = frozenset(stage_status._INTERNAL_TO_PUBLIC.keys()) + self.assertEqual(actual, self.EXPECTED_INTERNAL_STATES) + + def test_all_non_null_mappings_are_public_values(self): + """Every non-None target of the mapping must be in + PUBLIC_STATUS_VALUES — no smuggling new public states in via + the mapping table without updating the canonical set.""" + for internal, public in stage_status._INTERNAL_TO_PUBLIC.items(): + if public is None: + continue + self.assertIn( + public, + stage_status.PUBLIC_STATUS_VALUES, + msg=( + f"{internal!r} maps to {public!r}, which is not in " + f"PUBLIC_STATUS_VALUES. Either add it to the public " + f"set or change the mapping." + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/ui/src/services/analysisRunsClient.ts b/apps/ui/src/services/analysisRunsClient.ts index 24f86fae..2ad68371 100644 --- a/apps/ui/src/services/analysisRunsClient.ts +++ b/apps/ui/src/services/analysisRunsClient.ts @@ -18,6 +18,7 @@ import { StemSummaryResult, PitchNoteTranslationAttemptSummary, PitchNoteTranslationStageSnapshot, + PublicStageStatus, } from '../types'; import { parsePhase1Result } from './backendPhase1Client'; import { requestBackendEstimate } from './backendPhase1Client'; @@ -60,6 +61,17 @@ const ANALYSIS_RUN_STATUSES = new Set([ 'not_requested', ]); +// Mirror of stage_status.PUBLIC_STATUS_VALUES on the backend. Five-value +// collapse exposed as the additive `publicStatus` field; the Python +// source of truth is `apps/backend/stage_status.py`. +const PUBLIC_STAGE_STATUSES = new Set([ + 'queued', + 'running', + 'completed', + 'failed', + 'interrupted', +]); + export async function estimateAnalysisRun( file: File, options: EstimateAnalysisRunOptions, @@ -288,6 +300,7 @@ function parseAnalysisRunSnapshot(value: unknown): AnalysisRunSnapshot { stages: { measurement: { status: expectStageStatus(measurement.status), + publicStatus: parsePublicStageStatus(measurement.publicStatus), authoritative: true, result: measurement.result == null ? null : parseCanonicalMeasurementResult(measurement.result), provenance: parseNullableRecord(measurement.provenance), @@ -354,6 +367,7 @@ function parseSpectralArtifacts(value: unknown): SpectralArtifacts { function parsePitchNoteStage(value: Record): PitchNoteTranslationStageSnapshot { return { status: expectStageStatus(value.status), + publicStatus: parsePublicStageStatus(value.publicStatus), authoritative: false, preferredAttemptId: asString(value.preferredAttemptId), attemptsSummary: Array.isArray(value.attemptsSummary) @@ -385,6 +399,7 @@ function parseInterpretationStage(value: Record): Interpretatio : undefined; return { status: expectStageStatus(value.status), + publicStatus: parsePublicStageStatus(value.publicStatus), authoritative: false, preferredAttemptId: asString(value.preferredAttemptId), attemptsSummary, @@ -684,6 +699,21 @@ function expectStageStatus(value: unknown): AnalysisStageStatus { return value as AnalysisStageStatus; } +/** + * Parse the additive `publicStatus` field on a stage. Returns `null` for + * either a JSON null (the documented "not in pipeline" signal) or for + * missing values on legacy snapshots that pre-date the field — callers + * that need the public collapse can use this without crashing on + * older runs. + */ +function parsePublicStageStatus(value: unknown): PublicStageStatus | null { + if (value === null || value === undefined) return null; + if (typeof value !== 'string') return null; + return PUBLIC_STAGE_STATUSES.has(value as PublicStageStatus) + ? (value as PublicStageStatus) + : null; +} + function asString(value: unknown): string | null { return typeof value === 'string' && value.trim() !== '' ? value : null; } diff --git a/apps/ui/src/types/backend.ts b/apps/ui/src/types/backend.ts index ef172b30..f6ef3759 100644 --- a/apps/ui/src/types/backend.ts +++ b/apps/ui/src/types/backend.ts @@ -40,6 +40,30 @@ export type AnalysisStageStatus = | 'interrupted' | 'not_requested'; +/** + * The 5-value collapse of {@link AnalysisStageStatus} that the response + * boundary attaches as `publicStatus` on every stage. + * + * Mapping (from `apps/backend/stage_status.py`): + * - queued | blocked | ready → `'queued'` + * - running → `'running'` + * - completed → `'completed'` + * - failed → `'failed'` + * - interrupted → `'interrupted'` + * - not_requested → `null` (stage exists but not in pipeline) + * + * Additive over `status` — the original 8-state field is preserved on + * every stage. New code that doesn't need to distinguish blocked-vs- + * queued or ready-vs-queued can read `publicStatus` and ignore the + * internal vocabulary. + */ +export type PublicStageStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'interrupted'; + export interface AnalysisStageError { code: string; message: string; @@ -124,6 +148,8 @@ export interface MeasurementAvailabilityContext { export interface MeasurementStageSnapshot { status: AnalysisStageStatus; + /** Additive 5-value collapse of `status`. `null` when status is `not_requested`. */ + publicStatus: PublicStageStatus | null; authoritative: true; result: MeasurementResult | null; provenance: Record | null; @@ -147,6 +173,8 @@ export interface InterpretationAttemptSummary { export interface PitchNoteTranslationStageSnapshot { status: AnalysisStageStatus; + /** Additive 5-value collapse of `status`. `null` when status is `not_requested`. */ + publicStatus: PublicStageStatus | null; authoritative: false; preferredAttemptId: string | null; attemptsSummary: PitchNoteTranslationAttemptSummary[]; @@ -158,6 +186,8 @@ export interface PitchNoteTranslationStageSnapshot { export interface InterpretationStageSnapshot { status: AnalysisStageStatus; + /** Additive 5-value collapse of `status`. `null` when status is `not_requested`. */ + publicStatus: PublicStageStatus | null; authoritative: false; preferredAttemptId: string | null; attemptsSummary: InterpretationAttemptSummary[]; diff --git a/docs/adr/0001-phase1-json-schema-v1.md b/docs/adr/0001-phase1-json-schema-v1.md index 23240af8..e702c2fe 100644 --- a/docs/adr/0001-phase1-json-schema-v1.md +++ b/docs/adr/0001-phase1-json-schema-v1.md @@ -59,6 +59,17 @@ The following nested shapes are the load-bearing time-series fields exported via Adding a new exportable time-series field is an additive change: register a serializer in [`apps/backend/csv_export.py`](../../apps/backend/csv_export.py), update the test in `apps/backend/tests/test_csv_export.py`, and document the CSV columns in the table above. +## Stage status field — `status` (internal) + `publicStatus` (additive) + +Every stage object inside the run snapshot (`stages.measurement`, `stages.pitchNoteTranslation`, `stages.interpretation`) carries two status fields: + +- **`status`** (8-state, internal vocabulary): `queued | running | blocked | ready | completed | failed | interrupted | not_requested`. This is the runtime's scheduling vocabulary and reflects internal state-machine transitions. Stable. +- **`publicStatus`** (5-state collapse + null): `queued | running | completed | failed | interrupted | null`. Added in Track 3.4 of the external-repo incorporation work. Maps `blocked`/`ready` → `queued`, `not_requested` → `null`, everything else 1:1. Stable. + +The collapse exists so external consumers who don't care about the distinction between internal scheduling states (e.g. blocked-waiting-for-dependency vs queued-and-ready) can read one field with a smaller vocabulary. The internal `status` is preserved for tools that need to debug or inspect runtime behavior. + +Source of truth for the mapping: [`apps/backend/stage_status.py`](../../apps/backend/stage_status.py). TypeScript mirror: `PublicStageStatus` in [`apps/ui/src/types/backend.ts`](../../apps/ui/src/types/backend.ts). + ## Where v1 is enforced | Layer | Enforcement | If you break v1 here | From a901996d81920c86fe443e9cfa01255f7d7396ab Mon Sep 17 00:00:00 2001 From: assiduous repetition Date: Thu, 14 May 2026 15:05:30 +1200 Subject: [PATCH 2/8] Audit overhaul: chain-of-custody citations, recommendations-first IA, applied tracker, grammar post-process (#41) --- 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 +- apps/ui/tests/e2e/phase1-exports.spec.ts | 5 +- .../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 + apps/ui/tests/smoke/file-validation.spec.ts | 14 +- .../ui/tests/smoke/phase2-degradation.spec.ts | 6 +- apps/ui/tests/smoke/responsive-layout.spec.ts | 16 +- apps/ui/tests/smoke/ui-details.spec.ts | 200 +----- 34 files changed, 3553 insertions(+), 424 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 →
@@ -963,17 +997,10 @@ export default function App() { {phase2StatusBadge} )} -
-
- CPU -
-
-
-
+ {/* 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. +
+
+
+

Analyzed

+ {(() => { + const formatted = formatTrackDuration(phase1ForRender?.durationSeconds); + return formatted ? ( + + {formatted} + + ) : null; + })()} +
+

+ {audioFile.name} +

+
+
+ + +
+
+ ) : ( + <> + {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.