kitten-text-processing 0.1.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.
- kitten_text_processing/__init__.py +5 -0
- kitten_text_processing/__main__.py +23 -0
- kitten_text_processing/_detokenize.py +62 -0
- kitten_text_processing/_fst.py +304 -0
- kitten_text_processing/_ordering.py +126 -0
- kitten_text_processing/_punctuation.py +112 -0
- kitten_text_processing/_token_parser.py +193 -0
- kitten_text_processing/_unicode_data.py +6 -0
- kitten_text_processing/data/ar/metadata.json +20 -0
- kitten_text_processing/data/ar/tagger.fst.gz +0 -0
- kitten_text_processing/data/ar/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/de/metadata.json +20 -0
- kitten_text_processing/data/de/tagger.fst.gz +0 -0
- kitten_text_processing/data/de/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/en/metadata.json +26 -0
- kitten_text_processing/data/en/post.fst.gz +0 -0
- kitten_text_processing/data/en/tagger.fst.gz +0 -0
- kitten_text_processing/data/en/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/es/metadata.json +20 -0
- kitten_text_processing/data/es/tagger.fst.gz +0 -0
- kitten_text_processing/data/es/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/fr/metadata.json +20 -0
- kitten_text_processing/data/fr/tagger.fst.gz +0 -0
- kitten_text_processing/data/fr/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/hi/metadata.json +26 -0
- kitten_text_processing/data/hi/post.fst.gz +0 -0
- kitten_text_processing/data/hi/tagger.fst.gz +0 -0
- kitten_text_processing/data/hi/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/hu/metadata.json +20 -0
- kitten_text_processing/data/hu/tagger.fst.gz +0 -0
- kitten_text_processing/data/hu/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/hy/metadata.json +24 -0
- kitten_text_processing/data/hy/tagger.fst.gz +0 -0
- kitten_text_processing/data/hy/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/it/metadata.json +20 -0
- kitten_text_processing/data/it/tagger.fst.gz +0 -0
- kitten_text_processing/data/it/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/ja/metadata.json +20 -0
- kitten_text_processing/data/ja/tagger.fst.gz +0 -0
- kitten_text_processing/data/ja/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/ko/metadata.json +24 -0
- kitten_text_processing/data/ko/tagger.fst.gz +0 -0
- kitten_text_processing/data/ko/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/pt/metadata.json +20 -0
- kitten_text_processing/data/pt/tagger.fst.gz +0 -0
- kitten_text_processing/data/pt/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/ru/metadata.json +20 -0
- kitten_text_processing/data/ru/tagger.fst.gz +0 -0
- kitten_text_processing/data/ru/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/rw/metadata.json +20 -0
- kitten_text_processing/data/rw/tagger.fst.gz +0 -0
- kitten_text_processing/data/rw/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/sv/metadata.json +20 -0
- kitten_text_processing/data/sv/tagger.fst.gz +0 -0
- kitten_text_processing/data/sv/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/vi/metadata.json +26 -0
- kitten_text_processing/data/vi/post.fst.gz +0 -0
- kitten_text_processing/data/vi/tagger.fst.gz +0 -0
- kitten_text_processing/data/vi/verbalizer.fst.gz +0 -0
- kitten_text_processing/data/zh/metadata.json +20 -0
- kitten_text_processing/data/zh/tagger.fst.gz +0 -0
- kitten_text_processing/data/zh/verbalizer.fst.gz +0 -0
- kitten_text_processing/normalizer.py +114 -0
- kitten_text_processing-0.1.0.dist-info/METADATA +132 -0
- kitten_text_processing-0.1.0.dist-info/RECORD +71 -0
- kitten_text_processing-0.1.0.dist-info/WHEEL +5 -0
- kitten_text_processing-0.1.0.dist-info/entry_points.txt +2 -0
- kitten_text_processing-0.1.0.dist-info/licenses/LICENSE +201 -0
- kitten_text_processing-0.1.0.dist-info/licenses/LICENSE-SACREMOSES +21 -0
- kitten_text_processing-0.1.0.dist-info/licenses/NOTICE +26 -0
- kitten_text_processing-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""Dependency-free multilingual text normalization for speech synthesis."""
|
|
2
|
+
from .normalizer import Normalizer, PRESERVES_SENTINELS, SUPPORTED_LANGUAGES, normalize_text, warm
|
|
3
|
+
|
|
4
|
+
__version__ = '0.1.0'
|
|
5
|
+
__all__ = ['Normalizer', 'PRESERVES_SENTINELS', 'SUPPORTED_LANGUAGES', 'normalize_text', 'warm']
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Command-line interface: python -m kitten_text_processing --lang es 'Tengo 2 gatos.'"""
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
from . import SUPPORTED_LANGUAGES, normalize_text
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
parser = argparse.ArgumentParser(description='Normalize written text for speech; no downloads or dependencies.')
|
|
9
|
+
parser.add_argument('text', nargs='?', help='Text to normalize; otherwise read stdin line by line')
|
|
10
|
+
parser.add_argument('--lang', default='en', help='Language code or locale (' + ', '.join(SUPPORTED_LANGUAGES) + ')')
|
|
11
|
+
args = parser.parse_args()
|
|
12
|
+
try:
|
|
13
|
+
if args.text is not None:
|
|
14
|
+
print(normalize_text(args.text, locale=args.lang))
|
|
15
|
+
else:
|
|
16
|
+
for line in sys.stdin:
|
|
17
|
+
print(normalize_text(line.rstrip('\n'), locale=args.lang))
|
|
18
|
+
except (ValueError, RuntimeError) as exc:
|
|
19
|
+
parser.exit(1, f'{exc}\n')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == '__main__':
|
|
23
|
+
main()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Moses-compatible punctuation handling for the supported TN languages.
|
|
2
|
+
|
|
3
|
+
Reimplemented using only re and Unicode character properties. NeMo calls Moses
|
|
4
|
+
with a single string in a list, which is then split on whitespace.
|
|
5
|
+
"""
|
|
6
|
+
import re
|
|
7
|
+
from bisect import bisect_right
|
|
8
|
+
from ._unicode_data import ALPHA_RANGES, CURRENCY_SYMBOLS
|
|
9
|
+
|
|
10
|
+
_ALPHA_STARTS = tuple(a for a, _ in ALPHA_RANGES)
|
|
11
|
+
|
|
12
|
+
def _alpha(character):
|
|
13
|
+
value = ord(character)
|
|
14
|
+
i = bisect_right(_ALPHA_STARTS, value) - 1
|
|
15
|
+
return i >= 0 and value <= ALPHA_RANGES[i][1]
|
|
16
|
+
|
|
17
|
+
_CJK = ((4352, 4607), (11904, 42191), (43072, 43135), (44032, 55215),
|
|
18
|
+
(63744, 64255), (65072, 65103), (65381, 65500), (94208, 101119),
|
|
19
|
+
(110592, 110895), (110960, 111359), (131072, 196607))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _cjk(c):
|
|
23
|
+
return any(a <= ord(c) <= b for a, b in _CJK)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def detokenize(text, lang):
|
|
27
|
+
tokens = re.sub(r' @-@ ', '-', ' ' + text + ' ').split()
|
|
28
|
+
quotes = {}
|
|
29
|
+
space, result = ' ', ''
|
|
30
|
+
for i, token in enumerate(tokens):
|
|
31
|
+
if _cjk(token[0]) and lang != 'ko':
|
|
32
|
+
result += ('' if i and _cjk(tokens[i-1][-1]) else space) + token
|
|
33
|
+
space = ' '
|
|
34
|
+
elif all(c in CURRENCY_SYMBOLS or c in '([{¿¡' for c in token):
|
|
35
|
+
result += space + token
|
|
36
|
+
space = ''
|
|
37
|
+
elif re.fullmatch(r'[,\.?!:;\\%}\])]+', token):
|
|
38
|
+
result += (' ' if lang == 'fr' and re.fullmatch(r'[?!:;\\%]', token) else '') + token
|
|
39
|
+
space = ' '
|
|
40
|
+
elif lang == 'en' and i and len(token) > 1 and token[0] == "'" and _alpha(token[1]):
|
|
41
|
+
result += token
|
|
42
|
+
space = ' '
|
|
43
|
+
elif (lang in ('fr', 'it') and i+1 < len(tokens) and len(token) > 1
|
|
44
|
+
and token[-1] == "'" and _alpha(token[-2]) and _alpha(tokens[i+1][0])):
|
|
45
|
+
result += space + token
|
|
46
|
+
space = ''
|
|
47
|
+
elif re.fullmatch('[\'"„“`]+', token):
|
|
48
|
+
key = '"' if all(c in '„“”' for c in token) else token
|
|
49
|
+
count = quotes.get(key, 0)
|
|
50
|
+
if count % 2 or (lang == 'en' and token == "'" and i and tokens[i-1].endswith('s')):
|
|
51
|
+
result += token
|
|
52
|
+
space = ' '
|
|
53
|
+
if count % 2:
|
|
54
|
+
quotes[key] = count + 1
|
|
55
|
+
else:
|
|
56
|
+
result += space + token
|
|
57
|
+
space = ''
|
|
58
|
+
quotes[key] = count + 1
|
|
59
|
+
else:
|
|
60
|
+
result += space + token
|
|
61
|
+
space = ' '
|
|
62
|
+
return re.sub(' {2,}', ' ', result).strip()
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Byte-level tropical weighted transducers, implemented with the standard library.
|
|
2
|
+
|
|
3
|
+
Graph data is immutable. A frontier decoder handles unambiguous rewrites; tied
|
|
4
|
+
outputs use a trimmed lattice and OpenFst-compatible topological ordering.
|
|
5
|
+
Weights are rounded to float32 at each addition, as in the source grammars.
|
|
6
|
+
No beam pruning or vocabulary approximations are used.
|
|
7
|
+
"""
|
|
8
|
+
from array import array
|
|
9
|
+
from collections import deque
|
|
10
|
+
import gzip
|
|
11
|
+
import math
|
|
12
|
+
import struct
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NoPathError(ValueError):
|
|
17
|
+
"""The grammar has no accepting path for this input."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Graph:
|
|
21
|
+
def __init__(self, path, *, project_output=False):
|
|
22
|
+
self.project_output = project_output
|
|
23
|
+
with gzip.open(path, 'rb') as source:
|
|
24
|
+
magic, self.start, states, arcs = struct.unpack('<8sIII', source.read(20))
|
|
25
|
+
if magic != b'KITTEN1\0':
|
|
26
|
+
raise ValueError(f'Invalid grammar: {path}')
|
|
27
|
+
for name, kind, size in [('offsets', 'I', states + 1), ('finals', 'f', states),
|
|
28
|
+
('labels', 'I', arcs), ('targets', 'I', arcs), ('weights', 'f', arcs)]:
|
|
29
|
+
values = array(kind)
|
|
30
|
+
values.frombytes(source.read(size * 4))
|
|
31
|
+
if len(values) != size:
|
|
32
|
+
raise ValueError(f'Truncated grammar: {path}')
|
|
33
|
+
if sys.byteorder != 'little':
|
|
34
|
+
values.byteswap()
|
|
35
|
+
setattr(self, name, values)
|
|
36
|
+
self._index = {}
|
|
37
|
+
|
|
38
|
+
def arcs(self, state):
|
|
39
|
+
index = self._index.get(state)
|
|
40
|
+
if index is None:
|
|
41
|
+
incoming = getattr(self, '_indegree', None)
|
|
42
|
+
if incoming is None:
|
|
43
|
+
incoming = bytearray(len(self.finals))
|
|
44
|
+
for target in self.targets:
|
|
45
|
+
if incoming[target] < 2:
|
|
46
|
+
incoming[target] += 1
|
|
47
|
+
self._indegree = incoming
|
|
48
|
+
index = {}
|
|
49
|
+
for i in range(self.offsets[state], self.offsets[state + 1]):
|
|
50
|
+
label = self.labels[i]
|
|
51
|
+
target = self.targets[i]
|
|
52
|
+
suffix = bytearray([label >> 8]) if label >> 8 else bytearray()
|
|
53
|
+
# Contract deterministic zero-cost output-only chains. Do not
|
|
54
|
+
# merge nonzero weights: float32 addition is not associative.
|
|
55
|
+
# Never contract joins: their order resolves equal-weight paths.
|
|
56
|
+
visited = set()
|
|
57
|
+
while (not getattr(self, 'project_output', False) and incoming[target] == 1
|
|
58
|
+
and not math.isfinite(self.finals[target]) and target not in visited):
|
|
59
|
+
start, end = self.offsets[target], self.offsets[target + 1]
|
|
60
|
+
if end != start + 1 or self.labels[start] & 255 or self.weights[start]:
|
|
61
|
+
break
|
|
62
|
+
# Keep the predecessor of a join. Contracting it could
|
|
63
|
+
# turn separate paths into parallel arcs and reverse the
|
|
64
|
+
# equal-weight winner (arc order versus topological order).
|
|
65
|
+
if incoming[self.targets[start]] != 1:
|
|
66
|
+
break
|
|
67
|
+
visited.add(target)
|
|
68
|
+
output = self.labels[start] >> 8
|
|
69
|
+
if output:
|
|
70
|
+
suffix.append(output)
|
|
71
|
+
target = self.targets[start]
|
|
72
|
+
index.setdefault(label & 255, []).append((target, self.weights[i], bytes(suffix)))
|
|
73
|
+
# Bound auxiliary memory; graph arrays remain the source of truth.
|
|
74
|
+
if len(self._index) >= 50000:
|
|
75
|
+
self._index.clear()
|
|
76
|
+
self._index[state] = index
|
|
77
|
+
return index
|
|
78
|
+
|
|
79
|
+
def rewrite(self, text):
|
|
80
|
+
# Most inputs have one best output. Keep only the current input frontier
|
|
81
|
+
# there; build a full lattice only when equal-cost output strings differ.
|
|
82
|
+
# Pynini compiles NUL bytes as epsilon labels, so they consume no input.
|
|
83
|
+
text = text.replace('\x00', '')
|
|
84
|
+
if getattr(self, 'project_output', False):
|
|
85
|
+
return self._rewrite_ordered(text)
|
|
86
|
+
active = {self.start: (0.0, b'', False)}
|
|
87
|
+
pack, unpack = struct.Struct('<f').pack, struct.Struct('<f').unpack
|
|
88
|
+
for char in (*text.encode('utf-8'), None):
|
|
89
|
+
queue = deque(active)
|
|
90
|
+
queued = set(active)
|
|
91
|
+
while queue:
|
|
92
|
+
state = queue.popleft()
|
|
93
|
+
queued.discard(state)
|
|
94
|
+
cost, output, ambiguous = active[state]
|
|
95
|
+
for target, weight, suffix in self.arcs(state).get(0, ()):
|
|
96
|
+
candidate = unpack(pack(cost + weight))[0] if weight else cost
|
|
97
|
+
old = active.get(target)
|
|
98
|
+
value = output + suffix
|
|
99
|
+
if old is None or candidate < old[0]:
|
|
100
|
+
active[target] = (candidate, value, ambiguous)
|
|
101
|
+
elif candidate == old[0] and not old[2] and (ambiguous or value != old[1]):
|
|
102
|
+
active[target] = (old[0], old[1], True)
|
|
103
|
+
else:
|
|
104
|
+
continue
|
|
105
|
+
if target not in queued:
|
|
106
|
+
queue.append(target)
|
|
107
|
+
queued.add(target)
|
|
108
|
+
if char is None:
|
|
109
|
+
best = None
|
|
110
|
+
for state, (cost, output, ambiguous) in active.items():
|
|
111
|
+
final = self.finals[state]
|
|
112
|
+
if not math.isfinite(final):
|
|
113
|
+
continue
|
|
114
|
+
candidate = unpack(pack(cost + final))[0]
|
|
115
|
+
if best is None or candidate < best[0]:
|
|
116
|
+
best = (candidate, output, ambiguous)
|
|
117
|
+
elif candidate == best[0] and (ambiguous or output != best[1]):
|
|
118
|
+
best = (best[0], best[1], True)
|
|
119
|
+
if best is None:
|
|
120
|
+
raise NoPathError(f'No accepting path for {text[:80]!r}')
|
|
121
|
+
if best[2]:
|
|
122
|
+
return self._rewrite_ordered(text)
|
|
123
|
+
return best[1].decode('utf-8')
|
|
124
|
+
following = {}
|
|
125
|
+
for state, (cost, output, ambiguous) in active.items():
|
|
126
|
+
for target, weight, suffix in self.arcs(state).get(char, ()):
|
|
127
|
+
candidate = unpack(pack(cost + weight))[0] if weight else cost
|
|
128
|
+
old = following.get(target)
|
|
129
|
+
value = output + suffix
|
|
130
|
+
if old is None or candidate < old[0]:
|
|
131
|
+
following[target] = (candidate, value, ambiguous)
|
|
132
|
+
elif candidate == old[0] and not old[2] and (ambiguous or value != old[1]):
|
|
133
|
+
following[target] = (old[0], old[1], True)
|
|
134
|
+
if not following:
|
|
135
|
+
raise NoPathError(f'No accepting path for {text[:80]!r}')
|
|
136
|
+
active = following
|
|
137
|
+
|
|
138
|
+
def _rewrite_ordered(self, text):
|
|
139
|
+
data = text.encode('utf-8')
|
|
140
|
+
length = len(data)
|
|
141
|
+
start = (0, self.start)
|
|
142
|
+
adjacency = {}
|
|
143
|
+
|
|
144
|
+
def edges(node):
|
|
145
|
+
position, state = node
|
|
146
|
+
arcs = self.arcs(state)
|
|
147
|
+
out = [((position, target), weight, suffix) for target, weight, suffix in arcs.get(0, ())]
|
|
148
|
+
if position < length:
|
|
149
|
+
out.extend(((position + 1, target), weight, suffix)
|
|
150
|
+
for target, weight, suffix in arcs.get(data[position], ()))
|
|
151
|
+
adjacency[node] = out
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
# Reverse DFS finishing order matches OpenFst's topological queue. In
|
|
155
|
+
# particular, the order of equal-weight readings is not arbitrary.
|
|
156
|
+
seen = {start}
|
|
157
|
+
visiting = {start}
|
|
158
|
+
stack = [(start, iter(edges(start)))]
|
|
159
|
+
ordered = []
|
|
160
|
+
live = set()
|
|
161
|
+
cyclic = False
|
|
162
|
+
while stack:
|
|
163
|
+
node, iterator = stack[-1]
|
|
164
|
+
edge = next(iterator, None)
|
|
165
|
+
if edge is None:
|
|
166
|
+
ordered.append(node)
|
|
167
|
+
if ((node[0] == length and math.isfinite(self.finals[node[1]]))
|
|
168
|
+
or any(target in live for target, _, _ in adjacency[node])):
|
|
169
|
+
live.add(node)
|
|
170
|
+
visiting.remove(node)
|
|
171
|
+
stack.pop()
|
|
172
|
+
continue
|
|
173
|
+
target = edge[0]
|
|
174
|
+
if target in visiting:
|
|
175
|
+
cyclic = True
|
|
176
|
+
if target not in seen:
|
|
177
|
+
seen.add(target)
|
|
178
|
+
visiting.add(target)
|
|
179
|
+
stack.append((target, iter(edges(target))))
|
|
180
|
+
# Composition in OpenFst trims states that cannot reach a final state.
|
|
181
|
+
# Some grammars contain dead epsilon cycles, so trim before decoding.
|
|
182
|
+
if cyclic:
|
|
183
|
+
# Back edges can hide productive exits until their SCC is complete.
|
|
184
|
+
# Finish liveness by reverse reachability only for cyclic lattices.
|
|
185
|
+
reverse = {}
|
|
186
|
+
for node, outgoing in adjacency.items():
|
|
187
|
+
for target, _, _ in outgoing:
|
|
188
|
+
reverse.setdefault(target, []).append(node)
|
|
189
|
+
pending = list(live)
|
|
190
|
+
while pending:
|
|
191
|
+
for parent in reverse.get(pending.pop(), ()):
|
|
192
|
+
if parent not in live:
|
|
193
|
+
live.add(parent)
|
|
194
|
+
pending.append(parent)
|
|
195
|
+
if start not in live:
|
|
196
|
+
raise NoPathError(f'No accepting path for {text[:80]!r}')
|
|
197
|
+
final_weights = {node: self.finals[node[1]] for node in live if node[0] == length}
|
|
198
|
+
if getattr(self, 'project_output', False):
|
|
199
|
+
adjacency, final_weights, ordered = self._project_lattice(adjacency, final_weights, ordered, live, start)
|
|
200
|
+
live = set(ordered)
|
|
201
|
+
costs = {start: 0.0}
|
|
202
|
+
parents = {}
|
|
203
|
+
best, final = float('inf'), None
|
|
204
|
+
pack, unpack = struct.Struct('<f').pack, struct.Struct('<f').unpack
|
|
205
|
+
pending = deque(node for node in reversed(ordered) if node in live)
|
|
206
|
+
queued = set(pending)
|
|
207
|
+
while pending:
|
|
208
|
+
node = pending.popleft()
|
|
209
|
+
queued.remove(node)
|
|
210
|
+
cost = costs.get(node, float('inf'))
|
|
211
|
+
if not math.isfinite(cost):
|
|
212
|
+
continue
|
|
213
|
+
final_weight = final_weights.get(node, math.inf)
|
|
214
|
+
if math.isfinite(final_weight):
|
|
215
|
+
candidate = unpack(pack(cost + final_weight))[0]
|
|
216
|
+
if candidate < best:
|
|
217
|
+
best, final = candidate, node
|
|
218
|
+
for target, weight, suffix in adjacency[node]:
|
|
219
|
+
if target not in live:
|
|
220
|
+
continue
|
|
221
|
+
candidate = unpack(pack(cost + weight))[0] if weight else cost
|
|
222
|
+
if candidate < costs.get(target, float('inf')):
|
|
223
|
+
costs[target] = candidate
|
|
224
|
+
parents[target] = (node, suffix)
|
|
225
|
+
# Usually the lattice is a DAG. Requeue an already visited
|
|
226
|
+
# state if a live epsilon cycle improves its distance.
|
|
227
|
+
if target not in queued:
|
|
228
|
+
pending.append(target)
|
|
229
|
+
queued.add(target)
|
|
230
|
+
if final is None:
|
|
231
|
+
raise NoPathError(f'No accepting path for {text[:80]!r}')
|
|
232
|
+
result = []
|
|
233
|
+
while final != start:
|
|
234
|
+
final, suffix = parents[final]
|
|
235
|
+
result.append(suffix)
|
|
236
|
+
return b''.join(reversed(result)).decode('utf-8')
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def _project_lattice(adjacency, finals, ordered, live, start):
|
|
240
|
+
"""Output projection and epsilon removal used by Pynini top_rewrite.
|
|
241
|
+
|
|
242
|
+
The insertion order and reversal of expanded arcs follow OpenFst's
|
|
243
|
+
RmEpsilon. Calling shortestpath on the unprojected transducer resolves
|
|
244
|
+
equally weighted punctuation rewrites differently.
|
|
245
|
+
"""
|
|
246
|
+
pack, unpack = struct.Struct('<f').pack, struct.Struct('<f').unpack
|
|
247
|
+
def add(a, b):
|
|
248
|
+
return unpack(pack(a + b))[0] if b else a
|
|
249
|
+
incoming = {start}
|
|
250
|
+
for node in live:
|
|
251
|
+
for target, _, output in adjacency[node]:
|
|
252
|
+
if output and target in live:
|
|
253
|
+
incoming.add(target)
|
|
254
|
+
for source in ordered:
|
|
255
|
+
if source not in live or source not in incoming:
|
|
256
|
+
continue
|
|
257
|
+
distances = {source: 0.0}
|
|
258
|
+
pending = deque([source])
|
|
259
|
+
queued = {source}
|
|
260
|
+
while pending:
|
|
261
|
+
node = pending.popleft()
|
|
262
|
+
queued.remove(node)
|
|
263
|
+
for target, weight, output in adjacency[node]:
|
|
264
|
+
if output or target not in live:
|
|
265
|
+
continue
|
|
266
|
+
distance = add(distances[node], weight)
|
|
267
|
+
if distance < distances.get(target, math.inf):
|
|
268
|
+
distances[target] = distance
|
|
269
|
+
if target not in queued:
|
|
270
|
+
pending.append(target)
|
|
271
|
+
queued.add(target)
|
|
272
|
+
expanded = {}
|
|
273
|
+
final = math.inf
|
|
274
|
+
stack, seen = [source], set()
|
|
275
|
+
while stack:
|
|
276
|
+
node = stack.pop()
|
|
277
|
+
if node in seen:
|
|
278
|
+
continue
|
|
279
|
+
seen.add(node)
|
|
280
|
+
distance = distances[node]
|
|
281
|
+
final = min(final, add(distance, finals.get(node, math.inf)))
|
|
282
|
+
for target, weight, output in adjacency[node]:
|
|
283
|
+
if target not in live:
|
|
284
|
+
continue
|
|
285
|
+
if not output:
|
|
286
|
+
if target not in seen:
|
|
287
|
+
stack.append(target)
|
|
288
|
+
else:
|
|
289
|
+
key = (target, output)
|
|
290
|
+
expanded[key] = min(expanded.get(key, math.inf), add(distance, weight))
|
|
291
|
+
adjacency[source] = [(target, weight, output) for (target, output), weight in reversed(expanded.items())]
|
|
292
|
+
finals[source] = final
|
|
293
|
+
seen, order = {start}, []
|
|
294
|
+
stack = [(start, iter(adjacency[start]))]
|
|
295
|
+
while stack:
|
|
296
|
+
node, arcs = stack[-1]
|
|
297
|
+
arc = next(arcs, None)
|
|
298
|
+
if arc is None:
|
|
299
|
+
order.append(node)
|
|
300
|
+
stack.pop()
|
|
301
|
+
elif arc[0] not in seen:
|
|
302
|
+
seen.add(arc[0])
|
|
303
|
+
stack.append((arc[0], iter(adjacency[arc[0]])))
|
|
304
|
+
return adjacency, finals, order
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
# Adapted from NeMo 1.2.0 normalize.py; only token ordering is retained.
|
|
15
|
+
import itertools
|
|
16
|
+
from collections import OrderedDict
|
|
17
|
+
from math import factorial
|
|
18
|
+
from typing import Dict, List, Union
|
|
19
|
+
from ._token_parser import PRESERVE_ORDER_KEY
|
|
20
|
+
|
|
21
|
+
class TokenOrdering:
|
|
22
|
+
def _estimate_number_of_permutations_in_nested_dict(
|
|
23
|
+
self, token_group: Dict[str, Union[OrderedDict, str, bool]]
|
|
24
|
+
) -> int:
|
|
25
|
+
num_perms = 1
|
|
26
|
+
for k, inner in token_group.items():
|
|
27
|
+
if isinstance(inner, dict):
|
|
28
|
+
num_perms *= self._estimate_number_of_permutations_in_nested_dict(inner)
|
|
29
|
+
num_perms *= factorial(len(token_group))
|
|
30
|
+
return num_perms
|
|
31
|
+
|
|
32
|
+
def _split_tokens_to_reduce_number_of_permutations(self, tokens: List[dict]) -> List[List[dict]]:
|
|
33
|
+
"""
|
|
34
|
+
Splits a sequence of tokens in a smaller sequences of tokens in a way that maximum number of composite
|
|
35
|
+
tokens permutations does not exceed ``max_number_of_permutations_per_split``.
|
|
36
|
+
|
|
37
|
+
For example,
|
|
38
|
+
|
|
39
|
+
.. code-block:: python
|
|
40
|
+
|
|
41
|
+
# setup normalizer with self.max_number_of_permutations_per_split=6
|
|
42
|
+
tokens = [
|
|
43
|
+
{"tokens": {"date": {"year": "twenty eighteen", "month": "december", "day": "thirty one"}}},
|
|
44
|
+
{"tokens": {"date": {"year": "twenty eighteen", "month": "january", "day": "eight"}}},
|
|
45
|
+
]
|
|
46
|
+
split = normalizer._split_tokens_to_reduce_number_of_permutations(tokens)
|
|
47
|
+
assert split == [
|
|
48
|
+
[{"tokens": {"date": {"year": "twenty eighteen", "month": "december", "day": "thirty one"}}}],
|
|
49
|
+
[{"tokens": {"date": {"year": "twenty eighteen", "month": "january", "day": "eight"}}}],
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
Date tokens contain 3 items each which gives 6 permutations for every date. Since there are 2 dates, total
|
|
53
|
+
number of permutations would be ``6 * 6 == 36``. Parameter ``self.max_number_of_permutations_per_split`` equals 6,
|
|
54
|
+
so input sequence of tokens is split into 2 smaller sequences.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
tokens: a list of dictionaries, possibly nested.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
a list of smaller sequences of tokens resulting from ``tokens`` split.
|
|
61
|
+
"""
|
|
62
|
+
splits = []
|
|
63
|
+
prev_end_of_split = 0
|
|
64
|
+
current_number_of_permutations = 1
|
|
65
|
+
for i, token_group in enumerate(tokens):
|
|
66
|
+
n = self._estimate_number_of_permutations_in_nested_dict(token_group)
|
|
67
|
+
if n * current_number_of_permutations > self.max_number_of_permutations_per_split:
|
|
68
|
+
splits.append(tokens[prev_end_of_split:i])
|
|
69
|
+
prev_end_of_split = i
|
|
70
|
+
current_number_of_permutations = 1
|
|
71
|
+
if n > self.max_number_of_permutations_per_split:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Could not split token list with respect to condition that every split can generate number of "
|
|
74
|
+
f"permutations less or equal to "
|
|
75
|
+
f"`self.max_number_of_permutations_per_split={self.max_number_of_permutations_per_split}`. "
|
|
76
|
+
f"There is an unsplittable token group that generates more than "
|
|
77
|
+
f"{self.max_number_of_permutations_per_split} permutations. Try to increase "
|
|
78
|
+
f"`--max_number_of_permutations_per_split` parameter."
|
|
79
|
+
)
|
|
80
|
+
current_number_of_permutations *= n
|
|
81
|
+
splits.append(tokens[prev_end_of_split:])
|
|
82
|
+
assert sum([len(s) for s in splits]) == len(tokens)
|
|
83
|
+
return splits
|
|
84
|
+
|
|
85
|
+
def _permute(self, d: OrderedDict) -> List[str]:
|
|
86
|
+
"""
|
|
87
|
+
Creates reorderings of dictionary elements and serializes as strings
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
d: (nested) dictionary of key value pairs
|
|
91
|
+
|
|
92
|
+
Return permutations of different string serializations of key value pairs
|
|
93
|
+
"""
|
|
94
|
+
l = []
|
|
95
|
+
if PRESERVE_ORDER_KEY in d.keys():
|
|
96
|
+
d_permutations = [d.items()]
|
|
97
|
+
else:
|
|
98
|
+
d_permutations = itertools.permutations(d.items())
|
|
99
|
+
for perm in d_permutations:
|
|
100
|
+
subl = [""]
|
|
101
|
+
for k, v in perm:
|
|
102
|
+
if isinstance(v, str):
|
|
103
|
+
subl = ["".join(x) for x in itertools.product(subl, [f"{k}: \"{v}\" "])]
|
|
104
|
+
elif isinstance(v, OrderedDict):
|
|
105
|
+
rec = self._permute(v)
|
|
106
|
+
subl = ["".join(x) for x in itertools.product(subl, [f" {k} {{ "], rec, [f" }} "])]
|
|
107
|
+
elif isinstance(v, bool):
|
|
108
|
+
subl = ["".join(x) for x in itertools.product(subl, [f"{k}: true "])]
|
|
109
|
+
else:
|
|
110
|
+
raise ValueError("Key: " + str(k) + " Value: " + str(v))
|
|
111
|
+
l.extend(subl)
|
|
112
|
+
return l
|
|
113
|
+
|
|
114
|
+
def generate_permutations(self, tokens: List[dict]):
|
|
115
|
+
"""
|
|
116
|
+
Generates permutations of string serializations of list of dictionaries
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
tokens: list of dictionaries
|
|
120
|
+
|
|
121
|
+
Returns string serialization of list of dictionaries
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
# itertools.product avoids recursion proportional to sentence length.
|
|
125
|
+
# Each split has a bounded product of options, as in the upstream API.
|
|
126
|
+
return ("".join(parts) for parts in itertools.product(*(self._permute(t) for t in tokens)))
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
# Adapted from NeMo 1.2.0 data_loader_utils.py; punctuation helpers only.
|
|
15
|
+
import re
|
|
16
|
+
import string
|
|
17
|
+
import sys
|
|
18
|
+
from unicodedata import category
|
|
19
|
+
import logging
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
def pre_process(text: str) -> str:
|
|
23
|
+
"""
|
|
24
|
+
Optional text preprocessing before normalization (part of TTS TN pipeline)
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
text: string that may include semiotic classes
|
|
28
|
+
|
|
29
|
+
Returns: text with spaces around punctuation marks
|
|
30
|
+
"""
|
|
31
|
+
space_both = '[]'
|
|
32
|
+
for punct in space_both:
|
|
33
|
+
text = text.replace(punct, ' ' + punct + ' ')
|
|
34
|
+
|
|
35
|
+
# remove extra space
|
|
36
|
+
text = re.sub(r' +', ' ', text)
|
|
37
|
+
return text
|
|
38
|
+
|
|
39
|
+
def post_process_punct(input: str, normalized_text: str, add_unicode_punct: bool = False):
|
|
40
|
+
"""
|
|
41
|
+
Post-processing of the normalized output to match input in terms of spaces around punctuation marks.
|
|
42
|
+
After NN normalization, Moses detokenization puts a space after
|
|
43
|
+
punctuation marks, and attaches an opening quote "'" to the word to the right.
|
|
44
|
+
E.g., input to the TN NN model is "12 test' example",
|
|
45
|
+
after normalization and detokenization -> "twelve test 'example" (the quote is considered to be an opening quote,
|
|
46
|
+
but it doesn't match the input and can cause issues during TTS voice generation.)
|
|
47
|
+
The current function will match the punctuation and spaces of the normalized text with the input sequence.
|
|
48
|
+
"12 test' example" -> "twelve test 'example" -> "twelve test' example" (the quote was shifted to match the input).
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
input: input text (original input to the NN, before normalization or tokenization)
|
|
52
|
+
normalized_text: output text (output of the TN NN model)
|
|
53
|
+
add_unicode_punct: set to True to handle unicode punctuation marks as well as default string.punctuation (increases post processing time)
|
|
54
|
+
"""
|
|
55
|
+
# in the post-processing WFST graph "``" are repalced with '"" quotes (otherwise single quotes "`" won't be handled correctly)
|
|
56
|
+
# this function fixes spaces around them based on input sequence, so here we're making the same double quote replacement
|
|
57
|
+
# to make sure these new double quotes work with this function
|
|
58
|
+
if "``" in input and "``" not in normalized_text:
|
|
59
|
+
input = input.replace("``", '"')
|
|
60
|
+
input = [x for x in input]
|
|
61
|
+
normalized_text = [x for x in normalized_text]
|
|
62
|
+
punct_marks = [x for x in string.punctuation if x in input]
|
|
63
|
+
|
|
64
|
+
if add_unicode_punct:
|
|
65
|
+
punct_unicode = [
|
|
66
|
+
chr(i)
|
|
67
|
+
for i in range(sys.maxunicode)
|
|
68
|
+
if category(chr(i)).startswith("P") and chr(i) not in punct_marks and chr(i) in input
|
|
69
|
+
]
|
|
70
|
+
punct_marks = punct_marks.extend(punct_unicode)
|
|
71
|
+
|
|
72
|
+
for punct in punct_marks:
|
|
73
|
+
try:
|
|
74
|
+
equal = True
|
|
75
|
+
if input.count(punct) != normalized_text.count(punct):
|
|
76
|
+
equal = False
|
|
77
|
+
idx_in, idx_out = 0, 0
|
|
78
|
+
while punct in input[idx_in:]:
|
|
79
|
+
idx_out = normalized_text.index(punct, idx_out)
|
|
80
|
+
idx_in = input.index(punct, idx_in)
|
|
81
|
+
|
|
82
|
+
def _is_valid(idx_out, idx_in, normalized_text, input):
|
|
83
|
+
"""Check if previous or next word match (for cases when punctuation marks are part of
|
|
84
|
+
semiotic token, i.e. some punctuation can be missing in the normalized text)"""
|
|
85
|
+
return (idx_out > 0 and idx_in > 0 and normalized_text[idx_out - 1] == input[idx_in - 1]) or (
|
|
86
|
+
idx_out < len(normalized_text) - 1
|
|
87
|
+
and idx_in < len(input) - 1
|
|
88
|
+
and normalized_text[idx_out + 1] == input[idx_in + 1]
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if not equal and not _is_valid(idx_out, idx_in, normalized_text, input):
|
|
92
|
+
idx_in += 1
|
|
93
|
+
continue
|
|
94
|
+
if idx_in > 0 and idx_out > 0:
|
|
95
|
+
if normalized_text[idx_out - 1] == " " and input[idx_in - 1] != " ":
|
|
96
|
+
normalized_text[idx_out - 1] = ""
|
|
97
|
+
|
|
98
|
+
elif normalized_text[idx_out - 1] != " " and input[idx_in - 1] == " ":
|
|
99
|
+
normalized_text[idx_out - 1] += " "
|
|
100
|
+
|
|
101
|
+
if idx_in < len(input) - 1 and idx_out < len(normalized_text) - 1:
|
|
102
|
+
if normalized_text[idx_out + 1] == " " and input[idx_in + 1] != " ":
|
|
103
|
+
normalized_text[idx_out + 1] = ""
|
|
104
|
+
elif normalized_text[idx_out + 1] != " " and input[idx_in + 1] == " ":
|
|
105
|
+
normalized_text[idx_out] = normalized_text[idx_out] + " "
|
|
106
|
+
idx_out += 1
|
|
107
|
+
idx_in += 1
|
|
108
|
+
except:
|
|
109
|
+
logger.info(f"Skipping post-processing of {''.join(normalized_text)} for '{punct}'")
|
|
110
|
+
|
|
111
|
+
normalized_text = "".join(normalized_text)
|
|
112
|
+
return re.sub(r' +', ' ', normalized_text)
|