2 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
3 changed files with 22 additions and 40 deletions
+8 -8
View File
@@ -100,7 +100,7 @@ async function api(path, options) {
} }
async function loadMetrics() { async function loadMetrics() {
const data = await api('/api/metrics'); const data = await api('api/metrics');
state.metrics = data.metrics; state.metrics = data.metrics;
state.metric = data.default; state.metric = data.default;
const select = el('metric-select'); const select = el('metric-select');
@@ -134,7 +134,7 @@ function renderFileList() {
check.addEventListener('click', (ev) => ev.stopPropagation()); check.addEventListener('click', (ev) => ev.stopPropagation());
check.addEventListener('change', async () => { check.addEventListener('change', async () => {
const ids = collectOverlayFromDOM(); const ids = collectOverlayFromDOM();
await api('/api/overlay', { await api('api/overlay', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_ids: ids }), 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.title = 'Forget this track (drops the analysis and removes it from browser storage)';
remove.addEventListener('click', async (ev) => { remove.addEventListener('click', async (ev) => {
ev.stopPropagation(); 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. // The server only ever held numbers; the audio is ours to delete.
await store.remove(f.file_id); await store.remove(f.file_id);
releaseObjectUrl(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.yLog !== null) params.set('y_log', String(state.yLog));
if (state.xLog !== null) params.set('x_log', String(state.xLog)); 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; state.payload = payload;
el('plot-title').textContent = payload.datasets.length el('plot-title').textContent = payload.datasets.length
? `${payload.metric_name}${payload.datasets.length > 1 ? ` — ${payload.datasets.length} tracks` : ` — ${payload.datasets[0].label}`}` ? `${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.files = s.files;
state.overlay = s.overlay; state.overlay = s.overlay;
if (!state.selected && state.files.length) state.selected = state.files[0].file_id; 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`. * second round trip) and is recorded in `state.analysed`.
*/ */
async function analyseOne(fileId, name, metric = state.metric) { async function analyseOne(fileId, name, metric = state.metric) {
const url = `/api/analyse?` + new URLSearchParams({ const url = `api/analyse?` + new URLSearchParams({
file_id: fileId, file_id: fileId,
name, name,
metric: metric || state.metric || '', metric: metric || state.metric || '',
@@ -972,7 +972,7 @@ async function analyseOne(fileId, name, metric = state.metric) {
function ensureOverlay(fileId) { function ensureOverlay(fileId) {
if (!state.overlay.includes(fileId)) { if (!state.overlay.includes(fileId)) {
state.overlay = [...state.overlay, fileId]; state.overlay = [...state.overlay, fileId];
api('/api/overlay', { api('api/overlay', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_ids: state.overlay }), body: JSON.stringify({ file_ids: state.overlay }),
@@ -1129,7 +1129,7 @@ async function boot() {
} }
await loadMetrics(); await loadMetrics();
const s = await api('/api/session'); const s = await api('api/session');
state.files = s.files; state.files = s.files;
state.overlay = s.overlay; state.overlay = s.overlay;
state.selected = state.files.length ? state.files[0].file_id : null; state.selected = state.files.length ? state.files[0].file_id : null;
+10 -28
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 * The OPFS File is passed to fetch as-is: the browser reads a Blob body lazily
* be materialised as a second in-memory copy just to be uploaded. * 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} = {}) { export async function send(fileId, url, {method = 'POST', onProgress} = {}) {
const blob = await get(fileId); const blob = await get(fileId);
if (!blob) throw new Error('audio is no longer in browser storage — re-add the file'); 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 const res = await fetch(url, {
// requests, and below a few MB there is nothing to gain.
if (blob.size < 8 * 1024 * 1024) {
const buf = await blob.arrayBuffer();
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, method,
headers: {'Content-Type': 'application/octet-stream'}, headers: {'Content-Type': 'application/octet-stream'},
body, body: blob,
duplex: 'half',
}); });
if (onProgress) onProgress(blob.size, blob.size);
return res;
} }
+4 -4
View File
@@ -4,8 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Audio Mastering Analysis Toolkit</title> <title>Audio Mastering Analysis Toolkit</title>
<link rel="stylesheet" href="/static/vendor/uPlot.min.css"> <link rel="stylesheet" href="static/vendor/uPlot.min.css">
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="static/app.css">
</head> </head>
<body> <body>
@@ -89,9 +89,9 @@
<div id="drop-overlay" class="drop-overlay" hidden><span>Drop to analyse</span></div> <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 <!-- app.js is an ES module: it imports ./audio-store.js, which owns the
browser-side copy of every uploaded file. --> browser-side copy of every uploaded file. -->
<script type="module" src="/static/app.js"></script> <script type="module" src="static/app.js"></script>
</body> </body>
</html> </html>