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