v1.2.0: 全系统响度归一化(pipe 插件挂自研 DSP) + 目标响度可调 + 控制台 4 路电平/增益历史
新增功能
- 响度归一化/: 挂未文档化的 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
This commit is contained in:
@@ -13,6 +13,9 @@ hrir/
|
||||
*.deb
|
||||
/tmp*
|
||||
|
||||
# 本地备份(挪走的旧配置等, 不上仓)
|
||||
备份/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
+182
-29
@@ -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 会把 <b>原始电平</b> 压变形(实测重叠) */
|
||||
.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 @@
|
||||
<section class="flow">
|
||||
<div class="col meters">
|
||||
<div class="meter">
|
||||
<div class="mhead"><b>原始电平</b>(虚拟声卡捕捉)<span class="num" id="raw-num">—</span></div>
|
||||
<div class="mhead"><b>原始电平</b>
|
||||
<span class="lg"><i style="background:rgb(74,222,128)"></i>L<i style="background:rgb(96,165,250)"></i>R</span>
|
||||
<span class="num" id="raw-num">—</span></div>
|
||||
<canvas id="raw"></canvas>
|
||||
<div class="ingain">
|
||||
<span>输入增益</span>
|
||||
@@ -194,9 +227,33 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="meter">
|
||||
<div class="mhead"><b>处理后电平</b><span class="num" id="post-num">—</span></div>
|
||||
<div class="mhead"><b>处理后电平</b>
|
||||
<span class="lg"><i style="background:rgb(74,222,128)"></i>L<i style="background:rgb(96,165,250)"></i>R</span>
|
||||
<span class="num" id="post-num">—</span></div>
|
||||
<canvas id="post"></canvas>
|
||||
</div>
|
||||
<!-- 响度归一化: 推子显示 DSP 当前施加的增益, 曲线是它的历史(看动态平衡过程) -->
|
||||
<div class="meter lnbox">
|
||||
<div class="mhead"><b>响度归一化</b>
|
||||
<span class="hint" style="margin-left:6px">自动拉平各软件响度</span>
|
||||
<span class="num" id="ln-num">未启用</span></div>
|
||||
<div class="lnfader">
|
||||
<div class="lntrack">
|
||||
<div class="lnfill" id="ln-fill"></div>
|
||||
<div class="lnzero"></div>
|
||||
<div class="lnk" id="ln-k"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 目标响度: 用户自己定「拉平到多大声」; DSP 每秒热读, 拖完 1 秒内生效, 不用重建 -->
|
||||
<div class="ingain lntgt">
|
||||
<span>目标响度</span>
|
||||
<input id="ln-tgt" type="range" min="-40" max="-6" step="1" value="-16">
|
||||
<b id="ln-tgt-v">-16</b>
|
||||
<span class="hint">dBFS</span>
|
||||
</div>
|
||||
<div class="lnfoot"><span id="ln-target">目标 —</span><span id="ln-loud">判据 —</span></div>
|
||||
<canvas id="ln"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col vsw" id="sw-raw" title="原始音频信号: 通/断">
|
||||
@@ -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){}
|
||||
}
|
||||
|
||||
|
||||
+88
-6
@@ -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("<f", mm, LN_TARGET_OFF)[0]
|
||||
now = time.monotonic()
|
||||
for i in (0, 1):
|
||||
b = i * LN_STRIDE
|
||||
rec.append((
|
||||
struct.unpack_from("<f", mm, b)[0], # e_smooth
|
||||
struct.unpack_from("<f", mm, b + 4)[0], # e_block
|
||||
struct.unpack_from("<f", mm, b + 12)[0], # gain_db
|
||||
now - struct.unpack_from("<d", mm, b + 16)[0],
|
||||
))
|
||||
finally:
|
||||
mm.close()
|
||||
finally:
|
||||
os.close(fd)
|
||||
except Exception:
|
||||
return out
|
||||
if not rec or min(r[3] for r in rec) > 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":
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+192
@@ -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
|
||||
```
|
||||
@@ -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 ] }
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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("<f", mm, base)[0]
|
||||
t = struct.unpack_from("<d", mm, base + 16)[0]
|
||||
except Exception:
|
||||
return 0.0
|
||||
if e != e or e < 0.0: # NaN
|
||||
return 0.0
|
||||
if abs(time.monotonic() - t) > 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("<f", mm, TARGET_OFF, target_db)
|
||||
struct.pack_into("<I", mm, RATE_OFF, int(RATE))
|
||||
except Exception:
|
||||
pass
|
||||
next_target_check = time.monotonic() + 1.0
|
||||
|
||||
# 平滑状态
|
||||
energy_smooth = 0.0
|
||||
gain_db = 0.0
|
||||
_n: int = 0
|
||||
|
||||
block_seconds = BLOCK / 4.0 / RATE # 每块时长(单声道 float32)
|
||||
alpha = 1.0 - math.exp(-block_seconds / WINDOW_S) if WINDOW_S > 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("<f", mm, TARGET_OFF, target_db)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
raw = stdin.read(BLOCK)
|
||||
except Exception:
|
||||
break
|
||||
if not raw:
|
||||
break
|
||||
|
||||
buf = carry + raw
|
||||
n = len(buf) - (len(buf) % 4)
|
||||
carry = buf[n:]
|
||||
if n == 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
x = np.frombuffer(buf[:n], dtype="<f4")
|
||||
if x.size == 0:
|
||||
continue
|
||||
|
||||
# ---- 1) 块能量 + 滑窗平滑 ----
|
||||
energy = float(np.mean(x.astype(np.float64) ** 2))
|
||||
energy_smooth += alpha * (energy - energy_smooth)
|
||||
if energy_smooth < 0.0:
|
||||
energy_smooth = 0.0
|
||||
|
||||
# ---- 2) 跨声道联合响度(两声道能量和) ----
|
||||
now = time.monotonic()
|
||||
base = slot * CH_STRIDE
|
||||
struct.pack_into("<f", mm, base, energy_smooth)
|
||||
struct.pack_into("<d", mm, base + 16, now)
|
||||
joint = energy_smooth + read_peer_energy(mm, other)
|
||||
|
||||
# ---- 3) 目标增益(dB) ----
|
||||
if joint > 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("<f4")
|
||||
|
||||
# ---- 7) 写回 + 状态(web 面板读这块内存画 4 路电平/推子) ----
|
||||
_n += 1
|
||||
struct.pack_into("<f", mm, base + 4, energy) # 本块能量(快响应)
|
||||
struct.pack_into("<f", mm, base + 8, peak) # 本块峰值
|
||||
struct.pack_into("<f", mm, base + 12, gain_db) # 实际增益 -> 推子
|
||||
struct.pack_into("<I", mm, base + 24, _n & 0xFFFFFFFF)
|
||||
if DEBUG and _n % 40 == 0:
|
||||
dbg("slot=%d blk=%d raw=%.6f smooth=%.8f joint=%.8f lvl=%.2f want=%.2f g=%.2f peak=%.4f" % (
|
||||
slot, _n, energy, energy_smooth, joint,
|
||||
10.0 * math.log10(joint + EPS) if joint > 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
|
||||
@@ -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("<f", mm, base)[0]
|
||||
e_block = struct.unpack_from("<f", mm, base + 4)[0]
|
||||
peak = struct.unpack_from("<f", mm, base + 8)[0]
|
||||
gain = struct.unpack_from("<f", mm, base + 12)[0]
|
||||
t = struct.unpack_from("<d", mm, base + 16)[0]
|
||||
n = struct.unpack_from("<I", mm, base + 24)[0]
|
||||
age = time.monotonic() - t
|
||||
return {
|
||||
"e_smooth": e_smooth,
|
||||
"e_block": e_block,
|
||||
"peak": peak,
|
||||
"gain_db": gain,
|
||||
"n": n,
|
||||
"age": age,
|
||||
"alive": age < STALE_S,
|
||||
}
|
||||
|
||||
|
||||
def snapshot() -> 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("<f", mm, TARGET_OFF)[0]
|
||||
rate = struct.unpack_from("<I", mm, RATE_OFF)[0]
|
||||
ch = [read_slot(mm, 0), read_slot(mm, 1)]
|
||||
finally:
|
||||
mm.close()
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
out: dict[str, object] = {
|
||||
"ok": bool(ch[0]["alive"] or ch[1]["alive"]),
|
||||
"target_db": round(target, 2),
|
||||
"rate": rate,
|
||||
}
|
||||
for i, name in ((0, "L"), (1, "R")):
|
||||
c = ch[i]
|
||||
# 原始输入电平 = 快响应块能量; 处理后 = 原始 + 实际增益
|
||||
raw_db = db(c["e_block"])
|
||||
raw_pk = 20.0 * math.log10(c["peak"] + EPS) if c["peak"] > 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()
|
||||
Executable
+14
@@ -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"
|
||||
@@ -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 <edgevoid@users.noreply.github.com>
|
||||
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 "/$"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user