Short chunk overlaps often contain only one participant, so returning speakers receive new transcript IDs. MAI can also split one voice into several IDs within a single request, which overlap stitching cannot fix. Add an opt-in audio comparison pass: --speakers N selects Gemini 3.8 Flash with a known count, and --speaker-model allows another compatible model. Keep request-local identities separate, select up to three clean excerpts per ID, and compare all excerpts in one OpenRouter request. Gemini uses Google AI Studio because the Vertex backend limits prompts to one audio file. No local model or extra Python dependency is required. Only reconcile identities whose sample assignments agree. Preserve uncertain identities separately, reject incomplete or malformed replies, and retain the original words and timestamps. Bound sample count and request size, clip sampling to the real audio duration, and extend worker cleanup and buffered output to the comparison step. Document the sampled method's limitations and additional provider request. Test Plan: - python3 -B -m unittest -v: all 82 tests passed, including real MP3 encoding with fake transcription/comparison responses and cancellation. - bash -n transcribe: passed. - git diff --cached --check: passed. - Live API and voice accuracy unverified: this workspace could not access the configured 1Password credential. Mocked responses prove pipeline behavior, not model accuracy on the supplied recording.
230 lines
9.7 KiB
Python
230 lines
9.7 KiB
Python
"""Plan bounded audio uploads and stitch their overlapping word transcripts."""
|
|
|
|
import argparse
|
|
import array
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
import sys
|
|
import wave
|
|
|
|
from subtitles import convert, normalized_words
|
|
from transcript import render_text
|
|
|
|
CONTEXT = 3.0 # Audio on each side of a seam: six seconds shared by two uploads.
|
|
ENCODING_MARGIN = 0.1 # MP3 encoder delay and frame padding at 16 kHz.
|
|
PAUSE_SEARCH = 30.0
|
|
MIN_SILENCE = 0.3
|
|
SILENCE_PEAK = 32768 * 10 ** (-35 / 20)
|
|
|
|
|
|
def pause_before(audio, earliest, latest):
|
|
"""Find the last quiet interval in a small PCM window before the limit."""
|
|
rate = audio.getframerate()
|
|
first, last = round(earliest * rate), round(latest * rate)
|
|
audio.setpos(first)
|
|
samples = array.array("h", audio.readframes(last - first))
|
|
if sys.byteorder != "little":
|
|
samples.byteswap()
|
|
frame = round(0.02 * rate)
|
|
quiet_start = None
|
|
boundary = latest
|
|
for offset in range(0, len(samples) + frame, frame):
|
|
block = samples[offset:offset + frame]
|
|
quiet = bool(block) and max(map(abs, block)) <= SILENCE_PEAK
|
|
if quiet and quiet_start is None:
|
|
quiet_start = offset
|
|
elif not quiet and quiet_start is not None:
|
|
quiet_end = min(offset, len(samples))
|
|
if (quiet_end - quiet_start) / rate >= MIN_SILENCE:
|
|
boundary = (first + (quiet_start + quiet_end) / 2) / rate
|
|
quiet_start = None
|
|
return boundary
|
|
|
|
|
|
def plan(path, max_seconds=1800):
|
|
if not math.isfinite(max_seconds) or not 60 <= max_seconds <= 1800:
|
|
raise ValueError("Chunk length must be between 1 and 30 minutes")
|
|
with wave.open(str(path), "rb") as audio:
|
|
if (audio.getnchannels(), audio.getsampwidth(), audio.getframerate()) != (1, 2, 16000):
|
|
raise ValueError("Chunk planning requires mono 16 kHz 16-bit PCM WAV")
|
|
duration = audio.getnframes() / audio.getframerate()
|
|
if not duration:
|
|
raise ValueError("Audio file contains no samples")
|
|
chunks = []
|
|
core_start = 0.0
|
|
while core_start < duration:
|
|
start = max(0.0, core_start - CONTEXT)
|
|
if duration - start <= max_seconds - ENCODING_MARGIN:
|
|
core_end = duration
|
|
else:
|
|
latest = start + max_seconds - ENCODING_MARGIN - CONTEXT
|
|
core_end = pause_before(audio, max(core_start, latest - PAUSE_SEARCH), latest)
|
|
end = min(duration, core_end + CONTEXT)
|
|
chunks.append({"start": start, "end": end, "seam": core_start})
|
|
core_start = core_end
|
|
return chunks
|
|
|
|
|
|
def midpoint(word):
|
|
return (word["start"] + word["end"]) / 2
|
|
|
|
|
|
def token(word):
|
|
return "".join(c for c in word["word"].casefold() if c.isalnum())
|
|
|
|
|
|
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]
|
|
right_near = [(i, word, token(word)) for i, word in enumerate(right)
|
|
if abs(midpoint(word) - seam) <= radius]
|
|
matches = {}
|
|
for i, a, a_token in left_near:
|
|
for j, b, b_token in right_near:
|
|
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
|
|
run = [matches[pair][0]]
|
|
for direction in (-1, 1):
|
|
step = direction
|
|
while (i + step, j + step) in matches:
|
|
run.append(matches[i + step, j + step][0])
|
|
step += direction
|
|
# A whole matching phrase disambiguates "yes yes yes" better than
|
|
# a single word that just happens to be closer to the seam.
|
|
return (-len(run), round(sum(run) / len(run), 3), matches[pair][1])
|
|
|
|
i, j = min(matches, key=score)
|
|
return left[:i + 1] + right[j + 1:]
|
|
|
|
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,
|
|
)
|
|
return ([word for word in left 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, scope_speakers=False):
|
|
"""Stitch words, optionally leaving speaker identity to an audio pass."""
|
|
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")
|
|
for word in words:
|
|
if word["end"] > chunk["end"] - chunk["start"] + 1.0:
|
|
raise ValueError("Word timestamp exceeds the chunk duration")
|
|
word["start"] += chunk["start"]
|
|
word["end"] += chunk["start"]
|
|
mapping = {} if scope_speakers else 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 and not scope_speakers:
|
|
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}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
planner = commands.add_parser("plan")
|
|
planner.add_argument("audio")
|
|
planner.add_argument("minutes", type=int)
|
|
merger = commands.add_parser("merge")
|
|
merger.add_argument("manifest", type=Path)
|
|
merger.add_argument("--srt", action="store_true")
|
|
merger.add_argument("--json", action="store_true", help="Emit stitched words for further processing")
|
|
merger.add_argument("--scope-speakers", action="store_true",
|
|
help="Keep request-local speakers separate for subsequent audio comparison")
|
|
args = parser.parse_args()
|
|
try:
|
|
if args.command == "plan":
|
|
print(json.dumps(plan(args.audio, args.minutes * 60)))
|
|
else:
|
|
chunks = json.loads(args.manifest.read_text(encoding="utf-8"))
|
|
responses = [json.loads(
|
|
(args.manifest.parent / f"response-{i}.json").read_text(encoding="utf-8")
|
|
) for i in range(len(chunks))]
|
|
if args.json and args.srt or args.scope_speakers and not args.json:
|
|
raise ValueError("Use --scope-speakers with --json, and --json without --srt")
|
|
response = merge(chunks, responses, scope_speakers=args.scope_speakers)
|
|
if args.json:
|
|
print(json.dumps(response))
|
|
else:
|
|
sys.stdout.write(convert(response) if args.srt else render_text(response))
|
|
except (ValueError, OSError, wave.Error) as error:
|
|
parser.exit(1, f"Audio chunking failed: {error}\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|