Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,18 +193,19 @@ Single-page React 19 + Vite + TypeScript + Tailwind CSS v4 app with no router. V
3. **`src/services/backendPhase1Client.ts`**: Legacy multipart transport (typed error classes, `AbortController` timeouts, identity probe via `/openapi.json`). Kept for the compatibility wrappers; new flows should go through `analysisRunsClient.ts`.
4. **`src/services/httpClient.ts`**: Shared fetch helpers and request-header injection used by the run/artifact/sample clients.
5. **`src/services/spectralArtifactsClient.ts`**: Fetches spectrogram/spectral-evolution artifacts via `/api/analysis-runs/{run_id}/artifacts/…`.
6. **`src/services/sampleGenerationClient.ts`**: Phase 3 audition-sample POST/GET against `/api/analysis-runs/{run_id}/samples`, plus per-clip artifact streaming.
7. **`src/services/mixDoctor.ts`**: Mix advisory logic — client-side scoring and suggestions against measured spectral balance.
8. **`src/services/phase2Validator.ts`** + **`loudnessGuardrails.ts`**: Runtime guardrail. Validates Phase 2 consistency against Phase 1 (`validateBPMConsistency`, `validateKeyConsistency`, `validateLUFSConsistency`, `validateGenreDSPConsistency`, `validateNumericBounds`, `validateLoudnessActionPresence`). `loudnessGuardrails.ts` defines the objective loudness defects (digital clipping via `saturationDetail.clippedSampleCount`, true-peak overs via `truePeak`) that a Phase 2 mastering/dynamics card *must* address — a missing action surfaces as a `MISSING_LOUDNESS_ACTION` violation. The aggregate `validatePhase2Consistency` drives **`Phase2ConsistencyReport.tsx`**, which renders the chain-of-custody report on the results surface (`AnalysisResults.tsx`, `hideWhenClean`) and in full inside the diagnostic log (`DiagnosticLog.tsx`); `App.tsx` computes the report and passes it down.
9. **`src/services/appliedRecommendations.ts`** + **`userLabels.ts`**: Applied-recommendations tracker and persisted-label state used by the audit overhaul.
6. **`src/services/transcriptionPianorollClient.ts`**: Fetches the velocity-encoded transcription pianoroll matrix via `/api/analysis-runs/{run_id}/transcription/pianoroll`. Backed by `apps/backend/transcription_pianoroll.py`. Rendered by `TranscriptionPianoroll.tsx` (canvas heatmap) inside `TranscriptionPianorollBlock.tsx`, which `AnalysisResults.tsx` mounts in the Session Musician suite when `transcriptionDetail.noteCount > 0`.
7. **`src/services/sampleGenerationClient.ts`**: Phase 3 audition-sample POST/GET against `/api/analysis-runs/{run_id}/samples`, plus per-clip artifact streaming.
8. **`src/services/mixDoctor.ts`**: Mix advisory logic — client-side scoring and suggestions against measured spectral balance.
9. **`src/services/phase2Validator.ts`** + **`loudnessGuardrails.ts`**: Runtime guardrail. Validates Phase 2 consistency against Phase 1 (`validateBPMConsistency`, `validateKeyConsistency`, `validateLUFSConsistency`, `validateGenreDSPConsistency`, `validateNumericBounds`, `validateLoudnessActionPresence`). `loudnessGuardrails.ts` defines the objective loudness defects (digital clipping via `saturationDetail.clippedSampleCount`, true-peak overs via `truePeak`) that a Phase 2 mastering/dynamics card *must* address — a missing action surfaces as a `MISSING_LOUDNESS_ACTION` violation. The aggregate `validatePhase2Consistency` drives **`Phase2ConsistencyReport.tsx`**, which renders the chain-of-custody report on the results surface (`AnalysisResults.tsx`, `hideWhenClean`) and in full inside the diagnostic log (`DiagnosticLog.tsx`); `App.tsx` computes the report and passes it down.
10. **`src/services/appliedRecommendations.ts`** + **`userLabels.ts`**: Applied-recommendations tracker and persisted-label state used by the audit overhaul.
9a. **`src/services/recommendationVerification.ts`** + **`src/data/recommendationVerification.ts`** + **`components/RecommendationVerificationBadge.tsx`**: Per-recommendation corpus-verification badge (`GOAL.md` sub-goal 4). The data module is a generated artifact from `apps/backend/scripts/evaluate_recommendations.py --verification-artifact` (all-`NONE` until the ground-truth corpus has renders); the service infers a card's domain (mirroring the backend scorer's `infer_domain`) and looks up its confidence band; the badge renders on `AnalysisResults.tsx` recommendation cards, hidden when there is no corpus evidence. Research/proof surface — see [`apps/backend/NEEDS.md`](apps/backend/NEEDS.md).
10. **`src/services/phase1Picker.ts`** + **`phaseLabels.ts`**: Phase-snapshot projection helpers consumed by the results surface.
11. **`src/services/audioFile.ts`**: Client-side audio validation, blank-MIME extension fallback, preview-URL lifecycle.
12. **`src/services/fieldAnalytics.ts`** + **`diagnosticLogs.ts`**: Instrumentation hooks and diagnostic-log capture for the request panel.
13. **`src/services/midi/`**: MIDI export, preview, and quantization utilities (`midiExport.ts`, `midiPreview.ts`, `quantization.ts`).
14. **`src/services/sessionMusician/`**: Session Musician helpers — `confidenceBand.ts`, `noteConversion.ts`, `renderState.ts`, `stemListeningNotes.ts`.
15. **`src/types.ts`** + **`src/types/`**: `types.ts` is a barrel re-export of `./types/{measurement,interpretation,backend}.ts`. `Phase1Result` lives in `types/measurement.ts`; `AnalysisRunSnapshot` in `types/backend.ts`; `Phase2Result` in `types/interpretation.ts`; `./types/samples.ts` is imported directly, not through the barrel.
16. **`src/config.ts`**: Runtime resolution of `VITE_API_BASE_URL` and feature flags; falls back to `http://127.0.0.1:8100`. Supports window-level overrides (`window.__VITE_API_BASE_URL_OVERRIDE__`, `window.__VITE_ENABLE_PHASE2_GEMINI_OVERRIDE__`) for hosted deployments that inject config at runtime without a rebuild.
11. **`src/services/phase1Picker.ts`** + **`phaseLabels.ts`**: Phase-snapshot projection helpers consumed by the results surface.
12. **`src/services/audioFile.ts`**: Client-side audio validation, blank-MIME extension fallback, preview-URL lifecycle.
13. **`src/services/fieldAnalytics.ts`** + **`diagnosticLogs.ts`**: Instrumentation hooks and diagnostic-log capture for the request panel.
14. **`src/services/midi/`**: MIDI export, preview, and quantization utilities (`midiExport.ts`, `midiPreview.ts`, `quantization.ts`).
15. **`src/services/sessionMusician/`**: Session Musician helpers — `confidenceBand.ts`, `noteConversion.ts`, `renderState.ts`, `stemListeningNotes.ts`.
16. **`src/types.ts`** + **`src/types/`**: `types.ts` is a barrel re-export of `./types/{measurement,interpretation,backend}.ts`. `Phase1Result` lives in `types/measurement.ts`; `AnalysisRunSnapshot` in `types/backend.ts`; `Phase2Result` in `types/interpretation.ts`; `./types/samples.ts` is imported directly, not through the barrel.
17. **`src/config.ts`**: Runtime resolution of `VITE_API_BASE_URL` and feature flags; falls back to `http://127.0.0.1:8100`. Supports window-level overrides (`window.__VITE_API_BASE_URL_OVERRIDE__`, `window.__VITE_ENABLE_PHASE2_GEMINI_OVERRIDE__`) for hosted deployments that inject config at runtime without a rebuild.

