Loudness levelling (EBU R128, -14 LUFS) measured in the browser with WebAssembly
- frontend/wasm/loudness.c → loudness-wasm.js (1.5 KB, base64-embedded; built by scripts/build-loudness-wasm.sh with clang or Zig's clang): K-weighting + 100 ms energy steps. loudness.js does the 400 ms gating (abs -70 LUFS, rel -10 LU) with an identical JS fallback. EBU Tech 3341 sine cases measure -23.0/-33.0 on both engines; a real track reads -21.7 vs ffmpeg ebur128 -21.8. - The device measures from audio it already has (saved copy or the server cache's m4a sidecar, songs up to 12 min, decoded natively at 22.05 kHz) and reports the number to /api/loudness, so each song is measured once for everyone; the server stores numbers only, no audio processing. - The EQ graph gains a level stage (gain -> limiter) that eases each track toward -14 LUFS (-12..+9 dB). Settings -> Playback -> Level volume: on by default on Android/desktop, opt-in on iPhone (Web Audio stops sound when the screen locks there).
This commit is contained in:
25
server/db.js
25
server/db.js
@@ -240,6 +240,14 @@ export async function initDb() {
|
||||
for (const col of ['owner TEXT', 'listed INTEGER NOT NULL DEFAULT 1']) {
|
||||
try { await db.execute(`ALTER TABLE uploads ADD COLUMN ${col}`); } catch { /* already there */ }
|
||||
}
|
||||
// Integrated loudness (EBU R128 LUFS) measured by devices in the browser and
|
||||
// shared, so each song is measured once for everyone.
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS loudness (
|
||||
id TEXT PRIMARY KEY,
|
||||
lufs REAL NOT NULL,
|
||||
n INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)`);
|
||||
// Chapters YouTube publishes for a video (from yt-dlp's `chapters`), cached so
|
||||
// a server-cached copy can show them without another yt-dlp call.
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS yt_chapters (
|
||||
@@ -846,3 +854,20 @@ export async function setYtChapters(id, items) {
|
||||
args: [id, JSON.stringify(items || [])],
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Shared loudness (device-measured) --------------------------------------
|
||||
export async function getLoudness(id) {
|
||||
const r = await db.execute({ sql: 'SELECT lufs, n FROM loudness WHERE id = ?', args: [id] });
|
||||
return r.rows[0] ? { lufs: Number(r.rows[0].lufs), n: Number(r.rows[0].n) } : null;
|
||||
}
|
||||
// Running mean of the first 10 reports, then it stops moving.
|
||||
export async function addLoudness(id, lufs) {
|
||||
await db.execute({
|
||||
sql: `INSERT INTO loudness (id, lufs, n, updated_at) VALUES (?, ?, 1, unixepoch())
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
lufs = CASE WHEN n >= 10 THEN lufs ELSE (lufs * n + excluded.lufs) / (n + 1) END,
|
||||
n = CASE WHEN n >= 10 THEN n ELSE n + 1 END,
|
||||
updated_at = unixepoch()`,
|
||||
args: [id, lufs],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import { Readable } from 'node:stream';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { brotliCompressSync, constants as zlibConstants } from 'node:zlib';
|
||||
import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, setProfileSecret, getYtChapters, setYtChapters, createSharedPlaylist, getSharedPlaylist, queueInboxPlaylist, listInbox, deleteInboxItem, countInbox,
|
||||
import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, setProfileSecret, getYtChapters, setYtChapters, getLoudness, addLoudness, createSharedPlaylist, getSharedPlaylist, queueInboxPlaylist, listInbox, deleteInboxItem, countInbox,
|
||||
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats } from './db.js';
|
||||
import { createMediaCache, HIGH, LOW, validateMedia, MediaSkip } from './media-cache.js';
|
||||
import * as notesDb from './db.js';
|
||||
@@ -766,6 +766,27 @@ function countView(c, videoId) {
|
||||
}
|
||||
|
||||
// GET /api/streams?v=<videoId> — meta + proxied audio/quality URLs.
|
||||
// Loudness levelling (EBU R128). Devices measure in the browser (WebAssembly)
|
||||
// and report here; the server only stores numbers — no audio processing.
|
||||
// GET /api/loudness?v=<id> → { lufs | null }
|
||||
// POST /api/loudness { id, lufs } → stored (running mean of 10 reports)
|
||||
app.get('/api/loudness', async (c) => {
|
||||
const id = (c.req.query('v') || '').trim();
|
||||
if (!media.isMediaId(id) && !isUpload(id)) return c.json({ ok: true, lufs: null });
|
||||
const row = await getLoudness(id).catch(() => null);
|
||||
return c.json({ ok: true, lufs: row ? Math.round(row.lufs * 10) / 10 : null }, 200, { 'Cache-Control': 'no-store' });
|
||||
});
|
||||
app.post('/api/loudness', async (c) => {
|
||||
let body;
|
||||
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
|
||||
const id = String(body && body.id || '').trim();
|
||||
const lufs = Number(body && body.lufs);
|
||||
if (!media.isMediaId(id) && !isUpload(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||
if (!Number.isFinite(lufs) || lufs < -60 || lufs > 0) return c.json({ ok: false, error: 'lufs out of range' }, 400);
|
||||
await addLoudness(id, lufs);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/chapters?v=<id> — YouTube's chapters for a video ([] when it has
|
||||
// none). Served from the cache table; otherwise resolved once (the stream
|
||||
// resolver's own cache usually makes that free) and remembered for 30 days.
|
||||
|
||||
Reference in New Issue
Block a user