Files
recorder-studio/recorder/dsp.py
T

898 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""DSP 核心:线性相位滤波、电平计量、真峰值、ITU-R BS.1770 响度、离线处理。
设计原则是"录音链路尽量透明":
* 除用户显式开启的功能(低切、门限、归一化)外,不做任何隐式处理;
* 实时链路上的滤波器一律使用**线性相位 FIR**,不引入相位失真;
* 所有电平/响度计量按国际标准实现(真峰值 4 倍过采样、BS.1770 K 加权);
* 不依赖 scipy,只用 numpy,便于打包分发。
"""
from __future__ import annotations
import math
from collections import deque
from dataclasses import dataclass, field
import numpy as np
__all__ = [
"db_to_lin", "lin_to_db", "rms", "peak",
"design_highpass_fir", "design_lowpass_fir", "FIRFilter",
"true_peak", "true_peak_dbfs", "dbfs",
"kweighting_coeffs", "kweighting_response_sq",
"LoudnessMeter", "LevelMeter", "analyze_data", "analyze_file",
"remove_dc", "trim_silence", "normalize_peak", "normalize_loudness",
"fade_edges", "noise_gate", "mixdown_mono", "highpass_offline",
"SILENCE_DBFS_FLOOR",
]
# 低于该电平即可视为数字静音(16 位理论本底约 -96 dBFS)
SILENCE_DBFS_FLOOR = -120.0
# --------------------------------------------------------------------- 基础
def db_to_lin(db: float) -> float:
return float(10.0 ** (float(db) / 20.0))
def lin_to_db(x: float, floor: float = SILENCE_DBFS_FLOOR) -> float:
x = float(x)
if x <= 0 or not math.isfinite(x):
return float(floor)
v = 20.0 * math.log10(x)
return v if v > floor else float(floor)
def dbfs(x: np.ndarray | float, floor: float = SILENCE_DBFS_FLOOR) -> np.ndarray | float:
"""线性幅度 -> dBFS(数组安全)。"""
arr = np.asarray(x, dtype=np.float64)
with np.errstate(divide="ignore", invalid="ignore"):
out = 20.0 * np.log10(np.abs(arr))
out = np.where(np.isfinite(out) & (out > floor), out, floor)
return out if isinstance(x, np.ndarray) else float(out)
def rms(x: np.ndarray, axis=None) -> np.ndarray | float:
arr = np.asarray(x, dtype=np.float64)
if arr.size == 0:
return 0.0
return np.sqrt(np.mean(np.square(arr), axis=axis))
def peak(x: np.ndarray, axis=None) -> np.ndarray | float:
arr = np.asarray(x, dtype=np.float64)
if arr.size == 0:
return 0.0
return np.max(np.abs(arr), axis=axis)
# ------------------------------------------------------------------- 滤波器
def _windowed_sinc(numtaps: int, cutoff: float, *, highpass: bool,
beta: float = 8.6) -> np.ndarray:
"""窗函数法设计线性相位 FIR。``cutoff`` 为归一化频率(cycles/sample)。"""
if numtaps % 2 == 0:
numtaps += 1 # 奇数长度 -> 整数群延迟,且可表示真正的直流零点
n = np.arange(numtaps) - (numtaps - 1) / 2.0
h = 2.0 * cutoff * np.sinc(2.0 * cutoff * n)
if highpass:
h = -h
h[(numtaps - 1) // 2] += 1.0
h *= np.kaiser(numtaps, beta)
if highpass:
# 强制直流增益为严格 0:彻底消除直流漂移,同时保持线性相位(对称加权)
h -= h.mean()
return h
def design_highpass_fir(cutoff_hz: float, samplerate: int, *,
numtaps: int | None = None,
beta: float = 8.0,
max_taps: int = 8193) -> np.ndarray:
"""设计低切(高通)线性相位 FIR,通带增益归一到 0 dB。
抽头数按 Kaiser 公式反推,保证阻带衰减约 80 dB 且过渡带足够窄
(过渡带宽度取截止频率的一半),这样 100 Hz 以上的语音基频几乎不受影响。
"""
if cutoff_hz <= 0:
raise ValueError("截至频率必须为正")
nyq = samplerate / 2.0
if cutoff_hz >= nyq * 0.9:
raise ValueError("截至频率过高")
if numtaps is None:
atten = max(40.0, 9.0 * beta) # beta 与阻带衰减的经验对应
trans = max(cutoff_hz * 0.5, 4.0) # 过渡带宽度 (Hz)
d_omega = 2.0 * math.pi * trans / samplerate
numtaps = int(math.ceil((atten - 7.95) / (2.285 * d_omega)))
numtaps = int(np.clip(numtaps, 129, max_taps))
h = _windowed_sinc(int(numtaps), cutoff_hz / samplerate,
highpass=True, beta=beta)
# 用 1 kHz(或奈奎斯特的 10%)处的响应把通带增益归一到 1.0
ref = min(1000.0, nyq * 0.1)
w = 2.0 * math.pi * ref / samplerate
k = np.arange(h.size)
gain = np.abs(np.sum(h * np.exp(-1j * w * k)))
if gain > 1e-12:
h = h / gain
return h
def design_lowpass_fir(cutoff_hz: float, samplerate: int,
numtaps: int = 129, beta: float = 8.6) -> np.ndarray:
h = _windowed_sinc(int(numtaps), cutoff_hz / samplerate,
highpass=False, beta=beta)
s = h.sum()
return h / s if abs(s) > 1e-12 else h
class FIRFilter:
"""多通道流式 FIR(重叠相加),保持跨块状态。"""
def __init__(self, coeffs: np.ndarray, channels: int):
self.h = np.asarray(coeffs, dtype=np.float64)
self.channels = int(channels)
self._tail = np.zeros((self.h.size - 1, self.channels), dtype=np.float64)
@property
def latency_samples(self) -> int:
return (self.h.size - 1) // 2
def reset(self) -> None:
self._tail[:] = 0.0
def process(self, block: np.ndarray) -> np.ndarray:
x = np.asarray(block, dtype=np.float64)
if x.ndim == 1:
x = x[:, None]
n = x.shape[0]
out = np.empty((n, self.channels), dtype=np.float64)
for c in range(self.channels):
y = np.convolve(x[:, c], self.h)
y[:self.h.size - 1] += self._tail[:, c]
out[:, c] = y[:n]
self._tail[:, c] = y[n:n + self.h.size - 1]
return out
# ------------------------------------------------------------------ 真峰值
_OVERSAMPLE_CACHE: dict[tuple[int, int], list[np.ndarray]] = {}
def _polyphase_bank(factor: int = 4, taps_per_phase: int = 12) -> list[np.ndarray]:
"""L 倍过采样的多相插值滤波器组。
``h_p[j] = g[p + jL]``,其中 ``g`` 是以 1/(2L) 为截止频率的窗函数 sinc
(在过采样后的采样率下)。零相位支路 ``h_0`` 即近似恒等,
因此插值结果绝不会低于原始采样峰值。
"""
key = (factor, taps_per_phase)
if key in _OVERSAMPLE_CACHE:
return _OVERSAMPLE_CACHE[key]
numtaps = factor * taps_per_phase + 1
n = np.arange(numtaps) - (numtaps - 1) / 2.0
# 关键:插值核峰值必须为 1(不能用 sum 归一化),这样零相位支路恰好是
# 恒等滤波,插值结果的峰值才不会低于原始采样峰值。
g = np.sinc(n / factor) * np.kaiser(numtaps, 9.0)
bank = [g[p::factor].copy() for p in range(factor)]
_OVERSAMPLE_CACHE[key] = bank
return bank
def true_peak(x: np.ndarray, *, factor: int = 4) -> np.ndarray:
"""按 ITU-R BS.1770 附录 2 计算真峰值(返回每声道线性幅度)。
做法是多相插值到 ``factor`` 倍采样率后取最大绝对值,
可以捕捉到采样点之间的过冲(inter-sample peak)。
"""
arr = np.asarray(x, dtype=np.float64)
if arr.ndim == 1:
arr = arr[:, None]
if arr.size == 0:
return np.zeros(arr.shape[1])
bank = _polyphase_bank(factor)
out = np.zeros(arr.shape[1])
for c in range(arr.shape[1]):
col = arr[:, c]
best = 0.0
for h in bank:
y = np.convolve(col, h)
m = float(np.max(np.abs(y))) if y.size else 0.0
if m > best:
best = m
out[c] = best
return out
def true_peak_dbfs(x: np.ndarray, *, factor: int = 4) -> np.ndarray:
return dbfs(true_peak(x, factor=factor))
# ------------------------------------------------------- BS.1770 K 加权响度
# ITU-R BS.1770-4 在 48 kHz 下给出的参考系数(用于自检比对)
_KW_48K_SHELF = ([1.53512485958697, -2.69169618940638, 1.19839281085285],
[1.0, -1.69065929318241, 0.73248077421585])
_KW_48K_HPF = ([1.0, -2.0, 1.0],
[1.0, -1.99004745483398, 0.99007225036621])
# 等效设计参数(与 ITU 系数在 48 kHz 下逐位吻合,见自检用例)
_SHELF_GAIN_DB = 3.999843853973347
_SHELF_FC = 1681.974450955533
_SHELF_Q = 0.7071752369554196
_SHELF_VB_EXP = 0.4996667741545416
_HPF_FC = 38.13547087602444
_HPF_Q = 0.5003270373238773
def _itu_highshelf(samplerate: int):
"""BS.1770 第一级:高频搁架(+4 dB @ 高频段)。
采用 ITU 参考实现所用的参数化公式,在 48 kHz 下与标准给定系数
完全一致(误差 < 1e-15),其它采样率下即为标准的双线性变换推广。
"""
k = math.tan(math.pi * _SHELF_FC / samplerate)
vh = 10.0 ** (_SHELF_GAIN_DB / 20.0)
vb = vh ** _SHELF_VB_EXP
a0 = 1.0 + k / _SHELF_Q + k * k
b = np.array([(vh + vb * k / _SHELF_Q + k * k) / a0,
2.0 * (k * k - vh) / a0,
(vh - vb * k / _SHELF_Q + k * k) / a0])
a = np.array([1.0,
2.0 * (k * k - 1.0) / a0,
(1.0 - k / _SHELF_Q + k * k) / a0])
return b, a
def _itu_highpass(samplerate: int):
"""BS.1770 第二级:约 38 Hz 的二阶高通。"""
k = math.tan(math.pi * _HPF_FC / samplerate)
a0 = 1.0 + k / _HPF_Q + k * k
b = np.array([1.0, -2.0, 1.0])
a = np.array([1.0,
2.0 * (k * k - 1.0) / a0,
(1.0 - k / _HPF_Q + k * k) / a0])
return b, a
def kweighting_coeffs(samplerate: int):
"""返回 K 加权的两级 biquad 系数 ``((b1,a1),(b2,a2))``。"""
if int(samplerate) == 48000:
# 48 kHz 直接使用标准原文系数,保证逐位一致
b1, a1 = _KW_48K_SHELF
b2, a2 = _KW_48K_HPF
return (np.array(b1, dtype=np.float64), np.array(a1, dtype=np.float64)), \
(np.array(b2, dtype=np.float64), np.array(a2, dtype=np.float64))
return _itu_highshelf(int(samplerate)), _itu_highpass(int(samplerate))
def biquad_response_sq(b: np.ndarray, a: np.ndarray,
w: np.ndarray) -> np.ndarray:
"""|H(e^{jw})|^2,``w`` 为归一化角频率数组。"""
z1 = np.exp(-1j * w)
z2 = z1 * z1
num = b[0] + b[1] * z1 + b[2] * z2
den = a[0] + a[1] * z1 + a[2] * z2
return np.abs(num / den) ** 2
def kweighting_response_sq(samplerate: int, fft_size: int) -> np.ndarray:
"""K 加权滤波器在 rfft 各 bin 上的功率响应(长度 ``fft_size//2+1``)。"""
(b1, a1), (b2, a2) = kweighting_coeffs(samplerate)
w = 2.0 * math.pi * np.arange(fft_size // 2 + 1) / fft_size
return biquad_response_sq(b1, a1, w) * biquad_response_sq(b2, a2, w)
# BS.1770 的绝对门限与相对门限
_LUFS_ABS_GATE = -70.0
_LUFS_REL_GATE = -10.0
_LUFS_OFFSET = -0.691
def _lufs_from_ms(mean_square: float) -> float:
if mean_square <= 0:
return float("-inf")
return _LUFS_OFFSET + 10.0 * math.log10(mean_square)
class LoudnessMeter:
"""流式响度计(ITU-R BS.1770-4 门限算法)。
以 100 ms 为 hop、400 ms 为瞬时块(即 75% 重叠)统计能量,
再按绝对门限 -70 LUFS、相对门限 -10 LU 计算整体响度。
为实现高效,K 加权在频域按 |H(f)|² 施加——对"能量"计量而言与
时域滤波完全等价(K 加权滤波器冲激响应仅数毫秒,块边界残留可忽略)。
"""
HOP_SECONDS = 0.1
MOMENTARY_HOPS = 4 # 400 ms
SHORT_TERM_HOPS = 30 # 3 s
def __init__(self, samplerate: int, channels: int, *,
keep_hops: bool = True):
self.samplerate = int(samplerate)
self.channels = int(channels)
self.hop = max(1, int(round(self.HOP_SECONDS * self.samplerate)))
self._resp = kweighting_response_sq(self.samplerate, self.hop)
# rfft 的单边能量补正(直流与奈奎斯特 bin 不翻倍)
self._fold = np.full(self._resp.size, 2.0)
self._fold[0] = 1.0
if self.hop % 2 == 0:
self._fold[-1] = 1.0
self._weight = self._resp * self._fold / float(self.hop * self.hop)
self._acc = np.zeros((0, self.channels), dtype=np.float64)
self._hops: deque[float] = deque(maxlen=self.SHORT_TERM_HOPS)
self._all_hops: list[float] = [] if keep_hops else []
self._keep_hops = keep_hops
self.processed_frames = 0
# ------------------------------------------------------------------ push
def push(self, block: np.ndarray) -> None:
"""送入一块音频(float,(n, channels))。"""
x = np.asarray(block, dtype=np.float64)
if x.ndim == 1:
x = x[:, None]
if x.shape[1] != self.channels:
raise ValueError("通道数与响度计配置不符")
self.processed_frames += x.shape[0]
if self._acc.size:
x = np.concatenate((self._acc, x), axis=0)
while x.shape[0] >= self.hop:
self._hop_energy(x[:self.hop])
x = x[self.hop:]
self._acc = x
def _hop_energy(self, hop_data: np.ndarray) -> None:
"""计算一个 hop 的加权均方(所有声道求和,声道权重取 1.0)。"""
spec = np.fft.rfft(hop_data, axis=0)
power = np.abs(spec) ** 2 # (bins, channels)
weighted = np.einsum("kc,k->", power, self._weight)
ms = float(weighted)
self._hops.append(ms)
if self._keep_hops:
self._all_hops.append(ms)
# ------------------------------------------------------------- readings
def _window_ms(self, hops: int) -> float:
n = min(hops, len(self._hops))
if n == 0:
return 0.0
vals = list(self._hops)[-n:]
return float(np.mean(vals))
@property
def momentary(self) -> float:
"""瞬时响度(400 ms 窗),LUFS。"""
return _lufs_from_ms(self._window_ms(self.MOMENTARY_HOPS))
@property
def short_term(self) -> float:
"""短时响度(3 s 窗),LUFS。"""
return _lufs_from_ms(self._window_ms(self.SHORT_TERM_HOPS))
@property
def integrated(self) -> float:
"""整体响度(带门限),LUFS。"""
if not self._keep_hops or len(self._all_hops) < self.MOMENTARY_HOPS:
return float("-inf")
# 400 ms 块(100 ms 步进 = 75% 重叠)的块能量序列
arr = np.asarray(self._all_hops, dtype=np.float64)
if arr.size < self.MOMENTARY_HOPS:
return float("-inf")
kernel = np.ones(self.MOMENTARY_HOPS) / self.MOMENTARY_HOPS
blocks = np.convolve(arr, kernel, mode="valid")
abs_gate = 10.0 ** ((_LUFS_ABS_GATE - _LUFS_OFFSET) / 10.0)
keep = blocks > abs_gate
if not np.any(keep):
return float("-inf")
mean1 = float(np.mean(blocks[keep]))
rel_gate = mean1 * (10.0 ** (_LUFS_REL_GATE / 10.0))
keep2 = blocks > max(rel_gate, abs_gate)
if not np.any(keep2):
return _lufs_from_ms(mean1)
return _lufs_from_ms(float(np.mean(blocks[keep2])))
def loudness_range_estimate(self) -> float:
"""LRA 粗估(10%~95% 分位的短时响度差)。
真正符合 EBU Tech 3342 的 LRA 需要 3 s 窗 + 10 s 分片,这里给出
一个足够稳定的工程近似值,用于提示动态范围。
"""
if not self._keep_hops:
return 0.0
arr = np.asarray(self._all_hops, dtype=np.float64)
if arr.size < self.SHORT_TERM_HOPS:
return 0.0
kernel = np.ones(self.SHORT_TERM_HOPS) / self.SHORT_TERM_HOPS
st = np.convolve(arr, kernel, mode="valid")
st = st[st > 10.0 ** ((_LUFS_ABS_GATE - _LUFS_OFFSET) / 10.0)]
if st.size < 2:
return 0.0
loud = _LUFS_OFFSET + 10.0 * np.log10(st)
p10, p95 = np.percentile(loud, 10.0), np.percentile(loud, 95.0)
return float(p95 - p10)
def reset(self) -> None:
self._acc = np.zeros((0, self.channels), dtype=np.float64)
self._hops.clear()
self._all_hops.clear()
self.processed_frames = 0
def flush(self) -> None:
"""把不足一个 hop 的尾部补零送算,保证短录音也有响度读数。"""
if self._acc.size:
tail = self._acc
pad = np.zeros((self.hop - tail.shape[0], self.channels))
self._hop_energy(np.concatenate((tail, pad), axis=0))
self._acc = np.zeros((0, self.channels))
# --------------------------------------------------------------- 实时电平表
@dataclass
class MeterSnapshot:
rms_db: list[float] = field(default_factory=list)
peak_db: list[float] = field(default_factory=list)
hold_db: list[float] = field(default_factory=list)
true_peak_db: list[float] = field(default_factory=list)
clipped: list[bool] = field(default_factory=list)
clip_count: int = 0
momentary_lufs: float = float("-inf")
short_term_lufs: float = float("-inf")
integrated_lufs: float = float("-inf")
class LevelMeter:
"""实时电平表:每块 RMS/峰值 + 峰值保持 + 削波锁存 + 真峰值 + 响度。"""
def __init__(self, samplerate: int, channels: int, *,
hold_decay_db_per_s: float = 12.0,
true_peak_enabled: bool = True,
loudness_enabled: bool = True):
self.samplerate = int(samplerate)
self.channels = int(channels)
self.hold_decay = float(hold_decay_db_per_s)
self.true_peak_enabled = bool(true_peak_enabled)
self.loudness_enabled = bool(loudness_enabled)
self._hold = np.full(self.channels, SILENCE_DBFS_FLOOR)
self._hold_timer = np.zeros(self.channels)
self._tp = np.zeros(self.channels)
self._clip = np.zeros(self.channels, dtype=bool)
self.clip_count = 0
self._last_block_time: float | None = None
self._tp_hop = max(1, int(round(0.1 * self.samplerate)))
self._acc: list[np.ndarray] = []
self._acc_frames = 0
self._preroll = np.zeros((0, self.channels))
self.loudness = LoudnessMeter(self.samplerate, self.channels) \
if self.loudness_enabled else None
self._rms_db = [SILENCE_DBFS_FLOOR] * self.channels
self._peak_db = [SILENCE_DBFS_FLOOR] * self.channels
# ------------------------------------------------------------------ push
def process(self, block: np.ndarray, *, now: float | None = None) -> None:
x = np.asarray(block, dtype=np.float64)
if x.ndim == 1:
x = x[:, None]
if x.size == 0:
return
# 1) 逐块 RMS / 峰值(更新快,界面刷新率高)
self._rms_db = [lin_to_db(v) for v in rms(x, axis=0)]
self._peak_db = [lin_to_db(v) for v in peak(x, axis=0)]
# 2) 峰值保持(带自动回落,避免"钉住"不动)
if now is None:
import time
now = time.monotonic()
dt = 0.0 if self._last_block_time is None else max(0.0, now - self._last_block_time)
self._last_block_time = now
self._hold = np.maximum(self._hold - self.hold_decay * dt,
np.asarray(self._peak_db, dtype=np.float64))
# 3) 削波锁存
over = np.any(np.abs(x) >= 0.99999, axis=0)
self._clip |= over
self.clip_count += int(np.count_nonzero(np.abs(x) >= 0.99999))
# 4) 响度
if self.loudness is not None:
self.loudness.push(x)
# 5) 真峰值:按 100 ms 累积(含 12 样本前导以避免块边界低估)
if self.true_peak_enabled:
self._acc.append(x)
self._acc_frames += x.shape[0]
while self._acc_frames >= self._tp_hop:
merged = np.concatenate(self._acc, axis=0)
window = merged[:self._tp_hop]
rest = merged[self._tp_hop:]
seg = np.concatenate((self._preroll, window), axis=0) \
if self._preroll.size else window
tp = true_peak(seg)
# 只统计新增部分的真峰值(近似:整段最大值)
self._tp = np.maximum(self._tp, tp)
self._preroll = window[-12:] if window.shape[0] >= 12 else window
self._acc = [rest] if rest.size else []
self._acc_frames = rest.shape[0]
# ------------------------------------------------------------- readings
def snapshot(self) -> MeterSnapshot:
return MeterSnapshot(
rms_db=[float(v) for v in self._rms_db],
peak_db=list(self._peak_db),
hold_db=[float(v) for v in self._hold],
true_peak_db=[lin_to_db(v) for v in self._tp],
clipped=[bool(v) for v in self._clip],
clip_count=self.clip_count,
momentary_lufs=self.loudness.momentary if self.loudness else float("-inf"),
short_term_lufs=self.loudness.short_term if self.loudness else float("-inf"),
integrated_lufs=self.loudness.integrated if self.loudness else float("-inf"),
)
def reset_clip(self) -> None:
self._clip[:] = False
def reset(self) -> None:
self._hold[:] = SILENCE_DBFS_FLOOR
self._tp[:] = 0.0
self._clip[:] = False
self.clip_count = 0
self._acc = []
self._acc_frames = 0
self._preroll = np.zeros((0, self.channels))
self._last_block_time = None
if self.loudness is not None:
self.loudness.reset()
# --------------------------------------------------------------- 离线分析
def _noise_floor(block_rms: np.ndarray) -> tuple[list[float], bool]:
"""从 50 ms 块 RMS 序列估计本底噪声。
取安静段的 10% 分位作为本底,但要求整体动态范围(90% 分位 / 10% 分位)
至少 10 dB——否则说明录音里根本没有"安静段"(例如全程持续发声的
测试信号),此时返回不可测,而不是编造一个数字。
"""
if block_rms.size < 4:
return [], False
p10 = np.percentile(block_rms, 10.0, axis=0)
p90 = np.percentile(block_rms, 90.0, axis=0)
if np.any(p10 <= 0) or np.any(p90 <= 0):
return [], False
if np.any(20.0 * np.log10(p90 / p10) < 10.0):
return [], False
return [round(float(v), 2) for v in np.atleast_1d(dbfs(p10))], True
def analyze_data(data: np.ndarray, samplerate: int, *,
true_peak_factor: int = 4) -> dict:
"""对一段音频做全面体检,返回可直接写入元数据的字典。"""
x = np.asarray(data, dtype=np.float64)
if x.ndim == 1:
x = x[:, None]
n, ch = x.shape
duration = n / float(samplerate) if samplerate else 0.0
pk = peak(x, axis=0)
pk_db = dbfs(pk)
tp = true_peak(x, factor=true_peak_factor) if n else np.zeros(ch)
tp_db = dbfs(tp)
rms_db = dbfs(rms(x, axis=0))
# 直流偏移
dc = np.mean(x, axis=0) if n else np.zeros(ch)
# 噪声本底:只在检测到安静段时才给出
block = max(1, int(0.05 * samplerate))
usable = (n // block) * block
if usable >= block:
blocks = x[:usable].reshape(-1, block, ch)
blk_rms = np.sqrt(np.mean(np.square(blocks), axis=1)) # (nblocks, ch)
floor_db, floor_ok = _noise_floor(blk_rms)
else:
floor_db, floor_ok = [], False
# 削波统计
clip_counts = np.count_nonzero(np.abs(x) >= 0.99999, axis=0)
# 静音占比(低于 -60 dBFS 的采样比例)
quiet = float(np.mean(np.abs(x) < db_to_lin(-60.0))) if n else 1.0
# 响度
lm = LoudnessMeter(samplerate, ch)
step = 1 << 16
for i in range(0, n, step):
lm.push(x[i:i + step])
lm.flush()
return {
"samplerate": int(samplerate),
"channels": int(ch),
"frames": int(n),
"duration": duration,
"peak_dbfs": [round(float(v), 3) for v in np.atleast_1d(pk_db)],
"true_peak_dbtp": [round(float(v), 3) for v in np.atleast_1d(tp_db)],
"rms_dbfs": [round(float(v), 3) for v in np.atleast_1d(rms_db)],
"integrated_lufs": round(float(lm.integrated), 3)
if math.isfinite(lm.integrated) else None,
"short_term_max_lufs": None,
"loudness_range_lu": round(float(lm.loudness_range_estimate()), 3),
"dc_offset": [round(float(v), 8) for v in np.atleast_1d(dc)],
"noise_floor_dbfs": floor_db,
"noise_floor_available": floor_ok,
"clipped_samples": [int(v) for v in np.atleast_1d(clip_counts)],
"clipped_total": int(np.sum(clip_counts)),
"silence_ratio": round(quiet, 4),
}
def analyze_file(path: str, *, max_seconds: float | None = None) -> dict:
"""对 WAV 文件做体检(分块读取,适合超长录音)。"""
from .wavfile import WavReader
with WavReader(path) as r:
info = r.info()
limit = r.frames if max_seconds is None else min(
r.frames, int(max_seconds * r.samplerate))
lm = LoudnessMeter(r.samplerate, r.channels)
pk = np.zeros(r.channels)
tp = np.zeros(r.channels)
rms_acc = np.zeros(r.channels, dtype=np.float64)
dc_acc = np.zeros(r.channels, dtype=np.float64)
clips = np.zeros(r.channels, dtype=np.int64)
count = 0
blk_rms: list[np.ndarray] = []
preroll = np.zeros((0, r.channels), dtype=np.float64)
while count < limit:
block = r.read(count, min(1 << 16, limit - count))
if block.size == 0:
break
x64 = block # 已是 float64
pk = np.maximum(pk, np.max(np.abs(x64), axis=0))
# 真峰值:带 32 样本前导,避免块边界漏掉采样点之间的过冲
seg = np.concatenate((preroll, x64), axis=0) if preroll.size else x64
if seg.shape[0] > 128:
tp = np.maximum(tp, true_peak(seg))
else:
tp = np.maximum(tp, np.max(np.abs(seg), axis=0))
preroll = x64[-32:] if x64.shape[0] >= 32 else x64
rms_acc += np.sum(np.square(x64), axis=0)
dc_acc += np.sum(x64, axis=0)
clips += np.count_nonzero(np.abs(x64) >= 0.99999, axis=0)
lm.push(x64)
sub = max(1, int(0.05 * r.samplerate))
usable = (x64.shape[0] // sub) * sub
if usable:
blk_rms.append(np.sqrt(np.mean(
np.square(x64[:usable].reshape(-1, sub, r.channels)), axis=1)))
count += block.shape[0]
lm.flush()
duration = count / r.samplerate if r.samplerate else 0.0
floor_db, floor_ok = _noise_floor(np.concatenate(blk_rms, axis=0)) \
if blk_rms else ([], False)
out = dict(info)
out.update({
"analyzed_frames": count,
"analyzed_duration": duration,
"peak_dbfs": [round(float(v), 3) for v in np.atleast_1d(dbfs(pk))],
"true_peak_dbtp": [round(float(v), 3) for v in np.atleast_1d(dbfs(tp))],
"rms_dbfs": [round(float(v), 3) for v in np.atleast_1d(
dbfs(np.sqrt(rms_acc / max(1, count))))],
"integrated_lufs": round(float(lm.integrated), 3)
if math.isfinite(lm.integrated) else None,
"loudness_range_lu": round(float(lm.loudness_range_estimate()), 3),
"dc_offset": [round(float(v), 8) for v in dc_acc / max(1, count)],
"noise_floor_dbfs": floor_db,
"noise_floor_available": floor_ok,
"clipped_samples": [int(v) for v in clips],
"clipped_total": int(clips.sum()),
})
return out
# ------------------------------------------------------------- 离线处理链
def remove_dc(x: np.ndarray, samplerate: int | None = None,
window_seconds: float = 1.0) -> np.ndarray:
"""去除直流与极低频漂移。
给出 ``samplerate`` 时减去**滑动平均**(默认 1 秒窗)而不是全局均值:
录音中途如果直流电平发生漂移(设备热漂移、某些 USB 声卡的固有偏移),
全局均值法只会把漂移原样留成低频噪声,滑动平均才能真正跟掉它。
对 1 秒窗而言,2 Hz 以上的频响基本平坦,不会动到有用信号。
"""
arr = np.asarray(x, dtype=np.float64)
if arr.size == 0:
return arr
if arr.ndim == 1:
arr = arr[:, None]
n, ch = arr.shape
w = int(window_seconds * samplerate) if samplerate else 0
if w < 3 or w >= n:
return arr - np.mean(arr, axis=0, keepdims=True)
cum = np.cumsum(np.concatenate([np.zeros((1, ch)), arr], axis=0), axis=0)
pad = w // 2
idx = np.arange(n)
lo = np.clip(idx - pad, 0, n)
hi = np.clip(idx - pad + w, 0, n)
count = np.maximum(1, hi - lo)
avg = (cum[hi] - cum[lo]) / count[:, None]
return arr - avg
def highpass_offline(x: np.ndarray, samplerate: int, cutoff_hz: float) -> np.ndarray:
h = design_highpass_fir(cutoff_hz, samplerate)
f = FIRFilter(h, x.shape[1] if x.ndim > 1 else 1)
y = f.process(x)
# 补偿滤波群延迟,保持时间轴对齐
d = f.latency_samples
return y[d:] if d else y
def trim_silence(x: np.ndarray, samplerate: int, *,
threshold_dbfs: float = -50.0,
min_silence: float = 0.35,
pad: float = 0.15) -> tuple[np.ndarray, dict]:
"""裁掉首尾静音,返回 ``(新数据, 裁剪信息)``。
只有当首/尾静音长于 ``min_silence`` 时才裁剪,避免把短促的气口误伤;
裁剪后仍保留 ``pad`` 秒的呼吸空间,防止削掉起音与尾音的自然衰减。
"""
if x.size == 0:
return x, {"trimmed_seconds": 0.0}
thr = db_to_lin(threshold_dbfs)
win = max(1, int(0.01 * samplerate))
usable = (x.shape[0] // win) * win
if usable < win:
return x, {"trimmed_seconds": 0.0}
env = np.max(np.abs(x[:usable]), axis=1).reshape(-1, win).max(axis=1)
idx = np.flatnonzero(env >= thr)
if idx.size == 0:
return x, {"trimmed_seconds": 0.0, "all_silent": True}
pad_blocks = int(pad * samplerate / win)
min_blocks = max(1, int(min_silence * samplerate / win))
start_block = int(idx[0])
end_block = int(idx[-1]) + 1
# 首部静音不够长就不动它
if start_block < min_blocks:
start_block = 0
else:
start_block = max(0, start_block - pad_blocks)
# 尾部同理
if env.size - end_block < min_blocks:
end_block = env.size
else:
end_block = min(env.size, end_block + pad_blocks)
start = start_block * win
end = min(x.shape[0], end_block * win)
trimmed = x[start:end]
if trimmed.size == 0:
return x, {"trimmed_seconds": 0.0}
return trimmed, {
"trimmed_seconds": round((x.shape[0] - trimmed.shape[0]) / samplerate, 3),
"lead_seconds": round(start / samplerate, 3),
"tail_seconds": round((x.shape[0] - end) / samplerate, 3),
"min_silence": min_silence,
"threshold_dbfs": threshold_dbfs,
}
def fade_edges(x: np.ndarray, samplerate: int, *,
fade_in: float = 0.005, fade_out: float = 0.02) -> np.ndarray:
y = np.array(x, copy=True)
n = y.shape[0]
for dur, head in ((fade_in, True), (fade_out, False)):
k = min(n, int(dur * samplerate))
if k <= 1:
continue
ramp = np.linspace(0.0, 1.0, k, dtype=np.float64)
if head:
y[:k] *= ramp[:, None]
else:
y[n - k:] *= ramp[::-1, None]
return y
def normalize_peak(x: np.ndarray, target_dbfs: float = -1.0,
*, true_peak_mode: bool = True,
samplerate: int | None = None) -> tuple[np.ndarray, float]:
"""按峰值归一化(默认按真峰值,避免转码后过载)。"""
if x.size == 0:
return x, 0.0
cur = float(np.max(true_peak(x))) if (true_peak_mode and x.shape[0] > 64) \
else float(peak(x))
if cur <= 1e-12:
return x, 0.0
gain = db_to_lin(target_dbfs) / cur
return x * gain, float(20.0 * math.log10(gain))
def normalize_loudness(x: np.ndarray, samplerate: int, target_lufs: float,
*, max_gain_db: float = 30.0,
true_peak_ceiling_dbfs: float = -1.0,
) -> tuple[np.ndarray, dict]:
"""按整体响度(BS.1770)归一化,并保证真峰值不超过上限。"""
if x.size == 0:
return x, {"gain_db": 0.0}
ch = x.shape[1] if x.ndim > 1 else 1
lm = LoudnessMeter(samplerate, ch)
step = 1 << 16
for i in range(0, x.shape[0], step):
lm.push(x[i:i + step])
lm.flush()
current = float(lm.integrated)
if not math.isfinite(current):
return x, {"gain_db": 0.0, "note": "无法测定响度,未做处理"}
gain_db = target_lufs - current
limited = False
if gain_db > max_gain_db:
gain_db, limited = max_gain_db, True
if gain_db < -max_gain_db:
gain_db, limited = -max_gain_db, True
y = x * db_to_lin(gain_db)
# 峰值保护:若真峰值越界则回退增益
tp = float(np.max(true_peak(y))) if y.shape[0] > 64 else float(peak(y))
ceiling = db_to_lin(true_peak_ceiling_dbfs)
if tp > ceiling and tp > 0:
extra = ceiling / tp
y = y * extra
gain_db += 20.0 * math.log10(extra)
return y, {
"gain_db": round(float(gain_db), 3),
"measured_lufs": round(current, 3),
"target_lufs": float(target_lufs),
"gain_limited": limited,
}
def noise_gate(x: np.ndarray, samplerate: int, *, threshold_dbfs: float = -60.0,
attack: float = 0.005, release: float = 0.12,
hold: float = 0.1) -> tuple[np.ndarray, dict]:
"""带包络跟随的软门限(仅在用户显式开启时使用)。"""
if x.size == 0:
return x, {"gated_ratio": 0.0}
thr = db_to_lin(threshold_dbfs)
win = max(1, int(0.005 * samplerate))
usable = (x.shape[0] // win) * win
env = np.max(np.abs(x[:usable]), axis=1).reshape(-1, win).max(axis=1)
open_mask = env >= thr
# 保持(hold)+ 攻放(attack/release)平滑,避免产生"抽气"感
hop = win / samplerate
hold_frames = max(1, int(hold / hop))
atk = max(1, int(attack / hop))
rel = max(1, int(release / hop))
gain = np.zeros(env.size)
g = 0.0
counter = 0
for i in range(env.size):
if open_mask[i]:
counter = hold_frames
target = 1.0
elif counter > 0:
counter -= 1
target = 1.0
else:
target = 0.0
step = 1.0 / (atk if target > g else rel)
g += np.clip(target - g, -step, step)
gain[i] = g
# 上采样到采样级并做线性插值,避免阶梯噪声
up = np.interp(np.arange(x.shape[0]),
np.arange(env.size) * win, gain)
y = x * up[:, None]
return y, {
"threshold_dbfs": threshold_dbfs,
"gated_ratio": round(float(np.mean(up < 0.5)), 4),
}
def mixdown_mono(x: np.ndarray) -> np.ndarray:
if x.ndim == 1 or x.shape[1] == 1:
return x
return np.mean(x, axis=1, keepdims=True)