feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user