From 18cc030cac5e180f79d49083846839ee320cfe8f Mon Sep 17 00:00:00 2001 From: edgevoid Date: Sun, 13 Sep 2026 18:06:52 +0800 Subject: [PATCH] =?UTF-8?q?v1.2.0:=20=E5=85=A8=E7=B3=BB=E7=BB=9F=E5=93=8D?= =?UTF-8?q?=E5=BA=A6=E5=BD=92=E4=B8=80=E5=8C=96(pipe=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=8C=82=E8=87=AA=E7=A0=94=20DSP)=20+=20=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E5=93=8D=E5=BA=A6=E5=8F=AF=E8=B0=83=20+=20=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=8F=B0=204=20=E8=B7=AF=E7=94=B5=E5=B9=B3/=E5=A2=9E=E7=9B=8A?= =?UTF-8?q?=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能 - 响度归一化/: 挂未文档化的 filter-chain `pipe` 插件跑自研 DSP, 把任何往虚拟声卡 推流的软件(Chrome/播放器/短视频)响度拉平到同一目标。ITU-R BS.1770 滑窗能量 -> 联合响度 -> 目标增益(6 dB/s 限速 + 峰值保护), 左右声道共享同一增益防声像漂移。 实测: 输入 20 dB 差 -> 输出 0.02 dB 差; 开销约 30% 单核; 延迟 +11 ms - 目标电平可调(热生效, 不重建链路): 面板「目标响度」滑块(-40 ~ -6 dBFS) / POST /api/loudness {"target_db"} / 直接改 ~/.local/state/cinema-spatial/loudness.json, DSP 每秒重读该文件 -> 1 秒内跟上 - 控制台: 电平表拆成 4 路(原始 L/R + 处理后 L/R, 绿/蓝两条线), 新增增益历史曲线 (看得到"锁住 -> 换段 -> 追赶"的全过程); 修表头被长数值挤扁 关键实现决定(都是实测撞出来的) - 归一化必须做成**独立 filter-chain 实例**串在上混图之前 —— pipe 节点塞进那个 40+ 节点的大图里(图入口/copy 之后/输出末端都试过)会被正常 fork、数据也在流, 但收到的永远是恒定 -43 dBFS 空数据, 音频绕过, 零报错 - pipe 的 command 必须是**纯 ASCII 路径**: 传中文路径时静默失效(节点照建、音频 绕过、DSP 不 fork、无日志) -> 入口固定为 /usr/bin/collaplex-loudness-norm - 整条链必须有下游消费者, 否则图不被驱动, DSP 收到全 0 - 系统级(/usr/share)与用户级(~/.config)的**同名 conf 会被同时加载**(不是覆盖), 会建出两份同名节点让音频进错实例 -> 铁律: 同名只留一份 打包 - 补入 loudness/loudness_norm.py 与 /usr/bin/collaplex-loudness-norm(ASCII 路径) - 生成配置时用 CINEMA_SPATIAL_LN 指定包内入口(开发机是 ~/.local/bin) - postinst: 装完自动启动控制台并打开浏览器(su 到登录用户 + 补 XDG_RUNTIME_DIR) - 依赖加 xdg-utils --- .gitignore | 3 + web/index.html | 211 +++++++++++++++++++++---- web/webui.py | 94 ++++++++++- 发布release.sh | 2 +- 响度归一化/README.md | 192 ++++++++++++++++++++++ 响度归一化/lab-测试配置.conf | 19 +++ 响度归一化/loudness_norm.py | 298 +++++++++++++++++++++++++++++++++++ 响度归一化/read_state.py | 128 +++++++++++++++ 响度归一化/run_norm.sh | 14 ++ 打包deb.sh | 70 ++++++-- 生成配置.py | 109 ++++++++++++- 11 files changed, 1085 insertions(+), 55 deletions(-) create mode 100644 响度归一化/README.md create mode 100644 响度归一化/lab-测试配置.conf create mode 100644 响度归一化/loudness_norm.py create mode 100644 响度归一化/read_state.py create mode 100755 响度归一化/run_norm.sh diff --git a/.gitignore b/.gitignore index 3104fda..4cc965f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ hrir/ *.deb /tmp* +# 本地备份(挪走的旧配置等, 不上仓) +备份/ + # Python __pycache__/ *.pyc diff --git a/web/index.html b/web/index.html index 90036fa..a1a4e4d 100644 --- a/web/index.html +++ b/web/index.html @@ -42,22 +42,53 @@ select.pmin{width:auto;padding:2px 6px;font-size:11.5px;border-radius:6px} .ingain{display:flex;align-items:center;gap:8px;margin-top:7px;font-size:11.5px;color:var(--mut)} .ingain input[type=range]{flex:1;max-width:240px} + /* 滑块统一用主题绿(浏览器默认的蓝和深色面板不搭) */ + input[type=range]{accent-color:var(--ok)} .ingain b{color:var(--fg);font-weight:500;min-width:34px;text-align:right} .chip i{margin-right:6px} /* 信号流那一行 */ + /* ★ 2026-09-13 加"响度归一化"框(固定 150px)后, 原 260px 会把两个电平表压成 + 一条 10px 的缝(实测截图确认) —— 行高抬到 430, 两个电平表各得约 130px。 */ .flow{display:grid;grid-template-columns:44% 4.5% 4.5% 15% 4.5% 6% 17% 4.5%;gap:0; - align-items:stretch;height:260px;overflow:hidden;margin:0 16px 0} + align-items:stretch;height:430px;overflow:hidden;margin:0 16px 0} .col{min-width:0;min-height:0;overflow:hidden} .meters{display:flex;flex-direction:column;gap:10px} .meter{flex:1;background:var(--card);border:1px solid var(--bd);border-radius:12px; padding:6px 10px 2px;display:flex;flex-direction:column;min-height:0;overflow:hidden} - .mhead{display:flex;align-items:baseline;gap:8px;font-size:12px;color:var(--mut);padding:2px 0 0} + /* ★ 别换行: 面板窄时 "原始电平" 会被挤成两行(实测截图确认) */ + /* ★ 表头别换行, 标签也别被数值挤扁: 面板宽度有限(约 870px), 数值串长到 + "峰值 1.1 · L -10.3 / R -10.4 dBFS" 时 flex 会把 原始电平 压变形(实测重叠) */ + .mhead{display:flex;align-items:baseline;gap:8px;font-size:12px;color:var(--mut);padding:2px 0 0;white-space:nowrap;overflow:hidden} + .mhead b,.mhead .lg,.mhead .hint{flex:none} .mhead b{color:var(--fg);font-weight:500;font-size:12.5px} - .mhead .num{margin-left:auto;font-variant-numeric:tabular-nums;color:var(--fg);font-size:12px} + .mhead .num{margin-left:auto;font-variant-numeric:tabular-nums;color:var(--fg);font-size:10px} /* ★ canvas 是替换元素, 固有高度 150px 会把网格行撑开(实测 260→425 把下面压了) —— 给死高度 */ .meter canvas{width:100%;display:block;height:calc(100% - 26px);min-height:0} + /* ---------- 4 路电平: L/R 图例 ---------- */ + .lg{display:inline-flex;align-items:center;gap:3px;font-size:10px;color:var(--mut); + margin-left:7px;vertical-align:middle} + .lg i{width:9px;height:2px;border-radius:1px;display:inline-block} + + /* ---------- 响度归一化: 推子 + 增益历史 ---------- */ + /* 高度 150 → 184: 框里加了「目标响度」滑块那一行, 不加高会把历史曲线 canvas 挤扁 */ + .lnbox{flex:0 0 auto;height:184px} + .lntgt{margin:6px 0 0;font-size:11px} + .lntgt input[type=range]{flex:1;min-width:0} + .lnfader{margin:8px 1px 0} + .lntrack{position:relative;height:14px;background:#151515;border:1px solid var(--bd); + border-radius:7px} + .lnfill{position:absolute;top:1px;bottom:1px;left:50%;width:0;border-radius:6px} + .lnfill.up{background:linear-gradient(90deg,rgba(250,204,21,.35),rgba(74,222,128,.75))} + .lnfill.dn{background:linear-gradient(90deg,rgba(96,165,250,.75),rgba(96,165,250,.35))} + .lnzero{position:absolute;top:-2px;bottom:-2px;left:50%;width:1px;background:#4a4a4a} + .lnk{position:absolute;top:50%;left:50%;width:13px;height:13px;margin:-6.5px 0 0 -6.5px; + border-radius:50%;background:#e5e5e5;border:2px solid #0f0f0f; + box-shadow:0 0 0 1px var(--bd);transition:left .12s linear} + .lnfoot{display:flex;justify-content:space-between;font-size:11px;color:var(--mut);margin-top:6px} + #ln{width:100%;display:block;height:calc(100% - 84px);min-height:30px;margin-top:5px} + /* 竖排开关 */ .vsw{background:var(--card);border:1px solid var(--bd);border-radius:12px;cursor:pointer; display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:8px 0; @@ -147,7 +178,7 @@ .seg button{padding:6px 11px} .flow{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-rows:auto; height:auto;overflow:visible;gap:8px;margin:0;padding:10px 12px 0} - .flow > .meters{grid-column:1/-1;height:230px} + .flow > .meters{grid-column:1/-1;height:400px} .flow > .vsw,.flow > .vsl,.flow > .lines,.flow > .routes{height:170px} .flow > .lines{padding:26px 0} .row{margin:10px 12px 0;flex-wrap:wrap} @@ -184,7 +215,9 @@
-
原始电平(虚拟声卡捕捉)—
+
原始电平 + LR + —
输入增益 @@ -194,9 +227,33 @@
-
处理后电平—
+
处理后电平 + LR + —
+ +
+
响度归一化 + 自动拉平各软件响度 + 未启用
+
+
+
+
+
+
+
+ +
+ 目标响度 + + -16 + dBFS +
+
目标 —判据 —
+ +
@@ -333,6 +390,17 @@ if (inVol) { await tickState(); }); } +// 响度归一化目标电平: 拖完写 loudness.json, DSP 每秒热读 → 1 秒内跟上, 不重建链路 +const lnTgt = document.getElementById("ln-tgt"); +if (lnTgt) { + lnTgt.addEventListener("change", async () => { + const v = parseFloat(lnTgt.value); + document.getElementById("ln-tgt-v").textContent = v.toFixed(0); + document.getElementById("ln-target").textContent = "目标 " + v.toFixed(1) + " dBFS"; + flash("目标响度 → " + v.toFixed(0) + " dBFS(DSP 1 秒内跟上)"); + await jpost("/api/loudness", { target_db: v.toFixed(1) }); + }); +} async function jget(p){ const r = await fetch(p, {cache:"no-store"}); return r.json(); } async function jpost(p, body){ const r = await fetch(p, {method:"POST", headers:{"Content-Type":"application/json"}, @@ -362,12 +430,24 @@ function y_of(db, h, top, bot){ /* ★ 自动量程(2026-09-13): 原先两张表都固定 0..-60dBFS。处理后信号比原始低约 22dB (虚拟音量 -10.5 + HRTF 约 -16), 落在 -48 上下 —— 幅度只剩 2~3dB 抖动, 肉眼看就是 一条贴底死线(老板报"不滚动")。改成每张表按自己窗口内的最大峰值定顶线, 两边都看得见。 */ +/* ★ 2026-09-13 老板要求: 电平分 4 路 —— 原始 L/R + 处理后 L/R。 + 取值统一走 pOf/rOf, 万一拿到旧格式(只有合并的 peak/rms)自动兜底。 */ +const pOf = (p, ch) => (p["peak" + ch] !== undefined ? p["peak" + ch] : p.peak); +const rOf = (p, ch) => (p["rms" + ch] !== undefined ? p["rms" + ch] : p.rms); +const CH_L = {line: "rgb(74,222,128)", fill: "rgba(74,222,128,.26)"}; +const CH_R = {line: "rgb(96,165,250)", fill: "rgba(96,165,250,.26)"}; + function meterTop(pts, fallback){ // ★ 只看**最近 2 秒**(约 20 个窗): 拿整 12 秒的最大值定顶线会被历史瞬态拖住 —— // 实测最响的一瞬 -10dBFS 把顶线钉在 -6, 安静段(-48)照样贴底看不见。 let mx = fallback; const n = pts.length, from = Math.max(0, n - 20); - for (let i = from; i < n; i++){ const p = pts[i]; if (p && p.peak > mx) mx = p.peak; } + for (let i = from; i < n; i++){ + const p = pts[i]; + if (!p) continue; + const v = Math.max(pOf(p, "L"), pOf(p, "R")); + if (v > mx) mx = v; + } return Math.max(-48, Math.min(0, Math.ceil((mx + 3) / 6) * 6)); // 6dB 步进, 不逐帧抖 } @@ -384,28 +464,26 @@ function drawMeter(cv, pts, peakHold){ if (i === 0 || i === SPAN) { c.fillStyle = "#4a4a4a"; c.font = "9px ui-monospace,monospace"; c.fillText(String(db), 2, Math.max(9, y - 2)); } } - const box = 40; // 前 40 列画成柱(像电平表), 后面画曲线 const n = pts.length; - if (!n){ c.fillStyle = "#4a4a4a"; c.font = "11px system-ui"; c.fillText("无信号", 8, h / 2); return; } + if (!n){ c.fillStyle = "#4a4a4a"; c.font = "11px system-ui"; c.fillText("无信号", 8, h / 2); + return peakHold ? peakHold.v : -60; } const step = w / MAXS; const off = Math.max(0, MAXS - n); - // RMS 面积 - c.beginPath(); - c.moveTo(off * step, h); - pts.forEach((p, i) => c.lineTo((off + i) * step, y_of(p.rms, h, top, bot))); - c.lineTo((off + n - 1) * step, h); - c.closePath(); - const g = c.createLinearGradient(0, y_of(top, h, top, bot), 0, y_of(bot, h, top, bot)); - g.addColorStop(0, "rgba(239,68,68,.75)"); g.addColorStop(.28, "rgba(250,204,21,.7)"); - g.addColorStop(1, "rgba(74,222,128,.55)"); - c.fillStyle = g; c.fill(); - // Peak 折线 - c.beginPath(); - pts.forEach((p, i) => { const x = (off + i) * step, y = y_of(p.peak, h, top, bot); - i ? c.lineTo(x, y) : c.moveTo(x, y); }); - c.strokeStyle = "#9ae6b4"; c.lineWidth = 1.2; c.stroke(); - // 峰值保持线 - const pk = Math.max(...pts.map(p => p.peak)); + // 两条声道: 淡面积(RMS) + 实线(Peak) —— 绿=L, 蓝=R + for (const [ch, col] of [["L", CH_L], ["R", CH_R]]){ + c.beginPath(); + c.moveTo(off * step, h); + pts.forEach((p, i) => c.lineTo((off + i) * step, y_of(rOf(p, ch), h, top, bot))); + c.lineTo((off + n - 1) * step, h); + c.closePath(); + c.fillStyle = col.fill; c.fill(); + c.beginPath(); + pts.forEach((p, i) => { const x = (off + i) * step, y = y_of(pOf(p, ch), h, top, bot); + i ? c.lineTo(x, y) : c.moveTo(x, y); }); + c.strokeStyle = col.line; c.lineWidth = 1.2; c.stroke(); + } + // 峰值保持线(两声道取最大) + const pk = Math.max(...pts.map(p => Math.max(pOf(p, "L"), pOf(p, "R")))); if (peakHold) peakHold.v = Math.max(pk, peakHold.v - 12); // 每秒 ~12dB 回落 const ph = peakHold ? peakHold.v : pk; const yh = y_of(ph, h, top, bot); @@ -413,13 +491,87 @@ function drawMeter(cv, pts, peakHold){ c.beginPath(); c.moveTo(0, yh + .5); c.lineTo(w, yh + .5); c.stroke(); return ph; } -function fmt(dbm, rms){ return "峰值 " + dbm.toFixed(1) + " dBFS · RMS " + rms.toFixed(1); } const hold = {raw:null, post:null}; function num(pts, el){ if (!pts.length){ $(el).textContent = "—"; return; } const last = pts[pts.length - 1]; - const pk = Math.max(...pts.map(p => p.peak)); - $(el).textContent = fmt(pk, last.rms); + const pk = Math.max(...pts.map(p => Math.max(pOf(p, "L"), pOf(p, "R")))); + $(el).textContent = "峰值 " + pk.toFixed(1) + " · L " + rOf(last, "L").toFixed(1) + + " / R " + rOf(last, "R").toFixed(1) + " dBFS"; +} + +/* ---------- 响度归一化: 推子 + 增益历史 ---------- */ +const LN_RANGE = 20; // 推子量程 ±20 dB +const LN_HIST = 120; // 增益历史点数(=12 秒, 与电平表对齐) +let lnHist = []; + +function drawLoud(ln){ + const on = ln && ln.ok; + const kEl = $("#ln-k"), fEl = $("#ln-fill"); + if (!on){ + $("#ln-num").textContent = ln && ln.err ? ln.err : "未启用"; + if (kEl) kEl.style.left = "50%"; + if (fEl){ fEl.style.width = "0%"; fEl.className = "lnfill"; } + $("#ln-target").textContent = "目标 —"; + $("#ln-loud").textContent = "判据 —"; + lnHist = []; + drawLnHist(); + return; + } + const g = Number(ln.gain_db || 0); + const f = Math.max(0, Math.min(1, (g + LN_RANGE) / (2 * LN_RANGE))); + const cur = f * 100, zero = 50; + if (kEl) kEl.style.left = cur + "%"; + if (fEl){ + fEl.className = "lnfill " + (g >= 0 ? "up" : "dn"); + fEl.style.left = Math.min(zero, cur) + "%"; + fEl.style.width = Math.abs(cur - zero) + "%"; + } + $("#ln-num").textContent = (g >= 0 ? "+" : "") + g.toFixed(2) + " dB"; + // 目标滑块跟 DSP 的实际目标同步(CLI 改过也反映); 用户正拖着就不动, 免得抢他的输入 + const tEl = $("#ln-tgt"); + if (tEl && document.activeElement !== tEl) { + const tv = Math.round(Number(ln.target_db)); + tEl.value = String(tv); + $("#ln-tgt-v").textContent = String(tv); + } + $("#ln-target").textContent = "目标 " + Number(ln.target_db).toFixed(1) + " dBFS"; + $("#ln-loud").textContent = "判据 " + Number(ln.loud).toFixed(1) + " dBFS"; + lnHist.push(g); + while (lnHist.length > LN_HIST) lnHist.shift(); + drawLnHist(); +} + +function drawLnHist(){ + const cv = $("#ln"); + if (!cv) return; + const {c, w, h} = fit(cv); + c.clearRect(0, 0, w, h); + const yOf = (db) => h * (1 - Math.max(0, Math.min(1, (db + LN_RANGE) / (2 * LN_RANGE)))); + // 0dB 基准线 + 网格 + for (const db of [LN_RANGE, LN_RANGE / 2, 0, -LN_RANGE / 2, -LN_RANGE]){ + const y = yOf(db); + c.strokeStyle = db === 0 ? "#3a3a3a" : "#1c1c1c"; + c.lineWidth = 1; + c.beginPath(); c.moveTo(0, y + .5); c.lineTo(w, y + .5); c.stroke(); + } + c.fillStyle = "#4a4a4a"; c.font = "9px ui-monospace,monospace"; + c.fillText("+" + LN_RANGE, 2, 9); + c.fillText("0", 2, Math.max(9, yOf(0) - 2)); + c.fillText("-" + LN_RANGE, 2, h - 2); + if (!lnHist.length){ c.fillStyle = "#4a4a4a"; c.font = "11px system-ui"; + c.fillText("无数据", w - 56, h / 2); return; } + const step = w / LN_HIST; + const off = Math.max(0, LN_HIST - lnHist.length); + // 增益折线(黄) + c.beginPath(); + lnHist.forEach((v, i) => { const x = (off + i) * step, y = yOf(v); + i ? c.lineTo(x, y) : c.moveTo(x, y); }); + c.strokeStyle = "rgb(250,204,21)"; c.lineWidth = 1.3; c.stroke(); + // 当前点 + const lx = (off + lnHist.length - 1) * step, ly = yOf(lnHist[lnHist.length - 1]); + c.fillStyle = "rgb(250,204,21)"; + c.beginPath(); c.arc(lx, ly, 2.2, 0, 6.2832); c.fill(); } /* ---------- 竖排滑块 ---------- */ @@ -585,6 +737,7 @@ async function tickMeters(){ hold.raw = hold.raw || {v: -60}; drawMeter($("#raw"), m.raw, hold.raw); hold.post = hold.post || {v: -60}; drawMeter($("#post"), m.post, hold.post); num(m.raw, "#raw-num"); num(m.post, "#post-num"); + drawLoud(m.loud); } catch(e){} } diff --git a/web/webui.py b/web/webui.py index 9536385..0629c86 100644 --- a/web/webui.py +++ b/web/webui.py @@ -10,8 +10,10 @@ import argparse import array import json import math +import mmap import os import re +import struct import subprocess import sys import threading @@ -317,11 +319,20 @@ def _meter_worker(key: str) -> None: a.frombytes(raw) lf = a[0::2] rt = a[1::2] - pk = max(max(abs(v) for v in lf), max(abs(v) for v in rt)) - rl = math.sqrt(sum(v * v for v in lf) / len(lf)) - rr = math.sqrt(sum(v * v for v in rt) / len(rt)) + pk_l = max(abs(v) for v in lf) + pk_r = max(abs(v) for v in rt) + rms_l = math.sqrt(sum(v * v for v in lf) / len(lf)) + rms_r = math.sqrt(sum(v * v for v in rt) / len(rt)) with METER_LOCK: - METERS[key].append({"peak": _db(pk), "rms": _db(max(rl, rr))}) + # ★ 2026-09-13 老板要求电平分 4 路(原始 L/R + 处理后 L/R): + # 这里左右**分开**存, 不再 max() 合并成一条线。 + # 旧的 peak/rms(取两声道较大者)保留, 兼容别处调用。 + METERS[key].append({ + "peakL": _db(pk_l), "peakR": _db(pk_r), + "rmsL": _db(rms_l), "rmsR": _db(rms_r), + "peak": _db(max(pk_l, pk_r)), + "rms": _db(max(rms_l, rms_r)), + }) except Exception: pass finally: @@ -391,10 +402,71 @@ def toggle(which: str, on: bool) -> str: return "未知开关" +# ---------------- 响度归一化(pipe 插件挂自研 DSP) ---------------- +# ★ 数据源是 /dev/shm 的共享内存(DSP 进程写的), 不是录音 —— "程序内部状态"(实际增益) +# 从音频里看不出来, 只能问 DSP。字段布局见 响度归一化/loudness_norm.py 顶部注释。 +# ★ 这一段跟上面那个"响度统一"(数字/模拟两条 route 的硬件增益补偿)不是一回事: +# 那个统一的是"哪条输出路", 这个统一的是"每个视频/软件自己的响度"。 +LN_SHM = "/dev/shm/collaplex-loudness" +LN_STRIDE = 32 +LN_SIZE = 80 +LN_TARGET_OFF = 64 + + +def loudness_state() -> dict[str, object]: + """响度归一化的实时状态: 4 路电平(原始/处理后 x L/R) + 实际增益(推子) + 目标电平. + + 未启用(共享区不在 / 两个声道都超时没更新)时 ok=False, 前端显示占位。 + """ + out: dict[str, object] = { + "ok": False, "target_db": None, "gain_db": None, "loud": None, + "raw_L": None, "raw_R": None, "post_L": None, "post_R": None, + } + if not os.path.exists(LN_SHM): + return out + rec: list[tuple[float, float, float, float]] = [] + try: + fd = os.open(LN_SHM, os.O_RDONLY) + try: + mm = mmap.mmap(fd, LN_SIZE, prot=mmap.PROT_READ) + try: + target = struct.unpack_from(" 1.0: # 都超过 1 秒没更新 = 没在跑 + return out + out["ok"] = True + out["target_db"] = round(target, 2) + out["gain_db"] = round(rec[0][2], 2) + for i, name in ((0, "L"), (1, "R")): + raw = _db(math.sqrt(max(rec[i][1], 0.0))) # 能量 -> 幅度 -> dBFS + out["raw_" + name] = round(raw, 2) + out["post_" + name] = round(raw + rec[i][2], 2) + joint = max(rec[0][0], 0.0) + max(rec[1][0], 0.0) + out["loud"] = round(_db(math.sqrt(joint)), 2) # 归一化的判据(两声道联合响度) + return out + + # ---------------- 响度统一 ---------------- def load_loud() -> dict[str, object]: - d: dict[str, object] = {"unified": False, "target": 0.33, "digital": 1.0, "analog": 1.0} + # target_db = 响度归一化(pipe+DSP)的目标电平, DSP 每秒读这个文件; + # ★ 与上面的 unified/target「响度统一」(数字/模拟两条输出路的增益补偿)不是一回事, 名字撞车 + d: dict[str, object] = {"unified": False, "target": 0.33, "digital": 1.0, "analog": 1.0, + "target_db": -16.0} try: with open(LOUD_FILE, encoding="utf-8") as f: d.update(json.load(f)) @@ -594,6 +666,7 @@ class Handler(BaseHTTPRequestHandler): self._json(state()) elif u.path == "/api/meters": self._json({"raw": meter_points("raw"), "post": meter_points("post"), + "loud": loudness_state(), "raw_target": virtual_sink(), "post_target": chain_target() or phys_sink()}) else: self._json({"error": "not found"}, 404) @@ -687,8 +760,17 @@ class Handler(BaseHTTPRequestHandler): d["digital"] = max(0.0, min(1.5, float(g("digital")))) if "analog" in q: d["analog"] = max(0.0, min(1.5, float(g("analog")))) + # 响度归一化的目标电平(面板那根「目标响度」滑块): 只写 json 就够 —— + # DSP 每秒自己热读, 不用重建链路、也不碰物理音量 + only_tgt = "target_db" in q and len(q) == 1 + if "target_db" in q: + try: + d["target_db"] = max(-40.0, min(-6.0, float(g("target_db")))) + except ValueError: + pass save_loud(d) - msg = apply_loudness() + msg = (f"归一化目标 → {_num(d.get('target_db'), -16.0):.1f} dBFS (立即生效)" + if only_tgt else apply_loudness()) elif u.path == "/api/fix": msg = cli("开") elif u.path == "/api/device": diff --git a/发布release.sh b/发布release.sh index 20b951c..84afc1a 100644 --- a/发布release.sh +++ b/发布release.sh @@ -14,7 +14,7 @@ bash 打包deb.sh 2>&1 | tail -3 echo "本地: $(stat -c%s "$DEB") 字节" echo "=== 建 release v$VER ===" -BODY="collaplex-cinema-spatial $VER - 耳机电影院空间音频 (PipeWire filter-chain + SADIE-II HRTF, 96kHz/24bit)\n\n核心技术: 镜像法房间声学追踪(0-80ms 早期反射, 8473 条镜面路径) + Sabine 统计尾音(80ms-2s) -> 96kHz 真实房间 IR 双耳卷积; 湿量按能量归一(湿量 G 时湿路 = 干路 + 20log10 G)。不是 Freeverb/Schroeder 类算法混响。\n\n本版 1.1.1: README 重写为正式文档(核心技术/信号链/参数/已知问题/实测数据/版本记录), 未改动功能代码。\n\n自 1.0.x 以来的主要变化:\n- 单声道设备支持: 单声道蓝牙音响/耳机连不上(链输出是 2 声道 FL/FR, 单声道设备只有 1 个 MONO 端口) -> 新增单声道合并(自动/开/关), 默认按目标设备声道数自动判定\n- 切换设备不再重启 PipeWire: 新增运行期重连(链输出本质是 stream, 端口可运行期改连) -> 蓝牙设备不再被重启带掉\n- 修复'重启后目标失效': 原来固定 sleep 4 就判死, 而蓝牙/USB 设备要 3~8 秒重连 -> 改为轮询等待(最多 20 秒), 回退探测跳过显卡/HDMI\n- 修复削波: 房间混响 IR 原来只按峰值归一, 能量增益 +11dB, 处理后峰值冲到 +12dBFS -> 改为能量归一(sigma ir^2 = 1)\n- 电平表采集: 处理后电平改为按端口 id 显式连线读取链输出, 杜绝 pw-record 静默回退到麦克风\n- 面板新增'核心技术'说明区; 新增输入增益(削波余量)滑块\n- 打包: control 版本号改为跟随 VER(旧版恒为 1.0.0 致 dpkg 无法升级), 补入 音频状态.py 与 web/ 面板\n\n安装: sudo dpkg -i ${PKG}_${VER}_all.deb; systemctl --user restart pipewire pipewire-pulse" +BODY="collaplex-cinema-spatial $VER - 耳机电影院空间音频 (PipeWire filter-chain + SADIE-II HRTF, 96kHz/24bit)\n\n核心技术: 镜像法房间声学追踪(0-80ms 早期反射, 8473 条镜面路径) + Sabine 统计尾音(80ms-2s) -> 96kHz 真实房间 IR 双耳卷积; 湿量按能量归一。不是 Freeverb/Schroeder 类算法混响。\n\n本版 1.2.0 — 新增【全系统响度归一化】:\n- 任何往虚拟声卡推流的软件(Chrome/播放器/短视频)响度自动拉平到同一目标, 换片不再一响一轻\n- 目标电平可调: Web 面板新增「目标响度」滑块(-40 ~ -6 dBFS), 或 POST /api/loudness {target_db}, 或直接改 ~/.local/state/cinema-spatial/loudness.json; DSP 每秒热读 -> 1 秒内跟上, 不必重建链路\n- 算法: ITU-R BS.1770 滑窗能量 -> 联合响度 -> 目标增益 -> 6 dB/s 限速 + 峰值保护; 左右声道共享同一增益(不漂声像)\n- 实测: 输入 20 dB 差 -> 输出 0.02 dB 差; 开销约 30% 单核; 延迟 +11ms\n- 控制台电平表拆为 4 路(原始 L/R + 处理后 L/R), 新增增益历史曲线(看得到动态平衡的全过程)\n- 架构: 归一化做成独立 filter-chain 实例串联在上混图之前(pipe 节点塞进 40+ 节点的大图会收到恒定 -43dBFS 空数据, 音频绕过)\n- 安装后自动启动控制台并打开浏览器\n\n自 1.1.1 以来:\n- 新增响度归一化 DSP(loudness/loudness_norm.py) 与入口 /usr/bin/collaplex-loudness-norm(纯 ASCII 路径)\n- 控制台: 新增目标响度滑块、4 路电平、增益历史曲线; 修表头挤压\n\n安装: sudo dpkg -i ${PKG}_${VER}_all.deb; systemctl --user restart pipewire pipewire-pulse" RID=$(curl -s -X POST "$API/releases" -u edgevoid:147427 -H "Content-Type: application/json" -d "{\"tag_name\":\"v$VER\",\"name\":\"v$VER\",\"body\":\"$BODY\"}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))") echo "release id: $RID" [ -n "$RID" ] || exit 1 diff --git a/响度归一化/README.md b/响度归一化/README.md new file mode 100644 index 0000000..519d69d --- /dev/null +++ b/响度归一化/README.md @@ -0,0 +1,192 @@ +# 响度归一化(全系统自动拉平响度) + +把 Chrome / 抖音 / 播放器 / 任何往 `cinema_spatial_up_sink` 推流的软件,全部自动拉到 +同一个目标电平 —— 小声的拉起来、大声的压下去,切换视频不再一响一轻。 + +`2026-09-13` 端到端实测通过并**已上生产**。 + +--- + +## 结论(实测数据) + +| 阶段 | 输入电平 | 输出电平 | 增益(推子) | +|---|---|---|---| +| 响段 | -12.07 dBFS | **-19.04 dBFS** | -6.97 dB | +| 换轻段瞬间 | -32.07 dBFS | -32.90 dBFS | -0.83 dB | +| 追赶中 | -32.07 dBFS | -26.76 dBFS | +5.31 dB | +| 追赶中 | -32.07 dBFS | -20.62 dBFS | +11.46 dB | +| 稳定 | -32.07 dBFS | **-19.06 dBFS** | **+13.02 dB** | + +- **20 dB 的节目差异 → 0.02 dB**(左右声道读数完全一致,声像不漂) +- 追赶速度 **6 dB/s** 限速(每 0.5 秒走 3 dB,分毫不差) +- 开销:2 个进程共 ~30% **单核**;整链延迟 ~11 ms + +--- + +## 怎么实现的 + +PipeWire 本机(1.6.2)**没编译** `ebur128` / `ffmpeg` 这两类原生响度节点,所以走 +未文档化的 `pipe` 插件:它 fork 一个外部程序,用 stdin/stdout 搬运音频。 + +``` +任何软件 → cinema_spatial_up_sink (Audio/Sink) + ↓ + 【响度归一化实例】c1L/c1R → lnL/lnR (pipe→DSP) → c2L/c2R + ↓ (node.target) + cinema_spatial_up_raw (Audio/Sink) + ↓ + 【上混图】上混 5.1 → HRTF → 房间混响 + ↓ + cinema_spatial_up_out → 声卡 +``` + +**独立的 filter-chain 实例**,不是塞进上混图里(原因见下面的坑 #1)。 + +### 文件 + +| 文件 | 作用 | +|---|---| +| `loudness_norm.py` | 核心 DSP:ITU-R BS.1770 滑窗能量 → 联合响度 → 目标增益 → 限速 + 峰值保护 | +| `run_norm.sh` | pipe 插件入口 wrapper。部署:`cp` 到 `~/.local/bin/collaplex-loudness-norm && chmod +x` | +| `read_state.py` | 读共享内存看实时状态(诊断 / 面板数据源),`--watch` 持续刷新 | +| `生成配置.py` | 改这里 → 重跑。`loudness_instance()` 生成那一级 | + +### 关键机制 + +- **左右两个进程 + 共享内存协调**:filter-chain 给 pipe 的是一个**单声道**节点,所以左右 + 各起一个 pipe 实例(各 fork 一个进程),两个进程用 `flock` 抢 0/1 槽位,通过 + `/dev/shm/collaplex-loudness` 交换能量读数、**算同一个增益** —— 否则各自算各自的, + 声像会漂。 +- **共享内存布局**:每声道 32 字节 × 2,之后是全局参数。**字段只增不改顺序** + (web 面板直接读这块内存画电平表和推子)。 +- **目标电平热更新**:DSP 每秒重读 `~/.local/state/cinema-spatial/loudness.json` + (`{"on": bool, "target_db": float}`),面板/CLI 改完立即生效,**不用重建链路**。 +- **自愈退出**:连续 10 秒没有数据就自杀(`LN_IDLE_LIMIT`)。因为 PDEATHSIG 在 pipe + 隔了一层 shell 时不生效,孤儿会被 systemd 收养(`ppid` 不是 1),靠管道 EOF 也判不出来。 + +--- + +## 踩过的坑(都是实测撞出来的) + +### 1. pipe 节点塞进大图里**收不到音频** ★最坑 + +放进那个 40+ 节点的上混图里 —— 不管放**图入口**、**copy 之后**、还是**输出末端** —— +pipe 都会被正常 fork、数据也在稳定流动(每 0.5 秒 47 个块),但它收到的**永远是恒定 +-43 dBFS 的空数据**,音频直接从旁边绕过去,日志里**没有任何报错**。 + +同一个 pipe 放在 `copy → pipe → copy` 这种小图里完全正常(实测收到 -12.04 dBFS 的 +真实节目电平)。 + +**解法**:拆成两级串联的独立 filter-chain 实例(现在就是这么做的)。 + +### 2. pipe 的 `command` **不支持中文路径** ★隐蔽 + +传中文路径(比如 `~/桌面/工作区/.../run_norm.sh`)时它**一声不吭**:节点照建、 +音频绕过、DSP 完全不执行、日志无任何输出。 + +**解法**:wrapper 放**纯 ASCII 路径**(`~/.local/bin/collaplex-loudness-norm`)。 +wrapper 内部再去 `exec` 中文路径的 DSP 没问题。 + +### 3. pipe 的 `command` 只能写"程序 + **一个**参数" + +它走 shebang 式 exec,多一个参数就炸: +`env A=B python3 x.py` → `use -[v]S to pass options in shebang lines`。 + +**解法**:所有环境变量/参数都塞进 wrapper 脚本,配置里只给脚本路径。 + +### 4. 整条链**必须有下游消费者**,否则图不被驱动 + +`playback.props` 上加了 `node.autoconnect = false`、又没手动接下游时,图不跑, +DSP 收到的是**全零**(`raw=0.000000`,不是底噪)。 + +**判据**:看 `pw-link -l` 里最终输出有没有 `|-> 目标`。 + +### 5. ★系统级 + 用户级**同名配置会被同时加载** + +`/usr/share/pipewire/pipewire.conf.d/90-cinema-spatial.conf`(deb 装的旧版) +和 `~/.config/pipewire/pipewire.conf.d/90-cinema-spatial.conf`(新版)**不会互相覆盖**, +而是**两个都加载** → 同名节点出现**两份** → 音频进了没有归一化的旧实例, +DSP 一直收 0。 + +**症状**:`pw-dump` 里 `cinema_spatial_up_sink` 等节点**重复 2 次**。 + +**解法**:只留一份。已删掉系统级那份,保留用户级(`生成配置.py` 默认就写这里)。 +系统级备份在 `/tmp/sys-90-cinema-spatial.conf.bak-*`。 + +### 6. pipe 的端口名是 `In` / `Out`(单声道),不是 `In 1` / `In 2` + +`In 1..In 8` 是别的插件的端口。写错的话 filter-chain **静默**把那一级丢掉。 + +### 7. 面板加上第三个框后 `.flow` 高度不够 + +`.flow` 固定 260px,塞进响度归一化框(150px)后两个电平表被压成 10px 的缝。 +已抬到 430px。 + +### 8. 用 `write_file` 重写 shell 脚本会**冲掉可执行位** + +wrapper 不可执行 → pipe 起不来(DSP 进程 0)。写完记得 `chmod +x`。 + +--- + +## Web 面板(`../web/`,:8788) + +- **4 路电平**:原始 L/R(绿/蓝)+ 处理后 L/R,各自的淡色面积是 RMS、实线是 Peak、 + 横线是峰值保持(黄/红),表头带实时读数 +- **响度归一化框**:推子(±20 dB 刻度,实时位置 = 当前增益)+ 目标/判据读数 + + **黄色增益历史曲线**(能看到"锁住 → 换段 → 追赶"的全过程) +- **「目标响度」滑块**(-40 ~ -6 dBFS,步长 1 dB)—— 用户自己定"拉平到多大声"。 + 拖完写进 `~/.local/state/cinema-spatial/loudness.json` 的 `target_db`, + **DSP 每秒热读 → 1 秒内跟上,不用重建链路**(实测:改成 -20 后 3 秒 DSP 已锁到 -20)。 + 滑块位置会跟 DSP 的实际目标同步(CLI 改过也反映);用户正在拖时不回写,免得抢输入。 + ★ 写接口复用 `/api/loudness`(`{"target_db": "-20.0"}`),但**只有 target_db 一个参数时 + 不调 `apply_loudness()`** —— 那个是「响度统一」(数字/模拟输出路增益补偿)的逻辑,两码事。 +- 后端 `/api/meters` 多返回一个 `loud` 字段(`ok/target_db/gain_db/raw_L..post_R/loud`), + 数据源是 DSP 的共享内存;DSP 没在跑时 `ok=false`,前端显示"未启用" + +--- + +## 参数 + +| 环境变量 | 默认 | 说明 | +|---|---|---| +| `LN_TARGET_DB` | -16 | 目标电平(dBFS)。**状态文件优先**,见上 | +| `LN_MAX_BOOST` | 20 | 最大提升量(dB,自动取绝对值) | +| `LN_MAX_CUT` | 20 | 最大压低量(dB,**代码自动取负**,传正数也行) | +| `LN_RATE` | 96000 | 采样率,要跟 `91-clock.conf` 一致 | +| `LN_PEAK_CEIL` | 0.985 | 峰值保护上限 | +| `LN_IDLE_LIMIT` | 10 | 连续多少秒没数据就退出 | +| `LN_DEBUG` | 0 | 1 = 每块打印一行到 stderr | + +--- + +## 隔离测试方法 + +不想动生产时,另起一个 PipeWire 实例(要用同一份 `XDG_RUNTIME_DIR`, +**并且 `unset XDG_CONFIG_HOME`**,否则会把生产的 `90-cinema-spatial.conf` 一起加载): + +```sh +export XDG_RUNTIME_DIR=/run/user/1000 PIPEWIRE_RUNTIME_DIR=/run/user/1000 +export PIPEWIRE_CONFIG_DIR=/tmp/pw-lab/prod +unset XDG_CONFIG_HOME +pipewire -c labtest.conf +``` + +**注意**:查状态前也要 `unset PIPEWIRE_CONFIG_DIR`,否则 `pw-dump` / `wpctl` +连不上主 daemon,会给你**假阴性**(看着像节点全没了,其实好好的)。 + +诊断探针 `probe3.py` 很好用:透传 stdin→stdout,每 0.5 秒把块数/RMS/峰值写日志, +一眼看出 pipe 到底收到什么、跟播放时间轴对不对得上。 + +--- + +## 开关 / 调参 + +```sh +# 关掉(生成器会跳过这一级,然后重建) +echo '{"on": false, "target_db": -16}' > ~/.local/state/cinema-spatial/loudness.json +cd ~/桌面/工作区/实验/空间音频 && python3 生成配置.py +systemctl --user restart pipewire pipewire-pulse wireplumber # 音频断 2~3 秒 + +# 只改目标电平(热生效,不用重建) +echo '{"on": true, "target_db": -20}' > ~/.local/state/cinema-spatial/loudness.json +``` diff --git a/响度归一化/lab-测试配置.conf b/响度归一化/lab-测试配置.conf new file mode 100644 index 0000000..f44747a --- /dev/null +++ b/响度归一化/lab-测试配置.conf @@ -0,0 +1,19 @@ +# LAB: 响度归一化 隔离测试 +context.modules = [ + { name = libpipewire-module-filter-chain + flags = [ nofail ] + args = { + node.description = "LAB 响度归一化测试" + media.name = "LAB" + + filter.graph = { + nodes = [ + { type = builtin label = pipe name = p + config = { command = "/tmp/pw-lab/run_norm.sh" } } + ] + } + capture.props = { node.name = lab_in media.class = Audio/Sink audio.rate = 96000 audio.channels = 2 audio.position = [ FL FR ] } + playback.props = { node.name = lab_out media.class = Audio/Source audio.rate = 96000 audio.channels = 2 audio.position = [ FL FR ] } + } + } +] diff --git a/响度归一化/loudness_norm.py b/响度归一化/loudness_norm.py new file mode 100644 index 0000000..a9867f4 --- /dev/null +++ b/响度归一化/loudness_norm.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""Collaplex 响度归一化器 — PipeWire filter-chain `pipe` 插件载荷 + +★ 运行环境: 由 filter-chain 的 pipe 插件 fork, stdin=音频进, stdout=音频出。 +★ 关键机制: filter-chain 会**按声道复制 graph**, 所以左右声道各起一个本进程实例, + 每个实例只看到**单声道**数据。为保持立体声声像不漂移, 两个实例通过 /dev/shm + 共享各自的能量值, 用"两声道能量和"作为联合响度, 算出**同一个增益**。 + +★ 铁律: 本进程**绝不能退出** —— pipe 插件的外部程序一旦退出, 音频立刻变静音。 + 所有异常都吃掉, 出错时退回纯透传。 + +算法(简化版 EBU R128 思路, 纯 numpy 向量化, 零逐样本循环): + 块 RMS -> 能量 -> 指数滑动平均(≈400ms 短时响度) -> 门控 -> + 目标电平求增益(dB) -> 限速平滑 -> 峰值保护 -> 施加 + +环境变量调参(改配置时注入): + LN_TARGET_DB 目标响度(dBFS), 默认 -16 + LN_GATE_DB 静音门限(dBFS), 默认 -50 + LN_MAX_BOOST 最大提升(dB), 默认 +12 + LN_MAX_CUT 最大衰减(dB), 默认 -18 + LN_SLEW_DB_S 增益变化速率上限(dB/s), 默认 6 + LN_WINDOW_S 响度平滑窗口(s), 默认 0.4 + LN_RATE 采样率, 默认 96000 + LN_BLOCK 每次读取字节数, 默认 4096 +""" +import ctypes +import fcntl +import json +import math +import mmap +import os +import select +import signal +import struct +import sys +import time + +import numpy as np + +# ---------------- 参数 ---------------- +TARGET_DB = float(os.environ.get("LN_TARGET_DB", "-16")) # 兜底默认(状态文件优先) +# ★ 目标电平放状态文件: 面板/CLI 改它, DSP 每秒重读一次 -> 实时生效, 不用重建链路。 +LOUDNESS_STATE = os.path.expanduser("~/.local/state/cinema-spatial/loudness.json") +GATE_DB = float(os.environ.get("LN_GATE_DB", "-50")) +MAX_BOOST = abs(float(os.environ.get("LN_MAX_BOOST", "12"))) +MAX_CUT = -abs(float(os.environ.get("LN_MAX_CUT", "18"))) # ★ 必须为负, 传正数也自动取负 +SLEW_DB_S = float(os.environ.get("LN_SLEW_DB_S", "6")) +WINDOW_S = float(os.environ.get("LN_WINDOW_S", "0.4")) +RATE = float(os.environ.get("LN_RATE", "96000")) +BLOCK = int(os.environ.get("LN_BLOCK", "4096")) +PEAK_CEIL = float(os.environ.get("LN_PEAK_CEIL", "0.985")) +IDLE_LIMIT = int(os.environ.get("LN_IDLE_LIMIT", "10")) # 连续无数据多少秒后自行退出 +DEBUG = os.environ.get("LN_DEBUG", "") not in ("", "0") + +_ERR = sys.stderr + + +def dbg(msg: str) -> None: + if DEBUG: + try: + _ERR.write(msg + "\n") + _ERR.flush() + except Exception: + pass + +EPS = 1e-12 +GATE_E = 10.0 ** (GATE_DB / 10.0) + + +def read_target() -> float: + """当前目标电平(dBFS) —— 状态文件优先, 读不到回落到环境变量/默认. + + ★ 面板"响度归一化"推子写的就是这个值; 主循环每秒重读一次, 所以改完立即生效。 + """ + try: + with open(LOUDNESS_STATE, encoding="utf-8") as fh: + d = json.load(fh) + if isinstance(d, dict) and d.get("target_db") is not None: + return max(-40.0, min(-6.0, float(d["target_db"]))) + except (OSError, ValueError, TypeError): + pass + return TARGET_DB + +# ---------------- 共享区(两声道交换状态 + 面板读取) ---------------- +# ★ web 面板也读这块内存, 所以**字段只增不改顺序**。 +# 每声道 32 字节, 两声道; 其后是全局参数。 +# +0 f32 e_smooth 平滑能量(响度判据, ~400ms) —— 归一化的"输入电平" +# +4 f32 e_block 本块能量(电平表用, 快响应) +# +8 f32 peak 本块峰值(电平表用) +# +12 f32 gain_db 本块**实际**施加的增益 dB(含峰值保护) -> 面板推子 +# +16 f64 t 时间戳(monotonic) +# +24 u32 n 已处理块数(判活) +# +28 u32 _pad +# +64 f32 target_db 目标响度(面板推子的基准线) +# +68 u32 rate 采样率 +SHM_PATH = "/dev/shm/collaplex-loudness" +CH_STRIDE = 32 +SHM_SIZE = 80 +TARGET_OFF = 64 +RATE_OFF = 68 + + +def set_pdeathsig() -> None: + """父进程(pipewire filter-chain)一旦消失, 本进程立即被内核杀掉。 + ★ 否则本进程会变成孤儿: 继续持有槽位锁, 让新实例拿不到槽位。""" + try: + libc = ctypes.CDLL("libc.so.6", use_errno=True) + libc.prctl(1, signal.SIGKILL) # PR_SET_PDEATHSIG=1 + except Exception: + pass + + +def claim_slot() -> int: + """用 flock 抢一个声道槽位(0/1), 锁随进程退出自动释放。""" + for i in (0, 1): + try: + fd = os.open("%s.lock%d" % (SHM_PATH, i), os.O_CREAT | os.O_RDWR, 0o600) + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + return i # fd 不关, 锁一直持有 + except OSError: + try: + os.close(fd) + except Exception: + pass + return 0 + + +def open_shm() -> mmap.mmap: + fd = os.open(SHM_PATH, os.O_CREAT | os.O_RDWR, 0o600) + if os.fstat(fd).st_size < SHM_SIZE: + os.ftruncate(fd, SHM_SIZE) + return mmap.mmap(fd, SHM_SIZE) + + +STALE_S = 1.0 # 对方声道超过这么久没更新, 视为不存在(防残留值污染联合响度) + + +def read_peer_energy(mm: mmap.mmap, other: int) -> float: + """读另一声道的平滑能量; 陈旧(NaN/负/超时)一律当 0。""" + base = other * CH_STRIDE + try: + e = struct.unpack_from(" STALE_S: # 陈旧 + return 0.0 + return e + + +def main() -> None: + stdin = sys.stdin.buffer + stdout = sys.stdout.buffer + + set_pdeathsig() + slot = claim_slot() + mm = open_shm() + other = 1 - slot + + # 全局参数写进共享区(面板推子的基准线) + target_db = read_target() + try: + struct.pack_into(" 0 else 1.0 + max_step_db = SLEW_DB_S * block_seconds + + carry = b"" # 不足 4 字节的尾巴 + idle = 0 # 连续无数据的秒数 + + while True: + # ★ 用 select 带超时读。为什么必须这样: + # 1) filter-chain 死后管道写端不一定关闭, 所以 read() 不一定返回 EOF; + # 2) 孤儿进程会被 systemd(subreaper) 收养, ppid 变成 session manager 而不是 1, + # 所以 "ppid==1" 也不可靠。 + # 唯一可靠的判据 = 连续若干秒收不到任何数据(链路已断)。到时自行退出, + # 否则每重启一次音频就会攒一对僵尸进程、霸占左右槽位锁。 + try: + ready, _, _ = select.select([stdin], [], [], 1.0) + except Exception: + ready = [stdin] + if not ready: + idle += 1 + if idle >= IDLE_LIMIT or os.getppid() == 1: + break + continue + idle = 0 + + # 目标电平热更新: 面板/CLI 改了 loudness.json, 这里每秒跟上(不用重建链路) + _now_t = time.monotonic() + if _now_t >= next_target_check: + next_target_check = _now_t + 1.0 + _t = read_target() + if _t != target_db: + target_db = _t + try: + struct.pack_into(" GATE_E: + level_db = 10.0 * math.log10(joint + EPS) + want_db = target_db - level_db + want_db = max(MAX_CUT, min(MAX_BOOST, want_db)) + else: + want_db = gain_db # 静音段保持当前增益 + + # ---- 4) 限速平滑(避免抽气/爆音) ---- + delta = want_db - gain_db + if delta > max_step_db: + delta = max_step_db + elif delta < -max_step_db: + delta = -max_step_db + gain_db += delta + + # ---- 5) 峰值保护(快降, 只作用于本块) ---- + g = 10.0 ** (gain_db / 20.0) + peak = float(np.max(np.abs(x))) if x.size else 0.0 + if peak * g > PEAK_CEIL > 0.0: + g = PEAK_CEIL / (peak + EPS) + gain_db = 20.0 * math.log10(g + EPS) # 状态跟随, 下块再按限速恢复 + + # ---- 6) 施加增益 ---- + y = x * g + if y.dtype != np.float32: + y = y.astype(" 推子 + struct.pack_into(" 0 else -999.0, + want_db, gain_db, peak)) + stdout.write(y.tobytes()) + stdout.flush() + except Exception: + # ★ 任何异常都不能让进程退出(退出=静音), 退回纯透传 + try: + stdout.write(buf[:n] if n else raw) + stdout.flush() + except Exception: + pass + + +if __name__ == "__main__": + try: + main() + except Exception: + pass diff --git a/响度归一化/read_state.py b/响度归一化/read_state.py new file mode 100644 index 0000000..8d07400 --- /dev/null +++ b/响度归一化/read_state.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""读 Collaplex 响度归一化的共享状态(诊断 / web 面板数据源) + +共享区布局(见 loudness_norm.py 顶部注释): + 每声道 32 字节, 两声道: + +0 f32 e_smooth 平滑能量(响度判据) —— 归一化的"输入电平" + +4 f32 e_block 本块能量(快响应) + +8 f32 peak 本块峰值 + +12 f32 gain_db 本块实际施加的增益 dB -> 推子 + +16 f64 t 时间戳(monotonic) + +24 u32 n 已处理块数(判活) + +64 f32 target_db 目标响度 + +68 u32 rate 采样率 + +用法: + python3 read_state.py 打印一次 + python3 read_state.py --watch 每 0.5 秒刷新(诊断动态平衡过程) +""" +import json +import math +import mmap +import os +import struct +import sys +import time + +SHM_PATH = "/dev/shm/collaplex-loudness" +CH_STRIDE = 32 +SHM_SIZE = 80 +TARGET_OFF = 64 +RATE_OFF = 68 +STALE_S = 1.0 + +EPS = 1e-12 + + +def db(x: float) -> float: + """线性能量/幅度 -> dBFS(0 以下)。""" + return 10.0 * math.log10(x + EPS) + + +def read_slot(mm: mmap.mmap, i: int) -> dict[str, float]: + base = i * CH_STRIDE + e_smooth = struct.unpack_from(" dict[str, object]: + if not os.path.exists(SHM_PATH): + return {"ok": False, "err": "共享区不存在(响度归一化未启用)"} + fd = os.open(SHM_PATH, os.O_RDONLY) + try: + mm = mmap.mmap(fd, SHM_SIZE, prot=mmap.PROT_READ) + try: + target = struct.unpack_from(" 0 else -120.0 + out["raw_" + name] = round(raw_db, 2) if c["alive"] else None + out["raw_peak_" + name] = round(raw_pk, 2) if c["alive"] else None + out["post_" + name] = round(raw_db + c["gain_db"], 2) if c["alive"] else None + out["gain_db_" + name] = round(c["gain_db"], 2) if c["alive"] else None + # 平滑响度(归一化真正依据的那条) + out["loud_" + name] = round(db(c["e_smooth"]), 2) if c["alive"] else None + if ch[0]["alive"] and ch[1]["alive"]: + joint = ch[0]["e_smooth"] + ch[1]["e_smooth"] + out["loud_joint"] = round(db(joint), 2) + out["deviation_db"] = round(target - db(joint), 2) # >0 = 还要提, <0 = 还要压 + return out + + +def main() -> None: + watch = "--watch" in sys.argv + try: + if not watch: + print(json.dumps(snapshot(), ensure_ascii=False, indent=2)) + return + print("每 0.5 秒刷新 (Ctrl-C 退出)") + print("%8s %9s %9s %9s %9s %8s" % ( + "时间", "原始L", "原始R", "处理后L", "处理后R", "增益dB")) + t0 = time.time() + while time.time() - t0 < 3600: + s = snapshot() + if not s.get("ok"): + print(" (无数据: %s)" % s.get("err", "声道未在跑")) + else: + fmt = lambda k: ("%9.2f" % s[k]) if s.get(k) is not None else "%9s" % "-" + _g = s.get("gain_db_L") + gv = float(_g) if isinstance(_g, (int, float)) else 0.0 + print("%8.1f %s %s %s %s %8.2f" % ( + time.time() - t0, fmt("raw_L"), fmt("raw_R"), + fmt("post_L"), fmt("post_R"), gv)) + sys.stdout.flush() + time.sleep(0.5) + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/响度归一化/run_norm.sh b/响度归一化/run_norm.sh new file mode 100755 index 0000000..7888517 --- /dev/null +++ b/响度归一化/run_norm.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Collaplex 响度归一化 — pipe 插件入口(项目源文件) +# +# ★ pipe 插件的 command 只支持 "程序 + 一个参数"(它走 shebang 式 exec): +# 实测 `env A=B python3 x.py` 会报 "use -[v]S to pass options in shebang lines", +# 所以参数全在这个脚本里设, 配置里只给这个脚本的路径。 +# ★ 路径必须**纯 ASCII**: pipe 插件传中文路径时一声不吭 —— 节点照建、音频绕过、 +# 无任何日志(排了很久)。wrapper 内部再去 exec 中文路径的 DSP 是没问题的。 +# +# 部署: cp 这个文件到 ~/.local/bin/collaplex-loudness-norm && chmod +x +export LN_MAX_BOOST=20 +export LN_MAX_CUT=20 +export LN_RATE=96000 +exec /usr/bin/python3 "/home/lou/桌面/工作区/实验/空间音频/响度归一化/loudness_norm.py" diff --git a/打包deb.sh b/打包deb.sh index 7b132dc..2ed401d 100644 --- a/打包deb.sh +++ b/打包deb.sh @@ -1,10 +1,10 @@ #!/bin/bash -# 把电影院空间音频全链打包成 deb (2026-09-12) +# 把电影院空间音频全链打包成 deb (2026-09-12 首版; 2026-09-13 v1.2.0 加响度归一化) # 包名 collaplex-cinema-spatial, Arch: all (纯脚本+数据, 无编译) set -eu SRC="/home/lou/桌面/工作区/实验/空间音频" PKG="collaplex-cinema-spatial" -VER="1.1.1" +VER="1.2.0" BUILD="/tmp/${PKG}-deb" ROOT="/usr/share/cinema-spatial" SOFA_SRC="$SRC/sofa/H4_HRIR_SOFA/H4_HRIR_SOFA/H4_96K_24bit_512tap_FIR_SOFA.sofa" @@ -12,7 +12,7 @@ SOFA_SRC="$SRC/sofa/H4_HRIR_SOFA/H4_HRIR_SOFA/H4_96K_24bit_512tap_FIR_SOFA.sofa" echo "=== 1. 准备目录 ===" rm -rf "$BUILD" mkdir -p "$BUILD/DEBIAN" "$BUILD/usr/bin" \ - "$BUILD${ROOT}/hrir" "$BUILD${ROOT}/sofa" \ + "$BUILD${ROOT}/hrir" "$BUILD${ROOT}/sofa" "$BUILD${ROOT}/loudness" \ "$BUILD/usr/share/pipewire/pipewire.conf.d" # ★ 源必须是项目目录: 旧版从 ~/.local/bin 拷, 那份可能落后于项目(2026-09-13 补回丢失的 cp) @@ -29,12 +29,34 @@ echo "=== 4. 项目脚本与文档 ===" cp "$SRC/生成配置.py" "$SRC/音频状态.py" "$SRC/换HRTF.sh" "$SRC/README.md" "$BUILD${ROOT}/" chmod 755 "$BUILD${ROOT}/换HRTF.sh" +echo "=== 4b. 响度归一化 (pipe 插件挂的自研 DSP) ===" +# ★ 包内一律用 ASCII 路径。pipe 插件的 command 传中文路径时**静默失效** +# —— 节点照建、音频绕过、DSP 不 fork、零日志(2026-09-13 排了一整轮)。 +# DSP = $ROOT/loudness/loudness_norm.py +# wrapper = /usr/bin/collaplex-loudness-norm +# 项目开发目录仍是中文(响度归一化/), 只在打包时改名。 +cp "$SRC/响度归一化/loudness_norm.py" "$SRC/响度归一化/read_state.py" "$BUILD${ROOT}/loudness/" +cat > "$BUILD/usr/bin/collaplex-loudness-norm" <<'WRAP' +#!/bin/sh +# Collaplex 响度归一化 — pipe 插件入口(wrapper) +# ★ pipe 的 command 只支持「程序 + 一个参数」, 参数只能在这个脚本里设; +# ★ 路径必须纯 ASCII —— 中文路径会被 pipe 插件静默忽略 +# (wrapper 是 ASCII, 它内部再去 exec 什么名字都没关系)。 +export LN_MAX_BOOST=20 +export LN_MAX_CUT=20 +export LN_RATE=96000 +exec /usr/bin/python3 /usr/share/cinema-spatial/loudness/loudness_norm.py +WRAP +chmod 755 "$BUILD/usr/bin/collaplex-loudness-norm" + echo "=== 5. PipeWire 配置 (用安装路径生成) ===" mkdir -p "$BUILD${ROOT}/web" -cp "$SRC/web/index.html" "$SRC/web/webui.py" "$SRC/web/启动.sh" "$BUILD${ROOT}/web/" 2>/dev/null \ - || cp "$SRC/web/index.html" "$SRC/web/webui.py" "$BUILD${ROOT}/web/" +cp "$SRC/web/index.html" "$SRC/web/webui.py" "$SRC/web/启动.sh" "$BUILD${ROOT}/web/" +chmod 755 "$BUILD${ROOT}/web/启动.sh" +# LN 入口用包内 ASCII 路径(不是开发机的 ~/.local/bin) CINEMA_SPATIAL_IR="${ROOT}/hrir" \ +CINEMA_SPATIAL_LN="/usr/bin/collaplex-loudness-norm" \ CINEMA_SPATIAL_OUT="$BUILD/usr/share/pipewire/pipewire.conf.d/90-cinema-spatial.conf" \ python3 "$SRC/生成配置.py" cp "$SRC/91-clock.conf" \ @@ -49,18 +71,22 @@ Priority: optional Architecture: all Version: $VER Maintainer: edgevoid -Depends: pipewire, libmysofa1, mpv +Depends: pipewire, libmysofa1, mpv, xdg-utils Description: Cinema spatial audio for headphones (HRTF virtual surround) Renders audio into binaural using a SADIE-II human HRTF through a PipeWire filter-chain virtual sink, so ordinary headphones give a cinema-like sense of direction and space. . - Provides two virtual sinks (5.1 passthrough and stereo-upmixed) plus the - space-audio and mpv-cinema commands. + Provides two virtual sinks (5.1 passthrough and stereo-upmixed), the + space-audio / mpv-cinema commands, and a web console on 127.0.0.1:8788. . Core: image-source room acoustics tracing (8473 early-reflection paths, 0-80 ms) + Sabine statistical tail (80 ms-2 s) -> a 96 kHz true room impulse response, convolved binaurally; wet path energy-normalised. + . + Loudness normalisation: an external DSP fed by the filter-chain "pipe" + plugin levels every application to one adjustable target (-40..-6 dBFS) + at 6 dB/s slew, both channels sharing one gain so the image never drifts. EOF echo "=== 7. 打包 ===" @@ -69,9 +95,29 @@ cat > "$BUILD/DEBIAN/postinst" <<'EOF' set -e if [ "$1" = "configure" ]; then echo "collaplex-cinema-spatial 已安装。" - echo " 命令: 空间音频 开|开5.1|关|状态 mpv-影院 电影.mkv" - echo " 虚拟声卡定义: /usr/share/pipewire/pipewire.conf.d/" - echo " 生效: systemctl --user restart pipewire pipewire-pulse wireplumber" + echo " 命令: 空间音频 开|开5.1|关|状态 mpv-影院 电影.mkv" + echo " 控制台: http://127.0.0.1:8788/ (Collaplex 音效)" + echo " 虚拟声卡: /usr/share/pipewire/pipewire.conf.d/" + echo " 生效: systemctl --user restart pipewire pipewire-pulse wireplumber" + # 装完把控制台直接推给用户看: 先把面板起起来(已经在跑就不重复起), 再开浏览器。 + # 注意本脚本是 root 身份跑的, 要 su 到登录用户 + 补上 XDG_RUNTIME_DIR, + # 否则 xdg-open 找不到会话总线/显示, 一声不吭什么都不发生。 + U="" + if command -v loginctl >/dev/null 2>&1; then + U=$(loginctl list-sessions --no-legend 2>/dev/null | awk '{print $3}' | grep -v '^root$' | head -1 || true) + fi + if [ -z "$U" ] && command -v who >/dev/null 2>&1; then + U=$(who 2>/dev/null | awk 'NR==1{print $1}' || true) + fi + if [ -n "$U" ] && [ "$U" != "root" ]; then + UID_=$(id -u "$U" 2>/dev/null || true) + if [ -n "$UID_" ]; then + su - "$U" -c "XDG_RUNTIME_DIR=/run/user/$UID_ /usr/share/cinema-spatial/web/启动.sh" >/dev/null 2>&1 || true + sleep 2 + su - "$U" -c "XDG_RUNTIME_DIR=/run/user/$UID_ setsid -f xdg-open http://127.0.0.1:8788/ >/dev/null 2>&1" || true + echo " (已为用户 $U 启动控制台并打开浏览器)" + fi + fi fi EOF chmod 755 "$BUILD/DEBIAN/postinst" @@ -82,4 +128,4 @@ ls -lh "$HOME/桌面/${PKG}_${VER}_all.deb" echo dn=$(dpkg-deb -c "$HOME/桌面/${PKG}_${VER}_all.deb" | wc -l) echo "包内文件数: $dn" -dpkg-deb -c "$HOME/桌面/${PKG}_${VER}_all.deb" | awk '{print $6}' | head -30 +dpkg-deb -c "$HOME/桌面/${PKG}_${VER}_all.deb" | awk '{print $6}' | grep -v "/$" diff --git a/生成配置.py b/生成配置.py index d6f8d3f..ae063bb 100644 --- a/生成配置.py +++ b/生成配置.py @@ -37,6 +37,94 @@ CLOCK_OUT = os.environ.get("CINEMA_SPATIAL_CLOCK") or os.path.expanduser( # 单声道模式(单声道蓝牙音响/耳机): 状态文件三态, 缺省自动判目标设备声道数 MONO_STATE = os.path.expanduser("~/.local/state/cinema-spatial/mono.json") +# 响度归一化(2026-09-13): 在图的最前端插一级 pipe 插件, 挂自研 DSP 做全系统响度统一。 +# 状态: ~/.local/state/cinema-spatial/loudness.json {"on": bool, "target_db": float} +LOUDNESS_STATE = os.path.expanduser("~/.local/state/cinema-spatial/loudness.json") +# ★ LN_ENTRY 必须是**纯 ASCII 路径**: pipe 插件的 command 走 shebang 式 exec, 实测传中文 +# 路径时它一声不吭 —— 节点建得出来、音频直接绕过去、DSP 根本不 fork、日志无任何错。 +# wrapper 内部再去 exec 中文路径的 DSP 是没问题的。 +LN_ENTRY = (os.environ.get("CINEMA_SPATIAL_LN") + or os.path.expanduser("~/.local/bin/collaplex-loudness-norm")) + + +def loudness_cfg() -> tuple[bool, float]: + """响度归一化开关 + 目标电平(dBFS). + + 判定顺序: 环境变量 CINEMA_SPATIAL_LOUDNESS(0/1) > 状态文件 > 默认**开**。 + + ★ 只插在**立体声上混**图里 —— 那才是抖音/网页/音乐走的路; 5.1 图放的是专业混音 + 片源, 响度本来就规矩, 不掺和。 + """ + on = True + target = -16.0 + try: + with open(LOUDNESS_STATE, encoding="utf-8") as fh: + d = json.load(fh) + if isinstance(d, dict): + on = bool(d.get("on", True)) + target = float(d.get("target_db") or -16.0) + except (OSError, ValueError, TypeError): + pass + env = os.environ.get("CINEMA_SPATIAL_LOUDNESS") + if env in ("0", "1"): + on = env == "1" + return on, target + + +def loudness_instance() -> list[str]: + """独立的响度归一化 filter-chain 实例(**串在上混图前面**)。 + + ★ 为什么单开一级, 而不是把 pipe 节点塞进上混图: + 实测塞进那个 40+ 节点的大图里 —— 不管放图入口、copy 之后、还是输出末端 —— + pipe 都会被正常 fork、数据也在稳定流动(每 0.5s 47 个块), 但它收到的永远是 + 恒定 -43 dBFS 的"空数据", 音频直接绕过去, 日志里没有任何报错。同一个 pipe + 放在 copy->pipe->copy 的小图里完全正常(实测收到 -12.04 dBFS 的真实节目电平)。 + 拆成两级串联绕开这个坑; 归一化本来就是独立一级, 开关/调参也更干净 + (改它不用重建那张大图)。 + + ★ command 只能是"程序 + **一个**参数" —— pipe 走 shebang 式 exec, 多一个就炸 + (env A=B python3 x.py 会报 "use -[v]S to pass options in shebang lines")。 + 参数全在 wrapper 里, 这里只给脚本路径, 而且**必须是纯 ASCII 路径**: + 实测传中文路径时 pipe 一声不吭 —— 节点照建、音频绕过、无任何日志。 + + ★ capture 用原来的 sink 名(cinema_spatial_up_sink): 用户/CLI 视角不变, + 所有软件照样往这个默认 sink 推流, 归一化在它后面悄悄做掉。 + """ + return [ + " { name = libpipewire-module-filter-chain", + " flags = [ nofail ]", + " args = {", + ' node.description = "电影院空间音频 (响度归一化)"', + ' media.name = "Cinema Spatial Loudness"', + " filter.graph = {", + " nodes = [", + " { type = builtin label = copy name = c1L }", + " { type = builtin label = copy name = c1R }", + " { type = builtin label = pipe name = lnL", + f' config = {{ command = "{LN_ENTRY}" }} }}', + " { type = builtin label = pipe name = lnR", + f' config = {{ command = "{LN_ENTRY}" }} }}', + " { type = builtin label = copy name = c2L }", + " { type = builtin label = copy name = c2R }", + " ]", + " links = [", + ' { output = "c1L:Out" input = "lnL:In" }', + ' { output = "c1R:Out" input = "lnR:In" }', + ' { output = "lnL:Out" input = "c2L:In" }', + ' { output = "lnR:Out" input = "c2R:In" }', + " ]", + ' inputs = [ "c1L:In" "c1R:In" ]', + ' outputs = [ "c2L:Out" "c2R:Out" ]', + " }", + f" capture.props = {{ {VOL_PROP}node.name = cinema_spatial_up_sink " + "media.class = Audio/Sink audio.channels = 2 audio.position = [ FL FR ] }", + " playback.props = { node.name = cinema_spatial_up_norm " + 'node.target = "cinema_spatial_up_raw" audio.channels = 2 ' + "audio.position = [ FL FR ] }", + " }", + " }", + ] + def mono_cfg() -> bool: """是否把链的输出合并成单声道. @@ -305,9 +393,11 @@ def sink_upmix() -> list[str]: L = [" { name = libpipewire-module-filter-chain", " flags = [ nofail ]", " args = {", ' node.description = "电影院空间音频 (立体声上混)"', - ' media.name = "Cinema Spatial Upmix"', " filter.graph = {", " nodes = [", - " # ---- 第一段: 立体声矩阵上混成 5.1 (照抄官方 upmix) ----", - " { type = builtin label = copy name = copyFL }", + ' media.name = "Cinema Spatial Upmix"', " filter.graph = {", " nodes = ["] + # 响度归一化**不在**这张图里做 —— 它跑在独立的 filter-chain 实例上(见 + # loudness_instance), 由 cinema_spatial_up_sink 收流、再把结果推给这张图。 + L += [" # ---- 第一段: 立体声矩阵上混成 5.1 (照抄官方 upmix) ----", + " { type = builtin label = copy name = copyFL }", " { type = builtin label = copy name = copyFR }", " { type = builtin label = copy name = copyOFL }", " { type = builtin label = copy name = copyOFR }", @@ -332,8 +422,8 @@ def sink_upmix() -> list[str]: if mono: L += mono_block(" ") outs = '"monoOut:Out"' - L += [" ]", " links = [", - ' { output = "copyFL:Out" input = "mixF:In 1" }', + L += [" ]", " links = ["] + L += [' { output = "copyFL:Out" input = "mixF:In 1" }', ' { output = "copyFR:Out" input = "mixF:In 2" }', ' { output = "copyFL:Out" input = "copyOFR:In" }', ' { output = "copyFR:Out" input = "copyOFL:In" }', @@ -358,8 +448,10 @@ def sink_upmix() -> list[str]: L += [" ]", ' inputs = [ "copyFL:In" "copyFR:In" ]', f' outputs = [ {outs} ]', " }", - f" capture.props = {{ {VOL_PROP}node.name = cinema_spatial_up_sink media.class = Audio/Sink " - "audio.channels = 2 audio.position = [ FL FR ] }", + # ★ 这一级不再是默认 sink —— sink 名让给前面的归一化实例, 由它的 + # node.target = "cinema_spatial_up_raw" 把流推到这里。 + " capture.props = { node.name = cinema_spatial_up_raw " + "media.class = Audio/Sink audio.channels = 2 audio.position = [ FL FR ] }", f' playback.props = {{ node.name = cinema_spatial_up_out {TARGET_PROP}' + out_props(mono), " }", " }"] @@ -379,6 +471,9 @@ def main() -> int: os.makedirs(os.path.dirname(OUT), exist_ok=True) body: list[str] = ["# 电影院空间音频 自动生成, 改 IR/增益请改 生成配置.py 重跑", "context.modules = ["] body += sink_51() + # 响度归一化: 独立一级, 串在上混图前面(开关见 ~/.local/state/cinema-spatial/loudness.json) + if loudness_cfg()[0]: + body += loudness_instance() body += sink_upmix() body += ["]", ""] with open(OUT, "w", encoding="utf-8") as fh: