Files
transcribe/test_transcribe.py
T
ddidderr 38894c2549 fix: format text transcripts and show speaker markers
Chunked text output previously joined every word into one line and discarded
speaker labels. Add a shared plain-text formatter that wraps lines, breaks
paragraphs at speaker changes and pauses, and splits long turns while
preserving word order. Display provider speaker IDs with chunk scope where
needed, without implying that IDs identify the same person across requests.

Request structured output for short diarized recordings too, and warn when
speaker labels are missing or incomplete. Keep SRT formatting separate and
preserve existing line breaks for short requests without diarization.

Test Plan:
- python3 -B -m unittest -q: all 43 tests passed.
- bash -n transcribe and git diff --cached --check: passed.
- Reflowed the existing transcript locally and verified all 15,888 words
  were preserved; source audio and generated transcripts are not committed.
- A live excerpt check was blocked before upload by unavailable 1Password
  access; actual provider diarization remains unverified.
2026-09-12 09:26:21 +02:00

204 lines
9.8 KiB
Python

import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from test_chunks import make_audio
ROOT = Path(__file__).resolve().parent
TOOLS = ("bash", "ffmpeg", "ffprobe", "jq", "base64", "mktemp", "realpath")
@unittest.skipUnless(all(shutil.which(tool) for tool in TOOLS), "CLI media dependencies missing")
class TranscribeTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.path = Path(self.directory.name)
self.bin = self.path / "bin"
self.bin.mkdir()
for name in ("op", "curl"):
fake = self.bin / name
shutil.copyfile(ROOT / "tests" / "fake_provider.py", fake)
fake.chmod(0o755)
self.log = self.path / "requests.jsonl"
self.env = dict(os.environ, PATH=f"{self.bin}{os.pathsep}{os.environ['PATH']}",
TRANSCRIBE_TEST_LOG=str(self.log), TRANSCRIBE_TEST_MODE="ok")
self.audio = self.path / "input with spaces.wav"
def run_cli(self, *args, mode="ok", duration=70):
make_audio(self.audio, duration)
return subprocess.run([str(ROOT / "transcribe"), *args, str(self.audio)],
env=dict(self.env, TRANSCRIBE_TEST_MODE=mode),
capture_output=True, text=True, timeout=30)
def requests(self):
return [json.loads(line) for line in self.log.read_text().splitlines()]
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)
self.assertEqual(result.stdout, "Hello.\n\nboundary continues.\n\nLast.\n")
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):
result = self.run_cli("--srt", "--chunk-minutes", "1")
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: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_without_diarization_retains_provider_line_breaks(self):
result = self.run_cli("--no-diarization", duration=5)
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(False)
self.assert_cleaned()
def test_short_text_requests_and_prints_speaker_labels(self):
result = self.run_cli(mode="speakers", duration=5)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "[Speaker 0]\nHello\n\n[Speaker 1]\nthere\n")
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.assert_cleaned()
def test_chunked_text_prints_speaker_ids_with_request_scope(self):
result = self.run_cli("--chunk-minutes", "1", mode="speakers")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, (
"[Chunk 1, Speaker 0]\nHello.\n\n"
"[Chunk 1, Speaker 0]\nboundary\n\n"
"[Chunk 2, Speaker 1]\ncontinues.\n\n"
"[Chunk 2, Speaker 1]\nLast.\n"
))
self.assertNotIn("Warning", result.stderr)
self.assert_cleaned()
def test_missing_speaker_labels_warn_instead_of_implying_detection_worked(self):
for output in ((), ("--srt",)):
with self.subTest(output=output):
result = self.run_cli(*output, duration=5)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("returned no speaker labels", result.stderr)
self.assertNotIn("[Speaker", result.stdout)
result = self.run_cli(mode="partial-speakers", duration=5)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("some words without speaker labels", result.stderr)
self.assertEqual(result.stdout, "[Speaker 0]\nHello\n\n[Speaker unknown]\nthere\n")
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"):
with self.subTest(mode=mode):
result = self.run_cli("--chunk-minutes", "1", mode=mode)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(result.stdout, "")
self.assertTrue(result.stderr)
self.assert_cleaned()
def test_invalid_limits_fail_before_provider_access(self):
for value in ("0", "31", "abc", "1.5", "-1"):
with self.subTest(value=value):
result = self.run_cli("--chunk-minutes", value, duration=1)
self.assertNotEqual(result.returncode, 0)
self.assertIn("integer between 1 and 30", result.stderr)
self.assertFalse(self.log.exists())
if __name__ == "__main__":
unittest.main()