`AnalysisResults.tsx` is the large results surface, lazy-loaded via Suspense. Manual vendor chunks in `vite.config.ts` control bundle splitting.

Expand Down
49 changes: 18 additions & 31 deletions apps/backend/analyze_rhythm.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,50 +456,37 @@ def analyze_melody(

midi_file_path = None
try:
import mido
# Symusic handles the tick math and event ordering — we just build
# a Score in seconds and dump. Failure (e.g. an unwritable output
# dir) is non-critical: melody MIDI is a nice-to-have artifact, so
# we swallow the exception and report no file.
from symusic import Note, Score, Tempo, Track

bpm = 120.0
if rhythm_data is not None and rhythm_data.get("bpm") is not None:
bpm = float(rhythm_data["bpm"])
if not np.isfinite(bpm) or bpm <= 0:
bpm = 120.0

ppq = 96
ticks_per_second = (ppq * bpm) / 60.0
midi_out = mido.MidiFile(ticks_per_beat=ppq)
track = mido.MidiTrack()
midi_out.tracks.append(track)
track.append(
mido.MetaMessage("set_tempo", tempo=int(mido.bpm2tempo(bpm)), time=0)
)

events = []
score = Score(96, ttype="Second")
score.tempos.append(Tempo(time=0.0, qpm=float(bpm), ttype="Second"))
track = Track(name="melody", program=0, is_drum=False, ttype="Second")
for onset, duration, midi_note in note_events:
start_tick = max(0, int(round(onset * ticks_per_second)))
end_tick = max(
start_tick + 1, int(round((onset + duration) * ticks_per_second))
)
events.append((start_tick, 1, midi_note))
events.append((end_tick, 0, midi_note))
events.sort(key=lambda e: (e[0], e[1]))

prev_tick = 0
for tick, is_note_on, midi_note in events:
delta = max(0, tick - prev_tick)
if is_note_on == 1:
track.append(
mido.Message("note_on", note=midi_note, velocity=90, time=delta)
track.notes.append(
Note(
time=float(onset),
duration=float(max(0.0, duration)),
pitch=int(midi_note),
velocity=90,
ttype="Second",
)
else:
track.append(
mido.Message("note_off", note=midi_note, velocity=0, time=delta)
)
prev_tick = tick
)
score.tracks.append(track)

output_dir = os.path.dirname(audio_path)
base_name = os.path.splitext(os.path.basename(audio_path))[0]
midi_file_path = os.path.join(output_dir, f"{base_name}_melody.mid")
midi_out.save(midi_file_path)
score.dump_midi(midi_file_path)
except Exception as e:
print(f"[warn] Melody MIDI export failed: {e}", file=sys.stderr)
midi_file_path = None
Expand Down
9 changes: 9 additions & 0 deletions apps/backend/beat_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@
beat_this and mir_eval are OPTIONAL: imported lazily/guarded so stride-vs-kick_accent
runs in the product venv with zero new dependencies (metrics fall back to a
hand-rolled F-measure when mir_eval is absent).

Note on symusic (post-PR-A/D investigation): symusic's ``Score.get_beats`` /
``get_downbeats`` derive beats from a symbolic score's *tempo map and time
signatures*, not from audio. This harness operates on audio inputs, so symusic
has nothing to offer as an additional method here. The original consolidation
plan (PR-E) called for adding ``symusic-beat`` as an observational fourth
method — it was dropped after the probe confirmed symusic is not an
audio-analysis library. The pre-registered ``beat_this`` vs ``kick_accent``
gate is unaffected.
"""

import json
Expand Down
16 changes: 10 additions & 6 deletions apps/backend/polyphonic_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
from pathlib import Path
from typing import Any, Callable

import pretty_midi
import soundfile as sf
from symusic import Score

REPO_DIR = Path(__file__).resolve().parent
DEFAULT_OUTPUT_DIR = REPO_DIR / ".runtime" / "polyphonic_eval"
Expand Down Expand Up @@ -53,8 +53,10 @@ def build_manual_scorecard(existing: dict[str, Any] | None = None) -> dict[str,


def summarize_midi_file(midi_path: Path, audio_duration_seconds: float) -> dict[str, Any]:
midi_data = pretty_midi.PrettyMIDI(str(midi_path))
notes = [note for instrument in midi_data.instruments for note in instrument.notes]
# Loaded scores arrive in Tick units; convert to Second so note.time and
# note.duration are seconds, matching the metric definitions below.
score = Score(midi_path).to("Second")
notes = [note for track in score.tracks for note in track.notes]
if len(notes) == 0:
return {
"noteCount": 0,
Expand All @@ -72,11 +74,13 @@ def summarize_midi_file(midi_path: Path, audio_duration_seconds: float) -> dict[
pitch_values: list[int] = []
events: list[tuple[float, int]] = []
for note in notes:
duration = max(0.0, float(note.end) - float(note.start))
start = float(note.time)
duration = max(0.0, float(note.duration))
end = start + duration
total_note_duration += duration
pitch_values.append(int(note.pitch))
events.append((float(note.start), 1))
events.append((float(note.end), -1))
events.append((start, 1))
events.append((end, -1))

events.sort(key=lambda item: (item[0], item[1]))
max_polyphony = 0
Expand Down
2 changes: 0 additions & 2 deletions apps/backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ lazy-loader==0.5
librosa==0.11.0
matplotlib==3.10.8
llvmlite==0.46.0
mido==1.3.3
mpmath==1.3.0
msgpack==1.1.2
networkx==3.6.1
Expand All @@ -42,7 +41,6 @@ openunmix==1.3.0
packaging==26.0
platformdirs==4.9.4
pooch==1.9.0
pretty_midi==0.2.11
protobuf==7.34.0
pyaml==26.2.1
pycparser==3.0
Expand Down
12 changes: 6 additions & 6 deletions apps/backend/sample_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ def generate_samples(
phase2: dict[str, Any] | None,
output_dir: Path,
pitch_note_hints: list[int] | None = None,
prefer_fluidsynth: bool = True,
allow_soundfont_backends: bool = True,
) -> GenerationResult:
"""Build the full audition set for a single run.

`pitch_note_hints` may carry scale degrees pulled from the stem-summary
pitch/note translation output. When absent we render the default ascent.

`prefer_fluidsynth` is plumbed through so tests can force the fallback
even if FluidSynth is installed on the dev machine.
`allow_soundfont_backends` is plumbed through so tests can force the
sine fallback even if FluidSynth (or symusic) could otherwise run.
"""
output_dir.mkdir(parents=True, exist_ok=True)
artifact_paths: dict[str, Path] = {}
Expand Down Expand Up @@ -86,7 +86,7 @@ def generate_samples(

chord_plan = sample_theory.plan_chord_progression(ctx, bars=8)
chord_result = sample_synthesis.render_clip(
chord_plan, prefer_fluidsynth=prefer_fluidsynth
chord_plan, allow_soundfont_backends=allow_soundfont_backends
)
selected_backend = chord_result.backend
selected_soundfont = chord_result.soundfont_path
Expand Down Expand Up @@ -118,7 +118,7 @@ def generate_samples(

bass_plan = sample_theory.plan_bass_root(ctx, bars=8)
bass_result = sample_synthesis.render_clip(
bass_plan, prefer_fluidsynth=prefer_fluidsynth
bass_plan, allow_soundfont_backends=allow_soundfont_backends
)
# `selected_backend` is already pinned by the chord render above —
# `render_clip` always returns a concrete backend string, so the bass
Expand Down Expand Up @@ -242,7 +242,7 @@ def generate_samples(
)
if melody_plan is not None:
melody_result = sample_synthesis.render_clip(
melody_plan, prefer_fluidsynth=prefer_fluidsynth
melody_plan, allow_soundfont_backends=allow_soundfont_backends
)
# See bass-render note above — `selected_backend` is pinned at the
# first chord render and `render_clip` never returns falsy.
Expand Down
Loading
Loading