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:
1 parent
a5c535cfb9
commit
24b1030776
4 files changed
+121
-10
No files matched your search
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user