diff --git a/.claude/skills/asa-context/SKILL.md b/.claude/skills/asa-context/SKILL.md new file mode 100644 index 00000000..41f52ba8 --- /dev/null +++ b/.claude/skills/asa-context/SKILL.md @@ -0,0 +1,87 @@ +--- +name: asa-context +description: Use this skill at the start of a session working on the ASA project (Ableton Sonic Analyzer) at ~/code/projects/asa, or when the user says "load asa context", "/asa-context", "warm up on asa", "what's the asa project about", or asks for an overview of ASA's mission, architecture, or quality invariants. Loads the canonical project docs and produces a structured summary the user can scan to confirm the assistant has the right context. +version: 0.1.0 +--- + +# ASA Context Loader + +## Purpose + +Bring an agent (or a fresh session) up to speed on the Ableton Sonic Analyzer project in a single, sourced summary. ASA is opinionated — read the canonical docs in order, do not paraphrase from memory. + +## When to use + +- Start of any session where ASA work is the focus. +- User says "load context", "/asa-context", "warm up on asa", "give me the asa overview". +- An agent has been asked to make a non-trivial change to ASA and you want a sanity check that it has internalized the quality invariants before it writes code. + +## Procedure + +Read the following files in this exact order. Do not skip — each one builds on the prior. + +1. `~/code/projects/asa/PURPOSE.md` — **highest authority.** Mission, target user, what good output looks like, the 6 quality invariants, decision framework. Read in full. +2. `~/code/projects/asa/AGENTS.md` — root agent-facing guide. Build commands, testing strategy, key modules. +3. `~/code/projects/asa/apps/backend/AGENTS.md` — backend-specific patterns and contracts. +4. `~/code/projects/asa/apps/ui/AGENTS.md` — frontend-specific patterns and contracts. +5. `~/code/projects/asa/CLAUDE.md` — Claude Code-specific guidance for working in this repo. (Some content overlaps with AGENTS.md — read for the parts that differ.) +6. `~/code/projects/asa/BACKLOG.md` — backport candidates from `sonic-architect-app`. Read **headings only** unless the user asks about a specific item. + +If `~/code/projects/asa/docs/ARCHITECTURE_STRATEGY.md` exists, also read it — it records *why* the three-layer design is shaped the way it is, and is required reading before proposing structural changes. + +## Output format + +After reading, produce a structured summary in this exact shape. Keep it to 10–14 lines total so it's scannable. + +``` +**ASA Context Summary** + +Mission: + +Architecture (three layers): + 1. Measurement — + 2. Pitch/note translation — + 3. Interpretation — + +Quality invariants (from PURPOSE.md, all six by name): + 1. Measurement authority + 2. Citation chain + 3. Ableton specificity + 4. Honest uncertainty + 5. Reconstruction completeness + 6. Intermediate accessibility + +Working stack: + - Backend: + - Frontend: + - Ports: UI , backend + +Notable in-flight or recently-touched: + - + - + +Pointers (read these before changing the matching surface): + - apps/backend/prompts/phase2_system.txt — Phase 2 system prompt + - apps/backend/JSON_SCHEMA.md — Phase 1 schema + - docs/ARCHITECTURE_STRATEGY.md — structural decisions +``` + +## Quality bar for the summary + +The summary is correct if: + +1. **All six quality invariants are named** (by the labels above — "Measurement authority", etc.). Missing any one is a regression. +2. **The mission line is from PURPOSE.md**, not invented. Paraphrasing is fine; new claims are not. +3. **Three-layer architecture is named correctly** — Measurement / Pitch-note translation / Interpretation. Other orderings or names are wrong. +4. **Working stack mentions Python 3.11.x** (the version constraint is load-bearing — Essentia 2.1b6 wheels are 3.11-only on macOS arm64). +5. **Pointers list at least the three files above.** + +If you cannot confirm a claim from a file you actually read in this session, say "not confirmed in loaded docs" instead of guessing. + +## After the summary + +Offer the user one of: +- "Tell me what you're working on and I'll pull in the relevant deeper docs." +- "Want me to read `docs/ARCHITECTURE_STRATEGY.md` or a specific `apps/*/AGENTS.md` more carefully before we start?" + +Do not start coding until the user has confirmed direction. diff --git a/.claude/skills/asa-dev/SKILL.md b/.claude/skills/asa-dev/SKILL.md new file mode 100644 index 00000000..35d9e413 --- /dev/null +++ b/.claude/skills/asa-dev/SKILL.md @@ -0,0 +1,139 @@ +--- +name: asa-dev +description: Use this skill when the user wants to start ASA's full development stack (frontend + backend together) and says "start asa", "run asa locally", "/asa-dev", "bring asa up", "spin up the dev server", or asks to launch ASA for local testing. Runs preflight checks (Python 3.11, venv, npm deps, ports 3100/8100 free, .env wiring) BEFORE invoking scripts/dev.sh, and routes specific failure modes to specific fixes instead of dumping cryptic stack traces. +version: 0.1.0 +--- + +# ASA Dev Launcher (Guarded) + +## Purpose + +`scripts/dev.sh` is the canonical entry point for the full ASA stack — it starts the backend on 8100, waits for the OpenAPI contract, and then launches the UI on 3100. The script already does some preflight, but new contributors (and AI agents) hit the same setup gaps repeatedly: missing venv, missing `node_modules`, occupied ports, stale `.env.local` files. This skill front-runs those checks so the failure mode is named, not mysterious. + +## When to use + +- The user wants ASA running and asks for it ("start asa", "/asa-dev", "bring up the dev environment"). +- An AI agent has been asked to do work that requires a live ASA instance (e.g., smoke-testing a UI change end-to-end). +- After a fresh clone, or after `git pull` brought in dependency changes. + +**Do NOT use** for: single-process invocations (`python analyze.py `), running tests (use `asa-verify`), or background CI work. + +## Procedure + +### Step 1 — Locate the repo + +Default to `~/code/projects/asa`. If the user is in a different working directory and clearly wants ASA, confirm before continuing. + +### Step 2 — Run preflight checks in order + +Stop at the first failure and route the user to the fix. Do NOT auto-fix — these checks gate real environmental decisions. + +#### 2a. Python 3.11.x available + +```bash +python3.11 --version 2>/dev/null || python3 --version +``` + +The Python version constraint is load-bearing — Essentia 2.1b6 wheels are only published for 3.11 on macOS arm64. If neither `python3.11` is on PATH nor `python3 --version` reports 3.11.x: + +- **Fix:** Install Python 3.11.x (typically via `brew install python@3.11`). +- Do not attempt to run the backend with Python 3.12+ — `apps/backend/scripts/bootstrap.sh` will fail. + +#### 2b. Backend venv exists + +```bash +test -x ~/code/projects/asa/apps/backend/venv/bin/python && \ + ~/code/projects/asa/apps/backend/venv/bin/python --version +``` + +- If the venv is missing or the Python inside it is not 3.11.x: + **Fix:** Run `~/code/projects/asa/apps/backend/scripts/bootstrap.sh` (must be invoked from inside `apps/backend/`). Bootstrap is destructive (recreates the venv) — ask the user before running. + +#### 2c. Frontend `node_modules` exists + +```bash +test -d ~/code/projects/asa/apps/ui/node_modules && \ + test -f ~/code/projects/asa/apps/ui/node_modules/.package-lock.json +``` + +- If missing or stale: + **Fix:** `npm --prefix ~/code/projects/asa/apps/ui install`. Ask before running (npm install can be slow). + +#### 2d. Ports 3100 and 8100 are free + +```bash +lsof -nP -iTCP:3100 -sTCP:LISTEN +lsof -nP -iTCP:8100 -sTCP:LISTEN +``` + +- If anything is listening on 3100, identify the process (name + PID). +- If anything is listening on 8100, same. +- **Two common cases:** + 1. **A stale ASA backend or UI** from a previous run — kill with `kill ` after confirming with the user. + 2. **Something unrelated** (another dev server, a system service) — abort and let the user decide. Do not kill blindly. + +The canonical ports are baked in: UI on 3100, backend on 8100 (or `SONIC_ANALYZER_PORT`). `scripts/dev.sh` fails loudly if they're occupied — better to surface that here with the process identified. + +#### 2e. UI `.env` is sane + +```bash +cat ~/code/projects/asa/apps/ui/.env 2>/dev/null +``` + +Check: +- File exists. If missing, copy from `.env.example` and proceed. +- `VITE_API_BASE_URL` is set. `scripts/dev.sh` overrides this for the spawned process to point at the local backend, but a stale file with a hosted URL can still confuse later builds. Surface the value to the user. +- `VITE_ENABLE_PHASE2_GEMINI` — if `true`, the user should know that Phase 2 calls will need `GEMINI_API_KEY` set in the backend environment. Mention it. + +### Step 3 — Launch + +If all preflight passes: + +```bash +cd ~/code/projects/asa && ./scripts/dev.sh +``` + +This is a long-running foreground process. Run it in the foreground (NOT background) so the user sees the logs and can Ctrl-C it. Do not wrap it in `nohup` or `&`. + +### Step 4 — Confirm it came up + +The user will see `dev.sh`'s own output, including: +- Backend startup banner +- OpenAPI contract check passing +- UI startup (Vite reporting `Local: http://127.0.0.1:3100`) + +If you're verifying from outside the terminal, wait 8–10 seconds and check: + +```bash +curl -fsS http://127.0.0.1:8100/openapi.json | jq -r '.info.title' +# expected: Sonic Analyzer Local API +curl -fsS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3100 +# expected: 200 +``` + +## Failure modes — fast routing + +| Symptom | Likely cause | Fix | +|---|---|---| +| `python3.11: command not found` | Python 3.11 not installed | `brew install python@3.11` | +| `Missing required command: jq` (from dev.sh) | jq not installed | `brew install jq` | +| `Address already in use: 8100` | Stale backend | `lsof -nP -iTCP:8100 -sTCP:LISTEN` → `kill ` | +| `ModuleNotFoundError: essentia` | Venv broken or wrong Python | `apps/backend/scripts/bootstrap.sh` (run from `apps/backend/`) | +| `Vite reports the wrong API URL` | Stale `apps/ui/.env` overriding | confirm what `dev.sh` is exporting; clear stale `.env` | +| UI 200s but `/api/analyze` 502s | Backend is up but errored | tail backend stderr; check `GEMINI_API_KEY` if Phase 2 was attempted | +| OpenAPI title is wrong | Someone changed `server.py` title | revert or update the contract probe expectations in `asa-verify` | + +## Gotchas + +- **`scripts/dev.sh` overrides `VITE_API_BASE_URL`** for the spawned UI process. A stale value in `apps/ui/.env` won't break the dev session but *will* break `npm run build` if used as-is. If the user reports "production-like behavior" surprises after `dev.sh`, check `.env`. +- **`SONIC_ANALYZER_PORT` is honored by `dev.sh`** but not by every script. If the user is on a non-default port, mention it explicitly in the report so they don't get confused later. +- **Bootstrap is destructive** — it recreates the venv from scratch. Always ask first. +- **AI agents in restricted shells:** if you cannot run interactive long-lived processes, run `dev.sh` in background mode and tell the user where to watch the logs — but explicitly note that interrupting it requires killing the PID. + +## After launch + +Do not start coding or testing immediately. Wait for the user to confirm: +- The UI is accessible at http://127.0.0.1:3100 +- The backend is responsive at http://127.0.0.1:8100 + +If they want to verify with a real upload, point them at `apps/ui/tests/fixtures/` (if it exists) for a small sample file, or let them drop in their own. diff --git a/.claude/skills/asa-explain/SKILL.md b/.claude/skills/asa-explain/SKILL.md new file mode 100644 index 00000000..fdd183a7 --- /dev/null +++ b/.claude/skills/asa-explain/SKILL.md @@ -0,0 +1,132 @@ +--- +name: asa-explain +description: Use this skill when the user asks what a Phase 1 measurement field in the ASA project means, hands you a dotted field path like "kickDetail.fundamentalHz" or "spectralBalance.subBass", says "/asa-explain ", or asks "what does crest factor mean in ASA", "where does this number come from", or "how should I interpret this measurement". Looks up the field in the canonical ASA schema docs and grounds it against Ableton Live 12 concepts via the Ableton Knowledge MCP. +version: 0.1.0 +--- + +# ASA Measurement Field Explainer + +## Purpose + +Translate a Phase 1 measurement field into a producer-grounded answer: what it measures, what range to expect, why it matters for the reconstruction blueprint, and which Live 12 device(s) surface or shape it. + +Inputs the user might hand you: +- A dotted path: `kickDetail.fundamentalHz`, `spectralBalance.subBass`, `sidechainDetail.pumpingStrength` +- A concept name: "crest factor", "the BPM agreement field", "spectral flatness" +- A measurement value in context: "ASA says my track has spectralCentroid 4200 Hz — what does that mean?" + +## Procedure + +### Step 1 — Normalize the request to a dotted path + +If the user gives you a concept name, identify the matching field path. Common mappings: + +- "crest factor" → `crestFactor` (root scalar) +- "BPM" → `bpm` + `bpmConfidence`; mention `bpmDoubletime` if half/double-time correction is relevant +- "key" → `key` + `keyConfidence` +- "stereo width" → `stereoDetail.stereoWidth` +- "spectral centroid" → `spectralDetail.spectralCentroidMean` (the post-normalization name — the field uses `Mean` suffix after server normalization; this is documented in the Phase 2 prompt as a common mistake to avoid) + +If you cannot map the request to a known field, say so and ask the user to clarify before proceeding. + +### Step 2 — Look up the field in the canonical schema + +Read the relevant section(s) of `~/code/projects/asa/apps/backend/JSON_SCHEMA.md`. The doc is grep-friendly: + +```bash +grep -n "^### \`\`" apps/backend/JSON_SCHEMA.md +``` + +The file is organized as `## Section Name` → `### \`containerName\`` → bullet points per field. Section index includes: +- Core Metrics (root scalars: `bpm`, `key`, `lufsIntegrated`, `crestFactor`, etc.) +- Loudness & Dynamics (`lufsCurve`, `dynamicCharacter`, `textureCharacter`) +- Stereo (`stereoDetail`) +- Spectral Balance (`spectralBalance`, `spectralBalanceTimeSeries`, `spectralDetail`) +- Rhythm (`rhythmDetail`, `grooveDetail`, `beatsLoudness`, `rhythmTimeline`, `sidechainDetail`, `effectsDetail`) +- Melody (`melodyDetail`, `transcriptionDetail`) +- Pitch Detail (`pitchDetail`) +- Harmony (`chordDetail`, `segmentKey`) +- Synthesis Character (`synthesisCharacter`, `perceptual`, `essentiaFeatures`) +- Structure (`structure`, `arrangementDetail`, `segmentLoudness`, `segmentSpectral`, `segmentStereo`) + +Capture: the field's definition, type/range, and any caveats the schema doc calls out. + +### Step 3 — Confirm against the TypeScript type + +Read the corresponding type definition. Phase 1 types live in `~/code/projects/asa/apps/ui/src/types/measurement.ts` (re-exported via `apps/ui/src/types/index.ts`). Confirm: +- The field name is spelled the same as in the schema. +- The optional/nullable status matches (the schema doc says "feature functions return `null` on failure" — this is real, downstream consumers must handle nulls). + +If the schema doc and the TS type disagree, **flag the drift in your output**. This is a real bug class. + +### Step 4 — Ground it in Ableton Live 12 + +Call `mcp__Ableton_Knowledge__search_live_manual` with a query oriented around the *Live device or concept* that this measurement informs, not the measurement name itself. Examples: + +| Measurement | Good search query | +|---|---| +| `crestFactor` | "Compressor transient handling Glue Compressor attack" | +| `spectralBalance.subBass` | "EQ Eight low shelf sub bass band" | +| `sidechainDetail.pumpingStrength` | "sidechain compression Compressor external sidechain" | +| `kickDetail.fundamentalHz` | "Operator Drum Rack kick fundamental tuning" | +| `stereoDetail.stereoCorrelation` | "Utility width mono compatibility" | +| `melodyDetail.vibratoExtent` | "pitch modulation LFO vibrato in instruments" | + +For root-level rhythm/timing fields (`bpm`, `key`, `timeSignature`), search for the Live concept that uses them — warp modes, time-signature handling, key signature. + +If `search_live_manual` returns weak results, fall back to `mcp__Ableton_Knowledge__search_knowledge_base` for help-article-level context. Skip videos (`search_videos`) for this skill — the goal is a precise definition, not a tutorial. + +### Step 5 — Compose the answer + +Use this exact structure. Keep each section to 1–3 sentences. + +``` +**Field:** `` (alias: if applicable) + +**What it measures:** + + +**Typical range / interpretation:** + + +**Why it matters for the reconstruction blueprint:** + + +**Related Live 12 device(s) / concept(s):** +<1–3 specific devices/parameters from the Ableton Knowledge MCP results, with exact spelling.> + +**Sources:** +- `apps/backend/JSON_SCHEMA.md` §
+- `apps/ui/src/types/measurement.ts` (line range) +- Live 12 manual:
+``` + +### Step 6 — Flag drift, hedges, and gotchas + +After the structured answer, surface: + +1. **Documented gotchas** from `apps/backend/prompts/phase2_system.txt` lines 93–97 — if the user's path is one of the historically-typoed ones, name the correct spelling: + - `monoCompatible` is top-level, **not** `stereoDetail.monoCompatible`. + - Spectral mean fields use the `Mean` suffix after server normalization — use `spectralDetail.spectralCentroidMean`, **not** `spectralDetail.spectralCentroid`. + - Sidechain fields live under `sidechainDetail`, **not** `pumpingDetail`. +2. **Confidence pairing** — if the field has a sibling `Confidence` (e.g., `bpm` ↔ `bpmConfidence`), say so and remind that low-confidence measurements must produce hedged recommendations (Quality Invariant #4). +3. **Drift between schema and type** — if you spotted any in Step 3. + +## Quality bar + +The answer is correct if: + +1. The field path is one that actually appears in `apps/backend/JSON_SCHEMA.md` (or is explicitly called out as not present and renamed). +2. The Live 12 device/concept named comes from the MCP result, not from memory. +3. The "Sources" section cites real files and a real MCP-returned manual article. +4. Any documented gotcha that applies to this field is flagged. + +Do not invent ranges. If the schema doesn't state a range, say so and offer a typical-music range as a separate, labeled annotation. + +## After the answer + +Offer one follow-up: +- "Want me to also explain a related field?" (suggest one based on the section the user's field lives in) +- "Want to see how this field is cited in `phase2Validator.ts` or `phase2_system.txt`?" + +Don't volunteer a redesign or refactor unless asked — this skill is an explainer, not a code-changer. diff --git a/.claude/skills/asa-verify-device/SKILL.md b/.claude/skills/asa-verify-device/SKILL.md new file mode 100644 index 00000000..ac95822e --- /dev/null +++ b/.claude/skills/asa-verify-device/SKILL.md @@ -0,0 +1,136 @@ +--- +name: asa-verify-device +description: Use this skill when working on ASA and you need to verify that an Ableton Live 12 device name (or parameter name) is spelled exactly right and actually exists. Triggered by edits to apps/ui/src/data/abletonDevices.ts, edits to the Phase 2 system prompt at apps/backend/prompts/phase2_system.txt that reference devices, questions like "does Live 12 have a Glue Compressor", "is 'Drum Bus' a real device", "verify ", or any time Phase 2 output is being reviewed for device-spelling correctness against the LIVE_12_DEVICE_CATALOG_JSON contract. +version: 0.1.0 +--- + +# ASA Device Verifier + +## Purpose + +The Phase 2 system prompt (`apps/backend/prompts/phase2_system.txt`, lines 22–24) requires recommendations to use *exact* device and parameter spellings from `LIVE_12_DEVICE_CATALOG_JSON`. Rule #6 of the prompt's ABSOLUTE RULES forbids third-party devices entirely — only NATIVE and MAX_FOR_LIVE are allowed. + +This skill is the verification step those rules rely on. It confirms a device exists in Live 12, returns its canonical spelling and parameter list, and flags non-existent or third-party names. + +## When to use + +- The user is editing `apps/ui/src/data/abletonDevices.ts` (the spectral-band → device mapping table). +- The user is editing `apps/backend/prompts/phase2_system.txt` and references devices. +- The user is reviewing a Phase 2 output and wants to confirm every emitted `device` field is real. +- The user asks directly: "verify ", "does Live 12 have ", "is the real spelling". + +## Procedure + +### Step 1 — Identify the device candidate(s) + +Pull the exact strings the user wants verified. If they paste a Phase 2 output, extract every distinct `device` value from `abletonRecommendations`, `mixAndMasterChain`, and `secretSauce.workflowSteps`. + +### Step 2 — Search the Live manual + +For each candidate, call: + +``` +mcp__Ableton_Knowledge__search_live_manual + question: " device in Ableton Live 12" + num_results: 5 +``` + +Look for results where the title or top match contains the device name as a heading or section reference. The manual is the source of truth. + +### Step 3 — Fall back to the knowledge base + +If `search_live_manual` returns no clear match, call: + +``` +mcp__Ableton_Knowledge__search_knowledge_base + question: " Ableton Live" +``` + +Knowledge base articles often cover newer devices and naming changes between Live versions. + +### Step 4 — Classify the device + +Categorize into one of: + +- **NATIVE (Live 12 stock)** — exists in the manual and ships with Live 12. Examples: Glue Compressor, EQ Eight, Operator, Wavetable, Drum Rack, Utility. +- **MAX_FOR_LIVE** — ships in Live 12 Suite via Max for Live. The manual lists Max for Live devices separately; if the device appears in those sections, label it MAX_FOR_LIVE. +- **LIVE 11 ONLY or RENAMED** — if the device exists in Live 11 manual results but not Live 12, or has been renamed (e.g., classic instrument renamed/replaced), call this out. +- **NOT FOUND** — no clear match in the manual. The candidate is likely a third-party plugin, a hallucination, or a misspelling. Suggest the closest plausible Live 12 name(s) from the search results. + +The Phase 2 prompt forbids third-party devices, so a NOT FOUND result on a Phase 2 emission is a violation worth surfacing loudly. + +### Step 5 — Extract the parameter list + +When the device is NATIVE or MAX_FOR_LIVE, capture its parameter names *exactly as they appear in the Live UI*. The Phase 2 prompt requires parameter spellings to match too — "Attack" not "attack", "Threshold" not "threshold dB". + +If the search result snippet doesn't surface enough parameters, do one targeted follow-up: + +``` +mcp__Ableton_Knowledge__search_live_manual + question: " parameters Attack Release Threshold" + num_results: 3 +``` + +### Step 6 — Report + +Use this exact format. One block per candidate. + +``` +**Device:** `` +**Status:** NATIVE | MAX_FOR_LIVE | LIVE 11 ONLY | NOT FOUND +**Canonical spelling:** `` +**Family:** + +**One-line description:** + +**Parameters (exact UI spelling):** + - + - + - + ... + +**Manual reference:**
+``` + +If status is NOT FOUND, replace the body with: + +``` +**Status:** NOT FOUND +**Closest plausible Live 12 alternatives:** + - (from search result ) + - +**Recommendation:** . +``` + +### Step 7 — When verifying multiple devices, end with a summary table + +``` +| Candidate | Status | Canonical spelling | Issue | +| ---------------------- | -------------- | ----------------------- | ---------------------- | +| Glue Compressor | NATIVE | Glue Compressor | — | +| Drum Buss | NATIVE | Drum Bus | misspelled (one 's') | +| Pro-Q 3 | NOT FOUND | — | third-party (forbidden)| +``` + +## Quality bar + +The verification is correct if: + +1. Every device classified NATIVE/MAX_FOR_LIVE has a manual reference URL or article title from an actual MCP result, not from memory. +2. Parameter names use the exact capitalization from the manual snippet (do not lowercase or paraphrase). +3. NOT FOUND results include at least one suggested alternative when the search returned anything relevant. +4. If the user pasted a Phase 2 output, every distinct device value in that output appears in the summary table. + +Do not classify a device as NATIVE without a manual reference. "I'm pretty sure it exists" is not acceptable — the entire point of this skill is to remove that guesswork. + +## Common cases worth knowing + +- "Glue Compressor" and "Compressor" are distinct devices in Live 12; both are NATIVE. Confirm which one the user means. +- "Drum Bus" is one word in the canonical spelling, but "Drum Buss" (with double-s) is a common typo. +- "Hybrid Reverb" was added in Live 11 — confirm it's documented for Live 12 in the manual results before classifying. +- "Echo" exists; "Echo Delay" does not — make sure that distinction is preserved. +- Third-party plugins to watch for (Phase 2 violations): Pro-Q, FabFilter, Soothe2, Waves, Vital, Serum, Massive, Diva. Any of these → NOT FOUND + Phase 2 violation. + +## After the report + +If the user is editing `abletonDevices.ts`, offer to surface the exact rename/diff. If they're reviewing Phase 2 output, offer to also run a parameter-level verification (next step in the `asa-citation-auditor` workflow, which is on the Tier 2 backlog). diff --git a/.claude/skills/asa-verify/SKILL.md b/.claude/skills/asa-verify/SKILL.md new file mode 100644 index 00000000..9d1c5ed4 --- /dev/null +++ b/.claude/skills/asa-verify/SKILL.md @@ -0,0 +1,110 @@ +--- +name: asa-verify +description: Use this skill when the user wants to verify ASA before commit or merge, says "verify asa", "/asa-verify", "run all the tests", "is asa green", "make sure i didn't break anything", or after a meaningful code change to apps/backend or apps/ui. Orchestrates the full ASA test pipeline — frontend lint+unit+build+smoke, backend unittest discover, and an optional contract probe — and isolates failures to a specific layer and test name. +version: 0.1.0 +--- + +# ASA Full-Stack Verifier + +## Purpose + +ASA has three test surfaces that must all pass before a change is safe: the frontend verify pipeline, the backend `unittest` suite, and the HTTP contract (the schema the UI relies on). This skill runs them in order, aggregates pass/fail per layer, and on failure surfaces the failing test name plus one line of relevant output — replacing "did I run all the things?" guesswork. + +## When to use + +- Pre-commit / pre-push, after any change touching `apps/backend/`, `apps/ui/`, or `apps/backend/prompts/`. +- Before merging a worktree branch. +- When the user asks "is asa green", "run all asa tests", "/asa-verify", "verify before i ship". +- When debugging a regression and you need to know which layer (UI, backend, contract) broke. + +## Procedure + +### Step 1 — Confirm working tree state + +Print the current branch and a one-line dirty/clean status from `git -C ~/code/projects/asa status --porcelain | head -5`. This goes into the report so the user knows what they verified. + +### Step 2 — Frontend verify pipeline + +```bash +npm --prefix ~/code/projects/asa/apps/ui run verify +``` + +`npm run verify` is the canonical aggregate. Composition (from `apps/ui/package.json`): + +1. `npm run lint` — `tsc --noEmit` (TypeScript type-check; no ESLint/Prettier are configured) +2. `npm run test:unit` — `vitest run tests/services` +3. `npm run build` — `vite build` +4. `npm run test:smoke` — Playwright smoke tests (stubbed backend + Gemini) + +If `verify` fails, **do not fall back to the sub-commands silently** — the failing sub-step is informative. Capture the first failing step's name and the failing test/error line. + +### Step 3 — Backend test suite + +```bash +~/code/projects/asa/apps/backend/venv/bin/python -m unittest discover -s ~/code/projects/asa/apps/backend/tests +``` + +Backend uses stdlib `unittest`, not pytest. If the venv doesn't exist, surface that as a setup failure — point the user at `~/code/projects/asa/apps/backend/scripts/bootstrap.sh` (must be invoked from `apps/backend/`; do not auto-run; bootstrap recreates the venv and the user should approve). + +On failure, parse the unittest output for the line beginning `FAIL: ` or `ERROR: ` followed by the test path (e.g., `FAIL: test_analyze_endpoint_combines_separate_and_transcribe (tests.test_server.ServerContractTests)`). Quote that line plus the first 2–3 lines of the traceback. + +### Step 4 — Contract probe (optional but recommended) + +This is light — skip if both Step 2 and Step 3 already passed and the user is in a hurry. Run if the user explicitly asked, or if changes touched `apps/backend/server.py`. + +1. Check port 8100 is free: `lsof -nP -iTCP:8100 -sTCP:LISTEN`. If occupied by anything other than an ASA backend, abort the probe. +2. Start the backend in the background: `~/code/projects/asa/apps/backend/venv/bin/python ~/code/projects/asa/apps/backend/server.py &` (background mode). +3. Wait up to 10 seconds for `/openapi.json` to respond: `curl -fsS http://127.0.0.1:8100/openapi.json | jq -r '.info.title'`. +4. Confirm the title is exactly `Sonic Analyzer Local API`. +5. Kill the backend (`kill %1` or the captured PID). + +A contract probe failure means `server.py` is producing the wrong OpenAPI document — that's a real bug. + +### Step 5 — Aggregate and report + +Output format: + +``` +ASA verify on () + +[1/3] Frontend verify ......... +[2/3] Backend unittest ........ +[3/3] Contract probe .......... + +Result: PASS | FAIL + + +``` + +### Step 6 — On failure, route the user + +For each failure mode, suggest the next step: + +- **lint failure (tsc):** name the file and the first error. Suggest `npx tsc --noEmit -p apps/ui` to iterate. +- **test:unit failure:** name the failing test and suggest `npx --prefix apps/ui vitest run -t ""` to focus. +- **build failure:** likely Vite/TS error or import path issue; suggest reading the first error and the file it points at. +- **test:smoke failure:** Playwright failures often need `npm --prefix apps/ui run test:smoke -- --reporter=list ` and the trace flag. Suggest that. +- **backend unittest failure:** suggest the targeted runner: `apps/backend/venv/bin/python -m unittest `. +- **contract failure:** point at `apps/backend/server.py` and ask the user whether they intentionally changed the OpenAPI title. + +## Quality bar + +The skill's output is correct if: + +1. The three layers are reported separately. A backend pass does not gloss over a frontend fail. +2. On failure, the failing test/step is named explicitly (not "frontend failed" — instead "test:smoke failed in tests/smoke/upload-phase1.spec.ts: expected ..."). +3. Timing is reported per step (rough seconds is fine; helps the user notice when tests are dragging). +4. If a step is skipped, the report says SKIPPED, not silently absent. + +## Gotchas + +- **`npm run verify` runs `test:smoke` which needs Playwright browsers.** First run on a fresh machine may need `npx playwright install`. If smoke fails with a "browser not found" error, name that fix specifically. +- **`RUN_GEMINI_LIVE_SMOKE=true` runs against the real Gemini Files API** — do not set this unintentionally. Default is `false` and the verify pipeline does not need it. +- **The contract probe assumes nothing else is on 8100.** If `scripts/dev.sh` is already running, skip the probe rather than fight for the port. +- **Backend tests use stdlib `unittest`**, not pytest. Don't run `pytest`; it will discover nothing. + +## After the report + +If everything is green, congratulate briefly and stop — don't volunteer follow-up work. + +If there's a failure, ask once whether to attempt a fix or just report the failure and stop. Don't start editing files without confirmation. diff --git a/.gitignore b/.gitignore index 9cbeb892..f6ae1e5f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,5 +35,24 @@ apps/backend/.runtime/ # UI prototypes apps/ui/redesign-concept.html +# Runtime-generated MIDI artifacts under public/. The Phase 1 melody/transcription +# analyzers export .mid files for "Session Musician" downloads; when those land +# under apps/ui/public/ during a local dev session they should NOT be committed. +# The intentional reference assets (demo.mp3) keep their own line. +apps/ui/public/*.mid +apps/ui/public/demo_melody.mid + # Local-only superpowers plans docs/superpowers/ + +# Track 2 bench tracks: copyrighted reference audio + derived per-track +# artifacts. Audio files and per-track derivatives live in your local +# checkout only — keep the README so the directory shape is checked in. +apps/backend/tests/fixtures/bench_tracks/* +!apps/backend/tests/fixtures/bench_tracks/README.md +apps/backend/tests/fixtures/estimations/ + +# Ephemeral local state +.claude/scheduled_tasks.lock +.continue.sh +.plan.md diff --git a/apps/backend/JSON_SCHEMA.md b/apps/backend/JSON_SCHEMA.md index 0a4cbb8e..da1d8653 100644 --- a/apps/backend/JSON_SCHEMA.md +++ b/apps/backend/JSON_SCHEMA.md @@ -15,7 +15,7 @@ Conventions: Top-level keys: -`bpm`, `bpmConfidence`, `bpmPercival`, `bpmAgreement`, `bpmDoubletime`, `bpmSource`, `bpmRawOriginal`, `key`, `keyConfidence`, `keyProfile`, `tuningFrequency`, `tuningCents`, `timeSignature`, `timeSignatureSource`, `timeSignatureConfidence`, `durationSeconds`, `sampleRate`, `lufsIntegrated`, `lufsRange`, `lufsMomentaryMax`, `lufsShortTermMax`, `truePeak`, `crestFactor`, `dynamicSpread`, `dynamicCharacter`, `textureCharacter`, `stereoDetail`, `spectralBalance`, `spectralDetail`, `rhythmDetail`, `melodyDetail`, `transcriptionDetail`, `pitchDetail`, `grooveDetail`, `beatsLoudness`, `rhythmTimeline`, `sidechainDetail`, `effectsDetail`, `synthesisCharacter`, `danceability`, `structure`, `arrangementDetail`, `segmentLoudness`, `segmentSpectral`, `segmentStereo`, `segmentKey`, `chordDetail`, `perceptual`, `essentiaFeatures`. +`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`. ## Relationship To `POST /api/analyze` @@ -181,6 +181,17 @@ Current server behavior that affects schema expectations: | `lufsShortTermMax` | `float \| null` | Maximum short-term loudness (3 s window) via `LoudnessEBUR128`. | LUFS | Peak sustained loudness; gap between this and integrated LUFS indicates dynamic range use. | | `dynamicSpread` | `float \| null` | Ratio of broad-band energy means (sub/mid/high approximation). | unitless ratio | Quick indicator of how unevenly energy is distributed across broad frequency regions. | +### `lufsCurve` + +Type: `object \| null` + +Per-frame EBU R128 loudness over time. Each curve is downsampled to ~200 points on a 2-minute track; points sit at bin-center timestamps relative to track start. + +| Field | Type | Description | Units / Scale | LLM interpretation note | +|---|---|---|---|---| +| `lufsCurve.shortTerm` | `Array<{t: float, lufs: float}>` | Short-term (3 s window) loudness curve. | seconds / LUFS | Phase 2 cites this to explain breakdown-vs-drop loudness contrast and section-relative dynamics. | +| `lufsCurve.momentary` | `Array<{t: float, lufs: float}>` | Momentary (400 ms window) loudness curve. | seconds / LUFS | Finer-grained than `shortTerm`; useful for spotting punch and transient swells. | + ### `dynamicCharacter` Type: `object \| null` @@ -220,9 +231,12 @@ Type: `object \| null` | `stereoDetail.stereoCorrelation` | `float \| null` | Pearson correlation of full-band L/R channels. | -1.0 to 1.0 | Near 1 = mono-compatible; near 0 = wide/decorrelated; negative may collapse poorly to mono. | | `stereoDetail.subBassCorrelation` | `float \| null` | L/R correlation after sub-band isolation (20-80 Hz target; low-pass fallback). | -1.0 to 1.0 | Sub mono-compatibility signal; low values suggest risky stereo low-end for club playback. | | `stereoDetail.subBassMono` | `bool \| null` | `true` when `subBassCorrelation > 0.85`. | boolean | `true` means sub region is effectively mono-compatible; standard for most dance/club mixes. | +| `stereoDetail.correlationCurve` | `Array<{t: float, full: float \| null, sub: float \| null}> \| null` | 1-second windowed L/R correlation, full-band and sub-band side-by-side. | seconds / Pearson r in [-1, 1] | Surfaces stereo automation (Utility width sweeps, mono-collapsing the drop) that the global scalars conflate into one number. `sub` may be null in windows where the sub band is silent. | +| `stereoDetail.bandCorrelations` | `object \| null` | Per-frequency-band L/R Pearson correlation, keyed by the same 7 bands as `spectralBalance` (subBass / lowBass / lowMids / mids / upperMids / highs / brilliance). | dict of Pearson r in [-1, 1] or null | Phase 2 cites these to recommend Utility-tool width per band ("bass is mono at 0.98 but mids are wide at 0.45 — add width on the synth bus only"). Per-band null means that band carries no usable energy on this track. | Example interpretation: - `subBassMono: true` -> "Sub bass is mono-compatible. Standard for club music. Advise keeping bass synthesis below ~150 Hz mono in Ableton." +- A `correlationCurve` row at `t=48.0` showing `full: 0.42` while the integrated `stereoCorrelation` is `0.78` indicates a deliberate width-opening automation around that section. --- @@ -234,12 +248,112 @@ Type: `object \| null` | Field | Type | Description | Units / Scale | LLM interpretation note | |---|---|---|---|---| -| `spectralBalance.subBass` | `float` | Mean energy in 20-60 Hz band. | dB (relative) | Indicates weight of true sub fundamentals. | -| `spectralBalance.lowBass` | `float` | Mean energy in 60-200 Hz band. | dB (relative) | Covers kick thump and bass body. | -| `spectralBalance.mids` | `float` | Mean energy in 200-2000 Hz band. | dB (relative) | Core musical body and intelligibility region. | -| `spectralBalance.upperMids` | `float` | Mean energy in 2-6 kHz band. | dB (relative) | Presence/attack region; affects perceived forwardness. | -| `spectralBalance.highs` | `float` | Mean energy in 6-12 kHz band. | dB (relative) | Brightness and air onset content. | -| `spectralBalance.brilliance` | `float` | Mean energy in 12-20 kHz band. | dB (relative) | Extreme top-end "air"; often reduced on lossy or dark masters. | +| `spectralBalance.subBass` | `float` | Mean energy in 20-80 Hz band. | dB (relative) | Indicates weight of true sub fundamentals. | +| `spectralBalance.lowBass` | `float` | Mean energy in 80-250 Hz band. | dB (relative) | Covers kick thump and bass body. | +| `spectralBalance.lowMids` | `float` | Mean energy in 250-500 Hz band. | dB (relative) | Lower midrange — body of low instruments, room boom. Field exists in code but was previously absent from this doc; mind the asymmetry until older readers update. | +| `spectralBalance.mids` | `float` | Mean energy in 500-2000 Hz band. | dB (relative) | Core musical body and intelligibility region. | +| `spectralBalance.upperMids` | `float` | Mean energy in 2-5 kHz band. | dB (relative) | Presence/attack region; affects perceived forwardness. | +| `spectralBalance.highs` | `float` | Mean energy in 5-10 kHz band. | dB (relative) | Brightness and air onset content. | +| `spectralBalance.brilliance` | `float` | Mean energy in 10-20 kHz band. | dB (relative) | Extreme top-end "air"; often reduced on lossy or dark masters. | + +### `spectralBalanceTimeSeries` + +Type: `Array<{t: float, subBass: float, lowBass: float, lowMids: float, mids: float, upperMids: float, highs: float, brilliance: float}> \| null` + +Sibling time-series partner for `spectralBalance`. Each row carries all seven bands at a given timestamp (bin-center, seconds). Downsampled to ~200 rows on a 2-minute track. Phase 2 cites entries like `spectralBalanceTimeSeries` plus a time index ("the high-end opens up at 1:23") rather than only static averages. Lives as a sibling rather than nested inside `spectralBalance` because that field's exact 7-key shape is asserted by the backend test contract. + +### `stemAnalysis` + +Type: `object \| null` + +Phase 1.B per-stem analytical surface — populated only when Demucs stem separation ran successfully (`--separate`). Null when separation wasn't requested or failed. Phase 2 cites individual stems for element-specific recommendations. + +For each available stem (`drums` / `bass` / `other` / `vocals`), the same high-value full-mix analyzers run on the stem's audio: + +| Field per stem | Type | Description | +|---|---|---| +| `stemAnalysis.{stem}.spectralBalance` | `object` | Same 7-band shape as the top-level `spectralBalance`. Per-element EQ target. | +| `stemAnalysis.{stem}.spectralBalanceTimeSeries` | `array` | Per-stem 7-band time series, downsampled to ~200 frames. | +| `stemAnalysis.{stem}.spectralDetail` | `object` | Per-stem centroid/rolloff/MFCC/Bark/ERB/chroma. Same shape as top-level. | +| `stemAnalysis.{stem}.lufsIntegrated` | `float` | Per-stem integrated EBU R128 loudness. | +| `stemAnalysis.{stem}.lufsRange` / `.lufsMomentaryMax` / `.lufsShortTermMax` | `float` | Per-stem loudness statistics. | +| `stemAnalysis.{stem}.lufsCurve` | `object` | Per-stem `{shortTerm, momentary}` curves. | +| `stemAnalysis.{stem}.stereoDetail` | `object` | Per-stem stereo width/correlation/subBassMono/correlationCurve. | +| `stemAnalysis.{stem}.truePeak` | `float` | Per-stem true peak across L/R. | +| `stemAnalysis.{stem}.crestFactor` / `.dynamicSpread` | `float` | Per-stem dynamics scalars. | +| `stemAnalysis.{stem}.dynamicCharacter` | `object` | Per-stem dynamic-complexity / attack profile. | +| `stemAnalysis.{stem}.reverbDetail` | `object` | Phase 1.D #5 — per-stem reverb estimation. Same shape as the top-level `reverbDetail` (rt60 / isWet / tailEnergyRatio / measured / perBandRt60 / preDelayMs). Drums stem is typically the cleanest signal; long RT60 on bass/other/vocals often reflects sustained tonal decay rather than reverb. When `measured: false` the slope-fit didn't have enough transients on that stem — treat as fallback. | + +**Intentionally not per-stem:** BPM, key, time signature, structure novelty, sidechain pumping, danceability, chord progression. These are song-level properties — splitting them per-stem would produce noise. + +### `transientDensityDetail` + +Type: `object \| null` + +Phase 1.C #1 — per-frequency-band onset density across the 7 `SPECTRAL_BALANCE_BANDS`. For each band (`subBass`, `lowBass`, `lowMids`, `mids`, `upperMids`, `highs`, `brilliance`): + +| Field per band | Type | Description | +|---|---|---| +| `transientDensityDetail.{band}.onsetRatePerSecond` | `float` | Detected onset events per second within this band. | +| `transientDensityDetail.{band}.meanOnsetStrength` | `float` | Mean librosa onset-envelope value at detected peaks. | +| `transientDensityDetail.{band}.peakOnsetStrength` | `float` | Max onset-envelope value across the track. | +| `transientDensityDetail.{band}.eventCount` | `int` | Number of detected onsets. | + +**LLM interpretation notes:** +- Cite `transientDensityDetail.lowBass.onsetRatePerSecond` for kick-density / drum-bus claims. +- Cite `transientDensityDetail.highs.eventCount` for hi-hat density / shaker activity. +- Use cross-band comparisons to anchor "drums-heavy in upper mids, bass smooth in subs" advice. +- High onset density + low mean strength suggests sustained content (synth pad); high mean strength + low density suggests sparse hits. + +### `saturationDetail` + +Type: `object \| null` + +Phase 1.C #5 — saturation / clipping / over-compression telltales. Hint-level signals — Phase 2 must hedge per the citation contract's low-confidence rules until the audit bench confirms. + +| Field | Type | Description | +|---|---|---| +| `saturationDetail.clippedSampleCount` | `int` | Stereo samples with `|x| >= 0.9999`. | +| `saturationDetail.clippedSamplePercent` | `float` | Percentage of total samples that crossed the clip threshold. | +| `saturationDetail.nearClippedSampleCount` | `int` | Samples with `|x| >= 0.95`. | +| `saturationDetail.nearClippedSamplePercent` | `float` | Percentage near mastering-loud territory. | +| `saturationDetail.peakRatio95to50` | `float \| null` | Ratio of 95th-percentile `|x|` to 50th-percentile. Higher = more dynamic. | +| `saturationDetail.rmsToPeakRatioDb` | `float \| null` | Peak-vs-RMS in dB on the mono buffer. Inverse-shape of crest factor. | +| `saturationDetail.saturationLikely` | `bool` | Heuristic: any clipping, sustained near-clipping, or low peak ratio + low RMS-to-peak. NOT definitive — Phase 2 should hedge. | + +**LLM interpretation notes:** +- A non-zero `clippedSampleCount` is a strong signal — recommend Saturator or Limiter ceiling at -0.3 dB. +- `peakRatio95to50` below ~1.7 with low `rmsToPeakRatioDb` (< 7 dB) suggests heavy limiting / brickwall mastering. +- `saturationLikely=true` alone is a hint, not a verdict — combine with crest factor and `dynamicCharacter.dynamicComplexity` for confidence. + +### `snareDetail` / `hihatDetail` + +Both types: `object \| null`. Same shape (BandDrumDetail), different bands. + +Phase 1.C #4 — band-limited drum character. Mirrors the `analyze_kick_detail` pattern in the 120-2000 Hz band (snare body + snap) and 2000-12000 Hz band (hi-hat brightness / openness). Uses the drums stem when available, otherwise falls back to spectrum-bin selection on the full mix. + +| Field | Type | Description | +|---|---|---| +| `{snare\|hihat}Detail.hitCount` | `int` | Detected onsets in band. | +| `{snare\|hihat}Detail.hitsPerSecond` | `float` | Hits per second. Useful for groove density. | +| `{snare\|hihat}Detail.meanAttackSharpness` | `float` | Mean envelope rise across 2 frames before each peak — a transient sharpness proxy. | +| `{snare\|hihat}Detail.meanBodyEnergyRatio` | `float \| null` | Fraction of per-hit spectral energy in the lower half of the band. For snare: body region (~120-755 Hz). For hi-hat: lower half (~2-6 kHz). | +| `{snare\|hihat}Detail.meanSnapEnergyRatio` | `float \| null` | Fraction in the upper half of the band. Higher snare value = more snap; higher hi-hat value = more sizzle. | +| `{snare\|hihat}Detail.meanCentroidHz` | `float \| null` | Mean spectral centroid weighted by magnitude across the per-hit spectra. | +| `{snare\|hihat}Detail.meanDecayFrames` | `float` | Mean frames between peak and envelope falling below 30% of peak. | +| `{snare\|hihat}Detail.meanDecaySeconds` | `float` | Same as above in seconds. For hi-hat, a rough open-vs-closed proxy (closed ~30-60 ms, open >150 ms). | +| `{snare\|hihat}Detail.bandHz` | `[float, float]` | Inclusive band edges actually used. | + +**LLM interpretation notes:** +- Snare body vs snap balance: `meanBodyEnergyRatio > 0.6` = thick / round snare; `meanSnapEnergyRatio > 0.55` = snappy / clappy. Recommends Saturator drive or EQ Eight body/snap shaping accordingly. +- Hi-hat decay: `meanDecaySeconds < 0.06` = closed/tight hats; `> 0.15` = open / shaker / cymbal-ish content. Drives reverb send choice and Auto Filter tone. +- `hitsPerSecond` on hi-hat: 4-8 = 8th notes at house tempos, 8-16 = 16th notes / trap-style, >16 = rolls / shaker textures. +- Detail is null when no source provides ≥2 detected hits in band (fallback returns null gracefully). + +**LLM interpretation notes:** +- Cite per-stem paths to justify element-specific recommendations ("Glue Compress the kick at -8 dB threshold because `stemAnalysis.drums.crestFactor = 11.2 dB`" rather than the full-mix crest). +- Compare values across stems for masking / EQ-balance claims (e.g. `stemAnalysis.bass.spectralBalance.subBass` vs `stemAnalysis.drums.spectralBalance.subBass` for sub-bass overlap). +- If a stem is missing from the dict, treat that stem as "not separated" — don't infer it's silent. ### `spectralDetail` @@ -280,6 +394,7 @@ Type: `object \| null` | `rhythmDetail.phraseGrid.phrases16Bar` | `float[]` | Start times of 16-bar phrases. | seconds | Section-level phrase boundaries. | | `rhythmDetail.phraseGrid.totalBars` | `int` | Total number of detected bars. | count | Track length in bars for arrangement planning. | | `rhythmDetail.phraseGrid.totalPhrases8Bar` | `int` | Total number of 8-bar phrases. | count | Quick structural count for electronic arrangement estimation. | +| `rhythmDetail.tempoCurve` | `Array<{t: float, bpm: float}> \| null` | Instantaneous-BPM curve from beat ticks, smoothed with a 4-beat rolling median, downsampled to ~200 points. | seconds / BPM | Surfaces deliberate ritardando/accelerando and DJ-tool tempo blends that the single mean `bpm` scalar conflates away. Phase 2 cites this to explain tempo-modulated sections. | Note: `rhythmDetail.beatPositions` previously referred to a truncated beat-timestamp alias. That timestamp array is now exposed as `rhythmDetail.beatGrid` for the full track. @@ -293,6 +408,9 @@ Type: `object \| null` | `grooveDetail.hihatSwing` | `float` | Swing proxy from high-band accented beat spacing. | unitless | Captures high-frequency rhythmic looseness. | | `grooveDetail.kickAccent` | `float[]` | Up-to-16 sampled low-band beat loudness values. | linear loudness proxy | Shape of kick emphasis over time. | | `grooveDetail.hihatAccent` | `float[]` | Up-to-16 sampled high-band beat loudness values. | linear loudness proxy | Shape of high-percussion emphasis over time. | +| `grooveDetail.perDrumSwing.kick` | `float` | Phase 1.C #3 — same kickSwing value, exposed as part of the per-drum-swing object so Phase 2 can cite all three groups uniformly. | 0-1 tanh-compressed | Cite alongside `perDrumSwing.snare` and `perDrumSwing.hihat` for groove-aware MIDI quantization recommendations. | +| `grooveDetail.perDrumSwing.snare` | `float` | Phase 1.C #3 — swing computed from the snare-band (200-4000 Hz) beat-loudness signal. | 0-1 tanh-compressed | New in this pass. Compare with `perDrumSwing.kick`: if snareSwing >> kickSwing, the snare is being human-pushed/pulled against a quantized kick (classic hip-hop / R&B layered feel). | +| `grooveDetail.perDrumSwing.hihat` | `float` | Phase 1.C #3 — same hihatSwing value, exposed inside the perDrumSwing object. | 0-1 tanh-compressed | Cite for shuffle / dilla / triplet-hat-feel recommendations. | ### `beatsLoudness` @@ -343,9 +461,37 @@ Type: `object \| null` |---|---|---|---|---| | `sidechainDetail.pumpingStrength` | `float` | Depth/alignment score for loudness dips vs kick activity. | 0.0-1.0 | Higher values suggest stronger audible sidechain-style ducking. | | `sidechainDetail.pumpingRegularity` | `float` | Period consistency of detected pumping intervals. | 0.0-1.0 | High values indicate clock-like pumping, useful for genre-consistent groove reconstruction. | -| `sidechainDetail.pumpingRate` | `"quarter" \| "eighth" \| "sixteenth" \| null` | Best-matching pumping grid rate. | categorical | Suggests compressor trigger rhythm for Ableton sidechain setup. | +| `sidechainDetail.pumpingRate` | `"quarter" \| "eighth" \| "sixteenth" \| "thirty_second" \| null` | Best-matching pumping grid rate. | categorical | Suggests compressor trigger rhythm for Ableton sidechain setup. `thirty_second` added in Phase 1.C #6 (32nd-note grid). | | `sidechainDetail.pumpingConfidence` | `float` | Reliability score (kick clarity + dip correlation + timing stability penalties). | 0.0-1.0 | Low confidence means avoid overcommitting to sidechain recreation without ear-checking. | -| `sidechainDetail.envelopeShape` | `float[16] \| null` | Normalized median RMS envelope across bars at 16th-note resolution. | 0-1 per step | Rhythmic amplitude shape useful for sidechain curve recreation; peak at step 0 typically indicates kick position. | +| `sidechainDetail.envelopeShape` | `float[16] \| null` | Normalized median RMS envelope across bars at 16th-note resolution (downsampled from the internal 32nd-note grid via max-pairing). | 0-1 per step | Rhythmic amplitude shape useful for sidechain curve recreation; peak at step 0 typically indicates kick position. | +| `sidechainDetail.envelopeShape32` | `float[32] \| null` | Normalized median RMS envelope across bars at 32nd-note resolution (added Phase 1.C #6). | 0-1 per step | Higher-resolution view of the same sidechain ducking pattern — exposes attack/release within the 16th-note slot. Prefer this over `envelopeShape` for Auto Filter/Auto Pan rate inference. | + +### `reverbDetail` + +Type: `object \| null` + +| Field | Type | Description | Units / Scale | LLM interpretation note | +|---|---|---|---|---| +| `reverbDetail.rt60` | `float \| null` | Mean RT60 estimate (-60 dB decay time) across detected transients. | seconds, capped at 3.0 | Quick "wet/dry" proxy at the bus level. `null` when fewer than 4 transients (no decay slopes to fit). | +| `reverbDetail.isWet` | `bool` | True when mean RT60 > 0.5 s. | boolean | Categorical "this material has measurable reverb" flag. Use to gate Reverb/Convolution recommendations vs dry-source advice. | +| `reverbDetail.tailEnergyRatio` | `float \| null` | Mean fraction of total post-transient energy that lives in the tail vs direct portion. | 0-1 | Higher = wetter; useful for deciding wet/dry mix percentage when recreating the reverb. | +| `reverbDetail.measured` | `bool` | Whether enough transients were detected to actually fit RT60 slopes. | boolean | If `measured` is False, all other fields are fallbacks — don't cite RT60 numbers in this case. | +| `reverbDetail.perBandRt60` | `{low?, lowMids?, highMids?, highs?} \| null` | Phase 1.D #5 — RT60 estimated separately in 4 octave bands (low ≈ 20-250 Hz, lowMids ≈ 250-2000 Hz, highMids ≈ 2000-8000 Hz, highs ≈ 8000-16000 Hz). Each band measures the same transient stream. | seconds | Long lows / short highs is a typical room signature; long highs / short lows suggests plate or bright chamber. Use to choose Reverb device type (Hall vs Plate vs Room) and damping. | +| `reverbDetail.preDelayMs` | `float \| null` | Phase 1.D #5 — median time between direct peak and first envelope minimum within the next 100 ms, across all detected transients. | milliseconds | A proxy for reverb pre-delay; close to zero on dry sources. Cite for Reverb device PreDelay parameter recommendations. | + +### `vocalDetail` + +Type: `object \| null` + +| Field | Type | Description | Units / Scale | LLM interpretation note | +|---|---|---|---|---| +| `vocalDetail.hasVocals` | `bool` | Categorical decision: true when composite confidence > 0.55. | boolean | Use to gate vocal-bus recommendations vs instrumental ones. | +| `vocalDetail.confidence` | `float` | Composite 35/35/30 weighting of energy / formant / MFCC scores, scaled by stemEnergyRatio when a vocals stem is present. | 0.0-1.0 | Hedge any vocal claim when confidence < 0.7. | +| `vocalDetail.vocalEnergyRatio` | `float` | Fraction of spectral energy in 150-1500 Hz vocal fundamental band. | 0.0-1.0 | Higher = more energy in the vocal range. Common on vocal-led tracks (0.25-0.45). | +| `vocalDetail.formantStrength` | `float` | Coherent-formant-frames fraction × temporal-stability multiplier. Penalizes sustained synth leads with static "formants". | 0.0-1.0 | Real vocals usually 0.4-0.8; sustained synth leads should land at 0.2 due to the static-formant penalty. | +| `vocalDetail.mfccLikelihood` | `float` | How closely the MFCC distribution matches a 40/35/25 low/mid/high voice-like profile. | 0.0-1.0 | Hint-only. Can score high on sustained tonal content; don't rely on this alone. | +| `vocalDetail.stemEnergyRatio` | `float \| null` | Phase 1.D follow-up: vocals-stem RMS / full-mix RMS. Null when no vocals stem was used (full-mix path). | 0.0-2.0 typical, capped at 2.0 | Below ~0.05 indicates Demucs ghost-stem leakage on an instrumental track; analyzer scales confidence down accordingly. Phase 2 should hedge vocal claims when this value is low. | +| `vocalDetail.stemOtherCorrelation` | `float \| null` | Phase 1.D follow-up: Pearson correlation between vocals stem and "other" stem at 200 Hz envelope rate. Null when no vocals stem was used. | -1.0 to 1.0 | Above ~0.55 indicates Demucs split a single melodic source across both stems (misclassified lead); analyzer scales confidence down. Below ~0.30 indicates independent stems — no penalty. Phase 2 should hedge vocal-bus claims when this is high. | ### `effectsDetail` @@ -463,6 +609,8 @@ Type: `object \| null` | `chordDetail.chordStrength` | `float` | Mean chord detection strength. | 0-1 (approx) | Low/medium values indicate probable ambiguity on full-master chord detection. | | `chordDetail.progression` | `string[]` | Consecutive-duplicate-removed progression, capped at 16. | chord labels | Compact harmonic change path for arrangement planning. | | `chordDetail.dominantChords` | `string[]` | Top 4 most frequent chord labels. | chord labels | Candidate tonic/relative function anchors. | +| `chordDetail.chordTimeline` | `array<{startSec, endSec, label, confidence}> \| null` | Phase 1.D #2 — chord segments with start/end times (seconds) and per-segment confidence (mean of per-frame strength values). Each segment is the result of a 5-frame median-filter smoothing of the per-frame chord labels, then merging consecutive same-label frames. Segments shorter than ~250 ms are dropped as detection noise. Capped at 64 segments. | seconds; 0-1 confidence | Use for arrangement-aware harmonic recommendations ("the bridge sits on Cm for 8 bars, then drops to Eb for 4 — recommend a chord-stab MIDI sequence that matches the timeline"). When `confidence < 0.5` on a segment, hedge — chord-detection on full-mix is noisy. | +| `chordDetail.chordChangeCount` | `int` | Phase 1.D #2 — count of unique chord-to-chord transitions in the smoothed timeline. Flat 1-chord tracks score 0; harmonically active tracks score 16+. | count | A proxy for "how harmonically active" the track is. Cite for arrangement-density and harmonic-rhythm recommendations. | ### `segmentKey` @@ -531,7 +679,7 @@ Type: `object \| null` | Field | Type | Description | Units / Scale | LLM interpretation note | |---|---|---|---|---| -| `arrangementDetail.noveltyCurve` | `float[]` | Downsampled novelty timeline (max 64 points) from Bark-band change detection. | relative novelty units | Highlights where timbral/energy surprises occur (risers, transitions, filter moves). | +| `arrangementDetail.noveltyCurve` | `float[]` | Downsampled novelty timeline (max 256 points as of Phase 1.A.5; was 64 previously) from Bark-band change detection. | relative novelty units | Highlights where timbral/energy surprises occur (risers, transitions, filter moves). Higher resolution lets Phase 2 cite "novelty ramps for 8 bars before the drop" rather than just naming peaks. | | `arrangementDetail.noveltyPeaks` | `array` | Top novelty events with spacing constraint (max 8). | list of events | Candidate transition markers for arrangement mapping beyond SBic segmentation. | | `arrangementDetail.noveltyPeaks[].time` | `float` | Time of a novelty peak. | seconds | Place transition/automation markers in arrangement timeline. | | `arrangementDetail.noveltyPeaks[].strength` | `float` | Relative strength at novelty peak. | novelty magnitude | Higher values indicate more pronounced spectral/energy change. | @@ -575,6 +723,70 @@ Type: `array \| null` --- +## Detection analyzers (additional sections — added in JSON_SCHEMA.md reconciliation pass) + +### `acidDetail` + +Type: `object \| null` + +| Field | Type | Description | Units / Scale | LLM interpretation note | +|---|---|---|---|---| +| `acidDetail.isAcid` | `bool` | Categorical "this track sounds like acid-style synthesis" flag. | boolean | When True, recommend resonant filter modulation (Auto Filter with high resonance + LFO automation) on the bass-like element. | +| `acidDetail.confidence` | `float` | Composite confidence in the acid-detection decision. | 0.0-1.0 | Hedge when confidence < 0.5; cite the resonanceLevel + centroidOscillationHz scalars in the recommendation reason. | +| `acidDetail.resonanceLevel` | `float` | Mean spectral-centroid oscillation amplitude proxy. | 0.0-1.0 | High values indicate strong filter sweep; cite for Auto Filter Q recommendations. | +| `acidDetail.centroidOscillationHz` | `float` | Dominant frequency of centroid modulation. | Hz | Cite for LFO rate recommendations on the filter cutoff (e.g. "the centroid oscillates at 97 Hz → set Auto Filter LFO rate to match"). | +| `acidDetail.bassRhythmDensity` | `float` | Onset events per second in the bass-energy band. | events/s | Cross-reference with `kickDetail.kickCount / durationSeconds` — high bassRhythmDensity + low kickCount = acid line driving the rhythm. | + +### `bassDetail` + +Type: `object \| null` + +| Field | Type | Description | Units / Scale | LLM interpretation note | +|---|---|---|---|---| +| `bassDetail.fundamentalHz` | `int \| null` | Estimated bass fundamental via ZCR on the 150 Hz lowpassed signal. **Approximation: ZCR/2 equals f₀ only for pure sinusoids. Harmonic-rich basses (e.g. a 35 Hz sub with strong 70 Hz second-harmonic energy) bias upward — prefer `pitchDetail` when stems are available.** | Hz, clamped to 30-120 | Cite for Operator/Wavetable Coarse tuning; cross-reference with `key` to determine the bass note. Avoid driving a narrow filter Q within ±15 Hz of this value without corroborating evidence from `pitchDetail`. | +| `bassDetail.averageDecayMs` | `int \| null` | Mean decay time per detected bass transient (peak-anchored envelope decay to -6 dB). | milliseconds | Drives bass-type categorization. Cite for Glue Compressor release-time. <100 ms = punchy; 100-300 = medium; 300-600 = rolling; 600+ = sustained. | +| `bassDetail.type` | `"punchy" \| "medium" \| "rolling" \| "sustained"` | Categorical bass character derived from `averageDecayMs`. | categorical | Drives device-choice direction: punchy → kick-bus-style compression; sustained → Glue Compressor with longer release. | +| `bassDetail.transientCount` | `int` | Number of detected bass onsets. | count | A bass density proxy. | +| `bassDetail.transientRatio` | `float` | Ratio of transient-window energy to total bass-band energy. | 0.0-1.0 | High values = punchy/staccato; low values = sustained pad-like bass. | +| `bassDetail.swingPercent` | `int` | Swing inferred from bass onset-interval alternation (lag-1 autocorr). | 0-50 | Cite for Ableton Groove pool swing % when adding humanization to a re-built MIDI bass. | +| `bassDetail.grooveType` | `"straight" \| "slight-swing" \| "heavy-swing" \| "shuffle"` | Categorical groove derived from `swingPercent`. | categorical | Drives MIDI-quantize humanize amount. | + +### `kickDetail` + +Type: `object \| null` + +| Field | Type | Description | Units / Scale | LLM interpretation note | +|---|---|---|---|---| +| `kickDetail.fundamentalHz` | `int \| null` | Estimated kick fundamental from sub-bass spectral peak detection. | Hz, typically 30-120 | Cite for Operator/Wavetable kick-tone selection. <60 Hz = deep sub kick; 60-90 Hz = standard 909-ish; 90+ Hz = high-tuned kick. | +| `kickDetail.kickCount` | `int` | Count of detected kick events (preferentially on the drums stem when stems are available). | count | Cross-check density vs BPM. On full mix this can over-count due to bass/transient bleed; on `drums` stem the count is more reliable. | +| `kickDetail.thd` | `float \| null` | Total harmonic distortion proxy for the kick hits. | 0.0-1.0+ | Cite for Saturator drive recommendations. >0.2 indicates audible saturation. | +| `kickDetail.harmonicRatio` | `float \| null` | Harmonic-to-noise ratio across detected kicks. | 0.0-1.0 | High values (>0.9) indicate clean/sub-heavy kicks; low values indicate noisy/punchy kicks with snap content. | +| `kickDetail.isDistorted` | `bool` | True when THD exceeds a threshold + harmonic spread is broad. | boolean | Hint-only; cite for "the kick is already saturated — don't add more Saturator." | + +### `supersawDetail` + +Type: `object \| null` + +| Field | Type | Description | Units / Scale | LLM interpretation note | +|---|---|---|---|---| +| `supersawDetail.isSupersaw` | `bool` | Categorical "stacked-detuned-saws" presence. | boolean | When True, recommend Wavetable in Supersaw / Sawtooth mode with the measured voiceCount + detuneCents. | +| `supersawDetail.confidence` | `float` | Composite confidence. | 0.0-1.0 | Hedge when below 0.5. | +| `supersawDetail.voiceCount` | `int` | Estimated unison voice count via near-unison peak grouping. | count | Cite directly for Wavetable Voice Stack value (typically 5-7). | +| `supersawDetail.avgDetuneCents` | `float` | Mean detune spread of detected unison voices. | cents | Cite for Wavetable Detune amount in cents. | +| `supersawDetail.spectralComplexity` | `float` | Per-frame peak-count proxy for harmonic richness. | unitless | High values support the supersaw classification; cite alongside `voiceCount`. | + +### `genreDetail` + +Type: `object \| null` + +| Field | Type | Description | Units / Scale | LLM interpretation note | +|---|---|---|---|---| +| `genreDetail.genre` | `string` | Predicted primary genre from heuristic signature matching. | label | Treat as context only — Phase 2 invariant says genre is short context, not the main blueprint. | +| `genreDetail.confidence` | `float` | Match-score against the predicted genre signature. | 0.0-1.0 | Genre detection on electronic music is noisy; hedge when below 0.7. | +| `genreDetail.secondaryGenre` | `string` | Second-best signature match. | label | Use to triangulate when primary is borderline — e.g. "psytrance + acid-techno" both indicates Goa-style content. | +| `genreDetail.genreFamily` | `string` | Coarse parent family (`trance`, `house`, `hip-hop`, etc.). | label | More stable than the specific genre; safer to anchor recommendations against the family. | +| `genreDetail.topScores` | `array<{genre, score}>` | Top-5 candidate scores. | (label, 0.0-1.0) | Surface as alternates when primary confidence is below 0.7. | + ## Danceability ### `danceability` diff --git a/apps/backend/analysis_runtime.py b/apps/backend/analysis_runtime.py index 7fa1332f..44b7c663 100644 --- a/apps/backend/analysis_runtime.py +++ b/apps/backend/analysis_runtime.py @@ -1554,13 +1554,21 @@ def _resolve_analysis_mode(requested_mode: str) -> str: def resolve_measurement_flags( requested_pitch_note_mode: str, ) -> tuple[bool, bool]: - # Symbolic work (separation + transcription) is handled by the dedicated - # pitch_note_translation stage enqueued via _enqueue_requested_followups(). - # Running it inline during measurement was redundant — the result was - # stripped anyway (see complete_measurement: pop("transcriptionDetail")). + # Transcription is handled by the dedicated pitch_note_translation + # stage enqueued via _enqueue_requested_followups() — running it inline + # during measurement was redundant since transcriptionDetail is popped + # in complete_measurement. + # + # Separation is needed at MEASUREMENT time for the Phase 1.B stem-first + # overlay (stemAnalysis namespace) — analyze.py only emits per-stem + # analyzers when stems exist when the orchestrator runs. So when the + # user opts into pitch-note translation (mode != "off"), run separation + # inline so stemAnalysis is in the payload that Phase 2 sees, without + # waiting on a second separation pass. if requested_pitch_note_mode not in ("off", "stem_notes"): raise UnsupportedPitchNoteModeError(requested_pitch_note_mode) - return False, False + run_separation = requested_pitch_note_mode == "stem_notes" + return run_separation, False @staticmethod def _preferred_pitch_note_row( diff --git a/apps/backend/analyze.py b/apps/backend/analyze.py index be7d7a2e..6ebc6587 100644 --- a/apps/backend/analyze.py +++ b/apps/backend/analyze.py @@ -60,6 +60,7 @@ _write_wav_pcm16, _demucs_chunked_inference, _load_stem_mono, + _load_stem_stereo, separate_stems, analyze_crepe_pitch, cleanup_stems, @@ -82,6 +83,7 @@ analyze_true_peak, analyze_dynamics, analyze_dynamic_character, + analyze_saturation_detail, TEXTURE_FLATNESS_BANDS, _build_texture_character, analyze_texture_character, @@ -147,6 +149,7 @@ analyze_reverb_detail, analyze_vocal_detail, analyze_supersaw_detail, + analyze_per_band_transient_density, _GENRE_SIGNATURES, _GENRE_FAMILY_MAP, _genre_range_score, @@ -320,17 +323,24 @@ def analyze_sidechain_detail( if total_samples < 2: return {"sidechainDetail": None} - # Build a 16th-note grid from beat intervals. - sixteenth_times = [] + # Build a 32nd-note grid (8 steps per beat). Internally the analyzer + # works at 32nd-note resolution so rate detection can distinguish + # 32nd-note from 16th-note pumping (Phase 1.C #6); the legacy + # `envelopeShape` field is still emitted at 16-step resolution by + # downsampling 32 → 16 to keep the existing contract green. + GRID_STEPS_PER_BEAT = 8 # 1 beat → 8 thirty-second-note slots + thirty_second_times: list[float] = [] for i in range(beats.size - 1): start = float(beats[i]) end = float(beats[i + 1]) if not np.isfinite(start) or not np.isfinite(end) or end <= start: continue - step = (end - start) / 4.0 - sixteenth_times.extend([start + j * step for j in range(4)]) + step = (end - start) / GRID_STEPS_PER_BEAT + thirty_second_times.extend( + [start + j * step for j in range(GRID_STEPS_PER_BEAT)] + ) - if len(sixteenth_times) == 0: + if len(thirty_second_times) == 0: return { "sidechainDetail": { "pumpingStrength": 0.0, @@ -339,15 +349,15 @@ def analyze_sidechain_detail( "pumpingConfidence": 0.0, } } - sixteenth_times.append(float(beats[-1])) - sixteenth_times = np.asarray(sixteenth_times, dtype=np.float64) + thirty_second_times.append(float(beats[-1])) + thirty_second_times = np.asarray(thirty_second_times, dtype=np.float64) rms_algo = es.RMS() rms_values = [] centers = [] - for i in range(sixteenth_times.size - 1): - start_t = float(sixteenth_times[i]) - end_t = float(sixteenth_times[i + 1]) + for i in range(thirty_second_times.size - 1): + start_t = float(thirty_second_times[i]) + end_t = float(thirty_second_times[i + 1]) if end_t <= start_t: continue start_idx = max(0, min(total_samples, int(round(start_t * sample_rate)))) @@ -427,11 +437,15 @@ def zscore(values: np.ndarray) -> np.ndarray: np.clip(1.0 - (np.std(interval_steps) / mean_step), 0.0, 1.0) ) + # Grid is now at 32nd-note resolution (8 steps per beat). + # quarter = 1 beat = 8 steps, eighth = 1/2 beat = 4 steps, + # sixteenth = 1/4 beat = 2 steps, thirty_second = 1/8 beat = 1 step. rate_scores = {} for label, target in ( - ("quarter", 4.0), - ("eighth", 2.0), - ("sixteenth", 1.0), + ("quarter", 8.0), + ("eighth", 4.0), + ("sixteenth", 2.0), + ("thirty_second", 1.0), ): if interval_steps.size == 0: rate_scores[label] = 0.0 @@ -487,17 +501,29 @@ def zscore(values: np.ndarray) -> np.ndarray: confidence *= 0.7 pumping_confidence = float(np.clip(confidence, 0.0, 1.0)) - # Envelope shape: median RMS across bars at 16th-note resolution (16 values) - envelope_shape = None - if rms_values.size >= 16: - n_bars = rms_values.size // 16 + # Envelope shape: median RMS across bars at 32nd-note (32-step) resolution. + # Then downsample 32 → 16 for the legacy `envelopeShape` contract while + # preserving the full 32-step detail under `envelopeShape32`. + envelope_shape: list[float] | None = None + envelope_shape_32: list[float] | None = None + if rms_values.size >= 32: + n_bars = rms_values.size // 32 if n_bars >= 1: - bars_matrix = rms_values[:n_bars * 16].reshape(n_bars, 16) - median_bar = np.median(bars_matrix, axis=0) - bar_max = float(np.max(median_bar)) - if bar_max > 0: - normalized = median_bar / bar_max - envelope_shape = [round(float(v), 3) for v in normalized] + bars_matrix = rms_values[:n_bars * 32].reshape(n_bars, 32) + median_bar_32 = np.median(bars_matrix, axis=0) + bar_max_32 = float(np.max(median_bar_32)) + if bar_max_32 > 0: + normalized_32 = median_bar_32 / bar_max_32 + envelope_shape_32 = [round(float(v), 3) for v in normalized_32] + # Downsample to 16 by max-pairing adjacent 32nd-note samples + # — preserves the "peak at the kick" shape that the legacy + # consumer expects, rather than averaging and smearing dips. + paired = normalized_32.reshape(16, 2) + median_bar_16 = paired.max(axis=1) + bar_max_16 = float(np.max(median_bar_16)) + if bar_max_16 > 0: + normalized_16 = median_bar_16 / bar_max_16 + envelope_shape = [round(float(v), 3) for v in normalized_16] return { "sidechainDetail": { @@ -508,6 +534,7 @@ def zscore(values: np.ndarray) -> np.ndarray: "pumpingRate": pumping_rate, "pumpingConfidence": round(pumping_confidence, 4), "envelopeShape": envelope_shape, + "envelopeShape32": envelope_shape_32, } } except Exception as e: @@ -600,6 +627,25 @@ def analyze_bass_detail( MAX_DECAY_MS = 2000.0 decay_times: list[float] = [] + # Pre-compute an RMS envelope of the bass band so decay-to-threshold + # is measured on the envelope, not the raw oscillating waveform + # (a 50 Hz sine crosses zero every 10 ms, which made the old loop + # record sub-millisecond decays for every note). + env_hop = max(1, int(sample_rate * 0.005)) # 5 ms hop + env_win = max(env_hop, int(sample_rate * 0.02)) # 20 ms window + if bass.size >= env_win: + n_frames = 1 + (bass.size - env_win) // env_hop + bass_envelope = np.empty(n_frames, dtype=np.float32) + for fi in range(n_frames): + start = fi * env_hop + segment = bass[start:start + env_win] + bass_envelope[fi] = float(np.sqrt(np.mean(segment ** 2))) + else: + bass_envelope = np.array([float(np.sqrt(np.mean(bass ** 2)))], dtype=np.float32) + + def _sample_to_env(sample_index: int) -> int: + return int(min(max(0, sample_index // env_hop), bass_envelope.size - 1)) + for idx in range(len(onsets) - 1): onset_sample = onsets[idx] next_onset = onsets[idx + 1] @@ -607,22 +653,35 @@ def analyze_bass_detail( next_onset - onset_sample, int((MAX_DECAY_MS / 1000.0) * sample_rate), ) - # Find peak near onset (50 ms search window) - search_end = min(onset_sample + int(sample_rate * 0.05), bass.size) - peak_val = float(np.max(np.abs(bass[onset_sample:search_end]))) if search_end > onset_sample else 0.0 - if peak_val < 0.001: + # Find peak of the envelope inside a 50 ms search window starting at + # the onset trigger. + env_start = _sample_to_env(onset_sample) + env_search_end = _sample_to_env(min(onset_sample + int(sample_rate * 0.05), bass.size - 1)) + 1 + if env_search_end <= env_start: continue - threshold_val = peak_val * (10.0 ** (DECAY_THRESHOLD_DB / 20.0)) + window_env = bass_envelope[env_start:env_search_end] + if window_env.size == 0: + continue + peak_env_offset = int(np.argmax(window_env)) + peak_env_val = float(window_env[peak_env_offset]) + if peak_env_val < 0.001: + continue + threshold_val = peak_env_val * (10.0 ** (DECAY_THRESHOLD_DB / 20.0)) + + # Decay window in envelope frames, anchored at the peak (not the onset). + peak_env_index = env_start + peak_env_offset + max_decay_env = max(0, max_decay_samples // env_hop - peak_env_offset) found = False - for s in range(max_decay_samples): - if onset_sample + s >= bass.size: + for s in range(max_decay_env): + env_pos = peak_env_index + s + if env_pos >= bass_envelope.size: break - if abs(float(bass[onset_sample + s])) < threshold_val: - decay_times.append((s / float(sample_rate)) * 1000.0) + if bass_envelope[env_pos] < threshold_val: + decay_times.append((s * env_hop / float(sample_rate)) * 1000.0) found = True break - if not found: - decay_times.append((max_decay_samples / float(sample_rate)) * 1000.0) + if not found and max_decay_env > 0: + decay_times.append((max_decay_env * env_hop / float(sample_rate)) * 1000.0) avg_decay = float(np.mean(decay_times)) if decay_times else 800.0 @@ -858,6 +917,211 @@ def analyze_kick_detail( return {"kickDetail": None} +def _analyze_band_drum_detail( + mono: np.ndarray, + sample_rate: int, + band_lo_hz: float, + band_hi_hz: float, + bpm: float | None, + stems: dict | None, + *, + min_event_dist_subdivisions: float = 0.25, # 16th-note default + body_split_ratio: float = 0.5, +) -> dict | None: + """Phase 1.C #4 shared helper — band-limited drum onset + character analysis. + + Returns a dict with hit count, mean attack sharpness, body-vs-snap energy + ratio, mean band centroid, and decay character. The same shape is used for + snare and hi-hat — only the band range differs. Returns None on failure or + when fewer than 2 onsets are detected. + """ + if es is None or mono is None or getattr(mono, "size", 0) < 4096: + return None + try: + source_mono = _load_stem_mono(stems, "drums", sample_rate=sample_rate) + if source_mono is None: + source_mono = mono + mono_arr = np.asarray(source_mono, dtype=np.float32) + if mono_arr.ndim != 1 or mono_arr.size < 4096: + return None + + effective_bpm = bpm if (bpm is not None and np.isfinite(bpm) and bpm > 0) else 120.0 + frame_size = 2048 + hop_size = 256 + nyquist = sample_rate / 2.0 + lo = max(20.0, min(band_lo_hz, nyquist - 2.0)) + hi = max(lo + 50.0, min(band_hi_hz, nyquist - 1.0)) + + window = es.Windowing(type="hann", size=frame_size) + spectrum_algo = es.Spectrum(size=frame_size) + freq_resolution = float(sample_rate) / float(frame_size) + low_bin = max(1, int(lo / freq_resolution)) + high_bin = min(frame_size // 2 - 1, int(hi / freq_resolution)) + mid_bin = max(low_bin + 1, int((lo + (hi - lo) * body_split_ratio) / freq_resolution)) + + envelope: list[float] = [] + for frame in es.FrameGenerator(mono_arr, frameSize=frame_size, hopSize=hop_size): + spec = spectrum_algo(window(frame)) + band_energy = 0.0 + for k in range(low_bin, high_bin + 1): + band_energy += float(spec[k]) ** 2 + n_bins = max(1, high_bin - low_bin + 1) + envelope.append(float(np.sqrt(band_energy / n_bins))) + + if len(envelope) < 5: + return None + + beat_dur_s = 60.0 / effective_bpm + min_dist_samples = int(beat_dur_s * min_event_dist_subdivisions * sample_rate) + min_dist_frames = max(1, min_dist_samples // hop_size) + + envelope_arr = np.asarray(envelope, dtype=np.float64) + env_max = float(envelope_arr.max()) if envelope_arr.size > 0 else 0.0 + if env_max <= 0.0: + return None + # Adaptive threshold: 10% of envelope max is "real" event territory. + peak_floor = max(env_max * 0.10, 0.005) + + transients: list[int] = [] + last_t = -min_dist_frames + for i in range(2, len(envelope) - 2): + if ( + envelope[i] > envelope[i - 1] + and envelope[i] > envelope[i + 1] + and envelope[i] > peak_floor + and i - last_t >= min_dist_frames + ): + transients.append(i) + last_t = i + + if len(transients) < 2: + return None + + attack_sharpness_values: list[float] = [] + body_energy_values: list[float] = [] + snap_energy_values: list[float] = [] + centroid_values: list[float] = [] + decay_frames_values: list[int] = [] + + for t_idx in transients: + # Attack sharpness: envelope rise across the 2 frames before peak. + if t_idx >= 2: + rise = envelope[t_idx] - envelope[t_idx - 2] + attack_sharpness_values.append(max(0.0, rise)) + + # Decay: how many frames after the peak until envelope drops below + # peak * 0.3. Caps at 60 frames (~350 ms at 256-hop 44.1k). + decay_target = envelope[t_idx] * 0.3 + decay_count = 0 + for j in range(t_idx + 1, min(t_idx + 60, len(envelope))): + if envelope[j] < decay_target: + break + decay_count += 1 + decay_frames_values.append(decay_count) + + # Per-hit spectral split: body (lower half of band) vs snap (upper half). + start_sample = t_idx * hop_size + if start_sample + frame_size > mono_arr.size: + continue + raw_frame = mono_arr[start_sample : start_sample + frame_size] + spec = spectrum_algo(window(raw_frame)) + body_e = 0.0 + snap_e = 0.0 + num_weighted = 0.0 + denom_weighted = 0.0 + for k in range(low_bin, min(high_bin + 1, spec.size)): + mag = float(spec[k]) + power = mag * mag + if k <= mid_bin: + body_e += power + else: + snap_e += power + num_weighted += k * freq_resolution * mag + denom_weighted += mag + total_e = body_e + snap_e + if total_e > 0.0: + body_energy_values.append(body_e / total_e) + snap_energy_values.append(snap_e / total_e) + if denom_weighted > 0.0: + centroid_values.append(num_weighted / denom_weighted) + + if not attack_sharpness_values: + return None + + return { + "hitCount": len(transients), + "hitsPerSecond": round(len(transients) / (mono_arr.size / float(sample_rate)), 2), + "meanAttackSharpness": round(float(np.mean(attack_sharpness_values)), 4), + "meanBodyEnergyRatio": round(float(np.mean(body_energy_values)), 3) if body_energy_values else None, + "meanSnapEnergyRatio": round(float(np.mean(snap_energy_values)), 3) if snap_energy_values else None, + "meanCentroidHz": round(float(np.mean(centroid_values)), 1) if centroid_values else None, + "meanDecayFrames": round(float(np.mean(decay_frames_values)), 1), + "meanDecaySeconds": round(float(np.mean(decay_frames_values)) * hop_size / float(sample_rate), 3), + "bandHz": [round(lo, 1), round(hi, 1)], + } + except Exception as exc: + print(f"[warn] band drum analysis [{band_lo_hz}-{band_hi_hz} Hz] failed: {exc}", file=sys.stderr) + return None + + +def analyze_snare_detail( + mono: np.ndarray, + sample_rate: int = 44100, + bpm: float | None = None, + stems: dict | None = None, +) -> dict: + """Phase 1.C #4 — snare-band character: hit count, attack sharpness, + body-vs-snap energy ratio. Mirrors analyze_kick_detail but in the + 120-2000 Hz band where snare fundamentals and body live. + + Uses the drums stem when stems are available, otherwise falls back to + full-mix audio bandpassed by spectrum-bin selection. Phase 2 cites + `snareDetail.meanBodyEnergyRatio` for snare-bus body/saturation choices, + `snareDetail.hitsPerSecond` for groove-density claims. + """ + return { + "snareDetail": _analyze_band_drum_detail( + mono, + sample_rate, + band_lo_hz=120.0, + band_hi_hz=2000.0, + bpm=bpm, + stems=stems, + min_event_dist_subdivisions=0.5, # 8th-note minimum (snare typically on 2 & 4) + body_split_ratio=0.35, # body=120-755 Hz, snap=755-2000 Hz + ) + } + + +def analyze_hihat_detail( + mono: np.ndarray, + sample_rate: int = 44100, + bpm: float | None = None, + stems: dict | None = None, +) -> dict: + """Phase 1.C #4 — hi-hat-band character: hit count, attack sharpness, + decay character, mean brightness. Mirrors analyze_kick_detail but in the + 2000-12000 Hz band where hi-hat content sits. + + `meanDecaySeconds` is a rough open-vs-closed proxy: closed hats decay + quickly (~30-60 ms), open hats sustain longer. Phase 2 cites + `hihatDetail.hitsPerSecond` for 16th-note hat density and + `hihatDetail.meanDecaySeconds` for open/closed inference. + """ + return { + "hihatDetail": _analyze_band_drum_detail( + mono, + sample_rate, + band_lo_hz=2000.0, + band_hi_hz=12000.0, + bpm=bpm, + stems=stems, + min_event_dist_subdivisions=0.125, # 32nd-note (hats can be dense) + body_split_ratio=0.4, + ) + } + + # ───────────────────────────────────────────────────────────────────────────── # GENRE CLASSIFICATION # Backport of genreClassifierEnhanced.ts — scores 35 electronic subgenres @@ -913,6 +1177,114 @@ def _run_pitch_note_translation( shutil.rmtree(temp_dir, ignore_errors=True) +def _run_per_stem_analyses( + stems: dict | None, + sample_rate: int, +) -> dict | None: + """Run the high-value full-mix analyzers against each Demucs stem and + return their results namespaced under ``stemAnalysis.{stem}.{field}``. + + This is the Phase 1.B "stem-first overlay" from the depth roadmap. Each + stem becomes its own analytical surface so Phase 2 can recommend + different processing for kick vs bass vs lead vs vocals — e.g. cite + ``stemAnalysis.bass.spectralBalance.subBass`` for a bass-only EQ move + instead of relying on a full-mix scalar that conflates every element. + + The subset of analyzers run per-stem is deliberately narrow: + - spectralBalance / spectralBalanceTimeSeries — per-element EQ shape. + - spectralDetail — per-element timbre summary. + - LUFS (integrated, range, momentary/short-term max, lufsCurve) — per-element loudness. + - stereo (width, correlation, sub-bass mono check, correlationCurve) — only meaningful when stems are stereo, which Demucs output is. + - dynamics + dynamicCharacter — per-element transient/dynamic shape. + + BPM, key, time signature, structure novelty, sidechain pumping, etc. are + intentionally NOT per-stem — they're properties of the song, not any + one element. + + Sequential implementation. Per the plan, parallelism over stems is a + follow-up gated on a benchmark — Essentia's C++ side can use OpenMP + threads internally, so naive Python-thread parallelism risks CPU + oversubscription on Apple Silicon. + """ + if not isinstance(stems, dict) or not stems: + return None + + stem_results: dict[str, dict[str, Any]] = {} + for stem_name in ("drums", "bass", "other", "vocals"): + try: + mono = _load_stem_mono(stems, stem_name, sample_rate=sample_rate) + except Exception: + mono = None + if mono is None or mono.size == 0: + continue + try: + stereo = _load_stem_stereo(stems, stem_name) + except Exception: + stereo = None + + per_stem: dict[str, Any] = {} + try: + spectral_balance_block = analyze_spectral_balance(mono, sample_rate) + per_stem["spectralBalance"] = spectral_balance_block.get("spectralBalance") + per_stem["spectralBalanceTimeSeries"] = spectral_balance_block.get("spectralBalanceTimeSeries") + except Exception as exc: + print(f"[stem:{stem_name}] spectralBalance failed: {exc}", file=sys.stderr) + + try: + spectral_detail_block = analyze_spectral_detail(mono, sample_rate) + per_stem["spectralDetail"] = spectral_detail_block.get("spectralDetail") + except Exception as exc: + print(f"[stem:{stem_name}] spectralDetail failed: {exc}", file=sys.stderr) + + if stereo is not None and isinstance(stereo, np.ndarray) and stereo.ndim == 2 and stereo.shape[1] >= 2: + try: + loudness_block = analyze_loudness(stereo) + per_stem["lufsIntegrated"] = loudness_block.get("lufsIntegrated") + per_stem["lufsRange"] = loudness_block.get("lufsRange") + per_stem["lufsMomentaryMax"] = loudness_block.get("lufsMomentaryMax") + per_stem["lufsShortTermMax"] = loudness_block.get("lufsShortTermMax") + per_stem["lufsCurve"] = loudness_block.get("lufsCurve") + except Exception as exc: + print(f"[stem:{stem_name}] LUFS failed: {exc}", file=sys.stderr) + try: + stereo_block = analyze_stereo(stereo, sample_rate) + per_stem["stereoDetail"] = stereo_block.get("stereoDetail") + except Exception as exc: + print(f"[stem:{stem_name}] stereoDetail failed: {exc}", file=sys.stderr) + try: + true_peak_block = analyze_true_peak(stereo) + per_stem["truePeak"] = true_peak_block.get("truePeak") + except Exception as exc: + print(f"[stem:{stem_name}] truePeak failed: {exc}", file=sys.stderr) + + try: + dynamics_block = analyze_dynamics(mono, sample_rate) + per_stem["crestFactor"] = dynamics_block.get("crestFactor") + per_stem["dynamicSpread"] = dynamics_block.get("dynamicSpread") + except Exception as exc: + print(f"[stem:{stem_name}] dynamics failed: {exc}", file=sys.stderr) + try: + dynamic_character_block = analyze_dynamic_character(mono, sample_rate) + per_stem["dynamicCharacter"] = dynamic_character_block.get("dynamicCharacter") + except Exception as exc: + print(f"[stem:{stem_name}] dynamicCharacter failed: {exc}", file=sys.stderr) + + # Phase 1.D #5: per-stem reverb (RT60 + perBandRt60 + preDelayMs). + # Drums and "other" benefit most — bass/vocals are often dry; the + # analyzer gates with `measured=False` when there aren't enough + # transients, so dry sources don't pollute the output. + try: + reverb_block = analyze_reverb_detail(mono, sample_rate) + per_stem["reverbDetail"] = reverb_block.get("reverbDetail") + except Exception as exc: + print(f"[stem:{stem_name}] reverbDetail failed: {exc}", file=sys.stderr) + + if per_stem: + stem_results[stem_name] = per_stem + + return stem_results if stem_results else None + + def main(): if len(sys.argv) < 2: print( @@ -1022,6 +1394,7 @@ def main(): "sampleRate": result.get("sampleRate"), "lufsIntegrated": result.get("lufsIntegrated"), "lufsRange": result.get("lufsRange"), + "lufsCurve": result.get("lufsCurve"), "truePeak": result.get("truePeak"), "plr": fast_plr, "crestFactor": result.get("crestFactor"), @@ -1031,7 +1404,13 @@ def main(): "stereoDetail": result.get("stereoDetail"), "monoCompatible": fast_mono_compatible, "spectralBalance": result.get("spectralBalance"), + "spectralBalanceTimeSeries": result.get("spectralBalanceTimeSeries"), "spectralDetail": result.get("spectralDetail"), + "stemAnalysis": result.get("stemAnalysis"), + "transientDensityDetail": result.get("transientDensityDetail"), + "saturationDetail": result.get("saturationDetail"), + "snareDetail": result.get("snareDetail"), + "hihatDetail": result.get("hihatDetail"), "rhythmDetail": result.get("rhythmDetail"), "melodyDetail": result.get("melodyDetail"), "transcriptionDetail": result.get("transcriptionDetail"), @@ -1109,7 +1488,7 @@ def main(): result.update(analyze_bpm(rhythm_data, mono, sample_rate)) result.update(analyze_key(mono)) - result.update(analyze_time_signature(rhythm_data)) + result.update(analyze_time_signature(rhythm_data, mono=mono, sample_rate=sample_rate)) result.update(analyze_duration_and_sr(mono, sample_rate)) # LUFS + LRA (needs stereo) @@ -1167,6 +1546,13 @@ def main(): result.update(analyze_groove(mono, sample_rate, rhythm_data, beat_data)) result.update(analyze_beats_loudness(mono, sample_rate, rhythm_data, beat_data)) result.update(analyze_rhythm_timeline(mono, sample_rate, rhythm_data, beat_data)) + # Phase 1.C #1: per-band transient density. ~5-8s on a 2-min track. + result.update(analyze_per_band_transient_density(mono, sample_rate)) + + # Phase 1.C #5: saturation / clipping / over-compression telltales. + # Cheap (~0.5s), no new deps. Hint-only — Phase 2 must hedge. + saturation_stereo = stereo if stereo is not None else None + result.update(analyze_saturation_detail(mono, saturation_stereo, sample_rate)) result.update( analyze_sidechain_detail( mono, @@ -1235,6 +1621,23 @@ def main(): stems=stems, ) ) + # Phase 1.C #4: snare + hi-hat character analyzers. + result.update( + analyze_snare_detail( + mono, + sample_rate, + bpm=result.get("bpm"), + stems=stems, + ) + ) + result.update( + analyze_hihat_detail( + mono, + sample_rate, + bpm=result.get("bpm"), + stems=stems, + ) + ) result.update( analyze_effects_detail( mono, @@ -1324,6 +1727,17 @@ def main(): # Merge pitch extraction results result.update(pitch_result) + # Phase 1.B stem-first overlay: run high-value analyzers per Demucs stem + # and namespace results under ``stemAnalysis``. Returns ``None`` when + # stems aren't available (no separation requested or separation failed), + # so callers downstream can treat ``stemAnalysis`` as an additive + # capability rather than a contract change. + try: + result["stemAnalysis"] = _run_per_stem_analyses(stems, sample_rate) + except Exception as exc: + print(f"[warn] stem-first overlay failed: {exc}", file=sys.stderr) + result["stemAnalysis"] = None + # Build final output in the exact requested key order output = { "bpm": result.get("bpm"), @@ -1347,6 +1761,7 @@ def main(): "lufsRange": result.get("lufsRange"), "lufsMomentaryMax": result.get("lufsMomentaryMax"), "lufsShortTermMax": result.get("lufsShortTermMax"), + "lufsCurve": result.get("lufsCurve"), "truePeak": result.get("truePeak"), "plr": result.get("plr"), "crestFactor": result.get("crestFactor"), @@ -1356,7 +1771,13 @@ def main(): "stereoDetail": result.get("stereoDetail"), "monoCompatible": result.get("monoCompatible"), "spectralBalance": result.get("spectralBalance"), + "spectralBalanceTimeSeries": result.get("spectralBalanceTimeSeries"), "spectralDetail": result.get("spectralDetail"), + "stemAnalysis": result.get("stemAnalysis"), + "transientDensityDetail": result.get("transientDensityDetail"), + "saturationDetail": result.get("saturationDetail"), + "snareDetail": result.get("snareDetail"), + "hihatDetail": result.get("hihatDetail"), "rhythmDetail": result.get("rhythmDetail"), "melodyDetail": result.get("melodyDetail"), "transcriptionDetail": result.get("transcriptionDetail"), diff --git a/apps/backend/analyze_audio_io.py b/apps/backend/analyze_audio_io.py index 46625007..17e49c6d 100644 --- a/apps/backend/analyze_audio_io.py +++ b/apps/backend/analyze_audio_io.py @@ -47,6 +47,33 @@ def _load_stem_mono( return None +def _load_stem_stereo( + stems: dict | None, + stem_name: str, +) -> np.ndarray | None: + """Load a Demucs-produced stem as a (N, 2) stereo array. + + Demucs writes stems as 44.1 kHz stereo PCM16 WAV via `_write_wav_pcm16` + above. The stereo path is what `analyze_loudness` (LoudnessEBUR128) and + `analyze_stereo` consume. Returns None when the stem is missing or + unreadable. + """ + if not isinstance(stems, dict): + return None + + stem_path = stems.get(stem_name) + if not isinstance(stem_path, str) or not os.path.isfile(stem_path): + return None + + try: + audio, _sr, _channels = load_stereo(stem_path) + if audio is None: + return None + return audio + except Exception: + return None + + def _write_wav_pcm16(path: str, audio: np.ndarray, sample_rate: int) -> None: """Write a float waveform array to PCM16 WAV.""" data = np.asarray(audio, dtype=np.float32) diff --git a/apps/backend/analyze_core.py b/apps/backend/analyze_core.py index 1907b9b9..9b46bf2f 100644 --- a/apps/backend/analyze_core.py +++ b/apps/backend/analyze_core.py @@ -9,7 +9,14 @@ except ImportError: es = None -from dsp_utils import _safe_db, _compute_stereo_metrics +from dsp_utils import ( + _compute_stereo_correlation_curve, + _compute_stereo_metrics, + _downsample_band_energies_curve, + _downsample_lufs_array, + _pearson_corr, + _safe_db, +) def extract_rhythm(mono: np.ndarray) -> dict | None: @@ -177,7 +184,15 @@ def analyze_key(mono: np.ndarray) -> dict: def analyze_loudness(stereo: np.ndarray) -> dict: - """LUFS integrated loudness, range, and max momentary/short-term via LoudnessEBUR128.""" + """LUFS integrated loudness, range, max momentary/short-term, plus the + downsampled momentary + short-term curves over time. + + The curve series surface what EBU R128 already computes internally but the + pre-existing call discarded: producers can now see when the loudest + moments occur, not just how loud they peaked. Phase 2 cites + ``lufsCurve.shortTerm`` to explain drop vs breakdown contrast in section + advice. + """ try: loudness = es.LoudnessEBUR128() momentary, short_term, integrated, loudness_range = loudness(stereo) @@ -193,15 +208,26 @@ def analyze_loudness(stereo: np.ndarray) -> dict: finite_short_term = short_term_arr[np.isfinite(short_term_arr)] if finite_short_term.size > 0: lufs_short_term_max = round(float(np.max(finite_short_term)), 1) + lufs_curve = { + "shortTerm": _downsample_lufs_array(short_term_arr), + "momentary": _downsample_lufs_array(momentary_arr), + } return { "lufsIntegrated": round(float(integrated), 1), "lufsRange": round(float(loudness_range), 1), "lufsMomentaryMax": lufs_momentary_max, "lufsShortTermMax": lufs_short_term_max, + "lufsCurve": lufs_curve, } except Exception as e: print(f"[warn] LUFS extraction failed: {e}", file=sys.stderr) - return {"lufsIntegrated": None, "lufsRange": None, "lufsMomentaryMax": None, "lufsShortTermMax": None} + return { + "lufsIntegrated": None, + "lufsRange": None, + "lufsMomentaryMax": None, + "lufsShortTermMax": None, + "lufsCurve": None, + } def analyze_true_peak(stereo: np.ndarray) -> dict: @@ -528,14 +554,27 @@ def analyze_spectral_balance( *, precomputed_band_energies: dict | None = None, ) -> dict: - """Spectral balance across 7 frequency bands using EnergyBand + spectrum.""" + """Spectral balance across 7 frequency bands using EnergyBand + spectrum. + + Returns the existing per-band mean dB values under ``spectralBalance`` + (scalar-shape contract preserved verbatim) AND a sibling + ``spectralBalanceTimeSeries`` array of ``{t, : dB, ...}`` rows so + Phase 2 can cite section-relative spectral motion ("the high-end opens up + at 1:23") instead of static averages alone. The sibling field deliberately + sits outside ``spectralBalance`` because ``spectralBalance``'s exact-keys + shape is asserted by ``test_spectral_balance_has_seven_numeric_bands``. + """ try: + bands = SPECTRAL_BALANCE_BANDS + # Hop matches both the precomputed path (analyze_spectral_detail) and + # this function's own frame loop, both at 1024-sample hops. + hop_size = 1024 + frame_hop_seconds = hop_size / float(sample_rate) if sample_rate > 0 else 0.0 + if precomputed_band_energies is not None: band_energies = precomputed_band_energies else: - bands = SPECTRAL_BALANCE_BANDS frame_size = 2048 - hop_size = 1024 window = es.Windowing(type="hann", size=frame_size) spectrum = es.Spectrum(size=frame_size) @@ -560,10 +599,19 @@ def analyze_spectral_balance( db = 10 * np.log10(mean_energy) if mean_energy > 0 else -100.0 result[name] = round(float(db), 1) - return {"spectralBalance": result} + time_series = _downsample_band_energies_curve( + band_energies, + band_names=list(bands.keys()), + frame_hop_seconds=frame_hop_seconds, + ) + + return { + "spectralBalance": result, + "spectralBalanceTimeSeries": time_series, + } except Exception as e: print(f"[warn] Spectral balance analysis failed: {e}", file=sys.stderr) - return {"spectralBalance": None} + return {"spectralBalance": None, "spectralBalanceTimeSeries": None} def analyze_plr(lufs_integrated: float | None, true_peak: float | None) -> dict: @@ -819,6 +867,7 @@ def analyze_stereo(stereo: np.ndarray, sample_rate: int = 44100) -> dict: "stereoCorrelation": None, "subBassCorrelation": None, "subBassMono": None, + "correlationCurve": None, } } @@ -872,12 +921,34 @@ def analyze_stereo(stereo: np.ndarray, sample_rate: int = 44100) -> dict: sub_corr = sub_metrics.get("stereoCorrelation") sub_mono = None if sub_corr is None else bool(float(sub_corr) > 0.85) + # 1-second windowed correlation timeline — surfaces stereo automation + # (utility width sweeps, mono-collapsing the drop) that the global + # scalars conflate into one number. + correlation_curve = _compute_stereo_correlation_curve( + left, + right, + left_sub, + right_sub, + sample_rate=sample_rate, + ) + + # Phase 1.C #2: per-frequency-band L/R correlation (goniometer-style). + # Splits the global stereoCorrelation into the same 7 bands used by + # spectralBalance so Phase 2 can say "bass is mono, mids are wide, + # highs are narrow" instead of one global ratio. Each band gets a + # BandPass filter and Pearson correlation; bands with no energy + # (e.g. brilliance on a dark master) return null rather than a + # misleading zero. + band_correlations = _compute_band_stereo_correlations(left, right, sample_rate) + return { "stereoDetail": { "stereoWidth": stereo_metrics.get("stereoWidth"), "stereoCorrelation": stereo_metrics.get("stereoCorrelation"), "subBassCorrelation": sub_corr, "subBassMono": sub_mono, + "correlationCurve": correlation_curve, + "bandCorrelations": band_correlations, } } except Exception as e: @@ -887,9 +958,126 @@ def analyze_stereo(stereo: np.ndarray, sample_rate: int = 44100) -> dict: "stereoWidth": None, "stereoCorrelation": None, "subBassCorrelation": None, + "correlationCurve": None, "subBassMono": None, + "bandCorrelations": None, + } + } + + +def _compute_band_stereo_correlations( + left: np.ndarray, + right: np.ndarray, + sample_rate: int, +) -> dict[str, float | None]: + """Per-frequency-band L/R Pearson correlation across SPECTRAL_BALANCE_BANDS. + + Returns a dict keyed by band name with values in [-1, 1] (or None when the + band's bandpassed output has zero variance — i.e. the band is silent). + Phase 2 cites these to anchor element-band-specific stereo recommendations + (Utility width per band, mid-side EQ moves, etc.). + """ + if es is None: + return {name: None for name in SPECTRAL_BALANCE_BANDS} + results: dict[str, float | None] = {} + left_f32 = np.asarray(left, dtype=np.float32) + right_f32 = np.asarray(right, dtype=np.float32) + for name, (lo, hi) in SPECTRAL_BALANCE_BANDS.items(): + # Clamp to Nyquist defensively so a 22 kHz "brilliance" band stops at fs/2 - 1. + nyquist = sample_rate / 2.0 - 1.0 + lo_clamped = max(20.0, min(float(lo), nyquist)) + hi_clamped = max(lo_clamped + 1.0, min(float(hi), nyquist)) + cutoff = (lo_clamped + hi_clamped) / 2.0 + bandwidth = hi_clamped - lo_clamped + try: + bp_l = es.BandPass(cutoffFrequency=cutoff, bandwidth=bandwidth, sampleRate=sample_rate) + bp_r = es.BandPass(cutoffFrequency=cutoff, bandwidth=bandwidth, sampleRate=sample_rate) + band_left = np.asarray(bp_l(left_f32), dtype=np.float64) + band_right = np.asarray(bp_r(right_f32), dtype=np.float64) + corr = _pearson_corr(band_left, band_right) + results[name] = round(float(corr), 3) if np.isfinite(corr) else None + except Exception as exc: + print(f"[warn] band correlation {name} failed: {exc}", file=sys.stderr) + results[name] = None + return results + + +def analyze_saturation_detail( + mono: np.ndarray, + stereo: np.ndarray | None, + sample_rate: int = 44100, +) -> dict: + """Phase 1.C #5 — saturation / clipping / over-compression telltales. + + Cheap proxies for the kind of mastering decisions Phase 2 needs to make. + For each track we report: + + - clippedSampleCount / clippedSamplePercent: stereo samples whose absolute + value crosses 0.9999 (hard clipping threshold). + - nearClippedSamplePercent: stereo samples above 0.95 (mastering-loud territory). + - peakRatio95to50: ratio of |audio| 95th-percentile to 50th-percentile — + higher means more dynamic, lower means harder compression / limiting. + - rmsToPeakRatioDb: peak vs RMS in dB. Roughly the inverse of crest factor; + kept here so saturation analysis is self-contained. + - saturationLikely: heuristic boolean from the above. NOT a definitive + "is there saturation" — Phase 2 should treat as a hint. + + Cost: O(N) on the mono/stereo buffer. ~0.5 s on a 2-min track. No new + library dependencies. + """ + try: + if mono is None or getattr(mono, "size", 0) == 0: + return {"saturationDetail": None} + + # Clipping math on the stereo buffer when available; fall back to mono. + if stereo is not None and isinstance(stereo, np.ndarray) and stereo.size > 0: + sample_buffer = np.asarray(np.abs(stereo).max(axis=1) if stereo.ndim == 2 else np.abs(stereo), dtype=np.float64) + else: + sample_buffer = np.abs(np.asarray(mono, dtype=np.float64)) + + total = int(sample_buffer.size) + if total == 0: + return {"saturationDetail": None} + + clipped_mask = sample_buffer >= 0.9999 + near_clipped_mask = sample_buffer >= 0.95 + clipped_count = int(np.sum(clipped_mask)) + near_clipped_count = int(np.sum(near_clipped_mask)) + + # Compressed-ness proxy. + p95 = float(np.percentile(sample_buffer, 95)) + p50 = float(np.percentile(sample_buffer, 50)) if total > 0 else 0.0 + peak_ratio = round(p95 / p50, 3) if p50 > 1e-9 else None + + # Crest factor from |mono| (mirror analyze_dynamics math). Stays here for + # the saturationDetail summary so consumers can cite a single object. + mono_64 = np.asarray(mono, dtype=np.float64) + peak = float(np.max(np.abs(mono_64))) if mono_64.size > 0 else 0.0 + rms = float(np.sqrt(np.mean(mono_64 ** 2))) if mono_64.size > 0 else 0.0 + rms_to_peak_db = round(20.0 * np.log10(peak / rms), 2) if rms > 1e-9 and peak > 1e-9 else None + + # Heuristic: any of (1) >100 clipped samples in stereo, (2) >0.5% near-clipped, + # or (3) peak-ratio below 2.0 with low crest signals saturation/limiting. + saturation_likely = ( + clipped_count > 100 + or (total > 0 and (near_clipped_count / total) > 0.005) + or (peak_ratio is not None and peak_ratio < 2.0 and rms_to_peak_db is not None and rms_to_peak_db < 8.0) + ) + + return { + "saturationDetail": { + "clippedSampleCount": clipped_count, + "clippedSamplePercent": round(100.0 * clipped_count / total, 4), + "nearClippedSampleCount": near_clipped_count, + "nearClippedSamplePercent": round(100.0 * near_clipped_count / total, 3), + "peakRatio95to50": peak_ratio, + "rmsToPeakRatioDb": rms_to_peak_db, + "saturationLikely": bool(saturation_likely), } } + except Exception as exc: + print(f"[warn] saturation analysis failed: {exc}", file=sys.stderr) + return {"saturationDetail": None} def analyze_perceptual(mono: np.ndarray, sample_rate: int = 44100) -> dict: @@ -1012,8 +1200,36 @@ def analyze_duration_and_sr(mono: np.ndarray, sample_rate: int = 44100) -> dict: return {"durationSeconds": None, "sampleRate": None} -def analyze_time_signature(rhythm_data: dict | None) -> dict: - """Estimate time signature from shared rhythm data.""" +_TIME_SIG_BAR_CANDIDATES = (3, 4, 5, 6, 7) +_TIME_SIG_LABELS = {3: "3/4", 4: "4/4", 5: "5/4", 6: "6/8", 7: "7/8"} +_TIME_SIG_MARGIN_THRESHOLD = 0.20 # winner must beat 4/4 by 20% to override + + +def analyze_time_signature( + rhythm_data: dict | None, + mono: np.ndarray | None = None, + sample_rate: int = 44100, +) -> dict: + """Phase 1.C #0 — onset-accent autocorrelation for meter detection. + + Previously this function always returned ``"4/4"`` with confidence 0 + (assumed). Now: + + 1. Collect raw onset times via ``_detect_onset_times`` on ``mono`` (lazy + import to avoid a circular dependency with analyze_rhythm). + 2. For each beat tick, count onsets within ±half-beat-duration. + 3. For each candidate bar length B in (3, 4, 5, 6, 7), reshape the + per-beat onset counts into bars of B and compute "downbeat + dominance" = mean accent at position 1 / mean accent at positions 2..B. + 4. The candidate with the highest dominance wins, but only overrides 4/4 + when it beats 4/4's dominance by ``_TIME_SIG_MARGIN_THRESHOLD`` (20%). + + Falls back cleanly to the previous "assumed 4/4" behavior when: + - rhythm_data is missing + - fewer than 16 beats are detected + - mono is not provided (legacy callers) + - onset detection fails + """ try: if rhythm_data is None: return { @@ -1021,13 +1237,122 @@ def analyze_time_signature(rhythm_data: dict | None) -> dict: "timeSignatureSource": None, "timeSignatureConfidence": None, } + + raw_ticks = rhythm_data.get("ticks") + # Defensive: rhythm_data["ticks"] may already be a numpy array. + # ``arr or []`` raises "truth value of array is ambiguous" so use a + # None / len check instead. + if raw_ticks is None: + ticks = np.asarray([], dtype=np.float64) + else: + ticks = np.asarray(raw_ticks, dtype=np.float64) + if mono is None or ticks.size < 16: + # Not enough information to disambiguate — preserve the + # "assumed 4/4" contract that consumers expect today. + return { + "timeSignature": "4/4", + "timeSignatureSource": "assumed_four_four", + "timeSignatureConfidence": 0.0, + } + + # Lazy import — analyze_rhythm is loaded after analyze_core at the + # module level. Importing at function-call time avoids the cycle. + try: + from analyze_rhythm import _detect_onset_times + except Exception: + return { + "timeSignature": "4/4", + "timeSignatureSource": "assumed_four_four", + "timeSignatureConfidence": 0.0, + } + + onset_times = _detect_onset_times(mono, sample_rate) + if onset_times.size < 16: + return { + "timeSignature": "4/4", + "timeSignatureSource": "assumed_four_four", + "timeSignatureConfidence": 0.0, + } + + beat_diffs = np.diff(ticks) + beat_diffs = beat_diffs[beat_diffs > 0] + if beat_diffs.size == 0: + return { + "timeSignature": "4/4", + "timeSignatureSource": "assumed_four_four", + "timeSignatureConfidence": 0.0, + } + beat_duration = float(np.median(beat_diffs)) + half_beat = beat_duration / 2.0 + + # Count onsets falling within ±half-beat-duration of each beat tick — + # this is the "accent strength" signal at beat resolution. + beat_onset_counts = np.zeros(ticks.size, dtype=np.float64) + # Use searchsorted for O(N log N) instead of O(N*M) loop. + sorted_onsets = np.sort(onset_times) + for i, beat_t in enumerate(ticks): + left = np.searchsorted(sorted_onsets, beat_t - half_beat, side="left") + right = np.searchsorted(sorted_onsets, beat_t + half_beat, side="left") + beat_onset_counts[i] = float(right - left) + + # Compute downbeat dominance for each candidate bar length. + scores: dict[int, float] = {} + per_position_means: dict[int, list[float]] = {} + for B in _TIME_SIG_BAR_CANDIDATES: + if B <= 0: + continue + n_bars = beat_onset_counts.size // B + if n_bars < 2: + continue + matrix = beat_onset_counts[: n_bars * B].reshape(n_bars, B) + mean_per_position = matrix.mean(axis=0) + others_mean = float(np.mean(mean_per_position[1:])) + if others_mean <= 0.0: + continue + dominance = float(mean_per_position[0] / others_mean) + scores[B] = dominance + per_position_means[B] = [round(float(v), 3) for v in mean_per_position] + + if 4 not in scores: + # We couldn't even score 4/4 — fall back to the assumption. + return { + "timeSignature": "4/4", + "timeSignatureSource": "assumed_four_four", + "timeSignatureConfidence": 0.0, + } + + baseline = scores[4] + best_B = max(scores, key=scores.get) + best_score = scores[best_B] + winner_label = _TIME_SIG_LABELS.get(best_B, "4/4") + + if best_B == 4: + # 4/4 won outright; confidence rises with downbeat dominance. + confidence = max(0.0, min(1.0, (baseline - 1.0) / 2.0)) + return { + "timeSignature": "4/4", + "timeSignatureSource": "onset_autocorrelation", + "timeSignatureConfidence": round(float(confidence), 2), + } + + # Non-4/4 candidate won. Require a margin over 4/4 to override. + margin = (best_score - baseline) / max(baseline, 0.1) + if margin < _TIME_SIG_MARGIN_THRESHOLD: + confidence = max(0.0, min(1.0, (baseline - 1.0) / 2.0)) + return { + "timeSignature": "4/4", + "timeSignatureSource": "onset_autocorrelation_low_margin", + "timeSignatureConfidence": round(float(confidence), 2), + } + + confidence = max(0.0, min(1.0, margin)) return { - "timeSignature": "4/4", - "timeSignatureSource": "assumed_four_four", - "timeSignatureConfidence": 0.0, + "timeSignature": winner_label, + "timeSignatureSource": "onset_autocorrelation", + "timeSignatureConfidence": round(float(confidence), 2), } - except Exception as e: - print(f"[warn] Time signature estimation failed: {e}", file=sys.stderr) + except Exception as exc: + print(f"[warn] Time signature estimation failed: {exc}", file=sys.stderr) return { "timeSignature": None, "timeSignatureSource": None, diff --git a/apps/backend/analyze_detection.py b/apps/backend/analyze_detection.py index aa459dce..e1438890 100644 --- a/apps/backend/analyze_detection.py +++ b/apps/backend/analyze_detection.py @@ -9,10 +9,157 @@ except ImportError: es = None +try: + import librosa +except ImportError: + librosa = None # type: ignore[assignment] + +try: + from scipy import signal as scipy_signal # type: ignore[import-not-found] +except ImportError: + scipy_signal = None # type: ignore[assignment] + from dsp_utils import _safe_db, _compute_bark_db from analyze_audio_io import _load_stem_mono +# Phase 1.D #5 — bands for per-band RT60 estimation. Chosen to roughly mirror +# the SPECTRAL_BALANCE_BANDS aggregation but at octave granularity for +# decay-slope stability — wider bands give more energy per band → less +# noise-floor contamination during the dB-decay fit. +_REVERB_BANDS = ( + ("low", 20.0, 250.0), + ("lowMids", 250.0, 2000.0), + ("highMids", 2000.0, 8000.0), + ("highs", 8000.0, 16000.0), +) + + +def _bandpass_signal(mono: np.ndarray, sample_rate: int, lo_hz: float, hi_hz: float) -> np.ndarray | None: + """4th-order Butterworth bandpass via scipy.signal. Returns None on failure.""" + if scipy_signal is None or mono.size == 0: + return None + nyquist = 0.5 * sample_rate + lo = max(1.0, lo_hz) / nyquist + hi = min(sample_rate * 0.49, hi_hz) / nyquist + if not (0.0 < lo < hi < 1.0): + return None + try: + sos = scipy_signal.butter(4, [lo, hi], btype="bandpass", output="sos") + return scipy_signal.sosfiltfilt(sos, mono).astype(np.float32, copy=False) + except Exception: + return None + + +# Mirrors apps/backend/analyze_core.py SPECTRAL_BALANCE_BANDS — imported lazily +# below to avoid a circular import (analyze_core imports from this module's +# kin elsewhere). Falls back to a literal copy when the import path is +# unavailable at module-load time. +def _spectral_balance_bands() -> dict[str, tuple[int, int]]: + try: + from analyze_core import SPECTRAL_BALANCE_BANDS + return SPECTRAL_BALANCE_BANDS + except Exception: + return { + "subBass": (20, 80), + "lowBass": (80, 250), + "lowMids": (250, 500), + "mids": (500, 2000), + "upperMids": (2000, 5000), + "highs": (5000, 10000), + "brilliance": (10000, 20000), + } + + +def analyze_per_band_transient_density( + mono: np.ndarray, + sample_rate: int = 44100, +) -> dict: + """Per-frequency-band onset density across the 7 spectralBalance bands. + + Phase 1.C #1. Augments the global kick/transient detail with hi-hat + density / snare hit counts per band, computed via librosa onset + detection on bandpass-filtered audio. For each band we report: + + - onsetRatePerSecond: detected onset events per second (transient density) + - meanOnsetStrength: mean onset-envelope value at detected peaks + - peakOnsetStrength: max onset-envelope value across the track + - eventCount: number of detected onsets + + Phase 2 cites e.g. ``transientDensityDetail.highs.onsetRatePerSecond`` + to anchor hi-hat-bus recommendations, ``transientDensityDetail.lowBass`` + for kick density, etc. + """ + if librosa is None or mono is None or getattr(mono, "size", 0) == 0: + return {"transientDensityDetail": None} + + try: + from scipy.signal import butter, sosfiltfilt + except Exception: + return {"transientDensityDetail": None} + + duration_seconds = float(mono.size) / float(sample_rate) if sample_rate > 0 else 0.0 + if duration_seconds <= 0.0: + return {"transientDensityDetail": None} + + bands = _spectral_balance_bands() + result: dict[str, dict] = {} + nyquist = sample_rate / 2.0 + mono_64 = np.asarray(mono, dtype=np.float64) + + for name, (lo, hi) in bands.items(): + try: + lo_clamped = max(20.0, min(float(lo), nyquist - 2.0)) + hi_clamped = max(lo_clamped + 1.0, min(float(hi), nyquist - 1.0)) + sos = butter( + 4, + [lo_clamped / nyquist, hi_clamped / nyquist], + btype="band", + output="sos", + ) + band_audio = sosfiltfilt(sos, mono_64) + onset_env = librosa.onset.onset_strength( + y=np.asarray(band_audio, dtype=np.float32), + sr=sample_rate, + hop_length=512, + ) + if onset_env.size == 0: + result[name] = { + "onsetRatePerSecond": 0.0, + "meanOnsetStrength": 0.0, + "peakOnsetStrength": 0.0, + "eventCount": 0, + } + continue + onset_frames = librosa.onset.onset_detect( + onset_envelope=onset_env, + sr=sample_rate, + hop_length=512, + ) + n_events = int(len(onset_frames)) + rate = n_events / duration_seconds if duration_seconds > 0 else 0.0 + if n_events > 0: + mean_strength = float(np.mean(onset_env[onset_frames])) + else: + mean_strength = 0.0 + peak_strength = float(np.max(onset_env)) if onset_env.size > 0 else 0.0 + result[name] = { + "onsetRatePerSecond": round(rate, 2), + "meanOnsetStrength": round(mean_strength, 3), + "peakOnsetStrength": round(peak_strength, 3), + "eventCount": n_events, + } + except Exception as exc: + print(f"[warn] transient density {name} failed: {exc}", file=sys.stderr) + result[name] = { + "onsetRatePerSecond": 0.0, + "meanOnsetStrength": 0.0, + "peakOnsetStrength": 0.0, + "eventCount": 0, + } + return {"transientDensityDetail": result} + + def analyze_effects_detail( mono: np.ndarray, sample_rate: int = 44100, @@ -244,6 +391,83 @@ def analyze_acid_detail( return {"acidDetail": None} +def _measure_rt60_from_envelope( + envelope: np.ndarray, + hop_ms: float, + transient_indices: list[int], + *, + analysis_window_s: float = 2.0, + direct_ms: float = 50.0, +) -> tuple[list[float], list[float], list[float]]: + """Shared RT60-slope-fit helper. + + Returns three parallel lists for each transient that produced a measurable + decay slope: (rt60_seconds, tail_ratio, pre_delay_ms). Caller decides how + to aggregate (mean / median / per-band etc.). + """ + max_decay_frames = int(np.floor((analysis_window_s * 1000.0) / hop_ms)) + direct_end_frames = max(1, int(np.floor(direct_ms / hop_ms))) + + rt60_estimates: list[float] = [] + tail_ratios: list[float] = [] + pre_delay_estimates_ms: list[float] = [] + + for t_idx in range(len(transient_indices) - 1): + start_f = transient_indices[t_idx] + peak_e = float(envelope[start_f]) + if peak_e < 0.001: + continue + + end_f = min(start_f + max_decay_frames, transient_indices[t_idx + 1]) + if end_f <= start_f + 5: + continue + + direct_end = start_f + direct_end_frames + direct_energy = float(np.sum(envelope[start_f : min(direct_end, end_f)] ** 2)) + tail_energy = float(np.sum(envelope[min(direct_end, end_f) : end_f] ** 2)) + total_energy = direct_energy + tail_energy + if total_energy > 0: + tail_ratios.append(tail_energy / total_energy) + + # Pre-delay heuristic: between the peak frame and the first envelope + # minimum within the next 100 ms, then "the tail starts". For + # impulsive content this is the dry-tail boundary; for sustained + # content the minimum may be very close to the peak — that's the + # honest signal that there's no measurable pre-delay. + pre_delay_search_end = min(start_f + int(round(100.0 / hop_ms)), end_f) + if pre_delay_search_end > start_f + 2: + window_after_peak = envelope[start_f + 1 : pre_delay_search_end] + if window_after_peak.size > 0: + local_min_offset = int(np.argmin(window_after_peak)) + pre_delay_ms = (local_min_offset + 1) * hop_ms + if 0.0 <= pre_delay_ms <= 100.0: + pre_delay_estimates_ms.append(pre_delay_ms) + + seg = envelope[start_f:end_f] + valid = seg > 0 + if not np.any(valid): + continue + decay_db = 20.0 * np.log10(np.clip(seg[valid] / peak_e, 1e-10, None)) + if decay_db.size < 5: + continue + + n = decay_db.size + x = np.arange(n, dtype=np.float64) + x_mean, y_mean = float(np.mean(x)), float(np.mean(decay_db)) + num = float(np.sum((x - x_mean) * (decay_db - y_mean))) + den = float(np.sum((x - x_mean) ** 2)) + if den == 0: + continue + slope = num / den + if slope >= 0: + continue + rt60 = abs(-60.0 / (slope / (hop_ms / 1000.0))) + if 0.0 < rt60 < 5.0: + rt60_estimates.append(rt60) + + return rt60_estimates, tail_ratios, pre_delay_estimates_ms + + def analyze_reverb_detail( mono: np.ndarray, sample_rate: int = 44100, @@ -251,7 +475,14 @@ def analyze_reverb_detail( ) -> dict: """Estimate RT60 reverberation time from energy decay slopes after transients. - Ported from sonic-architect-app/services/reverbAnalysis.ts. + Ported from sonic-architect-app/services/reverbAnalysis.ts. Phase 1.D #5 + adds: + - `perBandRt60` — RT60 estimated separately in 4 octave bands (low / + lowMids / highMids / highs) by bandpassing the input before envelope + extraction. Each band measures the same transient stream. + - `preDelayMs` — median time between direct peak and first envelope + minimum within the next 100 ms, across all detected transients. A + proxy for reverb pre-delay; close to zero on dry sources. """ _TRANSIENT_THRESHOLD = 2.0 _MIN_TRANSIENTS = 4 @@ -277,7 +508,7 @@ def analyze_reverb_detail( envelope[i] = float(np.sqrt(np.mean(seg ** 2))) if envelope.size < 20: - return {"reverbDetail": {"rt60": None, "isWet": False, "tailEnergyRatio": None, "measured": False}} + return {"reverbDetail": {"rt60": None, "isWet": False, "tailEnergyRatio": None, "measured": False, "perBandRt60": None, "preDelayMs": None}} min_dist_frames = max(1, int(np.floor((((60.0 / bpm) * 1000.0) / _HOP_MS) * 0.5))) transient_indices: list[int] = [] @@ -295,64 +526,63 @@ def analyze_reverb_detail( transient_indices.append(i) if len(transient_indices) < _MIN_TRANSIENTS: - return {"reverbDetail": {"rt60": None, "isWet": False, "tailEnergyRatio": None, "measured": False}} - - max_decay_frames = int(np.floor((_ANALYSIS_WINDOW_S * 1000.0) / _HOP_MS)) - direct_end_frames = max(1, int(np.floor(_DIRECT_MS / _HOP_MS))) - rt60_estimates: list[float] = [] - tail_ratios: list[float] = [] - - for t_idx in range(len(transient_indices) - 1): - start_f = transient_indices[t_idx] - peak_e = envelope[start_f] - if peak_e < 0.001: - continue - - end_f = min(start_f + max_decay_frames, transient_indices[t_idx + 1]) - if end_f <= start_f + 5: - continue - - direct_end = start_f + direct_end_frames - direct_energy = float(np.sum(envelope[start_f : min(direct_end, end_f)] ** 2)) - tail_energy = float(np.sum(envelope[min(direct_end, end_f) : end_f] ** 2)) - total_energy = direct_energy + tail_energy - if total_energy > 0: - tail_ratios.append(tail_energy / total_energy) - - seg = envelope[start_f:end_f] - valid = seg > 0 - if not np.any(valid): - continue - decay_db = 20.0 * np.log10(np.clip(seg[valid] / peak_e, 1e-10, None)) - if decay_db.size < 5: - continue - - n = decay_db.size - x = np.arange(n, dtype=np.float64) - x_mean, y_mean = float(np.mean(x)), float(np.mean(decay_db)) - num = float(np.sum((x - x_mean) * (decay_db - y_mean))) - den = float(np.sum((x - x_mean) ** 2)) - if den == 0: - continue - slope = num / den - if slope >= 0: - continue - rt60 = abs(-60.0 / (slope / (_HOP_MS / 1000.0))) - if 0.0 < rt60 < 5.0: - rt60_estimates.append(rt60) + return {"reverbDetail": {"rt60": None, "isWet": False, "tailEnergyRatio": None, "measured": False, "perBandRt60": None, "preDelayMs": None}} + + rt60_estimates, tail_ratios, pre_delay_estimates_ms = _measure_rt60_from_envelope( + envelope, + hop_ms=_HOP_MS, + transient_indices=transient_indices, + analysis_window_s=_ANALYSIS_WINDOW_S, + direct_ms=_DIRECT_MS, + ) if not rt60_estimates: - return {"reverbDetail": {"rt60": None, "isWet": False, "tailEnergyRatio": None, "measured": False}} + return {"reverbDetail": {"rt60": None, "isWet": False, "tailEnergyRatio": None, "measured": False, "perBandRt60": None, "preDelayMs": None}} avg_rt60 = float(np.mean(rt60_estimates)) avg_tail = float(np.mean(tail_ratios)) if tail_ratios else 0.2 capped_rt60 = round(min(3.0, avg_rt60), 2) + + # Per-band RT60: re-use the SAME transient indices on the broadband + # envelope so each band measures the same events. Bandpass the raw + # signal first, recompute the per-band envelope, then run the slope + # fit. Requires scipy.signal — if unavailable, omit per-band. + per_band_rt60: dict[str, float] | None = None + if scipy_signal is not None: + per_band: dict[str, float] = {} + for band_name, lo_hz, hi_hz in _REVERB_BANDS: + filtered = _bandpass_signal(mono_arr, sample_rate, lo_hz, hi_hz) + if filtered is None: + continue + band_envelope = np.zeros(n_frames, dtype=np.float64) + for i in range(n_frames): + s = i * hop_samples + seg_b = filtered[s : s + hop_samples].astype(np.float64) + band_envelope[i] = float(np.sqrt(np.mean(seg_b ** 2))) + band_rt60, _, _ = _measure_rt60_from_envelope( + band_envelope, + hop_ms=_HOP_MS, + transient_indices=transient_indices, + analysis_window_s=_ANALYSIS_WINDOW_S, + direct_ms=_DIRECT_MS, + ) + if band_rt60: + per_band[band_name] = round(min(3.0, float(np.mean(band_rt60))), 2) + if per_band: + per_band_rt60 = per_band + + median_pre_delay_ms: float | None = None + if pre_delay_estimates_ms: + median_pre_delay_ms = round(float(np.median(pre_delay_estimates_ms)), 2) + return { "reverbDetail": { "rt60": capped_rt60, "isWet": avg_rt60 > 0.5, "tailEnergyRatio": round(float(np.clip(avg_tail, 0.0, 1.0)), 2), "measured": True, + "perBandRt60": per_band_rt60, + "preDelayMs": median_pre_delay_ms, } } except Exception as e: @@ -373,7 +603,68 @@ def analyze_vocal_detail( for formant detection instead of browser FFT. """ try: + # Demucs-ghost-stem check #1: vocals-stem RMS vs full-mix RMS. A vocals + # stem on a vocal-led track typically lands at 15-45% of full-mix RMS; + # a Demucs ghost stem on a track with NO real vocal drops below ~5% + # because Demucs has nothing real to extract and emits residual leakage. + full_mix_rms = float( + np.sqrt(np.mean(np.asarray(mono, dtype=np.float32) ** 2)) + ) if mono is not None and getattr(mono, "size", 0) > 0 else 0.0 + source_mono = _load_stem_mono(stems, "vocals", sample_rate) + stem_energy_ratio: float | None = None + if source_mono is not None and full_mix_rms > 1e-6: + stem_rms = float( + np.sqrt(np.mean(np.asarray(source_mono, dtype=np.float32) ** 2)) + ) + stem_energy_ratio = float(min(2.0, stem_rms / full_mix_rms)) + + # Demucs-ghost-stem check #2: vocals-vs-other cross-correlation. The + # energy check above only catches *quiet* ghost stems; tracks with a + # melodic lead that Demucs misclassifies push real lead-synth content + # into the vocals stem at 20-40% RMS — energy alone won't flag those. + # But a genuine vocal is uncorrelated with the synth/melody stem (they + # come from different sources, different mics, different rooms); a + # misclassified lead is heavily correlated with the "other" stem + # because both are looking at the same underlying source. We compute + # Pearson correlation on a 200 Hz envelope-rate downsample, which is + # robust to phase differences while preserving amplitude structure. + stem_other_correlation: float | None = None + if source_mono is not None and stems is not None: + try: + other_mono = _load_stem_mono(stems, "other", sample_rate) + if ( + other_mono is not None + and source_mono is not None + and getattr(source_mono, "size", 0) > sample_rate // 100 + and getattr(other_mono, "size", 0) > sample_rate // 100 + ): + # Decimate to a 200 Hz envelope via |signal| then mean-pool. + target_rate_hz = 200 + decimate = max(1, sample_rate // target_rate_hz) + v_arr = np.abs(np.asarray(source_mono, dtype=np.float32)) + o_arr = np.abs(np.asarray(other_mono, dtype=np.float32)) + common = min(v_arr.size, o_arr.size) + common -= common % decimate + if common >= decimate * 4: + v_env = v_arr[:common].reshape(-1, decimate).mean(axis=1) + o_env = o_arr[:common].reshape(-1, decimate).mean(axis=1) + v_std = float(np.std(v_env)) + o_std = float(np.std(o_env)) + if v_std > 1e-9 and o_std > 1e-9: + corr = float( + np.mean( + (v_env - v_env.mean()) * (o_env - o_env.mean()) + ) + / (v_std * o_std) + ) + if np.isfinite(corr): + stem_other_correlation = float( + max(-1.0, min(1.0, corr)) + ) + except Exception: + stem_other_correlation = None + if source_mono is None: source_mono = mono mono_arr = np.asarray(source_mono, dtype=np.float32) @@ -398,9 +689,16 @@ def analyze_vocal_detail( # Expected formant centre frequencies for an average adult voice expected_formants = [500.0, 1500.0, 2500.0] - formant_tolerance = 200.0 # Hz - formant_match_total = 0 + formant_tolerance = 100.0 # Hz — tightened from 200 to reject sustained + # synth harmonics that trivially fall inside a + # 400 Hz window around each expected formant. formant_frames = 0 + # Per-sampled-frame record of which expected formants were matched and at + # what peak frequency. Used after the loop to compute a temporal-stability + # penalty: real vocals shift formants by 100+ Hz across syllables; a + # sustained synth lead has near-static "formants" because the harmonic + # series of a fixed pitch lands at nearly the same bin every frame. + per_frame_matches: list[list[float | None]] = [] spectral_peaks_algo = es.SpectralPeaks( orderBy="frequency", @@ -425,22 +723,64 @@ def analyze_vocal_detail( if formant_low <= freq <= formant_high: formant_energy_sum += energy - # Formant peak matching via SpectralPeaks (every 4th frame for speed) + # Formant peak matching via SpectralPeaks (every 4th frame for speed). + # Record the *closest* matched peak per expected formant so we can + # later compute the temporal variance of each formant's position. formant_frames += 1 if formant_frames % 4 == 0: - peak_freqs, peak_mags = spectral_peaks_algo(spec) - frame_matches = 0 + peak_freqs, _peak_mags = spectral_peaks_algo(spec) + matched: list[float | None] = [] for ef in expected_formants: + closest: float | None = None + closest_dist = formant_tolerance for pf in peak_freqs: - if abs(float(pf) - ef) < formant_tolerance: - frame_matches += 1 - break - formant_match_total += frame_matches + d = abs(float(pf) - ef) + if d < closest_dist: + closest_dist = d + closest = float(pf) + matched.append(closest) + per_frame_matches.append(matched) vocal_energy_ratio = vocal_energy_sum / total_energy_sum if total_energy_sum > 0 else 0.0 - sampled_formant_frames = max(1, formant_frames // 4) - formant_strength = min(1.0, formant_match_total / sampled_formant_frames / 3.0) + sampled_formant_frames = max(1, len(per_frame_matches)) + # 1) Count frames with at least 2 of 3 expected formants matched + # (single-formant matches are noise; real vocal phones produce + # coherent F1+F2 or F1+F2+F3 patterns). + coherent_frames = sum( + 1 for matches in per_frame_matches + if sum(1 for m in matches if m is not None) >= 2 + ) + coherent_fraction = coherent_frames / sampled_formant_frames + + # 2) Temporal-stability penalty: compute std-dev of the matched peak + # frequency for each expected formant across the sampled frames. + # A singer's formants drift 100-500 Hz with syllables and vibrato; + # a sustained synth tone produces near-zero variance. + formant_movement_hz = 0.0 + if sampled_formant_frames >= 4: + stds: list[float] = [] + for slot in range(len(expected_formants)): + values = [ + matches[slot] + for matches in per_frame_matches + if matches[slot] is not None + ] + if len(values) >= 4: + stds.append(float(np.std(values))) + if stds: + formant_movement_hz = float(np.mean(stds)) + # Map mean std-dev to a [0.2, 1.0] multiplier. Below ~30 Hz movement + # the score is clamped at 0.2 (static-tone penalty); above ~120 Hz + # it saturates at 1.0 (normal vocal motion). + if formant_movement_hz <= 30.0: + movement_factor = 0.2 + elif formant_movement_hz >= 120.0: + movement_factor = 1.0 + else: + movement_factor = 0.2 + 0.8 * (formant_movement_hz - 30.0) / 90.0 + + formant_strength = min(1.0, coherent_fraction * movement_factor) # --- MFCC vocal likelihood --- mfcc_algo = es.MFCC( @@ -481,7 +821,47 @@ def analyze_vocal_detail( # --- Composite score (35 / 35 / 30 weighting) --- energy_score = min(1.0, max(0.0, (vocal_energy_ratio - 0.1) / 0.3)) confidence = energy_score * 0.35 + formant_strength * 0.35 + mfcc_likelihood * 0.30 - has_vocals = confidence > 0.45 + + # Demucs-ghost-stem scaling #1 (low energy): when a vocals stem was + # loaded and its RMS is below ~5% of the full-mix RMS, the "vocals" + # Demucs produced are leakage from a track with no real vocal content. + # Scale composite confidence down linearly: at 0% stem energy → 0.0×; + # at 5% stem energy → 1.0× (no penalty). Above 5% the multiplier stays + # at 1.0. + if stem_energy_ratio is not None and stem_energy_ratio < 0.05: + ghost_multiplier = max(0.0, stem_energy_ratio / 0.05) + confidence = confidence * ghost_multiplier + + # Demucs-ghost-stem scaling #2 (other-correlation): when the vocals + # stem is highly correlated with the "other" stem at the 200 Hz + # envelope rate, Demucs is splitting one source (typically a melodic + # lead) into two stems; the vocals stem is then misclassified content + # rather than a genuine voice. Empirical thresholds — corr ≤ 0.30 is + # uncorrelated and gets no penalty; corr ≥ 0.55 is heavily entangled + # and gets a 0.30× multiplier; in between we ramp. + if stem_other_correlation is not None and stem_other_correlation > 0.30: + if stem_other_correlation >= 0.55: + corr_multiplier = 0.30 + else: + corr_multiplier = 1.0 - ( + (stem_other_correlation - 0.30) / 0.25 + ) * 0.70 + confidence = confidence * corr_multiplier + + # Threshold raised from 0.45 to 0.55 (2026-05-12): the temporal-stability + # check on formant_strength now downweights synth leads that previously + # scored ~1.0 there. With the tighter formant logic, real vocals on + # representative material still clear 0.55 (energy_score≈0.7 + + # formant_strength≈0.6 + mfcc_likelihood≈0.7 ≈ 0.66 composite). + # + # Hard formant-strength gate (2026-05-12 follow-up): a sustained synth + # lead can still drag composite confidence above 0.55 via high + # energy_score + mfcc_likelihood, even with the static-formant penalty. + # Require formant_strength > 0.3 for the boolean decision — without + # measurable formant motion, the content is melodic/instrumental, not + # vocal phonemes. The numeric confidence field is unchanged so the UI + # can still display the hedged value; only `hasVocals` flips. + has_vocals = confidence > 0.55 and formant_strength > 0.3 return { "vocalDetail": { @@ -490,6 +870,14 @@ def analyze_vocal_detail( "vocalEnergyRatio": round(float(vocal_energy_ratio), 2), "formantStrength": round(float(formant_strength), 2), "mfccLikelihood": round(float(mfcc_likelihood), 2), + "stemEnergyRatio": ( + round(float(stem_energy_ratio), 3) + if stem_energy_ratio is not None else None + ), + "stemOtherCorrelation": ( + round(float(stem_other_correlation), 3) + if stem_other_correlation is not None else None + ), } } except Exception as e: diff --git a/apps/backend/analyze_fast.py b/apps/backend/analyze_fast.py index 53aad22d..fea78e76 100644 --- a/apps/backend/analyze_fast.py +++ b/apps/backend/analyze_fast.py @@ -136,8 +136,15 @@ def analyze_fast(mono: np.ndarray, sample_rate: int = 44100) -> dict: result["textureCharacter"] = None result["stereoDetail"] = None result["spectralBalance"] = None + result["spectralBalanceTimeSeries"] = None result["spectralDetail"] = None + result["stemAnalysis"] = None + result["transientDensityDetail"] = None + result["saturationDetail"] = None + result["snareDetail"] = None + result["hihatDetail"] = None result["rhythmDetail"] = None + result["lufsCurve"] = None result["melodyDetail"] = None result["transcriptionDetail"] = None result["grooveDetail"] = None diff --git a/apps/backend/analyze_rhythm.py b/apps/backend/analyze_rhythm.py index 19c88cd5..b2bb34de 100644 --- a/apps/backend/analyze_rhythm.py +++ b/apps/backend/analyze_rhythm.py @@ -13,6 +13,8 @@ except ImportError: es = None +from dsp_utils import _compute_tempo_curve_from_ticks + def _extract_beat_loudness_data( mono: np.ndarray, @@ -56,6 +58,15 @@ def _extract_beat_loudness_data( band_loudness = band_loudness * beat_loudness[:, np.newaxis] low_band = band_loudness[:, 0] + # Phase 1.C #3: surface the middle band (200-4000 Hz) so analyze_groove + # can compute per-drum-group swing for the snare separately from kick + # and hi-hat. The BeatLoudness algorithm already computes this — we + # were just discarding it. + mid_band = ( + band_loudness[:, 1] + if band_loudness.shape[1] >= 3 + else np.zeros(band_loudness.shape[0], dtype=np.float64) + ) high_band = band_loudness[:, -1] count = min( ticks.size, @@ -71,6 +82,7 @@ def _extract_beat_loudness_data( beat_loudness = beat_loudness[:count] band_loudness = band_loudness[:count, :] low_band = low_band[:count] + mid_band = mid_band[:count] high_band = high_band[:count] return { @@ -78,6 +90,7 @@ def _extract_beat_loudness_data( "beatLoudness": beat_loudness, "bandLoudness": band_loudness, "lowBand": low_band, + "midBand": mid_band, "highBand": high_band, } except Exception: @@ -189,6 +202,11 @@ def analyze_rhythm_detail( "totalPhrases8Bar": len(phrases_8bar), } + # Instantaneous-BPM curve from beat ticks, smoothed with a 4-beat + # rolling median. Surfaces deliberate ritardando/accelerando and + # DJ-tool transitions that the single mean BPM scalar conflates away. + tempo_curve = _compute_tempo_curve_from_ticks(ticks) + return { "rhythmDetail": { "onsetRate": round(onset_rate, 2), @@ -198,6 +216,7 @@ def analyze_rhythm_detail( "grooveAmount": round(groove, 4), "tempoStability": tempo_stability, "phraseGrid": phrase_grid, + "tempoCurve": tempo_curve, } } except Exception as e: @@ -453,6 +472,7 @@ def analyze_groove( beats = np.asarray(beat_data.get("beats", []), dtype=np.float64) low_band = np.asarray(beat_data.get("lowBand", []), dtype=np.float64) + mid_band = np.asarray(beat_data.get("midBand", []), dtype=np.float64) high_band = np.asarray(beat_data.get("highBand", []), dtype=np.float64) if beats.size < 2 or low_band.size < 2 or high_band.size < 2: return {"grooveDetail": None} @@ -482,19 +502,36 @@ def sample_accents(values: np.ndarray, max_points: int = 16) -> list[float]: return [round(float(v), 4) for v in values] raw_kick_swing = calc_swing(low_band, beats) + raw_snare_swing = ( + calc_swing(mid_band, beats) if mid_band.size >= 2 else 0.0 + ) raw_hihat_swing = calc_swing(high_band, beats) # Normalize to 0-1 scale using tanh compression kick_swing = round(math.tanh(raw_kick_swing * 0.5), 4) + snare_swing = round(math.tanh(raw_snare_swing * 0.5), 4) hihat_swing = round(math.tanh(raw_hihat_swing * 0.5), 4) kick_accent = sample_accents(low_band, 16) hihat_accent = sample_accents(high_band, 16) + # Phase 1.C #3: per-drum-group swing object — derived from the three + # beat-loudness bands (kick: 20-200 Hz, snare: 200-4000 Hz, hi-hat: + # 4000-20000 Hz). When `stems.drums` is present the per-stem drum + # analyzers (snareDetail, hihatDetail) give more accurate event timing; + # but this object is computed on the same loudness signal used for + # kickSwing/hihatSwing, so it's available even when stems are absent. + per_drum_swing = { + "kick": kick_swing, + "snare": snare_swing, + "hihat": hihat_swing, + } + return { "grooveDetail": { "kickSwing": kick_swing, "hihatSwing": hihat_swing, "kickAccent": kick_accent, "hihatAccent": hihat_accent, + "perDrumSwing": per_drum_swing, } } except Exception as e: diff --git a/apps/backend/analyze_segments.py b/apps/backend/analyze_segments.py index b38f0389..24f4bcd0 100644 --- a/apps/backend/analyze_segments.py +++ b/apps/backend/analyze_segments.py @@ -1,6 +1,7 @@ """Per-segment analysis — loudness, stereo, spectral, key, and chords.""" import sys +from collections import Counter import numpy as np @@ -9,7 +10,13 @@ except ImportError: es = None -from dsp_utils import _safe_db, _compute_bark_db, _slice_segments, _to_finite_float +from dsp_utils import ( + _compute_bark_db, + _compute_stereo_metrics, + _safe_db, + _slice_segments, + _to_finite_float, +) def analyze_segment_loudness( @@ -333,6 +340,8 @@ def analyze_chords(mono: np.ndarray, sample_rate: int = 44100) -> dict: "chordStrength": 0.0, "progression": [], "dominantChords": [], + "chordTimeline": [], + "chordChangeCount": 0, } } @@ -356,12 +365,94 @@ def analyze_chords(mono: np.ndarray, sample_rate: int = 44100) -> dict: dominant_chords = [label for label, _count in Counter(chords).most_common(4)] + # Phase 1.D #2 — temporal chord timeline. Each per-frame label is + # smoothed with a 5-frame median (≈ 250 ms at hop_size=2048/44.1k), + # then consecutive same-label frames are merged into segments with + # start/end times and the mean strength across that segment. Segments + # shorter than the smoothing window (after merging) are dropped to + # suppress noise; the cap of 64 segments keeps the payload bounded. + frame_duration_s = float(hop_size) / float(sample_rate) + smooth_window = 5 # frames + + def _median_label(window: list[str]) -> str: + counts: dict[str, int] = {} + for label in window: + counts[label] = counts.get(label, 0) + 1 + return max(counts.items(), key=lambda kv: kv[1])[0] + + smoothed: list[str] = [] + n_chords = len(chords) + for i in range(n_chords): + lo = max(0, i - smooth_window // 2) + hi = min(n_chords, i + smooth_window // 2 + 1) + smoothed.append(_median_label(chords[lo:hi])) + + chord_timeline: list[dict] = [] + if smoothed: + seg_label = smoothed[0] + seg_start_idx = 0 + for idx in range(1, n_chords): + if smoothed[idx] != seg_label: + seg_end_idx = idx + seg_strength_slice = strength[seg_start_idx:seg_end_idx] + seg_conf = ( + float(np.mean(seg_strength_slice)) + if seg_strength_slice.size > 0 else 0.0 + ) + chord_timeline.append({ + "startSec": round(seg_start_idx * frame_duration_s, 3), + "endSec": round(seg_end_idx * frame_duration_s, 3), + "label": seg_label, + "confidence": round(seg_conf, 4), + }) + seg_label = smoothed[idx] + seg_start_idx = idx + # Flush the final open segment. + seg_strength_slice = strength[seg_start_idx:n_chords] + seg_conf = ( + float(np.mean(seg_strength_slice)) + if seg_strength_slice.size > 0 else 0.0 + ) + chord_timeline.append({ + "startSec": round(seg_start_idx * frame_duration_s, 3), + "endSec": round(n_chords * frame_duration_s, 3), + "label": seg_label, + "confidence": round(seg_conf, 4), + }) + + # Drop segments shorter than the smoothing-window equivalent + # (≈ 250 ms). They typically reflect chord-detector noise around + # transitions rather than real harmonic events. + min_segment_s = smooth_window * frame_duration_s + chord_timeline = [ + seg for seg in chord_timeline + if (seg["endSec"] - seg["startSec"]) >= min_segment_s + ] + + # Cap at 64 segments. If we overflow, keep the 64 longest + # by duration — those carry the most musical weight. + if len(chord_timeline) > 64: + chord_timeline.sort( + key=lambda seg: seg["endSec"] - seg["startSec"], reverse=True + ) + chord_timeline = sorted(chord_timeline[:64], key=lambda s: s["startSec"]) + + # Count of unique chord-to-chord transitions in the smoothed sequence + # (a proxy for "how harmonically active is this track" — flat 1-chord + # tracks score 0; rapid-changes tracks score 16+). + chord_change_count = sum( + 1 for i in range(1, len(chord_timeline)) + if chord_timeline[i]["label"] != chord_timeline[i - 1]["label"] + ) + return { "chordDetail": { "chordSequence": chord_sequence, "chordStrength": chord_strength, "progression": progression, "dominantChords": dominant_chords, + "chordTimeline": chord_timeline, + "chordChangeCount": chord_change_count, } } except Exception as e: diff --git a/apps/backend/analyze_structure.py b/apps/backend/analyze_structure.py index cb7b239d..acc7c05e 100644 --- a/apps/backend/analyze_structure.py +++ b/apps/backend/analyze_structure.py @@ -89,7 +89,13 @@ def _compute_arrangement_novelty_summary( sample_rate: int, frame_size: int = STRUCTURE_FRAME_SIZE, hop_size: int = STRUCTURE_HOP_SIZE, - max_curve_points: int = 64, + # Bumped from 64 to 256 for Phase 1.A.5. At STRUCTURE_HOP_SIZE the native + # novelty resolution on a 4-minute track is ~3000 frames; 64 collapsed + # that to roughly one point per 4 seconds (transitions inside a build + # become invisible). 256 keeps the payload modest while resolving roughly + # one point per second — enough for Phase 2 to cite "novelty ramps for + # 8 bars before the drop" instead of just naming peaks. + max_curve_points: int = 256, max_peaks: int = 8, min_spacing_sec: float = 2.0, ) -> dict | None: diff --git a/apps/backend/dsp_utils.py b/apps/backend/dsp_utils.py index 7b5ae732..16f07ef8 100644 --- a/apps/backend/dsp_utils.py +++ b/apps/backend/dsp_utils.py @@ -147,6 +147,202 @@ def _downsample_evenly( return [round(float(v), decimals) for v in arr] +def _downsample_lufs_array( + values: np.ndarray, + target_points: int = 200, + frame_hop_seconds: float = 0.1, + value_decimals: int = 1, + time_decimals: int = 2, +) -> list[dict[str, float]]: + """Downsample an EBU R128 momentary/short-term LUFS array to ``[{t, lufs}, ...]``. + + Each output point is the bin-center timestamp paired with the mean of finite + LUFS values in that bin. Returns ``[]`` when the input has no finite samples. + The default ``frame_hop_seconds`` matches Essentia's ``LoudnessEBUR128`` + output rate (100 ms between frames). + """ + arr = np.asarray(values, dtype=np.float64) + if arr.size == 0 or target_points <= 0: + return [] + if not np.any(np.isfinite(arr)): + return [] + n = arr.size + bin_size = max(1, int(np.ceil(n / target_points))) + points: list[dict[str, float]] = [] + for start in range(0, n, bin_size): + stop = min(n, start + bin_size) + chunk = arr[start:stop] + chunk_finite = chunk[np.isfinite(chunk)] + if chunk_finite.size == 0: + continue + center_frame = (start + stop - 1) / 2.0 + t = round(center_frame * frame_hop_seconds, time_decimals) + lufs_value = round(float(np.mean(chunk_finite)), value_decimals) + points.append({"t": t, "lufs": lufs_value}) + return points + + +def _downsample_band_energies_curve( + band_energies: dict[str, list[float]], + band_names: list[str], + frame_hop_seconds: float, + target_points: int = 200, + db_decimals: int = 1, + time_decimals: int = 2, +) -> list[dict[str, float]]: + """Convert per-frame band-energy lists to a downsampled time series. + + Returns rows of the shape ``{t, : dB, ...}`` so a single row carries + all bands at a given timestamp — the schema the UI and Phase 2 prompt expect. + Energies that are zero or negative collapse to a -100 dB floor so JSON + serialization stays finite. + """ + if not band_energies or not band_names: + return [] + first_band = band_names[0] + series_len = len(band_energies.get(first_band, [])) + if series_len == 0: + return [] + bin_size = max(1, int(np.ceil(series_len / target_points))) + points: list[dict[str, float]] = [] + for start in range(0, series_len, bin_size): + stop = min(series_len, start + bin_size) + center_frame = (start + stop - 1) / 2.0 + t = round(center_frame * frame_hop_seconds, time_decimals) + point: dict[str, float] = {"t": t} + for name in band_names: + chunk = band_energies.get(name, [])[start:stop] + if not chunk: + point[name] = -100.0 + continue + chunk_arr = np.asarray(chunk, dtype=np.float64) + chunk_arr = chunk_arr[chunk_arr > 0] + if chunk_arr.size == 0: + point[name] = -100.0 + continue + mean_energy = float(np.mean(chunk_arr)) + point[name] = round(_safe_db(mean_energy), db_decimals) + points.append(point) + return points + + +def _compute_tempo_curve_from_ticks( + ticks: np.ndarray, + smoothing_window_beats: int = 4, + target_points: int = 200, + bpm_decimals: int = 1, + time_decimals: int = 2, +) -> list[dict[str, float]]: + """Instantaneous BPM from beat ticks, smoothed with a rolling median. + + For each pair of consecutive ticks we compute ``60 / (t_{i+1} - t_i)``; the + series is then smoothed with a centered ``smoothing_window_beats``-wide + median to reject single-beat jitter, downsampled to ``target_points``, and + returned as ``[{t, bpm}, ...]`` rows aligned to interval midpoints. + """ + arr = np.asarray(ticks, dtype=np.float64) + if arr.size < 2: + return [] + intervals = np.diff(arr) + intervals = np.where(intervals > 0, intervals, np.nan) + instant_bpm = np.full_like(intervals, np.nan, dtype=np.float64) + valid = np.isfinite(intervals) + instant_bpm[valid] = 60.0 / intervals[valid] + if not np.any(np.isfinite(instant_bpm)): + return [] + + window = max(1, smoothing_window_beats) + half = window // 2 + n = instant_bpm.size + smoothed = np.copy(instant_bpm) + for i in range(n): + lo = max(0, i - half) + hi = min(n, i + half + 1) + chunk = instant_bpm[lo:hi] + chunk_finite = chunk[np.isfinite(chunk)] + if chunk_finite.size > 0: + smoothed[i] = float(np.median(chunk_finite)) + + midpoints = (arr[:-1] + arr[1:]) / 2.0 + bin_size = max(1, int(np.ceil(n / target_points))) + points: list[dict[str, float]] = [] + for start in range(0, n, bin_size): + stop = min(n, start + bin_size) + chunk_bpm = smoothed[start:stop] + chunk_t = midpoints[start:stop] + chunk_bpm_finite = chunk_bpm[np.isfinite(chunk_bpm)] + if chunk_bpm_finite.size == 0: + continue + points.append( + { + "t": round(float(np.mean(chunk_t)), time_decimals), + "bpm": round(float(np.mean(chunk_bpm_finite)), bpm_decimals), + } + ) + return points + + +def _compute_stereo_correlation_curve( + left: np.ndarray, + right: np.ndarray, + left_sub: np.ndarray, + right_sub: np.ndarray, + sample_rate: int, + window_seconds: float = 1.0, + correlation_decimals: int = 3, + time_decimals: int = 2, +) -> list[dict[str, float | None]]: + """1-second windowed L/R correlation, full-band and sub-band side-by-side. + + Emits one row per non-overlapping window ``[{t, full, sub}, ...]``. Sub + correlation is ``None`` when the sub band is silent in that window so the + UI can render a gap rather than a misleading zero. + """ + if sample_rate <= 0 or window_seconds <= 0: + return [] + samples_per_window = int(window_seconds * sample_rate) + if samples_per_window <= 0: + return [] + full_n = min(left.size, right.size) + sub_n = min(left_sub.size, right_sub.size) + n = min(full_n, sub_n) + if n < samples_per_window: + return [] + points: list[dict[str, float | None]] = [] + for start in range(0, n - samples_per_window + 1, samples_per_window): + stop = start + samples_per_window + full_corr = _pearson_corr(left[start:stop], right[start:stop]) + sub_corr = _pearson_corr(left_sub[start:stop], right_sub[start:stop]) + t = round((start + samples_per_window / 2.0) / sample_rate, time_decimals) + points.append( + { + "t": t, + "full": round(full_corr, correlation_decimals) + if np.isfinite(full_corr) + else None, + "sub": round(sub_corr, correlation_decimals) + if np.isfinite(sub_corr) + else None, + } + ) + return points + + +def _pearson_corr(a: np.ndarray, b: np.ndarray) -> float: + """Pearson correlation between two equal-length arrays, NaN on degenerate input.""" + if a.size == 0 or b.size == 0: + return float("nan") + n = min(a.size, b.size) + a64 = np.asarray(a[:n], dtype=np.float64) + b64 = np.asarray(b[:n], dtype=np.float64) + a_var = float(np.var(a64)) + b_var = float(np.var(b64)) + if a_var <= 0.0 or b_var <= 0.0: + return float("nan") + cov = float(np.mean((a64 - np.mean(a64)) * (b64 - np.mean(b64)))) + return cov / np.sqrt(a_var * b_var) + + def _to_finite_float(value, default=None): try: numeric = float(value) diff --git a/apps/backend/phase1_evaluation.py b/apps/backend/phase1_evaluation.py index f51f16e3..c1631d75 100644 --- a/apps/backend/phase1_evaluation.py +++ b/apps/backend/phase1_evaluation.py @@ -17,6 +17,7 @@ REPO_DIR = Path(__file__).resolve().parent DEFAULT_MANIFEST_PATH = REPO_DIR / "tests" / "fixtures" / "phase1_eval_manifest.json" DEFAULT_REPORT_PATH = REPO_DIR / ".runtime" / "reports" / "phase1_eval_report.json" +DEFAULT_BENCH_TRACKS_DIR = REPO_DIR / "tests" / "fixtures" / "bench_tracks" EXPECTED_SPECTRAL_KEYS = { "subBass", "lowBass", @@ -35,6 +36,18 @@ class FixtureCheck: message: str +@dataclass +class RealTrackResult: + track_id: str + audio_path: str + category: str + description: str + status: str # "evaluated" | "skipped_audio_missing" | "skipped_analyze_failed" + skip_reason: str | None + checks: list[FixtureCheck] + all_passed: bool + + def _write_stereo_wav(path: Path, mono: np.ndarray, sample_rate: int) -> None: mono_arr = np.asarray(mono, dtype=np.float32) stereo = np.stack([mono_arr, mono_arr], axis=1) @@ -81,8 +94,10 @@ def _generate_fixture_audio(generator: dict[str, Any]) -> tuple[np.ndarray, int] raise ValueError(f"Unsupported fixture generator type '{fixture_type}'.") -def _run_analyze(audio_path: Path) -> dict[str, Any]: +def _run_analyze(audio_path: Path, extra_flags: list[str] | None = None) -> dict[str, Any]: command = [sys.executable, str(REPO_DIR / "analyze.py"), str(audio_path), "--yes"] + if extra_flags: + command.extend(extra_flags) completed = subprocess.run( command, cwd=REPO_DIR, @@ -186,6 +201,92 @@ def _evaluate_plr_consistency(payload: dict[str, Any]) -> FixtureCheck: ) +def _evaluate_real_track( + entry: dict[str, Any], + real_tracks_dir: Path, +) -> RealTrackResult: + """Evaluate a single real-track entry from the manifest. + + Gracefully skips with a clear reason when the audio file is absent, so that + a missing bench track never fails the run — opt-in real-track gate only + surfaces failures when audio is present AND analyzer output disagrees with + ground truth. + """ + track_id = str(entry.get("id") or "unknown") + audio_rel = str(entry.get("audioPath") or "") + category = str(entry.get("category") or "uncategorized") + description = str(entry.get("description") or "") + thresholds = entry.get("thresholds") + analyze_flags = entry.get("analyzeFlags") + + if not isinstance(thresholds, dict): + thresholds = {} + if isinstance(analyze_flags, list): + flags_list = [str(flag) for flag in analyze_flags] + else: + flags_list = None + + if not audio_rel: + return RealTrackResult( + track_id=track_id, + audio_path="", + category=category, + description=description, + status="skipped_audio_missing", + skip_reason="manifest entry has no audioPath", + checks=[], + all_passed=True, + ) + + audio_path = (real_tracks_dir / audio_rel).resolve() + if not audio_path.exists(): + return RealTrackResult( + track_id=track_id, + audio_path=str(audio_path), + category=category, + description=description, + status="skipped_audio_missing", + skip_reason=( + f"audio not present at {audio_path} — add the file locally to " + "include this track in real-track evaluation" + ), + checks=[], + all_passed=True, + ) + + try: + payload = _run_analyze(audio_path, flags_list) + except subprocess.CalledProcessError as exc: + stderr_tail = (exc.stderr or "")[-400:] + return RealTrackResult( + track_id=track_id, + audio_path=str(audio_path), + category=category, + description=description, + status="skipped_analyze_failed", + skip_reason=f"analyze.py failed (exit {exc.returncode}): {stderr_tail}", + checks=[], + all_passed=False, + ) + + checks: list[FixtureCheck] = [] + for field, config in thresholds.items(): + if not isinstance(config, dict): + continue + checks.append(_evaluate_threshold(payload, field, config)) + + return RealTrackResult( + track_id=track_id, + audio_path=str(audio_path), + category=category, + description=description, + status="evaluated", + skip_reason=None, + checks=checks, + all_passed=all(check.passed for check in checks), + ) + + def _evaluate_stability( outputs: list[dict[str, Any]], stability_checks: list[dict[str, Any]], @@ -237,12 +338,17 @@ def run_phase1_evaluation( manifest_path: Path = DEFAULT_MANIFEST_PATH, report_path: Path = DEFAULT_REPORT_PATH, runs_per_fixture: int = 2, + include_real: bool = False, + real_tracks_dir: Path = DEFAULT_BENCH_TRACKS_DIR, ) -> dict[str, Any]: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) fixtures = manifest.get("fixtures", []) stability_checks = manifest.get("stabilityChecks", []) + real_tracks_manifest = manifest.get("realTracks", []) if include_real else [] if not isinstance(fixtures, list) or len(fixtures) == 0: raise ValueError("Manifest must define one or more fixtures.") + if not isinstance(real_tracks_manifest, list): + raise ValueError("Manifest 'realTracks' must be a list when present.") fixture_reports: list[dict[str, Any]] = [] passed_checks = 0 @@ -295,20 +401,69 @@ def run_phase1_evaluation( } ) + real_track_reports: list[dict[str, Any]] = [] + real_evaluated = 0 + real_skipped = 0 + real_failed_subprocess = 0 + + if include_real: + for raw_entry in real_tracks_manifest: + if not isinstance(raw_entry, dict): + continue + result = _evaluate_real_track(raw_entry, real_tracks_dir) + if result.status == "evaluated": + real_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": + real_skipped += 1 + elif result.status == "skipped_analyze_failed": + # An analyze.py crash on a present audio file is a real failure + # — count it once so summary.allPassed reflects the breakage. + real_failed_subprocess += 1 + failed_checks += 1 + + real_track_reports.append( + { + "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, + } + ) + summary = { "fixtures": len(fixture_reports), + "realTracksEvaluated": real_evaluated, + "realTracksSkipped": real_skipped, + "realTracksAnalyzeFailed": real_failed_subprocess, "checksPassed": passed_checks, "checksFailed": failed_checks, "allPassed": failed_checks == 0, } - report = { + report: dict[str, Any] = { "generatedAt": datetime.now(timezone.utc).isoformat(), "manifestPath": str(manifest_path), "runsPerFixture": runs_per_fixture, + "includeReal": include_real, + "realTracksDir": str(real_tracks_dir) if include_real else None, "fixtures": fixture_reports, "summary": summary, } + if include_real: + report["realTracks"] = real_track_reports report_path.parent.mkdir(parents=True, exist_ok=True) report_path.write_text(json.dumps(report, indent=2), encoding="utf-8") diff --git a/apps/backend/phase1_report_html.py b/apps/backend/phase1_report_html.py new file mode 100644 index 00000000..7ed26594 --- /dev/null +++ b/apps/backend/phase1_report_html.py @@ -0,0 +1,270 @@ +"""Render the Phase 1 evaluation report as a standalone HTML page. + +The HTML report is the milestone-audit deliverable from Track 2 of the depth +plan. It surfaces synthetic-fixture and real-track results side-by-side, marks +skipped real tracks with their reason, and stays self-contained (no external +CSS or JS) so it can be opened directly from `.runtime/reports/`. + +Confidence-calibration analytics — "% wrong above threshold X" — are intentionally +deferred to a follow-up; this first render keeps to the data the harness already +produces, plus a clearly-flagged TODO for the calibration sub-report. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import jinja2 + +_TEMPLATE = """ + + + + ASA Phase 1 Accuracy Report — {{ generated_at }} + + + +
+
+

ASA Phase 1 Accuracy Report

+
+ Generated {{ generated_at }} · + Manifest {{ manifest_path }} · + Runs per fixture: {{ runs_per_fixture }} + {% if include_real %} · Real-tracks dir: {{ real_tracks_dir }}{% endif %} +
+
+ +
+ + {% if summary.allPassed %}ALL CHECKS PASSED{% else %}REGRESSIONS DETECTED{% endif %} + +
+ +
+

Summary

+
+
+
Synthetic fixtures
+
{{ summary.fixtures }}
+
+
+
Real tracks evaluated
+
{{ summary.realTracksEvaluated }}
+
+
+
Real tracks skipped
+
{{ summary.realTracksSkipped }}
+
+
+
analyze.py failures
+
{{ summary.realTracksAnalyzeFailed }}
+
+
+
Checks passed
+
{{ summary.checksPassed }}
+
+
+
Checks failed
+
{{ summary.checksFailed }}
+
+
+
+ +
+

Synthetic fixtures

+ {% for fixture in fixtures %} +
+

+ {{ fixture.id }} + + {{ "PASS" if fixture.allPassed else "FAIL" }} + +

+ + + + + + {% for check in fixture.checks %} + + + + + + {% endfor %} + +
CheckResultMessage
{{ check.name }} + {{ "PASS" if check.passed else "FAIL" }} + {{ check.message }}
+
+ {% endfor %} +
+ + {% if include_real %} +
+

Real tracks

+ {% if real_tracks %} + {% for track in real_tracks %} +
+

+ {{ track.id }} + {% if track.category %}· {{ track.category }}{% endif %} + + {% if track.status == 'evaluated' %} + {{ "PASS" if track.allPassed else "FAIL" }} + {% elif track.status == 'skipped_audio_missing' %} + SKIP + {% else %} + ANALYZE FAILED + {% endif %} + +

+ {% if track.description %}

{{ track.description }}

{% endif %} + {% if track.skipReason %} +
{{ track.skipReason }}
+ {% endif %} + {% if track.checks %} + + + + + + {% for check in track.checks %} + + + + + + {% endfor %} + +
CheckResultMessage
{{ check.name }} + {{ "PASS" if check.passed else "FAIL" }} + {{ check.message }}
+ {% endif %} +
+ {% endfor %} + {% else %} +

No real-track entries in the manifest yet. See bench_tracks/README.md for how to register tracks.

+ {% endif %} +
+ {% endif %} + +
+ Phase 1 accuracy bench · {{ now_local }} · + Confidence-calibration sub-report (% wrong above threshold X) is a planned follow-up. +
+
+ + +""" + + +def render_html_report(report: dict[str, Any], output_path: Path) -> Path: + """Render an evaluation report dict into a standalone HTML file. + + `report` is the dict returned by `run_phase1_evaluation`. `output_path` is + the absolute path to write — its parent is created if missing. + """ + env = jinja2.Environment( + autoescape=True, + trim_blocks=True, + lstrip_blocks=True, + ) + template = env.from_string(_TEMPLATE) + html = template.render( + generated_at=report.get("generatedAt", ""), + manifest_path=report.get("manifestPath", ""), + runs_per_fixture=report.get("runsPerFixture", 1), + include_real=bool(report.get("includeReal", False)), + real_tracks_dir=report.get("realTracksDir") or "", + summary=report.get("summary", {}), + fixtures=report.get("fixtures", []), + real_tracks=report.get("realTracks", []), + now_local=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(html, encoding="utf-8") + return output_path + + +def default_html_report_path(reports_dir: Path) -> Path: + """Return the conventional dated HTML report path under `reports_dir`.""" + stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%SZ") + return reports_dir / f"accuracy_{stamp}.html" diff --git a/apps/backend/prompts/live12_device_catalog.json b/apps/backend/prompts/live12_device_catalog.json index dddc927e..d831d520 100644 --- a/apps/backend/prompts/live12_device_catalog.json +++ b/apps/backend/prompts/live12_device_catalog.json @@ -391,7 +391,11 @@ "Makeup", "Dry/Wet", "Soft Clip", - "Peak Clip" + "Peak Clip", + "Range", + "Sidechain", + "Sidechain Gain", + "Sidechain Dry/Wet" ] }, { @@ -531,7 +535,11 @@ "LFO Rate", "LFO Waveform", "Dry/Wet" - ] + ], + "parameterAliases": { + "Filter Resonance": "Resonance", + "Filter Frequency": "Frequency" + } }, { "name": "Auto Pan-Tremolo", diff --git a/apps/backend/prompts/phase2_system.txt b/apps/backend/prompts/phase2_system.txt index 56270653..f2bb02ad 100644 --- a/apps/backend/prompts/phase2_system.txt +++ b/apps/backend/prompts/phase2_system.txt @@ -30,12 +30,12 @@ LIVE 12 OUTPUT POLICY - Return: where is the EXACT string from routingBlueprint.returns[].name - CRITICAL: Every Return: trackContext MUST use a name that appears verbatim in your routingBlueprint.returns array. Do not abbreviate, shorten, or paraphrase return names. If you declared a return named "Long Reverb", use Return:Long Reverb — never Return:Reverb. - For return tracks, use the exact format Return: with no space after the colon. - - workflowStage to one of: - - PROJECT_SETUP - - SOUND_DESIGN - - ARRANGEMENT - - MIX - - MASTER + - workflowStage to one of: PROJECT_SETUP | SOUND_DESIGN | ARRANGEMENT | MIX | MASTER + - These five values are the ONLY allowed workflowStage values across mixAndMasterChain, abletonRecommendations, and secretSauce.workflowSteps. Other strings (e.g. "EFFECTS", "DYNAMICS", "EQ", "STEREO") will be rejected by the server schema. + - Effects-shaped recommendations (Reverb, Delay, Saturator, Echo, etc.) belong in MIX or MASTER — NOT a "EFFECTS" stage. + - Synth/sound-design recommendations belong in SOUND_DESIGN. + - Mastering chain items belong in MASTER, not MIX. + - If you are tempted to use a stage name outside the five allowed values, the recommendation belongs to one of the existing five and you have just mislabelled its stage. DEFAULTS AND INTERPRETIVE BOUNDARIES - If the measurement JSON does not expose source format, use: @@ -84,6 +84,131 @@ GROUNDING REQUIREMENTS - secretSauce.explanation must cite at least 3 concrete measured cues. - secretSauce.workflowSteps[].measurementJustification must explain why the step suits this exact track. +CITATION CONTRACT +- Every recommendation must carry a phase1Fields array listing the specific Phase 1 measurement paths it is grounded in. +- This is the same pattern already used by trackLayout.grounding.phase1Fields, extended to every recommendation array. +- Format: dotted paths like "spectralBalance.subBass", "kickDetail.fundamentalHz", "sidechainDetail.pumpingRate", or "stemAnalysis.bass.spectralBalance.subBass" once per-stem fields exist. +- phase1Fields is REQUIRED on every mixAndMasterChain item, every secretSauce.workflowSteps item, and every abletonRecommendations card. +- Each phase1Fields array must contain at least one entry. Two to four entries is typical for substantive recommendations. +- Entries must reference fields actually present in AUTHORITATIVE_MEASUREMENT_RESULT_JSON. Before emitting a phase1Fields entry, verify the dotted path resolves to a non-null value in the JSON you were given. Common mistakes to AVOID: + - The mono-compatibility flag is "monoCompatible" at top level, NOT "stereoDetail.monoCompatible". + - Spectral mean fields use the "Mean" suffix after server normalization: use "spectralDetail.spectralCentroidMean", NOT "spectralDetail.spectralCentroid". + - Sidechain fields live under "sidechainDetail", NOT "pumpingDetail" — there is no "pumpingDetail" object. +- Device parameter naming note (relevant when the catalog accepts the device): Auto Filter (the audio effect) uses "Resonance" and "Frequency" — not the long-form "Filter Resonance" / "Filter Frequency" used by instruments (Operator, Wavetable, Drift, Meld). The catalog accepts the long forms as aliases, but prefer the short forms for clarity. +- Do not copy the same anchor field onto every recommendation. Use the most specific field that justifies the move. Different cards should cite different measurements unless they genuinely depend on the same anchor. + +STEM AVAILABILITY GATE — read this BEFORE citing anything under stemAnalysis +- Before citing any path that starts with `stemAnalysis.`, verify that `stemAnalysis` is non-null in AUTHORITATIVE_MEASUREMENT_RESULT_JSON. +- If `stemAnalysis` is absent (no Demucs separation ran), do NOT cite ANY `stemAnalysis.*` paths. Fall back to the full-mix equivalent and explicitly hedge any element-specific claim. Example: instead of "stemAnalysis.bass.spectralBalance.subBass is high → bass dominates", say "spectralBalance.subBass is high; without stem separation we cannot isolate kick from bass contribution at sub frequencies — hedge accordingly". +- If `stemAnalysis` is present but a specific stem entry is missing (e.g. `stemAnalysis.vocals` is absent), the same rule applies for that one stem — do not cite paths under the missing stem. +- This gate is unconditional. The presence of stem-related guidance in the sections below does NOT override it; those sections describe what to cite WHEN STEMS EXIST. + +PATH SHAPE RULES — anti-shortcut (applies to every citation, both modes) +- Honor every intermediate object level. The dot-separated segments between the top-level key and the leaf are not decorative — each names a real intermediate object in the JSON tree. If the schema-doc path is `foo.bar.baz`, your citation must include `bar`; dropping it produces an invalid path that the validator will flag as MISSING_CITATION. +- Specific stem-citation example: `stemAnalysis.bass.stereoDetail.stereoWidth` is VALID; `stemAnalysis.bass.stereoWidth` is INVALID (the `stereoDetail` intermediate was dropped). The same shortcut trap exists for `stemAnalysis.{stem}.dynamicCharacter.*`, `stemAnalysis.{stem}.spectralDetail.*`, `stemAnalysis.{stem}.lufsCurve.*`, `stemAnalysis.{stem}.reverbDetail.*`, `stemAnalysis.{stem}.stereoDetail.bandCorrelations.*` — always include the intermediate object name. +- Do not invent intermediate object names. The intermediates under each top-level key are exhaustively listed in the per-namespace sections below — use them verbatim. +- This rule is intentionally generic so adding a new sibling field with the same nesting depth never creates a new exception. + +FIELD CITATION MAP — first place to look for each recommendation kind +Scan this table once before drafting a recommendation. Cite the MOST SPECIFIC applicable path under the listed namespace(s); the "Two to four entries" rule from the citation contract still applies. `stemAnalysis.{stem}.*` entries are only valid when stems are present (see STEM AVAILABILITY GATE above). + +| Recommending... | Namespaces to look at first | +|---|---| +| EQ — master or mix bus | `spectralBalance.*` + `spectralBalanceTimeSeries` + `spectralDetail.spectralCentroidMean` / `spectralRolloffMean` | +| EQ — specific element | (stem mode) `stemAnalysis.{stem}.spectralBalance.*` + `stemAnalysis.{stem}.spectralDetail.*` / (no-stem) `spectralBalance.*` + element-specific `kickDetail` / `bassDetail` / `snareDetail` / `hihatDetail` with explicit hedge | +| Dynamics / Compression — master | `crestFactor`, `dynamicSpread`, `dynamicCharacter.*`, `lufsRange`, `lufsCurve.shortTerm` | +| Dynamics / Compression — element | `stemAnalysis.{stem}.crestFactor` + `stemAnalysis.{stem}.dynamicCharacter.*` | +| Sidechain Glue / Compressor | `sidechainDetail.pumpingRate`, `sidechainDetail.pumpingStrength`, `sidechainDetail.pumpingConfidence`, `sidechainDetail.envelopeShape32` (preferred for attack/release), `sidechainDetail.envelopeShape` | +| Reverb device | `reverbDetail.rt60`, `reverbDetail.tailEnergyRatio`, `reverbDetail.perBandRt60.{low|lowMids|highMids|highs}`, `reverbDetail.preDelayMs`, `reverbDetail.isWet`; per-stem: `stemAnalysis.{stem}.reverbDetail.*` | +| Stereo width / Utility | `stereoDetail.stereoWidth`, `stereoDetail.stereoCorrelation`, `stereoDetail.subBassCorrelation`, `stereoDetail.bandCorrelations.{band}`, `stereoDetail.correlationCurve`; per-stem: `stemAnalysis.{stem}.stereoDetail.*` | +| Saturator / Limiter ceiling | `saturationDetail.clippedSamplePercent`, `saturationDetail.peakRatio95to50`, `saturationDetail.rmsToPeakRatioDb`, `saturationDetail.saturationLikely` | +| Groove / MIDI quantize / humanize | `bpm`, `grooveDetail.perDrumSwing.{kick|snare|hihat}`, `grooveDetail.kickAccent`, `grooveDetail.hihatAccent`, `rhythmDetail.tempoCurve` | +| Drum-bus character / hat-snare moves | `kickDetail.*`, `snareDetail.*`, `hihatDetail.*`, `transientDensityDetail.{band}.onsetRatePerSecond` / `.eventCount` | +| Synthesis / Sound design | `kickDetail.*`, `bassDetail.*`, `supersawDetail.*`, `acidDetail.*`, `synthesisCharacter.*`, `melodyDetail.*` | +| Vocal-bus processing | `vocalDetail.hasVocals`, `vocalDetail.confidence`, `vocalDetail.formantStrength`, `vocalDetail.stemEnergyRatio` (gate: see VOCAL DETECTION section below) | +| Arrangement automation / build-drop | `arrangementDetail.noveltyCurve`, `arrangementDetail.noveltyPeaks`, `structure.segments`, `lufsCurve.shortTerm`, `spectralBalanceTimeSeries` | +| Chord / harmonic moves | `key`, `keyConfidence`, `chordDetail.dominantChords`, `chordDetail.chordTimeline`, `chordDetail.chordChangeCount` | +| Tempo automation | `bpm`, `bpmConfidence`, `rhythmDetail.tempoCurve`, `timeSignature` | + +The per-namespace sections below provide deeper detail on WHEN to prefer one field over another within a namespace. The map above is for "where to look first"; the sections are for "which specific field within that namespace fits this recommendation". + +CITATION CONTRACT — PHASE 1 FIELDS YOU SHOULD ACTIVELY USE +Recent measurement additions are easy to miss; cite them when relevant rather than defaulting only to top-level scalars: +- lufsCurve.shortTerm / lufsCurve.momentary — per-frame EBU R128 loudness over time. Cite for section-relative dynamics ("the short-term LUFS rises from -16 LU at 1:23 to -8 LU at 1:30"), breakdown-vs-drop contrast, automation hints. +- spectralBalanceTimeSeries — per-frame 7-band balance ([{t, subBass, lowBass, lowMids, mids, upperMids, highs, brilliance}, ...]). Cite for filter-sweep claims, build-up brightness opening, time-localized spectral motion that the spectralBalance scalars conflate away. +- rhythmDetail.tempoCurve — instantaneous-BPM curve smoothed across beats. Cite when discussing ritardando/accelerando, DJ-tool tempo blends, or any tempo-modulated section. If the curve is flat, the single mean BPM is enough. +- stereoDetail.correlationCurve — 1-second windowed L/R correlation, full and sub-band. Cite for Utility-width automation, mono-collapsing the drop, stereo motion the global stereoCorrelation misses. +- arrangementDetail.noveltyCurve — higher-resolution (256-point) arrangement-change timeline. Cite for build-up ramps, transition density, sections-within-sections detail. arrangementDetail.noveltyPeaks remains the peak summary. +When these fields exist in the payload AND are relevant to a recommendation, prefer citing them over reaching for older scalar fields alone. + +PER-BAND CITATIONS (Phase 1.C #1 + #2) +- transientDensityDetail.{subBass | lowBass | lowMids | mids | upperMids | highs | brilliance}.onsetRatePerSecond and .eventCount — per-band onset density. Cite e.g. transientDensityDetail.lowBass.eventCount for kick-density / drum-bus claims; transientDensityDetail.highs.onsetRatePerSecond for hi-hat density / shaker activity. +- stereoDetail.bandCorrelations.{subBass | lowBass | ... | brilliance} — per-band L/R Pearson correlation. Cite for "bass is mono, mids are wide, highs narrow" claims and Utility-width-per-band recommendations rather than the global stereoCorrelation alone. + +SATURATION / CLIPPING HINTS (Phase 1.C #5) +- saturationDetail.clippedSamplePercent / saturationDetail.clippedSampleCount — any non-zero clipping is a real signal. Cite when recommending Limiter ceilings or Saturator pre-master moves. +- saturationDetail.peakRatio95to50 / saturationDetail.rmsToPeakRatioDb — proxy for compression/limiting depth. Low peakRatio (<1.7) + low rmsToPeakRatioDb (<7 dB) ≈ heavy mastering. Cite for makeup-gain / limiter recommendations. +- saturationDetail.saturationLikely is hint-only. Treat as low-confidence; hedge ("saturation may be present" rather than "saturation is present") and ground the claim in the specific scalar that triggered it. + +SIDECHAIN ENVELOPE (Phase 1.C #6) +- sidechainDetail.envelopeShape32 — 32-sample median-bar RMS envelope at 32nd-note resolution. Cite for fine attack/release recommendations on the Glue Compressor or Compressor's sidechain trigger when pumping is detected: a sharp dip at sample 0 with rapid recovery by sample 4 means "attack ~5 ms, release ~80 ms"; a gradual sag across samples 0-8 means "longer release ~200 ms". +- sidechainDetail.envelopeShape (16-step) remains valid; envelopeShape32 is the higher-resolution sibling — prefer it when distinguishing 16th vs 32nd-note pumping rates or specifying release-time numbers. +- sidechainDetail.pumpingRate is now four-valued: "quarter" | "eighth" | "sixteenth" | "thirty_second" | null. When the rate is "thirty_second", the kick is hitting on every 32nd-note; that's almost always a synth-bus tremolo / Auto Pan choice, not a Glue Compressor sidechain choice — recommend accordingly. + +SNARE / HI-HAT CHARACTER (Phase 1.C #4) +- snareDetail.{hitCount | hitsPerSecond | meanAttackSharpness | meanBodyEnergyRatio | meanSnapEnergyRatio | meanCentroidHz | meanDecayFrames | meanDecaySeconds | bandHz} — snare-band (120-2000 Hz) character. Cite snareDetail.meanBodyEnergyRatio for snare-bus body / saturation moves; meanSnapEnergyRatio for clap-style snap shaping; meanDecaySeconds for snare-room reverb decisions. +- hihatDetail.{...same shape...} — hi-hat band (2000-12000 Hz). meanDecaySeconds is a rough open-vs-closed proxy (closed hats ~30-60 ms, open hats >150 ms). Cite for hat-bus tone shaping, Auto Filter Q + cutoff choices, reverb-send levels. +- Both are null when no source provides ≥2 detected hits. Don't cite a null field — fall back to kickDetail + grooveDetail + beatsLoudness instead. + +PER-DRUM SWING (Phase 1.C #3) +- grooveDetail.perDrumSwing.{kick | snare | hihat} — swing values computed from the three beat-loudness bands (20-200 Hz / 200-4000 Hz / 4000-20000 Hz). Cite these together when recommending Groove pool entries, MIDI quantize amounts, or "humanize" automation — they let you say "humanize the snare 12% but keep the kick rigid" with the measurement attached. +- The legacy fields `grooveDetail.kickSwing` and `grooveDetail.hihatSwing` still exist and equal `perDrumSwing.kick` / `perDrumSwing.hihat` for backward compatibility — prefer the perDrumSwing object when citing two or three drum groups so the citations don't fragment. + +REVERB DETAIL (Phase 1.D #5) +- reverbDetail.rt60 / reverbDetail.tailEnergyRatio / reverbDetail.isWet — the global wet/dry summary. Cite for the Reverb device decision (use it or skip). +- reverbDetail.perBandRt60.{low | lowMids | highMids | highs} — RT60 per octave band, seconds. Long lows + short highs is room/hall-like; flat across bands is plate-like; long highs + short lows is bright chamber. Cite for Reverb device type (Hall vs Plate vs Convolution preset) AND for Damping parameter recommendations. +- reverbDetail.preDelayMs — pre-delay estimate in milliseconds. Cite directly for the Reverb device PreDelay parameter (`reverbDetail.preDelayMs: 42` → "Set Reverb PreDelay to 42 ms"). When close to zero, the source is dry — don't recommend a reverb with audible pre-delay. +- When `stemAnalysis.{stem}.reverbDetail` is present (Phase 1.D #5 per-stem), cite it for per-element reverb advice — e.g. "drums-bus PreDelay 12 ms; other-bus PreDelay 38 ms with longer highs (HighMids RT60 1.8 s) → Hall reverb with damping set high." This is the cleanest way to recommend different reverb treatments per element. + +CHORD TIMELINE (Phase 1.D #2) +- chordDetail.chordTimeline is an array of `{startSec, endSec, label, confidence}` segments after 5-frame median-filter smoothing. Cite for arrangement-aware harmonic claims: "the build sits on Cm from 0:48 to 1:00 (confidence 0.7), then drops to Eb for 8 bars (confidence 0.6)" — gives Phase 2 a real progression to recreate via MIDI Chord Trigger or a Drum Rack chord-stab. +- chordDetail.chordChangeCount is the count of unique chord-to-chord transitions in the timeline. Low values (≤3) indicate a static harmonic bed — recommend Reverb/Wide synth-pad treatments. High values (≥10) indicate active progression — recommend rhythmic chord-stab patches or a more dynamic sidechain feel. +- When a chordTimeline segment has `confidence < 0.5`, treat it as noisy — chord detection on a full-mix dense electronic track is hard. Hedge: "the harmony around 0:48 reads as Cm but with low confidence; alternative reading is Eb minor — keep the synth-pad chord-agnostic with sus2 voicings". +- Always cross-reference with `key` and `keyConfidence` at the top level. If `chordTimeline` consistently lands outside the measured key, either the key detection is wrong (low keyConfidence) or the track modulates — say so explicitly rather than committing to one interpretation. + +VOCAL DETECTION (with Demucs ghost-stem awareness) +- vocalDetail.hasVocals is the categorical decision; vocalDetail.confidence is the underlying composite (0.55 threshold). +- vocalDetail.stemEnergyRatio (when non-null) reports vocals-stem RMS / full-mix RMS. Below ~0.05 means Demucs leaked unrelated content into the "vocals" stem because the track is instrumental — the analyzer has already scaled `confidence` down, but Phase 2 should HEDGE any vocal-bus claim further: "if there are vocals" / "should vocal stems be present". Don't recommend vocal-bus processing devices on a track with hasVocals=true AND stemEnergyRatio < 0.05. +- vocalDetail.formantStrength below 0.3 indicates static-pitched content (synth lead, pad) rather than real vocal phones. When formantStrength is low AND stemEnergyRatio is low, treat the "vocal" content as a melodic synth lead and recommend lead-bus processing (Auto Filter, Saturator, Reverb send) rather than vocal-bus processing (de-esser, Vocal Aligner, pitch-correction). +- vocalDetail.stemOtherCorrelation (when non-null) reports envelope-rate Pearson correlation between the vocals stem and the other stem. Above ~0.55 means Demucs split one melodic source across both stems (a misclassified lead); the analyzer already scales confidence down. Phase 2 should hedge vocal-bus claims in that case AND highlight that the lead/synth element is likely the "vocal" content. Below ~0.30 means the vocals stem is independent — no penalty. +- The hard gate: regardless of confidence, `vocalDetail.hasVocals` is False whenever `formantStrength <= 0.3`. So a True `hasVocals` always implies measurable formant motion. Trust this for "is there a vocal to mix?" decisions; trust `confidence` for "how much to commit to vocal-specific moves". + +STEM-FIRST CITATIONS (Phase 1.B) +When stemAnalysis is present in the payload (Demucs-separated runs), prefer per-stem citations over full-mix scalars for element-specific recommendations. The per-stem subtree under each of ``drums | bass | other | vocals`` mirrors the top-level full-mix shape — these are the EXACT valid sub-paths you can cite. Do NOT collapse intermediate objects (e.g. ``stemAnalysis.drums.spectralFlatness`` is INVALID; the correct path is ``stemAnalysis.drums.dynamicCharacter.spectralFlatness`` or ``stemAnalysis.drums.spectralDetail.spectralFlatness`` — both exist): + +- stemAnalysis.{stem}.spectralBalance.{subBass | lowBass | lowMids | mids | upperMids | highs | brilliance} +- stemAnalysis.{stem}.spectralBalanceTimeSeries (array of {t, subBass, ..., brilliance}) +- stemAnalysis.{stem}.spectralDetail.{spectralCentroidMean | spectralRolloffMean | spectralBandwidthMean | spectralFlatnessMean | mfcc | chroma | barkBands | erbBands} +- stemAnalysis.{stem}.lufsIntegrated | lufsRange | lufsMomentaryMax | lufsShortTermMax +- stemAnalysis.{stem}.lufsCurve.{shortTerm | momentary} +- stemAnalysis.{stem}.stereoDetail.{stereoWidth | stereoCorrelation | subBassCorrelation | subBassMono | correlationCurve | bandCorrelations.{subBass | ... | brilliance}} +- stemAnalysis.{stem}.truePeak | crestFactor | dynamicSpread +- stemAnalysis.{stem}.dynamicCharacter.{dynamicComplexity | loudnessDb | loudnessVariation | spectralFlatness | logAttackTime | attackTimeStdDev} +- stemAnalysis.{stem}.reverbDetail.{rt60 | isWet | tailEnergyRatio | measured | perBandRt60.{low | lowMids | highMids | highs} | preDelayMs} (Phase 1.D #5; null on dry stems where `measured` is False) + +Element-targeting examples: +- drums-bus compression — cite stemAnalysis.drums.crestFactor and stemAnalysis.drums.dynamicCharacter.logAttackTime +- bass-only EQ — cite stemAnalysis.bass.spectralBalance.subBass and stemAnalysis.bass.spectralDetail.spectralCentroidMean +- lead/synth brightness — cite stemAnalysis.other.spectralBalance.upperMids and stemAnalysis.other.spectralBalance.highs +- vocal de-essing — cite stemAnalysis.vocals.spectralDetail.spectralCentroidMean and stemAnalysis.vocals.spectralBalance.upperMids + +When stemAnalysis is null/absent (no separation ran), hedge any element-specific claim that depends on isolating one element. "The supersaw is wide" without stemAnalysis means you're inferring from a full-mix stereo metric that conflates every element — say so explicitly or skip the claim. + +BPM, key, time signature, structure novelty, sidechain pumping remain song-level and do NOT have stem-specific variants. Cite them at the top-level. + +PHASE-2 SELF-REFERENCE IS NOT A CITATION +phase1Fields entries may ONLY reference fields under AUTHORITATIVE_MEASUREMENT_RESULT_JSON. Your own Phase 2 output fields (``arrangementOverview.segments[i].lufs``, ``audioObservations.*``, ``mixAndMasterChain[i].*``, ``trackLayout[i].*``, ``styleProfile.*``, etc.) are NOT Phase 1 measurements — they're the response YOU are generating. Citing them as ``phase1Fields`` is a logical error: a recommendation cannot be grounded in another recommendation. The validator flags this as a missing-citation error. + AUDIO LISTENING TASKS - Use the audio file itself for perceptual observations that DSP cannot measure well. - Perform explicit audio listening for: @@ -260,6 +385,7 @@ mixAndMasterChain - parameter - value - reason + - phase1Fields (see CITATION CONTRACT) - Keep the chain practical and grounded. Do not invent unsupported device names or parameter labels. secretSauce @@ -276,6 +402,7 @@ secretSauce - value - instruction - measurementJustification + - phase1Fields (see CITATION CONTRACT — the structured sibling of measurementJustification's free text) confidenceNotes - Include at least 5 items. @@ -302,6 +429,7 @@ abletonRecommendations - value - reason - advancedTip + - phase1Fields (see CITATION CONTRACT) - category must be exactly one of: - DYNAMICS - EFFECTS @@ -321,7 +449,7 @@ abletonRecommendations - workflowStage = SOUND_DESIGN - category = SYNTHESIS - Valid example card: - - {"device":"Operator","deviceFamily":"NATIVE","trackContext":"Acid Bass","workflowStage":"SOUND_DESIGN","category":"SYNTHESIS","parameter":"Envelope Decay","value":"320 ms","reason":"The measured bass sustain is compact and percussive.","advancedTip":"Add a second oscillator one octave down only if the measured sub range still feels thin."} + - {"device":"Operator","deviceFamily":"NATIVE","trackContext":"Acid Bass","workflowStage":"SOUND_DESIGN","category":"SYNTHESIS","parameter":"Envelope Decay","value":"320 ms","reason":"The measured bass sustain is compact and percussive.","phase1Fields":["bassDetail.decayTime","bassDetail.transientRatio"],"advancedTip":"Add a second oscillator one octave down only if the measured sub range still feels thin."} - advancedTip must still be specific to this track, not a generic producer quote. FAILURE AVOIDANCE diff --git a/apps/backend/scripts/audit_pass1.py b/apps/backend/scripts/audit_pass1.py new file mode 100644 index 00000000..5ed555ef --- /dev/null +++ b/apps/backend/scripts/audit_pass1.py @@ -0,0 +1,665 @@ +#!/usr/bin/env python3 +"""Track 2 audit pass 1. + +Loads a Phase 1 measurement JSON for a real bench track and produces a +structured audit report comparing ASA's measurements against listener +ground-truth values declared inline below. + +Audit-priority ordering matches the plan +(.claude/plans/okay-im-quite-worried-partitioned-reddy.md, Track 2 section). + +Outputs a markdown report to .runtime/reports/audit_pass1_.md. + +Status verdicts: + PASS — measurement falls inside tolerance / matches ground truth + WARN — measurement is sane but outside tight tolerance OR confidence is honestly low + FAIL — confident wrong answer (the actual product risk) + NEEDS_LISTENER — requires human ground-truth comparison; documented for later pass + N/A — field structurally absent + +NOTE: this script reads the raw analyze.py output (pre-HTTP-normalization). +Spectral-detail fields use their raw names (spectralCentroid, etc.) here. +The normalizer in server_phase1.py renames these to *Mean for the frontend +contract — but for raw-JSON auditing we use the analyzer-side names. +""" + +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass +class GroundTruth: + """Per-track ground-truth values + audit tolerances.""" + + label: str + audio_relpath: str + + bpm_listener: float | None = None + bpm_tolerance: float = 1.0 + + key_listener: str | None = None + + lufs_integrated_expected_min: float = -30.0 + lufs_integrated_expected_max: float = 0.0 + lufs_integrated_listener: float | None = None + lufs_tolerance: float = 0.5 + + sub_bass_correlation_expected_min: float = 0.5 + sub_bass_correlation_listener: float | None = None + stereo_width_listener: float | None = None + + time_signature_listener: str | None = None + + has_acid_listener: bool | None = None + has_supersaw_listener: bool | None = None + has_kick_listener: bool | None = None + has_bass_listener: bool | None = None + has_vocal_listener: bool | None = None + has_reverb_listener: bool | None = None + has_sidechain_listener: bool | None = None + + kick_count_per_sec_listener_low: float | None = None + kick_count_per_sec_listener_high: float | None = None + + notes: list[str] = field(default_factory=list) + + +def _get(payload: dict, dotted: str, default: Any = None) -> Any: + cur: Any = payload + for part in dotted.split("."): + if isinstance(cur, dict) and part in cur: + cur = cur[part] + else: + return default + return cur + + +# ---------- BPM ---------- +def _audit_bpm(p: dict, gt: GroundTruth) -> list[tuple[str, str, str]]: + bpm = _get(p, "bpm") + src = _get(p, "bpmSource") + conf = _get(p, "bpmConfidence") + pc = _get(p, "bpmPercival") + rows: list[tuple[str, str, str]] = [] + rows.append(("bpm", f"{bpm:.2f} (src={src}, conf={conf})" if bpm else "None", + _bpm_verdict(bpm, gt))) + if pc is not None: + rows.append(("bpmPercival cross-check", f"{pc:.2f}", _pc_verdict(bpm, pc))) + else: + rows.append(("bpmPercival cross-check", "absent", "N/A")) + return rows + + +def _bpm_verdict(bpm: float | None, gt: GroundTruth) -> str: + if bpm is None: + return "FAIL — bpm is None" + if gt.bpm_listener is None: + return f"NEEDS_LISTENER — {bpm:.2f} in 40-220 plausible range" + diff = abs(bpm - gt.bpm_listener) + if diff <= gt.bpm_tolerance: + return f"PASS — within ±{gt.bpm_tolerance:.1f} of listener {gt.bpm_listener:.2f}" + if abs(bpm - gt.bpm_listener * 2) <= 2 or abs(bpm * 2 - gt.bpm_listener) <= 2: + return f"FAIL — possible doubletime: ASA {bpm:.2f} vs listener {gt.bpm_listener:.2f}" + return f"WARN — {diff:.2f} BPM off from listener {gt.bpm_listener:.2f}" + + +def _pc_verdict(bpm: float | None, pc: float) -> str: + if bpm is None: + return "N/A" + if abs(bpm - pc) <= 1.5: + return "PASS — Percival agrees within 1.5 BPM" + if abs(bpm - pc * 2) <= 2 or abs(bpm * 2 - pc) <= 2: + return f"WARN — Percival ratio cross-check suggests doubletime ({pc:.2f})" + return f"WARN — Percival disagreement: {pc:.2f} vs RhythmExtractor {bpm:.2f}" + + +# ---------- Key ---------- +def _audit_key(p: dict, gt: GroundTruth) -> list[tuple[str, str, str]]: + key = _get(p, "key") + conf = _get(p, "keyConfidence") + src = _get(p, "keySource") + return [ + ("key", f"{key} (src={src})", _key_verdict(key, conf, gt)), + ("keyConfidence", f"{conf}", _key_conf_verdict(key, conf, gt)), + ] + + +def _key_verdict(key: str | None, conf: float | None, gt: GroundTruth) -> str: + if key is None: + return "FAIL — key is None" + if gt.key_listener is None: + return "NEEDS_LISTENER — listener key not declared" + if gt.key_listener.lower() in ("modal", "atonal", "modal/atonal"): + return f"PASS — modal material correctly hedged (conf {conf:.2f} < 0.5)" if conf is not None and conf < 0.5 \ + else f"WARN — modal material but ASA reports confident key {key} (conf {conf})" + if key.strip().lower() == gt.key_listener.strip().lower(): + return f"PASS — matches listener key {gt.key_listener}" + return f"FAIL — ASA says {key}, listener says {gt.key_listener}" + + +def _key_conf_verdict(key: str | None, conf: float | None, gt: GroundTruth) -> str: + if conf is None: + return "FAIL — keyConfidence is None" + if key is None or gt.key_listener is None: + return "N/A" + listener = gt.key_listener.strip().lower() + asa = key.strip().lower() + if listener in ("modal", "atonal", "modal/atonal"): + return "PASS" if conf < 0.5 else "FAIL — should be honestly low for modal material" + if asa == listener: + return "PASS — confident on a correct match" if conf >= 0.5 else "WARN — correct but unhedged-low" + return "FAIL — high confidence on a wrong answer" if conf >= 0.6 else "PASS — wrong but honest about it" + + +# ---------- LUFS ---------- +def _audit_lufs(p: dict, gt: GroundTruth) -> list[tuple[str, str, str]]: + lufs = _get(p, "lufsIntegrated") + rows: list[tuple[str, str, str]] = [("lufsIntegrated", f"{lufs}", _lufs_verdict(lufs, gt))] + curve = _get(p, "lufsCurve") or {} + short = curve.get("shortTerm") or [] + momentary = curve.get("momentary") or [] + if short: + st_vals = [pt.get("lufs") for pt in short if pt.get("lufs") is not None] + if st_vals: + rows.append(( + "lufsCurve.shortTerm range", + f"min {min(st_vals):.2f}, max {max(st_vals):.2f}, points {len(short)}", + _lufs_curve_verdict(st_vals, lufs), + )) + if momentary: + rows.append(("lufsCurve.momentary points", f"{len(momentary)}", + "PASS" if 50 <= len(momentary) <= 1000 else "WARN — outside 50-1000")) + return rows + + +def _lufs_verdict(lufs: float | None, gt: GroundTruth) -> str: + if lufs is None: + return "FAIL — lufsIntegrated is None" + if not (gt.lufs_integrated_expected_min <= lufs <= gt.lufs_integrated_expected_max): + return f"FAIL — {lufs} outside sane range [{gt.lufs_integrated_expected_min}, {gt.lufs_integrated_expected_max}]" + if gt.lufs_integrated_listener is not None: + diff = abs(lufs - gt.lufs_integrated_listener) + return f"PASS — within ±{gt.lufs_tolerance} of listener {gt.lufs_integrated_listener}" if diff <= gt.lufs_tolerance \ + else f"WARN — {diff:.2f} LU off from listener {gt.lufs_integrated_listener}" + return "PASS — in sane range (NEEDS_LISTENER for tight tolerance)" + + +def _lufs_curve_verdict(vals: list[float], integrated: float | None) -> str: + if integrated is None: + return "N/A" + mn, mx = min(vals), max(vals) + return f"PASS — integrated {integrated:.2f} within short-term range" if mn - 1 <= integrated <= mx + 1 \ + else f"WARN — integrated {integrated:.2f} outside short-term [{mn:.2f}, {mx:.2f}]" + + +# ---------- Spectral balance ---------- +EXPECTED_BANDS = ["subBass", "lowBass", "lowMids", "mids", "upperMids", "highs", "brilliance"] + + +def _audit_spectral_balance(p: dict) -> list[tuple[str, str, str]]: + bal = _get(p, "spectralBalance") or {} + rows: list[tuple[str, str, str]] = [] + for band in EXPECTED_BANDS: + v = bal.get(band) + if v is None: + rows.append((f"spectralBalance.{band}", "None", "FAIL")) + elif not (-80 < v < 20): + rows.append((f"spectralBalance.{band}", f"{v:.2f} dB", "FAIL — outside [-80, 20] dB sanity")) + else: + rows.append((f"spectralBalance.{band}", f"{v:.2f} dB", "PASS")) + ts = p.get("spectralBalanceTimeSeries") or [] + if ts: + rows.append(("spectralBalanceTimeSeries length", f"{len(ts)} points", + "PASS" if 100 <= len(ts) <= 500 else "WARN — outside 100-500")) + first = ts[0] if ts else {} + missing = [b for b in EXPECTED_BANDS if b not in first] + rows.append(("spectralBalanceTimeSeries band coverage", + f"first-row missing: {missing or 'none'}", + "PASS" if not missing else "FAIL")) + else: + rows.append(("spectralBalanceTimeSeries", "absent or empty", + "FAIL — new Phase 1.A field expected")) + return rows + + +# ---------- Stereo ---------- +def _audit_stereo(p: dict, gt: GroundTruth) -> list[tuple[str, str, str]]: + stereo = _get(p, "stereoDetail") or {} + rows: list[tuple[str, str, str]] = [] + sw = stereo.get("stereoWidth") + rows.append(("stereoDetail.stereoWidth", f"{sw}", _stereo_width_verdict(sw, gt))) + sc = stereo.get("stereoCorrelation") + rows.append(("stereoDetail.stereoCorrelation", f"{sc}", + "PASS" if sc is not None and -1.0 <= sc <= 1.0 else "FAIL")) + sub = stereo.get("subBassCorrelation") + rows.append(("stereoDetail.subBassCorrelation", f"{sub}", _sub_bass_verdict(sub, gt))) + bands = stereo.get("bandCorrelations") or {} + if bands: + missing = [b for b in EXPECTED_BANDS if b not in bands] + present_vals = [(b, bands[b]) for b in EXPECTED_BANDS if bands.get(b) is not None] + sane = all(-1 <= v <= 1 for _, v in present_vals) + rows.append(( + "stereoDetail.bandCorrelations coverage", + f"present={len(present_vals)}/7, missing={missing or 'none'}", + "PASS" if not missing and sane else "WARN", + )) + else: + rows.append(("stereoDetail.bandCorrelations", "absent", + "FAIL — new Phase 1.C field expected")) + curve = stereo.get("correlationCurve") or [] + rows.append(("stereoDetail.correlationCurve", f"{len(curve)} points", + "PASS" if 30 <= len(curve) <= 500 else "WARN — outside 30-500")) + return rows + + +def _stereo_width_verdict(sw: float | None, gt: GroundTruth) -> str: + if sw is None: + return "FAIL — None" + if not (0 <= sw <= 2.5): + return "FAIL — outside sane [0, 2.5]" + if gt.stereo_width_listener is not None: + diff = abs(sw - gt.stereo_width_listener) + return "PASS" if diff <= 0.15 else f"WARN — {diff:.2f} off from listener {gt.stereo_width_listener}" + return "PASS — in sane range (NEEDS_LISTENER tighter)" + + +def _sub_bass_verdict(sub: float | None, gt: GroundTruth) -> str: + if sub is None: + return "FAIL — None" + if not (-1 <= sub <= 1): + return "FAIL — outside [-1, 1]" + if sub < gt.sub_bass_correlation_expected_min: + return f"WARN — sub-bass correlation {sub:.2f} below {gt.sub_bass_correlation_expected_min} expected for modern mixes" + return f"PASS — sub-bass correlation {sub:.2f} consistent with mono-centered bass" + + +# ---------- Kick / Bass ---------- +def _audit_kick(p: dict, gt: GroundTruth, duration_sec: float | None) -> list[tuple[str, str, str]]: + kd = _get(p, "kickDetail") or {} + rows: list[tuple[str, str, str]] = [] + fund = kd.get("fundamentalHz") + rows.append(("kickDetail.fundamentalHz", f"{fund}", + "PASS — in 30-120 Hz typical" if fund and 30 <= fund <= 120 else "WARN — outside 30-120 Hz typical")) + kc = kd.get("kickCount") + if kc is not None and duration_sec: + density = kc / duration_sec + rows.append(( + "kickDetail.kickCount density", + f"{kc} kicks / {duration_sec:.1f}s = {density:.2f} kicks/s", + _kick_density_verdict(density, gt), + )) + else: + rows.append(("kickDetail.kickCount density", "kc or duration unavailable", "N/A")) + distorted = kd.get("isDistorted") + rows.append(("kickDetail.isDistorted (raw flag)", f"{distorted}", + "PASS — categorical bool" if isinstance(distorted, bool) else "WARN")) + return rows + + +def _kick_density_verdict(density: float, gt: GroundTruth) -> str: + if gt.kick_count_per_sec_listener_low is None or gt.kick_count_per_sec_listener_high is None: + if density > 5.0: + return f"FAIL — {density:.2f} kicks/s unphysically high (likely transient miscount; Codex PDF finding)" + if 1.5 <= density <= 5.0: + return "PASS — plausible for typical electronic music" + return f"WARN — {density:.2f} kicks/s is low; possible miss" + if gt.kick_count_per_sec_listener_low <= density <= gt.kick_count_per_sec_listener_high: + return f"PASS — within listener-declared {gt.kick_count_per_sec_listener_low}-{gt.kick_count_per_sec_listener_high} kicks/s" + return f"FAIL — outside listener-declared {gt.kick_count_per_sec_listener_low}-{gt.kick_count_per_sec_listener_high} kicks/s" + + +def _audit_bass(p: dict) -> list[tuple[str, str, str]]: + bd = _get(p, "bassDetail") or {} + rows: list[tuple[str, str, str]] = [] + fund = bd.get("fundamentalHz") + rows.append(("bassDetail.fundamentalHz", f"{fund}", + "PASS" if fund and 30 <= fund <= 350 else "WARN — outside 30-350 Hz typical")) + avg_decay_ms = bd.get("averageDecayMs") + rows.append(("bassDetail.averageDecayMs", f"{avg_decay_ms}", _bass_decay_verdict(avg_decay_ms))) + tc = bd.get("transientCount") + tr = bd.get("transientRatio") + rows.append(("bassDetail.transientCount / transientRatio", f"{tc} / {tr}", + "PASS" if tr is not None and 0 <= tr <= 10 else "WARN")) + return rows + + +def _bass_decay_verdict(avg_decay_ms: float | None) -> str: + if avg_decay_ms is None: + return "FAIL — averageDecayMs is None" + if avg_decay_ms == 0 or avg_decay_ms == 0.0: + return "FAIL — Codex PDF finding reproduced: averageDecayMs == 0 ms is unphysical for sustained bass" + if 30 <= avg_decay_ms <= 3000: + return f"PASS — {avg_decay_ms} ms decay is plausible" + return f"WARN — {avg_decay_ms} ms outside typical 30-3000 ms" + + +# ---------- Sidechain ---------- +def _audit_sidechain(p: dict) -> list[tuple[str, str, str]]: + sd = _get(p, "sidechainDetail") or {} + rows: list[tuple[str, str, str]] = [] + rate = sd.get("pumpingRate") + rows.append(("sidechainDetail.pumpingRate", f"{rate}", + "PASS — categorical" if rate else "WARN — null (no rhythm assigned)")) + pump_conf = sd.get("pumpingConfidence") + rows.append(("sidechainDetail.pumpingConfidence", f"{pump_conf}", _pump_conf_verdict(pump_conf))) + strength = sd.get("pumpingStrength") + rows.append(("sidechainDetail.pumpingStrength", f"{strength}", + "PASS — numeric" if isinstance(strength, (int, float)) and 0 <= strength <= 1.5 else "WARN")) + regularity = sd.get("pumpingRegularity") + rows.append(("sidechainDetail.pumpingRegularity", f"{regularity}", + "PASS — numeric" if regularity is None or isinstance(regularity, (int, float)) else "WARN")) + env = sd.get("envelopeShape") or [] + rows.append(("sidechainDetail.envelopeShape length", f"{len(env)} samples", + "PASS" if 12 <= len(env) <= 64 else "WARN — outside 12-64")) + return rows + + +def _pump_conf_verdict(pc: float | None) -> str: + if pc is None: + return "N/A" + return f"PASS — {pc:.2f} in [0,1]" if 0.0 <= pc <= 1.0 else f"FAIL — {pc} outside [0,1]" + + +# ---------- Detectors ---------- +DETECTOR_FLAGS = [ + ("acidDetail", "isAcid", "has_acid_listener"), + ("supersawDetail", "isSupersaw", "has_supersaw_listener"), + ("kickDetail", "isDistorted", None), # there's no "is there a kick" flag — only "is the kick distorted" + ("bassDetail", None, None), # bassDetail has no presence-flag — its type/grooveType fields imply presence + ("vocalDetail", "hasVocals", "has_vocal_listener"), + ("reverbDetail", "isWet", "has_reverb_listener"), +] + + +def _audit_detectors(p: dict, gt: GroundTruth) -> list[tuple[str, str, str]]: + rows: list[tuple[str, str, str]] = [] + for det_path, flag_field, gt_attr in DETECTOR_FLAGS: + det = _get(p, det_path) or {} + if flag_field is None: + # No presence-flag for this detector; report type/groove only + type_or_groove = det.get("type") or det.get("grooveType") + rows.append((f"{det_path} (no presence flag)", f"type/groove={type_or_groove}", + "N/A — detector has no boolean presence flag")) + continue + flag = det.get(flag_field) + conf = det.get("confidence") + observed = f"{flag_field}={flag}, confidence={conf}" + listener = getattr(gt, gt_attr) if gt_attr else None + if listener is None: + rows.append((f"{det_path}.{flag_field}", observed, "NEEDS_LISTENER")) + continue + if flag is None: + rows.append((f"{det_path}.{flag_field}", observed, "FAIL — flag is None")) + continue + if bool(flag) == bool(listener): + rows.append((f"{det_path}.{flag_field}", observed, f"PASS — matches listener ({listener})")) + else: + sev = "FAIL" if (conf is None or conf >= 0.5) else "WARN" + rows.append((f"{det_path}.{flag_field}", observed, + f"{sev} — listener says {listener}, conf {conf}")) + return rows + + +# ---------- Time signature ---------- +def _audit_time_signature(p: dict, gt: GroundTruth) -> list[tuple[str, str, str]]: + ts = _get(p, "timeSignature") + src = _get(p, "timeSignatureSource") + conf = _get(p, "timeSignatureConfidence") + return [("timeSignature", f"{ts} (src={src}, conf={conf})", + _time_sig_verdict(ts, src, conf, gt))] + + +def _time_sig_verdict(ts: str | None, src: str | None, conf: float | None, gt: GroundTruth) -> str: + if ts is None: + return "FAIL — None" + if src == "fallback" or src is None: + return f"WARN — fallback path active (no real detection); ts={ts}" + if gt.time_signature_listener is None: + return f"NEEDS_LISTENER — source={src}, conf={conf}" + if ts.strip() == gt.time_signature_listener.strip(): + return f"PASS — matches listener {gt.time_signature_listener} (src={src}, conf={conf})" + return f"FAIL — ASA says {ts}, listener says {gt.time_signature_listener}" + + +# ---------- Structure ---------- +def _audit_structure(p: dict) -> list[tuple[str, str, str]]: + rows: list[tuple[str, str, str]] = [] + # Top-level `structure.segments` holds the SBic + novelty-fusion segments. + # `arrangementDetail` holds the novelty curve + peaks (no segments key). + struct = _get(p, "structure") or {} + segs = struct.get("segments") or [] + seg_count = struct.get("segmentCount") + rows.append(( + "structure.segments count", + f"{len(segs)} segments (segmentCount={seg_count})", + "PASS — non-trivial structure" if 3 <= len(segs) <= 20 else "WARN — outside 3-20", + )) + + arr = _get(p, "arrangementDetail") or {} + peaks = arr.get("noveltyPeaks") or [] + rows.append(( + "arrangementDetail.noveltyPeaks count", + f"{len(peaks)} peaks", + "PASS — non-trivial" if 3 <= len(peaks) <= 30 else "WARN", + )) + nc = arr.get("noveltyCurve") or [] + rows.append(( + "arrangementDetail.noveltyCurve points", + f"{len(nc)} points", + "PASS" if 100 <= len(nc) <= 500 else "WARN — outside 100-500", + )) + return rows + + +# ---------- New-field shape audit ---------- +def _audit_new_fields(p: dict) -> list[tuple[str, str, str]]: + """Phase 1.A / 1.B / 1.C new fields. Existence + shape only — not vs ground truth.""" + rows: list[tuple[str, str, str]] = [] + + # Phase 1.A + rhythm = _get(p, "rhythmDetail") or {} + tc = rhythm.get("tempoCurve") or [] + rows.append(("rhythmDetail.tempoCurve", f"{len(tc)} points", + "PASS" if 10 <= len(tc) <= 500 else "WARN")) + + # Phase 1.B per-stem spectralDetail (raw analyzer names; HTTP-side they're normalized to *Mean) + stems = _get(p, "stemAnalysis") or {} + if stems: + rows.append(("stemAnalysis stems", ", ".join(stems.keys()), + "PASS — stems analyzed")) + for stem_name in ("drums", "bass", "other", "vocals"): + stem = stems.get(stem_name) or {} + sd = stem.get("spectralDetail") or {} + sc = sd.get("spectralCentroid") if sd else None + rows.append(( + f"stemAnalysis.{stem_name}.spectralDetail.spectralCentroid (raw name)", + f"{sc}" if sc is None or isinstance(sc, (int, float)) else f"{type(sc).__name__} (non-scalar)", + "PASS" if isinstance(sc, (int, float)) else "WARN — not scalar at raw layer", + )) + sb_keys = stem.get("spectralBalance") + sb_keys_label = "absent" if sb_keys is None else f"{len(sb_keys)} bands" if isinstance(sb_keys, dict) else f"{type(sb_keys).__name__}" + rows.append(( + f"stemAnalysis.{stem_name}.spectralBalance", + sb_keys_label, + "PASS" if isinstance(sb_keys, dict) and len(sb_keys) == 7 else "WARN", + )) + else: + rows.append(("stemAnalysis", "absent", "WARN — no --separate flag passed")) + + # Phase 1.C #1 (transient density flattened) + td = _get(p, "transientDensityDetail") or {} + bands_present = [b for b in EXPECTED_BANDS if td.get(b) is not None] + rows.append(( + "transientDensityDetail per-band density coverage", + f"present={len(bands_present)}/7 ({bands_present})", + "PASS" if len(bands_present) == 7 else f"FAIL — expected 7, got {len(bands_present)}", + )) + + # Phase 1.C #5 (saturation) + sat = _get(p, "saturationDetail") or {} + rows.append(("saturationDetail.clippedSampleCount", f"{sat.get('clippedSampleCount')}", + "PASS" if sat.get("clippedSampleCount") is not None else "WARN — null")) + rows.append(("saturationDetail.peakRatio95to50", f"{sat.get('peakRatio95to50')}", + "PASS" if sat.get("peakRatio95to50") is not None else "WARN — null")) + rows.append(("saturationDetail.saturationLikely", f"{sat.get('saturationLikely')}", + "PASS — bool" if isinstance(sat.get("saturationLikely"), bool) else "WARN")) + + # Phase 1.C #4 (snare / hihat) + for det_name in ("snareDetail", "hihatDetail"): + det = _get(p, det_name) or {} + hits = det.get("hitCount") + band = det.get("bandHz") + att = det.get("meanAttackSharpness") + rows.append(( + f"{det_name} shape (hitCount / bandHz / meanAttackSharpness)", + f"{hits} / {band} / {att}", + "PASS — populated" if all(v is not None for v in (hits, band, att)) else "WARN — partial null", + )) + + return rows + + +# ---------- Rendering ---------- +def _section(title: str, rows: list[tuple[str, str, str]]) -> str: + out = [f"### {title}\n", "| Field | Observed | Verdict |", "|---|---|---|"] + for f_name, observed, verdict in rows: + out.append(f"| `{f_name}` | {observed} | {verdict} |") + return "\n".join(out) + "\n" + + +def _summarize(rows: list[tuple[str, str, str]]) -> dict: + c = {"PASS": 0, "WARN": 0, "FAIL": 0, "NEEDS_LISTENER": 0, "N/A": 0} + for _, _, v in rows: + for k in c: + if v.startswith(k): + c[k] += 1 + break + return c + + +VTSS_GT = GroundTruth( + label="Vtss — Can't Catch Me", + audio_relpath="tests/fixtures/bench_tracks/Vtss-CantCatchMe.mp3", + bpm_listener=145.0, + bpm_tolerance=2.0, + key_listener="C Minor", + lufs_integrated_listener=None, + lufs_integrated_expected_min=-25.0, + lufs_integrated_expected_max=-4.0, + sub_bass_correlation_expected_min=0.5, + stereo_width_listener=None, + time_signature_listener="4/4", + has_acid_listener=None, + has_supersaw_listener=None, + has_kick_listener=True, + has_bass_listener=True, + has_vocal_listener=False, + has_reverb_listener=None, + has_sidechain_listener=None, + kick_count_per_sec_listener_low=None, + kick_count_per_sec_listener_high=None, + notes=[ + "Bench reference track for 2026 Phase 1 audit; declared as ~145 BPM, C Minor four-on-the-floor electronic.", + "Replace `None` ground-truth fields with reference-tool values during a future listener pass.", + "Confidence calibration is the most product-valuable thing this audit can produce — see PURPOSE.md invariant #4.", + ], +) + + +def _report_for(path: Path, gt: GroundTruth, *, label_suffix: str = "") -> str: + payload = json.loads(path.read_text()) + duration = _get(payload, "duration") or _get(payload, "durationSeconds") + + bpm = _audit_bpm(payload, gt) + key = _audit_key(payload, gt) + lufs = _audit_lufs(payload, gt) + sb = _audit_spectral_balance(payload) + st = _audit_stereo(payload, gt) + kk = _audit_kick(payload, gt, duration) + bs = _audit_bass(payload) + sc = _audit_sidechain(payload) + det = _audit_detectors(payload, gt) + ts = _audit_time_signature(payload, gt) + sr = _audit_structure(payload) + nf = _audit_new_fields(payload) + + all_rows = bpm + key + lufs + sb + st + kk + bs + sc + det + ts + sr + nf + sm = _summarize(all_rows) + + parts = [ + f"## {gt.label}{label_suffix}\n", + f"Source JSON: `{path}`. Duration: `{duration}s`.", + "", + f"**Summary:** PASS={sm['PASS']} WARN={sm['WARN']} FAIL={sm['FAIL']} NEEDS_LISTENER={sm['NEEDS_LISTENER']} N/A={sm['N/A']}", + "", + _section("1. BPM", bpm), + _section("2. Key", key), + _section("3. LUFS", lufs), + _section("4. Spectral balance (7 bands + time series)", sb), + _section("5. Stereo (width / correlation / per-band / curve)", st), + _section("6. Kick detail (fundamental Hz + density + distortion flag)", kk), + _section("7. Bass detail (fundamental Hz, decay, transient ratio)", bs), + _section("8. Sidechain detail (rate / confidence / strength / regularity / envelope)", sc), + _section("9. Detectors (acid, supersaw, kick distortion, vocal, reverb wet)", det), + _section("10. Time signature (real detection vs fallback)", ts), + _section("11. Structure / arrangement", sr), + _section("12. Phase 1.A / 1.B / 1.C new-field shape audit", nf), + ] + if gt.notes: + parts.append("### Ground-truth notes\n") + for n in gt.notes: + parts.append(f"- {n}") + parts.append("") + return "\n".join(parts) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--vtss-nostem", default="/tmp/vtss_post_fix.json") + parser.add_argument("--vtss-stem", default="/tmp/vtss_full_separate.json") + parser.add_argument("--out", default=None) + args = parser.parse_args() + + reports: list[str] = [] + pnostem = Path(args.vtss_nostem) + pstem = Path(args.vtss_stem) + + if pnostem.exists(): + reports.append(_report_for(pnostem, VTSS_GT, label_suffix=" (no-stem run)")) + else: + reports.append(f"## {VTSS_GT.label} (no-stem run)\n\n_Missing JSON `{pnostem}`._\n") + + if pstem.exists(): + reports.append(_report_for(pstem, VTSS_GT, label_suffix=" (--separate stem run)")) + else: + reports.append(f"## {VTSS_GT.label} (--separate stem run)\n\n_Missing JSON `{pstem}`._\n") + + timestamp = _dt.datetime.utcnow().strftime("%Y-%m-%dT%H-%M-%SZ") + out_path = Path(args.out) if args.out else Path(__file__).resolve().parent.parent / ".runtime" / "reports" / f"audit_pass1_{timestamp}.md" + out_path.parent.mkdir(parents=True, exist_ok=True) + + header = [ + "# Track 2 Audit Pass 1 — Vtss baseline", + f"Generated: {_dt.datetime.utcnow().isoformat()}Z", + "", + "Audits real-track Phase 1 measurements against listener ground-truth + structural sanity checks.", + "Verdict legend: PASS / WARN / FAIL / NEEDS_LISTENER / N/A.", + "", + "Audit-priority ordering matches the approved plan's Track 2 'Audit priorities (in order)' section.", + "Field names match the raw analyze.py output (pre-HTTP normalization); spectralCentroid → spectralCentroidMean rename happens at the HTTP/Gemini boundary, not in analyze.py.", + "", + ] + out_path.write_text("\n".join(header) + "\n".join(reports)) + print(f"Wrote {out_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apps/backend/scripts/evaluate_phase1.py b/apps/backend/scripts/evaluate_phase1.py index 3b888364..84359528 100644 --- a/apps/backend/scripts/evaluate_phase1.py +++ b/apps/backend/scripts/evaluate_phase1.py @@ -7,20 +7,29 @@ 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 phase1_evaluation import ( + DEFAULT_BENCH_TRACKS_DIR, DEFAULT_MANIFEST_PATH, DEFAULT_REPORT_PATH, run_phase1_evaluation, ) +from phase1_report_html import default_html_report_path, render_html_report def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Run Phase 1 evaluation harness.") + parser = argparse.ArgumentParser( + description=( + "Run Phase 1 evaluation harness. By default runs only the synthetic-" + "fixture gate (safe for CI). Pass --include-real to also evaluate " + "real reference tracks from the local bench when present." + ) + ) parser.add_argument( "--manifest", type=Path, @@ -39,6 +48,36 @@ def parse_args() -> argparse.Namespace: default=2, help="Number of repeated analyze runs per fixture (default: 2)", ) + parser.add_argument( + "--include-real", + action="store_true", + help=( + "Opt in to evaluating real reference tracks listed under " + "manifest.realTracks. Missing audio files are skipped with a clear " + "notice rather than failing the run." + ), + ) + parser.add_argument( + "--real-tracks-dir", + type=Path, + default=DEFAULT_BENCH_TRACKS_DIR, + help=( + f"Directory holding local real reference tracks " + f"(default: {DEFAULT_BENCH_TRACKS_DIR})" + ), + ) + parser.add_argument( + "--html-report", + type=Path, + default=None, + help=( + "Also render an HTML accuracy report. Pass an explicit path or leave " + "blank to use the dated default at .runtime/reports/accuracy_.html. " + "Use 'auto' to opt in with the default path explicitly." + ), + nargs="?", + const="auto", + ) return parser.parse_args() @@ -48,8 +87,29 @@ def main() -> None: manifest_path=args.manifest, report_path=args.report, runs_per_fixture=max(args.runs, 1), + include_real=args.include_real, + real_tracks_dir=args.real_tracks_dir, ) - print(json.dumps({"summary": report["summary"], "reportPath": report["reportPath"]}, indent=2)) + summary_line: dict[str, Any] = { + "summary": report["summary"], + "reportPath": report["reportPath"], + } + if args.include_real and report["summary"]["realTracksSkipped"] > 0: + summary_line["note"] = ( + "Some real tracks were skipped because audio files were not present " + f"in {args.real_tracks_dir}. See report for per-track skipReason." + ) + + if args.html_report is not None: + if isinstance(args.html_report, Path): + html_path = args.html_report + else: + # const="auto" sentinel — use the dated default location alongside the JSON report. + html_path = default_html_report_path(args.report.parent) + html_written = render_html_report(report, html_path) + summary_line["htmlReportPath"] = str(html_written) + + print(json.dumps(summary_line, indent=2)) if not report["summary"]["allPassed"]: raise SystemExit(1) diff --git a/apps/backend/scripts/replay_catalog_validation.py b/apps/backend/scripts/replay_catalog_validation.py new file mode 100644 index 00000000..347dae5e --- /dev/null +++ b/apps/backend/scripts/replay_catalog_validation.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Replay the Live 12 catalog validation against the v3.1 stems-gate +snapshots without spending Gemini API budget. + +For each snapshot in `/tmp/decision_gate_{stems,real}_gemini-*.json`: + 1. Pull the model's Phase 2 result dict from + stages.interpretation.profiles["producer_summary"].result + (the "preferred" attempt for this model). + 2. Re-run `_validate_phase2_semantics()` against the freshly-loaded + catalog. The catalog now has Auto Filter parameterAliases and + Glue Compressor's expanded allowedParameters. + 3. Tally `UNKNOWN_PARAMETER` + `UNKNOWN_DEVICE` counts per model: + - `before`: the count emitted into the snapshot when the gate + originally ran (read from + stages.interpretation.profiles[].diagnostics.validationWarnings). + - `after`: the count produced by the live re-validation. + +Expected outcome: + - Auto Filter "Filter Resonance" + Glue Compressor "Sidechain" hits → 0 + - Compressor "Sustain" hits remain (v3.2 prompt target) + - "Ableton Project Settings" / "Mixer" UNKNOWN_DEVICE hits remain + (v3.2 prompt target) + +Run: + ./venv/bin/python scripts/replay_catalog_validation.py +""" + +from __future__ import annotations + +import json +import sys +from collections import Counter +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from server_phase2 import _validate_phase2_semantics # noqa: E402 + + +# Glob covers both stem and non-stem snapshots from v3.1 (and any earlier +# generators that share the naming convention). +SNAPSHOT_GLOBS = ("/tmp/decision_gate_stems_gemini-*.json", + "/tmp/decision_gate_real_gemini-*.json") + + +def _extract_model_name_from_path(path: Path) -> str: + # /tmp/decision_gate_stems_gemini-2.5-flash.json → gemini-2.5-flash + stem = path.stem # decision_gate_stems_gemini-2.5-flash + for prefix in ("decision_gate_stems_", "decision_gate_real_"): + if stem.startswith(prefix): + return stem[len(prefix):] + return stem + + +def _phase2_result_from_snapshot(snap: dict) -> dict | None: + stages = snap.get("stages") or {} + interp = stages.get("interpretation") or {} + # First try the "preferred" path used by the live UI; fall back to the + # profiles map (canonical when v3-style 1-meas/N-interp pattern was used). + if isinstance(interp.get("result"), dict): + return interp["result"] + profiles = interp.get("profiles") or {} + pref = profiles.get("producer_summary") or {} + if isinstance(pref.get("result"), dict): + return pref["result"] + return None + + +def _existing_warning_counts(snap: dict) -> Counter: + # Mirrors the v3 comparator's diagnostics access path. The "before" + # numbers are whatever the backend originally emitted at gate-run time. + stages = snap.get("stages") or {} + interp = stages.get("interpretation") or {} + counts: Counter = Counter() + + diag = interp.get("diagnostics") or {} + for w in (diag.get("validationWarnings") or []): + if isinstance(w, dict): + counts[w.get("code", "UNKNOWN")] += 1 + + profiles = interp.get("profiles") or {} + pref = profiles.get("producer_summary") or {} + pref_diag = pref.get("diagnostics") or {} + for w in (pref_diag.get("validationWarnings") or []): + if isinstance(w, dict): + counts[w.get("code", "UNKNOWN")] += 1 + + return counts + + +def main() -> int: + snapshot_paths: list[Path] = [] + for pattern in SNAPSHOT_GLOBS: + snapshot_paths.extend(sorted(Path("/").glob(pattern.lstrip("/")))) + + if not snapshot_paths: + print("No snapshots found at", " or ".join(SNAPSHOT_GLOBS), file=sys.stderr) + return 1 + + headline = ( + f"{'snapshot':<60} {'before-UP':>10} {'after-UP':>10} " + f"{'before-UD':>10} {'after-UD':>10}" + ) + print(headline) + print("-" * len(headline)) + + grand_before_up = 0 + grand_after_up = 0 + grand_before_ud = 0 + grand_after_ud = 0 + + for path in snapshot_paths: + try: + snap = json.loads(path.read_text()) + except Exception as exc: + print(f"{path.name}: PARSE ERROR ({exc})") + continue + phase2 = _phase2_result_from_snapshot(snap) + if not phase2: + print(f"{path.name}: NO PHASE 2 RESULT (skipped)") + continue + + before = _existing_warning_counts(snap) + # Live re-validation against the current catalog dict. + after_warnings = _validate_phase2_semantics(phase2) + after = Counter() + for w in after_warnings: + after[w.get("code", "UNKNOWN")] += 1 + + before_up = before.get("UNKNOWN_PARAMETER", 0) + after_up = after.get("UNKNOWN_PARAMETER", 0) + before_ud = before.get("UNKNOWN_DEVICE", 0) + after_ud = after.get("UNKNOWN_DEVICE", 0) + + grand_before_up += before_up + grand_after_up += after_up + grand_before_ud += before_ud + grand_after_ud += after_ud + + print( + f"{path.name:<60} " + f"{before_up:>10} {after_up:>10} {before_ud:>10} {after_ud:>10}" + ) + + print("-" * len(headline)) + print( + f"{'TOTAL':<60} " + f"{grand_before_up:>10} {grand_after_up:>10} " + f"{grand_before_ud:>10} {grand_after_ud:>10}" + ) + + # Show which specific (device, parameter) pairs remain after the + # catalog change — the post-fix offenders are the v3.2 prompt targets. + print("\n--- Remaining UNKNOWN_* after re-validation ---") + remaining: Counter = Counter() + remaining_examples: dict[str, list[str]] = {} + for path in snapshot_paths: + try: + snap = json.loads(path.read_text()) + except Exception: + continue + phase2 = _phase2_result_from_snapshot(snap) + if not phase2: + continue + after_warnings = _validate_phase2_semantics(phase2) + for w in after_warnings: + code = w.get("code") + if code not in ("UNKNOWN_PARAMETER", "UNKNOWN_DEVICE"): + continue + msg = w.get("message", "") + key = msg.split(" in the curated")[0] if "in the curated" in msg else msg[:80] + remaining[(code, key)] += 1 + remaining_examples.setdefault((code, key), []).append(path.name) + + for (code, key), count in remaining.most_common(): + sample_files = ", ".join(sorted(set(remaining_examples[(code, key)]))[:4]) + print(f" {count:>3} × {code} :: {key}") + print(f" seen in: {sample_files}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apps/backend/server_phase1.py b/apps/backend/server_phase1.py index bd0e7356..401aa3f9 100644 --- a/apps/backend/server_phase1.py +++ b/apps/backend/server_phase1.py @@ -99,6 +99,30 @@ def _normalize_spectral_detail(detail: Any) -> dict[str, Any] | None: return out +def _normalize_stem_analysis(stem_analysis: Any) -> dict[str, Any] | None: + """Apply ``_normalize_spectral_detail`` to every per-stem spectralDetail. + + Keeps the field-name contract consistent between the top-level + ``spectralDetail`` and the per-stem nested ``stemAnalysis.{stem}.spectralDetail`` + so the frontend, Gemini, and the citation validator all speak the same + naming convention. Other per-stem keys (spectralBalance, lufsCurve, + stereoDetail, etc.) keep their existing shapes — only the spectralDetail + rename is needed today. + """ + if not isinstance(stem_analysis, dict): + return None + out: dict[str, Any] = {} + for stem_name, stem_entry in stem_analysis.items(): + if not isinstance(stem_entry, dict): + out[stem_name] = stem_entry + continue + stem_copy = dict(stem_entry) + if isinstance(stem_copy.get("spectralDetail"), dict): + stem_copy["spectralDetail"] = _normalize_spectral_detail(stem_copy["spectralDetail"]) + out[stem_name] = stem_copy + return out + + def _build_phase1(payload: dict[str, Any]) -> dict[str, Any]: stereo_detail = payload.get("stereoDetail") if not isinstance(stereo_detail, dict): @@ -142,6 +166,7 @@ def _build_phase1(payload: dict[str, Any]) -> dict[str, Any]: "lufsRange": _coerce_nullable_number(payload.get("lufsRange")), "lufsMomentaryMax": _coerce_nullable_number(payload.get("lufsMomentaryMax")), "lufsShortTermMax": _coerce_nullable_number(payload.get("lufsShortTermMax")), + "lufsCurve": payload.get("lufsCurve"), "truePeak": _coerce_number(payload.get("truePeak")), "plr": plr, "crestFactor": _coerce_nullable_number(payload.get("crestFactor")), @@ -161,6 +186,15 @@ def _build_phase1(payload: dict[str, Any]) -> dict[str, Any]: "highs": _coerce_number(spectral_balance.get("highs")), "brilliance": _coerce_number(spectral_balance.get("brilliance")), }, + "spectralBalanceTimeSeries": payload.get("spectralBalanceTimeSeries"), + # Stem subtree is forwarded as-is, but per-stem spectralDetail keys are + # renamed to match the top-level Mean-suffix contract — keeps the + # frontend and the Gemini citation contract speaking one schema. + "stemAnalysis": _normalize_stem_analysis(payload.get("stemAnalysis")), + "transientDensityDetail": payload.get("transientDensityDetail"), + "saturationDetail": payload.get("saturationDetail"), + "snareDetail": payload.get("snareDetail"), + "hihatDetail": payload.get("hihatDetail"), "spectralDetail": _normalize_spectral_detail(payload.get("spectralDetail")), "rhythmDetail": payload.get("rhythmDetail"), "melodyDetail": payload.get("melodyDetail"), diff --git a/apps/backend/server_phase2.py b/apps/backend/server_phase2.py index a4e6800f..fd729d3d 100644 --- a/apps/backend/server_phase2.py +++ b/apps/backend/server_phase2.py @@ -7,7 +7,12 @@ from typing import Any, Callable from analysis_runtime import AnalysisRuntime, UnsupportedPitchNoteModeError -from server_phase1 import _coerce_nullable_number, _coerce_nullable_string, _safe_snippet +from server_phase1 import ( + _coerce_nullable_number, + _coerce_nullable_string, + _normalize_spectral_detail, + _safe_snippet, +) GEMINI_RETRYABLE_SUBSTRINGS = [ @@ -168,6 +173,31 @@ def _load_live12_device_catalog(path: Path | None = None) -> dict[str, Any]: f"Live 12 device catalog entry '{name}' must have a non-empty allowedParameters array." ) + # Optional parameterAliases: {alias_string: canonical_string} where every + # canonical_string must already be in allowedParameters. Catches typos in + # the alias-target at startup rather than at runtime. + aliases = device.get("parameterAliases") + if aliases is not None: + if not isinstance(aliases, dict): + raise RuntimeError( + f"Live 12 device catalog entry '{name}' parameterAliases must be an object." + ) + allowed_set = set(allowed_parameters) + for alias, canonical in aliases.items(): + if not isinstance(alias, str) or not alias.strip(): + raise RuntimeError( + f"Live 12 device catalog entry '{name}' parameterAliases has a non-string key." + ) + if not isinstance(canonical, str) or not canonical.strip(): + raise RuntimeError( + f"Live 12 device catalog entry '{name}' parameterAliases['{alias}'] must be a non-empty string." + ) + if canonical not in allowed_set: + raise RuntimeError( + f"Live 12 device catalog entry '{name}' parameterAliases['{alias}'] -> " + f"'{canonical}' is not in allowedParameters." + ) + return catalog @@ -461,6 +491,10 @@ def _load_live12_device_catalog(path: Path | None = None) -> dict[str, Any]: "parameter": {"type": "STRING"}, "value": {"type": "STRING"}, "reason": {"type": "STRING"}, + "phase1Fields": { + "type": "ARRAY", + "items": {"type": "STRING"}, + }, }, "required": [ "order", @@ -471,6 +505,7 @@ def _load_live12_device_catalog(path: Path | None = None) -> dict[str, Any]: "parameter", "value", "reason", + "phase1Fields", ], }, }, @@ -493,6 +528,10 @@ def _load_live12_device_catalog(path: Path | None = None) -> dict[str, Any]: "value": {"type": "STRING"}, "instruction": {"type": "STRING"}, "measurementJustification": {"type": "STRING"}, + "phase1Fields": { + "type": "ARRAY", + "items": {"type": "STRING"}, + }, }, "required": [ "step", @@ -502,6 +541,7 @@ def _load_live12_device_catalog(path: Path | None = None) -> dict[str, Any]: "value", "instruction", "measurementJustification", + "phase1Fields", ], }, }, @@ -534,6 +574,10 @@ def _load_live12_device_catalog(path: Path | None = None) -> dict[str, Any]: "value": {"type": "STRING"}, "reason": {"type": "STRING"}, "advancedTip": {"type": "STRING"}, + "phase1Fields": { + "type": "ARRAY", + "items": {"type": "STRING"}, + }, }, "required": [ "device", @@ -545,6 +589,7 @@ def _load_live12_device_catalog(path: Path | None = None) -> dict[str, Any]: "value", "reason", "advancedTip", + "phase1Fields", ], }, }, @@ -693,6 +738,46 @@ def _is_retryable_gemini_error(error_message: str) -> bool: +def _normalize_measurement_result_for_gemini(payload: dict[str, Any]) -> dict[str, Any]: + """Rename the spectral fields Gemini sees so they match what the frontend + parses and what the Phase 2 citation contract names. + + The analyzer emits ``spectralDetail.spectralCentroid`` (without the + ``Mean`` suffix) but ``server_phase1._build_phase1`` renames those keys to + ``spectralCentroidMean`` etc. when shaping the snapshot for the frontend. + Gemini was previously seeing the raw names, then citing them under + ``phase1Fields`` — and the validator (which checks against the + frontend-shaped Phase 1 payload) flagged those citations as invented + paths. + + Applying the same renames here unifies the field-name contract: the JSON + Gemini reads, the JSON the frontend parses, and the paths the validator + accepts all line up. The normalization is applied to both the top-level + ``spectralDetail`` AND to every per-stem ``stemAnalysis.{stem}.spectralDetail`` + so Gemini sees one consistent name everywhere. + """ + if not isinstance(payload, dict): + return payload + normalized = dict(payload) + spectral_detail = normalized.get("spectralDetail") + if isinstance(spectral_detail, dict): + normalized["spectralDetail"] = _normalize_spectral_detail(spectral_detail) + stem_analysis = normalized.get("stemAnalysis") + if isinstance(stem_analysis, dict): + normalized_stems = {} + for stem_name, stem_entry in stem_analysis.items(): + if not isinstance(stem_entry, dict): + normalized_stems[stem_name] = stem_entry + continue + stem_copy = dict(stem_entry) + stem_spectral_detail = stem_copy.get("spectralDetail") + if isinstance(stem_spectral_detail, dict): + stem_copy["spectralDetail"] = _normalize_spectral_detail(stem_spectral_detail) + normalized_stems[stem_name] = stem_copy + normalized["stemAnalysis"] = normalized_stems + return normalized + + def _build_phase2_prompt( *, measurement_result: dict[str, Any], @@ -700,10 +785,11 @@ def _build_phase2_prompt( grounding_metadata: dict[str, Any], descriptor_hooks: dict[str, Any] | None = None, ) -> str: + measurement_for_gemini = _normalize_measurement_result_for_gemini(measurement_result) sections = [ PRODUCER_SUMMARY_PROMPT_TEMPLATE.rstrip(), "\n\nAUTHORITATIVE_MEASUREMENT_RESULT_JSON:\n", - json.dumps(measurement_result, indent=2), + json.dumps(measurement_for_gemini, indent=2), "\n\nOPTIONAL_PITCH_NOTE_TRANSLATION_RESULT_JSON:\n", json.dumps(pitch_note_result, indent=2), "\n\nLIVE_12_DEVICE_CATALOG_JSON:\n", @@ -2217,8 +2303,16 @@ def _validate_phase2_catalog_entry( ) ) + # Resolve aliases before membership check. parameterAliases is a + # per-device map of {wrong-but-defensible-name: canonical-name}. The + # alias only affects validation — the recommendation's emitted + # `parameter` value is NOT mutated by this layer. Closes the long-form + # naming bleed (instrument-side "Filter Resonance" misapplied to the + # Auto Filter audio effect). + alias_map = catalog_entry.get("parameterAliases") or {} + canonical_parameter = alias_map.get(parameter, parameter) allowed_parameters = set(catalog_entry.get("allowedParameters", [])) - if parameter not in allowed_parameters: + if canonical_parameter not in allowed_parameters: warnings.append( _build_phase2_validation_warning( code="UNKNOWN_PARAMETER", diff --git a/apps/backend/tests/fixtures/bench_tracks/.gitignore b/apps/backend/tests/fixtures/bench_tracks/.gitignore new file mode 100644 index 00000000..bd9667a2 --- /dev/null +++ b/apps/backend/tests/fixtures/bench_tracks/.gitignore @@ -0,0 +1,5 @@ +# Real-track accuracy bench — copyrighted reference material the user owns locally. +# Track audio files are NEVER committed. Only this .gitignore and README.md are tracked. +* +!.gitignore +!README.md diff --git a/apps/backend/tests/fixtures/bench_tracks/README.md b/apps/backend/tests/fixtures/bench_tracks/README.md new file mode 100644 index 00000000..38b0b8b1 --- /dev/null +++ b/apps/backend/tests/fixtures/bench_tracks/README.md @@ -0,0 +1,70 @@ +# Real-Track Accuracy Bench + +This directory holds copyrighted reference tracks used to audit ASA's Phase 1 measurement accuracy. The audio files are **never** committed — they live in your local copy only. + +## What goes here + +10–15 reference tracks (full MP3/WAV/FLAC files you own) covering the audit categories from the depth plan: + +1. 3 four-on-the-floor electronic at varied tempos (e.g., 108 / 124 / 145 BPM) — the easy case +2. 2 half-time / double-time ambiguous tracks — tests the `bpmDoubletime` correction +3. 2 modal / non-tonal tracks — tests honest key-confidence failure +4. 2 mix-test tracks — one extremely wide, one mono — for stereo width +5. 2 sidechained tracks — one obvious, one subtle — for `sidechainDetail` +6. 1 polyrhythmic / odd-meter (e.g., 7/8) — exposes the 4/4 assumption +7. 1 vocally-led — for vocal detector and monophonic vocal key detection +8. 2–3 spare slots for edges discovered during audit + +Total disk: roughly 150 MB. + +## Registering a track + +Add an entry to `../phase1_eval_manifest.json` under the `realTracks` array. The minimal shape is: + +```json +{ + "id": "house_125_example", + "audioPath": "house_125_example.mp3", + "category": "four_on_floor", + "description": "Standard four-on-the-floor at 125 BPM", + "thresholds": { + "bpm": { "target": 125.0, "tolerance": 2.0 }, + "key": { "equals": "F Minor" }, + "lufsIntegrated": { "target": -9.5, "tolerance": 0.5 } + } +} +``` + +The `audioPath` is relative to this `bench_tracks/` directory. + +### Ground-truth methodology + +Per the audit plan: + +- **BPM** — three-way agreement (Logic Pro BPM + Mixed In Key + tap-along). Disagreement = mark as "BPM ambiguous" rather than fail. +- **Key** — Mixed In Key + Logic detection + playing along. Modal/atonal → ground truth is "modal" with low `keyConfidence` target. +- **LUFS integrated** — Youlean (free EBU R128) or MeldaProduction MLoudnessAnalyzer. Tolerance ±0.5 LU. +- **Stereo width/correlation** — MeldaProduction MStereoAnalyzer or Voxengo Correlometer. +- **Spectral balance per band** — Voxengo SPAN integrated mode, 1/12-octave smoothing, same 7-band cutpoints as ASA. Tolerance ±1.5 dB. +- **Detectors** — listener writes one-line "is there acid? yes/no/maybe". Confidence below 0.4 on the wrong answer is **not** a failure — it's the system being honest. + +## Running the bench + +```bash +# Default — synthetic-only gate, always runnable, runs in CI +./venv/bin/python scripts/evaluate_phase1.py + +# Local opt-in — runs real tracks when files exist, skips with notice when absent +./venv/bin/python scripts/evaluate_phase1.py --include-real + +# Specify a custom directory for real tracks +./venv/bin/python scripts/evaluate_phase1.py --include-real --real-tracks-dir /path/to/tracks +``` + +When `--include-real` is passed but no audio files match the manifest entries, each missing track is reported as skipped (not failed) with a clear notice. The overall run still passes if all synthetic-fixture checks and any present real-track checks pass. + +## Adding the real-track gate to your workflow + +- Run `--include-real` locally during development of any change that affects high-stakes measurements (BPM, key, LUFS, spectralBalance, detectors). +- Run the milestone full-bench audit after Phase 1.A completes, after Phase 1.B completes, and before/after each Phase 1.C analyzer ships. +- CI continues to run the default synthetic gate only — real tracks never go through GitHub Actions. diff --git a/apps/backend/tests/fixtures/phase1_eval_manifest.json b/apps/backend/tests/fixtures/phase1_eval_manifest.json index ee85e895..41c868f0 100644 --- a/apps/backend/tests/fixtures/phase1_eval_manifest.json +++ b/apps/backend/tests/fixtures/phase1_eval_manifest.json @@ -43,5 +43,6 @@ { "field": "vocalDetail.hasVocals", "mode": "exact" }, { "field": "supersawDetail.isSupersaw", "mode": "exact" }, { "field": "kickDetail.kickCount", "maxDelta": 0.0 } - ] + ], + "realTracks": [] } diff --git a/apps/backend/tests/test_analysis_runtime.py b/apps/backend/tests/test_analysis_runtime.py index e7b85af2..374108d1 100644 --- a/apps/backend/tests/test_analysis_runtime.py +++ b/apps/backend/tests/test_analysis_runtime.py @@ -21,10 +21,12 @@ def _runtime(self): def test_resolve_measurement_flags_supports_known_pitch_note_modes(self) -> None: runtime = self._runtime() - # Pitch/note translation work is now handled by the dedicated pitch_note_translation stage, - # not inline during measurement. Both modes return (False, False). + # Phase 1.B change: ``stem_notes`` mode now also runs separation at + # measurement time so the stem-first overlay (stemAnalysis) populates + # without waiting on the pitch-note worker stage. Transcription still + # runs in its own stage, so ``run_transcribe`` stays False. self.assertEqual(runtime.resolve_measurement_flags("off"), (False, False)) - self.assertEqual(runtime.resolve_measurement_flags("stem_notes"), (False, False)) + self.assertEqual(runtime.resolve_measurement_flags("stem_notes"), (True, False)) def test_resolve_measurement_flags_rejects_unknown_pitch_note_mode(self) -> None: runtime = self._runtime() diff --git a/apps/backend/tests/test_analyze.py b/apps/backend/tests/test_analyze.py index e33d96b8..f930d3d9 100644 --- a/apps/backend/tests/test_analyze.py +++ b/apps/backend/tests/test_analyze.py @@ -28,9 +28,12 @@ "bpmDoubletime", "bpmSource", "bpmRawOriginal", "key", "keyConfidence", "timeSignature", "timeSignatureSource", "timeSignatureConfidence", "durationSeconds", "sampleRate", - "lufsIntegrated", "lufsRange", "truePeak", "plr", "crestFactor", - "dynamicSpread", "dynamicCharacter", "textureCharacter", "stereoDetail", "monoCompatible", "spectralBalance", - "spectralDetail", "rhythmDetail", "melodyDetail", "transcriptionDetail", + "lufsIntegrated", "lufsRange", "lufsCurve", "truePeak", "plr", "crestFactor", + "dynamicSpread", "dynamicCharacter", "textureCharacter", "stereoDetail", "monoCompatible", + "spectralBalance", "spectralBalanceTimeSeries", + "spectralDetail", "stemAnalysis", "transientDensityDetail", "saturationDetail", + "snareDetail", "hihatDetail", + "rhythmDetail", "melodyDetail", "transcriptionDetail", "grooveDetail", "beatsLoudness", "rhythmTimeline", "sidechainDetail", "acidDetail", "reverbDetail", "vocalDetail", "supersawDetail", "bassDetail", "kickDetail", "genreDetail", "effectsDetail", "synthesisCharacter", @@ -1097,13 +1100,16 @@ def test_returns_none_for_empty_signal(self): self.assertEqual(result, {"reverbDetail": None}) def test_output_schema_fields(self): - """All expected fields must be present.""" + """All expected fields must be present (Phase 1.D #5 added perBandRt60 + preDelayMs).""" sr = 44100 mono = self._make_decaying_signal(sr, n_transients=8, rt60_target=0.4, duration=6.0) result = self.analyze.analyze_reverb_detail(mono, sr, bpm=130.0) detail = result.get("reverbDetail") self.assertIsNotNone(detail) - self.assertEqual(set(detail.keys()), {"rt60", "isWet", "tailEnergyRatio", "measured"}) + self.assertEqual( + set(detail.keys()), + {"rt60", "isWet", "tailEnergyRatio", "measured", "perBandRt60", "preDelayMs"}, + ) def test_rt60_bounded(self): """RT60 must be >= 0 and <= 3.0 (capped) when measured.""" @@ -1189,7 +1195,14 @@ def test_output_schema_fields(self): result = self.analyze.analyze_vocal_detail(mono, sr, bpm=120.0) detail = result.get("vocalDetail") self.assertIsNotNone(detail) - expected_keys = {"hasVocals", "confidence", "vocalEnergyRatio", "formantStrength", "mfccLikelihood"} + # 2026-05-12: stemEnergyRatio + stemOtherCorrelation added for the + # two-signal Demucs-ghost-stem scaling (energy + envelope correlation + # against the "other" stem). + expected_keys = { + "hasVocals", "confidence", "vocalEnergyRatio", + "formantStrength", "mfccLikelihood", + "stemEnergyRatio", "stemOtherCorrelation", + } self.assertEqual(set(detail.keys()), expected_keys) def test_confidence_bounded_zero_to_one(self): @@ -1224,7 +1237,11 @@ def test_prefers_vocal_stem_when_available(self): result = self.analyze.analyze_vocal_detail(mono, sr, bpm=120.0, stems=stems) self.assertIsNotNone(result["vocalDetail"]) - mock_load.assert_called_once_with(stems, "vocals", sr) + # As of 2026-05-12 the analyzer also loads the "other" stem to compute + # the Demucs-ghost-stem cross-correlation. The invariant is that the + # vocals stem WAS loaded with the right arguments, not that it was the + # only stem touched. + mock_load.assert_any_call(stems, "vocals", sr) def test_vocal_detail_falls_back_to_mix_when_stem_unavailable(self): """Missing vocals stem should keep the existing full-mix behavior.""" @@ -1416,6 +1433,44 @@ def test_prefers_bass_stem_when_available(self): self.assertGreater(result["bassDetail"]["fundamentalHz"], 50) + def test_average_decay_ms_is_positive_for_bass_pulses(self): + """Regression for the envelope-decay fix: a series of decaying bass pulses must + report a sane positive averageDecayMs, never the sub-millisecond values the old + raw-waveform loop produced (a 50 Hz sine crosses zero every ~10 ms, which used + to trigger the -6 dB threshold almost immediately on every note). + """ + sr = 44_100 + duration = 4.0 + n_samples = int(sr * duration) + mono = np.zeros(n_samples, dtype=np.float32) + # Eight 60 Hz pulses, each ~400 ms with a slow exponential decay envelope so + # the expected envelope-to-half time sits comfortably above the regression floor. + beat_samples = int(sr * 0.5) # 120 BPM + pulse_len = int(sr * 0.4) + decay_const = pulse_len / 1.5 # ~177 ms time constant → ~123 ms to -6 dB + for i in range(8): + onset = i * beat_samples + if onset + pulse_len >= n_samples: + break + t = np.arange(pulse_len, dtype=np.float32) + env = np.exp(-t / decay_const) + pulse = (0.7 * env * np.sin(2 * np.pi * 60.0 * t / sr)).astype(np.float32) + mono[onset:onset + pulse_len] = pulse + + result = self.analyze.analyze_bass_detail(mono, sr, bpm=120.0) + detail = result.get("bassDetail") + self.assertIsNotNone(detail) + self.assertIsNotNone(detail["averageDecayMs"]) + # Pre-fix: averageDecayMs was effectively 0 (sub-ms) — the raw oscillating + # waveform's RMS over a small window crossed -6 dB on every zero crossing. + # Post-fix: the envelope-based search anchors at the per-pulse peak and waits + # for the smoothed envelope to fall -6 dB, so the reported value must be at + # least a few tens of ms for any realistic bass pulse. + self.assertGreaterEqual( + detail["averageDecayMs"], 30, + f"averageDecayMs={detail['averageDecayMs']} regressed near the pre-fix sub-ms range", + ) + class KickDetailTests(unittest.TestCase): """Tests for analyze_kick_detail — kick drum distortion and THD.""" @@ -1510,6 +1565,483 @@ def test_prefers_drums_stem_when_available(self): self.assertGreater(result["kickDetail"]["kickCount"], 1) +def _make_drum_band_signal( + sr: int, + band_lo_hz: float, + band_hi_hz: float, + n_hits: int, + duration: float, + decay_samples: int = 1500, +) -> np.ndarray: + """Synth a signal of band-limited transients spaced uniformly across `duration`. + + Each hit is a noise burst windowed by exp(-t/decay) and weakly bandpass-shaped + by a centered sine, so band onset detectors light up reliably. + """ + n_samples = int(sr * duration) + mono = np.zeros(n_samples, dtype=np.float32) + if n_hits <= 0: + return mono + center_hz = 0.5 * (band_lo_hz + band_hi_hz) + step = n_samples // n_hits + for i in range(n_hits): + onset = i * step + if onset >= n_samples: + break + burst_len = min(decay_samples, n_samples - onset) + t = np.arange(burst_len, dtype=np.float32) + env = np.exp(-t / (decay_samples * 0.35)) + noise = np.random.default_rng(seed=42 + i).standard_normal(burst_len).astype(np.float32) * 0.3 + carrier = np.sin(2 * np.pi * center_hz * t / sr).astype(np.float32) + mono[onset:onset + burst_len] += (0.8 * env * (carrier + 0.3 * noise)).astype(np.float32) + return mono + + +class BandDrumDetailTests(unittest.TestCase): + """Tests for _analyze_band_drum_detail — shared snare/hi-hat band analyzer.""" + + @classmethod + def setUpClass(cls): + analyze_path = Path(__file__).resolve().parents[1] / "analyze.py" + spec = importlib.util.spec_from_file_location("analyze_band_drum_test", analyze_path) + if spec is None or spec.loader is None: + raise AssertionError("Could not load analyze.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + cls.analyze = module + + def test_returns_none_for_empty_signal(self): + mono = np.array([], dtype=np.float32) + result = self.analyze._analyze_band_drum_detail( + mono, 44100, band_lo_hz=120.0, band_hi_hz=2000.0, bpm=120.0, stems=None, + ) + self.assertIsNone(result) + + def test_returns_none_for_short_signal(self): + mono = np.zeros(2048, dtype=np.float32) + result = self.analyze._analyze_band_drum_detail( + mono, 44100, band_lo_hz=120.0, band_hi_hz=2000.0, bpm=120.0, stems=None, + ) + self.assertIsNone(result) + + def test_returns_none_for_silence(self): + """Silent input has env_max == 0 → returns None.""" + mono = np.zeros(int(44100 * 3.0), dtype=np.float32) + result = self.analyze._analyze_band_drum_detail( + mono, 44100, band_lo_hz=120.0, band_hi_hz=2000.0, bpm=120.0, stems=None, + ) + self.assertIsNone(result) + + def test_detects_band_limited_hits(self): + """A signal with 8 mid-band transients should yield ≥ 2 hits and a sane schema.""" + sr = 44100 + mono = _make_drum_band_signal(sr, 120.0, 2000.0, n_hits=8, duration=4.0) + result = self.analyze._analyze_band_drum_detail( + mono, sr, band_lo_hz=120.0, band_hi_hz=2000.0, bpm=120.0, stems=None, + min_event_dist_subdivisions=0.5, body_split_ratio=0.35, + ) + self.assertIsNotNone(result) + expected_keys = { + "hitCount", "hitsPerSecond", "meanAttackSharpness", + "meanBodyEnergyRatio", "meanSnapEnergyRatio", "meanCentroidHz", + "meanDecayFrames", "meanDecaySeconds", "bandHz", + } + self.assertEqual(set(result.keys()), expected_keys) + self.assertGreaterEqual(result["hitCount"], 2) + self.assertGreater(result["hitsPerSecond"], 0.0) + self.assertGreater(result["meanAttackSharpness"], 0.0) + self.assertGreaterEqual(result["meanDecayFrames"], 0.0) + self.assertEqual(result["bandHz"], [120.0, 2000.0]) + + def test_uses_drums_stem_when_available(self): + """When a `drums` stem is provided via `_load_stem_mono`, it overrides the full-mix input.""" + sr = 44100 + mono = np.zeros(int(sr * 3.0), dtype=np.float32) # silent full mix + stem_signal = _make_drum_band_signal(sr, 120.0, 2000.0, n_hits=6, duration=3.0) + with mock.patch.object(self.analyze, "_load_stem_mono", return_value=stem_signal): + result = self.analyze._analyze_band_drum_detail( + mono, sr, band_lo_hz=120.0, band_hi_hz=2000.0, bpm=120.0, + stems={"drums": "/tmp/drums.wav"}, + min_event_dist_subdivisions=0.5, body_split_ratio=0.35, + ) + self.assertIsNotNone(result) + self.assertGreaterEqual(result["hitCount"], 2) + + +class SnareDetailTests(unittest.TestCase): + """Tests for analyze_snare_detail — 120-2000 Hz drum-band character.""" + + @classmethod + def setUpClass(cls): + analyze_path = Path(__file__).resolve().parents[1] / "analyze.py" + spec = importlib.util.spec_from_file_location("analyze_snare_test", analyze_path) + if spec is None or spec.loader is None: + raise AssertionError("Could not load analyze.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + cls.analyze = module + + def test_returns_none_for_empty_signal(self): + mono = np.array([], dtype=np.float32) + result = self.analyze.analyze_snare_detail(mono, 44100, bpm=120.0) + self.assertEqual(result, {"snareDetail": None}) + + def test_returns_none_for_short_signal(self): + mono = np.zeros(2048, dtype=np.float32) + result = self.analyze.analyze_snare_detail(mono, 44100, bpm=120.0) + self.assertEqual(result, {"snareDetail": None}) + + def test_output_schema_fields(self): + sr = 44100 + mono = _make_drum_band_signal(sr, 120.0, 2000.0, n_hits=8, duration=4.0) + result = self.analyze.analyze_snare_detail(mono, sr, bpm=120.0) + detail = result.get("snareDetail") + self.assertIsNotNone(detail) + expected_keys = { + "hitCount", "hitsPerSecond", "meanAttackSharpness", + "meanBodyEnergyRatio", "meanSnapEnergyRatio", "meanCentroidHz", + "meanDecayFrames", "meanDecaySeconds", "bandHz", + } + self.assertEqual(set(detail.keys()), expected_keys) + self.assertEqual(detail["bandHz"], [120.0, 2000.0]) + + def test_fallback_on_no_bpm(self): + sr = 44100 + mono = _make_drum_band_signal(sr, 120.0, 2000.0, n_hits=6, duration=3.0) + result = self.analyze.analyze_snare_detail(mono, sr, bpm=None) + self.assertIn("snareDetail", result) + + +class HihatDetailTests(unittest.TestCase): + """Tests for analyze_hihat_detail — 2000-12000 Hz drum-band character.""" + + @classmethod + def setUpClass(cls): + analyze_path = Path(__file__).resolve().parents[1] / "analyze.py" + spec = importlib.util.spec_from_file_location("analyze_hihat_test", analyze_path) + if spec is None or spec.loader is None: + raise AssertionError("Could not load analyze.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + cls.analyze = module + + def test_returns_none_for_empty_signal(self): + mono = np.array([], dtype=np.float32) + result = self.analyze.analyze_hihat_detail(mono, 44100, bpm=120.0) + self.assertEqual(result, {"hihatDetail": None}) + + def test_returns_none_for_short_signal(self): + mono = np.zeros(2048, dtype=np.float32) + result = self.analyze.analyze_hihat_detail(mono, 44100, bpm=120.0) + self.assertEqual(result, {"hihatDetail": None}) + + def test_output_schema_fields(self): + sr = 44100 + mono = _make_drum_band_signal(sr, 2000.0, 12000.0, n_hits=16, duration=4.0) + result = self.analyze.analyze_hihat_detail(mono, sr, bpm=120.0) + detail = result.get("hihatDetail") + self.assertIsNotNone(detail) + expected_keys = { + "hitCount", "hitsPerSecond", "meanAttackSharpness", + "meanBodyEnergyRatio", "meanSnapEnergyRatio", "meanCentroidHz", + "meanDecayFrames", "meanDecaySeconds", "bandHz", + } + self.assertEqual(set(detail.keys()), expected_keys) + self.assertEqual(detail["bandHz"], [2000.0, 12000.0]) + + def test_fallback_on_no_bpm(self): + sr = 44100 + mono = _make_drum_band_signal(sr, 2000.0, 12000.0, n_hits=12, duration=3.0) + result = self.analyze.analyze_hihat_detail(mono, sr, bpm=None) + self.assertIn("hihatDetail", result) + + +class TransientDensityDetailTests(unittest.TestCase): + """Tests for analyze_per_band_transient_density — per-band onset rates.""" + + @classmethod + def setUpClass(cls): + analyze_path = Path(__file__).resolve().parents[1] / "analyze.py" + spec = importlib.util.spec_from_file_location("analyze_transient_density_test", analyze_path) + if spec is None or spec.loader is None: + raise AssertionError("Could not load analyze.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + cls.analyze = module + + def test_returns_none_for_empty_signal(self): + mono = np.array([], dtype=np.float32) + result = self.analyze.analyze_per_band_transient_density(mono, 44100) + self.assertEqual(result, {"transientDensityDetail": None}) + + def test_returns_none_for_zero_sample_rate(self): + mono = np.zeros(44100, dtype=np.float32) + result = self.analyze.analyze_per_band_transient_density(mono, 0) + self.assertEqual(result, {"transientDensityDetail": None}) + + def test_output_has_all_seven_bands(self): + """Each spectralBalance band must appear in the output with the documented field set.""" + sr = 44100 + # Mid-band transients should drive at least one band's onset rate above zero. + mono = _make_drum_band_signal(sr, 500.0, 2000.0, n_hits=8, duration=4.0) + result = self.analyze.analyze_per_band_transient_density(mono, sr) + detail = result.get("transientDensityDetail") + self.assertIsNotNone(detail) + self.assertEqual(set(detail.keys()), EXPECTED_SPECTRAL_BANDS) + for band_name, stats in detail.items(): + self.assertEqual( + set(stats.keys()), + {"onsetRatePerSecond", "meanOnsetStrength", "peakOnsetStrength", "eventCount"}, + f"band {band_name} missing fields", + ) + self.assertGreaterEqual(stats["onsetRatePerSecond"], 0.0) + self.assertGreaterEqual(stats["meanOnsetStrength"], 0.0) + self.assertGreaterEqual(stats["peakOnsetStrength"], 0.0) + self.assertGreaterEqual(stats["eventCount"], 0) + + def test_silence_yields_zero_rates(self): + """Pure silence must not invent onsets — every band's eventCount must be 0.""" + sr = 44100 + mono = np.zeros(int(sr * 3.0), dtype=np.float32) + result = self.analyze.analyze_per_band_transient_density(mono, sr) + detail = result.get("transientDensityDetail") + self.assertIsNotNone(detail) + for band_name, stats in detail.items(): + self.assertEqual(stats["eventCount"], 0, f"band {band_name} hallucinated onsets in silence") + self.assertEqual(stats["onsetRatePerSecond"], 0.0) + + def test_mid_band_transients_increase_mids_rate(self): + """Transients in the 500-2000 Hz band should produce a non-zero `mids` onset rate.""" + sr = 44100 + mono = _make_drum_band_signal(sr, 500.0, 2000.0, n_hits=10, duration=4.0) + result = self.analyze.analyze_per_band_transient_density(mono, sr) + detail = result["transientDensityDetail"] + # Any of mids / lowMids / upperMids should detect something. The cluster is + # asserted (rather than `mids` exactly) because the synthetic carrier sweeps + # both sides of the band edge after windowing. + nearby_event_total = ( + detail["lowMids"]["eventCount"] + + detail["mids"]["eventCount"] + + detail["upperMids"]["eventCount"] + ) + self.assertGreater(nearby_event_total, 0) + + +class SaturationDetailTests(unittest.TestCase): + """Tests for analyze_saturation_detail — clip / compression telltales.""" + + @classmethod + def setUpClass(cls): + analyze_path = Path(__file__).resolve().parents[1] / "analyze.py" + spec = importlib.util.spec_from_file_location("analyze_saturation_test", analyze_path) + if spec is None or spec.loader is None: + raise AssertionError("Could not load analyze.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + cls.analyze = module + + def test_returns_none_for_empty_mono(self): + mono = np.array([], dtype=np.float32) + result = self.analyze.analyze_saturation_detail(mono, None, 44100) + self.assertEqual(result, {"saturationDetail": None}) + + def test_output_schema_fields(self): + sr = 44100 + t = np.linspace(0, 2.0, int(sr * 2.0), endpoint=False, dtype=np.float32) + mono = 0.3 * np.sin(2 * np.pi * 220 * t).astype(np.float32) + result = self.analyze.analyze_saturation_detail(mono, None, sr) + detail = result.get("saturationDetail") + self.assertIsNotNone(detail) + expected_keys = { + "clippedSampleCount", "clippedSamplePercent", + "nearClippedSampleCount", "nearClippedSamplePercent", + "peakRatio95to50", "rmsToPeakRatioDb", "saturationLikely", + } + self.assertEqual(set(detail.keys()), expected_keys) + + def test_clean_signal_has_no_clipped_samples(self): + """A 0.3 amplitude sine produces zero clipped/near-clipped samples.""" + sr = 44100 + t = np.linspace(0, 2.0, int(sr * 2.0), endpoint=False, dtype=np.float32) + mono = 0.3 * np.sin(2 * np.pi * 220 * t).astype(np.float32) + result = self.analyze.analyze_saturation_detail(mono, None, sr) + detail = result["saturationDetail"] + self.assertEqual(detail["clippedSampleCount"], 0) + self.assertEqual(detail["clippedSamplePercent"], 0.0) + self.assertEqual(detail["nearClippedSampleCount"], 0) + + def test_dynamic_signal_is_not_flagged_as_saturated(self): + """A transient-rich signal with natural crest factor must not trip saturationLikely. + + A pure sine has rms_to_peak ≈ 3 dB and p95/p50 ≈ 1.4, which the heuristic + misreads as "compressed" — so we test against a realistic decaying-pulse signal + whose crest factor is well above the 8 dB heuristic threshold. + """ + sr = 44100 + n = int(sr * 2.0) + mono = np.zeros(n, dtype=np.float32) + pulse_len = int(sr * 0.05) + beat_samples = int(sr * 0.25) + rng = np.random.default_rng(seed=7) + for i in range(8): + onset = i * beat_samples + if onset + pulse_len >= n: + break + t = np.arange(pulse_len, dtype=np.float32) + env = np.exp(-t / (pulse_len * 0.2)) + noise = rng.standard_normal(pulse_len).astype(np.float32) * 0.5 + mono[onset:onset + pulse_len] = (0.7 * env * (np.sin(2 * np.pi * 200 * t / sr) + 0.3 * noise)).astype(np.float32) + result = self.analyze.analyze_saturation_detail(mono, None, sr) + detail = result["saturationDetail"] + self.assertEqual(detail["clippedSampleCount"], 0) + self.assertFalse(detail["saturationLikely"]) + + def test_clipped_signal_is_flagged(self): + """Hard-clipped signal must produce a non-zero clippedSampleCount and saturationLikely=True.""" + sr = 44100 + t = np.linspace(0, 2.0, int(sr * 2.0), endpoint=False, dtype=np.float32) + # 2.0-amplitude sine clipped to [-1, 1]: ~50% of samples sit at ±1.0. + raw = 2.0 * np.sin(2 * np.pi * 220 * t).astype(np.float32) + mono = np.clip(raw, -1.0, 1.0).astype(np.float32) + stereo = np.stack([mono, mono], axis=1) + result = self.analyze.analyze_saturation_detail(mono, stereo, sr) + detail = result["saturationDetail"] + self.assertGreater(detail["clippedSampleCount"], 100) + self.assertGreater(detail["clippedSamplePercent"], 0.0) + self.assertGreater(detail["nearClippedSamplePercent"], 0.5) + self.assertTrue(detail["saturationLikely"]) + + def test_peak_ratio_lower_for_compressed_signal(self): + """A nearly-flat (compressed) waveform has p95/p50 close to 1; a sine pattern is higher.""" + sr = 44100 + t = np.linspace(0, 2.0, int(sr * 2.0), endpoint=False, dtype=np.float32) + sine = (0.5 * np.sin(2 * np.pi * 220 * t)).astype(np.float32) + # Flat tone with low dynamic range + compressed = (0.4 * np.sign(np.sin(2 * np.pi * 220 * t))).astype(np.float32) + sine_detail = self.analyze.analyze_saturation_detail(sine, None, sr)["saturationDetail"] + compressed_detail = self.analyze.analyze_saturation_detail(compressed, None, sr)["saturationDetail"] + self.assertIsNotNone(sine_detail["peakRatio95to50"]) + self.assertIsNotNone(compressed_detail["peakRatio95to50"]) + self.assertGreater(sine_detail["peakRatio95to50"], compressed_detail["peakRatio95to50"]) + + +class RunPerStemAnalysesTests(unittest.TestCase): + """Tests for _run_per_stem_analyses — Phase 1.B stem-overlay orchestrator.""" + + @classmethod + def setUpClass(cls): + analyze_path = Path(__file__).resolve().parents[1] / "analyze.py" + spec = importlib.util.spec_from_file_location("analyze_per_stem_test", analyze_path) + if spec is None or spec.loader is None: + raise AssertionError("Could not load analyze.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + cls.analyze = module + + @staticmethod + def _make_stem_mono(sr: int, duration: float = 1.5) -> np.ndarray: + t = np.linspace(0, duration, int(sr * duration), endpoint=False, dtype=np.float32) + return (0.3 * np.sin(2 * np.pi * 220.0 * t)).astype(np.float32) + + @classmethod + def _make_stem_stereo(cls, sr: int, duration: float = 1.5) -> np.ndarray: + mono = cls._make_stem_mono(sr, duration) + return np.stack([mono, mono * 0.9], axis=1) + + def test_returns_none_for_none_stems(self): + self.assertIsNone(self.analyze._run_per_stem_analyses(None, 44100)) + + def test_returns_none_for_empty_stems_dict(self): + self.assertIsNone(self.analyze._run_per_stem_analyses({}, 44100)) + + def test_returns_none_when_all_loads_fail(self): + """If _load_stem_mono returns None for every stem, the whole result collapses to None.""" + stems = {"drums": "/tmp/d.wav", "bass": "/tmp/b.wav"} + with mock.patch.object(self.analyze, "_load_stem_mono", return_value=None): + result = self.analyze._run_per_stem_analyses(stems, 44100) + self.assertIsNone(result) + + def test_returns_dict_for_two_loaded_stems(self): + """When two stems load successfully, the result is keyed by stem name.""" + sr = 44100 + stem_mono = self._make_stem_mono(sr) + stem_stereo = self._make_stem_stereo(sr) + stems = {"drums": "/tmp/d.wav", "bass": "/tmp/b.wav"} + + def _mono_side_effect(stems_arg, name, sample_rate=44100): + if name in ("drums", "bass"): + return stem_mono + return None + + def _stereo_side_effect(stems_arg, name): + if name in ("drums", "bass"): + return stem_stereo + return None + + with mock.patch.object(self.analyze, "_load_stem_mono", side_effect=_mono_side_effect), \ + mock.patch.object(self.analyze, "_load_stem_stereo", side_effect=_stereo_side_effect): + result = self.analyze._run_per_stem_analyses(stems, sr) + + self.assertIsInstance(result, dict) + self.assertIn("drums", result) + self.assertIn("bass", result) + self.assertNotIn("other", result) + self.assertNotIn("vocals", result) + # Schema sanity: each stem block carries the documented set of overlay fields. + for stem_name, block in result.items(): + self.assertIsInstance(block, dict) + self.assertIn("spectralBalance", block) + self.assertIn("spectralDetail", block) + self.assertIn("crestFactor", block) + self.assertIn("dynamicSpread", block) + + def test_partial_analyzer_failure_does_not_drop_stem(self): + """If one analyzer raises for one stem, other analyzers' fields still appear.""" + sr = 44100 + stem_mono = self._make_stem_mono(sr) + stem_stereo = self._make_stem_stereo(sr) + stems = {"drums": "/tmp/d.wav"} + + original_spectral_balance = self.analyze.analyze_spectral_balance + + def _failing_spectral_balance(mono, sample_rate): + raise RuntimeError("simulated spectralBalance failure") + + with mock.patch.object(self.analyze, "_load_stem_mono", return_value=stem_mono), \ + mock.patch.object(self.analyze, "_load_stem_stereo", return_value=stem_stereo), \ + mock.patch.object(self.analyze, "analyze_spectral_balance", side_effect=_failing_spectral_balance): + result = self.analyze._run_per_stem_analyses(stems, sr) + + self.assertIsNotNone(result) + self.assertIn("drums", result) + drums_block = result["drums"] + # spectralBalance failed → not present (or None), but other analyzers still produced fields. + self.assertNotIn("spectralBalance", drums_block) + self.assertIn("crestFactor", drums_block) + # Sanity: the unmocked analyzer is still callable on the same input. + _ = original_spectral_balance + + def test_skips_stem_when_stereo_is_unavailable(self): + """Stereo-only analyzers (LUFS, stereoDetail) must be silently skipped when stereo load fails.""" + sr = 44100 + stem_mono = self._make_stem_mono(sr) + stems = {"drums": "/tmp/d.wav"} + + with mock.patch.object(self.analyze, "_load_stem_mono", return_value=stem_mono), \ + mock.patch.object(self.analyze, "_load_stem_stereo", return_value=None): + result = self.analyze._run_per_stem_analyses(stems, sr) + + self.assertIsNotNone(result) + drums_block = result["drums"] + # Mono-only fields still appear. + self.assertIn("spectralBalance", drums_block) + self.assertIn("crestFactor", drums_block) + # Stereo-only LUFS/stereoDetail/truePeak fields are skipped. + self.assertNotIn("lufsIntegrated", drums_block) + self.assertNotIn("stereoDetail", drums_block) + self.assertNotIn("truePeak", drums_block) + + class SidechainDetailTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: diff --git a/apps/backend/tests/test_audio_fixture.py b/apps/backend/tests/test_audio_fixture.py index 15e713cc..0ab22dc0 100644 --- a/apps/backend/tests/test_audio_fixture.py +++ b/apps/backend/tests/test_audio_fixture.py @@ -16,10 +16,13 @@ "timeSignature", "timeSignatureSource", "timeSignatureConfidence", "durationSeconds", "sampleRate", "lufsIntegrated", "lufsRange", "lufsMomentaryMax", "lufsShortTermMax", + "lufsCurve", "truePeak", "plr", "crestFactor", "dynamicSpread", "dynamicCharacter", "textureCharacter", "stereoDetail", - "monoCompatible", "spectralBalance", - "spectralDetail", "rhythmDetail", "melodyDetail", "transcriptionDetail", + "monoCompatible", "spectralBalance", "spectralBalanceTimeSeries", + "spectralDetail", "stemAnalysis", "transientDensityDetail", "saturationDetail", + "snareDetail", "hihatDetail", + "rhythmDetail", "melodyDetail", "transcriptionDetail", "pitchDetail", "grooveDetail", "beatsLoudness", "rhythmTimeline", "sidechainDetail", "acidDetail", "reverbDetail", "vocalDetail", "supersawDetail", "bassDetail", "kickDetail", diff --git a/apps/backend/tests/test_dsp_utils.py b/apps/backend/tests/test_dsp_utils.py new file mode 100644 index 00000000..2823d97d --- /dev/null +++ b/apps/backend/tests/test_dsp_utils.py @@ -0,0 +1,278 @@ +"""Unit tests for shared DSP utilities in `dsp_utils.py`. + +A numerical bug in any of these helpers — `_pearson_corr` in particular — +propagates into the sidechain dip-correlation loop, the stereo band-correlation +curve, and the segment-by-segment downsamplers consumed by the UI. These tests +exercise each one against synthetic inputs with closed-form expected values. +""" + +import importlib.util +import math +import sys +import unittest +from pathlib import Path + +import numpy as np + + +_BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(_BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(_BACKEND_ROOT)) + +_DSP_PATH = _BACKEND_ROOT / "dsp_utils.py" +_DSP_SPEC = importlib.util.spec_from_file_location("dsp_utils_test", _DSP_PATH) +if _DSP_SPEC is None or _DSP_SPEC.loader is None: + raise AssertionError("Could not load dsp_utils.py for direct helper tests.") +dsp_utils = importlib.util.module_from_spec(_DSP_SPEC) +_DSP_SPEC.loader.exec_module(dsp_utils) + + +class PearsonCorrTests(unittest.TestCase): + """Sanity-check the in-house Pearson correlation used across the analyzers.""" + + def test_perfect_positive_correlation_is_one(self): + a = np.linspace(-1.0, 1.0, 200, dtype=np.float64) + b = 2.0 * a + 0.5 + self.assertAlmostEqual(dsp_utils._pearson_corr(a, b), 1.0, places=6) + + def test_perfect_negative_correlation_is_negative_one(self): + a = np.linspace(-1.0, 1.0, 200, dtype=np.float64) + b = -3.0 * a + 1.0 + self.assertAlmostEqual(dsp_utils._pearson_corr(a, b), -1.0, places=6) + + def test_orthogonal_signals_correlate_near_zero(self): + n = 4096 + t = np.linspace(0, 1.0, n, endpoint=False, dtype=np.float64) + a = np.sin(2 * np.pi * 4 * t) + b = np.cos(2 * np.pi * 4 * t) + corr = dsp_utils._pearson_corr(a, b) + self.assertLess(abs(corr), 1e-6) + + def test_constant_input_returns_nan(self): + """Zero-variance input must return NaN (caller treats NaN as 'no correlation').""" + a = np.full(128, 0.5, dtype=np.float64) + b = np.linspace(0, 1, 128, dtype=np.float64) + self.assertTrue(math.isnan(dsp_utils._pearson_corr(a, b))) + self.assertTrue(math.isnan(dsp_utils._pearson_corr(b, a))) + + def test_empty_input_returns_nan(self): + a = np.array([], dtype=np.float64) + b = np.array([], dtype=np.float64) + self.assertTrue(math.isnan(dsp_utils._pearson_corr(a, b))) + + def test_unequal_lengths_truncates_to_shorter(self): + a = np.linspace(-1.0, 1.0, 50, dtype=np.float64) + b = np.concatenate([2.0 * a + 0.5, np.full(20, 999.0)]) + self.assertAlmostEqual(dsp_utils._pearson_corr(a, b), 1.0, places=6) + + +class DownsampleLufsArrayTests(unittest.TestCase): + """Verify the LUFS array downsampler used by the UI loudness curve.""" + + def test_empty_input_returns_empty_list(self): + self.assertEqual(dsp_utils._downsample_lufs_array(np.array([], dtype=np.float64)), []) + + def test_all_nan_input_returns_empty_list(self): + values = np.full(50, np.nan, dtype=np.float64) + self.assertEqual(dsp_utils._downsample_lufs_array(values), []) + + def test_constant_value_preserved_through_downsample(self): + values = np.full(1000, -14.2, dtype=np.float64) + points = dsp_utils._downsample_lufs_array(values, target_points=50) + self.assertGreater(len(points), 0) + self.assertLessEqual(len(points), 50) + for point in points: + self.assertAlmostEqual(point["lufs"], -14.2, places=1) + self.assertIn("t", point) + + def test_target_points_caps_output(self): + """At target_points=10 over a 1000-frame array, we get ≤ 10 rows.""" + values = np.linspace(-30.0, -10.0, 1000, dtype=np.float64) + points = dsp_utils._downsample_lufs_array(values, target_points=10) + self.assertGreater(len(points), 0) + self.assertLessEqual(len(points), 10) + + def test_finite_filter_drops_nan_bins(self): + """Bins that are entirely NaN must be skipped (not emitted as NaN/zero).""" + values = np.full(100, np.nan, dtype=np.float64) + values[10:20] = -12.0 # only one bin worth of finite samples + points = dsp_utils._downsample_lufs_array(values, target_points=10) + for point in points: + self.assertTrue(np.isfinite(point["lufs"])) + + def test_timestamps_are_increasing(self): + values = np.linspace(-30.0, -10.0, 200, dtype=np.float64) + points = dsp_utils._downsample_lufs_array(values, target_points=20) + timestamps = [p["t"] for p in points] + for prev, curr in zip(timestamps, timestamps[1:]): + self.assertLess(prev, curr) + + +class DownsampleBandEnergiesCurveTests(unittest.TestCase): + """Verify the time-series spectralBalance downsampler.""" + + def test_empty_input_returns_empty(self): + self.assertEqual( + dsp_utils._downsample_band_energies_curve( + {}, [], frame_hop_seconds=0.1, target_points=100, + ), + [], + ) + + def test_zero_band_names_returns_empty(self): + energies = {"subBass": [0.1, 0.2], "lowBass": [0.1, 0.2]} + self.assertEqual( + dsp_utils._downsample_band_energies_curve(energies, [], 0.1), [], + ) + + def test_zero_or_negative_energy_collapses_to_minus_100_db(self): + energies = {"subBass": [0.0, 0.0, 0.0, 0.0]} + result = dsp_utils._downsample_band_energies_curve( + energies, ["subBass"], frame_hop_seconds=0.1, target_points=4, + ) + self.assertGreater(len(result), 0) + for point in result: + self.assertEqual(point["subBass"], -100.0) + + def test_positive_energy_yields_finite_db_values(self): + energies = {"subBass": [1.0] * 10, "lowBass": [0.5] * 10} + result = dsp_utils._downsample_band_energies_curve( + energies, ["subBass", "lowBass"], frame_hop_seconds=0.05, target_points=5, + ) + self.assertGreater(len(result), 0) + for point in result: + self.assertIn("t", point) + self.assertIn("subBass", point) + self.assertIn("lowBass", point) + self.assertTrue(np.isfinite(point["subBass"])) + self.assertTrue(np.isfinite(point["lowBass"])) + # subBass (1.0) is 3 dB above lowBass (0.5) in power → ≥ lowBass. + self.assertGreater(point["subBass"], point["lowBass"]) + + def test_target_points_caps_output_length(self): + energies = {"subBass": list(np.linspace(0.01, 1.0, 1000))} + result = dsp_utils._downsample_band_energies_curve( + energies, ["subBass"], frame_hop_seconds=0.01, target_points=20, + ) + self.assertLessEqual(len(result), 20) + self.assertGreater(len(result), 0) + + +class ComputeTempoCurveTests(unittest.TestCase): + """Verify the tick → instantaneous-BPM curve helper.""" + + def test_empty_ticks_returns_empty(self): + self.assertEqual(dsp_utils._compute_tempo_curve_from_ticks(np.array([])), []) + + def test_single_tick_returns_empty(self): + """Need at least two ticks to compute an interval.""" + self.assertEqual( + dsp_utils._compute_tempo_curve_from_ticks(np.array([1.0])), [], + ) + + def test_steady_120_bpm_resolves_to_120(self): + # Beat every 0.5 s = 120 BPM + ticks = np.arange(0.0, 10.0, 0.5, dtype=np.float64) + curve = dsp_utils._compute_tempo_curve_from_ticks(ticks, target_points=50) + self.assertGreater(len(curve), 0) + for point in curve: + self.assertAlmostEqual(point["bpm"], 120.0, delta=0.5) + + def test_zero_intervals_are_treated_as_invalid(self): + """Duplicate ticks (zero interval) must not produce inf BPM rows.""" + ticks = np.array([0.0, 0.0, 0.5, 1.0, 1.5, 2.0], dtype=np.float64) + curve = dsp_utils._compute_tempo_curve_from_ticks(ticks, target_points=10) + for point in curve: + self.assertTrue(np.isfinite(point["bpm"])) + + def test_negative_intervals_are_treated_as_invalid(self): + """Out-of-order ticks must not crash; the rejected intervals are dropped.""" + ticks = np.array([0.0, 0.5, 0.4, 1.0, 1.5, 2.0], dtype=np.float64) + curve = dsp_utils._compute_tempo_curve_from_ticks(ticks, target_points=10) + for point in curve: + self.assertTrue(np.isfinite(point["bpm"])) + self.assertGreater(point["bpm"], 0.0) + + def test_tempo_change_detected_in_curve(self): + """A first-half/second-half tempo change must be reflected in the curve range.""" + first_half = np.arange(0.0, 5.0, 0.5) # 120 BPM + # Start the second half just after the first half ends so the diff between + # the last 120-BPM tick and the first 90-BPM tick stays positive. + second_half = np.arange(5.0 + 2.0 / 3.0, 10.0, 2.0 / 3.0) # 90 BPM + ticks = np.concatenate([first_half, second_half]).astype(np.float64) + curve = dsp_utils._compute_tempo_curve_from_ticks(ticks, target_points=50) + bpm_values = [point["bpm"] for point in curve] + self.assertGreater(max(bpm_values), 110.0) + self.assertLess(min(bpm_values), 100.0) + + +class ComputeStereoCorrelationCurveTests(unittest.TestCase): + """Verify the 1-second windowed L/R correlation helper.""" + + def test_short_input_returns_empty(self): + sr = 44_100 + left = np.zeros(sr // 2, dtype=np.float64) # 0.5 s < window + right = np.zeros(sr // 2, dtype=np.float64) + sub_l = np.zeros(sr // 2, dtype=np.float64) + sub_r = np.zeros(sr // 2, dtype=np.float64) + result = dsp_utils._compute_stereo_correlation_curve( + left, right, sub_l, sub_r, sr, window_seconds=1.0, + ) + self.assertEqual(result, []) + + def test_invalid_sample_rate_returns_empty(self): + left = right = np.zeros(1000, dtype=np.float64) + sub_l = sub_r = np.zeros(1000, dtype=np.float64) + self.assertEqual( + dsp_utils._compute_stereo_correlation_curve(left, right, sub_l, sub_r, 0), + [], + ) + self.assertEqual( + dsp_utils._compute_stereo_correlation_curve(left, right, sub_l, sub_r, 44_100, window_seconds=0.0), + [], + ) + + def test_identical_l_r_gives_full_correlation_one(self): + sr = 44_100 + duration = 3.0 + t = np.linspace(0, duration, int(sr * duration), endpoint=False, dtype=np.float64) + signal = np.sin(2 * np.pi * 220 * t) + sub_signal = np.sin(2 * np.pi * 40 * t) + result = dsp_utils._compute_stereo_correlation_curve( + signal, signal, sub_signal, sub_signal, sr, window_seconds=1.0, + ) + self.assertGreater(len(result), 0) + for point in result: + self.assertIsNotNone(point["full"]) + self.assertAlmostEqual(point["full"], 1.0, places=2) + + def test_inverted_l_r_gives_full_correlation_negative_one(self): + sr = 44_100 + duration = 3.0 + t = np.linspace(0, duration, int(sr * duration), endpoint=False, dtype=np.float64) + signal = np.sin(2 * np.pi * 220 * t) + sub_signal = np.sin(2 * np.pi * 40 * t) + result = dsp_utils._compute_stereo_correlation_curve( + signal, -signal, sub_signal, -sub_signal, sr, window_seconds=1.0, + ) + for point in result: + self.assertIsNotNone(point["full"]) + self.assertAlmostEqual(point["full"], -1.0, places=2) + + def test_silent_sub_band_produces_none_sub(self): + """Sub-correlation must be None when the sub band is silent (not 0 or NaN).""" + sr = 44_100 + duration = 3.0 + t = np.linspace(0, duration, int(sr * duration), endpoint=False, dtype=np.float64) + signal = np.sin(2 * np.pi * 220 * t) + sub_silent = np.zeros_like(signal) + result = dsp_utils._compute_stereo_correlation_curve( + signal, signal, sub_silent, sub_silent, sr, window_seconds=1.0, + ) + self.assertGreater(len(result), 0) + for point in result: + self.assertIsNone(point["sub"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/backend/tests/test_phase1_evaluation.py b/apps/backend/tests/test_phase1_evaluation.py index ed91fd69..57330e6a 100644 --- a/apps/backend/tests/test_phase1_evaluation.py +++ b/apps/backend/tests/test_phase1_evaluation.py @@ -4,6 +4,7 @@ from pathlib import Path from phase1_evaluation import DEFAULT_MANIFEST_PATH, run_phase1_evaluation +from phase1_report_html import default_html_report_path, render_html_report class Phase1EvaluationHarnessTests(unittest.TestCase): @@ -27,6 +28,155 @@ def test_evaluation_harness_generates_report_and_meets_thresholds(self) -> None: self.assertIn("click_120", fixture_ids) self.assertIn("sine_220", fixture_ids) + def test_default_run_does_not_include_real_tracks_section(self) -> None: + """Without --include-real, real-track plumbing stays inert. + + The default report shape should be backward-compatible with anything + that read it before the real-track tier was added. + """ + with tempfile.TemporaryDirectory(prefix="asa_phase1_default_test_") as temp_dir: + report_path = Path(temp_dir) / "phase1_eval_report.json" + report = run_phase1_evaluation( + manifest_path=DEFAULT_MANIFEST_PATH, + report_path=report_path, + runs_per_fixture=1, + ) + + self.assertFalse(report["includeReal"]) + self.assertIsNone(report["realTracksDir"]) + self.assertNotIn("realTracks", report) + self.assertEqual(report["summary"]["realTracksEvaluated"], 0) + self.assertEqual(report["summary"]["realTracksSkipped"], 0) + + def test_include_real_with_missing_audio_skips_gracefully(self) -> None: + """include_real with a manifest entry whose audio is absent should skip, + not fail, and the synthetic-fixture run should still pass overall. + + This is the day-1 behavior promised by the bench README — populating + bench tracks is opt-in, and a missing track is reported with a clear + skipReason instead of breaking the audit. + """ + with tempfile.TemporaryDirectory(prefix="asa_phase1_real_skip_") as temp_dir: + temp_root = Path(temp_dir) + manifest_path = temp_root / "manifest.json" + + base_manifest = json.loads(DEFAULT_MANIFEST_PATH.read_text(encoding="utf-8")) + base_manifest["realTracks"] = [ + { + "id": "missing_track_for_test", + "audioPath": "definitely_not_here.wav", + "category": "four_on_floor", + "description": "Synthetic entry for unit test — audio absent on disk", + "thresholds": { + "bpm": {"target": 128.0, "tolerance": 2.0}, + }, + } + ] + manifest_path.write_text(json.dumps(base_manifest, indent=2), encoding="utf-8") + + empty_real_tracks_dir = temp_root / "empty_bench_tracks" + empty_real_tracks_dir.mkdir() + + report_path = temp_root / "phase1_eval_report.json" + report = run_phase1_evaluation( + manifest_path=manifest_path, + report_path=report_path, + runs_per_fixture=1, + include_real=True, + real_tracks_dir=empty_real_tracks_dir, + ) + + self.assertTrue(report["includeReal"]) + self.assertTrue(report["summary"]["allPassed"]) + self.assertEqual(report["summary"]["realTracksEvaluated"], 0) + self.assertEqual(report["summary"]["realTracksSkipped"], 1) + self.assertEqual(report["summary"]["realTracksAnalyzeFailed"], 0) + + real_tracks = report["realTracks"] + self.assertEqual(len(real_tracks), 1) + entry = real_tracks[0] + self.assertEqual(entry["id"], "missing_track_for_test") + self.assertEqual(entry["status"], "skipped_audio_missing") + self.assertIn("audio not present at", entry["skipReason"]) + self.assertEqual(entry["checks"], []) + self.assertTrue(entry["allPassed"]) + + +class Phase1HtmlReportTests(unittest.TestCase): + def test_html_renderer_emits_expected_markers(self) -> None: + """Render a hand-built report dict and confirm the HTML body contains + the verdict, fixture identifiers, skipped-track reason, and the + confidence-calibration footer note. + + Synthetic input keeps this test fast — we don't need to run analyze.py + just to exercise rendering. + """ + sample_report = { + "generatedAt": "2026-05-11T10:00:00+00:00", + "manifestPath": "/tmp/manifest.json", + "runsPerFixture": 1, + "includeReal": True, + "realTracksDir": "/tmp/bench_tracks", + "fixtures": [ + { + "id": "click_120", + "audioPath": "/tmp/click_120.wav", + "checks": [ + {"name": "threshold:bpm", "passed": True, "message": "target=120.1 actual=120.05"}, + ], + "allPassed": True, + } + ], + "realTracks": [ + { + "id": "missing_track_for_test", + "audioPath": "/tmp/bench_tracks/missing.wav", + "category": "four_on_floor", + "description": "Synthetic test entry", + "status": "skipped_audio_missing", + "skipReason": "audio not present at /tmp/bench_tracks/missing.wav", + "checks": [], + "allPassed": True, + } + ], + "summary": { + "fixtures": 1, + "realTracksEvaluated": 0, + "realTracksSkipped": 1, + "realTracksAnalyzeFailed": 0, + "checksPassed": 1, + "checksFailed": 0, + "allPassed": True, + }, + } + + with tempfile.TemporaryDirectory(prefix="asa_phase1_html_") as temp_dir: + output_path = Path(temp_dir) / "accuracy.html" + written = render_html_report(sample_report, output_path) + + self.assertEqual(written, output_path) + self.assertTrue(output_path.exists()) + html = output_path.read_text(encoding="utf-8") + + self.assertIn("ASA Phase 1 Accuracy Report", html) + self.assertIn("ALL CHECKS PASSED", html) + self.assertIn("click_120", html) + self.assertIn("missing_track_for_test", html) + self.assertIn("audio not present at /tmp/bench_tracks/missing.wav", html) + self.assertIn("Confidence-calibration sub-report", html) + + def test_default_html_report_path_uses_dated_filename(self) -> None: + with tempfile.TemporaryDirectory(prefix="asa_phase1_path_") as temp_dir: + reports_dir = Path(temp_dir) + path = default_html_report_path(reports_dir) + + self.assertEqual(path.parent, reports_dir) + self.assertTrue(path.name.startswith("accuracy_")) + self.assertTrue(path.name.endswith(".html")) + # Filename shape: accuracy_YYYYMMDD-HHMMSSZ.html → 8 digits, dash, 6 digits + Z + stamp = path.stem.removeprefix("accuracy_") + self.assertRegex(stamp, r"^\d{8}-\d{6}Z$") + if __name__ == "__main__": unittest.main() diff --git a/apps/backend/tests/test_server.py b/apps/backend/tests/test_server.py index f162e670..668c41c0 100644 --- a/apps/backend/tests/test_server.py +++ b/apps/backend/tests/test_server.py @@ -3855,11 +3855,14 @@ def test_reserved_measurement_job_uses_runtime_pitch_note_mode_resolution(self) ) as execute_measurement_run_mock: server._execute_reserved_measurement_job(runtime, job) + # pitch_note_mode="stem_notes" now also triggers separation at + # measurement time so the Phase 1.B stem-first overlay (stemAnalysis) + # can populate without waiting on the pitch-note worker stage. execute_measurement_run_mock.assert_called_once_with( runtime, created["runId"], request_id=created["runId"], - run_separation=False, + run_separation=True, run_transcribe=False, run_fast=False, run_standard=False, @@ -4016,5 +4019,91 @@ def test_pitch_note_worker_passes_backend_flag_to_subprocess(self) -> None: self.assertIn("torchcrepe-viterbi", cmd) +class Phase2CatalogValidationTests(unittest.TestCase): + """Direct exercise of `_validate_phase2_catalog_entry` for the v3.1 + follow-up Live 12 catalog completion (Auto Filter aliases + Glue + Compressor allowedParameters expansion). No Gemini mocking — just + runs the validator against the live catalog dict in + `server_phase2.LIVE12_DEVICE_LOOKUP`. + """ + + def _call(self, device: str, parameter: str) -> list[dict]: + from server_phase2 import _validate_phase2_catalog_entry, LIVE12_DEVICE_LOOKUP + + warnings: list[dict] = [] + entry = LIVE12_DEVICE_LOOKUP.get(device) + if entry is None: + # Mirror the production codepath where unknown devices emit + # UNKNOWN_DEVICE and short-circuit. The test still expects the + # validator to be invoked normally — caller-supplied device + # may legitimately be missing. + _validate_phase2_catalog_entry( + warnings=warnings, + device=device, + device_family=None, + parameter=parameter, + base_path="mixAndMasterChain[0]", + ) + return warnings + _validate_phase2_catalog_entry( + warnings=warnings, + device=device, + device_family=entry["family"], + parameter=parameter, + base_path="mixAndMasterChain[0]", + ) + return warnings + + def _codes(self, warnings: list[dict]) -> list[str]: + return [w.get("code") for w in warnings] + + # ----- Auto Filter (alias resolution) ----- + def test_auto_filter_resonance_passes_silently(self) -> None: + self.assertEqual(self._call("Auto Filter", "Resonance"), []) + + def test_auto_filter_filter_resonance_passes_via_alias(self) -> None: + # Alias resolves "Filter Resonance" → "Resonance"; no warning. + self.assertEqual(self._call("Auto Filter", "Filter Resonance"), []) + + def test_auto_filter_frequency_passes_silently(self) -> None: + self.assertEqual(self._call("Auto Filter", "Frequency"), []) + + def test_auto_filter_filter_frequency_passes_via_alias(self) -> None: + # Alias resolves "Filter Frequency" → "Frequency"; no warning. + self.assertEqual(self._call("Auto Filter", "Filter Frequency"), []) + + def test_auto_filter_invented_parameter_emits_unknown_parameter(self) -> None: + warnings = self._call("Auto Filter", "InventedParameter") + self.assertEqual(self._codes(warnings), ["UNKNOWN_PARAMETER"]) + + # ----- Glue Compressor (catalog expansion) ----- + def test_glue_compressor_sidechain_passes(self) -> None: + self.assertEqual(self._call("Glue Compressor", "Sidechain"), []) + + def test_glue_compressor_sidechain_gain_passes(self) -> None: + self.assertEqual(self._call("Glue Compressor", "Sidechain Gain"), []) + + def test_glue_compressor_sidechain_dry_wet_passes(self) -> None: + self.assertEqual(self._call("Glue Compressor", "Sidechain Dry/Wet"), []) + + def test_glue_compressor_range_passes(self) -> None: + self.assertEqual(self._call("Glue Compressor", "Range"), []) + + # ----- Negative: hallucinations still flagged after the catalog change ----- + def test_compressor_sustain_still_emits_unknown_parameter(self) -> None: + # v3.2-target hallucination; this catalog pass intentionally does + # not touch the Compressor entry. Asserts the new alias mechanism + # didn't accidentally swallow this case. + warnings = self._call("Compressor", "Sustain") + self.assertEqual(self._codes(warnings), ["UNKNOWN_PARAMETER"]) + + def test_aliases_scoped_per_device_not_global(self) -> None: + # Compressor does NOT define parameterAliases; "Filter Resonance" + # there is meaningless and must remain flagged. Asserts we didn't + # accidentally apply aliases across devices. + warnings = self._call("Compressor", "Filter Resonance") + self.assertEqual(self._codes(warnings), ["UNKNOWN_PARAMETER"]) + + if __name__ == "__main__": unittest.main() diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 0d77f70e..dd6b06cc 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -49,9 +49,13 @@ import { import { getAppViewHref } from './utils/appView'; import { startRenderBenchmarkCycle } from './utils/renderBenchmark'; +// Note: gemini-3.1-flash-preview is intentionally omitted — three live test runs +// against the Gemini API on 2026-05-11/12 returned 404 NOT_FOUND for this model +// ID. The 3.1 Pro variant works. Re-add this entry once Google publishes the +// flash-preview ID; the backend ALLOWED_GEMINI_MODELS set at server.py:151 +// still includes it so it can be enabled with a single line restore. const MODELS = [ { id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro Preview (Recommended)' }, - { id: 'gemini-3.1-flash-preview', name: 'Gemini 3.1 Flash Preview' }, { id: 'gemini-3-pro-preview', name: 'Gemini 3.0 Pro Preview' }, { id: 'gemini-3-flash-preview', name: 'Gemini 3.0 Flash Preview' }, { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro' }, diff --git a/apps/ui/src/components/analysisResultsViewModel.ts b/apps/ui/src/components/analysisResultsViewModel.ts index 2efe10d8..6fd7a233 100644 --- a/apps/ui/src/components/analysisResultsViewModel.ts +++ b/apps/ui/src/components/analysisResultsViewModel.ts @@ -127,6 +127,19 @@ export interface PatchCardViewModel { transcriptionDerived?: boolean; } +/** + * "source" disambiguates which Phase 1 path produced the note insights: + * - "transcription" — torchcrepe-derived notes from Demucs-separated stems + * (only present when pitch/note translation ran); higher-confidence. + * - "melody" — full-mix melody extraction via PredominantPitchMelodia, which + * runs in full analysis mode regardless of pitch/note mode. This is the + * "low-confidence full-mix draft" path — labelling it as transcription + * misled users (PR feedback: PDF showed "PITCH/NOTE TRANSLATION IS OFF" + * while still rendering "Transcribed Notes 439"). Callers should label + * melody-sourced insights as "Melody Notes (full-mix draft)" or similar. + */ +export type MelodyInsightsSource = "transcription" | "melody"; + export interface MelodyInsightsViewModel { noteCount: number; dominantNotes: string[]; @@ -134,6 +147,7 @@ export interface MelodyInsightsViewModel { confidence: number; confidenceLabel: ConfidenceLevel; isDraft: boolean; + source: MelodyInsightsSource; } interface SonicElementDefinition { @@ -424,6 +438,7 @@ export function buildMelodyInsights(phase1: Phase1Result): MelodyInsightsViewMod confidence, confidenceLabel, isDraft: confidence < LOW_TRANSCRIPTION_CONFIDENCE_THRESHOLD, + source: "transcription", }; } @@ -446,6 +461,7 @@ export function buildMelodyInsights(phase1: Phase1Result): MelodyInsightsViewMod confidence, confidenceLabel, isDraft: confidence < LOW_MELODY_CONFIDENCE_THRESHOLD, + source: "melody", }; } @@ -471,7 +487,18 @@ function getSonicMeasurements( { icon: "🧭", label: "Meter", value: phase1.timeSignature }, ...(melodyInsights ? [ - { icon: "🧮", label: "Transcribed Notes", value: `${melodyInsights.noteCount}` }, + { + icon: "🧮", + // Distinguish the two source paths: torchcrepe stem-aware + // transcription vs full-mix melody extraction. Labelling the + // full-mix path as "Transcribed Notes" misled users whose + // pitch/note translation was OFF. + label: + melodyInsights.source === "transcription" + ? "Transcribed Notes" + : "Melody Notes (full-mix draft)", + value: `${melodyInsights.noteCount}`, + }, { icon: "📐", label: "Note Range", value: melodyInsights.rangeLabel }, { icon: "🎵", @@ -480,7 +507,10 @@ function getSonicMeasurements( }, { icon: "📝", - label: "Transcription", + label: + melodyInsights.source === "transcription" + ? "Transcription" + : "Melody Confidence", value: `${melodyInsights.confidenceLabel} (${Math.round(melodyInsights.confidence * 100)}%)`, }, ] @@ -992,11 +1022,13 @@ function buildPatchFallbackParameters(phase1: Phase1Result): ChainParameterViewM } function buildMelodyPatchParameters(insights: MelodyInsightsViewModel): ChainParameterViewModel[] { + const noteLabel = insights.source === "transcription" ? "Transcribed Notes" : "Melody Notes (full-mix draft)"; + const confidenceLabel = insights.source === "transcription" ? "Transcription Confidence" : "Melody Confidence"; return [ - { label: "Transcribed Notes", value: `${insights.noteCount}` }, + { label: noteLabel, value: `${insights.noteCount}` }, { label: "Note Range", value: insights.rangeLabel }, { label: "Dominant Notes", value: insights.dominantNotes.slice(0, 3).join(", ") || "n/a" }, - { label: "Transcription Confidence", value: `${Math.round(insights.confidence * 100)}% (${insights.confidenceLabel})` }, + { label: confidenceLabel, value: `${Math.round(insights.confidence * 100)}% (${insights.confidenceLabel})` }, ]; } diff --git a/apps/ui/src/services/backendPhase1Client.ts b/apps/ui/src/services/backendPhase1Client.ts index e07f5608..6d58a842 100644 --- a/apps/ui/src/services/backendPhase1Client.ts +++ b/apps/ui/src/services/backendPhase1Client.ts @@ -585,6 +585,9 @@ export function parsePhase1Result(value: unknown): Phase1Result { lufsRange: toNumber(phase1.lufsRange), lufsMomentaryMax: toNumber(phase1.lufsMomentaryMax), lufsShortTermMax: toNumber(phase1.lufsShortTermMax), + lufsCurve: isRecord(phase1.lufsCurve) + ? (phase1.lufsCurve as unknown as Phase1Result["lufsCurve"]) + : null, truePeak, plr: normalizedPlr, crestFactor: toNumber(phase1.crestFactor), @@ -604,7 +607,25 @@ export function parsePhase1Result(value: unknown): Phase1Result { highs: expectNumber(spectralBalance, "highs", "spectralBalance.highs"), brilliance: expectNumber(spectralBalance, "brilliance", "spectralBalance.brilliance"), }, + spectralBalanceTimeSeries: Array.isArray(phase1.spectralBalanceTimeSeries) + ? (phase1.spectralBalanceTimeSeries as unknown as Phase1Result["spectralBalanceTimeSeries"]) + : null, spectralDetail: isRecord(phase1.spectralDetail) ? phase1.spectralDetail as Phase1Result["spectralDetail"] : null, + stemAnalysis: isRecord(phase1.stemAnalysis) + ? (phase1.stemAnalysis as unknown as Phase1Result["stemAnalysis"]) + : null, + transientDensityDetail: isRecord(phase1.transientDensityDetail) + ? (phase1.transientDensityDetail as unknown as Phase1Result["transientDensityDetail"]) + : null, + saturationDetail: isRecord(phase1.saturationDetail) + ? (phase1.saturationDetail as unknown as Phase1Result["saturationDetail"]) + : null, + snareDetail: isRecord(phase1.snareDetail) + ? (phase1.snareDetail as unknown as Phase1Result["snareDetail"]) + : null, + hihatDetail: isRecord(phase1.hihatDetail) + ? (phase1.hihatDetail as unknown as Phase1Result["hihatDetail"]) + : null, rhythmDetail: isRecord(phase1.rhythmDetail) ? phase1.rhythmDetail as unknown as Phase1Result["rhythmDetail"] : null, melodyDetail, transcriptionDetail, diff --git a/apps/ui/src/services/phase2Validator.ts b/apps/ui/src/services/phase2Validator.ts index 8cbe80d9..87121fc0 100644 --- a/apps/ui/src/services/phase2Validator.ts +++ b/apps/ui/src/services/phase2Validator.ts @@ -1,7 +1,17 @@ import { Phase1Result, Phase2Result, AbletonRecommendation } from '../types'; +export type ValidationViolationType = + | 'NUMERIC_OVERRIDE' + | 'GENRE_IGNORES_DSP' + | 'BOUNDS_VIOLATION' + | 'MISSING_CITATION' + | 'TRIVIAL_CITATIONS' + | 'NEW_FIELD_UNCITED' + | 'LOW_CONFIDENCE_NOT_HEDGED' + | 'RECOMMENDATION_SALVAGED'; + export interface ValidationViolation { - type: 'NUMERIC_OVERRIDE' | 'GENRE_IGNORES_DSP' | 'BOUNDS_VIOLATION' | 'MISSING_CITATION'; + type: ValidationViolationType; field: string; phase1Value?: any; phase2Value?: any; @@ -23,6 +33,159 @@ export interface ValidationReport { const BPM_TOLERANCE = 2.0; const LUFS_TOLERANCE = 5.0; // LUFS difference threshold for warnings +/** + * Threshold above which an identical phase1Fields citation array, repeated + * across recommendations, counts as "trivial" — Gemini gaming the contract by + * citing the same anchor on every card instead of grounding each in a distinct + * measurement. + */ +const TRIVIAL_CITATION_DOMINANCE_THRESHOLD = 0.6; + +/** + * Confidence below which paired recommendation text must use hedged language. + * Mirrors the PURPOSE.md invariant: low-confidence measurements must propagate + * to hedged recommendations, not confident-sounding guesses. + */ +const LOW_CONFIDENCE_THRESHOLD = 0.4; + +/** + * Phase 1 fields whose presence-but-zero-citations should warn. The list now + * spans Phase 1.A (cheap-wins) through Phase 1.D #5 (RT60 per stem). If Gemini + * ignores them after they land in the payload, the depth gain isn't reaching + * the user. Add new paths here as later phases land. + * + * The path matcher is bidirectional with wildcard support — see + * `pathCoversTracked` for the precise semantics: + * - "exact match" — citation === tracked + * - "parent covers child" — citation is a path-prefix of tracked + * - "child covers parent" — tracked is a path-prefix of citation + * - "wildcard match" — segment-wise equality, with `*` in either + * position matching any single segment + * + * Wildcards (`*`) are the way stem-scoped paths are tracked without binding + * to a specific stem name. e.g. `stemAnalysis.*.reverbDetail` is "covered" + * by a citation against `stemAnalysis.bass.reverbDetail.preDelayMs`. + */ +export const PHASE1_NEW_FIELD_PATHS = [ + // Phase 1.A — cheap-win additions + 'lufsCurve', + 'lufsCurve.shortTerm', + 'lufsCurve.momentary', + 'spectralBalanceTimeSeries', + 'rhythmDetail.tempoCurve', + 'stereoDetail.correlationCurve', + 'arrangementDetail.noveltyCurve', + // Phase 1.C — mid-investment new analyzers + 'transientDensityDetail', // #1: per-band onset density + 'stereoDetail.bandCorrelations', // #2: per-band L/R correlation + 'grooveDetail.perDrumSwing', // #3: per-drum-group swing (kick / snare / hihat) + 'snareDetail', // #4: snare character (BandDrumDetail) + 'hihatDetail', // #4: hi-hat character (BandDrumDetail) + 'saturationDetail', // #5: saturation / clipping signals + 'saturationDetail.peakRatio95to50', + 'saturationDetail.clippedSamplePercent', + 'sidechainDetail.envelopeShape32', // #6: 32nd-note sidechain envelope + // Phase 1.D — bigger lifts + 'reverbDetail.perBandRt60', // #5: RT60 per octave band + 'reverbDetail.preDelayMs', // #5: median pre-delay + 'stemAnalysis.*.reverbDetail', // #5: per-stem reverb (any of drums/bass/other/vocals) +] as const; + +/** + * Convenience subset used for "phase-aware" reporting (e.g. the live + * comparator's split metrics). The full list above is the matcher's source + * of truth; these helpers expose the structure. + */ +export const PHASE1A_FIELD_PATHS = PHASE1_NEW_FIELD_PATHS.slice(0, 7); +export const PHASE1_CD_FIELD_PATHS = PHASE1_NEW_FIELD_PATHS.slice(7); + +/** + * Backward-compat alias for any external consumer that referenced the old + * name. Prefer `PHASE1_NEW_FIELD_PATHS` going forward. + */ +const PHASE1A_NEW_FIELD_PATHS = PHASE1_NEW_FIELD_PATHS; + +/** + * Maps a Phase 1 measurement path to its paired *Confidence path. When a + * recommendation cites the left side, the validator looks up the right side + * to decide whether the recommendation text must be hedged. + * + * Keep these conservative — only fields where the analyzer emits a confidence + * sibling and where misuse would harm the user. Add detector confidences as + * the audit surfaces patterns of over-confident recommendations. + */ +const CONFIDENCE_PAIRS: Record = { + 'bpm': 'bpmConfidence', + 'key': 'keyConfidence', + 'timeSignature': 'timeSignatureConfidence', + 'acidDetail': 'acidDetail.confidence', + 'acidDetail.isAcid': 'acidDetail.confidence', + 'reverbDetail': 'reverbDetail.confidence', + 'vocalDetail': 'vocalDetail.confidence', + 'vocalDetail.hasVocals': 'vocalDetail.confidence', + 'supersawDetail': 'supersawDetail.confidence', + 'supersawDetail.isSupersaw': 'supersawDetail.confidence', + 'sidechainDetail': 'sidechainDetail.pumpingConfidence', + 'sidechainDetail.pumpingRate': 'sidechainDetail.pumpingConfidence', + 'sidechainDetail.pumpingStrength': 'sidechainDetail.pumpingConfidence', + 'sidechainDetail.pumpingRegularity': 'sidechainDetail.pumpingConfidence', + 'sidechainDetail.envelopeShape': 'sidechainDetail.pumpingConfidence', + 'melodyDetail': 'melodyDetail.pitchConfidence', + 'transcriptionDetail': 'transcriptionDetail.averageConfidence', + 'genreDetail': 'genreDetail.confidence', + 'chordDetail': 'chordDetail.chordStrength', +}; + +/** + * Word lists used by the hedging check. Matched with word-boundary regexes — + * "may" matches "may " but not "many", "must" matches "must " but not + * "mustard". Keep these short and surgical; broader lists produce noise. + */ +const HEDGE_WORDS = [ + 'possible', 'possibly', 'subtle', 'subtly', 'maybe', 'consider', + 'may', 'might', 'can', 'could', 'often', 'sometimes', 'lightly', + 'gently', 'tentative', 'try', 'experiment', 'optional', 'low-confidence', + 'low confidence', 'if needed', 'if present', 'where appropriate', +]; + +const IMPERATIVE_WORDS = [ + 'mandatory', 'must', 'required', 'always', 'never', 'absolutely', + 'critical', 'essential', 'crucial', 'definitely', 'strictly', + 'prohibited', 'forbidden', +]; + +/** + * Backend warning codes the validator should surface as RECOMMENDATION_SALVAGED. + * Mirrors every code `_build_phase2_validation_warning()` can emit in + * apps/backend/server_phase2.py — broader than the backend's own narrower + * `_PHASE2_SALVAGE_WARNING_CODES` set because the decision gate cares about + * any "Gemini emitted something invalid, server intervened" signal, not just + * the items that were salvaged or dropped after a bounded salvage pass. + */ +const SALVAGE_WARNING_CODES = new Set([ + 'DROPPED_INVALID_ARRAY_ITEM', + 'DROPPED_INVALID_STYLE_PROFILE', + 'COERCED_ENUM_VALUE', + 'COERCED_TRACK_CONTEXT', + 'BACKFILLED_FIELD', + 'AUTHORITATIVE_MEASUREMENT_OVERRIDDEN', + 'DEVICE_FAMILY_MISMATCH', + 'UNKNOWN_DEVICE', + 'UNKNOWN_PARAMETER', + 'UNKNOWN_TRACK_CONTEXT', +]); + +/** + * Optional diagnostics shape the validator accepts. Mirrors what the backend + * envelope exposes under ``diagnostics.warnings`` after a Phase 2 run. The + * validator surfaces any salvage codes here as RECOMMENDATION_SALVAGED + * violations so the decision gate can fail when Gemini output is being + * silently fixed up. + */ +export interface ValidationDiagnostics { + warnings?: Array<{ code?: string; path?: string; message?: string }>; +} + /** * Validates that Phase 2 output is consistent with Phase 1 measurements. * Checks for numeric overrides, genre/DSP consistency, and physical bounds. @@ -30,6 +193,7 @@ const LUFS_TOLERANCE = 5.0; // LUFS difference threshold for warnings export function validatePhase2Consistency( phase1: Phase1Result, phase2: Phase2Result, + diagnostics?: ValidationDiagnostics, ): ValidationReport { const violations: ValidationViolation[] = []; let checkedFields = 0; @@ -52,6 +216,35 @@ export function validatePhase2Consistency( violations.push(...validateNumericBounds(phase1, phase2)); checkedFields++; + // 4. Citation contract — only run on new-shape responses where at least one + // recommendation already exposes phase1Fields. Legacy stored runs (predating + // the citation contract) skip this check silently. + if (isNewShapePhase2(phase2)) { + violations.push(...validatePhase1FieldCitations(phase1, phase2)); + checkedFields++; + + // 5. Citation diversity — non-trivial spread across recommendations. + violations.push(...validateCitationDiversity(phase2)); + checkedFields++; + + // 6. New-field coverage — Phase 1.A fields present in payload should be + // cited at least once. + violations.push(...validateNewFieldCoverage(phase1, phase2)); + checkedFields++; + + // 7. Low-confidence hedging — recommendation text grounded in a + // low-confidence measurement must be hedged, not imperative. + violations.push(...validateLowConfidenceHedging(phase1, phase2)); + checkedFields++; + } + + // 8. Salvage warnings — only when diagnostics are passed in. Surfaces the + // case where Gemini returned data the backend repaired or dropped. + if (diagnostics) { + violations.push(...validateSalvagedRecommendations(diagnostics)); + checkedFields++; + } + // Calculate summary statistics const errorCount = violations.filter(v => v.severity === 'ERROR').length; const warningCount = violations.filter(v => v.severity === 'WARNING').length; @@ -67,6 +260,177 @@ export function validatePhase2Consistency( }; } +/** + * Returns true if at least one recommendation across mixAndMasterChain, + * abletonRecommendations, or secretSauce.workflowSteps exposes a phase1Fields + * array. Used to gate the citation contract check so legacy stored Phase 2 + * results from before the contract landed do not produce spurious errors. + */ +function isNewShapePhase2(phase2: Phase2Result): boolean { + const buckets: Array> = [ + phase2.mixAndMasterChain ?? [], + phase2.abletonRecommendations ?? [], + phase2.secretSauce?.workflowSteps ?? [], + ]; + for (const bucket of buckets) { + for (const rec of bucket) { + if (Array.isArray(rec.phase1Fields)) { + return true; + } + } + } + return false; +} + +/** + * Recursively collect every dotted path that resolves to a non-null value in + * the Phase 1 result. Both intermediate keys and leaf scalars are included so + * that citations like "kickDetail" and "kickDetail.fundamentalHz" both match. + * + * For arrays of objects, we also surface `path.field` shapes (e.g. + * "arrangementDetail.noveltyPeaks.time") so Gemini can cite the field name + * of array items without indexing into them. + * + * Exported for tests; consumed by validatePhase1FieldCitations. + */ +export function collectPhase1FieldPaths(phase1: Phase1Result): Set { + const paths = new Set(); + walkForPaths(phase1, '', paths); + return paths; +} + +function walkForPaths(value: unknown, prefix: string, paths: Set): void { + if (value === null || value === undefined) { + return; + } + if (Array.isArray(value)) { + if (prefix.length > 0) { + paths.add(prefix); + } + // For arrays of objects, register `prefix.field` paths so citations to a + // field name on array items (e.g. "noveltyPeaks.time") are valid. + for (const item of value) { + if (item && typeof item === 'object' && !Array.isArray(item)) { + for (const key of Object.keys(item as Record)) { + const subPath = prefix ? `${prefix}.${key}` : key; + walkForPaths((item as Record)[key], subPath, paths); + } + } + } + return; + } + if (typeof value !== 'object') { + if (prefix.length > 0) { + paths.add(prefix); + } + return; + } + if (prefix.length > 0) { + paths.add(prefix); + } + for (const key of Object.keys(value as Record)) { + const subPath = prefix ? `${prefix}.${key}` : key; + walkForPaths((value as Record)[key], subPath, paths); + } +} + +interface CitationBearing { + phase1Fields?: string[]; +} + +interface CitationBucket { + pathPrefix: string; + recs: CitationBearing[]; +} + +/** + * Citation contract validation. Each recommendation must: + * 1. expose a phase1Fields array, + * 2. include at least one entry, + * 3. only reference paths that exist in the Phase 1 payload. + * + * Caller gates this via isNewShapePhase2() so legacy stored runs do not + * generate spurious MISSING_CITATION errors. + */ +function validatePhase1FieldCitations( + phase1: Phase1Result, + phase2: Phase2Result, +): ValidationViolation[] { + const violations: ValidationViolation[] = []; + const allowed = collectPhase1FieldPaths(phase1); + + const buckets: CitationBucket[] = [ + { pathPrefix: 'mixAndMasterChain', recs: phase2.mixAndMasterChain ?? [] }, + { pathPrefix: 'abletonRecommendations', recs: phase2.abletonRecommendations ?? [] }, + { + pathPrefix: 'secretSauce.workflowSteps', + recs: phase2.secretSauce?.workflowSteps ?? [], + }, + ]; + + for (const bucket of buckets) { + bucket.recs.forEach((rec, index) => { + const fieldPath = `${bucket.pathPrefix}[${index}].phase1Fields`; + const phase1Fields = rec.phase1Fields; + + if (!Array.isArray(phase1Fields)) { + violations.push({ + type: 'MISSING_CITATION', + field: fieldPath, + severity: 'ERROR', + message: + `Recommendation at ${bucket.pathPrefix}[${index}] is missing the required ` + + 'phase1Fields citation array. Per the Citation Contract every recommendation ' + + 'must list the Phase 1 measurement paths that justify it.', + }); + return; + } + + if (phase1Fields.length === 0) { + violations.push({ + type: 'MISSING_CITATION', + field: fieldPath, + severity: 'ERROR', + message: + `Recommendation at ${bucket.pathPrefix}[${index}] has an empty phase1Fields array. ` + + 'At least one Phase 1 measurement path must be cited.', + }); + return; + } + + phase1Fields.forEach((cited, entryIndex) => { + if (typeof cited !== 'string' || cited.trim().length === 0) { + violations.push({ + type: 'MISSING_CITATION', + field: `${fieldPath}[${entryIndex}]`, + severity: 'ERROR', + message: + `phase1Fields entry ${entryIndex} on ${bucket.pathPrefix}[${index}] is not a ` + + 'non-empty string. Each citation must be a dotted measurement path.', + phase2Value: cited, + }); + return; + } + const normalized = cited.trim(); + if (!allowed.has(normalized)) { + violations.push({ + type: 'MISSING_CITATION', + field: `${fieldPath}[${entryIndex}]`, + severity: 'ERROR', + message: + `phase1Fields entry "${normalized}" on ${bucket.pathPrefix}[${index}] does not ` + + 'match any path present in the Phase 1 payload. Do not invent field paths; ' + + 'only cite measurements that exist in AUTHORITATIVE_MEASUREMENT_RESULT_JSON.', + phase2Value: normalized, + }); + } + }); + }); + } + + return violations; +} + /** * Validates BPM consistency between Phase 1 and Phase 2. * Phase 2 should not contradict Phase 1 BPM by more than 2.0 BPM. @@ -430,3 +794,366 @@ function extractFrequencyValue(value: string): number | null { } return null; } + +// ─────────────────────────────────────────────────────────────────────────── +// Contract Slice step 9 extensions — added after the first live Gemini run +// surfaced over-confident text, single-anchor citation patterns, and +// salvaged-but-not-surfaced recommendation drops. +// ─────────────────────────────────────────────────────────────────────────── + +interface CitationRec { + bucket: string; + index: number; + phase1Fields: string[]; + reasonText: string; +} + +function collectCitationRecs(phase2: Phase2Result): CitationRec[] { + const recs: CitationRec[] = []; + const buckets: Array<{ name: string; items: any[] }> = [ + { name: 'mixAndMasterChain', items: phase2.mixAndMasterChain ?? [] }, + { name: 'abletonRecommendations', items: phase2.abletonRecommendations ?? [] }, + { name: 'secretSauce.workflowSteps', items: phase2.secretSauce?.workflowSteps ?? [] }, + ]; + for (const bucket of buckets) { + bucket.items.forEach((rec: any, index) => { + const fields = Array.isArray(rec?.phase1Fields) + ? rec.phase1Fields.map((s: unknown) => (typeof s === 'string' ? s.trim() : '')).filter(Boolean) + : []; + const reasonText = [rec?.reason, rec?.advancedTip, rec?.measurementJustification, rec?.instruction] + .filter((s: unknown): s is string => typeof s === 'string' && s.length > 0) + .join(' \n '); + recs.push({ bucket: bucket.name, index, phase1Fields: fields, reasonText }); + }); + } + return recs; +} + +/** + * Returns a normalized key for a phase1Fields array — sorted, lowercased, + * joined. Two recs with the same anchors in different orders collapse to the + * same key so the dominance heuristic catches "always cite bpm + key" gaming. + */ +function citationKey(phase1Fields: string[]): string { + return phase1Fields.map((s) => s.toLowerCase().trim()).filter(Boolean).sort().join('|'); +} + +function validateCitationDiversity(phase2: Phase2Result): ValidationViolation[] { + const recs = collectCitationRecs(phase2); + // Only consider recs that actually have citations — recs missing phase1Fields + // are already surfaced by MISSING_CITATION. Counting them here would + // double-penalize and could mask real dominance among the citing recs. + const cited = recs.filter((r) => r.phase1Fields.length > 0); + if (cited.length < 4) { + return []; // Not enough recs to meaningfully measure dominance. + } + + const keyCounts = new Map(); + for (const rec of cited) { + const key = citationKey(rec.phase1Fields); + keyCounts.set(key, (keyCounts.get(key) ?? 0) + 1); + } + + let dominantKey = ''; + let dominantCount = 0; + for (const [key, count] of keyCounts.entries()) { + if (count > dominantCount) { + dominantKey = key; + dominantCount = count; + } + } + const dominance = dominantCount / cited.length; + if (dominance < TRIVIAL_CITATION_DOMINANCE_THRESHOLD) { + return []; + } + + return [ + { + type: 'TRIVIAL_CITATIONS', + field: 'phase1Fields.dominantPattern', + phase2Value: dominantKey || '(empty)', + severity: 'WARNING', + message: + `${Math.round(dominance * 100)}% of cited recommendations share the same ` + + `phase1Fields anchor (${dominantKey || '(empty)'}). Citations should be diverse — different ` + + `cards should cite different measurements unless they genuinely depend on the same anchor.`, + }, + ]; +} + +/** + * Bidirectional + wildcard match between a citation path and a tracked path. + * Accepts: + * 1. exact string equality + * 2. citation is a path-prefix of tracked (parent-citation covers child-tracked) + * 3. tracked is a path-prefix of citation (child-citation satisfies parent-tracked) + * 4. segment-wise equality with `*` wildcard token matching any single segment + * on either side (used for stem-scoped paths like + * `stemAnalysis.*.reverbDetail`) + * + * A wildcard token matches exactly one segment, but the segment-wise match + * also runs as a prefix scan — so a shorter tracked path with a trailing `*` + * still covers longer citations under the same prefix. Concretely: + * - tracked `stemAnalysis.*` matches citation `stemAnalysis.drums.spectralBalance` + * (length-2 tracked is a wildcard-prefix of length-3 citation). + * - tracked `stemAnalysis.*.reverbDetail` matches citation + * `stemAnalysis.bass.reverbDetail.preDelayMs` for the same reason. + * To require exact-length matching, add a non-wildcard leaf to the tracked path. + */ +export function pathCoversTracked(citation: string, tracked: string): boolean { + if (citation === tracked) return true; + if (citation.startsWith(`${tracked}.`)) return true; // child citation satisfies parent tracked + if (tracked.startsWith(`${citation}.`)) return true; // parent citation covers child tracked + if (!citation.includes('*') && !tracked.includes('*')) return false; + + const cs = citation.split('.'); + const ts = tracked.split('.'); + // Wildcard prefix: tracked has fewer or equal segments and each tracked + // segment matches the corresponding citation segment (with `*` wild). + if (ts.length <= cs.length) { + let ok = true; + for (let i = 0; i < ts.length; i++) { + if (ts[i] !== cs[i] && ts[i] !== '*' && cs[i] !== '*') { + ok = false; + break; + } + } + if (ok) return true; + } + // Symmetric: citation prefix matches a leaf-extended tracked path. Rare but + // keeps the function symmetric across direction so callers can pass either + // argument as the "more specific" path. + if (cs.length <= ts.length) { + let ok = true; + for (let i = 0; i < cs.length; i++) { + if (cs[i] !== ts[i] && cs[i] !== '*' && ts[i] !== '*') { + ok = false; + break; + } + } + if (ok) return true; + } + return false; +} + +function validateNewFieldCoverage( + phase1: Phase1Result, + phase2: Phase2Result, +): ValidationViolation[] { + const recs = collectCitationRecs(phase2); + const citedPaths = new Set(); + for (const rec of recs) { + for (const f of rec.phase1Fields) citedPaths.add(f); + } + const citedList = Array.from(citedPaths); + + const violations: ValidationViolation[] = []; + for (const path of PHASE1A_NEW_FIELD_PATHS) { + if (!isPathPresentAndUseful(phase1, path)) continue; + // A tracked path is "covered" iff some citation matches under the + // bidirectional + wildcard rules. This lets either a parent or a more + // specific child satisfy a tracked anchor — important because Gemini + // tends to cite leaves (e.g. `grooveDetail.perDrumSwing.snare`) where + // the tracked path is the parent (`grooveDetail.perDrumSwing`). + if (citedList.some((c) => pathCoversTracked(c, path))) continue; + violations.push({ + type: 'NEW_FIELD_UNCITED', + field: path, + severity: 'WARNING', + message: + `Phase 1 field "${path}" is present in the measurement payload but no Phase 2 ` + + `recommendation cites it. If the field is relevant to this track, Gemini should ` + + `use it as a citation anchor; if it is not relevant, this warning is benign.`, + }); + } + return violations; +} + +/** + * Minimum useful density for a curve/array to be worth citing. Curves with + * fewer points carry no narrative — e.g. a 1-point tempoCurve has no "drift" + * to describe, a 1-point lufsCurve has no envelope. We treat shorter than + * this as "field present but not yet meaningful for this track" and skip + * the NEW_FIELD_UNCITED warning rather than fault Gemini for not citing it. + */ +const MIN_USEFUL_CURVE_POINTS = 5; + +function isPathPresentAndUseful(phase1: Phase1Result, path: string): boolean { + // Wildcard support: a tracked path like `stemAnalysis.*.reverbDetail` is + // present when ANY key at the `*` position has the remaining sub-path + // populated. We recurse on each candidate and short-circuit on the first + // useful match. This keeps the gate from warning about per-stem paths when + // at least one stem actually carries the field. + if (path.includes('*')) { + const parts = path.split('.'); + const starIndex = parts.indexOf('*'); + // Resolve the prefix up to the first wildcard segment. + let cursor: any = phase1; + for (let i = 0; i < starIndex; i++) { + if (cursor === null || cursor === undefined) return false; + if (typeof cursor !== 'object') return false; + const part = parts[i]; + if (!(part in cursor)) return false; + cursor = cursor[part]; + } + if (cursor === null || cursor === undefined || typeof cursor !== 'object') return false; + const remainder = parts.slice(starIndex + 1).join('.'); + if (remainder.length === 0) { + // `foo.*` is "present" if foo has any non-empty child. + return Object.values(cursor).some( + (v) => v !== null && v !== undefined && (typeof v !== 'object' || Object.keys(v as object).length > 0), + ); + } + return Object.values(cursor).some((child) => + child && typeof child === 'object' + ? isPathPresentAndUseful(child as Phase1Result, remainder) + : false, + ); + } + + let cursor: any = phase1; + for (const part of path.split('.')) { + if (cursor === null || cursor === undefined) return false; + if (typeof cursor !== 'object') return false; + if (!(part in cursor)) return false; + cursor = cursor[part]; + } + if (cursor === null || cursor === undefined) return false; + if (Array.isArray(cursor)) { + if (cursor.length < MIN_USEFUL_CURVE_POINTS) return false; + // Reject curves where every cell is null (e.g. correlationCurve on silence). + const hasAnyFinite = cursor.some((row: any) => { + if (row === null || row === undefined) return false; + if (typeof row === 'number') return Number.isFinite(row); + if (typeof row === 'object') { + for (const v of Object.values(row)) { + if (typeof v === 'number' && Number.isFinite(v)) return true; + } + return false; + } + return false; + }); + return hasAnyFinite; + } + if (typeof cursor === 'object') { + if (Object.keys(cursor).length === 0) return false; + // Container objects like lufsCurve = {shortTerm, momentary} count as + // useful when at least one child curve passes the threshold. + for (const value of Object.values(cursor)) { + if (Array.isArray(value) && value.length >= MIN_USEFUL_CURVE_POINTS) return true; + if (value !== null && value !== undefined && typeof value !== 'object') return true; + } + return false; + } + return true; +} + +const WORD_BOUNDARY_REGEX_CACHE = new Map(); + +function wordBoundaryRegex(phrase: string): RegExp { + let cached = WORD_BOUNDARY_REGEX_CACHE.get(phrase); + if (cached) return cached; + // Allow multi-word phrases (e.g. "low confidence") but anchor each side to a + // word boundary or whitespace boundary. Escape regex specials defensively. + const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + cached = new RegExp(`(?:^|\\b|\\s)${escaped}(?=\\b|\\s|$|[\\.,;:!?])`, 'i'); + WORD_BOUNDARY_REGEX_CACHE.set(phrase, cached); + return cached; +} + +function containsAny(text: string, words: string[]): { matched: string | null } { + for (const w of words) { + if (wordBoundaryRegex(w).test(text)) return { matched: w }; + } + return { matched: null }; +} + +function validateLowConfidenceHedging( + phase1: Phase1Result, + phase2: Phase2Result, +): ValidationViolation[] { + const recs = collectCitationRecs(phase2); + const violations: ValidationViolation[] = []; + + for (const rec of recs) { + if (rec.phase1Fields.length === 0) continue; + if (rec.reasonText.length === 0) continue; + + // Find the most-cited field that has a paired confidence; use the lowest + // confidence value among the rec's cited fields as the gate. (If the rec + // cites multiple fields and any one has low confidence, the text needs + // hedging.) + let lowestConfidence: number | null = null; + let triggeringField: string | null = null; + let triggeringConfidenceField: string | null = null; + for (const cited of rec.phase1Fields) { + const confidencePath = + CONFIDENCE_PAIRS[cited] ?? CONFIDENCE_PAIRS[cited.split('.')[0]]; + if (!confidencePath) continue; + const value = readNumberAtPath(phase1, confidencePath); + if (value === null) continue; + if (value >= LOW_CONFIDENCE_THRESHOLD) continue; + if (lowestConfidence === null || value < lowestConfidence) { + lowestConfidence = value; + triggeringField = cited; + triggeringConfidenceField = confidencePath; + } + } + if (lowestConfidence === null) continue; + + const imperative = containsAny(rec.reasonText, IMPERATIVE_WORDS); + const hedge = containsAny(rec.reasonText, HEDGE_WORDS); + if (imperative.matched && !hedge.matched) { + violations.push({ + type: 'LOW_CONFIDENCE_NOT_HEDGED', + field: `${rec.bucket}[${rec.index}]`, + phase1Value: { confidenceField: triggeringConfidenceField, value: lowestConfidence }, + phase2Value: imperative.matched, + severity: 'ERROR', + message: + `Recommendation at ${rec.bucket}[${rec.index}] cites "${triggeringField}" whose paired ` + + `confidence ${triggeringConfidenceField}=${lowestConfidence} is below the ` + + `${LOW_CONFIDENCE_THRESHOLD} threshold, but the text uses the imperative word ` + + `"${imperative.matched}" without any hedging language. Low-confidence ` + + `measurements must propagate to hedged recommendations (PURPOSE.md invariant #4).`, + }); + } + } + return violations; +} + +function readNumberAtPath(payload: unknown, path: string): number | null { + let cursor: any = payload; + for (const part of path.split('.')) { + if (cursor === null || cursor === undefined) return null; + if (typeof cursor !== 'object') return null; + cursor = (cursor as Record)[part]; + } + if (typeof cursor === 'number' && Number.isFinite(cursor)) return cursor; + return null; +} + +function validateSalvagedRecommendations( + diagnostics: ValidationDiagnostics, +): ValidationViolation[] { + const warnings = Array.isArray(diagnostics.warnings) ? diagnostics.warnings : []; + const violations: ValidationViolation[] = []; + for (const warning of warnings) { + const code = typeof warning?.code === 'string' ? warning.code : ''; + if (!SALVAGE_WARNING_CODES.has(code)) continue; + const path = typeof warning?.path === 'string' ? warning.path : '(unknown path)'; + const message = typeof warning?.message === 'string' ? warning.message : ''; + violations.push({ + type: 'RECOMMENDATION_SALVAGED', + field: path, + phase2Value: code, + severity: 'ERROR', + message: + `Backend salvage code ${code} at ${path}: the Phase 2 response had to be repaired ` + + `or had an item dropped before reaching the user (${message || 'no detail provided'}). ` + + `Silent loss of recommendations means the prompt or schema is letting Gemini produce ` + + `invalid items.`, + }); + } + return violations; +} diff --git a/apps/ui/src/types/interpretation.ts b/apps/ui/src/types/interpretation.ts index 90afa09f..60cefe03 100644 --- a/apps/ui/src/types/interpretation.ts +++ b/apps/ui/src/types/interpretation.ts @@ -85,6 +85,11 @@ export interface SecretSauceWorkflowStep { value: string; instruction: string; measurementJustification: string; + // Structured citation array. Required by the Gemini response schema; optional + // in this type only to keep stored older Phase 2 results parseable until + // they are re-run under the new schema. The validator enforces presence on + // fresh runs. + phase1Fields?: string[]; } export interface AbletonRecommendation { @@ -97,6 +102,8 @@ export interface AbletonRecommendation { value: string; reason: string; advancedTip?: string; + // See SecretSauceWorkflowStep.phase1Fields for the rollout rationale. + phase1Fields?: string[]; } export interface AudioObservationElement { @@ -174,6 +181,8 @@ export interface Phase2Result { parameter: string; value: string; reason: string; + // See SecretSauceWorkflowStep.phase1Fields for the rollout rationale. + phase1Fields?: string[]; }>; secretSauce: { title: string; diff --git a/apps/ui/src/types/measurement.ts b/apps/ui/src/types/measurement.ts index b5f327ae..4d688ab9 100644 --- a/apps/ui/src/types/measurement.ts +++ b/apps/ui/src/types/measurement.ts @@ -58,11 +58,166 @@ export interface DanceabilityResult { dfa: number; } +export interface LufsCurvePoint { + t: number; + lufs: number; +} + +export interface LufsCurve { + shortTerm: LufsCurvePoint[]; + momentary: LufsCurvePoint[]; +} + +export interface SpectralBalanceTimeSeriesPoint { + t: number; + subBass: number; + lowBass: number; + lowMids: number; + mids: number; + upperMids: number; + highs: number; + brilliance: number; +} + +/** + * Per-Demucs-stem analytical surface from Phase 1.B's stem-first overlay. + * + * Populated only when stem separation was requested AND succeeded + * (``--separate``/``run_separation`` path on the backend). When stems are + * unavailable this is ``null`` and the top-level full-mix scalars are the + * only source of truth. The subset of analyzers run per-stem mirrors the + * full-mix versions — spectralBalance, spectralDetail, loudness, stereo, + * dynamics — so Phase 2 can cite e.g. ``stemAnalysis.bass.spectralBalance.subBass`` + * for a bass-targeted EQ move instead of conflating every element under one + * full-mix scalar. Song-level fields (BPM, key, time signature, structure + * novelty) are intentionally absent here. + */ +export interface StemAnalysisEntry { + spectralBalance?: { + subBass: number; + lowBass: number; + lowMids: number; + mids: number; + upperMids: number; + highs: number; + brilliance: number; + } | null; + spectralBalanceTimeSeries?: SpectralBalanceTimeSeriesPoint[] | null; + spectralDetail?: SpectralDetail | null; + lufsIntegrated?: number | null; + lufsRange?: number | null; + lufsMomentaryMax?: number | null; + lufsShortTermMax?: number | null; + lufsCurve?: LufsCurve | null; + stereoDetail?: StereoDetail | null; + truePeak?: number | null; + crestFactor?: number | null; + dynamicSpread?: number | null; + dynamicCharacter?: DynamicCharacter | null; + /** + * Phase 1.D #5 — per-stem reverb estimation. The analyzer runs the + * same RT60-slope-fit pipeline on each Demucs stem; the drums stem is + * usually the most defensible signal (real room reverb) while bass / + * other / vocals may report long RT60s that actually reflect sustained + * tonal decay rather than reverb. `measured: false` means the analyzer + * didn't find enough transients to fit a slope on this stem — caller + * should treat the result as a fallback. + */ + reverbDetail?: ReverbDetail | null; +} + +export interface StemAnalysis { + drums?: StemAnalysisEntry; + bass?: StemAnalysisEntry; + other?: StemAnalysisEntry; + vocals?: StemAnalysisEntry; +} + +export interface TransientDensityBandEntry { + onsetRatePerSecond: number; + meanOnsetStrength: number; + peakOnsetStrength: number; + eventCount: number; +} + +export interface TransientDensityDetail { + subBass: TransientDensityBandEntry; + lowBass: TransientDensityBandEntry; + lowMids: TransientDensityBandEntry; + mids: TransientDensityBandEntry; + upperMids: TransientDensityBandEntry; + highs: TransientDensityBandEntry; + brilliance: TransientDensityBandEntry; +} + +/** + * Phase 1.C #4 — band-limited drum character (shared shape for snare and + * hi-hat). Hit count, attack sharpness, body-vs-snap energy ratio, mean + * spectral centroid in band, decay character. Phase 2 cites these for + * snare- / hi-hat-bus EQ + saturation + dynamics moves. + */ +export interface BandDrumDetail { + hitCount: number; + hitsPerSecond: number; + meanAttackSharpness: number; + meanBodyEnergyRatio: number | null; + meanSnapEnergyRatio: number | null; + meanCentroidHz: number | null; + meanDecayFrames: number; + meanDecaySeconds: number; + bandHz: [number, number]; +} + +/** + * Phase 1.C #5 — saturation / clipping / over-compression telltales. Hint- + * only; Phase 2 should hedge per the citation contract's low-confidence + * rules until the audit bench confirms the signal. + */ +export interface SaturationDetail { + clippedSampleCount: number; + clippedSamplePercent: number; + nearClippedSampleCount: number; + nearClippedSamplePercent: number; + peakRatio95to50: number | null; + rmsToPeakRatioDb: number | null; + saturationLikely: boolean; +} + +export interface StereoCorrelationCurvePoint { + t: number; + full: number | null; + sub: number | null; +} + +/** + * Phase 1.C #2 — per-frequency-band L/R Pearson correlation, keyed by the + * same 7 bands as ``spectralBalance``. ``null`` per band when that band has + * no usable energy (e.g. brilliance on a dark master). Phase 2 cites these + * to recommend Utility-tool width per band ("the bass is mono at 0.98 but + * the mids are wide at 0.45 — add Utility on the synth bus only"). + */ +export interface StereoBandCorrelations { + subBass: number | null; + lowBass: number | null; + lowMids: number | null; + mids: number | null; + upperMids: number | null; + highs: number | null; + brilliance: number | null; +} + export interface StereoDetail { stereoWidth: number | null; stereoCorrelation: number | null; subBassCorrelation?: number | null; subBassMono?: boolean | null; + /** + * 1-second windowed L/R correlation, full-band and sub-band side-by-side. + * Surfaces stereo automation (utility-tool width sweeps, mono-collapsing + * the drop) that the global scalars conflate into one number. + */ + correlationCurve?: StereoCorrelationCurvePoint[] | null; + bandCorrelations?: StereoBandCorrelations | null; } export interface SpectralDetail { @@ -86,6 +241,11 @@ export interface PhraseGrid { totalPhrases8Bar: number; } +export interface TempoCurvePoint { + t: number; + bpm: number; +} + export interface RhythmDetail { onsetRate: number; beatGrid: number[]; @@ -94,6 +254,12 @@ export interface RhythmDetail { grooveAmount: number; tempoStability?: number | null; phraseGrid?: PhraseGrid | null; + /** + * Instantaneous-BPM curve smoothed with a 4-beat rolling median and + * downsampled to ~200 points. Surfaces deliberate ritardando/accelerando + * and DJ-tool transitions that the single mean BPM scalar conflates away. + */ + tempoCurve?: TempoCurvePoint[] | null; } export interface GrooveDetail { @@ -101,14 +267,24 @@ export interface GrooveDetail { hihatSwing: number; kickAccent: number[]; hihatAccent: number[]; + /** Phase 1.C #3: per-drum-group swing across kick (20-200 Hz), snare (200-4000 Hz), and hi-hat (4000-20000 Hz) beat-loudness bands. Same tanh-normalized scale as kickSwing / hihatSwing. */ + perDrumSwing?: { + kick: number; + snare: number; + hihat: number; + } | null; } export interface SidechainDetail { pumpingStrength: number; pumpingRegularity: number; + /** `"quarter" | "eighth" | "sixteenth" | "thirty_second" | null` — added thirty_second in Phase 1.C #6. */ pumpingRate: string | null; pumpingConfidence: number; + /** Median per-bar RMS envelope at 16th-note (16 samples) resolution. */ envelopeShape?: number[] | null; + /** Phase 1.C #6: Median per-bar RMS envelope at 32nd-note (32 samples) resolution. */ + envelopeShape32?: number[] | null; } export interface EffectsDetail { @@ -168,11 +344,27 @@ export interface SegmentKeyEntry { keyConfidence?: number | null; } +/** + * Phase 1.D #2 — temporal chord-progression timeline entry. Each entry + * represents a stable chord region after 5-frame median-filter smoothing; + * regions shorter than ~250 ms are dropped as noise. + */ +export interface ChordTimelineEntry { + startSec: number; + endSec: number; + label: string; + confidence: number; +} + export interface ChordDetail { chordSequence?: string[] | null; chordStrength?: number | null; progression?: string[] | null; dominantChords?: string[] | null; + /** Phase 1.D #2: chord segments with start/end times + per-segment confidence. */ + chordTimeline?: ChordTimelineEntry[] | null; + /** Phase 1.D #2: count of unique chord-to-chord transitions in the smoothed timeline. */ + chordChangeCount?: number | null; } export interface PerceptualDetail { @@ -266,6 +458,19 @@ export interface ReverbDetail { isWet: boolean; tailEnergyRatio: number | null; measured: boolean; + /** + * Phase 1.D #5: RT60 estimated per octave band. + * `low` ≈ 20-250 Hz, `lowMids` ≈ 250-2000 Hz, + * `highMids` ≈ 2000-8000 Hz, `highs` ≈ 8000-16000 Hz. + */ + perBandRt60?: { + low?: number; + lowMids?: number; + highMids?: number; + highs?: number; + } | null; + /** Phase 1.D #5: median pre-delay in milliseconds (time from direct peak to first envelope minimum). */ + preDelayMs?: number | null; } export interface VocalDetail { @@ -274,6 +479,22 @@ export interface VocalDetail { vocalEnergyRatio: number; formantStrength: number; mfccLikelihood: number; + /** + * Demucs-ghost-stem proxy #1: vocals-stem RMS / full-mix RMS. Null when no + * vocals stem was used (full-mix path). Below ~0.05 indicates the vocals + * stem is leakage from a track with no real vocal content; the analyzer + * scales `confidence` down linearly when this is the case. + */ + stemEnergyRatio?: number | null; + /** + * Demucs-ghost-stem proxy #2: Pearson correlation between the vocals stem + * and the "other" stem at a 200 Hz envelope rate. High correlation (>0.3) + * means Demucs is splitting one source (typically a melodic lead) into two + * stems — the "vocals" stem is misclassified content, not a genuine voice. + * The analyzer scales `confidence` down proportionally above 0.3. + * Null when either stem is unavailable or too short to compute. + */ + stemOtherCorrelation?: number | null; } export interface SupersawDetail { @@ -338,6 +559,13 @@ export interface Phase1Result { dynamicSpread?: number | null; dynamicCharacter?: DynamicCharacter | null; textureCharacter?: TextureCharacter | null; + /** + * Per-frame EBU R128 momentary (400 ms window) and short-term (3 s window) + * loudness, downsampled to ~200 points each. Phase 2 cites + * lufsCurve.shortTerm to explain breakdown vs drop loudness contrast. + * Null when LUFS extraction failed. + */ + lufsCurve?: LufsCurve | null; stereoWidth: number; stereoCorrelation: number; stereoDetail?: StereoDetail | null; @@ -351,7 +579,43 @@ export interface Phase1Result { highs: number; brilliance: number; }; + /** + * Sibling time-series partner for spectralBalance. Each row carries all + * seven bands at a given timestamp. Downsampled to ~200 rows on a 2-min + * track so Phase 2 can cite section-relative spectral motion ("the + * high-end opens up at 1:23") instead of static averages alone. + * Sibling rather than nested because spectralBalance's exact 7-key shape + * is asserted by backend tests. + */ + spectralBalanceTimeSeries?: SpectralBalanceTimeSeriesPoint[] | null; spectralDetail?: SpectralDetail | null; + /** + * Phase 1.B per-stem analytical surface (Demucs-separated). Null when + * separation wasn't requested or failed. See {@link StemAnalysis}. + */ + stemAnalysis?: StemAnalysis | null; + /** + * Phase 1.C #1 — per-frequency-band onset density across the 7 + * spectralBalance bands. Each entry carries rate (events/sec), mean + * onset strength, peak, and event count. Phase 2 cites e.g. + * ``transientDensityDetail.highs.onsetRatePerSecond`` for hi-hat + * density claims or ``transientDensityDetail.lowBass`` for kick. + */ + transientDensityDetail?: TransientDensityDetail | null; + /** + * Phase 1.C #5 — saturation / clipping / over-compression hints. + */ + saturationDetail?: SaturationDetail | null; + /** + * Phase 1.C #4 — snare-band character (120-2000 Hz). Uses the drums stem + * when available, otherwise full-mix audio with spectrum-bin selection. + */ + snareDetail?: BandDrumDetail | null; + /** + * Phase 1.C #4 — hi-hat-band character (2000-12000 Hz). meanDecaySeconds + * is a rough open-vs-closed proxy. + */ + hihatDetail?: BandDrumDetail | null; rhythmDetail?: RhythmDetail | null; melodyDetail?: MelodyDetail; transcriptionDetail?: TranscriptionDetail | null; diff --git a/apps/ui/tests/decision_gate.live.test.ts b/apps/ui/tests/decision_gate.live.test.ts new file mode 100644 index 00000000..e590679b --- /dev/null +++ b/apps/ui/tests/decision_gate.live.test.ts @@ -0,0 +1,137 @@ +/** + * One-shot decision-gate runner. + * + * Reads a live Phase 2 snapshot from /tmp/decision_gate_snapshot.json, runs + * the production validator (phase2Validator.ts) over it, and writes a + * markdown report under .runtime/reports/. This file is intentionally + * scoped to a single Phase 1.A gate run — delete after the gate has been + * adjudicated or convert to a permanent harness if useful. + * + * Skip-gates if the snapshot file is missing so the suite still runs clean + * on machines without the snapshot. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import { validatePhase2Consistency } from '../src/services/phase2Validator'; +import type { Phase1Result, Phase2Result } from '../src/types'; + +const SNAPSHOT_PATH = '/tmp/decision_gate_snapshot.json'; +const REPORTS_DIR = path.resolve(__dirname, '../../backend/.runtime/reports'); + +describe('decision-gate (live snapshot)', () => { + if (!existsSync(SNAPSHOT_PATH)) { + it.skip(`skipped — ${SNAPSHOT_PATH} not present`, () => { /* noop */ }); + return; + } + + it('runs the validator on the live Phase 2 snapshot and writes a markdown report', () => { + const snap = JSON.parse(readFileSync(SNAPSHOT_PATH, 'utf8')); + const measurement = snap.stages?.measurement?.result; + const phase1 = (measurement?.phase1 ?? measurement) as Phase1Result; + const interpretation = snap.stages?.interpretation; + const phase2 = (interpretation?.result ?? interpretation?.attemptsSummary?.[0]?.result) as Phase2Result; + const validationWarnings = interpretation?.diagnostics?.validationWarnings ?? []; + + expect(phase1).toBeTruthy(); + expect(phase2).toBeTruthy(); + + const report = validatePhase2Consistency(phase1, phase2, { warnings: validationWarnings }); + + const byType: Record = {}; + for (const v of report.violations) { + byType[v.type] = byType[v.type] || []; + byType[v.type].push(v); + } + + const recCounts = { + mixAndMasterChain: (phase2 as any)?.mixAndMasterChain?.length ?? 0, + abletonRecommendations: (phase2 as any)?.abletonRecommendations?.length ?? 0, + workflowSteps: (phase2 as any)?.secretSauce?.workflowSteps?.length ?? 0, + }; + + // Gate #4 is permissive — see decision_gate.multi.live.test.ts for the + // rationale. + const allRecs = [ + ...((phase2 as any)?.mixAndMasterChain ?? []), + ...((phase2 as any)?.abletonRecommendations ?? []), + ...((phase2 as any)?.secretSauce?.workflowSteps ?? []), + ]; + const citedSet = new Set(); + for (const rec of allRecs) for (const f of (rec?.phase1Fields ?? [])) citedSet.add(String(f)); + const NEW_FIELDS = [ + 'lufsCurve', 'lufsCurve.shortTerm', 'lufsCurve.momentary', + 'spectralBalanceTimeSeries', 'rhythmDetail.tempoCurve', + 'stereoDetail.correlationCurve', 'arrangementDetail.noveltyCurve', + ]; + const newCitedCount = NEW_FIELDS.filter( + (p) => citedSet.has(p) || Array.from(citedSet).some((c) => p.startsWith(`${c}.`)) + ).length; + const usefulUncited = (byType['NEW_FIELD_UNCITED'] ?? []).length; + const newFieldsOK = usefulUncited === 0 || newCitedCount >= 1; + + const gateCriteria = { + '1. zero RECOMMENDATION_SALVAGED': (byType['RECOMMENDATION_SALVAGED'] ?? []).length === 0, + '2. zero MISSING_CITATION': (byType['MISSING_CITATION'] ?? []).length === 0, + '3. non-trivial citation diversity (no TRIVIAL_CITATIONS)': (byType['TRIVIAL_CITATIONS'] ?? []).length === 0, + '4. at least one new Phase 1.A field cited (or none meaningful)': newFieldsOK, + '5. low-confidence text is hedged (no LOW_CONFIDENCE_NOT_HEDGED)': (byType['LOW_CONFIDENCE_NOT_HEDGED'] ?? []).length === 0, + }; + const gatePassed = Object.values(gateCriteria).every(Boolean); + + const lines: string[] = []; + lines.push(`# Phase 1.A Decision Gate — ${new Date().toISOString()}`); + lines.push(''); + lines.push(`- runId: \`${snap.runId}\``); + lines.push(`- track: \`apps/ui/public/demo.mp3\` (${phase1?.durationSeconds}s, ${phase1?.bpm} BPM, key ${phase1?.key})`); + lines.push(`- mode: full DSP + Gemini interpretation (no stem separation, no transcription)`); + lines.push(`- recommendation counts: \`mixAndMasterChain=${recCounts.mixAndMasterChain} abletonRecommendations=${recCounts.abletonRecommendations} workflowSteps=${recCounts.workflowSteps}\` (total ${recCounts.mixAndMasterChain + recCounts.abletonRecommendations + recCounts.workflowSteps})`); + lines.push(''); + lines.push('## Gate verdict'); + lines.push(''); + lines.push(gatePassed ? '**PASSED** ✓ all five criteria met.' : '**FAILED** — one or more gate criteria failed (see below).'); + lines.push(''); + lines.push('| # | Criterion | Result |'); + lines.push('|---|---|---|'); + Object.entries(gateCriteria).forEach(([k, ok]) => { + lines.push(`| ${k.split('.')[0]} | ${k.split('.').slice(1).join('.').trim()} | ${ok ? '✓ pass' : '✗ fail'} |`); + }); + lines.push(''); + lines.push(`## Validator summary`); + lines.push(''); + lines.push(`- total violations: **${report.violations.length}** (errors=${report.summary.errorCount}, warnings=${report.summary.warningCount})`); + lines.push(`- backend validationWarnings on the run: **${validationWarnings.length}**`); + lines.push(''); + lines.push('## Violations by type'); + lines.push(''); + for (const [type, vs] of Object.entries(byType).sort()) { + lines.push(`### ${type} (${vs.length})`); + lines.push(''); + for (const v of vs.slice(0, 8)) { + lines.push(`- **${v.severity}** \`${v.field}\` — ${v.message}`); + } + if (vs.length > 8) lines.push(`- ...and ${vs.length - 8} more`); + lines.push(''); + } + if (validationWarnings.length > 0) { + lines.push('## Raw backend validationWarnings'); + lines.push(''); + for (const w of validationWarnings) { + lines.push(`- **${w.code}** at \`${w.path}\`: ${w.message ?? ''}`); + if (w.dropReason) lines.push(` - dropReason: \`${w.dropReason}\``); + } + lines.push(''); + } + + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + mkdirSync(REPORTS_DIR, { recursive: true }); + const reportPath = path.join(REPORTS_DIR, `decision_gate_${stamp}.md`); + writeFileSync(reportPath, lines.join('\n'), 'utf8'); + console.log(`decision-gate report written to ${reportPath}`); + console.log(`gatePassed=${gatePassed}; violations=${report.violations.length}`); + + // Don't fail the test on a gate fail — this is a diagnostic runner. The + // assertion just ensures the runner produced a report. + expect(existsSync(reportPath)).toBe(true); + }); +}); diff --git a/apps/ui/tests/decision_gate.multi.live.test.ts b/apps/ui/tests/decision_gate.multi.live.test.ts new file mode 100644 index 00000000..7abf2c95 --- /dev/null +++ b/apps/ui/tests/decision_gate.multi.live.test.ts @@ -0,0 +1,231 @@ +/** + * Multi-model decision-gate comparator. + * + * Loads /tmp/decision_gate_.json for each Gemini variant we tested + * against demo.mp3, runs the validator on each, and emits a side-by-side + * markdown comparison so we can see whether prompt fixes are universal or + * the weakest model is the bottleneck. + * + * Skips when no snapshots are present, so the suite stays clean elsewhere. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import { validatePhase2Consistency } from '../src/services/phase2Validator'; +import type { Phase1Result, Phase2Result } from '../src/types'; + +const MODELS = [ + 'gemini-2.5-flash', + 'gemini-3-flash-preview', + 'gemini-3-pro-preview', + 'gemini-3.1-flash-preview', + 'gemini-3.1-pro-preview', +]; + +const REPORTS_DIR = path.resolve(__dirname, '../../backend/.runtime/reports'); + +interface ModelGate { + model: string; + snapshotPath: string; + ok: boolean; + reason?: string; + bpm?: number | string; + key?: string | null; + recCounts?: { mmc: number; ar: number; ws: number; total: number }; + violationsByType?: Record; + topMissingCitation?: string[]; + topNewFieldUncited?: string[]; + topSalvaged?: string[]; + gateCriteria?: Record; + gatePassed?: boolean; + newFieldsCitedAt?: string[]; + invalidWorkflowStage?: boolean; + interpretationStatus?: string; + errorCode?: string; +} + +function gateFor(model: string): ModelGate { + const snapshotPath = `/tmp/decision_gate_${model}.json`; + if (!existsSync(snapshotPath)) { + return { model, snapshotPath, ok: false, reason: 'snapshot not present' }; + } + let snap: any; + try { + snap = JSON.parse(readFileSync(snapshotPath, 'utf8')); + } catch (exc) { + return { model, snapshotPath, ok: false, reason: `parse error: ${exc}` }; + } + const interpretation = snap?.stages?.interpretation ?? {}; + const interpretationStatus = interpretation.status; + const measurement = snap?.stages?.measurement?.result; + const phase1 = (measurement?.phase1 ?? measurement) as Phase1Result | undefined; + const phase2 = (interpretation?.result ?? interpretation?.attemptsSummary?.[0]?.result) as Phase2Result | undefined; + if (!phase1) { + return { model, snapshotPath, ok: false, reason: 'no phase1', interpretationStatus }; + } + if (!phase2) { + const errCode = interpretation?.error?.code; + return { + model, snapshotPath, ok: false, + reason: errCode || 'no phase2 result', + interpretationStatus, errorCode: errCode, + bpm: phase1?.bpm, key: phase1?.key, + }; + } + const validationWarnings = interpretation?.diagnostics?.validationWarnings ?? []; + const report = validatePhase2Consistency(phase1, phase2, { warnings: validationWarnings }); + const byType: Record = {}; + const samplesByType: Record = {}; + for (const v of report.violations) { + byType[v.type] = (byType[v.type] || 0) + 1; + samplesByType[v.type] = samplesByType[v.type] || []; + if (samplesByType[v.type].length < 3) { + samplesByType[v.type].push(`${v.field}: ${v.message.slice(0, 110)}…`); + } + } + const mmc = (phase2 as any)?.mixAndMasterChain || []; + const ar = (phase2 as any)?.abletonRecommendations || []; + const ws = (phase2 as any)?.secretSauce?.workflowSteps || []; + const recCounts = { mmc: mmc.length, ar: ar.length, ws: ws.length, total: mmc.length + ar.length + ws.length }; + + const allRecs = [...mmc, ...ar, ...ws]; + const citedPaths = new Set(); + for (const rec of allRecs) { + for (const f of (rec?.phase1Fields || [])) citedPaths.add(String(f)); + } + const NEW_FIELDS = [ + 'lufsCurve', + 'lufsCurve.shortTerm', + 'lufsCurve.momentary', + 'spectralBalanceTimeSeries', + 'rhythmDetail.tempoCurve', + 'stereoDetail.correlationCurve', + 'arrangementDetail.noveltyCurve', + ]; + const newFieldsCitedAt = NEW_FIELDS.filter((p) => citedPaths.has(p) || Array.from(citedPaths).some((c) => p.startsWith(`${c}.`))); + + const invalidWorkflowStage = validationWarnings.some( + (w: any) => typeof w?.dropReason === 'string' && w.dropReason.toLowerCase().includes('workflowstage'), + ); + + // Gate #4 is permissive: PASS as long as Gemini cites at least one new + // field OR no new field has useful density. We fail only when >=1 dense + // new fields exist AND zero are cited — the "Gemini ignored the depth + // family" case. + const usefulUncited = byType['NEW_FIELD_UNCITED'] ?? 0; + const totalNewCited = newFieldsCitedAt.length; + const newFieldsOK = usefulUncited === 0 || totalNewCited >= 1; + + const gateCriteria = { + '1. zero RECOMMENDATION_SALVAGED': (byType['RECOMMENDATION_SALVAGED'] ?? 0) === 0, + '2. zero MISSING_CITATION': (byType['MISSING_CITATION'] ?? 0) === 0, + '3. citation diversity OK': (byType['TRIVIAL_CITATIONS'] ?? 0) === 0, + '4. at least one new Phase 1.A field cited (or none meaningful)': newFieldsOK, + '5. low-confidence text hedged': (byType['LOW_CONFIDENCE_NOT_HEDGED'] ?? 0) === 0, + }; + const gatePassed = Object.values(gateCriteria).every(Boolean); + + return { + model, snapshotPath, ok: true, + bpm: phase1?.bpm, key: phase1?.key, + recCounts, + violationsByType: byType, + topMissingCitation: samplesByType['MISSING_CITATION'] ?? [], + topNewFieldUncited: samplesByType['NEW_FIELD_UNCITED'] ?? [], + topSalvaged: samplesByType['RECOMMENDATION_SALVAGED'] ?? [], + gateCriteria, gatePassed, + newFieldsCitedAt, + invalidWorkflowStage, + interpretationStatus, + }; +} + +describe('decision-gate (multi-model)', () => { + it('compares validator output across Gemini variants and writes a markdown table', () => { + const gates = MODELS.map(gateFor); + const ok = gates.filter((g) => g.ok); + expect(ok.length).toBeGreaterThan(0); + + const lines: string[] = []; + lines.push(`# Phase 1.A Decision Gate — Multi-model comparison`); + lines.push(`Generated: ${new Date().toISOString()}`); + lines.push(''); + lines.push(`Track: \`apps/ui/public/demo.mp3\` (1.2s synthetic demo). Profile: \`producer_summary\`. No stems, no transcription.`); + lines.push(''); + lines.push('## Verdict summary'); + lines.push(''); + lines.push('| Model | Status | Total recs | Errors | Warnings | Salvaged | Missing cite | New-fld uncited | New-fld cited | Gate |'); + lines.push('|---|---|---|---|---|---|---|---|---|---|'); + for (const g of gates) { + if (!g.ok) { + lines.push(`| ${g.model} | ${g.reason ?? 'n/a'} | — | — | — | — | — | — | — | ✗ |`); + continue; + } + const v = g.violationsByType!; + const total = g.recCounts!.total; + const newCited = g.newFieldsCitedAt!.length; + const verdict = g.gatePassed ? '✓ PASS' : '✗ FAIL'; + lines.push( + `| ${g.model} | ${g.interpretationStatus} | ${total} (mmc=${g.recCounts!.mmc} ar=${g.recCounts!.ar} ws=${g.recCounts!.ws}) | ${v['MISSING_CITATION']??0 + (v['LOW_CONFIDENCE_NOT_HEDGED']??0) + (v['RECOMMENDATION_SALVAGED']??0)} | ${Object.entries(v).filter(([k])=>!['MISSING_CITATION','LOW_CONFIDENCE_NOT_HEDGED','RECOMMENDATION_SALVAGED'].includes(k)).reduce((s,[,n])=>s+n,0)} | ${v['RECOMMENDATION_SALVAGED']??0} | ${v['MISSING_CITATION']??0} | ${v['NEW_FIELD_UNCITED']??0}/7 | ${newCited}/7 (${g.newFieldsCitedAt!.join(', ')||'none'}) | ${verdict} |`, + ); + } + lines.push(''); + lines.push('## Failure patterns by model'); + lines.push(''); + for (const g of gates) { + lines.push(`### ${g.model}`); + if (!g.ok) { + lines.push(`- skipped: ${g.reason ?? 'n/a'}${g.errorCode ? ` (\`${g.errorCode}\`)` : ''}`); + lines.push(''); + continue; + } + lines.push(`- gate: **${g.gatePassed ? 'PASS' : 'FAIL'}**`); + lines.push(`- recommendation counts: \`mmc=${g.recCounts!.mmc} ar=${g.recCounts!.ar} ws=${g.recCounts!.ws}\` (total ${g.recCounts!.total})`); + lines.push(`- invalid workflowStage hallucinated: ${g.invalidWorkflowStage ? 'YES' : 'no'}`); + lines.push(`- new Phase 1.A fields cited (out of 7 paths): \`${g.newFieldsCitedAt!.join(', ') || 'NONE'}\``); + if (g.topSalvaged!.length) { + lines.push(`- RECOMMENDATION_SALVAGED examples:`); + for (const s of g.topSalvaged!) lines.push(` - ${s}`); + } + if (g.topMissingCitation!.length) { + lines.push(`- MISSING_CITATION examples (invented paths):`); + for (const s of g.topMissingCitation!) lines.push(` - ${s}`); + } + lines.push(''); + } + + lines.push('## Cross-model failure pattern table'); + lines.push(''); + const ALL_TYPES = ['RECOMMENDATION_SALVAGED','MISSING_CITATION','NEW_FIELD_UNCITED','TRIVIAL_CITATIONS','LOW_CONFIDENCE_NOT_HEDGED','GENRE_IGNORES_DSP','BOUNDS_VIOLATION','NUMERIC_OVERRIDE']; + lines.push('| Violation type | ' + ok.map((g) => g.model.replace('gemini-', '').replace('-preview','')).join(' | ') + ' |'); + lines.push('|---|' + ok.map(() => '---').join('|') + '|'); + for (const t of ALL_TYPES) { + const row = ok.map((g) => g.violationsByType?.[t] ?? 0).join(' | '); + lines.push(`| ${t} | ${row} |`); + } + lines.push(''); + + lines.push('## Prompt-change implications'); + lines.push(''); + const issues = []; + if (ok.some((g) => g.invalidWorkflowStage)) issues.push('- **workflowStage hallucination** appears on at least one model — prompt enum reminder is needed regardless of model.'); + if (ok.every((g) => (g.violationsByType?.['NEW_FIELD_UNCITED'] ?? 0) > 0)) issues.push('- **Every model ignored the new Phase 1.A fields** — they need to be enumerated in the prompt; this is not a model-strength issue.'); + if (ok.every((g) => (g.violationsByType?.['MISSING_CITATION'] ?? 0) > 0)) issues.push('- **Every model invented at least one citation path** — the prompt rule about verifying paths against the payload is not strong enough; needs a "before emitting, the path must resolve" gate.'); + else if (ok.some((g) => (g.violationsByType?.['MISSING_CITATION'] ?? 0) > 0)) issues.push('- **Some models invented citation paths but not all** — model-dependent; the better-model option is to require it via prompt; the weaker-model option is to also add server-side citation validation against the payload schema.'); + if (ok.every((g) => (g.violationsByType?.['RECOMMENDATION_SALVAGED'] ?? 0) > 0)) issues.push('- **Every model triggered salvage** — backend hardening + prompt enum reminders together.'); + else if (ok.some((g) => (g.violationsByType?.['RECOMMENDATION_SALVAGED'] ?? 0) > 0)) issues.push('- **Some models triggered salvage but not all** — primarily a prompt issue; a stronger model would obviate.'); + if (issues.length === 0) issues.push('- No universal failures across models; prompt may be acceptable as-is.'); + for (const i of issues) lines.push(i); + lines.push(''); + + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + mkdirSync(REPORTS_DIR, { recursive: true }); + const out = path.join(REPORTS_DIR, `decision_gate_multi_${stamp}.md`); + writeFileSync(out, lines.join('\n'), 'utf8'); + console.log(`multi-model report written to ${out}`); + for (const g of gates) { + console.log(` ${g.model}: ${g.ok ? (g.gatePassed ? 'PASS' : 'FAIL') : g.reason}`); + } + expect(existsSync(out)).toBe(true); + }); +}); diff --git a/apps/ui/tests/decision_gate.real.live.test.ts b/apps/ui/tests/decision_gate.real.live.test.ts new file mode 100644 index 00000000..beea2228 --- /dev/null +++ b/apps/ui/tests/decision_gate.real.live.test.ts @@ -0,0 +1,224 @@ +/** + * Real-track multi-model decision-gate comparator — NO-STEM path. + * + * v3.1: shares the PHASE1_NEW_FIELD_PATHS constant and the bidirectional + + * wildcard `pathCoversTracked` matcher with phase2Validator.ts. Splits report + * metrics into Phase 1.A vs Phase 1.C/D buckets, matching the stems-mode + * comparator's shape so v3.1 numbers are directly comparable across modes. + * + * Reads /tmp/decision_gate_real_.json snapshots produced by + * /tmp/decision_gate_real.py (which uses `pitch_note_mode="off"` so + * `stemAnalysis` is absent — exercising the no-stem path the prompt's STEM + * AVAILABILITY GATE governs). + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import { + validatePhase2Consistency, + pathCoversTracked, + PHASE1_NEW_FIELD_PATHS, + PHASE1A_FIELD_PATHS, + PHASE1_CD_FIELD_PATHS, +} from '../src/services/phase2Validator'; +import type { Phase1Result, Phase2Result } from '../src/types'; + +const MODELS = [ + 'gemini-2.5-flash', + 'gemini-3-flash-preview', + 'gemini-3-pro-preview', + 'gemini-3.1-pro-preview', +]; +const SNAPSHOT_PREFIX = '/tmp/decision_gate_real_'; +const REPORTS_DIR = path.resolve(__dirname, '../../backend/.runtime/reports'); + +function bucketCited(bucket: readonly string[], cited: Iterable): string[] { + const out: string[] = []; + for (const tracked of bucket) { + for (const c of cited) { + if (pathCoversTracked(c, tracked)) { + out.push(tracked); + break; + } + } + } + return out; +} + +function bucketApplicable(bucket: readonly string[], phase1: any): string[] { + return bucket.filter((p) => isPathPresentShallow(phase1, p)); +} + +function isPathPresentShallow(payload: any, p: string): boolean { + if (p.includes('*')) { + const parts = p.split('.'); + const starIndex = parts.indexOf('*'); + let cursor: any = payload; + for (let i = 0; i < starIndex; i++) { + if (cursor === null || cursor === undefined) return false; + if (typeof cursor !== 'object') return false; + const part = parts[i]; + if (!(part in cursor)) return false; + cursor = cursor[part]; + } + if (!cursor || typeof cursor !== 'object') return false; + const remainder = parts.slice(starIndex + 1).join('.'); + if (remainder.length === 0) return Object.keys(cursor).length > 0; + return Object.values(cursor).some((child) => isPathPresentShallow(child, remainder)); + } + let cursor: any = payload; + for (const part of p.split('.')) { + if (cursor === null || cursor === undefined) return false; + if (typeof cursor !== 'object') return false; + if (!(part in cursor)) return false; + cursor = cursor[part]; + } + return cursor !== null && cursor !== undefined; +} + +describe('decision-gate (multi-model, real track)', () => { + it('compares validator output across Gemini variants on Vtss-CantCatchMe.mp3', () => { + const gates = MODELS.map((model) => { + const snapshotPath = `${SNAPSHOT_PREFIX}${model}.json`; + if (!existsSync(snapshotPath)) return { model, ok: false, reason: 'snapshot missing' }; + let snap: any; + try { snap = JSON.parse(readFileSync(snapshotPath, 'utf8')); } + catch (exc) { return { model, ok: false, reason: `parse: ${exc}` }; } + const interpretation = snap?.stages?.interpretation ?? {}; + const measurement = snap?.stages?.measurement?.result; + const phase1 = (measurement?.phase1 ?? measurement) as Phase1Result | undefined; + const phase2 = (interpretation?.result ?? interpretation?.attemptsSummary?.[0]?.result) as Phase2Result | undefined; + if (!phase1 || !phase2) { + return { model, ok: false, reason: interpretation?.error?.code || 'no phase2', interpretationStatus: interpretation?.status }; + } + const validationWarnings = interpretation?.diagnostics?.validationWarnings ?? []; + const report = validatePhase2Consistency(phase1, phase2, { warnings: validationWarnings }); + const byType: Record = {}; + const samplesByType: Record = {}; + for (const v of report.violations) { + byType[v.type] = (byType[v.type] || 0) + 1; + samplesByType[v.type] = samplesByType[v.type] || []; + if (samplesByType[v.type].length < 3) { + samplesByType[v.type].push(`${v.field}: ${v.message.slice(0, 120)}…`); + } + } + const mmc = (phase2 as any)?.mixAndMasterChain || []; + const ar = (phase2 as any)?.abletonRecommendations || []; + const ws = (phase2 as any)?.secretSauce?.workflowSteps || []; + const allRecs = [...mmc, ...ar, ...ws]; + const cited = new Set(); + for (const rec of allRecs) for (const f of (rec?.phase1Fields || [])) cited.add(String(f)); + + // Same shape as the stems comparator: split A vs C/D, use the + // bidirectional+wildcard matcher, report applicable-vs-cited fractions. + // For the no-stem path, `stemAnalysis.*` tracked paths will report as + // "not applicable" (the payload doesn't contain stems) so the gate + // criterion is automatically gated. + const newCitedA = bucketCited(PHASE1A_FIELD_PATHS, cited); + const newCitedCD = bucketCited(PHASE1_CD_FIELD_PATHS, cited); + const newCited = bucketCited(PHASE1_NEW_FIELD_PATHS, cited); + const applicableA = bucketApplicable(PHASE1A_FIELD_PATHS, phase1); + const applicableCD = bucketApplicable(PHASE1_CD_FIELD_PATHS, phase1); + const stemCited = Array.from(cited).filter((c) => c.startsWith('stemAnalysis.')); + const stemAnalysisPresent = !!(phase1 as any).stemAnalysis; + + const newFieldsOK_CD = applicableCD.length === 0 || newCitedCD.length >= 1; + const newFieldsOK_A = applicableA.length === 0 || newCitedA.length >= 1; + const gateCriteria = { + '1. zero RECOMMENDATION_SALVAGED': (byType['RECOMMENDATION_SALVAGED'] ?? 0) === 0, + '2. zero MISSING_CITATION': (byType['MISSING_CITATION'] ?? 0) === 0, + '3. citation diversity OK': (byType['TRIVIAL_CITATIONS'] ?? 0) === 0, + '4. Phase 1.A field cited when any are present': newFieldsOK_A, + '5. low-confidence text hedged': (byType['LOW_CONFIDENCE_NOT_HEDGED'] ?? 0) === 0, + '6. Phase 1.C/D field cited when any are present': newFieldsOK_CD, + }; + const gatePassed = Object.values(gateCriteria).every(Boolean); + return { + model, ok: true, + bpm: phase1?.bpm, key: phase1?.key, durationSeconds: phase1?.durationSeconds, + recCounts: { mmc: mmc.length, ar: ar.length, ws: ws.length, total: allRecs.length }, + violationsByType: byType, + samples: samplesByType, + newCited, newCitedCount: newCited.length, + newCitedA, newCitedCountA: newCitedA.length, applicableA, applicableCountA: applicableA.length, + newCitedCD, newCitedCountCD: newCitedCD.length, applicableCD, applicableCountCD: applicableCD.length, + stemCited, stemAnalysisPresent, + gateCriteria, gatePassed, + validationWarnings, + interpretationStatus: interpretation?.status, + }; + }); + const ok = gates.filter((g: any) => g.ok); + expect(ok.length).toBeGreaterThan(0); + + const lines: string[] = []; + lines.push('# Phase 1.B+1.C+1.D Decision Gate v3.1 — NO-STEM path multi-model on real track'); + lines.push(`Generated: ${new Date().toISOString()}`); + lines.push(''); + const first: any = ok[0]; + lines.push(`Track: \`apps/backend/tests/fixtures/bench_tracks/Vtss-CantCatchMe.mp3\` (~${Math.round(first.durationSeconds)}s, ${first.bpm} BPM, key ${first.key}).`); + lines.push(`Profile: \`producer_summary\`. Mode: no stems, no transcription (pitch_note_mode=off).`); + lines.push(''); + lines.push(`Tracked Phase 1 paths: **Phase 1.A** = ${PHASE1A_FIELD_PATHS.length}, **Phase 1.C/D** = ${PHASE1_CD_FIELD_PATHS.length} (combined ${PHASE1_NEW_FIELD_PATHS.length}). Stem-scoped paths (\`stemAnalysis.*.*\`) are correctly absent from "applicable" counts in this no-stem run.`); + lines.push(''); + lines.push('## Verdict summary'); + lines.push(''); + lines.push('| Model | Status | Recs | Err | Warn | Salvg | Missing | NewUncited | A-cited | C/D-cited | Gate |'); + lines.push('|---|---|---|---|---|---|---|---|---|---|---|'); + for (const g of gates as any[]) { + if (!g.ok) { lines.push(`| ${g.model} | ${g.reason} | — | — | — | — | — | — | — | — | ✗ |`); continue; } + const v = g.violationsByType; + const errors = (v['MISSING_CITATION']??0) + (v['LOW_CONFIDENCE_NOT_HEDGED']??0) + (v['RECOMMENDATION_SALVAGED']??0); + const warningsCount = Object.entries(v).filter(([k]) => !['MISSING_CITATION','LOW_CONFIDENCE_NOT_HEDGED','RECOMMENDATION_SALVAGED'].includes(k)).reduce((s, [,n]) => s + (n as number), 0); + const verdict = g.gatePassed ? '✓ PASS' : '✗ FAIL'; + const aFrac = `${g.newCitedCountA}/${g.applicableCountA}`; + const cdFrac = `${g.newCitedCountCD}/${g.applicableCountCD}`; + lines.push(`| ${g.model} | ${g.interpretationStatus} | ${g.recCounts.total} | ${errors} | ${warningsCount} | ${v['RECOMMENDATION_SALVAGED']??0} | ${v['MISSING_CITATION']??0} | ${v['NEW_FIELD_UNCITED']??0} | ${aFrac} | ${cdFrac} | ${verdict} |`); + } + lines.push(''); + lines.push('## Cross-model failure pattern table'); + lines.push(''); + const ALL_TYPES = ['RECOMMENDATION_SALVAGED','MISSING_CITATION','NEW_FIELD_UNCITED','TRIVIAL_CITATIONS','LOW_CONFIDENCE_NOT_HEDGED','GENRE_IGNORES_DSP','BOUNDS_VIOLATION','NUMERIC_OVERRIDE']; + lines.push('| Violation type | ' + ok.map((g: any) => g.model.replace('gemini-','').replace('-preview','')).join(' | ') + ' |'); + lines.push('|---|' + ok.map(() => '---').join('|') + '|'); + for (const t of ALL_TYPES) { + const row = ok.map((g: any) => g.violationsByType[t] ?? 0).join(' | '); + lines.push(`| ${t} | ${row} |`); + } + lines.push(''); + + for (const g of ok as any[]) { + lines.push(`## ${g.model}`); + lines.push(`- gate: **${g.gatePassed ? 'PASS' : 'FAIL'}**`); + lines.push(`- Phase 1.A fields cited (${g.newCitedCountA}/${g.applicableCountA}): \`${g.newCitedA.join(', ') || 'NONE'}\``); + lines.push(`- Phase 1.C/D fields cited (${g.newCitedCountCD}/${g.applicableCountCD}): \`${g.newCitedCD.join(', ') || 'NONE'}\``); + lines.push(`- Phase 1.C/D paths applicable but uncited: \`${g.applicableCD.filter((p: string) => !g.newCitedCD.includes(p)).join(', ') || 'NONE'}\``); + if (g.stemCited.length > 0) { + lines.push(`- ⚠ stemAnalysis paths cited (${g.stemCited.length}) — UNEXPECTED in no-stem mode: \`${g.stemCited.slice(0, 8).join(', ')}\``); + } + for (const t of ['RECOMMENDATION_SALVAGED', 'MISSING_CITATION', 'LOW_CONFIDENCE_NOT_HEDGED', 'TRIVIAL_CITATIONS']) { + const samples = g.samples[t] ?? []; + if (samples.length === 0) continue; + lines.push(`- ${t} (${samples.length} shown):`); + for (const s of samples) lines.push(` - ${s}`); + } + if (g.validationWarnings.length > 0) { + lines.push(`- backend validationWarnings: ${g.validationWarnings.length}`); + for (const w of g.validationWarnings.slice(0, 3)) { + lines.push(` - **${w.code}** at \`${w.path}\`: ${w.message ?? ''}`); + } + } + lines.push(''); + } + + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + mkdirSync(REPORTS_DIR, { recursive: true }); + const out = path.join(REPORTS_DIR, `decision_gate_real_${stamp}.md`); + writeFileSync(out, lines.join('\n'), 'utf8'); + console.log(`real-track decision-gate report at ${out}`); + for (const g of gates as any[]) { + console.log(` ${g.model}: ${g.ok ? (g.gatePassed ? 'PASS' : 'FAIL') : g.reason}`); + } + expect(existsSync(out)).toBe(true); + }); +}); diff --git a/apps/ui/tests/decision_gate.stems.live.test.ts b/apps/ui/tests/decision_gate.stems.live.test.ts new file mode 100644 index 00000000..abd57f79 --- /dev/null +++ b/apps/ui/tests/decision_gate.stems.live.test.ts @@ -0,0 +1,233 @@ +/** + * Stem-aware multi-model decision gate comparator (v3). + * + * v3 changes vs v2: + * - Imports the tracked-paths list directly from `phase2Validator.ts` + * (`PHASE1_NEW_FIELD_PATHS`) so the two stay in sync. + * - Reports metrics split into Phase 1.A vs Phase 1.C/D buckets, so v3 is + * fairly comparable to v2 (v2 only had Phase 1.A fields available). + * - `newFieldsOK` is tightened: when Phase 1.C/D fields are present in the + * payload, the gate REQUIRES at least one Phase 1.C/D citation rather + * than letting "no warnings" trivially pass. The v2 escape hatch + * (`usefulUncited === 0 || newCited.length >= 1`) was too easy to satisfy + * once the tracked list went stale. + * - Uses `pathCoversTracked` for bidirectional + wildcard matching so a + * citation to `grooveDetail.perDrumSwing.snare` correctly satisfies the + * tracked parent `grooveDetail.perDrumSwing`, and a citation to + * `stemAnalysis.drums.reverbDetail.preDelayMs` satisfies + * `stemAnalysis.*.reverbDetail`. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import { + validatePhase2Consistency, + pathCoversTracked, + PHASE1_NEW_FIELD_PATHS, + PHASE1A_FIELD_PATHS, + PHASE1_CD_FIELD_PATHS, +} from '../src/services/phase2Validator'; +import type { Phase1Result, Phase2Result } from '../src/types'; + +const MODELS = ['gemini-2.5-flash','gemini-3-flash-preview','gemini-3-pro-preview','gemini-3.1-pro-preview']; +const PREFIX = '/tmp/decision_gate_stems_'; +const REPORTS_DIR = path.resolve(__dirname, '../../backend/.runtime/reports'); + +// Helper: did any citation cover any path in the bucket using the validator's +// own matching rules? Mirrors validateNewFieldCoverage so a citation to a +// leaf/wildcard sub-path counts. +function bucketCited(bucket: readonly string[], cited: Iterable): string[] { + const out: string[] = []; + for (const tracked of bucket) { + for (const c of cited) { + if (pathCoversTracked(c, tracked)) { + out.push(tracked); + break; + } + } + } + return out; +} + +// Helper: a tracked path is "applicable" to this run iff the Phase 1 payload +// actually populates it (the validator already does this, but the comparator +// needs to know which paths to expect citations for in the gate criterion). +function bucketApplicable(bucket: readonly string[], phase1: any): string[] { + return bucket.filter((p) => isPathPresentShallow(phase1, p)); +} + +function isPathPresentShallow(payload: any, p: string): boolean { + // Mirror of phase2Validator.isPathPresentAndUseful, but lighter — we only + // care whether the path navigates somewhere non-null. The full + // density-aware check lives in the validator; here it suffices that the + // analyzer EMITTED the field for this track so a citation would be valid. + if (p.includes('*')) { + const parts = p.split('.'); + const starIndex = parts.indexOf('*'); + let cursor: any = payload; + for (let i = 0; i < starIndex; i++) { + if (cursor === null || cursor === undefined) return false; + if (typeof cursor !== 'object') return false; + const part = parts[i]; + if (!(part in cursor)) return false; + cursor = cursor[part]; + } + if (!cursor || typeof cursor !== 'object') return false; + const remainder = parts.slice(starIndex + 1).join('.'); + if (remainder.length === 0) return Object.keys(cursor).length > 0; + return Object.values(cursor).some((child) => isPathPresentShallow(child, remainder)); + } + let cursor: any = payload; + for (const part of p.split('.')) { + if (cursor === null || cursor === undefined) return false; + if (typeof cursor !== 'object') return false; + if (!(part in cursor)) return false; + cursor = cursor[part]; + } + return cursor !== null && cursor !== undefined; +} + +describe('decision-gate (multi-model, stem-aware, real track)', () => { + it('compares per-model validator output with stemAnalysis populated', () => { + const gates = MODELS.map((model) => { + const p = `${PREFIX}${model}.json`; + if (!existsSync(p)) return { model, ok: false, reason: 'missing' }; + const snap = JSON.parse(readFileSync(p,'utf8')); + const interpretation = snap?.stages?.interpretation ?? {}; + const measurement = snap?.stages?.measurement?.result; + const phase1 = (measurement?.phase1 ?? measurement) as Phase1Result | undefined; + const phase2 = (interpretation?.result ?? interpretation?.attemptsSummary?.[0]?.result) as Phase2Result | undefined; + if (!phase1 || !phase2) return { model, ok: false, reason: interpretation?.error?.code || 'no phase2' }; + + const validationWarnings = interpretation?.diagnostics?.validationWarnings ?? []; + const report = validatePhase2Consistency(phase1, phase2, { warnings: validationWarnings }); + const byType: Record = {}; + const samples: Record = {}; + for (const v of report.violations) { + byType[v.type] = (byType[v.type] || 0) + 1; + samples[v.type] = samples[v.type] || []; + if (samples[v.type].length < 3) samples[v.type].push(`${v.field}: ${v.message.slice(0,110)}…`); + } + const mmc = (phase2 as any)?.mixAndMasterChain || []; + const ar = (phase2 as any)?.abletonRecommendations || []; + const ws = (phase2 as any)?.secretSauce?.workflowSteps || []; + const allRecs = [...mmc,...ar,...ws]; + const cited = new Set(); + for (const r of allRecs) for (const f of (r?.phase1Fields || [])) cited.add(String(f)); + + // Split the tracked-paths citation tally into Phase 1.A vs Phase 1.C/D + // buckets so v3 vs v2 is fairly comparable (v2 only had Phase 1.A fields + // available to be cited). Uses pathCoversTracked so leaf citations + // satisfy parent tracked paths and stem-scoped wildcards work. + const newCitedA = bucketCited(PHASE1A_FIELD_PATHS, cited); + const newCitedCD = bucketCited(PHASE1_CD_FIELD_PATHS, cited); + const newCited = bucketCited(PHASE1_NEW_FIELD_PATHS, cited); + const applicableCD = bucketApplicable(PHASE1_CD_FIELD_PATHS, phase1); + const applicableA = bucketApplicable(PHASE1A_FIELD_PATHS, phase1); + const stemCited = Array.from(cited).filter((c) => c.startsWith('stemAnalysis.')); + const stemAnalysisPresent = !!(phase1 as any).stemAnalysis; + + // Tightened gate criterion: when Phase 1.C/D fields are actually + // populated in the payload, the gate REQUIRES at least one Phase 1.C/D + // citation — the "no warnings = pass" escape hatch is removed. If no + // Phase 1.C/D fields are populated (e.g. degenerate fixture), the + // criterion no-ops and the rest of the gate still runs. + const newFieldsOK_CD = applicableCD.length === 0 || newCitedCD.length >= 1; + // Phase 1.A criterion preserved for backward-compat (used to be the only + // one — kept for the report's diff against v2). + const usefulUncited = byType['NEW_FIELD_UNCITED'] ?? 0; + const newFieldsOK_A = usefulUncited === 0 || newCited.length >= 1; + + const stemsOK = !stemAnalysisPresent || stemCited.length >= 1; // when stems exist, expect at least 1 stem citation + const gateCriteria = { + '1. zero RECOMMENDATION_SALVAGED': (byType['RECOMMENDATION_SALVAGED'] ?? 0) === 0, + '2. zero MISSING_CITATION': (byType['MISSING_CITATION'] ?? 0) === 0, + '3. citation diversity OK': (byType['TRIVIAL_CITATIONS'] ?? 0) === 0, + '4. new Phase 1.A field cited (or none meaningful)': newFieldsOK_A, + '5. low-confidence text hedged': (byType['LOW_CONFIDENCE_NOT_HEDGED'] ?? 0) === 0, + '6. stemAnalysis paths cited when available': stemsOK, + '7. Phase 1.C/D field cited when any are present': newFieldsOK_CD, + }; + const gatePassed = Object.values(gateCriteria).every(Boolean); + return { + model, ok: true, + bpm: phase1?.bpm, key: phase1?.key, duration: phase1?.durationSeconds, + recs: { mmc: mmc.length, ar: ar.length, ws: ws.length, total: allRecs.length }, + byType, samples, + newCited, newCitedCount: newCited.length, + newCitedA, newCitedCountA: newCitedA.length, applicableA, applicableCountA: applicableA.length, + newCitedCD, newCitedCountCD: newCitedCD.length, applicableCD, applicableCountCD: applicableCD.length, + stemCited, stemCitedCount: stemCited.length, + stemAnalysisPresent, + gateCriteria, gatePassed, + validationWarnings, + interpretationStatus: interpretation?.status, + }; + }); + const ok = gates.filter((g: any) => g.ok); + expect(ok.length).toBeGreaterThan(0); + + const lines: string[] = []; + lines.push('# Phase 1.B+1.C+1.D Decision Gate v3 — Stem-aware multi-model on real track'); + lines.push(`Generated: ${new Date().toISOString()}`); + lines.push(''); + const first: any = ok[0]; + lines.push(`Track: \`Vtss-CantCatchMe.mp3\` (~${Math.round(first.duration)}s, ${first.bpm} BPM, key ${first.key}). Mode: full + stem_notes (separation).`); + lines.push(''); + lines.push(`Tracked Phase 1 paths: **Phase 1.A** = ${PHASE1A_FIELD_PATHS.length} paths, **Phase 1.C/D** = ${PHASE1_CD_FIELD_PATHS.length} paths (combined ${PHASE1_NEW_FIELD_PATHS.length}). Matching is bidirectional with \`*\` wildcard support — see \`pathCoversTracked\` in phase2Validator.ts.`); + lines.push(''); + lines.push('## Verdict summary'); + lines.push(''); + lines.push('| Model | Status | Recs | Err | Warn | Salvg | Missing | NewUncited | A-cited | C/D-cited | StemCited | StemPresent | Gate |'); + lines.push('|---|---|---|---|---|---|---|---|---|---|---|---|---|'); + for (const g of gates as any[]) { + if (!g.ok) { lines.push(`| ${g.model} | ${g.reason} | — | — | — | — | — | — | — | — | — | — | ✗ |`); continue; } + const v = g.byType; + const errors = (v['MISSING_CITATION']??0) + (v['LOW_CONFIDENCE_NOT_HEDGED']??0) + (v['RECOMMENDATION_SALVAGED']??0); + const warnings = Object.entries(v).filter(([k]) => !['MISSING_CITATION','LOW_CONFIDENCE_NOT_HEDGED','RECOMMENDATION_SALVAGED'].includes(k)).reduce((s, [,n]) => s + (n as number), 0); + const verdict = g.gatePassed ? '✓ PASS' : '✗ FAIL'; + const cdFrac = `${g.newCitedCountCD}/${g.applicableCountCD}`; + const aFrac = `${g.newCitedCountA}/${g.applicableCountA}`; + lines.push(`| ${g.model} | ${g.interpretationStatus} | ${g.recs.total} | ${errors} | ${warnings} | ${v['RECOMMENDATION_SALVAGED']??0} | ${v['MISSING_CITATION']??0} | ${v['NEW_FIELD_UNCITED']??0} | ${aFrac} | ${cdFrac} | ${g.stemCitedCount} | ${g.stemAnalysisPresent?'yes':'no'} | ${verdict} |`); + } + lines.push(''); + lines.push('A-cited / C/D-cited columns are "tracked paths cited / tracked paths applicable to this run" — applicable = the path is actually populated in this Phase 1 payload. A higher ratio means Gemini is using more of the available depth.'); + lines.push(''); + lines.push('## Cross-model violations'); + lines.push(''); + const TYPES = ['RECOMMENDATION_SALVAGED','MISSING_CITATION','NEW_FIELD_UNCITED','TRIVIAL_CITATIONS','LOW_CONFIDENCE_NOT_HEDGED','GENRE_IGNORES_DSP','BOUNDS_VIOLATION']; + lines.push('| Type | ' + ok.map((g: any) => g.model.replace('gemini-','').replace('-preview','')).join(' | ') + ' |'); + lines.push('|---|' + ok.map(() => '---').join('|') + '|'); + for (const t of TYPES) { + const row = ok.map((g: any) => g.byType[t] ?? 0).join(' | '); + lines.push(`| ${t} | ${row} |`); + } + lines.push(''); + + for (const g of ok as any[]) { + lines.push(`## ${g.model}`); + lines.push(`- gate: **${g.gatePassed ? 'PASS' : 'FAIL'}**`); + lines.push(`- Phase 1.A fields cited (${g.newCitedCountA}/${g.applicableCountA}): \`${g.newCitedA.join(', ') || 'NONE'}\``); + lines.push(`- Phase 1.C/D fields cited (${g.newCitedCountCD}/${g.applicableCountCD}): \`${g.newCitedCD.join(', ') || 'NONE'}\``); + lines.push(`- Phase 1.C/D paths applicable but uncited: \`${g.applicableCD.filter((p: string) => !g.newCitedCD.includes(p)).join(', ') || 'NONE'}\``); + lines.push(`- stemAnalysis paths cited: ${g.stemCitedCount} → \`${g.stemCited.slice(0, 8).join(', ') || 'NONE'}\``); + for (const t of ['RECOMMENDATION_SALVAGED','MISSING_CITATION','LOW_CONFIDENCE_NOT_HEDGED']) { + const s = g.samples[t] ?? []; + if (!s.length) continue; + lines.push(`- ${t}:`); + for (const x of s) lines.push(` - ${x}`); + } + if (g.validationWarnings.length > 0) { + lines.push(`- validation warnings: ${g.validationWarnings.length} — codes: ${g.validationWarnings.slice(0,5).map((w: any) => w.code).join(', ')}`); + } + lines.push(''); + } + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + mkdirSync(REPORTS_DIR, { recursive: true }); + const out = path.join(REPORTS_DIR, `decision_gate_stems_${stamp}.md`); + writeFileSync(out, lines.join('\n'), 'utf8'); + console.log(`stem-aware report at ${out}`); + for (const g of gates as any[]) console.log(` ${g.model}: ${g.ok ? (g.gatePassed ? 'PASS' : 'FAIL') : g.reason}`); + expect(existsSync(out)).toBe(true); + }); +}); diff --git a/apps/ui/tests/services/analysisResultsViewModel.test.ts b/apps/ui/tests/services/analysisResultsViewModel.test.ts index 42be5ea0..7d8538ea 100644 --- a/apps/ui/tests/services/analysisResultsViewModel.test.ts +++ b/apps/ui/tests/services/analysisResultsViewModel.test.ts @@ -152,7 +152,13 @@ describe('analysisResultsViewModel helpers', () => { const melodicCard = sonicCards.find((card) => card.id === 'melodicArp'); expect(widthCard).toBeDefined(); expect(melodicCard?.transcriptionDerived).toBe(true); - expect(melodicCard?.measurements.some((m) => m.label === 'Transcribed Notes')).toBe(true); + // The fixture has melodyDetail but no transcriptionDetail, so the label + // is the full-mix-draft variant rather than "Transcribed Notes". When a + // run actually has transcriptionDetail.noteCount > 0, the label becomes + // "Transcribed Notes" instead — see analysisResultsViewModel.ts. + expect( + melodicCard?.measurements.some((m) => m.label === 'Melody Notes (full-mix draft)'), + ).toBe(true); expect(widthCard?.description.includes('Seven sentence.')).toBe(false); }); diff --git a/apps/ui/tests/services/phase2Validator.test.ts b/apps/ui/tests/services/phase2Validator.test.ts index 3b0852b8..b3226297 100644 --- a/apps/ui/tests/services/phase2Validator.test.ts +++ b/apps/ui/tests/services/phase2Validator.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; import { + collectPhase1FieldPaths, + pathCoversTracked, + PHASE1_NEW_FIELD_PATHS, validatePhase2Consistency, ValidationViolation, ValidationReport, @@ -332,6 +335,697 @@ describe('validatePhase2Consistency', () => { }); }); + describe('Citation contract (phase1Fields)', () => { + it('skips citation checks for legacy stored runs that have no phase1Fields anywhere', () => { + // Existing fixture intentionally lacks phase1Fields on recommendations — + // simulates a stored Phase 2 result from before the citation contract. + const phase1 = createBasePhase1(); + const phase2 = createBasePhase2(); + + const result = validatePhase2Consistency(phase1, phase2); + const citationViolations = result.violations.filter(v => v.type === 'MISSING_CITATION'); + expect(citationViolations).toHaveLength(0); + }); + + it('runs citation checks when at least one rec exposes phase1Fields', () => { + const phase1 = createBasePhase1(); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + { + device: 'Operator', + category: 'SYNTHESIS', + parameter: 'Coarse', + value: '1.00', + reason: 'Matches tonal center.', + phase1Fields: ['bpm', 'spectralBalance.subBass'], + }, + ], + mixAndMasterChain: [ + { + order: 1, + device: 'EQ Eight', + parameter: 'Low Cut', + value: '30 Hz', + reason: 'Removes rumble.', + // Intentionally omit phase1Fields here — this rec should error. + }, + ], + }); + + const result = validatePhase2Consistency(phase1, phase2); + const missing = result.violations.filter( + v => v.type === 'MISSING_CITATION' && v.field.startsWith('mixAndMasterChain'), + ); + expect(missing).toHaveLength(1); + expect(missing[0].severity).toBe('ERROR'); + expect(missing[0].message).toContain('missing the required phase1Fields'); + }); + + it('rejects an empty phase1Fields array', () => { + const phase1 = createBasePhase1(); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + { + device: 'Operator', + category: 'SYNTHESIS', + parameter: 'Coarse', + value: '1.00', + reason: 'Matches tonal center.', + phase1Fields: [], + }, + ], + }); + + const result = validatePhase2Consistency(phase1, phase2); + const empty = result.violations.filter( + v => v.type === 'MISSING_CITATION' && v.message.includes('empty phase1Fields array'), + ); + expect(empty).toHaveLength(1); + }); + + it('rejects citations to invented field paths', () => { + const phase1 = createBasePhase1(); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + { + device: 'Operator', + category: 'SYNTHESIS', + parameter: 'Coarse', + value: '1.00', + reason: 'Matches tonal center.', + phase1Fields: ['bpm', 'inventedField.thatDoesNotExist'], + }, + ], + }); + + const result = validatePhase2Consistency(phase1, phase2); + const inventedViolations = result.violations.filter( + v => v.type === 'MISSING_CITATION' && v.message.includes('does not match any path'), + ); + expect(inventedViolations).toHaveLength(1); + expect(inventedViolations[0].phase2Value).toBe('inventedField.thatDoesNotExist'); + }); + + it('accepts citations to nested scalar paths like spectralBalance.subBass', () => { + const phase1 = createBasePhase1(); + const phase2 = createBasePhase2({ + // Once auto-detection treats the response as new-shape, every rec + // bucket must carry phase1Fields. This fixture exercises the happy + // path across all three buckets. + abletonRecommendations: [ + { + device: 'EQ Eight', + category: 'EQ', + parameter: 'Low Shelf', + value: '+1.5 dB', + reason: 'Bring up the sub-bass region.', + phase1Fields: ['spectralBalance.subBass', 'lufsIntegrated'], + }, + ], + mixAndMasterChain: [ + { + order: 1, + device: 'EQ Eight', + parameter: 'Low Cut', + value: '30 Hz', + reason: 'Removes rumble.', + phase1Fields: ['spectralBalance.subBass'], + }, + ], + secretSauce: { + title: 'Punch Layering', + explanation: 'Layered transient enhancement.', + implementationSteps: ['Step 1', 'Step 2', 'Step 3', 'Step 4', 'Step 5', 'Step 6'], + workflowSteps: [ + { + step: 1, + trackContext: 'Master', + device: 'Glue Compressor', + parameter: 'Ratio', + value: '2:1', + instruction: 'Glue the bus.', + measurementJustification: 'Crest factor supports gentle glue.', + phase1Fields: ['crestFactor'], + }, + ], + }, + }); + + const result = validatePhase2Consistency(phase1, phase2); + const citationErrors = result.violations.filter(v => v.type === 'MISSING_CITATION'); + expect(citationErrors).toHaveLength(0); + }); + + it('validates phase1Fields on secretSauce.workflowSteps too', () => { + const phase1 = createBasePhase1(); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + { + device: 'Operator', + category: 'SYNTHESIS', + parameter: 'Coarse', + value: '1.00', + reason: 'Matches tonal center.', + phase1Fields: ['bpm'], + }, + ], + secretSauce: { + title: 'Punch Layering', + explanation: 'Layered transient enhancement.', + implementationSteps: ['Step 1', 'Step 2', 'Step 3', 'Step 4', 'Step 5', 'Step 6'], + workflowSteps: [ + { + step: 1, + trackContext: 'Drum Group', + device: 'Glue Compressor', + parameter: 'Attack', + value: '3 ms', + instruction: 'Set up bus glue.', + measurementJustification: 'Crest profile supports controlled transient shape.', + // phase1Fields intentionally missing + }, + ], + }, + }); + + const result = validatePhase2Consistency(phase1, phase2); + const workflowViolations = result.violations.filter( + v => v.type === 'MISSING_CITATION' && v.field.startsWith('secretSauce.workflowSteps'), + ); + expect(workflowViolations).toHaveLength(1); + }); + }); + + describe('Citation diversity (TRIVIAL_CITATIONS)', () => { + const baseRec = (phase1Fields: string[]) => ({ + device: 'Operator', + category: 'SYNTHESIS' as const, + parameter: 'Coarse', + value: '1.00', + reason: 'Matches tonal center.', + phase1Fields, + }); + + it('warns when >60% of recommendations cite the same single anchor', () => { + const phase1 = createBasePhase1(); + const phase2 = createBasePhase2({ + // 6 of 6 cite ["bpm"] — 100% dominance, deep into trivial. + abletonRecommendations: Array.from({ length: 6 }, () => baseRec(['bpm'])), + }); + + const result = validatePhase2Consistency(phase1, phase2); + const trivial = result.violations.filter(v => v.type === 'TRIVIAL_CITATIONS'); + expect(trivial).toHaveLength(1); + expect(trivial[0].severity).toBe('WARNING'); + expect(trivial[0].phase2Value).toBe('bpm'); + }); + + it('does not warn when citations are diverse', () => { + const phase1 = createBasePhase1(); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + baseRec(['bpm']), + baseRec(['key']), + baseRec(['spectralBalance.subBass']), + baseRec(['stereoDetail.subBassCorrelation']), + baseRec(['kickDetail.fundamentalHz']), + baseRec(['lufsIntegrated', 'crestFactor']), + ], + }); + + const result = validatePhase2Consistency(phase1, phase2); + const trivial = result.violations.filter(v => v.type === 'TRIVIAL_CITATIONS'); + expect(trivial).toHaveLength(0); + }); + + it('skips the diversity check when fewer than 4 recommendations have citations', () => { + // 3 recs total, all identical — under the minimum threshold so no warning. + const phase1 = createBasePhase1(); + const phase2 = createBasePhase2({ + abletonRecommendations: Array.from({ length: 3 }, () => baseRec(['bpm'])), + }); + + const result = validatePhase2Consistency(phase1, phase2); + const trivial = result.violations.filter(v => v.type === 'TRIVIAL_CITATIONS'); + expect(trivial).toHaveLength(0); + }); + }); + + describe('New-field coverage (NEW_FIELD_UNCITED)', () => { + it('warns when a Phase 1.A field is populated but uncited', () => { + const phase1 = createBasePhase1({ + // Inject the new Phase 1.A field as if the analyzer produced it. + // Density must clear MIN_USEFUL_CURVE_POINTS (5) on at least one + // child curve or the validator correctly treats it as "not yet + // meaningful" and skips the warning. + lufsCurve: { + shortTerm: [ + { t: 0.0, lufs: -16.2 }, + { t: 3.0, lufs: -15.8 }, + { t: 6.0, lufs: -14.9 }, + { t: 9.0, lufs: -13.5 }, + { t: 12.0, lufs: -11.2 }, + { t: 15.0, lufs: -9.4 }, + ], + momentary: [{ t: 0.0, lufs: -15.8 }], + }, + } as Partial); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + { + device: 'EQ Eight', + category: 'EQ', + parameter: 'Low Cut', + value: '30 Hz', + reason: 'Removes rumble.', + phase1Fields: ['spectralBalance.subBass'], + }, + ], + }); + + const result = validatePhase2Consistency(phase1, phase2); + const uncited = result.violations.filter(v => v.type === 'NEW_FIELD_UNCITED'); + expect(uncited.some(v => v.field === 'lufsCurve')).toBe(true); + }); + + it('does not warn when a parent path is cited (prefix tolerance)', () => { + const phase1 = createBasePhase1({ + // Same density requirement as the prior test. + lufsCurve: { + shortTerm: [ + { t: 0.0, lufs: -16.2 }, + { t: 3.0, lufs: -15.8 }, + { t: 6.0, lufs: -14.9 }, + { t: 9.0, lufs: -13.5 }, + { t: 12.0, lufs: -11.2 }, + { t: 15.0, lufs: -9.4 }, + ], + momentary: [{ t: 0.0, lufs: -15.8 }], + }, + } as Partial); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + { + device: 'Glue Compressor', + category: 'DYNAMICS', + parameter: 'Attack', + value: '10 ms', + reason: 'Preserves transients.', + // Citing the parent path covers any child paths. + phase1Fields: ['lufsCurve'], + }, + ], + }); + + const result = validatePhase2Consistency(phase1, phase2); + const uncited = result.violations.filter( + v => v.type === 'NEW_FIELD_UNCITED' && v.field.startsWith('lufsCurve.'), + ); + expect(uncited).toHaveLength(0); + }); + + it('does not warn when the field is absent from Phase 1', () => { + // Default fixture has no lufsCurve; no rec cites it; should NOT warn. + const phase1 = createBasePhase1(); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + { + device: 'Operator', + category: 'SYNTHESIS', + parameter: 'Coarse', + value: '1.00', + reason: 'Matches tonal center.', + phase1Fields: ['bpm'], + }, + ], + }); + + const result = validatePhase2Consistency(phase1, phase2); + const uncited = result.violations.filter( + v => v.type === 'NEW_FIELD_UNCITED' && v.field === 'lufsCurve', + ); + expect(uncited).toHaveLength(0); + }); + + it('does not warn for a parent tracked path when a more-specific child is cited (bidirectional)', () => { + // grooveDetail.perDrumSwing is the tracked parent; Gemini cites the + // leaf perDrumSwing.snare — the gate must accept the leaf citation as + // covering the tracked parent (the case the v2 validator missed). + const phase1 = createBasePhase1({ + grooveDetail: { + kickSwing: 0.9, + hihatSwing: 0.6, + kickAccent: [0.4, 0.6, 0.8, 0.7, 0.5], + hihatAccent: [0.3, 0.5, 0.7, 0.6, 0.4], + perDrumSwing: { kick: 0.9, snare: 0.51, hihat: 0.64 }, + } as any, + } as Partial); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + { + device: 'Beat Repeat', + category: 'GROOVE', + parameter: 'Variation', + value: '12 %', + reason: 'Snare is humanized while kick stays rigid.', + phase1Fields: ['grooveDetail.perDrumSwing.snare'], + }, + ], + }); + + const result = validatePhase2Consistency(phase1, phase2); + const uncited = result.violations.filter( + v => v.type === 'NEW_FIELD_UNCITED' && v.field === 'grooveDetail.perDrumSwing', + ); + expect(uncited).toHaveLength(0); + }); + + it('does not warn for a stem-scoped wildcard path when any stem cites it', () => { + // stemAnalysis.*.reverbDetail tracked; one stem (bass) has the field + // populated and Gemini cites it on that stem — the gate must treat + // the wildcard as satisfied without enumerating per-stem paths. + const phase1 = createBasePhase1({ + stemAnalysis: { + bass: { + reverbDetail: { + rt60: 2.25, + isWet: true, + tailEnergyRatio: 0.88, + measured: true, + perBandRt60: { low: 2.06, lowMids: 2.21, highMids: 2.8, highs: 2.33 }, + preDelayMs: 80.0, + }, + }, + } as any, + } as Partial); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + { + device: 'Reverb', + category: 'EFFECTS', + parameter: 'PreDelay', + value: '80 ms', + reason: 'Bass-bus reverb pre-delay matches measured stem value.', + phase1Fields: ['stemAnalysis.bass.reverbDetail.preDelayMs'], + }, + ], + }); + + const result = validatePhase2Consistency(phase1, phase2); + const uncited = result.violations.filter( + v => v.type === 'NEW_FIELD_UNCITED' && v.field === 'stemAnalysis.*.reverbDetail', + ); + expect(uncited).toHaveLength(0); + }); + + it('warns for a stem-scoped wildcard path when no stem is cited', () => { + // stemAnalysis.*.reverbDetail tracked; field is populated on at least + // one stem; no recommendation cites any per-stem reverb path. The gate + // SHOULD warn (and the wildcard must not falsely auto-pass). + const phase1 = createBasePhase1({ + stemAnalysis: { + drums: { + reverbDetail: { + rt60: 0.81, + isWet: true, + tailEnergyRatio: 0.65, + measured: true, + perBandRt60: { low: 0.62, lowMids: 0.92, highMids: 1.35, highs: 1.68 }, + preDelayMs: 40.0, + }, + }, + } as any, + } as Partial); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + { + device: 'EQ Eight', + category: 'EQ', + parameter: 'Low Cut', + value: '30 Hz', + reason: 'Removes rumble.', + phase1Fields: ['spectralBalance.subBass'], + }, + ], + }); + + const result = validatePhase2Consistency(phase1, phase2); + const uncited = result.violations.filter( + v => v.type === 'NEW_FIELD_UNCITED' && v.field === 'stemAnalysis.*.reverbDetail', + ); + expect(uncited).toHaveLength(1); + }); + + it('exported PHASE1_NEW_FIELD_PATHS includes the Phase 1.C/D additions', () => { + // The live decision-gate comparator imports this constant — guard the + // shape so that future renames or accidental removals are caught. + expect(PHASE1_NEW_FIELD_PATHS).toContain('grooveDetail.perDrumSwing'); + expect(PHASE1_NEW_FIELD_PATHS).toContain('reverbDetail.perBandRt60'); + expect(PHASE1_NEW_FIELD_PATHS).toContain('reverbDetail.preDelayMs'); + expect(PHASE1_NEW_FIELD_PATHS).toContain('sidechainDetail.envelopeShape32'); + expect(PHASE1_NEW_FIELD_PATHS).toContain('stemAnalysis.*.reverbDetail'); + expect(PHASE1_NEW_FIELD_PATHS).toContain('transientDensityDetail'); + expect(PHASE1_NEW_FIELD_PATHS).toContain('snareDetail'); + expect(PHASE1_NEW_FIELD_PATHS).toContain('hihatDetail'); + expect(PHASE1_NEW_FIELD_PATHS).toContain('saturationDetail'); + }); + }); + + describe('pathCoversTracked helper', () => { + it('matches exact strings', () => { + expect(pathCoversTracked('grooveDetail.perDrumSwing', 'grooveDetail.perDrumSwing')).toBe(true); + }); + + it('parent citation covers child tracked', () => { + // citation `lufsCurve` covers tracked `lufsCurve.shortTerm` + expect(pathCoversTracked('lufsCurve', 'lufsCurve.shortTerm')).toBe(true); + }); + + it('child citation covers parent tracked', () => { + // citation `grooveDetail.perDrumSwing.snare` covers tracked `grooveDetail.perDrumSwing` + expect(pathCoversTracked('grooveDetail.perDrumSwing.snare', 'grooveDetail.perDrumSwing')).toBe(true); + }); + + it('wildcard matches stem-scoped citation', () => { + // tracked has `*`; citation has a concrete stem name at that segment; + // the rest of the citation is a deeper sub-path under the tracked + // remainder. + expect(pathCoversTracked('stemAnalysis.drums.reverbDetail.rt60', 'stemAnalysis.*.reverbDetail')).toBe(true); + expect(pathCoversTracked('stemAnalysis.other.reverbDetail.preDelayMs', 'stemAnalysis.*.reverbDetail')).toBe(true); + expect(pathCoversTracked('stemAnalysis.bass.reverbDetail', 'stemAnalysis.*.reverbDetail')).toBe(true); + }); + + it('wildcard rejects unrelated path', () => { + // Concrete segment beside the wildcard must still match; an unrelated + // sub-path must not satisfy the tracked wildcard. + expect(pathCoversTracked('stemAnalysis.drums.spectralBalance.subBass', 'stemAnalysis.*.reverbDetail')).toBe(false); + expect(pathCoversTracked('rhythmDetail.tempoCurve', 'stemAnalysis.*.reverbDetail')).toBe(false); + }); + + it('non-overlapping paths return false', () => { + expect(pathCoversTracked('spectralBalance.subBass', 'rhythmDetail.tempoCurve')).toBe(false); + }); + }); + + describe('Low-confidence hedging (LOW_CONFIDENCE_NOT_HEDGED)', () => { + it('flags the exact PDF case: PUMPING_CONFIDENCE 0.26 + "ducking is mandatory"', () => { + // Fixture mirrors the real Gemini output from the no-stem reference run. + const phase1 = createBasePhase1({ + sidechainDetail: { + pumpingStrength: 0.30, + pumpingRegularity: 0.0, + pumpingRate: null, + pumpingConfidence: 0.26, + }, + } as Partial); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + { + device: 'Compressor', + category: 'DYNAMICS', + parameter: 'Sidechain', + value: 'On', + reason: 'Pumping strength is 0.29; ducking the bass is mandatory for clarity at 144.9 BPM.', + phase1Fields: ['sidechainDetail.pumpingStrength'], + }, + ], + }); + + const result = validatePhase2Consistency(phase1, phase2); + const violations = result.violations.filter( + v => v.type === 'LOW_CONFIDENCE_NOT_HEDGED', + ); + expect(violations).toHaveLength(1); + expect(violations[0].severity).toBe('ERROR'); + expect(violations[0].phase2Value).toBe('mandatory'); + }); + + it('does not flag hedged language at low confidence', () => { + const phase1 = createBasePhase1({ + sidechainDetail: { + pumpingStrength: 0.30, + pumpingRegularity: 0.0, + pumpingRate: null, + pumpingConfidence: 0.26, + }, + } as Partial); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + { + device: 'Compressor', + category: 'DYNAMICS', + parameter: 'Sidechain', + value: 'On', + reason: 'Pumping strength is 0.29 with low confidence; consider subtle bass ducking — may add clarity at 144.9 BPM.', + phase1Fields: ['sidechainDetail.pumpingStrength'], + }, + ], + }); + + const result = validatePhase2Consistency(phase1, phase2); + const violations = result.violations.filter( + v => v.type === 'LOW_CONFIDENCE_NOT_HEDGED', + ); + expect(violations).toHaveLength(0); + }); + + it('does not flag imperative language when the cited field has high confidence', () => { + // Same imperative text, but pumping confidence is high — gate is correctly + // about LOW-confidence + imperative, not imperative alone. + const phase1 = createBasePhase1({ + sidechainDetail: { + pumpingStrength: 0.85, + pumpingRegularity: 0.92, + pumpingRate: 'quarter', + pumpingConfidence: 0.91, + }, + } as Partial); + const phase2 = createBasePhase2({ + abletonRecommendations: [ + { + device: 'Compressor', + category: 'DYNAMICS', + parameter: 'Sidechain', + value: 'On', + reason: 'Pumping strength is 0.85; ducking the bass is mandatory for clarity at 144.9 BPM.', + phase1Fields: ['sidechainDetail.pumpingStrength'], + }, + ], + }); + + const result = validatePhase2Consistency(phase1, phase2); + const violations = result.violations.filter( + v => v.type === 'LOW_CONFIDENCE_NOT_HEDGED', + ); + expect(violations).toHaveLength(0); + }); + }); + + describe('Salvage warnings (RECOMMENDATION_SALVAGED)', () => { + it('emits an ERROR for each salvage warning code passed via diagnostics', () => { + const phase1 = createBasePhase1(); + const phase2 = createBasePhase2({ + // Need at least one phase1Fields anywhere to make this new-shape, + // though salvage check runs independent of the new-shape gate. + abletonRecommendations: [ + { + device: 'Operator', + category: 'SYNTHESIS', + parameter: 'Coarse', + value: '1.00', + reason: 'Matches tonal center.', + phase1Fields: ['key'], + }, + ], + }); + const diagnostics = { + warnings: [ + { + code: 'DROPPED_INVALID_ARRAY_ITEM', + path: 'abletonRecommendations[3]', + message: 'workflowStage "EQ" is not valid', + }, + { + code: 'COERCED_TRACK_CONTEXT', + path: 'mixAndMasterChain[1].trackContext', + message: 'Return: Reverb → Return:Long Reverb', + }, + ], + }; + + const result = validatePhase2Consistency(phase1, phase2, diagnostics); + const salvaged = result.violations.filter(v => v.type === 'RECOMMENDATION_SALVAGED'); + expect(salvaged).toHaveLength(2); + expect(salvaged.every(v => v.severity === 'ERROR')).toBe(true); + expect(salvaged[0].phase2Value).toBe('DROPPED_INVALID_ARRAY_ITEM'); + }); + + it('ignores unrelated warning codes', () => { + const phase1 = createBasePhase1(); + const phase2 = createBasePhase2(); + const diagnostics = { + warnings: [{ code: 'UNRELATED_INFO', path: 'foo', message: 'bar' }], + }; + + const result = validatePhase2Consistency(phase1, phase2, diagnostics); + const salvaged = result.violations.filter(v => v.type === 'RECOMMENDATION_SALVAGED'); + expect(salvaged).toHaveLength(0); + }); + + it('does nothing when diagnostics is undefined', () => { + const phase1 = createBasePhase1(); + const phase2 = createBasePhase2(); + const result = validatePhase2Consistency(phase1, phase2); + const salvaged = result.violations.filter(v => v.type === 'RECOMMENDATION_SALVAGED'); + expect(salvaged).toHaveLength(0); + }); + }); + + describe('collectPhase1FieldPaths helper', () => { + it('collects top-level scalar paths', () => { + const phase1 = createBasePhase1(); + const paths = collectPhase1FieldPaths(phase1); + expect(paths.has('bpm')).toBe(true); + expect(paths.has('lufsIntegrated')).toBe(true); + expect(paths.has('key')).toBe(true); + }); + + it('collects nested object scalar paths', () => { + const phase1 = createBasePhase1(); + const paths = collectPhase1FieldPaths(phase1); + expect(paths.has('spectralBalance')).toBe(true); + expect(paths.has('spectralBalance.subBass')).toBe(true); + expect(paths.has('spectralBalance.brilliance')).toBe(true); + expect(paths.has('rhythmDetail.kickSwing')).toBe(true); + expect(paths.has('synthesisCharacter.inharmonicity')).toBe(true); + }); + + it('omits paths whose value is null or undefined', () => { + const phase1 = createBasePhase1({ key: null }); + const paths = collectPhase1FieldPaths(phase1); + expect(paths.has('key')).toBe(false); + // Adjacent non-null paths should still be present. + expect(paths.has('bpm')).toBe(true); + }); + + it('surfaces array-of-object field names so citations like noveltyPeaks.time are valid', () => { + const phase1 = createBasePhase1({ + arrangementDetail: { + noveltyCurve: [0.1, 0.2, 0.3], + noveltyPeaks: [{ time: 12.4, strength: 0.81 }], + noveltyMean: 0.18, + noveltyStdDev: 0.05, + }, + } as Partial); + const paths = collectPhase1FieldPaths(phase1); + expect(paths.has('arrangementDetail')).toBe(true); + expect(paths.has('arrangementDetail.noveltyCurve')).toBe(true); + expect(paths.has('arrangementDetail.noveltyPeaks')).toBe(true); + expect(paths.has('arrangementDetail.noveltyPeaks.time')).toBe(true); + expect(paths.has('arrangementDetail.noveltyPeaks.strength')).toBe(true); + }); + }); + describe('Summary statistics', () => { it('should count errors and warnings correctly', () => { const phase1 = createBasePhase1({ bpm: 126, key: 'F minor' });