aboutsummaryrefslogtreecommitdiff
path: root/working/meeting-transcription-service/src/merge_transcript.py
blob: 13aec75762c9ce4cde456a5c68ce64a9323cf23b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
#!/usr/bin/env python3
"""Merge whisper word timings with speaker turns into transcript lines.

Input is two JSON files: whisper-cli's ``-oj`` output (run at word level) and
the diarizer's list of speaker turns. Output is one line per stretch of speech,
``HH:MM:SS Speaker A: text``, the same shape the hosted services produced.

Standard library only, so it runs under any Python 3.10+ without the venv.
"""

from __future__ import annotations

import json
import sys
from dataclasses import dataclass
from pathlib import Path

DEFAULT_MAX_GAP_S = 3.0
DEFAULT_SPEECH_MARGIN_S = 2.0
DEFAULT_MAX_LOOP_S = 30.0


@dataclass(frozen=True)
class Unit:
    """A piece of transcribed text with its start and end in seconds."""

    start: float
    end: float
    text: str


@dataclass(frozen=True)
class Turn:
    """A stretch of audio the diarizer attributes to one speaker."""

    start: float
    end: float
    speaker: str


def _timestamp(seconds: float) -> str:
    """Render seconds as HH:MM:SS, floored."""
    whole = int(seconds)
    return f"{whole // 3600:02d}:{whole % 3600 // 60:02d}:{whole % 60:02d}"


def _speaker_name(index: int) -> str:
    """Name speakers A-Z in order of first speech, then by number."""
    return chr(ord("A") + index) if index < 26 else str(index)


def _speaker_for(unit: Unit, turns: list[Turn]) -> str:
    """Pick the turn a unit belongs to.

    The turn overlapping most of the unit wins. A unit overlapping nothing (a
    zero-length word, or one whisper timed into a silence) goes to the turn
    nearest its midpoint, because whisper's word timings drift by a few hundred
    milliseconds and dropping the word would be worse than a near guess.
    """
    best = max(turns, key=lambda t: min(unit.end, t.end) - max(unit.start, t.start))
    if min(unit.end, best.end) - max(unit.start, best.start) > 0:
        return best.speaker
    mid = (unit.start + unit.end) / 2

    def distance(turn: Turn) -> float:
        if turn.start <= mid <= turn.end:
            return 0.0
        return min(abs(mid - turn.start), abs(mid - turn.end))

    return min(turns, key=distance).speaker


def merge(units: list[Unit], turns: list[Turn], max_gap_s: float = DEFAULT_MAX_GAP_S) -> list[str]:
    """Return transcript lines for ``units`` labelled by ``turns``.

    Consecutive units from one speaker share a line. The line breaks when the
    speaker changes, or when the speaker pauses longer than ``max_gap_s``, so a
    long monologue still carries usable timestamps.

    Raises:
        ValueError: if there are no turns, no spoken words, or ``max_gap_s`` is negative.
    """
    if max_gap_s < 0:
        raise ValueError("max_gap_s must not be negative")
    spoken = sorted((u for u in units if u.text.strip()), key=lambda u: (u.start, u.end))
    if not spoken:
        raise ValueError("no speech: the transcription holds no words")
    if not turns:
        raise ValueError("no speaker turns: the diarization is empty")
    ordered_turns = sorted(turns, key=lambda t: (t.start, t.end))

    names: dict[str, str] = {}
    lines: list[tuple[float, str, list[str]]] = []
    previous_end = 0.0
    for unit in spoken:
        speaker = _speaker_for(unit, ordered_turns)
        name = names.setdefault(speaker, _speaker_name(len(names)))
        if lines and lines[-1][1] == name and unit.start - previous_end <= max_gap_s:
            lines[-1][2].append(unit.text)
        else:
            lines.append((unit.start, name, [unit.text]))
        previous_end = max(previous_end, unit.end)

    return [
        f"{_timestamp(start)} Speaker {name}: {' '.join(''.join(parts).split())}"
        for start, name, parts in lines
    ]


