12 Commits

Author SHA1 Message Date
mikkeli 239b764393 Document web UI interactions and content-versioned assets
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
2026-09-15 17:15:01 +09:00
mikkeli 5420975f8e Version web assets by content so caches can't serve stale scripts
Cloudflare forces browser caching of .js/.css to max-age=14400 regardless
of the origin's no-cache, while the page itself stays uncached. After a
deploy, browsers paired the new index.html with the old app.js, which
failed on upload with "Cannot set properties of null (setting
'innerHTML')".

The image build now copies every asset into v/<content hash>/ and rewrites
index.html's references; nginx serves v/ as immutable and the page as
no-cache, so each deploy changes every asset URL. Security headers move to
a snippet included per location, since nginx drops server-level add_header
in locations that set their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
2026-09-15 17:15:01 +09:00
mikkeli aed2d54b45 Match the desktop plot interactions in the web UI
- Wheel zoom around the cursor on both curve plots and the spectrogram:
  Ctrl zooms time only, Shift values only, scrolling over an axis zooms
  that axis; drag pans live; double-click resets. The zoom window survives
  redraws, overlay changes and resizes, and resets when units change.
- Guide and reference lines are painted in a uPlot draw hook, so a dragged
  reference line (now with a triangle handle and label) follows the cursor
  instead of jumping on release. Per-track integrated lines take the
  track's colour when overlaid.
- In-plot legend like pyqtgraph's: a swatch per track plus LRA / max dBTP.
- Metadata shows integrated loudness, LRA and max true peak (prefetched
  first) instead of debug rows; the file list shows each track's LUFS.
- UI, canvas and axis text prefer M PLUS 1 Code, else the system font.
- Spectrogram image is cached offscreen so panning is cheap; its tick
  labels adapt to the zoom level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
2026-09-15 17:15:01 +09:00
mikkeli 6ae4d5e8e3 Document the in-browser web UI and its parity tests
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
2026-09-14 10:16:52 +09:00
mikkeli da4231ad36 Add parity tests for the browser port against metrics.py
fixtures.py synthesises audio aimed at the metric code's branches: shorter
than each window, digital silence, a passage below the relative gate,
96 kHz, every PCM layout the direct WAV/AIFF reader handles, and (with
ffmpeg) MP3/Vorbis/Opus/AAC. reference.py loads each file as the desktop
app does and saves the exact mono signal plus every compute() result;
parity.mjs runs web/dsp.js on that same signal and compares every field.

Current result: 323/323 fields in tolerance; float64 paths agree to ~1e-12,
float32 paths (true peak, spectrogram dB, RMS) to ~1e-6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
2026-09-14 10:16:52 +09:00
mikkeli e8da88b6c1 Run the web UI entirely in the browser
The FastAPI backend kept one analysis session shared by every visitor and
spooled each upload to the server's /tmp, and the browser copied audio into
persistent OPFS storage. None of that fits a tool for unreleased masters, so
the web UI is now a static site that decodes and analyses in the tab:

- web/decode.js: WAV/AIFF PCM and float read directly with libsndfile's
  scaling (bit-identical to librosa.load; Chromium's WAV decoder scales
  positive int16 by 1/32767), other formats via decodeAudioData on an
  OfflineAudioContext at the header-sniffed native rate. ID3 song names.
- web/dsp.js: ports of every Metric.compute with the same numerics
  (pyloudnorm K-weighting via lfilter's DF2T recurrence, BS.1770 gating,
  EBU LRA, resample_poly's Kaiser filter for true peak, librosa.stft and
  amplitude_to_db), streaming passes with no signal-length intermediates.
- web/metrics.js: build_spec ports producing the existing plot payload.
- web/analysis-worker.js + app.js: per-tab track list and result cache, a
  worker job queue where the visible metric jumps background prefetch, and
  the renderer, which now also draws the metrics' own reference lines.

