import os import hashlib import subprocess import tempfile import threading from collections import OrderedDict from typing import Optional from pydub import AudioSegment from config.config_utils import get_ffmpeg, get_ffprobe # 让 pydub 也用项目自带的 ffmpeg/ffprobe(显式指定,不依赖 PATH) AudioSegment.converter = get_ffmpeg() AudioSegment.ffmpeg = get_ffmpeg() AudioSegment.ffprobe = get_ffprobe() _MAX_CACHE_SIZE = 512 class WhisperManager: _instance = None _lock = threading.Lock() def __new__(cls): if cls._instance is None: with cls._lock: if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._initialized = False return cls._instance def __init__(self): if not self._initialized: self.model = None self.model_name = None self._load_lock = threading.Lock() # 仅保护模型加载,推理不加锁 self._cache = OrderedDict() self._cache_lock = threading.Lock() self._initialized = True @staticmethod def _cuda_available() -> bool: """nvidia-smi 可用即认为有可用 GPU。""" try: r = subprocess.run(["nvidia-smi"], capture_output=True, timeout=10) return r.returncode == 0 except (FileNotFoundError, OSError, subprocess.TimeoutExpired): return False @staticmethod def _gpu_count() -> int: """数 GPU 张数(nvidia-smi -L 行数)。""" try: r = subprocess.run(["nvidia-smi", "-L"], capture_output=True, text=True, timeout=10) if r.returncode == 0: return sum(1 for ln in r.stdout.splitlines() if ln.strip().startswith("GPU ")) except (FileNotFoundError, OSError, subprocess.TimeoutExpired): pass return 0 def _resolve_device(self): """决定 whisper 跑在 cpu 还是 gpu。读 config.audio.device:auto/cuda/gpu/cpu。 device_index=auto/last 时用【最后一张卡】,与抽帧(用前 N-1 张)错开。 返回 (device, compute_type, device_index)。""" try: from config.config_utils import config acfg = config.get_audio_config() or {} except Exception: acfg = {} dev = str(acfg.get("device", "auto")).lower() idx_cfg = acfg.get("device_index", "auto") ctype = str(acfg.get("compute_type", "float16")) if dev in ("cuda", "gpu") or (dev == "auto" and self._cuda_available()): if str(idx_cfg).lower() in ("auto", "last"): idx = max(0, self._gpu_count() - 1) # 用最后一张 else: try: idx = int(idx_cfg) except (TypeError, ValueError): idx = 0 return "cuda", ctype, idx return "cpu", "int8", 0 def load_model(self, model_name: str = "base"): """加载 faster-whisper 模型:有 GPU 用 cuda(float16),否则 CPU(int8);GPU 失败自动回退 CPU。""" with self._load_lock: if self.model is None or self.model_name != model_name: from faster_whisper import WhisperModel device, ctype, idx = self._resolve_device() # CPU 推理限制内部线程数:防止进程池下每个 worker 的 whisper 各抓一堆核 → 线程爆炸。 # whisper 只识别首尾各5秒,2 线程足够;可用 config.audio.cpu_threads 调整。 try: from config.config_utils import config as _cfg cpu_threads = int((_cfg.get_audio_config() or {}).get("cpu_threads", 2) or 2) except Exception: cpu_threads = 2 cpu_threads = max(1, cpu_threads) try: print(f"[WhisperManager] 加载 faster-whisper '{model_name}' (device={device}, {ctype})...") if device == "cuda": self.model = WhisperModel(model_name, device="cuda", device_index=idx, compute_type=ctype) else: self.model = WhisperModel(model_name, device="cpu", compute_type="int8", cpu_threads=cpu_threads) except Exception as e: print(f"[WhisperManager] GPU 加载失败({type(e).__name__}: {e}),回退 CPU(int8, cpu_threads={cpu_threads})") self.model = WhisperModel(model_name, device="cpu", compute_type="int8", cpu_threads=cpu_threads) self.model_name = model_name print(f"[WhisperManager] 模型 '{model_name}' 加载完成。") return self.model def extract_audio_from_video( self, video_path: str, output_audio_path: str, logger: Optional[object] = None ) -> Optional[float]: if not os.path.exists(video_path): if logger: logger.log(f"视频文件不存在: {video_path}", level="error") return None cmd = [ get_ffmpeg(), "-loglevel", "error", "-i", video_path, "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "-y", output_audio_path ] try: subprocess.run(cmd, check=True, capture_output=True, text=True) except subprocess.CalledProcessError as e: if logger: logger.log(f"ffmpeg 提取音频失败: {e.stderr}", level="error") return None try: audio = AudioSegment.from_file(output_audio_path) duration = len(audio) / 1000.0 if logger: logger.log(f"音频提取成功,时长: {duration:.2f} 秒") return duration except Exception as e: if logger: logger.log(f"获取音频时长失败: {e}", level="error") return None def extract_audio_text_with_whisper( self, audio_path: str, start_time: float, end_time: float, logger: Optional[object] = None, use_cache: bool = True ) -> str: if self.model is None: raise RuntimeError("Whisper 模型未加载,请先调用 load_model()") if not os.path.exists(audio_path): if logger: logger.log(f"音频文件不存在: {audio_path}", level="error") return "" cache_key = None if use_cache: mtime = os.path.getmtime(audio_path) cache_key = hashlib.md5( f"{audio_path}_{mtime}_{start_time}_{end_time}".encode() ).hexdigest() with self._cache_lock: if cache_key in self._cache: self._cache.move_to_end(cache_key) if logger: logger.log("使用缓存识别结果") return self._cache[cache_key] try: full_audio = AudioSegment.from_file(audio_path) start_ms = int(start_time * 1000) end_ms = int(end_time * 1000) + 500 segment = full_audio[start_ms:end_ms] if len(segment) == 0: if logger: logger.log("音频片段为空,跳过识别") return "" segment = segment.set_frame_rate(16000).set_channels(1).set_sample_width(2) with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: temp_path = tmp.name segment.export(temp_path, format="wav") try: # faster-whisper CPU 模式线程安全,无需加锁,多线程并发推理 segments, _ = self.model.transcribe( temp_path, language="zh", beam_size=5 ) text = "".join(seg.text for seg in segments).strip() finally: try: os.unlink(temp_path) except OSError: pass if use_cache and cache_key: with self._cache_lock: self._cache[cache_key] = text self._cache.move_to_end(cache_key) while len(self._cache) > _MAX_CACHE_SIZE: self._cache.popitem(last=False) if logger: logger.log(f"音频片段识别结果: {text}") return text except Exception as e: if logger: logger.log(f"音频识别失败: {e}", level="error") return "" whisper_manager = WhisperManager()