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,255 @@
#!/usr/bin/env bash
set -euo pipefail
INPUT=${1:?audio file required}
OUT=${2:?output txt required}
ASR_URL=${MEETING_ASR_URL:-http://192.168.50.100:8000/v1/audio/transcriptions}
ASR_MODEL=${MEETING_ASR_MODEL:-qwen3-asr}
SEGMENT_SECONDS=${MEETING_ASR_SEGMENT_SECONDS:-540}
LANGUAGE=${MEETING_ASR_LANGUAGE:-zh}
GLOSSARY=${MEETING_ASR_GLOSSARY:-$HOME/obsidian-vault/minutes/_glossary.md}
# Base dir derived from this script's location (recordings/bin/asr-transcribe.sh -> recordings/),
# overridable via MEETING_INBOX_BASE. Migration-proof.
BASE="${MEETING_INBOX_BASE:-$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)}"
LOG_DIR="$BASE/logs"
LOG="$LOG_DIR/asr-transcribe.log"
mkdir -p "$LOG_DIR" "$(dirname "$OUT")"
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG"
}
if [[ ! -f "$INPUT" ]]; then
echo "input file not found: $INPUT" >&2
exit 2
fi
# ---- Cloud / Unified Provider branch (soniox / gemini / openai / grok) ------
case "${MEETING_ASR_BACKEND:-}" in
soniox|gemini|openai|grok)
PROJECT_ROOT="$(cd "$BASE/.." && pwd)"
log "START backend=${MEETING_ASR_BACKEND} input=$INPUT out=$OUT"
if PYTHONPATH="$PROJECT_ROOT:${PYTHONPATH:-}" python3 - "$INPUT" "$OUT" "${MEETING_ASR_BACKEND}" "${MEETING_ASR_MODEL:-}" <<'PY' 2>> "$LOG"
import sys
from pathlib import Path
from asrpipe.providers import transcribe_audio
audio = Path(sys.argv[1])
out = Path(sys.argv[2])
provider = sys.argv[3]
model = sys.argv[4] if len(sys.argv) > 4 else ""
res = transcribe_audio(audio, provider=provider, model=model)
out.write_text(res.transcript_text, encoding="utf-8")
PY
then
log "DONE backend=${MEETING_ASR_BACKEND} out=$OUT chars=$(wc -c < "$OUT")"
exit 0
fi
echo "${MEETING_ASR_BACKEND} transcription failed (see $LOG)" >&2
log "ERROR backend=${MEETING_ASR_BACKEND} failed"
exit 13
;;
esac
# ---- Backend switch (trial): VibeVoice 分角色后端 ----------------------------
# Default backend stays qwen3 (logic below). Set MEETING_ASR_BACKEND=vibevoice
# (e.g. in obsidian-recording-watch.service) to route the live pipeline through
# vibevoice-transcribe.py, producing a diarized transcript (timestamps + S#).
# Interface is preserved: this branch writes the same single $OUT txt the bridge
# expects. Fully reversible: unset the env / restore the .bak.
if [[ "${MEETING_ASR_BACKEND:-qwen3}" == "vibevoice" ]]; then
VIBE_PY="$BASE/bin/vibevoice-transcribe.py"
CHUNK="${MEETING_ASR_VIBE_CHUNK:-200}"
if [[ ! -f "$VIBE_PY" ]]; then
echo "vibevoice backend selected but $VIBE_PY missing" >&2
exit 2
fi
mkdir -p "$BASE/processing"
VOUT=$(mktemp -d "$BASE/processing/vibe.XXXXXX")
log "START backend=vibevoice input=$INPUT chunk=${CHUNK}s out=$OUT"
base=$(basename "$INPUT"); base="${base%.*}"
if python3 "$VIBE_PY" "$INPUT" --out "$VOUT" --chunk "$CHUNK" >> "$LOG" 2>&1 \
&& [[ -f "$VOUT/$base-diarized.txt" ]]; then
cp "$VOUT/$base-diarized.txt" "$OUT"
log "DONE backend=vibevoice out=$OUT chars=$(wc -c < "$OUT")"
rm -rf "$VOUT"
exit 0
fi
echo "vibevoice transcription failed or produced no output (see $LOG)" >&2
log "ERROR backend=vibevoice produced no diarized.txt"
rm -rf "$VOUT"
exit 11
fi
# ---- Backend: Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹 (:10099) ------------------
# MEETING_ASR_BACKEND=qwen3cam (别名 paraformer 向后兼容) 路由到分角色服务。整文件一次
# 请求(FSMN-VAD 内部切段),无客户端分段。输出带说话人标签;声纹命中显示真名,否则
# speaker_N。可用 env 切换回退。热词/postprocess 均已退役(解码软偏置服务端关闭、
# legacy hotword 不生效、确定性预替换抢跑污染),实体纠错统一在 refine 单点处理。
if [[ "${MEETING_ASR_BACKEND:-qwen3}" == "qwen3cam" || "${MEETING_ASR_BACKEND:-qwen3}" == "paraformer" ]]; then
DIAR_URL="${MEETING_DIARIZE_URL:-${MEETING_PARAFORMER_URL:-http://192.168.50.100:10099/asr}}"
log "START backend=qwen3cam input=$INPUT out=$OUT"
RESP=$(mktemp)
# 中文会议固定 Chinese,避免远场噪声触发粤语/日语漂移;英文缩写仍可正常识别。
args=( -sS --max-time 900 -F "file=@$INPUT" -F 'language=Chinese' -F 'save_voiceprint_session=true' )
if curl "${args[@]}" "$DIAR_URL" > "$RESP" 2>> "$LOG"; then
if python3 - "$RESP" "$OUT" <<'PY'
import json, sys
try:
d = json.load(open(sys.argv[1], encoding='utf-8'))
except Exception as e:
sys.stderr.write(f"qwen3cam json parse failed: {e}\n"); sys.exit(1)
if 'detail' in d or not d.get('segments'):
sys.stderr.write(f"qwen3cam no segments: {str(d)[:160]}\n"); sys.exit(1)
vp = d.get('voiceprint', {})
matched = [f"{m['speaker_local']}={m['person_name']}" for m in vp.get('matches', []) if m.get('status') == 'matched']
sid = vp.get('session_id') or ''
# machine-readable line for the voiceprint auto-enroll tool (survives to .asr-inbox)
nclusters = len({s.get('speaker') for s in d['segments']})
with open(sys.argv[2], 'w', encoding='utf-8') as out:
out.write(f"<!-- voiceprint_session: {sid} clusters: {nclusters} -->\n")
hdr = "# 转写(Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹)"
if matched:
hdr += " | 声纹命名: " + ", ".join(matched)
out.write(hdr + "\n\n")
turns = []
for s in d['segments']:
spk = s.get('speaker', '?'); t = (s.get('text') or '').strip()
if not t:
continue
if turns and turns[-1][0] == spk:
turns[-1][1] += t
else:
turns.append([spk, t])
for spk, t in turns:
out.write(f"【{spk}】{t}\n\n")
PY
then
log "DONE backend=qwen3cam out=$OUT chars=$(wc -c < "$OUT")"
rm -f "$RESP"; exit 0
fi
fi
echo "qwen3cam transcription failed (see $LOG)" >&2
log "ERROR backend=qwen3cam failed"
rm -f "$RESP"; exit 12
fi
# ---- default Qwen3 path follows --------------------------------------------
case "$SEGMENT_SECONDS" in
''|*[!0-9]*) echo "MEETING_ASR_SEGMENT_SECONDS must be integer" >&2; exit 2 ;;
esac
if (( SEGMENT_SECONDS < 480 || SEGMENT_SECONDS > 600 )); then
log "WARN segment seconds $SEGMENT_SECONDS outside recommended 480-600; continuing"
fi
if ! command -v ffprobe >/dev/null 2>&1; then
echo "ffprobe not found" >&2
exit 2
fi
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "ffmpeg not found" >&2
exit 2
fi
if ! command -v curl >/dev/null 2>&1; then
echo "curl not found" >&2
exit 2
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "python3 not found" >&2
exit 2
fi
HOTWORD_GEN="$HOME/claude-workspace/OBSIDIAN/bin/obsidian-asr-hotwords.py"
build_prompt() {
local terms
# Shared generator: cards ∪ glossary entity terms, mapping wrong-sides excluded,
# ranked by mention count. Fall back to a minimal inline reader if unavailable.
if [[ -f "$HOTWORD_GEN" ]]; then
terms=$(python3 "$HOTWORD_GEN" --budget 1800 2>/dev/null | tr ',' '、')
fi
if [[ -z "$terms" ]]; then
terms=$(python3 - "$GLOSSARY" <<'PY'
import re, sys
from pathlib import Path
p = Path(sys.argv[1]).expanduser()
if not p.exists():
sys.exit(0)
out, seen = [], set()
for raw in p.read_text(errors='ignore').splitlines():
s = raw.strip()
if not s.startswith(('-', '*')) or re.search(r'[→⇒]|->', s):
continue # mapping lines carry the WRONG form on the left — never bias to it
s = re.split(r'\s[—–-]\s|[::]', re.sub(r'^[-*+\d\.\)\s]+', '', s), maxsplit=1)[0].strip()
s = re.sub(r'\[\[|\]\]', '', s).strip()
if s and s not in seen and len(s) <= 30:
seen.add(s); out.append(s)
print('、'.join(out[:200]))
PY
)
fi
[[ -z "$terms" ]] && return 0
printf '以下是会议中可能出现的人名、公司、项目、产品和术语,请优先准确识别这些正确写法:%s' "$terms"
}
DURATION=$(ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 "$INPUT" | awk '{printf "%d", ($1 == int($1) ? $1 : int($1)+1)}')
if [[ -z "$DURATION" || "$DURATION" == "0" ]]; then
echo "failed to detect duration: $INPUT" >&2
exit 3
fi
mkdir -p "$BASE/processing"
TMPDIR=$(mktemp -d "$BASE/processing/asr.XXXXXX")
cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXIT
PROMPT_TEXT=$(build_prompt || true)
: > "$OUT"
log "START input=$INPUT duration=${DURATION}s segment=${SEGMENT_SECONDS}s out=$OUT"
segments=$(( (DURATION + SEGMENT_SECONDS - 1) / SEGMENT_SECONDS ))
failures=0
for ((i=0; i<segments; i++)); do
start=$(( i * SEGMENT_SECONDS ))
end=$(( start + SEGMENT_SECONDS ))
if (( end > DURATION )); then end=$DURATION; fi
seg="$TMPDIR/segment_$((i+1)).mp3"
txt="$TMPDIR/segment_$((i+1)).txt"
printf -- '--- 第%d段 [%02d:%02d - %02d:%02d] ---\n' "$((i+1))" "$((start/60))" "$((start%60))" "$((end/60))" "$((end%60))" >> "$OUT"
ffmpeg -hide_banner -loglevel error -y -ss "$start" -t "$SEGMENT_SECONDS" -i "$INPUT" -ar 16000 -ac 1 -b:a 64k "$seg"
log "SEGMENT $((i+1))/$segments file=$seg size=$(stat -c%s "$seg")"
curl_args=(
-sS --fail-with-body --max-time 600
-F "model=$ASR_MODEL"
-F "file=@$seg"
)
if [[ -n "$LANGUAGE" ]]; then
curl_args+=( -F "language=$LANGUAGE" )
fi
if [[ -n "$PROMPT_TEXT" ]]; then
curl_args+=( -F "prompt=$PROMPT_TEXT" )
fi
if curl "${curl_args[@]}" "$ASR_URL" > "$txt" 2>> "$LOG"; then
python3 - "$txt" >> "$OUT" <<'PY'
import json, sys
from pathlib import Path
s=Path(sys.argv[1]).read_text(errors='ignore').strip()
try:
obj=json.loads(s)
print((obj.get('text') or obj.get('transcript') or s).strip())
except Exception:
print(s)
PY
else
failures=$((failures+1))
printf '[第%d段转录失败]\n' "$((i+1))" >> "$OUT"
log "ERROR segment $((i+1)) failed"
fi
printf '\n\n' >> "$OUT"
done
if (( failures > 0 )); then
log "DONE_WITH_FAILURES failures=$failures out=$OUT"
exit 10
fi
log "DONE out=$OUT chars=$(wc -c < "$OUT")"
@@ -0,0 +1,362 @@
#!/usr/bin/env python3
import fcntl
import hashlib
import hmac
import json
import os
import shutil
import sqlite3
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
PROJECT = Path(__file__).resolve().parents[2]
BASE = Path(os.environ.get("MEETING_INBOX_BASE", str(PROJECT / "recordings")))
INBOX = BASE / "inbox"
PROCESSING = BASE / "processing"
PROCESSED = BASE / "processed"
FAILED = BASE / "failed"
TRANSCRIPTS = BASE / "transcripts"
STATE = BASE / "state"
LOGS = BASE / "logs"
ASR = Path(os.environ.get("MEETING_ASR_SCRIPT", str(PROJECT / "recordings" / "bin" / "asr-transcribe.sh")))
SEEN_FILE = STATE / "seen.json"
UPLOAD_META = STATE / "uploads"
CONTROL_DB = Path(os.environ.get(
"ASR_CONTROL_DB",
str(Path(os.environ.get("ASR_STATE_DIR", str(PROJECT / "state"))) / "intake.db")))
LOG = LOGS / "meeting-watch.log"
POLL_SECONDS = int(os.environ.get("MEETING_WATCH_POLL_SECONDS", "30"))
STABLE_AGE_SECONDS = int(os.environ.get("MEETING_WATCH_STABLE_AGE_SECONDS", "60"))
WEBHOOK_URL = os.environ.get("MEETING_WEBHOOK_URL", "http://127.0.0.1:8644/webhooks/meeting-recording")
WEBHOOK_ROUTE = os.environ.get("MEETING_WEBHOOK_ROUTE", "meeting-recording")
WEBHOOK_SECRET = os.environ.get("MEETING_WEBHOOK_SECRET", "")
STATUS_WEBHOOK_URL = os.environ.get("MEETING_STATUS_WEBHOOK_URL", "http://127.0.0.1:8644/webhooks/meeting-recording-status")
STATUS_WEBHOOK_ROUTE = os.environ.get("MEETING_STATUS_WEBHOOK_ROUTE", "meeting-recording-status")
SUBSCRIPTIONS = Path.home() / ".hermes" / "webhook_subscriptions.json"
SUPPORTED = {".m4a", ".mp3", ".wav", ".mp4", ".aac", ".ogg", ".opus", ".webm", ".flac"}
def log(msg):
LOGS.mkdir(parents=True, exist_ok=True)
with LOG.open("a", encoding="utf-8") as f:
f.write(time.strftime("[%Y-%m-%d %H:%M:%S] ") + msg + "\n")
def load_seen():
try:
return json.loads(SEEN_FILE.read_text())
except Exception:
return {}
def save_seen(seen):
STATE.mkdir(parents=True, exist_ok=True)
tmp = SEEN_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(seen, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(SEEN_FILE)
def upload_meta(filename):
try:
return json.loads((UPLOAD_META / f"{filename}.json").read_text(encoding="utf-8"))
except Exception:
return {}
def update_job(meta, status, detail="", transcript=""):
job_id = str(meta.get("job_id") or "")
if not job_id or not CONTROL_DB.exists():
return
try:
con = sqlite3.connect(CONTROL_DB, timeout=15)
fields, vals = ["status=?", "updated_at=?"], [status, time.time()]
if detail:
fields.append("detail=?"); vals.append(detail[:1000])
if transcript:
fields.append("transcript=?"); vals.append(transcript)
vals.append(job_id)
con.execute(f"UPDATE intake_job SET {','.join(fields)} WHERE id=?", vals)
con.commit()
con.close()
except Exception as exc:
log(f"CONTROL_STATUS_FAILED job={job_id} status={status} error={exc}")
def ensure_job(path, fid):
"""Create a visible control-plane task for files dropped outside Web upload."""
job_id = f"fs-{fid}"
now = time.time()
try:
CONTROL_DB.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(CONTROL_DB, timeout=15)
con.execute("""
CREATE TABLE IF NOT EXISTS intake_job(
id TEXT PRIMARY KEY, original_name TEXT NOT NULL,
stored_name TEXT NOT NULL, title TEXT NOT NULL DEFAULT '',
context TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '',
transcript TEXT NOT NULL DEFAULT '', created_at REAL NOT NULL,
updated_at REAL NOT NULL, draft TEXT NOT NULL DEFAULT '',
result TEXT NOT NULL DEFAULT ''
)
""")
columns = {row[1] for row in con.execute("PRAGMA table_info(intake_job)")}
for name in ("draft", "result"):
if name not in columns:
con.execute(
f"ALTER TABLE intake_job ADD COLUMN {name} "
"TEXT NOT NULL DEFAULT ''")
con.execute(
"INSERT OR IGNORE INTO intake_job("
"id,original_name,stored_name,title,context,size,status,detail,"
"transcript,created_at,updated_at,draft,result"
") VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
(job_id, path.name, path.name, path.stem, "", path.stat().st_size,
"queued", "监控目录已接收", "", now, now, "", ""))
con.commit()
con.close()
return {
"job_id": job_id, "title": path.stem, "context": "",
"original_name": path.name, "stored_name": path.name,
}
except Exception as exc:
log(f"CONTROL_JOB_CREATE_FAILED file={path} error={exc}")
return {}
def duration_seconds(path):
try:
out = subprocess.check_output([
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nk=1:nw=1", str(path)
], text=True, timeout=30).strip()
return int(float(out) + 0.999) if out else None
except Exception:
return None
def file_id(path):
st = path.stat()
h = hashlib.sha256()
h.update(str(path.name).encode())
h.update(str(st.st_size).encode())
h.update(str(int(st.st_mtime)).encode())
if st.st_size <= 256 * 1024 * 1024:
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()[:24]
def stable(path):
try:
st1 = path.stat()
if time.time() - st1.st_mtime < STABLE_AGE_SECONDS:
return False
time.sleep(2)
st2 = path.stat()
return st1.st_size == st2.st_size and int(st1.st_mtime) == int(st2.st_mtime)
except FileNotFoundError:
return False
def unique_path(directory, name):
p = directory / name
if not p.exists():
return p
stem, suffix = p.stem, p.suffix
for i in range(1, 10000):
q = directory / f"{stem}-{i}{suffix}"
if not q.exists():
return q
raise RuntimeError(f"cannot allocate unique path for {name}")
def webhook_secret(route=WEBHOOK_ROUTE):
if route == WEBHOOK_ROUTE and WEBHOOK_SECRET:
return WEBHOOK_SECRET
try:
data = json.loads(SUBSCRIPTIONS.read_text(encoding="utf-8"))
return str(data.get(route, {}).get("secret", ""))
except Exception:
return ""
def post_webhook(payload, url=WEBHOOK_URL, route=WEBHOOK_ROUTE, label="WEBHOOK", timeout=30):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json"}
secret = webhook_secret(route)
if secret and secret != "INSECURE_NO_AUTH":
headers["X-Webhook-Signature"] = hmac.new(secret.encode(), data, hashlib.sha256).hexdigest()
req = urllib.request.Request(
url,
data=data,
headers=headers,
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read(1000).decode("utf-8", errors="ignore")
log(f"{label} status={resp.status} body={body[:300]!r}")
return resp.status
def post_status(status, filename, detail=""):
payload = {"status": status, "filename": filename, "detail": detail}
try:
return post_webhook(
payload,
url=STATUS_WEBHOOK_URL,
route=STATUS_WEBHOOK_ROUTE,
label="STATUS_WEBHOOK",
timeout=10,
)
except Exception as exc:
log(f"STATUS_WEBHOOK_FAILED status={status} file={filename} error={exc}")
return None
import re
_CONSUMER_LOCK = None
ASR_INBOX = Path(os.environ.get("ASR_VAULT", str(Path.home() / "obsidian-vault"))) / ".asr-inbox"
def bridge_to_asr_inbox(transcript, stem, title=""):
"""Atomically drop the finished transcript into the Obsidian .asr-inbox so the
Claude refine pipeline picks it up. Slug carries date+time for the draft name."""
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})[_-](\d{2})[_-](\d{2})[_-](\d{2})", stem)
if m:
y, mo, d, hh, mm, ss = m.groups()
tail = stem[m.end():].strip("_- ")
safe_tail = re.sub(
r"[^\w一-鿿-]+", "_", title or tail).strip("_-")
safe_tail = re.sub(r"-[0-9a-f]{8}$", "", safe_tail)
label = safe_tail or "录音"
slug = f"{y}-{mo}-{d}-{hh}{mm}{ss}-{label}"
else:
safe = re.sub(r"[^\w一-鿿-]+", "_", stem).strip("_") or "录音"
slug = f"{time.strftime('%Y-%m-%d-%H%M%S')}-{safe}"
incoming = ASR_INBOX / ".incoming"
incoming.mkdir(parents=True, exist_ok=True)
(ASR_INBOX / ".state").mkdir(parents=True, exist_ok=True)
# Collision guard: never overwrite an unprocessed transcript.
dest = ASR_INBOX / f"{slug}.txt"
n = 2
while dest.exists():
dest = ASR_INBOX / f"{slug}-{n}.txt"
n += 1
tmp = incoming / dest.name
shutil.copyfile(str(transcript), str(tmp))
tmp.replace(dest) # atomic within the vault filesystem -> triggers the path unit
return dest
def process(path):
seen = load_seen()
fid = file_id(path)
intake = upload_meta(path.name) or ensure_job(path, fid)
if fid in seen:
log(f"SKIP already seen file={path} id={fid}")
return
PROCESSING.mkdir(parents=True, exist_ok=True)
TRANSCRIPTS.mkdir(parents=True, exist_ok=True)
PROCESSED.mkdir(parents=True, exist_ok=True)
FAILED.mkdir(parents=True, exist_ok=True)
proc = unique_path(PROCESSING, path.name)
try:
shutil.move(str(path), str(proc))
except FileNotFoundError:
log(f"SKIP disappeared before claim file={path}")
return
transcript = TRANSCRIPTS / f"{fid}-{proc.stem}.txt"
dur = duration_seconds(proc)
log(f"PROCESS file={proc} id={fid} duration={dur}")
update_job(intake, "transcribing", f"录音时长约 {dur}s" if dur else "正在转写")
post_status("开始转录", proc.name, f"时长约 {dur}s;完成后会继续发送粗整理。")
try:
subprocess.run([str(ASR), str(proc), str(transcript)], check=True, timeout=None)
if intake:
original = transcript.read_text(encoding="utf-8", errors="ignore")
marker = json.dumps({
"job_id": intake.get("job_id", ""),
"title": intake.get("title", ""),
"context": intake.get("context", ""),
}, ensure_ascii=False)
transcript.write_text(f"<!-- intake: {marker} -->\n" + original,
encoding="utf-8")
done = unique_path(PROCESSED, proc.name)
shutil.move(str(proc), str(done))
post_status("转录完成,正在生成粗整理", done.name, f"转录文件:{transcript}")
# Refinement is handled by the Claude Code pipeline (not the Hermes agent
# webhook). Bridge the finished transcript into the Obsidian .asr-inbox so
# obsidian-asr-refine (systemd path unit) produces a rough-outline draft.
bridged = bridge_to_asr_inbox(
transcript, proc.stem, str(intake.get("title") or ""))
update_job(intake, "refining", "转写完成,正在生成整理稿",
transcript=str(transcript))
log(f"BRIDGED transcript -> {bridged}")
seen[fid] = {"filename": done.name, "audio_path": str(done), "transcript_path": str(transcript), "ts": int(time.time())}
save_seen(seen)
log(f"DONE file={done} transcript={transcript}")
except Exception as exc:
fail = unique_path(FAILED, proc.name)
if proc.exists():
shutil.move(str(proc), str(fail))
err = FAILED / f"{fail.name}.error.txt"
err.write_text(str(exc), encoding="utf-8")
post_status("处理失败", fail.name, f"错误:{exc}")
update_job(intake, "failed", f"处理失败:{exc}")
log(f"FAILED file={path} error={exc}")
def once():
for p in sorted(INBOX.iterdir() if INBOX.exists() else []):
if not p.is_file() or p.suffix.lower() not in SUPPORTED:
continue
if stable(p):
process(p)
def hold_consumer_lock():
"""Share ASR_STATE_DIR/consumer.lock with asrservice.worker. Exit if it is taken."""
path = Path(os.environ.get("ASR_CONSUMER_LOCK", str(CONTROL_DB.parent / "consumer.lock")))
path.parent.mkdir(parents=True, exist_ok=True)
handle = path.open("a+")
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
log(f"EXIT another recording consumer holds {path}")
raise SystemExit(1)
handle.seek(0)
handle.truncate()
handle.write(str(os.getpid()))
handle.flush()
return handle
def main():
global _CONSUMER_LOCK
_CONSUMER_LOCK = hold_consumer_lock()
for d in [INBOX, PROCESSING, PROCESSED, FAILED, TRANSCRIPTS, STATE, LOGS]:
d.mkdir(parents=True, exist_ok=True)
if not ASR.exists():
raise SystemExit(f"missing ASR script: {ASR}")
log("START watcher")
if "--once" in sys.argv:
once()
return
while True:
try:
once()
except Exception as exc:
log(f"LOOP_ERROR {exc}")
time.sleep(POLL_SECONDS)
if __name__ == "__main__":
main()
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""
ECAPA-TDNN 声纹 + KMeans 全局说话人统一(在 ASR venv 里跑)。
把 VibeVoice 每块独立的 S# 标签换成全场统一的 G# ID。
用法(由 vibevoice-transcribe.py --unify 调用,也可单独跑):
<venv>/bin/python vibevoice-speaker-unify.py <audio> <segs.json> <out.md> [K]
局限(见 vault: ASR服务横评-2026-06):单麦远场录音里声纹只能可靠锁"主讲人",
次要说话人分不干净。K 默认 4(K>=5 会劈裂主讲人)。
"""
import json, os, sys, subprocess, numpy as np
import torch, soundfile as sf
from speechbrain.inference.speaker import EncoderClassifier
from sklearn.cluster import KMeans
from collections import Counter
audio = sys.argv[1]
segs_path = sys.argv[2]
out_md = sys.argv[3]
K = int(sys.argv[4]) if len(sys.argv) > 4 else 4
MIN_DUR = 1.2
segs = [s for s in json.load(open(segs_path)) if s.get("Content", "").strip()]
work = os.path.join(os.path.dirname(segs_path), "_segwav"); os.makedirs(work, exist_ok=True)
clf = EncoderClassifier.from_hparams(source="speechbrain/spkrec-ecapa-voxceleb",
savedir=os.path.expanduser("~/.cache/ecapa_model"),
run_opts={"device": "cpu"})
emb, idx = [], []
for i, s in enumerate(segs):
if s["End"] - s["Start"] < MIN_DUR:
continue
wp = os.path.join(work, f"{i:04d}.wav")
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-ss", str(s["Start"]),
"-t", str(s["End"] - s["Start"]), "-i", audio, "-ar", "16000", "-ac", "1", wp], check=True)
try:
wav, sr = sf.read(wp)
if len(wav) < 16000 * 0.8:
continue
e = clf.encode_batch(torch.tensor(wav, dtype=torch.float32).unsqueeze(0)).squeeze().detach().numpy()
emb.append(e); idx.append(i)
except Exception as ex:
print("embed fail", i, ex)
emb = np.array(emb)
X = emb / np.linalg.norm(emb, axis=1, keepdims=True)
print(f"embedded {len(emb)} segments; clustering K={K}", flush=True)
km = KMeans(n_clusters=K, n_init=10, random_state=0).fit(X)
gid = {idx[k]: int(km.labels_[k]) for k in range(len(idx))}
es = sorted(gid)
for i, s in enumerate(segs):
s["G"] = gid.get(i) if i in gid else gid[min(es, key=lambda j: abs(segs[j]["Start"] - segs[i]["Start"]))]
# rank G by talk-time (G0 = most)
t = Counter()
for s in segs: t[s["G"]] += s["End"] - s["Start"]
rank = {old: new for new, (old, _) in enumerate(t.most_common())}
for s in segs: s["G"] = rank[s["G"]]
tt = Counter()
for s in segs: tt[s["G"]] += s["End"] - s["Start"]
total = sum(tt.values()) or 1
print("talk-time:", {f"G{k}": f"{round(v)}s/{v/total*100:.0f}%" for k, v in sorted(tt.items())})
def hms(x): x = int(x); return f"{x//60:02d}:{x%60:02d}"
lines = [f"# {os.path.basename(audio)} · 全局统一说话人 (ECAPA+KMeans K={K})",
"> G0=说话最多 · 括号内为 VibeVoice 原始每块标签 · 单麦远场次要说话人分离有限 · 仅草稿\n"]
for s in sorted(segs, key=lambda x: x["Start"]):
lines.append(f"- `{hms(s['Start'])}` **G{s['G']}** (S{s['Speaker']}): {s['Content']}")
open(out_md, "w").write("\n".join(lines))
print(f"wrote {out_md}", flush=True)
@@ -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()