storyshot 0.4.2 → 0.6.0

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.
package/src/zh_g2p.py ADDED
@@ -0,0 +1,118 @@
1
+ # Mandarin text -> Kokoro v1.1-zh phonemes, for storyshot's Chinese voice (src/kokoro-zh.mjs).
2
+ # Runs in storyshot's own venv (~/.cache/storyshot/zh-venv), once per render for every line.
3
+ # stdin: {"lines": ["...", ...], "target": 300, "max": 510}
4
+ # stdout: {"pieces": [["<phonemes>", ...], ...]} one list per line; long lines come back in pieces
5
+ # G2P: misaki ZHG2P(version='1.1'); English words and single letters go through misaki's espeak-ng
6
+ # fallback (letter names: "m x" -> ˈɛm ˈɛks), instead of being dropped as the stock frontend does.
7
+ import json
8
+ import logging
9
+ import os
10
+ import re
11
+ import shutil
12
+ import sys
13
+
14
+
15
+ def espeak_data_path():
16
+ # espeak-ng truncates its data path at 160 characters; use a copy at a shorter path if needed
17
+ import espeakng_loader
18
+ data = espeakng_loader.get_data_path()
19
+ if len(data) <= 120:
20
+ return data
21
+ import tempfile
22
+ from importlib.metadata import version
23
+ for base in (os.path.join(os.path.expanduser('~'), '.cache', 'storyshot'), tempfile.gettempdir()):
24
+ short = os.path.join(base, f"espeak-ng-data-{version('espeakng-loader')}")
25
+ if len(short) > 120:
26
+ continue
27
+ if not os.path.exists(os.path.join(short, 'phontab')):
28
+ shutil.rmtree(short, ignore_errors=True)
29
+ shutil.copytree(data, short)
30
+ return short
31
+ return data
32
+
33
+
34
+ def make_g2p():
35
+ import jieba
36
+ jieba.setLogLevel(logging.ERROR)
37
+ from phonemizer.backend.espeak.wrapper import EspeakWrapper
38
+ import misaki.espeak # noqa: F401 (points phonemizer at the bundled espeak-ng library)
39
+ EspeakWrapper.set_data_path(espeak_data_path())
40
+ from misaki.espeak import EspeakFallback
41
+ from misaki import zh
42
+
43
+ esp = EspeakFallback(british=False)
44
+
45
+ class Token:
46
+ def __init__(self, text):
47
+ self.text = text
48
+
49
+ def en_callable(run): # the v1.1 frontend hands over Latin runs like "m x" or "GeoGebra"
50
+ return ' '.join(esp(Token(w))[0] or '' for w in re.findall(r"[A-Za-z']+", run))
51
+
52
+ g2p = zh.ZHG2P(version='1.1', en_callable=en_callable)
53
+ return lambda text: (g2p(text)[0] or '').strip()
54
+
55
+
56
+ SENTENCE = re.compile(r'(?<=[。!?;!?;])')
57
+ CLAUSE = re.compile(r'(?<=[,、:,:])')
58
+
59
+
60
+ def units(g2p, text, target, hard):
61
+ """Split text into (text, phoneme length) units no longer than `target`: sentences first, then clauses,
62
+ then (rarely) plain character slices."""
63
+ out = []
64
+ for sentence in [s for s in SENTENCE.split(text) if s.strip()]:
65
+ n = len(g2p(sentence))
66
+ if n <= target:
67
+ out.append((sentence, n))
68
+ continue
69
+ for clause in [c for c in CLAUSE.split(sentence) if c.strip()]:
70
+ m = len(g2p(clause))
71
+ if m <= hard:
72
+ out.append((clause, m))
73
+ continue
74
+ k = -(-m // target)
75
+ step = -(-len(clause) // k)
76
+ for i in range(0, len(clause), step):
77
+ part = clause[i:i + step]
78
+ out.append((part, len(g2p(part))))
79
+ return out
80
+
81
+
82
+ def pieces(g2p, text, target, hard):
83
+ text = text.strip()
84
+ if not text:
85
+ return []
86
+ ps = g2p(text)
87
+ if len(ps) <= target:
88
+ return [ps] if ps else []
89
+ us = units(g2p, text, target, hard)
90
+ total = sum(n for _, n in us)
91
+ k = -(-total // target)
92
+ goal = total / k
93
+ groups, cur, n = [], [], 0
94
+ for t, m in us:
95
+ # close the group when the next unit would overflow, or would land further from an even split
96
+ if cur and (n + m > target or abs(n + m - goal) > abs(n - goal)):
97
+ groups.append(''.join(cur))
98
+ cur, n = [], 0
99
+ cur.append(t)
100
+ n += m
101
+ if cur:
102
+ groups.append(''.join(cur))
103
+ return [p[:hard] for p in (g2p(g) for g in groups) if p]
104
+
105
+
106
+ def main():
107
+ req = json.loads(sys.stdin.buffer.read().decode('utf-8'))
108
+ out = sys.stdout
109
+ sys.stdout = sys.stderr # keep any library chatter off the JSON channel
110
+ g2p = make_g2p()
111
+ target, hard = int(req.get('target', 300)), int(req.get('max', 510))
112
+ result = [pieces(g2p, line, target, hard) for line in req['lines']]
113
+ out.buffer.write(json.dumps({'pieces': result}, ensure_ascii=False).encode('utf-8'))
114
+ out.flush()
115
+
116
+
117
+ if __name__ == '__main__':
118
+ main()