earshot-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.
earshot/models.py ADDED
@@ -0,0 +1,72 @@
1
+ """模型自动获取:缺失时首次运行自动下载(MacWhisper/Ollama 式体验)。
2
+
3
+ 源默认走 hf-mirror(国内可达),可用 HF_ENDPOINT 覆盖。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+
12
+ DEFAULT_ENDPOINT = "https://hf-mirror.com"
13
+ SENSE_VOICE_REPO = "csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17"
14
+ SILERO_VAD_REPO = "onnx-community/silero-vad"
15
+
16
+ # 相对路径 -> (HF 仓库, 仓库内文件)
17
+ MODEL_FILES: dict[str, tuple[str, str]] = {
18
+ "asr/model.int8.onnx": (SENSE_VOICE_REPO, "model.int8.onnx"),
19
+ "asr/tokens.txt": (SENSE_VOICE_REPO, "tokens.txt"),
20
+ "vad/silero_vad.onnx": (SILERO_VAD_REPO, "onnx/model.onnx"),
21
+ }
22
+
23
+
24
+ def missing_models(model_root: Path) -> list[str]:
25
+ """列出缺失的模型相对路径。"""
26
+ return [rel for rel in MODEL_FILES if not (model_root / rel).is_file()]
27
+
28
+
29
+ def ensure_models(model_root: Path, endpoint: str | None = None) -> bool:
30
+ """确保模型齐全,缺失则下载。
31
+
32
+ Args:
33
+ model_root: 模型根目录。
34
+ endpoint: HF 端点,默认 hf-mirror,可 ``HF_ENDPOINT`` 覆盖。
35
+
36
+ Returns:
37
+ 是否发生了下载。
38
+
39
+ Raises:
40
+ RuntimeError: 下载失败(网络不可达等)。
41
+ """
42
+ import os
43
+
44
+ base = endpoint or os.environ.get("HF_ENDPOINT") or DEFAULT_ENDPOINT
45
+ downloaded = False
46
+ for rel in missing_models(model_root):
47
+ repo, remote = MODEL_FILES[rel]
48
+ dest = model_root / rel
49
+ dest.parent.mkdir(parents=True, exist_ok=True)
50
+ url = f"{base}/{repo}/resolve/main/{remote}"
51
+ print(f"↓ 下载模型 {rel}(首次运行自动补齐,之后不再下载)")
52
+ try:
53
+ mb = 1 << 20
54
+ with httpx.stream("GET", url, follow_redirects=True, timeout=60.0) as resp:
55
+ resp.raise_for_status()
56
+ total = int(resp.headers.get("content-length") or 0)
57
+ done = 0
58
+ with dest.with_suffix(dest.suffix + ".part").open("wb") as f:
59
+ for chunk in resp.iter_bytes(chunk_size=mb):
60
+ f.write(chunk)
61
+ done += len(chunk)
62
+ if total:
63
+ print(f"\r {done // mb}/{total // mb} MB", end="", flush=True)
64
+ dest.with_suffix(dest.suffix + ".part").replace(dest)
65
+ downloaded = True
66
+ print()
67
+ except httpx.HTTPError as e:
68
+ for suffix in ("", ".part"):
69
+ Path(str(dest) + suffix).unlink(missing_ok=True)
70
+ msg = f"模型下载失败: {rel}({e})。可设 HF_ENDPOINT=https://huggingface.co 重试,或手动放置到 {model_root}"
71
+ raise RuntimeError(msg) from e
72
+ return downloaded
earshot/subtitle.py ADDED
@@ -0,0 +1,207 @@
1
+ """B站字幕直拉:yt-dlp 提取元信息与字幕轨 → Segment 列表。
2
+
3
+ 高信噪比路线:听录会混入环境噪音/被打断,字幕来自平台已生成文本,无此问题。
4
+ 支持 .bcc/.json(B站)与 .srt/.vtt 格式;AI 字幕(ai-zh)通常需要登录态
5
+ (--cookies-from-browser)。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from earshot.transcribe import Segment
18
+
19
+ # 人工字幕优先于 AI 字幕;同组内先列者优先
20
+ PREFERRED_LANGS = ("zh-CN", "zh-Hans", "zh", "ai-zh")
21
+ PARSABLE_EXTS = ("bcc", "json", "srt", "vtt")
22
+
23
+ _SRT_TIME = re.compile(r"(?P<h>\d{2}):(?P<m>\d{2}):(?P<s>\d{2})[.,](?P<ms>\d{3})")
24
+
25
+
26
+ class SubtitleError(RuntimeError):
27
+ """字幕获取失败(无字幕/网络/解析)。"""
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class VideoMeta:
32
+ """视频元信息(写进笔记脚注)。
33
+
34
+ Attributes:
35
+ title: 视频标题。
36
+ uploader: UP主/作者。
37
+ duration_s: 视频时长(秒,缺失为 0)。
38
+ url: 原始链接(回溯用)。
39
+ subtitle_lang: 实际采用的字幕语言(如 zh-CN / ai-zh)。
40
+ """
41
+
42
+ title: str
43
+ uploader: str
44
+ duration_s: float
45
+ url: str
46
+ subtitle_lang: str
47
+ chapter_starts: list[float] = field(default_factory=list) # 章节起点(秒),按章节分段
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class _Track:
52
+ """一条字幕轨:url 与 data 二选一(yt-dlp 对B站已下载转内联 data)。"""
53
+
54
+ lang: str
55
+ ext: str
56
+ url: str = ""
57
+ data: str = ""
58
+
59
+
60
+ def choose_subtitle(tracks: list[_Track]) -> _Track | None:
61
+ """按语言优先级与格式可解析性选字幕轨(纯函数)。
62
+
63
+ 人工 zh 字幕 > ai-zh;同语言内 bcc/json 优先于 srt/vtt。
64
+ """
65
+ by_lang: dict[str, list[_Track]] = {}
66
+ for t in tracks:
67
+ if t.ext in PARSABLE_EXTS and (t.url or t.data):
68
+ by_lang.setdefault(t.lang, []).append(t)
69
+ for lang in PREFERRED_LANGS:
70
+ candidates = by_lang.get(lang)
71
+ if candidates:
72
+ for ext in ("bcc", "json", "srt", "vtt"):
73
+ for t in candidates:
74
+ if t.ext == ext:
75
+ return t
76
+ return candidates[0]
77
+ return None
78
+
79
+
80
+ def parse_subtitle(content: str, ext: str) -> list[Segment]:
81
+ """解析字幕内容为 Segment 列表(纯函数)。
82
+
83
+ Args:
84
+ content: 字幕文件文本。
85
+ ext: bcc/json(B站 JSON 体)或 srt/vtt。
86
+
87
+ Returns:
88
+ 按时间排序、非空文本的 Segment 列表。
89
+
90
+ Raises:
91
+ SubtitleError: 格式无法解析。
92
+ """
93
+ if ext in ("bcc", "json"):
94
+ try:
95
+ body = json.loads(content).get("body") or []
96
+ except ValueError as e:
97
+ msg = f"bcc/json 字幕解析失败: {e}"
98
+ raise SubtitleError(msg) from e
99
+ bcc_segments = [
100
+ Segment(start_s=float(item["from"]), end_s=float(item["to"]), text=str(item["content"]).strip())
101
+ for item in body
102
+ if item.get("content")
103
+ ]
104
+ return bcc_segments
105
+ # srt / vtt
106
+ segments: list[Segment] = []
107
+ blocks = content.replace("\r\n", "\n").split("\n\n")
108
+ for block in blocks:
109
+ lines = [ln for ln in block.split("\n") if ln.strip()]
110
+ times = [ln for ln in lines if "-->" in ln]
111
+ if not times:
112
+ continue
113
+ m_start = _SRT_TIME.search(times[0])
114
+ m_end = _SRT_TIME.search(times[0], times[0].find("-->") + 3)
115
+ text = " ".join(ln.strip() for ln in lines if "-->" not in ln and not ln.strip().isdigit())
116
+ if m_start and m_end and text:
117
+ segments.append(
118
+ Segment(
119
+ start_s=_to_seconds(m_start),
120
+ end_s=_to_seconds(m_end),
121
+ text=text,
122
+ )
123
+ )
124
+ if not segments:
125
+ msg = f"srt/vtt 字幕解析失败(无有效块): ext={ext}"
126
+ raise SubtitleError(msg)
127
+ return segments
128
+
129
+
130
+ def _to_seconds(m: re.Match[str]) -> float:
131
+ return int(m["h"]) * 3600 + int(m["m"]) * 60 + int(m["s"]) + int(m["ms"]) / 1000.0
132
+
133
+
134
+ def fetch_video_note(url: str, cookies_browser: str | None = None) -> tuple[VideoMeta, list[Segment]]:
135
+ """拉取视频字幕,返回 (元信息, Segment 列表)。
136
+
137
+ Args:
138
+ url: 视频链接(B站等 yt-dlp 支持的平台)。
139
+ cookies_browser: 浏览器名(chrome/safari/firefox/edge),拉 ai-zh AI 字幕
140
+ 通常需要登录态;None 则匿名尝试。
141
+
142
+ Raises:
143
+ SubtitleError: 提取失败或无可用字幕。
144
+ """
145
+ import yt_dlp # noqa: PLC0415 延迟导入:不用 video 命令时不加载
146
+
147
+ opts: dict[str, Any] = {
148
+ "quiet": True,
149
+ "no_warnings": True,
150
+ "skip_download": True,
151
+ "noplaylist": True,
152
+ # yt-dlp 契约:不设 writesubtitles,提取器根本不会调字幕接口(info["subtitles"] 恒空)
153
+ "writesubtitles": True,
154
+ "writeautomaticsub": True,
155
+ "subtitleslangs": list(PREFERRED_LANGS) + ["zh-Hant", "en"],
156
+ }
157
+ if cookies_browser:
158
+ opts["cookiesfrombrowser"] = (cookies_browser,)
159
+ try:
160
+ info = yt_dlp.YoutubeDL(opts).extract_info(url, download=False)
161
+ except Exception as e: # yt_dlp 异常族庞大,统一收口
162
+ msg = f"yt-dlp 提取失败: {e}"
163
+ raise SubtitleError(msg) from e
164
+ if not info:
165
+ msg = "yt-dlp 未返回视频信息"
166
+ raise SubtitleError(msg)
167
+
168
+ tracks: list[_Track] = []
169
+ for subs_field in ("subtitles", "automatic_captions"):
170
+ for lang, subs in (info.get(subs_field) or {}).items():
171
+ for s in subs:
172
+ tracks.append(
173
+ _Track(
174
+ lang=lang,
175
+ ext=str(s.get("ext") or "srt"),
176
+ url=str(s.get("url") or ""),
177
+ data=str(s.get("data") or ""),
178
+ )
179
+ )
180
+ track = choose_subtitle(tracks)
181
+ if track is None:
182
+ hints = []
183
+ if not cookies_browser:
184
+ hints.append("试试 --cookies-from-browser chrome(AI 字幕通常需要登录态)")
185
+ hints.append("若字幕是烧在画面里的硬字幕(播放器右下角无 CC 开关),字幕轨本来就不存在,请改用 earshot listen 听录")
186
+ msg = f"无可用中文字幕。{' ; '.join(hints)}"
187
+ raise SubtitleError(msg)
188
+ if track.data: # yt-dlp 已下载并转好格式(B站 ai-zh 即此路径)
189
+ content = track.data
190
+ else:
191
+ try:
192
+ resp = httpx.get(track.url, timeout=60.0, follow_redirects=True, headers={"User-Agent": "Mozilla/5.0"})
193
+ resp.raise_for_status()
194
+ except httpx.HTTPError as e:
195
+ msg = f"字幕下载失败({track.lang}/{track.ext}): {e}"
196
+ raise SubtitleError(msg) from e
197
+ content = resp.text
198
+ segments = parse_subtitle(content, track.ext)
199
+ meta = VideoMeta(
200
+ title=str(info.get("title") or url),
201
+ uploader=str(info.get("uploader") or info.get("channel") or "-"),
202
+ duration_s=float(info.get("duration") or 0),
203
+ url=url,
204
+ subtitle_lang=track.lang,
205
+ chapter_starts=[float(c["start_time"]) for c in (info.get("chapters") or []) if "start_time" in c],
206
+ )
207
+ return meta, segments
earshot/transcribe.py ADDED
@@ -0,0 +1,220 @@
1
+ """本地转写引擎:silero VAD 切段 + SenseVoice 离线解码。
2
+
3
+ sherpa-onnx 延迟导入 —— compose 等纯逻辑模块与单测不依赖模型栈。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import wave
9
+ from collections.abc import Callable, Iterator
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+
16
+ SAMPLE_RATE = 16000
17
+ VAD_WINDOW = 512 # silero 要求 512 样本/窗(16k 下 32ms)
18
+ MAX_SPEECH_RUN_S = 55.0 # 连续无停顿语音强制切段,防 VAD 60s buffer 溢出
19
+ MIN_SILENCE_S = 0.45 # 短于此的静音不算说完(挡住句中停顿)
20
+ PARAGRAPH_FEED_EVERY_S = 5.0 # 进度回调最小间隔
21
+
22
+
23
+ class TranscribeError(RuntimeError):
24
+ """本地转写失败(模型缺失 / 配置 / 解码错误)。"""
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class Segment:
29
+ """一段连续语音的转写结果。
30
+
31
+ Attributes:
32
+ start_s: 段起点(秒,相对音频开头)。
33
+ end_s: 段终点(秒)。
34
+ text: 转写文本。
35
+ """
36
+
37
+ start_s: float
38
+ end_s: float
39
+ text: str
40
+
41
+
42
+ class LocalTranscriber:
43
+ """VAD 切段 + SenseVoice 批量识别,输入须为 16k mono wav。"""
44
+
45
+ def __init__(self, model_root: Path) -> None:
46
+ """加载 ASR/VAD 模型。
47
+
48
+ Args:
49
+ model_root: 模型根目录,须含 ``asr/model.int8.onnx``、``asr/tokens.txt``、
50
+ ``vad/silero_vad.onnx``(scripts/download_models.sh 产物)。
51
+
52
+ Raises:
53
+ TranscribeError: 模型缺失或初始化失败。
54
+ """
55
+ asr_model = model_root / "asr" / "model.int8.onnx"
56
+ tokens = model_root / "asr" / "tokens.txt"
57
+ vad_model = model_root / "vad" / "silero_vad.onnx"
58
+ for p in (asr_model, tokens, vad_model):
59
+ if not p.is_file():
60
+ msg = f"模型缺失: {p}(先跑 scripts/download_models.sh,或设 EARSHOT_MODEL_ROOT 指向已有模型)"
61
+ raise TranscribeError(msg)
62
+
63
+ import sherpa_onnx # noqa: PLC0415 延迟导入:纯逻辑模块/单测不拉模型栈
64
+
65
+ try:
66
+ self._recognizer: Any = sherpa_onnx.OfflineRecognizer.from_sense_voice(
67
+ model=str(asr_model),
68
+ tokens=str(tokens),
69
+ use_itn=True,
70
+ )
71
+ self._vad_model_config: Any = sherpa_onnx.VadModelConfig()
72
+ self._vad_model_config.silero_vad.model = str(vad_model)
73
+ self._vad_model_config.silero_vad.threshold = 0.5
74
+ self._vad_model_config.silero_vad.min_silence_duration = MIN_SILENCE_S
75
+ self._vad_model_config.silero_vad.min_speech_duration = 0.15
76
+ self._vad_model_config.silero_vad.window_size = VAD_WINDOW
77
+ self._vad_model_config.sample_rate = SAMPLE_RATE
78
+ if not self._vad_model_config.validate():
79
+ msg = "VAD 配置校验失败"
80
+ raise TranscribeError(msg)
81
+ except TranscribeError:
82
+ raise
83
+ except Exception as e: # sherpa 抛的均为未类型化异常
84
+ msg = f"初始化本地转写失败: {e}"
85
+ raise TranscribeError(msg) from e
86
+
87
+ def transcribe(self, wav: Path, on_progress: Any = None) -> list[Segment]:
88
+ """转写整个 wav 文件。
89
+
90
+ Args:
91
+ wav: 16k mono wav(earshot.extract.to_wav16k 产物)。
92
+ on_progress: 可选回调 ``on_progress(fraction)``,fraction ∈ [0,1]。
93
+
94
+ Returns:
95
+ 按时间排序的 Segment 列表。
96
+
97
+ Raises:
98
+ TranscribeError: wav 格式不符或解码失败。
99
+ """
100
+ samples = read_wav16k_mono(wav)
101
+ return self.transcribe_samples(samples, on_progress)
102
+
103
+ def transcribe_samples(self, samples: np.ndarray, on_progress: Any = None) -> list[Segment]:
104
+ """转写内存样本(16k float32 mono)。"""
105
+ return run_live(
106
+ vad=self._new_vad(),
107
+ chunks=iter([samples]),
108
+ decode=self._decode,
109
+ on_progress=on_progress,
110
+ total_samples=len(samples),
111
+ )
112
+
113
+ def _new_vad(self) -> Any:
114
+ """新建一个 VAD 状态机(有状态,每次转写须新建)。"""
115
+ import sherpa_onnx # noqa: PLC0415
116
+
117
+ return sherpa_onnx.VoiceActivityDetector(self._vad_model_config, buffer_size_in_seconds=60)
118
+
119
+ def _decode(self, samples: np.ndarray) -> str:
120
+ """单段离线解码,返回去空白文本。"""
121
+ try:
122
+ stream = self._recognizer.create_stream()
123
+ stream.accept_waveform(SAMPLE_RATE, samples)
124
+ self._recognizer.decode_stream(stream)
125
+ return str(stream.result.text).strip()
126
+ except Exception as e:
127
+ msg = f"解码失败: {e}"
128
+ raise TranscribeError(msg) from e
129
+
130
+
131
+ def run_live(
132
+ vad: Any,
133
+ chunks: Iterator[np.ndarray],
134
+ decode: Callable[[np.ndarray], str],
135
+ on_segment: Callable[[Segment], None] | None = None,
136
+ on_progress: Any = None,
137
+ total_samples: int | None = None,
138
+ max_speech_run_s: float = MAX_SPEECH_RUN_S,
139
+ ) -> list[Segment]:
140
+ """VAD 切段 + 逐段解码核心:批量转写与实时旁听共用。
141
+
142
+ Args:
143
+ vad: sherpa VoiceActivityDetector(状态机,每次调用须新建)。
144
+ chunks: 16k mono float32 音频块迭代器,逐块喂入。
145
+ decode: 单段样本 -> 文本。
146
+ on_segment: 每段解码完成即回调(实时字幕用)。
147
+ on_progress: 进度回调(需 total_samples;实时模式无总量则不回调)。
148
+ total_samples: 音频总样本数(进度计算用,实时模式为 None)。
149
+ max_speech_run_s: 连续无闭段语音超过该秒数强制闭段 —— 批量为防 VAD
150
+ buffer 溢出(55s),实时为字幕滚动预算(10s):连续口播停顿全部低于
151
+ min_silence 阈值时,VAD 永不自然闭段,字幕要等收尾 flush 才出。
152
+
153
+ Returns:
154
+ 按时间排序的 Segment 列表。
155
+ """
156
+ segments: list[Segment] = []
157
+ fed = 0 # 已喂样本数(绝对时间锚点)
158
+ last_pop = 0 # 上次弹出段时的已喂样本数
159
+ next_progress = PARAGRAPH_FEED_EVERY_S * SAMPLE_RATE
160
+
161
+ def drain() -> None:
162
+ nonlocal last_pop
163
+ while not vad.empty():
164
+ seg = vad.front
165
+ seg_samples = np.asarray(seg.samples, dtype=np.float32)
166
+ text = decode(seg_samples)
167
+ if text:
168
+ start = seg.start / SAMPLE_RATE
169
+ segment = Segment(start_s=start, end_s=start + len(seg_samples) / SAMPLE_RATE, text=text)
170
+ segments.append(segment)
171
+ if on_segment is not None:
172
+ on_segment(segment)
173
+ vad.pop()
174
+ last_pop = fed
175
+
176
+ carry = np.empty(0, dtype=np.float32)
177
+ for chunk in chunks:
178
+ buf = (
179
+ np.concatenate([carry, np.asarray(chunk, dtype=np.float32)])
180
+ if carry.size
181
+ else np.asarray(chunk, dtype=np.float32)
182
+ )
183
+ n_windows = len(buf) // VAD_WINDOW
184
+ for w in range(n_windows):
185
+ vad.accept_waveform(buf[w * VAD_WINDOW : (w + 1) * VAD_WINDOW])
186
+ fed += VAD_WINDOW
187
+ drain()
188
+ if bool(vad.is_speech_detected()) and (fed - last_pop) / SAMPLE_RATE > max_speech_run_s:
189
+ vad.flush() # 强制闭段:连续语音超预算(批量=防溢出,实时=字幕滚动)
190
+ drain()
191
+ carry = buf[n_windows * VAD_WINDOW :]
192
+ if on_progress is not None and total_samples and fed >= next_progress:
193
+ on_progress(fed / total_samples)
194
+ next_progress = fed + PARAGRAPH_FEED_EVERY_S * SAMPLE_RATE
195
+ if carry.size:
196
+ vad.accept_waveform(carry)
197
+ fed += carry.size
198
+ vad.flush()
199
+ drain()
200
+ if on_progress is not None and total_samples:
201
+ on_progress(1.0)
202
+ return segments
203
+
204
+
205
+ def read_wav16k_mono(wav: Path) -> np.ndarray:
206
+ """读 wav 为 16k mono float32 样本。
207
+
208
+ Raises:
209
+ TranscribeError: 非法 wav 或采样率/声道不符。
210
+ """
211
+ try:
212
+ with wave.open(str(wav), "rb") as w:
213
+ if w.getframerate() != SAMPLE_RATE or w.getnchannels() != 1:
214
+ msg = f"须为 16k mono wav(实际 {w.getframerate()}Hz/{w.getnchannels()}ch): {wav}"
215
+ raise TranscribeError(msg)
216
+ pcm = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2")
217
+ except wave.Error as e:
218
+ msg = f"不是合法 wav: {wav}({e})"
219
+ raise TranscribeError(msg) from e
220
+ return pcm.astype(np.float32) / 32768.0
@@ -0,0 +1,76 @@
1
+ """云端转写:复用 exomind 服务端 /transcribe/upload + /transcribe/status(dashscope)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+
10
+ from earshot.config import Settings
11
+
12
+ POLL_INTERVAL_S = 3.0
13
+ DEFAULT_TIMEOUT_S = 900.0
14
+ UPLOAD_TIMEOUT_S = 120.0
15
+
16
+
17
+ class CloudTranscribeError(RuntimeError):
18
+ """云端转写失败(未配置凭据 / 上传失败 / 转写失败 / 超时)。"""
19
+
20
+
21
+ def transcribe_cloud(wav: Path, settings: Settings, timeout_s: float = DEFAULT_TIMEOUT_S) -> str:
22
+ """上传 wav 并轮询至转写完成。
23
+
24
+ Args:
25
+ wav: 音频文件(16k wav;服务端另收 .aac/.mp3/.m4a)。
26
+ settings: 含 exomind_base_url / api_key 的配置。
27
+ timeout_s: 轮询总预算。
28
+
29
+ Returns:
30
+ 转写全文。
31
+
32
+ Raises:
33
+ CloudTranscribeError: 任一环节失败。
34
+ """
35
+ if not settings.exomind_api_key:
36
+ msg = "未配置 exomind 凭据(先 `exomind login` 生成 ~/.exomind/config.json,或设 EXOMIND_API_KEY)"
37
+ raise CloudTranscribeError(msg)
38
+ base = settings.exomind_base_url
39
+ headers = {"Authorization": f"Bearer {settings.exomind_api_key}"}
40
+
41
+ try:
42
+ with wav.open("rb") as f:
43
+ resp = httpx.post(
44
+ f"{base}/transcribe/upload",
45
+ files={"file": (wav.name, f, "audio/wav")},
46
+ headers=headers,
47
+ timeout=UPLOAD_TIMEOUT_S,
48
+ )
49
+ except httpx.HTTPError as e:
50
+ msg = f"上传失败: {e}"
51
+ raise CloudTranscribeError(msg) from e
52
+ if resp.status_code != 200:
53
+ msg = f"上传被拒({resp.status_code}): {resp.text[:300]}"
54
+ raise CloudTranscribeError(msg)
55
+ task_id = resp.json().get("task_id")
56
+ if not task_id:
57
+ msg = f"响应缺 task_id: {resp.text[:300]}"
58
+ raise CloudTranscribeError(msg)
59
+
60
+ deadline = time.monotonic() + timeout_s
61
+ while time.monotonic() < deadline:
62
+ try:
63
+ r = httpx.get(f"{base}/transcribe/status", params={"task_id": task_id}, headers=headers, timeout=30.0)
64
+ except httpx.HTTPError as e:
65
+ msg = f"轮询失败: {e}"
66
+ raise CloudTranscribeError(msg) from e
67
+ payload = r.json()
68
+ status = payload.get("status")
69
+ if status == "done":
70
+ return str(payload.get("text") or "")
71
+ if status == "failed":
72
+ msg = f"转写失败: {payload.get('error', '未知错误')}"
73
+ raise CloudTranscribeError(msg)
74
+ time.sleep(POLL_INTERVAL_S)
75
+ msg = f"转写超时({timeout_s:.0f}s),task_id={task_id}"
76
+ raise CloudTranscribeError(msg)