前端加 4 个电平表(入L/出L/入R/出R, 双声道由 flock 分槽) + 混响湿度滑块(实时); 湿量抽成独立 DSP 增益级(wet_gain.py), 混响仍用 PipeWire convolver 跑 3 秒长 IR; 实测湿量 0 无尾巴 / 1 有 3 秒衰减尾巴
This commit is contained in:
+2
-1
@@ -1,8 +1,9 @@
|
||||
#!/bin/sh
|
||||
# Collaplex 32 段 EQ + 总音量 —— pipe 插件入口
|
||||
# Collaplex 32 段 EQ + 总音量 + 房间混响 —— pipe 插件入口
|
||||
#
|
||||
# pipe 插件的 command 只支持「程序 + 一个参数」, 所以参数全写在这里;
|
||||
# 且路径必须是纯 ASCII(中文路径会静默失效)。
|
||||
# 用项目 venv 的解释器(带 scipy/numpy)。
|
||||
export CX_RATE=96000
|
||||
export CX_REVERB="__REVERB__"
|
||||
exec "__PY__" "__DSP__"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
# Collaplex 混响湿量增益级 —— pipe 插件入口
|
||||
export CX_RATE=96000
|
||||
exec "__PY__" "__DSP__"
|
||||
+69
-43
@@ -1,22 +1,31 @@
|
||||
"""Collaplex 效果 DSP 公共部分: 前端 <-> DSP 的共享内存协议。
|
||||
|
||||
前端(web/server.py) 与 DSP 进程(pipe 插件载荷)通过这一块共享内存通信:
|
||||
前端(web/server.py) 与 DSP 进程(filter-chain 的 pipe 插件载荷)通过这一块共享内存通信:
|
||||
|
||||
参数区: 32 段 EQ 增益(dB) + 总音量(dB) <- 前端写, DSP 读
|
||||
版本区: 参数版本号 <- 前端写完参数后 +1
|
||||
状态区: 输入/输出 RMS 与峰值(dB) <- DSP 写, 前端读
|
||||
参数区: 32 段 EQ 增益(dB) + 总音量(dB) + 混响湿量(0~1) <- 前端写, DSP 读
|
||||
版本区: 参数版本号 <- 前端写完参数后 +1
|
||||
状态区: 每声道 输入/输出 RMS 与峰值(dB) + 推子 <- DSP 写, 前端读
|
||||
|
||||
版本号最后写, DSP 只在版本号变化时重读参数, 所以前端一次改多个值也不会让
|
||||
DSP 读到半更新的数据。
|
||||
DSP 是**每声道一个进程**(pipe 插件按声道 fork, 两个进程命令相同), 它们靠 flock
|
||||
抢声道槽位(0 = 左, 1 = 右)各写自己那份状态, 前端才能拿到双声道共 4 个电平表。
|
||||
|
||||
版本号最后写, 所以 DSP 只在版本号变化时重读参数, 前端一次改多个值不会读到半更新。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import mmap
|
||||
import os
|
||||
import struct
|
||||
|
||||
EQ_BANDS = 32
|
||||
CHANNELS = 2
|
||||
EQ_PATH = "/dev/shm/collaplex-eq"
|
||||
LOCK_PATH = "/dev/shm/collaplex-eq.ch"
|
||||
|
||||
GAIN_LIMIT = 15.0
|
||||
VOLUME_LIMIT = 12.0
|
||||
WET_LIMIT = 1.0
|
||||
|
||||
# 1/3 倍频程中心频率(20 Hz 起, 32 段, 覆盖到 25 kHz)
|
||||
EQ_FREQS: list[float] = [
|
||||
@@ -26,16 +35,14 @@ EQ_FREQS: list[float] = [
|
||||
5000.0, 6300.0, 8000.0, 10000.0, 12500.0, 16000.0, 20000.0, 25000.0,
|
||||
]
|
||||
|
||||
GAIN_LIMIT = 15.0
|
||||
VOLUME_LIMIT = 12.0
|
||||
_PARAMS = struct.Struct("<34d") # 32 段增益 + 总音量 + 湿量
|
||||
_VER = struct.Struct("<I") # 参数版本号
|
||||
_CH = struct.Struct("<6d") # in_rms in_peak out_rms out_peak gain_db 保留
|
||||
_STATE = struct.Struct("<12d") # 2 声道
|
||||
|
||||
_DATA = struct.Struct("<33d") # 32 段增益 + 总音量
|
||||
_VER = struct.Struct("<I") # 参数版本号
|
||||
_STATE = struct.Struct("<6d") # in_rms in_peak out_rms out_peak 推子(dB) 保留
|
||||
|
||||
DATA_OFF = 0
|
||||
VER_OFF = _DATA.size
|
||||
STATE_OFF = _DATA.size + _VER.size
|
||||
PARAMS_OFF = 0
|
||||
VER_OFF = _PARAMS.size
|
||||
STATE_OFF = _PARAMS.size + _VER.size
|
||||
SIZE = STATE_OFF + _STATE.size
|
||||
|
||||
|
||||
@@ -50,23 +57,24 @@ def open_store() -> mmap.mmap:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def read_params(store: mmap.mmap) -> tuple[list[float], float, int]:
|
||||
"""读参数: (32 段增益 dB, 总音量 dB, 版本号)。"""
|
||||
store.seek(DATA_OFF)
|
||||
values = _DATA.unpack(store.read(_DATA.size))
|
||||
def read_params(store: mmap.mmap) -> tuple[list[float], float, float, int]:
|
||||
"""读参数: (32 段增益 dB, 总音量 dB, 湿量 0~1, 版本号)。"""
|
||||
store.seek(PARAMS_OFF)
|
||||
values = _PARAMS.unpack(store.read(_PARAMS.size))
|
||||
store.seek(VER_OFF)
|
||||
version = _VER.unpack(store.read(_VER.size))[0]
|
||||
return list(values[:EQ_BANDS]), float(values[EQ_BANDS]), int(version)
|
||||
return (list(values[:EQ_BANDS]), float(values[EQ_BANDS]),
|
||||
float(values[EQ_BANDS + 1]), int(version))
|
||||
|
||||
|
||||
def write_params(store: mmap.mmap, gains: list[float], volume_db: float) -> int:
|
||||
"""写参数并让版本号 +1, 返回新版本号。"""
|
||||
def write_params(store: mmap.mmap, gains: list[float], volume_db: float, wet: float) -> int:
|
||||
"""写参数并让版本号 +1(版本号最后写), 返回新版本号。"""
|
||||
if len(gains) != EQ_BANDS:
|
||||
raise ValueError("gains 长度必须是 %d" % EQ_BANDS)
|
||||
store.seek(VER_OFF)
|
||||
version = int(_VER.unpack(store.read(_VER.size))[0])
|
||||
store.seek(DATA_OFF)
|
||||
store.write(_DATA.pack(*[float(g) for g in gains], float(volume_db)))
|
||||
store.seek(PARAMS_OFF)
|
||||
store.write(_PARAMS.pack(*[float(g) for g in gains], float(volume_db), float(wet)))
|
||||
version = (version + 1) & 0xFFFFFFFF
|
||||
store.seek(VER_OFF)
|
||||
store.write(_VER.pack(version))
|
||||
@@ -74,27 +82,45 @@ def write_params(store: mmap.mmap, gains: list[float], volume_db: float) -> int:
|
||||
return version
|
||||
|
||||
|
||||
def read_state(store: mmap.mmap) -> dict[str, float]:
|
||||
"""读状态(DSP 写的电平)。"""
|
||||
def read_state(store: mmap.mmap) -> dict[str, list[float]]:
|
||||
"""读双声道状态: 每个字段是一个 [左, 右] 列表(dB, 未知时为 -120)。"""
|
||||
store.seek(STATE_OFF)
|
||||
in_rms, in_peak, out_rms, out_peak, gain_db, _spare = _STATE.unpack(store.read(_STATE.size))
|
||||
return {
|
||||
"in_rms": float(in_rms),
|
||||
"in_peak": float(in_peak),
|
||||
"out_rms": float(out_rms),
|
||||
"out_peak": float(out_peak),
|
||||
"gain_db": float(gain_db),
|
||||
}
|
||||
raw = _STATE.unpack(store.read(_STATE.size))
|
||||
keys = ("in_rms", "in_peak", "out_rms", "out_peak", "gain_db")
|
||||
return {key: [float(raw[ch * 6 + i]) for ch in range(CHANNELS)]
|
||||
for i, key in enumerate(keys)}
|
||||
|
||||
|
||||
def write_state(store: mmap.mmap, in_rms: float, in_peak: float, out_rms: float,
|
||||
out_peak: float, gain_db: float) -> None:
|
||||
"""写状态(DSP 侧调用)。"""
|
||||
store.seek(STATE_OFF)
|
||||
store.write(_STATE.pack(float(in_rms), float(in_peak), float(out_rms),
|
||||
float(out_peak), float(gain_db), 0.0))
|
||||
def write_state(store: mmap.mmap, channel: int, in_rms: float, in_peak: float,
|
||||
out_rms: float, out_peak: float, gain_db: float) -> None:
|
||||
"""写本声道的状态(DSP 侧调用)。"""
|
||||
if not 0 <= channel < CHANNELS:
|
||||
return
|
||||
store.seek(STATE_OFF + channel * _CH.size)
|
||||
store.write(_CH.pack(float(in_rms), float(in_peak), float(out_rms),
|
||||
float(out_peak), float(gain_db), 0.0))
|
||||
|
||||
|
||||
def defaults() -> tuple[list[float], float]:
|
||||
"""默认参数: 全平直 + 0 dB 总音量。"""
|
||||
return [0.0] * EQ_BANDS, 0.0
|
||||
def claim_channel() -> int:
|
||||
"""占一个声道槽位并返回它(0 = 左, 1 = 右)。
|
||||
|
||||
pipe 插件对每个声道 fork 一个进程、命令完全一样, 只能用锁区分: 谁的锁在谁占位。
|
||||
锁随进程消失自动释放, 所以重启后会重新分配(左右可能互换, 电平相近时无影响)。
|
||||
"""
|
||||
for channel in range(CHANNELS):
|
||||
path = "%s%d" % (LOCK_PATH, channel)
|
||||
try:
|
||||
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o666)
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return channel # 故意不关 fd: 进程活着锁就一直在
|
||||
except OSError:
|
||||
os.close(fd)
|
||||
return 0
|
||||
|
||||
|
||||
def defaults() -> tuple[list[float], float, float]:
|
||||
"""默认参数: 全平直 + 0 dB 总音量 + 0.3 混响湿量。"""
|
||||
return [0.0] * EQ_BANDS, 0.0, 0.3
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
参数实时来自共享内存(dsp/common.py): 32 段增益(dB) + 总音量(dB)。
|
||||
做法: 每段一个 peaking biquad, 32 段级联成二阶节(sos), 用 scipy.signal.sosfilt
|
||||
逐块滤波(C 实现, 保留滤波状态, 无额外延迟), 末级乘总音量。
|
||||
逐块滤波(C 实现, 保留滤波状态, 无额外延迟), 末级乘总音量, 再做软限幅兜底。
|
||||
|
||||
混响不在这里: 它由 PipeWire 的 convolver 完成(能跑 3 秒长 IR), 湿量由独立的
|
||||
湿量增益级(wet_gain.py)实时控制 —— 图里的 mixer 增益是加载时定死的, 改不了。
|
||||
|
||||
每声道一个进程(pipe 插件按声道 fork, 命令相同), 靠 flock 抢声道槽位(0 左 / 1 右),
|
||||
各写自己那份电平状态 —— 前端因此能画双声道共 4 个电平表。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -19,15 +25,16 @@ import common # noqa: E402
|
||||
|
||||
RATE = int(os.environ.get("CX_RATE", "96000"))
|
||||
BLOCK_BYTES = 4096
|
||||
N = BLOCK_BYTES // 4 # 每块样本数(1024)
|
||||
Q = 4.318 # 1/3 倍频程
|
||||
|
||||
CEILING = 0.99 # -0.09 dBFS 绝对上限(只防真削波)
|
||||
KNEE = 0.06 # 软拐点: 只有峰值超过约 -0.63 dBFS 才介入
|
||||
# 注: 原先 ceiling=0.891 / knee=0.25 让介入点低到 -3.9 dBFS, 而正常音乐峰值在 -4~0 dBFS,
|
||||
# 于是限幅长期工作 -> 中低频谐波失真(听感"沙沙")。阈值贴近 0 后平时完全透明。
|
||||
|
||||
|
||||
def design_peaking(freq: float, gain_db: float, fs: int, q: float) -> list[float]:
|
||||
"""RBJ cookbook 的 peaking biquad, 返回一行 sos [b0 b1 b2 1 a1 a2]。
|
||||
|
||||
gain_db = 0 时结果恰好是单位传递函数, 所以 32 段可以一直挂着(不必增删节点)。
|
||||
"""
|
||||
"""RBJ cookbook 的 peaking biquad, 返回 sos 一行 [b0 b1 b2 1 a1 a2]。"""
|
||||
amp = 10.0 ** (gain_db / 40.0)
|
||||
w0 = 2.0 * math.pi * freq / fs
|
||||
alpha = math.sin(w0) / (2.0 * q)
|
||||
@@ -42,22 +49,15 @@ def design_peaking(freq: float, gain_db: float, fs: int, q: float) -> list[float
|
||||
|
||||
|
||||
def build_sos(gains: list[float], fs: int) -> np.ndarray:
|
||||
"""32 段增益 -> sos 矩阵(32 x 6)。"""
|
||||
"""32 段 peaking 级联成二阶节(平直段的 biquad 恰好是单位响应)。"""
|
||||
return np.array([design_peaking(freq, gain, fs, Q)
|
||||
for freq, gain in zip(common.EQ_FREQS, gains)], dtype=np.float64)
|
||||
|
||||
|
||||
CEILING = 0.99 # -0.09 dBFS 绝对上限(只防真削波)
|
||||
KNEE = 0.06 # 软拐点: 只有峰值超过约 -0.63 dBFS 才介入
|
||||
# 注: 原先 ceiling=0.891 / knee=0.25 让介入点低到 -3.9 dBFS, 而正常音乐峰值在 -4~0 dBFS,
|
||||
# 于是限幅长期工作 -> 中低频谐波失真(听感"沙沙")。阈值贴近 0 后平时完全透明。
|
||||
|
||||
|
||||
def soft_limit(x: np.ndarray, ceiling: float = CEILING, knee: float = KNEE) -> np.ndarray:
|
||||
"""软限幅: 幅值在 ceiling-knee 以内的样本原样通过, 超过的部分用 tanh 平滑渐近 ceiling。
|
||||
"""软限幅: 幅值在 ceiling-knee 以内的样本原样通过, 超出部分用 tanh 平滑渐近 ceiling。
|
||||
|
||||
与 np.clip 的关键区别: 不产生平顶。硬削(平顶)听感就是"炸麦", 而且削掉的峰值
|
||||
不增加响度; 软限幅只把峰值压下来, 波形其余部分不动, 所以放大后响度能真正上来。
|
||||
和 np.clip 的区别: 不产生平顶(硬削听起来像炸麦), 只把峰值压下来。
|
||||
"""
|
||||
threshold = ceiling - knee
|
||||
magnitude = np.abs(x)
|
||||
@@ -71,7 +71,7 @@ def soft_limit(x: np.ndarray, ceiling: float = CEILING, knee: float = KNEE) -> n
|
||||
|
||||
|
||||
def level_db(block: np.ndarray) -> tuple[float, float]:
|
||||
"""返回 (RMS dBFS, 峰值 dBFS)。"""
|
||||
"""(RMS dBFS, 峰值 dBFS)。"""
|
||||
if block.size == 0:
|
||||
return -120.0, -120.0
|
||||
rms = float(np.sqrt(np.mean(block * block)))
|
||||
@@ -83,12 +83,13 @@ def level_db(block: np.ndarray) -> tuple[float, float]:
|
||||
|
||||
def main() -> None:
|
||||
store = common.open_store()
|
||||
channel = common.claim_channel()
|
||||
stdin = sys.stdin.buffer
|
||||
stdout = sys.stdout.buffer
|
||||
|
||||
version = -1
|
||||
sos = build_sos(common.defaults()[0], RATE)
|
||||
zi = np.zeros((sos.shape[0], 2), dtype=np.float64)
|
||||
sos: np.ndarray | None = None
|
||||
zi: np.ndarray | None = None
|
||||
volume = 1.0
|
||||
volume_db = 0.0
|
||||
|
||||
@@ -99,10 +100,11 @@ def main() -> None:
|
||||
if len(raw) % 4:
|
||||
raw = raw[: len(raw) - (len(raw) % 4)]
|
||||
|
||||
gains, want_volume_db, new_version = common.read_params(store)
|
||||
gains, want_volume_db, _wet, new_version = common.read_params(store)
|
||||
if new_version != version:
|
||||
version = new_version
|
||||
sos = build_sos(gains, RATE)
|
||||
zi = np.zeros((sos.shape[0], 2), dtype=np.float64)
|
||||
volume_db = want_volume_db
|
||||
volume = 10.0 ** (volume_db / 20.0)
|
||||
|
||||
@@ -110,12 +112,15 @@ def main() -> None:
|
||||
if x.size == 0:
|
||||
continue
|
||||
|
||||
y, zi = sosfilt(sos, x, zi=zi)
|
||||
out = soft_limit(y * volume)
|
||||
if sos is None or zi is None:
|
||||
out = np.zeros(x.size, dtype=np.float64)
|
||||
else:
|
||||
y, zi = sosfilt(sos, x, zi=zi)
|
||||
out = soft_limit(y * volume)
|
||||
|
||||
in_rms, in_peak = level_db(x)
|
||||
out_rms, out_peak = level_db(out)
|
||||
common.write_state(store, in_rms, in_peak, out_rms, out_peak, volume_db)
|
||||
common.write_state(store, channel, in_rms, in_peak, out_rms, out_peak, volume_db)
|
||||
|
||||
stdout.write(out.astype("<f4").tobytes())
|
||||
stdout.flush()
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""混响湿量增益级 —— pipe 插件载荷(单声道块进, 单声道块出)。
|
||||
|
||||
只做一件事: 把湿信号乘以共享内存里的湿量(0~1), 逐样本平滑过渡。
|
||||
|
||||
为什么需要它: 混响本身交给 PipeWire 的 convolver(能跑 3 秒长 IR), 但图里的
|
||||
mixer 增益是**加载时定死**的, 改它必须重载 = 断音。把"湿量"抽成 DSP 之后
|
||||
滑块就能实时生效。
|
||||
|
||||
理论说明: 混响 IR 是**未归一化**的原始响应, 中低频频响比中高频高约 20 dB,
|
||||
所以 20- 里的混响 convolver 带 gain = 0.126 做标定。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import common # noqa: E402
|
||||
|
||||
BLOCK_BYTES = 4096
|
||||
|
||||
|
||||
def main() -> None:
|
||||
store = common.open_store()
|
||||
stdin = sys.stdin.buffer
|
||||
stdout = sys.stdout.buffer
|
||||
|
||||
version = -1
|
||||
wet = 0.0
|
||||
wet_prev = 0.0
|
||||
|
||||
while True:
|
||||
raw = stdin.read(BLOCK_BYTES)
|
||||
if not raw:
|
||||
break
|
||||
if len(raw) % 4:
|
||||
raw = raw[: len(raw) - (len(raw) % 4)]
|
||||
|
||||
_gains, _volume_db, want_wet, new_version = common.read_params(store)
|
||||
if new_version != version:
|
||||
version = new_version
|
||||
wet = max(0.0, min(1.0, want_wet))
|
||||
|
||||
x = np.frombuffer(raw, dtype="<f4").astype(np.float64)
|
||||
if x.size == 0:
|
||||
continue
|
||||
|
||||
# 逐样本线性过渡, 拖滑块时不会有台阶(zipper 噪声)
|
||||
out = x * np.linspace(wet_prev, wet, x.size)
|
||||
wet_prev = wet
|
||||
|
||||
stdout.write(out.astype("<f4").tobytes())
|
||||
stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
"""把共享内存参数写成默认值(安装脚本在共享内存不存在时调用)。
|
||||
|
||||
默认: 全平直 / 总音量 0 dB / 混响湿量 0.3。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import common
|
||||
|
||||
|
||||
def main() -> None:
|
||||
store = common.open_store()
|
||||
gains, volume_db, wet = common.defaults()
|
||||
version = common.write_params(store, gains, volume_db, wet)
|
||||
print("默认参数已写入: 全平直 / 总音量 %+.1f dB / 湿量 %.2f (版本 %d)"
|
||||
% (volume_db, wet, version))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -22,8 +22,10 @@ context.modules = [
|
||||
{ type = builtin label = mixer name = hR control = { "Gain 1" = 1.0 "Gain 2" = 1.0 } }
|
||||
{ type = builtin label = convolver name = revL config = { filename = "__REVERB__" gain = 0.126 } }
|
||||
{ type = builtin label = convolver name = revR config = { filename = "__REVERB__" gain = 0.126 } }
|
||||
{ type = builtin label = mixer name = wetL control = { "Gain 1" = 1.0 "Gain 2" = __WET__ } }
|
||||
{ type = builtin label = mixer name = wetR control = { "Gain 1" = 1.0 "Gain 2" = __WET__ } }
|
||||
{ type = builtin label = pipe name = wetgL config = { command = "__WET_WRAPPER__" } }
|
||||
{ type = builtin label = pipe name = wetgR config = { command = "__WET_WRAPPER__" } }
|
||||
{ type = builtin label = mixer name = wetL control = { "Gain 1" = 1.0 "Gain 2" = 1.0 } }
|
||||
{ type = builtin label = mixer name = wetR control = { "Gain 1" = 1.0 "Gain 2" = 1.0 } }
|
||||
]
|
||||
links = [
|
||||
{ output = "iL:Out" input = "cFLL:In" }
|
||||
@@ -36,10 +38,12 @@ context.modules = [
|
||||
{ output = "cFRR:Out" input = "hR:In 2" }
|
||||
{ output = "hL:Out" input = "wetL:In 1" }
|
||||
{ output = "hL:Out" input = "revL:In" }
|
||||
{ output = "revL:Out" input = "wetL:In 2" }
|
||||
{ output = "revL:Out" input = "wetgL:In" }
|
||||
{ output = "wetgL:Out" input = "wetL:In 2" }
|
||||
{ output = "hR:Out" input = "wetR:In 1" }
|
||||
{ output = "hR:Out" input = "revR:In" }
|
||||
{ output = "revR:Out" input = "wetR:In 2" }
|
||||
{ output = "revR:Out" input = "wetgR:In" }
|
||||
{ output = "wetgR:Out" input = "wetR:In 2" }
|
||||
]
|
||||
inputs = [ "iL:In" "iR:In" ]
|
||||
outputs = [ "wetL:Out" "wetR:Out" ]
|
||||
|
||||
+195
-210
@@ -7,244 +7,229 @@
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f0f0f; --card:#161616; --card-hi:#1c1c1c; --bd:#262626; --bd-hi:#343434;
|
||||
--fg:#e8e8e8; --mut:#8a8a8a; --dim:#666; --ok:#4ade80; --ok-bg:#12211a; --ok-bd:#166534;
|
||||
--r:12px;
|
||||
--fg:#e8e8e8; --mut:#8a8a8a; --dim:#666; --ok:#4ade80; --warn:#fbbf24; --hot:#f87171;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}
|
||||
.wrap{max-width:1280px;margin:0 auto;padding:18px 20px 40px}
|
||||
header{display:flex;align-items:baseline;gap:16px;border-bottom:1px solid var(--bd);padding-bottom:12px}
|
||||
h1{font-size:15px;font-weight:600;margin:0;letter-spacing:.02em}
|
||||
.hint{color:var(--dim);font-size:11px}
|
||||
.meter{margin-left:auto;display:flex;gap:18px;align-items:baseline;color:var(--mut);font-size:11px}
|
||||
.meter b{color:var(--ok);font-weight:500}
|
||||
.rows{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:16px 0}
|
||||
.card{background:var(--card);border:1px solid var(--bd);border-radius:var(--r);padding:14px 16px}
|
||||
.card h2{margin:0 0 12px;font-size:12px;font-weight:500;color:var(--mut);letter-spacing:.06em}
|
||||
.slider-row{display:flex;align-items:center;gap:14px}
|
||||
.val{min-width:78px;text-align:right;color:var(--ok);font-variant-numeric:tabular-nums}
|
||||
input[type=range]{-webkit-appearance:none;appearance:none;height:22px;background:transparent;outline:none;flex:1}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--fg);font-family:"JetBrains Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
font-size:13px;padding:18px 20px 26px}
|
||||
h1{font-size:16px;font-weight:600;letter-spacing:.5px}
|
||||
h1 small{color:var(--mut);font-size:11px;font-weight:400;margin-left:10px}
|
||||
.head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:14px;gap:12px}
|
||||
.status{color:var(--mut);font-size:11px}
|
||||
.card{background:var(--card);border:1px solid var(--bd);border-radius:12px;padding:14px 16px;margin-bottom:14px}
|
||||
.card h2{font-size:11px;font-weight:500;color:var(--mut);letter-spacing:1px;margin-bottom:12px;text-transform:uppercase}
|
||||
|
||||
/* ---- 电平表 ---- */
|
||||
.meters{display:grid;grid-template-columns:1fr 1fr;gap:10px 22px}
|
||||
.meter{display:flex;align-items:center;gap:9px}
|
||||
.meter .lbl{width:30px;color:var(--mut);font-size:11px;flex:none}
|
||||
.bar{position:relative;flex:1;height:12px;background:#111;border:1px solid var(--bd);border-radius:3px;overflow:hidden}
|
||||
.bar i{position:absolute;left:0;top:0;bottom:0;width:0%;
|
||||
background:linear-gradient(90deg,#166534,#4ade80 72%,#fbbf24 90%,#f87171);transition:width .08s linear}
|
||||
.meter .val{width:56px;text-align:right;font-size:11px;color:var(--fg);flex:none;font-variant-numeric:tabular-nums}
|
||||
.meter .pk{font-size:10px;color:var(--dim);width:52px;text-align:right;flex:none}
|
||||
|
||||
/* ---- 滑块 ---- */
|
||||
.row{display:flex;align-items:center;gap:14px;margin-bottom:12px}
|
||||
.row:last-child{margin-bottom:0}
|
||||
.row .name{width:96px;flex:none;color:var(--fg);font-size:12px}
|
||||
.row .num{width:84px;text-align:right;flex:none;color:var(--ok);font-variant-numeric:tabular-nums}
|
||||
input[type=range]{-webkit-appearance:none;appearance:none;flex:1;height:22px;background:transparent;cursor:ns-resize}
|
||||
input[type=range]::-webkit-slider-runnable-track{height:4px;background:var(--bd-hi);border-radius:2px}
|
||||
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;margin-top:-6px;
|
||||
border-radius:50%;background:var(--ok);border:0;cursor:grab;box-shadow:0 0 0 3px rgba(74,222,128,.15)}
|
||||
input[type=range]:active::-webkit-slider-thumb{cursor:grabbing}
|
||||
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;margin-top:-5px;
|
||||
border-radius:50%;background:var(--ok);border:none}
|
||||
input[type=range]:hover::-webkit-slider-thumb{background:#86efac}
|
||||
input[type=range]::-moz-range-track{height:4px;background:var(--bd-hi);border-radius:2px}
|
||||
input[type=range]::-moz-range-thumb{width:16px;height:16px;border:0;border-radius:50%;background:var(--ok)}
|
||||
.eqcard{background:var(--card);border:1px solid var(--bd);border-radius:var(--r);padding:16px 10px 10px}
|
||||
.eq{display:grid;grid-template-columns:repeat(32,1fr);gap:2px;height:230px}
|
||||
.band{display:flex;flex-direction:column;align-items:center;gap:4px}
|
||||
.band input[type=range]{writing-mode:vertical-lr;direction:rtl;width:22px;height:190px;flex:none}
|
||||
.band .db{font-size:9px;color:var(--dim);font-variant-numeric:tabular-nums;height:11px}
|
||||
.freqs{display:grid;grid-template-columns:repeat(32,1fr);gap:2px;margin-top:6px}
|
||||
.freqs span{font-size:9px;color:var(--dim);text-align:center;transform:rotate(-60deg);transform-origin:center;
|
||||
white-space:nowrap;height:26px}
|
||||
.actions{display:flex;gap:10px;margin-top:14px;align-items:center}
|
||||
button{background:var(--card-hi);color:var(--fg);border:1px solid var(--bd-hi);border-radius:8px;
|
||||
padding:7px 14px;font:inherit;font-size:12px;cursor:pointer}
|
||||
input[type=range]::-moz-range-thumb{width:14px;height:14px;border:none;border-radius:50%;background:var(--ok)}
|
||||
.hint{color:var(--dim);font-size:10px;margin-top:8px}
|
||||
|
||||
/* ---- EQ ---- */
|
||||
.eq{display:flex;align-items:flex-end;gap:3px;height:190px;padding-top:6px}
|
||||
.band{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;height:100%}
|
||||
.band .v{font-size:9px;color:var(--dim);height:11px;font-variant-numeric:tabular-nums}
|
||||
.band input{writing-mode:vertical-lr;direction:rtl;width:100%;height:100%;
|
||||
-webkit-appearance:none;appearance:none;background:transparent;cursor:ns-resize}
|
||||
.band input::-webkit-slider-runnable-track{width:4px;height:100%;background:var(--bd-hi);border-radius:2px}
|
||||
.band input::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:9px;margin-left:-5px;
|
||||
border-radius:2px;background:var(--ok)}
|
||||
.band:hover input::-webkit-slider-thumb{background:#86efac}
|
||||
.band input::-moz-range-track{width:4px;background:var(--bd-hi);border-radius:2px}
|
||||
.band input::-moz-range-thumb{width:14px;height:9px;border:none;border-radius:2px;background:var(--ok)}
|
||||
.band .f{font-size:9px;color:var(--mut);transform:rotate(-60deg);transform-origin:center;height:20px;
|
||||
white-space:nowrap;margin-top:2px}
|
||||
.btns{display:flex;gap:8px;margin-top:12px}
|
||||
button{background:var(--card-hi);color:var(--fg);border:1px solid var(--bd-hi);border-radius:6px;
|
||||
padding:5px 12px;font:inherit;font-size:11px;cursor:pointer}
|
||||
button:hover{border-color:var(--ok);color:var(--ok)}
|
||||
button.lit{background:var(--ok-bg);border-color:var(--ok-bd);color:#bbf7d0}
|
||||
.toggle{display:flex;align-items:center;gap:7px;color:var(--mut);font-size:12px;cursor:pointer}
|
||||
.state{margin-left:auto;color:var(--dim);font-size:11px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1>Collaplex 音效</h1>
|
||||
<span class="hint">滚轮调滑块 · Shift+滚轮微调 · 双击归零</span>
|
||||
<div class="meter">
|
||||
<span>入 <b id="lvl-in">-∞</b> dBFS</span>
|
||||
<span>出 <b id="lvl-out">-∞</b> dBFS</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="head">
|
||||
<h1>Collaplex 音效<small>滚轮调节 · Shift+滚轮微调 · 双击归零</small></h1>
|
||||
<div class="status" id="status">就绪</div>
|
||||
</div>
|
||||
|
||||
<div class="rows">
|
||||
<div class="card">
|
||||
<h2>电平归一化(响度目标)</h2>
|
||||
<div class="slider-row">
|
||||
<input type="range" id="loud" min="-40" max="-5" step="0.5" value="-16">
|
||||
<span class="val" id="loud-val">-16.0 dB</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<label class="toggle"><input type="checkbox" id="loud-on" checked> 启用</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>总音量放大</h2>
|
||||
<div class="slider-row">
|
||||
<input type="range" id="vol" min="-12" max="12" step="0.5" value="6">
|
||||
<span class="val" id="vol-val">+6.0 dB</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="reset-all">全部归零</button>
|
||||
<button id="reset-eq">EQ 归零</button>
|
||||
<span class="state" id="state">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>电平</h2>
|
||||
<div class="meters">
|
||||
<div class="meter"><span class="lbl">入 L</span><div class="bar"><i id="bar-inL"></i></div><span class="val" id="val-inL">-∞</span><span class="pk" id="pk-inL"></span></div>
|
||||
<div class="meter"><span class="lbl">出 L</span><div class="bar"><i id="bar-outL"></i></div><span class="val" id="val-outL">-∞</span><span class="pk" id="pk-outL"></span></div>
|
||||
<div class="meter"><span class="lbl">入 R</span><div class="bar"><i id="bar-inR"></i></div><span class="val" id="val-inR">-∞</span><span class="pk" id="pk-inR"></span></div>
|
||||
<div class="meter"><span class="lbl">出 R</span><div class="bar"><i id="bar-outR"></i></div><span class="val" id="val-outR">-∞</span><span class="pk" id="pk-outR"></span></div>
|
||||
</div>
|
||||
<div class="hint">条长 = 实时 RMS(-60 ~ 0 dBFS),右侧小字 = 本块峰值</div>
|
||||
</div>
|
||||
|
||||
<div class="eqcard">
|
||||
<div class="eq" id="eq"></div>
|
||||
<div class="freqs" id="freqs"></div>
|
||||
<div class="card">
|
||||
<h2>控制</h2>
|
||||
<div class="row">
|
||||
<span class="name">电平归一化</span>
|
||||
<input type="range" id="loud" min="-30" max="-6" step="0.5">
|
||||
<span class="num" id="loud-num">—</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="name">混响湿度</span>
|
||||
<input type="range" id="wet" min="0" max="1" step="0.01">
|
||||
<span class="num" id="wet-num">—</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="name">总音量</span>
|
||||
<input type="range" id="vol" min="-12" max="12" step="0.5">
|
||||
<span class="num" id="vol-num">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>32 段 EQ(1/3 倍频程 · ±15 dB)</h2>
|
||||
<div class="eq" id="eq"></div>
|
||||
<div class="btns">
|
||||
<button id="flat">全部归零</button>
|
||||
<button id="reset">重新读取</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const el = (id) => document.getElementById(id);
|
||||
let gains = new Array(32).fill(0);
|
||||
let freqs = [];
|
||||
let pending = null;
|
||||
let state = null;
|
||||
const post = async (path, body) => {
|
||||
const r = await fetch(path, {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(body)});
|
||||
return r.ok;
|
||||
};
|
||||
const say = (t) => { el("status").textContent = t; };
|
||||
|
||||
function fmt(v, plus) {
|
||||
const s = (v > 0 && plus ? "+" : "") + v.toFixed(1);
|
||||
return s + " dB";
|
||||
}
|
||||
|
||||
// 滑块 + 滚轮捕捉
|
||||
function bindWheel(el, onChange) {
|
||||
el.addEventListener("wheel", (e) => {
|
||||
/* ---------- 滚轮捕获 ---------- */
|
||||
function bindWheel(input, onDelta, onDone) {
|
||||
input.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 0.1 : 0.5;
|
||||
const dir = e.deltaY < 0 ? 1 : -1;
|
||||
const next = parseFloat(el.value) + dir * step;
|
||||
el.value = Math.min(parseFloat(el.max), Math.max(parseFloat(el.min), next));
|
||||
onChange(parseFloat(el.value));
|
||||
}, { passive: false });
|
||||
const step = parseFloat(input.step) || 1;
|
||||
const fine = e.shiftKey ? step / 5 : step;
|
||||
const next = Math.min(parseFloat(input.max), Math.max(parseFloat(input.min),
|
||||
parseFloat(input.value) + (e.deltaY < 0 ? fine : -fine)));
|
||||
input.value = next;
|
||||
onDelta(next);
|
||||
if (onDone) clearTimeout(input._t), input._t = setTimeout(onDone, 90);
|
||||
}, {passive:false});
|
||||
}
|
||||
|
||||
function throttled(fn, ms) {
|
||||
let last = 0, timer = null, lastArgs = null;
|
||||
return function (...args) {
|
||||
lastArgs = args;
|
||||
const now = Date.now();
|
||||
const run = () => { last = Date.now(); timer = null; fn(...lastArgs); };
|
||||
if (now - last >= ms) run();
|
||||
else if (!timer) timer = setTimeout(run, ms - (now - last));
|
||||
};
|
||||
/* ---------- 三个控制滑块 ---------- */
|
||||
const loud = el("loud"), wet = el("wet"), vol = el("vol");
|
||||
loud.addEventListener("input", () => el("loud-num").textContent = (+loud.value).toFixed(1) + " dB");
|
||||
wet.addEventListener("input", () => el("wet-num").textContent = (+wet.value).toFixed(2));
|
||||
vol.addEventListener("input", () => el("vol-num").textContent = (+vol.value >= 0 ? "+" : "") + (+vol.value).toFixed(1) + " dB");
|
||||
loud.addEventListener("change", async () => { await post("/api/loudness", {target_db:+loud.value}); say("归一化已生效"); });
|
||||
wet.addEventListener("change", async () => { await post("/api/wet", {wet:+wet.value}); say("湿度已生效"); });
|
||||
vol.addEventListener("change", async () => { await post("/api/volume", {db:+vol.value}); say("总音量已生效"); });
|
||||
bindWheel(loud, v => el("loud-num").textContent = v.toFixed(1) + " dB", () => loud.dispatchEvent(new Event("change")));
|
||||
bindWheel(wet, v => el("wet-num").textContent = v.toFixed(2), () => wet.dispatchEvent(new Event("change")));
|
||||
bindWheel(vol, v => el("vol-num").textContent = (v >= 0 ? "+" : "") + v.toFixed(1) + " dB", () => vol.dispatchEvent(new Event("change")));
|
||||
[loud, wet, vol].forEach(s => s.addEventListener("dblclick", () => {
|
||||
s.value = s.dataset.def; s.dispatchEvent(new Event("input")); s.dispatchEvent(new Event("change"));
|
||||
}));
|
||||
loud.dataset.def = -16; wet.dataset.def = 0.3; vol.dataset.def = 0;
|
||||
|
||||
/* ---------- 32 段 EQ ---------- */
|
||||
const eqBox = el("eq");
|
||||
let commitTimer = null;
|
||||
function commitEq() {
|
||||
clearTimeout(commitTimer);
|
||||
commitTimer = setTimeout(async () => {
|
||||
const ok = await post("/api/eq", {gains});
|
||||
say(ok ? "EQ 已生效" : "EQ 写入失败");
|
||||
}, 90);
|
||||
}
|
||||
|
||||
const pushEq = throttled(async () => {
|
||||
setState("写入 EQ…");
|
||||
try {
|
||||
const r = await fetch("/api/eq", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gains })
|
||||
});
|
||||
setState(r.ok ? "EQ 已生效" : "EQ 写入失败");
|
||||
} catch (err) { setState("EQ 写入失败"); }
|
||||
}, 90);
|
||||
|
||||
const pushVol = throttled(async (db) => {
|
||||
setState("写入总音量…");
|
||||
try {
|
||||
const r = await fetch("/api/volume", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ db })
|
||||
});
|
||||
setState(r.ok ? "总音量已生效" : "写入失败");
|
||||
} catch (err) { setState("写入失败"); }
|
||||
}, 90);
|
||||
|
||||
const pushLoud = throttled(async (db, on) => {
|
||||
setState("写入归一化…");
|
||||
try {
|
||||
const r = await fetch("/api/loudness", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target_db: db, on })
|
||||
});
|
||||
setState(r.ok ? "归一化已生效" : "写入失败");
|
||||
} catch (err) { setState("写入失败"); }
|
||||
}, 90);
|
||||
|
||||
function setState(text) { $("state").textContent = text; }
|
||||
|
||||
// 32 段 EQ 滑块
|
||||
function buildEq() {
|
||||
const box = $("eq"), fbox = $("freqs");
|
||||
box.innerHTML = ""; fbox.innerHTML = "";
|
||||
for (let i = 0; i < 32; i++) {
|
||||
state = null;
|
||||
function buildEq(freqs) {
|
||||
eqBox.innerHTML = "";
|
||||
freqs.forEach((f, i) => {
|
||||
const band = document.createElement("div");
|
||||
band.className = "band";
|
||||
const db = document.createElement("div");
|
||||
db.className = "db"; db.id = "db" + i; db.textContent = "0.0";
|
||||
const sl = document.createElement("input");
|
||||
sl.type = "range"; sl.min = "-15"; sl.max = "15"; sl.step = "0.5"; sl.value = "0";
|
||||
sl.dataset.index = i;
|
||||
sl.addEventListener("input", () => {
|
||||
gains[i] = parseFloat(sl.value);
|
||||
$("db" + i).textContent = gains[i].toFixed(1);
|
||||
pushEq();
|
||||
const v = document.createElement("div"); v.className = "v"; v.textContent = "0";
|
||||
const input = document.createElement("input");
|
||||
input.type = "range"; input.min = -15; input.max = 15; input.step = 0.5; input.value = 0;
|
||||
input.dataset.index = i;
|
||||
const label = document.createElement("div"); label.className = "f";
|
||||
label.textContent = f >= 1000 ? (f / 1000) + "k" : f;
|
||||
input.addEventListener("input", () => {
|
||||
const val = parseFloat(input.value);
|
||||
v.textContent = val === 0 ? "" : (val > 0 ? "+" : "") + val.toFixed(1);
|
||||
gains[i] = val;
|
||||
commitEq();
|
||||
});
|
||||
bindWheel(sl, (v) => {
|
||||
gains[i] = v; $("db" + i).textContent = v.toFixed(1); pushEq();
|
||||
});
|
||||
sl.addEventListener("dblclick", () => {
|
||||
sl.value = "0"; gains[i] = 0; $("db" + i).textContent = "0.0"; pushEq();
|
||||
});
|
||||
band.appendChild(db); band.appendChild(sl);
|
||||
box.appendChild(band);
|
||||
const f = document.createElement("span");
|
||||
f.textContent = freqs[i] ? (freqs[i] >= 1000 ? (freqs[i] / 1000) + "k" : String(freqs[i])) : "";
|
||||
fbox.appendChild(f);
|
||||
bindWheel(input, (val) => { input.value = val; input.dispatchEvent(new Event("input")); });
|
||||
input.addEventListener("dblclick", () => { input.value = 0; input.dispatchEvent(new Event("input")); });
|
||||
band.append(v, input, label);
|
||||
eqBox.appendChild(band);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 电平表 ---------- */
|
||||
const METERS = [
|
||||
["inL", "in_rms", 0], ["outL", "out_rms", 0],
|
||||
["inR", "in_rms", 1], ["outR", "out_rms", 1],
|
||||
];
|
||||
const PK = [["pk-inL","in_peak",0],["pk-outL","out_peak",0],["pk-inR","in_peak",1],["pk-outR","out_peak",1]];
|
||||
function fmt(db) { return db <= -119 ? "-∞" : db.toFixed(1); }
|
||||
function updateMeters(levels) {
|
||||
if (!levels) return;
|
||||
for (const [id, key, ch] of METERS) {
|
||||
const db = (levels[key] || [-120, -120])[ch];
|
||||
const pct = Math.max(0, Math.min(100, (db + 60) / 60 * 100));
|
||||
el("bar-" + id).style.width = pct + "%";
|
||||
el("val-" + id).textContent = fmt(db);
|
||||
}
|
||||
for (const [id, key, ch] of PK) {
|
||||
const db = (levels[key] || [-120, -120])[ch];
|
||||
el(id).textContent = db <= -119 ? "" : fmt(db);
|
||||
}
|
||||
}
|
||||
|
||||
// 同步 DSP 实时电平
|
||||
async function poll() {
|
||||
/* ---------- 轮询 ---------- */
|
||||
async function tick() {
|
||||
try {
|
||||
const s = await (await fetch("/api/state")).json();
|
||||
$("lvl-in").textContent = (s.levels.in_rms <= -120 ? "-∞" : s.levels.in_rms.toFixed(1));
|
||||
$("lvl-out").textContent = (s.levels.out_rms <= -120 ? "-∞" : s.levels.out_rms.toFixed(1));
|
||||
} catch (err) { /* 忽略 */ }
|
||||
const r = await fetch("/api/state");
|
||||
const d = await r.json();
|
||||
if (state === null) {
|
||||
buildEq(d.freqs);
|
||||
gains = d.gains.slice();
|
||||
eqBox.querySelectorAll("input").forEach((inp) => {
|
||||
inp.value = gains[+inp.dataset.index];
|
||||
const v = inp.previousElementSibling;
|
||||
const val = parseFloat(inp.value);
|
||||
v.textContent = val === 0 ? "" : (val > 0 ? "+" : "") + val.toFixed(1);
|
||||
});
|
||||
}
|
||||
if (document.activeElement !== vol) { vol.value = d.volume_db; el("vol-num").textContent = (d.volume_db >= 0 ? "+" : "") + d.volume_db.toFixed(1) + " dB"; }
|
||||
if (document.activeElement !== wet) { wet.value = d.wet; el("wet-num").textContent = (+d.wet).toFixed(2); }
|
||||
if (document.activeElement !== loud && d.loudness) { loud.value = d.loudness.target_db; el("loud-num").textContent = (+d.loudness.target_db).toFixed(1) + " dB"; }
|
||||
updateMeters(d.levels);
|
||||
state = d;
|
||||
} catch (e) { say("面板与 DSP 失联"); }
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const s = await (await fetch("/api/state")).json();
|
||||
freqs = s.freqs;
|
||||
gains = s.gains.slice();
|
||||
buildEq();
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const sl = document.querySelector('.band input[data-index="' + i + '"]');
|
||||
sl.value = String(gains[i]);
|
||||
$("db" + i).textContent = gains[i].toFixed(1);
|
||||
}
|
||||
const loud = s.loudness || { target_db: -16, on: true };
|
||||
$("loud").value = loud.target_db; $("loud-val").textContent = fmt(loud.target_db, false);
|
||||
$("loud-on").checked = loud.on !== false;
|
||||
$("vol").value = s.volume_db; $("vol-val").textContent = fmt(s.volume_db, true);
|
||||
|
||||
$("loud").addEventListener("input", (e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
$("loud-val").textContent = fmt(v, false);
|
||||
pushLoud(v, $("loud-on").checked);
|
||||
});
|
||||
bindWheel($("loud"), (v) => { $("loud-val").textContent = fmt(v, false); pushLoud(v, $("loud-on").checked); });
|
||||
$("loud-on").addEventListener("change", (e) => pushLoud(parseFloat($("loud").value), e.target.checked));
|
||||
|
||||
$("vol").addEventListener("input", (e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
$("vol-val").textContent = fmt(v, true);
|
||||
pushVol(v);
|
||||
});
|
||||
bindWheel($("vol"), (v) => { $("vol-val").textContent = fmt(v, true); pushVol(v); });
|
||||
|
||||
$("reset-eq").addEventListener("click", () => {
|
||||
gains = new Array(32).fill(0);
|
||||
for (let i = 0; i < 32; i++) { document.querySelector('.band input[data-index="' + i + '"]').value = "0"; $("db" + i).textContent = "0.0"; }
|
||||
pushEq();
|
||||
});
|
||||
$("reset-all").addEventListener("click", () => {
|
||||
$("reset-eq").click();
|
||||
$("vol").value = "0"; $("vol-val").textContent = fmt(0, true); pushVol(0);
|
||||
});
|
||||
|
||||
setInterval(poll, 700);
|
||||
poll();
|
||||
}
|
||||
|
||||
init();
|
||||
el("flat").addEventListener("click", () => { gains = new Array(32).fill(0); commitEq(); buildEq(state ? state.freqs : []); });
|
||||
el("reset").addEventListener("click", () => { state = null; tick(); });
|
||||
tick();
|
||||
setInterval(tick, 250);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+82
-70
@@ -4,33 +4,32 @@
|
||||
|
||||
页面: /
|
||||
接口:
|
||||
GET /api/state 参数 + DSP 实时电平
|
||||
POST /api/eq {"gains": [32 个 dB]}
|
||||
POST /api/volume {"db": 数值}
|
||||
POST /api/loudness {"target_db": 数值, "on": true/false}
|
||||
GET /api/state 参数 + DSP 双声道电平
|
||||
POST /api/eq {"gains": [32 个数]} 每段 dB(±15)
|
||||
POST /api/volume {"db": -12..12} 总音量
|
||||
POST /api/wet {"wet": 0..1} 混响湿量
|
||||
POST /api/loudness {"target_db": -30..-6, "on": bool} 响度归一化(DSP 每秒重读该 JSON)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import cast
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "..", "dsp"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "dsp"))
|
||||
|
||||
import common # noqa: E402
|
||||
|
||||
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8789
|
||||
UI_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
|
||||
LOUDNESS_JSON = os.path.expanduser("~/.local/state/cinema-spatial/loudness.json")
|
||||
UI = os.path.join(HERE, "index.html")
|
||||
DEFAULT_PORT = 8789
|
||||
|
||||
|
||||
def _as_float(value: object, fallback: float = 0.0) -> float:
|
||||
"""把 JSON 取到的值转 float(来源类型不安全, 这里只做值校验)。"""
|
||||
if isinstance(value, bool):
|
||||
return fallback
|
||||
"""把 JSON 里取到的值转成 float(类型不安全, 只在值上做校验)。"""
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
@@ -41,43 +40,45 @@ def _as_float(value: object, fallback: float = 0.0) -> float:
|
||||
return fallback
|
||||
|
||||
|
||||
def _clamp(value: float, low: float, high: float) -> float:
|
||||
return max(low, min(high, value))
|
||||
|
||||
|
||||
def read_loudness() -> dict[str, object]:
|
||||
"""读响度归一化设置(归一化 DSP 每秒重读这个文件)。"""
|
||||
fallback: dict[str, object] = {"target_db": -16.0, "on": True}
|
||||
try:
|
||||
with open(LOUDNESS_JSON, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if isinstance(data, dict):
|
||||
return cast(dict[str, object], data)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return {"on": True, "target_db": -16.0}
|
||||
return fallback
|
||||
return cast(dict[str, object], data) if isinstance(data, dict) else fallback
|
||||
|
||||
|
||||
def write_loudness(target_db: float, on: bool) -> None:
|
||||
os.makedirs(os.path.dirname(LOUDNESS_JSON), exist_ok=True)
|
||||
with open(LOUDNESS_JSON, "w", encoding="utf-8") as fh:
|
||||
json.dump({"on": bool(on), "target_db": float(target_db)}, fh)
|
||||
def write_loudness(data: dict[str, object]) -> None:
|
||||
"""原子写(临时文件 + rename), 归一化 DSP 每秒重读。"""
|
||||
directory = os.path.dirname(LOUDNESS_JSON)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
tmp = LOUDNESS_JSON + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, LOUDNESS_JSON)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
store = common.open_store()
|
||||
protocol_version = "HTTP/1.1"
|
||||
lock = threading.Lock()
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
pass
|
||||
|
||||
def _send(self, body: bytes, ctype: str, code: int = 200) -> None:
|
||||
def _send_json(self, payload: object, code: int = 200) -> None:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_json(self, obj: object, code: int = 200) -> None:
|
||||
self._send(json.dumps(obj, ensure_ascii=False).encode("utf-8"),
|
||||
"application/json; charset=utf-8", code)
|
||||
|
||||
def _read_json(self) -> dict[str, object]:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.rfile.read(length) if length else b"{}"
|
||||
@@ -90,63 +91,74 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
if self.path in ("/", "/index.html"):
|
||||
try:
|
||||
with open(UI, "rb") as fh:
|
||||
self._send(fh.read(), "text/html; charset=utf-8")
|
||||
with open(UI_PATH, "rb") as fh:
|
||||
body = fh.read()
|
||||
except OSError:
|
||||
self._send("面板文件缺失".encode("utf-8"), "text/plain; charset=utf-8", 500)
|
||||
self.send_error(404)
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if self.path == "/api/state":
|
||||
gains, volume_db, _ver = common.read_params(self.store)
|
||||
loudness = read_loudness()
|
||||
with self.lock:
|
||||
gains, volume_db, wet, _version = common.read_params(self.store)
|
||||
levels = common.read_state(self.store)
|
||||
self._send_json({
|
||||
"freqs": common.EQ_FREQS,
|
||||
"gains": gains,
|
||||
"volume_db": volume_db,
|
||||
"wet": wet,
|
||||
"gain_limit": common.GAIN_LIMIT,
|
||||
"volume_limit": common.VOLUME_LIMIT,
|
||||
"loudness": {
|
||||
"target_db": _as_float(loudness.get("target_db", -16.0), -16.0),
|
||||
"on": bool(loudness.get("on", True)),
|
||||
},
|
||||
"levels": common.read_state(self.store),
|
||||
"levels": levels,
|
||||
"loudness": read_loudness(),
|
||||
})
|
||||
return
|
||||
self._send_json({"error": "not found"}, 404)
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
data = self._read_json()
|
||||
if self.path == "/api/eq":
|
||||
raw_gains = data.get("gains")
|
||||
if not isinstance(raw_gains, list) or len(raw_gains) != common.EQ_BANDS:
|
||||
self._send_json({"error": "gains 必须是 %d 个数" % common.EQ_BANDS}, 400)
|
||||
with self.lock:
|
||||
gains, volume_db, wet, _version = common.read_params(self.store)
|
||||
|
||||
if self.path == "/api/eq":
|
||||
raw_gains = data.get("gains")
|
||||
if not isinstance(raw_gains, list) or len(raw_gains) != common.EQ_BANDS:
|
||||
self._send_json(
|
||||
{"ok": False, "error": "gains 需要 %d 个数" % common.EQ_BANDS}, 400)
|
||||
return
|
||||
gains = [_clamp(_as_float(g), -common.GAIN_LIMIT, common.GAIN_LIMIT)
|
||||
for g in cast(list[object], raw_gains)]
|
||||
elif self.path == "/api/volume":
|
||||
volume_db = _clamp(_as_float(data.get("db", 0.0)),
|
||||
-common.VOLUME_LIMIT, common.VOLUME_LIMIT)
|
||||
elif self.path == "/api/wet":
|
||||
wet = _clamp(_as_float(data.get("wet", 0.0)), 0.0, common.WET_LIMIT)
|
||||
elif self.path == "/api/loudness":
|
||||
current = read_loudness()
|
||||
target = _clamp(_as_float(data.get("target_db", current.get("target_db", -16.0)), -16.0),
|
||||
-30.0, -6.0)
|
||||
on = data.get("on", current.get("on", True))
|
||||
write_loudness({"target_db": target, "on": bool(on)})
|
||||
self._send_json({"ok": True, "loudness": read_loudness()})
|
||||
return
|
||||
items = cast(list[object], raw_gains)
|
||||
gains = [max(-common.GAIN_LIMIT, min(common.GAIN_LIMIT, _as_float(item))) for item in items]
|
||||
_g, volume_db, _v = common.read_params(self.store)
|
||||
version = common.write_params(self.store, gains, volume_db)
|
||||
self._send_json({"ok": True, "version": version})
|
||||
return
|
||||
if self.path == "/api/volume":
|
||||
volume_db = max(-common.VOLUME_LIMIT,
|
||||
min(common.VOLUME_LIMIT, _as_float(data.get("db", 0.0))))
|
||||
gains, _old, _v = common.read_params(self.store)
|
||||
version = common.write_params(self.store, gains, volume_db)
|
||||
self._send_json({"ok": True, "version": version, "volume_db": volume_db})
|
||||
return
|
||||
if self.path == "/api/loudness":
|
||||
current = read_loudness()
|
||||
target_db = _as_float(data.get("target_db", current.get("target_db", -16.0)), -16.0)
|
||||
on = bool(data.get("on", current.get("on", True)))
|
||||
write_loudness(max(-40.0, min(-5.0, target_db)), on)
|
||||
self._send_json({"ok": True, "loudness": read_loudness()})
|
||||
return
|
||||
self._send_json({"error": "not found"}, 404)
|
||||
else:
|
||||
self.send_error(404)
|
||||
return
|
||||
|
||||
common.write_params(self.store, gains, volume_db, wet)
|
||||
self._send_json({"ok": True, "gains": gains, "volume_db": volume_db, "wet": wet})
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT
|
||||
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||
print("Collaplex 音效控制面板: http://127.0.0.1:%d" % port, flush=True)
|
||||
server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
|
||||
print("Collaplex 音效面板: http://127.0.0.1:%d" % PORT, flush=True)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
"""湿度滑块验证: 播脉冲, 测输出里的混响尾巴能量随湿量变化。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
import wave
|
||||
|
||||
import numpy as np
|
||||
from scipy.io import wavfile
|
||||
|
||||
RATE = 96000
|
||||
IMPULSE = "/tmp/imp.wav"
|
||||
REC = "/tmp/imp_rec.wav"
|
||||
API = "http://127.0.0.1:8789"
|
||||
|
||||
|
||||
def sink() -> str:
|
||||
"""找数字输出设备的 node.name(含 iec958 的那个)。"""
|
||||
try:
|
||||
out = subprocess.run(["pw-dump"], capture_output=True, text=True, timeout=20).stdout
|
||||
nodes = json.loads(out)
|
||||
except (OSError, ValueError) as exc:
|
||||
print(" (pw-dump 失败: %s)" % exc)
|
||||
return ""
|
||||
for node in nodes:
|
||||
props = (node.get("info") or {}).get("props") or {}
|
||||
if props.get("media.class") != "Audio/Sink":
|
||||
continue
|
||||
name = str(props.get("node.name", ""))
|
||||
if "iec958" in name:
|
||||
return name
|
||||
print(" (没找到 iec958 设备, 现有 sink 已打印在上方)")
|
||||
return ""
|
||||
|
||||
|
||||
def post(path: str, body: dict[str, float]) -> None:
|
||||
req = urllib.request.Request(API + path, data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
urllib.request.urlopen(req, timeout=10).read()
|
||||
|
||||
|
||||
def make_impulse() -> None:
|
||||
"""0.1 秒脉冲 + 1.9 秒静音 = 2 秒素材。
|
||||
|
||||
加静音尾巴是因为 pw-play 起播和 pw-record 启动都有几百毫秒延迟,
|
||||
素材太短会在录音真正开始前就播完(录到全零)。
|
||||
"""
|
||||
n = int(RATE * 3.0)
|
||||
x = np.zeros(n)
|
||||
m = int(RATE * 0.1)
|
||||
t = np.arange(m) / RATE
|
||||
start = int(RATE * 1.0) # 脉冲放第 1 秒: 等录音真正跑起来再响
|
||||
x[start:start + m] = 0.5 * np.sin(2 * np.pi * 1000 * t) * np.hanning(m)
|
||||
data = (x * 32767).astype("<i2")
|
||||
with wave.open(IMPULSE, "wb") as fh:
|
||||
fh.setnchannels(2)
|
||||
fh.setsampwidth(2)
|
||||
fh.setframerate(RATE)
|
||||
fh.writeframes(np.repeat(data, 2).tobytes())
|
||||
|
||||
|
||||
def read_rec() -> np.ndarray:
|
||||
"""读回录音文件(取左声道)。"""
|
||||
try:
|
||||
_rate, data = wavfile.read(REC)
|
||||
except OSError:
|
||||
return np.zeros(1, dtype=np.float64)
|
||||
arr = np.asarray(data, dtype=np.float64)
|
||||
return arr[:, 0] if arr.ndim > 1 else arr
|
||||
|
||||
|
||||
def tail_db(rec: np.ndarray) -> float:
|
||||
"""从录音里的峰值(脉冲)之后 0.2~1.2 秒算能量 = 混响尾巴。
|
||||
|
||||
用峰值自校准, 免得依赖"录音开始时脉冲正好在第 N 秒"。
|
||||
"""
|
||||
if rec.size < RATE:
|
||||
return -120.0
|
||||
peak_idx = int(np.argmax(np.abs(rec)))
|
||||
start = peak_idx + int(0.2 * RATE)
|
||||
end = min(peak_idx + int(1.2 * RATE), rec.size)
|
||||
if end <= start:
|
||||
return -120.0
|
||||
rms = float(np.sqrt(np.mean(rec[start:end] ** 2)))
|
||||
return 20.0 * np.log10(rms + 1e-12)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
dev = sink()
|
||||
if not dev:
|
||||
print("找不到数字输出设备, 退出")
|
||||
return
|
||||
print(" 设备: %s" % dev.split(".")[-3])
|
||||
make_impulse()
|
||||
for wet in (0.0, 0.3, 0.6, 1.0):
|
||||
post("/api/wet", {"wet": wet})
|
||||
time.sleep(0.3)
|
||||
# 先起录音再播放: pw-record 启动有延迟, 反过来会漏掉脉冲
|
||||
play = subprocess.Popen(["pw-play", "--target", "collaplex_vsink", IMPULSE],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
time.sleep(0.3)
|
||||
subprocess.run(["timeout", "3.5", "pw-record", "--target", dev,
|
||||
"-P", "{ stream.capture.sink = true }", "--rate", str(RATE),
|
||||
"--channels", "2", "--format", "f32", REC],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
play.wait(timeout=6)
|
||||
recorded = read_rec()
|
||||
print(" 湿量 %.2f -> 混响尾巴 %6.1f dBFS" % (wet, tail_db(recorded)))
|
||||
post("/api/wet", {"wet": 0.3})
|
||||
print(" (已恢复湿量 0.3)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -39,6 +39,7 @@ echo "数字输出: $TARGET"
|
||||
mkdir -p "$(dirname "$WRAPPER")"
|
||||
sed -e "s|__DSP__|$ROOT/dsp/loudness_norm.py|" "$ROOT/bin/collaplex-loudness-norm" > "$WRAPPER"
|
||||
sed -e "s|__PY__|$ROOT/.venv/bin/python|" -e "s|__DSP__|$ROOT/dsp/eq.py|" \
|
||||
-e "s|__REVERB__|$ROOT/reverb/房间混响IR-96k.wav|" \
|
||||
"$ROOT/bin/collaplex-eq" > "$HOME/.local/bin/collaplex-eq"
|
||||
chmod 755 "$WRAPPER" "$HOME/.local/bin/collaplex-eq"
|
||||
echo "已装入口: $WRAPPER / $HOME/.local/bin/collaplex-eq"
|
||||
@@ -48,11 +49,18 @@ mkdir -p "$CONF_DIR"
|
||||
sed -e "s|__WRAPPER__|$WRAPPER|" -e "s|__HRTF_IN__|$HRTF_IN|" \
|
||||
"$ROOT/pipewire/10-collaplex-vsink.conf" > "$CONF_DIR/10-collaplex-vsink.conf"
|
||||
sed -e "s|__HRIR__|$ROOT/hrir|" -e "s|__REVERB__|$ROOT/reverb/房间混响IR-96k.wav|" \
|
||||
-e "s|__EQ_IN__|$EQ_IN|" -e "s|__WET__|$WET|" \
|
||||
-e "s|__EQ_IN__|$EQ_IN|" -e "s|__WET__|$WET|" -e "s|__WET_WRAPPER__|$HOME/.local/bin/collaplex-wet|" \
|
||||
"$ROOT/pipewire/20-collaplex-hrtf.conf" > "$CONF_DIR/20-collaplex-hrtf.conf"
|
||||
sed -e "s|__EQ_WRAPPER__|$HOME/.local/bin/collaplex-eq|" -e "s|__TARGET__|$TARGET|" \
|
||||
sed -e "s|__PY__|$ROOT/.venv/bin/python|" -e "s|__DSP__|$ROOT/dsp/wet_gain.py|" \
|
||||
"$ROOT/bin/collaplex-wet" > "$HOME/.local/bin/collaplex-wet"
|
||||
chmod 755 "$HOME/.local/bin/collaplex-wet"
|
||||
sed -e "s|__EQ_WRAPPER__|$HOME/.local/bin/collaplex-eq|" -e "s|__WET_WRAPPER__|$HOME/.local/bin/collaplex-wet|" -e "s|__TARGET__|$TARGET|" \
|
||||
"$ROOT/pipewire/30-collaplex-eq.conf" > "$CONF_DIR/30-collaplex-eq.conf"
|
||||
cp "$ROOT/pipewire/91-collaplex-clock.conf" "$CONF_DIR/91-clock.conf"
|
||||
# 共享内存不存在时写入默认参数(全平直 / 0 dB / 湿量 0.3); 已存在则保留用户当前设置
|
||||
if [ ! -f /dev/shm/collaplex-eq ]; then
|
||||
"$ROOT/.venv/bin/python" "$ROOT/dsp/初始化.py"
|
||||
fi
|
||||
echo "已装配置: 10-collaplex-vsink.conf / 20-collaplex-hrtf.conf(混响湿量 $WET) / 30-collaplex-eq.conf"
|
||||
|
||||
# ---- 4. 重启前清掉残留 DSP(占住共享槽位会让新实例起不来) ----
|
||||
@@ -60,7 +68,8 @@ for p in /proc/[0-9]*; do
|
||||
c=$(tr '\0' ' ' < "$p/cmdline" 2>/dev/null) || continue
|
||||
case "$c" in
|
||||
"/usr/bin/python3 "*dsp/loudness_norm.py*) kill -9 "${p#/proc/}" 2>/dev/null || true;;
|
||||
*dsp/eq.py*) kill -9 "${p#/proc/}" 2>/dev/null || true;;
|
||||
*"/.venv/bin/python "*dsp/eq.py*) kill -9 "${p#/proc/}" 2>/dev/null || true;;
|
||||
*"/.venv/bin/python "*dsp/wet_gain.py*) kill -9 "${p#/proc/}" 2>/dev/null || true;;
|
||||
esac
|
||||
done
|
||||
sleep 1
|
||||
|
||||
Reference in New Issue
Block a user