初始化: 耳机电影院空间音频(PipeWire filter-chain + SADIE-II HRTF)

- 两个虚拟声卡: 5.1 直通版 / 立体声矩阵上混版
- 全链路 96kHz/24bit, SADIE-II 真人 HRTF 96K/512tap
- 命令: 空间音频(开关) / mpv-影院(单程序)
- deb 打包 + 验收测试脚本
- README 记录 9 个实测踩坑(增益校准/采样率/相位等)
This commit is contained in:
edgevoid
2026-09-12 23:52:43 +08:00
commit 68db84a10c
15 changed files with 871 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# HRTF 数据集(体积大, 自行下载, 见 README)
sofa/
# 生成的脉冲响应(跑 换HRTF.sh 重新生成)
hrir/
# 测试/演示产物
*.wav
方向测试/
验证_*.log
# 构建产物
*.deb
/tmp*
+21
View File
@@ -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.
+113
View File
@@ -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
+61
View File
@@ -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}'
+7
View File
@@ -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]
+69
View File
@@ -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
+62
View File
@@ -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())
+76
View File
@@ -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
+41
View File
@@ -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)"
+39
View File
@@ -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
+24
View File
@@ -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
+63
View File
@@ -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
+180
View File
@@ -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())
+56
View File
@@ -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") 行)"
+45
View File
@@ -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