Deployment is nginx-unprivileged serving web/ read-only with
Content-Security-Policy connect-src 'none', so the page cannot make network
requests at all, and no access log. No secure context is needed, so plain
http on a LAN works. The Python web backend, its smoke test, the web extra
and the OPFS store are removed; the desktop app is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
2026-09-14 10:16:52 +09:00
mikkeli b5c12bfcb4 Send stored audio as a Blob body so large files upload on HTTP/1.1
Files over 8 MB were sent as a ReadableStream with duplex: 'half'. Chrome
only allows streaming request bodies over HTTP/2+, so every such upload
failed with "Failed to fetch" on HTTP/1.1 (e.g. http://localhost). A Blob
body is read lazily from OPFS by the browser, so it keeps the "never hold
the whole master in memory" property without depending on the protocol.

Verified in headless Chromium: a 33 MB WAV drops and analyses both at
http://localhost:8050 and behind Cloudflare/Caddy at a /uj/ prefix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
2026-09-14 02:44:20 +09:00
mikkeli e50eff9122 Use relative asset and API URLs so the UI works under a path prefix
index.html and app.js requested /static/... and /api/... from the site
root, so serving the app behind a reverse proxy at a sub-path (e.g.
https://host/uj/ with the prefix stripped) 404'd every asset and call.
Relative URLs resolve against the page, which is the same at / and at
/uj/; direct access is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
2026-09-14 02:37:26 +09:00
mikkeli 677d09524e Make the hidden attribute win over display rules
.drop-overlay sets display: flex, which beats the UA's [hidden] rule, so
the "Drop to analyse" overlay covered the page permanently and hid the
status line. A global [hidden] { display: none !important } fixes it and
any other element that pairs `hidden` with a display class.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
2026-09-14 02:33:28 +09:00
mikkeli 5c918109d5 Keep master_core off Qt so the web image starts
master_core imported safe_title from font_manager, which imports PyQt5 at
module load. The web extra installs no Qt, so `ujm-web` crashed on startup
with "No module named 'PyQt5'" inside the container. safe_title is only a
UTF-8 round-trip, so master_core now carries its own Qt-free copy; the
desktop path keeps using font_manager's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
2026-09-14 02:33:28 +09:00
irene a1fed2cd71 Add docker compose deployment template
Multi-stage image built from the `web` extra only, so it carries no Qt: the
whole web path (webapp, web_session, metrics_json, and the shared analysis
modules) imports no Qt, pyqtgraph or matplotlib canvas. The build stage has the
compiler scipy/numba need; the runtime layer installs the built wheel, so the
same image works from a checkout or a release tarball.

Compose declares no audio volume on purpose. The app keeps no audio, so there is
nothing to persist: `read_only` plus a /tmp tmpfs means request spools are
memory-backed and never reach a disk. Publishing is loopback-only because the
tool has no authentication, and BLAS thread pools are pinned because analysis is
CPU-bound.

Fix three packaging bugs the build surfaced, each of which broke the image:

- `pip install --extra web` is not valid for a source tree; uv wants `.[web]`
  and failed with "Requesting extras requires a pylock.toml".
- The wheel shipped no static assets at all. `package-data` keys match
  *packages*, and the app modules are top-level `py-modules`, so the `"*"` key
  matched nothing - verified by inspecting the built wheel: 14 .py files, zero
  HTML/JS/CSS. An installed `ujm-web` would have 404'd every asset. `static/` is
  now declared a package (hence its packaging-shim `__init__.py`) so the assets
  install next to the modules, which is where `webapp.py` looks for them.
- The Dockerfile's curated COPY list omitted modules that `py-modules` requires,
  failing the build with "file <module>.py not found". It copies the checkout,
  filtered by .dockerignore.

Move PyQt5/pyqtgraph into a `desktop` extra so the web install is genuinely
Qt-free. Verified: `find` for Qt in a web-only venv now returns nothing, and the
desktop path still resolves and imports.

Add scripts/make_fixtures.py so the smoke suite has no network or ffmpeg
dependency (the tagged MP3 is optional and its assertions skip when absent), and
make the suite tolerate that. Verified against the *installed package* running
the container's own command line, from a directory with no source tree:
61/61 passing.

Also correct README claims that had gone stale: BPM (removed), the matplotlib
canvas (replaced by pyqtgraph), and font_control_widget.py (deleted).
2026-09-14 01:57:00 +09:00
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
22 changed files with 3855 additions and 21 deletions
+7
View File
@@ -0,0 +1,7 @@
# The image is web/ served with deploy/nginx.conf. Allowlist those and nothing
# else, so no local state (audio, fixtures, caches, .env, keys) can ever end up
# in the build context.
*
!web/
!deploy/nginx.conf
!deploy/security-headers.conf
+66
View File
@@ -11,6 +11,8 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
- **Modular GUI Architecture**: Complete PyQt5 interface with drag-and-drop and file dialog support - **Modular GUI Architecture**: Complete PyQt5 interface with drag-and-drop and file dialog support
- **Font Management**: CJK-capable, fixed UI font (M PLUS 1 Code @ 10pt) with system fallback - **Font Management**: CJK-capable, fixed UI font (M PLUS 1 Code @ 10pt) with system fallback
- **Threading & Logging**: Robust background processing with detailed logging system - **Threading & Logging**: Robust background processing with detailed logging system
- **Web UI** (`web/`): the same metrics ported to in-browser JavaScript — a static
site; audio never leaves the tab
### Technical stack ### Technical stack
- **Audio Processing**: librosa, numpy - **Audio Processing**: librosa, numpy
@@ -109,6 +111,56 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
gone with the matplotlib render path. If wanted back, it belongs in the gone with the matplotlib render path. If wanted back, it belongs in the
renderer, applied uniformly to every metric — not per-metric renderer, applied uniformly to every metric — not per-metric
#### `web/` (browser version — no backend)
- A static site: decoding and every metric run in the visitor's tab; nothing is
uploaded or stored. Served with `Content-Security-Policy: connect-src 'none'`
(`deploy/nginx.conf`), so the page and its worker cannot make network requests
- The image versions assets by content: everything but `index.html` is copied to
`v/<hash>/` and the page's references rewritten (Dockerfile `assets` stage);
nginx serves `v/` immutable and the page `no-cache`. Needed because Cloudflare
forces browser caching of .js/.css to 4 h, which paired a fresh index.html with
a stale app.js (`Cannot set properties of null`). Source `web/index.html` keeps
plain relative paths, so serving `web/` directly still works
- `decode.js` — `AudioFile.__init__` counterpart. WAV/AIFF PCM & float are parsed
directly with libsndfile scaling (bit-identical to `librosa.load`; Chromium's
own WAV decoder scales positive int16 by 1/32767). Other formats go through
`decodeAudioData` on an `OfflineAudioContext` at the header-sniffed native rate
(no resampling). ID3 artist/title for MP3. Downmix = float32 channel mean
- `dsp.js` — ports of every `Metric.compute`: pure functions over the mono
Float32Array (run in the worker and under Node). Same numerics as the Python:
sequential float64 prefix sums sampled only at window edges (no N-length
arrays), pyloudnorm K-weighting via scipy `lfilter`'s DF2T recurrence (streamed),
`resample_poly(y, 4, 1)`'s 81-tap Kaiser(5) filter + monotonic-deque window max,
`librosa.stft` centred framing + `amplitude_to_db(ref=max, top_db=80)`.
Loudness (short-term, integrated, LRA) is one memoised pass shared by LUFS and PSR
- `metrics.js` — registry + `build_spec` ports producing the plot payload
(`{axes, hlines, annotations, segments, series, heatmap}`), plus `applyRelativeX`
- `analysis-worker.js` — Web Worker: holds each track's mono signal, computes one
metric per request, transfers typed arrays back
- `app.js` — `MainWindow` + `AnalysisResultsManager` + renderer: track list,
overlay set, per-metric result cache, job queue (foreground jobs jump ahead of
background prefetch; LUFS + true peak prefetched first for the metadata
panel's loudness summary), uPlot for curves/bands + canvas spectrogram (log
axis drawn from linear rows, cached offscreen image). Guide and reference
lines are painted in a uPlot draw hook, so dragging a line is a cheap redraw
and moves live. One zoom/pan controller serves both renderers: wheel zoom
(Ctrl = x only, Shift = y only, over an axis = that axis), rAF-throttled drag
pan, double-click reset; `state.zoom` survives redraws and resets when units
change. In-plot legend overlay like pyqtgraph's
- Font: CSS `--font` stack prefers M PLUS 1 Code, else the system UI font;
canvas/uPlot text reads the same variable
- `vendor/` — uPlot 1.6.32, vendored (the CSP only allows same-origin scripts)
- Needs an HTTP server (module scripts and workers don't load from `file://`),
but no secure context — plain `http://` on a LAN works
#### `tests/parity/`
- `fixtures.py` synthesises edge-case audio (short / silent / below the relative
gate, 96 kHz, every PCM layout, lossy formats via ffmpeg); `reference.py` runs
`metrics.py` and saves the exact mono signal it analysed; `parity.mjs` runs
`web/dsp.js` on that signal and compares every field (float64 paths ~1e-12,
float32 paths ~1e-6)
- Any change to `metrics.py` numerics must be mirrored in `web/dsp.js` and pass this
#### `master_core.py` #### `master_core.py`
- Defines the `AudioFile` class: librosa loading, rolling RMS power. BPM detection - Defines the `AudioFile` class: librosa loading, rolling RMS power. BPM detection
was **removed** — `librosa.beat.beat_track` cost ~3.7 s on every load for a was **removed** — `librosa.beat.beat_track` cost ~3.7 s on every load for a
@@ -225,6 +277,8 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
- matplotlib: Colormaps only (consumed by pyqtgraph) + librosa dependency - matplotlib: Colormaps only (consumed by pyqtgraph) + librosa dependency
- mutagen: Audio metadata extraction - mutagen: Audio metadata extraction
- PyQt5: GUI framework - PyQt5: GUI framework
- Web UI: no Python dependencies; uPlot is vendored in `web/vendor/`. The parity
tests need Node 18+
### Architecture considerations ### Architecture considerations
- Three-stage split: `metrics.compute` (heavy, worker thread, backend-neutral - Three-stage split: `metrics.compute` (heavy, worker thread, backend-neutral
@@ -248,6 +302,18 @@ uv sync # one-time, after cloning
uv run ujm # launch the GUI uv run ujm # launch the GUI
``` ```
### Running the web UI
Branch `feature/web-js`. Static files in `web/`; everything runs in the browser.
```bash
docker compose up -d --build # nginx + CSP, http://localhost:8080
python3 -m http.server -d web 8080 # quick local try (no CSP header)
```
Audio is decoded and analysed in the tab and never leaves it (CSP
`connect-src 'none'`). Check the port against `metrics.py` with the parity tests
(`tests/parity`, see README).
Optional flags (handled by `logger_setup.parse_log_args`): Optional flags (handled by `logger_setup.parse_log_args`):
```bash ```bash
uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE
+38
View File
@@ -0,0 +1,38 @@
# uj-mastering-master — web UI
#
# The web UI is a static site: decoding and every metric run in the visitor's
# browser (web/dsp.js inside a Web Worker). This image only serves files — no
# application server, no upload endpoint, nothing written anywhere — so it runs
# read-only as nginx's unprivileged user. See deploy/nginx.conf for the CSP that
# stops the page from making network requests at all.
#
# The desktop app (PyQt5) is unaffected and not part of this image.
# ---- assets: version every file except the page by content ------------------
# Everything but index.html goes under v/<hash of the assets>/, and index.html's
# references are rewritten to match. Each deploy therefore changes every asset
# URL, so no cache (browser or Cloudflare, which forces .js/.css browser caching
# to 4 h) can pair a new index.html with an old app.js. Module imports and the
# worker URL are relative, so they resolve inside the versioned directory as is.
FROM alpine:3.22 AS assets
COPY web/ /src/
RUN set -eu; \
cd /src; \
v="$(find . -type f ! -name index.html | sort | xargs sha256sum | sha256sum | cut -c1-12)"; \
mkdir -p "/out/v/$v"; \
for f in *; do [ "$f" = index.html ] || cp -R "$f" "/out/v/$v/"; done; \
sed -E "s#(href|src)=\"(app\.css|app\.js|vendor/[^\"]+)\"#\1=\"v/$v/\2\"#g" index.html > /out/index.html; \
grep -q "src=\"v/$v/app.js\"" /out/index.html; \
grep -q "href=\"v/$v/app.css\"" /out/index.html
# ---- runtime -------------------------------------------------------------------
FROM nginxinc/nginx-unprivileged:stable-alpine
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY deploy/security-headers.conf /etc/nginx/snippets/ujm-security-headers.conf
COPY --from=assets /out/ /usr/share/nginx/html/
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1
+116 -15
View File
@@ -20,11 +20,11 @@ Developed with Claude Code assistance.
spectrogram's 22 kHz top, which otherwise falls between decade ticks) spectrogram's 22 kHz top, which otherwise falls between decade ticks)
- **Native sample rate**: audio is loaded without resampling, so the full band - **Native sample rate**: audio is loaded without resampling, so the full band
(up to the file's own nyquist, e.g. ~22 kHz for 44.1 kHz files) is analysed (up to the file's own nyquist, e.g. ~22 kHz for 44.1 kHz files) is analysed
- **BPM detection** via librosa - **CJK-safe font system** with custom fonts loaded from `fonts/` (gitignored) and system fallbacks; the UI is pinned to M PLUS 1 Code @ 10pt
- **CJK-safe font system** with custom fonts loaded from `fonts/` (gitignored), system fallbacks, and a live font selector
- **Background analysis thread** so the UI stays responsive; metric switches - **Background analysis thread** so the UI stays responsive; metric switches
compute off the GUI thread and cache, so re-selecting a metric is instant compute off the GUI thread and cache, so re-selecting a metric is instant
- **Embedded matplotlib canvas** with auto-regenerated plots on font change - **pyqtgraph renderer**: one persistent `PlotItem` reused across replots, with
per-axis zoom modifiers (Ctrl+scroll for time only, Shift+scroll for the value axis)
### Roadmap ### Roadmap
See [CLAUDE.md](CLAUDE.md) for the full development roadmap. Near-term: See [CLAUDE.md](CLAUDE.md) for the full development roadmap. Near-term:
@@ -35,11 +35,15 @@ dynamic range (DR meter), plot-style controls, interactive axis controls.
This project uses [uv](https://docs.astral.sh/uv/). With uv installed: This project uses [uv](https://docs.astral.sh/uv/). With uv installed:
```bash ```bash
uv sync uv sync --extra desktop
uv run ujm uv run ujm
``` ```
`uv run ujm` is the only supported entry point — it boots the GUI. `uv run ujm` boots the desktop GUI (see *Web UI* for the browser version).
`uv sync` on its own installs only the analysis dependencies (librosa, numpy,
scipy, mutagen, pyloudnorm). The GUI stack — PyQt5, pyqtgraph — is the
`desktop` extra.
### Logging flags ### Logging flags
```bash ```bash
@@ -52,9 +56,100 @@ Drop `.ttf` / `.otf` / `.ttc` files into `fonts/` to get them in the font
selector. The directory is gitignored to avoid bundling licensed font data. selector. The directory is gitignored to avoid bundling licensed font data.
See [CJK_FONTS.md](CJK_FONTS.md) for details. See [CJK_FONTS.md](CJK_FONTS.md) for details.
## Web UI
A browser version that runs entirely inside the tab, in `web/` (branch
`feature/web-js`). There is no Python backend: decoding and all seven metrics are
JavaScript ports of `master_core.py` / `metrics.py`, computed in a Web Worker and
drawn from the same `PlotSpec`-shaped payloads the desktop renderer uses.
**Audio never leaves the browser.** A dropped file is read into memory, decoded,
reduced to the mono signal the metrics use and analysed — nothing is uploaded,
cached or written to storage, and closing the tab discards it. This is enforced,
not just promised: the page is served with `Content-Security-Policy: connect-src
'none'`, so neither the page nor its worker is permitted to make any network
request.
Everything the desktop app does works the same way: all seven metrics, the
overlay, lin/log toggles, absolute/relative time, an in-plot legend, wheel zoom
around the cursor (Ctrl: time only, Shift: values only, or scroll over an axis),
live drag-to-pan and double-click to reset, and draggable reference lines (kept
per metric) alongside the metrics' own guides (−14 LUFS, integrated loudness,
0 dBTP…). The metadata panel and file list show integrated loudness, LRA and
max true peak. Analysis runs at the file's native sample rate.
Serve `web/` with any static file server, over `http://` or `https://` — no
secure context is needed. (ES modules and workers don't load from `file://`, so
opening `index.html` directly won't work.)
```bash
docker compose up -d --build # nginx + the CSP, http://localhost:8080
python3 -m http.server -d web 8080 # quick local try (no CSP header)
```
### How the port matches the Python
- **Decoding** (`web/decode.js`): WAV and AIFF (PCM 8/16/24/32-bit, float 32/64,
WAVE_FORMAT_EXTENSIBLE) are parsed directly with libsndfile's sample scaling,
so the signal is bit-identical to `librosa.load`. Other formats (FLAC, MP3, Ogg
Vorbis/Opus, AAC) use the browser's decoder through an `OfflineAudioContext`
running at the sample rate read from the file header, so nothing is resampled.
MP3 artist/title come from ID3 tags, as on the desktop.
- **Metrics** (`web/dsp.js`): the same algorithms and numerics — pyloudnorm's
K-weighting through scipy's `lfilter` recurrence, BS.1770 gating and EBU LRA,
`resample_poly`'s 81-tap Kaiser filter for true peak, `librosa.stft` framing and
`amplitude_to_db` for the spectrogram. Running sums and window maxima are
single streaming passes, so no signal-length intermediate arrays are allocated.
### Parity tests
`tests/parity` checks the port against the Python on synthetic fixtures that
cover what the metric code branches on — shorter than each window, silence,
passages below the relative gate, 96 kHz, every PCM layout, lossy formats:
```bash
uv run --no-project --with numpy --with soundfile \
python tests/parity/fixtures.py /tmp/ujm-parity
uv run --no-project --with librosa --with pyloudnorm --with scipy --with mutagen \
python tests/parity/reference.py /tmp/ujm-ref /tmp/ujm-parity/*
node tests/parity/parity.mjs /tmp/ujm-ref
```
`reference.py` saves the exact mono signal `metrics.py` analysed, and
`parity.mjs` runs `web/dsp.js` on that same signal and compares every output
field. The float64 paths (loudness, crest factor, PSR) agree to ~1e-12, the
float32 paths (true peak, spectrogram dB, RMS) to ~1e-6.
### Deploying with Docker
The image is nginx (unprivileged, read-only root filesystem) serving `web/` with
`deploy/nginx.conf`, which sets the CSP and turns access logging off. There is
nothing to persist, upload or log on the server side, so there are no volumes.
Assets are served from a content-hashed `v/<hash>/` directory (cached
indefinitely) while the page itself is always revalidated, so a deploy can never
leave a browser running old scripts against a new page — even behind a CDN that
overrides cache headers.
### Web architecture
| Module | Responsibility |
| --- | --- |
| `web/decode.js` | File → mono Float32Array at native rate (direct WAV/AIFF, browser decoder otherwise); ID3 tags |
| `web/dsp.js` | Ports of every `Metric.compute`; pure functions (also run under Node for the parity tests) |
| `web/metrics.js` | Metric registry + `build_spec` ports producing plot payloads; relative-time transform |
| `web/analysis-worker.js` | Web Worker holding each track's signal and computing one metric per request |
| `web/app.js` | Track list, overlay, result cache, foreground/prefetch job queue, uPlot + canvas renderer |
| `deploy/nginx.conf` | Static serving, CSP (`connect-src 'none'`), no access log |
## Dependencies ## Dependencies
`librosa`, `numpy`, `matplotlib`, `mutagen`, `pyloudnorm`, `PyQt5` — all pinned Core (always installed): `librosa`, `numpy`, `matplotlib`, `mutagen`,
through `uv.lock`. Python 3.10+. `pyloudnorm` — pinned through `uv.lock`. Python 3.10+.
Optional extras:
- `desktop` — `PyQt5`, `pyqtgraph` (the GUI)
The web UI has no dependencies beyond uPlot, vendored in `web/vendor/`. The parity
tests need Node 18+ plus the Python analysis dependencies.
## Architecture ## Architecture
@@ -62,18 +157,24 @@ through `uv.lock`. Python 3.10+.
| --- | --- | | --- | --- |
| `main.py` | `MainWindow` + the `ujm` entry point | | `main.py` | `MainWindow` + the `ujm` entry point |
| `analysis_results_manager.py` | Background `QThread` worker, result + metric-data cache | | `analysis_results_manager.py` | Background `QThread` worker, result + metric-data cache |
| `master_core.py` | `AudioFile`: native-rate librosa loading, RMS rolling window, BPM | | `master_core.py` | `AudioFile`: native-rate librosa loading, rolling RMS |
| `metrics.py` | Pluggable `Metric` ABC + registry (RMS, Waveform, LUFS, Crest, PSR, True Peak, Spectrogram) | | `metrics.py` | Pluggable `Metric` ABC + registry (RMS, Waveform, LUFS, Crest, PSR, True Peak, Spectrogram) |
| `audio_visualization_widget.py` | Embedded `FigureCanvasQTAgg` host | | `audio_visualization_widget.py` | pyqtgraph `PlotItem` host; renders `PlotSpec` |
| `font_manager.py` | Custom + system CJK font discovery, matplotlib/Qt config | | `plotspec.py` | Renderer-neutral plot description (`Curve`/`Band`/`HLine`/`Heatmap`) |
| `font_control_widget.py` | Font picker + size slider | | `font_manager.py` | Custom + system CJK font discovery, Qt font config |
| `plot_control_widget.py` | Metric selector + refresh-plot button | | `plot_control_widget.py` | Metric selector, log-frequency toggle, time-axis mode |
| `ref_line_widget.py` | Reference-line control; values kept per metric, in its own units |
| `logger_setup.py` | CLI log-level parsing + custom TRACE level | | `logger_setup.py` | CLI log-level parsing + custom TRACE level |
| `setup_fonts.py` | Diagnostic utility (run standalone) | | `setup_fonts.py` | Diagnostic utility (run standalone) |
### Adding a metric ### Adding a metric
Subclass `Metric` in `metrics.py`, implement `compute(audio_file) -> data` (the Subclass `Metric` in `metrics.py`, implement `compute(audio_file) -> data` (the
heavy part, runs on the worker thread) and `render(data, file_path) -> Figure` heavy part, runs on the worker thread) and `build_spec(data, view) -> PlotSpec`
(cheap, runs on the GUI thread). Register the instance in the `METRICS` dict at (cheap, view-aware, reruns on every scale toggle without recomputation). Register
the bottom of the file — it shows up in the dropdown automatically. the instance in the `METRICS` dict at the bottom of the file — it shows up in the
desktop dropdown automatically.
For the web UI, port `compute` to `web/dsp.js` (add it to `COMPUTE`) and
`build_spec` to `web/metrics.js` (add it to `METRICS`), then add its fields to the
tolerance table in `tests/parity/parity.mjs` so the parity run checks it.
+39
View File
@@ -0,0 +1,39 @@
# Static server for the web UI (web/).
#
# The page decodes and analyses audio inside the visitor's browser. The CSP's
# `connect-src 'none'` (security-headers.conf) forbids the page and its worker
# from making any network request (fetch, XHR, WebSocket, beacon), so dropped
# audio has no way to leave the tab — enforced by the browser, not just promised
# by the code.
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Nothing the app does reaches the server beyond fetching these files, and
# there is no reason to keep a record of who fetched them.
access_log off;
server_tokens off;
gzip on;
gzip_types text/css application/javascript;
# The page names the current asset version, so it is always revalidated.
# (Each location includes the security headers itself: nginx drops
# server-level add_header in any location that adds its own.)
location / {
include /etc/nginx/snippets/ujm-security-headers.conf;
add_header Cache-Control "no-cache" always;
try_files $uri $uri/ =404;
}
# Assets live under v/<content hash>/ (see Dockerfile): a URL's content never
# changes, so it can be cached indefinitely.
location /v/ {
include /etc/nginx/snippets/ujm-security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
}
+8
View File
@@ -0,0 +1,8 @@
# Security headers for every response, included per location in nginx.conf.
#
# connect-src 'none': neither the page nor its Web Worker may make network
# requests, so audio cannot be sent anywhere. The worker's own policy comes from
# its script response, which is why every location carries these headers.
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'none'; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Content-Type-Options "nosniff" always;
+37
View File
@@ -0,0 +1,37 @@
# uj-mastering-master — web UI, container deployment template.
#
# docker compose up -d --build
# open http://localhost:8080
#
# The container serves static files only. Audio is decoded and analysed inside
# the visitor's browser tab and never sent anywhere (the page's CSP forbids
# network requests), so there is nothing to upload, persist or log here — and no
# volumes, on purpose.
services:
ujm-web:
build: .
image: uj-mastering-master-web:local
container_name: ujm-web
ports:
- "8080:8080"
# Nothing to write: nginx only needs somewhere for its pid file and empty
# temp directories.
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "1m"
max-file: "2"
+7 -1
View File
@@ -7,7 +7,13 @@ import numpy as np
from mutagen.mp3 import MP3 from mutagen.mp3 import MP3
from mutagen.easyid3 import EasyID3 from mutagen.easyid3 import EasyID3
from font_manager import safe_title
def safe_title(title):
# Deliberately not font_manager.safe_title: that module imports PyQt5 at load
# time, and master_core is on the Qt-free web path (the `web` extra has no Qt).
if not title:
return title
return title.encode('utf-8', errors='replace').decode('utf-8')
def _try_mp3_tags(file_path): def _try_mp3_tags(file_path):
+21 -2
View File
@@ -6,12 +6,25 @@ build-backend = "setuptools.build_meta"
name = "uj-mastering-master" name = "uj-mastering-master"
version = "0.1.0" version = "0.1.0"
requires-python = ">=3.10" requires-python = ">=3.10"
# What the *analysis* needs. The GUI stack lives in the `desktop` extra below.
# The web UI (web/) is a static site that analyses audio in the browser and needs
# none of these; `tests/parity` uses them to check the browser port against
# `metrics.py`.
dependencies = [ dependencies = [
"librosa", "librosa",
"numpy", "numpy",
"matplotlib", "matplotlib", # librosa dependency, and colormaps for the colormap helper
"mutagen", "mutagen",
"pyloudnorm", "pyloudnorm",
]
license = {text = "Apache-2.0"}
[project.optional-dependencies]
# Desktop GUI. Installed with:
# uv sync --extra desktop (or `uv sync --all-extras`)
desktop = [
"PyQt5>=5.15.10", "PyQt5>=5.15.10",
# 5.15.2 is the only pyqt5-qt5 release with a Windows wheel; later # 5.15.2 is the only pyqt5-qt5 release with a Windows wheel; later
# versions are Linux/macOS only. # versions are Linux/macOS only.
@@ -19,7 +32,13 @@ dependencies = [
"pyqtgraph>=0.14.0", "pyqtgraph>=0.14.0",
] ]
license = {text = "Apache-2.0"} [tool.uv]
# Note: `default-groups` cannot name an entry from `[project.optional-dependencies]`
# — it only accepts `[dependency-groups]` names, and errors with "Default group
# `desktop` ... is not defined in the project's `dependency-groups` table".
# Extras must be requested explicitly:
# uv sync # analysis only
# uv sync --extra desktop # + Qt/pyqtgraph (the GUI; `uv run ujm`)
[project.scripts] [project.scripts]
ujm = "main:main" ujm = "main:main"
+86
View File
@@ -0,0 +1,86 @@
# SPDX-License-Identifier: Apache-2.0
"""Synthesise the audio the web-port parity tests run on.
Aimed at the branches in the metric code rather than at sounding like a song:
shorter than each metric's window, digital silence (gated loudness -> -inf /
NaN), a passage below the relative gate, int16 / int24 / float WAV, 96 kHz FLAC
and stereo downmix. When ffmpeg is on PATH it also writes an ID3-tagged MP3 and
Ogg Vorbis / Opus / AAC files for the in-browser decoder checks.
uv run --no-project --with numpy --with soundfile \
python tests/parity/fixtures.py OUT_DIR
"""
import shutil
import subprocess
import sys
from pathlib import Path
import numpy as np
import soundfile as sf
def tone_mix(sr: int, seconds: float, seed: int) -> np.ndarray:
rng = np.random.default_rng(seed)
t = np.arange(int(sr * seconds)) / sr
y = (0.3 * np.sin(2 * np.pi * 110 * t)
+ 0.15 * np.sin(2 * np.pi * 1760 * t + 0.3)
+ 0.05 * rng.standard_normal(len(t)))
# Slow level movement so short-term loudness and LRA have something to show.
return (y * (0.6 + 0.4 * np.sin(2 * np.pi * 0.07 * t))).astype(np.float32)
def main() -> None:
out = Path(sys.argv[1] if len(sys.argv) > 1 else "/tmp/ujm-parity")
out.mkdir(parents=True, exist_ok=True)
sr = 44100
left, right = tone_mix(sr, 40, 1), tone_mix(sr, 40, 2) * 0.8
for ch in (left, right):
ch[10 * sr:15 * sr] = 0.0 # below the absolute gate
ch[20 * sr:25 * sr] *= 10 ** (-45 / 20) # below the relative gate
left[30 * sr:30 * sr + 2000] = 0.99 # near-full-scale burst for true peak
music = out / "music_44k1_s16_stereo.wav"
sf.write(music, np.stack([left, right], 1), sr, subtype="PCM_16")
y = tone_mix(48000, 120, 3)
sf.write(out / "long_48k_s24_stereo.wav", np.stack([y, np.roll(y, 480)], 1), 48000, subtype="PCM_24")
y = tone_mix(96000, 20, 4)
sf.write(out / "hires_96k_s24.flac", np.stack([y, -0.5 * y], 1), 96000, subtype="PCM_24")
sf.write(out / "short_1500ms_48k_float.wav", tone_mix(48000, 1.5, 5), 48000, subtype="FLOAT")
sf.write(out / "tiny_200ms_44k1_s16.wav", tone_mix(44100, 0.2, 6), 44100, subtype="PCM_16")
sf.write(out / "silence_5s_48k_s16.wav", np.zeros(5 * 48000, dtype=np.float32), 48000, subtype="PCM_16")
# Every PCM layout the browser's direct WAV/AIFF reader handles.
y = tone_mix(48000, 4, 7)
stereo = np.stack([y, -0.7 * y], 1)
sf.write(out / "fmt_s24_48k.wav", stereo, 48000, subtype="PCM_24")
sf.write(out / "fmt_s32_48k.wav", stereo, 48000, subtype="PCM_32")
sf.write(out / "fmt_f64_48k_mono.wav", y, 48000, subtype="DOUBLE")
sf.write(out / "fmt_u8_22k05_mono.wav", tone_mix(22050, 4, 8), 22050, subtype="PCM_U8")
sf.write(out / "fmt_wavex_s24_96k.wav", np.stack([tone_mix(96000, 3, 9)] * 3, 1), 96000,
format="WAVEX", subtype="PCM_24")
sf.write(out / "fmt_s16_44k1.aiff", stereo[: 44100 * 4], 44100, format="AIFF", subtype="PCM_16")
sf.write(out / "fmt_f32_48k.aifc", stereo, 48000, format="AIFF", subtype="FLOAT")
if shutil.which("ffmpeg"):
encodes = {
"tagged_44k1.mp3": ["-codec:a", "libmp3lame", "-b:a", "192k", "-id3v2_version", "3",
"-metadata", "artist=ウジ", "-metadata", "title=テスト曲"],
"vorbis_44k1.ogg": ["-codec:a", "libvorbis", "-q:a", "5"],
"opus_48k.opus": ["-codec:a", "libopus", "-b:a", "128k"],
"aac_44k1.m4a": ["-codec:a", "aac", "-b:a", "192k"],
}
for name, args in encodes.items():
try:
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(music), *args, str(out / name)],
check=True)
except subprocess.CalledProcessError as exc:
print(f"skipped {name}: ffmpeg failed ({exc.returncode})")
for f in sorted(out.iterdir()):
print(f" {f.name:32s} {f.stat().st_size / 1024:8.0f} KiB")
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: Apache-2.0
//
// Parity check: web/dsp.js against the Python metrics.
//
// reference.py runs metrics.py on each fixture and saves the exact mono signal
// it analysed. This feeds that same signal to dsp.js and compares every output
// field, so decoder differences are out of the picture.
//
// node tests/parity/parity.mjs REF_DIR
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { COMPUTE } from '../../web/dsp.js';
// Largest acceptable |js - python| per field. The float64 paths (loudness,
// crest, PSR) should agree to ~1e-12; float32 paths (RMS mean, 4x resampling,
// spectrogram dB) to ~1e-6. Waveform min/max are exact sample values.
const TOLERANCE = {
rms_power: { times: 1e-9, rms: 1e-6 },
waveform: { times: 1e-9, lo: 0, hi: 0 },
lufs: { times: 1e-9, lufs: 1e-6, integrated: 1e-6, lra: 1e-6 },
crest_factor: { times: 1e-9, crest_db: 1e-6 },
psr: { times: 1e-9, psr: 1e-6 },
true_peak: { times: 1e-9, tp_db: 1e-4, integrated_tp_db: 1e-4 },
spectrogram: { times: 1e-9, freqs: 1e-6, s_db: 1e-3 },
};
function readF32(path) {
const buf = readFileSync(path);
return new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
}
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
function compare(js, py) {
const a = ArrayBuffer.isView(js) || Array.isArray(js) ? js : [js];
const b = ArrayBuffer.isView(py) || Array.isArray(py) ? py : [py];
if (a.length !== b.length) return { error: `length ${a.length} vs ${b.length}` };
let max = 0;
let at = -1;
let nonFinite = 0;
for (let i = 0; i < a.length; i++) {
const fx = isNum(a[i]);
const fy = isNum(b[i]);
if (!fx || !fy) {
if (fx !== fy) nonFinite++;
continue;
}
const d = Math.abs(a[i] - b[i]);
if (d > max) { max = d; at = i; }
}
return { max, at, nonFinite };
}
const refDir = process.argv[2];
if (!refDir) {
console.error('usage: node tests/parity/parity.mjs REF_DIR');
process.exit(2);
}
let failures = 0;
let checked = 0;
for (const name of readdirSync(refDir).sort()) {
const dir = join(refDir, name);
if (!existsSync(join(dir, 'reference.json'))) continue;
const ref = JSON.parse(readFileSync(join(dir, 'reference.json'), 'utf8'));
const mono = readF32(join(dir, 'mono.f32'));
const sr = ref.meta.sr;
const memo = {};
console.log(`\n${name} (${sr} Hz, ${(mono.length / sr).toFixed(2)} s)`);
for (const [metricId, expected] of Object.entries(ref.metrics)) {
const t0 = performance.now();
const got = COMPUTE[metricId](mono, sr, memo);
const ms = performance.now() - t0;
const fields = metricId === 'spectrogram'
? { times: expected.times, freqs: expected.freqs, s_db: readF32(join(dir, 'spectrogram.f32')) }
: expected;
if (metricId === 'spectrogram' && (got.rows !== expected.rows || got.cols !== expected.cols)) {
failures++;
console.log(` FAIL spectrogram shape ${got.rows}x${got.cols} vs ${expected.rows}x${expected.cols}`);
}
let first = true;
for (const [field, py] of Object.entries(fields)) {
const tol = TOLERANCE[metricId][field];
if (tol === undefined) continue;
const r = compare(got[field], py);
const ok = !r.error && r.nonFinite === 0 && r.max <= tol;
checked++;
if (!ok) failures++;
const diff = r.error || `max|d| ${r.max.toExponential(2)}${r.nonFinite ? `, ${r.nonFinite} non-finite mismatches` : ''}`;
console.log(` ${ok ? 'ok ' : 'FAIL'} ${metricId.padEnd(13)} ${field.padEnd(17)} ${diff.padEnd(24)} tol ${tol}`
+ (first ? ` ${ms.toFixed(0)} ms` : ''));
first = false;
}
}
}
console.log(failures ? `\n${failures} of ${checked} fields out of tolerance` : `\nall ${checked} fields within tolerance`);
process.exit(failures ? 1 : 0);
+74
View File
@@ -0,0 +1,74 @@
# SPDX-License-Identifier: Apache-2.0
"""Dump the Python metrics as the reference for the browser port (web/dsp.js).
Each audio file is loaded exactly as the desktop app loads it
(`master_core.AudioFile`); the mono signal the metrics see is written out next
to every metric's `compute()` result. `parity.mjs` feeds that same signal to
dsp.js, so the comparison isolates the DSP port from decoder differences.
uv run --no-project --with librosa --with pyloudnorm --with scipy --with mutagen \
python tests/parity/reference.py OUT_DIR AUDIO...
Writes OUT_DIR/<file name>/{mono.f32, reference.json, spectrogram.f32}.
"""
import json
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from master_core import AudioFile # noqa: E402
from metrics import METRICS # noqa: E402
def _jsonable(value):
if isinstance(value, np.ndarray):
return [float(v) if np.isfinite(v) else None for v in value.astype(np.float64).ravel()]
if isinstance(value, (float, np.floating)):
return float(value) if np.isfinite(value) else None
return value
def main() -> None:
out = Path(sys.argv[1])
out.mkdir(parents=True, exist_ok=True)
for path in sys.argv[2:]:
name = Path(path).name
try:
audio = AudioFile(path)
except Exception as exc: # librosa can't read every container the browser can
print(f"{name}: skipped ({exc})")
continue
target = out / name
target.mkdir(exist_ok=True)
audio.y_mono.astype(np.float32).tofile(target / "mono.f32")
results = {}
for metric_id, metric in METRICS.items():
data = metric.compute(audio)
if metric_id == "spectrogram":
s_db = np.ascontiguousarray(data["s_db"], dtype=np.float32)
s_db.tofile(target / "spectrogram.f32")
results[metric_id] = {
"rows": int(s_db.shape[0]), "cols": int(s_db.shape[1]),
"times": _jsonable(data["times"]), "freqs": _jsonable(data["freqs"]),
"nyquist": float(data["nyquist"]),
}
else:
results[metric_id] = {key: _jsonable(val) for key, val in data.items()}
meta = {
"file": name, "sr": int(audio.sr), "n": int(len(audio.y_mono)),
"song_name": audio.song_name,
"max_amplitude": float(audio.max_amplitude), "avg_amplitude": float(audio.avg_amplitude),
}
(target / "reference.json").write_text(json.dumps({"meta": meta, "metrics": results}))
print(f"{name}: {audio.sr} Hz, {len(audio.y_mono) / audio.sr:.2f} s, song_name={audio.song_name!r}")
if __name__ == "__main__":
main()
Generated
+8 -3
View File
@@ -1680,6 +1680,10 @@ dependencies = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "pyloudnorm" }, { name = "pyloudnorm" },
]
[package.optional-dependencies]
desktop = [
{ name = "pyqt5" }, { name = "pyqt5" },
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'" }, { name = "pyqt5-qt5", marker = "sys_platform == 'win32'" },
{ name = "pyqtgraph" }, { name = "pyqtgraph" },
@@ -1692,10 +1696,11 @@ requires-dist = [
{ name = "mutagen" }, { name = "mutagen" },
{ name = "numpy" }, { name = "numpy" },
{ name = "pyloudnorm" }, { name = "pyloudnorm" },
{ name = "pyqt5", specifier = ">=5.15.10" }, { name = "pyqt5", marker = "extra == 'desktop'", specifier = ">=5.15.10" },
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'", specifier = "==5.15.2" }, { name = "pyqt5-qt5", marker = "sys_platform == 'win32' and extra == 'desktop'", specifier = "==5.15.2" },
{ name = "pyqtgraph", specifier = ">=0.14.0" }, { name = "pyqtgraph", marker = "extra == 'desktop'", specifier = ">=0.14.0" },
] ]
provides-extras = ["desktop"]
[[package]] [[package]]
name = "urllib3" name = "urllib3"
+49
View File
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: Apache-2.0
//
// Analysis worker: the browser's AnalysisResultsManager worker thread.
//
// Holds each loaded track's mono signal (transferred in, never copied) and runs
// one metric at a time on request, so a 10-minute spectrogram never blocks the
// page. Everything stays in this tab's memory; closing the tab frees it.
//
// Messages in: {type: 'load', trackId, mono, sampleRate}
// {type: 'compute', reqId, trackId, metricId}
// {type: 'drop', trackId}
// Messages out: {type: 'result', reqId, trackId, metricId, data, ms}
// {type: 'error', reqId, trackId, metricId, message}
import { COMPUTE, transferables } from './dsp.js';
const tracks = new Map(); // trackId -> {mono, sampleRate, memo}
self.onmessage = (event) => {
const msg = event.data;
if (msg.type === 'load') {
tracks.set(msg.trackId, { mono: msg.mono, sampleRate: msg.sampleRate, memo: {} });
return;
}
if (msg.type === 'drop') {
tracks.delete(msg.trackId);
return;
}
if (msg.type !== 'compute') return;
const { reqId, trackId, metricId } = msg;
const track = tracks.get(trackId);
const compute = COMPUTE[metricId];
if (!track || !compute) {
self.postMessage({
type: 'error', reqId, trackId, metricId,
message: track ? `unknown metric ${metricId}` : 'track is no longer loaded',
});
return;
}
try {
const t0 = performance.now();
const data = compute(track.mono, track.sampleRate, track.memo);
const ms = performance.now() - t0;
self.postMessage({ type: 'result', reqId, trackId, metricId, data, ms }, transferables(data));
} catch (err) {
self.postMessage({ type: 'error', reqId, trackId, metricId, message: String((err && err.message) || err) });
}
};
+300
View File
@@ -0,0 +1,300 @@
/* Audio Mastering Analysis Toolkit — web front end.
Dark by default: a spectrogram is read against its surroundings, and the
desktop app's pythonic defaults are what these numbers were tuned against. */
:root {
--bg: #12141a;
--panel: #191c24;
--panel-2: #1f232d;
--line: #2b303c;
--text: #e8eaf0;
--muted: #939aa8;
--accent: #4fc3f7;
--accent-dim: #2b7fa3;
--warn: #ffb74d;
--error: #ef5350;
--ok: #81c784;
--radius: 6px;
/* M PLUS 1 Code (the desktop app's font) where installed, else the system UI font. */
--font: "M PLUS 1 Code", system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif;
--mono: "M PLUS 1 Code", ui-monospace, "Cascadia Mono", "Source Code Pro", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
/* The UA's [hidden] { display: none } loses to any class that sets display
(e.g. .drop-overlay's flex), which left the overlay permanently covering the
page. Make the attribute win everywhere. */
[hidden] { display: none !important; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font: 13px/1.45 var(--font);
overflow: hidden;
}
/* ---- top bar ---- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
height: 48px;
padding: 0 14px;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: baseline; gap: 8px; }
.logo { color: var(--accent); font-size: 16px; }
.brand-name { font-weight: 600; letter-spacing: .2px; }
.brand-sub {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--bg);
background: var(--accent-dim);
border-radius: 3px;
padding: 1px 5px;
}
.topbar-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.field { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
select, .btn, .toggle {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 5px 9px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
select:hover, .btn:hover, .toggle:hover { border-color: var(--accent-dim); }
select:focus-visible, .btn:focus-visible, .toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.btn.primary { background: var(--accent-dim); border-color: var(--accent); color: #eaf7ff; }
.btn.primary:hover { background: var(--accent); color: #06202b; }
.btn.small { padding: 3px 7px; font-size: 11px; }
.btn:disabled, .toggle:disabled { opacity: .45; cursor: not-allowed; }
.toggle.on { border-color: var(--accent); color: var(--accent); }
.toggle.off { color: var(--muted); }
.separator { width: 1px; height: 22px; background: var(--line); }
/* ---- layout ---- */
.layout {
display: grid;
grid-template-columns: 288px minmax(0, 1fr);
height: calc(100% - 48px);
}
.sidebar {
background: var(--panel);
border-right: 1px solid var(--line);
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.panel {
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 10px;
}
.panel h2 {
margin: 0 0 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .8px;
color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
}
.badge {
background: var(--line);
color: var(--text);
border-radius: 8px;
padding: 0 6px;
font-size: 10px;
font-variant-numeric: tabular-nums;
}
/* ---- file list ---- */
.file-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.file-row {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 6px;
border-radius: 4px;
cursor: pointer;
border: 1px solid transparent;
}
.file-row:hover { background: #252a35; }
.file-row.selected { background: #232a38; border-color: var(--accent-dim); }
.file-row input[type=checkbox] { margin: 0; accent-color: var(--accent-dim); flex: none; }
.file-swatch { width: 9px; height: 9px; border-radius: 2px; flex: none; }
.file-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
}
.file-lufs { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.file-remove {
flex: none;
border: 0;
background: none;
color: var(--muted);
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 2px;
opacity: 0;
}
.file-row:hover .file-remove { opacity: 1; }
.file-remove:hover { color: var(--error); }
.empty, .hint { color: var(--muted); font-size: 11.5px; margin: 4px 0 0; }
.hint code { background: #000; padding: 0 3px; border-radius: 3px; }
/* ---- metadata ---- */
.metadata { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; margin: 0; font-size: 12px; }
.metadata dt { color: var(--muted); }
.metadata dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.metadata dd.muted { color: var(--muted); }
.metadata .group { margin-top: 7px; }
.metadata dd.strong { font-weight: 600; }
/* ---- reference lines ---- */
.ref-list { list-style: none; margin: 0 0 8px; padding: 0; display: flex; flex-direction: column; gap: 3px; }
.ref-row { display: flex; align-items: center; gap: 6px; font-size: 12px; }
.ref-row input[type=color] { width: 20px; height: 18px; padding: 0; border: 1px solid var(--line); background: none; }
.ref-row input[type=text] { width: 62px; }
.ref-row input[type=number] { width: 62px; }
.ref-row input { background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: 4px; padding: 2px 4px; font: inherit; font-size: 11px; }
.ref-row .ref-remove { border: 0; background: none; color: var(--muted); cursor: pointer; }
.ref-row .ref-remove:hover { color: var(--error); }
.ref-actions { display: flex; gap: 6px; }
/* ---- plot stage ---- */
.stage { display: flex; flex-direction: column; min-width: 0; padding: 10px 12px 0; }
.plot-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 24px; }
.plot-head h1 { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
.plot-hint { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.plot-host {
position: relative;
flex: 1;
min-height: 200px;
margin-top: 6px;
background: #0d0f14;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.plot-host .uplot { position: absolute; inset: 0; width: 100% !important; height: 100% !important; }
.plot-host canvas.heatmap { position: absolute; inset: 0; width: 100%; height: 100%; }
/* In-plot legend, like pyqtgraph's: swatches per track plus scalar readouts.
pointer-events: none so zoom and pan work straight through it. */
.plot-legend {
position: absolute;
z-index: 5;
pointer-events: none;
display: flex;
flex-direction: column;
gap: 2px;
max-width: 46%;
padding: 6px 9px;
background: rgba(13, 15, 20, .82);
border: 1px solid var(--line);
border-radius: 4px;
font-size: 11.5px;
}
.plot-legend .entry { display: flex; align-items: center; gap: 7px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.plot-legend .swatch { width: 14px; height: 3px; border-radius: 1px; flex: none; }
.plot-legend .note { color: var(--muted); padding-left: 21px; font-variant-numeric: tabular-nums; }
body.dragging-pan, body.dragging-pan * { cursor: grabbing !important; user-select: none; }
body.dragging-ref, body.dragging-ref * { cursor: ns-resize !important; user-select: none; }
.placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: var(--muted);
gap: 6px;
padding: 20px;
}
.ph-title { font-size: 15px; color: var(--text); margin: 0; }
.ph-body { margin: 0; max-width: 44ch; font-size: 12px; }
.ph-body code { background: #000; padding: 0 3px; border-radius: 3px; }
.statusbar {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 6px 2px;
font-size: 11.5px;
color: var(--muted);
min-height: 24px;
}
.status.working { color: var(--accent); }
.status.error { color: var(--error); }
.status.warn { color: var(--warn); }
.timing { font-family: var(--mono); }
/* ---- drag & drop ---- */
.drop-overlay {
position: fixed;
inset: 0;
background: rgba(10, 20, 30, .82);
border: 3px dashed var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: var(--accent);
z-index: 40;
}
/* ---- tooltip ---- */
.tooltip {
position: fixed;
background: #05070b;
border: 1px solid var(--line);
border-radius: 4px;
padding: 5px 7px;
font: 11px/1.4 var(--mono);
color: var(--text);
pointer-events: none;
z-index: 30;
white-space: nowrap;
}
+1481
View File
File diff suppressed because it is too large Load Diff
+435
View File
@@ -0,0 +1,435 @@
// SPDX-License-Identifier: Apache-2.0
//
// Decode a dropped audio file entirely inside this tab.
//
// This is the browser counterpart of `master_core.AudioFile.__init__`: tags ->
// song name, `librosa.load(sr=None)` -> native-rate samples, `librosa.to_mono`
// -> the mono signal every metric reads. Nothing is uploaded or stored: the File
// is read into memory, decoded, downmixed, and the compressed bytes and
// per-channel buffers are dropped as soon as the mono signal exists.
//
// Two decode paths:
// * WAV and AIFF PCM/float are read directly with libsndfile's sample scaling,
// so the signal is bit-identical to what librosa hands the desktop metrics
// (Chromium's WAV decoder scales positive int16 by 1/32767, a small but
// systematic gain skew; browsers don't decode AIFF at all).
// * Everything else goes through decodeAudioData on an OfflineAudioContext
// running at the rate sniffed from the container header, so nothing is
// resampled — the same "analyse the full band" guarantee as `sr=None`.
const MIN_CONTEXT_RATE = 3000; // OfflineAudioContext's legal range
const MAX_CONTEXT_RATE = 768000;
const FALLBACK_RATE = 48000;
/**
* Decode `file` to a mono Float32Array at its native sample rate.
*
* Returns {name, songName, format, sampleRate, nativeRateKnown, channels,
* duration, mono, maxAmplitude, avgAmplitude, size}.
*/
export async function decodeFile(file) {
const bytes = await file.arrayBuffer();
const view = new DataView(bytes);
// Everything that reads the raw bytes must run before decodeAudioData, which
// detaches the ArrayBuffer.
const format = sniffFormat(view);
const tags = format === 'mp3' ? readId3(view) : null;
let decoded = format === 'wav' || format === 'aiff' ? readPcm(view, format) : null;
let nativeRateKnown = true;
if (!decoded) {
const sniffed = sniffSampleRate(view, format);
nativeRateKnown = sniffed !== null && sniffed >= MIN_CONTEXT_RATE && sniffed <= MAX_CONTEXT_RATE;
const sampleRate = nativeRateKnown ? sniffed : FALLBACK_RATE;
const ctx = new OfflineAudioContext({ numberOfChannels: 1, length: 1, sampleRate });
let buffer;
try {
buffer = await ctx.decodeAudioData(bytes);
} catch (err) {
throw new Error(`this browser cannot decode ${format || 'this file'} (${err.message || err})`);
}
decoded = { sampleRate: buffer.sampleRate, channels: buffer.numberOfChannels, mono: bufferToMono(buffer) };
}
const { mono, sampleRate, channels } = decoded;
let maxAmplitude = 0;
let absSum = 0;
for (let i = 0; i < mono.length; i++) {
const a = Math.abs(mono[i]);
if (a > maxAmplitude) maxAmplitude = a;
absSum += a;
}
// Same naming rule as AudioFile: "artist - title" when both tags exist.
const songName = tags && tags.artist && tags.title
? `${tags.artist} - ${tags.title}`
: file.name;
return {
name: file.name,
songName,
format,
sampleRate,
nativeRateKnown,
channels,
duration: mono.length / sampleRate,
mono,
maxAmplitude,
avgAmplitude: mono.length ? absSum / mono.length : 0,
size: file.size,
};
}
/**
* `librosa.to_mono`: the float32 mean across channels.
*
* Accumulating in a Float32Array rounds each partial sum to float32 exactly as
* numpy's float32 `mean(axis=0)` does, so downmixes match bit for bit.
*/
function bufferToMono(buffer) {
const channels = buffer.numberOfChannels;
if (channels === 1) return new Float32Array(buffer.getChannelData(0));
const n = buffer.length;
const mono = new Float32Array(n);
for (let c = 0; c < channels; c++) {
const data = buffer.getChannelData(c);
for (let i = 0; i < n; i++) mono[i] += data[i];
}
for (let i = 0; i < n; i++) mono[i] /= channels;
return mono;
}
// ---- direct PCM (WAV / AIFF) ------------------------------------------------
function ascii(view, offset, length) {
if (offset < 0 || offset + length > view.byteLength) return '';
let s = '';
for (let i = 0; i < length; i++) s += String.fromCharCode(view.getUint8(offset + i));
return s;
}
/**
* A reader for one sample as libsndfile's sf_read_float returns it, or null
* for encodings left to the browser decoder.
*/
function sampleReader(view, encoding, bytesPerSample, littleEndian) {
if (encoding === 'int') {
switch (bytesPerSample) {
case 1: return null; // handled by the caller: WAV is unsigned, AIFF signed
case 2: return (p) => view.getInt16(p, littleEndian) / 0x8000;
case 3: return littleEndian
? (p) => (((view.getUint8(p) | (view.getUint8(p + 1) << 8) | (view.getUint8(p + 2) << 16)) << 8) >> 8) / 0x800000
: (p) => (((view.getUint8(p + 2) | (view.getUint8(p + 1) << 8) | (view.getUint8(p) << 16)) << 8) >> 8) / 0x800000;
case 4: return (p) => Math.fround(view.getInt32(p, littleEndian)) / 0x80000000;
default: return null;
}
}
if (encoding === 'float') {
if (bytesPerSample === 4) return (p) => view.getFloat32(p, littleEndian);
if (bytesPerSample === 8) return (p) => Math.fround(view.getFloat64(p, littleEndian));
}
return null;
}
/** Interleaved frames -> float32 mono, channel sums in channel order. */
function interleavedToMono(view, dataOffset, frames, channels, bytesPerSample, read) {
const mono = new Float32Array(frames);
const scratch = new Float32Array(1);
for (let f = 0; f < frames; f++) {
const base = dataOffset + f * channels * bytesPerSample;
scratch[0] = 0;
for (let c = 0; c < channels; c++) scratch[0] += read(base + c * bytesPerSample);
mono[f] = channels === 1 ? scratch[0] : scratch[0] / channels;
}
return mono;
}
function readPcm(view, format) {
return format === 'wav' ? readWav(view) : readAiff(view);
}
function readWav(view) {
let fmt = null;
let dataOffset = -1;
let dataSize = 0;
let p = 12;
while (p + 8 <= view.byteLength) {
const id = ascii(view, p, 4);
const size = view.getUint32(p + 4, true);
if (id === 'fmt ' && p + 24 <= view.byteLength) {
let code = view.getUint16(p + 8, true);
// WAVE_FORMAT_EXTENSIBLE: the real format code leads the SubFormat GUID.
if (code === 0xfffe && size >= 40 && p + 34 <= view.byteLength) code = view.getUint16(p + 32, true);
fmt = {
code,
channels: view.getUint16(p + 10, true),
sampleRate: view.getUint32(p + 12, true),
blockAlign: view.getUint16(p + 20, true),
};
} else if (id === 'data') {
dataOffset = p + 8;
// Streamed/RF64 files may carry a placeholder size; trust the file length.
dataSize = size === 0xffffffff || dataOffset + size > view.byteLength ? view.byteLength - dataOffset : size;
break;
}
p += 8 + size + (size & 1);
}
if (!fmt || dataOffset < 0 || !fmt.channels || !fmt.blockAlign) return null;
const bytesPerSample = fmt.blockAlign / fmt.channels;
const frames = Math.floor(dataSize / fmt.blockAlign);
let read;
if (fmt.code === 1 && bytesPerSample === 1) read = (q) => (view.getUint8(q) - 128) / 0x80;
else if (fmt.code === 1) read = sampleReader(view, 'int', bytesPerSample, true);
else if (fmt.code === 3) read = sampleReader(view, 'float', bytesPerSample, true);
if (!read) return null;
return {
sampleRate: fmt.sampleRate,
channels: fmt.channels,
mono: interleavedToMono(view, dataOffset, frames, fmt.channels, bytesPerSample, read),
};
}
function readAiff(view) {
const aifc = ascii(view, 8, 4) === 'AIFC';
let comm = null;
let dataOffset = -1;
let dataSize = 0;
let p = 12;
while (p + 8 <= view.byteLength) {
const id = ascii(view, p, 4);
const size = view.getUint32(p + 4, false);
if (id === 'COMM' && p + 26 <= view.byteLength) {
comm = {
channels: view.getUint16(p + 8, false),
frames: view.getUint32(p + 10, false),
bits: view.getUint16(p + 14, false),
sampleRate: aiffRate(view, p + 16),
compression: aifc && p + 30 <= view.byteLength ? ascii(view, p + 26, 4) : 'NONE',
};
} else if (id === 'SSND' && p + 16 <= view.byteLength) {
dataOffset = p + 16 + view.getUint32(p + 8, false);
dataSize = Math.min(size - 8, view.byteLength - dataOffset);
}
p += 8 + size + (size & 1);
}
if (!comm || dataOffset < 0 || !comm.channels) return null;
const bytesPerSample = Math.ceil(comm.bits / 8);
let read = null;
const c = comm.compression;
if ((c === 'NONE' || c === 'twos') && bytesPerSample === 1) read = (q) => view.getInt8(q) / 0x80;
else if (c === 'NONE' || c === 'twos') read = sampleReader(view, 'int', bytesPerSample, false);
else if (c === 'sowt') read = sampleReader(view, 'int', bytesPerSample, true);
else if (c === 'fl32' || c === 'FL32') read = sampleReader(view, 'float', 4, false);
else if (c === 'fl64' || c === 'FL64') read = sampleReader(view, 'float', 8, false);
if (!read) return null;
const width = c.toLowerCase() === 'fl32' ? 4 : c.toLowerCase() === 'fl64' ? 8 : bytesPerSample;
const frames = Math.min(comm.frames, Math.floor(dataSize / (width * comm.channels)));
return {
sampleRate: comm.sampleRate,
channels: comm.channels,
mono: interleavedToMono(view, dataOffset, frames, comm.channels, width, read),
};
}
/** 80-bit IEEE 754 extended float (AIFF COMM sample rate). */
function aiffRate(view, q) {
const exponent = view.getUint16(q, false) & 0x7fff;
const mantissa = view.getUint32(q + 2, false) * 2 ** 32 + view.getUint32(q + 6, false);
return Math.round(mantissa * 2 ** (exponent - 16383 - 63));
}
// ---- container sniffing (browser-decoded formats) ---------------------------
function syncsafe(view, offset) {
return ((view.getUint8(offset) & 0x7f) << 21) | ((view.getUint8(offset + 1) & 0x7f) << 14)
| ((view.getUint8(offset + 2) & 0x7f) << 7) | (view.getUint8(offset + 3) & 0x7f);
}
/** Byte offset just past a leading ID3v2 tag (0 when there is none). */
function skipId3(view) {
if (ascii(view, 0, 3) !== 'ID3' || view.byteLength < 10) return 0;
const footer = (view.getUint8(5) & 0x10) ? 10 : 0;
return 10 + syncsafe(view, 6) + footer;
}
export function sniffFormat(view) {
const riff = ascii(view, 0, 4);
if ((riff === 'RIFF' || riff === 'RF64') && ascii(view, 8, 4) === 'WAVE') return 'wav';
if (riff === 'FORM' && /^AIF[FC]$/.test(ascii(view, 8, 4))) return 'aiff';
if (riff === 'OggS') return 'ogg';
if (ascii(view, 4, 4) === 'ftyp') return 'mp4';
const start = skipId3(view);
if (ascii(view, start, 4) === 'fLaC') return 'flac';
if (findMpegFrame(view, start) !== null) return 'mp3';
return null;
}
export function sniffSampleRate(view, format) {
switch (format) {
case 'wav': return wavRate(view);
case 'aiff': return aiffCommRate(view);
case 'flac': return flacRate(view, skipId3(view));
case 'mp3': {
const frame = findMpegFrame(view, skipId3(view));
return frame ? frame.sampleRate : null;
}
case 'ogg': return oggRate(view);
case 'mp4': return mp4Rate(view);
default: return null;
}
}
function wavRate(view) {
let p = 12;
while (p + 8 <= view.byteLength) {
const id = ascii(view, p, 4);
const size = view.getUint32(p + 4, true);
if (id === 'fmt ' && p + 16 <= view.byteLength) return view.getUint32(p + 12, true);
p += 8 + size + (size & 1);
}
return null;
}
function aiffCommRate(view) {
let p = 12;
while (p + 8 <= view.byteLength) {
const size = view.getUint32(p + 4, false);
if (ascii(view, p, 4) === 'COMM' && p + 26 <= view.byteLength) return aiffRate(view, p + 16);
p += 8 + size + (size & 1);
}
return null;
}
function flacRate(view, start) {
// STREAMINFO is always the first metadata block: 4-byte marker, 4-byte block
// header, then 10 bytes of block/frame sizes before the 20-bit sample rate.
const q = start + 8 + 10;
if (q + 3 > view.byteLength) return null;
return (view.getUint8(q) << 12) | (view.getUint8(q + 1) << 4) | (view.getUint8(q + 2) >> 4);
}
const MPEG_RATES = { 3: [44100, 48000, 32000], 2: [22050, 24000, 16000], 0: [11025, 12000, 8000] };
const L3_KBPS = {
v1: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
v2: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
};
function parseMpegHeader(view, p) {
if (p + 4 > view.byteLength) return null;
const b1 = view.getUint8(p + 1), b2 = view.getUint8(p + 2);
if (view.getUint8(p) !== 0xff || (b1 & 0xe0) !== 0xe0) return null;
const version = (b1 >> 3) & 3;
const layer = (b1 >> 1) & 3;
const bitrateIndex = (b2 >> 4) & 15;
const rateIndex = (b2 >> 2) & 3;
if (version === 1 || layer === 0 || bitrateIndex === 15 || rateIndex === 3) return null;
const sampleRate = MPEG_RATES[version][rateIndex];
let frameLength = null;
if (layer === 1 && bitrateIndex !== 0) { // Layer III
const kbps = (version === 3 ? L3_KBPS.v1 : L3_KBPS.v2)[bitrateIndex];
const coeff = version === 3 ? 144 : 72;
frameLength = Math.floor((coeff * kbps * 1000) / sampleRate) + ((b2 >> 1) & 1);
}
return { sampleRate, frameLength };
}
/**
* First plausible MPEG audio frame at or after `start`.
*
* A lone 0xFFE sync pattern is common in arbitrary bytes, so a Layer III frame
* is only accepted when another header with the same rate follows at its
* computed length.
*/
function findMpegFrame(view, start) {
const limit = Math.min(view.byteLength - 4, start + 64 * 1024);
for (let p = start; p < limit; p++) {
const head = parseMpegHeader(view, p);
if (!head) continue;
if (head.frameLength === null) return head;
const next = parseMpegHeader(view, p + head.frameLength);
if (next && next.sampleRate === head.sampleRate) return head;
}
return null;
}
function oggRate(view) {
const segments = view.byteLength > 26 ? view.getUint8(26) : 0;
const packet = 27 + segments;
if (ascii(view, packet, 7) === '\x01vorbis' && packet + 16 <= view.byteLength) {
return view.getUint32(packet + 12, true);
}
// Opus always decodes at 48 kHz; its header's "input rate" is informational.
if (ascii(view, packet, 8) === 'OpusHead') return 48000;
return null;
}
function mp4Rate(view) {
// Audio sample entries (mp4a/alac/Opus/fLaC) carry a 16.16 fixed-point rate
// 32 bytes into the box. moov may sit at the end of the file, so scan it all.
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
const types = ['mp4a', 'alac', 'Opus', 'fLaC'].map((t) => [...t].map((c) => c.charCodeAt(0)));
for (let i = 4; i + 30 < bytes.length; i++) {
for (const t of types) {
if (bytes[i] === t[0] && bytes[i + 1] === t[1] && bytes[i + 2] === t[2] && bytes[i + 3] === t[3]) {
const rate = view.getUint16(i - 4 + 32, false);
if (rate >= MIN_CONTEXT_RATE) return rate;
}
}
}
return null;
}
// ---- ID3 tags ---------------------------------------------------------------
/** {artist, title} from an ID3v2.2/2.3/2.4 tag (EasyID3's TPE1/TIT2), or null. */
export function readId3(view) {
if (ascii(view, 0, 3) !== 'ID3' || view.byteLength < 10) return null;
const major = view.getUint8(3);
const flags = view.getUint8(5);
const end = Math.min(10 + syncsafe(view, 6), view.byteLength);
let p = 10;
if (flags & 0x40) p += major === 4 ? syncsafe(view, 10) : view.getUint32(10, false) + 4;
const v22 = major === 2;
const idLen = v22 ? 3 : 4;
const headerLen = v22 ? 6 : 10;
const wanted = v22 ? { TT2: 'title', TP1: 'artist' } : { TIT2: 'title', TPE1: 'artist' };
const out = {};
while (p + headerLen <= end) {
const id = ascii(view, p, idLen);
if (!/^[A-Z0-9]+$/.test(id)) break; // reached padding
let size;
if (v22) size = (view.getUint8(p + 3) << 16) | (view.getUint8(p + 4) << 8) | view.getUint8(p + 5);
else if (major === 4) size = syncsafe(view, p + 4);
else size = view.getUint32(p + 4, false);
const body = p + headerLen;
if (size < 0 || body + size > end) break;
if (wanted[id] && size > 1) {
const text = decodeId3Text(new Uint8Array(view.buffer, view.byteOffset + body, size));
if (text) out[wanted[id]] = text;
}
p = body + size;
}
return out;
}
function decodeId3Text(bytes) {
const encoding = bytes[0];
let data = bytes.subarray(1);
let label = 'latin1';
if (encoding === 1) {
if (data[0] === 0xfe && data[1] === 0xff) { label = 'utf-16be'; data = data.subarray(2); }
else { label = 'utf-16le'; if (data[0] === 0xff && data[1] === 0xfe) data = data.subarray(2); }
} else if (encoding === 2) {
label = 'utf-16be';
} else if (encoding === 3) {
label = 'utf-8';
}
// v2.4 separates multiple values with NUL; EasyID3 reports the first.
return new TextDecoder(label).decode(data).split('\0')[0].trim();
}
+694
View File
@@ -0,0 +1,694 @@
// SPDX-License-Identifier: Apache-2.0
//
// Metric computation, ported from `metrics.py` / `master_core.py` to run in the
// browser.
//
// Pure functions over a mono Float32Array at the file's native sample rate — no
// DOM, no Web Audio — so the same module runs inside the analysis Web Worker and
// under Node for the parity tests (tests/parity), which check every metric
// against the Python implementation on identical samples.
//
// The numerics follow the Python where it matters:
// * running sums of squares are sequential float64 sums, like np.cumsum, but
// sampled only at the window/block edges that are read — the same values
// without an N-length array;
// * K-weighting is pyloudnorm's biquad pair run through scipy.signal.lfilter's
// direct-form-II-transposed recurrence in float64, streamed sample by sample;
// * true peak reproduces scipy.signal.resample_poly(y, 4, 1) — the same 81-tap
// Kaiser(beta=5) firwin filter and zero-padded alignment — then an exact
// sliding maximum (what maximum_filter1d sampled at window centres yields);
// * the spectrogram follows librosa.stft (centred, zero padded, periodic Hann)
// and amplitude_to_db(ref=np.max, top_db=80), dropping the DC row.
// What remains is float32 rounding where numpy works in float32 (resampling,
// RMS, spectrogram dB): far below anything a plot or readout can show.
const EPS = 1e-12; // keeps 20*log10 finite on perfect silence
const LUFS_OFFSET = -0.691; // BS.1770
const ABS_GATE = -70.0; // BS.1770 absolute gate
const SILENCE_FLOOR = -70.0;
const toDbfs = (v) => 20 * Math.log10(Math.max(v, EPS));
/** Number of full windows of `windowN` stepped by `hopN` over `n` samples. */
function windowCount(n, windowN, hopN) {
return 1 + Math.floor((n - windowN) / hopN);
}
function sampleStream(x) {
let i = 0;
return () => x[i++];
}
function absStream(x) {
let i = 0;
return () => Math.abs(x[i++]);
}
function lowerBound(sorted, value) {
let lo = 0;
let hi = sorted.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (sorted[mid] < value) lo = mid + 1;
else hi = mid;
}
return lo;
}
/**
* csq(i) = sum of x[k]^2 for k < i — np.concatenate(([0], np.cumsum(x * x))) —
* at the requested indices only.
*
* `next()` yields x[0], x[1], ... in order, so a filtered signal can be streamed
* straight in. Indices past `n` read csq(n), i.e. a zero-padded tail.
*/
function prefixSquares(n, next, indexArrays) {
let total = 0;
for (const a of indexArrays) total += a.length;
const marks = new Float64Array(total);
let offset = 0;
for (const a of indexArrays) {
marks.set(a, offset);
offset += a.length;
}
marks.sort();
const values = new Float64Array(marks.length);
let m = 0;
while (m < marks.length && marks[m] <= 0) values[m++] = 0;
let acc = 0;
for (let k = 0; k < n && m < marks.length; k++) {
const v = next();
acc += v * v;
while (m < marks.length && marks[m] === k + 1) values[m++] = acc;
}
while (m < marks.length) values[m++] = acc;
return (index) => values[lowerBound(marks, index)];
}
/**
* max(x[s .. s + windowN)) for s = 0, hopN, 2*hopN, ... (`count` windows).
*
* A monotonic deque: one O(N) pass, O(windowN + hopN) memory, exact maxima.
*/
function windowMaxima(count, windowN, hopN, next) {
const out = new Float64Array(Math.max(count, 0));
if (count <= 0) return out;
const cap = windowN + hopN + 1;
const idx = new Float64Array(cap);
const val = new Float64Array(cap);
let head = 0;
let size = 0;
let w = 0;
let end = windowN; // exclusive end of window w
const last = (count - 1) * hopN + windowN;
for (let k = 0; k < last; k++) {
const v = next();
while (size > 0) {
let back = head + size - 1;
if (back >= cap) back -= cap;
if (val[back] <= v) size--;
else break;
}
let pos = head + size;
if (pos >= cap) pos -= cap;
idx[pos] = k;
val[pos] = v;
size++;
if (k + 1 === end) {
const start = end - windowN;
while (idx[head] < start) {
head++;
if (head === cap) head = 0;
size--;
}
out[w++] = val[head];
if (w === count) break;
end += hopN;
}
}
return out;
}
// ---- K-weighting and loudness ------------------------------------------------
/** pyloudnorm IIRfilter.generate_coefficients, normalised by a0. */
function rbjBiquad(type, G, Q, fc, rate) {
const A = 10 ** (G / 40.0);
const w0 = 2.0 * Math.PI * (fc / rate);
const alpha = Math.sin(w0) / (2.0 * Q);
let b0, b1, b2, a0, a1, a2;
if (type === 'high_shelf') {
b0 = A * (A + 1 + (A - 1) * Math.cos(w0) + 2 * Math.sqrt(A) * alpha);
b1 = -2 * A * (A - 1 + (A + 1) * Math.cos(w0));
b2 = A * (A + 1 + (A - 1) * Math.cos(w0) - 2 * Math.sqrt(A) * alpha);
a0 = A + 1 - (A - 1) * Math.cos(w0) + 2 * Math.sqrt(A) * alpha;
a1 = 2 * (A - 1 - (A + 1) * Math.cos(w0));
a2 = A + 1 - (A - 1) * Math.cos(w0) - 2 * Math.sqrt(A) * alpha;
} else if (type === 'high_pass') {
b0 = (1 + Math.cos(w0)) / 2;
b1 = -(1 + Math.cos(w0));
b2 = (1 + Math.cos(w0)) / 2;
a0 = 1 + alpha;
a1 = -2 * Math.cos(w0);
a2 = 1 - alpha;
} else {
throw new Error(`unsupported filter type ${type}`);
}
return { b0: b0 / a0, b1: b1 / a0, b2: b2 / a0, a1: a1 / a0, a2: a2 / a0 };
}
/** pyloudnorm Meter(rate) 'K-weighting': high shelf, then high pass. */
export function kWeightingCoefficients(rate) {
return [
rbjBiquad('high_shelf', 4.0, 1 / Math.sqrt(2), 1500.0, rate),
rbjBiquad('high_pass', 0.0, 0.5, 38.0, rate),
];
}
/**
* The K-weighted signal as a stream (metrics._kweight).
*
* Each stage is scipy's lfilter recurrence — y = z0 + b0*x;
* z0 = z1 + x*b1 - y*a1; z1 = x*b2 - y*a2 — in the same operation order, so the
* filtered samples match pyloudnorm's whole-array filtering.
*/
function kWeightedStream(mono, rate) {
const [s, p] = kWeightingCoefficients(rate);
let i = 0;
let sz0 = 0, sz1 = 0, pz0 = 0, pz1 = 0;
return () => {
const x = mono[i++];
const y1 = sz0 + s.b0 * x;
sz0 = sz1 + x * s.b1 - y1 * s.a1;
sz1 = x * s.b2 - y1 * s.a2;
const y2 = pz0 + p.b0 * y1;
pz0 = pz1 + y1 * p.b1 - y2 * p.a1;
pz1 = y1 * p.b2 - y2 * p.a2;
return y2;
};
}
/** np.round: round half to even. */
function roundHalfEven(x) {
const r = Math.round(x);
return Math.abs(x % 1) === 0.5 && r % 2 !== 0 ? r - 1 : r;
}
/** Block edges as metrics._block_loudness lays them out, or null when none fit. */
function blockEdges(nSignal, sr, blockS, stepPct) {
const T = nSignal / sr;
const nBlocks = Math.trunc(roundHalfEven((T - blockS) / (blockS * stepPct)) + 1);
if (!(nBlocks >= 1)) return null;
const lo = new Float64Array(nBlocks);
const up = new Float64Array(nBlocks);
for (let j = 0; j < nBlocks; j++) {
lo[j] = Math.trunc(blockS * (j * stepPct) * sr);
up[j] = Math.min(Math.trunc(blockS * (j * stepPct + 1) * sr), nSignal);
}
return { lo, up, n: nBlocks, divisor: blockS * sr };
}
function blockLoudness(blocks, csq) {
const z = new Float64Array(blocks.n);
const l = new Float64Array(blocks.n);
for (let j = 0; j < blocks.n; j++) {
z[j] = (csq(blocks.up[j]) - csq(blocks.lo[j])) / blocks.divisor;
l[j] = LUFS_OFFSET + 10.0 * Math.log10(z[j]);
}
return { z, l };
}
/** metrics._integrated_lufs: two-stage gated mean of the 400 ms blocks. */
function gatedIntegrated(z, l) {
let sum = 0;
let count = 0;
for (let j = 0; j < l.length; j++) {
if (l[j] >= ABS_GATE) { sum += z[j]; count++; }
}
if (!count) return -Infinity;
const gammaR = LUFS_OFFSET + 10.0 * Math.log10(sum / count) - 10.0;
sum = 0;
count = 0;
for (let j = 0; j < l.length; j++) {
if (l[j] > gammaR && l[j] > ABS_GATE) { sum += z[j]; count++; }
}
if (!count) return -Infinity;
return LUFS_OFFSET + 10.0 * Math.log10(sum / count);
}
/** np.percentile(values, q), default 'linear' method, on a sorted array. */
function percentileSorted(sorted, q) {
const index = (q / 100) * (sorted.length - 1);
const lo = Math.floor(index);
const hi = Math.min(lo + 1, sorted.length - 1);
const t = index - lo;
const a = sorted[lo];
const b = sorted[hi];
return t >= 0.5 ? b - (b - a) * (1 - t) : a + (b - a) * t;
}
/** metrics._loudness_range: EBU Tech 3342 LRA from 3 s block loudness. */
function loudnessRange(l) {
const absGated = [];
for (let j = 0; j < l.length; j++) if (l[j] >= ABS_GATE) absGated.push(l[j]);
if (!absGated.length) return NaN;
let energy = 0;
for (const v of absGated) energy += 10.0 ** (v / 10.0);
const stlIntegrated = 10.0 * Math.log10(energy / absGated.length);
const rel = Float64Array.from(absGated.filter((v) => v >= stlIntegrated - 20.0));
if (!rel.length) return NaN;
rel.sort();
return percentileSorted(rel, 95) - percentileSorted(rel, 10);
}
/**
* Every loudness quantity from one K-weighting pass: the ungated short-term
* (3 s / 0.5 s) series, integrated loudness and LRA.
*
* Memoised on `memo` (the AudioFile attribute caching in Python) so LUFS and PSR
* share it. Returned arrays are owned by the memo — callers copy before handing
* them out.
*/
function loudness(mono, sr, memo) {
if (memo && memo.loudness) return memo.loudness;
const n = mono.length;
const windowN = Math.max(Math.trunc(3.0 * sr), 1);
const hopN = Math.max(Math.trunc(0.5 * sr), 1);
const stCount = n >= windowN ? windowCount(n, windowN, hopN) : 0;
const stStarts = new Float64Array(stCount);
const stEnds = new Float64Array(stCount);
for (let i = 0; i < stCount; i++) {
stStarts[i] = i * hopN;
stEnds[i] = i * hopN + windowN;
}
const integ = blockEdges(n, sr, 0.4, 0.25);
// LRA appends 1.5 s of silence; indices past n read csq(n), which is exactly that.
const lraBlocks = blockEdges(n + Math.trunc(1.5 * sr), sr, 3.0, 0.03);
const wanted = [stStarts, stEnds, Float64Array.of(0, n)];
if (integ) wanted.push(integ.lo, integ.up);
if (lraBlocks) wanted.push(lraBlocks.lo, lraBlocks.up);
const csq = prefixSquares(n, kWeightedStream(mono, sr), wanted);
let times;
let lufs;
if (n < windowN) {
const ms = n ? csq(n) / n : 0;
times = Float64Array.of(n / (2.0 * sr));
lufs = Float64Array.of(LUFS_OFFSET + 10.0 * Math.log10(Math.max(ms, EPS)));
} else {
times = new Float64Array(stCount);
lufs = new Float64Array(stCount);
for (let i = 0; i < stCount; i++) {
const ms = (csq(stEnds[i]) - csq(stStarts[i])) / windowN;
lufs[i] = LUFS_OFFSET + 10.0 * Math.log10(Math.max(ms, EPS));
times[i] = (stStarts[i] + windowN / 2.0) / sr;
}
}
let integrated = -Infinity;
if (integ) {
const { z, l } = blockLoudness(integ, csq);
integrated = gatedIntegrated(z, l);
}
let lra = NaN;
if (n >= Math.trunc(3.0 * sr) && lraBlocks) {
lra = loudnessRange(blockLoudness(lraBlocks, csq).l);
}
const result = { times, lufs, integrated, lra };
if (memo) memo.loudness = result;
return result;
}
// ---- metrics -------------------------------------------------------------------
/** RMSPowerMetric: librosa.feature.rms, 10 s frames / 2 s hop, centred. */
export function computeRmsPower(mono, sr, window = 10, hop = 2) {
const n = mono.length;
const frameLength = window * sr;
const hopLength = hop * sr;
const half = Math.floor(frameLength / 2);
const padded = n + 2 * half;
const count = padded >= frameLength ? 1 + Math.floor((padded - frameLength) / hopLength) : 0;
// Frame i covers padded[i*hop, i*hop + frameLength): the zero padding adds
// nothing to the sum but still counts in the mean, as in librosa.
const starts = new Float64Array(count);
const ends = new Float64Array(count);
for (let i = 0; i < count; i++) {
const a = i * hopLength - half;
starts[i] = Math.min(Math.max(a, 0), n);
ends[i] = Math.min(Math.max(a + frameLength, 0), n);
}
const csq = prefixSquares(n, sampleStream(mono), [starts, ends]);
const times = new Float64Array(count);
const rms = new Float64Array(count);
for (let i = 0; i < count; i++) {
times[i] = (i * hopLength) / sr;
rms[i] = Math.sqrt((csq(ends[i]) - csq(starts[i])) / frameLength);
}
return { times, rms };
}
/** WaveformMetric: min/max envelope over ~4000 columns. */
export function computeWaveform(mono, sr, targetColumns = 4000) {
const n = mono.length;
if (n <= targetColumns) {
const times = new Float64Array(n);
for (let i = 0; i < n; i++) times[i] = i / sr;
return { times, lo: Float32Array.from(mono), hi: Float32Array.from(mono) };
}
const chunk = Math.floor(n / targetColumns);
const times = new Float64Array(targetColumns);
const lo = new Float32Array(targetColumns);
const hi = new Float32Array(targetColumns);
for (let c = 0; c < targetColumns; c++) {
let mn = Infinity;
let mx = -Infinity;
const base = c * chunk;
for (let k = base; k < base + chunk; k++) {
const v = mono[k];
if (v < mn) mn = v;
if (v > mx) mx = v;
}
lo[c] = mn;
hi[c] = mx;
times[c] = (c * chunk + chunk / 2) / sr;
}
return { times, lo, hi };
}
/** LUFSMetric: short-term series clipped to [-70, 0], plus integrated and LRA. */
export function computeLufs(mono, sr, memo) {
const L = loudness(mono, sr, memo);
const lufs = new Float64Array(L.lufs.length);
for (let i = 0; i < lufs.length; i++) {
const v = Number.isFinite(L.lufs[i]) ? L.lufs[i] : SILENCE_FLOOR;
lufs[i] = Math.min(Math.max(v, SILENCE_FLOOR), 0.0);
}
return { times: L.times.slice(), lufs, integrated: L.integrated, lra: L.lra };
}
/** CrestFactorMetric: 20*log10(peak / RMS) per 1 s window, 0.25 s hop. */
export function computeCrestFactor(mono, sr) {
const n = mono.length;
const windowN = Math.trunc(1.0 * sr);
const hopN = Math.trunc(0.25 * sr);
if (n < windowN) {
let peak = 0;
let sumSq = 0;
for (let i = 0; i < n; i++) {
const v = mono[i];
if (Math.abs(v) > peak) peak = Math.abs(v);
sumSq += v * v;
}
const rms = n ? Math.sqrt(sumSq / n) : 0;
return {
times: Float64Array.of(n / (2.0 * sr)),
crest_db: Float64Array.of(20.0 * Math.log10(Math.max(peak, EPS) / Math.max(rms, EPS))),
};
}
const count = windowCount(n, windowN, hopN);
const starts = new Float64Array(count);
const ends = new Float64Array(count);
for (let i = 0; i < count; i++) {
starts[i] = i * hopN;
ends[i] = i * hopN + windowN;
}
const csq = prefixSquares(n, sampleStream(mono), [starts, ends]);
const peaks = windowMaxima(count, windowN, hopN, absStream(mono));
const times = new Float64Array(count);
const crest = new Float64Array(count);
for (let i = 0; i < count; i++) {
const meanSq = (csq(ends[i]) - csq(starts[i])) / windowN;
const rms = Math.sqrt(Math.max(meanSq, EPS));
crest[i] = 20.0 * Math.log10(Math.max(peaks[i], EPS) / rms);
times[i] = (starts[i] + windowN / 2.0) / sr;
}
return { times, crest_db: crest };
}
/** PSRMetric: sample peak minus short-term LUFS (3 s), NaN below the gate. */
export function computePsr(mono, sr, memo) {
const n = mono.length;
const windowN = Math.max(Math.trunc(3.0 * sr), 1);
const hopN = Math.max(Math.trunc(0.5 * sr), 1);
const L = loudness(mono, sr, memo);
const count = L.times.length;
let peaksDb;
if (n < windowN) {
let peak = 0;
for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(mono[i]));
peaksDb = Float64Array.of(n ? toDbfs(peak) : SILENCE_FLOOR);
} else {
peaksDb = windowMaxima(count, windowN, hopN, absStream(mono)).map(toDbfs);
}
const psr = new Float64Array(count);
for (let i = 0; i < count; i++) {
const l = L.lufs[i];
psr[i] = Number.isFinite(l) && l > SILENCE_FLOOR ? peaksDb[i] - l : NaN;
}
return { times: L.times.slice(), psr };
}
// ---- true peak -------------------------------------------------------------------
const OVERSAMPLE = 4;
let truePeakTaps = null;
function besselI0(x) {
const q = (x * x) / 4;
let term = 1;
let sum = 1;
for (let k = 1; k < 500; k++) {
term *= q / (k * k);
sum += term;
if (term < 1e-17 * sum) break;
}
return sum;
}
/**
* The anti-imaging filter resample_poly(x, 4, 1) builds for float32 input:
* firwin(81, 1/4, window=('kaiser', 5.0)), cast to float32, times 4.
*/
function truePeakFilter() {
if (truePeakTaps) return truePeakTaps;
const numtaps = 2 * 10 * OVERSAMPLE + 1;
const cutoff = 1.0 / OVERSAMPLE;
const beta = 5.0;
const alpha = 0.5 * (numtaps - 1);
const i0beta = besselI0(beta);
const h = new Float64Array(numtaps);
let sum = 0;
for (let k = 0; k < numtaps; k++) {
const m = k - alpha;
const x = Math.PI * (m === 0 ? 1e-20 : cutoff * m);
const sinc = Math.sin(x) / x;
const r = (k - alpha) / alpha;
h[k] = cutoff * sinc * (besselI0(beta * Math.sqrt(1 - r * r)) / i0beta);
sum += h[k];
}
truePeakTaps = new Float32Array(numtaps);
for (let k = 0; k < numtaps; k++) truePeakTaps[k] = Math.fround(h[k] / sum) * OVERSAMPLE;
return truePeakTaps;
}
/**
* |resample_poly(x, 4, 1)| as a stream, one output sample per call.
*
* resample_poly zero-stuffs x, convolves with the taps and keeps outputs
* [41, 41 + 4n); output k therefore sums x[s] * h[40 + k - 4s] over the input
* samples within the filter's reach — 20 or 21 taps per output.
*/
function upsampledAbsStream(x, h) {
const n = x.length;
const half = 10 * OVERSAMPLE;
let k = 0;
return () => {
const q = Math.floor(k / OVERSAMPLE);
const r = k - OVERSAMPLE * q;
const sLo = Math.max(0, r === 0 ? q - 10 : q - 9);
const sHi = Math.min(n - 1, q + 10);
let acc = 0;
for (let s = sLo; s <= sHi; s++) acc += x[s] * h[half + r + OVERSAMPLE * (q - s)];
k++;
return Math.abs(Math.fround(acc));
};
}
/** TruePeakMetric: 4x oversampled dBTP per 250 ms window, 100 ms hop. */
export function computeTruePeak(mono, sr) {
const n = mono.length;
const windowN = Math.trunc(0.25 * sr);
const hopN = Math.trunc(0.1 * sr);
const h = truePeakFilter();
if (n < windowN) {
let peak = 0;
const next = upsampledAbsStream(mono, h);
for (let k = 0; k < n * OVERSAMPLE; k++) peak = Math.max(peak, next());
const db = toDbfs(peak);
return { times: Float64Array.of(n / (2.0 * sr)), tp_db: Float64Array.of(db), integrated_tp_db: db };
}
const count = windowCount(n, windowN, hopN);
const peaks = windowMaxima(count, windowN * OVERSAMPLE, hopN * OVERSAMPLE, upsampledAbsStream(mono, h));
const times = new Float64Array(count);
const tpDb = new Float64Array(count);
let max = -Infinity;
for (let i = 0; i < count; i++) {
tpDb[i] = toDbfs(peaks[i]);
times[i] = (i * hopN + windowN / 2.0) / sr;
if (tpDb[i] > max) max = tpDb[i];
}
return { times, tp_db: tpDb, integrated_tp_db: max };
}
// ---- spectrogram -----------------------------------------------------------------
const N_FFT = 4096;
const SPEC_TARGET_COLUMNS = 4000;
const SPEC_DB_FLOOR = -80.0;
const fftPlans = new Map();
/** In-place radix-2 complex FFT for `size` (a power of two). */
function fftPlan(size) {
if (fftPlans.has(size)) return fftPlans.get(size);
const levels = Math.round(Math.log2(size));
const rev = new Uint32Array(size);
for (let i = 0; i < size; i++) {
let r = 0;
for (let b = 0; b < levels; b++) r |= ((i >> b) & 1) << (levels - 1 - b);
rev[i] = r;
}
const cos = new Float64Array(size / 2);
const sin = new Float64Array(size / 2);
for (let i = 0; i < size / 2; i++) {
cos[i] = Math.cos((2 * Math.PI * i) / size);
sin[i] = Math.sin((2 * Math.PI * i) / size);
}
const plan = (re, im) => {
for (let i = 0; i < size; i++) {
const j = rev[i];
if (j > i) {
let t = re[i]; re[i] = re[j]; re[j] = t;
t = im[i]; im[i] = im[j]; im[j] = t;
}
}
for (let len = 2; len <= size; len <<= 1) {
const halfLen = len >> 1;
const step = size / len;
for (let i = 0; i < size; i += len) {
for (let j = 0; j < halfLen; j++) {
const c = cos[j * step];
const s = sin[j * step];
const u = i + j;
const v = u + halfLen;
const tre = re[v] * c + im[v] * s;
const tim = im[v] * c - re[v] * s;
re[v] = re[u] - tre;
im[v] = im[u] - tim;
re[u] += tre;
im[u] += tim;
}
}
}
};
fftPlans.set(size, plan);
return plan;
}
/**
* SpectrogramMetric: |STFT| in dB relative to the loudest bin, floored at -80.
*
* `s_db` is row-major (rows x cols), row 0 the lowest non-DC bin — the same
* layout as the Python `s_db[1:, :]`.
*/
export function computeSpectrogram(mono, sr) {
const n = mono.length;
const half = N_FFT / 2;
const hop = Math.max(N_FFT / 4, Math.floor(n / SPEC_TARGET_COLUMNS));
const cols = 1 + Math.floor(n / hop);
const rows = half; // bins 1..N_FFT/2
const window = new Float64Array(N_FFT); // periodic Hann (fftbins=True)
for (let j = 0; j < N_FFT; j++) window[j] = 0.5 - 0.5 * Math.cos((2 * Math.PI * j) / N_FFT);
const fft = fftPlan(N_FFT);
const re = new Float64Array(N_FFT);
const im = new Float64Array(N_FFT);
const sDb = new Float32Array(rows * cols);
let maxPower = 0;
for (let c = 0; c < cols; c++) {
// Centred framing: frame c is padded[c*hop, c*hop + N_FFT) with N_FFT/2
// zeros either side of the signal.
const offset = c * hop - half;
for (let j = 0; j < N_FFT; j++) {
const t = offset + j;
re[j] = t >= 0 && t < n ? mono[t] * window[j] : 0;
im[j] = 0;
}
fft(re, im);
const p0 = re[0] * re[0] + im[0] * im[0];
if (p0 > maxPower) maxPower = p0;
for (let b = 1; b <= half; b++) {
const p = re[b] * re[b] + im[b] * im[b];
if (p > maxPower) maxPower = p;
sDb[(b - 1) * cols + c] = p;
}
}
// amplitude_to_db(ref=np.max, amin=1e-5, top_db=80): in power terms
// 10*log10(max(1e-10, p)) - 10*log10(max(1e-10, max p)), then clipped to
// (maximum - 80). The maximum of that is 0 at the reference bin, so the clip
// is a fixed -80 floor.
const amin = 1e-5 ** 2;
const refDb = 10.0 * Math.log10(Math.max(amin, maxPower));
for (let i = 0; i < sDb.length; i++) {
sDb[i] = Math.max(10.0 * Math.log10(Math.max(amin, sDb[i])) - refDb, SPEC_DB_FLOOR);
}
const binHz = 1.0 / (N_FFT * (1.0 / sr)); // np.fft.rfftfreq spacing
const freqs = new Float64Array(rows);
for (let b = 0; b < rows; b++) freqs[b] = (b + 1) * binHz;
const times = new Float64Array(cols);
for (let c = 0; c < cols; c++) times[c] = (c * hop + half) / sr;
return { freqs, times, s_db: sDb, rows, cols, nyquist: sr / 2.0 };
}
// ---- registry ----------------------------------------------------------------------
/** metric id -> compute(mono, sampleRate, memo). `memo` is per track. */
export const COMPUTE = {
rms_power: (mono, sr) => computeRmsPower(mono, sr),
waveform: (mono, sr) => computeWaveform(mono, sr),
lufs: (mono, sr, memo) => computeLufs(mono, sr, memo),
crest_factor: (mono, sr) => computeCrestFactor(mono, sr),
psr: (mono, sr, memo) => computePsr(mono, sr, memo),
true_peak: (mono, sr) => computeTruePeak(mono, sr),
spectrogram: (mono, sr) => computeSpectrogram(mono, sr),
};
/** The ArrayBuffers inside a computed result, for a zero-copy postMessage. */
export function transferables(data) {
const buffers = new Set();
for (const value of Object.values(data)) {
if (ArrayBuffer.isView(value)) buffers.add(value.buffer);
}
return [...buffers];
}
+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Audio Mastering Analysis Toolkit</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="vendor/uPlot.min.css">
<link rel="stylesheet" href="app.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo">◉</span>
<span class="brand-name">uj-mastering-master</span>
<span class="brand-sub">web</span>
</div>
<div class="topbar-controls">
<label class="field">
<span>Metric</span>
<select id="metric-select" title="Which metric to display"></select>
</label>
<label class="field">
<span>X axis</span>
<select id="x-mode-select" title="Absolute time, or each track normalised to its own length">
<option value="absolute">Absolute (s)</option>
<option value="relative">Relative (%)</option>
</select>
</label>
<button id="toggle-y-log" class="toggle" type="button">Y: log</button>
<button id="refresh-plot" class="btn" type="button" title="Redraw from the computed results">Refresh</button>
<div class="separator"></div>
<button id="pick-files" class="btn primary" type="button">Add audio…</button>
<input id="file-input" type="file" accept=".mp3,.wav,.flac,.ogg,.oga,.m4a,.aif,.aiff,.aifc,.opus" multiple hidden>
</div>
</header>
<main class="layout">
<aside class="sidebar">
<section class="panel">
<h2>Files <span id="file-count" class="badge">0</span></h2>
<ul id="file-list" class="file-list"></ul>
<p id="file-empty" class="empty">Drop audio files here, or use <em>Add audio…</em>.
They are analysed in this tab and never uploaded.</p>
</section>
<section class="panel">
<h2>Metadata</h2>
<dl id="metadata" class="metadata">
<dt>Track</dt><dd class="muted">nothing selected</dd>
</dl>
</section>
<section class="panel">
<h2>Reference lines <span id="ref-count" class="badge">0</span></h2>
<ul id="ref-list" class="ref-list"></ul>
<div class="ref-actions">
<button id="add-ref" class="btn small" type="button">Add at centre</button>
<button id="clear-refs" class="btn small" type="button">Clear</button>
</div>
<p class="hint">Drag a line on the plot to move it. Sets are kept per metric.</p>
</section>
</aside>
<section class="stage">
<div class="plot-head">
<h1 id="plot-title">No track loaded</h1>
<div class="plot-hint">Scroll to zoom · Ctrl: time only · Shift: values only · drag to pan · double-click to reset</div>
</div>
<div id="plot-host" class="plot-host">
<div id="placeholder" class="placeholder">
<p class="ph-title">Audio Mastering Analysis Toolkit</p>
<p class="ph-body">Drop <code>.wav</code>, <code>.flac</code> or <code>.mp3</code> files to analyse.
Decoding and every metric run inside this tab: the audio is never uploaded
or saved anywhere (this page isn't even permitted to make network requests),
and closing the tab discards it. Analysis runs at the file's native sample rate.</p>
</div>
<canvas id="heatmap" class="heatmap" hidden></canvas>
<div id="plot-legend" class="plot-legend" hidden></div>
</div>
<div class="statusbar">
<span id="status" class="status">Ready</span>
<span id="timing" class="timing"></span>
</div>
</section>
</main>
<div id="drop-overlay" class="drop-overlay" hidden><span>Drop to analyse</span></div>
<script src="vendor/uPlot.iife.min.js"></script>
<!-- app.js is an ES module: it decodes with decode.js and runs the metrics
from dsp.js in analysis-worker.js. -->
<script type="module" src="app.js"></script>
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
// SPDX-License-Identifier: Apache-2.0
//
// Metric registry for the browser: display names and `build_spec` ports.
//
// Mirrors `metrics.METRICS`. Computation lives in dsp.js and runs in the
// analysis worker; `buildSpec` is cheap and runs on the main thread whenever the
// view changes, turning computed data into the plot payload the renderer draws —
// the same shape `metrics_json.serialize_spec` used to put on the wire:
// {axes, hlines, annotations, segments (bands), series (curves), heatmap}.
const numbers = (arr) => Array.from(arr, (v) => (Number.isFinite(v) ? v : null));
const extent = (times) => (times.length ? [times[0], times[times.length - 1]] : null);
function payload({ axes, bands = [], curves = [], hlines = [], heatmap = null, annotations = [] }) {
return {
title: '',
axes: {
x_label: '', y_label: '', y_log: false, x_log: false,
y_range: null, x_range: null, y_log_allowed: false, x_log_allowed: false,
...axes,
},
hlines: hlines.map((h) => ({ label: '', color: '#888888', style: 'dot', width: 0.8, ...h })),
annotations,
segments: bands.map((b) => ({
kind: 'band', x: numbers(b.x), lo: numbers(b.lo), hi: numbers(b.hi),
label: b.label || '', color: null,
})),
series: curves.map((c) => ({
kind: 'line', x: numbers(c.x), y: numbers(c.y),
label: c.label || '', width: c.width ?? 1.4, color: c.color ?? null,
})),
heatmap,
y_bounds: null,
};
}
export const METRICS = [
{
id: 'rms_power',
displayName: 'RMS Power',
buildSpec(data) {
let peak = 0;
for (const v of data.rms) if (v > peak) peak = v;
// Adaptive headroom: loud masters get a taller scale.
const ymax = data.rms.length && peak > 0.3 ? 0.6 : 0.3;
return payload({
axes: { x_label: 'Time (seconds)', y_label: 'Power', y_range: [0.0, ymax], x_range: extent(data.times) },
bands: [{ x: data.times, lo: new Float64Array(data.rms.length), hi: data.rms, label: 'RMS power' }],
});
},
},
{
id: 'waveform',
displayName: 'Waveform',
buildSpec(data) {
return payload({
axes: { x_label: 'Time (seconds)', y_label: 'Amplitude', y_range: [-1.1, 1.1], x_range: extent(data.times) },
bands: [{ x: data.times, lo: data.lo, hi: data.hi, label: 'Waveform' }],
});
},
},
{
id: 'lufs',
displayName: 'LUFS',
buildSpec(data) {
const hlines = [{ y: -14.0, label: '-14 LUFS (streaming target)', style: 'dot' }];
const annotations = [];
if (Number.isFinite(data.integrated)) {
hlines.push({
y: data.integrated, label: `Integrated: ${data.integrated.toFixed(1)} LUFS`,
color: '#e76f51', style: 'dash', width: 1.5,
perTrack: true, // differs per track: coloured by track when overlaid
});
}
if (Number.isFinite(data.lra)) annotations.push(`LRA: ${data.lra.toFixed(1)} LU`);
return payload({
axes: { x_label: 'Time (seconds)', y_label: 'LUFS', y_range: [-50.0, 0.0], x_range: extent(data.times) },
curves: [{ x: data.times, y: data.lufs, label: 'Short-term (3 s)' }],
hlines,
annotations,
});
},
},
{
id: 'crest_factor',
displayName: 'Crest Factor',
buildSpec(data) {
return payload({
axes: { x_label: 'Time (seconds)', y_label: 'Crest factor (dB)', y_range: [0.0, 25.0], x_range: extent(data.times) },
curves: [{ x: data.times, y: data.crest_db, label: 'Crest factor (1 s)' }],
hlines: [
{ y: 12.0, label: '12 dB', style: 'dot' },
{ y: 6.0, label: '6 dB (squashed)', style: 'dot' },
],
});
},
},
{
id: 'psr',
displayName: 'PSR',
buildSpec(data) {
return payload({
axes: { x_label: 'Time (seconds)', y_label: 'PSR (LU)', y_range: [0.0, 25.0], x_range: extent(data.times) },
curves: [{ x: data.times, y: data.psr, label: 'PSR (3 s)' }],
hlines: [
{ y: 10.0, label: '10 LU (good punch)', style: 'dot' },
{ y: 4.0, label: '4 LU (squashed)', style: 'dot' },
],
});
},
},
{
id: 'true_peak',
displayName: 'True Peak',
buildSpec(data) {
const annotations = [];
if (Number.isFinite(data.integrated_tp_db)) annotations.push(`Max: ${data.integrated_tp_db.toFixed(2)} dBTP`);
return payload({
axes: { x_label: 'Time (seconds)', y_label: 'dBTP', y_range: [-30.0, 6.0], x_range: extent(data.times) },
curves: [{ x: data.times, y: data.tp_db, label: 'True Peak (250 ms)', width: 1.0 }],
hlines: [
{ y: 0.0, label: '0 dBTP (clip)', color: '#000000', style: 'dash', width: 1.0 },
{ y: -1.0, label: '-1 dBTP (typical ceiling)', style: 'dot' },
],
annotations,
});
},
},
{
id: 'spectrogram',
displayName: 'Spectrogram',
buildSpec(data, view) {
const yLog = view && view.yLog !== null && view.yLog !== undefined ? view.yLog : true;
return payload({
axes: {
x_label: 'Time (seconds)', y_label: 'Frequency (Hz)',
y_log: yLog, y_log_allowed: true,
y_range: [20.0, data.nyquist], x_range: extent(data.times),
},
heatmap: {
x: Array.from(data.times),
y: data.freqs,
z: data.s_db, // Float32Array, row-major rows x cols
rows: data.rows,
cols: data.cols,
z_min: -80.0,
z_max: 0.0,
cmap: 'magma',
label: 'Power (dB)',
// Rows are uniform in frequency: the renderer addresses them in Hz,
// which is what lets it draw a log axis without resampling.
y0: data.freqs.length ? data.freqs[0] : 0,
dy: data.freqs.length > 1 ? data.freqs[1] - data.freqs[0] : 1,
},
});
},
},
];
export const METRIC_BY_ID = Object.fromEntries(METRICS.map((m) => [m.id, m]));
export const DEFAULT_METRIC_ID = 'rms_power';
/**
* plotspec.apply_x_mode: rewrite a payload's x values to 0-100 % of the
* dataset's own span. A pure view transform — returns a new payload and leaves
* the cached one untouched.
*/
export function applyRelativeX(p) {
const xs = [...p.series.map((s) => s.x), ...p.segments.map((s) => s.x)];
if (p.heatmap) xs.push(p.heatmap.x);
const nonEmpty = xs.filter((x) => x.length);
if (!nonEmpty.length) return p;
const lo = Math.min(...nonEmpty.map((x) => x[0]));
const hi = Math.max(...nonEmpty.map((x) => x[x.length - 1]));
const span = hi - lo || 1.0;
const rel = (x) => x.map((v) => (v === null ? null : ((v - lo) / span) * 100.0));
return {
...p,
axes: { ...p.axes, x_label: 'Position (%)', x_range: [0.0, 100.0] },
series: p.series.map((s) => ({ ...s, x: rel(s.x) })),
segments: p.segments.map((s) => ({ ...s, x: rel(s.x) })),
heatmap: p.heatmap ? { ...p.heatmap, x: rel(p.heatmap.x) } : null,
};
}
+2
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}