diff --git a/AGENTS.md b/AGENTS.md index 7f754df..6443424 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,8 @@ - `test_*.py` and `tests/fake_provider.py`: local regression tests without real credentials or provider requests. -Keep the CLI and its three Python helpers together. Preserve transcript order, -scope speaker IDs to their request, and emit stdout only after all chunks and -output conversion succeed. Progress and errors belong on stderr. +Keep the CLI and its three Python helpers together. Preserve transcript order +and emit stdout only after all chunks and output conversion succeed. Provider +speaker IDs are request-local; reuse transcript IDs across requests only with +supporting overlap evidence. Keep chunk details out of the final transcript. +Progress, processing details, and errors belong on stderr. diff --git a/README.md b/README.md index 988f0bf..4bffccd 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ without punctuation. The formatter preserves the words and their order. Unlabelled passages use `[Speaker unknown]` when other words have labels; a response without any labels is printed without speaker markers. A warning on stderr reports missing or incomplete labels when diarization was requested. -Speaker IDs come from the provider; they are not names or inferred from text. +Speaker labels use the provider's diarization; they are not names inferred from text. SRT uses labels to break cues at speaker changes but does not print the IDs. Long recordings are automatically split into uploads of at most **10 minutes** @@ -76,11 +76,15 @@ timestamps can still require corrections. Missing or invalid word timestamps are an error. Plain text for a chunked recording is assembled from the words. SRT timestamps are shifted back to the original recording's timeline before -cue generation, with continuous cue numbering. Speaker labels, when returned, -are local to each request; text marks them as `[Chunk 1, Speaker 0]`, -`[Chunk 2, Speaker 0]`, etc. The same ID in different chunks does **not** establish -that it is the same person. The script does not identify speakers across -chunks. Short recordings still use one request and use `[Speaker 0]` markers. +cue generation, with continuous cue numbering. Text uses `[Speaker 0]`, +`[Speaker 1]`, etc., without exposing upload boundaries to the reader. +For a chunked recording, provider IDs are mapped to transcript-wide IDs: +at least two unambiguous words shared at matching times in the overlap must +agree on the speaker correspondence. Conflicting or absent evidence gets a +new ID and a warning on stderr. This is not voice recognition across the whole +recording: the same person can receive another ID when absent from an overlap +or when diarization disagrees. Numeric provider IDs alone are never used to +identify a speaker across requests. Audio is first decoded to temporary mono 16 kHz PCM (about 115 MB per hour), so all cuts share the same sample clock even when container duration metadata diff --git a/chunks.py b/chunks.py index 8e177ee..323ee02 100644 --- a/chunks.py +++ b/chunks.py @@ -74,13 +74,8 @@ def token(word): return "".join(c for c in word["word"].casefold() if c.isalnum()) -def stitch(left, right, seam): - """Switch transcripts at a shared word, away from either audio cut. - - Match text AND time to avoid deleting intentional repetitions. Prefer an - anchor with a matching neighbour; accept a single shared word when speech - is sparse. Timestamp ownership is the fallback if hypotheses disagree. - """ +def overlap_matches(left, right, seam): + """Find matching words at matching times in the shared audio.""" radius = CONTEXT - 0.5 left_near = [(i, word, token(word)) for i, word in enumerate(left) if abs(midpoint(word) - seam) <= radius] @@ -92,6 +87,17 @@ def stitch(left, right, seam): difference = abs(midpoint(a) - midpoint(b)) if a_token and a_token == b_token and difference <= 1.0: matches[i, j] = (difference, abs((midpoint(a) + midpoint(b)) / 2 - seam)) + return matches, bool(left_near or right_near) + + +def stitch(left, right, seam): + """Switch transcripts at a shared word, away from either audio cut. + + Match text AND time to avoid deleting intentional repetitions. Prefer an + anchor with a matching neighbour; accept a single shared word when speech + is sparse. Timestamp ownership is the fallback if hypotheses disagree. + """ + matches, speech_near = overlap_matches(left, right, seam) if matches: def score(pair): i, j = pair @@ -108,7 +114,7 @@ def stitch(left, right, seam): i, j = min(matches, key=score) return left[:i + 1] + right[j + 1:] - if left_near or right_near: + if speech_near: print( f"Warning: no shared words near {seam:.2f}s; joining by timestamps. " "Review the transcript at this boundary.", file=sys.stderr, @@ -117,10 +123,38 @@ def stitch(left, right, seam): [word for word in right if midpoint(word) >= seam]) +def speaker_mapping(left, right, seam): + """Reuse a speaker ID only with unambiguous shared-word evidence. + + Require two distinct word pairs, with no competing speaker correspondence + in either direction. Numeric provider IDs alone carry no identity evidence. + """ + matches, _ = overlap_matches(left, right, seam) + left_words, right_words = {}, {} + candidates, reverse = {}, {} + for i, j in matches: + left_words.setdefault(i, set()).add(j) + right_words.setdefault(j, set()).add(i) + old, new = left[i].get("speaker"), right[j].get("speaker") + if old is not None and new is not None: + candidates.setdefault(new, set()).add(old) + reverse.setdefault(old, set()).add(new) + support = {} + for i, j in matches: + old, new = left[i].get("speaker"), right[j].get("speaker") + if (old is not None and new is not None and + len(left_words[i]) == len(right_words[j]) == 1): + support[new, old] = support.get((new, old), 0) + 1 + return {new: old for (new, old), count in support.items() + if count >= 2 and len(candidates[new]) == len(reverse[old]) == 1} + + def merge(chunks, responses): if len(chunks) != len(responses) or not chunks: raise ValueError("Expected one transcription response per chunk") merged = [] + previous = [] + next_speaker = 0 for index, (chunk, response) in enumerate(zip(chunks, responses)): try: words = normalized_words(response, "Chunk stitching") @@ -129,12 +163,32 @@ def merge(chunks, responses): raise ValueError("Word timestamp exceeds the chunk duration") word["start"] += chunk["start"] word["end"] += chunk["start"] - if word.get("speaker") is not None: - # Speaker IDs belong to individual requests, not the file. - word["speaker"] = (index, word["speaker"]) + mapping = speaker_mapping(previous, words, chunk["seam"]) + unmatched = False + for word in words: + speaker = word.get("speaker") + if speaker is not None: + if speaker not in mapping: + mapping[speaker] = next_speaker + next_speaker += 1 + unmatched = True + word["speaker"] = mapping[speaker] + if index and unmatched: + print( + f"Warning: speaker continuity is uncertain near {chunk['seam']:.2f}s; " + "unmatched speakers received new transcript IDs.", file=sys.stderr, + ) except ValueError as error: raise ValueError(f"Chunk {index + 1}: {error}") from error merged = stitch(merged, words, chunk["seam"]) if index else words + previous = words + # Overlap trimming may discard a speaker's only words. Number only the + # speakers present in the final transcript, in order of first appearance. + visible_speakers = {} + for word in merged: + speaker = word.get("speaker") + if speaker is not None: + word["speaker"] = visible_speakers.setdefault(speaker, len(visible_speakers)) return {"text": " ".join(word["word"] for word in merged), "words": merged} diff --git a/test_chunks.py b/test_chunks.py index 2c22b45..1aebfb2 100644 --- a/test_chunks.py +++ b/test_chunks.py @@ -6,7 +6,7 @@ import tempfile import unittest import wave -from chunks import CONTEXT, ENCODING_MARGIN, merge, plan, stitch +from chunks import CONTEXT, ENCODING_MARGIN, merge, plan, speaker_mapping, stitch from subtitles import convert @@ -73,6 +73,45 @@ class ChunkPlanTests(unittest.TestCase): class ChunkMergeTests(unittest.TestCase): + def test_speaker_mapping_uses_shared_words_even_when_provider_ids_swap(self): + left = [dict(word(text, start), speaker=speaker) for text, start, speaker in ( + ("one", 58, 0), ("two", 58.5, 0), ("three", 60, 1), ("four", 60.5, 1), + )] + right = [dict(w, speaker=1 - w["speaker"]) for w in left] + self.assertEqual(speaker_mapping(left, right, 60), {1: 0, 0: 1}) + + def test_speaker_mapping_rejects_missing_sparse_and_conflicting_evidence(self): + left = [dict(word(text, start), speaker=0) for text, start in ( + ("one", 58), ("two", 58.5), ("three", 60), ("four", 60.5), + )] + self.assertEqual(speaker_mapping(left, [dict(left[0], speaker=5)], 60), {}) + self.assertEqual(speaker_mapping(left, [dict(word("elsewhere", 90), speaker=0)], 60), {}) + conflicting = [dict(w, speaker=i // 2) for i, w in enumerate(left)] + self.assertEqual(speaker_mapping(left, conflicting, 60), {}) + self.assertEqual(speaker_mapping(conflicting, left, 60), {}) + repeated = [dict(word("yes", 60), speaker=0)] + self.assertEqual(speaker_mapping(repeated, repeated * 2, 60), {}) + + def test_overlap_keeps_speaker_identity_through_three_requests(self): + chunks = [{"start": 0, "end": 63, "seam": 0}, + {"start": 57, "end": 120, "seam": 60}, + {"start": 114, "end": 140, "seam": 117}] + responses = [ + {"words": [dict(word(text, start), speaker=7) for text, start in + (("First", 1), ("shared", 59), ("words", 60))]}, + {"words": [dict(word(text, start), speaker=2) for text, start in + (("shared", 2), ("words", 3), ("more", 59), ("speech", 60))]}, + {"words": [dict(word(text, start), speaker=9) for text, start in + (("more", 2), ("speech", 3), ("Last", 20))]}, + ] + original = copy.deepcopy(responses) + with contextlib.redirect_stderr(io.StringIO()) as errors: + result = merge(chunks, responses) + self.assertEqual(result["text"], "First shared words more speech Last") + self.assertEqual({w["speaker"] for w in result["words"]}, {0}) + self.assertEqual(errors.getvalue(), "") + self.assertEqual(responses, original) + def test_boundary_word_survives_once_despite_timestamp_drift(self): left = [word("before", 8), word("boundary,", 9.8, 10.4), word("after", 10.5)] right = [word("Boundary", 10.0, 10.6), word("after", 10.6), word("later", 14)] diff --git a/test_transcribe.py b/test_transcribe.py index c4e8f61..f12abcf 100644 --- a/test_transcribe.py +++ b/test_transcribe.py @@ -92,16 +92,17 @@ class TranscribeTests(unittest.TestCase): self.assertEqual(result.stderr, "") self.assert_cleaned() - def test_chunked_text_prints_speaker_ids_with_request_scope(self): + def test_chunked_text_has_speaker_markers_without_chunk_details(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" + "[Speaker 0]\nHello.\n\n" + "[Speaker 0]\nboundary\n\n" + "[Speaker 1]\ncontinues.\n\n" + "[Speaker 1]\nLast.\n" )) - self.assertNotIn("Warning", result.stderr) + self.assertIn("speaker continuity is uncertain", result.stderr) + self.assertNotIn("Chunk", result.stdout) self.assert_cleaned() def test_missing_speaker_labels_warn_instead_of_implying_detection_worked(self): diff --git a/test_transcript.py b/test_transcript.py index 1eb73ec..2e4bb41 100644 --- a/test_transcript.py +++ b/test_transcript.py @@ -22,13 +22,13 @@ class TranscriptTests(unittest.TestCase): )) self.assertEqual(response, original) - def test_speaker_ids_are_scoped_to_chunks_even_when_numbers_match(self): + def test_unmatched_speakers_get_separate_ids_without_chunk_headers(self): response = merge( [{"start": 0, "end": 63, "seam": 0}, {"start": 57, "end": 100, "seam": 60}], [{"words": [word("First.", 1, 0)]}, {"words": [word("Second.", 10, 0)]}], ) self.assertEqual(render_text(response), ( - "[Chunk 1, Speaker 0]\nFirst.\n\n[Chunk 2, Speaker 0]\nSecond.\n" + "[Speaker 0]\nFirst.\n\n[Speaker 1]\nSecond.\n" )) def test_long_unpunctuated_turn_is_wrapped_and_split_without_losing_words(self): diff --git a/transcript.py b/transcript.py index 61964a5..991309a 100644 --- a/transcript.py +++ b/transcript.py @@ -17,9 +17,6 @@ SENTENCE_END = re.compile(r'[.!?…]["\u201d\u2019\u0027)]*$') def speaker_marker(speaker): if speaker is None: return "[Speaker unknown]" - if isinstance(speaker, tuple): - chunk, speaker = speaker - return f"[Chunk {chunk + 1}, Speaker {speaker}]" return f"[Speaker {speaker}]"