kinescript-elevenlabs 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kinescript_elevenlabs/__init__.py +157 -0
- kinescript_elevenlabs/__main__.py +10 -0
- kinescript_elevenlabs-0.1.0.dist-info/METADATA +88 -0
- kinescript_elevenlabs-0.1.0.dist-info/RECORD +8 -0
- kinescript_elevenlabs-0.1.0.dist-info/WHEEL +5 -0
- kinescript_elevenlabs-0.1.0.dist-info/entry_points.txt +2 -0
- kinescript_elevenlabs-0.1.0.dist-info/licenses/LICENSE +25 -0
- kinescript_elevenlabs-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""ElevenLabs voices for `kine voice`: ElevenLabs' API speaks a sound's text, Eleven v4 and v3 with audio tags such
|
|
2
|
+
as [whispers] or [laughs], and times each character it says; those times become the words.
|
|
3
|
+
|
|
4
|
+
It needs an API key in ELEVENLABS_API_KEY, and every line it speaks uses characters of that account's quota.
|
|
5
|
+
kine voice calls synth(req) through the "kinescript.voice" entry point; the package has no dependencies, so it
|
|
6
|
+
installs next to Kinescript in any Python it runs on.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
import uuid
|
|
17
|
+
import wave
|
|
18
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
19
|
+
|
|
20
|
+
API = "https://api.elevenlabs.io"
|
|
21
|
+
MODEL = "eleven_v4"
|
|
22
|
+
SPEAKER = "JBFqnCBsd6RMkjVDRZzb" # George, the voice in ElevenLabs' own examples
|
|
23
|
+
RATE = 24000 # pcm_24000: raw 16-bit mono, on every plan
|
|
24
|
+
TAG = re.compile(r"(?<!\[)\[(?!\[)[^\[\]()]+\](?") # as kinescript.voice.TAG
|
|
25
|
+
SETTINGS = {"stability", "similarity_boost", "style", "use_speaker_boost"} # voice_settings
|
|
26
|
+
BODY = {"seed", "language_code", "apply_text_normalization"}
|
|
27
|
+
SPEED = (0.7, 1.2) # what voice_settings.speed takes
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def acts_on_tags(model: str) -> bool:
|
|
31
|
+
return model.startswith(("eleven_v3", "eleven_v4"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _key() -> str:
|
|
35
|
+
key = os.environ.get("ELEVENLABS_API_KEY") or os.environ.get("XI_API_KEY")
|
|
36
|
+
if not key:
|
|
37
|
+
raise RuntimeError("set ELEVENLABS_API_KEY to an ElevenLabs API key (elevenlabs.io > Developers > API keys)")
|
|
38
|
+
return key
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _call(path: str, key: str, body: bytes | None = None, ctype: str = "application/json") -> dict:
|
|
42
|
+
req = urllib.request.Request(API + path, data=body, method="POST" if body is not None else "GET",
|
|
43
|
+
headers={"xi-api-key": key, "Content-Type": ctype, "Accept": "application/json"})
|
|
44
|
+
try:
|
|
45
|
+
with urllib.request.urlopen(req, timeout=300) as r:
|
|
46
|
+
return json.loads(r.read())
|
|
47
|
+
except urllib.error.HTTPError as e:
|
|
48
|
+
detail = e.read().decode("utf-8", "replace")[:600]
|
|
49
|
+
raise RuntimeError(f"ElevenLabs {path.split('?')[0]} answered {e.code}: {detail}") from None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def voice_id(name: str, key: str) -> str:
|
|
53
|
+
"""A voice id as given, or the id of the account's voice with that name (\"George\")."""
|
|
54
|
+
if re.fullmatch(r"[A-Za-z0-9]{20}", name):
|
|
55
|
+
return name
|
|
56
|
+
voices = _call("/v1/voices", key).get("voices") or []
|
|
57
|
+
for v in voices:
|
|
58
|
+
if v.get("name", "").split(" - ")[0].strip().lower() == name.strip().lower():
|
|
59
|
+
return v["voice_id"]
|
|
60
|
+
names = ", ".join(sorted(v.get("name", "").split(" - ")[0] for v in voices)[:30])
|
|
61
|
+
raise ValueError(f"no ElevenLabs voice named '{name}' in this account; its voices: {names or 'none'}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def words_from_chars(chars: list[str], starts: list[float], ends: list[float], offset: float = 0.0) -> list[dict]:
|
|
65
|
+
"""ElevenLabs' character times -> [{"word", "start", "end"}], split where the text has spaces, as the text
|
|
66
|
+
reads. Tags are not words; a word's time runs from its first letter to its last, so punctuation and the pause
|
|
67
|
+
after it are not stretched in."""
|
|
68
|
+
text = "".join(chars)
|
|
69
|
+
at, hidden = 0, [False] * len(chars)
|
|
70
|
+
spans = [m.span() for m in TAG.finditer(text)]
|
|
71
|
+
for k, c in enumerate(chars):
|
|
72
|
+
hidden[k] = any(a <= at < b for a, b in spans)
|
|
73
|
+
at += len(c)
|
|
74
|
+
out, cur = [], None
|
|
75
|
+
for c, s, e, h in zip(chars, starts, ends, hidden):
|
|
76
|
+
if h or c.isspace():
|
|
77
|
+
if cur and cur["word"]:
|
|
78
|
+
out.append(cur)
|
|
79
|
+
cur = None
|
|
80
|
+
continue
|
|
81
|
+
cur = cur or {"word": "", "start": None, "end": None}
|
|
82
|
+
cur["word"] += c
|
|
83
|
+
if re.search(r"\w", c):
|
|
84
|
+
cur["start"] = s if cur["start"] is None else cur["start"]
|
|
85
|
+
cur["end"] = e
|
|
86
|
+
if cur and cur["word"]:
|
|
87
|
+
out.append(cur)
|
|
88
|
+
timed, last = [], offset
|
|
89
|
+
for w in out:
|
|
90
|
+
if not re.search(r"\w", w["word"]): # punctuation on its own ("—"): nothing is said
|
|
91
|
+
continue
|
|
92
|
+
start, end = (w["start"] + offset, w["end"] + offset) if w["start"] is not None else (last, last)
|
|
93
|
+
timed.append({"word": w["word"], "start": round(start, 4), "end": round(end, 4)})
|
|
94
|
+
last = end
|
|
95
|
+
return timed
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _wav(path: str, pcm: bytes) -> None:
|
|
99
|
+
with wave.open(path, "wb") as f:
|
|
100
|
+
f.setnchannels(1)
|
|
101
|
+
f.setsampwidth(2)
|
|
102
|
+
f.setframerate(RATE)
|
|
103
|
+
f.writeframes(pcm)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _forced(path: str, text: str, key: str) -> list[dict]:
|
|
107
|
+
"""Words timed by ElevenLabs' forced alignment, for a model that returned no character times."""
|
|
108
|
+
b = uuid.uuid4().hex
|
|
109
|
+
with open(path, "rb") as f:
|
|
110
|
+
audio = f.read()
|
|
111
|
+
body = (f"--{b}\r\nContent-Disposition: form-data; name=\"text\"\r\n\r\n{text}\r\n"
|
|
112
|
+
f"--{b}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"voice.wav\"\r\n"
|
|
113
|
+
"Content-Type: audio/wav\r\n\r\n").encode() + audio + f"\r\n--{b}--\r\n".encode()
|
|
114
|
+
got = _call("/v1/forced-alignment", key, body, f"multipart/form-data; boundary={b}")
|
|
115
|
+
return [{"word": w["text"].strip(), "start": round(w["start"], 4), "end": round(w["end"], 4)}
|
|
116
|
+
for w in got.get("words") or [] if re.search(r"\w", w.get("text", ""))]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _version(model: str) -> str:
|
|
120
|
+
try:
|
|
121
|
+
own = version("kinescript-elevenlabs")
|
|
122
|
+
except PackageNotFoundError:
|
|
123
|
+
own = "?"
|
|
124
|
+
return f"{model}, kinescript-elevenlabs {own}"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def synth(req: dict) -> dict:
|
|
128
|
+
"""Speak req["tagged"] (or req["text"] on a model without tags) with the voice req["speaker"] names (an id or
|
|
129
|
+
a name in the account, George unless named) into the WAV req["out"], and time each word."""
|
|
130
|
+
key = _key()
|
|
131
|
+
opts = dict(req.get("options") or {})
|
|
132
|
+
model = str(opts.pop("model", MODEL))
|
|
133
|
+
tags = acts_on_tags(model)
|
|
134
|
+
text = (req.get("tagged") or req["text"]) if tags else req["text"]
|
|
135
|
+
speaker = req.get("speaker") or SPEAKER
|
|
136
|
+
ignored = [f"options.{k}" for k in opts if k not in SETTINGS | BODY]
|
|
137
|
+
settings = {k: opts[k] for k in SETTINGS if k in opts}
|
|
138
|
+
rate = float(req.get("rate") or 1)
|
|
139
|
+
if rate != 1:
|
|
140
|
+
if SPEED[0] <= rate <= SPEED[1]:
|
|
141
|
+
settings["speed"] = rate
|
|
142
|
+
else:
|
|
143
|
+
ignored.append(f"rate {req['rate']}: ElevenLabs speaks at {SPEED[0]} to {SPEED[1]}")
|
|
144
|
+
body = {"text": text, "model_id": model, **{k: opts[k] for k in BODY if k in opts}}
|
|
145
|
+
if settings:
|
|
146
|
+
body["voice_settings"] = settings
|
|
147
|
+
vid = voice_id(speaker, key)
|
|
148
|
+
got = _call(f"/v1/text-to-speech/{vid}/with-timestamps?output_format=pcm_{RATE}", key, json.dumps(body).encode())
|
|
149
|
+
pcm = base64.b64decode(got["audio_base64"])
|
|
150
|
+
_wav(req["out"], pcm)
|
|
151
|
+
timing = got.get("alignment") or {}
|
|
152
|
+
words = words_from_chars(timing.get("characters") or [], timing.get("character_start_times_seconds") or [],
|
|
153
|
+
timing.get("character_end_times_seconds") or [])
|
|
154
|
+
if not words or not any(w["end"] for w in words):
|
|
155
|
+
words = _forced(req["out"], TAG.sub("", text), key)
|
|
156
|
+
return {"words": words, "speaker": speaker, "version": _version(model), "seconds": round(len(pcm) / 2 / RATE, 3),
|
|
157
|
+
"tags": tags, "ignored": ignored}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""For KINE_VOICE_CMD_ELEVENLABS: a JSON list of requests on stdin, the list of results on stdout.
|
|
2
|
+
|
|
3
|
+
KINE_VOICE_CMD_ELEVENLABS='["/path/to/python", "-m", "kinescript_elevenlabs"]' kine voice scene.json
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import synth
|
|
9
|
+
|
|
10
|
+
sys.stdout.write(json.dumps([synth(r) for r in json.loads(sys.stdin.read())]))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kinescript-elevenlabs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ElevenLabs voices for kine voice: audio tags such as whispers, any voice in your account, and word times
|
|
5
|
+
Author: Dr. Know How
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs
|
|
8
|
+
Project-URL: Documentation, https://drknowhow.github.io/kinescript/
|
|
9
|
+
Project-URL: Source, https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs
|
|
10
|
+
Project-URL: Issues, https://github.com/drknowhow/kinescript/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/drknowhow/kinescript/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: kinescript,elevenlabs,text to speech,tts,voice-over,audio tags,word timestamps
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
25
|
+
Classifier: Topic :: Multimedia :: Video
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# kinescript-elevenlabs
|
|
32
|
+
|
|
33
|
+
[ElevenLabs](https://elevenlabs.io) voices for `kine voice`. It speaks the `text` of a scene's sounds through
|
|
34
|
+
ElevenLabs' API into WAV files and writes the time of each word, so layers can start on a word and captions follow
|
|
35
|
+
the voice. Eleven v4 and v3 act on audio tags such as `[whispers]`, `[laughs]` or `[sighs]`.
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{"id": "vo1", "role": "voice", "backend": "elevenlabs", "speaker": "George",
|
|
39
|
+
"text": "[whispers] Nobody knows yet. [[go]][laughs] Render it.", "options": {"stability": 0.3}}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
kine voice scene.json # writes voice/vo1.wav and voice/vo1.words.json
|
|
44
|
+
kine check scene.json
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
It needs Kinescript 1.3 or newer. It has no dependencies of its own, so it installs next to Kinescript on any
|
|
50
|
+
Python 3.10 or newer:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install kinescript-elevenlabs
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
From a checkout of [Kinescript](https://github.com/drknowhow/kinescript), `pip install ./plugins/kinescript-elevenlabs`
|
|
57
|
+
does the same.
|
|
58
|
+
|
|
59
|
+
It needs an API key from your ElevenLabs account (Developers > API keys) in `ELEVENLABS_API_KEY`. Every line it
|
|
60
|
+
speaks uses characters of that account's quota. `kine voice` speaks a line again only when its text, speaker, rate,
|
|
61
|
+
backend or options changed, so re-running it on an unchanged scene costs nothing.
|
|
62
|
+
|
|
63
|
+
## Voices and settings
|
|
64
|
+
|
|
65
|
+
`speaker` is a voice id, or the name of a voice in your account (`George`, `Aria`); George unless the sound names one.
|
|
66
|
+
`rate` sets ElevenLabs' speed, which takes 0.7 to 1.2; a rate outside that is spoken at 1 and listed under
|
|
67
|
+
`ignored`.
|
|
68
|
+
|
|
69
|
+
`options` passes these on:
|
|
70
|
+
|
|
71
|
+
- `model`: `eleven_v4` by default. `eleven_v3` also acts on tags; other models, such as `eleven_multilingual_v2`
|
|
72
|
+
or `eleven_flash_v2_5`, get the text without its tags.
|
|
73
|
+
- `stability`, `similarity_boost`, `style`, `use_speaker_boost`: ElevenLabs' voice settings. Lower stability gives
|
|
74
|
+
a wider emotional range.
|
|
75
|
+
- `seed`, `language_code`, `apply_text_normalization`: passed to the request as they are.
|
|
76
|
+
|
|
77
|
+
An option it does not know is listed under `ignored` in what `kine voice` prints.
|
|
78
|
+
|
|
79
|
+
## Word times
|
|
80
|
+
|
|
81
|
+
ElevenLabs returns the time of each character it says; the plugin joins them into the words of the text as written,
|
|
82
|
+
leaving tags out and timing each word from its first letter to its last. When a model returns no character times,
|
|
83
|
+
the plugin asks ElevenLabs' forced alignment for them, which is billed like speech to text.
|
|
84
|
+
|
|
85
|
+
## Another Python
|
|
86
|
+
|
|
87
|
+
To run it from a Python other than Kinescript's, set `KINE_VOICE_CMD_ELEVENLABS` to that Python and the module:
|
|
88
|
+
`["/path/to/python", "-m", "kinescript_elevenlabs"]`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
kinescript_elevenlabs/__init__.py,sha256=JlJqT5qDxmgIje86ZvCGEhE0J7Wd63_3MprlcWGcMAc,7167
|
|
2
|
+
kinescript_elevenlabs/__main__.py,sha256=OWrkTgoc0B4X76AGCeoiODVDfOQWIcxkWV2Kkwy0Bu4,333
|
|
3
|
+
kinescript_elevenlabs-0.1.0.dist-info/licenses/LICENSE,sha256=1dTEpVbx7pmCEJy776QUX7yDNLQKZ2csOgwWSqXa_o8,1260
|
|
4
|
+
kinescript_elevenlabs-0.1.0.dist-info/METADATA,sha256=Q3N7_0R9MsQw4wFM0z822_M7AXwg-l0WYU1JOUDURM8,4054
|
|
5
|
+
kinescript_elevenlabs-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
kinescript_elevenlabs-0.1.0.dist-info/entry_points.txt,sha256=ZX-1c26Q_oTeMY8ZLCm98PlJSlPYo25RhOFfRB0_JBs,60
|
|
7
|
+
kinescript_elevenlabs-0.1.0.dist-info/top_level.txt,sha256=TI1vKI5aJQYV-gUpEWTF5pBFVd6SCtQ9zMNThh0ugKo,22
|
|
8
|
+
kinescript_elevenlabs-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dr. Know How
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
The fonts in kinescript/fonts/ (Space Grotesk, IBM Plex Mono) are not covered by
|
|
24
|
+
this license. They are distributed under the SIL Open Font License 1.1; see the
|
|
25
|
+
-OFL.txt files next to them.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kinescript_elevenlabs
|