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.
@@ -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__"]
@@ -0,0 +1,10 @@
1
+ """Allow `python -m localcaption ...` invocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -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())
@@ -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."""
@@ -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
+ )
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/localcaption?logo=pypi&logoColor=white&color=%233775A9)](https://pypi.org/project/localcaption/)
62
+ [![Python versions](https://img.shields.io/pypi/pyversions/localcaption?logo=python&logoColor=white)](https://pypi.org/project/localcaption/)
63
+ [![PyPI downloads](https://img.shields.io/pypi/dm/localcaption?logo=pypi&logoColor=white&label=downloads%2Fmo&color=%23306998)](https://pypistats.org/packages/localcaption)
64
+ [![License: MIT](https://img.shields.io/github/license/jatinkrmalik/localcaption?color=yellow)](LICENSE)
65
+
66
+ <!-- Build & code quality -->
67
+ [![CI](https://github.com/jatinkrmalik/localcaption/actions/workflows/ci.yml/badge.svg)](https://github.com/jatinkrmalik/localcaption/actions/workflows/ci.yml)
68
+ [![Release](https://github.com/jatinkrmalik/localcaption/actions/workflows/release.yml/badge.svg)](https://github.com/jatinkrmalik/localcaption/actions/workflows/release.yml)
69
+ [![Code style: ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
70
+ [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch)
71
+
72
+ <!-- Repo activity & community -->
73
+ [![GitHub release](https://img.shields.io/github/v/release/jatinkrmalik/localcaption?include_prereleases&label=latest%20release&logo=github)](https://github.com/jatinkrmalik/localcaption/releases)
74
+ [![GitHub stars](https://img.shields.io/github/stars/jatinkrmalik/localcaption?style=flat&logo=github)](https://github.com/jatinkrmalik/localcaption/stargazers)
75
+ [![Open issues](https://img.shields.io/github/issues/jatinkrmalik/localcaption?logo=github)](https://github.com/jatinkrmalik/localcaption/issues)
76
+ [![Last commit](https://img.shields.io/github/last-commit/jatinkrmalik/localcaption?logo=github)](https://github.com/jatinkrmalik/localcaption/commits/main)
77
+ [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](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
+ ![Pipeline overview](docs/diagrams/pipeline.png)
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
+ ![Module architecture](docs/diagrams/architecture.png)
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
+ ![Sequence diagram](docs/diagrams/sequence.png)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ localcaption = localcaption.cli:main
@@ -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.