Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 239b764393 | |||
| 5420975f8e | |||
| aed2d54b45 | |||
| 6ae4d5e8e3 | |||
| da4231ad36 | |||
| e8da88b6c1 |
+7
-29
@@ -1,29 +1,7 @@
|
||||
# Build context hygiene. The image needs the source modules and static/, but
|
||||
# nothing else — a fat context here means slow builds and a bigger surface for
|
||||
# accidentally baking in local state (audio, caches, .env, ssh keys).
|
||||
.git
|
||||
.gitignore
|
||||
.venv
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Tests and tooling are not needed to run the service.
|
||||
tests/
|
||||
scripts/
|
||||
.claude/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Local state that must never enter an image.
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
fonts/
|
||||
*.ttf
|
||||
*.otf
|
||||
*.ttc
|
||||
audio_analysis.log
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
- **Font Management**: CJK-capable, fixed UI font (M PLUS 1 Code @ 10pt) with system fallback
|
||||
- **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
|
||||
- **Audio Processing**: librosa, numpy
|
||||
@@ -109,49 +111,55 @@ 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
|
||||
renderer, applied uniformly to every metric — not per-metric
|
||||
|
||||
#### `web_session.py`
|
||||
- Server-side equivalent of `AnalysisResultsManager`, without Qt: owns the
|
||||
per-track metric cache and the overlay set, and mirrors the desktop's
|
||||
compute-off-the-request-thread behaviour
|
||||
- **No audio is stored.** Tracks are keyed by `file_id` = SHA-256 of the audio,
|
||||
and the session keeps only analysis results and metadata. There is no upload
|
||||
directory and no cache of bytes
|
||||
- Because the key is a content hash, re-sending the same master for a second
|
||||
metric reuses the first decode — the client only re-uploads what the server has
|
||||
never seen (checked via `metrics_cached`)
|
||||
#### `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
|
||||
|
||||
#### `webapp.py`
|
||||
- FastAPI routes + static mount + the `ujm-web` entry point (binds loopback by
|
||||
default; `--host`/`--port`/`--open` to override)
|
||||
- Analysis endpoints take the audio as a **raw request body**, not a multipart
|
||||
upload: spool to a temp file, `SESSION.observe(...)`, unlink in `finally`. The
|
||||
bytes exist on disk only for the duration of that one request
|
||||
- `POST /api/analyse` (analyse + plot data), `POST /api/file` (register without
|
||||
plotting), `DELETE /api/file`, `POST /api/overlay`, `GET /api/plot`,
|
||||
`GET /api/metadata`, `GET /api/metrics`, `GET /api/session`
|
||||
|
||||
#### `metrics_json.py`
|
||||
- The renderer seam: `PlotSpec`/metric data → JSON, so a non-pyqtgraph renderer
|
||||
consumes the identical spec (curves, bands, hlines, heatmap, axis specs) that
|
||||
the desktop widget draws. `hlines` carry the integrated LUFS reference line —
|
||||
it is not an annotation
|
||||
|
||||
#### `static/` (web front end)
|
||||
- `audio-store.js` — OPFS store. Copies each picked/dropped `File` into Origin
|
||||
Private File System (browser-sandboxed, not the host's filesystem), hashes it
|
||||
with WebCrypto in chunks while streaming (never loads a whole master into
|
||||
memory), and re-sends bytes to the server per metric
|
||||
- `app.js` — the web renderer, an ES module importing the store. Draws the same
|
||||
`PlotSpec` the desktop does: uPlot for curves/bands, a plain canvas for the
|
||||
spectrogram heatmap (a canvas can map rows through an arbitrary function, so it
|
||||
draws native linear rows and maps them, unlike pyqtgraph's affine-only
|
||||
`ImageItem`). Mirrors the desktop's per-metric colour cycle, lin/log toggles,
|
||||
absolute/relative time, and draggable reference lines
|
||||
- `vendor/` — uPlot 1.6.32 (IIFE + CSS), vendored so nothing is fetched from a
|
||||
CDN at runtime
|
||||
- Boot rehydrates: the server still has the last session's analysis, so it
|
||||
re-offers audio only for metric/track combinations the server reports as
|
||||
uncached, and reports unavailable combinations rather than faking them
|
||||
#### `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`
|
||||
- Defines the `AudioFile` class: librosa loading, rolling RMS power. BPM detection
|
||||
@@ -269,7 +277,8 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
||||
- matplotlib: Colormaps only (consumed by pyqtgraph) + librosa dependency
|
||||
- mutagen: Audio metadata extraction
|
||||
- PyQt5: GUI framework
|
||||
- fastapi / uvicorn: web UI (optional extra; the desktop app does not need them)
|
||||
- Web UI: no Python dependencies; uPlot is vendored in `web/vendor/`. The parity
|
||||
tests need Node 18+
|
||||
|
||||
### Architecture considerations
|
||||
- Three-stage split: `metrics.compute` (heavy, worker thread, backend-neutral
|
||||
@@ -294,19 +303,16 @@ uv run ujm # launch the GUI
|
||||
```
|
||||
|
||||
### Running the web UI
|
||||
Branch `feature/web-ui`. Same backend, browser front end.
|
||||
Branch `feature/web-js`. Static files in `web/`; everything runs in the browser.
|
||||
|
||||
```bash
|
||||
uv sync --extra web
|
||||
uv run ujm-web # http://127.0.0.1:8000
|
||||
uv run ujm-web --host 0.0.0.0 --port 8080 # reachable from another machine
|
||||
docker compose up -d --build # nginx + CSP, http://localhost:8080
|
||||
python3 -m http.server -d web 8080 # quick local try (no CSP header)
|
||||
```
|
||||
|
||||
Uploaded audio is held in the browser's Origin Private File System and streamed
|
||||
to the server per request; the server spools each body to a temp file and unlinks
|
||||
it before responding. Nothing is written to the host beyond that transient spool.
|
||||
Verify with `python3 scripts/webapi_smoke.py` against a running server (needs
|
||||
`ffmpeg` for the fixtures).
|
||||
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`):
|
||||
```bash
|
||||
|
||||
+30
-80
@@ -1,88 +1,38 @@
|
||||
# uj-mastering-master — web UI
|
||||
#
|
||||
# Multi-stage on purpose: the build stage compiles wheels (librosa pulls in
|
||||
# scipy, numba and llvmlite, all of which have C extensions), and none of that
|
||||
# toolchain belongs in the runtime image.
|
||||
# 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 runtime image installs the *installed package*, not a source tree, so the
|
||||
# same image works whether you built it from a checkout or a release tarball.
|
||||
#
|
||||
# Note on audio: the web UI stores uploaded audio in the browser (OPFS) and
|
||||
# spools each request body to /tmp only for the duration of that request. There
|
||||
# is no volume for audio, because the app never keeps any — see README.
|
||||
#
|
||||
# Note on the desktop app: this image is the *web* front end only. PyQt5 and
|
||||
# pyqtgraph are deliberately not installed — there is no display in a container.
|
||||
# The desktop app (PyQt5) is unaffected and not part of this image.
|
||||
|
||||
# ---- build -----------------------------------------------------------------
|
||||
FROM python:3.11-slim AS build
|
||||
# ---- 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
|
||||
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
UV_LINK_MODE=copy
|
||||
# ---- runtime -------------------------------------------------------------------
|
||||
FROM nginxinc/nginx-unprivileged:stable-alpine
|
||||
|
||||
# numba/llvmlite and scipy need a compiler; libsndfile is what soundfile
|
||||
# (librosa's decoder) links against at runtime, but the wheel needs it at build
|
||||
# time to be detected.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
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/
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.12.5 /uv /usr/local/bin/uv
|
||||
EXPOSE 8080
|
||||
|
||||
WORKDIR /src
|
||||
# Copy the whole checkout minus .dockerignore. Deliberately not a hand-picked
|
||||
# file list: `py-modules` in pyproject.toml enumerates every module the build
|
||||
# backend must find, and a curated COPY that omits any of them fails the build
|
||||
# with "file <module>.py not found". The exclusions that matter (venv, caches,
|
||||
# .git, local state) are in .dockerignore, which is the right place for them.
|
||||
COPY . .
|
||||
|
||||
# Build a wheel, then install it into a self-contained prefix to copy forward.
|
||||
# `"uj-mastering-master[web]"` (not `--extra web`) is the syntax uv wants when
|
||||
# installing a source tree — `--extra` needs a lockfile/setup.py it does not
|
||||
# have here and fails with "Requesting extras requires a pylock.toml...".
|
||||
RUN uv venv /opt/venv \
|
||||
&& uv pip install --python /opt/venv/bin/python --no-cache \
|
||||
".[web]"
|
||||
|
||||
# ---- runtime ---------------------------------------------------------------
|
||||
FROM python:3.11-slim AS runtime
|
||||
|
||||
# libsndfile1 is the only runtime system dependency: librosa decodes through
|
||||
# soundfile, which dlopens it. Everything else is a self-contained wheel.
|
||||
# (FFmpeg is *not* needed — it only ever generated test fixtures.)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libsndfile1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root, with a home it can actually write to: librosa/numba want a cache
|
||||
# dir, and the app spools request bodies under /tmp.
|
||||
RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin ujm
|
||||
|
||||
COPY --from=build /opt/venv /opt/venv
|
||||
|
||||
ENV PATH=/opt/venv/bin:$PATH \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
HOME=/home/ujm \
|
||||
NUMBA_CACHE_DIR=/tmp/numba-cache \
|
||||
MPLCONFIGDIR=/tmp/matplotlib
|
||||
|
||||
# The app's temporary spools land here. Declared so a read-only root filesystem
|
||||
# can still be used (`read_only: true` + `tmpfs: /tmp` in compose).
|
||||
VOLUME ["/tmp"]
|
||||
|
||||
USER ujm
|
||||
WORKDIR /home/ujm
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Baked-in healthcheck: hit the API rather than the page, so it fails when the
|
||||
# *backend* is broken and not merely when a static asset is missing.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/metrics', timeout=4).status == 200 else 1)"]
|
||||
|
||||
# 0.0.0.0, not the CLI default of 127.0.0.1: loopback inside a container is
|
||||
# unreachable from the host, which is the whole point of publishing a port.
|
||||
CMD ["ujm-web", "--host", "0.0.0.0", "--port", "8000", "--log-level", "INFO"]
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1
|
||||
|
||||
@@ -39,12 +39,11 @@ uv sync --extra desktop
|
||||
uv run ujm
|
||||
```
|
||||
|
||||
`uv run ujm` boots the desktop GUI (see *Web UI* for the browser front end).
|
||||
`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, kept separate because nothing on the web path imports it. Add
|
||||
`--all-extras` to get both front ends.
|
||||
`desktop` extra.
|
||||
|
||||
### Logging flags
|
||||
```bash
|
||||
@@ -59,102 +58,88 @@ See [CJK_FONTS.md](CJK_FONTS.md) for details.
|
||||
|
||||
## Web UI
|
||||
|
||||
The same analysis backend with a browser front end, on branch `feature/web-ui`.
|
||||
No PyQt5, no matplotlib: `metrics.py` was already split into a backend-neutral
|
||||
`compute` and a view-aware `build_spec`, so the web UI reuses the analysis code
|
||||
unchanged and reimplements only the drawing.
|
||||
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.
|
||||
|
||||
```bash
|
||||
uv sync --extra web
|
||||
uv run ujm-web # http://127.0.0.1:8000
|
||||
uv run ujm-web --host 0.0.0.0 --port 8080 --open
|
||||
```
|
||||
|
||||
A server install needs no Qt at all, which is also what keeps the container
|
||||
image small.
|
||||
**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, and draggable reference lines
|
||||
(kept per metric, as on the desktop).
|
||||
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.
|
||||
|
||||
**Uploaded audio is never written to the host.** The browser copies each file
|
||||
into Origin Private File System — its own sandboxed storage — and streams the
|
||||
bytes to the server for one request at a time. The server spools each body to a
|
||||
temp file, decodes, computes, and unlinks it in a `finally` before the response
|
||||
is sent. There is no upload directory, no cache of audio, and no session state
|
||||
keyed on a file path: `file_id` is the SHA-256 of the audio, so re-sending the
|
||||
same master for a second metric reuses the first decode.
|
||||
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.)
|
||||
|
||||
Being precise about the guarantee: the bytes do have to reach the server, because
|
||||
`librosa` decodes from a path and the analysis is CPU-bound numpy/scipy.
|
||||
*Running this does not accumulate the user's masters on the host* — nothing
|
||||
persists between requests. It is not a claim that audio is never on the machine
|
||||
mid-request.
|
||||
```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)
|
||||
```
|
||||
|
||||
The front end is served entirely from `static/` with uPlot vendored locally, so
|
||||
nothing is fetched from a CDN, and the server binds loopback by default.
|
||||
### 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
|
||||
|
||||
A `docker compose` template is provided:
|
||||
|
||||
```bash
|
||||
docker compose up --build # build and start
|
||||
open http://localhost:8000
|
||||
```
|
||||
|
||||
The image is `web` extra only — no Qt, no display server — and runs as an
|
||||
unprivileged user (uid 10001). It is built multi-stage so the compiler toolchain
|
||||
that scipy/numba need never reaches the runtime layer; the runtime image installs
|
||||
the built wheel, so the same image works from a checkout or a release tarball.
|
||||
|
||||
What the template assumes, and why:
|
||||
|
||||
- **There is no audio volume.** Not an oversight: the app keeps no audio, so
|
||||
there is nothing to persist. `read_only: true` with a `/tmp` tmpfs means
|
||||
request spools are memory-backed and never touch a disk at all.
|
||||
- **Loopback publishing.** The app binds `0.0.0.0` inside the container (the CLI
|
||||
default of `127.0.0.1` would be unreachable from the host), but compose
|
||||
publishes `127.0.0.1:8000:8000`. This tool has no authentication, so it should
|
||||
not appear on the LAN by accident. Change to `"8000:8000"` or put it behind a
|
||||
reverse proxy to expose it deliberately.
|
||||
- **CPU limits, not just memory.** Analysis is CPU-bound numpy/scipy, so the BLAS
|
||||
thread pools are pinned (`OMP_NUM_THREADS` etc.) to stop overlapping requests
|
||||
from oversubscribing. Raise them if you raise `cpus`.
|
||||
- `cap_drop: [ALL]` and `no-new-privileges` — nothing in the image needs
|
||||
capabilities.
|
||||
|
||||
For a read-only root filesystem with no writable layer at all, the defaults
|
||||
already give you that.
|
||||
|
||||
### Verifying it
|
||||
|
||||
With a server running:
|
||||
|
||||
```bash
|
||||
python3 scripts/make_fixtures.py # synthesise test audio
|
||||
python3 scripts/webapi_smoke.py # 61 assertions
|
||||
python3 scripts/webapi_smoke.py http://host:port /path/to/audio
|
||||
```
|
||||
|
||||
`make_fixtures.py` generates the audio the suite needs (`sine440.wav`,
|
||||
`pinknoise.wav`) with numpy alone, so no network and no ffmpeg are required — a
|
||||
tagged MP3 is added only if ffmpeg happens to be present, and the tag assertions
|
||||
skip cleanly when it is not. The suite drives the real HTTP surface with real
|
||||
audio and checks the contract the browser depends on — payload shapes per
|
||||
metric, physics cross-checked against an independent `librosa` computation, view
|
||||
toggles that must not alter data, error paths, and that no spool file survives a
|
||||
request.
|
||||
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_session.py` | Headless analysis session: content-addressed track cache, metric cache, overlay |
|
||||
| `metrics_json.py` | `PlotSpec` → JSON (the renderer seam; the desktop renderer is the other consumer) |
|
||||
| `webapp.py` | FastAPI routes, raw-body analysis endpoints, static mount, `ujm-web` entry point |
|
||||
| `static/audio-store.js` | OPFS audio store, content hashing, streamed upload |
|
||||
| `static/app.js` | Web renderer: same `PlotSpec` drawn with uPlot + canvas |
|
||||
| `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
|
||||
Core (always installed): `librosa`, `numpy`, `matplotlib`, `mutagen`,
|
||||
@@ -162,7 +147,9 @@ Core (always installed): `librosa`, `numpy`, `matplotlib`, `mutagen`,
|
||||
|
||||
Optional extras:
|
||||
- `desktop` — `PyQt5`, `pyqtgraph` (the GUI)
|
||||
- `web` — `fastapi`, `uvicorn`, `python-multipart` (the browser front end)
|
||||
|
||||
The web UI has no dependencies beyond uPlot, vendored in `web/vendor/`. The parity
|
||||
tests need Node 18+ plus the Python analysis dependencies.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -186,5 +173,8 @@ Subclass `Metric` in `metrics.py`, implement `compute(audio_file) -> data` (the
|
||||
heavy part, runs on the worker thread) and `build_spec(data, view) -> PlotSpec`
|
||||
(cheap, view-aware, reruns on every scale toggle without recomputation). Register
|
||||
the instance in the `METRICS` dict at the bottom of the file — it shows up in the
|
||||
desktop dropdown and in the web UI's metric list automatically, because both
|
||||
renderers consume the same `PlotSpec`.
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
+13
-64
@@ -1,88 +1,37 @@
|
||||
# uj-mastering-master — web UI, container deployment template.
|
||||
#
|
||||
# docker compose up --build # build and start
|
||||
# docker compose logs -f # watch startup
|
||||
# open http://localhost:8000
|
||||
# docker compose up -d --build
|
||||
# open http://localhost:8080
|
||||
#
|
||||
# There is deliberately no audio volume. Uploaded audio lives in the browser
|
||||
# (Origin Private File System) and each request body is spooled to /tmp only
|
||||
# while that request is being decoded. Nothing about a user's masters is
|
||||
# persisted here — read-only storage is not just possible, it is the intent.
|
||||
# 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:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# Pin the build to this branch's source; override to build from a
|
||||
# release tag instead:
|
||||
# docker compose build --build-arg ...
|
||||
build: .
|
||||
image: uj-mastering-master-web:local
|
||||
container_name: ujm-web
|
||||
|
||||
# Loopback-only by default. The app binds 0.0.0.0 *inside* the container,
|
||||
# but publishing to 127.0.0.1 means it is only reachable from this host —
|
||||
# an analysis tool with no authentication should not appear on the LAN by
|
||||
# accident. Change to "8000:8000" (or put it behind a reverse proxy) to
|
||||
# expose it deliberately.
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
- "8080:8080"
|
||||
|
||||
environment:
|
||||
# Analysis is CPU-bound numpy/scipy; keep BLAS from oversubscribing when
|
||||
# several requests overlap. Raise these if you raise `cpus` below.
|
||||
OMP_NUM_THREADS: "4"
|
||||
OPENBLAS_NUM_THREADS: "4"
|
||||
MKL_NUM_THREADS: "4"
|
||||
NUMEXPR_NUM_THREADS: "4"
|
||||
# Writable scratch for numba/matplotlib caches and request spools.
|
||||
NUMBA_CACHE_DIR: /tmp/numba-cache
|
||||
MPLCONFIGDIR: /tmp/matplotlib
|
||||
TMPDIR: /tmp
|
||||
|
||||
# The container filesystem can be read-only: the app writes nothing it
|
||||
# needs to keep. /tmp is a tmpfs (memory-backed) so spooled request bodies
|
||||
# never touch a disk at all.
|
||||
# Nothing to write: nginx only needs somewhere for its pid file and empty
|
||||
# temp directories.
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=1g,mode=1777
|
||||
- /tmp:size=16m,mode=1777
|
||||
|
||||
# Analysis needs headroom: a long master at native sample rate is heavy in
|
||||
# RAM. Raise the limit for large files; the tmpfs above is also backed by
|
||||
# memory, so --memory must comfortably exceed it.
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "4.0"
|
||||
memory: 4g
|
||||
|
||||
# Run as the image's unprivileged user (uid 10001). Nothing here needs root.
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 10s
|
||||
|
||||
# The image ships a HEALTHCHECK; this is the same check expressed at the
|
||||
# orchestration layer, so `docker compose ps` shows real health.
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/metrics', timeout=4).status == 200 else 1)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
retries: 3
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# No volumes are declared, on purpose. If you want to run the API smoke test
|
||||
# inside the image, do it ad hoc rather than mounting a source tree:
|
||||
#
|
||||
# docker compose exec ujm-web python - <<'PY'
|
||||
# ... # or see `make smoke` in the README
|
||||
# PY
|
||||
max-size: "1m"
|
||||
max-file: "2"
|
||||
|
||||
-170
@@ -1,170 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
PlotSpec -> JSON.
|
||||
|
||||
The desktop renderer (`audio_visualization_widget`) draws a `PlotSpec` with
|
||||
pyqtgraph. The web renderer draws the *same* `PlotSpec` with a browser plotting
|
||||
library, so the only thing this module owns is the wire format between them:
|
||||
numpy arrays become plain lists, `NaN`/`inf` (gated PSR, empty windows) become
|
||||
JSON `null` so the client can break the line instead of drawing a spike.
|
||||
|
||||
Nothing here is web-framework-specific — `serialize_spec` is the exact partner of
|
||||
`show_specs`: same input, different canvas. Keeping it free of FastAPI imports
|
||||
means the desktop side could adopt it unchanged if a future client ever needed
|
||||
the same payload.
|
||||
|
||||
Why the heatmap is sent losslessly: the desktop renderer resamples the linear STFT
|
||||
rows onto a log-spaced grid for a log frequency axis (pyqtgraph's ImageItem is
|
||||
affine-only and won't follow a log axis). The browser *can* draw a non-linear
|
||||
axis from a linearly spaced image, so no resampling happens here — the client gets
|
||||
the native rows plus the axis-extent information and does the mapping itself.
|
||||
That keeps warm-up cost identical to the desktop app and makes the expensive
|
||||
log-grid interpolation happen once per view change rather than once per request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from plotspec import (
|
||||
PlotSpec, RefLineProps, ViewState, X_RELATIVE,
|
||||
)
|
||||
|
||||
|
||||
def _numbers(values) -> list:
|
||||
"""numpy array -> JSON-safe list, with non-finite floats mapped to None."""
|
||||
if values is None:
|
||||
return []
|
||||
arr = np.asarray(values, dtype=float).ravel()
|
||||
out: list[Optional[float]] = []
|
||||
for v in arr.tolist():
|
||||
out.append(None if (v is None or not math.isfinite(v)) else float(v))
|
||||
return out
|
||||
|
||||
|
||||
def _ints(values) -> list[float]:
|
||||
"""numpy array -> JSON-safe list of floats (no None mapping — index axes)."""
|
||||
return [float(v) for v in np.asarray(values, dtype=float).ravel().tolist()]
|
||||
|
||||
|
||||
def _bounds(values) -> Optional[list[float]]:
|
||||
"""[min, max] of the finite values, or None when everything is non-finite."""
|
||||
if values is None:
|
||||
return None
|
||||
arr = np.asarray(values, dtype=float).ravel()
|
||||
arr = arr[np.isfinite(arr)]
|
||||
if not arr.size:
|
||||
return None
|
||||
return [float(arr.min()), float(arr.max())]
|
||||
|
||||
|
||||
def _serialize_axes(axes) -> dict[str, Any]:
|
||||
return {
|
||||
"x_label": axes.x_label,
|
||||
"y_label": axes.y_label,
|
||||
"y_log": bool(axes.y_log),
|
||||
"x_log": bool(axes.x_log),
|
||||
"y_range": list(axes.y_range) if axes.y_range else None,
|
||||
"x_range": list(axes.x_range) if axes.x_range else None,
|
||||
"y_log_allowed": bool(axes.y_log_allowed),
|
||||
"x_log_allowed": bool(axes.x_log_allowed),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_heatmap(hm) -> dict[str, Any]:
|
||||
y = np.asarray(hm.y, dtype=float)
|
||||
z = np.asarray(hm.z, dtype=np.float32)
|
||||
return {
|
||||
"x": _numbers(hm.x),
|
||||
"y": _numbers(y),
|
||||
"z": [[float(v) for v in row] for row in z.tolist()],
|
||||
"z_min": float(hm.z_min),
|
||||
"z_max": float(hm.z_max),
|
||||
"cmap": hm.cmap,
|
||||
"label": hm.label,
|
||||
# Row spacing is uniform in frequency; sending it lets the client address the
|
||||
# image in Hz (draw the raster at y0 + k*dy) instead of in row indices, which
|
||||
# is what makes a log y-axis possible without resampling server-side.
|
||||
"y0": float(y[0]) if y.size else 0.0,
|
||||
"dy": float(y[1] - y[0]) if y.size > 1 else 1.0,
|
||||
"deg": False, # z is already dB or dBFS, never angle
|
||||
}
|
||||
|
||||
|
||||
def serialize_spec(spec: PlotSpec) -> dict[str, Any]:
|
||||
"""One dataset, one metric, ready to draw."""
|
||||
payload = {
|
||||
"title": spec.title,
|
||||
"axes": _serialize_axes(spec.axes),
|
||||
"hlines": [
|
||||
{
|
||||
"y": float(hl.y),
|
||||
"label": hl.label,
|
||||
"color": hl.color,
|
||||
"style": hl.style,
|
||||
"width": float(hl.width),
|
||||
}
|
||||
for hl in spec.hlines
|
||||
],
|
||||
"annotations": list(spec.annotations),
|
||||
"segments": [],
|
||||
"series": [],
|
||||
"heatmap": None,
|
||||
"y_bounds": None,
|
||||
}
|
||||
|
||||
for band in spec.bands:
|
||||
lo = np.broadcast_to(np.asarray(band.lo, dtype=float), np.asarray(band.x).shape)
|
||||
payload["segments"].append({
|
||||
"kind": "band",
|
||||
"x": _numbers(band.x),
|
||||
"lo": _numbers(lo),
|
||||
"hi": _numbers(band.hi),
|
||||
"label": band.label,
|
||||
"color": band.color,
|
||||
})
|
||||
|
||||
for curve in spec.curves:
|
||||
payload["series"].append({
|
||||
"kind": "line",
|
||||
"x": _numbers(curve.x),
|
||||
"y": _numbers(curve.y),
|
||||
"label": curve.label,
|
||||
"width": float(curve.width),
|
||||
"color": curve.color,
|
||||
})
|
||||
|
||||
if spec.heatmap is not None:
|
||||
payload["heatmap"] = _serialize_heatmap(spec.heatmap)
|
||||
|
||||
# Value range the browser should frame to, before any user zoom. For bands the
|
||||
# envelope is lo..hi, not just hi — RMS fills from zero, waveform spans ±peak.
|
||||
values = [np.asarray(c.y, dtype=float) for c in spec.curves]
|
||||
for band in spec.bands:
|
||||
values.append(np.asarray(band.lo, dtype=float))
|
||||
values.append(np.asarray(band.hi, dtype=float))
|
||||
if not spec.axes.y_range:
|
||||
finite = [v[np.isfinite(v)] for v in values]
|
||||
finite = [v for v in finite if v.size]
|
||||
if finite:
|
||||
payload["y_bounds"] = [float(min(v.min() for v in finite)),
|
||||
float(max(v.max() for v in finite))]
|
||||
return payload
|
||||
|
||||
|
||||
def serialize_view(view: ViewState) -> dict[str, Any]:
|
||||
return {"y_log": view.y_log, "x_log": view.x_log, "x_mode": view.x_mode}
|
||||
|
||||
|
||||
def serialize_ref_lines(lines: list[RefLineProps]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"value": float(l.value), "color": l.color, "style": l.style, "label": l.label}
|
||||
for l in lines
|
||||
]
|
||||
|
||||
|
||||
def relative_x_mode(view: ViewState) -> bool:
|
||||
return view.x_mode == X_RELATIVE
|
||||
+6
-44
@@ -7,12 +7,10 @@ name = "uj-mastering-master"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
# Only what the *analysis* needs. The GUI stack lives in the `desktop` extra
|
||||
# below, because nothing on the web path touches it: `webapp.py`,
|
||||
# `web_session.py`, `metrics_json.py`, `master_core.py`, `metrics.py` and
|
||||
# `plotspec.py` import no Qt, no pyqtgraph and no matplotlib canvas. Keeping
|
||||
# PyQt5 here would force a `--headless`-style install to drag in a full Qt
|
||||
# stack it never loads.
|
||||
# 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 = [
|
||||
"librosa",
|
||||
"numpy",
|
||||
@@ -34,30 +32,16 @@ desktop = [
|
||||
"pyqtgraph>=0.14.0",
|
||||
]
|
||||
|
||||
# Web front end. Installed with:
|
||||
# uv sync --extra web
|
||||
# Deliberately separate from the desktop path: a server install of the web UI
|
||||
# needs no Qt/wheel stack. Which is what makes the container image small and
|
||||
# lets it run without a display server at all.
|
||||
web = [
|
||||
"fastapi>=0.110",
|
||||
"uvicorn[standard]>=0.27",
|
||||
"python-multipart>=0.0.9", # multipart upload parsing for FastAPI
|
||||
]
|
||||
|
||||
[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, which is why the README spells out both:
|
||||
# uv sync # analysis + nothing else
|
||||
# Extras must be requested explicitly:
|
||||
# uv sync # analysis only
|
||||
# uv sync --extra desktop # + Qt/pyqtgraph (the GUI; `uv run ujm`)
|
||||
# uv sync --extra web # + fastapi/uvicorn (`uv run ujm-web`)
|
||||
# uv sync --all-extras # both
|
||||
|
||||
[project.scripts]
|
||||
ujm = "main:main"
|
||||
ujm-web = "webapp:main"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = [
|
||||
@@ -72,26 +56,4 @@ py-modules = [
|
||||
"ref_line_widget",
|
||||
"logger_setup",
|
||||
"setup_fonts",
|
||||
# web front end
|
||||
"webapp",
|
||||
"web_session",
|
||||
"metrics_json",
|
||||
]
|
||||
# `static/` is declared a *package* so `package-data` has something to attach
|
||||
# to. `package-data` keys match packages, and top-level `py-modules` are not
|
||||
# packages — so the earlier `"*" = [...]` form silently matched nothing and the
|
||||
# built wheel contained no assets at all (verified: 14 .py files, zero HTML/
|
||||
# JS/CSS). An installed `ujm-web` therefore 404'd every asset. With this entry
|
||||
# the assets install to `site-packages/static/`, which is exactly where
|
||||
# `webapp.py` looks (`Path(__file__).parent / "static"`).
|
||||
packages = ["static"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
# Vendored browser assets: uPlot (curves), no CDN at runtime. Keyed on the
|
||||
# `static` package (declared above) rather than `"*"`: a `"*"` key matches
|
||||
# *packages*, and there are none besides `static` — the app modules are
|
||||
# top-level `py-modules`, which is why the original form shipped nothing.
|
||||
#
|
||||
# `**/*` rather than `*` so the glob reaches `static/vendor/`, where the uPlot
|
||||
# bundle lives.
|
||||
static = ["*", "**/*"]
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the audio fixtures `webapi_smoke.py` expects.
|
||||
|
||||
Synthesised rather than downloaded so the smoke test has no network dependency
|
||||
and no external tools — `soundfile` and `numpy` come in with `librosa`, so this
|
||||
runs in the container image as-is (no ffmpeg needed).
|
||||
|
||||
sine440.wav 12 s, 440 Hz, stereo, -12 dBFS — a known-tone reference
|
||||
pinknoise.wav 8 s, pink noise, stereo — broadband, exercises the STFT
|
||||
tagged.mp3 a tagged file — only if ffmpeg is available
|
||||
|
||||
Usage: python3 scripts/make_fixtures.py [output_dir] (default /tmp/ujm-test)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
SR = 44100
|
||||
|
||||
|
||||
def sine(path: Path, seconds: float = 12.0, hz: float = 440.0,
|
||||
dbfs: float = -12.0) -> None:
|
||||
"""A tone at a known level. Peak is exactly 10**(dbfs/20) before encoding."""
|
||||
t = np.arange(int(seconds * SR)) / SR
|
||||
mono = (10 ** (dbfs / 20)) * np.sin(2 * np.pi * hz * t)
|
||||
sf.write(path, np.stack([mono, mono], axis=1), SR, subtype="PCM_16")
|
||||
|
||||
|
||||
def pink(path: Path, seconds: float = 8.0, peak: float = 0.25) -> None:
|
||||
"""Pink noise via 1/sqrt(f) shaping, normalised to a known peak."""
|
||||
rng = np.random.default_rng(7)
|
||||
n = int(seconds * SR)
|
||||
spectrum = np.fft.rfft(rng.standard_normal(n))
|
||||
freqs = np.fft.rfftfreq(n, 1 / SR)
|
||||
freqs[0] = freqs[1] # avoid dividing by zero at DC
|
||||
shaped = np.fft.irfft(spectrum / np.sqrt(freqs), n)
|
||||
mono = peak * shaped / np.max(np.abs(shaped))
|
||||
sf.write(path, np.stack([mono, mono], axis=1), SR, subtype="PCM_16")
|
||||
|
||||
|
||||
def tagged_mp3(path: Path) -> bool:
|
||||
"""An MP3 with ID3 tags, so the tag-reading path is exercised.
|
||||
|
||||
Genuinely optional: MP3 encoding is the one thing numpy cannot do, so this
|
||||
needs ffmpeg and is skipped when it is absent. The suite's tag assertions
|
||||
adapt — see `webapi_smoke.py`.
|
||||
"""
|
||||
if shutil.which("ffmpeg") is None:
|
||||
return False
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", "sine=frequency=330:duration=6:sample_rate=44100",
|
||||
"-metadata", "artist=Test Artist", "-metadata", "title=Test Title",
|
||||
"-codec:a", "libmp3lame", "-q:a", "2", str(path)],
|
||||
check=True,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
out = Path(sys.argv[1] if len(sys.argv) > 1 else "/tmp/ujm-test")
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sine(out / "sine440.wav")
|
||||
pink(out / "pinknoise.wav")
|
||||
mp3 = tagged_mp3(out / "テスト.mp3")
|
||||
|
||||
for f in sorted(out.iterdir()):
|
||||
print(f" {f.name} {f.stat().st_size / 1024:.0f} KiB")
|
||||
if not mp3:
|
||||
print("\nnote: no ffmpeg, so no tagged MP3 fixture was written "
|
||||
"(tag assertions will be skipped)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,355 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end check of the web API against a running server.
|
||||
|
||||
Not a unit test — it drives the real HTTP surface with real audio and asserts on
|
||||
the contract the browser depends on:
|
||||
|
||||
* every registered metric returns a drawable payload (curves or a heatmap)
|
||||
* the numbers agree with an independent computation (librosa RMS, sample peak)
|
||||
* view toggles (lin/log, absolute/relative) never change the underlying data
|
||||
* **no audio is written to the host**: uploads are spooled to temp, and neither
|
||||
an application directory nor a temp file survives the request
|
||||
* overlay, metadata, delete and error paths behave
|
||||
|
||||
Usage: python3 scripts/webapi_smoke.py [base_url] [audio_dir]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8137"
|
||||
AUDIO = Path(sys.argv[2] if len(sys.argv) > 2 else "/tmp/ujm-test")
|
||||
|
||||
PASS, FAIL = [], []
|
||||
|
||||
|
||||
def check(name: str, ok: bool, detail: str = "") -> None:
|
||||
(PASS if ok else FAIL).append(name + (f" — {detail}" if detail else ""))
|
||||
print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + detail if detail else ''}")
|
||||
|
||||
|
||||
def get(path: str, **params) -> dict:
|
||||
url = BASE + path + (("?" + urllib.parse.urlencode(params)) if params else "")
|
||||
with urllib.request.urlopen(url, timeout=600) as res:
|
||||
return json.loads(res.read())
|
||||
|
||||
|
||||
def post(path: str, payload=None, **params) -> dict:
|
||||
url = BASE + path + (("?" + urllib.parse.urlencode(params)) if params else "")
|
||||
data = json.dumps(payload).encode() if payload is not None else b""
|
||||
req = urllib.request.Request(url, data=data, method="POST",
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=600) as res:
|
||||
return json.loads(res.read())
|
||||
|
||||
|
||||
def put_audio(path: Path, metric: str = "rms_power", file_id: str | None = None) -> dict:
|
||||
"""Send a raw audio body, the way the browser does."""
|
||||
data = path.read_bytes()
|
||||
qs = {"name": path.name, "metric": metric}
|
||||
if file_id:
|
||||
qs["file_id"] = file_id
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/api/analyse?" + urllib.parse.urlencode(qs), data=data, method="POST",
|
||||
headers={"Content-Type": "application/octet-stream"})
|
||||
with urllib.request.urlopen(req, timeout=900) as res:
|
||||
return json.loads(res.read())
|
||||
|
||||
|
||||
def expect_http_error(fn, code: int, name: str) -> None:
|
||||
try:
|
||||
fn()
|
||||
except urllib.error.HTTPError as exc:
|
||||
check(name, exc.code == code, f"HTTP {exc.code}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
check(name, False, f"raised {type(exc).__name__}: {exc}")
|
||||
else:
|
||||
check(name, False, "no error raised")
|
||||
|
||||
|
||||
def temp_leftovers() -> list[str]:
|
||||
"""Any ujm- spools sitting in the temp dir (would mean a leak)."""
|
||||
return glob.glob(os.path.join(tempfile.gettempdir(), "ujm-*"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
files = sorted(AUDIO.glob("*.wav")) + sorted(AUDIO.glob("*.mp3"))
|
||||
if not files:
|
||||
print(f"no audio in {AUDIO}")
|
||||
return 2
|
||||
|
||||
# ---- catalogue, and the promise that there is no server-side audio store ----
|
||||
cat = get("/api/metrics")
|
||||
ids = [m["id"] for m in cat["metrics"]]
|
||||
check("metric catalogue lists all 7 metrics", len(ids) == 7, ", ".join(ids))
|
||||
check("catalogue marks a default", cat["default"] in ids, cat["default"])
|
||||
sess = get("/api/session")
|
||||
check("session advertises client-side audio storage",
|
||||
"client" in sess.get("audio_storage", ""), sess.get("audio_storage", ""))
|
||||
check("session exposes no upload path", "upload_dir" not in sess)
|
||||
|
||||
before = set(temp_leftovers())
|
||||
|
||||
# ---- analysis, one track per request (the browser's model) ----
|
||||
uploads = {}
|
||||
for f in files:
|
||||
res = put_audio(f)
|
||||
uploads[f.name] = res
|
||||
check(f"analyse {f.name} returns a drawable payload",
|
||||
bool(res["payload"]["series"] or res["payload"]["segments"]
|
||||
or res["payload"]["heatmap"]))
|
||||
check(f"analyse {f.name} returns a content-hash id",
|
||||
res["file"]["file_id"] == hashlib.sha256(f.read_bytes()).hexdigest()[:32],
|
||||
res["file"]["file_id"][:12])
|
||||
|
||||
sine = next(r for n, r in uploads.items() if "sine440" in n)
|
||||
noise = next(r for n, r in uploads.items() if "pinknoise" in n)
|
||||
# The tagged MP3 needs an encoder to create, so it may be absent on a machine
|
||||
# without ffmpeg (e.g. the container image). Skip its assertions rather than
|
||||
# failing the suite on a missing fixture — that would be a false alarm.
|
||||
tagged = next((r for r in uploads.values()
|
||||
if r["file"]["song_name"].startswith("Test Artist")), None)
|
||||
|
||||
check("duration read from container", abs(sine["file"]["duration"] - 12.0) < 0.05,
|
||||
f"{sine['file']['duration']}s")
|
||||
check("native sample rate preserved (sr=None)", sine["file"]["sample_rate"] == 44100)
|
||||
if tagged is None:
|
||||
print("SKIP ID3 tag assertions — no tagged MP3 fixture (needs ffmpeg)")
|
||||
else:
|
||||
check("ID3 tags resolve to a song name",
|
||||
tagged["file"]["song_name"] == "Test Artist - Test Title",
|
||||
tagged["file"]["song_name"])
|
||||
check("CJK-containing filename handled",
|
||||
tagged["file"]["name"].endswith(".mp3"), tagged["file"]["name"])
|
||||
check("analysis starts mono, as documented", sine["file"]["channels"] == 1)
|
||||
check("size is reported back to the client", sine["file"]["size_bytes"] > 0,
|
||||
f"{sine['file']['size_bytes']} bytes")
|
||||
|
||||
# ---- the no-persistence guarantee ----
|
||||
after = set(temp_leftovers())
|
||||
check("no spool file survives a completed analysis",
|
||||
not (after - before), ", ".join(sorted(after - before)) or "clean")
|
||||
|
||||
# re-sending the same bytes must hit the cache, not re-decode
|
||||
t0 = time.perf_counter()
|
||||
again = put_audio(Path(AUDIO / "sine440.wav"), metric="waveform")
|
||||
warm = time.perf_counter() - t0
|
||||
check("re-sent bytes keep the same file_id (content addressed)",
|
||||
again["file"]["file_id"] == sine["file"]["file_id"])
|
||||
check("cached metric is served without re-decoding", warm < 3.0, f"{warm:.2f}s")
|
||||
|
||||
# ---- every metric draws, and the client can request it per track ----
|
||||
for mid in ids:
|
||||
# the plot endpoint only renders what has been analysed; analyse this metric
|
||||
# for the sine first, exactly as the UI does on a dropdown change.
|
||||
put_audio(Path(AUDIO / "sine440.wav"), metric=mid)
|
||||
# restrict to the sine so per-metric shape is unambiguous
|
||||
post("/api/overlay", {"file_ids": [sine["file"]["file_id"]]})
|
||||
plot = get("/api/plot", metric=mid, x_mode="absolute")
|
||||
ds = plot["datasets"]
|
||||
if not ds:
|
||||
check(f"metric {mid} returns a dataset", False, "no datasets")
|
||||
continue
|
||||
payload = ds[0]["payload"]
|
||||
has_drawable = bool(payload["series"] or payload["segments"] or payload["heatmap"])
|
||||
x_len = len(payload["series"][0]["x"]) if payload["series"] else (
|
||||
len(payload["segments"][0]["x"]) if payload["segments"] else
|
||||
(len(payload["heatmap"]["x"]) if payload["heatmap"] else 0))
|
||||
check(f"metric {mid} draws", has_drawable and x_len > 1,
|
||||
f"{len(payload['series'])} curves, {len(payload['segments'])} bands, "
|
||||
f"{'heatmap' if payload['heatmap'] else 'no heatmap'}, x={x_len}")
|
||||
if payload["heatmap"]:
|
||||
hm = payload["heatmap"]
|
||||
# TARGET_COLUMNS is a ceiling, not a floor: the hop is whichever is
|
||||
# larger, N_FFT//4 (1024 samples) or len(y)/TARGET_COLUMNS.
|
||||
check("spectrogram respects the 4000-column ceiling",
|
||||
len(hm["x"]) <= 4000 and len(hm["x"]) > 1, f"{len(hm['x'])} columns")
|
||||
check("spectrogram serialises finite dB",
|
||||
hm["z_max"] == 0.0 and hm["z_min"] == -80.0,
|
||||
f"z range {hm['z_min']}..{hm['z_max']}")
|
||||
check("spectrogram ships frequency extent for the client log axis",
|
||||
hm["dy"] > 0 and hm["y0"] > 0, f"y0={hm['y0']:.2f} dy={hm['dy']:.3f}")
|
||||
|
||||
# ---- physics sanity: independent recomputation ----
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
post("/api/overlay", {"file_ids": [sine["file"]["file_id"]]})
|
||||
put_audio(Path(AUDIO / "sine440.wav"), metric="rms_power")
|
||||
y, sr = librosa.load(str(AUDIO / "sine440.wav"), sr=None)
|
||||
y = librosa.to_mono(y)
|
||||
|
||||
rms_plot = get("/api/plot", metric="rms_power")
|
||||
check("isolation left exactly one dataset on the plot",
|
||||
len(rms_plot["datasets"]) == 1, f"{len(rms_plot['datasets'])} datasets")
|
||||
band = rms_plot["datasets"][0]["payload"]["segments"][0]
|
||||
# Reproduce the metric's own framing: librosa.feature.rms at 10 s / 2 s.
|
||||
expected = librosa.feature.rms(y=y, frame_length=10 * sr, hop_length=2 * sr).ravel()
|
||||
reported = np.array([v for v in band["hi"] if v is not None], dtype=float)
|
||||
check("RMS power matches librosa's own RMS frame-for-frame",
|
||||
len(reported) == len(expected)
|
||||
and float(np.max(np.abs(reported - expected))) < 1e-4,
|
||||
f"{len(reported)} frames, max Δ {float(np.max(np.abs(reported - expected))):.2e}")
|
||||
|
||||
put_audio(Path(AUDIO / "sine440.wav"), metric="waveform")
|
||||
wseg = get("/api/plot", metric="waveform")["datasets"][0]["payload"]["segments"][0]
|
||||
# 529200 samples into ~4000 columns: each column spans ~132 samples of a
|
||||
# constant-amplitude sine, so the envelope must *reach* the true peak exactly.
|
||||
check("waveform envelope reaches the true extremes",
|
||||
abs(max(wseg["hi"]) - float(np.max(y))) < 1e-6
|
||||
and abs(min(wseg["lo"]) - float(np.min(y))) < 1e-6,
|
||||
f"hi {max(wseg['hi']):.6f} vs peak {float(np.max(y)):.6f}")
|
||||
|
||||
put_audio(Path(AUDIO / "sine440.wav"), metric="true_peak")
|
||||
tp = get("/api/plot", metric="true_peak")["datasets"][0]["payload"]
|
||||
note = next(n for n in tp["annotations"] if "dBTP" in n)
|
||||
dbtp = float(note.replace("Max:", "").replace("dBTP", "").strip())
|
||||
expected_dbtp = 20 * math.log10(float(np.max(np.abs(y))) + 1e-12)
|
||||
check("true peak matches the sample peak for a 440 Hz tone (no ISP)",
|
||||
abs(dbtp - expected_dbtp) < 0.5,
|
||||
f"api {dbtp:.2f} dBTP vs {expected_dbtp:.2f} dBFS")
|
||||
|
||||
put_audio(Path(AUDIO / "sine440.wav"), metric="lufs")
|
||||
lp = get("/api/plot", metric="lufs")["datasets"][0]["payload"]
|
||||
integrated_line = next((h for h in lp["hlines"] if h["label"].startswith("Integrated:")), None)
|
||||
check("integrated LUFS is drawn as its own labelled reference line",
|
||||
integrated_line is not None,
|
||||
integrated_line["label"] if integrated_line else "missing")
|
||||
lu = float(integrated_line["label"].split(":")[1].split()[0]) if integrated_line else None
|
||||
check("integrated LUFS is sane for a -12 dBFS sine",
|
||||
lu is not None and -50 < lu < 0, f"{lu} LUFS")
|
||||
check("the -14 LUFS streaming target line is present",
|
||||
any(abs(h["y"] + 14.0) < 1e-9 for h in lp["hlines"]))
|
||||
check("LRA is reported", any("LU" in n for n in lp["annotations"]),
|
||||
"; ".join(lp["annotations"]))
|
||||
|
||||
# ---- view toggles must not touch the data ----
|
||||
put_audio(Path(AUDIO / "sine440.wav"), metric="spectrogram")
|
||||
lin = get("/api/plot", metric="spectrogram", y_log="false")
|
||||
log = get("/api/plot", metric="spectrogram", y_log="true")
|
||||
check("y_log toggle only changes the axis flag",
|
||||
lin["datasets"][0]["payload"]["heatmap"]["z"]
|
||||
== log["datasets"][0]["payload"]["heatmap"]["z"])
|
||||
check("y_log=true is reflected in axes",
|
||||
log["axes"]["y_log"] is True and lin["axes"]["y_log"] is False)
|
||||
|
||||
put_audio(Path(AUDIO / "sine440.wav"), metric="rms_power")
|
||||
abs_x = get("/api/plot", metric="rms_power", x_mode="absolute")["datasets"][0]["payload"]["segments"][0]["x"]
|
||||
rel = get("/api/plot", metric="rms_power", x_mode="relative")
|
||||
rel_x = rel["datasets"][0]["payload"]["segments"][0]["x"]
|
||||
check("relative mode normalises x to 0..100",
|
||||
abs(rel_x[0]) < 1e-6 and abs(rel_x[-1] - 100.0) < 1e-6,
|
||||
f"{rel_x[0]:.4f} .. {rel_x[-1]:.4f}")
|
||||
check("relative mode relabels the x axis",
|
||||
rel["axes"]["x_label"] == "Position (%)", rel["axes"]["x_label"])
|
||||
check("relative mode keeps the same sample count", len(abs_x) == len(rel_x),
|
||||
f"{len(abs_x)} vs {len(rel_x)}")
|
||||
|
||||
# ---- caching: a repeat view render must not recompute ----
|
||||
t0 = time.perf_counter()
|
||||
first = get("/api/plot", metric="spectrogram")
|
||||
t_cold = time.perf_counter() - t0
|
||||
t0 = time.perf_counter()
|
||||
second = get("/api/plot", metric="spectrogram")
|
||||
t_warm = time.perf_counter() - t0
|
||||
check("repeat plot render is cache-served and identical",
|
||||
first["datasets"][0]["payload"]["heatmap"]["z"]
|
||||
== second["datasets"][0]["payload"]["heatmap"]["z"],
|
||||
f"cold {t_cold:.2f}s vs warm {t_warm:.2f}s")
|
||||
|
||||
# ---- overlay ----
|
||||
all_ids = [r["file"]["file_id"] for r in uploads.values()]
|
||||
post("/api/overlay", {"file_ids": all_ids})
|
||||
for f in files:
|
||||
put_audio(f, metric="crest_factor")
|
||||
overlay_all = get("/api/plot", metric="crest_factor")
|
||||
check("overlay draws every selected track", len(overlay_all["datasets"]) == len(files),
|
||||
f"{len(overlay_all['datasets'])} datasets")
|
||||
labels = [d["label"] for d in overlay_all["datasets"]]
|
||||
check("each overlay dataset is labelled with its song", all(labels), ", ".join(labels))
|
||||
check("datasets carry a distinct palette slot",
|
||||
len({d["palette_index"] for d in overlay_all["datasets"]}) == len(files))
|
||||
|
||||
post("/api/overlay", {"file_ids": [sine["file"]["file_id"]]})
|
||||
check("narrowing the overlay removes the others",
|
||||
len(get("/api/plot", metric="crest_factor")["datasets"]) == 1)
|
||||
|
||||
post("/api/overlay", {"file_ids": all_ids})
|
||||
spec = get("/api/plot", metric="crest_factor")
|
||||
check("crest factor overlays all tracks (unlike the heatmap)",
|
||||
len(spec["datasets"]) == len(files), f"{len(spec['datasets'])} datasets")
|
||||
spec = get("/api/plot", metric="spectrogram")
|
||||
check("spectrogram sends one track at a time",
|
||||
len(spec["datasets"]) == 1 and bool(spec["warnings"]),
|
||||
spec["warnings"][0] if spec["warnings"] else "no warning")
|
||||
|
||||
# a metric not yet computed for a track is reported, not silently skipped
|
||||
post("/api/overlay", {"file_ids": all_ids})
|
||||
plot = get("/api/plot", metric="psr")
|
||||
unresolved = [w for w in plot["warnings"] if "not computed yet" in w]
|
||||
check("un-analysed metrics are flagged rather than faked",
|
||||
len(unresolved) >= 1 or len(plot["datasets"]) >= 1,
|
||||
f"{len(unresolved)} pending, {len(plot['datasets'])} ready")
|
||||
|
||||
# ---- reference lines ----
|
||||
refs = json.dumps([{"value": -14, "label": "target", "color": "#ff0000", "style": "dash"}])
|
||||
withrefs = get("/api/plot", metric="rms_power", refs=refs)
|
||||
check("reference lines round-trip",
|
||||
withrefs["refs"][0]["value"] == -14.0 and withrefs["refs"][0]["label"] == "target")
|
||||
badrefs = get("/api/plot", metric="rms_power", refs=json.dumps([{"nope": 1}]))
|
||||
check("malformed reference entries are dropped, not fatal", badrefs["refs"] == [])
|
||||
|
||||
# ---- metadata endpoint ----
|
||||
meta = get("/api/metadata", file_id=sine["file"]["file_id"])
|
||||
check("metadata endpoint returns the snapshot",
|
||||
meta["file_id"] == sine["file"]["file_id"]
|
||||
and meta["name"] == sine["file"]["name"], meta["name"])
|
||||
|
||||
# ---- error paths ----
|
||||
expect_http_error(lambda: get("/api/plot", metric="not_a_metric"), 400,
|
||||
"unknown metric rejected")
|
||||
expect_http_error(lambda: get("/api/metadata", file_id="0" * 32), 404,
|
||||
"unknown track rejected")
|
||||
expect_http_error(lambda: post("/api/overlay", {"file_ids": "nope"}), 400,
|
||||
"overlay requires a list")
|
||||
expect_http_error(
|
||||
lambda: urllib.request.urlopen(urllib.request.Request(
|
||||
f"{BASE}/api/analyse?name=evil.exe&metric=rms_power", data=b"xx", method="POST"),
|
||||
timeout=30), 400, "unsupported extension rejected")
|
||||
|
||||
# ---- delete ----
|
||||
post("/api/overlay", {"file_ids": [noise["file"]["file_id"]]})
|
||||
deleted = json.loads(urllib.request.urlopen(urllib.request.Request(
|
||||
f"{BASE}/api/file?" + urllib.parse.urlencode({"file_id": noise["file"]["file_id"]}),
|
||||
method="DELETE"), timeout=60).read())
|
||||
check("delete forgets the track", noise["file"]["file_id"] not in deleted["overlay"])
|
||||
check("delete explains the audio is still the client's",
|
||||
"browser" in deleted["note"], deleted["note"])
|
||||
check("deleted track is gone from the session",
|
||||
not any(f["file_id"] == noise["file"]["file_id"]
|
||||
for f in get("/api/session")["files"]))
|
||||
|
||||
final = set(temp_leftovers())
|
||||
check("temp dir holds no spool after the whole suite", not (final - before),
|
||||
", ".join(sorted(final - before)) or "clean")
|
||||
|
||||
print(f"\n{len(PASS)} passed, {len(FAIL)} failed")
|
||||
for f in FAIL:
|
||||
print(" FAILED:", f)
|
||||
return 1 if FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Packaging shim — not an importable Python module.
|
||||
|
||||
`static/` holds the web UI's browser assets (HTML, JS, CSS, vendored uPlot).
|
||||
It is declared as a setuptools *package* purely so `[tool.setuptools.package-data]`
|
||||
has a package to attach the assets to. Without this file the directory is not a
|
||||
package, `package-data` matches nothing, and the built wheel ships without any
|
||||
assets at all — leaving an installed `ujm-web` serving 404s for every file.
|
||||
|
||||
Nothing imports it. `webapp.py` reads the directory from disk via
|
||||
`Path(__file__).parent / "static"`, so the empty module has no runtime role.
|
||||
"""
|
||||
-1157
File diff suppressed because it is too large
Load Diff
@@ -1,187 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Browser-side audio store.
|
||||
//
|
||||
// Uploaded audio never lives on the server. The File handle from the picker (or
|
||||
// drop) is copied into Origin Private File System — a sandboxed, per-origin
|
||||
// filesystem the browser owns, which no other site can read and which the OS
|
||||
// treats as browser cache, not user data. Everything downstream is driven from
|
||||
// there:
|
||||
//
|
||||
// * playback reads a blob URL minted from OPFS, so scrubbing a 60 MB WAV
|
||||
// never re-reads the original File
|
||||
// * the SHA-256 that identifies a track is computed over OPFS bytes, so the
|
||||
// id is stable across a reload (the File handle doesn't survive one)
|
||||
// * re-analysis after a page reload re-streams from OPFS instead of asking the
|
||||
// user to pick the file again
|
||||
//
|
||||
// The server only ever sees the bytes it needs for one request, spooled to a
|
||||
// temp file and deleted before the response is written.
|
||||
|
||||
const OPFS_DIR = 'ujm-audio';
|
||||
|
||||
/** True when the browser can give us a durable private store. */
|
||||
export function opfsSupported() {
|
||||
return !!(navigator.storage && navigator.storage.getDirectory);
|
||||
}
|
||||
|
||||
async function dir() {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
return root.getDirectoryHandle(OPFS_DIR, { create: true });
|
||||
}
|
||||
|
||||
/** Human-readable reason the store is unusable, or null when it's fine. */
|
||||
export async function storageProblem(estimateBytes) {
|
||||
if (!opfsSupported()) return 'this browser has no OPFS support';
|
||||
try {
|
||||
const est = await navigator.storage.estimate();
|
||||
if (est.quota && estimateBytes && estimateBytes > est.quota * 0.9) {
|
||||
return `need ~${mb(estimateBytes)} but only ${mb(est.quota - (est.usage || 0))} of browser storage is free`;
|
||||
}
|
||||
// Ask to be exempt from eviction: without this the browser may clear the
|
||||
// store under pressure, and the file is the only copy we have.
|
||||
if (navigator.storage.persist) await navigator.storage.persist();
|
||||
} catch {
|
||||
// estimate() is optional and can throw in private windows; not fatal.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mb(bytes) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a File into OPFS under its content hash.
|
||||
*
|
||||
* Returns {fileId, size}. Uses `createWritable` in a single streamed pass so a
|
||||
* large file is never held in memory in full.
|
||||
*/
|
||||
export async function put(file, onProgress) {
|
||||
const d = await dir();
|
||||
const tmpName = `.incoming-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const handle = await d.getFileHandle(tmpName, { create: true });
|
||||
|
||||
let written = 0;
|
||||
const writable = await handle.createWritable();
|
||||
try {
|
||||
const reader = file.stream().getReader();
|
||||
for (;;) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
await writable.write(value);
|
||||
written += value.byteLength;
|
||||
if (onProgress) onProgress(written, file.size);
|
||||
}
|
||||
await writable.close();
|
||||
} catch (err) {
|
||||
await writable.abort().catch(() => {});
|
||||
await d.removeEntry(tmpName).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const hash = await sha256(handle);
|
||||
// Content-addressed: the same audio dropped twice costs one copy, and the id
|
||||
// the server keys its analysis on is reproducible.
|
||||
try {
|
||||
await d.removeEntry(hash);
|
||||
} catch {
|
||||
// first time we've seen these bytes
|
||||
}
|
||||
const named = await rename(d, tmpName, hash);
|
||||
const blob = await named.getFile();
|
||||
return {fileId: hash, size: blob.size, sha256: hash};
|
||||
}
|
||||
|
||||
async function rename(d, from, to) {
|
||||
const fromHandle = await d.getFileHandle(from);
|
||||
const src = await fromHandle.getFile();
|
||||
const toHandle = await d.getFileHandle(to, {create: true});
|
||||
const w = await toHandle.createWritable();
|
||||
await src.stream().pipeTo(w);
|
||||
await d.removeEntry(from);
|
||||
return toHandle;
|
||||
}
|
||||
|
||||
/** SHA-256 of the stored file, read in chunks so memory stays flat. */
|
||||
async function sha256(handle) {
|
||||
const blob = await handle.getFile();
|
||||
const digest = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer());
|
||||
return [...new Uint8Array(digest)]
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
/** The stored File for an id, or null when it isn't here. */
|
||||
export async function get(fileId) {
|
||||
if (!opfsSupported()) return null;
|
||||
try {
|
||||
const d = await dir();
|
||||
const handle = await d.getFileHandle(fileId);
|
||||
return await handle.getFile();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function has(fileId) {
|
||||
return (await get(fileId)) !== null;
|
||||
}
|
||||
|
||||
export async function remove(fileId) {
|
||||
if (!opfsSupported()) return;
|
||||
try {
|
||||
const d = await dir();
|
||||
await d.removeEntry(fileId);
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
/** Ids currently held, newest touched last — used to prune orphans. */
|
||||
export async function list() {
|
||||
if (!opfsSupported()) return [];
|
||||
const out = [];
|
||||
const d = await dir();
|
||||
for await (const [name] of d.entries()) {
|
||||
if (!name.startsWith('.incoming-')) out.push(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop anything `.incoming-*`, i.e. an upload that died mid-write. */
|
||||
export async function sweepIncomplete() {
|
||||
if (!opfsSupported()) return 0;
|
||||
const d = await dir();
|
||||
let n = 0;
|
||||
for await (const [name] of d.entries()) {
|
||||
if (name.startsWith('.incoming-')) {
|
||||
await d.removeEntry(name).catch(() => {});
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send stored audio to an endpoint as a raw body.
|
||||
*
|
||||
* The OPFS File is passed to fetch as-is: the browser reads a Blob body lazily
|
||||
* from its backing store, so a 200 MB master is never materialised in memory.
|
||||
* (A ReadableStream body with duplex: 'half' was used before, but Chrome only
|
||||
* allows streaming uploads over HTTP/2+, so any file past the old 8 MB cutoff
|
||||
* failed with "Failed to fetch" on HTTP/1.1 — e.g. http://localhost.)
|
||||
*/
|
||||
export async function send(fileId, url, {method = 'POST', onProgress} = {}) {
|
||||
const blob = await get(fileId);
|
||||
if (!blob) throw new Error('audio is no longer in browser storage — re-add the file');
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {'Content-Type': 'application/octet-stream'},
|
||||
body: blob,
|
||||
});
|
||||
if (onProgress) onProgress(blob.size, blob.size);
|
||||
return res;
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
@@ -7,38 +7,6 @@ resolution-markers = [
|
||||
"python_full_version < '3.11'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "audioop-lts"
|
||||
version = "0.2.2"
|
||||
@@ -304,15 +272,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -330,7 +289,7 @@ resolution-markers = [
|
||||
"python_full_version < '3.11'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" }
|
||||
wheels = [
|
||||
@@ -401,7 +360,7 @@ resolution-markers = [
|
||||
"python_full_version >= '3.11' and python_full_version < '3.13'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
|
||||
wheels = [
|
||||
@@ -496,34 +455,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.141.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fonttools"
|
||||
version = "4.63.0"
|
||||
@@ -581,65 +512,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826", size = 208805, upload-time = "2026-05-25T22:16:50.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f7/b4d41eaae2869d31356bc4bbf546f44fae83ff298af0a043ca0625b06773/httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77", size = 113527, upload-time = "2026-05-25T22:16:51.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/e4/77487e14fc7be47180fd0eb4267c7486d0cc59b74031839a3daf8650136b/httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4", size = 450035, upload-time = "2026-05-25T22:16:53.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/72/5a8f787e323f56fbd86c32a4be92a86776e4cfe8b4317db999f452028362/httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb", size = 451101, upload-time = "2026-05-25T22:16:54.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/41/b44a25560955197674b6744cb903664300e239235a5eaa69df0890d87054/httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813", size = 436140, upload-time = "2026-05-25T22:16:56.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/b0/054aac84c03d7e097bf4c605fb7e74eec3d65c0276adf64ee97f3a103ff5/httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba", size = 437041, upload-time = "2026-05-25T22:16:57.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/e8/86b85bbc0ac7892232f1a99ab96a9aa71936984fa06adfc0afc83ca7789e/httptools-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557", size = 90454, upload-time = "2026-05-25T22:16:58.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.16"
|
||||
@@ -1324,137 +1196,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyloudnorm"
|
||||
version = "0.2.0"
|
||||
@@ -1565,88 +1306,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.34.2"
|
||||
@@ -1670,10 +1329,10 @@ resolution-markers = [
|
||||
"python_full_version < '3.11'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "joblib" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "threadpoolctl" },
|
||||
{ name = "joblib", 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 = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "threadpoolctl", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" }
|
||||
wheels = [
|
||||
@@ -1718,10 +1377,10 @@ resolution-markers = [
|
||||
"python_full_version >= '3.11' and python_full_version < '3.13'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "joblib" },
|
||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "threadpoolctl" },
|
||||
{ name = "joblib", 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 = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "threadpoolctl", marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
|
||||
wheels = [
|
||||
@@ -1771,7 +1430,7 @@ resolution-markers = [
|
||||
"python_full_version < '3.11'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
|
||||
wheels = [
|
||||
@@ -1831,7 +1490,7 @@ resolution-markers = [
|
||||
"python_full_version >= '3.11' and python_full_version < '3.13'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
||||
wheels = [
|
||||
@@ -1963,8 +1622,8 @@ name = "standard-aifc"
|
||||
version = "3.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "audioop-lts" },
|
||||
{ name = "standard-chunk" },
|
||||
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "standard-chunk", marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" }
|
||||
wheels = [
|
||||
@@ -1985,26 +1644,13 @@ name = "standard-sunau"
|
||||
version = "3.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "audioop-lts" },
|
||||
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/e3/ce8d38cb2d70e05ffeddc28bb09bad77cfef979eb0a299c9117f7ed4e6a9/standard_sunau-3.13.0.tar.gz", hash = "sha256:b319a1ac95a09a2378a8442f403c66f4fd4b36616d6df6ae82b8e536ee790908", size = 9368, upload-time = "2024-10-30T16:01:41.626Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ae/e3707f6c1bc6f7aa0df600ba8075bfb8a19252140cd595335be60e25f9ee/standard_sunau-3.13.0-py3-none-any.whl", hash = "sha256:53af624a9529c41062f4c2fd33837f297f3baa196b0cfceffea6555654602622", size = 7364, upload-time = "2024-10-30T16:01:28.003Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "threadpoolctl"
|
||||
version = "3.6.0"
|
||||
@@ -2023,18 +1669,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uj-mastering-master"
|
||||
version = "0.1.0"
|
||||
@@ -2054,15 +1688,9 @@ desktop = [
|
||||
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pyqtgraph" },
|
||||
]
|
||||
web = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", marker = "extra == 'web'", specifier = ">=0.110" },
|
||||
{ name = "librosa" },
|
||||
{ name = "matplotlib" },
|
||||
{ name = "mutagen" },
|
||||
@@ -2071,10 +1699,8 @@ requires-dist = [
|
||||
{ name = "pyqt5", marker = "extra == 'desktop'", specifier = ">=5.15.10" },
|
||||
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32' and extra == 'desktop'", specifier = "==5.15.2" },
|
||||
{ name = "pyqtgraph", marker = "extra == 'desktop'", specifier = ">=0.14.0" },
|
||||
{ name = "python-multipart", marker = "extra == 'web'", specifier = ">=0.0.9" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.27" },
|
||||
]
|
||||
provides-extras = ["desktop", "web"]
|
||||
provides-extras = ["desktop"]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
@@ -2084,467 +1710,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.52.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.11'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/e7/d1671fb984f9dd844e1da5288070c7c23c9eaba3082d3871aae19c3ab8b9/websockets-16.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d", size = 179570, upload-time = "2026-07-17T22:48:24.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/f5/70df723bf571f5e0b1b845e0a4ff1c966eeb84f667599fc251caa37d15a3/websockets-16.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731", size = 177252, upload-time = "2026-07-17T22:48:25.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/72/2f14b2e167170b8bf1c8bb7f9b0d78000f470d41a2085a91f33e3917b6c9/websockets-16.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4", size = 177530, upload-time = "2026-07-17T22:48:27.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/18/a17e2f0cde02dc10154c808deed7e1d8528afff93612f70d3f0a5b19b011/websockets-16.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb", size = 186038, upload-time = "2026-07-17T22:48:28.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/b0/41de283899cf5929d637b72a508cdbc9aa40dc0f317c6b77613fd1000488/websockets-16.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838", size = 187278, upload-time = "2026-07-17T22:48:30.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/61/874aab5257e027f9f61b5004cec65e592babca7942b1bc09f38e72b7f1fd/websockets-16.1.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87", size = 189936, upload-time = "2026-07-17T22:48:31.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/1a/42173913ac5519607220849ed417c864d77384e4119f06dbba964a50f096/websockets-16.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3", size = 187796, upload-time = "2026-07-17T22:48:33.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/f4/37c1840bd89b529479aec41470b97b7c683b107ca90b6399ac5afb99dedf/websockets-16.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4", size = 186481, upload-time = "2026-07-17T22:48:34.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/70/652d9b964adcfbeb056f42e0ca6bece34d108fe75534e74df20643cae199/websockets-16.1.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3", size = 184351, upload-time = "2026-07-17T22:48:36.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/f1/af3850e5d48d482921985be72ebcb169c6180b3a77b57bd612deebcee23b/websockets-16.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b", size = 186791, upload-time = "2026-07-17T22:48:37.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/40/1a4e3ed4969ec378dcad337e5f1472c5e292cb3e733bc392f0dc2e230abd/websockets-16.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d", size = 185413, upload-time = "2026-07-17T22:48:39.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/3e/4e3fa1afe8f1a6a780434cd9ba8eb422632b044eff3dd73f6af67523c147/websockets-16.1.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d", size = 187178, upload-time = "2026-07-17T22:48:40.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/ab/dd742766aa5dda7f349be0de49e4d565b84cf6f7f7fa02e07692f0f2bdd9/websockets-16.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165", size = 185051, upload-time = "2026-07-17T22:48:42.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/f5/76438c6560f416f1c0a7f587679fb97cc6e99ed336011d43ce2002dd27c1/websockets-16.1.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc", size = 185846, upload-time = "2026-07-17T22:48:43.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/12/5c0320f2127823d27b2d56d611d31b0b284ad4edcb41364d66bf4c92b537/websockets-16.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a", size = 186066, upload-time = "2026-07-17T22:48:44.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/97/875986b857b955c3f9dd192cb8a1af81254dfb2ea22cc9590f0a1e020b8b/websockets-16.1.1-cp310-cp310-win32.whl", hash = "sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9", size = 179940, upload-time = "2026-07-17T22:48:46.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/82/1013a5fe7ddae8e102bc3b4b39db81d8d28fd02100a324ce6ede8cd832b1/websockets-16.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f", size = 180239, upload-time = "2026-07-17T22:48:48.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.13'",
|
||||
"python_full_version >= '3.11' and python_full_version < '3.13'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/ad/66a74d42fb537bd44056483eae6cbb7ebb10b742c300a0bf8cee427556d4/websockets-17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:88b882764ef65147a7a5ae13168dedbe225a04e2ff4858fe543f2c402f093e9c", size = 216984, upload-time = "2026-08-26T14:55:20.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/1b/344ab22cea729e872f759b926441f7b822ab6cd106db527736afc066927f/websockets-17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:98a5b2589a56a4b4f098b0a958099a4356ab904a7844f1da3841efca469af7e9", size = 214667, upload-time = "2026-08-26T14:55:22.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/42/bace574b6ae80e1a8d6935b8c5f03fb67236233ec572e976fe826ff719cf/websockets-17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:020e271205f8ab3406d7a59cd00de6dec722315924411c421bd00642f18bad86", size = 214944, upload-time = "2026-08-26T14:55:23.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/87/08e35ca4a0ffafb500a16ff461bf9561ad2b755362adb5d077d4dba9affc/websockets-17.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:65be6bda2b537fefa4b3a5ccd6ab386533ce39dd8fe62433ec90901fdc81752d", size = 224004, upload-time = "2026-08-26T14:55:24.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/9a/00ae2e147eaa086fe8bdddd36f57216ce72b9a9dfc0b17c717005ebdacaf/websockets-17.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c84bdef916556cbe1d5a43b423398be4dd3cba6522b463e53d848578b920695", size = 224278, upload-time = "2026-08-26T14:55:26.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e2/7aeb4e00defa68826f449392922a382ce7fdf542fe52190558dc1714e284/websockets-17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47a62d6045c6eaa0d8f97bc2fb68b8cf90077a0cbfd4e83d6f2d2145611ee134", size = 225511, upload-time = "2026-08-26T14:55:27.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e4/655be3d93c3edbe1a51606073b5454ea6b1b32d87aa26253a6df952417b7/websockets-17.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34879e19bb0a3c44f9317679435aea5327fac993933a704cbf353bf1234b10c7", size = 228802, upload-time = "2026-08-26T14:55:28.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/33/98549a2afa9d68fe1b5a8e0a61cd461a43ea1ab7209bce675eea67c79190/websockets-17.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2d72879819f5145a342d0030c418702496c65a4b913ef81f5ae944dd91dd50f6", size = 226075, upload-time = "2026-08-26T14:55:29.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/6b/cbc27e014d6c292b9b2709cfd32781a2b61eb30cd4c9130e7c57e41a204a/websockets-17.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f25e099fdfe3b09f953d84698f729a1f7d1e99101b2787d7a28ed77b323750", size = 224846, upload-time = "2026-08-26T14:55:30.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/a6/e57925f7a423d90f24559e85bac21a7f0b44c0cf4a5c0babc0759ca54bab/websockets-17.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:469355ab1af100b9380f1afb09985019f4a4b94fa1dd0e9396db4361626d7ab8", size = 222136, upload-time = "2026-08-26T14:55:32.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/07/b9de0400addb542ba7c819022abc3afa46cd7e518068881bceadac69d995/websockets-17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00679b7468b4c2b12b0757118174e8eabac56bb2f579a928a104d9554a56e098", size = 225000, upload-time = "2026-08-26T14:55:33.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/bb/af2828a1d7f2beb792af6ba56d7b02d56262070266b95d2af9ef391fbfb0/websockets-17.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a9fe648abd1d9b89aebfa30407bfdd08a0271ec5dc7d44a4c6ccd1ce22cf562a", size = 223592, upload-time = "2026-08-26T14:55:34.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/51/7379f254730c1dc7d8e4dd8d686868d8f7be55bdc94c6d3a44538840f639/websockets-17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f47aafd92aa28b941180e6da8a42b0f711851b14b81a5b6bb28dbbb1fa35152c", size = 224360, upload-time = "2026-08-26T14:55:35.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/c6/fcd91320dd71dda7046df9bc60f60c70ee15c052dee21e31ed6221dc8b5d/websockets-17.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c89406fa3dcd4aa8662c6406cc5c0de1790e9614d2c3aaf03ca53a8a8ccf3405", size = 225404, upload-time = "2026-08-26T14:55:36.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/b2/655a4f939388079f80f1b3f8a1b9d40783e70a376e7423988cc9a590a09f/websockets-17.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b3b451fd2723ad3191a209afe6f3f4bc86c83e9a85bdc255353b91803ee6aa66", size = 222982, upload-time = "2026-08-26T14:55:38.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/75/37c84c4371c6aa668910d7841036c4397ea10554c07030603ccd5e44b02a/websockets-17.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:054c28db2dcec0e857e3b705d8c28012613e555b38c765d6a4f75340a4fc06a0", size = 224017, upload-time = "2026-08-26T14:55:39.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/20/8a9a94323bfcfe03bde3f9d98926bea4855856359702fe3ca0d07051ef5d/websockets-17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f8e822efd54137d8cc8310eb64635ab827a4a6c72ff08691f38aa624776d8ecb", size = 224252, upload-time = "2026-08-26T14:55:40.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/dd/aa66e6500188cd40306abeb92c9a738ca6dd7029d8d8532c538055ab5daf/websockets-17.1-cp311-cp311-win32.whl", hash = "sha256:dcb8d5f7edef7a399d322cf28d4c4e6f98dab64d301c8f50581a1080e5198142", size = 217484, upload-time = "2026-08-26T14:55:41.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a2/cdf3b551f0b9177023afd3a45d3b431a0d4064951008c4321a8b42ac2288/websockets-17.1-cp311-cp311-win_amd64.whl", hash = "sha256:b1bc819c6db90e8f91a38250a1ab4c058261871aa52d2fe36382eddedf146dee", size = 217779, upload-time = "2026-08-26T14:55:42.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/13/51253dbed7d16a4bb87b05110ad3bf12165f410e915f6da1edd4186d8dc1/websockets-17.1-cp311-cp311-win_arm64.whl", hash = "sha256:edadce7a22052056fd4384543019856b34850363c9d387929f677ae01d79709c", size = 217710, upload-time = "2026-08-26T14:55:44.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload-time = "2026-08-26T14:55:47.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload-time = "2026-08-26T14:55:49.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload-time = "2026-08-26T14:55:50.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload-time = "2026-08-26T14:55:51.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload-time = "2026-08-26T14:55:52.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload-time = "2026-08-26T14:55:54.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload-time = "2026-08-26T14:55:55.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload-time = "2026-08-26T14:55:57.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload-time = "2026-08-26T14:55:58.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload-time = "2026-08-26T14:55:59.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload-time = "2026-08-26T14:56:00.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload-time = "2026-08-26T14:56:02.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload-time = "2026-08-26T14:56:03.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload-time = "2026-08-26T14:56:04.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload-time = "2026-08-26T14:56:05.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload-time = "2026-08-26T14:56:06.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload-time = "2026-08-26T14:56:08.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload-time = "2026-08-26T14:56:09.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/31/5f6450a7879f4f063ef08897cc385ea3ce3f1fe17f08b11e3fd959abdf27/websockets-17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e", size = 217006, upload-time = "2026-08-26T14:56:10.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/2a/c1b006fc861695d2aa4e35327b842015ce1d98cf8f99241829b3d6460bfc/websockets-17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc", size = 214690, upload-time = "2026-08-26T14:56:11.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/69/66e5b7d01445e0eeb1d4ab419c30315f2c90cf7a8a8cd4ecc47f894dba54/websockets-17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80", size = 214947, upload-time = "2026-08-26T14:56:12.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/ce/033cafe2d2538562efa876b9149a2c7a0f7787870a4b1bb6e28adc9ceb6b/websockets-17.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce", size = 224329, upload-time = "2026-08-26T14:56:14.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/c7/e1c2e8a67f6cc0aa43abe0046fb3b7a020980649e6a843751dc7ce9eb170/websockets-17.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51", size = 224611, upload-time = "2026-08-26T14:56:15.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/de/07c6d48eb3d2069709410c851e7de10ab83d752c4bd09862899627c2729b/websockets-17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c", size = 225848, upload-time = "2026-08-26T14:56:16.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/dd/3c68572d20509648cc2fb6f50ccf3deeb4b87270f2c8966e99476e278ea3/websockets-17.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3", size = 227290, upload-time = "2026-08-26T14:56:18.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4a/8f6651c8a22093539c9215af0c5bbf217b87b382c99d2112039b92d593c2/websockets-17.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435", size = 226476, upload-time = "2026-08-26T14:56:19.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/be/f6fc33cea86b1127fd1297b18c107e81580ab55a73a39f9a934441ef321f/websockets-17.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906", size = 225233, upload-time = "2026-08-26T14:56:20.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/83/65edaf05f7c9b1dea82f4d252fdc37706a84571646f06119a27b0a16fe19/websockets-17.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c", size = 222488, upload-time = "2026-08-26T14:56:22.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/42/d1169c2f7f1f0032b0d4b0c00f0711a070cd7c735de37bfeb876bc0f9606/websockets-17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780", size = 225295, upload-time = "2026-08-26T14:56:23.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/f4/64e2a386c3899b917c2933225c9b47887874229d159797f3bf1a11c20d51/websockets-17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c", size = 223891, upload-time = "2026-08-26T14:56:24.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/b3/dfb5c482f7e310a3432fdbb045ddfe6d34114680e89a233d4ff900a32961/websockets-17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad", size = 224661, upload-time = "2026-08-26T14:56:26.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/cf/94865130a336029f46412adc127c4fbe380f46172b90ce251369e35c4302/websockets-17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c", size = 225766, upload-time = "2026-08-26T14:56:27.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/34/eb8c658f86dfe562ed49a887a27424bfe9e618c26ea6f865b093d075d3a6/websockets-17.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a", size = 223323, upload-time = "2026-08-26T14:56:28.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/7e/2629609652ece5ca0c7ac235927dd4511b08131e3a5d53439b798fddf002/websockets-17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0", size = 224276, upload-time = "2026-08-26T14:56:29.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/6b/8525737fe840b38e5f40956c198fb586a4fac1e07144d41a5b949b989cf8/websockets-17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d", size = 224558, upload-time = "2026-08-26T14:56:31.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/ab/3a958c6cbcf74b118f601c20a80ac8bd5e8dfec0bcf7345116feaeefb121/websockets-17.1-cp313-cp313-win32.whl", hash = "sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608", size = 217475, upload-time = "2026-08-26T14:56:32.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/36/fb521f0f2994c25509651f169efe5582dddd8713d57a0757ba87859372ef/websockets-17.1-cp313-cp313-win_amd64.whl", hash = "sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f", size = 217784, upload-time = "2026-08-26T14:56:33.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/92/9b8419584681a12a7534b746dfb2737c466efe2455483e2fbf8b941a04ec/websockets-17.1-cp313-cp313-win_arm64.whl", hash = "sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2", size = 217715, upload-time = "2026-08-26T14:56:34.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/0d/500cf5daea09d4669dff3a7d67159094a0bd6c4ef130381404f6edd3eb5f/websockets-17.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e", size = 217048, upload-time = "2026-08-26T14:56:36.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/12/5b12c6168aa269cffbfd24d177cd492b130120403a418c7e89462e27b4ac/websockets-17.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:57b39dc8541cf7ed3f639da82bf7451060483967f9e733da1f8173e4095f0642", size = 214737, upload-time = "2026-08-26T14:56:37.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/36/e453e5106e4e2416f008ac222837c2f1637a063b08008afcd1088889b631/websockets-17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6", size = 214955, upload-time = "2026-08-26T14:56:38.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/30/0204bb86176db02cdfc678ce65ed808a66fab87d250ce61a8790800a60b0/websockets-17.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9fc873e239c5abeb150bc24dbd1a7af23a9254526383ce0a077f5e20adbeb19", size = 224331, upload-time = "2026-08-26T14:56:39.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/c8/d8372256e00c4e3cab1115c45075d1eeedb642a3f2b42bd70c4deae03f06/websockets-17.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b", size = 224685, upload-time = "2026-08-26T14:56:41.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/7d/650355b8f67f908ff99603351d4458d1a0b787d627950a47c38db7e25308/websockets-17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce", size = 225927, upload-time = "2026-08-26T14:56:42.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/6c/a9ffa5b903579eed76017870f055d75ecc73988d9d0c9b65a92ba0bf2a27/websockets-17.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d334d11398086bb5559606cb42d51c013ea7c061c7db701521392373d3c087f5", size = 227300, upload-time = "2026-08-26T14:56:43.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/5d/4551c2269066af7481ee44605a0813770961615b5b5da3e87a8f5cb859ea/websockets-17.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c27336b1a0ac56569493e858497870347854372395f50483725f8cdacc5a45c", size = 226533, upload-time = "2026-08-26T14:56:44.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/43/237a99233e5c445759a613831b3a92e91905afc064dc3bd0ad33c35fd1e2/websockets-17.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555", size = 225280, upload-time = "2026-08-26T14:56:45.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/b5/e9407a91613d1d1cd932414143a1012096b26674a782fc55a0bd23217ee4/websockets-17.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f", size = 222540, upload-time = "2026-08-26T14:56:47.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/d2/db76628db0577b783205d9779f64d8e373416b04c62d1546be4b75dc8540/websockets-17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a", size = 225354, upload-time = "2026-08-26T14:56:48.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/4c/2174181c067b89a74ae18e2650c2ac29959f4b796afe876ab3f4d30d642c/websockets-17.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78", size = 223867, upload-time = "2026-08-26T14:56:49.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/75/274decb9a8253561b5be3261e02a6676fc8ecdf31e95b722e53d5bfb8fd2/websockets-17.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6", size = 224652, upload-time = "2026-08-26T14:56:50.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/e6/49824f1fb4db7656d2f7492b1d8be16147b759d909490e32f4776843ee64/websockets-17.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:406b8107943a43ef4649b1e0cb0cdc052bbf08fe1c8905a623c4af9586e5cebb", size = 225822, upload-time = "2026-08-26T14:56:52.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/6a/5dc43838c0b02a95f42c47a0de33c5ddd7767a9feeb4d0d8777ac1cfefe4/websockets-17.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d", size = 223379, upload-time = "2026-08-26T14:56:53.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/62/585637cf06d6b321232f79c55dc14d65518d12cf87c94c44f5864068810e/websockets-17.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81", size = 224330, upload-time = "2026-08-26T14:56:55.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/68/c3b234a6a1366b6ab5bbfaa4434a1b946e1dc4e8ddd6824bfd93a8835b7f/websockets-17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5d4724255fb8398acd9e583b97eb2279cec20e0bd0f9a94bf75f6056ef9f13da", size = 224622, upload-time = "2026-08-26T14:56:56.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/d4/84cf3d1376f5d8207f55f43c1c818babd6b89447f5dcd01f18a6d5526796/websockets-17.1-cp314-cp314-win32.whl", hash = "sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a", size = 217036, upload-time = "2026-08-26T14:56:57.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/0f/9e7ac63c5d7cb642952200814f584318e65146df008b7d375d5d9c6b2c97/websockets-17.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5", size = 217382, upload-time = "2026-08-26T14:56:59.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/bb/1ae6b91f7f3ac05f5c9f14a72dc2181c115ff370bcd8a7f10f02c174adfd/websockets-17.1-cp314-cp314-win_arm64.whl", hash = "sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82", size = 217268, upload-time = "2026-08-26T14:57:00.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/f0/f65644d0e0b2b90918a8c41503841cc4072a58f2bf76c09bc36e751fc0dd/websockets-17.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d411ea5ca18ac1b12c0c94be88b60c18ca641ac43bcdfdf1c9f79d46cdbe1603", size = 217379, upload-time = "2026-08-26T14:57:02.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/35/4c46d1f620ac1a30f92b6eae78ee40a772a93f568647ca7ccdc5ea283cf8/websockets-17.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f", size = 214911, upload-time = "2026-08-26T14:57:03.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/6e/4587e8406d7c1188e97b9cf466c081e93399380d447f885bfce81626cd37/websockets-17.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de9acef07e3a78e9567fcd26c29011a4da8f050b13004bbf880a0fd82a6eea5", size = 215115, upload-time = "2026-08-26T14:57:04.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/06/1381c8fff525041025909eb80ace32489194a00ba22a0a8d428030afcc84/websockets-17.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2", size = 224696, upload-time = "2026-08-26T14:57:05.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/9d/9034e867dc85340be058619751742b895f722326e83100d110063461ca07/websockets-17.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b", size = 224975, upload-time = "2026-08-26T14:57:07.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/eb/ed03aa3cae748ebf6397e5d44028f433f746bad09dc568ff754fda3a3c9b/websockets-17.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a74531ce81af587f906ab42f194032388fcff8fc7938402e5917c9147a39441", size = 226151, upload-time = "2026-08-26T14:57:08.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/c9/cc1964a096d16f3b73cb1ee5f14f277f5a3bcac07c6e8f9a1dcded99f4c8/websockets-17.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db", size = 228292, upload-time = "2026-08-26T14:57:09.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/26/46da6dd0363c2db2e4876fd59a40fd40c1943a82d7018d0a33afbce47d52/websockets-17.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd", size = 226722, upload-time = "2026-08-26T14:57:11.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/98/ecd8f5e1c5d0e54c08ebc5c66852271112166db68107cb0e17ca1bf25009/websockets-17.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c", size = 225451, upload-time = "2026-08-26T14:57:12.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/4d/da8d2760db53e17aae763738b6ba834b1fcf16813d3632f3edb6951e1ec8/websockets-17.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256", size = 223003, upload-time = "2026-08-26T14:57:13.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/40/ea401c141a79c5b1d0021a0dab9d0df2051c108f1620fbb39a6e7c714c3b/websockets-17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257", size = 225704, upload-time = "2026-08-26T14:57:15.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/8e/07ab3f44215d89840d5385fdcaaab1fed8caeffa67c6899e15062957c12c/websockets-17.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d", size = 224192, upload-time = "2026-08-26T14:57:16.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/93/ccf1af0a23e5748d4e22292a377d78d15cf294d7e707bbb11a8990ae6bd5/websockets-17.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:db9d7ee47f3ba531e278be539af39e2c7c7d28fb94897b6cd1120d63b0ef5922", size = 225082, upload-time = "2026-08-26T14:57:17.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/db/e32200f99ce282e728d2929f2c429db353cf3282db7d0eba99eb32c9fec1/websockets-17.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ff3e2ba7a9f0a110b0555452e9b5a03a34e11662544e01beea15f144b48ba7b7", size = 226101, upload-time = "2026-08-26T14:57:18.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/3d/e7a6e9777b29433620167c98f3caaff0d6b08b1239a273ef7f7fd1393349/websockets-17.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6da17fc94bd270f5987b10bee113461ac36a36a98b0481ddcc98056e5a90001a", size = 223794, upload-time = "2026-08-26T14:57:20.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/05/ac569090726dedd6656f3ee28b0c02dfb1ba76e898dceaccc2987a237cef/websockets-17.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2", size = 224567, upload-time = "2026-08-26T14:57:21.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/50/4ef62941111db6b31193f4fabbb65f845a5177579040cb8fe0d774d25034/websockets-17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566", size = 224993, upload-time = "2026-08-26T14:57:22.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/42/2b95ada4ea19bf3a2072b68669ce4f4afb212690b727d31640576287fd68/websockets-17.1-cp314-cp314t-win32.whl", hash = "sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68", size = 217168, upload-time = "2026-08-26T14:57:24.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/0a/67d5ee08dd8060a37d612fd40a625b5376ad19ae48fe1c8ad428c278b817/websockets-17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51", size = 217508, upload-time = "2026-08-26T14:57:25.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/a3/822005d0c674451d2411027b878cdc128a2b7ea5a30d337d9e279da22eba/websockets-17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d1e2f5fa2b6d01f0d85b4f223fea7ed1d504be282a02a81bd2be4817ef7a2f03", size = 217425, upload-time = "2026-08-26T14:57:27.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/d5/99a6c6a1eb5d5ae9f45f59a3c97f4e3b21f310eb404a547fb3e7d2fc054c/websockets-17.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:88381602e379165b66244b2ebc29f9b23ea0851fbe63ae157f91ca324f072d6f", size = 216970, upload-time = "2026-08-26T14:57:28.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/0e/1e7f6e833728193958d3ed3d67b5d57c3c7cfa948abf94d4bc553257c954/websockets-17.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:88bc5138e53903a85c354e59df7ba73ce306f7b09724cef74dba121e60a88ce2", size = 214699, upload-time = "2026-08-26T14:57:29.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/00/95d39549f86e34425a0412bcbe61708dd1fc46af654e2134a6c4389102ad/websockets-17.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3546ef55b3a074494106508bc6505c73825970d2d9505f7bf53882b3e88b0d1e", size = 214927, upload-time = "2026-08-26T14:57:31.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/ff/b442415fc4f7f9943b0fc8e8eebaa13923ca73361e167c439ba634eecbd9/websockets-17.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9ae55d24241fc055f22aea3ac924559069848bd0ad4ea065fdd72d2194685fe8", size = 224373, upload-time = "2026-08-26T14:57:32.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/dd/b83537aae4cf61615b9d8b2dbb235c0030ba85457a6d934798273814600f/websockets-17.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7b349265fad6244013eecd99df8d83c12bf3013943e431f4fadd5bffc37db42", size = 224801, upload-time = "2026-08-26T14:57:34.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/83/5ab0abed58454909e8dbab45086ac68ee4556d7a8ada26735addc909b903/websockets-17.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc5789e5ea182b77a38881383ada5347202a6c66f4857d054e075290e80b604b", size = 225967, upload-time = "2026-08-26T14:57:35.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/26/e2412f2b998a8c1dfc00c0709ff6ee0c634dd0b0b4f92bdfe9667876b71c/websockets-17.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce13c7d233239e739600a57d4a73c1192ad8259e655a4d55aa1a454242bc809d", size = 227664, upload-time = "2026-08-26T14:57:36.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/25/0dd4495df3c0e02f6db705312ba85ab9b2dd42257dc23eb0da10066e4844/websockets-17.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1036189bd34b0bc1b10a4679321e2c7968af317efe6e8e4c1c5141c4254fb5bb", size = 226447, upload-time = "2026-08-26T14:57:37.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/67/6df3f63ffc48f08126ed0cd2fd2a41092967c3e364f8ec100deae90b6d77/websockets-17.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e78fd4b7b2c5086a38671c9c882c1e643385eccea360b5b1fda4a105e590087e", size = 225343, upload-time = "2026-08-26T14:57:39.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/8d/a8479bbb09ff054907d141123d8f52fb6ae5ac39c6dbe39e6a02a8408309/websockets-17.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46e7a10bf04318c7b0c0273791925ae5e1cbe4a11e34aa934d2ef27862058a80", size = 222748, upload-time = "2026-08-26T14:57:40.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/fb/4c3d2a3269cde3f3087916de9c3d9fc5d7196b46846d8c3a9ae59ad0a884/websockets-17.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:33e45c7ea38428e740a7f233555d71df0b875cef7fc080acebc9654475e35335", size = 225453, upload-time = "2026-08-26T14:57:41.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/1c/6467b401d19408f34e1c7389c222c2c7e1dfdf08c551190269b5eabc726c/websockets-17.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6e63c01803be425ff062b7f7fc201a74def1d49fc94a2410dd17375df75936e9", size = 224112, upload-time = "2026-08-26T14:57:43.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/5f/744e032ac80e11039a7447657ebabb46e9b5c2dbcec83be571335212932f/websockets-17.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:722ec21717eec6477bce582147a28acdfe034e604239466a6a95daedb863e774", size = 224646, upload-time = "2026-08-26T14:57:44.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/47/bcb9128d9afc4d0934d9192e2a24897ca2f7a63df2654904915349c6c46d/websockets-17.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:e74e41f0ad12ff1e8983e349daef79d37cc8280c743ce9d134d6c74c18dab5d6", size = 225797, upload-time = "2026-08-26T14:57:46.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/e0/b058047b7cf565e1105b10ef6b6b24a6ebe3575678c7dc75a645334705a7/websockets-17.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:12fe8984a32dbfd084e0603f1a8d740c0180cb85b3174585c54a80d2455a8394", size = 223605, upload-time = "2026-08-26T14:57:48.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/69/fc1555bff884de363f1bf9eebf2836dbeb29fa7e4f957debb7bbcf43abba/websockets-17.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:01dcb47deebc40b38fd4a493b9b9f4d0a704b7bec6f35e4d34085b329abce71a", size = 224508, upload-time = "2026-08-26T14:57:49.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/f9/648d4e68621688b19093b06f7b497d520952e68cdea1c1b54371fe9491de/websockets-17.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f4c45ee2512d3757b5e6c67c5a34e435143f2ecb7df3324f9fd888688c45c0f4", size = 224767, upload-time = "2026-08-26T14:57:50.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/93/f8342b55864f71df13eb8e9ef7dce691b87a87f04f75bb8a1385b3336e7c/websockets-17.1-cp315-cp315-win32.whl", hash = "sha256:0f4f50dfe2cc810fc4e2de979b35e83bf8bb4bccdc6fe472d93762ea7b1d5927", size = 217003, upload-time = "2026-08-26T14:57:52.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f0/7b5fdb774c245e0b6217009e2a24d2105c1a64923949f33be41aa7959302/websockets-17.1-cp315-cp315-win_amd64.whl", hash = "sha256:4af784f3e436f65b355c117c6497320f2b5cf6a559295cb1c4c7338e335d45cc", size = 217300, upload-time = "2026-08-26T14:57:53.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/33/1fe6ed1b5087516115ca451b2c240314b010647071f8fc3bd78a21e4dddb/websockets-17.1-cp315-cp315-win_arm64.whl", hash = "sha256:d58159af7835fde09c462394293c0d7aaf8fb4557d8f8e5699f5e722ccae013d", size = 217214, upload-time = "2026-08-26T14:57:54.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/ca/ed02e75996a266d76c5fcb5dd9b930db4cf2b388ca5fa3d2a72086f81568/websockets-17.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1a5cf4e7bbe3ca499e6a289206cb4fcb7444b09919e129bd517f57d5fa192c13", size = 217282, upload-time = "2026-08-26T14:57:56.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/7d/d536f5bc89ea5b52fd1c1727c59fabafee6bc41f5ce92c3bd2f83047908c/websockets-17.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:416b4bc8789a1865a3ff643ec4ee073a5f52402d0dbeafd27b1798d5dd6b6a51", size = 214863, upload-time = "2026-08-26T14:57:57.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/37/944cf17bad668e9be1247e6314f88a48b9faf7c250e383410db8b38af0b9/websockets-17.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:259f45358c76d3b18489e3e80636cdbe807e05ecf1b10fdf1a779106d23d0c8e", size = 215073, upload-time = "2026-08-26T14:57:58.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/bf/3267966cc1bbc2b8fa62fd329651b0af502df1f5d1c0eed027ff339d6aa8/websockets-17.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9d01e8ede41fea4f5a847dad9d628355f74905f437a5b6856d67aa66d193800", size = 225229, upload-time = "2026-08-26T14:58:00.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/d8/85ea722f483510abb39fc71aafb4465d17cf9051a275ab036874ff3c300c/websockets-17.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b35181a14cbfcae163b4de545d22abfd07d06c2c41ca69cfcd99251d6888ab", size = 225500, upload-time = "2026-08-26T14:58:01.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/ce/64c7d00005bd0d15ecb5c5fcb7fb2597b6b92ddd16c4fa6bbc3d2835ad63/websockets-17.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a8e768a048c2220697477ce2e67e4345dc9f693d0ee6af53945b5e30227c6a7", size = 226829, upload-time = "2026-08-26T14:58:03.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/dc/096c67940fb957e667ca3c542818150434eb0388c6fdc90b3a502f3c3e96/websockets-17.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:880069d21cc33a558dcf180924a546d1ecf8ada5be3e4e70acee87019d706a24", size = 228457, upload-time = "2026-08-26T14:58:04.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/fe/f2331b6b7ccc67589891da354fa46a5cb79e95f83b9fd0e734d77f1f2140/websockets-17.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cec1bb8f22abccc8d20f8ca63df9be41600c26c190f4b97ee86c675fd4a863a6", size = 227265, upload-time = "2026-08-26T14:58:06.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a5/fb1642302f8ec77ca922203074f155a9831a5128ad75e725059a476d1227/websockets-17.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3a1d577e081667dda7f8e5b4796e6e32f9713c93e2a3d930669519840a3c623", size = 226143, upload-time = "2026-08-26T14:58:07.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/41/7133fcfb63f5562750b269d6a845c689dde6a2c6407286da395beea19ddd/websockets-17.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc053f9e95a76213c5eb7ed95779f7daf0d2bf0e4e03073629ebfa43a033f151", size = 223501, upload-time = "2026-08-26T14:58:08.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b1/82b36bfabc79ff2d383a1fc043cee6a13f794ef4f6bf1b4810ad6988cf6f/websockets-17.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bb0efe019480a1c93e168ce96479273aaebd672fc8c350d5eed1e507ababb1b8", size = 226330, upload-time = "2026-08-26T14:58:09.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/7d/5b511b9bf6e9ad331e6ff902fcbcc71c3794d10ef3b5efe80ccb8f0a7861/websockets-17.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:615746b12b26a3fd4077bc6fbeb277a1c192a45dd57b531d07ad9ed5c52a9a7a", size = 224980, upload-time = "2026-08-26T14:58:11.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/50/aed08f25301f8eef23be903ff9319fcf35630ca2bdec9d226f7d804dd5b3/websockets-17.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:1a20136d61f9ca3a31493732762661fafc2c20e8861930214e21afc6a8a692a2", size = 225478, upload-time = "2026-08-26T14:58:12.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/47/0d63d4168536b4682c9d19b7399443b1176f25dbb68878374fa716670230/websockets-17.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2786cbd273ab69c22612db8a41229ddf2c158060b17b5928884bf388d07887f3", size = 226588, upload-time = "2026-08-26T14:58:14.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/dd/844bd0b6386fc81ed6a55f4b6dd26f01c6987eda205afa10175ea12b2164/websockets-17.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c323fc3be1dc3f87f6c59458cb7d9e13dcbbf971d6c3f3e2bbaf58d3bfcdfe", size = 224336, upload-time = "2026-08-26T14:58:15.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/18/03709c84bc88ec4dcea68d4be4ccd07d611073dec111203a5bf45af8809d/websockets-17.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12c8e2b25df59755954a04dfa09c990b96691025aaf7eafd19ed6da24b09c18d", size = 225197, upload-time = "2026-08-26T14:58:17.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/cf/0d1c694b6466c89e875b85b32b51312c472cf6708eee91914866f5087dde/websockets-17.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f58f58b4b29bbea2a3635e2c56eff4a3adab011fe383802a9e542e31b97085fc", size = 225493, upload-time = "2026-08-26T14:58:18.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/f5/99857c3dd9676749f33e3668665a34ad6099505fb8d75eb084f49f7807a9/websockets-17.1-cp315-cp315t-win32.whl", hash = "sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3", size = 217130, upload-time = "2026-08-26T14:58:20.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/84/77599922ab441bfe61508f97dab2c71f8e114d31793993ea54011db16199/websockets-17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110", size = 217448, upload-time = "2026-08-26T14:58:21.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/e4/af4abbcf07eac6a725ec6f865611526b2b0c23d482723de551bec667880d/websockets-17.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:10ecb38ffc05e1841b619d99c725307a223ef9ad58e7b1ed33311d472dc43918", size = 214602, upload-time = "2026-08-26T14:58:25.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/fe/819fba7ba35f92b639333da7355041c07dd50048f9c76fba0b8e292a6483/websockets-17.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17aa424ab61620aad21b36b2240efc87b500cc496e7d0e999a5c2ae99395e886", size = 214874, upload-time = "2026-08-26T14:58:26.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/a7/d370ab794f47fbeea648d17ad08caf0bb50131d6c04b7ad83e6af63c405a/websockets-17.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:764cf7bfa149365f32b7a0fd9fed32debdac29dd06295d5635cde1745b446cd8", size = 215821, upload-time = "2026-08-26T17:25:23.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/6b/251b00fe634e2a9c2cb5d6390e0e97cec55e3d18dd09b4b976620eed5d7b/websockets-17.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d1b108bd8f5f6a8b90801f6db3b3858d5deca889acfdb8ac497bbb24e4b0edf", size = 215714, upload-time = "2026-08-26T17:25:26.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b1/37fe0c96c206b4208a072c3a74add6a72af4b8228be3f5435163c5a6d099/websockets-17.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a62d8c424383c9dc769ff3672018df822603117e32686e567d452ed035b6fb2e", size = 216608, upload-time = "2026-08-26T17:25:28.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/7e/75a0a491b512412e08333b9f8412757af6186fe1c598186261002de1a793/websockets-17.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8196d217eeca52b9235ee1f8a684a09885a5f953d5a31e80ef915bf2c5c94f9d", size = 217870, upload-time = "2026-08-26T17:25:29.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" },
|
||||
]
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
@@ -15,7 +15,9 @@
|
||||
--error: #ef5350;
|
||||
--ok: #81c784;
|
||||
--radius: 6px;
|
||||
--mono: ui-monospace, "Cascadia Mono", "Source Code Pro", Menlo, Consolas, monospace;
|
||||
/* 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; }
|
||||
@@ -30,7 +32,7 @@ html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 13px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif;
|
||||
font: 13px/1.45 var(--font);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -154,6 +156,7 @@ select:focus-visible, .btn:focus-visible, .toggle:focus-visible { outline: 2px s
|
||||
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;
|
||||
@@ -177,6 +180,8 @@ select:focus-visible, .btn:focus-visible, .toggle:focus-visible { outline: 2px s
|
||||
.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 ---- */
|
||||
|
||||
@@ -196,10 +201,7 @@ select:focus-visible, .btn:focus-visible, .toggle:focus-visible { outline: 2px s
|
||||
|
||||
.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); }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 10px; font-size: 11.5px; color: var(--muted); }
|
||||
.legend .item { display: flex; align-items: center; gap: 5px; }
|
||||
.legend .swatch { width: 9px; height: 9px; border-radius: 2px; }
|
||||
.legend .annotation { font-family: var(--mono); color: var(--text); }
|
||||
.plot-hint { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.plot-host {
|
||||
position: relative;
|
||||
@@ -212,7 +214,30 @@ select:focus-visible, .btn:focus-visible, .toggle:focus-visible { outline: 2px s
|
||||
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%; cursor: crosshair; }
|
||||
.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;
|
||||
+1481
File diff suppressed because it is too large
Load Diff
+435
@@ -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
@@ -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];
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Audio Mastering Analysis Toolkit</title>
|
||||
<link rel="stylesheet" href="static/vendor/uPlot.min.css">
|
||||
<link rel="stylesheet" href="static/app.css">
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="vendor/uPlot.min.css">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -28,10 +29,10 @@
|
||||
</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 cached data">Refresh</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,.opus" multiple hidden>
|
||||
<input id="file-input" type="file" accept=".mp3,.wav,.flac,.ogg,.oga,.m4a,.aif,.aiff,.aifc,.opus" multiple hidden>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -42,7 +43,7 @@
|
||||
<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>.
|
||||
Files are held in this browser, not on the server.</p>
|
||||
They are analysed in this tab and never uploaded.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
@@ -66,18 +67,19 @@
|
||||
<section class="stage">
|
||||
<div class="plot-head">
|
||||
<h1 id="plot-title">No track loaded</h1>
|
||||
<div id="legend" class="legend"></div>
|
||||
<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>.mp3</code>, <code>.wav</code> or <code>.flac</code> files to analyse.
|
||||
Audio stays in this browser's private storage; the server receives it
|
||||
only for the moment it takes to compute a metric, and writes nothing to
|
||||
disk. Analysis runs at the file's native sample rate.</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">
|
||||
@@ -89,9 +91,9 @@
|
||||
|
||||
<div id="drop-overlay" class="drop-overlay" hidden><span>Drop to analyse</span></div>
|
||||
|
||||
<script src="static/vendor/uPlot.iife.min.js"></script>
|
||||
<!-- app.js is an ES module: it imports ./audio-store.js, which owns the
|
||||
browser-side copy of every uploaded file. -->
|
||||
<script type="module" src="static/app.js"></script>
|
||||
<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
@@ -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,
|
||||
};
|
||||
}
|
||||
-309
@@ -1,309 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
Headless analysis service for the web UI.
|
||||
|
||||
This is the server-side equivalent of `AnalysisResultsManager` + its worker
|
||||
threads: it owns the decode-and-compute lifecycle and the per-file metric cache,
|
||||
but it knows nothing about a GUI toolkit. No PyQt import appears in this file or
|
||||
anything it imports — that property is what lets `webapp.py` run under a plain
|
||||
ASGI server on a box with no display.
|
||||
|
||||
## Where the audio lives
|
||||
|
||||
The browser is the store of record. The client keeps every uploaded file in OPFS
|
||||
(origin-private file system) and re-sends the bytes for each analysis request; the
|
||||
server spools them to a temporary file, analyses it, and deletes it in a
|
||||
`finally` block. Nothing this module or the API layer does leaves an audio file
|
||||
behind on the host.
|
||||
|
||||
That is a deliberate trade, made because the analysis backend is
|
||||
`master_core.AudioFile` -> `librosa.load(path)` -> numpy/scipy. Decoding needs a
|
||||
real path, and the metrics need the samples in *this* process, so the bytes must
|
||||
transit the host. What the design guarantees is that they never *persist* there:
|
||||
no upload directory, no files surviving a request, and no server state keyed on a
|
||||
path the client chose. A crash mid-analysis can leave a file in `tempfile`'s
|
||||
directory, which is the OS's to clean; there is no application-owned location
|
||||
that accumulates audio.
|
||||
|
||||
The cost is bandwidth: switching metrics re-uploads the track. For a local
|
||||
workstation deployment that is a loopback copy of a few tens of megabytes, which
|
||||
is cheaper than the alternative (an LRU cache of decoded signals) — and the
|
||||
client only re-sends when the metric isn't already computed, because the computed
|
||||
*data* is cached here, keyed by content hash.
|
||||
|
||||
## Content addressing
|
||||
|
||||
`file_id` is the SHA-256 of the audio bytes, computed by the client. Using the
|
||||
hash rather than the filename means:
|
||||
|
||||
- two takes with the same name never collide, and renaming a file doesn't orphan
|
||||
its cached metrics;
|
||||
- the server can key its cache on the bytes actually analysed, so a client that
|
||||
re-sends the same file gets the cached result without re-decoding;
|
||||
- nothing the client sends is used to construct a path, so there is no traversal
|
||||
surface to defend.
|
||||
|
||||
## Differences from the desktop manager, and why
|
||||
|
||||
- **No prefetch.** The desktop app warms every metric for the loaded file so a
|
||||
dropdown switch is instant. Analysing in a browser is request-driven, and
|
||||
prefetching seven metrics on every upload would occupy a core for minutes on a
|
||||
long track while the user looks at one plot. So `compute()` runs on demand and
|
||||
the result is cached.
|
||||
- **One lock, not a thread-per-job.** Long analysis holds the CPU — numpy and
|
||||
scipy release the GIL, but concurrent librosa loads on the same box just thrash
|
||||
the cache and make every request slower. Serialising here also keeps the
|
||||
`metric_data` cache simple to reason about.
|
||||
- **The decoded signal is not kept.** Once a metric is computed, the `AudioFile`
|
||||
is dropped: the samples are the expensive part (tens of MB of float32 for a
|
||||
4-minute track), and every metric we ship is computed from one pass. Holding
|
||||
them to save a decode on a *second* metric costs more memory than the re-upload
|
||||
costs time.
|
||||
|
||||
`AudioFile` itself memoises the K-weighted signal so the three metrics that share
|
||||
it (LUFS and PSR via `_short_term_lufs`, Crest Factor's windowing) don't each
|
||||
re-filter the audio within a single request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from master_core import AudioFile
|
||||
from metrics import METRICS, DEFAULT_METRIC_ID
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Suffixes the decoder is expected to handle. The client filters on these too,
|
||||
# but the server is the authority — a client is not a trusted input.
|
||||
AUDIO_SUFFIXES = frozenset({
|
||||
".wav", ".mp3", ".flac", ".ogg", ".oga", ".opus", ".m4a", ".aac",
|
||||
".aiff", ".aif", ".aifc", ".wma", ".mp4", ".webm",
|
||||
})
|
||||
|
||||
# Spool chunk while receiving the body: 1 MiB keeps peak memory flat regardless
|
||||
# of file size while not hammering the syscall boundary.
|
||||
_SPOOL_CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioFileSnapshot:
|
||||
"""The bits of an `AudioFile` the API needs, without the decoded signal."""
|
||||
file_id: str
|
||||
name: str
|
||||
song_name: str
|
||||
sample_rate: int
|
||||
duration: float
|
||||
max_amplitude: float
|
||||
avg_amplitude: float
|
||||
size_bytes: int = 0
|
||||
|
||||
@property
|
||||
def channels(self) -> int:
|
||||
"""Always 1: analysis is mono.
|
||||
|
||||
`librosa.load` downmixes to mono by default and `AudioFile` stores the mono
|
||||
signal, so every metric here measures the mono sum — same as the desktop app.
|
||||
The field is kept explicit (rather than omitted) because a "0.0 dBTP on a
|
||||
stereo master" reading is only meaningful once you know that.
|
||||
"""
|
||||
return 1
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"file_id": self.file_id,
|
||||
"name": self.name,
|
||||
"song_name": self.song_name,
|
||||
"sample_rate": self.sample_rate,
|
||||
"duration": self.duration,
|
||||
"max_amplitude": self.max_amplitude,
|
||||
"avg_amplitude": self.avg_amplitude,
|
||||
"channels": self.channels,
|
||||
"size_bytes": self.size_bytes,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionFile:
|
||||
"""One analysed track: its snapshot plus every metric computed for it so far."""
|
||||
snapshot: AudioFileSnapshot
|
||||
metric_data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def metadata(self) -> dict[str, Any]:
|
||||
payload = self.snapshot.as_dict()
|
||||
payload["metrics_cached"] = sorted(self.metric_data.keys())
|
||||
return payload
|
||||
|
||||
|
||||
def file_id_for(data: bytes) -> str:
|
||||
"""Content hash used as the session key (see the module docstring)."""
|
||||
return hashlib.sha256(data).hexdigest()[:32]
|
||||
|
||||
|
||||
# Spool chunk while receiving a body: 1 MiB keeps peak memory flat regardless of
|
||||
# file size while not hammering the syscall boundary.
|
||||
SPOOL_CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
class AnalysisSession:
|
||||
"""Server-side state: analysed tracks keyed by content hash, and the view set."""
|
||||
|
||||
def __init__(self, max_files: int = 64) -> None:
|
||||
self.files: dict[str, SessionFile] = {}
|
||||
# File ids currently drawn (the desktop app's file-list checkboxes).
|
||||
self.overlay: list[str] = []
|
||||
self.max_files = max_files
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# ---- loading ------------------------------------------------------------
|
||||
|
||||
def load(self, path: Path, file_id: str, name: str, size_bytes: int = 0) -> SessionFile:
|
||||
"""Decode `path` (a spool that the caller owns) into the session.
|
||||
|
||||
Idempotent by `file_id`: a client re-sending the same bytes for a metric that
|
||||
isn't cached yet re-uses the existing snapshot rather than re-decoding.
|
||||
"""
|
||||
with self._lock:
|
||||
existing = self.files.get(file_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
t0 = time.perf_counter()
|
||||
audio = AudioFile(str(path))
|
||||
snapshot = AudioFileSnapshot(
|
||||
file_id=file_id,
|
||||
name=name,
|
||||
song_name=audio.song_name,
|
||||
sample_rate=int(audio.sr),
|
||||
duration=float(len(audio.y_mono) / audio.sr) if audio.sr else 0.0,
|
||||
max_amplitude=float(audio.max_amplitude),
|
||||
avg_amplitude=float(audio.avg_amplitude),
|
||||
size_bytes=size_bytes,
|
||||
)
|
||||
entry = SessionFile(snapshot=snapshot)
|
||||
logger.info(
|
||||
"Loaded %s in %.1fs (%.0f s @ %d Hz, %s)",
|
||||
snapshot.song_name, time.perf_counter() - t0,
|
||||
snapshot.duration, snapshot.sample_rate, file_id[:8],
|
||||
)
|
||||
with self._lock:
|
||||
self.files[file_id] = entry
|
||||
self._evict_locked()
|
||||
if file_id not in self.overlay:
|
||||
self.overlay.append(file_id)
|
||||
return entry
|
||||
|
||||
# ---- metrics ------------------------------------------------------------
|
||||
|
||||
def compute_metric(self, path: Path, file_id: str, metric_id: str,
|
||||
name: str = "upload", size_bytes: int = 0) -> Any:
|
||||
"""Metric data for a track, computed (and cached) if needed.
|
||||
|
||||
`path` is a spool the caller owns and will delete; this method only reads it.
|
||||
"""
|
||||
metric = METRICS.get(metric_id)
|
||||
if metric is None:
|
||||
raise KeyError(f"Unknown metric: {metric_id}")
|
||||
|
||||
with self._lock:
|
||||
entry = self.files.get(file_id)
|
||||
if entry is not None:
|
||||
cached = entry.metric_data.get(metric_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
t0 = time.perf_counter()
|
||||
audio = AudioFile(str(path))
|
||||
if entry is None:
|
||||
# First contact with these bytes: snapshot it while we have them decoded.
|
||||
entry = self.load(path, file_id, name, size_bytes)
|
||||
data = metric.compute(audio)
|
||||
elapsed = time.perf_counter() - t0
|
||||
logger.info("%s on %s in %.2fs", metric.display_name,
|
||||
entry.snapshot.song_name, elapsed)
|
||||
|
||||
with self._lock:
|
||||
# Keep the first result if a concurrent request won the race — identical,
|
||||
# because both hashed the same bytes.
|
||||
entry.metric_data.setdefault(metric_id, data)
|
||||
return entry.metric_data[metric_id]
|
||||
|
||||
def observe(self, path: Path, file_id: str, name: str, size_bytes: int = 0) -> SessionFile:
|
||||
"""Decode just enough to register a track (used for metadata-only requests)."""
|
||||
return self.load(path, file_id, name, size_bytes)
|
||||
|
||||
# ---- lifecycle ----------------------------------------------------------
|
||||
|
||||
def drop(self, file_id: str) -> bool:
|
||||
"""Forget a track and everything computed for it.
|
||||
|
||||
Nothing to delete on disk: the audio was never persisted. The browser still
|
||||
holds its copy, which is why this is the client's decision to make.
|
||||
"""
|
||||
with self._lock:
|
||||
entry = self.files.pop(file_id, None)
|
||||
if file_id in self.overlay:
|
||||
self.overlay.remove(file_id)
|
||||
return entry is not None
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self.files.clear()
|
||||
self.overlay.clear()
|
||||
|
||||
def _evict_locked(self) -> None:
|
||||
"""Bound the cache so a long-lived server can't grow without limit.
|
||||
|
||||
Evicts whole tracks, oldest first. The client can always re-upload, so the
|
||||
worst case is a re-decode rather than an error.
|
||||
"""
|
||||
while len(self.files) > self.max_files:
|
||||
oldest = next(iter(self.files))
|
||||
self.files.pop(oldest, None)
|
||||
if oldest in self.overlay:
|
||||
self.overlay.remove(oldest)
|
||||
logger.info("Evicted %s from the analysis cache", oldest[:8])
|
||||
|
||||
# ---- view set -----------------------------------------------------------
|
||||
|
||||
def set_overlay(self, file_ids: list[str]) -> list[str]:
|
||||
"""Replace the drawn set, keeping only ids this session has analysed."""
|
||||
with self._lock:
|
||||
self.overlay = [f for f in file_ids if f in self.files]
|
||||
return list(self.overlay)
|
||||
|
||||
def toggle(self, file_id: str, checked: bool) -> list[str]:
|
||||
with self._lock:
|
||||
if file_id not in self.files:
|
||||
return list(self.overlay)
|
||||
if checked and file_id not in self.overlay:
|
||||
self.overlay.append(file_id)
|
||||
elif not checked and file_id in self.overlay:
|
||||
self.overlay.remove(file_id)
|
||||
return list(self.overlay)
|
||||
|
||||
def overlay_entries(self) -> list[SessionFile]:
|
||||
with self._lock:
|
||||
return [self.files[f] for f in self.overlay if f in self.files]
|
||||
|
||||
def entry(self, file_id: str) -> Optional[SessionFile]:
|
||||
with self._lock:
|
||||
return self.files.get(file_id)
|
||||
|
||||
def snapshot_size(self) -> int:
|
||||
"""Number of tracks held in the analysis cache — surfaced in /api/session."""
|
||||
with self._lock:
|
||||
return len(self.files)
|
||||
|
||||
|
||||
# One process-wide session. The server is single-user by design (it runs on a
|
||||
# workstation and analyses files the user picked), so per-connection sessions
|
||||
# would only duplicate an expensive decode of the same audio.
|
||||
SESSION = AnalysisSession()
|
||||
DEFAULT_METRIC = DEFAULT_METRIC_ID
|
||||
@@ -1,451 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
Web front end for the mastering toolkit.
|
||||
|
||||
Same backend as the desktop app — `master_core.AudioFile` for decode,
|
||||
`metrics.METRICS[...].compute` for the numbers — with the PyQt5 layer replaced by
|
||||
a JSON API and a browser canvas. `metrics.Metric` was already split into a
|
||||
backend-neutral `compute` and a view-aware `build_spec`, so this costs no changes
|
||||
to the analysis code; `metrics_json` is the new renderer seam.
|
||||
|
||||
Run it:
|
||||
|
||||
uv run ujm-web # http://127.0.0.1:8000
|
||||
uv run ujm-web --host 0.0.0.0 --port 8080 --open
|
||||
|
||||
Design notes:
|
||||
|
||||
- **The browser holds the audio; the server holds only computed data.** The
|
||||
client keeps every uploaded track in OPFS and PUTs the bytes for each analysis
|
||||
request. The server spools the body to a temp file, decodes, computes, and
|
||||
deletes it inside a `finally` — there is no upload directory and no audio file
|
||||
survives a request. `web_session`'s docstring explains why the bytes still have
|
||||
to reach this process (librosa decodes by path) and what that does and doesn't
|
||||
guarantee. Concretely: hosting this does not accumulate the user's masters on
|
||||
the host.
|
||||
- **Nothing is keyed on a client-supplied path.** `file_id` is the SHA-256 of the
|
||||
audio bytes, so there is no traversal surface and no filename collision; the
|
||||
only place a client string reaches the spool is its extension, which is matched
|
||||
against an allow-list.
|
||||
- **Analysis runs in the event loop's threadpool, not in the loop.** Metric
|
||||
computes are CPU-bound numpy/scipy calls that do release the GIL; a plain `def`
|
||||
endpoint is dispatched to Starlette's threadpool, so a long spectrogram doesn't
|
||||
block status requests.
|
||||
- **Bodies are read as a stream.** `await request.stream()` is written straight to
|
||||
the spool, so a 200 MB master never sits in RAM as one `bytes` object.
|
||||
- **Nothing is served by a CDN.** The front end (uPlot for curves, a plain canvas
|
||||
for the spectrogram) is vendored under `static/` and the server binds loopback
|
||||
by default: mastering work-in-progress shouldn't leak out of the box.
|
||||
- **`/api/plot` is a pure view render.** Lin/log, time mode and the reference-line
|
||||
set are query parameters, so a view change costs a JSON re-serialisation from
|
||||
the cache — never a recompute. That is the property that made the desktop app
|
||||
feel fast; the web UI keeps it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import Body, FastAPI, HTTPException, Query, Request
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from logger_setup import setup_logging
|
||||
from metrics import METRICS, DEFAULT_METRIC_ID
|
||||
from metrics_json import serialize_ref_lines, serialize_spec, serialize_view
|
||||
from plotspec import RefLineProps, ViewState, X_ABSOLUTE, X_RELATIVE, apply_x_mode
|
||||
from web_session import AUDIO_SUFFIXES, SESSION, DEFAULT_METRIC, file_id_for
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
title="uj-mastering-master (web)",
|
||||
description="Audio mastering analysis metrics over HTTP.",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
|
||||
# ---- request helpers -------------------------------------------------------
|
||||
|
||||
def _query(request: Request, key: str) -> Optional[str]:
|
||||
"""Read a query parameter.
|
||||
|
||||
FastAPI's `Query` only populates declared signature parameters, and these
|
||||
endpoints take a raw body rather than a parameter list, so the two values the
|
||||
client sends alongside the audio are pulled off the URL directly.
|
||||
"""
|
||||
value = request.query_params.get(key)
|
||||
return value or None
|
||||
|
||||
|
||||
def _suffix_or_400(name: str) -> str:
|
||||
suffix = Path(name or "").suffix.lower()
|
||||
if suffix not in AUDIO_SUFFIXES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported format '{suffix or '?'}' "
|
||||
f"(expected one of {', '.join(sorted(AUDIO_SUFFIXES))})",
|
||||
)
|
||||
return suffix
|
||||
|
||||
|
||||
def _require_file(file_id: str):
|
||||
entry = SESSION.entry(file_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Track not analysed in this session")
|
||||
return entry
|
||||
|
||||
|
||||
def _metric_or_400(metric: Optional[str]) -> str:
|
||||
uid = metric or DEFAULT_METRIC_ID
|
||||
if uid not in METRICS:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown metric: {uid}")
|
||||
return uid
|
||||
|
||||
|
||||
# ---- catalogue & session ---------------------------------------------------
|
||||
|
||||
def _metric_catalogue() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"display_name": m.display_name,
|
||||
"default": m.id == DEFAULT_METRIC_ID,
|
||||
}
|
||||
for m in METRICS.values()
|
||||
]
|
||||
|
||||
|
||||
@app.get("/api/metrics")
|
||||
def list_metrics():
|
||||
return {"metrics": _metric_catalogue(), "default": DEFAULT_METRIC_ID}
|
||||
|
||||
|
||||
@app.get("/api/session")
|
||||
def session_state():
|
||||
return {
|
||||
"files": [f.metadata() for f in SESSION.files.values()],
|
||||
"overlay": list(SESSION.overlay),
|
||||
"default_metric": DEFAULT_METRIC_ID,
|
||||
# Explicit, so the front end can state it in the UI and a reader of the API
|
||||
# can confirm there is no audio store behind this server.
|
||||
"audio_storage": "client (browser OPFS) — nothing persisted server-side",
|
||||
}
|
||||
|
||||
|
||||
# ---- analysis --------------------------------------------------------------
|
||||
|
||||
@app.post("/api/analyse")
|
||||
async def analyse(
|
||||
request: Request,
|
||||
file_id: Optional[str] = Query(None, description="SHA-256 of the body; computed if absent"),
|
||||
name: str = Query("upload", description="Original filename, for display only"),
|
||||
metric: Optional[str] = Query(None),
|
||||
):
|
||||
"""Decode and measure one track, sent as a raw body.
|
||||
|
||||
Returns the snapshot plus the serialised metric payload in a single round trip:
|
||||
the client has to send the bytes either way, so making it then ask again for the
|
||||
plot would upload the same file twice.
|
||||
|
||||
Declared `async` so the body can be streamed to the spool with `await`; the
|
||||
decode/compute that follows is a blocking call, so it is handed to the
|
||||
threadpool explicitly to keep the event loop free.
|
||||
"""
|
||||
uid = _metric_or_400(metric)
|
||||
suffix = _suffix_or_400(name)
|
||||
display_name = Path(name).name
|
||||
|
||||
spool = tempfile.NamedTemporaryFile(prefix="ujm-", suffix=suffix, delete=False)
|
||||
path = Path(spool.name)
|
||||
try:
|
||||
size = 0
|
||||
with spool:
|
||||
async for chunk in request.stream():
|
||||
spool.write(chunk)
|
||||
size += len(chunk)
|
||||
if size == 0:
|
||||
raise HTTPException(status_code=400, detail="Empty request body")
|
||||
|
||||
fid = file_id or await run_in_threadpool(_hash_file, path)
|
||||
|
||||
def _work() -> tuple[Any, Any]:
|
||||
entry = SESSION.load(path, fid, display_name, size)
|
||||
data = SESSION.compute_metric(path, fid, uid, display_name, size)
|
||||
spec = METRICS[uid].build_spec(data, ViewState())
|
||||
return entry, spec
|
||||
|
||||
entry, spec = await run_in_threadpool(_work)
|
||||
finally:
|
||||
# The guarantee this design makes: no audio outlives the request.
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
payload = serialize_spec(spec)
|
||||
return {
|
||||
"file": entry.metadata(),
|
||||
"metric": uid,
|
||||
"metric_name": METRICS[uid].display_name,
|
||||
"payload": payload,
|
||||
"axes": payload["axes"],
|
||||
"overlay": list(SESSION.overlay),
|
||||
}
|
||||
|
||||
|
||||
def _hash_file(path: Path) -> str:
|
||||
"""SHA-256 of a spooled body, read in chunks."""
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()[:32]
|
||||
|
||||
|
||||
# ---- plotting --------------------------------------------------------------
|
||||
|
||||
@app.get("/api/metadata")
|
||||
def metadata(file_id: str):
|
||||
return _require_file(file_id).metadata()
|
||||
|
||||
|
||||
@app.post("/api/file")
|
||||
async def analyse_extra(request: Request, file_id: Optional[str] = Query(None),
|
||||
name: str = Query("upload")):
|
||||
"""Register a track without plotting it.
|
||||
|
||||
Used when the client wants the file on the list (duration, amplitudes, tags)
|
||||
for a metric that is already drawn, so the plot endpoint doesn't need to
|
||||
re-upload it.
|
||||
"""
|
||||
suffix = _suffix_or_400(name)
|
||||
display_name = Path(name).name
|
||||
|
||||
spool = tempfile.NamedTemporaryFile(prefix="ujm-", suffix=suffix, delete=False)
|
||||
path = Path(spool.name)
|
||||
try:
|
||||
size = 0
|
||||
with spool:
|
||||
async for chunk in request.stream():
|
||||
spool.write(chunk)
|
||||
size += len(chunk)
|
||||
if size == 0:
|
||||
raise HTTPException(status_code=400, detail="Empty request body")
|
||||
|
||||
fid = file_id or await run_in_threadpool(_hash_file, path)
|
||||
entry = await run_in_threadpool(SESSION.observe, path, fid, display_name, size)
|
||||
finally:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
return {"file": entry.metadata(), "overlay": list(SESSION.overlay)}
|
||||
|
||||
|
||||
@app.post("/api/overlay")
|
||||
def set_overlay(payload: dict = Body(...)):
|
||||
ids = payload.get("file_ids")
|
||||
if not isinstance(ids, list):
|
||||
raise HTTPException(status_code=400, detail="'file_ids' must be a list")
|
||||
return {"overlay": SESSION.set_overlay([str(i) for i in ids])}
|
||||
|
||||
|
||||
@app.delete("/api/file")
|
||||
def delete_file(file_id: str):
|
||||
if not SESSION.drop(file_id):
|
||||
raise HTTPException(status_code=404, detail="Track not analysed in this session")
|
||||
return {"overlay": list(SESSION.overlay),
|
||||
"note": "server forgot the analysis; the browser still holds the audio"}
|
||||
|
||||
|
||||
@app.get("/api/plot")
|
||||
def plot(
|
||||
metric: str = Query(DEFAULT_METRIC),
|
||||
y_log: Optional[bool] = Query(None),
|
||||
x_log: Optional[bool] = Query(None),
|
||||
x_mode: str = Query(X_ABSOLUTE),
|
||||
refs: str = Query("", description="JSON list of reference lines"),
|
||||
):
|
||||
"""Draw the current overlay of `metric`, honouring view state.
|
||||
|
||||
Pure view render: every dataset here was already computed by `/api/analyse`, so
|
||||
a lin/log flip or a time-mode change never touches numpy. Returns one
|
||||
serialised dataset per drawn file plus the shared axis description, mirroring
|
||||
the desktop `show_specs([(label, PlotSpec, colour), ...])`.
|
||||
"""
|
||||
if metric not in METRICS:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown metric: {metric}")
|
||||
if x_mode not in (X_ABSOLUTE, X_RELATIVE):
|
||||
raise HTTPException(status_code=400, detail=f"Unknown x_mode: {x_mode}")
|
||||
|
||||
view = ViewState(y_log=y_log, x_log=x_log, x_mode=x_mode)
|
||||
ref_lines = _parse_refs(refs)
|
||||
|
||||
entries = SESSION.overlay_entries()
|
||||
if not entries:
|
||||
return {
|
||||
"metric": metric, "metric_name": METRICS[metric].display_name,
|
||||
"view": serialize_view(view), "refs": serialize_ref_lines(ref_lines),
|
||||
"datasets": [], "axes": None, "warnings": [],
|
||||
}
|
||||
|
||||
datasets, warnings = [], []
|
||||
for index, entry in enumerate(entries):
|
||||
data = entry.metric_data.get(metric)
|
||||
if data is None:
|
||||
# Never analysed this metric for this track: the client has the bytes and
|
||||
# knows to send them. Say so rather than recomputing from nothing.
|
||||
warnings.append(f"{entry.snapshot.song_name}: {METRICS[metric].display_name} "
|
||||
f"not computed yet")
|
||||
continue
|
||||
try:
|
||||
spec = METRICS[metric].build_spec(data, view)
|
||||
except Exception as exc:
|
||||
logger.exception("build_spec failed for %s", metric)
|
||||
warnings.append(f"{entry.snapshot.song_name}: {exc}")
|
||||
continue
|
||||
|
||||
if view.x_mode == X_RELATIVE:
|
||||
spec = apply_x_mode(spec, view.x_mode)
|
||||
|
||||
datasets.append({
|
||||
"file_id": entry.snapshot.file_id,
|
||||
"label": entry.snapshot.song_name,
|
||||
"index": index,
|
||||
"payload": serialize_spec(spec),
|
||||
# The client assigns colours; it keeps a song's colour stable across
|
||||
# metric switches the same way the desktop palette-by-row does.
|
||||
"palette_index": _palette_index(entry.snapshot.file_id),
|
||||
})
|
||||
|
||||
# Heatmaps don't overlay: only the first drawn dataset is rendered, as on the
|
||||
# desktop, so the browser isn't sent z-grids it will throw away.
|
||||
if datasets and datasets[0]["payload"]["heatmap"] is not None and len(datasets) > 1:
|
||||
warnings.append(
|
||||
f"{METRICS[metric].display_name}: one track at a time — "
|
||||
f"showing {datasets[0]['label']}"
|
||||
)
|
||||
datasets = datasets[:1]
|
||||
|
||||
return {
|
||||
"metric": metric,
|
||||
"metric_name": METRICS[metric].display_name,
|
||||
"view": serialize_view(view),
|
||||
"refs": serialize_ref_lines(ref_lines),
|
||||
"datasets": datasets,
|
||||
"axes": datasets[0]["payload"]["axes"] if datasets else None,
|
||||
"warnings": warnings,
|
||||
"recompute_free": True,
|
||||
}
|
||||
|
||||
|
||||
def _palette_index(file_id: str) -> int:
|
||||
"""Stable palette slot for a track, by first-seen order in the session."""
|
||||
order = list(SESSION.files.keys())
|
||||
try:
|
||||
return order.index(file_id)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def _parse_refs(raw: str) -> list[RefLineProps]:
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
items = json.loads(raw)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="refs must be JSON")
|
||||
if not isinstance(items, list):
|
||||
raise HTTPException(status_code=400, detail="refs must be a JSON list")
|
||||
lines = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict) or "value" not in item:
|
||||
continue
|
||||
try:
|
||||
value = float(item["value"])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
lines.append(RefLineProps(
|
||||
value=value,
|
||||
color=str(item.get("color", "#444444")),
|
||||
style=str(item.get("style", "dash")),
|
||||
label=str(item.get("label", "")),
|
||||
))
|
||||
return lines
|
||||
|
||||
|
||||
# ---- front end -------------------------------------------------------------
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
index_file = STATIC_DIR / "index.html"
|
||||
if not index_file.exists():
|
||||
raise HTTPException(status_code=500, detail="Front end assets missing")
|
||||
return FileResponse(index_file)
|
||||
|
||||
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled(request, exc): # pragma: no cover - safety net
|
||||
logger.exception("Unhandled error on %s", request.url.path)
|
||||
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||
|
||||
|
||||
# ---- entry point -----------------------------------------------------------
|
||||
|
||||
# Our CLI speaks `logger_setup`'s vocabulary (ERROR/WARN/INFO/DEBUG/TRACE);
|
||||
# uvicorn only accepts its own names. Translate rather than passing through —
|
||||
# `--log-level WARN` used to die with KeyError: 'warn'.
|
||||
_UVICORN_LEVEL = {
|
||||
"ERROR": "error",
|
||||
"WARN": "warning",
|
||||
"INFO": "info",
|
||||
"DEBUG": "debug",
|
||||
"TRACE": "debug",
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import uvicorn
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="ujm-web",
|
||||
description="Mastering analysis toolkit — web front end",
|
||||
)
|
||||
parser.add_argument("--host", default="127.0.0.1",
|
||||
help="interface to bind (default: %(default)s — loopback)")
|
||||
parser.add_argument("--port", type=int, default=8000)
|
||||
parser.add_argument("--open", action="store_true",
|
||||
help="open the UI in a browser on startup")
|
||||
parser.add_argument("--reload", action="store_true", help="auto-reload on edits")
|
||||
parser.add_argument("--log-level", "-l", default="INFO",
|
||||
choices=["ERROR", "WARN", "INFO", "DEBUG", "TRACE"])
|
||||
parser.add_argument("--log-file", action="store_true",
|
||||
help="also write audio_analysis.log")
|
||||
args = parser.parse_args()
|
||||
|
||||
setup_logging(level=args.log_level, log_to_file=args.log_file)
|
||||
url = f"http://{args.host if args.host != '0.0.0.0' else '127.0.0.1'}:{args.port}"
|
||||
logger.info("Web UI at %s", url)
|
||||
logger.info("Audio is stored in the browser (OPFS); "
|
||||
"uploads are spooled to temp and deleted after each request")
|
||||
|
||||
if args.open:
|
||||
import threading
|
||||
import webbrowser
|
||||
threading.Timer(1.0, lambda: webbrowser.open(url)).start()
|
||||
|
||||
uvicorn.run("webapp:app", host=args.host, port=args.port, reload=args.reload,
|
||||
log_level=_UVICORN_LEVEL[args.log_level])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user