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

This commit is contained in:
WpyQwq
2026-09-19 11:56:01 +08:00
commit b7f9098ccd
62 changed files with 10796 additions and 0 deletions
+263
View File
@@ -0,0 +1,263 @@
"""命令行录音:适合自动化、定时任务、无界面服务器。
示例::
python -m recorder.cli --list-devices
python -m recorder.cli -d 3 -t 60 -o D:\\rec --name 会议
python -m recorder.cli --probe 3
python -m recorder.cli --analyze D:\\rec\\会议.wav
python -m recorder.cli --process D:\\rec\\会议.wav --normalize lufs --target -16
"""
from __future__ import annotations
import argparse
import os
import signal
import sys
import time
from . import __version__, engine, post
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="recorder",
description="RecorderStudio · 高清晰度无损录音机(命令行模式)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="提示:加 --gui 可启动图形界面(等价于 python -m recorder.gui)。")
p.add_argument("--version", action="version", version=f"RecorderStudio {__version__}")
g = p.add_argument_group("设备")
g.add_argument("--list-devices", action="store_true", help="列出所有输入设备并退出")
g.add_argument("--probe", type=int, metavar="INDEX",
help="检测指定设备支持哪些采样率")
g.add_argument("-d", "--device", type=int, help="输入设备索引(默认使用系统默认设备)")
g.add_argument("--no-exclusive", action="store_true",
help="关闭 WASAPI 独占模式(共享模式,兼容性更好)")
g.add_argument("--blocksize", type=int, default=0, help="缓冲区采样数,0 = 自动")
g = p.add_argument_group("格式")
g.add_argument("-r", "--rate", type=int, default=48000, help="采样率(默认 48000)")
g.add_argument("-c", "--channels", type=int, default=2, help="声道数(默认 2)")
g.add_argument("-b", "--bit-depth", default="24",
choices=["16", "24", "32", "float32"],
help="位深(默认 24)")
g.add_argument("--no-dither", action="store_true", help="关闭 TPDF 抖动")
g.add_argument("--rf64", action="store_true", help="使用 RF64 容器(>4 GB 单文件)")
g.add_argument("--gain", type=float, default=0.0, help="软件增益 dB(默认 0)")
g.add_argument("--lowcut", type=float, default=0.0,
help="线性相位低切频率 Hz(0 = 关闭)")
g = p.add_argument_group("录制")
g.add_argument("-t", "--seconds", type=float, help="录制时长(秒);不填则按回车停止")
g.add_argument("-o", "--outdir", default="", help="输出目录(默认 ./recordings)")
g.add_argument("--name", default="{datetime}_{device}", help="文件命名模板")
g.add_argument("--split-seconds", type=float, default=0.0, help="按时长自动分段(秒)")
g.add_argument("--split-mb", type=float, default=0.0, help="按体积自动分段(MB)")
g.add_argument("--stop-after-silence", type=float, default=0.0,
help="静音多少秒后自动停止(0 = 不自动停止)")
g.add_argument("--silence-threshold", type=float, default=-50.0,
help="静音判定阈值 dBFS")
g = p.add_argument_group("后处理")
g.add_argument("--analyze", metavar="WAV", help="分析已有录音并退出")
g.add_argument("--process", metavar="WAV", help="对已有录音做后期处理")
g.add_argument("--normalize", choices=["none", "peak", "lufs"], default="none",
help="归一化方式(默认 none)")
g.add_argument("--target", type=float, default=-16.0, help="响度归一化目标 LUFS")
g.add_argument("--target-peak", type=float, default=-1.0,
help="峰值归一化目标 dBFS")
g.add_argument("--trim", action="store_true", help="裁剪首尾静音")
g.add_argument("--mono", action="store_true", help="混合为单声道")
g.add_argument("--export", default="", help="导出格式预设(flac/mp3_320/opus/m4a/wav_16…)")
g.add_argument("--report", action="store_true", help="额外生成报告与波形预览图")
g.add_argument("--gui", action="store_true", help="启动图形界面")
return p
def cmd_list_devices() -> int:
print(engine.device_summary())
return 0
def cmd_probe(index: int, channels: int) -> int:
info = engine.find_device(index) if hasattr(engine, "find_device") else None
if info is None:
devs = [d for d in engine.list_input_devices() if d.index == index]
info = devs[0] if devs else None
if info is None:
print(f"找不到设备 {index}", file=sys.stderr)
return 1
print(f"设备 [{info.index}] {info.name} 宿主 API:{info.hostapi}")
res = engine.probe_capabilities(index, channels=min(channels,
info.max_input_channels))
print(f"检测通道数:{res['channels']}\n")
for rate, entry in res["rates"].items():
if entry["supported"]:
mode = "独占" if entry.get("exclusive") else "共享"
print(f" ✓ {rate:>6} Hz {mode} {', '.join(entry['dtypes'])}")
else:
print(f" ✗ {rate:>6} Hz 不支持({entry.get('error', '')[:60]})")
return 0
def cmd_record(args: argparse.Namespace) -> int:
cfg = engine.RecordConfig(
device=args.device,
samplerate=args.rate,
channels=args.channels,
bit_depth=args.bit_depth,
gain_db=args.gain,
exclusive=not args.no_exclusive,
blocksize=args.blocksize,
lowcut_hz=args.lowcut,
dither=not args.no_dither,
rf64=args.rf64,
output_dir=args.outdir or os.path.join(os.getcwd(), "recordings"),
name_template=args.name,
split_seconds=args.split_seconds,
split_megabytes=args.split_mb,
silence_threshold_dbfs=args.silence_threshold,
auto_stop_silence_seconds=args.stop_after_silence,
)
rec = engine.Recorder(cfg)
try:
rec.start()
except Exception as exc:
print(f"启动失败:{exc}", file=sys.stderr)
return 2
print(f"正在录音 → {cfg.output_dir}")
print(f"格式:{cfg.wav_format().describe()} "
f"{'独占模式' if cfg.exclusive else '共享模式'}"
f"{' 低切 %.0f Hz' % cfg.lowcut_hz if cfg.lowcut_hz else ''}")
for n in rec.notes():
print(" · " + n)
stop_by_signal = {"flag": False}
def _handler(signum, frame): # noqa: ANN001
stop_by_signal["flag"] = True
for sig in (signal.SIGINT, signal.SIGTERM):
try:
signal.signal(sig, _handler)
except Exception:
pass
try:
if args.seconds:
deadline = time.time() + args.seconds
while time.time() < deadline and not stop_by_signal["flag"]:
time.sleep(0.2)
snap = rec.live()
sys.stdout.write(
f"\r 已录 {engine.format_duration(snap.elapsed)} "
f"{engine.format_bytes(snap.bytes_written)} "
f"峰值 {snap.peak_dbfs:+.1f} dBFS "
f"响度 {snap.meter.momentary_lufs:+.1f} LUFS "
f"xrun {snap.xruns} 溢出 {snap.overflow_blocks} ")
sys.stdout.flush()
if snap.state == engine.RecorderState.IDLE.value:
break # 静音自动停止
else:
print("按回车停止录音…")
input()
except KeyboardInterrupt:
pass
print()
result = rec.stop()
print(post.render_report(result))
if args.report and result.primary_file:
files = post.write_metadata(result)
png = post.make_preview_for(result)
if png:
files.append(png)
print("\n已生成:" + "、".join(os.path.basename(f) for f in files))
if args.export and result.primary_file:
meta = post.EXPORT_PRESETS.get(args.export)
if meta is None:
print(f"未知导出格式:{args.export};可选:{', '.join(post.EXPORT_PRESETS)}",
file=sys.stderr)
return 1
dst = post.default_export_path(result.primary_file, args.export)
ok, msg = post.export_audio(result.primary_file, dst, args.export)
print(("导出成功:" if ok else "导出失败:") + msg)
return 0 if ok else 1
return 0
def cmd_analyze(path: str) -> int:
if not os.path.exists(path):
print(f"文件不存在:{path}", file=sys.stderr)
return 1
print(post.regenerate_report(path))
return 0
def cmd_process(args: argparse.Namespace) -> int:
if not os.path.exists(args.process):
print(f"文件不存在:{args.process}", file=sys.stderr)
return 1
opts = post.ProcessOptions(
trim_silence=args.trim,
remove_dc=True,
lowcut_hz=args.lowcut,
normalize=args.normalize,
normalize_target_lufs=args.target,
normalize_target_dbfs=args.target_peak,
mono=args.mono,
bit_depth=args.bit_depth,
dither=not args.no_dither,
)
report = post.process_file(args.process, opts,
progress=lambda m: print(" " + m))
print("\n处理完成:" + report["output"])
for s in report.get("steps", []):
print(" · " + s)
an = report.get("analysis") or {}
if an:
print(f" 输出体检:峰值 {an.get('peak_dbfs')} dBFS "
f"真峰值 {an.get('true_peak_dbtp')} dBTP "
f"响度 {an.get('integrated_lufs')} LUFS")
if args.report:
png = post.write_waveform_png(
os.path.splitext(report["output"])[0] + "_waveform.png",
*post._read_whole(report["output"]))
print(" 波形预览:" + png)
if args.export:
meta = post.EXPORT_PRESETS.get(args.export)
if meta is None:
print(f"未知导出格式:{args.export}", file=sys.stderr)
return 1
dst = post.default_export_path(report["output"], args.export)
ok, msg = post.export_audio(report["output"], dst, args.export)
print((" 导出成功:" if ok else " 导出失败:") + msg)
return 0 if ok else 1
return 0
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.gui:
from .gui import run
return run([sys.argv[0]])
if args.list_devices:
return cmd_list_devices()
if args.probe is not None:
return cmd_probe(args.probe, args.channels)
if args.analyze:
return cmd_analyze(args.analyze)
if args.process:
return cmd_process(args)
if engine.sd is None:
print(f"音频后端不可用:{engine.SD_IMPORT_ERROR}", file=sys.stderr)
return 2
return cmd_record(args)
if __name__ == "__main__":
sys.exit(main())