Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 239b764393 | |||
| 5420975f8e | |||
| aed2d54b45 | |||
| 6ae4d5e8e3 | |||
| da4231ad36 | |||
| e8da88b6c1 | |||
| b5c12bfcb4 | |||
| e50eff9122 | |||
| 677d09524e | |||
| 5c918109d5 | |||
| a1fed2cd71 | |||
| 5df802a574 | |||
| 99d99b866f | |||
| 4f82c9197b |
@@ -0,0 +1,7 @@
|
||||
# The image is web/ served with deploy/nginx.conf. Allowlist those and nothing
|
||||
# else, so no local state (audio, fixtures, caches, .env, keys) can ever end up
|
||||
# in the build context.
|
||||
*
|
||||
!web/
|
||||
!deploy/nginx.conf
|
||||
!deploy/security-headers.conf
|
||||
@@ -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,6 +111,56 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
||||
gone with the matplotlib render path. If wanted back, it belongs in the
|
||||
renderer, applied uniformly to every metric — not per-metric
|
||||
|
||||
#### `web/` (browser version — no backend)
|
||||
- A static site: decoding and every metric run in the visitor's tab; nothing is
|
||||
uploaded or stored. Served with `Content-Security-Policy: connect-src 'none'`
|
||||
(`deploy/nginx.conf`), so the page and its worker cannot make network requests
|
||||
- The image versions assets by content: everything but `index.html` is copied to
|
||||
`v/<hash>/` and the page's references rewritten (Dockerfile `assets` stage);
|
||||
nginx serves `v/` immutable and the page `no-cache`. Needed because Cloudflare
|
||||
forces browser caching of .js/.css to 4 h, which paired a fresh index.html with
|
||||
a stale app.js (`Cannot set properties of null`). Source `web/index.html` keeps
|
||||
plain relative paths, so serving `web/` directly still works
|
||||
- `decode.js` — `AudioFile.__init__` counterpart. WAV/AIFF PCM & float are parsed
|
||||
directly with libsndfile scaling (bit-identical to `librosa.load`; Chromium's
|
||||
own WAV decoder scales positive int16 by 1/32767). Other formats go through
|
||||
`decodeAudioData` on an `OfflineAudioContext` at the header-sniffed native rate
|
||||
(no resampling). ID3 artist/title for MP3. Downmix = float32 channel mean
|
||||
- `dsp.js` — ports of every `Metric.compute`: pure functions over the mono
|
||||
Float32Array (run in the worker and under Node). Same numerics as the Python:
|
||||
sequential float64 prefix sums sampled only at window edges (no N-length
|
||||
arrays), pyloudnorm K-weighting via scipy `lfilter`'s DF2T recurrence (streamed),
|
||||
`resample_poly(y, 4, 1)`'s 81-tap Kaiser(5) filter + monotonic-deque window max,
|
||||
`librosa.stft` centred framing + `amplitude_to_db(ref=max, top_db=80)`.
|
||||
Loudness (short-term, integrated, LRA) is one memoised pass shared by LUFS and PSR
|
||||
- `metrics.js` — registry + `build_spec` ports producing the plot payload
|
||||
(`{axes, hlines, annotations, segments, series, heatmap}`), plus `applyRelativeX`
|
||||
- `analysis-worker.js` — Web Worker: holds each track's mono signal, computes one
|
||||
metric per request, transfers typed arrays back
|
||||
- `app.js` — `MainWindow` + `AnalysisResultsManager` + renderer: track list,
|
||||
overlay set, per-metric result cache, job queue (foreground jobs jump ahead of
|
||||
background prefetch; LUFS + true peak prefetched first for the metadata
|
||||
panel's loudness summary), uPlot for curves/bands + canvas spectrogram (log
|
||||
axis drawn from linear rows, cached offscreen image). Guide and reference
|
||||
lines are painted in a uPlot draw hook, so dragging a line is a cheap redraw
|
||||
and moves live. One zoom/pan controller serves both renderers: wheel zoom
|
||||
(Ctrl = x only, Shift = y only, over an axis = that axis), rAF-throttled drag
|
||||
pan, double-click reset; `state.zoom` survives redraws and resets when units
|
||||
change. In-plot legend overlay like pyqtgraph's
|
||||
- Font: CSS `--font` stack prefers M PLUS 1 Code, else the system UI font;
|
||||
canvas/uPlot text reads the same variable
|
||||
- `vendor/` — uPlot 1.6.32, vendored (the CSP only allows same-origin scripts)
|
||||
- Needs an HTTP server (module scripts and workers don't load from `file://`),
|
||||
but no secure context — plain `http://` on a LAN works
|
||||
|
||||
#### `tests/parity/`
|
||||
- `fixtures.py` synthesises edge-case audio (short / silent / below the relative
|
||||
gate, 96 kHz, every PCM layout, lossy formats via ffmpeg); `reference.py` runs
|
||||
`metrics.py` and saves the exact mono signal it analysed; `parity.mjs` runs
|
||||
`web/dsp.js` on that signal and compares every field (float64 paths ~1e-12,
|
||||
float32 paths ~1e-6)
|
||||
- Any change to `metrics.py` numerics must be mirrored in `web/dsp.js` and pass this
|
||||
|
||||
#### `master_core.py`
|
||||
- Defines the `AudioFile` class: librosa loading, rolling RMS power. BPM detection
|
||||
was **removed** — `librosa.beat.beat_track` cost ~3.7 s on every load for a
|
||||
@@ -225,6 +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
|
||||
- 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
|
||||
@@ -248,6 +302,18 @@ uv sync # one-time, after cloning
|
||||
uv run ujm # launch the GUI
|
||||
```
|
||||
|
||||
### Running the web UI
|
||||
Branch `feature/web-js`. Static files in `web/`; everything runs in the browser.
|
||||
|
||||
```bash
|
||||
docker compose up -d --build # nginx + CSP, http://localhost:8080
|
||||
python3 -m http.server -d web 8080 # quick local try (no CSP header)
|
||||
```
|
||||
|
||||
Audio is decoded and analysed in the tab and never leaves it (CSP
|
||||
`connect-src 'none'`). Check the port against `metrics.py` with the parity tests
|
||||
(`tests/parity`, see README).
|
||||
|
||||
Optional flags (handled by `logger_setup.parse_log_args`):
|
||||
```bash
|
||||
uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# uj-mastering-master — web UI
|
||||
#
|
||||
# The web UI is a static site: decoding and every metric run in the visitor's
|
||||
# browser (web/dsp.js inside a Web Worker). This image only serves files — no
|
||||
# application server, no upload endpoint, nothing written anywhere — so it runs
|
||||
# read-only as nginx's unprivileged user. See deploy/nginx.conf for the CSP that
|
||||
# stops the page from making network requests at all.
|
||||
#
|
||||
# The desktop app (PyQt5) is unaffected and not part of this image.
|
||||
|
||||
# ---- assets: version every file except the page by content ------------------
|
||||
# Everything but index.html goes under v/<hash of the assets>/, and index.html's
|
||||
# references are rewritten to match. Each deploy therefore changes every asset
|
||||
# URL, so no cache (browser or Cloudflare, which forces .js/.css browser caching
|
||||
# to 4 h) can pair a new index.html with an old app.js. Module imports and the
|
||||
# worker URL are relative, so they resolve inside the versioned directory as is.
|
||||
FROM alpine:3.22 AS assets
|
||||
COPY web/ /src/
|
||||
RUN set -eu; \
|
||||
cd /src; \
|
||||
v="$(find . -type f ! -name index.html | sort | xargs sha256sum | sha256sum | cut -c1-12)"; \
|
||||
mkdir -p "/out/v/$v"; \
|
||||
for f in *; do [ "$f" = index.html ] || cp -R "$f" "/out/v/$v/"; done; \
|
||||
sed -E "s#(href|src)=\"(app\.css|app\.js|vendor/[^\"]+)\"#\1=\"v/$v/\2\"#g" index.html > /out/index.html; \
|
||||
grep -q "src=\"v/$v/app.js\"" /out/index.html; \
|
||||
grep -q "href=\"v/$v/app.css\"" /out/index.html
|
||||
|
||||
# ---- runtime -------------------------------------------------------------------
|
||||
FROM nginxinc/nginx-unprivileged:stable-alpine
|
||||
|
||||
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY deploy/security-headers.conf /etc/nginx/snippets/ujm-security-headers.conf
|
||||
COPY --from=assets /out/ /usr/share/nginx/html/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1
|
||||
@@ -1,674 +1,201 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Mikkeli Matlock
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
@@ -20,11 +20,11 @@ Developed with Claude Code assistance.
|
||||
spectrogram's 22 kHz top, which otherwise falls between decade ticks)
|
||||
- **Native sample rate**: audio is loaded without resampling, so the full band
|
||||
(up to the file's own nyquist, e.g. ~22 kHz for 44.1 kHz files) is analysed
|
||||
- **BPM detection** via librosa
|
||||
- **CJK-safe font system** with custom fonts loaded from `fonts/` (gitignored), system fallbacks, and a live font selector
|
||||
- **CJK-safe font system** with custom fonts loaded from `fonts/` (gitignored) and system fallbacks; the UI is pinned to M PLUS 1 Code @ 10pt
|
||||
- **Background analysis thread** so the UI stays responsive; metric switches
|
||||
compute off the GUI thread and cache, so re-selecting a metric is instant
|
||||
- **Embedded matplotlib canvas** with auto-regenerated plots on font change
|
||||
- **pyqtgraph renderer**: one persistent `PlotItem` reused across replots, with
|
||||
per-axis zoom modifiers (Ctrl+scroll for time only, Shift+scroll for the value axis)
|
||||
|
||||
### Roadmap
|
||||
See [CLAUDE.md](CLAUDE.md) for the full development roadmap. Near-term:
|
||||
@@ -35,11 +35,15 @@ dynamic range (DR meter), plot-style controls, interactive axis controls.
|
||||
This project uses [uv](https://docs.astral.sh/uv/). With uv installed:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv sync --extra desktop
|
||||
uv run ujm
|
||||
```
|
||||
|
||||
`uv run ujm` is the only supported entry point — it boots the GUI.
|
||||
`uv run ujm` boots the desktop GUI (see *Web UI* for the browser version).
|
||||
|
||||
`uv sync` on its own installs only the analysis dependencies (librosa, numpy,
|
||||
scipy, mutagen, pyloudnorm). The GUI stack — PyQt5, pyqtgraph — is the
|
||||
`desktop` extra.
|
||||
|
||||
### Logging flags
|
||||
```bash
|
||||
@@ -52,9 +56,100 @@ Drop `.ttf` / `.otf` / `.ttc` files into `fonts/` to get them in the font
|
||||
selector. The directory is gitignored to avoid bundling licensed font data.
|
||||
See [CJK_FONTS.md](CJK_FONTS.md) for details.
|
||||
|
||||
## Web UI
|
||||
|
||||
A browser version that runs entirely inside the tab, in `web/` (branch
|
||||
`feature/web-js`). There is no Python backend: decoding and all seven metrics are
|
||||
JavaScript ports of `master_core.py` / `metrics.py`, computed in a Web Worker and
|
||||
drawn from the same `PlotSpec`-shaped payloads the desktop renderer uses.
|
||||
|
||||
**Audio never leaves the browser.** A dropped file is read into memory, decoded,
|
||||
reduced to the mono signal the metrics use and analysed — nothing is uploaded,
|
||||
cached or written to storage, and closing the tab discards it. This is enforced,
|
||||
not just promised: the page is served with `Content-Security-Policy: connect-src
|
||||
'none'`, so neither the page nor its worker is permitted to make any network
|
||||
request.
|
||||
|
||||
Everything the desktop app does works the same way: all seven metrics, the
|
||||
overlay, lin/log toggles, absolute/relative time, an in-plot legend, wheel zoom
|
||||
around the cursor (Ctrl: time only, Shift: values only, or scroll over an axis),
|
||||
live drag-to-pan and double-click to reset, and draggable reference lines (kept
|
||||
per metric) alongside the metrics' own guides (−14 LUFS, integrated loudness,
|
||||
0 dBTP…). The metadata panel and file list show integrated loudness, LRA and
|
||||
max true peak. Analysis runs at the file's native sample rate.
|
||||
|
||||
Serve `web/` with any static file server, over `http://` or `https://` — no
|
||||
secure context is needed. (ES modules and workers don't load from `file://`, so
|
||||
opening `index.html` directly won't work.)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build # nginx + the CSP, http://localhost:8080
|
||||
python3 -m http.server -d web 8080 # quick local try (no CSP header)
|
||||
```
|
||||
|
||||
### How the port matches the Python
|
||||
|
||||
- **Decoding** (`web/decode.js`): WAV and AIFF (PCM 8/16/24/32-bit, float 32/64,
|
||||
WAVE_FORMAT_EXTENSIBLE) are parsed directly with libsndfile's sample scaling,
|
||||
so the signal is bit-identical to `librosa.load`. Other formats (FLAC, MP3, Ogg
|
||||
Vorbis/Opus, AAC) use the browser's decoder through an `OfflineAudioContext`
|
||||
running at the sample rate read from the file header, so nothing is resampled.
|
||||
MP3 artist/title come from ID3 tags, as on the desktop.
|
||||
- **Metrics** (`web/dsp.js`): the same algorithms and numerics — pyloudnorm's
|
||||
K-weighting through scipy's `lfilter` recurrence, BS.1770 gating and EBU LRA,
|
||||
`resample_poly`'s 81-tap Kaiser filter for true peak, `librosa.stft` framing and
|
||||
`amplitude_to_db` for the spectrogram. Running sums and window maxima are
|
||||
single streaming passes, so no signal-length intermediate arrays are allocated.
|
||||
|
||||
### Parity tests
|
||||
|
||||
`tests/parity` checks the port against the Python on synthetic fixtures that
|
||||
cover what the metric code branches on — shorter than each window, silence,
|
||||
passages below the relative gate, 96 kHz, every PCM layout, lossy formats:
|
||||
|
||||
```bash
|
||||
uv run --no-project --with numpy --with soundfile \
|
||||
python tests/parity/fixtures.py /tmp/ujm-parity
|
||||
uv run --no-project --with librosa --with pyloudnorm --with scipy --with mutagen \
|
||||
python tests/parity/reference.py /tmp/ujm-ref /tmp/ujm-parity/*
|
||||
node tests/parity/parity.mjs /tmp/ujm-ref
|
||||
```
|
||||
|
||||
`reference.py` saves the exact mono signal `metrics.py` analysed, and
|
||||
`parity.mjs` runs `web/dsp.js` on that same signal and compares every output
|
||||
field. The float64 paths (loudness, crest factor, PSR) agree to ~1e-12, the
|
||||
float32 paths (true peak, spectrogram dB, RMS) to ~1e-6.
|
||||
|
||||
### Deploying with Docker
|
||||
|
||||
The image is nginx (unprivileged, read-only root filesystem) serving `web/` with
|
||||
`deploy/nginx.conf`, which sets the CSP and turns access logging off. There is
|
||||
nothing to persist, upload or log on the server side, so there are no volumes.
|
||||
Assets are served from a content-hashed `v/<hash>/` directory (cached
|
||||
indefinitely) while the page itself is always revalidated, so a deploy can never
|
||||
leave a browser running old scripts against a new page — even behind a CDN that
|
||||
overrides cache headers.
|
||||
|
||||
### Web architecture
|
||||
|
||||
| Module | Responsibility |
|
||||
| --- | --- |
|
||||
| `web/decode.js` | File → mono Float32Array at native rate (direct WAV/AIFF, browser decoder otherwise); ID3 tags |
|
||||
| `web/dsp.js` | Ports of every `Metric.compute`; pure functions (also run under Node for the parity tests) |
|
||||
| `web/metrics.js` | Metric registry + `build_spec` ports producing plot payloads; relative-time transform |
|
||||
| `web/analysis-worker.js` | Web Worker holding each track's signal and computing one metric per request |
|
||||
| `web/app.js` | Track list, overlay, result cache, foreground/prefetch job queue, uPlot + canvas renderer |
|
||||
| `deploy/nginx.conf` | Static serving, CSP (`connect-src 'none'`), no access log |
|
||||
|
||||
## Dependencies
|
||||
`librosa`, `numpy`, `matplotlib`, `mutagen`, `pyloudnorm`, `PyQt5` — all pinned
|
||||
through `uv.lock`. Python 3.10+.
|
||||
Core (always installed): `librosa`, `numpy`, `matplotlib`, `mutagen`,
|
||||
`pyloudnorm` — pinned through `uv.lock`. Python 3.10+.
|
||||
|
||||
Optional extras:
|
||||
- `desktop` — `PyQt5`, `pyqtgraph` (the GUI)
|
||||
|
||||
The web UI has no dependencies beyond uPlot, vendored in `web/vendor/`. The parity
|
||||
tests need Node 18+ plus the Python analysis dependencies.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -62,18 +157,24 @@ through `uv.lock`. Python 3.10+.
|
||||
| --- | --- |
|
||||
| `main.py` | `MainWindow` + the `ujm` entry point |
|
||||
| `analysis_results_manager.py` | Background `QThread` worker, result + metric-data cache |
|
||||
| `master_core.py` | `AudioFile`: native-rate librosa loading, RMS rolling window, BPM |
|
||||
| `master_core.py` | `AudioFile`: native-rate librosa loading, rolling RMS |
|
||||
| `metrics.py` | Pluggable `Metric` ABC + registry (RMS, Waveform, LUFS, Crest, PSR, True Peak, Spectrogram) |
|
||||
| `audio_visualization_widget.py` | Embedded `FigureCanvasQTAgg` host |
|
||||
| `font_manager.py` | Custom + system CJK font discovery, matplotlib/Qt config |
|
||||
| `font_control_widget.py` | Font picker + size slider |
|
||||
| `plot_control_widget.py` | Metric selector + refresh-plot button |
|
||||
| `audio_visualization_widget.py` | pyqtgraph `PlotItem` host; renders `PlotSpec` |
|
||||
| `plotspec.py` | Renderer-neutral plot description (`Curve`/`Band`/`HLine`/`Heatmap`) |
|
||||
| `font_manager.py` | Custom + system CJK font discovery, Qt font config |
|
||||
| `plot_control_widget.py` | Metric selector, log-frequency toggle, time-axis mode |
|
||||
| `ref_line_widget.py` | Reference-line control; values kept per metric, in its own units |
|
||||
| `logger_setup.py` | CLI log-level parsing + custom TRACE level |
|
||||
| `setup_fonts.py` | Diagnostic utility (run standalone) |
|
||||
|
||||
### Adding a metric
|
||||
|
||||
Subclass `Metric` in `metrics.py`, implement `compute(audio_file) -> data` (the
|
||||
heavy part, runs on the worker thread) and `render(data, file_path) -> Figure`
|
||||
(cheap, runs on the GUI thread). Register the instance in the `METRICS` dict at
|
||||
the bottom of the file — it shows up in the dropdown automatically.
|
||||
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 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.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
Analysis Results Manager - Bridge between audio processing and GUI.
|
||||
Manages analysis queue and coordinates between components.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
Interactive visualization widget built on pyqtgraph.
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,37 @@
|
||||
# uj-mastering-master — web UI, container deployment template.
|
||||
#
|
||||
# docker compose up -d --build
|
||||
# open http://localhost:8080
|
||||
#
|
||||
# The container serves static files only. Audio is decoded and analysed inside
|
||||
# the visitor's browser tab and never sent anywhere (the page's CSP forbids
|
||||
# network requests), so there is nothing to upload, persist or log here — and no
|
||||
# volumes, on purpose.
|
||||
|
||||
services:
|
||||
ujm-web:
|
||||
build: .
|
||||
image: uj-mastering-master-web:local
|
||||
container_name: ujm-web
|
||||
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
# Nothing to write: nginx only needs somewhere for its pid file and empty
|
||||
# temp directories.
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "1m"
|
||||
max-file: "2"
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
Font management system with CJK fallback support.
|
||||
Handles matplotlib and Qt font configuration with licensing-safe approach.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
Logging configuration for the Audio Mastering Toolkit.
|
||||
Provides CLI configurable logging with different verbosity levels.
|
||||
|
||||
@@ -1,420 +1,421 @@
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
||||
QHBoxLayout, QSplitter, QLabel, QListWidget,
|
||||
QTextEdit, QListWidgetItem, QPushButton, QFileDialog)
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from audio_visualization_widget import AudioVisualizationWidget, dataset_color
|
||||
from analysis_results_manager import AnalysisResultsManager
|
||||
from logger_setup import setup_logging, parse_log_args
|
||||
from font_manager import initialize_fonts, apply_fixed_font
|
||||
from plot_control_widget import PlotControlWidget
|
||||
from ref_line_widget import RefLineControlWidget, RefLineDialog
|
||||
from metrics import METRICS
|
||||
from plotspec import RefLineProps, apply_x_mode
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Main application window with modular audio analysis interface."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.analysis_manager = AnalysisResultsManager()
|
||||
# Guards programmatic list mutations from triggering re-render storms.
|
||||
self._suppress_list_signals = False
|
||||
# Reference lines are kept per metric (a -14 LUFS line means nothing on a
|
||||
# spectrogram), so they persist when you switch metrics and come back.
|
||||
self.ref_lines_by_metric: dict[str, list[RefLineProps]] = {}
|
||||
self.initUI()
|
||||
self.connect_signals()
|
||||
self._activate_ref_lines()
|
||||
|
||||
def initUI(self):
|
||||
"""Initialize the user interface."""
|
||||
self.setWindowTitle('Audio Mastering Analysis Toolkit')
|
||||
self.setGeometry(100, 100, 1200, 700)
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
# Create central widget with splitter
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
# Main horizontal layout
|
||||
main_layout = QHBoxLayout(central_widget)
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
main_layout.addWidget(splitter)
|
||||
|
||||
# Left panel - File list and metadata
|
||||
left_panel = self.create_left_panel()
|
||||
splitter.addWidget(left_panel)
|
||||
|
||||
# Right panel - Visualization
|
||||
self.visualization_widget = AudioVisualizationWidget()
|
||||
splitter.addWidget(self.visualization_widget)
|
||||
|
||||
# Set splitter proportions
|
||||
splitter.setSizes([300, 900]) # Left panel narrower than visualization
|
||||
|
||||
def create_left_panel(self):
|
||||
"""Create the left panel with file list and metadata."""
|
||||
panel = QWidget()
|
||||
layout = QVBoxLayout(panel)
|
||||
|
||||
# Open File button
|
||||
self.open_file_button = QPushButton("Open Audio File...")
|
||||
self.open_file_button.clicked.connect(self.open_file_dialog)
|
||||
layout.addWidget(self.open_file_button)
|
||||
|
||||
# Plot control cluster (metric selector + scale toggle + refresh)
|
||||
self.plot_control = PlotControlWidget()
|
||||
self.plot_control.metricChanged.connect(self.on_metric_changed)
|
||||
self.plot_control.viewChanged.connect(self.on_view_changed)
|
||||
self.plot_control.plotRefreshRequested.connect(self.on_plot_refresh_requested)
|
||||
layout.addWidget(self.plot_control)
|
||||
|
||||
# Reference-line management cluster (list + add/edit/remove/clear).
|
||||
self.ref_line_control = RefLineControlWidget()
|
||||
self.ref_line_control.addRequested.connect(self.on_add_reference_line)
|
||||
self.ref_line_control.editRequested.connect(self.on_edit_reference_line)
|
||||
self.ref_line_control.removeRequested.connect(self.on_remove_reference_line)
|
||||
self.ref_line_control.clearRequested.connect(self.on_clear_reference_lines)
|
||||
layout.addWidget(self.ref_line_control)
|
||||
|
||||
# File list. Each item carries a checkbox: the checked set is the overlay
|
||||
# set drawn on the graph; the highlighted item drives the metadata panel.
|
||||
self.file_list_label = QLabel("Analyzed Files (tick to overlay):")
|
||||
layout.addWidget(self.file_list_label)
|
||||
|
||||
self.file_list = QListWidget()
|
||||
self.file_list.itemClicked.connect(self.on_file_selected)
|
||||
self.file_list.itemChanged.connect(self.on_file_check_changed)
|
||||
layout.addWidget(self.file_list)
|
||||
|
||||
# Metadata display
|
||||
self.metadata_label = QLabel("File Information:")
|
||||
layout.addWidget(self.metadata_label)
|
||||
|
||||
self.metadata_display = QTextEdit()
|
||||
self.metadata_display.setReadOnly(True)
|
||||
self.metadata_display.setMaximumHeight(150)
|
||||
layout.addWidget(self.metadata_display)
|
||||
|
||||
# Instructions
|
||||
instructions = QLabel(
|
||||
"Drag and drop audio files (.mp3, .wav) onto this window to analyze them."
|
||||
)
|
||||
instructions.setWordWrap(True)
|
||||
instructions.setStyleSheet("color: gray; font-style: italic;")
|
||||
layout.addWidget(instructions)
|
||||
|
||||
return panel
|
||||
|
||||
def connect_signals(self):
|
||||
"""Connect analysis manager signals to GUI updates."""
|
||||
self.analysis_manager.analysisStarted.connect(self.on_analysis_started)
|
||||
self.analysis_manager.analysisCompleted.connect(self.on_analysis_completed)
|
||||
self.analysis_manager.analysisError.connect(self.on_analysis_error)
|
||||
self.analysis_manager.progressUpdate.connect(self.on_progress_update)
|
||||
self.analysis_manager.metricComputeStarted.connect(self.on_metric_compute_started)
|
||||
self.analysis_manager.metricReady.connect(self.on_metric_ready)
|
||||
self.analysis_manager.metricComputeError.connect(self.on_metric_compute_error)
|
||||
self.analysis_manager.metricTiming.connect(self.on_metric_timing)
|
||||
self.visualization_widget.referenceLineMoved.connect(self.on_reference_line_moved)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Stop background analysis/prefetch threads before the window closes."""
|
||||
self.analysis_manager.shutdown()
|
||||
super().closeEvent(event)
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
"""Handle drag enter event for file drops."""
|
||||
if event.mimeData().hasUrls():
|
||||
# Check if any files have audio extensions
|
||||
urls = event.mimeData().urls()
|
||||
for url in urls:
|
||||
file_path = url.toLocalFile()
|
||||
if file_path.lower().endswith(('.mp3', '.wav', '.flac')):
|
||||
event.accept()
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
def dropEvent(self, event):
|
||||
"""Handle file drop event."""
|
||||
files = [u.toLocalFile() for u in event.mimeData().urls()]
|
||||
audio_files = [f for f in files if f.lower().endswith(('.mp3', '.wav', '.flac'))]
|
||||
|
||||
self.logger.info(f"Files dropped: {len(files)} total, {len(audio_files)} audio files")
|
||||
|
||||
if audio_files:
|
||||
# Analyze the first audio file
|
||||
# TODO: Add support for multiple file queue
|
||||
file_path = audio_files[0]
|
||||
self.logger.info(f"Starting analysis of dropped file: {os.path.basename(file_path)}")
|
||||
self.analysis_manager.analyze_file(file_path, self.plot_control.current_metric_id())
|
||||
else:
|
||||
self.visualization_widget.set_status("No audio files detected in drop")
|
||||
self.logger.warning("No supported audio files found in drop")
|
||||
|
||||
def open_file_dialog(self):
|
||||
"""Open file dialog to select audio files for analysis."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Select Audio File",
|
||||
"", # Default directory (empty = current directory)
|
||||
"Audio Files (*.mp3 *.wav *.flac);;All Files (*)"
|
||||
)
|
||||
|
||||
if file_path: # User selected a file (didn't cancel)
|
||||
self.logger.info(f"File selected via dialog: {os.path.basename(file_path)}")
|
||||
self.analysis_manager.analyze_file(file_path, self.plot_control.current_metric_id())
|
||||
|
||||
def on_analysis_started(self, file_path):
|
||||
"""Called when analysis starts."""
|
||||
filename = os.path.basename(file_path)
|
||||
self.visualization_widget.set_status(f"Analyzing: {filename}...")
|
||||
|
||||
def on_analysis_completed(self, file_path, result):
|
||||
"""Called when analysis completes successfully."""
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
# Add to file list (checked, so it joins the overlay set) if not present.
|
||||
item = self._item_for_path(file_path)
|
||||
if item is None:
|
||||
self._suppress_list_signals = True
|
||||
item = QListWidgetItem(filename)
|
||||
item.setData(Qt.UserRole, file_path) # Store full path
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||
item.setCheckState(Qt.Checked)
|
||||
self.file_list.addItem(item)
|
||||
self._suppress_list_signals = False
|
||||
|
||||
# Update metadata display and highlight the analyzed file.
|
||||
self.metadata_display.setText(self.analysis_manager.get_metadata_text(file_path))
|
||||
self.file_list.setCurrentItem(item)
|
||||
|
||||
# Redraw the overlay set for the current metric.
|
||||
self._refresh_view()
|
||||
|
||||
def on_analysis_error(self, file_path, error_message):
|
||||
"""Called when analysis fails."""
|
||||
filename = os.path.basename(file_path)
|
||||
self.logger.error(f"Analysis failed for {filename}: {error_message}")
|
||||
self.visualization_widget.set_status(f"Error analyzing {filename}: {error_message}")
|
||||
|
||||
def on_progress_update(self, message, percentage):
|
||||
"""Called when analysis progress updates.
|
||||
|
||||
The messages already carry phase + timing; the percentage was a coarse
|
||||
fake (load jumped 10->done), so it's logged but not shown in the slip.
|
||||
"""
|
||||
self.logger.debug(f"Progress: {message} ({percentage}%)")
|
||||
self.visualization_widget.set_status(message)
|
||||
|
||||
def on_file_selected(self, item):
|
||||
"""Called when a file is highlighted (drives the metadata panel only)."""
|
||||
file_path = item.data(Qt.UserRole)
|
||||
self.metadata_display.setText(self.analysis_manager.get_metadata_text(file_path))
|
||||
|
||||
def on_file_check_changed(self, _item):
|
||||
"""A checkbox toggled — the overlay set changed; redraw."""
|
||||
if self._suppress_list_signals:
|
||||
return
|
||||
self._refresh_view()
|
||||
|
||||
def on_metric_changed(self, metric_id: str):
|
||||
"""Called when the metric selector changes."""
|
||||
self.logger.info(f"Metric changed via GUI: {metric_id}")
|
||||
# Reference lines are kept per metric, so swap in this metric's set rather
|
||||
# than discarding — switch away and back and your lines are still there.
|
||||
self._activate_ref_lines()
|
||||
self._refresh_view()
|
||||
|
||||
def on_add_reference_line(self):
|
||||
"""Add a reference line at the current view centre, then edit it."""
|
||||
value = self.visualization_widget.current_view_center_value()
|
||||
props = RefLineProps(value=round(value, 2))
|
||||
self.ref_lines.append(props)
|
||||
self._sync_ref_lines()
|
||||
# Open the editor immediately so colour/tag/value can be set right away.
|
||||
self.on_edit_reference_line(len(self.ref_lines) - 1)
|
||||
|
||||
def on_edit_reference_line(self, index: int):
|
||||
"""Open the properties dialog for a reference line."""
|
||||
if not (0 <= index < len(self.ref_lines)):
|
||||
return
|
||||
dialog = RefLineDialog(self, self.ref_lines[index], value_units=self._ref_value_units())
|
||||
if dialog.exec_():
|
||||
self.ref_lines[index] = dialog.result_props()
|
||||
self._sync_ref_lines()
|
||||
|
||||
def on_remove_reference_line(self, index: int):
|
||||
"""Delete a reference line."""
|
||||
if 0 <= index < len(self.ref_lines):
|
||||
del self.ref_lines[index]
|
||||
self._sync_ref_lines()
|
||||
|
||||
def on_clear_reference_lines(self):
|
||||
"""Remove all custom reference lines for the current metric."""
|
||||
self.ref_lines.clear()
|
||||
self._sync_ref_lines()
|
||||
|
||||
def on_reference_line_moved(self, index: int):
|
||||
"""A line was dragged on the plot — its value is already updated; refresh list."""
|
||||
self.ref_line_control.set_lines(self.ref_lines)
|
||||
|
||||
def _activate_ref_lines(self):
|
||||
"""Point `self.ref_lines` at the current metric's set and sync the UI."""
|
||||
metric_id = self.plot_control.current_metric_id()
|
||||
self.ref_lines = self.ref_lines_by_metric.setdefault(metric_id, [])
|
||||
self._sync_ref_lines()
|
||||
|
||||
def _ref_value_units(self) -> str:
|
||||
"""Units a reference line's value is expressed in for the current metric."""
|
||||
return "Hz" if self.plot_control.current_metric_id() == "spectrogram" else ""
|
||||
|
||||
def _sync_ref_lines(self):
|
||||
"""Push the current reference-line set to both the list view and the plot."""
|
||||
self.ref_line_control.set_lines(self.ref_lines)
|
||||
self.visualization_widget.set_reference_lines(self.ref_lines)
|
||||
|
||||
def on_view_changed(self):
|
||||
"""Called when a view-scale toggle (lin/log) changes. Recompute-free redraw."""
|
||||
self.logger.info("View scale changed via GUI")
|
||||
self._refresh_view()
|
||||
|
||||
def on_plot_refresh_requested(self):
|
||||
"""Called when manual plot refresh is requested."""
|
||||
self.logger.info("Manual plot refresh requested via GUI")
|
||||
self._refresh_view()
|
||||
|
||||
def on_metric_compute_started(self, file_path: str, metric_id: str):
|
||||
"""Called when an off-thread metric compute starts."""
|
||||
if file_path not in self._overlay_paths():
|
||||
return # not in the drawn set; status bar shouldn't lie
|
||||
metric = METRICS.get(metric_id)
|
||||
display = metric.display_name if metric else metric_id
|
||||
self.visualization_widget.set_status(f"Computing {display}...")
|
||||
|
||||
def on_metric_ready(self, file_path: str, metric_id: str):
|
||||
"""Called when metric data is available (cached hit or async finish)."""
|
||||
if metric_id != self.plot_control.current_metric_id():
|
||||
return # user already switched to a different metric
|
||||
if file_path not in self._overlay_paths():
|
||||
return # no longer part of the overlay set
|
||||
self._refresh_view()
|
||||
|
||||
def on_metric_timing(self, file_path: str, metric_id: str, seconds: float):
|
||||
"""An on-demand metric compute finished — report how long it took."""
|
||||
if file_path not in self._overlay_paths():
|
||||
return
|
||||
if metric_id != self.plot_control.current_metric_id():
|
||||
return
|
||||
metric = METRICS.get(metric_id)
|
||||
display = metric.display_name if metric else metric_id
|
||||
self.visualization_widget.set_status(f"{display} computed in {seconds:.1f}s")
|
||||
|
||||
def on_metric_compute_error(self, file_path: str, metric_id: str, error_message: str):
|
||||
self.logger.error(f"Metric compute failed ({metric_id} / {os.path.basename(file_path)}): {error_message}")
|
||||
if file_path in self._overlay_paths():
|
||||
self.visualization_widget.set_status(f"Error computing {metric_id}: {error_message}")
|
||||
|
||||
def _current_file_path(self):
|
||||
item = self.file_list.currentItem()
|
||||
return item.data(Qt.UserRole) if item else None
|
||||
|
||||
def _item_for_path(self, file_path):
|
||||
for i in range(self.file_list.count()):
|
||||
item = self.file_list.item(i)
|
||||
if item.data(Qt.UserRole) == file_path:
|
||||
return item
|
||||
return None
|
||||
|
||||
def _row_index(self, file_path) -> int:
|
||||
for i in range(self.file_list.count()):
|
||||
if self.file_list.item(i).data(Qt.UserRole) == file_path:
|
||||
return i
|
||||
return 0
|
||||
|
||||
def _overlay_paths(self):
|
||||
"""File paths whose checkbox is ticked — the set drawn on the graph."""
|
||||
return [
|
||||
self.file_list.item(i).data(Qt.UserRole)
|
||||
for i in range(self.file_list.count())
|
||||
if self.file_list.item(i).checkState() == Qt.Checked
|
||||
]
|
||||
|
||||
def _refresh_view(self):
|
||||
"""Redraw the checked overlay set for the current metric and view-state.
|
||||
|
||||
Renders every dataset whose data is cached; for any that isn't, kicks off
|
||||
an async compute (or a full load if the file was never analysed) and
|
||||
leaves a status note. `on_metric_ready` calls back here when each lands.
|
||||
"""
|
||||
paths = self._overlay_paths()
|
||||
metric_id = self.plot_control.current_metric_id()
|
||||
view = self.plot_control.current_view_state()
|
||||
metric = METRICS.get(metric_id)
|
||||
if not paths or metric is None:
|
||||
self.visualization_widget.show_specs([])
|
||||
return
|
||||
|
||||
specs = []
|
||||
pending = 0
|
||||
for path in paths:
|
||||
data = self.analysis_manager.get_metric_data(path, metric_id)
|
||||
if data is None:
|
||||
if self.analysis_manager.is_file_analyzed(path):
|
||||
self.analysis_manager.request_metric(path, metric_id)
|
||||
else:
|
||||
self.analysis_manager.analyze_file(path, metric_id)
|
||||
pending += 1
|
||||
continue
|
||||
label = self.analysis_manager.display_label(path)
|
||||
# Colour is keyed to the file's row, not its position in the overlay
|
||||
# subset, so a song keeps its colour as others are ticked/unticked.
|
||||
color = dataset_color(self._row_index(path))
|
||||
spec = apply_x_mode(metric.build_spec(data, view), view.x_mode)
|
||||
specs.append((label, spec, color))
|
||||
|
||||
if specs:
|
||||
self.visualization_widget.show_specs(specs, view)
|
||||
if pending:
|
||||
self.visualization_widget.set_status(
|
||||
f"Computing {metric.display_name} for {pending} file(s)..."
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
# Parse logging arguments before creating QApplication
|
||||
log_level, log_to_file = parse_log_args()
|
||||
|
||||
# Initialize logging
|
||||
logger = setup_logging(log_level, log_to_file)
|
||||
logger.info("Starting Audio Mastering Analysis Toolkit")
|
||||
logger.info(f"Command line args: log-level={log_level}, log-file={log_to_file}")
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# Initialize font system (loads any fonts/ files, configures fallbacks) then
|
||||
# lock the UI font. M PLUS 1 Code has full Japanese coverage, so this stays
|
||||
# CJK-safe; falls back to the system default if the family isn't present.
|
||||
initialize_fonts()
|
||||
chosen = apply_fixed_font("M PLUS 1 Code", 10)
|
||||
logger.info(f"UI font locked to '{chosen}' at 10pt")
|
||||
|
||||
# Set application style
|
||||
app.setStyle('Fusion') # Modern cross-platform style
|
||||
logger.debug("Application style set to Fusion")
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
logger.info("GUI window displayed")
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
||||
QHBoxLayout, QSplitter, QLabel, QListWidget,
|
||||
QTextEdit, QListWidgetItem, QPushButton, QFileDialog)
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from audio_visualization_widget import AudioVisualizationWidget, dataset_color
|
||||
from analysis_results_manager import AnalysisResultsManager
|
||||
from logger_setup import setup_logging, parse_log_args
|
||||
from font_manager import initialize_fonts, apply_fixed_font
|
||||
from plot_control_widget import PlotControlWidget
|
||||
from ref_line_widget import RefLineControlWidget, RefLineDialog
|
||||
from metrics import METRICS
|
||||
from plotspec import RefLineProps, apply_x_mode
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Main application window with modular audio analysis interface."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.analysis_manager = AnalysisResultsManager()
|
||||
# Guards programmatic list mutations from triggering re-render storms.
|
||||
self._suppress_list_signals = False
|
||||
# Reference lines are kept per metric (a -14 LUFS line means nothing on a
|
||||
# spectrogram), so they persist when you switch metrics and come back.
|
||||
self.ref_lines_by_metric: dict[str, list[RefLineProps]] = {}
|
||||
self.initUI()
|
||||
self.connect_signals()
|
||||
self._activate_ref_lines()
|
||||
|
||||
def initUI(self):
|
||||
"""Initialize the user interface."""
|
||||
self.setWindowTitle('Audio Mastering Analysis Toolkit')
|
||||
self.setGeometry(100, 100, 1200, 700)
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
# Create central widget with splitter
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
# Main horizontal layout
|
||||
main_layout = QHBoxLayout(central_widget)
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
main_layout.addWidget(splitter)
|
||||
|
||||
# Left panel - File list and metadata
|
||||
left_panel = self.create_left_panel()
|
||||
splitter.addWidget(left_panel)
|
||||
|
||||
# Right panel - Visualization
|
||||
self.visualization_widget = AudioVisualizationWidget()
|
||||
splitter.addWidget(self.visualization_widget)
|
||||
|
||||
# Set splitter proportions
|
||||
splitter.setSizes([300, 900]) # Left panel narrower than visualization
|
||||
|
||||
def create_left_panel(self):
|
||||
"""Create the left panel with file list and metadata."""
|
||||
panel = QWidget()
|
||||
layout = QVBoxLayout(panel)
|
||||
|
||||
# Open File button
|
||||
self.open_file_button = QPushButton("Open Audio File...")
|
||||
self.open_file_button.clicked.connect(self.open_file_dialog)
|
||||
layout.addWidget(self.open_file_button)
|
||||
|
||||
# Plot control cluster (metric selector + scale toggle + refresh)
|
||||
self.plot_control = PlotControlWidget()
|
||||
self.plot_control.metricChanged.connect(self.on_metric_changed)
|
||||
self.plot_control.viewChanged.connect(self.on_view_changed)
|
||||
self.plot_control.plotRefreshRequested.connect(self.on_plot_refresh_requested)
|
||||
layout.addWidget(self.plot_control)
|
||||
|
||||
# Reference-line management cluster (list + add/edit/remove/clear).
|
||||
self.ref_line_control = RefLineControlWidget()
|
||||
self.ref_line_control.addRequested.connect(self.on_add_reference_line)
|
||||
self.ref_line_control.editRequested.connect(self.on_edit_reference_line)
|
||||
self.ref_line_control.removeRequested.connect(self.on_remove_reference_line)
|
||||
self.ref_line_control.clearRequested.connect(self.on_clear_reference_lines)
|
||||
layout.addWidget(self.ref_line_control)
|
||||
|
||||
# File list. Each item carries a checkbox: the checked set is the overlay
|
||||
# set drawn on the graph; the highlighted item drives the metadata panel.
|
||||
self.file_list_label = QLabel("Analyzed Files (tick to overlay):")
|
||||
layout.addWidget(self.file_list_label)
|
||||
|
||||
self.file_list = QListWidget()
|
||||
self.file_list.itemClicked.connect(self.on_file_selected)
|
||||
self.file_list.itemChanged.connect(self.on_file_check_changed)
|
||||
layout.addWidget(self.file_list)
|
||||
|
||||
# Metadata display
|
||||
self.metadata_label = QLabel("File Information:")
|
||||
layout.addWidget(self.metadata_label)
|
||||
|
||||
self.metadata_display = QTextEdit()
|
||||
self.metadata_display.setReadOnly(True)
|
||||
self.metadata_display.setMaximumHeight(150)
|
||||
layout.addWidget(self.metadata_display)
|
||||
|
||||
# Instructions
|
||||
instructions = QLabel(
|
||||
"Drag and drop audio files (.mp3, .wav) onto this window to analyze them."
|
||||
)
|
||||
instructions.setWordWrap(True)
|
||||
instructions.setStyleSheet("color: gray; font-style: italic;")
|
||||
layout.addWidget(instructions)
|
||||
|
||||
return panel
|
||||
|
||||
def connect_signals(self):
|
||||
"""Connect analysis manager signals to GUI updates."""
|
||||
self.analysis_manager.analysisStarted.connect(self.on_analysis_started)
|
||||
self.analysis_manager.analysisCompleted.connect(self.on_analysis_completed)
|
||||
self.analysis_manager.analysisError.connect(self.on_analysis_error)
|
||||
self.analysis_manager.progressUpdate.connect(self.on_progress_update)
|
||||
self.analysis_manager.metricComputeStarted.connect(self.on_metric_compute_started)
|
||||
self.analysis_manager.metricReady.connect(self.on_metric_ready)
|
||||
self.analysis_manager.metricComputeError.connect(self.on_metric_compute_error)
|
||||
self.analysis_manager.metricTiming.connect(self.on_metric_timing)
|
||||
self.visualization_widget.referenceLineMoved.connect(self.on_reference_line_moved)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Stop background analysis/prefetch threads before the window closes."""
|
||||
self.analysis_manager.shutdown()
|
||||
super().closeEvent(event)
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
"""Handle drag enter event for file drops."""
|
||||
if event.mimeData().hasUrls():
|
||||
# Check if any files have audio extensions
|
||||
urls = event.mimeData().urls()
|
||||
for url in urls:
|
||||
file_path = url.toLocalFile()
|
||||
if file_path.lower().endswith(('.mp3', '.wav', '.flac')):
|
||||
event.accept()
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
def dropEvent(self, event):
|
||||
"""Handle file drop event."""
|
||||
files = [u.toLocalFile() for u in event.mimeData().urls()]
|
||||
audio_files = [f for f in files if f.lower().endswith(('.mp3', '.wav', '.flac'))]
|
||||
|
||||
self.logger.info(f"Files dropped: {len(files)} total, {len(audio_files)} audio files")
|
||||
|
||||
if audio_files:
|
||||
# Analyze the first audio file
|
||||
# TODO: Add support for multiple file queue
|
||||
file_path = audio_files[0]
|
||||
self.logger.info(f"Starting analysis of dropped file: {os.path.basename(file_path)}")
|
||||
self.analysis_manager.analyze_file(file_path, self.plot_control.current_metric_id())
|
||||
else:
|
||||
self.visualization_widget.set_status("No audio files detected in drop")
|
||||
self.logger.warning("No supported audio files found in drop")
|
||||
|
||||
def open_file_dialog(self):
|
||||
"""Open file dialog to select audio files for analysis."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Select Audio File",
|
||||
"", # Default directory (empty = current directory)
|
||||
"Audio Files (*.mp3 *.wav *.flac);;All Files (*)"
|
||||
)
|
||||
|
||||
if file_path: # User selected a file (didn't cancel)
|
||||
self.logger.info(f"File selected via dialog: {os.path.basename(file_path)}")
|
||||
self.analysis_manager.analyze_file(file_path, self.plot_control.current_metric_id())
|
||||
|
||||
def on_analysis_started(self, file_path):
|
||||
"""Called when analysis starts."""
|
||||
filename = os.path.basename(file_path)
|
||||
self.visualization_widget.set_status(f"Analyzing: {filename}...")
|
||||
|
||||
def on_analysis_completed(self, file_path, result):
|
||||
"""Called when analysis completes successfully."""
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
# Add to file list (checked, so it joins the overlay set) if not present.
|
||||
item = self._item_for_path(file_path)
|
||||
if item is None:
|
||||
self._suppress_list_signals = True
|
||||
item = QListWidgetItem(filename)
|
||||
item.setData(Qt.UserRole, file_path) # Store full path
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||
item.setCheckState(Qt.Checked)
|
||||
self.file_list.addItem(item)
|
||||
self._suppress_list_signals = False
|
||||
|
||||
# Update metadata display and highlight the analyzed file.
|
||||
self.metadata_display.setText(self.analysis_manager.get_metadata_text(file_path))
|
||||
self.file_list.setCurrentItem(item)
|
||||
|
||||
# Redraw the overlay set for the current metric.
|
||||
self._refresh_view()
|
||||
|
||||
def on_analysis_error(self, file_path, error_message):
|
||||
"""Called when analysis fails."""
|
||||
filename = os.path.basename(file_path)
|
||||
self.logger.error(f"Analysis failed for {filename}: {error_message}")
|
||||
self.visualization_widget.set_status(f"Error analyzing {filename}: {error_message}")
|
||||
|
||||
def on_progress_update(self, message, percentage):
|
||||
"""Called when analysis progress updates.
|
||||
|
||||
The messages already carry phase + timing; the percentage was a coarse
|
||||
fake (load jumped 10->done), so it's logged but not shown in the slip.
|
||||
"""
|
||||
self.logger.debug(f"Progress: {message} ({percentage}%)")
|
||||
self.visualization_widget.set_status(message)
|
||||
|
||||
def on_file_selected(self, item):
|
||||
"""Called when a file is highlighted (drives the metadata panel only)."""
|
||||
file_path = item.data(Qt.UserRole)
|
||||
self.metadata_display.setText(self.analysis_manager.get_metadata_text(file_path))
|
||||
|
||||
def on_file_check_changed(self, _item):
|
||||
"""A checkbox toggled — the overlay set changed; redraw."""
|
||||
if self._suppress_list_signals:
|
||||
return
|
||||
self._refresh_view()
|
||||
|
||||
def on_metric_changed(self, metric_id: str):
|
||||
"""Called when the metric selector changes."""
|
||||
self.logger.info(f"Metric changed via GUI: {metric_id}")
|
||||
# Reference lines are kept per metric, so swap in this metric's set rather
|
||||
# than discarding — switch away and back and your lines are still there.
|
||||
self._activate_ref_lines()
|
||||
self._refresh_view()
|
||||
|
||||
def on_add_reference_line(self):
|
||||
"""Add a reference line at the current view centre, then edit it."""
|
||||
value = self.visualization_widget.current_view_center_value()
|
||||
props = RefLineProps(value=round(value, 2))
|
||||
self.ref_lines.append(props)
|
||||
self._sync_ref_lines()
|
||||
# Open the editor immediately so colour/tag/value can be set right away.
|
||||
self.on_edit_reference_line(len(self.ref_lines) - 1)
|
||||
|
||||
def on_edit_reference_line(self, index: int):
|
||||
"""Open the properties dialog for a reference line."""
|
||||
if not (0 <= index < len(self.ref_lines)):
|
||||
return
|
||||
dialog = RefLineDialog(self, self.ref_lines[index], value_units=self._ref_value_units())
|
||||
if dialog.exec_():
|
||||
self.ref_lines[index] = dialog.result_props()
|
||||
self._sync_ref_lines()
|
||||
|
||||
def on_remove_reference_line(self, index: int):
|
||||
"""Delete a reference line."""
|
||||
if 0 <= index < len(self.ref_lines):
|
||||
del self.ref_lines[index]
|
||||
self._sync_ref_lines()
|
||||
|
||||
def on_clear_reference_lines(self):
|
||||
"""Remove all custom reference lines for the current metric."""
|
||||
self.ref_lines.clear()
|
||||
self._sync_ref_lines()
|
||||
|
||||
def on_reference_line_moved(self, index: int):
|
||||
"""A line was dragged on the plot — its value is already updated; refresh list."""
|
||||
self.ref_line_control.set_lines(self.ref_lines)
|
||||
|
||||
def _activate_ref_lines(self):
|
||||
"""Point `self.ref_lines` at the current metric's set and sync the UI."""
|
||||
metric_id = self.plot_control.current_metric_id()
|
||||
self.ref_lines = self.ref_lines_by_metric.setdefault(metric_id, [])
|
||||
self._sync_ref_lines()
|
||||
|
||||
def _ref_value_units(self) -> str:
|
||||
"""Units a reference line's value is expressed in for the current metric."""
|
||||
return "Hz" if self.plot_control.current_metric_id() == "spectrogram" else ""
|
||||
|
||||
def _sync_ref_lines(self):
|
||||
"""Push the current reference-line set to both the list view and the plot."""
|
||||
self.ref_line_control.set_lines(self.ref_lines)
|
||||
self.visualization_widget.set_reference_lines(self.ref_lines)
|
||||
|
||||
def on_view_changed(self):
|
||||
"""Called when a view-scale toggle (lin/log) changes. Recompute-free redraw."""
|
||||
self.logger.info("View scale changed via GUI")
|
||||
self._refresh_view()
|
||||
|
||||
def on_plot_refresh_requested(self):
|
||||
"""Called when manual plot refresh is requested."""
|
||||
self.logger.info("Manual plot refresh requested via GUI")
|
||||
self._refresh_view()
|
||||
|
||||
def on_metric_compute_started(self, file_path: str, metric_id: str):
|
||||
"""Called when an off-thread metric compute starts."""
|
||||
if file_path not in self._overlay_paths():
|
||||
return # not in the drawn set; status bar shouldn't lie
|
||||
metric = METRICS.get(metric_id)
|
||||
display = metric.display_name if metric else metric_id
|
||||
self.visualization_widget.set_status(f"Computing {display}...")
|
||||
|
||||
def on_metric_ready(self, file_path: str, metric_id: str):
|
||||
"""Called when metric data is available (cached hit or async finish)."""
|
||||
if metric_id != self.plot_control.current_metric_id():
|
||||
return # user already switched to a different metric
|
||||
if file_path not in self._overlay_paths():
|
||||
return # no longer part of the overlay set
|
||||
self._refresh_view()
|
||||
|
||||
def on_metric_timing(self, file_path: str, metric_id: str, seconds: float):
|
||||
"""An on-demand metric compute finished — report how long it took."""
|
||||
if file_path not in self._overlay_paths():
|
||||
return
|
||||
if metric_id != self.plot_control.current_metric_id():
|
||||
return
|
||||
metric = METRICS.get(metric_id)
|
||||
display = metric.display_name if metric else metric_id
|
||||
self.visualization_widget.set_status(f"{display} computed in {seconds:.1f}s")
|
||||
|
||||
def on_metric_compute_error(self, file_path: str, metric_id: str, error_message: str):
|
||||
self.logger.error(f"Metric compute failed ({metric_id} / {os.path.basename(file_path)}): {error_message}")
|
||||
if file_path in self._overlay_paths():
|
||||
self.visualization_widget.set_status(f"Error computing {metric_id}: {error_message}")
|
||||
|
||||
def _current_file_path(self):
|
||||
item = self.file_list.currentItem()
|
||||
return item.data(Qt.UserRole) if item else None
|
||||
|
||||
def _item_for_path(self, file_path):
|
||||
for i in range(self.file_list.count()):
|
||||
item = self.file_list.item(i)
|
||||
if item.data(Qt.UserRole) == file_path:
|
||||
return item
|
||||
return None
|
||||
|
||||
def _row_index(self, file_path) -> int:
|
||||
for i in range(self.file_list.count()):
|
||||
if self.file_list.item(i).data(Qt.UserRole) == file_path:
|
||||
return i
|
||||
return 0
|
||||
|
||||
def _overlay_paths(self):
|
||||
"""File paths whose checkbox is ticked — the set drawn on the graph."""
|
||||
return [
|
||||
self.file_list.item(i).data(Qt.UserRole)
|
||||
for i in range(self.file_list.count())
|
||||
if self.file_list.item(i).checkState() == Qt.Checked
|
||||
]
|
||||
|
||||
def _refresh_view(self):
|
||||
"""Redraw the checked overlay set for the current metric and view-state.
|
||||
|
||||
Renders every dataset whose data is cached; for any that isn't, kicks off
|
||||
an async compute (or a full load if the file was never analysed) and
|
||||
leaves a status note. `on_metric_ready` calls back here when each lands.
|
||||
"""
|
||||
paths = self._overlay_paths()
|
||||
metric_id = self.plot_control.current_metric_id()
|
||||
view = self.plot_control.current_view_state()
|
||||
metric = METRICS.get(metric_id)
|
||||
if not paths or metric is None:
|
||||
self.visualization_widget.show_specs([])
|
||||
return
|
||||
|
||||
specs = []
|
||||
pending = 0
|
||||
for path in paths:
|
||||
data = self.analysis_manager.get_metric_data(path, metric_id)
|
||||
if data is None:
|
||||
if self.analysis_manager.is_file_analyzed(path):
|
||||
self.analysis_manager.request_metric(path, metric_id)
|
||||
else:
|
||||
self.analysis_manager.analyze_file(path, metric_id)
|
||||
pending += 1
|
||||
continue
|
||||
label = self.analysis_manager.display_label(path)
|
||||
# Colour is keyed to the file's row, not its position in the overlay
|
||||
# subset, so a song keeps its colour as others are ticked/unticked.
|
||||
color = dataset_color(self._row_index(path))
|
||||
spec = apply_x_mode(metric.build_spec(data, view), view.x_mode)
|
||||
specs.append((label, spec, color))
|
||||
|
||||
if specs:
|
||||
self.visualization_widget.show_specs(specs, view)
|
||||
if pending:
|
||||
self.visualization_widget.set_status(
|
||||
f"Computing {metric.display_name} for {pending} file(s)..."
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
# Parse logging arguments before creating QApplication
|
||||
log_level, log_to_file = parse_log_args()
|
||||
|
||||
# Initialize logging
|
||||
logger = setup_logging(log_level, log_to_file)
|
||||
logger.info("Starting Audio Mastering Analysis Toolkit")
|
||||
logger.info(f"Command line args: log-level={log_level}, log-file={log_to_file}")
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# Initialize font system (loads any fonts/ files, configures fallbacks) then
|
||||
# lock the UI font. M PLUS 1 Code has full Japanese coverage, so this stays
|
||||
# CJK-safe; falls back to the system default if the family isn't present.
|
||||
initialize_fonts()
|
||||
chosen = apply_fixed_font("M PLUS 1 Code", 10)
|
||||
logger.info(f"UI font locked to '{chosen}' at 10pt")
|
||||
|
||||
# Set application style
|
||||
app.setStyle('Fusion') # Modern cross-platform style
|
||||
logger.debug("Application style set to Fusion")
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
logger.info("GUI window displayed")
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+8
-1
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
import os
|
||||
|
||||
import librosa
|
||||
@@ -6,7 +7,13 @@ import numpy as np
|
||||
from mutagen.mp3 import MP3
|
||||
from mutagen.easyid3 import EasyID3
|
||||
|
||||
from font_manager import safe_title
|
||||
|
||||
def safe_title(title):
|
||||
# Deliberately not font_manager.safe_title: that module imports PyQt5 at load
|
||||
# time, and master_core is on the Qt-free web path (the `web` extra has no Qt).
|
||||
if not title:
|
||||
return title
|
||||
return title.encode('utf-8', errors='replace').decode('utf-8')
|
||||
|
||||
|
||||
def _try_mp3_tags(file_path):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
Pluggable analysis metrics.
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
Plot control widget: pick the metric, set axis scale/mode, refresh the plot.
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
Backend-agnostic plot descriptors.
|
||||
|
||||
|
||||
+22
-1
@@ -6,12 +6,25 @@ build-backend = "setuptools.build_meta"
|
||||
name = "uj-mastering-master"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
# What the *analysis* needs. The GUI stack lives in the `desktop` extra below.
|
||||
# The web UI (web/) is a static site that analyses audio in the browser and needs
|
||||
# none of these; `tests/parity` uses them to check the browser port against
|
||||
# `metrics.py`.
|
||||
dependencies = [
|
||||
"librosa",
|
||||
"numpy",
|
||||
"matplotlib",
|
||||
"matplotlib", # librosa dependency, and colormaps for the colormap helper
|
||||
"mutagen",
|
||||
"pyloudnorm",
|
||||
]
|
||||
|
||||
license = {text = "Apache-2.0"}
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Desktop GUI. Installed with:
|
||||
# uv sync --extra desktop (or `uv sync --all-extras`)
|
||||
desktop = [
|
||||
"PyQt5>=5.15.10",
|
||||
# 5.15.2 is the only pyqt5-qt5 release with a Windows wheel; later
|
||||
# versions are Linux/macOS only.
|
||||
@@ -19,6 +32,14 @@ dependencies = [
|
||||
"pyqtgraph>=0.14.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
# Note: `default-groups` cannot name an entry from `[project.optional-dependencies]`
|
||||
# — it only accepts `[dependency-groups]` names, and errors with "Default group
|
||||
# `desktop` ... is not defined in the project's `dependency-groups` table".
|
||||
# Extras must be requested explicitly:
|
||||
# uv sync # analysis only
|
||||
# uv sync --extra desktop # + Qt/pyqtgraph (the GUI; `uv run ujm`)
|
||||
|
||||
[project.scripts]
|
||||
ujm = "main:main"
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
Reference-line management: a side-panel list of custom horizontal markers plus a
|
||||
properties dialog.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Font setup utility for CJK character support.
|
||||
|
||||
@@ -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()
|
||||
@@ -1680,6 +1680,10 @@ dependencies = [
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "pyloudnorm" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
desktop = [
|
||||
{ name = "pyqt5" },
|
||||
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pyqtgraph" },
|
||||
@@ -1692,10 +1696,11 @@ requires-dist = [
|
||||
{ name = "mutagen" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pyloudnorm" },
|
||||
{ name = "pyqt5", specifier = ">=5.15.10" },
|
||||
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'", specifier = "==5.15.2" },
|
||||
{ name = "pyqtgraph", specifier = ">=0.14.0" },
|
||||
{ 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" },
|
||||
]
|
||||
provides-extras = ["desktop"]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Analysis worker: the browser's AnalysisResultsManager worker thread.
|
||||
//
|
||||
// Holds each loaded track's mono signal (transferred in, never copied) and runs
|
||||
// one metric at a time on request, so a 10-minute spectrogram never blocks the
|
||||
// page. Everything stays in this tab's memory; closing the tab frees it.
|
||||
//
|
||||
// Messages in: {type: 'load', trackId, mono, sampleRate}
|
||||
// {type: 'compute', reqId, trackId, metricId}
|
||||
// {type: 'drop', trackId}
|
||||
// Messages out: {type: 'result', reqId, trackId, metricId, data, ms}
|
||||
// {type: 'error', reqId, trackId, metricId, message}
|
||||
|
||||
import { COMPUTE, transferables } from './dsp.js';
|
||||
|
||||
const tracks = new Map(); // trackId -> {mono, sampleRate, memo}
|
||||
|
||||
self.onmessage = (event) => {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'load') {
|
||||
tracks.set(msg.trackId, { mono: msg.mono, sampleRate: msg.sampleRate, memo: {} });
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'drop') {
|
||||
tracks.delete(msg.trackId);
|
||||
return;
|
||||
}
|
||||
if (msg.type !== 'compute') return;
|
||||
|
||||
const { reqId, trackId, metricId } = msg;
|
||||
const track = tracks.get(trackId);
|
||||
const compute = COMPUTE[metricId];
|
||||
if (!track || !compute) {
|
||||
self.postMessage({
|
||||
type: 'error', reqId, trackId, metricId,
|
||||
message: track ? `unknown metric ${metricId}` : 'track is no longer loaded',
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
const data = compute(track.mono, track.sampleRate, track.memo);
|
||||
const ms = performance.now() - t0;
|
||||
self.postMessage({ type: 'result', reqId, trackId, metricId, data, ms }, transferables(data));
|
||||
} catch (err) {
|
||||
self.postMessage({ type: 'error', reqId, trackId, metricId, message: String((err && err.message) || err) });
|
||||
}
|
||||
};
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
/* Audio Mastering Analysis Toolkit — web front end.
|
||||
Dark by default: a spectrogram is read against its surroundings, and the
|
||||
desktop app's pythonic defaults are what these numbers were tuned against. */
|
||||
|
||||
:root {
|
||||
--bg: #12141a;
|
||||
--panel: #191c24;
|
||||
--panel-2: #1f232d;
|
||||
--line: #2b303c;
|
||||
--text: #e8eaf0;
|
||||
--muted: #939aa8;
|
||||
--accent: #4fc3f7;
|
||||
--accent-dim: #2b7fa3;
|
||||
--warn: #ffb74d;
|
||||
--error: #ef5350;
|
||||
--ok: #81c784;
|
||||
--radius: 6px;
|
||||
/* M PLUS 1 Code (the desktop app's font) where installed, else the system UI font. */
|
||||
--font: "M PLUS 1 Code", system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif;
|
||||
--mono: "M PLUS 1 Code", ui-monospace, "Cascadia Mono", "Source Code Pro", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* The UA's [hidden] { display: none } loses to any class that sets display
|
||||
(e.g. .drop-overlay's flex), which left the overlay permanently covering the
|
||||
page. Make the attribute win everywhere. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 13px/1.45 var(--font);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- top bar ---- */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
height: 48px;
|
||||
padding: 0 14px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: baseline; gap: 8px; }
|
||||
.logo { color: var(--accent); font-size: 16px; }
|
||||
.brand-name { font-weight: 600; letter-spacing: .2px; }
|
||||
.brand-sub {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--bg);
|
||||
background: var(--accent-dim);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.topbar-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.field { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
|
||||
|
||||
select, .btn, .toggle {
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 5px 9px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
select:hover, .btn:hover, .toggle:hover { border-color: var(--accent-dim); }
|
||||
select:focus-visible, .btn:focus-visible, .toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
.btn.primary { background: var(--accent-dim); border-color: var(--accent); color: #eaf7ff; }
|
||||
.btn.primary:hover { background: var(--accent); color: #06202b; }
|
||||
.btn.small { padding: 3px 7px; font-size: 11px; }
|
||||
.btn:disabled, .toggle:disabled { opacity: .45; cursor: not-allowed; }
|
||||
|
||||
.toggle.on { border-color: var(--accent); color: var(--accent); }
|
||||
.toggle.off { color: var(--muted); }
|
||||
|
||||
.separator { width: 1px; height: 22px; background: var(--line); }
|
||||
|
||||
/* ---- layout ---- */
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 288px minmax(0, 1fr);
|
||||
height: calc(100% - 48px);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px;
|
||||
}
|
||||
.panel h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .8px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.badge {
|
||||
background: var(--line);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 0 6px;
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- file list ---- */
|
||||
|
||||
.file-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 5px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.file-row:hover { background: #252a35; }
|
||||
.file-row.selected { background: #232a38; border-color: var(--accent-dim); }
|
||||
.file-row input[type=checkbox] { margin: 0; accent-color: var(--accent-dim); flex: none; }
|
||||
.file-swatch { width: 9px; height: 9px; border-radius: 2px; flex: none; }
|
||||
.file-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
}
|
||||
.file-lufs { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.file-remove {
|
||||
flex: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
opacity: 0;
|
||||
}
|
||||
.file-row:hover .file-remove { opacity: 1; }
|
||||
.file-remove:hover { color: var(--error); }
|
||||
|
||||
.empty, .hint { color: var(--muted); font-size: 11.5px; margin: 4px 0 0; }
|
||||
.hint code { background: #000; padding: 0 3px; border-radius: 3px; }
|
||||
|
||||
/* ---- metadata ---- */
|
||||
|
||||
.metadata { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; margin: 0; font-size: 12px; }
|
||||
.metadata dt { color: var(--muted); }
|
||||
.metadata dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||
.metadata dd.muted { color: var(--muted); }
|
||||
.metadata .group { margin-top: 7px; }
|
||||
.metadata dd.strong { font-weight: 600; }
|
||||
|
||||
/* ---- reference lines ---- */
|
||||
|
||||
.ref-list { list-style: none; margin: 0 0 8px; padding: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.ref-row { display: flex; align-items: center; gap: 6px; font-size: 12px; }
|
||||
.ref-row input[type=color] { width: 20px; height: 18px; padding: 0; border: 1px solid var(--line); background: none; }
|
||||
.ref-row input[type=text] { width: 62px; }
|
||||
.ref-row input[type=number] { width: 62px; }
|
||||
.ref-row input { background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: 4px; padding: 2px 4px; font: inherit; font-size: 11px; }
|
||||
.ref-row .ref-remove { border: 0; background: none; color: var(--muted); cursor: pointer; }
|
||||
.ref-row .ref-remove:hover { color: var(--error); }
|
||||
.ref-actions { display: flex; gap: 6px; }
|
||||
|
||||
/* ---- plot stage ---- */
|
||||
|
||||
.stage { display: flex; flex-direction: column; min-width: 0; padding: 10px 12px 0; }
|
||||
|
||||
.plot-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 24px; }
|
||||
.plot-head h1 { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.plot-hint { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.plot-host {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
margin-top: 6px;
|
||||
background: #0d0f14;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.plot-host .uplot { position: absolute; inset: 0; width: 100% !important; height: 100% !important; }
|
||||
.plot-host canvas.heatmap { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
|
||||
/* In-plot legend, like pyqtgraph's: swatches per track plus scalar readouts.
|
||||
pointer-events: none so zoom and pan work straight through it. */
|
||||
.plot-legend {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-width: 46%;
|
||||
padding: 6px 9px;
|
||||
background: rgba(13, 15, 20, .82);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.plot-legend .entry { display: flex; align-items: center; gap: 7px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.plot-legend .swatch { width: 14px; height: 3px; border-radius: 1px; flex: none; }
|
||||
.plot-legend .note { color: var(--muted); padding-left: 21px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
body.dragging-pan, body.dragging-pan * { cursor: grabbing !important; user-select: none; }
|
||||
body.dragging-ref, body.dragging-ref * { cursor: ns-resize !important; user-select: none; }
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
gap: 6px;
|
||||
padding: 20px;
|
||||
}
|
||||
.ph-title { font-size: 15px; color: var(--text); margin: 0; }
|
||||
.ph-body { margin: 0; max-width: 44ch; font-size: 12px; }
|
||||
.ph-body code { background: #000; padding: 0 3px; border-radius: 3px; }
|
||||
|
||||
.statusbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 2px;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
min-height: 24px;
|
||||
}
|
||||
.status.working { color: var(--accent); }
|
||||
.status.error { color: var(--error); }
|
||||
.status.warn { color: var(--warn); }
|
||||
.timing { font-family: var(--mono); }
|
||||
|
||||
/* ---- drag & drop ---- */
|
||||
|
||||
.drop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 20, 30, .82);
|
||||
border: 3px dashed var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: var(--accent);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
/* ---- tooltip ---- */
|
||||
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
background: #05070b;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 5px 7px;
|
||||
font: 11px/1.4 var(--mono);
|
||||
color: var(--text);
|
||||
pointer-events: none;
|
||||
z-index: 30;
|
||||
white-space: nowrap;
|
||||
}
|
||||
+1481
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];
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Audio Mastering Analysis Toolkit</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="vendor/uPlot.min.css">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="logo">◉</span>
|
||||
<span class="brand-name">uj-mastering-master</span>
|
||||
<span class="brand-sub">web</span>
|
||||
</div>
|
||||
<div class="topbar-controls">
|
||||
<label class="field">
|
||||
<span>Metric</span>
|
||||
<select id="metric-select" title="Which metric to display"></select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>X axis</span>
|
||||
<select id="x-mode-select" title="Absolute time, or each track normalised to its own length">
|
||||
<option value="absolute">Absolute (s)</option>
|
||||
<option value="relative">Relative (%)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="toggle-y-log" class="toggle" type="button">Y: log</button>
|
||||
<button id="refresh-plot" class="btn" type="button" title="Redraw from the computed results">Refresh</button>
|
||||
<div class="separator"></div>
|
||||
<button id="pick-files" class="btn primary" type="button">Add audio…</button>
|
||||
<input id="file-input" type="file" accept=".mp3,.wav,.flac,.ogg,.oga,.m4a,.aif,.aiff,.aifc,.opus" multiple hidden>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
|
||||
<aside class="sidebar">
|
||||
<section class="panel">
|
||||
<h2>Files <span id="file-count" class="badge">0</span></h2>
|
||||
<ul id="file-list" class="file-list"></ul>
|
||||
<p id="file-empty" class="empty">Drop audio files here, or use <em>Add audio…</em>.
|
||||
They are analysed in this tab and never uploaded.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Metadata</h2>
|
||||
<dl id="metadata" class="metadata">
|
||||
<dt>Track</dt><dd class="muted">nothing selected</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Reference lines <span id="ref-count" class="badge">0</span></h2>
|
||||
<ul id="ref-list" class="ref-list"></ul>
|
||||
<div class="ref-actions">
|
||||
<button id="add-ref" class="btn small" type="button">Add at centre</button>
|
||||
<button id="clear-refs" class="btn small" type="button">Clear</button>
|
||||
</div>
|
||||
<p class="hint">Drag a line on the plot to move it. Sets are kept per metric.</p>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="stage">
|
||||
<div class="plot-head">
|
||||
<h1 id="plot-title">No track loaded</h1>
|
||||
<div class="plot-hint">Scroll to zoom · Ctrl: time only · Shift: values only · drag to pan · double-click to reset</div>
|
||||
</div>
|
||||
|
||||
<div id="plot-host" class="plot-host">
|
||||
<div id="placeholder" class="placeholder">
|
||||
<p class="ph-title">Audio Mastering Analysis Toolkit</p>
|
||||
<p class="ph-body">Drop <code>.wav</code>, <code>.flac</code> or <code>.mp3</code> files to analyse.
|
||||
Decoding and every metric run inside this tab: the audio is never uploaded
|
||||
or saved anywhere (this page isn't even permitted to make network requests),
|
||||
and closing the tab discards it. Analysis runs at the file's native sample rate.</p>
|
||||
</div>
|
||||
<canvas id="heatmap" class="heatmap" hidden></canvas>
|
||||
<div id="plot-legend" class="plot-legend" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="statusbar">
|
||||
<span id="status" class="status">Ready</span>
|
||||
<span id="timing" class="timing"></span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="drop-overlay" class="drop-overlay" hidden><span>Drop to analyse</span></div>
|
||||
|
||||
<script src="vendor/uPlot.iife.min.js"></script>
|
||||
<!-- app.js is an ES module: it decodes with decode.js and runs the metrics
|
||||
from dsp.js in analysis-worker.js. -->
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+184
@@ -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,
|
||||
};
|
||||
}
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}
|
||||
Reference in New Issue
Block a user