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/compose.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""转写段 → Markdown 笔记:分段、标题、元信息脚注、outbox 落盘。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from earshot.transcribe import Segment
|
|
11
|
+
|
|
12
|
+
PARAGRAPH_GAP_S = 2.5 # 段间隔超过此值即换段(自然段)
|
|
13
|
+
PARAGRAPH_MAX_CHARS = 400 # 单段上限,超出在句号处拆(口播字幕无长停顿时的兜底)
|
|
14
|
+
SENTENCE_MAX_CHARS = 60 # 句长预算:连续无句号超过该字数,强制断句
|
|
15
|
+
TITLE_MAX_CHARS = 40
|
|
16
|
+
|
|
17
|
+
SOURCE_LABELS = {"listen": "旁听录音", "audio": "音频文件", "video": "视频字幕"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class NoteMeta:
|
|
22
|
+
"""笔记元信息(渲染进脚注)。
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
started_at: 录音开始时间。
|
|
26
|
+
duration_s: 音频时长(秒)。
|
|
27
|
+
device: 采集设备名(audio 文件模式为 ``-``)。
|
|
28
|
+
source: ``listen`` 或 ``audio``。
|
|
29
|
+
note: 来源备注(如"B站·xx视频"),可为空。
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
started_at: datetime
|
|
33
|
+
duration_s: float
|
|
34
|
+
device: str
|
|
35
|
+
source: str
|
|
36
|
+
note: str
|
|
37
|
+
source_url: str = "" # 原文链接(video 路线=视频 URL),投递时传服务端作 source_url 留痕
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def group_paragraphs(
|
|
41
|
+
segments: list[Segment],
|
|
42
|
+
gap_s: float = PARAGRAPH_GAP_S,
|
|
43
|
+
chapter_breaks: list[float] | None = None,
|
|
44
|
+
) -> list[list[Segment]]:
|
|
45
|
+
"""聚段为自然段:章节边界 > 时间间隔 > 长度兜底。
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
segments: 时间排序的转写段。
|
|
49
|
+
gap_s: 相邻段时间间隔超过该值则分段。
|
|
50
|
+
chapter_breaks: 视频章节起点(秒,B站章节标记),跨过即强制换段。
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
段的列表(每个元素是一个自然段);单段不超过 PARAGRAPH_MAX_CHARS 字(句号处拆)。
|
|
54
|
+
"""
|
|
55
|
+
chapters = sorted(chapter_breaks or [])
|
|
56
|
+
paragraphs: list[list[Segment]] = []
|
|
57
|
+
for seg in segments:
|
|
58
|
+
make_new = not paragraphs or seg.start_s - paragraphs[-1][-1].end_s > gap_s
|
|
59
|
+
if not make_new and chapters and paragraphs:
|
|
60
|
+
prev_start = paragraphs[-1][0].start_s # 现段起点跨过章节边界而本段首未跨 → 新段
|
|
61
|
+
make_new = any(prev_start < cb <= seg.start_s for cb in chapters)
|
|
62
|
+
if make_new:
|
|
63
|
+
paragraphs.append([seg])
|
|
64
|
+
else:
|
|
65
|
+
paragraphs[-1].append(seg)
|
|
66
|
+
|
|
67
|
+
result: list[list[Segment]] = []
|
|
68
|
+
for para in paragraphs:
|
|
69
|
+
if sum(len(s.text) for s in para) <= PARAGRAPH_MAX_CHARS:
|
|
70
|
+
result.append(para)
|
|
71
|
+
continue
|
|
72
|
+
current: list[Segment] = []
|
|
73
|
+
current_chars = 0
|
|
74
|
+
for s in para: # 超长段在句号处拆(口播字幕无长停顿时的兜底)
|
|
75
|
+
current.append(s)
|
|
76
|
+
current_chars += len(s.text)
|
|
77
|
+
if current_chars >= PARAGRAPH_MAX_CHARS and s.text.rstrip().endswith(("。", "?", "!", "!", "?")):
|
|
78
|
+
result.append(current)
|
|
79
|
+
current = []
|
|
80
|
+
current_chars = 0
|
|
81
|
+
if current:
|
|
82
|
+
result.append(current)
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
SENTENCE_END_GAP_S = 0.8 # 段间隔超过此值视为一句话说完
|
|
87
|
+
_PUNCT_ENDINGS = "。!?;,、,.!?;:"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def add_punctuation(segments: list[Segment]) -> list[Segment]:
|
|
91
|
+
"""按时间轴给无标点转写恢复标点(B站 AI 字幕原文无标点;SenseVoice 已有,自动跳过)。
|
|
92
|
+
|
|
93
|
+
规则:段尾已有标点不动;与下段时间间隔大→句号(问句语气词→问号),小→逗号;末段补句号。
|
|
94
|
+
句长预算:连续无句号超过 SENTENCE_MAX_CHARS 字时强制断句(口播字幕行间隔密集,
|
|
95
|
+
固定间隔阈值会漏断,产生超长逗号串)。
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
segments: 时间排序的转写段。
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
补齐标点的新 Segment 列表(原列表不改)。
|
|
102
|
+
"""
|
|
103
|
+
out: list[Segment] = []
|
|
104
|
+
chars_since_period = 0
|
|
105
|
+
for i, seg in enumerate(segments):
|
|
106
|
+
text = seg.text.strip()
|
|
107
|
+
if not text:
|
|
108
|
+
continue
|
|
109
|
+
chars_since_period += len(text)
|
|
110
|
+
if text[-1] in _PUNCT_ENDINGS:
|
|
111
|
+
if text[-1] in "。!?!?":
|
|
112
|
+
chars_since_period = 0
|
|
113
|
+
elif i + 1 < len(segments):
|
|
114
|
+
gap = segments[i + 1].start_s - seg.end_s
|
|
115
|
+
if gap > SENTENCE_END_GAP_S or chars_since_period >= SENTENCE_MAX_CHARS:
|
|
116
|
+
text += "?" if text[-1] in ("吗", "呢", "吧") else "。"
|
|
117
|
+
chars_since_period = 0
|
|
118
|
+
else:
|
|
119
|
+
text += ","
|
|
120
|
+
else:
|
|
121
|
+
text += "?" if text[-1] in ("吗", "呢", "吧") else "。"
|
|
122
|
+
chars_since_period = 0
|
|
123
|
+
out.append(Segment(start_s=seg.start_s, end_s=seg.end_s, text=text))
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def derive_title(segments: list[Segment], override: str | None = None) -> str:
|
|
128
|
+
"""标题:``--title`` 覆盖优先,否则取首个非空段截断。
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
标题文本;无任何内容时返回 ``"(无语音内容)"``。
|
|
132
|
+
"""
|
|
133
|
+
if override:
|
|
134
|
+
return override.strip()
|
|
135
|
+
first = next((s.text.strip() for s in segments if s.text.strip()), "")
|
|
136
|
+
first = first.rstrip(_PUNCT_ENDINGS)
|
|
137
|
+
if not first:
|
|
138
|
+
return "(无语音内容)"
|
|
139
|
+
if len(first) > TITLE_MAX_CHARS:
|
|
140
|
+
return first[:TITLE_MAX_CHARS].rstrip() + "…"
|
|
141
|
+
return first
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def format_duration(seconds: float) -> str:
|
|
145
|
+
"""时长人性化:``1时02分03秒`` / ``12分34秒`` / ``45秒``。"""
|
|
146
|
+
total = max(0, int(round(seconds)))
|
|
147
|
+
h, rem = divmod(total, 3600)
|
|
148
|
+
m, s = divmod(rem, 60)
|
|
149
|
+
if h:
|
|
150
|
+
return f"{h}时{m:02d}分{s:02d}秒"
|
|
151
|
+
if m:
|
|
152
|
+
return f"{m}分{s:02d}秒"
|
|
153
|
+
return f"{s}秒"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def slugify(title: str) -> str:
|
|
157
|
+
"""标题 → 文件名安全片段(保留中文/字母/数字,其余折叠为 ``-``)。"""
|
|
158
|
+
slug = re.sub(r"[^\w一-鿿]+", "-", title).strip("-")
|
|
159
|
+
slug = slug[:30].strip("-")
|
|
160
|
+
return slug or "note"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def render_markdown(meta: NoteMeta, paragraphs: list[str], title: str) -> str:
|
|
164
|
+
"""渲染完整 Markdown 笔记。
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
meta: 元信息。
|
|
168
|
+
paragraphs: 已拼接好的自然段文本列表。
|
|
169
|
+
title: 笔记标题(同时用于投递侧 ``--title``)。
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Markdown 字符串,含元信息脚注。
|
|
173
|
+
"""
|
|
174
|
+
source_label = SOURCE_LABELS.get(meta.source, meta.source)
|
|
175
|
+
lines = [f"# {title}", ""]
|
|
176
|
+
for para in paragraphs: # Markdown 相邻行会合并同段,段落间必须空行
|
|
177
|
+
lines.append(para)
|
|
178
|
+
lines.append("")
|
|
179
|
+
lines += [
|
|
180
|
+
"",
|
|
181
|
+
"---",
|
|
182
|
+
"",
|
|
183
|
+
f"- 来源: {source_label}",
|
|
184
|
+
f"- 开始: {meta.started_at.strftime('%Y-%m-%d %H:%M:%S')}",
|
|
185
|
+
f"- 时长: {format_duration(meta.duration_s)}",
|
|
186
|
+
f"- 设备: {meta.device}",
|
|
187
|
+
]
|
|
188
|
+
if meta.note:
|
|
189
|
+
lines.append(f"- 备注: {meta.note}")
|
|
190
|
+
return "\n".join(lines).strip() + "\n"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def outbox_path(outbox: Path, started_at: datetime, title: str) -> Path:
|
|
194
|
+
"""outbox 文件路径:``YYYYMMDD-HHMMSS-<slug>.md``。"""
|
|
195
|
+
return outbox / f"{started_at.strftime('%Y%m%d-%H%M%S')}-{slugify(title)}.md"
|
earshot/config.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""配置加载:earshot 数据目录、模型根目录、exomind 凭据。
|
|
2
|
+
|
|
3
|
+
优先级:环境变量 > ``~/.exomind/config.json`` > 默认值。
|
|
4
|
+
exomind 凭据与 exomind CLI 共用(``~/.exomind/config.json``),云引擎与投递不用重复登录。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
EARSHOT_HOME_ENV = "EARSHOT_HOME"
|
|
15
|
+
EARSHOT_MODEL_ROOT_ENV = "EARSHOT_MODEL_ROOT"
|
|
16
|
+
EXOMIND_API_KEY_ENV = "EXOMIND_API_KEY"
|
|
17
|
+
EXOMIND_BASE_URL_ENV = "EXOMIND_BASE_URL"
|
|
18
|
+
|
|
19
|
+
DEFAULT_EXOMIND_BASE_URL = "https://exomind-api.ai-as.cn"
|
|
20
|
+
EXOMIND_CONFIG_FILENAME = ".exomind/config.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class Settings:
|
|
25
|
+
"""运行时配置快照。
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
home: earshot 数据目录(outbox 留档、tmp 录音)。
|
|
29
|
+
model_root: 模型根目录,内含 ``asr/`` 与 ``vad/``。
|
|
30
|
+
exomind_base_url: exomind API 地址(云引擎 + 投递)。
|
|
31
|
+
exomind_api_key: API key,未配置时为空串(仅本地模式可用)。
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
home: Path
|
|
35
|
+
model_root: Path
|
|
36
|
+
exomind_base_url: str
|
|
37
|
+
exomind_api_key: str
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def outbox(self) -> Path:
|
|
41
|
+
"""转写笔记留档目录。"""
|
|
42
|
+
return self.home / "outbox"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_settings(
|
|
46
|
+
env: dict[str, str] | None = None,
|
|
47
|
+
exomind_config_path: Path | None = None,
|
|
48
|
+
) -> Settings:
|
|
49
|
+
"""解析配置。
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
env: 环境变量映射,默认取 ``os.environ``(测试注入用)。
|
|
53
|
+
exomind_config_path: exomind 配置文件路径,默认 ``~/.exomind/config.json``。
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Settings 快照。
|
|
57
|
+
"""
|
|
58
|
+
env = dict(os.environ) if env is None else env
|
|
59
|
+
exo = load_exomind_config(exomind_config_path)
|
|
60
|
+
home = Path(env.get(EARSHOT_HOME_ENV, str(Path.home() / ".earshot")))
|
|
61
|
+
model_root = Path(env.get(EARSHOT_MODEL_ROOT_ENV, str(home / "models")))
|
|
62
|
+
base_url = env.get(EXOMIND_BASE_URL_ENV) or exo.get("base_url") or DEFAULT_EXOMIND_BASE_URL
|
|
63
|
+
api_key = env.get(EXOMIND_API_KEY_ENV) or exo.get("api_key") or ""
|
|
64
|
+
return Settings(
|
|
65
|
+
home=home,
|
|
66
|
+
model_root=model_root,
|
|
67
|
+
exomind_base_url=base_url.rstrip("/"),
|
|
68
|
+
exomind_api_key=api_key,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_exomind_config(path: Path | None = None) -> dict[str, str]:
|
|
73
|
+
"""读取 exomind CLI 的配置文件,不存在或损坏时返回空 dict。
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
path: 配置文件路径,默认 ``~/.exomind/config.json``。
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
只含 ``base_url`` / ``api_key`` 等字符串键的 dict。
|
|
80
|
+
"""
|
|
81
|
+
cfg_path = path if path is not None else Path.home() / EXOMIND_CONFIG_FILENAME
|
|
82
|
+
try:
|
|
83
|
+
raw = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
84
|
+
except (OSError, ValueError):
|
|
85
|
+
return {}
|
|
86
|
+
if not isinstance(raw, dict):
|
|
87
|
+
return {}
|
|
88
|
+
return {k: v for k, v in raw.items() if isinstance(v, str)}
|
earshot/deliver.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""投递层:exomind API(直连,免 CLI)/ exomind CLI / webhook / 不投递,可插拔。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from earshot.config import Settings
|
|
16
|
+
|
|
17
|
+
EXOMIND_ORIGIN = "earshot"
|
|
18
|
+
EXOMIND_PENDING_EXIT_CODE = 3 # exomind CLI 契约:3 = 已提交、后台仍在抽取,不是失败
|
|
19
|
+
DELIVER_TIMEOUT_S = 600.0
|
|
20
|
+
INGEST_POLL_INTERVAL_S = 2.0
|
|
21
|
+
INGEST_POLL_BUDGET_S = 120.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class DeliveryResult:
|
|
26
|
+
"""投递结果。
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
status: ``delivered`` / ``pending`` / ``skipped`` / ``failed``。
|
|
30
|
+
detail: 给用户看的一句话说明。
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
status: str
|
|
34
|
+
detail: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_exomind_args(
|
|
38
|
+
md_path: Path,
|
|
39
|
+
title: str,
|
|
40
|
+
tags: list[str],
|
|
41
|
+
origin: str = EXOMIND_ORIGIN,
|
|
42
|
+
) -> list[str]:
|
|
43
|
+
"""组装 exomind ingest 命令行(纯函数,wire 契约由单测钉死)。"""
|
|
44
|
+
args = ["exomind", "ingest", "--file", str(md_path), "--origin", origin]
|
|
45
|
+
if title:
|
|
46
|
+
args += ["--title", title]
|
|
47
|
+
for tag in tags:
|
|
48
|
+
args += ["--tag", tag]
|
|
49
|
+
return args
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def deliver_exomind(
|
|
53
|
+
md_path: Path,
|
|
54
|
+
title: str,
|
|
55
|
+
tags: list[str],
|
|
56
|
+
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
|
57
|
+
) -> DeliveryResult:
|
|
58
|
+
"""经 exomind CLI 投递(复用其异步 job、轮询与 client_key 幂等)。
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
md_path: 笔记文件。
|
|
62
|
+
title: 标题(透传 ``--title``)。
|
|
63
|
+
tags: 标签列表。
|
|
64
|
+
runner: subprocess 运行器(测试注入用)。
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
DeliveryResult,``pending`` 对应 CLI exit 3(已提交未完成)。
|
|
68
|
+
"""
|
|
69
|
+
cmd = build_exomind_args(md_path, title, tags)
|
|
70
|
+
try:
|
|
71
|
+
proc = runner(cmd, capture_output=True, text=True, timeout=DELIVER_TIMEOUT_S)
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
return DeliveryResult("failed", "未找到 exomind CLI(npm i -g exomind,或改用 --deliver webhook/none)")
|
|
74
|
+
except subprocess.TimeoutExpired:
|
|
75
|
+
return DeliveryResult("failed", f"exomind CLI 超时({DELIVER_TIMEOUT_S:.0f}s)")
|
|
76
|
+
out_lines = (proc.stdout or "").strip().splitlines()
|
|
77
|
+
tail = out_lines[-1] if out_lines else ""
|
|
78
|
+
if proc.returncode == 0:
|
|
79
|
+
return DeliveryResult("delivered", tail or "已投递进知识库")
|
|
80
|
+
if proc.returncode == EXOMIND_PENDING_EXIT_CODE:
|
|
81
|
+
return DeliveryResult("pending", "已提交,后台抽取中(exomind 会幂等续查回执)")
|
|
82
|
+
err = (proc.stderr or "").strip().splitlines()
|
|
83
|
+
detail = err[-1] if err else tail or f"exomind CLI 退出码 {proc.returncode}"
|
|
84
|
+
return DeliveryResult("failed", detail)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def deliver_webhook(
|
|
88
|
+
url: str,
|
|
89
|
+
title: str,
|
|
90
|
+
content_md: str,
|
|
91
|
+
tags: list[str],
|
|
92
|
+
origin: str = EXOMIND_ORIGIN,
|
|
93
|
+
poster: Callable[[str, dict[str, str | list[str]]], None] | None = None,
|
|
94
|
+
) -> DeliveryResult:
|
|
95
|
+
"""POST JSON 到任意 webhook,适配非 exomind 知识库。
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
url: webhook 地址。
|
|
99
|
+
title: 标题。
|
|
100
|
+
content_md: Markdown 正文。
|
|
101
|
+
tags: 标签。
|
|
102
|
+
origin: 来源标识。
|
|
103
|
+
poster: 发送函数(测试注入用),抛异常即视为失败。
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
DeliveryResult。
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def _post(target: str, payload: dict[str, str | list[str]]) -> None:
|
|
110
|
+
resp = httpx.post(target, json=payload, timeout=30.0)
|
|
111
|
+
resp.raise_for_status()
|
|
112
|
+
|
|
113
|
+
send = poster or _post
|
|
114
|
+
payload: dict[str, str | list[str]] = {"origin": origin, "title": title, "content": content_md, "tags": tags}
|
|
115
|
+
try:
|
|
116
|
+
send(url, payload)
|
|
117
|
+
except Exception as e: # httpx 异常族不稳定,统一收口
|
|
118
|
+
return DeliveryResult("failed", f"webhook 投递失败: {e}")
|
|
119
|
+
return DeliveryResult("delivered", f"已 POST 到 {url}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def client_key_for(content: str) -> str:
|
|
123
|
+
"""内容幂等键:与服务端两层幂等(优先级最高)对齐,重复投递不会入库两次。"""
|
|
124
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _as_count(value: Any) -> int:
|
|
128
|
+
"""回执计数兼容:服务端字段时而为个数(int)、时而为列表,统一取数。"""
|
|
129
|
+
if isinstance(value, int):
|
|
130
|
+
return value
|
|
131
|
+
if isinstance(value, list):
|
|
132
|
+
return len(value)
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def deliver_exomind_api(
|
|
137
|
+
md_path: Path,
|
|
138
|
+
title: str,
|
|
139
|
+
tags: list[str],
|
|
140
|
+
settings: Settings,
|
|
141
|
+
poster: Callable[[str, dict[str, Any], dict[str, str]], dict[str, Any]] | None = None,
|
|
142
|
+
getter: Callable[[str, dict[str, str], dict[str, str]], dict[str, Any]] | None = None,
|
|
143
|
+
poll_interval_s: float = INGEST_POLL_INTERVAL_S,
|
|
144
|
+
poll_budget_s: float = INGEST_POLL_BUDGET_S,
|
|
145
|
+
source_url: str | None = None,
|
|
146
|
+
) -> DeliveryResult:
|
|
147
|
+
"""直连 exomind HTTP API 投递(免装 exomind CLI)。
|
|
148
|
+
|
|
149
|
+
走 POST /ingest/async + GET /ingest/status,与 CLI 同契约:
|
|
150
|
+
秒回 job_id → 轮询至 done/failed;超预算视为 pending(内容已入库,抽取仍在跑)。
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
md_path: 笔记文件。
|
|
154
|
+
title: 标题。
|
|
155
|
+
tags: 标签。
|
|
156
|
+
settings: 含 base_url / api_key 的配置。
|
|
157
|
+
poster: ``(url, payload, headers) -> 响应 dict``(测试注入)。
|
|
158
|
+
getter: ``(url, params, headers) -> 响应 dict``(测试注入)。
|
|
159
|
+
poll_interval_s / poll_budget_s: 轮询节奏与总预算。
|
|
160
|
+
source_url: 原文链接(video 路线);content+url 并存时服务端仅作出处留痕,不抓取。
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
DeliveryResult。
|
|
164
|
+
"""
|
|
165
|
+
if not settings.exomind_api_key:
|
|
166
|
+
return DeliveryResult("failed", "无 exomind 凭据(调用方应回退 CLI)")
|
|
167
|
+
|
|
168
|
+
def _post(url: str, payload: dict[str, Any], headers: dict[str, str]) -> dict[str, Any]:
|
|
169
|
+
resp = httpx.post(url, json=payload, headers=headers, timeout=60.0)
|
|
170
|
+
resp.raise_for_status()
|
|
171
|
+
return dict(resp.json())
|
|
172
|
+
|
|
173
|
+
def _get(url: str, params: dict[str, str], headers: dict[str, str]) -> dict[str, Any]:
|
|
174
|
+
resp = httpx.get(url, params=params, headers=headers, timeout=30.0)
|
|
175
|
+
resp.raise_for_status()
|
|
176
|
+
return dict(resp.json())
|
|
177
|
+
|
|
178
|
+
post = poster or _post
|
|
179
|
+
get = getter or _get
|
|
180
|
+
base = settings.exomind_base_url
|
|
181
|
+
headers = {"Authorization": f"Bearer {settings.exomind_api_key}"}
|
|
182
|
+
|
|
183
|
+
content = md_path.read_text(encoding="utf-8")
|
|
184
|
+
payload: dict[str, Any] = {"content": content, "origin": EXOMIND_ORIGIN, "client_key": client_key_for(content)}
|
|
185
|
+
if title:
|
|
186
|
+
payload["title"] = title
|
|
187
|
+
if tags:
|
|
188
|
+
payload["tags"] = tags
|
|
189
|
+
if source_url:
|
|
190
|
+
payload["url"] = source_url
|
|
191
|
+
try:
|
|
192
|
+
resp = post(f"{base}/ingest/async", payload, headers)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
return DeliveryResult("failed", f"提交 ingest 失败: {e}")
|
|
195
|
+
job_id = resp.get("job_id")
|
|
196
|
+
if not job_id:
|
|
197
|
+
return DeliveryResult("failed", f"响应缺 job_id: {str(resp)[:200]}")
|
|
198
|
+
|
|
199
|
+
deadline = time.monotonic() + poll_budget_s
|
|
200
|
+
try:
|
|
201
|
+
while time.monotonic() < deadline:
|
|
202
|
+
try:
|
|
203
|
+
st = get(f"{base}/ingest/status", {"job_id": str(job_id)}, headers)
|
|
204
|
+
except Exception as e:
|
|
205
|
+
return DeliveryResult("failed", f"查询回执失败(job_id={job_id}): {e}")
|
|
206
|
+
status = st.get("status")
|
|
207
|
+
if status == "done":
|
|
208
|
+
n_entities = _as_count(st.get("entities"))
|
|
209
|
+
n_pages = _as_count(st.get("created_pages"))
|
|
210
|
+
n_updated = _as_count(st.get("updated_pages"))
|
|
211
|
+
detail = f"已入库:实体 {n_entities} 个,页面 +{n_pages}/更{n_updated}(job_id={job_id})"
|
|
212
|
+
return DeliveryResult("delivered", detail)
|
|
213
|
+
if status == "failed":
|
|
214
|
+
return DeliveryResult("failed", f"服务端抽取失败: {st.get('error', '未知')}(job_id={job_id})")
|
|
215
|
+
time.sleep(poll_interval_s)
|
|
216
|
+
except KeyboardInterrupt:
|
|
217
|
+
# Ctrl-C = 不等回执了;内容已提交,服务端 job 会继续跑完
|
|
218
|
+
return DeliveryResult("pending", f"已提交,不再等待回执(job_id={job_id})")
|
|
219
|
+
return DeliveryResult("pending", f"已提交,后台抽取中(job_id={job_id})")
|
earshot/extract.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""ffmpeg 工具:任意音视频 → 16k mono wav(ASR 统一入口格式)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import wave
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
FFMPEG = "ffmpeg"
|
|
11
|
+
TARGET_SAMPLE_RATE = 16000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ExtractError(RuntimeError):
|
|
15
|
+
"""ffmpeg 抽音轨失败。"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ffmpeg_exe() -> str | None:
|
|
19
|
+
"""定位 ffmpeg:优先系统 PATH,其次 imageio-ffmpeg wheel 内置二进制。
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
ffmpeg 可执行文件路径,两级都无则 None。
|
|
23
|
+
"""
|
|
24
|
+
found = shutil.which(FFMPEG)
|
|
25
|
+
if found:
|
|
26
|
+
return found
|
|
27
|
+
try:
|
|
28
|
+
import imageio_ffmpeg # noqa: PLC0415 延迟导入:装了系统 ffmpeg 就不必加载
|
|
29
|
+
|
|
30
|
+
return str(imageio_ffmpeg.get_ffmpeg_exe())
|
|
31
|
+
except Exception: # imageio_ffmpeg 缺失或平台无二进制
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def ffmpeg_available() -> bool:
|
|
36
|
+
"""ffmpeg 是否可用(系统或内置 wheel 任一)。"""
|
|
37
|
+
return ffmpeg_exe() is not None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def to_wav16k(src: Path, dst: Path) -> None:
|
|
41
|
+
"""抽音轨并重采样为 16k mono wav。
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
src: 输入任意音视频文件(ffmpeg 支持即行)。
|
|
45
|
+
dst: 输出 wav 路径。
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ExtractError: ffmpeg 缺失或执行失败。
|
|
49
|
+
"""
|
|
50
|
+
exe = ffmpeg_exe()
|
|
51
|
+
if exe is None:
|
|
52
|
+
msg = "未找到 ffmpeg(系统 PATH 与 imageio-ffmpeg 内置均无;重装 earshot 依赖)"
|
|
53
|
+
raise ExtractError(msg)
|
|
54
|
+
cmd = [
|
|
55
|
+
exe,
|
|
56
|
+
"-hide_banner",
|
|
57
|
+
"-loglevel",
|
|
58
|
+
"error",
|
|
59
|
+
"-y",
|
|
60
|
+
"-i",
|
|
61
|
+
str(src),
|
|
62
|
+
"-vn",
|
|
63
|
+
"-ac",
|
|
64
|
+
"1",
|
|
65
|
+
"-ar",
|
|
66
|
+
str(TARGET_SAMPLE_RATE),
|
|
67
|
+
"-f",
|
|
68
|
+
"wav",
|
|
69
|
+
str(dst),
|
|
70
|
+
]
|
|
71
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600) # noqa: S603
|
|
72
|
+
if proc.returncode != 0:
|
|
73
|
+
msg = f"ffmpeg 失败({proc.returncode}): {(proc.stderr or '').strip()[:500]}"
|
|
74
|
+
raise ExtractError(msg)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def wav_duration(path: Path) -> float:
|
|
78
|
+
"""读 wav 时长(秒)。
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ExtractError: 文件不是合法 wav。
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
with wave.open(str(path), "rb") as w:
|
|
85
|
+
return w.getnframes() / float(w.getframerate())
|
|
86
|
+
except wave.Error as e:
|
|
87
|
+
msg = f"不是合法 wav: {path}({e})"
|
|
88
|
+
raise ExtractError(msg) from e
|
earshot/live.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""实时旁听:录音线程边录边喂,后台线程 VAD 切段+解码,段闭合即出字幕。
|
|
2
|
+
|
|
3
|
+
录音不中断、转写不阻塞:两者经队列解耦,SenseVoice 单段解码远快于实时,
|
|
4
|
+
后台线程永远追得上。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import queue
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable, Iterator
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from earshot.transcribe import Segment, TranscribeError, run_live
|
|
17
|
+
|
|
18
|
+
LIVE_CAPTION_MAX_RUN_S = 10.0 # 字幕滚动预算:连续语音超过该秒数强制闭段出字幕
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LiveSession:
|
|
22
|
+
"""边录边转会话:push 喂 16k 音频块,finalize 收尾并返回全部 Segment。
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
transcriber: 已初始化的 LocalTranscriber(提供 VAD 工厂与解码器)。
|
|
26
|
+
on_text: 每段文本就绪即回调(实时字幕;回调里别做慢活,会拖住解码线程)。
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, transcriber: Any, on_text: Callable[[str], None] | None = None) -> None:
|
|
30
|
+
self._transcriber = transcriber
|
|
31
|
+
self._on_text = on_text
|
|
32
|
+
self._q: queue.Queue[np.ndarray | None] = queue.Queue()
|
|
33
|
+
self._segments: list[Segment] = []
|
|
34
|
+
self._err: BaseException | None = None
|
|
35
|
+
self._thread = threading.Thread(target=self._consume, daemon=True)
|
|
36
|
+
|
|
37
|
+
def start(self) -> None:
|
|
38
|
+
"""启动后台解码线程。"""
|
|
39
|
+
self._thread.start()
|
|
40
|
+
|
|
41
|
+
def push(self, samples: np.ndarray) -> None:
|
|
42
|
+
"""喂一块 16k mono float32 样本(录音线程调用,非阻塞)。"""
|
|
43
|
+
self._q.put(samples)
|
|
44
|
+
|
|
45
|
+
def finalize(self, timeout_s: float = 300.0) -> list[Segment]:
|
|
46
|
+
"""收尾:喂入结束哨兵,等尾部音频解码完,返回全部 Segment。
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
TranscribeError: 后台线程曾出错。
|
|
50
|
+
"""
|
|
51
|
+
self._q.put(None)
|
|
52
|
+
self._thread.join(timeout_s)
|
|
53
|
+
if self._err is not None:
|
|
54
|
+
msg = f"实时转写线程出错: {self._err}"
|
|
55
|
+
raise TranscribeError(msg) from self._err
|
|
56
|
+
return self._segments
|
|
57
|
+
|
|
58
|
+
def _consume(self) -> None:
|
|
59
|
+
try:
|
|
60
|
+
self._segments = run_live(
|
|
61
|
+
vad=self._transcriber._new_vad(),
|
|
62
|
+
chunks=self._iter_queue(),
|
|
63
|
+
decode=self._transcriber._decode,
|
|
64
|
+
on_segment=lambda seg: self._on_text(seg.text) if self._on_text else None,
|
|
65
|
+
max_speech_run_s=LIVE_CAPTION_MAX_RUN_S, # 连续口播停顿<min_silence 时也保证字幕滚动
|
|
66
|
+
)
|
|
67
|
+
except BaseException as e: # 线程内不可抛,存起来由 finalize 抛
|
|
68
|
+
self._err = e
|
|
69
|
+
|
|
70
|
+
def _iter_queue(self) -> Iterator[np.ndarray]:
|
|
71
|
+
"""队列 -> 迭代器,收到哨兵即结束。"""
|
|
72
|
+
while True:
|
|
73
|
+
item = self._q.get()
|
|
74
|
+
if item is None:
|
|
75
|
+
return
|
|
76
|
+
yield item
|