Files
uj-mastering-master/metrics_json.py
T
irene 5df802a574 Add web UI: same backend, browser front end
Reuses the analysis backend unchanged. metrics.py already split compute()
from build_spec(), and plotspec.PlotSpec is backend-agnostic, so the web UI
reimplements only the renderer: uPlot for curves/bands, a canvas for the
spectrogram heatmap. No PyQt5, pyqtgraph or matplotlib on the web path.

Uploaded audio is never persisted on the host. The browser copies each file
into Origin Private File System (sandboxed browser storage) and streams the
bytes to the server per request; the server spools each body to a temp file,
decodes, computes, and unlinks it in a finally before responding. There is no
upload directory, no cache of audio, and no session state keyed on a path:
file_id is the SHA-256 of the audio, so re-sending a master for another metric
reuses the first decode. Verified: a spool is observable mid-request and gone
after the response.

Being precise: the bytes do have to reach the server, since librosa decodes
from a path and the analysis is CPU-bound numpy/scipy. The claim is that
running this does not accumulate masters on the host, not that audio is never
on the machine mid-request.

New:
  metrics_json.py       PlotSpec/metric data -> JSON (the renderer seam)
  web_session.py        headless analysis session, content-addressed tracks
  webapp.py             FastAPI routes, raw-body analysis, ujm-web entry point
  static/               browser front end + test suite
  scripts/webapi_smoke.py  61 end-to-end assertions against a running server

Also fixes a bug the direct-uvicorn dev loop had hidden: the CLI advertises
logger_setup's WARN level but passed it straight to uvicorn, which only knows
'warning' -- `ujm-web --log-level WARN` died with KeyError: 'warn'.

Removes an earlier /api/collect debug route that served every source module
over HTTP.

pyproject: 'web' extra (fastapi, uvicorn, python-multipart), package-data for
static/, Apache-2.0 licence.
2026-09-14 01:44:48 +09:00

171 lines
5.5 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
PlotSpec -> JSON.
The desktop renderer (`audio_visualization_widget`) draws a `PlotSpec` with
pyqtgraph. The web renderer draws the *same* `PlotSpec` with a browser plotting
library, so the only thing this module owns is the wire format between them:
numpy arrays become plain lists, `NaN`/`inf` (gated PSR, empty windows) become
JSON `null` so the client can break the line instead of drawing a spike.
Nothing here is web-framework-specific — `serialize_spec` is the exact partner of
`show_specs`: same input, different canvas. Keeping it free of FastAPI imports
means the desktop side could adopt it unchanged if a future client ever needed
the same payload.
Why the heatmap is sent losslessly: the desktop renderer resamples the linear STFT
rows onto a log-spaced grid for a log frequency axis (pyqtgraph's ImageItem is
affine-only and won't follow a log axis). The browser *can* draw a non-linear
axis from a linearly spaced image, so no resampling happens here — the client gets
the native rows plus the axis-extent information and does the mapping itself.
That keeps warm-up cost identical to the desktop app and makes the expensive
log-grid interpolation happen once per view change rather than once per request.
"""
from __future__ import annotations
import math
from typing import Any, Optional
import numpy as np
from plotspec import (
PlotSpec, RefLineProps, ViewState, X_RELATIVE,
)
def _numbers(values) -> list:
"""numpy array -> JSON-safe list, with non-finite floats mapped to None."""
if values is None:
return []
arr = np.asarray(values, dtype=float).ravel()
out: list[Optional[float]] = []
for v in arr.tolist():
out.append(None if (v is None or not math.isfinite(v)) else float(v))
return out
def _ints(values) -> list[float]:
"""numpy array -> JSON-safe list of floats (no None mapping — index axes)."""
return [float(v) for v in np.asarray(values, dtype=float).ravel().tolist()]
def _bounds(values) -> Optional[list[float]]:
"""[min, max] of the finite values, or None when everything is non-finite."""
if values is None:
return None
arr = np.asarray(values, dtype=float).ravel()
arr = arr[np.isfinite(arr)]
if not arr.size:
return None
return [float(arr.min()), float(arr.max())]
def _serialize_axes(axes) -> dict[str, Any]:
return {
"x_label": axes.x_label,
"y_label": axes.y_label,
"y_log": bool(axes.y_log),
"x_log": bool(axes.x_log),
"y_range": list(axes.y_range) if axes.y_range else None,
"x_range": list(axes.x_range) if axes.x_range else None,
"y_log_allowed": bool(axes.y_log_allowed),
"x_log_allowed": bool(axes.x_log_allowed),
}
def _serialize_heatmap(hm) -> dict[str, Any]:
y = np.asarray(hm.y, dtype=float)
z = np.asarray(hm.z, dtype=np.float32)
return {
"x": _numbers(hm.x),
"y": _numbers(y),
"z": [[float(v) for v in row] for row in z.tolist()],
"z_min": float(hm.z_min),
"z_max": float(hm.z_max),
"cmap": hm.cmap,
"label": hm.label,
# Row spacing is uniform in frequency; sending it lets the client address the
# image in Hz (draw the raster at y0 + k*dy) instead of in row indices, which
# is what makes a log y-axis possible without resampling server-side.
"y0": float(y[0]) if y.size else 0.0,
"dy": float(y[1] - y[0]) if y.size > 1 else 1.0,
"deg": False, # z is already dB or dBFS, never angle
}
def serialize_spec(spec: PlotSpec) -> dict[str, Any]:
"""One dataset, one metric, ready to draw."""
payload = {
"title": spec.title,
"axes": _serialize_axes(spec.axes),
"hlines": [
{
"y": float(hl.y),
"label": hl.label,
"color": hl.color,
"style": hl.style,
"width": float(hl.width),
}
for hl in spec.hlines
],
"annotations": list(spec.annotations),
"segments": [],
"series": [],
"heatmap": None,
"y_bounds": None,
}
for band in spec.bands:
lo = np.broadcast_to(np.asarray(band.lo, dtype=float), np.asarray(band.x).shape)
payload["segments"].append({
"kind": "band",
"x": _numbers(band.x),
"lo": _numbers(lo),
"hi": _numbers(band.hi),
"label": band.label,
"color": band.color,
})
for curve in spec.curves:
payload["series"].append({
"kind": "line",
"x": _numbers(curve.x),
"y": _numbers(curve.y),
"label": curve.label,
"width": float(curve.width),
"color": curve.color,
})
if spec.heatmap is not None:
payload["heatmap"] = _serialize_heatmap(spec.heatmap)
# Value range the browser should frame to, before any user zoom. For bands the
# envelope is lo..hi, not just hi — RMS fills from zero, waveform spans ±peak.
values = [np.asarray(c.y, dtype=float) for c in spec.curves]
for band in spec.bands:
values.append(np.asarray(band.lo, dtype=float))
values.append(np.asarray(band.hi, dtype=float))
if not spec.axes.y_range:
finite = [v[np.isfinite(v)] for v in values]
finite = [v for v in finite if v.size]
if finite:
payload["y_bounds"] = [float(min(v.min() for v in finite)),
float(max(v.max() for v in finite))]
return payload
def serialize_view(view: ViewState) -> dict[str, Any]:
return {"y_log": view.y_log, "x_log": view.x_log, "x_mode": view.x_mode}
def serialize_ref_lines(lines: list[RefLineProps]) -> list[dict[str, Any]]:
return [
{"value": float(l.value), "color": l.color, "style": l.style, "label": l.label}
for l in lines
]
def relative_x_mode(view: ViewState) -> bool:
return view.x_mode == X_RELATIVE