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
+271
View File
@@ -0,0 +1,271 @@
"""依赖安装器:把 sounddevice(及可选 PyQt5)装进项目内的 _vendor 目录。
为什么不用 ``pip install sounddevice`` 就完事?
--------------------------------------------------
* 项目内的 ``_vendor`` 目录让整个软件"自包含":换台电脑、拷个 U 盘就能跑,
不污染系统 Python 环境,也不需要管理员权限;
* 某些受限环境(企业代理、受管控的临时目录、沙箱)里 pip 会因为无法写入
自己的临时目录而失败,本脚本提供了 **直接下载 wheel 并解压** 的兜底路径,
完全不依赖 pip 的临时目录机制。
用法::
python install_deps.py # 安装必需依赖(sounddevice)
python install_deps.py --with-pyqt5 # 同时安装图形界面依赖 PyQt5(较大)
python install_deps.py --check # 只检查当前依赖状态
"""
from __future__ import annotations
import argparse
import io
import json
import os
import platform
import ssl
import subprocess
import sys
import sysconfig
import zipfile
HERE = os.path.dirname(os.path.abspath(__file__))
VENDOR = os.path.join(HERE, "_vendor")
# 每一组都会被装进 _vendor;顺序即安装顺序
PACKAGE_SETS = {
"sounddevice": ["sounddevice"],
"pyqt5": ["PyQt5", "PyQt5-Qt5", "PyQt5-sip"],
}
# --------------------------------------------------------------- 平台标签
def platform_tag() -> str | None:
"""返回当前平台在 wheel 文件名里的标签(如 win_amd64)。"""
system = platform.system().lower()
machine = platform.machine().lower()
if system == "windows":
if machine in ("amd64", "x86_64"):
return "win_amd64"
if machine in ("arm64", "aarch64"):
return "win_arm64"
return "win32"
if system == "darwin":
return "macosx"
if system == "linux":
return "manylinux"
return None
def python_tags() -> list[str]:
"""当前解释器可接受的 ABI 标签,用于挑选正确的 wheel。"""
ver = f"cp{sys.version_info.major}{sys.version_info.minor}"
return [ver, "abi3", "py3", "py2.py3", ver.replace("cp", "cp3")]
def _ssl_context() -> ssl.SSLContext:
try:
import certifi # type: ignore
return ssl.create_default_context(cafile=certifi.where())
except Exception:
return ssl.create_default_context()
def _http_get_json(url: str, timeout: int = 40) -> dict:
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "RecorderStudio-installer"})
with urllib.request.urlopen(req, context=_ssl_context(), timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def _http_get_bytes(url: str, timeout: int = 120) -> bytes:
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "RecorderStudio-installer"})
with urllib.request.urlopen(req, context=_ssl_context(), timeout=timeout) as resp:
return resp.read()
def pick_wheel(files: list[dict], plat: str | None) -> dict | None:
"""从 PyPI 的文件列表里挑出最适合当前平台的 wheel。"""
wheels = [f for f in files if f["filename"].endswith(".whl")]
if not wheels:
return None
def score(name: str) -> int:
base = name[:-4]
parts = base.split("-")
if len(parts) < 5:
return -1
py_tag, abi_tag, plat_tag = parts[-3], parts[-2], parts[-1]
s = 0
# 平台匹配
tags = plat_tag.split(".")
if plat and any(plat in t for t in tags):
s += 100
elif "any" in plat_tag:
s += 40
else:
return -1
# Python/ABI 匹配
want = python_tags()
if any(t == abi_tag for t in want):
s += 30
elif "abi3" in abi_tag:
s += 20
elif "any" in abi_tag or abi_tag == "none":
s += 5
else:
return -1
if any(py_tag == t or py_tag.startswith("py3") for t in want):
s += 10
# 64 位优先
if "64" in plat_tag or "amd64" in plat_tag or "arm64" in plat_tag:
s += 2
return s
best, best_score = None, 0
for w in wheels:
s = score(w["filename"])
if s > best_score:
best, best_score = w, s
return best
def install_via_wheel_download(pkg: str) -> tuple[bool, str]:
"""直接下载 wheel 并解压到 _vendor(不依赖 pip 的临时目录)。"""
plat = platform_tag()
try:
data = _http_get_json(f"https://pypi.org/pypi/{pkg}/json")
except Exception as exc:
return False, f"无法访问 PyPI:{exc}"
wheel = pick_wheel(data.get("urls") or [], plat)
if wheel is None:
return False, (f"PyPI 上没有适配当前平台({plat} / Python "
f"{sys.version_info.major}.{sys.version_info.minor})的 wheel")
try:
blob = _http_get_bytes(wheel["url"])
except Exception as exc:
return False, f"下载失败:{exc}"
try:
os.makedirs(VENDOR, exist_ok=True)
with zipfile.ZipFile(io.BytesIO(blob)) as z:
z.extractall(VENDOR)
except Exception as exc:
return False, f"解压失败:{exc}"
return True, f"{wheel['filename']}({wheel['size'] / 1024:.0f} KB)"
def install_via_pip(pkg: str) -> tuple[bool, str]:
"""优先尝试 pip(把临时目录也指到项目内,避免受管控目录写入失败)。"""
tmp = os.path.join(HERE, "_build", "tmp")
os.makedirs(tmp, exist_ok=True)
env = dict(os.environ, TEMP=tmp, TMP=tmp, TMPDIR=tmp, PIP_DISABLE_PIP_VERSION_CHECK="1")
cmd = [sys.executable, "-m", "pip", "install", "--no-input", "--no-cache-dir",
"--target", VENDOR, pkg]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=900)
except Exception as exc:
return False, f"pip 调用失败:{exc}"
if proc.returncode == 0:
return True, "pip 安装成功"
tail = (proc.stderr or proc.stdout or "").strip().splitlines()
return False, tail[-1] if tail else "pip 返回非零退出码"
def ensure(pkg: str, *, verbose: bool = True) -> bool:
ok, msg = install_via_pip(pkg)
if ok:
print(f" ✓ {pkg}:{msg}")
return True
if verbose:
print(f" · {pkg}:pip 方式失败({msg}),改用直接下载 wheel…")
ok, msg = install_via_wheel_download(pkg)
if ok:
print(f" ✓ {pkg}:{msg}")
return True
print(f" ✗ {pkg}:安装失败 —— {msg}")
return False
# ------------------------------------------------------------------ 检查
def check(verbose: bool = True) -> dict:
"""检查依赖是否就绪(会临时把 _vendor 加入搜索路径)。"""
if VENDOR not in sys.path:
sys.path.insert(0, VENDOR)
status = {}
for mod, label in (("numpy", "numpy(数值运算,必需)"),
("sounddevice", "sounddevice(音频后端,必需)"),
("PyQt5", "PyQt5(图形界面,必需)")):
try:
m = __import__(mod)
ver = getattr(m, "__version__", "")
if mod == "PyQt5":
from PyQt5.QtCore import QT_VERSION_STR # noqa: F401
ver = QT_VERSION_STR
status[mod] = (True, str(ver))
if verbose:
print(f" ✓ {label}:{ver}")
except Exception as exc:
status[mod] = (False, str(exc))
if verbose:
print(f" ✗ {label}:未安装({exc})")
if status.get("sounddevice", (False,))[0]:
try:
import sounddevice as sd
n = len([d for d in sd.query_devices() if d["max_input_channels"] > 0])
if verbose:
print(f" ✓ PortAudio:{sd.get_portaudio_version()[1]},"
f"检测到 {n} 个输入设备")
status["devices"] = (n > 0, str(n))
except Exception as exc:
status["devices"] = (False, str(exc))
if verbose:
print(f" ✗ 音频设备枚举失败:{exc}")
return status
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="RecorderStudio 依赖安装器")
p.add_argument("--with-pyqt5", action="store_true",
help="同时安装 PyQt5(图形界面所需,约 60 MB)")
p.add_argument("--check", action="store_true", help="只检查依赖状态")
args = p.parse_args(argv)
print(f"Python {sys.version.split()[0]} {sysconfig.get_platform()} "
f"目标目录 {VENDOR}\n")
if args.check:
st = check()
needed = [k for k in ("numpy", "sounddevice", "PyQt5") if not st.get(k, (False,))[0]]
if needed:
print(f"\n缺少:{', '.join(needed)} 请运行 python install_deps.py")
return 1
print("\n依赖齐全,可以启动:python -m recorder.gui")
return 0
print("正在安装依赖:")
ok = True
for pkg in PACKAGE_SETS["sounddevice"]:
ok &= ensure(pkg)
if args.with_pyqt5:
for pkg in PACKAGE_SETS["pyqt5"]:
ok &= ensure(pkg)
print("\n安装后检查:")
st = check()
missing = [k for k in ("numpy", "sounddevice", "PyQt5") if not st.get(k, (False,))[0]]
if not ok or missing:
print("\n以下依赖仍不可用:" + "、".join(missing))
if "PyQt5" in missing and not args.with_pyqt5:
print("提示:图形界面需要 PyQt5,请加参数重新运行:"
"python install_deps.py --with-pyqt5")
print("也可以手动安装:pip install --target _vendor sounddevice PyQt5")
return 1
print("\n全部就绪!启动方式:")
print(" · 图形界面:python -m recorder.gui (或双击 启动录音机.bat)")
print(" · 命令行: python -m recorder.cli --list-devices")
print(" · 自检: python -m recorder.selftest")
return 0
if __name__ == "__main__":
sys.exit(main())