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.
@@ -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,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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)