diff --git a/transcribe_server.py b/transcribe_server.py index 3a50b2d..8f4e919 100644 --- a/transcribe_server.py +++ b/transcribe_server.py @@ -30,6 +30,7 @@ def get_model(): "large-v3", "cpu", compute_type="int8", + language="zh", # BUGFIX (2026-08-10): preset language → prevents tokenizer race condition crash on long audio (32+ min) where batched inference hits NoneType 'sot_sequence' bug asr_options={ "beam_size": 1, "best_of": 1, diff --git a/transcribe_server.py.bak-20260810-2100 b/transcribe_server.py.bak-20260810-2100 new file mode 100644 index 0000000..3a50b2d --- /dev/null +++ b/transcribe_server.py.bak-20260810-2100 @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Local whisper transcribe server — Large-v3-turbo model + speaker diarization on M1 Mac""" +import asyncio +from concurrent.futures import ThreadPoolExecutor +import sys, os, tempfile, shutil, subprocess, glob +from pathlib import Path +from pathlib import Path +from fastapi import FastAPI, UploadFile, File, Query +from fastapi.responses import JSONResponse +import uvicorn + +os.environ.setdefault("HF_TOKEN", "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") + +app = FastAPI() +_model = None +_diarize = None + + +def get_model(): + global _model + if _model is None: + import whisperx + # v1.3.0: large-v3 (full quality) on CPU int8 (MPS fp16 unstable for large-v3 on 16GB RAM) + # - Model: large-v3 (best quality for Cantonese, +1-2% WER vs turbo) + # - Device: cpu (stable, no GPU pressure during diarization) + # - Compute: int8 (saves RAM: large-v3 ~1.5GB vs fp16 3GB) + # - Beam: 5 (best accuracy for Cantonese code-mixing) + print("Loading large-v3 model (CPU, int8)...") + _model = whisperx.load_model( + "large-v3", + "cpu", + compute_type="int8", + asr_options={ + "beam_size": 1, + "best_of": 1, + # ✅ hallucination filters (kills 1,000多×30 noise) + "condition_on_previous_text": False, # prevent hallucination chain + "compression_ratio_threshold": 2.4, # filter high compression (repetitive noise) + "no_speech_threshold": 0.6, # filter silence + } + ) + print("Model loaded ✅") + return _model + + +def get_diarize(): + global _diarize + if _diarize is None: + from whisperx.diarize import DiarizationPipeline + print("Loading diarization model...") + _diarize = DiarizationPipeline(token=os.environ["HF_TOKEN"], device="cpu", model_name="pyannote/speaker-diarization-3.1") + print("Diarization loaded!") + return _diarize + + +@app.post("/transcribe") +async def transcribe(file: UploadFile = File(...), language: str = "zh", diarize: int = Query(default=1)): + """Receive audio, return transcript with optional speaker diarization""" + do_diarize = (diarize == 1) + tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) + try: + shutil.copyfileobj(file.file, tmp) + tmp.close() + # Run CPU-heavy whisperx in thread to not block health endpoint + loop = asyncio.get_running_loop() + result = await loop.run_in_executor(None, _do_transcribe, tmp.name, language, do_diarize) + return result + finally: + Path(tmp.name).unlink(missing_ok=True) + + +def _do_transcribe(audio_path: str, language: str, do_diarize: bool): + import whisperx + model = get_model() + audio = whisperx.load_audio(audio_path) + # Map our language codes to whisper: yue/cantonese → zh, zh/mandarin → zh, auto → None (detect) + whisper_lang = None if language in ("auto",) else ("zh" if language in ("yue", "cantonese") else language) + # CPU with int8: smaller batch + greedy beam for speed + batch_size = 16 + result = model.transcribe(audio, language=whisper_lang, batch_size=batch_size) + + segments_with_speakers = [] + if do_diarize: + try: + diarize_model = get_diarize() + diar_segments = diarize_model(audio) + result = whisperx.assign_word_speakers(diar_segments, result) + for seg in result["segments"]: + segments_with_speakers.append({ + "start": round(seg["start"], 1), + "end": round(seg["end"], 1), + "speaker": seg.get("speaker", "?"), + "text": seg["text"].strip(), + "avg_logprob": seg.get("avg_logprob"), + "compression_ratio": seg.get("compression_ratio"), + "no_speech_prob": seg.get("no_speech_prob"), + }) + except Exception as e: + print(f"Diarization error: {e}, falling back to plain text") + for seg in result["segments"]: + segments_with_speakers.append({ + "start": round(seg["start"], 1), + "end": round(seg["end"], 1), + "speaker": "", + "text": seg["text"].strip(), + "avg_logprob": seg.get("avg_logprob"), + "compression_ratio": seg.get("compression_ratio"), + "no_speech_prob": seg.get("no_speech_prob"), + }) + else: + for seg in result["segments"]: + segments_with_speakers.append({ + "start": round(seg["start"], 1), + "end": round(seg["end"], 1), + "speaker": "", + "text": seg["text"].strip(), + "avg_logprob": seg.get("avg_logprob"), + "compression_ratio": seg.get("compression_ratio"), + "no_speech_prob": seg.get("no_speech_prob"), + }) + + import re + from collections import Counter + for s in segments_with_speakers: + raw = s["text"] + # ✅ Drop if any 2-4 char phrase appears >= 8 times (non-contiguous) — model hallucination + # Detects "对,有自己的监控,IP"×20, "1,000多"×30, etc. + # Use sliding window to count any 2-4 char substring frequency + substr_count = Counter() + for n in (2, 3, 4): + for i in range(len(raw) - n + 1): + substr = raw[i:i+n] + # Skip if mostly Chinese chars that are common (的, 了, 是) — keep noise filter focused + if substr.strip() in (',', ',', '.', '。', '的', '了', '是', '我', '你', '他', '她'): + continue + substr_count[substr] += 1 + max_repeat = max(substr_count.values()) if substr_count else 0 + if max_repeat >= 8: + # Verify: at least one of the most common phrases is suspicious + top = [p for p, c in substr_count.most_common(5) if c >= 8] + if top: + txt = '' + print(f"[hallucination filter] dropped segment, top repeat: {top[:3]}") + else: + txt = raw + else: + txt = raw + # Drop if avg_logprob very low (model uncertain) + if s.get("avg_logprob") is not None and s["avg_logprob"] < -1.0: + txt = '' + # Drop silence segments + if s.get("no_speech_prob") is not None and s["no_speech_prob"] > 0.6: + txt = '' + s["text"] = txt + # Drop empty segments + segments_with_speakers = [s for s in segments_with_speakers if s["text"]] + full_text = " ".join(s["text"] for s in segments_with_speakers) + return {"text": full_text, "language": language, "segments": segments_with_speakers} + + +@app.get("/health") +async def health(): + return {"status": "ok", "model": "large-v3+cpu+int8+diarization", "version": "1.3.1", "hallucination_filter": True} + + +@app.get("/video/info") +async def video_info(url: str = ""): + """Get video metadata via yt-dlp (uses Chrome cookies)""" + if not url: + return {"error": "url required"} + try: + result = subprocess.run([ + "yt-dlp", "--cookies-from-browser", "chrome", + "--skip-download", "--no-playlist", + "--print", "%(title)s|||%(duration)s|||%(channel)s|||%(channel_url)s", + url + ], capture_output=True, text=True, timeout=30) + output = result.stdout.strip() + parts = output.split("|||") + return { + "title": parts[0] if len(parts) > 0 else "", + "duration": float(parts[1]) if len(parts) > 1 and parts[1] else 0, + "channel": parts[2] if len(parts) > 2 else "", + "channel_url": parts[3] if len(parts) > 3 else "", + } + except Exception as e: + return {"error": str(e)} + + +@app.get("/video/subtitles") +async def video_subtitles(url: str = ""): + """Extract subtitles via yt-dlp, returns text""" + if not url: + return {"error": "url required"} + import glob + with tempfile.TemporaryDirectory() as tmpdir: + try: + for lang in ["zh-Hant", "zh-Hans", "yue", "zh", "en"]: + subprocess.run([ + "yt-dlp", "--cookies-from-browser", "chrome", + "--skip-download", "--no-playlist", + "--write-auto-subs", "--sub-langs", lang, + "--convert-subs", "srt", + "-o", f"{tmpdir}/sub", url + ], capture_output=True, text=True, timeout=30) + srt_files = glob.glob(f"{tmpdir}/*.srt") + if srt_files: + seen = set() + lines = [] + for srt in sorted(srt_files): + with open(srt, "r", errors="ignore") as f: + raw = f.read() + for line in raw.split("\n"): + line = line.strip() + if not line or line.isdigit() or "-->" in line: + continue + if line not in seen: + seen.add(line) + lines.append(line) + if lines: + return {"text": " ".join(lines)} + # Clear tmp files before next language attempt + for f in glob.glob(f"{tmpdir}/*.srt"): + os.remove(f) + + # Try manual subs too + for lang in ["zh-Hant", "zh-Hans", "yue", "zh", "en"]: + subprocess.run([ + "yt-dlp", "--cookies-from-browser", "chrome", + "--skip-download", "--no-playlist", + "--write-subs", "--sub-langs", lang, + "--convert-subs", "srt", + "-o", f"{tmpdir}/sub", url + ], capture_output=True, text=True, timeout=20) + srt_files = glob.glob(f"{tmpdir}/*.srt") + if srt_files: + seen = set() + lines = [] + for srt in sorted(srt_files): + with open(srt, "r", errors="ignore") as f: + raw = f.read() + for line in raw.split("\n"): + line = line.strip() + if not line or line.isdigit() or "-->" in line: + continue + if line not in seen: + seen.add(line) + lines.append(line) + if lines: + return {"text": " ".join(lines)} + # Clear tmp files before next language attempt + for f in glob.glob(f"{tmpdir}/*.srt"): + os.remove(f) + return {"text": ""} + except Exception as e: + return {"error": str(e)} + + +@app.get("/video/download") +async def video_download_audio(url: str = ""): + """Download audio only, transcribe with whisper, returns text""" + if not url: + return {"error": "url required"} + with tempfile.TemporaryDirectory() as tmpdir: + try: + # Try bestaudio first + result = subprocess.run([ + "yt-dlp", "--cookies-from-browser", "chrome", + "-f", "bestaudio", "--extract-audio", "--audio-format", "wav", + "--audio-quality", "160k", "--no-playlist", + "-o", f"{tmpdir}/audio.%(ext)s", url + ], capture_output=True, text=True, timeout=120) + wav_files = list(Path(tmpdir).glob("*.wav")) + # Fallback: try best (combined video+audio) for Threads/IG reels + if not wav_files: + result2 = subprocess.run([ + "yt-dlp", "--cookies-from-browser", "chrome", + "-f", "best", "--extract-audio", "--audio-format", "wav", + "--audio-quality", "160k", "--no-playlist", + "-o", f"{tmpdir}/audio.%(ext)s", url + ], capture_output=True, text=True, timeout=120) + wav_files = list(Path(tmpdir).glob("*.wav")) + if wav_files: + # Run whisper transcription + proc = subprocess.run([ + sys.executable, "-m", "whisperx", str(wav_files[0]), + "--model", "small", "--language", "zh", + "--output_format", "txt", "--output_dir", tmpdir + ], capture_output=True, text=True, timeout=600) + txt_files = list(Path(tmpdir).glob("*.txt")) + if txt_files: + with open(txt_files[0]) as f: + return {"text": f.read()} + return {"text": proc.stdout[:5000] if proc.stdout else ""} + return {"error": "No audio downloaded"} + except Exception as e: + return {"error": str(e)} + + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765 + uvicorn.run(app, host="0.0.0.0", port=port)