feat: transcribe long recordings in overlapping chunks

Sending entire recordings in one request can exceed provider limits.
Decode audio once to a shared PCM sample clock and split uploads at a
configurable maximum of 1 to 30 minutes, defaulting to 30 minutes.
Prefer nearby pauses and include six seconds of overlap to protect words
at audio cuts while keeping each encoded MP3 within the duration cap.

Request word timestamps for chunked text and SRT output. Match overlapping
words and times to retain each passage once, warn when a join falls back
to timestamps, and generate SRT cues on the original recording timeline.
Reuse subtitle timestamp validation without reordering regressing words.
Only emit output after every request and conversion succeeds.

Document the Python dependency, temporary storage, boundary limitations,
and lack of automatic retry or resume. Keep single-request text formatting.

Test Plan:
- python3 -B -m unittest -v: all 29 tests passed, including mocked provider
  failures, native MP3 payload checks, boundary stitching, and SRT timing.
- bash -n transcribe and shellcheck transcribe: passed.
- git diff --cached --check: passed.
- Locally decoded and encoded the supplied 74-minute WebM into three MP3
  chunks; ffprobe confirmed each duration was below 30 minutes.
- Live transcription was not verified: the configured 1Password credential
  was unavailable.
This commit is contained in:
ddidderr committed 2026-09-12 08:53:31 +02:00
1 parent 300203d33f
commit a5c535cfb9
7 files changed
+635 -57

No files matched your search

+76 -35
View File
@@ -3,18 +3,32 @@
set -euo pipefail
srt=false
if [[ ${1:-} == --srt ]]; then
srt=true
shift
fi
if [[ $# == 1 && ($1 == -h || $1 == --help) ]]; then
echo "Usage: ${0##*/} [--srt] AUDIO_FILE"
chunk_minutes=30
usage() {
echo "Usage: ${0##*/} [--srt] [--chunk-minutes 1..30] AUDIO_FILE"
echo 'Transcribe audio using OPENROUTER_API_KEY; print the text to stdout.'
echo ' --srt Print SRT subtitles in short cues using word timestamps instead.'
exit 0
fi
echo ' --chunk-minutes N Maximum upload duration in minutes (default: 30).'
}
while [[ $# -gt 0 ]]; do
case $1 in
--srt) srt=true; 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 [[ $# != 1 ]]; then
echo "Usage: ${0##*/} [--srt] AUDIO_FILE" >&2
usage >&2
exit 1
fi
if [[ ! -f $1 || ! -r $1 ]]; then
@@ -24,10 +38,7 @@ 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)
if "$srt"; then
dependencies+=(python3)
fi
dependencies=(ffmpeg curl jq base64 mktemp python3)
for tool in "${dependencies[@]}"; do
command -v "$tool" >/dev/null || {
echo "Missing dependency: $tool" >&2
@@ -39,31 +50,61 @@ tmp=$(mktemp -d)
trap 'rm -rf -- "$tmp"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
script_dir=$(dirname -- "$(realpath -- "$0")")
# Normalize any FFmpeg-readable audio (or the first audio track in a video).
# 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 libmp3lame -b:a 64k "$tmp/audio.mp3"
-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 (( count > 1 )); then
timestamps=true
echo "Splitting audio into $count overlapping chunks (maximum $chunk_minutes minutes each)." >&2
fi
# 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 srt "$srt" \
'{model: "microsoft/mai-transcribe-2", input_audio: {data: $data, format: "mp3"}}
+ (if $srt then {response_format: "verbose_json", timestamp_granularities: ["segment", "word"]}
else {} end)' \
>"$tmp/request.json"
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"
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.json" || {
cat "$tmp/response.json" >&2
exit 1
}
if "$srt"; then
python3 "$(dirname -- "$(realpath -- "$0")")/subtitles.py" "$tmp/response.json"
# 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" \
'{model: "microsoft/mai-transcribe-2", input_audio: {data: $data, format: "mp3"}}
+ (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
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
jq -r 'if (.text | type) == "string" then .text
else error("Transcription failed: " + (.error.message // tojson)) end' \
"$tmp/response.json"
jq -r .text "$tmp/response-0.json"
fi