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.
Files changed (54) hide show
  1. {kinescript-1.2.0 → kinescript-1.3.0}/PKG-INFO +15 -7
  2. {kinescript-1.2.0 → kinescript-1.3.0}/README.md +14 -6
  3. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/__init__.py +1 -1
  4. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/docs.py +24 -7
  5. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/mcp.py +3 -1
  6. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/scene.py +3 -2
  7. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/schema.py +10 -3
  8. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/voice.py +88 -31
  9. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript.egg-info/PKG-INFO +15 -7
  10. {kinescript-1.2.0 → kinescript-1.3.0}/pyproject.toml +1 -1
  11. {kinescript-1.2.0 → kinescript-1.3.0}/tests/test_core.py +190 -0
  12. {kinescript-1.2.0 → kinescript-1.3.0}/tests/test_packaging.py +29 -19
  13. {kinescript-1.2.0 → kinescript-1.3.0}/LICENSE +0 -0
  14. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/audio.py +0 -0
  15. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/cli.py +0 -0
  16. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/color.py +0 -0
  17. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/components.py +0 -0
  18. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/ease.py +0 -0
  19. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/IBMPlexMono-Bold.ttf +0 -0
  20. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/IBMPlexMono-Medium.ttf +0 -0
  21. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/IBMPlexMono-OFL.txt +0 -0
  22. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/IBMPlexMono-Regular.ttf +0 -0
  23. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  24. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/SpaceGrotesk-Bold.ttf +0 -0
  25. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/SpaceGrotesk-Medium.ttf +0 -0
  26. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/SpaceGrotesk-OFL.txt +0 -0
  27. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/SpaceGrotesk-Regular.ttf +0 -0
  28. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/fonts/SpaceGrotesk-SemiBold.ttf +0 -0
  29. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/listen.py +0 -0
  30. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/media.py +0 -0
  31. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/mesh.py +0 -0
  32. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/models.py +0 -0
  33. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/output.py +0 -0
  34. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/presets.py +0 -0
  35. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/props.py +0 -0
  36. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/render.py +0 -0
  37. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/render3d.py +0 -0
  38. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/shaders.py +0 -0
  39. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/space.py +0 -0
  40. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/svgpath.py +0 -0
  41. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/timing.py +0 -0
  42. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/verify.py +0 -0
  43. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript/words.py +0 -0
  44. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript.egg-info/SOURCES.txt +0 -0
  45. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript.egg-info/dependency_links.txt +0 -0
  46. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript.egg-info/entry_points.txt +0 -0
  47. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript.egg-info/requires.txt +0 -0
  48. {kinescript-1.2.0 → kinescript-1.3.0}/kinescript.egg-info/top_level.txt +0 -0
  49. {kinescript-1.2.0 → kinescript-1.3.0}/setup.cfg +0 -0
  50. {kinescript-1.2.0 → kinescript-1.3.0}/tests/test_color_pipeline.py +0 -0
  51. {kinescript-1.2.0 → kinescript-1.3.0}/tests/test_docs.py +0 -0
  52. {kinescript-1.2.0 → kinescript-1.3.0}/tests/test_models.py +0 -0
  53. {kinescript-1.2.0 → kinescript-1.3.0}/tests/test_motion_blur.py +0 -0
  54. {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.2.0
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. [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
137
- is the first. `[[go]]` in the text names the next word, so a cue to `vo1:go` survives a rewrite of the line.
138
- Only a line whose text, speaker or rate changed is spoken again, and rendering never calls a speech engine.
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 "kinescript-kokoro @ git+https://github.com/drknowhow/kinescript#subdirectory=plugins/kinescript-kokoro"
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/ kinescript-kokoro, the Kokoro voice for kine voice (installed on its own)
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. [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
90
- is the first. `[[go]]` in the text names the next word, so a cue to `vo1:go` survives a rewrite of the line.
91
- Only a line whose text, speaker or rate changed is spoken again, and rendering never calls a speech engine.
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 "kinescript-kokoro @ git+https://github.com/drknowhow/kinescript#subdirectory=plugins/kinescript-kokoro"
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/ kinescript-kokoro, the Kokoro voice for kine voice (installed on its own)
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.2.0"
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. kine check reports a sound with text that is not spoken
331
- yet (voice-missing) and one whose text, speaker or rate changed since it was spoken (voice-stale).
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 or rate changed
400
- (--force: all of them, --only vo1: one). Needs a voice backend,
401
- the Kokoro plugin (with none installed, kine voice prints the pip
402
- command), or KINE_VOICE_CMD set to a command that reads JSON
403
- requests on stdin. Rendering never speaks.
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 rate changed are spoken again, unless force. Run it before check and render.",
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 rate of '{sid}' changed after {src} "
236
- "was spoken, so it says the old text", "run kine voice on this scene to speak it again")
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 sound is spoken again only when its text, speaker or rate changed since
9
- its file was made (or with force); `kine check` warns when they changed and the file was not remade.
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). KINE_VOICE_CMD names a command instead, for a backend in another Python: it reads a JSON
14
- list of those requests on stdin and prints the list of results. Scene text never reaches a shell.
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 @ git+https://github.com/drknowhow/kinescript#subdirectory=plugins/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 and rate. A change in any means it must be spoken again."""
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 _command() -> list[str] | None:
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
- cmd = _command()
139
- if cmd and not name:
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(cmd, input=json.dumps(reqs), capture_output=True, text=True, encoding="utf-8")
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"KINE_VOICE_CMD failed ({res.returncode}): {res.stderr.strip()[-600:]}")
182
+ raise RuntimeError(f"{var} failed ({res.returncode}): {res.stderr.strip()[-600:]}")
144
183
  return json.loads(res.stdout)
