7 Commits

Author SHA1 Message Date
edgevoid c23936e69f 发版 1.3.6: 打包脚本版本号 + release 说明更新
- 打包deb.sh: VER 1.3.0 -> 1.3.6
- 发布release.sh: release 正文补 1.3.1~1.3.6 的改动清单
2026-09-14 08:04:19 +08:00
edgevoid 4bc381604b v1.3.6: 电平表缩放加"平均+变速" —— 消除量程一跳一跳
老板: "我觉的应该给缩放加点平均和变速算法, 这样就不会出现一下出现一下拉满了"

根因: 之前纯按瞬时极值(最近2秒的 max/min)定量程, 单帧瞬态就能把量程猛地张开/
收缩, 观感"一时全空一时拉满"。

两层平滑(思路同压缩器 attack/release):
① 平均: 峰值/谷值各走 EMA —— 峰值涨得快(.28)落得慢(.08), 谷值反过来
         -> 单帧毛刺不再定调
② 变速: 目标量程走**非对称** EMA —— 张开快(.30, 大声音进来立刻扩不削顶),
        收缩慢(.07, 安静段不急着缩), 避免波形忽大忽小
- 中心走 EMA(.12) 保证"居中"不跳
- 首帧直接就位, 不让开机时慢慢爬
- 显示仍 6dB 步进

模拟实测(安静 -> 一帧 -6dBFS 突发 -> 中等 -> 安静, 帧内跨度 30dB):
最大单帧量程跳变仅 6dB; 突发帧量程 54->60(而不是跳到 90+); 之后 60->66->60 缓慢回落。
2026-09-14 07:59:42 +08:00
edgevoid 520b007b54 v1.3.5: 电平表波形放大 —— 量程跟随信号实际幅度 (波形占约 2/3)
老板反馈"你只改了一半还有一半还没实现, 现在确实压低了, 波形再放大一点就好看了":
1.3.4 只做了"压低"(波峰从贴顶挪到中线), 但波峰在正中时波形只占上半区、下半区
全空 —— 又小又浪费。这次做"放大"。

改法(把固定量程换成自适应):
- 量程 = 信号跨度 × 1.45 -> 波形固定占画布约 2/3, 上下各留一半余量(垂直居中)
- 跨度只看最近 2 秒(拿整 12 秒极值会被历史瞬态拖住); 用 rms 当"谷"更稳
- 跨度下限 18dB(信号平直时别把噪声放大成锯齿), 上限 72dB
- 6dB 步进 -> 不逐帧抖; 顶线允许超过 0dBFS(多出来的是留白); 0dBFS 单独画亮基准线
- 网格与左侧标签跟着量程走(不再写死 SPAN=48)

顺带修自己引入的 bug: mn 初值曾用 fallback(-60), 把底线拖到 -66, 波形反而更小(21%)。

CDP 实测四档(跨度 10/14/18/24dB): 波形占画布 42% / 47% / 60% / 57%, 上下留白对称。
2026-09-14 07:55:16 +08:00
edgevoid f3645accd5 v1.3.4: 两个电平表(原始/处理后)波形垂直居中 —— 波峰落在画布正中
★ 更正上一版改错的对象: 老板说的是「原始电平」和「处理后电平」两个表的波形,
  1.3.3 我改成了「响度归一化」的增益曲线。现在把对的这两个也改了(归一化那边保留)。

根因: meterTop() 的顶线 = 最近 2 秒峰值 + 3dB, 于是波峰永远贴在画布最上沿,
看着就是被上面那行标题压住("太靠上波形被盖住了")。

改法:
- 顶线 = 峰值 + SPAN/2 -> 波峰正好落在画布垂直中线, 上下各留半屏
- 顶线允许超过 0dBFS(那只是绘图范围, 多出来的是留白); 上下限放宽到 ±48
- 0dBFS 单独画一条亮基准线(在可见范围内才画), 刻度不失参照
- 6dB 步进保留(不逐帧抖)

CDP 注入三档电平(-6 / -12 / -30 dBFS)实测: 波峰偏离画布中线均为 0px, 上下留白对称。
2026-09-14 07:50:37 +08:00
edgevoid 4768b2211d v1.3.3: 响度归一化曲线改动态量程 —— 波峰自动落在图表垂直正中
原来量程固定 ±20dB, 而增益常年偏一边(实测 +4.6dB 整条压在上半区)。现在按最近
120 个样本的 min/max 定中心 mid=(lo+hi)/2 与半幅 half=(hi-lo)/2*1.24:
- 半幅下限 2dB  -> 曲线接近平直时不会把噪声放大成锯齿
- 半幅上限 30dB -> 量程不会失控
- mid/half 各走一阶低通(每帧 12%) -> 平移/缩放平滑, 不"呼吸"抖动
- 网格与左侧标签跟着量程走, 显示真实 dB 值(不再固定 +20/0/-20)
- 0dB 基准线只在可见范围内才画

CDP 实测: 三个场景(波峰+12/波谷+2 / 波峰-4/波谷-16 / 几乎平直+4.6)中值偏离
画布正中线均为 0px。
2026-09-14 07:47:36 +08:00
edgevoid 1e635f251b v1.3.2: 面板补 favicon + 修响度曲线被压; 纠正"物理输出对齐虚拟声卡"的错误假设
面板:
- 加内联 SVG favicon(深底 + 五根声波柱), 之前没有 favicon 浏览器显示默认破图标
- 响度归一化卡片: 高度 184 装不下(标题22+推子22+文字行20+间距 ≈ 70px), 而 #ln 只扣
  46px -> canvas 溢出、上沿被「目标/判据」那行文字压住。改: 卡片 210px、#ln 扣 74px、
  网格 430->456(把这 26px 补给上面两个电平表, 免得它们被压矮)。实测无重叠, #ln 126px

