#!/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"\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 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")"