feat: transcribe chunks with configurable parallel workers

Process up to eight chunks concurrently by default and add --jobs 1..64,
with --jobs 1 retaining sequential operation. Give each worker separate
encoding and request files, release payloads after completion, and merge
responses by original chunk index regardless of completion order.

Use a bounded worker scheduler that handles jobs finishing before wait is
called. Stop scheduling on an observed worker failure and terminate worker
process groups on failure or interruption before removing temporary files.
Include jobs interrupted between launch and scheduler registration in cleanup.
Preserve all-or-nothing stdout for both text and SRT output.

Document the Bash 5.1 requirement, concurrency option, resource usage, and
limits of cancelling requests already accepted by a provider.

Test Plan:
- python3 -B -m unittest -v: all 52 tests passed.
- Mocked provider tests verified eight concurrent requests by default,
  configurable limits, rolling worker replacement, isolated payloads,
  ordered text, and identical sequential/parallel SRT output.
- Verified immediate encoder failure, concurrent API failure, SIGINT and
  SIGTERM, child-process cancellation, cleanup, and no partial stdout.
- bash -n transcribe, ./transcribe --help, and
  git diff --cached --check: passed.
- No live provider request; credentials are unavailable in this environment.
This commit is contained in:
ddidderr committed 2026-09-12 09:37:09 +02:00
1 parent 5dd1b5811b
commit 1d03dcd587
4 files changed
+298 -18

No files matched your search

