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/__init__.py +7 -0
- earshot/capture.py +148 -0
- earshot/cli.py +545 -0
- earshot/compose.py +195 -0
- earshot/config.py +88 -0
- earshot/deliver.py +219 -0
- earshot/extract.py +88 -0
- earshot/live.py +76 -0
- earshot/models.py +72 -0
- earshot/subtitle.py +207 -0
- earshot/transcribe.py +220 -0
- earshot/transcribe_cloud.py +76 -0
- earshot_cli-0.1.0.dist-info/METADATA +140 -0
- earshot_cli-0.1.0.dist-info/RECORD +17 -0
- earshot_cli-0.1.0.dist-info/WHEEL +4 -0
- earshot_cli-0.1.0.dist-info/entry_points.txt +2 -0
- earshot_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|