kinescript 1.2.0__tar.gz → 1.3.0__tar.gz
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.
- {kinescript-1.2.0 → kinescript-1.3.0}/PKG-INFO +15 -7
- {kinescript-1.2.0 → kinescript-1.3.0}/README.md +14 -6
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/__init__.py +1 -1
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/docs.py +24 -7
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/mcp.py +3 -1
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/scene.py +3 -2
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/schema.py +10 -3
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/voice.py +88 -31
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript.egg-info/PKG-INFO +15 -7
- {kinescript-1.2.0 → kinescript-1.3.0}/pyproject.toml +1 -1
- {kinescript-1.2.0 → kinescript-1.3.0}/tests/test_core.py +190 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/tests/test_packaging.py +29 -19
- {kinescript-1.2.0 → kinescript-1.3.0}/LICENSE +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/audio.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/cli.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/color.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/components.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/ease.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/IBMPlexMono-Bold.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/IBMPlexMono-Medium.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/IBMPlexMono-OFL.txt +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/IBMPlexMono-Regular.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/IBMPlexMono-SemiBold.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/SpaceGrotesk-Bold.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/SpaceGrotesk-Medium.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/SpaceGrotesk-OFL.txt +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/SpaceGrotesk-Regular.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/SpaceGrotesk-SemiBold.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/listen.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/media.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/mesh.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/models.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/output.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/presets.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/props.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/render.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/render3d.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/shaders.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/space.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/svgpath.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/timing.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/verify.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/words.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript.egg-info/SOURCES.txt +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript.egg-info/dependency_links.txt +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript.egg-info/entry_points.txt +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript.egg-info/requires.txt +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/kinescript.egg-info/top_level.txt +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/setup.cfg +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/tests/test_color_pipeline.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/tests/test_docs.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/tests/test_models.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/tests/test_motion_blur.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.0}/tests/test_schema.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kinescript
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Render motion graphics, video edits and voice-over from JSON scenes on the GPU, with checks an agent can read.
|
|
5
5
|
Author: Dr. Know How
|
|
6
6
|
License-Expression: MIT
|
|
@@ -133,9 +133,15 @@ Sounds are a top-level `audio` list, timed like layers and mixed by ffmpeg while
|
|
|
133
133
|
word: `"at": "vo1:render"` starts a layer as the voice says "render", and `{"type": "captions", "voice": "vo1"}`
|
|
134
134
|
captions it, lighting each word as it is said.
|
|
135
135
|
- **Voice-over from text.** Write what a voice says in `text` and `kine voice` speaks it through a plugin, writing
|
|
136
|
-
the file and its word times
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
the file and its word times: [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
|
|
137
|
+
(local, fast),
|
|
138
|
+
[Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local, tags,
|
|
139
|
+
cloned voices) or [ElevenLabs](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs)
|
|
140
|
+
(cloud, needs an API key), picked per sound with `backend`. `[[go]]` in the text names the next
|
|
141
|
+
word, so a cue to `vo1:go` survives a rewrite of the line. Only a line whose text, speaker, rate, backend or
|
|
142
|
+
options changed is spoken again, and rendering never calls a speech engine.
|
|
143
|
+
- **Delivery tags.** `[whispers]`, `[laughs]` or `[sighs]` in the text tell Chatterbox and ElevenLabs how to say
|
|
144
|
+
what follows. Tags are not words, so cues, marks and captions skip them, and Kokoro says the line without them.
|
|
139
145
|
- **A mix without listening.** Give a sound a `role` and it is set to that role's level: voice -18 LUFS, music
|
|
140
146
|
-16, ambience -30, effects peaking at -6 dBFS. Music and ambience dip 14 dB while a voice speaks, an effect's
|
|
141
147
|
leading silence is skipped so it lands on its cue, and the whole mix ends at -16 LUFS with peaks under
|
|
@@ -164,7 +170,7 @@ A narrated beat with a click on a marked word, over music that ducks under the v
|
|
|
164
170
|
to its scene file. The Kokoro plugin needs Python 3.10 to 3.12:
|
|
165
171
|
|
|
166
172
|
```bash
|
|
167
|
-
pip install
|
|
173
|
+
pip install kinescript-kokoro
|
|
168
174
|
```
|
|
169
175
|
|
|
170
176
|
On a newer Python, install it into another one and point `KINE_VOICE_CMD` at it
|
|
@@ -300,7 +306,8 @@ Within one process, such as the MCP server, the workers stay warm for two minute
|
|
|
300
306
|
- Audio is mixing and levelling (trim, gain over time, fades, speed, loop, pan, role levels, ducking, a loudness
|
|
301
307
|
target), with no effects such as EQ, compression of single sounds or reverb.
|
|
302
308
|
- Kinescript does not speak by itself: `kine voice` needs a plugin or a command. The Kokoro plugin times words
|
|
303
|
-
for English voices only and needs Python 3.10 to 3.12
|
|
309
|
+
for English voices only and needs Python 3.10 to 3.12; the Chatterbox plugin speaks and times English only;
|
|
310
|
+
the ElevenLabs plugin needs an API key and a network connection.
|
|
304
311
|
- Video is decoded and resampled by ffmpeg to the scene frame rate. Jumping backwards or far ahead reopens the
|
|
305
312
|
decoder, which is slower than sequential playback.
|
|
306
313
|
- Text layout is single-script left-to-right with greedy wrapping. No shaping for complex scripts or bidi.
|
|
@@ -313,7 +320,8 @@ kinescript/ engine: timing, props, scene, components, render (Skia), space, re
|
|
|
313
320
|
mesh and models (3D), output (encoding and the audio mix),
|
|
314
321
|
verify (check, look, probe), schema, docs, mcp, cli
|
|
315
322
|
examples/ example scenes and the generator for sample media
|
|
316
|
-
plugins/
|
|
323
|
+
plugins/ voices for kine voice, each installed on its own: kinescript-kokoro, kinescript-chatterbox,
|
|
324
|
+
kinescript-elevenlabs
|
|
317
325
|
marketing/ the launch video as a scene and its builder; build_site.py renders the site
|
|
318
326
|
site/ the project site: demo scenes next to the videos rendered from them
|
|
319
327
|
brand/ logo files, generated art and the brand guide
|
|
@@ -86,9 +86,15 @@ Sounds are a top-level `audio` list, timed like layers and mixed by ffmpeg while
|
|
|
86
86
|
word: `"at": "vo1:render"` starts a layer as the voice says "render", and `{"type": "captions", "voice": "vo1"}`
|
|
87
87
|
captions it, lighting each word as it is said.
|
|
88
88
|
- **Voice-over from text.** Write what a voice says in `text` and `kine voice` speaks it through a plugin, writing
|
|
89
|
-
the file and its word times
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
the file and its word times: [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
|
|
90
|
+
(local, fast),
|
|
91
|
+
[Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local, tags,
|
|
92
|
+
cloned voices) or [ElevenLabs](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs)
|
|
93
|
+
(cloud, needs an API key), picked per sound with `backend`. `[[go]]` in the text names the next
|
|
94
|
+
word, so a cue to `vo1:go` survives a rewrite of the line. Only a line whose text, speaker, rate, backend or
|
|
95
|
+
options changed is spoken again, and rendering never calls a speech engine.
|
|
96
|
+
- **Delivery tags.** `[whispers]`, `[laughs]` or `[sighs]` in the text tell Chatterbox and ElevenLabs how to say
|
|
97
|
+
what follows. Tags are not words, so cues, marks and captions skip them, and Kokoro says the line without them.
|
|
92
98
|
- **A mix without listening.** Give a sound a `role` and it is set to that role's level: voice -18 LUFS, music
|
|
93
99
|
-16, ambience -30, effects peaking at -6 dBFS. Music and ambience dip 14 dB while a voice speaks, an effect's
|
|
94
100
|
leading silence is skipped so it lands on its cue, and the whole mix ends at -16 LUFS with peaks under
|
|
@@ -117,7 +123,7 @@ A narrated beat with a click on a marked word, over music that ducks under the v
|
|
|
117
123
|
to its scene file. The Kokoro plugin needs Python 3.10 to 3.12:
|
|
118
124
|
|
|
119
125
|
```bash
|
|
120
|
-
pip install
|
|
126
|
+
pip install kinescript-kokoro
|
|
121
127
|
```
|
|
122
128
|
|
|
123
129
|
On a newer Python, install it into another one and point `KINE_VOICE_CMD` at it
|
|
@@ -253,7 +259,8 @@ Within one process, such as the MCP server, the workers stay warm for two minute
|
|
|
253
259
|
- Audio is mixing and levelling (trim, gain over time, fades, speed, loop, pan, role levels, ducking, a loudness
|
|
254
260
|
target), with no effects such as EQ, compression of single sounds or reverb.
|
|
255
261
|
- Kinescript does not speak by itself: `kine voice` needs a plugin or a command. The Kokoro plugin times words
|
|
256
|
-
for English voices only and needs Python 3.10 to 3.12
|
|
262
|
+
for English voices only and needs Python 3.10 to 3.12; the Chatterbox plugin speaks and times English only;
|
|
263
|
+
the ElevenLabs plugin needs an API key and a network connection.
|
|
257
264
|
- Video is decoded and resampled by ffmpeg to the scene frame rate. Jumping backwards or far ahead reopens the
|
|
258
265
|
decoder, which is slower than sequential playback.
|
|
259
266
|
- Text layout is single-script left-to-right with greedy wrapping. No shaping for complex scripts or bidi.
|
|
@@ -266,7 +273,8 @@ kinescript/ engine: timing, props, scene, components, render (Skia), space, re
|
|
|
266
273
|
mesh and models (3D), output (encoding and the audio mix),
|
|
267
274
|
verify (check, look, probe), schema, docs, mcp, cli
|
|
268
275
|
examples/ example scenes and the generator for sample media
|
|
269
|
-
plugins/
|
|
276
|
+
plugins/ voices for kine voice, each installed on its own: kinescript-kokoro, kinescript-chatterbox,
|
|
277
|
+
kinescript-elevenlabs
|
|
270
278
|
marketing/ the launch video as a scene and its builder; build_site.py renders the site
|
|
271
279
|
site/ the project site: demo scenes next to the videos rendered from them
|
|
272
280
|
brand/ logo files, generated art and the brand guide
|
|
@@ -9,5 +9,5 @@ except ImportError as e: # on Linux the wheel loads the system's EGL and GL, ev
|
|
|
9
9
|
from .output import render, still
|
|
10
10
|
from .scene import Scene, SceneError, load
|
|
11
11
|
|
|
12
|
-
__version__ = "1.
|
|
12
|
+
__version__ = "1.3.0"
|
|
13
13
|
__all__ = ["Scene", "SceneError", "load", "render", "still", "__version__"]
|
|
@@ -327,8 +327,23 @@ SOUND
|
|
|
327
327
|
It writes voice/vo1.wav (or the sound's src) and its words file, so cues and captions work as above.
|
|
328
328
|
[[name]] gives the next word that id: "at": "vo1:go" starts as it says Render, whatever the line
|
|
329
329
|
says later. speaker is the backend's voice (Kokoro: af_heart, af_bella, am_adam, bf_emma, bm_george),
|
|
330
|
-
rate (0.5 to 2, default 1) how fast it speaks.
|
|
331
|
-
|
|
330
|
+
rate (0.5 to 2, default 1) how fast it speaks. With Kokoro, a speaker of "af_heart,af_bella" blends
|
|
331
|
+
the two voices, [word](/phonemes/) in text says that word with the IPA phonemes you give (for a name it
|
|
332
|
+
says wrong), and [word](-1) or [word](+1) lowers or raises its stress; captions and word ids keep the
|
|
333
|
+
word. kine check reports a sound with text that is not spoken yet (voice-missing) and one whose text,
|
|
334
|
+
speaker, rate, backend or options changed since it was spoken (voice-stale).
|
|
335
|
+
A tag in the text says how to speak what follows, for a backend that acts on tags: [whispers], [laughs],
|
|
336
|
+
[sighs]. Tags are not words: marks and word ids skip them, a backend without tags says the line without
|
|
337
|
+
them, and kine voice lists what a backend could not do under "ignored". A sound picks its backend with
|
|
338
|
+
backend (kokoro, chatterbox, elevenlabs, each a plugin) and hands it settings with options:
|
|
339
|
+
{"id": "vo2", "role": "voice", "text": "[whispers] Nobody knows. [laughs] Yet.", "backend": "elevenlabs",
|
|
340
|
+
"speaker": "George", "options": {"stability": 0.3}}
|
|
341
|
+
chatterbox acts on [laugh], [chuckle], [sigh], [gasp], [cough], [clear throat], [whispering], [angry],
|
|
342
|
+
[happy], [sarcastic], [surprised], [crying] and a few more ([laughs] reads as [laugh]); its speaker
|
|
343
|
+
is a clip of 5 s or more whose voice it copies, and "options": {"model": "standard", "exaggeration": 0.8}
|
|
344
|
+
trades the tags for stronger emotion. elevenlabs needs an ELEVENLABS_API_KEY and uses the account's
|
|
345
|
+
quota; its speaker is a voice name or id, its options model (eleven_v4), stability, style and
|
|
346
|
+
similarity_boost. kokoro has no tags.
|
|
332
347
|
You cannot hear the mix, so read it: kine check measures it and names each sound problem with its
|
|
333
348
|
time range, and kine look --audio draws it under the frames, with each sound's loudness in the JSON.
|
|
334
349
|
Sound is mixed into .mp4, .mov and .webm, and into --from/--to renders. -o mix.wav (or .m4a,
|
|
@@ -396,11 +411,13 @@ COMMANDS
|
|
|
396
411
|
.wav, .m4a, .mp3 or .flac: the sound alone.
|
|
397
412
|
--scale 0.5 halves the size; --from 2 --to 4 renders a part.
|
|
398
413
|
kine voice scene.json speaks each sound's text into its file and times its words; a sound
|
|
399
|
-
is spoken again only when its text, speaker
|
|
400
|
-
(--force: all of them, --only vo1: one). Needs a
|
|
401
|
-
|
|
402
|
-
command)
|
|
403
|
-
|
|
414
|
+
is spoken again only when its text, speaker, rate, backend or
|
|
415
|
+
options changed (--force: all of them, --only vo1: one). Needs a
|
|
416
|
+
voice backend plugin (with none installed, kine voice prints the
|
|
417
|
+
pip command); --backend picks one for sounds without their own.
|
|
418
|
+
KINE_VOICE_CMD (or KINE_VOICE_CMD_CHATTERBOX for one backend) runs
|
|
419
|
+
a backend in another Python, as JSON requests on stdin.
|
|
420
|
+
Rendering never speaks.
|
|
404
421
|
kine watch scene.json re-makes the contact sheet on every save
|
|
405
422
|
kine doctor checks ffmpeg, the GPU and fonts
|
|
406
423
|
kine mcp the same tools for MCP clients
|
|
@@ -43,7 +43,9 @@ TOOLS = [
|
|
|
43
43
|
"required": ["scene"]}},
|
|
44
44
|
{"name": "kine_voice", "description": "Speak each sound's `text` into its file (voice/<id>.wav unless it has a "
|
|
45
45
|
"src) and write its word times, so layers can cue from words (\"vo1:render\") and captions can follow it. Only "
|
|
46
|
-
"sounds whose text, speaker or
|
|
46
|
+
"sounds whose text, speaker, rate, backend or options changed are spoken again, unless force. A sound's own "
|
|
47
|
+
"\"backend\" picks its voice plugin; backend here is the one for sounds without. Tags such as [laughs] are "
|
|
48
|
+
"acted by backends that can and listed under \"ignored\" by the rest. Run it before check and render.",
|
|
47
49
|
"inputSchema": {"type": "object", "properties": {"scene": _SCENE, "base": {"type": "string"},
|
|
48
50
|
"only": {"type": "array", "items": {"type": "string"}},
|
|
49
51
|
"force": {"type": "boolean", "default": False},
|
|
@@ -232,8 +232,9 @@ class Scene:
|
|
|
232
232
|
continue
|
|
233
233
|
rec = V.made(near) if a.get("text") else None
|
|
234
234
|
if rec and rec.get("hash") != V.fingerprint(a):
|
|
235
|
-
self.note("warning", "voice-stale", sid, f"the text, speaker or
|
|
236
|
-
"was spoken, so it says the old text",
|
|
235
|
+
self.note("warning", "voice-stale", sid, f"the text, speaker, rate, backend or options of '{sid}' "
|
|
236
|
+
f"changed after {src} was spoken, so it says the old text",
|
|
237
|
+
"run kine voice on this scene to speak it again")
|
|
237
238
|
given = a.get("words")
|
|
238
239
|
if given is None and not near.exists():
|
|
239
240
|
continue
|
|
@@ -48,13 +48,14 @@ TWEEN = {"from", "to", "dur", "delay", "ease", "keys"}
|
|
|
48
48
|
TWEEN_ALIAS = {"duration": "dur", "easing": "ease", "start": "from", "end": "to", "begin": "from"}
|
|
49
49
|
GRADIENT = {"linear", "radial", "stops"}
|
|
50
50
|
AUDIO = {"src", "at", "trim", "gain", "volume", "fadeIn", "fadeOut", "id", "role", "dur", "until", "loop", "pan",
|
|
51
|
-
"words", "text", "speaker", "rate"}
|
|
51
|
+
"words", "text", "speaker", "rate", "backend", "options"}
|
|
52
52
|
AUDIO_ALIAS = {"source": "src", "file": "src", "path": "src", "start": "at", "fade_in": "fadeIn",
|
|
53
53
|
"fade_out": "fadeOut", "fadein": "fadeIn", "fadeout": "fadeOut", "duration": "dur", "length": "dur",
|
|
54
54
|
"end": "until", "type": "role", "kind": "role", "balance": "pan", "repeat": "loop",
|
|
55
|
-
"script": "text", "say": "text", "tts": "text", "narrator": "speaker", "pace": "rate"
|
|
55
|
+
"script": "text", "say": "text", "tts": "text", "narrator": "speaker", "pace": "rate",
|
|
56
|
+
"engine": "backend", "settings": "options", "voiceSettings": "options"}
|
|
56
57
|
# A layer's "sound": cued from the layer, so it has no id, until, loop, words or text of its own.
|
|
57
|
-
SOUND = AUDIO - {"id", "until", "loop", "words", "text", "speaker", "rate"}
|
|
58
|
+
SOUND = AUDIO - {"id", "until", "loop", "words", "text", "speaker", "rate", "backend", "options"}
|
|
58
59
|
MIX = {"loudness", "peak", "levels", "duck"}
|
|
59
60
|
MIX_ALIAS = {"lufs": "loudness", "target": "loudness", "ceiling": "peak", "truePeak": "peak", "ducking": "duck",
|
|
60
61
|
"level": "levels"}
|
|
@@ -598,6 +599,12 @@ def _audio_values(a: dict, rep: Report, lid, where: str):
|
|
|
598
599
|
rep.add("error", "bad-value", lid, f"{where}text is {t!r}; it is the words to speak, as a string")
|
|
599
600
|
if r is not None and (isinstance(r, bool) or not isinstance(r, (int, float)) or not 0.5 <= r <= 2):
|
|
600
601
|
rep.add("error", "bad-value", lid, f"{where}rate is {r!r}; it is how fast the text is spoken, from 0.5 to 2")
|
|
602
|
+
b, o = a.get("backend"), a.get("options")
|
|
603
|
+
if b is not None and not (isinstance(b, str) and b.strip()):
|
|
604
|
+
rep.add("error", "bad-value", lid, f"{where}backend is {b!r}; it names the voice backend, like \"kokoro\"")
|
|
605
|
+
if o is not None and not isinstance(o, dict):
|
|
606
|
+
rep.add("error", "bad-value", lid, f"{where}options is {o!r}; it is an object the backend reads, like "
|
|
607
|
+
"{\"exaggeration\": 0.7}")
|
|
601
608
|
if not a.get("src") and not a.get("text"):
|
|
602
609
|
rep.add("error", "missing-key", lid, f"{where}an audio entry needs 'src' (or 'text' to speak)",
|
|
603
610
|
"\"src\": \"music.mp3\"")
|
|
@@ -5,30 +5,41 @@ A sound with text is spoken by a backend:
|
|
|
5
5
|
{"id": "vo1", "role": "voice", "text": "Write a scene. [[check]]Check it.", "speaker": "af_heart"}
|
|
6
6
|
|
|
7
7
|
writes voice/vo1.wav (or its src) and voice/vo1.words.json. [[name]] gives the next word that id, so a cue can
|
|
8
|
-
say "vo1:check" whatever the word is. A
|
|
9
|
-
|
|
8
|
+
say "vo1:check" whatever the word is. A tag such as [laughs] or [whispers] tells a backend that acts on tags how
|
|
9
|
+
to say what follows; it is not a word, and other backends never see it. A sound is spoken again only when its text,
|
|
10
|
+
speaker, rate, backend or options changed since its file was made (or with force); `kine check` warns when they
|
|
11
|
+
changed and the file was not remade.
|
|
10
12
|
|
|
11
13
|
A backend is a function registered under the entry-point group "kinescript.voice" (kinescript-kokoro is one) that
|
|
12
14
|
takes {"text", "speaker", "rate", "out"}, writes a WAV to out, and returns {"words": [{"word", "start", "end"}],
|
|
13
|
-
"speaker", "version", "seconds"} (seconds, the file's length, is optional).
|
|
14
|
-
|
|
15
|
+
"speaker", "version", "seconds"} (seconds, the file's length, is optional). The request also carries "tagged" (the
|
|
16
|
+
text with its tags), "options" (the sound's options, as given) and "base" (the scene's folder, for files the options
|
|
17
|
+
or speaker name). A backend that acts on tags returns "tags": true, anything it could not do in "ignored", and
|
|
18
|
+
words it could not time in "note".
|
|
19
|
+
A sound picks its backend with "backend"; KINE_VOICE_CMD_<NAME> runs that backend in another Python, and
|
|
20
|
+
KINE_VOICE_CMD runs the default one (or the backend whose module it runs, python -m kinescript_<name>). A command
|
|
21
|
+
reads a JSON list of those requests on stdin and prints the list of results. Scene text never reaches a shell.
|
|
15
22
|
"""
|
|
16
23
|
from __future__ import annotations
|
|
17
24
|
|
|
25
|
+
import contextlib
|
|
18
26
|
import hashlib
|
|
19
27
|
import json
|
|
20
28
|
import os
|
|
21
29
|
import re
|
|
22
30
|
import shlex
|
|
23
31
|
import subprocess
|
|
32
|
+
import sys
|
|
24
33
|
import time
|
|
25
34
|
from pathlib import Path
|
|
26
35
|
|
|
27
36
|
from . import words as W
|
|
28
37
|
|
|
29
38
|
GROUP = "kinescript.voice"
|
|
30
|
-
KOKORO = "kinescript-kokoro
|
|
39
|
+
KOKORO = "kinescript-kokoro"
|
|
31
40
|
MARK = re.compile(r"\[\[\s*(\w+)\s*\]\]")
|
|
41
|
+
# [laughs], [clears throat]: in single brackets, and neither a mark ([[name]]) nor a pronunciation ([word](/ph/)).
|
|
42
|
+
TAG = re.compile(r"(?<!\[)\[(?!\[)[^\[\]()]+\](?")
|
|
32
43
|
|
|
33
44
|
|
|
34
45
|
def default_src(sid: str) -> str:
|
|
@@ -36,17 +47,29 @@ def default_src(sid: str) -> str:
|
|
|
36
47
|
|
|
37
48
|
|
|
38
49
|
def fingerprint(a: dict) -> str:
|
|
39
|
-
"""What the file was made from: text, speaker
|
|
50
|
+
"""What the file was made from: text, speaker, rate, and the backend and options when the sound names them.
|
|
51
|
+
A change in any means it must be spoken again."""
|
|
40
52
|
spec = {"text": a.get("text"), "speaker": a.get("speaker"), "rate": a.get("rate", 1)}
|
|
53
|
+
spec.update({k: a[k] for k in ("backend", "options") if a.get(k)}) # absent: the same hash as before they existed
|
|
41
54
|
return hashlib.sha256(json.dumps(spec, sort_keys=True).encode()).hexdigest()[:16]
|
|
42
55
|
|
|
43
56
|
|
|
57
|
+
def tags(text: str) -> list[str]:
|
|
58
|
+
return TAG.findall(text)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def untag(text: str) -> str:
|
|
62
|
+
"""The text without its tags: what a backend that does not act on tags says, and the words marks count."""
|
|
63
|
+
return re.sub(r"\s+", " ", TAG.sub("", text)).strip()
|
|
64
|
+
|
|
65
|
+
|
|
44
66
|
def split_marks(text: str) -> tuple[str, dict[int, str]]:
|
|
45
|
-
"""'Go [[x]]now' -> ('Go now', {1: 'x'}): the text to speak, and the word index each mark names.
|
|
67
|
+
"""'Go [[x]]now' -> ('Go now', {1: 'x'}): the text to speak, and the word index each mark names. Tags stay in
|
|
68
|
+
the text and are not counted as words."""
|
|
46
69
|
out, marks = [], {}
|
|
47
70
|
for k, part in enumerate(MARK.split(text)):
|
|
48
71
|
if k % 2:
|
|
49
|
-
n = len(" ".join(out).split())
|
|
72
|
+
n = len(untag(" ".join(out)).split())
|
|
50
73
|
if n in marks:
|
|
51
74
|
raise ValueError(f"two marks, [[{marks[n]}]] and [[{part}]], name the same word")
|
|
52
75
|
marks[n] = part
|
|
@@ -124,39 +147,61 @@ def backends() -> dict:
|
|
|
124
147
|
return {e.name: e for e in entry_points(group=GROUP)}
|
|
125
148
|
|
|
126
149
|
|
|
127
|
-
def
|
|
128
|
-
cmd = os.environ.get("KINE_VOICE_CMD", "").strip()
|
|
129
|
-
if not cmd:
|
|
130
|
-
return None
|
|
150
|
+
def _argv(cmd: str) -> list[str]:
|
|
131
151
|
if cmd.startswith("["):
|
|
132
152
|
return [str(x) for x in json.loads(cmd)]
|
|
133
153
|
return [t.strip('"') for t in shlex.split(cmd, posix=os.name != "nt")]
|
|
134
154
|
|
|
135
155
|
|
|
156
|
+
def _env_name(name: str) -> str:
|
|
157
|
+
return "KINE_VOICE_CMD_" + re.sub(r"\W", "_", name).upper()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _command(name: str | None = None) -> tuple[list[str], str] | None:
|
|
161
|
+
"""The command that runs backend `name` in another Python, and the variable it came from: KINE_VOICE_CMD_<NAME>,
|
|
162
|
+
else KINE_VOICE_CMD when no backend is named or it runs that backend's module (-m kinescript_<name>)."""
|
|
163
|
+
own = os.environ.get(_env_name(name), "").strip() if name else ""
|
|
164
|
+
if own:
|
|
165
|
+
return _argv(own), _env_name(name)
|
|
166
|
+
cmd = os.environ.get("KINE_VOICE_CMD", "").strip()
|
|
167
|
+
if cmd and (not name or f"kinescript_{name}" in _argv(cmd)):
|
|
168
|
+
return _argv(cmd), "KINE_VOICE_CMD"
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
136
172
|
def _speaker(name: str | None):
|
|
137
173
|
"""The function that speaks a list of requests, and its name."""
|
|
138
|
-
|
|
139
|
-
|
|
174
|
+
found = backends()
|
|
175
|
+
cmd = _command(name)
|
|
176
|
+
if cmd and (not name or name not in found or cmd[1] != "KINE_VOICE_CMD"):
|
|
177
|
+
argv, var = cmd
|
|
178
|
+
|
|
140
179
|
def run(reqs):
|
|
141
|
-
res = subprocess.run(
|
|
180
|
+
res = subprocess.run(argv, input=json.dumps(reqs), capture_output=True, text=True, encoding="utf-8")
|
|
142
181
|
if res.returncode:
|
|
143
|
-
raise RuntimeError(f"
|
|
182
|
+
raise RuntimeError(f"{var} failed ({res.returncode}): {res.stderr.strip()[-600:]}")
|
|
144
183
|
return json.loads(res.stdout)
|
|
145
|
-
return run, Path(
|
|
146
|
-
found = backends()
|
|
184
|
+
return run, name or Path(argv[-1]).name
|
|
147
185
|
if name:
|
|
148
186
|
if name not in found:
|
|
149
|
-
raise ValueError(f"no voice backend '{name}'
|
|
187
|
+
raise ValueError(f"no voice backend '{name}': pip install kinescript-{name}, or set {_env_name(name)} to a "
|
|
188
|
+
f"command that runs it in another Python; installed: {', '.join(found) or 'none'}")
|
|
150
189
|
key = name
|
|
151
190
|
elif len(found) == 1:
|
|
152
191
|
key = next(iter(found))
|
|
153
192
|
elif found:
|
|
154
193
|
raise ValueError(f"several voice backends are installed ({', '.join(found)}); pick one with --backend")
|
|
155
194
|
else:
|
|
156
|
-
raise ValueError(f"no voice backend is installed: pip install
|
|
195
|
+
raise ValueError(f"no voice backend is installed: pip install {KOKORO} (Python 3.10 to 3.12), "
|
|
157
196
|
"or set KINE_VOICE_CMD to a command that speaks")
|
|
158
|
-
|
|
159
|
-
|
|
197
|
+
# What a backend prints goes to stderr: kine voice answers in JSON on stdout, and kine mcp speaks JSON-RPC there.
|
|
198
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
199
|
+
fn = found[key].load()
|
|
200
|
+
|
|
201
|
+
def run(reqs):
|
|
202
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
203
|
+
return [fn(r) for r in reqs]
|
|
204
|
+
return run, key
|
|
160
205
|
|
|
161
206
|
|
|
162
207
|
def generate(scene, only=None, force: bool = False, backend: str | None = None, base=None) -> dict:
|
|
@@ -181,24 +226,36 @@ def generate(scene, only=None, force: bool = False, backend: str | None = None,
|
|
|
181
226
|
continue
|
|
182
227
|
src.parent.mkdir(parents=True, exist_ok=True)
|
|
183
228
|
todo.append((sid, a, src, words_file, text, marks))
|
|
184
|
-
|
|
185
|
-
|
|
229
|
+
groups: dict = {} # a sound's own backend, else the one asked for, else the default
|
|
230
|
+
for job in todo:
|
|
231
|
+
groups.setdefault(job[1].get("backend") or backend, []).append(job)
|
|
232
|
+
for want, jobs in groups.items():
|
|
233
|
+
speak, name = _speaker(want)
|
|
186
234
|
t0 = time.perf_counter()
|
|
187
|
-
results = speak([{"text": text, "speaker": a.get("speaker"), "rate": a.get("rate", 1),
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
235
|
+
results = speak([{"text": untag(text), "tagged": text, "speaker": a.get("speaker"), "rate": a.get("rate", 1),
|
|
236
|
+
"options": a.get("options") or {}, "out": str(src), "base": str(Path(base).resolve())}
|
|
237
|
+
for _, a, src, _, text, _ in jobs])
|
|
238
|
+
took = (time.perf_counter() - t0) / len(jobs)
|
|
239
|
+
for (sid, a, src, words_file, text, marks), res in zip(jobs, results):
|
|
191
240
|
if not src.exists():
|
|
192
241
|
raise RuntimeError(f"the voice backend {name} did not write {src} for '{sid}'")
|
|
193
242
|
timed = [{"word": w["word"], "start": w["start"], "end": w["end"]} for w in W.read(res.get("words") or [])]
|
|
194
|
-
said = place_marks(text, marks, timed)
|
|
243
|
+
said = place_marks(untag(text), marks, timed)
|
|
195
244
|
W.read(said) # the ids are valid and unique before the file is written
|
|
196
245
|
rec = {"hash": fingerprint(a), "backend": name, "version": res.get("version"),
|
|
197
246
|
"speaker": res.get("speaker") or a.get("speaker")}
|
|
198
247
|
words_file.write_text(json.dumps({"words": said, "made": rec}, indent=1, ensure_ascii=False),
|
|
199
248
|
encoding="utf-8")
|
|
200
249
|
secs = res.get("seconds") or (said[-1]["end"] if said else 0.0)
|
|
201
|
-
|
|
202
|
-
|
|
250
|
+
item = {"id": sid, "src": str(a.get("src") or default_src(sid)), "status": "made", "words": len(said),
|
|
251
|
+
"seconds": round(float(secs), 2), "took": round(took, 2)}
|
|
252
|
+
ignored = list(res.get("ignored") or []) + ([] if res.get("tags") else tags(text))
|
|
253
|
+
if ignored:
|
|
254
|
+
item["ignored"] = ignored # said without these: the backend cannot do them
|
|
255
|
+
if res.get("note"):
|
|
256
|
+
item["note"] = str(res["note"])
|
|
257
|
+
elif not said and untag(text).strip():
|
|
258
|
+
item["note"] = f"{name} gave no word times, so word cues and voice captions of '{sid}' do not work"
|
|
259
|
+
out.append(item)
|
|
203
260
|
order = {sid: k for k, (sid, _) in enumerate(entries)}
|
|
204
261
|
return {"ok": True, "voices": sorted(out, key=lambda v: order[v["id"]])}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kinescript
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Render motion graphics, video edits and voice-over from JSON scenes on the GPU, with checks an agent can read.
|
|
5
5
|
Author: Dr. Know How
|
|
6
6
|
License-Expression: MIT
|
|
@@ -133,9 +133,15 @@ Sounds are a top-level `audio` list, timed like layers and mixed by ffmpeg while
|
|
|
133
133
|
word: `"at": "vo1:render"` starts a layer as the voice says "render", and `{"type": "captions", "voice": "vo1"}`
|
|
134
134
|
captions it, lighting each word as it is said.
|
|
135
135
|
- **Voice-over from text.** Write what a voice says in `text` and `kine voice` speaks it through a plugin, writing
|
|
136
|
-
the file and its word times
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
the file and its word times: [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
|
|
137
|
+
(local, fast),
|
|
138
|
+
[Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local, tags,
|
|
139
|
+
cloned voices) or [ElevenLabs](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs)
|
|
140
|
+
(cloud, needs an API key), picked per sound with `backend`. `[[go]]` in the text names the next
|
|
141
|
+
word, so a cue to `vo1:go` survives a rewrite of the line. Only a line whose text, speaker, rate, backend or
|
|
142
|
+
options changed is spoken again, and rendering never calls a speech engine.
|
|
143
|
+
- **Delivery tags.** `[whispers]`, `[laughs]` or `[sighs]` in the text tell Chatterbox and ElevenLabs how to say
|
|
144
|
+
what follows. Tags are not words, so cues, marks and captions skip them, and Kokoro says the line without them.
|
|
139
145
|
- **A mix without listening.** Give a sound a `role` and it is set to that role's level: voice -18 LUFS, music
|
|
140
146
|
-16, ambience -30, effects peaking at -6 dBFS. Music and ambience dip 14 dB while a voice speaks, an effect's
|
|
141
147
|
leading silence is skipped so it lands on its cue, and the whole mix ends at -16 LUFS with peaks under
|
|
@@ -164,7 +170,7 @@ A narrated beat with a click on a marked word, over music that ducks under the v
|
|
|
164
170
|
to its scene file. The Kokoro plugin needs Python 3.10 to 3.12:
|
|
165
171
|
|
|
166
172
|
```bash
|
|
167
|
-
pip install
|
|
173
|
+
pip install kinescript-kokoro
|
|
168
174
|
```
|
|
169
175
|
|
|
170
176
|
On a newer Python, install it into another one and point `KINE_VOICE_CMD` at it
|
|
@@ -300,7 +306,8 @@ Within one process, such as the MCP server, the workers stay warm for two minute
|
|
|
300
306
|
- Audio is mixing and levelling (trim, gain over time, fades, speed, loop, pan, role levels, ducking, a loudness
|
|
301
307
|
target), with no effects such as EQ, compression of single sounds or reverb.
|
|
302
308
|
- Kinescript does not speak by itself: `kine voice` needs a plugin or a command. The Kokoro plugin times words
|
|
303
|
-
for English voices only and needs Python 3.10 to 3.12
|
|
309
|
+
for English voices only and needs Python 3.10 to 3.12; the Chatterbox plugin speaks and times English only;
|
|
310
|
+
the ElevenLabs plugin needs an API key and a network connection.
|
|
304
311
|
- Video is decoded and resampled by ffmpeg to the scene frame rate. Jumping backwards or far ahead reopens the
|
|
305
312
|
decoder, which is slower than sequential playback.
|
|
306
313
|
- Text layout is single-script left-to-right with greedy wrapping. No shaping for complex scripts or bidi.
|
|
@@ -313,7 +320,8 @@ kinescript/ engine: timing, props, scene, components, render (Skia), space, re
|
|
|
313
320
|
mesh and models (3D), output (encoding and the audio mix),
|
|
314
321
|
verify (check, look, probe), schema, docs, mcp, cli
|
|
315
322
|
examples/ example scenes and the generator for sample media
|
|
316
|
-
plugins/
|
|
323
|
+
plugins/ voices for kine voice, each installed on its own: kinescript-kokoro, kinescript-chatterbox,
|
|
324
|
+
kinescript-elevenlabs
|
|
317
325
|
marketing/ the launch video as a scene and its builder; build_site.py renders the site
|
|
318
326
|
site/ the project site: demo scenes next to the videos rendered from them
|
|
319
327
|
brand/ logo files, generated art and the brand guide
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kinescript"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.3.0"
|
|
8
8
|
description = "Render motion graphics, video edits and voice-over from JSON scenes on the GPU, with checks an agent can read."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -886,6 +886,196 @@ def test_marks_find_their_word_when_the_voice_reads_it_differently():
|
|
|
886
886
|
split_marks("[[a]]x [[a]]y")
|
|
887
887
|
|
|
888
888
|
|
|
889
|
+
def test_tags_are_not_words_and_reach_only_backends_that_act_on_them(monkeypatch, tmp_path):
|
|
890
|
+
from kinescript import voice
|
|
891
|
+
text, marks = voice.split_marks("Well [sighs] okay, [[go]]go [laughs]now.")
|
|
892
|
+
assert (text, marks) == ("Well [sighs] okay, go [laughs]now.", {2: "go"})
|
|
893
|
+
assert voice.untag(text) == "Well okay, go now."
|
|
894
|
+
# marks and pronunciations are not tags
|
|
895
|
+
assert voice.tags("Meet [Kinescript](/kˈɪn/) [[a]]now [clears throat] ok") == ["[clears throat]"]
|
|
896
|
+
got = []
|
|
897
|
+
|
|
898
|
+
def entry(acts):
|
|
899
|
+
def speak(req):
|
|
900
|
+
got.append((req["text"], req["tagged"]))
|
|
901
|
+
return {**_fake_speak(req), "tags": acts}
|
|
902
|
+
return type("E", (), {"load": lambda self: speak})()
|
|
903
|
+
monkeypatch.delenv("KINE_VOICE_CMD", raising=False)
|
|
904
|
+
monkeypatch.setattr(voice, "backends", lambda: {"plain": entry(False), "acts": entry(True)})
|
|
905
|
+
spec = scene([{"type": "rect"}], duration="auto")
|
|
906
|
+
spec["audio"] = [{"id": "a", "text": "Well [sighs] okay. [[go]]Go.", "backend": "plain"},
|
|
907
|
+
{"id": "b", "text": "Well [sighs] okay. [[go]]Go.", "backend": "acts"}]
|
|
908
|
+
a, b = voice.generate(spec, base=tmp_path)["voices"]
|
|
909
|
+
assert got == [("Well okay. Go.", "Well [sighs] okay. Go.")] * 2
|
|
910
|
+
assert a["ignored"] == ["[sighs]"] and "ignored" not in b # said without it, and said so
|
|
911
|
+
words = json.loads((tmp_path / "voice" / "b.words.json").read_text())["words"]
|
|
912
|
+
assert [w["word"] for w in words] == ["Well", "okay.", "Go."] and words[2]["id"] == "go"
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def test_what_a_backend_prints_stays_off_stdout(monkeypatch, tmp_path, capsys):
|
|
916
|
+
# kine voice answers in JSON on stdout and kine mcp speaks JSON-RPC there; Chatterbox prints as it loads
|
|
917
|
+
from kinescript import voice
|
|
918
|
+
|
|
919
|
+
def speak(req):
|
|
920
|
+
print("loaded PerthNet")
|
|
921
|
+
return _fake_speak(req)
|
|
922
|
+
monkeypatch.delenv("KINE_VOICE_CMD", raising=False)
|
|
923
|
+
monkeypatch.setattr(voice, "backends", lambda: {"loud": type("E", (), {"load": lambda self: speak})()})
|
|
924
|
+
spec = scene([{"type": "rect"}], duration="auto")
|
|
925
|
+
spec["audio"] = [{"id": "a", "text": "hello"}]
|
|
926
|
+
assert voice.generate(spec, base=tmp_path)["voices"][0]["status"] == "made"
|
|
927
|
+
out, err = capsys.readouterr()
|
|
928
|
+
assert out == "" and "loaded PerthNet" in err
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def test_each_sound_picks_its_backend_and_options(monkeypatch, tmp_path):
|
|
932
|
+
import hashlib
|
|
933
|
+
from kinescript import voice
|
|
934
|
+
seen = []
|
|
935
|
+
|
|
936
|
+
def entry(name):
|
|
937
|
+
def speak(req):
|
|
938
|
+
seen.append((name, req["options"], req["base"]))
|
|
939
|
+
return {**_fake_speak(req), **({"note": "half untimed"} if name == "two" else {})}
|
|
940
|
+
return type("E", (), {"load": lambda self: speak})()
|
|
941
|
+
monkeypatch.delenv("KINE_VOICE_CMD", raising=False)
|
|
942
|
+
monkeypatch.setattr(voice, "backends", lambda: {"one": entry("one"), "two": entry("two")})
|
|
943
|
+
spec = scene([{"type": "rect"}], duration="auto")
|
|
944
|
+
spec["audio"] = [{"id": "a", "text": "first"},
|
|
945
|
+
{"id": "b", "text": "second", "engine": "two", "settings": {"seed": 3}}] # aliases read
|
|
946
|
+
with pytest.raises(ValueError, match="several voice backends are installed"):
|
|
947
|
+
voice.generate(spec, base=tmp_path)
|
|
948
|
+
got = voice.generate(spec, base=tmp_path, backend="one")
|
|
949
|
+
assert [v.get("note") for v in got["voices"]] == [None, "half untimed"] # the backend's own note
|
|
950
|
+
assert seen == [("one", {}, str(tmp_path.resolve())), ("two", {"seed": 3}, str(tmp_path.resolve()))]
|
|
951
|
+
assert json.loads((tmp_path / "voice" / "b.words.json").read_text())["made"]["backend"] == "two"
|
|
952
|
+
# options are part of what the file was made from; a sound without backend or options keeps its old hash
|
|
953
|
+
f = tmp_path / "s.json"
|
|
954
|
+
spec["audio"] = [{"id": "b", "text": "second", "backend": "two", "options": {"seed": 4}}]
|
|
955
|
+
f.write_text(json.dumps(spec))
|
|
956
|
+
assert [i["code"] for i in k.load(str(f)).issues] == ["voice-stale"]
|
|
957
|
+
old = {"text": "x", "speaker": None, "rate": 1}
|
|
958
|
+
assert voice.fingerprint({"text": "x"}) == hashlib.sha256(json.dumps(old, sort_keys=True).encode()).hexdigest()[:16]
|
|
959
|
+
with pytest.raises(ValueError, match="no voice backend 'three': pip install kinescript-three, or set "
|
|
960
|
+
"KINE_VOICE_CMD_THREE"):
|
|
961
|
+
voice.generate({**spec, "audio": [{"id": "c", "text": "x", "backend": "three"}]}, base=tmp_path)
|
|
962
|
+
# a backend in another Python, by name: its own variable, or KINE_VOICE_CMD when it runs that module
|
|
963
|
+
tool = tmp_path / "speak.py"
|
|
964
|
+
tool.write_text("import json, sys\nsys.path.insert(0, %r)\nfrom test_core import _fake_speak\n"
|
|
965
|
+
"print(json.dumps([_fake_speak(r) for r in json.load(sys.stdin)]))\n" % str(Path(__file__).parent))
|
|
966
|
+
monkeypatch.setenv("KINE_VOICE_CMD_THREE", json.dumps([sys.executable, str(tool)]))
|
|
967
|
+
got = voice.generate({**spec, "audio": [{"id": "c", "text": "x y", "backend": "three"}]}, base=tmp_path)
|
|
968
|
+
assert got["voices"][0]["words"] == 2
|
|
969
|
+
assert voice._command("kokoro") is None
|
|
970
|
+
monkeypatch.setenv("KINE_VOICE_CMD", "py -m kinescript_kokoro")
|
|
971
|
+
assert voice._command("kokoro") == (["py", "-m", "kinescript_kokoro"], "KINE_VOICE_CMD")
|
|
972
|
+
assert voice._command("other") is None and voice._command(None)[1] == "KINE_VOICE_CMD"
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _plugin(monkeypatch, name):
|
|
976
|
+
monkeypatch.syspath_prepend(str(Path(__file__).parents[1] / "plugins" / f"kinescript-{name}"))
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def test_forced_alignment_places_words_around_a_laugh_and_a_number(monkeypatch):
|
|
980
|
+
_plugin(monkeypatch, "chatterbox")
|
|
981
|
+
from kinescript_chatterbox.align import ctc_path, times, units
|
|
982
|
+
vocab = {"<pad>": 0, "|": 1, "A": 2, "B": 3, "C": 4, "D": 5}
|
|
983
|
+
words, seq = units("AB [laugh] C 12.", vocab)
|
|
984
|
+
assert words == ["AB", "C", "12."] and [s[0] for s in seq] == [2, 3, -2, 4, 1, -1]
|
|
985
|
+
|
|
986
|
+
def frames(said): # what each 20 ms frame sounds like most: a vocabulary id, 0 for nothing
|
|
987
|
+
lp = np.full((len(said), len(vocab)), np.log(0.02))
|
|
988
|
+
lp[np.arange(len(said)), said] = np.log(0.9)
|
|
989
|
+
return lp
|
|
990
|
+
# silence, A A B, a laugh (heard as D D D), silence, C C, a word gap, "twelve" (D D D), silence
|
|
991
|
+
said = [0, 0, 2, 2, 3, 5, 5, 5, 0, 4, 4, 1, 5, 5, 5, 0]
|
|
992
|
+
got = times(ctc_path(frames(said), seq, 0), seq, words, offset=1)
|
|
993
|
+
assert got == [{"word": "AB", "start": 1.04, "end": 1.1}, {"word": "C", "start": 1.18, "end": 1.22},
|
|
994
|
+
{"word": "12.", "start": 1.24, "end": 1.3}]
|
|
995
|
+
# no laugh at all: the tag takes no time
|
|
996
|
+
said = [0, 2, 3, 0, 4, 1, 5, 0]
|
|
997
|
+
assert [w["start"] for w in times(ctc_path(frames(said), seq, 0), seq, words)] == [0.02, 0.08, 0.12]
|
|
998
|
+
with pytest.raises(ValueError, match="too short"):
|
|
999
|
+
ctc_path(frames([2, 3]), seq, 0)
|
|
1000
|
+
# a line of more than 127 states (63 letters and gaps) backtracks without overflowing
|
|
1001
|
+
words, seq = units(" ".join(["AB"] * 30), vocab)
|
|
1002
|
+
said = [0] + [2, 3, 1] * 30
|
|
1003
|
+
assert [w["start"] for w in times(ctc_path(frames(said), seq, 0), seq, words)][-1] == 1.76
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def test_forced_alignment_reads_numbers_as_they_are_said(monkeypatch):
|
|
1007
|
+
_plugin(monkeypatch, "chatterbox")
|
|
1008
|
+
from kinescript_chatterbox.align import spoken, units
|
|
1009
|
+
said = {w: " ".join(spoken(w)) for w in ["250", "2026", "2005", "1,000", "3.5", "v1.2.0", "$5", "40%", "21st",
|
|
1010
|
+
"100th", "Render.", "—"]}
|
|
1011
|
+
assert said == {"250": "TWO HUNDRED FIFTY", "2026": "TWENTY TWENTY SIX", "2005": "TWO THOUSAND FIVE",
|
|
1012
|
+
"1,000": "ONE THOUSAND", "3.5": "THREE POINT FIVE", "v1.2.0": "V ONE POINT TWO POINT ZERO",
|
|
1013
|
+
"$5": "FIVE DOLLARS", "40%": "FORTY PERCENT", "21st": "TWENTY FIRST", "100th": "ONE HUNDREDTH",
|
|
1014
|
+
"Render.": "RENDER", "—": ""}
|
|
1015
|
+
vocab = {"<pad>": 0, "|": 1, **{c: 2 + i for i, c in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZ'")}}
|
|
1016
|
+
words, seq = units("took 12 — now", vocab)
|
|
1017
|
+
# each word of a number is its own run of letters, a lone dash says nothing
|
|
1018
|
+
assert words == ["took", "12", "now"]
|
|
1019
|
+
assert "".join("|" if s[0] == 1 else "?" if s[0] < 0 else chr(63 + s[0]) for s in seq) == "TOOK|TWELVE|NOW"
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def test_chatterbox_reads_tags_written_for_other_backends(monkeypatch):
|
|
1023
|
+
_plugin(monkeypatch, "chatterbox")
|
|
1024
|
+
import kinescript_chatterbox as cb
|
|
1025
|
+
monkeypatch.setattr(cb, "TURBO_TAGS", {"[laugh]", "[sigh]", "[chuckle]", "[clear throat]", "[whispering]"})
|
|
1026
|
+
written = ["[laugh]", "[Laughs]", "[laughing]", "[sighs]", "[chuckles]", "[whispers]", "[clears throat]", "[sings]"]
|
|
1027
|
+
assert [cb.turbo_tag(t) for t in written] == ["[laugh]", "[laugh]", "[laugh]", "[sigh]", "[chuckle]",
|
|
1028
|
+
"[whispering]", "[clear throat]", None]
|
|
1029
|
+
long = "One two three. " * 30
|
|
1030
|
+
assert all(len(c) <= 300 for c in cb.chunks(long)) and " ".join(cb.chunks(long)) == long.strip()
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def test_elevenlabs_times_words_from_its_character_times(monkeypatch, tmp_path):
|
|
1034
|
+
import base64
|
|
1035
|
+
import wave
|
|
1036
|
+
_plugin(monkeypatch, "elevenlabs")
|
|
1037
|
+
import kinescript_elevenlabs as el
|
|
1038
|
+
text = "Hi [laughs] there, all."
|
|
1039
|
+
chars = list(text)
|
|
1040
|
+
starts = [0.1 * k for k in range(len(chars))]
|
|
1041
|
+
words = el.words_from_chars(chars, starts, [s + 0.08 for s in starts])
|
|
1042
|
+
assert [w["word"] for w in words] == ["Hi", "there,", "all."]
|
|
1043
|
+
assert words[1] == {"word": "there,", "start": 1.2, "end": 1.68} # first to last letter, not the comma
|
|
1044
|
+
calls = []
|
|
1045
|
+
|
|
1046
|
+
def call(path, key, body=None, ctype="application/json"):
|
|
1047
|
+
calls.append((path, json.loads(body) if ctype == "application/json" and body else body))
|
|
1048
|
+
if path == "/v1/voices":
|
|
1049
|
+
return {"voices": [{"voice_id": "a" * 20, "name": "Aria - calm"}]}
|
|
1050
|
+
if path.startswith("/v1/forced-alignment"):
|
|
1051
|
+
return {"words": [{"text": "Hi", "start": 0.0, "end": 0.2}, {"text": " ", "start": 0.2, "end": 0.3},
|
|
1052
|
+
{"text": "there,", "start": 0.3, "end": 0.6}]}
|
|
1053
|
+
timing = None if "multilingual" in json.loads(body)["model_id"] else {
|
|
1054
|
+
"characters": chars, "character_start_times_seconds": starts,
|
|
1055
|
+
"character_end_times_seconds": [s + 0.08 for s in starts]}
|
|
1056
|
+
return {"audio_base64": base64.b64encode(b"\0\0" * 24000).decode(), "alignment": timing}
|
|
1057
|
+
monkeypatch.setattr(el, "_call", call)
|
|
1058
|
+
monkeypatch.setenv("ELEVENLABS_API_KEY", "k")
|
|
1059
|
+
out = tmp_path / "v.wav"
|
|
1060
|
+
req = {"text": "Hi there, all.", "tagged": text, "speaker": "Aria", "rate": 1.1, "out": str(out),
|
|
1061
|
+
"options": {"stability": 0.3, "model": "eleven_v4", "loud": 1}}
|
|
1062
|
+
res = el.synth(req)
|
|
1063
|
+
assert calls[1] == (f"/v1/text-to-speech/{'a' * 20}/with-timestamps?output_format=pcm_24000",
|
|
1064
|
+
{"text": text, "model_id": "eleven_v4", "voice_settings": {"stability": 0.3, "speed": 1.1}})
|
|
1065
|
+
assert res["tags"] and res["ignored"] == ["options.loud"] and res["seconds"] == 1.0 and len(res["words"]) == 3
|
|
1066
|
+
with wave.open(str(out)) as f:
|
|
1067
|
+
assert (f.getframerate(), f.getnframes()) == (24000, 24000)
|
|
1068
|
+
# a model without tags gets the text without them; with no character times, forced alignment times the words
|
|
1069
|
+
calls.clear()
|
|
1070
|
+
res = el.synth({**req, "rate": 2, "speaker": "b" * 20, "options": {"model": "eleven_multilingual_v2"}})
|
|
1071
|
+
assert calls[0][1]["text"] == "Hi there, all." and not res["tags"]
|
|
1072
|
+
assert res["ignored"] == ["rate 2: ElevenLabs speaks at 0.7 to 1.2"] and calls[1][0] == "/v1/forced-alignment"
|
|
1073
|
+
assert [w["word"] for w in res["words"]] == ["Hi", "there,"]
|
|
1074
|
+
monkeypatch.delenv("ELEVENLABS_API_KEY")
|
|
1075
|
+
with pytest.raises(RuntimeError, match="set ELEVENLABS_API_KEY"):
|
|
1076
|
+
el.synth(req)
|
|
1077
|
+
|
|
1078
|
+
|
|
889
1079
|
def test_kokoro_tokens_become_the_words_of_the_text(monkeypatch):
|
|
890
1080
|
from types import SimpleNamespace as T
|
|
891
1081
|
monkeypatch.syspath_prepend(str(Path(__file__).parents[1] / "plugins" / "kinescript-kokoro"))
|
|
@@ -13,7 +13,10 @@ from urllib.parse import unquote, urlparse
|
|
|
13
13
|
import pytest
|
|
14
14
|
|
|
15
15
|
ROOT = Path(__file__).resolve().parents[1]
|
|
16
|
-
|
|
16
|
+
# Every package this repository publishes to PyPI: the folder with its pyproject.toml and README.md.
|
|
17
|
+
PACKAGES = {"kinescript": ROOT, **{f"kinescript-{p}": ROOT / "plugins" / f"kinescript-{p}"
|
|
18
|
+
for p in ("kokoro", "chatterbox", "elevenlabs")}}
|
|
19
|
+
each_package = pytest.mark.parametrize("pkg", list(PACKAGES))
|
|
17
20
|
|
|
18
21
|
# Link targets that work on PyPI. Everything else (docs/hero.gif, ../x, /x, //host/x, a bare filename) is
|
|
19
22
|
# resolved against pypi.org and breaks.
|
|
@@ -49,14 +52,15 @@ def relative_targets(text):
|
|
|
49
52
|
return [t for t in link_targets(text) if not ABSOLUTE.match(t)]
|
|
50
53
|
|
|
51
54
|
|
|
52
|
-
def _readme():
|
|
53
|
-
return README.read_text(encoding="utf-8")
|
|
55
|
+
def _readme(pkg="kinescript"):
|
|
56
|
+
return (PACKAGES[pkg] / "README.md").read_text(encoding="utf-8")
|
|
54
57
|
|
|
55
58
|
|
|
56
59
|
# --- README.md ---------------------------------------------------------------------------------------------
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
@each_package
|
|
62
|
+
def test_readme_has_no_relative_links_or_images(pkg):
|
|
63
|
+
bad = relative_targets(_readme(pkg))
|
|
60
64
|
assert not bad, (
|
|
61
65
|
"README.md is the PyPI long description, so a relative target resolves against pypi.org and breaks, "
|
|
62
66
|
"and PyPI drops any other scheme (data:, javascript:). "
|
|
@@ -72,10 +76,11 @@ REPO_FILE = re.compile(
|
|
|
72
76
|
)
|
|
73
77
|
|
|
74
78
|
|
|
75
|
-
|
|
79
|
+
@each_package
|
|
80
|
+
def test_readme_points_at_files_that_exist_in_the_repo(pkg):
|
|
76
81
|
# The README's images are served from the main branch. PyPI keeps the README of every release forever, so a
|
|
77
82
|
# file that is renamed or deleted breaks the page of every release that links to it.
|
|
78
|
-
hits = [m for m in map(REPO_FILE.match, link_targets(_readme())) if m]
|
|
83
|
+
hits = [m for m in map(REPO_FILE.match, link_targets(_readme(pkg))) if m]
|
|
79
84
|
missing = [m.group(0) for m in hits if not (ROOT / unquote(m.group(1))).exists()]
|
|
80
85
|
assert not missing, f"README.md links to repo files that do not exist: {missing}"
|
|
81
86
|
|
|
@@ -102,8 +107,9 @@ def _render(text):
|
|
|
102
107
|
return rm.render(text)
|
|
103
108
|
|
|
104
109
|
|
|
105
|
-
|
|
106
|
-
|
|
110
|
+
@each_package
|
|
111
|
+
def test_readme_renders_the_way_pypi_renders_it(pkg):
|
|
112
|
+
text = _readme(pkg)
|
|
107
113
|
out = _render(text)
|
|
108
114
|
assert out, "readme_renderer returned nothing: PyPI would show the plain README text instead of HTML"
|
|
109
115
|
out = unquote(html.unescape(out)) # comrak percent-encodes some characters in URLs
|
|
@@ -115,24 +121,26 @@ def test_readme_renders_the_way_pypi_renders_it():
|
|
|
115
121
|
|
|
116
122
|
# --- pyproject.toml ----------------------------------------------------------------------------------------
|
|
117
123
|
|
|
118
|
-
def _project():
|
|
124
|
+
def _project(pkg="kinescript"):
|
|
119
125
|
try:
|
|
120
126
|
import tomllib
|
|
121
127
|
except ModuleNotFoundError: # Python 3.10
|
|
122
128
|
tomllib = pytest.importorskip("tomli")
|
|
123
|
-
return tomllib.loads((
|
|
129
|
+
return tomllib.loads((PACKAGES[pkg] / "pyproject.toml").read_text(encoding="utf-8"))["project"]
|
|
124
130
|
|
|
125
131
|
|
|
126
|
-
|
|
127
|
-
|
|
132
|
+
@each_package
|
|
133
|
+
def test_summary_is_one_short_plain_line(pkg):
|
|
134
|
+
summary = _project(pkg)["description"]
|
|
128
135
|
assert 20 <= len(summary) <= 120, f"{len(summary)} characters: {summary!r}"
|
|
129
136
|
assert "\n" not in summary and not re.search(r"[`*\[\]<>]", summary), "the summary is plain text, not Markdown"
|
|
130
137
|
assert not summary.endswith(" "), summary
|
|
131
138
|
|
|
132
139
|
|
|
133
|
-
|
|
140
|
+
@each_package
|
|
141
|
+
def test_classifiers_are_all_valid(pkg):
|
|
134
142
|
trove = pytest.importorskip("trove_classifiers")
|
|
135
|
-
listed = _project()["classifiers"]
|
|
143
|
+
listed = _project(pkg)["classifiers"]
|
|
136
144
|
unknown = [c for c in listed if c not in trove.classifiers]
|
|
137
145
|
deprecated = [c for c in listed if c in trove.deprecated_classifiers]
|
|
138
146
|
assert not unknown, f"PyPI rejects the upload for classifiers it does not know: {unknown}"
|
|
@@ -142,13 +150,15 @@ def test_classifiers_are_all_valid():
|
|
|
142
150
|
assert not [c for c in listed if c.startswith("License ::")]
|
|
143
151
|
|
|
144
152
|
|
|
145
|
-
|
|
146
|
-
|
|
153
|
+
@each_package
|
|
154
|
+
def test_keywords_are_unique(pkg):
|
|
155
|
+
words = [k.lower() for k in _project(pkg)["keywords"]]
|
|
147
156
|
assert words and len(set(words)) == len(words)
|
|
148
157
|
|
|
149
158
|
|
|
150
|
-
|
|
151
|
-
|
|
159
|
+
@each_package
|
|
160
|
+
def test_project_links_are_complete_and_verifiable(pkg):
|
|
161
|
+
urls = _project(pkg)["urls"]
|
|
152
162
|
# These labels get an icon on PyPI (see docs.pypi.org/project_metadata, "Link icons").
|
|
153
163
|
for label in ("Homepage", "Documentation", "Source", "Issues", "Changelog"):
|
|
154
164
|
assert label in urls, f"missing project link {label!r}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|