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:
edgevoid
2026-09-13 18:06:52 +08:00
parent 3d3ce35507
commit 18cc030cac
11 changed files with 1085 additions and 55 deletions
+3
View File
@@ -13,6 +13,9 @@ hrir/
*.deb
/tmp*
# 本地备份(挪走的旧配置等, 不上仓)
备份/
# Python
__pycache__/
*.pyc
+182 -29
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
```
+19
View File
@@ -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 ] }
}
}
]
+298
View File
@@ -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
+128
View File
@@ -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()
+14
View File
@@ -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"
+58 -12
View File
@@ -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 "/$"
+102 -7
View File
@@ -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: