v1.3.0: 离线渲染(音频/视频→空间音频FLAC, 渲染锁+文件就绪检查) + 组件看门狗(30s核对期望状态并自愈)

- 离线渲染.py: pan上混5.1 → sofalizer(H4 SOFA) → 房间IR(afir) → loudnorm → 96k FLAC
  视频自动取音轨; 已是5.1的片源不重上混; 实测约 4.4 倍速
  两道锁: (1)渲染锁 —— 一轮任务期间拒绝新任务, 不排队堆积
          (2)文件就绪检查 —— 相隔1.2s两次stat大小/时间不变且ffprobe能读, 未落盘的跳过
  三个入口: 命令行 / 面板「离线渲染」区(拖拽或贴路径, 实时进度+日志+取消) / 右键「打开方式」
- 音频守护.py + config/组件配置单.json: 30s 核对 DSP进程数与槽位新鲜度/链输出连线/
  默认设备/虚拟声卡优先级/中间级音量/节点份数/面板/物理设备静音, 扫到就修
  (能温和就温和, 链路级才重建), 同类问题 90s 冷却, 连续 3 次修不好转只报警
- 组件配置单.md: 链路拓扑 + 组件表 + 六个必须 + 自愈表
- 面板: 控制滑块从图表卡片独立成「控制」卡片; 数字/模拟输出音量条拉长到各占一半
  (合计 = 虚拟声卡开关卡片总高); 新增离线渲染区
