fix: keep chunk details out of speaker headers
Render transcript-wide speaker numbers instead of exposing upload chunks in reader-facing headers. Reconcile adjacent requests using at least two unambiguous word-and-time matches in their shared audio, including when provider speaker IDs change between requests. Reject conflicting mappings and never treat matching numeric provider IDs as identity evidence. Assign fresh IDs where continuity cannot be established and report that uncertainty on stderr. Number only speakers retained after overlap trimming. Document that overlap matching cannot identify a returning speaker who was absent from the overlap, and add the reader-facing boundary to AGENTS.md. Test Plan: - python3 -B -m unittest -v: all 46 tests passed, including swapped IDs, ambiguous evidence, continuity across three requests, and plain headers. - git diff --cached --check: passed. - Tests use synthetic speaker labels; live diarization accuracy was not verified because provider credentials are unavailable in this environment.
This commit is contained in:
1 parent
6e94379a25
commit
5dd1b5811b
7 files changed
+129
-32
No files matched your search
@@ -27,6 +27,8 @@
|
|||||||
- `test_*.py` and `tests/fake_provider.py`: local regression tests without real
|
- `test_*.py` and `tests/fake_provider.py`: local regression tests without real
|
||||||
credentials or provider requests.
|
credentials or provider requests.
|
||||||
|
|
||||||
Keep the CLI and its three Python helpers together. Preserve transcript order,
|
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
|
and emit stdout only after all chunks and output conversion succeed. Provider
|
||||||
output conversion succeed. Progress and errors belong on stderr.
|
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.
|
||||||
@@ -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
|
Unlabelled passages use `[Speaker unknown]` when other words have labels; a
|
||||||
response without any labels is printed without speaker markers. A warning on
|
response without any labels is printed without speaker markers. A warning on
|
||||||
stderr reports missing or incomplete labels when diarization was requested.
|
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.
|
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**
|
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.
|
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
|
SRT timestamps are shifted back to the original recording's timeline before
|
||||||
cue generation, with continuous cue numbering. Speaker labels, when returned,
|
cue generation, with continuous cue numbering. Text uses `[Speaker 0]`,
|
||||||
are local to each request; text marks them as `[Chunk 1, Speaker 0]`,
|
`[Speaker 1]`, etc., without exposing upload boundaries to the reader.
|
||||||
`[Chunk 2, Speaker 0]`, etc. The same ID in different chunks does **not** establish
|
For a chunked recording, provider IDs are mapped to transcript-wide IDs:
|
||||||
that it is the same person. The script does not identify speakers across
|
at least two unambiguous words shared at matching times in the overlap must
|
||||||
chunks. Short recordings still use one request and use `[Speaker 0]` markers.
|
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),
|
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
|
so all cuts share the same sample clock even when container duration metadata
|
||||||
|
|||||||
@@ -74,13 +74,8 @@ def token(word):
|
|||||||
return "".join(c for c in word["word"].casefold() if c.isalnum())
|
return "".join(c for c in word["word"].casefold() if c.isalnum())
|
||||||
|
|
||||||
|
|
||||||
def stitch(left, right, seam):
|
def overlap_matches(left, right, seam):
|
||||||
"""Switch transcripts at a shared word, away from either audio cut.
|
"""Find matching words at matching times in the shared audio."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
radius = CONTEXT - 0.5
|
radius = CONTEXT - 0.5
|
||||||
left_near = [(i, word, token(word)) for i, word in enumerate(left)
|
left_near = [(i, word, token(word)) for i, word in enumerate(left)
|
||||||
if abs(midpoint(word) - seam) <= radius]
|
if abs(midpoint(word) - seam) <= radius]
|
||||||
@@ -92,6 +87,17 @@ def stitch(left, right, seam):
|
|||||||
difference = abs(midpoint(a) - midpoint(b))
|
difference = abs(midpoint(a) - midpoint(b))
|
||||||
if a_token and a_token == b_token and difference <= 1.0:
|
if a_token and a_token == b_token and difference <= 1.0:
|
||||||
matches[i, j] = (difference, abs((midpoint(a) + midpoint(b)) / 2 - seam))
|
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:
|
if matches:
|
||||||
def score(pair):
|
def score(pair):
|
||||||
i, j = pair
|
i, j = pair
|
||||||
@@ -108,7 +114,7 @@ def stitch(left, right, seam):
|
|||||||
i, j = min(matches, key=score)
|
i, j = min(matches, key=score)
|
||||||
return left[:i + 1] + right[j + 1:]
|
return left[:i + 1] + right[j + 1:]
|
||||||
|
|
||||||
if left_near or right_near:
|
if speech_near:
|
||||||
print(
|
print(
|
||||||
f"Warning: no shared words near {seam:.2f}s; joining by timestamps. "
|
f"Warning: no shared words near {seam:.2f}s; joining by timestamps. "
|
||||||
"Review the transcript at this boundary.", file=sys.stderr,
|
"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])
|
[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):
|
def merge(chunks, responses):
|
||||||
if len(chunks) != len(responses) or not chunks:
|
if len(chunks) != len(responses) or not chunks:
|
||||||
raise ValueError("Expected one transcription response per chunk")
|
raise ValueError("Expected one transcription response per chunk")
|
||||||
merged = []
|
merged = []
|
||||||
|
previous = []
|
||||||
|
next_speaker = 0
|
||||||
for index, (chunk, response) in enumerate(zip(chunks, responses)):
|
for index, (chunk, response) in enumerate(zip(chunks, responses)):
|
||||||
try:
|
try:
|
||||||
words = normalized_words(response, "Chunk stitching")
|
words = normalized_words(response, "Chunk stitching")
|
||||||
@@ -129,12 +163,32 @@ def merge(chunks, responses):
|
|||||||
raise ValueError("Word timestamp exceeds the chunk duration")
|
raise ValueError("Word timestamp exceeds the chunk duration")
|
||||||
word["start"] += chunk["start"]
|
word["start"] += chunk["start"]
|
||||||
word["end"] += chunk["start"]
|
word["end"] += chunk["start"]
|
||||||
if word.get("speaker") is not None:
|
mapping = speaker_mapping(previous, words, chunk["seam"])
|
||||||
# Speaker IDs belong to individual requests, not the file.
|
unmatched = False
|
||||||
word["speaker"] = (index, word["speaker"])
|
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:
|
except ValueError as error:
|
||||||
raise ValueError(f"Chunk {index + 1}: {error}") from error
|
raise ValueError(f"Chunk {index + 1}: {error}") from error
|
||||||
merged = stitch(merged, words, chunk["seam"]) if index else words
|
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}
|
return {"text": " ".join(word["word"] for word in merged), "words": merged}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+40
-1
@@ -6,7 +6,7 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
import wave
|
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
|
from subtitles import convert
|
||||||
|
|
||||||
|
|
||||||
@@ -73,6 +73,45 @@ class ChunkPlanTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ChunkMergeTests(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):
|
def test_boundary_word_survives_once_despite_timestamp_drift(self):
|
||||||
left = [word("before", 8), word("boundary,", 9.8, 10.4), word("after", 10.5)]
|
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)]
|
right = [word("Boundary", 10.0, 10.6), word("after", 10.6), word("later", 14)]
|
||||||
|
|||||||
+7
-6
@@ -92,16 +92,17 @@ class TranscribeTests(unittest.TestCase):
|
|||||||
self.assertEqual(result.stderr, "")
|
self.assertEqual(result.stderr, "")
|
||||||
self.assert_cleaned()
|
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")
|
result = self.run_cli("--chunk-minutes", "1", mode="speakers")
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
self.assertEqual(result.stdout, (
|
self.assertEqual(result.stdout, (
|
||||||
"[Chunk 1, Speaker 0]\nHello.\n\n"
|
"[Speaker 0]\nHello.\n\n"
|
||||||
"[Chunk 1, Speaker 0]\nboundary\n\n"
|
"[Speaker 0]\nboundary\n\n"
|
||||||
"[Chunk 2, Speaker 1]\ncontinues.\n\n"
|
"[Speaker 1]\ncontinues.\n\n"
|
||||||
"[Chunk 2, Speaker 1]\nLast.\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()
|
self.assert_cleaned()
|
||||||
|
|
||||||
def test_missing_speaker_labels_warn_instead_of_implying_detection_worked(self):
|
def test_missing_speaker_labels_warn_instead_of_implying_detection_worked(self):
|
||||||
|
|||||||
+2
-2
@@ -22,13 +22,13 @@ class TranscriptTests(unittest.TestCase):
|
|||||||
))
|
))
|
||||||
self.assertEqual(response, original)
|
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(
|
response = merge(
|
||||||
[{"start": 0, "end": 63, "seam": 0}, {"start": 57, "end": 100, "seam": 60}],
|
[{"start": 0, "end": 63, "seam": 0}, {"start": 57, "end": 100, "seam": 60}],
|
||||||
[{"words": [word("First.", 1, 0)]}, {"words": [word("Second.", 10, 0)]}],
|
[{"words": [word("First.", 1, 0)]}, {"words": [word("Second.", 10, 0)]}],
|
||||||
)
|
)
|
||||||
self.assertEqual(render_text(response), (
|
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):
|
def test_long_unpunctuated_turn_is_wrapped_and_split_without_losing_words(self):
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ SENTENCE_END = re.compile(r'[.!?…]["\u201d\u2019\u0027)]*$')
|
|||||||
def speaker_marker(speaker):
|
def speaker_marker(speaker):
|
||||||
if speaker is None:
|
if speaker is None:
|
||||||
return "[Speaker unknown]"
|
return "[Speaker unknown]"
|
||||||
if isinstance(speaker, tuple):
|
|
||||||
chunk, speaker = speaker
|
|
||||||
return f"[Chunk {chunk + 1}, Speaker {speaker}]"
|
|
||||||
return f"[Speaker {speaker}]"
|
return f"[Speaker {speaker}]"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user