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")"