From 3b6ce2c57ee67182e0484923346e0966fdfe98b9 Mon Sep 17 00:00:00 2001 From: lou Date: Sun, 20 Sep 2026 13:40:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=82=E6=95=B0=E6=8C=81=E4=B9=85=E5=8C=96:?= =?UTF-8?q?=20=E9=9D=A2=E6=9D=BF=E6=94=B9=E5=8A=A8=E8=90=BD=E7=9B=98,=20?= =?UTF-8?q?=E9=87=8D=E5=90=AF/=E5=BC=80=E6=9C=BA=E5=90=8E=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题(09-20 实测复现): dsp/common.py 的 open_store() 是"文件不存在就零填充创建", 而 /dev/shm 是 tmpfs —— 重启后参数全变 0。听感上不是"参数没保存", 而是**"混响突然没了"** (湿量 0), 极易被当成链路故障去查。 修法: - common.py: 加 params.json 落盘(load_saved_params / save_params / restore_params); open_store() 检测到文件是"新建的"就从落盘恢复(带上下限夹取)。幂等 —— 两个声道进程 同时判定为新建也没关系, 写进去的值一样。 - web/server.py: 面板每次改参数(POST /api/eq|volume|wet)后 save_params() 落盘; 值没变不写。 - 初始化.py: 改成"优先用落盘, 没有才用默认值", 并报告参数来源。 实测(rm /dev/shm/collaplex-eq* + 重启 pipewire): 参数自动恢复, 与落盘逐值一致 (总音量 +2.0 / 湿量 0.600 / EQ 非零 30 段)。 落盘位置 ~/.local/state/cinema-spatial/params.json(与 loudness.json 同目录)。 README 加「参数持久化」节 + 坑表条目。 --- README.md | 32 +++++++++++++++++++++++++ dsp/common.py | 65 +++++++++++++++++++++++++++++++++++++++++++++++++-- dsp/初始化.py | 15 +++++++----- web/server.py | 1 + 打包deb.sh | 4 ++-- 5 files changed, 107 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8f848b2..4e0f79d 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,10 @@ collaplex-audio stop # 连链路一起 **功率平均**取参考(假设各方向互不相关), 这套 HRIR 按它算只需要压 1.5 dB; 但真实内容 (人声/低频居中, L ≈ R)两耳信号是**相加**的, 同相合并响应实测低频高 **7.5 dB**, 这才 是用户实际听到的量。按同相合并设计后低频压 7.2 dB, 端到端复测低频落到 0(见下)。 +- **参数必须落盘, 不能只活在 `/dev/shm` 里** —— 共享内存是 tmpfs, 重启即空。如果 + `open_store()` 只是"不存在就零填充创建", 重启后参数全变 0 —— 听感上不是"参数没保存", + 而是**"混响突然没了"**(湿量 0), 极易被当成链路故障去查(2026-09-20 实测复现)。 + 修法: 改成"文件是**新建的** → 从落盘恢复", 落盘由面板在每次改参数时写(见「参数持久化」)。 ## IR 校正(2026-09-20) @@ -189,6 +193,34 @@ collaplex-audio stop # 连链路一起 - 房间 IR 被压低频后峰值掉 3.7 dB, 湿路 `gain` 相应 **0.4 → 0.61** 补偿, 这样听感差异 纯粹来自"低频变干净"而不是"混响变小"。 +## 参数持久化(2026-09-20) + +面板上拖出来的 32 段 EQ / 总音量 / 湿量, 现在会**落盘**; 共享内存被清空后(开机、重启、 +`systemctl restart pipewire`)由 DSP 自动恢复。 + +**为什么需要**: `/dev/shm` 是 tmpfs, 重启就没了。修之前 `dsp/common.py` 的 `open_store()` +是"文件不存在就零填充创建" —— 参数全变 0, 听感上就是**混响直接消失**(湿量 0), 得重新调一遍。 + +**机制**: + +| 环节 | 做什么 | +|---|---| +| 面板改参数 | `POST /api/eq` `/api/volume` `/api/wet` → `common.save_params()` 落盘(值没变不写) | +| DSP 启动 | `common.open_store()` 判断文件是**新建的** → `restore_params()` 从落盘恢复(带上下限夹取) | +| 落盘缺失/损坏 | 退回 `defaults()`(全平直 / 0 dB / 0.3) | + +落盘在 `~/.local/state/cinema-spatial/params.json`(与响度归一化的 `loudness.json` 同目录): + +```json +{"eq": [2.5, 2.5, "... 32 段 ..."], "volume_db": 2.0, "wet": 0.6} +``` + +**实测**(`rm /dev/shm/collaplex-eq*` 后重启 pipewire): 参数自动恢复, 与落盘逐值一致 —— +总音量 +2.0 / 湿量 0.600 / EQ 非零 30 段 ✓ + +**注意**: 落盘是"最后一次改动的快照", 不是"出厂默认"。要回到出厂默认就删掉 `params.json` +并清掉共享内存(`rm /dev/shm/collaplex-eq*`), 再重启链路。 + ## 实测(2026-09-19) | 指标 | 数据 | diff --git a/dsp/common.py b/dsp/common.py index 1d9db55..3c94f2c 100644 --- a/dsp/common.py +++ b/dsp/common.py @@ -14,6 +14,7 @@ DSP 是**每声道一个进程**(pipe 插件按声道 fork, 两个进程命令 from __future__ import annotations import fcntl +import json import mmap import os import struct @@ -24,6 +25,12 @@ EQ_PATH = "/dev/shm/collaplex-eq" LOCK_PATH = "/dev/shm/collaplex-eq.lock" LOUDNESS_SHM = "/dev/shm/collaplex-loudness" +# 参数落盘位置: 面板每次改参数都会写这里, 共享内存不存在时(开机/重启后)自动恢复。 +# 没有它的话 /dev/shm 一清空参数就全归零 —— 听感上就是"混响突然没了"(湿量 0)。 +# 与响度归一化的状态文件 loudness.json 同目录。 +STATE_DIR = os.path.expanduser("~/.local/state/cinema-spatial") +PARAMS_JSON = os.path.join(STATE_DIR, "params.json") + GAIN_LIMIT = 15.0 VOLUME_LIMIT = 12.0 WET_LIMIT = 1.0 @@ -48,15 +55,69 @@ STATE_OFF = _PARAMS.size + _VER.size SIZE = STATE_OFF + _STATE.size +def load_saved_params() -> tuple[list[float], float, float] | None: + """读落盘的参数; 文件不存在或内容坏了都返回 None。""" + try: + with open(PARAMS_JSON, encoding="utf-8") as fh: + data = json.load(fh) + gains = [float(x) for x in data["eq"]] # type: ignore[union-attr] + if len(gains) != EQ_BANDS: + return None + return (gains, float(data["volume_db"]), float(data["wet"])) # type: ignore[arg-type] + except (OSError, ValueError, KeyError, TypeError): + return None + + +def save_params(gains: list[float], volume_db: float, wet: float) -> None: + """把参数落盘(先写临时文件再改名, 避免别处读到半截)。值没变就不写。""" + old = load_saved_params() + if old is not None and old[0] == gains and old[1] == volume_db and old[2] == wet: + return + try: + os.makedirs(STATE_DIR, exist_ok=True) + tmp = PARAMS_JSON + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump({"eq": gains, "volume_db": volume_db, "wet": wet}, fh) + os.replace(tmp, PARAMS_JSON) + except OSError: + pass # 落盘失败不该影响听音(比如状态目录不可写), 静默降级 + + +def restore_params(store: mmap.mmap) -> None: + """把落盘的参数(没有则默认值)恢复进共享内存, 带上下限夹取。""" + saved = load_saved_params() + if saved is None: + gains, volume_db, wet = defaults() + else: + raw_gains, raw_vol, raw_wet = saved + gains = [max(-GAIN_LIMIT, min(GAIN_LIMIT, g)) for g in raw_gains] + volume_db = max(-VOLUME_LIMIT, min(VOLUME_LIMIT, raw_vol)) + wet = max(0.0, min(WET_LIMIT, raw_wet)) + write_params(store, gains, volume_db, wet) + + def open_store() -> mmap.mmap: - """打开(不存在则创建)共享内存映射。""" + """打开(不存在则创建)共享内存映射。 + + 文件是**新创建**的时候(开机/重启后 /dev/shm 已清空)顺手把落盘的参数恢复进去 —— + 否则参数全是 0, 听感上就是"混响没了、EQ 也没了"。恢复幂等, 两个声道进程同时判定 + 为"新建"也不要紧(写进去的值一样)。 + """ + created = True + try: + created = os.path.getsize(EQ_PATH) < SIZE + except OSError: + pass 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) + store = mmap.mmap(fd, SIZE) finally: os.close(fd) + if created: + restore_params(store) + return store def read_params(store: mmap.mmap) -> tuple[list[float], float, float, int]: diff --git a/dsp/初始化.py b/dsp/初始化.py index 9f3df1a..da1afbf 100644 --- a/dsp/初始化.py +++ b/dsp/初始化.py @@ -1,6 +1,7 @@ -"""把共享内存参数写成默认值(安装脚本在共享内存不存在时调用)。 +"""把参数写进共享内存: 优先用上次落盘的(params.json), 没有则用默认值。 -默认: 全平直 / 总音量 0 dB / 混响湿量 0.3。 +安装脚本在共享内存不存在时调用它。平时开机后由 dsp/common.py 的 open_store() +自动完成同样的恢复(见那里的注释), 所以这个入口主要给安装/手动修复用。 """ from __future__ import annotations @@ -14,10 +15,12 @@ import common def main() -> None: store = common.open_store() - gains, volume_db, wet = common.defaults() - version = common.write_params(store, gains, volume_db, wet) - print("默认参数已写入: 全平直 / 总音量 %+.1f dB / 湿量 %.2f (版本 %d)" - % (volume_db, wet, version)) + common.restore_params(store) # 显式恢复一次(幂等) + gains, volume_db, wet, version = common.read_params(store) + source = "落盘文件" if common.load_saved_params() is not None else "默认值" + nonzero = sum(1 for x in gains if abs(x) > 1e-9) + print("参数已写入: 来源%s / 总音量 %+.1f dB / 湿量 %.2f / EQ 非零 %d 段 (版本 %d)" + % (source, volume_db, wet, nonzero, version)) if __name__ == "__main__": diff --git a/web/server.py b/web/server.py index fc9aa05..903f041 100644 --- a/web/server.py +++ b/web/server.py @@ -180,6 +180,7 @@ class Handler(BaseHTTPRequestHandler): return common.write_params(self.store, gains, volume_db, wet) + common.save_params(gains, volume_db, wet) # 落盘, 重启后自动恢复 self._send_json({"ok": True, "gains": gains, "volume_db": volume_db, "wet": wet}) def log_message(self, format: str, *args: object) -> None: diff --git a/打包deb.sh b/打包deb.sh index 19ea544..cc5dfc4 100755 --- a/打包deb.sh +++ b/打包deb.sh @@ -26,8 +26,8 @@ for d in dsp bin pipewire web hrir reverb 脚本 systemd; do cp -a "$ROOT/$d" "$STAGE/usr/share/collaplex-audio/" done cp "$ROOT/README.md" "$STAGE/usr/share/collaplex-audio/" -# 备份文件不入包(IR 的 .orig 备份只留在源码树里) -find "$STAGE" -name "*.orig.wav" -delete +# 备份文件不入包(IR 的 .orig / .pre-eq 备份只留在源码树里) +find "$STAGE" \( -name "*.orig.wav" -o -name "*.pre-eq.wav" \) -delete find "$STAGE/usr/share" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true chmod -R a+rX "$STAGE/usr/share"