headvox 0.1.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.
- headvox-0.1.0/LICENSE +21 -0
- headvox-0.1.0/PKG-INFO +185 -0
- headvox-0.1.0/README.md +157 -0
- headvox-0.1.0/pyproject.toml +44 -0
- headvox-0.1.0/setup.cfg +4 -0
- headvox-0.1.0/src/headvox/__init__.py +33 -0
- headvox-0.1.0/src/headvox/__main__.py +5 -0
- headvox-0.1.0/src/headvox/backends/__init__.py +79 -0
- headvox-0.1.0/src/headvox/backends/edge.py +73 -0
- headvox-0.1.0/src/headvox/backends/sine.py +63 -0
- headvox-0.1.0/src/headvox/captions.py +142 -0
- headvox-0.1.0/src/headvox/cli.py +132 -0
- headvox-0.1.0/src/headvox/script.py +232 -0
- headvox-0.1.0/src/headvox/synth.py +240 -0
- headvox-0.1.0/src/headvox/video.py +91 -0
- headvox-0.1.0/src/headvox.egg-info/PKG-INFO +185 -0
- headvox-0.1.0/src/headvox.egg-info/SOURCES.txt +21 -0
- headvox-0.1.0/src/headvox.egg-info/dependency_links.txt +1 -0
- headvox-0.1.0/src/headvox.egg-info/entry_points.txt +2 -0
- headvox-0.1.0/src/headvox.egg-info/requires.txt +6 -0
- headvox-0.1.0/src/headvox.egg-info/top_level.txt +1 -0
- headvox-0.1.0/tests/test_pipeline.py +100 -0
- headvox-0.1.0/tests/test_script.py +41 -0
headvox-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HeadVox contributors
|
|
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.
|
headvox-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: headvox
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Turn a script into a talking-head voiceover with word-aligned SRT/VTT captions. Chinese, English and Japanese, free neural voices, one command.
|
|
5
|
+
Author: HeadVox contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: tts,text-to-speech,voiceover,talking-head,subtitles,srt,vtt,captions,edge-tts,chinese-tts,ai-voice,video,ffmpeg,youtube,shorts
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Natural Language :: Chinese (Simplified)
|
|
14
|
+
Classifier: Natural Language :: English
|
|
15
|
+
Classifier: Natural Language :: Japanese
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
19
|
+
Classifier: Topic :: Multimedia :: Video
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Provides-Extra: edge
|
|
24
|
+
Requires-Dist: edge-tts>=7.0; extra == "edge"
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# HeadVox
|
|
30
|
+
|
|
31
|
+
**Turn a script into a talking‑head voiceover — with word‑aligned captions — in one command.**
|
|
32
|
+
|
|
33
|
+
HeadVox reads a plain‑text or Markdown script, speaks it with a neural TTS voice,
|
|
34
|
+
stitches the clips into a single track with natural pauses, and writes
|
|
35
|
+
**SRT + WebVTT captions that line up with the audio**, plus a JSON timing manifest
|
|
36
|
+
you can feed to a talking‑head avatar or a video editor. Point it at a video clip
|
|
37
|
+
and it muxes the voiceover on, loops the clip to length, and attaches the subtitles.
|
|
38
|
+
|
|
39
|
+
Chinese, English and Japanese work out of the box (mixed CJK/Latin scripts
|
|
40
|
+
segment correctly). The voices come from the free [`edge-tts`](https://github.com/rany2/edge-tts)
|
|
41
|
+
service; HeadVox does **not** bundle or re‑host any model.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install "headvox[edge]"
|
|
45
|
+
headvox script.md -o out/ --voice zh-female --video avatar.mp4
|
|
46
|
+
# -> out/voiceover.mp3 out/voiceover.srt out/voiceover.vtt
|
|
47
|
+
# out/voiceover.json out/voiceover.mp4
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Why
|
|
53
|
+
|
|
54
|
+
Making a short explainer or a "talking head" clip usually means three fiddly
|
|
55
|
+
steps: generate the audio, hand‑time the subtitles, and mux it onto video.
|
|
56
|
+
HeadVox does all three from one script and keeps them in sync, so the captions
|
|
57
|
+
actually match the voice instead of drifting a few hundred milliseconds off.
|
|
58
|
+
|
|
59
|
+
## Features
|
|
60
|
+
|
|
61
|
+
- **One command, three outputs** — audio, captions (SRT **and** VTT), and a timing manifest.
|
|
62
|
+
- **Word‑aligned captions.** With the `edge` backend HeadVox uses real word
|
|
63
|
+
boundaries, then breaks lines at punctuation and a character budget
|
|
64
|
+
(a CJK character counts as two columns) so no caption runs off screen.
|
|
65
|
+
- **Pacing you control.** Sentence and paragraph pauses are automatic; drop
|
|
66
|
+
`[pause]`, `[pause 1.5s]` or `[pause 400ms]` anywhere for a beat.
|
|
67
|
+
- **Markdown‑aware.** Headings, emphasis, links, code fences and lists are
|
|
68
|
+
reduced to the words a narrator would actually say.
|
|
69
|
+
- **Pluggable backends.** `edge` (online neural voices) and `sine` (offline,
|
|
70
|
+
no network, used by the tests and for dry runs). Register your own in a few lines.
|
|
71
|
+
- **Caches** every clip, so re‑rendering after a tiny script edit is nearly free.
|
|
72
|
+
- **Video muxing** with optional looped, ducked background music, and either
|
|
73
|
+
soft subtitles (toggleable) or burned‑in captions.
|
|
74
|
+
- **Pure‑Python, tiny deps.** Only `ffmpeg`/`ffprobe` on your PATH, and
|
|
75
|
+
`edge-tts` if you use the online voices.
|
|
76
|
+
|
|
77
|
+
## Install
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install "headvox[edge]" # online neural voices
|
|
81
|
+
pip install headvox # library + offline 'sine' backend only
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
You also need **ffmpeg** (with `ffprobe`) on your PATH — <https://ffmpeg.org/download.html>.
|
|
85
|
+
|
|
86
|
+
## Usage
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Simplest: script in, files out
|
|
90
|
+
headvox talk.md -o out/
|
|
91
|
+
|
|
92
|
+
# Pick a voice and tweak the delivery
|
|
93
|
+
headvox talk.md -o out/ --voice en-female --rate +6 --pitch +1
|
|
94
|
+
|
|
95
|
+
# Put it on a clip, loop the clip to the voiceover length, duck some music under it
|
|
96
|
+
headvox talk.md -o out/ --video avatar.mp4 --music bed.mp3
|
|
97
|
+
|
|
98
|
+
# Burn captions into the picture instead of a soft track
|
|
99
|
+
headvox talk.md -o out/ --video avatar.mp4 --burn-subs
|
|
100
|
+
|
|
101
|
+
# See how the script will be segmented without calling any TTS
|
|
102
|
+
headvox talk.md --dry-run
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Voice presets: `zh-female`, `zh-male`, `en-female`, `en-male`, `ja-female`, `ja-male`
|
|
106
|
+
— or pass any `edge-tts` voice id directly (e.g. `--voice zh-CN-YunxiNeural`).
|
|
107
|
+
Run `edge-tts --list-voices` to see them all. Behind a proxy? Use `--proxy`
|
|
108
|
+
or set `HEADVOX_PROXY`.
|
|
109
|
+
|
|
110
|
+
### As a library
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from headvox import synthesize, Script, VoiceSettings
|
|
114
|
+
|
|
115
|
+
res = synthesize(
|
|
116
|
+
Script.from_file("talk.md"),
|
|
117
|
+
"out/",
|
|
118
|
+
backend="edge",
|
|
119
|
+
settings=VoiceSettings(voice="zh-CN-XiaoxiaoNeural", rate_pct=5),
|
|
120
|
+
)
|
|
121
|
+
print(res.duration_ms, "ms,", len(res.cues), "captions")
|
|
122
|
+
for cue in res.cues:
|
|
123
|
+
print(cue.start_ms, cue.text)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`synthesize()` returns paths to the audio, SRT, VTT and manifest, the total
|
|
127
|
+
duration, and the per‑segment word timings.
|
|
128
|
+
|
|
129
|
+
## Timing manifest
|
|
130
|
+
|
|
131
|
+
`voiceover.json` holds everything downstream tools need to drive an avatar or
|
|
132
|
+
re‑style captions:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"tool": "headvox",
|
|
137
|
+
"backend": "edge",
|
|
138
|
+
"duration_ms": 19600,
|
|
139
|
+
"segments": [
|
|
140
|
+
{"index": 0, "text": "Hi everyone, and welcome back.",
|
|
141
|
+
"start_ms": 2398, "end_ms": 4049,
|
|
142
|
+
"words": [{"text": "Hi", "start_ms": 2398, "end_ms": 2560}, ...]}
|
|
143
|
+
],
|
|
144
|
+
"cues": [{"index": 1, "start_ms": 262, "end_ms": 1573, "text": "HeadVox demo"}]
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Write your own backend
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from headvox.backends import SegmentAudio, WordMark, register_backend
|
|
152
|
+
|
|
153
|
+
class MyBackend:
|
|
154
|
+
name = "mine"
|
|
155
|
+
def synthesize_segment(self, text, settings):
|
|
156
|
+
audio, fmt, dur = my_tts(text, settings.voice) # bytes, "wav"/"mp3", ms
|
|
157
|
+
return SegmentAudio(audio, fmt, dur, [WordMark("hi", 0, 200)])
|
|
158
|
+
|
|
159
|
+
register_backend("mine", lambda **kw: MyBackend())
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Then `headvox script.md -b mine`.
|
|
163
|
+
|
|
164
|
+
## How it works
|
|
165
|
+
|
|
166
|
+
1. **Parse** the script to speech segments, stripping Markdown and honouring pause directives.
|
|
167
|
+
2. **Synthesize** each segment through the chosen backend (cached by text + voice settings).
|
|
168
|
+
3. **Assemble** one 24 kHz track, trimming the padding some engines add so your pauses are exact.
|
|
169
|
+
4. **Caption** from the word boundaries, breaking lines at punctuation within a width budget.
|
|
170
|
+
5. **Mux** (optional) onto video with ffmpeg, with ducked music and soft or burned‑in subtitles.
|
|
171
|
+
|
|
172
|
+
## Development
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
git clone <your fork>
|
|
176
|
+
cd headvox
|
|
177
|
+
pip install -e ".[dev]"
|
|
178
|
+
pytest # the suite runs fully offline via the 'sine' backend
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT — see [LICENSE](LICENSE). HeadVox is original work and is not affiliated
|
|
184
|
+
with Microsoft or the `edge-tts` project; it simply uses `edge-tts` as one
|
|
185
|
+
optional backend.
|
headvox-0.1.0/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# HeadVox
|
|
2
|
+
|
|
3
|
+
**Turn a script into a talking‑head voiceover — with word‑aligned captions — in one command.**
|
|
4
|
+
|
|
5
|
+
HeadVox reads a plain‑text or Markdown script, speaks it with a neural TTS voice,
|
|
6
|
+
stitches the clips into a single track with natural pauses, and writes
|
|
7
|
+
**SRT + WebVTT captions that line up with the audio**, plus a JSON timing manifest
|
|
8
|
+
you can feed to a talking‑head avatar or a video editor. Point it at a video clip
|
|
9
|
+
and it muxes the voiceover on, loops the clip to length, and attaches the subtitles.
|
|
10
|
+
|
|
11
|
+
Chinese, English and Japanese work out of the box (mixed CJK/Latin scripts
|
|
12
|
+
segment correctly). The voices come from the free [`edge-tts`](https://github.com/rany2/edge-tts)
|
|
13
|
+
service; HeadVox does **not** bundle or re‑host any model.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install "headvox[edge]"
|
|
17
|
+
headvox script.md -o out/ --voice zh-female --video avatar.mp4
|
|
18
|
+
# -> out/voiceover.mp3 out/voiceover.srt out/voiceover.vtt
|
|
19
|
+
# out/voiceover.json out/voiceover.mp4
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Why
|
|
25
|
+
|
|
26
|
+
Making a short explainer or a "talking head" clip usually means three fiddly
|
|
27
|
+
steps: generate the audio, hand‑time the subtitles, and mux it onto video.
|
|
28
|
+
HeadVox does all three from one script and keeps them in sync, so the captions
|
|
29
|
+
actually match the voice instead of drifting a few hundred milliseconds off.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- **One command, three outputs** — audio, captions (SRT **and** VTT), and a timing manifest.
|
|
34
|
+
- **Word‑aligned captions.** With the `edge` backend HeadVox uses real word
|
|
35
|
+
boundaries, then breaks lines at punctuation and a character budget
|
|
36
|
+
(a CJK character counts as two columns) so no caption runs off screen.
|
|
37
|
+
- **Pacing you control.** Sentence and paragraph pauses are automatic; drop
|
|
38
|
+
`[pause]`, `[pause 1.5s]` or `[pause 400ms]` anywhere for a beat.
|
|
39
|
+
- **Markdown‑aware.** Headings, emphasis, links, code fences and lists are
|
|
40
|
+
reduced to the words a narrator would actually say.
|
|
41
|
+
- **Pluggable backends.** `edge` (online neural voices) and `sine` (offline,
|
|
42
|
+
no network, used by the tests and for dry runs). Register your own in a few lines.
|
|
43
|
+
- **Caches** every clip, so re‑rendering after a tiny script edit is nearly free.
|
|
44
|
+
- **Video muxing** with optional looped, ducked background music, and either
|
|
45
|
+
soft subtitles (toggleable) or burned‑in captions.
|
|
46
|
+
- **Pure‑Python, tiny deps.** Only `ffmpeg`/`ffprobe` on your PATH, and
|
|
47
|
+
`edge-tts` if you use the online voices.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install "headvox[edge]" # online neural voices
|
|
53
|
+
pip install headvox # library + offline 'sine' backend only
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
You also need **ffmpeg** (with `ffprobe`) on your PATH — <https://ffmpeg.org/download.html>.
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Simplest: script in, files out
|
|
62
|
+
headvox talk.md -o out/
|
|
63
|
+
|
|
64
|
+
# Pick a voice and tweak the delivery
|
|
65
|
+
headvox talk.md -o out/ --voice en-female --rate +6 --pitch +1
|
|
66
|
+
|
|
67
|
+
# Put it on a clip, loop the clip to the voiceover length, duck some music under it
|
|
68
|
+
headvox talk.md -o out/ --video avatar.mp4 --music bed.mp3
|
|
69
|
+
|
|
70
|
+
# Burn captions into the picture instead of a soft track
|
|
71
|
+
headvox talk.md -o out/ --video avatar.mp4 --burn-subs
|
|
72
|
+
|
|
73
|
+
# See how the script will be segmented without calling any TTS
|
|
74
|
+
headvox talk.md --dry-run
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Voice presets: `zh-female`, `zh-male`, `en-female`, `en-male`, `ja-female`, `ja-male`
|
|
78
|
+
— or pass any `edge-tts` voice id directly (e.g. `--voice zh-CN-YunxiNeural`).
|
|
79
|
+
Run `edge-tts --list-voices` to see them all. Behind a proxy? Use `--proxy`
|
|
80
|
+
or set `HEADVOX_PROXY`.
|
|
81
|
+
|
|
82
|
+
### As a library
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from headvox import synthesize, Script, VoiceSettings
|
|
86
|
+
|
|
87
|
+
res = synthesize(
|
|
88
|
+
Script.from_file("talk.md"),
|
|
89
|
+
"out/",
|
|
90
|
+
backend="edge",
|
|
91
|
+
settings=VoiceSettings(voice="zh-CN-XiaoxiaoNeural", rate_pct=5),
|
|
92
|
+
)
|
|
93
|
+
print(res.duration_ms, "ms,", len(res.cues), "captions")
|
|
94
|
+
for cue in res.cues:
|
|
95
|
+
print(cue.start_ms, cue.text)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`synthesize()` returns paths to the audio, SRT, VTT and manifest, the total
|
|
99
|
+
duration, and the per‑segment word timings.
|
|
100
|
+
|
|
101
|
+
## Timing manifest
|
|
102
|
+
|
|
103
|
+
`voiceover.json` holds everything downstream tools need to drive an avatar or
|
|
104
|
+
re‑style captions:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"tool": "headvox",
|
|
109
|
+
"backend": "edge",
|
|
110
|
+
"duration_ms": 19600,
|
|
111
|
+
"segments": [
|
|
112
|
+
{"index": 0, "text": "Hi everyone, and welcome back.",
|
|
113
|
+
"start_ms": 2398, "end_ms": 4049,
|
|
114
|
+
"words": [{"text": "Hi", "start_ms": 2398, "end_ms": 2560}, ...]}
|
|
115
|
+
],
|
|
116
|
+
"cues": [{"index": 1, "start_ms": 262, "end_ms": 1573, "text": "HeadVox demo"}]
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Write your own backend
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from headvox.backends import SegmentAudio, WordMark, register_backend
|
|
124
|
+
|
|
125
|
+
class MyBackend:
|
|
126
|
+
name = "mine"
|
|
127
|
+
def synthesize_segment(self, text, settings):
|
|
128
|
+
audio, fmt, dur = my_tts(text, settings.voice) # bytes, "wav"/"mp3", ms
|
|
129
|
+
return SegmentAudio(audio, fmt, dur, [WordMark("hi", 0, 200)])
|
|
130
|
+
|
|
131
|
+
register_backend("mine", lambda **kw: MyBackend())
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Then `headvox script.md -b mine`.
|
|
135
|
+
|
|
136
|
+
## How it works
|
|
137
|
+
|
|
138
|
+
1. **Parse** the script to speech segments, stripping Markdown and honouring pause directives.
|
|
139
|
+
2. **Synthesize** each segment through the chosen backend (cached by text + voice settings).
|
|
140
|
+
3. **Assemble** one 24 kHz track, trimming the padding some engines add so your pauses are exact.
|
|
141
|
+
4. **Caption** from the word boundaries, breaking lines at punctuation within a width budget.
|
|
142
|
+
5. **Mux** (optional) onto video with ffmpeg, with ducked music and soft or burned‑in subtitles.
|
|
143
|
+
|
|
144
|
+
## Development
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
git clone <your fork>
|
|
148
|
+
cd headvox
|
|
149
|
+
pip install -e ".[dev]"
|
|
150
|
+
pytest # the suite runs fully offline via the 'sine' backend
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT — see [LICENSE](LICENSE). HeadVox is original work and is not affiliated
|
|
156
|
+
with Microsoft or the `edge-tts` project; it simply uses `edge-tts` as one
|
|
157
|
+
optional backend.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "headvox"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Turn a script into a talking-head voiceover with word-aligned SRT/VTT captions. Chinese, English and Japanese, free neural voices, one command."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "HeadVox contributors" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"tts", "text-to-speech", "voiceover", "talking-head", "subtitles", "srt", "vtt",
|
|
15
|
+
"captions", "edge-tts", "chinese-tts", "ai-voice", "video", "ffmpeg", "youtube", "shorts",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Intended Audience :: End Users/Desktop",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Natural Language :: Chinese (Simplified)",
|
|
24
|
+
"Natural Language :: English",
|
|
25
|
+
"Natural Language :: Japanese",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
29
|
+
"Topic :: Multimedia :: Video",
|
|
30
|
+
]
|
|
31
|
+
dependencies = []
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
edge = ["edge-tts>=7.0"]
|
|
35
|
+
dev = ["pytest>=7"]
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
headvox = "headvox.cli:main"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
headvox-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""HeadVox — turn a script into a talking-head voiceover with aligned captions.
|
|
2
|
+
|
|
3
|
+
HeadVox reads a plain-text or Markdown script, synthesizes it segment by
|
|
4
|
+
segment through a pluggable TTS backend, stitches the audio into a single
|
|
5
|
+
track, and emits word-aligned subtitles plus a timing manifest you can use
|
|
6
|
+
to drive a talking-head avatar or a video editor.
|
|
7
|
+
|
|
8
|
+
Public API:
|
|
9
|
+
from headvox import synthesize, Script, get_backend
|
|
10
|
+
|
|
11
|
+
Everything here is original work, released under the MIT License.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .script import Script, Segment, split_script
|
|
16
|
+
from .backends import TTSBackend, SegmentAudio, WordMark, get_backend, register_backend
|
|
17
|
+
from .synth import synthesize, SynthResult, VoiceSettings
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Script",
|
|
21
|
+
"Segment",
|
|
22
|
+
"split_script",
|
|
23
|
+
"TTSBackend",
|
|
24
|
+
"SegmentAudio",
|
|
25
|
+
"WordMark",
|
|
26
|
+
"get_backend",
|
|
27
|
+
"register_backend",
|
|
28
|
+
"synthesize",
|
|
29
|
+
"SynthResult",
|
|
30
|
+
"VoiceSettings",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Pluggable TTS backends.
|
|
2
|
+
|
|
3
|
+
A backend turns a short piece of text into audio bytes plus optional word
|
|
4
|
+
boundaries. Register your own with :func:`register_backend` and select it by
|
|
5
|
+
name on the command line or through :func:`headvox.synthesize`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Callable, Dict, List, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class WordMark:
|
|
15
|
+
"""A word and where it falls inside a segment's audio (milliseconds)."""
|
|
16
|
+
|
|
17
|
+
text: str
|
|
18
|
+
start_ms: int
|
|
19
|
+
end_ms: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SegmentAudio:
|
|
24
|
+
"""Result of synthesizing one segment."""
|
|
25
|
+
|
|
26
|
+
audio: bytes
|
|
27
|
+
audio_format: str # "mp3" or "wav"
|
|
28
|
+
duration_ms: int
|
|
29
|
+
words: List[WordMark] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@runtime_checkable
|
|
33
|
+
class TTSBackend(Protocol):
|
|
34
|
+
"""Interface every backend implements."""
|
|
35
|
+
|
|
36
|
+
name: str
|
|
37
|
+
|
|
38
|
+
def synthesize_segment(self, text: str, settings) -> SegmentAudio: # noqa: D401
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_REGISTRY: Dict[str, Callable[..., TTSBackend]] = {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def register_backend(name: str, factory: Callable[..., TTSBackend]) -> None:
|
|
46
|
+
_REGISTRY[name.lower()] = factory
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def available_backends() -> List[str]:
|
|
50
|
+
return sorted(_REGISTRY)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_backend(name: str, **kwargs) -> TTSBackend:
|
|
54
|
+
key = name.lower()
|
|
55
|
+
if key not in _REGISTRY:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"unknown backend {name!r}; available: {', '.join(available_backends()) or '(none)'}"
|
|
58
|
+
)
|
|
59
|
+
return _REGISTRY[key](**kwargs)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Register the backends that ship with HeadVox. Imports are deferred inside the
|
|
63
|
+
# factories so a missing optional dependency only fails when that backend is
|
|
64
|
+
# actually used.
|
|
65
|
+
|
|
66
|
+
def _edge_factory(**kw):
|
|
67
|
+
from .edge import EdgeBackend
|
|
68
|
+
|
|
69
|
+
return EdgeBackend(**kw)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _sine_factory(**kw):
|
|
73
|
+
from .sine import SineBackend
|
|
74
|
+
|
|
75
|
+
return SineBackend(**kw)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
register_backend("edge", _edge_factory)
|
|
79
|
+
register_backend("sine", _sine_factory)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Edge TTS backend.
|
|
2
|
+
|
|
3
|
+
Wraps the `edge-tts` project (Microsoft Edge's online neural voices, free to
|
|
4
|
+
use) behind HeadVox's backend interface. It returns MP3 audio and real
|
|
5
|
+
word-boundary marks, which is what makes tight caption alignment possible.
|
|
6
|
+
|
|
7
|
+
Install the optional dependency::
|
|
8
|
+
|
|
9
|
+
pip install "headvox[edge]"
|
|
10
|
+
|
|
11
|
+
HeadVox does not bundle or re-host any voice model; it calls the same public
|
|
12
|
+
service the `edge-tts` CLI uses.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
from typing import List, Optional
|
|
18
|
+
|
|
19
|
+
from . import SegmentAudio, WordMark
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fmt_pct(v: float, unit: str = "%") -> str:
|
|
23
|
+
sign = "+" if v >= 0 else "-"
|
|
24
|
+
return f"{sign}{abs(int(round(v)))}{unit}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EdgeBackend:
|
|
28
|
+
"""TTS backend backed by edge-tts neural voices."""
|
|
29
|
+
|
|
30
|
+
name = "edge"
|
|
31
|
+
|
|
32
|
+
def __init__(self, proxy: Optional[str] = None) -> None:
|
|
33
|
+
try:
|
|
34
|
+
import edge_tts # noqa: F401
|
|
35
|
+
except ImportError as exc: # pragma: no cover - import guard
|
|
36
|
+
raise ImportError(
|
|
37
|
+
"the 'edge' backend needs edge-tts; install with "
|
|
38
|
+
"pip install \"headvox[edge]\""
|
|
39
|
+
) from exc
|
|
40
|
+
self.proxy = proxy
|
|
41
|
+
|
|
42
|
+
def synthesize_segment(self, text: str, settings) -> SegmentAudio:
|
|
43
|
+
return asyncio.run(self._run(text, settings))
|
|
44
|
+
|
|
45
|
+
async def _run(self, text: str, settings) -> SegmentAudio:
|
|
46
|
+
import edge_tts
|
|
47
|
+
|
|
48
|
+
comm = edge_tts.Communicate(
|
|
49
|
+
text,
|
|
50
|
+
voice=settings.voice or "en-US-EmmaMultilingualNeural",
|
|
51
|
+
rate=_fmt_pct(settings.rate_pct),
|
|
52
|
+
volume=_fmt_pct(settings.volume_pct),
|
|
53
|
+
pitch=_fmt_pct(settings.pitch_hz, "Hz"),
|
|
54
|
+
boundary="WordBoundary",
|
|
55
|
+
proxy=self.proxy,
|
|
56
|
+
)
|
|
57
|
+
audio = bytearray()
|
|
58
|
+
words: List[WordMark] = []
|
|
59
|
+
async for chunk in comm.stream():
|
|
60
|
+
if chunk["type"] == "audio":
|
|
61
|
+
audio += chunk["data"]
|
|
62
|
+
elif chunk["type"] == "WordBoundary":
|
|
63
|
+
start = int(chunk["offset"] / 10_000) # 100-ns ticks -> ms
|
|
64
|
+
dur = int(chunk["duration"] / 10_000)
|
|
65
|
+
words.append(WordMark(chunk["text"], start, start + dur))
|
|
66
|
+
duration_ms = words[-1].end_ms if words else 0
|
|
67
|
+
return SegmentAudio(
|
|
68
|
+
audio=bytes(audio),
|
|
69
|
+
audio_format="mp3",
|
|
70
|
+
duration_ms=duration_ms,
|
|
71
|
+
words=words,
|
|
72
|
+
)
|
|
73
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Offline "sine" backend.
|
|
2
|
+
|
|
3
|
+
Produces a soft tone per word instead of speech. It needs no network and no
|
|
4
|
+
extra packages, so it's what the test suite uses, and it's handy for checking
|
|
5
|
+
pacing, caption layout, and video muxing before spending real TTS calls.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import math
|
|
11
|
+
import struct
|
|
12
|
+
import wave
|
|
13
|
+
|
|
14
|
+
from . import SegmentAudio, WordMark
|
|
15
|
+
from ..script import iter_words
|
|
16
|
+
|
|
17
|
+
SAMPLE_RATE = 24_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SineBackend:
|
|
21
|
+
name = "sine"
|
|
22
|
+
|
|
23
|
+
def __init__(self, ms_per_word: int = 260, ms_per_cjk: int = 210, gap_ms: int = 40) -> None:
|
|
24
|
+
self.ms_per_word = ms_per_word
|
|
25
|
+
self.ms_per_cjk = ms_per_cjk
|
|
26
|
+
self.gap_ms = gap_ms
|
|
27
|
+
|
|
28
|
+
def synthesize_segment(self, text: str, settings) -> SegmentAudio:
|
|
29
|
+
speed = max(0.25, 1.0 + settings.rate_pct / 100.0)
|
|
30
|
+
freq = 220.0 * (2 ** (settings.pitch_hz / 1200.0)) if settings.pitch_hz else 220.0
|
|
31
|
+
frames = bytearray()
|
|
32
|
+
words = []
|
|
33
|
+
t = 0
|
|
34
|
+
for tok in iter_words(text):
|
|
35
|
+
if not any(ch.isalnum() for ch in tok):
|
|
36
|
+
continue # punctuation is silent
|
|
37
|
+
base = self.ms_per_cjk if len(tok) == 1 and ord(tok) > 0x2E80 else self.ms_per_word
|
|
38
|
+
dur = int(base / speed)
|
|
39
|
+
frames += _tone(freq, dur)
|
|
40
|
+
frames += _silence(self.gap_ms)
|
|
41
|
+
words.append(WordMark(tok, t, t + dur))
|
|
42
|
+
t += dur + self.gap_ms
|
|
43
|
+
buf = io.BytesIO()
|
|
44
|
+
with wave.open(buf, "wb") as w:
|
|
45
|
+
w.setnchannels(1)
|
|
46
|
+
w.setsampwidth(2)
|
|
47
|
+
w.setframerate(SAMPLE_RATE)
|
|
48
|
+
w.writeframes(bytes(frames))
|
|
49
|
+
return SegmentAudio(buf.getvalue(), "wav", t, words)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _tone(freq: float, ms: int) -> bytes:
|
|
53
|
+
n = int(SAMPLE_RATE * ms / 1000)
|
|
54
|
+
fade = max(1, int(SAMPLE_RATE * 0.01))
|
|
55
|
+
out = bytearray()
|
|
56
|
+
for i in range(n):
|
|
57
|
+
env = min(1.0, i / fade, (n - i) / fade)
|
|
58
|
+
out += struct.pack("<h", int(6000 * env * math.sin(2 * math.pi * freq * i / SAMPLE_RATE)))
|
|
59
|
+
return bytes(out)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _silence(ms: int) -> bytes:
|
|
63
|
+
return b"\x00\x00" * int(SAMPLE_RATE * ms / 1000)
|