fix: reconcile speakers automatically by default

The plain transcribe command still used chunk overlap speaker matching,
so users had to discover a model flag or supply a known speaker count to
get the improved voice comparison behavior.

Enable OpenRouter voice comparison with automatic speaker counting for
normal text and SRT invocations. Keep --speakers and --speaker-model as
optional overrides, and let --no-diarization disable both provider labels
and voice comparison. Make the basic command the documentation entry point
and clarify the defaults in CLI help and repository guidance.

Cover bare invocations across one and multiple uploads, default SRT,
optional count constraints, opt-out ordering, and atomic output when the
default comparison fails.

Test Plan:
- python3 -B -m unittest -v: all 85 tests passed.
- bash -n transcribe: passed.
- git diff --cached --check: passed.
- Tests use real audio encoding with fake provider responses. Live voice
  accuracy remains unverified; no live API request was made for this change.
This commit is contained in:
ddidderr committed 2026-09-12 10:28:42 +02:00
1 parent 138c796399
commit d7827d39f5
5 files changed
+107 -48

No files matched your search

+64 -4
View File
@@ -53,6 +53,14 @@ class TranscribeTests(unittest.TestCase):
def speaker_request(self):
return json.loads(self.log.with_suffix(".speakers.json").read_text())
def assert_automatic_speaker_comparison(self):
request = self.speaker_request()["request"]
self.assertEqual(request["model"], "google/gemini-3.8-flash")
schema = request["response_format"]["json_schema"]["schema"]
speaker_schema = schema["properties"]["assignments"]["items"]["properties"]["speaker"]
self.assertNotIn("maximum", speaker_schema)
self.assertNotIn("complete recording has exactly", request["messages"][0]["content"])
def assert_processes_stopped(self):
for event in self.events():
for pid in (event["pid"], event.get("child_pid")):
@@ -236,7 +244,8 @@ class TranscribeTests(unittest.TestCase):
self.assertEqual(self.requests()[0]["response_format"], "verbose_json")
self.assertEqual(self.requests()[0]["timestamp_granularities"], ["segment", "word"])
self.assert_diarization(True)
self.assertEqual(result.stderr, "")
self.assertIn("no clean speaker samples", result.stderr)
self.assertFalse(self.log.with_suffix(".speakers.json").exists())
self.assert_cleaned()
def test_chunked_text_has_speaker_markers_without_chunk_details(self):
@@ -248,7 +257,8 @@ class TranscribeTests(unittest.TestCase):
"[Speaker 1]\ncontinues.\n\n"
"[Speaker 1]\nLast.\n"
))
self.assertIn("speaker continuity is uncertain", result.stderr)
self.assertIn("no clean speaker samples", result.stderr)
self.assertFalse(self.log.with_suffix(".speakers.json").exists())
self.assertNotIn("Chunk", result.stdout)
self.assert_cleaned()
@@ -276,7 +286,7 @@ class TranscribeTests(unittest.TestCase):
for output in ((), ("--srt",)):
for chunked in (False, True):
with self.subTest(output=output, chunked=chunked):
self.log.unlink(missing_ok=True)
self.reset_logs()
options = ("--chunk-minutes", "1") if chunked else ()
result = self.run_cli(
*output, *options, "--no-diarization", duration=70 if chunked else 5,
@@ -285,6 +295,7 @@ class TranscribeTests(unittest.TestCase):
self.assertTrue(result.stdout)
self.assertEqual(len(self.requests()), 2 if chunked else 1)
self.assert_diarization(False)
self.assertFalse(self.log.with_suffix(".speakers.json").exists())
for request in self.requests():
if output or chunked:
self.assertEqual(request["response_format"], "verbose_json")
@@ -302,6 +313,22 @@ class TranscribeTests(unittest.TestCase):
self.assert_diarization(False)
self.assert_cleaned()
def test_no_diarization_skips_voice_comparison_with_usable_samples(self):
# Do not compare voices even if a provider returns labels after opt-out.
for output in ((), ("--srt",)):
for options in (("--no-diarization", "--chunk-minutes", "1", "--jobs", "1"),
("--chunk-minutes", "1", "--jobs", "2", "--no-diarization")):
with self.subTest(output=output, options=options):
self.reset_logs()
result = self.run_cli(*output, *options, mode="reconcile")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("AlphaLate.", result.stdout)
self.assertEqual(len(self.requests()), 2)
self.assert_diarization(False)
self.assertFalse(self.log.with_suffix(".speakers.json").exists())
self.assertNotIn("Acoustic speaker matching", result.stderr)
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):
@@ -356,12 +383,44 @@ class TranscribeTests(unittest.TestCase):
self.assertNotIn("speaker continuity is uncertain", result.stderr)
comparison = self.speaker_request()
self.assertEqual(comparison["request"]["model"], "google/gemini-3.8-flash")
schema = comparison["request"]["response_format"]["json_schema"]["schema"]
self.assertEqual(schema["properties"]["assignments"]["items"]["properties"]["speaker"]["maximum"], 1)
self.assertIn("exactly 2 speakers", comparison["request"]["messages"][0]["content"])
self.assertEqual(len(comparison["durations"]), 5)
self.assertTrue(all(1.5 <= duration <= 8.1 for duration in comparison["durations"]))
self.assertEqual(len(self.requests()), 2)
self.assert_diarization(True)
self.assert_cleaned()
def test_bare_command_compares_voices_without_a_known_speaker_count(self):
for duration in (30, 611):
with self.subTest(duration=duration):
self.reset_logs()
result = self.run_cli(mode="reconcile", duration=duration)
self.assertEqual(result.returncode, 0, result.stderr)
expected = "[Speaker 0]\nAlpha.\n\n[Speaker 0]\nAgain.\n\n[Speaker 1]\nBeta.\n"
if duration > 600:
expected += "\n[Speaker 0]\nAlphaLate.\n\n[Speaker 1]\nBetaLate.\n"
self.assertEqual(result.stdout, expected)
self.assert_automatic_speaker_comparison()
self.assertEqual(len(self.requests()), 2 if duration > 600 else 1)
self.assertEqual(len(self.speaker_request()["samples"]), 5 if duration > 600 else 3)
self.assert_diarization(True)
self.assert_cleaned()
def test_default_srt_compares_voices_without_speaker_options(self):
result = self.run_cli("--srt", mode="reconcile", duration=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, (
"1\n00:00:01,000 --> 00:00:03,000\nAlpha.\n\n"
"2\n00:00:06,000 --> 00:00:08,000\nAgain.\n\n"
"3\n00:00:12,000 --> 00:00:14,000\nBeta.\n\n"
))
self.assert_automatic_speaker_comparison()
self.assertEqual(len(self.requests()), 1)
self.assert_diarization(True)
self.assert_cleaned()
def test_voice_comparison_model_override_without_count_and_single_request(self):
for model, count in (("test/audio-model", ()), ("~audio-model", ("--speakers", "2"))):
with self.subTest(model=model):
@@ -411,7 +470,7 @@ class TranscribeTests(unittest.TestCase):
"reconcile-truncated"):
with self.subTest(mode=mode):
self.reset_logs()
result = self.run_cli("--speakers", "2", "--chunk-minutes", "1", mode=mode)
result = self.run_cli("--chunk-minutes", "1", mode=mode)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(result.stdout, "")
self.assertTrue(self.log.with_suffix(".speakers.json").exists())
@@ -438,6 +497,7 @@ class TranscribeTests(unittest.TestCase):
options += [("--speaker-model", value) for value in ("", "-bad", "bad model", "a\nb")]
options += [("--no-diarization", "--speakers", "2"),
("--speakers", "2", "--no-diarization"),
("--no-diarization", "--speaker-model", "test/audio"),
("--speaker-model", "test/audio", "--no-diarization")]
for args in options:
with self.subTest(options=args):