Chunked text output previously joined every word into one line and discarded speaker labels. Add a shared plain-text formatter that wraps lines, breaks paragraphs at speaker changes and pauses, and splits long turns while preserving word order. Display provider speaker IDs with chunk scope where needed, without implying that IDs identify the same person across requests. Request structured output for short diarized recordings too, and warn when speaker labels are missing or incomplete. Keep SRT formatting separate and preserve existing line breaks for short requests without diarization. Test Plan: - python3 -B -m unittest -q: all 43 tests passed. - bash -n transcribe and git diff --cached --check: passed. - Reflowed the existing transcript locally and verified all 15,888 words were preserved; source audio and generated transcripts are not committed. - A live excerpt check was blocked before upload by unavailable 1Password access; actual provider diarization remains unverified.
128 lines
5.2 KiB
Bash
Executable File
128 lines
5.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -euo pipefail
|
|
|
|
srt=false
|
|
diarization=true
|
|
chunk_minutes=
|
|
usage() {
|
|
echo "Usage: ${0##*/} [--srt] [--no-diarization] [--chunk-minutes 1..30] AUDIO_FILE"
|
|
echo 'Transcribe audio using OPENROUTER_API_KEY; print wrapped text with speaker markers to stdout.'
|
|
echo ' --srt Print SRT subtitles in short cues using word timestamps instead.'
|
|
echo ' --no-diarization Disable speaker diarization (enabled by default).'
|
|
echo ' --chunk-minutes N Maximum upload duration in minutes (default: 10; 30 with --no-diarization).'
|
|
}
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
--srt) srt=true; shift ;;
|
|
--no-diarization) diarization=false; shift ;;
|
|
--chunk-minutes)
|
|
if [[ ! ${2:-} =~ ^([1-9]|[12][0-9]|30)$ ]]; then
|
|
echo '--chunk-minutes must be an integer between 1 and 30' >&2
|
|
exit 1
|
|
fi
|
|
chunk_minutes=$2
|
|
shift 2
|
|
;;
|
|
-h|--help) usage; exit 0 ;;
|
|
--) shift; break ;;
|
|
-*) usage >&2; exit 1 ;;
|
|
*) break ;;
|
|
esac
|
|
done
|
|
if [[ -z $chunk_minutes ]]; then
|
|
# Azure documents diarization failures for recordings around 15 minutes.
|
|
if "$diarization"; then chunk_minutes=10; else chunk_minutes=30; fi
|
|
fi
|
|
if [[ $# != 1 ]]; then
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
if [[ ! -f $1 || ! -r $1 ]]; then
|
|
echo "Cannot read audio file: $1" >&2
|
|
exit 1
|
|
fi
|
|
export OPENROUTER_API_KEY
|
|
OPENROUTER_API_KEY="$(op read "op://API Keys/openrouter-mai-transcribe-2/credential")"
|
|
: "${OPENROUTER_API_KEY:?Set OPENROUTER_API_KEY first}"
|
|
dependencies=(ffmpeg curl jq base64 mktemp python3)
|
|
for tool in "${dependencies[@]}"; do
|
|
command -v "$tool" >/dev/null || {
|
|
echo "Missing dependency: $tool" >&2
|
|
exit 1
|
|
}
|
|
done
|
|
|
|
tmp=$(mktemp -d)
|
|
trap 'rm -rf -- "$tmp"' EXIT
|
|
trap 'exit 130' INT
|
|
trap 'exit 143' TERM
|
|
script_dir=$(dirname -- "$(realpath -- "$0")")
|
|
|
|
# Decode once so duration, pause detection, and all cuts share a sample clock.
|
|
# This also works for inputs whose container omits or misreports the duration.
|
|
ffmpeg -nostdin -hide_banner -loglevel error -i "$(realpath -- "$1")" \
|
|
-map 0:a:0 -ac 1 -ar 16000 -c:a pcm_s16le "$tmp/audio.wav"
|
|
python3 "$script_dir/chunks.py" plan "$tmp/audio.wav" "$chunk_minutes" >"$tmp/chunks.json"
|
|
count=$(jq 'length' "$tmp/chunks.json")
|
|
timestamps=$srt
|
|
if "$diarization"; then timestamps=true; fi
|
|
if (( count > 1 )); then
|
|
timestamps=true
|
|
echo "Splitting audio into $count overlapping chunks (maximum $chunk_minutes minutes each)." >&2
|
|
fi
|
|
|
|
for ((i = 0; i < count; i++)); do
|
|
read -r start duration < <(jq -r --argjson i "$i" \
|
|
'.[$i] | "\(.start) \(.end - .start)"' "$tmp/chunks.json")
|
|
if (( count > 1 )); then
|
|
echo "Transcribing chunk $((i + 1))/$count (start ${start}s, duration ${duration}s)..." >&2
|
|
fi
|
|
ffmpeg -nostdin -hide_banner -loglevel error -y -ss "$start" -i "$tmp/audio.wav" \
|
|
-t "$duration" -map 0:a:0 -ac 1 -ar 16000 -c:a libmp3lame -b:a 64k "$tmp/audio.mp3"
|
|
|
|
# Keep large audio payloads out of shell arguments (which have a size limit).
|
|
base64 <"$tmp/audio.mp3" | tr -d '\n\r' >"$tmp/audio.b64"
|
|
jq -n --rawfile data "$tmp/audio.b64" --argjson timestamps "$timestamps" \
|
|
--argjson diarization "$diarization" \
|
|
'{model: "microsoft/mai-transcribe-2", input_audio: {data: $data, format: "mp3"},
|
|
provider: {options: {azure: {diarization: {enabled: $diarization}}}}}
|
|
+ (if $timestamps then {response_format: "verbose_json", timestamp_granularities: ["segment", "word"]}
|
|
else {} end)' \
|
|
>"$tmp/request.json"
|
|
|
|
curl --silent --show-error --fail-with-body \
|
|
https://openrouter.ai/api/v1/audio/transcriptions \
|
|
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
|
-H 'Content-Type: application/json' \
|
|
--data-binary @"$tmp/request.json" -o "$tmp/response-$i.json" || {
|
|
echo "Transcription failed for chunk $((i + 1))/$count." >&2
|
|
if [[ -f $tmp/response-$i.json ]]; then
|
|
cat "$tmp/response-$i.json" >&2
|
|
fi
|
|
exit 1
|
|
}
|
|
jq -e 'if .error then error("Transcription failed: " + (.error | tostring))
|
|
elif (.text | type) == "string" then true
|
|
else error("Transcription failed: missing text in response") end' \
|
|
"$tmp/response-$i.json" >/dev/null
|
|
if "$diarization" && jq -e '.text | test("\\S")' "$tmp/response-$i.json" >/dev/null; then
|
|
if ! jq -e 'any(.words[]?; .speaker != null)' "$tmp/response-$i.json" >/dev/null; then
|
|
echo "Warning: chunk $((i + 1))/$count returned no speaker labels despite diarization being enabled." >&2
|
|
elif jq -e 'any(.words[]?; .speaker == null)' "$tmp/response-$i.json" >/dev/null; then
|
|
echo "Warning: chunk $((i + 1))/$count returned some words without speaker labels." >&2
|
|
fi
|
|
fi
|
|
done
|
|
|
|
# Only emit a transcript after every chunk succeeds, including SRT conversion.
|
|
if (( count > 1 )); then
|
|
format=()
|
|
if "$srt"; then format+=(--srt); fi
|
|
python3 "$script_dir/chunks.py" merge "$tmp/chunks.json" "${format[@]}"
|
|
elif "$srt"; then
|
|
python3 "$script_dir/subtitles.py" "$tmp/response-0.json"
|
|
else
|
|
python3 "$script_dir/transcript.py" "$tmp/response-0.json"
|
|
fi
|