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.
- openmd/__init__.py +3 -0
- openmd/__main__.py +5 -0
- openmd/adapters/__init__.py +1 -0
- openmd/adapters/base.py +95 -0
- openmd/adapters/bilibili/__init__.py +11 -0
- openmd/adapters/bilibili/adapter.py +104 -0
- openmd/adapters/bilibili/discovery.py +33 -0
- openmd/adapters/douyin/__init__.py +11 -0
- openmd/adapters/douyin/adapter.py +117 -0
- openmd/adapters/douyin/discovery.py +36 -0
- openmd/adapters/douyin/sidecar.py +77 -0
- openmd/adapters/generic_media/__init__.py +14 -0
- openmd/adapters/high_privacy.py +77 -0
- openmd/adapters/local_audio/__init__.py +14 -0
- openmd/adapters/local_audio/adapter.py +169 -0
- openmd/adapters/local_audio/asr.py +58 -0
- openmd/adapters/local_audio/backends/__init__.py +1 -0
- openmd/adapters/local_audio/backends/auto.py +177 -0
- openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
- openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
- openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
- openmd/adapters/opencli.py +770 -0
- openmd/adapters/pdf/__init__.py +11 -0
- openmd/adapters/pdf/adapter.py +153 -0
- openmd/adapters/pdf/backends/__init__.py +1 -0
- openmd/adapters/pdf/backends/markitdown.py +165 -0
- openmd/adapters/pdf/parser.py +56 -0
- openmd/adapters/qq_group/__init__.py +11 -0
- openmd/adapters/qq_group/adapter.py +289 -0
- openmd/adapters/qq_group/parsers/__init__.py +1 -0
- openmd/adapters/qq_group/parsers/html.py +60 -0
- openmd/adapters/qq_group/parsers/json.py +71 -0
- openmd/adapters/registry.py +149 -0
- openmd/adapters/screenshot/__init__.py +11 -0
- openmd/adapters/screenshot/adapter.py +154 -0
- openmd/adapters/screenshot/backends/__init__.py +1 -0
- openmd/adapters/screenshot/backends/paddle.py +15 -0
- openmd/adapters/screenshot/backends/rapidocr.py +176 -0
- openmd/adapters/screenshot/backends/tesseract.py +15 -0
- openmd/adapters/screenshot/ocr.py +84 -0
- openmd/adapters/wechat_group/__init__.py +11 -0
- openmd/adapters/wechat_group/adapter.py +160 -0
- openmd/adapters/wechat_group/wx_cli.py +268 -0
- openmd/adapters/wechat_moments/__init__.py +11 -0
- openmd/adapters/wechat_moments/adapter.py +167 -0
- openmd/adapters/wechat_moments/wx_cli.py +265 -0
- openmd/adapters/weixin/__init__.py +11 -0
- openmd/adapters/weixin/adapter.py +148 -0
- openmd/adapters/weixin/discovery.py +510 -0
- openmd/adapters/weixin/report.py +278 -0
- openmd/adapters/xiaohongshu.py +158 -0
- openmd/backends/__init__.py +1 -0
- openmd/backends/base.py +204 -0
- openmd/backends/bilibili.py +1480 -0
- openmd/backends/bilibili_wbi.py +277 -0
- openmd/backends/discourse.py +426 -0
- openmd/backends/douyin.py +868 -0
- openmd/backends/douyin_detail.py +124 -0
- openmd/backends/douyin_providers.py +602 -0
- openmd/backends/executor.py +546 -0
- openmd/backends/feishu.py +861 -0
- openmd/backends/generic_media.py +77 -0
- openmd/backends/interactive.py +60 -0
- openmd/backends/ocr.py +41 -0
- openmd/backends/opencli.py +82 -0
- openmd/backends/pdf.py +122 -0
- openmd/backends/registry.py +234 -0
- openmd/backends/screenshot.py +79 -0
- openmd/backends/video_metadata.py +167 -0
- openmd/backends/web_article.py +363 -0
- openmd/backends/wechat_group.py +35 -0
- openmd/backends/wechat_moments.py +35 -0
- openmd/backends/weixin.py +767 -0
- openmd/backends/x.py +455 -0
- openmd/backends/xiaoe.py +940 -0
- openmd/backends/xiaohongshu.py +911 -0
- openmd/backends/xiaohongshu_providers.py +876 -0
- openmd/backends/xiaoyuzhou.py +1017 -0
- openmd/backends/youtube.py +280 -0
- openmd/backends/youtube_discovery.py +1245 -0
- openmd/backends/youtube_transcript.py +213 -0
- openmd/browser_extension/background.js +231 -0
- openmd/browser_extension/icons/icon-128.png +0 -0
- openmd/browser_extension/icons/icon-16.png +0 -0
- openmd/browser_extension/icons/icon-32.png +0 -0
- openmd/browser_extension/icons/icon-48.png +0 -0
- openmd/browser_extension/icons/icon-512.png +0 -0
- openmd/browser_extension/idle.html +6 -0
- openmd/browser_extension/manifest.json +43 -0
- openmd/browser_extension/options.html +42 -0
- openmd/browser_extension/options.js +117 -0
- openmd/browser_extension/popup.css +71 -0
- openmd/browser_extension/popup.html +17 -0
- openmd/browser_extension/popup.js +59 -0
- openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
- openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
- openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
- openmd/browser_host.py +503 -0
- openmd/browser_setup.py +106 -0
- openmd/cli.py +2012 -0
- openmd/console.py +32 -0
- openmd/core/__init__.py +1 -0
- openmd/core/capabilities.py +162 -0
- openmd/core/content_validation.py +323 -0
- openmd/core/coverage.py +163 -0
- openmd/core/detector.py +247 -0
- openmd/core/doctor.py +104 -0
- openmd/core/execution_policy.py +164 -0
- openmd/core/filename.py +64 -0
- openmd/core/input_validation.py +91 -0
- openmd/core/local_config.py +96 -0
- openmd/core/manifest.py +156 -0
- openmd/core/models.py +168 -0
- openmd/core/opencli_output.py +34 -0
- openmd/core/operation_contract.py +60 -0
- openmd/core/output.py +194 -0
- openmd/core/package_contract.py +335 -0
- openmd/core/package_validator.py +489 -0
- openmd/core/package_writer.py +666 -0
- openmd/core/planner.py +345 -0
- openmd/core/preferences.py +61 -0
- openmd/core/readable.py +129 -0
- openmd/core/receipt.py +285 -0
- openmd/core/renderer.py +185 -0
- openmd/core/runlog.py +208 -0
- openmd/core/secrets.py +313 -0
- openmd/doctor_view.py +68 -0
- openmd/interactive.py +453 -0
- openmd/ocr/__init__.py +1 -0
- openmd/ocr/breakers.py +84 -0
- openmd/ocr/config.py +54 -0
- openmd/ocr/documents.py +522 -0
- openmd/ocr/endpoint.py +72 -0
- openmd/ocr/layout.py +92 -0
- openmd/ocr/layout_filter.py +118 -0
- openmd/ocr/layout_worker.py +89 -0
- openmd/ocr/native_math.py +220 -0
- openmd/ocr/office.py +305 -0
- openmd/ocr/ollama.py +200 -0
- openmd/ocr/otsl.py +51 -0
- openmd/ocr/ovis-prompt.txt +1 -0
- openmd/ocr/pipeline.py +384 -0
- openmd/ocr/settings.json +25 -0
- openmd/ocr/tiling.py +631 -0
- openmd/post_video.py +154 -0
- openmd/probe.py +397 -0
- openmd/routing/__init__.py +1 -0
- openmd/routing/cards/builtin.json +620 -0
- openmd/routing/cards/defuddle.json +70 -0
- openmd/routing/cards/x-tweet-fetcher.json +71 -0
- openmd/routing/cards/xyz-dl.json +60 -0
- openmd/routing/cards/yutto.json +68 -0
- openmd/routing/entry_shim.py +71 -0
- openmd/routing/lifecycle.py +459 -0
- openmd/routing/loader.py +138 -0
- openmd/routing/providers.py +158 -0
- openmd/routing/readers.py +108 -0
- openmd/routing/routes.json +504 -0
- openmd/routing/runner.py +192 -0
- openmd/routing/view.py +40 -0
- openmd/runtime/__init__.py +1 -0
- openmd/runtime/_windows_job.py +234 -0
- openmd/runtime/asr.py +217 -0
- openmd/runtime/asr_worker.py +119 -0
- openmd/runtime/gpu_lock.py +81 -0
- openmd/runtime/gpu_quarantine.py +85 -0
- openmd/runtime/interpreters.py +407 -0
- openmd/runtime/managed_tools.py +204 -0
- openmd/runtime/models.py +202 -0
- openmd/runtime/services.py +175 -0
- openmd/runtime/tempdirs.py +227 -0
- openmd_cli-0.1.0.dist-info/METADATA +168 -0
- openmd_cli-0.1.0.dist-info/RECORD +177 -0
- openmd_cli-0.1.0.dist-info/WHEEL +5 -0
- openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|