podcue 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
podcue/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Turn podcast audio into timed, speaker-grouped cues."""
2
+
3
+ __version__ = "0.2.0"
podcue/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
podcue/audio.py ADDED
@@ -0,0 +1,58 @@
1
+ """Fetch a source, convert it to 16-bit mono 16 kHz WAV with ffmpeg, and read the samples."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import urllib.parse
9
+ import urllib.request
10
+ import wave
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+
15
+ RATE = 16_000
16
+ TIMEOUT_S = 60 # per connect or read; a stalled download fails instead of hanging
17
+
18
+
19
+ def is_url(source: str) -> bool:
20
+ return urllib.parse.urlsplit(source).scheme in ("http", "https")
21
+
22
+
23
+ def download(url: str, directory: Path, *, opener=urllib.request.urlopen) -> Path:
24
+ suffix = Path(urllib.parse.urlsplit(url).path).suffix
25
+ target = directory / f"download{suffix}"
26
+ print(f"podcue: downloading {url}", file=sys.stderr)
27
+ request = urllib.request.Request(url, headers={"User-Agent": "podcue"})
28
+ with opener(request, timeout=TIMEOUT_S) as response, target.open("wb") as out:
29
+ shutil.copyfileobj(response, out, 1 << 20)
30
+ return target
31
+
32
+
33
+ def to_wav(source: Path, target: Path) -> None:
34
+ if shutil.which("ffmpeg") is None:
35
+ raise RuntimeError("ffmpeg is not installed or not on PATH")
36
+ args = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", str(source),
37
+ "-vn", "-ac", "1", "-ar", str(RATE), "-c:a", "pcm_s16le", "-f", "wav", str(target)]
38
+ proc = subprocess.run(args, capture_output=True, text=True, check=False)
39
+ if proc.returncode != 0:
40
+ detail = proc.stderr.strip() or f"exit code {proc.returncode}"
41
+ raise RuntimeError(f"ffmpeg could not convert {source}: {detail}")
42
+
43
+
44
+ def read_wav(path: Path) -> np.ndarray:
45
+ """Float32 samples in [-1, 1). Only the 16-bit mono 16 kHz WAV that `to_wav` writes."""
46
+ with wave.open(str(path), "rb") as reader:
47
+ if reader.getnchannels() != 1 or reader.getsampwidth() != 2 or reader.getframerate() != RATE:
48
+ raise ValueError(f"{path} is not 16-bit mono 16 kHz WAV")
49
+ pcm = reader.readframes(reader.getnframes())
50
+ return np.frombuffer(pcm, dtype="<i2").astype(np.float32) / np.float32(32768.0)
51
+
52
+
53
+ def duration_ms(samples: np.ndarray) -> int:
54
+ return len(samples) * 1000 // RATE
55
+
56
+
57
+ def span(samples: np.ndarray, start_ms: int, end_ms: int) -> np.ndarray:
58
+ return samples[start_ms * RATE // 1000 : end_ms * RATE // 1000]
podcue/cli.py ADDED
@@ -0,0 +1,368 @@
1
+ """The podcue commands. Each prints exactly one JSON line on stdout; progress goes to stderr.
2
+
3
+ A non-zero exit always comes with `{"kind": "error", "message": ...}`. An id the episode does
4
+ not have is `{"state": "unknown", ...}` with exit 0, and a command that needs cues before
5
+ `start` has written them is `{"state": "not-ready", ...}` with exit 0.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import yaml
16
+
17
+ from . import __version__
18
+ from .groups import (
19
+ DEFAULT_LIMIT,
20
+ LABEL_SHAPE,
21
+ MAX_LIMIT,
22
+ Unknown,
23
+ host_names,
24
+ known_groups,
25
+ labeled_share,
26
+ merge_labels,
27
+ page,
28
+ parse_label_updates,
29
+ )
30
+ from .store import (
31
+ SUMMARY_ID,
32
+ Episode,
33
+ dump_yaml,
34
+ load_labels,
35
+ load_rows,
36
+ read_json,
37
+ resolve_root,
38
+ start_state,
39
+ summary_ids,
40
+ write_json,
41
+ write_text,
42
+ writing,
43
+ )
44
+
45
+
46
+ class Failure(Exception):
47
+ def __init__(self, message: str, **fields: object):
48
+ super().__init__(message)
49
+ self.fields = fields
50
+
51
+
52
+ class NotReady(Exception):
53
+ pass
54
+
55
+
56
+ class Parser(argparse.ArgumentParser):
57
+ def error(self, message: str):
58
+ raise Failure(f"{self.prog}: {message}")
59
+
60
+
61
+ def build_parser() -> Parser:
62
+ common = argparse.ArgumentParser(add_help=False)
63
+ common.add_argument("--root", default=argparse.SUPPRESS, metavar="DIR",
64
+ help="episode root (default: $PODCUE_HOME, else ~/podcue)")
65
+ parser = Parser(
66
+ prog="podcue",
67
+ parents=[common],
68
+ description="Turn podcast audio into timed, speaker-grouped cues, label the voices, and store "
69
+ "summaries that cite the cues. State lives in <root>/episodes/<episodeId>/. "
70
+ "Every command prints one JSON line.",
71
+ )
72
+ parser.add_argument("--version", action="version", version=f"podcue {__version__}")
73
+ sub = parser.add_subparsers(dest="command", required=True, parser_class=Parser, metavar="COMMAND")
74
+
75
+ def command(name: str, help: str) -> Parser:
76
+ return sub.add_parser(name, parents=[common], help=help, description=help)
77
+
78
+ start = command("start", "transcribe and group an audio file or URL; returns when the cues are ready")
79
+ start.add_argument("source", help="audio or video file, or an http(s) URL to one")
80
+ start.add_argument("--language", default="en", metavar="CODE",
81
+ help="en de fr es it nl pl, or ja ko zh with the moonshine extra (default: en)")
82
+
83
+ command("episode", "status, groups, labels and summaries of an episode").add_argument("episodeId")
84
+
85
+ cues = command("cues", "one page of cue rows")
86
+ cues.add_argument("episodeId")
87
+ cues.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help=f"1 through {MAX_LIMIT} (default {DEFAULT_LIMIT})")
88
+ cues.add_argument("--after", metavar="CUE_ID", help="start after this cue")
89
+ cues.add_argument("--group-id", metavar="GROUP_ID", help="only this group's cues")
90
+ cues.add_argument("--hosts", action="store_true", help="also return the person names in labels.json")
91
+
92
+ labels = command("label-groups", "merge labels into labels.json: '[{\"groupId\":...,\"label\":...}]'")
93
+ labels.add_argument("episodeId")
94
+ labels.add_argument("json")
95
+
96
+ command("start-summary", "create an empty summary and return its id").add_argument("episodeId")
97
+ for name, help in (
98
+ ("post-paragraph", "append a paragraph '{\"text\":...,\"cues\":[...]}', or an array of them"),
99
+ ("put-paragraph", "replace paragraph '{\"index\":n,\"text\":...,\"cues\":[...]}'"),
100
+ ("set-summary", "set the episode summary '{\"summary\":...}' after the paragraphs"),
101
+ ):
102
+ body = command(name, help)
103
+ body.add_argument("episodeId")
104
+ body.add_argument("summaryId")
105
+ body.add_argument("json")
106
+ read = command("summary", "read a summary back")
107
+ read.add_argument("episodeId")
108
+ read.add_argument("summaryId")
109
+
110
+ check = command("check", "strictly validate a summary file (by id, or a YAML/JSON path) against the episode")
111
+ check.add_argument("episodeId")
112
+ check.add_argument("summary", help="a summary id, or a path to a .yaml/.json file")
113
+
114
+ command("fetch-weights", "optional: download the pinned diarization weights now")
115
+
116
+ fetch = command("fetch", "download episode audio to a 16 kHz mono wav that start can take")
117
+ fetch.add_argument("url", help="direct audio URL, or a page URL yt-dlp can read (recap extra)")
118
+ fetch.add_argument("--out", default=".", metavar="DIR", help="directory for audio.wav (default: .)")
119
+
120
+ build = command("build", "narrate script.json, cut clips from the episode, and stitch one audio file")
121
+ build.add_argument("episodeId")
122
+ build.add_argument("script", help="script.json: {voice?, output?, segments:[...]}")
123
+ build.add_argument("--out", metavar="FILE",
124
+ help="output audio (default: the script's output, else produced_summary.mp3)")
125
+ return parser
126
+
127
+
128
+ def _episode(root: Path, episode_id: str) -> Episode:
129
+ episode = Episode(root, episode_id)
130
+ if not episode.known():
131
+ raise Unknown()
132
+ return episode
133
+
134
+
135
+ def _ready(episode: Episode) -> list[dict]:
136
+ if not episode.ready():
137
+ raise NotReady()
138
+ return load_rows(episode)
139
+
140
+
141
+ def _json(text: str, message: str) -> object:
142
+ try:
143
+ return json.loads(text)
144
+ except ValueError:
145
+ raise Failure(message) from None
146
+
147
+
148
+ def episode_view(episode: Episode) -> dict:
149
+ manifest = read_json(episode.manifest)
150
+ state = start_state(episode)
151
+ artifacts = []
152
+ for name, path in (("transcript", episode.transcript), ("cues", episode.cues)):
153
+ artifact = {"artifact": name, **state}
154
+ if state["state"] == "ready":
155
+ artifact["path"] = str(path)
156
+ artifacts.append(artifact)
157
+ view = {"state": "present", **manifest, "dir": str(episode.dir), "artifacts": artifacts}
158
+ if episode.ready():
159
+ labels = load_labels(episode)
160
+ label_of = {label["groupId"]: label["label"] for label in labels}
161
+ view["groups"] = [{**group, "label": label_of.get(group["groupId"])} for group in read_json(episode.groups)]
162
+ view["labeledShare"] = labeled_share(load_rows(episode), labels)
163
+ view["summaries"] = [{"summaryId": summary_id} for summary_id in summary_ids(episode)]
164
+ return view
165
+
166
+
167
+ def _present_summary(episode: Episode, document: dict) -> dict:
168
+ return {"state": "present", "episodeId": episode.id, **{k: document[k] for k in ("version", "summaryId", "summary", "paragraphs")}}
169
+
170
+
171
+ def _read_summary(episode: Episode, summary_id: str) -> dict:
172
+ path = episode.summary_file(summary_id)
173
+ if not SUMMARY_ID.match(summary_id) or not path.is_file():
174
+ raise Unknown(summaryId=summary_id)
175
+ try:
176
+ document = yaml.safe_load(path.read_text(encoding="utf-8"))
177
+ except yaml.YAMLError:
178
+ document = None
179
+ if not (isinstance(document, dict) and document.get("summaryId") == summary_id
180
+ and isinstance(document.get("paragraphs"), list) and isinstance(document.get("summary"), str)):
181
+ raise Failure("the summary could not be read")
182
+ return document
183
+
184
+
185
+ def _store_summary(episode: Episode, document: dict) -> dict:
186
+ write_text(episode.summary_file(document["summaryId"]), dump_yaml(document))
187
+ return _present_summary(episode, document)
188
+
189
+
190
+ def run_start(args, root):
191
+ from .pipeline import start
192
+
193
+ episode, created = start(args.source, args.language, root)
194
+ return {"kind": "started", "created": created, "episode": episode_view(episode)}
195
+
196
+
197
+ def run_cues(args, root):
198
+ if not 1 <= args.limit <= MAX_LIMIT:
199
+ raise Failure(f"limit must be an integer from 1 through {MAX_LIMIT}", reason="limit")
200
+ episode = _episode(root, args.episodeId)
201
+ rows = page(_ready(episode), args.limit, args.after, args.group_id)
202
+ result = {"state": "present", "episodeId": episode.id, "cues": rows}
203
+ if args.hosts:
204
+ result["hostNames"] = host_names(load_labels(episode))
205
+ return result
206
+
207
+
208
+ def run_label_groups(args, root):
209
+ updates = parse_label_updates(_json(args.json, LABEL_SHAPE))
210
+ if updates is None:
211
+ raise Failure(LABEL_SHAPE)
212
+ episode = _episode(root, args.episodeId)
213
+ rows = _ready(episode)
214
+ known = known_groups(rows)
215
+ for update in updates:
216
+ if update["groupId"] not in known:
217
+ raise Unknown(groupId=update["groupId"])
218
+ with writing(episode):
219
+ labels = merge_labels(load_labels(episode), updates)
220
+ write_json(episode.labels, {"groups": labels})
221
+ return {"state": "present", "episodeId": episode.id, "labels": labels, "labeledShare": labeled_share(rows, labels)}
222
+
223
+
224
+ def run_start_summary(args, root):
225
+ from .summary import empty, new_summary_id
226
+
227
+ episode = _episode(root, args.episodeId)
228
+ with writing(episode):
229
+ summary_id = new_summary_id()
230
+ while episode.summary_file(summary_id).exists():
231
+ summary_id = new_summary_id()
232
+ return _store_summary(episode, empty(episode.id, summary_id))
233
+
234
+
235
+ def _change_summary(args, root, change, message):
236
+ episode = _episode(root, args.episodeId)
237
+ body = _json(args.json, message)
238
+ with writing(episode):
239
+ document = _read_summary(episode, args.summaryId)
240
+ return _store_summary(episode, change(episode, document, body))
241
+
242
+
243
+ def run_post_paragraph(args, root):
244
+ from .summary import POST_SHAPE, post
245
+
246
+ return _change_summary(args, root, lambda ep, doc, body: post(doc, body, _ready(ep)), POST_SHAPE)
247
+
248
+
249
+ def run_put_paragraph(args, root):
250
+ from .summary import PUT_SHAPE, put
251
+
252
+ return _change_summary(args, root, lambda ep, doc, body: put(doc, body, _ready(ep)), PUT_SHAPE)
253
+
254
+
255
+ def run_set_summary(args, root):
256
+ from .summary import Invalid, set_summary
257
+
258
+ def change(_episode, document, body):
259
+ if not isinstance(body, dict):
260
+ raise Invalid("set-summary expects JSON { summary }")
261
+ return set_summary(document, body.get("summary"))
262
+
263
+ return _change_summary(args, root, change, "set-summary expects JSON { summary }")
264
+
265
+
266
+ def run_summary(args, root):
267
+ episode = _episode(root, args.episodeId)
268
+ return _present_summary(episode, _read_summary(episode, args.summaryId))
269
+
270
+
271
+ def run_check(args, root):
272
+ from .summary import check, load
273
+
274
+ episode = Episode(root, args.episodeId)
275
+ if not episode.known():
276
+ raise Failure(f"unknown episode {args.episodeId}")
277
+ if not episode.ready():
278
+ raise Failure(f"{episode.id} has no cues yet; run start first")
279
+ summary_id = args.summary if SUMMARY_ID.match(args.summary) else None
280
+ path = episode.summary_file(summary_id) if summary_id else Path(args.summary)
281
+ if not path.is_file():
282
+ raise Failure(f"{args.summary} is not a summary of {episode.id} or a file")
283
+ try:
284
+ document = load(path)
285
+ except (ValueError, yaml.YAMLError) as error:
286
+ raise Failure(f"{path} could not be parsed: {error}") from None
287
+ problems, warnings = check(document, episode.id, load_rows(episode), load_labels(episode), summary_id)
288
+ if problems:
289
+ raise Failure(f"{len(problems)} problem(s): " + "; ".join(problems), problems=problems, warnings=warnings)
290
+ result = {"state": "valid", "episodeId": episode.id, "path": str(path), "paragraphs": len(document["paragraphs"])}
291
+ if summary_id or isinstance(document.get("summaryId"), str):
292
+ result["summaryId"] = document["summaryId"]
293
+ result["warnings"] = warnings
294
+ return result
295
+
296
+
297
+ def run_fetch(args, _root):
298
+ from .fetch import fetch
299
+
300
+ return fetch(args.url, Path(args.out).expanduser())
301
+
302
+
303
+ def run_build(args, root):
304
+ from .recap import build
305
+
306
+ episode = _episode(root, args.episodeId)
307
+ return build(episode, _ready(episode), Path(args.script), getattr(args, "out", None))
308
+
309
+
310
+ def run_fetch_weights(_args, _root):
311
+ from . import weights
312
+
313
+ return {"kind": "ready", "weights": [
314
+ {"file": pin.file, "path": str(weights.ensure(pin)), "sha256": pin.sha256, "license": pin.license}
315
+ for pin in (weights.ECAPA, weights.SEGMENTATION)
316
+ ]}
317
+
318
+
319
+ COMMANDS = {
320
+ "start": run_start,
321
+ "episode": lambda args, root: episode_view(_episode(root, args.episodeId)),
322
+ "cues": run_cues,
323
+ "label-groups": run_label_groups,
324
+ "start-summary": run_start_summary,
325
+ "post-paragraph": run_post_paragraph,
326
+ "put-paragraph": run_put_paragraph,
327
+ "set-summary": run_set_summary,
328
+ "summary": run_summary,
329
+ "check": run_check,
330
+ "fetch-weights": run_fetch_weights,
331
+ "fetch": run_fetch,
332
+ "build": run_build,
333
+ }
334
+
335
+
336
+ def execute(argv: list[str]) -> tuple[dict, int]:
337
+ from .pipeline import Rejected
338
+ from .recap import RecapError
339
+ from .summary import Invalid
340
+
341
+ episode_id = None
342
+ try:
343
+ args = build_parser().parse_args(argv)
344
+ episode_id = getattr(args, "episodeId", None)
345
+ return COMMANDS[args.command](args, resolve_root(getattr(args, "root", None))), 0
346
+ except Unknown as unknown:
347
+ return {"state": "unknown", "episodeId": episode_id, **unknown.fields}, 0
348
+ except NotReady:
349
+ return {"state": "not-ready", "episodeId": episode_id, "on": "cues"}, 0
350
+ except Failure as error:
351
+ return {"kind": "error", "message": str(error), **error.fields}, 1
352
+ except Rejected as error:
353
+ return {"kind": "error", "reason": error.reason, "message": str(error)}, 1
354
+ except Invalid as error:
355
+ return {"kind": "error", "message": str(error)}, 1
356
+ except RecapError as error:
357
+ return {"kind": "error", "message": str(error), **error.fields}, 1
358
+ except KeyboardInterrupt:
359
+ return {"kind": "error", "message": "interrupted"}, 130
360
+ except Exception as error: # one JSON line instead of a traceback
361
+ return {"kind": "error", "message": str(error) or type(error).__name__}, 1
362
+
363
+
364
+ def main(argv: list[str] | None = None) -> int:
365
+ result, code = execute(sys.argv[1:] if argv is None else argv)
366
+ sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
367
+ sys.stdout.flush()
368
+ return code
podcue/cues.py ADDED
@@ -0,0 +1,176 @@
1
+ """Build cues from timed words, attach diarization speaker groups, and render WebVTT.
2
+
3
+ A cue time is only ever a slice start plus a transcriber word time, so a rerun on the same
4
+ words gives the same cues.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import re
11
+ from typing import NamedTuple
12
+
13
+ from .diarize import Tracks, Turn
14
+ from .regions import Slice
15
+
16
+ UNGROUPED = "ungrouped"
17
+ MAX_CUE_MS = 12_000 # a cue closes even when the transcriber left out punctuation
18
+ _SENTENCE_END = re.compile(r"[.!?。!?]$") # CJK sentences end on 。!?
19
+
20
+
21
+ class Word(NamedTuple):
22
+ """One word as the transcriber returned it, in seconds from the slice start."""
23
+
24
+ text: str
25
+ start: float
26
+ end: float
27
+
28
+
29
+ class Cue(NamedTuple):
30
+ id: str
31
+ start_ms: int
32
+ end_ms: int
33
+ text: str
34
+
35
+
36
+ def shift(slice_start_ms: int, seconds: float) -> int:
37
+ if not math.isfinite(seconds) or seconds < 0:
38
+ raise ValueError(f"word time must be a non-negative finite second, got {seconds}")
39
+ return slice_start_ms + math.floor(seconds * 1000 + 0.5)
40
+
41
+
42
+ def cue_id(index: int) -> str:
43
+ return f"c{index + 1:04d}"
44
+
45
+
46
+ def build_cues(results: list[tuple[Slice, list[Word]]]) -> list[Cue]:
47
+ """Keep each word that starts inside its slice's keep window, then group words into cues.
48
+
49
+ A cue closes on sentence punctuation, after MAX_CUE_MS, and where one speaker region ends.
50
+ """
51
+ cues: list[Cue] = []
52
+ bucket: list[tuple[int, int, str]] = []
53
+ bucket_region = -1
54
+
55
+ def close() -> None:
56
+ nonlocal bucket
57
+ if bucket:
58
+ text = " ".join(word for _, _, word in bucket)
59
+ cues.append(Cue(cue_id(len(cues)), bucket[0][0], bucket[-1][1], text))
60
+ bucket = []
61
+
62
+ for piece, words in results:
63
+ for word in words:
64
+ text = word.text.strip()
65
+ if not text:
66
+ continue
67
+ start = shift(piece.start_ms, word.start)
68
+ end = shift(piece.start_ms, word.end)
69
+ if end <= start:
70
+ end = start + 1
71
+ if not piece.keep_start_ms <= start < piece.keep_end_ms:
72
+ continue
73
+ if bucket and (piece.region != bucket_region or start - bucket[0][0] >= MAX_CUE_MS):
74
+ close()
75
+ bucket_region = piece.region
76
+ bucket.append((start, end, text))
77
+ if _SENTENCE_END.search(text):
78
+ close()
79
+ close()
80
+ return cues
81
+
82
+
83
+ def overlapping_speaker(turns: list[Turn], start_ms: int, end_ms: int) -> int | None:
84
+ best, best_overlap = None, 0
85
+ for turn in turns:
86
+ overlap = min(end_ms, turn.end_ms) - max(start_ms, turn.start_ms)
87
+ if overlap > best_overlap:
88
+ best, best_overlap = turn.speaker, overlap
89
+ return best
90
+
91
+
92
+ def cosine(left: list[float], right: list[float]) -> float:
93
+ dot = sum(a * b for a, b in zip(left, right))
94
+ norm = math.sqrt(sum(a * a for a in left)) * math.sqrt(sum(b * b for b in right))
95
+ return 0.0 if norm == 0 else dot / norm
96
+
97
+
98
+ def cue_rows(cues: list[Cue], tracks: Tracks) -> list[dict]:
99
+ """The `cues.json` rows. A voice's group id is `g` + its first cue id; no voice is `ungrouped`.
100
+
101
+ `nearestGroupId`/`nearestCosine` name the other group whose ECAPA centroid is most similar to
102
+ this cue's group. Ties go to the smaller group id. Both are null for `ungrouped` and when
103
+ there is only one group.
104
+ """
105
+ speakers = [overlapping_speaker(tracks.turns, cue.start_ms, cue.end_ms) for cue in cues]
106
+ group_of: dict[int, str] = {}
107
+ for cue, speaker in zip(cues, speakers):
108
+ if speaker is not None and speaker not in group_of:
109
+ group_of[speaker] = f"g{cue.id}"
110
+
111
+ nearest: dict[int, tuple[str | None, float | None]] = {}
112
+ for speaker, group_id in group_of.items():
113
+ best_id, best_score = None, None
114
+ for other, other_id in group_of.items():
115
+ if other == speaker:
116
+ continue
117
+ score = round(cosine(tracks.centroids[speaker], tracks.centroids[other]), 4)
118
+ if best_score is None or score > best_score or (score == best_score and other_id < best_id):
119
+ best_id, best_score = other_id, score
120
+ nearest[speaker] = (best_id, best_score)
121
+
122
+ rows = []
123
+ for cue, speaker in zip(cues, speakers):
124
+ group_id = UNGROUPED if speaker is None else group_of[speaker]
125
+ near_id, near_score = (None, None) if speaker is None else nearest[speaker]
126
+ rows.append({
127
+ "id": cue.id,
128
+ "startMs": cue.start_ms,
129
+ "endMs": cue.end_ms,
130
+ "text": cue.text,
131
+ "groupId": group_id,
132
+ "nearestGroupId": near_id,
133
+ "nearestCosine": near_score,
134
+ })
135
+ return rows
136
+
137
+
138
+ def group_table(rows: list[dict]) -> list[dict]:
139
+ """The `groups.json` rows, in order of each group's first cue.
140
+
141
+ `speakingMs` is the sum of the group's cue durations (endMs - startMs), the same measure
142
+ `labeledShare` uses.
143
+ """
144
+ groups: dict[str, dict] = {}
145
+ for row in rows:
146
+ group = groups.setdefault(row["groupId"], {
147
+ "groupId": row["groupId"],
148
+ "speakingMs": 0,
149
+ "cueCount": 0,
150
+ "nearestGroupId": row["nearestGroupId"],
151
+ "nearestCosine": row["nearestCosine"],
152
+ })
153
+ group["speakingMs"] += row["endMs"] - row["startMs"]
154
+ group["cueCount"] += 1
155
+ return list(groups.values())
156
+
157
+
158
+ def stamp(ms: int) -> str:
159
+ hours, rest = divmod(ms, 3_600_000)
160
+ minutes, rest = divmod(rest, 60_000)
161
+ seconds, millis = divmod(rest, 1_000)
162
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
163
+
164
+
165
+ def _escape(text: str) -> str:
166
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
167
+
168
+
169
+ def render_vtt(rows: list[dict]) -> str:
170
+ blocks = ["WEBVTT"]
171
+ for cue in rows:
172
+ text = _escape(cue["text"])
173
+ if cue["groupId"] != UNGROUPED:
174
+ text = f"<v {cue['groupId']}>{text}"
175
+ blocks.append(f"{cue['id']}\n{stamp(cue['startMs'])} --> {stamp(cue['endMs'])}\n{text}")
176
+ return "\n\n".join(blocks) + "\n"