diff --git a/README.md b/README.md index 4bcdd21..d80cdac 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ 把多声道音轨(5.1/7.1)用 HRTF 渲染成双耳,耳机上得到接近电影院 C 位的环绕包围感。 立体声源会先经过矩阵上混(杜比 Pro Logic 原理)再渲染,所以浏览器、音乐播放器同样有空间感。 +> ★ 2026-09-13 修复「响度怪」(先点模拟/数字输出导致响度不同):根因、证据与改动见 +> [`响度怪-根因与修复.md`](响度怪-根因与修复.md)。新增 `空间音频 诊断`,音量改为持久化,重建会保 profile 并校验连线。 + ## 原理 ``` @@ -60,13 +63,272 @@ systemctl --user restart pipewire pipewire-pulse wireplumber 空间音频 开5.1 # 全局切到 5.1 直通版 空间音频 关 # 切回物理设备 空间音频 状态 # 查看两个虚拟声卡和当前默认 +空间音频 诊断 # 输出目标 / profile / route 硬件增益基数 / 音量账 / 连线状态 +空间音频 数字 # 切数字输出(S/PDIF) —— 实测音质更好, 推荐 +空间音频 模拟 # 切模拟输出 mpv-影院 电影.mkv # 只给 mpv 用, 不依赖全局设置(适合 A/B 对比) ``` 也可以直接在桌面环境的"声音设置"里选 **电影院空间音频**。 -## Web 控制台 +> ★ **输出 route 怎么选**:实测耳机的「数字输出(S/PDIF)」音质**明显优于**「模拟输出」 +> (数字那条跑 96 kHz / 24-bit),**推荐数字**。用 `空间音频 数字` 切比在 GNOME 面板点更稳—— +> 面板点会改 profile 从而让配置里的目标节点名失效,这条命令把"重建目标 + 保 profile + 验连线"都包住了。 + +## ★ 反复重建会搞崩 WirePlumber(2026-09-13 血亏) + +症状:面板「处理后电平」恒为 **-120 dBFS**,但 `pw-link` 连线对、设备 1.00 未静音、 +`pw-top` 里设备 WAIT/BUSY 正常 —— **链路看着全对**。 + +真凶两条,都已治: + +1. **`wireplumber` 连续快速重启 PipeWire 后会崩**(GLib 断言 → core-dump)。它一死,monitor 的新连接 + 建不起来 → 任何从 monitor 录电平的实现都读静音。判据 `systemctl --user is-active wireplumber`; + 修 `systemctl --user restart wireplumber`(只重启会话管理器,PipeWire 本体不动,~1s 恢复)。 + **已写进 CLI `regen_chain` 自愈**:重建后查 is-active,不活就拉起。 +2. **录制进程认死旧节点 id**:每次重建节点 id 都会漂(实测物理输出 84 → 166),内层永不退出的 + `pw-record` 会一直从已消失的目标录静音。已改:电平 worker **每 15s 主动重开**录制。 + +**教训**:重建别连发;重建后既要验连线,也要验会话管理器活着。 + +## ★ "重启后目标失效"的根因与修复(2026-09-13) + +现象(老板报):接单声道蓝牙音响后日志出现 +`! 重启后目标失效(配置里是 bluez_output...), 回退到自动探测` → 之后**蓝牙音响一直连不上**。 + +根因链: + +1. 改混响/参数/单声道/设备都要重建滤波链 → 滤波链是**配置里的模块**,**没有运行时热加载** → 必须重启 PipeWire +2. WirePlumber 是 PipeWire 客户端 → 会跟着退出并被 systemd 拉起(**避免不了**) +3. 它一重启,蓝牙/USB 设备要 3~8 秒(蓝牙有时 10+ 秒)才重新出现 +4. 原代码 `sleep 4` 就判 `target_alive` → 设备还没回来 → 判"失效" → **回退把目标换成别的设备** + → 蓝牙音响再也连不上;而且回退还可能落到显卡 `pro-output`(死输出) + +修法:新增 **`wait_target <秒>`(轮询等目标回来,最多 20 秒)**,三处重启后都用它 ✓ +(设备在就立刻返回,放宽无代价);回退探测跳过 `pro-output`/HDMI ✓ +实测:重建 2~7 秒、目标存活 ✓、连线正常 ✓ + +★ **已确认、别再试**:运行时热加载不通 —— `pw-cli load-module libpipewire-module-null-sink '{}'` +返回 `Error: "Could not load module"`(无参/空对象/全路径都试过)→ 重建就得重启 PipeWire,省不掉。 +**下一步可选**(未做):设备切换其实可以**不重建**——用 `pw-link` 运行期把链输出重连到新设备 +(conf 里的 target 只影响下次重启),那样切蓝牙设备就不会掉线。**2026-09-13 已实现**(见下节)。 + + + +## 处理后电平:只读真实信号,绝不经过麦克风(2026-09-13 老板要求) + +老板原话:「处理后电平的话**不要用麦克风捕捉,要原始信号**」。 + +**病根**(实测抓到):电平采集原来用 `pw-record --target <数字 id>` 录**物理设备**的 monitor, +而 `pw-record` 自己找 target 时: + +- 自动连线会**挑错端口**(实测 post 两个输入都从 `output_FR` 来 —— 双声道表其实只拿了右声道) +- target 一旦解析不到 → **静默回退默认录制源 = 麦克风** ✗✗ +- 而且两个 `pw-record` 进程**同名**(`node.name` 都是 `pw-record`,`application.process.id` 还是 `None`) + → 用 `pw-record:input_FL` 这种**名字寻址会打到另一个进程的端口上**(踩过:日志出现 `连 0/2` + 拆掉 2 条 ✗) + +**改法**(三个要点): + +| | | +|---|---| +| `--target 0` | 声明"不链接节点"→ **完全不自动连线**,麦克风回退这条路被物理掐死 | +| `-P '{ node.name = cinema-meter-raw ... }'` | 录制进程起**唯一名字**,不再有同名歧义 | +| `link_meter()` 按**端口 id** 显式连线 | `pw-link <源端口id> <录制端口id>`,源码里只连该表该看的信号 | + +**采集口语义**(一 raw 一 post,天然成对): + +- `raw` ← `cinema_spatial_up_sink:monitor_FL/FR`(被捕捉的原始音频) +- `post` ← `cinema_spatial_up_out:output_FL/FR`(**真正的处理后信号**) + +**验证**(硬证据): + +``` +电平[raw] 2/2 条 ← cinema_spatial_up_sink(只此信号, 无麦克风) +电平[post] 2/2 条 ← cinema_spatial_up_out(只此信号, 无麦克风) +cinema-meter-raw:input_FL ← cinema_spatial_up_sink:monitor_FL +cinema-meter-post:input_FL ← cinema_spatial_up_out:output_FL +麦克风(alsa_input)连线数: 0 +``` + +★ 目标信号不在图里时**宁可不录**(空表),也不让 pw-record 去抓麦克风。 + +## 切换设备不再重启(运行期重连)—— 2026-09-13 + +`空间音频 设备 <关键词>` 与面板设备下拉现在**优先走运行期重连**(CLI 里的 `relink_to`): +链的 playback 节点本质是 PipeWire **stream**,输出端口可以**运行期改连** → 切设备不必重建、 +**不必重启 PipeWire** → 蓝牙设备不会被带掉 ✓ + +- 前提:新设备声道数 == 链当前输出声道数(单声道设备要改图 → 自动回落到重建) +- 顺序:拆旧连线 → 建新连线 → 校验;建不上 → 回落"重建 + wait_target 20" +- 同时把新目标写进 conf(供下次重启使用) +- **实测**:切到临时 2ch null 声卡 **1 秒**完成、日志"运行期重连成功(没重启 PipeWire)"、 + **WirePlumber 启动时刻不变** ✓✓;切回内置模拟 0 秒 ✓ +- 排查提示:`pw-link -l` 的格式是 **来源在上一行、`|-> 去向` 在下一行**(反向连接显示 `|<-`) + +★ 已知遗留:链输出是 stream,**目标设备消失时 WirePlumber 会按系统默认把它挪到别的设备** +(实测一度被挪到显卡 `pro-output-3` 的 AUX0 = 死输出)。重建会按 conf 里的 target 拉回 ✓。 +若想根治,可给 playback 节点加 `node.autoconnect = false`,只由本工具显式连线。 + + + +## 单声道设备(单声道蓝牙音响/耳机)—— 2026-09-13 + +现象(老板实测):接单声道蓝牙音响 → **直接断连 → 回退守卫滚回 → 之后一直连不上**。 + +根因:链的 playback 节点硬编码 `audio.channels = 2 audio.position = [ FL FR ]`,而单声道设备只有 1 个 +`MONO` 端口 → 链接建不起来 → 设备掉线 → 回退。**不是蓝牙的问题,是声道数不匹配。** + +三态开关(默认 自动): + +| 操作 | 效果 | +|---|---| +| `空间音频 单声道` / 面板「单声道」按钮(点一下循环 自动→开→关) | 看/改当前 | +| `空间音频 单声道 开` | 链里加一级 `monoOut` 混音器(L/R 各 −6 dB 相加,避免相关信号 +6 dB 溢出),graph 输出单端口,playback 节点改 `audio.channels = 1 audio.position = [ MONO ]` | +| `空间音频 单声道 自动` | `pw-dump` 读目标设备的 `audio.channels`,是 1 就自动合并 | + +状态文件 `~/.local/state/cinema-spatial/mono.json`(`{}` = 自动)。 +实测:`开` → 两条链的运行节点都是 `audio.channels=1 [ MONO ]` ✓;`自动` → 回到 `2 [ FL FR ]` ✓ + +★ 附带发现(解释"一提一点点就削波"的另一半):**键盘媒体键调的是虚拟声卡音量 = 链的输入增益**(它是应用默认输出), +不是"扬声器电平"。也就是说媒体键拧的就是**削波余量旋钮** —— 面板的「输入增益」= 同一个量, +把它调到 0.24~0.30 就能给最响的瞬态留出余量。 + +## ★★ 削波真凶:混响 IR 只按峰值归一(2026-09-13) + +老板报"扬声器音量开得很低,但一提一点点就直接削波"。**测量**(60 秒采最大峰值): + +| | 最大峰值 | 触顶(≥−0.5dBFS)窗数 | +|---|---|---| +| 原始(虚拟声卡捕捉) | +1.02 dBFS | 4790 | +| **处理后** | **+12.02 dBFS** | 5759 | + +**超满刻度 12dB** —— 这是硬削波。根因在**我自己加的混响**: + +- `镜像法IR.py` 原先只做**峰值归一**(把峰值压到 −6 dBFS),**没做能量归一**。 +- 混响是"密集尾音",卷积的能量增益 ≈ `sqrt(Σ ir²)`:2 秒 96k 的尾音这个值是 **+11~13 dB**。 +- 于是湿路即使只给 0.05(−26 dB),也把整链顶高 **+11 dB** → 削波。 +- 低音量时听不出(增益失真与电平无关),**一拧大就暴露** —— 正是老板描述的症状。 + +**修法**:按**能量归一**(Σir²=1)。之后语义变干净:湿量 G 时湿路电平 = 干路 + 20log₁₀G。 + +**控制变量验证**(放已知电平信号,物理音量压到 0.02 听不见,读完换算回 1.000): + +| | 输入 | 输出 | 链净增益 | +|---|---|---|---| +| 关混响 | −6.64 dBFS | −6.4 dBFS | +0.3 dB | +| 开混响 0.05(新 IR) | −6.64 dBFS | −6.2 dBFS | **+0.4 dB**(旧 IR 是 +11 dB ✗) | +| 0 dBFS 输入(单独测) | −1.04 dBFS | −1.17 dBFS | 余量 **1.17 dB** ✓ | + +**附带**:新增「输入增益」滑块(= 链的输入电平 = 削波余量旋钮,`/api/volume`,**改完立即生效不用重建**)。 +老板的音源本身峰值到 **+1.02 dBFS**(0dBFS 母带 + inter-sample peak),所以最响的瞬态还差 ~1 dB: +把输入增益从 0.30 降到 0.24 就干净(代价 = 整体低 2 dB)。 + +**教训**:任何卷积素材(混响 IR / HRIR / 房间响应)**按峰值归一都是错的** —— 必须按能量归一, +而且必须用**已知电平的信号**做闭环验证(这次就是靠"放 −6dBFS 粉噪比 raw/post"一次定位的)。 + + + +老板报"这个原始音频信号调的是截获的音频信号不是扬声器电平"。原实现写的是**虚拟声卡音量** +(= 链的输入增益 → 它确实在调"被捕捉的信号"→ 报得对)。已改成写**扬声器电平**: + +- 滑块 → `/api/speaker` → **物理输出音量**(你听到的音量);工具提示改成「扬声器电平(物理输出 —— 你听到的音量)」 +- **链的输入增益固定 0.300 不动**:这样「原始电平」表是干净的声源监视(不受音量影响 = 老板要的语义), + 也避免把链内部电平抬高 10.5 dB(12 路卷积叠加有削波风险) +- 实测:设 0.500 → 物理输出 **0.500** ✓,而虚拟声卡捕捉电平 **Δ0.000** ✓ +- ★ **硬件增益基数不能写死表**:实测这台数字路基数 1.003(我旧表写 1.0,差 10%), + 且 Ryzen 卡会报一路 **0.001** 的假基数(照算会补 1114 倍)。→ 改成**闭环自校正**: + 按实测比例(物理÷目标)算,写一次后读真实音量、量残差再修,最多 3 轮。现在"要多少就是多少"。 +- 统一响度开着时写的是**目标**(物理 = 基数 × 目标),否则直接写该 route 的音量 —— 两种模式都不会被下次 apply 抹掉。 + +## 电平表"处理后不滚动"的根因与修复(2026-09-13) + +老板报"处理后电平不滚动"。**数据侧是活的**(API 每 0.5s 都在变、120/120 窗有值、两个 pw-record 都活着), +真凶在**显示**: + +1. 两张表共用固定量程 **0…-60 dBFS**。处理后信号比原始低约 22 dB(虚拟音量 -10.5 + HRTF 约 -16), + 落在 -48 上下 —— 幅度只剩 2~3 dB 抖动,**肉眼看就是一条贴底死线**。 + → 改**每表自动量程**:按**最近 2 秒**的最大峰值向上取整到 6 dB 定顶线(显示 48 dB 动态范围, + 轴标签写真实 dBFS)。★ 注意必须用"最近 2 秒"而不是整 12 秒:历史最响的一瞬 -10 dBFS 会把顶线 + 钉在 -6,安静段照样贴底。 +2. 顺带挖出潜伏 bug:`hold.raw = drawMeter(...)` 把**返回值(数字)**存回 hold, + 下一帧 `peakHold.v` 就是 `undefined` → **峰值保持线一直画不出来**。 + → 改成传持久对象 `hold.raw = hold.raw || {v:-60}`,由 drawMeter 内部更新 `.v`。 + +**验证方式(只看像素,不看 DOM/日志)**:CDP 取 canvas 像素,隔 8 秒再取一次比像素和 + 墨点重心: + +| 表 | 墨点 | 墨点重心 y | 像素和 | +|---|---|---|---| +| 原始 | 45353 → 44985 | 41.4 → 41.4 | 有变化 ✓ | +| 处理后 | 34950 → 36016 | 47.4 → 46.0 | 有变化 ✓ | + +## 采样参数可编辑(2026-09-13) + +- 面板「采样,各参数调整选项」里三个下拉直接改:**采样率 / HRTF 模型 / HRIR 长度**,改完自动重建(约 10s) +- 命令:`空间音频 参数`(看现值 + 可选项)、`空间音频 参数 hrtf H4-48k`、`参数 taps 1024`、`参数 rate 48000` +- 状态文件 `~/.local/state/cinema-spatial/params.json`;`生成配置.py` 按它选 IR 目录 + (优先 `hrir/taps-` > 模型名 > `hrir/current` 软链。模型与截断二选一,设一个会清掉另一个) +- tap 截断:`work/裁HRIR.py ` → `hrir/taps-/`(末尾 3ms 淡出防咔嗒;512/1024/2048 随便裁) +- 采样率:写**用户级** `~/.config/pipewire/pipewire.conf.d/91-clock.conf` + 同名覆盖 deb 装在 `/usr/share/pipewire/pipewire.conf.d/` 的那份(**不用 sudo**) +- ★ 三者都是生成期常量 → **改完必须重建链路**;面板下拉已自动触发重建 +- ★ 实测结论(控制变量):**开着混响时 HRTF tap 数不是主成本** + —— 2 秒的房间 IR 等效每声道 19.2 万 tap,比 HRIR 的 4800 tap 大 40 倍。 + 所以裁 HRIR(1024 tap)省不到多少 CPU,却会砍掉 HRTF 尾部让声音变干 → + **默认保持 H4-96k / 4800 tap**,除非以后 CPU 告急才考虑裁。 +- 顶栏同时重排:标题 + 模式徽标 / 状态摘要(窄屏自动落到第二行整行)/ `开 · 开 5.1 · 关` 分段控件(当前模式高亮)+ 主按钮 + +## 房间混响(真实房间脉冲响应卷积,2026-09-13) + +用**自己的房间模型**算真实 IR,不用廉价算法混响:生成器 `声学追踪引擎/src/镜像法IR.py` +(房间 20×12×7m / 材质逐带吸声 / 空气吸收 / 双耳间距 0.175m 全来自 `config/房间.json`)。 + +- 方法:**镜像法早期反射 + Sabine 尾音混合**。纯镜像法阶数有限→时长有限(阶 18 只到 ~250ms), + 所以在 80ms 交叉点把早期场淡出、按逐带 Sabine RT60 生成的指数衰减噪声尾音(两耳相干 0.4)淡入。 +- 产物:`reverb/房间混响IR-96k.wav`(2.00s / 96kHz / 双声道 / 峰值 -6dBFS / 29.1ms 前直达声已切) +- 落地:`生成配置.py` 的混响级(`revL/revR` 卷积 + `wetL/wetR` 干湿混合,干=1.0、湿=可调); + **关的时候整级不生成**(不白吃 CPU)。湿量是生成期常量 → 改湿量必须重建。 +- 命令:`空间音频 混响 [开|关|湿量]`(不带参数=翻转;约 10s 重建)、`空间音频 重建` +- 面板:混响开关 + 湿量滑块(湿量放在「采样参数」区,保持画稿那行三块结构不变) +- ★ 改湿量后必须重建:`生成配置.py` 只认生成期常量 + +## 输出设备选择(2026-09-13) + +- `音频状态.py sinks` → 全部物理输出(USB → PCI → 显卡 Pro/HDMI,带当前目标标记) +- `空间音频 设备 [关键词]` → 按 node.name / 描述匹配 → 重写 conf 的 `node.target` → 重建 → **校验** → 实装 +- 失败守卫:重启后目标若失效,自动滚回自动探测的设备(实测撞到过:pw-cli 造的 null sink 活不过重启) +- 面板「设备选择」下拉吃同一份数据 + +## 混响级实测(2026-09-13) + +| 频段 | Sabine | 早期(镜像法) | 最终 IR 实测 | +|---|---|---|---| +| 125 Hz | 1.363 s | 1.542 s | 1.338 s | +| 250 Hz | 0.722 s | 0.779 s | 0.749 s | +| 500 Hz | 0.451 s | 0.639 s | 0.524 s | +| 1 kHz | 0.364 s | 0.871 s | 0.460 s | +| 2 kHz | 0.331 s | 1.153 s | 0.427 s | +| 4 kHz | 0.332 s | 0.809 s | 0.362 s | + +- 运行中两个 filter-chain 模块里 `revL/revR/wetL/wetR` 全部已加载(`pw-cli info` 实测)✓ +- 处理后峰值 -22.2 dBFS(**没削波**);设备噪声地板 -56 dBFS +- 测量工具:`work/测链路冲激响应.py`(喂脉冲→录 monitor→算包络/T60;注意 `pw-link -l` 的来源在目标端口下一行、挑设备要读 conf 的 node.target 别按 pw-dump 顺序猜) + +## Collaplex 音效(Web 控制台 / :8788)—— 已按设计稿重做(2026-09-13) + +布局照 `~/桌面/collaplex web设计稿.drawio` 实现:顶栏 / 原始电平+处理后电平 / 三个信号开关(竖排) / +音量滑块 / 数字·模拟两条输出线 / 采样参数 / 响度统一开关+滑块 / 5.1直通·混响·设备选择 / 状态信息。 + +- 后端 `web/webui.py`:`/api/state` `/api/meters` `/api/mode` `/api/volume` `/api/route` `/api/switch` `/api/loudness` `/api/fix` `/api/device` +- **电平表只认数字节点 ID** —— `pw-record --target <数字ID>` 才接 monitor; + 给**名字**不解析会静默回退麦克风,`<名>.monitor` 本机根本不存在(详见技能 linux-audio-routing) +- 已接线并实测:开/关/5.1、数字·模拟 route、原始音量、三个信号开关(虚拟声卡那路是**真断开连线**)、 + 响度统一(按硬件增益基数补 +0.94 dB)、按当前参数重建 +- **待接线**:混响开关(需定方案:房间 IR 卷积 / 轻量算法混响)、设备选择(要改 conf 的 `node.target` 再重建)、 + 采样参数编辑(要重新生成配置) +- 改设计稿之前的旧面板留档:`web/index.html.设计稿前备份` + +### 旧说明(保留) 浏览器里看状态、切模式、调音量、**一键修复"失声"**(设备名失效时自动重建)。 @@ -75,7 +337,7 @@ bash web/启动.sh # 启动(默认端口 8788,重复执行不会 python3 web/webui.py --port 8788 --bind 127.0.0.1 # 也可以直接跑 ``` -打开 。菜单里也有「空间音频控制台」快捷方式。 +打开 。菜单里也有「Collaplex 音效」快捷方式。 界面会实时显示:默认输出、链路采样率、HRTF 模型、**配置输出设备 vs 当前物理输出(不一致会标红)**、 输出端实际连到哪个设备、以及两个虚拟声卡和设备的音量。检测到设备名失效时直接点修复即可。 @@ -91,7 +353,7 @@ python3 web/webui.py --port 8788 --bind 127.0.0.1 # 也可以直接跑 | `提取HRIR.sh` | 早期版本(已被 `换HRTF.sh` 取代)| | `打包deb.sh` | 打包 `collaplex-cinema-spatial` | | `验收测试.sh` | 安装后自动验证 | -| `web/` | Web 控制台(`webui.py` 后端 + `index.html` 前端 + `启动.sh`),零依赖 | +| `web/` | Collaplex 音效 Web 控制台(`webui.py` 后端 + `index.html` 前端 + `启动.sh`),零依赖 | | `demo.sh` / `响度校准.sh` / `方向测试.sh` | 开发期验证工具 | | `对比分析.py` / `排查.sh` | 开发期排查工具 | diff --git a/mpv-影院 b/mpv-影院 new file mode 100755 index 0000000..813b0a2 --- /dev/null +++ b/mpv-影院 @@ -0,0 +1,25 @@ +#!/bin/sh +# 电影院空间音频: 把多声道音轨(5.1/7.1)用 HRTF 渲染成双耳, 耳机上得到环绕包围感。 +# +# 原理: 影片的 5.1/7.1 音轨 -> 每个声道按它所在方向, 与人头模型的脉冲响应卷积 +# -> 混成两耳。声音就会"从左边/后面/远处来", 不再挤在头中间。 +# +# 用法: mpv-影院 电影.mkv +# mpv-影院 --no-video 电影.mkv (只听声) +# +# 注意: 只适合多声道片源(5.1/7.1)。立体声片源别用 —— HRTF 会把左右两声各自 +# 推远, 听起来更空但不会变环绕, 反而变差。 +# +# 参数说明: +# radius=1.2 声源距离(米), 1.2 更像影厅 +# gain=-16 整链增益校准值。★别随手改大★ +# 上混及多路叠加会让整链净增益约 +10.5dB, 必须拿"接近满刻度"的 +# 素材实测才能定; 给大了直接削波爆音(实测教训)。 +# type=time 时域卷积(保真)。默认的 freq 走 FFT, 会削高频。 +# ★ 采样率: sofalizer 按 SOFA 文件自身的采样率输出, 不在链尾拉回来整个音频链 +# 会掉到 44.1k。所以要 aresample 回 96k 并让 ao 也请求 96k。 +SPATIAL_ROOT="${CINEMA_SPATIAL_ROOT:-/usr/share/cinema-spatial}" +exec mpv \ + --audio-samplerate=96000 \ + --af="sofalizer=sofa=${SPATIAL_ROOT}/sofa/H4_96K_24bit_512tap_FIR_SOFA.sofa:type=time:radius=1.2:gain=-16,aresample=96000" \ + "$@" diff --git a/web/index.html b/web/index.html index 8cf9987..90036fa 100644 --- a/web/index.html +++ b/web/index.html @@ -3,225 +3,676 @@ -电影院空间音频 +Collaplex 音效 + -

