From 24b10307765ba4c72888db73978b34c71282ac86 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sat, 12 Sep 2026 09:04:29 +0200 Subject: [PATCH] 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. --- README.md | 23 ++++++++++--- test_transcribe.py | 74 ++++++++++++++++++++++++++++++++++++++++++ tests/fake_provider.py | 17 ++++++++-- transcribe | 17 +++++++--- 4 files changed, 121 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b751d7a..1da127c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/test_transcribe.py b/test_transcribe.py index b762293..a1cfdaf 100644 --- a/test_transcribe.py +++ b/test_transcribe.py @@ -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"): diff --git a/tests/fake_provider.py b/tests/fake_provider.py index 341b8f4..dba0e26 100644 --- a/tests/fake_provider.py +++ b/tests/fake_provider.py @@ -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)) diff --git a/transcribe b/transcribe index 0abc2c2..5d0a231 100755 --- a/transcribe +++ b/transcribe @@ -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"