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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ Artifact access goes through `artifact_storage.py` rather than direct disk paths
15. **`dsp_bandbank.py` + `dsp_utils.py`**: Shared DSP primitives — `BatchedBandpass` (4th-order Butterworth bandpass bank) and cross-module utility functions.
16. **`phase1_evaluation.py` + `phase1_report_html.py`, `polyphonic_evaluation.py`, `beat_evaluation.py` + `beat_report_html.py`, `loudness_rec_evaluation.py`, `recommendation_evaluation.py`**: Offline evaluation harnesses (deterministic-metric / detector-stability reporting, research-only polyphonic transcription, the beat/downbeat measurement gate that benchmarks CPJKU/beat_this against the shipping kick-accent heuristic, loudness-recommendation reachability, and the recommendation-quality scorer that grades Phase 2 recommendations against known-settings fixtures — see [`apps/backend/NEEDS.md`](apps/backend/NEEDS.md)). Not on the product path; driven by `scripts/evaluate_*.py`. Deleting them restores the product exactly.
17. **`utils/cleanup.py`**: Periodic artifact-cleanup helpers used by the server background-task loop.
18. **`transcription_pianoroll.py`**: Renders the pitch-note translation stage's `transcriptionDetail` as a velocity-encoded `(pitch, time)` uint8 matrix via [`symusic`](https://github.com/Yikai-Liao/symusic). Backs `GET /api/analysis-runs/{run_id}/transcription/pianoroll`. Derived view, never overrides Phase 1; the response cites Phase 1's `bpm` + `timeSignature` so chain of custody is preserved. Note: `transcriptionDetail` is *stripped* from `measurement.result` (see `analysis_runtime.py:685`) and lives in the `pitchNoteTranslation` stage instead — the route reads from there.

The subprocess isolation means `analyze.py` works as a standalone CLI. Check `apps/backend/JSON_SCHEMA.md` before adding new analyzer output fields. Check `apps/backend/ARCHITECTURE.md` for the full HTTP flow and contract details.

