256 lines
9.6 KiB
Bash
Executable File
256 lines
9.6 KiB
Bash
Executable File
#!/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")"
|