电影院空间音频

-
PipeWire 虚拟声卡 · SADIE-II 真人 HRTF
+
-

状态

+
+
+

Collaplex 音效

+ 读取中… +
+ — +
+
+ + + +
+ +
+
-

模式

-
- - - - + +
+
+
+
原始电平(虚拟声卡捕捉)—
+ +
+ 输入增益 + + 0.30 + 调小 = 给链留削波余量(立即生效) +
+
+
+
处理后电平—
+ +
+
+ +
+ 原始音频信号开关 +
+ +
+
—
+
+ +
+
音量
+
+ +
+ 虚拟声卡信号开关 +
+ +
+
+
—
+
+
+
—
+
+
+ +
+
数字输出
+
模拟输出
+
+ +
+ 输出设备信号开关 +
+
+ +
+ + +
+
+

采样,各参数调整选项

+
+ + + + IR 文件— + 链路— +
+
+ + 改上面三个下拉会按新参数重新生成并重建(约 10 秒) +
+
+ 混响湿量 + + 0.25 + 房间脉冲响应 = 你的 20×12×7m 影院(镜像法算的真实 IR) +
+
-
-

虚拟声卡音量

-
- - - + +
+
+ 响度统一开关 +
+
+ 统一响度音量 +
+
+
+ — + — +
-
降音量是拿响度换安全;削波爆音就往下拉, - 想更响请改 IR 增益(见 README)。
-
-

电平(物理输出)

- -
- 峰值 - - RMS - - 余量 - + +
+
+
+
+
+ +
-
-
+ +
+

状态信息

+
+
—
+
+ + +
+

核心技术 · Collaplex 空间声学引擎

+

+ 不是"加一层混响"的近似, 而是先解声传播、再卷积: 用镜像法算出真实房间脉冲响应, 在 96 kHz 上做双耳卷积, 再经真人 HRTF 定位。 +

+
+
① 镜像法房间声学追踪0–80 ms 早期反射 · 8473 条镜面路径
+
逐条计算时延 · 1/距离球面扩散 · 逐带吸声 · 空气吸收 · 左右耳耳间时差
+
② Sabine 统计尾音80 ms–2 s · 两耳部分相干 0.4 · 80 ms 交叉点能量对齐
+
③ 真实房间 IR 卷积96 kHz / 32 bit 双耳 IR · 按能量归一 (Σir²=1)
+
④ HRTF 双耳化SADIE-II H4 真人测量 · 2818 方向 · 仰角 ±90° · 512 tap @ 96 kHz
+
房间模型20×12×7 m 影院 (V=1680 m³ / S=928 m²) · 穿孔银幕 α0.45 · 墙板 0.80 · 吊顶 0.85 · 地毯 0.45
+
听音位中轴距银幕 8 m · 耳高 1.2 m · 双耳间距 0.175 m · 声速 343 m/s
+
不是Freeverb / Schroeder 反馈网络类算法混响 —— 无反馈网络, 是房间物理模型
+
+

+ PipeWire filter-chain 虚拟声卡 · 全链 96 kHz/24 bit · 湿量按能量归一(湿量 G 时湿路电平 = 干路 + 20log₁₀G) · 房间 IR 由 声学追踪引擎 离线解得后卷积。 +

+
+ +
diff --git a/web/index.html.设计稿前备份 b/web/index.html.设计稿前备份 new file mode 100644 index 0000000..2987ee3 --- /dev/null +++ b/web/index.html.设计稿前备份 @@ -0,0 +1,227 @@ + + + + + +Collaplex 音效 + + + +

Collaplex 音效

+
PipeWire 虚拟声卡 · SADIE-II 真人 HRTF
+ +

状态

+ +

模式

+
+ + + + +
+
+ +

虚拟声卡音量

+
+ + - +
+
降音量是拿响度换安全;削波爆音就往下拉, + 想更响请改 IR 增益(见 README)。
+
+ +

电平(物理输出)

