Initial commit: RecorderStudio:PyQt5 高保真录音软件,无损 WAV / WASAPI 独占、BS.1770 响度与 ffmpeg 交叉验证
This commit is contained in:
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+173
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user