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.
167 lines
6.7 KiB
Python
167 lines
6.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 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.
|
|
"""
|
|
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))
|
|
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 left_near or right_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 merge(chunks, responses):
|
|
if len(chunks) != len(responses) or not chunks:
|
|
raise ValueError("Expected one transcription response per chunk")
|
|
merged = []
|
|
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"]
|
|
if word.get("speaker") is not None:
|
|
# Speaker IDs belong to individual requests, not the file.
|
|
word["speaker"] = (index, word["speaker"])
|
|
except ValueError as error:
|
|
raise ValueError(f"Chunk {index + 1}: {error}") from error
|
|
merged = stitch(merged, words, chunk["seam"]) if index else words
|
|
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")
|
|
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))]
|
|
response = merge(chunks, responses)
|
|
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()
|