前端: 抽公共层(dsp/common.py 共享内存协议)+ 32段EQ/总音量 DSP(dsp/eq.py, scipy sosfilt) + 本地控制面板(web/, 滑块+滚轮捕捉); 安装脚本接 30-collaplex-eq; pyright strict 0 error

This commit is contained in:
lou
2026-09-19 18:34:56 +08:00
parent f32c2cad24
commit 5a4b11092c
11 changed files with 701 additions and 6 deletions
+11
View File
@@ -37,6 +37,17 @@ filters2 = [ { type = bq_highshelf freq = 6000 gain = 0.0 } ] # 高频架
★ 坑: `param_eq` 的端口名**带编号** —— `In 1` / `Out 1`(走 filters1)、`In 2` / `Out 2`(走 filters2)。
写成 `:In` / `:Out` 会报 `unknown input port` 并让**整张图加载失败**(模块静默降级成直通, 节点都不建)。
## 控制面板
```sh
bash 脚本/面板.sh # http://127.0.0.1:8789
```
- 电平归一化(响度目标)、总音量、32 段 EQ **全部是滑块**:滚轮调节、Shift+滚轮微调(0.1 dB)、双击归零
- EQ 是 1/3 倍频程 32 段(20 Hz … 25 kHz),每段 ±15 dB,**实时生效**(DSP 从共享内存读参数,不用重装)
- 参数落点:`/dev/shm/collaplex-eq`(前端写、DSP 读,版本号保证不成半更新);响度目标写在
`~/.local/state/cinema-spatial/loudness.json`(归一化 DSP 每秒重读)
## 实测(2026-09-19)
| 指标 | 数据 |
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# Collaplex 32 段 EQ + 总音量 —— pipe 插件入口
#
# pipe 插件的 command 只支持「程序 + 一个参数」, 所以参数全写在这里;
# 且路径必须是纯 ASCII(中文路径会静默失效)。
# 用项目 venv 的解释器(带 scipy/numpy)。
export CX_RATE=96000
exec "__PY__" "__DSP__"
+100
View File
@@ -0,0 +1,100 @@
"""Collaplex 效果 DSP 公共部分: 前端 <-> DSP 的共享内存协议。
前端(web/server.py) 与 DSP 进程(pipe 插件载荷)通过这一块共享内存通信:
参数区: 32 段 EQ 增益(dB) + 总音量(dB) <- 前端写, DSP 读
版本区: 参数版本号 <- 前端写完参数后 +1
状态区: 输入/输出 RMS 与峰值(dB) <- DSP 写, 前端读
版本号最后写, DSP 只在版本号变化时重读参数, 所以前端一次改多个值也不会让
DSP 读到半更新的数据。
"""
from __future__ import annotations
import mmap
import os
import struct
EQ_BANDS = 32
EQ_PATH = "/dev/shm/collaplex-eq"
# 1/3 倍频程中心频率(20 Hz 起, 32 段, 覆盖到 25 kHz)
EQ_FREQS: list[float] = [
20.0, 25.0, 31.5, 40.0, 50.0, 63.0, 80.0, 100.0,
125.0, 160.0, 200.0, 250.0, 315.0, 400.0, 500.0, 630.0,
800.0, 1000.0, 1250.0, 1600.0, 2000.0, 2500.0, 3150.0, 4000.0,
5000.0, 6300.0, 8000.0, 10000.0, 12500.0, 16000.0, 20000.0, 25000.0,
]
GAIN_LIMIT = 15.0
VOLUME_LIMIT = 12.0
_DATA = struct.Struct("<33d") # 32 段增益 + 总音量
_VER = struct.Struct("<I") # 参数版本号
_STATE = struct.Struct("<6d") # in_rms in_peak out_rms out_peak 推子(dB) 保留
DATA_OFF = 0
VER_OFF = _DATA.size
STATE_OFF = _DATA.size + _VER.size
SIZE = STATE_OFF + _STATE.size
def open_store() -> mmap.mmap:
"""打开(不存在则创建)共享内存映射。"""
fd = os.open(EQ_PATH, os.O_RDWR | os.O_CREAT, 0o666)
try:
if os.fstat(fd).st_size < SIZE:
os.ftruncate(fd, SIZE)
return mmap.mmap(fd, SIZE)
finally:
os.close(fd)
def read_params(store: mmap.mmap) -> tuple[list[float], float, int]:
"""读参数: (32 段增益 dB, 总音量 dB, 版本号)。"""
store.seek(DATA_OFF)
values = _DATA.unpack(store.read(_DATA.size))
store.seek(VER_OFF)
version = _VER.unpack(store.read(_VER.size))[0]
return list(values[:EQ_BANDS]), float(values[EQ_BANDS]), int(version)
def write_params(store: mmap.mmap, gains: list[float], volume_db: float) -> int:
"""写参数并让版本号 +1, 返回新版本号。"""
if len(gains) != EQ_BANDS:
raise ValueError("gains 长度必须是 %d" % EQ_BANDS)
store.seek(VER_OFF)
version = int(_VER.unpack(store.read(_VER.size))[0])
store.seek(DATA_OFF)
store.write(_DATA.pack(*[float(g) for g in gains], float(volume_db)))
version = (version + 1) & 0xFFFFFFFF
store.seek(VER_OFF)
store.write(_VER.pack(version))
store.flush()
return version
def read_state(store: mmap.mmap) -> dict[str, float]:
"""读状态(DSP 写的电平)。"""
store.seek(STATE_OFF)
in_rms, in_peak, out_rms, out_peak, gain_db, _spare = _STATE.unpack(store.read(_STATE.size))
return {
"in_rms": float(in_rms),
"in_peak": float(in_peak),
"out_rms": float(out_rms),
"out_peak": float(out_peak),
"gain_db": float(gain_db),
}
def write_state(store: mmap.mmap, in_rms: float, in_peak: float, out_rms: float,
out_peak: float, gain_db: float) -> None:
"""写状态(DSP 侧调用)。"""
store.seek(STATE_OFF)
store.write(_STATE.pack(float(in_rms), float(in_peak), float(out_rms),
float(out_peak), float(gain_db), 0.0))
def defaults() -> tuple[list[float], float]:
"""默认参数: 全平直 + 0 dB 总音量。"""
return [0.0] * EQ_BANDS, 0.0
+102
View File
@@ -0,0 +1,102 @@
"""32 段 EQ + 总音量 —— pipe 插件载荷(单声道块进, 单声道块出)。
参数实时来自共享内存(dsp/common.py): 32 段增益(dB) + 总音量(dB)。
做法: 每段一个 peaking biquad, 32 段级联成二阶节(sos), 用 scipy.signal.sosfilt
逐块滤波(C 实现, 保留滤波状态, 无额外延迟), 末级乘总音量。
"""
from __future__ import annotations
import math
import os
import sys
import numpy as np
from scipy.signal import sosfilt
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import common # noqa: E402
RATE = int(os.environ.get("CX_RATE", "96000"))
BLOCK_BYTES = 4096
N = BLOCK_BYTES // 4 # 每块样本数(1024)
Q = 4.318 # 1/3 倍频程
def design_peaking(freq: float, gain_db: float, fs: int, q: float) -> list[float]:
"""RBJ cookbook 的 peaking biquad, 返回一行 sos [b0 b1 b2 1 a1 a2]。
gain_db = 0 时结果恰好是单位传递函数, 所以 32 段可以一直挂着(不必增删节点)。
"""
amp = 10.0 ** (gain_db / 40.0)
w0 = 2.0 * math.pi * freq / fs
alpha = math.sin(w0) / (2.0 * q)
cos_w0 = math.cos(w0)
b0 = 1.0 + alpha * amp
b1 = -2.0 * cos_w0
b2 = 1.0 - alpha * amp
a0 = 1.0 + alpha / amp
a1 = -2.0 * cos_w0
a2 = 1.0 - alpha / amp
return [b0 / a0, b1 / a0, b2 / a0, 1.0, a1 / a0, a2 / a0]
def build_sos(gains: list[float], fs: int) -> np.ndarray:
"""32 段增益 -> sos 矩阵(32 x 6)。"""
return np.array([design_peaking(freq, gain, fs, Q)
for freq, gain in zip(common.EQ_FREQS, gains)], dtype=np.float64)
def level_db(block: np.ndarray) -> tuple[float, float]:
"""返回 (RMS dBFS, 峰值 dBFS)。"""
if block.size == 0:
return -120.0, -120.0
rms = float(np.sqrt(np.mean(block * block)))
peak = float(np.max(np.abs(block)))
rms_db = 20.0 * math.log10(rms) if rms > 1e-9 else -120.0
peak_db = 20.0 * math.log10(peak) if peak > 1e-9 else -120.0
return rms_db, peak_db
def main() -> None:
store = common.open_store()
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
version = -1
sos = build_sos(common.defaults()[0], RATE)
zi = np.zeros((sos.shape[0], 2), dtype=np.float64)
volume = 1.0
volume_db = 0.0
while True:
raw = stdin.read(BLOCK_BYTES)
if not raw:
break
if len(raw) % 4:
raw = raw[: len(raw) - (len(raw) % 4)]
gains, want_volume_db, new_version = common.read_params(store)
if new_version != version:
version = new_version
sos = build_sos(gains, RATE)
volume_db = want_volume_db
volume = 10.0 ** (volume_db / 20.0)
x = np.frombuffer(raw, dtype="<f4").astype(np.float64)
if x.size != N:
x = np.resize(x, N)
y, zi = sosfilt(sos, x, zi=zi)
out = y * volume
in_rms, in_peak = level_db(x)
out_rms, out_peak = level_db(np.clip(out, -1.0, 1.0))
common.write_state(store, in_rms, in_peak, out_rms, out_peak, volume_db)
stdout.write(np.clip(out, -1.0, 1.0).astype("<f4").tobytes())
stdout.flush()
if __name__ == "__main__":
main()
+1 -1
View File
@@ -63,7 +63,7 @@ context.modules = [
node.name = collaplex_hrtf_out
audio.channels = 2
audio.position = [ FL FR ]
node.target = "__TARGET__"
node.target = "__EQ_IN__"
}
}
}
+41
View File
@@ -0,0 +1,41 @@
# Collaplex 32 段 EQ + 总音量 —— 本文件由 脚本/安装.sh 生成(占位符 __EQ_WRAPPER__ / __TARGET__)
# 链路: ... -> HRTF 核心 -> [本图] 32 段 EQ + 总音量(pipe DSP) -> 数字输出
context.modules = [
{ name = libpipewire-module-filter-chain
flags = [ nofail ]
args = {
node.description = "Collaplex EQ + 总音量"
media.name = "Collaplex EQ"
filter.graph = {
nodes = [
{ type = builtin label = copy name = c1L }
{ type = builtin label = copy name = c1R }
{ type = builtin label = pipe name = eqpL config = { command = "__EQ_WRAPPER__" } }
{ type = builtin label = pipe name = eqpR config = { command = "__EQ_WRAPPER__" } }
{ type = builtin label = copy name = c2L }
{ type = builtin label = copy name = c2R }
]
links = [
{ output = "c1L:Out" input = "eqpL:In" }
{ output = "c1R:Out" input = "eqpR:In" }
{ output = "eqpL:Out" input = "c2L:In" }
{ output = "eqpR:Out" input = "c2R:In" }
]
inputs = [ "c1L:In" "c1R:In" ]
outputs = [ "c2L:Out" "c2R:Out" ]
}
capture.props = {
media.class = Audio/Sink
node.name = collaplex_eq_in
audio.channels = 2
audio.position = [ FL FR ]
}
playback.props = {
node.name = collaplex_eq_out
audio.channels = 2
audio.position = [ FL FR ]
node.target = "__TARGET__"
}
}
}
]
+13
View File
@@ -0,0 +1,13 @@
{
"include": ["dsp", "web"],
"exclude": ["dsp/loudness_norm.py", "dsp/状态.py"],
"extraPaths": ["dsp"],
"venvPath": ".",
"venv": ".venv",
"typeCheckingMode": "strict",
"pythonVersion": "3.11",
"reportMissingTypeStubs": false,
"reportUnknownMemberType": false,
"reportUnknownArgumentType": false,
"reportUnknownVariableType": false
}
+250
View File
@@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Collaplex 音效</title>
<style>
:root{
--bg:#0f0f0f; --card:#161616; --card-hi:#1c1c1c; --bd:#262626; --bd-hi:#343434;
--fg:#e8e8e8; --mut:#8a8a8a; --dim:#666; --ok:#4ade80; --ok-bg:#12211a; --ok-bd:#166534;
--r:12px;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--fg);
font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}
.wrap{max-width:1280px;margin:0 auto;padding:18px 20px 40px}
header{display:flex;align-items:baseline;gap:16px;border-bottom:1px solid var(--bd);padding-bottom:12px}
h1{font-size:15px;font-weight:600;margin:0;letter-spacing:.02em}
.hint{color:var(--dim);font-size:11px}
.meter{margin-left:auto;display:flex;gap:18px;align-items:baseline;color:var(--mut);font-size:11px}
.meter b{color:var(--ok);font-weight:500}
.rows{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:16px 0}
.card{background:var(--card);border:1px solid var(--bd);border-radius:var(--r);padding:14px 16px}
.card h2{margin:0 0 12px;font-size:12px;font-weight:500;color:var(--mut);letter-spacing:.06em}
.slider-row{display:flex;align-items:center;gap:14px}
.val{min-width:78px;text-align:right;color:var(--ok);font-variant-numeric:tabular-nums}
input[type=range]{-webkit-appearance:none;appearance:none;height:22px;background:transparent;outline:none;flex:1}
input[type=range]::-webkit-slider-runnable-track{height:4px;background:var(--bd-hi);border-radius:2px}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;margin-top:-6px;
border-radius:50%;background:var(--ok);border:0;cursor:grab;box-shadow:0 0 0 3px rgba(74,222,128,.15)}
input[type=range]:active::-webkit-slider-thumb{cursor:grabbing}
input[type=range]::-moz-range-track{height:4px;background:var(--bd-hi);border-radius:2px}
input[type=range]::-moz-range-thumb{width:16px;height:16px;border:0;border-radius:50%;background:var(--ok)}
.eqcard{background:var(--card);border:1px solid var(--bd);border-radius:var(--r);padding:16px 10px 10px}
.eq{display:grid;grid-template-columns:repeat(32,1fr);gap:2px;height:230px}
.band{display:flex;flex-direction:column;align-items:center;gap:4px}
.band input[type=range]{writing-mode:vertical-lr;direction:rtl;width:22px;height:190px;flex:none}
.band .db{font-size:9px;color:var(--dim);font-variant-numeric:tabular-nums;height:11px}
.freqs{display:grid;grid-template-columns:repeat(32,1fr);gap:2px;margin-top:6px}
.freqs span{font-size:9px;color:var(--dim);text-align:center;transform:rotate(-60deg);transform-origin:center;
white-space:nowrap;height:26px}
.actions{display:flex;gap:10px;margin-top:14px;align-items:center}
button{background:var(--card-hi);color:var(--fg);border:1px solid var(--bd-hi);border-radius:8px;
padding:7px 14px;font:inherit;font-size:12px;cursor:pointer}
button:hover{border-color:var(--ok);color:var(--ok)}
button.lit{background:var(--ok-bg);border-color:var(--ok-bd);color:#bbf7d0}
.toggle{display:flex;align-items:center;gap:7px;color:var(--mut);font-size:12px;cursor:pointer}
.state{margin-left:auto;color:var(--dim);font-size:11px}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Collaplex 音效</h1>
<span class="hint">滚轮调滑块 · Shift+滚轮微调 · 双击归零</span>
<div class="meter">
<span><b id="lvl-in">-∞</b> dBFS</span>
<span><b id="lvl-out">-∞</b> dBFS</span>
</div>
</header>
<div class="rows">
<div class="card">
<h2>电平归一化(响度目标)</h2>
<div class="slider-row">
<input type="range" id="loud" min="-40" max="-5" step="0.5" value="-16">
<span class="val" id="loud-val">-16.0 dB</span>
</div>
<div class="actions">
<label class="toggle"><input type="checkbox" id="loud-on" checked> 启用</label>
</div>
</div>
<div class="card">
<h2>总音量放大</h2>
<div class="slider-row">
<input type="range" id="vol" min="-12" max="12" step="0.5" value="6">
<span class="val" id="vol-val">+6.0 dB</span>
</div>
<div class="actions">
<button id="reset-all">全部归零</button>
<button id="reset-eq">EQ 归零</button>
<span class="state" id="state"></span>
</div>
</div>
</div>
<div class="eqcard">
<div class="eq" id="eq"></div>
<div class="freqs" id="freqs"></div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
let gains = new Array(32).fill(0);
let freqs = [];
let pending = null;
function fmt(v, plus) {
const s = (v > 0 && plus ? "+" : "") + v.toFixed(1);
return s + " dB";
}
// 滑块 + 滚轮捕捉
function bindWheel(el, onChange) {
el.addEventListener("wheel", (e) => {
e.preventDefault();
const step = e.shiftKey ? 0.1 : 0.5;
const dir = e.deltaY < 0 ? 1 : -1;
const next = parseFloat(el.value) + dir * step;
el.value = Math.min(parseFloat(el.max), Math.max(parseFloat(el.min), next));
onChange(parseFloat(el.value));
}, { passive: false });
}
function throttled(fn, ms) {
let last = 0, timer = null, lastArgs = null;
return function (...args) {
lastArgs = args;
const now = Date.now();
const run = () => { last = Date.now(); timer = null; fn(...lastArgs); };
if (now - last >= ms) run();
else if (!timer) timer = setTimeout(run, ms - (now - last));
};
}
const pushEq = throttled(async () => {
setState("写入 EQ…");
try {
const r = await fetch("/api/eq", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gains })
});
setState(r.ok ? "EQ 已生效" : "EQ 写入失败");
} catch (err) { setState("EQ 写入失败"); }
}, 90);
const pushVol = throttled(async (db) => {
setState("写入总音量…");
try {
const r = await fetch("/api/volume", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ db })
});
setState(r.ok ? "总音量已生效" : "写入失败");
} catch (err) { setState("写入失败"); }
}, 90);
const pushLoud = throttled(async (db, on) => {
setState("写入归一化…");
try {
const r = await fetch("/api/loudness", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target_db: db, on })
});
setState(r.ok ? "归一化已生效" : "写入失败");
} catch (err) { setState("写入失败"); }
}, 90);
function setState(text) { $("state").textContent = text; }
// 32 段 EQ 滑块
function buildEq() {
const box = $("eq"), fbox = $("freqs");
box.innerHTML = ""; fbox.innerHTML = "";
for (let i = 0; i < 32; i++) {
const band = document.createElement("div");
band.className = "band";
const db = document.createElement("div");
db.className = "db"; db.id = "db" + i; db.textContent = "0.0";
const sl = document.createElement("input");
sl.type = "range"; sl.min = "-15"; sl.max = "15"; sl.step = "0.5"; sl.value = "0";
sl.dataset.index = i;
sl.addEventListener("input", () => {
gains[i] = parseFloat(sl.value);
$("db" + i).textContent = gains[i].toFixed(1);
pushEq();
});
bindWheel(sl, (v) => {
gains[i] = v; $("db" + i).textContent = v.toFixed(1); pushEq();
});
sl.addEventListener("dblclick", () => {
sl.value = "0"; gains[i] = 0; $("db" + i).textContent = "0.0"; pushEq();
});
band.appendChild(db); band.appendChild(sl);
box.appendChild(band);
const f = document.createElement("span");
f.textContent = freqs[i] ? (freqs[i] >= 1000 ? (freqs[i] / 1000) + "k" : String(freqs[i])) : "";
fbox.appendChild(f);
}
}
// 同步 DSP 实时电平
async function poll() {
try {
const s = await (await fetch("/api/state")).json();
$("lvl-in").textContent = (s.levels.in_rms <= -120 ? "-∞" : s.levels.in_rms.toFixed(1));
$("lvl-out").textContent = (s.levels.out_rms <= -120 ? "-∞" : s.levels.out_rms.toFixed(1));
} catch (err) { /* 忽略 */ }
}
async function init() {
const s = await (await fetch("/api/state")).json();
freqs = s.freqs;
gains = s.gains.slice();
buildEq();
for (let i = 0; i < 32; i++) {
const sl = document.querySelector('.band input[data-index="' + i + '"]');
sl.value = String(gains[i]);
$("db" + i).textContent = gains[i].toFixed(1);
}
const loud = s.loudness || { target_db: -16, on: true };
$("loud").value = loud.target_db; $("loud-val").textContent = fmt(loud.target_db, false);
$("loud-on").checked = loud.on !== false;
$("vol").value = s.volume_db; $("vol-val").textContent = fmt(s.volume_db, true);
$("loud").addEventListener("input", (e) => {
const v = parseFloat(e.target.value);
$("loud-val").textContent = fmt(v, false);
pushLoud(v, $("loud-on").checked);
});
bindWheel($("loud"), (v) => { $("loud-val").textContent = fmt(v, false); pushLoud(v, $("loud-on").checked); });
$("loud-on").addEventListener("change", (e) => pushLoud(parseFloat($("loud").value), e.target.checked));
$("vol").addEventListener("input", (e) => {
const v = parseFloat(e.target.value);
$("vol-val").textContent = fmt(v, true);
pushVol(v);
});
bindWheel($("vol"), (v) => { $("vol-val").textContent = fmt(v, true); pushVol(v); });
$("reset-eq").addEventListener("click", () => {
gains = new Array(32).fill(0);
for (let i = 0; i < 32; i++) { document.querySelector('.band input[data-index="' + i + '"]').value = "0"; $("db" + i).textContent = "0.0"; }
pushEq();
});
$("reset-all").addEventListener("click", () => {
$("reset-eq").click();
$("vol").value = "0"; $("vol-val").textContent = fmt(0, true); pushVol(0);
});
setInterval(poll, 700);
poll();
}
init();
</script>
</body>
</html>
+154
View File
@@ -0,0 +1,154 @@
"""Collaplex 音效控制面板 —— 本地服务(纯标准库, 无第三方依赖)。
python3 web/server.py [端口] 默认 8789
页面: /
接口:
GET /api/state 参数 + DSP 实时电平
POST /api/eq {"gains": [32 个 dB]}
POST /api/volume {"db": 数值}
POST /api/loudness {"target_db": 数值, "on": true/false}
"""
from __future__ import annotations
import json
import os
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import cast
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "dsp"))
import common # noqa: E402
LOUDNESS_JSON = os.path.expanduser("~/.local/state/cinema-spatial/loudness.json")
UI = os.path.join(HERE, "index.html")
DEFAULT_PORT = 8789
def _as_float(value: object, fallback: float = 0.0) -> float:
"""把 JSON 取到的值转 float(来源类型不安全, 这里只做值校验)。"""
if isinstance(value, bool):
return fallback
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value)
except ValueError:
return fallback
return fallback
def read_loudness() -> dict[str, object]:
"""读响度归一化设置(归一化 DSP 每秒重读这个文件)。"""
try:
with open(LOUDNESS_JSON, "r", encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict):
return cast(dict[str, object], data)
except (OSError, ValueError):
pass
return {"on": True, "target_db": -16.0}
def write_loudness(target_db: float, on: bool) -> None:
os.makedirs(os.path.dirname(LOUDNESS_JSON), exist_ok=True)
with open(LOUDNESS_JSON, "w", encoding="utf-8") as fh:
json.dump({"on": bool(on), "target_db": float(target_db)}, fh)
class Handler(BaseHTTPRequestHandler):
store = common.open_store()
protocol_version = "HTTP/1.1"
def log_message(self, format: str, *args: object) -> None:
pass
def _send(self, body: bytes, ctype: str, code: int = 200) -> None:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _send_json(self, obj: object, code: int = 200) -> None:
self._send(json.dumps(obj, ensure_ascii=False).encode("utf-8"),
"application/json; charset=utf-8", code)
def _read_json(self) -> dict[str, object]:
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
data = json.loads(raw.decode("utf-8"))
except ValueError:
return {}
return cast(dict[str, object], data) if isinstance(data, dict) else {}
def do_GET(self) -> None:
if self.path in ("/", "/index.html"):
try:
with open(UI, "rb") as fh:
self._send(fh.read(), "text/html; charset=utf-8")
except OSError:
self._send("面板文件缺失".encode("utf-8"), "text/plain; charset=utf-8", 500)
return
if self.path == "/api/state":
gains, volume_db, _ver = common.read_params(self.store)
loudness = read_loudness()
self._send_json({
"freqs": common.EQ_FREQS,
"gains": gains,
"volume_db": volume_db,
"gain_limit": common.GAIN_LIMIT,
"volume_limit": common.VOLUME_LIMIT,
"loudness": {
"target_db": _as_float(loudness.get("target_db", -16.0), -16.0),
"on": bool(loudness.get("on", True)),
},
"levels": common.read_state(self.store),
})
return
self._send_json({"error": "not found"}, 404)
def do_POST(self) -> None:
data = self._read_json()
if self.path == "/api/eq":
raw_gains = data.get("gains")
if not isinstance(raw_gains, list) or len(raw_gains) != common.EQ_BANDS:
self._send_json({"error": "gains 必须是 %d 个数" % common.EQ_BANDS}, 400)
return
items = cast(list[object], raw_gains)
gains = [max(-common.GAIN_LIMIT, min(common.GAIN_LIMIT, _as_float(item))) for item in items]
_g, volume_db, _v = common.read_params(self.store)
version = common.write_params(self.store, gains, volume_db)
self._send_json({"ok": True, "version": version})
return
if self.path == "/api/volume":
volume_db = max(-common.VOLUME_LIMIT,
min(common.VOLUME_LIMIT, _as_float(data.get("db", 0.0))))
gains, _old, _v = common.read_params(self.store)
version = common.write_params(self.store, gains, volume_db)
self._send_json({"ok": True, "version": version, "volume_db": volume_db})
return
if self.path == "/api/loudness":
current = read_loudness()
target_db = _as_float(data.get("target_db", current.get("target_db", -16.0)), -16.0)
on = bool(data.get("on", current.get("on", True)))
write_loudness(max(-40.0, min(-5.0, target_db)), on)
self._send_json({"ok": True, "loudness": read_loudness()})
return
self._send_json({"error": "not found"}, 404)
def main() -> None:
port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
print("Collaplex 音效控制面板: http://127.0.0.1:%d" % port, flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+17 -5
View File
@@ -19,6 +19,7 @@ WET="${WET:-0.3}"
VOL_DB="${VOL_DB:-6}"
VOL="$(/usr/bin/python3 -c "print(f'{10**(${VOL_DB}/20):.4f}')")"
HRTF_IN="collaplex_hrtf_in"
EQ_IN="collaplex_eq_in"
# ---- 1. 探测数字输出 ----
TARGET=$(pw-dump | /usr/bin/python3 -c '
@@ -39,23 +40,28 @@ echo "数字输出: $TARGET"
# ---- 2. 部署 pipe 插件入口(必须 ASCII 路径) ----
mkdir -p "$(dirname "$WRAPPER")"
sed -e "s|__DSP__|$ROOT/dsp/loudness_norm.py|" "$ROOT/bin/collaplex-loudness-norm" > "$WRAPPER"
chmod 755 "$WRAPPER"
echo "已装入口: $WRAPPER"
sed -e "s|__PY__|$ROOT/.venv/bin/python|" -e "s|__DSP__|$ROOT/dsp/eq.py|" \
"$ROOT/bin/collaplex-eq" > "$HOME/.local/bin/collaplex-eq"
chmod 755 "$WRAPPER" "$HOME/.local/bin/collaplex-eq"
echo "已装入口: $WRAPPER / $HOME/.local/bin/collaplex-eq"
# ---- 3. 渲染两份配置 ----
mkdir -p "$CONF_DIR"
sed -e "s|__WRAPPER__|$WRAPPER|" -e "s|__HRTF_IN__|$HRTF_IN|" \
"$ROOT/pipewire/10-collaplex-vsink.conf" > "$CONF_DIR/10-collaplex-vsink.conf"
sed -e "s|__HRIR__|$ROOT/hrir|" -e "s|__REVERB__|$ROOT/reverb/房间混响IR-96k.wav|" \
-e "s|__TARGET__|$TARGET|" -e "s|__WET__|$WET|" -e "s|__VOL__|$VOL|" \
-e "s|__EQ_IN__|$EQ_IN|" -e "s|__WET__|$WET|" -e "s|__VOL__|$VOL|" \
"$ROOT/pipewire/20-collaplex-hrtf.conf" > "$CONF_DIR/20-collaplex-hrtf.conf"
echo "已装配置: 10-collaplex-vsink.conf / 20-collaplex-hrtf.conf(混响湿量 $WET, 总音量 +${VOL_DB}dB)"
sed -e "s|__EQ_WRAPPER__|$HOME/.local/bin/collaplex-eq|" -e "s|__TARGET__|$TARGET|" \
"$ROOT/pipewire/30-collaplex-eq.conf" > "$CONF_DIR/30-collaplex-eq.conf"
echo "已装配置: 10-collaplex-vsink.conf / 20-collaplex-hrtf.conf(湿量 $WET, 总音量 +${VOL_DB}dB) / 30-collaplex-eq.conf"
# ---- 4. 重启前清掉残留 DSP(占住共享槽位会让新实例起不来) ----
for p in /proc/[0-9]*; do
c=$(tr '\0' ' ' < "$p/cmdline" 2>/dev/null) || continue
case "$c" in
"/usr/bin/python3 "*dsp/loudness_norm.py*) kill -9 "${p#/proc/}" 2>/dev/null || true;;
*dsp/eq.py*) kill -9 "${p#/proc/}" 2>/dev/null || true;;
esac
done
sleep 1
@@ -72,5 +78,11 @@ for p in /proc/[0-9]*; do
c=$(tr '\0' ' ' < "$p/cmdline" 2>/dev/null) || continue
case "$c" in "/usr/bin/python3 "*dsp/loudness_norm.py*) n=$((n+1));; esac
done
echo "DSP 进程数: $n (应为 2)"
echo "DSP 进程数: $n (归一化, 应为 2)"
m=0
for p in /proc/[0-9]*; do
c=$(tr '\0' ' ' < "$p/cmdline" 2>/dev/null) || continue
case "$c" in */dsp/eq.py*) m=$((m+1));; esac
done
echo "EQ 进程数: $m (应为 2)"
echo -n "默认输出: "; wpctl inspect @DEFAULT_SINK@ 2>/dev/null | grep -m1 "node.name" | sed 's/.*= //'
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
# Collaplex 音效控制面板 —— 起本地服务(默认 8789)
cd "$(dirname "$0")/.." || exit 1
exec .venv/bin/python web/server.py "${1:-8789}"