Initial commit: RecorderStudio:PyQt5 高保真录音软件,无损 WAV / WASAPI 独占、BS.1770 响度与 ffmpeg 交叉验证

This commit is contained in:
WpyQwq
2026-09-19 11:56:01 +08:00
commit b7f9098ccd
62 changed files with 10796 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
"""RecorderStudio —— 高清晰度无损录音机。
模块一览:
dsp DSP 核心(滤波器、电平、真峰值、BS.1770 响度、离线处理)
wavfile 无损 WAV 读写(16/24/32/float32、TPDF 抖动、RF64、崩溃安全头)
engine 录音引擎(设备枚举、独占模式、落盘线程、自动分段)
post 后期处理与导出(归一化、裁剪、门限、转码、元数据、波形图)
widgets 自绘界面控件(电平表、示波器、响度条)
window 主窗口
cli 命令行录音
selftest 自检(数学正确性 + 硬件端到端)
"""
__version__ = "1.0.0"
__all__ = ["__version__"]
+263
View File
@@ -0,0 +1,263 @@
"""命令行录音:适合自动化、定时任务、无界面服务器。
示例::
python -m recorder.cli --list-devices
python -m recorder.cli -d 3 -t 60 -o D:\\rec --name 会议
python -m recorder.cli --probe 3
python -m recorder.cli --analyze D:\\rec\\会议.wav
python -m recorder.cli --process D:\\rec\\会议.wav --normalize lufs --target -16
"""
from __future__ import annotations
import argparse
import os
import signal
import sys
import time
from . import __version__, engine, post
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="recorder",
description="RecorderStudio · 高清晰度无损录音机(命令行模式)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="提示:加 --gui 可启动图形界面(等价于 python -m recorder.gui)。")
p.add_argument("--version", action="version", version=f"RecorderStudio {__version__}")
g = p.add_argument_group("设备")
g.add_argument("--list-devices", action="store_true", help="列出所有输入设备并退出")
g.add_argument("--probe", type=int, metavar="INDEX",
help="检测指定设备支持哪些采样率")
g.add_argument("-d", "--device", type=int, help="输入设备索引(默认使用系统默认设备)")
g.add_argument("--no-exclusive", action="store_true",
help="关闭 WASAPI 独占模式(共享模式,兼容性更好)")
g.add_argument("--blocksize", type=int, default=0, help="缓冲区采样数,0 = 自动")
g = p.add_argument_group("格式")
g.add_argument("-r", "--rate", type=int, default=48000, help="采样率(默认 48000)")
g.add_argument("-c", "--channels", type=int, default=2, help="声道数(默认 2)")
g.add_argument("-b", "--bit-depth", default="24",
choices=["16", "24", "32", "float32"],
help="位深(默认 24)")
g.add_argument("--no-dither", action="store_true", help="关闭 TPDF 抖动")
g.add_argument("--rf64", action="store_true", help="使用 RF64 容器(>4 GB 单文件)")
g.add_argument("--gain", type=float, default=0.0, help="软件增益 dB(默认 0)")
g.add_argument("--lowcut", type=float, default=0.0,
help="线性相位低切频率 Hz(0 = 关闭)")
g = p.add_argument_group("录制")
g.add_argument("-t", "--seconds", type=float, help="录制时长(秒);不填则按回车停止")
g.add_argument("-o", "--outdir", default="", help="输出目录(默认 ./recordings)")
g.add_argument("--name", default="{datetime}_{device}", help="文件命名模板")
g.add_argument("--split-seconds", type=float, default=0.0, help="按时长自动分段(秒)")
g.add_argument("--split-mb", type=float, default=0.0, help="按体积自动分段(MB)")
g.add_argument("--stop-after-silence", type=float, default=0.0,
help="静音多少秒后自动停止(0 = 不自动停止)")
g.add_argument("--silence-threshold", type=float, default=-50.0,
help="静音判定阈值 dBFS")
g = p.add_argument_group("后处理")
g.add_argument("--analyze", metavar="WAV", help="分析已有录音并退出")
g.add_argument("--process", metavar="WAV", help="对已有录音做后期处理")
g.add_argument("--normalize", choices=["none", "peak", "lufs"], default="none",
help="归一化方式(默认 none)")
g.add_argument("--target", type=float, default=-16.0, help="响度归一化目标 LUFS")
g.add_argument("--target-peak", type=float, default=-1.0,
help="峰值归一化目标 dBFS")
g.add_argument("--trim", action="store_true", help="裁剪首尾静音")
g.add_argument("--mono", action="store_true", help="混合为单声道")
g.add_argument("--export", default="", help="导出格式预设(flac/mp3_320/opus/m4a/wav_16…)")
g.add_argument("--report", action="store_true", help="额外生成报告与波形预览图")
g.add_argument("--gui", action="store_true", help="启动图形界面")
return p
def cmd_list_devices() -> int:
print(engine.device_summary())
return 0
def cmd_probe(index: int, channels: int) -> int:
info = engine.find_device(index) if hasattr(engine, "find_device") else None
if info is None:
devs = [d for d in engine.list_input_devices() if d.index == index]
info = devs[0] if devs else None
if info is None:
print(f"找不到设备 {index}", file=sys.stderr)
return 1
print(f"设备 [{info.index}] {info.name} 宿主 API:{info.hostapi}")
res = engine.probe_capabilities(index, channels=min(channels,
info.max_input_channels))
print(f"检测通道数:{res['channels']}\n")
for rate, entry in res["rates"].items():
if entry["supported"]:
mode = "独占" if entry.get("exclusive") else "共享"
print(f" ✓ {rate:>6} Hz {mode} {', '.join(entry['dtypes'])}")
else:
print(f" ✗ {rate:>6} Hz 不支持({entry.get('error', '')[:60]})")
return 0
def cmd_record(args: argparse.Namespace) -> int:
cfg = engine.RecordConfig(
device=args.device,
samplerate=args.rate,
channels=args.channels,
bit_depth=args.bit_depth,
gain_db=args.gain,
exclusive=not args.no_exclusive,
blocksize=args.blocksize,
lowcut_hz=args.lowcut,
dither=not args.no_dither,
rf64=args.rf64,
output_dir=args.outdir or os.path.join(os.getcwd(), "recordings"),
name_template=args.name,
split_seconds=args.split_seconds,
split_megabytes=args.split_mb,
silence_threshold_dbfs=args.silence_threshold,
auto_stop_silence_seconds=args.stop_after_silence,
)
rec = engine.Recorder(cfg)
try:
rec.start()
except Exception as exc:
print(f"启动失败:{exc}", file=sys.stderr)
return 2
print(f"正在录音 → {cfg.output_dir}")
print(f"格式:{cfg.wav_format().describe()} "
f"{'独占模式' if cfg.exclusive else '共享模式'}"
f"{' 低切 %.0f Hz' % cfg.lowcut_hz if cfg.lowcut_hz else ''}")
for n in rec.notes():
print(" · " + n)
stop_by_signal = {"flag": False}
def _handler(signum, frame): # noqa: ANN001
stop_by_signal["flag"] = True
for sig in (signal.SIGINT, signal.SIGTERM):
try:
signal.signal(sig, _handler)
except Exception:
pass
try:
if args.seconds:
deadline = time.time() + args.seconds
while time.time() < deadline and not stop_by_signal["flag"]:
time.sleep(0.2)
snap = rec.live()
sys.stdout.write(
f"\r 已录 {engine.format_duration(snap.elapsed)} "
f"{engine.format_bytes(snap.bytes_written)} "
f"峰值 {snap.peak_dbfs:+.1f} dBFS "
f"响度 {snap.meter.momentary_lufs:+.1f} LUFS "
f"xrun {snap.xruns} 溢出 {snap.overflow_blocks} ")
sys.stdout.flush()
if snap.state == engine.RecorderState.IDLE.value:
break # 静音自动停止
else:
print("按回车停止录音…")
input()
except KeyboardInterrupt:
pass
print()
result = rec.stop()
print(post.render_report(result))
if args.report and result.primary_file:
files = post.write_metadata(result)
png = post.make_preview_for(result)
if png:
files.append(png)
print("\n已生成:" + "、".join(os.path.basename(f) for f in files))
if args.export and result.primary_file:
meta = post.EXPORT_PRESETS.get(args.export)
if meta is None:
print(f"未知导出格式:{args.export};可选:{', '.join(post.EXPORT_PRESETS)}",
file=sys.stderr)
return 1
dst = post.default_export_path(result.primary_file, args.export)
ok, msg = post.export_audio(result.primary_file, dst, args.export)
print(("导出成功:" if ok else "导出失败:") + msg)
return 0 if ok else 1
return 0
def cmd_analyze(path: str) -> int:
if not os.path.exists(path):
print(f"文件不存在:{path}", file=sys.stderr)
return 1
print(post.regenerate_report(path))
return 0
def cmd_process(args: argparse.Namespace) -> int:
if not os.path.exists(args.process):
print(f"文件不存在:{args.process}", file=sys.stderr)
return 1
opts = post.ProcessOptions(
trim_silence=args.trim,
remove_dc=True,
lowcut_hz=args.lowcut,
normalize=args.normalize,
normalize_target_lufs=args.target,
normalize_target_dbfs=args.target_peak,
mono=args.mono,
bit_depth=args.bit_depth,
dither=not args.no_dither,
)
report = post.process_file(args.process, opts,
progress=lambda m: print(" " + m))
print("\n处理完成:" + report["output"])
for s in report.get("steps", []):
print(" · " + s)
an = report.get("analysis") or {}
if an:
print(f" 输出体检:峰值 {an.get('peak_dbfs')} dBFS "
f"真峰值 {an.get('true_peak_dbtp')} dBTP "
f"响度 {an.get('integrated_lufs')} LUFS")
if args.report:
png = post.write_waveform_png(
os.path.splitext(report["output"])[0] + "_waveform.png",
*post._read_whole(report["output"]))
print(" 波形预览:" + png)
if args.export:
meta = post.EXPORT_PRESETS.get(args.export)
if meta is None:
print(f"未知导出格式:{args.export}", file=sys.stderr)
return 1
dst = post.default_export_path(report["output"], args.export)
ok, msg = post.export_audio(report["output"], dst, args.export)
print((" 导出成功:" if ok else " 导出失败:") + msg)
return 0 if ok else 1
return 0
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.gui:
from .gui import run
return run([sys.argv[0]])
if args.list_devices:
return cmd_list_devices()
if args.probe is not None:
return cmd_probe(args.probe, args.channels)
if args.analyze:
return cmd_analyze(args.analyze)
if args.process:
return cmd_process(args)
if engine.sd is None:
print(f"音频后端不可用:{engine.SD_IMPORT_ERROR}", file=sys.stderr)
return 2
return cmd_record(args)
if __name__ == "__main__":
sys.exit(main())
+897
View File
@@ -0,0 +1,897 @@
"""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)
+1082
View File
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
"""图形界面入口:``python -m recorder.gui``(或双击 启动录音机.bat)。
负责三件事:High-DPI 适配、深色主题样式表、以及缺依赖时的友好提示。
"""
from __future__ import annotations
import sys
QSS = """
* { font-family: "Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI", sans-serif; }
QWidget { background: #0F1218; color: #E8ECF4; font-size: 12px; }
QMainWindow, QDialog { background: #0F1218; }
QMenuBar { background: #12161D; border-bottom: 1px solid #2A313D; }
QMenuBar::item { padding: 5px 12px; background: transparent; }
QMenuBar::item:selected { background: #1D222C; border-radius: 4px; }
QMenu { background: #161A22; border: 1px solid #2A313D; padding: 4px; }
QMenu::item { padding: 6px 22px; border-radius: 4px; }
QMenu::item:selected { background: #23303F; }
QGroupBox {
background: #161A22;
border: 1px solid #2A313D;
border-radius: 8px;
margin-top: 12px;
padding-top: 8px;
font-weight: 600;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 12px;
padding: 0 6px;
color: #9FB0CB;
}
QFrame#card {
background: #161A22;
border: 1px solid #2A313D;
border-radius: 10px;
}
QLabel { background: transparent; }
QPushButton {
background: #232A36;
border: 1px solid #323A48;
border-radius: 6px;
padding: 6px 12px;
color: #E8ECF4;
}
QPushButton:hover { background: #2A3341; border-color: #3E4A5C; }
QPushButton:pressed { background: #1B2129; }
QPushButton:disabled { color: #5C6577; background: #1A1F27; border-color: #262D38; }
QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QPlainTextEdit {
background: #1B2029;
border: 1px solid #2E3644;
border-radius: 6px;
padding: 5px 8px;
selection-background-color: #2C5F9E;
}
QComboBox:hover, QLineEdit:hover, QSpinBox:hover, QDoubleSpinBox:hover {
border-color: #3E4A5C;
}
QComboBox::drop-down { border: none; width: 18px; }
QComboBox QAbstractItemView {
background: #1B2029;
border: 1px solid #2E3644;
selection-background-color: #23303F;
outline: none;
}
QComboBox::down-arrow {
image: none;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid #8B94A7;
margin-right: 6px;
}
QCheckBox { spacing: 7px; }
QCheckBox::indicator {
width: 15px; height: 15px;
border: 1px solid #3A4352; border-radius: 4px;
background: #1B2029;
}
QCheckBox::indicator:checked {
background: #4EA1FF; border-color: #4EA1FF;
}
QSlider::groove:horizontal {
height: 4px; background: #2A313D; border-radius: 2px;
}
QSlider::sub-page:horizontal { background: #4EA1FF; border-radius: 2px; }
QSlider::handle:horizontal {
background: #E8ECF4; width: 14px; margin: -6px 0; border-radius: 7px;
}
QScrollArea { background: transparent; border: none; }
QScrollBar:vertical { background: #12161D; width: 10px; border-radius: 5px; }
QScrollBar::handle:vertical { background: #333B49; border-radius: 5px; min-height: 30px; }
QScrollBar::handle:vertical:hover { background: #414B5C; }
QScrollBar::add-line, QScrollBar::sub-line { height: 0; }
QStatusBar { background: #12161D; border-top: 1px solid #2A313D; color: #8B94A7; }
QStatusBar QLabel { color: #8B94A7; }
QSplitter::handle { background: transparent; width: 6px; }
QProgressBar {
background: #1B2029; border: 1px solid #2E3644; border-radius: 6px;
text-align: center; color: #E8ECF4; height: 16px;
}
QProgressBar::chunk { background: #4EA1FF; border-radius: 5px; }
QToolTip {
background: #1B2029; color: #E8ECF4; border: 1px solid #3E4A5C;
padding: 4px 6px; border-radius: 4px;
}
QTabBar::tab {
background: #1B2029; padding: 6px 14px; border: 1px solid #2E3644;
border-bottom: none; border-top-left-radius: 6px; border-top-right-radius: 6px;
}
QTabBar::tab:selected { background: #232A36; }
"""
def _check_dependencies() -> str | None:
"""返回缺失依赖的说明,全部就绪时返回 None。"""
missing = []
try:
import numpy # noqa: F401 (仅用于探测依赖是否就绪)
except Exception:
missing.append("numpy(数值运算)")
try:
import PyQt5 # noqa: F401 (仅用于探测依赖是否就绪)
except Exception:
missing.append("PyQt5(图形界面)")
from . import engine
if engine.sd is None:
missing.append("sounddevice(PortAudio 音频后端)")
if not missing:
return None
return ("缺少以下依赖,无法启动图形界面:\n\n · " + "\n · ".join(missing)
+ "\n\n请运行:python install_deps.py\n"
"(在项目目录下执行,会把依赖装到项目内的 _vendor 目录)")
def run(argv: list[str] | None = None) -> int:
argv = list(sys.argv if argv is None else argv)
problem = _check_dependencies()
if problem:
print(problem, file=sys.stderr)
try:
from PyQt5.QtWidgets import QApplication as _QA, QMessageBox
except Exception:
return 2
_app = _QA(argv)
QMessageBox.critical(None, "缺少依赖", problem)
return 2
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import QApplication
# High-DPI 相关属性必须在 QApplication 实例化之前设置
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
from .window import MainWindow
app = QApplication(argv)
app.setApplicationName("RecorderStudio")
app.setOrganizationName("RecorderStudio")
app.setStyleSheet(QSS)
app.setFont(QFont("Microsoft YaHei UI", 9))
win = MainWindow()
win.show()
return app.exec_()
if __name__ == "__main__":
sys.exit(run())
+469
View File
@@ -0,0 +1,469 @@
"""后期处理与导出:归一化、裁剪、门限、淡入淡出、转码、元数据、波形预览。
设计取向:**原始 WAV 母版永远保留且不被覆盖**。所有后期处理默认写出新文件
(``*_processed.wav``),源文件只读。
"""
from __future__ import annotations
import json
import os
import shutil
import struct
import subprocess
import threading
import zlib
from dataclasses import asdict, dataclass
from datetime import datetime
import numpy as np
from . import dsp
from .engine import TakeResult, format_bytes, format_duration
from .wavfile import WavReader, write_wav
# ---------------------------------------------------------------- ffmpeg
_FFMPEG_CACHE: list[str | None] = []
_FFMPEG_LOCK = threading.Lock()
def find_ffmpeg() -> str | None:
"""定位 ffmpeg(PATH 或常见安装位置)。"""
with _FFMPEG_LOCK:
if _FFMPEG_CACHE:
return _FFMPEG_CACHE[0]
candidates: list[str | None] = []
exe = shutil.which("ffmpeg")
if exe:
candidates.append(exe)
for p in (
r"C:\ffmpeg\bin\ffmpeg.exe",
r"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
os.path.expanduser(r"~\scoop\shims\ffmpeg.exe"),
os.path.expanduser(r"~\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe"),
):
if os.path.exists(p):
candidates.append(p)
_FFMPEG_CACHE.append(candidates[0] if candidates else None)
return _FFMPEG_CACHE[0]
EXPORT_PRESETS: dict[str, dict] = {
"wav_16": {"label": "WAV 16-bit(兼容性最好)", "ext": ".wav", "kind": "wav"},
"wav_24": {"label": "WAV 24-bit(无损母版)", "ext": ".wav", "kind": "wav"},
"wav_f32": {"label": "WAV 32-bit float(后期制作)", "ext": ".wav", "kind": "wav"},
"flac": {"label": "FLAC(无损压缩,约 50% 体积)", "ext": ".flac", "kind": "ffmpeg"},
"mp3_320": {"label": "MP3 320 kbps(高码率有损)", "ext": ".mp3", "kind": "ffmpeg"},
"mp3_v0": {"label": "MP3 V0(VBR 约 245 kbps)", "ext": ".mp3", "kind": "ffmpeg"},
"opus": {"label": "Opus 128 kbps(语音/播客首选)", "ext": ".opus", "kind": "ffmpeg"},
"m4a": {"label": "AAC/M4A 256 kbps(苹果生态)", "ext": ".m4a", "kind": "ffmpeg"},
}
def _ffmpeg_args(preset: str, src: str, dst: str) -> list[str]:
ff = find_ffmpeg() or "ffmpeg"
base = [ff, "-hide_banner", "-loglevel", "error", "-y", "-i", src]
if preset == "flac":
return base + ["-c:a", "flac", "-compression_level", "8", dst]
if preset == "mp3_320":
return base + ["-c:a", "libmp3lame", "-b:a", "320k", dst]
if preset == "mp3_v0":
return base + ["-c:a", "libmp3lame", "-q:a", "0", dst]
if preset == "opus":
return base + ["-c:a", "libopus", "-b:a", "128k", dst]
if preset == "m4a":
return base + ["-c:a", "aac", "-b:a", "256k", dst]
return base + [dst]
def default_export_path(src: str, preset: str) -> str:
"""给出导出的默认目标路径;**绝不允许覆盖源文件**。"""
meta = EXPORT_PRESETS.get(preset) or {"ext": ".wav"}
root, _ext = os.path.splitext(src)
dst = f"{root}{meta['ext']}"
if os.path.abspath(dst) == os.path.abspath(src):
dst = f"{root}_{preset}{meta['ext']}"
return dst
def export_audio(src: str, dst: str, preset: str) -> tuple[bool, str]:
"""把 WAV 转成目标格式。返回 ``(是否成功, 说明)``。
安全约束:如果 ``dst`` 指向源文件本身,会自动改名,避免把母版覆盖掉。
"""
info = EXPORT_PRESETS.get(preset)
if info is None:
return False, f"未知的导出预设:{preset}"
if os.path.abspath(dst) == os.path.abspath(src):
dst = default_export_path(src, preset)
if info["kind"] == "wav":
try:
data, sr = _read_whole(src)
bits = {"wav_16": "16", "wav_24": "24", "wav_f32": "float32"}[preset]
write_wav(dst, data, sr, bit_depth=bits,
dither=(bits in ("16", "24")))
return True, f"{os.path.basename(dst)}({format_bytes(os.path.getsize(dst))})"
except Exception as exc:
return False, f"写入失败:{exc}"
ff = find_ffmpeg()
if ff is None:
return False, "未找到 ffmpeg:请安装 ffmpeg 并加入 PATH,或改用 WAV 导出"
try:
proc = subprocess.run(_ffmpeg_args(preset, src, dst),
capture_output=True, text=True, timeout=1800)
except Exception as exc:
return False, f"调用 ffmpeg 失败:{exc}"
if proc.returncode != 0:
return False, f"ffmpeg 出错:{(proc.stderr or '').strip()[:300]}"
return True, f"{os.path.basename(dst)}({format_bytes(os.path.getsize(dst))})"
# ------------------------------------------------------------ 处理选项
@dataclass
class ProcessOptions:
trim_silence: bool = False
trim_threshold_dbfs: float = -50.0
trim_min_silence: float = 0.4
remove_dc: bool = False
lowcut_hz: float = 0.0
noise_gate: bool = False
gate_threshold_dbfs: float = -60.0
normalize: str = "none" # 'none' | 'peak' | 'lufs'
normalize_target_dbfs: float = -1.0
normalize_target_lufs: float = -16.0
fade_in: float = 0.0
fade_out: float = 0.0
mono: bool = False
bit_depth: str = "24"
dither: bool = True
@property
def is_identity(self) -> bool:
return (not self.trim_silence and not self.remove_dc and self.lowcut_hz <= 0
and not self.noise_gate and self.normalize == "none"
and self.fade_in <= 0 and self.fade_out <= 0 and not self.mono)
def process_array(data: np.ndarray, samplerate: int, opts: ProcessOptions
) -> tuple[np.ndarray, dict]:
"""在内存中执行后期处理链,返回 ``(处理后的数据, 处理报告)``。"""
x = np.asarray(data, dtype=np.float64)
report: dict = {"steps": []}
if opts.remove_dc:
x = dsp.remove_dc(x, samplerate)
report["steps"].append("去除直流偏移(1 秒滑动平均,兼顾漂移)")
if opts.lowcut_hz > 0:
x = dsp.highpass_offline(x, samplerate, opts.lowcut_hz)
report["steps"].append(f"{opts.lowcut_hz:.0f} Hz 线性相位低切")
if opts.noise_gate:
x, gate_info = dsp.noise_gate(x, samplerate,
threshold_dbfs=opts.gate_threshold_dbfs)
report["noise_gate"] = gate_info
report["steps"].append(f"噪声门({opts.gate_threshold_dbfs:.0f} dBFS)")
if opts.trim_silence:
x, trim_info = dsp.trim_silence(x, samplerate,
threshold_dbfs=opts.trim_threshold_dbfs,
min_silence=opts.trim_min_silence)
report["trim"] = trim_info
report["steps"].append(f"裁剪首尾静音({trim_info.get('trimmed_seconds', 0)} s)")
if opts.mono and x.ndim > 1 and x.shape[1] > 1:
x = dsp.mixdown_mono(x)
report["steps"].append("混合为单声道")
if opts.normalize == "peak":
x, gain = dsp.normalize_peak(x, opts.normalize_target_dbfs)
report["normalize"] = {"mode": "peak", "gain_db": round(gain, 3),
"target_dbfs": opts.normalize_target_dbfs}
report["steps"].append(f"峰值归一化到 {opts.normalize_target_dbfs} dBFS")
elif opts.normalize == "lufs":
x, lufs_info = dsp.normalize_loudness(x, samplerate,
opts.normalize_target_lufs)
report["normalize"] = {"mode": "lufs", **lufs_info}
report["steps"].append(f"响度归一化到 {opts.normalize_target_lufs} LUFS")
if opts.fade_in > 0 or opts.fade_out > 0:
x = dsp.fade_edges(x, samplerate, fade_in=opts.fade_in, fade_out=opts.fade_out)
report["steps"].append(f"淡入 {opts.fade_in}s / 淡出 {opts.fade_out}s")
report["output_peak_dbfs"] = [round(float(v), 3) for v in
np.atleast_1d(dsp.dbfs(dsp.peak(x, axis=0)))]
return x, report
def _read_whole(path: str) -> tuple[np.ndarray, int]:
"""读取整个 WAV;超大文件自动降级为 float32 以节省内存。"""
with WavReader(path) as r:
frames = r.frames
need = frames * r.channels * 8
dtype = np.float64 if need < (1 << 31) else np.float32
chunks = []
for blk in r.iter_blocks(1 << 20):
chunks.append(blk.astype(dtype))
sr = r.samplerate
if not chunks:
return np.zeros((0, 1), dtype=dtype), sr
return np.concatenate(chunks, axis=0), sr
def process_file(src: str, opts: ProcessOptions, *,
dst: str | None = None,
progress=None) -> dict:
"""对录音文件执行后期处理并写出新文件(源文件保持不变)。"""
if dst is None:
root, ext = os.path.splitext(src)
dst = f"{root}_processed{ext or '.wav'}"
if progress:
progress("读取音频…")
data, sr = _read_whole(src)
if progress:
progress(f"处理 {len(data) / max(1, sr):.1f} 秒音频…")
out, report = process_array(data, sr, opts)
if progress:
progress("写出文件…")
stats = write_wav(dst, out.astype(np.float32), sr,
bit_depth=opts.bit_depth, dither=opts.dither)
try:
report["analysis"] = dsp.analyze_file(dst)
except Exception:
report["analysis"] = None
report.update({"source": src, "output": dst, "format": stats.get("format", "")})
return report
# --------------------------------------------------------------- 元数据
def write_metadata(result: TakeResult, *, extra: dict | None = None) -> list[str]:
"""写出 JSON 元数据 + 人类可读文本日志,返回生成的文件列表。"""
written: list[str] = []
cfg = result.config
meta = {
"app": "RecorderStudio",
"version": "1.0",
"recorded_at": result.started_at,
"finished_at": result.ended_at,
"device": result.device_label,
"format": result.format_label,
"sample_rate": cfg.samplerate if cfg else None,
"bit_depth": cfg.bit_depth if cfg else None,
"channels": cfg.channels if cfg else None,
"gain_db": cfg.gain_db if cfg else 0.0,
"lowcut_hz": cfg.lowcut_hz if cfg else 0.0,
"exclusive_mode": cfg.exclusive if cfg else None,
"dither": cfg.dither if cfg else None,
"duration_seconds": round(result.duration, 3),
"frames": result.frames,
"bytes": result.bytes_written,
"peak_dbfs": round(result.peak_dbfs, 3)
if np.isfinite(result.peak_dbfs) else None,
"clipped_samples": result.clipped_samples,
"xruns": result.xruns,
"queue_overflows": result.overflow_blocks,
"files": [os.path.basename(f) for f in result.files],
"markers": [asdict(m) for m in result.markers],
"analysis": result.analysis,
"notes": result.notes,
}
if extra:
meta.update(extra)
base = os.path.splitext(result.primary_file)[0] if result.primary_file else None
if not base:
return written
json_path = f"{base}.json"
with open(json_path, "w", encoding="utf-8") as fh:
json.dump(meta, fh, ensure_ascii=False, indent=2)
written.append(json_path)
txt_path = f"{base}.txt"
with open(txt_path, "w", encoding="utf-8") as fh:
fh.write(render_report(result))
written.append(txt_path)
return written
def render_report(result: TakeResult) -> str:
"""生成人类可读的录音报告(也用于界面上的"体检"面板)。"""
cfg = result.config
an = result.analysis or {}
lines = [
"RecorderStudio 录音报告",
"=" * 46,
f"开始时间 : {result.started_at}",
f"结束时间 : {result.ended_at}",
f"输入设备 : {result.device_label or '—'}",
f"录制格式 : {result.format_label or '—'}",
f"独占模式 : {'是' if (cfg and cfg.exclusive) else '否'}",
f"软件增益 : {cfg.gain_db:+.1f} dB" if cfg else "",
f"低切滤波 : {cfg.lowcut_hz:.0f} Hz" if cfg and cfg.lowcut_hz > 0 else "低切滤波 : 关闭",
f"抖动 : {'开启 (TPDF)' if (cfg and cfg.dither) else '关闭'}",
"",
f"总时长 : {format_duration(result.duration)}",
f"总采样帧 : {result.frames}",
f"数据量 : {format_bytes(result.bytes_written)}",
f"文件数 : {len(result.files)}",
]
for f in result.files:
try:
sz = os.path.getsize(f)
except OSError:
sz = 0
lines.append(f" · {os.path.basename(f)} ({format_bytes(sz)})")
lines += [
"",
"音质体检",
"-" * 46,
f"采样峰值 : {_fmt_list(an.get('peak_dbfs'))} dBFS",
f"真峰值 : {_fmt_list(an.get('true_peak_dbtp'))} dBTP"
+ ("(已达上限,建议降低增益)"
if _maxf(an.get("true_peak_dbtp")) is not None
and _maxf(an.get("true_peak_dbtp")) > -0.1 else ""),
f"RMS 电平 : {_fmt_list(an.get('rms_dbfs'))} dBFS",
f"整体响度 : {an.get('integrated_lufs')} LUFS",
f"动态范围 : {an.get('loudness_range_lu')} LU",
f"直流偏移 : {an.get('dc_offset')}",
f"本底噪声 : {_fmt_list(an.get('noise_floor_dbfs'))} dBFS"
+ ("" if an.get("noise_floor_available") else "(录音中未检测到静音段,无法测定)"),
f"削波样本 : {an.get('clipped_total', result.clipped_samples)}",
f"丢弃块/溢出: {result.overflow_blocks}",
f"驱动层 xrun: {result.xruns}",
]
if result.markers:
lines += ["", "标记", "-" * 46]
for m in result.markers:
lines.append(f" {m.seconds:8.3f} s {m.label} ({m.file})")
if result.notes:
lines += ["", "运行日志", "-" * 46] + [f" · {n}" for n in result.notes]
if an.get("segment_count"):
lines += ["", f"注:本次录音共 {an['segment_count']} 个分段,以上为合并统计。"]
return "\n".join(l for l in lines if l is not None)
def _fmt_list(v) -> str:
if v is None:
return "—"
if isinstance(v, list):
return ", ".join("—" if x is None else f"{x:+.2f}" for x in v)
return f"{v:+.2f}"
def _maxf(v) -> float | None:
if v is None:
return None
if isinstance(v, list):
vals = [x for x in v if x is not None]
return max(vals) if vals else None
return float(v)
# ------------------------------------------------------- 波形预览 (PNG)
def _png_chunk(tag: bytes, data: bytes) -> bytes:
return (struct.pack(">I", len(data)) + tag + data
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
def write_waveform_png(path: str, data: np.ndarray, samplerate: int,
*, width: int = 1600, height: int = 320,
bg=(18, 20, 26), wave=(90, 200, 255), mid=(70, 78, 96),
rms_color=(255, 190, 80)) -> str:
"""用纯 numpy + zlib 画一张波形预览图(不依赖 PIL)。"""
x = np.asarray(data, dtype=np.float32)
if x.ndim == 1:
x = x[:, None]
n, ch = x.shape
if n == 0:
x = np.zeros((1, 1), np.float32)
n, ch = 1, 1
canvas = np.zeros((height, width, 3), dtype=np.uint8)
canvas[:, :] = bg
lanes = ch if ch <= 2 else 2
lane_h = height // lanes
per = max(1, n // width)
usable = (n // per) * per
block = x[:usable].reshape(-1, per, ch)
mn = block.min(axis=1)
mx = block.max(axis=1)
rms = np.sqrt(np.mean(block.astype(np.float64) ** 2, axis=1))
cols = mn.shape[0]
for lane in range(lanes):
y0 = lane * lane_h
yc = y0 + lane_h // 2
canvas[max(0, yc - 1):yc + 1, :] = mid
src = lane if ch <= 2 else 0
for c in range(cols):
xx = int(c * width / max(1, cols))
if xx >= width:
continue
top = int(yc - mx[c, src] * (lane_h / 2 - 4))
bot = int(yc - mn[c, src] * (lane_h / 2 - 4))
top = max(y0, min(y0 + lane_h - 1, top))
bot = max(y0, min(y0 + lane_h - 1, bot))
if bot < top:
top, bot = bot, top
canvas[top:bot + 1, xx] = wave
r = float(rms[c, src]) * (lane_h / 2 - 4)
canvas[max(y0, int(yc - r)):min(y0 + lane_h, int(yc + r) + 1), xx] = rms_color
raw = b"".join(b"\x00" + canvas[y].tobytes() for y in range(height))
png = (b"\x89PNG\r\n\x1a\n"
+ _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
+ _png_chunk(b"IDAT", zlib.compress(raw, 6))
+ _png_chunk(b"IEND", b""))
with open(path, "wb") as fh:
fh.write(png)
return path
def make_preview_for(result: TakeResult, *, seconds: float | None = None) -> str | None:
"""为录音结果生成波形预览图 + 报告文本。"""
if not result.primary_file or not os.path.exists(result.primary_file):
return None
try:
data, sr = _read_whole(result.primary_file)
if seconds is not None and len(data) > seconds * sr:
data = data[:int(seconds * sr)]
base = os.path.splitext(result.primary_file)[0]
return write_waveform_png(f"{base}_waveform.png", data, sr)
except Exception:
return None
def write_readme_for_session(result: TakeResult) -> list[str]:
"""额外的"每次录音都留一份说明"的兜底函数(供 CLI 使用)。"""
out: list[str] = []
if not result.primary_file:
return out
base = os.path.splitext(result.primary_file)[0]
p = f"{base}_info.txt"
with open(p, "w", encoding="utf-8") as fh:
fh.write(render_report(result) + "\n")
out.append(p)
return out
def regenerate_report(path: str) -> str:
"""对已有录音重新生成报告(界面上的"重新体检")。"""
an = dsp.analyze_file(path)
lines = [
"RecorderStudio 文件体检",
"=" * 46,
f"文件 : {os.path.basename(path)}",
f"生成时间 : {datetime.now().isoformat(timespec='seconds')}",
f"格式 : {an.get('format')}",
f"时长 : {format_duration(an.get('duration', 0.0))}",
f"数据量 : {format_bytes(an.get('data_bytes', 0))}",
"",
f"采样峰值 : {_fmt_list(an.get('peak_dbfs'))} dBFS",
f"真峰值 : {_fmt_list(an.get('true_peak_dbtp'))} dBTP",
f"RMS 电平 : {_fmt_list(an.get('rms_dbfs'))} dBFS",
f"整体响度 : {an.get('integrated_lufs')} LUFS",
f"动态范围 : {an.get('loudness_range_lu')} LU",
f"直流偏移 : {an.get('dc_offset')}",
f"本底噪声 : {_fmt_list(an.get('noise_floor_dbfs'))} dBFS",
f"削波样本 : {an.get('clipped_total')}",
]
return "\n".join(lines)
+661
View File
@@ -0,0 +1,661 @@
"""自检测试:数学正确性 + 格式往返 + 硬件端到端。
用法::
python -m recorder.selftest # 全部(含真实硬件录音,约 20 秒)
python -m recorder.selftest --no-hw # 只跑离线数学/格式测试(无需麦克风)
python -m recorder.selftest --quick # 硬件测试缩短到 1 秒
"""
from __future__ import annotations
import argparse
import math
import os
import sys
import time
import numpy as np
from . import dsp, engine, post
from .wavfile import WavReader, _TpdfDither, float_to_pcm, pcm_to_float, write_wav
class Runner:
def __init__(self, quiet: bool = False):
self.passed = 0
self.failed: list[tuple[str, str]] = []
self.quiet = quiet
self.current = ""
def section(self, name: str) -> None:
self.current = name
if not self.quiet:
print(f"\n── {name} " + "─" * max(0, 56 - len(name)))
def check(self, name: str, ok: bool, detail: str = "") -> bool:
if ok:
self.passed += 1
if not self.quiet:
print(f" ✓ {name}" + (f" {detail}" if detail else ""))
else:
self.failed.append((f"{self.current} / {name}", detail))
print(f" ✗ {name} {detail}", file=sys.stderr)
return bool(ok)
def near(self, name: str, got: float, expect: float, tol: float,
unit: str = "") -> bool:
ok = abs(float(got) - float(expect)) <= tol
return self.check(name, ok,
f"实测 {got:.4f}{unit},期望 {expect:.4f}±{tol:g}{unit}")
@property
def ok(self) -> bool:
return not self.failed
# ------------------------------------------------------------- 1. 响度与滤波
def test_loudness(r: Runner) -> None:
r.section("ITU-R BS.1770 响度标准符合性")
fb1, fa1 = dsp._itu_highshelf(48000)
fb2, fa2 = dsp._itu_highpass(48000)
itu_b1 = np.array([1.53512485958697, -2.69169618940638, 1.19839281085285])
itu_a1 = np.array([1.0, -1.69065929318241, 0.73248077421585])
itu_b2 = np.array([1.0, -2.0, 1.0])
itu_a2 = np.array([1.0, -1.99004745483398, 0.99007225036621])
r.check("高频搁架设计系数与 ITU 原文一致",
np.max(np.abs(fb1 - itu_b1)) < 1e-12
and np.max(np.abs(fa1 - itu_a1)) < 1e-12,
f"最大偏差 {max(np.max(np.abs(fb1 - itu_b1)), np.max(np.abs(fa1 - itu_a1))):.2e}")
r.check("二阶高通设计系数与 ITU 原文一致",
np.max(np.abs(fb2 - itu_b2)) < 1e-12
and np.max(np.abs(fa2 - itu_a2)) < 1e-12,
f"最大偏差 {max(np.max(np.abs(fb2 - itu_b2)), np.max(np.abs(fa2 - itu_a2))):.2e}")
for sr in (22050, 44100, 96000):
for b, a in dsp.kweighting_coeffs(sr):
r.check(f"{sr} Hz K 加权滤波器稳定",
float(np.max(np.abs(np.roots(a)))) < 1.0)
wref = 2 * math.pi * 997.0 / 48000
gain = 10 * math.log10(float((
dsp.biquad_response_sq(fb1, fa1, np.array([wref]))
* dsp.biquad_response_sq(fb2, fa2, np.array([wref])))[0]))
r.near("K 加权在 997 Hz 的增益 = +0.691 dB(标准偏移量的来源)",
gain, 0.691, 0.01, " dB")
sr = 48000
t = np.arange(sr * 5) / sr
sine = 0.999 * np.sin(2 * np.pi * 997 * t)
st = np.stack([sine, sine], axis=1)
def loud(data, ch):
m = dsp.LoudnessMeter(sr, ch)
for i in range(0, data.shape[0], 4096):
m.push(data[i:i + 4096])
m.flush()
return m.integrated
# 期望值已用 ffmpeg 的 ebur128 滤波器独立验证过
r.near("满量程立体声 997 Hz 正弦 = 0.0 LUFS(ffmpeg 实测 -0.0)",
loud(st, 2), 0.0, 0.1, " LUFS")
r.near("满量程单声道 997 Hz 正弦 = -3.01 LUFS(ffmpeg 实测 -3.0)",
loud(sine[:, None], 1), -3.010, 0.1, " LUFS")
r.near("电平 -20 dB 时响度同步下降 20 LU(ffmpeg 实测 -20.0)",
loud(st * 0.1, 2), -20.0, 0.1, " LUFS")
rng = np.random.default_rng(7)
noise = rng.standard_normal((sr * 6, 2)) * 0.1
got = loud(noise, 2)
r.near("宽带白噪声响度(ffmpeg 实测 -13.8 LUFS)", got, -13.84, 0.15, " LUFS")
def test_filters(r: Runner) -> None:
r.section("线性相位滤波器")
h = dsp.design_highpass_fir(80.0, 48000)
k = np.arange(h.size)
def resp(f):
w = 2 * np.pi * f / 48000
return float(np.abs(np.sum(h * np.exp(-1j * w * k))))
r.check("直流增益 < -60 dB", 20 * math.log10(max(resp(0.0), 1e-12)) < -60,
f"{20 * math.log10(max(resp(0.0), 1e-12)):.1f} dB")
r.check("1 kHz 通带增益 ≈ 0 dB", abs(20 * math.log10(resp(1000.0))) < 0.02,
f"{20 * math.log10(resp(1000.0)):+.4f} dB")
r.check("100 Hz 通带起伏 < 0.35 dB", abs(20 * math.log10(resp(100.0))) < 0.35,
f"{20 * math.log10(resp(100.0)):+.3f} dB")
r.check("20 Hz 抑制 > 40 dB", 20 * math.log10(resp(20.0)) < -40,
f"{20 * math.log10(resp(20.0)):.1f} dB,{h.size} 抽头")
r.check("系数严格对称(线性相位)", np.allclose(h, h[::-1], atol=1e-15))
# 群延迟对齐:首尾各补一段直流,滤波后不应丢样本
x = np.concatenate([np.zeros((5000, 1)), np.ones((5000, 1)) * 0.5,
np.zeros((5000, 1))])
y = dsp.highpass_offline(x, 48000, 80.0)
r.check("离线低切保持样本数(群延迟已补偿)", y.shape[0] == x.shape[0] - (h.size - 1) // 2,
f"{x.shape[0]} → {y.shape[0]}")
def test_true_peak(r: Runner) -> None:
r.section("真峰值(4 倍过采样)")
sr = 48000
t = np.arange(sr) / sr
aligned = np.sin(2 * np.pi * 12000 * t) * 0.5
r.near("采样点对齐时真峰值 = 采样峰值 0.5",
float(dsp.true_peak(aligned[:, None])[0]), 0.5, 0.005)
shifted = np.sin(2 * np.pi * 12000 * t + np.pi / 4) * 0.5
sp = float(np.max(np.abs(shifted)))
tp = float(dsp.true_peak(shifted[:, None])[0])
r.check("能捕捉采样点之间的过冲(这是 dBTP 的意义)",
tp > sp * 1.2 and abs(tp - 0.5) < 0.01,
f"采样峰值 {sp:.4f} → 真峰值 {tp:.4f}(理论 0.5)")
mono = np.zeros((0, 1))
r.check("空输入不崩溃", dsp.true_peak(mono).shape == (1,))
def test_meters(r: Runner) -> None:
r.section("电平表与响度计(流式)")
sr = 48000
m = dsp.LevelMeter(sr, 2)
x = np.stack([np.ones(1024) * 0.5, np.ones(1024) * 0.25], axis=1).astype(np.float32)
for _ in range(20):
m.process(x)
snap = m.snapshot()
r.near("RMS 计算正确(0.5 → -6.02 dBFS)", snap.rms_db[0], -6.02, 0.02, " dB")
r.near("峰值计算正确(0.25 → -12.04 dBFS)", snap.peak_db[1], -12.04, 0.02, " dB")
m.process(np.ones((256, 2), dtype=np.float32))
r.check("削波锁存生效", m.snapshot().clipped[0] and m.snapshot().clip_count > 0)
m.reset_clip()
r.check("削波锁存可复位", not m.snapshot().clipped[0])
lm = dsp.LoudnessMeter(sr, 2, keep_hops=False)
t2 = np.arange(sr) / sr
sine = np.sin(2 * np.pi * 997 * t2) * 0.1
for i in range(0, sr, 4800):
lm.push(np.stack([sine[i:i + 4800]] * 2, axis=1))
r.check("不保留历史时整体响度返回 -inf 而不是乱码",
not math.isfinite(lm.integrated))
r.near("瞬时响度读数正确(-20 dBFS 立体声正弦 ≈ -20 LUFS)",
lm.momentary, -20.0, 0.3, " LUFS")
dc_meter = dsp.LoudnessMeter(sr, 2)
dc_meter.push(np.ones((sr, 2)) * 0.1)
dc_meter.flush()
r.check("纯直流被 K 加权高通滤除(响度极低)",
dc_meter.momentary < -60, f"{dc_meter.momentary:.1f} LUFS")
# ------------------------------------------------------- 2. WAV 格式与量化
def test_wav(r: Runner, tmp: str) -> None:
r.section("WAV 编解码与量化")
rng = np.random.default_rng(11)
sig = ((rng.random((48000, 2)) * 2 - 1) * 0.5).astype(np.float32)
cases = [("16", 1.6 / 32768), ("24", 2.1 / 8388608), ("32", 1e-7),
("float32", 1e-7)]
for depth, tol in cases:
p = os.path.join(tmp, f"rt_{depth}.wav")
stats = write_wav(p, sig, 48000, bit_depth=depth, dither=(depth != "float32"))
with WavReader(p) as rd:
back = rd.read()
info = rd.info()
err = float(np.max(np.abs(back - sig)))
r.check(f"{depth} 位往返误差在量化极限内",
back.shape == sig.shape and err <= tol,
f"最大误差 {err:.2e}(容差 {tol:.1e}),{info['format']}")
# 头部正确性:用 Python 标准库 wave 模块独立复核 16 位文件
import wave as pywave
p16 = os.path.join(tmp, "rt_16.wav")
with pywave.open(p16, "rb") as w:
r.check("标准库 wave 能读我们的 16 位文件",
w.getnchannels() == 2 and w.getframerate() == 48000
and w.getsampwidth() == 2 and w.getnframes() == 48000,
f"{w.getnchannels()}ch {w.getframerate()}Hz {w.getsampwidth()*8}bit "
f"{w.getnframes()}帧")
# 24 位文件用 ffmpeg 独立解码核对
if post.find_ffmpeg():
import subprocess
p24 = os.path.join(tmp, "rt_24.wav")
proc = subprocess.run(
[post.find_ffmpeg(), "-hide_banner", "-v", "error", "-i", p24,
"-f", "s16le", "-ac", "2", "-ar", "48000", "-"],
capture_output=True)
decoded = np.frombuffer(proc.stdout, dtype="<i2").reshape(-1, 2)
ref = np.clip(sig, -1, 1) * 32767
err = float(np.max(np.abs(decoded.astype(np.float64) - ref))) / 32767
r.check("ffmpeg 独立解码 24 位文件内容正确", decoded.shape[0] == 48000 and err < 1e-4,
f"最大偏差 {err:.2e},{decoded.shape[0]} 帧")
else:
r.check("ffmpeg 可用(跳过交叉解码验证)", True, "未检测到 ffmpeg,已跳过")
# RF64
pr = os.path.join(tmp, "rt_rf64.wav")
write_wav(pr, sig, 48000, bit_depth="24", rf64=True)
with WavReader(pr) as rd:
back = rd.read()
r.check("RF64 容器读写往返", rd.rf64 and back.shape == sig.shape,
f"rf64={rd.rf64},{back.shape[0]} 帧")
# 损坏/边界输入
pbad = os.path.join(tmp, "bad.wav")
with open(pbad, "wb") as fh:
fh.write(b"not a wav file")
try:
WavReader(pbad)
r.check("非法文件被拒绝", False, "竟然没有报错")
except ValueError:
r.check("非法文件被拒绝", True)
# 空录音
pe = os.path.join(tmp, "empty.wav")
write_wav(pe, np.zeros((0, 2), np.float32), 48000)
with WavReader(pe) as rd:
r.check("零长度录音可被正常写出与读取",
rd.frames == 0 and rd.duration == 0.0)
def test_dither(r: Runner) -> None:
r.section("TPDF 抖动")
sr = 48000
t = np.arange(sr * 2) / sr
lo = (np.sin(2 * np.pi * 300 * t) * dsp.db_to_lin(-80)).astype(np.float64)
def qerr(x, use_dither):
d = _TpdfDither(4242) if use_dither else None
raw = float_to_pcm(x[:, None], "pcm", 16, d)
return pcm_to_float(raw, "pcm", 16) - x
e_plain = qerr(lo, False)
e_dith = qerr(lo, True)
c_plain = abs(float(np.corrcoef(e_plain, lo)[0, 1]))
c_dith = abs(float(np.corrcoef(e_dith, lo)[0, 1]))
r.check("未抖动时量化误差与信号强相关(会产生非线性失真)",
c_plain > 0.05, f"相关系数 {c_plain:.4f}")
r.check("加抖动后误差与信号去相关", c_dith < 0.05 and c_dith < c_plain,
f"相关系数 {c_dith:.4f}")
lsb = 1.0 / 32767
r.near("抖动总误差方差 = 1/4 LSB²(1/6 抖动 + 1/12 量化)",
float(np.var(e_dith)) / lsb ** 2, 0.25, 0.02)
r.near("未抖动误差方差 = 1/12 LSB²",
float(np.var(e_plain)) / lsb ** 2, 1 / 12, 0.02)
def test_bitdepth(r: Runner) -> None:
r.section("位深与满量程处理")
edge = np.array([[1.0], [-1.0], [0.0], [1.5], [-1.5], [0.99999]],
dtype=np.float64)
for depth in ("16", "24", "32"):
raw = float_to_pcm(edge, "pcm", int(depth), None)
back = pcm_to_float(raw, "pcm", int(depth))
r.check(f"{depth} 位满量程对称且不越界",
abs(back[0] - 1.0) < 1e-6 and abs(back[1] + 1.0) < 1e-6
and abs(back[2]) < 1e-9,
f"+1→{back[0]:.6f} −1→{back[1]:.6f} 0→{back[2]:.1e}")
f = float_to_pcm(edge, "float", 32, None)
r.check("32 位浮点保留超量程信息(不做削波,便于后期)",
len(f) == edge.size * 4)
# ------------------------------------------------------------ 3. 离线处理
def test_processing(r: Runner, tmp: str) -> None:
r.section("离线处理与导出")
sr = 48000
rng = np.random.default_rng(5)
silence = rng.standard_normal((sr, 2)) * dsp.db_to_lin(-80)
tone = np.stack([np.sin(2 * np.pi * 440 * np.arange(sr * 2) / sr) * 0.2] * 2,
axis=1)
x = np.concatenate([silence, tone, silence], axis=0)
x += 0.01 # 人为加入直流偏移
opts = post.ProcessOptions(trim_silence=True, remove_dc=True, lowcut_hz=60.0,
normalize="peak", normalize_target_dbfs=-1.0,
fade_in=0.01, fade_out=0.05, bit_depth="24")
y, report = post.process_array(x, sr, opts)
r.check("后期处理链执行成功", y.shape[0] > 0 and len(report["steps"]) >= 4,
"、".join(report["steps"]))
r.check("裁剪掉了首尾静音", y.shape[0] < x.shape[0] - sr,
f"{x.shape[0]} → {y.shape[0]} 帧")
r.near("直流偏移被消除", float(np.mean(y)), 0.0, 1e-6)
r.near("峰值归一化到目标 -1 dBFS", float(report["output_peak_dbfs"][0]),
-1.0, 0.15, " dBFS")
r.near("淡入淡出生效(首样本接近 0)", float(abs(y[0, 0])), 0.0, 0.02)
src = os.path.join(tmp, "proc_src.wav")
write_wav(src, x.astype(np.float32), sr, bit_depth="24")
rep = post.process_file(src, opts)
r.check("处理结果写到新文件,原文件保持不变",
os.path.exists(rep["output"]) and os.path.exists(src)
and rep["output"] != src, os.path.basename(rep["output"]))
ok, msg = post.export_audio(rep["output"], os.path.join(tmp, "out16.wav"), "wav_16")
r.check("导出 16 位 WAV", ok, msg)
# 关键安全约束:导出目标与源文件同名时绝不覆盖母版
same = post.default_export_path(rep["output"], "wav_16")
r.check("导出目标与源同名时自动改名,母版不会被覆盖",
os.path.abspath(same) != os.path.abspath(rep["output"]),
os.path.basename(same))
with WavReader(rep["output"]) as before:
frames_before = before.frames
bits_before = before.bits
ok, _msg = post.export_audio(rep["output"], rep["output"], "wav_16")
with WavReader(rep["output"]) as after:
r.check("即使显式传入源路径,源文件位深/长度也不被改动",
ok and after.frames == frames_before and after.bits == bits_before,
f"{after.bits} 位,{after.frames} 帧")
if post.find_ffmpeg():
ok, msg = post.export_audio(rep["output"], os.path.join(tmp, "out.flac"), "flac")
r.check("导出 FLAC", ok, msg)
else:
r.check("缺少 ffmpeg 时给出清晰提示",
not post.export_audio(rep["output"],
os.path.join(tmp, "x.mp3"), "mp3_320")[0])
png = post.write_waveform_png(os.path.join(tmp, "wave.png"), y, sr)
with open(png, "rb") as fh:
head = fh.read(8)
r.check("纯 numpy 生成的 PNG 波形图有效",
head == b"\x89PNG\r\n\x1a\n" and os.path.getsize(png) > 1000,
f"{os.path.getsize(png)} 字节")
# 分析 & 报告
an = dsp.analyze_file(rep["output"])
r.check("文件体检字段齐全",
all(k in an for k in ("peak_dbfs", "true_peak_dbtp", "rms_dbfs",
"integrated_lufs", "loudness_range_lu",
"dc_offset", "noise_floor_dbfs",
"clipped_total")),
f"峰值 {an['peak_dbfs']},真峰值 {an['true_peak_dbtp']},"
f"响度 {an['integrated_lufs']} LUFS")
r.check("体检报告可渲染", "RecorderStudio 文件体检" in post.regenerate_report(rep["output"]))
# 多分段合并统计
a = {"duration": 10.0, "peak_dbfs": [-3.0], "true_peak_dbtp": [-2.8],
"integrated_lufs": -20.0, "clipped_total": 0}
b = {"duration": 30.0, "peak_dbfs": [-1.0], "true_peak_dbtp": [-0.9],
"integrated_lufs": -14.0, "clipped_total": 3}
m = engine._merge_analysis([a, b])
r.check("分段合并取最严值", m["peak_dbfs"] == [-1.0] and m["true_peak_dbtp"] == [-0.9]
and m["clipped_total"] == 3 and abs(m["duration"] - 40.0) < 1e-6,
f"峰值 {m['peak_dbfs']},削波 {m['clipped_total']},时长 {m['duration']}")
r.check("响度按能量加权合并(应介于两段之间)",
-20.0 < m["integrated_lufs"] < -14.0, f"{m['integrated_lufs']} LUFS")
def test_metadata(r: Runner, tmp: str) -> None:
r.section("元数据与报告")
sr = 48000
tone = np.sin(2 * np.pi * 440 * np.arange(sr) / sr) * 0.3
path = os.path.join(tmp, "meta_test.wav")
write_wav(path, np.stack([tone, tone], axis=1).astype(np.float32), sr,
bit_depth="24")
result = engine.TakeResult(
files=[path], duration=1.0, frames=sr, bytes_written=os.path.getsize(path),
peak_dbfs=-10.4, markers=[engine.Marker("测试标记", os.path.basename(path),
0.5, "2024-01-01T00:00:00")],
config=engine.RecordConfig(samplerate=sr, bit_depth="24", channels=2),
started_at="2024-01-01T00:00:00", ended_at="2024-01-01T00:00:01",
device_label="测试设备 [WASAPI]", format_label="48000 Hz / 24-bit PCM / 2 声道",
analysis=dsp.analyze_file(path))
files = post.write_metadata(result)
import json
with open(files[0], encoding="utf-8") as fh:
meta = json.load(fh)
r.check("元数据 JSON 含关键字段",
meta["bit_depth"] == "24" and meta["sample_rate"] == sr
and meta["markers"][0]["label"] == "测试标记"
and meta["analysis"]["peak_dbfs"] is not None,
"、".join(k for k in ("format", "markers", "analysis")))
r.check("文本报告含标记与体检", "测试标记" in post.render_report(result)
and "整体响度" in post.render_report(result))
# ---------------------------------------------------------- 4. 引擎(离线)
def test_engine_offline(r: Runner, tmp: str) -> None:
r.section("引擎(离线部分)")
cfg = engine.RecordConfig(samplerate=48000, channels=2, bit_depth="24")
r.check("配置清洗:非法采样率/声道被夹紧",
engine.RecordConfig(samplerate=1, channels=99, gain_db=999,
bit_depth="24").sanitized().channels <= 32)
r.near("24 位 48 kHz 立体声码率 = 288 kB/s",
cfg.wav_format().bytes_per_second, 288000.0, 1.0, " B/s")
r.check("小时体积估算正确",
abs(cfg.estimate_bytes_per_hour() - 288000 * 3600) < 1,
engine.format_bytes(cfg.estimate_bytes_per_hour()))
r.check("时长格式化", engine.format_duration(3725.4) == "1:02:05.4",
engine.format_duration(3725.4))
r.check("体积格式化", engine.format_bytes(1024 * 1024 * 3) == "3.0 MB",
engine.format_bytes(1024 * 1024 * 3))
r.check("磁盘空间可查", engine.free_space_bytes(tmp) > 0,
engine.format_bytes(engine.free_space_bytes(tmp)))
lc = engine.AlignedLowCut(80.0, 48000, 2)
r.check("低切滤波器抽头数与群延迟合理",
lc.taps > 1000 and lc.latency > 0,
f"{lc.taps} 抽头,群延迟 {lc.latency / 48.0:.1f} ms")
total_in = 0
total_out = 0
rng = np.random.default_rng(3)
for _ in range(20):
blk = rng.standard_normal((1024, 2)).astype(np.float32) * 0.1
total_in += blk.shape[0]
total_out += lc.process(blk).shape[0]
total_out += lc.flush().shape[0]
r.check("低切流式处理后样本数严格守恒(不丢头不掉尾)",
total_in == total_out, f"输入 {total_in} 帧 → 输出 {total_out} 帧")
# 文件名模板
rec = engine.Recorder(cfg)
rec._out_dir = tmp # noqa: SLF001
rec.config.name_template = "{date}_{time}_{sr}_{bits}_{ch}_{seq}"
p = rec._next_path() # noqa: SLF001
r.check("命名模板变量全部替换且无非法字符",
"{" not in p and "}" not in p and p.endswith(".wav")
and "48000" in p and "24" in p and "2ch" in p,
os.path.basename(p))
# 设备枚举(不要求存在设备)
devs = engine.list_input_devices()
r.check("设备枚举可调用", isinstance(devs, list),
f"发现 {len(devs)} 个输入设备" if devs else "当前机器无输入设备")
if devs:
r.check("设备按宿主 API 保真度排序",
all(devs[i].quality_rank <= devs[i + 1].quality_rank
for i in range(len(devs) - 1)),
" → ".join(dict.fromkeys(d.hostapi for d in devs)))
# ---------------------------------------------------------- 5. 硬件端到端
def test_hardware(r: Runner, tmp: str, seconds: float = 1.5) -> None:
r.section("硬件端到端录音")
if engine.sd is None:
r.check("音频后端可用", False, f"sounddevice 不可用:{engine.SD_IMPORT_ERROR}")
return
devs = [d for d in engine.list_input_devices() if d.max_input_channels > 0]
if not devs:
r.check("存在可用输入设备", False, "本机没有录音输入设备,已跳过硬件测试")
return
# 优先选 WASAPI 设备
dev = next((d for d in devs if d.quality_rank == 0), devs[0])
r.check("选中设备", True, f"[{dev.index}] {dev.name} [{dev.hostapi}]")
rate = int(dev.default_samplerate)
ch = min(2, dev.max_input_channels)
is_wasapi = "wasapi" in dev.hostapi.lower()
for depth, lowcut in (("24", 0.0), ("float32", 80.0)):
cfg = engine.RecordConfig(device=dev.index, samplerate=rate, channels=ch,
bit_depth=depth, exclusive=is_wasapi,
lowcut_hz=lowcut, output_dir=tmp,
name_template=f"hw_{depth}_{int(lowcut)}",
split_seconds=0.0)
rec = engine.Recorder(cfg)
try:
rec.start()
except Exception as exc:
r.check(f"{depth} 位开流", False, str(exc).splitlines()[0])
continue
time.sleep(seconds)
marker = rec.add_marker("自检测试标记")
snap = rec.live()
time.sleep(0.2)
res = rec.stop()
expect = res.duration
r.check(f"{depth} 位录音时长接近请求值",
abs(expect - (seconds + 0.2)) < 0.6,
f"实得 {expect:.3f}s(请求 {seconds + 0.2:.1f}s)")
r.check(f"{depth} 位文件存在且可解析",
bool(res.files) and all(os.path.exists(f) for f in res.files),
"、".join(os.path.basename(f) for f in res.files))
if res.files:
with WavReader(res.files[0]) as rd:
info = rd.info()
frames_ok = abs(info["duration"] - res.duration) < 0.05
r.check(f"{depth} 位头部元数据自洽", frames_ok and info["samplerate"] == rate,
f"{info['format']},{info['frames']} 帧")
r.check(f"{depth} 位格式符合请求",
(info["bits"] == int(depth)) if depth != "float32"
else info["encoding"] == "float",
info["format"])
r.check(f"{depth} 位无驱动溢出/xrun",
res.xruns == 0 and res.overflow_blocks == 0,
f"xrun {res.xruns},溢出块 {res.overflow_blocks}")
r.check(f"{depth} 位标记已记录", marker is not None and len(res.markers) == 1,
f"{len(res.markers)} 个标记")
r.check(f"{depth} 位体检报告生成", bool(res.analysis),
f"峰值 {res.peak_dbfs:+.1f} dBFS,"
f"响度 {(res.analysis or {}).get('integrated_lufs')} LUFS")
if lowcut > 0 and res.files:
# 低切路径同样要做到样本数守恒(对齐正确)
expected_frames = int(round(res.duration * rate))
with WavReader(res.files[0]) as rd:
r.check("启用低切后样本数与时长一致(对齐正确)",
abs(rd.frames - expected_frames) <= 2,
f"{rd.frames} 帧 vs 期望约 {expected_frames} 帧")
# 分段能力
cfg = engine.RecordConfig(device=dev.index, samplerate=rate, channels=ch,
bit_depth="24", exclusive=is_wasapi, output_dir=tmp,
name_template="hw_split", split_seconds=0.5)
rec = engine.Recorder(cfg)
rec.start()
time.sleep(1.6)
res = rec.stop()
r.check("按时长自动分段生效", len(res.files) >= 3,
f"{len(res.files)} 个文件:" +
"、".join(f"{WavReader(f).duration:.2f}s" for f in res.files
if os.path.exists(f)))
if res.files:
total = 0.0
for f in res.files:
with WavReader(f) as rd:
total += rd.duration
r.check("分段后总时长不丢数据", abs(total - res.duration) < 0.05,
f"文件合计 {total:.3f}s vs 统计 {res.duration:.3f}s")
test_hardware_auto_stop(r, tmp, dev, rate, ch, is_wasapi)
def test_hardware_auto_stop(r: Runner, tmp: str, dev, rate: int, ch: int,
is_wasapi: bool) -> None:
"""手动分段与静音自动停止(这两条路径最容易出"文件没收尾/流没关"的问题)。"""
# 手动分段
cfg = engine.RecordConfig(device=dev.index, samplerate=rate, channels=ch,
bit_depth="24", exclusive=is_wasapi, output_dir=tmp,
name_template="hw_manual_split")
rec = engine.Recorder(cfg)
rec.start()
time.sleep(0.6)
rec.split_now()
time.sleep(0.7)
res = rec.stop()
r.check("手动分段立即切换新文件", len(res.files) == 2,
"、".join(f"{WavReader(f).duration:.2f}s" for f in res.files
if os.path.exists(f)))
# 静音自动停止(阈值故意设得很高,保证当前环境一定能触发)
cfg = engine.RecordConfig(device=dev.index, samplerate=rate, channels=ch,
bit_depth="24", exclusive=is_wasapi, output_dir=tmp,
name_template="hw_autostop",
silence_threshold_dbfs=-20.0,
auto_stop_silence_seconds=0.6)
rec = engine.Recorder(cfg)
rec.start()
t0 = time.time()
stopped_at = None
while time.time() - t0 < 6.0:
if rec.live().state == engine.RecorderState.IDLE.value:
stopped_at = time.time() - t0
break
time.sleep(0.05)
r.check("静音自动停止被触发", stopped_at is not None,
f"{stopped_at:.2f} 秒后停止" if stopped_at else "6 秒内未触发")
# 关键:引擎自己停止时必须同时关闭 PortAudio 流,否则回调会持续灌满队列
time.sleep(1.2)
backlog_after = rec.live().queue_backlog
overflow_after = rec.live().overflow_blocks
r.check("自动停止后音频流已关闭(队列不再增长、无溢出)",
backlog_after <= 1 and overflow_after == 0,
f"静置 1.2 秒后队列 {backlog_after},溢出块 {overflow_after}")
res = rec.stop()
r.check("自动停止后文件已正确收尾",
bool(res.files) and res.duration > 0.3
and all(os.path.exists(f) for f in res.files),
f"{res.duration:.2f}s,{len(res.files)} 个文件")
r.check("自动停止的原因写进了运行日志",
any("自动停止" in n for n in res.notes),
res.notes[-1] if res.notes else "无日志")
# ---------------------------------------------------------------- 入口
def run_all(*, include_hardware: bool = True, quick: bool = False,
quiet: bool = False) -> Runner:
r = Runner(quiet=quiet)
tmp = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"_build", "selftest")
os.makedirs(tmp, exist_ok=True)
print(f"RecorderStudio 自检 Python {sys.version.split()[0]} "
f"numpy {np.__version__} ffmpeg "
f"{'已找到' if post.find_ffmpeg() else '未找到'}")
print(f"临时目录:{tmp}")
t0 = time.time()
test_loudness(r)
test_filters(r)
test_true_peak(r)
test_meters(r)
test_wav(r, tmp)
test_dither(r)
test_bitdepth(r)
test_processing(r, tmp)
test_metadata(r, tmp)
test_engine_offline(r, tmp)
if include_hardware:
test_hardware(r, tmp, seconds=0.8 if quick else 1.5)
print("\n" + "=" * 62)
elapsed = time.time() - t0
if r.ok:
print(f"全部通过:{r.passed} 项检查,用时 {elapsed:.1f} 秒。")
else:
print(f"通过 {r.passed} 项,失败 {len(r.failed)} 项,用时 {elapsed:.1f} 秒:")
for name, detail in r.failed:
print(f" ✗ {name} {detail}")
return r
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="RecorderStudio 自检")
p.add_argument("--no-hw", action="store_true", help="跳过需要真实硬件的测试")
p.add_argument("--quick", action="store_true", help="硬件测试缩短到 1 秒")
p.add_argument("--quiet", action="store_true", help="只输出失败项")
args = p.parse_args(argv)
r = run_all(include_hardware=not args.no_hw, quick=args.quick, quiet=args.quiet)
return 0 if r.ok else 1
if __name__ == "__main__":
sys.exit(main())
+579
View File
@@ -0,0 +1,579 @@
"""无损 WAV 读写核心。
设计要点(面向"高质量、高清晰度"):
* 支持 16 / 24 / 32 位整数 PCM 与 32 位浮点(IEEE float)四种载荷;
* 24 位采用真正的 3 字节打包,不做 16 位截断;
* 多声道、24 位、浮点载荷统一使用 ``WAVE_FORMAT_EXTENSIBLE``(含通道掩码),
避免播放器把 24 位误判为 16 位、把浮点误判为整数;
* 高位深写入时可选 **TPDF(三角概率密度)抖动**,把量化失真从
"与信号相关的非线性失真" 变成 "与信号无关的宽带白噪",这是后期降位深的
标准做法,听感明显更干净;
* 录音过程中定期回写文件头(``checkpoint``),即使程序崩溃/断电,
已落盘的数据也能被播放器正常识别;
* 超过 4 GiB 时可选 RF64(EBU Tech 3306)容器,避免 RIFF 32 位长度溢出。
本模块不依赖 numpy 以外的任何第三方库。
"""
from __future__ import annotations
import os
import struct
from dataclasses import dataclass
from typing import BinaryIO, Iterator
import numpy as np
WAVE_FORMAT_PCM = 0x0001
WAVE_FORMAT_IEEE_FLOAT = 0x0003
WAVE_FORMAT_EXTENSIBLE = 0xFFFE
# KSDATAFORMAT_SUBTYPE_*
_SUBTYPE_PCM = bytes.fromhex("0100000000001000800000aa00389b71")
_SUBTYPE_FLOAT = bytes.fromhex("0300000000001000800000aa00389b71")
# 声道掩码(dwChannelMask),用于 EXTENSIBLE 头部
_CHANNEL_MASKS = {
1: 0x4, # SPEAKER_FRONT_CENTER -> 单声道习惯上用 CENTER,但很多软件写 FRONT_LEFT
2: 0x3, # FRONT_LEFT | FRONT_RIGHT
4: 0x33, # FL FR BL BR
6: 0x3F,
8: 0x63F,
}
# 1 GiB 保留量:RIFF 数据块上限 4 GiB-1,留出余量给头部与安全边界
RIFF_DATA_LIMIT = 0xFFF00000 - 64
__all__ = [
"WavFormat",
"WavWriter",
"WavReader",
"wav_info",
"read_wav",
"write_wav",
"RIFF_DATA_LIMIT",
"SUPPORTED_BIT_DEPTHS",
]
# (名称, 位深, 采样格式) —— 直接对应界面上的"位深"下拉框
SUPPORTED_BIT_DEPTHS = ("16", "24", "32", "float32")
@dataclass(frozen=True)
class WavFormat:
"""描述一个 WAV 载荷格式。"""
samplerate: int
channels: int
encoding: str # 'pcm' | 'float'
bits: int # 每个采样的有效位宽(float 为 32)
@property
def bytes_per_sample(self) -> int:
return self.bits // 8
@property
def frame_bytes(self) -> int:
return self.bytes_per_sample * self.channels
@property
def bytes_per_second(self) -> float:
return float(self.frame_bytes) * self.samplerate
@property
def block_align(self) -> int:
return self.frame_bytes
@property
def label(self) -> str:
if self.encoding == "float":
return "32-bit float"
return f"{self.bits}-bit PCM"
def describe(self) -> str:
return (
f"{self.samplerate} Hz / {self.label} / "
f"{'单声道' if self.channels == 1 else f'{self.channels} 声道'}"
)
def _fmt_for_bitdepth(bit_depth: int | str) -> tuple[str, int]:
"""把界面上的位深选项翻译成 (encoding, bits)。"""
if isinstance(bit_depth, str):
s = bit_depth.strip().lower()
if s in ("float", "float32", "f32", "32f"):
return "float", 32
s = s.rstrip("bit").rstrip("-").strip()
bit_depth = int(s)
bit_depth = int(bit_depth)
if bit_depth == 16:
return "pcm", 16
if bit_depth == 24:
return "pcm", 24
if bit_depth == 32:
return "pcm", 32
if bit_depth == 64:
raise ValueError("不支持 64 位整数 PCM;如需更高精度请使用 32-bit float")
raise ValueError(f"不支持的位深: {bit_depth}")
def _safe_scale(bits: int) -> float:
"""整数满量程对应的浮点值。
采用 ``2**(bits-1) - 1``(对称满量程),这样 +1.0 与 -1.0 都能被精确表示,
不会出现 "正半周先削顶、负半周仍有余量" 的非对称削波。
"""
return float((1 << (bits - 1)) - 1)
class _TpdfDither:
"""TPDF 抖动噪声源。
三角分布 = 两个独立均匀分布之和,峰峰值恰好 1 LSB,
这是业界公认的"无调制噪声"抖动,优于矩形(RPDF)抖动。
"""
def __init__(self, seed: int | None = None):
self._rng = np.random.Generator(np.random.PCG64(seed))
def generate(self, size: int | tuple[int, ...], lsb: float = 1.0) -> np.ndarray:
u1 = self._rng.random(size)
u2 = self._rng.random(size)
return (u1 - u2) * lsb
def reseed(self, seed: int) -> None:
self._rng = np.random.Generator(np.random.PCG64(seed))
def float_to_pcm(data: np.ndarray, encoding: str, bits: int,
dither: _TpdfDither | None = None) -> bytes:
"""把 float32/float64 的 [-1, 1] 数据编码为 WAV 载荷字节。"""
x = np.asarray(data)
if encoding == "float":
return np.ascontiguousarray(x, dtype="<f4").tobytes()
# 抖动与量化都必须在"整数域"内进行:抖动幅度 = 1 LSB(峰峰值)
scale = _safe_scale(bits)
y = x.astype(np.float64, copy=True) * scale
if dither is not None:
y += dither.generate(y.shape, lsb=1.0)
ints = np.rint(y)
lo, hi = -(1 << (bits - 1)), (1 << (bits - 1)) - 1
np.clip(ints, lo, hi, out=ints)
ints = ints.astype(np.int64)
if bits == 16:
return ints.astype("<i2").tobytes()
if bits == 32:
return ints.astype("<i4").tobytes()
if bits == 24:
buf = np.ascontiguousarray(ints, dtype="<i4")
# 小端序下低 3 字节即为 24 位补码,直接切片比逐样本运算快两个数量级
return buf.view(np.uint8).reshape(-1, 4)[:, :3].tobytes()
raise ValueError(f"不支持的位深: {bits}")
def pcm_to_float(raw: bytes, encoding: str, bits: int) -> np.ndarray:
"""WAV 载荷字节 -> float64 数组([-1, 1] 标度)。"""
if encoding == "float":
return np.frombuffer(raw, dtype="<f4").astype(np.float64)
if bits == 16:
return np.frombuffer(raw, dtype="<i2").astype(np.float64) / _safe_scale(16)
if bits == 32:
return np.frombuffer(raw, dtype="<i4").astype(np.float64) / _safe_scale(32)
if bits == 24:
b = np.frombuffer(raw, dtype=np.uint8)
usable = (b.size // 3) * 3
b = b[:usable].reshape(-1, 3).astype(np.int32)
v = b[:, 0] | (b[:, 1] << 8) | (b[:, 2] << 16)
# 手动符号扩展
v = np.where(v & 0x800000, v - 0x1000000, v)
return v.astype(np.float64) / _safe_scale(24)
raise ValueError(f"不支持的位深: {bits}")
class WavWriter:
"""流式(边录边写)WAV 写入器。
典型用法::
w = WavWriter("a.wav", WavFormat(48000, 2, "pcm", 24), dither=True)
w.write(block) # block: float32 (n, channels)
w.checkpoint() # 每秒调用一次,保证崩溃安全
info = w.close() # 回写文件头,返回统计信息
"""
def __init__(self, path: str, fmt: WavFormat, *, dither: bool = True,
rf64: bool = False, seed: int | None = None,
checkpoint_interval: float = 1.0):
self.path = os.fspath(path)
self.fmt = fmt
self.rf64 = bool(rf64)
self._dither = _TpdfDither(seed) if (dither and fmt.encoding == "pcm") else None
self._checkpoint_interval = float(checkpoint_interval)
self._last_checkpoint = 0.0
self.frames_written = 0
self.bytes_written = 0
self.clipped_samples = 0
self.peak = 0.0
self.closed = False
self._extensible = (
fmt.encoding == "float" or fmt.bits > 16 or fmt.channels > 2
)
self._fh: BinaryIO = open(self.path, "wb", buffering=1024 * 1024)
hdr = self._build_header()
self._header = hdr
self._data_size_offset = hdr.data_offset - 4
self._fh.write(hdr.bytes)
self._fh.flush()
# ---------------------------------------------------------------- header
def _build_header(self) -> "_Header":
f = self.fmt
if f.encoding == "float":
fmt_tag = WAVE_FORMAT_IEEE_FLOAT if not self._extensible else WAVE_FORMAT_EXTENSIBLE
subtype = _SUBTYPE_FLOAT
else:
fmt_tag = WAVE_FORMAT_PCM if not self._extensible else WAVE_FORMAT_EXTENSIBLE
subtype = _SUBTYPE_PCM
if self._extensible:
mask = _CHANNEL_MASKS.get(f.channels, 0)
fmt_body = struct.pack(
"<HHIIHHHHI16s",
WAVE_FORMAT_EXTENSIBLE, f.channels, f.samplerate,
int(f.bytes_per_second), f.block_align, f.bits,
22, f.bits, mask, subtype,
)
else:
fmt_body = struct.pack(
"<HHIIHH",
fmt_tag, f.channels, f.samplerate,
int(f.bytes_per_second), f.block_align, f.bits,
)
chunks: list[bytes] = []
# fmt
chunks.append(b"fmt " + struct.pack("<I", len(fmt_body)) + fmt_body)
# fact(非 PCM 载荷必须声明采样帧数)
if f.encoding == "float":
chunks.append(b"fact" + struct.pack("<II", 4, 0))
self._fact_offset = 12 + len(chunks[0]) + 8
else:
self._fact_offset = None
riff_overhead = 12 + sum(len(c) for c in chunks) + 8
if self.rf64:
# RF64: ds64 紧跟 WAVE 之后,RIFF size 写 0xFFFFFFFF
ds64_body = struct.pack("<QQQI", 0, 0, 0, 0)
ds64 = b"ds64" + struct.pack("<I", len(ds64_body)) + ds64_body
body = ds64 + b"".join(chunks)
header = (b"RF64" + struct.pack("<I", 0xFFFFFFFF) + b"WAVE"
+ body + b"data" + struct.pack("<I", 0))
return _Header(header, len(header), riff_overhead, ds64_offset=12)
body = b"".join(chunks)
size = len(body) + 8 + 8 # 占位,收尾时回写
header = (b"RIFF" + struct.pack("<I", size) + b"WAVE" + body
+ b"data" + struct.pack("<I", 0))
return _Header(header, len(header), riff_overhead, ds64_offset=None)
# ----------------------------------------------------------------- write
def write(self, data: np.ndarray) -> int:
"""写入一块浮点音频(形状 (n,) 或 (n, channels)),返回写入的采样帧数。"""
if self.closed:
raise ValueError("写入器已关闭")
x = np.asarray(data, dtype=np.float32)
if x.ndim == 1:
x = x.reshape(-1, 1)
if x.shape[1] != self.fmt.channels:
raise ValueError(
f"通道数不匹配:收到 {x.shape[1]},期望 {self.fmt.channels}")
n = int(x.shape[0])
if n == 0:
return 0
# 统计(在抖动/削波之前,反映真实输入)
blk_peak = float(np.max(np.abs(x))) if n else 0.0
if blk_peak > self.peak:
self.peak = blk_peak
if blk_peak > 1.0:
self.clipped_samples += int(np.count_nonzero(np.abs(x) > 1.0))
payload = float_to_pcm(x, self.fmt.encoding, self.fmt.bits, self._dither)
# 先判断是否越界,再落盘:这样调用方可以无损地切到下一个文件
if not self.rf64 and self.bytes_written + len(payload) > RIFF_DATA_LIMIT:
raise _RiffOverflow(
f"RIFF 容器已达 4 GiB 上限(已写 {self.bytes_written} 字节),"
"请启用自动分段或 RF64 模式")
self._fh.write(payload)
self.frames_written += n
self.bytes_written += len(payload)
return n
def checkpoint(self, now: float | None = None) -> None:
"""定期回写长度字段,保证异常退出后文件仍可播放。"""
if self.closed:
return
now = _monotonic() if now is None else now
if now - self._last_checkpoint < self._checkpoint_interval:
return
self._last_checkpoint = now
self._patch_sizes(final=False)
def _patch_sizes(self, *, final: bool) -> None:
f = self.fmt
data_bytes = self.frames_written * f.frame_bytes
pos = self._fh.tell()
if self.rf64:
body_len = len(self._header.bytes) - 8 + data_bytes
if final:
self._fh.seek(4)
self._fh.write(struct.pack("<I", 0xFFFFFFFF))
self._fh.seek(self._header.ds64_offset + 8) # ds64 body 起点
self._fh.write(struct.pack(
"<QQQI", body_len, data_bytes, self.frames_written, 0))
self._fh.seek(pos)
return
# RIFF
riff_size = (len(self._header.bytes) - 8) + data_bytes
self._fh.seek(4)
self._fh.write(struct.pack("<I", riff_size & 0xFFFFFFFF))
self._fh.seek(self._data_size_offset)
self._fh.write(struct.pack("<I", data_bytes & 0xFFFFFFFF))
if self._fact_offset is not None:
self._fh.seek(self._fact_offset)
self._fh.write(struct.pack("<I", self.frames_written & 0xFFFFFFFF))
self._fh.seek(pos)
# ----------------------------------------------------------------- close
def close(self) -> dict:
if self.closed:
return self.stats()
self._patch_sizes(final=True)
self._fh.flush()
try:
os.fsync(self._fh.fileno())
except OSError:
pass
self._fh.close()
self.closed = True
return self.stats()
def stats(self) -> dict:
f = self.fmt
duration = self.frames_written / f.samplerate if f.samplerate else 0.0
return {
"path": self.path,
"frames": self.frames_written,
"duration": duration,
"bytes": self.bytes_written,
"peak": self.peak,
"peak_dbfs": 20 * np.log10(self.peak) if self.peak > 0 else float("-inf"),
"clipped_samples": self.clipped_samples,
"format": f.describe(),
"dither": self._dither is not None,
"rf64": self.rf64,
}
def abort(self) -> None:
"""放弃写入:关闭句柄但不回写头部(用于创建后立即失败的场景)。"""
if not self.closed:
try:
self._fh.close()
finally:
self.closed = True
def __enter__(self) -> "WavWriter":
return self
def __exit__(self, *exc) -> None:
if exc and exc[0] is not None:
self.abort()
else:
self.close()
@dataclass
class _Header:
bytes: bytes
data_offset: int
riff_overhead: int
ds64_offset: int | None = None
class _RiffOverflow(Exception):
"""RIFF 4 GiB 上限。引擎捕获后自动分段。"""
def _monotonic() -> float:
import time
return time.monotonic()
class WavReader:
"""轻量 WAV 读取器,支持 PCM 16/24/32、float32、EXTENSIBLE、RF64。"""
def __init__(self, path: str):
self.path = os.fspath(path)
self.fh = open(self.path, "rb")
self._parse()
def _parse(self) -> None:
fh = self.fh
hdr = fh.read(12)
if len(hdr) < 12:
raise ValueError("不是有效的 WAV 文件(文件过短)")
riff, _size, wave = hdr[:4], hdr[4:8], hdr[8:12]
if riff == b"RF64":
self.rf64 = True
elif riff == b"RIFF":
self.rf64 = False
else:
raise ValueError("不是有效的 WAV 文件(缺少 RIFF/RF64 标记)")
if wave != b"WAVE":
raise ValueError("不是有效的 WAV 文件(缺少 WAVE 标记)")
self.fmt_tag = None
self.channels = self.samplerate = self.bits = 0
self.encoding = "pcm"
self.data_offset = self.data_size = 0
self.frames = 0
self._ds64_data_size = None
self._ds64_frames = None
self._fact_frames = None
while True:
cid = fh.read(4)
if len(cid) < 4:
break
(csize,) = struct.unpack("<I", fh.read(4))
body_start = fh.tell()
if cid == b"fmt ":
body = fh.read(csize)
(tag, ch, sr, _bps, _align, bits) = struct.unpack("<HHIIHH", body[:16])
if tag == WAVE_FORMAT_EXTENSIBLE and csize >= 40:
(bits,) = struct.unpack("<H", body[18:20])
if body[24:40] == _SUBTYPE_FLOAT:
tag = WAVE_FORMAT_IEEE_FLOAT
elif body[24:40] == _SUBTYPE_PCM:
tag = WAVE_FORMAT_PCM
self.fmt_tag, self.channels, self.samplerate = tag, ch, sr
self.bits = bits
self.encoding = "float" if tag == WAVE_FORMAT_IEEE_FLOAT else "pcm"
elif cid == b"data":
self.data_offset = body_start
self.data_size = csize
elif cid == b"fact" and csize >= 4:
(self._fact_frames,) = struct.unpack("<I", fh.read(4))
elif cid == b"ds64":
body = fh.read(csize)
_riff64, data64, frames64, _table = struct.unpack("<QQQI", body[:28])
self._ds64_data_size = data64
self._ds64_frames = frames64
fh.seek(body_start + csize + (csize & 1))
if self.fmt_tag is None:
raise ValueError("WAV 文件缺少 fmt 块")
if self.encoding == "pcm" and self.bits not in (16, 24, 32):
raise ValueError(f"暂不支持的 PCM 位深: {self.bits}")
if self.rf64 and self._ds64_data_size is not None:
self.data_size = int(self._ds64_data_size)
if self.channels <= 0 or self.samplerate <= 0:
raise ValueError("WAV 头部信息不完整")
self.frames = self.data_size // (self.channels * (self.bits // 8))
@property
def fmt(self) -> WavFormat:
return WavFormat(self.samplerate, self.channels, self.encoding, self.bits)
@property
def duration(self) -> float:
return self.frames / self.samplerate if self.samplerate else 0.0
def read(self, start_frame: int = 0, num_frames: int | None = None) -> np.ndarray:
"""读取为 float64 数组,形状 (frames, channels),范围 [-1, 1]。"""
bps = self.bits // 8
if num_frames is None:
num_frames = max(0, self.frames - start_frame)
num_frames = min(num_frames, max(0, self.frames - start_frame))
if num_frames <= 0:
return np.zeros((0, self.channels), dtype=np.float64)
self.fh.seek(self.data_offset + start_frame * self.channels * bps)
raw = self.fh.read(num_frames * self.channels * bps)
flat = pcm_to_float(raw, self.encoding, self.bits)
usable = (flat.size // self.channels) * self.channels
return flat[:usable].reshape(-1, self.channels)
def iter_blocks(self, block_frames: int = 1 << 16) -> Iterator[np.ndarray]:
"""分块迭代读取,便于处理超长录音而不占内存。"""
pos = 0
while pos < self.frames:
block = self.read(pos, block_frames)
if block.size == 0:
break
yield block
pos += block.shape[0]
def close(self) -> None:
if not self.fh.closed:
self.fh.close()
def __enter__(self) -> "WavReader":
return self
def __exit__(self, *exc) -> None:
self.close()
def info(self) -> dict:
return {
"path": self.path,
"samplerate": self.samplerate,
"channels": self.channels,
"encoding": self.encoding,
"bits": self.bits,
"frames": self.frames,
"duration": self.duration,
"data_bytes": self.data_size,
"rf64": self.rf64,
"format": self.fmt.describe(),
}
def wav_info(path: str) -> dict:
with WavReader(path) as r:
return r.info()
def read_wav(path: str, *, max_seconds: float | None = None) -> tuple[np.ndarray, int]:
"""读取整个 WAV,返回 ``(data, samplerate)``;``max_seconds`` 可只读前段。"""
with WavReader(path) as r:
n = None if max_seconds is None else int(max_seconds * r.samplerate)
return r.read(0, n), r.samplerate
def write_wav(path: str, data: np.ndarray, samplerate: int, *,
bit_depth: int | str = 24, dither: bool = True,
rf64: bool = False) -> dict:
"""一次性写出 WAV(离线后期处理用)。"""
x = np.asarray(data, dtype=np.float32)
if x.ndim == 1:
x = x[:, None]
encoding, bits = _fmt_for_bitdepth(bit_depth)
fmt = WavFormat(int(samplerate), int(x.shape[1]), encoding, bits)
w = WavWriter(path, fmt, dither=dither, rf64=rf64)
try:
# 分块写,避免超大数组一次性编码造成内存峰值
step = 1 << 18
for i in range(0, x.shape[0], step):
w.write(x[i:i + step])
finally:
stats = w.close()
stats["format"] = fmt.describe()
return stats
+564
View File
@@ -0,0 +1,564 @@
"""自绘界面控件:专业电平表、实时示波器、响度条、录音按钮、状态标签。
全部用 QPainter 手绘,好处是刷新率与刻度都能精确控制,
并且不受样式表在自定义绘制上的限制。
"""
from __future__ import annotations
import math
import numpy as np
from PyQt5.QtCore import QPointF, QRect, QRectF, Qt, pyqtSignal
from PyQt5.QtGui import (QBrush, QColor, QFontMetrics, QLinearGradient,
QPainter, QPen, QPolygonF)
from PyQt5.QtWidgets import QSizePolicy, QWidget
from . import dsp
# ------------------------------------------------------------- 设计变量
BG = "#0F1218"
PANEL = "#161A22"
PANEL_2 = "#1D222C"
BORDER = "#2A313D"
TEXT = "#E8ECF4"
TEXT_DIM = "#8B94A7"
ACCENT = "#4EA1FF"
REC = "#FF4757"
OK = "#37D67A"
WARN = "#FFB020"
CRIT = "#FF5C5C"
DB_MIN = -60.0
DB_MAX = 6.0
def db_x(db: float, width: float, db_min: float = DB_MIN,
db_max: float = 0.0) -> float:
"""dB -> 像素(线性刻度,0 dBFS 贴右端)。"""
v = (float(db) - db_min) / (db_max - db_min)
return max(0.0, min(1.0, v)) * width
class LevelMeter(QWidget):
"""多通道电平表:RMS 条 + 峰值保持 + 削波锁存 + dB 刻度。"""
SCALE = [0, -3, -6, -12, -18, -24, -36, -48, -60]
def __init__(self, parent=None, *, channels: int = 2, compact: bool = False):
super().__init__(parent)
self.channels = channels
self.compact = compact
self._rms: list[float] = [DB_MIN] * channels
self._peak: list[float] = [DB_MIN] * channels
self._hold: list[float] = [DB_MIN] * channels
self._tp: list[float] = [DB_MIN] * channels
self._clip: list[bool] = [False] * channels
self._interfaces: float = float("-inf")
self._labels = ["L", "R", "3", "4", "5", "6", "7", "8"]
self.setMinimumHeight(self._preferred_height())
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
def _preferred_height(self) -> int:
per = 20 if self.compact else 30
return per * max(1, self.channels) + 26
def set_channels(self, channels: int) -> None:
self.channels = max(1, int(channels))
self._rms = [DB_MIN] * self.channels
self._peak = [DB_MIN] * self.channels
self._hold = [DB_MIN] * self.channels
self._tp = [DB_MIN] * self.channels
self._clip = [False] * self.channels
self.setMinimumHeight(self._preferred_height())
self.update()
def update_levels(self, snap: dsp.MeterSnapshot) -> None:
n = self.channels
self._rms = (list(snap.rms_db) + [DB_MIN] * n)[:n]
self._peak = (list(snap.peak_db) + [DB_MIN] * n)[:n]
self._hold = (list(snap.hold_db) + [DB_MIN] * n)[:n]
self._tp = ([v if v > -200 else DB_MIN for v in snap.true_peak_db]
+ [DB_MIN] * n)[:n]
self._clip = (list(snap.clipped) + [False] * n)[:n]
self._interfaces = snap.integrated_lufs
self.update()
def reset(self) -> None:
self._rms = [DB_MIN] * self.channels
self._peak = [DB_MIN] * self.channels
self._hold = [DB_MIN] * self.channels
self._tp = [DB_MIN] * self.channels
self._clip = [False] * self.channels
self.update()
# ------------------------------------------------------------ painting
def paintEvent(self, _event) -> None: # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing, True)
w, h = self.width(), self.height()
fm = QFontMetrics(self.font())
scale_w = 34
bar_right = w - scale_w - 6
label_w = 26 if self.channels > 2 else 22
bar_left = label_w + 4
bar_w = max(10.0, bar_right - bar_left)
top = 4
footer = 20
per = (h - top - footer) / self.channels
bar_h = max(8.0, per - (6 if not self.compact else 3))
# 底部统一说明
p.setPen(QColor(TEXT_DIM))
p.setFont(self.font())
tp_max = max(self._tp) if self._tp else DB_MIN
intf = ("—" if not math.isfinite(self._interfaces)
else f"{self._interfaces:+.1f}")
p.drawText(QRect(2, h - footer + 1, w - 4, footer - 2),
Qt.AlignLeft | Qt.AlignVCenter,
f"真峰值 {tp_max:+.1f} dBTP 整体响度 {intf} LUFS")
for ch in range(self.channels):
y = top + ch * per
bar = QRectF(bar_left, y, bar_w, bar_h)
# 轨道
p.setPen(Qt.NoPen)
p.setBrush(QColor(PANEL_2))
p.drawRoundedRect(bar, 3, 3)
rms_x = db_x(self._rms[ch], bar_w)
peak_x = db_x(self._peak[ch], bar_w)
hold_x = db_x(self._hold[ch], bar_w)
# 峰值填充(暗一些)+ RMS 填充(亮)
if peak_x > 0.5:
p.setBrush(QColor(ACCENT).darker(190))
p.drawRoundedRect(QRectF(bar_left, y, peak_x, bar_h), 2, 2)
if rms_x > 0.5:
grad = QLinearGradient(bar_left, 0, bar_left + bar_w, 0)
grad.setColorAt(0.0, QColor("#1FA97B"))
grad.setColorAt(max(0.001, db_x(-18, 1.0) - 0.001), QColor("#37D67A"))
grad.setColorAt(db_x(-6, 1.0), QColor("#FFD24A"))
grad.setColorAt(db_x(-1, 1.0), QColor("#FF6B5A"))
grad.setColorAt(1.0, QColor("#FF4757"))
p.setBrush(QBrush(grad))
p.drawRoundedRect(QRectF(bar_left, y, rms_x, bar_h), 2, 2)
# 刻度线
p.setPen(QPen(QColor(255, 255, 255, 26), 1))
for mark in self.SCALE:
mx = bar_left + db_x(mark, bar_w)
p.drawLine(QPointF(mx, y), QPointF(mx, y + bar_h))
# 峰值保持
if hold_x > 1:
p.setPen(QPen(QColor("#F2F5FA"), 2))
p.drawLine(QPointF(bar_left + hold_x, y - 1),
QPointF(bar_left + hold_x, y + bar_h + 1))
# 削波锁存
if self._clip[ch]:
p.setPen(Qt.NoPen)
p.setBrush(QColor(REC))
p.drawRoundedRect(QRectF(bar_left + bar_w - 6, y, 6, bar_h), 2, 2)
# 通道标记
p.setPen(QColor(TEXT if self._peak[ch] > DB_MIN + 1 else TEXT_DIM))
p.drawText(QRect(2, int(y), label_w, int(bar_h)),
Qt.AlignLeft | Qt.AlignVCenter,
self._labels[ch] if ch < len(self._labels) else str(ch + 1))
# 刻度文字(仅每档首行画一次)
p.setPen(QColor(TEXT_DIM))
p.setFont(self.font())
if ch == 0:
for mark in (0, -6, -18, -36, -60):
mx = bar_left + db_x(mark, bar_w)
txt = "0" if mark == 0 else str(mark)
tw = fm.horizontalAdvance(txt)
p.drawText(QRectF(mx - tw / 2, top - 2, tw + 2, 12),
Qt.AlignCenter, txt)
# 当前峰值数字
val = self._peak[ch]
p.setPen(QColor(CRIT if self._clip[ch]
else (WARN if val > -2 else TEXT)))
p.drawText(QRect(int(bar_right) + 4, int(y), scale_w,
int(bar_h)),
Qt.AlignRight | Qt.AlignVCenter,
"−∞" if val <= DB_MIN + 0.5 else f"{val:.1f}")
p.end()
class Scope(QWidget):
"""实时波形示波器:绘制最近一段的 min/max 包络。"""
def __init__(self, parent=None, *, seconds: float = 1.0):
super().__init__(parent)
self.seconds = seconds
self.samplerate = 48000
self._data = np.zeros((0, 2), dtype=np.float32)
self._recording = False
self.setMinimumHeight(150)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
def set_format(self, samplerate: int, channels: int) -> None:
self.samplerate = int(samplerate)
if self._data.shape[1] != channels:
self._data = np.zeros((0, channels), dtype=np.float32)
self.update()
def update_data(self, data: np.ndarray, recording: bool) -> None:
self._data = data
self._recording = recording
self.update()
def paintEvent(self, _event) -> None: # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing, False)
w, h = self.width(), self.height()
p.fillRect(0, 0, w, h, QColor(PANEL))
p.setPen(QPen(QColor(BORDER), 1))
p.drawRect(0, 0, w - 1, h - 1)
ch = max(1, self._data.shape[1] if self._data.size else 2)
lanes = min(2, ch)
lane_h = h / lanes
cols = max(64, min(2048, w))
# 网格
p.setPen(QPen(QColor(255, 255, 255, 18), 1))
step = max(1, int(self.seconds * 10)) # 100 ms 一条
px_per_div = w / max(1.0, self.seconds * 10)
for i in range(step + 1):
x = w - i * px_per_div
if x < 0:
break
p.drawLine(QPointF(x, 0), QPointF(x, h))
for lane in range(lanes):
y0 = lane * lane_h
yc = y0 + lane_h / 2
p.setPen(QPen(QColor(255, 255, 255, 40), 1))
p.drawLine(QPointF(0, yc), QPointF(w, yc))
# ±0.5 参考线
p.setPen(QPen(QColor(255, 255, 255, 16), 1))
for amp in (0.5, -0.5):
y = yc - amp * (lane_h / 2 - 3)
p.drawLine(QPointF(0, y), QPointF(w, y))
if self._data.size == 0:
p.setPen(QColor(TEXT_DIM))
p.drawText(self.rect(), Qt.AlignCenter,
"等待录音…" if not self._recording else "正在采集…")
p.end()
return
n = self._data.shape[0]
src_ch = [0] if ch == 1 else [0, 1]
per = max(1, n // cols)
usable = (n // per) * per
for lane in range(lanes):
c = src_ch[min(lane, len(src_ch) - 1)]
col = self._data[:usable, c] if usable else self._data[:, c]
if usable:
blk = col.reshape(-1, per)
mn = blk.min(axis=1)
mx = blk.max(axis=1)
else:
mn = mx = col
y0 = lane * lane_h
yc = y0 + lane_h / 2
scale = (lane_h / 2 - 3)
grid = np.arange(mn.size) * (w / max(1, mn.size))
p.setPen(QPen(QColor(ACCENT) if lane == 0 else QColor("#5AD1A8"), 1))
for i in range(mn.size):
x = float(grid[i])
top = yc - float(mx[i]) * scale
bot = yc - float(mn[i]) * scale
if bot - top < 1.0:
top, bot = top - 0.5, bot + 0.5
p.drawLine(QPointF(x, top), QPointF(x, bot))
p.setPen(QColor(TEXT_DIM))
p.drawText(QRect(6, 2, w - 12, 14), Qt.AlignLeft,
f"{self.seconds:.1f} 秒窗口 · {self.samplerate} Hz")
p.end()
class LoudnessBar(QWidget):
"""BS.1770 响度条:瞬时 / 短时 / 整体 + 目标线。"""
def __init__(self, parent=None, *, minimum: float = -40.0, maximum: float = 0.0):
super().__init__(parent)
self.minimum = minimum
self.maximum = maximum
self.target = -16.0
self._momentary = float("-inf")
self._short_term = float("-inf")
self._integrated = float("-inf")
self.setMinimumHeight(54)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
def set_target(self, target: float) -> None:
self.target = float(target)
self.update()
def update_levels(self, momentary: float, short_term: float,
integrated: float) -> None:
self._momentary, self._short_term, self._integrated = \
momentary, short_term, integrated
self.update()
def _x(self, lufs: float, w: float) -> float:
if not math.isfinite(lufs):
return 0.0
v = (lufs - self.minimum) / (self.maximum - self.minimum)
return max(0.0, min(1.0, v)) * w
def paintEvent(self, _event) -> None: # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing, True)
w, h = self.width(), self.height()
bar = QRectF(0, 22, w, 14)
p.setPen(Qt.NoPen)
p.setBrush(QColor(PANEL_2))
p.drawRoundedRect(bar, 3, 3)
# 瞬时响度填充
mx = self._x(self._momentary, w)
if mx > 0.5:
grad = QLinearGradient(0, 0, w, 0)
grad.setColorAt(0.0, QColor("#1D5FA8"))
grad.setColorAt(0.65, QColor(ACCENT))
grad.setColorAt(0.9, QColor("#7BD3A0"))
grad.setColorAt(1.0, QColor("#FFD24A"))
p.setBrush(QBrush(grad))
p.drawRoundedRect(QRectF(0, 22, mx, 14), 3, 3)
# 短时(亮度更高的一条细线)
sx = self._x(self._short_term, w)
if sx > 0.5:
p.setBrush(QColor(255, 255, 255, 190))
p.drawRect(QRectF(0, 25, sx, 2))
# 整体响度三角标
ix = self._x(self._integrated, w)
if ix > 0.0:
tri = QPolygonF([QPointF(ix, 19), QPointF(ix - 5, 11), QPointF(ix + 5, 11)])
p.setBrush(QColor("#FFD24A"))
p.drawPolygon(tri)
# 目标线
tx = self._x(self.target, w)
p.setPen(QPen(QColor(OK), 1, Qt.DashLine))
p.drawLine(QPointF(tx, 8), QPointF(tx, 44))
# 刻度
p.setPen(QColor(TEXT_DIM))
p.setFont(self.font())
for val in range(int(self.minimum), int(self.maximum) + 1, 5):
x = self._x(val, w)
p.drawLine(QPointF(x, 37), QPointF(x, 41))
tw = QFontMetrics(self.font()).horizontalAdvance(str(val))
p.drawText(QRectF(x - tw / 2, 40, tw + 2, 12), Qt.AlignCenter, str(val))
def shown(v: float) -> str:
return "—" if not math.isfinite(v) else f"{v:.1f}"
p.setPen(QColor(TEXT))
p.drawText(QRect(0, 2, w, 14), Qt.AlignLeft,
f"瞬时 {shown(self._momentary)} LUFS "
f"短时 {shown(self._short_term)} "
f"整体 {shown(self._integrated)}")
p.setPen(QColor(TEXT_DIM))
p.drawText(QRect(0, 2, w, 14), Qt.AlignRight,
f"目标 {self.target:.0f} LUFS")
p.end()
class RecordButton(QWidget):
"""圆形录音按钮(带脉冲光晕)。"""
clicked = pyqtSignal()
def __init__(self, parent=None, *, diameter: int = 74):
super().__init__(parent)
self.d = diameter
self.setFixedSize(diameter, diameter)
self.state = "idle" # idle | recording | paused
self._pulse = 0.0
self.setCursor(Qt.PointingHandCursor)
self.setToolTip("开始 / 停止录音(空格)")
def set_state(self, state: str) -> None:
self.state = state
self.update()
def set_pulse(self, phase: float) -> None:
self._pulse = phase
if self.state != "idle":
self.update()
def mousePressEvent(self, event) -> None: # noqa: N802
if event.button() == Qt.LeftButton:
self.clicked.emit()
def paintEvent(self, _event) -> None: # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing, True)
c = self.d / 2.0
r = c - 5
if self.state == "recording":
halo = 4 + 3 * (0.5 + 0.5 * math.sin(self._pulse * 2 * math.pi))
p.setPen(Qt.NoPen)
p.setBrush(QColor(255, 71, 87, 60))
p.drawEllipse(QPointF(c, c), r + halo, r + halo)
base = {"idle": QColor("#242A36"), "recording": QColor(REC),
"paused": QColor(WARN)}[self.state]
p.setPen(QPen(QColor("#0B0E13"), 2))
p.setBrush(base)
p.drawEllipse(QPointF(c, c), r, r)
inner = {"idle": QColor(REC), "recording": QColor("#FFFFFF"),
"paused": QColor("#1A1E27")}[self.state]
p.setPen(Qt.NoPen)
p.setBrush(inner)
if self.state == "recording":
p.drawRoundedRect(QRectF(c - 11, c - 11, 22, 22), 3, 3)
elif self.state == "paused":
p.drawRoundedRect(QRectF(c - 11, c - 11, 8, 22), 2, 2)
p.drawRoundedRect(QRectF(c + 3, c - 11, 8, 22), 2, 2)
else:
p.drawEllipse(QPointF(c, c), r * 0.52, r * 0.52)
p.end()
class StatusPill(QWidget):
"""小圆角状态标签(正常/警告/错误)。"""
def __init__(self, text: str = "", level: str = "ok", parent=None):
super().__init__(parent)
self.text = text
self.level = level
self.setMinimumHeight(22)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
def set(self, text: str, level: str = "ok") -> None:
if text == self.text and level == self.level:
return
self.text = text
self.level = level
self.update()
def paintEvent(self, _event) -> None: # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing, True)
color = {"ok": OK, "warn": WARN, "err": CRIT, "info": ACCENT,
"dim": TEXT_DIM}.get(self.level, TEXT_DIM)
p.setPen(Qt.NoPen)
p.setBrush(QColor(color))
p.setOpacity(0.16)
p.drawRoundedRect(QRectF(0, 1, self.width(), self.height() - 2), 5, 5)
p.setOpacity(1.0)
p.setPen(QColor(color))
p.drawText(self.rect().adjusted(8, 0, -8, 0),
Qt.AlignLeft | Qt.AlignVCenter, self.text)
p.end()
class HistoryPlot(QWidget):
"""整段录音的峰值包络总览(类似 DAW 的概览条)。"""
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumHeight(72)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self._mins = np.zeros((0, 2), np.float32)
self._maxs = np.zeros((0, 2), np.float32)
self._cursor = 0.0
self._buckets_per_second = 48000 / 256
def update_envelope(self, mins: np.ndarray, maxs: np.ndarray,
bucket: int, samplerate: int, cursor_seconds: float) -> None:
self._mins, self._maxs = mins, maxs
self._buckets_per_second = samplerate / max(1, bucket)
self._cursor = cursor_seconds
self.update()
def paintEvent(self, _event) -> None: # noqa: N802
p = QPainter(self)
w, h = self.width(), self.height()
p.fillRect(0, 0, w, h, QColor(PANEL))
p.setPen(QPen(QColor(BORDER), 1))
p.drawRect(0, 0, w - 1, h - 1)
yc = h / 2
p.setPen(QPen(QColor(255, 255, 255, 36), 1))
p.drawLine(QPointF(0, yc), QPointF(w, yc))
if self._mins.size == 0:
p.setPen(QColor(TEXT_DIM))
p.drawText(self.rect(), Qt.AlignCenter, "录音总览(开始录音后显示)")
p.end()
return
n = self._mins.shape[0]
cols = max(1, min(w, 4000))
per = max(1, n // cols)
usable = (n // per) * per
mn = self._mins[:usable].reshape(-1, per, self._mins.shape[1]).min(axis=1)
mx = self._maxs[:usable].reshape(-1, per, self._maxs.shape[1]).max(axis=1)
scale = (h / 2 - 4)
p.setPen(QPen(QColor(ACCENT), 1))
for i in range(mn.shape[0]):
x = i * (w / max(1, mn.shape[0]))
top = yc - float(mx[i, 0]) * scale
bot = yc - float(mn[i, 0]) * scale
if bot - top < 1:
top, bot = top - 0.5, bot + 0.5
p.drawLine(QPointF(x, top), QPointF(x, bot))
# 光标
total_seconds = n / self._buckets_per_second
if total_seconds > 0:
cx = min(w - 1, self._cursor / total_seconds * w)
p.setPen(QPen(QColor(REC), 1))
p.drawLine(QPointF(cx, 0), QPointF(cx, h))
p.end()
class MarkerRail(QWidget):
"""标记轨道:把标记点画在整段录音的时间轴上。"""
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedHeight(18)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self._marks: list[tuple[float, str]] = []
self._total = 0.0
def set_marks(self, marks: list[tuple[float, str]], total: float) -> None:
self._marks = marks
self._total = max(1e-6, total)
self.update()
def paintEvent(self, _event) -> None: # noqa: N802
p = QPainter(self)
w, h = self.width(), self.height()
p.fillRect(0, 0, w, h, QColor(PANEL))
p.setPen(QPen(QColor(BORDER), 1))
p.drawLine(0, h - 1, w, h - 1)
p.setPen(QPen(QColor("#FFD24A"), 1))
for sec, _label in self._marks:
x = min(w - 1.0, max(0.0, sec / self._total * w))
p.drawLine(QPointF(x, 2), QPointF(x, h - 2))
if not self._marks:
p.setPen(QColor(TEXT_DIM))
p.drawText(self.rect(), Qt.AlignLeft | Qt.AlignVCenter,
" 标记轨道(录音中按 M 打点)")
p.end()
+1471
View File
File diff suppressed because it is too large Load Diff