webinar-transcriber 1.3.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.
Files changed (70) hide show
  1. webinar_transcriber/__init__.py +27 -0
  2. webinar_transcriber/__main__.py +8 -0
  3. webinar_transcriber/_env.py +56 -0
  4. webinar_transcriber/_version.py +24 -0
  5. webinar_transcriber/asr/__init__.py +22 -0
  6. webinar_transcriber/asr/carryover.py +21 -0
  7. webinar_transcriber/asr/config.py +20 -0
  8. webinar_transcriber/asr/transcriber.py +362 -0
  9. webinar_transcriber/asr/windows.py +43 -0
  10. webinar_transcriber/assets/__init__.py +1 -0
  11. webinar_transcriber/assets/silero_vad.onnx +0 -0
  12. webinar_transcriber/cli.py +132 -0
  13. webinar_transcriber/diagnostics.py +41 -0
  14. webinar_transcriber/diarization/__init__.py +76 -0
  15. webinar_transcriber/diarization/sherpa_diarizer.py +361 -0
  16. webinar_transcriber/export/__init__.py +9 -0
  17. webinar_transcriber/export/docx_report.py +131 -0
  18. webinar_transcriber/export/formatting.py +56 -0
  19. webinar_transcriber/export/json_report.py +22 -0
  20. webinar_transcriber/export/markdown.py +59 -0
  21. webinar_transcriber/io.py +15 -0
  22. webinar_transcriber/llm/__init__.py +19 -0
  23. webinar_transcriber/llm/processor.py +270 -0
  24. webinar_transcriber/llm/prompts.py +57 -0
  25. webinar_transcriber/llm/providers.py +96 -0
  26. webinar_transcriber/llm/types.py +37 -0
  27. webinar_transcriber/llm/utils.py +238 -0
  28. webinar_transcriber/media.py +159 -0
  29. webinar_transcriber/models.py +278 -0
  30. webinar_transcriber/normalized_audio.py +123 -0
  31. webinar_transcriber/paths.py +103 -0
  32. webinar_transcriber/processor.py +529 -0
  33. webinar_transcriber/py.typed +0 -0
  34. webinar_transcriber/segmentation.py +152 -0
  35. webinar_transcriber/structure.py +197 -0
  36. webinar_transcriber/tests/__init__.py +1 -0
  37. webinar_transcriber/tests/conftest.py +371 -0
  38. webinar_transcriber/tests/fixtures/sample-audio.mp3 +0 -0
  39. webinar_transcriber/tests/fixtures/sample-video.mp4 +0 -0
  40. webinar_transcriber/tests/fixtures/speech-sample.wav +0 -0
  41. webinar_transcriber/tests/test_asr.py +515 -0
  42. webinar_transcriber/tests/test_cli.py +314 -0
  43. webinar_transcriber/tests/test_coalesce.py +139 -0
  44. webinar_transcriber/tests/test_diarization.py +677 -0
  45. webinar_transcriber/tests/test_export.py +401 -0
  46. webinar_transcriber/tests/test_llm.py +752 -0
  47. webinar_transcriber/tests/test_llm_utils.py +133 -0
  48. webinar_transcriber/tests/test_media.py +191 -0
  49. webinar_transcriber/tests/test_models.py +136 -0
  50. webinar_transcriber/tests/test_normalized_audio.py +381 -0
  51. webinar_transcriber/tests/test_paths.py +66 -0
  52. webinar_transcriber/tests/test_processor.py +866 -0
  53. webinar_transcriber/tests/test_reconciliation.py +146 -0
  54. webinar_transcriber/tests/test_structure.py +339 -0
  55. webinar_transcriber/tests/test_ui.py +141 -0
  56. webinar_transcriber/tests/test_version.py +28 -0
  57. webinar_transcriber/tests/test_video_pipeline.py +207 -0
  58. webinar_transcriber/text_utils.py +38 -0
  59. webinar_transcriber/transcript/__init__.py +8 -0
  60. webinar_transcriber/transcript/coalesce.py +85 -0
  61. webinar_transcriber/transcript/reconcile.py +99 -0
  62. webinar_transcriber/ui.py +181 -0
  63. webinar_transcriber/video/__init__.py +15 -0
  64. webinar_transcriber/video/scenes.py +214 -0
  65. webinar_transcriber-1.3.0.dist-info/METADATA +335 -0
  66. webinar_transcriber-1.3.0.dist-info/RECORD +70 -0
  67. webinar_transcriber-1.3.0.dist-info/WHEEL +4 -0
  68. webinar_transcriber-1.3.0.dist-info/entry_points.txt +2 -0
  69. webinar_transcriber-1.3.0.dist-info/licenses/LICENSE +21 -0
  70. webinar_transcriber-1.3.0.dist-info/licenses/LICENSE-3RDPARTY.md +27 -0
