localcaption 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- localcaption/__init__.py +14 -0
- localcaption/__main__.py +10 -0
- localcaption/_logging.py +26 -0
- localcaption/audio.py +48 -0
- localcaption/cli.py +260 -0
- localcaption/download.py +79 -0
- localcaption/errors.py +27 -0
- localcaption/pipeline.py +82 -0
- localcaption/whisper.py +103 -0
- localcaption-0.1.0.dist-info/METADATA +333 -0
- localcaption-0.1.0.dist-info/RECORD +14 -0
- localcaption-0.1.0.dist-info/WHEEL +4 -0
- localcaption-0.1.0.dist-info/entry_points.txt +2 -0
- localcaption-0.1.0.dist-info/licenses/LICENSE +21 -0
localcaption/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""localcaption — fully-local YouTube → transcript pipeline.
|
|
2
|
+
|
|
3
|
+
A thin orchestrator over yt-dlp, ffmpeg, and whisper.cpp. No API keys,
|
|
4
|
+
nothing leaves your machine.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("localcaption")
|
|
11
|
+
except PackageNotFoundError: # editable install before metadata is generated
|
|
12
|
+
__version__ = "0.0.0+local"
|
|
13
|
+
|
|
14
|
+
__all__ = ["__version__"]
|
localcaption/__main__.py
ADDED
localcaption/_logging.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Tiny logging shim so every module logs in the same style without pulling
|
|
2
|
+
in a heavyweight logging config. Honors `NO_COLOR` and non-tty stdout.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
_ENABLE_COLOR = sys.stdout.isatty() and "NO_COLOR" not in os.environ
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _wrap(code: str, msg: str) -> str:
|
|
14
|
+
return f"\033[{code}m{msg}\033[0m" if _ENABLE_COLOR else msg
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def info(msg: str) -> None:
|
|
18
|
+
print(f"{_wrap('1;34', '[localcaption]')} {msg}", flush=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def warn(msg: str) -> None:
|
|
22
|
+
print(f"{_wrap('1;33', '[warning ]')} {msg}", file=sys.stderr, flush=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def error(msg: str) -> None:
|
|
26
|
+
print(f"{_wrap('1;31', '[error ]')} {msg}", file=sys.stderr, flush=True)
|
localcaption/audio.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Stage 2: re-encode arbitrary audio to 16 kHz mono PCM WAV.
|
|
2
|
+
|
|
3
|
+
whisper.cpp expects exactly that format, so this stage is a hard requirement
|
|
4
|
+
even when the source is already a `.wav` (sample rate / channel count may
|
|
5
|
+
not match).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import _logging as log
|
|
15
|
+
from .errors import AudioConversionError, DependencyError
|
|
16
|
+
|
|
17
|
+
WHISPER_SAMPLE_RATE = 16_000
|
|
18
|
+
WHISPER_CHANNELS = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def to_whisper_wav(src: Path, dst: Path) -> Path:
|
|
22
|
+
"""Convert *src* to a 16 kHz mono PCM WAV at *dst* and return *dst*."""
|
|
23
|
+
if shutil.which("ffmpeg") is None:
|
|
24
|
+
raise DependencyError(
|
|
25
|
+
"Required tool 'ffmpeg' was not found on PATH. "
|
|
26
|
+
"On macOS: brew install ffmpeg"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
cmd = [
|
|
30
|
+
"ffmpeg", "-y", "-loglevel", "error",
|
|
31
|
+
"-i", str(src),
|
|
32
|
+
"-ac", str(WHISPER_CHANNELS),
|
|
33
|
+
"-ar", str(WHISPER_SAMPLE_RATE),
|
|
34
|
+
"-vn", # drop any video stream
|
|
35
|
+
"-c:a", "pcm_s16le", # signed 16-bit little-endian PCM
|
|
36
|
+
str(dst),
|
|
37
|
+
]
|
|
38
|
+
log.info(f"ffmpeg: re-encoding to {WHISPER_SAMPLE_RATE} Hz mono WAV")
|
|
39
|
+
try:
|
|
40
|
+
subprocess.run(cmd, check=True)
|
|
41
|
+
except subprocess.CalledProcessError as exc:
|
|
42
|
+
raise AudioConversionError(
|
|
43
|
+
f"ffmpeg failed (exit {exc.returncode}) while converting {src}"
|
|
44
|
+
) from exc
|
|
45
|
+
|
|
46
|
+
if not dst.is_file():
|
|
47
|
+
raise AudioConversionError(f"ffmpeg ran but {dst} was not created")
|
|
48
|
+
return dst
|
localcaption/cli.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Command-line entry point.
|
|
2
|
+
|
|
3
|
+
Exposed as the ``localcaption`` console script via ``pyproject.toml``.
|
|
4
|
+
|
|
5
|
+
Two invocation styles are supported:
|
|
6
|
+
|
|
7
|
+
localcaption <url> [options] # one-shot transcription (default)
|
|
8
|
+
localcaption doctor # diagnose your install
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from . import _logging as log
|
|
21
|
+
from .errors import LocalCaptionError
|
|
22
|
+
from .pipeline import transcribe_url
|
|
23
|
+
from .whisper import DEFAULT_MODEL, WhisperPaths
|
|
24
|
+
|
|
25
|
+
# Subcommands recognised by the dispatcher. Anything else is treated as a URL
|
|
26
|
+
# and routed to the implicit "transcribe" command for backwards compatibility.
|
|
27
|
+
SUBCOMMANDS = frozenset({"doctor", "transcribe"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- whisper.cpp directory resolution ------------------------------------
|
|
31
|
+
|
|
32
|
+
def _xdg_data_home() -> Path:
|
|
33
|
+
"""Return $XDG_DATA_HOME or its conventional fallback (~/.local/share)."""
|
|
34
|
+
env = os.environ.get("XDG_DATA_HOME")
|
|
35
|
+
return Path(env).expanduser() if env else Path.home() / ".local" / "share"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _candidate_whisper_dirs() -> list[Path]:
|
|
39
|
+
"""Where to look for the whisper.cpp checkout, in priority order.
|
|
40
|
+
|
|
41
|
+
1. ``$LOCALCAPTION_WHISPER_DIR`` if set (explicit override).
|
|
42
|
+
2. ``./whisper.cpp`` if running from a dev checkout.
|
|
43
|
+
3. ``$XDG_DATA_HOME/localcaption/whisper.cpp`` (where ``install.sh`` puts it).
|
|
44
|
+
"""
|
|
45
|
+
candidates: list[Path] = []
|
|
46
|
+
env = os.environ.get("LOCALCAPTION_WHISPER_DIR")
|
|
47
|
+
if env:
|
|
48
|
+
candidates.append(Path(env).expanduser())
|
|
49
|
+
candidates.append(Path.cwd() / "whisper.cpp")
|
|
50
|
+
candidates.append(_xdg_data_home() / "localcaption" / "whisper.cpp")
|
|
51
|
+
return candidates
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _default_whisper_dir() -> Path:
|
|
55
|
+
"""Pick the first existing whisper.cpp directory, or the last candidate.
|
|
56
|
+
|
|
57
|
+
The "last candidate" fallback ensures error messages point users at the
|
|
58
|
+
canonical install location rather than the dev-only ``./whisper.cpp``.
|
|
59
|
+
"""
|
|
60
|
+
candidates = _candidate_whisper_dirs()
|
|
61
|
+
for c in candidates:
|
|
62
|
+
if c.is_dir():
|
|
63
|
+
return c
|
|
64
|
+
return candidates[-1]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- transcribe (default) subcommand -------------------------------------
|
|
68
|
+
|
|
69
|
+
def _build_transcribe_parser() -> argparse.ArgumentParser:
|
|
70
|
+
parser = argparse.ArgumentParser(
|
|
71
|
+
prog="localcaption",
|
|
72
|
+
description="Fully-local YouTube → transcript using yt-dlp + ffmpeg + whisper.cpp.",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument("url", help="YouTube URL (or any URL yt-dlp supports)")
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"-m", "--model", default=DEFAULT_MODEL,
|
|
77
|
+
help=f"whisper model name (default: {DEFAULT_MODEL})",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"-o", "--out", type=Path, default=Path.cwd() / "transcripts",
|
|
81
|
+
help="output directory for transcript files (default: ./transcripts)",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"-l", "--language", default="auto",
|
|
85
|
+
help="ISO language code, or 'auto' (default: auto)",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--whisper-dir", type=Path, default=None,
|
|
89
|
+
help="path to a built whisper.cpp checkout "
|
|
90
|
+
"(default: $LOCALCAPTION_WHISPER_DIR, ./whisper.cpp, "
|
|
91
|
+
"or ~/.local/share/localcaption/whisper.cpp)",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--keep-audio", action="store_true",
|
|
95
|
+
help="keep the downloaded audio and intermediate WAV under <out>/.work/",
|
|
96
|
+
)
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--no-print", action="store_true",
|
|
99
|
+
help="do not echo the transcript to stdout when finished",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
102
|
+
return parser
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _cmd_transcribe(argv: list[str]) -> int:
|
|
106
|
+
args = _build_transcribe_parser().parse_args(argv)
|
|
107
|
+
whisper_dir = args.whisper_dir or _default_whisper_dir()
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
result = transcribe_url(
|
|
111
|
+
args.url,
|
|
112
|
+
out_dir=args.out,
|
|
113
|
+
whisper_dir=whisper_dir,
|
|
114
|
+
model=args.model,
|
|
115
|
+
language=args.language,
|
|
116
|
+
keep_intermediate=args.keep_audio,
|
|
117
|
+
)
|
|
118
|
+
except LocalCaptionError as exc:
|
|
119
|
+
log.error(str(exc))
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
log.info("transcript files:")
|
|
123
|
+
for kind, path in result.transcripts.existing().items():
|
|
124
|
+
print(f" {kind:>4}: {path}")
|
|
125
|
+
|
|
126
|
+
if not args.no_print:
|
|
127
|
+
txt = result.transcripts.txt
|
|
128
|
+
if txt.exists():
|
|
129
|
+
print("\n" + "─" * 30 + " transcript " + "─" * 30)
|
|
130
|
+
print(txt.read_text(encoding="utf-8", errors="replace"))
|
|
131
|
+
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --- doctor subcommand ---------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def _check(label: str, ok: bool, detail: str = "") -> bool:
|
|
138
|
+
"""Print a diagnostic line. Returns ``ok`` for chaining into a final exit code."""
|
|
139
|
+
mark = "✅" if ok else "❌"
|
|
140
|
+
suffix = f" ({detail})" if detail else ""
|
|
141
|
+
print(f" {mark} {label}{suffix}")
|
|
142
|
+
return ok
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _cmd_doctor(argv: list[str]) -> int:
|
|
146
|
+
"""Diagnose a localcaption install: prereqs, whisper.cpp, models."""
|
|
147
|
+
parser = argparse.ArgumentParser(
|
|
148
|
+
prog="localcaption doctor",
|
|
149
|
+
description="Diagnose a localcaption install: external tools, "
|
|
150
|
+
"whisper.cpp build, available models.",
|
|
151
|
+
)
|
|
152
|
+
parser.add_argument(
|
|
153
|
+
"--whisper-dir", type=Path, default=None,
|
|
154
|
+
help="check this whisper.cpp directory (default: auto-detect)",
|
|
155
|
+
)
|
|
156
|
+
args = parser.parse_args(argv)
|
|
157
|
+
|
|
158
|
+
print(f"localcaption {__version__}\n")
|
|
159
|
+
|
|
160
|
+
all_ok = True
|
|
161
|
+
|
|
162
|
+
print("System tools:")
|
|
163
|
+
all_ok &= _check("python", True, sys.version.split()[0])
|
|
164
|
+
ff = shutil.which("ffmpeg")
|
|
165
|
+
all_ok &= _check("ffmpeg", ff is not None, ff or "missing — `brew install ffmpeg`")
|
|
166
|
+
git = shutil.which("git")
|
|
167
|
+
all_ok &= _check("git", git is not None, git or "missing")
|
|
168
|
+
|
|
169
|
+
print("\nPython dependencies:")
|
|
170
|
+
try:
|
|
171
|
+
import yt_dlp # noqa: F401
|
|
172
|
+
from yt_dlp.version import __version__ as ytdlp_ver
|
|
173
|
+
all_ok &= _check("yt-dlp", True, ytdlp_ver)
|
|
174
|
+
except ImportError:
|
|
175
|
+
all_ok &= _check("yt-dlp", False, "missing — `pip install yt-dlp`")
|
|
176
|
+
|
|
177
|
+
print("\nwhisper.cpp:")
|
|
178
|
+
whisper_dir = args.whisper_dir or _default_whisper_dir()
|
|
179
|
+
print(f" searching: {whisper_dir}")
|
|
180
|
+
if whisper_dir.is_dir():
|
|
181
|
+
_check("directory exists", True, str(whisper_dir))
|
|
182
|
+
paths = WhisperPaths(whisper_dir)
|
|
183
|
+
try:
|
|
184
|
+
binary = paths.find_binary()
|
|
185
|
+
all_ok &= _check("binary built", True, str(binary))
|
|
186
|
+
except LocalCaptionError as exc:
|
|
187
|
+
all_ok &= _check("binary built", False, str(exc).splitlines()[0])
|
|
188
|
+
|
|
189
|
+
models_dir = paths.models_dir
|
|
190
|
+
if models_dir.is_dir():
|
|
191
|
+
available = sorted(p.name for p in models_dir.glob("ggml-*.bin"))
|
|
192
|
+
if available:
|
|
193
|
+
_check("models present", True, ", ".join(available))
|
|
194
|
+
else:
|
|
195
|
+
all_ok &= _check(
|
|
196
|
+
"models present", False,
|
|
197
|
+
f"no ggml-*.bin in {models_dir} — "
|
|
198
|
+
f"`bash {models_dir}/download-ggml-model.sh base.en`",
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
all_ok &= _check("models directory", False, str(models_dir))
|
|
202
|
+
else:
|
|
203
|
+
all_ok &= _check(
|
|
204
|
+
"directory exists", False,
|
|
205
|
+
"run install.sh or set --whisper-dir / $LOCALCAPTION_WHISPER_DIR",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
print("\nLookup paths searched:")
|
|
209
|
+
for c in _candidate_whisper_dirs():
|
|
210
|
+
marker = "✓" if c.is_dir() else "·"
|
|
211
|
+
print(f" {marker} {c}")
|
|
212
|
+
|
|
213
|
+
print()
|
|
214
|
+
if all_ok:
|
|
215
|
+
print("All checks passed. You're good to go: localcaption <url>")
|
|
216
|
+
return 0
|
|
217
|
+
else:
|
|
218
|
+
print("Some checks failed. See messages above.")
|
|
219
|
+
return 1
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# --- top-level dispatcher -------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def _print_top_level_help() -> None:
|
|
225
|
+
print("""\
|
|
226
|
+
usage: localcaption <url> [options] transcribe a video (default)
|
|
227
|
+
localcaption doctor diagnose your install
|
|
228
|
+
localcaption --help show transcribe help
|
|
229
|
+
localcaption --version print version
|
|
230
|
+
|
|
231
|
+
Run `localcaption <subcommand> --help` for details on each.""")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def main(argv: list[str] | None = None) -> int:
|
|
235
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
236
|
+
|
|
237
|
+
# Bare invocation → top-level help (exit non-zero, like most CLIs do).
|
|
238
|
+
if not argv:
|
|
239
|
+
_print_top_level_help()
|
|
240
|
+
return 2
|
|
241
|
+
|
|
242
|
+
head = argv[0]
|
|
243
|
+
|
|
244
|
+
# Allow `localcaption help` as a friendly alias for the top-level help.
|
|
245
|
+
if head in {"help", "--help-all"}:
|
|
246
|
+
_print_top_level_help()
|
|
247
|
+
return 0
|
|
248
|
+
|
|
249
|
+
# Explicit subcommands.
|
|
250
|
+
if head == "doctor":
|
|
251
|
+
return _cmd_doctor(argv[1:])
|
|
252
|
+
if head == "transcribe":
|
|
253
|
+
return _cmd_transcribe(argv[1:])
|
|
254
|
+
|
|
255
|
+
# Anything else (URL, --help, --version, …) goes to the default transcribe.
|
|
256
|
+
return _cmd_transcribe(argv)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
if __name__ == "__main__": # pragma: no cover
|
|
260
|
+
sys.exit(main())
|
localcaption/download.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Stage 1: download the best available audio stream with yt-dlp.
|
|
2
|
+
|
|
3
|
+
We use yt-dlp's Python API rather than the CLI so we can deterministically
|
|
4
|
+
discover the resulting filename via :py:meth:`YoutubeDL.prepare_filename`.
|
|
5
|
+
This avoids the fragility of parsing ``--print after_move:filepath`` output
|
|
6
|
+
across yt-dlp versions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from . import _logging as log
|
|
15
|
+
from .errors import DependencyError, DownloadError
|
|
16
|
+
|
|
17
|
+
# YouTube periodically blocks specific player clients. The mobile clients
|
|
18
|
+
# tend to be the most stable for audio-only retrieval. Order matters: yt-dlp
|
|
19
|
+
# tries them left-to-right.
|
|
20
|
+
DEFAULT_PLAYER_CLIENTS: tuple[str, ...] = ("android", "ios", "web")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def download_audio(
|
|
24
|
+
url: str,
|
|
25
|
+
work_dir: Path,
|
|
26
|
+
*,
|
|
27
|
+
player_clients: tuple[str, ...] = DEFAULT_PLAYER_CLIENTS,
|
|
28
|
+
) -> Path:
|
|
29
|
+
"""Download the best audio stream for *url* into *work_dir*.
|
|
30
|
+
|
|
31
|
+
Returns the path to the downloaded file. Raises :class:`DownloadError`
|
|
32
|
+
if yt-dlp fails to produce a usable file.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
from yt_dlp import YoutubeDL
|
|
36
|
+
except ImportError as exc:
|
|
37
|
+
raise DependencyError(
|
|
38
|
+
"Python package 'yt-dlp' is not installed. "
|
|
39
|
+
"Install with: pip install -e .[dev] (or) pip install yt-dlp"
|
|
40
|
+
) from exc
|
|
41
|
+
|
|
42
|
+
ydl_opts: dict[str, Any] = {
|
|
43
|
+
"format": "bestaudio/best",
|
|
44
|
+
"outtmpl": str(work_dir / "%(id)s.%(ext)s"),
|
|
45
|
+
"noplaylist": True,
|
|
46
|
+
"quiet": False,
|
|
47
|
+
"no_warnings": True,
|
|
48
|
+
"restrictfilenames": True,
|
|
49
|
+
"overwrites": True,
|
|
50
|
+
"retries": 5,
|
|
51
|
+
"fragment_retries": 5,
|
|
52
|
+
"extractor_args": {"youtube": {"player_client": list(player_clients)}},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
log.info(f"yt-dlp: downloading bestaudio for {url}")
|
|
56
|
+
try:
|
|
57
|
+
with YoutubeDL(ydl_opts) as ydl:
|
|
58
|
+
info = ydl.extract_info(url, download=True)
|
|
59
|
+
# Playlists nest one level deeper. We disabled them above, but be
|
|
60
|
+
# defensive in case the URL is e.g. a single video inside a list.
|
|
61
|
+
if "entries" in info and info["entries"]:
|
|
62
|
+
info = info["entries"][0]
|
|
63
|
+
audio_path = Path(ydl.prepare_filename(info))
|
|
64
|
+
except Exception as exc: # yt-dlp raises a zoo of exception types
|
|
65
|
+
raise DownloadError(f"yt-dlp failed: {exc}") from exc
|
|
66
|
+
|
|
67
|
+
# If a post-processor changed the extension, fall back to scanning by id.
|
|
68
|
+
if not audio_path.is_file():
|
|
69
|
+
matches = sorted(work_dir.glob(f"{info.get('id', '*')}.*"))
|
|
70
|
+
if matches:
|
|
71
|
+
audio_path = matches[0]
|
|
72
|
+
|
|
73
|
+
if not audio_path.is_file():
|
|
74
|
+
raise DownloadError(
|
|
75
|
+
f"yt-dlp finished but no audio file was found near {audio_path}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
log.info(f"downloaded audio: {audio_path.name}")
|
|
79
|
+
return audio_path
|
localcaption/errors.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Custom exceptions for localcaption.
|
|
2
|
+
|
|
3
|
+
Defining a small hierarchy makes it easy for callers (and tests) to catch
|
|
4
|
+
a specific failure mode rather than scraping error strings.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LocalCaptionError(Exception):
|
|
11
|
+
"""Base class for all localcaption errors."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DependencyError(LocalCaptionError):
|
|
15
|
+
"""A required external tool or model is missing."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DownloadError(LocalCaptionError):
|
|
19
|
+
"""yt-dlp failed to fetch the requested media."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AudioConversionError(LocalCaptionError):
|
|
23
|
+
"""ffmpeg failed to produce the expected WAV file."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TranscriptionError(LocalCaptionError):
|
|
27
|
+
"""whisper.cpp failed to produce a transcript."""
|
localcaption/pipeline.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""High-level orchestration: URL → transcript artefacts.
|
|
2
|
+
|
|
3
|
+
This module is the public Python API. The CLI is a thin wrapper around
|
|
4
|
+
:func:`transcribe_url`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import _logging as log
|
|
14
|
+
from .audio import to_whisper_wav
|
|
15
|
+
from .download import download_audio
|
|
16
|
+
from .whisper import DEFAULT_MODEL, TranscriptionResult, transcribe
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class PipelineResult:
|
|
21
|
+
"""Aggregated result of one URL → transcript run."""
|
|
22
|
+
source_url: str
|
|
23
|
+
audio_path: Path | None
|
|
24
|
+
wav_path: Path | None
|
|
25
|
+
transcripts: TranscriptionResult
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def transcribe_url(
|
|
29
|
+
url: str,
|
|
30
|
+
*,
|
|
31
|
+
out_dir: Path,
|
|
32
|
+
whisper_dir: Path,
|
|
33
|
+
model: str = DEFAULT_MODEL,
|
|
34
|
+
language: str = "auto",
|
|
35
|
+
keep_intermediate: bool = False,
|
|
36
|
+
) -> PipelineResult:
|
|
37
|
+
"""Run the full pipeline on *url* and return the produced artefacts.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
url:
|
|
42
|
+
Any URL `yt-dlp` can resolve.
|
|
43
|
+
out_dir:
|
|
44
|
+
Directory for the final transcript files.
|
|
45
|
+
whisper_dir:
|
|
46
|
+
Path to the whisper.cpp checkout (built and with a ggml model present).
|
|
47
|
+
model:
|
|
48
|
+
whisper.cpp model name (e.g. ``base.en``, ``small.en``, ``large-v3``).
|
|
49
|
+
language:
|
|
50
|
+
ISO language code or ``"auto"`` to let whisper detect it.
|
|
51
|
+
keep_intermediate:
|
|
52
|
+
If True, leave the downloaded audio + 16 kHz WAV in ``out_dir/.work``.
|
|
53
|
+
"""
|
|
54
|
+
out_dir = Path(out_dir)
|
|
55
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
work_dir = out_dir / ".work"
|
|
57
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
audio_path: Path | None = None
|
|
60
|
+
wav_path: Path | None = None
|
|
61
|
+
try:
|
|
62
|
+
audio_path = download_audio(url, work_dir)
|
|
63
|
+
wav_path = work_dir / f"{audio_path.stem}.16k.wav"
|
|
64
|
+
to_whisper_wav(audio_path, wav_path)
|
|
65
|
+
|
|
66
|
+
out_base = out_dir / audio_path.stem
|
|
67
|
+
transcripts = transcribe(
|
|
68
|
+
wav_path, model, out_base, whisper_dir=whisper_dir, language=language
|
|
69
|
+
)
|
|
70
|
+
finally:
|
|
71
|
+
if not keep_intermediate:
|
|
72
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
73
|
+
audio_path = None
|
|
74
|
+
wav_path = None
|
|
75
|
+
|
|
76
|
+
log.info("done")
|
|
77
|
+
return PipelineResult(
|
|
78
|
+
source_url=url,
|
|
79
|
+
audio_path=audio_path,
|
|
80
|
+
wav_path=wav_path,
|
|
81
|
+
transcripts=transcripts,
|
|
82
|
+
)
|
localcaption/whisper.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Stage 3: run whisper.cpp against a 16 kHz mono WAV.
|
|
2
|
+
|
|
3
|
+
The whisper.cpp checkout is treated as an external dependency installed by
|
|
4
|
+
``scripts/setup.sh``. We auto-detect the binary because the project moved
|
|
5
|
+
its executable around historically (``main`` → ``build/bin/main`` →
|
|
6
|
+
``build/bin/whisper-cli``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import _logging as log
|
|
17
|
+
from .errors import DependencyError, TranscriptionError
|
|
18
|
+
|
|
19
|
+
DEFAULT_MODEL = "base.en"
|
|
20
|
+
SUPPORTED_OUTPUT_FORMATS = ("txt", "srt", "vtt", "json")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class WhisperPaths:
|
|
25
|
+
"""Locations of an installed whisper.cpp checkout."""
|
|
26
|
+
root: Path
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def models_dir(self) -> Path:
|
|
30
|
+
return self.root / "models"
|
|
31
|
+
|
|
32
|
+
def model_file(self, model_name: str) -> Path:
|
|
33
|
+
return self.models_dir / f"ggml-{model_name}.bin"
|
|
34
|
+
|
|
35
|
+
def find_binary(self) -> Path:
|
|
36
|
+
candidates = [
|
|
37
|
+
self.root / "build" / "bin" / "whisper-cli",
|
|
38
|
+
self.root / "build" / "bin" / "main",
|
|
39
|
+
self.root / "main",
|
|
40
|
+
]
|
|
41
|
+
for c in candidates:
|
|
42
|
+
if c.is_file() and os.access(c, os.X_OK):
|
|
43
|
+
return c
|
|
44
|
+
raise DependencyError(
|
|
45
|
+
f"whisper.cpp binary not found under {self.root}. "
|
|
46
|
+
"Run scripts/setup.sh to build it."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class TranscriptionResult:
|
|
52
|
+
"""Paths to the artefacts emitted by whisper.cpp."""
|
|
53
|
+
txt: Path
|
|
54
|
+
srt: Path
|
|
55
|
+
vtt: Path
|
|
56
|
+
json: Path
|
|
57
|
+
|
|
58
|
+
def existing(self) -> dict[str, Path]:
|
|
59
|
+
"""Return only the outputs that actually exist on disk."""
|
|
60
|
+
return {k: v for k, v in vars(self).items() if isinstance(v, Path) and v.exists()}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def transcribe(
|
|
64
|
+
wav: Path,
|
|
65
|
+
model: str,
|
|
66
|
+
out_basename: Path,
|
|
67
|
+
*,
|
|
68
|
+
whisper_dir: Path,
|
|
69
|
+
language: str = "auto",
|
|
70
|
+
) -> TranscriptionResult:
|
|
71
|
+
"""Run whisper.cpp on *wav* and emit transcripts at *out_basename*.{txt,srt,vtt,json}."""
|
|
72
|
+
paths = WhisperPaths(whisper_dir)
|
|
73
|
+
binary = paths.find_binary()
|
|
74
|
+
model_path = paths.model_file(model)
|
|
75
|
+
if not model_path.is_file():
|
|
76
|
+
raise DependencyError(
|
|
77
|
+
f"Model file missing: {model_path}\n"
|
|
78
|
+
f"Download it with: bash {paths.models_dir}/download-ggml-model.sh {model}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
out_basename.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
cmd = [
|
|
83
|
+
str(binary),
|
|
84
|
+
"-m", str(model_path),
|
|
85
|
+
"-f", str(wav),
|
|
86
|
+
"-of", str(out_basename),
|
|
87
|
+
"-otxt", "-osrt", "-ovtt", "-oj",
|
|
88
|
+
"-l", language,
|
|
89
|
+
]
|
|
90
|
+
log.info(f"whisper.cpp: model={model} language={language}")
|
|
91
|
+
try:
|
|
92
|
+
subprocess.run(cmd, check=True)
|
|
93
|
+
except subprocess.CalledProcessError as exc:
|
|
94
|
+
raise TranscriptionError(
|
|
95
|
+
f"whisper.cpp failed (exit {exc.returncode}) on {wav}"
|
|
96
|
+
) from exc
|
|
97
|
+
|
|
98
|
+
return TranscriptionResult(
|
|
99
|
+
txt=out_basename.with_suffix(".txt"),
|
|
100
|
+
srt=out_basename.with_suffix(".srt"),
|
|
101
|
+
vtt=out_basename.with_suffix(".vtt"),
|
|
102
|
+
json=out_basename.with_suffix(".json"),
|
|
103
|
+
)
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: localcaption
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fully-local YouTube → transcript pipeline using yt-dlp, ffmpeg, and whisper.cpp. No API keys.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jatinkrmalik/localcaption
|
|
6
|
+
Project-URL: Repository, https://github.com/jatinkrmalik/localcaption
|
|
7
|
+
Project-URL: Issues, https://github.com/jatinkrmalik/localcaption/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/jatinkrmalik/localcaption/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Jatin Kumar Malik
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 Jatin Kumar Malik
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: captions,local,speech-to-text,subtitles,transcription,whisper,whisper.cpp,youtube,yt-dlp
|
|
33
|
+
Classifier: Development Status :: 3 - Alpha
|
|
34
|
+
Classifier: Environment :: Console
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
37
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
38
|
+
Classifier: Operating System :: MacOS
|
|
39
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
40
|
+
Classifier: Programming Language :: Python :: 3
|
|
41
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
45
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
46
|
+
Classifier: Topic :: Multimedia :: Video
|
|
47
|
+
Classifier: Topic :: Utilities
|
|
48
|
+
Requires-Python: >=3.10
|
|
49
|
+
Requires-Dist: yt-dlp>=2025.10.14
|
|
50
|
+
Provides-Extra: dev
|
|
51
|
+
Requires-Dist: pytest-cov>=4; extra == 'dev'
|
|
52
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
53
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
54
|
+
Description-Content-Type: text/markdown
|
|
55
|
+
|
|
56
|
+
# localcaption
|
|
57
|
+
|
|
58
|
+
> Paste a YouTube URL, get a transcript. **Fully local, no API keys.**
|
|
59
|
+
|
|
60
|
+
<!-- Package & versioning -->
|
|
61
|
+
[](https://pypi.org/project/localcaption/)
|
|
62
|
+
[](https://pypi.org/project/localcaption/)
|
|
63
|
+
[](https://pypistats.org/packages/localcaption)
|
|
64
|
+
[](LICENSE)
|
|
65
|
+
|
|
66
|
+
<!-- Build & code quality -->
|
|
67
|
+
[](https://github.com/jatinkrmalik/localcaption/actions/workflows/ci.yml)
|
|
68
|
+
[](https://github.com/jatinkrmalik/localcaption/actions/workflows/release.yml)
|
|
69
|
+
[](https://github.com/astral-sh/ruff)
|
|
70
|
+
[](https://github.com/pypa/hatch)
|
|
71
|
+
|
|
72
|
+
<!-- Repo activity & community -->
|
|
73
|
+
[](https://github.com/jatinkrmalik/localcaption/releases)
|
|
74
|
+
[](https://github.com/jatinkrmalik/localcaption/stargazers)
|
|
75
|
+
[](https://github.com/jatinkrmalik/localcaption/issues)
|
|
76
|
+
[](https://github.com/jatinkrmalik/localcaption/commits/main)
|
|
77
|
+
[](CONTRIBUTING.md)
|
|
78
|
+
|
|
79
|
+
`localcaption` is a tiny orchestrator over three battle-tested tools:
|
|
80
|
+
|
|
81
|
+
| Stage | Tool |
|
|
82
|
+
|---|---|
|
|
83
|
+
| Download bestaudio | [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) |
|
|
84
|
+
| Re-encode to 16 kHz mono WAV | [`ffmpeg`](https://ffmpeg.org/) |
|
|
85
|
+
| Transcribe locally | [`whisper.cpp`](https://github.com/ggerganov/whisper.cpp) |
|
|
86
|
+
|
|
87
|
+
Nothing is uploaded to a third-party service. No OpenAI / Google / DeepL keys
|
|
88
|
+
required. Runs happily on a laptop.
|
|
89
|
+
|
|
90
|
+

|
|
91
|
+
|
|
92
|
+
## Install
|
|
93
|
+
|
|
94
|
+
### Prerequisites
|
|
95
|
+
|
|
96
|
+
- Python 3.10+
|
|
97
|
+
- `git`, `ffmpeg`, `cmake` on your `$PATH`
|
|
98
|
+
(macOS: `brew install ffmpeg cmake`)
|
|
99
|
+
|
|
100
|
+
### Quick install (recommended for end users)
|
|
101
|
+
|
|
102
|
+
One command. Installs `localcaption` system-wide via [pipx](https://pipx.pypa.io)
|
|
103
|
+
and bootstraps `whisper.cpp` + a default model. After this you can run
|
|
104
|
+
`localcaption <url>` from any directory.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
curl -fsSL https://raw.githubusercontent.com/jatinkrmalik/localcaption/main/scripts/install.sh | bash
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
What it does:
|
|
111
|
+
|
|
112
|
+
1. Verifies prerequisites (`python3`, `git`, `ffmpeg`, `cmake`) and installs `pipx` + `cmake` if missing (via `brew` or `apt`).
|
|
113
|
+
2. `pipx install localcaption` — isolated venv, console script on `$PATH`.
|
|
114
|
+
3. Clones & builds `whisper.cpp` into `~/.local/share/localcaption/whisper.cpp/` (XDG-compliant).
|
|
115
|
+
4. Downloads the default `base.en` ggml model.
|
|
116
|
+
|
|
117
|
+
Override the default model with `WHISPER_MODEL=small.en bash install.sh`.
|
|
118
|
+
|
|
119
|
+
After install, verify everything is wired up:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
localcaption doctor
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Sample output:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
localcaption 0.1.0
|
|
129
|
+
|
|
130
|
+
System tools:
|
|
131
|
+
✅ python (3.12.3)
|
|
132
|
+
✅ ffmpeg (/opt/homebrew/bin/ffmpeg)
|
|
133
|
+
✅ git (/opt/homebrew/bin/git)
|
|
134
|
+
|
|
135
|
+
Python dependencies:
|
|
136
|
+
✅ yt-dlp (2025.10.14)
|
|
137
|
+
|
|
138
|
+
whisper.cpp:
|
|
139
|
+
searching: /Users/you/.local/share/localcaption/whisper.cpp
|
|
140
|
+
✅ directory exists
|
|
141
|
+
✅ binary built (.../build/bin/whisper-cli)
|
|
142
|
+
✅ models present (ggml-base.en.bin)
|
|
143
|
+
|
|
144
|
+
All checks passed. You're good to go: localcaption <url>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Dev install (contributors)
|
|
148
|
+
|
|
149
|
+
If you're hacking on `localcaption` itself, install editable from a clone:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
git clone https://github.com/jatinkrmalik/localcaption
|
|
153
|
+
cd localcaption
|
|
154
|
+
./scripts/setup.sh # creates .venv, pip install -e .[dev], clones+builds whisper.cpp HERE
|
|
155
|
+
source .venv/bin/activate
|
|
156
|
+
pytest # 14 tests, all should pass
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The dev setup keeps `whisper.cpp/` inside the repo (so you can poke at it),
|
|
160
|
+
and editable-installs the package so source edits take effect immediately.
|
|
161
|
+
|
|
162
|
+
## Usage
|
|
163
|
+
|
|
164
|
+
### CLI
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
localcaption "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
| flag | default | what it does |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| `-m`, `--model` | `base.en` | whisper model name (`tiny.en`, `base.en`, `small.en`, `medium.en`, `large-v3`, …) |
|
|
173
|
+
| `-o`, `--out` | `./transcripts` | output directory |
|
|
174
|
+
| `-l`, `--language` | `auto` | ISO language code, or `auto` to let whisper detect it |
|
|
175
|
+
| `--whisper-dir` | auto-detect¹ | path to a built whisper.cpp checkout |
|
|
176
|
+
| `--keep-audio` | off | keep the downloaded audio + intermediate WAV in `<out>/.work/` |
|
|
177
|
+
| `--no-print` | off | don't echo the transcript to stdout |
|
|
178
|
+
|
|
179
|
+
¹ `--whisper-dir` resolution order:
|
|
180
|
+
1. The explicit flag value, if given.
|
|
181
|
+
2. `$LOCALCAPTION_WHISPER_DIR` env var.
|
|
182
|
+
3. `./whisper.cpp` (dev checkout).
|
|
183
|
+
4. `~/.local/share/localcaption/whisper.cpp` (where `install.sh` puts it).
|
|
184
|
+
|
|
185
|
+
Outputs `<videoId>.txt`, `.srt`, `.vtt`, and `.json` in the chosen directory.
|
|
186
|
+
|
|
187
|
+
You can also invoke it as a module: `python -m localcaption <url>`.
|
|
188
|
+
|
|
189
|
+
### Subcommands
|
|
190
|
+
|
|
191
|
+
| Subcommand | What it does |
|
|
192
|
+
|---|---|
|
|
193
|
+
| _(default)_ `localcaption <url>` | Transcribe a single URL. |
|
|
194
|
+
| `localcaption doctor` | Diagnose your install: prereqs, whisper.cpp, available models. Useful before filing a bug. |
|
|
195
|
+
|
|
196
|
+
### Python API
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from pathlib import Path
|
|
200
|
+
from localcaption.pipeline import transcribe_url
|
|
201
|
+
|
|
202
|
+
result = transcribe_url(
|
|
203
|
+
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
204
|
+
out_dir=Path("transcripts"),
|
|
205
|
+
whisper_dir=Path("whisper.cpp"),
|
|
206
|
+
model="base.en",
|
|
207
|
+
)
|
|
208
|
+
print(result.transcripts.txt.read_text())
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Architecture
|
|
212
|
+
|
|
213
|
+
`localcaption` is intentionally tiny: an orchestrator (`pipeline.py`) drives
|
|
214
|
+
three single-responsibility stages, each wrapping one external tool. The
|
|
215
|
+
modules are split this way so that a contributor can swap, say, `whisper.cpp`
|
|
216
|
+
for `faster-whisper` without touching `download.py` or `audio.py`.
|
|
217
|
+
|
|
218
|
+
### Module map
|
|
219
|
+
|
|
220
|
+

|
|
221
|
+
|
|
222
|
+
| Layer | Files | Responsibility |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| Entry points | `cli.py`, `__main__.py` | argparse, exit codes, stdout formatting |
|
|
225
|
+
| Orchestration | `pipeline.py` | public Python API: `transcribe_url(...)` |
|
|
226
|
+
| Pipeline stages | `download.py`, `audio.py`, `whisper.py` | one external tool each |
|
|
227
|
+
| Support | `errors.py`, `_logging.py` | exception hierarchy, tiny logger |
|
|
228
|
+
|
|
229
|
+
### Runtime sequence
|
|
230
|
+
|
|
231
|
+
End-to-end call flow for a single `localcaption <url>` invocation, including
|
|
232
|
+
the subprocess hops to yt-dlp, ffmpeg, and whisper.cpp. The intermediate
|
|
233
|
+
`.work/` directory is cleaned up at the end unless `--keep-audio` is passed.
|
|
234
|
+
|
|
235
|
+

|
|
236
|
+
|
|
237
|
+
> Diagrams live in [`docs/diagrams/`](docs/diagrams) as Mermaid `.mmd` source
|
|
238
|
+
> files alongside the rendered PNGs. Regenerate with:
|
|
239
|
+
> ```bash
|
|
240
|
+
> mmdc -i docs/diagrams/<name>.mmd -o docs/diagrams/<name>.png \
|
|
241
|
+
> -t default -b transparent --width 1600 --scale 2
|
|
242
|
+
> ```
|
|
243
|
+
|
|
244
|
+
## Benchmarks
|
|
245
|
+
|
|
246
|
+
Wall-clock times for the **complete** pipeline (yt-dlp download → ffmpeg
|
|
247
|
+
re-encode → whisper.cpp transcription), measured with the default `base.en`
|
|
248
|
+
model. Numbers will vary with your network speed and CPU/GPU; treat them as
|
|
249
|
+
order-of-magnitude reference, not a competitive benchmark.
|
|
250
|
+
|
|
251
|
+
| Video | Length | Wall-clock | Speed vs. realtime | Hardware |
|
|
252
|
+
|---|---|---|---|---|
|
|
253
|
+
| [TED-Ed — *How does your immune system work?*](https://www.youtube.com/watch?v=PSRJfaAYkW4) | 5:23 | **7.5 s** | ~43× | MacBook Pro M4 Pro, 48 GB |
|
|
254
|
+
| [3Blue1Brown — *But what is a Neural Network?*](https://www.youtube.com/watch?v=aircAruvnKk) | 18:40 | **19.3 s** | ~58× | MacBook Pro M4 Pro, 48 GB |
|
|
255
|
+
| [Hasan Minhaj × Neil deGrasse Tyson — *Why AI is Overrated*](https://www.youtube.com/watch?v=BYizgB2FcAQ) | 54:17 | **49.8 s** | ~65× | MacBook Pro M4 Pro, 48 GB |
|
|
256
|
+
|
|
257
|
+
<details>
|
|
258
|
+
<summary>Reproduce</summary>
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
# Apple Silicon, macOS, whisper.cpp built with Metal,
|
|
262
|
+
# model: ggml-base.en, language: auto, no other heavy processes.
|
|
263
|
+
|
|
264
|
+
time localcaption --no-print -o /tmp/lc-bench-1 \
|
|
265
|
+
"https://www.youtube.com/watch?v=PSRJfaAYkW4"
|
|
266
|
+
|
|
267
|
+
time localcaption --no-print -o /tmp/lc-bench-2 \
|
|
268
|
+
"https://www.youtube.com/watch?v=aircAruvnKk"
|
|
269
|
+
|
|
270
|
+
time localcaption --no-print -o /tmp/lc-bench-3 \
|
|
271
|
+
"https://www.youtube.com/watch?v=BYizgB2FcAQ"
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
If you'd like to contribute numbers from a different machine (Linux + CUDA,
|
|
275
|
+
Windows + WSL, x86 macOS, etc.), open a PR adding a row above with your
|
|
276
|
+
hardware in the **Hardware** column.
|
|
277
|
+
</details>
|
|
278
|
+
|
|
279
|
+
## Notes
|
|
280
|
+
|
|
281
|
+
- Bigger models = better quality but slower. `base.en` is a good default;
|
|
282
|
+
try `small.en` if you have the patience and `tiny.en` for instant results.
|
|
283
|
+
- Apple Silicon: whisper.cpp's CMake build uses Metal automatically — you'll
|
|
284
|
+
see `ggml_metal_init` in the logs.
|
|
285
|
+
- The pipeline accepts any URL `yt-dlp` supports (Vimeo, Twitch VODs, podcast
|
|
286
|
+
pages, etc.), not just YouTube.
|
|
287
|
+
- If you hit `HTTP 403 Forbidden`, your `yt-dlp` is probably stale —
|
|
288
|
+
`pip install -U yt-dlp` usually fixes it.
|
|
289
|
+
|
|
290
|
+
## Roadmap
|
|
291
|
+
|
|
292
|
+
The roadmap lives on GitHub Issues so it's easy to track, comment on, and
|
|
293
|
+
contribute to:
|
|
294
|
+
|
|
295
|
+
👉 **[Open roadmap items](https://github.com/jatinkrmalik/localcaption/issues?q=is%3Aissue+is%3Aopen+label%3Aroadmap)**
|
|
296
|
+
|
|
297
|
+
A snapshot of what's planned (click through for full descriptions, acceptance
|
|
298
|
+
criteria, and discussion):
|
|
299
|
+
|
|
300
|
+
| # | Item | Labels |
|
|
301
|
+
|---|---|---|
|
|
302
|
+
| [#1](https://github.com/jatinkrmalik/localcaption/issues/1) | Switch default model from `base.en` to `small.en` | `good first issue` |
|
|
303
|
+
| [#2](https://github.com/jatinkrmalik/localcaption/issues/2) | Batch mode (`--batch urls.txt`) | `enhancement` |
|
|
304
|
+
| [#3](https://github.com/jatinkrmalik/localcaption/issues/3) | Local auto-summary via Ollama (`--summary`) | `enhancement` |
|
|
305
|
+
| [#4](https://github.com/jatinkrmalik/localcaption/issues/4) | Speaker diarization with pyannote.audio (`--diarize`) | `stretch`, `help wanted` |
|
|
306
|
+
| [#5](https://github.com/jatinkrmalik/localcaption/issues/5) | YouTube chapters & grep-able search index | `enhancement` |
|
|
307
|
+
| [#6](https://github.com/jatinkrmalik/localcaption/issues/6) | Pluggable transcription backends (faster-whisper / MLX) | `help wanted` |
|
|
308
|
+
|
|
309
|
+
**Have an idea?** Open a
|
|
310
|
+
[feature request](https://github.com/jatinkrmalik/localcaption/issues/new/choose) —
|
|
311
|
+
or jump into [Discussions](https://github.com/jatinkrmalik/localcaption/discussions)
|
|
312
|
+
if you want to chat about it first.
|
|
313
|
+
|
|
314
|
+
## Related projects
|
|
315
|
+
|
|
316
|
+
`localcaption` deliberately stays tiny. If you want more, check out:
|
|
317
|
+
|
|
318
|
+
- [`whishper`](https://github.com/pluja/whishper) — full web UI for local
|
|
319
|
+
transcription with translation and editing.
|
|
320
|
+
- [`transcribe-anything`](https://github.com/zackees/transcribe-anything) —
|
|
321
|
+
multi-backend, Mac-arm optimised, supports URLs.
|
|
322
|
+
- [`WhisperX`](https://github.com/m-bain/whisperX) — word-level timestamps and
|
|
323
|
+
diarisation on top of openai-whisper.
|
|
324
|
+
|
|
325
|
+
## Contributing
|
|
326
|
+
|
|
327
|
+
Pull requests welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). By
|
|
328
|
+
participating you agree to abide by our
|
|
329
|
+
[Code of Conduct](CODE_OF_CONDUCT.md).
|
|
330
|
+
|
|
331
|
+
## License
|
|
332
|
+
|
|
333
|
+
[MIT](LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
localcaption/__init__.py,sha256=r7bgowkiemdii0CjS_TnmuvtRVOUZVlGuCaV2UP4UrQ,417
|
|
2
|
+
localcaption/__main__.py,sha256=cI9jrT1b-BbZZMWCFWiPml5-lnwdsgWv5sFmQZv4VoA,173
|
|
3
|
+
localcaption/_logging.py,sha256=2IYAGOfquaHEPlc8n7x7NwAg4b6SXyeqCu6BeJd2cmw,706
|
|
4
|
+
localcaption/audio.py,sha256=ScJ9C7MNRc4ZsoY0ewNfGzJqHfz18iBh5n_tA0DcrmI,1516
|
|
5
|
+
localcaption/cli.py,sha256=rkIZ_KLoE9M-9aQ_7qTsSBG5r8FskgZN8dKdmFHzo6Y,8913
|
|
6
|
+
localcaption/download.py,sha256=zNjnFrIdjx-XNWbwHqS0pbOD68Cm4VAB8Oqyxv4Gh_E,2804
|
|
7
|
+
localcaption/errors.py,sha256=lJzKNibtJix7xEElG36sJf4eu7Iv6guUXTCm8o6F5Qg,706
|
|
8
|
+
localcaption/pipeline.py,sha256=DDr04_491PA7xZ_KIvYMbpALRngo3WnYvThC_ZQYVLA,2343
|
|
9
|
+
localcaption/whisper.py,sha256=o9KndumkoqA3nptZdatJTxTyb7nxq2ZtEXfbHwswgKY,3092
|
|
10
|
+
localcaption-0.1.0.dist-info/METADATA,sha256=j-8-273AGHGsqpfUk6LY5KG9MLeP0BRg-JYhUG6AhoU,14375
|
|
11
|
+
localcaption-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
localcaption-0.1.0.dist-info/entry_points.txt,sha256=YR6Ov5vPTQoCzmZabO0HaKu7xFhiPNolbWlC6fsGpuk,55
|
|
13
|
+
localcaption-0.1.0.dist-info/licenses/LICENSE,sha256=RyJWmox6YO3j9-J-ssABfzdeVNhKuuTklun4g9iwakk,1074
|
|
14
|
+
localcaption-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jatin Kumar Malik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|