Files
infra-docker-configs/servers/tools-pve/asr-pipeline/recordings/bin/asr-transcribe.sh
T
Codex 3c70861463
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s
feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
2026-11-02 06:37:33 -08:00

256 lines
9.6 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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")"