@@ -0,0 +1,27 @@
1
+ """Project metadata for webinar-transcriber."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ __all__ = ["__version__", "get_version"]
8
+
9
+ try:
10
+ from webinar_transcriber import _version
11
+ except ModuleNotFoundError: # pragma: no cover
12
+ _source_version: str | None = None
13
+ else:
14
+ _source_version = _version.__version__
15
+
16
+
17
+ def get_version() -> str:
18
+ """Return the generated source version or installed package version."""
19
+ if _source_version is not None:
20
+ return _source_version
21
+ try:
22
+ return version("webinar-transcriber")
23
+ except PackageNotFoundError:
24
+ return "0.0.0"
25
+
26
+
27
+ __version__ = get_version()
@@ -0,0 +1,8 @@
1
+ """Module entry point for `python -m webinar_transcriber`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from webinar_transcriber.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -0,0 +1,56 @@
1
+ """Environment-variable accessors and optional-dependency loaders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import os
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterator
13
+ from types import ModuleType
14
+
15
+ LLM_PROVIDER_ENV = "LLM_PROVIDER"
16
+ TQDM_DISABLE_ENV = "TQDM_DISABLE"
17
+ WEBINAR_DIARIZATION_CACHE_DIR_ENV = "WEBINAR_DIARIZATION_CACHE_DIR"
18
+
19
+
20
+ def load_sherpa_onnx() -> ModuleType | None:
21
+ """Return the imported sherpa-onnx module, or None when the optional wheel is absent.
22
+
23
+ Shared by speech-region detection and speaker diarization, which both depend on sherpa-onnx.
24
+
25
+ Returns:
26
+ ModuleType | None: The sherpa-onnx module, or None if it is not installed.
27
+ """
28
+ try:
29
+ return importlib.import_module("sherpa_onnx")
30
+ except ImportError: # pragma: no cover - optional wheel/import boundary
31
+ return None
32
+
33
+
34
+ def llm_provider_name() -> str:
35
+ """Return the configured LLM provider name."""
36
+ return os.environ.get(LLM_PROVIDER_ENV, "openai").strip().casefold()
37
+
38
+
39
+ def diarization_cache_dir() -> Path | None:
40
+ """Return the configured diarization cache directory, if any."""
41
+ value = os.environ.get(WEBINAR_DIARIZATION_CACHE_DIR_ENV)
42
+ return Path(value).expanduser() if value else None
43
+
44
+
45
+ @contextmanager
46
+ def temporary_environment_variable(name: str, value: str) -> Iterator[None]:
47
+ """Temporarily set one environment variable."""
48
+ previous = os.environ.get(name)
49
+ os.environ[name] = value
50
+ try:
51
+ yield
52
+ finally:
53
+ if previous is None:
54
+ os.environ.pop(name, None)
55
+ else:
56
+ os.environ[name] = previous
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.3.0'
22
+ __version_tuple__ = version_tuple = (1, 3, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,22 @@
1
+ """ASR adapter built on top of the whisper.cpp C API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from webinar_transcriber.asr.config import (
6
+ ASR_BACKEND_NAME,
7
+ WHISPER_CPP_MODEL_EXAMPLE,
8
+ WHISPER_CPP_MODEL_FILENAME,
9
+ default_asr_threads,
10
+ )
11
+ from webinar_transcriber.asr.transcriber import AsrProcessingError, WhisperCppTranscriber
12
+ from webinar_transcriber.asr.windows import plan_inference_windows
13
+
14
+ __all__ = [
15
+ "ASR_BACKEND_NAME",
16
+ "WHISPER_CPP_MODEL_EXAMPLE",
17
+ "WHISPER_CPP_MODEL_FILENAME",
18
+ "AsrProcessingError",
19
+ "WhisperCppTranscriber",
20
+ "default_asr_threads",
21
+ "plan_inference_windows",
22
+ ]
@@ -0,0 +1,21 @@
1
+ """Prompt-carryover helpers for adjacent whisper inference windows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING
7
+
8
+ from webinar_transcriber.asr.config import CARRYOVER_MAX_CHARS
9
+
10
+ if TYPE_CHECKING:
11
+ from webinar_transcriber.models import DecodedWindow
12
+
13
+ _CARRYOVER_WHITESPACE = re.compile(r"\s+")
14
+
15
+
16
+ def build_prompt_carryover(decoded_window: DecodedWindow) -> str:
17
+ """Return a bounded prompt suffix for the next window; empty when the text is empty."""
18
+ cleaned = _CARRYOVER_WHITESPACE.sub(" ", decoded_window.text.strip())
19
+ if len(cleaned) <= CARRYOVER_MAX_CHARS:
20
+ return cleaned
21
+ return cleaned[-CARRYOVER_MAX_CHARS:].lstrip()
@@ -0,0 +1,20 @@
1
+ """ASR configuration, defaults, and host-capability helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ ASR_BACKEND_NAME = "whisper.cpp"
8
+ WHISPER_CPP_MODEL_FILENAME = "large-v3-turbo"
9
+ WHISPER_CPP_MODEL_EXAMPLE = "models/whisper-cpp/ggml-large-v3-turbo.bin"
10
+ CARRYOVER_MAX_CHARS = 300
11
+ WHISPER_ENTROPY_THOLD = 2.4
12
+ WHISPER_LOGPROB_THOLD = -1.0
13
+ WHISPER_NO_SPEECH_THOLD = 0.6
14
+ WHISPER_TEMPERATURE_INC = 0.2
15
+ DEFAULT_MAX_ASR_THREADS = 8
16
+
17
+
18
+ def default_asr_threads() -> int:
19
+ """Return the preferred default whisper.cpp thread count for this host."""
20
+ return min(max(1, os.cpu_count() or 4), DEFAULT_MAX_ASR_THREADS)
@@ -0,0 +1,362 @@
1
+ """ASR adapter built on top of pywhispercpp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import io
7
+ import os
8
+ import re
9
+ import sys
10
+ from contextlib import contextmanager, redirect_stderr
11
+ from dataclasses import replace
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any, Self
14
+
15
+ import numpy as np
16
+
17
+ from webinar_transcriber._env import TQDM_DISABLE_ENV, temporary_environment_variable
18
+ from webinar_transcriber.asr.carryover import build_prompt_carryover
19
+ from webinar_transcriber.asr.config import (
20
+ WHISPER_CPP_MODEL_EXAMPLE,
21
+ WHISPER_CPP_MODEL_FILENAME,
22
+ WHISPER_ENTROPY_THOLD,
23
+ WHISPER_LOGPROB_THOLD,
24
+ WHISPER_NO_SPEECH_THOLD,
25
+ WHISPER_TEMPERATURE_INC,
26
+ )
27
+ from webinar_transcriber.models import DecodedWindow, TranscriptSegment
28
+ from webinar_transcriber.normalized_audio import sample_index_for_time
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Callable, Iterator
32
+ from types import TracebackType
33
+
34
+ from pywhispercpp.model import Model
35
+
36
+ from webinar_transcriber.models import InferenceWindow
37
+
38
+ GPU_BACKEND_PATTERN = re.compile(r"(?i)\b(metal|mtl|cuda)\b[^|]*?(?:=|:)\s*(?:1|true)")
39
+ _WHISPER_TICKS_PER_SECOND = 100.0
40
+
41
+
42
+ def _missing_model_error_message(model_path: Path) -> str:
43
+ model_path_text = str(model_path)
44
+ example_path = WHISPER_CPP_MODEL_EXAMPLE
45
+ location_hint = f"Download a whisper.cpp model there, or use --asr-model {example_path}."
46
+ return (
47
+ f"whisper.cpp model file does not exist: {model_path_text}. "
48
+ f"{location_hint} See README.md for model setup details."
49
+ )
50
+
51
+
52
+ def _model_prepare_error_message(model_name: str) -> str:
53
+ return (
54
+ f"Could not prepare whisper.cpp model '{model_name}'. "
55
+ "pywhispercpp accepts model identifiers such as 'large-v3-turbo' or local ggml model "
56
+ f"paths such as {WHISPER_CPP_MODEL_EXAMPLE}."
57
+ )
58
+
59
+
60
+ def _model_cls() -> type[Model]:
61
+ return importlib.import_module("pywhispercpp.model").Model
62
+
63
+
64
+ def _looks_like_model_path(model_name: str) -> bool:
65
+ model_path = Path(model_name).expanduser()
66
+ return model_path.suffix == ".bin" or model_path.parent != Path()
67
+
68
+
69
+ @contextmanager
70
+ def _disable_tqdm_progress() -> Iterator[None]:
71
+ """Suppress pywhispercpp download progress while constructing a model."""
72
+ # pywhispercpp does not expose a per-call progress flag for model downloads;
73
+ # tqdm reads TQDM_DISABLE when the progress bar is created.
74
+ with temporary_environment_variable(TQDM_DISABLE_ENV, "1"), redirect_stderr(io.StringIO()):
75
+ yield
76
+
77
+
78
+ @contextmanager
79
+ def _redirect_native_output(log_path: Path | None) -> Iterator[None]:
80
+ if log_path is None:
81
+ yield
82
+ return
83
+
84
+ log_path.parent.mkdir(parents=True, exist_ok=True)
85
+ sys.stdout.flush()
86
+ sys.stderr.flush()
87
+ with log_path.open("a", encoding="utf-8") as log_file:
88
+ saved_fds = _duplicated_output_fds()
89
+ try:
90
+ for fd, _saved_fd in saved_fds:
91
+ os.dup2(log_file.fileno(), fd)
92
+ yield
93
+ finally:
94
+ sys.stdout.flush()
95
+ sys.stderr.flush()
96
+ for fd, saved_fd in saved_fds:
97
+ os.dup2(saved_fd, fd)
98
+ os.close(saved_fd)
99
+
100
+
101
+ def _duplicated_output_fds() -> list[tuple[int, int]]:
102
+ saved_fds: list[tuple[int, int]] = []
103
+ for fd in _output_fds():
104
+ try:
105
+ saved_fds.append((fd, os.dup(fd)))
106
+ except OSError: # pragma: no cover - defensive for invalid inherited descriptors
107
+ continue
108
+ return saved_fds
109
+
110
+
111
+ def _output_fds() -> list[int]:
112
+ fds: list[int] = [1, 2]
113
+ for stream in (sys.stdout, sys.stderr):
114
+ try:
115
+ fd = stream.fileno()
116
+ except ( # pragma: no cover - defensive for streams without OS file descriptors
117
+ AttributeError,
118
+ OSError,
119
+ io.UnsupportedOperation,
120
+ ):
121
+ continue
122
+ if fd not in fds:
123
+ fds.append(fd)
124
+ return fds
125
+
126
+
127
+ class AsrProcessingError(RuntimeError):
128
+ """Raised when the whisper.cpp ASR adapter cannot prepare or run."""
129
+
130
+
131
+ class WhisperCppTranscriber:
132
+ """ASR implementation using pywhispercpp."""
133
+
134
+ def __init__(
135
+ self,
136
+ model_name: str | None = None,
137
+ *,
138
+ threads: int,
139
+ language: str | None = None,
140
+ log_path: Path | None = None,
141
+ ) -> None:
142
+ """Initialize the whisper.cpp transcriber wrapper."""
143
+ self._model_name = model_name or WHISPER_CPP_MODEL_FILENAME
144
+ self._threads = threads
145
+ self._language = language.strip() if language else None
146
+ self._log_path = log_path
147
+ self._model: Model | None = None
148
+
149
+ @property
150
+ def model_name(self) -> str:
151
+ """Return the configured model identifier or explicit local path."""
152
+ return self._model_name
153
+
154
+ @property
155
+ def device_name(self) -> str:
156
+ """Return the detected runtime backend name."""
157
+ if (system_info := self.system_info) is None:
158
+ return "auto"
159
+ return device_name_from_system_info(system_info)
160
+
161
+ def __str__(self) -> str:
162
+ """Return the model and runtime device description."""
163
+ return f"{self.model_name} | {self.device_name}"
164
+
165
+ @property
166
+ def system_info(self) -> str | None:
167
+ """Return the whisper.cpp runtime system info string when available."""
168
+ if self._model is None:
169
+ return None
170
+ return self._model.system_info()
171
+
172
+ def __enter__(self) -> Self:
173
+ """Return the transcriber as a context manager."""
174
+ return self
175
+
176
+ def set_log_path(self, log_path: Path) -> None:
177
+ """Set the whisper.cpp native log destination for future model loads."""
178
+ self._log_path = log_path
179
+
180
+ def prepare_model(self) -> None:
181
+ """Resolve the model and initialize one pywhispercpp model instance.
182
+
183
+ Raises:
184
+ AsrProcessingError: If model resolution or model creation fails.
185
+ """
186
+ self._model_name = self._resolve_model_name()
187
+ self.close()
188
+ model_kwargs: dict[str, Any] = {
189
+ "n_threads": self._threads,
190
+ "print_realtime": False,
191
+ "print_progress": False,
192
+ "no_context": True,
193
+ "split_on_word": False,
194
+ "entropy_thold": WHISPER_ENTROPY_THOLD,
195
+ "logprob_thold": WHISPER_LOGPROB_THOLD,
196
+ "no_speech_thold": WHISPER_NO_SPEECH_THOLD,
197
+ "temperature_inc": WHISPER_TEMPERATURE_INC,
198
+ }
199
+ try:
200
+ with _redirect_native_output(self._log_path), _disable_tqdm_progress():
201
+ model = _model_cls()(self._model_name, **model_kwargs)
202
+ except Exception as ex:
203
+ raise AsrProcessingError(_model_prepare_error_message(self._model_name)) from ex
204
+ # pywhispercpp keeps the native whisper context in Model._ctx and leaves it None when
205
+ # initialization failed without raising. A missing attribute (future pywhispercpp
206
+ # versions) is treated as healthy; only a present-but-None context is a failed init.
207
+ if getattr(model, "_ctx", "missing") is None:
208
+ raise AsrProcessingError(_model_prepare_error_message(self._model_name))
209
+ self._model = model
210
+
211
+ def transcribe_inference_windows(
212
+ self,
213
+ audio_samples: np.ndarray,
214
+ windows: list[InferenceWindow],
215
+ *,
216
+ progress_callback: Callable[[int, int], None] | None = None,
217
+ warning_callback: Callable[[str], None] | None = None,
218
+ ) -> list[DecodedWindow]:
219
+ """Decode ordered inference windows into transcript segments.
220
+
221
+ Returns:
222
+ list[DecodedWindow]: The decoded windows in deterministic order.
223
+ """
224
+ model = self._ensure_model()
225
+ ordered_windows = sorted(
226
+ windows, key=lambda item: (item.start_sec, item.end_sec, item.region_index, item.id)
227
+ )
228
+ forced_language = self._language
229
+ language_hint: str | None = forced_language
230
+ carryover_prompt = ""
231
+ decoded_windows: list[DecodedWindow] = []
232
+ decoded_segment_count = 0
233
+ previous_region_index: int | None = None
234
+
235
+ for window_index, window in enumerate(ordered_windows, start=1):
236
+ if forced_language is None and previous_region_index != window.region_index:
237
+ language_hint = None
238
+ decoded_window = self._transcribe_window(
239
+ model,
240
+ audio_samples,
241
+ window,
242
+ prompt=carryover_prompt,
243
+ language_hint=language_hint,
244
+ warning_callback=warning_callback,
245
+ )
246
+ decoded_windows.append(replace(decoded_window, input_prompt=carryover_prompt or None))
247
+ decoded_segment_count += len(decoded_window.segments)
248
+ next_carryover = build_prompt_carryover(decoded_window)
249
+ if forced_language is None:
250
+ language_hint = decoded_window.detected_language
251
+ previous_region_index = window.region_index
252
+ carryover_prompt = next_carryover
253
+ if progress_callback is not None:
254
+ progress_callback(window_index, decoded_segment_count)
255
+ return decoded_windows
256
+
257
+ def close(self) -> None:
258
+ """Release any active pywhispercpp model resources."""
259
+ if self._model is None:
260
+ return
261
+ with _redirect_native_output(self._log_path):
262
+ # pywhispercpp exposes native cleanup through Model.__del__ rather than a
263
+ # public close/free method. Dropping the adapter's reference runs that
264
+ # destructor immediately on CPython unless another reference is held.
265
+ self._model = None
266
+
267
+ def __exit__(
268
+ self,
269
+ exc_type: type[BaseException] | None,
270
+ exc: BaseException | None,
271
+ traceback: TracebackType | None,
272
+ ) -> None:
273
+ """Close the transcriber when leaving a context manager block."""
274
+ self.close()
275
+
276
+ def _ensure_model(self) -> Model:
277
+ if self._model is None:
278
+ self.prepare_model()
279
+ if self._model is None:
280
+ raise AsrProcessingError(
281
+ "pywhispercpp model was not initialized during model preparation."
282
+ )
283
+ return self._model
284
+
285
+ def _resolve_model_name(self) -> str:
286
+ if not _looks_like_model_path(self._model_name):
287
+ return self._model_name
288
+ configured_model_path = Path(self._model_name).expanduser()
289
+ if not configured_model_path.exists():
290
+ raise AsrProcessingError(_missing_model_error_message(configured_model_path))
291
+ return str(configured_model_path)
292
+
293
+ def _transcribe_window(
294
+ self,
295
+ model: Model,
296
+ audio_samples: np.ndarray,
297
+ window: InferenceWindow,
298
+ *,
299
+ prompt: str,
300
+ language_hint: str | None,
301
+ warning_callback: Callable[[str], None] | None,
302
+ ) -> DecodedWindow:
303
+ start_index = sample_index_for_time(window.start_sec)
304
+ end_index = min(len(audio_samples), sample_index_for_time(window.end_sec))
305
+ window_samples = np.ascontiguousarray(
306
+ audio_samples[start_index:end_index], dtype=np.float32
307
+ )
308
+ if window_samples.size == 0:
309
+ return DecodedWindow(window=window, detected_language=language_hint)
310
+
311
+ detected_language = language_hint
312
+ if detected_language is None:
313
+ try:
314
+ # pywhispercpp returns (("ru", 0.99), {"ru": 0.99, ...}).
315
+ detected_language = model.auto_detect_language(
316
+ window_samples, n_threads=self._threads
317
+ )[0][0]
318
+ except (RuntimeError, ValueError, IndexError):
319
+ if warning_callback is not None:
320
+ warning_callback(
321
+ f"whisper.cpp language detection failed for {window.id}; "
322
+ "transcribing without an explicit language hint."
323
+ )
324
+
325
+ transcribe_kwargs: dict[str, str] = {}
326
+ if prompt:
327
+ transcribe_kwargs["initial_prompt"] = prompt
328
+ if detected_language is not None:
329
+ transcribe_kwargs["language"] = detected_language
330
+ try:
331
+ # ty can't match the dynamically built **transcribe_kwargs to transcribe()'s typed
332
+ # parameters; the call is valid at runtime and the return type still infers.
333
+ raw_segments = model.transcribe(window_samples, **transcribe_kwargs) # type: ignore
334
+ except Exception as ex:
335
+ raise AsrProcessingError(f"whisper.cpp inference failed for {window.id}.") from ex
336
+
337
+ segments: list[TranscriptSegment] = []
338
+ for segment_index, raw_segment in enumerate(raw_segments):
339
+ seg_start = window.start_sec + (float(raw_segment.t0) / _WHISPER_TICKS_PER_SECOND)
340
+ seg_end = window.start_sec + (float(raw_segment.t1) / _WHISPER_TICKS_PER_SECOND)
341
+ segments.append(
342
+ TranscriptSegment(
343
+ id=f"{window.id}-segment-{segment_index + 1}",
344
+ text=str(raw_segment.text).strip(),
345
+ start_sec=max(window.start_sec, seg_start),
346
+ end_sec=min(window.end_sec, max(seg_start, seg_end)),
347
+ )
348
+ )
349
+ return DecodedWindow(
350
+ window=window,
351
+ text=" ".join(segment.text for segment in segments if segment.text).strip(),
352
+ segments=segments,
353
+ detected_language=detected_language,
354
+ )
355
+
356
+
357
+ def device_name_from_system_info(system_info: str) -> str:
358
+ """Return a user-facing ASR device name from pywhispercpp system info."""
359
+ if match := GPU_BACKEND_PATTERN.search(system_info):
360
+ backend_name = match.group(1).lower()
361
+ return "metal" if backend_name == "mtl" else backend_name
362
+ return "cpu"
@@ -0,0 +1,43 @@
1
+ """Whisper inference-window planning over detected speech regions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from webinar_transcriber.models import InferenceWindow
8
+
9
+ if TYPE_CHECKING:
10
+ from webinar_transcriber.models import SpeechRegion
11
+
12
+ INFERENCE_WINDOW_DURATION_SEC = 28.0
13
+ INFERENCE_WINDOW_OVERLAP_SEC = 2.0
14
+
15
+
16
+ def plan_inference_windows(speech_regions: list[SpeechRegion]) -> list[InferenceWindow]:
17
+ """Plan bounded Whisper inference windows from speech regions.
18
+
19
+ Returns:
20
+ list[InferenceWindow]: Ordered windows with overlap inside long regions.
21
+ """
22
+ windows: list[InferenceWindow] = []
23
+
24
+ for region_index, region in enumerate(speech_regions):
25
+ if region.duration_sec <= 0:
26
+ continue
27
+
28
+ window_start_sec = region.start_sec
29
+ while window_start_sec < region.end_sec:
30
+ window_end_sec = min(region.end_sec, window_start_sec + INFERENCE_WINDOW_DURATION_SEC)
31
+ windows.append(
32
+ InferenceWindow(
33
+ id=f"window-{len(windows) + 1}",
34
+ region_index=region_index,
35
+ start_sec=window_start_sec,
36
+ end_sec=window_end_sec,
37
+ )
38
+ )
39
+ if window_end_sec >= region.end_sec:
40
+ break
41
+ window_start_sec = max(window_start_sec, window_end_sec - INFERENCE_WINDOW_OVERLAP_SEC)
42
+
43
+ return windows
@@ -0,0 +1 @@
1
+ """Packaged runtime model assets."""