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 +7 -0
- transcripy/__main__.py +6 -0
- transcripy/audio.py +175 -0
- transcripy/cli.py +173 -0
- transcripy/diarize.py +99 -0
- transcripy/models.py +220 -0
- transcripy/transcribe.py +281 -0
- transcripy-1.0.0.dist-info/METADATA +142 -0
- transcripy-1.0.0.dist-info/RECORD +14 -0
- transcripy-1.0.0.dist-info/WHEEL +5 -0
- transcripy-1.0.0.dist-info/entry_points.txt +2 -0
- transcripy-1.0.0.dist-info/licenses/LICENSE +21 -0
- transcripy-1.0.0.dist-info/licenses/NOTICE +15 -0
- transcripy-1.0.0.dist-info/top_level.txt +1 -0
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
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}"
|
transcripy/transcribe.py
ADDED
|
@@ -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,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
|