openmd-cli 0.1.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 (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,169 @@
1
+ """Adapter for user-local audio files.
2
+
3
+ This lane is deliberately local and read-only: it resolves audio files into
4
+ ``ContentItem`` records and leaves transcription to an explicit backend
5
+ provider. Raw audio is referenced as a local source and is not copied into the
6
+ package writer's ``assets/`` directory.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from urllib.parse import unquote, urlparse
13
+ import hashlib
14
+
15
+ from openmd.adapters.base import PlatformAdapter
16
+ from openmd.core.detector import MEDIA_EXTENSIONS
17
+ from openmd.core.models import ContentItem, TranscriptResult
18
+
19
+ from .asr import MissingASRBackend, MockASRBackend
20
+
21
+ FASTER_WHISPER_BACKEND = "asr.faster_whisper"
22
+ QWEN3_BACKEND = "asr.qwen3"
23
+ AUTO_BACKEND = "asr.auto"
24
+
25
+ AUDIO_EXTENSIONS: tuple[str, ...] = (
26
+ ".mp3",
27
+ ".mp4",
28
+ ".m4a",
29
+ ".wav",
30
+ ".ogg",
31
+ ".flac",
32
+ ".opus",
33
+ ".aac",
34
+ )
35
+
36
+
37
+ class LocalAudioAdapter(PlatformAdapter):
38
+ """Resolve local audio files or folders into media items."""
39
+
40
+ def __init__(self, platform_id: str = "generic_media", default_backend: str = "asr.fallback") -> None:
41
+ self.id = platform_id
42
+ self.default_backend = default_backend
43
+
44
+ def can_handle(self, input_value: str) -> bool:
45
+ path = local_path_from_input(input_value)
46
+ return path is not None and _is_audio_path(path)
47
+
48
+ def resolve_items(
49
+ self,
50
+ input_value: str,
51
+ *,
52
+ limit: int | None = None,
53
+ all: bool = False,
54
+ ) -> list[ContentItem]:
55
+ path = local_path_from_input(input_value)
56
+ if path is None:
57
+ return [
58
+ _content_item_for_unresolved_input(
59
+ input_value,
60
+ platform=self.id,
61
+ error_code="local_audio_path_unresolved",
62
+ )
63
+ ]
64
+
65
+ if path.is_dir():
66
+ files = sorted(candidate for candidate in path.rglob("*") if _is_audio_path(candidate))
67
+ if limit is not None and not all:
68
+ files = files[: max(0, limit)]
69
+ return [_content_item_for_path(candidate, platform=self.id) for candidate in files]
70
+
71
+ return [_content_item_for_path(path, platform=self.id)]
72
+
73
+ def extract_text(
74
+ self,
75
+ item: ContentItem,
76
+ *,
77
+ backend: str | None = None,
78
+ backend_chain: tuple[str, ...] | None = None,
79
+ allow_fallback: bool = True,
80
+ ) -> TranscriptResult:
81
+ selected = (backend_chain or (backend or self.default_backend,))[0]
82
+ path = _item_path(item)
83
+ if selected == "local_audio.mock":
84
+ return MockASRBackend().transcribe(path)
85
+ if selected in {FASTER_WHISPER_BACKEND, AUTO_BACKEND, QWEN3_BACKEND}:
86
+ from openmd.runtime.asr import get_asr_backend
87
+
88
+ return get_asr_backend(selected).transcribe(path)
89
+ return MissingASRBackend(selected).transcribe(path)
90
+
91
+
92
+ def local_path_from_input(input_value: str) -> Path | None:
93
+ value = input_value.strip()
94
+ if not value:
95
+ return None
96
+
97
+ parsed = urlparse(value)
98
+ if parsed.scheme == "file":
99
+ # urlparse("file:///C:/tmp/a.wav").path -> "/C:/tmp/a.wav".
100
+ raw_path = unquote(parsed.path)
101
+ if parsed.netloc and not raw_path:
102
+ raw_path = parsed.netloc
103
+ if raw_path.startswith("/") and len(raw_path) >= 4 and raw_path[2] == ":":
104
+ raw_path = raw_path[1:]
105
+ return Path(raw_path)
106
+
107
+ if parsed.scheme in {"http", "https"}:
108
+ return None
109
+
110
+ return Path(value)
111
+
112
+
113
+ def _content_item_for_path(path: Path, *, platform: str) -> ContentItem:
114
+ source_ref = path.resolve().as_uri() if path.exists() else str(path)
115
+ return ContentItem(
116
+ platform=platform,
117
+ input_type="single",
118
+ url=source_ref,
119
+ id=_audio_content_id(path),
120
+ title=path.stem,
121
+ source_type="audio",
122
+ raw_meta={
123
+ "profile": "media",
124
+ "permission_scope": "local_file",
125
+ "local_path": str(path),
126
+ "content_id": _audio_content_id(path),
127
+ },
128
+ )
129
+
130
+
131
+ def _content_item_for_unresolved_input(
132
+ input_value: str,
133
+ *,
134
+ platform: str,
135
+ error_code: str,
136
+ ) -> ContentItem:
137
+ return ContentItem(
138
+ platform=platform,
139
+ input_type="single",
140
+ url=input_value,
141
+ source_type="audio",
142
+ raw_meta={
143
+ "profile": "media",
144
+ "permission_scope": "local_file",
145
+ "error_code": error_code,
146
+ },
147
+ )
148
+
149
+
150
+ def _is_audio_path(path: Path) -> bool:
151
+ return path.suffix.lower() in AUDIO_EXTENSIONS or path.suffix.lower() in MEDIA_EXTENSIONS
152
+
153
+
154
+ def _audio_content_id(path: Path) -> str:
155
+ if path.is_file():
156
+ digest = hashlib.sha256()
157
+ with path.open("rb") as handle:
158
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
159
+ digest.update(chunk)
160
+ return digest.hexdigest()[:12]
161
+ return hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:12]
162
+
163
+
164
+ def _item_path(item: ContentItem) -> Path:
165
+ local_path = item.raw_meta.get("local_path") if isinstance(item.raw_meta, dict) else None
166
+ if isinstance(local_path, str) and local_path.strip():
167
+ return Path(local_path)
168
+ path = local_path_from_input(item.url)
169
+ return path or Path(item.url)
@@ -0,0 +1,58 @@
1
+ """Local audio ASR backend abstractions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from pathlib import Path
7
+
8
+ from openmd.core.models import TranscriptResult
9
+ from openmd.core.secrets import sanitize_error_summary
10
+
11
+
12
+ class ASRBackend(ABC):
13
+ name: str
14
+
15
+ @abstractmethod
16
+ def transcribe(self, path: Path) -> TranscriptResult:
17
+ raise NotImplementedError
18
+
19
+
20
+ class MockASRBackend(ASRBackend):
21
+ name = "local_audio.mock"
22
+
23
+ def transcribe(self, path: Path) -> TranscriptResult:
24
+ return TranscriptResult(
25
+ text="[00:00:00] Mock local audio transcript for Openmd package smoke.\n",
26
+ status="verified",
27
+ source_level="asr",
28
+ backend=self.name,
29
+ source_notes=[
30
+ "local audio mock backend only",
31
+ "raw audio file was referenced, not copied into the package",
32
+ ],
33
+ title=path.stem or "local audio",
34
+ metadata={"profile": "media", "asr_backend": self.name},
35
+ )
36
+
37
+
38
+ class MissingASRBackend(ASRBackend):
39
+ def __init__(self, name: str) -> None:
40
+ self.name = name
41
+
42
+ def transcribe(self, path: Path) -> TranscriptResult:
43
+ return TranscriptResult(
44
+ text="",
45
+ status="failed",
46
+ source_level="partial_extraction",
47
+ backend=self.name,
48
+ source_notes=[
49
+ "local audio ASR backend is not installed or not enabled",
50
+ "no external ASR command was attempted",
51
+ ],
52
+ error_code="asr_backend_missing",
53
+ error_summary=sanitize_error_summary(
54
+ f"ASR backend {self.name!r} is not installed or enabled for {path.name}."
55
+ ),
56
+ title=path.stem or "local audio",
57
+ metadata={"profile": "media", "asr_backend": self.name},
58
+ )
@@ -0,0 +1 @@
1
+ """Optional real local-audio backend adapters."""
@@ -0,0 +1,177 @@
1
+ """Pick the local ASR model from the spoken language.
2
+
3
+ Routing follows the 2026-11-07 benchmark (quality 80%, speed 20%), recorded in
4
+ ``docs/benchmarks/asr/``:
5
+
6
+ - Mandarin: Qwen3-ASR (lowest CER, fewest dropped characters) when its GPU stack is
7
+ installed; otherwise Whisper large-v3.
8
+ - English: Whisper large-v3-turbo (best weighted score, 3.5x faster than large-v3).
9
+ - Anything else: Whisper large-v3.
10
+
11
+ The language is detected once with the English model, which is also the fastest one.
12
+ Every choice can be overridden by environment variables listed in ``execution_config``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import gc
18
+ import importlib.util
19
+ import os
20
+ from dataclasses import replace
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from openmd.adapters.local_audio.asr import ASRBackend, MissingASRBackend
25
+ from openmd.adapters.local_audio.backends.faster_whisper import (
26
+ FasterWhisperASRBackend,
27
+ _prepare_windows_dll_paths,
28
+ )
29
+ from openmd.adapters.local_audio.backends.qwen3_asr import Qwen3ASRBackend
30
+ from openmd.core.models import TranscriptResult
31
+ from openmd.runtime.models import ModelNotFoundError, model_label, offline_model_loading, require_model, resolve_model
32
+
33
+ DEFAULT_ENGLISH_MODEL = "large-v3-turbo"
34
+ DEFAULT_GENERAL_MODEL = "large-v3"
35
+
36
+
37
+ class AutoASRBackend(ASRBackend):
38
+ name = "asr.auto"
39
+
40
+ def __init__(self, *, qwen3: Qwen3ASRBackend | None = None) -> None:
41
+ self.english_model = os.getenv("OPENMD_ASR_AUTO_EN_MODEL") or DEFAULT_ENGLISH_MODEL
42
+ self.general_model = os.getenv("OPENMD_FASTER_WHISPER_MODEL") or DEFAULT_GENERAL_MODEL
43
+ self.chinese_engine = os.getenv("OPENMD_ASR_AUTO_ZH_ENGINE") or "qwen3"
44
+ self._qwen3 = qwen3
45
+
46
+ def execution_config(self) -> dict[str, Any]:
47
+ return {
48
+ "english_model": model_label(self.english_model),
49
+ "english_model_origin": resolve_model(self.english_model, family="whisper").origin,
50
+ "general_model": model_label(self.general_model),
51
+ "general_model_origin": resolve_model(self.general_model, family="whisper").origin,
52
+ "chinese_engine": self.chinese_engine,
53
+ "qwen3": (self._qwen3 or Qwen3ASRBackend()).execution_config(),
54
+ "device": os.getenv("OPENMD_FASTER_WHISPER_DEVICE") or "cuda_if_available",
55
+ "local_files_only": True,
56
+ }
57
+
58
+ def is_available(self) -> bool:
59
+ return importlib.util.find_spec("faster_whisper") is not None
60
+
61
+ def transcribe(self, path: Path) -> TranscriptResult:
62
+ source_path = path.expanduser()
63
+ if not source_path.is_file() or not self.is_available():
64
+ return MissingASRBackend(self.name).transcribe(source_path)
65
+ try:
66
+ language, probability = self.detect_language(source_path)
67
+ except ModelNotFoundError as exc:
68
+ from openmd.runtime.asr import failed_result
69
+
70
+ return failed_result(self.name, "asr_model_missing", exc)
71
+ except Exception as exc: # pragma: no cover - dependency/model safety net
72
+ result = FasterWhisperASRBackend(model_size=self.general_model, **self._whisper_device()).transcribe(source_path)
73
+ return self._annotate(result, None, None, f"language detection failed ({type(exc).__name__}); used Whisper {model_label(self.general_model)}")
74
+
75
+ backend, reason = self.choose(language)
76
+ result = backend.transcribe(source_path)
77
+ if result.status != "verified" and isinstance(backend, Qwen3ASRBackend):
78
+ fallback = FasterWhisperASRBackend(model_size=self.general_model, language="zh", **self._whisper_device())
79
+ result = fallback.transcribe(source_path)
80
+ reason += f"; Qwen3-ASR failed, fell back to Whisper {model_label(self.general_model)}"
81
+ return self._annotate(result, language, probability, reason)
82
+
83
+ def choose(self, language: str | None) -> tuple[ASRBackend, str]:
84
+ device = self._whisper_device()
85
+ if language == "zh":
86
+ qwen3 = self._qwen3 or Qwen3ASRBackend()
87
+ if self.chinese_engine == "qwen3" and qwen3.is_available() and self._device()[0] == "cuda":
88
+ return qwen3, "Mandarin: Qwen3-ASR scored the lowest CER"
89
+ reason = "Mandarin: Qwen3-ASR needs its GPU stack, which is unavailable" if self.chinese_engine == "qwen3" else "Mandarin: Whisper requested"
90
+ return FasterWhisperASRBackend(model_size=self.general_model, language="zh", **device), reason
91
+ if language == "en":
92
+ return (
93
+ FasterWhisperASRBackend(model_size=self.english_model, language="en", **device),
94
+ f"English: Whisper {model_label(self.english_model)} had the best quality/speed score",
95
+ )
96
+ return (
97
+ FasterWhisperASRBackend(model_size=self.general_model, language=language, **device),
98
+ f"Language {language or 'unknown'}: Whisper {model_label(self.general_model)} is the general model",
99
+ )
100
+
101
+ def detect_language(self, path: Path) -> tuple[str | None, float | None]:
102
+ model_path = require_model(self.english_model, family="whisper")
103
+ _load_torch_first()
104
+ _prepare_windows_dll_paths()
105
+ from faster_whisper import WhisperModel
106
+
107
+ device, compute_type = self._device()
108
+ with offline_model_loading():
109
+ model = WhisperModel(str(model_path), device=device, compute_type=compute_type, local_files_only=True)
110
+ try:
111
+ _, info = model.transcribe(str(path), vad_filter=True, beam_size=1)
112
+ return getattr(info, "language", None), getattr(info, "language_probability", None)
113
+ finally:
114
+ del model
115
+ gc.collect()
116
+ _free_torch_cache()
117
+
118
+ def _device(self) -> tuple[str, str]:
119
+ device = os.getenv("OPENMD_FASTER_WHISPER_DEVICE")
120
+ compute_type = os.getenv("OPENMD_FASTER_WHISPER_COMPUTE_TYPE")
121
+ if device is None:
122
+ device = "cuda" if _cuda_available() else "cpu"
123
+ if compute_type is None:
124
+ compute_type = "int8_float16" if device == "cuda" else "int8"
125
+ return device, compute_type
126
+
127
+ def _whisper_device(self) -> dict[str, str]:
128
+ device, compute_type = self._device()
129
+ return {"device": device, "compute_type": compute_type}
130
+
131
+ def _annotate(self, result: TranscriptResult, language: str | None, probability: float | None, reason: str) -> TranscriptResult:
132
+ route = {
133
+ "detected_language": language,
134
+ "language_probability": round(probability, 3) if probability is not None else None,
135
+ "selected_backend": result.backend,
136
+ "reason": reason,
137
+ }
138
+ return replace(
139
+ result,
140
+ source_notes=[*result.source_notes, f"asr.auto routed this audio: {reason}."],
141
+ metadata={**result.metadata, "asr_route": route},
142
+ )
143
+
144
+
145
+ def _load_torch_first() -> None:
146
+ """Load torch's cuDNN before CTranslate2 when both share a process.
147
+
148
+ If CTranslate2 loads cuDNN first, Qwen3 on torch later crashes the process with
149
+ "Could not load symbol cudnnGetLibConfig".
150
+ """
151
+ if importlib.util.find_spec("torch") is not None:
152
+ import torch # noqa: F401
153
+
154
+
155
+ def _cuda_available() -> bool:
156
+ try:
157
+ _load_torch_first()
158
+ _prepare_windows_dll_paths()
159
+ import ctranslate2
160
+ import torch
161
+
162
+ return torch.cuda.is_available() and ctranslate2.get_cuda_device_count() > 0
163
+ except Exception:
164
+ return False
165
+
166
+
167
+ def _free_torch_cache() -> None:
168
+ if importlib.util.find_spec("torch") is None:
169
+ return
170
+ import torch
171
+
172
+ if torch.cuda.is_available():
173
+ torch.cuda.empty_cache()
174
+
175
+
176
+ def create_backend() -> AutoASRBackend:
177
+ return AutoASRBackend()
@@ -0,0 +1,285 @@
1
+ """Optional faster-whisper backend for local audio files.
2
+
3
+ The dependency is imported lazily so normal Openmd imports, mock lanes, and
4
+ safe skeleton discovery never download models or run ASR. This backend only
5
+ transcribes user-local files; URL media download/extraction remains a separate
6
+ explicit media lane.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.util
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from openmd.adapters.local_audio.asr import ASRBackend, MissingASRBackend
18
+ from openmd.core.models import TranscriptResult
19
+ from openmd.core.secrets import sanitize_error_summary
20
+ from openmd.runtime.models import ModelNotFoundError, model_label, offline_model_loading, require_model, resolve_model
21
+
22
+
23
+ DEFAULT_MODEL_SIZE = "large-v3"
24
+ DEFAULT_DEVICE = "cpu"
25
+ DEFAULT_COMPUTE_TYPE = "int8"
26
+ DEFAULT_LANGUAGE = None
27
+ DEFAULT_BEAM_SIZE = 5
28
+ DEFAULT_VAD_MIN_SILENCE_MS = 700
29
+ DEFAULT_INITIAL_PROMPT = (
30
+ "这是中文播客或访谈,请保留中文专有名词、英文缩写、数字和人名。"
31
+ "常见词包括:搞钱女孩、大漂亮爱吃肉、梨涡小雨、Leiya、刘六六、Shelly、"
32
+ "小红书、LinkedIn、ETF、SOP、AI、自媒体、创业、投资理财。"
33
+ )
34
+ DEFAULT_HOTWORDS = (
35
+ "搞钱女孩 大漂亮爱吃肉 梨涡小雨 Leiya 刘六六 Shelly "
36
+ "小红书 LinkedIn ETF SOP AI 自媒体 创业 投资理财"
37
+ )
38
+ # Decode settings shared by the transcribe call and the execution fingerprint.
39
+ FIXED_DECODE_OPTIONS: dict[str, Any] = {"best_of": 5, "temperature": 0.0, "vad_filter": True}
40
+ # Keep handles alive: closing or collecting them removes Windows DLL directories.
41
+ _DLL_DIRECTORY_HANDLES: dict[str, Any] = {}
42
+
43
+
44
+ def _prepare_windows_dll_paths() -> None:
45
+ """Expose optional pip-installed NVIDIA runtimes before loading CTranslate2."""
46
+ if sys.platform != "win32":
47
+ return
48
+ spec = importlib.util.find_spec("faster_whisper")
49
+ if spec is None or spec.origin is None:
50
+ return
51
+ nvidia_root = Path(spec.origin).resolve().parent.parent / "nvidia"
52
+ directories = sorted(path.resolve() for path in nvidia_root.glob("*/bin") if path.is_dir())
53
+ # A CUDA torch install ships cuBLAS/cuDNN in torch/lib; reuse them without importing torch.
54
+ torch_lib = nvidia_root.parent / "torch" / "lib"
55
+ if torch_lib.is_dir():
56
+ directories.append(torch_lib.resolve())
57
+ existing = {
58
+ os.path.normcase(os.path.abspath(path))
59
+ for path in os.environ.get("PATH", "").split(os.pathsep)
60
+ if path
61
+ }
62
+ additions: list[str] = []
63
+ for directory in directories:
64
+ value = str(directory)
65
+ key = os.path.normcase(value)
66
+ if key not in _DLL_DIRECTORY_HANDLES:
67
+ _DLL_DIRECTORY_HANDLES[key] = os.add_dll_directory(value)
68
+ if key not in existing:
69
+ additions.append(value)
70
+ existing.add(key)
71
+ if additions:
72
+ os.environ["PATH"] = os.pathsep.join([*additions, os.environ.get("PATH", "")])
73
+
74
+
75
+ class FasterWhisperASRBackend(ASRBackend):
76
+ name = "asr.faster_whisper"
77
+
78
+ def __init__(
79
+ self,
80
+ *,
81
+ model_size: str | None = None,
82
+ device: str | None = None,
83
+ compute_type: str | None = None,
84
+ language: str | None = None,
85
+ beam_size: int = DEFAULT_BEAM_SIZE,
86
+ vad_min_silence_ms: int = DEFAULT_VAD_MIN_SILENCE_MS,
87
+ initial_prompt: str | None = None,
88
+ hotwords: str | None = None,
89
+ condition_on_previous_text: bool = False,
90
+ profile: str | None = None,
91
+ ) -> None:
92
+ self.profile = profile or os.getenv("OPENMD_FASTER_WHISPER_PROFILE") or "general_v1"
93
+ if self.profile not in {"general_v1", "zh_podcast_v2"}:
94
+ raise ValueError("Unknown faster-whisper profile; use general_v1 or zh_podcast_v2")
95
+ podcast = self.profile == "zh_podcast_v2"
96
+ self.model_size = model_size or os.getenv("OPENMD_FASTER_WHISPER_MODEL") or DEFAULT_MODEL_SIZE
97
+ self.device = device or os.getenv("OPENMD_FASTER_WHISPER_DEVICE") or DEFAULT_DEVICE
98
+ self.compute_type = (
99
+ compute_type
100
+ or os.getenv("OPENMD_FASTER_WHISPER_COMPUTE_TYPE")
101
+ or DEFAULT_COMPUTE_TYPE
102
+ )
103
+ selected_language = language if language is not None else os.getenv(
104
+ "OPENMD_FASTER_WHISPER_LANGUAGE", "zh" if podcast else ""
105
+ )
106
+ self.language = selected_language or DEFAULT_LANGUAGE
107
+ self.beam_size = beam_size
108
+ self.vad_min_silence_ms = vad_min_silence_ms
109
+ self.initial_prompt = initial_prompt if initial_prompt is not None else os.getenv(
110
+ "OPENMD_FASTER_WHISPER_INITIAL_PROMPT", DEFAULT_INITIAL_PROMPT if podcast else ""
111
+ )
112
+ self.hotwords = hotwords if hotwords is not None else os.getenv(
113
+ "OPENMD_FASTER_WHISPER_HOTWORDS", DEFAULT_HOTWORDS if podcast else ""
114
+ )
115
+ self.condition_on_previous_text = condition_on_previous_text
116
+ self.chinese_script = os.getenv("OPENMD_ASR_CHINESE_SCRIPT", "simplified")
117
+
118
+ def execution_config(self) -> dict[str, Any]:
119
+ """Effective result settings; construction never imports or downloads a model."""
120
+ return {
121
+ "profile": self.profile,
122
+ "model": model_label(self.model_size),
123
+ "model_origin": resolve_model(self.model_size, family="whisper").origin,
124
+ "local_files_only": True,
125
+ "language": self.language,
126
+ "device": self.device,
127
+ "compute_type": self.compute_type,
128
+ "beam_size": self.beam_size,
129
+ **FIXED_DECODE_OPTIONS,
130
+ "vad_min_silence_ms": self.vad_min_silence_ms,
131
+ "initial_prompt": self.initial_prompt,
132
+ "hotwords": self.hotwords,
133
+ "condition_on_previous_text": self.condition_on_previous_text,
134
+ "chinese_script": self.chinese_script,
135
+ }
136
+
137
+ def is_available(self) -> bool:
138
+ return importlib.util.find_spec("faster_whisper") is not None
139
+
140
+ def transcribe(self, path: Path) -> TranscriptResult:
141
+ source_path = path.expanduser()
142
+ if not source_path.is_file():
143
+ return _failed_result(
144
+ self.name,
145
+ source_path,
146
+ "asr_local_file_required",
147
+ "faster-whisper ASR requires a local audio file path.",
148
+ )
149
+
150
+ if not self.is_available():
151
+ return MissingASRBackend(self.name).transcribe(source_path)
152
+
153
+ try:
154
+ model_path = require_model(self.model_size, family="whisper")
155
+ _prepare_windows_dll_paths()
156
+ from faster_whisper import WhisperModel
157
+
158
+ with offline_model_loading():
159
+ model = WhisperModel(
160
+ str(model_path),
161
+ device=self.device,
162
+ compute_type=self.compute_type,
163
+ local_files_only=True,
164
+ )
165
+ kwargs: dict[str, Any] = {
166
+ "beam_size": self.beam_size,
167
+ **FIXED_DECODE_OPTIONS,
168
+ "vad_parameters": {"min_silence_duration_ms": self.vad_min_silence_ms},
169
+ "condition_on_previous_text": self.condition_on_previous_text,
170
+ "initial_prompt": self.initial_prompt or None,
171
+ "hotwords": self.hotwords or None,
172
+ }
173
+ if self.language:
174
+ kwargs["language"] = self.language
175
+ segments, info = model.transcribe(str(source_path), **kwargs)
176
+ lines = _format_segments(segments)
177
+ except ModelNotFoundError as exc:
178
+ return _failed_result(self.name, source_path, "asr_model_missing", exc)
179
+ except Exception as exc: # pragma: no cover - dependency/model safety net
180
+ return _failed_result(self.name, source_path, "asr_extraction_failed", exc)
181
+
182
+ text = "\n".join(lines).strip()
183
+ if not text:
184
+ return _failed_result(
185
+ self.name,
186
+ source_path,
187
+ "asr_empty_text",
188
+ "faster-whisper returned no recognized text.",
189
+ )
190
+
191
+ detected_language = getattr(info, "language", None) or self.language
192
+ text, script_note, applied_script = _normalize_chinese_script(text, detected_language, self.chinese_script)
193
+
194
+ return TranscriptResult(
195
+ text=f"{text}\n",
196
+ status="verified",
197
+ source_level="asr",
198
+ backend=self.name,
199
+ source_notes=[
200
+ "faster-whisper transcribed a local audio file.",
201
+ "Raw audio file was referenced, not copied into the package.",
202
+ *([script_note] if script_note else []),
203
+ ],
204
+ title=source_path.stem or "local audio",
205
+ metadata={
206
+ "profile": "media",
207
+ "asr_backend": self.name,
208
+ "chinese_script": applied_script,
209
+ "model_size": model_label(self.model_size),
210
+ "device": self.device,
211
+ "compute_type": self.compute_type,
212
+ "language": getattr(info, "language", None) or self.language,
213
+ "duration": getattr(info, "duration", None),
214
+ "segment_count": len(lines),
215
+ "beam_size": self.beam_size,
216
+ "vad_min_silence_ms": self.vad_min_silence_ms,
217
+ "condition_on_previous_text": self.condition_on_previous_text,
218
+ "quality_profile": self.profile,
219
+ },
220
+ )
221
+
222
+
223
+ def create_backend() -> FasterWhisperASRBackend:
224
+ return FasterWhisperASRBackend()
225
+
226
+
227
+ def _normalize_chinese_script(text: str, language: str | None, script: str) -> tuple[str, str | None, str | None]:
228
+ """Whisper mixes Traditional into Mandarin output even with a Simplified prompt.
229
+
230
+ Only the character form changes; wording is untouched. Cantonese keeps its own form.
231
+ """
232
+ if language != "zh" or script != "simplified":
233
+ return text, None, None
234
+ try:
235
+ from opencc import OpenCC
236
+ except ImportError:
237
+ return text, "OpenCC is not installed, so Traditional characters were left unconverted.", "original"
238
+ return OpenCC("t2s").convert(text), "Traditional characters were converted to Simplified with OpenCC; wording is unchanged.", "simplified"
239
+
240
+
241
+ def _format_segments(segments: Any) -> list[str]:
242
+ lines: list[str] = []
243
+ for segment in segments:
244
+ text = str(getattr(segment, "text", "")).strip()
245
+ if not text:
246
+ continue
247
+ start = getattr(segment, "start", None)
248
+ end = getattr(segment, "end", None)
249
+ if isinstance(start, (int, float)) and isinstance(end, (int, float)):
250
+ prefix = f"[{_timestamp(float(start))}-{_timestamp(float(end))}]"
251
+ elif isinstance(start, (int, float)):
252
+ prefix = f"[{_timestamp(float(start))}]"
253
+ else:
254
+ prefix = "[00:00:00]"
255
+ lines.append(f"{prefix} {text}")
256
+ return lines
257
+
258
+
259
+ def _timestamp(seconds: float) -> str:
260
+ value = max(0, int(seconds))
261
+ hours, remainder = divmod(value, 3600)
262
+ minutes, secs = divmod(remainder, 60)
263
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
264
+
265
+
266
+ def _failed_result(
267
+ backend: str,
268
+ path: Path,
269
+ code: str,
270
+ error: object,
271
+ ) -> TranscriptResult:
272
+ return TranscriptResult(
273
+ text="",
274
+ status="failed",
275
+ source_level="partial_extraction",
276
+ backend=backend,
277
+ source_notes=[
278
+ "local audio ASR backend did not produce usable text.",
279
+ "Raw audio file was referenced, not copied into the package.",
280
+ ],
281
+ error_code=code,
282
+ error_summary=sanitize_error_summary(error),
283
+ title=path.stem or "local audio",
284
+ metadata={"profile": "media", "asr_backend": backend, "source_ref": path.name},
285
+ )