- 修: DSP 孤儿抢共享槽位导致重建后无声(claim_slot 抢不到槽位即退出 + 重建前收旧 DSP)
- 修: 打包会把正在用的 PipeWire 配置移开(prune_duplicate_confs 只在自己写到生效目录时清理)
- 修: 离线渲染的 SOFA/混响IR 改为多路径探测(开发目录与 deb 包内布局不同)
- .gitignore: 保留 reverb/ 房间IR(离线渲染要卷积的素材)
This commit is contained in:
edgevoid
2026-09-13 23:24:22 +08:00
parent 18cc030cac
commit d8c3a27821
17 changed files with 2134 additions and 100 deletions
+3
View File
@@ -5,7 +5,10 @@ sofa/
hrir/
# 测试/演示产物
# ★ 例外: reverb/ 里的房间 IR 是**离线渲染要卷积的素材**, 必须进版本库
# (没有它, clone 下来的人跑离线渲染会静默不带混响)。
*.wav
!reverb/*.wav
方向测试/
验证_*.log
+109 -5
View File
@@ -96,8 +96,17 @@ sudo dpkg -i collaplex-cinema-spatial_1.1.1_all.deb
```
安装内容:可执行命令写入 `/usr/bin/`,数据与 Web 控制台写入 `/usr/share/cinema-spatial/`,
配置写入 `/usr/share/pipewire/pipewire.conf.d/`。**用户级同名配置优先级高于系统级**,
因此放在 `~/.config/pipewire/pipewire.conf.d/` 的同名文件会覆盖系统配置。
配置写入 `/usr/share/pipewire/pipewire.conf.d/`。
> **★★ 同名配置只留一份 —— 两份并存 = 没声音**(2026-09-13 实测)
> PipeWire 对 `conf.d/*.conf` **不做"用户级覆盖系统级",两份都加载**。装完 deb 之后,
> 若 `~/.config/pipewire/pipewire.conf.d/` 里还有一份开发时生成的同名配置,
> 就会建出**两套同名节点**(实测 6 个节点各 2 份)—— 音频进哪一份由调度决定,
> 常常进了没有下游消费者的那一份 → 整条链静默。
> 同一时刻还有 4 个归一化 DSP 进程抢同一对槽位锁、两条链都往同一个物理设备推流。
> 自检:`python3 音频状态.py dup`(空 = 正常)/`空间音频 诊断`。
> 修法:把另一处同名文件改名 `.disabled-dup` 后重启 pipewire。本机定**用户级为唯一真源**,
> 系统级那两份已改名 `/usr/share/pipewire/pipewire.conf.d/{90-cinema-spatial,91-clock}.conf.disabled-dup`。
**生效**:`systemctl --user restart pipewire pipewire-pulse`(或注销重新登录)。
@@ -170,7 +179,9 @@ systemctl --user restart pipewire pipewire-pulse
- 命令:`空间音频 参数`(查看现值与可选项)、`空间音频 参数 hrtf H4-48k`、`参数 taps 1024`、`参数 rate 48000`。
- IR 目录选择优先级:`hrir/taps-<N>` > 模型名 > `hrir/current` 软链;模型与长度截断二者互斥。
- 长度截断:`work/裁HRIR.py <N>` 生成 `hrir/taps-<N>/`(末尾 3 ms 淡出,可选 512 / 1024 / 2048)。
- 采样率写入**用户级** `~/.config/pipewire/pipewire.conf.d/91-clock.conf`,同名覆盖系统配置,**无需 sudo**。
- 采样率写入**用户级** `~/.config/pipewire/pipewire.conf.d/91-clock.conf`,**无需 sudo**;
★ 它**不会覆盖**系统级那份,两份会被同时加载 → 生成器写完后会自动把别处的同名 conf
改名 `.disabled-dup`(见 `生成配置.py` 的 `prune_duplicate_confs()`)。
- 三者均为生成期常量,**修改后必须重建链路**。
- 实测结论:开启混响时 **HRIR 长度不是主要 CPU 成本** —— 2 秒房间 IR 的等效长度是 HRIR 的 40 倍,
截断 HRIR 既省不下多少 CPU,又会损失 HRTF 尾部使声音变干。因此**默认保持 H4-96k / 4800 tap**。
@@ -211,7 +222,80 @@ systemctl --user restart pipewire pipewire-pulse
不可写死(本机实测数字通道 1.003;部分设备会返回 0.001 的无效基数)。
- **输入增益**:链的输入电平,同时是**削波余量旋钮**,修改立即生效、无需重建。
注意:**键盘媒体键调节的正是这一级**(虚拟声卡是应用默认输出)。
★ 它与「响度归一化」是**同一段链上的两个旋钮**:你压小输入,DSP 就把增益补回来,
净效果趋近不变;补到 `MAX_BOOST = +20 dB` 上限(面板推子"顶格")之后再压小,才会真的变小声。
**要更大声请调「扬声器电平」,不要去压输入增益。**
- **统一响度音量**(「响度统一」那一行的横滑块):与「扬声器电平」是**同一个量**,
走**同一个端点** `/api/speaker`。
★ 2026-09-13 修:它原本走 `/api/loudness{value}` 直写内部 `target`,而竖滑块走
`/api/speaker` 按硬件基数换算后也写同一个 `target` → **两个滑块互相覆盖**
(现场复现:拖扬声器到 0.60,target 存成 0.5354,横滑块立刻显示 0.5354 看着像"跳")。
现在两根滑块同源、同量纲(0~1.5),显示的数字永远一致。
- **响度统一**:按硬件增益基数补偿,使两条输出通道响度一致。
★ **物理音量只有 `/api/speaker` 一个写入入口**(`/api/loudness` 已不再接受 `value`)。
### 5.7 电平表的口径与"顶满"
- 「原始电平」= 本块**输入峰值**;「处理后电平」= DSP 上报的**输出峰值**(共享区 `+28` 字段,真实值,
受 `PEAK_CEIL = 0.985` 限制)。数字上的"峰值"取**最近 1.5 秒**,峰值**线**另按 12 dB/s 回落。
- ★ 2026-09-13 修:旧版"处理后" = 块能量开方(RMS) + 增益(快慢时标混用)→ 瞬态时会算出 >0 dBFS 的
不可能值,看着"顶满";旧版数字取整个 12 秒窗口最大值 → 一次冲顶会"顶"很久。
- ★ 上混图 **6 路相加做了能量归一(每路 1/√6 = 0.4082,净增益 −1.8 dB)**。改动在
`生成配置.py`(改 conf 无效,重建会覆盖)。改前每路 =1 → 净增益 **+6 dB**,正好把
DSP 的峰值保护吃掉 → 起播瞬间顶穿满刻度(实测 +7.83 dBFS)。
- ★ 起播淡入:长静音(>24 块≈1s)后重新起播的前 4 块(≈170ms)线性爬升,压掉卷积器冷启动过冲。
参数 `LN_FADE_BLOCKS` / `LN_FADE_GAP_BLOCKS`。
### 5.8 重建之后静音?先数 DSP 进程
**正常情况 DSP 进程必须正好 2 个**(左右声道各一个 `响度归一化/loudness_norm.py`):
```bash
ps -eo pid,etimes,cmd | grep -E "[l]oudness_norm"
```
多于 2 个 = 有孤儿在抢 `/dev/shm/collaplex-loudness` 的两个槽位 → pipe 插件的
同步管道卡死 → **整链静音**(链路看着全对:节点在、连线在、音量正常)。
★ 2026-09-13 修(老板报"没声音了"):
- `claim_slot()` 原来在两个槽都被占时 `return 0`(假装抢到)→ 第 3、4 个实例
一起写槽0,数据被劈开。现改为**打印一行并自我退出**。
- CLI 新增 `kill_old_dsp()`:所有重启 PipeWire 的地方统一走 `restart_pipewire()`,
**先收掉旧 DSP 再重启**。因为 DSP 的父进程是 pipewire 主进程,重建时
`pdeathsig` 不触发,不主动收就会每次攒一对。
验证:连续重建 3 次,每次都打印"清掉 2 个旧 DSP 实例",进程数恒为 2 ✓
### 5.9 默认设备为什么不会被 HDMI 抢走
虚拟声卡(`cinema_spatial_up_sink` / `cinema_spatial_sink`)带 `priority.session = 2000`,
**高于所有物理输出**(本机 HDMI 1196 / USB 1108 / 板载 1009)—— 见 `SINK_PRIO`。
★ 2026-09-13 修:原来虚拟声卡**没有这个属性**(`None`),所以每次 WirePlumber
"重新选择默认节点"(显示器唤醒、分辨率变化、设备插拔、服务重启)都被 HDMI 抢走;
从 GNOME 手动选回来,下一个事件又抢走("点都点不回来")。
验证:`wpctl set-default <HDMI>` 后 `systemctl --user restart wireplumber`,
默认应自动回到 `cinema_spatial_up_sink`。
想改用 HDMI 输出时手动 `wpctl set-default <HDMI>` 即可(但下一次重选会回到虚拟声卡)。
### 5.10 菜单音量桥(系统音量键 → 扬声器电平)
系统音量键 / GNOME 顶栏滑块 / 系统设置里的"输出音量"改的都是 `@DEFAULT_AUDIO_SINK@`,
也就是**虚拟声卡音量** = 链的**输入增益** —— 那一级会被响度归一化自动补回来,**按键几乎没效果**,
压太低还会把 DSP 顶到 +20 dB 上限。所以面板内置一个**音量桥**(随面板启动,200ms 轮询):
1. 把虚拟声卡的改动读走,按**比例**折算到**物理输出**(即「扬声器电平」,你听到的音量)
2. 再把虚拟声卡**复位到参考值**(= 「输入增益」滑块的值,存 `~/.local/state/cinema-spatial/volume`)
3. 静音键 → 物理输出归 0 并记住原值;解除 → 恢复
实测:按音量+ 物理 0.24→0.26→0.27,按− 回落,静音→0.000、解除恢复,
**虚拟声卡始终稳在参考值**(削波余量不被吃)。
> ⚠ 桥**随面板运行**(面板关掉,音量键就恢复成"改输入增益"的老行为)。
> 面板从菜单「Collaplex 音效」打开:<http://127.0.0.1:8788/>
> ⚠ 桥**只能有一份**(就是面板里这个)—— 不要另外再跑一个 CLI 版,两份会互相抢物理音量。
---
@@ -261,7 +345,7 @@ python3 web/webui.py --port 8788 --bind 127.0.0.1 # 或直接运行后
| `/api/speaker` | 扬声器电平(闭环自校正) |
| `/api/route` | 数字 / 模拟 |
| `/api/switch` | 三个信号开关 |
| `/api/loudness` | 响度统一 |
| `/api/loudness` | 响度统一 / 归一化目标(`target_db`)★ 不再接受 `value`:物理音量统一走 `/api/speaker` |
| `/api/device` | 输出设备 |
| `/api/reverb` | 混响开关与湿量 |
| `/api/mono` | 单声道模式 |
@@ -385,7 +469,12 @@ python3 web/webui.py --port 8788 --bind 127.0.0.1 # 或直接运行后
| `换HRTF.sh` | 从 SOFA 数据集提取脉冲响应 |
| `打包deb.sh` | 打包 `collaplex-cinema-spatial` |
| `发布release.sh` | 构建并发布到 Gitea Release |
| `web/` | Collaplex 音效 Web 控制台(`webui.py` + `index.html` + `启动.sh`,零依赖) |
| `离线渲染.py` | 离线批量渲染:音频/视频 → 空间音频 FLAC(含渲染锁 + 文件就绪检查) |
| `离线渲染-打开.sh` | 文件管理器右键「打开方式」入口(包内装为 `/usr/bin/collaplex-render`) |
| `音频守护.py` | 组件看门狗:30 秒一轮核对期望状态并自愈 |
| `组件配置单.md` / `config/组件配置单.json` | 链路组件的期望状态(人读版 / 机器可读版) |
| `桌面项/` | 包内版桌面项模板(打包时原样装进 `/usr/share/applications/`) |
| `web/` | Collaplex 音效 Web 控制台(`webui.py` + `index.html` + `启动.sh`,零依赖;含离线渲染区) |
| `reverb/` | 房间脉冲响应(由 `声学追踪引擎/` 生成) |
| `hrir/` | HRIR 数据与 `current` 软链 |
| `声学追踪引擎/` | 房间声学追踪与 IR 生成器(镜像法 + Sabine 尾音) |
@@ -397,6 +486,21 @@ python3 web/webui.py --port 8788 --bind 127.0.0.1 # 或直接运行后
## 11. 版本记录
### 1.3.0
- **离线渲染**(`离线渲染.py`):音频 / 视频 → 空间音频 FLAC(96 kHz 双耳)。链路与实时虚拟声卡同参数:`pan` 上混 5.1 → `sofalizer`(同一个 H4 SOFA)→ 房间 IR 卷积(`afir`)→ `loudnorm`。视频自动取音轨;已是 5.1 的片源不重上混
- **两道锁**:渲染锁(一轮任务期间**不接受新任务**,直接拒绝而非排队堆积);文件就绪检查(相隔 1.2 秒两次 stat 的大小/时间不变 + ffprobe 能读出音轨,防止把还在下载 / 写入的文件扔进渲染)
- 三个入口:命令行 / 面板「离线渲染」区(拖拽或贴路径,实时进度条 + 日志 + 取消)/ 文件管理器右键「打开方式」
- 实测:8 秒立体声 1.8 s 完成(≈4.4 倍速)
- **组件看门狗**(`音频守护.py` + `config/组件配置单.json`):30 秒一轮核对整条链的期望状态(DSP 进程数与槽位新鲜度 / 连线 / 默认设备 / 虚拟声卡优先级 / 中间级音量 / 节点份数 / 面板 / 物理设备静音),扫到问题就修 —— 能温和就温和(改音量、切默认、重连),只有链路级问题才重建;同类问题 90 秒冷却,连续 3 次修不好转只报警
- 面板:控制滑块从图表卡片里独立成「控制」卡片(图表归图表、控件归控件);数字 / 模拟输出音量条拉长到各占一半,合计等于虚拟声卡开关卡片的总高
- 修复:DSP 孤儿抢共享槽位导致重建后无声(`claim_slot()` 抢不到槽位即自我了断 + 重建前收掉旧 DSP)
- 修复:**打包动作会把正在用的 PipeWire 配置移开**(`prune_duplicate_confs()` 在 OUT 落到"非生效目录"时不再清理,另加 `CINEMA_SPATIAL_KEEP_CONFS=1` 双保险)
### 1.2.0
- 全系统响度归一化(filter-chain `pipe` 插件挂自研 DSP):各软件推进来的响度拉平到同一目标,目标可调(−40 ~ −6 dBFS),6 dB/s 限速 + 峰值保护,左右声道共享增益不漂声像
- 控制台拆 4 路电平(原始 L/R + 处理后 L/R),新增增益历史曲线
- 架构:归一化做成**独立 filter-chain 实例**串在上混图之前(塞进 40+ 节点大图会收到恒定 −43 dBFS 空数据、音频绕过)
### 1.1.1
- README 重写为正式文档,并同步至当前功能状态
+90
View File
@@ -0,0 +1,90 @@
{
"_说明": "电影院空间音频链路 —— 组件期望状态单。音频守护.py 读本文件逐项核对, 不符合就自动修复。",
"_版本": "2026-09-13",
"_依据": "2026-09-13 全链路修复后实测的稳定状态(当晚修掉 6 个坑: 双份conf/中间级被写坏/两滑块同源/上混未归一/HDMI抢默认/DSP孤儿)",
"项目目录": "/home/lou/桌面/工作区/实验/空间音频",
"配置真源": "~/.config/pipewire/pipewire.conf.d/{90-cinema-spatial.conf,91-clock.conf}",
"状态目录": "~/.local/state/cinema-spatial",
"日志": "~/.local/state/cinema-spatial/guardian.log",
"节点": {
"_说明": "滤波链的全部节点, 每个名字必须**恰好 1 份**(双份同名 conf 会各建一套 → 整链静默)",
"全部": [
"cinema_spatial_sink",
"cinema_spatial_out",
"cinema_spatial_up_sink",
"cinema_spatial_up_norm",
"cinema_spatial_up_raw",
"cinema_spatial_up_out"
],
"必须单位增益": [
"cinema_spatial_up_norm",
"cinema_spatial_up_raw"
],
"_单位增益说明": "中间两级是链内部节点, 不归用户调。被写低会凭空掉十几 dB, 而账面只算首尾两级 → 看着正常却没声音",
"虚拟声卡": [
"cinema_spatial_up_sink",
"cinema_spatial_sink"
],
"虚拟声卡最低优先级": 2000,
"_优先级说明": "priority.session 必须压过所有物理输出(本机 HDMI 1196 / USB 1108 / 板载 1009), 否则默认设备被 HDMI 抢走且点不回来"
},
"默认输出": {
"必须是": "cinema_spatial_up_sink",
"_说明": "媒体键/GNOME 顶栏改的就是这一级的音量, 由面板的菜单音量桥折算到物理输出"
},
"DSP": {
"脚本": "响度归一化/loudness_norm.py",
"进程数": 2,
"_进程数说明": "左右声道各一。多于 2 个 = 孤儿抢共享槽位 → 整链静音; 0 个 = 链里没有消费者 → 静音",
"共享区": "/dev/shm/collaplex-loudness",
"槽位数": 2,
"槽位新鲜秒": 5,
"_新鲜度说明": "每个槽 +16 是 f64 时刻, 超过该秒数没更新即为僵死",
"目标响度dBFS": -14.0,
"输出峰值上限": 0.985,
"_输出峰值说明": "PEAK_CEIL, DSP 输出不会超过 -0.13 dBFS",
"参数": {
"LN_MAX_BOOST": 12,
"LN_PEAK_CEIL": 0.985,
"LN_FADE_BLOCKS": 4,
"LN_FADE_GAP_BLOCKS": 24
}
},
"物理输出": {
"匹配": "alsa_output.usb-EDIFIER_Technology_EDIFIER_Fit900NB_4250315939393214-00.iec958-stereo",
"_匹配说明": "USB 耳机重启后 profile 可能在 iec958-stereo / analog-stereo 之间变, 名字会变; 所以按 EDIFIER 前缀匹配即可",
"_路由": "数字(S/PDIF) 优先, 96kHz/24bit(S24LE); 实测音质优于模拟"
},
"上混链": {
"输出端": "cinema_spatial_up_out",
"_输出端说明": "它的 output_FL/FR 必须显式连到物理设备的 playback_FL/FR",
"六路能量归一": 0.4082,
"_归一说明": "mixL/mixR 的 6 路 Gain 必须都是 1/sqrt(6)=0.4082; 每路 1.0 会让上混净增益 +6dB, 顶穿满刻度"
},
"时钟": {
"采样率": 96000,
"允许": [96000],
"_说明": "链路全程 96kHz; HRTF=SADIE-II H4 96k/512tap"
},
"面板": {
"端口": 8788,
"脚本": "web/webui.py",
"启动": "web/启动.sh",
"_说明": "常驻; 内含菜单音量桥(系统音量键 → 扬声器电平)和电平采集"
},
"看门狗": {
"间隔秒": 30,
"冷却秒": 90,
"连续失败告警": 3,
"_说明": "同一类问题在冷却期内不重复动手; 连续 3 次修不好就只报警不再折腾"
}
}
Binary file not shown.
+209 -24
View File
@@ -87,7 +87,7 @@
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}
#ln{width:100%;display:block;height:calc(100% - 46px);min-height:30px;margin-top:5px}
/* 竖排开关 */
.vsw{background:var(--card);border:1px solid var(--bd);border-radius:12px;cursor:pointer;
@@ -116,7 +116,20 @@
.vsl:hover{border-color:var(--bd2)}
/* 分支(数字/模拟) */
.routes{display:flex;flex-direction:column;justify-content:space-between;gap:6px}
/* ★ 2026-09-13: 两个条各占一半, 合起来 ≈ 虚拟声卡开关卡片的总长, 中间留 8px。
原来各 height:74px 一顶一底, 中间空着大半、条短得看不清(老板: "都拉长")。 */
.routes{display:flex;flex-direction:column;gap:8px}
.routes .vsl{flex:1;min-height:0}
/* ★ 控制卡片: 把滑块从图表卡片里解放出来 —— 图表归图表, 控件归控件(老板 2026-09-13) */
.ctl{background:var(--card);border:1px solid var(--bd);border-radius:12px;
padding:11px 13px;display:flex;flex-direction:column;gap:13px;overflow:auto}
.ctlhead{font-size:13px;font-weight:500;color:var(--mut);letter-spacing:.4px}
.ctl .ingain{flex-direction:column;align-items:stretch;gap:5px;margin:0}
.ctl .ingain > span:first-child{color:var(--fg)}
.ctl .ingain input[type=range]{width:100%;max-width:none}
.ctl .ingain b{text-align:left;min-width:0}
.ctl .ingain .hint{font-size:10.5px;line-height:1.5}
.lines{display:flex;flex-direction:column;justify-content:space-between;padding:36px 0 42px}
.rline{height:2px;background:var(--bd2);position:relative;cursor:pointer}
.rline span{position:absolute;top:-11px;left:50%;transform:translateX(-50%);background:var(--bg);
@@ -151,6 +164,26 @@
#loud-v{flex:none;min-width:46px;text-align:right;font-size:11.5px;color:var(--fg);font-variant-numeric:tabular-nums}
.acts{display:flex;gap:8px;flex:1}
/* 离线渲染 */
.rnddrop{border:1.5px dashed var(--bd2);border-radius:10px;padding:15px 18px;
display:flex;flex-direction:column;gap:6px;text-align:center;margin-bottom:10px;
transition:border-color .15s,background .15s}
.rnddrop b{font-size:13px;font-weight:500}
.rnddrop span{font-size:11.5px;color:var(--mut);line-height:1.6}
.rnddrop.over{border-color:var(--ok);background:#10251a}
.rndfiles{width:100%;background:var(--card2);color:var(--fg);border:1px solid var(--bd);
border-radius:9px;padding:9px 11px;font:inherit;font-size:12px;resize:vertical;
box-sizing:border-box;line-height:1.7}
.rndprog{margin-top:10px;font-size:11.5px;color:var(--mut);display:flex;
align-items:center;gap:10px;flex-wrap:wrap;min-height:20px}
.rndprog .rbar{flex:1;min-width:120px;height:7px;border-radius:4px;background:#202020;
border:1px solid var(--bd);overflow:hidden;position:relative}
.rndprog .rfill{position:absolute;left:0;top:0;bottom:0;width:0%;
background:linear-gradient(90deg,#22c55e,#86efac);transition:width .3s linear}
.rndprog .rmsg{color:var(--fg)}
.rndprog .rlog{margin-top:4px;font-size:11px;color:var(--mut);white-space:pre-wrap;
line-height:1.6;max-height:110px;overflow:auto;width:100%}
/* 状态信息 */
.status{margin:0 16px;background:var(--card);border:1px solid var(--bd);border-radius:12px;
padding:14px 16px;min-height:340px}
@@ -179,7 +212,7 @@
.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:400px}
.flow > .vsw,.flow > .vsl,.flow > .lines,.flow > .routes{height:170px}
.flow > .vsw,.flow > .vsl,.flow > .lines,.flow > .routes,.flow > .ctl{height:170px}
.flow > .lines{padding:26px 0}
.row{margin:10px 12px 0;flex-wrap:wrap}
.row > *{min-width:0}
@@ -219,12 +252,6 @@
<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>
<input id="in-vol" type="range" min="0.1" max="1" step="0.02" value="0.3">
<b id="in-vol-v">0.30</b>
<span class="hint">调小 = 给链留削波余量(立即生效)</span>
</div>
</div>
<div class="meter">
<div class="mhead"><b>处理后电平</b>
@@ -244,13 +271,6 @@
<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>
@@ -264,19 +284,33 @@
<div class="track"><div class="fill"></div></div><div class="val" id="raw-vol-v">—</div>
</div>
<div class="col lines" style="padding:0;justify-content:center">
<div class="rline" style="background:#2a2a2a"><span style="color:#5a5a5a">音量</span></div>
<!-- ★ 控制卡片: 把滑块从图表卡片里解放出来, 控件归控件、图表归图表(老板 2026-09-13) -->
<div class="col ctl">
<div class="ctlhead"><b>控制</b></div>
<div class="ingain">
<span>输入增益</span>
<input id="in-vol" type="range" min="0.1" max="1" step="0.02" value="0.3">
<b id="in-vol-v">0.30</b>
<span class="hint">调小 = 给链留削波余量(立即生效)</span>
</div>
<div class="ingain">
<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>
<div class="col vsw" id="sw-virtual" title="虚拟声卡信号: 通/断(断开连线)">
<span>虚拟声卡信号开关</span><i></i>
</div>
<!-- ★ 数字/模拟两个音量条各占一半(合起来 ≈ 虚拟声卡开关卡片的总长), 中间留一点点 -->
<div class="col routes">
<div class="vsl" id="dig-vol" title="数字输出音量" style="height:74px">
<div class="vsl" id="dig-vol" title="数字输出音量" style="flex:1">
<div class="track"><div class="fill"></div></div><div class="val" id="dig-vol-v">—</div>
</div>
<div class="vsl" id="ana-vol" title="模拟输出音量" style="height:74px">
<div class="vsl" id="ana-vol" title="模拟输出音量" style="flex:1">
<div class="track"><div class="fill"></div></div><div class="val" id="ana-vol-v">—</div>
</div>
</div>
@@ -344,6 +378,32 @@
</div>
</div>
<!-- 离线渲染 -->
<hr class="sep">
<div class="row">
<div class="box" style="flex:1">
<h3>离线渲染 · 音频 / 视频 → 空间音频 FLAC</h3>
<div class="chips" style="margin-bottom:11px">
<label class="chip"><i>混响</i>
<input id="rnd-wet" type="range" min="0" max="1" step="0.05" value="0.3" style="width:92px">
<b id="rnd-wet-v">0.30</b></label>
<label class="chip"><i>目标响度</i>
<input id="rnd-tgt" type="range" min="-24" max="-6" step="1" value="-14" style="width:92px">
<b id="rnd-tgt-v">-14</b> dBFS</label>
<label class="chip"><input id="rnd-ovw" type="checkbox"> 覆盖已存在</label>
<button id="rnd-go" class="primary">开始渲染</button>
<button id="rnd-cancel">取消</button>
</div>
<div class="rnddrop" id="rnd-drop">
<b>把音频 / 视频拖到这里</b>
<span>拖进来的会先上传到面板(浏览器拿不到本地路径);大文件直接贴下面的路径框更快</span>
</div>
<textarea id="rnd-files" class="rndfiles" rows="3"
placeholder="路径,一行一个(视频会自动取音轨;已经是 5.1 的片源不重上混)"></textarea>
<div class="rndprog" id="rnd-prog"><span class="rmsg">空闲 —— 拖文件进来开始</span></div>
</div>
</div>
<!-- 状态信息 -->
<div class="status">
<h3 style="margin:0 0 10px;font-size:13px;font-weight:500;color:var(--mut)">状态信息</h3>
@@ -494,8 +554,11 @@ function drawMeter(cv, pts, peakHold){
const hold = {raw:null, post:null};
function num(pts, el){
if (!pts.length){ $(el).textContent = "—"; return; }
// ★ 只取**最近 1.5 秒**的峰值: 之前取整个 12 秒窗口的最大值, 一次冲顶后数字会"顶"很久,
// 看着像"怎么都顶满"(2026-09-13)。峰值**线**另有 peakHold(12dB/s 回落), 保持得更久。
const win = pts.slice(-15);
const last = pts[pts.length - 1];
const pk = Math.max(...pts.map(p => Math.max(pOf(p, "L"), pOf(p, "R"))));
const pk = Math.max(...win.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";
}
@@ -527,7 +590,20 @@ function drawLoud(ln){
fEl.style.left = Math.min(zero, cur) + "%";
fEl.style.width = Math.abs(cur - zero) + "%";
}
$("#ln-num").textContent = (g >= 0 ? "+" : "") + g.toFixed(2) + " dB";
// ★ 增益顶到量程上限 = DSP 在拼命补输入电平(说明输入被压得太低) —— 必须明说,
// 否则用户只看到推子"一飞冲天"却不知道为什么(2026-09-13)。
const capped = g >= LN_RANGE - 0.5;
$("#ln-num").textContent = (g >= 0 ? "+" : "") + g.toFixed(2) + " dB"
+ (capped ? " · 已顶格(输入电平偏低)" : "");
// ★ 「输入增益」与归一化是同一段链上的两个旋钮: 你压小输入, DSP 就把增益加上去 ——
// 实时把它补偿了多少写在说明里, 免得以为"调了没用/乱跳"。
const ivBox = $("#in-vol");
if (ivBox) {
const ivHint = ivBox.parentElement && ivBox.parentElement.querySelector(".hint");
if (ivHint) ivHint.textContent = capped
? "归一化已顶格:再压小就真的变小声(要更大声调「扬声器电平」)"
: "归一化正自动补偿 " + g.toFixed(1) + " dB(拖它这里的数字会跟着变)";
}
// 目标滑块跟 DSP 的实际目标同步(CLI 改过也反映); 用户正拖着就不动, 免得抢他的输入
const tEl = $("#ln-tgt");
if (tEl && document.activeElement !== tEl) {
@@ -676,7 +752,11 @@ function renderState(s){
const t = $("#tgl-loud");
t.className = "tgl" + (s.loud.unified ? " on" : "");
$("#loud-vol")._draw && $("#loud-vol")._draw(Number(s.loud.target));
// ★ 这根横滑块与左边「扬声器电平」竖滑块是**同一个量**(物理输出音量)。
// 原来它画的是内部 target(量纲 0~1, 而竖滑块是 0~1.5), 两个数字对不上 ——
// 看着就是"拖一个, 另一个跳到别的值"(2026-09-13 复现)。现在同源、同量纲。
$("#loud-vol")._draw && $("#loud-vol")._draw(Math.max(0, Math.min(1, s.physical.vol / 1.5)));
$("#loud-v").textContent = s.physical.vol.toFixed(2);
$("#loud-note").textContent = s.loud.unified
? "已补偿硬件增益差 ±0.94 dB,数字/模拟切换响度一致"
: "关:两条 route 硬件增益差 +0.94 dB(数字偏小)";
@@ -817,12 +897,117 @@ vSlider("#dig-vol", "#dig-vol .fill", "#dig-vol-v",
{min:0, max:1.5, commit: async (v) => flash((await jpost("/api/loudness", {digital: v.toFixed(3)})).message)});
vSlider("#ana-vol", "#ana-vol .fill", "#ana-vol-v",
{min:0, max:1.5, commit: async (v) => flash((await jpost("/api/loudness", {analog: v.toFixed(3)})).message)});
// ★ 与左边「扬声器电平」竖滑块走**同一个入口** /api/speaker(物理输出音量)。
// 原来它 POST /api/loudness{value} 直写内部 target, 而竖滑块走 /api/speaker
// (按硬件基数换算后也写同一个 target) → 两个滑块互相覆盖 = "调着调着跳到其他值"。
hSlider("#loud-vol", "#loud-vol .f", "#loud-vol .k", "#loud-v",
{min:0, max:1, commit: async (v) => flash((await jpost("/api/loudness", {value: v.toFixed(3)})).message)});
{min:0, max:1.5, commit: async (v) => flash((await jpost("/api/speaker", {value: v.toFixed(3)})).message)});
window.addEventListener("resize", () => { tickMeters(); });
tickState();
// ---------------- 离线渲染 ----------------
// 拖进来的文件浏览器不给本地路径(那是 Electron 的扩展), 所以先 POST 上传到面板,
// 面板存到 STATE_DIR/uploads/ 再把**路径**交给 离线渲染.py —— 两道锁都在脚本里。
const rndDrop = $("#rnd-drop"), rndFiles = $("#rnd-files");
let rndTimer = null;
$("#rnd-wet")?.addEventListener("input", (e) => {
$("#rnd-wet-v").textContent = Number(e.target.value).toFixed(2);
});
$("#rnd-tgt")?.addEventListener("input", (e) => {
$("#rnd-tgt-v").textContent = e.target.value;
});
if (rndDrop && rndFiles) {
["dragenter", "dragover"].forEach((ev) => rndDrop.addEventListener(ev, (e) => {
e.preventDefault();
rndDrop.classList.add("over");
}));
["dragleave", "drop"].forEach((ev) => rndDrop.addEventListener(ev, (e) => {
e.preventDefault();
rndDrop.classList.remove("over");
}));
rndDrop.addEventListener("drop", async (e) => {
const list = [...((e.dataTransfer && e.dataTransfer.files) || [])];
if (!list.length) return;
let done = 0;
for (const f of list) {
flash("上传中 " + f.name + " …");
try {
const r = await fetch("/api/render/upload?name=" + encodeURIComponent(f.name),
{ method: "POST", body: f });
const j = await r.json();
if (j.path) {
rndFiles.value = (rndFiles.value.trim() ? rndFiles.value.trim() + "\n" : "") + j.path + "\n";
done++;
} else if (j.error) {
flash("✗ " + f.name + ": " + j.error);
}
} catch (err) {
flash("✗ " + f.name + ": 上传失败");
}
}
flash("已接收 " + done + "/" + list.length + " 个文件");
});
}
$("#rnd-go")?.addEventListener("click", async () => {
const files = rndFiles.value.split("\n").map((s) => s.trim()).filter(Boolean);
if (!files.length) { flash("先拖文件进来, 或者贴路径"); return; }
const msg = await jpost("/api/render", {
files: files.join("\n"),
wet: $("#rnd-wet").value,
target: $("#rnd-tgt").value,
overwrite: $("#rnd-ovw").checked ? "1" : "",
});
flash((msg && msg.message) || "已提交");
startRndPoll();
});
$("#rnd-cancel")?.addEventListener("click", async () => {
const msg = await jpost("/api/render/cancel", {});
flash((msg && msg.message) || "已取消");
startRndPoll();
});
async function tickRender() {
const box = $("#rnd-prog");
if (!box) return;
try {
const r = await fetch("/api/render", { cache: "no-store" });
const s = await r.json();
const p = s.progress || {};
const pct = Number(p["百分比"] !== undefined ? p["百分比"] : (s.running ? 0 : 100));
let html = '<div class="rbar"><div class="rfill" style="width:'
+ Math.max(0, Math.min(100, pct)) + '%"></div></div>';
if (s.running) {
html += '<span class="rmsg">渲染中 ' + (p["文件"] || "") + " " + pct.toFixed(1) + "%"
+ (p["速度"] ? " · " + p["速度"] + "x" : "") + "</span>";
} else if (s.exit === 0) {
html += '<span class="rmsg">✓ 完成(成功 ' + (p["成功"] || 0)
+ " / 跳过 " + (p["跳过"] || 0) + ")</span>";
} else if (s.exit !== null && s.exit !== undefined) {
html += '<span class="rmsg">✗ 结束(退出码 ' + s.exit + ")</span>";
} else {
html += '<span class="rmsg">空闲 —— 拖文件进来开始</span>';
}
if (s.log && s.log.length) {
html += '<div class="rlog">' + s.log.slice(-8).join("\n") + "</div>";
}
box.innerHTML = html;
rndTimer = s.running ? setTimeout(tickRender, 900) : null;
} catch (e) {
rndTimer = null;
}
}
function startRndPoll() {
if (rndTimer) clearTimeout(rndTimer);
tickRender();
}
tickRender();
tickMeters();
setInterval(tickMeters, 150);
setInterval(tickState, 2000);
+299 -49
View File
@@ -20,7 +20,8 @@ import threading
import time
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from typing import Any
from urllib.parse import ParseResult, parse_qs, urlparse
HERE = os.path.dirname(os.path.abspath(__file__))
INDEX = os.path.join(HERE, "index.html")
@@ -59,6 +60,88 @@ def hw_base(kind: str) -> float:
return b if b > 0.05 else 1.0
# ---------------------------------------------------------------- 离线渲染
# 复用 离线渲染.py 自己的两道锁(渲染锁 + 文件就绪), 面板只是把文件喂进去 + 显示进度。
RENDER_SCRIPT = os.path.join(ROOT, "离线渲染.py")
PROGRESS_FILE = os.path.join(STATE_DIR, "render-progress.json")
UPLOAD_DIR = os.path.join(STATE_DIR, "uploads")
RENDER: dict[str, Any] = {"proc": None, "files": [], "started": 0.0,
"log": deque(maxlen=300)}
RENDER_LOCK = threading.Lock()
def render_status() -> dict[str, Any]:
"""渲染状态(前端轮询)。"""
proc = RENDER["proc"]
running = bool(proc is not None and proc.poll() is None)
try:
with open(PROGRESS_FILE, encoding="utf-8") as fh:
prog: dict[str, Any] = json.load(fh)
except (OSError, json.JSONDecodeError):
prog = {}
return {"running": running, "files": RENDER["files"], "progress": prog,
"log": list(RENDER["log"])[-28:],
"took": round(time.time() - RENDER["started"], 1) if RENDER["started"] else 0.0,
"exit": None if (running or proc is None) else proc.returncode}
def _render_pump(proc: subprocess.Popen[str]) -> None:
"""把渲染输出收进日志(面板状态区能看)。"""
assert proc.stdout is not None
for line in proc.stdout:
clean = line.replace("\r", "").strip()
if clean:
RENDER["log"].append(clean)
proc.wait()
def render_start(files: list[str], wet: float, target: float, overwrite: bool) -> str:
"""启动离线渲染(后台线程)。
★ 已有任务在跑就**拒绝**, 不排队堆积 —— 与 离线渲染.py 里的渲染锁同一个意思
(那道锁会再兜一层, 这里先给用户一个爽快的答复)。
"""
with RENDER_LOCK:
proc = RENDER["proc"]
if proc is not None and proc.poll() is None:
return "⛔ 已有渲染任务在跑, 等它跑完再扔"
ok_files = [f for f in files if os.path.isfile(f)]
if not ok_files:
return "⛔ 没有可渲染的文件(路径不对, 或者还是目录)"
skip = len(files) - len(ok_files)
args = [sys.executable, RENDER_SCRIPT, "--混响", "%.2f" % wet,
"--目标", "%.1f" % target, "--进度json", PROGRESS_FILE]
if overwrite:
args.append("--覆盖")
args += ok_files
try:
os.remove(PROGRESS_FILE)
except OSError:
pass
RENDER["files"] = ok_files
RENDER["started"] = time.time()
RENDER["log"].clear()
p = subprocess.Popen(args, cwd=ROOT, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, bufsize=1)
RENDER["proc"] = p
threading.Thread(target=_render_pump, args=(p,), daemon=True).start()
note = "(另外 %d 个路径无效已略过)" % skip if skip else ""
return "已开始渲染 %d 个文件%s, 下面看进度" % (len(ok_files), note)
def render_cancel() -> str:
"""取消渲染(终止进程; 子进程 ffmpeg 由它自己收)。"""
proc = RENDER["proc"]
if proc is None or proc.poll() is not None:
return "当前没有在跑的渲染任务"
proc.terminate()
time.sleep(0.6)
if proc.poll() is None:
proc.kill()
RENDER["log"].append("· 已手动取消")
return "已取消渲染"
def _env() -> dict[str, str]:
e = dict(os.environ)
e.setdefault("XDG_RUNTIME_DIR", "/run/user/%d" % os.getuid())
@@ -106,9 +189,9 @@ def node_id(name: str) -> int:
return -1
def vol_of(nid: int) -> tuple[float, bool]:
"""(音量, 是否静音)."""
t = sh("wpctl", "get-volume", str(nid))
def vol_of(nid: int, timeout: int = 15) -> tuple[float, bool]:
"""(音量, 是否静音). 音量桥轮询时传小 timeout, 免得卡住面板. """
t = sh("wpctl", "get-volume", str(nid), timeout=timeout)
m = re.search(r"Volume:\s*([0-9.]+)", t)
return (float(m.group(1)) if m else -1.0), ("MUTED" in t)
@@ -424,7 +507,7 @@ def loudness_state() -> dict[str, object]:
}
if not os.path.exists(LN_SHM):
return out
rec: list[tuple[float, float, float, float]] = []
rec: list[tuple[float, float, float, float, float, float]] = []
try:
fd = os.open(LN_SHM, os.O_RDONLY)
try:
@@ -435,10 +518,12 @@ def loudness_state() -> dict[str, object]:
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],
struct.unpack_from("<f", mm, b)[0], # [0] e_smooth(响度判据)
struct.unpack_from("<f", mm, b + 8)[0], # [1] 本块**输入**峰值
struct.unpack_from("<f", mm, b + 4)[0], # [2] e_block(回退用)
struct.unpack_from("<f", mm, b + 12)[0], # [3] gain_db
now - struct.unpack_from("<d", mm, b + 16)[0], # [4] 数据年龄
struct.unpack_from("<f", mm, b + 28)[0], # [5] ★本块输出峰值(旧版=0)
))
finally:
mm.close()
@@ -446,15 +531,25 @@ def loudness_state() -> dict[str, object]:
os.close(fd)
except Exception:
return out
if not rec or min(r[3] for r in rec) > 1.0: # 都超过 1 秒没更新 = 没在跑
if not rec or min(r[4] 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)
out["gain_db"] = round(rec[0][3], 2)
# ★★ 2026-09-13 口径修正(老板报"电平怎么都顶满了"):
# 旧版: raw = 块能量开方(RMS) 却标成"峰值"; post = 快时标的块能量 + 慢时标的增益
# → 瞬态时会算出 >0 dBFS 的**不可能**值(实测出现过 +5.5 dBFS), 看着像顶满。
# 现在: raw = 本块**输入峰值**; post = DSP 上报的**本块输出峰值**(真实值, 受 PEAK_CEIL 限制)。
# 旧版 DSP(还没写 +28) → post 退回估算, 并截顶到 PEAK_CEIL, 免得又出现不可能值。
ceil_db = _db(0.985) # DSP 的峰值保护上限 ≈ -0.13 dBFS
for i, name in ((0, "L"), (1, "R")):
raw = _db(math.sqrt(max(rec[i][1], 0.0))) # 能量 -> 幅度 -> dBFS
raw = _db(max(rec[i][1], 0.0))
out["raw_" + name] = round(raw, 2)
out["post_" + name] = round(raw + rec[i][2], 2)
pko = max(rec[i][5], 0.0)
if pko > 0.0:
out["post_" + name] = round(_db(pko), 2)
else:
out["post_" + name] = round(min(raw + rec[i][3], ceil_db), 2)
joint = max(rec[0][0], 0.0) + max(rec[1][0], 0.0)
out["loud"] = round(_db(math.sqrt(joint)), 2) # 归一化的判据(两声道联合响度)
return out
@@ -556,6 +651,127 @@ def reverb_state() -> dict[str, object]:
return {"on": bool(d.get("on")), "wet": float(d.get("wet") or 0.0)}
def set_speaker(want: float) -> str:
"""写「扬声器电平」(物理输出音量 = 老板听到的音量).
统一响度开着时物理 = 基数 x 目标, 直接写物理会被下次 apply 抹掉 → 写目标;
基数随机型/route 不同(实测本机数字 0.900, 不是表里的 1.0 → 直接算差 10%),
所以闭环自校正: 按实测比例算 → 写 → 回读 → 残差修正, 最多 3 轮。
"""
d = load_loud()
kind = route_kind()
pid = node_id(chain_target() or phys_sink())
before, _ = vol_of(pid) if pid > 0 else (-1.0, False)
ratio = 0.0
if d.get("unified") and before > 0.01 and _num(d.get("target"), 0.0) > 0.01:
ratio = before / _num(d.get("target"), 1.0)
for _pass in range(3):
if d.get("unified"):
base = ratio if ratio > 0.05 else hw_base(kind)
d["target"] = max(0.0, min(1.0, want / base))
else:
d[kind] = want
save_loud(d)
apply_loudness()
got, _m = vol_of(pid) if pid > 0 else (-1.0, False)
if got > 0.01 and d.get("unified"):
ratio = got / max(_num(d.get("target"), 1.0), 1e-6) # 用真实读数校准基数
if abs(got - want) <= 0.01:
break
elif abs(got - want) <= 0.01:
break
got, mut = vol_of(pid) if pid > 0 else (-1.0, False)
return "扬声器电平 %.3f → 物理输出 %.3f (硬件基数 %.3f)%s" % (
want, got, ratio if ratio > 0 else hw_base(kind), " (静音)" if mut else "")
# ---------------- 菜单音量桥 (2026-09-13) ----------------
# ★ 老板报"ubuntu 菜单音量绑到输出增益了, 应该绑到扬声器电平上" —— 实测确认:
# GNOME 顶栏/媒体键改的是 @DEFAULT_AUDIO_SINK@ = **虚拟声卡音量** = 链的**输入增益**,
# 而那一级会被「响度归一化」自动补回来(所以按键几乎没效果), 压太低还会把 DSP 逼到
# 顶格(面板推子"一飞冲天")、把整条链的削波余量吃光。
# 做法(不引入新层级, 不加新设备): 把虚拟声卡那次改动当**用户音量指令**读走 →
# 按**比例**折算到物理输出(走 set_speaker 的闭环校准) → 再把虚拟声卡**复位到参考值**
# (削波余量)。于是: 菜单音量 = 扬声器电平; 「输入增益」仍是纯削波余量旋钮。
BRIDGE_REF: float | None = None # 链输入增益参考值(削波余量); 面板「输入增益」滑块改它
BRIDGE_MUTED: bool = False # 菜单静音中
BRIDGE_SAVED: float = 0.0 # 菜单静音前的扬声器电平
BRIDGE_VID: int = 0 # 虚拟声卡节点 id(缓存, 省掉每轮 pw-dump)
BRIDGE_PID: int = 0 # 物理输出节点 id(缓存)
BRIDGE_ID_AT: float = 0.0 # id 上次刷新时刻
BRIDGE_OK: bool = True
BRIDGE_ERR: str = ""
def vol_bridge_tick() -> str | None:
"""轮询一次; 有动作返回日志文本, 否则 None。"""
global BRIDGE_REF, BRIDGE_MUTED, BRIDGE_SAVED, BRIDGE_VID, BRIDGE_PID, BRIDGE_ID_AT
now = time.time()
# id 每 5 秒(或失效时)重解析一次 —— node_id 要跑 pw-dump, 不能每轮都跑
if BRIDGE_VID <= 0 or now - BRIDGE_ID_AT > 5.0:
BRIDGE_VID = node_id(SINK_UP)
BRIDGE_PID = node_id(chain_target() or phys_sink())
BRIDGE_ID_AT = now
if BRIDGE_VID <= 0 or BRIDGE_PID <= 0:
return None
v, vm = vol_of(BRIDGE_VID, timeout=4)
if v < 0: # id 已失效(音频栈重启过) → 下轮重解析
BRIDGE_ID_AT = 0.0
return None
if BRIDGE_REF is None: # 首次: 以**实况**为参考
# ★ 不能采信 VOL_FILE(存档): 重建/别处改动之后实况可能与存档不一致,
# 那桥会把这个差值当成"菜单音量"折算一次 → 音量无故跳一下(2026-09-13 教训)。
BRIDGE_REF = v if v > 0.01 else 0.35
return None
ref = BRIDGE_REF
p, _pm = vol_of(BRIDGE_PID, timeout=4)
if p < 0:
return None
if vm or v <= 0.001: # 菜单静音 / 音量拉到底
if not BRIDGE_MUTED:
BRIDGE_MUTED = True
BRIDGE_SAVED = p
set_speaker(0.0)
return "菜单音量: 静音 → 扬声器电平 0.000"
return None
if BRIDGE_MUTED: # 解除静音
BRIDGE_MUTED = False
back = BRIDGE_SAVED
sh("wpctl", "set-volume", str(BRIDGE_VID), "%.3f" % ref, timeout=4)
if back > 0.01:
set_speaker(back)
return "菜单音量: 取消静音 → 扬声器电平 %.3f" % (back if back > 0.01 else p)
if abs(v - ref) <= 0.02: # 没被动过
return None
ratio = v / ref if ref > 0.001 else 1.0 # 外部(菜单/媒体键/系统设置)改的
want = max(0.0, min(1.5, p * ratio))
msg = set_speaker(want)
sh("wpctl", "set-volume", str(BRIDGE_VID), "%.3f" % ref, timeout=4) # 复位削波余量
return "菜单音量 %.3f (×%.2f) → %s" % (v, ratio, msg)
def vol_bridge_loop() -> None:
global BRIDGE_OK, BRIDGE_ERR
while True:
try:
m = vol_bridge_tick()
if m:
log(m)
BRIDGE_OK = True
BRIDGE_ERR = ""
except Exception as e: # 桥不许把面板带崩
BRIDGE_OK = False
BRIDGE_ERR = str(e)
time.sleep(0.2)
def set_bridge_ref(v: float) -> None:
"""面板「输入增益」滑块改的就是参考值本身 —— 通知桥一声, 免得它把这次改动
误当成"菜单音量"再往物理输出折算一遍(桥判据是 v != ref)。"""
global BRIDGE_REF
BRIDGE_REF = v
def apply_loudness() -> str:
"""响度统一: 把两条 route 的硬件增益差补平(+0.94dB), 让数字/模拟切换时响度一致.
@@ -668,12 +884,18 @@ class Handler(BaseHTTPRequestHandler):
self._json({"raw": meter_points("raw"), "post": meter_points("post"),
"loud": loudness_state(),
"raw_target": virtual_sink(), "post_target": chain_target() or phys_sink()})
elif u.path == "/api/render":
self._json(render_status())
else:
self._json({"error": "not found"}, 404)
def do_POST(self) -> None:
u = urlparse(self.path)
q = parse_qs(u.query)
# ★ 上传是**二进制** body, 必须在下面对 body 做 JSON 解析之前分出去, 否则文件会坏
if u.path == "/api/render/upload":
self._upload(q)
return
try:
n = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(n).decode("utf-8") if n else ""
@@ -689,11 +911,19 @@ class Handler(BaseHTTPRequestHandler):
try:
if u.path == "/api/mode":
msg = cli({"off": "关", "on": "开", "on51": "开5.1"}.get(g("mode", "on"), "状态"))
elif u.path == "/api/volume": # 原始(虚拟声卡)音量
elif u.path == "/api/volume": # 虚拟声卡(链输入增益)
val = max(0.0, min(1.5, float(g("value", "0.33"))))
# ★★ 只写**虚拟声卡**(名字以 _sink 结尾的 Audio/Sink)。
# 绝不能按"名字里含 cinema_spatial"一把全写 —— 那会把上混图的
# 输入口 cinema_spatial_up_raw(以及归一化输出 up_norm)一起改掉,
# 而它们**不归用户调**、必须在单位增益。2026-09-13 实测血亏:
# up_raw 被这个接口写成 0.2068(-13.7 dB) → 全链由 -8.1 dB 掉到
# -29.9 dB, 耳机听着"一点声音都没有", 而界面、音量账**看着全正常**
# (旧账只算首尾两级), 排查绕了好几圈。
for s in node_list("Audio/Sink"):
if "cinema_spatial" in str(s["name"]):
if str(s["name"]).endswith("_sink"):
sh("wpctl", "set-volume", str(s["id"]), "%.3f" % val)
set_bridge_ref(val) # 这是面板改的 = 参考值本身, 别让桥再折算一次
try:
os.makedirs(STATE_DIR, exist_ok=True)
with open(VOL_FILE, "w", encoding="utf-8") as f:
@@ -712,34 +942,7 @@ class Handler(BaseHTTPRequestHandler):
want = max(0.0, min(1.5, float(g("value", "1.0"))))
except ValueError:
want = 1.0
d = load_loud()
kind = route_kind()
pid = node_id(chain_target() or phys_sink())
# ★ 闭环自校正: 物理输出 = 基数 x 目标, 而基数随机型/route 不同(实测这台数字 0.900,
# 不是我旧表里的 1.0 → 直接算会差 10%)。所以先按实测比例算, 再拿真实读数量一次残差。
before, _ = vol_of(pid) if pid > 0 else (-1.0, False)
ratio = 0.0
if d.get("unified") and before > 0.01 and _num(d.get("target"), 0.0) > 0.01:
ratio = before / _num(d.get("target"), 1.0)
for _pass in range(3):
if d.get("unified"):
# 统一响度开着: 物理是"基数x目标"算出来的, 直接写物理会被下次 apply 抹掉 → 写目标
base = ratio if ratio > 0.05 else hw_base(kind)
d["target"] = max(0.0, min(1.0, want / base))
else:
d[kind] = want
save_loud(d)
apply_loudness()
got, _m = vol_of(pid) if pid > 0 else (-1.0, False)
if got > 0.01 and d.get("unified"):
ratio = got / max(_num(d.get("target"), 1.0), 1e-6) # 用真实读数校准基数
if abs(got - want) <= 0.01:
break
elif abs(got - want) <= 0.01:
break
got, mut = vol_of(pid) if pid > 0 else (-1.0, False)
msg = "扬声器电平 %.3f → 物理输出 %.3f (硬件基数 %.3f)%s" % (
want, got, ratio if ratio > 0 else hw_base(kind), " (静音)" if mut else "")
msg = set_speaker(want) # 闭环自校正封装在函数里(菜单音量桥也用同一个)
elif u.path == "/api/route":
msg = cli("数字" if g("rt", "digital") == "digital" else "模拟")
elif u.path == "/api/switch":
@@ -751,11 +954,11 @@ class Handler(BaseHTTPRequestHandler):
if d["unified"] and "value" not in q:
# 打开时以当前这条路的音量作目标, 避免音量突跳
d["target"] = min(1.0, _num(d.get(route_kind()), 0.33))
if "value" in q:
try:
d["target"] = max(0.0, min(1.0, float(g("value"))))
except ValueError:
pass
# ★ 这里**故意不再接受 `value`**(2026-09-13 修):它和 /api/speaker
# (「扬声器电平」)写的是同一个 d["target"],两个入口各按自己的量纲换算
# → 拖一个、另一个"跳到别的值"(现场复现:拖扬声器到 0.60,target 被
# 写成 0.5354,面板那根「统一响度音量」横滑块立刻显示 0.5354)。
# 物理输出音量现在只有 /api/speaker 一个入口;面板那根横滑块也已改走它。
if "digital" in q:
d["digital"] = max(0.0, min(1.5, float(g("digital"))))
if "analog" in q:
@@ -771,6 +974,16 @@ class Handler(BaseHTTPRequestHandler):
save_loud(d)
msg = (f"归一化目标 → {_num(d.get('target_db'), -16.0):.1f} dBFS (立即生效)"
if only_tgt else apply_loudness())
elif u.path == "/api/render": # 启动离线渲染(后台线程, 不阻塞面板)
files = [f.strip() for f in g("files", "").split("\n") if f.strip()]
try:
wet = max(0.0, min(1.0, float(g("wet", "0.3"))))
tgt = max(-40.0, min(-6.0, float(g("target", "-14.0"))))
except ValueError:
wet, tgt = 0.3, -14.0
msg = render_start(files, wet, tgt, g("overwrite", "") == "1")
elif u.path == "/api/render/cancel":
msg = render_cancel()
elif u.path == "/api/fix":
msg = cli("开")
elif u.path == "/api/device":
@@ -806,6 +1019,40 @@ class Handler(BaseHTTPRequestHandler):
log(msg)
self._json({"message": msg, "state": state()})
def _upload(self, q: dict[str, list[str]]) -> None:
"""接收拖拽进来的文件(localhost 传输很快, 几 MB~几百 MB 都是瞬时的)。
上传到 STATE_DIR/uploads/ 再交给渲染 —— 因为浏览器出于安全**不给本地路径**
(那是 Electron 的扩展), 所以拖进来的文件只能这么进来。
超大文件(>2GB)建议用路径输入框直接贴路径, 免得白等一次全量传输。
"""
try:
raw_name = (q.get("name") or ["upload.bin"])[0]
name = re.sub(r"[/\\]", "_", os.path.basename(raw_name)).strip() or "upload.bin"
n = int(self.headers.get("Content-Length") or 0)
if n <= 0:
self._json({"error": "空文件"}, 400)
return
if n > 2 * 1024 ** 3:
self._json({"error": "文件超过 2GB, 请改用路径输入框"}, 413)
return
os.makedirs(UPLOAD_DIR, exist_ok=True)
dst = os.path.join(UPLOAD_DIR, name)
got = 0
with open(dst, "wb") as fh:
left = n
while left > 0:
chunk = self.rfile.read(min(1 << 20, left))
if not chunk:
break
fh.write(chunk)
got += len(chunk)
left -= len(chunk)
log("已接收拖入文件: %s (%.1f MB)" % (name, got / 1048576.0))
self._json({"message": "已接收 %s" % name, "path": dst, "size": got})
except Exception as e:
self._json({"error": "上传失败: %s" % e}, 500)
def main() -> int:
ap = argparse.ArgumentParser(description="Collaplex 音效 Web 控制台")
@@ -814,7 +1061,10 @@ def main() -> int:
a = ap.parse_args()
os.makedirs(STATE_DIR, exist_ok=True)
start_meters()
log("控制台启动, 电平采集: raw=%s post=%s(均为显式连线, 不经麦克风)" % (virtual_sink(), _meter_src("post")[0]))
threading.Thread(target=vol_bridge_loop, daemon=True).start() # 菜单音量 → 扬声器电平
log("控制台启动, 电平采集: raw=%s post=%s(均为显式连线, 不经麦克风)"
% (virtual_sink(), _meter_src("post")[0]))
log("菜单音量桥已启动: 菜单/媒体键 → 扬声器电平(物理输出), 链输入增益复位到削波余量")
srv = ThreadingHTTPServer((a.bind, a.port), Handler)
print("Collaplex 音效 控制台: http://%s:%d/ (Ctrl-C 退出)" % (a.bind, a.port))
sys.stdout.flush()
+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 双耳卷积; 湿量按能量归一。不是 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"
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.3.0 —— 新增【离线渲染】+【组件看门狗】\\n\\n【离线渲染】音频/视频 -> 空间音频 FLAC (96kHz 双耳), 与实时虚拟声卡同一套参数:\\n- 链路: pan 上混 5.1 -> sofalizer(同一个 H4 SOFA, 96k/512tap) -> 房间 IR 卷积(afir) -> loudnorm\\n- 视频自动取音轨; 已是 5.1 的片源不重上混; 输出 <原名>.空间音频.flac\\n- 两块锁: (1) 渲染锁 —— 一轮任务期间不接受新任务(直接拒绝, 不排队堆积); (2) 文件就绪检查 —— 相隔 1.2 秒两次 stat 的大小/时间不变且 ffprobe 能读出音轨, 还在下载/写入的文件直接跳过\\n- 三个入口: 命令行 collaplex-render 文件... / Web 控制台「离线渲染」区(拖文件或贴路径, 实时进度条 + 日志 + 取消) / 文件管理器右键「打开方式 -> Collaplex 离线渲染」\\n- 实测 8 秒立体声 1.8 秒完成(约 4.4 倍速)\\n\\n【组件看门狗】音频守护.py + config/组件配置单.json:\\n- 30 秒一轮核对整条链的期望状态: DSP 进程数与槽位新鲜度 / 链输出连线 / 默认设备 / 虚拟声卡优先级 / 中间级音量 / 节点份数 / 控制台 / 物理设备静音\\n- 扫到就修: 能温和就温和(改音量、切默认、pw-link 重连), 只有链路级问题才重建; 同类问题 90 秒冷却, 连续 3 次修不好转只报警\\n\\n其他:\\n- 控制台: 控制滑块从图表卡片独立成「控制」卡片; 数字/模拟输出音量条拉长到各占一半(合计 = 虚拟声卡开关卡片总高)\\n- 修: DSP 孤儿抢共享槽位导致重建后无声(claim_slot 抢不到槽位即退出 + 重建前收旧 DSP)\\n- 修: 打包会把正在用的 PipeWire 配置移开(prune_duplicate_confs 在 OUT 落到非生效目录时不再清理 + CINEMA_SPATIAL_KEEP_CONFS=1 双保险)\\n\\n自 1.2.0 以来新增文件: 离线渲染.py, 离线渲染-打开.sh, 音频守护.py, 组件配置单.md, config/组件配置单.json, 桌面项/\\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
+29 -3
View File
@@ -49,6 +49,8 @@ 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"))
FADE_BLOCKS = int(os.environ.get("LN_FADE_BLOCKS", "4")) # 起播淡入块数(~170ms @96k/4096)
FADE_GAP_BLOCKS = int(os.environ.get("LN_FADE_GAP_BLOCKS", "24")) # 连续静音多少块算"重新起播"
IDLE_LIMIT = int(os.environ.get("LN_IDLE_LIMIT", "10")) # 连续无数据多少秒后自行退出
DEBUG = os.environ.get("LN_DEBUG", "") not in ("", "0")
@@ -90,7 +92,8 @@ def read_target() -> float:
# +12 f32 gain_db 本块**实际**施加的增益 dB(含峰值保护) -> 面板推子
# +16 f64 t 时间戳(monotonic)
# +24 u32 n 已处理块数(判活)
# +28 u32 _pad
# +28 f32 peak_out ★本块**输出**峰值(2026-09-13 加; 面板"处理后电平"用它,
# 旧版这里是 _pad=0, 面板会回退到估算值并截顶到 PEAK_CEIL)
# +64 f32 target_db 目标响度(面板推子的基准线)
# +68 u32 rate 采样率
SHM_PATH = "/dev/shm/collaplex-loudness"
@@ -122,7 +125,14 @@ def claim_slot() -> int:
os.close(fd)
except Exception:
pass
return 0
# ★★★ 2026-09-13: 两个槽**都**被占 = 已经有一对 DSP 在跑, 本进程必须退出。
# 旧版这里 `return 0` —— 第 3、4 个实例会**一起写槽0**(没有锁保护), 数据被劈开,
# 整链静音(老板报"没声音了", 实测 4 个进程抢 2 个槽, 槽1 僵死在 152 秒前)。
# 注意 DSP 的父进程是 **pipewire 主进程**而不是 filter-chain 实例, 所以重建链路
# 时 pdeathsig 不触发 → 每重建一次就攒一对孤儿, 必须靠这里自我了断。
sys.stderr.write("[loudness] 两个槽位都被占用(已有实例在跑) -> 本进程退出\n")
sys.stderr.flush()
raise SystemExit(0)
def open_shm() -> mmap.mmap:
@@ -179,6 +189,13 @@ def main() -> None:
carry = b"" # 不足 4 字节的尾巴
idle = 0 # 连续无数据的秒数
# ★ 起播淡入(2026-09-13): 从"长时间静音"回到有声的头几块, 上混的 6 路 HRTF 卷积
# 会产生大幅过冲 —— 实测瞬间冲到 +7.8 dBFS(超满刻度 8dB, 削波), 之后指数衰减,
# 面板的峰值栏于是"顶满"。淡入让信号平滑爬升即可压掉。
# 只在**长静音**后触发(连续 FADE_GAP_BLOCKS 块无信号), 免得音乐里的安静段
# 被反复淡入听出"呼吸"。
fade_left = 0
silent_blocks = 0
while True:
# ★ 用 select 带超时读。为什么必须这样:
@@ -243,10 +260,14 @@ def main() -> None:
# ---- 3) 目标增益(dB) ----
if joint > GATE_E:
if silent_blocks > FADE_GAP_BLOCKS: # 长静音后重新起播 → 淡入
fade_left = FADE_BLOCKS
silent_blocks = 0
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:
silent_blocks += 1
want_db = gain_db # 静音段保持当前增益
# ---- 4) 限速平滑(避免抽气/爆音) ----
@@ -266,14 +287,19 @@ def main() -> None:
# ---- 6) 施加增益 ----
y = x * g
if fade_left > 0: # 起播淡入: 线性爬升, 压掉卷积过冲
y = y * ((FADE_BLOCKS - fade_left + 1) / float(FADE_BLOCKS))
fade_left -= 1
out_peak = float(np.max(np.abs(y))) if y.size else 0.0 # ★ 输出峰值(面板用, 真实值)
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 + 8, peak) # 本块峰值(输入)
struct.pack_into("<f", mm, base + 12, gain_db) # 实际增益 -> 推子
struct.pack_into("<f", mm, base + 28, out_peak) # ★ 本块输出峰值 -> 面板"处理后电平"
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" % (
+47 -3
View File
@@ -1,10 +1,11 @@
#!/bin/bash
# 把电影院空间音频全链打包成 deb (2026-09-12 首版; 2026-09-13 v1.2.0 加响度归一化)
# 把电影院空间音频全链打包成 deb
# 2026-09-12 首版; 2026-09-13 v1.2.0 加响度归一化; v1.3.0 加离线渲染 + 组件看门狗
# 包名 collaplex-cinema-spatial, Arch: all (纯脚本+数据, 无编译)
set -eu
SRC="/home/lou/桌面/工作区/实验/空间音频"
PKG="collaplex-cinema-spatial"
VER="1.2.0"
VER="1.3.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"
@@ -13,6 +14,8 @@ echo "=== 1. 准备目录 ==="
rm -rf "$BUILD"
mkdir -p "$BUILD/DEBIAN" "$BUILD/usr/bin" \
"$BUILD${ROOT}/hrir" "$BUILD${ROOT}/sofa" "$BUILD${ROOT}/loudness" \
"$BUILD${ROOT}/config" "$BUILD${ROOT}/reverb" \
"$BUILD/usr/share/applications" \
"$BUILD/usr/share/pipewire/pipewire.conf.d"
# ★ 源必须是项目目录: 旧版从 ~/.local/bin 拷, 那份可能落后于项目(2026-09-13 补回丢失的 cp)
@@ -36,6 +39,26 @@ echo "=== 4b. 响度归一化 (pipe 插件挂的自研 DSP) ==="
# wrapper = /usr/bin/collaplex-loudness-norm
# 项目开发目录仍是中文(响度归一化/), 只在打包时改名。
cp "$SRC/响度归一化/loudness_norm.py" "$SRC/响度归一化/read_state.py" "$BUILD${ROOT}/loudness/"
echo "=== 4c. 房间混响 IR (离线渲染要卷积它) ==="
# ★ 离线渲染.py 里混响 IR 与 SOFA 都是"多路径探测": 开发目录 reverb/房间混响IR-96k.wav,
# 包内 /usr/share/cinema-spatial/reverb/ 同名 —— 漏打这个包, 离线渲染的混响会静默不叠加。
cp "$SRC/reverb/房间混响IR-96k.wav" "$BUILD${ROOT}/reverb/"
echo "=== 4d. 离线渲染 + 组件看门狗 (v1.3.0) ==="
# 离线渲染.py: 音频/视频 -> 空间音频 FLAC(走同一套 5.1 上混 + SOFA HRTF + 混响 + loudnorm)
# 音频守护.py: 30 秒一轮核对 config/组件配置单.json, 扫到问题自动修
# 组件配置单: 机器可读的期望状态(json) + 人读版(md)
cp "$SRC/离线渲染.py" "$SRC/音频守护.py" "$SRC/组件配置单.md" "$BUILD${ROOT}/"
cp "$SRC/config/组件配置单.json" "$BUILD${ROOT}/config/"
chmod 755 "$BUILD${ROOT}/离线渲染.py" "$BUILD${ROOT}/音频守护.py"
# 右键"打开方式"入口: 包内落地到 /usr/bin(collaplex-render), 数据仍在 $ROOT
cp "$SRC/离线渲染-打开.sh" "$BUILD/usr/bin/collaplex-render"
chmod 755 "$BUILD/usr/bin/collaplex-render"
# 桌面项: 项目里存一份"包内版"模板(Exec 直接指向 /usr/bin/collaplex-render),
# 打包原样拷进 /usr/share/applications/ —— 不用 sed 现改, 免得模板与包内不一致。
cp "$SRC/桌面项/collaplex-render.desktop" "$BUILD/usr/share/applications/"
chmod 644 "$BUILD/usr/share/applications/collaplex-render.desktop"
cat > "$BUILD/usr/bin/collaplex-loudness-norm" <<'WRAP'
#!/bin/sh
# Collaplex 响度归一化 — pipe 插件入口(wrapper)
@@ -55,9 +78,12 @@ cp "$SRC/web/index.html" "$SRC/web/webui.py" "$SRC/web/启动.sh" "$BUILD${ROOT}
chmod 755 "$BUILD${ROOT}/web/启动.sh"
# LN 入口用包内 ASCII 路径(不是开发机的 ~/.local/bin)
# ★ CINEMA_SPATIAL_KEEP_CONFS=1: 生成"包内那份"配置时**绝不能**去清理用户/系统目录 ——
# 否则打包动作会把正在用的那份移开, 虚拟声卡当场消失(2026-09-13 踩过)。
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" \
CINEMA_SPATIAL_KEEP_CONFS=1 \
python3 "$SRC/生成配置.py"
cp "$SRC/91-clock.conf" \
"$BUILD/usr/share/pipewire/pipewire.conf.d/"
@@ -71,7 +97,7 @@ Priority: optional
Architecture: all
Version: $VER
Maintainer: edgevoid <edgevoid@users.noreply.github.com>
Depends: pipewire, libmysofa1, mpv, xdg-utils
Depends: pipewire, libmysofa1, mpv, xdg-utils, ffmpeg
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
@@ -87,6 +113,15 @@ Description: Cinema spatial audio for headphones (HRTF virtual surround)
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.
.
Offline rendering: the same chain (5.1 upmix -> SOFA HRTF -> room reverb
-> loudness) applied to files instead of the live sink, encoded to 96 kHz
binaural FLAC. Audio and video inputs (video's audio track is used). Two
guards: a render lock (no second job while one runs) and a landed-file
check (files still being written/downloaded are skipped).
.
Component watchdog: a 30 s loop verifies the whole chain against a
machine-readable spec (component states) and repairs what drifted.
EOF
echo "=== 7. 打包 ==="
@@ -96,9 +131,18 @@ set -e
if [ "$1" = "configure" ]; then
echo "collaplex-cinema-spatial 已安装。"
echo " 命令: 空间音频 开|开5.1|关|状态 mpv-影院 电影.mkv"
echo " 离线渲染: collaplex-render 片子.mp4 歌.mp3"
echo " (或文件管理器里右键 → 打开方式 → Collaplex 离线渲染)"
echo " 看门狗: python3 /usr/share/cinema-spatial/音频守护.py"
echo " (30 秒一轮核对 config/组件配置单.json, 扫到问题自动修)"
echo " 控制台: http://127.0.0.1:8788/ (Collaplex 音效)"
echo " 虚拟声卡: /usr/share/pipewire/pipewire.conf.d/"
echo " 生效: systemctl --user restart pipewire pipewire-pulse wireplumber"
# ★ PipeWire 对 conf.d/*.conf **两份都加载**(不做用户级覆盖系统级)。若用户目录里
# 还有一份开发时生成的同名配置, 就会建出两套同名节点 —— 音频进错那一份 = 没声音。
echo " ★ 检查用户目录是否还有一份同名配置(有就会被同时加载, 症状=没声音):"
echo " ls ~/.config/pipewire/pipewire.conf.d/90-cinema-spatial.conf 91-clock.conf"
echo " 有就移开: mv ~/.config/pipewire/pipewire.conf.d/<文件>{,.disabled-dup}"
# 装完把控制台直接推给用户看: 先把面板起起来(已经在跑就不重复起), 再开浏览器。
# 注意本脚本是 root 身份跑的, 要 su 到登录用户 + 补上 XDG_RUNTIME_DIR,
# 否则 xdg-open 找不到会话总线/显示, 一声不吭什么都不发生。
+14
View File
@@ -0,0 +1,14 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=Collaplex 离线渲染
Name[en]=Collaplex Offline Render
Comment=把音频/视频渲染成空间音频 FLAC (96 kHz 双耳 HRTF)
Comment[en]=Render audio/video into spatial-audio FLAC (96 kHz binaural)
Exec=/usr/bin/collaplex-render %F
Terminal=true
MimeType=audio/mpeg;audio/mp4;audio/flac;audio/x-flac;audio/x-wav;audio/wav;audio/ogg;audio/x-vorbis+ogg;audio/opus;audio/aac;audio/x-m4a;video/mp4;video/x-matroska;video/webm;video/quicktime;video/x-msvideo;video/mpeg;
Icon=multimedia-volume-control
Categories=AudioVideo;
Keywords=Collaplex;空间音频;离线渲染;flac;HRTF;spatial;
StartupNotify=false
+69 -4
View File
@@ -10,6 +10,7 @@
RL/RR = (FL-FR)*0.707 反相 + 希尔伯特卷积(产生相位差/延迟)
"""
import os
import math
import subprocess
import json
@@ -116,7 +117,7 @@ def loudness_instance() -> list[str]:
' inputs = [ "c1L:In" "c1R:In" ]',
' outputs = [ "c2L:Out" "c2R:Out" ]',
" }",
f" capture.props = {{ {VOL_PROP}node.name = cinema_spatial_up_sink "
f" capture.props = {{ {VOL_PROP}{PRIO_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 '
@@ -327,6 +328,13 @@ TARGET_PROP = f'node.target = "{_TARGET}" ' if _TARGET else ""
# 0.25 ≈ -12dB, 给电影那种高峰值因子瞬态留足余量(见 README 第 10、11 条)。
VOLUME = os.environ.get("CINEMA_SPATIAL_VOLUME", "0.25")
VOL_PROP = f"node.volume = {VOLUME} " if VOLUME else ""
# ★★ 虚拟声卡必须带**高于所有物理设备**的 priority.session, 否则默认节点会被抢走:
# 实测本机 HDMI(pro-output-3) = 1196、USB EDIFIER = 1108、板载模拟 = 1009,
# 而虚拟声卡原本**没有这个属性**(None) → 每次"重新选择默认节点"(显示器唤醒、
# 设备插拔、WirePlumber 重启)都是 HDMI 赢; 从 GNOME 手动选回来, 下一个事件又抢走
# (老板报"虚拟声卡老是被 hdmi 抢走, 我点都点不回来")。设成 2000 就压过全部物理口。
SINK_PRIO = 2000
PRIO_PROP = f"priority.session = {SINK_PRIO} "
def hrir_block(indent: str) -> list[str]:
@@ -339,7 +347,13 @@ def hrir_block(indent: str) -> list[str]:
for d in DIRS:
L.append(f'{indent}{{ type = builtin label = copy name = cp{d} }}')
for e in ("L", "R"):
gains = " ".join(f'"Gain {i}" = 1' for i in range(1, 7))
# ★★ 6 路相加必须做**能量归一**(1/sqrt(6) = -7.8 dB), 不能每路都 1。
# 2026-09-13 实测: 每路 gain=1 时上混链净增益 +6 dB —— 归一化 DSP 已经把
# 输出压在峰值保护线(-0.13 dBFS), 这里再抬 6 dB 就顶穿满刻度(起播瞬间实测
# +4.2 ~ +7.8 dBFS, 就是老板看到的"电平怎么都顶满")。
# 1/sqrt(6) 是"不相关信号相加"的能量守恒系数, 净增益落到 -1.8 dB, 留出余量。
g6 = "%.4f" % (1.0 / math.sqrt(6.0))
gains = " ".join(f'"Gain {i}" = {g6}' for i in range(1, 7))
L.append(f'{indent}{{ type = builtin label = mixer name = mix{e} control = {{ {gains} }} }}')
return L
@@ -381,7 +395,7 @@ def sink_51() -> list[str]:
L += [" ]",
' inputs = [ "cpFL:In" "cpFR:In" "cpFC:In" "cpLFE:In" "cpBL:In" "cpBR:In" ]',
f' outputs = [ {outs} ]', " }",
f" capture.props = {{ {VOL_PROP}node.name = cinema_spatial_sink media.class = Audio/Sink "
f" capture.props = {{ {VOL_PROP}{PRIO_PROP}node.name = cinema_spatial_sink media.class = Audio/Sink "
"audio.channels = 6 audio.position = [ FL FR FC LFE BL BR ] }",
f' playback.props = {{ node.name = cinema_spatial_out {TARGET_PROP}'
+ out_props(mono),
@@ -450,7 +464,7 @@ def sink_upmix() -> list[str]:
f' outputs = [ {outs} ]', " }",
# ★ 这一级不再是默认 sink —— sink 名让给前面的归一化实例, 由它的
# node.target = "cinema_spatial_up_raw" 把流推到这里。
" capture.props = { node.name = cinema_spatial_up_raw "
" capture.props = { node.volume = 1.0 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),
@@ -458,6 +472,56 @@ def sink_upmix() -> list[str]:
return L
# ★★ PipeWire 对每个 conf.d 目录里的 *.conf **全都加载**, 不做"用户级覆盖系统级" ——
# 所以"deb 装了一份到 /usr/share/pipewire/pipewire.conf.d/ 之后, 开发时又写了一份
# 用户级" = 同名实例建两遍、节点重名(实测 6 个节点各 2 份), 音频进哪一份由调度决定,
# 常常进了没有下游消费者的那一份 → 整条链静默。症状: "两边看着都在, 就是没声音"。
# (2026-09-13 实测) 写入后顺手把**别处**的同名 conf 挪开: 能改名就改名, root 的只告警。
CONF_DIRS: list[str] = [
"/usr/share/pipewire/pipewire.conf.d",
"/etc/pipewire/pipewire.conf.d",
os.path.expanduser("~/.config/pipewire/pipewire.conf.d"),
]
def _prune_one(name: str, keep: str) -> None:
"""把 keep 之外的同名 conf 挪成 .disabled-dup(root 的挪不动就告警)."""
mine = os.path.realpath(keep)
for d in CONF_DIRS:
p = os.path.join(d, name)
if not os.path.isfile(p) or os.path.realpath(p) == mine:
continue
try:
os.rename(p, p + ".disabled-dup")
print(f" · 移开重复配置 {p} -> {name}.disabled-dup")
except OSError as e:
print(f" ! 重复配置挪不动: {p} ({e.strerror})")
print(" 它会与本配置建出两份同名声卡 = 音频进错实例(没声音)。请执行一次:")
print(f" sudo mv {p} {p}.disabled-dup")
def prune_duplicate_confs() -> None:
"""移开 OUT/CLOCK_OUT 之外的同名 conf.
★ 91-clock.conf 同理: 改采样率写的是用户级那份, 而 deb 装在系统级也有一份 →
两个 default.clock.rate 同时加载 = 时钟不确定。所以两份都要"同名只留一份"。
★★ 但**只有在写真正生效的位置时**才该清理: 若 OUT 落在 CONF_DIRS 之外(打包时
写到临时构建目录、或测试时写到 /tmp), 那用户目录/系统目录里的配置一份都不该动。
2026-09-13 打包正是栽在这里 —— 生成包内 conf 时把用户级和系统级两份都移开,
虚拟声卡整个消失(看门狗先报"链输出没有任何连线, 且找不到物理设备")。
需要强制跳过时设 CINEMA_SPATIAL_KEEP_CONFS=1。
"""
if os.environ.get("CINEMA_SPATIAL_KEEP_CONFS") == "1":
return
live = {os.path.realpath(d) for d in CONF_DIRS}
if os.path.dirname(os.path.realpath(OUT)) not in live:
return
_prune_one(os.path.basename(OUT), OUT)
if os.path.isfile(CLOCK_OUT):
_prune_one(os.path.basename(CLOCK_OUT), CLOCK_OUT)
def main() -> int:
global IR
IR = ir_dir()
@@ -478,6 +542,7 @@ def main() -> int:
body += ["]", ""]
with open(OUT, "w", encoding="utf-8") as fh:
fh.write("\n".join(body))
prune_duplicate_confs()
print(f"已生成 {OUT} ({len(body)} 行)")
print(f"输出目标: {TARGET_PROP.replace('node.target = ', '').strip() or '自动(WirePlumber 路由到默认输出)'}")
return 0
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# 右键「打开方式 → Collaplex 离线渲染」的入口: 把文件交给离线渲染, 终端里跑(看得到进度)。
#
# ★ 为什么要这个 wrapper: .desktop 的 Exec 里不能直接塞 %F(路径有空格会断),
# 也不能在 bash -c 的字符串里写 %F(不会被展开) —— 必须用 `"$@"` 接收启动器展开的参数。
# 开发目录跑时按脚本自己所在目录(项目根); 装了 deb 后脚本在 /usr/bin、数据在
# /usr/share/cinema-spatial —— 两种布局都要认, 否则右键入口在装完后会找不到脚本。
if [ -n "${COLLAPLEX_ROOT:-}" ] && [ -d "$COLLAPLEX_ROOT" ]; then
cd "$COLLAPLEX_ROOT" || exit 1
else
cd "$(dirname "$(readlink -f "$0")")" || exit 1
if [ ! -f "./离线渲染.py" ]; then
cd /usr/share/cinema-spatial 2>/dev/null || exit 1
fi
fi
if [ "$#" -eq 0 ]; then
echo "用法: 右键音频/视频 → 打开方式 → Collaplex 离线渲染"
echo " (或者把多个文件一起选中再打开)"
echo
echo "命令行用法见: python3 离线渲染.py --help"
read -r -p "回车关闭…"
exit 1
fi
echo "Collaplex 离线渲染 —— 空间音频 FLAC (96 kHz 双耳)"
echo "待处理 $# 个文件"
echo
python3 离线渲染.py "$@"
rc=$?
echo
if [ "$rc" -eq 2 ]; then
echo "(退出码 2 = 已有渲染任务在跑, 本次没接 —— 等它跑完再来)"
fi
read -r -p "回车关闭…"
exit "$rc"
+437
View File
@@ -0,0 +1,437 @@
#!/usr/bin/env python3
"""离线渲染 —— 把音频/视频处理成空间音频 FLAC。
链路与实时虚拟声卡一致:
立体声 --pan上混--> 5.1 --sofalizer(H4 SOFA)--> 双耳立体声 --[混响卷积]--> 响度归一化 --> flac
输入:
音频(mp3/flac/wav/m4a/ogg/opus/aac...) 直接处理;
视频(mp4/mkv/mov/webm...) 只取音轨(ffmpeg 选流, 不落地中间文件);
已经是 5.1 的片源不做上混, 直接进 HRTF。
★ 锁(老板要求):
1) 渲染锁: 一轮任务处理期间**不接受新任务**(拿不到锁就退出, 不排队堆积);
2) 文件就绪检查: 每个文件进队列前查"是否还在写入/下载"(两次 stat + ffprobe),
没落盘完的直接跳过 —— 不会把半个文件扔进渲染。
用法:
python3 离线渲染.py 片子.mp4 歌.mp3 # 渲染(串行, 实时进度)
python3 离线渲染.py --状态 # 看是否在跑 / 上次结果
python3 离线渲染.py --混响 0.3 歌.mp3 # 指定混响湿量(0 = 不要混响)
python3 离线渲染.py --目标 -16 歌.mp3 # 响度目标(dBFS)
python3 离线渲染.py --无HRTF 歌.flac # 只上混 5.1, 不做双耳空间化
python3 离线渲染.py --输出目录 ~/音乐/空间 歌.mp3
"""
from __future__ import annotations
import argparse
import fcntl
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import IO
ROOT = Path(__file__).resolve().parent
def _first_existing(cands: list[Path]) -> Path | None:
"""按顺序取第一个存在的文件 —— 开发目录与 deb 包里的素材布局不一样, 两种都要认。"""
for c in cands:
if c.is_file():
return c
return None
# SOFA: 开发目录在 sofa/H4_HRIR_SOFA/H4_HRIR_SOFA/, deb 包里直接躺在 sofa/ 下
SOFA_96K = _first_existing([
ROOT / "sofa" / "H4_96K_24bit_512tap_FIR_SOFA.sofa",
ROOT / "sofa" / "H4_HRIR_SOFA" / "H4_HRIR_SOFA" / "H4_96K_24bit_512tap_FIR_SOFA.sofa",
Path("/usr/share/cinema-spatial/sofa/H4_96K_24bit_512tap_FIR_SOFA.sofa"),
])
# 混响 IR 同理(包里是 /usr/share/cinema-spatial/reverb/)
REVERB_IR = _first_existing([
ROOT / "reverb" / "房间混响IR-96k.wav",
Path("/usr/share/cinema-spatial/reverb/房间混响IR-96k.wav"),
])
LOCK_PATH = Path("/tmp/collaplex-render.lock")
STATE_PATH = Path(os.path.expanduser("~/.local/state/cinema-spatial/render.json"))
# ★ 进度文件(给面板轮询): 带 --进度json <路径> 时每 0.4 秒原子写一次, 面板读它画进度条。
PROGRESS_PATH: Path | None = None
def write_progress(data: dict) -> None:
"""原子写进度 JSON —— 面板在同时读, 不能让它读到半个文件。"""
if PROGRESS_PATH is None:
return
try:
tmp = PROGRESS_PATH.with_suffix(".tmp")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False)
tmp.replace(PROGRESS_PATH)
except OSError:
pass
VIDEO_EXT = {".mp4", ".mkv", ".mov", ".webm", ".avi", ".flv", ".wmv", ".m4v", ".ts", ".mpg", ".mpeg", ".rmvb"}
AUDIO_EXT = {".mp3", ".flac", ".wav", ".m4a", ".aac", ".ogg", ".opus", ".wma", ".aiff", ".ape", ".dff", ".dsf"}
OUT_SR = 96000
class Opt:
"""一次渲染的选项。"""
def __init__(self, wet: float, target: float, hrtf: bool, outdir: Path | None,
overwrite: bool, quiet: bool) -> None:
self.wet = wet # 混响湿量(0 = 关)
self.target = target # 响度目标 dBFS
self.hrtf = hrtf # 是否做双耳 HRTF
self.outdir = outdir # 输出目录(None = 源文件同级)
self.overwrite = overwrite # 已存在是否覆盖
self.quiet = quiet # 安静模式(只报结果)
# ------------------------------------------------------------------ 锁
def acquire_lock() -> IO[str] | None:
"""拿渲染锁。拿不到 -> None(已有任务在跑, 调用方直接退出)。"""
fd = os.open(str(LOCK_PATH), os.O_CREAT | os.O_RDWR, 0o644)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
os.close(fd)
return None
fh = os.fdopen(fd, "w+", encoding="utf-8")
fh.truncate(0)
fh.write("%d\n%s\n" % (os.getpid(), time.strftime("%F %T")))
fh.flush()
return fh
def runing_pid() -> str:
"""另一个进程是否正持有渲染锁; 返回它的 pid 描述(空 = 空闲)。"""
if not LOCK_PATH.exists():
return ""
try:
fd = os.open(str(LOCK_PATH), os.O_RDWR)
except OSError:
return ""
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
fcntl.flock(fd, fcntl.LOCK_UN)
return ""
except OSError:
try:
with open(LOCK_PATH, encoding="utf-8") as fh:
first = fh.readline().strip()
return first or "未知"
except OSError:
return "未知"
finally:
os.close(fd)
# ------------------------------------------------------------------ 探测
def ffprobe_json(path: Path) -> dict:
"""ffprobe -show_format -show_streams 的 JSON; 失败返回空 dict。"""
try:
r = subprocess.run(
["ffprobe", "-v", "error", "-print_format", "json",
"-show_format", "-show_streams", str(path)],
capture_output=True, text=True, timeout=60)
return json.loads(r.stdout or "{}")
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
return {}
def audio_layout(path: Path) -> tuple[str, float]:
"""(音轨的声道布局, 时长秒); 读不到返回 ("", 0.0)。"""
info = ffprobe_json(path)
for st in info.get("streams") or []:
if st.get("codec_type") == "audio":
layout = str(st.get("channel_layout") or "")
ch = int(st.get("channels") or 0)
if not layout:
layout = {1: "mono", 2: "stereo", 6: "5.1", 8: "7.1"}.get(ch, "stereo")
dur = 0.0
for key in ("duration",):
try:
dur = float(st.get(key) or 0.0)
except (TypeError, ValueError):
dur = 0.0
if dur <= 0.0:
try:
dur = float((info.get("format") or {}).get("duration") or 0.0)
except (TypeError, ValueError):
dur = 0.0
return (layout, dur)
return ("", 0.0)
def file_ready(path: Path) -> tuple[bool, str]:
"""文件是否已经落盘完成。
★ 老板要求的第二道锁: 防"文件还在内存里没下载下来就被扔进渲染"。
判据: ① 存在且是常规文件且非空
② 相隔 1.2 秒两次 stat, size 和 mtime 都不变(还在写会一直变)
③ ffprobe 能读出音轨(半个文件通常读不出)
"""
if not path.exists():
return (False, "文件不存在")
if not path.is_file():
return (False, "不是常规文件(目录/设备?)")
try:
s1 = path.stat()
except OSError as e:
return (False, "读不到文件属性: %s" % e)
if s1.st_size == 0:
return (False, "文件是空的(0 字节)")
time.sleep(1.2)
try:
s2 = path.stat()
except OSError as e:
return (False, "文件在处理期间消失: %s" % e)
if s2.st_size != s1.st_size or s2.st_mtime != s1.st_mtime:
return (False, "文件还在写入/下载中(大小还在变), 等它传完再扔")
layout, _dur = audio_layout(path)
if not layout:
return (False, "读不到音轨(可能没传完, 或不是音视频文件)")
return (True, "")
# ------------------------------------------------------------------ 滤镜图
def build_filter(layout: str, src_idx: int, opt: Opt, with_reverb: bool) -> str:
"""拼 ffmpeg 滤镜图。
与实时链对应关系:
pan 5.1 <- 生成配置.py 里的上混矩阵(FL/FR 直通, FC=(FL+FR)*0.707,
LFE 同源衰减, BL/BR 反相)
sofalizer <- 12 个 HRTF 卷积 + mixL/mixR(6 路各 1/sqrt(6) 能量归一)
afir(房间IR) <- 混响湿路
loudnorm <- 响度归一化 DSP
"""
src = "[%d:a]" % src_idx
chain = "%saformat=sample_rates=%d" % (src, OUT_SR)
if layout in ("5.1", "5.1(side)", "7.1", "6.1", "7.1(wide)"):
chain += ",aformat=channel_layouts=5.1" # 片源本就多声道, 不重上混
elif layout == "mono":
chain += ",aformat=channel_layouts=stereo,pan=5.1|FL=FL|FR=FR|FC=0.707*FL+0.707*FR|"
chain += "LFE=0.5*FL+0.5*FR|BL=0.707*FL-0.707*FR|BR=0.707*FR-0.707*FL"
else:
chain += (",aformat=channel_layouts=stereo,pan=5.1|FL=FL|FR=FR|FC=0.707*FL+0.707*FR|"
"LFE=0.5*FL+0.5*FR|BL=0.707*FL-0.707*FR|BR=0.707*FR-0.707*FL")
if opt.hrtf and SOFA_96K is not None:
chain += ",sofalizer=sofa=%s:type=freq" % SOFA_96K
else:
chain += ",pan=stereo|FL=FL|FR=FR" # 不做双耳: 5.1 下混成立体声
graph = "%s[hr]" % chain
if with_reverb:
graph += (";[hr]asplit=2[rdry][rwet]"
";[%d:a]aformat=sample_rates=%d:channel_layouts=mono[rir]"
";[rwet][rir]afir=dry=0:wet=1:gtype=none:irnorm=1[rwet2]"
";[rdry][rwet2]amix=inputs=2:weights=1 %.4f:normalize=0[hr2]"
% (src_idx + 1, OUT_SR, opt.wet))
hr = "[hr2]"
else:
hr = "[hr]"
graph += ";%sloudnorm=I=%.1f:TP=-1.5:LRA=11[out]" % (hr, opt.target)
return graph
def render(src: Path, dst: Path, opt: Opt) -> bool:
"""渲染一个文件; 返回是否成功。实时打印进度。"""
layout, dur = audio_layout(src)
if not layout:
print(" ✗ 读不到音轨, 跳过")
return False
with_reverb = opt.wet > 0.0 and REVERB_IR is not None
graph = build_filter(layout, 0, opt, with_reverb)
dst.parent.mkdir(parents=True, exist_ok=True)
cmd = ["ffmpeg", "-hide_banner", "-v", "error", "-nostdin", "-y",
"-i", str(src)]
if with_reverb:
cmd += ["-i", str(REVERB_IR)]
cmd += ["-filter_complex", graph, "-map", "[out]",
"-c:a", "flac", "-ar", str(OUT_SR), "-compression_level", "8",
"-progress", "pipe:1", "-nostats", str(dst)]
kind = "视频(取音轨)" if src.suffix.lower() in VIDEO_EXT else "音频"
spk = " + 混响 %.2f" % opt.wet if with_reverb else ""
print(" · %s %s %s 时长 %.1fs" % (kind, layout, spk, dur))
t0 = time.time()
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
last_draw = 0.0
assert proc.stdout is not None
for line in proc.stdout:
# ★ ffmpeg 的 out_time_ms 实际单位是**微秒**(历史遗留命名), 用 out_time_us 才不歧义 ——
# 按毫秒算会把 8 秒的素材显示成 4779 秒、进度瞬间冲到 100%(2026-09-13 实测)。
if not line.startswith("out_time_us="):
continue
try:
us = int(line.split("=", 1)[1].strip())
except ValueError:
continue
if opt.quiet or dur <= 0.0:
continue
now = time.time()
if now - last_draw < 0.4:
continue
last_draw = now
sec = max(0.0, us / 1e6)
pct = max(0.0, min(100.0, sec / dur * 100.0))
speed = sec / max(0.001, now - t0)
bar = "█" * int(pct / 4) + "·" * (25 - int(pct / 4))
sys.stdout.write("\r %s %5.1f%% %.1fx %4.0fs/%.0fs "
% (bar, pct, speed, sec, dur))
sys.stdout.flush()
write_progress({"状态": "渲染中", "文件": src.name, "百分比": round(pct, 1),
"速度": round(speed, 2), "当前秒": round(sec, 1), "总秒": round(dur, 1)})
proc.wait()
err = (proc.stderr.read() if proc.stderr else "") or ""
if not opt.quiet and dur > 0.0:
sys.stdout.write("\r" + " " * 72 + "\r")
if proc.returncode != 0 or not dst.exists():
print(" ✗ 失败: %s" % (err.strip().split("\n")[-1] if err.strip() else "ffmpeg 退出码 %d" % proc.returncode))
return False
mb = dst.stat().st_size / 1048576.0
print(" ✓ 完成 -> %s (%.1f MB, 用时 %.1fs)" % (dst.name, mb, time.time() - t0))
return True
# ------------------------------------------------------------------ 状态
def write_state(data: dict) -> None:
try:
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(STATE_PATH, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
except OSError:
pass
def show_state() -> None:
pid = runing_pid()
print("渲染锁: %s" % ("正被 PID %s 持有(有任务在跑)" % pid if pid else "空闲"))
if STATE_PATH.exists():
try:
with open(STATE_PATH, encoding="utf-8") as fh:
d = json.load(fh)
print("上次: %s" % d.get("结束时间", "?"))
print(" 成功 %s / 失败 %s" % (d.get("成功", 0), d.get("失败", 0)))
for f in d.get("文件", [])[:20]:
print(" %s %s" % (f.get("状态", "?"), f.get("名称", "")))
except (OSError, json.JSONDecodeError):
print("上次: (状态文件读不出)")
if not shutil.which("ffmpeg"):
print("! 找不到 ffmpeg")
# ------------------------------------------------------------------ main
def main() -> int:
ap = argparse.ArgumentParser(
description="离线渲染: 音频/视频 -> 空间音频 FLAC",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="示例:\n python3 离线渲染.py 片子.mp4 歌.mp3\n python3 离线渲染.py --混响 0.3 歌.flac")
ap.add_argument("files", nargs="*", help="要处理的音频/视频文件(可多个)")
ap.add_argument("--状态", action="store_true", help="显示是否在跑 / 上次结果")
ap.add_argument("--混响", type=float, default=0.3, help="混响湿量 0~1 (默认 0.3, 0 = 关)")
ap.add_argument("--目标", type=float, default=-14.0, help="响度目标 dBFS (默认 -14)")
ap.add_argument("--无HRTF", action="store_true", help="只上混 5.1, 不做双耳空间化")
ap.add_argument("--输出目录", default="", help="输出目录(默认与源文件同级)")
ap.add_argument("--覆盖", action="store_true", help="已存在同名输出时覆盖(默认跳过)")
ap.add_argument("--安静", action="store_true", help="不打进度条, 只报结果")
ap.add_argument("--进度json", default="", help="把进度写到该 JSON 文件(面板轮询用)")
args = ap.parse_args()
global PROGRESS_PATH
if args.进度json:
PROGRESS_PATH = Path(args.进度json).expanduser()
if args.状态:
show_state()
return 0
if not args.files:
ap.print_help()
return 1
if not shutil.which("ffmpeg"):
print("! 找不到 ffmpeg, 无法渲染")
return 1
if SOFA_96K is None and not args.无HRTF:
print("! 找不到 SOFA 文件(开发目录 sofa/ 或 /usr/share/cinema-spatial/sofa/); "
"可用 --无HRTF 跳过空间化")
return 1
# ★ 第一道锁: 渲染期间不接受新任务 —— 拿不到锁直接退出, 不排队堆积
lock = acquire_lock()
if lock is None:
print("⛔ 已有一个渲染任务在跑(PID %s), 本次不接 —— 等它跑完再扔。" % runing_pid())
return 2
outdir = Path(args.输出目录).expanduser() if args.输出目录 else None
opt = Opt(wet=max(0.0, min(1.0, args.混响)), target=args.目标, hrtf=not args.无HRTF,
outdir=outdir, overwrite=args.覆盖, quiet=args.安静)
print("离线渲染 —— 上混 5.1%s -> 响度目标 %.1f dBFS -> FLAC %d kHz"
% (" -> SOFA 双耳 HRTF" if opt.hrtf else "", opt.target, OUT_SR // 1000))
if opt.wet > 0:
print("混响湿量 %.2f (%s)" % (opt.wet, REVERB_IR.name if REVERB_IR else "找不到混响 IR, 本次不叠加"))
print("渲染锁已取得(PID %d); 期间不接受新任务\n" % os.getpid())
ok: list[dict] = []
skipped: list[dict] = []
failed: list[dict] = []
t_all = time.time()
write_progress({"状态": "开始", "总数": len(args.files), "文件": "", "百分比": 0,
"已处理": 0, "成功": 0, "跳过": 0, "失败": 0})
try:
for i, raw in enumerate(args.files, start=1):
src = Path(raw).expanduser()
print("[%d/%d] %s" % (i, len(args.files), src.name))
write_progress({"状态": "检查中", "文件": src.name, "百分比": 0, "序号": i,
"总数": len(args.files), "成功": len(ok), "跳过": len(skipped),
"失败": len(failed)})
# ★ 第二道锁: 文件没落盘完(还在下载/写入)就不扔进渲染
ready, why = file_ready(src)
if not ready:
print(" ⏭ 跳过: %s" % why)
skipped.append({"名称": src.name, "状态": "跳过", "原因": why})
continue
if src.suffix.lower() not in AUDIO_EXT | VIDEO_EXT:
print(" ⚠ 后缀不认识, 仍按音频尝试: %s" % src.suffix)
dst = (opt.outdir or src.parent) / ("%s.空间音频.flac" % src.stem)
if dst.exists() and not opt.overwrite:
print(" ⏭ 跳过: 已存在 %s (要覆盖加 --覆盖)" % dst.name)
skipped.append({"名称": src.name, "状态": "跳过", "原因": "输出已存在"})
continue
if render(src, dst, opt):
ok.append({"名称": src.name, "状态": "成功", "输出": str(dst)})
else:
failed.append({"名称": src.name, "状态": "失败"})
finally:
lock.close()
print("\n—— 完成: 成功 %d / 跳过 %d / 失败 %d, 总用时 %.1fs ——"
% (len(ok), len(skipped), len(failed), time.time() - t_all))
for f in failed:
print(" 失败: %s" % f["名称"])
for f in skipped:
print(" 跳过: %s (%s)" % (f["名称"], f.get("原因", "")))
write_progress({"状态": "完成", "成功": len(ok), "跳过": len(skipped),
"失败": len(failed), "百分比": 100})
write_state({"结束时间": time.strftime("%F %T"), "成功": len(ok), "失败": len(failed),
"文件": ok + skipped + failed})
return 0 if not failed else 3
if __name__ == "__main__":
sys.exit(main())
+57 -5
View File
@@ -43,6 +43,17 @@ nick() { wpctl inspect "$1" 2>/dev/null | grep -oP 'node\.nick = "\K[^"]+' | hea
vol_of_id() { wpctl get-volume "$1" 2>/dev/null | grep -oP 'Volume:\s*\K[0-9.]+'; }
conf_target() { grep -oP 'node\.target = "\K[^"]+' "$CONF" 2>/dev/null | head -1; }
helper() { [ -n "$HELPER" ] && python3 "$HELPER" "$@" 2>/dev/null || true; }
# 同名节点份数(>1 = 有两份同名 conf 被 PipeWire **同时加载**, 音频会进错那一份)
# ★ PipeWire 对 conf.d/*.conf 不做"用户级覆盖系统级", 两份都加载 —— 2026-09-13 实测
# "deb 装的 /usr/share 那份 + 开发目录用户级那份"并存 = 6 个节点各 2 份 → 没声音。
inst_count() {
unset PIPEWIRE_CONFIG_DIR
pw-dump 2>/dev/null | python3 -c '
import json,sys
n=sys.argv[1]
d=json.load(sys.stdin)
print(sum(1 for x in d if (x.get("info",{}).get("props",{}) or {}).get("node.name")==n))' "$1"
}
# ---- 音量持久化 ----------------------------------------------------------
read_vol() {
@@ -77,6 +88,14 @@ apply_volume() {
| grep -oE '[0-9]+\. cinema_spatial[a-z_]*_sink' | grep -oE '^[0-9]+'); do
wpctl set-volume "$id" "$vol" >/dev/null 2>&1
done
# ★★ 链中间级必须是**单位增益**: 归一化输出(up_norm)与上混图输入口(up_raw)不归用户调。
# 2026-09-13 实测: up_raw 被写成 0.2068(-13.7 dB) → 全链从 -8.1 dB 掉到 -29.9 dB,
# 耳机听着"一点声音没有", 而旧版音量账只算首尾两级、账面显示一切正常。
# 每次"开"都把它们纠回 1.0。
for id in $(wpctl status 2>/dev/null | sed -n '/Filters:/,/Streams:/p' \
| grep -oE '[0-9]+\. cinema_spatial_up_(norm|raw)' | grep -oE '^[0-9]+'); do
wpctl set-volume "$id" 1.0 >/dev/null 2>&1
done
save_vol "$vol"
}
@@ -115,6 +134,30 @@ print(w["'"$dev"'"],w["'"$out"'"])')
echo " ! 运行期重连失败, 回落重建"; return 1
}
# ★★ 2026-09-13: DSP 进程的父进程是 **pipewire 主进程**而不是 filter-chain 实例,
# 所以重建/重启时 pdeathsig 不触发 —— 旧 DSP 会一直活着并占着 /dev/shm 的共享槽位,
# 新实例抢不到槽位会自我了断(claim_slot 已改) → 链里没有 DSP = **完全静音**
# (老板报"没声音了", 实测攒了 4 个进程抢 2 个槽, 槽1 僵死在 152 秒前)。
# 所以**每次重启 PipeWire 之前必须先收掉旧的**。
kill_old_dsp() {
local pid cmd killed=0
for pid in /proc/[0-9]*; do
pid=${pid#/proc/}
[ -r "/proc/$pid/cmdline" ] || continue
cmd=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)
case "$cmd" in
*loudness_norm.py*) kill -9 "$pid" 2>/dev/null && killed=$((killed + 1)) ;;
esac
done
[ "$killed" -gt 0 ] && echo " · 清掉 $killed 个旧 DSP 实例(防占共享槽位)"
return 0
}
restart_pipewire() {
kill_old_dsp
systemctl --user restart pipewire pipewire-pulse >/dev/null 2>&1
}
wait_target() { # $1 = 最多等多少秒; 0 = 回来了, 1 = 超时
local i=0 n=${1:-15}
while [ "$i" -lt "$n" ]; do
@@ -166,7 +209,7 @@ rebuild_if_stale() {
# 被 systemd 拉起 —— 于是蓝牙/USB 设备要几秒才重新出现。原来固定 sleep 4 就判"目标失效"
# → 回退把目标换成别的设备 → 蓝牙音响再也连不上(老板 2026-09-13 报的)。
# 所以真正的修法是 wait_target: 轮询等目标回来, 最多 20 秒。
systemctl --user restart pipewire pipewire-pulse >/dev/null 2>&1
restart_pipewire
wait_target 20 || echo " · 目标 $(conf_target) 暂未回来, 继续走"
if [ -n "$card" ] && [ -n "$prof" ]; then
@@ -207,7 +250,7 @@ switch_route() {
if [ "$(conf_target)" != "$target" ]; then
echo " · 输出目标变了, 重建配置(target=$target)"
CINEMA_SPATIAL_TARGET="$target" python3 "$GEN" >/dev/null 2>&1 || { echo " ! 重建失败"; return 1; }
systemctl --user restart pipewire pipewire-pulse >/dev/null 2>&1
restart_pipewire
wait_target 20
python3 "$HELPER" set-route "$card" "$kind" >/dev/null 2>&1 # 重启可能被拉回, 再按一次
sleep 2
@@ -268,12 +311,12 @@ switch_device() {
fi
CINEMA_SPATIAL_TARGET="$matched" python3 "$GEN" >/dev/null 2>&1 \
|| { echo " ! 生成配置失败, 目标未改"; return 1; }
systemctl --user restart pipewire pipewire-pulse >/dev/null 2>&1
restart_pipewire
# ★ 不是"睡 4 秒就判死": 蓝牙/USB 重连要 3~8 秒, 原来没等够就宣布失效 → 目标被换掉
if ! wait_target 20; then
echo " ! 目标 $(conf_target) 等了 20 秒没回来, 回退到自动探测(跳过显卡/HDMI)"
CINEMA_SPATIAL_TARGET="$(helper phys-sink)" python3 "$GEN" >/dev/null 2>&1
systemctl --user restart pipewire pipewire-pulse >/dev/null 2>&1
restart_pipewire
wait_target 12
return 1
fi
@@ -328,7 +371,7 @@ regen_chain() {
CINEMA_SPATIAL_TARGET="$tgt" python3 "$GEN" >/dev/null 2>&1 || { echo " ! 生成配置失败"; return 1; }
[ -n "$tgt" ] && [ "$tgt" != "$sink" ] && echo " · 目标改用: $tgt"
# ★ 同 regen_chain: 重启后等目标回来(蓝牙/USB 要 3~8 秒), 别睡 4 秒就判死
systemctl --user restart pipewire pipewire-pulse >/dev/null 2>&1
restart_pipewire
wait_target 20 || echo " · 目标 $(conf_target) 暂未回来, 继续走"
# ★ 会话管理器可能被连续重建搞崩(实测 2026-09-13: GLib 断言 core-dump)。
# 它一死, monitor 的新连接就建不起来 —— 表现是电平表卡 -120 dBFS 而链路看着全对。
@@ -351,6 +394,15 @@ regen_chain() {
if [ -n "$v" ]; then wpctl set-default "$v"; apply_volume; else echo " ! 虚拟声卡 $VNAME 没加载, 检查 $CONF"; fi
echo " · 输出: $(conf_target)"
python3 "$HELPER" check | sed 's/^/ /'
# ★ 重建收尾必须查"同名实例只有一份": 两份同名 conf 会被 PipeWire 同时加载 →
# 节点重名、音频进错实例、整条链静默(2026-09-13 实测"开了两个都没声音")。
local nd; nd=$(inst_count "$VNAME")
if [ "${nd:-1}" -gt 1 ] 2>/dev/null; then
echo " ! $nd 份 $VNAME —— 有两份同名 conf 被同时加载, 音频会进错那一份"
echo " 另一份多半是 deb 装到系统级的。修:"
echo " sudo mv /usr/share/pipewire/pipewire.conf.d/90-cinema-spatial.conf{,.disabled-dup}"
echo " systemctl --user restart pipewire pipewire-pulse"
fi
}
# ---- 采样参数(HRTF 模型 / tap 数 / 采样率) --------------------------------
+206
View File
@@ -0,0 +1,206 @@
# 电影院空间音频 —— 组件配置单
> 2026-09-13 全链路修复后实测的稳定状态。机器可读版本在 **`config/组件配置单.json`**,
> 由 **`音频守护.py`**(30 秒一扫,扫到就修)逐项核对。
>
> 本文档的目的:**说清"什么必须是什么"** —— 当晚 6 个坑每一个都是"某处偏离了这里的期望值"。
---
## 1. 链路拓扑
```
应用 (Google Chrome / Chromium / mpv-影院 / 任何软件)
│ ↑ 默认输出 = 虚拟声卡, 应用不用配
▼
cinema_spatial_up_sink Audio/Sink priority.session=2000
│ ← 媒体键/GNOME 顶栏改的就是它的音量(= 链的输入增益 / 削波余量)
│ ← 面板「输入增益」滑块也改它; 菜单音量桥会把按键折算到「扬声器电平」
│
├─(capture)─ 归一化实例 (独立 filter-chain, 与上混图分开)
│ copy → pipe → 自研 DSP → copy
│ ↑ 左右声道各一个进程 (响度归一化/loudness_norm.py ×2)
│ 共享 /dev/shm/collaplex-loudness 的两个槽位
│
▼ up_norm (必须 = 1.000, 单位增益, 不归用户调)
cinema_spatial_up_raw Audio/Sink
│ ← 上混图的入口; 与 up_norm 一起构成"中间两级", 被写坏就是"耳机没声音"的元凶
│
├─ 上混实例 ── FL/FR 上混成 6 路虚拟扬声器(以听者为中心, 含正上方)
│ 每路各自 HRTF 卷积 (SADIE-II H4, 96k / 512tap)
│ + 混响 (镜像法早期反射 + Sabine 尾音)
│ → mixL / mixR ★ 6 路 Gain 必须各 1/√6 = 0.4082 (能量归一)
│
▼ up_out (1.120 = 链的输出端音量)
物理设备 EDIFIER USB (iec958-stereo / 模拟, 96kHz / 24bit S24LE)
↑ 它的音量 = 「扬声器电平」= 老板实际听到的音量 (= 菜单音量桥的目标)
```
**还有第二条并行的 5.1 链**(`cinema_spatial_sink → cinema_spatial_out`),默认不用;
两条链共用同一个物理输出,只有被设为默认 sink 的那条在收数据。
---
## 2. 组件清单
| 组件 | 类型 | 期望值 | 作用 / 失效后果 |
|---|---|---|---|
| `cinema_spatial_up_sink` | Audio/Sink | **默认 sink**、`priority.session=2000`、vol 由老板定(削波余量) | 应用入口。被 HDMI 抢走 → 声音跑去电视;被写成很低 → DSP 顶格 |
| `cinema_spatial_up_norm` | 链内部 | **vol = 1.000**(单位增益) | 归一化 DSP 的输出口。被写低 → 全链凭空掉十几 dB |
| `cinema_spatial_up_raw` | Audio/Sink | **vol = 1.000**(单位增益) | 上混图入口。同上,就是"耳机一点声音没有"的元凶 |
| `cinema_spatial_up_out` | 链输出端 | vol = 1.120;**output_FL/FR 必须连到物理设备 playback_FL/FR** | 断连 → 声音进不去设备(看着全对却静音) |
| `cinema_spatial_sink` / `cinema_spatial_out` | Audio/Sink / 链输出 | 各 1 份,`priority.session=2000` | 并行的 5.1 链,备用 |
| DSP 进程 | `python3 loudness_norm.py` | **恰好 2 个** | 多 = 孤儿抢槽位 → 整链静音;0 个 = 没消费者 → 静音 |
| `/dev/shm/collaplex-loudness` | 共享内存 | 两个槽**都在 5 秒内更新过**;槽内 +16 是 f64 时刻 | 有槽僵死 → pipe 同步管道卡住 → 静音 |
| 默认输出时钟 | settings metadata | `clock.rate=96000`、`allowed-rates=[96000]` | 不提 96k → HRTF IR 白换 |
| 面板 | HTTP 服务 | 8788 端口 200;`web/webui.py` 常驻 | 电平表/音量桥/诊断都在这里 |
| 归一化 DSP 参数 | env 默认值 | `LN_MAX_BOOST=12`、`LN_PEAK_CEIL=0.985`、`LN_FADE_BLOCKS=4`、`LN_FADE_GAP_BLOCKS=24` | 输出峰值被锁在 −0.13 dBFS;长静音后起播淡入 170ms |
**状态文件**(`~/.local/state/cinema-spatial/`)
| 文件 | 当前值 | 含义 |
|---|---|---|
| `loudness.json` | `unified=true, target=0.361, digital=1.005, analog=1.005, target_db=-14.0` | 响度统一 + 归一化目标 |
| `reverb.json` | `on=true, wet=0.3` | 混响 |
| `params.json` | `taps=0, hrtf=H4-96k` | HRTF 与混响 taps |
| `mono.json` | `on=false` | 单声道 |
| `volume` | `1.000` | 「输入增益」落盘值(= 链的削波余量) |
---
## 3. 六个"必须"(当晚的坑,每条都对应一个不变量)
| # | 不变量 | 违背时的症状 | 根因 |
|---|---|---|---|
| 1 | 同名 conf 只能有一份(真源 = `~/.config/pipewire/pipewire.conf.d/`) | 开了两个都没声音 | PipeWire 把每个 conf.d 目录里的 `*.conf` **全部加载**,不做用户级覆盖系统级 |
| 2 | `up_norm` / `up_raw` 必须 1.000 | 耳机一点声音没有 | 面板按"名字含 cinema_spatial"一把写节点,把中间级一起改了;而音量账只算首尾两级,账面 −8.1 dB 实际 −29.9 dB |
| 3 | 一个量只能有一个写入入口 | 调着调着跳到其他值 | 两个滑块写同一个 `target`,量纲还不同(0~1.5 vs 0~1) |
| 4 | 上混 6 路必须 1/√6 能量归一 | 电平顶满(+7.8 dBFS) | 每路 gain=1 → 净增益 +6 dB,把 DSP 的峰值保护吃掉 |
| 5 | 虚拟声卡 `priority.session ≥ 2000` | 被 HDMI 抢走、点都点不回来 | 虚拟声卡原来**没有**这个属性(None),HDMI 是 1196 |
| 6 | DSP 进程恰好 2 个 | 重建后静音 | `claim_slot()` 抢不到槽时 `return 0`(假装抢到)→ 孤儿挤在槽0;而 DSP 的父进程是 pipewire 主进程,重建时 `pdeathsig` 不触发 → 每次重建攒一对 |
---
## 4. 看门狗(`音频守护.py`)检查与自愈
30 秒一轮,**扫到问题直接修**(不是只报警)。同类问题 90 秒冷却;连续 3 次修不好就只报警。
| 严重度 | 检查 | 判定 | 自愈动作 |
|---|---|---|---|
| 🔴 | DSP 进程数 | ≠ 2 | >2 → SIGKILL 掉多余的(保留最新一对);=0 → 重建链路 |
| 🔴 | 槽位新鲜度 | 任一槽 > 5 秒未更新 | 重建链路 |
| 🔴 | 链输出连线 | `up_out:output_FL/FR` 没连到物理设备 | 先试运行期 `pw-link` 重连,失败则重建 |
| 🔴 | 默认 sink | ≠ `cinema_spatial_up_sink` | `wpctl set-default` |
| 🟡 | 虚拟声卡优先级 | < 2000 | 重建配置(生成器已带 `SINK_PRIO`) |
| 🟡 | 中间级音量 | `up_norm`/`up_raw` ≠ 1.0 | `wpctl set-volume <id> 1.0` |
| 🟡 | 节点份数 | 任一名字 > 1 份 | 重建链路(生成器会 prune 同名 conf) |
| 🟢 | 面板 | 8788 不通 | 后台拉起 `web/启动.sh` |
| 🟢 | 物理设备静音 | 物理 sink `MUTED` | 解除静音 |
---
## 5. 看门狗(`音频守护.py`)
30 秒一轮,按 `config/组件配置单.json` 的期望值逐项核对,**扫到问题直接修**。
```bash
python3 音频守护.py # 常驻 30 秒循环(前台, 实时输出)
python3 音频守护.py --once # 只跑一轮(给 cron 用)
python3 音频守护.py --check # 干跑: 只报不动手
tail -f ~/.local/state/cinema-spatial/guardian.log # 日志
```
日志文件:`~/.local/state/cinema-spatial/guardian.log`(正常时静默,每 10 分钟一条心跳)。
**行为**:能温和就温和(`set-default`/`set-volume`/`pw-link` 重连),链路级问题才重建;
同类问题 **90 秒冷却**;连续 3 次修不好 → 只报警。详见 `组件配置单.md` 第 4 节。
---
## 6. 离线渲染(`离线渲染.py`)
把音频/视频**离线**处理成空间音频 FLAC —— 走的是和实时虚拟声卡**同一条链**,
但用 ffmpeg 滤镜图跑,速度是实时的几十倍,不占用声卡。
```bash
python3 离线渲染.py 片子.mp4 歌.mp3 # 可多个, 串行处理
python3 离线渲染.py --状态 # 是否在跑 / 上次结果
python3 离线渲染.py --混响 0.3 歌.flac # 混响湿量(0 = 关)
python3 离线渲染.py --目标 -16 歌.mp3 # 响度目标 dBFS
python3 离线渲染.py --无HRTF 歌.flac # 只上混 5.1, 不做双耳空间化
python3 离线渲染.py --输出目录 ~/音乐/空间 --覆盖 歌.mp3
```
输出:`<原名>.空间音频.flac`(96 kHz / 立体声 / FLAC)。
**三个入口**
| 入口 | 用法 |
|---|---|
| **命令行** | `python3 离线渲染.py 文件...` |
| **面板** | `http://127.0.0.1:8788/` 最下面「离线渲染」区:把文件**拖进去**(会自动上传 —— 浏览器拿不到本地路径),或直接**贴路径**(一行一个,大文件推荐)→ 开始渲染。有实时进度条、速度、日志,可**取消** |
| **文件管理器右键** | 选中音频/视频 → 右键「打开方式」→ **Collaplex 离线渲染**:自动开终端跑,看得到进度。可一次多选 |
菜单项:`~/.local/share/applications/collaplex-render.desktop` → 调 `离线渲染-打开.sh`。
面板后端接口:`GET /api/render`(状态+进度)、`POST /api/render`(启动)、`POST /api/render/upload`(拖拽上传)、`POST /api/render/cancel`(取消)。
**链路与实时的对应关系**
```
立体声 --pan 上混--> 5.1 --sofalizer(H4 SOFA)--> 双耳立体声 --[afir 混响]--> loudnorm --> flac
```
| 实时链(PipeWire filter-chain) | 离线对应 |
|---|---|
| 上混矩阵(生成配置.py:FC=(FL+FR)×0.707、LFE 同源、BL/BR 反相) | `pan=5.1\|...`(同系数) |
| 12 个 HRTF 卷积 + mixL/mixR(6 路各 1/√6) | `sofalizer`(同一个 SOFA 源,H4 96k) |
| 混响湿路(wetL/wetR + 房间 IR) | `afir` + `reverb/房间混响IR-96k.wav` |
| 响度归一化 DSP | `loudnorm`(I=目标, TP=-1.5) |
已经是 5.1 / 7.1 的片源**不做上混**,直接进 HRTF。
**★★ 两道锁**(老板要求:"有文件还在内存里没下载下来就不要继续扔进去了")
| 锁 | 作用 | 表现 |
|---|---|---|
| **渲染锁**(`/tmp/collaplex-render.lock`,flock) | 一轮任务期间**不接受新任务**,不排队堆积 | 再扔 → `⛔ 已有一个渲染任务在跑(PID x)`, 退出码 **2** |
| **文件就绪检查** | 每个文件进队列前:① 非空 ② **相隔 1.2 秒两次 stat,大小/时间都不变** ③ ffprobe 能读出音轨 | 正在下载/写入的文件 → `⏭ 跳过: 文件还在写入/下载中(大小还在变), 等它传完再扔` |
**实测(2026-09-13)**
| 场景 | 结果 |
|---|---|
| 8 秒立体声音频 | 1.8 s 完成(≈4.4 倍速)→ flac 96k 立体声 |
| 视频(mp4) | 自动取音轨,同样处理,1.8 s |
| 渲染中再扔一个 | 被锁挡下,退出码 2 ✓ |
| 90 MB 且持续增长的文件 | 跳过:"还在写入/下载中" ✓ |
**已知坑**:ffmpeg 的 `out_time_ms` 单位其实是**微秒**(历史遗留命名)—— 解析进度要用
`out_time_us`,否则 8 秒素材会显示成 4779 秒、进度条瞬间冲满。
---
## 7. 常用命令
```bash
# 手动看状态(面板)
http://127.0.0.1:8788/
# 看门狗
python3 音频守护.py # 30 秒循环(前台, 可实时看)
python3 音频守护.py --once # 只跑一轮(给 cron 用)
python3 音频守护.py --check # 只检查不修
tail -f ~/.local/state/cinema-spatial/guardian.log
# 链路
bash 空间音频 开 | 关 | 状态 | 诊断 | 重建
bash 空间音频 数字 | 模拟 # 切输出 route
bash 空间音频 混响 on 0.3 # 混响
# 一分钟自查(静音时第一件事)
ps -eo pid,etimes,cmd | grep -E "[l]oudness_norm" # 必须是 2 行
python3 音频状态.py dup # 应为空(无重复节点)
```
> ⚠️ 杀 DSP 时**不要**用 `pgrep -f loudness_norm.py` —— 它会匹配到你自己那条命令行,
> 把自己的 shell 杀掉(实测 exit −15)。用 `/proc/<pid>/cmdline` 判断,或字符类 `[l]oudness_norm`。
+464
View File
@@ -0,0 +1,464 @@
#!/usr/bin/env python3
"""电影院空间音频 —— 看门狗。
30 秒扫一轮, 逐项核对 config/组件配置单.json 里的期望状态, **发现问题直接修**
(不是只报警)。同类问题有冷却期, 连续修不好就转为只报警, 不再反复折腾。
用法:
python3 音频守护.py # 30 秒循环(前台, 可实时看)
python3 音频守护.py --once # 只跑一轮(给 cron 用; 正常时几乎无输出)
python3 音频守护.py --check # 只检查不修(干跑)
python3 音频守护.py --interval 10
★ 杀进程一律用 /proc/<pid>/cmdline 精确判断, 绝不用 pgrep -f(会匹配到自己那条命令行
把自己杀掉, 2026-09-13 实测 exit -15)。
"""
from __future__ import annotations
import argparse
import json
import os
import struct
import subprocess
import sys
import time
from typing import Any
ROOT = os.path.dirname(os.path.abspath(__file__))
CFG_PATH = os.path.join(ROOT, "config", "组件配置单.json")
CLI = os.path.join(ROOT, "空间音频")
PANEL_SH = os.path.join(ROOT, "web", "启动.sh")
STATE = os.path.expanduser("~/.local/state/cinema-spatial")
LOG_PATH = os.path.join(STATE, "guardian.log")
SHM = "/dev/shm/collaplex-loudness"
# ---------------------------------------------------------------- 基础设施
def sh(*args: str, timeout: int = 25) -> str:
"""跑命令取 stdout; 失败返回空串(看门狗不许因为一条命令失败就崩)。"""
try:
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
return r.stdout or ""
except (subprocess.TimeoutExpired, OSError):
return ""
def log(msg: str, quiet: bool = False) -> None:
"""写日志(带时间戳) + 打到屏幕。"""
line = "%s %s" % (time.strftime("%m-%d %H:%M:%S"), msg)
if not quiet:
print(line, flush=True)
try:
os.makedirs(STATE, exist_ok=True)
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(line + "\n")
except OSError:
pass
def load_cfg() -> dict[str, Any]:
try:
with open(CFG_PATH, encoding="utf-8") as f:
data: dict[str, Any] = json.load(f)
return data
except (OSError, json.JSONDecodeError) as e:
log("! 读不到配置单 %s (%s), 用内置默认值" % (CFG_PATH, e))
return {}
# ---------------------------------------------------------------- 采集
_DUMP_CACHE: dict[str, Any] = {"t": 0.0, "data": []}
def dump_nodes(max_age: float = 2.0) -> list[dict[str, Any]]:
"""pw-dump 结果缓存(默认 2 秒) —— 一轮检查要查十几次节点, 不能每次都跑 pw-dump。"""
now = time.time()
cached = _DUMP_CACHE["data"]
if now - float(_DUMP_CACHE["t"]) < max_age and cached:
return list(cached)
try:
data: list[dict[str, Any]] = json.loads(sh("pw-dump", timeout=25) or "[]")
except json.JSONDecodeError:
data = []
_DUMP_CACHE["t"] = now
_DUMP_CACHE["data"] = data
return list(data)
def node_ids(name: str) -> list[int]:
"""某个 node.name 的全部 id(正常应恰好 1 个)。"""
out: list[int] = []
for o in dump_nodes():
props = (o.get("info") or {}).get("props") or {}
if props.get("node.name") == name:
out.append(int(o["id"]))
return out
def vol_of(nid: int) -> tuple[float, bool]:
"""(音量, 是否静音); 读不到返回 (-1.0, False)。"""
txt = sh("wpctl", "get-volume", str(nid), timeout=8)
if not txt:
return (-1.0, False)
vol = -1.0
for tok in txt.replace(":", " ").split():
try:
vol = float(tok)
break
except ValueError:
continue
return (vol, "MUTED" in txt.upper())
def default_sink() -> str:
txt = sh("wpctl", "inspect", "@DEFAULT_AUDIO_SINK@", timeout=8)
for line in txt.split("\n"):
if "node.name" in line:
return line.split('"')[1] if '"' in line else ""
return ""
def dsp_procs() -> list[int]:
"""DSP 进程 pid 列表 —— 走 /proc 精确匹配, 不用 pgrep -f(会自杀)。"""
out: list[int] = []
try:
entries = os.listdir("/proc")
except OSError:
return out
for entry in entries:
if not entry.isdigit():
continue
try:
with open("/proc/%s/cmdline" % entry, "rb") as f:
cmd = f.read().replace(b"\0", b" ").decode("utf-8", "replace")
except OSError:
continue
if "loudness_norm.py" in cmd:
out.append(int(entry))
return sorted(out)
def slots() -> list[tuple[int, float, float]]:
"""每个槽位 (块数n, 距今秒, 增益dB); 读不到返回空。
★ 时间戳必须用 time.monotonic() 相减 —— DSP 写进共享区 +16 的是 monotonic
(它内部判活也是这么比的)。用 time.time() 会得到十几亿秒的荒谬差值, 守护就会
误判"槽位僵死"然后疯狂重建。2026-09-13 干跑时抓到。
"""
out: list[tuple[int, float, float]] = []
try:
with open(SHM, "rb") as f:
raw = f.read()
except OSError:
return out
for i in range(2):
b = i * 32
try:
gain = struct.unpack_from("<f", raw, b + 12)[0]
ts = struct.unpack_from("<d", raw, b + 16)[0]
n = struct.unpack_from("<I", raw, b + 24)[0]
except struct.error:
return out
out.append((n, time.monotonic() - ts, gain))
return out
def out_links() -> list[str]:
"""cinema_spatial_up_out:output_FL/FR 连到了谁。"""
out: list[str] = []
for block in sh("pw-link", "-l", timeout=10).split("\n\n"):
if "cinema_spatial_up_out:output_FL" in block or "cinema_spatial_up_out:output_FR" in block:
for line in block.split("\n"):
if "|->" in line:
out.append(line.strip().lstrip("|-> ").strip())
return out
def panel_ok() -> bool:
txt = sh("curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"--noproxy", "*", "--max-time", "4", "http://127.0.0.1:8788/", timeout=10)
return txt.strip() == "200"
def phys_name() -> str:
"""物理输出设备全名(按配置单里的前缀匹配, 因为 profile 变了名字会变)。"""
for o in dump_nodes():
props = (o.get("info") or {}).get("props") or {}
name = str(props.get("node.name") or "")
if props.get("media.class") == "Audio/Sink" and "EDIFIER" in name:
return name
return ""
# ---------------------------------------------------------------- 修复动作
def fix_kill_dsp() -> str:
"""SIGKILL 掉全部 DSP 进程(重建会起干净的一对)。"""
killed = 0
for pid in dsp_procs():
try:
os.kill(pid, 9)
killed += 1
except OSError:
pass
time.sleep(1.0)
return "杀掉 %d 个 DSP 进程" % killed
def fix_rebuild() -> str:
"""重建链路(CLI 里已含 kill_old_dsp, 重启 PipeWire 前先收旧 DSP)。"""
if not os.path.exists(CLI):
return "! 找不到 %s" % CLI
out = subprocess.run(["bash", CLI, "重建"], capture_output=True, text=True,
timeout=200, cwd=ROOT).stdout or ""
tail = [ln.strip() for ln in out.split("\n") if ln.strip()][-2:]
return "重建链路 (%s)" % (" / ".join(tail) if tail else "完成")
def fix_set_default(name: str) -> str:
ids = node_ids(name)
if not ids:
return "! 找不到节点 %s, 改为重建" % name
sh("wpctl", "set-default", str(ids[0]), timeout=10)
return "把默认输出切回 %s (id %d)" % (name, ids[0])
def fix_unit_gain(name: str, nid: int) -> str:
sh("wpctl", "set-volume", str(nid), "1.000", timeout=10)
return "把 %s 音量纠回 1.000" % name
def fix_panel() -> str:
if not os.path.exists(PANEL_SH):
return "! 找不到 %s" % PANEL_SH
subprocess.Popen(["bash", PANEL_SH], cwd=ROOT, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
return "拉起面板 (8788)"
def fix_link() -> str:
"""尝试运行期重连(比重建温和)。用"连完再查"判断成败 —— pw-link 成功也不打输出。"""
dev = phys_name()
if not dev:
return "! 找不到物理设备"
for ch in ("FL", "FR"):
sh("pw-link", "cinema_spatial_up_out:output_%s" % ch, "%s:playback_%s" % (dev, ch), timeout=10)
time.sleep(0.8)
links = out_links()
if ("%s:playback_FL" % dev in " ".join(links)) and ("%s:playback_FR" % dev in " ".join(links)):
return "重连成功 → %s" % dev.split(".")[-1]
return "! 重连失败"
# ---------------------------------------------------------------- 检查项
class Problem:
"""一个问题: 类型(用于冷却) + 描述 + 修复动作。"""
def __init__(self, kind: str, level: str, desc: str, fix: Any) -> None:
self.kind = kind # 冷却键
self.level = level # 🔴 / 🟡 / 🟢
self.desc = desc
self.fix = fix # 可调用对象, 返回修复说明
def check_all(cfg: dict[str, Any]) -> list[str]:
"""返回问题列表(每项是可读的中文描述)。"""
probs: list[str] = []
want_procs = int((cfg.get("DSP") or {}).get("进程数", 2))
fresh_s = float((cfg.get("DSP") or {}).get("槽位新鲜秒", 5))
want_sink = str(cfg.get("默认输出", {}).get("必须是", "cinema_spatial_up_sink"))
min_prio = int(((cfg.get("节点") or {}).get("虚拟声卡最低优先级", 2000)))
unit_nodes: list[str] = list((cfg.get("节点") or {}).get("必须单位增益", []))
all_nodes: list[str] = list((cfg.get("节点") or {}).get("全部", []))
v_sinks: list[str] = list((cfg.get("节点") or {}).get("虚拟声卡", []))
# 1) DSP 进程数
procs = dsp_procs()
if len(procs) != want_procs:
probs.append("🔴 DSP 进程数 = %d (应为 %d)%s"
% (len(procs), want_procs, " —— 孤儿抢共享槽位会整链静音" if len(procs) > want_procs else ""))
# 2) 槽位新鲜度
for i, (n, age, _g) in enumerate(slots()):
if age > fresh_s:
probs.append("🔴 DSP 槽%d 已 %.0f 秒没更新(僵死) —— pipe 同步管道会卡死" % (i, age))
# 3) 节点份数
for name in all_nodes:
cnt = len(node_ids(name))
if cnt != 1:
probs.append("🟡 节点 %s 出现 %d 份(应为 1 份) —— 双份会各建一套链" % (name, cnt))
# 4) 中间级必须单位增益
for name in unit_nodes:
for nid in node_ids(name):
v, _m = vol_of(nid)
if v >= 0.0 and abs(v - 1.0) > 0.005:
probs.append("🟡 %s 音量 = %.3f (应为 1.000) —— 中间级被写低会凭空掉十几 dB" % (name, v))
# 5) 默认输出
got = default_sink()
if got and got != want_sink:
probs.append("🔴 默认输出是 %s (应为 %s) —— 声音跑到别的设备去了" % (got, want_sink))
# 6) 虚拟声卡优先级
for name in v_sinks:
for o in dump_nodes():
props = (o.get("info") or {}).get("props") or {}
if props.get("node.name") != name:
continue
prio = props.get("priority.session")
try:
pv = int(prio) if prio is not None else -1
except (TypeError, ValueError):
pv = -1
if pv < min_prio:
probs.append("🟡 %s 优先级 = %s (< %d) —— 默认设备会被 HDMI 抢走且点不回来"
% (name, prio, min_prio))
# 7) 链输出连线
links = out_links()
dev = phys_name()
if dev:
need = ["%s:playback_FL" % dev, "%s:playback_FR" % dev]
miss = [d for d in need if not any(d in l for l in links)]
if miss:
probs.append("🔴 链输出没连到物理设备(缺 %s) —— 看着全对却静音" % ", ".join(miss))
elif links == []:
probs.append("🔴 链输出没有任何连线, 且找不到物理设备")
# 8) 面板
if not panel_ok():
probs.append("🟢 面板 8788 无响应")
return probs
# ---------------------------------------------------------------- 主流程
def run_round(cfg: dict[str, Any], dry: bool, cd: dict[str, float], cooldown_s: float,
fails: dict[str, int]) -> int:
"""跑一轮检查+修复, 返回问题数。"""
probs = check_all(cfg)
if not probs:
return 0
for desc in probs:
key, action = _pick_fix(desc)
last = cd.get(key, 0.0)
if time.time() - last < cooldown_s:
log("%s (冷却中, %.0fs 后再处理)" % (desc, cooldown_s - (time.time() - last)))
continue
if fails.get(key, 0) >= 3:
log("%s ! 已连续 3 次修不好, 暂停自动修复(只报警)" % desc)
continue
if dry:
log("%s [--check 干跑, 不动手] %s" % (desc, action))
continue
log("%s → 修复: %s" % (desc, action))
cd[key] = time.time()
try:
result = _do_fix(key, cfg)
except Exception as e: # 看门狗不许因为一次修复失败就崩
result = "修复异常: %s" % e
log(" %s" % result)
if "!" in result or "失败" in result or "异常" in result:
fails[key] = fails.get(key, 0) + 1
else:
fails[key] = 0
return len(probs)
def _pick_fix(desc: str) -> tuple[str, str]:
"""从问题描述挑出 (冷却键, 将要做什么)。"""
if "DSP 进程数" in desc or "僵死" in desc:
return ("dsp", "杀掉全部 DSP 后重建链路")
if "没连到物理设备" in desc or "没有任何连线" in desc:
return ("link", "运行期 pw-link 重连, 失败则重建")
if "默认输出是" in desc:
return ("default", "把默认输出切回虚拟声卡")
if "优先级" in desc:
return ("prio", "重建配置(生成器带 SINK_PRIO)")
if "份(应为 1 份)" in desc:
return ("dup", "重建链路(生成器会清理同名 conf)")
if "应为 1.000" in desc:
return ("unitgain", "把中间级音量纠回 1.000")
if "面板" in desc:
return ("panel", "拉起面板")
return ("misc", "重建链路")
def _do_fix(key: str, cfg: dict[str, Any]) -> str:
"""执行修复。"""
if key == "dsp":
killed = fix_kill_dsp()
return "%s; %s" % (killed, fix_rebuild())
if key == "link":
r = fix_link()
if "!" in r or "失败" in r:
return "%s; 回落 -> %s" % (r, fix_rebuild())
return r
if key == "default":
want = str(cfg.get("默认输出", {}).get("必须是", "cinema_spatial_up_sink"))
r = fix_set_default(want)
if r.startswith("!"):
return "%s; 回落 -> %s" % (r, fix_rebuild())
return r
if key in ("prio", "dup", "misc"):
return fix_rebuild()
if key == "unitgain":
done: list[str] = []
for name in (cfg.get("节点") or {}).get("必须单位增益", []):
for nid in node_ids(name):
v, _m = vol_of(nid)
if v >= 0.0 and abs(v - 1.0) > 0.005:
done.append(fix_unit_gain(name, nid))
return "; ".join(done) if done else "无需纠正"
if key == "panel":
return fix_panel()
return fix_rebuild()
def main() -> None:
ap = argparse.ArgumentParser(description="电影院空间音频看门狗")
ap.add_argument("--once", action="store_true", help="只跑一轮")
ap.add_argument("--check", action="store_true", help="只检查不修")
ap.add_argument("--interval", type=float, default=0.0, help="轮询间隔秒(默认取配置单)")
args = ap.parse_args()
cfg = load_cfg()
guard = cfg.get("看门狗") or {}
interval = args.interval or float(guard.get("间隔秒", 30))
cooldown_s = float(guard.get("冷却秒", 90))
cd: dict[str, float] = {}
fails: dict[str, int] = {}
log("看门狗启动: 间隔 %.0fs, 冷却 %.0fs, %s"
% (interval, cooldown_s, "干跑模式" if args.check else "自动修复"))
if args.once or args.check:
n = run_round(cfg, args.check, cd, cooldown_s, fails)
log("本轮检查完成: %s" % ("一切正常" if n == 0 else "发现 %d 个问题" % n))
return
silent_ok = 0
while True:
try:
n = run_round(cfg, False, cd, cooldown_s, fails)
if n == 0:
silent_ok += 1
if silent_ok % 20 == 1: # 每 10 分钟留一条心跳, 证明还活着
log("心跳: 链路正常 (DSP %d 进程, 默认 %s)"
% (len(dsp_procs()), default_sink() or "?"))
except KeyboardInterrupt:
log("看门狗退出(手动中断)")
return
except Exception as e: # 兜底: 不许崩
log("! 本轮异常: %s" % e)
time.sleep(interval)
if __name__ == "__main__":
sys.exit(main())
+64 -6
View File
@@ -25,8 +25,13 @@ from pathlib import Path
from typing import Any
DEFAULT_CONF = Path.home() / ".config" / "pipewire" / "pipewire.conf.d" / "90-cinema-spatial.conf"
VOLUME_NODES = ("cinema_spatial_up_sink", "cinema_spatial_up_out",
"cinema_spatial_sink", "cinema_spatial_out")
VOLUME_NODES = ("cinema_spatial_up_sink", "cinema_spatial_up_norm", "cinema_spatial_up_raw",
"cinema_spatial_up_out", "cinema_spatial_sink", "cinema_spatial_out")
# ★ 全链节点的顺序(上混那条), 从应用进到物理设备出 —— 每一级都要进音量账。
CHAIN_UP_NODES = ("cinema_spatial_up_sink", "cinema_spatial_up_norm",
"cinema_spatial_up_raw", "cinema_spatial_up_out")
# 中间两级**不归用户调**, 必须是单位增益; 偏离 1.0 就是 bug(见 state() 里的注释)。
CHAIN_MID_NODES = ("cinema_spatial_up_norm", "cinema_spatial_up_raw")
# ★ 硬件增益基数(volumeBase)必须按**活动 Route** 读, 别读 EnumRoute:
# EnumRoute 是能力清单(不带 volumeBase), Route 才是当前/存档状态(带 volumeBase+softVolumes).
# 实测本机(EDIFIER Fit900NB): 模拟 route volumeBase=1.11428(+0.94 dB),
@@ -184,7 +189,16 @@ def state() -> dict[str, Any]:
card = card_of_sink(objs, target) if target else ""
prof = profile_of_card(objs, card) if card else {}
digital = any(h in target.lower() for h in DIGITAL_HINTS) if target else False
total = vols.get("cinema_spatial_up_sink", 1.0) * vols.get("cinema_spatial_up_out", 1.0)
# ★★ 全链 = 链上**每一级**相乘, 不能只算首尾。
# 2026-09-13 实测血亏: 旧版只乘 up_sink × up_out, 而中间的上混图输入口
# (cinema_spatial_up_raw) 被写成了 0.2068 —— 全链凭空掉 13.7 dB,
# **账面显示 -8.1 dB 一切正常, 实际 -29.9 dB 耳机几乎没声音**。
# 凡是"账对不上听感"的现场, 先怀疑这张账漏了级。
total = 1.0
for n in CHAIN_UP_NODES:
total *= vols.get(n, 1.0)
mid_off = {n: vols[n] for n in CHAIN_MID_NODES
if n in vols and abs(vols[n] - 1.0) > 0.02}
ok, why = links_ok()
return {
"conf_target": target,
@@ -198,6 +212,7 @@ def state() -> dict[str, Any]:
"volumes": vols,
"chain_linear": total,
"chain_db": db(total),
"chain_mid_off": mid_off,
"links_ok": ok,
"links_why": why,
}
@@ -222,18 +237,58 @@ def report() -> str:
if s["target_is_digital"]:
L.append(" · 输出走数字(S/PDIF) route: 实测音质更好(96k/24bit), 其硬件增益基数"
"比模拟低 0.94 dB(无感) —— 要补偿可设 CINEMA_SPATIAL_VOLUME_DIGITAL")
L.append("音量账:")
L.append("音量账(全链每一级):")
for k, label in (("cinema_spatial_up_sink", "虚拟声卡(上混)"),
("cinema_spatial_up_norm", "归一化输出"),
("cinema_spatial_up_raw", "上混图输入"),
("cinema_spatial_up_out", "filter 输出端"),
("cinema_spatial_sink", "虚拟声卡(5.1)"),
("cinema_spatial_out", "filter 输出端(5.1)")):
if k in s["volumes"]:
L.append(f" {label:<16}{k:<24} = {s['volumes'][k]:.3f} ({db(s['volumes'][k]):+.1f} dB)")
L.append(f" 整链(上混) 虚拟×输出端 = {s['chain_linear']:.3f} ({s['chain_db']:+.1f} dB)")
L.append(f" {label:<14}{k:<26} = {s['volumes'][k]:.3f} ({db(s['volumes'][k]):+.1f} dB)")
L.append(f" 整链(上混) 四级相乘 = {s['chain_linear']:.3f} ({s['chain_db']:+.1f} dB)")
if s.get("chain_mid_off"):
L.append(" !! 链中间级被写成非单位增益(不归用户调, 掉多少听感就掉多少):")
for n, v in s["chain_mid_off"].items():
L.append(f" {n} = {v:.3f} ({db(v):+.1f} dB)")
L.append(" 修: 跑 `空间音频 开`(会自动纠正), 或 wpctl set-volume <该节点id> 1.0")
L.append(f"连线 : {'✓ ' + s['links_why'] if s['links_ok'] else '✗ ' + s['links_why']}")
dup = duplicates(pw_dump())
if dup:
L.append(f"!! 同名节点重复 {len(dup)} 个 —— 有两份同名 conf 同时加载:")
L.append(" 音频会进到错的那一份(表现为没声音), 且两份都往同一个物理设备推流")
for n, c in dup:
L.append(f" {n} × {c}")
L.append(" 修: 只留一份 —— 把另一处同名 conf 改名成 .disabled-dup 后重启 pipewire")
return "\n".join(L)
DUP_WATCH: list[str] = [
"cinema_spatial_sink", "cinema_spatial_out",
"cinema_spatial_up_sink", "cinema_spatial_up_norm",
"cinema_spatial_up_raw", "cinema_spatial_up_out",
]
def duplicates(objs: list[dict[str, Any]]) -> list[tuple[str, int]]:
"""同名节点超过 1 份 = 有两份同名 conf 被同时加载(音频进错那一份).
★ PipeWire 对 conf.d/*.conf **两份都加载**, 不做"用户级覆盖系统级" —— 所以
"deb 装到 /usr/share/pipewire/pipewire.conf.d/ 的那份 + 开发目录用户级那份"
会各建一套实例。2026-09-13 实测症状: 6 个节点各 2 份、两条链的输出都推
同一个物理设备、归一化实例的 DSP 进程 4 个抢同一对槽位锁、应用流进了没有
下游消费者的那一份 → 整条链静默(没声音)。
"""
cnt: dict[str, int] = {}
for o in objs:
if o.get("type") != "PipeWire:Interface:Node":
continue
n = str(o.get("info", {}).get("props", {}).get("node.name", ""))
if n in DUP_WATCH:
cnt[n] = cnt.get(n, 0) + 1
return sorted((n, c) for n, c in cnt.items() if c > 1)
def phys_sink(objs: list[dict[str, Any]]) -> str:
"""最合适的物理输出 sink node.name: USB 优先, 排除虚拟声卡与 HDMI/DP 专业输出."""
cand: list[str] = []
@@ -349,6 +404,9 @@ def main() -> int:
print(f"已把 {card} 切到 profile index {idx}")
elif cmd == "route-kind":
print(route_kind(pw_dump()))
elif cmd == "dup":
for n, c in duplicates(pw_dump()):
print(f"{n}\t{c}")
elif cmd == "sinks":
for d in sinks(pw_dump()):
print("\t".join([d["id"], d["name"], d["desc"], d["current"], d["kind"]]))