5df802a574
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.
171 lines
5.5 KiB
Python
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
|