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 recording.m4a
./transcribe video.mp4 > transcript.txt ./transcribe video.mp4 > transcript.txt
./transcribe --srt video.mp4 > subtitles.srt ./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 ./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 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. 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**, Speaker diarization is **enabled by default** through
including overlap and MP3 padding. Use `--chunk-minutes N` (an integer from 1 to `provider.options.azure.diarization.enabled`, following
30) to lower the limit. At 64 kbps, a 30-minute upload is about 14.4 MB of MP3 [OpenRouter's speaker diarization API](https://openrouter.ai/docs/guides/overview/multimodal/stt#verbose-transcripts-timestamps-and-speakers).
or 19.2 MB of base64. This is a local default, not a guaranteed provider limit: 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), [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. 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): 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 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): def test_long_text_uploads_bounded_mp3_and_stitches_once(self):
result = self.run_cli("--chunk-minutes", "1") result = self.run_cli("--chunk-minutes", "1")
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
@@ -48,7 +57,9 @@ class TranscribeTests(unittest.TestCase):
self.assertIn("chunk 2/2", result.stderr) self.assertIn("chunk 2/2", result.stderr)
requests = self.requests() requests = self.requests()
self.assertEqual(len(requests), 2) 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.assertAlmostEqual(requests[0]["chunk"]["end"] - requests[1]["chunk"]["start"], 6)
self.assert_diarization(True)
self.assert_cleaned() self.assert_cleaned()
def test_long_srt_uses_original_timeline_and_continuous_numbers(self): 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" "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" "3\n00:01:05,000 --> 00:01:05,400\nLast.\n\n"
)) ))
self.assert_diarization(True)
self.assert_cleaned() self.assert_cleaned()
def test_short_text_retains_provider_formatting(self): 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.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "Provider's original formatting.\nNext line.\n") self.assertEqual(result.stdout, "Provider's original formatting.\nNext line.\n")
self.assertEqual(len(self.requests()), 1) self.assertEqual(len(self.requests()), 1)
self.assertIsNone(self.requests()[0]["response_format"])
self.assert_diarization(True)
self.assert_cleaned() self.assert_cleaned()
def test_short_srt_still_requests_word_timestamps(self): def test_short_srt_still_requests_word_timestamps(self):
result = self.run_cli("--srt", duration=5) result = self.run_cli("--srt", duration=5)
self.assertEqual(result.returncode, 0, result.stderr) 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(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): def test_failed_later_chunk_has_no_partial_stdout_and_cleans_up(self):
for mode in ("http-error", "bad-json", "api-error", "missing-words"): 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), "format=duration:stream=codec_name,sample_rate,channels", "-of", "json", str(audio),
], text=True)) ], text=True))
assert info["streams"][0] == {"codec_name": "mp3", "sample_rate": "16000", "channels": 1} 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 assert abs(float(info["format"]["duration"]) - (chunk["end"] - chunk["start"])) < 0.1
with open(os.environ["TRANSCRIBE_TEST_LOG"], "a") as log: 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") mode = os.environ.get("TRANSCRIBE_TEST_MODE", "ok")
if index == 1: if index == 1:
@@ -62,5 +67,13 @@ else:
("continues.", 57.4, 57.8), ("Last.", 65, 65.4), ("continues.", 57.4, 57.8), ("Last.", 65, 65.4),
] if start >= chunk["start"] and end <= chunk["end"] ] 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} response = {"text": " ".join(w["word"] for w in words), "words": words}
output.write_text(json.dumps(response)) output.write_text(json.dumps(response))
+13 -4
View File
@@ -3,16 +3,19 @@
set -euo pipefail set -euo pipefail
srt=false srt=false
chunk_minutes=30 diarization=true
chunk_minutes=
usage() { 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 'Transcribe audio using OPENROUTER_API_KEY; print the text 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 ' --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 while [[ $# -gt 0 ]]; do
case $1 in case $1 in
--srt) srt=true; shift ;; --srt) srt=true; shift ;;
--no-diarization) diarization=false; shift ;;
--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
@@ -27,6 +30,10 @@ while [[ $# -gt 0 ]]; do
*) break ;; *) break ;;
esac esac
done 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 if [[ $# != 1 ]]; then
usage >&2 usage >&2
exit 1 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). # 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 <"$tmp/audio.mp3" | tr -d '\n\r' >"$tmp/audio.b64"
jq -n --rawfile data "$tmp/audio.b64" --argjson timestamps "$timestamps" \ 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"]} + (if $timestamps then {response_format: "verbose_json", timestamp_granularities: ["segment", "word"]}
else {} end)' \ else {} end)' \
>"$tmp/request.json" >"$tmp/request.json"