★ 重要纠正(2026-09-14 老板当场抓出):
- 物理输出音量是**档位**, 不是"越大越响" —— 响度由 DSP 归一化保证(实测 0.35 就很好)
- 上一版错误地把它"对齐"到虚拟声卡(1.0) -> 进削波保护、动态被压扁: 面板波形顶满
  而听感反而更小(老 bug 复现)。已撤掉三处:
  · webui.py 桥启动的"对齐一次" -> 只兜静音
  · 音频守护.py 第9项"物理≠虚拟就纠" -> 只在静音/近乎归零(<0.02)时才动手
  · fix_phys_volume() 不再写正常档位
- config/组件配置单.json: 改记「档位 0.35」+ 写明教训
2026-09-14 07:44:08 +08:00
edgevoid 52b539cdfb v1.3.1: 修断电隔夜重启后"又没声音"(物理输出从存档恢复成旧值, 桥只监视变化不兜底)
根因: 菜单音量桥的约定是「虚拟声卡=音量真源, 物理输出=执行器」, 但桥只监视虚拟声卡
的变化(v != ref 才折算写物理)。断电重启后物理输出从 WirePlumber 存档恢复成旧值
(实测 0.35, 而虚拟声卡 1.0 = 小 9 dB), 虚拟声卡没变 -> 桥永远不动物理 -> 声音就小着。
而看门狗没起来(非自启), 整晚没人兜底。

- web/webui.py: 桥启动时(BRIDGE_REF is None 那次)把物理输出对齐一次
- 音频守护.py: 新增第 9 项检查「物理输出音量 ≠ 虚拟声卡」(容差 0.03; 虚拟声卡自身
  <0.1 时不判 —— 那是真被调小了) -> 自动纠回, 破坏测试实测 22 秒纠回
- config/组件配置单.json: 登记「音量应与虚拟声卡一致」期望项
- web/启动.sh: 起来时顺带拉起看门狗(已在跑不重复起) —— 不违背"不自启"的习惯,
  但重启后一用面板就有人守; 看门狗 setsid 脱离, 挂在 systemd --user 下
