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,14 @@
|
|
|
1
|
+
"""Placeholder for a future FunASR SenseVoice local backend.
|
|
2
|
+
|
|
3
|
+
The V1 lane intentionally does not import FunASR at module import time. A real
|
|
4
|
+
provider can be added behind an explicit backend gate after local dependency
|
|
5
|
+
smoke testing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from openmd.adapters.local_audio.asr import MissingASRBackend
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_backend() -> MissingASRBackend:
|
|
14
|
+
return MissingASRBackend("funasr.sensevoice")
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Local Qwen3-ASR backend.
|
|
2
|
+
|
|
3
|
+
Qwen3-ASR scored best on the Chinese benchmark but sends a whole file to the GPU at
|
|
4
|
+
once, which exhausts an 8 GB card on long audio. Audio is therefore cut at FunASR VAD
|
|
5
|
+
speech boundaries first; each line keeps the start time of its chunk. Heavy imports
|
|
6
|
+
happen only inside ``transcribe``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import gc
|
|
12
|
+
import importlib.util
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from openmd.adapters.local_audio.asr import ASRBackend, MissingASRBackend
|
|
20
|
+
from openmd.core.models import TranscriptResult
|
|
21
|
+
from openmd.core.local_config import qwen3_batch_size, validate_qwen3_batch_size
|
|
22
|
+
from openmd.core.secrets import sanitize_error_summary
|
|
23
|
+
from openmd.runtime.models import ModelNotFoundError, model_label, offline_model_loading, require_model, resolve_model
|
|
24
|
+
from openmd.runtime.tempdirs import owned_temporary_directory
|
|
25
|
+
from openmd.runtime.interpreters import run_interruptibly
|
|
26
|
+
|
|
27
|
+
DEFAULT_MODEL = "Qwen/Qwen3-ASR-1.7B"
|
|
28
|
+
DEFAULT_VAD_MODEL = "iic/speech_fsmn_vad_zh-cn-16k-common-pytorch"
|
|
29
|
+
DEFAULT_DEVICE = "cuda:0"
|
|
30
|
+
MAX_CHUNK_MS = 25_000
|
|
31
|
+
SAMPLE_RATE = 16_000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Qwen3ASRBackend(ASRBackend):
|
|
35
|
+
name = "asr.qwen3"
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
model: str | None = None,
|
|
41
|
+
vad_model: str | None = None,
|
|
42
|
+
device: str | None = None,
|
|
43
|
+
language: str | None = None,
|
|
44
|
+
batch_size: int | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.model = model or os.getenv("OPENMD_QWEN3_MODEL") or DEFAULT_MODEL
|
|
47
|
+
self.vad_model = vad_model or os.getenv("OPENMD_QWEN3_VAD_MODEL") or DEFAULT_VAD_MODEL
|
|
48
|
+
self.device = device or os.getenv("OPENMD_QWEN3_DEVICE") or DEFAULT_DEVICE
|
|
49
|
+
self.language = language or os.getenv("OPENMD_QWEN3_LANGUAGE") or None
|
|
50
|
+
self.batch_size = qwen3_batch_size() if batch_size is None else validate_qwen3_batch_size(batch_size)
|
|
51
|
+
self._run_metadata: dict[str, Any] = {}
|
|
52
|
+
|
|
53
|
+
def execution_config(self) -> dict[str, Any]:
|
|
54
|
+
return {
|
|
55
|
+
"model": model_label(self.model),
|
|
56
|
+
"model_origin": resolve_model(self.model, family="qwen3").origin,
|
|
57
|
+
"vad_model": model_label(self.vad_model),
|
|
58
|
+
"vad_origin": resolve_model(self.vad_model, family="vad").origin,
|
|
59
|
+
"local_files_only": True,
|
|
60
|
+
"device": self.device,
|
|
61
|
+
"language": self.language,
|
|
62
|
+
"max_chunk_ms": MAX_CHUNK_MS,
|
|
63
|
+
"batch_size": self.batch_size,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def is_available(self) -> bool:
|
|
67
|
+
return all(importlib.util.find_spec(name) is not None for name in ("qwen_asr", "funasr", "soundfile"))
|
|
68
|
+
|
|
69
|
+
def transcribe(self, path: Path) -> TranscriptResult:
|
|
70
|
+
self._run_metadata = {}
|
|
71
|
+
source_path = path.expanduser()
|
|
72
|
+
if not source_path.is_file():
|
|
73
|
+
return _failed(self.name, "asr_file_not_found", f"Local audio file not found: {source_path.name}")
|
|
74
|
+
if not self.is_available():
|
|
75
|
+
return MissingASRBackend(self.name).transcribe(source_path)
|
|
76
|
+
|
|
77
|
+
failure: TranscriptResult | None = None
|
|
78
|
+
try:
|
|
79
|
+
lines, languages, duration = self._run(source_path)
|
|
80
|
+
except ModelNotFoundError as exc:
|
|
81
|
+
failure = _failed(self.name, "asr_model_missing", exc)
|
|
82
|
+
except Exception as exc: # pragma: no cover - dependency/model safety net
|
|
83
|
+
failure = _failed(self.name, "asr_extraction_failed", exc)
|
|
84
|
+
if failure is not None:
|
|
85
|
+
# The exception and its model-owning traceback have now been released.
|
|
86
|
+
# This also permits asr.auto's Whisper fallback in the same process.
|
|
87
|
+
gc.collect()
|
|
88
|
+
torch = sys.modules.get("torch")
|
|
89
|
+
if torch is not None and torch.cuda.is_available():
|
|
90
|
+
torch.cuda.empty_cache()
|
|
91
|
+
failure.metadata.update(self._run_metadata)
|
|
92
|
+
return failure
|
|
93
|
+
|
|
94
|
+
if not lines:
|
|
95
|
+
failure = _failed(self.name, "asr_empty_text", "Qwen3-ASR returned no recognized text.")
|
|
96
|
+
failure.metadata.update(self._run_metadata)
|
|
97
|
+
return failure
|
|
98
|
+
return TranscriptResult(
|
|
99
|
+
text="\n".join(lines) + "\n",
|
|
100
|
+
status="verified",
|
|
101
|
+
source_level="asr",
|
|
102
|
+
backend=self.name,
|
|
103
|
+
source_notes=[
|
|
104
|
+
"Qwen3-ASR transcribed a local audio file in VAD-bounded chunks.",
|
|
105
|
+
"Each timestamp is the start of a chunk, not of a sentence.",
|
|
106
|
+
"Raw audio file was referenced, not copied into the package.",
|
|
107
|
+
*(["Native Qwen3-ASR batching may change individual words or punctuation through numerical differences; set OPENMD_QWEN3_BATCH_SIZE=1 for batch-1 reproducibility."] if self.batch_size > 1 else []),
|
|
108
|
+
],
|
|
109
|
+
title=source_path.stem or "local audio",
|
|
110
|
+
metadata={
|
|
111
|
+
"profile": "media",
|
|
112
|
+
"asr_backend": self.name,
|
|
113
|
+
"model": model_label(self.model),
|
|
114
|
+
"device": self.device,
|
|
115
|
+
"language": self.language or (languages[0] if len(set(languages)) == 1 else "mixed"),
|
|
116
|
+
"duration": duration,
|
|
117
|
+
"segment_count": len(lines),
|
|
118
|
+
**self._run_metadata,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _run(self, path: Path) -> tuple[list[str], list[str], float]:
|
|
123
|
+
started = time.monotonic()
|
|
124
|
+
model_path = require_model(self.model, family="qwen3")
|
|
125
|
+
vad_path = require_model(self.vad_model, family="vad")
|
|
126
|
+
import soundfile
|
|
127
|
+
import torch
|
|
128
|
+
from funasr import AutoModel
|
|
129
|
+
from qwen_asr import Qwen3ASRModel
|
|
130
|
+
|
|
131
|
+
with owned_temporary_directory(prefix="openmd-qwen3-") as tmp:
|
|
132
|
+
wav = Path(tmp) / "audio.wav"
|
|
133
|
+
run_interruptibly(
|
|
134
|
+
["ffmpeg", "-v", "error", "-y", "-i", str(path), "-ar", str(SAMPLE_RATE), "-ac", "1", str(wav)],
|
|
135
|
+
check=True,
|
|
136
|
+
capture_output=True,
|
|
137
|
+
)
|
|
138
|
+
audio, _ = soundfile.read(str(wav), dtype="float32")
|
|
139
|
+
with offline_model_loading():
|
|
140
|
+
vad = AutoModel(model=str(vad_path), device=self.device, disable_update=True)
|
|
141
|
+
spans = merge_speech_spans(vad.generate(input=str(wav))[0]["value"], MAX_CHUNK_MS)
|
|
142
|
+
del vad
|
|
143
|
+
with offline_model_loading():
|
|
144
|
+
model = Qwen3ASRModel.from_pretrained(
|
|
145
|
+
str(model_path),
|
|
146
|
+
dtype=torch.bfloat16,
|
|
147
|
+
device_map=self.device,
|
|
148
|
+
max_inference_batch_size=self.batch_size,
|
|
149
|
+
max_new_tokens=512,
|
|
150
|
+
local_files_only=True,
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
lines, languages = self._transcribe_spans(model, audio, spans, torch)
|
|
154
|
+
return lines, languages, len(audio) / SAMPLE_RATE
|
|
155
|
+
finally:
|
|
156
|
+
del model
|
|
157
|
+
gc.collect()
|
|
158
|
+
if torch.cuda.is_available():
|
|
159
|
+
torch.cuda.empty_cache()
|
|
160
|
+
self._run_metadata["elapsed_seconds"] = round(time.monotonic() - started, 6)
|
|
161
|
+
|
|
162
|
+
def _transcribe_spans(
|
|
163
|
+
self, model: Any, audio: Any, spans: list[list[int]], torch: Any,
|
|
164
|
+
) -> tuple[list[str], list[str]]:
|
|
165
|
+
"""Batch existing VAD chunks without changing their order or timestamps.
|
|
166
|
+
|
|
167
|
+
Only a CUDA OOM retries, and only the slice not yet appended. Leaving the
|
|
168
|
+
exception handler before cache cleanup releases the failed call's frames.
|
|
169
|
+
"""
|
|
170
|
+
lines: list[str] = []
|
|
171
|
+
languages: list[str] = []
|
|
172
|
+
effective = self.batch_size
|
|
173
|
+
histogram: dict[str, int] = {}
|
|
174
|
+
oom_attempts: list[int] = []
|
|
175
|
+
retries = 0
|
|
176
|
+
inference_seconds = 0.0
|
|
177
|
+
index = 0
|
|
178
|
+
try:
|
|
179
|
+
while index < len(spans):
|
|
180
|
+
current = spans[index:index + effective]
|
|
181
|
+
pieces = [audio[start * SAMPLE_RATE // 1000:end * SAMPLE_RATE // 1000] for start, end in current]
|
|
182
|
+
# Preserve the original tuple call when inference is scalar.
|
|
183
|
+
inputs = (pieces[0], SAMPLE_RATE) if effective == 1 else [(piece, SAMPLE_RATE) for piece in pieces]
|
|
184
|
+
started = time.monotonic()
|
|
185
|
+
retry = False
|
|
186
|
+
try:
|
|
187
|
+
results = model.transcribe(audio=inputs, language=self.language)
|
|
188
|
+
except torch.cuda.OutOfMemoryError:
|
|
189
|
+
oom_attempts.append(len(current))
|
|
190
|
+
if effective == 1:
|
|
191
|
+
raise
|
|
192
|
+
effective = max(1, effective // 2)
|
|
193
|
+
model.max_inference_batch_size = effective
|
|
194
|
+
retries += 1
|
|
195
|
+
retry = True
|
|
196
|
+
finally:
|
|
197
|
+
inference_seconds += time.monotonic() - started
|
|
198
|
+
if retry:
|
|
199
|
+
gc.collect()
|
|
200
|
+
torch.cuda.empty_cache()
|
|
201
|
+
continue
|
|
202
|
+
if len(results) != len(current):
|
|
203
|
+
raise ValueError(f"qwen3_batch_cardinality_mismatch: expected {len(current)}, got {len(results)}")
|
|
204
|
+
for (start, _), result in zip(current, results):
|
|
205
|
+
text = str(result.text).strip()
|
|
206
|
+
if text:
|
|
207
|
+
lines.append(f"[{format_timestamp(start)}] {text}")
|
|
208
|
+
languages.append(str(getattr(result, "language", "") or "unknown"))
|
|
209
|
+
key = str(len(current))
|
|
210
|
+
histogram[key] = histogram.get(key, 0) + 1
|
|
211
|
+
index += len(current)
|
|
212
|
+
finally:
|
|
213
|
+
self._run_metadata.update({
|
|
214
|
+
"batch_size_requested": self.batch_size,
|
|
215
|
+
"batch_size_effective": effective,
|
|
216
|
+
"batch_size_histogram": histogram,
|
|
217
|
+
"batch_oom_retries": retries,
|
|
218
|
+
"batch_oom_attempts": oom_attempts,
|
|
219
|
+
"inference_seconds": round(inference_seconds, 6),
|
|
220
|
+
})
|
|
221
|
+
return lines, languages
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def merge_speech_spans(spans: list[list[int]], limit_ms: int) -> list[list[int]]:
|
|
225
|
+
"""Join neighbouring VAD spans while the merged chunk stays within ``limit_ms``."""
|
|
226
|
+
merged: list[list[int]] = []
|
|
227
|
+
for start, end in spans:
|
|
228
|
+
if merged and end - merged[-1][0] <= limit_ms:
|
|
229
|
+
merged[-1][1] = end
|
|
230
|
+
else:
|
|
231
|
+
merged.append([start, end])
|
|
232
|
+
return merged
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def format_timestamp(milliseconds: int) -> str:
|
|
236
|
+
seconds = milliseconds // 1000
|
|
237
|
+
return f"{seconds // 3600:02d}:{seconds % 3600 // 60:02d}:{seconds % 60:02d}"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _failed(backend: str, code: str, error: object) -> TranscriptResult:
|
|
241
|
+
return TranscriptResult(
|
|
242
|
+
text="",
|
|
243
|
+
status="failed",
|
|
244
|
+
source_level="partial_extraction",
|
|
245
|
+
backend=backend,
|
|
246
|
+
source_notes=["Qwen3-ASR did not produce usable text."],
|
|
247
|
+
error_code=code,
|
|
248
|
+
error_summary=sanitize_error_summary(error),
|
|
249
|
+
metadata={"profile": "media", "asr_backend": backend},
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def create_backend() -> Qwen3ASRBackend:
|
|
254
|
+
return Qwen3ASRBackend()
|