Expand Down
2 changes: 2 additions & 0 deletions apps/backend/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
| `url_ingest.py` | SSRF-guarded URL-mode ingestion for `POST /api/analysis-runs`. Fetches a public `http`/`https` audio file and streams the bytes through the same downstream pipeline as a multipart upload, enforcing the shared 100 MiB cap. |
| `audio_mime.py` | Canonical, host-independent filename→MIME resolution for ingested audio (`canonical_audio_mime`). Mirrors the frontend map in `apps/ui/src/services/audioFile.ts` so a `.flac` resolves to `audio/flac` on every OS — stdlib `mimetypes` is host-dependent (`audio/x-flac` on macOS vs `audio/flac` on Linux), which failed the test gate and could mislabel a FLAC handed to Gemini. Imported directly by `server_phase2.py` and `url_ingest.py`, and reached by `server.py` via `server_phase2._get_audio_mime_type`. |
| `csv_export.py` | CSV exporters for Phase 1 time-series fields, keyed by dotted JSON path. Backs `GET /api/analysis-runs/{run_id}/export/csv/{field_path}` and keeps the route handler a thin lookup-and-serve. |
| `transcription_pianoroll.py` | Renders the pitch-note translation stage's `transcriptionDetail` as a velocity-encoded `(pitch, time)` uint8 matrix via `symusic`. Backs `GET /api/analysis-runs/{run_id}/transcription/pianoroll`. Derived view — Phase 1 stays authoritative; the response cites the Phase 1 `bpm` and `timeSignature` so chain of custody (PURPOSE invariant #2) is preserved. |
| `stage_status.py` | Collapses the eight internal stage statuses into the additive client-facing `publicStatus` field carried on every stage in the run snapshot. |
| `server_samples.py` + `sample_generation.py` / `sample_theory.py` / `sample_synthesis.py` / `sample_drums.py` | Phase 3 audition-sample generation. PyTheory musical plan, FluidSynth (sine-additive fallback) audio render, NumPy drum one-shots, and a citation manifest tying every clip back to a Phase 1 field. On-demand only — not part of the staged-execution queue. See [`docs/SAMPLE_GENERATION.md`](../../docs/SAMPLE_GENERATION.md). |
| `dsp_bandbank.py` + `dsp_utils.py` | Shared DSP primitives: `BatchedBandpass` (4th-order Butterworth bandpass bank with zero-phase `filtfilt`) and cross-module utility functions. |
Expand Down Expand Up @@ -88,6 +89,7 @@ Custom routes:
- `GET /api/analysis-runs/{run_id}/artifacts` and `…/artifacts/{artifact_id}`
- `GET /api/analysis-runs/{run_id}/source-audio` — re-serves the original ingested audio for the run. Owner-only (no admin bypass). Saves a round-trip vs looking up the source artifact id first.
- `GET /api/analysis-runs/{run_id}/export/csv/{field_path}` — CSV export of a Phase 1 time-series field. See [`docs/adr/0001-phase1-json-schema-v1.md`](../../docs/adr/0001-phase1-json-schema-v1.md) and the registry in [`csv_export.py`](csv_export.py).
- `GET /api/analysis-runs/{run_id}/transcription/pianoroll` — velocity-encoded pianoroll matrix derived from the pitch-note translation stage's `transcriptionDetail`. Query params: `mode` (`frame`|`onset`, default `frame`), `pitchLow` (default 21), `pitchHigh` (default 109, exclusive), `tpq` (default 4). Response cites the Phase 1 `bpm` and `timeSignature` so every cell traces back to a measurement. Status codes: 200 (payload), 400 (`INVALID_MODE`/`INVALID_PITCH_RANGE`/`INVALID_TPQ`), 404 (`RUN_NOT_FOUND`/`TRANSCRIPTION_NOT_REQUESTED`/`TRANSCRIPTION_NOT_AVAILABLE`), 409 (`MEASUREMENT_NOT_COMPLETED`/`TRANSCRIPTION_NOT_COMPLETED`). Implementation in [`transcription_pianoroll.py`](transcription_pianoroll.py).
- `POST /api/analysis-runs/{run_id}/spectral-enhancements/{kind}` — on-demand spectral artifacts. `kind` is one of `cqt`, `hpss`, `onset`, `chroma_interactive`, or `reassigned` (sharper transient/frequency localization via `librosa.reassigned_spectrogram`).
- `POST /api/analysis-runs/{run_id}/pitch-note-translations`
- `POST /api/analysis-runs/{run_id}/interpretations`
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ soundfile==0.13.1
soxr==1.0.0
starlette==0.52.1
submitit==1.5.4
# Symbolic music library — fast MIDI/Score primitives with a C++ core. Used by
# transcription_pianoroll.py to convert transcriptionDetail.notes into a
# velocity-encoded matrix for the UI heatmap. Ships cp311 wheels for darwin-arm64
# and manylinux_x86_64 — no compile step.
symusic==0.6.0
sympy==1.14.0
threadpoolctl==3.6.0
torch==2.10.0
Expand Down
195 changes: 195 additions & 0 deletions apps/backend/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
)
from utils.cleanup import cleanup_artifacts
import csv_export
import transcription_pianoroll
import upload_limits
import url_ingest
from server_upload import ( # noqa: F401 — re-exported for test backward compat
Expand Down Expand Up @@ -2544,6 +2545,200 @@ async def export_run_field_as_csv(
)


@app.get("/api/analysis-runs/{run_id}/transcription/pianoroll")
async def get_transcription_pianoroll(
run_id: str,
mode: str = Query(
"frame",
description="'frame' (sustained notes painted across their duration) "
"or 'onset' (note starts only — useful for rhythmic visualization).",
),
pitch_low: int = Query(
transcription_pianoroll.DEFAULT_PITCH_LOW,
alias="pitchLow",
description="Lower MIDI pitch bound (inclusive). Default: 21 (A0).",
),
pitch_high: int = Query(
transcription_pianoroll.DEFAULT_PITCH_HIGH,
alias="pitchHigh",
description="Upper MIDI pitch bound (exclusive). Default: 109 "
"(one above C8 — the 88-key piano range).",
),
tpq: int = Query(
transcription_pianoroll.DEFAULT_TPQ,
description="Time resolution in ticks per quarter note. Default: 4 "
"(one row per 16th note at typical tempos).",
),
x_asa_user_id: str | None = Header(None),
x_asa_user_email: str | None = Header(None),
) -> JSONResponse:
"""Render the run's transcriptionDetail as a velocity-encoded pianoroll.

Derived view — Phase 1's ``transcriptionDetail`` (sourced from the
pitch-note translation stage) stays authoritative. The response cites
Phase 1's ``bpm`` + ``timeSignature`` so the UI surface can attribute
every cell back to a measurement (invariant #2 in ``PURPOSE.md``).

Status mapping is deliberately split between 404 (won't appear without
user action) and 409 (wait or finish a prerequisite):

* 404 ``RUN_NOT_FOUND`` — run doesn't exist or is not owned by the caller.
* 409 ``MEASUREMENT_NOT_COMPLETED`` — measurement stage hasn't finished.
* 404 ``TRANSCRIPTION_NOT_REQUESTED`` — run was created with pitch-note
translation disabled. The caller should enable transcription on a new run.
* 409 ``TRANSCRIPTION_NOT_COMPLETED`` — pitch-note stage is queued/running.
* 404 ``TRANSCRIPTION_NOT_AVAILABLE`` — pitch-note stage completed (or
failed / was interrupted) without producing a ``transcriptionDetail``
payload. Caller's affordance is "re-run the transcription stage."
* 400 ``INVALID_MODE`` / ``INVALID_PITCH_RANGE`` / ``INVALID_TPQ`` —
query parameter validation. Returned as structured codes rather than
FastAPI's default 422 so clients can branch on them.
"""
# Up-front query validation so the route returns structured error codes
# rather than letting ``render_pianoroll`` raise generic ``ValueError``
# — clients should be able to switch on the specific failure.
if mode not in ("frame", "onset"):
return JSONResponse(
status_code=400,
content={
"error": {
"code": "INVALID_MODE",
"message": f"mode must be 'frame' or 'onset'; got {mode!r}.",
}
},
)
if pitch_low < 0 or pitch_high > 128 or pitch_low >= pitch_high:
return JSONResponse(
status_code=400,
content={
"error": {
"code": "INVALID_PITCH_RANGE",
"message": (
f"pitch range [{pitch_low}, {pitch_high}) is invalid; "
"must satisfy 0 <= pitchLow < pitchHigh <= 128."
),
}
},
)
if tpq < 1:
return JSONResponse(
status_code=400,
content={
"error": {
"code": "INVALID_TPQ",
"message": f"tpq must be >= 1; got {tpq}.",
}
},
)

user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email)
if isinstance(user_context, JSONResponse):
return user_context

