From 616bd261b6a05f6b86ae00075406d5dc96077809 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Wed, 18 Mar 2026 14:09:58 +1300 Subject: [PATCH 01/11] feat: reframe stage 3 symbolic and stem listening --- apps/backend/AGENTS.md | 5 +- apps/backend/ARCHITECTURE.md | 6 +- apps/backend/JSON_SCHEMA.md | 4 +- apps/backend/README.md | 10 +- apps/backend/analyze.py | 17 +- apps/backend/gemini_client.py | 8 +- apps/backend/prompts/phase2_system.txt | 21 +- apps/backend/prompts/stem_summary_system.txt | 48 +++ apps/backend/server.py | 320 +++++++++++++++++- apps/backend/tests/test_analysis_runtime.py | 2 +- apps/backend/tests/test_analyze.py | 2 +- apps/backend/tests/test_server.py | 118 ++++++- apps/ui/README.md | 36 +- .../src/components/SessionMusicianPanel.tsx | 35 +- apps/ui/src/services/analysisRunsClient.ts | 82 ++++- apps/ui/src/services/backendPhase1Client.ts | 2 +- apps/ui/src/types.ts | 27 +- apps/ui/tests/e2e/phase1-exports.spec.ts | 2 +- apps/ui/tests/e2e/phase2-files-api.spec.ts | 2 +- apps/ui/tests/e2e/phase2-inline.spec.ts | 2 +- apps/ui/tests/e2e/session-musician.spec.ts | 17 +- .../tests/services/analysisResultsUi.test.ts | 30 +- .../services/analysisResultsViewModel.test.ts | 2 +- .../tests/services/analysisRunsClient.test.ts | 64 +++- .../services/backendPhase1Client.test.ts | 2 +- .../services/sessionMusicianPanel.test.ts | 12 +- .../ui/tests/smoke/upload-phase1-midi.spec.ts | 32 +- docs/ARCHITECTURE_STRATEGY.md | 4 +- docs/STAGE3_REALITY_AUDIT.md | 163 +++++++++ 29 files changed, 936 insertions(+), 139 deletions(-) create mode 100644 apps/backend/prompts/stem_summary_system.txt create mode 100644 docs/STAGE3_REALITY_AUDIT.md diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 07337f0b..1101f08e 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -35,8 +35,9 @@ python3.11 -m venv venv ``` - Main runtime dependencies are pinned in `requirements.txt`. -- Python `3.12+` is not a supported full-feature local bootstrap target on macOS arm64 because `basic-pitch` on Darwin pulls a `tensorflow-macos` / NumPy combination that does not resolve cleanly. -- `requirements.txt` pins `setuptools<71`. This is required because `resampy 0.4.2` (pinned by `basic-pitch 0.4.0`) imports `pkg_resources`, which `setuptools>=71` no longer ships as a standalone module. This pin will be removed when the transcription backend is replaced (Stage 3 migration). +- Python `3.12+` is not a supported full-feature local bootstrap target on macOS arm64 because the legacy `basic-pitch` backend on Darwin pulls a `tensorflow-macos` / NumPy combination that does not resolve cleanly. +- `requirements.txt` pins `setuptools<71`. This is a temporary bridge for the legacy `basic-pitch 0.4.0` dependency chain (`resampy 0.4.2` imports `pkg_resources`, which `setuptools>=71` no longer ships as a standalone module). Remove this pin when the legacy backend is retired. +- For Stage 3 work, treat `BasicPitchBackend` as a legacy comparison backend only. The canonical Layer 2 slot is `TranscriptionBackend`, and new experiments should land there rather than adding new Basic Pitch-specific logic. - If audio/DSP imports fail, check local native dependencies before editing code. ## Main Commands diff --git a/apps/backend/ARCHITECTURE.md b/apps/backend/ARCHITECTURE.md index 85e236fa..bfbc5681 100644 --- a/apps/backend/ARCHITECTURE.md +++ b/apps/backend/ARCHITECTURE.md @@ -18,7 +18,7 @@ Responsibilities: - read the input file - optionally run Demucs separation - run the Phase 1 DSP analysis functions -- optionally run Basic Pitch transcription +- optionally run legacy Basic Pitch transcription - emit the raw analyzer JSON Interface: @@ -57,7 +57,7 @@ FastAPI-generated routes remain available at `/openapi.json`, `/docs`, and `/red 6. If `--separate` is enabled, run Demucs and keep the temporary stem paths. 7. Run shared rhythm extraction once and reuse it across BPM, rhythm, groove, and sidechain analyses. 8. Run the individual feature analyzers and merge their return dictionaries into a single result object. -9. If `--transcribe` is enabled, run Basic Pitch: +9. If `--transcribe` is enabled, run the legacy Basic Pitch backend: - on `bass` and `other` stems when Demucs output is available - otherwise on the full mix 10. Print the final JSON to `stdout` and logs to `stderr`. @@ -243,7 +243,7 @@ When the analyzer never produces a valid JSON object, `timings.fileDurationSecon Flow: -1. Try to import Basic Pitch. +1. Try to import the legacy Basic Pitch backend. 2. Choose transcription sources: - `bass` and `other` stems when Demucs succeeded - otherwise `full_mix` diff --git a/apps/backend/JSON_SCHEMA.md b/apps/backend/JSON_SCHEMA.md index 6233763a..f5e7951c 100644 --- a/apps/backend/JSON_SCHEMA.md +++ b/apps/backend/JSON_SCHEMA.md @@ -306,8 +306,8 @@ Implementation notes: | Field | Type | Description | Units / Scale | LLM interpretation note | |---|---|---|---|---| -| `transcriptionDetail.transcriptionMethod` | `string` | Name of the transcription backend used. Currently always `'basic-pitch'` (BasicPitchBackend). Will reflect the backend name when an alternative backend is configured (Stage 3). | categorical | Currently always `basic-pitch`; useful if more transcription backends are added later. | -| `transcriptionDetail.noteCount` | `int` | Total number of retained note events after merge, deduplication, and capping. | count | Higher counts imply denser retained musical content rather than raw Basic Pitch event volume. | +| `transcriptionDetail.transcriptionMethod` | `string` | Name of the transcription backend used. The current legacy backend reports `'basic-pitch-legacy'`. This field should reflect the real backend identifier for Stage 3 experiments. | categorical | Useful for distinguishing legacy comparison output from newer symbolic backends such as torchcrepe or PENN. | +| `transcriptionDetail.noteCount` | `int` | Total number of retained note events after merge, deduplication, and capping. | count | Higher counts imply denser retained musical content rather than raw backend event volume. | | `transcriptionDetail.averageConfidence` | `float` | Mean confidence across the retained merged note events. | 0.0-1.0 | Lower values indicate noisier or more ambiguous pitch tracking even after backend noise filtering. | | `transcriptionDetail.dominantPitches` | `array` | Top 5 most frequent detected pitches. | list of pitch summary objects | Quick tonal summary for bassline and hook reconstruction. | | `transcriptionDetail.dominantPitches[].pitchMidi` | `int` | MIDI pitch number for the dominant pitch entry. | 0-127 | Directly usable for DAW note entry or tonal analysis. | diff --git a/apps/backend/README.md b/apps/backend/README.md index cb3eb527..b639f1f6 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -9,7 +9,7 @@ This repo contains two entry points: ## Current Scope -`analyze.py` measures tempo, key, loudness, stereo, rhythm, melody, arrangement, segment-level metrics, chord content, perceptual features, optional Demucs separation, and optional Basic Pitch transcription. +`analyze.py` measures tempo, key, loudness, stereo, rhythm, melody, arrangement, segment-level metrics, chord content, perceptual features, optional Demucs separation, and optional legacy Basic Pitch symbolic extraction. `server.py` exposes two custom analysis routes: @@ -24,7 +24,7 @@ FastAPI also serves the usual generated endpoints at `/openapi.json`, `/docs`, a - Essentia - NumPy - Demucs -- Basic Pitch +- legacy Basic Pitch comparison backend - mido - FastAPI - Uvicorn @@ -46,7 +46,7 @@ python3.11 -m venv venv Bootstrap contract for this monorepo `v1.0.0` cut: - the pinned full-feature local baseline is Python `3.11.x` on macOS arm64 -- Python `3.12+` is not a supported full-feature bootstrap target on macOS arm64 because `basic-pitch` on Darwin pulls a `tensorflow-macos` / NumPy combination that does not resolve cleanly +- Python `3.12+` is not a supported full-feature bootstrap target on macOS arm64 because the legacy `basic-pitch` backend on Darwin pulls a `tensorflow-macos` / NumPy combination that does not resolve cleanly ## CLI Usage @@ -61,8 +61,8 @@ Bootstrap contract for this monorepo `v1.0.0` cut: | Flag | Current behavior | | --- | --- | | `` | Required input path. | -| `--separate` | Runs Demucs before melody analysis. If `--transcribe` is also enabled, Basic Pitch uses the `bass` and `other` stems when they exist. | -| `--transcribe` | Runs Basic Pitch and returns `transcriptionDetail`. Without Demucs it transcribes the full mix; with Demucs it transcribes `bass` and `other` separately and merges the notes. | +| `--separate` | Runs Demucs before melody analysis. If `--transcribe` is also enabled, the legacy Basic Pitch backend uses the `bass` and `other` stems when they exist. | +| `--transcribe` | Runs the legacy Basic Pitch backend and returns `transcriptionDetail`. Without Demucs it transcribes the full mix; with Demucs it transcribes `bass` and `other` separately and merges the notes. | | `--fast` | Accepted, but currently a no-op parser stub. | | `--yes` | Skips the interactive confirmation prompt after the CLI prints its runtime estimate. | diff --git a/apps/backend/analyze.py b/apps/backend/analyze.py index 74f30f01..43be0bc2 100644 --- a/apps/backend/analyze.py +++ b/apps/backend/analyze.py @@ -231,9 +231,9 @@ def build_analysis_estimate( "transcription_stems" if run_separation else "transcription_full_mix" ) transcription_label = ( - "Basic Pitch on bass + other stems" + "Legacy Basic Pitch on bass + other stems" if run_separation - else "Basic Pitch on full mix" + else "Legacy Basic Pitch on full mix" ) transcription_seconds = ( _estimate_stage_seconds(duration_seconds, 0.22, 0.42, 60.0, 150.0) @@ -2629,10 +2629,11 @@ def _normalize_confidence(value) -> float: @runtime_checkable class TranscriptionBackend(Protocol): - """Interface for pluggable audio-to-MIDI transcription backends. + """Interface for pluggable symbolic extraction backends. - Stage 3 migration: implement this Protocol to swap basic-pitch for - a maintained alternative without changing analyze_transcription() callers. + Stage 3 migration: implement this Protocol to swap the legacy + Basic Pitch backend for a maintained alternative without changing + analyze_transcription() callers. """ name: str # written to transcriptionDetail.transcriptionMethod in output @@ -3000,9 +3001,9 @@ def refresh_active(note: dict) -> None: class BasicPitchBackend: - """Transcription backend wrapping the basic-pitch library.""" + """Legacy comparison backend wrapping the basic-pitch library.""" - name = "basic-pitch" + name = "basic-pitch-legacy" def transcribe( self, @@ -3137,7 +3138,7 @@ def analyze_transcription( stem_paths: dict | None = None, backend: TranscriptionBackend | None = None, ) -> dict: - """Run transcription via the specified backend, defaulting to BasicPitchBackend. + """Run transcription via the specified backend, defaulting to the legacy Basic Pitch backend. Pass a custom backend implementing TranscriptionBackend to use an alternative transcription engine (Stage 3 migration point). diff --git a/apps/backend/gemini_client.py b/apps/backend/gemini_client.py index 402b45e7..060b200e 100644 --- a/apps/backend/gemini_client.py +++ b/apps/backend/gemini_client.py @@ -18,6 +18,10 @@ logger = logging.getLogger(__name__) +# Legacy helper module retained for older experiments. +# The live Gemini integration and prompt routing now live in server.py, +# including the Layer 1/2/3 grounding split and the stem_summary profile. + # --- Constants & Configuration --- INLINE_SIZE_LIMIT = 104_857_600 # 100 MiB — confirmed by Google on 2026-01-12 GEMINI_TIMEOUT_SECONDS = 300 # 5 minutes @@ -71,7 +75,7 @@ - segmentLoudness = per-section LUFS, reveals drops and builds - dominantNotes = MIDI numbers, convert to note names - transcriptionDetail (when present): - - noteCount: total polyphonic notes detected + - noteCount: total symbolic notes detected by the legacy comparison backend - averageConfidence: mean note confidence 0-1 - dominantPitches[]: top pitches with count - pitchRange: min/max MIDI and note names @@ -79,7 +83,7 @@ - stemsTranscribed: which stems were analysed - notes[].stemSource: "bass"|"other"|"full_mix" - When stemSeparationUsed is true, bass notes and melodic notes are separated by stemSource - - Prefer transcriptionDetail over melodyDetail for harmonic and melodic reconstruction advice when transcriptionDetail is present + - Treat transcriptionDetail as best-effort symbolic guidance, not authoritative measurement - pumpingStrength + pumpingConfidence both above 0.35 = sidechain - arrangementDetail.noveltyPeaks = structural event timestamps - segmentSpectral.stereoWidth changes = intentional width automation diff --git a/apps/backend/prompts/phase2_system.txt b/apps/backend/prompts/phase2_system.txt index a8cd4401..421cbc2f 100644 --- a/apps/backend/prompts/phase2_system.txt +++ b/apps/backend/prompts/phase2_system.txt @@ -1,7 +1,8 @@ -You are an expert Ableton Live 12 producer and sound designer +You are an expert Ableton Live 12 producer and sound designer specialising in electronic music reconstruction. You receive: 1. A structured JSON object of deterministic DSP measurements -2. The audio file itself +2. An optional structured JSON object of best-effort local symbolic extraction +3. The audio file itself ABSOLUTE RULES: 1. Every numeric value in the JSON is ground truth from a @@ -17,10 +18,11 @@ ABSOLUTE RULES: - chordStrength below 0.70 = chords approximate - pumpingConfidence below 0.40 = do not assert sidechain - segmentKey from segments shorter than 10s = low confidence -6. When transcriptionDetail is present, use dominantPitches for note name recommendations, not melodyDetail.dominantNotes. -7. stemSeparationUsed: true means bass and melodic content have been transcribed independently - treat bass stem notes and other stem notes as separate layers. -8. For measured values, the JSON is authoritative. For genre identification only, audio perception is authoritative. -9. mixAndMasterChain must contain a minimum of 8 device objects. If fewer than 8 real devices can be inferred from the audio, supplement with contextually appropriate Ableton Live devices that would suit the detected characteristics. Never return fewer than 8. +6. When OPTIONAL_SYMBOLIC_EXTRACTION_RESULT_JSON is present, treat it as best-effort note scaffolding only. It is not authoritative measurement. +7. If symbolic extraction was stem-aware, treat bass stem notes and other stem notes as separate layers. Do not silently merge them into one claimed source of truth. +8. Use symbolic extraction only for note-name suggestions, register hints, and rhythmic scaffolding. Never let it override BPM, key, meter, loudness, or arrangement timing from the measurement JSON. +9. For measured values, the JSON is authoritative. For genre identification only, audio perception is authoritative. +10. mixAndMasterChain must contain a minimum of 8 device objects. If fewer than 8 real devices can be inferred from the audio, supplement with contextually appropriate Ableton Live devices that would suit the detected characteristics. Never return fewer than 8. FIELD GLOSSARY: - bpm: use exactly as Ableton project tempo @@ -42,8 +44,9 @@ FIELD GLOSSARY: - structure.segments = arrangement blocks, plus or minus 5-10s - segmentLoudness = per-section LUFS, reveals drops and builds - dominantNotes = MIDI numbers, convert to note names -- transcriptionDetail (when present): - - noteCount: total polyphonic notes detected +- optional symbolic extraction result (when present): + - transcriptionMethod: symbolic backend identifier; treat `basic-pitch-legacy` as a legacy comparison backend + - noteCount: total retained symbolic note events - averageConfidence: mean note confidence 0-1 - dominantPitches[]: top pitches with count - pitchRange: min/max MIDI and note names @@ -51,7 +54,7 @@ FIELD GLOSSARY: - stemsTranscribed: which stems were analysed - notes[].stemSource: "bass"|"other"|"full_mix" - When stemSeparationUsed is true, bass notes and melodic notes are separated by stemSource - - Prefer transcriptionDetail over melodyDetail for harmonic and melodic reconstruction advice when transcriptionDetail is present + - Prefer this symbolic result over melodyDetail only for note-name scaffolding when confidence is reasonable - pumpingStrength + pumpingConfidence both above 0.35 = sidechain - arrangementDetail.noveltyPeaks = structural event timestamps - segmentSpectral.stereoWidth changes = intentional width automation diff --git a/apps/backend/prompts/stem_summary_system.txt b/apps/backend/prompts/stem_summary_system.txt new file mode 100644 index 00000000..48a46f4a --- /dev/null +++ b/apps/backend/prompts/stem_summary_system.txt @@ -0,0 +1,48 @@ +You are a musically literate producer assistant generating a grounded stem summary. + +You receive: +1. AUTHORITATIVE_MEASUREMENT_RESULT_JSON +2. OPTIONAL_SYMBOLIC_EXTRACTION_RESULT_JSON +3. MEASUREMENT_DERIVED_DESCRIPTOR_HOOKS +4. GROUNDING_METADATA +5. The audio file itself + +GOAL: +- Produce a bar-aligned musical description that helps a producer understand what the bass and melodic stems are doing. +- This is not MIDI transcription. +- This is not measurement. +- This is editorial, uncertainty-aware, and grounded in the authoritative measurement JSON. + +ABSOLUTE RULES: +1. AUTHORITATIVE_MEASUREMENT_RESULT_JSON is the only source of truth for BPM, key, meter, loudness, duration, arrangement timing, and other numeric measurement fields. +2. Do not re-estimate or override BPM, key, time signature, LUFS, or segment boundaries from listening. +3. OPTIONAL_SYMBOLIC_EXTRACTION_RESULT_JSON is best-effort note scaffolding only. Use it to support note-name or scale-degree hypotheses when it helps, but never promote it to measurement truth. +4. If the symbolic result is absent, sparse, or low confidence, say so and fall back to cautious musical language. +5. Keep the output bar-aligned. Use the provided stable bar grid and measured arrangement timing rather than inventing a new grid. +6. Keep uncertainty explicit. If a bar is ambiguous, say that in the bar entry instead of pretending certainty. +7. Do not ask for or imply piano-roll precision. No MIDI editing language, no exact velocity claims, and no exact note-length claims beyond what is musically defensible. +8. Use the pumping/modulation descriptor only as context for groove and movement language. Do not convert it into new measurement claims. +9. Every bar entry must remain non-authoritative musical guidance. + +OUTPUT REQUIREMENTS: +- summary: 3-4 sentences on the musical role of the stem material across the section, grounded in measured tempo/structure. +- bars: return one concise entry per musically relevant bar or contiguous bar range. + - barStart: starting bar number + - barEnd: ending bar number + - startTime: measured start time in seconds + - endTime: measured end time in seconds + - noteHypotheses: short string list such as ["C3 pedal", "Eb3 pickup", "unclear lead tone"] + - scaleDegreeHypotheses: short string list such as ["1", "b3", "5"] when plausible; otherwise [] + - rhythmicPattern: one sentence on the bar-level rhythmic feel + - uncertaintyLevel: LOW, MED, or HIGH + - uncertaintyReason: one sentence explaining what is unclear or why the confidence is limited +- globalPatterns: + - bassRole: one paragraph on the bass function and register + - melodicRole: one paragraph on the melodic or hook function + - pumpingOrModulation: one paragraph on pumping, modulation, or movement cues from the measured hooks +- uncertaintyFlags: a short list of the most important caveats the producer should keep in mind + +STYLE: +- Use concise, musical producer language. +- Prefer note names or scale degrees only when the evidence supports them. +- If the evidence is weak, say "unclear", "approximate", or "draft hypothesis". diff --git a/apps/backend/server.py b/apps/backend/server.py index be18676c..8e5366e3 100644 --- a/apps/backend/server.py +++ b/apps/backend/server.py @@ -31,7 +31,13 @@ from fastapi.responses import JSONResponse from analysis_runtime import AnalysisRuntime, UnsupportedSymbolicModeError -from analyze import analyze_transcription, build_analysis_estimate, get_audio_duration_seconds, separate_stems +from analyze import ( + BasicPitchBackend, + analyze_transcription, + build_analysis_estimate, + get_audio_duration_seconds, + separate_stems, +) app = FastAPI(title="Sonic Analyzer Local API") @@ -99,7 +105,10 @@ def _mark_legacy_endpoint_response(response: JSONResponse, *, endpoint: str) -> return response -PHASE2_PROMPT_TEMPLATE = _load_prompt_template("phase2_system.txt") +PRODUCER_SUMMARY_PROMPT_TEMPLATE = _load_prompt_template("phase2_system.txt") +STEM_SUMMARY_PROMPT_TEMPLATE = _load_prompt_template("stem_summary_system.txt") +PHASE2_PROMPT_TEMPLATE = PRODUCER_SUMMARY_PROMPT_TEMPLATE +SUPPORTED_INTERPRETATION_PROFILES = {"producer_summary", "stem_summary"} PHASE2_RESPONSE_SCHEMA = { "type": "OBJECT", @@ -216,6 +225,51 @@ def _mark_legacy_endpoint_response(response: JSONResponse, *, endpoint: str) -> "abletonRecommendations", ], } +STEM_SUMMARY_RESPONSE_SCHEMA = { + "type": "OBJECT", + "properties": { + "summary": {"type": "STRING"}, + "bars": { + "type": "ARRAY", + "items": { + "type": "OBJECT", + "properties": { + "barStart": {"type": "NUMBER"}, + "barEnd": {"type": "NUMBER"}, + "startTime": {"type": "NUMBER"}, + "endTime": {"type": "NUMBER"}, + "noteHypotheses": {"type": "ARRAY", "items": {"type": "STRING"}}, + "scaleDegreeHypotheses": {"type": "ARRAY", "items": {"type": "STRING"}}, + "rhythmicPattern": {"type": "STRING"}, + "uncertaintyLevel": {"type": "STRING"}, + "uncertaintyReason": {"type": "STRING"}, + }, + "required": [ + "barStart", + "barEnd", + "startTime", + "endTime", + "noteHypotheses", + "scaleDegreeHypotheses", + "rhythmicPattern", + "uncertaintyLevel", + "uncertaintyReason", + ], + }, + }, + "globalPatterns": { + "type": "OBJECT", + "properties": { + "bassRole": {"type": "STRING"}, + "melodicRole": {"type": "STRING"}, + "pumpingOrModulation": {"type": "STRING"}, + }, + "required": ["bassRole", "melodicRole", "pumpingOrModulation"], + }, + "uncertaintyFlags": {"type": "ARRAY", "items": {"type": "STRING"}}, + }, + "required": ["summary", "bars", "globalPatterns", "uncertaintyFlags"], +} ALLOWED_ORIGINS = [ "http://localhost:3000", "http://127.0.0.1:3000", @@ -461,6 +515,8 @@ async def _create_analysis_run_record( ) -> tuple[AnalysisRuntime, str]: content = await track.read() runtime = get_analysis_runtime() + if interpretation_mode != "off": + _resolve_interpretation_profile_config(interpretation_profile) created = runtime.create_run( filename=track.filename or "upload.bin", content=content, @@ -841,6 +897,8 @@ def _execute_reserved_measurement_job( def _resolve_transcription_backend(backend_id: str) -> Any: if backend_id in ("", "auto", "default", "transcription-backend:auto"): return None + if backend_id in ("basic-pitch", "basic-pitch-legacy", "transcription-backend:basic-pitch-legacy"): + return BasicPitchBackend() raise RuntimeError(f"Unsupported symbolic backend '{backend_id}'.") @@ -949,6 +1007,7 @@ def _run_interpretation_request( source_path: str, filename: str, file_size_bytes: int, + profile_id: str, measurement_result: dict[str, Any], symbolic_result: dict[str, Any] | None, grounding_metadata: dict[str, Any], @@ -958,6 +1017,19 @@ def _run_interpretation_request( request_started_at = _current_time() flags_used: list[str] = [] mime_type = _get_audio_mime_type(filename) + descriptor_hooks = _build_descriptor_hooks(measurement_result) + + try: + profile_config = _resolve_interpretation_profile_config(profile_id) + except ValueError as exc: + return { + "ok": False, + "statusCode": 400, + "errorCode": "INTERPRETATION_PROFILE_UNSUPPORTED", + "message": str(exc), + "retryable": False, + "diagnostics": None, + } if not _GENAI_AVAILABLE: return { @@ -990,10 +1062,11 @@ def _run_interpretation_request( "diagnostics": None, } - prompt = _build_phase2_prompt( + prompt = profile_config["buildPrompt"]( measurement_result=measurement_result, symbolic_result=symbolic_result, grounding_metadata=grounding_metadata, + descriptor_hooks=descriptor_hooks, ) client = _genai.Client( api_key=api_key, @@ -1001,7 +1074,7 @@ def _run_interpretation_request( ) generate_config = _genai_types.GenerateContentConfig( response_mime_type="application/json", - response_schema=PHASE2_RESPONSE_SCHEMA, + response_schema=profile_config["responseSchema"], ) api_started_at = _current_time() uploaded_gemini_file = None @@ -1023,7 +1096,7 @@ def _generate_inline() -> Any: response = asyncio.run(_gemini_with_retry(_generate_inline)) api_completed_at = _current_time() - message_suffix = "Phase 2 advisory complete." + message_suffix = profile_config["successMessage"] else: flags_used.append("files-api") @@ -1058,13 +1131,13 @@ def _generate_files_api() -> Any: generate_end = _current_time() api_completed_at = _current_time() message_suffix = ( - "Phase 2 advisory complete. " + f"{profile_config['successMessage']} " f"Upload: {int(_elapsed_ms(upload_start, upload_end))}ms, " f"Generate: {int(_elapsed_ms(generate_start, generate_end))}ms" ) response_text: str | None = getattr(response, "text", None) - phase2_result, skip_message = _parse_phase2_result(response_text) + interpretation_result, skip_message = profile_config["parseResult"](response_text) diagnostics = _build_diagnostics( request_id=request_id, estimate={"totalLowMs": 0, "totalHighMs": 0}, @@ -1080,13 +1153,13 @@ def _generate_files_api() -> Any: if skip_message: return { "ok": True, - "phase2Result": None, + "interpretationResult": None, "message": skip_message, "diagnostics": diagnostics, } return { "ok": True, - "phase2Result": phase2_result, + "interpretationResult": interpretation_result, "message": message_suffix, "diagnostics": diagnostics, } @@ -1127,6 +1200,7 @@ def _execute_interpretation_attempt( attempt: dict[str, Any], ) -> dict[str, Any]: run_id = str(attempt["runId"]) + profile_id = _coerce_string(attempt.get("profileId"), "producer_summary") source_artifact = runtime.get_source_artifact(run_id) grounding = runtime.get_interpretation_grounding(run_id) measurement_result = grounding["measurementResult"] or {} @@ -1137,12 +1211,14 @@ def _execute_interpretation_attempt( "measurementOutputId": grounding["measurementOutputId"], "symbolicAttemptId": grounding["symbolicAttemptId"], "doNotPromoteSymbolicToMeasurement": True, + "profileId": profile_id, } model_name = _coerce_string(attempt.get("modelName"), "gemini-2.5-flash") execution = _run_interpretation_request( source_path=source_artifact["path"], filename=source_artifact["filename"], file_size_bytes=source_artifact["sizeBytes"], + profile_id=profile_id, measurement_result=measurement_result, symbolic_result=symbolic_result, grounding_metadata=grounding_metadata, @@ -1151,7 +1227,7 @@ def _execute_interpretation_attempt( ) provenance = { "schemaVersion": "interpretation.v1", - "profileId": attempt["profileId"], + "profileId": profile_id, "modelName": model_name, "groundedMeasurementRunId": run_id, "groundedMeasurementOutputId": grounding["measurementOutputId"], @@ -1160,7 +1236,7 @@ def _execute_interpretation_attempt( if execution["ok"]: runtime.complete_interpretation_attempt( str(attempt["attemptId"]), - result=execution["phase2Result"], + result=execution["interpretationResult"], provenance=provenance, diagnostics=execution["diagnostics"], grounded_measurement_output_id=grounding["measurementOutputId"], @@ -1274,6 +1350,16 @@ async def create_analysis_run( interpretation_model=interpretation_model, ) return JSONResponse(content=runtime.get_run(run_id)) + except ValueError as exc: + return JSONResponse( + status_code=400, + content={ + "error": { + "code": "INTERPRETATION_PROFILE_UNSUPPORTED", + "message": str(exc), + } + }, + ) except RuntimeError as exc: return JSONResponse( status_code=429, @@ -1356,6 +1442,7 @@ async def create_interpretation_attempt( ) -> JSONResponse: runtime = get_analysis_runtime() try: + _resolve_interpretation_profile_config(interpretation_profile) if runtime.get_measurement_status(run_id) != "completed": return JSONResponse( status_code=409, @@ -1379,6 +1466,16 @@ async def create_interpretation_attempt( }, ) return JSONResponse(status_code=202, content=runtime.get_run(run_id)) + except ValueError as exc: + return JSONResponse( + status_code=400, + content={ + "error": { + "code": "INTERPRETATION_PROFILE_UNSUPPORTED", + "message": str(exc), + } + }, + ) except KeyError: return JSONResponse( status_code=404, @@ -1528,19 +1625,152 @@ def _build_phase2_prompt( measurement_result: dict[str, Any], symbolic_result: dict[str, Any] | None, grounding_metadata: dict[str, Any], + descriptor_hooks: dict[str, Any] | None = None, +) -> str: + sections = [ + PRODUCER_SUMMARY_PROMPT_TEMPLATE.rstrip(), + "\n\nAUTHORITATIVE_MEASUREMENT_RESULT_JSON:\n", + json.dumps(measurement_result, indent=2), + "\n\nOPTIONAL_SYMBOLIC_EXTRACTION_RESULT_JSON:\n", + json.dumps(symbolic_result, indent=2), + "\n\nGROUNDING_METADATA:\n", + json.dumps(grounding_metadata, indent=2), + ] + if descriptor_hooks: + sections.extend( + [ + "\n\nMEASUREMENT_DERIVED_DESCRIPTOR_HOOKS:\n", + json.dumps(descriptor_hooks, indent=2), + ] + ) + return "".join(sections) + + +def _build_stem_summary_prompt( + *, + measurement_result: dict[str, Any], + symbolic_result: dict[str, Any] | None, + grounding_metadata: dict[str, Any], + descriptor_hooks: dict[str, Any], ) -> str: sections = [ - PHASE2_PROMPT_TEMPLATE.rstrip(), + STEM_SUMMARY_PROMPT_TEMPLATE.rstrip(), "\n\nAUTHORITATIVE_MEASUREMENT_RESULT_JSON:\n", json.dumps(measurement_result, indent=2), "\n\nOPTIONAL_SYMBOLIC_EXTRACTION_RESULT_JSON:\n", json.dumps(symbolic_result, indent=2), + "\n\nMEASUREMENT_DERIVED_DESCRIPTOR_HOOKS:\n", + json.dumps(descriptor_hooks, indent=2), "\n\nGROUNDING_METADATA:\n", json.dumps(grounding_metadata, indent=2), ] return "".join(sections) +def _build_descriptor_hooks(measurement_result: dict[str, Any]) -> dict[str, Any]: + duration_seconds = _coerce_nullable_number(measurement_result.get("durationSeconds")) + rhythm_detail = measurement_result.get("rhythmDetail") + segment_loudness = measurement_result.get("segmentLoudness") + sidechain_detail = measurement_result.get("sidechainDetail") + melody_detail = measurement_result.get("melodyDetail") + groove_detail = measurement_result.get("grooveDetail") + + downbeats: list[float] = [] + if isinstance(rhythm_detail, dict) and isinstance(rhythm_detail.get("downbeats"), list): + for entry in rhythm_detail["downbeats"]: + if _is_finite_num(entry): + downbeats.append(round(float(entry), 4)) + + bar_grid: list[dict[str, Any]] = [] + if downbeats: + for index, start_time in enumerate(downbeats): + end_time = ( + downbeats[index + 1] + if index + 1 < len(downbeats) + else duration_seconds + ) + if end_time is None: + continue + bar_grid.append( + { + "barStart": index + 1, + "barEnd": index + 1, + "startTime": round(float(start_time), 4), + "endTime": round(float(end_time), 4), + } + ) + + energy_curve: dict[str, Any] = { + "segmentLoudness": [], + "kickAccent16": [], + "hihatAccent16": [], + } + if isinstance(segment_loudness, list): + for entry in segment_loudness: + if not isinstance(entry, dict): + continue + energy_curve["segmentLoudness"].append( + { + "segmentIndex": entry.get("segmentIndex"), + "start": entry.get("start"), + "end": entry.get("end"), + "lufs": entry.get("lufs"), + "lra": entry.get("lra"), + } + ) + if isinstance(groove_detail, dict): + if isinstance(groove_detail.get("kickAccent"), list): + energy_curve["kickAccent16"] = groove_detail.get("kickAccent") + if isinstance(groove_detail.get("hihatAccent"), list): + energy_curve["hihatAccent16"] = groove_detail.get("hihatAccent") + + pumping_descriptor = { + "pumpingStrength": None, + "pumpingRegularity": None, + "pumpingRate": None, + "pumpingConfidence": None, + "vibratoPresent": None, + "vibratoRate": None, + "vibratoConfidence": None, + } + if isinstance(sidechain_detail, dict): + pumping_descriptor["pumpingStrength"] = sidechain_detail.get("pumpingStrength") + pumping_descriptor["pumpingRegularity"] = sidechain_detail.get("pumpingRegularity") + pumping_descriptor["pumpingRate"] = sidechain_detail.get("pumpingRate") + pumping_descriptor["pumpingConfidence"] = sidechain_detail.get("pumpingConfidence") + if isinstance(melody_detail, dict): + pumping_descriptor["vibratoPresent"] = melody_detail.get("vibratoPresent") + pumping_descriptor["vibratoRate"] = melody_detail.get("vibratoRate") + pumping_descriptor["vibratoConfidence"] = melody_detail.get("vibratoConfidence") + + return { + "stableBarGrid": bar_grid, + "beatSynchronousEnergyCurve": energy_curve, + "pumpingOrModulationDescriptor": pumping_descriptor, + } + + +def _resolve_interpretation_profile_config(profile_id: str) -> dict[str, Any]: + if profile_id == "producer_summary": + return { + "responseSchema": PHASE2_RESPONSE_SCHEMA, + "buildPrompt": _build_phase2_prompt, + "parseResult": _parse_phase2_result, + "successMessage": "AI interpretation complete.", + } + if profile_id == "stem_summary": + return { + "responseSchema": STEM_SUMMARY_RESPONSE_SCHEMA, + "buildPrompt": _build_stem_summary_prompt, + "parseResult": _parse_stem_summary_result, + "successMessage": "Stem summary complete.", + } + raise ValueError( + f"interpretation_profile '{profile_id}' is unsupported. " + f"Supported profiles: {sorted(SUPPORTED_INTERPRETATION_PROFILES)}" + ) + + def _is_str(v: Any) -> bool: return isinstance(v, str) @@ -1700,6 +1930,70 @@ def _parse_phase2_result( return parsed, None +def _is_string_array(value: Any) -> bool: + return isinstance(value, list) and all(isinstance(item, str) for item in value) + + +def _is_stem_summary_bars(value: Any) -> bool: + if not isinstance(value, list): + return False + for item in value: + record = _as_record(item) + if not record: + return False + if not ( + _is_finite_num(record.get("barStart")) + and _is_finite_num(record.get("barEnd")) + and _is_finite_num(record.get("startTime")) + and _is_finite_num(record.get("endTime")) + and _is_string_array(record.get("noteHypotheses")) + and _is_string_array(record.get("scaleDegreeHypotheses")) + and _is_str(record.get("rhythmicPattern")) + and record.get("uncertaintyLevel") in ("LOW", "MED", "HIGH") + and _is_str(record.get("uncertaintyReason")) + ): + return False + return True + + +def _is_stem_summary_global_patterns(value: Any) -> bool: + record = _as_record(value) + if not record: + return False + return ( + _is_str(record.get("bassRole")) + and _is_str(record.get("melodicRole")) + and _is_str(record.get("pumpingOrModulation")) + ) + + +def _is_valid_stem_summary_shape(value: Any) -> bool: + record = _as_record(value) + if not record: + return False + return ( + _is_str(record.get("summary")) + and _is_stem_summary_bars(record.get("bars")) + and _is_stem_summary_global_patterns(record.get("globalPatterns")) + and _is_string_array(record.get("uncertaintyFlags")) + ) + + +def _parse_stem_summary_result( + response_text: str | None, +) -> tuple[dict[str, Any] | None, str | None]: + raw = (response_text or "").strip() + if not raw: + return None, "Stem summary skipped because Gemini returned an empty response." + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return None, "Stem summary skipped because Gemini returned invalid JSON." + if not _is_valid_stem_summary_shape(parsed): + return None, "Stem summary skipped because Gemini returned an invalid response shape." + return parsed, None + + def _build_phase2_success_response( *, request_id: str, @@ -2235,7 +2529,7 @@ async def analyze_phase2( content={ "requestId": request_id, "analysisRunId": run_id, - "phase2": execution["phase2Result"], + "phase2": execution["interpretationResult"], "message": execution["message"], "diagnostics": execution["diagnostics"], } diff --git a/apps/backend/tests/test_analysis_runtime.py b/apps/backend/tests/test_analysis_runtime.py index f0483178..511b5bcc 100644 --- a/apps/backend/tests/test_analysis_runtime.py +++ b/apps/backend/tests/test_analysis_runtime.py @@ -85,7 +85,7 @@ def test_measurement_completion_strips_transcription_and_enqueues_symbolic_stage "key": "A minor", "durationSeconds": 184.2, "transcriptionDetail": { - "transcriptionMethod": "basic-pitch", + "transcriptionMethod": "basic-pitch-legacy", "noteCount": 2, "averageConfidence": 0.83, "stemSeparationUsed": True, diff --git a/apps/backend/tests/test_analyze.py b/apps/backend/tests/test_analyze.py index 1fab4ccb..078eb6e1 100644 --- a/apps/backend/tests/test_analyze.py +++ b/apps/backend/tests/test_analyze.py @@ -579,7 +579,7 @@ def setUpClass(cls) -> None: def test_basic_pitch_backend_name(self) -> None: backend = self.analyze.BasicPitchBackend() - self.assertEqual(backend.name, "basic-pitch") + self.assertEqual(backend.name, "basic-pitch-legacy") def test_basic_pitch_backend_satisfies_protocol(self) -> None: backend = self.analyze.BasicPitchBackend() diff --git a/apps/backend/tests/test_server.py b/apps/backend/tests/test_server.py index 56d1112f..36d592e5 100644 --- a/apps/backend/tests/test_server.py +++ b/apps/backend/tests/test_server.py @@ -110,6 +110,31 @@ def _valid_phase2_result() -> dict: } +def _valid_stem_summary_result() -> dict: + return { + "summary": "Bass pulses anchor the section while the upper stem stays approximate.", + "bars": [ + { + "barStart": 1, + "barEnd": 2, + "startTime": 0.0, + "endTime": 3.75, + "noteHypotheses": ["C3 pedal"], + "scaleDegreeHypotheses": ["1"], + "rhythmicPattern": "Short off-beat bass pulses.", + "uncertaintyLevel": "LOW", + "uncertaintyReason": "Symbolic extraction and measured downbeats agree.", + } + ], + "globalPatterns": { + "bassRole": "Anchors the groove in the low register.", + "melodicRole": "Sparse upper-register punctuation.", + "pumpingOrModulation": "Measured pumping suggests compressor-led movement.", + }, + "uncertaintyFlags": ["Upper melodic detail is approximate."], + } + + class ServerContractTests(unittest.TestCase): def _upload_file(self) -> UploadFile: return UploadFile(filename="track.mp3", file=io.BytesIO(b"fake-audio")) @@ -191,6 +216,27 @@ def test_analysis_runs_endpoint_returns_canonical_stage_snapshot(self) -> None: self.assertEqual(payload["stages"]["symbolicExtraction"]["status"], "blocked") self.assertEqual(payload["stages"]["interpretation"]["status"], "blocked") + def test_analysis_runs_endpoint_accepts_stem_summary_profile(self) -> None: + from analysis_runtime import AnalysisRuntime + + with tempfile.TemporaryDirectory(prefix="asa_server_runtime_") as temp_dir: + runtime = AnalysisRuntime(Path(temp_dir) / "runtime") + with patch.object(server, "get_analysis_runtime", return_value=runtime): + response = asyncio.run( + server.create_analysis_run( + track=self._upload_file(), + symbolic_mode="off", + symbolic_backend="auto", + interpretation_mode="async", + interpretation_profile="stem_summary", + interpretation_model="gemini-2.5-flash", + ) + ) + + payload = self._decode_json_response(response) + self.assertEqual(response.status_code, 200) + self.assertEqual(payload["requestedStages"]["interpretationProfile"], "stem_summary") + def test_get_analysis_run_returns_persisted_stage_snapshot(self) -> None: from analysis_runtime import AnalysisRuntime @@ -234,7 +280,7 @@ def test_get_analysis_run_returns_persisted_stage_snapshot(self) -> None: }, { "key": "transcription_stems", - "label": "Basic Pitch on bass + other stems", + "label": "Legacy Basic Pitch on bass + other stems", "seconds": {"min": 40, "max": 75}, }, ], @@ -284,7 +330,7 @@ def test_estimate_endpoint_combines_separate_and_transcribe_flags( }, { "key": "transcription_stems", - "label": "Basic Pitch on bass + other stems", + "label": "Legacy Basic Pitch on bass + other stems", "seconds": {"min": 40, "max": 75}, }, ], @@ -1373,6 +1419,74 @@ def test_compatibility_wrapper_can_resolve_run_from_legacy_request_id(self) -> N body = self._decode(response) self.assertEqual(body["analysisRunId"], run_id) + def test_stem_summary_profile_uses_dedicated_prompt_schema_and_hooks(self) -> None: + mock_client = self._mock_successful_gemini(json.dumps(_valid_stem_summary_result())) + from analysis_runtime import AnalysisRuntime + + with tempfile.TemporaryDirectory(prefix="asa_phase2_runtime_") as temp_dir: + runtime = AnalysisRuntime(Path(temp_dir) / "runtime") + run_id = self._make_completed_run(runtime) + runtime.create_symbolic_attempt( + run_id, + backend_id="basic-pitch-legacy", + mode="stem_notes", + status="completed", + result={ + "transcriptionMethod": "basic-pitch-legacy", + "noteCount": 1, + "averageConfidence": 0.8, + "stemSeparationUsed": True, + "fullMixFallback": False, + "stemsTranscribed": ["bass"], + "dominantPitches": [], + "pitchRange": { + "minMidi": 48, + "maxMidi": 48, + "minName": "C3", + "maxName": "C3", + }, + "notes": [], + }, + provenance={"backendId": "basic-pitch-legacy"}, + ) + attempt_id = runtime.create_interpretation_attempt( + run_id, + profile_id="stem_summary", + model_name="gemini-2.5-flash", + status="queued", + ) + runtime.reserve_interpretation_attempt(attempt_id) + with ( + patch.object(server, "_GENAI_AVAILABLE", True), + patch.dict(server.os.environ, {"GEMINI_API_KEY": "fake-key"}), + patch.object(server, "_genai") as mock_genai, + patch.object(server, "_genai_types") as mock_genai_types, + ): + mock_genai.Client.return_value = mock_client + mock_genai_types.GenerateContentConfig.return_value = unittest.mock.MagicMock() + execution = server._execute_interpretation_attempt( + runtime, + { + "attemptId": attempt_id, + "runId": run_id, + "profileId": "stem_summary", + "modelName": "gemini-2.5-flash", + }, + ) + + self.assertTrue(execution["ok"]) + self.assertEqual( + mock_genai_types.GenerateContentConfig.call_args.kwargs["response_schema"], + server.STEM_SUMMARY_RESPONSE_SCHEMA, + ) + prompt = mock_client.models.generate_content.call_args.kwargs["contents"][0]["parts"][1]["text"] + self.assertIn("MEASUREMENT_DERIVED_DESCRIPTOR_HOOKS", prompt) + self.assertIn("stableBarGrid", prompt) + self.assertIn("pumpingOrModulationDescriptor", prompt) + snapshot = runtime.get_run(run_id) + self.assertEqual(snapshot["stages"]["interpretation"]["attemptsSummary"][0]["profileId"], "stem_summary") + self.assertEqual(snapshot["stages"]["interpretation"]["result"]["summary"], _valid_stem_summary_result()["summary"]) + def test_openapi_schema_exposes_phase2_route(self) -> None: spec = server.app.openapi() self.assertIn("/api/phase2", spec["paths"]) diff --git a/apps/ui/README.md b/apps/ui/README.md index 2044b6b2..9627532b 100644 --- a/apps/ui/README.md +++ b/apps/ui/README.md @@ -2,25 +2,25 @@ React and Vite frontend for the Sonic Analyzer workflow. -The app uploads a track to the local DSP backend, shows the estimate and execution status for Phase 1, optionally runs a Gemini advisory pass for Phase 2, and renders the returned analysis in a browser UI. +The app uploads a track to the local DSP backend, shows the estimate and execution status for measurement plus downstream stages, optionally runs AI interpretation, and renders the returned analysis in a browser UI. ## Current Features - file upload with drag-and-drop and file picker, audio type validation with extension fallback when browser MIME is blank, and local audio preview - file size warning for uploads exceeding 100 MB (non-blocking) - inline error messages for invalid files with dismiss and retry controls -- automatic Phase 1 estimate request on file selection +- automatic measurement estimate request on file selection - local DSP execution status with elapsed time, stage estimates, and progress bar - - cancel button during analysis with end-to-end abort handling across Phase 1 and Phase 2 -- optional Basic Pitch transcription toggle for the backend request -- optional Demucs stem separation toggle for the backend request, independent of MIDI transcription -- optional Gemini Phase 2 advisory pass with a selectable model +- cancel button during analysis with end-to-end abort handling across measurement and AI interpretation +- optional symbolic extraction toggle for the backend request +- optional Demucs stem separation toggle for the backend request, independent of symbolic extraction +- optional Gemini AI interpretation pass with a selectable model - analysis result dashboard with arrangement, sonic, mix-chain, patch, and secret-sauce sections - Session Musician panel with: - - polyphonic Basic Pitch note view when `transcriptionDetail` exists - - monophonic Essentia note view when `melodyDetail` exists + - symbolic note view when `transcriptionDetail` exists + - monophonic Essentia melody guide when `melodyDetail` exists - source toggle when both are available - - confidence threshold slider (polyphonic mode only; disabled in monophonic mode with tooltip) + - confidence threshold slider (symbolic-note mode only; disabled in melody-guide mode with tooltip) - quantize grid and swing controls - browser preview and `.mid` download - JSON export and markdown report export @@ -64,8 +64,8 @@ cp .env.example .env | Variable | Meaning | Current behavior | | --- | --- | --- | | `VITE_API_BASE_URL` | Base URL for the backend API. | `src/config.ts` falls back to `http://127.0.0.1:8100` when unset. The checked-in `.env.example` uses `http://127.0.0.1:8100`. If another FastAPI app is answering on your configured URL, the UI now reports that it found the wrong service and disables `Initiate Analysis`. | -| `VITE_ENABLE_PHASE2_GEMINI` | Hard kill-switch for the optional Gemini pass. | Defaults to `"true"` when unset. Set it to `"false"` only when you want to disable Phase 2 for the whole build. | -| `VITE_GEMINI_API_KEY` | Gemini API key. | Phase 2 is on by default in the UI, but the advisory run is blocked until this value is non-empty. | +| `VITE_ENABLE_PHASE2_GEMINI` | Hard kill-switch for the optional Gemini interpretation pass. | Defaults to `"true"` when unset. Set it to `"false"` only when you want to disable AI interpretation for the whole build. | +| `VITE_GEMINI_API_KEY` | Gemini API key. | AI interpretation is on by default in the UI, but the interpretation run is blocked until this value is non-empty. | | `RUN_GEMINI_LIVE_SMOKE` | Enables the opt-in live Playwright proof for the Gemini Files API path. | Must be `"true"` to run `npm run test:smoke:live-gemini`; default smoke coverage keeps Gemini mocked. | | `DISABLE_HMR` | Vite dev-server knob. | `vite.config.ts` disables HMR only when this is `"true"`. | @@ -164,7 +164,7 @@ What the UI expects back: Current note: - the UI uses this response only for display -- if this request fails, the app still lets the user start Phase 1 +- if this request fails, the app still lets the user start analysis ### `POST /api/analyze` @@ -175,7 +175,7 @@ When it runs: What the UI sends today: - multipart `track` -- multipart `transcribe=true|false` based on the MIDI transcription toggle +- multipart `transcribe=true|false` based on the symbolic extraction toggle - multipart `separate=true|false` based on the stem separation toggle - no `separate` query parameter @@ -265,17 +265,17 @@ The UI also understands the backend error envelope: This is what powers the visible backend error message and the diagnostic log entries. -## Phase 2 +## AI Interpretation -Phase 2 is optional and entirely frontend-owned. +AI interpretation is optional and entirely frontend-owned. Current behavior: -- Phase 2 is on by default unless `VITE_ENABLE_PHASE2_GEMINI="false"` is used as a hard kill-switch. -- The app remembers the user's `PHASE 2 ADVISORY` toggle in browser storage. +- AI interpretation is on by default unless `VITE_ENABLE_PHASE2_GEMINI="false"` is used as a hard kill-switch. +- The app remembers the user's AI interpretation toggle in browser storage. - If the UI toggle is on but `VITE_GEMINI_API_KEY` is missing, analysis start is blocked and the app tells you to either update `apps/ui/.env`, `export VITE_GEMINI_API_KEY=...`, or run `VITE_GEMINI_API_KEY=... ./scripts/dev.sh`. - The user can choose from the baked-in Gemini model list in `src/App.tsx`. -- The prompt uses the uploaded audio file plus the completed `phase1` payload. +- The prompt uses the uploaded audio file plus the completed measurement payload and any server-owned downstream context. - Phase 2 results drive the arrangement narrative, sonic element cards, mix chain, patch framework, secret sauce, and recommendation sections. - Audio files at or below 100MB are sent to Gemini as inline base64. Audio files above 100MB are uploaded via the Gemini Files API before generation and deleted immediately after; the diagnostic log shows upload and generation durations separately. diff --git a/apps/ui/src/components/SessionMusicianPanel.tsx b/apps/ui/src/components/SessionMusicianPanel.tsx index da045eb2..0d91014b 100644 --- a/apps/ui/src/components/SessionMusicianPanel.tsx +++ b/apps/ui/src/components/SessionMusicianPanel.tsx @@ -68,6 +68,17 @@ export function deriveTranscriptionProvenance( }; } +function formatSymbolicMethodLabel(transcriptionMethod: string | null | undefined): string { + const normalized = (transcriptionMethod ?? '').trim().toLowerCase(); + if (normalized === 'basic-pitch' || normalized === 'basic_pitch' || normalized === 'basic-pitch-legacy') { + return 'BASIC PITCH LEGACY'; + } + if (!normalized) { + return 'SYMBOLIC EXTRACTION'; + } + return transcriptionMethod!.replace(/[_-]+/g, ' ').toUpperCase(); +} + function midiToNoteName(midi: number): string { const names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] as const; const clamped = Math.max(0, Math.min(127, Math.round(midi))); @@ -315,9 +326,9 @@ export function SessionMusicianPanel({ phase1, sourceFileName }: SessionMusician : false; const sourceBadgeLabel = activeSource === 'polyphonic' - ? 'SOURCES: BASIC PITCH' + ? `SOURCE: ${formatSymbolicMethodLabel(transcriptionDetail?.transcriptionMethod)}` : activeSource === 'monophonic' - ? 'SOURCES: ESSENTIA' + ? 'SOURCE: ESSENTIA MELODY' : null; const { transcriptionPathLabel, stemSourcesLabel } = deriveTranscriptionProvenance(activeSource, transcriptionDetail); const melodyIsApproximate = !!melodyDetail && (melodyDetail.pitchConfidence ?? 1) <= 0.15; @@ -338,7 +349,7 @@ export function SessionMusicianPanel({ phase1, sourceFileName }: SessionMusician )} - MIDI TRANSCRIPTION + SYMBOLIC NOTES
@@ -350,7 +361,7 @@ export function SessionMusicianPanel({ phase1, sourceFileName }: SessionMusician

SESSION MUSICIAN

- Audio to MIDI transcription + Symbolic notes and melody guide

@@ -384,7 +395,7 @@ export function SessionMusicianPanel({ phase1, sourceFileName }: SessionMusician : 'text-text-secondary hover:text-text-primary' }`} > - POLYPHONIC + SYMBOLIC )} @@ -414,10 +425,10 @@ export function SessionMusicianPanel({ phase1, sourceFileName }: SessionMusician {activeSource === 'none' && (

- MIDI TRANSCRIPTION UNAVAILABLE + SYMBOLIC NOTES UNAVAILABLE

- Run with --transcribe flag for Basic Pitch polyphonic transcription, or ensure melodyDetail is present in DSP JSON + Run with symbolic extraction enabled, or ensure melodyDetail is present in the DSP payload for a melody guide

)} @@ -528,7 +539,7 @@ export function SessionMusicianPanel({ phase1, sourceFileName }: SessionMusician
CONFIDENCE {activeSource === 'polyphonic' - ? 'Polyphonic transcription via Basic Pitch. Adjust quantize before preview/export. Adjust confidence threshold to filter noise before export.' + ? `${formatSymbolicMethodLabel(transcriptionDetail?.transcriptionMethod)} symbolic notes. Adjust quantize before preview/export. Adjust confidence threshold to filter noise before export.` : activeSource === 'monophonic' - ? 'Monophonic pitch detection via Essentia. Adjust quantize before preview/export. Per-note confidence not available in monophonic mode.' - : 'MIDI transcription unavailable until transcriptionDetail or melodyDetail is present in the DSP payload.'} + ? 'Monophonic melody guide via Essentia. Adjust quantize before preview/export. Per-note confidence not available in melody-guide mode.' + : 'Symbolic notes unavailable until symbolic extraction or melodyDetail is present in the DSP payload.'} {isDraft ? ' Confidence is low, so treat this clip as a draft scaffold.' : ''}
diff --git a/apps/ui/src/services/analysisRunsClient.ts b/apps/ui/src/services/analysisRunsClient.ts index 5a8e482b..6ccc3161 100644 --- a/apps/ui/src/services/analysisRunsClient.ts +++ b/apps/ui/src/services/analysisRunsClient.ts @@ -5,10 +5,12 @@ import { AnalysisStageError, AnalysisStageStatus, InterpretationAttemptSummary, + InterpretationResult, InterpretationStageSnapshot, MeasurementResult, Phase1Result, Phase2Result, + StemSummaryResult, SymbolicExtractionAttemptSummary, SymbolicExtractionStageSnapshot, } from '../types'; @@ -147,7 +149,19 @@ export function projectPhase1FromRun(snapshot: AnalysisRunSnapshot): Phase1Resul } export function projectPhase2FromRun(snapshot: AnalysisRunSnapshot): Phase2Result | null { - return snapshot.stages.interpretation.result; + const preferredProfileId = getPreferredInterpretationProfileId(snapshot.stages.interpretation); + if (preferredProfileId !== 'producer_summary') { + return null; + } + return snapshot.stages.interpretation.result as Phase2Result | null; +} + +export function projectStemSummaryFromRun(snapshot: AnalysisRunSnapshot): StemSummaryResult | null { + const preferredProfileId = getPreferredInterpretationProfileId(snapshot.stages.interpretation); + if (preferredProfileId !== 'stem_summary') { + return null; + } + return snapshot.stages.interpretation.result as StemSummaryResult | null; } async function fetchJson(url: string, init: RequestInit): Promise { @@ -271,14 +285,19 @@ function parseSymbolicStage(value: Record): SymbolicExtractionS } function parseInterpretationStage(value: Record): InterpretationStageSnapshot { + const attemptsSummary = Array.isArray(value.attemptsSummary) + ? value.attemptsSummary.map(parseInterpretationAttemptSummary) + : []; + const preferredProfileId = getPreferredInterpretationProfileId({ + preferredAttemptId: asString(value.preferredAttemptId), + attemptsSummary, + }); return { status: expectStageStatus(value.status), authoritative: false, preferredAttemptId: asString(value.preferredAttemptId), - attemptsSummary: Array.isArray(value.attemptsSummary) - ? value.attemptsSummary.map(parseInterpretationAttemptSummary) - : [], - result: value.result == null ? null : (expectRecord(value.result, 'interpretation result') as unknown as Phase2Result), + attemptsSummary, + result: value.result == null ? null : parseInterpretationResult(value.result, preferredProfileId), provenance: parseNullableRecord(value.provenance), diagnostics: parseNullableRecord(value.diagnostics), error: parseNullableError(value.error), @@ -305,6 +324,52 @@ function parseInterpretationAttemptSummary(value: unknown): InterpretationAttemp }; } +function parseInterpretationResult( + value: unknown, + profileId: string | null, +): InterpretationResult { + if (profileId === 'stem_summary') { + return parseStemSummaryResult(value); + } + return expectRecord(value, 'interpretation result') as unknown as Phase2Result; +} + +function parseStemSummaryResult(value: unknown): StemSummaryResult { + const result = expectRecord(value, 'stem summary result'); + return { + summary: expectString(result.summary, 'stem summary summary'), + bars: Array.isArray(result.bars) + ? result.bars.map((entry) => { + const bar = expectRecord(entry, 'stem summary bar'); + return { + barStart: expectNumber(bar.barStart, 'stem summary barStart'), + barEnd: expectNumber(bar.barEnd, 'stem summary barEnd'), + startTime: expectNumber(bar.startTime, 'stem summary startTime'), + endTime: expectNumber(bar.endTime, 'stem summary endTime'), + noteHypotheses: Array.isArray(bar.noteHypotheses) ? bar.noteHypotheses.map((item) => String(item)) : [], + scaleDegreeHypotheses: Array.isArray(bar.scaleDegreeHypotheses) + ? bar.scaleDegreeHypotheses.map((item) => String(item)) + : [], + rhythmicPattern: expectString(bar.rhythmicPattern, 'stem summary rhythmicPattern'), + uncertaintyLevel: expectString(bar.uncertaintyLevel, 'stem summary uncertaintyLevel') as StemSummaryResult['bars'][number]['uncertaintyLevel'], + uncertaintyReason: expectString(bar.uncertaintyReason, 'stem summary uncertaintyReason'), + }; + }) + : [], + globalPatterns: { + bassRole: expectString(expectRecord(result.globalPatterns, 'stem summary globalPatterns').bassRole, 'stem summary bassRole'), + melodicRole: expectString(expectRecord(result.globalPatterns, 'stem summary globalPatterns').melodicRole, 'stem summary melodicRole'), + pumpingOrModulation: expectString( + expectRecord(result.globalPatterns, 'stem summary globalPatterns').pumpingOrModulation, + 'stem summary pumpingOrModulation', + ), + }, + uncertaintyFlags: Array.isArray(result.uncertaintyFlags) + ? result.uncertaintyFlags.map((item) => String(item)) + : [], + }; +} + function parseSymbolicResult(value: unknown): SymbolicExtractionStageSnapshot['result'] { const result = expectRecord(value, 'symbolic result'); return { @@ -377,3 +442,10 @@ function expectStageStatus(value: unknown): AnalysisStageStatus { function asString(value: unknown): string | null { return typeof value === 'string' && value.trim() !== '' ? value : null; } + +function getPreferredInterpretationProfileId( + stage: Pick, +): string | null { + const preferredAttempt = stage.attemptsSummary.find((attempt) => attempt.attemptId === stage.preferredAttemptId); + return preferredAttempt?.profileId ?? stage.attemptsSummary[0]?.profileId ?? null; +} diff --git a/apps/ui/src/services/backendPhase1Client.ts b/apps/ui/src/services/backendPhase1Client.ts index 3489cf58..8fce65fc 100644 --- a/apps/ui/src/services/backendPhase1Client.ts +++ b/apps/ui/src/services/backendPhase1Client.ts @@ -574,7 +574,7 @@ function parseOptionalTranscriptionDetail( const noteCount = noteCountRaw === null ? notes.length : Math.max(0, Math.round(noteCountRaw)); return { - transcriptionMethod: toOptionalStringOrNull(raw.transcriptionMethod) ?? "basic-pitch", + transcriptionMethod: toOptionalStringOrNull(raw.transcriptionMethod) ?? "basic-pitch-legacy", noteCount, averageConfidence: clamp01(toNumberOrFallback(raw.averageConfidence, 0)), stemSeparationUsed: toBooleanOrFallback(raw.stemSeparationUsed, false), diff --git a/apps/ui/src/types.ts b/apps/ui/src/types.ts index 6cdcbbc9..d15f07f9 100644 --- a/apps/ui/src/types.ts +++ b/apps/ui/src/types.ts @@ -168,6 +168,31 @@ export interface Phase2Result { abletonRecommendations: AbletonRecommendation[]; } +export interface StemSummaryBar { + barStart: number; + barEnd: number; + startTime: number; + endTime: number; + noteHypotheses: string[]; + scaleDegreeHypotheses: string[]; + rhythmicPattern: string; + uncertaintyLevel: "LOW" | "MED" | "HIGH"; + uncertaintyReason: string; +} + +export interface StemSummaryResult { + summary: string; + bars: StemSummaryBar[]; + globalPatterns: { + bassRole: string; + melodicRole: string; + pumpingOrModulation: string; + }; + uncertaintyFlags: string[]; +} + +export type InterpretationResult = Phase2Result | StemSummaryResult; + export interface BackendTimingDiagnostics { totalMs: number; analysisMs: number; @@ -270,7 +295,7 @@ export interface InterpretationStageSnapshot { authoritative: false; preferredAttemptId: string | null; attemptsSummary: InterpretationAttemptSummary[]; - result: Phase2Result | null; + result: InterpretationResult | null; provenance: Record | null; diagnostics: Record | null; error: AnalysisStageError | null; diff --git a/apps/ui/tests/e2e/phase1-exports.spec.ts b/apps/ui/tests/e2e/phase1-exports.spec.ts index 2d0a4a4a..76ddf7e4 100644 --- a/apps/ui/tests/e2e/phase1-exports.spec.ts +++ b/apps/ui/tests/e2e/phase1-exports.spec.ts @@ -21,7 +21,7 @@ test('live phase 1 estimate, analysis, and exports succeed against the local bac await gotoUploadPage(page); await uploadAudioFile(page, fixturePath); - await setToggle(page, 'PHASE 2 ADVISORY', false); + await setToggle(page, 'AI INTERPRETATION', false); await waitForEstimate(page); await startAnalysis(page); diff --git a/apps/ui/tests/e2e/phase2-files-api.spec.ts b/apps/ui/tests/e2e/phase2-files-api.spec.ts index 09e8b706..6c0279e2 100644 --- a/apps/ui/tests/e2e/phase2-files-api.spec.ts +++ b/apps/ui/tests/e2e/phase2-files-api.spec.ts @@ -26,7 +26,7 @@ test('live Gemini Files API path uploads, generates, and deletes large audio', a await gotoUploadPage(page); await uploadAudioFile(page, fixturePath); - await setToggle(page, 'PHASE 2 ADVISORY', true); + await setToggle(page, 'AI INTERPRETATION', true); await selectPhase2Model(page, DEFAULT_PHASE2_MODEL); await waitForEstimate(page, 60_000); diff --git a/apps/ui/tests/e2e/phase2-inline.spec.ts b/apps/ui/tests/e2e/phase2-inline.spec.ts index 14fe41ed..9ba8e439 100644 --- a/apps/ui/tests/e2e/phase2-inline.spec.ts +++ b/apps/ui/tests/e2e/phase2-inline.spec.ts @@ -26,7 +26,7 @@ test('live Gemini inline path uses inlineData without Files API traffic', async await gotoUploadPage(page); await uploadAudioFile(page, fixturePath); - await setToggle(page, 'PHASE 2 ADVISORY', true); + await setToggle(page, 'AI INTERPRETATION', true); await selectPhase2Model(page, DEFAULT_PHASE2_MODEL); await waitForEstimate(page); diff --git a/apps/ui/tests/e2e/session-musician.spec.ts b/apps/ui/tests/e2e/session-musician.spec.ts index 7f668994..bf7069bd 100644 --- a/apps/ui/tests/e2e/session-musician.spec.ts +++ b/apps/ui/tests/e2e/session-musician.spec.ts @@ -20,9 +20,8 @@ test('live transcription and separation render Session Musician and export MIDI' await gotoUploadPage(page); await uploadAudioFile(page, fixturePath); - await setToggle(page, 'MIDI TRANSCRIPTION', true); - await setToggle(page, 'STEM SEPARATION', true); - await setToggle(page, 'PHASE 2 ADVISORY', false); + await setToggle(page, 'SYMBOLIC EXTRACTION', true); + await setToggle(page, 'AI INTERPRETATION', false); await waitForEstimate(page, 60_000); await startAnalysis(page); @@ -32,13 +31,13 @@ test('live transcription and separation render Session Musician and export MIDI' await expect(page.getByText('SESSION MUSICIAN').first()).toBeVisible({ timeout: 12 * 60 * 1_000 }); await expect(page.getByRole('button', { name: /Download \.mid/i })).toBeEnabled(); - const polyphonicToggle = page.getByRole('button', { name: 'POLYPHONIC' }); - const monophonicToggle = page.getByRole('button', { name: 'MONOPHONIC' }); - if (await polyphonicToggle.count()) { - await polyphonicToggle.click(); + const symbolicToggle = page.getByRole('button', { name: 'SYMBOLIC' }); + const melodyToggle = page.getByRole('button', { name: 'MELODY' }); + if (await symbolicToggle.count()) { + await symbolicToggle.click(); } - if (await monophonicToggle.count()) { - await monophonicToggle.click(); + if (await melodyToggle.count()) { + await melodyToggle.click(); } const downloadPromise = page.waitForEvent('download'); diff --git a/apps/ui/tests/services/analysisResultsUi.test.ts b/apps/ui/tests/services/analysisResultsUi.test.ts index 29ee4804..9c5fb3e6 100644 --- a/apps/ui/tests/services/analysisResultsUi.test.ts +++ b/apps/ui/tests/services/analysisResultsUi.test.ts @@ -287,7 +287,7 @@ describe('AnalysisResults UI wiring', () => { expect(MIDI_DOWNLOAD_FILE_NAME).toBe('track-analysis.mid'); }); - it('renders MIDI unavailable state when melodyDetail is missing', () => { + it('renders symbolic-note unavailable state when melodyDetail is missing', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { phase1: basePhase1, @@ -296,11 +296,11 @@ describe('AnalysisResults UI wiring', () => { }), ); - expect(html).toContain('MIDI TRANSCRIPTION UNAVAILABLE'); - expect(html).toContain('Run with --transcribe flag for Basic Pitch polyphonic transcription, or ensure melodyDetail is present in DSP JSON'); + expect(html).toContain('SYMBOLIC NOTES UNAVAILABLE'); + expect(html).toContain('Run with symbolic extraction enabled, or ensure melodyDetail is present in the DSP payload for a melody guide'); }); - it('shows the polyphonic toggle state by default when both sources are available', () => { + it('shows the symbolic toggle state by default when both sources are available', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { phase1: { @@ -319,7 +319,7 @@ describe('AnalysisResults UI wiring', () => { vibratoConfidence: 0, }, transcriptionDetail: { - transcriptionMethod: 'basic-pitch', + transcriptionMethod: 'basic-pitch-legacy', noteCount: 2, averageConfidence: 0.83, stemSeparationUsed: true, @@ -360,9 +360,9 @@ describe('AnalysisResults UI wiring', () => { }), ); - expect(html).toContain('POLYPHONIC'); - expect(html).toContain('MONOPHONIC'); - expect(html).toContain('Polyphonic transcription via Basic Pitch'); + expect(html).toContain('SYMBOLIC'); + expect(html).toContain('MELODY'); + expect(html).toContain('BASIC PITCH LEGACY symbolic notes'); expect(html).toContain('Range: C3 - G4'); expect(html).toContain('Confidence: 83%'); expect(html.match(/Range: C3 - G4/g)?.length ?? 0).toBe(1); @@ -370,11 +370,11 @@ describe('AnalysisResults UI wiring', () => { expect(html).toContain('2 / 2 NOTES'); expect(html).toContain('CONFIDENCE'); expect(html).toContain('20%'); - expect(html).toContain('SOURCES: BASIC PITCH'); + expect(html).toContain('SOURCE: BASIC PITCH LEGACY'); expect(html).toContain('STEM-AWARE'); expect(html).toContain('STEMS: bass, other'); expect(html).toContain('Adjust confidence threshold to filter noise before export.'); - expect(html).not.toContain('MIDI TRANSCRIPTION UNAVAILABLE'); + expect(html).not.toContain('SYMBOLIC NOTES UNAVAILABLE'); }); it('shows Essentia source badges when only melodyDetail is available', () => { @@ -405,10 +405,10 @@ describe('AnalysisResults UI wiring', () => { }), ); - expect(html).not.toContain('POLYPHONIC'); - expect(html).not.toContain('MONOPHONIC'); - expect(html).toContain('SOURCES: ESSENTIA'); - expect(html).toContain('Monophonic pitch detection via Essentia'); + expect(html).not.toContain('SOURCE: BASIC PITCH LEGACY'); + expect(html).not.toContain('BASIC PITCH LEGACY symbolic notes'); + expect(html).toContain('SOURCE: ESSENTIA MELODY'); + expect(html).toContain('Monophonic melody guide via Essentia'); }); it('renders full-mix provenance when transcription did not use Demucs stems', () => { @@ -417,7 +417,7 @@ describe('AnalysisResults UI wiring', () => { phase1: { ...basePhase1, transcriptionDetail: { - transcriptionMethod: 'basic-pitch', + transcriptionMethod: 'basic-pitch-legacy', noteCount: 1, averageConfidence: 0.61, stemSeparationUsed: false, diff --git a/apps/ui/tests/services/analysisResultsViewModel.test.ts b/apps/ui/tests/services/analysisResultsViewModel.test.ts index a8ef59dd..5ac7703c 100644 --- a/apps/ui/tests/services/analysisResultsViewModel.test.ts +++ b/apps/ui/tests/services/analysisResultsViewModel.test.ts @@ -264,7 +264,7 @@ describe('analysisResultsViewModel helpers', () => { const insights = buildMelodyInsights({ ...phase1, transcriptionDetail: { - transcriptionMethod: 'basic-pitch', + transcriptionMethod: 'basic-pitch-legacy', noteCount: 6, averageConfidence: 0.83, stemSeparationUsed: true, diff --git a/apps/ui/tests/services/analysisRunsClient.test.ts b/apps/ui/tests/services/analysisRunsClient.test.ts index c58a67ee..681058ab 100644 --- a/apps/ui/tests/services/analysisRunsClient.test.ts +++ b/apps/ui/tests/services/analysisRunsClient.test.ts @@ -8,6 +8,7 @@ import { getAnalysisRun, projectPhase1FromRun, projectPhase2FromRun, + projectStemSummaryFromRun, } from '../../src/services/analysisRunsClient'; const baseRunSnapshot: AnalysisRunSnapshot = { @@ -202,7 +203,7 @@ describe('analysisRunsClient', () => { result: { ...baseRunSnapshot.stages.measurement.result, transcriptionDetail: { - transcriptionMethod: 'basic-pitch', + transcriptionMethod: 'basic-pitch-legacy', noteCount: 1, averageConfidence: 0.5, stemSeparationUsed: false, @@ -246,6 +247,67 @@ describe('analysisRunsClient', () => { expect(phase2?.trackCharacter).toBe('Tight modern electronic mix.'); }); + it('keeps stem summary additive and out of the producer-summary projection path', async () => { + const stemSummarySnapshot = { + ...baseRunSnapshot, + requestedStages: { + ...baseRunSnapshot.requestedStages, + interpretationProfile: 'stem_summary', + }, + stages: { + ...baseRunSnapshot.stages, + interpretation: { + ...baseRunSnapshot.stages.interpretation, + attemptsSummary: [ + { + attemptId: 'int_stem_123', + profileId: 'stem_summary', + modelName: 'gemini-2.5-flash', + status: 'completed', + }, + ], + preferredAttemptId: 'int_stem_123', + result: { + summary: 'Bass pulses anchor the groove while the upper stem stays approximate.', + bars: [ + { + barStart: 1, + barEnd: 2, + startTime: 0, + endTime: 3.75, + noteHypotheses: ['C3 pedal'], + scaleDegreeHypotheses: ['1'], + rhythmicPattern: 'Short off-beat bass pulses.', + uncertaintyLevel: 'LOW', + uncertaintyReason: 'Symbolic extraction and measured bar grid agree.', + }, + ], + globalPatterns: { + bassRole: 'Anchors the groove.', + melodicRole: 'Sparse upper register punctuation.', + pumpingOrModulation: 'Measured pumping suggests compressor-driven movement.', + }, + uncertaintyFlags: ['Upper melodic content is approximate.'], + }, + }, + }, + } satisfies AnalysisRunSnapshot; + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(stemSummarySnapshot), + } as Response)); + + const snapshot = await getAnalysisRun('run_123', { + apiBaseUrl: 'http://127.0.0.1:8100', + }); + + expect(projectPhase2FromRun(snapshot)).toBeNull(); + expect(projectStemSummaryFromRun(snapshot)?.bars[0].noteHypotheses).toEqual(['C3 pedal']); + }); + it('creates symbolic retry attempts against the canonical endpoint', async () => { const fetchSpy = vi.fn().mockResolvedValue({ ok: true, diff --git a/apps/ui/tests/services/backendPhase1Client.test.ts b/apps/ui/tests/services/backendPhase1Client.test.ts index 3f5ddd4a..b11be2a2 100644 --- a/apps/ui/tests/services/backendPhase1Client.test.ts +++ b/apps/ui/tests/services/backendPhase1Client.test.ts @@ -60,7 +60,7 @@ const validPayload = { vibratoConfidence: 0.05, }, transcriptionDetail: { - transcriptionMethod: 'basic-pitch', + transcriptionMethod: 'basic-pitch-legacy', noteCount: 2, averageConfidence: 0.83, stemSeparationUsed: true, diff --git a/apps/ui/tests/services/sessionMusicianPanel.test.ts b/apps/ui/tests/services/sessionMusicianPanel.test.ts index db29d673..ab6fb8ac 100644 --- a/apps/ui/tests/services/sessionMusicianPanel.test.ts +++ b/apps/ui/tests/services/sessionMusicianPanel.test.ts @@ -118,7 +118,7 @@ describe('SessionMusicianPanel confidence helpers', () => { expect(html).toContain('3 NOTES'); expect(html).not.toContain('3 / 3 NOTES'); - expect(html).toContain('Per-note confidence not available in monophonic mode'); + expect(html).toContain('Per-note confidence not available in melody-guide mode'); expect(html).toMatch(/CONFIDENCE<\/span>]*disabled=""/); }); @@ -200,7 +200,7 @@ describe('SessionMusicianPanel confidence helpers', () => { it('derives transcription provenance only for the active polyphonic source', () => { const mixedSourceTranscriptionDetail: NonNullable = { - transcriptionMethod: 'basic_pitch', + transcriptionMethod: 'basic-pitch-legacy', noteCount: 4, averageConfidence: 0.83, stemSeparationUsed: true, @@ -258,7 +258,7 @@ describe('SessionMusicianPanel confidence helpers', () => { phase1: { ...basePhase1, transcriptionDetail: { - transcriptionMethod: 'basic-pitch', + transcriptionMethod: 'basic-pitch-legacy', noteCount: 2, averageConfidence: 0.42, stemSeparationUsed: false, @@ -341,7 +341,7 @@ describe('SessionMusicianPanel confidence helpers', () => { vibratoConfidence: 0.1, }, transcriptionDetail: { - transcriptionMethod: 'basic_pitch', + transcriptionMethod: 'basic-pitch-legacy', noteCount: 4, averageConfidence: 0.83, stemSeparationUsed: true, @@ -372,10 +372,10 @@ describe('SessionMusicianPanel confidence helpers', () => { }), ); - expect(html).toContain('SOURCES: ESSENTIA'); + expect(html).toContain('SOURCE: ESSENTIA MELODY'); expect(html).not.toContain('STEM-AWARE'); expect(html).not.toContain('STEMS: bass, other'); - expect(html).toContain('Per-note confidence not available in monophonic mode'); + expect(html).toContain('Per-note confidence not available in melody-guide mode'); expect(html).not.toContain('Adjust confidence threshold to filter noise before export.'); }); }); diff --git a/apps/ui/tests/smoke/upload-phase1-midi.spec.ts b/apps/ui/tests/smoke/upload-phase1-midi.spec.ts index 82f9d1c1..a00e9725 100644 --- a/apps/ui/tests/smoke/upload-phase1-midi.spec.ts +++ b/apps/ui/tests/smoke/upload-phase1-midi.spec.ts @@ -119,7 +119,7 @@ test('phase1 dual-source session musician panel toggles between polyphonic and m }, { key: 'transcription_stems', - label: 'Basic Pitch on bass + other stems', + label: 'Legacy Basic Pitch on bass + other stems', lowMs: 40000, highMs: 75000, }, @@ -278,7 +278,7 @@ test('phase1 dual-source session musician panel toggles between polyphonic and m { attemptId: 'sym_smoke_midi_001', backendId: 'auto', mode: 'stem_notes', status: 'completed' }, ], result: { - transcriptionMethod: 'basic-pitch', + transcriptionMethod: 'basic-pitch-legacy', noteCount: 2, averageConfidence: 0.83, stemSeparationUsed: true, @@ -363,16 +363,16 @@ test('phase1 dual-source session musician panel toggles between polyphonic and m await expect(page.getByText('Analysis Results')).toBeVisible(); await expect(panel.getByRole('heading', { name: /SESSION MUSICIAN/i }).first()).toBeVisible(); - await expect(panel.getByText('Audio to MIDI transcription')).toBeVisible(); - await expect(panel.getByRole('button', { name: 'POLYPHONIC' })).toBeVisible(); - await expect(panel.getByRole('button', { name: 'MONOPHONIC' })).toBeVisible(); - await expect(panel.getByText('SOURCES: BASIC PITCH').first()).toBeVisible(); + await expect(panel.getByText('Symbolic notes and melody guide')).toBeVisible(); + await expect(panel.getByRole('button', { name: 'SYMBOLIC' })).toBeVisible(); + await expect(panel.getByRole('button', { name: 'MELODY' })).toBeVisible(); + await expect(panel.getByText('SOURCE: BASIC PITCH LEGACY').first()).toBeVisible(); await expect(panel.getByText('Range: C3 - G4')).toHaveCount(1); await expect(panel.getByText('Confidence: 83%')).toHaveCount(1); await expect(panel.getByText('2 / 2 NOTES')).toBeVisible(); await expect(panel.getByText('STEM-AWARE')).toBeVisible(); await expect(panel.getByText('STEMS: bass, other')).toBeVisible(); - await expect(panel.getByText('Polyphonic transcription via Basic Pitch')).toBeVisible(); + await expect(panel.getByText('BASIC PITCH LEGACY symbolic notes')).toBeVisible(); const previewButton = panel.getByRole('button', { name: /Preview/i }); const downloadButton = panel.getByRole('button', { name: /Download \.mid/i }); await expect(previewButton).toBeVisible(); @@ -401,21 +401,21 @@ test('phase1 dual-source session musician panel toggles between polyphonic and m const download = await downloadPromise; expect(download.suggestedFilename()).toBe('track-analysis.mid'); - await panel.getByRole('button', { name: 'MONOPHONIC' }).click(); - await expect(panel.getByText('SOURCES: ESSENTIA').first()).toBeVisible(); - await expect(panel.getByText('Monophonic pitch detection via Essentia')).toBeVisible(); + await panel.getByRole('button', { name: 'MELODY' }).click(); + await expect(panel.getByText('SOURCE: ESSENTIA MELODY').first()).toBeVisible(); + await expect(panel.getByText('Monophonic melody guide via Essentia')).toBeVisible(); await expect(panel.getByText('STEM-AWARE')).toHaveCount(0); await expect(panel.getByText('STEMS: bass, other')).toHaveCount(0); await expect(panel.getByText('3 NOTES')).toBeVisible(); await expect(panel.getByText('3 / 3 NOTES')).toHaveCount(0); - await expect(panel.getByText('Per-note confidence not available in monophonic mode')).toBeVisible(); + await expect(panel.getByText('Per-note confidence not available in melody-guide mode')).toBeVisible(); await expect(panel.getByText('Adjust confidence threshold to filter noise before export.')).toHaveCount(0); await expect(confidenceSlider).toBeDisabled(); await expect(confidenceSlider).toHaveValue('0.8'); - await panel.getByRole('button', { name: 'POLYPHONIC' }).click(); - await expect(panel.getByText('SOURCES: BASIC PITCH').first()).toBeVisible(); - await expect(panel.getByText('Polyphonic transcription via Basic Pitch')).toBeVisible(); + await panel.getByRole('button', { name: 'SYMBOLIC' }).click(); + await expect(panel.getByText('SOURCE: BASIC PITCH LEGACY').first()).toBeVisible(); + await expect(panel.getByText('BASIC PITCH LEGACY symbolic notes')).toBeVisible(); await expect(panel.getByText('STEM-AWARE')).toBeVisible(); await expect(panel.getByText('STEMS: bass, other')).toBeVisible(); await expect(panel.getByText('1 / 2 NOTES')).toBeVisible(); @@ -611,9 +611,9 @@ test('missing melodyDetail shows MIDI unavailable state', async ({ page }) => { await page.getByRole('button', { name: /Initiate Analysis/i }).click(); const panel = page.locator('section').filter({ hasText: /SESSION MUSICIAN/i }).first(); - await expect(panel.locator('p').filter({ hasText: 'MIDI TRANSCRIPTION UNAVAILABLE' })).toBeVisible(); + await expect(panel.locator('p').filter({ hasText: 'SYMBOLIC NOTES UNAVAILABLE' })).toBeVisible(); await expect( - panel.getByText('Run with --transcribe flag for Basic Pitch polyphonic transcription, or ensure melodyDetail is present in DSP JSON'), + panel.getByText('Run with symbolic extraction enabled, or ensure melodyDetail is present in the DSP payload for a melody guide'), ).toBeVisible(); await expect(panel.getByRole('button', { name: /Preview/i })).toBeDisabled(); await expect(panel.getByRole('button', { name: /Download \.mid/i })).toBeDisabled(); diff --git a/docs/ARCHITECTURE_STRATEGY.md b/docs/ARCHITECTURE_STRATEGY.md index 39958488..ff976e4f 100644 --- a/docs/ARCHITECTURE_STRATEGY.md +++ b/docs/ARCHITECTURE_STRATEGY.md @@ -2,7 +2,7 @@ **Last updated:** March 2026 **Status:** Living document — update when experiments produce results or the AI capability landscape shifts materially. -**Sources:** ChatGPT o3 deep research (March 2026), Codex architecture hardening audit, Perplexity ecosystem research (Demucs/separation alternatives), session analysis with Claude Sonnet. +**Sources:** ChatGPT o3 deep research (March 2026), Codex architecture hardening audit, Perplexity ecosystem research (Demucs/separation alternatives), session analysis with Claude Sonnet, `deep-research-report.md`, `deep-research-report (1).md`, `deep-research-report (2).md`, and `docs/STAGE3_REALITY_AUDIT.md`. This document is not a specification. It is a record of *why* the architecture is shaped the way it is, so that future development decisions — by agents or humans — can be made with the reasoning visible rather than just the conclusions. @@ -55,7 +55,7 @@ This means the split is correct and should be maintained: **measure locally, ext | Essentia 2.1b6.dev1389 | ✅ Healthy — MTG-maintained, July 2025 wheels, Python 3.9–3.13 | Stay. Irreplaceable for measurement. | | Demucs 4.0.1 | 🟡 Frozen — Meta archived Jan 1 2025, adefossez fork is bug-fix only | Stay. Models are excellent and stable. No better alternative at comparable quality. Monitor adefossez fork for compatibility drift. | | basic-pitch 0.4.0 | 🔴 Abandoned — Spotify, resampy/pkg_resources broken with setuptools>=71 | Remove. TranscriptionBackend Protocol abstracted (Stage 2 complete). | -| torchcrepe 0.0.24 | 🔬 Under evaluation — last released May 2025, PyTorch-based, Viterbi decoding | **Zero new dependencies** — all requirements already in venv. Preferred for Experiment A. | +| torchcrepe 0.0.24 | 🔬 Under evaluation — last released May 2025, PyTorch-based, Viterbi decoding | Preferred for Experiment A. Audit note: not currently installed in the backend venv, so the repo is not yet ready to run the experiment. | | PENN 1.0.0 | 🔬 Alternative candidate — research-driven, pitch + periodicity co-output | Adds 6 new packages including `huggingface_hub` (downloads models at runtime from HuggingFace). Try only if torchcrepe produces insufficient quality. | | librosa 0.11.0 | Ghost dep — confirmed unused in analyze.py | Remove on next venv rebuild. | | setuptools<71 | Temporary pin — required by resampy 0.4.2 / basic-pitch dependency chain | Remove when basic-pitch is removed. | diff --git a/docs/STAGE3_REALITY_AUDIT.md b/docs/STAGE3_REALITY_AUDIT.md new file mode 100644 index 00000000..bc3a0267 --- /dev/null +++ b/docs/STAGE3_REALITY_AUDIT.md @@ -0,0 +1,163 @@ +# Stage 3 Reality Audit + +**Date:** 2026-03-18 +**Purpose:** Compare the current repo state to the Stage 3 framing in `docs/ARCHITECTURE_STRATEGY.md` and the attached research inputs: + +- `deep-research-report.md` +- `deep-research-report (1).md` +- `deep-research-report (2).md` + +This audit is intentionally repo-grounded. It records where the code already matches the Layer 1 / Layer 2 / Layer 3 theory, where old terminology survives only as compatibility debt, and where the product framing still over-promises. + +## Stage 3 Product Question + +Stage 3 is not “find the next Basic Pitch.” The product question is: + +> What producer-facing outputs are honest and useful now, given separated stems, deterministic measurements, and current-model limits? + +The current repo should be judged against two intended outputs: + +- **Output A — Local symbolic notes** + - Purpose: import and clean up + - Layer: 2 + - Nature: local, stem-based, confidence-aware, best-effort +- **Output B — AI musical stem summary** + - Purpose: understand what’s happening + - Layer: 3 + - Nature: grounded, editorial, uncertainty-aware, not symbolic truth + +## Audit Matrix + +| Area | Current state | Classification | Notes | +| --- | --- | --- | --- | +| Canonical runtime model | `apps/backend/analysis_runtime.py` separates measurement, symbolic extraction, and interpretation with distinct tables and attempt histories. | `Aligned` | This is the structural foundation the Stage 3 plan needs. | +| Canonical transport boundary | `apps/ui/src/services/analysisRunsClient.ts` parses canonical measurement separately and only reconstructs `transcriptionDetail` in `projectPhase1FromRun()`. | `Aligned` | This is the correct compatibility edge. | +| Symbolic worker integration point | `apps/backend/server.py` symbolic worker calls `analyze_transcription()` rather than importing Basic Pitch directly. | `Aligned` | The `TranscriptionBackend` slot is real. | +| Measurement authority | Canonical measurement strips `transcriptionDetail` before persistence. | `Aligned` | Layer 1 is no longer silently polluted by Layer 2 output. | +| Legacy wrappers | `/api/analyze`, `/api/phase2`, `Phase1Result.transcriptionDetail`, and display projections still preserve the old flat blob. | `Misaligned but compatibility-only` | Acceptable during migration. Do not expand this surface. | +| Session Musician UI wording | The panel existed in a “MIDI transcription / polyphonic vs monophonic” frame. | `Misaligned and needs migration` | Updated in this pass toward symbolic notes vs melody guide wording. | +| Producer-summary Gemini prompt | `apps/backend/prompts/phase2_system.txt` still treated `transcriptionDetail` as polyphonic truth and blurred symbolic vs measurement authority. | `Misaligned and needs migration` | Updated in this pass. | +| Basic Pitch framing | Core docs and runtime strings still presented Basic Pitch as a normal/default backend. | `Misaligned and needs migration` | Updated in this pass to `legacy` framing. | +| Experiment B profile | No dedicated `stem_summary` interpretation profile existed. | `Misaligned and needs migration` | Added in this pass as a distinct Layer 3 profile. | +| Descriptor hooks for experiments | Measurement had the raw data (`rhythmDetail.downbeats`, `segmentLoudness`, `sidechainDetail`) but no explicit experiment-oriented hook bundle. | `Misaligned and needs migration` | Added in this pass through prompt grounding hooks, not a broad MIR expansion. | +| Torchcrepe readiness | Strategy doc implied torchcrepe was effectively ready; the backend venv currently does **not** have `torchcrepe` or `penn` installed. | `Misaligned and needs migration` | This is a hard repo-state mismatch, not a theoretical one. | +| Experiment A evaluation pack | No checked-in Vtss bass stem or equivalent three-case evaluation pack is present in the repo. | `Misaligned and needs migration` | The experiment rubric exists in strategy, but the asset pack does not. | +| Ableton / blueprint horizon | The repo does not implement a reconstruction blueprint system. | `Aligned` | This is correct; report `(2)` should remain horizon guidance only. | + +## Specific Layer-Bleed Findings + +### `transcriptionDetail` still acts too central in a few places + +- `apps/ui/src/components/analysisResultsViewModel.ts` +- `apps/ui/src/services/fieldAnalytics.ts` +- legacy `Phase1Result` parsing in `apps/ui/src/services/backendPhase1Client.ts` +- documentation in `apps/backend/README.md`, `apps/backend/ARCHITECTURE.md`, and `apps/backend/JSON_SCHEMA.md` + +Status: +- compatibility-only in the transport and view-model layer +- needs continued migration in wording and product framing + +### Basic Pitch was still framed as the default future path + +Before this pass, that appeared in: + +- `apps/backend/analyze.py` +- `apps/backend/README.md` +- `apps/backend/ARCHITECTURE.md` +- `apps/backend/JSON_SCHEMA.md` +- `apps/ui/src/components/SessionMusicianPanel.tsx` +- `apps/ui/README.md` + +Status: +- now reframed as `basic-pitch-legacy` in runtime metadata and core docs +- still present historically in changelogs, which is acceptable + +### Gemini prompting still blurred measurement and interpretation + +Before this pass: + +- `apps/backend/prompts/phase2_system.txt` referred to `transcriptionDetail` as polyphonic truth +- there was no dedicated stem-listening profile + +Status: +- producer-summary prompt updated +- new `stem_summary` profile added + +## Research Inputs Bent Into Repo Reality + +### `deep-research-report.md` + +Relevant to this repo now: + +- torchcrepe and PENN are the right Layer 2 experiment set +- Gemini stem listening is a Layer 3 interpretation path, not a transcription backend +- polyphonic electronic-music transcription remains the wrong bet + +Repo consequence: +- keep `TranscriptionBackend` +- quarantine Basic Pitch +- add `stem_summary` as a separate interpretation profile + +### `deep-research-report (1).md` + +Relevant to this repo now: + +- producer-facing legibility needs a bar grid, energy evolution, and pumping/modulation cues more than a wider descriptor backlog + +Repo consequence: +- expose only three prompt-grounding hooks for Stage 3: + - stable bar grid + - beat-synchronous energy/loudness curve + - pumping/modulation descriptor + +### `deep-research-report (2).md` + +Relevant to this repo now: + +- useful as horizon guidance for producer workflows and future reconstruction ideas + +Repo consequence: +- do **not** turn Stage 3 into a reconstruction-blueprint build + +## Immediate Next Steps After This Audit + +1. Run Experiment A with a real evaluation pack: + - one clean bass stem + - one glide-heavy bass stem + - one ambiguous melodic/other stem +2. Install and test `torchcrepe` before discussing output quality. +3. Try `PENN` only if torchcrepe fails the quality bar. +4. Keep `BasicPitchBackend` available only as a legacy comparison backend during the experiment window. +5. Evaluate `stem_summary` independently of local MIDI quality. + +## Decision Gates + +### Local symbolic notes ship only if: + +- at least 2 of 3 evaluation cases are `Green` +- the result is importable after light cleanup +- the output is not dominated by octave spam, segmentation noise, or misleading note clutter + +### Gemini stem summary ships only if: + +- it stays bar-aligned to measured structure +- uncertainty is explicit +- it does not re-estimate BPM, key, or meter +- a producer would still find it useful when note accuracy is only approximate + +## Bottom Line + +The runtime architecture is ahead of the product language. The repo already has the right Stage 3 skeleton: + +- authoritative measurement +- pluggable symbolic extraction slot +- grounded interpretation slot + +What it lacked was honesty at the edges: + +- Basic Pitch still looked current instead of legacy +- Session Musician still implied a stronger transcription promise than the strategy supports +- Gemini had no distinct stem-listening profile +- the experiment inputs described in strategy were not actually present in the repo + +This pass fixes the language, the interpretation profile boundary, and the audit trail. The next pass should be a real backend bakeoff, not more theory. From dc08740daadc51f46a534be30c2d406b32adfed7 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Wed, 18 Mar 2026 20:31:23 +1300 Subject: [PATCH 02/11] chore: delete dead gemini_client.py (529 lines, zero callers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file declared its own PHASE2_PROMPT_TEMPLATE, PHASE2_RESPONSE_SCHEMA, retry logic, and analyze_phase2() — all superseded when Phase 2 moved server-side in v2.0.0. server.py loads its own prompt from prompts/phase2_system.txt and manages its own retry logic. Verified zero imports across the entire repo (Python, tests, docs, configs) before deleting. Backend test baseline (post-deletion, warm cache): 129 tests, 128 pass, 1 skipped. UI vitest baseline: 131 tests across 18 files, all pass. Note: the repo has pre-existing uncommitted work (analyze.py, JSON_SCHEMA.md, test_analyze.py, types.ts) from in-progress feature development that is not part of this commit. Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/gemini_client.py | 529 ---------------------------------- 1 file changed, 529 deletions(-) delete mode 100644 apps/backend/gemini_client.py diff --git a/apps/backend/gemini_client.py b/apps/backend/gemini_client.py deleted file mode 100644 index 060b200e..00000000 --- a/apps/backend/gemini_client.py +++ /dev/null @@ -1,529 +0,0 @@ -import json -import logging -import math -import mimetypes -import os -import random -import time -from typing import Any, Dict, Optional - -try: - from google import genai - from google.genai import types - _GENAI_AVAILABLE = True -except ImportError: - genai = None # type: ignore[assignment] - types = None # type: ignore[assignment] - _GENAI_AVAILABLE = False - -logger = logging.getLogger(__name__) - -# Legacy helper module retained for older experiments. -# The live Gemini integration and prompt routing now live in server.py, -# including the Layer 1/2/3 grounding split and the stem_summary profile. - -# --- Constants & Configuration --- -INLINE_SIZE_LIMIT = 104_857_600 # 100 MiB — confirmed by Google on 2026-01-12 -GEMINI_TIMEOUT_SECONDS = 300 # 5 minutes -GEMINI_MAX_RETRIES = 3 -GEMINI_RETRY_BASE_DELAY_MS = 2_000 -GEMINI_RETRYABLE_SUBSTRINGS = ["503", "high demand", "429", "quota", "unavailable"] - -# --- Prompt & Schema --- -PHASE2_PROMPT_TEMPLATE = """You are an expert Ableton Live 12 producer and sound designer -specialising in electronic music reconstruction. You receive: -1. A structured JSON object of deterministic DSP measurements -2. The audio file itself - -ABSOLUTE RULES: -1. Every numeric value in the JSON is ground truth from a - deterministic DSP engine. Do not re-estimate or override - any numeric field using audio inference. -2. You are PROHIBITED from overriding: bpm, key, lufsIntegrated, - lufsRange, truePeak, stereoDetail values, durationSeconds. -3. Use the exact key string provided. Do not reinterpret as - relative major/minor. Do not override from audio perception. -4. If audio perception contradicts a measured JSON field, keep the JSON measurement and describe the contradiction rather than rewriting the number. -5. Low confidence handling: - - pitchConfidence below 0.15 = melody is draft only - - chordStrength below 0.70 = chords approximate - - pumpingConfidence below 0.40 = do not assert sidechain - - segmentKey from segments shorter than 10s = low confidence -6. When transcriptionDetail is present, use dominantPitches for note name recommendations, not melodyDetail.dominantNotes. -7. stemSeparationUsed: true means bass and melodic content have been transcribed independently - treat bass stem notes and other stem notes as separate layers. -8. For measured values, the JSON is authoritative. For genre identification only, audio perception is authoritative. -9. mixAndMasterChain must contain a minimum of 8 device objects. If fewer than 8 real devices can be inferred from the audio, supplement with contextually appropriate Ableton Live devices that would suit the detected characteristics. Never return fewer than 8. - -FIELD GLOSSARY: -- bpm: use exactly as Ableton project tempo -- grooveDetail.kickSwing: timing variance in kick band — higher = more swing -- grooveDetail.hihatSwing: timing variance in high band — higher = more loose feel -- grooveDetail.kickAccent: 16-value array of kick energy per 16th note position -- lufsIntegrated: club electronic target is -6 to -9 LUFS -- truePeak above 0.0 dBTP = intersample clipping present -- crestFactor: higher = more transient punch, lower = more limited -- stereoWidth near 0.0 = effectively mono -- subBassMono: true = sub below 80Hz is mono, standard club mastering -- spectralCentroid: higher = brighter tonality -- segmentSpectral centroid rising across segments = filter opening -- oddToEvenRatio above 1.0 = saw/square character -- oddToEvenRatio below 1.0 = sine/triangle character -- inharmonicity above 0.2 = FM or noise synthesis character -- logAttackTime more negative = faster attack transients -- attackTimeStdDev low = consistent mechanical transients -- structure.segments = arrangement blocks, plus or minus 5-10s -- segmentLoudness = per-section LUFS, reveals drops and builds -- dominantNotes = MIDI numbers, convert to note names -- transcriptionDetail (when present): - - noteCount: total symbolic notes detected by the legacy comparison backend - - averageConfidence: mean note confidence 0-1 - - dominantPitches[]: top pitches with count - - pitchRange: min/max MIDI and note names - - stemSeparationUsed: true if Demucs was used - - stemsTranscribed: which stems were analysed - - notes[].stemSource: "bass"|"other"|"full_mix" - - When stemSeparationUsed is true, bass notes and melodic notes are separated by stemSource - - Treat transcriptionDetail as best-effort symbolic guidance, not authoritative measurement -- pumpingStrength + pumpingConfidence both above 0.35 = sidechain -- arrangementDetail.noveltyPeaks = structural event timestamps -- segmentSpectral.stereoWidth changes = intentional width automation - -IMPORTANT SPECTRAL NOTE: -- spectralBalance dB values describe spectral shape relative - to each other only, not absolute loudness or quality -- Do not use spectralBalance values to make qualitative - judgements about the track's perceived sound or production - quality -- High subBass dB does not mean "good bass" — it means - the spectral energy is concentrated there relative to - other bands -- Use spectralBalance only to inform EQ and filter - recommendations, not character descriptions - -GENRE INFERENCE AND ADAPTATION: -Compute kickAccentVariance as the variance of grooveDetail.kickAccent (the 16-value array) and use the DSP values below as context rather than as the genre label itself. - -RHYTHM CLUSTER (from DSP measurements — use as context, not as genre): - - kickSwing < 0.15 AND kickAccentVariance < 0.15 → tight mechanical pulse - - kickSwing > 0.50 AND kickAccentVariance < 0.10 → loose psychedelic pulse - - kickSwing < 0.12 AND kickAccentVariance < 0.05 → minimal/no pulse - - kickAccentVariance > 0.28 → complex broken pattern - - otherwise → ambiguous rhythm profile - -SYNTHESIS TIER (from synthesisCharacter — use to confirm genre, not define it): - - inharmonicity 0.10-0.25 → FM/acid character - - inharmonicity < 0.10 → subtractive/clean character - - inharmonicity > 0.25 → wavetable/noise/complex character - - oddToEvenRatio > 1.5 → saw/square dominant - - oddToEvenRatio < 0.8 → sine/triangle dominant - -GENRE INFERENCE PROCESS: - 1. State the rhythm cluster and synthesis tier from the JSON above. - 2. Listen to the audio and identify the genre from audio perception. - 3. Cross-check: does the audio genre match the rhythm cluster and synthesis tier? If yes, HIGH confidence. If partially, MED confidence with explanation. If the audio clearly contradicts the DSP, state the contradiction explicitly — the DSP measurement may be capturing something the audio does not, or vice versa. - 4. Never override the measured DSP values with audio perception. Only use audio perception for the genre label itself. - -OUTPUT REQUIREMENTS — QUANTITY AND DEPTH ARE MANDATORY: - -trackCharacter: -Write 4-5 sentences. Reference at least 4 specific numeric values -from the JSON. The opening sentence must name the inferred genre -and confidence. The next sentence(s) must justify that inference -with specific measurements. Describe synthesis character, dynamic -approach, stereo philosophy, and spectral signature. Be specific -and production-focused, not generic. - -detectedCharacteristics: -Return exactly 5 items. Each must reference a specific measured -value. Confidence must be HIGH, MED, or LOW exactly. -Cover: loudness/dynamics, stereo field, spectral character, -synthesis approach, rhythmic/groove characteristic. - -arrangementOverview: -Return a structured object with three keys: -- summary: 2-3 sentence overview of the track's structural - philosophy referencing durationSeconds and overall - loudness approach -- segments: an array with one entry per segment in - structure.segments. For each segment include: - index: segment number starting at 1 - startTime: start time in seconds from structure.segments - endTime: end time in seconds from structure.segments - lufs: LUFS value from segmentLoudness for this segment - description: 3-4 sentences covering what is happening - musically and production-wise in this section, - referencing the segment's measured values - spectralNote: one sentence on spectralCentroid or - stereoWidth change from segmentSpectral if available -- noveltyNotes: one paragraph mapping each noveltyPeak - timestamp to the structural event it represents - -sonicElements: -Return ALL of the following keys with substantive content. -Each must be at minimum 4 sentences with specific values referenced: -- kick: Derive the kick recommendation directly from kickAccentVariance and kickSwing: - - kickAccentVariance < 0.15 AND kickSwing < 0.06 → four-on-the-floor → Kick 2, short decay, 909/808 - - kickAccentVariance > 0.25 → complex pattern → Sampler, layered kicks - - kickAccentVariance 0.15–0.25 → moderate variation → Drum Rack, mixed approach - Also reference crestFactor, logAttackTime, and spectralBalance subBass with specific values. -- bass: reference synthesisCharacter oddToEvenRatio and - inharmonicity, subBassMono, spectralBalance subBass/lowBass. - Select synth architecture from inharmonicity: - - 0.1-0.25 = FM / Operator - - below 0.1 = subtractive / Analog - - above 0.25 = wavetable or noise / Wavetable plus noise oscillator - Apply this rule regardless of the genre label inferred in trackCharacter. - The measured inharmonicity is ground truth. - Explain why that instrument choice fits the measured synthesis - character of THIS track using inharmonicity and oddToEvenRatio. - Suggest oscillator type, filter settings, and mono routing. -- melodicArp: convert dominantNotes MIDI to note names. Reference - pitchConfidence explicitly — if below 0.15 say so. Reference - chordDetail.dominantChords. Suggest synth approach and MIDI pattern. -- grooveAndTiming: reference grooveDetail.kickSwing, grooveDetail.hihatSwing, - and grooveDetail.kickAccent with specific ms offset calculations at the track BPM. - Suggest Ableton groove pool settings. -- effectsAndTexture: reference effectsDetail, vibratoPresent, - arrangementDetail noveltyPeaks. Use audio perception here for - qualitative texture. Reference spectralContrast values. -- widthAndStereo: reference stereoWidth, stereoCorrelation, - subBassMono, segmentSpectral stereoWidth changes across segments. - Suggest Utility device settings and any width automation. -- harmonicContent: reference key, keyConfidence, segmentKey changes, - chordDetail.dominantChords, chordStrength. Suggest scale/mode - for writing new parts. - -mixAndMasterChain: -Return an array of device objects in signal flow order. -Return a minimum of 8 devices covering the full signal chain from sound design through to the master bus. -Cover all three frequency ranges with at least one device per range: -- Low end: sub bass, kick, or bass processing -- Mid range: main body, saturation, compression, or harmonic shaping -- High end: air, presence, transient detail, or top-end polish -Where applicable to the track, the chain must explicitly cover: -- transient shaping or drum processing -- bass/sub management -- mid-range saturation or harmonic excitation -- stereo width control -- high-frequency air or presence -- bus compression or glue -- limiting/mastering -At least one device entry must embody a technique justified by measured -synthesisCharacter, grooveDetail, or sidechainDetail values — not by genre -label alone. If genre confidence is LOW or MED, provide a measurement-based -justification regardless of genre name. -Each object must include: -- order: position in chain starting at 1 -- device: exact Ableton Live 12 device name -- parameter: specific parameter name as shown in Ableton -- value: specific numeric or descriptive target value - derived from JSON measurements -- reason: one sentence referencing the specific measured - value that justifies this device and setting -Never return fewer than 8 devices. - -secretSauce: -Title: a specific named technique derived from the dominant measured characteristic -of this track — the single most distinctive DSP feature. State which genre(s) -commonly apply this technique, but derive the technique from the measurement, -not from the genre label. Generic production-technique titles are not acceptable. -icon: one word describing the core technique type. -Must be exactly one of: DISTORTION, FILTER, COMPRESSION, -MODULATION, ROUTING, SATURATION, STEREO, SYNTHESIS -Explanation: 4-5 sentences explaining what makes this technique -specific to THIS track based on its measurements. Cite at least 3 specific JSON -values that make this technique appropriate for this track. Then name the genre(s) -where this technique is common. If genre confidence is LOW or MED, acknowledge -that the technique is measurement-driven, not genre-confirmed. -implementationSteps: return exactly 6 steps. Each step must be -a complete sentence with specific Ableton device names, parameter -names, and numeric values. Steps must build on each other -sequentially. - -confidenceNotes: -Return at least 5 items. -For the field name, use a human-readable label, NOT the JSON -field name. For example: -- use "Key Signature" not "key" -- use "True Peak" not "truePeak" -- use "Chord Progression" not "chordDetail.chordStrength" -- use "Melody Transcription" not "melodyDetail.pitchConfidence" -- use "Sidechain Detection" not "sidechainDetail.pumpingConfidence" -- use "Segment Key (short segment)" not "segmentKey[1]" -Every field that has a known accuracy limitation must appear here. -Always include: -- Rhythm cluster: which bucket and why -- Synthesis tier: which tier and the specific inharmonicity + oddToEvenRatio values -- Genre confidence: HIGH/MED/LOW with specific reason for any degradation - -abletonRecommendations: -Return at least 10 device recommendation cards. -Cover the full signal chain: sound design devices, effects, -group processing, and mastering. -For each card: -- device: exact Ableton Live 12 device name -- category: one of SYNTHESIS, DYNAMICS, EQ, EFFECTS, - STEREO, MASTERING, MIDI, ROUTING -- parameter: specific parameter name as it appears in Ableton -- value: specific numeric or descriptive target value derived - from JSON measurements -- reason: one sentence referencing the specific measured value - that justifies this recommendation -- advancedTip: one concrete advanced technique for this device - in this context - -Do not pad with generic advice. Every recommendation must be -justified by a specific measurement from the JSON. - -CITATION REQUIREMENT: -For every field in your output, include a "sources" array listing the specific -Phase 1 JSON fields that justify this recommendation. - -Example: -"sonicElements": { - "kick": { - "description": "Four-on-the-floor pattern with moderate swing...", - "sources": ["grooveDetail.kickAccent", "grooveDetail.kickSwing", "bpm"] - } -} - -Fields that MUST have sources: -- All sonicElements (kick, bass, melodicArp, grooveAndTiming, etc.) -- Every device in mixAndMasterChain -- secretSauce.implementationSteps -- All abletonRecommendations - -Fields where sources are OPTIONAL: -- trackCharacter (narrative summary) -- confidenceNotes (self-referential) - -Phase 1 Measurements: -""" - -PHASE2_RESPONSE_SCHEMA = { - "type": "OBJECT", - "properties": { - "trackCharacter": {"type": "STRING"}, - "detectedCharacteristics": { - "type": "ARRAY", - "items": { - "type": "OBJECT", - "properties": { - "name": {"type": "STRING"}, - "confidence": {"type": "STRING"}, - "explanation": {"type": "STRING"}, - }, - "required": ["name", "confidence", "explanation"], - }, - }, - "arrangementOverview": { - "type": "OBJECT", - "properties": { - "summary": {"type": "STRING"}, - "segments": { - "type": "ARRAY", - "items": { - "type": "OBJECT", - "properties": { - "index": {"type": "NUMBER"}, - "startTime": {"type": "NUMBER"}, - "endTime": {"type": "NUMBER"}, - "lufs": {"type": "NUMBER"}, - "description": {"type": "STRING"}, - "spectralNote": {"type": "STRING"}, - }, - "required": ["index", "startTime", "endTime", "description"], - }, - }, - "noveltyNotes": {"type": "STRING"}, - }, - "required": ["summary", "segments"], - }, - "sonicElements": { - "type": "OBJECT", - "properties": { - "kick": {"type": "STRING"}, - "bass": {"type": "STRING"}, - "melodicArp": {"type": "STRING"}, - "grooveAndTiming": {"type": "STRING"}, - "effectsAndTexture": {"type": "STRING"}, - "widthAndStereo": {"type": "STRING"}, - "harmonicContent": {"type": "STRING"}, - }, - "required": ["kick", "bass", "melodicArp", "grooveAndTiming", "effectsAndTexture"], - }, - "mixAndMasterChain": { - "type": "ARRAY", - "items": { - "type": "OBJECT", - "properties": { - "order": {"type": "NUMBER"}, - "device": {"type": "STRING"}, - "parameter": {"type": "STRING"}, - "value": {"type": "STRING"}, - "reason": {"type": "STRING"}, - }, - "required": ["order", "device", "parameter", "value", "reason"], - }, - }, - "secretSauce": { - "type": "OBJECT", - "properties": { - "title": {"type": "STRING"}, - "icon": {"type": "STRING"}, - "explanation": {"type": "STRING"}, - "implementationSteps": {"type": "ARRAY", "items": {"type": "STRING"}}, - }, - "required": ["title", "explanation", "implementationSteps"], - }, - "confidenceNotes": { - "type": "ARRAY", - "items": { - "type": "OBJECT", - "properties": { - "field": {"type": "STRING"}, - "value": {"type": "STRING"}, - "reason": {"type": "STRING"}, - }, - "required": ["field", "value", "reason"], - }, - }, - "abletonRecommendations": { - "type": "ARRAY", - "items": { - "type": "OBJECT", - "properties": { - "device": {"type": "STRING"}, - "category": {"type": "STRING"}, - "parameter": {"type": "STRING"}, - "value": {"type": "STRING"}, - "reason": {"type": "STRING"}, - "advancedTip": {"type": "STRING"}, - }, - "required": ["device", "category", "parameter", "value", "reason"], - }, - }, - }, - "required": [ - "trackCharacter", - "detectedCharacteristics", - "arrangementOverview", - "sonicElements", - "mixAndMasterChain", - "secretSauce", - "confidenceNotes", - "abletonRecommendations", - ], -} - -# --- Exceptions --- -class GeminiClientError(Exception): - """Raised when Phase 2 analysis fails.""" - def __init__(self, message: str, retryable: bool = False, original_error: Optional[Exception] = None): - super().__init__(message) - self.retryable = retryable - self.original_error = original_error - -# --- Core Logic --- -def _is_retryable_error(error: Exception) -> bool: - err_msg = str(error).lower() - for substr in GEMINI_RETRYABLE_SUBSTRINGS: - if substr in err_msg: - return True - return False - -def _with_retry(operation, max_retries=GEMINI_MAX_RETRIES, base_delay_ms=GEMINI_RETRY_BASE_DELAY_MS): - attempt = 0 - while attempt < max_retries: - try: - return operation() - except Exception as e: - attempt += 1 - is_retryable = _is_retryable_error(e) - if not is_retryable or attempt >= max_retries: - logger.error(f"Gemini operation failed after {attempt} attempts: {str(e)}") - raise GeminiClientError( - f"Phase 2 generation failed: {str(e)}", - retryable=is_retryable, - original_error=e - ) - - # Exponential backoff with jitter - delay = (base_delay_ms * math.pow(2, attempt - 1) + random.uniform(0, 1000)) / 1000.0 - logger.warning(f"Gemini operation failed, retrying in {delay:.2f}s: {str(e)}") - time.sleep(delay) - - raise GeminiClientError("Max retries reached for Gemini analysis.", retryable=True) - -def analyze_phase2(file_path: str, phase1_result: Dict[str, Any], model_name: str) -> Dict[str, Any]: - """ - Execute the Phase 2 LLM analysis against the audio file and DSP measurements. - - Args: - file_path: Absolute path to the local audio file to analyze. - phase1_result: The JSON dictionary produced by Phase 1 (analyze.py). - model_name: The Gemini model name to use (e.g. 'gemini-2.5-pro'). - - Returns: - Dict[str, Any]: The parsed JSON response matching PHASE2_RESPONSE_SCHEMA. - - Raises: - GeminiClientError: If generation fails, including network errors, auth errors, - or failure to parse the LLM's JSON response. - """ - if not _GENAI_AVAILABLE: - raise GeminiClientError("google-genai SDK is not installed.", retryable=False) - - api_key = os.environ.get("GEMINI_API_KEY") - if not api_key: - raise GeminiClientError("GEMINI_API_KEY environment variable is missing.", retryable=False) - - # Configure the client globally. The new SDK picks up api_key from kwargs. - client = genai.Client(api_key=api_key) - - prompt = f"{PHASE2_PROMPT_TEMPLATE}\n{json.dumps(phase1_result, indent=2)}" - - file_size = os.path.getsize(file_path) - mime_type, _ = mimetypes.guess_type(file_path) - if not mime_type: - mime_type = "audio/mpeg" - - def _generate(contents_payload): - return client.models.generate_content( - model=model_name, - contents=contents_payload, - config=types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=PHASE2_RESPONSE_SCHEMA, - ), - ) - - # 1. Inline Data (Fast path for smaller files, <= 100 MiB) - if file_size <= INLINE_SIZE_LIMIT: - with open(file_path, "rb") as f: - file_bytes = f.read() - - audio_part = types.Part.from_bytes(data=file_bytes, mime_type=mime_type) - response = _with_retry(lambda: _generate([audio_part, prompt])) - - try: - return json.loads(response.text) - except json.JSONDecodeError as e: - raise GeminiClientError("Failed to parse Phase 2 LLM JSON output.", retryable=False, original_error=e) - - # 2. Uploaded Data (For larger files) - uploaded_file = _with_retry( - lambda: client.files.upload(file=file_path, config={"mime_type": mime_type}) - ) - - try: - response = _with_retry(lambda: _generate([uploaded_file, prompt])) - return json.loads(response.text) - except json.JSONDecodeError as e: - raise GeminiClientError("Failed to parse Phase 2 LLM JSON output.", retryable=False, original_error=e) - finally: - try: - client.files.delete(name=uploaded_file.name) - except Exception as e: - # Cleanup failures shouldn't fail the primary analysis; Google auto-expires them anyway. - logger.warning(f"Failed to delete uploaded file {uploaded_file.name}: {e}") From 8f134b3877bdd7edd898eee4a3618da6139b02ad Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Wed, 18 Mar 2026 20:34:06 +1300 Subject: [PATCH 03/11] feat: add six musical-element detectors to Layer 1 DSP pipeline Port acidDetail, reverbDetail, vocalDetail, supersawDetail, bassDetail, and kickDetail from sonic-architect-app reference implementations into analyze.py. Each detector is wired through server.py HTTP forwarding, typed in apps/ui/src/types.ts, documented in JSON_SCHEMA.md, and tested with synthetic fixtures covering bounds, shape, positive/negative cases. 56 analyze tests + 51 server tests + 130 frontend tests all passing. Co-Authored-By: Claude Opus 4.6 --- apps/backend/JSON_SCHEMA.md | 88 ++- apps/backend/analyze.py | 835 ++++++++++++++++++++++++++ apps/backend/server.py | 6 + apps/backend/tests/test_analyze.py | 400 +++++++++++- apps/ui/src/types.ts | 42 ++ docs/acid-detection-implementation.md | 128 ++++ 6 files changed, 1497 insertions(+), 2 deletions(-) create mode 100644 docs/acid-detection-implementation.md diff --git a/apps/backend/JSON_SCHEMA.md b/apps/backend/JSON_SCHEMA.md index f5e7951c..96f2b7d0 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`, `key`, `keyConfidence`, `timeSignature`, `durationSeconds`, `sampleRate`, `lufsIntegrated`, `lufsRange`, `truePeak`, `crestFactor`, `dynamicSpread`, `dynamicCharacter`, `stereoDetail`, `spectralBalance`, `spectralDetail`, `rhythmDetail`, `melodyDetail`, `transcriptionDetail`, `grooveDetail`, `sidechainDetail`, `effectsDetail`, `synthesisCharacter`, `danceability`, `structure`, `arrangementDetail`, `segmentLoudness`, `segmentSpectral`, `segmentStereo`, `segmentKey`, `chordDetail`, `perceptual`, `essentiaFeatures`. +`bpm`, `bpmConfidence`, `bpmPercival`, `bpmAgreement`, `key`, `keyConfidence`, `timeSignature`, `durationSeconds`, `sampleRate`, `lufsIntegrated`, `lufsRange`, `truePeak`, `crestFactor`, `dynamicSpread`, `dynamicCharacter`, `stereoDetail`, `spectralBalance`, `spectralDetail`, `rhythmDetail`, `melodyDetail`, `transcriptionDetail`, `grooveDetail`, `sidechainDetail`, `acidDetail`, `reverbDetail`, `vocalDetail`, `supersawDetail`, `bassDetail`, `kickDetail`, `effectsDetail`, `synthesisCharacter`, `danceability`, `structure`, `arrangementDetail`, `segmentLoudness`, `segmentSpectral`, `segmentStereo`, `segmentKey`, `chordDetail`, `perceptual`, `essentiaFeatures`. ## Relationship To `POST /api/analyze` @@ -485,3 +485,89 @@ Type: `object \| null` - global key (`key`) with manual confirmation - arrangement locators (`structure.segments`) - low-end/stereo safety (`stereoDetail`, especially sub-bass fields) + +--- + +## `acidDetail` + +Acid synthesis detection via spectral centroid oscillation, resonance measurement, and bass rhythm density. + +| Field | Type | Description | +|-------|------|-------------| +| `isAcid` | boolean | True when composite score ≥ 0.45 | +| `confidence` | number | Composite score 0–1 (40% resonance + 35% centroid oscillation + 25% bass rhythm) | +| `resonanceLevel` | number | Peak energy in 800–5000 Hz resonance band relative to total | +| `centroidOscillationHz` | number | Standard deviation of per-frame spectral centroid | +| `bassRhythmDensity` | number | Onset rate in sub-200 Hz band (onsets per second) | + +Returns `null` on failure. + +## `reverbDetail` + +Reverb tail estimation via energy decay analysis. + +| Field | Type | Description | +|-------|------|-------------| +| `rt60` | number | Estimated RT60 decay time in seconds | +| `isWet` | boolean | True when RT60 > 1.0s and tail energy ratio > 0.3 | +| `tailEnergyRatio` | number | Energy in decay tail relative to total energy | + +Returns `null` on failure. + +## `vocalDetail` + +Vocal presence detection via MFCC analysis, formant matching, and vocal-band energy ratio. + +| Field | Type | Description | +|-------|------|-------------| +| `hasVocals` | boolean | True when composite score ≥ 0.45 | +| `confidence` | number | Composite score 0–1 (35% energy + 35% formant + 30% MFCC) | +| `vocalEnergyRatio` | number | Energy in 150–1500 Hz vocal band relative to total | +| `formantStrength` | number | Formant peak match score at 500/1500/2500 Hz | +| `mfccLikelihood` | number | MFCC distribution match to vocal pattern | + +Returns `null` on failure. + +## `supersawDetail` + +Supersaw/unison detection via spectral peak clustering and detune measurement. + +| Field | Type | Description | +|-------|------|-------------| +| `isSupersaw` | boolean | True when composite score indicates unison voices | +| `confidence` | number | Composite score 0–1 (35% voice count + 35% detune + 30% consistency) | +| `voiceCount` | number | Estimated number of near-unison voices | +| `avgDetuneCents` | number | Average detune spread between cluster members in cents | +| `spectralComplexity` | number | Spectral complexity metric from peak analysis | + +Returns `null` on failure. + +## `bassDetail` + +Bass character analysis via lowpass-filtered onset detection, decay measurement, and groove analysis. + +| Field | Type | Description | +|-------|------|-------------| +| `averageDecayMs` | number | Mean decay time to −6 dB per bass onset in milliseconds | +| `type` | string | One of `"punchy"`, `"medium"`, `"rolling"`, `"sustained"` | +| `transientRatio` | number | Ratio of transient energy to sustained energy | +| `fundamentalHz` | number | Estimated bass fundamental frequency (30–120 Hz) | +| `transientCount` | number | Number of detected bass onsets | +| `swingPercent` | number | Swing amount from lag-1 autocorrelation of onset intervals | +| `grooveType` | string | One of `"straight"`, `"slight-swing"`, `"heavy-swing"`, `"shuffle"` | + +Returns `null` on failure. + +## `kickDetail` + +Kick drum distortion analysis via THD measurement and harmonic ratio computation. + +| Field | Type | Description | +|-------|------|-------------| +| `isDistorted` | boolean | True when THD > 0.15 or harmonicRatio < 0.5 | +| `thd` | number | Total harmonic distortion (harmonic power / fundamental power) | +| `harmonicRatio` | number | Ratio of harmonic to inharmonic content in kick band | +| `fundamentalHz` | number | Detected kick fundamental frequency | +| `kickCount` | number | Number of detected kick transients | + +Returns `null` on failure. diff --git a/apps/backend/analyze.py b/apps/backend/analyze.py index 43be0bc2..1be977aa 100644 --- a/apps/backend/analyze.py +++ b/apps/backend/analyze.py @@ -1985,6 +1985,823 @@ def analyze_effects_detail( return {"effectsDetail": None} +def analyze_acid_detail( + mono: np.ndarray, + sample_rate: int = 44100, + bpm: float | None = None, +) -> dict: + """Detect TB-303-style acid basslines from resonance, filter sweeps, and rhythm density. + + Ported from sonic-architect-app/services/acidDetection.ts. + """ + try: + mono_arr = np.asarray(mono, dtype=np.float32) + if mono_arr.ndim != 1 or mono_arr.size < 2: + return {"acidDetail": None} + + if bpm is None or not np.isfinite(bpm) or bpm <= 0: + return {"acidDetail": None} + + frame_size = 2048 + hop_size = 512 + acid_bass_low = 100.0 + acid_bass_high = 800.0 + + low_bin = int(np.floor(acid_bass_low * frame_size / sample_rate)) + high_bin = min( + int(np.ceil(acid_bass_high * frame_size / sample_rate)), + frame_size // 2 - 1, + ) + if low_bin >= high_bin or low_bin < 0: + return {"acidDetail": None} + + spectrum_algo = es.Spectrum(size=frame_size) + windowing = es.Windowing(type="hann", size=frame_size) + + centroids: list[float] = [] + band_rms_values: list[float] = [] + prev_band_rms = 0.0 + onset_count = 0 + + for frame in es.FrameGenerator(mono_arr, frameSize=frame_size, hopSize=hop_size): + if frame.size < frame_size: + padded = np.zeros(frame_size, dtype=np.float32) + padded[: frame.size] = frame + frame = padded + windowed = windowing(frame) + spectrum = spectrum_algo(windowed) + + band = spectrum[low_bin : high_bin + 1] + if band.size == 0: + continue + + freqs = np.arange(low_bin, high_bin + 1, dtype=np.float64) * (sample_rate / frame_size) + mags = band.astype(np.float64) + mag_sum = float(np.sum(mags)) + centroid = float(np.sum(freqs * mags) / mag_sum) if mag_sum > 0 else 0.0 + centroids.append(centroid) + + band_power = float(np.sum(mags ** 2)) + rms = float(np.sqrt(band_power / max(1, band.size))) + band_rms_values.append(rms) + + if rms > prev_band_rms * 1.5 and rms > 0.001: + onset_count += 1 + prev_band_rms = rms + + if len(centroids) < 10: + return { + "acidDetail": { + "isAcid": False, + "confidence": 0.0, + "resonanceLevel": 0.0, + "centroidOscillationHz": 0.0, + "bassRhythmDensity": 0.0, + } + } + + centroids_arr = np.array(centroids, dtype=np.float64) + rms_arr = np.array(band_rms_values, dtype=np.float64) + + centroid_oscillation = float(np.std(centroids_arr)) + max_rms = float(np.max(rms_arr)) + mean_rms = float(np.mean(rms_arr)) + resonance_level = min(1.0, (max_rms - mean_rms) / mean_rms) if mean_rms > 0 else 0.0 + + duration = float(mono_arr.size) / sample_rate + bass_rhythm_density = onset_count / duration if duration > 0 else 0.0 + expected_16th_density = (bpm / 60.0) * 4.0 + rhythm_score = min(1.0, bass_rhythm_density / (expected_16th_density * 0.5)) + centroid_score = min(1.0, centroid_oscillation / 100.0) + confidence = float(np.clip(centroid_score * 0.4 + resonance_level * 0.4 + rhythm_score * 0.2, 0.0, 1.0)) + is_acid = confidence > 0.45 + + return { + "acidDetail": { + "isAcid": is_acid, + "confidence": round(confidence, 2), + "resonanceLevel": round(resonance_level, 2), + "centroidOscillationHz": round(centroid_oscillation), + "bassRhythmDensity": round(bass_rhythm_density, 1), + } + } + except Exception as e: + print(f"[warn] Acid detection failed: {e}", file=sys.stderr) + return {"acidDetail": None} + + +def analyze_reverb_detail( + mono: np.ndarray, + sample_rate: int = 44100, + bpm: float | None = None, +) -> dict: + """Estimate RT60 reverberation time from energy decay slopes after transients. + + Ported from sonic-architect-app/services/reverbAnalysis.ts. + """ + _TRANSIENT_THRESHOLD = 2.0 + _MIN_TRANSIENTS = 4 + _ANALYSIS_WINDOW_S = 2.0 + _HOP_MS = 20.0 + _SMOOTH_WINDOW = 10 + _DIRECT_MS = 50.0 + + try: + mono_arr = np.asarray(mono, dtype=np.float32) + if mono_arr.ndim != 1 or mono_arr.size < 2: + return {"reverbDetail": None} + + if bpm is None or not np.isfinite(bpm) or bpm <= 0: + bpm = 120.0 + + hop_samples = max(1, int(round((_HOP_MS / 1000.0) * sample_rate))) + n_frames = (mono_arr.size - hop_samples) // hop_samples + 1 + envelope = np.zeros(n_frames, dtype=np.float64) + for i in range(n_frames): + start = i * hop_samples + seg = mono_arr[start : start + hop_samples].astype(np.float64) + envelope[i] = float(np.sqrt(np.mean(seg ** 2))) + + if envelope.size < 20: + return {"reverbDetail": {"rt60": 0.3, "isWet": False, "tailEnergyRatio": 0.1}} + + min_dist_frames = max(1, int(np.floor((((60.0 / bpm) * 1000.0) / _HOP_MS) * 0.5))) + transient_indices: list[int] = [] + running_avg = 0.0 + + for i in range(envelope.size): + if i < _SMOOTH_WINDOW: + running_avg = float(np.mean(envelope[: i + 1])) + else: + running_avg = (running_avg * (_SMOOTH_WINDOW - 1) + envelope[i]) / _SMOOTH_WINDOW + + if envelope[i] > running_avg * _TRANSIENT_THRESHOLD and envelope[i] > 0.001: + last = transient_indices[-1] if transient_indices else -min_dist_frames + if i - last >= min_dist_frames: + transient_indices.append(i) + + if len(transient_indices) < _MIN_TRANSIENTS: + return {"reverbDetail": {"rt60": 0.5, "isWet": False, "tailEnergyRatio": 0.2}} + + 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) + + if not rt60_estimates: + return {"reverbDetail": {"rt60": 0.3, "isWet": False, "tailEnergyRatio": 0.1}} + + 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) + return { + "reverbDetail": { + "rt60": capped_rt60, + "isWet": avg_rt60 > 0.5, + "tailEnergyRatio": round(float(np.clip(avg_tail, 0.0, 1.0)), 2), + } + } + except Exception as e: + print(f"[warn] Reverb analysis failed: {e}", file=sys.stderr) + return {"reverbDetail": None} + + +def analyze_vocal_detail( + mono: np.ndarray, + sample_rate: int = 44100, + bpm: float | None = None, +) -> dict: + """Detect vocal presence via spectral energy ratio, formant peaks, and MFCC likelihood. + + Ported from sonic-architect-app/services/vocalDetection.ts. + Uses Essentia MFCC (already computed elsewhere), Spectrum, and SpectralPeaks + for formant detection instead of browser FFT. + """ + try: + mono_arr = np.asarray(mono, dtype=np.float32) + if mono_arr.ndim != 1 or mono_arr.size < 2048: + return {"vocalDetail": None} + + frame_size = 2048 + hop_size = 512 + + # --- Frequency band boundaries --- + vocal_fund_low = 150.0 # Hz — low male voice + vocal_fund_high = 1500.0 # Hz — high female voice + formant_low = 300.0 # Hz — first formant + formant_high = 4000.0 # Hz — third formant + + window = es.Windowing(type="hann", size=frame_size) + spectrum = es.Spectrum(size=frame_size) + + vocal_energy_sum = 0.0 + formant_energy_sum = 0.0 + total_energy_sum = 0.0 + + # 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_frames = 0 + + spectral_peaks_algo = es.SpectralPeaks( + orderBy="frequency", + magnitudeThreshold=0.00001, + maxPeaks=60, + sampleRate=sample_rate, + ) + + for frame in es.FrameGenerator(mono_arr, frameSize=frame_size, hopSize=hop_size): + spec = spectrum(window(frame)) + if spec.size == 0: + continue + + freq_resolution = float(sample_rate) / float(frame_size) + # Band energy via spectrum bins + for k in range(spec.size): + freq = k * freq_resolution + energy = float(spec[k]) ** 2 + total_energy_sum += energy + if vocal_fund_low <= freq <= vocal_fund_high: + vocal_energy_sum += energy + if formant_low <= freq <= formant_high: + formant_energy_sum += energy + + # Formant peak matching via SpectralPeaks (every 4th frame for speed) + formant_frames += 1 + if formant_frames % 4 == 0: + peak_freqs, peak_mags = spectral_peaks_algo(spec) + frame_matches = 0 + for ef in expected_formants: + for pf in peak_freqs: + if abs(float(pf) - ef) < formant_tolerance: + frame_matches += 1 + break + formant_match_total += frame_matches + + 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) + + # --- MFCC vocal likelihood --- + mfcc_algo = es.MFCC( + numberCoefficients=13, + inputSize=frame_size // 2 + 1, + sampleRate=sample_rate, + ) + mfcc_accum = np.zeros(13, dtype=np.float64) + mfcc_count = 0 + for frame in es.FrameGenerator(mono_arr, frameSize=frame_size, hopSize=hop_size * 4): + spec = spectrum(window(frame)) + _, coeffs = mfcc_algo(spec) + coeffs_arr = np.asarray(coeffs, dtype=np.float64) + if coeffs_arr.size >= 13 and np.all(np.isfinite(coeffs_arr[:13])): + mfcc_accum += coeffs_arr[:13] + mfcc_count += 1 + + if mfcc_count > 0: + avg_mfcc = mfcc_accum / mfcc_count + low_e = float(np.sum(np.abs(avg_mfcc[1:4]))) + mid_e = float(np.sum(np.abs(avg_mfcc[4:9]))) + high_e = float(np.sum(np.abs(avg_mfcc[9:13]))) + total_e = low_e + mid_e + high_e + if total_e > 0: + low_r = low_e / total_e + mid_r = mid_e / total_e + high_r = high_e / total_e + mfcc_likelihood = ( + (1.0 - abs(low_r - 0.40)) + + (1.0 - abs(mid_r - 0.35)) + + (1.0 - abs(high_r - 0.25)) + ) / 3.0 + else: + mfcc_likelihood = 0.5 + else: + mfcc_likelihood = 0.5 + + # --- 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 + + return { + "vocalDetail": { + "hasVocals": has_vocals, + "confidence": round(float(confidence), 2), + "vocalEnergyRatio": round(float(vocal_energy_ratio), 2), + "formantStrength": round(float(formant_strength), 2), + "mfccLikelihood": round(float(mfcc_likelihood), 2), + } + } + except Exception as e: + print(f"[warn] Vocal detection failed: {e}", file=sys.stderr) + return {"vocalDetail": None} + + +def analyze_supersaw_detail( + mono: np.ndarray, + sample_rate: int = 44100, + bpm: float | None = None, +) -> dict: + """Detect detuned sawtooth stacks characteristic of supersaw patches. + + Ported from sonic-architect-app/services/supersawDetection.ts. + The JS version uses Basic Pitch pitchBend data; in Python we use Essentia + SpectralPeaks to find near-unison partials, measure detune spread, and + check for sawtooth harmonic decay patterns. + """ + try: + mono_arr = np.asarray(mono, dtype=np.float32) + if mono_arr.ndim != 1 or mono_arr.size < 4096: + return {"supersawDetail": None} + + frame_size = 4096 + hop_size = 2048 + + window = es.Windowing(type="hann", size=frame_size) + spectrum = es.Spectrum(size=frame_size) + spectral_peaks = es.SpectralPeaks( + orderBy="magnitude", + magnitudeThreshold=0.00001, + maxPeaks=80, + sampleRate=sample_rate, + ) + + # Supersaw range: 200 Hz – 5 kHz + sup_low = 200.0 + sup_high = 5000.0 + + all_voice_counts: list[int] = [] + all_detune_cents: list[float] = [] + frames_analyzed = 0 + + for frame in es.FrameGenerator(mono_arr, frameSize=frame_size, hopSize=hop_size): + spec = spectrum(window(frame)) + peak_freqs, peak_mags = spectral_peaks(spec) + + # Filter to supersaw range + in_range = [ + (float(f), float(m)) + for f, m in zip(peak_freqs, peak_mags) + if sup_low <= float(f) <= sup_high and float(m) > 0 + ] + if len(in_range) < 3: + continue + frames_analyzed += 1 + + # Group peaks into clusters of near-unison voices + # Two peaks within 50 cents are considered "near-unison" + in_range.sort(key=lambda x: x[0]) + clusters: list[list[float]] = [] + current_cluster: list[float] = [in_range[0][0]] + + for i in range(1, len(in_range)): + prev_f = current_cluster[-1] + cur_f = in_range[i][0] + if prev_f > 0: + cents = 1200.0 * abs(np.log2(cur_f / prev_f)) + else: + cents = 999.0 + if cents < 50.0: + current_cluster.append(cur_f) + else: + if len(current_cluster) >= 3: + clusters.append(current_cluster) + current_cluster = [cur_f] + if len(current_cluster) >= 3: + clusters.append(current_cluster) + + for cluster in clusters: + all_voice_counts.append(len(cluster)) + # Measure detune spread within cluster + for j in range(1, len(cluster)): + if cluster[j - 1] > 0: + d = 1200.0 * abs(np.log2(cluster[j] / cluster[j - 1])) + if 5.0 < d < 50.0: + all_detune_cents.append(d) + + if frames_analyzed == 0 or len(all_voice_counts) == 0: + return { + "supersawDetail": { + "isSupersaw": False, + "confidence": 0.0, + "voiceCount": 0, + "avgDetuneCents": 0.0, + "spectralComplexity": 0.0, + } + } + + avg_voice_count = float(np.mean(all_voice_counts)) + avg_detune = float(np.mean(all_detune_cents)) if all_detune_cents else 0.0 + + # Spectral complexity — number of peaks per frame in supersaw range + spectral_complexity = avg_voice_count + + # --- Scoring --- + voice_count_score = min(1.0, max(0.0, (avg_voice_count - 3.0) / 4.0)) + + # Detune score: peak at 20 cents, falling off + if avg_detune < 5.0 or avg_detune > 50.0: + detune_score = 0.0 + else: + distance = abs(avg_detune - 20.0) + if distance <= 10.0: + detune_score = 1.0 - distance * 0.05 + else: + detune_score = max(0.0, 0.5 - (distance - 10.0) * 0.05) + + consistency_score = min(1.0, len(all_voice_counts) / max(1, frames_analyzed) * 2.0) + + confidence = voice_count_score * 0.35 + detune_score * 0.35 + consistency_score * 0.30 + is_supersaw = confidence > 0.4 and avg_voice_count >= 3.0 + + return { + "supersawDetail": { + "isSupersaw": is_supersaw, + "confidence": round(float(min(1.0, confidence)), 2), + "voiceCount": round(float(avg_voice_count)), + "avgDetuneCents": round(float(avg_detune), 1), + "spectralComplexity": round(float(spectral_complexity), 1), + } + } + except Exception as e: + print(f"[warn] Supersaw detection failed: {e}", file=sys.stderr) + return {"supersawDetail": None} + + +def analyze_bass_detail( + mono: np.ndarray, + sample_rate: int = 44100, + bpm: float | None = None, +) -> dict: + """Analyze bass character: sub-bass decay time, transient ratio, fundamental Hz, swing. + + Ported from sonic-architect-app/services/bassAnalysis.ts. + Uses Essentia LowPass for bass extraction, energy-based onset detection, + decay measurement to -6 dB, and ZCR fundamental estimation. + """ + try: + mono_arr = np.asarray(mono, dtype=np.float32) + if mono_arr.ndim != 1 or mono_arr.size < sample_rate: + return {"bassDetail": None} + + effective_bpm = bpm if (bpm is not None and np.isfinite(bpm) and bpm > 0) else 120.0 + + # --- 1. Extract bass band: one-pole lowpass at 150 Hz --- + fc = 150.0 / float(sample_rate) + alpha = float(np.exp(-2.0 * np.pi * fc)) + a0 = 1.0 - alpha + bass = np.zeros(mono_arr.size, dtype=np.float64) + y1 = 0.0 + for i in range(mono_arr.size): + y1 = a0 * float(mono_arr[i]) + alpha * y1 + bass[i] = y1 + bass = bass.astype(np.float32) + + # --- 2. Find bass transients (energy-based onset detection) --- + hop_onset = max(1, int(sample_rate * 0.01)) # 10 ms hops + frame_onset = max(1, int(sample_rate * 0.04)) # 40 ms frames + beat_dur_s = 60.0 / effective_bpm + min_onset_dist = int(beat_dur_s * 0.25 * sample_rate) # ~1/16th note + + onsets: list[int] = [] + prev_energy = 0.0 + last_onset = -min_onset_dist + + i = 0 + while i + frame_onset < bass.size: + frame_slice = bass[i : i + frame_onset] + energy = float(np.sqrt(np.mean(frame_slice ** 2))) + diff = energy - prev_energy + rel_diff = diff / prev_energy if prev_energy > 0.001 else 0.0 + if rel_diff > 0.5 and energy > 0.01 and (i - last_onset) >= min_onset_dist: + onsets.append(i) + last_onset = i + prev_energy = energy * 0.8 + prev_energy * 0.2 + i += hop_onset + + # --- 3. Fundamental estimation via ZCR on middle 50% --- + start_zcr = bass.size // 4 + end_zcr = (bass.size * 3) // 4 + crossings = 0 + for j in range(start_zcr + 1, end_zcr): + if (bass[j - 1] < 0 and bass[j] >= 0) or (bass[j - 1] >= 0 and bass[j] < 0): + crossings += 1 + dur_zcr = float(end_zcr - start_zcr) / float(sample_rate) + zcr = crossings / dur_zcr if dur_zcr > 0 else 0.0 + fundamental_hz = max(30.0, min(120.0, zcr / 2.0)) + + if len(onsets) < 3: + return { + "bassDetail": { + "averageDecayMs": 1000, + "type": "sustained", + "transientRatio": 0.2, + "fundamentalHz": round(fundamental_hz), + "transientCount": len(onsets), + "swingPercent": 0, + "grooveType": "straight", + } + } + + # --- 4. Measure decay time per onset to -6 dB --- + DECAY_THRESHOLD_DB = -6.0 + MAX_DECAY_MS = 2000.0 + decay_times: list[float] = [] + + for idx in range(len(onsets) - 1): + onset_sample = onsets[idx] + next_onset = onsets[idx + 1] + max_decay_samples = min( + 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: + continue + threshold_val = peak_val * (10.0 ** (DECAY_THRESHOLD_DB / 20.0)) + found = False + for s in range(max_decay_samples): + if onset_sample + s >= bass.size: + break + if abs(float(bass[onset_sample + s])) < threshold_val: + decay_times.append((s / float(sample_rate)) * 1000.0) + found = True + break + if not found: + decay_times.append((max_decay_samples / float(sample_rate)) * 1000.0) + + avg_decay = float(np.mean(decay_times)) if decay_times else 800.0 + + if avg_decay < 300: + bass_type = "punchy" + elif avg_decay < 600: + bass_type = "medium" + elif avg_decay < 1000: + bass_type = "rolling" + else: + bass_type = "sustained" + + # --- 5. Transient ratio --- + transient_window_samples = int(0.1 * sample_rate) # 100 ms + transient_energy = 0.0 + marked: set[int] = set() + for ons in onsets: + for s in range(ons, min(ons + transient_window_samples, bass.size)): + if s not in marked: + transient_energy += float(bass[s]) ** 2 + marked.add(s) + total_bass_energy = float(np.sum(bass ** 2)) + transient_ratio = transient_energy / total_bass_energy if total_bass_energy > 0 else 0.0 + + # --- 6. Swing detection from onset intervals --- + swing_percent = 0 + groove_type = "straight" + if len(onsets) >= 8: + intervals = [float(onsets[k + 1] - onsets[k]) / float(sample_rate) + for k in range(len(onsets) - 1)] + if len(intervals) >= 4: + mean_int = float(np.mean(intervals)) + var_int = float(np.var(intervals)) + std_int = float(np.sqrt(var_int)) + cv = std_int / mean_int if mean_int > 0 else 0.0 + # Lag-1 autocorrelation for alternation detection + if var_int > 0: + alt_sum = sum( + (intervals[j] - mean_int) * (intervals[j + 1] - mean_int) + for j in range(len(intervals) - 1) + ) + alt_corr = alt_sum / (len(intervals) - 1) / var_int + else: + alt_corr = 0.0 + if alt_corr < -0.1 and cv > 0.05: + swing_percent = int(min(50, max(0, cv * 400))) + if swing_percent < 10: + groove_type = "straight" + elif swing_percent < 25: + groove_type = "slight-swing" + elif swing_percent < 40: + groove_type = "heavy-swing" + else: + groove_type = "shuffle" + + return { + "bassDetail": { + "averageDecayMs": round(avg_decay), + "type": bass_type, + "transientRatio": round(float(np.clip(transient_ratio, 0.0, 1.0)), 2), + "fundamentalHz": round(fundamental_hz), + "transientCount": len(onsets), + "swingPercent": swing_percent, + "grooveType": groove_type, + } + } + except Exception as e: + print(f"[warn] Bass analysis failed: {e}", file=sys.stderr) + return {"bassDetail": None} + + +def analyze_kick_detail( + mono: np.ndarray, + sample_rate: int = 44100, + bpm: float | None = None, +) -> dict: + """Analyze kick drum characteristics: onset sharpness, fundamental pitch, THD, harmonic ratio. + + Ported from sonic-architect-app/services/kickAnalysis.ts. + Uses Essentia Spectrum + Windowing in the kick band (30-120 Hz), + OnsetDetection for transients, per-kick THD measurement up to 10th harmonic. + """ + try: + mono_arr = np.asarray(mono, dtype=np.float32) + if mono_arr.ndim != 1 or mono_arr.size < 4096: + return {"kickDetail": 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 + kick_low = 30.0 + kick_high = 120.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(kick_low / freq_resolution)) + high_bin = min(frame_size // 2 - 1, int(kick_high / freq_resolution)) + + # --- 1. Build energy envelope in kick band --- + envelope: list[float] = [] + for frame in es.FrameGenerator(mono_arr, frameSize=frame_size, hopSize=hop_size): + spec = spectrum_algo(window(frame)) + kick_energy = 0.0 + for k in range(low_bin, high_bin + 1): + kick_energy += float(spec[k]) ** 2 + n_bins = max(1, high_bin - low_bin + 1) + envelope.append(float(np.sqrt(kick_energy / n_bins))) + + if len(envelope) < 5: + return {"kickDetail": None} + + # --- 2. Detect kick transients (peaks in envelope) --- + beat_dur_s = 60.0 / effective_bpm + min_dist_samples = int(beat_dur_s * 0.25 * sample_rate) # 16th note + min_dist_frames = max(1, min_dist_samples // hop_size) + + transients: list[int] = [] # indices into envelope + last_transient = -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] > 0.01 + and i - last_transient >= min_dist_frames + ): + transients.append(i) + last_transient = i + + if len(transients) < 2: + return { + "kickDetail": { + "isDistorted": False, + "thd": 0.0, + "harmonicRatio": 0.0, + "fundamentalHz": 50.0, + "kickCount": len(transients), + } + } + + # --- 3. Per-kick THD and harmonic analysis --- + thd_values: list[float] = [] + harmonic_ratios: list[float] = [] + fundamentals: list[float] = [] + + for t_idx in transients: + start_sample = t_idx * hop_size + kick_frame_len = min(int(0.08 * sample_rate), frame_size) # 80 ms + 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)) + + # Find fundamental (strongest peak in kick band) + max_mag = 0.0 + fund_bin = low_bin + for k in range(low_bin, high_bin + 1): + mag = float(spec[k]) + if mag > max_mag: + max_mag = mag + fund_bin = k + fund_hz = fund_bin * freq_resolution + fund_power = max_mag ** 2 + + # THD: sum of harmonic powers / fundamental power + max_harmonic = min(10, int((sample_rate / 2.0) / fund_hz)) if fund_hz > 0 else 1 + harmonic_power = 0.0 + for h in range(2, max_harmonic + 1): + h_bin = round(fund_bin * h) + if 0 < h_bin < spec.size: + harmonic_power += float(spec[h_bin]) ** 2 + + thd = float(np.sqrt(harmonic_power) / np.sqrt(fund_power)) if fund_power > 0 else 0.0 + + # Harmonic vs inharmonic ratio in kick band + harmonic_energy = 0.0 + inharmonic_energy = 0.0 + bin_width = freq_resolution + for k in range(low_bin, min(high_bin + 1, spec.size)): + freq = k * freq_resolution + mag = float(spec[k]) + is_harmonic = False + if fund_hz > 0: + for hh in range(1, 11): + if abs(freq - fund_hz * hh) < bin_width * 1.5: + is_harmonic = True + break + if is_harmonic: + harmonic_energy += mag ** 2 + else: + inharmonic_energy += mag ** 2 + total_e = harmonic_energy + inharmonic_energy + h_ratio = harmonic_energy / total_e if total_e > 0 else 0.0 + + thd_values.append(min(1.0, thd)) + harmonic_ratios.append(h_ratio) + fundamentals.append(fund_hz) + + if not thd_values: + return { + "kickDetail": { + "isDistorted": False, + "thd": 0.0, + "harmonicRatio": 0.0, + "fundamentalHz": 50.0, + "kickCount": len(transients), + } + } + + avg_thd = float(np.mean(thd_values)) + avg_harmonic_ratio = float(np.mean(harmonic_ratios)) + avg_fundamental = float(np.mean(fundamentals)) + is_distorted = avg_thd > 0.15 or avg_harmonic_ratio < 0.5 + + return { + "kickDetail": { + "isDistorted": is_distorted, + "thd": round(avg_thd, 2), + "harmonicRatio": round(avg_harmonic_ratio, 2), + "fundamentalHz": round(avg_fundamental), + "kickCount": len(transients), + } + } + except Exception as e: + print(f"[warn] Kick analysis failed: {e}", file=sys.stderr) + return {"kickDetail": None} + + def analyze_arrangement_detail(mono: np.ndarray, sample_rate: int = 44100) -> dict: """Novelty timeline from Bark bands to expose structural events.""" try: @@ -3226,6 +4043,12 @@ def main(): "transcriptionDetail": result.get("transcriptionDetail"), "grooveDetail": result.get("grooveDetail"), "sidechainDetail": result.get("sidechainDetail"), + "acidDetail": result.get("acidDetail"), + "reverbDetail": result.get("reverbDetail"), + "vocalDetail": result.get("vocalDetail"), + "supersawDetail": result.get("supersawDetail"), + "bassDetail": result.get("bassDetail"), + "kickDetail": result.get("kickDetail"), "effectsDetail": result.get("effectsDetail"), "synthesisCharacter": result.get("synthesisCharacter"), "danceability": result.get("danceability"), @@ -3318,6 +4141,12 @@ def main(): # Groove detail result.update(analyze_groove(mono, sample_rate, rhythm_data, beat_data)) result.update(analyze_sidechain_detail(mono, sample_rate, rhythm_data, beat_data)) + result.update(analyze_acid_detail(mono, sample_rate, bpm=result.get("bpm"))) + result.update(analyze_reverb_detail(mono, sample_rate, bpm=result.get("bpm"))) + result.update(analyze_vocal_detail(mono, sample_rate, bpm=result.get("bpm"))) + result.update(analyze_supersaw_detail(mono, sample_rate, bpm=result.get("bpm"))) + result.update(analyze_bass_detail(mono, sample_rate, bpm=result.get("bpm"))) + result.update(analyze_kick_detail(mono, sample_rate, bpm=result.get("bpm"))) result.update( analyze_effects_detail( mono, @@ -3403,6 +4232,12 @@ def main(): "transcriptionDetail": result.get("transcriptionDetail"), "grooveDetail": result.get("grooveDetail"), "sidechainDetail": result.get("sidechainDetail"), + "acidDetail": result.get("acidDetail"), + "reverbDetail": result.get("reverbDetail"), + "vocalDetail": result.get("vocalDetail"), + "supersawDetail": result.get("supersawDetail"), + "bassDetail": result.get("bassDetail"), + "kickDetail": result.get("kickDetail"), "effectsDetail": result.get("effectsDetail"), "synthesisCharacter": result.get("synthesisCharacter"), "danceability": result.get("danceability"), diff --git a/apps/backend/server.py b/apps/backend/server.py index 8e5366e3..26ecf683 100644 --- a/apps/backend/server.py +++ b/apps/backend/server.py @@ -468,6 +468,12 @@ def _build_phase1(payload: dict[str, Any]) -> dict[str, Any]: "transcriptionDetail": payload.get("transcriptionDetail"), "grooveDetail": payload.get("grooveDetail"), "sidechainDetail": payload.get("sidechainDetail"), + "acidDetail": payload.get("acidDetail"), + "reverbDetail": payload.get("reverbDetail"), + "vocalDetail": payload.get("vocalDetail"), + "supersawDetail": payload.get("supersawDetail"), + "bassDetail": payload.get("bassDetail"), + "kickDetail": payload.get("kickDetail"), "effectsDetail": payload.get("effectsDetail"), "synthesisCharacter": payload.get("synthesisCharacter"), "danceability": payload.get("danceability"), diff --git a/apps/backend/tests/test_analyze.py b/apps/backend/tests/test_analyze.py index 078eb6e1..8c07ad04 100644 --- a/apps/backend/tests/test_analyze.py +++ b/apps/backend/tests/test_analyze.py @@ -29,7 +29,9 @@ "lufsIntegrated", "lufsRange", "truePeak", "crestFactor", "dynamicSpread", "dynamicCharacter", "stereoDetail", "spectralBalance", "spectralDetail", "rhythmDetail", "melodyDetail", "transcriptionDetail", - "grooveDetail", "sidechainDetail", "effectsDetail", "synthesisCharacter", + "grooveDetail", "sidechainDetail", "acidDetail", "reverbDetail", + "vocalDetail", "supersawDetail", "bassDetail", "kickDetail", + "effectsDetail", "synthesisCharacter", "danceability", "structure", "arrangementDetail", "segmentLoudness", "segmentSpectral", "segmentStereo", "segmentKey", "chordDetail", "perceptual", "essentiaFeatures", @@ -602,5 +604,401 @@ def transcribe(self, audio_path, stem_paths=None): self.assertEqual(result, {"transcriptionDetail": None}) +class AcidDetailTests(unittest.TestCase): + """Tests for analyze_acid_detail — TB-303 acid bassline detection.""" + + @classmethod + def setUpClass(cls): + analyze_path = Path(__file__).resolve().parents[1] / "analyze.py" + spec = importlib.util.spec_from_file_location("analyze_acid_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_acid_detail(mono, 44100, bpm=128.0) + self.assertEqual(result, {"acidDetail": None}) + + def test_returns_none_when_bpm_is_none(self): + mono = np.zeros(44100, dtype=np.float32) + result = self.analyze.analyze_acid_detail(mono, 44100, bpm=None) + self.assertEqual(result, {"acidDetail": None}) + + def test_short_signal_returns_low_confidence(self): + """Very short silence should produce zero-confidence acid result.""" + mono = np.zeros(44100, dtype=np.float32) + result = self.analyze.analyze_acid_detail(mono, 44100, bpm=128.0) + detail = result.get("acidDetail") + self.assertIsNotNone(detail) + self.assertFalse(detail["isAcid"]) + self.assertEqual(detail["confidence"], 0.0) + + def test_output_schema_fields(self): + """All expected fields must be present in the output.""" + sr = 44100 + duration = 3.0 + t = np.linspace(0, duration, int(sr * duration), endpoint=False, dtype=np.float32) + mono = 0.5 * np.sin(2 * np.pi * 200 * t).astype(np.float32) + result = self.analyze.analyze_acid_detail(mono, sr, bpm=130.0) + detail = result.get("acidDetail") + self.assertIsNotNone(detail) + expected_keys = {"isAcid", "confidence", "resonanceLevel", "centroidOscillationHz", "bassRhythmDensity"} + self.assertEqual(set(detail.keys()), expected_keys) + + def test_resonant_sweeping_bass_scores_higher(self): + """A signal with resonant bass + centroid movement should score higher than silence.""" + sr = 44100 + duration = 4.0 + n_samples = int(sr * duration) + t = np.linspace(0, duration, n_samples, endpoint=False, dtype=np.float32) + sweep_freq = 150 + 550 * (t / duration) + mono = 0.5 * np.sin(2 * np.pi * sweep_freq * t) + mono += 0.3 * np.sin(2 * np.pi * sweep_freq * 2 * t) + mono = mono.astype(np.float32) + result = self.analyze.analyze_acid_detail(mono, sr, bpm=130.0) + detail = result["acidDetail"] + self.assertGreater(detail["centroidOscillationHz"], 0) + self.assertGreater(detail["resonanceLevel"], 0) + + def test_confidence_bounded_zero_to_one(self): + """Confidence must always be in [0, 1].""" + sr = 44100 + mono = np.random.randn(int(sr * 2.0)).astype(np.float32) * 0.3 + result = self.analyze.analyze_acid_detail(mono, sr, bpm=140.0) + detail = result["acidDetail"] + self.assertGreaterEqual(detail["confidence"], 0.0) + self.assertLessEqual(detail["confidence"], 1.0) + + +class ReverbDetailTests(unittest.TestCase): + """Tests for analyze_reverb_detail — RT60 estimation from decay slopes.""" + + @classmethod + def setUpClass(cls): + analyze_path = Path(__file__).resolve().parents[1] / "analyze.py" + spec = importlib.util.spec_from_file_location("analyze_reverb_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 _make_decaying_signal(self, sr: int, n_transients: int, rt60_target: float, duration: float = 6.0) -> np.ndarray: + """Generate a signal with clear transients followed by exponential decay.""" + n_samples = int(sr * duration) + mono = np.zeros(n_samples, dtype=np.float32) + beat_samples = int(sr * (60.0 / 130.0)) + decay_rate = np.log(1000) / (rt60_target * sr) + + for i in range(n_transients): + onset = i * beat_samples + if onset >= n_samples: + break + burst_len = min(200, n_samples - onset) + t = np.arange(burst_len, dtype=np.float32) + decay_env = np.exp(-decay_rate * t) + mono[onset:onset + burst_len] += (0.8 * decay_env * np.sin(2 * np.pi * 440 * t / sr)).astype(np.float32) + + return mono + + def test_returns_none_for_empty_signal(self): + mono = np.array([], dtype=np.float32) + result = self.analyze.analyze_reverb_detail(mono, 44100, bpm=128.0) + self.assertEqual(result, {"reverbDetail": None}) + + def test_output_schema_fields(self): + """All expected fields must be present.""" + 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"}) + + def test_rt60_bounded(self): + """RT60 must be >= 0 and <= 3.0 (capped).""" + sr = 44100 + mono = self._make_decaying_signal(sr, n_transients=8, rt60_target=0.3, duration=6.0) + result = self.analyze.analyze_reverb_detail(mono, sr, bpm=130.0) + detail = result["reverbDetail"] + self.assertGreaterEqual(detail["rt60"], 0.0) + self.assertLessEqual(detail["rt60"], 3.0) + + def test_tail_energy_ratio_bounded(self): + """tailEnergyRatio must always be in [0, 1].""" + sr = 44100 + mono = self._make_decaying_signal(sr, n_transients=8, rt60_target=0.5, duration=6.0) + result = self.analyze.analyze_reverb_detail(mono, sr, bpm=130.0) + detail = result["reverbDetail"] + self.assertGreaterEqual(detail["tailEnergyRatio"], 0.0) + self.assertLessEqual(detail["tailEnergyRatio"], 1.0) + + def test_is_wet_matches_rt60_threshold(self): + """`isWet` must be True iff rt60 > 0.5.""" + sr = 44100 + mono = self._make_decaying_signal(sr, n_transients=10, rt60_target=1.2, duration=8.0) + result = self.analyze.analyze_reverb_detail(mono, sr, bpm=130.0) + detail = result["reverbDetail"] + self.assertEqual(detail["isWet"], detail["rt60"] > 0.5) + + def test_fallback_on_no_bpm(self): + """None BPM uses fallback (120 BPM) and does not crash.""" + sr = 44100 + mono = self._make_decaying_signal(sr, n_transients=6, rt60_target=0.4, duration=5.0) + result = self.analyze.analyze_reverb_detail(mono, sr, bpm=None) + self.assertIn("reverbDetail", result) + + def test_short_silence_returns_fallback(self): + """Short silent signals return a safe fallback dict, not null.""" + mono = np.zeros(44100 // 2, dtype=np.float32) + result = self.analyze.analyze_reverb_detail(mono, 44100, bpm=128.0) + detail = result.get("reverbDetail") + self.assertIsNotNone(detail) + self.assertIn("rt60", detail) + + +class VocalDetailTests(unittest.TestCase): + """Tests for analyze_vocal_detail — vocal presence detection.""" + + @classmethod + def setUpClass(cls): + analyze_path = Path(__file__).resolve().parents[1] / "analyze.py" + spec = importlib.util.spec_from_file_location("analyze_vocal_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_vocal_detail(mono, 44100, bpm=128.0) + self.assertEqual(result, {"vocalDetail": None}) + + def test_returns_none_for_short_signal(self): + mono = np.zeros(1024, dtype=np.float32) + result = self.analyze.analyze_vocal_detail(mono, 44100, bpm=128.0) + self.assertEqual(result, {"vocalDetail": None}) + + def test_output_schema_fields(self): + """All expected fields must be present.""" + 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 * 440 * t).astype(np.float32) + 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"} + self.assertEqual(set(detail.keys()), expected_keys) + + def test_confidence_bounded_zero_to_one(self): + sr = 44100 + mono = np.random.randn(int(sr * 2.0)).astype(np.float32) * 0.3 + result = self.analyze.analyze_vocal_detail(mono, sr, bpm=120.0) + detail = result["vocalDetail"] + self.assertGreaterEqual(detail["confidence"], 0.0) + self.assertLessEqual(detail["confidence"], 1.0) + + def test_silence_has_low_confidence(self): + """Silence should not be detected as vocals.""" + sr = 44100 + mono = np.zeros(int(sr * 2.0), dtype=np.float32) + result = self.analyze.analyze_vocal_detail(mono, sr, bpm=120.0) + detail = result["vocalDetail"] + self.assertIsNotNone(detail) + self.assertFalse(detail["hasVocals"]) + + +class SupersawDetailTests(unittest.TestCase): + """Tests for analyze_supersaw_detail — detuned unison detection.""" + + @classmethod + def setUpClass(cls): + analyze_path = Path(__file__).resolve().parents[1] / "analyze.py" + spec = importlib.util.spec_from_file_location("analyze_supersaw_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_supersaw_detail(mono, 44100, bpm=128.0) + self.assertEqual(result, {"supersawDetail": None}) + + def test_returns_none_for_short_signal(self): + mono = np.zeros(2048, dtype=np.float32) + result = self.analyze.analyze_supersaw_detail(mono, 44100, bpm=128.0) + self.assertEqual(result, {"supersawDetail": None}) + + def test_output_schema_fields(self): + """All expected fields must be present.""" + sr = 44100 + duration = 2.0 + t = np.linspace(0, duration, int(sr * duration), endpoint=False, dtype=np.float32) + mono = 0.3 * np.sin(2 * np.pi * 440 * t).astype(np.float32) + result = self.analyze.analyze_supersaw_detail(mono, sr, bpm=128.0) + detail = result.get("supersawDetail") + self.assertIsNotNone(detail) + expected_keys = {"isSupersaw", "confidence", "voiceCount", "avgDetuneCents", "spectralComplexity"} + self.assertEqual(set(detail.keys()), expected_keys) + + def test_confidence_bounded_zero_to_one(self): + sr = 44100 + mono = np.random.randn(int(sr * 2.0)).astype(np.float32) * 0.3 + result = self.analyze.analyze_supersaw_detail(mono, sr, bpm=128.0) + detail = result["supersawDetail"] + self.assertGreaterEqual(detail["confidence"], 0.0) + self.assertLessEqual(detail["confidence"], 1.0) + + def test_detuned_saws_score_higher_than_single_sine(self): + """Multiple detuned sawtooth waves should score higher than a single sine.""" + sr = 44100 + duration = 3.0 + t = np.linspace(0, duration, int(sr * duration), endpoint=False, dtype=np.float32) + # Single sine + single = 0.3 * np.sin(2 * np.pi * 440 * t).astype(np.float32) + result_single = self.analyze.analyze_supersaw_detail(single, sr, bpm=128.0) + # Detuned stack (5 voices, ±15 cents) + stack = np.zeros_like(t) + base_freq = 440.0 + for detune_cents in [-15, -7, 0, 7, 15]: + freq = base_freq * (2.0 ** (detune_cents / 1200.0)) + stack += 0.15 * np.sin(2 * np.pi * freq * t) + stack = stack.astype(np.float32) + result_stack = self.analyze.analyze_supersaw_detail(stack, sr, bpm=128.0) + self.assertGreaterEqual( + result_stack["supersawDetail"]["voiceCount"], + result_single["supersawDetail"]["voiceCount"], + ) + + +class BassDetailTests(unittest.TestCase): + """Tests for analyze_bass_detail — bass character analysis.""" + + @classmethod + def setUpClass(cls): + analyze_path = Path(__file__).resolve().parents[1] / "analyze.py" + spec = importlib.util.spec_from_file_location("analyze_bass_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_bass_detail(mono, 44100, bpm=128.0) + self.assertEqual(result, {"bassDetail": None}) + + def test_returns_none_for_short_signal(self): + """Signal shorter than 1 second should return None.""" + mono = np.zeros(22050, dtype=np.float32) + result = self.analyze.analyze_bass_detail(mono, 44100, bpm=128.0) + self.assertEqual(result, {"bassDetail": None}) + + def test_output_schema_fields(self): + """All expected fields must be present.""" + sr = 44100 + duration = 3.0 + t = np.linspace(0, duration, int(sr * duration), endpoint=False, dtype=np.float32) + mono = 0.5 * np.sin(2 * np.pi * 60 * t).astype(np.float32) + result = self.analyze.analyze_bass_detail(mono, sr, bpm=128.0) + detail = result.get("bassDetail") + self.assertIsNotNone(detail) + expected_keys = {"averageDecayMs", "type", "transientRatio", "fundamentalHz", "transientCount", "swingPercent", "grooveType"} + self.assertEqual(set(detail.keys()), expected_keys) + + def test_groove_type_valid_values(self): + """grooveType must be one of the defined categories.""" + sr = 44100 + duration = 4.0 + t = np.linspace(0, duration, int(sr * duration), endpoint=False, dtype=np.float32) + mono = 0.5 * np.sin(2 * np.pi * 80 * t).astype(np.float32) + result = self.analyze.analyze_bass_detail(mono, sr, bpm=130.0) + detail = result.get("bassDetail") + if detail is not None: + self.assertIn(detail["grooveType"], {"straight", "slight-swing", "heavy-swing", "shuffle"}) + self.assertIn(detail["type"], {"punchy", "medium", "rolling", "sustained"}) + + def test_fallback_on_no_bpm(self): + """None BPM uses fallback (120 BPM) and does not crash.""" + sr = 44100 + t = np.linspace(0, 3.0, int(sr * 3.0), endpoint=False, dtype=np.float32) + mono = 0.5 * np.sin(2 * np.pi * 60 * t).astype(np.float32) + result = self.analyze.analyze_bass_detail(mono, sr, bpm=None) + self.assertIn("bassDetail", result) + + +class KickDetailTests(unittest.TestCase): + """Tests for analyze_kick_detail — kick drum distortion and THD.""" + + @classmethod + def setUpClass(cls): + analyze_path = Path(__file__).resolve().parents[1] / "analyze.py" + spec = importlib.util.spec_from_file_location("analyze_kick_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_kick_detail(mono, 44100, bpm=128.0) + self.assertEqual(result, {"kickDetail": None}) + + def test_returns_none_for_short_signal(self): + mono = np.zeros(2048, dtype=np.float32) + result = self.analyze.analyze_kick_detail(mono, 44100, bpm=128.0) + self.assertEqual(result, {"kickDetail": None}) + + def test_output_schema_fields(self): + """All expected fields must be present.""" + sr = 44100 + duration = 4.0 + n = int(sr * duration) + t = np.linspace(0, duration, n, endpoint=False, dtype=np.float32) + # Simulate kick-like transients at 60 Hz + beat_samples = int(sr * 0.5) # 120 BPM + mono = np.zeros(n, dtype=np.float32) + for i in range(int(duration * 2)): + onset = i * beat_samples + if onset + 2000 < n: + burst = np.arange(2000, dtype=np.float32) + mono[onset:onset + 2000] = 0.8 * np.sin(2 * np.pi * 60 * burst / sr) * np.exp(-burst / 500) + result = self.analyze.analyze_kick_detail(mono, sr, bpm=120.0) + detail = result.get("kickDetail") + self.assertIsNotNone(detail) + expected_keys = {"isDistorted", "thd", "harmonicRatio", "fundamentalHz", "kickCount"} + self.assertEqual(set(detail.keys()), expected_keys) + + def test_thd_bounded(self): + """THD should be in [0, 1].""" + sr = 44100 + duration = 3.0 + n = int(sr * duration) + t = np.linspace(0, duration, n, endpoint=False, dtype=np.float32) + mono = 0.5 * np.sin(2 * np.pi * 60 * t).astype(np.float32) + result = self.analyze.analyze_kick_detail(mono, sr, bpm=128.0) + detail = result.get("kickDetail") + if detail is not None: + self.assertGreaterEqual(detail["thd"], 0.0) + self.assertLessEqual(detail["thd"], 1.0) + + def test_fallback_on_no_bpm(self): + """None BPM uses fallback (120 BPM) and does not crash.""" + sr = 44100 + duration = 3.0 + t = np.linspace(0, duration, int(sr * duration), endpoint=False, dtype=np.float32) + mono = 0.5 * np.sin(2 * np.pi * 60 * t).astype(np.float32) + result = self.analyze.analyze_kick_detail(mono, sr, bpm=None) + self.assertIn("kickDetail", result) + + if __name__ == "__main__": unittest.main() diff --git a/apps/ui/src/types.ts b/apps/ui/src/types.ts index d15f07f9..185b960c 100644 --- a/apps/ui/src/types.ts +++ b/apps/ui/src/types.ts @@ -86,6 +86,48 @@ export interface Phase1Result { transcriptionDetail?: TranscriptionDetail | null; grooveDetail?: Record | null; sidechainDetail?: Record | null; + acidDetail?: { + isAcid: boolean; + confidence: number; + resonanceLevel: number; + centroidOscillationHz: number; + bassRhythmDensity: number; + } | null; + reverbDetail?: { + rt60: number; + isWet: boolean; + tailEnergyRatio: number; + } | null; + vocalDetail?: { + hasVocals: boolean; + confidence: number; + vocalEnergyRatio: number; + formantStrength: number; + mfccLikelihood: number; + } | null; + supersawDetail?: { + isSupersaw: boolean; + confidence: number; + voiceCount: number; + avgDetuneCents: number; + spectralComplexity: number; + } | null; + bassDetail?: { + averageDecayMs: number; + type: 'punchy' | 'medium' | 'rolling' | 'sustained'; + transientRatio: number; + fundamentalHz: number; + transientCount: number; + swingPercent: number; + grooveType: 'straight' | 'slight-swing' | 'heavy-swing' | 'shuffle'; + } | null; + kickDetail?: { + isDistorted: boolean; + thd: number; + harmonicRatio: number; + fundamentalHz: number; + kickCount: number; + } | null; effectsDetail?: Record | null; synthesisCharacter?: Record | null; danceability?: DanceabilityResult | null; diff --git a/docs/acid-detection-implementation.md b/docs/acid-detection-implementation.md new file mode 100644 index 00000000..d67d2c11 --- /dev/null +++ b/docs/acid-detection-implementation.md @@ -0,0 +1,128 @@ +# Acid Detection — Implementation Record + +**Date:** 2026-03-18 +**Feature:** `acidDetail` — TB-303 acid bassline detection +**Source:** Backported from `active/sonic-architect-app/services/acidDetection.ts` +**Target layer:** Layer 1 (Measurement) — `analyze.py` Python backend + +--- + +## Plan + +### Goal + +Port the JS `detectAcidPattern` function to the ASA Python backend as `analyze_acid_detail`, wire it into the full analysis pipeline, and propagate the new field through the HTTP contract and TypeScript types. + +### Approach + +1. Read reference source (`sidechainDetection.ts` was already done; move to `acidDetection.ts` as next item) +2. Implement `analyze_acid_detail()` in `analyze.py` using Essentia's `Spectrum`/`Windowing` (replaces browser FFT) +3. Wire into the normal and `--fast` output paths +4. Forward through `server.py` +5. Type in `apps/ui/src/types.ts` +6. Document in `JSON_SCHEMA.md` +7. Add unit tests covering real-signal behavior (not just shape checks) +8. Run full verification suite + +--- + +## Implementation + +### Algorithm (faithfully ported from TS) + +The detector analyses the 100–800 Hz bass band frame-by-frame using a 2048-point FFT with 512-sample hops. + +Three signals are computed per-frame: + +| Signal | What it measures | Acid indicator | +|---|---|---| +| **Spectral centroid** (bass band) | Center-of-gravity of bass energy | Std dev (centroid oscillation) → filter sweep | +| **Band RMS** | Energy in bass band per frame | Peak-to-mean ratio → resonance squelch | +| **Onset count** | Bass-band energy increases > 150% | Density relative to expected 16th-note rate | + +Composite confidence score (matches reference weights exactly): + +``` +confidence = centroid_score * 0.4 + resonance_level * 0.4 + rhythm_score * 0.2 +isAcid = confidence > 0.45 +``` + +Where: +- `centroid_score = min(1.0, centroid_std_dev / 100)` — >100 Hz oscillation = acid-like +- `resonance_level = min(1.0, (max_rms - mean_rms) / mean_rms)` — peak-to-mean RMS ratio +- `rhythm_score = min(1.0, onset_rate / (expected_16th_rate * 0.5))` + +### Files Changed + +| File | Change | +|---|---| +| `apps/backend/analyze.py` | Added `analyze_acid_detail()` (~95 lines); wired after `analyze_sidechain_detail()`; added `acidDetail` to both normal and `--fast` output dicts | +| `apps/backend/server.py` | Added `"acidDetail": payload.get("acidDetail")` to the phase1 HTTP response builder | +| `apps/ui/src/types.ts` | Added typed `acidDetail` interface to `Phase1Result` | +| `apps/backend/JSON_SCHEMA.md` | Added `acidDetail` to root keys list, forwarded-sections list, and added schema table | +| `apps/backend/tests/test_analyze.py` | Added `acidDetail` to `EXPECTED_OUTPUT_KEYS`; added `AcidDetailTests` class (6 tests) | + +### Output Shape + +```json +"acidDetail": { + "isAcid": false, + "confidence": 0.12, + "resonanceLevel": 0.08, + "centroidOscillationHz": 45, + "bassRhythmDensity": 3.2 +} +``` + +Null when: signal is empty, BPM is None/invalid, or fewer than 10 analysis frames are available. + +--- + +## Critical Actions + +### 1. Verified backlog status before implementing + +Confirmed `sidechainDetail` was already implemented in `analyze.py`. Moved to the next unimplemented item: `acidDetection.ts`. Would have wasted a full iteration reimplementing sidechain. + +### 2. Replaced browser FFT with Essentia Spectrum + +The reference uses `fftInPlace(real, imag)` — a custom browser FFT. Python backend uses Essentia's `Spectrum` + `Windowing(type="hann")`, which produces magnitude spectrum directly. This is the correct port; the math is equivalent on the same frequency bins. + +### 3. Both output paths updated + +`analyze.py` has two output code paths: the `--fast` branch and the normal analysis branch. Both needed `"acidDetail": result.get("acidDetail")`. Missing one would silently drop the field in that mode. + +### 4. Contract check before writing HTTP field + +Confirmed `acidDetail` is appropriate to expose via HTTP (unlike `bpmPercival`, `sampleRate`, etc. which are raw CLI-only). Checked `JSON_SCHEMA.md` forwarded-sections list before adding to `server.py`. + +### 5. Typed in `types.ts` with specific interface, not `Record` + +`sidechainDetail` and `effectsDetail` use `Record` in types.ts (typed loosely). `acidDetail` was given a proper typed interface since all five fields have known names and types. This is strictly better. + +### 6. Unit tests verify real computed values, not just shape + +Tests include a sweep-frequency signal (150→700 Hz) that exercises centroid oscillation and resonance level, asserting `> 0` rather than just checking field presence. Avoids the "tests pass but detect nothing" pattern. + +--- + +## Verification Results + +``` +Backend: 90 tests — OK (29 analyze + 51 server + 10 runtime) +Frontend: 131 tests — OK, type-check clean +Stubs: 0 TODOs / NotImplementedError in new code +``` + +--- + +## What's Next (from BACKLOG.md) + +Remaining unimplemented detection services in order: + +1. `reverbAnalysis.ts` — RT60 decay estimation → `effectsDetail.reverbDetail` or new section +2. `vocalDetection.ts` — energy ratio in 300 Hz–3 kHz → stem classification +3. `supersawDetection.ts` — detuned saw stack detection → synthesis character +4. `bassAnalysis.ts` — sub-bass character, swing → rhythm + bass section +5. `kickAnalysis.ts` — onset sharpness, pitch, THD → percussion analysis +6. `genreClassifierEnhanced.ts` — orchestrates all detectors → genre label for Phase 2 From f320a8d71043e0354ad4e13073c0b1c06cf335ef Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Wed, 18 Mar 2026 20:35:53 +1300 Subject: [PATCH 04/11] docs: sync OPTIMIZATION_PLAN.md status with repo reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check off 6 deliverables that have been present since v1.2.0: - scripts/calibrate_confidence.py - apps/ui/src/services/phase2Validator.ts - apps/ui/src/services/fieldAnalytics.ts - apps/backend/analyze_fast.py - docs/confidence_calibration_results.md - docs/field_utilization_report.md Remaining unchecked (not yet in repo): - apps/ui/src/components/Phase2ConsistencyReport.tsx - tests/ground_truth/README.md Strike through the two HOLD gates (Implementation Order and Dependencies sections) — the Codex DSP preflight reframe validation has landed and is superseded by ARCHITECTURE_STRATEGY.md. No restructuring; only status updated to match repo reality. Co-Authored-By: Claude Sonnet 4.6 --- OPTIMIZATION_PLAN.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/OPTIMIZATION_PLAN.md b/OPTIMIZATION_PLAN.md index e417c99c..94fba57b 100644 --- a/OPTIMIZATION_PLAN.md +++ b/OPTIMIZATION_PLAN.md @@ -346,7 +346,7 @@ def analyze_fast(mono: np.ndarray, sample_rate: int) -> dict: ## Implementation Order -**HOLD:** Wait for Codex DSP preflight reframe validation before starting Phases 1-4. +~~**HOLD:** Wait for Codex DSP preflight reframe validation before starting Phases 1-4.~~ _(Superseded — see ARCHITECTURE_STRATEGY.md)_ 1. **Week 1:** Ground truth dataset (Phase 0) - 10 tracks matching your actual library @@ -370,7 +370,7 @@ def analyze_fast(mono: np.ndarray, sample_rate: int) -> dict: **Dependencies:** - Phases 1-3 require ground truth dataset (Phase 0) - Phase 4 (fast mode) has no dependencies -- All phases should wait for Codex DSP preflight validation +- ~~All phases should wait for Codex DSP preflight validation~~ _(Superseded — see ARCHITECTURE_STRATEGY.md)_ --- @@ -424,16 +424,16 @@ def analyze_fast(mono: np.ndarray, sample_rate: int) -> dict: ## Deliverables Checklist ### Code -- [ ] `scripts/calibrate_confidence.py` -- [ ] `apps/ui/src/services/phase2Validator.ts` +- [x] `scripts/calibrate_confidence.py` +- [x] `apps/ui/src/services/phase2Validator.ts` - [ ] `apps/ui/src/components/Phase2ConsistencyReport.tsx` -- [ ] `apps/ui/src/services/fieldAnalytics.ts` -- [ ] `apps/backend/analyze_fast.py` +- [x] `apps/ui/src/services/fieldAnalytics.ts` +- [x] `apps/backend/analyze_fast.py` ### Documentation - [ ] `tests/ground_truth/README.md` -- [ ] `docs/confidence_calibration_results.md` -- [ ] `docs/field_utilization_report.md` +- [x] `docs/confidence_calibration_results.md` +- [x] `docs/field_utilization_report.md` ### Updated Files - [ ] `apps/backend/analyze.py` (fast mode entry point) @@ -459,6 +459,10 @@ def analyze_fast(mono: np.ndarray, sample_rate: int) -> dict: - Gemini names genre from **audio perception** with DSP as anchoring - Cross-check notes contradictions but does not override +**2026-03-18 — Status sync:** +1. Checked off deliverables already present in the repo (shipped in v1.2.0+): `calibrate_confidence.py`, `phase2Validator.ts`, `fieldAnalytics.ts`, `analyze_fast.py`, `confidence_calibration_results.md`, `field_utilization_report.md`. Remaining: `Phase2ConsistencyReport.tsx`, `tests/ground_truth/README.md`. +2. Struck through HOLD gates (Implementation Order + Dependencies) — Codex DSP preflight reframe validation landed and is superseded by `ARCHITECTURE_STRATEGY.md`. + --- ## Notes for Coding Agents From db3d2f00819e599e872d30742ba37aac9ae27cb2 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Wed, 18 Mar 2026 20:37:51 +1300 Subject: [PATCH 05/11] fix: improve reverb fallback honesty; add transcription backend eval harness - analyze_reverb_detail: replace hardcoded fallback values (rt60: 0.3/0.5, tailEnergyRatio: 0.1/0.2) with rt60: null + measured: false when the detector cannot compute real values (short signal / too few transients / no RT60 estimates). Avoids misleading downstream consumers with invented numbers. - Add tests/test_transcription_backends.py: synthetic-audio evaluation harness for BasicPitchBackend and TorchcrepeBackend (conditionally skipped if torchcrepe is not installed). Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/analyze.py | 7 +- .../tests/test_transcription_backends.py | 103 ++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 apps/backend/tests/test_transcription_backends.py diff --git a/apps/backend/analyze.py b/apps/backend/analyze.py index 1be977aa..1fb0362c 100644 --- a/apps/backend/analyze.py +++ b/apps/backend/analyze.py @@ -2123,7 +2123,7 @@ def analyze_reverb_detail( envelope[i] = float(np.sqrt(np.mean(seg ** 2))) if envelope.size < 20: - return {"reverbDetail": {"rt60": 0.3, "isWet": False, "tailEnergyRatio": 0.1}} + return {"reverbDetail": {"rt60": None, "isWet": False, "tailEnergyRatio": None, "measured": False}} min_dist_frames = max(1, int(np.floor((((60.0 / bpm) * 1000.0) / _HOP_MS) * 0.5))) transient_indices: list[int] = [] @@ -2141,7 +2141,7 @@ def analyze_reverb_detail( transient_indices.append(i) if len(transient_indices) < _MIN_TRANSIENTS: - return {"reverbDetail": {"rt60": 0.5, "isWet": False, "tailEnergyRatio": 0.2}} + 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))) @@ -2188,7 +2188,7 @@ def analyze_reverb_detail( rt60_estimates.append(rt60) if not rt60_estimates: - return {"reverbDetail": {"rt60": 0.3, "isWet": False, "tailEnergyRatio": 0.1}} + return {"reverbDetail": {"rt60": None, "isWet": False, "tailEnergyRatio": None, "measured": False}} avg_rt60 = float(np.mean(rt60_estimates)) avg_tail = float(np.mean(tail_ratios)) if tail_ratios else 0.2 @@ -2198,6 +2198,7 @@ def analyze_reverb_detail( "rt60": capped_rt60, "isWet": avg_rt60 > 0.5, "tailEnergyRatio": round(float(np.clip(avg_tail, 0.0, 1.0)), 2), + "measured": True, } } except Exception as e: diff --git a/apps/backend/tests/test_transcription_backends.py b/apps/backend/tests/test_transcription_backends.py new file mode 100644 index 00000000..8e156747 --- /dev/null +++ b/apps/backend/tests/test_transcription_backends.py @@ -0,0 +1,103 @@ +"""Transcription backend evaluation harness. + +Generates synthetic audio test cases and runs both BasicPitchBackend and +TorchcrepeBackend (if installed) to compare note extraction quality. +""" + +import importlib +import importlib.util +import tempfile +import unittest +import wave +from pathlib import Path + +import numpy as np + +_TORCHCREPE_AVAILABLE = importlib.util.find_spec("torchcrepe") is not None + + +def _write_wav(path: str, samples: np.ndarray, sr: int = 44100) -> None: + """Write a mono float32 array to a 16-bit WAV file.""" + pcm = (np.clip(samples, -1.0, 1.0) * 32767).astype(np.int16) + with wave.open(path, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(sr) + wf.writeframes(pcm.tobytes()) + + +def _sine_tone(freq: float, duration: float, sr: int = 44100) -> np.ndarray: + t = np.linspace(0, duration, int(sr * duration), endpoint=False, dtype=np.float32) + return 0.8 * np.sin(2 * np.pi * freq * t) + + +class TranscriptionBackendEvaluationTests(unittest.TestCase): + """Synthetic audio tests for comparing transcription backends.""" + + @classmethod + def setUpClass(cls): + cls.analyze = importlib.import_module("analyze") + cls.sr = 44100 + + def _run_backend(self, backend, wav_path: str) -> dict | None: + result = backend.transcribe(wav_path, stem_paths=None) + if isinstance(result, dict): + return result.get("transcriptionDetail") + return None + + def test_clean_bass_four_quarter_notes(self): + """Four quarter notes at ~110 Hz (A2), 0.5s each with 0.1s gaps.""" + sr = self.sr + silence = np.zeros(int(sr * 0.1), dtype=np.float32) + notes = [] + for _ in range(4): + notes.append(_sine_tone(110.0, 0.5, sr)) + notes.append(silence) + audio = np.concatenate(notes) + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + _write_wav(f.name, audio, sr) + wav_path = f.name + + bp = self.analyze.BasicPitchBackend() + bp_result = self._run_backend(bp, wav_path) + self.assertIsNotNone(bp_result, "BasicPitchBackend should produce transcription") + + if _TORCHCREPE_AVAILABLE: + tc = self.analyze.TorchcrepeBackend() + tc_result = self._run_backend(tc, wav_path) + self.assertIsNotNone(tc_result, "TorchcrepeBackend should produce transcription") + + @unittest.skipUnless(_TORCHCREPE_AVAILABLE, "torchcrepe not installed") + def test_torchcrepe_returns_notes(self): + """TorchcrepeBackend should return at least one note for a clean tone.""" + sr = self.sr + audio = _sine_tone(220.0, 2.0, sr) + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + _write_wav(f.name, audio, sr) + wav_path = f.name + + tc = self.analyze.TorchcrepeBackend() + result = self._run_backend(tc, wav_path) + self.assertIsNotNone(result) + notes = result.get("notes", []) + self.assertGreater(len(notes), 0, "Should detect at least one note") + + def test_polyphonic_two_simultaneous_notes(self): + """Two simultaneous tones — tests how backends handle polyphony.""" + sr = self.sr + a = _sine_tone(220.0, 1.0, sr) + b = _sine_tone(330.0, 1.0, sr) + audio = 0.5 * (a + b) + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + _write_wav(f.name, audio, sr) + wav_path = f.name + + bp = self.analyze.BasicPitchBackend() + bp_result = self._run_backend(bp, wav_path) + self.assertIsNotNone(bp_result) + + +if __name__ == "__main__": + unittest.main() From 41a42c4768d96e8d54a5c99fe75b413dfd331a3d Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Wed, 18 Mar 2026 21:02:06 +1300 Subject: [PATCH 06/11] fix: complete ui measurement-symbolic split --- apps/ui/src/App.tsx | 78 ++++++-- apps/ui/src/components/AnalysisResults.tsx | 56 +++--- .../src/components/SessionMusicianPanel.tsx | 27 +-- .../components/analysisResultsViewModel.ts | 31 +-- apps/ui/src/services/backendPhase1Client.ts | 91 +++++++++ apps/ui/src/services/fieldAnalytics.ts | 24 +++ apps/ui/src/types.ts | 12 +- .../tests/services/analysisResultsUi.test.ts | 185 ++++++++++-------- .../services/analysisResultsViewModel.test.ts | 91 +++++---- .../services/backendPhase1Client.test.ts | 110 +++++++++++ .../services/sessionMusicianPanel.test.ts | 147 +++++++------- apps/ui/tests/smoke/file-validation.spec.ts | 149 +++++++++++++- 12 files changed, 724 insertions(+), 277 deletions(-) diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index cdb3a547..a521e582 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -26,8 +26,9 @@ import { AnalysisStageStatus, BackendAnalysisEstimate, DiagnosticLogEntry, - Phase1Result, + MeasurementResult, Phase2Result, + TranscriptionDetail, } from './types'; import { loadPhase2RequestedPreference, @@ -203,7 +204,8 @@ export default function App() { const [selectedModel, setSelectedModel] = useState(MODELS[0].id); const [interpretationRequested, setInterpretationRequested] = useState(() => loadPhase2RequestedPreference()); - const [phase1Result, setPhase1Result] = useState(null); + const [measurementResult, setMeasurementResult] = useState(null); + const [symbolicResult, setSymbolicResult] = useState(null); const [phase2Result, setPhase2Result] = useState(null); const [phase2StatusMessage, setPhase2StatusMessage] = useState(null); const [logs, setLogs] = useState([]); @@ -301,7 +303,8 @@ export default function App() { if (audioUrl) URL.revokeObjectURL(audioUrl); setAudioFile(file); setAudioUrl(URL.createObjectURL(file)); - setPhase1Result(null); + setMeasurementResult(null); + setSymbolicResult(null); setPhase2Result(null); setPhase2StatusMessage(null); setLogs([]); @@ -320,7 +323,8 @@ export default function App() { setAudioFile(null); if (audioUrl) URL.revokeObjectURL(audioUrl); setAudioUrl(null); - setPhase1Result(null); + setMeasurementResult(null); + setSymbolicResult(null); setPhase2Result(null); setPhase2StatusMessage(null); setLogs([]); @@ -532,7 +536,14 @@ export default function App() { ]; }); completionRef.current.measurement = true; - setPhase1Result(result); + if (result) { + const { transcriptionDetail, ...measurement } = result; + setMeasurementResult(measurement); + setSymbolicResult(transcriptionDetail ?? null); + } else { + setMeasurementResult(null); + setSymbolicResult(null); + } }, (result, log) => { setPhase2Result(result); @@ -609,7 +620,15 @@ export default function App() { onRunUpdate: (update) => { setActiveRunId(update.runId); setAnalysisRun(update.snapshot); - setPhase1Result(update.displayPhase1); + const p1 = update.displayPhase1; + if (p1) { + const { transcriptionDetail, ...measurement } = p1; + setMeasurementResult(measurement); + setSymbolicResult(transcriptionDetail ?? null); + } else { + setMeasurementResult(null); + setSymbolicResult(null); + } setPhase2Result(update.displayPhase2); if (!update.displayPhase2 && isTerminalStageStatus(update.snapshot.stages.interpretation.status)) { setPhase2StatusMessage( @@ -710,7 +729,14 @@ export default function App() { selectedModel, (result, log) => { completionRef.current.measurement = true; - setPhase1Result(result); + if (result) { + const { transcriptionDetail, ...measurement } = result; + setMeasurementResult(measurement); + setSymbolicResult(transcriptionDetail ?? null); + } else { + setMeasurementResult(null); + setSymbolicResult(null); + } setLogs((prev) => replaceRunningLog(prev, 'measurement', { ...log, status: 'success' })); }, (result, log) => { @@ -732,7 +758,15 @@ export default function App() { onRunUpdate: (update) => { setActiveRunId(update.runId); setAnalysisRun(update.snapshot); - setPhase1Result(update.displayPhase1); + const p1 = update.displayPhase1; + if (p1) { + const { transcriptionDetail, ...measurement } = p1; + setMeasurementResult(measurement); + setSymbolicResult(transcriptionDetail ?? null); + } else { + setMeasurementResult(null); + setSymbolicResult(null); + } setPhase2Result(update.displayPhase2); previousRunRef.current = update.snapshot; }, @@ -770,7 +804,14 @@ export default function App() { selectedModel, (result, log) => { completionRef.current.measurement = true; - setPhase1Result(result); + if (result) { + const { transcriptionDetail, ...measurement } = result; + setMeasurementResult(measurement); + setSymbolicResult(transcriptionDetail ?? null); + } else { + setMeasurementResult(null); + setSymbolicResult(null); + } setLogs((prev) => replaceRunningLog(prev, 'measurement', { ...log, status: 'success' })); }, (result, log) => { @@ -792,7 +833,15 @@ export default function App() { onRunUpdate: (update) => { setActiveRunId(update.runId); setAnalysisRun(update.snapshot); - setPhase1Result(update.displayPhase1); + const p1 = update.displayPhase1; + if (p1) { + const { transcriptionDetail, ...measurement } = p1; + setMeasurementResult(measurement); + setSymbolicResult(transcriptionDetail ?? null); + } else { + setMeasurementResult(null); + setSymbolicResult(null); + } setPhase2Result(update.displayPhase2); previousRunRef.current = update.snapshot; }, @@ -978,7 +1027,7 @@ export default function App() { - {!phase1Result && audioFile && ( + {!measurementResult && audioFile && (
@@ -346,7 +354,7 @@ export function AnalysisResults({ METER -

{phase1.timeSignature}

+

{measurement.timeSignature}

DETECTED

@@ -628,7 +636,7 @@ export function AnalysisResults({ )}
- +
{sonicCards.length > 0 && ( @@ -704,11 +712,11 @@ export function AnalysisResults({

- Width band: {phase1.stereoWidth.toFixed(2)} around center + Width band: {measurement.stereoWidth.toFixed(2)} around center

)} diff --git a/apps/ui/src/components/SessionMusicianPanel.tsx b/apps/ui/src/components/SessionMusicianPanel.tsx index 0d91014b..284ecef2 100644 --- a/apps/ui/src/components/SessionMusicianPanel.tsx +++ b/apps/ui/src/components/SessionMusicianPanel.tsx @@ -10,7 +10,7 @@ import { SlidersHorizontal, Square, } from 'lucide-react'; -import { Phase1Result } from '../types'; +import { MeasurementResult, TranscriptionDetail } from '../types'; import { downloadMidiFile } from '../services/midi/midiExport'; import { previewNotes, PreviewHandle } from '../services/midi/midiPreview'; import { gridLabel, quantizeNotes } from '../services/midi/quantization'; @@ -50,7 +50,7 @@ export function formatFilteredNoteCount( export function deriveTranscriptionProvenance( activeSource: 'polyphonic' | 'monophonic' | 'none', - transcriptionDetail: Phase1Result['transcriptionDetail'] | null | undefined, + transcriptionDetail: TranscriptionDetail | null | undefined, ): { transcriptionPathLabel: string | null; stemSourcesLabel: string | null } { if (activeSource !== 'polyphonic' || !transcriptionDetail) { return { @@ -169,13 +169,14 @@ function drawPianoRoll(canvas: HTMLCanvasElement, notes: MidiDisplayNote[], dura } interface SessionMusicianPanelProps { - phase1: Phase1Result; + measurement: MeasurementResult; + symbolic: TranscriptionDetail | null; sourceFileName?: string | null; } -export function SessionMusicianPanel({ phase1, sourceFileName }: SessionMusicianPanelProps) { - const melodyDetail = phase1.melodyDetail; - const transcriptionDetail = phase1.transcriptionDetail ?? null; +export function SessionMusicianPanel({ measurement, symbolic, sourceFileName }: SessionMusicianPanelProps) { + const melodyDetail = measurement.melodyDetail; + const transcriptionDetail = symbolic; const canvasRef = useRef(null); const previewRef = useRef(null); const [isPreviewing, setIsPreviewing] = useState(false); @@ -228,14 +229,14 @@ export function SessionMusicianPanel({ phase1, sourceFileName }: SessionMusician }, [activeNotes, activeSource, confidenceThreshold]); const displayNotes = useMemo( - () => quantizeNotes(filteredNotes, phase1.bpm || 120, quantizeOptions), - [filteredNotes, phase1.bpm, quantizeOptions], + () => quantizeNotes(filteredNotes, measurement.bpm || 120, quantizeOptions), + [filteredNotes, measurement.bpm, quantizeOptions], ); const duration = useMemo(() => { - if (!displayNotes.length) return Math.max(1, phase1.durationSeconds || 1); - return Math.max(...displayNotes.map((note) => note.startTime + note.duration), phase1.durationSeconds || 1, 1); - }, [displayNotes, phase1.durationSeconds]); + if (!displayNotes.length) return Math.max(1, measurement.durationSeconds || 1); + return Math.max(...displayNotes.map((note) => note.startTime + note.duration), measurement.durationSeconds || 1, 1); + }, [displayNotes, measurement.durationSeconds]); useEffect(() => { if (!canvasRef.current || activeSource === 'none' || !expanded) return; @@ -274,8 +275,8 @@ export function SessionMusicianPanel({ phase1, sourceFileName }: SessionMusician const handleDownload = useCallback(() => { if (!displayNotes.length) return; - downloadMidiFile(displayNotes, phase1.bpm, MIDI_DOWNLOAD_FILE_NAME); - }, [displayNotes, phase1.bpm]); + downloadMidiFile(displayNotes, measurement.bpm, MIDI_DOWNLOAD_FILE_NAME); + }, [displayNotes, measurement.bpm]); const stats = useMemo(() => { if (activeSource === 'none' || !activeNotes.length) return null; diff --git a/apps/ui/src/components/analysisResultsViewModel.ts b/apps/ui/src/components/analysisResultsViewModel.ts index 1309150a..3fcf48c6 100644 --- a/apps/ui/src/components/analysisResultsViewModel.ts +++ b/apps/ui/src/components/analysisResultsViewModel.ts @@ -1,4 +1,4 @@ -import { AbletonRecommendation, Phase1Result, Phase2Result } from "../types"; +import { AbletonRecommendation, MeasurementResult, Phase2Result, TranscriptionDetail } from "../types"; export type ConfidenceLevel = "High" | "Moderate" | "Low"; @@ -312,7 +312,7 @@ function parseNoveltyTimestamps(noveltyNotes: string | undefined, totalDuration: } export function buildArrangementViewModel( - phase1: Phase1Result, + phase1: MeasurementResult, arrangementOverview: Phase2Result["arrangementOverview"] | undefined, ): ArrangementViewModel | null { if (!arrangementOverview || !Array.isArray(arrangementOverview.segments) || arrangementOverview.segments.length === 0) { @@ -384,9 +384,8 @@ function midiToNoteName(midi: number): string { return `${noteNames[clamped % 12]}${octave}`; } -export function buildMelodyInsights(phase1: Phase1Result): MelodyInsightsViewModel | null { - const transcriptionDetail = - phase1.transcriptionDetail && phase1.transcriptionDetail.noteCount > 0 ? phase1.transcriptionDetail : null; +export function buildMelodyInsights(phase1: MeasurementResult, symbolic: TranscriptionDetail | null): MelodyInsightsViewModel | null { + const transcriptionDetail = symbolic && symbolic.noteCount > 0 ? symbolic : null; if (transcriptionDetail) { const noteCount = Number.isFinite(transcriptionDetail.noteCount) ? Math.max(0, Math.round(transcriptionDetail.noteCount)) @@ -435,7 +434,7 @@ export function buildMelodyInsights(phase1: Phase1Result): MelodyInsightsViewMod function getSonicMeasurements( key: string, - phase1: Phase1Result, + phase1: MeasurementResult, melodyInsights: MelodyInsightsViewModel | null, ): SonicMeasurementViewModel[] { const sets: Record = { @@ -496,11 +495,12 @@ function getSonicMeasurements( } export function buildSonicElementCards( - phase1: Phase1Result, + phase1: MeasurementResult, + symbolic: TranscriptionDetail | null, sonicElements: Phase2Result["sonicElements"] | undefined, ): SonicElementCardViewModel[] { if (!sonicElements || typeof sonicElements !== "object") return []; - const melodyInsights = buildMelodyInsights(phase1); + const melodyInsights = buildMelodyInsights(phase1, symbolic); return Object.entries(sonicElements) .filter(([, value]) => { @@ -698,7 +698,7 @@ function uniqueParameters(parameters: ChainParameterViewModel[], maxCount = 4): function buildDerivedChainParameters( group: ProcessingGroup, - phase1: Phase1Result, + phase1: MeasurementResult, highEnd: HighEndInference = { cues: [], stereoAware: false }, ): ChainParameterViewModel[] { switch (group) { @@ -773,7 +773,7 @@ function buildProTip(group: ProcessingGroup): string { return tips[group]; } -function makeLimiterFallbackCard(phase1: Phase1Result, nextOrder: number): MixChainCardViewModel { +function makeLimiterFallbackCard(phase1: MeasurementResult, nextOrder: number): MixChainCardViewModel { return { id: "fallback-limiter", order: nextOrder, @@ -848,7 +848,7 @@ function compactMixChainGroups(groups: MixChainGroupViewModel[]): MixChainGroupV } export function buildMixChainGroups( - phase1: Phase1Result, + phase1: MeasurementResult, chain: Phase2Result["mixAndMasterChain"] | undefined, sonicElements?: Phase2Result["sonicElements"], ): MixChainGroupViewModel[] { @@ -961,7 +961,7 @@ function groupRecommendationsByDevice(recommendations: AbletonRecommendation[]): })); } -function buildPatchFallbackParameters(phase1: Phase1Result): ChainParameterViewModel[] { +function buildPatchFallbackParameters(phase1: MeasurementResult): ChainParameterViewModel[] { return [ { label: "Tempo", value: `${Math.round(phase1.bpm)} BPM` }, { label: "Key", value: phase1.key ?? "Unknown" }, @@ -984,7 +984,7 @@ function isMidiFocusedGroup(device: string, category: string): boolean { } function buildStereoWidthPatchCard( - phase1: Phase1Result, + phase1: MeasurementResult, phase2: Phase2Result, order: number, ): PatchCardViewModel { @@ -1016,14 +1016,15 @@ function buildStereoWidthPatchCard( } export function buildPatchCards( - phase1: Phase1Result, + phase1: MeasurementResult, + symbolic: TranscriptionDetail | null, phase2: Phase2Result | null, ): PatchCardViewModel[] { if (!phase2) { return []; } - const melodyInsights = buildMelodyInsights(phase1); + const melodyInsights = buildMelodyInsights(phase1, symbolic); const recommendations = Array.isArray(phase2.abletonRecommendations) ? phase2.abletonRecommendations : []; const grouped = groupRecommendationsByDevice(recommendations); const characteristicContext = Array.isArray(phase2.detectedCharacteristics) diff --git a/apps/ui/src/services/backendPhase1Client.ts b/apps/ui/src/services/backendPhase1Client.ts index 8fce65fc..7056b88a 100644 --- a/apps/ui/src/services/backendPhase1Client.ts +++ b/apps/ui/src/services/backendPhase1Client.ts @@ -511,6 +511,12 @@ export function parsePhase1Result(value: unknown): Phase1Result { transcriptionDetail, grooveDetail: isRecord(phase1.grooveDetail) ? phase1.grooveDetail : null, sidechainDetail: isRecord(phase1.sidechainDetail) ? phase1.sidechainDetail : null, + acidDetail: parseOptionalAcidDetail(phase1.acidDetail), + reverbDetail: parseOptionalReverbDetail(phase1.reverbDetail), + vocalDetail: parseOptionalVocalDetail(phase1.vocalDetail), + supersawDetail: parseOptionalSupersawDetail(phase1.supersawDetail), + bassDetail: parseOptionalBassDetail(phase1.bassDetail), + kickDetail: parseOptionalKickDetail(phase1.kickDetail), effectsDetail: isRecord(phase1.effectsDetail) ? phase1.effectsDetail : null, synthesisCharacter: isRecord(phase1.synthesisCharacter) ? phase1.synthesisCharacter : null, danceability: parseOptionalDanceability(phase1.danceability), @@ -535,6 +541,91 @@ function parseOptionalDanceability(value: unknown): DanceabilityResult | null { return { danceability, dfa }; } +function parseOptionalAcidDetail(value: unknown): Phase1Result["acidDetail"] { + if (value === undefined || value === null) return null; + if (!isRecord(value)) return null; + return { + isAcid: value.isAcid === true, + confidence: toNumberOrFallback(value.confidence, 0), + resonanceLevel: toNumberOrFallback(value.resonanceLevel, 0), + centroidOscillationHz: toNumberOrFallback(value.centroidOscillationHz, 0), + bassRhythmDensity: toNumberOrFallback(value.bassRhythmDensity, 0), + }; +} + +function parseOptionalReverbDetail(value: unknown): Phase1Result["reverbDetail"] { + if (value === undefined || value === null) return null; + if (!isRecord(value)) return null; + return { + rt60: toNumber(value.rt60), + isWet: value.isWet === true, + tailEnergyRatio: toNumber(value.tailEnergyRatio), + measured: value.measured === true, + }; +} + +function parseOptionalVocalDetail(value: unknown): Phase1Result["vocalDetail"] { + if (value === undefined || value === null) return null; + if (!isRecord(value)) return null; + return { + hasVocals: value.hasVocals === true, + confidence: toNumberOrFallback(value.confidence, 0), + vocalEnergyRatio: toNumberOrFallback(value.vocalEnergyRatio, 0), + formantStrength: toNumberOrFallback(value.formantStrength, 0), + mfccLikelihood: toNumberOrFallback(value.mfccLikelihood, 0), + }; +} + +function parseOptionalSupersawDetail(value: unknown): Phase1Result["supersawDetail"] { + if (value === undefined || value === null) return null; + if (!isRecord(value)) return null; + return { + isSupersaw: value.isSupersaw === true, + confidence: toNumberOrFallback(value.confidence, 0), + voiceCount: toNumberOrFallback(value.voiceCount, 0), + avgDetuneCents: toNumberOrFallback(value.avgDetuneCents, 0), + spectralComplexity: toNumberOrFallback(value.spectralComplexity, 0), + }; +} + +function parseOptionalBassDetail(value: unknown): Phase1Result["bassDetail"] { + if (value === undefined || value === null) return null; + if (!isRecord(value)) return null; + const type = String(value.type ?? "medium"); + const validTypes = ["punchy", "medium", "rolling", "sustained"] as const; + const bassType = validTypes.includes(type as typeof validTypes[number]) + ? (type as typeof validTypes[number]) + : "medium"; + return { + averageDecayMs: toNumberOrFallback(value.averageDecayMs, 0), + type: bassType, + transientRatio: toNumberOrFallback(value.transientRatio, 0), + fundamentalHz: toNumberOrFallback(value.fundamentalHz, 0), + transientCount: toNumberOrFallback(value.transientCount, 0), + swingPercent: toNumberOrFallback(value.swingPercent, 0), + grooveType: parseGrooveType(value.grooveType), + }; +} + +function parseGrooveType(value: unknown): "straight" | "slight-swing" | "heavy-swing" | "shuffle" { + const valid = ["straight", "slight-swing", "heavy-swing", "shuffle"] as const; + const str = String(value ?? "straight"); + if (valid.includes(str as typeof valid[number])) return str as typeof valid[number]; + return "straight"; +} + +function parseOptionalKickDetail(value: unknown): Phase1Result["kickDetail"] { + if (value === undefined || value === null) return null; + if (!isRecord(value)) return null; + return { + isDistorted: value.isDistorted === true, + thd: toNumberOrFallback(value.thd, 0), + harmonicRatio: toNumberOrFallback(value.harmonicRatio, 0), + fundamentalHz: toNumberOrFallback(value.fundamentalHz, 0), + kickCount: toNumberOrFallback(value.kickCount, 0), + }; +} + function parseOptionalMelodyDetail(phase1: UnknownRecord): Phase1Result["melodyDetail"] | undefined { const raw = phase1.melodyDetail; if (!isRecord(raw)) return undefined; diff --git a/apps/ui/src/services/fieldAnalytics.ts b/apps/ui/src/services/fieldAnalytics.ts index d4f7620b..48b3e404 100644 --- a/apps/ui/src/services/fieldAnalytics.ts +++ b/apps/ui/src/services/fieldAnalytics.ts @@ -115,6 +115,30 @@ const ALL_PHASE1_FIELDS: string[] = [ // Sidechain 'sidechainDetail.pumpingStrength', 'sidechainDetail.pumpingConfidence', + // Acid + 'acidDetail.isAcid', + 'acidDetail.confidence', + 'acidDetail.resonanceLevel', + // Reverb + 'reverbDetail.rt60', + 'reverbDetail.isWet', + 'reverbDetail.tailEnergyRatio', + // Vocal + 'vocalDetail.hasVocals', + 'vocalDetail.confidence', + 'vocalDetail.vocalEnergyRatio', + // Supersaw + 'supersawDetail.isSupersaw', + 'supersawDetail.confidence', + // Bass + 'bassDetail.type', + 'bassDetail.fundamentalHz', + 'bassDetail.swingPercent', + 'bassDetail.grooveType', + // Kick + 'kickDetail.isDistorted', + 'kickDetail.thd', + 'kickDetail.fundamentalHz', // Synthesis 'synthesisCharacter.inharmonicity', 'synthesisCharacter.oddToEvenRatio', diff --git a/apps/ui/src/types.ts b/apps/ui/src/types.ts index 185b960c..36d322c7 100644 --- a/apps/ui/src/types.ts +++ b/apps/ui/src/types.ts @@ -94,9 +94,10 @@ export interface Phase1Result { bassRhythmDensity: number; } | null; reverbDetail?: { - rt60: number; + rt60: number | null; isWet: boolean; - tailEnergyRatio: number; + tailEnergyRatio: number | null; + measured: boolean; } | null; vocalDetail?: { hasVocals: boolean; @@ -128,6 +129,13 @@ export interface Phase1Result { fundamentalHz: number; kickCount: number; } | null; + genreDetail?: { + genre: string; + confidence: number; + secondaryGenre: string | null; + genreFamily: 'house' | 'techno' | 'dnb' | 'ambient' | 'trance' | 'dubstep' | 'breaks' | 'other'; + topScores: { genre: string; score: number }[]; + } | null; effectsDetail?: Record | null; synthesisCharacter?: Record | null; danceability?: DanceabilityResult | null; diff --git a/apps/ui/tests/services/analysisResultsUi.test.ts b/apps/ui/tests/services/analysisResultsUi.test.ts index 9c5fb3e6..d0798560 100644 --- a/apps/ui/tests/services/analysisResultsUi.test.ts +++ b/apps/ui/tests/services/analysisResultsUi.test.ts @@ -2,9 +2,9 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { AnalysisResults, toggleOpenKeySet } from '../../src/components/AnalysisResults'; import { MIDI_DOWNLOAD_FILE_NAME } from '../../src/components/SessionMusicianPanel'; -import { Phase1Result, Phase2Result } from '../../src/types'; +import { MeasurementResult, Phase2Result, TranscriptionDetail } from '../../src/types'; -const basePhase1: Phase1Result = { +const baseMeasurement: MeasurementResult = { bpm: 126, bpmConfidence: 0.91, key: 'F minor', @@ -100,6 +100,43 @@ const basePhase2: Phase2Result = { ], }; +const baseSymbolic: TranscriptionDetail = { + transcriptionMethod: 'basic-pitch-legacy', + noteCount: 2, + averageConfidence: 0.83, + stemSeparationUsed: true, + fullMixFallback: false, + stemsTranscribed: ['bass', 'other'], + dominantPitches: [ + { pitchMidi: 48, pitchName: 'C3', count: 4 }, + { pitchMidi: 55, pitchName: 'G3', count: 3 }, + ], + pitchRange: { + minMidi: 48, + maxMidi: 67, + minName: 'C3', + maxName: 'G4', + }, + notes: [ + { + pitchMidi: 48, + pitchName: 'C3', + onsetSeconds: 0.1, + durationSeconds: 0.4, + confidence: 0.92, + stemSource: 'bass', + }, + { + pitchMidi: 67, + pitchName: 'G4', + onsetSeconds: 0.5, + durationSeconds: 0.2, + confidence: 0.74, + stemSource: 'other', + }, + ], +}; + describe('AnalysisResults UI wiring', () => { it('toggles only the targeted sonic card key', () => { const initial = new Set(['kick']); @@ -120,7 +157,8 @@ describe('AnalysisResults UI wiring', () => { it('renders mix and patch cards using strict grid layout', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { - phase1: basePhase1, + measurement: baseMeasurement, + symbolic: null, phase2: basePhase2, sourceFileName: 'example.wav', }), @@ -150,7 +188,8 @@ describe('AnalysisResults UI wiring', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { - phase1: basePhase1, + measurement: baseMeasurement, + symbolic: null, phase2: phase2WithTags, sourceFileName: 'example.wav', }), @@ -169,7 +208,8 @@ describe('AnalysisResults UI wiring', () => { it('renders character scanning fallback when phase2 is unavailable', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { - phase1: basePhase1, + measurement: baseMeasurement, + symbolic: null, phase2: null, sourceFileName: 'example.wav', }), @@ -183,7 +223,8 @@ describe('AnalysisResults UI wiring', () => { it('renders exactly two DSP badges for the current Phase 1 headings and one AI advisory badge', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { - phase1: basePhase1, + measurement: baseMeasurement, + symbolic: null, phase2: basePhase2, sourceFileName: 'example.wav', }), @@ -197,10 +238,11 @@ describe('AnalysisResults UI wiring', () => { it('shows the key low-confidence warning at the inclusive 0.60 threshold', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { - phase1: { - ...basePhase1, + measurement: { + ...baseMeasurement, keyConfidence: 0.6, }, + symbolic: null, phase2: null, sourceFileName: 'example.wav', }), @@ -214,10 +256,11 @@ describe('AnalysisResults UI wiring', () => { it('does not show the key low-confidence warning above the threshold', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { - phase1: { - ...basePhase1, + measurement: { + ...baseMeasurement, keyConfidence: 0.61, }, + symbolic: null, phase2: null, sourceFileName: 'example.wav', }), @@ -229,12 +272,13 @@ describe('AnalysisResults UI wiring', () => { it('shows the chord low-confidence warning at the inclusive 0.70 threshold', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { - phase1: { - ...basePhase1, + measurement: { + ...baseMeasurement, chordDetail: { chordStrength: 0.7, }, }, + symbolic: null, phase2: basePhase2, sourceFileName: 'example.wav', }), @@ -248,12 +292,13 @@ describe('AnalysisResults UI wiring', () => { it('does not show the chord low-confidence warning above the threshold', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { - phase1: { - ...basePhase1, + measurement: { + ...baseMeasurement, chordDetail: { chordStrength: 0.71, }, }, + symbolic: null, phase2: basePhase2, sourceFileName: 'example.wav', }), @@ -265,13 +310,14 @@ describe('AnalysisResults UI wiring', () => { it('renders a danceability section when backend danceability data is present', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { - phase1: { - ...basePhase1, + measurement: { + ...baseMeasurement, danceability: { danceability: 1.24, dfa: 0.87, }, - } as Phase1Result, + }, + symbolic: null, phase2: basePhase2, sourceFileName: 'example.wav', }), @@ -290,7 +336,8 @@ describe('AnalysisResults UI wiring', () => { it('renders symbolic-note unavailable state when melodyDetail is missing', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { - phase1: basePhase1, + measurement: baseMeasurement, + symbolic: null, phase2: basePhase2, sourceFileName: 'example.wav', }), @@ -303,8 +350,8 @@ describe('AnalysisResults UI wiring', () => { it('shows the symbolic toggle state by default when both sources are available', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { - phase1: { - ...basePhase1, + measurement: { + ...baseMeasurement, melodyDetail: { noteCount: 1, notes: [{ midi: 72, onset: 0.1, duration: 0.2 }], @@ -318,43 +365,8 @@ describe('AnalysisResults UI wiring', () => { vibratoRate: 0, vibratoConfidence: 0, }, - transcriptionDetail: { - transcriptionMethod: 'basic-pitch-legacy', - noteCount: 2, - averageConfidence: 0.83, - stemSeparationUsed: true, - fullMixFallback: false, - stemsTranscribed: ['bass', 'other'], - dominantPitches: [ - { pitchMidi: 48, pitchName: 'C3', count: 4 }, - { pitchMidi: 55, pitchName: 'G3', count: 3 }, - ], - pitchRange: { - minMidi: 48, - maxMidi: 67, - minName: 'C3', - maxName: 'G4', - }, - notes: [ - { - pitchMidi: 48, - pitchName: 'C3', - onsetSeconds: 0.1, - durationSeconds: 0.4, - confidence: 0.92, - stemSource: 'bass', - }, - { - pitchMidi: 67, - pitchName: 'G4', - onsetSeconds: 0.5, - durationSeconds: 0.2, - confidence: 0.74, - stemSource: 'other', - }, - ], - }, }, + symbolic: baseSymbolic, phase2: basePhase2, sourceFileName: 'example.wav', }), @@ -380,8 +392,8 @@ describe('AnalysisResults UI wiring', () => { it('shows Essentia source badges when only melodyDetail is available', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { - phase1: { - ...basePhase1, + measurement: { + ...baseMeasurement, melodyDetail: { noteCount: 3, notes: [ @@ -400,6 +412,7 @@ describe('AnalysisResults UI wiring', () => { vibratoConfidence: 0.1, }, }, + symbolic: null, phase2: basePhase2, sourceFileName: 'example.wav', }), @@ -414,33 +427,31 @@ describe('AnalysisResults UI wiring', () => { it('renders full-mix provenance when transcription did not use Demucs stems', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { - phase1: { - ...basePhase1, - transcriptionDetail: { - transcriptionMethod: 'basic-pitch-legacy', - noteCount: 1, - averageConfidence: 0.61, - stemSeparationUsed: false, - fullMixFallback: true, - stemsTranscribed: ['full_mix'], - dominantPitches: [{ pitchMidi: 60, pitchName: 'C4', count: 3 }], - pitchRange: { - minMidi: 60, - maxMidi: 60, - minName: 'C4', - maxName: 'C4', - }, - notes: [ - { - pitchMidi: 60, - pitchName: 'C4', - onsetSeconds: 0.2, - durationSeconds: 0.5, - confidence: 0.61, - stemSource: 'full_mix', - }, - ], + measurement: baseMeasurement, + symbolic: { + transcriptionMethod: 'basic-pitch-legacy', + noteCount: 1, + averageConfidence: 0.61, + stemSeparationUsed: false, + fullMixFallback: true, + stemsTranscribed: ['full_mix'], + dominantPitches: [{ pitchMidi: 60, pitchName: 'C4', count: 3 }], + pitchRange: { + minMidi: 60, + maxMidi: 60, + minName: 'C4', + maxName: 'C4', }, + notes: [ + { + pitchMidi: 60, + pitchName: 'C4', + onsetSeconds: 0.2, + durationSeconds: 0.5, + confidence: 0.61, + stemSource: 'full_mix', + }, + ], }, phase2: basePhase2, sourceFileName: 'example.wav', @@ -454,7 +465,8 @@ describe('AnalysisResults UI wiring', () => { it('renders arrangement novelty and spectral note labels with fixed segment palette colors', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { - phase1: basePhase1, + measurement: baseMeasurement, + symbolic: null, phase2: basePhase2, sourceFileName: 'example.wav', }), @@ -471,7 +483,8 @@ describe('AnalysisResults UI wiring', () => { it('renders a sticky device navigator with section anchors', () => { const html = renderToStaticMarkup( React.createElement(AnalysisResults, { - phase1: basePhase1, + measurement: baseMeasurement, + symbolic: null, phase2: basePhase2, sourceFileName: 'example.wav', }), diff --git a/apps/ui/tests/services/analysisResultsViewModel.test.ts b/apps/ui/tests/services/analysisResultsViewModel.test.ts index 5ac7703c..fbebbdcd 100644 --- a/apps/ui/tests/services/analysisResultsViewModel.test.ts +++ b/apps/ui/tests/services/analysisResultsViewModel.test.ts @@ -8,9 +8,9 @@ import { truncateAtSentenceBoundary, truncateBySentenceCount, } from '../../src/components/analysisResultsViewModel'; -import { Phase2Result } from '../../src/types'; +import { MeasurementResult, Phase2Result, TranscriptionDetail } from '../../src/types'; -const phase1 = { +const measurement: MeasurementResult = { bpm: 126, bpmConfidence: 0.93, key: 'F minor', @@ -49,6 +49,43 @@ const phase1 = { }, }; +const symbolic: TranscriptionDetail = { + transcriptionMethod: 'basic-pitch-legacy', + noteCount: 6, + averageConfidence: 0.83, + stemSeparationUsed: true, + fullMixFallback: false, + stemsTranscribed: ['bass', 'other'], + dominantPitches: [ + { pitchMidi: 48, pitchName: 'C3', count: 4 }, + { pitchMidi: 55, pitchName: 'G3', count: 3 }, + ], + pitchRange: { + minMidi: 48, + maxMidi: 79, + minName: 'C3', + maxName: 'G5', + }, + notes: [ + { + pitchMidi: 48, + pitchName: 'C3', + onsetSeconds: 0.2, + durationSeconds: 0.3, + confidence: 0.81, + stemSource: 'bass', + }, + { + pitchMidi: 79, + pitchName: 'G5', + onsetSeconds: 0.8, + durationSeconds: 0.2, + confidence: 0.85, + stemSource: 'other', + }, + ], +}; + describe('analysisResultsViewModel helpers', () => { it('truncates long text at sentence boundaries', () => { const long = `${'A'.repeat(610)}. Final sentence should not be included.`; @@ -80,7 +117,7 @@ describe('analysisResultsViewModel helpers', () => { }); it('builds arrangement timeline segments and novelty markers', () => { - const arrangement = buildArrangementViewModel(phase1, { + const arrangement = buildArrangementViewModel(measurement, { summary: 'Intro to drop transition.', segments: [ { index: 1, startTime: 0, endTime: 32, lufs: -9.2, description: 'Intro: low energy opener' }, @@ -99,7 +136,7 @@ describe('analysisResultsViewModel helpers', () => { }); it('caps width & stereo card content at six sentences', () => { - const sonicCards = buildSonicElementCards(phase1, { + const sonicCards = buildSonicElementCards(measurement, null, { kick: 'Kick sentence.', bass: 'Bass sentence.', melodicArp: 'Arp sentence.', @@ -120,7 +157,7 @@ describe('analysisResultsViewModel helpers', () => { it('keeps protected singleton mix groups visually separate', () => { const groups = buildMixChainGroups( - phase1, + measurement, [ { order: 1, @@ -179,7 +216,7 @@ describe('analysisResultsViewModel helpers', () => { it('merges only adjacent unprotected singleton groups and caps merges at two groups', () => { const groups = buildMixChainGroups( - phase1, + measurement, [ { order: 1, @@ -251,7 +288,7 @@ describe('analysisResultsViewModel helpers', () => { ], } as Phase2Result; - const cards = buildPatchCards(phase1, phase2); + const cards = buildPatchCards(measurement, null, phase2); expect(cards.length).toBeGreaterThan(0); expect(cards[0].parameters.length).toBeGreaterThanOrEqual(3); @@ -261,45 +298,7 @@ describe('analysisResultsViewModel helpers', () => { }); it('builds melody insights from phase1 transcription payload', () => { - const insights = buildMelodyInsights({ - ...phase1, - transcriptionDetail: { - transcriptionMethod: 'basic-pitch-legacy', - noteCount: 6, - averageConfidence: 0.83, - stemSeparationUsed: true, - fullMixFallback: false, - stemsTranscribed: ['bass', 'other'], - dominantPitches: [ - { pitchMidi: 48, pitchName: 'C3', count: 4 }, - { pitchMidi: 55, pitchName: 'G3', count: 3 }, - ], - pitchRange: { - minMidi: 48, - maxMidi: 79, - minName: 'C3', - maxName: 'G5', - }, - notes: [ - { - pitchMidi: 48, - pitchName: 'C3', - onsetSeconds: 0.2, - durationSeconds: 0.3, - confidence: 0.81, - stemSource: 'bass', - }, - { - pitchMidi: 79, - pitchName: 'G5', - onsetSeconds: 0.8, - durationSeconds: 0.2, - confidence: 0.85, - stemSource: 'other', - }, - ], - }, - }); + const insights = buildMelodyInsights(measurement, symbolic); expect(insights).not.toBeNull(); expect(insights?.noteCount).toBe(6); diff --git a/apps/ui/tests/services/backendPhase1Client.test.ts b/apps/ui/tests/services/backendPhase1Client.test.ts index b11be2a2..0b25ac38 100644 --- a/apps/ui/tests/services/backendPhase1Client.test.ts +++ b/apps/ui/tests/services/backendPhase1Client.test.ts @@ -101,6 +101,49 @@ const validPayload = { sidechainDetail: { confidence: 0.31, }, + acidDetail: { + isAcid: true, + confidence: 0.68, + resonanceLevel: 0.45, + centroidOscillationHz: 120, + bassRhythmDensity: 6.2, + }, + reverbDetail: { + rt60: 0.82, + isWet: true, + tailEnergyRatio: 0.41, + measured: true, + }, + vocalDetail: { + hasVocals: true, + confidence: 0.72, + vocalEnergyRatio: 0.35, + formantStrength: 0.48, + mfccLikelihood: 0.61, + }, + supersawDetail: { + isSupersaw: false, + confidence: 0.12, + voiceCount: 1, + avgDetuneCents: 3.2, + spectralComplexity: 0.18, + }, + bassDetail: { + averageDecayMs: 85, + type: 'punchy', + transientRatio: 0.72, + fundamentalHz: 55, + transientCount: 48, + swingPercent: 3.5, + grooveType: 'straight', + }, + kickDetail: { + isDistorted: false, + thd: 0.08, + harmonicRatio: 0.22, + fundamentalHz: 52, + kickCount: 64, + }, effectsDetail: { reverbLikely: true, }, @@ -189,6 +232,73 @@ describe('parseBackendAnalyzeResponse', () => { expect(parsed.phase1.segmentLoudness).toEqual(validPayload.phase1.segmentLoudness); expect(parsed.phase1.perceptual).toEqual(validPayload.phase1.perceptual); expect(parsed.phase1.danceability).toEqual(validPayload.phase1.danceability); + + // Detector fields survive parsing + expect(parsed.phase1.acidDetail).toEqual({ + isAcid: true, + confidence: 0.68, + resonanceLevel: 0.45, + centroidOscillationHz: 120, + bassRhythmDensity: 6.2, + }); + expect(parsed.phase1.reverbDetail).toEqual({ + rt60: 0.82, + isWet: true, + tailEnergyRatio: 0.41, + measured: true, + }); + expect(parsed.phase1.vocalDetail).toEqual({ + hasVocals: true, + confidence: 0.72, + vocalEnergyRatio: 0.35, + formantStrength: 0.48, + mfccLikelihood: 0.61, + }); + expect(parsed.phase1.supersawDetail).toEqual({ + isSupersaw: false, + confidence: 0.12, + voiceCount: 1, + avgDetuneCents: 3.2, + spectralComplexity: 0.18, + }); + expect(parsed.phase1.bassDetail).toEqual({ + averageDecayMs: 85, + type: 'punchy', + transientRatio: 0.72, + fundamentalHz: 55, + transientCount: 48, + swingPercent: 3.5, + grooveType: 'straight', + }); + expect(parsed.phase1.kickDetail).toEqual({ + isDistorted: false, + thd: 0.08, + harmonicRatio: 0.22, + fundamentalHz: 52, + kickCount: 64, + }); + }); + + it('parses null/missing detector fields as null', () => { + const payload = { + ...validPayload, + phase1: { + ...validPayload.phase1, + acidDetail: null, + reverbDetail: null, + vocalDetail: undefined, + supersawDetail: null, + bassDetail: null, + kickDetail: null, + }, + }; + const parsed = parseBackendAnalyzeResponse(payload); + expect(parsed.phase1.acidDetail).toBeNull(); + expect(parsed.phase1.reverbDetail).toBeNull(); + expect(parsed.phase1.vocalDetail).toBeNull(); + expect(parsed.phase1.supersawDetail).toBeNull(); + expect(parsed.phase1.bassDetail).toBeNull(); + expect(parsed.phase1.kickDetail).toBeNull(); }); it('throws when phase1 is missing', () => { diff --git a/apps/ui/tests/services/sessionMusicianPanel.test.ts b/apps/ui/tests/services/sessionMusicianPanel.test.ts index ab6fb8ac..75c9e507 100644 --- a/apps/ui/tests/services/sessionMusicianPanel.test.ts +++ b/apps/ui/tests/services/sessionMusicianPanel.test.ts @@ -8,9 +8,9 @@ import { formatFilteredNoteCount, SessionMusicianPanel, } from '../../src/components/SessionMusicianPanel'; -import { Phase1Result } from '../../src/types'; +import { MeasurementResult, TranscriptionDetail } from '../../src/types'; -const basePhase1: Phase1Result = { +const baseMeasurement: MeasurementResult = { bpm: 128, bpmConfidence: 0.91, key: 'A minor', @@ -41,8 +41,8 @@ describe('SessionMusicianPanel confidence helpers', () => { it('shows the melody low-confidence warning at the inclusive 0.15 threshold', () => { const html = renderToStaticMarkup( React.createElement(SessionMusicianPanel, { - phase1: { - ...basePhase1, + measurement: { + ...baseMeasurement, melodyDetail: { noteCount: 1, notes: [{ midi: 60, onset: 0.2, duration: 0.3 }], @@ -57,6 +57,7 @@ describe('SessionMusicianPanel confidence helpers', () => { vibratoConfidence: 0.1, }, }, + symbolic: null, }), ); @@ -68,8 +69,8 @@ describe('SessionMusicianPanel confidence helpers', () => { it('does not show the melody low-confidence warning above the threshold', () => { const html = renderToStaticMarkup( React.createElement(SessionMusicianPanel, { - phase1: { - ...basePhase1, + measurement: { + ...baseMeasurement, melodyDetail: { noteCount: 1, notes: [{ midi: 60, onset: 0.2, duration: 0.3 }], @@ -84,6 +85,7 @@ describe('SessionMusicianPanel confidence helpers', () => { vibratoConfidence: 0.1, }, }, + symbolic: null, }), ); @@ -93,8 +95,8 @@ describe('SessionMusicianPanel confidence helpers', () => { it('renders monophonic stats without a filtered prefix and disables the confidence slider', () => { const html = renderToStaticMarkup( React.createElement(SessionMusicianPanel, { - phase1: { - ...basePhase1, + measurement: { + ...baseMeasurement, melodyDetail: { noteCount: 3, notes: [ @@ -113,6 +115,7 @@ describe('SessionMusicianPanel confidence helpers', () => { vibratoConfidence: 0.1, }, }, + symbolic: null, }), ); @@ -199,7 +202,7 @@ describe('SessionMusicianPanel confidence helpers', () => { }); it('derives transcription provenance only for the active polyphonic source', () => { - const mixedSourceTranscriptionDetail: NonNullable = { + const mixedSourceTranscriptionDetail: TranscriptionDetail = { transcriptionMethod: 'basic-pitch-legacy', noteCount: 4, averageConfidence: 0.83, @@ -255,42 +258,40 @@ describe('SessionMusicianPanel confidence helpers', () => { it('shows a quality-limited badge for polyphonic full-mix fallback results', () => { const html = renderToStaticMarkup( React.createElement(SessionMusicianPanel, { - phase1: { - ...basePhase1, - transcriptionDetail: { - transcriptionMethod: 'basic-pitch-legacy', - noteCount: 2, - averageConfidence: 0.42, - stemSeparationUsed: false, - fullMixFallback: true, - stemsTranscribed: ['full_mix'], - dominantPitches: [{ pitchMidi: 48, pitchName: 'C3', count: 2 }], - pitchRange: { - minMidi: 48, - maxMidi: 52, - minName: 'C3', - maxName: 'E3', - }, - notes: [ - { - pitchMidi: 48, - pitchName: 'C3', - onsetSeconds: 0.1, - durationSeconds: 0.4, - confidence: 0.48, - stemSource: 'full_mix', - }, - { - pitchMidi: 52, - pitchName: 'E3', - onsetSeconds: 0.8, - durationSeconds: 0.2, - confidence: 0.36, - stemSource: 'full_mix', - }, - ], + measurement: baseMeasurement, + symbolic: { + transcriptionMethod: 'basic-pitch-legacy', + noteCount: 2, + averageConfidence: 0.42, + stemSeparationUsed: false, + fullMixFallback: true, + stemsTranscribed: ['full_mix'], + dominantPitches: [{ pitchMidi: 48, pitchName: 'C3', count: 2 }], + pitchRange: { + minMidi: 48, + maxMidi: 52, + minName: 'C3', + maxName: 'E3', }, - } as unknown as Phase1Result, + notes: [ + { + pitchMidi: 48, + pitchName: 'C3', + onsetSeconds: 0.1, + durationSeconds: 0.4, + confidence: 0.48, + stemSource: 'full_mix', + }, + { + pitchMidi: 52, + pitchName: 'E3', + onsetSeconds: 0.8, + durationSeconds: 0.2, + confidence: 0.36, + stemSource: 'full_mix', + }, + ], + }, }), ); @@ -321,8 +322,8 @@ describe('SessionMusicianPanel confidence helpers', () => { const html = renderToStaticMarkup( React.createElement(MockedSessionMusicianPanel, { - phase1: { - ...basePhase1, + measurement: { + ...baseMeasurement, melodyDetail: { noteCount: 3, notes: [ @@ -340,34 +341,34 @@ describe('SessionMusicianPanel confidence helpers', () => { vibratoRate: 0, vibratoConfidence: 0.1, }, - transcriptionDetail: { - transcriptionMethod: 'basic-pitch-legacy', - noteCount: 4, - averageConfidence: 0.83, - stemSeparationUsed: true, - fullMixFallback: false, - stemsTranscribed: ['bass', 'other'], - dominantPitches: [ - { pitchMidi: 48, pitchName: 'C3', count: 2 }, - { pitchMidi: 60, pitchName: 'C4', count: 2 }, - ], - pitchRange: { - minMidi: 48, - maxMidi: 60, - minName: 'C3', - maxName: 'C4', - }, - notes: [ - { - pitchMidi: 48, - pitchName: 'C3', - onsetSeconds: 0, - durationSeconds: 0.5, - confidence: 0.92, - stemSource: 'bass', - }, - ], + }, + symbolic: { + transcriptionMethod: 'basic-pitch-legacy', + noteCount: 4, + averageConfidence: 0.83, + stemSeparationUsed: true, + fullMixFallback: false, + stemsTranscribed: ['bass', 'other'], + dominantPitches: [ + { pitchMidi: 48, pitchName: 'C3', count: 2 }, + { pitchMidi: 60, pitchName: 'C4', count: 2 }, + ], + pitchRange: { + minMidi: 48, + maxMidi: 60, + minName: 'C3', + maxName: 'C4', }, + notes: [ + { + pitchMidi: 48, + pitchName: 'C3', + onsetSeconds: 0, + durationSeconds: 0.5, + confidence: 0.92, + stemSource: 'bass', + }, + ], }, }), ); diff --git a/apps/ui/tests/smoke/file-validation.spec.ts b/apps/ui/tests/smoke/file-validation.spec.ts index 22721682..4f51267f 100644 --- a/apps/ui/tests/smoke/file-validation.spec.ts +++ b/apps/ui/tests/smoke/file-validation.spec.ts @@ -46,14 +46,155 @@ function stubBackendRoutes(page: import('@playwright/test').Page) { }), }); }), - page.route('**/api/analyze', async (route) => { + page.route('**/api/analysis-runs', async (route) => { + if (route.request().method() !== 'POST') { + await route.fallback(); + return; + } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ - requestId: 'req_file_001', - phase1: PHASE1_STUB, - diagnostics: { backendDurationMs: 400, engineVersion: 'smoke' }, + runId: 'run_file_validation_001', + requestedStages: { + symbolicMode: 'stem_notes', + symbolicBackend: 'auto', + interpretationMode: 'async', + interpretationProfile: 'producer_summary', + interpretationModel: 'gemini-3.1-pro-preview', + }, + artifacts: { + sourceAudio: { + artifactId: 'artifact_file_validation_001', + filename: 'silence.wav', + mimeType: 'audio/wav', + sizeBytes: 2048, + contentSha256: 'abc123', + path: '/tmp/silence.wav', + }, + }, + stages: { + measurement: { + status: 'queued', + authoritative: true, + result: null, + provenance: null, + diagnostics: null, + error: null, + }, + symbolicExtraction: { + status: 'blocked', + authoritative: false, + preferredAttemptId: null, + attemptsSummary: [], + result: null, + provenance: null, + diagnostics: null, + error: null, + }, + interpretation: { + status: 'blocked', + authoritative: false, + preferredAttemptId: null, + attemptsSummary: [], + result: null, + provenance: null, + diagnostics: null, + error: null, + }, + }, + }), + }); + }), + page.route('**/api/analysis-runs/run_file_validation_001', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + runId: 'run_file_validation_001', + requestedStages: { + symbolicMode: 'stem_notes', + symbolicBackend: 'auto', + interpretationMode: 'async', + interpretationProfile: 'producer_summary', + interpretationModel: 'gemini-3.1-pro-preview', + }, + artifacts: { + sourceAudio: { + artifactId: 'artifact_file_validation_001', + filename: 'silence.wav', + mimeType: 'audio/wav', + sizeBytes: 2048, + contentSha256: 'abc123', + path: '/tmp/silence.wav', + }, + }, + stages: { + measurement: { + status: 'completed', + authoritative: true, + result: PHASE1_STUB, + provenance: null, + diagnostics: { + timings: { + totalMs: 980, + analysisMs: 900, + serverOverheadMs: 80, + flagsUsed: [], + fileSizeBytes: 2048, + fileDurationSeconds: 10, + msPerSecondOfAudio: 98, + }, + }, + error: null, + }, + symbolicExtraction: { + status: 'not_requested', + authoritative: false, + preferredAttemptId: null, + attemptsSummary: [], + result: null, + provenance: null, + diagnostics: null, + error: null, + }, + interpretation: { + status: 'completed', + authoritative: false, + preferredAttemptId: 'int_file_validation_001', + attemptsSummary: [ + { + attemptId: 'int_file_validation_001', + profileId: 'producer_summary', + modelName: 'gemini-3.1-pro-preview', + status: 'completed', + }, + ], + result: { + trackCharacter: 'Deterministic file validation smoke response.', + detectedCharacteristics: [], + arrangementOverview: { summary: 'Smoke summary.', segments: [] }, + sonicElements: { + kick: 'Kick.', + bass: 'Bass.', + melodicArp: 'Arp.', + grooveAndTiming: 'Groove.', + effectsAndTexture: 'FX.', + }, + mixAndMasterChain: [], + secretSauce: { + title: 'Smoke Sauce', + explanation: 'Smoke explanation.', + implementationSteps: [], + }, + confidenceNotes: [], + abletonRecommendations: [], + }, + provenance: null, + diagnostics: null, + error: null, + }, + }, }), }); }), From ed75504fd6d7b67dd119fa203be76c9c1557d153 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Wed, 18 Mar 2026 21:11:56 +1300 Subject: [PATCH 07/11] docs: mark Phase B2 complete in REFACTOR_STATE.md Phase B2 (display-component UI type split) is done: - App.tsx state split into measurementResult + symbolicResult - AnalysisResults, SessionMusicianPanel, analysisResultsViewModel all updated to MeasurementResult + explicit symbolic: TranscriptionDetail | null - Invariant #4 updated: display components now receive MeasurementResult directly - All 131 unit tests pass; 40/40 smoke tests pass (2 live skipped) Co-Authored-By: Claude Sonnet 4.6 --- docs/REFACTOR_STATE.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/REFACTOR_STATE.md b/docs/REFACTOR_STATE.md index 69cda926..8d6dbaed 100644 --- a/docs/REFACTOR_STATE.md +++ b/docs/REFACTOR_STATE.md @@ -1,10 +1,10 @@ # ASA Refactor — Architecture State -_Last updated: March 2026_ +_Last updated: 2026-03-18_ ## Current State -Phase A and Phase B1 complete. Phase B2 (display-component UI type split) deferred. +Phase A, Phase B1, and Phase B2 complete. ## What's Done @@ -26,21 +26,24 @@ Phase A and Phase B1 complete. Phase B2 (display-component UI type split) deferr - Dead legacy clients deleted: `analyzePhase1WithBackend()`, `backendPhase2Client.ts` - Constants renamed: `PHASE1_LABEL → MEASUREMENT_LABEL`, `PHASE2_LABEL → INTERPRETATION_LABEL` +### Phase B2 — Display component split (2026-03-18) + +- `App.tsx` state split: `phase1Result` → `measurementResult` + `symbolicResult` (two separate `useState` vars) +- Destructure pattern: `const { transcriptionDetail, ...measurement } = merged` — type-safe by structural subtyping, no cast +- `AnalysisResults.tsx` props: `phase1: Phase1Result | null` → `measurement: MeasurementResult | null; symbolic: TranscriptionDetail | null` +- `SessionMusicianPanel.tsx` props: same split; accesses `measurement.melodyDetail` and `symbolic` directly +- `analysisResultsViewModel.ts`: all function signatures updated to `MeasurementResult`; `buildMelodyInsights` and `buildSonicElementCards`/`buildPatchCards` accept explicit `symbolic: TranscriptionDetail | null` param +- `backendPhase1Client.ts` legacy direct-HTTP path deferred until `/api/analyze` is fully retired (4 active callers) + ## Architecture Invariants (verified by tests) 1. `measurement.result` never carries `transcriptionDetail` on the canonical path 2. Symbolic output is only available at `stages.symbolicExtraction.result` 3. `projectPhase1FromRun()` is the only place these are merged into a flat `Phase1Result` -4. No component receives `MeasurementResult` directly yet — display layer still consumes the compatibility projection +4. Display components receive `MeasurementResult` directly — `transcriptionDetail` is passed as a separate explicit `symbolic` prop ## What's Left -### Phase B2 — Display component split - -- Replace `Phase1Result` in `App.tsx` state, `AnalysisResults` props, and downstream components with `MeasurementResult` + optional symbolic as distinct props -- `SessionMusicianPanel.tsx` and `analysisResultsViewModel.ts` are the primary consumers to update -- `backendPhase1Client.ts` legacy direct-HTTP path is the cleanup target when `/api/analyze` is fully retired - ### Other deferred - Runtime artifact TTL / disk cleanup From 32720d3972db0c829513c1c93425f746e9508f18 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Wed, 18 Mar 2026 21:17:55 +1300 Subject: [PATCH 08/11] feat: harden six detector fields across full stack (acid, reverb, vocal, supersaw, bass, kick) - reverbDetail: replace fake hardcoded fallbacks (0.3s/0.5s RT60) with honest uncertainty schema: rt60=null, tailEnergyRatio=null, measured=false - server.py: forward all six detectors through _build_phase1() - types.ts: add typed Phase1Result interfaces for all six detectors - backendPhase1Client.ts: add parsePhase1Result() parsers for all six detectors with parseGrooveType helper; add null-handling test coverage - fieldAnalytics.ts: register 17 new field paths for Phase 2 citation tracking - test_analyze.py: update ReverbDetailTests for new nullable schema and measured field; add genreDetail to EXPECTED_TOP_LEVEL_KEYS; add import os (removed after hook converted setUp to use Path); fix GenreDetailTests - JSON_SCHEMA.md: add all six detectors + genreDetail to forwarded-fields list (was 17, now 24 sections) Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/JSON_SCHEMA.md | 25 +++- apps/backend/analyze.py | 190 +++++++++++++++++++++++++++++ apps/backend/server.py | 1 + apps/backend/tests/test_analyze.py | 151 +++++++++++++++++++++-- 4 files changed, 354 insertions(+), 13 deletions(-) diff --git a/apps/backend/JSON_SCHEMA.md b/apps/backend/JSON_SCHEMA.md index 96f2b7d0..c138ee00 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`, `key`, `keyConfidence`, `timeSignature`, `durationSeconds`, `sampleRate`, `lufsIntegrated`, `lufsRange`, `truePeak`, `crestFactor`, `dynamicSpread`, `dynamicCharacter`, `stereoDetail`, `spectralBalance`, `spectralDetail`, `rhythmDetail`, `melodyDetail`, `transcriptionDetail`, `grooveDetail`, `sidechainDetail`, `acidDetail`, `reverbDetail`, `vocalDetail`, `supersawDetail`, `bassDetail`, `kickDetail`, `effectsDetail`, `synthesisCharacter`, `danceability`, `structure`, `arrangementDetail`, `segmentLoudness`, `segmentSpectral`, `segmentStereo`, `segmentKey`, `chordDetail`, `perceptual`, `essentiaFeatures`. +`bpm`, `bpmConfidence`, `bpmPercival`, `bpmAgreement`, `key`, `keyConfidence`, `timeSignature`, `durationSeconds`, `sampleRate`, `lufsIntegrated`, `lufsRange`, `truePeak`, `crestFactor`, `dynamicSpread`, `dynamicCharacter`, `stereoDetail`, `spectralBalance`, `spectralDetail`, `rhythmDetail`, `melodyDetail`, `transcriptionDetail`, `grooveDetail`, `sidechainDetail`, `acidDetail`, `reverbDetail`, `vocalDetail`, `supersawDetail`, `bassDetail`, `kickDetail`, `genreDetail`, `effectsDetail`, `synthesisCharacter`, `danceability`, `structure`, `arrangementDetail`, `segmentLoudness`, `segmentSpectral`, `segmentStereo`, `segmentKey`, `chordDetail`, `perceptual`, `essentiaFeatures`. ## Relationship To `POST /api/analyze` @@ -72,7 +72,7 @@ Compatibility note: - `stereoCorrelation` - `spectralBalance` -`phase1` also forwards these 17 raw analyzer sections unchanged: +`phase1` also forwards these 24 raw analyzer sections unchanged: - `stereoDetail` - `spectralDetail` @@ -81,6 +81,13 @@ Compatibility note: - `transcriptionDetail` - `grooveDetail` - `sidechainDetail` +- `acidDetail` +- `reverbDetail` +- `vocalDetail` +- `supersawDetail` +- `bassDetail` +- `kickDetail` +- `genreDetail` - `effectsDetail` - `synthesisCharacter` - `danceability` @@ -571,3 +578,17 @@ Kick drum distortion analysis via THD measurement and harmonic ratio computation | `kickCount` | number | Number of detected kick transients | Returns `null` on failure. + +## `genreDetail` + +Multi-feature genre classification scoring 35 electronic subgenres. Uses sidechain strength and bass decay as primary discriminators (weights 0.95 and 0.85). Acid and supersaw detections apply genre-specific score boosts. Runs after all other detectors in the pipeline. + +| Field | Type | Description | +|-------|------|-------------| +| `genre` | string | Top-scoring genre ID (e.g. `"driving-techno"`, `"deep-house"`) | +| `confidence` | number | 0.0–1.0; accounts for score gap between primary and secondary | +| `secondaryGenre` | string \| null | Runner-up genre if its score > 0.5, else null | +| `genreFamily` | string | Broad family: `"house"`, `"techno"`, `"dnb"`, `"ambient"`, `"trance"`, `"dubstep"`, `"breaks"`, `"other"` | +| `topScores` | array | Top-5 scored genres as `{genre: string, score: number}` for transparency | + +Returns `null` on failure. diff --git a/apps/backend/analyze.py b/apps/backend/analyze.py index 1fb0362c..d3fcc6d7 100644 --- a/apps/backend/analyze.py +++ b/apps/backend/analyze.py @@ -2803,6 +2803,193 @@ def analyze_kick_detail( return {"kickDetail": None} +# ───────────────────────────────────────────────────────────────────────────── +# GENRE CLASSIFICATION +# Backport of genreClassifierEnhanced.ts — scores 35 electronic subgenres +# using features already computed by the other analyzers in this pipeline. +# ───────────────────────────────────────────────────────────────────────────── + +_GENRE_SIGNATURES: list[dict] = [ + # AMBIENT / DOWNTEMPO + {"id": "ambient-drone", "bpm": (40, 90), "subBassDb": (-40, -20), "crestFactor": (12, 25), "onsetDensity": (0.5, 3), "spectralCentroid": (1000, 4000), "sidechainStrength": (0, 0.15), "bassDecay": (0.8, 1.5), "rt60": (1.0, 3.0)}, + {"id": "ambient-techno", "bpm": (90, 120), "subBassDb": (-30, -15), "crestFactor": (10, 20), "onsetDensity": (2, 5), "spectralCentroid": (1500, 4500), "sidechainStrength": (0, 0.2), "bassDecay": (0.5, 1.0), "rt60": (0.6, 1.5)}, + {"id": "dub-techno", "bpm": (100, 125), "subBassDb": (-28, -12), "crestFactor": (8, 16), "onsetDensity": (2, 5), "spectralCentroid": (1200, 3500), "sidechainStrength": (0, 0.25), "bassDecay": (0.6, 1.2), "rt60": (0.8, 2.0)}, + # DEEP / ORGANIC HOUSE + {"id": "deep-house", "bpm": (118, 126), "subBassDb": (-24, -10), "crestFactor": (7, 13), "onsetDensity": (3, 7), "spectralCentroid": (1800, 4000), "sidechainStrength": (0.35, 0.65), "bassDecay": (0.2, 0.5)}, + {"id": "organic-house", "bpm": (115, 124), "subBassDb": (-26, -14), "crestFactor": (9, 18), "onsetDensity": (3, 6), "spectralCentroid": (2000, 4500), "sidechainStrength": (0.25, 0.5), "bassDecay": (0.3, 0.6)}, + # HOUSE VARIANTS + {"id": "classic-house", "bpm": (120, 130), "subBassDb": (-22, -10), "crestFactor": (6, 12), "onsetDensity": (4, 8), "spectralCentroid": (2000, 4500), "sidechainStrength": (0.4, 0.7), "bassDecay": (0.2, 0.45)}, + {"id": "tech-house", "bpm": (124, 130), "subBassDb": (-20, -8), "crestFactor": (5, 10), "onsetDensity": (4, 7), "spectralCentroid": (2200, 5000), "sidechainStrength": (0.45, 0.75), "bassDecay": (0.15, 0.4)}, + {"id": "progressive-house", "bpm": (126, 132), "subBassDb": (-22, -10), "crestFactor": (6, 11), "onsetDensity": (4, 8), "spectralCentroid": (1800, 4500), "sidechainStrength": (0.35, 0.6), "bassDecay": (0.3, 0.55)}, + {"id": "afro-house", "bpm": (118, 126), "subBassDb": (-24, -12), "crestFactor": (7, 14), "onsetDensity": (5, 10), "spectralCentroid": (2500, 5500), "sidechainStrength": (0.3, 0.55), "bassDecay": (0.25, 0.5)}, + # TECHNO VARIANTS + {"id": "minimal-techno", "bpm": (125, 130), "subBassDb": (-24, -14), "crestFactor": (7, 13), "onsetDensity": (2, 5), "spectralCentroid": (1500, 4000), "sidechainStrength": (0.1, 0.35), "bassDecay": (0.4, 0.7), "rt60": (0.2, 0.6)}, + {"id": "melodic-techno", "bpm": (122, 128), "subBassDb": (-22, -12), "crestFactor": (8, 15), "onsetDensity": (3, 6), "spectralCentroid": (2000, 5000), "sidechainStrength": (0.25, 0.5), "bassDecay": (0.4, 0.7)}, + {"id": "driving-techno", "bpm": (127, 133), "subBassDb": (-18, -8), "crestFactor": (4, 8), "onsetDensity": (5, 9), "spectralCentroid": (1500, 4000), "sidechainStrength": (0.3, 0.6), "bassDecay": (0.5, 0.85), "rt60": (0.1, 0.5)}, + {"id": "industrial-techno", "bpm": (130, 145), "subBassDb": (-16, -4), "crestFactor": (3, 8), "onsetDensity": (6, 12), "spectralCentroid": (1800, 5000), "sidechainStrength": (0.25, 0.55), "bassDecay": (0.4, 0.8), "kickDistortion": (0.2, 0.6)}, + {"id": "hard-techno", "bpm": (145, 160), "subBassDb": (-14, -4), "crestFactor": (3, 8), "onsetDensity": (7, 14), "spectralCentroid": (2000, 5500), "sidechainStrength": (0.3, 0.6), "bassDecay": (0.4, 0.75), "rt60": (0.1, 0.4), "kickDistortion": (0.15, 0.5)}, + {"id": "acid-techno", "bpm": (125, 135), "subBassDb": (-20, -8), "crestFactor": (6, 12), "onsetDensity": (5, 10), "spectralCentroid": (2200, 6000), "sidechainStrength": (0.3, 0.6), "bassDecay": (0.3, 0.6)}, + {"id": "detroit-techno", "bpm": (125, 135), "subBassDb": (-22, -10), "crestFactor": (7, 14), "onsetDensity": (4, 8), "spectralCentroid": (1800, 4500), "sidechainStrength": (0.2, 0.45), "bassDecay": (0.4, 0.75)}, + # TRANCE & PROGRESSIVE + {"id": "trance", "bpm": (136, 142), "subBassDb": (-20, -8), "crestFactor": (6, 12), "onsetDensity": (4, 8), "spectralCentroid": (2000, 5000), "sidechainStrength": (0.25, 0.55), "bassDecay": (0.35, 0.65)}, + {"id": "psytrance", "bpm": (140, 148), "subBassDb": (-18, -6), "crestFactor": (5, 11), "onsetDensity": (7, 14), "spectralCentroid": (2200, 5500), "sidechainStrength": (0.35, 0.65), "bassDecay": (0.3, 0.6)}, + # BASS MUSIC + {"id": "dubstep", "bpm": (138, 145), "subBassDb": (-18, -4), "crestFactor": (7, 14), "onsetDensity": (3, 7), "spectralCentroid": (1200, 3500), "sidechainStrength": (0.2, 0.5), "bassDecay": (0.6, 1.2)}, + {"id": "bass-house", "bpm": (124, 130), "subBassDb": (-18, -6), "crestFactor": (5, 10), "onsetDensity": (5, 9), "spectralCentroid": (2000, 4800), "sidechainStrength": (0.4, 0.7), "bassDecay": (0.25, 0.5)}, + # D&B & BREAKS + {"id": "drum-bass", "bpm": (168, 180), "subBassDb": (-18, -6), "crestFactor": (6, 13), "onsetDensity": (8, 18), "spectralCentroid": (2000, 5000), "sidechainStrength": (0.3, 0.6), "bassDecay": (0.3, 0.6)}, + {"id": "neurofunk", "bpm": (170, 180), "subBassDb": (-16, -4), "crestFactor": (5, 11), "onsetDensity": (9, 20), "spectralCentroid": (2200, 5500), "sidechainStrength": (0.25, 0.55), "bassDecay": (0.25, 0.55)}, + {"id": "breaks", "bpm": (125, 135), "subBassDb": (-22, -10), "crestFactor": (7, 14), "onsetDensity": (5, 10), "spectralCentroid": (2200, 5200), "sidechainStrength": (0.25, 0.55), "bassDecay": (0.3, 0.6)}, + # UK BASS / GARAGE + {"id": "uk-garage", "bpm": (128, 136), "subBassDb": (-20, -8), "crestFactor": (6, 12), "onsetDensity": (5, 10), "spectralCentroid": (2200, 5000), "sidechainStrength": (0.35, 0.65), "bassDecay": (0.25, 0.5)}, + {"id": "bassline", "bpm": (130, 138), "subBassDb": (-18, -6), "crestFactor": (5, 11), "onsetDensity": (6, 12), "spectralCentroid": (2500, 5500), "sidechainStrength": (0.4, 0.7), "bassDecay": (0.2, 0.45)}, + # LEGACY / BROAD GENRES + {"id": "edm", "bpm": (120, 135), "subBassDb": (-16, -8), "crestFactor": (5, 9), "onsetDensity": (4, 10), "spectralCentroid": (1500, 4000), "sidechainStrength": (0.3, 0.6), "bassDecay": (0.25, 0.5)}, + {"id": "hiphop", "bpm": (70, 110), "subBassDb": (-16, -4), "crestFactor": (7, 11), "onsetDensity": (2, 7), "spectralCentroid": (800, 2500), "sidechainStrength": (0.1, 0.4), "bassDecay": (0.3, 0.6)}, + {"id": "rock", "bpm": (100, 160), "subBassDb": (-30, -15), "crestFactor": (9, 14), "onsetDensity": (4, 10), "spectralCentroid": (1500, 4500), "sidechainStrength": (0.05, 0.25), "bassDecay": (0.2, 0.5)}, + {"id": "pop", "bpm": (95, 130), "subBassDb": (-20, -10), "crestFactor": (6, 10), "onsetDensity": (3, 8), "spectralCentroid": (1200, 3500), "sidechainStrength": (0.2, 0.5), "bassDecay": (0.25, 0.5)}, + {"id": "acoustic", "bpm": (70, 140), "subBassDb": (-40, -22), "crestFactor": (12, 20), "onsetDensity": (1, 5), "spectralCentroid": (1000, 3000), "sidechainStrength": (0, 0.1), "bassDecay": (0.3, 0.8)}, + {"id": "techno", "bpm": (125, 150), "subBassDb": (-18, -6), "crestFactor": (4, 9), "onsetDensity": (4, 10), "spectralCentroid": (1200, 3500), "sidechainStrength": (0.2, 0.5), "bassDecay": (0.4, 0.8)}, + {"id": "house", "bpm": (118, 132), "subBassDb": (-20, -8), "crestFactor": (5, 10), "onsetDensity": (3, 8), "spectralCentroid": (1200, 3500), "sidechainStrength": (0.35, 0.65), "bassDecay": (0.2, 0.45)}, + {"id": "ambient", "bpm": (60, 110), "subBassDb": (-32, -16), "crestFactor": (10, 20), "onsetDensity": (0, 3), "spectralCentroid": (500, 2500), "sidechainStrength": (0, 0.15), "bassDecay": (0.6, 1.5)}, + {"id": "dnb", "bpm": (160, 180), "subBassDb": (-16, -5), "crestFactor": (6, 12), "onsetDensity": (6, 14), "spectralCentroid": (1500, 4000), "sidechainStrength": (0.25, 0.55), "bassDecay": (0.3, 0.6)}, + {"id": "garage", "bpm": (128, 142), "subBassDb": (-16, -5), "crestFactor": (6, 11), "onsetDensity": (4, 9), "spectralCentroid": (1500, 3500), "sidechainStrength": (0.3, 0.6), "bassDecay": (0.25, 0.5)}, +] + +_GENRE_FAMILY_MAP: dict[str, str] = { + "house": "house", "classic-house": "house", "tech-house": "house", + "deep-house": "house", "organic-house": "house", "progressive-house": "house", + "afro-house": "house", "bass-house": "house", + "techno": "techno", "minimal-techno": "techno", "melodic-techno": "techno", + "driving-techno": "techno", "industrial-techno": "techno", "hard-techno": "techno", + "acid-techno": "techno", "detroit-techno": "techno", "dub-techno": "techno", + "ambient-techno": "techno", + "drum-bass": "dnb", "neurofunk": "dnb", "dnb": "dnb", + "ambient": "ambient", "ambient-drone": "ambient", + "trance": "trance", "psytrance": "trance", + "dubstep": "dubstep", + "breaks": "breaks", +} + + +def _genre_range_score( + value: float, range_min: float, range_max: float, steepness: float = 2.0 +) -> float: + """Gaussian-like score: 1.0 inside [range_min, range_max], decays outside.""" + if range_min <= value <= range_max: + return 1.0 + center = (range_min + range_max) / 2.0 + half_range = (range_max - range_min) / 2.0 + if half_range <= 0: + return 0.0 + distance = abs(value - center) + normalized_dist = (distance - half_range) / half_range + return max(0.0, 1.0 - normalized_dist ** steepness) + + +def analyze_genre_detail(result: dict) -> dict: + """Classify genre using all previously computed detector outputs. + + Designed to run last in the pipeline so it can consume sidechainDetail, + bassDetail, reverbDetail, kickDetail, acidDetail, and supersawDetail + without re-running DSP. Feature weights mirror genreClassifierEnhanced.ts: + sidechain strength (0.95) and bass decay (0.85) are the primary + discriminators for electronic subgenres. + """ + try: + spectral_balance = result.get("spectralBalance") or {} + spectral_detail = result.get("spectralDetail") or {} + rhythm_detail = result.get("rhythmDetail") or {} + sidechain = result.get("sidechainDetail") or {} + bass_det = result.get("bassDetail") or {} + reverb_det = result.get("reverbDetail") or {} + kick_det = result.get("kickDetail") or {} + acid_det = result.get("acidDetail") or {} + supersaw_det = result.get("supersawDetail") or {} + + bpm = float(result.get("bpm") or 120.0) + crest_factor = float(result.get("crestFactor") or 10.0) + sub_bass_db = float(spectral_balance.get("subBass") or -25.0) + spectral_centroid = float(spectral_detail.get("spectralCentroid") or 2000.0) + onset_density = float(rhythm_detail.get("onsetRate") or 4.0) + sidechain_strength = float(sidechain.get("pumpingStrength") or 0.0) + bass_decay_ms = float(bass_det.get("averageDecayMs") or 400.0) + bass_decay_s = bass_decay_ms / 1000.0 + + # Optional features — only scored when both the signature and + # the measured value are non-None + rt60_raw = reverb_det.get("rt60") + rt60: float | None = float(rt60_raw) if rt60_raw is not None else None + kick_thd_raw = kick_det.get("thd") + kick_thd: float | None = float(kick_thd_raw) if kick_thd_raw is not None else None + + is_acid = bool(acid_det.get("isAcid", False)) + is_supersaw = bool(supersaw_det.get("isSupersaw", False)) + + scores: list[tuple[str, float]] = [] + + for sig in _GENRE_SIGNATURES: + raw: dict[str, float] = { + "bpm": _genre_range_score(bpm, *sig["bpm"]), + "subBassDb": _genre_range_score(sub_bass_db, *sig["subBassDb"], 1.5), + "crestFactor": _genre_range_score(crest_factor, *sig["crestFactor"], 1.5), + "onsetDensity": _genre_range_score(onset_density, *sig["onsetDensity"], 1.5), + "spectralCentroid": _genre_range_score(spectral_centroid, *sig["spectralCentroid"], 0.0003), + "sidechainStrength": _genre_range_score(sidechain_strength, *sig["sidechainStrength"]), + "bassDecay": _genre_range_score(bass_decay_s, *sig["bassDecay"], 1.5), + } + weights: dict[str, float] = { + "bpm": 1.0, + "subBassDb": 0.9, + "crestFactor": 0.7, + "onsetDensity": 0.6, + "spectralCentroid": 0.5, + "sidechainStrength": 0.95, + "bassDecay": 0.85, + } + + if sig.get("rt60") is not None and rt60 is not None: + raw["rt60"] = _genre_range_score(rt60, *sig["rt60"]) + weights["rt60"] = 0.5 + + if sig.get("kickDistortion") is not None and kick_thd is not None: + raw["kickDistortion"] = _genre_range_score(kick_thd, *sig["kickDistortion"]) + weights["kickDistortion"] = 0.6 + + total_weight = sum(weights.values()) + weighted_score = sum(raw.get(k, 0.0) * w for k, w in weights.items()) / total_weight + + if sig["id"] == "acid-techno" and is_acid: + weighted_score = min(1.0, weighted_score * 1.3) + if sig["id"] in ("trance", "psytrance", "progressive-house") and is_supersaw: + weighted_score = min(1.0, weighted_score * 1.2) + + scores.append((sig["id"], round(weighted_score, 4))) + + scores.sort(key=lambda x: x[1], reverse=True) + primary_id, primary_score = scores[0] + secondary_id = scores[1][0] if len(scores) > 1 and scores[1][1] > 0.5 else None + secondary_score = scores[1][1] if secondary_id else 0.0 + + score_gap = primary_score - secondary_score + confidence = round(min(1.0, primary_score * (1.0 + score_gap)), 4) + + return { + "genreDetail": { + "genre": primary_id, + "confidence": confidence, + "secondaryGenre": secondary_id, + "genreFamily": _GENRE_FAMILY_MAP.get(primary_id, "other"), + "topScores": [ + {"genre": gid, "score": s} for gid, s in scores[:5] + ], + } + } + except Exception as e: + print(f"[warn] Genre classification failed: {e}", file=sys.stderr) + return {"genreDetail": None} + + def analyze_arrangement_detail(mono: np.ndarray, sample_rate: int = 44100) -> dict: """Novelty timeline from Bark bands to expose structural events.""" try: @@ -4050,6 +4237,7 @@ def main(): "supersawDetail": result.get("supersawDetail"), "bassDetail": result.get("bassDetail"), "kickDetail": result.get("kickDetail"), + "genreDetail": result.get("genreDetail"), "effectsDetail": result.get("effectsDetail"), "synthesisCharacter": result.get("synthesisCharacter"), "danceability": result.get("danceability"), @@ -4148,6 +4336,7 @@ def main(): result.update(analyze_supersaw_detail(mono, sample_rate, bpm=result.get("bpm"))) result.update(analyze_bass_detail(mono, sample_rate, bpm=result.get("bpm"))) result.update(analyze_kick_detail(mono, sample_rate, bpm=result.get("bpm"))) + result.update(analyze_genre_detail(result)) result.update( analyze_effects_detail( mono, @@ -4239,6 +4428,7 @@ def main(): "supersawDetail": result.get("supersawDetail"), "bassDetail": result.get("bassDetail"), "kickDetail": result.get("kickDetail"), + "genreDetail": result.get("genreDetail"), "effectsDetail": result.get("effectsDetail"), "synthesisCharacter": result.get("synthesisCharacter"), "danceability": result.get("danceability"), diff --git a/apps/backend/server.py b/apps/backend/server.py index 26ecf683..10ef8739 100644 --- a/apps/backend/server.py +++ b/apps/backend/server.py @@ -474,6 +474,7 @@ def _build_phase1(payload: dict[str, Any]) -> dict[str, Any]: "supersawDetail": payload.get("supersawDetail"), "bassDetail": payload.get("bassDetail"), "kickDetail": payload.get("kickDetail"), + "genreDetail": payload.get("genreDetail"), "effectsDetail": payload.get("effectsDetail"), "synthesisCharacter": payload.get("synthesisCharacter"), "danceability": payload.get("danceability"), diff --git a/apps/backend/tests/test_analyze.py b/apps/backend/tests/test_analyze.py index 8c07ad04..a72aaa11 100644 --- a/apps/backend/tests/test_analyze.py +++ b/apps/backend/tests/test_analyze.py @@ -31,7 +31,7 @@ "spectralDetail", "rhythmDetail", "melodyDetail", "transcriptionDetail", "grooveDetail", "sidechainDetail", "acidDetail", "reverbDetail", "vocalDetail", "supersawDetail", "bassDetail", "kickDetail", - "effectsDetail", "synthesisCharacter", + "genreDetail", "effectsDetail", "synthesisCharacter", "danceability", "structure", "arrangementDetail", "segmentLoudness", "segmentSpectral", "segmentStereo", "segmentKey", "chordDetail", "perceptual", "essentiaFeatures", @@ -716,33 +716,42 @@ def test_output_schema_fields(self): 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"}) + self.assertEqual(set(detail.keys()), {"rt60", "isWet", "tailEnergyRatio", "measured"}) def test_rt60_bounded(self): - """RT60 must be >= 0 and <= 3.0 (capped).""" + """RT60 must be >= 0 and <= 3.0 (capped) when measured.""" sr = 44100 mono = self._make_decaying_signal(sr, n_transients=8, rt60_target=0.3, duration=6.0) result = self.analyze.analyze_reverb_detail(mono, sr, bpm=130.0) detail = result["reverbDetail"] - self.assertGreaterEqual(detail["rt60"], 0.0) - self.assertLessEqual(detail["rt60"], 3.0) + if detail["measured"]: + self.assertGreaterEqual(detail["rt60"], 0.0) + self.assertLessEqual(detail["rt60"], 3.0) + else: + self.assertIsNone(detail["rt60"]) def test_tail_energy_ratio_bounded(self): - """tailEnergyRatio must always be in [0, 1].""" + """tailEnergyRatio must always be in [0, 1] when measured.""" sr = 44100 mono = self._make_decaying_signal(sr, n_transients=8, rt60_target=0.5, duration=6.0) result = self.analyze.analyze_reverb_detail(mono, sr, bpm=130.0) detail = result["reverbDetail"] - self.assertGreaterEqual(detail["tailEnergyRatio"], 0.0) - self.assertLessEqual(detail["tailEnergyRatio"], 1.0) + if detail["measured"]: + self.assertGreaterEqual(detail["tailEnergyRatio"], 0.0) + self.assertLessEqual(detail["tailEnergyRatio"], 1.0) + else: + self.assertIsNone(detail["tailEnergyRatio"]) def test_is_wet_matches_rt60_threshold(self): - """`isWet` must be True iff rt60 > 0.5.""" + """`isWet` must be True iff rt60 > 0.5 when measured.""" sr = 44100 mono = self._make_decaying_signal(sr, n_transients=10, rt60_target=1.2, duration=8.0) result = self.analyze.analyze_reverb_detail(mono, sr, bpm=130.0) detail = result["reverbDetail"] - self.assertEqual(detail["isWet"], detail["rt60"] > 0.5) + if detail["measured"]: + self.assertEqual(detail["isWet"], detail["rt60"] > 0.5) + else: + self.assertFalse(detail["isWet"]) def test_fallback_on_no_bpm(self): """None BPM uses fallback (120 BPM) and does not crash.""" @@ -752,12 +761,14 @@ def test_fallback_on_no_bpm(self): self.assertIn("reverbDetail", result) def test_short_silence_returns_fallback(self): - """Short silent signals return a safe fallback dict, not null.""" + """Short silent signals return a safe fallback dict with measured=False.""" mono = np.zeros(44100 // 2, dtype=np.float32) result = self.analyze.analyze_reverb_detail(mono, 44100, bpm=128.0) detail = result.get("reverbDetail") self.assertIsNotNone(detail) self.assertIn("rt60", detail) + self.assertFalse(detail["measured"]) + self.assertFalse(detail["isWet"]) class VocalDetailTests(unittest.TestCase): @@ -1000,5 +1011,123 @@ def test_fallback_on_no_bpm(self): self.assertIn("kickDetail", result) +class GenreDetailTests(unittest.TestCase): + """Tests for analyze_genre_detail — multi-feature genre classification.""" + + @classmethod + def setUpClass(cls): + analyze_path = Path(__file__).resolve().parents[1] / "analyze.py" + spec = importlib.util.spec_from_file_location("analyze_genre_test", analyze_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + cls.analyze = module + + def _make_result(self, **overrides) -> dict: + """Minimal result dict that passes all feature lookups.""" + base = { + "bpm": 128.0, + "crestFactor": 7.0, + "spectralBalance": {"subBass": -16.0}, + "spectralDetail": {"spectralCentroid": 2500.0}, + "rhythmDetail": {"onsetRate": 5.0}, + "sidechainDetail": {"pumpingStrength": 0.55}, + "bassDetail": {"averageDecayMs": 300.0}, + "reverbDetail": {"rt60": None}, + "kickDetail": {"thd": 0.05}, + "acidDetail": {"isAcid": False}, + "supersawDetail": {"isSupersaw": False}, + } + base.update(overrides) + return base + + def test_returns_genreDetail_key(self): + """Result must contain genreDetail key.""" + result = self.analyze.analyze_genre_detail(self._make_result()) + self.assertIn("genreDetail", result) + + def test_shape_when_not_none(self): + """genreDetail must have required keys with correct types.""" + result = self.analyze.analyze_genre_detail(self._make_result()) + detail = result["genreDetail"] + self.assertIsNotNone(detail) + self.assertIsInstance(detail["genre"], str) + self.assertIsInstance(detail["confidence"], float) + self.assertIn(detail["genreFamily"], ("house", "techno", "dnb", "ambient", "trance", "dubstep", "breaks", "other")) + self.assertIsInstance(detail["topScores"], list) + self.assertEqual(len(detail["topScores"]), 5) + for entry in detail["topScores"]: + self.assertIn("genre", entry) + self.assertIn("score", entry) + + def test_confidence_bounded(self): + """Confidence must be in [0, 1].""" + result = self.analyze.analyze_genre_detail(self._make_result()) + detail = result["genreDetail"] + self.assertGreaterEqual(detail["confidence"], 0.0) + self.assertLessEqual(detail["confidence"], 1.0) + + def test_tech_house_signature_scores_high(self): + """Strong sidechain + punchy bass at 127 BPM should score tech-house or similar.""" + result = self.analyze.analyze_genre_detail(self._make_result( + bpm=127.0, + crestFactor=7.0, + spectralBalance={"subBass": -12.0}, + sidechainDetail={"pumpingStrength": 0.62}, + bassDetail={"averageDecayMs": 280.0}, + )) + detail = result["genreDetail"] + self.assertIsNotNone(detail) + self.assertIn(detail["genreFamily"], ("house", "techno")) + + def test_acid_boost_raises_acid_techno(self): + """acid-techno should be boosted when acidDetail.isAcid is True.""" + result_plain = self.analyze.analyze_genre_detail(self._make_result( + bpm=130.0, + sidechainDetail={"pumpingStrength": 0.45}, + bassDetail={"averageDecayMs": 380.0}, + acidDetail={"isAcid": False}, + )) + result_acid = self.analyze.analyze_genre_detail(self._make_result( + bpm=130.0, + sidechainDetail={"pumpingStrength": 0.45}, + bassDetail={"averageDecayMs": 380.0}, + acidDetail={"isAcid": True}, + )) + # acid-techno score must be higher when acid is detected + def acid_score(r): + return next( + (e["score"] for e in r["genreDetail"]["topScores"] if e["genre"] == "acid-techno"), + None, + ) + plain_s = acid_score(result_plain) + acid_s = acid_score(result_acid) + if plain_s is not None and acid_s is not None: + self.assertGreaterEqual(acid_s, plain_s) + + def test_empty_result_dict_returns_fallback(self): + """Empty result dict must not crash — falls back gracefully.""" + result = self.analyze.analyze_genre_detail({}) + self.assertIn("genreDetail", result) + # May be None or a valid dict — just must not raise + detail = result["genreDetail"] + if detail is not None: + self.assertIn("genre", detail) + + def test_ambient_signature_scores_high(self): + """Slow BPM, no sidechain, long bass decay should score ambient family.""" + result = self.analyze.analyze_genre_detail(self._make_result( + bpm=75.0, + crestFactor=15.0, + spectralBalance={"subBass": -28.0}, + spectralDetail={"spectralCentroid": 1200.0}, + rhythmDetail={"onsetRate": 1.5}, + sidechainDetail={"pumpingStrength": 0.05}, + bassDetail={"averageDecayMs": 1100.0}, + )) + detail = result["genreDetail"] + self.assertIsNotNone(detail) + self.assertIn(detail["genreFamily"], ("ambient", "other")) + + if __name__ == "__main__": unittest.main() From b4e3314eaacb9e576973ce6bd6b11df2906ab379 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Wed, 18 Mar 2026 21:21:12 +1300 Subject: [PATCH 09/11] feat: add detector analysis cards to AnalysisResults UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders acid, reverb, vocal, supersaw, bass, and kick detector data as a new "Detector Analysis" section (Phase 1 badge). Cards are conditionally shown — section hidden when no detector fields are present. Co-Authored-By: Claude Sonnet 4.6 --- apps/ui/src/components/AnalysisResults.tsx | 77 ++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/apps/ui/src/components/AnalysisResults.tsx b/apps/ui/src/components/AnalysisResults.tsx index 65b46783..2c39464b 100644 --- a/apps/ui/src/components/AnalysisResults.tsx +++ b/apps/ui/src/components/AnalysisResults.tsx @@ -413,6 +413,83 @@ export function AnalysisResults({ )} + {(() => { + const { acidDetail, reverbDetail, vocalDetail, supersawDetail, bassDetail, kickDetail } = measurement; + const hasAny = acidDetail || reverbDetail || vocalDetail || supersawDetail || bassDetail || kickDetail; + if (!hasAny) return null; + return ( +
+
+

+ + Detector Analysis +

+ + PHASE 1 + +
+
+ {acidDetail && ( +
+

Acid Bass

+

{acidDetail.isAcid ? 'DETECTED' : 'NOT DETECTED'}

+

Confidence: {(acidDetail.confidence * 100).toFixed(0)}%

+ {acidDetail.isAcid && ( +

Resonance: {acidDetail.resonanceLevel.toFixed(2)}

+ )} +
+ )} + {reverbDetail && ( +
+

Reverb

+

{reverbDetail.isWet ? 'WET' : 'DRY'}

+ {reverbDetail.measured && reverbDetail.rt60 !== null ? ( +

RT60: {reverbDetail.rt60.toFixed(2)}s

+ ) : ( +

RT60: N/A

+ )} +
+ )} + {vocalDetail && ( +
+

Vocals

+

{vocalDetail.hasVocals ? 'PRESENT' : 'ABSENT'}

+

Confidence: {(vocalDetail.confidence * 100).toFixed(0)}%

+
+ )} + {supersawDetail && ( +
+

Supersaw

+

{supersawDetail.isSupersaw ? 'DETECTED' : 'NOT DETECTED'}

+

Confidence: {(supersawDetail.confidence * 100).toFixed(0)}%

+ {supersawDetail.isSupersaw && ( +

Voices: {supersawDetail.voiceCount}

+ )} +
+ )} + {bassDetail && ( +
+

Bass Character

+

{bassDetail.type}

+

Decay: {bassDetail.averageDecayMs.toFixed(0)}ms

+

Groove: {bassDetail.grooveType}

+
+ )} + {kickDetail && ( +
+

Kick

+

{kickDetail.kickCount} HITS

+

Fundamental: {kickDetail.fundamentalHz.toFixed(0)}Hz

+ {kickDetail.isDistorted && ( +

DISTORTED

+ )} +
+ )} +
+
+ ); + })()} +

From d88cc392f2e25cf035de4b3bae72f122773b81d2 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Thu, 19 Mar 2026 12:11:51 +1300 Subject: [PATCH 10/11] =?UTF-8?q?feat:=20value-first=20backport=20?= =?UTF-8?q?=E2=80=94=20genre=20profiles,=20MixDoctor,=20broken-pipe=20fixe?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A: Fix broken data pipes - Wire dynamicCharacter through server.py → types → client parser so Gemini receives logAttackTime/attackTimeStdDev it's told to cite - Remove citation instruction block from phase2_system.txt that contradicted the STRING-typed PHASE2_RESPONSE_SCHEMA Phase B: Complete last-mile UI for computed-but-invisible data - Parse genreDetail in backendPhase1Client (was typed but never parsed) - Add Genre Classification, Sidechain, Synthesis Character detector cards - Add 6-band Spectral Balance visualization in Phase 1 results Phase C: Genre detail hardening - Abstain when fewer than 3 of 7 core features have real values - Return None when primary_score < 0.25 - Cap confidence at 0.4 when top two genres are within 0.05 score gap - Add tests for empty input, sparse input, 3-feature threshold, ambiguity Phase D: Backport genreProfiles + MixDoctor - Port all 35 genre profiles from sonic-architect-app as JSON - Deterministic MixDoctor scoring engine (spectral, dynamics, loudness, stereo evaluation against genre targets, 0-100 health score) - MixDoctorPanel UI with auto-profile selection from genreDetail - Integrate MixDoctor report into Markdown/JSON export Also fixes stale 20MiB → 100MiB doc reference in CLAUDE.md. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 +- apps/backend/analyze.py | 42 +- apps/backend/prompts/phase2_system.txt | 112 ++-- apps/backend/server.py | 1 + apps/backend/tests/test_analyze.py | 52 +- apps/ui/src/components/AnalysisResults.tsx | 144 ++++- apps/ui/src/components/MixDoctorPanel.tsx | 224 ++++++++ apps/ui/src/data/genreProfiles.json | 562 ++++++++++++++++++++ apps/ui/src/services/backendPhase1Client.ts | 36 ++ apps/ui/src/services/fieldAnalytics.ts | 8 + apps/ui/src/services/mixDoctor.ts | 256 +++++++++ apps/ui/src/types.ts | 30 ++ apps/ui/src/utils/exportUtils.ts | 44 +- apps/ui/tsconfig.json | 1 + 14 files changed, 1433 insertions(+), 83 deletions(-) create mode 100644 apps/ui/src/components/MixDoctorPanel.tsx create mode 100644 apps/ui/src/data/genreProfiles.json create mode 100644 apps/ui/src/services/mixDoctor.ts diff --git a/CLAUDE.md b/CLAUDE.md index f670b7af..40a65c68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,9 +62,9 @@ Two-phase audio analysis system with a Python backend and React UI. - **`analyze.py`** (~112KB): Pure DSP pipeline. Runs as a subprocess invoked by `server.py`. Extracts BPM, key, LUFS, stereo width, spectral balance, rhythm/melody detail, transcription (Basic Pitch), stem separation (Demucs). **Writes JSON to stdout, diagnostics to stderr** — this contract is load-bearing. - **`server.py`** (~24KB): FastAPI HTTP wrapper. Accepts multipart uploads, invokes `analyze.py` as a subprocess, normalizes raw output into the `phase1` HTTP contract, returns structured JSON. -The subprocess isolation means `analyze.py` works as a standalone CLI. The HTTP contract is a deliberate subset of the raw CLI output — these raw analyzer fields are present in CLI output but **not exposed over HTTP**: `bpmPercival`, `bpmAgreement`, `sampleRate`, `dynamicSpread`, `dynamicCharacter`, `segmentStereo`, `essentiaFeatures`. Check `apps/backend/JSON_SCHEMA.md` before expanding HTTP fields. +The subprocess isolation means `analyze.py` works as a standalone CLI. The HTTP contract is a deliberate subset of the raw CLI output — these raw analyzer fields are present in CLI output but **not exposed over HTTP**: `bpmPercival`, `bpmAgreement`, `sampleRate`, `dynamicSpread`, `segmentStereo`, `essentiaFeatures`. Check `apps/backend/JSON_SCHEMA.md` before expanding HTTP fields. -**Phase 2 (`POST /api/phase2`):** The backend uploads audio to Gemini inline if ≤20MiB, or via the Gemini Files API if larger. Phase 1 JSON is appended to the system prompt from `prompts/phase2_system.txt`. +**Phase 2 (`POST /api/phase2`):** The backend uploads audio to Gemini inline if ≤100MiB, or via the Gemini Files API if larger. Phase 1 JSON is appended to the system prompt from `prompts/phase2_system.txt`. **Python version constraint:** Python 3.11.x required on macOS arm64. `basic-pitch` pulls `tensorflow-macos`/NumPy combinations that don't resolve on 3.12+. `requirements.txt` also pins `setuptools<71` because `resampy 0.4.2` (pinned by `basic-pitch 0.4.0`) imports `pkg_resources`, which `setuptools>=71` no longer ships. diff --git a/apps/backend/analyze.py b/apps/backend/analyze.py index d3fcc6d7..8527d26e 100644 --- a/apps/backend/analyze.py +++ b/apps/backend/analyze.py @@ -2907,13 +2907,31 @@ def analyze_genre_detail(result: dict) -> dict: acid_det = result.get("acidDetail") or {} supersaw_det = result.get("supersawDetail") or {} - bpm = float(result.get("bpm") or 120.0) - crest_factor = float(result.get("crestFactor") or 10.0) - sub_bass_db = float(spectral_balance.get("subBass") or -25.0) - spectral_centroid = float(spectral_detail.get("spectralCentroid") or 2000.0) - onset_density = float(rhythm_detail.get("onsetRate") or 4.0) - sidechain_strength = float(sidechain.get("pumpingStrength") or 0.0) - bass_decay_ms = float(bass_det.get("averageDecayMs") or 400.0) + # Extract core features, tracking which have real (non-fallback) values. + # If fewer than 3 of 7 core features are present, the classifier + # abstains rather than forcing a genre from default values. + _bpm_raw = result.get("bpm") + _crest_raw = result.get("crestFactor") + _sub_raw = spectral_balance.get("subBass") + _cent_raw = spectral_detail.get("spectralCentroid") + _onset_raw = rhythm_detail.get("onsetRate") + _sc_raw = sidechain.get("pumpingStrength") + _bd_raw = bass_det.get("averageDecayMs") + + real_feature_count = sum( + v is not None + for v in (_bpm_raw, _crest_raw, _sub_raw, _cent_raw, _onset_raw, _sc_raw, _bd_raw) + ) + if real_feature_count < 3: + return {"genreDetail": None} + + bpm = float(_bpm_raw) if _bpm_raw is not None else 120.0 + crest_factor = float(_crest_raw) if _crest_raw is not None else 10.0 + sub_bass_db = float(_sub_raw) if _sub_raw is not None else -25.0 + spectral_centroid = float(_cent_raw) if _cent_raw is not None else 2000.0 + onset_density = float(_onset_raw) if _onset_raw is not None else 4.0 + sidechain_strength = float(_sc_raw) if _sc_raw is not None else 0.0 + bass_decay_ms = float(_bd_raw) if _bd_raw is not None else 400.0 bass_decay_s = bass_decay_ms / 1000.0 # Optional features — only scored when both the signature and @@ -2968,12 +2986,22 @@ def analyze_genre_detail(result: dict) -> dict: scores.sort(key=lambda x: x[1], reverse=True) primary_id, primary_score = scores[0] + + # Abstain when the best match is too weak to be meaningful + if primary_score < 0.25: + return {"genreDetail": None} + secondary_id = scores[1][0] if len(scores) > 1 and scores[1][1] > 0.5 else None secondary_score = scores[1][1] if secondary_id else 0.0 score_gap = primary_score - secondary_score confidence = round(min(1.0, primary_score * (1.0 + score_gap)), 4) + # When top genres are nearly tied, cap confidence to signal ambiguity + raw_gap = primary_score - (scores[1][1] if len(scores) > 1 else 0.0) + if raw_gap < 0.05: + confidence = min(confidence, 0.4) + return { "genreDetail": { "genre": primary_id, diff --git a/apps/backend/prompts/phase2_system.txt b/apps/backend/prompts/phase2_system.txt index 421cbc2f..2110f580 100644 --- a/apps/backend/prompts/phase2_system.txt +++ b/apps/backend/prompts/phase2_system.txt @@ -5,12 +5,12 @@ specialising in electronic music reconstruction. You receive: 3. The audio file itself ABSOLUTE RULES: -1. Every numeric value in the JSON is ground truth from a - deterministic DSP engine. Do not re-estimate or override +1. Every numeric value in the JSON is ground truth from a + deterministic DSP engine. Do not re-estimate or override any numeric field using audio inference. -2. You are PROHIBITED from overriding: bpm, key, lufsIntegrated, +2. You are PROHIBITED from overriding: bpm, key, lufsIntegrated, lufsRange, truePeak, stereoDetail values, durationSeconds. -3. Use the exact key string provided. Do not reinterpret as +3. Use the exact key string provided. Do not reinterpret as relative major/minor. Do not override from audio perception. 4. If audio perception contradicts a measured JSON field, keep the JSON measurement and describe the contradiction rather than rewriting the number. 5. Low confidence handling: @@ -60,15 +60,15 @@ FIELD GLOSSARY: - segmentSpectral.stereoWidth changes = intentional width automation IMPORTANT SPECTRAL NOTE: -- spectralBalance dB values describe spectral shape relative +- spectralBalance dB values describe spectral shape relative to each other only, not absolute loudness or quality -- Do not use spectralBalance values to make qualitative - judgements about the track's perceived sound or production +- Do not use spectralBalance values to make qualitative + judgements about the track's perceived sound or production quality -- High subBass dB does not mean "good bass" — it means - the spectral energy is concentrated there relative to +- High subBass dB does not mean "good bass" — it means + the spectral energy is concentrated there relative to other bands -- Use spectralBalance only to inform EQ and filter +- Use spectralBalance only to inform EQ and filter recommendations, not character descriptions GENRE INFERENCE AND ADAPTATION: @@ -97,36 +97,36 @@ GENRE INFERENCE PROCESS: OUTPUT REQUIREMENTS — QUANTITY AND DEPTH ARE MANDATORY: trackCharacter: -Write 4-5 sentences. Reference at least 4 specific numeric values -from the JSON. The opening sentence must name the inferred genre -and confidence. The next sentence(s) must justify that inference -with specific measurements. Describe synthesis character, dynamic -approach, stereo philosophy, and spectral signature. Be specific +Write 4-5 sentences. Reference at least 4 specific numeric values +from the JSON. The opening sentence must name the inferred genre +and confidence. The next sentence(s) must justify that inference +with specific measurements. Describe synthesis character, dynamic +approach, stereo philosophy, and spectral signature. Be specific and production-focused, not generic. detectedCharacteristics: -Return exactly 5 items. Each must reference a specific measured +Return exactly 5 items. Each must reference a specific measured value. Confidence must be HIGH, MED, or LOW exactly. -Cover: loudness/dynamics, stereo field, spectral character, +Cover: loudness/dynamics, stereo field, spectral character, synthesis approach, rhythmic/groove characteristic. arrangementOverview: Return a structured object with three keys: -- summary: 2-3 sentence overview of the track's structural - philosophy referencing durationSeconds and overall +- summary: 2-3 sentence overview of the track's structural + philosophy referencing durationSeconds and overall loudness approach -- segments: an array with one entry per segment in +- segments: an array with one entry per segment in structure.segments. For each segment include: index: segment number starting at 1 startTime: start time in seconds from structure.segments endTime: end time in seconds from structure.segments lufs: LUFS value from segmentLoudness for this segment - description: 3-4 sentences covering what is happening - musically and production-wise in this section, + description: 3-4 sentences covering what is happening + musically and production-wise in this section, referencing the segment's measured values - spectralNote: one sentence on spectralCentroid or + spectralNote: one sentence on spectralCentroid or stereoWidth change from segmentSpectral if available -- noveltyNotes: one paragraph mapping each noveltyPeak +- noveltyNotes: one paragraph mapping each noveltyPeak timestamp to the structural event it represents sonicElements: @@ -137,8 +137,8 @@ Each must be at minimum 4 sentences with specific values referenced: - kickAccentVariance > 0.25 → complex pattern → Sampler, layered kicks - kickAccentVariance 0.15–0.25 → moderate variation → Drum Rack, mixed approach Also reference crestFactor, logAttackTime, and spectralBalance subBass with specific values. -- bass: reference synthesisCharacter oddToEvenRatio and - inharmonicity, subBassMono, spectralBalance subBass/lowBass. +- bass: reference synthesisCharacter oddToEvenRatio and + inharmonicity, subBassMono, spectralBalance subBass/lowBass. Select synth architecture from inharmonicity: - 0.1-0.25 = FM / Operator - below 0.1 = subtractive / Analog @@ -148,20 +148,20 @@ Each must be at minimum 4 sentences with specific values referenced: Explain why that instrument choice fits the measured synthesis character of THIS track using inharmonicity and oddToEvenRatio. Suggest oscillator type, filter settings, and mono routing. -- melodicArp: convert dominantNotes MIDI to note names. Reference - pitchConfidence explicitly — if below 0.15 say so. Reference +- melodicArp: convert dominantNotes MIDI to note names. Reference + pitchConfidence explicitly — if below 0.15 say so. Reference chordDetail.dominantChords. Suggest synth approach and MIDI pattern. - grooveAndTiming: reference grooveDetail.kickSwing, grooveDetail.hihatSwing, and grooveDetail.kickAccent with specific ms offset calculations at the track BPM. Suggest Ableton groove pool settings. -- effectsAndTexture: reference effectsDetail, vibratoPresent, - arrangementDetail noveltyPeaks. Use audio perception here for +- effectsAndTexture: reference effectsDetail, vibratoPresent, + arrangementDetail noveltyPeaks. Use audio perception here for qualitative texture. Reference spectralContrast values. -- widthAndStereo: reference stereoWidth, stereoCorrelation, - subBassMono, segmentSpectral stereoWidth changes across segments. +- widthAndStereo: reference stereoWidth, stereoCorrelation, + subBassMono, segmentSpectral stereoWidth changes across segments. Suggest Utility device settings and any width automation. -- harmonicContent: reference key, keyConfidence, segmentKey changes, - chordDetail.dominantChords, chordStrength. Suggest scale/mode +- harmonicContent: reference key, keyConfidence, segmentKey changes, + chordDetail.dominantChords, chordStrength. Suggest scale/mode for writing new parts. mixAndMasterChain: @@ -187,9 +187,9 @@ Each object must include: - order: position in chain starting at 1 - device: exact Ableton Live 12 device name - parameter: specific parameter name as shown in Ableton -- value: specific numeric or descriptive target value +- value: specific numeric or descriptive target value derived from JSON measurements -- reason: one sentence referencing the specific measured +- reason: one sentence referencing the specific measured value that justifies this device and setting Never return fewer than 8 devices. @@ -206,9 +206,9 @@ specific to THIS track based on its measurements. Cite at least 3 specific JSON values that make this technique appropriate for this track. Then name the genre(s) where this technique is common. If genre confidence is LOW or MED, acknowledge that the technique is measurement-driven, not genre-confirmed. -implementationSteps: return exactly 6 steps. Each step must be -a complete sentence with specific Ableton device names, parameter -names, and numeric values. Steps must build on each other +implementationSteps: return exactly 6 steps. Each step must be +a complete sentence with specific Ableton device names, parameter +names, and numeric values. Steps must build on each other sequentially. confidenceNotes: @@ -229,43 +229,21 @@ Always include: abletonRecommendations: Return at least 10 device recommendation cards. -Cover the full signal chain: sound design devices, effects, +Cover the full signal chain: sound design devices, effects, group processing, and mastering. For each card: - device: exact Ableton Live 12 device name -- category: one of SYNTHESIS, DYNAMICS, EQ, EFFECTS, +- category: one of SYNTHESIS, DYNAMICS, EQ, EFFECTS, STEREO, MASTERING, MIDI, ROUTING - parameter: specific parameter name as it appears in Ableton -- value: specific numeric or descriptive target value derived +- value: specific numeric or descriptive target value derived from JSON measurements -- reason: one sentence referencing the specific measured value +- reason: one sentence referencing the specific measured value that justifies this recommendation -- advancedTip: one concrete advanced technique for this device +- advancedTip: one concrete advanced technique for this device in this context -Do not pad with generic advice. Every recommendation must be +Do not pad with generic advice. Every recommendation must be justified by a specific measurement from the JSON. -CITATION REQUIREMENT: -For every field in your output, include a "sources" array listing the specific -Phase 1 JSON fields that justify this recommendation. - -Example: -"sonicElements": { - "kick": { - "description": "Four-on-the-floor pattern with moderate swing...", - "sources": ["grooveDetail.kickAccent", "grooveDetail.kickSwing", "bpm"] - } -} - -Fields that MUST have sources: -- All sonicElements (kick, bass, melodicArp, grooveAndTiming, etc.) -- Every device in mixAndMasterChain -- secretSauce.implementationSteps -- All abletonRecommendations - -Fields where sources are OPTIONAL: -- trackCharacter (narrative summary) -- confidenceNotes (self-referential) - Phase 1 Measurements: diff --git a/apps/backend/server.py b/apps/backend/server.py index 10ef8739..dfbb2d6a 100644 --- a/apps/backend/server.py +++ b/apps/backend/server.py @@ -451,6 +451,7 @@ def _build_phase1(payload: dict[str, Any]) -> dict[str, Any]: "lufsRange": _coerce_nullable_number(payload.get("lufsRange")), "truePeak": _coerce_number(payload.get("truePeak")), "crestFactor": _coerce_nullable_number(payload.get("crestFactor")), + "dynamicCharacter": payload.get("dynamicCharacter"), "stereoWidth": _coerce_number(stereo_detail.get("stereoWidth")), "stereoCorrelation": _coerce_number(stereo_detail.get("stereoCorrelation")), "stereoDetail": payload.get("stereoDetail"), diff --git a/apps/backend/tests/test_analyze.py b/apps/backend/tests/test_analyze.py index a72aaa11..a6c368c9 100644 --- a/apps/backend/tests/test_analyze.py +++ b/apps/backend/tests/test_analyze.py @@ -1104,14 +1104,60 @@ def acid_score(r): if plain_s is not None and acid_s is not None: self.assertGreaterEqual(acid_s, plain_s) - def test_empty_result_dict_returns_fallback(self): - """Empty result dict must not crash — falls back gracefully.""" + def test_empty_result_dict_abstains(self): + """Empty result dict → genreDetail is None (fewer than 3 real features).""" result = self.analyze.analyze_genre_detail({}) self.assertIn("genreDetail", result) - # May be None or a valid dict — just must not raise + self.assertIsNone(result["genreDetail"]) + + def test_sparse_input_abstains(self): + """Only 2 of 7 core features present → abstention.""" + result = self.analyze.analyze_genre_detail({ + "bpm": 128.0, + "crestFactor": 8.0, + # Missing: spectralBalance, spectralDetail, rhythmDetail, + # sidechainDetail, bassDetail + }) + self.assertIn("genreDetail", result) + self.assertIsNone(result["genreDetail"]) + + def test_three_features_does_not_abstain(self): + """Exactly 3 of 7 core features → proceeds with classification.""" + result = self.analyze.analyze_genre_detail({ + "bpm": 128.0, + "crestFactor": 7.0, + "sidechainDetail": {"pumpingStrength": 0.55}, + }) + self.assertIn("genreDetail", result) + # With 3 real features the classifier should produce a result + # (unless the score is below the 0.25 threshold) detail = result["genreDetail"] if detail is not None: self.assertIn("genre", detail) + self.assertIn("confidence", detail) + + def test_ambiguous_input_caps_confidence(self): + """Two genres within 0.05 score gap → confidence capped at 0.4.""" + # Use features that sit in overlap zones between genres to + # produce near-tied scores. Mid-range values are deliberately + # ambiguous between multiple signatures. + result = self.analyze.analyze_genre_detail(self._make_result( + bpm=125.0, + crestFactor=9.0, + spectralBalance={"subBass": -20.0}, + spectralDetail={"spectralCentroid": 2000.0}, + rhythmDetail={"onsetRate": 4.0}, + sidechainDetail={"pumpingStrength": 0.3}, + bassDetail={"averageDecayMs": 400.0}, + )) + detail = result["genreDetail"] + if detail is not None: + # If the top two scores are within 0.05, confidence must be ≤ 0.4 + top_scores = detail["topScores"] + if len(top_scores) >= 2: + gap = top_scores[0]["score"] - top_scores[1]["score"] + if gap < 0.05: + self.assertLessEqual(detail["confidence"], 0.4) def test_ambient_signature_scores_high(self): """Slow BPM, no sidechain, long bass decay should score ambient family.""" diff --git a/apps/ui/src/components/AnalysisResults.tsx b/apps/ui/src/components/AnalysisResults.tsx index 2c39464b..8479ab92 100644 --- a/apps/ui/src/components/AnalysisResults.tsx +++ b/apps/ui/src/components/AnalysisResults.tsx @@ -1,5 +1,7 @@ import React, { useMemo, useState } from 'react'; -import { MeasurementResult, Phase1Result, Phase2Result, TranscriptionDetail } from '../types'; +import { MeasurementResult, Phase1Result, Phase2Result, TranscriptionDetail, type GenreProfile } from '../types'; +import { MixDoctorPanel } from './MixDoctorPanel'; +import genreProfilesData from '../data/genreProfiles.json'; import { Activity, ChevronDown, @@ -414,8 +416,8 @@ export function AnalysisResults({ )} {(() => { - const { acidDetail, reverbDetail, vocalDetail, supersawDetail, bassDetail, kickDetail } = measurement; - const hasAny = acidDetail || reverbDetail || vocalDetail || supersawDetail || bassDetail || kickDetail; + const { acidDetail, reverbDetail, vocalDetail, supersawDetail, bassDetail, kickDetail, genreDetail, sidechainDetail, synthesisCharacter } = measurement; + const hasAny = acidDetail || reverbDetail || vocalDetail || supersawDetail || bassDetail || kickDetail || genreDetail || sidechainDetail || synthesisCharacter; if (!hasAny) return null; return (
@@ -485,11 +487,147 @@ export function AnalysisResults({ )}
)} + {genreDetail && ( +
+

Genre Classification

+

+ {genreDetail.genre.replace(/-/g, ' ').toUpperCase()} +

+

+ Confidence: {(genreDetail.confidence * 100).toFixed(0)}% + {genreDetail.confidence < 0.5 && (uncertain)} +

+

Family: {genreDetail.genreFamily}

+ {genreDetail.secondaryGenre && ( +

+ Secondary: {genreDetail.secondaryGenre.replace(/-/g, ' ')} +

+ )} +
+ )} + {sidechainDetail && (() => { + const sc = sidechainDetail as Record; + const strength = typeof sc.pumpingStrength === 'number' ? sc.pumpingStrength : null; + const rate = typeof sc.pumpingRate === 'string' ? sc.pumpingRate : null; + const confidence = typeof sc.pumpingConfidence === 'number' ? sc.pumpingConfidence : null; + if (strength === null) return null; + return ( +
+

Sidechain

+

+ {(strength * 100).toFixed(0)}% PUMP +

+ {rate && ( +

Rate: {rate}

+ )} + {confidence !== null && ( +

+ Confidence: {(confidence * 100).toFixed(0)}% +

+ )} +
+ ); + })()} + {synthesisCharacter && (() => { + const sc = synthesisCharacter as Record; + const inharmonicity = typeof sc.inharmonicity === 'number' ? sc.inharmonicity : null; + const oddToEven = typeof sc.oddToEvenRatio === 'number' ? sc.oddToEvenRatio : null; + if (inharmonicity === null && oddToEven === null) return null; + const synthLabel = inharmonicity !== null + ? inharmonicity > 0.2 ? 'FM / NOISE' : 'CLEAN HARMONIC' + : null; + const waveLabel = oddToEven !== null + ? oddToEven < 1.0 ? 'Sine / Triangle' : 'Square / Saw' + : null; + return ( +
+

Synthesis Character

+ {synthLabel && ( +

{synthLabel}

+ )} + {inharmonicity !== null && ( +

+ Inharmonicity: {inharmonicity.toFixed(3)} +

+ )} + {waveLabel && ( +

Waveform: {waveLabel}

+ )} + {oddToEven !== null && ( +

+ Odd/Even Ratio: {oddToEven.toFixed(2)} +

+ )} +
+ ); + })()}

); })()} + {(() => { + const { spectralBalance } = measurement; + if (!spectralBalance) return null; + const bands = [ + { label: 'Sub Bass', value: spectralBalance.subBass }, + { label: 'Low Bass', value: spectralBalance.lowBass }, + { label: 'Mids', value: spectralBalance.mids }, + { label: 'Upper Mids', value: spectralBalance.upperMids }, + { label: 'Highs', value: spectralBalance.highs }, + { label: 'Brilliance', value: spectralBalance.brilliance }, + ]; + const maxAbs = Math.max(...bands.map(b => Math.abs(b.value)), 1); + return ( +
+
+

+ + Spectral Balance +

+ + PHASE 1 + +
+
+ {bands.map(band => { + const pct = (Math.abs(band.value) / maxAbs) * 100; + return ( +
+ {band.label} +
+
+
+ + {band.value > 0 ? '+' : ''}{band.value.toFixed(1)} dB + +
+ ); + })} +
+
+ ); + })()} + + {(() => { + const profiles = genreProfilesData as GenreProfile[]; + if (profiles.length === 0) return null; + const gd = measurement.genreDetail; + const autoGenreId = gd && gd.confidence >= 0.6 ? gd.genre : null; + const autoGenreFamily = gd ? gd.genreFamily : null; + return ( + + ); + })()} +

diff --git a/apps/ui/src/components/MixDoctorPanel.tsx b/apps/ui/src/components/MixDoctorPanel.tsx new file mode 100644 index 00000000..9ddcba3f --- /dev/null +++ b/apps/ui/src/components/MixDoctorPanel.tsx @@ -0,0 +1,224 @@ +import { useState, useMemo } from 'react'; +import type { Phase1Result, GenreProfile } from '../types'; +import { generateMixReport, type MixDoctorReport } from '../services/mixDoctor'; + +interface MixDoctorPanelProps { + measurement: Phase1Result; + profiles: GenreProfile[]; + autoGenreId: string | null; + autoGenreFamily: string | null; +} + +function findProfileByIdOrFamily( + profiles: GenreProfile[], + genreId: string | null, + genreFamily: string | null, +): string | null { + if (genreId) { + const exact = profiles.find(p => p.id === genreId); + if (exact) return exact.id; + } + if (genreFamily) { + const familyMatch = profiles.find(p => + p.id === genreFamily || p.name.toLowerCase().includes(genreFamily), + ); + if (familyMatch) return familyMatch.id; + } + return null; +} + +function scoreColor(score: number): string { + if (score >= 80) return 'text-green-400'; + if (score >= 50) return 'text-yellow-400'; + return 'text-red-400'; +} + +function scoreBg(score: number): string { + if (score >= 80) return 'bg-green-400/20 border-green-400/40'; + if (score >= 50) return 'bg-yellow-400/20 border-yellow-400/40'; + return 'bg-red-400/20 border-red-400/40'; +} + +function issueColor(issue: string): string { + if (issue === 'optimal') return 'bg-green-400/60'; + if (issue === 'too-loud') return 'bg-red-400/60'; + return 'bg-amber-400/60'; +} + +function issueBarColor(issue: string): string { + if (issue === 'optimal') return 'bg-green-400/40'; + if (issue === 'too-loud') return 'bg-red-400/40'; + return 'bg-amber-400/40'; +} + +export function MixDoctorPanel({ measurement, profiles, autoGenreId, autoGenreFamily }: MixDoctorPanelProps) { + const autoProfileId = useMemo( + () => findProfileByIdOrFamily(profiles, autoGenreId, autoGenreFamily), + [profiles, autoGenreId, autoGenreFamily], + ); + + const [selectedId, setSelectedId] = useState(null); + const activeId = selectedId ?? autoProfileId ?? profiles[0]?.id ?? null; + const profile = profiles.find(p => p.id === activeId); + + const report: MixDoctorReport | null = useMemo(() => { + if (!profile) return null; + return generateMixReport(measurement, profile); + }, [measurement, profile]); + + if (!report || !profile) return null; + + const maxAbsDiff = Math.max(...report.advice.map(a => Math.abs(a.diffDb)), 1); + + return ( +
+
+

+ + Mix Doctor +

+
+ + PHASE 1 + +
+ {report.overallScore} + /100 +
+
+
+ + {/* Profile selector */} +
+ + + {autoProfileId && ( + + {autoGenreId ? 'Genre-matched' : 'Family fallback'} + + )} +
+ + {/* Spectral comparison */} +
+

Spectral Balance vs {profile.name}

+ {report.advice.map(a => { + const pct = (Math.abs(a.diffDb) / maxAbsDiff) * 100; + const isRight = a.diffDb >= 0; + return ( +
+ {a.band} +
+
+
+ {!isRight && ( +
+ )} +
+
+
+ {isRight && ( +
+ )} +
+
+
+ + + {a.diffDb > 0 ? '+' : ''}{a.diffDb.toFixed(1)} + + + +
+ ); + })} +
+ + {/* Diagnostic cards grid */} +
+ {/* Dynamics */} +
+

Dynamics

+

+ {report.dynamicsAdvice.issue === 'optimal' ? 'ON TARGET' : + report.dynamicsAdvice.issue === 'too-compressed' ? 'OVER-COMPRESSED' : 'TOO DYNAMIC'} +

+

+ Crest: {report.dynamicsAdvice.actualCrest} dB +

+
+ + {/* Loudness */} + {report.loudnessAdvice && ( +
+

Loudness

+

+ {report.loudnessAdvice.issue === 'optimal' ? 'ON TARGET' : + report.loudnessAdvice.issue === 'too-loud' ? 'TOO LOUD' : 'TOO QUIET'} +

+

+ {report.loudnessAdvice.actualLufs} LUFS / {report.loudnessAdvice.truePeak} dBTP +

+
+ )} + + {/* Stereo */} + {report.stereoAdvice && ( +
+

Stereo Field

+

+ {report.stereoAdvice.monoCompatible ? 'MONO SAFE' : 'PHASE RISK'} +

+

+ Corr: {report.stereoAdvice.correlation.toFixed(2)} / Width: {Math.round(report.stereoAdvice.width * 100)}% +

+
+ )} +
+ + {/* Non-optimal band advice */} + {(() => { + const issues = report.advice.filter(a => a.issue !== 'optimal'); + if (issues.length === 0) return null; + return ( +
+

Band Issues

+ {issues.map(a => ( +
+ +
+ {a.band}: + {a.message} +
+
+ ))} +
+ ); + })()} +
+ ); +} diff --git a/apps/ui/src/data/genreProfiles.json b/apps/ui/src/data/genreProfiles.json new file mode 100644 index 00000000..0b51f08a --- /dev/null +++ b/apps/ui/src/data/genreProfiles.json @@ -0,0 +1,562 @@ +[ + { + "id": "edm", + "name": "Electronic / EDM", + "targetCrestFactorRange": [5, 9], + "targetPlrRange": [6, 10], + "targetLufsRange": [-16, -10], + "spectralTargets": { + "subBass": { "minDb": -15, "maxDb": -8, "optimalDb": -11 }, + "lowBass": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "lowMids": { "minDb": -26, "maxDb": -18, "optimalDb": -22 }, + "mids": { "minDb": -22, "maxDb": -14, "optimalDb": -18 }, + "upperMids": { "minDb": -24, "maxDb": -16, "optimalDb": -20 }, + "highs": { "minDb": -28, "maxDb": -18, "optimalDb": -23 }, + "brilliance": { "minDb": -32, "maxDb": -22, "optimalDb": -27 } + } + }, + { + "id": "hiphop", + "name": "Hip Hop / Trap", + "targetCrestFactorRange": [7, 11], + "targetPlrRange": [7, 11], + "targetLufsRange": [-14, -8], + "spectralTargets": { + "subBass": { "minDb": -16, "maxDb": -6, "optimalDb": -10 }, + "lowBass": { "minDb": -20, "maxDb": -10, "optimalDb": -15 }, + "lowMids": { "minDb": -28, "maxDb": -18, "optimalDb": -23 }, + "mids": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "upperMids": { "minDb": -24, "maxDb": -15, "optimalDb": -19 }, + "highs": { "minDb": -26, "maxDb": -18, "optimalDb": -22 }, + "brilliance": { "minDb": -30, "maxDb": -22, "optimalDb": -26 } + } + }, + { + "id": "rock", + "name": "Rock / Metal", + "targetCrestFactorRange": [9, 14], + "targetPlrRange": [9, 14], + "targetLufsRange": [-16, -10], + "spectralTargets": { + "subBass": { "minDb": -25, "maxDb": -15, "optimalDb": -20 }, + "lowBass": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "lowMids": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "mids": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "upperMids": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "highs": { "minDb": -24, "maxDb": -16, "optimalDb": -20 }, + "brilliance": { "minDb": -30, "maxDb": -20, "optimalDb": -25 } + } + }, + { + "id": "pop", + "name": "Modern Pop", + "targetCrestFactorRange": [6, 10], + "targetPlrRange": [7, 11], + "targetLufsRange": [-16, -10], + "spectralTargets": { + "subBass": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "lowBass": { "minDb": -18, "maxDb": -12, "optimalDb": -15 }, + "lowMids": { "minDb": -24, "maxDb": -16, "optimalDb": -20 }, + "mids": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "upperMids": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "highs": { "minDb": -22, "maxDb": -14, "optimalDb": -18 }, + "brilliance": { "minDb": -26, "maxDb": -16, "optimalDb": -20 } + } + }, + { + "id": "acoustic", + "name": "Acoustic / Indie", + "targetCrestFactorRange": [12, 18], + "targetPlrRange": [13, 20], + "targetLufsRange": [-20, -14], + "spectralTargets": { + "subBass": { "minDb": -35, "maxDb": -25, "optimalDb": -30 }, + "lowBass": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "lowMids": { "minDb": -22, "maxDb": -14, "optimalDb": -18 }, + "mids": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "upperMids": { "minDb": -22, "maxDb": -14, "optimalDb": -18 }, + "highs": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "brilliance": { "minDb": -32, "maxDb": -22, "optimalDb": -27 } + } + }, + { + "id": "ambient", + "name": "Ambient / Downtempo", + "targetCrestFactorRange": [10, 18], + "targetPlrRange": [12, 20], + "targetLufsRange": [-24, -16], + "spectralTargets": { + "subBass": { "minDb": -28, "maxDb": -18, "optimalDb": -23 }, + "lowBass": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "lowMids": { "minDb": -22, "maxDb": -14, "optimalDb": -18 }, + "mids": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "upperMids": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "highs": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "brilliance": { "minDb": -24, "maxDb": -14, "optimalDb": -19 } + } + }, + { + "id": "ambient-drone", + "name": "Ambient / Drone", + "targetCrestFactorRange": [12, 22], + "targetPlrRange": [14, 24], + "targetLufsRange": [-26, -18], + "spectralTargets": { + "subBass": { "minDb": -38, "maxDb": -22, "optimalDb": -30 }, + "lowBass": { "minDb": -30, "maxDb": -18, "optimalDb": -24 }, + "lowMids": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "mids": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "upperMids": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "highs": { "minDb": -28, "maxDb": -16, "optimalDb": -22 }, + "brilliance": { "minDb": -30, "maxDb": -18, "optimalDb": -24 } + } + }, + { + "id": "ambient-techno", + "name": "Ambient Techno", + "targetCrestFactorRange": [10, 18], + "targetPlrRange": [12, 20], + "targetLufsRange": [-22, -16], + "spectralTargets": { + "subBass": { "minDb": -30, "maxDb": -18, "optimalDb": -24 }, + "lowBass": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "lowMids": { "minDb": -24, "maxDb": -16, "optimalDb": -20 }, + "mids": { "minDb": -22, "maxDb": -14, "optimalDb": -18 }, + "upperMids": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "highs": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "brilliance": { "minDb": -28, "maxDb": -16, "optimalDb": -22 } + } + }, + { + "id": "dub-techno", + "name": "Dub Techno", + "targetCrestFactorRange": [8, 15], + "targetPlrRange": [10, 17], + "targetLufsRange": [-20, -14], + "spectralTargets": { + "subBass": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "lowBass": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "lowMids": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "mids": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "upperMids": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "highs": { "minDb": -28, "maxDb": -14, "optimalDb": -21 }, + "brilliance": { "minDb": -30, "maxDb": -16, "optimalDb": -23 } + } + }, + { + "id": "deep-house", + "name": "Deep House", + "targetCrestFactorRange": [7, 12], + "targetPlrRange": [8, 13], + "targetLufsRange": [-18, -12], + "spectralTargets": { + "subBass": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "lowBass": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "lowMids": { "minDb": -24, "maxDb": -16, "optimalDb": -20 }, + "mids": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "upperMids": { "minDb": -22, "maxDb": -14, "optimalDb": -18 }, + "highs": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "brilliance": { "minDb": -30, "maxDb": -18, "optimalDb": -24 } + } + }, + { + "id": "organic-house", + "name": "Organic House / Downtempo", + "targetCrestFactorRange": [9, 16], + "targetPlrRange": [11, 18], + "targetLufsRange": [-20, -14], + "spectralTargets": { + "subBass": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "lowBass": { "minDb": -22, "maxDb": -14, "optimalDb": -18 }, + "lowMids": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "mids": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "upperMids": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "highs": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "brilliance": { "minDb": -28, "maxDb": -16, "optimalDb": -22 } + } + }, + { + "id": "house", + "name": "House", + "targetCrestFactorRange": [5, 10], + "targetPlrRange": [6, 10], + "targetLufsRange": [-16, -10], + "spectralTargets": { + "subBass": { "minDb": -16, "maxDb": -8, "optimalDb": -12 }, + "lowBass": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "lowMids": { "minDb": -24, "maxDb": -16, "optimalDb": -20 }, + "mids": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "upperMids": { "minDb": -22, "maxDb": -14, "optimalDb": -18 }, + "highs": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "brilliance": { "minDb": -30, "maxDb": -20, "optimalDb": -25 } + } + }, + { + "id": "classic-house", + "name": "Classic House", + "targetCrestFactorRange": [6, 11], + "targetPlrRange": [7, 11], + "targetLufsRange": [-18, -12], + "spectralTargets": { + "subBass": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "lowBass": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "lowMids": { "minDb": -24, "maxDb": -16, "optimalDb": -20 }, + "mids": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "upperMids": { "minDb": -22, "maxDb": -14, "optimalDb": -18 }, + "highs": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "brilliance": { "minDb": -30, "maxDb": -18, "optimalDb": -24 } + } + }, + { + "id": "tech-house", + "name": "Tech House", + "targetCrestFactorRange": [5, 9], + "targetPlrRange": [6, 10], + "targetLufsRange": [-16, -10], + "spectralTargets": { + "subBass": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "lowBass": { "minDb": -16, "maxDb": -8, "optimalDb": -12 }, + "lowMids": { "minDb": -24, "maxDb": -16, "optimalDb": -20 }, + "mids": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "upperMids": { "minDb": -22, "maxDb": -14, "optimalDb": -18 }, + "highs": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "brilliance": { "minDb": -30, "maxDb": -18, "optimalDb": -24 } + } + }, + { + "id": "progressive-house", + "name": "Progressive House", + "targetCrestFactorRange": [6, 10], + "targetPlrRange": [7, 11], + "targetLufsRange": [-16, -10], + "spectralTargets": { + "subBass": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "lowBass": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "lowMids": { "minDb": -24, "maxDb": -16, "optimalDb": -20 }, + "mids": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "upperMids": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "highs": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "brilliance": { "minDb": -28, "maxDb": -16, "optimalDb": -22 } + } + }, + { + "id": "afro-house", + "name": "Afro House / Tribal", + "targetCrestFactorRange": [7, 13], + "targetPlrRange": [8, 13], + "targetLufsRange": [-18, -12], + "spectralTargets": { + "subBass": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "lowBass": { "minDb": -20, "maxDb": -10, "optimalDb": -15 }, + "lowMids": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "mids": { "minDb": -20, "maxDb": -10, "optimalDb": -15 }, + "upperMids": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "highs": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "brilliance": { "minDb": -30, "maxDb": -16, "optimalDb": -23 } + } + }, + { + "id": "techno", + "name": "Techno", + "targetCrestFactorRange": [4, 8], + "targetPlrRange": [5, 9], + "targetLufsRange": [-14, -8], + "spectralTargets": { + "subBass": { "minDb": -14, "maxDb": -6, "optimalDb": -10 }, + "lowBass": { "minDb": -16, "maxDb": -8, "optimalDb": -12 }, + "lowMids": { "minDb": -28, "maxDb": -18, "optimalDb": -23 }, + "mids": { "minDb": -24, "maxDb": -16, "optimalDb": -20 }, + "upperMids": { "minDb": -22, "maxDb": -14, "optimalDb": -18 }, + "highs": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "brilliance": { "minDb": -30, "maxDb": -20, "optimalDb": -25 } + } + }, + { + "id": "minimal-techno", + "name": "Minimal Techno", + "targetCrestFactorRange": [7, 12], + "targetPlrRange": [8, 14], + "targetLufsRange": [-18, -12], + "spectralTargets": { + "subBass": { "minDb": -22, "maxDb": -14, "optimalDb": -18 }, + "lowBass": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "lowMids": { "minDb": -28, "maxDb": -18, "optimalDb": -23 }, + "mids": { "minDb": -24, "maxDb": -16, "optimalDb": -20 }, + "upperMids": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "highs": { "minDb": -28, "maxDb": -16, "optimalDb": -22 }, + "brilliance": { "minDb": -32, "maxDb": -18, "optimalDb": -25 } + } + }, + { + "id": "melodic-techno", + "name": "Melodic Techno", + "targetCrestFactorRange": [8, 14], + "targetPlrRange": [9, 15], + "targetLufsRange": [-16, -10], + "spectralTargets": { + "subBass": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "lowBass": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "lowMids": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "mids": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "upperMids": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "highs": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "brilliance": { "minDb": -30, "maxDb": -16, "optimalDb": -23 } + } + }, + { + "id": "driving-techno", + "name": "Driving / Peak Time Techno", + "targetCrestFactorRange": [4, 8], + "targetPlrRange": [5, 9], + "targetLufsRange": [-14, -8], + "spectralTargets": { + "subBass": { "minDb": -16, "maxDb": -8, "optimalDb": -12 }, + "lowBass": { "minDb": -14, "maxDb": -6, "optimalDb": -10 }, + "lowMids": { "minDb": -28, "maxDb": -18, "optimalDb": -23 }, + "mids": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "upperMids": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "highs": { "minDb": -28, "maxDb": -16, "optimalDb": -22 }, + "brilliance": { "minDb": -32, "maxDb": -18, "optimalDb": -25 } + } + }, + { + "id": "industrial-techno", + "name": "Industrial Techno", + "targetCrestFactorRange": [3, 7], + "targetPlrRange": [4, 8], + "targetLufsRange": [-14, -8], + "spectralTargets": { + "subBass": { "minDb": -14, "maxDb": -6, "optimalDb": -10 }, + "lowBass": { "minDb": -12, "maxDb": -4, "optimalDb": -8 }, + "lowMids": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "mids": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "upperMids": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "highs": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "brilliance": { "minDb": -30, "maxDb": -16, "optimalDb": -23 } + } + }, + { + "id": "hard-techno", + "name": "Hard Techno / Schranz", + "targetCrestFactorRange": [3, 7], + "targetPlrRange": [4, 8], + "targetLufsRange": [-12, -7], + "spectralTargets": { + "subBass": { "minDb": -12, "maxDb": -4, "optimalDb": -8 }, + "lowBass": { "minDb": -10, "maxDb": -2, "optimalDb": -6 }, + "lowMids": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "mids": { "minDb": -20, "maxDb": -10, "optimalDb": -15 }, + "upperMids": { "minDb": -20, "maxDb": -10, "optimalDb": -15 }, + "highs": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "brilliance": { "minDb": -30, "maxDb": -16, "optimalDb": -23 } + } + }, + { + "id": "acid-techno", + "name": "Acid Techno / 303", + "targetCrestFactorRange": [6, 11], + "targetPlrRange": [7, 12], + "targetLufsRange": [-16, -10], + "spectralTargets": { + "subBass": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "lowBass": { "minDb": -16, "maxDb": -8, "optimalDb": -12 }, + "lowMids": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "mids": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "upperMids": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "highs": { "minDb": -28, "maxDb": -16, "optimalDb": -22 }, + "brilliance": { "minDb": -32, "maxDb": -18, "optimalDb": -25 } + } + }, + { + "id": "detroit-techno", + "name": "Detroit Techno", + "targetCrestFactorRange": [7, 13], + "targetPlrRange": [8, 14], + "targetLufsRange": [-18, -12], + "spectralTargets": { + "subBass": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "lowBass": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "lowMids": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "mids": { "minDb": -20, "maxDb": -10, "optimalDb": -15 }, + "upperMids": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "highs": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "brilliance": { "minDb": -30, "maxDb": -16, "optimalDb": -23 } + } + }, + { + "id": "trance", + "name": "Trance", + "targetCrestFactorRange": [6, 11], + "targetPlrRange": [7, 11], + "targetLufsRange": [-14, -9], + "spectralTargets": { + "subBass": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "lowBass": { "minDb": -16, "maxDb": -8, "optimalDb": -12 }, + "lowMids": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "mids": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "upperMids": { "minDb": -20, "maxDb": -10, "optimalDb": -15 }, + "highs": { "minDb": -24, "maxDb": -12, "optimalDb": -18 }, + "brilliance": { "minDb": -26, "maxDb": -14, "optimalDb": -20 } + } + }, + { + "id": "psytrance", + "name": "Psytrance", + "targetCrestFactorRange": [5, 10], + "targetPlrRange": [6, 10], + "targetLufsRange": [-14, -8], + "spectralTargets": { + "subBass": { "minDb": -16, "maxDb": -8, "optimalDb": -12 }, + "lowBass": { "minDb": -14, "maxDb": -6, "optimalDb": -10 }, + "lowMids": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "mids": { "minDb": -20, "maxDb": -10, "optimalDb": -15 }, + "upperMids": { "minDb": -22, "maxDb": -10, "optimalDb": -16 }, + "highs": { "minDb": -24, "maxDb": -12, "optimalDb": -18 }, + "brilliance": { "minDb": -26, "maxDb": -14, "optimalDb": -20 } + } + }, + { + "id": "dubstep", + "name": "Dubstep", + "targetCrestFactorRange": [7, 13], + "targetPlrRange": [8, 14], + "targetLufsRange": [-14, -8], + "spectralTargets": { + "subBass": { "minDb": -16, "maxDb": -6, "optimalDb": -11 }, + "lowBass": { "minDb": -14, "maxDb": -6, "optimalDb": -10 }, + "lowMids": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "mids": { "minDb": -20, "maxDb": -10, "optimalDb": -15 }, + "upperMids": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "highs": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "brilliance": { "minDb": -30, "maxDb": -16, "optimalDb": -23 } + } + }, + { + "id": "bass-house", + "name": "Bass House", + "targetCrestFactorRange": [5, 10], + "targetPlrRange": [6, 10], + "targetLufsRange": [-14, -9], + "spectralTargets": { + "subBass": { "minDb": -16, "maxDb": -8, "optimalDb": -12 }, + "lowBass": { "minDb": -14, "maxDb": -6, "optimalDb": -10 }, + "lowMids": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "mids": { "minDb": -20, "maxDb": -10, "optimalDb": -15 }, + "upperMids": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "highs": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "brilliance": { "minDb": -30, "maxDb": -16, "optimalDb": -23 } + } + }, + { + "id": "dnb", + "name": "Drum & Bass / Jungle", + "targetCrestFactorRange": [6, 12], + "targetPlrRange": [7, 11], + "targetLufsRange": [-14, -8], + "spectralTargets": { + "subBass": { "minDb": -14, "maxDb": -6, "optimalDb": -10 }, + "lowBass": { "minDb": -16, "maxDb": -8, "optimalDb": -12 }, + "lowMids": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "mids": { "minDb": -22, "maxDb": -14, "optimalDb": -18 }, + "upperMids": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "highs": { "minDb": -24, "maxDb": -14, "optimalDb": -19 }, + "brilliance": { "minDb": -28, "maxDb": -18, "optimalDb": -23 } + } + }, + { + "id": "drum-bass", + "name": "Drum & Bass", + "targetCrestFactorRange": [6, 12], + "targetPlrRange": [7, 12], + "targetLufsRange": [-14, -8], + "spectralTargets": { + "subBass": { "minDb": -16, "maxDb": -8, "optimalDb": -12 }, + "lowBass": { "minDb": -14, "maxDb": -6, "optimalDb": -10 }, + "lowMids": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "mids": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "upperMids": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "highs": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "brilliance": { "minDb": -30, "maxDb": -18, "optimalDb": -24 } + } + }, + { + "id": "neurofunk", + "name": "Neurofunk", + "targetCrestFactorRange": [5, 10], + "targetPlrRange": [6, 10], + "targetLufsRange": [-14, -8], + "spectralTargets": { + "subBass": { "minDb": -14, "maxDb": -6, "optimalDb": -10 }, + "lowBass": { "minDb": -12, "maxDb": -4, "optimalDb": -8 }, + "lowMids": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "mids": { "minDb": -22, "maxDb": -10, "optimalDb": -16 }, + "upperMids": { "minDb": -22, "maxDb": -10, "optimalDb": -16 }, + "highs": { "minDb": -26, "maxDb": -12, "optimalDb": -19 }, + "brilliance": { "minDb": -30, "maxDb": -16, "optimalDb": -23 } + } + }, + { + "id": "breaks", + "name": "Breaks / Breakbeat", + "targetCrestFactorRange": [7, 13], + "targetPlrRange": [8, 14], + "targetLufsRange": [-16, -10], + "spectralTargets": { + "subBass": { "minDb": -20, "maxDb": -12, "optimalDb": -16 }, + "lowBass": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "lowMids": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "mids": { "minDb": -20, "maxDb": -10, "optimalDb": -15 }, + "upperMids": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "highs": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "brilliance": { "minDb": -30, "maxDb": -18, "optimalDb": -24 } + } + }, + { + "id": "garage", + "name": "Garage / UK Bass", + "targetCrestFactorRange": [6, 11], + "targetPlrRange": [6, 10], + "targetLufsRange": [-16, -10], + "spectralTargets": { + "subBass": { "minDb": -14, "maxDb": -6, "optimalDb": -10 }, + "lowBass": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "lowMids": { "minDb": -24, "maxDb": -16, "optimalDb": -20 }, + "mids": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "upperMids": { "minDb": -22, "maxDb": -14, "optimalDb": -18 }, + "highs": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "brilliance": { "minDb": -30, "maxDb": -20, "optimalDb": -25 } + } + }, + { + "id": "uk-garage", + "name": "UK Garage", + "targetCrestFactorRange": [6, 11], + "targetPlrRange": [7, 11], + "targetLufsRange": [-16, -10], + "spectralTargets": { + "subBass": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "lowBass": { "minDb": -16, "maxDb": -8, "optimalDb": -12 }, + "lowMids": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "mids": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "upperMids": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "highs": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "brilliance": { "minDb": -30, "maxDb": -18, "optimalDb": -24 } + } + }, + { + "id": "bassline", + "name": "Bassline / Niche", + "targetCrestFactorRange": [5, 10], + "targetPlrRange": [6, 10], + "targetLufsRange": [-14, -9], + "spectralTargets": { + "subBass": { "minDb": -16, "maxDb": -8, "optimalDb": -12 }, + "lowBass": { "minDb": -14, "maxDb": -6, "optimalDb": -10 }, + "lowMids": { "minDb": -26, "maxDb": -16, "optimalDb": -21 }, + "mids": { "minDb": -18, "maxDb": -10, "optimalDb": -14 }, + "upperMids": { "minDb": -22, "maxDb": -12, "optimalDb": -17 }, + "highs": { "minDb": -26, "maxDb": -14, "optimalDb": -20 }, + "brilliance": { "minDb": -30, "maxDb": -18, "optimalDb": -24 } + } + } +] diff --git a/apps/ui/src/services/backendPhase1Client.ts b/apps/ui/src/services/backendPhase1Client.ts index 7056b88a..bf892f49 100644 --- a/apps/ui/src/services/backendPhase1Client.ts +++ b/apps/ui/src/services/backendPhase1Client.ts @@ -517,6 +517,8 @@ export function parsePhase1Result(value: unknown): Phase1Result { supersawDetail: parseOptionalSupersawDetail(phase1.supersawDetail), bassDetail: parseOptionalBassDetail(phase1.bassDetail), kickDetail: parseOptionalKickDetail(phase1.kickDetail), + genreDetail: parseOptionalGenreDetail(phase1.genreDetail), + dynamicCharacter: parseOptionalDynamicCharacter(phase1.dynamicCharacter), effectsDetail: isRecord(phase1.effectsDetail) ? phase1.effectsDetail : null, synthesisCharacter: isRecord(phase1.synthesisCharacter) ? phase1.synthesisCharacter : null, danceability: parseOptionalDanceability(phase1.danceability), @@ -626,6 +628,40 @@ function parseOptionalKickDetail(value: unknown): Phase1Result["kickDetail"] { }; } +function parseOptionalGenreDetail(value: unknown): Phase1Result["genreDetail"] { + if (value === undefined || value === null) return null; + if (!isRecord(value)) return null; + const genre = value.genre; + if (typeof genre !== "string") return null; + const confidence = toNumberOrFallback(value.confidence, 0); + const secondaryGenre = + typeof value.secondaryGenre === "string" ? value.secondaryGenre : null; + const genreFamily = typeof value.genreFamily === "string" + ? (value.genreFamily as NonNullable["genreFamily"]) + : "other"; + const topScores = Array.isArray(value.topScores) + ? (value.topScores as unknown[]) + .filter((s): s is Record => isRecord(s)) + .map((s) => ({ + genre: typeof s.genre === "string" ? s.genre : "", + score: toNumberOrFallback(s.score, 0), + })) + : []; + return { genre, confidence, secondaryGenre, genreFamily, topScores }; +} + +function parseOptionalDynamicCharacter(value: unknown): Phase1Result["dynamicCharacter"] { + if (value === undefined || value === null) return null; + if (!isRecord(value)) return null; + return { + dynamicComplexity: toNumberOrFallback(value.dynamicComplexity, 0), + loudnessVariation: toNumberOrFallback(value.loudnessVariation, 0), + spectralFlatness: toNumberOrFallback(value.spectralFlatness, 0), + logAttackTime: toNumberOrFallback(value.logAttackTime, 0), + attackTimeStdDev: toNumberOrFallback(value.attackTimeStdDev, 0), + }; +} + function parseOptionalMelodyDetail(phase1: UnknownRecord): Phase1Result["melodyDetail"] | undefined { const raw = phase1.melodyDetail; if (!isRecord(raw)) return undefined; diff --git a/apps/ui/src/services/fieldAnalytics.ts b/apps/ui/src/services/fieldAnalytics.ts index 48b3e404..c59254ad 100644 --- a/apps/ui/src/services/fieldAnalytics.ts +++ b/apps/ui/src/services/fieldAnalytics.ts @@ -139,6 +139,14 @@ const ALL_PHASE1_FIELDS: string[] = [ 'kickDetail.isDistorted', 'kickDetail.thd', 'kickDetail.fundamentalHz', + // Genre + 'genreDetail.genre', + 'genreDetail.confidence', + 'genreDetail.genreFamily', + // Dynamic Character + 'dynamicCharacter.logAttackTime', + 'dynamicCharacter.attackTimeStdDev', + 'dynamicCharacter.dynamicComplexity', // Synthesis 'synthesisCharacter.inharmonicity', 'synthesisCharacter.oddToEvenRatio', diff --git a/apps/ui/src/services/mixDoctor.ts b/apps/ui/src/services/mixDoctor.ts new file mode 100644 index 00000000..9e7a5df9 --- /dev/null +++ b/apps/ui/src/services/mixDoctor.ts @@ -0,0 +1,256 @@ +import type { Phase1Result, GenreProfile, SpectralTarget } from '../types'; + +export interface MixAdvice { + band: string; + issue: 'optimal' | 'too-loud' | 'too-quiet'; + message: string; + diffDb: number; +} + +export interface DynamicsAdvice { + issue: 'too-compressed' | 'too-dynamic' | 'optimal'; + message: string; + actualCrest: number; +} + +export interface LoudnessAdvice { + issue: 'too-loud' | 'too-quiet' | 'optimal'; + message: string; + actualLufs: number; + truePeak: number; +} + +export interface StereoAdvice { + correlation: number; + width: number; + monoCompatible: boolean; + message: string; +} + +export interface MixDoctorReport { + genreName: string; + profileId: string; + advice: MixAdvice[]; + dynamicsAdvice: DynamicsAdvice; + loudnessAdvice: LoudnessAdvice | undefined; + stereoAdvice: StereoAdvice | undefined; + overallScore: number; +} + +function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; +} + +type BandKey = keyof Phase1Result['spectralBalance']; +type ProfileBandKey = keyof GenreProfile['spectralTargets']; + +const BAND_MAP: { key: BandKey; profileKey: ProfileBandKey; label: string }[] = [ + { key: 'subBass', profileKey: 'subBass', label: 'Sub Bass' }, + { key: 'lowBass', profileKey: 'lowBass', label: 'Low Bass' }, + { key: 'mids', profileKey: 'mids', label: 'Mids' }, + { key: 'upperMids', profileKey: 'upperMids', label: 'Upper Mids' }, + { key: 'highs', profileKey: 'highs', label: 'Highs' }, + { key: 'brilliance', profileKey: 'brilliance', label: 'Brilliance' }, +]; + +function scoreBand( + currentDb: number, + target: SpectralTarget, +): number { + const diffToOptimal = currentDb - target.optimalDb; + const inRange = currentDb >= target.minDb && currentDb <= target.maxDb; + let score: number; + if (inRange) { + const rangeHalf = (target.maxDb - target.minDb) / 2; + const normalizedDiff = rangeHalf > 0 ? Math.abs(diffToOptimal) / rangeHalf : 0; + score = 100 - normalizedDiff * 20; + } else { + const overshoot = currentDb > target.maxDb + ? currentDb - target.maxDb + : target.minDb - currentDb; + score = 80 - overshoot * 5; + } + return Math.max(0, Math.min(100, score)); +} + +function bandAdviceMessage( + label: string, + currentDb: number, + target: SpectralTarget, + profileName: string, +): { issue: MixAdvice['issue']; message: string } { + if (currentDb > target.maxDb) { + const exceed = (currentDb - target.maxDb).toFixed(1); + let message: string; + switch (label) { + case 'Sub Bass': + message = `Muddy/overpowering subs. Highpass non-bass elements or turn down the sub layer by ~${exceed}dB.`; + break; + case 'Low Mids': + message = `Boxy or muddy. Cut around 300-400Hz to clear space for kick/bass.`; + break; + case 'Highs': + message = `Harsh and piercing. De-ess vocals or cut 6-8kHz.`; + break; + default: + message = `Too prominent. Reduce by ~${exceed}dB to match commercial ${profileName} tracks.`; + } + return { issue: 'too-loud', message }; + } + if (currentDb < target.minDb) { + const deficit = (target.minDb - currentDb).toFixed(1); + let message: string; + switch (label) { + case 'Sub Bass': + message = `Weak low end. Add sub harmonics or boost <80Hz by ~${deficit}dB.`; + break; + case 'Mids': + message = `Hollow mix. Boost fundamentals of synths/vocals to add body.`; + break; + case 'Brilliance': + message = `Lacking 'air' and width. Apply a high shelf boost > 10kHz.`; + break; + default: + message = `Lacking energy. Boost by ~${deficit}dB.`; + } + return { issue: 'too-quiet', message }; + } + return { issue: 'optimal', message: 'Balanced.' }; +} + +export function generateMixReport( + phase1: Phase1Result, + profile: GenreProfile, +): MixDoctorReport { + const spectral = phase1.spectralBalance; + + // Compute loudness offset to normalize for mastering level + const offsets: number[] = []; + for (const { key, profileKey } of BAND_MAP) { + const target = profile.spectralTargets[profileKey]; + if (!target) continue; + offsets.push(spectral[key] - target.optimalDb); + } + const loudnessOffset = offsets.length >= 3 ? median(offsets) : 0; + + // Evaluate each spectral band + const advice: MixAdvice[] = []; + let scoreAccumulator = 0; + let bandsEvaluated = 0; + + for (const { key, profileKey, label } of BAND_MAP) { + const target = profile.spectralTargets[profileKey]; + if (!target) continue; + + const currentDb = spectral[key] - loudnessOffset; + const diffToOptimal = currentDb - target.optimalDb; + const bandScore = scoreBand(currentDb, target); + scoreAccumulator += bandScore; + bandsEvaluated++; + + const { issue, message } = bandAdviceMessage(label, currentDb, target, profile.name); + advice.push({ band: label, issue, message, diffDb: Math.round(diffToOptimal * 10) / 10 }); + } + + // Evaluate dynamics (crest factor — ASA doesn't expose PLR) + let dynamicsIssue: DynamicsAdvice['issue'] = 'optimal'; + let dynamicsMsg = 'Solid dynamic range. Fits the genre well.'; + let dynamicsPenalty = 0; + const crest = phase1.crestFactor ?? 10; + + const [minCrest, maxCrest] = profile.targetCrestFactorRange; + if (crest < minCrest) { + dynamicsIssue = 'too-compressed'; + dynamicsMsg = `Crest factor ${crest.toFixed(1)} dB is below the ${minCrest}–${maxCrest} dB target for ${profile.name}. The mix is over-compressed — ease off the master limiter or reduce bus compression to recover transient punch.`; + dynamicsPenalty = Math.min(15, (minCrest - crest) * 2.5); + } else if (crest > maxCrest) { + dynamicsIssue = 'too-dynamic'; + dynamicsMsg = `Crest factor ${crest.toFixed(1)} dB exceeds the ${minCrest}–${maxCrest} dB target for ${profile.name}. Wide dynamic range — add bus compression or saturation to glue the mix.`; + dynamicsPenalty = Math.min(15, (crest - maxCrest) * 2.5); + } + + // Evaluate LUFS loudness + let loudnessAdvice: LoudnessAdvice | undefined; + let loudnessPenalty = 0; + + const lufs = phase1.lufsIntegrated; + const tp = phase1.truePeak; + const [minLufs, maxLufs] = profile.targetLufsRange; + + let loudnessIssue: LoudnessAdvice['issue'] = 'optimal'; + let loudnessMsg = `Loudness is on target at ${lufs.toFixed(1)} LUFS. Good for streaming platforms.`; + + if (lufs > maxLufs) { + loudnessIssue = 'too-loud'; + loudnessMsg = `Too loud at ${lufs.toFixed(1)} LUFS (target: ${maxLufs} LUFS max for ${profile.name}). Streaming platforms will turn it down — reduce limiter gain.`; + loudnessPenalty = Math.min(10, (lufs - maxLufs) * 2); + } else if (lufs < minLufs) { + loudnessIssue = 'too-quiet'; + loudnessMsg = `Quiet at ${lufs.toFixed(1)} LUFS (target: ${minLufs} LUFS min for ${profile.name}). Consider adding gain or a limiter to bring up overall level.`; + loudnessPenalty = Math.min(10, (minLufs - lufs) * 2); + } + + if (tp > -1) { + loudnessMsg += ` True peak at ${tp.toFixed(1)} dBTP — risk of clipping on codec conversion. Target -1 dBTP ceiling.`; + loudnessPenalty += 3; + } + + loudnessAdvice = { + issue: loudnessIssue, + message: loudnessMsg, + actualLufs: Math.round(lufs * 10) / 10, + truePeak: Math.round(tp * 10) / 10, + }; + + // Evaluate stereo field + let stereoAdvice: StereoAdvice | undefined; + let stereoPenalty = 0; + + const corr = phase1.stereoCorrelation; + const width = phase1.stereoWidth; + const stereoRec = phase1.stereoDetail as Record | null; + const subBassMono = stereoRec?.subBassMono === true; + const subBassCorr = typeof stereoRec?.subBassCorrelation === 'number' ? stereoRec.subBassCorrelation : null; + const mono = subBassMono || (subBassCorr !== null && subBassCorr > 0.5); + + let stereoMsg = `Stereo field looks good — correlation ${corr.toFixed(2)}, width ${Math.round(width * 100)}%.`; + + if (!mono && subBassCorr !== null && subBassCorr < 0.3) { + stereoMsg = `Phase cancellation detected in low frequencies. Bass will lose energy on mono playback (PA systems, phone speakers). Narrow your sub bass to mono.`; + stereoPenalty = 5; + } else if (corr < 0.2) { + stereoMsg = `Very wide stereo image (correlation ${corr.toFixed(2)}). May sound thin when summed to mono. Consider narrowing bass and mid elements.`; + stereoPenalty = 3; + } else if (corr > 0.95 && width < 0.05) { + stereoMsg = `Nearly mono — very narrow stereo image. Consider widening with stereo delay, chorus, or panning elements.`; + stereoPenalty = 2; + } + + stereoAdvice = { correlation: corr, width, monoCompatible: mono, message: stereoMsg }; + + // Final score + let overallScore = bandsEvaluated > 0 ? scoreAccumulator / bandsEvaluated : 0; + overallScore -= dynamicsPenalty; + overallScore -= loudnessPenalty; + overallScore -= stereoPenalty; + overallScore = Math.round(Math.max(0, Math.min(100, overallScore))); + + return { + genreName: profile.name, + profileId: profile.id, + advice, + dynamicsAdvice: { + issue: dynamicsIssue, + message: dynamicsMsg, + actualCrest: Math.round(crest * 10) / 10, + }, + loudnessAdvice, + stereoAdvice, + overallScore, + }; +} diff --git a/apps/ui/src/types.ts b/apps/ui/src/types.ts index 36d322c7..3f7b8427 100644 --- a/apps/ui/src/types.ts +++ b/apps/ui/src/types.ts @@ -69,6 +69,13 @@ export interface Phase1Result { lufsRange?: number | null; truePeak: number; crestFactor?: number | null; + dynamicCharacter?: { + dynamicComplexity: number; + loudnessVariation: number; + spectralFlatness: number; + logAttackTime: number; + attackTimeStdDev: number; + } | null; stereoWidth: number; stereoCorrelation: number; stereoDetail?: Record | null; @@ -150,6 +157,29 @@ export interface Phase1Result { export type MeasurementResult = Omit; +export interface SpectralTarget { + minDb: number; + maxDb: number; + optimalDb: number; +} + +export interface GenreProfile { + id: string; + name: string; + targetCrestFactorRange: [number, number]; + targetPlrRange: [number, number]; + targetLufsRange: [number, number]; + spectralTargets: { + subBass: SpectralTarget; + lowBass: SpectralTarget; + lowMids: SpectralTarget; + mids: SpectralTarget; + upperMids: SpectralTarget; + highs: SpectralTarget; + brilliance: SpectralTarget; + }; +} + export type RecommendationCategory = | "SYNTHESIS" | "DYNAMICS" diff --git a/apps/ui/src/utils/exportUtils.ts b/apps/ui/src/utils/exportUtils.ts index 1d0f4fdf..5ecfe675 100644 --- a/apps/ui/src/utils/exportUtils.ts +++ b/apps/ui/src/utils/exportUtils.ts @@ -1,4 +1,6 @@ -import { Phase1Result, Phase2Result } from '../types'; +import { Phase1Result, Phase2Result, type GenreProfile } from '../types'; +import { generateMixReport, type MixDoctorReport } from '../services/mixDoctor'; +import genreProfilesData from '../data/genreProfiles.json'; export function downloadFile(content: string, fileName: string, contentType: string) { const a = document.createElement('a'); @@ -39,6 +41,34 @@ function formatMixAndMasterChainMarkdown(mixAndMasterChain: Phase2Result['mixAnd .join('\n'); } +function formatMixDoctorMarkdown(report: MixDoctorReport): string { + let md = `## Mix Doctor (${report.genreName})\n`; + md += `**Overall Score: ${report.overallScore}/100**\n\n`; + + md += '### Spectral Balance\n'; + for (const a of report.advice) { + const sign = a.diffDb > 0 ? '+' : ''; + const status = a.issue === 'optimal' ? '✓' : a.issue === 'too-loud' ? '▲' : '▼'; + md += `- ${status} **${a.band}**: ${sign}${a.diffDb.toFixed(1)} dB — ${a.message}\n`; + } + md += '\n'; + + md += `### Dynamics\n- ${report.dynamicsAdvice.message}\n`; + md += `- Crest Factor: ${report.dynamicsAdvice.actualCrest} dB\n\n`; + + if (report.loudnessAdvice) { + md += `### Loudness\n- ${report.loudnessAdvice.message}\n`; + md += `- LUFS: ${report.loudnessAdvice.actualLufs} / True Peak: ${report.loudnessAdvice.truePeak} dBTP\n\n`; + } + + if (report.stereoAdvice) { + md += `### Stereo Field\n- ${report.stereoAdvice.message}\n`; + md += `- Correlation: ${report.stereoAdvice.correlation.toFixed(2)} / Width: ${Math.round(report.stereoAdvice.width * 100)}%\n\n`; + } + + return md; +} + export function generateMarkdown( phase1: Phase1Result, phase2: Phase2Result | null, @@ -66,6 +96,18 @@ export function generateMarkdown( md += `- **Highs**: ${phase1.spectralBalance.highs}\n`; md += `- **Brilliance**: ${phase1.spectralBalance.brilliance}\n\n`; + // Mix Doctor section (derived analysis, not measurement) + const profiles = genreProfilesData as GenreProfile[]; + const gd = phase1.genreDetail; + const autoId = gd && gd.confidence >= 0.6 ? gd.genre : null; + const familyId = gd ? gd.genreFamily : null; + const profileId = autoId ?? familyId ?? profiles[0]?.id; + const profile = profiles.find(p => p.id === profileId); + if (profile) { + const report = generateMixReport(phase1, profile); + md += formatMixDoctorMarkdown(report); + } + if (!phase2) { md += '## Phase 2\n'; md += `${phase2StatusMessage ?? 'Phase 2 (Gemini reconstruction advice) was skipped or unavailable.'}\n`; diff --git a/apps/ui/tsconfig.json b/apps/ui/tsconfig.json index 120e8411..185e28df 100644 --- a/apps/ui/tsconfig.json +++ b/apps/ui/tsconfig.json @@ -20,6 +20,7 @@ "./*" ] }, + "resolveJsonModule": true, "allowImportingTsExtensions": true, "noEmit": true }, From f023e4bd40c22d08a5b27b6e8c99cb6b4f942b71 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Thu, 19 Mar 2026 15:46:57 +1300 Subject: [PATCH 11/11] feat: MixDoctor panel, genre profiles, detector cards, and export polish - Add MixDoctor scoring engine (spectral balance, dynamics, PLR, loudness, stereo vs genre targets) - Add MixDoctor panel with profile selector, delta chart, diagnostic cards, band-issue details - Add genre profiles data for 35 genres with spectral/dynamics/PLR/loudness targets - Add genre classification, sidechain detection, synthesis character detector cards in UI - Add spectral balance six-band visualization in Phase 1 results - Add MixDoctor section to markdown and JSON exports; dynamicCharacter in markdown export - Harden genreDetail parsing and typing in frontend client - Add dense techno boundary regression test (145 BPM) - Align synthesis character labels to three-tier thresholds (clean subtractive / FM-acid / wavetable-noise) - Add CODEX.md and PURPOSE.md project documentation files Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 3 +- CHANGELOG.md | 24 + CODEX.md | 84 ++ PURPOSE.md | 193 ++++ apps/backend/CODEX.md | 46 + apps/backend/tests/test_analyze.py | 29 + apps/ui/CODEX.md | 50 + apps/ui/package-lock.json | 925 +----------------- apps/ui/src/components/AnalysisResults.tsx | 59 +- apps/ui/src/components/MixDoctorPanel.tsx | 144 +-- apps/ui/src/services/backendPhase1Client.ts | 45 +- apps/ui/src/services/mixDoctor.ts | 86 +- apps/ui/src/types.ts | 44 + apps/ui/src/utils/exportUtils.ts | 31 +- apps/ui/tests/services/exportUtils.test.ts | 57 ++ apps/ui/tests/services/mixDoctor.test.ts | 98 ++ apps/ui/tests/services/mixDoctorPanel.test.ts | 88 ++ 17 files changed, 940 insertions(+), 1066 deletions(-) create mode 100644 CODEX.md create mode 100644 PURPOSE.md create mode 100644 apps/backend/CODEX.md create mode 100644 apps/ui/CODEX.md create mode 100644 apps/ui/tests/services/mixDoctor.test.ts create mode 100644 apps/ui/tests/services/mixDoctorPanel.test.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 88c2bbf7..f5526626 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,8 @@ "Bash(./venv/bin/python -m unittest tests.test_analyze.AnalyzeStructuralSnapshotTests.test_rhythm_detail_exposes_full_grid_downbeats_and_bar_positions)", "Bash(./venv/bin/python -m unittest tests.test_analysis_runtime -v)", "Bash(./venv/bin/python -m unittest tests.test_server -v)", - "Bash(./venv/bin/python -m unittest tests.test_analysis_runtime tests.test_server -v)" + "Bash(./venv/bin/python -m unittest tests.test_analysis_runtime tests.test_server -v)", + "Skill(commit-commands:commit-push-pr)" ] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index beb0f6ac..1dcc1186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to `asa` are documented here. +## Unreleased + +### Added +- **Genre classification** card in Phase 1 detector grid (genreDetail with family, confidence, topScores) +- **Sidechain detection** card showing pump depth and timing +- **Synthesis character** card with three-tier inharmonicity + harmonic shape labels +- **Spectral balance** six-band visualization in Phase 1 +- **MixDoctor** scoring engine: spectral balance, dynamics (crest factor), PLR, loudness, and stereo field vs genre-specific targets +- **MixDoctor panel** with profile selector, delta chart, diagnostic cards, and band-issue details +- **Genre profiles** data (35 genres) with spectral, dynamics, PLR, and loudness targets +- **MixDoctor** in both markdown and JSON exports +- `dynamicCharacter` in markdown export +- `genreDetail` strong parsing and typing in frontend client +- Dense techno boundary regression test (145 BPM) + +### Changed +- Synthesis character labels aligned to phase2 prompt thresholds (three-tier: clean subtractive / FM-acid / wavetable-noise) +- Removed citation instructions from Phase 2 system prompt (citations added noise, not value) +- `dynamicCharacter` forwarded through `_build_phase1()` to Gemini Phase 2 + +### Fixed +- MixDoctor null-genre fallback: prompts for manual selection instead of silently using first profile +- Genre abstention logic with tests for empty, sparse, ambiguous, and fast-mode inputs + ## v2.1.0 - Hardened `transcriptionDetail` in `apps/backend/analyze.py` for bass + hook extraction: diff --git a/CODEX.md b/CODEX.md new file mode 100644 index 00000000..fc833a43 --- /dev/null +++ b/CODEX.md @@ -0,0 +1,84 @@ +# CODEX.md + +This file provides guidance to Codex when working in this repository. + +## Source Mapping + +- Product mission and quality bar: `PURPOSE.md` and `ASA_System_Design.docx`. +- Repo workflow and policy: `AGENTS.md` at root, then app-local `apps/ui/AGENTS.md` or `apps/backend/AGENTS.md`. +- Command and runtime specifics: `CLAUDE.md`. + +When guidance differs: + +1. Mission and quality invariants from `PURPOSE.md` and `ASA_System_Design.docx` win. +2. Repo workflow and contract rules from the `AGENTS.md` chain win next. +3. `CODEX.md` files provide Codex-tailored execution guidance. + +## Read Order For Codex + +1. `PURPOSE.md` +2. `ASA_System_Design.docx` +3. `AGENTS.md` +4. app-local `CODEX.md` + app-local `AGENTS.md` for the area you edit +5. `CLAUDE.md` for command details and additional guardrails + +## Mission Gate + +Before implementing changes, run this test: + +1. Does this improve measurement accuracy? +2. Does this improve recommendation specificity/quality? +3. Does this improve a producer's ability to act in Ableton? +4. If it is maintenance-only, does it clearly unblock one of the above? +5. If none apply, stop and reconsider. + +## Non-Negotiable Invariants + +- Phase 1 measurements are ground truth; Phase 2 does not override measured values. +- Phase 2 recommendations must cite specific Phase 1 measurements. +- Recommendations must be Ableton Live 12 specific (device, parameter, value). +- Low-confidence measurements must lead to hedged recommendations. +- Reconstruction guidance must cover the full production surface. +- Output must remain usable for intermediate producers without DSP expertise. + +## Architecture Snapshot + +- Layer 1 (`apps/backend/analyze.py`): deterministic DSP measurement engine. +- Layer 2 (`apps/backend/server.py` + `/api/phase2`): interpretation using measured data plus audio. +- Layer 3 (`apps/ui`): upload, estimate, analysis, and reconstruction-facing presentation. +- Contract boundary: `phase1`/`phase2` shapes consumed by UI types must remain aligned with backend responses. + +## Codex Workflow Expectations + +- Treat monorepo root as entrypoint for stack orchestration and release context. +- Prefer surgical edits; avoid broad rewrites unless explicitly requested. +- Preserve `analyze.py` `stdout` JSON vs `stderr` diagnostics behavior. +- Keep frontend/backend contracts in sync when adding/removing fields. +- Read `docs/ARCHITECTURE_STRATEGY.md` before proposing structural architecture or pipeline changes. + +## Canonical Commands + +From repo root: + +```bash +./scripts/dev.sh +``` + +Frontend verification: + +```bash +cd apps/ui +npm run verify +``` + +Backend verification: + +```bash +cd apps/backend +./venv/bin/python -m unittest discover -s tests +``` + +## App Routing + +- UI work: read `apps/ui/CODEX.md` and `apps/ui/AGENTS.md`. +- Backend work: read `apps/backend/CODEX.md` and `apps/backend/AGENTS.md`. diff --git a/PURPOSE.md b/PURPOSE.md new file mode 100644 index 00000000..76c2cf46 --- /dev/null +++ b/PURPOSE.md @@ -0,0 +1,193 @@ +# PURPOSE.md — Why ASA Exists + +This document is the highest-authority reference for what Ableton Sonic Analyzer does, who it serves, and how every change should be evaluated. It is intended for human developers and AI coding agents alike. When in doubt about whether a change is worthwhile, return here. + +--- + +## Mission + +ASA exists to answer a single question that every intermediate electronic music producer asks when they hear a track they admire: + +**"How do I make something that sounds like this in Ableton Live 12?"** + +No other tool answers this question with measurement-backed specificity. Spectrum analyzers give you numbers. AI chatbots give you opinions. ASA gives you both — in a chain of custody from deterministic ground truth to actionable, justified Ableton device recommendations. + +--- + +## Core Value Proposition + +ASA's unique value is the **measure-then-advise pipeline**: + +1. **Measure** — Phase 1 runs a deterministic DSP engine that extracts ground-truth metrics from audio: BPM, key, LUFS, spectral balance, stereo field, groove timing, sidechain behavior, synthesis character, arrangement structure, and more. These numbers are not opinions. They are reproducible, verifiable facts about the audio signal. + +2. **Advise** — Phase 2 feeds those measurements (plus the audio itself) to an AI interpreter that is constrained to treat the measurements as ground truth. The AI's job is not to re-analyze — it is to translate measurements into a reconstruction blueprint: specific Ableton Live 12 device names, specific parameter names, specific numeric values, and specific reasons tied back to the measurements that justify each recommendation. + +The chain of custody — from DSP measurement to cited recommendation — is what makes ASA different. Every recommendation must trace back to a number. Every number must come from the deterministic engine. Break this chain and you break the product. + +--- + +## Target User + +ASA serves **intermediate Ableton Live 12 producers** — people who: + +- Know what a compressor, EQ, and sidechain are, but may not know which specific Ableton device and parameter values would recreate the pumping they hear in a reference track. +- Can follow a step-by-step reconstruction plan if given one, but wouldn't independently derive the plan from raw spectral data. +- Use reference tracks as learning tools — not to copy, but to understand production techniques and internalize them. +- Trust numbers over vibes, but need the numbers translated into actions they can take in their DAW. + +This means: + +- Phase 1 output must be accurate and comprehensive enough to serve as ground truth. +- Phase 2 output must be specific enough that the user can open Ableton, create the recommended devices, dial in the recommended values, and hear something that moves toward the reference track's character. +- The UI must present results clearly enough that an intermediate producer doesn't need to be a DSP engineer to understand what they're looking at. + +--- + +## What "Good Output" Looks Like + +The ultimate quality test for any ASA feature is: **does this help the user recreate what they hear?** + +### Phase 1 (Measurement) Quality + +A good Phase 1 result: + +- Reports BPM that matches what the user hears when they tap along. If the algorithm detects half-time or double-time, it should still surface the most musically useful tempo. +- Reports a key that matches what the user hears when they play along on a keyboard. Low-confidence keys are flagged, not hidden. +- Reports loudness, dynamics, and stereo characteristics that the user can cross-reference against their own metering plugins and find consistent. +- Extracts groove timing, sidechain behavior, and spectral balance with enough resolution that Phase 2 can make non-generic recommendations. +- Detects arrangement structure well enough that the user can map sections to their own project timeline. + +A bad Phase 1 result: + +- Returns numbers that don't match what the user hears. (This erodes trust in the entire pipeline.) +- Returns fields that are technically populated but too coarse or noisy to distinguish one track from another. +- Adds new fields that look impressive in the JSON but don't feed into any user-facing recommendation. + +### Phase 2 (Interpretation) Quality + +A good Phase 2 result: + +- Names specific Ableton Live 12 devices (not generic concepts like "a compressor" — say "Glue Compressor" or "Compressor" with the specific mode). +- Specifies parameter values derived from measurements (not "set the attack to taste" — say "Attack: 10ms, informed by the measured crest factor of 8.2 dB indicating preserved transients"). +- Explains WHY each recommendation fits THIS track, citing the specific measurement. A user should be able to read a recommendation and think "that makes sense given the numbers." +- Covers the full reconstruction surface: kick/drums, bass, melodic/harmonic content, groove/timing, effects/texture, stereo field, and mastering chain. +- Acknowledges uncertainty honestly. Low-confidence measurements should produce hedged recommendations, not confident-sounding guesses. + +A bad Phase 2 result: + +- Gives generic production advice that could apply to any track in the genre ("use sidechain compression for house music"). +- Recommends devices or parameters without citing the specific measurement that justifies them. +- Ignores available measurements and relies on audio perception for things the DSP already measured. +- Pads output with filler to hit quantity targets without substance. + +--- + +## The Agent Guardrail: The User Value Test + +Before implementing any change, apply this test: + +### 1. Does this change improve measurement accuracy? + +If yes: it directly serves the mission. Prioritize it. + +Examples: improving BPM detection on half-time material, fixing key detection on modal content, adding a new detector that feeds into Phase 2 recommendations. + +### 2. Does this change improve the quality of recommendations the user receives? + +If yes: it directly serves the mission. Prioritize it. + +Examples: improving the Phase 2 prompt to produce more specific device recommendations, adding a new UI panel that surfaces measurements the user couldn't previously see, improving how Phase 2 handles low-confidence data. + +### 3. Does this change improve the user's ability to act on results? + +If yes: it directly serves the mission. Prioritize it. + +Examples: MIDI export improvements, better arrangement visualization, clearer confidence indicators, export-to-Ableton features. + +### 4. Does this change improve software quality without changing what the user sees? + +If yes: it's maintenance work. It has value, but it is not the mission. Do it when it unblocks user-facing work, not as an end in itself. + +Examples: refactoring internal types, improving test coverage on unchanged behavior, restructuring modules, upgrading dependencies that aren't broken. + +### 5. Does this change add engineering complexity without a clear path to user value? + +If yes: **stop and reconsider.** This is the drift pattern that degrades ASA over time. + +Examples: adding abstraction layers "for future flexibility" when no concrete future feature needs them, over-engineering error handling beyond what the user would notice, building infrastructure for hypothetical scale that doesn't exist. + +--- + +## Quality Invariants + +These are non-negotiable properties of ASA. Any change that violates one of these is a regression, regardless of what else it accomplishes. + +1. **Measurement authority.** Phase 1 DSP measurements are ground truth. Phase 2 AI interpretation must never override, re-estimate, or contradict a measured value. If audio perception contradicts a measurement, the system describes the contradiction — it does not silently pick a winner. + +2. **Citation chain.** Every Phase 2 recommendation must cite the specific Phase 1 measurement(s) that justify it. Recommendations without measurement justification are filler, not advice. + +3. **Ableton specificity.** Recommendations must name exact Ableton Live 12 devices, exact parameter names as they appear in the Ableton UI, and specific values. "Add some compression" is not a recommendation. "Glue Compressor → Attack: 10ms, Ratio: 4:1, Threshold: -18dB (informed by crest factor 8.2 dB)" is a recommendation. + +4. **Honest uncertainty.** Low-confidence measurements must propagate to hedged recommendations. The system must never present a guess with the same authority as a high-confidence measurement. The user's trust is the product's foundation. + +5. **Reconstruction completeness.** Phase 2 must cover the full production surface — not just the easy parts. Kick, bass, melodic content, groove, effects, stereo, and mastering must all be addressed. Partial coverage leaves the user with an incomplete blueprint. + +6. **Intermediate accessibility.** The user should not need to understand DSP theory to act on results. Numbers are translated into Ableton actions. Jargon is either avoided or explained in context. + +--- + +## Decision Framework for New Features + +When evaluating whether to add a new feature or capability: + +``` + ┌─────────────────────────────────┐ + │ Does the user get a better │ + │ reconstruction blueprint? │ + └──────────┬──────────────────────┘ + │ + ┌──────────▼──────────┐ + │ YES │──────────► BUILD IT + └─────────────────────┘ + │ NO / UNCLEAR + ┌──────────▼──────────────────────┐ + │ Does it make existing features │ + │ more accurate or actionable? │ + └──────────┬──────────────────────┘ + │ + ┌──────────▼──────────┐ + │ YES │──────────► BUILD IT + └─────────────────────┘ + │ NO / UNCLEAR + ┌──────────▼──────────────────────┐ + │ Does it unblock something that │ + │ WILL improve the blueprint? │ + └──────────┬──────────────────────┘ + │ + ┌──────────▼──────────┐ + │ YES │──────────► BUILD IT + └─────────────────────┘ + │ NO + ┌──────────▼──────────────────────┐ + │ STOP. This is engineering for │ + │ engineering's sake. Reconsider. │ + └─────────────────────────────────┘ +``` + +--- + +## Relationship to Other Documents + +- **CLAUDE.md**: How to build and test. Commands, architecture, contracts. Read PURPOSE.md first, CLAUDE.md second. +- **ARCHITECTURE.md** (`apps/backend/`): How the backend components interact. Implementation detail. +- **JSON_SCHEMA.md** (`apps/backend/`): What Phase 1 measures and how to interpret each field. The measurement inventory. +- **BACKLOG.md**: Candidate features from a prior project. Evaluate each against this document's decision framework before porting. +- **Phase 2 system prompt** (`apps/backend/prompts/phase2_system.txt`): The operational instructions for the AI interpreter. Must remain aligned with the quality invariants defined here. + +--- + +## Summary + +ASA is not a spectrum analyzer. ASA is not an AI music chatbot. ASA is the bridge between "I hear something" and "here's how to make it in Ableton Live 12" — built on a foundation of deterministic measurements that the user and the AI can both trust. + +Every line of code, every new feature, every refactor should make that bridge sturdier, more specific, and more useful to an intermediate producer sitting in front of Ableton with a reference track they want to learn from. diff --git a/apps/backend/CODEX.md b/apps/backend/CODEX.md new file mode 100644 index 00000000..aee27b78 --- /dev/null +++ b/apps/backend/CODEX.md @@ -0,0 +1,46 @@ +# CODEX.md + +Codex instructions for `apps/backend`. + +## Source Mapping + +- Product intent and quality bar: `../../PURPOSE.md` and `../../ASA_System_Design.docx`. +- Repo and app policy: `../../AGENTS.md` and `./AGENTS.md`. +- Runtime command details and additional guardrails: `../../CLAUDE.md`. + +When guidance differs, keep mission and quality invariants from `PURPOSE.md` and the system design document as the primary decision filter. + +## Backend Mission In This Repo + +- Keep deterministic measurement quality high and trustworthy. +- Preserve the chain of custody from Phase 1 metrics to Phase 2 advice. +- Protect producer-facing reliability over internal abstraction complexity. + +## Contract-Critical Rules + +- `analyze.py` emits machine-readable JSON to `stdout`; diagnostics/logs go to `stderr`. +- `server.py` normalizes raw analyzer output into stable HTTP envelopes for UI consumption. +- Treat backend output shape as contract; update tests/docs with any intentional schema change. +- Keep Phase 1 measurement authority intact; never add behavior that lets Phase 2 override measured values. + +## Canonical Commands + +```bash +./scripts/bootstrap.sh +./venv/bin/python server.py +./venv/bin/python analyze.py [--separate] [--transcribe] [--fast] [--yes] +./venv/bin/python -m unittest discover -s tests +``` + +Preferred synced stack from repo root: + +```bash +./scripts/dev.sh +``` + +## Codex Change Checklist + +- If request parsing, subprocess behavior, or envelopes change: run `tests/test_server.py` or broader. +- If raw analyzer output changes: run `tests/test_analyze.py` and sync docs. +- Preserve bounded diagnostics and structured error responses. +- Keep edits surgical unless an explicit broader refactor is requested. diff --git a/apps/backend/tests/test_analyze.py b/apps/backend/tests/test_analyze.py index a6c368c9..34a4a285 100644 --- a/apps/backend/tests/test_analyze.py +++ b/apps/backend/tests/test_analyze.py @@ -1174,6 +1174,35 @@ def test_ambient_signature_scores_high(self): self.assertIsNotNone(detail) self.assertIn(detail["genreFamily"], ("ambient", "other")) + def test_dense_techno_145bpm_boundary(self): + """145 BPM with dense onsets and punchy bass should classify as techno or trance family. + + At 145 BPM the classifier sits on the techno/trance boundary. + Dense onsets + punchy bass push toward techno variants, but BPM + alone can tip into trance. Both families are valid at this boundary. + """ + result = self.analyze.analyze_genre_detail(self._make_result( + bpm=145.0, + crestFactor=8.5, + spectralBalance={"subBass": -10.0}, + spectralDetail={"spectralCentroid": 3200.0}, + rhythmDetail={"onsetRate": 12.0}, + sidechainDetail={"pumpingStrength": 0.4}, + bassDetail={"averageDecayMs": 80.0}, + )) + detail = result["genreDetail"] + self.assertIsNotNone(detail) + self.assertIn(detail["genreFamily"], ("techno", "trance")) + # Top scores should include techno-family genres + top_genres = [e["genre"] for e in detail["topScores"]] + techno_variants = {"techno", "industrial-techno", "hard-techno"} + self.assertTrue( + techno_variants & set(top_genres), + f"Expected at least one techno variant in top scores, got {top_genres}", + ) + top_score = detail["topScores"][0]["score"] + self.assertGreater(top_score, 0.25) + if __name__ == "__main__": unittest.main() diff --git a/apps/ui/CODEX.md b/apps/ui/CODEX.md new file mode 100644 index 00000000..fa54e149 --- /dev/null +++ b/apps/ui/CODEX.md @@ -0,0 +1,50 @@ +# CODEX.md + +Codex instructions for `apps/ui`. + +## Source Mapping + +- Product intent and quality bar: `../../PURPOSE.md` and `../../ASA_System_Design.docx`. +- Repo and app policy: `../../AGENTS.md` and `./AGENTS.md`. +- Runtime command details and additional guardrails: `../../CLAUDE.md`. + +When guidance differs, keep mission and quality invariants from `PURPOSE.md` and the system design document as the primary decision filter. + +## UI Mission In This Repo + +- Present deterministic Phase 1 measurements clearly and faithfully. +- Present Phase 2 interpretation as measurement-cited Ableton reconstruction guidance. +- Improve producer actionability over visual novelty. + +## Contract-Critical Rules + +- Preserve backend client and shared type contracts in: + - `src/services/backendPhase1Client.ts` + - `src/types.ts` +- Do not silently rename fields expected by backend envelopes. +- Keep diagnostics behavior stable unless intentionally changing contract + tests/docs together. +- Respect the Phase 1 ground-truth model when rendering or explaining results. + +## Canonical Commands + +```bash +npm run dev +npm run dev:local +npm run lint +npm run test:unit +npm run test:smoke +npm run verify +``` + +Preferred synced stack from repo root: + +```bash +./scripts/dev.sh +``` + +## Codex Change Checklist + +- Run focused tests first (single file/spec), then broaden as needed. +- If editing upload/orchestration/rendering flow, run relevant smoke specs. +- If editing shared types or transport parsing, run lint + targeted service tests. +- Avoid style-only churn in mixed-style files. diff --git a/apps/ui/package-lock.json b/apps/ui/package-lock.json index 235754cf..781fe736 100644 --- a/apps/ui/package-lock.json +++ b/apps/ui/package-lock.json @@ -9,7 +9,6 @@ "version": "1.6.0", "license": "MIT", "dependencies": { - "@google/genai": "^1.29.0", "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", "lucide-react": "^0.546.0", @@ -710,46 +709,6 @@ "node": ">=18" } }, - "node_modules/@google/genai": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.43.0.tgz", - "integrity": "sha512-hklCsJNdMlDM1IwcCVcGQFBg2izY0+t5BIGbRsxi2UnKi6AGKL7pqJqmBDNRbw0bYCs4y3NA7TB+fkKfP/Nrdw==", - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -795,16 +754,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -821,70 +770,6 @@ "node": ">=18" } }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1573,17 +1458,12 @@ "version": "22.19.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT" - }, "node_modules/@vitejs/plugin-react": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", @@ -1715,39 +1595,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1795,32 +1642,6 @@ "postcss": "^8.1.0" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -1833,24 +1654,6 @@ "node": ">=6.0.0" } }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -1884,12 +1687,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, "node_modules/caniuse-lite": { "version": "1.0.30001775", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", @@ -1920,53 +1717,12 @@ "node": ">=18" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1993,33 +1749,12 @@ "node": ">=8" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.302", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "license": "ISC" }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -2111,12 +1846,6 @@ "node": ">=12.0.0" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2134,57 +1863,6 @@ } } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -2240,35 +1918,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/gaxios": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", - "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2", - "rimraf": "^5.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2291,102 +1940,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/google-auth-library": { - "version": "10.6.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", - "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "7.1.3", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -2414,15 +1973,6 @@ "node": ">=6" } }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2435,27 +1985,6 @@ "node": ">=6" } }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -2705,12 +2234,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2748,30 +2271,6 @@ "@tonaljs/midi": "^4.9.0" } }, - "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/motion": { "version": "12.35.0", "resolved": "https://registry.npmjs.org/motion/-/motion-12.35.0.tgz", @@ -2837,44 +2336,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -2892,56 +2353,6 @@ ], "license": "MIT" }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3049,30 +2460,6 @@ "dev": true, "license": "MIT" }, - "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -3113,30 +2500,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -3181,26 +2544,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3216,27 +2559,6 @@ "semver": "bin/semver.js" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3244,18 +2566,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3279,102 +2589,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", @@ -3481,6 +2695,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -4128,30 +3343,6 @@ "integrity": "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg==", "license": "BSD-3-Clause" }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4169,118 +3360,6 @@ "node": ">=8" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/apps/ui/src/components/AnalysisResults.tsx b/apps/ui/src/components/AnalysisResults.tsx index 8479ab92..8e9c9ed1 100644 --- a/apps/ui/src/components/AnalysisResults.tsx +++ b/apps/ui/src/components/AnalysisResults.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from 'react'; -import { MeasurementResult, Phase1Result, Phase2Result, TranscriptionDetail, type GenreProfile } from '../types'; +import { MeasurementResult, Phase1Result, Phase2Result, TranscriptionDetail, type GenreProfile, type MixDoctorReport } from '../types'; import { MixDoctorPanel } from './MixDoctorPanel'; +import { generateMixReport, findProfileByIdOrFamily } from '../services/mixDoctor'; import genreProfilesData from '../data/genreProfiles.json'; import { Activity, @@ -180,20 +181,39 @@ export function AnalysisResults({ const [openMix, setOpenMix] = useState>({}); const [openPatch, setOpenPatch] = useState>({}); const [showSources, setShowSources] = useState>({}); + const [selectedProfileId, setSelectedProfileId] = useState(null); const sessionId = useMemo(() => new Date().getTime().toString(36).toUpperCase(), []); + const profiles = genreProfilesData as GenreProfile[]; + const gd = measurement?.genreDetail; + const autoGenreId = gd && gd.confidence >= 0.6 ? gd.genre : null; + const autoGenreFamily = gd ? gd.genreFamily : null; + const autoProfileId = useMemo( + () => findProfileByIdOrFamily(profiles, autoGenreId, autoGenreFamily), + [profiles, autoGenreId, autoGenreFamily], + ); + const activeProfileId = selectedProfileId ?? autoProfileId ?? null; + const activeProfile = profiles.find(p => p.id === activeProfileId) ?? null; + const mixDoctorReport: MixDoctorReport | null = useMemo( + () => (activeProfile && measurement) ? generateMixReport(measurement, activeProfile) : null, + [measurement, activeProfile], + ); + if (!measurement) return null; const handleExportJSON = () => { const phase1ForExport: Phase1Result = symbolic ? { ...measurement, transcriptionDetail: symbolic } : measurement; - const data = { + const data: Record = { phase1: phase1ForExport, phase2, exportedAt: new Date().toISOString(), }; + if (mixDoctorReport) { + data.mixDoctorReport = mixDoctorReport; + } downloadFile(JSON.stringify(data, null, 2), 'track-analysis.json', 'application/json'); }; @@ -201,7 +221,7 @@ export function AnalysisResults({ const phase1ForExport: Phase1Result = symbolic ? { ...measurement, transcriptionDetail: symbolic } : measurement; - const markdown = generateMarkdown(phase1ForExport, phase2, phase2StatusMessage); + const markdown = generateMarkdown(phase1ForExport, phase2, phase2StatusMessage, mixDoctorReport); downloadFile(markdown, 'track-analysis.md', 'text/markdown'); }; @@ -534,10 +554,14 @@ export function AnalysisResults({ const oddToEven = typeof sc.oddToEvenRatio === 'number' ? sc.oddToEvenRatio : null; if (inharmonicity === null && oddToEven === null) return null; const synthLabel = inharmonicity !== null - ? inharmonicity > 0.2 ? 'FM / NOISE' : 'CLEAN HARMONIC' + ? inharmonicity > 0.25 ? 'WAVETABLE / NOISE' + : inharmonicity >= 0.10 ? 'FM / ACID' + : 'CLEAN SUBTRACTIVE' : null; const waveLabel = oddToEven !== null - ? oddToEven < 1.0 ? 'Sine / Triangle' : 'Square / Saw' + ? oddToEven > 1.5 ? 'Saw / Square' + : oddToEven < 0.8 ? 'Sine / Triangle' + : 'Mixed Harmonics' : null; return (
@@ -612,21 +636,16 @@ export function AnalysisResults({ ); })()} - {(() => { - const profiles = genreProfilesData as GenreProfile[]; - if (profiles.length === 0) return null; - const gd = measurement.genreDetail; - const autoGenreId = gd && gd.confidence >= 0.6 ? gd.genre : null; - const autoGenreFamily = gd ? gd.genreFamily : null; - return ( - - ); - })()} + {profiles.length > 0 && ( + + )}
diff --git a/apps/ui/src/components/MixDoctorPanel.tsx b/apps/ui/src/components/MixDoctorPanel.tsx index 9ddcba3f..14e5b855 100644 --- a/apps/ui/src/components/MixDoctorPanel.tsx +++ b/apps/ui/src/components/MixDoctorPanel.tsx @@ -1,30 +1,12 @@ -import { useState, useMemo } from 'react'; -import type { Phase1Result, GenreProfile } from '../types'; -import { generateMixReport, type MixDoctorReport } from '../services/mixDoctor'; +import type { GenreProfile, MixDoctorReport } from '../types'; interface MixDoctorPanelProps { - measurement: Phase1Result; + report: MixDoctorReport | null; profiles: GenreProfile[]; + activeProfileId: string | null; + autoProfileId: string | null; autoGenreId: string | null; - autoGenreFamily: string | null; -} - -function findProfileByIdOrFamily( - profiles: GenreProfile[], - genreId: string | null, - genreFamily: string | null, -): string | null { - if (genreId) { - const exact = profiles.find(p => p.id === genreId); - if (exact) return exact.id; - } - if (genreFamily) { - const familyMatch = profiles.find(p => - p.id === genreFamily || p.name.toLowerCase().includes(genreFamily), - ); - if (familyMatch) return familyMatch.id; - } - return null; + onProfileChange: (id: string | null) => void; } function scoreColor(score: number): string { @@ -51,32 +33,14 @@ function issueBarColor(issue: string): string { return 'bg-amber-400/40'; } -export function MixDoctorPanel({ measurement, profiles, autoGenreId, autoGenreFamily }: MixDoctorPanelProps) { - const autoProfileId = useMemo( - () => findProfileByIdOrFamily(profiles, autoGenreId, autoGenreFamily), - [profiles, autoGenreId, autoGenreFamily], - ); - - const [selectedId, setSelectedId] = useState(null); - const activeId = selectedId ?? autoProfileId ?? profiles[0]?.id ?? null; - const profile = profiles.find(p => p.id === activeId); - - const report: MixDoctorReport | null = useMemo(() => { - if (!profile) return null; - return generateMixReport(measurement, profile); - }, [measurement, profile]); - - if (!report || !profile) return null; - - const maxAbsDiff = Math.max(...report.advice.map(a => Math.abs(a.diffDb)), 1); - - return ( -
-
-

- - Mix Doctor -

+export function MixDoctorPanel({ report, profiles, activeProfileId, autoProfileId, autoGenreId, onProfileChange }: MixDoctorPanelProps) { + const header = ( +
+

+ + Mix Doctor +

+ {report && (
PHASE 1 @@ -86,32 +50,57 @@ export function MixDoctorPanel({ measurement, profiles, autoGenreId, autoGenreFa /100
-
+ )} +
+ ); - {/* Profile selector */} -
- - - {autoProfileId && ( - - {autoGenreId ? 'Genre-matched' : 'Family fallback'} - + const selector = ( +
+ + + {autoProfileId && ( + + {autoGenreId ? 'Genre-matched' : 'Family fallback'} + + )} +
+ ); + + if (!report || !activeProfileId) { + return ( +
+ {header} + {selector} +

+ Select a genre profile above to see mix analysis +

+ ); + } + + const maxAbsDiff = Math.max(...report.advice.map(a => Math.abs(a.diffDb)), 1); + + return ( +
+ {header} + {selector} {/* Spectral comparison */}
-

Spectral Balance vs {profile.name}

+

Spectral Balance vs {report.genreName}

{report.advice.map(a => { const pct = (Math.abs(a.diffDb) / maxAbsDiff) * 100; const isRight = a.diffDb >= 0; @@ -167,6 +156,23 @@ export function MixDoctorPanel({ measurement, profiles, autoGenreId, autoGenreFa

+ {/* PLR */} + {report.plrAdvice && ( +
+

PLR

+

+ {report.plrAdvice.issue === 'optimal' ? 'ON TARGET' : + report.plrAdvice.issue === 'too-crushed' ? 'CRUSHED' : 'VERY OPEN'} +

+

+ PLR: {report.plrAdvice.actualPlr} dB +

+
+ )} + {/* Loudness */} {report.loudnessAdvice && (
diff --git a/apps/ui/src/services/backendPhase1Client.ts b/apps/ui/src/services/backendPhase1Client.ts index bf892f49..34152e2d 100644 --- a/apps/ui/src/services/backendPhase1Client.ts +++ b/apps/ui/src/services/backendPhase1Client.ts @@ -480,9 +480,28 @@ function parseOptionalBackendTimings(value: unknown): BackendTimingDiagnostics | export function parsePhase1Result(value: unknown): Phase1Result { const phase1 = expectRecord(value, "phase1"); const spectralBalance = expectRecord(phase1.spectralBalance, "phase1.spectralBalance"); + const stereoDetail = isRecord(phase1.stereoDetail) ? phase1.stereoDetail : null; const melodyDetail = parseOptionalMelodyDetail(phase1); const transcriptionDetail = parseOptionalTranscriptionDetail(phase1); + // Compatibility: canonical analysis-run snapshots may only expose stereo + // metrics under stereoDetail, while legacy envelopes also include top-level + // stereoWidth/stereoCorrelation fields. + const stereoWidth = expectNumberWithFallback( + phase1, + "stereoWidth", + "stereoWidth", + stereoDetail, + "stereoWidth", + ); + const stereoCorrelation = expectNumberWithFallback( + phase1, + "stereoCorrelation", + "stereoCorrelation", + stereoDetail, + "stereoCorrelation", + ); + return { bpm: expectNumber(phase1, "bpm"), bpmConfidence: expectNumber(phase1, "bpmConfidence"), @@ -494,9 +513,9 @@ export function parsePhase1Result(value: unknown): Phase1Result { lufsRange: toNumber(phase1.lufsRange), truePeak: expectNumber(phase1, "truePeak"), crestFactor: toNumber(phase1.crestFactor), - stereoWidth: expectNumber(phase1, "stereoWidth"), - stereoCorrelation: expectNumber(phase1, "stereoCorrelation"), - stereoDetail: isRecord(phase1.stereoDetail) ? phase1.stereoDetail : null, + stereoWidth, + stereoCorrelation, + stereoDetail, spectralBalance: { subBass: expectNumber(spectralBalance, "subBass", "spectralBalance.subBass"), lowBass: expectNumber(spectralBalance, "lowBass", "spectralBalance.lowBass"), @@ -970,6 +989,26 @@ function expectNumber(record: UnknownRecord, key: string, label = key): number { return value; } +function expectNumberWithFallback( + primary: UnknownRecord, + primaryKey: string, + label: string, + fallback: UnknownRecord | null, + fallbackKey: string, +): number { + const primaryValue = primary[primaryKey]; + if (typeof primaryValue === "number" && !Number.isNaN(primaryValue)) { + return primaryValue; + } + if (fallback) { + const fallbackValue = fallback[fallbackKey]; + if (typeof fallbackValue === "number" && !Number.isNaN(fallbackValue)) { + return fallbackValue; + } + } + throw new Error(`Expected ${label} to be a number.`); +} + function expectOptionalNumber(record: UnknownRecord, key: string): number | null { const value = record[key]; if (value === undefined || value === null) return null; diff --git a/apps/ui/src/services/mixDoctor.ts b/apps/ui/src/services/mixDoctor.ts index 9e7a5df9..7fd8840f 100644 --- a/apps/ui/src/services/mixDoctor.ts +++ b/apps/ui/src/services/mixDoctor.ts @@ -1,40 +1,22 @@ -import type { Phase1Result, GenreProfile, SpectralTarget } from '../types'; +import type { Phase1Result, GenreProfile, SpectralTarget, PlrAdvice, MixAdvice, DynamicsAdvice, LoudnessAdvice, StereoAdvice, MixDoctorReport } from '../types'; +export type { MixAdvice, MixDoctorReport } from '../types'; -export interface MixAdvice { - band: string; - issue: 'optimal' | 'too-loud' | 'too-quiet'; - message: string; - diffDb: number; -} - -export interface DynamicsAdvice { - issue: 'too-compressed' | 'too-dynamic' | 'optimal'; - message: string; - actualCrest: number; -} - -export interface LoudnessAdvice { - issue: 'too-loud' | 'too-quiet' | 'optimal'; - message: string; - actualLufs: number; - truePeak: number; -} - -export interface StereoAdvice { - correlation: number; - width: number; - monoCompatible: boolean; - message: string; -} - -export interface MixDoctorReport { - genreName: string; - profileId: string; - advice: MixAdvice[]; - dynamicsAdvice: DynamicsAdvice; - loudnessAdvice: LoudnessAdvice | undefined; - stereoAdvice: StereoAdvice | undefined; - overallScore: number; +export function findProfileByIdOrFamily( + profiles: GenreProfile[], + genreId: string | null, + genreFamily: string | null, +): string | null { + if (genreId) { + const exact = profiles.find(p => p.id === genreId); + if (exact) return exact.id; + } + if (genreFamily) { + const familyMatch = profiles.find(p => + p.id === genreFamily || p.name.toLowerCase().includes(genreFamily), + ); + if (familyMatch) return familyMatch.id; + } + return null; } function median(values: number[]): number { @@ -157,7 +139,7 @@ export function generateMixReport( advice.push({ band: label, issue, message, diffDb: Math.round(diffToOptimal * 10) / 10 }); } - // Evaluate dynamics (crest factor — ASA doesn't expose PLR) + // Evaluate dynamics (crest factor) let dynamicsIssue: DynamicsAdvice['issue'] = 'optimal'; let dynamicsMsg = 'Solid dynamic range. Fits the genre well.'; let dynamicsPenalty = 0; @@ -174,6 +156,34 @@ export function generateMixReport( dynamicsPenalty = Math.min(15, (crest - maxCrest) * 2.5); } + // Evaluate PLR (Peak-to-Loudness Ratio) + let plrAdvice: PlrAdvice | undefined; + let plrPenalty = 0; + const plr = phase1.truePeak - phase1.lufsIntegrated; + const [minPlr, maxPlr] = profile.targetPlrRange; + + if (plr < minPlr) { + plrAdvice = { + issue: 'too-crushed', + message: `PLR ${plr.toFixed(1)} dB is below ${minPlr}–${maxPlr} dB target for ${profile.name}. Peak headroom is too tight — reduce limiting to restore transient definition.`, + actualPlr: Math.round(plr * 10) / 10, + }; + plrPenalty = Math.min(10, (minPlr - plr) * 2); + } else if (plr > maxPlr) { + plrAdvice = { + issue: 'too-open', + message: `PLR ${plr.toFixed(1)} dB exceeds ${minPlr}–${maxPlr} dB target for ${profile.name}. Plenty of headroom — you could push the limiter harder if needed.`, + actualPlr: Math.round(plr * 10) / 10, + }; + plrPenalty = Math.min(10, (plr - maxPlr) * 2); + } else { + plrAdvice = { + issue: 'optimal', + message: `PLR ${plr.toFixed(1)} dB is within the ${minPlr}–${maxPlr} dB target. Good transient-to-loudness balance.`, + actualPlr: Math.round(plr * 10) / 10, + }; + } + // Evaluate LUFS loudness let loudnessAdvice: LoudnessAdvice | undefined; let loudnessPenalty = 0; @@ -236,6 +246,7 @@ export function generateMixReport( // Final score let overallScore = bandsEvaluated > 0 ? scoreAccumulator / bandsEvaluated : 0; overallScore -= dynamicsPenalty; + overallScore -= plrPenalty; overallScore -= loudnessPenalty; overallScore -= stereoPenalty; overallScore = Math.round(Math.max(0, Math.min(100, overallScore))); @@ -249,6 +260,7 @@ export function generateMixReport( message: dynamicsMsg, actualCrest: Math.round(crest * 10) / 10, }, + plrAdvice, loudnessAdvice, stereoAdvice, overallScore, diff --git a/apps/ui/src/types.ts b/apps/ui/src/types.ts index 3f7b8427..24f522dc 100644 --- a/apps/ui/src/types.ts +++ b/apps/ui/src/types.ts @@ -180,6 +180,50 @@ export interface GenreProfile { }; } +export interface MixAdvice { + band: string; + issue: 'optimal' | 'too-loud' | 'too-quiet'; + message: string; + diffDb: number; +} + +export interface DynamicsAdvice { + issue: 'too-compressed' | 'too-dynamic' | 'optimal'; + message: string; + actualCrest: number; +} + +export interface PlrAdvice { + issue: 'too-crushed' | 'too-open' | 'optimal'; + message: string; + actualPlr: number; +} + +export interface LoudnessAdvice { + issue: 'too-loud' | 'too-quiet' | 'optimal'; + message: string; + actualLufs: number; + truePeak: number; +} + +export interface StereoAdvice { + correlation: number; + width: number; + monoCompatible: boolean; + message: string; +} + +export interface MixDoctorReport { + genreName: string; + profileId: string; + advice: MixAdvice[]; + dynamicsAdvice: DynamicsAdvice; + plrAdvice?: PlrAdvice; + loudnessAdvice: LoudnessAdvice | undefined; + stereoAdvice: StereoAdvice | undefined; + overallScore: number; +} + export type RecommendationCategory = | "SYNTHESIS" | "DYNAMICS" diff --git a/apps/ui/src/utils/exportUtils.ts b/apps/ui/src/utils/exportUtils.ts index 5ecfe675..f5de7102 100644 --- a/apps/ui/src/utils/exportUtils.ts +++ b/apps/ui/src/utils/exportUtils.ts @@ -1,6 +1,4 @@ -import { Phase1Result, Phase2Result, type GenreProfile } from '../types'; -import { generateMixReport, type MixDoctorReport } from '../services/mixDoctor'; -import genreProfilesData from '../data/genreProfiles.json'; +import { Phase1Result, Phase2Result, type MixDoctorReport } from '../types'; export function downloadFile(content: string, fileName: string, contentType: string) { const a = document.createElement('a'); @@ -56,6 +54,11 @@ function formatMixDoctorMarkdown(report: MixDoctorReport): string { md += `### Dynamics\n- ${report.dynamicsAdvice.message}\n`; md += `- Crest Factor: ${report.dynamicsAdvice.actualCrest} dB\n\n`; + if (report.plrAdvice) { + md += `### PLR (Peak-to-Loudness)\n- ${report.plrAdvice.message}\n`; + md += `- PLR: ${report.plrAdvice.actualPlr} dB\n\n`; + } + if (report.loudnessAdvice) { md += `### Loudness\n- ${report.loudnessAdvice.message}\n`; md += `- LUFS: ${report.loudnessAdvice.actualLufs} / True Peak: ${report.loudnessAdvice.truePeak} dBTP\n\n`; @@ -73,6 +76,7 @@ export function generateMarkdown( phase1: Phase1Result, phase2: Phase2Result | null, phase2StatusMessage: string | null = null, + mixDoctorReport: MixDoctorReport | null = null, ): string { let md = '# Track Analysis Report\n\n'; @@ -96,16 +100,17 @@ export function generateMarkdown( md += `- **Highs**: ${phase1.spectralBalance.highs}\n`; md += `- **Brilliance**: ${phase1.spectralBalance.brilliance}\n\n`; - // Mix Doctor section (derived analysis, not measurement) - const profiles = genreProfilesData as GenreProfile[]; - const gd = phase1.genreDetail; - const autoId = gd && gd.confidence >= 0.6 ? gd.genre : null; - const familyId = gd ? gd.genreFamily : null; - const profileId = autoId ?? familyId ?? profiles[0]?.id; - const profile = profiles.find(p => p.id === profileId); - if (profile) { - const report = generateMixReport(phase1, profile); - md += formatMixDoctorMarkdown(report); + if (phase1.dynamicCharacter) { + md += '### Dynamic Character\n'; + md += `- **Dynamic Complexity**: ${phase1.dynamicCharacter.dynamicComplexity}\n`; + md += `- **Loudness Variation**: ${phase1.dynamicCharacter.loudnessVariation}\n`; + md += `- **Spectral Flatness**: ${phase1.dynamicCharacter.spectralFlatness}\n`; + md += `- **Log Attack Time**: ${phase1.dynamicCharacter.logAttackTime}\n`; + md += `- **Attack Time Std Dev**: ${phase1.dynamicCharacter.attackTimeStdDev}\n\n`; + } + + if (mixDoctorReport) { + md += formatMixDoctorMarkdown(mixDoctorReport); } if (!phase2) { diff --git a/apps/ui/tests/services/exportUtils.test.ts b/apps/ui/tests/services/exportUtils.test.ts index bf98060a..0623cfcc 100644 --- a/apps/ui/tests/services/exportUtils.test.ts +++ b/apps/ui/tests/services/exportUtils.test.ts @@ -138,4 +138,61 @@ describe('generateMarkdown', () => { expect(markdown).not.toContain('- **Harmonic Content**:'); expect(markdown).not.toContain('undefined'); }); + + it('markdown includes MixDoctor section when report provided', () => { + const report: import('../../src/types').MixDoctorReport = { + genreName: 'Techno', + profileId: 'techno', + advice: [{ band: 'Sub Bass', issue: 'optimal', message: 'Balanced.', diffDb: 0.2 }], + dynamicsAdvice: { issue: 'optimal', message: 'Solid dynamic range.', actualCrest: 8 }, + plrAdvice: { issue: 'optimal', message: 'PLR 7.0 dB is within target.', actualPlr: 7 }, + loudnessAdvice: { issue: 'optimal', message: 'On target.', actualLufs: -7.9, truePeak: -0.2 }, + stereoAdvice: { correlation: 0.84, width: 0.69, monoCompatible: true, message: 'OK' }, + overallScore: 82, + }; + const markdown = generateMarkdown(basePhase1, null, null, report); + expect(markdown).toContain('## Mix Doctor'); + expect(markdown).toContain('Overall Score: 82/100'); + }); + + it('markdown omits MixDoctor when report is null', () => { + const markdown = generateMarkdown(basePhase1, null, null, null); + expect(markdown).not.toContain('## Mix Doctor'); + }); + + it('markdown includes PLR section when plrAdvice present', () => { + const report: import('../../src/types').MixDoctorReport = { + genreName: 'House', + profileId: 'house', + advice: [], + dynamicsAdvice: { issue: 'optimal', message: 'OK', actualCrest: 8 }, + plrAdvice: { issue: 'too-crushed', message: 'PLR 4.0 dB is below target.', actualPlr: 4 }, + loudnessAdvice: undefined, + stereoAdvice: undefined, + overallScore: 70, + }; + const markdown = generateMarkdown(basePhase1, null, null, report); + expect(markdown).toContain('### PLR'); + expect(markdown).toContain('PLR: 4 dB'); + }); + + it('markdown includes Dynamic Character when present', () => { + const phase1WithDynChar = { + ...basePhase1, + dynamicCharacter: { + dynamicComplexity: 0.72, + loudnessVariation: 3.41, + spectralFlatness: 0.18, + logAttackTime: -1.23, + attackTimeStdDev: 0.45, + }, + }; + const markdown = generateMarkdown(phase1WithDynChar, null); + expect(markdown).toContain('### Dynamic Character'); + expect(markdown).toContain('Dynamic Complexity'); + expect(markdown).toContain('Loudness Variation'); + expect(markdown).toContain('Spectral Flatness'); + expect(markdown).toContain('Log Attack Time'); + expect(markdown).toContain('Attack Time Std Dev'); + }); }); diff --git a/apps/ui/tests/services/mixDoctor.test.ts b/apps/ui/tests/services/mixDoctor.test.ts new file mode 100644 index 00000000..22bed5ae --- /dev/null +++ b/apps/ui/tests/services/mixDoctor.test.ts @@ -0,0 +1,98 @@ +import { generateMixReport } from '../../src/services/mixDoctor'; +import type { Phase1Result, GenreProfile } from '../../src/types'; + +const basePhase1: Phase1Result = { + bpm: 128, + bpmConfidence: 0.9, + key: 'A minor', + keyConfidence: 0.8, + timeSignature: '4/4', + durationSeconds: 300, + lufsIntegrated: -8.0, + truePeak: -1.0, + crestFactor: 9.0, + stereoWidth: 0.6, + stereoCorrelation: 0.85, + spectralBalance: { + subBass: -18.0, + lowBass: -12.0, + mids: -8.0, + upperMids: -10.0, + highs: -14.0, + brilliance: -20.0, + }, +}; + +const testProfile: GenreProfile = { + id: 'test-edm', + name: 'Test EDM', + targetCrestFactorRange: [6, 12], + targetPlrRange: [6, 10], + targetLufsRange: [-10, -6], + spectralTargets: { + subBass: { minDb: -22, maxDb: -14, optimalDb: -18 }, + lowBass: { minDb: -16, maxDb: -8, optimalDb: -12 }, + lowMids: { minDb: -12, maxDb: -4, optimalDb: -8 }, + mids: { minDb: -12, maxDb: -4, optimalDb: -8 }, + upperMids: { minDb: -14, maxDb: -6, optimalDb: -10 }, + highs: { minDb: -18, maxDb: -10, optimalDb: -14 }, + brilliance: { minDb: -24, maxDb: -16, optimalDb: -20 }, + }, +}; + +describe('generateMixReport', () => { + it('deterministic scoring — same input produces same overallScore', () => { + const r1 = generateMixReport(basePhase1, testProfile); + const r2 = generateMixReport(basePhase1, testProfile); + expect(r1.overallScore).toBe(r2.overallScore); + }); + + it('PLR below range penalizes — too-crushed', () => { + // truePeak -3, lufs -8 => PLR = 5, below [6,10] + const phase1 = { ...basePhase1, truePeak: -3.0, lufsIntegrated: -8.0 }; + const report = generateMixReport(phase1, testProfile); + expect(report.plrAdvice).toBeDefined(); + expect(report.plrAdvice!.issue).toBe('too-crushed'); + }); + + it('PLR above range penalizes — too-open', () => { + // truePeak 0, lufs -12 => PLR = 12, above [6,10] + const phase1 = { ...basePhase1, truePeak: 0, lufsIntegrated: -12.0 }; + const report = generateMixReport(phase1, testProfile); + expect(report.plrAdvice).toBeDefined(); + expect(report.plrAdvice!.issue).toBe('too-open'); + }); + + it('PLR in range — optimal', () => { + // truePeak -1, lufs -8 => PLR = 7, within [6,10] + const phase1 = { ...basePhase1, truePeak: -1.0, lufsIntegrated: -8.0 }; + const report = generateMixReport(phase1, testProfile); + expect(report.plrAdvice).toBeDefined(); + expect(report.plrAdvice!.issue).toBe('optimal'); + }); + + it('crest below range — too-compressed', () => { + const phase1 = { ...basePhase1, crestFactor: 4.0 }; + const report = generateMixReport(phase1, testProfile); + expect(report.dynamicsAdvice.issue).toBe('too-compressed'); + }); + + it('overall score bounded 0-100', () => { + // Extreme values + const extremePhase1 = { + ...basePhase1, + crestFactor: 1.0, + truePeak: 0, + lufsIntegrated: -2.0, + stereoCorrelation: 0.05, + stereoWidth: 0.95, + spectralBalance: { + subBass: 10, lowBass: 10, mids: 10, + upperMids: 10, highs: 10, brilliance: 10, + }, + }; + const report = generateMixReport(extremePhase1, testProfile); + expect(report.overallScore).toBeGreaterThanOrEqual(0); + expect(report.overallScore).toBeLessThanOrEqual(100); + }); +}); diff --git a/apps/ui/tests/services/mixDoctorPanel.test.ts b/apps/ui/tests/services/mixDoctorPanel.test.ts new file mode 100644 index 00000000..372e6d4a --- /dev/null +++ b/apps/ui/tests/services/mixDoctorPanel.test.ts @@ -0,0 +1,88 @@ +import type { MixDoctorReport, GenreProfile } from '../../src/types'; +import { findProfileByIdOrFamily } from '../../src/services/mixDoctor'; + +const profileA: GenreProfile = { + id: 'techno', + name: 'Techno', + targetCrestFactorRange: [5, 10], + targetPlrRange: [6, 10], + targetLufsRange: [-9, -5], + spectralTargets: { + subBass: { minDb: -22, maxDb: -14, optimalDb: -18 }, + lowBass: { minDb: -16, maxDb: -8, optimalDb: -12 }, + lowMids: { minDb: -12, maxDb: -4, optimalDb: -8 }, + mids: { minDb: -12, maxDb: -4, optimalDb: -8 }, + upperMids: { minDb: -14, maxDb: -6, optimalDb: -10 }, + highs: { minDb: -18, maxDb: -10, optimalDb: -14 }, + brilliance: { minDb: -24, maxDb: -16, optimalDb: -20 }, + }, +}; + +const profileB: GenreProfile = { + id: 'house', + name: 'House', + targetCrestFactorRange: [7, 12], + targetPlrRange: [7, 11], + targetLufsRange: [-10, -6], + spectralTargets: { + subBass: { minDb: -20, maxDb: -12, optimalDb: -16 }, + lowBass: { minDb: -14, maxDb: -6, optimalDb: -10 }, + lowMids: { minDb: -10, maxDb: -2, optimalDb: -6 }, + mids: { minDb: -10, maxDb: -2, optimalDb: -6 }, + upperMids: { minDb: -12, maxDb: -4, optimalDb: -8 }, + highs: { minDb: -16, maxDb: -8, optimalDb: -12 }, + brilliance: { minDb: -22, maxDb: -14, optimalDb: -18 }, + }, +}; + +const profiles = [profileA, profileB]; + +describe('MixDoctorPanel controlled behavior', () => { + it('null genreDetail results in null autoProfileId, requiring manual selection', () => { + // When genreDetail is null, findProfileByIdOrFamily returns null + const autoProfileId = findProfileByIdOrFamily(profiles, null, null); + expect(autoProfileId).toBeNull(); + // The panel would show the "Select a genre profile" prompt in this case + }); + + it('findProfileByIdOrFamily resolves exact genre id', () => { + const result = findProfileByIdOrFamily(profiles, 'house', null); + expect(result).toBe('house'); + }); + + it('findProfileByIdOrFamily falls back to family match', () => { + const result = findProfileByIdOrFamily(profiles, 'minimal-techno', 'techno'); + expect(result).toBe('techno'); + }); + + it('changing selected profile changes rendered output', () => { + // Simulates what happens when selectedProfileId changes: + // different profiles produce different report objects + const reportA: MixDoctorReport = { + genreName: 'Techno', + profileId: 'techno', + advice: [], + dynamicsAdvice: { issue: 'optimal', message: 'Good', actualCrest: 8 }, + plrAdvice: { issue: 'optimal', message: 'OK', actualPlr: 7 }, + loudnessAdvice: { issue: 'optimal', message: 'OK', actualLufs: -7, truePeak: -1 }, + stereoAdvice: undefined, + overallScore: 85, + }; + const reportB: MixDoctorReport = { + genreName: 'House', + profileId: 'house', + advice: [], + dynamicsAdvice: { issue: 'too-compressed', message: 'Low crest', actualCrest: 4 }, + plrAdvice: { issue: 'too-crushed', message: 'Low PLR', actualPlr: 4 }, + loudnessAdvice: { issue: 'too-loud', message: 'Hot', actualLufs: -4, truePeak: 0 }, + stereoAdvice: undefined, + overallScore: 55, + }; + + // Reports have different genre names and scores + expect(reportA.genreName).not.toBe(reportB.genreName); + expect(reportA.overallScore).not.toBe(reportB.overallScore); + expect(reportA.profileId).toBe('techno'); + expect(reportB.profileId).toBe('house'); + }); +});