readalign 1.0.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.
- readalign/__init__.py +3 -0
- readalign/__main__.py +8 -0
- readalign/align.py +340 -0
- readalign/asr.py +277 -0
- readalign/audio.py +362 -0
- readalign/cli.py +495 -0
- readalign/clock.py +42 -0
- readalign/epub.py +671 -0
- readalign/errors.py +27 -0
- readalign/fs.py +22 -0
- readalign/members.py +72 -0
- readalign/package.py +287 -0
- readalign/refine.py +175 -0
- readalign/report.py +153 -0
- readalign/sentences.py +78 -0
- readalign/sidecar.py +142 -0
- readalign/smil.py +77 -0
- readalign/textnorm.py +158 -0
- readalign-1.0.0.dist-info/METADATA +332 -0
- readalign-1.0.0.dist-info/RECORD +23 -0
- readalign-1.0.0.dist-info/WHEEL +4 -0
- readalign-1.0.0.dist-info/entry_points.txt +2 -0
- readalign-1.0.0.dist-info/licenses/LICENSE +21 -0
readalign/report.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Build readalign-report.json and the short summary the CLI prints.
|
|
2
|
+
|
|
3
|
+
Everything a reader would want to check before trusting the output goes here: how much of the
|
|
4
|
+
book got timed, where the alignment drifted, and which chapters got no overlay at all.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from .align import CONFIDENT, AlignmentResult
|
|
15
|
+
from .audio import AudioTrack
|
|
16
|
+
from .epub import EpubPackage
|
|
17
|
+
from .fs import ensure_directory, write_text
|
|
18
|
+
|
|
19
|
+
REPORT_VERSION = 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class DocStat:
|
|
24
|
+
"""Per content document: how much of it ended up narrated."""
|
|
25
|
+
|
|
26
|
+
manifest_id: str
|
|
27
|
+
href: str
|
|
28
|
+
sentences: int
|
|
29
|
+
aligned: int
|
|
30
|
+
max_drift: float
|
|
31
|
+
start: float | None
|
|
32
|
+
end: float | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _percentile(values: list[float], fraction: float) -> float:
|
|
36
|
+
if not values:
|
|
37
|
+
return 0.0
|
|
38
|
+
ordered = sorted(values)
|
|
39
|
+
position = min(len(ordered) - 1, max(0, round(fraction * (len(ordered) - 1))))
|
|
40
|
+
return ordered[position]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def document_stats(package: EpubPackage, result: AlignmentResult) -> list[DocStat]:
|
|
44
|
+
timed_by_doc: dict[int, list] = {}
|
|
45
|
+
for entry in result.timed:
|
|
46
|
+
timed_by_doc.setdefault(entry.sentence.doc_index, []).append(entry)
|
|
47
|
+
stats = []
|
|
48
|
+
for index, doc in enumerate(package.docs):
|
|
49
|
+
entries = timed_by_doc.get(index, [])
|
|
50
|
+
stats.append(
|
|
51
|
+
DocStat(
|
|
52
|
+
manifest_id=doc.manifest_id,
|
|
53
|
+
href=doc.href,
|
|
54
|
+
sentences=len(doc.sentences),
|
|
55
|
+
aligned=len(entries),
|
|
56
|
+
max_drift=round(max((entry.drift for entry in entries), default=0.0), 3),
|
|
57
|
+
start=round(min(entry.start for entry in entries), 3) if entries else None,
|
|
58
|
+
end=round(max(entry.end for entry in entries), 3) if entries else None,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return stats
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_report(
|
|
65
|
+
package: EpubPackage,
|
|
66
|
+
result: AlignmentResult,
|
|
67
|
+
tracks: list[AudioTrack],
|
|
68
|
+
stats: list[DocStat],
|
|
69
|
+
*,
|
|
70
|
+
output: str,
|
|
71
|
+
asr_info: dict,
|
|
72
|
+
drift_threshold: float,
|
|
73
|
+
warnings: list[str],
|
|
74
|
+
extras: dict | None = None,
|
|
75
|
+
) -> dict:
|
|
76
|
+
drifts = [entry.drift for entry in result.timed]
|
|
77
|
+
total_sentences = sum(len(doc.sentences) for doc in package.docs)
|
|
78
|
+
max_drift = round(max(drifts, default=0.0), 3)
|
|
79
|
+
return {
|
|
80
|
+
"version": REPORT_VERSION,
|
|
81
|
+
"tool": "readalign",
|
|
82
|
+
"generated": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
83
|
+
"input": {
|
|
84
|
+
"epub": os.path.abspath(package.source),
|
|
85
|
+
"title": package.title(),
|
|
86
|
+
"epub_version": package.version,
|
|
87
|
+
"audio_files": [os.path.basename(track.source) for track in tracks],
|
|
88
|
+
"audio_duration": round(sum(track.duration for track in tracks), 3),
|
|
89
|
+
"transcoded": [
|
|
90
|
+
os.path.basename(track.source) for track in tracks if track.transcoded
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
"output": os.path.abspath(output),
|
|
94
|
+
"asr": asr_info,
|
|
95
|
+
"coverage": {
|
|
96
|
+
"sentences": total_sentences,
|
|
97
|
+
"aligned": len(result.timed),
|
|
98
|
+
"unaligned": total_sentences - len(result.timed),
|
|
99
|
+
"interpolated": sum(1 for entry in result.timed if entry.interpolated),
|
|
100
|
+
"low_confidence": sum(1 for entry in result.timed if entry.confidence < CONFIDENT),
|
|
101
|
+
"clamped_to_track_end": sum(1 for entry in result.timed if entry.clamped),
|
|
102
|
+
"trimmed_head": result.trimmed_head,
|
|
103
|
+
"trimmed_tail": result.trimmed_tail,
|
|
104
|
+
"token_match_rate": round(
|
|
105
|
+
result.matched_tokens / result.book_tokens if result.book_tokens else 0.0, 4
|
|
106
|
+
),
|
|
107
|
+
},
|
|
108
|
+
"drift": {
|
|
109
|
+
"threshold": drift_threshold,
|
|
110
|
+
"max": max_drift,
|
|
111
|
+
"p95": round(_percentile(drifts, 0.95), 3),
|
|
112
|
+
"mean": round(sum(drifts) / len(drifts), 3) if drifts else 0.0,
|
|
113
|
+
"within_threshold": max_drift <= drift_threshold,
|
|
114
|
+
},
|
|
115
|
+
"documents": [stat.__dict__ for stat in stats],
|
|
116
|
+
"documents_without_overlay": [stat.href for stat in stats if stat.aligned == 0],
|
|
117
|
+
"unaligned_regions": result.unaligned_regions,
|
|
118
|
+
"warnings": warnings,
|
|
119
|
+
**(extras or {}),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def write_report(path: str, report: dict) -> None:
|
|
124
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
125
|
+
ensure_directory(directory)
|
|
126
|
+
write_text(path, json.dumps(report, ensure_ascii=False, indent=2) + "\n")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def format_summary(report: dict) -> str:
|
|
130
|
+
coverage = report["coverage"]
|
|
131
|
+
drift = report["drift"]
|
|
132
|
+
missing = report["documents_without_overlay"]
|
|
133
|
+
lines = [
|
|
134
|
+
f" sentences {coverage['aligned']}/{coverage['sentences']} timed "
|
|
135
|
+
f"({coverage['aligned'] / max(coverage['sentences'], 1):.1%})",
|
|
136
|
+
f" interpolated {coverage['interpolated']}"
|
|
137
|
+
f" low confidence {coverage['low_confidence']}",
|
|
138
|
+
f" token match {coverage['token_match_rate']:.1%}",
|
|
139
|
+
f" drift max {drift['max']:.2f}s, p95 {drift['p95']:.2f}s, "
|
|
140
|
+
f"threshold {drift['threshold']:.2f}s",
|
|
141
|
+
]
|
|
142
|
+
if coverage["trimmed_head"] or coverage["trimmed_tail"]:
|
|
143
|
+
lines.append(
|
|
144
|
+
f" untimed matter {coverage['trimmed_head']} sentences before the narration, "
|
|
145
|
+
f"{coverage['trimmed_tail']} after"
|
|
146
|
+
)
|
|
147
|
+
if missing:
|
|
148
|
+
rest = f" and {len(missing) - 5} more" if len(missing) > 5 else ""
|
|
149
|
+
shown = ", ".join(missing[:5]) + rest
|
|
150
|
+
lines.append(f" no overlay {len(missing)} document(s): {shown}")
|
|
151
|
+
for warning in report["warnings"]:
|
|
152
|
+
lines.append(f" warning {warning}")
|
|
153
|
+
return "\n".join(lines)
|
readalign/sentences.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Sentence boundary detection for prose, good enough to drive a read-along highlight.
|
|
2
|
+
|
|
3
|
+
Deliberately dependency-free and conservative: a wrong split costs one mistimed highlight,
|
|
4
|
+
so the rules favour leaving two sentences joined over cutting an abbreviation in half.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
_ABBREVIATIONS = {
|
|
12
|
+
"mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "rev", "hon", "gen", "col", "capt",
|
|
13
|
+
"lieut", "lt", "sgt", "maj", "messrs", "mme", "mlle", "esq", "no", "vol", "chap", "fig",
|
|
14
|
+
"pp", "ed", "vs", "etc", "viz", "cf", "ibid", "al", "inc", "ltd", "co", "dept", "univ",
|
|
15
|
+
"jan", "feb", "mar", "apr", "jun", "jul", "aug", "sep", "sept", "oct", "nov", "dec",
|
|
16
|
+
"i.e", "e.g", "a.m", "p.m", "u.s", "u.k",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_TERMINATOR = re.compile(r"[.!?…]")
|
|
20
|
+
# Typographic quotes and dashes, deliberately: real ebooks are full of them.
|
|
21
|
+
_CLOSERS = "\"')]}’”»›*" # noqa: RUF001
|
|
22
|
+
_OPENERS = "\"'([{‘“«‹—–-" # noqa: RUF001
|
|
23
|
+
_WORD_BEFORE = re.compile(r"([\w.]+)$", re.UNICODE)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_abbreviation(text: str, position: int) -> bool:
|
|
27
|
+
match = _WORD_BEFORE.search(text[:position])
|
|
28
|
+
if not match:
|
|
29
|
+
return False
|
|
30
|
+
word = match[1].rstrip(".").casefold()
|
|
31
|
+
if word in _ABBREVIATIONS:
|
|
32
|
+
return True
|
|
33
|
+
# A single letter followed by a period is an initial ("J. Smith"), not a sentence end.
|
|
34
|
+
return len(word) == 1 and word.isalpha()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def split_sentences(text: str) -> list[tuple[int, int]]:
|
|
38
|
+
"""Return ``(start, end)`` character ranges of the sentences in ``text``.
|
|
39
|
+
|
|
40
|
+
Ranges exclude the whitespace between sentences and cover the whole string otherwise,
|
|
41
|
+
so callers can map a range straight back onto the markup it came from.
|
|
42
|
+
"""
|
|
43
|
+
if not text.strip():
|
|
44
|
+
return []
|
|
45
|
+
ranges: list[tuple[int, int]] = []
|
|
46
|
+
start = 0
|
|
47
|
+
position = 0
|
|
48
|
+
length = len(text)
|
|
49
|
+
while position < length:
|
|
50
|
+
char = text[position]
|
|
51
|
+
if not _TERMINATOR.match(char):
|
|
52
|
+
position += 1
|
|
53
|
+
continue
|
|
54
|
+
if char == "." and _is_abbreviation(text, position):
|
|
55
|
+
position += 1
|
|
56
|
+
continue
|
|
57
|
+
end = position + 1
|
|
58
|
+
while end < length and _TERMINATOR.match(text[end]):
|
|
59
|
+
end += 1
|
|
60
|
+
while end < length and text[end] in _CLOSERS:
|
|
61
|
+
end += 1
|
|
62
|
+
rest = text[end:]
|
|
63
|
+
if rest and not rest[0].isspace():
|
|
64
|
+
position = end
|
|
65
|
+
continue
|
|
66
|
+
following = rest.lstrip()
|
|
67
|
+
if following and not (following[0].isupper() or following[0] in _OPENERS):
|
|
68
|
+
position = end
|
|
69
|
+
continue
|
|
70
|
+
chunk = text[start:end]
|
|
71
|
+
if chunk.strip():
|
|
72
|
+
ranges.append((start + len(chunk) - len(chunk.lstrip()), end))
|
|
73
|
+
start = end
|
|
74
|
+
position = end
|
|
75
|
+
tail = text[start:]
|
|
76
|
+
if tail.strip():
|
|
77
|
+
ranges.append((start + len(tail) - len(tail.lstrip()), start + len(tail.rstrip())))
|
|
78
|
+
return ranges
|
readalign/sidecar.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Write the sidecars: WebVTT cues for players, and one flat align.json for tooling.
|
|
2
|
+
|
|
3
|
+
The EPUB is self-contained, but the same timings are useful outside it. WebVTT is per audio
|
|
4
|
+
file, because that is what a player loads next to a track, so a chapter spanning two files
|
|
5
|
+
produces two cue files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import posixpath
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
|
|
16
|
+
from .align import AlignmentResult, TimedSentence
|
|
17
|
+
from .audio import AudioTrack
|
|
18
|
+
from .fs import ensure_directory, write_text
|
|
19
|
+
|
|
20
|
+
SIDECAR_VERSION = 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def vtt_timestamp(seconds: float) -> str:
|
|
24
|
+
"""WebVTT needs HH:MM:SS.mmm, and unlike SMIL it wants the hours field zero-padded."""
|
|
25
|
+
milliseconds = max(0, round(seconds * 1000))
|
|
26
|
+
hours, rest = divmod(milliseconds, 3_600_000)
|
|
27
|
+
minutes, rest = divmod(rest, 60_000)
|
|
28
|
+
secs, millis = divmod(rest, 1000)
|
|
29
|
+
return f"{hours:02d}:{minutes:02d}:{secs:02d}.{millis:03d}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _cue_text(text: str) -> str:
|
|
33
|
+
collapsed = " ".join(text.split())
|
|
34
|
+
return collapsed.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_vtt(entries: list[TimedSentence], track: AudioTrack, audio_name: str) -> str:
|
|
38
|
+
"""One WebVTT file: cues in the audio file's own timeline, not the book's."""
|
|
39
|
+
lines = ["WEBVTT", "", f"NOTE audio {audio_name}", ""]
|
|
40
|
+
for entry in entries:
|
|
41
|
+
start = max(0.0, entry.start - track.offset)
|
|
42
|
+
end = max(start + 0.001, min(entry.end - track.offset, track.duration))
|
|
43
|
+
lines.append(entry.sentence.fragment_id)
|
|
44
|
+
lines.append(f"{vtt_timestamp(start)} --> {vtt_timestamp(end)}")
|
|
45
|
+
lines.append(_cue_text(entry.sentence.text))
|
|
46
|
+
lines.append("")
|
|
47
|
+
return "\n".join(lines)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_align_json(
|
|
51
|
+
result: AlignmentResult,
|
|
52
|
+
tracks: dict[int, AudioTrack],
|
|
53
|
+
doc_hrefs: dict[int, str],
|
|
54
|
+
audio_hrefs: dict[int, str],
|
|
55
|
+
*,
|
|
56
|
+
title: str | None,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""The whole alignment as one flat JSON document, in absolute book time."""
|
|
59
|
+
rows = []
|
|
60
|
+
for entry in result.timed:
|
|
61
|
+
track = tracks[entry.track_index]
|
|
62
|
+
rows.append(
|
|
63
|
+
{
|
|
64
|
+
"id": entry.sentence.fragment_id,
|
|
65
|
+
"document": doc_hrefs.get(entry.sentence.doc_index, ""),
|
|
66
|
+
"audio": audio_hrefs.get(entry.track_index, posixpath.basename(track.href)),
|
|
67
|
+
"start": round(entry.start, 3),
|
|
68
|
+
"end": round(entry.end, 3),
|
|
69
|
+
"clip_begin": round(max(0.0, entry.start - track.offset), 3),
|
|
70
|
+
"clip_end": round(max(0.0, entry.end - track.offset), 3),
|
|
71
|
+
"confidence": entry.confidence,
|
|
72
|
+
"interpolated": entry.interpolated,
|
|
73
|
+
"drift": round(entry.drift, 3),
|
|
74
|
+
"text": " ".join(entry.sentence.text.split()),
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
payload = {
|
|
78
|
+
"version": SIDECAR_VERSION,
|
|
79
|
+
"title": title,
|
|
80
|
+
"sentences": len(rows),
|
|
81
|
+
"audio": [
|
|
82
|
+
{
|
|
83
|
+
"index": index,
|
|
84
|
+
"href": audio_hrefs.get(index, posixpath.basename(track.href)),
|
|
85
|
+
"source": os.path.basename(track.source),
|
|
86
|
+
"offset": round(track.offset, 3),
|
|
87
|
+
"duration": round(track.duration, 3),
|
|
88
|
+
}
|
|
89
|
+
for index, track in sorted(tracks.items())
|
|
90
|
+
],
|
|
91
|
+
"alignment": rows,
|
|
92
|
+
}
|
|
93
|
+
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _clear_previous(directory: str) -> None:
|
|
97
|
+
"""Drop the sidecars of an earlier run: a re-run can name its files differently."""
|
|
98
|
+
for name in os.listdir(directory):
|
|
99
|
+
if name == "align.json" or name.endswith(".vtt"):
|
|
100
|
+
with contextlib.suppress(OSError):
|
|
101
|
+
os.unlink(os.path.join(directory, name))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def write_sidecars(
|
|
105
|
+
directory: str,
|
|
106
|
+
result: AlignmentResult,
|
|
107
|
+
tracks: list[AudioTrack],
|
|
108
|
+
doc_hrefs: dict[int, str],
|
|
109
|
+
audio_hrefs: dict[int, str],
|
|
110
|
+
*,
|
|
111
|
+
title: str | None = None,
|
|
112
|
+
) -> list[str]:
|
|
113
|
+
"""Write align.json plus one WebVTT per (document, audio file) pair. Returns the paths."""
|
|
114
|
+
by_index = {track.index: track for track in tracks}
|
|
115
|
+
ensure_directory(directory, "sidecar directory")
|
|
116
|
+
_clear_previous(directory)
|
|
117
|
+
|
|
118
|
+
grouped: dict[tuple[int, int], list[TimedSentence]] = defaultdict(list)
|
|
119
|
+
for entry in result.timed:
|
|
120
|
+
grouped[(entry.sentence.doc_index, entry.track_index)].append(entry)
|
|
121
|
+
|
|
122
|
+
written: list[str] = []
|
|
123
|
+
multi_track = {
|
|
124
|
+
doc_index
|
|
125
|
+
for doc_index in {key[0] for key in grouped}
|
|
126
|
+
if len({key[1] for key in grouped if key[0] == doc_index}) > 1
|
|
127
|
+
}
|
|
128
|
+
for (doc_index, track_index), entries in sorted(grouped.items()):
|
|
129
|
+
track = by_index[track_index]
|
|
130
|
+
href = doc_hrefs.get(doc_index, f"doc{doc_index}")
|
|
131
|
+
stem = posixpath.splitext(posixpath.basename(href))[0]
|
|
132
|
+
name = f"{stem}.part{track_index:03d}.vtt" if doc_index in multi_track else f"{stem}.vtt"
|
|
133
|
+
path = os.path.join(directory, name)
|
|
134
|
+
audio_name = audio_hrefs.get(track_index, posixpath.basename(track.href))
|
|
135
|
+
write_text(path, build_vtt(entries, track, audio_name))
|
|
136
|
+
written.append(path)
|
|
137
|
+
|
|
138
|
+
path = os.path.join(directory, "align.json")
|
|
139
|
+
write_text(path, build_align_json(result, by_index, doc_hrefs, audio_hrefs, title=title))
|
|
140
|
+
written.append(path)
|
|
141
|
+
return written
|
|
142
|
+
|
readalign/smil.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Write one media overlay document per content document.
|
|
2
|
+
|
|
3
|
+
Structure follows EPUB 3.3 section 9.2.2: a ``smil`` root at version 3.0, a ``body`` carrying
|
|
4
|
+
``par`` elements, and nested ``seq`` wrappers mirroring the structures a reading system may let
|
|
5
|
+
the user skip (a footnote, a page number) or escape out of (a table, a list, a figure).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import posixpath
|
|
11
|
+
|
|
12
|
+
from lxml import etree
|
|
13
|
+
|
|
14
|
+
from .align import TimedSentence
|
|
15
|
+
from .audio import AudioTrack
|
|
16
|
+
from .clock import format_clock
|
|
17
|
+
|
|
18
|
+
SMIL_NS = "http://www.w3.org/ns/SMIL"
|
|
19
|
+
EPUB_NS = "http://www.idpf.org/2007/ops"
|
|
20
|
+
|
|
21
|
+
_NSMAP = {None: SMIL_NS, "epub": EPUB_NS}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def relative_href(from_path: str, to_path: str) -> str:
|
|
25
|
+
"""Path from one archive member to another, as a path-relative-scheme-less-URL string."""
|
|
26
|
+
relative = posixpath.relpath(to_path, posixpath.dirname(from_path))
|
|
27
|
+
return relative.replace("\\", "/")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_smil(
|
|
31
|
+
smil_path: str,
|
|
32
|
+
doc_path: str,
|
|
33
|
+
entries: list[TimedSentence],
|
|
34
|
+
tracks: dict[int, AudioTrack],
|
|
35
|
+
audio_paths: dict[int, str],
|
|
36
|
+
) -> bytes:
|
|
37
|
+
"""Serialise the overlay for one content document."""
|
|
38
|
+
text_href = relative_href(smil_path, doc_path)
|
|
39
|
+
root = etree.Element(f"{{{SMIL_NS}}}smil", nsmap=_NSMAP)
|
|
40
|
+
root.set("version", "3.0")
|
|
41
|
+
body = etree.SubElement(root, f"{{{SMIL_NS}}}body")
|
|
42
|
+
body.set(f"{{{EPUB_NS}}}textref", text_href)
|
|
43
|
+
|
|
44
|
+
# Open seqs, innermost last. Nesting has to mirror the document for escapability to work,
|
|
45
|
+
# and entries arrive in document order, so a structure is left as soon as its ids stop matching.
|
|
46
|
+
open_seqs: list[tuple[str, etree._Element]] = []
|
|
47
|
+
for entry in entries:
|
|
48
|
+
chain = entry.sentence.structure
|
|
49
|
+
shared = 0
|
|
50
|
+
while (
|
|
51
|
+
shared < len(open_seqs)
|
|
52
|
+
and shared < len(chain)
|
|
53
|
+
and open_seqs[shared][0] == chain[shared].container_id
|
|
54
|
+
):
|
|
55
|
+
shared += 1
|
|
56
|
+
del open_seqs[shared:]
|
|
57
|
+
for level in chain[shared:]:
|
|
58
|
+
parent = open_seqs[-1][1] if open_seqs else body
|
|
59
|
+
seq = etree.SubElement(parent, f"{{{SMIL_NS}}}seq")
|
|
60
|
+
seq.set(f"{{{EPUB_NS}}}textref", f"{text_href}#{level.container_id}")
|
|
61
|
+
seq.set(f"{{{EPUB_NS}}}type", level.epub_type)
|
|
62
|
+
open_seqs.append((level.container_id, seq))
|
|
63
|
+
container = open_seqs[-1][1] if open_seqs else body
|
|
64
|
+
par = etree.SubElement(container, f"{{{SMIL_NS}}}par")
|
|
65
|
+
text = etree.SubElement(par, f"{{{SMIL_NS}}}text")
|
|
66
|
+
text.set("src", f"{text_href}#{entry.sentence.fragment_id}")
|
|
67
|
+
track = tracks[entry.track_index]
|
|
68
|
+
audio = etree.SubElement(par, f"{{{SMIL_NS}}}audio")
|
|
69
|
+
audio.set("src", relative_href(smil_path, audio_paths[entry.track_index]))
|
|
70
|
+
audio.set("clipBegin", format_clock(entry.start - track.offset))
|
|
71
|
+
audio.set("clipEnd", format_clock(entry.end - track.offset))
|
|
72
|
+
return etree.tostring(root, xml_declaration=True, encoding="utf-8", pretty_print=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def overlay_duration(entries: list[TimedSentence]) -> float:
|
|
76
|
+
"""Playing time of an overlay: the sum of its clips, which is what media:duration means."""
|
|
77
|
+
return sum(max(0.0, entry.end - entry.start) for entry in entries)
|
readalign/textnorm.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Turn raw words from an ebook or an ASR transcript into comparable tokens.
|
|
2
|
+
|
|
3
|
+
Both sides go through :func:`tokenize_word`, so the aligner only ever compares like with like:
|
|
4
|
+
case folded, accent stripped, punctuation removed, numbers and roman numerals spelled out.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import unicodedata
|
|
11
|
+
|
|
12
|
+
_UNITS = [
|
|
13
|
+
"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
|
|
14
|
+
"ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen",
|
|
15
|
+
"seventeen", "eighteen", "nineteen",
|
|
16
|
+
]
|
|
17
|
+
_TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"]
|
|
18
|
+
_SCALES = [(1_000_000_000, "billion"), (1_000_000, "million"), (1_000, "thousand")]
|
|
19
|
+
|
|
20
|
+
_ORDINALS = {
|
|
21
|
+
"one": "first",
|
|
22
|
+
"two": "second",
|
|
23
|
+
"three": "third",
|
|
24
|
+
"five": "fifth",
|
|
25
|
+
"eight": "eighth",
|
|
26
|
+
"nine": "ninth",
|
|
27
|
+
"twelve": "twelfth",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_ROMAN_VALUES = {"i": 1, "v": 5, "x": 10, "l": 50, "c": 100, "d": 500, "m": 1000}
|
|
31
|
+
_ROMAN_RE = re.compile(r"^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$")
|
|
32
|
+
_ORDINAL_SUFFIX_RE = re.compile(r"^(\d+)(?:st|nd|rd|th)$")
|
|
33
|
+
_DIGITS_RE = re.compile(r"\d")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _small_number_words(value: int) -> list[str]:
|
|
37
|
+
if value < 20:
|
|
38
|
+
return [_UNITS[value]]
|
|
39
|
+
if value < 100:
|
|
40
|
+
tens, unit = divmod(value, 10)
|
|
41
|
+
words = [_TENS[tens]]
|
|
42
|
+
if unit:
|
|
43
|
+
words.append(_UNITS[unit])
|
|
44
|
+
return words
|
|
45
|
+
hundreds, rest = divmod(value, 100)
|
|
46
|
+
words = [_UNITS[hundreds], "hundred"]
|
|
47
|
+
if rest:
|
|
48
|
+
words.extend(_small_number_words(rest))
|
|
49
|
+
return words
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def number_words(value: int) -> list[str]:
|
|
53
|
+
"""Spell an integer in English, e.g. 1207 -> ['one', 'thousand', 'two', 'hundred', 'seven']."""
|
|
54
|
+
if value < 0:
|
|
55
|
+
return ["minus", *number_words(-value)]
|
|
56
|
+
if value == 0:
|
|
57
|
+
return ["zero"]
|
|
58
|
+
words: list[str] = []
|
|
59
|
+
remainder = value
|
|
60
|
+
for scale, name in _SCALES:
|
|
61
|
+
count, remainder = divmod(remainder, scale)
|
|
62
|
+
if count:
|
|
63
|
+
words.extend([*number_words(count), name])
|
|
64
|
+
if remainder:
|
|
65
|
+
words.extend(_small_number_words(remainder))
|
|
66
|
+
return words
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _year_words(value: int) -> list[str]:
|
|
70
|
+
"""Read a four-digit year the way a narrator does: 1797 -> seventeen ninety seven."""
|
|
71
|
+
high, low = divmod(value, 100)
|
|
72
|
+
if low == 0:
|
|
73
|
+
return [*_small_number_words(high), "hundred"]
|
|
74
|
+
if low < 10:
|
|
75
|
+
return [*_small_number_words(high), "oh", _UNITS[low]]
|
|
76
|
+
return [*_small_number_words(high), *_small_number_words(low)]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ordinal_words(value: int) -> list[str]:
|
|
80
|
+
words = number_words(value)
|
|
81
|
+
last = words[-1]
|
|
82
|
+
if last in _ORDINALS:
|
|
83
|
+
words[-1] = _ORDINALS[last]
|
|
84
|
+
elif last.endswith("y"):
|
|
85
|
+
words[-1] = last[:-1] + "ieth"
|
|
86
|
+
else:
|
|
87
|
+
words[-1] = last + "th"
|
|
88
|
+
return words
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def roman_value(token: str) -> int | None:
|
|
92
|
+
"""Value of a canonical roman numeral, or None if the token is not one."""
|
|
93
|
+
lowered = token.lower()
|
|
94
|
+
if len(lowered) < 2 or not _ROMAN_RE.match(token.upper()):
|
|
95
|
+
return None
|
|
96
|
+
total = 0
|
|
97
|
+
previous = 0
|
|
98
|
+
for char in reversed(lowered):
|
|
99
|
+
value = _ROMAN_VALUES[char]
|
|
100
|
+
total += value if value >= previous else -value
|
|
101
|
+
previous = max(previous, value)
|
|
102
|
+
return total
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _strip_accents(text: str) -> str:
|
|
106
|
+
decomposed = unicodedata.normalize("NFKD", text)
|
|
107
|
+
return "".join(char for char in decomposed if not unicodedata.combining(char))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def tokenize_word(raw: str) -> list[str]:
|
|
111
|
+
"""Normalise one whitespace-delimited word into zero or more comparison tokens."""
|
|
112
|
+
if not raw:
|
|
113
|
+
return []
|
|
114
|
+
# Roman numerals are only recognised as written in the source, before case folding,
|
|
115
|
+
# so that the pronoun "I" and ordinary lowercase words are never rewritten as numbers.
|
|
116
|
+
stripped = re.sub(r"[^\w]", "", _strip_accents(raw), flags=re.UNICODE)
|
|
117
|
+
if not stripped:
|
|
118
|
+
return []
|
|
119
|
+
if stripped.isupper():
|
|
120
|
+
value = roman_value(stripped)
|
|
121
|
+
if value is not None:
|
|
122
|
+
return number_words(value)
|
|
123
|
+
folded = stripped.casefold()
|
|
124
|
+
if _DIGITS_RE.search(folded):
|
|
125
|
+
return _number_tokens(folded)
|
|
126
|
+
return [folded] if folded else []
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _number_tokens(folded: str) -> list[str]:
|
|
130
|
+
ordinal = _ORDINAL_SUFFIX_RE.match(folded)
|
|
131
|
+
if ordinal:
|
|
132
|
+
return _ordinal_words(int(ordinal[1]))
|
|
133
|
+
if folded.isdigit():
|
|
134
|
+
value = int(folded)
|
|
135
|
+
if len(folded) == 4 and 1100 <= value <= 2099:
|
|
136
|
+
return _year_words(value)
|
|
137
|
+
return number_words(value)
|
|
138
|
+
# Mixed letters and digits (a chapter label like "12a", a model number): split the runs.
|
|
139
|
+
tokens: list[str] = []
|
|
140
|
+
for part in re.findall(r"\d+|[^\d]+", folded):
|
|
141
|
+
tokens.extend(number_words(int(part)) if part.isdigit() else [part])
|
|
142
|
+
return tokens
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def tokenize_words(raw_words: list[str]) -> tuple[list[str], list[int]]:
|
|
146
|
+
"""Tokenise a word list, returning the tokens and the source index each token came from."""
|
|
147
|
+
tokens: list[str] = []
|
|
148
|
+
origins: list[int] = []
|
|
149
|
+
for index, raw in enumerate(raw_words):
|
|
150
|
+
for token in tokenize_word(raw):
|
|
151
|
+
tokens.append(token)
|
|
152
|
+
origins.append(index)
|
|
153
|
+
return tokens, origins
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def split_raw_words(text: str) -> list[str]:
|
|
157
|
+
"""Split running text into raw words on whitespace."""
|
|
158
|
+
return text.split()
|