def drop_outside_speech(
    units: list[Unit], turns: list[Turn], margin_s: float = DEFAULT_SPEECH_MARGIN_S
) -> tuple[list[Unit], int]:
    """Return the units that belong to speech, and how many were dropped.

    Whisper invents words ("Thank you.") when it is handed silence. The diarizer
    marks where people actually spoke, so a unit that touches no turn and sits
    more than ``margin_s`` from the nearest one is treated as invented. The margin
    protects real words the diarizer clipped off the edge of a turn.

    Raises:
        ValueError: if there are no turns, or ``margin_s`` is negative.
    """
    if margin_s < 0:
        raise ValueError("margin_s must not be negative")
    if not turns:
        raise ValueError("no speaker turns: the diarization is empty")

    def gap(unit: Unit) -> float:
        # Seconds between the unit and its nearest turn; zero when they touch.
        # Rounded to the millisecond, the resolution of whisper's offsets.
        nearest = min(max(turn.start - unit.end, unit.start - turn.end, 0.0) for turn in turns)
        return round(nearest, 3)

    kept = [unit for unit in units if gap(unit) <= margin_s]
    return kept, len(units) - len(kept)


def _unit(item: dict) -> Unit:
    """A Unit from a whisper segment or token dict (offsets in milliseconds).

    whisper-cli clamps a token's start to its segment's start without moving the
    end, so some tokens arrive ending before they begin. Those become zero-length
    at their start; left alone they corrupt both the overlap and the pause maths.
    """
    start = item["offsets"]["from"] / 1000
    end = item["offsets"]["to"] / 1000
    return Unit(start, max(start, end), item["text"])


def find_repetition(
    text: str, min_words: int = 3, max_words: int = 12, min_repeats: int = 4
) -> tuple[str, int] | None:
    """Find a phrase repeated back to back, whisper's hallucination signature.

    Returns the phrase and its repeat count, or None. Four consecutive repeats of
    a phrase of three or more words is the line: people say a thing two or three
    times, and single-word runs ("yeah yeah yeah") are ordinary speech.
    """
    words = text.split()
    keys = [w.lower().strip(".,!?;:\"'") for w in words]
    for size in range(min_words, max_words + 1):
        for i in range(len(keys) - size * min_repeats + 1):
            phrase = keys[i : i + size]
            if len(set(phrase)) < 2:
                continue
            count = 1
            while keys[i + count * size : i + (count + 1) * size] == phrase:
                count += 1
            if count >= min_repeats:
                return " ".join(words[i : i + size]), count
    return None


def collapse_repetitions(
    units: list[Unit],
    min_words: int = 3,
    max_words: int = 12,
    min_repeats: int = 4,
    max_loop_s: float = DEFAULT_MAX_LOOP_S,
) -> tuple[list[Unit], list[tuple[str, int]]]:
    """Collapse whisper's repetition loops, keeping one copy of the phrase.

    Whisper sometimes gets stuck and emits the same phrase over and over. A short
    loop (up to ``max_loop_s`` of audio) costs a few seconds of speech, so it is
    collapsed to a single occurrence and reported. A longer one means real speech
    was lost for a stretch, and that is raised instead, so the job fails rather
    than hand back a transcript with a hole in it.

    Returns the surviving units and a list of (phrase, repeat count) for every
    loop collapsed. Units are matched word by word, so a phrase spread over word
    units and a phrase sitting in one segment unit are both found.

    Raises:
        ValueError: if a loop lasts longer than ``max_loop_s``, or the limit is negative.
    """
    if max_loop_s < 0:
        raise ValueError("max_loop_s must not be negative")
    units = list(units)
    collapsed: list[tuple[str, int]] = []

    def find() -> tuple[int, int, int] | None:
        # (first word index, phrase size, repeat count) of the earliest loop, or None
        words = [(w, ui) for ui, u in enumerate(units) for w in u.text.split()]
        keys = [w.lower().strip(".,!?;:\"'") for w, _ in words]
        best: tuple[int, int, int] | None = None
        for size in range(min_words, max_words + 1):
            for i in range(len(keys) - size * min_repeats + 1):
                if best is not None and i >= best[0]:
                    break
                phrase = keys[i : i + size]
                if len(set(phrase)) < 2:
                    continue
                count = 1
                while keys[i + count * size : i + (count + 1) * size] == phrase:
                    count += 1
                if count >= min_repeats:
                    best = (i, size, count)
                    break
        return best

    while True:
        hit = find()
        if hit is None:
            return units, collapsed
        i, size, count = hit
        words = [(w, ui) for ui, u in enumerate(units) for w in u.text.split()]
        phrase_text = " ".join(w for w, _ in words[i : i + size])
        doomed = set(range(i + size, i + count * size))  # word indexes of the repeats
        touched = {ui for wi, (_, ui) in enumerate(words) if wi in doomed or i <= wi < i + size}
        span_start = min(units[ui].start for ui in touched)
        span_end = max(units[ui].end for ui in touched)
        duration = round(span_end - span_start, 3)
        if duration > max_loop_s:
            raise ValueError(
                f"whisper looped: {phrase_text!r} repeats {count} times over {duration:.0f} s; "
                "rerun whisper with -mc 0"
            )
        # Rebuild every touched unit from the words it keeps. A unit that held only
        # repeats disappears; one that also held the first copy or later speech keeps
        # those words. Every pass removes (count - 1) * size words, so this ends.
        last_kept = max(ui for wi, (_, ui) in enumerate(words) if i <= wi < i + size)
        rebuilt: list[Unit] = []
        for ui, unit in enumerate(units):
            if ui not in touched:
                rebuilt.append(unit)
                continue
            keep = [w for wi, (w, wui) in enumerate(words) if wui == ui and wi not in doomed]
            if not keep:
                continue
            # The kept copy takes over the time the loop occupied, so the merge does
            # not read the removed stretch as a pause and break the line there.
            end = max(unit.end, span_end) if ui == last_kept else unit.end
            rebuilt.append(Unit(unit.start, end, " " + " ".join(keep)))
        units = rebuilt
        collapsed.append((phrase_text, count))


