Files
transcribe/test_diarization.py
T
ddidderr 3042b7743b feat!: use whole-recording Pyannote speaker diarization
Sampled Gemini voice matching could leave one person under separate IDs
across transcription chunks. Use hosted Precision-2 on the complete
recording and align its exclusive speaker timeline with MAI word timing.
Preserve transcription text, order, and timestamps; leave ambiguous word
assignments unknown instead of merging identities to satisfy a count.

Upload lossless FLAC, bound polling, retry transient status reads against
the same job, and validate results before emitting stdout. Keep optional
speaker-count and hosted model overrides. Disable MAI diarization and use
30-minute transcription uploads in both modes. Respect environment keys,
keep authentication out of process arguments, and save optional diagnostics
without audio, credentials, or signed upload URLs.

Test Plan:
- python3 -B -m unittest -v: all 101 regression tests passed, including
  mocked provider upload, polling, alignment, failure, and cleanup paths.
- bash -n transcribe and shellcheck transcribe: passed.
- git diff --cached --check: passed.
- Prepared the 74-minute input locally and verified identical decoded PCM
  hashes for the normalized WAV and full-recording lossless FLAC.
- Live API testing was explicitly deferred by the user; mocked results do
  not establish speaker accuracy on the recording.

BREAKING CHANGE: Default diarization requires PYANNOTE_API_KEY or its
1Password reference. --speaker-model accepts hosted precision-2 or
community-1 instead of OpenRouter audio-chat model IDs.
2026-09-12 11:18:30 +02:00

381 lines
21 KiB
Python

