feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s

This commit is contained in:
Codex
2026-11-02 06:37:33 -08:00
parent 9debd1c47d
commit 3c70861463
44 changed files with 4354 additions and 333 deletions
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
VibeVoice 分角色转录(手动按需后端,非自动管线)。
把任意音频转成带说话人标签 + 时间戳的草稿。默认管线仍是 Qwen3(asr-transcribe.sh);
这个工具用于"需要谁说了什么"的场景。
用法:
vibevoice-transcribe.py <audio> [--out DIR] [--chunk 200] [--unify]
输出:
<out>/<basename>-diarized.md # 带 `mm:ss` 时间戳 + S# 说话人(每块独立编号)
<out>/<basename>-diarized.txt # 纯文本版
--unify 时额外 -global.md # ECAPA+KMeans 全局统一说话人(需 venv,单麦只能锁主讲)
注意(踩坑见 vault: ASR服务横评-2026-06):
- 服务端 segments 是 JSON 字符串,需 json.loads;非语音段无 Speaker 键。
- 200s 是最佳分段(被服务端 max_time 限,单请求 ~280s 音频上限)。
- 仅出草稿,正式纪要仍需 Kai 确认(memo-inbox 纪律)。
"""
import json, subprocess, os, math, re, sys, argparse
URL = os.environ.get("VIBE_URL", "http://192.168.50.100:10112/v1/audio/transcriptions")
MODEL = "vibevoice-asr-hf-nf4"
GLOSSARY = os.path.expanduser("~/obsidian-vault/minutes/_glossary.md")
SEG_RE = re.compile(r'\{"Start":\s*([\d.]+)\s*,\s*"End":\s*([\d.]+)\s*,\s*"Speaker":\s*(\d+|null)\s*,\s*"Content":\s*"((?:[^"\\]|\\.)*)"\s*\}')
def build_prompt():
"""Reuse glossary wikilinks + terms as a hotword prompt (same spirit as asr-transcribe.sh)."""
terms = []
p = GLOSSARY
if os.path.exists(p):
for raw in open(p, errors="ignore").read().splitlines():
s = raw.strip()
if not s or s.startswith("#") or s.startswith(">"):
continue
terms += [m.strip() for m in re.findall(r'\[\[([^\]|#]+)', s)]
s = re.sub(r'^[-*+\d\.\)\s]+', '', s)
s = re.split(r'\s[—–-]\s|[::]', s, maxsplit=1)[0].strip()
s = re.sub(r'\[\[|\]\]', '', s).strip()
if s and len(s) <= 80:
terms.append(s)
seen, out = set(), []
for t in terms:
if t and t not in seen:
seen.add(t); out.append(t)
if not out:
return ""
return "以下是会议中可能出现的人名、公司、项目、产品和术语,请优先按这些词纠正识别错误:" + " ".join(out[:200])
def parse_segs(d):
raw = d.get("segments")
arr = raw if isinstance(raw, list) else None
if arr is None and isinstance(raw, str):
try: arr = json.loads(raw)
except Exception: arr = None
if arr is None:
body = re.sub(r'^\s*assistant\s*', '', d.get("text", ""))
arr = [{"Start": float(a), "End": float(b),
"Speaker": (None if c == "null" else int(c)), "Content": w}
for a, b, c, w in SEG_RE.findall(body)]
out = []
for s in arr:
if not isinstance(s, dict):
continue
cc = (s.get("Content") or "").strip()
if not cc:
continue
out.append({"Start": float(s.get("Start", 0)), "End": float(s.get("End", 0)),
"Speaker": s.get("Speaker"), "Content": cc})
return out
def is_loop(cc):
return len(cc) > 200 and cc[:18] and cc.count(cc[:18]) > 8
def hms(x):
x = int(x); return f"{x // 60:02d}:{x % 60:02d}"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("audio")
ap.add_argument("--out", default=None, help="output dir (default: alongside audio)")
ap.add_argument("--chunk", type=int, default=200)
ap.add_argument("--unify", action="store_true", help="also run ECAPA global speaker unification")
args = ap.parse_args()
audio = os.path.abspath(os.path.expanduser(args.audio))
if not os.path.isfile(audio):
sys.exit(f"audio not found: {audio}")
base = re.sub(r'\.[^.]+$', '', os.path.basename(audio))
outdir = os.path.abspath(args.out) if args.out else os.path.dirname(audio)
os.makedirs(outdir, exist_ok=True)
work = os.path.join(outdir, f".{base}.vibework"); os.makedirs(work, exist_ok=True)
prompt = build_prompt()
dur = float(subprocess.check_output(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nk=1:nw=1", audio]).decode().strip())
n = math.ceil(dur / args.chunk)
print(f"[vibevoice] {os.path.basename(audio)} dur={dur:.0f}s -> {n} chunks of {args.chunk}s", flush=True)
all_segs = []
for i in range(n):
start = i * args.chunk
length = min(args.chunk, dur - start)
mp3 = os.path.join(work, f"c{i:02d}.mp3")
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-ss", str(start),
"-t", str(length), "-i", audio, "-ar", "16000", "-ac", "1", "-b:a", "64k", mp3], check=True)
out_json = os.path.join(work, f"c{i:02d}.json")
mnt = min(4096, 1024 + args.chunk * 12)
rc = subprocess.run(["curl", "-sS", "--max-time", "260", "-F", f"model={MODEL}",
"-F", f"file=@{mp3}", "-F", f"prompt={prompt}",
"-F", f"max_new_tokens={mnt}", URL, "-o", out_json]).returncode
try:
d = json.load(open(out_json)); segs = parse_segs(d)
except Exception as e:
print(f" chunk {i+1}/{n} FAIL ({e}) rc={rc}", flush=True); continue
loops = sum(1 for s in segs if is_loop(s["Content"]))
last = max((s["End"] for s in segs), default=0)
for s in segs:
cc = s["Content"]
if len(cc) > 400:
cc = cc[:120] + " …[复读截断]"
all_segs.append({"Start": start + s["Start"], "End": start + s["End"],
"Speaker": s["Speaker"], "Content": cc})
tag = f" ⚠loop×{loops}" if loops else ""
print(f" chunk {i+1}/{n} @{start:.0f}s: {len(segs)} segs, cov->{last:.0f}/{length:.0f}s{tag}", flush=True)
all_segs.sort(key=lambda x: x["Start"])
md = [f"# {base} · VibeVoice 分角色草稿",
f"> 音频 {dur/60:.0f}min · {args.chunk}s 切块 · 说话人 S# **每块独立编号**(块间需对齐) · 仅草稿待确认\n"]
txt = []
for s in all_segs:
spk = f"S{s['Speaker']}" if s["Speaker"] is not None else "·"
md.append(f"- `{hms(s['Start'])}` **{spk}**: {s['Content']}")
txt.append(f"[{hms(s['Start'])}] {spk}: {s['Content']}")
md_path = os.path.join(outdir, f"{base}-diarized.md")
txt_path = os.path.join(outdir, f"{base}-diarized.txt")
open(md_path, "w").write("\n".join(md))
open(txt_path, "w").write("\n".join(txt))
json.dump(all_segs, open(os.path.join(work, "segs.json"), "w"), ensure_ascii=False)
print(f"[vibevoice] {len(all_segs)} segs -> {md_path}", flush=True)
if args.unify:
unify = os.path.join(os.path.dirname(os.path.abspath(__file__)), "vibevoice-speaker-unify.py")
venv_py = os.environ.get("ASR_VENV_PY",
os.path.expanduser("~/claude-workspace/OBSIDIAN/recordings/asr-venv/bin/python"))
if os.path.exists(venv_py) and os.path.exists(unify):
print("[vibevoice] running ECAPA global speaker unification ...", flush=True)
subprocess.run([venv_py, unify, audio, os.path.join(work, "segs.json"),
os.path.join(outdir, f"{base}-global.md")])
else:
print(f"[vibevoice] --unify skipped: venv ({venv_py}) or unify script missing. "
f"See vault ASR服务横评 to rebuild venv.", flush=True)
if __name__ == "__main__":
main()