145
- return run, Path(cmd[-1]).name
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}'; installed: {', '.join(found) or 'none'}")
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 \"{KOKORO}\" (Python 3.10 to 3.12), "
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
- fn = found[key].load()
159
- return (lambda reqs: [fn(r) for r in reqs]), key
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
- if todo:
185
- speak, name = _speaker(backend)
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), "out": str(src)}
188
- for _, a, src, _, text, _ in todo])
189
- took = (time.perf_counter() - t0) / len(todo)
190
- for (sid, a, src, words_file, text, marks), res in zip(todo, results):
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
- out.append({"id": sid, "src": str(a.get("src") or default_src(sid)), "status": "made", "words": len(said),
202
- "seconds": round(float(secs), 2), "took": round(took, 2)})
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.2.0
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. [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
137
- is the first. `[[go]]` in the text names the next word, so a cue to `vo1:go` survives a rewrite of the line.
138
- Only a line whose text, speaker or rate changed is spoken again, and rendering never calls a speech engine.
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 "kinescript-kokoro @ git+https://github.com/drknowhow/kinescript#subdirectory=plugins/kinescript-kokoro"
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/ kinescript-kokoro, the Kokoro voice for kine voice (installed on its own)
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.2.0"
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
- README = ROOT / "README.md"
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
- def test_readme_has_no_relative_links_or_images():
59
- bad = relative_targets(_readme())
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
- def test_readme_points_at_files_that_exist_in_the_repo():
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
- def test_readme_renders_the_way_pypi_renders_it():
106
- text = _readme()
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((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
129
+ return tomllib.loads((PACKAGES[pkg] / "pyproject.toml").read_text(encoding="utf-8"))["project"]
124
130
 
125
131
 
126
- def test_summary_is_one_short_plain_line():
127
- summary = _project()["description"]
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
- def test_classifiers_are_all_valid():
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
- def test_keywords_are_unique():
146
- words = [k.lower() for k in _project()["keywords"]]
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
- def test_project_links_are_complete_and_verifiable():
151
- urls = _project()["urls"]
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