transcripy 1.0.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.
transcripy/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Local transcription of voicebot calls and phone recordings."""
2
+
3
+ from transcripy.models import Segment, Transcript, Turn, Word
4
+ from transcripy.transcribe import DEFAULT_MODEL, transcribe
5
+
6
+ __all__ = ["DEFAULT_MODEL", "Segment", "Transcript", "Turn", "Word", "transcribe"]
7
+ __version__ = "1.0.0"
transcripy/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m transcripy``."""
2
+
3
+ from transcripy.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
transcripy/audio.py ADDED
@@ -0,0 +1,175 @@
1
+ """Read a soundtrack from a video or an unusual recording."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ # Formats faster-whisper can open directly through PyAV.
12
+ PASSTHROUGH_SUFFIXES = {".wav", ".mp3", ".m4a", ".flac", ".ogg", ".opus", ".aac"}
13
+
14
+
15
+ def prepare_media(
16
+ source: Path,
17
+ *,
18
+ which=shutil.which,
19
+ runner=subprocess.run,
20
+ ) -> tuple[Path, bool]:
21
+ """Return audio for the recognizer and whether that file is temporary.
22
+
23
+ Plain audio is returned unchanged. Video and other containers are converted
24
+ with ffmpeg to 16 kHz mono WAV. The second value is true when the caller
25
+ must delete the returned file.
26
+ """
27
+ if source.suffix.lower() in PASSTHROUGH_SUFFIXES:
28
+ return source, False
29
+
30
+ executable = which("ffmpeg")
31
+ if not executable:
32
+ raise RuntimeError(
33
+ "ffmpeg is required to read this file. Install ffmpeg and add it to PATH."
34
+ )
35
+
36
+ descriptor, name = tempfile.mkstemp(suffix=".wav")
37
+ os.close(descriptor)
38
+ destination = Path(name)
39
+ try:
40
+ completed = runner(
41
+ [
42
+ executable,
43
+ "-y",
44
+ "-i",
45
+ str(source),
46
+ "-vn",
47
+ "-ac",
48
+ "1",
49
+ "-ar",
50
+ "16000",
51
+ str(destination),
52
+ ],
53
+ capture_output=True,
54
+ check=False,
55
+ )
56
+ if completed.returncode != 0:
57
+ detail = _last_line(getattr(completed, "stderr", b""))
58
+ message = "ffmpeg could not read the soundtrack"
59
+ if detail:
60
+ message = f"{message}: {detail}"
61
+ raise RuntimeError(message)
62
+ except Exception:
63
+ destination.unlink(missing_ok=True)
64
+ raise
65
+ return destination, True
66
+
67
+
68
+ def split_stereo(
69
+ source: Path,
70
+ *,
71
+ labels: tuple[str, str] = ("left", "right"),
72
+ which=shutil.which,
73
+ runner=subprocess.run,
74
+ ) -> list[tuple[str, Path]] | None:
75
+ """Split a stereo recording into two 16 kHz mono files.
76
+
77
+ ``labels`` names the first and second channel (for example ``agent`` /
78
+ ``customer``). Returns nothing when the soundtrack has one channel. Both
79
+ returned files are temporary and the caller deletes them.
80
+ """
81
+ if len(labels) != 2 or not all(label.strip() for label in labels):
82
+ raise ValueError("labels must be two non-empty channel names")
83
+
84
+ ffmpeg = which("ffmpeg")
85
+ ffprobe = which("ffprobe")
86
+ if not ffmpeg or not ffprobe:
87
+ raise RuntimeError(
88
+ "ffmpeg is required to separate speakers. Install ffmpeg and add it to PATH."
89
+ )
90
+
91
+ probe = runner(
92
+ [
93
+ ffprobe,
94
+ "-v",
95
+ "error",
96
+ "-select_streams",
97
+ "a:0",
98
+ "-show_entries",
99
+ "stream=channels",
100
+ "-of",
101
+ "csv=p=0",
102
+ str(source),
103
+ ],
104
+ capture_output=True,
105
+ check=False,
106
+ )
107
+ if probe.returncode != 0:
108
+ detail = _last_line(getattr(probe, "stderr", b""))
109
+ message = "ffprobe could not read the audio channels"
110
+ if detail:
111
+ message = f"{message}: {detail}"
112
+ raise RuntimeError(message)
113
+ channels = _channel_count(getattr(probe, "stdout", b""))
114
+ if channels < 2:
115
+ return None
116
+
117
+ created: list[Path] = []
118
+ try:
119
+ for index, speaker in enumerate(labels):
120
+ descriptor, name = tempfile.mkstemp(suffix=".wav")
121
+ os.close(descriptor)
122
+ destination = Path(name)
123
+ created.append(destination)
124
+ completed = runner(
125
+ [
126
+ ffmpeg,
127
+ "-y",
128
+ "-i",
129
+ str(source),
130
+ "-vn",
131
+ "-af",
132
+ f"pan=mono|c0=c{index}",
133
+ "-ac",
134
+ "1",
135
+ "-ar",
136
+ "16000",
137
+ str(destination),
138
+ ],
139
+ capture_output=True,
140
+ check=False,
141
+ )
142
+ if completed.returncode != 0:
143
+ detail = _last_line(getattr(completed, "stderr", b""))
144
+ message = f"ffmpeg could not extract the {speaker} channel"
145
+ if detail:
146
+ message = f"{message}: {detail}"
147
+ raise RuntimeError(message)
148
+ except Exception:
149
+ for path in created:
150
+ path.unlink(missing_ok=True)
151
+ raise
152
+ return [(labels[0], created[0]), (labels[1], created[1])]
153
+
154
+
155
+ def _channel_count(stdout: bytes | str) -> int:
156
+ if isinstance(stdout, bytes):
157
+ text = stdout.decode("utf-8", errors="replace")
158
+ else:
159
+ text = stdout
160
+ token = text.strip().splitlines()
161
+ if not token:
162
+ return 1
163
+ try:
164
+ return int(token[0].strip())
165
+ except ValueError:
166
+ return 1
167
+
168
+
169
+ def _last_line(stderr: bytes | str) -> str:
170
+ if isinstance(stderr, bytes):
171
+ text = stderr.decode("utf-8", errors="replace")
172
+ else:
173
+ text = stderr
174
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
175
+ return lines[-1] if lines else ""
transcripy/cli.py ADDED
@@ -0,0 +1,173 @@
1
+ """Command line entry point for one-file transcription."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from transcripy.models import Transcript
11
+ from transcripy.transcribe import DEFAULT_MODEL, transcribe
12
+
13
+ _FORMATS = ("txt", "srt", "vtt", "json")
14
+
15
+
16
+ def main(argv: list[str] | None = None, *, transcriber=transcribe) -> int:
17
+ """Transcribe one call recording and print it or write it to ``--output``."""
18
+ parser = argparse.ArgumentParser(
19
+ prog="transcripy",
20
+ description=(
21
+ "Transcribe a voicebot call, phone recording, or meeting soundtrack "
22
+ "into timed turns with optional speaker labels."
23
+ ),
24
+ )
25
+ parser.add_argument("source", help="audio or video file")
26
+ parser.add_argument(
27
+ "-o",
28
+ "--output",
29
+ help="file to write; the extension picks txt, srt, vtt, or json",
30
+ )
31
+ parser.add_argument("--language", help="ISO language code, for example en")
32
+ parser.add_argument(
33
+ "--model",
34
+ default=DEFAULT_MODEL,
35
+ help=f"Whisper model name (default: {DEFAULT_MODEL})",
36
+ )
37
+ parser.add_argument("--device", default="cpu", help="cpu or cuda")
38
+ parser.add_argument("--prompt", help="words the recording is likely to contain")
39
+ parser.add_argument("--hotwords", help="names or phrases to bias recognition toward")
40
+ parser.add_argument(
41
+ "--speakers",
42
+ action="store_true",
43
+ help="label stereo channels, or separate voices on a mono recording",
44
+ )
45
+ parser.add_argument(
46
+ "--channel-labels",
47
+ default="left,right",
48
+ help="comma-separated stereo channel names (default: left,right)",
49
+ )
50
+ parser.add_argument(
51
+ "--no-word-timestamps",
52
+ action="store_true",
53
+ help="skip per-word timings to speed up transcription a little",
54
+ )
55
+ parser.add_argument(
56
+ "--pause",
57
+ type=float,
58
+ default=1.0,
59
+ help="seconds of silence that start a new utterance",
60
+ )
61
+ parser.add_argument(
62
+ "--format",
63
+ choices=_FORMATS,
64
+ help="output format; overrides the extension of --output",
65
+ )
66
+ args = parser.parse_args(argv)
67
+
68
+ if args.pause < 0:
69
+ print("pause must be >= 0", file=sys.stderr)
70
+ return 1
71
+
72
+ try:
73
+ channel_labels = _parse_channel_labels(args.channel_labels)
74
+ except ValueError as exc:
75
+ print(exc, file=sys.stderr)
76
+ return 1
77
+
78
+ try:
79
+ output_format = choose_format(args.output, args.format)
80
+ except ValueError as exc:
81
+ print(exc, file=sys.stderr)
82
+ return 1
83
+
84
+ reported = False
85
+
86
+ def on_progress(fraction: float, segments_done: int) -> None:
87
+ nonlocal reported
88
+ reported = True
89
+ percent = int(round(min(max(fraction, 0.0), 1.0) * 100))
90
+ print(
91
+ f"\r{percent:3d}% {segments_done} segments",
92
+ file=sys.stderr,
93
+ end="",
94
+ flush=True,
95
+ )
96
+
97
+ try:
98
+ result = transcriber(
99
+ args.source,
100
+ model=args.model,
101
+ language=args.language,
102
+ device=args.device,
103
+ initial_prompt=args.prompt,
104
+ hotwords=args.hotwords,
105
+ speakers=args.speakers,
106
+ channel_labels=channel_labels,
107
+ word_timestamps=not args.no_word_timestamps,
108
+ on_progress=on_progress,
109
+ )
110
+ except (FileNotFoundError, RuntimeError, ValueError) as exc:
111
+ if reported:
112
+ print(file=sys.stderr)
113
+ print(exc, file=sys.stderr)
114
+ return 1
115
+ if reported:
116
+ print(file=sys.stderr)
117
+
118
+ rendered = render(result, output_format, args.pause)
119
+ if args.output is None:
120
+ sys.stdout.write(rendered)
121
+ return 0
122
+
123
+ destination = Path(args.output).expanduser()
124
+ try:
125
+ destination.write_text(rendered, encoding="utf-8", newline="\n")
126
+ except OSError as exc:
127
+ print(exc, file=sys.stderr)
128
+ return 1
129
+ return 0
130
+
131
+
132
+ def _parse_channel_labels(raw: str) -> tuple[str, str]:
133
+ parts = [part.strip() for part in raw.split(",")]
134
+ if len(parts) != 2 or not all(parts):
135
+ raise ValueError(
136
+ "channel labels must be two non-empty names, for example agent,customer"
137
+ )
138
+ return parts[0], parts[1]
139
+
140
+
141
+ def choose_format(output: str | None, explicit: str | None) -> str:
142
+ """Pick txt, srt, vtt, or json from a flag or a file name."""
143
+ if explicit:
144
+ return explicit
145
+ if not output:
146
+ return "txt"
147
+ suffix = Path(output).suffix.lower().lstrip(".")
148
+ if suffix in _FORMATS:
149
+ return suffix
150
+ if suffix == "":
151
+ return "txt"
152
+ raise ValueError(
153
+ f"cannot tell the format from .{suffix}; pass --format txt, srt, vtt, or json"
154
+ )
155
+
156
+
157
+ def render(result: Transcript, output_format: str, pause: float = 1.0) -> str:
158
+ """Turn a transcript into the text that should be stored."""
159
+ if output_format == "txt":
160
+ if not result.text or result.text.endswith("\n"):
161
+ return result.text
162
+ return result.text + "\n"
163
+ if output_format == "srt":
164
+ return result.to_srt()
165
+ if output_format == "vtt":
166
+ return result.to_vtt()
167
+ if output_format == "json":
168
+ return json.dumps(result.to_dict(pause=pause), ensure_ascii=False, indent=2) + "\n"
169
+ raise ValueError(f"unsupported format: {output_format}")
170
+
171
+
172
+ if __name__ == "__main__":
173
+ raise SystemExit(main())
transcripy/diarize.py ADDED
@@ -0,0 +1,99 @@
1
+ """Label who is speaking on a single mixed recording."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from transcripy.models import Segment, Transcript
10
+
11
+ _MODEL = "pyannote/speaker-diarization-3.1"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class SpeakerSpan:
16
+ """One stretch of audio assigned to a speaker by a diarizer."""
17
+
18
+ start: float
19
+ end: float
20
+ speaker: str
21
+
22
+ def __post_init__(self) -> None:
23
+ if self.start < 0:
24
+ raise ValueError("speaker span start must be >= 0")
25
+ if self.end < self.start:
26
+ raise ValueError("speaker span end is before start")
27
+ if not self.speaker.strip():
28
+ raise ValueError("speaker is required")
29
+
30
+
31
+ def assign_speakers(transcript: Transcript, spans: list[SpeakerSpan]) -> Transcript:
32
+ """Give each segment the speaker it overlaps the most."""
33
+ labeled = [
34
+ Segment(
35
+ start=segment.start,
36
+ end=segment.end,
37
+ text=segment.text,
38
+ speaker=_best_speaker(segment, spans),
39
+ words=segment.words,
40
+ )
41
+ for segment in transcript.segments
42
+ ]
43
+ return Transcript.from_segments(
44
+ labeled,
45
+ language=transcript.language,
46
+ duration=transcript.duration,
47
+ )
48
+
49
+
50
+ def pyannote_speakers(source: Path) -> list[SpeakerSpan]:
51
+ """Diarize a mono file with pyannote.
52
+
53
+ The model is downloaded on first use. Set ``HF_TOKEN`` to a Hugging Face
54
+ token that can read ``pyannote/speaker-diarization-3.1``.
55
+ """
56
+ token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
57
+ if not token:
58
+ raise RuntimeError(
59
+ "Set HF_TOKEN to label speakers on a mono recording. "
60
+ "The token must be allowed to download pyannote/speaker-diarization-3.1."
61
+ )
62
+ try:
63
+ pipeline = _load_pyannote(token)
64
+ except ImportError as exc:
65
+ raise RuntimeError(
66
+ "pyannote.audio is required to label speakers on a mono recording. "
67
+ "Install it with: pip install 'transcripy[diarize]'"
68
+ ) from exc
69
+ annotation = pipeline(str(source))
70
+ spans: list[SpeakerSpan] = []
71
+ for turn, _, speaker in annotation.itertracks(yield_label=True):
72
+ spans.append(
73
+ SpeakerSpan(start=float(turn.start), end=float(turn.end), speaker=str(speaker))
74
+ )
75
+ return spans
76
+
77
+
78
+ def _best_speaker(segment: Segment, spans: list[SpeakerSpan]) -> str | None:
79
+ best_name: str | None = None
80
+ best_overlap = 0.0
81
+ for span in spans:
82
+ amount = _overlap(segment.start, segment.end, span.start, span.end)
83
+ if amount > best_overlap:
84
+ best_overlap = amount
85
+ best_name = span.speaker
86
+ return best_name
87
+
88
+
89
+ def _overlap(start: float, end: float, other_start: float, other_end: float) -> float:
90
+ return max(0.0, min(end, other_end) - max(start, other_start))
91
+
92
+
93
+ def _load_pyannote(token: str):
94
+ from pyannote.audio import Pipeline
95
+
96
+ try:
97
+ return Pipeline.from_pretrained(_MODEL, token=token)
98
+ except TypeError:
99
+ return Pipeline.from_pretrained(_MODEL, use_auth_token=token)
transcripy/models.py ADDED
@@ -0,0 +1,220 @@
1
+ """Transcript types returned by the library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Word:
10
+ """One timed word inside a segment."""
11
+
12
+ start: float
13
+ end: float
14
+ word: str
15
+ probability: float | None = None
16
+
17
+ def __post_init__(self) -> None:
18
+ if self.start < 0:
19
+ raise ValueError("word start must be >= 0")
20
+ if self.end < self.start:
21
+ raise ValueError("word end is before start")
22
+ if not self.word.strip():
23
+ raise ValueError("word text is required")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Segment:
28
+ """One stretch of speech with start and end in seconds."""
29
+
30
+ start: float
31
+ end: float
32
+ text: str
33
+ speaker: str | None = None
34
+ words: tuple[Word, ...] = ()
35
+
36
+ def __post_init__(self) -> None:
37
+ if self.start < 0:
38
+ raise ValueError("segment start must be >= 0")
39
+ if self.end < self.start:
40
+ raise ValueError("segment end is before start")
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Turn:
45
+ """Consecutive segments spoken close together."""
46
+
47
+ start: float
48
+ end: float
49
+ text: str
50
+ speaker: str | None = None
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Transcript:
55
+ """Full transcription of one recording."""
56
+
57
+ text: str
58
+ language: str
59
+ duration: float
60
+ segments: tuple[Segment, ...]
61
+
62
+ @classmethod
63
+ def from_segments(
64
+ cls,
65
+ segments: list[Segment] | tuple[Segment, ...],
66
+ *,
67
+ language: str,
68
+ duration: float | None = None,
69
+ ) -> Transcript:
70
+ if not language or not language.strip():
71
+ raise ValueError("language is required")
72
+
73
+ cleaned: list[Segment] = []
74
+ for segment in segments:
75
+ text = segment.text.strip()
76
+ if not text:
77
+ continue
78
+ words = tuple(
79
+ Word(
80
+ start=word.start,
81
+ end=word.end,
82
+ word=word.word.strip(),
83
+ probability=word.probability,
84
+ )
85
+ for word in segment.words
86
+ if word.word.strip()
87
+ )
88
+ cleaned.append(
89
+ Segment(
90
+ start=segment.start,
91
+ end=segment.end,
92
+ text=text,
93
+ speaker=segment.speaker,
94
+ words=words,
95
+ )
96
+ )
97
+
98
+ if duration is None:
99
+ duration = cleaned[-1].end if cleaned else 0.0
100
+ if duration < 0:
101
+ raise ValueError("duration must be >= 0")
102
+
103
+ full_text = " ".join(segment.text for segment in cleaned)
104
+ return cls(
105
+ text=full_text,
106
+ language=language.strip(),
107
+ duration=float(duration),
108
+ segments=tuple(cleaned),
109
+ )
110
+
111
+ def turns(self, pause: float = 1.0) -> tuple[Turn, ...]:
112
+ """Group segments into utterances separated by at least ``pause`` seconds."""
113
+ if pause < 0:
114
+ raise ValueError("pause must be >= 0")
115
+
116
+ grouped: list[list[Segment]] = []
117
+ for segment in self.segments:
118
+ previous = grouped[-1][-1] if grouped else None
119
+ gap = segment.start - previous.end if previous else pause
120
+ speaker_changed = previous is not None and segment.speaker != previous.speaker
121
+ if gap >= pause or speaker_changed:
122
+ grouped.append([])
123
+ grouped[-1].append(segment)
124
+
125
+ return tuple(
126
+ Turn(
127
+ start=segments[0].start,
128
+ end=segments[-1].end,
129
+ text=" ".join(segment.text for segment in segments),
130
+ speaker=segments[0].speaker,
131
+ )
132
+ for segments in grouped
133
+ )
134
+
135
+ def to_dict(self, *, pause: float = 1.0) -> dict[str, object]:
136
+ return {
137
+ "text": self.text,
138
+ "language": self.language,
139
+ "duration": self.duration,
140
+ "segments": [
141
+ {
142
+ "start": segment.start,
143
+ "end": segment.end,
144
+ "text": segment.text,
145
+ "speaker": segment.speaker,
146
+ "words": [
147
+ {
148
+ "start": word.start,
149
+ "end": word.end,
150
+ "word": word.word,
151
+ "probability": word.probability,
152
+ }
153
+ for word in segment.words
154
+ ],
155
+ }
156
+ for segment in self.segments
157
+ ],
158
+ "turns": [
159
+ {
160
+ "start": turn.start,
161
+ "end": turn.end,
162
+ "text": turn.text,
163
+ "speaker": turn.speaker,
164
+ }
165
+ for turn in self.turns(pause)
166
+ ],
167
+ }
168
+
169
+ def to_srt(self) -> str:
170
+ blocks = [
171
+ "\n".join(
172
+ [
173
+ str(index),
174
+ (
175
+ f"{format_timestamp(segment.start)} --> "
176
+ f"{format_timestamp(segment.end)}"
177
+ ),
178
+ _cue_text(segment),
179
+ ]
180
+ )
181
+ for index, segment in enumerate(self.segments, start=1)
182
+ ]
183
+ if not blocks:
184
+ return ""
185
+ return "\n\n".join(blocks) + "\n"
186
+
187
+ def to_vtt(self) -> str:
188
+ cues = [
189
+ "\n".join(
190
+ [
191
+ (
192
+ f"{format_timestamp(segment.start, decimal_separator='.')} --> "
193
+ f"{format_timestamp(segment.end, decimal_separator='.')}"
194
+ ),
195
+ _cue_text(segment),
196
+ ]
197
+ )
198
+ for segment in self.segments
199
+ ]
200
+ body = "\n\n".join(cues)
201
+ if body:
202
+ return f"WEBVTT\n\n{body}\n"
203
+ return "WEBVTT\n"
204
+
205
+
206
+ def _cue_text(segment: Segment) -> str:
207
+ if segment.speaker:
208
+ return f"[{segment.speaker}] {segment.text}"
209
+ return segment.text
210
+
211
+
212
+ def format_timestamp(seconds: float, *, decimal_separator: str = ",") -> str:
213
+ """Format seconds as an SRT or VTT timestamp."""
214
+ if seconds < 0:
215
+ seconds = 0.0
216
+ total_milliseconds = int(round(float(seconds) * 1000))
217
+ hours, remainder = divmod(total_milliseconds, 3_600_000)
218
+ minutes, remainder = divmod(remainder, 60_000)
219
+ secs, milliseconds = divmod(remainder, 1000)
220
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}{decimal_separator}{milliseconds:03d}"
@@ -0,0 +1,281 @@
1
+ """Transcribe one recording into a Transcript."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from pathlib import Path
7
+ from collections.abc import Callable, Sequence
8
+ from typing import Any, Protocol
9
+
10
+ from transcripy.audio import prepare_media, split_stereo
11
+ from transcripy.diarize import SpeakerSpan, assign_speakers, pyannote_speakers
12
+ from transcripy.models import Segment, Transcript, Word
13
+
14
+ Progress = Callable[[float, int], None]
15
+ Diarizer = Callable[[Path], list[SpeakerSpan]]
16
+ DEFAULT_MODEL = "large-v3"
17
+ DEFAULT_CHANNEL_LABELS = ("left", "right")
18
+
19
+ _models: dict[tuple[str, str, str], Any] = {}
20
+ _models_lock = threading.Lock()
21
+
22
+
23
+ class SpeechEngine(Protocol):
24
+ """Something that can turn one media file into a transcript."""
25
+
26
+ def transcribe(
27
+ self,
28
+ source: Path,
29
+ language: str | None = None,
30
+ *,
31
+ initial_prompt: str | None = None,
32
+ hotwords: str | None = None,
33
+ word_timestamps: bool = True,
34
+ on_progress: Progress | None = None,
35
+ ) -> Transcript:
36
+ """Transcribe ``source`` and return timed text."""
37
+
38
+
39
+ def transcribe(
40
+ source: str | Path,
41
+ *,
42
+ model: str = DEFAULT_MODEL,
43
+ language: str | None = None,
44
+ device: str = "cpu",
45
+ compute_type: str | None = None,
46
+ initial_prompt: str | None = None,
47
+ hotwords: str | None = None,
48
+ speakers: bool = False,
49
+ channel_labels: Sequence[str] = DEFAULT_CHANNEL_LABELS,
50
+ word_timestamps: bool = True,
51
+ on_progress: Progress | None = None,
52
+ engine: SpeechEngine | None = None,
53
+ diarizer: Diarizer | None = None,
54
+ ) -> Transcript:
55
+ """Transcribe a voicebot call, phone recording, or meeting soundtrack.
56
+
57
+ The first call for a given model downloads its weights. Later calls reuse
58
+ the loaded model. ``language`` is an ISO code such as ``en``; when it is
59
+ omitted, the model detects the language. ``initial_prompt`` and ``hotwords``
60
+ bias the recognizer toward names and phrases that are likely to be spoken.
61
+ ``on_progress`` receives a fraction from 0 to 1 and the number of segments
62
+ finished so far. ``speakers`` labels a stereo call with ``channel_labels``
63
+ (default ``left`` / ``right``). On a mono recording it separates the mixed
64
+ voices. That path needs pyannote and an ``HF_TOKEN``.
65
+ """
66
+ path = Path(source).expanduser()
67
+ if not path.is_file():
68
+ raise FileNotFoundError(f"media file not found: {path}")
69
+
70
+ labels = _normalize_channel_labels(channel_labels)
71
+
72
+ if engine is None:
73
+ engine = FasterWhisperEngine(
74
+ model_name=model,
75
+ device=device,
76
+ compute_type=compute_type or default_compute_type(device),
77
+ )
78
+
79
+ channels: list[tuple[str | None, Path]] = [(None, path)]
80
+ temporary: list[Path] = []
81
+ if speakers:
82
+ split = split_stereo(path, labels=labels)
83
+ if split:
84
+ channels = [(speaker, audio) for speaker, audio in split]
85
+ temporary = [audio for _, audio in split]
86
+
87
+ try:
88
+ parts: list[Transcript] = []
89
+ finished = 0
90
+ weight = 1 / len(channels)
91
+ for index, (speaker, audio) in enumerate(channels):
92
+ result = engine.transcribe(
93
+ audio,
94
+ language=language,
95
+ initial_prompt=_blank_to_none(initial_prompt),
96
+ hotwords=_blank_to_none(hotwords),
97
+ word_timestamps=word_timestamps,
98
+ on_progress=_scale_progress(on_progress, index * weight, weight, finished),
99
+ )
100
+ parts.append(_label(result, speaker))
101
+ finished += len(result.segments)
102
+ if len(parts) == 1:
103
+ result = parts[0]
104
+ if speakers and channels[0][0] is None:
105
+ spans = (diarizer or pyannote_speakers)(path)
106
+ result = assign_speakers(result, spans)
107
+ return result
108
+ return _merge(parts)
109
+ finally:
110
+ for audio in temporary:
111
+ audio.unlink(missing_ok=True)
112
+
113
+
114
+ def _normalize_channel_labels(channel_labels: Sequence[str]) -> tuple[str, str]:
115
+ labels = tuple(label.strip() for label in channel_labels)
116
+ if len(labels) != 2 or not all(labels):
117
+ raise ValueError("channel_labels must be two non-empty names, for example ('agent', 'customer')")
118
+ return labels[0], labels[1]
119
+
120
+
121
+ def _scale_progress(
122
+ on_progress: Progress | None,
123
+ offset: float,
124
+ weight: float,
125
+ finished: int,
126
+ ) -> Progress | None:
127
+ if on_progress is None:
128
+ return None
129
+
130
+ def report(fraction: float, count: int) -> None:
131
+ on_progress(min(offset + fraction * weight, 1.0), finished + count)
132
+
133
+ return report
134
+
135
+
136
+ def _label(result: Transcript, speaker: str | None) -> Transcript:
137
+ if speaker is None:
138
+ return result
139
+ return Transcript.from_segments(
140
+ [
141
+ Segment(
142
+ start=segment.start,
143
+ end=segment.end,
144
+ text=segment.text,
145
+ speaker=speaker,
146
+ words=segment.words,
147
+ )
148
+ for segment in result.segments
149
+ ],
150
+ language=result.language,
151
+ duration=result.duration,
152
+ )
153
+
154
+
155
+ def _merge(parts: list[Transcript]) -> Transcript:
156
+ segments = [segment for part in parts for segment in part.segments]
157
+ segments.sort(key=lambda segment: (segment.start, segment.speaker or ""))
158
+ return Transcript.from_segments(
159
+ segments,
160
+ language=parts[0].language,
161
+ duration=max(part.duration for part in parts),
162
+ )
163
+
164
+
165
+ def _blank_to_none(value: str | None) -> str | None:
166
+ if value is None:
167
+ return None
168
+ value = value.strip()
169
+ return value or None
170
+
171
+
172
+ def default_compute_type(device: str) -> str:
173
+ """Pick a quantization that runs on the given device."""
174
+ if device == "cpu":
175
+ return "int8"
176
+ return "float16"
177
+
178
+
179
+ class FasterWhisperEngine:
180
+ """Local transcription through faster-whisper."""
181
+
182
+ def __init__(
183
+ self,
184
+ model_name: str = DEFAULT_MODEL,
185
+ device: str = "cpu",
186
+ compute_type: str = "int8",
187
+ model: Any | None = None,
188
+ ) -> None:
189
+ self._model = model if model is not None else load_model(
190
+ model_name,
191
+ device,
192
+ compute_type,
193
+ )
194
+
195
+ def transcribe(
196
+ self,
197
+ source: Path,
198
+ language: str | None = None,
199
+ *,
200
+ initial_prompt: str | None = None,
201
+ hotwords: str | None = None,
202
+ word_timestamps: bool = True,
203
+ on_progress: Progress | None = None,
204
+ ) -> Transcript:
205
+ prepared, temporary = prepare_media(source)
206
+ try:
207
+ options: dict[str, Any] = {
208
+ "language": language,
209
+ "vad_filter": True,
210
+ "word_timestamps": word_timestamps,
211
+ }
212
+ if initial_prompt:
213
+ options["initial_prompt"] = initial_prompt
214
+ if hotwords:
215
+ options["hotwords"] = hotwords
216
+ segments_iter, info = self._model.transcribe(str(prepared), **options)
217
+ duration = getattr(info, "duration", None)
218
+ duration = float(duration) if duration is not None else 0.0
219
+ segments: list[Segment] = []
220
+ for index, segment in enumerate(segments_iter, start=1):
221
+ words = ()
222
+ if word_timestamps:
223
+ raw_words = getattr(segment, "words", None) or ()
224
+ words = tuple(
225
+ Word(
226
+ start=float(word.start),
227
+ end=float(word.end),
228
+ word=str(word.word),
229
+ probability=(
230
+ float(word.probability)
231
+ if getattr(word, "probability", None) is not None
232
+ else None
233
+ ),
234
+ )
235
+ for word in raw_words
236
+ if str(getattr(word, "word", "")).strip()
237
+ )
238
+ segments.append(
239
+ Segment(
240
+ start=segment.start,
241
+ end=segment.end,
242
+ text=segment.text,
243
+ words=words,
244
+ )
245
+ )
246
+ if on_progress is not None:
247
+ fraction = segment.end / duration if duration > 0 else 0.0
248
+ on_progress(min(max(fraction, 0.0), 1.0), index)
249
+ if on_progress is not None:
250
+ on_progress(1.0, len(segments))
251
+ detected = language or getattr(info, "language", None) or "unknown"
252
+ reported_duration = duration if duration > 0 else None
253
+ return Transcript.from_segments(
254
+ segments,
255
+ language=str(detected),
256
+ duration=reported_duration,
257
+ )
258
+ finally:
259
+ if temporary:
260
+ prepared.unlink(missing_ok=True)
261
+
262
+
263
+ def load_model(model_name: str, device: str, compute_type: str) -> Any:
264
+ """Load a Whisper model once per name, device, and compute type."""
265
+ key = (model_name, device, compute_type)
266
+ with _models_lock:
267
+ cached = _models.get(key)
268
+ if cached is None:
269
+ try:
270
+ from faster_whisper import WhisperModel
271
+ except ImportError as exc:
272
+ raise RuntimeError(
273
+ "faster-whisper is required. Install it with: pip install faster-whisper"
274
+ ) from exc
275
+ cached = WhisperModel(
276
+ model_name,
277
+ device=device,
278
+ compute_type=compute_type,
279
+ )
280
+ _models[key] = cached
281
+ return cached
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: transcripy
3
+ Version: 1.0.0
4
+ Summary: Local transcription of voicebot calls and phone recordings into timed speaker turns.
5
+ Author: Bartosz Niewolski
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/niewolski/TranscriPy
8
+ Project-URL: Repository, https://github.com/niewolski/TranscriPy
9
+ Project-URL: Issues, https://github.com/niewolski/TranscriPy/issues
10
+ Project-URL: Changelog, https://github.com/niewolski/TranscriPy/blob/main/CHANGELOG.md
11
+ Keywords: transcription,whisper,faster-whisper,diarization,voicebot,call-center,speech-to-text
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ License-File: NOTICE
25
+ Requires-Dist: faster-whisper<2,>=1.2.1
26
+ Provides-Extra: diarize
27
+ Requires-Dist: pyannote.audio>=3.3; extra == "diarize"
28
+ Provides-Extra: server
29
+ Requires-Dist: fastapi<1,>=0.104; extra == "server"
30
+ Requires-Dist: uvicorn[standard]<1,>=0.24; extra == "server"
31
+ Requires-Dist: python-multipart<1,>=0.0.6; extra == "server"
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest>=8; extra == "dev"
34
+ Requires-Dist: httpx<0.28,>=0.27; extra == "dev"
35
+ Requires-Dist: fastapi<1,>=0.104; extra == "dev"
36
+ Requires-Dist: uvicorn[standard]<1,>=0.24; extra == "dev"
37
+ Requires-Dist: python-multipart<1,>=0.0.6; extra == "dev"
38
+ Dynamic: license-file
39
+
40
+ # TranscriPy
41
+
42
+ Local transcription of **voicebot calls and phone recordings** into timed turns with speaker labels. Audio stays on your machine.
43
+
44
+ ```bash
45
+ pip install transcripy
46
+ ```
47
+
48
+ ```python
49
+ from transcripy import transcribe
50
+
51
+ result = transcribe(
52
+ "call.wav",
53
+ language="en",
54
+ speakers=True,
55
+ channel_labels=("agent", "customer"),
56
+ hotwords="TranscriPy, Acme",
57
+ initial_prompt="A voicebot call about the TranscriPy product.",
58
+ )
59
+
60
+ for turn in result.turns():
61
+ print(f"[{turn.speaker}] {turn.text}")
62
+ ```
63
+
64
+ ```bash
65
+ transcripy call.wav \
66
+ --language en \
67
+ --speakers \
68
+ --channel-labels agent,customer \
69
+ --hotwords "TranscriPy, Acme" \
70
+ --prompt "A voicebot call about the TranscriPy product." \
71
+ -o call.json
72
+ ```
73
+
74
+ ## What it is for
75
+
76
+ - Stereo call recordings where each party has a channel (agent / customer)
77
+ - Mono mixed calls when you install the optional diarization extra
78
+ - Offline / self-hosted pipelines that must not upload audio to a cloud ASR API
79
+
80
+ The public contract is simple: one media file in, a `Transcript` with `segments`, `turns`, and optional per-word timings out. Export as text, SRT, VTT, or JSON.
81
+
82
+ ## Requirements
83
+
84
+ - Python 3.10+
85
+ - [`faster-whisper`](https://github.com/SYSTRAN/faster-whisper) (installed with the package)
86
+ - [`ffmpeg`](https://ffmpeg.org/) on `PATH` for video containers and stereo channel split
87
+ - Optional NVIDIA CUDA for `--device cuda`
88
+ - Optional Hugging Face token for mono speaker diarization (see below)
89
+
90
+ ## Speakers
91
+
92
+ **Stereo (recommended for voicebots).** `--speakers` (or `speakers=True`) splits the two channels and labels them with `--channel-labels` / `channel_labels`. Default labels are `left` and `right`.
93
+
94
+ **Mono.** The same flag runs [pyannote](https://github.com/pyannote/pyannote-audio) and assigns each segment the speaker with the largest time overlap. Overlapping speech and similar voices reduce quality; this is not WhisperX-level alignment.
95
+
96
+ ```bash
97
+ pip install "transcripy[diarize]"
98
+ ```
99
+
100
+ 1. Accept the user agreements for [`pyannote/speaker-diarization-3.1`](https://huggingface.co/pyannote/speaker-diarization-3.1) and [`pyannote/segmentation-3.0`](https://huggingface.co/pyannote/segmentation-3.0).
101
+ 2. Create a Hugging Face access token.
102
+ 3. Export it as `HF_TOKEN` (or `HUGGING_FACE_HUB_TOKEN`).
103
+
104
+ ## Defaults
105
+
106
+ | Setting | Default | Notes |
107
+ | --- | --- | --- |
108
+ | `model` | `large-v3` | Pass a smaller Whisper name (`medium`, `small`, `tiny`) to trade quality for speed |
109
+ | `language` | auto-detect | Prefer an ISO code such as `en` or `pl` for call center audio |
110
+ | `word_timestamps` | on | Disable with `--no-word-timestamps` / `word_timestamps=False` |
111
+ | `device` | `cpu` | Use `cuda` when a GPU is available |
112
+
113
+ Compared with [WhisperX](https://github.com/m-bain/whisperX): TranscriPy focuses on a small call-recording API and reliable **stereo channel labels**. WhisperX is stronger when you need batched GPU throughput and forced word alignment on mixed mono audio.
114
+
115
+ ## Non-goals (1.0)
116
+
117
+ - Live / streaming microphone transcription
118
+ - Hosted cloud ASR
119
+ - Production HTTP API (the `api/` folder in this repo is experimental; not shipped in the wheel)
120
+ - High-throughput batched GPU pipelines
121
+ - Subtitle tooling for films as the primary use case
122
+
123
+ ## Install options
124
+
125
+ ```bash
126
+ pip install transcripy # library + CLI
127
+ pip install "transcripy[diarize]" # + pyannote for mono speakers
128
+ pip install "transcripy[dev]" # tests and the experimental server deps
129
+ ```
130
+
131
+ ## Development
132
+
133
+ ```bash
134
+ git clone https://github.com/niewolski/TranscriPy.git
135
+ cd TranscriPy
136
+ python -m venv .venv
137
+ source .venv/bin/activate
138
+ pip install -e ".[dev]"
139
+ pytest
140
+ ```
141
+
142
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Model weight notices are in [NOTICE](NOTICE).
@@ -0,0 +1,14 @@
1
+ transcripy/__init__.py,sha256=ga2SL74UFhZx8gSsRx3qXv8Al6oPc9xIrcS-hwzNOo0,295
2
+ transcripy/__main__.py,sha256=Xzz0PD_p-VNRGEM60RfVrAU1FKQTPy7s4wklH0b0Q_k,128
3
+ transcripy/audio.py,sha256=0x2cChBPXqEutQU-NThN7H5Y6wmju480yc5vqvYuNf4,5241
4
+ transcripy/cli.py,sha256=FIAOG4LUN8dg4gPUTzwSmcrxJ0klBGn4UvHd4wh7hPQ,5429
5
+ transcripy/diarize.py,sha256=oS6w8n9dVk_fWOy5hamqvXQIFw1Lamo01W2qLHX1m4g,3145
6
+ transcripy/models.py,sha256=vQzD4NigodN1FJ75kq3YVNFT4df3qOgdmsfsB016U7Y,6614
7
+ transcripy/transcribe.py,sha256=vnarLJKYqUO6BrW2dHMLY772Mf1Sr0mWLgBhTa5dAhY,9710
8
+ transcripy-1.0.0.dist-info/licenses/LICENSE,sha256=n9ZWa05VVPUZ1MOHqCoMwqk6U6VW2-ODNRseaOR6Img,1074
9
+ transcripy-1.0.0.dist-info/licenses/NOTICE,sha256=SPQZQbmvl0K40MphlOv64i-CzlAb3ahJBjVODm9nALY,766
10
+ transcripy-1.0.0.dist-info/METADATA,sha256=Zu3VcykNmfqL-XlF05TqfjR2ONOsD2O_R5_STvmkswY,5499
11
+ transcripy-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
12
+ transcripy-1.0.0.dist-info/entry_points.txt,sha256=Fr8YsO0kcp8T7z7SKhIafGv2-pG8Kb5y32YSOhs_pIU,51
13
+ transcripy-1.0.0.dist-info/top_level.txt,sha256=SHXg_1ernjC7pJ-XDxJk1RiiSuH4F1zVB2dPbIc6sjw,11
14
+ transcripy-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ transcripy = transcripy.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bartosz Niewolski
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.
@@ -0,0 +1,15 @@
1
+ TranscriPy
2
+ Copyright (c) 2026 Bartosz Niewolski
3
+
4
+ This product uses third-party speech models and runtimes:
5
+
6
+ - OpenAI Whisper model weights, used through faster-whisper / CTranslate2.
7
+ See https://github.com/openai/whisper and the model cards on Hugging Face.
8
+ - faster-whisper (MIT), https://github.com/SYSTRAN/faster-whisper
9
+ - Optional mono speaker diarization via pyannote.audio and the gated Hugging
10
+ Face pipelines pyannote/speaker-diarization-3.1 and pyannote/segmentation-3.0.
11
+ Users must accept those model agreements and set HF_TOKEN before first use.
12
+ See https://github.com/pyannote/pyannote-audio and the linked model cards.
13
+
14
+ TranscriPy itself does not redistribute those model weights; they are downloaded
15
+ on first use under each project's own terms.
@@ -0,0 +1 @@
1
+ transcripy