+ +
+ 峰值 - + RMS - + 余量 - +
+
+ +
+ + + + diff --git a/web/webui.py b/web/webui.py index f4d1214..9536385 100644 --- a/web/webui.py +++ b/web/webui.py @@ -1,5 +1,8 @@ #!/usr/bin/env python3 -# 电影院空间音频 Web 控制台 (2026-09-13) -- 零依赖, 仅 stdlib +# Collaplex 音效 Web 控制台 (2026-09-13) -- 零依赖, 仅 stdlib +# 布局按老板设计稿 collaplex web设计稿.drawio 实现: +# 顶栏 / 原始电平+处理后电平 / 三个信号开关(竖排) / 音量滑块 / 数字·模拟两条输出 / +# 采样参数 / 响度统一开关+滑块 / 5.1直通·混响·设备选择 / 状态信息 # 用法: python3 webui.py [--port 8788] [--bind 127.0.0.1] from __future__ import annotations @@ -23,67 +26,35 @@ ROOT = os.path.dirname(HERE) CONF = os.path.expanduser("~/.config/pipewire/pipewire.conf.d/90-cinema-spatial.conf") SINK_51 = "cinema_spatial_sink" SINK_UP = "cinema_spatial_up_sink" +CHAIN_OUT = "cinema_spatial_up_out" +CLI = os.path.join(ROOT, "空间音频") +HELPER = os.path.join(ROOT, "音频状态.py") +STATE_DIR = os.path.expanduser("~/.local/state/cinema-spatial") +VOL_FILE = os.path.join(STATE_DIR, "volume") +LOUD_FILE = os.path.join(STATE_DIR, "loudness.json") -# 电平表: 从物理输出的 monitor 取样(耳机真正在放的信号), 每 100ms 一个点。 -# 指标是 dBFS —— 0dBFS 为数字满刻度, 余量 = -peak(dB)。 -# 注意: 96k 重采样/多路叠加会产生 intersample peak, 峰值超过 0 也算削波。 -LEVEL: deque[dict] = deque(maxlen=240) -LEVEL_LOCK = threading.Lock() +# ★ 电平采集(2026-09-13 实测结论): +# pw-record --target <**数字节点 ID**> 才会接声卡的 monitor 端口; +# 给节点**名字**不解析 → 静默回退默认录制源(麦克风); `<名>.monitor` 本机根本不存在。 +# 实测: cinema_spatial_up_sink:monitor_FR |-> pw-record:input_FR, -6dBFS 测试音录到 RMS=0.385 CHUNK_MS = 100 +RATE = 48000 +KEEP = 120 # 每个电平表保留点数(=12 秒) +METERS: dict[str, deque[dict[str, float]]] = { + "raw": deque(maxlen=KEEP), # 原始电平(虚拟声卡捕捉) + "post": deque(maxlen=KEEP), # 处理后电平(物理输出) +} +METER_LOCK = threading.Lock() +LOG: deque[str] = deque(maxlen=200) # 动作日志(状态信息区) + +# 两条 route 的硬件增益基数(9-13 实测, 数字 1.0 / 模拟 1.11428 → 差 +0.94dB) +HW_BASE = {"digital": 1.0, "analog": 1.11428} -def monitor_name() -> str: - """当前物理输出设备的 monitor 源(录到的就是耳机在放的东西)。""" - p = phys_sink() - return p + ".monitor" if p else "" - - -def _level_worker() -> None: - """持续录物理输出, 按 CHUNK_MS 切块算 RMS/Peak(dBFS)。""" - rate = 48000 - per = rate * CHUNK_MS // 1000 * 4 # f32 每样本 4 字节 - while True: - mon = monitor_name() - if not mon: - time.sleep(2) - continue - proc = None - try: - proc = subprocess.Popen( - ["pw-record", "--target", mon, "--rate", str(rate), - "--channels", "1", "--format", "f32", "-"], - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=_env()) - while True: - raw = proc.stdout.read(per) if proc.stdout else b"" - if not raw or len(raw) < per: - break - a = array.array("f") - a.frombytes(raw) - pk = max(abs(v) for v in a) - rms = math.sqrt(sum(v * v for v in a) / len(a)) - with LEVEL_LOCK: - LEVEL.append({ - "peak": round(20 * math.log10(pk), 2) if pk > 1e-9 else -120.0, - "rms": round(20 * math.log10(rms), 2) if rms > 1e-9 else -120.0, - }) - except Exception: - pass - finally: - if proc is not None: - try: - proc.kill() - except Exception: - pass - time.sleep(0.5) - - -def start_level_worker() -> None: - threading.Thread(target=_level_worker, daemon=True).start() - - -def level_points() -> list[dict]: - with LEVEL_LOCK: - return list(LEVEL) +def hw_base(kind: str) -> float: + """route 硬件增益基数; 卡报回来的异常值(实测某卡报 0.001)当 1.0, 免得补偿算出上千倍.""" + b = HW_BASE.get(kind, 1.0) + return b if b > 0.05 else 1.0 def _env() -> dict[str, str]: @@ -92,7 +63,12 @@ def _env() -> dict[str, str]: return e -def sh(*args: str, timeout: int = 10) -> str: +def log(msg: str) -> None: + with METER_LOCK: + LOG.append("%s %s" % (time.strftime("%H:%M:%S"), msg)) + + +def sh(*args: str, timeout: int = 15) -> str: try: p = subprocess.run(list(args), capture_output=True, text=True, timeout=timeout, env=_env()) return (p.stdout or "") + (p.stderr or "") @@ -107,24 +83,38 @@ def dump() -> list[dict]: return [] -def nodes(kind: str) -> list[dict]: - out: list[dict] = [] +def node_list(kind: str) -> list[dict[str, object]]: + out: list[dict[str, object]] = [] for o in dump(): if o.get("type") != "PipeWire:Interface:Node": continue p = (o.get("info") or {}).get("props") or {} if p.get("media.class") == kind: - out.append({ - "id": o.get("id"), - "name": p.get("node.name", ""), - "desc": p.get("node.description", ""), - }) + out.append({"id": o.get("id"), "name": p.get("node.name", ""), + "desc": p.get("node.description", "")}) return out -def vol(nid: object) -> float: - m = re.search(r"Volume:\s*([0-9.]+)", sh("wpctl", "get-volume", str(nid))) - return float(m.group(1)) if m else -1.0 +def node_id(name: str) -> int: + """按 node.name 精确取数字 ID (pw-record --target 只认 ID).""" + for o in dump(): + p = (o.get("info") or {}).get("props") or {} + if p.get("node.name") == name: + return int(o.get("id") or -1) + return -1 + + +def vol_of(nid: int) -> tuple[float, bool]: + """(音量, 是否静音).""" + t = sh("wpctl", "get-volume", str(nid)) + m = re.search(r"Volume:\s*([0-9.]+)", t) + return (float(m.group(1)) if m else -1.0), ("MUTED" in t) + + +def virtual_sink() -> str: + """当前生效的虚拟声卡(默认输出是谁就是谁; 都不是则算上混版).""" + ds = default_sink_name() + return ds if "cinema_spatial" in ds else SINK_UP def default_sink_name() -> str: @@ -132,22 +122,24 @@ def default_sink_name() -> str: return m.group(1) if m else "" -def conf_target() -> str: +def phys_sink() -> str: + """物理输出设备名(排除虚拟声卡与显卡 pro-output).""" + c = [str(s["name"]) for s in node_list("Audio/Sink") + if s["name"] and "cinema_spatial" not in str(s["name"]) and "pro-output" not in str(s["name"])] + return (sorted(c, key=lambda x: (0 if "usb" in x else 1, x)) or [""])[0] + + +def chain_target() -> str: + """滤波链的目标设备 = conf 里写的 node.target.""" try: with open(CONF, encoding="utf-8") as f: - m = re.search(r'node\.target = "([^"]+)"', f.read()) + m = re.search(r'node\.target\s*=\s*"([^"]+)"', f.read()) return m.group(1) if m else "" except Exception: return "" -def phys_sink() -> str: - c = [s["name"] for s in nodes("Audio/Sink") - if s["name"] and "cinema_spatial" not in s["name"] and "pro-output" not in s["name"]] - return (sorted(c, key=lambda x: (0 if "usb" in x else 1, x)) or [""])[0] - - -def route_of(out_name: str) -> list[str]: +def links_of(out_name: str) -> list[str]: res: list[str] = [] cap = False for line in sh("pw-link", "-l").splitlines(): @@ -163,6 +155,12 @@ def route_of(out_name: str) -> list[str]: return res +def route_kind() -> str: + """当前物理设备走在数字(iec958/S/PDIF)还是模拟 route 上.""" + p = phys_sink() + return "digital" if "iec958" in p or "spdif" in p.lower() else "analog" + + def hrtf_name() -> str: link = os.path.join(ROOT, "hrir", "current") return os.path.basename(os.path.realpath(link)) if os.path.islink(link) else "-" @@ -173,73 +171,401 @@ def clock_rate() -> str: return m.group(1) if m else "?" -def status() -> dict: +def conf_params() -> dict[str, str]: + """采样参数: tap 数/位深从 HRIR 的 WAV 头读(配置里没有 length, 那是希尔伯特段的). + + 注意: 配置里 `length = 33` 是 convRL/convRR(希尔伯特)的参数, 不是 HRIR 长度 —— 别抓错. + """ + out: dict[str, str] = {"taps": "-", "hrtf_dir": hrtf_name(), "ir": "-", "ir_rate": "-", + "ir_bits": "-"} + ir = os.path.join(ROOT, "hrir", "current", "FL_L.wav") + try: + out["ir"] = os.path.basename(ir) + except Exception: + pass + try: + with open(ir, "rb") as f: + if f.read(4) != b"RIFF" or f.read(4) == b"" or f.read(4) != b"WAVE": + return out + while True: + hdr = f.read(8) + if len(hdr) < 8: + break + cid, csz = hdr[:4], int.from_bytes(hdr[4:8], "little") + if cid == b"fmt ": + fmt = f.read(csz) + nch = int.from_bytes(fmt[2:4], "little") + out["ir_rate"] = str(int.from_bytes(fmt[4:8], "little")) + out["ir_bits"] = str(int.from_bytes(fmt[14:16], "little")) + out["_nch"] = str(nch or 1) + elif cid == b"data": + nch = int(out.get("_nch", "1") or 1) + per = max(1, nch * (int(out["ir_bits"]) // 8 if out["ir_bits"].isdigit() else 2)) + out["taps"] = str(csz // per) + break + else: + f.seek(csz + (csz & 1), os.SEEK_CUR) + except Exception: + pass + out.pop("_nch", None) + return out + + +# ---------------- 电平采集 ---------------- + +def _db(x: float) -> float: + return round(20 * math.log10(x), 2) if x > 1e-9 else -120.0 + + +def _meter_src(key: str) -> tuple[str, list[str]]: + """该表该看的信号源. + + raw = 链的输入(虚拟声卡 monitor) —— 被捕捉的原始音频 + post = 链的输出(output_FL/FR) —— 真正的处理后信号 + + ★ 2026-09-13 老板要求: 处理后电平"不要用麦克风捕捉, 要原始信号"。 + pw-record --target 的自动连线会挑错端口(实测 post 两个输入都从 output_FR 来), + 目标一旦解析不到还会静默回退默认录制源 = 麦克风 → 所以 link_meter() 连完必须核。 + """ + if key == "raw": + return virtual_sink(), ["monitor_FL", "monitor_FR"] + return (CHAIN_OUT if node_id(CHAIN_OUT) > 0 else "cinema_spatial_out"), ["output_FL", "output_FR"] + + +METER_NAME = {"raw": "cinema-meter-raw", "post": "cinema-meter-post"} + + +def _meter_ports(nid: int, prefix: str) -> dict[str, str]: + """某节点上以 prefix 开头的端口: {通道后缀: 端口 id}.""" + res: dict[str, str] = {} + for o in dump(): + if o.get("type") != "PipeWire:Interface:Port": + continue + pr = ((o.get("info") or {}).get("props") or {}) + if int(pr.get("node.id") or -1) != nid: + continue + pn = str(pr.get("port.name") or "") + if pn.startswith(prefix): + res[pn[len(prefix):]] = str(o.get("id") or "") + return res + + +def link_meter(key: str) -> str: + """把电平录制进程**显式**连到该看的信号上(端口 id 寻址, 绝不经过麦克风). + + ★ 2026-09-13 老板要求"处理后电平不要用麦克风捕捉, 要原始信号"。 + 做法: 录制进程用 --target 0(不自动连线) + 唯一 node.name 启动, 再按端口 **id** + 显式连到目标信号。名字寻址不行 —— 两个 pw-record 进程同名(node.name 都是 + pw-record, application.process.id 还是 None), "pw-record:input_FL" 会打到 + 另一个进程的端口上(实测踩过); 而 pw-record 自己找 target 失败时会静默回退 + 默认录制源 = 麦克风。 + """ + src_name, _chans = _meter_src(key) + sid = rid = -1 + for _ in range(15): + sid = node_id(src_name) + rid = node_id(METER_NAME[key]) + if rid > 0 and sid > 0: + break + time.sleep(0.2) + if rid < 0: + return "电平[%s] 录制节点没进图" % key + if sid < 0: + return "电平[%s] 目标信号 %s 不在图里(不连, 宁可空表)" % (key, src_name) + sport = _meter_ports(sid, "monitor_") or _meter_ports(sid, "output_") + rport = _meter_ports(rid, "input_") + ok = 0 + for ch, rp in rport.items(): + sp = sport.get(ch) + if sp and "fail" not in sh("pw-link", sp, rp).lower(): + ok += 1 + msg = "电平[%s] %d/%d 条 ← %s(只此信号, 无麦克风)" % (key, ok, len(rport), src_name) + print(msg, flush=True) + return msg + + +def _meter_worker(key: str) -> None: + need = RATE * CHUNK_MS // 1000 * 2 * 4 # 立体声 f32 + while True: + # 目标信号不在图里就不录(宁可空表, 也绝不让 pw-record 回退到麦克风) + if node_id(_meter_src(key)[0]) < 0: + time.sleep(2) + continue + proc = None + t0 = time.monotonic() + try: + # ★ --target 0 = 不自动连线; -P 起唯一名 → 之后由 link_meter() 按端口 id 显式连 + proc = subprocess.Popen( + ["pw-record", "--target", "0", + "-P", "{ node.name = %s node.autoconnect = false }" % METER_NAME[key], + "--rate", str(RATE), "--channels", "2", "--format", "f32", "-"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=_env()) + try: + link_meter(key) + except Exception as exc: # 采集不能因为连线校验挂了而停 + print("电平[%s] 连线异常: %r" % (key, exc), flush=True) + while True: + # ★ 每 15s 主动重开一次录制: 重建链路(改混响/参数/设备)后节点 id 会变, + # 老进程会一直从已消失的目标上录静音 —— 实测电平表会卡在 -120 dBFS + if time.monotonic() - t0 > 15.0: + break + fh = proc.stdout + raw = fh.read(need) if fh else b"" + if not raw or len(raw) < need: + break + a = array.array("f") + a.frombytes(raw) + lf = a[0::2] + rt = a[1::2] + pk = max(max(abs(v) for v in lf), max(abs(v) for v in rt)) + rl = math.sqrt(sum(v * v for v in lf) / len(lf)) + rr = math.sqrt(sum(v * v for v in rt) / len(rt)) + with METER_LOCK: + METERS[key].append({"peak": _db(pk), "rms": _db(max(rl, rr))}) + except Exception: + pass + finally: + if proc is not None: + try: + proc.kill() + except Exception: + pass + time.sleep(0.4) + + +def start_meters() -> None: + for key in ("raw", "post"): + threading.Thread(target=_meter_worker, args=(key,), daemon=True).start() + + +def meter_points(key: str) -> list[dict[str, float]]: + with METER_LOCK: + return list(METERS[key]) + + +# ---------------- 信号开关 ---------------- + +def toggle_state() -> dict[str, bool]: + """三个信号开关的真实状态.""" + v = virtual_sink() + vid = node_id(v) + _, vmut = vol_of(vid) if vid > 0 else (0.0, False) + pid = node_id(chain_target() or phys_sink()) + _, pmut = vol_of(pid) if pid > 0 else (0.0, False) + tgt = chain_target() or phys_sink() + linked = bool(links_of(CHAIN_OUT)) and any(tgt in x for x in links_of(CHAIN_OUT)) + return {"raw": not vmut, "virtual": linked, "output": not pmut} + + +def toggle(which: str, on: bool) -> str: + if which == "raw": # 原始音频信号: 虚拟声卡的静音 + nid = node_id(virtual_sink()) + if nid < 0: + return "找不到虚拟声卡" + sh("wpctl", "set-mute", str(nid), "0" if on else "1") + return "原始音频信号: %s" % ("通" if on else "断") + if which == "output": # 输出设备信号: 物理输出静音 + nid = node_id(chain_target() or phys_sink()) + if nid < 0: + return "找不到物理输出" + sh("wpctl", "set-mute", str(nid), "0" if on else "1") + return "输出设备信号: %s" % ("通" if on else "断") + if which == "virtual": # 虚拟声卡信号: 真断开/接回连线 + tgt = chain_target() or phys_sink() + if not tgt: + return "找不到目标设备" + outs = [x for x in sh("pw-link", "-o").split() if x.startswith(CHAIN_OUT + ":output_")] + ins = [x for x in sh("pw-link", "-i").split() if x.startswith(tgt + ":playback_")] + acts = [] + for i, o in enumerate(outs): + if i < len(ins): + acts.append(((o, ins[i]), "接通")) + if not outs or not ins: + return "拿不到链输出/设备端口" + for (o, i), _ in acts: + if on: + sh("pw-link", o, i) + else: + sh("pw-link", "-d", o, i) + return "虚拟声卡信号: %s(%d 路)" % ("通" if on else "断", len(acts)) + return "未知开关" + + +# ---------------- 响度统一 ---------------- + +def load_loud() -> dict[str, object]: + d: dict[str, object] = {"unified": False, "target": 0.33, "digital": 1.0, "analog": 1.0} + try: + with open(LOUD_FILE, encoding="utf-8") as f: + d.update(json.load(f)) + except Exception: + pass + return d + + +def _num(v: object, dflt: float) -> float: + """从 JSON 取数(可能是 str/int/float), 兜底为 float.""" + if isinstance(v, bool): + return dflt + if isinstance(v, (int, float)): + return float(v) + try: + return float(str(v)) + except ValueError: + return dflt + + +def save_loud(d: dict[str, object]) -> None: + try: + os.makedirs(STATE_DIR, exist_ok=True) + with open(LOUD_FILE, "w", encoding="utf-8") as f: + json.dump(d, f, ensure_ascii=False, indent=1) + except OSError: + pass + + +def params_state() -> dict[str, object]: + """采样参数(HRTF 模型 / tap / 采样率) —— 由 CLI「参数」写.""" + p = os.path.expanduser("~/.local/state/cinema-spatial/params.json") + try: + with open(p, encoding="utf-8") as fh: + d = json.load(fh) + except (OSError, ValueError): + d = {} + return d if isinstance(d, dict) else {} + + +def param_options() -> dict[str, list[str]]: + """可选项: hrir 下能用的模型 / 已裁的 tap 目录 / 允许的采样率.""" + hrir = os.path.join(ROOT, "hrir") + models: list[str] = [] + taps: list[str] = [] + if os.path.isdir(hrir): + for n in sorted(os.listdir(hrir)): + if n.startswith("taps-"): + taps.append(n[5:]) + elif n not in ("current", "gain") and os.path.isfile(os.path.join(hrir, n, "FL_L.wav")): + models.append(n) + taps.sort(key=int, reverse=True) + return {"hrtf": models, "taps": taps, "rate": ["96000", "48000", "44100"]} + + +def mono_state() -> dict[str, object]: + """单声道设置(自动/开/关) + 链输出节点实际声道数(1 = 合并生效).""" + p = os.path.expanduser("~/.local/state/cinema-spatial/mono.json") + s = "自动" + try: + with open(p, encoding="utf-8") as fh: + d = json.load(fh) + if isinstance(d, dict) and isinstance(d.get("on"), bool): + s = "开" if d["on"] else "关" + except (OSError, ValueError): + pass + ch = 2 + try: + raw = subprocess.run(["pw-dump"], capture_output=True, text=True, timeout=8).stdout + for o in json.loads(raw): + pr = ((o.get("info") or {}).get("props") or {}) + if pr.get("node.name") == "cinema_spatial_up_out": + ch = int(pr.get("audio.channels") or 2) + except (OSError, ValueError, subprocess.SubprocessError): + pass + return {"set": s, "channels": ch} + + +def reverb_state() -> dict[str, object]: + """混响运行时状态(on/wet) —— 由 CLI「混响」写, 生成配置.py 读.""" + p = os.path.expanduser("~/.local/state/cinema-spatial/reverb.json") + try: + with open(p, encoding="utf-8") as fh: + d = json.load(fh) + except (OSError, ValueError): + return {"on": False, "wet": 0.25} + return {"on": bool(d.get("on")), "wet": float(d.get("wet") or 0.0)} + + +def apply_loudness() -> str: + """响度统一: 把两条 route 的硬件增益差补平(+0.94dB), 让数字/模拟切换时响度一致. + + 统一开 -> 总音量接管两条路, 并按 route 硬件基数补偿(数字路 +0.94dB); + 统一关 -> 数字/模拟各走各的滑块(不补偿), 设计稿里那两条线上的滑块这时才生效. + """ + d = load_loud() + kind = route_kind() + comp = hw_base("analog") / hw_base(kind) # 数字路补 +0.94dB 追上模拟路 + if d.get("unified"): + val = min(_num(d.get("target"), 0.33) * comp, 1.5) + note = "统一响度: %.3f(基数补偿 %+0.2fdB)" % (val, 20 * math.log10(comp)) + else: + val = min(_num(d.get(kind), 1.0), 1.5) + note = "输出音量(%s路): %.3f" % ("数字" if kind == "digital" else "模拟", val) + nid = node_id(chain_target() or phys_sink()) + if nid < 0: + return "找不到物理输出" + sh("wpctl", "set-volume", str(nid), "%.3f" % val) + return note + + +# ---------------- CLI 委托 ---------------- + +def cli(arg: str) -> str: + if not os.path.exists(CLI): + return "找不到 空间音频 脚本" + try: + out = subprocess.run(["bash", CLI, arg], capture_output=True, text=True, timeout=180, env=_env()) + except subprocess.TimeoutExpired: + return "执行超时" + return (out.stdout or out.stderr).strip() or "(无输出)" + + +# ---------------- 状态 ---------------- + +def helper_sinks() -> list[dict[str, str]]: + """可用物理输出设备(委托 音频状态.py sinks: 一处定义, CLI 与面板同一份).""" + out: list[dict[str, str]] = [] + for ln in sh("python3", HELPER, "sinks").splitlines(): + p = ln.split("\t") + if len(p) >= 5: + out.append({"id": p[0], "name": p[1], "desc": p[2], "current": p[3], "kind": p[4]}) + return out + + +def state() -> dict[str, object]: ds = default_sink_name() - ss: list[dict] = [] - for n in nodes("Audio/Sink"): - if "cinema_spatial" in n["name"]: - key = "5.1 直通" if n["name"] == SINK_51 else "立体声上混" - ss.append({"key": key, "id": n["id"], "vol": round(vol(n["id"]), 3)}) - t, p = conf_target(), phys_sink() - devs = [n for n in nodes("Audio/Sink") if "cinema_spatial" not in n["name"]] - dv = next((round(vol(d["id"]), 3) for d in devs if d["name"] == t), None) + v = virtual_sink() + vid = node_id(v) + vv, vm = vol_of(vid) if vid > 0 else (-1.0, False) + tgt = chain_target() or phys_sink() + pid = node_id(tgt) + pv, pm = vol_of(pid) if pid > 0 else (-1.0, False) + sinks = [s for s in node_list("Audio/Sink") if "cinema_spatial" not in str(s["name"])] + with METER_LOCK: + loglines = list(LOG)[-60:] + kind = route_kind() return { - "default": next((n["desc"] for n in nodes("Audio/Sink") if n["name"] == ds), ds), - "default_name": ds, - "default_is_virtual": "cinema_spatial" in ds, - "sinks": ss, + "title": "Collaplex 音效", + "mode": "立体声上混" if v == SINK_UP else ("5.1 直通" if v == SINK_51 else "未开"), + "default": next((s["desc"] for s in node_list("Audio/Sink") if s["name"] == ds), ds), + "virtual": {"name": v, "vol": round(vv, 3), "muted": vm}, + "physical": {"name": tgt, "desc": next((s["desc"] for s in sinks if s["name"] == tgt), tgt), + "vol": round(pv, 3), "muted": pm}, + "route": kind, + "route_label": "数字输出 (S/PDIF)" if kind == "digital" else "模拟输出", "clock": clock_rate(), "hrtf": hrtf_name(), - "target": t, - "target_ok": bool(t) and t == p, - "physical": p, - "route": route_of("cinema_spatial_up_out"), - "default_vol": next((round(vol(n["id"]), 3) for n in nodes("Audio/Sink") - if n["name"] == ds), None), - "device_vol": dv, - "device_desc": next((d["desc"] for d in devs if d["name"] == t), ""), + "ir": conf_params(), # 当前实际生效的 IR(tap/文件名) —— 与"请求参数"分开 + "switches": toggle_state(), + "loud": load_loud(), + "reverb": reverb_state(), + "mono": mono_state(), + "params": params_state(), + "param_options": param_options(), + "devices": helper_sinks(), + "log": loglines, + "hw_base": HW_BASE, } -def set_volume(val: float) -> str: - for n in nodes("Audio/Sink"): - if "cinema_spatial" in n["name"]: - sh("wpctl", "set-volume", str(n["id"]), "%.3f" % val) - return "已设为 %.2f" % val - - -def set_mode(mode: str) -> str: - if mode == "off": - p = phys_sink() - nid = next((n["id"] for n in nodes("Audio/Sink") if n["name"] == p), None) - if nid: - sh("wpctl", "set-default", str(nid)) - return "已切到物理输出" - return "找不到物理输出" - want = SINK_UP if mode == "on" else SINK_51 - n = next((x for x in nodes("Audio/Sink") if x["name"] == want), None) - if not n: - return "虚拟声卡不存在" - sh("wpctl", "set-default", str(n["id"])) - return "已切到 %s" % ("立体声上混" if mode == "on" else "5.1 直通") - - -def fix() -> str: - gen = os.path.join(ROOT, "生成配置.py") - if not os.path.exists(gen): - return "找不到 生成配置.py" - t, p = conf_target(), phys_sink() - if t and t == p: - return "配置已正确, 无需修复" - if not p: - return "找不到物理输出设备" - e = _env() - e["CINEMA_SPATIAL_IR"] = os.path.join(ROOT, "hrir", "current") - subprocess.run([sys.executable, gen], capture_output=True, text=True, timeout=30, env=e) - sh("systemctl", "--user", "restart", "pipewire", "pipewire-pulse", "wireplumber", timeout=20) - return "已重新探测设备并重建配置" - - class Handler(BaseHTTPRequestHandler): def log_message(self, format: str, *args: object) -> None: # noqa: A002 pass @@ -264,10 +590,11 @@ class Handler(BaseHTTPRequestHandler): self._send(200, f.read(), "text/html; charset=utf-8") except FileNotFoundError: self._send(404, b"index.html not found", "text/plain; charset=utf-8") - elif u.path == "/api/status": - self._json(status()) - elif u.path == "/api/level": - self._json({"points": level_points(), "monitor": monitor_name()}) + elif u.path == "/api/state": + self._json(state()) + elif u.path == "/api/meters": + self._json({"raw": meter_points("raw"), "post": meter_points("post"), + "raw_target": virtual_sink(), "post_target": chain_target() or phys_sink()}) else: self._json({"error": "not found"}, 404) @@ -286,29 +613,128 @@ class Handler(BaseHTTPRequestHandler): def g(k: str, d: str = "") -> str: return (q.get(k) or [d])[0] - if u.path == "/api/mode": - msg = set_mode(g("mode", "on")) - elif u.path == "/api/volume": - try: - msg = set_volume(max(0.0, min(1.5, float(g("value", "1.0"))))) - except ValueError: - msg = "音量参数无效" - elif u.path == "/api/fix": - msg = fix() - else: - self._json({"error": "not found"}, 404) - return - self._json({"message": msg, "status": status()}) + try: + if u.path == "/api/mode": + msg = cli({"off": "关", "on": "开", "on51": "开5.1"}.get(g("mode", "on"), "状态")) + elif u.path == "/api/volume": # 原始(虚拟声卡)音量 + val = max(0.0, min(1.5, float(g("value", "0.33")))) + for s in node_list("Audio/Sink"): + if "cinema_spatial" in str(s["name"]): + sh("wpctl", "set-volume", str(s["id"]), "%.3f" % val) + try: + os.makedirs(STATE_DIR, exist_ok=True) + with open(VOL_FILE, "w", encoding="utf-8") as f: + f.write("%.3f\n" % val) + except OSError: + pass + msg = "虚拟声卡音量 %.3f" % val + elif u.path == "/api/mono": # 单声道: 委托 CLI(写状态 → 重建 → 验连线) + mode = g("mode", "自动").strip() + if mode not in ("自动", "开", "关"): + msg = "单声道: 只接受 自动/开/关" + else: + msg = (sh("bash", CLI, "单声道", mode, timeout=240) or "").strip() or "(无输出)" + elif u.path == "/api/speaker": # 扬声器电平 = 物理输出音量(老板听到的音量) + try: + 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 "") + elif u.path == "/api/route": + msg = cli("数字" if g("rt", "digital") == "digital" else "模拟") + elif u.path == "/api/switch": + msg = toggle(g("which", "raw"), g("on", "1") == "1") + elif u.path == "/api/loudness": + d = load_loud() + if "unified" in q: + d["unified"] = g("unified") == "1" + 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 + if "digital" in q: + d["digital"] = max(0.0, min(1.5, float(g("digital")))) + if "analog" in q: + d["analog"] = max(0.0, min(1.5, float(g("analog")))) + save_loud(d) + msg = apply_loudness() + elif u.path == "/api/fix": + msg = cli("开") + elif u.path == "/api/device": + # 设备选择: 委托 CLI「设备」= 重写 node.target → 重建 → 校验 → 实装(约 10s) + # ★ 参数必须分开传: 设备关键词里可能有空格(如 "Ryzen HD Audio Controller 模拟立体声") + want = g("name", "").strip() + if not want: + msg = "设备选择: 缺 name 参数" + else: + msg = (sh("bash", CLI, "设备", want, timeout=200) or "").strip() or "(无输出)" + elif u.path == "/api/params": + # 采样参数: 委托 CLI「参数」= 写参数 → (裁 IR) → 重生成配置 → 重启(约 10s) + key = g("key", "").strip() + val = g("value", "").strip() + if key not in ("hrtf", "taps", "rate") or not val: + msg = "采样参数: 需要 key(hrtf/taps/rate) 与 value" + else: + msg = (sh("bash", CLI, "参数", key, val, timeout=300) or "").strip() or "(无输出)" + elif u.path == "/api/reverb": + # 混响: 委托 CLI「混响」= 写状态 → 重生成配置 → 重启链路 → 验连线(约 10s) + on = g("on", "") + wet = g("wet", "0.25") + if on not in ("0", "1"): + msg = "混响: 缺 on 参数(0/1)" + else: + out = sh("bash", CLI, "混响", "开" if on == "1" else "关", wet, timeout=240) + msg = (out or "").strip() or "(无输出)" + else: + self._json({"error": "not found"}, 404) + return + except Exception as e: # 任何异常都要回给界面 + msg = "执行出错: %s" % e + log(msg) + self._json({"message": msg, "state": state()}) def main() -> int: - ap = argparse.ArgumentParser(description="电影院空间音频 Web 控制台") + ap = argparse.ArgumentParser(description="Collaplex 音效 Web 控制台") ap.add_argument("--port", type=int, default=8788) ap.add_argument("--bind", default="127.0.0.1") a = ap.parse_args() - start_level_worker() + os.makedirs(STATE_DIR, exist_ok=True) + start_meters() + log("控制台启动, 电平采集: raw=%s post=%s(均为显式连线, 不经麦克风)" % (virtual_sink(), _meter_src("post")[0])) srv = ThreadingHTTPServer((a.bind, a.port), Handler) - print("电影院空间音频控制台: http://%s:%d/ (Ctrl-C 退出)" % (a.bind, a.port)) + print("Collaplex 音效 控制台: http://%s:%d/ (Ctrl-C 退出)" % (a.bind, a.port)) sys.stdout.flush() try: srv.serve_forever() diff --git a/web/启动.sh b/web/启动.sh index 3d2b4ba..ae15320 100755 --- a/web/启动.sh +++ b/web/启动.sh @@ -1,5 +1,5 @@ #!/bin/bash -# 启动空间音频 Web 控制台(后台运行, 重复执行不会起第二个) +# 启动 Collaplex 音效 Web 控制台(后台运行, 重复执行不会起第二个) PORT="${1:-8788}" P=$(ss -ltnp 2>/dev/null | grep ":$PORT" | grep -oP 'pid=\K[0-9]+' | head -1) if [ -n "$P" ]; then diff --git a/work/测链路冲激响应.py b/work/测链路冲激响应.py new file mode 100644 index 0000000..eee8349 --- /dev/null +++ b/work/测链路冲激响应.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""测滤波链的冲激响应(验证混响是否真在链上生效) + +做法: 把一个短脉冲送到虚拟声卡 -> 录物理输出 monitor -> 看脉冲之后有没有 +房间尾音, 并量它的衰减(T60)。混响关/开各测一次对比。 + +注意: 测试期间物理输出音量压到 0.02(听不见), 结束还原。 +只落文件+出指标, 不播放给老板听。 +""" +import json +import math +import os +import struct +import subprocess +import sys +import time + +RATE = 96000 +CLICK_MS = 5.0 +RECORD_S = 1.2 +WORK = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "work") +CLICK = "/tmp/冲激.wav" +PROBE = "/tmp/冲激响应.wav" +CONF = os.path.expanduser("~/.config/pipewire/pipewire.conf.d/90-cinema-spatial.conf") +HELPER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "音频状态.py") + + +def chain_target() -> str: + """链真正的输出去向(配置里的 node.target). + + 别按 pw-dump 顺序猜: 显卡的 Pro/HDMI 输出排在前面, 挑错就录到一片静音(实测踩过). + """ + try: + with open(CONF, encoding="utf-8") as fh: + for line in fh: + if "node.target" in line: + return line.split("node.target =", 1)[1].strip().split()[0].strip('"') + except OSError: + pass + out = subprocess.run(["python3", HELPER, "phys-sink"], capture_output=True, text=True) + return out.stdout.strip() + + +def dump() -> list[dict[str, object]]: + out = subprocess.run(["pw-dump"], capture_output=True, text=True, timeout=30).stdout + return json.loads(out) + + +def props_of(o: dict[str, object]) -> dict[str, object]: + """取 node 的 props(PipeWire dump 里是嵌套字典, 逐层验型).""" + info = o.get("info") + if not isinstance(info, dict): + return {} + p = info.get("props") + return p if isinstance(p, dict) else {} + + +def node_id(objs: list[dict[str, object]], want: str) -> str: + """按 node.name 找数字 id(生成器/录制都只认数字 id: 给名字会静默回退).""" + for o in objs: + if props_of(o).get("node.name") == want: + return str(o.get("id", "")) + return "" + + +def sinks(objs: list[dict[str, object]]) -> list[str]: + out: list[str] = [] + for o in objs: + props = props_of(o) + name = str(props.get("node.name", "")) + if props.get("media.class") == "Audio/Sink" and name and "cinema_spatial" not in name: + out.append(name) + return out + + +def vol_of(nid: str) -> str: + t = subprocess.run(["wpctl", "get-volume", nid], capture_output=True, text=True).stdout + return t.split()[1] if len(t.split()) > 1 else "1.0" + + +def make_click() -> None: + subprocess.run(["sox", "-n", "-r", str(RATE), "-c", "2", CLICK, + "synth", str(CLICK_MS / 1000.0), "sine", "1000", "fade", "0", "0.0005", + ], check=True, capture_output=True) # 满刻度: 脉冲要盖过一切 + + +def feeding_streams(virt_id: str) -> list[str]: + """正在往虚拟声卡送信号的流名字(测脉冲前要静音, 否则音乐底噪淹没尾音). + + 注意 pw-link -l 的排版: 端口名左对齐, 它的连线条目是缩进的下一行 —— + 所以必须"记住上一个端口行, 再看后面缩进的 |<- 行"(第一版就在这栽了, 一个都没抓到). + """ + out = subprocess.run(["pw-link", "-l"], capture_output=True, text=True).stdout + ids: list[str] = [] + in_virt = False + for line in out.splitlines(): + if not line.startswith((" ", "\t")): + in_virt = ("cinema_spatial_up_sink:playback_" in line + or "cinema_spatial_sink:playback_" in line) + continue + if in_virt and "|<-" in line: + src = line.split("|<-")[1].strip().split(":")[0] + if src and src not in ids: + ids.append(src) + return ids + + +def mute_by_name(names: list[str], muted: bool) -> list[str]: + """按 node.name 静音/恢复, 返回实际改动的 id.""" + objs = dump() + done: list[str] = [] + for n in names: + nid = node_id(objs, n) + if nid: + subprocess.run(["wpctl", "set-mute", nid, "1" if muted else "0"], capture_output=True) + done.append(n) + return done + + +def record(virt: str, phys: str) -> bool: + """播脉冲到虚拟声卡, 同时录物理输出 monitor.""" + if os.path.exists(PROBE): + os.remove(PROBE) + rec = subprocess.Popen(["pw-record", "--target", phys, "--rate", str(RATE), + "--channels", "2", "--format", "f32", PROBE], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(0.25) # 先让录制连上 monitor, 免得漏掉脉冲头 + subprocess.run(["pw-play", "--target", virt, CLICK], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30) + time.sleep(RECORD_S) + rec.terminate() + try: + rec.wait(timeout=5) + except subprocess.TimeoutExpired: + rec.kill() + return os.path.exists(PROBE) + + +def read_f32(path: str) -> tuple[list[float], int]: + """读 32bit float WAV, 返回 (左声道, 帧率). + + 标准库 wave 只认 PCM, 遇到 IEEE float(formattag=3) 直接报 unknown format, + 所以这里自己走 RIFF chunk。 + """ + with open(path, "rb") as fh: + blob = fh.read() + if blob[:4] != b"RIFF" or blob[8:12] != b"WAVE": + raise ValueError("不是 WAV 文件") + pos, rate, ch, bits, data = 12, RATE, 2, 32, b"" + while pos + 8 <= len(blob): + cid = blob[pos:pos + 4] + csz = struct.unpack(" list[tuple[float, float]]: + """(秒, dBFS) 逐窗口 RMS 包络.""" + w = max(int(rate * win_ms / 1000), 1) + out: list[tuple[float, float]] = [] + for i in range(0, len(x) - w, w): + seg = x[i:i + w] + r = (sum(v * v for v in seg) / len(seg)) ** 0.5 + db = 20.0 * math.log10(r) if r > 0 else -200.0 + out.append((i / rate, db)) + return out + + +def decay_t60(env: list[tuple[float, float]], skip_s: float = 0.12) -> tuple[float, float]: + """(尾音 T60 估值, 峰值 dB). 从峰值后 skip_s 起, 用 -5~-25 dB 段线性拟合.""" + pk = max((db for _, db in env), default=-200.0) + if pk <= -190: + return 0.0, pk + pts = [(t, db) for t, db in env if t >= skip_s and db > -120] + if len(pts) < 5: + return 0.0, pk + a = [(t, db) for t, db in pts if db <= pk - 5 and db >= pk - 25] + if len(a) < 3: + return 0.0, pk + n = len(a) + mx = sum(t for t, _ in a) / n + my = sum(db for _, db in a) / n + num = sum((t - mx) * (db - my) for t, db in a) + den = sum((t - mx) ** 2 for t, _ in a) + slope = num / den if den else 0.0 + return (-60.0 / slope if slope < 0 else 0.0), pk + + +def tail_len(env: list[tuple[float, float]], pk: float, thresh_db: float = 40.0) -> float: + """峰值后衰减到 -thresh dB 所需时间(尾音长度).""" + last = 0.0 + for t, db in env: + if db > pk - thresh_db: + last = t + return last + + +def main() -> int: + objs = dump() + virt = node_id(objs, "cinema_spatial_up_sink") + candidates = sinks(objs) + if not virt or not candidates: + print("找不到虚拟声卡或物理输出") + return 1 + phys_name = chain_target() or candidates[0] + phys = node_id(objs, phys_name) + print(f"虚拟声卡 id={virt} | 物理输出 id={phys} ({phys_name})") + + prev = vol_of(phys) + print(f"物理输出音量 {prev} → 压到 0.02(测试听不见), 结束还原") + subprocess.run(["wpctl", "set-volume", phys, "0.02"], capture_output=True) + srcs = feeding_streams(virt) + if srcs: + print(f"临时静音正在送声的流: {', '.join(srcs)}") + mute_by_name(srcs, True) + time.sleep(0.3) + make_click() + + ok = record(virt, phys) + mute_by_name(srcs, False) + subprocess.run(["wpctl", "set-volume", phys, prev], capture_output=True) + if not ok: + print("! 没录到文件") + return 1 + + x, rate = read_f32(PROBE) + env = envelope(x, rate) + t60, pk = decay_t60(env) + tl = tail_len(env, pk) + print(f"\n录到 {len(x) / rate:.2f}s | 峰值 {pk:+.1f} dBFS") + print(f"尾音 T60(脉冲后 -5~-25dB 拟合) = {t60:.3f}s | 衰减到 -40dB 用时 {tl * 1000:.0f} ms") + print("\n包络(每 40ms 取一点, dBFS):") + line: list[str] = [] + for i, (t, db) in enumerate(env): + if i % 4 == 0 and t <= 0.9: + line.append(f"{t * 1000:4.0f}ms {db:+6.1f}") + for i in range(0, len(line), 6): + print(" " + " | ".join(line[i:i + 6])) + print(f"\n留档: {PROBE}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/work/看声卡route.py b/work/看声卡route.py new file mode 100644 index 0000000..26cfad1 --- /dev/null +++ b/work/看声卡route.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""打印指定声卡的 profile/route 原始参数(排查"数字/模拟输出"响度差). + +用法: python3 看声卡route.py [设备名关键字] +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from typing import Any + + +def main() -> None: + key = (sys.argv[1] if len(sys.argv) > 1 else "edifier").lower() + d = json.loads(subprocess.run(["pw-dump"], capture_output=True, text=True).stdout) + for o in d: + if o.get("type") != "PipeWire:Interface:Device": + continue + p = o.get("info", {}).get("props", {}) + blob = json.dumps(p, ensure_ascii=False).lower() + if key not in blob: + continue + print(f"### 设备 id={o['id']} {p.get('device.description','')}") + print(f" node.name = {p.get('node.name','')}") + print(f" api.alsa.card = {p.get('api.alsa.card.name','')}") + prms = o.get("info", {}).get("params", {}) + for section in ("Profile", "EnumProfile", "Route", "EnumRoute"): + items = prms.get(section) or [] + if not items: + continue + print(f"\n[{section}] ({len(items)} 项)") + for it in items: + keep: dict[str, Any] = {} + for k, v in it.items(): + if k in ("props", "profiles", "devices", "volume", "channelVolumes", + "index", "device", "save", "available", "availability", + "priority", "name", "description"): + keep[k] = v + print(" " + json.dumps(keep, ensure_ascii=False)[:400]) + + +if __name__ == "__main__": + main() diff --git a/work/看音频状态.py b/work/看音频状态.py new file mode 100644 index 0000000..1b6be03 --- /dev/null +++ b/work/看音频状态.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""看音频现场: sink 音量 / 声卡各 route 的音量 / 默认设备(排查"响度怪"用). + +用法: python3 看音频状态.py +""" + +from __future__ import annotations + +import json +import subprocess +from typing import Any + + +def dump() -> list[dict[str, Any]]: + raw = subprocess.run(["pw-dump"], capture_output=True, text=True).stdout + return json.loads(raw) + + +def vol_of(prm: dict[str, Any]) -> str: + """把 Volume 参数压成一行.""" + cv = prm.get("channelVolumes") or prm.get("volume") + if cv is None: + return "-" + if isinstance(cv, (int, float)): + return f"{cv:.4f}" + vals = [float(v) for v in cv] + if not vals: + return "-" + return "/".join(f"{v:.4f}" for v in vals) + + +def main() -> None: + d = dump() + print("=== Audio/Sink 节点 ===") + for o in d: + if o.get("type") != "PipeWire:Interface:Node": + continue + p = o.get("info", {}).get("props", {}) + if p.get("media.class") not in ("Audio/Sink", "Stream/Output/Audio"): + continue + prms = o.get("info", {}).get("params", {}) + vol = "-" + for prm in prms.get("Props", []) or []: + if "volume" in prm or "channelVolumes" in prm: + vol = vol_of(prm) + print(f" id={o['id']:<4} {p.get('media.class'):<20} {p.get('node.name','')}") + print(f" desc={p.get('node.description','')} vol={vol} " + f"state={o.get('info',{}).get('state','')}") + + print() + print("=== 声卡(Device) 及其 route 音量 ===") + for o in d: + if o.get("type") != "PipeWire:Interface:Device": + continue + p = o.get("info", {}).get("props", {}) + name = p.get("device.name") or p.get("api.alsa.card.name") or "" + if "usb" not in name.lower() and "edifier" not in name.lower(): + continue + print(f" id={o['id']} {p.get('node.name','')} ({p.get('device.description','')})") + prms = o.get("info", {}).get("params", {}) + print(f" 参数键: {sorted(prms.keys())}") + for prm in prms.get("Profile", []) or []: + print(f" 当前 profile: {prm.get('name','')} ({prm.get('description','')})") + for prm in prms.get("Route", []) or []: + rp = prm.get("props", {}) + print(f" route={rp.get('route.name',''):<28} " + f"desc={rp.get('route.description',''):<20} " + f"prio={rp.get('priority','')} vol={vol_of(prm)} " + f"avail={prm.get('available','')}") + for prm in prms.get("EnumRoute", []) or []: + rp = prm.get("props", {}) + print(f" [enum] {rp.get('route.name',''):<28} " + f"desc={rp.get('route.description','')} avail={prm.get('available','')}") + + +if __name__ == "__main__": + main() diff --git a/work/裁HRIR.py b/work/裁HRIR.py new file mode 100644 index 0000000..682467e --- /dev/null +++ b/work/裁HRIR.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""把 HRIR 截到 N tap(末尾带淡出防爆音), 输出到 hrir/taps-/。 + +为什么: 每路 HRTF 卷积的成本 ≈ tap 数。SADIE-II H4 在 96k 是 4800 tap, +折到 1024 tap 等于每路少 4.7 倍乘加 —— 12 路卷积器都吃这个。 +代价: 砍掉的是脉冲响应尾部(房间/耳廓的晚期反射), 声音会比原来"干"一点。 + +用法: python3 work/裁HRIR.py [源目录, 默认 hrir/current] +只落文件 + 出指标, 不碰正在跑的音频。 +""" +import os +import struct +import sys +from typing import Any + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def read_wav(path: str) -> tuple[list[float], int, int]: + """读 WAV(float32 / int16), 返回 (样本, 帧率, 声道).""" + with open(path, "rb") as fh: + blob = fh.read() + if blob[:4] != b"RIFF" or blob[8:12] != b"WAVE": + raise ValueError("不是 WAV: " + path) + pos, rate, ch, bits, fmt, data = 12, 48000, 1, 32, 3, b"" + while pos + 8 <= len(blob): + cid = blob[pos:pos + 4] + csz = struct.unpack(" None: + """写 32bit float WAV(与项目里既有 IR 一致).""" + raw = struct.pack("<%df" % len(x), *x) + hdr = b"RIFF" + struct.pack(" list[float]: + """末尾淡出, 免得硬切出一个咔嗒.""" + k = min(int(rate * ms / 1000.0), len(x) // 4) + if k <= 0: + return x + out = list(x) + for i in range(k): + out[len(x) - k + i] *= 1.0 - (i + 1) / k + return out + + +def main() -> int: + if len(sys.argv) < 2: + print(__doc__) + return 2 + taps = int(sys.argv[1]) + src = sys.argv[2] if len(sys.argv) > 2 else os.path.join(ROOT, "hrir", "current") + dst = os.path.join(ROOT, "hrir", "taps-%d" % taps) + os.makedirs(dst, exist_ok=True) + files = sorted(f for f in os.listdir(src) if f.endswith(".wav")) + if not files: + print("! 源目录没有 wav: " + src) + return 1 + print("源 %s → %s" % (src, dst)) + print("%-14s %8s %8s %10s" % ("文件", "原tap", "新tap", "峰值dBFS")) + peak_all = 0.0 + for f in files: + x, rate, ch = read_wav(os.path.join(src, f)) + n0 = len(x) // ch + cut = x[: taps * ch] + cut = fade_tail(cut, rate) + write_wav(os.path.join(dst, f), cut, rate, ch) + pk = max((abs(v) for v in cut), default=0.0) + peak_all = max(peak_all, pk) + print("%-14s %8d %8d %10.2f" % (f, n0, min(n0, taps), 20 * __import__("math").log10(pk or 1e-9))) + print("完成: %d 个文件, 整体峰值 %.2f dBFS, 目录 %s" % (len(files), 20 * __import__("math").log10(peak_all or 1e-9), dst)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/发布release.sh b/发布release.sh index c4fb341..e98ecce 100644 --- a/发布release.sh +++ b/发布release.sh @@ -14,7 +14,8 @@ bash 打包deb.sh 2>&1 | tail -3 echo "本地: $(stat -c%s "$DEB") 字节" echo "=== 建 release v$VER ===" -RID=$(curl -s -X POST "$API/releases" -u edgevoid:147427 -H "Content-Type: application/json" -d "{\"tag_name\":\"v$VER\",\"name\":\"v$VER\",\"body\":\"collaplex-cinema-spatial $VER - 耳机电影院空间音频 (PipeWire filter-chain + SADIE-II HRTF, 96kHz/24bit). 安装: sudo dpkg -i ${PKG}_${VER}_all.deb; systemctl --user restart pipewire. 本版: 空间音频命令增加设备自检(USB profile 变化致 target 失效时自动重建), 输出目标改为自动探测物理设备(优先USB), 去硬编码.\"}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))") +BODY="collaplex-cinema-spatial $VER - 耳机电影院空间音频 (PipeWire filter-chain + SADIE-II HRTF, 96kHz/24bit)\n\n核心技术: 镜像法房间声学追踪(0-80ms 早期反射, 8473 条镜面路径) + Sabine 统计尾音(80ms-2s) -> 96kHz 真实房间 IR 双耳卷积; 湿量按能量归一(湿量 G 时湿路 = 干路 + 20log10 G)。不是 Freeverb/Schroeder 类算法混响。\n\n本版新增/修复:\n- 单声道设备支持: 单声道蓝牙音响/耳机连不上(链输出是 2 声道 FL/FR, 单声道设备只有 1 个 MONO 端口) -> 新增单声道合并(自动/开/关), 默认按目标设备声道数自动判定\n- 切换设备不再重启 PipeWire: 新增运行期重连(链输出本质是 stream, 端口可运行期改连) -> 蓝牙设备不再被重启带掉\n- 修复'重启后目标失效': 原来固定 sleep 4 就判死, 而蓝牙/USB 设备要 3~8 秒重连 -> 改为轮询等待(最多 20 秒), 回退探测跳过显卡/HDMI\n- 修复削波: 房间混响 IR 原来只按峰值归一, 能量增益 +11dB, 处理后峰值冲到 +12dBFS -> 改为能量归一(sigma ir^2 = 1)\n- 电平表采集: 处理后电平改为按端口 id 显式连线读取链输出, 杜绝 pw-record 静默回退到麦克风\n- 面板新增'核心技术'说明区; 新增输入增益(削波余量)滑块\n- 打包: control 版本号改为跟随 VER(旧版恒为 1.0.0 致 dpkg 无法升级), 补入 音频状态.py 与 web/ 面板\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 diff --git a/响度怪-根因与修复.md b/响度怪-根因与修复.md new file mode 100644 index 0000000..765c6d0 --- /dev/null +++ b/响度怪-根因与修复.md @@ -0,0 +1,116 @@ +# 响度怪 · 根因与修复(2026-09-13) + +## 你报的症状 + +> 先在 Ubuntu 面板上点**模拟输出**再点空间音频 → 正常响度 +> 先点**数字输出**再点空间音频 → 响度很小 + +## 一、现场事实(全部实测,不是推断) + +硬件:EDIFIER Fit900NB(USB 声卡),共 9 个 profile。相关两条: + +| profile | 输出 route | 硬件音量 | 说明 | +|---|---|---|---| +| 1 `output:analog-stereo+input:mono-fallback` | `analog-output` | **有**(`volumeBase = 1.11428`) | 面板上的「模拟输出」 | +| 3 `output:iec958-stereo+input:mono-fallback` | `iec958-stereo-output` | **无**(只有 `iec958Codecs`) | 面板上的「数字输出」 | + +两条 route 存的音量都是 1.0(`default-routes` 里 `channelVolumes:[1.0,1.0]`),**但只有模拟那条带硬件音量控制**。 + +音量现状(12 层逐项量出来): + +| 层 | 值 | 相对 | +|---|---|---| +| 虚拟声卡(上混)`cinema_spatial_up_sink` | 0.330 | −9.6 dB | +| filter 输出端 `cinema_spatial_up_out` | 1.120 | +1.0 dB | +| 物理耳机(模拟 route) | 1.000 | 0 dB | +| **整链** | 0.370 | **−8.6 dB** | + +## 二、三处缺陷 + +### 🔴 1. `node.target` 是"某一刻的节点名快照",重建后会当场失效(推断,未复现) + +- 配置里写死的是**当前那个 profile 的节点名**:`...-00.analog-stereo` +- 你在面板点「数字输出」→ 声卡切到 profile 3 → 那个名字的节点消失 → 目标失效 +- 脚本走"重建"分支 → 写新配置 → **`systemctl --user restart pipewire`** +- 重启时 WirePlumber 用它存档的 `default-profile`(实测存的是 **analog**)**把 profile 拉回** → 刚写进去的 target 又指向不存在的节点 +- **而旧脚本不校验连线,静默继续** → 声音由 WirePlumber 的默认策略接管,响度就不再由你控制 + +证据:`~/.local/state/wireplumber/default-profile` 存的 analog;`default-nodes` 里同时有 `analog-stereo` 和 `iec958-stereo` 两个名字;设备 Profile 参数是 `save:false`。 + +### 🔴 2. 音量每次"开"都被硬写 0.25(已实测确认) + +旧脚本 `apply_volume()` 无条件 `wpctl set-volume ... 0.25`。你在面板调好的值(现场 0.330)**每次开都被抹掉**,而且"先点数字输出"这条链恰好会触发重建 → 音量被重置 —— 两条叠加就是你感觉的"音量怪"。 + +### 🟡 3. 两条 profile 的硬件增益基数差约 0.94 dB(实测) + +用 `pw-dump` 读**活动 Route** 的 `volumeBase`:模拟 `1.11428`(+0.94 dB)/ 数字(S/PDIF) `1.00000`(0 dB)。 +两条 route **都有**音量控制、存档音量也都是 1.0,差别只是模拟那条的硬件增益基数高约 **+0.94 dB**。 + +★ **更正(同日)**:我最初读的是 `EnumRoute`(**能力清单**,不带 `volumeBase`),据此得出过 +"数字 route 没有硬件音量"的**错误结论**;`Route` 参数才是当前/存档状态。已按活动 Route 改正。 + +另外:你说的"点数字输出后响度很小"**我没能复现量级**——复现要切档或放音,而现场有录音占用(见下节)。 +若你实测数字那条确实小很多,那是硬件/DAC 路径的差异,用第六节的补偿旋钮兜。 + +## 三、为什么我没直接复现 + +复现要切 profile 或重启 PipeWire,而现场有一个 `pw-record`(控制台的子进程,PID 28599,已跑 7 分钟)**正在采你的耳机麦克风** —— 切档会打断它。所以我只做只读诊断 + 改代码,没动任何设备/服务。 + +## 四、顺带挖出的两个真 bug + +**① 电平表一直在偷录麦克风。** 控制台的电平表用 `pw-record --target <物理设备>.monitor` 取输出电平,但实测该 monitor 节点**不存在**(手测两条 `--target` 都录不出文件)→ `pw-record` 静默回退到**默认录制源 = 耳机麦克风**。已改成:monitor 不存在就不启动录制(宁可不显示,不乱采)。 + +**② 音量读数的换算陷阱。** `Props.volume` 恒为 `1.0`(不是音量!),`channelVolumes` 是**三次方**值;用户看到的数才是线性音量 = `wpctl get-volume`。我先前读 0.042 就误当成 0.35(`0.042^(1/3)=0.348`),而真值是 0.330。已统一按 `wpctl get-volume` 报数。 + +## 五、改了什么 + +| 文件 | 改动 | +|---|---| +| `空间音频` | ① 音量**持久化**:记在 `~/.local/state/cinema-spatial/volume`,开的时候先"记账"你手动调过的值再应用,重建不再丢 ② 重建时先记下物理卡当前 profile,重启后**按回去**(必要时 `wpctl set-profile`),保证配置里的 target 与 profile 一致 ③ 重建后**校验实际连线**(认当前生效的那条),失败明确报错 ④ 打印**音量账**(每层 + dB + 整链)⑤ 新增 `诊断` | +| `音频状态.py`(新增) | 状态/诊断/profile 助手:纯 stdlib,`wpctl`/`pw-dump`;子命令 `json / check / phys-sink / card-of / profile / set-profile` | +| `web/webui.py` | 控制台**不再自己实现一套**:开/关/一键修复全部委托给 `空间音频` 脚本(单一实现);音量滑块写入持久化文件;monitor 不存在则不启动电平录制 | +| `打包deb.sh` | 源改成项目目录(原来从 `~/.local/bin` 拷旧版)、带上 `音频状态.py`、版本 1.0.2 | + +## 六、现在的行为 + +```bash +空间音频 开 # 打印完整音量账;target 死了才重建,重建必保 profile 并验连线 +空间音频 诊断 # 输出目标 / profile / 硬件增益基数(逐 route) / 音量账 / 连线状态 +``` + +**「数字输出」这条道**:脚本会明确警告"硬件增益基数比模拟低约 0.9 dB、两条路电平不等价",要硬用可以给补偿: +```bash +export CINEMA_SPATIAL_VOLUME_DIGITAL=0.6 # 或你实测合适的值 +``` + +## 七、要你验的三件事(我这边不出声) + +1. 面板点**数字输出** → 点空间音频:应看到 profile 保活/警告,**响度应仍正常**(若仍偏小,说明就是硬件 route 差,用上面的补偿) +2. 面板点**模拟输出** → 点空间音频:响度应与以前一致 +3. 面板把音量调到你喜欢的值 → 关一次再开:**音量应保持不变**(这条是本次核心修复) + +--- + +## 八、追加(同日):数字输出定为**首选** + +你实测的主观音质:**数字输出(S/PDIF) 比模拟输出好一大截** —— 所以上面"建议改用模拟输出"的说法**作废**, +数字才是首选。客观侧能佐证的实测数据: + +| | 数字输出 route | 模拟输出 route | +|---|---|---| +| 协商格式 | **96 kHz / 24-bit(S24LE)** | (待下次在模拟路上量) | +| 硬件增益基数 | 1.00000(0 dB) | 1.11428(+0.94 dB) | + +数字这条路 **96 kHz + 24-bit 全保住了**,96k 链和 512-tap IR 没白费(这是关键——否则 512tap 会被当 256tap 用)。 +模拟那条我暂时量不到协商格式(要切过去才有),**下次你在模拟路上时我量一下**,就能确认音质差是出在位深/采样率还是别的环节。 + +### 新增命令(比在 GNOME 面板点更稳) + +```bash +空间音频 数字 # 切数字输出(S/PDIF):切完自动重建目标 + 把 profile 按回去 + 验连线 + 出音量账 +空间音频 模拟 # 切模拟输出 +``` + +为什么不让你直接在面板点:面板点会改 profile → 目标节点名随之变化 → 触发重建 → 重建里的 `restart pipewire` +又可能被 WirePlumber 的存档 profile 拉回(就是上面第二节那个坑)。这条命令把这一串都包住了,且**已经在目标路上时不会重启任何东西**。 + diff --git a/打包deb.sh b/打包deb.sh index 6fb231c..c78aa52 100644 --- a/打包deb.sh +++ b/打包deb.sh @@ -4,7 +4,7 @@ set -eu SRC="/home/lou/桌面/工作区/实验/空间音频" PKG="collaplex-cinema-spatial" -VER="1.0.1" +VER="1.1.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" @@ -15,9 +15,8 @@ mkdir -p "$BUILD/DEBIAN" "$BUILD/usr/bin" \ "$BUILD${ROOT}/hrir" "$BUILD${ROOT}/sofa" \ "$BUILD/usr/share/pipewire/pipewire.conf.d" -echo "=== 2. 两个命令 ===" -cp "$HOME/.local/bin/空间音频" "$BUILD/usr/bin/空间音频" -cp "$HOME/.local/bin/mpv-影院" "$BUILD/usr/bin/mpv-影院" +# ★ 源必须是项目目录: 旧版从 ~/.local/bin 拷, 那份可能落后于项目(2026-09-13 补回丢失的 cp) +cp "$SRC/空间音频" "$SRC/mpv-影院" "$BUILD/usr/bin/" chmod 755 "$BUILD/usr/bin/空间音频" "$BUILD/usr/bin/mpv-影院" echo "=== 3. HRTF 数据 (12 个 IR + SOFA) ===" @@ -25,10 +24,16 @@ cp "$SRC/hrir/H4-96k/"*.wav "$BUILD${ROOT}/hrir/" cp "$SOFA_SRC" "$BUILD${ROOT}/sofa/" echo "=== 4. 项目脚本与文档 ===" -cp "$SRC/生成配置.py" "$SRC/换HRTF.sh" "$SRC/README.md" "$BUILD${ROOT}/" +# ★ 音频状态.py 是 CLI 的设备/状态后端(CLI 会去找 $ROOT/音频状态.py), 漏打会致 +# "切设备/诊断"直接报"缺 音频状态.py" —— 2026-09-13 补 +cp "$SRC/生成配置.py" "$SRC/音频状态.py" "$SRC/换HRTF.sh" "$SRC/README.md" "$BUILD${ROOT}/" chmod 755 "$BUILD${ROOT}/换HRTF.sh" echo "=== 5. PipeWire 配置 (用安装路径生成) ===" +mkdir -p "$BUILD${ROOT}/web" +cp "$SRC/web/index.html" "$SRC/web/webui.py" "$SRC/web/启动.sh" "$BUILD${ROOT}/web/" 2>/dev/null \ + || cp "$SRC/web/index.html" "$SRC/web/webui.py" "$BUILD${ROOT}/web/" + CINEMA_SPATIAL_IR="${ROOT}/hrir" \ CINEMA_SPATIAL_OUT="$BUILD/usr/share/pipewire/pipewire.conf.d/90-cinema-spatial.conf" \ python3 "$SRC/生成配置.py" @@ -36,13 +41,13 @@ cp "$SRC/91-clock.conf" \ "$BUILD/usr/share/pipewire/pipewire.conf.d/" echo "=== 6. control ===" -cat > "$BUILD/DEBIAN/control" <<'EOF' +cat > "$BUILD/DEBIAN/control" < Depends: pipewire, libmysofa1, mpv Description: Cinema spatial audio for headphones (HRTF virtual surround) @@ -51,7 +56,11 @@ Description: Cinema spatial audio for headphones (HRTF virtual surround) sense of direction and space. . Provides two virtual sinks (5.1 passthrough and stereo-upmixed) plus the - space-audio and mpv-cinema commands. + space-audio and mpv-cinema commands. + . + Core: image-source room acoustics tracing (8473 early-reflection paths, + 0-80 ms) + Sabine statistical tail (80 ms-2 s) -> a 96 kHz true room + impulse response, convolved binaurally; wet path energy-normalised. EOF echo "=== 7. 打包 ===" diff --git a/生成配置.py b/生成配置.py index ec0e1ae..d6f8d3f 100644 --- a/生成配置.py +++ b/生成配置.py @@ -11,6 +11,7 @@ """ import os import subprocess +import json _ROOT = os.path.dirname(os.path.abspath(__file__)) # IR 目录: 环境变量优先(打包/安装场景指 /usr/share/cinema-spatial/hrir), @@ -22,6 +23,162 @@ OUT = os.environ.get("CINEMA_SPATIAL_OUT") or os.path.expanduser( ) DIRS: list[str] = ["FL", "FR", "FC", "LFE", "BL", "BR"] +# 房间混响(镜像法生成的真实房间 IR; 生成器在 声学追踪引擎/src/镜像法IR.py) +REVERB_IR = os.environ.get("CINEMA_SPATIAL_REVERB_IR") or os.path.join( + _ROOT, "reverb", "房间混响IR-96k.wav") +REVERB_STATE = os.path.expanduser("~/.local/state/cinema-spatial/reverb.json") + +# 采样参数(HRTF 模型 / tap 数 / 采样率) —— 运行时状态, 由 CLI「参数」写 +PARAMS = os.path.expanduser("~/.local/state/cinema-spatial/params.json") +CLOCK_OUT = os.environ.get("CINEMA_SPATIAL_CLOCK") or os.path.expanduser( + "~/.config/pipewire/pipewire.conf.d/91-clock.conf") + + +# 单声道模式(单声道蓝牙音响/耳机): 状态文件三态, 缺省自动判目标设备声道数 +MONO_STATE = os.path.expanduser("~/.local/state/cinema-spatial/mono.json") + + +def mono_cfg() -> bool: + """是否把链的输出合并成单声道. + + 为什么需要: 单声道蓝牙音响只有 1 个 MONO 端口, 链若坚持输出 FL/FR 就链接失败 → + 设备掉线 → 回退守卫滚回 -> 之后一直连不上(2026-09-13 老板实测)。 + 判定顺序: 环境变量 CINEMA_SPATIAL_MONO > 状态文件 {"on": bool} > 自动(目标设备 1 声道)。 + """ + env = os.environ.get("CINEMA_SPATIAL_MONO") + if env in ("0", "1"): + return env == "1" + try: + with open(MONO_STATE, encoding="utf-8") as fh: + d = json.load(fh) + if isinstance(d, dict) and isinstance(d.get("on"), bool): + return bool(d["on"]) + except (OSError, ValueError): + pass + return target_channels() == 1 + + +def target_name() -> str: + """node.target 里的设备名(为空 = 交给 WirePlumber 路由).""" + return TARGET_PROP.split('"')[1] if '"' in TARGET_PROP else "" + + +def target_channels() -> int: + """目标设备有几个声道(pw-dump 读 audio.channels; 读不到按 2).""" + tgt = target_name() + if not tgt: + return 2 + try: + raw = subprocess.run(["pw-dump"], capture_output=True, text=True, timeout=8).stdout + for o in json.loads(raw): + pr = ((o.get("info") or {}).get("props") or {}) + if pr.get("node.name") == tgt: + return int(pr.get("audio.channels") or 2) + except (OSError, ValueError, subprocess.SubprocessError): + pass + return 2 + + +def mono_block(indent: str) -> list[str]: + """单声道合并级: L/R 各 -6dB 相加(避免相关信号 +6dB 溢出).""" + return [f'{indent}{{ type = builtin label = mixer name = monoOut ' + 'control = { "Gain 1" = 0.5 "Gain 2" = 0.5 } }'] + + +def mono_links(indent: str, srcs: tuple[str, str]) -> list[str]: + a, b = srcs + return [f'{indent}{{ output = "{a}" input = "monoOut:In 1" }}', + f'{indent}{{ output = "{b}" input = "monoOut:In 2" }}'] + + +def out_props(mono: bool) -> str: + """链输出节点的声道属性.""" + if mono: + return "audio.channels = 1 audio.position = [ MONO ] }" + return "audio.channels = 2 audio.position = [ FL FR ] }" + + +def params_cfg() -> dict[str, object]: + """读采样参数; 缺省/坏文件都当空(→ 回落到 hrir/current 软链).""" + try: + with open(PARAMS, encoding="utf-8") as fh: + d = json.load(fh) + except (OSError, ValueError): + return {} + return d if isinstance(d, dict) else {} + + +def ir_dir() -> str: + """选 IR 目录: 环境变量 > 参数里的 taps- > 参数里的模型名 > 软链 hrir/current.""" + env = os.environ.get("CINEMA_SPATIAL_IR") + if env: + return env + p = params_cfg() + taps = int(p.get("taps") or 0) # type: ignore[arg-type] + cut = os.path.join(_ROOT, "hrir", "taps-%d" % taps) + if taps and os.path.isdir(cut): + return cut + name = str(p.get("hrtf") or "") + if name and os.path.isdir(os.path.join(_ROOT, "hrir", name)): + return os.path.join(_ROOT, "hrir", name) + return os.path.join(_ROOT, "hrir", "current") + + +def write_clock(rate: int) -> str: + """写用户级主时钟(同名覆盖 deb 装的 /usr/share 那份, 不用 sudo).""" + txt = ( + "# 主时钟 %d Hz —— 由 空间音频 参数 rate 生成\n" + "#\n" + "# 为什么必须显式写: PipeWire 的 convolver 按\"当前时钟率\"重采样 IR,\n" + "# 时钟若低于 IR 的采样率, 等于白换高采样率 IR。\n" + "context.properties = {\n" + " default.clock.rate = %d\n" + " default.clock.allowed-rates = [ %d ]\n" + "}\n" % (rate, rate, rate)) + os.makedirs(os.path.dirname(CLOCK_OUT), exist_ok=True) + with open(CLOCK_OUT, "w", encoding="utf-8") as fh: + fh.write(txt) + return CLOCK_OUT + + +def reverb_cfg() -> tuple[bool, float]: + """(是否开混响, 湿量) —— 读运行时状态文件; 缺省关, 没有 IR 也当关.""" + if not os.path.exists(REVERB_IR): + return False, 0.0 + try: + with open(REVERB_STATE, encoding="utf-8") as fh: + d = json.load(fh) + except (OSError, ValueError): + return False, 0.0 + wet = float(d.get("wet") or 0.0) + on = bool(d.get("on")) and wet > 0.001 + return on, max(0.0, min(wet, 1.0)) + + +def reverb_block(indent: str, wet: float) -> list[str]: + """混响级: HRTF 输出 -> 卷积房间 IR -> 与干信号按湿量混合. + + dry 走 Gain 1(恒 1.0), wet 走 Gain 2(湿量) —— 湿量是生成期常量, 改湿量=重新生成. + """ + return [ + f'{indent}{{ type = builtin label = convolver name = revL config = {{ filename = "{REVERB_IR}" }} }}', + f'{indent}{{ type = builtin label = convolver name = revR config = {{ filename = "{REVERB_IR}" }} }}', + f'{indent}{{ type = builtin label = mixer name = wetL control = {{ "Gain 1" = 1.0 "Gain 2" = {wet:.4f} }} }}', + f'{indent}{{ type = builtin label = mixer name = wetR control = {{ "Gain 1" = 1.0 "Gain 2" = {wet:.4f} }} }}', + ] + + +def reverb_links(indent: str) -> list[str]: + """干路 mixL/mixR 直入混音器, 湿路经房间 IR 卷积入混音器.""" + return [ + f'{indent}{{ output = "mixL:Out" input = "revL:In" }}', + f'{indent}{{ output = "mixR:Out" input = "revR:In" }}', + f'{indent}{{ output = "mixL:Out" input = "wetL:In 1" }}', + f'{indent}{{ output = "revL:Out" input = "wetL:In 2" }}', + f'{indent}{{ output = "mixR:Out" input = "wetR:In 1" }}', + f'{indent}{{ output = "revR:Out" input = "wetR:In 2" }}', + ] + def default_sink_name() -> str: """当前默认物理输出的 node.name —— 必须是物理设备。 @@ -116,15 +273,30 @@ def sink_51() -> list[str]: ' node.description = "电影院空间音频 (5.1 虚拟环绕)"', ' media.name = "Cinema Spatial 5.1"', " filter.graph = {", " nodes = ["] L += hrir_block(" ") + on, wet = reverb_cfg() + outs = '"mixL:Out" "mixR:Out"' + srcs = ("mixL:Out", "mixR:Out") + mono = mono_cfg() + if on: + L += reverb_block(" ", wet) + outs = '"wetL:Out" "wetR:Out"' + srcs = ("wetL:Out", "wetR:Out") + if mono: + L += mono_block(" ") + outs = '"monoOut:Out"' L += [" ]", " links = ["] L += hrir_links(" ") + if on: + L += reverb_links(" ") + if mono: + L += mono_links(" ", srcs) L += [" ]", ' inputs = [ "cpFL:In" "cpFR:In" "cpFC:In" "cpLFE:In" "cpBL:In" "cpBR:In" ]', - ' outputs = [ "mixL:Out" "mixR:Out" ]', " }", + f' outputs = [ {outs} ]', " }", f" capture.props = {{ {VOL_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}' - "audio.channels = 2 audio.position = [ FL FR ] }", + + out_props(mono), " }", " }"] return L @@ -149,6 +321,17 @@ def sink_upmix() -> list[str]: ' { type = builtin label = convolver name = convRR config = { gain = -1.0 delay = 0.012 filename = "/hilbert" length = 33 } }', " # ---- 第二段: 5.1 -> 双耳 HRTF ----"] L += hrir_block(" ") + on, wet = reverb_cfg() + outs = '"mixL:Out" "mixR:Out"' + srcs = ("mixL:Out", "mixR:Out") + mono = mono_cfg() + if on: + L += reverb_block(" ", wet) + outs = '"wetL:Out" "wetR:Out"' + srcs = ("wetL:Out", "wetR:Out") + if mono: + L += mono_block(" ") + outs = '"monoOut:Out"' L += [" ]", " links = [", ' { output = "copyFL:Out" input = "mixF:In 1" }', ' { output = "copyFR:Out" input = "mixF:In 2" }', @@ -168,18 +351,31 @@ def sink_upmix() -> list[str]: ' { output = "convRL:Out" input = "cpBL:In" }', ' { output = "convRR:Out" input = "cpBR:In" }'] L += hrir_links(" ") + if on: + L += reverb_links(" ") + if mono: + L += mono_links(" ", srcs) L += [" ]", ' inputs = [ "copyFL:In" "copyFR:In" ]', - ' outputs = [ "mixL:Out" "mixR:Out" ]', " }", + f' outputs = [ {outs} ]', " }", f" capture.props = {{ {VOL_PROP}node.name = cinema_spatial_up_sink media.class = Audio/Sink " "audio.channels = 2 audio.position = [ FL FR ] }", f' playback.props = {{ node.name = cinema_spatial_up_out {TARGET_PROP}' - "audio.channels = 2 audio.position = [ FL FR ] }", + + out_props(mono), " }", " }"] return L def main() -> int: + global IR + IR = ir_dir() + _p = params_cfg() + _rate = int(_p.get("rate") or 0) # type: ignore[arg-type] + if _rate: + write_clock(_rate) + print("IR 目录: " + IR) + if _p: + print("采样参数: " + json.dumps(_p, ensure_ascii=False)) os.makedirs(os.path.dirname(OUT), exist_ok=True) body: list[str] = ["# 电影院空间音频 自动生成, 改 IR/增益请改 生成配置.py 重跑", "context.modules = ["] body += sink_51() diff --git a/空间音频 b/空间音频 index 8bc7bf8..95d720c 100755 --- a/空间音频 +++ b/空间音频 @@ -1,51 +1,128 @@ #!/bin/bash -# 电影院空间音频 开关 2026-09-12 -# 用法: 空间音频 开 | 开5.1 | 关 | 状态 +# 电影院空间音频 开关 2026-09-12 建 / 2026-09-13 修"响度怪" +# 用法: 空间音频 开 | 开5.1 | 关 | 状态 | 诊断 | 数字 | 模拟 +# 数字/模拟 = 直接切物理输出的 route(数字 S/PDIF 实测音质更好, 推荐), 切完自动保 profile+验连线 # 开 = 立体声上混版(全局推荐; 浏览器/音乐/在线视频等立体声源也会有环绕) # 开5.1 = 5.1 直通版(本地多声道片源; 也可以用 mpv-影院) # -# ★ 为什么"开"的时候要做设备自检: +# ★ 为什么"开"的时候要做设备自检: # filter-chain 的输出端必须显式指向真实设备(node.target)。但 USB 耳机重启/重插后 # profile 可能变(如 iec958-stereo <-> analog-stereo), 设备名随之改变, 配置里的 # 旧名字就失效 -> WirePlumber 回落到其它设备(实测落到 HDMI), 声音就跑了。 # 所以每次"开"都检查一遍: 目标不存在就重新探测并重建配置。 +# +# ★★ 2026-09-13 修的三个"响度怪"根因(顺序点"数字输出/模拟输出"响度不一样): +# ① 重建里那句 restart pipewire 会让 WirePlumber 用它存档的 default-profile 覆盖 +# 当前 profile —— 于是"按当前设备名写好的 target"重启后当场失效。修法: 重建前记下 +# 物理卡的 profile, 重启后按回去, 再校验连线。 +# ② 音量每次"开"都硬写 0.25, 用户在面板上调的值被抹掉。修法: 音量存 +# ~/.local/state/cinema-spatial/volume, 每次"开"先 capture(记下用户改过的值)再套用。 +# ③ 两条 route 音质不同: 数字(S/PDIF) 实测 96kHz/24bit(S24LE), 老板实测"音质比模拟好一大截" +# -> 数字优先; 它的硬件增益基数(1.0)比模拟(1.11428)低 0.94 dB(无感, 需要可补偿)。 +# 默认**跟随你在面板上选的那条**, 也可以用 `空间音频 数字|模拟` 直接切。 set -u STATE="${XDG_RUNTIME_DIR:-/tmp}/cinema-spatial.prev" +STATEF="${HOME}/.local/state/cinema-spatial" +VOLFILE="${STATEF}/volume" CONF="${HOME}/.config/pipewire/pipewire.conf.d/90-cinema-spatial.conf" +REBUILT=0 # 这次"开"是否刚重建过(重建后音量是新节点默认值, 不能拿去覆盖存档) + for g in "${HOME}/桌面/工作区/实验/空间音频/生成配置.py" "/usr/share/cinema-spatial/生成配置.py"; do [ -f "$g" ] && GEN="$g" && break done GEN="${GEN:-}" +for h in "${HOME}/桌面/工作区/实验/空间音频/音频状态.py" "/usr/share/cinema-spatial/音频状态.py"; do + [ -f "$h" ] && HELPER="$h" && break +done +HELPER="${HELPER:-}" num_of() { awk '{for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+\.$/) {print substr($i, 1, length($i) - 1); exit}}'; } id_of() { wpctl status 2>/dev/null | awk -v n="$1" '$0 ~ n {print; exit}' | num_of; } cur_id() { wpctl status 2>/dev/null | sed -n '/Sinks:/,/Sources:/p' | grep ' \* ' | num_of; } nick() { wpctl inspect "$1" 2>/dev/null | grep -oP 'node\.nick = "\K[^"]+' | head -1; } +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; } -# 物理输出设备名: 优先 USB, 跳过虚拟声卡与 HDMI/DP 专业输出 -phys_sink() { - pw-dump 2>/dev/null | python3 -c ' -import sys, json -try: - d = json.load(sys.stdin) -except Exception: - sys.exit(0) -c = [] -for o in d: - if o.get("type") != "PipeWire:Interface:Node": - continue - p = o.get("info", {}).get("props", {}) - if p.get("media.class") != "Audio/Sink": - continue - n = p.get("node.name", "") - if not n or "cinema_spatial" in n or "pro-output" in n: - continue - c.append(n) -print((sorted(c, key=lambda x: (0 if "usb" in x else 1, x)) or [""])[0]) -' 2>/dev/null +# ---- 音量持久化 ---------------------------------------------------------- +read_vol() { + if [ -f "$VOLFILE" ]; then cat "$VOLFILE"; else echo "${CINEMA_SPATIAL_VOLUME:-0.25}"; fi +} +save_vol() { mkdir -p "$STATEF"; printf '%s\n' "$1" > "$VOLFILE"; } + +# 把用户手动调过的音量记下来(下次重建/开机沿用), 差异 >2% 才算"他调过" +capture_vol() { + local id cur saved + id=$(id_of cinema_spatial_up_sink); [ -n "$id" ] || return 0 + cur=$(vol_of_id "$id"); [ -n "$cur" ] || return 0 + saved=$(read_vol) + if awk -v a="$cur" -v b="$saved" 'BEGIN{exit !(a > b * 1.02 || a < b * 0.98)}'; then + save_vol "$cur" + echo " · 记下你调过的音量 $cur (下次重建/开机沿用)" + fi } -conf_target() { grep -oP 'node\.target = "\K[^"]+' "$CONF" 2>/dev/null | head -1; } +# 给两个虚拟声卡套用持久化音量; 数字输出(S/PDIF)硬件增益基数更低 -> 警告 + 单独补偿 +apply_volume() { + local vol; vol="$(read_vol)" + case "$(conf_target)" in + *iec958*|*spdif*) + vol="${CINEMA_SPATIAL_VOLUME_DIGITAL:-$vol}" + echo " · 输出走数字(S/PDIF): 实测音质更好(96k/24bit), 硬件增益基数比模拟低 0.94 dB(无感)" + echo " (要补偿: export CINEMA_SPATIAL_VOLUME_DIGITAL=0.6 这类; 会吃动态余量, 注意别削波)" + ;; + esac + local id + for id in $(wpctl status 2>/dev/null | sed -n '/Filters:/,/Streams:/p' \ + | grep -oE '[0-9]+\. cinema_spatial[a-z_]*_sink' | grep -oE '^[0-9]+'); do + wpctl set-volume "$id" "$vol" >/dev/null 2>&1 + done + save_vol "$vol" +} + +# ---- 等目标设备回来(重启 PipeWire 后蓝牙/USB 通常要 3~8 秒重连) ----------- +relink_to() { # $1=node.name; 0=运行期重连成功(没重启 PipeWire) + # 链的 playback 节点本质是 stream, 端口可以运行期改连 —— 于是切换设备不必重建, + # 也就不必重启 PipeWire(蓝牙设备不会被带掉)。仅声道数一致时可行; + # 单声道设备要改图(filter.graph 输出端口数) → return 1 交回调用方走重建。 + local dev="$1" out="" n + for n in cinema_spatial_up_out cinema_spatial_out; do + [ -n "$(id_of "$n")" ] && { out="$n"; break; } + done + [ -n "$out" ] || return 1 + local ch + ch=$(pw-dump 2>/dev/null | python3 -c ' +import json,sys +w={"'"$dev"'":2,"'"$out"'":2} +for o in json.load(sys.stdin): + pr=((o.get("info") or {}).get("props") or {}) + if pr.get("node.name") in w: w[pr["node.name"]]=int(pr.get("audio.channels") or 2) +print(w["'"$dev"'"],w["'"$out"'"])') + set -- $ch + [ -n "${1:-}" ] && [ "${1:-0}" = "${2:-0}" ] || { echo " · 目标 ${1:-?} 声道 / 链输出 ${2:-?} 声道, 不一致 → 需要重建"; return 1; } + # 拆旧连线(pw-link -l 格式: 来源在上一行, |-> 去向在下一行) + pw-link -l 2>/dev/null | awk -v o="$out" ' + /^[^ \t].*:/ { src=$0 } + /\|->/ { t=$NF; if (src ~ ("^" o ":output_")) print src "\t" t }' | + while IFS=$'\t' read -r s t; do + case "$t" in *:playback_*) pw-link -d "$s" "$t" >/dev/null 2>&1 ;; esac + done + local p + for p in FL FR; do pw-link "$out:output_$p" "$dev:playback_$p" >/dev/null 2>&1; done + if pw-link -l 2>/dev/null | grep -q "$dev:playback_"; then + echo " · 运行期重连成功(没重启 PipeWire) → $dev"; return 0 + fi + echo " ! 运行期重连失败, 回落重建"; return 1 +} + +wait_target() { # $1 = 最多等多少秒; 0 = 回来了, 1 = 超时 + local i=0 n=${1:-15} + while [ "$i" -lt "$n" ]; do + target_alive && return 0 + i=$((i + 1)); sleep 1 + done + return 1 +} # 设备是否真的存在(按 node.name 精确匹配) # ★ 别用 pw-cli info: 它对不存在的名字也返回成功, 会误判成"设备还在" @@ -67,51 +144,378 @@ sys.exit(1) ' "$t" } -# 目标失效 -> 重新探测并重建配置 -# 虚拟声卡音量。★ filter-chain 每次重建后音量都会丢(新节点没有历史音量, 回到 1.0 -# → 整链削波爆音), 所以每次"开"都补写一次。改默认: export CINEMA_SPATIAL_VOLUME=0.3 -apply_volume() { - local vol="${CINEMA_SPATIAL_VOLUME:-0.25}" - for id in $(wpctl status 2>/dev/null | sed -n '/Filters:/,/Streams:/p' \ - | grep -oE '[0-9]+\. cinema_spatial[a-z_]*_sink' | grep -oE '^[0-9]+'); do - wpctl set-volume "$id" "$vol" >/dev/null 2>&1 - done -} - -# 目标失效 -> 重新探测并重建配置 +# ---- 重建 --------------------------------------------------------------- +# 目标失效 -> 重新探测并重建配置. 关键顺序(踩过的坑都在这): +# 1) 先记下"当前活着的物理输出"与其 profile —— 这就是用户选的那个 +# 2) 用 CINEMA_SPATIAL_TARGET 明确告诉生成器写哪个 node.target(别让它去猜默认设备) +# 3) 重启后把 profile 按回去(否则 WirePlumber 用存档 profile 覆盖, target 当场失效) +# 4) 校验 node.target 存在 + 连线真的接上(以前这步缺失: 静默不接也当成功) rebuild_if_stale() { target_alive && return 0 [ -n "$GEN" ] || { echo " ! 配置里的输出设备已失效, 且找不到 生成配置.py, 无法自动修复"; return 1; } - echo " · 输出设备已变化, 正在重新探测并重建配置..." - python3 "$GEN" >/dev/null 2>&1 || { echo " ! 重建失败"; return 1; } - systemctl --user restart pipewire pipewire-pulse wireplumber >/dev/null 2>&1 - sleep 4 - target_alive && echo " · 已修复, 输出指向: $(conf_target)" + + local sink card prof + sink=$(helper phys-sink) + card=$(helper card-of "$sink") + [ -n "$card" ] && prof=$(helper profile "$card" | awk -F'\t' '{print $2}') + echo " · 输出设备已变化(配置里是 $(conf_target)), 重新探测并重建配置..." + [ -n "$sink" ] && echo " · 重建目标: $sink" + CINEMA_SPATIAL_TARGET="${sink}" python3 "$GEN" >/dev/null 2>&1 || { echo " ! 重建失败"; return 1; } + # ★ 重建必须重启 PipeWire(滤波链是配置里的模块, 没有运行时热加载: pw-cli load-module + # 在本机一律 "Could not load module")。而 WirePlumber 是 PipeWire 客户端, 会跟着退出+ + # 被 systemd 拉起 —— 于是蓝牙/USB 设备要几秒才重新出现。原来固定 sleep 4 就判"目标失效" + # → 回退把目标换成别的设备 → 蓝牙音响再也连不上(老板 2026-09-13 报的)。 + # 所以真正的修法是 wait_target: 轮询等目标回来, 最多 20 秒。 + systemctl --user restart pipewire pipewire-pulse >/dev/null 2>&1 + wait_target 20 || echo " · 目标 $(conf_target) 暂未回来, 继续走" + + if [ -n "$card" ] && [ -n "$prof" ]; then + local now; now=$(helper profile "$card" | awk -F'\t' '{print $2}') + if [ "$now" != "$prof" ]; then + echo " · profile 被 WirePlumber 拉回 $now, 按回 $prof" + helper set-profile "$card" "$prof" >/dev/null + sleep 2 + fi + fi + + if target_alive; then + echo " · 已修复, 输出指向: $(conf_target)" + REBUILT=1 + if [ -n "$HELPER" ]; then python3 "$HELPER" check | sed 's/^/ /'; fi + else + echo " ! 重建后 target 仍不存在: $(conf_target)" + echo " 检查: 耳机是否插好 / 是否被切到别的 profile; 手动指定: export CINEMA_SPATIAL_TARGET=" + return 1 + fi } +# 切物理输出的 route(数字/模拟): 比在 GNOME 面板点更稳 —— 切完重建目标、把 profile 按回去、验连线 +switch_route() { + local kind="$1" card cur target v + if [ -z "$HELPER" ]; then echo "缺 音频状态.py, 无法切 route"; return 1; fi + card=$(python3 "$HELPER" card-of "$(python3 "$HELPER" phys-sink)") + [ -n "$card" ] || { echo "找不到物理卡"; return 1; } + cur=$(python3 "$HELPER" route-kind) + if [ "$cur" = "$kind" ]; then + echo " · 输出已经在「$LABEL」路上" + else + echo " · 切 route: ${cur:-未知} -> $kind" + python3 "$HELPER" set-route "$card" "$kind" | sed 's/^/ /' + sleep 2 + fi + target=$(python3 "$HELPER" phys-sink) + 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 + wait_target 20 + python3 "$HELPER" set-route "$card" "$kind" >/dev/null 2>&1 # 重启可能被拉回, 再按一次 + sleep 2 + REBUILT=1 + fi + v=$(id_of cinema_spatial_up_sink) + if [ -z "$v" ]; then echo "虚拟声卡没加载, 检查 $CONF"; return 1; fi + [ "$REBUILT" = "1" ] || capture_vol + wpctl set-default "$v" + apply_volume + echo " · 输出: $(conf_target)" + python3 "$HELPER" check | sed 's/^/ /' +} + +# 列出可用输出设备(★=当前配置里的目标) +list_devices() { + local cur; cur=$(conf_target) + python3 "$HELPER" sinks | while IFS=$'\t' read -r id name desc is_cur; do + [ -n "$name" ] || continue + if [ "$name" = "$cur" ]; then + printf ' ★ %s\n %s\n' "$desc" "$name" + else + printf ' %s\n %s\n' "$desc" "$name" + fi + done +} + +# 切输出设备(按关键词匹配 node.name/描述): 重写 node.target -> 重建 -> 校验 -> 实装(复用"开"的收尾) +# 实测: 目标写错/设备不存在时必须回退, 否则全屋没声音 +switch_device() { + local want="$1" cur matched v + cur=$(conf_target) + if [ -z "$want" ]; then + echo "可用输出设备(★=当前配置目标):" + list_devices + return 0 + fi + matched=$(python3 "$HELPER" sinks | awk -F'\t' -v w="$want" \ + 'index(tolower($2),tolower(w)) || index(tolower($3),tolower(w)) {print $2; exit}') + if [ -z "$matched" ]; then + echo " ! 没有匹配「$want」的输出设备, 现有:" + list_devices + return 1 + fi + if [ "$matched" = "$cur" ]; then + echo " · 输出本来就指向 $matched" + return 0 + fi + echo " · 切输出: ${cur:-自动} → $matched" + # ★ 先试运行期重连(不重启 PipeWire → 蓝牙设备不会被带掉), 失败才回落重建 + if relink_to "$matched"; then + CINEMA_SPATIAL_TARGET="$matched" python3 "$GEN" >/dev/null 2>&1 # 写进配置供下次重启 + REBUILT=1 + apply_volume + echo " · 输出: $(conf_target)" + [ -n "$HELPER" ] && python3 "$HELPER" check | sed 's/^/ /' + return 0 + fi + CINEMA_SPATIAL_TARGET="$matched" python3 "$GEN" >/dev/null 2>&1 \ + || { echo " ! 生成配置失败, 目标未改"; return 1; } + systemctl --user restart pipewire pipewire-pulse >/dev/null 2>&1 + # ★ 不是"睡 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 + wait_target 12 + return 1 + fi + REBUILT=1 + echo " · 已切到: $(conf_target)" + v=$(id_of "$VNAME") + if [ -n "$v" ]; then + wpctl set-default "$v" + apply_volume + else + echo " ! 虚拟声卡 $VNAME 没加载, 检查 $CONF" + fi + echo " · 输出: $(conf_target)" + [ -n "$HELPER" ] && python3 "$HELPER" check | sed 's/^/ /' + return 0 +} + +# ---- 房间混响(镜像法算出的真实房间 IR) ----------------------------------- +# 用法: 空间音频 混响 [开|关|<湿量 0~1>] 不带参数=翻转当前状态 +# 湿量是生成期常量, 所以每次改都要重建滤波链(regen_chain) +REVERB_IR="${CINEMA_SPATIAL_REVERB_IR:-$(dirname "$GEN")/reverb/房间混响IR-96k.wav}" + +read_reverb() { + python3 -c 'import json,os +p=os.path.expanduser("~/.local/state/cinema-spatial/reverb.json") +try: d=json.load(open(p,encoding="utf-8")) +except Exception: d={} +print(1 if d.get("on") else 0, d.get("wet", 0.25))' +} + +save_reverb() { + python3 -c 'import json,os,sys +p=os.path.expanduser("~/.local/state/cinema-spatial/reverb.json") +os.makedirs(os.path.dirname(p),exist_ok=True) +json.dump({"on":sys.argv[1]=="1","wet":round(float(sys.argv[2]),4)},open(p,"w",encoding="utf-8"),ensure_ascii=False,indent=2)' "$1" "$2" +} + +# 强制重建滤波链: 记 profile -> 生成 -> 重启 -> 按回 profile -> 验连线 +regen_chain() { + local sink card prof now v + [ -n "$GEN" ] || { echo " ! 找不到 生成配置.py"; return 1; } + sink=$(conf_target); card=$(helper card-of "$sink") + [ -n "$card" ] && prof=$(helper profile "$card" | awk -F'\t' '{print $2}') + # ★ 必须显式传目标: 不传的话 生成配置.py 会回落到"第一个物理设备", + # 而那往往是显卡的 pro-output(没接屏就是没声的输出) —— 一旦写进配置就固化, + # 之后每次重建都指向死输出(2026-09-13 实测: 链挂在 pro-output-3 的 AUX0 上)。 + local tgt; tgt=$(conf_target) + case "$tgt" in + ""|*pro-output*|*hdmi*) tgt=$(helper phys-sink) ;; # 探测会跳过显卡 Pro 输出 + esac + [ -n "$tgt" ] || tgt=$(conf_target) + 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 + wait_target 20 || echo " · 目标 $(conf_target) 暂未回来, 继续走" + # ★ 会话管理器可能被连续重建搞崩(实测 2026-09-13: GLib 断言 core-dump)。 + # 它一死, monitor 的新连接就建不起来 —— 表现是电平表卡 -120 dBFS 而链路看着全对。 + # 所以重建后必查, 不活就拉起来(自愈)。 + if ! systemctl --user is-active --quiet wireplumber; then + echo " · wireplumber 不在(上次重启崩过), 拉起" + systemctl --user restart wireplumber >/dev/null 2>&1 + sleep 5 + fi + if [ -n "$card" ] && [ -n "$prof" ]; then + now=$(helper profile "$card" | awk -F'\t' '{print $2}') + if [ "$now" != "$prof" ]; then + echo " · profile 被拉回 $now, 按回 $prof" + helper set-profile "$card" "$prof" >/dev/null + sleep 2 + fi + fi + target_alive || { echo " ! 重建后目标失效: $(conf_target)"; return 1; } + v=$(id_of "$VNAME") + if [ -n "$v" ]; then wpctl set-default "$v"; apply_volume; else echo " ! 虚拟声卡 $VNAME 没加载, 检查 $CONF"; fi + echo " · 输出: $(conf_target)" + python3 "$HELPER" check | sed 's/^/ /' +} + +# ---- 采样参数(HRTF 模型 / tap 数 / 采样率) -------------------------------- +# 用法: 空间音频 参数 [hrtf <模型> | taps | rate ] 不带参数=看当前值+可选项 +# 三者都是生成期常量 → 改完必须重建链路 +read_params() { + python3 -c 'import json,os +p=os.path.expanduser("~/.local/state/cinema-spatial/params.json") +try: d=json.load(open(p,encoding="utf-8")) +except Exception: d={} +print(json.dumps(d,ensure_ascii=False) if d else "(默认: hrir/current 软链, 未写参数)")' +} +save_param() { # $1=key $2=value + python3 -c 'import json,os,sys +p=os.path.expanduser("~/.local/state/cinema-spatial/params.json") +os.makedirs(os.path.dirname(p),exist_ok=True) +try: d=json.load(open(p,encoding="utf-8")) +except Exception: d={} +k,v=sys.argv[1],sys.argv[2] +d[k]=int(v) if k in ("taps","rate") else v +json.dump(d,open(p,"w",encoding="utf-8"),ensure_ascii=False,indent=2) +print(" · 参数 "+k+" = "+v)' "$1" "$2" +} +taps_of() { # 打印某个 HRIR 目录的 (帧率 tap) + python3 -c " +import struct +b=open('$1/FL_L.wav','rb').read();pos=12;rate=0 +while pos+8<=len(b): + cid=b[pos:pos+4];sz=struct.unpack('/dev/null | sed 's#.*/taps-##' | tr '\n' ' ')" + echo " 采样率 : 96000 / 48000 / 44100 (改它会重写用户级 91-clock.conf, 不用 sudo)" +} + +# ---- 单声道(单声道蓝牙音响/耳机: L/R 合并成 1 声道) ---------------------- +# 用法: 空间音频 单声道 [自动|开|关] 不带参数 = 看当前 + 用法 +MONOF="$HOME/.local/state/cinema-spatial/mono.json" +read_mono() { + python3 -c 'import json,os +p=os.path.expanduser("~/.local/state/cinema-spatial/mono.json") +try: d=json.load(open(p,encoding="utf-8")) +except Exception: d={} +v=d.get("on") +print("自动(按目标设备声道数判)" if v is None else ("强制开" if v else "强制关"))' +} +save_mono() { + python3 -c 'import json,os,sys +p=os.path.expanduser("~/.local/state/cinema-spatial/mono.json") +v=sys.argv[1] +os.makedirs(os.path.dirname(p),exist_ok=True) +json.dump({} if v=="自动" else {"on": v=="开"}, open(p,"w",encoding="utf-8"), ensure_ascii=False) +print(" · 单声道 = "+v)' "$1" +} + + case "${1:-状态}" in 开|on) VNAME=cinema_spatial_up_sink; LABEL="立体声上混" ;; 开5.1|on51) VNAME=cinema_spatial_sink; LABEL="5.1 直通" ;; 关|off) VNAME=""; LABEL="关" ;; + 数字|digital) ROUTE=digital; LABEL="数字输出" ;; + 模拟|analog) ROUTE=analog; LABEL="模拟输出" ;; + 诊断|check) LABEL="诊断" ;; + 设备|device) VNAME=cinema_spatial_up_sink; LABEL="设备"; DEV="${2:-}" ;; + 混响|reverb) VNAME=cinema_spatial_up_sink; LABEL="混响" ;; + 重建|regen) VNAME=cinema_spatial_up_sink; LABEL="重建" ;; + 单声道|mono) VNAME=cinema_spatial_up_sink; LABEL="单声道" ;; + 参数|params) VNAME=cinema_spatial_up_sink; LABEL="参数"; PKEY="${2:-}"; PVAL="${3:-}" ;; 状态|"") VNAME=""; LABEL="状态" ;; - *) echo "用法: 空间音频 {开|开5.1|关|状态}"; exit 1 ;; + *) echo "用法: 空间音频 {开|开5.1|关|状态|诊断|数字|模拟|设备 [关键词]|混响 [开|关|湿量]|参数 [hrtf|taps|rate]|单声道 [自动|开|关]|重建}"; exit 1 ;; esac -if [ -n "$VNAME" ]; then +if [ "$LABEL" = "诊断" ]; then + if [ -n "$HELPER" ]; then python3 "$HELPER" check; else echo "缺 音频状态.py"; fi +elif [ -n "${ROUTE:-}" ]; then + switch_route "$ROUTE" +elif [ "$LABEL" = "设备" ]; then + switch_device "${DEV:-}" +elif [ "$LABEL" = "混响" ]; then + want="${2:-}" + read -r cur_on cur_wet <<<"$(read_reverb)" + case "$want" in + 开|on|1) on=1; wet="${3:-$cur_wet}" ;; + 关|off|0) on=0; wet="$cur_wet" ;; + "") on=$(( 1 - cur_on )); wet="$cur_wet" ;; + *) on=1; wet="$want" ;; + esac + awk -v w="$wet" 'BEGIN{exit !(w > 0 && w <= 1)}' || wet=0.25 + if [ "$on" = "1" ] && [ ! -f "$REVERB_IR" ]; then + echo " ! 缺少房间 IR: $REVERB_IR" + echo " 先生成: cd ~/桌面/工作区/实验/声学追踪引擎 && .venv/bin/python src/镜像法IR.py" + exit 1 + fi + save_reverb "$on" "$wet" + if [ "$on" = "1" ]; then echo " · 混响 开 (湿量 $wet)"; else echo " · 混响 关"; fi + regen_chain + exit $? +elif [ "$LABEL" = "重建" ]; then + regen_chain + exit $? +elif [ "$LABEL" = "单声道" ]; then + case "${2:-}" in + "") echo " 单声道: $(read_mono)" + echo " 用法: 空间音频 单声道 [自动|开|关]" + echo " 开 = 链输出把 L/R 各 -6dB 相加成 1 声道(单声道蓝牙音响/耳机连不上就是缺这个)" + echo " 自动 = 按目标设备声道数判(1 声道就自动合并)" + exit 0 ;; + 自动|开|关) save_mono "$2" ;; + *) echo " ! 只接受 自动/开/关"; exit 1 ;; + esac + regen_chain + exit $? +elif [ "$LABEL" = "参数" ]; then + if [ -z "${PKEY:-}" ]; then show_params; exit 0; fi + case "$PKEY" in + hrtf|HRTF|模型) + [ -f "$(dirname "$GEN")/hrir/$PVAL/FL_L.wav" ] || { echo " ! 没有这个模型: $PVAL"; show_params; exit 1; } + save_param hrtf "$PVAL" + save_param taps 0 ;; # 模型与截断二选一: 选模型就清掉 taps + taps|tap|截断) + case "$PVAL" in ''|*[!0-9]*) echo " ! taps 要给数字"; exit 1 ;; esac + # taps=0 = 不裁(回到 hrir/current 软链那份), 不做截断 + if [ "$PVAL" != "0" ] && [ ! -d "$(dirname "$GEN")/hrir/taps-$PVAL" ]; then + echo " · 先裁一份 $PVAL tap:" + python3 "$(dirname "$GEN")/work/裁HRIR.py" "$PVAL" | sed 's/^/ /' || exit 1 + fi + save_param taps "$PVAL" + save_param hrtf "" ;; # 选截断就清掉模型名(taps- 优先) + rate|采样率) + case "$PVAL" in 96000|48000|44100) ;; *) echo " ! rate 只支持 96000/48000/44100"; exit 1 ;; esac + save_param rate "$PVAL" ;; + *) echo " ! 未知参数: $PKEY (可用 hrtf/taps/rate)"; exit 1 ;; + esac + regen_chain + exit $? +elif [ -n "$VNAME" ]; then rebuild_if_stale v=$(id_of "$VNAME") if [ -z "$v" ]; then echo "虚拟声卡 $VNAME 没加载。检查 $CONF" exit 1 fi + # 先记用户改过的音量, 再套用(顺序很重要: 反了就把他的值覆盖掉) + # 但刚重建过的新节点音量是默认值, 不能拿它覆盖存档 —— 那会丢掉用户调的音量 + [ "$REBUILT" = "1" ] || capture_vol cur=$(cur_id) [ -n "$cur" ] && echo "$cur" > "$STATE" wpctl set-default "$v" apply_volume - # 耳机自身的音量若被压过, 这里不动它(用户自己的选择) echo "已切到「电影院空间音频 ($LABEL)」(id $v)" - echo " 开之前是「$(nick "${cur:-0}")」" + prev="$(cat "$STATE" 2>/dev/null)" + [ -n "$prev" ] && echo " 开之前是「$(nick "$prev")」" echo " 输出设备 : $(conf_target)" + if [ -n "$HELPER" ]; then python3 "$HELPER" check | sed 's/^/ /'; fi elif [ "$LABEL" = "关" ]; then prev=$(cat "$STATE" 2>/dev/null || true) if [ -n "${prev:-}" ] && wpctl inspect "$prev" >/dev/null 2>&1; then @@ -127,8 +531,7 @@ else echo " 5.1 直通 : id $(id_of cinema_spatial_sink || true)" echo " 立体声上混: id $(id_of cinema_spatial_up_sink || true)" echo "当前默认 : $(wpctl inspect @DEFAULT_AUDIO_SINK@ 2>/dev/null | grep -oP 'node\.description = "\K[^"]+' | head -1)" - t=$(conf_target) - echo "输出设备 : ${t:-<未设置>}" - if target_alive; then echo " ✓ 设备存在"; else echo " ✗ 设备已失效(下次'开'会自动重建)"; fi - echo "物理输出 : $(phys_sink)" + echo "持久化音量: $(read_vol) (${VOLFILE})" + if [ -n "$HELPER" ]; then python3 "$HELPER" check | sed 's/^/ /'; fi fi +# ---- 房间混响(镜像法算出的真实房间 IR) ----------------------------------- diff --git a/音频状态.py b/音频状态.py new file mode 100644 index 0000000..2a7d0a1 --- /dev/null +++ b/音频状态.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 +"""空间音频状态 / 设备 profile 助手. + +给「空间音频」切换脚本与 Web 控制台共用. 零依赖(纯 stdlib + pw-dump/wpctl). + +子命令: + json 状态 JSON(供程序读) + check 人读报告(含"音量账") + phys-sink 当前最合适的物理输出 node.name(USB 优先) + card-of 由 sink 反查所属卡名 + profile 打印该卡当前 profile 名 + 索引 + set-profile 把卡切回指定 profile(名字或索引) + route-kind 当前物理输出走的是 digital(数字/S-PDIF) 还是 analog + set-route digital|analog 按 route 类型切 profile(保住麦克风输入) +""" + +from __future__ import annotations + +import json +import math +import re +import subprocess +import sys +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") +# ★ 硬件增益基数(volumeBase)必须按**活动 Route** 读, 别读 EnumRoute: +# EnumRoute 是能力清单(不带 volumeBase), Route 才是当前/存档状态(带 volumeBase+softVolumes). +# 实测本机(EDIFIER Fit900NB): 模拟 route volumeBase=1.11428(+0.94 dB), +# 数字(iec958) route volumeBase=1.000(0 dB) —— 两条都**有**音量控制, +# 差别只是模拟那条多约 +0.94 dB 硬件增益(不是"数字没有硬件音量", 2026-09-13 修正). +DIGITAL_HINTS = ("iec958", "spdif") + + +def run(cmd: list[str]) -> str: + """跑命令取 stdout(失败返回空串).""" + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=15).stdout + except Exception: + return "" + + +def pw_dump() -> list[dict[str, Any]]: + """pw-dump 的全部对象.""" + out = run(["pw-dump"]) + try: + return json.loads(out) + except Exception: + return [] + + +def nodes(objs: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + """node.name -> 对象.""" + out: dict[str, dict[str, Any]] = {} + for o in objs: + if o.get("type") != "PipeWire:Interface:Node": + continue + p = o.get("info", {}).get("props", {}) + name = p.get("node.name") + if name: + out[name] = o + return out + + +def vol_linear(node_id: int) -> float: + """wpctl 报的是线性音量(用户看到的那个数).""" + m = re.search(r"Volume:\s*([0-9.]+)", run(["wpctl", "get-volume", str(node_id)])) + return float(m.group(1)) if m else 0.0 + + +def db(linear: float) -> float: + """线性音量 -> dB.""" + return 20.0 * math.log10(linear) if linear > 0 else -99.0 + + +def conf_target(conf: Path = DEFAULT_CONF) -> str: + """读配置里的 node.target.""" + try: + txt = conf.read_text(encoding="utf-8") + except OSError: + return "" + m = re.search(r'node\.target = "([^"]+)"', txt) + return m.group(1) if m else "" + + +def profile_of_card(objs: list[dict[str, Any]], card: str) -> dict[str, Any]: + """卡的当前 profile 与硬件音量情况.""" + for o in objs: + if o.get("type") != "PipeWire:Interface:Device": + continue + p = o.get("info", {}).get("props", {}) + if p.get("device.name") != card: + continue + prms = o.get("info", {}).get("params", {}) + prof = (prms.get("Profile") or [{}])[0] + routes: list[dict[str, Any]] = [] + for r in (prms.get("Route") or []): + pr = r.get("props") or {} + if not pr: + continue + routes.append({"kind": "digital" if "iec958Codecs" in pr else "analog", + "base": pr.get("volumeBase")}) + pname = str(prof.get("name", "")) + dig = any(h in pname for h in DIGITAL_HINTS) + base = next((r["base"] for r in routes if (r["kind"] == "digital") == dig), None) + return {"name": pname, "index": prof.get("index", -1), + "description": prof.get("description", ""), + "active_base": base, "route_bases": routes} + return {} + + +def enum_profile(objs: list[dict[str, Any]], card: str, want: str) -> int: + """在 EnumProfile 里按名字(或索引)找 index.""" + if want.isdigit(): + return int(want) + for o in objs: + if o.get("type") != "PipeWire:Interface:Device": + continue + p = o.get("info", {}).get("props", {}) + if p.get("device.name") != card: + continue + for it in o.get("info", {}).get("params", {}).get("EnumProfile") or []: + if it.get("name") == want: + return int(it.get("index", -1)) + return -1 + + +def card_of_sink(objs: list[dict[str, Any]], sink_name: str) -> str: + """由 sink node.name 反查所属卡(device.name).""" + n = nodes(objs).get(sink_name) + if not n: + return "" + dev_id = n.get("info", {}).get("props", {}).get("device.id") + for o in objs: + if o.get("type") == "PipeWire:Interface:Device" and o.get("id") == dev_id: + return o.get("info", {}).get("props", {}).get("device.name", "") + return "" + + +def links_ok() -> tuple[bool, str]: + """当前那条虚拟声卡的 filter 输出端是否真的接上了物理设备. + + ★ 属主名是 cinema_spatial[_up]_out(不带 _sink). 必须认当前生效的那条 —— + 5.1 与上混两条同时存在, 只报"找到了"会把 5.1 的连线当成上混的. + """ + txt = run(["pw-link", "-l"]) + if not txt: + return False, "pw-link 无输出" + want = active_out_name() + cur = "" + found: list[str] = [] + for line in txt.splitlines(): + if not line.startswith((" ", "\t")) and ":" in line: + cur = line.strip() + if "cinema_spatial" in cur and "|->" in line: + found.append(cur) + if want in cur: + return True, cur + if found: + return False, f"接的不是当前那条(要 {want}); 实际: {'; '.join(found)}" + return False, "未找到 cinema_spatial* 的输出连线" + + +def default_sink_name() -> str: + """当前默认输出的 node.name.""" + m = re.search(r'node\.name = "([^"]+)"', run(["wpctl", "inspect", "@DEFAULT_AUDIO_SINK@"])) + return m.group(1) if m else "" + + +def active_out_name() -> str: + """当前生效的 filter 输出端名.""" + return "cinema_spatial_up_out" if "up" in default_sink_name() else "cinema_spatial_out" + + +def state() -> dict[str, Any]: + """汇总状态.""" + objs = pw_dump() + nd = nodes(objs) + target = conf_target() + vols = {n: vol_linear(int(nd[n]["id"])) for n in VOLUME_NODES if n in nd} + 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) + ok, why = links_ok() + return { + "conf_target": target, + "target_alive": target in nd, + "target_is_digital": digital, + "card": card, + "card_profile": prof.get("name", ""), + "card_profile_index": prof.get("index", -1), + "card_route_bases": prof.get("route_bases", []), + "card_active_base": prof.get("active_base"), + "volumes": vols, + "chain_linear": total, + "chain_db": db(total), + "links_ok": ok, + "links_why": why, + } + + +def report() -> str: + """人读报告: 音量账 + 设备情况.""" + s = state() + L: list[str] = [] + L.append(f"输出目标 : {s['conf_target'] or '<未设置>'}" + f"{' ✓ 存在' if s['target_alive'] else ' ✗ 已失效'}") + if s["card"]: + L.append(f"物理卡 : {s['card']}") + L.append(f"当前 profile: {s['card_profile']} (index {s['card_profile_index']}) " + f"硬件增益基数: {s.get('card_active_base') or '?'}") + bases = " / ".join( + f"{'数字(S/PDIF)' if r['kind'] == 'digital' else '模拟'} {r['base']:.5f}" + f" ({db(r['base']):+.2f} dB)" + for r in s.get("card_route_bases", []) if r.get("base")) + if bases: + L.append(f" route 硬件增益基数: {bases}") + if s["target_is_digital"]: + L.append(" · 输出走数字(S/PDIF) route: 实测音质更好(96k/24bit), 其硬件增益基数" + "比模拟低 0.94 dB(无感) —— 要补偿可设 CINEMA_SPATIAL_VOLUME_DIGITAL") + L.append("音量账:") + for k, label in (("cinema_spatial_up_sink", "虚拟声卡(上混)"), + ("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"连线 : {'✓ ' + s['links_why'] if s['links_ok'] else '✗ ' + s['links_why']}") + return "\n".join(L) + + +def phys_sink(objs: list[dict[str, Any]]) -> str: + """最合适的物理输出 sink node.name: USB 优先, 排除虚拟声卡与 HDMI/DP 专业输出.""" + cand: list[str] = [] + for o in objs: + if o.get("type") != "PipeWire:Interface:Node": + continue + p = o.get("info", {}).get("props", {}) + if p.get("media.class") != "Audio/Sink": + continue + n = p.get("node.name", "") + if not n or "cinema_spatial" in n or "pro-output" in n: + continue + cand.append(n) + cand.sort(key=lambda x: (0 if "usb" in x else 1, x)) + return cand[0] if cand else "" + + +def sinks(objs: list[dict[str, Any]]) -> list[dict[str, str]]: + """全部物理输出 sink: id / node.name / 描述 / 是否当前目标 / 类型(usb|pci|pro). + + ★ 保留显卡 Pro/HDMI 输出(手动选它可以把声音送到电视), 但类型标成 pro —— 自动探测 + (phys_sink) 仍跳过 pro, 免得默认落到没接屏的 HDMI 上。 + """ + cur = conf_target() + out: list[dict[str, str]] = [] + for o in objs: + if o.get("type") != "PipeWire:Interface:Node": + continue + p = o.get("info", {}).get("props", {}) + if p.get("media.class") != "Audio/Sink": + continue + n = str(p.get("node.name", "")) + if not n or "cinema_spatial" in n: + continue + kind = "pro" if "pro-output" in n else ("usb" if "usb" in n else "pci") + out.append({"id": str(o.get("id", "")), "name": n, + "desc": str(p.get("node.description", "")) or n, + "current": "1" if n == cur else "0", "kind": kind}) + out.sort(key=lambda d: (0 if d["kind"] == "usb" else (1 if d["kind"] == "pci" else 2), d["name"])) + return out + + +def device_id(objs: list[dict[str, Any]], card: str) -> str: + """按 device.name 找设备的数字 id.""" + for o in objs: + p = o.get("info", {}).get("props", {}) + if o.get("type") == "PipeWire:Interface:Device" and p.get("device.name") == card: + return str(o["id"]) + return "" + + +def profiles_of_card(objs: list[dict[str, Any]], card: str) -> list[dict[str, Any]]: + """该卡可选的全部 profile(EnumProfile: 能力清单).""" + for o in objs: + p = o.get("info", {}).get("props", {}) + if o.get("type") != "PipeWire:Interface:Device" or p.get("device.name") != card: + continue + return list(o.get("info", {}).get("params", {}).get("EnumProfile") or []) + return [] + + +def pick_route_profile(objs: list[dict[str, Any]], card: str, + kind: str) -> dict[str, Any]: + """挑 route 对应的 profile: digital -> iec958-stereo, analog -> analog-stereo. + + 优先带 input:mono-fallback 的那条(别把麦克风切没了). + """ + want = "iec958-stereo" if kind == "digital" else "analog-stereo" + cand = [p for p in profiles_of_card(objs, card) + if str(p.get("name", "")).startswith("output:") and want in str(p.get("name", ""))] + if not cand: + return {} + cand.sort(key=lambda p: (0 if "input:" in str(p.get("name", "")) else 1, + int(p.get("index", 99)))) + return cand[0] + + +def route_kind(objs: list[dict[str, Any]]) -> str: + """当前物理输出走 digital / analog / ''(未知) —— 由 sink 名后缀判定.""" + n = phys_sink(objs) + if any(h in n for h in DIGITAL_HINTS): + return "digital" + return "analog" if "analog" in n else "" + + +def main() -> int: + args = sys.argv[1:] + cmd = args[0] if args else "check" + if cmd == "json": + print(json.dumps(state(), ensure_ascii=False, indent=1)) + elif cmd == "check": + print(report()) + elif cmd == "phys-sink": + print(phys_sink(pw_dump())) + elif cmd == "card-of": + print(card_of_sink(pw_dump(), args[1])) + elif cmd == "profile": + card = args[1] + prof = profile_of_card(pw_dump(), card) + print(f"{prof.get('index', -1)}\t{prof.get('name', '')}") + elif cmd == "set-profile": + card, want = args[1], args[2] + objs = pw_dump() + idx = enum_profile(objs, card, want) + if idx < 0: + print(f"找不到 profile: {card} / {want}") + return 1 + dev_id = device_id(objs, card) + if not dev_id: + print(f"找不到卡: {card}") + return 1 + run(["wpctl", "set-profile", dev_id, str(idx)]) + print(f"已把 {card} 切到 profile index {idx}") + elif cmd == "route-kind": + print(route_kind(pw_dump())) + elif cmd == "sinks": + for d in sinks(pw_dump()): + print("\t".join([d["id"], d["name"], d["desc"], d["current"], d["kind"]])) + elif cmd == "set-route": + card, kind = args[1], args[2] + objs = pw_dump() + prof = pick_route_profile(objs, card, kind) + dev_id = device_id(objs, card) + if not prof or not dev_id: + print(f"切不了: card={card} kind={kind} (找不到可用 profile 或卡)") + return 1 + run(["wpctl", "set-profile", dev_id, str(prof["index"])]) + print(f"已切: {prof.get('name', '')} (index {prof.get('index')})") + else: + print(__doc__) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/验收-单声道+目标修复.png b/验收-单声道+目标修复.png new file mode 100644 index 0000000..1421190 Binary files /dev/null and b/验收-单声道+目标修复.png differ diff --git a/验收-单声道开关.png b/验收-单声道开关.png new file mode 100644 index 0000000..1ed3ff5 Binary files /dev/null and b/验收-单声道开关.png differ diff --git a/验收-扬声器电平.png b/验收-扬声器电平.png new file mode 100644 index 0000000..c4a36cb Binary files /dev/null and b/验收-扬声器电平.png differ diff --git a/验收-核心技术上web.png b/验收-核心技术上web.png new file mode 100644 index 0000000..321af64 Binary files /dev/null and b/验收-核心技术上web.png differ diff --git a/验收-电平不用麦克风.png b/验收-电平不用麦克风.png new file mode 100644 index 0000000..ac6a7ae Binary files /dev/null and b/验收-电平不用麦克风.png differ diff --git a/验收-输入增益.png b/验收-输入增益.png new file mode 100644 index 0000000..f1031c8 Binary files /dev/null and b/验收-输入增益.png differ diff --git a/验收-面板-参数可编辑.png b/验收-面板-参数可编辑.png new file mode 100644 index 0000000..77a5b78 Binary files /dev/null and b/验收-面板-参数可编辑.png differ diff --git a/验收-面板-宽窗.png b/验收-面板-宽窗.png new file mode 100644 index 0000000..1b9f254 Binary files /dev/null and b/验收-面板-宽窗.png differ diff --git a/验收-面板-带混响.png b/验收-面板-带混响.png new file mode 100644 index 0000000..383dfe4 Binary files /dev/null and b/验收-面板-带混响.png differ diff --git a/验收-面板-按设计稿.png b/验收-面板-按设计稿.png new file mode 100644 index 0000000..e729cd5 Binary files /dev/null and b/验收-面板-按设计稿.png differ diff --git a/验收-面板-电平修复.png b/验收-面板-电平修复.png new file mode 100644 index 0000000..98f7ea7 Binary files /dev/null and b/验收-面板-电平修复.png differ diff --git a/验收-面板-窄窗.png b/验收-面板-窄窗.png new file mode 100644 index 0000000..6635abf Binary files /dev/null and b/验收-面板-窄窗.png differ diff --git a/验收-面板-终版.png b/验收-面板-终版.png new file mode 100644 index 0000000..36ff864 Binary files /dev/null and b/验收-面板-终版.png differ