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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user