初始化: 耳机电影院空间音频(PipeWire filter-chain + SADIE-II HRTF)
- 两个虚拟声卡: 5.1 直通版 / 立体声矩阵上混版 - 全链路 96kHz/24bit, SADIE-II 真人 HRTF 96K/512tap - 命令: 空间音频(开关) / mpv-影院(单程序) - deb 打包 + 验收测试脚本 - README 记录 9 个实测踩坑(增益校准/采样率/相位等)
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
# HRTF 数据集(体积大, 自行下载, 见 README)
|
||||
sofa/
|
||||
|
||||
# 生成的脉冲响应(跑 换HRTF.sh 重新生成)
|
||||
hrir/
|
||||
|
||||
# 测试/演示产物
|
||||
*.wav
|
||||
方向测试/
|
||||
验证_*.log
|
||||
|
||||
# 构建产物
|
||||
*.deb
|
||||
/tmp*
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 edgevoid
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Cinema Spatial —— 耳机上的电影院空间音频
|
||||
|
||||
把多声道音轨(5.1/7.1)用 HRTF 渲染成双耳,耳机上得到接近电影院 C 位的环绕包围感。
|
||||
立体声源会先经过矩阵上混(杜比 Pro Logic 原理)再渲染,所以浏览器、音乐播放器同样有空间感。
|
||||
|
||||
## 原理
|
||||
|
||||
```
|
||||
多声道源 ──┐
|
||||
├─> [矩阵上混 5.1] ─> 6 路 ─> [每路与人头脉冲响应卷积] ─> 双耳混音 ─> 耳机
|
||||
立体声源 ──┘ (SADIE-II 真人 HRTF)
|
||||
```
|
||||
|
||||
- **HRTF**(Head-Related Transfer Function,头相关传输函数):同一方向的声音到达两耳时,
|
||||
会被人头、耳廓滤波,产生不同的时间差、强度差和频谱染色 —— 大脑靠这些线索判断方位。
|
||||
把这些滤波做成脉冲响应(IR),与声道信号卷积,就能在耳机上"伪造"出方位。
|
||||
- **虚拟声卡**:用 PipeWire 的 `filter-chain` 模块建两个 `Audio/Sink`,程序把声音送进来,
|
||||
内部渲染完再输出到真实设备。全局生效,任何播放器都能用。
|
||||
|
||||
## 特性
|
||||
|
||||
- 全链路 **96kHz / 24bit**(HRTF 采用 SADIE-II `96K/24bit/512tap` 真人数据)
|
||||
- 立体声源自动矩阵上混,不需要多声道片源
|
||||
- 提供 deb 包,系统级安装
|
||||
- 纯脚本 + 数据,无编译,`Architecture: all`
|
||||
|
||||
## 安装
|
||||
|
||||
### 方式一:deb 包
|
||||
|
||||
```sh
|
||||
sudo dpkg -i collaplex-cinema-spatial_1.0.0_all.deb
|
||||
# 依赖: pipewire libmysofa1 mpv
|
||||
```
|
||||
|
||||
配置装在 `/usr/share/pipewire/pipewire.conf.d/`,对所有用户生效。
|
||||
**生效**:`systemctl --user restart pipewire pipewire-pulse wireplumber`(或注销重登)
|
||||
|
||||
### 方式二:从源码
|
||||
|
||||
```sh
|
||||
# 1. 下载 HRTF 数据集(不随仓库分发)
|
||||
mkdir -p sofa && cd sofa
|
||||
curl -L -O https://zenodo.org/records/12092466/files/H4_HRIR_SOFA.zip
|
||||
unzip H4_HRIR_SOFA.zip && cd ..
|
||||
|
||||
# 2. 从 SOFA 提取脉冲响应(12 个 = 6 方向 x 左右耳)
|
||||
bash 换HRTF.sh "$PWD/sofa/H4_HRIR_SOFA/H4_HRIR_SOFA/H4_96K_24bit_512tap_FIR_SOFA.sofa" H4-96k -16
|
||||
ln -sfn H4-96k hrir/current
|
||||
|
||||
# 3. 生成 PipeWire 配置
|
||||
python3 生成配置.py
|
||||
systemctl --user restart pipewire pipewire-pulse wireplumber
|
||||
```
|
||||
|
||||
## 用法
|
||||
|
||||
```sh
|
||||
空间音频 开 # 全局切到立体声上混版(推荐)
|
||||
空间音频 开5.1 # 全局切到 5.1 直通版
|
||||
空间音频 关 # 切回物理设备
|
||||
空间音频 状态 # 查看两个虚拟声卡和当前默认
|
||||
|
||||
mpv-影院 电影.mkv # 只给 mpv 用, 不依赖全局设置(适合 A/B 对比)
|
||||
```
|
||||
|
||||
也可以直接在桌面环境的"声音设置"里选 **电影院空间音频**。
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `空间音频` | 开关命令 |
|
||||
| `mpv-影院` | mpv 专用的 HRTF 播放包装 |
|
||||
| `生成配置.py` | 生成 PipeWire filter-chain 配置 |
|
||||
| `换HRTF.sh` | 从 SOFA 提取脉冲响应(可换任意 HRTF 模型)|
|
||||
| `提取HRIR.sh` | 早期版本(已被 `换HRTF.sh` 取代)|
|
||||
| `打包deb.sh` | 打包 `collaplex-cinema-spatial` |
|
||||
| `验收测试.sh` | 安装后自动验证 |
|
||||
| `demo.sh` / `响度校准.sh` / `方向测试.sh` | 开发期验证工具 |
|
||||
| `对比分析.py` / `排查.sh` | 开发期排查工具 |
|
||||
|
||||
## 踩过的坑(均为实测)
|
||||
|
||||
1. **增益必须按"整链"算**:矩阵上混出来的 6 路会在混音器里相加,实测**整链净增益约 +10.5dB**。
|
||||
只按单路脉冲响应峰值校准,一听真实内容就削波爆音。
|
||||
2. **校准素材必须接近满刻度**:自造测试信号(峰值 -12dB)与真实母带(≈0dBFS)差 10dB 以上,
|
||||
用它校出的增益必然炸。
|
||||
3. **不同 SOFA 的电平差异极大**:同一数据集内,D1(假头)IR 峰值 -11.6dB、H4(真人)-3.1dB。
|
||||
**换模型必须重新量峰值**,目标压在 -12dB 左右。
|
||||
4. **`sofalizer` 按 SOFA 自身的采样率输出**:不管输入 48k 还是 96k,只要 SOFA 是 44.1k,
|
||||
输出就掉到 44.1k —— 这是"采样率偏低"的根因。要么在滤镜链尾 `aresample` 拉回,
|
||||
要么用原生高采样率的数据集。
|
||||
5. **`convolver` 按当前时钟率重采样脉冲响应**:主时钟还是 48k 的话,96k/512tap 的 IR
|
||||
会被降成等效 256tap,等于白换。必须把 `default.clock.rate` 一起提到 96k。
|
||||
6. **`node.passive = true` 必须配 `node.target`**:否则 filter-chain 的输出端不连任何设备,
|
||||
声音凭空消失。
|
||||
7. **ffmpeg 滤镜表达式里的 `,` 和 `|`** 要用单引号包住,否则会被当分隔符解析。
|
||||
8. **`adelay` + `join` 合成多声道素材不可靠**:曾静默产出 0.25 秒的单声道文件。
|
||||
改用 `aevalsrc` 直接合成。
|
||||
9. **`type=time` 是时域卷积(保真)**;默认的 `type=freq` 走 FFT,会削高频。
|
||||
|
||||
## 数据来源
|
||||
|
||||
HRTF 采用 **[SADIE II](https://www.york.ac.uk/sadie-project/database.html)**
|
||||
(University of York,Zenodo DOI [10.5281/zenodo.12092466](https://doi.org/10.5281/zenodo.12092466)),
|
||||
使用其中真人受试者 **H4** 的 `96K/24bit/512tap` 版本。
|
||||
|
||||
引用方式见数据集页面。数据集**不随本仓库分发**,请从 Zenodo 自行下载。
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# 电影院空间音频 demo v2 (2026-09-12)
|
||||
# v1 教训: join+adelay 链没生效却因为 -loglevel error 看不出来, 交付了个假货。
|
||||
# v2 换 aevalsrc 直接合成 6 声道, 且每一步都用能量分析验证。
|
||||
set -eu
|
||||
cd "/home/lou/桌面/工作区/实验/空间音频"
|
||||
SOFA=/usr/share/libmysofa/MIT_KEMAR_normal_pinna.sofa
|
||||
|
||||
echo "===== 1. 造 5.1 素材: 6 个方向依次响(每声道一个频率) ====="
|
||||
echo " FL 440Hz | FR 554Hz | FC 659Hz | LFE 80Hz | BL 880Hz | BR 1046Hz"
|
||||
ffmpeg -y -hide_banner -loglevel warning \
|
||||
-f lavfi -i "aevalsrc='0.6*sin(2*PI*440*t)*between(t,0,0.25)|0.6*sin(2*PI*554*t)*between(t,0.45,0.70)|0.6*sin(2*PI*659*t)*between(t,0.90,1.15)|0.6*sin(2*PI*80*t)*between(t,1.35,1.60)|0.6*sin(2*PI*880*t)*between(t,1.80,2.05)|0.6*sin(2*PI*1046*t)*between(t,2.25,2.50)':d=2.6:s=48000:c=5.1" \
|
||||
-c:a pcm_s16le 51-test.wav
|
||||
|
||||
echo
|
||||
echo "===== 2. 验证素材: 每 1/4 秒应该只有一个声道有能量 ====="
|
||||
python3 - <<'PY'
|
||||
import struct, wave
|
||||
with wave.open("51-test.wav") as w:
|
||||
ch, n = w.getnchannels(), w.getnframes()
|
||||
raw = w.readframes(n)
|
||||
s = struct.unpack("<%dh" % (len(raw) // 2), raw)
|
||||
print(" 声道数 %d, 时长 %.2fs" % (ch, len(s) / ch / 48000))
|
||||
seg = 48000 // 4
|
||||
names = ["FL", "FR", "FC", "LFE", "BL", "BR"]
|
||||
for k in range(ch):
|
||||
c = [float(s[i * ch + k]) for i in range(len(s) // ch)]
|
||||
blocks = ["%8.0f" % (sum(x * x for x in c[b:b + seg]) ** 0.5) for b in range(0, len(c), seg)]
|
||||
print(" %-4s %s" % (names[k], " ".join(blocks)))
|
||||
PY
|
||||
|
||||
echo
|
||||
echo "===== 3. A 版: 普通下混(平时耳机听 5.1) ====="
|
||||
ffmpeg -y -hide_banner -loglevel warning -i 51-test.wav -ac 2 -c:a pcm_s16le "A-普通下混.wav"
|
||||
|
||||
echo "===== 4. B 版: HRTF 双耳渲染(电影院包围感, gain=10 补回卷积摊掉的响度) ====="
|
||||
ffmpeg -y -hide_banner -loglevel warning -i 51-test.wav \
|
||||
-af "sofalizer=sofa=$SOFA:type=freq:radius=1.2:gain=10" -c:a pcm_s16le "B-HRTF环绕.wav"
|
||||
|
||||
echo
|
||||
echo "===== 5. 双耳指标: 各段 L/R 能量比 ====="
|
||||
python3 - <<'PY'
|
||||
import struct, wave
|
||||
for f in ("A-普通下混.wav", "B-HRTF环绕.wav"):
|
||||
with wave.open(f) as w:
|
||||
ch, n = w.getnchannels(), w.getnframes()
|
||||
raw = w.readframes(n)
|
||||
s = struct.unpack("<%dh" % (len(raw) // 2), raw)
|
||||
left = [float(s[i * 2]) for i in range(len(s) // 2)]
|
||||
right = [float(s[i * 2 + 1]) for i in range(len(s) // 2)]
|
||||
seg = 48000 // 4
|
||||
out = []
|
||||
for b in range(0, len(left), seg):
|
||||
el = sum(x * x for x in left[b:b + seg]) ** 0.5
|
||||
er = sum(x * x for x in right[b:b + seg]) ** 0.5
|
||||
out.append("%5.2f" % (el / er if er else 99.9))
|
||||
print(" %-18s %s" % (f, " ".join(out)))
|
||||
print(" (段序: FL FR FC LFE BL BR; 比值 >1 偏左耳, <1 偏右耳)")
|
||||
PY
|
||||
echo
|
||||
ls -la *.wav | awk '{printf " %-22s %6.0f KB\n", $9, $5/1024}'
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
# 切换到 SADIE-II H4 96K/24bit/512tap + 音量校准 + 96k 时钟 (2026-09-12)
|
||||
set -eu
|
||||
cd "/home/lou/桌面/工作区/实验/空间音频"
|
||||
SOFA96="$PWD/sofa/H4_HRIR_SOFA/H4_HRIR_SOFA/H4_96K_24bit_512tap_FIR_SOFA.sofa"
|
||||
|
||||
echo "=== 1. 提取 96K...[truncated]
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# 响度校准: HRTF 卷积会把能量摊薄, 算准该补多少 dB 再补, 别拍脑袋
|
||||
set -eu
|
||||
cd "/home/lou/桌面/工作区/实验/空间音频"
|
||||
SOFA=/usr/share/libmysofa/MIT_KEMAR_normal_pinna.sofa
|
||||
|
||||
echo "===== 1. 量现有两版的响度 ====="
|
||||
python3 - <<'PY'
|
||||
import math
|
||||
import struct
|
||||
import wave
|
||||
|
||||
|
||||
def stat(path: str) -> tuple[float, float]:
|
||||
"""返回 (峰值, 均方根), 都归一化到 1.0。"""
|
||||
with wave.open(path) as w:
|
||||
channels, frames = w.getnchannels(), w.getnframes()
|
||||
raw = w.readframes(frames)
|
||||
data = struct.unpack("<%dh" % (len(raw) // 2), raw)
|
||||
peak = max(abs(x) for x in data) / 32768.0
|
||||
rms = (sum(x * x for x in data) / len(data)) ** 0.5 / 32768.0
|
||||
return peak, rms
|
||||
|
||||
|
||||
peak_a, rms_a = stat("A-普通下混.wav")
|
||||
peak_b, rms_b = stat("B-HRTF环绕.wav")
|
||||
need = 20 * math.log10(rms_a / rms_b)
|
||||
print(" A 普通下混 峰值 %.3f (%.1f dBFS) 均方根 %.4f (%.1f dBFS)"
|
||||
% (peak_a, 20 * math.log10(peak_a), rms_a, 20 * math.log10(rms_a)))
|
||||
print(" B HRTF 峰值 %.3f (%.1f dBFS) 均方根 %.4f (%.1f dBFS)"
|
||||
% (peak_b, 20 * math.log10(peak_b), rms_b, 20 * math.log10(rms_b)))
|
||||
print(" => 需补 %.1f dB; 补后 B 峰值约 %.1f dBFS" % (need, 20 * math.log10(peak_b) + need))
|
||||
PY
|
||||
|
||||
echo
|
||||
echo "===== 2. 按算出的量重渲染 B 版 ====="
|
||||
GAIN=$(python3 -c "
|
||||
import math, struct, wave
|
||||
def rms(p):
|
||||
with wave.open(p) as w:
|
||||
c, n = w.getnchannels(), w.getnframes()
|
||||
raw = w.readframes(n)
|
||||
d = struct.unpack('<%dh' % (len(raw)//2), raw)
|
||||
return (sum(x*x for x in d)/len(d))**0.5
|
||||
print('%.1f' % (20*math.log10(rms('A-普通下混.wav')/rms('B-HRTF环绕.wav'))))
|
||||
")
|
||||
echo " gain = ${GAIN} dB"
|
||||
ffmpeg -y -hide_banner -loglevel warning -i 51-test.wav \
|
||||
-af "sofalizer=sofa=$SOFA:type=freq:radius=1.2:gain=${GAIN}" \
|
||||
-c:a pcm_s16le "B-HRTF环绕.wav"
|
||||
|
||||
echo
|
||||
echo "===== 3. 复测(峰值别顶到 0dBFS, 否则削波) ====="
|
||||
python3 - <<'PY'
|
||||
import math
|
||||
import struct
|
||||
import wave
|
||||
|
||||
for f in ("A-普通下混.wav", "B-HRTF环绕.wav"):
|
||||
with wave.open(f) as w:
|
||||
channels, frames = w.getnchannels(), w.getnframes()
|
||||
raw = w.readframes(frames)
|
||||
data = struct.unpack("<%dh" % (len(raw) // 2), raw)
|
||||
peak = max(abs(x) for x in data) / 32768.0
|
||||
rms = (sum(x * x for x in data) / len(data)) ** 0.5 / 32768.0
|
||||
clip = sum(1 for x in data if abs(x) >= 32767)
|
||||
print(" %-18s 峰值 %.1f dBFS 均方根 %.1f dBFS 削波点 %d"
|
||||
% (f, 20 * math.log10(peak), 20 * math.log10(rms), clip))
|
||||
PY
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""对比 A/B 两版的双耳空间分离度。
|
||||
|
||||
指标: 左右声道去均值后的相关系数。
|
||||
接近 1.0 = 两耳听到的几乎一样 -> 声音"钉在头中间"(普通耳机听 5.1 的听感)
|
||||
明显偏低 = 两耳有差异 -> 携带了方位信息(HRTF 起了作用)
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import wave
|
||||
|
||||
|
||||
def read_stereo(path: str) -> tuple[list[float], list[float]]:
|
||||
"""读 wav 的前两个声道。"""
|
||||
with wave.open(path) as w:
|
||||
channels = w.getnchannels()
|
||||
raw = w.readframes(w.getnframes())
|
||||
frames = struct.unpack("<%dh" % (len(raw) // 2), raw)
|
||||
left: list[float] = []
|
||||
right: list[float] = []
|
||||
for i in range(0, len(frames) - channels + 1, channels):
|
||||
left.append(float(frames[i]))
|
||||
right.append(float(frames[i + 1]) if channels > 1 else float(frames[i]))
|
||||
return left, right
|
||||
|
||||
|
||||
def centered(xs: list[float]) -> list[float]:
|
||||
"""去均值。"""
|
||||
if not xs:
|
||||
return []
|
||||
mean = sum(xs) / len(xs)
|
||||
return [x - mean for x in xs]
|
||||
|
||||
|
||||
def correlation(a: list[float], b: list[float]) -> float:
|
||||
"""皮尔逊相关系数。"""
|
||||
num = sum(x * y for x, y in zip(a, b))
|
||||
da = sum(x * x for x in a) ** 0.5
|
||||
db = sum(y * y for y in b) ** 0.5
|
||||
return num / (da * db) if da and db else 0.0
|
||||
|
||||
|
||||
def rms(xs: list[float]) -> float:
|
||||
"""均方根。"""
|
||||
return (sum(x * x for x in xs) / len(xs)) ** 0.5 if xs else 0.0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
print("%-22s %10s %10s %10s" % ("文件", "相关性", "L 均方根", "R 均方根"))
|
||||
print("-" * 58)
|
||||
for name in ("A-普通下混.wav", "B-HRTF环绕.wav"):
|
||||
if not os.path.isfile(name):
|
||||
continue
|
||||
left, right = read_stereo(name)
|
||||
print("%-22s %10.3f %10.1f %10.1f"
|
||||
% (name, correlation(centered(left), centered(right)), rms(left), rms(right)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
# 把电影院空间音频全链打包成 deb (2026-09-12)
|
||||
# 包名 collaplex-cinema-spatial, Arch: all (纯脚本+数据, 无编译)
|
||||
set -eu
|
||||
SRC="/home/lou/桌面/工作区/实验/空间音频"
|
||||
PKG="collaplex-cinema-spatial"
|
||||
VER="1.0.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"
|
||||
|
||||
echo "=== 1. 准备目录 ==="
|
||||
rm -rf "$BUILD"
|
||||
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-影院"
|
||||
chmod 755 "$BUILD/usr/bin/空间音频" "$BUILD/usr/bin/mpv-影院"
|
||||
|
||||
echo "=== 3. HRTF 数据 (12 个 IR + SOFA) ==="
|
||||
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}/"
|
||||
chmod 755 "$BUILD${ROOT}/换HRTF.sh"
|
||||
|
||||
echo "=== 5. PipeWire 配置 (用安装路径生成) ==="
|
||||
CINEMA_SPATIAL_IR="${ROOT}/hrir" \
|
||||
CINEMA_SPATIAL_OUT="$BUILD/usr/share/pipewire/pipewire.conf.d/90-cinema-spatial.conf" \
|
||||
python3 "$SRC/生成配置.py"
|
||||
cp "$HOME/.config/pipewire/pipewire.conf.d/91-clock.conf" \
|
||||
"$BUILD/usr/share/pipewire/pipewire.conf.d/"
|
||||
|
||||
echo "=== 6. control ==="
|
||||
cat > "$BUILD/DEBIAN/control" <<'EOF'
|
||||
Source: collaplex-cinema-spatial
|
||||
Package: collaplex-cinema-spatial
|
||||
Section: sound
|
||||
Priority: optional
|
||||
Architecture: all
|
||||
Version: 1.0.0
|
||||
Maintainer: edgevoid <edgevoid@users.noreply.github.com>
|
||||
Depends: pipewire, libmysofa1, mpv
|
||||
Description: Cinema spatial audio for headphones (HRTF virtual surround)
|
||||
Renders audio into binaural using a SADIE-II human HRTF through a PipeWire
|
||||
filter-chain virtual sink, so ordinary headphones give a cinema-like
|
||||
sense of direction and space.
|
||||
.
|
||||
Provides two virtual sinks (5.1 passthrough and stereo-upmixed) plus the
|
||||
space-audio and mpv-cinema commands.
|
||||
EOF
|
||||
|
||||
echo "=== 7. 打包 ==="
|
||||
cat > "$BUILD/DEBIAN/postinst" <<'EOF'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ "$1" = "configure" ]; then
|
||||
echo "collaplex-cinema-spatial 已安装。"
|
||||
echo " 命令: 空间音频 开|开5.1|关|状态 mpv-影院 电影.mkv"
|
||||
echo " 虚拟声卡定义: /usr/share/pipewire/pipewire.conf.d/"
|
||||
echo " 生效: systemctl --user restart pipewire pipewire-pulse wireplumber"
|
||||
fi
|
||||
EOF
|
||||
chmod 755 "$BUILD/DEBIAN/postinst"
|
||||
dpkg-deb -b --root-owner-group "$BUILD" "$HOME/桌面/${PKG}_${VER}_all.deb"
|
||||
echo
|
||||
echo "=== 结果 ==="
|
||||
ls -lh "$HOME/桌面/${PKG}_${VER}_all.deb"
|
||||
echo
|
||||
dn=$(dpkg-deb -c "$HOME/桌面/${PKG}_${VER}_all.deb" | wc -l)
|
||||
echo "包内文件数: $dn"
|
||||
dpkg-deb -c "$HOME/桌面/${PKG}_${VER}_all.deb" | awk '{print $6}' | head -30
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# 从指定 SOFA 提取 12 个单声道 IR 给 PipeWire convolver 用
|
||||
# 用法: 换HRTF.sh <sofa文件> <输出目录名>
|
||||
# 例: 换HRTF.sh H4_..._48K_24bit_256tap_FIR_SOFA.sofa H4-48k
|
||||
# 提取方式: 给 5.1 里只有一路单位脉冲的输入, 让 sofalizer 时域渲染,
|
||||
# 输出即该方向到两耳的脉冲响应。
|
||||
# ★ 增益必须按 SOFA 实测再给: MIT KEMAR 电平低, +10dB 才不小声;
|
||||
# 而 SADIE-II 的 IR 本身就顶到 0dBFS, 再加就削波爆音。默认 0。
|
||||
set -eu
|
||||
SOFA="$(readlink -f "$1")"
|
||||
NAME="$2"
|
||||
GAIN="${3:-0}"
|
||||
BASE="/home/lou/桌面/工作区/实验/空间音频"
|
||||
OUT="$BASE/hrir/$NAME"
|
||||
[ -f "$SOFA" ] || { echo "找不到 SOFA: $SOFA"; exit 1; }
|
||||
|
||||
rm -rf "$OUT"; mkdir -p "$OUT"
|
||||
cd "$OUT"
|
||||
|
||||
i=0
|
||||
for name in FL FR FC LFE BL BR; do
|
||||
exp=""
|
||||
for j in 0 1 2 3 4 5; do
|
||||
[ "$j" -gt 0 ] && exp="$exp|"
|
||||
if [ "$j" -eq "$i" ]; then exp="${exp}between(t,0,1/48000)"; else exp="${exp}0"; fi
|
||||
done
|
||||
ffmpeg -y -hide_banner -loglevel error -f lavfi \
|
||||
-i "aevalsrc='${exp}':d=0.05:s=48000:c=5.1" \
|
||||
-af "sofalizer=sofa=${SOFA}:type=time:gain=${GAIN}" -c:a pcm_f32le "${name}_bi.wav"
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
for f in FL FR FC LFE BL BR; do
|
||||
ffmpeg -y -hide_banner -loglevel error -i "${f}_bi.wav" -af "pan=mono|c0=c0" -c:a pcm_f32le "${f}_L.wav"
|
||||
ffmpeg -y -hide_banner -loglevel error -i "${f}_bi.wav" -af "pan=mono|c0=c1" -c:a pcm_f32le "${f}_R.wav"
|
||||
rm -f "${f}_bi.wav"
|
||||
done
|
||||
|
||||
echo "完成 -> $OUT"
|
||||
printf " %-8s %s Hz\n" "$(ls *.wav | head -1)" "$(ffprobe -v error -show_entries stream=sample_rate -of csv=p=0 FL_L.wav)"
|
||||
echo " 文件数: $(ls *.wav | wc -l)"
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# 排查 sofalizer 为什么没做出双耳差异
|
||||
set -u
|
||||
cd "/home/lou/桌面/工作区/实验/空间音频"
|
||||
SOFA=/usr/share/libmysofa/MIT_KEMAR_normal_pinna.sofa
|
||||
|
||||
echo "===== 1. sofalizer 支持哪些参数 ====="
|
||||
ffmpeg -hide_banner -h filter=sofalizer 2>&1 | sed -n '1,45p'
|
||||
|
||||
echo
|
||||
echo "===== 2. 详细日志跑一次(找 hrtf/layout/channel 相关) ====="
|
||||
ffmpeg -y -v verbose -i 51-test.wav \
|
||||
-af "sofalizer=sofa=$SOFA:type=freq:radius=1.2" \
|
||||
-c:a pcm_s16le /tmp/b2.wav 2>&1 | grep -iE "sofal|hrtf|layout|channel|error|warn|invalid|fail" | head -25
|
||||
|
||||
echo
|
||||
echo "===== 3. 输出到底是什么布局 ====="
|
||||
ffprobe -v error -show_entries stream=channels,channel_layout,codec_name -of default=nw=1 /tmp/b2.wav
|
||||
|
||||
echo
|
||||
echo "===== 4. 单方向测试: 只有左前一个脉冲, 渲染后左耳该明显响于右耳 ====="
|
||||
ffmpeg -y -hide_banner -loglevel error -f lavfi -i "anoisesrc=d=0.3:c=pink:a=0.7:r=48000" \
|
||||
-af "pan=5.1|FL=c0|FR=0*c0|FC=0*c0|LFE=0*c0|BL=0*c0|BR=0*c0" -t 0.5 -c:a pcm_s16le /tmp/onlyFL.wav
|
||||
ffmpeg -y -hide_banner -loglevel error -i /tmp/onlyFL.wav \
|
||||
-af "sofalizer=sofa=$SOFA:type=freq:radius=1.2" -c:a pcm_s16le /tmp/FL_hrtf.wav
|
||||
python3 - <<'PY'
|
||||
import struct, wave
|
||||
def chan(p, i):
|
||||
with wave.open(p) as w:
|
||||
ch, n = w.getnchannels(), w.getnframes()
|
||||
raw = w.readframes(n)
|
||||
s = struct.unpack("<%dh" % (len(raw)//2), raw)
|
||||
return [float(s[j]) for j in range(i, len(s), ch)]
|
||||
for label, path in (("输入(只有FL)", "/tmp/onlyFL.wav"), ("sofalizer 输出", "/tmp/FL_hrtf.wav")):
|
||||
L, R = chan(path, 0), chan(path, 1)
|
||||
el = sum(x*x for x in L) ** 0.5
|
||||
er = sum(x*x for x in R) ** 0.5
|
||||
print(" %-16s L能量=%9.1f R能量=%9.1f L/R=%.3f" % (label, el, er, (el/er) if er else 0))
|
||||
PY
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
cd "/home/lou/桌面/工作区/实验/空间音频"
|
||||
SOFA=/usr/share/libmysofa/MIT_KEMAR_normal_pinna.sofa
|
||||
mkdir -p hrir && cd hrir
|
||||
|
||||
i=0
|
||||
for name in FL FR FC LFE BL BR; do
|
||||
exp=""
|
||||
for j in 0 1 2 3 4 5; do
|
||||
[ $j -gt 0 ] && exp="$exp|"
|
||||
if [ $j -eq $i ]; then exp="${exp}between(t,0,1/48000)"; else exp="${exp}0"; fi
|
||||
done
|
||||
ffmpeg -y -hide_banner -loglevel error -f lavfi \
|
||||
-i "aevalsrc='${exp}':d=0.05:s=48000:c=5.1" \
|
||||
-af "sofalizer=sofa=${SOFA}:type=time" -c:a pcm_s16le "${name}.wav"
|
||||
i=$((i+1))
|
||||
done
|
||||
|
||||
echo "=== 提取结果(每对双耳脉冲响应) ==="
|
||||
for f in FL FR FC LFE BL BR; do
|
||||
printf " %-5s " "$f"
|
||||
ffprobe -v error -show_entries stream=channels,sample_rate,duration -of csv=p=0 "${f}.wav"
|
||||
done
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
# 逐方向精确测试: 每个方向单独生成一个 5.1(只有该声道有声), 渲染后看左右能量比
|
||||
set -eu
|
||||
cd "/home/lou/桌面/工作区/实验/空间音频"
|
||||
SOFA=/usr/share/libmysofa/MIT_KEMAR_normal_pinna.sofa
|
||||
mkdir -p 方向测试
|
||||
cd 方向测试
|
||||
|
||||
i=0
|
||||
for name in FL FR FC LFE BL BR; do
|
||||
expr=""
|
||||
j=0
|
||||
while [ "$j" -lt 6 ]; do
|
||||
[ "$j" -gt 0 ] && expr="$expr|"
|
||||
if [ "$j" -eq "$i" ]; then
|
||||
expr="${expr}0.6*sin(2*PI*440*t)"
|
||||
else
|
||||
expr="${expr}0"
|
||||
fi
|
||||
j=$((j + 1))
|
||||
done
|
||||
ffmpeg -y -hide_banner -loglevel warning -f lavfi \
|
||||
-i "aevalsrc='$expr':d=0.4:s=48000:c=5.1" -c:a pcm_s16le "$name.wav"
|
||||
ffmpeg -y -hide_banner -loglevel warning -i "$name.wav" \
|
||||
-af "sofalizer=sofa=$SOFA:type=freq:radius=1.2" -c:a pcm_s16le "${name}_hrtf.wav"
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import struct
|
||||
import wave
|
||||
|
||||
NAMES = ["FL", "FR", "FC", "LFE", "BL", "BR"]
|
||||
EXPECT = {"FL": ">1 偏左", "FR": "<1 偏右", "FC": "~1 居中",
|
||||
"LFE": "~1 居中", "BL": ">1 偏左", "BR": "<1 偏右"}
|
||||
|
||||
|
||||
def ratio(path: str) -> float:
|
||||
with wave.open(path) as w:
|
||||
ch, n = w.getnchannels(), w.getnframes()
|
||||
raw = w.readframes(n)
|
||||
s = struct.unpack("<%dh" % (len(raw) // 2), raw)
|
||||
left = [float(s[i * 2]) for i in range(len(s) // 2)]
|
||||
right = [float(s[i * 2 + 1]) for i in range(len(s) // 2)]
|
||||
el = (sum(x * x for x in left) / len(left)) ** 0.5
|
||||
er = (sum(x * x for x in right) / len(right)) ** 0.5
|
||||
return el / er if er else 999.0
|
||||
|
||||
|
||||
print("%-5s %14s %14s %s" % ("方向", "原始 L/R", "HRTF L/R", "期望"))
|
||||
print("-" * 56)
|
||||
for nm in NAMES:
|
||||
a, b = nm + ".wav", nm + "_hrtf.wav"
|
||||
if os.path.isfile(a) and os.path.isfile(b):
|
||||
ra = ratio(a)
|
||||
rb = ratio(b)
|
||||
flag = ""
|
||||
ok = (rb > 1.05) if EXPECT[nm].startswith(">1") else \
|
||||
((rb < 0.95) if EXPECT[nm].startswith("<1") else (0.8 < rb < 1.25))
|
||||
flag = "OK" if ok else "偏了"
|
||||
print("%-5s %14.2f %14.2f %-9s %s" % (nm, ra, rb, EXPECT[nm], flag))
|
||||
PY
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成 PipeWire filter-chain 配置(2026-09-12)
|
||||
|
||||
两个虚拟声卡:
|
||||
1) cinema_spatial_sink 5.1 输入 -> 双耳 HRTF (多声道片源)
|
||||
2) cinema_spatial_up_sink 立体声输入 -> 上混5.1 -> HRTF (浏览器/音乐等, 全局默认用这个)
|
||||
|
||||
上混链照抄 PipeWire 自带的 sink-upmix-5.1-filter.conf(矩阵式):
|
||||
FL/FR 直通; FC = (FL+FR)*0.707 低通; LFE = 同源再低通+衰减;
|
||||
RL/RR = (FL-FR)*0.707 反相 + 希尔伯特卷积(产生相位差/延迟)
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
# IR 目录: 环境变量优先(打包/安装场景指 /usr/share/cinema-spatial/hrir),
|
||||
# 否则用项目内的软链 hrir/current (开发时换 HRTF 只改软链)。
|
||||
IR = os.environ.get("CINEMA_SPATIAL_IR") or os.path.join(_ROOT, "hrir", "current")
|
||||
OUT = os.environ.get("CINEMA_SPATIAL_OUT") or os.path.expanduser(
|
||||
"~/.config/pipewire/pipewire.conf.d/90-cinema-spatial.conf"
|
||||
)
|
||||
DIRS: list[str] = ["FL", "FR", "FC", "LFE", "BL", "BR"]
|
||||
|
||||
|
||||
def default_sink_name() -> str:
|
||||
"""当前默认物理输出的 node.name —— 必须是物理设备。
|
||||
|
||||
切换到虚拟声卡之后 @DEFAULT_AUDIO_SINK@ 会指向虚拟声卡自己, 那会让
|
||||
node.target 变成自我循环, 所以这种情况要去 Sinks 列表里挑第一个物理输出。
|
||||
"""
|
||||
def props(cmd: list[str]) -> str:
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout
|
||||
except Exception:
|
||||
return ""
|
||||
for line in out.splitlines():
|
||||
if "node.name" in line and '"' in line:
|
||||
return line.split('"')[1]
|
||||
return ""
|
||||
|
||||
cur = props(["wpctl", "inspect", "@DEFAULT_AUDIO_SINK@"])
|
||||
if cur and "cinema_spatial" not in cur:
|
||||
return cur
|
||||
|
||||
try:
|
||||
status = subprocess.run(["wpctl", "status"], capture_output=True, text=True, timeout=10).stdout
|
||||
except Exception:
|
||||
return ""
|
||||
in_sinks = False
|
||||
first = ""
|
||||
for line in status.splitlines():
|
||||
if "Sinks:" in line:
|
||||
in_sinks = True
|
||||
continue
|
||||
if "Sources:" in line:
|
||||
break
|
||||
if not in_sinks or "cinema_spatial" in line:
|
||||
continue
|
||||
for tok in line.split():
|
||||
if tok.endswith(".") and tok[:-1].isdigit():
|
||||
name = props(["wpctl", "inspect", tok[:-1]])
|
||||
if not name or "cinema_spatial" in name:
|
||||
continue
|
||||
if "usb-" in name:
|
||||
return name # USB 设备优先(耳机/外置声卡)
|
||||
if not first:
|
||||
first = name
|
||||
return first
|
||||
|
||||
|
||||
def hrir_block(indent: str) -> list[str]:
|
||||
"""12 个卷积器 + 6 个分发 + 左右耳混音。输入端口: cp<dir>:In"""
|
||||
L: list[str] = []
|
||||
for d in DIRS:
|
||||
for e in ("L", "R"):
|
||||
L.append(f'{indent}{{ type = builtin label = convolver name = c{d}_{e} '
|
||||
f'config = {{ filename = "{IR}/{d}_{e}.wav" }} }}')
|
||||
for d in DIRS:
|
||||
L.append(f'{indent}{{ type = builtin label = copy name = cp{d} }}')
|
||||
for e in ("L", "R"):
|
||||
gains = " ".join(f'"Gain {i}" = 1' for i in range(1, 7))
|
||||
L.append(f'{indent}{{ type = builtin label = mixer name = mix{e} control = {{ {gains} }} }}')
|
||||
return L
|
||||
|
||||
|
||||
def hrir_links(indent: str) -> list[str]:
|
||||
L: list[str] = []
|
||||
for d in DIRS:
|
||||
for e in ("L", "R"):
|
||||
L.append(f'{indent}{{ output = "cp{d}:Out" input = "c{d}_{e}:In" }}')
|
||||
for i, d in enumerate(DIRS, start=1):
|
||||
L.append(f'{indent}{{ output = "c{d}_L:Out" input = "mixL:In {i}" }}')
|
||||
for i, d in enumerate(DIRS, start=1):
|
||||
L.append(f'{indent}{{ output = "c{d}_R:Out" input = "mixR:In {i}" }}')
|
||||
return L
|
||||
|
||||
|
||||
def sink_51() -> list[str]:
|
||||
L = [" { name = libpipewire-module-filter-chain", " flags = [ nofail ]", " args = {",
|
||||
' node.description = "电影院空间音频 (5.1 虚拟环绕)"',
|
||||
' media.name = "Cinema Spatial 5.1"', " filter.graph = {", " nodes = ["]
|
||||
L += hrir_block(" ")
|
||||
L += [" ]", " links = ["]
|
||||
L += hrir_links(" ")
|
||||
L += [" ]",
|
||||
' inputs = [ "cpFL:In" "cpFR:In" "cpFC:In" "cpLFE:In" "cpBL:In" "cpBR:In" ]',
|
||||
' outputs = [ "mixL:Out" "mixR:Out" ]', " }",
|
||||
" capture.props = { 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 node.passive = true node.target = "{default_sink_name()}" '
|
||||
"audio.channels = 2 audio.position = [ FL FR ] }",
|
||||
" }", " }"]
|
||||
return L
|
||||
|
||||
|
||||
def sink_upmix() -> list[str]:
|
||||
tgt = default_sink_name()
|
||||
L = [" { name = libpipewire-module-filter-chain", " flags = [ nofail ]", " args = {",
|
||||
' node.description = "电影院空间音频 (立体声上混)"',
|
||||
' media.name = "Cinema Spatial Upmix"', " filter.graph = {", " nodes = [",
|
||||
" # ---- 第一段: 立体声矩阵上混成 5.1 (照抄官方 upmix) ----",
|
||||
" { type = builtin label = copy name = copyFL }",
|
||||
" { type = builtin label = copy name = copyFR }",
|
||||
" { type = builtin label = copy name = copyOFL }",
|
||||
" { type = builtin label = copy name = copyOFR }",
|
||||
' { type = builtin label = mixer name = mixF control = { "Gain 1" = 0.707 "Gain 2" = 0.707 } }',
|
||||
" { type = builtin label = param_eq name = eq_FC_LFE config = {",
|
||||
" filters1 = [ { type = bq_lowpass freq = 12000 } { type = bq_lowpass freq = 12000 } ]",
|
||||
" filters2 = [ { type = bq_highshelf freq = 0 gain = -20.0 } "
|
||||
"{ type = bq_lowpass freq = 120 } { type = bq_lowpass freq = 120 } ] } }",
|
||||
' { type = builtin label = mixer name = subR control = { "Gain 1" = 0.707 "Gain 2" = -0.707 } }',
|
||||
' { type = builtin label = convolver name = convRL config = { gain = 1.0 delay = 0.012 filename = "/hilbert" length = 33 } }',
|
||||
' { type = builtin label = convolver name = convRR config = { gain = -1.0 delay = 0.012 filename = "/hilbert" length = 33 } }',
|
||||
" # ---- 第二段: 5.1 -> 双耳 HRTF ----"]
|
||||
L += hrir_block(" ")
|
||||
L += [" ]", " links = [",
|
||||
' { output = "copyFL:Out" input = "mixF:In 1" }',
|
||||
' { output = "copyFR:Out" input = "mixF:In 2" }',
|
||||
' { output = "copyFL:Out" input = "copyOFR:In" }',
|
||||
' { output = "copyFR:Out" input = "copyOFL:In" }',
|
||||
' { output = "mixF:Out" input = "eq_FC_LFE:In 1" }',
|
||||
' { output = "mixF:Out" input = "eq_FC_LFE:In 2" }',
|
||||
' { output = "copyFL:Out" input = "subR:In 1" }',
|
||||
' { output = "copyFR:Out" input = "subR:In 2" }',
|
||||
' { output = "subR:Out" input = "convRL:In" }',
|
||||
' { output = "subR:Out" input = "convRR:In" }',
|
||||
" # 上混产出的 6 路接进 HRTF 的分发",
|
||||
' { output = "copyOFL:Out" input = "cpFL:In" }',
|
||||
' { output = "copyOFR:Out" input = "cpFR:In" }',
|
||||
' { output = "eq_FC_LFE:Out 1" input = "cpFC:In" }',
|
||||
' { output = "eq_FC_LFE:Out 2" input = "cpLFE:In" }',
|
||||
' { output = "convRL:Out" input = "cpBL:In" }',
|
||||
' { output = "convRR:Out" input = "cpBR:In" }']
|
||||
L += hrir_links(" ")
|
||||
L += [" ]",
|
||||
' inputs = [ "copyFL:In" "copyFR:In" ]',
|
||||
' outputs = [ "mixL:Out" "mixR:Out" ]', " }",
|
||||
" capture.props = { 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 node.passive = true node.target = "{tgt}" '
|
||||
"audio.channels = 2 audio.position = [ FL FR ] }",
|
||||
" }", " }"]
|
||||
return L
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
body: list[str] = ["# 电影院空间音频 自动生成, 改 IR/增益请改 生成配置.py 重跑", "context.modules = ["]
|
||||
body += sink_51()
|
||||
body += sink_upmix()
|
||||
body += ["]", ""]
|
||||
with open(OUT, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(body))
|
||||
print(f"已生成 {OUT} ({len(body)} 行)")
|
||||
print(f"输出目标: {default_sink_name()}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# 生成 PipeWire filter-chain 配置: 5.1 -> 双耳 HRTF (KEMAR)
|
||||
# 输出目标自动取当前默认输出设备; 换耳机/音箱后重跑本脚本即可。
|
||||
set -eu
|
||||
IR="/home/lou/桌面/工作区/实验/空间音频/hrir/gain"
|
||||
OUT="/home/lou/.config/pipewire/pipewire.conf.d/90-cinema-spatial.conf"
|
||||
D="FL FR FC LFE BL BR"
|
||||
mkdir -p "$(dirname "$OUT")"
|
||||
|
||||
TARGET=$(wpctl inspect @DEFAULT_AUDIO_SINK@ 2>/dev/null | grep -oP 'node\.name = "\K[^"]+' | head -1 || true)
|
||||
[ -n "${TARGET:-}" ] || TARGET="alsa_output.usb-EDIFIER_Technology_EDIFIER_Fit900NB_4250315939393214-00.iec958-stereo"
|
||||
echo "输出目标: $TARGET"
|
||||
|
||||
{
|
||||
echo "# 电影院空间音频: 6 声道 -> 双耳 HRTF(KEMAR) 自动生成 $(date '+%F %T')"
|
||||
echo "context.modules = ["
|
||||
echo " { name = libpipewire-module-filter-chain"
|
||||
echo " flags = [ nofail ]"
|
||||
echo " args = {"
|
||||
echo " node.description = \"电影院空间音频 (5.1 虚拟环绕)\""
|
||||
echo " media.name = \"Cinema Spatial\""
|
||||
echo " filter.graph = {"
|
||||
echo " nodes = ["
|
||||
for d in $D; do for e in L R; do
|
||||
echo " { type = builtin label = convolver name = c${d}_${e} config = { filename = \"$IR/${d}_${e}.wav\" } }"
|
||||
done; done
|
||||
for d in $D; do
|
||||
echo " { type = builtin label = copy name = cp${d} }"
|
||||
done
|
||||
for e in L R; do
|
||||
echo " { type = builtin label = mixer name = mix${e} control = { \"Gain 1\" = 1 \"Gain 2\" = 1 \"Gain 3\" = 1 \"Gain 4\" = 1 \"Gain 5\" = 1 \"Gain 6\" = 1 } }"
|
||||
done
|
||||
echo " ]"
|
||||
echo " links = ["
|
||||
for d in $D; do
|
||||
echo " { output = \"cp${d}:Out\" input = \"c${d}_L:In\" }"
|
||||
echo " { output = \"cp${d}:Out\" input = \"c${d}_R:In\" }"
|
||||
done
|
||||
n=1; for d in $D; do
|
||||
echo " { output = \"c${d}_L:Out\" input = \"mixL:In $n\" }"; n=$((n+1))
|
||||
done
|
||||
n=1; for d in $D; do
|
||||
echo " { output = \"c${d}_R:Out\" input = \"mixR:In $n\" }"; n=$((n+1))
|
||||
done
|
||||
echo " ]"
|
||||
IN=""; for d in $D; do IN="$IN \"cp${d}:In\""; done
|
||||
echo " inputs = [${IN} ]"
|
||||
echo " outputs = [ \"mixL:Out\" \"mixR:Out\" ]"
|
||||
echo " }"
|
||||
echo " capture.props = { node.name = cinema_spatial_sink media.class = Audio/Sink audio.channels = 6 audio.position = [ FL FR FC LFE BL BR ] }"
|
||||
echo " playback.props = { node.name = cinema_spatial_out node.passive = true node.target = \"$TARGET\" audio.channels = 2 audio.position = [ FL FR ] }"
|
||||
echo " }"
|
||||
echo " }"
|
||||
echo "]"
|
||||
} > "$OUT"
|
||||
echo "已生成 $OUT ($(wc -l < "$OUT") 行)"
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# 验收测试: 重新打包 -> 装 -> 验证虚拟声卡和命令都工作 (2026-09-12)
|
||||
set -eu
|
||||
cd "/home/lou/桌面/工作区/实验/空间音频"
|
||||
DEB="$HOME/桌面/collaplex-cinema-spatial_1.0.0_all.deb"
|
||||
BK="$HOME/归档/空间音频-用户级配置备份"
|
||||
PW="$HOME/.config/pipewire/pipewire.conf.d"
|
||||
|
||||
echo "=== 1. 重新打包 ==="
|
||||
bash 打包deb.sh 2>&1 | tail -4
|
||||
|
||||
echo
|
||||
echo "=== 2. 备份并移走用户级配置(否则与系统级重复定义) ==="
|
||||
mkdir -p "$BK"
|
||||
mv "$PW/90-cinema-spatial.conf" "$PW/91-clock.conf" "$BK/" 2>/dev/null || true
|
||||
echo "用户级目录:"; ls -A "$PW/" 2>/dev/null || echo " (空)"
|
||||
echo "备份到:"; ls -A "$BK/"
|
||||
|
||||
echo
|
||||
echo "=== 3. 安装 deb ==="
|
||||
sudo dpkg -i "$DEB" 2>&1 | tail -6
|
||||
|
||||
echo
|
||||
echo "=== 4. 包安装的文件 ==="
|
||||
dpkg -L collaplex-cinema-spatial 2>/dev/null | grep -vE "^/usr/share/cinema-spatial/(hrir|sofa)/" | head -12
|
||||
|
||||
echo
|
||||
echo "=== 5. 重载 PipeWire ==="
|
||||
systemctl --user restart pipewire pipewire-pulse wireplumber
|
||||
sleep 6
|
||||
echo "重载完成"
|
||||
|
||||
echo
|
||||
echo "=== 6. 虚拟声卡 ==="
|
||||
wpctl status 2>&1 | sed -n '/Filters:/,/Streams:/p' | grep cinema || echo " ❌ 没起来"
|
||||
|
||||
echo
|
||||
echo "=== 7. 命令可用性 ==="
|
||||
command -v 空间音频 && command -v mpv-影院
|
||||
echo
|
||||
空间音频 状态 2>&1 | head -4
|
||||
|
||||
echo
|
||||
echo "=== 8. 时钟 ==="
|
||||
pw-metadata -n settings 2>&1 | grep -E "clock.rate" | head -1
|
||||
Reference in New Issue
Block a user