From eceb99b3a8bef41f03f27a7f7370054ba68fa304 Mon Sep 17 00:00:00 2001 From: assiduous repetition Date: Wed, 1 Jul 2026 23:16:31 +1200 Subject: [PATCH 1/8] feat(phase1): local-only fundamentals-quality trust layer Adds a Phase 1 `fundamentalsQuality` block that labels each musical fundamental (tempo, beat grid, downbeats, meter, key, chords, percussion, transcription) as authoritative / ambiguous / failed / not_run, purely from local DSP output. The Phase 2 validator now blocks recommendations from replacing local BPM/key/meter or citing weak fundamentals as confident facts, and the UI surfaces per-domain trust indicators plus an overall pill. Also reworks the fast analyzer to reuse the canonical analyze_core BPM/key/meter/rhythm functions, adds a fundamentals benchmark harness (gitignored local audio), and wires a verify-time evaluation gate. Review fixes applied on top of the initial implementation: - Fast mode no longer runs the full-track tuning-frequency pass whose output it discards (analyze_key gains include_tuning=False for the fast path). - Beat-grid trust now uses the detector's own confidence floor (0.4) instead of the tempo domain's 0.7, so ordinary well-tracked grids are not marked ambiguous and do not force every beat-grid-citing recommendation to hedge. - Removed the dead `low` threshold in _status_from_confidence (both branches returned "ambiguous", so it never affected the result). - Replaced the no-op schemaVersion/targetProfile ternaries in the Phase 1 parser with direct literal assignment. - overallStatus now ignores not_run domains, so a fully-measured standard run (which never runs transcription) is no longer collapsed to "ambiguous", and is surfaced as an overall trust pill in the Measurement Summary. Co-Authored-By: Claude Opus 4.8 --- apps/backend/JSON_SCHEMA.md | 22 +- apps/backend/analyze.py | 9 + apps/backend/analyze_core.py | 15 +- apps/backend/analyze_fast.py | 86 +-- apps/backend/fundamentals_evaluation.py | 488 ++++++++++++++++++ apps/backend/fundamentals_quality.py | 360 +++++++++++++ apps/backend/scripts/evaluate_fundamentals.py | 59 +++ apps/backend/server_phase1.py | 1 + .../fixtures/fundamentals_eval_manifest.json | 94 ++++ .../fixtures/fundamentals_tracks/.gitignore | 4 + .../fixtures/fundamentals_tracks/README.md | 9 + .../tests/fixtures/golden/phase1_default.json | 98 +++- apps/backend/tests/test_analyze.py | 36 +- apps/backend/tests/test_audio_fixture.py | 8 + .../tests/test_fundamentals_evaluation.py | 150 ++++++ .../tests/test_fundamentals_quality.py | 143 +++++ apps/ui/src/components/HarmonyLanes.tsx | 30 ++ .../MeasurementSummarySection.tsx | 57 ++ apps/ui/src/services/backendPhase1Client.ts | 48 ++ apps/ui/src/services/phase2Validator.ts | 154 ++++++ apps/ui/src/types/measurement.ts | 22 + apps/ui/tests/fixtures/phase1FullPayload.ts | 86 +++ .../services/backendPhase1Client.test.ts | 6 + .../services/fundamentalsQualityUi.test.ts | 39 ++ .../ui/tests/services/phase2Validator.test.ts | 133 +++++ bin/asa | 4 + 26 files changed, 2084 insertions(+), 77 deletions(-) create mode 100644 apps/backend/fundamentals_evaluation.py create mode 100644 apps/backend/fundamentals_quality.py create mode 100644 apps/backend/scripts/evaluate_fundamentals.py create mode 100644 apps/backend/tests/fixtures/fundamentals_eval_manifest.json create mode 100644 apps/backend/tests/fixtures/fundamentals_tracks/.gitignore create mode 100644 apps/backend/tests/fixtures/fundamentals_tracks/README.md create mode 100644 apps/backend/tests/test_fundamentals_evaluation.py create mode 100644 apps/backend/tests/test_fundamentals_quality.py create mode 100644 apps/ui/tests/services/fundamentalsQualityUi.test.ts diff --git a/apps/backend/JSON_SCHEMA.md b/apps/backend/JSON_SCHEMA.md index 1d48af8b..f3e1cc02 100644 --- a/apps/backend/JSON_SCHEMA.md +++ b/apps/backend/JSON_SCHEMA.md @@ -15,7 +15,7 @@ Conventions: Top-level keys: -`phase1Version`, `bpm`, `bpmConfidence`, `bpmPercival`, `bpmAgreement`, `bpmDoubletime`, `bpmSource`, `bpmRawOriginal`, `key`, `keyConfidence`, `keyProfile`, `tuningFrequency`, `tuningCents`, `timeSignature`, `timeSignatureSource`, `timeSignatureConfidence`, `durationSeconds`, `sampleRate`, `lufsIntegrated`, `lufsRange`, `lufsMomentaryMax`, `lufsShortTermMax`, `lufsCurve`, `truePeak`, `crestFactor`, `dynamicSpread`, `monoCompatible`, `plr`, `dynamicCharacter`, `textureCharacter`, `stereoDetail`, `spectralBalance`, `spectralBalanceTimeSeries`, `spectralDetail`, `stemAnalysis`, `transientDensityDetail`, `saturationDetail`, `snareDetail`, `hihatDetail`, `rhythmDetail`, `melodyDetail`, `transcriptionDetail`, `pitchDetail`, `grooveDetail`, `beatsLoudness`, `rhythmTimeline`, `sidechainDetail`, `reverbDetail`, `vocalDetail`, `acidDetail`, `supersawDetail`, `bassDetail`, `kickDetail`, `genreDetail`, `effectsDetail`, `synthesisCharacter`, `danceability`, `structure`, `arrangementDetail`, `segmentLoudness`, `segmentSpectral`, `segmentStereo`, `segmentKey`, `chordDetail`, `perceptual`, `essentiaFeatures`. +`phase1Version`, `fundamentalsQuality`, `bpm`, `bpmConfidence`, `bpmPercival`, `bpmAgreement`, `bpmDoubletime`, `bpmSource`, `bpmRawOriginal`, `key`, `keyConfidence`, `keyProfile`, `tuningFrequency`, `tuningCents`, `timeSignature`, `timeSignatureSource`, `timeSignatureConfidence`, `durationSeconds`, `sampleRate`, `lufsIntegrated`, `lufsRange`, `lufsMomentaryMax`, `lufsShortTermMax`, `lufsCurve`, `truePeak`, `crestFactor`, `dynamicSpread`, `monoCompatible`, `plr`, `dynamicCharacter`, `textureCharacter`, `stereoDetail`, `spectralBalance`, `spectralBalanceTimeSeries`, `spectralDetail`, `stemAnalysis`, `transientDensityDetail`, `saturationDetail`, `snareDetail`, `hihatDetail`, `rhythmDetail`, `melodyDetail`, `transcriptionDetail`, `pitchDetail`, `grooveDetail`, `beatsLoudness`, `rhythmTimeline`, `sidechainDetail`, `reverbDetail`, `vocalDetail`, `acidDetail`, `supersawDetail`, `bassDetail`, `kickDetail`, `genreDetail`, `effectsDetail`, `synthesisCharacter`, `danceability`, `structure`, `arrangementDetail`, `segmentLoudness`, `segmentSpectral`, `segmentStereo`, `segmentKey`, `chordDetail`, `perceptual`, `essentiaFeatures`. **Shared (fast + full) vs full-only.** Fast-mode output is asserted byte-for-byte against the `EXPECTED_TOP_LEVEL_KEYS` set in [`tests/test_analyze.py`](tests/test_analyze.py). Full mode emits those keys *plus* a handful of detail-only fields that are deliberately absent from the shared snapshot: `keyProfile`, `tuningFrequency`, `tuningCents`, `lufsMomentaryMax`, `lufsShortTermMax`, and `pitchDetail`. When changing the schema, update both this list and `EXPECTED_TOP_LEVEL_KEYS`; full-only fields stay out of that set on purpose. See CLAUDE.md tripwire #4. Schema changes are also cross-checked executably against the frontend fixture and parser by `apps/ui/tests/services/phase1ContractParity.test.ts`, driven by the golden snapshot's `topLevelKeys`/`keyTree` — regenerating the golden (`UPDATE_PHASE1_GOLDEN=1`) is what arms the nested half of that gate. @@ -150,6 +150,7 @@ Current server behavior that affects schema expectations: | Field | Type | Description | Units / Scale | LLM interpretation note | |---|---|---|---|---| | `phase1Version` | `string` | Phase 1 JSON schema version, e.g. `"phase1.v2"`. | identifier | Lets consumers detect the schema generation. v2 changed `truePeak` (now dBTP) and `bpmConfidence` (now 0-1) units vs v1. | +| `fundamentalsQuality` | `object` | Local-only authority summary for tempo, beat grid, downbeats, meter, key, chords, percussion, and monophonic transcription. | `fundamentals-quality.v1` | Never produced by an LLM. Use its status and plain-English messages to avoid presenting weak measurements as settled facts. | | `bpm` | `float \| null` | Primary tempo estimate from `RhythmExtractor2013`. | beats per minute | Main tempo anchor for Ableton project tempo and clip warp assumptions. | | `bpmConfidence` | `float \| null` | Tempo confidence from `RhythmExtractor2013`, normalized to 0-1. | 0-1 (Phase 1 v2: raw Essentia confidence ~0-5.32 divided by 5.0 and clamped) | Higher = stronger rhythmic periodicity. Below 0.4 (raw < 2.0) suggests an ambiguous pulse or half/double-time content; hedge tempo claims there. | | `key` | `string \| null` | Global key label from `KeyExtractor` (`edma` profile), e.g. `"A Minor"`. | categorical | Starting point for harmonic reconstruction; validate by ear against bass/chord roots. | @@ -163,6 +164,25 @@ Current server behavior that affects schema expectations: | `tuningFrequency` | `float \| null` | Estimated tuning reference frequency from spectral peak analysis. | Hz | Deviation from 440 Hz helps detect detuned material or concert-pitch variants. | | `tuningCents` | `float \| null` | Tuning offset from A440 in cents. | cents | Positive = sharp of A440, negative = flat. Useful for pitch-correcting reconstructions. | +### `fundamentalsQuality` + +`fundamentalsQuality` is a summary of trust, not a separate analyzer. It reads +the local Phase 1 output and labels each musical fundamental as +`authoritative`, `ambiguous`, `failed`, or `not_run`. + +| Field | Type | Description | +|---|---|---| +| `fundamentalsQuality.schemaVersion` | `string` | Currently `"fundamentals-quality.v1"`. | +| `fundamentalsQuality.targetProfile` | `string` | Currently `"electronic_ableton_v1"`. | +| `fundamentalsQuality.localOnly` | `bool` | Always `true`; this block is derived from local DSP/model outputs only. | +| `fundamentalsQuality.llmExcluded` | `bool` | Always `true`; Phase 2 must never write or override this block. | +| `fundamentalsQuality.overallStatus` | `string` | Worst-case status across domains. | +| `fundamentalsQuality.domains..status` | `string` | Domain status for `tempo`, `beatGrid`, `downbeats`, `meter`, `key`, `chords`, `percussion`, and `transcription`. | +| `fundamentalsQuality.domains..plainEnglish` | `string` | Short user-facing explanation of how much to trust that domain. | +| `fundamentalsQuality.domains..source` | `string \| null` | Local algorithm/source label, e.g. `rhythm_extractor_confirmed`, `edma`, or `librosa_viterbi`. | +| `fundamentalsQuality.domains..confidence` | `float \| null` | Normalized confidence when a domain has a scalar confidence. | +| `fundamentalsQuality.domains..evidence` | `object` | Small domain-specific evidence object, such as BPM agreement, chord disagreement, hit counts, or full-mix fallback. | + --- ## BPM Cross-Check diff --git a/apps/backend/analyze.py b/apps/backend/analyze.py index 8050909d..d79798a3 100644 --- a/apps/backend/analyze.py +++ b/apps/backend/analyze.py @@ -197,6 +197,7 @@ TorchcrepeBackend, analyze_transcription, ) +from fundamentals_quality import build_fundamentals_quality # noqa: E402 def analyze_structure( @@ -1531,6 +1532,10 @@ def main(): "perceptual": result.get("perceptual"), "essentiaFeatures": result.get("essentiaFeatures"), } + output["fundamentalsQuality"] = build_fundamentals_quality( + output, + analysis_mode="fast", + ) _emit_progress_marker("complete", "Analysis complete.", 1.0) print("Done.", file=sys.stderr) print(json.dumps(output, indent=2)) @@ -1946,6 +1951,10 @@ def main(): "perceptual": result.get("perceptual"), "essentiaFeatures": result.get("essentiaFeatures"), } + output["fundamentalsQuality"] = build_fundamentals_quality( + output, + analysis_mode="standard" if run_standard else "full", + ) # Conditional MT3 namespace — *absent* (not null) by default. Only added # when ASA_ENABLE_MT3=1 AND the transcribe() call succeeded. See the gate diff --git a/apps/backend/analyze_core.py b/apps/backend/analyze_core.py index c279b905..d35d7057 100644 --- a/apps/backend/analyze_core.py +++ b/apps/backend/analyze_core.py @@ -135,8 +135,14 @@ def analyze_bpm( } -def analyze_key(mono: np.ndarray) -> dict: - """Extract musical key and confidence using KeyExtractor with EDMA profile.""" +def analyze_key(mono: np.ndarray, *, include_tuning: bool = True) -> dict: + """Extract musical key and confidence using KeyExtractor with EDMA profile. + + ``include_tuning`` gates the per-frame spectral-peak tuning-frequency pass, + which is the dominant cost of key analysis on a full track. Fast mode does + not surface ``tuningFrequency``/``tuningCents``, so it passes ``False`` to + skip that work while keeping the return shape identical. + """ try: extractor = es.KeyExtractor(profileType="edma") key, scale, strength = extractor(mono) @@ -147,6 +153,11 @@ def analyze_key(mono: np.ndarray) -> dict: "keyProfile": "edma", } + if not include_tuning: + result["tuningFrequency"] = None + result["tuningCents"] = None + return result + try: frame_size = 2048 hop_size = 1024 diff --git a/apps/backend/analyze_fast.py b/apps/backend/analyze_fast.py index 1deebcd4..1d499bc4 100644 --- a/apps/backend/analyze_fast.py +++ b/apps/backend/analyze_fast.py @@ -10,9 +10,15 @@ import sys import numpy as np -import essentia.standard as es -from analyze import apply_bpm_correction +from analyze_core import ( + analyze_bpm, + analyze_key, + analyze_loudness, + analyze_time_signature, + analyze_true_peak, + extract_rhythm, +) def analyze_fast(mono: np.ndarray, sample_rate: int = 44100) -> dict: @@ -37,64 +43,10 @@ def analyze_fast(mono: np.ndarray, sample_rate: int = 44100) -> dict: """ result = {} - # Run RhythmExtractor2013 for BPM and beat data - try: - rhythm_extractor = es.RhythmExtractor2013(method="multifeature") - bpm, beats, bpm_confidence, _, _ = rhythm_extractor(mono) - result["bpm"] = round(float(bpm), 2) if bpm is not None else None - # Phase 1 v2: normalize RhythmExtractor2013 confidence (~0-5.32) to 0-1. - result["bpmConfidence"] = ( - round(min(max(float(bpm_confidence), 0.0) / 5.0, 1.0), 3) - if bpm_confidence is not None - else None - ) - except Exception as e: - print(f"[warn] Fast mode BPM analysis failed: {e}", file=sys.stderr) - result["bpm"] = None - result["bpmConfidence"] = None - beats = None - - # Percival BPM for cross-check - try: - percival = es.PercivalBpmEstimator() - bpm_percival = percival(mono) - result["bpmPercival"] = round(float(bpm_percival), 2) if bpm_percival is not None else None - except Exception as e: - print(f"[warn] Fast mode Percival BPM failed: {e}", file=sys.stderr) - result["bpmPercival"] = None - - # BPM agreement - if result.get("bpm") is not None and result.get("bpmPercival") is not None: - result["bpmAgreement"] = abs(result["bpm"] - result["bpmPercival"]) < 2.0 - else: - result["bpmAgreement"] = None - - # BPM correction (shared helper) - correction = apply_bpm_correction(result["bpm"], result["bpmPercival"], result["bpmAgreement"]) - result.update(correction) - - # Key extraction - try: - key_extractor = es.KeyExtractor(profileType="edma") - key, scale, strength = key_extractor(mono) - result["key"] = f"{key} {scale}".title() if key and scale else None - result["keyConfidence"] = round(float(strength), 3) if strength is not None else None - except Exception as e: - print(f"[warn] Fast mode key analysis failed: {e}", file=sys.stderr) - result["key"] = None - result["keyConfidence"] = None - - # Time signature (default to 4/4 if we have rhythm data) - if beats is not None and len(beats) > 0: - result["timeSignature"] = "4/4" - result["timeSignatureSource"] = "assumed_four_four" - result["timeSignatureConfidence"] = 0.0 - else: - result["timeSignature"] = None - result["timeSignatureSource"] = None - result["timeSignatureConfidence"] = None - - # Duration + rhythm_data = extract_rhythm(mono) + result.update(analyze_bpm(rhythm_data, mono, sample_rate)) + result.update(analyze_key(mono, include_tuning=False)) + result.update(analyze_time_signature(rhythm_data, mono=mono, sample_rate=sample_rate)) result["durationSeconds"] = round(float(len(mono) / sample_rate), 3) result["sampleRate"] = sample_rate @@ -102,24 +54,16 @@ def analyze_fast(mono: np.ndarray, sample_rate: int = 44100) -> dict: try: # Need stereo for LUFS — Essentia expects shape (N, 2), not (2, N) stereo = np.stack([mono, mono], axis=-1) - loudness_algo = es.LoudnessEBUR128(sampleRate=sample_rate) - _, _, lufs_integrated, lufs_range = loudness_algo(stereo) - result["lufsIntegrated"] = round(float(lufs_integrated), 2) if lufs_integrated is not None else None - result["lufsRange"] = round(float(lufs_range), 2) if lufs_range is not None else None + result.update(analyze_loudness(stereo, sample_rate=sample_rate)) except Exception as e: print(f"[warn] Fast mode loudness analysis failed: {e}", file=sys.stderr) result["lufsIntegrated"] = None result["lufsRange"] = None - # True peak (from stereo), emitted in dBTP (Phase 1 v2). NOTE: fast mode uses - # a plain sample peak (np.max(np.abs)), NOT the oversampled inter-sample true - # peak the standard path computes via Essentia's TruePeakDetector — so this is - # a sample-peak approximation and may read lower than the standard path on - # material with real inter-sample overs. + # True peak (from stereo), emitted in dBTP (Phase 1 v2). try: if stereo is not None: - max_peak = float(np.max(np.abs(stereo))) - result["truePeak"] = round(20.0 * np.log10(max_peak), 1) if max_peak > 0 else None + result.update(analyze_true_peak(stereo)) else: result["truePeak"] = None except Exception as e: diff --git a/apps/backend/fundamentals_evaluation.py b/apps/backend/fundamentals_evaluation.py new file mode 100644 index 00000000..1b06856d --- /dev/null +++ b/apps/backend/fundamentals_evaluation.py @@ -0,0 +1,488 @@ +"""Evaluation harness for local musical-fundamentals accuracy gates.""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + + +BACKEND_DIR = Path(__file__).resolve().parent +DEFAULT_MANIFEST_PATH = BACKEND_DIR / "tests" / "fixtures" / "fundamentals_eval_manifest.json" +DEFAULT_TRACKS_DIR = BACKEND_DIR / "tests" / "fixtures" / "fundamentals_tracks" +DEFAULT_REPORT_PATH = BACKEND_DIR / ".runtime" / "reports" / "fundamentals_eval_report.json" + +STATUS_RANK = { + "failed": 0, + "not_run": 1, + "ambiguous": 2, + "authoritative": 3, +} + + +@dataclass +class FundamentalsCheck: + name: str + passed: bool + message: str + + +@dataclass +class FundamentalsTrackResult: + track_id: str + audio_path: str + category: str + description: str + status: str + skip_reason: str | None + checks: list[FundamentalsCheck] + all_passed: bool + + +Runner = Callable[[Path, list[str] | None], dict[str, Any]] + + +def run_fundamentals_evaluation( + *, + manifest_path: Path = DEFAULT_MANIFEST_PATH, + tracks_dir: Path = DEFAULT_TRACKS_DIR, + report_path: Path = DEFAULT_REPORT_PATH, + runner: Runner | None = None, +) -> dict[str, Any]: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + tracks = manifest.get("tracks", []) + if not isinstance(tracks, list): + raise ValueError("fundamentals manifest must define tracks as a list") + if runner is None: + runner = _run_analyze + + evaluated = 0 + skipped = 0 + failed_subprocess = 0 + passed_checks = 0 + failed_checks = 0 + track_reports: list[dict[str, Any]] = [] + + for raw_track in tracks: + if not isinstance(raw_track, dict): + continue + result = _evaluate_track(raw_track, tracks_dir, runner) + if result.status == "evaluated": + evaluated += 1 + passed_checks += sum(1 for check in result.checks if check.passed) + failed_checks += sum(1 for check in result.checks if not check.passed) + elif result.status == "skipped_audio_missing": + skipped += 1 + else: + failed_subprocess += 1 + failed_checks += 1 + track_reports.append(_track_result_to_report(result)) + + summary = { + "tracks": len(track_reports), + "tracksEvaluated": evaluated, + "tracksSkipped": skipped, + "tracksAnalyzeFailed": failed_subprocess, + "checksPassed": passed_checks, + "checksFailed": failed_checks, + "allPassed": failed_checks == 0, + } + report: dict[str, Any] = { + "schemaVersion": "fundamentals-eval-report.v1", + "generatedAt": datetime.now(timezone.utc).isoformat(), + "manifestPath": str(manifest_path), + "tracksDir": str(tracks_dir), + "targetProfile": manifest.get("targetProfile", "electronic_ableton_v1"), + "gates": manifest.get("gates", {}), + "tracks": track_reports, + "summary": summary, + } + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, indent=2), encoding="utf-8") + report["reportPath"] = str(report_path) + return report + + +def _evaluate_track(raw: dict[str, Any], tracks_dir: Path, runner: Runner) -> FundamentalsTrackResult: + track_id = str(raw.get("id") or "unknown") + audio_rel = str(raw.get("audioPath") or "") + category = str(raw.get("category") or "uncategorized") + description = str(raw.get("description") or "") + expected = raw.get("expected") if isinstance(raw.get("expected"), dict) else {} + thresholds = raw.get("thresholds") if isinstance(raw.get("thresholds"), dict) else {} + flags = raw.get("analyzeFlags") + analyze_flags = [str(flag) for flag in flags] if isinstance(flags, list) else None + + if not audio_rel: + return FundamentalsTrackResult( + track_id, + "", + category, + description, + "skipped_audio_missing", + "manifest entry has no audioPath", + [], + True, + ) + + audio_path = (tracks_dir / audio_rel).resolve() + if not audio_path.exists(): + return FundamentalsTrackResult( + track_id, + str(audio_path), + category, + description, + "skipped_audio_missing", + f"audio not present at {audio_path}; add the local file to activate this gate", + [], + True, + ) + + try: + payload = runner(audio_path, analyze_flags) + except subprocess.CalledProcessError as exc: + stderr_tail = (exc.stderr or "")[-400:] + return FundamentalsTrackResult( + track_id, + str(audio_path), + category, + description, + "skipped_analyze_failed", + f"analyze.py failed (exit {exc.returncode}): {stderr_tail}", + [], + False, + ) + + checks = _evaluate_expected(payload, expected, thresholds) + return FundamentalsTrackResult( + track_id, + str(audio_path), + category, + description, + "evaluated", + None, + checks, + all(check.passed for check in checks), + ) + + +def _evaluate_expected( + payload: dict[str, Any], + expected: dict[str, Any], + thresholds: dict[str, Any], +) -> list[FundamentalsCheck]: + checks: list[FundamentalsCheck] = [] + bpm = expected.get("bpm") + if isinstance(bpm, (int, float)): + tolerance = float(thresholds.get("bpmTolerance", 1.0)) + actual = _number(payload.get("bpm")) + passed = actual is not None and abs(actual - float(bpm)) <= tolerance + checks.append(FundamentalsCheck( + "tempo:bpm", + passed, + f"target={bpm} tolerance={tolerance} actual={actual}", + )) + + key = expected.get("key") + if isinstance(key, str) and key.strip(): + actual_key = payload.get("key") if isinstance(payload.get("key"), str) else None + allow_relative = thresholds.get("allowRelativeMajorMinor") is True + passed = _keys_match(actual_key, key, allow_relative=allow_relative) + checks.append(FundamentalsCheck( + "key:label", + passed, + f"expected={key} actual={actual_key} allowRelativeMajorMinor={allow_relative}", + )) + + meter = expected.get("timeSignature") + if isinstance(meter, str) and meter.strip(): + actual_meter = payload.get("timeSignature") if isinstance(payload.get("timeSignature"), str) else None + passed = _normalize_meter(actual_meter) == _normalize_meter(meter) + checks.append(FundamentalsCheck( + "meter:timeSignature", + passed, + f"expected={meter} actual={actual_meter}", + )) + + if isinstance(expected.get("beatGrid"), list): + threshold = float(thresholds.get("beatF1", 0.9)) + actual = _nested_list(payload, "rhythmDetail.beatGrid") + f1 = _event_f1(actual, expected["beatGrid"], tolerance_seconds=float(thresholds.get("beatToleranceSeconds", 0.07))) + checks.append(FundamentalsCheck( + "beatGrid:f1", + f1 >= threshold, + f"target>={threshold} actual={round(f1, 4)}", + )) + + if isinstance(expected.get("downbeats"), list): + threshold = float(thresholds.get("downbeatF1", 0.75)) + actual = _nested_list(payload, "rhythmDetail.downbeats") + f1 = _event_f1(actual, expected["downbeats"], tolerance_seconds=float(thresholds.get("downbeatToleranceSeconds", 0.1))) + checks.append(FundamentalsCheck( + "downbeats:f1", + f1 >= threshold, + f"target>={threshold} actual={round(f1, 4)}", + )) + + if isinstance(expected.get("chordTimeline"), list): + threshold = float(thresholds.get("chordSegmentAccuracy", 0.65)) + actual = _nested_list(payload, "chordDetail.chordTimeline") + score = _chord_segment_accuracy(actual, expected["chordTimeline"]) + checks.append(FundamentalsCheck( + "chords:segmentAccuracy", + score >= threshold, + f"target>={threshold} actual={round(score, 4)}", + )) + + percussion = expected.get("percussion") + if isinstance(percussion, dict): + checks.extend(_evaluate_percussion_counts(payload, percussion, thresholds)) + + transcription_notes = expected.get("transcriptionNotes") + if isinstance(transcription_notes, list): + threshold = float(thresholds.get("transcriptionNoteF1", 0.75)) + actual = _nested_list(payload, "transcriptionDetail.notes") + f1 = _note_f1(actual, transcription_notes) + checks.append(FundamentalsCheck( + "transcription:noteF1", + f1 >= threshold, + f"target>={threshold} actual={round(f1, 4)}", + )) + + required_quality = thresholds.get("requiredQuality") + if isinstance(required_quality, dict): + checks.extend(_evaluate_required_quality(payload, required_quality)) + + return checks + + +def _evaluate_percussion_counts( + payload: dict[str, Any], + percussion: dict[str, Any], + thresholds: dict[str, Any], +) -> list[FundamentalsCheck]: + checks: list[FundamentalsCheck] = [] + count_specs = [ + ("kick", "kickDetail.kickCount"), + ("snare", "snareDetail.hitCount"), + ("hihat", "hihatDetail.hitCount"), + ] + tolerance = int(thresholds.get("percussionCountTolerance", 1)) + for label, path in count_specs: + expected_count = percussion.get(f"{label}Count") + if not isinstance(expected_count, int): + continue + actual_count = _number(_nested_value(payload, path)) + passed = actual_count is not None and abs(int(actual_count) - expected_count) <= tolerance + checks.append(FundamentalsCheck( + f"percussion:{label}Count", + passed, + f"expected={expected_count} tolerance={tolerance} actual={actual_count}", + )) + return checks + + +def _evaluate_required_quality( + payload: dict[str, Any], + required_quality: dict[str, Any], +) -> list[FundamentalsCheck]: + checks: list[FundamentalsCheck] = [] + for domain, requirement in required_quality.items(): + actual = _nested_value(payload, f"fundamentalsQuality.domains.{domain}.status") + if isinstance(requirement, list): + allowed = [str(value) for value in requirement] + passed = str(actual) in allowed + message = f"allowed={allowed} actual={actual}" + else: + expected = str(requirement) + passed = _status_rank(str(actual)) >= _status_rank(expected) + message = f"minimum={expected} actual={actual}" + checks.append(FundamentalsCheck(f"quality:{domain}", passed, message)) + return checks + + +def _run_analyze(audio_path: Path, extra_flags: list[str] | None = None) -> dict[str, Any]: + command = [sys.executable, str(BACKEND_DIR / "analyze.py"), str(audio_path), "--yes"] + if extra_flags: + command.extend(extra_flags) + completed = subprocess.run( + command, + cwd=BACKEND_DIR, + check=True, + capture_output=True, + text=True, + ) + payload = json.loads(completed.stdout) + if not isinstance(payload, dict): + raise ValueError("analyze.py did not emit a JSON object") + return payload + + +def _track_result_to_report(result: FundamentalsTrackResult) -> dict[str, Any]: + return { + "id": result.track_id, + "audioPath": result.audio_path, + "category": result.category, + "description": result.description, + "status": result.status, + "skipReason": result.skip_reason, + "checks": [ + {"name": check.name, "passed": check.passed, "message": check.message} + for check in result.checks + ], + "allPassed": result.all_passed, + } + + +def _nested_value(payload: dict[str, Any], dotted_path: str) -> Any: + current: Any = payload + for part in dotted_path.split("."): + if not isinstance(current, dict): + return None + current = current.get(part) + return current + + +def _nested_list(payload: dict[str, Any], dotted_path: str) -> list[Any]: + value = _nested_value(payload, dotted_path) + return value if isinstance(value, list) else [] + + +def _number(value: Any) -> float | None: + return float(value) if isinstance(value, (int, float)) else None + + +def _normalize_meter(value: str | None) -> str: + return (value or "").strip().replace(" ", "").lower() + + +def _normalize_key(value: str | None) -> str: + normalized = (value or "").strip().lower() + normalized = re.sub(r"\bmaj\b", "major", normalized) + normalized = re.sub(r"\bmin\b", "minor", normalized) + return re.sub(r"\s+", " ", normalized) + + +def _keys_match(actual: str | None, expected: str, *, allow_relative: bool) -> bool: + actual_norm = _normalize_key(actual) + expected_norm = _normalize_key(expected) + if actual_norm == expected_norm: + return True + if not allow_relative: + return False + return _relative_key(actual_norm) == expected_norm or _relative_key(expected_norm) == actual_norm + + +def _relative_key(normalized: str) -> str: + minor_to_major = { + "a minor": "c major", + "e minor": "g major", + "b minor": "d major", + "f# minor": "a major", + "c# minor": "e major", + "g# minor": "b major", + "d# minor": "f# major", + "a# minor": "c# major", + "d minor": "f major", + "g minor": "bb major", + "c minor": "eb major", + "f minor": "ab major", + "bb minor": "db major", + "eb minor": "gb major", + "ab minor": "cb major", + } + major_to_minor = {major: minor for minor, major in minor_to_major.items()} + return minor_to_major.get(normalized) or major_to_minor.get(normalized) or "" + + +def _event_f1(actual: list[Any], expected: list[Any], *, tolerance_seconds: float) -> float: + actual_times = [float(value) for value in actual if isinstance(value, (int, float))] + expected_times = [float(value) for value in expected if isinstance(value, (int, float))] + if not actual_times and not expected_times: + return 1.0 + if not actual_times or not expected_times: + return 0.0 + used: set[int] = set() + matches = 0 + for expected_time in expected_times: + for index, actual_time in enumerate(actual_times): + if index in used: + continue + if abs(actual_time - expected_time) <= tolerance_seconds: + used.add(index) + matches += 1 + break + precision = matches / len(actual_times) if actual_times else 0.0 + recall = matches / len(expected_times) if expected_times else 0.0 + return _f1(precision, recall) + + +def _chord_segment_accuracy(actual: list[Any], expected: list[Any]) -> float: + expected_segments = [entry for entry in expected if isinstance(entry, dict)] + actual_segments = [entry for entry in actual if isinstance(entry, dict)] + if not expected_segments and not actual_segments: + return 1.0 + if not expected_segments or not actual_segments: + return 0.0 + total_duration = 0.0 + matching_duration = 0.0 + for expected_segment in expected_segments: + start = _number(expected_segment.get("startSec")) + end = _number(expected_segment.get("endSec")) + label = str(expected_segment.get("label") or "") + if start is None or end is None or end <= start: + continue + total_duration += end - start + for actual_segment in actual_segments: + actual_start = _number(actual_segment.get("startSec")) + actual_end = _number(actual_segment.get("endSec")) + actual_label = str(actual_segment.get("label") or "") + if actual_start is None or actual_end is None: + continue + overlap = max(0.0, min(end, actual_end) - max(start, actual_start)) + if overlap > 0.0 and actual_label == label: + matching_duration += overlap + return matching_duration / total_duration if total_duration > 0 else 0.0 + + +def _note_f1(actual: list[Any], expected: list[Any]) -> float: + actual_notes = [entry for entry in actual if isinstance(entry, dict)] + expected_notes = [entry for entry in expected if isinstance(entry, dict)] + if not actual_notes and not expected_notes: + return 1.0 + if not actual_notes or not expected_notes: + return 0.0 + used: set[int] = set() + matches = 0 + for expected_note in expected_notes: + expected_onset = _number(expected_note.get("onsetSeconds")) + expected_pitch = _number(expected_note.get("pitchMidi")) + if expected_onset is None or expected_pitch is None: + continue + for index, actual_note in enumerate(actual_notes): + if index in used: + continue + actual_onset = _number(actual_note.get("onsetSeconds")) + actual_pitch = _number(actual_note.get("pitchMidi")) + if actual_onset is None or actual_pitch is None: + continue + if abs(actual_onset - expected_onset) <= 0.05 and abs(actual_pitch - expected_pitch) <= 1: + used.add(index) + matches += 1 + break + precision = matches / len(actual_notes) if actual_notes else 0.0 + recall = matches / len(expected_notes) if expected_notes else 0.0 + return _f1(precision, recall) + + +def _f1(precision: float, recall: float) -> float: + return 0.0 if precision + recall == 0 else (2.0 * precision * recall) / (precision + recall) + + +def _status_rank(status: str) -> int: + return STATUS_RANK.get(status, -1) diff --git a/apps/backend/fundamentals_quality.py b/apps/backend/fundamentals_quality.py new file mode 100644 index 00000000..fee22d1a --- /dev/null +++ b/apps/backend/fundamentals_quality.py @@ -0,0 +1,360 @@ +"""Local-only quality summary for Phase 1 musical fundamentals. + +This module does not measure audio. It summarizes how much authority the +existing local DSP output has for core musical facts so downstream code can +avoid presenting weak estimates as settled truth. +""" + +from __future__ import annotations + +from typing import Any + + +QUALITY_SCHEMA_VERSION = "fundamentals-quality.v1" +TARGET_PROFILE = "electronic_ableton_v1" + +STATUS_AUTHORITATIVE = "authoritative" +STATUS_AMBIGUOUS = "ambiguous" +STATUS_FAILED = "failed" +STATUS_NOT_RUN = "not_run" + + +def build_fundamentals_quality( + payload: dict[str, Any], + *, + analysis_mode: str = "full", +) -> dict[str, Any]: + """Build a compact local-authority summary for core musical fields.""" + domains = { + "tempo": _tempo_quality(payload), + "beatGrid": _beat_grid_quality(payload), + "downbeats": _downbeat_quality(payload), + "meter": _meter_quality(payload), + "key": _key_quality(payload), + "chords": _chord_quality(payload), + "percussion": _percussion_quality(payload), + "transcription": _transcription_quality(payload), + } + return { + "schemaVersion": QUALITY_SCHEMA_VERSION, + "targetProfile": TARGET_PROFILE, + "analysisMode": analysis_mode, + "localOnly": True, + "llmExcluded": True, + "overallStatus": _overall_status(domains), + "domains": domains, + } + + +def _domain( + *, + status: str, + plain: str, + source: str | None, + confidence: float | None, + evidence: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "status": status, + "plainEnglish": plain, + "source": source, + "confidence": _confidence(confidence), + "evidence": evidence or {}, + } + + +def _confidence(value: Any) -> float | None: + if isinstance(value, (int, float)): + numeric = float(value) + if numeric != numeric: + return None + return round(min(max(numeric, 0.0), 1.0), 3) + return None + + +def _status_from_confidence( + confidence: Any, + *, + high: float = 0.7, + present: bool = True, +) -> str: + if not present: + return STATUS_FAILED + score = _confidence(confidence) + if score is None: + return STATUS_AMBIGUOUS + return STATUS_AUTHORITATIVE if score >= high else STATUS_AMBIGUOUS + + +def _tempo_quality(payload: dict[str, Any]) -> dict[str, Any]: + bpm = payload.get("bpm") + confidence = payload.get("bpmConfidence") + agreement = payload.get("bpmAgreement") + source = payload.get("bpmSource") + if not isinstance(bpm, (int, float)): + return _domain( + status=STATUS_FAILED, + plain="Tempo was not measured locally.", + source=None, + confidence=None, + evidence={"bpm": bpm}, + ) + status = _status_from_confidence(confidence) + if agreement is False and payload.get("bpmDoubletime") is not True: + status = STATUS_AMBIGUOUS + return _domain( + status=status, + plain=( + "Tempo was measured locally." + if status == STATUS_AUTHORITATIVE + else "Tempo was measured locally, but the cross-check is not strong enough to treat it as settled." + ), + source=str(source) if source else "rhythm_extractor", + confidence=confidence, + evidence={ + "bpm": bpm, + "bpmPercival": payload.get("bpmPercival"), + "bpmAgreement": agreement, + "bpmDoubletime": payload.get("bpmDoubletime"), + "bpmRawOriginal": payload.get("bpmRawOriginal"), + }, + ) + + +def _beat_grid_quality(payload: dict[str, Any]) -> dict[str, Any]: + rhythm = payload.get("rhythmDetail") if isinstance(payload.get("rhythmDetail"), dict) else None + beat_grid = rhythm.get("beatGrid") if rhythm else None + if not isinstance(beat_grid, list) or len(beat_grid) < 2: + return _domain( + status=STATUS_NOT_RUN, + plain="Beat grid was not produced in this analysis mode.", + source=None, + confidence=None, + evidence={"beatCount": 0}, + ) + confidence = payload.get("bpmConfidence") + # Beat-grid authority reflects whether beats were reliably located, which the + # tempo detector itself treats as settled at raw confidence >= 2.0 + # (normalized 0.4 — see analyze_core.analyze_bpm). Reusing the tempo domain's + # stricter 0.7 here wrongly marked ordinary, well-tracked grids "ambiguous", + # forcing every beat-grid-citing Phase 2 recommendation to be hedged. + return _domain( + status=_status_from_confidence(confidence, high=0.4), + plain="Beat grid was derived locally from the measured tempo and beat tracker.", + source=str(rhythm.get("source") or "rhythm_extractor"), + confidence=confidence, + evidence={ + "beatCount": len(beat_grid), + "firstBeatSec": beat_grid[0], + "lastBeatSec": beat_grid[-1], + }, + ) + + +def _downbeat_quality(payload: dict[str, Any]) -> dict[str, Any]: + rhythm = payload.get("rhythmDetail") if isinstance(payload.get("rhythmDetail"), dict) else None + downbeats = rhythm.get("downbeats") if rhythm else None + confidence = rhythm.get("downbeatConfidence") if rhythm else None + source = rhythm.get("downbeatSource") if rhythm else None + if not isinstance(downbeats, list) or len(downbeats) == 0: + return _domain( + status=STATUS_NOT_RUN, + plain="Downbeats were not produced in this analysis mode.", + source=None, + confidence=None, + evidence={"downbeatCount": 0}, + ) + status = _status_from_confidence(confidence, high=0.6) + return _domain( + status=status, + plain=( + "Bar starts were located locally." + if status == STATUS_AUTHORITATIVE + else "The beat grid is usable, but exact bar starts are uncertain." + ), + source=str(source) if source else "rhythm_detail", + confidence=confidence, + evidence={"downbeatCount": len(downbeats)}, + ) + + +def _meter_quality(payload: dict[str, Any]) -> dict[str, Any]: + meter = payload.get("timeSignature") + source = payload.get("timeSignatureSource") + confidence = payload.get("timeSignatureConfidence") + if not isinstance(meter, str) or not meter: + return _domain( + status=STATUS_FAILED, + plain="Meter was not measured locally.", + source=None, + confidence=None, + evidence={"timeSignature": meter}, + ) + assumed = source == "assumed_four_four" or _confidence(confidence) == 0.0 + status = STATUS_AMBIGUOUS if assumed else _status_from_confidence(confidence, high=0.5) + return _domain( + status=status, + plain=( + "Meter was detected locally." + if status == STATUS_AUTHORITATIVE + else "Meter is a local working assumption, not a confirmed reading." + ), + source=str(source) if source else "time_signature", + confidence=confidence, + evidence={"timeSignature": meter}, + ) + + +def _key_quality(payload: dict[str, Any]) -> dict[str, Any]: + key = payload.get("key") + confidence = payload.get("keyConfidence") + if not isinstance(key, str) or not key: + return _domain( + status=STATUS_FAILED, + plain="Key was not measured locally.", + source=str(payload.get("keyProfile") or "edma"), + confidence=None, + evidence={"key": key}, + ) + status = _status_from_confidence(confidence, high=0.7) + return _domain( + status=status, + plain=( + "Key was measured locally." + if status == STATUS_AUTHORITATIVE + else "Key was estimated locally and should be checked by ear." + ), + source=str(payload.get("keyProfile") or "edma"), + confidence=confidence, + evidence={ + "key": key, + "tuningFrequency": payload.get("tuningFrequency"), + "tuningCents": payload.get("tuningCents"), + }, + ) + + +def _chord_quality(payload: dict[str, Any]) -> dict[str, Any]: + chord = payload.get("chordDetail") if isinstance(payload.get("chordDetail"), dict) else None + if chord is None: + return _domain( + status=STATUS_NOT_RUN, + plain="Chord analysis was not run in this analysis mode.", + source=None, + confidence=None, + evidence={}, + ) + confidence = chord.get("chordStrength") + agreement = chord.get("chordTimelineAgreement") + status = _status_from_confidence(confidence, high=0.7) + if agreement is False: + status = STATUS_AMBIGUOUS + return _domain( + status=status, + plain=( + "Chord labels were measured locally and agree across detectors." + if status == STATUS_AUTHORITATIVE + else "Chord labels are local estimates; detector disagreement or low strength means they should be treated carefully." + ), + source=str(chord.get("chordTimelineSource") or "essentia_chords"), + confidence=confidence, + evidence={ + "chordStrength": confidence, + "chordTimelineAgreement": agreement, + "chordChangeCount": chord.get("chordChangeCount"), + "dominantChords": chord.get("dominantChords"), + }, + ) + + +def _percussion_quality(payload: dict[str, Any]) -> dict[str, Any]: + kick = payload.get("kickDetail") if isinstance(payload.get("kickDetail"), dict) else None + snare = payload.get("snareDetail") if isinstance(payload.get("snareDetail"), dict) else None + hihat = payload.get("hihatDetail") if isinstance(payload.get("hihatDetail"), dict) else None + has_any = any(isinstance(item, dict) for item in (kick, snare, hihat)) + if not has_any: + return _domain( + status=STATUS_NOT_RUN, + plain="Percussion detail was not run in this analysis mode.", + source=None, + confidence=None, + evidence={}, + ) + kick_count = kick.get("kickCount") if kick else None + snare_count = snare.get("hitCount") if snare else None + hihat_count = hihat.get("hitCount") if hihat else None + confidence = 0.6 + if isinstance(kick_count, int) and kick_count > 0: + confidence = 0.7 + status = STATUS_AUTHORITATIVE if confidence >= 0.7 else STATUS_AMBIGUOUS + return _domain( + status=status, + plain=( + "Percussion events were measured locally." + if status == STATUS_AUTHORITATIVE + else "Percussion detail is local, but not all drum families have strong evidence." + ), + source="local_band_and_stem_detectors", + confidence=confidence, + evidence={ + "kickCount": kick_count, + "kickFundamentalHz": kick.get("fundamentalHz") if kick else None, + "snareHitCount": snare_count, + "hihatHitCount": hihat_count, + }, + ) + + +def _transcription_quality(payload: dict[str, Any]) -> dict[str, Any]: + detail = ( + payload.get("transcriptionDetail") + if isinstance(payload.get("transcriptionDetail"), dict) + else None + ) + if detail is None: + return _domain( + status=STATUS_NOT_RUN, + plain="Monophonic note transcription was not run.", + source=None, + confidence=None, + evidence={}, + ) + confidence = detail.get("averageConfidence") + full_mix = detail.get("fullMixFallback") is True + status = _status_from_confidence(confidence, high=0.75) + if full_mix and status == STATUS_AUTHORITATIVE: + status = STATUS_AMBIGUOUS + return _domain( + status=status, + plain=( + "Monophonic notes were translated locally from stem-aware pitch tracking." + if status == STATUS_AUTHORITATIVE + else "Note transcription is local, but it is approximate and should not be treated as a complete polyphonic score." + ), + source=str(detail.get("transcriptionMethod") or "torchcrepe"), + confidence=confidence, + evidence={ + "noteCount": detail.get("noteCount"), + "fullMixFallback": full_mix, + "perStemAverageConfidence": detail.get("perStemAverageConfidence"), + }, + ) + + +def _overall_status(domains: dict[str, dict[str, Any]]) -> str: + # Domains that were not run in this analysis mode carry no quality signal — + # a clean standard run never runs transcription, and fast mode skips several + # domains. Excluding them keeps a fully-measured run from being dragged to + # "ambiguous" by a domain that simply wasn't executed. + ran = [ + str(domain.get("status")) + for domain in domains.values() + if str(domain.get("status")) != STATUS_NOT_RUN + ] + if not ran: + return STATUS_NOT_RUN + if STATUS_FAILED in ran: + return STATUS_FAILED + if STATUS_AMBIGUOUS in ran: + return STATUS_AMBIGUOUS + return STATUS_AUTHORITATIVE diff --git a/apps/backend/scripts/evaluate_fundamentals.py b/apps/backend/scripts/evaluate_fundamentals.py new file mode 100644 index 00000000..f9681ed7 --- /dev/null +++ b/apps/backend/scripts/evaluate_fundamentals.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Run the local musical-fundamentals evaluation gate.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +BACKEND_DIR = Path(__file__).resolve().parent.parent +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +from fundamentals_evaluation import ( # noqa: E402 + DEFAULT_MANIFEST_PATH, + DEFAULT_REPORT_PATH, + DEFAULT_TRACKS_DIR, + run_fundamentals_evaluation, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run the local fundamentals benchmark. Missing local audio files are " + "reported as skips; present audio must pass the declared gates." + ) + ) + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST_PATH) + parser.add_argument("--tracks-dir", type=Path, default=DEFAULT_TRACKS_DIR) + parser.add_argument("--report", type=Path, default=DEFAULT_REPORT_PATH) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + report = run_fundamentals_evaluation( + manifest_path=args.manifest, + tracks_dir=args.tracks_dir, + report_path=args.report, + ) + summary: dict[str, Any] = { + "summary": report["summary"], + "reportPath": report["reportPath"], + } + if report["summary"]["tracksSkipped"] > 0: + summary["note"] = ( + "Some fundamentals tracks were skipped because local audio files " + f"were not present in {args.tracks_dir}." + ) + print(json.dumps(summary, indent=2)) + if not report["summary"]["allPassed"]: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/apps/backend/server_phase1.py b/apps/backend/server_phase1.py index 2a12164a..ce8e0007 100644 --- a/apps/backend/server_phase1.py +++ b/apps/backend/server_phase1.py @@ -185,6 +185,7 @@ def _build_phase1(payload: dict[str, Any]) -> dict[str, Any]: return { "phase1Version": _coerce_nullable_string(payload.get("phase1Version")), + "fundamentalsQuality": payload.get("fundamentalsQuality"), "bpm": _coerce_number(payload.get("bpm")), "bpmConfidence": _coerce_number(payload.get("bpmConfidence")), "bpmPercival": _coerce_nullable_number(payload.get("bpmPercival")), diff --git a/apps/backend/tests/fixtures/fundamentals_eval_manifest.json b/apps/backend/tests/fixtures/fundamentals_eval_manifest.json new file mode 100644 index 00000000..1bab7875 --- /dev/null +++ b/apps/backend/tests/fixtures/fundamentals_eval_manifest.json @@ -0,0 +1,94 @@ +{ + "schemaVersion": "fundamentals-eval.v1", + "targetProfile": "electronic_ableton_v1", + "gates": { + "clearTempoWithinBpm": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "keyExactOrRelativeRate": 0.8, + "chordSegmentAccuracy": 0.65, + "kickF1": 0.85, + "snareHihatF1": 0.75, + "transcriptionNoteF1": 0.75 + }, + "tracks": [ + { + "id": "four_on_floor_clear_128", + "audioPath": "four_on_floor_clear_128.wav", + "category": "tempo_beat_meter", + "description": "Owned or locally rendered four-on-the-floor electronic loop with clear 128 BPM grid.", + "expected": { + "bpm": 128, + "timeSignature": "4/4" + }, + "thresholds": { + "bpmTolerance": 1.0, + "requiredQuality": { + "tempo": "authoritative", + "beatGrid": "authoritative", + "meter": ["authoritative", "ambiguous"] + } + } + }, + { + "id": "tonal_minor_static_chords", + "audioPath": "tonal_minor_static_chords.wav", + "category": "key_chords", + "description": "Owned or locally rendered tonal electronic loop with stable minor-key harmony.", + "expected": { + "key": "A minor", + "chordTimeline": [ + { "startSec": 0.0, "endSec": 4.0, "label": "Am" }, + { "startSec": 4.0, "endSec": 8.0, "label": "F" } + ] + }, + "thresholds": { + "allowRelativeMajorMinor": true, + "chordSegmentAccuracy": 0.65, + "requiredQuality": { + "key": "authoritative", + "chords": ["authoritative", "ambiguous"] + } + } + }, + { + "id": "drum_stem_known_counts", + "audioPath": "drum_stem_known_counts.wav", + "category": "percussion", + "description": "MIDI-rendered drum loop with known kick, snare, and hi-hat counts.", + "expected": { + "percussion": { + "kickCount": 16, + "snareCount": 8, + "hihatCount": 32 + } + }, + "thresholds": { + "percussionCountTolerance": 1, + "requiredQuality": { + "percussion": ["authoritative", "ambiguous"] + } + } + }, + { + "id": "mono_bass_transcription", + "audioPath": "mono_bass_transcription.wav", + "category": "monophonic_transcription", + "description": "Monophonic bass or lead line with hand/MIDI-labeled notes.", + "analyzeFlags": ["--transcribe"], + "expected": { + "transcriptionNotes": [ + { "pitchMidi": 48, "onsetSeconds": 0.0, "durationSeconds": 0.5 }, + { "pitchMidi": 51, "onsetSeconds": 0.5, "durationSeconds": 0.5 }, + { "pitchMidi": 55, "onsetSeconds": 1.0, "durationSeconds": 0.5 } + ] + }, + "thresholds": { + "transcriptionNoteF1": 0.75, + "requiredQuality": { + "transcription": ["authoritative", "ambiguous"] + } + } + } + ] +} diff --git a/apps/backend/tests/fixtures/fundamentals_tracks/.gitignore b/apps/backend/tests/fixtures/fundamentals_tracks/.gitignore new file mode 100644 index 00000000..9599227b --- /dev/null +++ b/apps/backend/tests/fixtures/fundamentals_tracks/.gitignore @@ -0,0 +1,4 @@ +# Local fundamentals benchmark audio is never committed. +* +!.gitignore +!README.md diff --git a/apps/backend/tests/fixtures/fundamentals_tracks/README.md b/apps/backend/tests/fixtures/fundamentals_tracks/README.md new file mode 100644 index 00000000..5d597ba3 --- /dev/null +++ b/apps/backend/tests/fixtures/fundamentals_tracks/README.md @@ -0,0 +1,9 @@ +# Fundamentals Benchmark Audio + +Put owned, licensed, or locally rendered reference audio here to activate +`scripts/evaluate_fundamentals.py`. + +The manifest is committed at `../fundamentals_eval_manifest.json`; audio files +in this directory are gitignored. When a listed file is absent, the harness +reports a skip. When it is present, the declared tempo, beat, meter, key, +chord, percussion, and transcription gates must pass. diff --git a/apps/backend/tests/fixtures/golden/phase1_default.json b/apps/backend/tests/fixtures/golden/phase1_default.json index e385bcfe..aa57a9ac 100644 --- a/apps/backend/tests/fixtures/golden/phase1_default.json +++ b/apps/backend/tests/fixtures/golden/phase1_default.json @@ -8,7 +8,7 @@ "lufsIntegrated": -5.6, "lufsMomentaryMax": -4.8, "lufsRange": 0.8, - "lufsShortTermMax": -5.2, + "lufsShortTermMax": -5.1, "monoCompatible": true, "plr": 5.6, "sampleRate": 44100, @@ -116,6 +116,100 @@ "spectralComplexity": "number", "zeroCrossingRate": "number" }, + "fundamentalsQuality": { + "analysisMode": "str", + "domains": { + "beatGrid": { + "confidence": "number", + "evidence": { + "beatCount": "number", + "firstBeatSec": "number", + "lastBeatSec": "number" + }, + "plainEnglish": "str", + "source": "str", + "status": "str" + }, + "chords": { + "confidence": "number", + "evidence": { + "chordChangeCount": "number", + "chordStrength": "number", + "chordTimelineAgreement": "bool", + "dominantChords": "scalarList" + }, + "plainEnglish": "str", + "source": "str", + "status": "str" + }, + "downbeats": { + "confidence": "number", + "evidence": { + "downbeatCount": "number" + }, + "plainEnglish": "str", + "source": "str", + "status": "str" + }, + "key": { + "confidence": "number", + "evidence": { + "key": "str", + "tuningCents": "number", + "tuningFrequency": "number" + }, + "plainEnglish": "str", + "source": "str", + "status": "str" + }, + "meter": { + "confidence": "number", + "evidence": { + "timeSignature": "str" + }, + "plainEnglish": "str", + "source": "str", + "status": "str" + }, + "percussion": { + "confidence": "number", + "evidence": { + "hihatHitCount": "null", + "kickCount": "number", + "kickFundamentalHz": "number", + "snareHitCount": "number" + }, + "plainEnglish": "str", + "source": "str", + "status": "str" + }, + "tempo": { + "confidence": "number", + "evidence": { + "bpm": "number", + "bpmAgreement": "bool", + "bpmDoubletime": "bool", + "bpmPercival": "number", + "bpmRawOriginal": "number" + }, + "plainEnglish": "str", + "source": "str", + "status": "str" + }, + "transcription": { + "confidence": "null", + "evidence": {}, + "plainEnglish": "str", + "source": "null", + "status": "str" + } + }, + "llmExcluded": "bool", + "localOnly": "bool", + "overallStatus": "str", + "schemaVersion": "str", + "targetProfile": "str" + }, "genreDetail": { "confidence": "number", "genre": "str", @@ -473,6 +567,7 @@ "dynamicSpread", "effectsDetail", "essentiaFeatures", + "fundamentalsQuality", "genreDetail", "grooveDetail", "hihatDetail", @@ -541,6 +636,7 @@ "dynamicSpread": "number", "effectsDetail": "dict", "essentiaFeatures": "dict", + "fundamentalsQuality": "dict", "genreDetail": "dict", "grooveDetail": "dict", "hihatDetail": "null", diff --git a/apps/backend/tests/test_analyze.py b/apps/backend/tests/test_analyze.py index 3fdc8648..877373f6 100644 --- a/apps/backend/tests/test_analyze.py +++ b/apps/backend/tests/test_analyze.py @@ -38,6 +38,7 @@ # "absent when off" contract. See JSON_SCHEMA.md "Optional MT3 Namespace". EXPECTED_TOP_LEVEL_KEYS = { "phase1Version", + "fundamentalsQuality", "bpm", "bpmConfidence", "bpmPercival", "bpmAgreement", "bpmDoubletime", "bpmSource", "bpmRawOriginal", "key", "keyConfidence", "timeSignature", "timeSignatureSource", @@ -59,6 +60,7 @@ # Fields fast mode populates with real values. FAST_MODE_POPULATED_FIELDS = { "phase1Version", + "fundamentalsQuality", "bpm", "bpmConfidence", "bpmPercival", "bpmAgreement", "bpmDoubletime", "bpmSource", "bpmRawOriginal", "key", "keyConfidence", "timeSignature", "timeSignatureSource", @@ -258,6 +260,36 @@ def test_core_fields_are_present_with_plausible_types(self) -> None: self.assertGreater(self.payload["sampleRate"], 0) self.assertIsInstance(self.payload["lufsIntegrated"], (int, float)) self.assertTrue(np.isfinite(self.payload["lufsIntegrated"])) + + def test_fundamentals_quality_declares_local_authority(self) -> None: + quality = self.payload.get("fundamentalsQuality") + self.assertIsInstance(quality, dict) + self.assertEqual(quality["schemaVersion"], "fundamentals-quality.v1") + self.assertEqual(quality["targetProfile"], "electronic_ableton_v1") + self.assertEqual(quality["analysisMode"], "full") + self.assertTrue(quality["localOnly"]) + self.assertTrue(quality["llmExcluded"]) + domains = quality["domains"] + for domain in ( + "tempo", + "beatGrid", + "downbeats", + "meter", + "key", + "chords", + "percussion", + "transcription", + ): + with self.subTest(domain=domain): + self.assertIn(domain, domains) + self.assertIn(domains[domain]["status"], { + "authoritative", + "ambiguous", + "failed", + "not_run", + }) + self.assertIsInstance(domains[domain]["plainEnglish"], str) + self.assertTrue(domains[domain]["plainEnglish"].strip()) self.assertIsInstance(self.payload["truePeak"], (int, float)) self.assertTrue(np.isfinite(self.payload["truePeak"])) @@ -403,8 +435,8 @@ def test_core_fields_are_populated(self) -> None: self.assertTrue(self.payload["key"].strip(), "key should be a non-empty string") self.assertIsInstance(self.payload["timeSignature"], str) self.assertTrue(self.payload["timeSignature"].strip(), "timeSignature should be non-empty") - self.assertEqual(self.payload["timeSignatureSource"], "assumed_four_four") - self.assertEqual(self.payload["timeSignatureConfidence"], 0.0) + self.assertIsInstance(self.payload["fundamentalsQuality"], dict) + self.assertEqual(self.payload["fundamentalsQuality"]["analysisMode"], "fast") def test_duration_matches_fixture(self) -> None: """Regression: audio must actually be loaded and measured correctly in fast mode.""" diff --git a/apps/backend/tests/test_audio_fixture.py b/apps/backend/tests/test_audio_fixture.py index d8b5f71a..05d28056 100644 --- a/apps/backend/tests/test_audio_fixture.py +++ b/apps/backend/tests/test_audio_fixture.py @@ -11,6 +11,7 @@ EXPECTED_TOP_LEVEL_KEYS = { "phase1Version", + "fundamentalsQuality", "bpm", "bpmConfidence", "bpmPercival", "bpmAgreement", "bpmDoubletime", "bpmSource", "bpmRawOriginal", "key", "keyConfidence", "keyProfile", "tuningFrequency", "tuningCents", @@ -316,6 +317,13 @@ def test_remaining_detail_fields_populated(self) -> None: def test_chord_detail_present(self) -> None: self.assertIn("chordDetail", self.result) + def test_fundamentals_quality_present(self) -> None: + quality = self.result["fundamentalsQuality"] + self.assertEqual(quality["schemaVersion"], "fundamentals-quality.v1") + self.assertTrue(quality["localOnly"]) + self.assertTrue(quality["llmExcluded"]) + self.assertIn("tempo", quality["domains"]) + # -- Tier 6: Expected nulls -- def test_transcription_detail_null(self) -> None: diff --git a/apps/backend/tests/test_fundamentals_evaluation.py b/apps/backend/tests/test_fundamentals_evaluation.py new file mode 100644 index 00000000..cef34f97 --- /dev/null +++ b/apps/backend/tests/test_fundamentals_evaluation.py @@ -0,0 +1,150 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from fundamentals_evaluation import ( + DEFAULT_MANIFEST_PATH, + run_fundamentals_evaluation, +) + + +class FundamentalsEvaluationTests(unittest.TestCase): + def test_default_manifest_skips_missing_local_audio(self) -> None: + with tempfile.TemporaryDirectory(prefix="asa_fundamentals_eval_") as temp_dir: + report_path = Path(temp_dir) / "fundamentals_report.json" + tracks_dir = Path(temp_dir) / "empty_tracks" + tracks_dir.mkdir() + + report = run_fundamentals_evaluation( + manifest_path=DEFAULT_MANIFEST_PATH, + tracks_dir=tracks_dir, + report_path=report_path, + ) + + self.assertTrue(report["summary"]["allPassed"]) + self.assertEqual(report["summary"]["tracksEvaluated"], 0) + self.assertGreaterEqual(report["summary"]["tracksSkipped"], 1) + self.assertTrue(report_path.exists()) + + def test_present_track_enforces_declared_fundamental_gates(self) -> None: + with tempfile.TemporaryDirectory(prefix="asa_fundamentals_eval_present_") as temp_dir: + temp_root = Path(temp_dir) + tracks_dir = temp_root / "tracks" + tracks_dir.mkdir() + audio_path = tracks_dir / "loop.wav" + audio_path.write_bytes(b"placeholder") + manifest_path = temp_root / "manifest.json" + manifest_path.write_text( + json.dumps( + { + "schemaVersion": "fundamentals-eval.v1", + "targetProfile": "electronic_ableton_v1", + "tracks": [ + { + "id": "loop", + "audioPath": "loop.wav", + "category": "unit", + "description": "unit test", + "expected": { + "bpm": 128, + "key": "A minor", + "timeSignature": "4/4", + "beatGrid": [0.0, 0.46875, 0.9375], + "downbeats": [0.0], + "percussion": {"kickCount": 3}, + "transcriptionNotes": [ + {"pitchMidi": 48, "onsetSeconds": 0.0}, + ], + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "percussionCountTolerance": 0, + "transcriptionNoteF1": 0.75, + "requiredQuality": { + "tempo": "authoritative", + "key": "authoritative", + }, + }, + } + ], + } + ), + encoding="utf-8", + ) + + def runner(path: Path, flags: list[str] | None) -> dict: + self.assertEqual(path, audio_path.resolve()) + self.assertIsNone(flags) + return { + "bpm": 128.2, + "key": "A minor", + "timeSignature": "4/4", + "rhythmDetail": { + "beatGrid": [0.0, 0.469, 0.938], + "downbeats": [0.02], + }, + "kickDetail": {"kickCount": 3}, + "transcriptionDetail": { + "notes": [{"pitchMidi": 48, "onsetSeconds": 0.01}], + }, + "fundamentalsQuality": { + "domains": { + "tempo": {"status": "authoritative"}, + "key": {"status": "authoritative"}, + } + }, + } + + report = run_fundamentals_evaluation( + manifest_path=manifest_path, + tracks_dir=tracks_dir, + report_path=temp_root / "report.json", + runner=runner, + ) + + self.assertTrue(report["summary"]["allPassed"]) + self.assertEqual(report["summary"]["tracksEvaluated"], 1) + checks = report["tracks"][0]["checks"] + self.assertTrue(all(check["passed"] for check in checks)) + self.assertIn("tempo:bpm", {check["name"] for check in checks}) + self.assertIn("transcription:noteF1", {check["name"] for check in checks}) + + def test_present_track_failure_fails_report(self) -> None: + with tempfile.TemporaryDirectory(prefix="asa_fundamentals_eval_fail_") as temp_dir: + temp_root = Path(temp_dir) + tracks_dir = temp_root / "tracks" + tracks_dir.mkdir() + (tracks_dir / "loop.wav").write_bytes(b"placeholder") + manifest_path = temp_root / "manifest.json" + manifest_path.write_text( + json.dumps( + { + "tracks": [ + { + "id": "loop", + "audioPath": "loop.wav", + "expected": {"bpm": 128}, + "thresholds": {"bpmTolerance": 1.0}, + } + ] + } + ), + encoding="utf-8", + ) + + report = run_fundamentals_evaluation( + manifest_path=manifest_path, + tracks_dir=tracks_dir, + report_path=temp_root / "report.json", + runner=lambda _path, _flags: {"bpm": 132}, + ) + + self.assertFalse(report["summary"]["allPassed"]) + self.assertEqual(report["summary"]["checksFailed"], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/backend/tests/test_fundamentals_quality.py b/apps/backend/tests/test_fundamentals_quality.py new file mode 100644 index 00000000..52674c6a --- /dev/null +++ b/apps/backend/tests/test_fundamentals_quality.py @@ -0,0 +1,143 @@ +import unittest + +from fundamentals_quality import build_fundamentals_quality + + +class FundamentalsQualityTests(unittest.TestCase): + def test_marks_strong_local_measurements_as_authoritative(self) -> None: + payload = { + "bpm": 128.0, + "bpmConfidence": 0.92, + "bpmPercival": 128.1, + "bpmAgreement": True, + "bpmDoubletime": False, + "bpmSource": "rhythm_extractor_confirmed", + "key": "A Minor", + "keyConfidence": 0.82, + "keyProfile": "edma", + "timeSignature": "4/4", + "timeSignatureSource": "onset_autocorrelation", + "timeSignatureConfidence": 0.71, + "rhythmDetail": { + "beatGrid": [0.0, 0.46875, 0.9375, 1.40625], + "downbeats": [0.0], + "downbeatConfidence": 0.7, + "downbeatSource": "kick_accent", + }, + "chordDetail": { + "chordStrength": 0.78, + "chordTimelineAgreement": True, + "chordTimelineSource": "librosa_viterbi", + "chordChangeCount": 2, + }, + "kickDetail": {"kickCount": 16, "fundamentalHz": 55.0}, + "snareDetail": {"hitCount": 8}, + "hihatDetail": {"hitCount": 32}, + "transcriptionDetail": { + "averageConfidence": 0.81, + "fullMixFallback": False, + "noteCount": 12, + "transcriptionMethod": "torchcrepe-viterbi", + }, + } + + quality = build_fundamentals_quality(payload, analysis_mode="full") + + self.assertTrue(quality["localOnly"]) + self.assertTrue(quality["llmExcluded"]) + self.assertEqual(quality["domains"]["tempo"]["status"], "authoritative") + self.assertEqual(quality["domains"]["key"]["status"], "authoritative") + self.assertEqual(quality["domains"]["meter"]["status"], "authoritative") + self.assertEqual(quality["domains"]["chords"]["status"], "authoritative") + self.assertEqual(quality["domains"]["percussion"]["status"], "authoritative") + self.assertEqual(quality["domains"]["transcription"]["status"], "authoritative") + + def test_marks_assumed_meter_and_chord_disagreement_as_ambiguous(self) -> None: + payload = { + "bpm": 126.0, + "bpmConfidence": 0.86, + "bpmAgreement": True, + "key": "C Minor", + "keyConfidence": 0.3, + "timeSignature": "4/4", + "timeSignatureSource": "assumed_four_four", + "timeSignatureConfidence": 0.0, + "chordDetail": { + "chordStrength": 0.8, + "chordTimelineAgreement": False, + }, + "transcriptionDetail": { + "averageConfidence": 0.9, + "fullMixFallback": True, + "noteCount": 8, + }, + } + + quality = build_fundamentals_quality(payload) + + self.assertEqual(quality["overallStatus"], "ambiguous") + self.assertEqual(quality["domains"]["meter"]["status"], "ambiguous") + self.assertEqual(quality["domains"]["key"]["status"], "ambiguous") + self.assertEqual(quality["domains"]["chords"]["status"], "ambiguous") + self.assertEqual(quality["domains"]["transcription"]["status"], "ambiguous") + self.assertIn("working assumption", quality["domains"]["meter"]["plainEnglish"]) + self.assertIn("detector disagreement", quality["domains"]["chords"]["plainEnglish"]) + + def test_beat_grid_is_authoritative_at_mid_tempo_confidence(self) -> None: + # A well-tracked grid on a track whose tempo cross-check is only middling + # (0.5) must not be marked "ambiguous" — that wrongly forced every + # beat-grid-citing recommendation to be hedged. Below the detector's own + # ambiguity floor (0.4), the grid is genuinely uncertain. + base = { + "bpm": 128.0, + "timeSignature": "4/4", + "rhythmDetail": {"beatGrid": [0.0, 0.47, 0.94, 1.41]}, + } + + mid = build_fundamentals_quality({**base, "bpmConfidence": 0.5}) + self.assertEqual(mid["domains"]["beatGrid"]["status"], "authoritative") + + low = build_fundamentals_quality({**base, "bpmConfidence": 0.3}) + self.assertEqual(low["domains"]["beatGrid"]["status"], "ambiguous") + + def test_overall_status_ignores_not_run_domains(self) -> None: + # A clean standard run never runs transcription, so that domain is + # not_run. A single not_run domain must not drag the overall status + # down to "ambiguous" when everything actually measured is solid. + payload = { + "bpm": 128.0, + "bpmConfidence": 0.92, + "bpmAgreement": True, + "key": "A Minor", + "keyConfidence": 0.82, + "timeSignature": "4/4", + "timeSignatureSource": "onset_autocorrelation", + "timeSignatureConfidence": 0.71, + "rhythmDetail": { + "beatGrid": [0.0, 0.47, 0.94, 1.41], + "downbeats": [0.0, 1.88], + "downbeatConfidence": 0.7, + }, + "chordDetail": {"chordStrength": 0.78, "chordTimelineAgreement": True}, + "kickDetail": {"kickCount": 16, "fundamentalHz": 55.0}, + "snareDetail": {"hitCount": 8}, + "hihatDetail": {"hitCount": 32}, + } + + quality = build_fundamentals_quality(payload) + + self.assertEqual(quality["domains"]["transcription"]["status"], "not_run") + self.assertEqual(quality["overallStatus"], "authoritative") + + def test_marks_missing_mandatory_fundamentals_as_failed(self) -> None: + quality = build_fundamentals_quality({}) + + self.assertEqual(quality["overallStatus"], "failed") + self.assertEqual(quality["domains"]["tempo"]["status"], "failed") + self.assertEqual(quality["domains"]["key"]["status"], "failed") + self.assertEqual(quality["domains"]["meter"]["status"], "failed") + self.assertEqual(quality["domains"]["beatGrid"]["status"], "not_run") + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/ui/src/components/HarmonyLanes.tsx b/apps/ui/src/components/HarmonyLanes.tsx index 61b14109..65b60c34 100644 --- a/apps/ui/src/components/HarmonyLanes.tsx +++ b/apps/ui/src/components/HarmonyLanes.tsx @@ -10,8 +10,23 @@ interface HarmonyLanesProps { const formatNum = (v: number | null | undefined, d = 2): string => typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—'; +function qualityLabel(status: string | null | undefined): string { + if (status === 'authoritative') return 'Local'; + if (status === 'failed') return 'Failed'; + if (status === 'ambiguous') return 'Check'; + return 'Not run'; +} + +function qualityColor(status: string | null | undefined): string { + if (status === 'authoritative') return '#22c55e'; + if (status === 'failed') return '#ef4444'; + if (status === 'ambiguous') return '#f59e0b'; + return '#777'; +} + export function HarmonyLanes({ phase1 }: HarmonyLanesProps) { const chord = phase1.chordDetail; + const chordQuality = phase1.fundamentalsQuality?.domains.chords; const keyStr = phase1.key ?? null; const hasChord = chord && ((chord.progression && chord.progression.length > 0) || @@ -43,6 +58,11 @@ export function HarmonyLanes({ phase1 }: HarmonyLanesProps) { const statsItems = [ ...(keyStr ? [{ label: 'Key', value: keyStr }] : []), { label: 'Strength', value: strengthPct, color: '#ff8800' }, + ...(chordQuality ? [{ + label: 'Quality', + value: qualityLabel(chordQuality.status), + color: qualityColor(chordQuality.status), + }] : []), { label: 'Chords', value: String(uniqueCount || '—') }, ]; @@ -51,6 +71,16 @@ export function HarmonyLanes({ phase1 }: HarmonyLanesProps) { {/* Header stats */} + {chordQuality && chordQuality.status !== 'authoritative' && ( + +
+ + {chordQuality.plainEnglish} + +
+
+ )} + {/* Chord Palette lane */} {palette.length > 0 && ( diff --git a/apps/ui/src/components/analysisResults/MeasurementSummarySection.tsx b/apps/ui/src/components/analysisResults/MeasurementSummarySection.tsx index 18444072..8789f64c 100644 --- a/apps/ui/src/components/analysisResults/MeasurementSummarySection.tsx +++ b/apps/ui/src/components/analysisResults/MeasurementSummarySection.tsx @@ -32,6 +32,32 @@ function meterStatusLabel(phase1: Phase1Result): string { return isAssumedMeter(phase1) ? 'ASSUMED' : 'DETECTED'; } +type PillTone = React.ComponentProps['tone']; + +function fundamentalsTone(status: string | null | undefined): PillTone { + if (status === 'authoritative') return 'success'; + if (status === 'failed') return 'error'; + if (status === 'ambiguous') return 'warning'; + return 'neutral'; +} + +function fundamentalsLabel(status: string | null | undefined): string { + if (status === 'authoritative') return 'LOCAL'; + if (status === 'failed') return 'FAILED'; + if (status === 'ambiguous') return 'CHECK'; + return 'NOT RUN'; +} + +const FUNDAMENTALS_PILLS = [ + ['Tempo', 'tempo'], + ['Beat', 'beatGrid'], + ['Meter', 'meter'], + ['Key', 'key'], + ['Chords', 'chords'], + ['Drums', 'percussion'], + ['Notes', 'transcription'], +] as const; + export function MeasurementSummarySection({ phase1, finalBpm, @@ -170,6 +196,37 @@ export function MeasurementSummarySection({ /> )} + {phase1.fundamentalsQuality && ( +
+ + Local fundamentals + + + Overall {fundamentalsLabel(phase1.fundamentalsQuality.overallStatus)} + + {FUNDAMENTALS_PILLS.map(([label, domainKey]) => { + const domain = phase1.fundamentalsQuality?.domains[domainKey]; + if (!domain) return null; + return ( + + {label} {fundamentalsLabel(domain.status)} + + ); + })} +
+ )} ); } diff --git a/apps/ui/src/services/backendPhase1Client.ts b/apps/ui/src/services/backendPhase1Client.ts index f796a7e5..e7f4e25a 100644 --- a/apps/ui/src/services/backendPhase1Client.ts +++ b/apps/ui/src/services/backendPhase1Client.ts @@ -11,6 +11,8 @@ import { ChordTimelineEntry, DanceabilityResult, DynamicCharacter, + FundamentalsQuality, + FundamentalsQualityStatus, GenreDetail, KickDetail, Phase1Result, @@ -577,6 +579,7 @@ export function parsePhase1Result(value: unknown): Phase1Result { return { phase1Version: toOptionalStringOrNull(phase1.phase1Version), + fundamentalsQuality: parseOptionalFundamentalsQuality(phase1.fundamentalsQuality), bpm: expectNumber(phase1, "bpm"), bpmConfidence: expectNumber(phase1, "bpmConfidence"), bpmPercival: toNumber(phase1.bpmPercival), @@ -669,6 +672,51 @@ export function parsePhase1Result(value: unknown): Phase1Result { }; } +const FUNDAMENTALS_QUALITY_STATUSES = new Set([ + "authoritative", + "ambiguous", + "failed", + "not_run", +]); + +function parseOptionalFundamentalsQuality(value: unknown): FundamentalsQuality | null { + if (value === undefined || value === null) return null; + if (!isRecord(value)) return null; + + const rawDomains = isRecord(value.domains) ? value.domains : {}; + const domains: FundamentalsQuality["domains"] = {}; + for (const [name, rawDomain] of Object.entries(rawDomains)) { + if (!isRecord(rawDomain)) continue; + const status = parseFundamentalsQualityStatus(rawDomain.status); + const plainEnglish = typeof rawDomain.plainEnglish === "string" + ? rawDomain.plainEnglish + : ""; + domains[name] = { + status, + plainEnglish, + source: toOptionalStringOrNull(rawDomain.source), + confidence: toNumber(rawDomain.confidence), + evidence: isRecord(rawDomain.evidence) ? { ...rawDomain.evidence } : {}, + }; + } + + return { + schemaVersion: "fundamentals-quality.v1", + targetProfile: "electronic_ableton_v1", + analysisMode: typeof value.analysisMode === "string" ? value.analysisMode : "unknown", + localOnly: value.localOnly === true, + llmExcluded: value.llmExcluded === true, + overallStatus: parseFundamentalsQualityStatus(value.overallStatus), + domains, + }; +} + +function parseFundamentalsQualityStatus(value: unknown): FundamentalsQualityStatus { + return typeof value === "string" && FUNDAMENTALS_QUALITY_STATUSES.has(value as FundamentalsQualityStatus) + ? value as FundamentalsQualityStatus + : "ambiguous"; +} + function roundToTwoDecimals(value: number): number { if (!Number.isFinite(value)) return value; return Math.round(value * 100) / 100; diff --git a/apps/ui/src/services/phase2Validator.ts b/apps/ui/src/services/phase2Validator.ts index 82a9bd47..28dda3d0 100644 --- a/apps/ui/src/services/phase2Validator.ts +++ b/apps/ui/src/services/phase2Validator.ts @@ -12,6 +12,7 @@ export type ValidationViolationType = | 'TRIVIAL_CITATIONS' | 'NEW_FIELD_UNCITED' | 'LOW_CONFIDENCE_NOT_HEDGED' + | 'FUNDAMENTAL_AUTHORITY_VIOLATION' | 'RECOMMENDATION_SALVAGED' | 'MISSING_LOUDNESS_ACTION'; @@ -232,6 +233,9 @@ export function validatePhase2Consistency( violations.push(...validateKeyConsistency(phase1, phase2)); checkedFields++; + violations.push(...validateFundamentalAuthority(phase1, phase2)); + checkedFields++; + violations.push(...validateLUFSConsistency(phase1, phase2)); checkedFields++; @@ -752,6 +756,156 @@ function normalizeKey(key: string): string { .trim(); } +function normalizeMeter(value: string): string { + return value.trim().replace(/\s+/g, '').toLowerCase(); +} + +const FUNDAMENTAL_CITATION_DOMAINS: Array<{ domain: string; prefixes: string[] }> = [ + { domain: 'tempo', prefixes: ['bpm', 'bpmConfidence', 'bpmPercival', 'bpmAgreement', 'bpmSource'] }, + { domain: 'beatGrid', prefixes: ['rhythmDetail.beatGrid', 'rhythmDetail.beatPositions', 'rhythmDetail.tempoCurve'] }, + { domain: 'downbeats', prefixes: ['rhythmDetail.downbeats', 'rhythmDetail.downbeatConfidence'] }, + { domain: 'meter', prefixes: ['timeSignature', 'timeSignatureSource', 'timeSignatureConfidence'] }, + { domain: 'key', prefixes: ['key', 'keyConfidence', 'keyProfile', 'segmentKey', 'tuningFrequency', 'tuningCents'] }, + { domain: 'chords', prefixes: ['chordDetail'] }, + { domain: 'percussion', prefixes: ['kickDetail', 'snareDetail', 'hihatDetail', 'beatsLoudness', 'rhythmTimeline'] }, + { domain: 'transcription', prefixes: ['transcriptionDetail', 'pitchDetail', 'melodyDetail'] }, +]; + +function domainForCitation(cited: string): string | null { + const normalized = cited.trim(); + for (const entry of FUNDAMENTAL_CITATION_DOMAINS) { + if (entry.prefixes.some(prefix => normalized === prefix || normalized.startsWith(`${prefix}.`))) { + return entry.domain; + } + } + return null; +} + +function validateFundamentalAuthority( + phase1: Phase1Result, + phase2: Phase2Result, +): ValidationViolation[] { + const violations: ValidationViolation[] = []; + + if (phase2.projectSetup?.tempoBpm !== undefined) { + const diff = Math.abs(phase2.projectSetup.tempoBpm - phase1.bpm); + if (diff > BPM_TOLERANCE) { + violations.push({ + type: 'FUNDAMENTAL_AUTHORITY_VIOLATION', + field: 'projectSetup.tempoBpm', + phase1Value: phase1.bpm, + phase2Value: phase2.projectSetup.tempoBpm, + severity: 'ERROR', + message: + `Phase 2 projectSetup.tempoBpm (${phase2.projectSetup.tempoBpm}) contradicts ` + + `the local Phase 1 BPM (${phase1.bpm}). Phase 2 may explain the measured tempo, ` + + 'but it must not supply a replacement tempo.', + }); + } + } + + if (phase2.projectSetup?.timeSignature !== undefined) { + const phase2Meter = normalizeMeter(phase2.projectSetup.timeSignature); + const phase1Meter = normalizeMeter(phase1.timeSignature); + if (phase2Meter !== phase1Meter) { + violations.push({ + type: 'FUNDAMENTAL_AUTHORITY_VIOLATION', + field: 'projectSetup.timeSignature', + phase1Value: phase1.timeSignature, + phase2Value: phase2.projectSetup.timeSignature, + severity: 'ERROR', + message: + `Phase 2 projectSetup.timeSignature (${phase2.projectSetup.timeSignature}) contradicts ` + + `the local Phase 1 meter (${phase1.timeSignature}). Phase 2 must not replace ` + + 'meter with an inferred value.', + }); + } + } + + const authoritative = phase2.styleProfile?.authoritativeMeasurements; + if (authoritative?.bpm !== undefined && authoritative.bpm !== null) { + const diff = Math.abs(authoritative.bpm - phase1.bpm); + if (diff > BPM_TOLERANCE) { + violations.push({ + type: 'FUNDAMENTAL_AUTHORITY_VIOLATION', + field: 'styleProfile.authoritativeMeasurements.bpm', + phase1Value: phase1.bpm, + phase2Value: authoritative.bpm, + severity: 'ERROR', + message: + `Phase 2 styleProfile.authoritativeMeasurements.bpm (${authoritative.bpm}) ` + + `contradicts the local Phase 1 BPM (${phase1.bpm}).`, + }); + } + } + if (authoritative?.key !== undefined && authoritative.key !== null && phase1.key !== null) { + if (normalizeKey(authoritative.key) !== normalizeKey(phase1.key)) { + violations.push({ + type: 'FUNDAMENTAL_AUTHORITY_VIOLATION', + field: 'styleProfile.authoritativeMeasurements.key', + phase1Value: phase1.key, + phase2Value: authoritative.key, + severity: 'ERROR', + message: + `Phase 2 styleProfile.authoritativeMeasurements.key (${authoritative.key}) ` + + `contradicts the local Phase 1 key (${phase1.key}).`, + }); + } + } + if (authoritative?.timeSignature !== undefined && authoritative.timeSignature !== null) { + if (normalizeMeter(authoritative.timeSignature) !== normalizeMeter(phase1.timeSignature)) { + violations.push({ + type: 'FUNDAMENTAL_AUTHORITY_VIOLATION', + field: 'styleProfile.authoritativeMeasurements.timeSignature', + phase1Value: phase1.timeSignature, + phase2Value: authoritative.timeSignature, + severity: 'ERROR', + message: + `Phase 2 styleProfile.authoritativeMeasurements.timeSignature ` + + `(${authoritative.timeSignature}) contradicts the local Phase 1 meter ` + + `(${phase1.timeSignature}).`, + }); + } + } + + const qualityDomains = phase1.fundamentalsQuality?.domains; + if (!qualityDomains) { + return violations; + } + + for (const rec of collectCitationRecs(phase2)) { + for (const cited of rec.phase1Fields) { + const domainName = domainForCitation(cited); + if (!domainName) continue; + const domain = qualityDomains[domainName]; + if (!domain || domain.status === 'authoritative') continue; + const field = `${rec.bucket}[${rec.index}].phase1Fields`; + if (domain.status === 'ambiguous') { + const hedge = containsAny(rec.reasonText, HEDGE_WORDS); + if (hedge.matched) continue; + } + violations.push({ + type: 'FUNDAMENTAL_AUTHORITY_VIOLATION', + field, + phase1Value: { + cited, + domain: domainName, + status: domain.status, + plainEnglish: domain.plainEnglish, + }, + severity: 'ERROR', + message: + `Recommendation at ${rec.bucket}[${rec.index}] cites ${cited}, but Phase 1 marks ` + + `${domainName} as ${domain.status}. Phase 2 can use this only with clear ` + + 'uncertainty language; it must not turn an ambiguous or missing local measurement ' + + 'into a confident musical fact.', + }); + } + } + + return violations; +} + /** * Validates LUFS consistency between Phase 1 and Phase 2. */ diff --git a/apps/ui/src/types/measurement.ts b/apps/ui/src/types/measurement.ts index 87cf5ecd..afa160d5 100644 --- a/apps/ui/src/types/measurement.ts +++ b/apps/ui/src/types/measurement.ts @@ -627,9 +627,31 @@ export interface GenreDetail { topScores: Array<{ genre: string; score: number }>; } +export type FundamentalsQualityStatus = "authoritative" | "ambiguous" | "failed" | "not_run"; + +export interface FundamentalsQualityDomain { + status: FundamentalsQualityStatus; + plainEnglish: string; + source: string | null; + confidence: number | null; + evidence: Record; +} + +export interface FundamentalsQuality { + schemaVersion: "fundamentals-quality.v1"; + targetProfile: "electronic_ableton_v1"; + analysisMode: string; + localOnly: boolean; + llmExcluded: boolean; + overallStatus: FundamentalsQualityStatus; + domains: Record; +} + export interface Phase1Result { /** Phase 1 schema version, e.g. "phase1.v2". Absent on pre-v2 snapshots. */ phase1Version?: string | null; + /** Local-only trust summary for musical fundamentals. LLMs must not write this. */ + fundamentalsQuality?: FundamentalsQuality | null; bpm: number; /** Tempo confidence, normalized 0-1 (Phase 1 v2; raw Essentia ~0-5.32 in v1). */ bpmConfidence: number; diff --git a/apps/ui/tests/fixtures/phase1FullPayload.ts b/apps/ui/tests/fixtures/phase1FullPayload.ts index 56c8a885..206276f8 100644 --- a/apps/ui/tests/fixtures/phase1FullPayload.ts +++ b/apps/ui/tests/fixtures/phase1FullPayload.ts @@ -47,6 +47,92 @@ const transientBand = ( export const phase1EnvelopeFixture = { phase1Version: 'phase1.v2', + fundamentalsQuality: { + schemaVersion: 'fundamentals-quality.v1', + targetProfile: 'electronic_ableton_v1', + analysisMode: 'full', + localOnly: true, + llmExcluded: true, + overallStatus: 'ambiguous', + domains: { + tempo: { + status: 'authoritative', + plainEnglish: 'Tempo was measured locally.', + source: 'rhythm_extractor_confirmed', + confidence: 0.98, + evidence: { + bpm: 128, + bpmPercival: 127.5, + bpmAgreement: true, + bpmDoubletime: false, + bpmRawOriginal: 128, + }, + }, + beatGrid: { + status: 'authoritative', + plainEnglish: 'Beat grid was derived locally from the measured tempo and beat tracker.', + source: 'rhythm_extractor', + confidence: 0.98, + evidence: { beatCount: 256, firstBeatSec: 0, lastBeatSec: 183.75 }, + }, + downbeats: { + status: 'ambiguous', + plainEnglish: 'The beat grid is usable, but exact bar starts are uncertain.', + source: 'kick_accent', + confidence: 0.36, + evidence: { downbeatCount: 64 }, + }, + meter: { + status: 'ambiguous', + plainEnglish: 'Meter is a local working assumption, not a confirmed reading.', + source: 'assumed_four_four', + confidence: 0, + evidence: { timeSignature: '4/4' }, + }, + key: { + status: 'authoritative', + plainEnglish: 'Key was measured locally.', + source: 'edma', + confidence: 0.91, + evidence: { key: 'A minor', tuningFrequency: 440.12, tuningCents: 0.05 }, + }, + chords: { + status: 'ambiguous', + plainEnglish: 'Chord labels are local estimates.', + source: 'librosa_viterbi', + confidence: 0.54, + evidence: { + chordStrength: 0.54, + chordTimelineAgreement: false, + chordChangeCount: 3, + dominantChords: ['Am', 'F', 'G'], + }, + }, + percussion: { + status: 'authoritative', + plainEnglish: 'Percussion events were measured locally.', + source: 'local_band_and_stem_detectors', + confidence: 0.7, + evidence: { + kickCount: 256, + kickFundamentalHz: 55, + snareHitCount: 64, + hihatHitCount: 128, + }, + }, + transcription: { + status: 'authoritative', + plainEnglish: 'Monophonic notes were translated locally from stem-aware pitch tracking.', + source: 'torchcrepe-viterbi', + confidence: 0.83, + evidence: { + noteCount: 2, + fullMixFallback: false, + perStemAverageConfidence: { bass: 0.92, other: 0.74 }, + }, + }, + }, + }, bpm: 128, bpmConfidence: 0.98, bpmPercival: 127.5, diff --git a/apps/ui/tests/services/backendPhase1Client.test.ts b/apps/ui/tests/services/backendPhase1Client.test.ts index d0c29370..e16592ae 100644 --- a/apps/ui/tests/services/backendPhase1Client.test.ts +++ b/apps/ui/tests/services/backendPhase1Client.test.ts @@ -59,6 +59,11 @@ describe('parseBackendAnalyzeResponse', () => { expect(parsed.phase1.segmentLoudness).toEqual(validPayload.phase1.segmentLoudness); expect(parsed.phase1.perceptual).toEqual(validPayload.phase1.perceptual); expect(parsed.phase1.danceability).toEqual(validPayload.phase1.danceability); + expect(parsed.phase1.fundamentalsQuality?.schemaVersion).toBe('fundamentals-quality.v1'); + expect(parsed.phase1.fundamentalsQuality?.localOnly).toBe(true); + expect(parsed.phase1.fundamentalsQuality?.llmExcluded).toBe(true); + expect(parsed.phase1.fundamentalsQuality?.domains.tempo.status).toBe('authoritative'); + expect(parsed.phase1.fundamentalsQuality?.domains.chords.status).toBe('ambiguous'); // New fields expect(parsed.phase1.bpmPercival).toBe(127.5); @@ -165,6 +170,7 @@ describe('parseBackendAnalyzeResponse', () => { expect(parsed.phase1.bpmDoubletime).toBeNull(); expect(parsed.phase1.bpmSource).toBeNull(); expect(parsed.phase1.bpmRawOriginal).toBeNull(); + expect(parsed.phase1.fundamentalsQuality).toBeNull(); expect(parsed.phase1.monoCompatible).toBeNull(); expect(parsed.phase1.acidDetail).toBeNull(); expect(parsed.phase1.genreDetail).toBeNull(); diff --git a/apps/ui/tests/services/fundamentalsQualityUi.test.ts b/apps/ui/tests/services/fundamentalsQualityUi.test.ts new file mode 100644 index 00000000..8b39dc22 --- /dev/null +++ b/apps/ui/tests/services/fundamentalsQualityUi.test.ts @@ -0,0 +1,39 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { HarmonyLanes } from '../../src/components/HarmonyLanes'; +import { MeasurementSummarySection } from '../../src/components/analysisResults/MeasurementSummarySection'; +import type { Phase1Result } from '../../src/types'; +import { phase1EnvelopeFixture } from '../fixtures/phase1FullPayload'; + +const phase1 = phase1EnvelopeFixture as unknown as Phase1Result; + +describe('fundamentals quality UI', () => { + it('renders local fundamentals trust labels in the measurement summary', () => { + const html = renderToStaticMarkup( + React.createElement(MeasurementSummarySection, { + phase1, + finalBpm: 128, + finalKey: 'A minor', + keyIsApproximate: false, + characteristicPills: [], + }), + ); + + expect(html).toContain('Local fundamentals'); + expect(html).toContain('Tempo LOCAL'); + expect(html).toContain('Meter CHECK'); + expect(html).toContain('Chords CHECK'); + }); + + it('surfaces ambiguous chord authority in the harmony lanes', () => { + const html = renderToStaticMarkup( + React.createElement(HarmonyLanes, { phase1 }), + ); + + expect(html).toContain('Quality'); + expect(html).toContain('Check'); + expect(html).toContain('Chord labels are local estimates.'); + }); +}); diff --git a/apps/ui/tests/services/phase2Validator.test.ts b/apps/ui/tests/services/phase2Validator.test.ts index f2692bbb..59e17fc9 100644 --- a/apps/ui/tests/services/phase2Validator.test.ts +++ b/apps/ui/tests/services/phase2Validator.test.ts @@ -231,6 +231,139 @@ describe('validatePhase2Consistency', () => { }); }); + describe('Fundamental authority validation', () => { + it('rejects Phase 2 project setup tempo that replaces the local BPM', () => { + const phase1 = createBasePhase1({ bpm: 126 }); + const phase2 = createBasePhase2({ + projectSetup: { + tempoBpm: 140, + timeSignature: '4/4', + sampleRate: 44100, + bitDepth: 24, + headroomTarget: '-6 dB', + sessionGoal: 'Reference rebuild', + }, + }); + + const result = validatePhase2Consistency(phase1, phase2); + + const violation = result.violations.find(v => v.field === 'projectSetup.tempoBpm'); + expect(violation).toBeDefined(); + expect(violation?.type).toBe('FUNDAMENTAL_AUTHORITY_VIOLATION'); + expect(violation?.severity).toBe('ERROR'); + }); + + it('rejects authoritative style-profile key that contradicts local key', () => { + const phase1 = createBasePhase1({ key: 'F minor' }); + const phase2 = createBasePhase2({ + trackCharacter: 'Tight modern electronic mix at 126 BPM in F minor.', + styleProfile: { + genre: 'Techno', + subGenre: 'Warehouse', + mood: ['Driving'], + instruments: ['Kick', 'Bass'], + productionTechniques: ['Sidechain'], + description: 'Driving warehouse techno at 126 BPM in F minor.', + generationPrompt: 'Warehouse techno groove in F minor at 126 BPM.', + authoritativeMeasurements: { bpm: 126, key: 'A minor', timeSignature: '4/4' }, + }, + }); + + const result = validatePhase2Consistency(phase1, phase2); + + const violation = result.violations.find( + v => v.field === 'styleProfile.authoritativeMeasurements.key', + ); + expect(violation).toBeDefined(); + expect(violation?.type).toBe('FUNDAMENTAL_AUTHORITY_VIOLATION'); + expect(violation?.severity).toBe('ERROR'); + }); + + it('requires hedging when a recommendation cites ambiguous chord fundamentals', () => { + const phase1 = createBasePhase1({ + fundamentalsQuality: { + schemaVersion: 'fundamentals-quality.v1', + targetProfile: 'electronic_ableton_v1', + analysisMode: 'full', + localOnly: true, + llmExcluded: true, + overallStatus: 'ambiguous', + domains: { + chords: { + status: 'ambiguous', + plainEnglish: 'Chord labels are local estimates.', + source: 'librosa_viterbi', + confidence: 0.31, + evidence: { chordTimelineAgreement: false }, + }, + }, + }, + }); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + { + device: 'Chord', + category: 'MIDI', + parameter: 'Shift 1', + value: '+3 st', + reason: 'Set the chord shape from the detected progression.', + phase1Fields: ['chordDetail.chordTimeline'], + }, + ], + }); + + const result = validatePhase2Consistency(phase1, phase2); + + const violation = result.violations.find( + v => v.type === 'FUNDAMENTAL_AUTHORITY_VIOLATION' && + v.field === 'abletonRecommendations[0].phase1Fields', + ); + expect(violation).toBeDefined(); + expect(violation?.severity).toBe('ERROR'); + }); + + it('allows hedged advice from ambiguous chord fundamentals', () => { + const phase1 = createBasePhase1({ + fundamentalsQuality: { + schemaVersion: 'fundamentals-quality.v1', + targetProfile: 'electronic_ableton_v1', + analysisMode: 'full', + localOnly: true, + llmExcluded: true, + overallStatus: 'ambiguous', + domains: { + chords: { + status: 'ambiguous', + plainEnglish: 'Chord labels are local estimates.', + source: 'librosa_viterbi', + confidence: 0.31, + evidence: { chordTimelineAgreement: false }, + }, + }, + }, + }); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + { + device: 'Chord', + category: 'MIDI', + parameter: 'Shift 1', + value: '+3 st', + reason: 'The chord timeline is uncertain, so try this voicing only if it matches by ear.', + phase1Fields: ['chordDetail.chordTimeline'], + }, + ], + }); + + const result = validatePhase2Consistency(phase1, phase2); + + expect(result.violations.some( + v => v.type === 'FUNDAMENTAL_AUTHORITY_VIOLATION' && + v.field === 'abletonRecommendations[0].phase1Fields', + )).toBe(false); + }); + }); + describe('LUFS validation', () => { it('should pass when Phase 2 LUFS values are within reasonable bounds', () => { const phase1 = createBasePhase1({ lufsIntegrated: -7.9 }); diff --git a/bin/asa b/bin/asa index 92e9f9a9..2f9bdce3 100755 --- a/bin/asa +++ b/bin/asa @@ -296,6 +296,10 @@ cmd_verify() { echo "== Backend unittest discover ==" # Must run from apps/backend: test modules do `import server` and rely on CWD. ( cd "$BACKEND_DIR" && ./venv/bin/python -m unittest discover -s tests ) || be=$? + if [[ "$be" -eq 0 ]]; then + echo "== Backend fundamentals evaluation ==" + ( cd "$BACKEND_DIR" && ./venv/bin/python scripts/evaluate_fundamentals.py ) || be=$? + fi fi echo "----" From 73eefc3f08efd988c9875053711a5fa686803b9d Mon Sep 17 00:00:00 2001 From: assiduous repetition Date: Thu, 2 Jul 2026 00:16:26 +1200 Subject: [PATCH 2/8] =?UTF-8?q?feat(ui):=20notable-findings=20aggregator?= =?UTF-8?q?=20=E2=80=94=20loudness=20+=20fundamentals=20sources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- apps/ui/src/services/notableFindings.ts | 92 +++++++++++++++++++ .../ui/tests/services/notableFindings.test.ts | 81 ++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 apps/ui/src/services/notableFindings.ts create mode 100644 apps/ui/tests/services/notableFindings.test.ts diff --git a/apps/ui/src/services/notableFindings.ts b/apps/ui/src/services/notableFindings.ts new file mode 100644 index 00000000..e09da4cb --- /dev/null +++ b/apps/ui/src/services/notableFindings.ts @@ -0,0 +1,92 @@ +import type { Phase1Result } from '../types'; +import { loudnessDefectsDemandingAction, type LoudnessDefect } from './loudnessGuardrails'; + +export type FindingSeverity = 'critical' | 'warning' | 'info'; + +export interface NotableFinding { + id: string; + severity: FindingSeverity; + domain: string; + title: string; + detail: string; + phase1Field?: string; +} + +const SEVERITY_ORDER: Record = { critical: 0, warning: 1, info: 2 }; + +// loudnessGuardrails' LoudnessDefect carries no human text, so generate it here. +function loudnessDefectFinding(defect: LoudnessDefect): NotableFinding { + if (defect.kind === 'CLIPPING') { + return { + id: 'loudness.clipping', + severity: 'critical', + domain: 'Loudness', + title: 'Master is clipping', + detail: `${defect.value} clipped sample${defect.value === 1 ? '' : 's'} detected — pull the master gain down or add a limiter.`, + phase1Field: defect.field, + }; + } + return { + id: 'loudness.truePeakOver', + severity: 'critical', + domain: 'Loudness', + title: 'Inter-sample peak over 0 dBTP', + detail: `True peak is ${defect.value} dBTP — leave ~1 dB of true-peak headroom.`, + phase1Field: defect.field, + }; +} + +// fundamentalsQuality: reuse the domain's plainEnglish text. +// Meter is intentionally excluded from the ambiguous->warning rule: its routine +// "assumed 4/4" reading is ambiguous on nearly every track, so flagging it would +// be constant noise. Meter still surfaces when it FAILED to measure. +const FUNDAMENTAL_DOMAIN_META: Record = { + tempo: { label: 'Tempo', field: 'bpm' }, + beatGrid: { label: 'Beat grid', field: 'rhythmDetail.beatGrid' }, + downbeats: { label: 'Downbeats', field: 'rhythmDetail.downbeats' }, + meter: { label: 'Meter', field: 'timeSignature' }, + key: { label: 'Key', field: 'key' }, + chords: { label: 'Chords', field: 'chordDetail' }, + percussion: { label: 'Percussion', field: 'kickDetail' }, + transcription: { label: 'Notes', field: 'transcriptionDetail' }, +}; + +function fundamentalsFindings(phase1: Phase1Result): NotableFinding[] { + const domains = phase1.fundamentalsQuality?.domains; + if (!domains) return []; + const out: NotableFinding[] = []; + for (const [key, meta] of Object.entries(FUNDAMENTAL_DOMAIN_META)) { + const domain = domains[key]; + if (!domain) continue; + if (domain.status === 'failed') { + out.push({ + id: `fundamentals.${key}.failed`, + severity: 'critical', + domain: meta.label, + title: `${meta.label} could not be measured`, + detail: domain.plainEnglish, + phase1Field: meta.field, + }); + } else if (domain.status === 'ambiguous' && key !== 'meter') { + out.push({ + id: `fundamentals.${key}.ambiguous`, + severity: 'warning', + domain: meta.label, + title: `${meta.label} is uncertain`, + detail: domain.plainEnglish, + phase1Field: meta.field, + }); + } + } + return out; +} + +export function collectNotableFindings(phase1: Phase1Result): NotableFinding[] { + const findings: NotableFinding[] = [ + ...loudnessDefectsDemandingAction(phase1).map(loudnessDefectFinding), + ...fundamentalsFindings(phase1), + ]; + // Array.prototype.sort is stable, so equal-severity findings keep insertion + // order (loudness, then fundamentals, then — added in Task 2 — mixDoctor). + return findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); +} diff --git a/apps/ui/tests/services/notableFindings.test.ts b/apps/ui/tests/services/notableFindings.test.ts new file mode 100644 index 00000000..09a6a711 --- /dev/null +++ b/apps/ui/tests/services/notableFindings.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Phase1Result } from '../../src/types'; +import type { MixDoctorReport } from '../../src/services/mixDoctor'; +import { collectNotableFindings } from '../../src/services/notableFindings'; + +// mixDoctor is genre-relative; mock it so these unit tests are deterministic and +// exercise the aggregation logic (mixDoctor's genre math has its own tests). +vi.mock('../../src/services/mixDoctor', () => ({ generateMixDoctorReport: vi.fn() })); +import { generateMixDoctorReport } from '../../src/services/mixDoctor'; + +const OPTIMAL_REPORT = { + advice: [], + loudnessAdvice: { issue: 'optimal', message: '' }, + dynamicsAdvice: { issue: 'optimal', message: '' }, + stereoAdvice: { monoCompatible: true, message: '' }, +} as unknown as MixDoctorReport; + +// Minimal valid Phase1Result (mirrors the factory in phase2Validator.test.ts). +const makePhase1 = (overrides: Partial = {}): Phase1Result => ({ + bpm: 126, bpmConfidence: 0.91, key: 'F minor', keyConfidence: 0.87, + timeSignature: '4/4', durationSeconds: 210.6, + lufsIntegrated: -9.0, lufsRange: 6.0, truePeak: -1.0, crestFactor: 11.0, + stereoWidth: 0.6, stereoCorrelation: 0.6, + spectralBalance: { subBass: 0, lowBass: 0, lowMids: 0, mids: 0, upperMids: 0, highs: 0, brilliance: 0 }, + spectralDetail: { spectralCentroidMean: 3000 }, + rhythmDetail: { kickSwing: 0.05 }, + synthesisCharacter: { inharmonicity: 0.1, oddToEvenRatio: 1.1 }, + ...overrides, +}); + +const dom = (status: string, plainEnglish: string) => + ({ status, plainEnglish, source: null, confidence: null, evidence: {} }); + +const fq = (domains: Record) => + ({ + schemaVersion: 'fundamentals-quality.v1', targetProfile: 'electronic_ableton_v1', + analysisMode: 'full', localOnly: true, llmExcluded: true, overallStatus: 'ambiguous', domains, + } as unknown as NonNullable); + +beforeEach(() => { + vi.mocked(generateMixDoctorReport).mockReset(); + vi.mocked(generateMixDoctorReport).mockReturnValue(OPTIMAL_REPORT); +}); + +describe('collectNotableFindings', () => { + it('returns [] for a clean, unremarkable track', () => { + expect(collectNotableFindings(makePhase1())).toEqual([]); + }); + + it('flags digital clipping as a critical Loudness finding', () => { + const findings = collectNotableFindings(makePhase1({ saturationDetail: { clippedSampleCount: 42 } as never })); + const clip = findings.find((f) => f.id === 'loudness.clipping'); + expect(clip?.severity).toBe('critical'); + expect(clip?.phase1Field).toBe('saturationDetail.clippedSampleCount'); + expect(clip?.detail).toContain('42'); + }); + + it('flags an inter-sample true-peak over as critical', () => { + const findings = collectNotableFindings(makePhase1({ truePeak: 0.6 })); + expect(findings.find((f) => f.id === 'loudness.truePeakOver')?.severity).toBe('critical'); + }); + + it('flags an ambiguous fundamentals domain as a warning', () => { + const findings = collectNotableFindings(makePhase1({ fundamentalsQuality: fq({ tempo: dom('ambiguous', 'Tempo cross-check weak.') }) })); + const tempo = findings.find((f) => f.id === 'fundamentals.tempo.ambiguous'); + expect(tempo?.severity).toBe('warning'); + expect(tempo?.detail).toBe('Tempo cross-check weak.'); + expect(tempo?.phase1Field).toBe('bpm'); + }); + + it('does NOT flag the routine assumed-4/4 meter (ambiguous meter is the default)', () => { + const findings = collectNotableFindings(makePhase1({ fundamentalsQuality: fq({ meter: dom('ambiguous', 'Meter is a working assumption.') }) })); + expect(findings.some((f) => f.domain === 'Meter')).toBe(false); + }); + + it('orders critical before warning', () => { + const findings = collectNotableFindings(makePhase1({ truePeak: 0.6, fundamentalsQuality: fq({ key: dom('ambiguous', 'Key uncertain.') }) })); + const sev = findings.map((f) => f.severity); + expect(sev.indexOf('critical')).toBeLessThan(sev.indexOf('warning')); + }); +}); From 9a88823d014502f852698795466122b2881372b1 Mon Sep 17 00:00:00 2001 From: assiduous repetition Date: Thu, 2 Jul 2026 13:18:11 +1200 Subject: [PATCH 3/8] feat(ui): add mixDoctor sources + fast-mode guard + dedup to notable findings --- apps/ui/src/services/notableFindings.ts | 85 ++++++++++++++++++- .../ui/tests/services/notableFindings.test.ts | 34 ++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/apps/ui/src/services/notableFindings.ts b/apps/ui/src/services/notableFindings.ts index e09da4cb..1e7b2abc 100644 --- a/apps/ui/src/services/notableFindings.ts +++ b/apps/ui/src/services/notableFindings.ts @@ -1,5 +1,6 @@ import type { Phase1Result } from '../types'; import { loudnessDefectsDemandingAction, type LoudnessDefect } from './loudnessGuardrails'; +import { generateMixDoctorReport } from './mixDoctor'; export type FindingSeverity = 'critical' | 'warning' | 'info'; @@ -81,12 +82,92 @@ function fundamentalsFindings(phase1: Phase1Result): NotableFinding[] { return out; } +const BAND_TO_KEY: Record = { + 'Sub Bass': 'subBass', + 'Low Bass': 'lowBass', + 'Low Mids': 'lowMids', + Mids: 'mids', + 'Upper Mids': 'upperMids', + Highs: 'highs', + Brilliance: 'brilliance', +}; + +function mixDoctorFindings(phase1: Phase1Result): NotableFinding[] { + // generateMixDoctorReport dereferences phase1.spectralBalance.* directly, so + // it throws on a fast-mode payload where spectralBalance is null. Skip it. + if (!phase1.spectralBalance) return []; + + const report = generateMixDoctorReport(phase1); + const out: NotableFinding[] = []; + + if (report.loudnessAdvice.issue !== 'optimal') { + out.push({ + id: 'mix.loudness', + severity: 'warning', + domain: 'Loudness', + title: report.loudnessAdvice.issue === 'too-loud' + ? 'Master is louder than the genre target' + : 'Master is quieter than the genre target', + detail: report.loudnessAdvice.message, + phase1Field: 'lufsIntegrated', + }); + } + + if (report.dynamicsAdvice.issue !== 'optimal') { + out.push({ + id: 'mix.dynamics', + severity: 'warning', + domain: 'Dynamics', + title: report.dynamicsAdvice.issue === 'too-compressed' + ? 'Dynamics are heavily compressed' + : 'Dynamics are unusually wide', + detail: report.dynamicsAdvice.message, + phase1Field: typeof phase1.plr === 'number' ? 'plr' : 'crestFactor', + }); + } + + if (report.stereoAdvice.monoCompatible === false) { + out.push({ + id: 'mix.stereo.mono', + severity: 'warning', + domain: 'Stereo', + title: 'Sub-bass may not be mono-compatible', + detail: report.stereoAdvice.message, + // Mirror mixDoctor.estimateMonoCompatible's precedence for the citation. + phase1Field: phase1.monoCompatible === true || phase1.monoCompatible === false + ? 'monoCompatible' + : 'stereoDetail.subBassMono', + }); + } + + for (const band of report.advice) { + if (band.issue === 'optimal') continue; + const key = BAND_TO_KEY[band.band] ?? band.band; + out.push({ + id: `mix.band.${key}`, + severity: 'info', + domain: 'Balance', + title: `${band.band} ${band.issue === 'too-loud' ? 'over' : 'under'} target`, + detail: band.message, + phase1Field: `spectralBalance.${key}`, + }); + } + + return out; +} + +function dedup(findings: NotableFinding[]): NotableFinding[] { + const hasLoudnessCritical = findings.some((f) => f.domain === 'Loudness' && f.severity === 'critical'); + return findings.filter((f) => !(f.id === 'mix.loudness' && hasLoudnessCritical)); +} + export function collectNotableFindings(phase1: Phase1Result): NotableFinding[] { const findings: NotableFinding[] = [ ...loudnessDefectsDemandingAction(phase1).map(loudnessDefectFinding), ...fundamentalsFindings(phase1), + ...mixDoctorFindings(phase1), ]; // Array.prototype.sort is stable, so equal-severity findings keep insertion - // order (loudness, then fundamentals, then — added in Task 2 — mixDoctor). - return findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); + // order (loudness, then fundamentals, then mixDoctor). + return dedup(findings).sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); } diff --git a/apps/ui/tests/services/notableFindings.test.ts b/apps/ui/tests/services/notableFindings.test.ts index 09a6a711..59762e72 100644 --- a/apps/ui/tests/services/notableFindings.test.ts +++ b/apps/ui/tests/services/notableFindings.test.ts @@ -78,4 +78,38 @@ describe('collectNotableFindings', () => { const sev = findings.map((f) => f.severity); expect(sev.indexOf('critical')).toBeLessThan(sev.indexOf('warning')); }); + + it('flags a genre-relative too-loud master as a warning', () => { + vi.mocked(generateMixDoctorReport).mockReturnValue({ ...OPTIMAL_REPORT, loudnessAdvice: { issue: 'too-loud', message: 'Hot master.' } } as unknown as MixDoctorReport); + const loud = collectNotableFindings(makePhase1()).find((f) => f.id === 'mix.loudness'); + expect(loud?.severity).toBe('warning'); + expect(loud?.phase1Field).toBe('lufsIntegrated'); + }); + + it('flags a sub-bass mono issue, citing the field mixDoctor used', () => { + vi.mocked(generateMixDoctorReport).mockReturnValue({ ...OPTIMAL_REPORT, stereoAdvice: { monoCompatible: false, message: 'Collapse sub bass.' } } as unknown as MixDoctorReport); + const mono = collectNotableFindings(makePhase1({ monoCompatible: false })).find((f) => f.id === 'mix.stereo.mono'); + expect(mono?.severity).toBe('warning'); + expect(mono?.phase1Field).toBe('monoCompatible'); + }); + + it('maps an off-target band to an info Balance finding', () => { + vi.mocked(generateMixDoctorReport).mockReturnValue({ ...OPTIMAL_REPORT, advice: [{ band: 'Highs', issue: 'too-loud', message: 'Harsh highs.' }] } as unknown as MixDoctorReport); + const band = collectNotableFindings(makePhase1()).find((f) => f.id === 'mix.band.highs'); + expect(band?.severity).toBe('info'); + expect(band?.phase1Field).toBe('spectralBalance.highs'); + }); + + it('dedups: objective clipping supersedes the genre-relative too-loud warning', () => { + vi.mocked(generateMixDoctorReport).mockReturnValue({ ...OPTIMAL_REPORT, loudnessAdvice: { issue: 'too-loud', message: 'Hot.' } } as unknown as MixDoctorReport); + const findings = collectNotableFindings(makePhase1({ saturationDetail: { clippedSampleCount: 10 } as never })); + expect(findings.some((f) => f.id === 'loudness.clipping')).toBe(true); + expect(findings.some((f) => f.id === 'mix.loudness')).toBe(false); + }); + + it('skips mixDoctor (no crash) when spectralBalance is absent (fast mode)', () => { + const p = makePhase1({ spectralBalance: null as never }); + expect(() => collectNotableFindings(p)).not.toThrow(); + expect(generateMixDoctorReport).not.toHaveBeenCalled(); + }); }); From 5cf4921a607a93e3614c566ad8a928ccc2dfc310 Mon Sep 17 00:00:00 2001 From: assiduous repetition Date: Thu, 2 Jul 2026 13:19:07 +1200 Subject: [PATCH 4/8] feat(ui): render 'Worth checking' notable-findings panel atop results --- apps/ui/src/components/AnalysisResults.tsx | 3 ++ .../NotableFindingsSection.tsx | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 apps/ui/src/components/analysisResults/NotableFindingsSection.tsx diff --git a/apps/ui/src/components/AnalysisResults.tsx b/apps/ui/src/components/AnalysisResults.tsx index 368565b5..a0aaec7e 100644 --- a/apps/ui/src/components/AnalysisResults.tsx +++ b/apps/ui/src/components/AnalysisResults.tsx @@ -61,6 +61,7 @@ import { type TextRole, } from '../utils/displayText'; import { Collapsible, MetaBadgeList, ResultsSectionHeader, textRoleClassName, type StyleProfileSectionState } from './analysisResults/shared'; +import { NotableFindingsSection } from './analysisResults/NotableFindingsSection'; import { MeasurementSummarySection } from './analysisResults/MeasurementSummarySection'; import { AudioObservationsSection } from './analysisResults/AudioObservationsSection'; import { ProjectSetupSection } from './analysisResults/ProjectSetupSection'; @@ -651,6 +652,8 @@ export function AnalysisResults({ + + = { + critical: 'error', + warning: 'warning', + info: 'neutral', +}; + +const SEVERITY_LABEL: Record = { + critical: 'CRITICAL', + warning: 'CHECK', + info: 'FYI', +}; + +export function NotableFindingsSection({ phase1 }: { phase1: Phase1Result }) { + const findings = collectNotableFindings(phase1); + if (findings.length === 0) return null; + + return ( +
+

+ Worth checking +

+
    + {findings.map((f) => ( +
  • + + {SEVERITY_LABEL[f.severity]} + +
    + + {f.domain}: {f.title} + +

    {f.detail}

    +
    +
  • + ))} +
+
+ ); +} From 82fc58fdfb17f38fdf9aa73795600dbc2b858413 Mon Sep 17 00:00:00 2001 From: assiduous repetition Date: Thu, 2 Jul 2026 13:20:49 +1200 Subject: [PATCH 5/8] =?UTF-8?q?test(ui):=20smoke=20=E2=80=94=20Worth=20Che?= =?UTF-8?q?cking=20panel=20presence/absence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ui/tests/smoke/notable-findings.spec.ts | 236 +++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 apps/ui/tests/smoke/notable-findings.spec.ts diff --git a/apps/ui/tests/smoke/notable-findings.spec.ts b/apps/ui/tests/smoke/notable-findings.spec.ts new file mode 100644 index 00000000..221696c4 --- /dev/null +++ b/apps/ui/tests/smoke/notable-findings.spec.ts @@ -0,0 +1,236 @@ +import { test, expect, type Page } from '@playwright/test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const RUN_ID = 'run_notable_findings_001'; + +const PHASE2_STUB = { + trackCharacter: 'Deterministic smoke response.', + detectedCharacteristics: [], + arrangementOverview: { summary: 'Smoke summary.', segments: [] }, + sonicElements: { + kick: 'Kick.', + bass: 'Bass.', + melodicArp: 'Arp.', + grooveAndTiming: 'Groove.', + effectsAndTexture: 'FX.', + }, + mixAndMasterChain: [], + secretSauce: { + title: 'Smoke Sauce', + explanation: 'Smoke explanation.', + implementationSteps: [], + }, + confidenceNotes: [], + abletonRecommendations: [], +}; + +const BASE_PHASE1 = { + bpm: 126, + bpmConfidence: 0.93, + key: 'F minor', + keyConfidence: 0.88, + timeSignature: '4/4', + durationSeconds: 210.6, + lufsIntegrated: -12, + truePeak: -3, + plr: 9, + crestFactor: 7, + stereoWidth: 0.69, + stereoCorrelation: 0.84, + monoCompatible: true, + spectralBalance: { + subBass: -11, + lowBass: -14, + lowMids: -22, + mids: -18, + upperMids: -20, + highs: -23, + brilliance: -27, + }, +}; + +async function stubEstimateRoute(page: Page) { + let hits = 0; + await page.route('**/api/analysis-runs/estimate', async (route) => { + hits += 1; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + requestId: 'req_estimate_notable_findings', + estimate: { + durationSeconds: 210.6, + totalLowMs: 22000, + totalHighMs: 38000, + stages: [{ key: 'local_dsp', label: 'Local DSP analysis', lowMs: 22000, highMs: 38000 }], + }, + }), + }); + }); + return () => hits; +} + +async function stubAnalysisRun( + page: Page, + { phase1Overrides = {} }: { phase1Overrides?: Record } = {}, +) { + await page.route('**/api/analysis-runs', async (route) => { + if (route.request().method() !== 'POST') { + await route.fallback(); + return; + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + runId: RUN_ID, + requestedStages: { + pitchNoteMode: 'off', + pitchNoteBackend: 'auto', + interpretationMode: 'async', + interpretationProfile: 'producer_summary', + interpretationModel: 'gemini-3.1-pro-preview', + }, + artifacts: { + sourceAudio: { + artifactId: 'artifact_notable_findings', + filename: 'silence.wav', + mimeType: 'audio/wav', + sizeBytes: 2048, + contentSha256: 'abc123', + path: 'uploads/test.wav', + }, + }, + stages: { + measurement: { + status: 'queued', + authoritative: true, + result: null, + provenance: null, + diagnostics: null, + error: null, + }, + pitchNoteTranslation: { + status: 'not_requested', + authoritative: false, + preferredAttemptId: null, + attemptsSummary: [], + result: null, + provenance: null, + diagnostics: null, + error: null, + }, + interpretation: { + status: 'blocked', + authoritative: false, + preferredAttemptId: null, + attemptsSummary: [], + result: null, + provenance: null, + diagnostics: null, + error: null, + }, + }, + }), + }); + }); + + await page.route(`**/api/analysis-runs/${RUN_ID}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + runId: RUN_ID, + requestedStages: { + pitchNoteMode: 'off', + pitchNoteBackend: 'auto', + interpretationMode: 'async', + interpretationProfile: 'producer_summary', + interpretationModel: 'gemini-3.1-pro-preview', + }, + artifacts: { + sourceAudio: { + artifactId: 'artifact_notable_findings', + filename: 'silence.wav', + mimeType: 'audio/wav', + sizeBytes: 2048, + contentSha256: 'abc123', + path: 'uploads/test.wav', + }, + }, + stages: { + measurement: { + status: 'completed', + authoritative: true, + result: { ...BASE_PHASE1, ...phase1Overrides }, + provenance: null, + diagnostics: { timings: { totalMs: 980, analysisMs: 900, serverOverheadMs: 80, flagsUsed: [], fileSizeBytes: 2048, fileDurationSeconds: 10, msPerSecondOfAudio: 98 } }, + error: null, + }, + pitchNoteTranslation: { + status: 'not_requested', + authoritative: false, + preferredAttemptId: null, + attemptsSummary: [], + result: null, + provenance: null, + diagnostics: null, + error: null, + }, + interpretation: { + status: 'completed', + authoritative: false, + preferredAttemptId: 'int_notable_findings', + attemptsSummary: [ + { attemptId: 'int_notable_findings', profileId: 'producer_summary', modelName: 'gemini-3.1-pro-preview', status: 'completed' }, + ], + result: PHASE2_STUB, + provenance: null, + diagnostics: null, + error: null, + }, + }, + }), + }); + }); +} + +async function uploadAndDriveToResults(page: Page, getEstimateHits: () => number) { + await page.goto('/', { waitUntil: 'networkidle' }); + + const fixturePath = path.resolve(testDir, './fixtures/silence.wav'); + await page.setInputFiles('#audio-upload', fixturePath); + await expect.poll(() => getEstimateHits()).toBe(1); + + await expect(page.getByRole('button', { name: /Run Analysis/i })).toBeVisible(); + await page.getByRole('button', { name: /Run Analysis/i }).click(); + await expect(page.getByText('Analysis Results')).toBeVisible(); +} + +test('shows the Worth Checking panel when Phase 1 has a clipping defect', async ({ page }) => { + const getEstimateHits = await stubEstimateRoute(page); + await stubAnalysisRun(page, { phase1Overrides: { saturationDetail: { clippedSampleCount: 128 } } }); + + await uploadAndDriveToResults(page, getEstimateHits); + + const panel = page.getByTestId('notable-findings'); + await expect(panel).toBeVisible(); + await expect(panel).toContainText('Master is clipping'); +}); + +test('hides the Worth Checking panel on a clean track', async ({ page }) => { + const getEstimateHits = await stubEstimateRoute(page); + await stubAnalysisRun(page, { + phase1Overrides: { + saturationDetail: { clippedSampleCount: 0 }, + truePeak: -3, + monoCompatible: true, + }, + }); + + await uploadAndDriveToResults(page, getEstimateHits); + + await expect(page.getByTestId('notable-findings')).toHaveCount(0); +}); From cdf1e57c45ebd4b870b99cd71c8fb328638d16d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 02:57:39 +0000 Subject: [PATCH 6/8] feat(ui): always-on plain-English Reconstruction Brief section Deterministic summary of key/tempo/meter/groove/loudness/stereo/spectral/ dynamics rendered above the measurement dashboard, built purely from Phase 1 plus the fundamentalsQuality trust layer. Each line cites its Phase 1 fields (invariant #2); lines whose driving measurement is absent are omitted rather than guessed (invariant #4); non-authoritative fundamentals reuse the domain's plainEnglish hedge verbatim. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01T4wfz87k6kzJqkKLKZE7YL --- apps/ui/src/components/AnalysisResults.tsx | 3 + .../ReconstructionBriefSection.tsx | 42 ++++ apps/ui/src/services/reconstructionBrief.ts | 233 ++++++++++++++++++ .../services/reconstructionBrief.test.ts | 162 ++++++++++++ 4 files changed, 440 insertions(+) create mode 100644 apps/ui/src/components/analysisResults/ReconstructionBriefSection.tsx create mode 100644 apps/ui/src/services/reconstructionBrief.ts create mode 100644 apps/ui/tests/services/reconstructionBrief.test.ts diff --git a/apps/ui/src/components/AnalysisResults.tsx b/apps/ui/src/components/AnalysisResults.tsx index 224ed272..7288f335 100644 --- a/apps/ui/src/components/AnalysisResults.tsx +++ b/apps/ui/src/components/AnalysisResults.tsx @@ -45,6 +45,7 @@ import { } from '../utils/displayText'; import { Collapsible, textRoleClassName, type StyleProfileSectionState } from './analysisResults/shared'; import { NotableFindingsSection } from './analysisResults/NotableFindingsSection'; +import { ReconstructionBriefSection } from './analysisResults/ReconstructionBriefSection'; import { MeasurementSummarySection } from './analysisResults/MeasurementSummarySection'; import { AudioObservationsSection } from './analysisResults/AudioObservationsSection'; import { ProjectSetupSection } from './analysisResults/ProjectSetupSection'; @@ -526,6 +527,8 @@ export function AnalysisResults({ + + + } + /> +
    + {lines.map((line) => ( +
  • + + {line.label} + + {line.text} + {line.confidence !== null && ( + + )} + + {line.phase1Fields.join(' · ')} + +
  • + ))} +
+ + ); +} diff --git a/apps/ui/src/services/reconstructionBrief.ts b/apps/ui/src/services/reconstructionBrief.ts new file mode 100644 index 00000000..2c46f9cf --- /dev/null +++ b/apps/ui/src/services/reconstructionBrief.ts @@ -0,0 +1,233 @@ +import type { FundamentalsQualityDomain, Phase1Result } from '../types'; +import { getConfidenceBand, type ConfidenceBand } from './sessionMusician/confidenceBand'; + +// Plain-English reconstruction brief built purely from Phase 1 measurements +// (plus the local-only fundamentalsQuality trust layer). Deterministic — no +// AI involved — so it renders even when Phase 2 is disabled or fails. Every +// line cites the Phase 1 field(s) it was derived from (PURPOSE.md invariant +// #2), and a line whose driving measurement is absent is omitted rather than +// guessed (invariant #4). Fast-mode payloads null out rhythmDetail / +// spectralBalance / stereoDetail / spectralDetail despite the optimistic TS +// types, so every read is guarded. + +export type BriefDomain = + | 'key' + | 'tempo' + | 'meter' + | 'groove' + | 'loudness' + | 'stereo' + | 'spectral' + | 'dynamics'; + +export interface BriefLine { + domain: BriefDomain; + label: string; + /** Deterministic plain-English sentence, hedged when confidence is weak. */ + text: string; + /** 0-1 scalar when the measurement carries one; null for pure DSP scalars. */ + confidence: number | null; + band: ConfidenceBand | null; + /** Phase 1 citation paths — always at least one. */ + phase1Fields: string[]; +} + +const APPROXIMATE_KEY_CONFIDENCE = 0.6; // mirrors AnalysisResults keyIsApproximate + +function finite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function fmt(value: number, digits = 1): string { + return value.toFixed(digits); +} + +function domainOf(phase1: Phase1Result, key: string): FundamentalsQualityDomain | null { + return phase1.fundamentalsQuality?.domains?.[key] ?? null; +} + +/** Append the fundamentals domain's own plainEnglish hedge when it is not authoritative. */ +function withDomainHedge(text: string, domain: FundamentalsQualityDomain | null): string { + if (!domain || domain.status === 'authoritative') return text; + if (!domain.plainEnglish) return text; + return `${text} ${domain.plainEnglish}`; +} + +function line( + domain: BriefDomain, + label: string, + text: string, + confidence: number | null, + phase1Fields: string[], +): BriefLine { + return { + domain, + label, + text, + confidence, + band: confidence !== null ? getConfidenceBand(confidence) : null, + phase1Fields, + }; +} + +function keyLine(phase1: Phase1Result): BriefLine | null { + if (typeof phase1.key !== 'string' || phase1.key.length === 0) return null; + const quality = domainOf(phase1, 'key'); + const confidence = quality?.confidence ?? (finite(phase1.keyConfidence) ? phase1.keyConfidence : null); + let text = `The track reads as ${phase1.key}.`; + if (finite(phase1.keyConfidence) && phase1.keyConfidence <= APPROXIMATE_KEY_CONFIDENCE) { + text += ' Treat the key as approximate — confirm by ear against a sustained pad before committing.'; + } + return line('key', 'Key', withDomainHedge(text, quality), confidence, ['key', 'keyConfidence']); +} + +function tempoLine(phase1: Phase1Result): BriefLine | null { + if (!finite(phase1.bpm)) return null; + const quality = domainOf(phase1, 'tempo'); + const confidence = quality?.confidence ?? (finite(phase1.bpmConfidence) ? phase1.bpmConfidence : null); + const text = `Set your Live set to ${fmt(phase1.bpm)} BPM.`; + return line('tempo', 'Tempo', withDomainHedge(text, quality), confidence, ['bpm', 'bpmConfidence']); +} + +function meterLine(phase1: Phase1Result): BriefLine | null { + if (typeof phase1.timeSignature !== 'string' || phase1.timeSignature.length === 0) return null; + const quality = domainOf(phase1, 'meter'); + const assumed = phase1.timeSignatureSource === 'assumed_four_four'; + const text = assumed + ? `Meter was not measured — ${phase1.timeSignature} is assumed (safe for most electronic tracks).` + : `Measured meter: ${phase1.timeSignature}.`; + // The routine "assumed 4/4" hedge already says everything the meter domain's + // ambiguous plainEnglish would; only layer the domain text on failure. + const hedged = quality?.status === 'failed' ? withDomainHedge(text, quality) : text; + return line('meter', 'Meter', hedged, quality?.confidence ?? null, ['timeSignature']); +} + +function grooveLine(phase1: Phase1Result): BriefLine | null { + const rhythm = phase1.rhythmDetail; + if (!rhythm || !finite(rhythm.onsetRate)) return null; + + const fields = ['rhythmDetail.onsetRate']; + const parts: string[] = [`About ${fmt(rhythm.onsetRate)} rhythmic events per second.`]; + + const kickSwing = phase1.grooveDetail?.kickSwing; + const hihatSwing = phase1.grooveDetail?.hihatSwing; + if (finite(kickSwing) && finite(hihatSwing)) { + const maxSwing = Math.max(kickSwing, hihatSwing); + const feel = maxSwing > 0.3 ? 'a noticeably swung feel — reach for the Groove Pool' : maxSwing > 0.1 ? 'a light swing' : 'a straight, quantized grid'; + parts.push(`The drums have ${feel}.`); + fields.push('grooveDetail.kickSwing', 'grooveDetail.hihatSwing'); + } + + const sidechain = phase1.sidechainDetail; + if (sidechain && sidechain.pumpingRate !== null && finite(sidechain.pumpingConfidence) && sidechain.pumpingConfidence >= 0.5) { + parts.push(`Sidechain-style pumping detected at a ${sidechain.pumpingRate}-note rate.`); + fields.push('sidechainDetail.pumpingRate', 'sidechainDetail.pumpingConfidence'); + } + + const quality = domainOf(phase1, 'beatGrid'); + return line('groove', 'Groove', withDomainHedge(parts.join(' '), quality), quality?.confidence ?? null, fields); +} + +function loudnessLine(phase1: Phase1Result): BriefLine | null { + if (!finite(phase1.lufsIntegrated)) return null; + const fields = ['lufsIntegrated']; + const lufs = phase1.lufsIntegrated; + const character = lufs > -8 ? 'a loud, club-style master' : lufs <= -14 ? 'a conservative, streaming-friendly level' : 'a moderately loud master'; + const parts = [`Integrated loudness is ${fmt(lufs)} LUFS — ${character}.`]; + if (finite(phase1.lufsRange)) { + parts.push(`Loudness range is ${fmt(phase1.lufsRange)} LU.`); + fields.push('lufsRange'); + } + if (finite(phase1.truePeak)) { + parts.push(`True peak ${fmt(phase1.truePeak)} dBTP${phase1.truePeak > 0 ? ' — inter-sample overs; pull the ceiling down' : ''}.`); + fields.push('truePeak'); + } + return line('loudness', 'Loudness', parts.join(' '), null, fields); +} + +function stereoLine(phase1: Phase1Result): BriefLine | null { + if (!finite(phase1.stereoWidth) || !finite(phase1.stereoCorrelation)) return null; + const fields = ['stereoWidth', 'stereoCorrelation']; + const width = phase1.stereoWidth; + const widthWord = width > 0.5 ? 'wide' : width < 0.2 ? 'narrow, mostly mono' : 'moderately wide'; + const parts = [`The stereo image is ${widthWord} (width ${fmt(width, 2)}).`]; + if (phase1.stereoCorrelation < 0) { + parts.push(`L/R correlation is ${fmt(phase1.stereoCorrelation, 2)} — check mono compatibility.`); + } + if (phase1.stereoDetail?.subBassMono === true) { + parts.push('Sub-bass is mono — good for club playback; keep yours mono below ~120 Hz.'); + fields.push('stereoDetail.subBassMono'); + } + return line('stereo', 'Stereo', parts.join(' '), null, fields); +} + +const BAND_LABELS: Array<[key: keyof Phase1Result['spectralBalance'], label: string]> = [ + ['subBass', 'sub bass'], + ['lowBass', 'low bass'], + ['lowMids', 'low mids'], + ['mids', 'mids'], + ['upperMids', 'upper mids'], + ['highs', 'highs'], + ['brilliance', 'brilliance'], +]; + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +function spectralLine(phase1: Phase1Result): BriefLine | null { + const balance = phase1.spectralBalance; + if (!balance) return null; + const entries = BAND_LABELS.filter(([key]) => finite(balance[key])); + if (entries.length === 0) return null; + + const mid = median(entries.map(([key]) => balance[key])); + const emphasized = entries.filter(([key]) => balance[key] - mid >= 3); + const recessed = entries.filter(([key]) => balance[key] - mid <= -3); + + const parts: string[] = []; + const fields: string[] = []; + if (emphasized.length > 0) { + parts.push(`Energy is concentrated in the ${emphasized.map(([, label]) => label).join(', ')}.`); + fields.push(...emphasized.map(([key]) => `spectralBalance.${key}`)); + } + if (recessed.length > 0) { + parts.push(`The ${recessed.map(([, label]) => label).join(', ')} ${recessed.length === 1 ? 'sits' : 'sit'} well below the rest.`); + fields.push(...recessed.map(([key]) => `spectralBalance.${key}`)); + } + if (parts.length === 0) { + parts.push('Spectral energy is fairly even across the bands — no single range dominates.'); + fields.push('spectralBalance.subBass', 'spectralBalance.brilliance'); + } + return line('spectral', 'Spectrum', parts.join(' '), null, fields); +} + +function dynamicsLine(phase1: Phase1Result): BriefLine | null { + // Mirror mixDoctor's precedence: PLR when measured, crest factor as fallback. + if (finite(phase1.plr)) { + const plr = phase1.plr; + const word = plr < 6 ? 'heavily limited — expect a dense, compressed sound' : plr <= 10 ? 'controlled — typical of a finished electronic master' : 'dynamic — transients are left to breathe'; + return line('dynamics', 'Dynamics', `Peak-to-loudness ratio is ${fmt(plr)} dB: ${word}.`, null, ['plr']); + } + if (finite(phase1.crestFactor)) { + const crest = phase1.crestFactor; + const word = crest < 6 ? 'heavily compressed' : crest < 12 ? 'moderately compressed' : 'wide open'; + return line('dynamics', 'Dynamics', `Crest factor is ${fmt(crest)} dB — the dynamics are ${word}.`, null, ['crestFactor']); + } + return null; +} + +export function buildReconstructionBrief(phase1: Phase1Result): BriefLine[] { + return [ + keyLine(phase1), + tempoLine(phase1), + meterLine(phase1), + grooveLine(phase1), + loudnessLine(phase1), + stereoLine(phase1), + spectralLine(phase1), + dynamicsLine(phase1), + ].filter((entry): entry is BriefLine => entry !== null); +} diff --git a/apps/ui/tests/services/reconstructionBrief.test.ts b/apps/ui/tests/services/reconstructionBrief.test.ts new file mode 100644 index 00000000..7852123f --- /dev/null +++ b/apps/ui/tests/services/reconstructionBrief.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from 'vitest'; +import type { Phase1Result } from '../../src/types'; +import { buildReconstructionBrief } from '../../src/services/reconstructionBrief'; + +// Minimal valid Phase1Result (mirrors the factory in notableFindings.test.ts). +const makePhase1 = (overrides: Partial = {}): Phase1Result => ({ + bpm: 126, bpmConfidence: 0.91, key: 'F minor', keyConfidence: 0.87, + timeSignature: '4/4', timeSignatureSource: 'assumed_four_four', durationSeconds: 210.6, + lufsIntegrated: -9.0, lufsRange: 6.0, truePeak: -1.0, crestFactor: 11.0, plr: 7.9, + stereoWidth: 0.6, stereoCorrelation: 0.6, + stereoDetail: { stereoWidth: 0.6, stereoCorrelation: 0.6, subBassMono: true }, + spectralBalance: { subBass: 0, lowBass: 0, lowMids: 0, mids: 0, upperMids: 0, highs: 0, brilliance: 0 }, + rhythmDetail: { + onsetRate: 4.2, beatGrid: [0, 0.5], downbeats: [0], beatPositions: [0, 0.5], grooveAmount: 0.4, + }, + grooveDetail: { kickSwing: 0.12, hihatSwing: 0.31, kickAccent: [], hihatAccent: [] }, + sidechainDetail: { + pumpingStrength: 0.65, pumpingRegularity: 0.8, pumpingRate: 'quarter', pumpingConfidence: 0.31, + }, + ...overrides, +}); + +const dom = (status: string, plainEnglish: string, confidence: number | null = null) => + ({ status, plainEnglish, source: null, confidence, evidence: {} }); + +const fq = (domains: Record) => + ({ + schemaVersion: 'fundamentals-quality.v1', targetProfile: 'electronic_ableton_v1', + analysisMode: 'full', localOnly: true, llmExcluded: true, overallStatus: 'ambiguous', domains, + } as unknown as NonNullable); + +describe('buildReconstructionBrief', () => { + it('produces all 8 lines from a full payload, each with at least one citation', () => { + const lines = buildReconstructionBrief(makePhase1()); + expect(lines.map((l) => l.domain)).toEqual([ + 'key', 'tempo', 'meter', 'groove', 'loudness', 'stereo', 'spectral', 'dynamics', + ]); + for (const line of lines) { + expect(line.phase1Fields.length).toBeGreaterThanOrEqual(1); + expect(line.text.length).toBeGreaterThan(0); + } + }); + + it('omits groove/stereo/spectral/dynamics lines on a fast-mode-shaped payload', () => { + const lines = buildReconstructionBrief(makePhase1({ + rhythmDetail: null, + grooveDetail: null, + sidechainDetail: null, + spectralBalance: null as unknown as Phase1Result['spectralBalance'], + stereoWidth: NaN, + stereoCorrelation: NaN, + stereoDetail: null, + plr: null, + crestFactor: null, + })); + expect(lines.map((l) => l.domain)).toEqual(['key', 'tempo', 'meter', 'loudness']); + }); + + it('omits the key line entirely when key is null instead of guessing', () => { + const lines = buildReconstructionBrief(makePhase1({ key: null })); + expect(lines.find((l) => l.domain === 'key')).toBeUndefined(); + }); + + it('hedges the key line at the approximate threshold (keyConfidence <= 0.6)', () => { + const hedged = buildReconstructionBrief(makePhase1({ keyConfidence: 0.6 })); + expect(hedged.find((l) => l.domain === 'key')?.text).toContain('approximate'); + const confident = buildReconstructionBrief(makePhase1({ keyConfidence: 0.61 })); + expect(confident.find((l) => l.domain === 'key')?.text).not.toContain('approximate'); + }); + + it('reuses the fundamentals domain plainEnglish verbatim when non-authoritative', () => { + const phase1 = makePhase1({ + fundamentalsQuality: fq({ + tempo: dom('ambiguous', 'Two tempo detectors disagree; the grid may be half-time.', 0.4), + }), + }); + const tempo = buildReconstructionBrief(phase1).find((l) => l.domain === 'tempo'); + expect(tempo?.text).toContain('Two tempo detectors disagree; the grid may be half-time.'); + expect(tempo?.confidence).toBe(0.4); + expect(tempo?.band?.id).toBe('rough'); + }); + + it('does not append plainEnglish for authoritative domains', () => { + const phase1 = makePhase1({ + fundamentalsQuality: fq({ tempo: dom('authoritative', 'Tempo was measured locally.', 0.98) }), + }); + const tempo = buildReconstructionBrief(phase1).find((l) => l.domain === 'tempo'); + expect(tempo?.text).toBe('Set your Live set to 126.0 BPM.'); + expect(tempo?.confidence).toBe(0.98); + }); + + it('marks the meter as assumed when timeSignatureSource is assumed_four_four', () => { + const meter = buildReconstructionBrief(makePhase1()).find((l) => l.domain === 'meter'); + expect(meter?.text).toContain('assumed'); + const measured = buildReconstructionBrief(makePhase1({ timeSignatureSource: 'measured' })) + .find((l) => l.domain === 'meter'); + expect(measured?.text).toBe('Measured meter: 4/4.'); + }); + + it('describes swing qualitatively and skips low-confidence sidechain pumping', () => { + const groove = buildReconstructionBrief(makePhase1()).find((l) => l.domain === 'groove'); + // max(kickSwing 0.12, hihatSwing 0.31) > 0.3 -> noticeably swung + expect(groove?.text).toContain('noticeably swung'); + // pumpingConfidence 0.31 < 0.5 -> no pumping claim + expect(groove?.text).not.toContain('pumping'); + + const pumping = buildReconstructionBrief(makePhase1({ + sidechainDetail: { + pumpingStrength: 0.65, pumpingRegularity: 0.8, pumpingRate: 'quarter', pumpingConfidence: 0.8, + }, + })).find((l) => l.domain === 'groove'); + expect(pumping?.text).toContain('pumping'); + expect(pumping?.phase1Fields).toContain('sidechainDetail.pumpingRate'); + }); + + it('cites lufsRange and truePeak only when present, and flags inter-sample overs', () => { + const loudness = buildReconstructionBrief(makePhase1()).find((l) => l.domain === 'loudness'); + expect(loudness?.phase1Fields).toEqual(['lufsIntegrated', 'lufsRange', 'truePeak']); + + const overs = buildReconstructionBrief(makePhase1({ truePeak: 0.3 })) + .find((l) => l.domain === 'loudness'); + expect(overs?.text).toContain('inter-sample overs'); + + const bare = buildReconstructionBrief(makePhase1({ lufsRange: null, truePeak: null })) + .find((l) => l.domain === 'loudness'); + expect(bare?.phase1Fields).toEqual(['lufsIntegrated']); + }); + + it('names ±3 dB-from-median outlier bands with per-band citations', () => { + const lines = buildReconstructionBrief(makePhase1({ + spectralBalance: { subBass: 6, lowBass: 0, lowMids: 0, mids: 0, upperMids: 0, highs: -4, brilliance: 0 }, + })); + const spectral = lines.find((l) => l.domain === 'spectral'); + expect(spectral?.text).toContain('sub bass'); + expect(spectral?.text).toContain('highs'); + expect(spectral?.phase1Fields).toContain('spectralBalance.subBass'); + expect(spectral?.phase1Fields).toContain('spectralBalance.highs'); + }); + + it('describes an even spectrum when no band is a ±3 dB outlier', () => { + const spectral = buildReconstructionBrief(makePhase1()).find((l) => l.domain === 'spectral'); + expect(spectral?.text).toContain('fairly even'); + expect(spectral?.phase1Fields.length).toBeGreaterThanOrEqual(1); + }); + + it('prefers PLR for dynamics and falls back to crest factor', () => { + const plr = buildReconstructionBrief(makePhase1()).find((l) => l.domain === 'dynamics'); + expect(plr?.phase1Fields).toEqual(['plr']); + expect(plr?.text).toContain('controlled'); + + const crest = buildReconstructionBrief(makePhase1({ plr: null })) + .find((l) => l.domain === 'dynamics'); + expect(crest?.phase1Fields).toEqual(['crestFactor']); + expect(crest?.text).toContain('moderately compressed'); + }); + + it('notes mono sub-bass with the stereoDetail citation', () => { + const stereo = buildReconstructionBrief(makePhase1()).find((l) => l.domain === 'stereo'); + expect(stereo?.text).toContain('Sub-bass is mono'); + expect(stereo?.phase1Fields).toContain('stereoDetail.subBassMono'); + }); +}); From 1a5e6ca92778c072f357f1c8742687fff6f59506 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 03:06:23 +0000 Subject: [PATCH 7/8] feat(ui): deterministic Live 12 fallback when Phase 2 is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires abletonDevices.ts into the product as a clearly-labeled 'Deterministic Baseline · NO AI' section, reversing the 2026-06-11 research-only demotion (owner-approved 2026-07-02). - deterministicRecommendations.ts projects the real Phase 1 payload into the engine's AudioFeatures (median-relative band dominance, NaN sentinels for fast-mode nulls, bpmConfidence clamp for pre-v2 snapshots) and attaches per-card Phase 1 citations (invariant #2) via maps keyed on the engine's exact rule strings — unmapped rules are dropped, never rendered uncited, and coverage tests sweep all 8 FX rules + 5 sauce tricks so engine drift fails loudly. - Cards citing weak fundamentals are hedged with the fundamentalsQuality domain's plainEnglish or skipped on failure (invariant #4). - shouldShowDeterministicFallback gates the section to settled, phase2-empty runs only (off / failed / interrupted / terminally empty) — never mid-run, never alongside real Phase 2 output. - Eval bridge emit_deterministic_recs.ts unchanged; abletonDevices.ts stays import-pure. Docs updated (module header, NEEDS.md reversal note, CLAUDE.md Backport Candidates). - Smoke: phase2-degradation.spec.ts asserts fallback presence on config-off / user-off / failed and absence when Phase 2 renders. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01T4wfz87k6kzJqkKLKZE7YL --- CLAUDE.md | 2 +- apps/backend/NEEDS.md | 11 + apps/ui/src/components/AnalysisResults.tsx | 6 + .../DeterministicAdviceSection.tsx | 54 +++ apps/ui/src/data/abletonDevices.ts | 22 +- .../services/deterministicRecommendations.ts | 309 ++++++++++++++++++ .../deterministicRecommendations.test.ts | 257 +++++++++++++++ .../ui/tests/smoke/phase2-degradation.spec.ts | 7 + 8 files changed, 657 insertions(+), 11 deletions(-) create mode 100644 apps/ui/src/components/analysisResults/DeterministicAdviceSection.tsx create mode 100644 apps/ui/src/services/deterministicRecommendations.ts create mode 100644 apps/ui/tests/services/deterministicRecommendations.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7bf234b4..45fd52d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -445,7 +445,7 @@ A quick map from intent to the right place to start: ## Backport Candidates -The full original `sonic-architect-app` backlog is **shipped** — genre profiles, Ableton device mappings (`abletonDevices.ts` — ported but **research-only** since the 2026-06-11 demotion decision: a scoring baseline for the recommendation-proof harness, imported by no product code; see the NEEDS-WIRING decision in `apps/backend/NEEDS.md`), mix doctor, eight detectors, Phase 3 audition-sample generation, and `patchSmith.ts` (Phase 3 Vital preset generation, [`apps/ui/src/services/patchSmith.ts`](apps/ui/src/services/patchSmith.ts)). See [`BACKLOG.md`](BACKLOG.md) for the landed inventory. Consult it before re-implementing genre detection, mix analysis, acid/reverb/vocal/supersaw/bass/kick detection — they're already in [`apps/backend/analyze_detection.py`](apps/backend/analyze_detection.py) and emit fields visible in `EXPECTED_TOP_LEVEL_KEYS`. +The full original `sonic-architect-app` backlog is **shipped** — genre profiles, Ableton device mappings (`abletonDevices.ts` — **product-wired 2026-07-02** as the Phase-2-off deterministic fallback via `src/services/deterministicRecommendations.ts` (cited + hedged, renders only when interpretation is unavailable), reversing the 2026-06-11 research-only demotion; the eval bridge that scores it as the harness baseline is unchanged — see `apps/backend/NEEDS.md`), mix doctor, eight detectors, Phase 3 audition-sample generation, and `patchSmith.ts` (Phase 3 Vital preset generation, [`apps/ui/src/services/patchSmith.ts`](apps/ui/src/services/patchSmith.ts)). See [`BACKLOG.md`](BACKLOG.md) for the landed inventory. Consult it before re-implementing genre detection, mix analysis, acid/reverb/vocal/supersaw/bass/kick detection — they're already in [`apps/backend/analyze_detection.py`](apps/backend/analyze_detection.py) and emit fields visible in `EXPECTED_TOP_LEVEL_KEYS`. ## Companion Agent Docs diff --git a/apps/backend/NEEDS.md b/apps/backend/NEEDS.md index 1355d406..46c5ea4c 100644 --- a/apps/backend/NEEDS.md +++ b/apps/backend/NEEDS.md @@ -188,6 +188,17 @@ resolved the wire-or-demote choice: **demote**. Standing consequences: change (it remains a legitimate baseline-fairness tweak if the comparison ever needs it). +**⤴ REVERSED 2026-07-02 (owner-approved): wired as the Phase-2-off fallback.** +`apps/ui/src/services/deterministicRecommendations.ts` now projects the real +Phase 1 payload into `AudioFeatures` (the render-gated projection flagged +above — it lives there, not in `analyzer.ts`), attaches per-card Phase 1 +citations, and hedges/skips cards whose fundamentals are weak; +`DeterministicAdviceSection` renders it **only** when Phase 2 interpretation +is unavailable (off, failed, interrupted, or terminally empty), labeled +"Deterministic Baseline · NO AI". The eval bridge and its uncited-by-design +scoring are unchanged. Standing consequence #2 stands for the Phase-2-on +path: when interpretation runs, its output is the product surface. + --- ## Sub-goal 3 — the Gemini verdict diff --git a/apps/ui/src/components/AnalysisResults.tsx b/apps/ui/src/components/AnalysisResults.tsx index 7288f335..4e323d33 100644 --- a/apps/ui/src/components/AnalysisResults.tsx +++ b/apps/ui/src/components/AnalysisResults.tsx @@ -46,6 +46,8 @@ import { import { Collapsible, textRoleClassName, type StyleProfileSectionState } from './analysisResults/shared'; import { NotableFindingsSection } from './analysisResults/NotableFindingsSection'; import { ReconstructionBriefSection } from './analysisResults/ReconstructionBriefSection'; +import { DeterministicAdviceSection } from './analysisResults/DeterministicAdviceSection'; +import { shouldShowDeterministicFallback } from '../services/deterministicRecommendations'; import { MeasurementSummarySection } from './analysisResults/MeasurementSummarySection'; import { AudioObservationsSection } from './analysisResults/AudioObservationsSection'; import { ProjectSetupSection } from './analysisResults/ProjectSetupSection'; @@ -551,6 +553,10 @@ export function AnalysisResults({ hasRenderablePhase2Content={hasRenderablePhase2Content} /> + {shouldShowDeterministicFallback(phase2, interpretationStatus) && ( + + )} + {phase2ConsistencyReport && phase2ConsistencyReport.violations.some((v) => v.audience !== 'dev') && (
+ + NO AI + + } + > +
+

+ Deterministic mapping from measured spectrum and dynamics — not AI + interpretation. Enable AI interpretation (Phase 2) for richer, + track-specific advice. +

+
    + {cards.map((card) => ( +
  • +
    + + {card.device} + + {card.title} +
    +

    {card.detail}

    + {card.hedges.map((hedge) => ( +

    + {hedge} +

    + ))} + +
  • + ))} +
+
+
+
+ ); +} diff --git a/apps/ui/src/data/abletonDevices.ts b/apps/ui/src/data/abletonDevices.ts index bb25559b..c5a09b26 100644 --- a/apps/ui/src/data/abletonDevices.ts +++ b/apps/ui/src/data/abletonDevices.ts @@ -4,16 +4,18 @@ * Maps spectral characteristics to specific Ableton device recommendations. * This is deterministic — no hallucination possible. * - * RESEARCH-ONLY BASELINE — not on the product path (owner decision, 2026-06-11). - * No UI component or service imports this module; product recommendations come - * from the Phase 2 interpretation providers. Its sole consumer is the eval - * bridge `apps/backend/scripts/emit_deterministic_recs.ts`, which scores it as - * the free deterministic baseline in GOAL.md sub-goal 3's three-source - * comparison. Do NOT wire it into the product, and land score-driven *product* - * improvements on the Phase 2 path instead — raising this module's harness - * score improves code no user runs (see the NEEDS-WIRING decision in - * apps/backend/NEEDS.md). Keep it import-pure: the bridge runs it under Node - * native type-stripping with no build step. + * PRODUCT-WIRED as the Phase-2-off fallback (owner decision, 2026-07-02, + * reversing the 2026-06-11 research-only demotion). The product consumer is + * `src/services/deterministicRecommendations.ts`, which owns the Phase 1 → + * AudioFeatures projection, attaches per-card Phase 1 citations, and hedges + * or skips cards whose driving measurement is weak; it renders only when + * Phase 2 interpretation is unavailable (see `DeterministicAdviceSection`). + * The eval bridge `apps/backend/scripts/emit_deterministic_recs.ts` still + * scores this module as the free deterministic baseline in GOAL.md sub-goal + * 3's comparison and is unchanged (uncited by design). Score-driven *product* + * improvements still land on the Phase 2 path (apps/backend/NEEDS.md). + * Keep it import-pure: the bridge runs it under Node native type-stripping + * with no build step. */ export interface DeviceRecommendation { diff --git a/apps/ui/src/services/deterministicRecommendations.ts b/apps/ui/src/services/deterministicRecommendations.ts new file mode 100644 index 00000000..3e950054 --- /dev/null +++ b/apps/ui/src/services/deterministicRecommendations.ts @@ -0,0 +1,309 @@ +import { + getFXRecommendations, + getInstrumentRecommendations, + getSecretSauce, + type AudioFeatures, + type SpectralBandEnergy, +} from '../data/abletonDevices'; +import type { Phase1Result, Phase2Result } from '../types'; +import type { AnalysisStageStatus } from '../types/backend'; + +// Product adapter for the deterministic Live 12 recommendation engine +// (`src/data/abletonDevices.ts`) — the Phase-2-off fallback wired by the +// 2026-07-02 reversal of the 2026-06-11 demotion decision. Unlike the eval +// bridge (`apps/backend/scripts/emit_deterministic_recs.ts`, uncited by +// design), every card emitted here carries the Phase 1 field(s) that +// triggered it (PURPOSE.md invariant #2); a card whose driving measurement +// is weak is hedged or skipped (invariant #4), and a card whose trigger has +// no citation mapping is dropped rather than rendered uncited. + +export interface DeterministicCard { + id: string; + kind: 'instrument' | 'fx' | 'secretSauce'; + /** Exact Live 12 device the card is anchored on. */ + device: string; + title: string; + detail: string; + /** Phase 1 citation paths — always at least one. */ + phase1Fields: string[]; + /** Invariant-#4 disclosures appended when a driving measurement is weak. */ + hedges: string[]; +} + +/** + * Show the fallback only once interpretation is settled and produced nothing + * displayable — never while Phase 2 is pending, so the section doesn't flash + * mid-run and vanish when recommendations land. `completed` with a null + * phase2 means interpretation terminally produced nothing renderable. + */ +export function shouldShowDeterministicFallback( + phase2: Phase2Result | null, + interpretationStatus: AnalysisStageStatus | null | undefined, +): boolean { + if (phase2) return false; + return ( + interpretationStatus == null || + interpretationStatus === 'not_requested' || + interpretationStatus === 'failed' || + interpretationStatus === 'interrupted' || + interpretationStatus === 'completed' + ); +} + +function finite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +// Engine band vocabulary ↔ spectralBalance camelCase keys and JSON_SCHEMA.md +// band edges. Order matches the backend's 7-band contract. +const BANDS: Array<{ name: string; key: keyof Phase1Result['spectralBalance']; rangeHz: [number, number] }> = [ + { name: 'Sub Bass', key: 'subBass', rangeHz: [20, 80] }, + { name: 'Low Bass', key: 'lowBass', rangeHz: [80, 250] }, + { name: 'Low Mids', key: 'lowMids', rangeHz: [250, 500] }, + { name: 'Mids', key: 'mids', rangeHz: [500, 2000] }, + { name: 'Upper Mids', key: 'upperMids', rangeHz: [2000, 5000] }, + { name: 'Highs', key: 'highs', rangeHz: [5000, 10000] }, + { name: 'Brilliance', key: 'brilliance', rangeHz: [10000, 20000] }, +]; + +const BAND_KEY_BY_NAME = new Map(BANDS.map((b) => [b.name, b.key])); + +// spectralBalance is dB *relative* to an arbitrary reference (JSON_SCHEMA.md), +// so dominance is judged against the median of the 7 bands rather than any +// absolute level: ≥ +3 dB over median is a defining element, within 12 dB is +// present, further down is absent. +const DOMINANT_OVER_MEDIAN_DB = 3; +const ABSENT_UNDER_MEDIAN_DB = -12; + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +export function classifySpectralBands(phase1: Phase1Result): SpectralBandEnergy[] { + const balance = phase1.spectralBalance; + if (!balance) return []; + const present = BANDS.filter(({ key }) => finite(balance[key])); + if (present.length === 0) return []; + const mid = median(present.map(({ key }) => balance[key])); + + return present.map(({ name, key, rangeHz }) => { + const averageDb = Math.round(balance[key] * 10) / 10; + const series = phase1.spectralBalanceTimeSeries; + const seriesValues = (series ?? []) + .map((point) => point[key]) + .filter((value): value is number => finite(value)); + const peakDb = seriesValues.length > 0 + ? Math.round(Math.max(...seriesValues) * 10) / 10 + : averageDb; + const delta = balance[key] - mid; + const dominance: SpectralBandEnergy['dominance'] = + delta >= DOMINANT_OVER_MEDIAN_DB ? 'dominant' : delta >= ABSENT_UNDER_MEDIAN_DB ? 'present' : 'absent'; + return { name, rangeHz, averageDb, peakDb, dominance }; + }); +} + +export function projectAudioFeatures(phase1: Phase1Result): AudioFeatures { + // NaN sentinels make the engine's numeric comparisons all fail on + // fast-mode payloads that null these out, cleanly skipping those rules. + const keyText = typeof phase1.key === 'string' ? phase1.key.trim() : ''; + const spaceIndex = keyText.indexOf(' '); + const root = spaceIndex > 0 ? keyText.slice(0, spaceIndex) : keyText; + const scale = spaceIndex > 0 ? keyText.slice(spaceIndex + 1).toLowerCase() : ''; + + return { + bpm: phase1.bpm, + key: { root: root || '?', scale }, + crestFactor: finite(phase1.crestFactor) ? phase1.crestFactor : NaN, + onsetDensity: finite(phase1.rhythmDetail?.onsetRate) ? phase1.rhythmDetail.onsetRate : NaN, + duration: phase1.durationSeconds, + bpmConfidence: finite(phase1.bpmConfidence) ? Math.min(1, Math.max(0, phase1.bpmConfidence)) : 0, + spectralBands: classifySpectralBands(phase1), + spectralCentroidMean: finite(phase1.spectralDetail?.spectralCentroidMean) + ? phase1.spectralDetail.spectralCentroidMean + : NaN, + }; +} + +// Citation maps keyed on the engine's exact rule-output strings. An engine +// rule with no entry here produces an UNCITED card, which we refuse to render +// — the coverage tests in deterministicRecommendations.test.ts sweep every +// rule so drift fails loudly instead of silently dropping advice. +const FX_ARTIFACT_MAP: Record = { + 'Heavy dynamic compression detected (low crest factor)': { + slug: 'crest-low', device: 'Glue Compressor', fields: ['crestFactor'], + }, + 'Moderate compression / controlled dynamics': { + slug: 'crest-moderate', device: 'Compressor', fields: ['crestFactor'], + }, + 'High dynamic range — minimal compression': { + slug: 'crest-high', device: 'Glue Compressor', fields: ['crestFactor'], + }, + 'Dominant sub-bass energy detected': { + slug: 'sub-dominant', device: 'Saturator', fields: ['spectralBalance.subBass'], + }, + 'Bright overall spectrum — high spectral centroid': { + slug: 'bright', device: 'EQ Eight', fields: ['spectralDetail.spectralCentroidMean'], + }, + 'Dark overall spectrum — low spectral centroid': { + slug: 'dark', device: 'EQ Eight', fields: ['spectralDetail.spectralCentroidMean'], + }, + 'High rhythmic density detected (many transients)': { + slug: 'dense', device: 'Drum Buss', fields: ['rhythmDetail.onsetRate'], + }, + 'Low transient density — sustained/ambient content': { + slug: 'sparse', device: 'Reverb', fields: ['rhythmDetail.onsetRate', 'durationSeconds'], + }, +}; + +// Sauce tricks embed dynamic values ("… at 128 BPM"), so match on prefix. +const SAUCE_TRICK_MAP: Array<{ prefix: string; device: string; fields: (phase1: Phase1Result, bands: SpectralBandEnergy[]) => string[] }> = [ + { + prefix: 'Wall-of-Sound Layering', + device: 'Glue Compressor', + fields: (_phase1, bands) => [ + ...bands.filter((b) => b.dominance === 'dominant') + .map((b) => `spectralBalance.${BAND_KEY_BY_NAME.get(b.name) ?? ''}`) + .filter((f) => !f.endsWith('.')), + 'crestFactor', + ], + }, + { + prefix: 'Tight Rhythmic Programming', + device: 'Drum Rack', + fields: () => ['rhythmDetail.onsetRate', 'bpm', 'bpmConfidence'], + }, + { + prefix: 'Air and Presence Engineering', + device: 'EQ Eight', + fields: () => ['spectralDetail.spectralCentroidMean', 'spectralBalance.brilliance'], + }, + { + prefix: 'Sub Bass Design and Low-End Control', + device: 'Operator', + fields: () => ['spectralBalance.subBass'], + }, + { + prefix: 'Balanced Mix Architecture', + device: 'EQ Eight', + fields: () => ['bpm', 'key'], + }, +]; + +interface FundamentalHedge { + hedge: string | null; + skip: boolean; +} + +function fundamentalHedge( + phase1: Phase1Result, + domainKey: 'tempo' | 'key', + scalarConfidence: number | null, +): FundamentalHedge { + const domain = phase1.fundamentalsQuality?.domains?.[domainKey]; + if (domain?.status === 'failed') return { hedge: null, skip: true }; + const weakScalar = scalarConfidence !== null && scalarConfidence <= 0.6; + const weakDomain = domain != null && domain.status !== 'authoritative'; + if (weakDomain && domain.plainEnglish) return { hedge: domain.plainEnglish, skip: false }; + if (weakScalar) { + return { + hedge: domainKey === 'tempo' + ? 'Tempo confidence is low — verify the BPM by ear before building on this.' + : 'Key confidence is low — confirm the key by ear before committing.', + skip: false, + }; + } + return { hedge: null, skip: false }; +} + +function cardHedges(phase1: Phase1Result, fields: string[]): { hedges: string[]; skip: boolean } { + const hedges: string[] = []; + if (fields.includes('bpm') || fields.includes('bpmConfidence')) { + const { hedge, skip } = fundamentalHedge( + phase1, + 'tempo', + finite(phase1.bpmConfidence) ? phase1.bpmConfidence : null, + ); + if (skip) return { hedges, skip: true }; + if (hedge) hedges.push(hedge); + } + if (fields.includes('key')) { + if (typeof phase1.key !== 'string' || phase1.key.length === 0) return { hedges, skip: true }; + const { hedge, skip } = fundamentalHedge( + phase1, + 'key', + finite(phase1.keyConfidence) ? phase1.keyConfidence : null, + ); + if (skip) return { hedges, skip: true }; + if (hedge) hedges.push(hedge); + } + return { hedges, skip: false }; +} + +export function buildDeterministicAdvice(phase1: Phase1Result): DeterministicCard[] { + const features = projectAudioFeatures(phase1); + const cards: DeterministicCard[] = []; + + for (const inst of getInstrumentRecommendations(features.spectralBands)) { + const bandName = inst.element.replace(/ Element$/, ''); + const bandKey = BAND_KEY_BY_NAME.get(bandName); + if (!bandKey) continue; // unknown band — refuse to render uncited + const [device] = inst.abletonDevice.split(' — '); + const fields = [`spectralBalance.${bandKey}`]; + if ((phase1.spectralBalanceTimeSeries?.length ?? 0) > 0) { + fields.push('spectralBalanceTimeSeries'); + } + cards.push({ + id: `det.band.${bandKey}`, + kind: 'instrument', + device: device?.trim() ?? inst.abletonDevice, + title: `${inst.element} · ${inst.frequency}`, + detail: `${inst.timbre} ${inst.abletonDevice}`, + phase1Fields: fields, + hedges: [], + }); + } + + for (const fx of getFXRecommendations(features)) { + const mapping = FX_ARTIFACT_MAP[fx.artifact]; + if (!mapping) continue; // unmapped rule — refuse to render uncited + cards.push({ + id: `det.fx.${mapping.slug}`, + kind: 'fx', + device: mapping.device, + title: fx.artifact, + detail: fx.recommendation, + phase1Fields: mapping.fields, + hedges: [], + }); + } + + const sauce = getSecretSauce(features); + const sauceMapping = SAUCE_TRICK_MAP.find((entry) => sauce.trick.startsWith(entry.prefix)); + if (sauceMapping) { + const fields = sauceMapping.fields(phase1, features.spectralBands); + const { hedges, skip } = cardHedges(phase1, fields); + if (!skip && fields.length > 0) { + cards.push({ + id: 'det.sauce', + kind: 'secretSauce', + device: sauceMapping.device, + title: sauce.trick, + detail: sauce.execution, + phase1Fields: fields, + hedges, + }); + } + } + + // Apply tempo/key hedges to the non-sauce cards that cite them (none today + // — FX/instrument rules are spectrum/dynamics-driven — but the pass keeps + // the invariant local if a future rule cites a fundamental). + return cards.map((card) => { + if (card.kind === 'secretSauce') return card; + const { hedges, skip } = cardHedges(phase1, card.phase1Fields); + if (skip) return null; + return hedges.length > 0 ? { ...card, hedges: [...card.hedges, ...hedges] } : card; + }).filter((card): card is DeterministicCard => card !== null); +} diff --git a/apps/ui/tests/services/deterministicRecommendations.test.ts b/apps/ui/tests/services/deterministicRecommendations.test.ts new file mode 100644 index 00000000..3dc54aea --- /dev/null +++ b/apps/ui/tests/services/deterministicRecommendations.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect } from 'vitest'; +import type { Phase1Result, Phase2Result } from '../../src/types'; +import type { AnalysisStageStatus } from '../../src/types/backend'; +import { + buildDeterministicAdvice, + classifySpectralBands, + projectAudioFeatures, + shouldShowDeterministicFallback, +} from '../../src/services/deterministicRecommendations'; + +const makePhase1 = (overrides: Partial = {}): Phase1Result => ({ + bpm: 126, bpmConfidence: 0.91, key: 'F minor', keyConfidence: 0.87, + timeSignature: '4/4', durationSeconds: 210.6, + lufsIntegrated: -9.0, lufsRange: 6.0, truePeak: -1.0, crestFactor: 11.0, + stereoWidth: 0.6, stereoCorrelation: 0.6, + spectralBalance: { subBass: 0, lowBass: 0, lowMids: 0, mids: 0, upperMids: 0, highs: 0, brilliance: 0 }, + spectralDetail: { spectralCentroidMean: 2000 }, + rhythmDetail: { + onsetRate: 4.0, beatGrid: [0, 0.5], downbeats: [0], beatPositions: [0, 0.5], grooveAmount: 0.4, + }, + ...overrides, +}); + +const dom = (status: string, plainEnglish: string, confidence: number | null = null) => + ({ status, plainEnglish, source: null, confidence, evidence: {} }); + +const fq = (domains: Record) => + ({ + schemaVersion: 'fundamentals-quality.v1', targetProfile: 'electronic_ableton_v1', + analysisMode: 'full', localOnly: true, llmExcluded: true, overallStatus: 'ambiguous', domains, + } as unknown as NonNullable); + +describe('shouldShowDeterministicFallback', () => { + const statuses: AnalysisStageStatus[] = [ + 'queued', 'running', 'blocked', 'ready', 'completed', 'failed', 'interrupted', 'not_requested', + ]; + const expectedWithoutPhase2: Record = { + queued: false, running: false, blocked: false, ready: false, + completed: true, failed: true, interrupted: true, not_requested: true, + }; + + it('matches the truth table when phase2 is null', () => { + for (const status of statuses) { + expect(shouldShowDeterministicFallback(null, status), status).toBe(expectedWithoutPhase2[status]); + } + expect(shouldShowDeterministicFallback(null, null)).toBe(true); + expect(shouldShowDeterministicFallback(null, undefined)).toBe(true); + }); + + it('is always false when phase2 is present', () => { + const phase2 = { trackCharacter: 'x' } as unknown as Phase2Result; + for (const status of statuses) { + expect(shouldShowDeterministicFallback(phase2, status), status).toBe(false); + } + expect(shouldShowDeterministicFallback(phase2, null)).toBe(false); + }); +}); + +describe('classifySpectralBands', () => { + it('maps the 7 bands with JSON_SCHEMA.md ranges and median-relative dominance', () => { + const bands = classifySpectralBands(makePhase1({ + spectralBalance: { subBass: 4, lowBass: 0, lowMids: 0, mids: 0, upperMids: 0, highs: -13, brilliance: 0 }, + })); + expect(bands).toHaveLength(7); + expect(bands[0]).toMatchObject({ name: 'Sub Bass', rangeHz: [20, 80], dominance: 'dominant' }); + expect(bands.find((b) => b.name === 'Highs')?.dominance).toBe('absent'); + expect(bands.find((b) => b.name === 'Mids')).toMatchObject({ rangeHz: [500, 2000], dominance: 'present' }); + expect(bands.find((b) => b.name === 'Brilliance')?.rangeHz).toEqual([10000, 20000]); + }); + + it('returns [] on a fast-mode payload with null spectralBalance', () => { + expect(classifySpectralBands(makePhase1({ + spectralBalance: null as unknown as Phase1Result['spectralBalance'], + }))).toEqual([]); + }); + + it('takes peakDb from spectralBalanceTimeSeries when present, else averageDb', () => { + const noSeries = classifySpectralBands(makePhase1()); + expect(noSeries[0].peakDb).toBe(noSeries[0].averageDb); + + const withSeries = classifySpectralBands(makePhase1({ + spectralBalanceTimeSeries: [ + { t: 0, subBass: 2.5, lowBass: 0, lowMids: 0, mids: 0, upperMids: 0, highs: 0, brilliance: 0 }, + { t: 1, subBass: 5.5, lowBass: 0, lowMids: 0, mids: 0, upperMids: 0, highs: 0, brilliance: 0 }, + ], + })); + expect(withSeries[0].peakDb).toBe(5.5); + }); +}); + +describe('projectAudioFeatures', () => { + it('splits the Phase 1 key string into root and lowercased scale', () => { + expect(projectAudioFeatures(makePhase1({ key: 'A Minor' })).key).toEqual({ root: 'A', scale: 'minor' }); + expect(projectAudioFeatures(makePhase1({ key: null })).key).toEqual({ root: '?', scale: '' }); + }); + + it('uses NaN sentinels for absent fast-mode measurements', () => { + const features = projectAudioFeatures(makePhase1({ + crestFactor: null, rhythmDetail: null, spectralDetail: null, + })); + expect(Number.isNaN(features.crestFactor)).toBe(true); + expect(Number.isNaN(features.onsetDensity)).toBe(true); + expect(Number.isNaN(features.spectralCentroidMean)).toBe(true); + }); + + it('clamps pre-v2 raw bpmConfidence into 0-1', () => { + expect(projectAudioFeatures(makePhase1({ bpmConfidence: 4.2 })).bpmConfidence).toBe(1); + }); +}); + +describe('buildDeterministicAdvice', () => { + it('cites every card with at least one Phase 1 field', () => { + const cards = buildDeterministicAdvice(makePhase1()); + expect(cards.length).toBeGreaterThan(0); + for (const card of cards) { + expect(card.phase1Fields.length, card.id).toBeGreaterThanOrEqual(1); + expect(card.device.length, card.id).toBeGreaterThan(0); + } + }); + + it('covers all 8 FX rules with citations (engine drift guard)', () => { + // Drive features through every FX_RULES branch and assert each fired + // rule produced a cited card — an unmapped artifact would be dropped. + const scenarios: Array<{ phase1: Phase1Result; slug: string }> = [ + { phase1: makePhase1({ crestFactor: 4 }), slug: 'det.fx.crest-low' }, + { phase1: makePhase1({ crestFactor: 8 }), slug: 'det.fx.crest-moderate' }, + { phase1: makePhase1({ crestFactor: 13 }), slug: 'det.fx.crest-high' }, + { + phase1: makePhase1({ + spectralBalance: { subBass: 5, lowBass: 0, lowMids: 0, mids: 0, upperMids: 0, highs: 0, brilliance: 0 }, + }), + slug: 'det.fx.sub-dominant', + }, + { phase1: makePhase1({ spectralDetail: { spectralCentroidMean: 3500 } }), slug: 'det.fx.bright' }, + { phase1: makePhase1({ spectralDetail: { spectralCentroidMean: 800 } }), slug: 'det.fx.dark' }, + { + phase1: makePhase1({ + rhythmDetail: { onsetRate: 9, beatGrid: [], downbeats: [], beatPositions: [], grooveAmount: 0 }, + }), + slug: 'det.fx.dense', + }, + { + phase1: makePhase1({ + rhythmDetail: { onsetRate: 1, beatGrid: [], downbeats: [], beatPositions: [], grooveAmount: 0 }, + }), + slug: 'det.fx.sparse', + }, + ]; + for (const { phase1, slug } of scenarios) { + const cards = buildDeterministicAdvice(phase1); + expect(cards.map((c) => c.id), slug).toContain(slug); + } + }); + + it('covers all 5 secret-sauce tricks with citations (engine drift guard)', () => { + const wall = buildDeterministicAdvice(makePhase1({ + crestFactor: 5, + spectralBalance: { subBass: 5, lowBass: 5, lowMids: 5, mids: 0, upperMids: 0, highs: 0, brilliance: 0 }, + })).find((c) => c.kind === 'secretSauce'); + expect(wall?.title).toContain('Wall-of-Sound'); + expect(wall?.phase1Fields).toEqual([ + 'spectralBalance.subBass', 'spectralBalance.lowBass', 'spectralBalance.lowMids', 'crestFactor', + ]); + + const tight = buildDeterministicAdvice(makePhase1({ + rhythmDetail: { onsetRate: 7, beatGrid: [], downbeats: [], beatPositions: [], grooveAmount: 0 }, + })).find((c) => c.kind === 'secretSauce'); + expect(tight?.title).toContain('Tight Rhythmic Programming'); + expect(tight?.phase1Fields).toEqual(['rhythmDetail.onsetRate', 'bpm', 'bpmConfidence']); + + const air = buildDeterministicAdvice(makePhase1({ + spectralDetail: { spectralCentroidMean: 3000 }, + spectralBalance: { subBass: 0, lowBass: 0, lowMids: 0, mids: 0, upperMids: 0, highs: 0, brilliance: 1 }, + })).find((c) => c.kind === 'secretSauce'); + expect(air?.title).toContain('Air and Presence'); + expect(air?.phase1Fields).toContain('spectralBalance.brilliance'); + + const sub = buildDeterministicAdvice(makePhase1({ + spectralBalance: { subBass: 5, lowBass: 0, lowMids: 0, mids: 0, upperMids: 0, highs: 0, brilliance: 0 }, + })).find((c) => c.kind === 'secretSauce'); + expect(sub?.title).toContain('Sub Bass Design'); + expect(sub?.phase1Fields).toEqual(['spectralBalance.subBass']); + + const balanced = buildDeterministicAdvice(makePhase1()).find((c) => c.kind === 'secretSauce'); + expect(balanced?.title).toContain('Balanced Mix Architecture'); + expect(balanced?.phase1Fields).toEqual(['bpm', 'key']); + }); + + it('hedges tempo-citing cards when the tempo fundamentals domain is ambiguous', () => { + const cards = buildDeterministicAdvice(makePhase1({ + rhythmDetail: { onsetRate: 7, beatGrid: [], downbeats: [], beatPositions: [], grooveAmount: 0 }, + fundamentalsQuality: fq({ + tempo: dom('ambiguous', 'Two tempo detectors disagree; the grid may be half-time.', 0.4), + }), + })); + const sauce = cards.find((c) => c.kind === 'secretSauce'); + expect(sauce?.hedges).toContain('Two tempo detectors disagree; the grid may be half-time.'); + }); + + it('skips tempo-citing cards when the tempo fundamentals domain failed', () => { + const cards = buildDeterministicAdvice(makePhase1({ + rhythmDetail: { onsetRate: 7, beatGrid: [], downbeats: [], beatPositions: [], grooveAmount: 0 }, + fundamentalsQuality: fq({ tempo: dom('failed', 'Tempo could not be measured.') }), + })); + expect(cards.find((c) => c.kind === 'secretSauce')).toBeUndefined(); + }); + + it('hedges via the scalar threshold when no fundamentals layer is present', () => { + const cards = buildDeterministicAdvice(makePhase1({ + bpmConfidence: 0.5, + rhythmDetail: { onsetRate: 7, beatGrid: [], downbeats: [], beatPositions: [], grooveAmount: 0 }, + })); + // bpmConfidence 0.5 fails the engine's own > 0.7 check, so the tight- + // rhythm sauce never fires; the fallback sauce cites bpm and is hedged. + const sauce = cards.find((c) => c.kind === 'secretSauce'); + expect(sauce?.title).toContain('Balanced Mix Architecture'); + expect(sauce?.hedges.some((h) => h.includes('Tempo confidence is low'))).toBe(true); + }); + + it('skips the key-citing fallback sauce when key is null', () => { + const cards = buildDeterministicAdvice(makePhase1({ key: null })); + expect(cards.find((c) => c.kind === 'secretSauce')).toBeUndefined(); + }); + + it('emits instrument cards only for non-absent bands, citing the band key', () => { + const cards = buildDeterministicAdvice(makePhase1({ + spectralBalance: { subBass: 5, lowBass: 0, lowMids: 0, mids: 0, upperMids: 0, highs: -20, brilliance: 0 }, + })); + const subCard = cards.find((c) => c.id === 'det.band.subBass'); + expect(subCard?.device).toBe('Operator'); + expect(subCard?.phase1Fields).toEqual(['spectralBalance.subBass']); + expect(cards.find((c) => c.id === 'det.band.highs')).toBeUndefined(); + }); + + it('adds the time-series citation when peakDb came from it', () => { + const cards = buildDeterministicAdvice(makePhase1({ + spectralBalanceTimeSeries: [ + { t: 0, subBass: 1, lowBass: 0, lowMids: 0, mids: 0, upperMids: 0, highs: 0, brilliance: 0 }, + ], + })); + const instrument = cards.find((c) => c.kind === 'instrument'); + expect(instrument?.phase1Fields).toContain('spectralBalanceTimeSeries'); + }); + + it('produces no spectrum-driven cards on a fast-mode payload', () => { + const cards = buildDeterministicAdvice(makePhase1({ + spectralBalance: null as unknown as Phase1Result['spectralBalance'], + rhythmDetail: null, + spectralDetail: null, + crestFactor: null, + })); + expect(cards.filter((c) => c.kind === 'instrument')).toEqual([]); + expect(cards.filter((c) => c.kind === 'fx')).toEqual([]); + // The balanced-mix fallback sauce still fires from bpm + key. + expect(cards.find((c) => c.kind === 'secretSauce')?.phase1Fields).toEqual(['bpm', 'key']); + }); +}); diff --git a/apps/ui/tests/smoke/phase2-degradation.spec.ts b/apps/ui/tests/smoke/phase2-degradation.spec.ts index 7791623c..d317b65f 100644 --- a/apps/ui/tests/smoke/phase2-degradation.spec.ts +++ b/apps/ui/tests/smoke/phase2-degradation.spec.ts @@ -214,6 +214,8 @@ test('Phase 2 controls show config-disabled state when the env kill-switch is of await expect( page.getByText('AI interpretation skipped because it was disabled by configuration.', { exact: true }).first(), ).toBeVisible(); + // With interpretation off, the deterministic Live 12 baseline steps in. + await expect(page.getByTestId('deterministic-advice')).toBeVisible(); }); test('turning Phase 2 off in the UI runs Phase 1 only and records the user-disabled reason', async ({ page }) => { @@ -238,6 +240,7 @@ test('turning Phase 2 off in the UI runs Phase 1 only and records the user-disab await expect( page.getByText('AI interpretation skipped because it was disabled in the UI.', { exact: true }).first(), ).toBeVisible(); + await expect(page.getByTestId('deterministic-advice')).toBeVisible(); }); test('Phase 2 runs Phase 1 and delegates Gemini to the backend when enabled', async ({ page }) => { @@ -273,6 +276,8 @@ test('Phase 2 runs Phase 1 and delegates Gemini to the backend when enabled', as await expectAnalysisResultsVisible(page); await expect(page.getByText('126', { exact: true }).first()).toBeVisible(); await expect(page.getByRole('heading', { name: 'Sauce', exact: true })).toBeVisible(); + // Real Phase 2 output present — the deterministic fallback must stay hidden. + await expect(page.getByTestId('deterministic-advice')).toHaveCount(0); }); test('malformed Gemini Phase 2 response degrades gracefully to skipped', async ({ page }) => { @@ -300,4 +305,6 @@ test('malformed Gemini Phase 2 response degrades gracefully to skipped', async ( await expect( page.getByText('Gemini returned invalid JSON.', { exact: true }).first(), ).toBeVisible(); + // Interpretation failed terminally — the deterministic fallback steps in. + await expect(page.getByTestId('deterministic-advice')).toBeVisible(); }); From 75e5a2997970dc2e7ee343ea78b5f20fc7d66dcb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 03:14:58 +0000 Subject: [PATCH 8/8] test(ui): expect third DSP badge from the Reconstruction Brief section Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01T4wfz87k6kzJqkKLKZE7YL --- apps/ui/tests/services/analysisResultsUi.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/ui/tests/services/analysisResultsUi.test.ts b/apps/ui/tests/services/analysisResultsUi.test.ts index 6b693cc9..6f35e85d 100644 --- a/apps/ui/tests/services/analysisResultsUi.test.ts +++ b/apps/ui/tests/services/analysisResultsUi.test.ts @@ -1031,7 +1031,7 @@ describe('AnalysisResults UI wiring', () => { expect(html).not.toContain('Perceptual / Audio-Derived'); }); - it('renders exactly two DSP badges for the current Phase 1 headings and one AI advisory badge', () => { + it('renders exactly three DSP badges for the current Phase 1 headings and one AI advisory badge', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { phase1: baseMeasurement, @@ -1040,7 +1040,8 @@ describe('AnalysisResults UI wiring', () => { }), ); - expect((html.match(/>DSPDSPAI