mirror of
https://github.com/zhouxiaoka/autoclip.git
synced 2026-09-03 06:24:14 +08:00
Videos without embedded subtitles (e.g. B站 without AI字幕) need Whisper to generate subtitles, but bundling it would bloat every install. Instead let users install it on demand from Settings → 语音识别, and pick which model. Backend uses faster-whisper (CTranslate2, no PyTorch, ~214MB installed, several times faster than openai-whisper, cross-platform) — chosen over mlx-whisper, which hard-depends on torch (~2-3GB). - whisper_runtime.py (new): pip-install faster-whisper into a user-writable dir (<data>/whisper-runtime) using the bundled Python; add to sys.path; status + coarse progress; uninstall. Never writes into the signed .app bundle. - whisper_model_manager.py: tiny→large-v3 from Systran/faster-whisper-*, background download via huggingface_hub, real status; cache under <data>/whisper-models. - speech_recognizer.py: subtitle generation rewritten from the `whisper` CLI to faster-whisper's WhisperModel API → SRT; availability = runtime installed. - speech_recognition.py API: /whisper/install, /whisper/uninstall, /whisper/runtime-status (+ existing /whisper-models*). - SpeechRecognitionConfig.tsx: was a stub; now a full UI (install button + progress + log, model list with download/delete/status) wired to a new speechApi in services/api.ts. - build_macos_arm.sh: allowlist faster_whisper/ctranslate2/huggingface_hub in the dependency guard (they're installed at runtime, imported lazily). Verified end-to-end: install runtime → download tiny model → transcribe a real video into a valid SRT, all through the API/runtime. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
189 lines
7.2 KiB
Python
189 lines
7.2 KiB
Python
"""
|
||
Whisper 运行时管理(桌面模式,按需安装)
|
||
|
||
桌面安装包默认不带 Whisper(运行时 + 模型有体积,没必要让所有用户都背)。用户在设置页
|
||
里可以自己决定是否安装、以及下载哪个模型。后端用 faster-whisper(CTranslate2,不依赖
|
||
PyTorch,运行时 ~200-400MB,比官方 whisper 快数倍,跨平台)。
|
||
|
||
设计要点:
|
||
- 安装到「用户可写目录」`<data_dir>/whisper-runtime`,而不是 .app 包内
|
||
(/Applications 通常只读,且写入会破坏代码签名)。
|
||
- 用「当前正在跑后端的便携 Python」(sys.executable) 的 pip 安装,保证解释器一致。
|
||
- 模型缓存放 `<data_dir>/whisper-models`(通过 HF_HOME 收口)。
|
||
- 所有对 mlx_whisper / huggingface_hub 的 import 都延迟到函数内部,避免构建期
|
||
依赖扫描把它们当成缺失依赖而让打包失败。
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import shutil
|
||
import logging
|
||
import threading
|
||
import subprocess
|
||
from pathlib import Path
|
||
from typing import Dict, Any, Optional
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 要安装的运行时包(faster-whisper 带上 ctranslate2、onnxruntime、av、huggingface_hub 等,
|
||
# 不含 PyTorch)
|
||
WHISPER_PACKAGES = ["faster-whisper"]
|
||
# 运行时核心模块(用于探测是否已装)
|
||
WHISPER_IMPORT_NAME = "faster_whisper"
|
||
|
||
|
||
def _data_dir() -> Path:
|
||
try:
|
||
from backend.core.desktop_config import get_desktop_data_dir
|
||
return Path(get_desktop_data_dir())
|
||
except Exception:
|
||
return Path(os.getenv("AUTOCLIP_DATA_DIR", str(Path.home() / "Library/Application Support/AutoClip")))
|
||
|
||
|
||
def get_install_dir() -> Path:
|
||
d = _data_dir() / "whisper-runtime"
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
return d
|
||
|
||
|
||
def get_models_dir() -> Path:
|
||
d = _data_dir() / "whisper-models"
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
return d
|
||
|
||
|
||
def ensure_on_path() -> None:
|
||
"""把运行时目录加入 sys.path,并把模型缓存目录收口到 HF_HOME。"""
|
||
install_dir = str(get_install_dir())
|
||
if install_dir not in sys.path:
|
||
sys.path.insert(0, install_dir)
|
||
# 模型统一缓存到数据目录,便于管理/卸载
|
||
os.environ.setdefault("HF_HOME", str(get_models_dir()))
|
||
# mlx-whisper 解码音频要用 ffmpeg:把内置 ffmpeg 所在目录并入 PATH
|
||
ffmpeg_path = os.getenv("AUTOCLIP_FFMPEG_PATH")
|
||
if ffmpeg_path:
|
||
ffmpeg_dir = str(Path(ffmpeg_path).parent)
|
||
if ffmpeg_dir not in os.environ.get("PATH", "").split(os.pathsep):
|
||
os.environ["PATH"] = ffmpeg_dir + os.pathsep + os.environ.get("PATH", "")
|
||
|
||
|
||
def is_installed() -> bool:
|
||
"""运行时是否已就绪(mlx_whisper 可被导入)。"""
|
||
ensure_on_path()
|
||
try:
|
||
import importlib.util
|
||
return importlib.util.find_spec(WHISPER_IMPORT_NAME) is not None
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
# ---- 安装状态(供前端轮询)----
|
||
_state_lock = threading.Lock()
|
||
_state: Dict[str, Any] = {
|
||
"status": "unknown", # not_installed | installing | installed | error
|
||
"progress": 0, # 粗粒度百分比
|
||
"message": "",
|
||
"log_tail": "",
|
||
}
|
||
|
||
|
||
def _set_state(**kw) -> None:
|
||
with _state_lock:
|
||
_state.update(kw)
|
||
|
||
|
||
def get_status() -> Dict[str, Any]:
|
||
with _state_lock:
|
||
st = dict(_state)
|
||
# 没在安装时,用实际探测结果覆盖
|
||
if st["status"] not in ("installing",):
|
||
st["status"] = "installed" if is_installed() else "not_installed"
|
||
if st["status"] == "installed":
|
||
st["progress"] = 100
|
||
st["platform_supported"] = True # faster-whisper 跨平台
|
||
st["packages"] = WHISPER_PACKAGES
|
||
return st
|
||
|
||
|
||
def _do_install(index_url: Optional[str]) -> None:
|
||
install_dir = get_install_dir()
|
||
cmd = [
|
||
sys.executable, "-m", "pip", "install",
|
||
"--upgrade",
|
||
"--target", str(install_dir),
|
||
*WHISPER_PACKAGES,
|
||
]
|
||
if index_url:
|
||
cmd += ["--index-url", index_url]
|
||
logger.info(f"开始安装 Whisper 运行时: {' '.join(cmd)}")
|
||
_set_state(status="installing", progress=5, message="正在准备安装…", log_tail="")
|
||
try:
|
||
proc = subprocess.Popen(
|
||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||
text=True, bufsize=1,
|
||
)
|
||
lines: list[str] = []
|
||
for line in iter(proc.stdout.readline, ""):
|
||
line = line.rstrip()
|
||
if not line:
|
||
continue
|
||
lines.append(line)
|
||
lines[:] = lines[-40:]
|
||
# 粗粒度进度:根据 pip 的阶段词推进,纯属观感
|
||
low = line.lower()
|
||
if low.startswith("collecting") or "downloading" in low:
|
||
_bump_progress(min_v=10, max_v=70, message=line)
|
||
elif "installing collected packages" in low or "building" in low:
|
||
_bump_progress(min_v=70, max_v=95, message="正在安装依赖…")
|
||
_set_state(log_tail="\n".join(lines[-12:]))
|
||
proc.wait()
|
||
if proc.returncode == 0 and is_installed():
|
||
_set_state(status="installed", progress=100, message="安装完成")
|
||
logger.info("Whisper 运行时安装完成")
|
||
else:
|
||
_set_state(status="error", message=f"安装失败(pip 退出码 {proc.returncode})")
|
||
logger.error(f"Whisper 运行时安装失败,pip 退出码 {proc.returncode}")
|
||
except Exception as e: # noqa: BLE001
|
||
logger.error(f"安装 Whisper 运行时异常: {e}", exc_info=True)
|
||
_set_state(status="error", message=f"安装异常: {e}")
|
||
|
||
|
||
def _bump_progress(min_v: int, max_v: int, message: str) -> None:
|
||
with _state_lock:
|
||
cur = _state.get("progress", 0)
|
||
_state["progress"] = max(min_v, min(max_v, cur + 2))
|
||
_state["message"] = message
|
||
|
||
|
||
def start_install(index_url: Optional[str] = None) -> Dict[str, Any]:
|
||
with _state_lock:
|
||
if _state["status"] == "installing":
|
||
return {"started": False, "message": "正在安装中"}
|
||
if is_installed():
|
||
_set_state(status="installed", progress=100, message="已安装")
|
||
return {"started": False, "message": "已安装"}
|
||
# 默认走环境变量里的 pip 源(构建脚本/桌面默认清华),否则 PyPI
|
||
idx = index_url or os.getenv("PIP_INDEX_URL")
|
||
threading.Thread(target=_do_install, args=(idx,), name="whisper-install", daemon=True).start()
|
||
return {"started": True, "message": "已开始安装"}
|
||
|
||
|
||
def uninstall() -> Dict[str, Any]:
|
||
with _state_lock:
|
||
if _state["status"] == "installing":
|
||
return {"success": False, "message": "正在安装中,无法卸载"}
|
||
install_dir = get_install_dir()
|
||
try:
|
||
shutil.rmtree(install_dir, ignore_errors=True)
|
||
# 从 sys.modules 里剔除,避免本进程仍能 import
|
||
for mod in [m for m in list(sys.modules) if m.startswith("faster_whisper") or m.startswith("ctranslate2")]:
|
||
sys.modules.pop(mod, None)
|
||
p = str(install_dir)
|
||
if p in sys.path:
|
||
sys.path.remove(p)
|
||
_set_state(status="not_installed", progress=0, message="已卸载")
|
||
return {"success": True, "message": "已卸载 Whisper 运行时"}
|
||
except Exception as e: # noqa: BLE001
|
||
logger.error(f"卸载 Whisper 运行时失败: {e}")
|
||
return {"success": False, "message": str(e)}
|