def load_whisper_json(path: str | Path) -> list[Unit]:
    """Read whisper-cli JSON output. Offsets there are in milliseconds.

    With ``-ojf`` each segment carries its tokens and their offsets; those become
    word-level units, which is what lets a speaker change land mid-segment. Plain
    ``-oj`` output, or a segment with no tokens, falls back to the segment itself.

    Raises:
        ValueError: if the file is not whisper's JSON shape.
    """
    try:
        data = json.loads(Path(path).read_text(encoding="utf-8"))
        units: list[Unit] = []
        for item in data["transcription"]:
            tokens = item.get("tokens") or []
            if not tokens:
                units.append(_unit(item))
                continue
            for token in tokens:
                text = token["text"]
                if not text or text.startswith("[_"):  # [_BEG_], [_TT_123], [_EOT_]
                    continue
                unit = _unit(token)
                if units and not text.startswith(" "):
                    # A sub-word piece or punctuation: it belongs to the word before it.
                    previous = units[-1]
                    units[-1] = Unit(previous.start, max(previous.end, unit.end), previous.text + text)
                else:
                    units.append(unit)
        return units
    except OSError as err:
        raise ValueError(f"{path}: cannot read whisper output ({err.strerror})") from err
    except (json.JSONDecodeError, KeyError, TypeError) as err:
        raise ValueError(f"{path}: not whisper-cli JSON output ({err!r})") from err


def load_turns_json(path: str | Path) -> list[Turn]:
    """Read the diarizer's turns: a list of {start, end, speaker}, in seconds.

    Raises:
        ValueError: if the file is not that shape.
    """
    try:
        data = json.loads(Path(path).read_text(encoding="utf-8"))
        if not isinstance(data, list):
            raise TypeError("expected a list of turns")
        return [Turn(float(t["start"]), float(t["end"]), str(t["speaker"])) for t in data]
    except OSError as err:
        raise ValueError(f"{path}: cannot read speaker turns ({err.strerror})") from err
    except (json.JSONDecodeError, KeyError, TypeError, ValueError) as err:
        raise ValueError(f"{path}: not a speaker-turns file ({err!r})") from err


def main(argv: list[str]) -> int:
    """CLI: ``merge_transcript.py WHISPER_JSON TURNS_JSON`` prints the transcript."""
    if len(argv) != 2:
        print("usage: merge_transcript.py WHISPER_JSON TURNS_JSON", file=sys.stderr)
        return 2
    try:
        turns = load_turns_json(argv[1])
        units, _dropped = drop_outside_speech(load_whisper_json(argv[0]), turns)
        units, _collapsed = collapse_repetitions(units)
        lines = merge(units, turns)
    except ValueError as err:
        print(f"Error: {err}", file=sys.stderr)
        return 1
    print("\n".join(lines))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))