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/README.md +66 -4
- package/package.json +7 -2
- package/skills/storyshot/SKILL.md +20 -3
- package/src/audio.mjs +2 -2
- package/src/check.mjs +17 -2
- package/src/compose.mjs +94 -8
- package/src/config.mjs +42 -15
- package/src/doctor.mjs +17 -0
- package/src/fonts/Virgil-LICENSE.md +45 -0
- package/src/fonts/Virgil.woff2 +0 -0
- package/src/hyperframes.mjs +536 -0
- package/src/kokoro-zh.mjs +204 -0
- package/src/qwen-tts.mjs +134 -0
- package/src/qwen_tts.py +70 -0
- package/src/record.mjs +4 -1
- package/src/voice.mjs +61 -10
- package/src/zh_g2p.py +118 -0
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()
|