Files
autoclip/backend/services/whisper_runtime.py
周小舟 6e59f40f01 feat: in-app Whisper (faster-whisper) — opt-in install + model management
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>
2026-05-31 01:06:26 +08:00

189 lines
7.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Whisper 运行时管理(桌面模式,按需安装)
桌面安装包默认不带 Whisper运行时 + 模型有体积,没必要让所有用户都背)。用户在设置页
里可以自己决定是否安装、以及下载哪个模型。后端用 faster-whisperCTranslate2不依赖
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)}