Transcribe videos without subtitles via faster-whisper fallback
When yt-dlp yields no subtitle files, the server now runs scripts/transcribe.py (faster-whisper, large-v3-turbo, CUDA with CPU fallback) on the downloaded media and feeds the resulting VTT through the existing Markdown conversion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P1w53ybPWArpZpcei9JPYX
This commit is contained in:
parent
891886b6e8
commit
a3eb962b90
3
.gitignore
vendored
3
.gitignore
vendored
@ -27,3 +27,6 @@ users.txt
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Whisper venv
|
||||
.venv/
|
||||
|
||||
24
INSTALL.md
24
INSTALL.md
@ -80,6 +80,30 @@ Edit `start.sh` to configure the variables below, or pass them directly on the c
|
||||
| `USERS_FILE` | `users.txt` next to `start.sh` | Path to the user database file |
|
||||
| `ADS_DIR` | same directory as `start.sh` | Directory containing `ads-left.html` and `ads-right.html` |
|
||||
|
||||
### Whisper transcription fallback
|
||||
|
||||
When a download yields no subtitles (e.g. Instagram, archive.org), the server
|
||||
transcribes the media locally with [faster-whisper](https://github.com/SYSTRAN/faster-whisper)
|
||||
and feeds the result through the same VTT→Markdown conversion. The fallback
|
||||
is skipped with a warning if faster-whisper is not installed.
|
||||
|
||||
Setup (GPU recommended; the pip-installed cuBLAS/cuDNN libraries are found
|
||||
automatically, no `LD_LIBRARY_PATH` needed):
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install faster-whisper "nvidia-cublas-cu12<13" "nvidia-cudnn-cu12<10"
|
||||
```
|
||||
|
||||
The model (~1.6 GB for `large-v3-turbo`) is downloaded from Hugging Face on
|
||||
first use and cached in `~/.cache/huggingface`.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `WHISPER_PYTHON` | `python3` | Python interpreter with faster-whisper installed (e.g. `.venv/bin/python`) |
|
||||
| `WHISPER_MODEL` | `large-v3-turbo` | faster-whisper model name (`large-v3` for best quality, `small` for low-end machines) |
|
||||
| `WHISPER_DEVICE` | _(auto)_ | Force `cuda` or `cpu`; by default CUDA is tried first with CPU fallback |
|
||||
|
||||
### ZIP & Send mode
|
||||
|
||||
When `ZIP_AND_SEND=true`, all downloaded files are packed into a ZIP and offered as a browser download instead of (only) being saved on the server. A random prefix is added to the temporary directory name to avoid collisions when multiple users download the same video simultaneously.
|
||||
|
||||
@ -7,6 +7,7 @@ YouTube Downloader & Transcript Extractor — a local web frontend for `yt-dlp`
|
||||
- Paste a YouTube URL and download the best available video quality
|
||||
- Subtitles downloaded automatically in the video's original language, English, and German (where available)
|
||||
- Subtitles converted to clean Markdown (timestamps stripped, text deduplicated and paragraph-wrapped)
|
||||
- Videos without subtitles (Instagram, archive.org, …) are transcribed locally via Whisper (faster-whisper, GPU-accelerated) — see `INSTALL.md`
|
||||
- Optional audio extraction to MP3 via ffmpeg
|
||||
- Real-time progress log streamed to the browser
|
||||
- Files saved to `~/YouTube/<video title>/`
|
||||
|
||||
103
scripts/transcribe.py
Executable file
103
scripts/transcribe.py
Executable file
@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Transcribe a media file to WebVTT using faster-whisper.
|
||||
|
||||
Usage: transcribe.py <mediafile>
|
||||
|
||||
The VTT is written next to the input file as <base>.<detected-lang>.vtt
|
||||
and its path is printed as a final "TRANSCRIPT_VTT:<path>" line so the
|
||||
calling server can pick it up.
|
||||
|
||||
Environment variables:
|
||||
WHISPER_MODEL faster-whisper model name (default: large-v3-turbo)
|
||||
WHISPER_DEVICE "cuda" or "cpu" (default: try cuda, fall back to cpu)
|
||||
|
||||
Exit codes: 0 ok, 2 usage/input error, 3 faster-whisper not installed.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def preload_nvidia_libs():
|
||||
"""Load pip-installed cuBLAS/cuDNN so ctranslate2 finds them without
|
||||
LD_LIBRARY_PATH (dlopen by soname succeeds once they are in-process)."""
|
||||
site_dirs = [d for d in sys.path if d.endswith("site-packages")]
|
||||
for site in site_dirs:
|
||||
for lib in sorted(glob.glob(os.path.join(site, "nvidia", "*", "lib", "*.so*"))):
|
||||
try:
|
||||
ctypes.CDLL(lib, mode=ctypes.RTLD_GLOBAL)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def fmt_ts(seconds):
|
||||
ms = int(round(seconds * 1000))
|
||||
h, ms = divmod(ms, 3_600_000)
|
||||
m, ms = divmod(ms, 60_000)
|
||||
s, ms = divmod(ms, 1000)
|
||||
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print(__doc__.strip(), file=sys.stderr)
|
||||
return 2
|
||||
media = sys.argv[1]
|
||||
if not os.path.isfile(media):
|
||||
print(f"Input file not found: {media}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
preload_nvidia_libs()
|
||||
try:
|
||||
from faster_whisper import WhisperModel
|
||||
except ImportError:
|
||||
print("faster-whisper is not installed in this Python environment", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
model_name = os.environ.get("WHISPER_MODEL", "large-v3-turbo")
|
||||
device = os.environ.get("WHISPER_DEVICE")
|
||||
attempts = [(device, None)] if device else [("cuda", "float16"), ("cpu", "int8")]
|
||||
|
||||
model = None
|
||||
for dev, compute in attempts:
|
||||
try:
|
||||
model = WhisperModel(model_name, device=dev, compute_type=compute or "default")
|
||||
print(f"Whisper model {model_name} loaded on {dev}", flush=True)
|
||||
break
|
||||
except Exception as err:
|
||||
print(f"Could not load model on {dev}: {err}", file=sys.stderr, flush=True)
|
||||
if model is None:
|
||||
return 1
|
||||
|
||||
segments, info = model.transcribe(media, vad_filter=True)
|
||||
print(f"Detected language: {info.language} "
|
||||
f"(p={info.language_probability:.2f}), duration {fmt_ts(info.duration)}", flush=True)
|
||||
|
||||
base, _ = os.path.splitext(media)
|
||||
vtt_path = f"{base}.{info.language}.vtt"
|
||||
|
||||
lines = ["WEBVTT", ""]
|
||||
for seg in segments:
|
||||
text = seg.text.strip()
|
||||
if not text:
|
||||
continue
|
||||
lines.append(f"{fmt_ts(seg.start)} --> {fmt_ts(seg.end)}")
|
||||
lines.append(text)
|
||||
lines.append("")
|
||||
# progress for the live log (segments arrive lazily during transcription)
|
||||
print(f"[transcribe] {fmt_ts(seg.start)} {text}", flush=True)
|
||||
|
||||
if len(lines) <= 2:
|
||||
print("No speech detected — no transcript written", flush=True)
|
||||
return 0
|
||||
|
||||
with open(vtt_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
print(f"TRANSCRIPT_VTT:{vtt_path}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -9,6 +9,7 @@ import { config, zipStore } from '$lib/server/store.js';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const YTDLP = process.env.YTDLP_PATH ?? 'yt-dlp';
|
||||
const FFMPEG = process.env.FFMPEG_PATH ?? 'ffmpeg';
|
||||
const WHISPER_PYTHON = process.env.WHISPER_PYTHON ?? 'python3';
|
||||
|
||||
export async function GET({ url, request }) {
|
||||
const videoUrl = url.searchParams.get('url');
|
||||
@ -105,6 +106,28 @@ async function runDownload(videoUrl, audioOnly, send, signal) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no subtitles from the platform — transcribe locally with Whisper
|
||||
if (subtitlePaths.length === 0) {
|
||||
if (!videoPath && existsSync(outputDir)) {
|
||||
const media = readdirSync(outputDir).find(f => /\.(mp4|mkv|webm|m4a|mp3)$/.test(f));
|
||||
if (media) videoPath = join(outputDir, media);
|
||||
}
|
||||
if (videoPath && existsSync(videoPath)) {
|
||||
send('info', 'No subtitles found — transcribing with Whisper…');
|
||||
try {
|
||||
await runProcess(WHISPER_PYTHON, [
|
||||
join(process.cwd(), 'scripts', 'transcribe.py'), videoPath
|
||||
], line => {
|
||||
const vtt = line.match(/^TRANSCRIPT_VTT:(.+)$/);
|
||||
if (vtt) subtitlePaths.push(vtt[1].trim());
|
||||
else send('log', line);
|
||||
}, signal);
|
||||
} catch (err) {
|
||||
send('warn', `Transcription failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const writtenMd = new Set();
|
||||
for (const subPath of subtitlePaths) {
|
||||
if (!existsSync(subPath)) continue;
|
||||
|
||||
5
start.sh
5
start.sh
@ -30,6 +30,11 @@
|
||||
#
|
||||
# ADS_DIR Directory containing ads-left.html and ads-right.html.
|
||||
# (default: same directory as start.sh)
|
||||
#
|
||||
# WHISPER_PYTHON Python interpreter with faster-whisper installed, used to
|
||||
# transcribe media that has no subtitles. (default: python3)
|
||||
# WHISPER_MODEL faster-whisper model name. (default: large-v3-turbo)
|
||||
# WHISPER_DEVICE Force "cuda" or "cpu". (default: try cuda, fall back to cpu)
|
||||
|
||||
export PORT="${PORT:-3000}"
|
||||
export ORIGIN="${ORIGIN:-http://localhost:${PORT}}"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user