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.
This commit is contained in:
1 parent
d7827d39f5
commit
3042b7743b
7 files changed
+1336
-864
No files matched your search
+139
-67
@@ -9,20 +9,37 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
log_path = Path(os.environ["TRANSCRIBE_TEST_LOG"])
|
||||
if Path(sys.argv[0]).name == "op":
|
||||
print("test-api-key")
|
||||
with log_path.with_suffix(".credentials.jsonl").open("a") as log:
|
||||
log.write(json.dumps({"args": sys.argv[1:]}) + "\n")
|
||||
if os.environ.get("TRANSCRIBE_TEST_OP_FAIL"):
|
||||
sys.exit(1)
|
||||
print("test-op-api-key")
|
||||
sys.exit(0)
|
||||
|
||||
args = sys.argv[1:]
|
||||
request_path = Path(args[args.index("--data-binary") + 1][1:])
|
||||
request = json.loads(request_path.read_text())
|
||||
output = Path(args[args.index("-o") + 1])
|
||||
url = next(value for value in args if value.startswith("https://"))
|
||||
headers = {}
|
||||
for position, value in enumerate(args):
|
||||
if value in ("-H", "--header"):
|
||||
value = args[position + 1]
|
||||
header_lines = Path(value[1:]).read_text().splitlines() if value.startswith("@") else [value]
|
||||
for header_line in header_lines:
|
||||
key, _, header_value = header_line.partition(":")
|
||||
headers[key.lower()] = header_value.strip()
|
||||
request = None
|
||||
request_path = None
|
||||
if "--data-binary" in args:
|
||||
request_path = Path(args[args.index("--data-binary") + 1][1:])
|
||||
request = json.loads(request_path.read_text())
|
||||
mode = os.environ.get("TRANSCRIBE_TEST_MODE", "ok")
|
||||
events_path = Path(os.environ["TRANSCRIBE_TEST_LOG"]).with_suffix(".events.jsonl")
|
||||
is_comparison = "https://openrouter.ai/api/v1/chat/completions" in args
|
||||
index = -1 if is_comparison else int(output.stem.split("-")[1])
|
||||
staging = output.parent.parent if is_comparison else output.parent
|
||||
events_path = log_path.with_suffix(".events.jsonl")
|
||||
is_pyannote = "openrouter.ai" not in url
|
||||
index = -1 if is_pyannote else int(output.stem.split("-")[1])
|
||||
staging = next(parent for parent in (output.parent, *output.parents)
|
||||
if (parent / "chunks.json").is_file())
|
||||
|
||||
|
||||
def event(kind, **extra):
|
||||
@@ -32,68 +49,121 @@ def event(kind, **extra):
|
||||
log.write(json.dumps(record) + "\n")
|
||||
|
||||
|
||||
def reply(value, status=0, http_status=200):
|
||||
if "--write-out" in args:
|
||||
print(str(http_status), end="")
|
||||
output.write_text(json.dumps(value))
|
||||
event("finish")
|
||||
sys.exit(status)
|
||||
|
||||
|
||||
event("start")
|
||||
if is_comparison:
|
||||
samples = json.loads((output.parent / "samples.json").read_text())
|
||||
manifest = json.loads((staging / "chunks.json").read_text())
|
||||
late_start = round(manifest[0]["end"]) if len(manifest) > 1 else 60
|
||||
content = request["messages"][-1]["content"]
|
||||
audio_parts = [part["input_audio"] for part in content if part["type"] == "input_audio"]
|
||||
assert len(content) == 2 * len(samples) == 2 * len(audio_parts)
|
||||
for number, sample in enumerate(samples):
|
||||
assert content[number * 2] == {"type": "text", "text": f"Sample ID {sample['id']}"}
|
||||
assert content[number * 2 + 1]["type"] == "input_audio"
|
||||
assert request["response_format"]["type"] == "json_schema"
|
||||
assert request["response_format"]["json_schema"]["strict"] is True
|
||||
prompt = "\n".join(part["text"] for message in request["messages"]
|
||||
for part in (message["content"] if isinstance(message["content"], list)
|
||||
else [{"type": "text", "text": message["content"]}])
|
||||
if part["type"] == "text")
|
||||
assert not any(word in prompt for word in ("Alpha.", "Again.", "Beta.", "AlphaLate.", "BetaLate."))
|
||||
durations = []
|
||||
for number, part in enumerate(audio_parts):
|
||||
assert part["format"] == "mp3"
|
||||
audio = output.parent / f"checked-{number}.mp3"
|
||||
audio.write_bytes(base64.b64decode(part["data"], validate=True))
|
||||
if is_pyannote:
|
||||
pyannote_log = log_path.with_suffix(".pyannote.jsonl")
|
||||
previous = [json.loads(line) for line in pyannote_log.read_text().splitlines()] if pyannote_log.exists() else []
|
||||
record = {"url": url, "request": request, "headers": headers, "staging": str(staging)}
|
||||
if url == "https://api.pyannote.ai/v1/media/input":
|
||||
stage = "media"
|
||||
assert request["url"].startswith("media://") and request["url"].endswith(".flac")
|
||||
response = {"url": "https://storage.test.invalid/upload?signature=fixture-signed-url"}
|
||||
elif url.startswith("https://storage.test.invalid/"):
|
||||
stage = "upload"
|
||||
assert not headers.get("authorization"), "API bearer leaked to signed storage URL"
|
||||
upload = Path(args[args.index("--upload-file") + 1])
|
||||
info = json.loads(subprocess.check_output([
|
||||
"ffprobe", "-v", "error", "-show_entries",
|
||||
"format=duration:stream=codec_name,sample_rate,channels", "-of", "json", str(audio),
|
||||
"format=duration:stream=codec_name,sample_rate,channels", "-of", "json", str(upload),
|
||||
], text=True))
|
||||
assert info["streams"][0] == {"codec_name": "mp3", "sample_rate": "16000", "channels": 1}
|
||||
durations.append(float(info["format"]["duration"]))
|
||||
assert 1.5 <= durations[-1] <= 8.1
|
||||
Path(os.environ["TRANSCRIBE_TEST_LOG"]).with_suffix(".speakers.json").write_text(json.dumps({
|
||||
"request": request, "samples": samples, "durations": durations, "staging": str(staging),
|
||||
}))
|
||||
if mode == "reconcile-blocked":
|
||||
assert info["streams"][0] == {"codec_name": "flac", "sample_rate": "16000", "channels": 1}
|
||||
record["upload_duration"] = float(info["format"]["duration"])
|
||||
record["stream"] = info["streams"][0]
|
||||
response = {}
|
||||
elif url == "https://api.pyannote.ai/v1/diarize":
|
||||
stage = "submit"
|
||||
assert request["model"] in ("precision-2", "community-1")
|
||||
assert request["url"] == next(item["request"]["url"] for item in previous if item["stage"] == "media")
|
||||
assert request["exclusive"] is True
|
||||
assert request["transcription"] is False
|
||||
response = {"jobId": "test-job-42"}
|
||||
elif url == "https://api.pyannote.ai/v1/jobs/test-job-42":
|
||||
stage = "poll"
|
||||
duration = next(item["upload_duration"] for item in previous if item["stage"] == "upload")
|
||||
manifest = json.loads((staging / "chunks.json").read_text())
|
||||
if mode == "silence":
|
||||
turns = []
|
||||
elif mode.startswith("reconcile"):
|
||||
late_start = round(manifest[0]["end"]) if len(manifest) > 1 else 60
|
||||
turns = [(0, min(duration, 10), "voice_a"), (10, min(duration, 16), "voice_b"),
|
||||
(late_start, min(duration, late_start + 3), "voice_a"),
|
||||
(late_start + 4, duration, "voice_b")]
|
||||
elif mode in ("speakers", "partial-speakers"):
|
||||
boundary = 1.45 if len(manifest) == 1 else 57.3
|
||||
turns = [(0, boundary, "voice_a"), (boundary, duration, "voice_b")]
|
||||
else:
|
||||
turns = [(0, duration, "voice_a")]
|
||||
timeline = [{"start": start, "end": end, "speaker": speaker}
|
||||
for start, end, speaker in turns if start < end]
|
||||
# Standard diarization intentionally disagrees: clients must select the
|
||||
# exclusive timeline requested for word-level speaker attribution.
|
||||
response = {"jobId": "test-job-42", "status": "succeeded", "output": {
|
||||
"exclusiveDiarization": timeline,
|
||||
"diarization": [{"start": 0, "end": duration, "speaker": "wrong_timeline"}],
|
||||
}}
|
||||
if mode == "reconcile-diagnostics-metadata":
|
||||
response["request"] = {"url": "media://fixture-recording.flac", "key": "test-pyannote-key"}
|
||||
response["output"]["debug"] = "https://storage.test.invalid/?signature=fixture-signed-url"
|
||||
response["output"]["exclusiveDiarization"][0]["request"] = "test-openrouter-key"
|
||||
response["output"]["exclusiveDiarization"][0]["confidence"] = {"voice_a": 99.0}
|
||||
response["output"]["diarization"][0]["debug"] = "fixture-signed-url"
|
||||
poll_count = sum(item["stage"] == "poll" for item in previous)
|
||||
if mode == "pyannote-lifecycle" and poll_count < 2:
|
||||
response = {"jobId": "test-job-42", "status": ("created", "running")[poll_count]}
|
||||
elif mode in ("pyannote-failed", "pyannote-canceled"):
|
||||
response = {"jobId": "test-job-42", "status": mode.removeprefix("pyannote-"),
|
||||
"error": "simulated diarization failure"}
|
||||
elif mode == "pyannote-missing-timeline":
|
||||
response["output"].pop("exclusiveDiarization")
|
||||
elif mode == "pyannote-malformed-timeline":
|
||||
response["output"]["exclusiveDiarization"][0]["end"] = "not a timestamp"
|
||||
elif mode == "pyannote-empty-timeline":
|
||||
response["output"]["exclusiveDiarization"] = []
|
||||
elif mode == "pyannote-wrong-job":
|
||||
response["jobId"] = "different-job"
|
||||
elif mode == "pyannote-output-error":
|
||||
response["output"]["error"] = "simulated incomplete diarization output"
|
||||
elif mode == "pyannote-partial-timeline":
|
||||
response["output"]["exclusiveDiarization"] = [{"start": 0, "end": 2, "speaker": "voice_a"}]
|
||||
else:
|
||||
raise AssertionError(f"Unexpected curl URL: {url}")
|
||||
assert args[args.index("--request") + 1] == {
|
||||
"media": "POST", "upload": "PUT", "submit": "POST", "poll": "GET",
|
||||
}[stage]
|
||||
if stage != "upload":
|
||||
assert headers.get("authorization") in ("Bearer test-pyannote-key", "Bearer test-op-api-key")
|
||||
record["stage"] = stage
|
||||
record["response"] = response
|
||||
with pyannote_log.open("a") as log:
|
||||
log.write(json.dumps(record) + "\n")
|
||||
if mode == f"pyannote-{stage}-http-error":
|
||||
reply({"error": f"simulated {stage} failure"}, http_status=401)
|
||||
if stage == "poll" and mode == "pyannote-transient-poll" and poll_count == 0:
|
||||
reply({"error": "simulated transient poll failure"}, status=22)
|
||||
if stage == "upload" and mode == "pyannote-transport-error":
|
||||
reply({"error": "simulated transport failure"}, status=22)
|
||||
if stage == "poll" and mode == "pyannote-bad-json":
|
||||
if "--write-out" in args:
|
||||
print("200", end="")
|
||||
output.write_text("not json")
|
||||
event("finish")
|
||||
sys.exit(0)
|
||||
if stage == "poll" and mode == "pyannote-blocked":
|
||||
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
|
||||
event("child", child_pid=child.pid)
|
||||
time.sleep(30)
|
||||
if mode == "reconcile-http-error":
|
||||
output.write_text('{"error":{"message":"simulated comparison failure"}}')
|
||||
sys.exit(22)
|
||||
if mode == "reconcile-bad-json":
|
||||
output.write_text("not json")
|
||||
sys.exit(0)
|
||||
assignments = [{"sample": sample["id"], "speaker": 0 if round(sample["start"]) in (1, 6, late_start) else 1}
|
||||
for sample in samples]
|
||||
if mode == "reconcile-missing":
|
||||
assignments.pop()
|
||||
elif mode == "reconcile-duplicate":
|
||||
assignments.append(assignments[0])
|
||||
elif mode == "reconcile-unknown-sample":
|
||||
assignments[0]["sample"] = "not-a-sample"
|
||||
elif mode == "reconcile-invalid-speaker":
|
||||
assignments[0]["speaker"] = -1
|
||||
elif mode == "reconcile-null":
|
||||
assignments[0]["speaker"] = None
|
||||
output.write_text(json.dumps({"choices": [{
|
||||
"finish_reason": "length" if mode == "reconcile-truncated" else "stop",
|
||||
"message": {"content": json.dumps({"assignments": assignments})},
|
||||
}]}))
|
||||
event("finish")
|
||||
sys.exit(0)
|
||||
reply(response)
|
||||
|
||||
assert url == "https://openrouter.ai/api/v1/audio/transcriptions"
|
||||
assert headers.get("authorization") in ("Bearer test-openrouter-key", "Bearer test-op-api-key")
|
||||
manifest = json.loads((output.parent / "chunks.json").read_text())
|
||||
chunk = manifest[index]
|
||||
if mode in ("parallel-error", "blocked") and index == 0:
|
||||
@@ -121,6 +191,7 @@ with open(os.environ["TRANSCRIBE_TEST_LOG"], "a") as log:
|
||||
"provider": request.get("provider"),
|
||||
"response_format": request.get("response_format"),
|
||||
"timestamp_granularities": request.get("timestamp_granularities"),
|
||||
"authorization": headers.get("authorization"),
|
||||
}) + "\n")
|
||||
|
||||
if mode in ("parallel", "parallel-error"):
|
||||
@@ -173,11 +244,12 @@ else:
|
||||
{"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):
|
||||
if mode == "speakers" or speaker == 0:
|
||||
word["speaker"] = (speaker if len(manifest) == 1 else
|
||||
int(word["start"] + chunk["start"] >= 57.3))
|
||||
for speaker, word in enumerate(words):
|
||||
if mode == "speakers" or speaker == 0:
|
||||
word["speaker"] = (speaker if len(manifest) == 1 else
|
||||
int(word["start"] + chunk["start"] >= 57.3))
|
||||
if mode == "silence":
|
||||
words = []
|
||||
if mode == "parallel":
|
||||
middle = (chunk["end"] - chunk["start"]) / 2
|
||||
words = [{"word": f"Part{index}.", "start": middle, "end": middle + 0.4}]
|
||||
|
||||
Reference in new issue
Block a user