4 Commits

Author SHA1 Message Date
mikkeli b5c12bfcb4 Send stored audio as a Blob body so large files upload on HTTP/1.1
Files over 8 MB were sent as a ReadableStream with duplex: 'half'. Chrome
only allows streaming request bodies over HTTP/2+, so every such upload
failed with "Failed to fetch" on HTTP/1.1 (e.g. http://localhost). A Blob
body is read lazily from OPFS by the browser, so it keeps the "never hold
the whole master in memory" property without depending on the protocol.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
2026-09-14 02:33:28 +09:00
5 changed files with 34 additions and 41 deletions
+7 -1
View File
@@ -7,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):
+5
View File
@@ -20,6 +20,11 @@
* { 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%;
+8 -8
View File
@@ -100,7 +100,7 @@ async function api(path, options) {
}
async function loadMetrics() {
const data = await api('/api/metrics');
const data = await api('api/metrics');
state.metrics = data.metrics;
state.metric = data.default;
const select = el('metric-select');
@@ -134,7 +134,7 @@ function renderFileList() {
check.addEventListener('click', (ev) => ev.stopPropagation());
check.addEventListener('change', async () => {
const ids = collectOverlayFromDOM();
await api('/api/overlay', {
await api('api/overlay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_ids: ids }),
@@ -159,7 +159,7 @@ function renderFileList() {
remove.title = 'Forget this track (drops the analysis and removes it from browser storage)';
remove.addEventListener('click', async (ev) => {
ev.stopPropagation();
await api(`/api/file?file_id=${encodeURIComponent(f.file_id)}`, { method: 'DELETE' });
await api(`api/file?file_id=${encodeURIComponent(f.file_id)}`, { method: 'DELETE' });
// The server only ever held numbers; the audio is ours to delete.
await store.remove(f.file_id);
releaseObjectUrl(f.file_id);
@@ -871,7 +871,7 @@ async function refreshPlot() {
if (state.yLog !== null) params.set('y_log', String(state.yLog));
if (state.xLog !== null) params.set('x_log', String(state.xLog));
const payload = await api(`/api/plot?${params.toString()}`);
const payload = await api(`api/plot?${params.toString()}`);
state.payload = payload;
el('plot-title').textContent = payload.datasets.length
? `${payload.metric_name}${payload.datasets.length > 1 ? ` — ${payload.datasets.length} tracks` : ` — ${payload.datasets[0].label}`}`
@@ -933,7 +933,7 @@ async function ingest(fileList) {
}
}
const s = await api('/api/session');
const s = await api('api/session');
state.files = s.files;
state.overlay = s.overlay;
if (!state.selected && state.files.length) state.selected = state.files[0].file_id;
@@ -952,7 +952,7 @@ async function ingest(fileList) {
* second round trip) and is recorded in `state.analysed`.
*/
async function analyseOne(fileId, name, metric = state.metric) {
const url = `/api/analyse?` + new URLSearchParams({
const url = `api/analyse?` + new URLSearchParams({
file_id: fileId,
name,
metric: metric || state.metric || '',
@@ -972,7 +972,7 @@ async function analyseOne(fileId, name, metric = state.metric) {
function ensureOverlay(fileId) {
if (!state.overlay.includes(fileId)) {
state.overlay = [...state.overlay, fileId];
api('/api/overlay', {
api('api/overlay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_ids: state.overlay }),
@@ -1129,7 +1129,7 @@ async function boot() {
}
await loadMetrics();
const s = await api('/api/session');
const s = await api('api/session');
state.files = s.files;
state.overlay = s.overlay;
state.selected = state.files.length ? state.files[0].file_id : null;
+12 -30
View File
@@ -165,41 +165,23 @@ export async function sweepIncomplete() {
}
/**
* Stream stored audio to an endpoint as a raw body.
* Send stored audio to an endpoint as a raw body.
*
* The body is sent as a stream (duplex: 'half') so a 200 MB file doesn't have to
* be materialised as a second in-memory copy just to be uploaded.
* The OPFS File is passed to fetch as-is: the browser reads a Blob body lazily
* from its backing store, so a 200 MB master is never materialised in memory.
* (A ReadableStream body with duplex: 'half' was used before, but Chrome only
* allows streaming uploads over HTTP/2+, so any file past the old 8 MB cutoff
* failed with "Failed to fetch" on HTTP/1.1 — e.g. http://localhost.)
*/
export async function send(fileId, url, {method = 'POST', onProgress} = {}) {
const blob = await get(fileId);
if (!blob) throw new Error('audio is no longer in browser storage — re-add the file');
// Small files go as a plain body: some proxies mishandle streaming duplex
// requests, and below a few MB there is nothing to gain.
if (blob.size < 8 * 1024 * 1024) {
const buf = await blob.arrayBuffer();
const res = await fetch(url, {
method,
headers: {'Content-Type': 'application/octet-stream'},
body: blob,
});
if (onProgress) onProgress(blob.size, blob.size);
return fetch(url, {
method,
headers: {'Content-Type': 'application/octet-stream'},
body: buf,
});
}
let sent = 0;
const body = blob.stream().pipeThrough(
new TransformStream({
transform(chunk, controller) {
sent += chunk.byteLength;
if (onProgress) onProgress(sent, blob.size);
controller.enqueue(chunk);
},
}),
);
return fetch(url, {
method,
headers: {'Content-Type': 'application/octet-stream'},
body,
duplex: 'half',
});
return res;
}
+4 -4
View File
@@ -4,8 +4,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Audio Mastering Analysis Toolkit</title>
<link rel="stylesheet" href="/static/vendor/uPlot.min.css">
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="static/vendor/uPlot.min.css">
<link rel="stylesheet" href="static/app.css">
</head>
<body>
@@ -89,9 +89,9 @@
<div id="drop-overlay" class="drop-overlay" hidden><span>Drop to analyse</span></div>
<script src="/static/vendor/uPlot.iife.min.js"></script>
<script src="static/vendor/uPlot.iife.min.js"></script>
<!-- app.js is an ES module: it imports ./audio-store.js, which owns the
browser-side copy of every uploaded file. -->
<script type="module" src="/static/app.js"></script>
<script type="module" src="static/app.js"></script>
</body>
</html>