transcripy 1.0.0__tar.gz
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-1.0.0/LICENSE +21 -0
- transcripy-1.0.0/NOTICE +15 -0
- transcripy-1.0.0/PKG-INFO +142 -0
- transcripy-1.0.0/README.md +103 -0
- transcripy-1.0.0/pyproject.toml +67 -0
- transcripy-1.0.0/setup.cfg +4 -0
- transcripy-1.0.0/tests/test_api.py +232 -0
- transcripy-1.0.0/tests/test_audio.py +149 -0
- transcripy-1.0.0/tests/test_cli.py +195 -0
- transcripy-1.0.0/tests/test_diarize.py +82 -0
- transcripy-1.0.0/tests/test_stereo_integration.py +122 -0
- transcripy-1.0.0/tests/test_transcribe.py +250 -0
- transcripy-1.0.0/tests/test_transcript.py +172 -0
- transcripy-1.0.0/transcripy/__init__.py +7 -0
- transcripy-1.0.0/transcripy/__main__.py +6 -0
- transcripy-1.0.0/transcripy/audio.py +175 -0
- transcripy-1.0.0/transcripy/cli.py +173 -0
- transcripy-1.0.0/transcripy/diarize.py +99 -0
- transcripy-1.0.0/transcripy/models.py +220 -0
- transcripy-1.0.0/transcripy/transcribe.py +281 -0
- transcripy-1.0.0/transcripy.egg-info/PKG-INFO +142 -0
- transcripy-1.0.0/transcripy.egg-info/SOURCES.txt +24 -0
- transcripy-1.0.0/transcripy.egg-info/dependency_links.txt +1 -0
- transcripy-1.0.0/transcripy.egg-info/entry_points.txt +2 -0
- transcripy-1.0.0/transcripy.egg-info/requires.txt +16 -0
- transcripy-1.0.0/transcripy.egg-info/top_level.txt +1 -0
transcripy-1.0.0/LICENSE
ADDED
|
@@ -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.
|
transcripy-1.0.0/NOTICE
ADDED
|
@@ -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,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,103 @@
|
|
|
1
|
+
# TranscriPy
|
|
2
|
+
|
|
3
|
+
Local transcription of **voicebot calls and phone recordings** into timed turns with speaker labels. Audio stays on your machine.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install transcripy
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from transcripy import transcribe
|
|
11
|
+
|
|
12
|
+
result = transcribe(
|
|
13
|
+
"call.wav",
|
|
14
|
+
language="en",
|
|
15
|
+
speakers=True,
|
|
16
|
+
channel_labels=("agent", "customer"),
|
|
17
|
+
hotwords="TranscriPy, Acme",
|
|
18
|
+
initial_prompt="A voicebot call about the TranscriPy product.",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
for turn in result.turns():
|
|
22
|
+
print(f"[{turn.speaker}] {turn.text}")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
transcripy call.wav \
|
|
27
|
+
--language en \
|
|
28
|
+
--speakers \
|
|
29
|
+
--channel-labels agent,customer \
|
|
30
|
+
--hotwords "TranscriPy, Acme" \
|
|
31
|
+
--prompt "A voicebot call about the TranscriPy product." \
|
|
32
|
+
-o call.json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## What it is for
|
|
36
|
+
|
|
37
|
+
- Stereo call recordings where each party has a channel (agent / customer)
|
|
38
|
+
- Mono mixed calls when you install the optional diarization extra
|
|
39
|
+
- Offline / self-hosted pipelines that must not upload audio to a cloud ASR API
|
|
40
|
+
|
|
41
|
+
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.
|
|
42
|
+
|
|
43
|
+
## Requirements
|
|
44
|
+
|
|
45
|
+
- Python 3.10+
|
|
46
|
+
- [`faster-whisper`](https://github.com/SYSTRAN/faster-whisper) (installed with the package)
|
|
47
|
+
- [`ffmpeg`](https://ffmpeg.org/) on `PATH` for video containers and stereo channel split
|
|
48
|
+
- Optional NVIDIA CUDA for `--device cuda`
|
|
49
|
+
- Optional Hugging Face token for mono speaker diarization (see below)
|
|
50
|
+
|
|
51
|
+
## Speakers
|
|
52
|
+
|
|
53
|
+
**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`.
|
|
54
|
+
|
|
55
|
+
**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.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install "transcripy[diarize]"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
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).
|
|
62
|
+
2. Create a Hugging Face access token.
|
|
63
|
+
3. Export it as `HF_TOKEN` (or `HUGGING_FACE_HUB_TOKEN`).
|
|
64
|
+
|
|
65
|
+
## Defaults
|
|
66
|
+
|
|
67
|
+
| Setting | Default | Notes |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| `model` | `large-v3` | Pass a smaller Whisper name (`medium`, `small`, `tiny`) to trade quality for speed |
|
|
70
|
+
| `language` | auto-detect | Prefer an ISO code such as `en` or `pl` for call center audio |
|
|
71
|
+
| `word_timestamps` | on | Disable with `--no-word-timestamps` / `word_timestamps=False` |
|
|
72
|
+
| `device` | `cpu` | Use `cuda` when a GPU is available |
|
|
73
|
+
|
|
74
|
+
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.
|
|
75
|
+
|
|
76
|
+
## Non-goals (1.0)
|
|
77
|
+
|
|
78
|
+
- Live / streaming microphone transcription
|
|
79
|
+
- Hosted cloud ASR
|
|
80
|
+
- Production HTTP API (the `api/` folder in this repo is experimental; not shipped in the wheel)
|
|
81
|
+
- High-throughput batched GPU pipelines
|
|
82
|
+
- Subtitle tooling for films as the primary use case
|
|
83
|
+
|
|
84
|
+
## Install options
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install transcripy # library + CLI
|
|
88
|
+
pip install "transcripy[diarize]" # + pyannote for mono speakers
|
|
89
|
+
pip install "transcripy[dev]" # tests and the experimental server deps
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
git clone https://github.com/niewolski/TranscriPy.git
|
|
96
|
+
cd TranscriPy
|
|
97
|
+
python -m venv .venv
|
|
98
|
+
source .venv/bin/activate
|
|
99
|
+
pip install -e ".[dev]"
|
|
100
|
+
pytest
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Model weight notices are in [NOTICE](NOTICE).
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "transcripy"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Local transcription of voicebot calls and phone recordings into timed speaker turns."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Bartosz Niewolski" }]
|
|
9
|
+
keywords = [
|
|
10
|
+
"transcription",
|
|
11
|
+
"whisper",
|
|
12
|
+
"faster-whisper",
|
|
13
|
+
"diarization",
|
|
14
|
+
"voicebot",
|
|
15
|
+
"call-center",
|
|
16
|
+
"speech-to-text",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 5 - Production/Stable",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
27
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
28
|
+
]
|
|
29
|
+
dependencies = ["faster-whisper>=1.2.1,<2"]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
diarize = ["pyannote.audio>=3.3"]
|
|
33
|
+
server = [
|
|
34
|
+
"fastapi>=0.104,<1",
|
|
35
|
+
"uvicorn[standard]>=0.24,<1",
|
|
36
|
+
"python-multipart>=0.0.6,<1",
|
|
37
|
+
]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=8",
|
|
40
|
+
"httpx>=0.27,<0.28",
|
|
41
|
+
"fastapi>=0.104,<1",
|
|
42
|
+
"uvicorn[standard]>=0.24,<1",
|
|
43
|
+
"python-multipart>=0.0.6,<1",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://github.com/niewolski/TranscriPy"
|
|
48
|
+
Repository = "https://github.com/niewolski/TranscriPy"
|
|
49
|
+
Issues = "https://github.com/niewolski/TranscriPy/issues"
|
|
50
|
+
Changelog = "https://github.com/niewolski/TranscriPy/blob/main/CHANGELOG.md"
|
|
51
|
+
|
|
52
|
+
[project.scripts]
|
|
53
|
+
transcripy = "transcripy.cli:main"
|
|
54
|
+
|
|
55
|
+
[build-system]
|
|
56
|
+
requires = ["setuptools>=68"]
|
|
57
|
+
build-backend = "setuptools.build_meta"
|
|
58
|
+
|
|
59
|
+
[tool.setuptools.packages.find]
|
|
60
|
+
include = ["transcripy*"]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
64
|
+
pythonpath = ["."]
|
|
65
|
+
markers = [
|
|
66
|
+
"integration: tests that need ffmpeg or real audio fixtures",
|
|
67
|
+
]
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi.testclient import TestClient
|
|
6
|
+
|
|
7
|
+
from api.jobs import JobStore
|
|
8
|
+
from main import app
|
|
9
|
+
from transcripy.models import Segment, Transcript
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RecordingTranscriber:
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self.source: Path | None = None
|
|
15
|
+
self.model: str | None = None
|
|
16
|
+
self.language: str | None = None
|
|
17
|
+
self.device: str | None = None
|
|
18
|
+
|
|
19
|
+
def __call__(
|
|
20
|
+
self,
|
|
21
|
+
source: str | Path,
|
|
22
|
+
*,
|
|
23
|
+
model: str = "large-v3",
|
|
24
|
+
language: str | None = None,
|
|
25
|
+
device: str = "cpu",
|
|
26
|
+
compute_type: str | None = None,
|
|
27
|
+
engine: object | None = None,
|
|
28
|
+
initial_prompt: str | None = None,
|
|
29
|
+
hotwords: str | None = None,
|
|
30
|
+
speakers: bool = False,
|
|
31
|
+
channel_labels=("left", "right"),
|
|
32
|
+
word_timestamps: bool = True,
|
|
33
|
+
on_progress=None,
|
|
34
|
+
) -> Transcript:
|
|
35
|
+
self.source = Path(source)
|
|
36
|
+
assert self.source.is_file()
|
|
37
|
+
self.model = model
|
|
38
|
+
self.language = language
|
|
39
|
+
self.device = device
|
|
40
|
+
self.initial_prompt = initial_prompt
|
|
41
|
+
self.hotwords = hotwords
|
|
42
|
+
self.speakers = speakers
|
|
43
|
+
self.channel_labels = channel_labels
|
|
44
|
+
self.word_timestamps = word_timestamps
|
|
45
|
+
return Transcript.from_segments(
|
|
46
|
+
[Segment(0.0, 1.5, "hello")],
|
|
47
|
+
language=language or "en",
|
|
48
|
+
duration=1.5,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_transcribe_upload_returns_json(monkeypatch):
|
|
53
|
+
recorder = RecordingTranscriber()
|
|
54
|
+
monkeypatch.setattr("api.routes.transcribe", recorder)
|
|
55
|
+
client = TestClient(app)
|
|
56
|
+
|
|
57
|
+
response = client.post(
|
|
58
|
+
"/api/v1/transcribe",
|
|
59
|
+
files={"file": ("call.mp3", b"not-real-audio", "audio/mpeg")},
|
|
60
|
+
data={"language": "en", "model": "tiny", "device": "cpu"},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
assert response.status_code == 200
|
|
64
|
+
assert response.json() == {
|
|
65
|
+
"text": "hello",
|
|
66
|
+
"language": "en",
|
|
67
|
+
"duration": 1.5,
|
|
68
|
+
"segments": [{"start": 0.0, "end": 1.5, "text": "hello", "speaker": None}],
|
|
69
|
+
"turns": [{"start": 0.0, "end": 1.5, "text": "hello", "speaker": None}],
|
|
70
|
+
}
|
|
71
|
+
assert recorder.model == "tiny"
|
|
72
|
+
assert recorder.language == "en"
|
|
73
|
+
assert recorder.device == "cpu"
|
|
74
|
+
assert recorder.source is not None
|
|
75
|
+
assert not recorder.source.exists()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_transcribe_upload_can_return_subtitles(monkeypatch):
|
|
79
|
+
monkeypatch.setattr("api.routes.transcribe", RecordingTranscriber())
|
|
80
|
+
client = TestClient(app)
|
|
81
|
+
upload = {"file": ("film.mp4", b"not-real-video", "video/mp4")}
|
|
82
|
+
|
|
83
|
+
srt = client.post("/api/v1/transcribe?format=srt", files=upload)
|
|
84
|
+
vtt = client.post("/api/v1/transcribe?format=vtt", files=upload)
|
|
85
|
+
text = client.post("/api/v1/transcribe?format=txt", files=upload)
|
|
86
|
+
|
|
87
|
+
assert srt.status_code == 200
|
|
88
|
+
assert srt.text == "1\n00:00:00,000 --> 00:00:01,500\nhello\n"
|
|
89
|
+
assert srt.headers["content-type"].startswith("application/x-subrip")
|
|
90
|
+
assert vtt.text.startswith("WEBVTT\n")
|
|
91
|
+
assert text.text == "hello"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_speakers_flag_is_forwarded(monkeypatch):
|
|
95
|
+
recorder = RecordingTranscriber()
|
|
96
|
+
monkeypatch.setattr("api.routes.transcribe", recorder)
|
|
97
|
+
client = TestClient(app)
|
|
98
|
+
|
|
99
|
+
response = client.post(
|
|
100
|
+
"/api/v1/transcribe",
|
|
101
|
+
files={"file": ("call.wav", b"stereo", "audio/wav")},
|
|
102
|
+
data={"speakers": "true"},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
assert response.status_code == 200
|
|
106
|
+
assert recorder.speakers is True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_empty_upload_is_rejected(monkeypatch):
|
|
110
|
+
monkeypatch.setattr("api.routes.transcribe", RecordingTranscriber())
|
|
111
|
+
client = TestClient(app)
|
|
112
|
+
|
|
113
|
+
response = client.post(
|
|
114
|
+
"/api/v1/transcribe",
|
|
115
|
+
files={"file": ("call.mp3", b"", "audio/mpeg")},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
assert response.status_code == 400
|
|
119
|
+
assert response.json()["detail"] == "file is empty"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_unknown_format_is_rejected():
|
|
123
|
+
client = TestClient(app)
|
|
124
|
+
|
|
125
|
+
response = client.post(
|
|
126
|
+
"/api/v1/transcribe?format=pdf",
|
|
127
|
+
files={"file": ("call.mp3", b"audio", "audio/mpeg")},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
assert response.status_code == 400
|
|
131
|
+
assert response.json()["detail"] == "format must be json, txt, srt, or vtt"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_job_returns_before_transcription_finishes(monkeypatch):
|
|
135
|
+
release = threading.Event()
|
|
136
|
+
reported = threading.Event()
|
|
137
|
+
recorder = RecordingTranscriber()
|
|
138
|
+
|
|
139
|
+
def slow(*args, on_progress=None, **kwargs):
|
|
140
|
+
if on_progress is not None:
|
|
141
|
+
on_progress(0.4, 2)
|
|
142
|
+
reported.set()
|
|
143
|
+
release.wait(2)
|
|
144
|
+
return recorder(*args, on_progress=on_progress, **kwargs)
|
|
145
|
+
|
|
146
|
+
monkeypatch.setattr("api.routes.jobs", JobStore(slow))
|
|
147
|
+
client = TestClient(app)
|
|
148
|
+
|
|
149
|
+
response = client.post(
|
|
150
|
+
"/api/v1/jobs?format=srt",
|
|
151
|
+
files={"file": ("film.mp4", b"video", "video/mp4")},
|
|
152
|
+
data={"language": "en", "model": "tiny", "device": "cpu"},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
assert response.status_code == 202
|
|
156
|
+
job_id = response.json()["id"]
|
|
157
|
+
assert response.json()["status"] == "queued"
|
|
158
|
+
assert reported.wait(2)
|
|
159
|
+
running = client.get(f"/api/v1/jobs/{job_id}").json()
|
|
160
|
+
assert running["status"] == "running"
|
|
161
|
+
assert running["progress"] == 0.4
|
|
162
|
+
assert running["segments_done"] == 2
|
|
163
|
+
assert client.get(f"/api/v1/jobs/{job_id}/result").status_code == 409
|
|
164
|
+
|
|
165
|
+
release.set()
|
|
166
|
+
finished = _wait_for_job(client, job_id)
|
|
167
|
+
|
|
168
|
+
assert finished["status"] == "done"
|
|
169
|
+
assert finished["error"] is None
|
|
170
|
+
assert finished["progress"] == 1.0
|
|
171
|
+
assert finished["segments_done"] == 1
|
|
172
|
+
result = client.get(f"/api/v1/jobs/{job_id}/result")
|
|
173
|
+
assert result.status_code == 200
|
|
174
|
+
assert result.text == "1\n00:00:00,000 --> 00:00:01,500\nhello\n"
|
|
175
|
+
assert recorder.model == "tiny"
|
|
176
|
+
assert recorder.language == "en"
|
|
177
|
+
assert recorder.source is not None
|
|
178
|
+
assert not recorder.source.exists()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_failed_job_reports_the_error_and_deletes_the_file(monkeypatch):
|
|
182
|
+
seen: dict[str, Path] = {}
|
|
183
|
+
|
|
184
|
+
def fail(source, **kwargs):
|
|
185
|
+
seen["path"] = Path(source)
|
|
186
|
+
raise RuntimeError("model missing")
|
|
187
|
+
|
|
188
|
+
monkeypatch.setattr("api.routes.jobs", JobStore(fail))
|
|
189
|
+
client = TestClient(app)
|
|
190
|
+
created = client.post(
|
|
191
|
+
"/api/v1/jobs",
|
|
192
|
+
files={"file": ("call.mp3", b"audio", "audio/mpeg")},
|
|
193
|
+
)
|
|
194
|
+
job_id = created.json()["id"]
|
|
195
|
+
finished = _wait_for_job(client, job_id)
|
|
196
|
+
|
|
197
|
+
assert finished["status"] == "failed"
|
|
198
|
+
assert finished["error"] == "model missing"
|
|
199
|
+
result = client.get(f"/api/v1/jobs/{job_id}/result")
|
|
200
|
+
assert result.status_code == 500
|
|
201
|
+
assert result.json()["detail"] == "model missing"
|
|
202
|
+
assert not seen["path"].exists()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_unknown_job_is_not_found():
|
|
206
|
+
client = TestClient(app)
|
|
207
|
+
|
|
208
|
+
assert client.get("/api/v1/jobs/missing").status_code == 404
|
|
209
|
+
assert client.get("/api/v1/jobs/missing/result").status_code == 404
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_job_rejects_an_empty_file(monkeypatch):
|
|
213
|
+
monkeypatch.setattr("api.routes.jobs", JobStore(RecordingTranscriber()))
|
|
214
|
+
client = TestClient(app)
|
|
215
|
+
|
|
216
|
+
response = client.post(
|
|
217
|
+
"/api/v1/jobs",
|
|
218
|
+
files={"file": ("call.mp3", b"", "audio/mpeg")},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
assert response.status_code == 400
|
|
222
|
+
assert response.json()["detail"] == "file is empty"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _wait_for_job(client: TestClient, job_id: str) -> dict:
|
|
226
|
+
last = {}
|
|
227
|
+
for _ in range(100):
|
|
228
|
+
last = client.get(f"/api/v1/jobs/{job_id}").json()
|
|
229
|
+
if last["status"] in {"done", "failed"}:
|
|
230
|
+
return last
|
|
231
|
+
time.sleep(0.01)
|
|
232
|
+
raise AssertionError(last)
|