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
5 changes: 5 additions & 0 deletions apps/backend/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ Custom routes:
- `POST /api/analysis-runs`
- `GET /api/analysis-runs/{run_id}`
- `DELETE /api/analysis-runs/{run_id}`
- `GET /api/analysis-runs/{run_id}/artifacts` and `…/artifacts/{artifact_id}`
- `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).
- `POST /api/analysis-runs/{run_id}/spectral-enhancements/{kind}`
- `POST /api/analysis-runs/{run_id}/pitch-note-translations`
- `POST /api/analysis-runs/{run_id}/interpretations`
- `POST /api/analyze` (legacy compatibility)
- `POST /api/analyze/estimate` (legacy compatibility)
- `POST /api/phase2` (legacy compatibility)
Expand Down
268 changes: 268 additions & 0 deletions apps/backend/csv_export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
"""CSV exporters for Phase 1 time-series fields.

Patterned on the ``(time, duration, value)`` tabular shape used by Partiels
and similar Vamp-output tools (see ``docs/external-repo-review-2026-05-13.md``
Track 2 for the rationale and the rejected-alternatives discussion). Each
exporter maps one dotted JSON path inside the Phase 1 measurement payload to
a small CSV with documented columns.

The exporters are kept here, outside ``server.py``, so the route handler can
stay a thin lookup-and-serve.

Registered paths (v1):

================================== =====================================
Path CSV columns
================================== =====================================
``lufsCurve.shortTerm`` ``time, duration, lufs`` (duration=3.0)
``lufsCurve.momentary`` ``time, duration, lufs`` (duration=0.4)
``rhythmDetail.tempoCurve`` ``time, bpm``
``spectralBalanceTimeSeries`` ``time, subBass, lowBass, lowMids,``
``mids, upperMids, highs, brilliance``
================================== =====================================

Design notes:

- The ``duration`` column on the LUFS curves reflects EBU R128's measurement
window for that point (3.0 s short-term, 0.4 s momentary). Other curves
do not have an intrinsic per-point duration and so omit the column.
- A field that is ``None`` or missing from the measurement payload returns
``None`` from :func:`export_field_to_csv`. The HTTP layer treats that
separately from "unknown field" so users get a useful error code.
- Field paths are deliberately allowlisted: arbitrary JSONPath/nested-key
descent on the measurement payload is not supported. Adding a new
exportable field is a small, deliberate code change in this file.
"""

from __future__ import annotations

import csv
import io
import math
from typing import Any, Callable


__all__ = [
"export_field_to_csv",
"is_supported_field",
"list_supported_fields",
]


# EBU R128 short-term loudness window is 3.0 s; momentary is 0.4 s.
# Each curve point reports the integrated loudness over that window
# ending at ``t``.
_LUFS_SHORT_TERM_WINDOW_S = 3.0
_LUFS_MOMENTARY_WINDOW_S = 0.4


_SPECTRAL_BANDS: tuple[str, ...] = (
"subBass",
"lowBass",
"lowMids",
"mids",
"upperMids",
"highs",
"brilliance",
)


# ----------------------------------------------------------------------
# Public API
# ----------------------------------------------------------------------


def export_field_to_csv(
measurement_result: dict | None,
field_path: str,
) -> str | None:
"""Look up ``field_path`` in the registry and serialize the value to CSV.

Returns the CSV text (header row + data rows) when the field is present
and non-empty. Returns ``None`` when any of these are true:

- ``measurement_result`` itself is ``None``
- the field path is not registered (caller should treat as 404 "unknown")
- the field is missing or ``None`` in the measurement payload (caller
should treat as 404 "not available for this run")
- the field is present but empty (zero data points; same as above)

The caller is responsible for distinguishing "unknown path" from
"known path, no data" — see :func:`is_supported_field`.
"""
if measurement_result is None:
return None
if field_path not in _EXPORTERS:
return None
field_value = _resolve_dot_path(measurement_result, field_path)
if field_value is None:
return None
return _EXPORTERS[field_path](field_value)


def is_supported_field(field_path: str) -> bool:
"""Whether ``field_path`` is in the export registry.

Independent of whether any particular run has data for it.
"""
return field_path in _EXPORTERS


def list_supported_fields() -> list[str]:
"""All currently exportable field paths, sorted alphabetically."""
return sorted(_EXPORTERS.keys())


# ----------------------------------------------------------------------
# Path resolution
# ----------------------------------------------------------------------


def _resolve_dot_path(payload: dict, dot_path: str) -> Any:
"""Walk ``payload`` along ``a.b.c`` and return the leaf, or ``None``.

Returns ``None`` on any of: non-dict at an intermediate step, missing
key at any step, or explicit ``None`` value at any step. This is a
simple nested-key descent — *not* JSONPath syntax (no ``$``, no
``[*]``, no array indexing).
"""
node: Any = payload
for key in dot_path.split("."):
if not isinstance(node, dict):
return None
node = node.get(key)
if node is None:
return None
return node


def _as_finite_float(value: Any) -> float | None:
"""Convert ``value`` to a finite ``float``, or ``None``.

Returns ``None`` for: ``None`` input, non-numeric input (raises on
``float()``), and ``NaN`` / ``Infinity`` / ``-Infinity``. The
non-finite case matters: Python's ``json.loads`` accepts ``NaN`` and
``Infinity`` by default (non-standard JSON), so a pathological
measurement could round-trip an NaN into a snapshot and from there
into a CSV cell as the string ``"nan"`` — which most downstream
consumers (REAPER, Max, pandas) would mis-parse as a string.

Used at the per-row level: a row with any non-finite value is
skipped entirely, matching the existing "missing key → skip row"
behavior.
"""
if value is None:
return None
try:
as_float = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(as_float):
return None
return as_float


# ----------------------------------------------------------------------
# Per-field serializers
# ----------------------------------------------------------------------


def _serialize_lufs_curve(
points: Any, window_duration_s: float
) -> str | None:
"""Serialize a list of ``{t, lufs}`` points with a constant duration column.

Skips any row where either ``t`` or ``lufs`` is missing, non-numeric,
or non-finite (NaN, Infinity).
"""
if not isinstance(points, list) or len(points) == 0:
return None
buf = io.StringIO()
writer = csv.writer(buf, lineterminator="\n")
writer.writerow(["time", "duration", "lufs"])
wrote_any = False
for point in points:
if not isinstance(point, dict):
continue
t = _as_finite_float(point.get("t"))
lufs = _as_finite_float(point.get("lufs"))
if t is None or lufs is None:
continue
writer.writerow([f"{t:.6f}", f"{window_duration_s}", f"{lufs:.2f}"])
wrote_any = True
return buf.getvalue() if wrote_any else None


def _serialize_tempo_curve(points: Any) -> str | None:
"""Serialize a list of ``{t, bpm}`` points.

Skips any row where either ``t`` or ``bpm`` is missing, non-numeric,
or non-finite.
"""
if not isinstance(points, list) or len(points) == 0:
return None
buf = io.StringIO()
writer = csv.writer(buf, lineterminator="\n")
writer.writerow(["time", "bpm"])
wrote_any = False
for point in points:
if not isinstance(point, dict):
continue
t = _as_finite_float(point.get("t"))
bpm = _as_finite_float(point.get("bpm"))
if t is None or bpm is None:
continue
writer.writerow([f"{t:.6f}", f"{bpm:.3f}"])
wrote_any = True
return buf.getvalue() if wrote_any else None


def _serialize_spectral_balance_time_series(points: Any) -> str | None:
"""Serialize a list of ``{t, subBass, ..., brilliance}`` points.

Skips any row where ``t`` or any band value is missing, non-numeric,
or non-finite (NaN, Infinity) — keeps the CSV regular and
downstream-loadable.
"""
if not isinstance(points, list) or len(points) == 0:
return None
buf = io.StringIO()
writer = csv.writer(buf, lineterminator="\n")
writer.writerow(["time", *_SPECTRAL_BANDS])
wrote_any = False
for point in points:
if not isinstance(point, dict):
continue
t = _as_finite_float(point.get("t"))
if t is None:
continue
band_values: list[str] = []
skip = False
for band in _SPECTRAL_BANDS:
value = _as_finite_float(point.get(band))
if value is None:
skip = True
break
band_values.append(f"{value:.4f}")
if skip:
continue
writer.writerow([f"{t:.6f}", *band_values])
wrote_any = True
return buf.getvalue() if wrote_any else None


# ----------------------------------------------------------------------
# Registry
# ----------------------------------------------------------------------


_EXPORTERS: dict[str, Callable[[Any], str | None]] = {
"lufsCurve.shortTerm": lambda value: _serialize_lufs_curve(
value, _LUFS_SHORT_TERM_WINDOW_S
),
"lufsCurve.momentary": lambda value: _serialize_lufs_curve(
value, _LUFS_MOMENTARY_WINDOW_S
),
"rhythmDetail.tempoCurve": _serialize_tempo_curve,
"spectralBalanceTimeSeries": _serialize_spectral_balance_time_series,
}
88 changes: 87 additions & 1 deletion apps/backend/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

from fastapi import FastAPI, File, Form, Header, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse, Response

from auth_context import AuthenticationRequiredError, UserContext, resolve_api_user_context
from analysis_runtime import (
Expand All @@ -46,6 +46,7 @@
should_start_in_process_workers,
)
from utils.cleanup import cleanup_artifacts
import csv_export
import upload_limits
from server_upload import ( # noqa: F401 — re-exported for test backward compat
LEGACY_ENDPOINT_SUNSET,
Expand Down Expand Up @@ -2088,6 +2089,91 @@ async def get_run_artifact(
)


@app.get(
"/api/analysis-runs/{run_id}/export/csv/{field_path}",
response_model=None,
)
async def export_run_field_as_csv(
run_id: str,
field_path: str,
x_asa_user_id: str | None = Header(None),
x_asa_user_email: str | None = Header(None),
) -> Response | JSONResponse:
"""Export one Phase 1 time-series field as CSV.

The ``field_path`` is a simple dot-path into the measurement payload —
e.g. ``lufsCurve.shortTerm`` or ``rhythmDetail.tempoCurve``. Not
JSONPath; arbitrary nested-key descent is not supported. See
:func:`csv_export.list_supported_fields` for the registered list.

Returns:
- 200 ``text/csv`` with header row + data rows on success.
- 404 ``EXPORT_FIELD_NOT_SUPPORTED`` if the path is not in the
registry.
- 404 ``RUN_NOT_FOUND`` if the run does not exist or is not
owned by the requesting user.
- 404 ``EXPORT_FIELD_NOT_AVAILABLE`` if the field is registered
but missing/null/empty for this run (measurement may be
incomplete or the analyzer skipped that field for this audio).
"""
user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email)
if isinstance(user_context, JSONResponse):
return user_context

if not csv_export.is_supported_field(field_path):
supported = ", ".join(csv_export.list_supported_fields())
return JSONResponse(
status_code=404,
content={
"error": {
"code": "EXPORT_FIELD_NOT_SUPPORTED",
"message": (
f"Field path '{field_path}' is not exportable. "
f"Supported paths: {supported}."
),
}
},
)

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)

measurement_result = (
snapshot.get("stages", {})
.get("measurement", {})
.get("result")
)
csv_text = csv_export.export_field_to_csv(measurement_result, field_path)
if csv_text is None:
return JSONResponse(
status_code=404,
content={
"error": {
"code": "EXPORT_FIELD_NOT_AVAILABLE",
"message": (
f"Field '{field_path}' is supported but not "
f"populated in run '{run_id}'. The measurement "
f"may be incomplete or the analyzer skipped this "
f"field for this audio."
),
}
},
)

# Filename: <run_id>_<dotted_path_with_underscores>.csv
filename = f"{run_id}_{field_path.replace('.', '_')}.csv"
return Response(
content=csv_text,
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)


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