+17 -4
View File
@@ -1,6 +1,6 @@
# Transcribe # Transcribe
Requires Bash, FFmpeg, curl, jq, Python 3, and standard coreutils. Keep Requires Bash 5.1 or newer, FFmpeg, curl, jq, Python 3, and standard coreutils. Keep
`chunks.py`, `subtitles.py`, and `transcript.py` beside the script. The API key is read using `chunks.py`, `subtitles.py`, and `transcript.py` beside the script. The API key is read using
the 1Password CLI (`op`) from `op://API Keys/openrouter-mai-transcribe-2/credential`. the 1Password CLI (`op`) from `op://API Keys/openrouter-mai-transcribe-2/credential`.
@@ -11,6 +11,8 @@ the 1Password CLI (`op`) from `op://API Keys/openrouter-mai-transcribe-2/credent
./transcribe --no-diarization recording.m4a > transcript.txt ./transcribe --no-diarization recording.m4a > transcript.txt
./transcribe --srt --no-diarization video.mp4 > subtitles.srt ./transcribe --srt --no-diarization video.mp4 > subtitles.srt
./transcribe --chunk-minutes 10 too_long.webm > transcript.txt ./transcribe --chunk-minutes 10 too_long.webm > transcript.txt
./transcribe --jobs 4 too_long.webm > transcript.txt
./transcribe --jobs 1 --srt too_long.webm > subtitles.srt
``` ```
Accepts m4a, mp4, aac, flac, opus, mp3, ogg, wav, and other formats your Accepts m4a, mp4, aac, flac, opus, mp3, ogg, wav, and other formats your
@@ -88,9 +90,20 @@ identify a speaker across requests.
Audio is first decoded to temporary mono 16 kHz PCM (about 115 MB per hour), Audio is first decoded to temporary mono 16 kHz PCM (about 115 MB per hour),
so all cuts share the same sample clock even when container duration metadata so all cuts share the same sample clock even when container duration metadata
is missing. Only one MP3 chunk and its request payload are staged at a time. is missing. Up to **eight chunks** are encoded and transcribed concurrently
Requests run sequentially. A failed request stops the run; there is no automatic by default. Use `--jobs N` (an integer from 1 to 64) to change the limit;
retry or resume, and rerunning sends the earlier chunks again. `--jobs 1` runs sequentially. Each worker uses separate temporary files and
releases its MP3 and request payload when finished, so staged upload data is
bounded by the number of workers. Lower `--jobs` to reduce simultaneous CPU,
memory, disk, or provider demand.
Responses may finish in any order; merging and speaker numbering always follow
the recording's original order. A failed worker stops further scheduling and
cancels the remaining workers. Interruptions also stop workers and their child
processes before temporary files are removed. Requests already accepted by the
provider may still be processed and billed. There is no automatic retry or
resume, and rerunning sends earlier chunks again. No transcript is emitted
unless every worker and the final conversion succeed.
`--srt` requests word timestamps using OpenRouter's `verbose_json` response. `--srt` requests word timestamps using OpenRouter's `verbose_json` response.
Words are grouped into cues of at most two lines (42 characters per line, Words are grouped into cues of at most two lines (42 characters per line,
+146 -3
View File
@@ -2,8 +2,10 @@ import json
import os import os
from pathlib import Path from pathlib import Path
import shutil import shutil
import signal
import subprocess import subprocess
import tempfile import tempfile
import time
import unittest import unittest
from test_chunks import make_audio from test_chunks import make_audio
@@ -29,18 +31,159 @@ class TranscribeTests(unittest.TestCase):
TRANSCRIBE_TEST_LOG=str(self.log), TRANSCRIBE_TEST_MODE="ok") TRANSCRIBE_TEST_LOG=str(self.log), TRANSCRIBE_TEST_MODE="ok")
self.audio = self.path / "input with spaces.wav" self.audio = self.path / "input with spaces.wav"
def run_cli(self, *args, mode="ok", duration=70): def run_cli(self, *args, mode="ok", duration=70, env_overrides=None):
make_audio(self.audio, duration) make_audio(self.audio, duration)
return subprocess.run([str(ROOT / "transcribe"), *args, str(self.audio)], return subprocess.run([str(ROOT / "transcribe"), *args, str(self.audio)],
env=dict(self.env, TRANSCRIBE_TEST_MODE=mode), env=dict(self.env, TRANSCRIBE_TEST_MODE=mode, **(env_overrides or {})),
capture_output=True, text=True, timeout=30) capture_output=True, text=True, timeout=30)
def requests(self): def requests(self):
return [json.loads(line) for line in self.log.read_text().splitlines()] return sorted((json.loads(line) for line in self.log.read_text().splitlines()),
key=lambda request: request["index"])
def events(self):
path = self.log.with_suffix(".events.jsonl")
return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []
def reset_logs(self):
self.log.unlink(missing_ok=True)
self.log.with_suffix(".events.jsonl").unlink(missing_ok=True)
def assert_processes_stopped(self):
for event in self.events():
for pid in (event["pid"], event.get("child_pid")):
if pid is None:
continue
try:
# A killed descendant can await reaping by init; it must
# not still be running or holding our temporary files.
state = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()[0]
self.assertEqual(state, "Z", f"Process {pid} survived worker cleanup")
except FileNotFoundError:
pass
def assert_cleaned(self): def assert_cleaned(self):
self.assertTrue(all(not Path(r["staging"]).exists() for r in self.requests())) self.assertTrue(all(not Path(r["staging"]).exists() for r in self.requests()))
def test_parallel_default_and_job_limit_with_ordered_output(self):
for options, limit, duration in (((), 8, 500), (("--jobs", "2"), 2, 180),
(("--jobs", "1"), 1, 130)):
with self.subTest(options=options):
self.reset_logs()
result = self.run_cli(
*options, "--chunk-minutes", "1", mode="parallel", duration=duration,
env_overrides={"TRANSCRIBE_TEST_BARRIER": str(limit)},
)
self.assertEqual(result.returncode, 0, result.stderr)
requests = self.requests()
self.assertEqual(result.stdout.split(), [f"Part{i}." for i in range(len(requests))])
active = maximum = 0
events = sorted(self.events(), key=lambda event: event["time"])
for event in events:
active += 1 if event["event"] == "start" else -1
maximum = max(maximum, active)
self.assertEqual(active, 0)
self.assertEqual(maximum, limit)
self.assertEqual(len({r["work"] for r in requests}), len(requests))
if limit > 1:
first_finished = next(e for e in events if e["event"] == "finish")
self.assertNotEqual(first_finished["index"], 0)
refilled = next(e["time"] for e in events if e["event"] == "start" and e["index"] == limit)
slow_finished = next(e["time"] for e in events if e["event"] == "finish" and e["index"] == 0)
self.assertLess(refilled, slow_finished)
self.assert_processes_stopped()
self.assert_cleaned()
def test_parallel_srt_matches_sequential_output(self):
serial = self.run_cli("--jobs", "1", "--chunk-minutes", "1", "--srt", mode="parallel", duration=130)
self.assertEqual(serial.returncode, 0, serial.stderr)
self.reset_logs()
parallel = self.run_cli(
"--jobs", "3", "--chunk-minutes", "1", "--srt", mode="parallel", duration=130,
env_overrides={"TRANSCRIBE_TEST_BARRIER": "3"},
)
self.assertEqual(parallel.returncode, 0, parallel.stderr)
self.assertEqual(parallel.stdout, serial.stdout)
self.assertEqual([block.splitlines()[0] for block in parallel.stdout.strip().split("\n\n")],
["1", "2", "3"])
self.assert_cleaned()
def test_parallel_failure_cancels_workers_and_their_children(self):
result = self.run_cli(
"--jobs", "2", "--chunk-minutes", "1", mode="parallel-error", duration=180,
env_overrides={"TRANSCRIBE_TEST_BARRIER": "2"},
)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(result.stdout, "")
self.assertIn("simulated concurrent failure", result.stderr)
self.assertIn("stopping remaining workers", result.stderr)
self.assertEqual({e["index"] for e in self.events() if e["event"] == "start"}, {0, 1})
self.assertTrue(any(e["event"] == "child" for e in self.events()))
self.assert_processes_stopped()
self.assert_cleaned()
def test_interruptions_stop_workers_before_removing_staging(self):
for signum in (signal.SIGINT, signal.SIGTERM):
with self.subTest(signal=signum):
self.reset_logs()
make_audio(self.audio, 180)
process = subprocess.Popen(
[str(ROOT / "transcribe"), "--jobs", "2", "--chunk-minutes", "1", str(self.audio)],
env=dict(self.env, TRANSCRIBE_TEST_MODE="blocked"),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True,
)
try:
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
events = self.events()
if (sum(e["event"] == "start" for e in events) >= 2 and
any(e["event"] == "child" for e in events)):
break
time.sleep(0.01)
else:
self.fail("Workers did not start")
process.send_signal(signum)
stdout, stderr = process.communicate(timeout=5)
self.assertEqual(process.returncode, 128 + signum, stderr)
self.assertEqual(stdout, "")
self.assert_processes_stopped()
self.assertTrue(all(not Path(e["staging"]).exists() for e in self.events()))
finally:
if process.poll() is None:
process.kill()
for event in self.events():
try:
os.killpg(event["group"], signal.SIGKILL)
except ProcessLookupError:
pass
process.communicate(timeout=5)
def test_immediate_encoder_failure_is_not_lost_by_worker_waiting(self):
encoder = self.bin / "ffmpeg"
encoder.write_text(
"#!/usr/bin/env python3\nimport os, sys\n"
"if '-ss' in sys.argv:\n sys.exit(42)\n"
f"os.execv({shutil.which('ffmpeg')!r}, ['ffmpeg', *sys.argv[1:]])\n"
)
encoder.chmod(0o755)
result = self.run_cli("--jobs", "64", "--chunk-minutes", "1", duration=500)
self.assertEqual(result.returncode, 42, result.stderr)
self.assertEqual(result.stdout, "")
self.assertIn("stopping remaining workers", result.stderr)
self.assertFalse(self.log.exists())
def test_invalid_jobs_fail_before_provider_access(self):
for value in ("0", "65", "-1", "1.5", "abc", "9999999999999999999999"):
with self.subTest(value=value):
result = self.run_cli("--jobs", value, duration=1)
self.assertNotEqual(result.returncode, 0)
self.assertIn("integer between 1 and 64", result.stderr)
result = subprocess.run([str(ROOT / "transcribe"), "--jobs"], env=self.env,
capture_output=True, text=True, timeout=5)
self.assertNotEqual(result.returncode, 0)
self.assertIn("integer between 1 and 64", result.stderr)
self.assertFalse(self.log.exists())
def assert_diarization(self, enabled): def assert_diarization(self, enabled):
for request in self.requests(): for request in self.requests():
self.assertEqual(request["provider"], { self.assertEqual(request["provider"], {
+44 -3
View File
@@ -7,6 +7,7 @@ import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import sys import sys
import time
if Path(sys.argv[0]).name == "op": if Path(sys.argv[0]).name == "op":
@@ -14,18 +15,34 @@ if Path(sys.argv[0]).name == "op":
sys.exit(0) sys.exit(0)
args = sys.argv[1:] args = sys.argv[1:]
request = json.loads(Path(args[args.index("--data-binary") + 1][1:]).read_text()) request_path = Path(args[args.index("--data-binary") + 1][1:])
request = json.loads(request_path.read_text())
output = Path(args[args.index("-o") + 1]) output = Path(args[args.index("-o") + 1])
index = int(output.stem.split("-")[1]) index = int(output.stem.split("-")[1])
manifest = json.loads((output.parent / "chunks.json").read_text()) manifest = json.loads((output.parent / "chunks.json").read_text())
chunk = manifest[index] chunk = manifest[index]
mode = os.environ.get("TRANSCRIBE_TEST_MODE", "ok")
events_path = Path(os.environ["TRANSCRIBE_TEST_LOG"]).with_suffix(".events.jsonl")
def event(kind, **extra):
record = dict(event=kind, index=index, pid=os.getpid(), group=os.getpgrp(),
time=time.monotonic(), staging=str(output.parent), **extra)
with events_path.open("a") as log:
log.write(json.dumps(record) + "\n")
event("start")
if mode in ("parallel-error", "blocked") and index == 0:
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
event("child", child_pid=child.pid)
assert request["model"] == "microsoft/mai-transcribe-2" assert request["model"] == "microsoft/mai-transcribe-2"
assert request["input_audio"]["format"] == "mp3" assert request["input_audio"]["format"] == "mp3"
if len(manifest) > 1: if len(manifest) > 1:
assert request["response_format"] == "verbose_json" assert request["response_format"] == "verbose_json"
assert request["timestamp_granularities"] == ["segment", "word"] assert request["timestamp_granularities"] == ["segment", "word"]
audio = output.parent / "uploaded.mp3" audio = output.parent / f"uploaded-{index}.mp3"
audio.write_bytes(base64.b64decode(request["input_audio"]["data"], validate=True)) audio.write_bytes(base64.b64decode(request["input_audio"]["data"], validate=True))
info = json.loads(subprocess.check_output([ info = json.loads(subprocess.check_output([
"ffprobe", "-v", "error", "-show_entries", "ffprobe", "-v", "error", "-show_entries",
@@ -36,13 +53,33 @@ assert abs(float(info["format"]["duration"]) - (chunk["end"] - chunk["start"]))
with open(os.environ["TRANSCRIBE_TEST_LOG"], "a") as log: with open(os.environ["TRANSCRIBE_TEST_LOG"], "a") as log:
log.write(json.dumps({ log.write(json.dumps({
"index": index, "chunk": chunk, "staging": str(output.parent), "index": index, "chunk": chunk, "staging": str(output.parent),
"work": str(request_path.parent),
"upload_duration": float(info["format"]["duration"]), "upload_duration": float(info["format"]["duration"]),
"provider": request.get("provider"), "provider": request.get("provider"),
"response_format": request.get("response_format"), "response_format": request.get("response_format"),
"timestamp_granularities": request.get("timestamp_granularities"), "timestamp_granularities": request.get("timestamp_granularities"),
}) + "\n") }) + "\n")
mode = os.environ.get("TRANSCRIBE_TEST_MODE", "ok") if mode in ("parallel", "parallel-error"):
deadline = time.monotonic() + 5
barrier = int(os.environ.get("TRANSCRIBE_TEST_BARRIER", "1"))
while True:
events = [json.loads(line) for line in events_path.read_text().splitlines()]
enough = sum(e["event"] == "start" for e in events) >= barrier
child_ready = mode != "parallel-error" or any(e["event"] == "child" for e in events)
if enough and child_ready:
break
if time.monotonic() >= deadline:
raise RuntimeError("Worker concurrency barrier timed out")
time.sleep(0.01)
if mode == "parallel-error" and index == 1:
output.write_text('{"error":{"message":"simulated concurrent failure"}}')
event("finish")
sys.exit(22)
time.sleep(30 if mode == "parallel-error" else (0.8 if index == 0 else 0.05))
elif mode == "blocked":
time.sleep(30)
if index == 1: if index == 1:
if mode == "http-error": if mode == "http-error":
output.write_text('{"error":{"message":"simulated timeout"}}') output.write_text('{"error":{"message":"simulated timeout"}}')
@@ -78,5 +115,9 @@ else:
if mode == "speakers" or speaker == 0: if mode == "speakers" or speaker == 0:
word["speaker"] = (speaker if len(manifest) == 1 else word["speaker"] = (speaker if len(manifest) == 1 else
int(word["start"] + chunk["start"] >= 57.3)) int(word["start"] + chunk["start"] >= 57.3))
if mode == "parallel":
middle = (chunk["end"] - chunk["start"]) / 2
words = [{"word": f"Part{index}.", "start": middle, "end": middle + 0.4}]
response = {"text": " ".join(w["word"] for w in words), "words": words} response = {"text": " ".join(w["word"] for w in words), "words": words}
output.write_text(json.dumps(response)) output.write_text(json.dumps(response))
event("finish")
+91 -8
View File
@@ -5,17 +5,27 @@ set -euo pipefail
srt=false srt=false
diarization=true diarization=true
chunk_minutes= chunk_minutes=
jobs=8
usage() { usage() {
echo "Usage: ${0##*/} [--srt] [--no-diarization] [--chunk-minutes 1..30] AUDIO_FILE" echo "Usage: ${0##*/} [--srt] [--no-diarization] [--chunk-minutes 1..30] [--jobs 1..64] AUDIO_FILE"
echo 'Transcribe audio using OPENROUTER_API_KEY; print wrapped text with speaker markers to stdout.' 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 ' --srt Print SRT subtitles in short cues using word timestamps instead.'
echo ' --no-diarization Disable speaker diarization (enabled by default).' echo ' --no-diarization Disable speaker diarization (enabled by default).'
echo ' --chunk-minutes N Maximum upload duration in minutes (default: 10; 30 with --no-diarization).' echo ' --chunk-minutes N Maximum upload duration in minutes (default: 10; 30 with --no-diarization).'
echo ' --jobs N Maximum chunks processed in parallel (default: 8; 1 for sequential).'
} }
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
--srt) srt=true; shift ;; --srt) srt=true; shift ;;
--no-diarization) diarization=false; shift ;; --no-diarization) diarization=false; shift ;;
--jobs)
if [[ ! ${2:-} =~ ^([1-9]|[1-5][0-9]|6[0-4])$ ]]; then
echo '--jobs must be an integer between 1 and 64' >&2
exit 1
fi
jobs=$2
shift 2
;;
--chunk-minutes) --chunk-minutes)
if [[ ! ${2:-} =~ ^([1-9]|[12][0-9]|30)$ ]]; then if [[ ! ${2:-} =~ ^([1-9]|[12][0-9]|30)$ ]]; then
echo '--chunk-minutes must be an integer between 1 and 30' >&2 echo '--chunk-minutes must be an integer between 1 and 30' >&2
@@ -42,6 +52,10 @@ if [[ ! -f $1 || ! -r $1 ]]; then
echo "Cannot read audio file: $1" >&2 echo "Cannot read audio file: $1" >&2
exit 1 exit 1
fi fi
if (( BASH_VERSINFO[0] < 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] < 1) )); then
echo 'Bash 5.1 or newer is required for parallel chunk processing.' >&2
exit 1
fi
export OPENROUTER_API_KEY export OPENROUTER_API_KEY
OPENROUTER_API_KEY="$(op read "op://API Keys/openrouter-mai-transcribe-2/credential")" OPENROUTER_API_KEY="$(op read "op://API Keys/openrouter-mai-transcribe-2/credential")"
: "${OPENROUTER_API_KEY:?Set OPENROUTER_API_KEY first}" : "${OPENROUTER_API_KEY:?Set OPENROUTER_API_KEY first}"
@@ -54,7 +68,25 @@ for tool in "${dependencies[@]}"; do
done done
tmp=$(mktemp -d) tmp=$(mktemp -d)
trap 'rm -rf -- "$tmp"' EXIT declare -A workers=()
cleanup() {
trap - EXIT
trap '' INT TERM
local pid
local -a pending
# Include a job interrupted between launch and registration in workers.
mapfile -t pending < <(jobs -p)
# Each worker owns a process group, including its encoder and curl. Stop
# the whole group and reap workers before removing files they might use.
for pid in "${pending[@]}"; do
kill -KILL -- "-$pid" 2>/dev/null || true
done
for pid in "${pending[@]}"; do
wait "$pid" 2>/dev/null || true
done
rm -rf -- "$tmp"
}
trap cleanup EXIT
trap 'exit 130' INT trap 'exit 130' INT
trap 'exit 143' TERM trap 'exit 143' TERM
script_dir=$(dirname -- "$(realpath -- "$0")") script_dir=$(dirname -- "$(realpath -- "$0")")
@@ -70,32 +102,37 @@ if "$diarization"; then timestamps=true; fi
if (( count > 1 )); then if (( count > 1 )); then
timestamps=true timestamps=true
echo "Splitting audio into $count overlapping chunks (maximum $chunk_minutes minutes each)." >&2 echo "Splitting audio into $count overlapping chunks (maximum $chunk_minutes minutes each)." >&2
echo "Processing up to $jobs chunks in parallel." >&2
fi fi
for ((i = 0; i < count; i++)); do transcribe_chunk() {
trap - EXIT INT TERM
local i=$1 start duration work
work=$tmp/chunk-$i
mkdir -- "$work"
read -r start duration < <(jq -r --argjson i "$i" \ read -r start duration < <(jq -r --argjson i "$i" \
'.[$i] | "\(.start) \(.end - .start)"' "$tmp/chunks.json") '.[$i] | "\(.start) \(.end - .start)"' "$tmp/chunks.json")
if (( count > 1 )); then if (( count > 1 )); then
echo "Transcribing chunk $((i + 1))/$count (start ${start}s, duration ${duration}s)..." >&2 echo "Transcribing chunk $((i + 1))/$count (start ${start}s, duration ${duration}s)..." >&2
fi fi
ffmpeg -nostdin -hide_banner -loglevel error -y -ss "$start" -i "$tmp/audio.wav" \ 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" -t "$duration" -map 0:a:0 -ac 1 -ar 16000 -c:a libmp3lame -b:a 64k "$work/audio.mp3"
# Keep large audio payloads out of shell arguments (which have a size limit). # Keep large audio payloads out of shell arguments (which have a size limit).
base64 <"$tmp/audio.mp3" | tr -d '\n\r' >"$tmp/audio.b64" base64 <"$work/audio.mp3" | tr -d '\n\r' >"$work/audio.b64"
jq -n --rawfile data "$tmp/audio.b64" --argjson timestamps "$timestamps" \ jq -n --rawfile data "$work/audio.b64" --argjson timestamps "$timestamps" \
--argjson diarization "$diarization" \ --argjson diarization "$diarization" \
'{model: "microsoft/mai-transcribe-2", input_audio: {data: $data, format: "mp3"}, '{model: "microsoft/mai-transcribe-2", input_audio: {data: $data, format: "mp3"},
provider: {options: {azure: {diarization: {enabled: $diarization}}}}} provider: {options: {azure: {diarization: {enabled: $diarization}}}}}
+ (if $timestamps then {response_format: "verbose_json", timestamp_granularities: ["segment", "word"]} + (if $timestamps then {response_format: "verbose_json", timestamp_granularities: ["segment", "word"]}
else {} end)' \ else {} end)' \
>"$tmp/request.json" >"$work/request.json"
curl --silent --show-error --fail-with-body \ curl --silent --show-error --fail-with-body \
https://openrouter.ai/api/v1/audio/transcriptions \ https://openrouter.ai/api/v1/audio/transcriptions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \ -H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
--data-binary @"$tmp/request.json" -o "$tmp/response-$i.json" || { --data-binary @"$work/request.json" -o "$tmp/response-$i.json" || {
echo "Transcription failed for chunk $((i + 1))/$count." >&2 echo "Transcription failed for chunk $((i + 1))/$count." >&2
if [[ -f $tmp/response-$i.json ]]; then if [[ -f $tmp/response-$i.json ]]; then
cat "$tmp/response-$i.json" >&2 cat "$tmp/response-$i.json" >&2
@@ -113,7 +150,53 @@ for ((i = 0; i < count; i++)); do
echo "Warning: chunk $((i + 1))/$count returned some words without speaker labels." >&2 echo "Warning: chunk $((i + 1))/$count returned some words without speaker labels." >&2
fi fi
fi fi
rm -rf -- "$work"
}
wait_for_worker() {
local pid finished status=0
# wait -n skips jobs that finished before it was called. Reap those by PID
# first, so even an immediate encoder/request failure cannot disappear.
for pid in "${!workers[@]}"; do
if ! kill -0 "$pid" 2>/dev/null; then
wait "$pid" || status=$?
unset 'workers[$pid]'
return "$status"
fi
done
wait -n -f -p finished || status=$?
if [[ -z ${finished:-} ]]; then
# All remaining jobs may have exited between the probe and wait -n.
for pid in "${!workers[@]}"; do
status=0
wait "$pid" || status=$?
finished=$pid
break
done
fi
unset 'workers[$finished]'
return "$status"
}
# Job control gives every background worker a separate process group. Their
# response filenames retain recording order regardless of completion order.
set -m
next=0
while (( next < count || ${#workers[@]} > 0 )); do
while (( next < count && ${#workers[@]} < jobs )); do
transcribe_chunk "$next" &
workers[$!]=$next
next=$((next + 1))
done
if wait_for_worker; then
:
else
status=$?
echo 'Transcription failed; stopping remaining workers.' >&2
exit "$status"
fi
done done
set +m
# Only emit a transcript after every chunk succeeds, including SRT conversion. # Only emit a transcript after every chunk succeeds, including SRT conversion.
if (( count > 1 )); then if (( count > 1 )); then