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.
- webinar_transcriber/__init__.py +27 -0
- webinar_transcriber/__main__.py +8 -0
- webinar_transcriber/_env.py +56 -0
- webinar_transcriber/_version.py +24 -0
- webinar_transcriber/asr/__init__.py +22 -0
- webinar_transcriber/asr/carryover.py +21 -0
- webinar_transcriber/asr/config.py +20 -0
- webinar_transcriber/asr/transcriber.py +362 -0
- webinar_transcriber/asr/windows.py +43 -0
- webinar_transcriber/assets/__init__.py +1 -0
- webinar_transcriber/assets/silero_vad.onnx +0 -0
- webinar_transcriber/cli.py +132 -0
- webinar_transcriber/diagnostics.py +41 -0
- webinar_transcriber/diarization/__init__.py +76 -0
- webinar_transcriber/diarization/sherpa_diarizer.py +361 -0
- webinar_transcriber/export/__init__.py +9 -0
- webinar_transcriber/export/docx_report.py +131 -0
- webinar_transcriber/export/formatting.py +56 -0
- webinar_transcriber/export/json_report.py +22 -0
- webinar_transcriber/export/markdown.py +59 -0
- webinar_transcriber/io.py +15 -0
- webinar_transcriber/llm/__init__.py +19 -0
- webinar_transcriber/llm/processor.py +270 -0
- webinar_transcriber/llm/prompts.py +57 -0
- webinar_transcriber/llm/providers.py +96 -0
- webinar_transcriber/llm/types.py +37 -0
- webinar_transcriber/llm/utils.py +238 -0
- webinar_transcriber/media.py +159 -0
- webinar_transcriber/models.py +278 -0
- webinar_transcriber/normalized_audio.py +123 -0
- webinar_transcriber/paths.py +103 -0
- webinar_transcriber/processor.py +529 -0
- webinar_transcriber/py.typed +0 -0
- webinar_transcriber/segmentation.py +152 -0
- webinar_transcriber/structure.py +197 -0
- webinar_transcriber/tests/__init__.py +1 -0
- webinar_transcriber/tests/conftest.py +371 -0
- webinar_transcriber/tests/fixtures/sample-audio.mp3 +0 -0
- webinar_transcriber/tests/fixtures/sample-video.mp4 +0 -0
- webinar_transcriber/tests/fixtures/speech-sample.wav +0 -0
- webinar_transcriber/tests/test_asr.py +515 -0
- webinar_transcriber/tests/test_cli.py +314 -0
- webinar_transcriber/tests/test_coalesce.py +139 -0
- webinar_transcriber/tests/test_diarization.py +677 -0
- webinar_transcriber/tests/test_export.py +401 -0
- webinar_transcriber/tests/test_llm.py +752 -0
- webinar_transcriber/tests/test_llm_utils.py +133 -0
- webinar_transcriber/tests/test_media.py +191 -0
- webinar_transcriber/tests/test_models.py +136 -0
- webinar_transcriber/tests/test_normalized_audio.py +381 -0
- webinar_transcriber/tests/test_paths.py +66 -0
- webinar_transcriber/tests/test_processor.py +866 -0
- webinar_transcriber/tests/test_reconciliation.py +146 -0
- webinar_transcriber/tests/test_structure.py +339 -0
- webinar_transcriber/tests/test_ui.py +141 -0
- webinar_transcriber/tests/test_version.py +28 -0
- webinar_transcriber/tests/test_video_pipeline.py +207 -0
- webinar_transcriber/text_utils.py +38 -0
- webinar_transcriber/transcript/__init__.py +8 -0
- webinar_transcriber/transcript/coalesce.py +85 -0
- webinar_transcriber/transcript/reconcile.py +99 -0
- webinar_transcriber/ui.py +181 -0
- webinar_transcriber/video/__init__.py +15 -0
- webinar_transcriber/video/scenes.py +214 -0
- webinar_transcriber-1.3.0.dist-info/METADATA +335 -0
- webinar_transcriber-1.3.0.dist-info/RECORD +70 -0
- webinar_transcriber-1.3.0.dist-info/WHEEL +4 -0
- webinar_transcriber-1.3.0.dist-info/entry_points.txt +2 -0
- webinar_transcriber-1.3.0.dist-info/licenses/LICENSE +21 -0
- 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,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."""
|
|
Binary file
|