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 +3 -0
- podcue/__main__.py +5 -0
- podcue/audio.py +58 -0
- podcue/cli.py +368 -0
- podcue/cues.py +176 -0
- podcue/diarize.py +327 -0
- podcue/fetch.py +130 -0
- podcue/groups.py +78 -0
- podcue/pipeline.py +130 -0
- podcue/recap.py +683 -0
- podcue/regions.py +78 -0
- podcue/store.py +154 -0
- podcue/summary.py +230 -0
- podcue/transcribe.py +110 -0
- podcue/weights.py +96 -0
- podcue-0.2.0.dist-info/METADATA +393 -0
- podcue-0.2.0.dist-info/RECORD +21 -0
- podcue-0.2.0.dist-info/WHEEL +4 -0
- podcue-0.2.0.dist-info/entry_points.txt +2 -0
- podcue-0.2.0.dist-info/licenses/LICENSE +21 -0
- podcue-0.2.0.dist-info/licenses/NOTICE +77 -0
podcue/__init__.py
ADDED
podcue/__main__.py
ADDED
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("&", "&").replace("<", "<").replace(">", ">")
|
|
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"
|