runtime = get_analysis_runtime()
try:
snapshot = runtime.get_run(run_id, owner_user_id=user_context.user_id)
except (KeyError, PermissionError):
return _run_not_found_response(run_id)

stages = snapshot.get("stages", {}) if isinstance(snapshot, dict) else {}
measurement = stages.get("measurement", {}) or {}
if measurement.get("status") != "completed":
return JSONResponse(
status_code=409,
content={
"error": {
"code": "MEASUREMENT_NOT_COMPLETED",
"message": (
"Measurement stage must complete before the "
"transcription pianoroll can be rendered."
),
}
},
)

pn_stage = stages.get("pitchNoteTranslation", {}) or {}
pn_status = pn_stage.get("status")
if pn_status == "not_requested":
return JSONResponse(
status_code=404,
content={
"error": {
"code": "TRANSCRIPTION_NOT_REQUESTED",
"message": (
"This run was created with pitch-note translation "
"disabled. Enable transcription on a new run to "
"render the pianoroll."
),
}
},
)
if pn_status in ("ready", "queued", "running"):
return JSONResponse(
status_code=409,
content={
"error": {
"code": "TRANSCRIPTION_NOT_COMPLETED",
"message": (
"Pitch-note translation has not finished. Wait for "
"the stage to complete and try again."
),
}
},
)

pn_result = pn_stage.get("result")
transcription_detail = (
pn_result.get("transcriptionDetail")
if isinstance(pn_result, dict)
else None
)
if not isinstance(transcription_detail, dict):
# Covers: completed-but-null result, failed attempt, interrupted
# attempt — the user's recourse is identical (re-run transcription).
return JSONResponse(
status_code=404,
content={
"error": {
"code": "TRANSCRIPTION_NOT_AVAILABLE",
"message": (
"Pitch-note translation did not produce a "
"transcriptionDetail payload for this run."
),
}
},
)

measurement_result = (
measurement.get("result")
if isinstance(measurement.get("result"), dict)
else {}
)
bpm_value = measurement_result.get("bpm")
time_signature_value = measurement_result.get("timeSignature")

payload = transcription_pianoroll.render_pianoroll(
transcription_detail,
bpm=(
float(bpm_value)
if isinstance(bpm_value, (int, float)) and bpm_value > 0
else None
),
time_signature=(
time_signature_value
if isinstance(time_signature_value, str)
else None
),
mode=mode, # type: ignore[arg-type]
pitch_low=pitch_low,
pitch_high=pitch_high,
tpq=tpq,
)
return JSONResponse(
content=transcription_pianoroll.payload_to_json_dict(payload)
)


_ENHANCEMENT_GENERATORS = {
"cqt": ("generate_cqt_spectrogram", ["spectrogram_cqt"], True),
"hpss": ("generate_hpss_spectrograms", ["spectrogram_harmonic", "spectrogram_percussive"], True),
Expand Down
Loading