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.
Files changed (71) hide show
  1. kitten_text_processing/__init__.py +5 -0
  2. kitten_text_processing/__main__.py +23 -0
  3. kitten_text_processing/_detokenize.py +62 -0
  4. kitten_text_processing/_fst.py +304 -0
  5. kitten_text_processing/_ordering.py +126 -0
  6. kitten_text_processing/_punctuation.py +112 -0
  7. kitten_text_processing/_token_parser.py +193 -0
  8. kitten_text_processing/_unicode_data.py +6 -0
  9. kitten_text_processing/data/ar/metadata.json +20 -0
  10. kitten_text_processing/data/ar/tagger.fst.gz +0 -0
  11. kitten_text_processing/data/ar/verbalizer.fst.gz +0 -0
  12. kitten_text_processing/data/de/metadata.json +20 -0
  13. kitten_text_processing/data/de/tagger.fst.gz +0 -0
  14. kitten_text_processing/data/de/verbalizer.fst.gz +0 -0
  15. kitten_text_processing/data/en/metadata.json +26 -0
  16. kitten_text_processing/data/en/post.fst.gz +0 -0
  17. kitten_text_processing/data/en/tagger.fst.gz +0 -0
  18. kitten_text_processing/data/en/verbalizer.fst.gz +0 -0
  19. kitten_text_processing/data/es/metadata.json +20 -0
  20. kitten_text_processing/data/es/tagger.fst.gz +0 -0
  21. kitten_text_processing/data/es/verbalizer.fst.gz +0 -0
  22. kitten_text_processing/data/fr/metadata.json +20 -0
  23. kitten_text_processing/data/fr/tagger.fst.gz +0 -0
  24. kitten_text_processing/data/fr/verbalizer.fst.gz +0 -0
  25. kitten_text_processing/data/hi/metadata.json +26 -0
  26. kitten_text_processing/data/hi/post.fst.gz +0 -0
  27. kitten_text_processing/data/hi/tagger.fst.gz +0 -0
  28. kitten_text_processing/data/hi/verbalizer.fst.gz +0 -0
  29. kitten_text_processing/data/hu/metadata.json +20 -0
  30. kitten_text_processing/data/hu/tagger.fst.gz +0 -0
  31. kitten_text_processing/data/hu/verbalizer.fst.gz +0 -0
  32. kitten_text_processing/data/hy/metadata.json +24 -0
  33. kitten_text_processing/data/hy/tagger.fst.gz +0 -0
  34. kitten_text_processing/data/hy/verbalizer.fst.gz +0 -0
  35. kitten_text_processing/data/it/metadata.json +20 -0
  36. kitten_text_processing/data/it/tagger.fst.gz +0 -0
  37. kitten_text_processing/data/it/verbalizer.fst.gz +0 -0
  38. kitten_text_processing/data/ja/metadata.json +20 -0
  39. kitten_text_processing/data/ja/tagger.fst.gz +0 -0
  40. kitten_text_processing/data/ja/verbalizer.fst.gz +0 -0
  41. kitten_text_processing/data/ko/metadata.json +24 -0
  42. kitten_text_processing/data/ko/tagger.fst.gz +0 -0
  43. kitten_text_processing/data/ko/verbalizer.fst.gz +0 -0
  44. kitten_text_processing/data/pt/metadata.json +20 -0
  45. kitten_text_processing/data/pt/tagger.fst.gz +0 -0
  46. kitten_text_processing/data/pt/verbalizer.fst.gz +0 -0
  47. kitten_text_processing/data/ru/metadata.json +20 -0
  48. kitten_text_processing/data/ru/tagger.fst.gz +0 -0
  49. kitten_text_processing/data/ru/verbalizer.fst.gz +0 -0
  50. kitten_text_processing/data/rw/metadata.json +20 -0
  51. kitten_text_processing/data/rw/tagger.fst.gz +0 -0
  52. kitten_text_processing/data/rw/verbalizer.fst.gz +0 -0
  53. kitten_text_processing/data/sv/metadata.json +20 -0
  54. kitten_text_processing/data/sv/tagger.fst.gz +0 -0
  55. kitten_text_processing/data/sv/verbalizer.fst.gz +0 -0
  56. kitten_text_processing/data/vi/metadata.json +26 -0
  57. kitten_text_processing/data/vi/post.fst.gz +0 -0
  58. kitten_text_processing/data/vi/tagger.fst.gz +0 -0
  59. kitten_text_processing/data/vi/verbalizer.fst.gz +0 -0
  60. kitten_text_processing/data/zh/metadata.json +20 -0
  61. kitten_text_processing/data/zh/tagger.fst.gz +0 -0
  62. kitten_text_processing/data/zh/verbalizer.fst.gz +0 -0
  63. kitten_text_processing/normalizer.py +114 -0
  64. kitten_text_processing-0.1.0.dist-info/METADATA +132 -0
  65. kitten_text_processing-0.1.0.dist-info/RECORD +71 -0
  66. kitten_text_processing-0.1.0.dist-info/WHEEL +5 -0
  67. kitten_text_processing-0.1.0.dist-info/entry_points.txt +2 -0
  68. kitten_text_processing-0.1.0.dist-info/licenses/LICENSE +201 -0
  69. kitten_text_processing-0.1.0.dist-info/licenses/LICENSE-SACREMOSES +21 -0
  70. kitten_text_processing-0.1.0.dist-info/licenses/NOTICE +26 -0
  71. 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)