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:
@@ -5,7 +5,10 @@ sofa/
|
||||
hrir/
|
||||
|
||||
# 测试/演示产物
|
||||
# ★ 例外: reverb/ 里的房间 IR 是**离线渲染要卷积的素材**, 必须进版本库
|
||||
# (没有它, clone 下来的人跑离线渲染会静默不带混响)。
|
||||
*.wav
|
||||
!reverb/*.wav
|
||||
方向测试/
|
||||
验证_*.log
|
||||
|
||||
|
||||
@@ -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 重写为正式文档,并同步至当前功能状态
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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" % (
|
||||
|
||||
@@ -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 找不到会话总线/显示, 一声不吭什么都不发生。
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Executable
+36
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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 数 / 采样率) --------------------------------
|
||||
|
||||
@@ -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`。
|
||||
@@ -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())
|
||||
@@ -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"]]))
|
||||
|
||||
Reference in New Issue
Block a user