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
+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":