- README: 版本记录 1.3.1
2026-09-14 07:31:05 +08:00
8 changed files with 222 additions and 27 deletions
+29
View File
@@ -486,6 +486,35 @@ python3 web/webui.py --port 8788 --bind 127.0.0.1 # 或直接运行后
## 11. 版本记录
### 1.3.6
- **电平表缩放加「平均 + 变速」**(老板:"给缩放加点平均和变速算法,这样就不会出现一下出现一下拉满了")—— 纯按瞬时极值定量程,遇到单帧瞬态就猛地张开/收缩,观感一跳一跳(一时全空一时拉满)。现在两层平滑,思路同压缩器的 attack/release
- **① 平均**:峰值/谷值先各走 EMA(峰值涨得快 0.28 / 落得慢 0.08,谷值反过来),不让单帧毛刺定调
- **② 变速**:目标量程再走**非对称** EMA —— **张开快(0.30,大声音进来立刻扩,不削顶)**、**收缩慢(0.07,安静段不急着缩,免得波形忽大忽小)**
- 中心同样走 EMA(0.12),保证"波形居中"不跳
- 首帧直接就位(不让开机时量程慢慢爬)
- 模拟测试(安静 → 一帧 −6dBFS 突发 → 中等 → 安静,帧内跨度 30dB):**最大单帧量程跳变仅 6 dB**,突发帧量程 54→60 而非跳到 90+,之后 60→66→60 缓慢回落
### 1.3.5
- **两个电平表波形"放大"** —— 1.3.4 只把波峰压到中线(确实不再被压住),但波形只占上半区、下半区全空,显得又小又浪费(老板:"你只改了一半,现在确实压低了,波形再放大一点就好看了")。现在**量程跟着信号的实际幅度走**:`量程 = 信号跨度 × 1.45`(上下各留一半余量 → 波形垂直居中且占约 2/3),跨度下限 18 dB(信号平直时不放大噪声)、上限 72 dB,6 dB 步进不逐帧抖。CDP 实测四档电平(跨度 10~24 dB)波形占画布 **42%~60%**,上下留白对称
- 附带修一个自己引入的 bug`mn` 初值曾取 `fallback(-60)`,把底线拖到 −66 导致波形反而更小(21%);谷值现在只认真实数据
### 1.3.4
- **两个电平表(原始 / 处理后)波形改为垂直居中** —— 原来顶线 = 「最近 2 秒峰值 + 3 dB」,于是**波峰永远贴在画布最上沿**,看着像被上面那行标题压住(老板原话"太靠上波形被盖住了")。现改为 **顶线 = 峰值 + SPAN/2**,波峰正好落在画布垂直中线,上下各留半屏;顶线**允许超过 0 dBFS**(那只是绘图范围,多出来的是留白),0 dBFS 单独画一条亮基准线保住刻度参照。CDP 注入三档电平(−6 / −12 / −30 dBFS)实测**波峰偏离中线均为 0 px**
- 上一版(1.3.3)误把动态量程加在了「响度归一化」的增益曲线上 —— 老板要的是这两个电平表。归一化那边的动态量程保留(同样是"波峰居中"的需求),两处现在都居中
### 1.3.3
- 响度归一化曲线改为**动态量程**:原来量程固定 ±20 dB,而增益常年偏一边(实测 +4.6 dB 整条压在上半区)。现在按最近 120 个样本的 min/max 定中心与半幅,**让曲线的波峰自动落在图表垂直正中**;两处细节:① 半幅下限 2 dB(曲线接近平直时别把噪声放大成锯齿),② 中心/半幅都用一阶低通(每帧挪 12%)平滑,避免曲线"呼吸"抖动。左侧量程标签同步改成**跟随数据的真实 dB 值**(不再固定 ±20/0),0 dB 基准线只在可见范围内才画
- CDP 实测三个场景(波峰+12/波谷+2、波峰−4/波谷−16、几乎平直+4.6)**中值偏离画布正中线均为 0 px**
### 1.3.2
- 面板补 **favicon**(内联 SVG:深底 + 五根声波柱)—— 之前没有图标,浏览器标签页显示默认破图标
- 修复:**响度归一化卡片里的曲线被压** —— 卡片高 184px 装不下(标题 22 + 推子 22 + 文字行 20 + 间距 ≈ 70px),而 `#ln` 只扣了 46px → canvas 溢出、上沿被「目标/判据」那行文字盖住。改:卡片 210px、`#ln` 扣 74px、网格 430→456(把这 26px 补给上面两个电平表,免得它们被压矮)。CDP 量尺实测**无重叠**、`#ln` 126px
-**纠正一个错误假设**:**物理输出音量是一个「档位」,不是"越大越响"** —— 响度由 DSP 归一化保证(实测 0.35 就很好)。1.3.1 曾错误地把它"对齐"到虚拟声卡(1.0),结果进削波保护、动态被压扁:**面板波形顶满而听感反而更小**(老 bug 复现)。已撤掉三处相关逻辑,看门狗现在只兜「静音 / 近乎归零(<0.02)」,**绝不改正常档位**
### 1.3.1
- 修复:**断电隔夜重启后"又没声音"** —— 物理输出的音量会从 WirePlumber 存档恢复成旧值(实测 0.35,而虚拟声卡是 1.0),而菜单音量桥只"监视虚拟声卡变化"、虚拟没变就永远不动物理 → 声音凭空小 9 dB。**两处兜底**:① 桥启动时把物理输出对齐一次;② 看门狗新增第 9 项检查「物理输出音量 ≠ 虚拟声卡」并自动纠回(实测 22 秒纠回)
- 修复:**隔夜无人看守** —— 看门狗不做开机自启(既有约定),重启后链路就没有守护。现在 `web/启动.sh` 起来时**顺带把看门狗拉起**(已在跑则不重复起);看门狗用 `setsid` 脱离会话,挂在 `systemd --user` 下,Hermes 关了它照样跑
### 1.3.0
- **离线渲染**`离线渲染.py`):音频 / 视频 → 空间音频 FLAC(96 kHz 双耳)。链路与实时虚拟声卡同参数:`pan` 上混 5.1 → `sofalizer`(同一个 H4 SOFA)→ 房间 IR 卷积(`afir`)→ `loudnorm`。视频自动取音轨;已是 5.1 的片源不重上混
- **两道锁**:渲染锁(一轮任务期间**不接受新任务**,直接拒绝而非排队堆积);文件就绪检查(相隔 1.2 秒两次 stat 的大小/时间不变 + ffprobe 能读出音轨,防止把还在下载 / 写入的文件扔进渲染)
+5 -1
View File
@@ -58,7 +58,11 @@
"物理输出": {
"匹配": "alsa_output.usb-EDIFIER_Technology_EDIFIER_Fit900NB_4250315939393214-00.iec958-stereo",
"_匹配说明": "USB 耳机重启后 profile 可能在 iec958-stereo / analog-stereo 之间变, 名字会变; 所以按 EDIFIER 前缀匹配即可",
"_路由": "数字(S/PDIF) 优先, 96kHz/24bit(S24LE); 实测音质优于模拟"
"_路由": "数字(S/PDIF) 优先, 96kHz/24bit(S24LE); 实测音质优于模拟",
"档位": 0.35,
"_档位说明": "★★ 2026-09-14 修正(重要): 本级音量是一个**档位**, 实测 0.35 已经很响(响度由 DSP 归一化保证), **不是**\"越小越弱、越大越响\"。曾经错误地把它对齐到虚拟声卡(1.0) → 进削波保护、动态被压扁: 面板波形顶满而听感反而更小(老 bug 复现, 老板当场抓出)。看门狗只允许兜\"静音\"和\"近乎归零(<0.02)\", **绝不改正常档位**",
"静音应被解除": true,
"近乎归零阈值": 0.02
},
"上混链": {
+106 -24
View File
@@ -4,6 +4,11 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Collaplex 音效</title>
<!-- ★ 图标(2026-09-14): 原来没有 favicon, 浏览器标签页显示默认破图标。
用内联 SVG data URI: 深色圆角底 + 五根声波柱(与"音效"主题一致)。
data URI 里的 # 必须写成 %23; 零文件依赖, 不占额外请求。 -->
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%230f0f0f'/><g fill='%234ade80'><rect x='5' y='13' width='2.6' height='6' rx='1.3'/><rect x='10.5' y='9' width='2.6' height='14' rx='1.3'/><rect x='16' y='12' width='2.6' height='8' rx='1.3'/><rect x='21.5' y='9' width='2.6' height='14' rx='1.3'/><rect x='27' y='13' width='2.6' height='6' rx='1.3'/></g></svg>">
<link rel="apple-touch-icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%230f0f0f'/><g fill='%234ade80'><rect x='5' y='13' width='2.6' height='6' rx='1.3'/><rect x='10.5' y='9' width='2.6' height='14' rx='1.3'/><rect x='16' y='12' width='2.6' height='8' rx='1.3'/><rect x='21.5' y='9' width='2.6' height='14' rx='1.3'/><rect x='27' y='13' width='2.6' height='6' rx='1.3'/></g></svg>">
<style>
:root{
--bg:#0f0f0f; --card:#141414; --card2:#1a1a1a; --bd:#262626; --bd2:#333;
@@ -51,7 +56,7 @@
/* ★ 2026-09-13 加"响度归一化"框(固定 150px)后, 原 260px 会把两个电平表压成
一条 10px 的缝(实测截图确认) —— 行高抬到 430, 两个电平表各得约 130px。 */
.flow{display:grid;grid-template-columns:44% 4.5% 4.5% 15% 4.5% 6% 17% 4.5%;gap:0;
align-items:stretch;height:430px;overflow:hidden;margin:0 16px 0}
align-items:stretch;height:456px;overflow:hidden;margin:0 16px 0}
.col{min-width:0;min-height:0;overflow:hidden}
.meters{display:flex;flex-direction:column;gap:10px}
.meter{flex:1;background:var(--card);border:1px solid var(--bd);border-radius:12px;
@@ -72,8 +77,10 @@
.lg i{width:9px;height:2px;border-radius:1px;display:inline-block}
/* ---------- 响度归一化: 推子 + 增益历史 ---------- */
/* 高度 150 → 184: 框里加了「目标响度」滑块那一行, 不加高会把历史曲线 canvas 挤扁 */
.lnbox{flex:0 0 auto;height:184px}
/* ★ 2026-09-14 修: 高度 184 装不下(标题 22 + 推子 22 + 文字行 20 + 间距 = ~70px),
而 #ln 只扣了 46px → canvas 溢出、上沿被「目标/判据」那行文字压住。
现在: 卡片加高到 210, 扣减改 74px(与实际占用一致), canvas 归位并为 0dB 居中。 */
.lnbox{flex:0 0 auto;height:210px}
.lntgt{margin:6px 0 0;font-size:11px}
.lntgt input[type=range]{flex:1;min-width:0}
.lnfader{margin:8px 1px 0}
@@ -87,7 +94,7 @@
border-radius:50%;background:#e5e5e5;border:2px solid #0f0f0f;
box-shadow:0 0 0 1px var(--bd);transition:left .12s linear}
.lnfoot{display:flex;justify-content:space-between;font-size:11px;color:var(--mut);margin-top:6px}
#ln{width:100%;display:block;height:calc(100% - 46px);min-height:30px;margin-top:5px}
#ln{width:100%;display:block;height:calc(100% - 74px);min-height:60px;margin-top:6px}
/* 竖排开关 */
.vsw{background:var(--card);border:1px solid var(--bd);border-radius:12px;cursor:pointer;
@@ -497,31 +504,76 @@ const rOf = (p, ch) => (p["rms" + ch] !== undefined ? p["rms" + ch] : p.rms);
const CH_L = {line: "rgb(74,222,128)", fill: "rgba(74,222,128,.26)"};
const CH_R = {line: "rgb(96,165,250)", fill: "rgba(96,165,250,.26)"};
function meterTop(pts, fallback){
// ★ 只看**最近 2 秒**(约 20 个窗): 拿整 12 秒的最大值定顶线会被历史瞬态拖住 ——
// 实测最响的一瞬 -10dBFS 把顶线钉在 -6, 安静段(-48)照样贴底看不见。
let mx = fallback;
// ★ 2026-09-14 老板要求「给缩放加点平均和变速算法, 这样就不会一下出现一下拉满了」。
// 纯按瞬时极值定量程, 遇到单帧瞬态就猛地张开/收缩, 观感一跳一跳(一时全空一时拉满)。
// 这里加两层, 思路就是压缩器的 attack/release:
// ① 平均 —— 峰值/谷值先各走 EMA(涨得快、落得慢), 不让单帧毛刺定调;
// ② 变速 —— 目标量程再走**非对称** EMA: 张开快(大声音进来立刻扩, 不削顶),
// 收缩慢(安静段不急着缩, 免得波形忽大忽小);
// 中心同样平滑, 保证"波形居中"不会跳。
let meterView = { peak: -60, floor: -90, range: 30, mid: -30, init: false };
function meterRange(pts, fallback){
let mx = fallback, mn = Infinity; // ★ 谷值只认真实数据: fallback 是"没有信号"时的兜底值,
// 当初把它也当谷 -> 底线被拖到 -66, 波形反而更小(实测 21%)。
const n = pts.length, from = Math.max(0, n - 20);
for (let i = from; i < n; i++){
const p = pts[i];
if (!p) continue;
const v = Math.max(pOf(p, "L"), pOf(p, "R"));
if (v > mx) mx = v;
const hi = Math.max(pOf(p, "L"), pOf(p, "R"));
const lo = Math.min(rOf(p, "L"), rOf(p, "R"));
if (hi > mx) mx = hi;
if (lo < mn) mn = lo;
}
return Math.max(-48, Math.min(0, Math.ceil((mx + 3) / 6) * 6)); // 6dB 步进, 不逐帧抖
if (!isFinite(mn)) mn = mx - 24;
if (mx <= fallback && mn <= fallback) mn = mx - 24; // 完全没信号时给个底线, 免得 range 无穷
if (!meterView.init){ // 首帧直接就位, 免得开机时量程慢慢爬
meterView.init = true;
meterView.peak = mx; meterView.floor = mn;
meterView.range = Math.max(18, Math.min(72, (mx - mn) * 1.45));
meterView.mid = (mx + mn) / 2;
} else {
// ① 平均: 峰值涨得快(0.28)、落得慢(0.08); 谷值反过来(落得快、涨得慢)
meterView.peak += (mx - meterView.peak) * (mx > meterView.peak ? 0.28 : 0.08);
meterView.floor += (mn - meterView.floor) * (mn < meterView.floor ? 0.28 : 0.08);
// ② 变速: 目标量程走非对称 EMA —— 张开快(0.30, 不削顶), 收缩慢(0.07, 不忽大忽小)
const want = Math.max(18, Math.min(72, (meterView.peak - meterView.floor) * 1.45));
meterView.range += (want - meterView.range) * (want > meterView.range ? 0.30 : 0.07);
// 中心平滑, 保证"波形居中"不跳
const wantMid = (meterView.peak + meterView.floor) / 2;
meterView.mid += (wantMid - meterView.mid) * 0.12;
}
const range = Math.max(18, Math.round(meterView.range / 6) * 6); // 6dB 步进(显示不抖)
let top = Math.ceil((meterView.mid + range / 2) / 6) * 6; // 量程以平滑后的中心为中点
let bot = top - range;
top = Math.max(-48, Math.min(48, top)); // 顶线允许超过 0dBFS(多出来的是留白)
return [top, bot];
}
function drawMeter(cv, pts, peakHold){
const {c, w, h} = fit(cv);
c.clearRect(0, 0, w, h);
const top = meterTop(pts, -60), bot = top - SPAN;
const [top, bot] = meterRange(pts, -60);
const span = Math.round(top - bot); // 量程现在跟着信号走, 不再是固定的 SPAN
// ★ 2026-09-14: 顶线允许超过 0dBFS(为给波形留白), 所以额外画一条 0dBFS 基准线
// 保住刻度参照 —— 只在它落进可见范围时画。
{
const y0 = y_of(0, h, top, bot);
if (y0 > 1 && y0 < h - 1){
c.strokeStyle = "#4a4a4a"; c.lineWidth = 1;
c.beginPath(); c.moveTo(0, y0 + .5); c.lineTo(w, y0 + .5); c.stroke();
c.fillStyle = "#5a5a5a"; c.font = "9px ui-monospace,monospace";
c.fillText("0", 2, Math.max(9, y0 - 2));
}
}
// 网格: 顶线起每 12dB 一条 + 底线(标签写真实 dBFS, 量程随信号自动变)
c.lineWidth = 1;
for (let i = 0; i <= SPAN; i += 12){
for (let i = 0; i <= span; i += 12){
const db = top - i, y = y_of(db, h, top, bot);
c.strokeStyle = i === 0 ? "#3a3a3a" : "#1e1e1e";
c.beginPath(); c.moveTo(0, y + .5); c.lineTo(w, y + .5); c.stroke();
if (i === 0 || i === SPAN) { c.fillStyle = "#4a4a4a"; c.font = "9px ui-monospace,monospace";
if (i === 0 || i + 12 > span) { c.fillStyle = "#4a4a4a"; c.font = "9px ui-monospace,monospace";
c.fillText(String(db), 2, Math.max(9, y - 2)); }
}
const n = pts.length;
@@ -567,6 +619,10 @@ function num(pts, el){
const LN_RANGE = 20; // 推子量程 ±20 dB
const LN_HIST = 120; // 增益历史点数(=12 秒, 与电平表对齐)
let lnHist = [];
// ★ 2026-09-14 动态量程: 让增益曲线的"波峰"自动落在图表垂直正中。
// 原来量程固定 ±LN_RANGE, 而增益常年偏一边(实测 +4.6dB 整条压在上半区)。
// mid/half = 画布当前的显示中心与半幅; 用一阶低通(每帧挪 12%)平滑, 免得曲线"呼吸"抖动。
let lnView = { mid: 0, half: LN_RANGE, init: false };
function drawLoud(ln){
const on = ln && ln.ok;
@@ -623,29 +679,55 @@ function drawLnHist(){
if (!cv) return;
const {c, w, h} = fit(cv);
c.clearRect(0, 0, w, h);
const yOf = (db) => h * (1 - Math.max(0, Math.min(1, (db + LN_RANGE) / (2 * LN_RANGE))));
// 0dB 基准线 + 网格
for (const db of [LN_RANGE, LN_RANGE / 2, 0, -LN_RANGE / 2, -LN_RANGE]){
// ---- 动态量程: 按最近样本定中心, 让波峰落在图表中间 ----
const n = lnHist.length;
let mid = 0, half = LN_RANGE;
if (n >= 2){
let lo = Infinity, hi = -Infinity;
for (const v of lnHist){ if (v < lo) lo = v; if (v > hi) hi = v; }
mid = (lo + hi) / 2; // 中心 = 波谷与波峰的中点
half = (hi - lo) / 2 * 1.24; // 半幅 = 峰值幅度 + 24% 余量
half = Math.max(half, 2.0); // 下限 2dB: 曲线接近平直时别把噪声放大成锯齿
half = Math.min(half, LN_RANGE * 1.5); // 上限: 别让量程失控
}
if (!lnView.init){ lnView.init = true; lnView.mid = mid; lnView.half = half; }
lnView.mid += (mid - lnView.mid ) * 0.12; // 一阶低通: 平移/缩放都平滑过渡
lnView.half += (half - lnView.half) * 0.12;
const M = lnView.mid, HV = lnView.half;
const yOf = (db) => h * 0.5 - (db - M) / (2 * HV) * (h * 0.92);
// ---- 网格: 顶/中/底 三档跟着当前量程走 ----
[M + HV, M, M - HV].forEach((db, i) => {
const y = yOf(db);
c.strokeStyle = db === 0 ? "#3a3a3a" : "#1c1c1c";
c.strokeStyle = i === 1 ? "#333" : "#1c1c1c";
c.lineWidth = 1;
c.beginPath(); c.moveTo(0, y + .5); c.lineTo(w, y + .5); c.stroke();
});
// 0 dB 基准线(更亮, 只在可见范围内才画)
const y0 = yOf(0);
if (y0 > 1 && y0 < h - 1){
c.strokeStyle = "#3a3a3a"; c.lineWidth = 1;
c.beginPath(); c.moveTo(0, y0 + .5); c.lineTo(w, y0 + .5); c.stroke();
}
// ---- 标签: 标当前量程的实际 dB 值 ----
const f1 = (v) => (v >= 0 ? "+" : "") + v.toFixed(1);
c.fillStyle = "#4a4a4a"; c.font = "9px ui-monospace,monospace";
c.fillText("+" + LN_RANGE, 2, 9);
c.fillText("0", 2, Math.max(9, yOf(0) - 2));
c.fillText("-" + LN_RANGE, 2, h - 2);
if (!lnHist.length){ c.fillStyle = "#4a4a4a"; c.font = "11px system-ui";
c.fillText(f1(M + HV), 2, 9);
c.fillText(f1(M), 2, h * 0.5 - 2);
c.fillText(f1(M - HV), 2, h - 2);
if (!n){ c.fillStyle = "#4a4a4a"; c.font = "11px system-ui";
c.fillText("无数据", w - 56, h / 2); return; }
const step = w / LN_HIST;
const off = Math.max(0, LN_HIST - lnHist.length);
const off = Math.max(0, LN_HIST - n);
// 增益折线(黄)
c.beginPath();
lnHist.forEach((v, i) => { const x = (off + i) * step, y = yOf(v);
i ? c.lineTo(x, y) : c.moveTo(x, y); });
c.strokeStyle = "rgb(250,204,21)"; c.lineWidth = 1.3; c.stroke();
// 当前点
const lx = (off + lnHist.length - 1) * step, ly = yOf(lnHist[lnHist.length - 1]);
const lx = (off + n - 1) * step, ly = yOf(lnHist[n - 1]);
c.fillStyle = "rgb(250,204,21)";
c.beginPath(); c.arc(lx, ly, 2.2, 0, 6.2832); c.fill();
}
+8
View File
@@ -722,6 +722,14 @@ def vol_bridge_tick() -> str | None:
# ★ 不能采信 VOL_FILE(存档): 重建/别处改动之后实况可能与存档不一致,
# 那桥会把这个差值当成"菜单音量"折算一次 → 音量无故跳一下(2026-09-13 教训)。
BRIDGE_REF = v if v > 0.01 else 0.35
# ★★ 2026-09-14 修正: **不要**在这里把物理输出"对齐"到虚拟声卡 —— 物理输出音量
# 是一个**档位**(WirePlumber 存档里多少就是多少), 响度由 DSP 归一化负责。
# 上一版就是这么把老板调好的 0.35 顶到 1.0 的, 结果进削波保护、动态被压扁:
# 面板波形顶满而听感反而更小(老 bug 复现)。启动时**只兜静音**, 档位一律不动。
_p0, pm0 = vol_of(BRIDGE_PID, timeout=4)
if pm0:
sh("wpctl", "set-mute", str(BRIDGE_PID), "0", timeout=4)
return "启动检查: 物理输出原本静音 → 已解除(档位不动)"
return None
ref = BRIDGE_REF
p, _pm = vol_of(BRIDGE_PID, timeout=4)
+23
View File
@@ -16,3 +16,26 @@ else
cat /tmp/cinema-webui.log
exit 1
fi
# ---------------------------------------------------------------- 看门狗
# ★ 2026-09-14: 隔夜断电重启后"又没声音"的真因之一 = 看门狗没起来(它刻意不做开机自启,
# 这是老板的习惯), 于是重启后没人看着链路(那次是物理输出被恢复成旧值 0.35)。
# 面板是日常入口, 由它顺手把看门狗拉起最自然 —— 不算"自启", 但重启后一用面板就有人守。
# 判在不在跑一律读 /proc/<pid>/cmdline: grep -f 会匹配到自己的命令行把自己算进去。
n=0
for d in /proc/[0-9]*; do
[ -r "$d/cmdline" ] || continue
if tr '\0' ' ' < "$d/cmdline" 2>/dev/null | grep -q '音频守护\.py'; then
n=$((n + 1))
fi
done
if [ "$n" -eq 0 ]; then
GD="$(dirname "$(readlink -f "$0")")/.."
mkdir -p "$HOME/.local/state/cinema-spatial"
( cd "$GD" && setsid -f python3 音频守护.py \
> "$HOME/.local/state/cinema-spatial/guardian-console.log" 2>&1 )
sleep 1
echo "看门狗: 已拉起(30 秒一轮, 扫到问题自动修)"
else
echo "看门狗: 已在跑($n 个)"
fi
+1 -1
View File
@@ -14,7 +14,7 @@ bash 打包deb.sh 2>&1 | tail -3
echo "本地: $(stat -c%s "$DEB") 字节"
echo "=== 建 release v$VER ==="
BODY="collaplex-cinema-spatial $VER — 耳机电影院空间音频 (PipeWire filter-chain + SADIE-II HRTF, 96kHz/24bit)\\n\\n核心技术: 镜像法房间声学追踪(0-80ms 早期反射, 8473 条镜面路径) + Sabine 统计尾音(80ms-2s) -> 96kHz 真实房间 IR 双耳卷积; 湿量按能量归一。不是 Freeverb/Schroeder 类算法混响。\\n\\n本版 1.3.0 —— 新增【离线渲染】+【组件看门狗】\\n\\n【离线渲染】音频/视频 -> 空间音频 FLAC (96kHz 双耳), 与实时虚拟声卡同一套参数:\\n- 链路: pan 上混 5.1 -> sofalizer(同一个 H4 SOFA, 96k/512tap) -> 房间 IR 卷积(afir) -> loudnorm\\n- 视频自动取音轨; 已是 5.1 的片源不重上混; 输出 <原名>.空间音频.flac\\n- 两块锁: (1) 渲染锁 —— 一轮任务期间不接受新任务(直接拒绝, 不排队堆积); (2) 文件就绪检查 —— 相隔 1.2 秒两次 stat 的大小/时间不变且 ffprobe 能读出音轨, 还在下载/写入的文件直接跳过\\n- 三个入口: 命令行 collaplex-render 文件... / Web 控制台「离线渲染」区(拖文件或贴路径, 实时进度条 + 日志 + 取消) / 文件管理器右键「打开方式 -> Collaplex 离线渲染」\\n- 实测 8 秒立体声 1.8 秒完成(约 4.4 倍速)\\n\\n【组件看门狗】音频守护.py + config/组件配置单.json:\\n- 30 秒一轮核对整条链的期望状态: DSP 进程数与槽位新鲜度 / 链输出连线 / 默认设备 / 虚拟声卡优先级 / 中间级音量 / 节点份数 / 控制台 / 物理设备静音\\n- 扫到就修: 能温和就温和(改音量、切默认、pw-link 重连), 只有链路级问题才重建; 同类问题 90 秒冷却, 连续 3 次修不好转只报警\\n\\n其他:\\n- 控制台: 控制滑块从图表卡片独立成「控制」卡片; 数字/模拟输出音量条拉长到各占一半(合计 = 虚拟声卡开关卡片总高)\\n- 修: DSP 孤儿抢共享槽位导致重建后无声(claim_slot 抢不到槽位即退出 + 重建前收旧 DSP)\\n- 修: 打包会把正在用的 PipeWire 配置移开(prune_duplicate_confs 在 OUT 落到非生效目录时不再清理 + CINEMA_SPATIAL_KEEP_CONFS=1 双保险)\\n\\n自 1.2.0 以来新增文件: 离线渲染.py, 离线渲染-打开.sh, 音频守护.py, 组件配置单.md, config/组件配置单.json, 桌面项/\\n\\n安装: sudo dpkg -i ${PKG}_${VER}_all.deb; systemctl --user restart pipewire pipewire-pulse"
BODY="collaplex-cinema-spatial $VER — 耳机电影院空间音频 (PipeWire filter-chain + SADIE-II HRTF, 96kHz/24bit)\\n\\n核心技术: 镜像法房间声学追踪(0-80ms 早期反射, 8473 条镜面路径) + Sabine 统计尾音(80ms-2s) -> 96kHz 真实房间 IR 双耳卷积; 湿量按能量归一。不是 Freeverb/Schroeder 类算法混响。\\n\\n本版 1.3.6 —— 面板电平可视化大修 + "没声音"类问题根治\\\\n\\\\n【1.3.6】电平表缩放加「平均 + 变速」: 峰值/谷值走 EMA(涨快落慢) + 目标量程走非对称 EMA(张开快/收缩慢, 即 attack/release 思路), 消除量程"一时全空一时拉满"的跳变。实测最大单帧跳变 6dB。\\\\n【1.3.5】电平表波形放大: 量程跟随信号实际幅度(跨度x1.45), 波形占画布约 2/3。\\\\n【1.3.4】原始/处理后电平表波形垂直居中: 顶线=峰值+半量程, 波峰不再贴框顶被标题压住。\\\\n【1.3.3】响度归一化曲线改动态量程(波峰自动居中)。\\\\n【1.3.2】面板补 favicon(内联 SVG 声波柱) + 修响度曲线被挤压。\\\\n【1.3.1】★ 重要: 物理输出音量是**档位**、不是增益 —— 撤销上一版"把它对齐到虚拟声卡(1.0)"的错误假设(那会让动态被压扁: 面板波形顶满而听感反而更小); 看门狗只兜"静音/近乎归零", 绝不改正常档位。\\\\n\\\\n本版 1.3.0 起 —— 新增【离线渲染】+【组件看门狗】\\n\\n【离线渲染】音频/视频 -> 空间音频 FLAC (96kHz 双耳), 与实时虚拟声卡同一套参数:\\n- 链路: pan 上混 5.1 -> sofalizer(同一个 H4 SOFA, 96k/512tap) -> 房间 IR 卷积(afir) -> loudnorm\\n- 视频自动取音轨; 已是 5.1 的片源不重上混; 输出 <原名>.空间音频.flac\\n- 两块锁: (1) 渲染锁 —— 一轮任务期间不接受新任务(直接拒绝, 不排队堆积); (2) 文件就绪检查 —— 相隔 1.2 秒两次 stat 的大小/时间不变且 ffprobe 能读出音轨, 还在下载/写入的文件直接跳过\\n- 三个入口: 命令行 collaplex-render 文件... / Web 控制台「离线渲染」区(拖文件或贴路径, 实时进度条 + 日志 + 取消) / 文件管理器右键「打开方式 -> Collaplex 离线渲染」\\n- 实测 8 秒立体声 1.8 秒完成(约 4.4 倍速)\\n\\n【组件看门狗】音频守护.py + config/组件配置单.json:\\n- 30 秒一轮核对整条链的期望状态: DSP 进程数与槽位新鲜度 / 链输出连线 / 默认设备 / 虚拟声卡优先级 / 中间级音量 / 节点份数 / 控制台 / 物理设备静音\\n- 扫到就修: 能温和就温和(改音量、切默认、pw-link 重连), 只有链路级问题才重建; 同类问题 90 秒冷却, 连续 3 次修不好转只报警\\n\\n其他:\\n- 控制台: 控制滑块从图表卡片独立成「控制」卡片; 数字/模拟输出音量条拉长到各占一半(合计 = 虚拟声卡开关卡片总高)\\n- 修: DSP 孤儿抢共享槽位导致重建后无声(claim_slot 抢不到槽位即退出 + 重建前收旧 DSP)\\n- 修: 打包会把正在用的 PipeWire 配置移开(prune_duplicate_confs 在 OUT 落到非生效目录时不再清理 + CINEMA_SPATIAL_KEEP_CONFS=1 双保险)\\n\\n自 1.2.0 以来新增文件: 离线渲染.py, 离线渲染-打开.sh, 音频守护.py, 组件配置单.md, config/组件配置单.json, 桌面项/\\n\\n安装: sudo dpkg -i ${PKG}_${VER}_all.deb; systemctl --user restart pipewire pipewire-pulse"
RID=$(curl -s -X POST "$API/releases" -u edgevoid:147427 -H "Content-Type: application/json" -d "{\"tag_name\":\"v$VER\",\"name\":\"v$VER\",\"body\":\"$BODY\"}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
echo "release id: $RID"
[ -n "$RID" ] || exit 1
+1 -1
View File
@@ -5,7 +5,7 @@
set -eu
SRC="/home/lou/桌面/工作区/实验/空间音频"
PKG="collaplex-cinema-spatial"
VER="1.3.0"
VER="1.3.6"
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"
+49
View File
@@ -334,6 +334,21 @@ def check_all(cfg: dict[str, Any]) -> list[str]:
if not panel_ok():
probs.append("🟢 面板 8788 无响应")
# 9) 物理输出被静音 / 近乎归零
# ★★ 2026-09-14 修正: 上一版按"物理输出应等于虚拟声卡"去纠, 结果把老板调好的 0.35
# 顶到 1.0 → 进削波保护、动态被压扁 —— 面板"波形顶满"而听感反而更小(老 bug 复现)。
# 实际约定: **物理输出音量是个档位**(存档里多少就是多少, 0.35 就很响), 响度由 DSP
# 归一化保证。所以这里只兜"静音 / 近乎归零"这种明显故障, **绝不改档位数值**。
pn = phys_name()
if pn:
for pid in node_ids(pn):
pv, pm = vol_of(pid)
if pm:
probs.append("🔴 物理输出 %s 被静音 —— 链上全对却没声音" % pn)
elif pv >= 0.0 and pv < 0.02:
probs.append("🔴 物理输出音量 %.3f (近乎归零) —— 只剩这一级还有问题" % pv)
break
return probs
@@ -385,6 +400,8 @@ def _pick_fix(desc: str) -> tuple[str, str]:
return ("dup", "重建链路(生成器会清理同名 conf)")
if "应为 1.000" in desc:
return ("unitgain", "把中间级音量纠回 1.000")
if "物理输出" in desc:
return ("physvol", "把物理输出对齐到虚拟声卡(音量真源)")
if "面板" in desc:
return ("panel", "拉起面板")
return ("misc", "重建链路")
@@ -418,9 +435,41 @@ def _do_fix(key: str, cfg: dict[str, Any]) -> str:
return "; ".join(done) if done else "无需纠正"
if key == "panel":
return fix_panel()
if key == "physvol":
return fix_phys_volume(cfg)
return fix_rebuild()
def fix_phys_volume(cfg: dict[str, Any]) -> str:
""""物理输出被静音 / 近乎归零"
★★ 2026-09-14 修正: 本函数**不再把物理输出"对齐"到虚拟声卡** —— 那是错的假设。
物理输出音量是一个**档位**(WirePlumber 存档里多少就是多少, 实测 0.35 就很响),
响度由 DSP 归一化负责。把它顶到 1.0 会进削波保护、动态被压扁: 面板波形顶满
而听感反而更小(老板当场抓出来的老 bug 复现)。所以这里只解除静音, 且只在
"近乎归零"时把档位救到一个安全值, 正常档位一律不动。
"""
pn = phys_name()
if not pn:
return "! 找不到物理输出"
pids = node_ids(pn)
if not pids:
return "! 物理输出节点不存在"
pid = pids[0]
before, bm = vol_of(pid)
if bm:
sh("wpctl", "set-mute", str(pid), "0")
after, am = vol_of(pid)
if am:
return "! 静音解不掉(仍静音)"
return "物理输出原本静音 -> 已解除(音量 %.3f 不动)" % after
if 0.0 <= before < 0.02:
sh("wpctl", "set-volume", str(pid), "0.35")
after, _am = vol_of(pid)
return "物理输出 %.3f 近乎归零 -> 救回安全档位 %.3f" % (before, after)
return "物理输出 %.3f 正常, 无需处理(档位不归看门狗管)" % before
def main() -> None:
ap = argparse.ArgumentParser(description="电影院空间音频看门狗")
ap.add_argument("--once", action="store_true", help="只跑一轮")