import contextlib
import copy
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import wave
import diarization
def word(start, end, speaker=9, text="speech"):
return {"word": text, "start": start, "end": end, "speaker": speaker}
def transcript(*words):
return {"text": " ".join(item["word"] for item in words), "words": list(words)}
def turn(start, end, speaker="voice-A", **extra):
return dict(start=start, end=end, speaker=speaker, **extra)
def job(*segments, **extra):
return dict(jobId="test-job", status="succeeded",
output={"exclusiveDiarization": list(segments)}, **extra)
def align(source, result, speakers=None):
with contextlib.redirect_stderr(io.StringIO()):
return diarization.align(source, result, speakers)
def write_audio(directory, duration=30):
path = Path(directory) / "audio.wav"
with wave.open(str(path), "wb") as audio:
audio.setparams((1, 2, 16000, 0, "NONE", "not compressed"))
audio.writeframes(b"\0\0" * int(duration * 16000))
return path
class AlignmentTests(unittest.TestCase):
def test_global_timeline_reunites_host_in_ads_and_across_request_local_ids(self):
source = transcript(word(1, 3, 7, "Host interview."), word(4, 6, 8, "Guest answer."),
word(601, 603, 8, "Host ad with a different microphone."),
word(1001, 1003, 9, "Host interview again."),
word(1004, 1006, 7, "Guest answer again."))
timeline = job(turn(0, 3.5, "HOST"), turn(4, 8, "GUEST"), turn(600, 650, "HOST"),
turn(1000, 1003.5, "HOST"), turn(1004, 1020, "GUEST"))
result = align(source, timeline, speakers=2)
self.assertEqual([w["speaker"] for w in result["words"]], [0, 1, 0, 0, 1])
def test_reused_provider_local_id_never_merges_distinct_global_voices(self):
source = transcript(word(1, 2, 0), word(4, 5, 0))
result = align(source, job(turn(0, 3, "a"), turn(4, 6, "b")))
self.assertEqual([w["speaker"] for w in result["words"]], [0, 1])
def test_all_original_text_order_timestamps_and_other_fields_survive(self):
source = transcript(word(8, 12, None, " Original\t text "), word(7, 9, 3, "Earlier timing"))
source["words"][0]["confidence"] = 0.7
source["words"][1].pop("speaker")
source["metadata"] = {"nested": [1, 2]}
before = copy.deepcopy(source)
result = align(source, job(turn(0, 20)))
self.assertEqual(source, before)
self.assertEqual({k: v for k, v in result.items() if k != "words"},
{k: v for k, v in before.items() if k != "words"})
for original, changed in zip(before["words"], result["words"]):
self.assertEqual({k: v for k, v in original.items() if k != "speaker"},
{k: v for k, v in changed.items() if k != "speaker"})
self.assertEqual([w["speaker"] for w in result["words"]], [0, 0])
def test_ids_follow_first_assigned_nonempty_word_not_untranscribed_timeline_turns(self):
source = transcript(word(5, 6, text=" "), word(8, 9, text="First"), word(4, 4.5, text="Second"))
result = align(source, job(turn(0, 2, "ignored"), turn(3, 6, "later"), turn(7, 10, "first")))
self.assertEqual([w["speaker"] for w in result["words"]], [None, 0, 1])
def test_overlap_aggregates_same_speaker_across_separate_turns(self):
result = align(transcript(word(0, 10)), job(turn(0, 4, "a"), turn(4, 6, "b"), turn(6, 10, "a")))
self.assertEqual(result["words"][0]["speaker"], 0)
def test_dominant_overlap_wins_but_equal_voices_or_low_coverage_stay_unknown(self):
for segments, interval, expected in [
([turn(0, 6, "a"), turn(6, 10, "b")], (0, 10), 0),
([turn(0, 5, "a"), turn(5, 10, "b")], (0, 10), None),
([turn(0, 1, "a")], (0, 10), None),
]:
with self.subTest(segments=segments):
self.assertEqual(align(transcript(word(*interval)), job(*segments))["words"][0]["speaker"], expected)
def test_nearest_is_bounded_for_whole_word_and_not_only_closest_edge(self):
source = transcript(word(1.01, 1.2), word(1.01, 2), word(0, 0.1), word(8, 8.1))
log = io.StringIO()
with contextlib.redirect_stderr(log):
result = diarization.align(source, job(turn(0.2, 1)))
self.assertEqual([w["speaker"] for w in result["words"]], [0, None, 0, None])
self.assertIn("2 word(s) used a nearest", log.getvalue())
self.assertIn("2 word(s) have ambiguous", log.getvalue())
def test_zero_duration_and_nearest_ties_do_not_force_a_speaker(self):
cases = [
(job(turn(0, 1, "a"), turn(1, 2, "b")), 1, None),
(job(turn(0, 1, "a"), turn(1.2, 2, "b")), 1.1, None),
(job(turn(0, 1, "a"), turn(1.2, 2, "a")), 1.1, 0),
(job(turn(0, 1, "a")), 0.5, 0),
(job(turn(0, 1, "a")), 1.3, None),
]
for timeline, timestamp, expected in cases:
with self.subTest(timestamp=timestamp, timeline=timeline):
self.assertEqual(align(transcript(word(timestamp, timestamp)), timeline)["words"][0]["speaker"], expected)
def test_empty_timeline_is_valid_only_without_transcribed_speech(self):
self.assertEqual(align(transcript(), job()), transcript())
with self.assertRaisesRegex(ValueError, "empty speaker timeline"):
align(transcript(word(0, 1, 42)), job())
def test_known_count_warns_without_merging_or_fabricating_speakers(self):
log = io.StringIO()
with contextlib.redirect_stderr(log):
result = diarization.align(transcript(word(0, 1), word(2, 3), word(4, 5)),
job(turn(0, 1, "a"), turn(2, 3, "b"), turn(4, 5, "c")), 2)
self.assertEqual([w["speaker"] for w in result["words"]], [0, 1, 2])
self.assertIn("expected 2 speakers", log.getvalue())
def test_malformed_incomplete_unsorted_or_overlapping_timelines_fail(self):
invalid = [None, {}, {"status": "running"}, dict(job(), status="failed"),
dict(job(), output={}), dict(job(), error="failure"),
dict(job(), output={"error": "failure", "exclusiveDiarization": []}),
job(None), job(turn(-1, 1)), job(turn(2, 1)), job(turn(1, 1)),
job(turn(True, 2)), job(turn(0, float("nan"))), job(turn(0, float("inf"))),
job(turn(0, 1, None)), job(turn(0, 1, " ")), job(turn(0, 1, 0)),
job(turn(0, 3), turn(2, 4)), job(turn(5, 6), turn(1, 2)),
job(turn(0, 1, confidence=0.7)), job(turn(0, 1, confidence={"a": 101})),
job(turn(0, 1, confidence={"a": True})),
job(turn(0, 1, confidence={"a": float("nan")}))]
for timeline in invalid:
with self.subTest(timeline=timeline), self.assertRaises(ValueError):
align(transcript(word(0, 1)), timeline)
align(transcript(word(0, 1)), job(turn(0, 1, confidence={"voice-A": 97.2, "voice-B": 2.8})))
def test_invalid_words_or_known_speaker_counts_fail(self):
for item in (word(-1, 1), word(1, 0), word(False, 1), word(0, float("nan"))):
with self.subTest(item=item), self.assertRaises(ValueError):
align(transcript(item), job(turn(0, 2)))
for count in (0, -1, True, 1.5):
with self.subTest(count=count), self.assertRaises(ValueError):
align(transcript(word(0, 1)), job(turn(0, 2)), count)
def test_cli_output_only_after_complete_validation(self):
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "transcript.json"
result = Path(directory) / "result.json"
source.write_text(json.dumps(transcript(word(0, 1, text="Must survive."))))
result.write_text(json.dumps(job(turn(0, 2))))
command = [sys.executable, "-B", str(Path(diarization.__file__)), "apply", str(source), str(result)]
completed = subprocess.run([*command, "--json"], capture_output=True, text=True)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertEqual(json.loads(completed.stdout)["words"][0]["speaker"], 0)
completed = subprocess.run([*command, "--srt"], capture_output=True, text=True)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertIn("00:00:00,000 --> 00:00:01,000", completed.stdout)
result.write_text('{"status":"succeeded","status":"running"}')
completed = subprocess.run(command, capture_output=True, text=True)
self.assertNotEqual(completed.returncode, 0)
self.assertEqual(completed.stdout, "")
self.assertIn("Duplicate JSON key", completed.stderr)
class FakeTransport:
"""Exercise real Client command construction without executing curl."""
def __init__(self, polls=None, failure=None):
self.polls = list(polls or [job(turn(0, 10, confidence={"voice-A": 99}))])
self.failure = failure
self.calls = []
self.submissions = []
self.uploads = []
self.authenticated = []
def __call__(self, command, **kwargs):
self.calls.append(command)
if command[0] == "ffmpeg":
Path(command[-1]).write_bytes(b"fLaC fake whole recording")
return subprocess.CompletedProcess(command, 0, stderr=b"")
output = Path(command[command.index("-o") + 1])
method = command[command.index("--request") + 1]
url = command[-1]
if method != "PUT":
header = Path(command[command.index("--header") + 1][1:])
assert header.stat().st_mode & 0o777 == 0o600
self.authenticated.append(header.read_text())
if self.failure and self.failure[0] in url:
status, body, code = self.failure[1:]
elif url.endswith("/media/input"):
self.media_request = json.loads(Path(command[command.index("--data-binary") + 1][1:]).read_text())
status, body, code = 200, {"url": "https://storage.example/file?secret=signature"}, 0
elif method == "PUT":
self.uploads.append(Path(command[command.index("--upload-file") + 1]).read_bytes())
status, body, code = 200, "", 0
elif url.endswith("/diarize"):
self.submissions.append(json.loads(Path(command[command.index("--data-binary") + 1][1:]).read_text()))
status, body, code = 200, {"jobId": "test-job", "status": "created"}, 0
else:
item = self.polls.pop(0)
status, body, code = item if isinstance(item, tuple) else (200, item, 0)
output.write_text(body if isinstance(body, str) else json.dumps(body))
return subprocess.CompletedProcess(command, code, stdout=str(status).encode(),
stderr=b"sensitive https://storage.example/file?secret=signature")
class RunTests(unittest.TestCase):
def execute(self, fake, directory, **kwargs):
audio = write_audio(directory)
with patch.dict(os.environ, {"PYANNOTE_API_KEY": "test-secret-key"}), \
patch.object(diarization.subprocess, "run", side_effect=fake), \
patch.object(diarization.time, "sleep"), contextlib.redirect_stderr(io.StringIO()) as log:
result = diarization.run(audio, Path(directory) / "work", **kwargs)
return result, log.getvalue()
def test_complete_flac_upload_exact_model_flags_and_private_request_cleanup(self):
fake = FakeTransport()
with tempfile.TemporaryDirectory() as directory:
result, _ = self.execute(fake, directory, speakers=2)
work = Path(directory) / "work"
self.assertEqual(json.loads((work / "result.json").read_text()), result)
self.assertEqual([p.name for p in work.iterdir()], ["result.json"])
self.assertEqual(fake.uploads, [b"fLaC fake whole recording"])
self.assertEqual(fake.submissions, [dict(url=fake.media_request["url"], model="precision-2",
exclusive=True, turnLevelConfidence=True, transcription=False, numSpeakers=2)])
self.assertRegex(fake.media_request["url"], r"^media://[a-f0-9-]+\.flac$")
encoding = fake.calls[0]
self.assertNotIn("-ss", encoding)
self.assertNotIn("-t", encoding)
self.assertIn("flac", encoding)
for command in fake.calls[1:]:
self.assertIn("--max-time", command)
self.assertIn("--connect-timeout", command)
self.assertIn("--proto", command)
self.assertNotIn("--location", command)
self.assertNotIn("test-secret-key", " ".join(command))
self.assertEqual(any(value.startswith("@") for value in command
if "authorization.header" in value),
command[command.index("--request") + 1] != "PUT")
self.assertEqual(fake.authenticated, ["Authorization: Bearer test-secret-key\n"] * 3)
def test_hosted_community_override_is_sent_unchanged(self):
fake = FakeTransport()
with tempfile.TemporaryDirectory() as directory:
self.execute(fake, directory, model="community-1")
self.assertEqual(fake.submissions[0]["model"], "community-1")
def test_saved_result_keeps_timelines_and_confidence_without_request_or_url_metadata(self):
original = job(turn(0, 10, confidence={"voice-A": 91}, url="https://secret/segment"),
request={"key": "test-secret-key"}, url="https://secret/job")
original["output"].update(diarization=[turn(2, 9, "b"), turn(0, 10, "a")],
warning="constraints adjusted https://secret/warning", url="https://secret/output")
fake = FakeTransport(polls=[original])
with tempfile.TemporaryDirectory() as directory:
result, log = self.execute(fake, directory)
saved = (Path(directory) / "work" / "result.json").read_text()
self.assertEqual(set(result), {"jobId", "status", "output"})
self.assertEqual(set(result["output"]), {"diarization", "exclusiveDiarization"})
self.assertEqual(result["output"]["diarization"], original["output"]["diarization"])
self.assertEqual(result["output"]["exclusiveDiarization"][0]["confidence"], {"voice-A": 91})
for value in ("https://", "test-secret-key", "request", "warning"):
self.assertNotIn(value, saved)
self.assertIn("constraints adjusted", log)
self.assertNotIn("https://", log)
def test_automatic_count_is_omitted_and_created_pending_running_are_polled(self):
fake = FakeTransport(polls=[{"status": status} for status in ("created", "pending", "running")]
+ [job(turn(0, 10))])
with tempfile.TemporaryDirectory() as directory:
_, log = self.execute(fake, directory)
self.assertNotIn("numSpeakers", fake.submissions[0])
self.assertEqual(len(fake.submissions), 1)
self.assertEqual(sum("GET" in call for call in fake.calls), 4)
self.assertIn("status: created", log)
self.assertIn("status: pending", log)
self.assertIn("status: running", log)
def test_transient_get_http_and_transport_errors_repoll_same_job(self):
fake = FakeTransport(polls=[(503, {"error": "try again"}, 0), (0, "", 28), job(turn(0, 10))])
with tempfile.TemporaryDirectory() as directory:
_, log = self.execute(fake, directory)
urls = [call[-1] for call in fake.calls if "GET" in call]
self.assertEqual(urls, ["https://api.pyannote.ai/v1/jobs/test-job"] * 3)
self.assertEqual(len(fake.submissions), 1)
self.assertEqual(len(fake.uploads), 1)
self.assertIn("retrying the same job", log)
self.assertNotIn("secret=signature", log)
def test_post_and_upload_failures_are_never_retried_and_leave_no_result(self):
for endpoint in ("/media/input", "storage.example", "/diarize"):
fake = FakeTransport(failure=(endpoint, 503, {"error": "provider unavailable"}, 0))
with self.subTest(endpoint=endpoint), tempfile.TemporaryDirectory() as directory:
with self.assertRaisesRegex(ValueError, "HTTP 503"):
self.execute(fake, directory)
self.assertEqual(list((Path(directory) / "work").iterdir()), [])
self.assertEqual(sum(endpoint in call[-1] for call in fake.calls), 1)
def test_terminal_and_malformed_poll_failures_never_yield_partial_result(self):
failures = [dict(job(), status="failed"), dict(job(), status="canceled"),
{"status": "unexpected"}, {"status": "succeeded", "output": {}},
dict(job(turn(0, 10)), jobId="wrong-job"), job(turn(0, 31)),
(401, {"error": "bad key"}, 0), (200, "not JSON", 0)]
for response in failures:
with self.subTest(response=response), tempfile.TemporaryDirectory() as directory:
with self.assertRaises(ValueError):
self.execute(FakeTransport(polls=[response]), directory)
self.assertEqual(list((Path(directory) / "work").iterdir()), [])
def test_error_details_redact_key_and_urls(self):
fake = FakeTransport(failure=("/media/input", 401, {"error": {
"message": "invalid test-secret-key Bearer abc https://storage.example/?token=secret"}}, 0))
with tempfile.TemporaryDirectory() as directory, self.assertRaises(ValueError) as caught:
self.execute(fake, directory)
self.assertIn("invalid", str(caught.exception))
for secret in ("test-secret-key", "abc", "https://", "token=secret"):
self.assertNotIn(secret, str(caught.exception))
def test_missing_key_or_invalid_model_timeout_and_count_prevent_network(self):
with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {}, clear=True), \
patch.object(diarization.subprocess, "run") as provider:
with self.assertRaisesRegex(ValueError, "PYANNOTE_API_KEY"):
diarization.run(write_audio(directory), directory)
provider.assert_not_called()
for kwargs in ({"model": "google/gemini"}, {"timeout": 0}, {"timeout": float("inf")},
{"timeout": True}, {"speakers": 0}):
with self.subTest(kwargs=kwargs), tempfile.TemporaryDirectory() as directory:
fake = FakeTransport()
with self.assertRaises(ValueError):
self.execute(fake, directory, **kwargs)
self.assertEqual(fake.calls, [])
def test_overall_deadline_is_bounded_even_while_waiting(self):
with tempfile.TemporaryDirectory() as directory, patch.object(diarization.time, "monotonic", return_value=10):
client = diarization.Client(directory, "key", 9)
with self.assertRaisesRegex(ValueError, "timed out"):
client.request("GET", "https://api.pyannote.ai/v1/jobs/one")
with tempfile.TemporaryDirectory() as directory, patch.object(diarization.time, "monotonic", return_value=10), \
patch.object(diarization.time, "sleep") as sleep:
diarization.Client(directory, "key", 10.5).pause()
sleep.assert_called_once_with(0.5)
def test_pending_job_times_out_without_restarting_upload_or_submission(self):
fake = FakeTransport(polls=[{"status": "running"}] * 10)
clock = [0]
with tempfile.TemporaryDirectory() as directory, \
patch.dict(os.environ, {"PYANNOTE_API_KEY": "test-secret-key"}), \
patch.object(diarization.subprocess, "run", side_effect=fake), \
patch.object(diarization.time, "monotonic", side_effect=lambda: clock[0]), \
patch.object(diarization.time, "sleep", side_effect=lambda seconds: clock.__setitem__(0, clock[0] + seconds)), \
contextlib.redirect_stderr(io.StringIO()), self.assertRaisesRegex(ValueError, "timed out"):
diarization.run(write_audio(directory), Path(directory) / "work", timeout=5)
self.assertEqual(len(fake.submissions), 1)
self.assertEqual(len(fake.uploads), 1)
self.assertEqual(sum("GET" in call for call in fake.calls), 3)
def test_upload_size_limit_prevents_any_http_request(self):
fake = FakeTransport()
with tempfile.TemporaryDirectory() as directory, patch.object(diarization, "MAX_UPLOAD_BYTES", 2), \
self.assertRaisesRegex(ValueError, "1 GiB"):
self.execute(fake, directory)
self.assertEqual(len(fake.calls), 1)
def test_invalid_upload_url_never_receives_audio_or_bearer(self):
for url in ("http://storage.example/upload", "file:///tmp/upload", "https://key@storage.example/upload"):
with self.subTest(url=url), tempfile.TemporaryDirectory() as directory, \
patch.object(diarization.subprocess, "run") as transport, self.assertRaises(ValueError):
diarization.Client(directory, "key", float("inf")).request("PUT", url, authenticated=False)
transport.assert_not_called()
if __name__ == "__main__":
unittest.main()