feat: enable diarization by default with an opt-out

Request speaker diarization through OpenRouter's Azure provider options for
all transcription uploads. Add --no-diarization to explicitly disable it for
both plain text and SRT output; returned speaker labels let existing SRT
cue generation split at speaker changes.

Default to 10-minute chunks when diarization is enabled because Microsoft
reports failures around 15 minutes and longer. Preserve the 30-minute
default with the opt-out and honor explicit --chunk-minutes values in both
modes. Document the options, duration limits, and output behavior.

Test Plan:
- python3 -B -m unittest -v: all 32 tests passed, including request options,
  default upload limits, opt-out combinations, and SRT speaker boundaries.
- bash -n transcribe: passed.
- ./transcribe --help: verified the new option and chunk defaults.
- git diff --cached --check: passed.
- Provider responses were mocked; no live API request was made.
This commit is contained in:
ddidderr committed 2026-09-12 09:04:29 +02:00
1 parent a5c535cfb9
commit 24b1030776
4 files changed
+121 -10

No files matched your search

+19 -4
View File
@@ -8,6 +8,8 @@ the 1Password CLI (`op`) from `op://API Keys/openrouter-mai-transcribe-2/credent
./transcribe recording.m4a
./transcribe video.mp4 > transcript.txt
./transcribe --srt video.mp4 > subtitles.srt
./transcribe --no-diarization recording.m4a > transcript.txt
./transcribe --srt --no-diarization video.mp4 > subtitles.srt
./transcribe --chunk-minutes 10 too_long.webm > transcript.txt
```
@@ -18,10 +20,23 @@ and sends it to OpenRouter's `microsoft/mai-transcribe-2` model. Temporary
files are removed on exit. The transcript goes to stdout; progress and errors
go to stderr. Output is emitted only after all requests and conversion succeed.
Long recordings are automatically split into uploads of at most **30 minutes**,
including overlap and MP3 padding. Use `--chunk-minutes N` (an integer from 1 to
30) to lower the limit. At 64 kbps, a 30-minute upload is about 14.4 MB of MP3
or 19.2 MB of base64. This is a local default, not a guaranteed provider limit:
Speaker diarization is **enabled by default** through
`provider.options.azure.diarization.enabled`, following
[OpenRouter's speaker diarization API](https://openrouter.ai/docs/guides/overview/multimodal/stt#verbose-transcripts-timestamps-and-speakers).
Use `--no-diarization` to explicitly disable it for every request, with either
plain text or `--srt` output. SRT uses returned speaker labels to break cues at
speaker changes; neither output format prints speaker names or IDs.
Long recordings are automatically split into uploads of at most **10 minutes**
with diarization, or **30 minutes** with `--no-diarization`, including overlap
and MP3 padding. These defaults account for
[Microsoft's documented diarization failures around 15 minutes and longer](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/mai-transcribe).
Use `--chunk-minutes N` (an integer from 1 to 30) to override the limit in either
mode. Larger chunks with diarization may fail with a timeout or
`diarization_unavailable`; reduce the chunk size or use `--no-diarization`.
At 64 kbps, a 10-minute upload is about 4.8 MB of MP3 or 6.4 MB of base64;
a 30-minute upload is about 14.4 MB of MP3 or 19.2 MB of base64.
These are local defaults, not guaranteed provider limits:
[OpenRouter documents a 60-second processing timeout](https://openrouter.ai/docs/guides/overview/multimodal/stt),
so reduce the chunk size if requests still time out.
+74
View File
@@ -41,6 +41,15 @@ class TranscribeTests(unittest.TestCase):
def assert_cleaned(self):
self.assertTrue(all(not Path(r["staging"]).exists() for r in self.requests()))
def assert_diarization(self, enabled):
for request in self.requests():
self.assertEqual(request["provider"], {
"options": {"azure": {"diarization": {"enabled": enabled}}},
})
self.assertIs(
request["provider"]["options"]["azure"]["diarization"]["enabled"], enabled,
)
def test_long_text_uploads_bounded_mp3_and_stitches_once(self):
result = self.run_cli("--chunk-minutes", "1")
self.assertEqual(result.returncode, 0, result.stderr)
@@ -48,7 +57,9 @@ class TranscribeTests(unittest.TestCase):
self.assertIn("chunk 2/2", result.stderr)
requests = self.requests()
self.assertEqual(len(requests), 2)
self.assertTrue(all(r["upload_duration"] <= 60 for r in requests))
self.assertAlmostEqual(requests[0]["chunk"]["end"] - requests[1]["chunk"]["start"], 6)
self.assert_diarization(True)
self.assert_cleaned()
def test_long_srt_uses_original_timeline_and_continuous_numbers(self):
@@ -59,6 +70,7 @@ class TranscribeTests(unittest.TestCase):
"2\n00:00:56,700 --> 00:00:57,800\nboundary continues.\n\n"
"3\n00:01:05,000 --> 00:01:05,400\nLast.\n\n"
))
self.assert_diarization(True)
self.assert_cleaned()
def test_short_text_retains_provider_formatting(self):
@@ -66,12 +78,74 @@ class TranscribeTests(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "Provider's original formatting.\nNext line.\n")
self.assertEqual(len(self.requests()), 1)
self.assertIsNone(self.requests()[0]["response_format"])
self.assert_diarization(True)
self.assert_cleaned()
def test_short_srt_still_requests_word_timestamps(self):
result = self.run_cli("--srt", duration=5)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "1\n00:00:01,000 --> 00:00:01,400\nHello.\n\n")
self.assertEqual(self.requests()[0]["response_format"], "verbose_json")
self.assertEqual(self.requests()[0]["timestamp_granularities"], ["segment", "word"])
self.assert_diarization(True)
def test_no_diarization_applies_to_text_srt_and_every_chunk(self):
for output in ((), ("--srt",)):
for chunked in (False, True):
with self.subTest(output=output, chunked=chunked):
self.log.unlink(missing_ok=True)
options = ("--chunk-minutes", "1") if chunked else ()
result = self.run_cli(
*output, *options, "--no-diarization", duration=70 if chunked else 5,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(result.stdout)
self.assertEqual(len(self.requests()), 2 if chunked else 1)
self.assert_diarization(False)
for request in self.requests():
if output or chunked:
self.assertEqual(request["response_format"], "verbose_json")
self.assertEqual(request["timestamp_granularities"], ["segment", "word"])
else:
self.assertIsNone(request["response_format"])
self.assertIsNone(request["timestamp_granularities"])
self.assertLessEqual(request["upload_duration"], 60)
self.assert_cleaned()
# The opt-out must also preserve a limit specified after it.
self.log.unlink()
result = self.run_cli("--no-diarization", "--chunk-minutes", "1")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(len(self.requests()), 2)
self.assert_diarization(False)
self.assert_cleaned()
def test_default_upload_limits_depend_on_diarization(self):
for options, limit in (((), 600), (("--no-diarization",), 1800)):
with self.subTest(options=options):
self.log.unlink(missing_ok=True)
result = self.run_cli(*options, duration=limit + 10)
self.assertEqual(result.returncode, 0, result.stderr)
requests = self.requests()
self.assertEqual(len(requests), 2)
self.assertGreaterEqual(requests[0]["upload_duration"], limit - 0.1)
self.assertTrue(all(r["upload_duration"] <= limit for r in requests))
self.assert_diarization(not options)
self.assert_cleaned()
def test_diarization_breaks_srt_at_speaker_changes(self):
result = self.run_cli("--srt", mode="speakers", duration=5)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, (
"1\n00:00:01,000 --> 00:00:01,400\nHello\n\n"
"2\n00:00:01,500 --> 00:00:01,900\nthere\n\n"
))
self.assert_diarization(True)
self.log.unlink()
result = self.run_cli("--no-diarization", "--srt", mode="speakers", duration=5)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "1\n00:00:01,000 --> 00:00:01,900\nHello there\n\n")
self.assert_diarization(False)
def test_failed_later_chunk_has_no_partial_stdout_and_cleans_up(self):
for mode in ("http-error", "bad-json", "api-error", "missing-words"):
+15 -2
View File
@@ -32,10 +32,15 @@ info = json.loads(subprocess.check_output([
"format=duration:stream=codec_name,sample_rate,channels", "-of", "json", str(audio),
], text=True))
assert info["streams"][0] == {"codec_name": "mp3", "sample_rate": "16000", "channels": 1}
assert float(info["format"]["duration"]) <= 60
assert abs(float(info["format"]["duration"]) - (chunk["end"] - chunk["start"])) < 0.1
with open(os.environ["TRANSCRIBE_TEST_LOG"], "a") as log:
log.write(json.dumps({"index": index, "chunk": chunk, "staging": str(output.parent)}) + "\n")
log.write(json.dumps({
"index": index, "chunk": chunk, "staging": str(output.parent),
"upload_duration": float(info["format"]["duration"]),
"provider": request.get("provider"),
"response_format": request.get("response_format"),
"timestamp_granularities": request.get("timestamp_granularities"),
}) + "\n")
mode = os.environ.get("TRANSCRIBE_TEST_MODE", "ok")
if index == 1:
@@ -62,5 +67,13 @@ else:
("continues.", 57.4, 57.8), ("Last.", 65, 65.4),
] if start >= chunk["start"] and end <= chunk["end"]
]
if mode == "speakers":
words = [
{"word": "Hello", "start": 1, "end": 1.4},
{"word": "there", "start": 1.5, "end": 1.9},
]
if request["provider"]["options"]["azure"]["diarization"]["enabled"]:
for speaker, word in enumerate(words):
word["speaker"] = speaker
response = {"text": " ".join(w["word"] for w in words), "words": words}
output.write_text(json.dumps(response))
+13 -4
View File
@@ -3,16 +3,19 @@
set -euo pipefail
srt=false
chunk_minutes=30
diarization=true
chunk_minutes=
usage() {
echo "Usage: ${0##*/} [--srt] [--chunk-minutes 1..30] AUDIO_FILE"
echo "Usage: ${0##*/} [--srt] [--no-diarization] [--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.'
echo ' --chunk-minutes N Maximum upload duration in minutes (default: 30).'
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
@@ -27,6 +30,10 @@ while [[ $# -gt 0 ]]; do
*) 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
@@ -76,7 +83,9 @@ for ((i = 0; i < count; i++)); do
# 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"}}
--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"