headvox 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.
- headvox/__init__.py +33 -0
- headvox/__main__.py +5 -0
- headvox/backends/__init__.py +79 -0
- headvox/backends/edge.py +73 -0
- headvox/backends/sine.py +63 -0
- headvox/captions.py +142 -0
- headvox/cli.py +132 -0
- headvox/script.py +232 -0
- headvox/synth.py +240 -0
- headvox/video.py +91 -0
- headvox-0.1.0.dist-info/METADATA +185 -0
- headvox-0.1.0.dist-info/RECORD +16 -0
- headvox-0.1.0.dist-info/WHEEL +5 -0
- headvox-0.1.0.dist-info/entry_points.txt +2 -0
- headvox-0.1.0.dist-info/licenses/LICENSE +21 -0
- headvox-0.1.0.dist-info/top_level.txt +1 -0
headvox/__init__.py
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"
|
headvox/__main__.py
ADDED
|
@@ -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)
|
headvox/backends/edge.py
ADDED
|
@@ -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
|
+
|
headvox/backends/sine.py
ADDED
|
@@ -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)
|
headvox/captions.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Caption building: word timings -> readable SRT / WebVTT cues.
|
|
2
|
+
|
|
3
|
+
Cues never cross a segment boundary, prefer to break after punctuation, and
|
|
4
|
+
respect a character budget where a CJK character counts as two columns. The
|
|
5
|
+
cue text is sliced from the original script, so punctuation and casing survive
|
|
6
|
+
even when the TTS engine reports bare words.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Optional, Sequence, Tuple
|
|
13
|
+
|
|
14
|
+
from .script import _is_wide, _visual_len
|
|
15
|
+
|
|
16
|
+
_BREAK_AFTER = set("。!?!?…,,;;::、.")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Cue:
|
|
21
|
+
index: int
|
|
22
|
+
start_ms: int
|
|
23
|
+
end_ms: int
|
|
24
|
+
text: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _locate(text: str, words) -> List[Optional[Tuple[int, int]]]:
|
|
28
|
+
"""Find each word's span in ``text``, scanning left to right."""
|
|
29
|
+
spans: List[Optional[Tuple[int, int]]] = []
|
|
30
|
+
cursor = 0
|
|
31
|
+
low = text.lower()
|
|
32
|
+
for w in words:
|
|
33
|
+
needle = w.text.strip().lower()
|
|
34
|
+
if not needle:
|
|
35
|
+
spans.append(None)
|
|
36
|
+
continue
|
|
37
|
+
idx = low.find(needle, cursor)
|
|
38
|
+
if idx == -1 or idx - cursor > 40:
|
|
39
|
+
spans.append(None)
|
|
40
|
+
continue
|
|
41
|
+
spans.append((idx, idx + len(needle)))
|
|
42
|
+
cursor = idx + len(needle)
|
|
43
|
+
return spans
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _extend_punct(text: str, end: int) -> int:
|
|
47
|
+
while end < len(text) and (text[end] in _BREAK_AFTER or text[end] in "\"'”’」』))】]"):
|
|
48
|
+
end += 1
|
|
49
|
+
return end
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _join_words(tokens: Sequence[str]) -> str:
|
|
53
|
+
out = ""
|
|
54
|
+
for t in tokens:
|
|
55
|
+
if out and not (_is_wide(out[-1]) or _is_wide(t[0])):
|
|
56
|
+
out += " "
|
|
57
|
+
out += t
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_cues(timed_segments, *, max_chars: int = 42, max_ms: int = 5000) -> List[Cue]:
|
|
62
|
+
cues: List[Cue] = []
|
|
63
|
+
budget = max_chars
|
|
64
|
+
for seg in timed_segments:
|
|
65
|
+
words = seg.words
|
|
66
|
+
if not words:
|
|
67
|
+
cues.append(Cue(len(cues) + 1, seg.start_ms, seg.end_ms, seg.text))
|
|
68
|
+
continue
|
|
69
|
+
spans = _locate(seg.text, words)
|
|
70
|
+
group: List[int] = []
|
|
71
|
+
|
|
72
|
+
def flush(group_idx: List[int], last_in_segment: bool) -> None:
|
|
73
|
+
first, last = group_idx[0], group_idx[-1]
|
|
74
|
+
s_span, e_span = spans[first], spans[last]
|
|
75
|
+
if s_span and e_span:
|
|
76
|
+
start_char = 0 if first == 0 else s_span[0]
|
|
77
|
+
end_char = len(seg.text) if last_in_segment else _extend_punct(seg.text, e_span[1])
|
|
78
|
+
text = seg.text[start_char:end_char].strip()
|
|
79
|
+
else:
|
|
80
|
+
text = _join_words([words[k].text for k in group_idx])
|
|
81
|
+
start = words[first].start_ms
|
|
82
|
+
end = seg.end_ms if last_in_segment else words[last].end_ms
|
|
83
|
+
cues.append(Cue(len(cues) + 1, start, max(end, start + 200), text))
|
|
84
|
+
|
|
85
|
+
def breaks_after(k: int) -> bool:
|
|
86
|
+
sp = spans[k]
|
|
87
|
+
return bool(sp) and sp[1] < len(seg.text) and seg.text[sp[1]] in _BREAK_AFTER
|
|
88
|
+
|
|
89
|
+
for i, w in enumerate(words):
|
|
90
|
+
group.append(i)
|
|
91
|
+
if i + 1 >= len(words):
|
|
92
|
+
break
|
|
93
|
+
cur_len = _visual_len(_join_words([words[k].text for k in group]))
|
|
94
|
+
nxt_len = _visual_len(_join_words([words[k].text for k in group] + [words[i + 1].text]))
|
|
95
|
+
dur = w.end_ms - words[group[0]].start_ms
|
|
96
|
+
if cur_len >= budget * 0.6 and breaks_after(i):
|
|
97
|
+
flush(group, False)
|
|
98
|
+
group = []
|
|
99
|
+
elif nxt_len > budget or dur >= max_ms:
|
|
100
|
+
# back up to the last punctuation in this cue when there is one,
|
|
101
|
+
# so lines end on a natural phrase instead of mid-word
|
|
102
|
+
cut = next((j for j in range(len(group) - 1, 0, -1) if breaks_after(group[j - 1])), None)
|
|
103
|
+
if cut is not None and cut < len(group):
|
|
104
|
+
flush(group[:cut], False)
|
|
105
|
+
group = group[cut:]
|
|
106
|
+
else:
|
|
107
|
+
flush(group, False)
|
|
108
|
+
group = []
|
|
109
|
+
if group:
|
|
110
|
+
flush(group, True)
|
|
111
|
+
|
|
112
|
+
# make cues contiguous enough to read, without overlapping
|
|
113
|
+
for a, b in zip(cues, cues[1:]):
|
|
114
|
+
if b.start_ms - a.end_ms < 120:
|
|
115
|
+
a.end_ms = max(a.start_ms + 200, b.start_ms - 1)
|
|
116
|
+
return cues
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _ts(ms: int, sep: str) -> str:
|
|
120
|
+
h, rem = divmod(max(0, ms), 3_600_000)
|
|
121
|
+
m, rem = divmod(rem, 60_000)
|
|
122
|
+
s, ms = divmod(rem, 1000)
|
|
123
|
+
return f"{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def to_srt(cues: Sequence[Cue]) -> str:
|
|
127
|
+
return "\n".join(
|
|
128
|
+
f"{c.index}\n{_ts(c.start_ms, ',')} --> {_ts(c.end_ms, ',')}\n{c.text}\n" for c in cues
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def to_vtt(cues: Sequence[Cue]) -> str:
|
|
133
|
+
body = "\n".join(f"{_ts(c.start_ms, '.')} --> {_ts(c.end_ms, '.')}\n{c.text}\n" for c in cues)
|
|
134
|
+
return "WEBVTT\n\n" + body
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def write_srt(cues: Sequence[Cue], path: Path) -> None:
|
|
138
|
+
Path(path).write_text(to_srt(cues), "utf-8")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def write_vtt(cues: Sequence[Cue], path: Path) -> None:
|
|
142
|
+
Path(path).write_text(to_vtt(cues), "utf-8")
|
headvox/cli.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Command line interface: ``headvox script.md -o out/ --voice ...``"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .backends import available_backends
|
|
11
|
+
from .script import Script
|
|
12
|
+
from .synth import VoiceSettings, default_cache_dir, synthesize
|
|
13
|
+
|
|
14
|
+
PRESET_VOICES = {
|
|
15
|
+
"zh-female": "zh-CN-XiaoxiaoNeural",
|
|
16
|
+
"zh-male": "zh-CN-YunxiNeural",
|
|
17
|
+
"en-female": "en-US-AvaMultilingualNeural",
|
|
18
|
+
"en-male": "en-US-AndrewMultilingualNeural",
|
|
19
|
+
"ja-female": "ja-JP-NanamiNeural",
|
|
20
|
+
"ja-male": "ja-JP-KeitaNeural",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _signed(v: str) -> float:
|
|
25
|
+
return float(v.rstrip("%").rstrip("Hz").rstrip("hz"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
p = argparse.ArgumentParser(
|
|
30
|
+
prog="headvox",
|
|
31
|
+
description="Turn a script into a talking-head voiceover with aligned SRT/VTT captions.",
|
|
32
|
+
)
|
|
33
|
+
p.add_argument("script", help="script file (.md or .txt), or '-' for stdin")
|
|
34
|
+
p.add_argument("-o", "--out", default="headvox-out", help="output directory (default: headvox-out)")
|
|
35
|
+
p.add_argument("-n", "--name", default="voiceover", help="base name for output files")
|
|
36
|
+
p.add_argument("-b", "--backend", default="edge", choices=available_backends())
|
|
37
|
+
p.add_argument("-v", "--voice", default="zh-female",
|
|
38
|
+
help=f"voice id or preset ({', '.join(PRESET_VOICES)})")
|
|
39
|
+
p.add_argument("--rate", type=_signed, default=0, help="speed change in percent, e.g. +8 or -5")
|
|
40
|
+
p.add_argument("--pitch", type=_signed, default=0, help="pitch change in Hz, e.g. +2")
|
|
41
|
+
p.add_argument("--volume", type=_signed, default=0, help="volume change in percent")
|
|
42
|
+
p.add_argument("--format", default="mp3", choices=["mp3", "wav", "m4a"])
|
|
43
|
+
p.add_argument("--plain", action="store_true", help="treat input as plain text, not Markdown")
|
|
44
|
+
p.add_argument("--max-len", type=int, default=120, help="longest segment sent to TTS")
|
|
45
|
+
p.add_argument("--sentence-pause", type=int, default=250, help="ms between sentences")
|
|
46
|
+
p.add_argument("--paragraph-pause", type=int, default=700, help="ms between paragraphs")
|
|
47
|
+
p.add_argument("--caption-chars", type=int, default=42, help="caption width (CJK char = 2)")
|
|
48
|
+
p.add_argument("--no-cache", action="store_true", help="always call the TTS backend")
|
|
49
|
+
p.add_argument("--proxy", default=None, help="HTTP proxy for online backends")
|
|
50
|
+
p.add_argument("--video", help="video to put the voiceover on")
|
|
51
|
+
p.add_argument("--burn-subs", action="store_true", help="render captions into the picture")
|
|
52
|
+
p.add_argument("--no-subs", action="store_true", help="don't attach captions to the video")
|
|
53
|
+
p.add_argument("--music", help="background music, looped and ducked under the voice")
|
|
54
|
+
p.add_argument("--music-volume", type=float, default=0.12)
|
|
55
|
+
p.add_argument("--fit", choices=["loop", "audio"], default="loop",
|
|
56
|
+
help="loop the video to the voiceover length, or cut at the shorter one")
|
|
57
|
+
p.add_argument("--dry-run", action="store_true", help="only print how the script will be segmented")
|
|
58
|
+
p.add_argument("-q", "--quiet", action="store_true")
|
|
59
|
+
p.add_argument("--version", action="version", version=f"headvox {__version__}")
|
|
60
|
+
return p
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(argv=None) -> int:
|
|
64
|
+
args = build_parser().parse_args(argv)
|
|
65
|
+
raw = sys.stdin.read() if args.script == "-" else Path(args.script).read_text("utf-8")
|
|
66
|
+
script = Script.from_text(
|
|
67
|
+
raw,
|
|
68
|
+
markdown=not args.plain,
|
|
69
|
+
max_len=args.max_len,
|
|
70
|
+
sentence_pause_ms=args.sentence_pause,
|
|
71
|
+
paragraph_pause_ms=args.paragraph_pause,
|
|
72
|
+
)
|
|
73
|
+
if not script.segments:
|
|
74
|
+
print("headvox: the script has nothing to say", file=sys.stderr)
|
|
75
|
+
return 2
|
|
76
|
+
|
|
77
|
+
if args.dry_run:
|
|
78
|
+
for i, s in enumerate(script.segments, 1):
|
|
79
|
+
print(f"{i:>3}. [{s.pause_after_ms:>4} ms] {s.text}")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
settings = VoiceSettings(
|
|
83
|
+
voice=PRESET_VOICES.get(args.voice, args.voice),
|
|
84
|
+
rate_pct=args.rate, pitch_hz=args.pitch, volume_pct=args.volume,
|
|
85
|
+
)
|
|
86
|
+
backend_opts = {}
|
|
87
|
+
if args.backend == "edge":
|
|
88
|
+
backend_opts["proxy"] = args.proxy or os.environ.get("HEADVOX_PROXY")
|
|
89
|
+
|
|
90
|
+
def progress(i, total, seg):
|
|
91
|
+
if args.quiet:
|
|
92
|
+
return
|
|
93
|
+
bar = "#" * int(20 * i / total)
|
|
94
|
+
print(f"\r[{bar:<20}] {i}/{total}", end="" if i < total else "\n", file=sys.stderr, flush=True)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
res = synthesize(
|
|
98
|
+
script, args.out,
|
|
99
|
+
backend=args.backend, settings=settings, name=args.name,
|
|
100
|
+
audio_format=args.format,
|
|
101
|
+
cache_dir=None if args.no_cache else default_cache_dir(),
|
|
102
|
+
caption_max_chars=args.caption_chars,
|
|
103
|
+
backend_options=backend_opts, progress=progress,
|
|
104
|
+
)
|
|
105
|
+
except Exception as exc: # surface a clean message on the CLI
|
|
106
|
+
print(f"\nheadvox: {exc}", file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
|
|
109
|
+
outputs = [res.audio_path, res.srt_path, res.vtt_path, res.manifest_path]
|
|
110
|
+
if args.video:
|
|
111
|
+
from .video import mux
|
|
112
|
+
|
|
113
|
+
out_video = Path(args.out) / f"{args.name}.mp4"
|
|
114
|
+
try:
|
|
115
|
+
mux(args.video, res.audio_path, out_video,
|
|
116
|
+
subtitles=None if args.no_subs else res.srt_path,
|
|
117
|
+
burn=args.burn_subs, music=args.music,
|
|
118
|
+
music_volume=args.music_volume, fit=args.fit)
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
print(f"headvox: {exc}", file=sys.stderr)
|
|
121
|
+
return 1
|
|
122
|
+
outputs.append(out_video)
|
|
123
|
+
|
|
124
|
+
if not args.quiet:
|
|
125
|
+
print(f"duration {res.duration_ms / 1000:.2f}s, {len(res.segments)} segments, {len(res.cues)} captions")
|
|
126
|
+
for o in outputs:
|
|
127
|
+
print(f" {o}")
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__": # pragma: no cover
|
|
132
|
+
sys.exit(main())
|
headvox/script.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Script parsing: Markdown/plain text -> speakable segments.
|
|
2
|
+
|
|
3
|
+
Rules
|
|
4
|
+
-----
|
|
5
|
+
* Markdown formatting (headings, emphasis, links, code fences, list bullets,
|
|
6
|
+
block quotes, images, HTML tags) is stripped down to the words a narrator
|
|
7
|
+
would actually say.
|
|
8
|
+
* Blank lines separate paragraphs. A paragraph break inserts a longer pause
|
|
9
|
+
than a sentence break.
|
|
10
|
+
* Inline directives let the writer control pacing:
|
|
11
|
+
[pause] -> default extra pause (600 ms)
|
|
12
|
+
[pause 1.5s] -> 1.5 s pause
|
|
13
|
+
[pause 400ms] -> 400 ms pause
|
|
14
|
+
* Long sentences are split at the nearest clause punctuation so no single
|
|
15
|
+
TTS request (or caption) grows unwieldy. CJK and Latin punctuation are both
|
|
16
|
+
understood, so mixed Chinese/English scripts segment naturally.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Iterable, List
|
|
23
|
+
|
|
24
|
+
# Sentence-ending punctuation (Latin + CJK). Closing quotes/brackets that
|
|
25
|
+
# follow the terminator stay attached to the sentence.
|
|
26
|
+
_SENT_END = r"[。!?!?…]+|(?<![0-9A-Z])\.(?=\s|$)"
|
|
27
|
+
_CLOSERS = "\"'”’」』))】]"
|
|
28
|
+
_CLAUSE = ",,;;::、—"
|
|
29
|
+
|
|
30
|
+
_PAUSE_RE = re.compile(r"\[pause(?:\s+([0-9]*\.?[0-9]+)\s*(ms|s)?)?\]", re.IGNORECASE)
|
|
31
|
+
|
|
32
|
+
DEFAULT_PAUSE_MS = 600
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Segment:
|
|
37
|
+
"""One unit of speech sent to the TTS backend."""
|
|
38
|
+
|
|
39
|
+
text: str
|
|
40
|
+
pause_after_ms: int = 0
|
|
41
|
+
paragraph: int = 0
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
self.text = self.text.strip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Script:
|
|
49
|
+
segments: List[Segment] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_text(cls, text: str, **kw) -> "Script":
|
|
53
|
+
return cls(split_script(text, **kw))
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_file(cls, path: str, **kw) -> "Script":
|
|
57
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
58
|
+
return cls.from_text(fh.read(), **kw)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def text(self) -> str:
|
|
62
|
+
return " ".join(s.text for s in self.segments)
|
|
63
|
+
|
|
64
|
+
def __len__(self) -> int: # pragma: no cover - trivial
|
|
65
|
+
return len(self.segments)
|
|
66
|
+
|
|
67
|
+
def __iter__(self): # pragma: no cover - trivial
|
|
68
|
+
return iter(self.segments)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------- markdown
|
|
72
|
+
|
|
73
|
+
def strip_markdown(text: str) -> str:
|
|
74
|
+
"""Reduce Markdown to narratable prose while keeping paragraph breaks."""
|
|
75
|
+
# fenced code blocks are not narrated
|
|
76
|
+
text = re.sub(r"```.*?```", "", text, flags=re.S)
|
|
77
|
+
text = re.sub(r"~~~.*?~~~", "", text, flags=re.S)
|
|
78
|
+
# HTML comments are author notes
|
|
79
|
+
text = re.sub(r"<!--.*?-->", "", text, flags=re.S)
|
|
80
|
+
# front matter
|
|
81
|
+
text = re.sub(r"\A---\n.*?\n---\n", "", text, flags=re.S)
|
|
82
|
+
out_lines = []
|
|
83
|
+
for line in text.splitlines():
|
|
84
|
+
s = line.strip()
|
|
85
|
+
s = re.sub(r"^#{1,6}\s+", "", s) # headings
|
|
86
|
+
s = re.sub(r"^>\s?", "", s) # quotes
|
|
87
|
+
s = re.sub(r"^([-*+]|\d+[.)])\s+", "", s) # list bullets
|
|
88
|
+
s = re.sub(r"^\|.*\|$", "", s) if re.match(r"^\|[\s:-|]+\|$", s) else s # table rules
|
|
89
|
+
s = s.replace("|", " ") if s.startswith("|") else s
|
|
90
|
+
s = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", s) # images
|
|
91
|
+
s = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", s) # links -> label
|
|
92
|
+
s = re.sub(r"`([^`]*)`", r"\1", s) # inline code
|
|
93
|
+
s = re.sub(r"(\*\*|__)(.+?)\1", r"\2", s) # bold
|
|
94
|
+
s = re.sub(r"(?<![\w*])([*_])(?!\s)(.+?)(?<!\s)\1(?![\w*])", r"\2", s) # italics
|
|
95
|
+
s = re.sub(r"~~(.+?)~~", r"\1", s)
|
|
96
|
+
s = re.sub(r"<[^>]+>", "", s) # html tags
|
|
97
|
+
if re.fullmatch(r"[-*_]{3,}", s): # horizontal rule
|
|
98
|
+
s = ""
|
|
99
|
+
out_lines.append(s)
|
|
100
|
+
return "\n".join(out_lines)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------- splitting
|
|
104
|
+
|
|
105
|
+
def _parse_pause(m: re.Match) -> int:
|
|
106
|
+
num, unit = m.group(1), (m.group(2) or "").lower()
|
|
107
|
+
if num is None:
|
|
108
|
+
return DEFAULT_PAUSE_MS
|
|
109
|
+
val = float(num)
|
|
110
|
+
if unit == "ms":
|
|
111
|
+
return int(round(val))
|
|
112
|
+
# bare numbers and "s" are seconds
|
|
113
|
+
return int(round(val * 1000))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _sentences(par: str) -> List[str]:
|
|
117
|
+
parts: List[str] = []
|
|
118
|
+
buf = ""
|
|
119
|
+
i = 0
|
|
120
|
+
pattern = re.compile(_SENT_END)
|
|
121
|
+
while i < len(par):
|
|
122
|
+
m = pattern.search(par, i)
|
|
123
|
+
if not m:
|
|
124
|
+
buf += par[i:]
|
|
125
|
+
break
|
|
126
|
+
end = m.end()
|
|
127
|
+
while end < len(par) and par[end] in _CLOSERS:
|
|
128
|
+
end += 1
|
|
129
|
+
buf += par[i:end]
|
|
130
|
+
parts.append(buf)
|
|
131
|
+
buf = ""
|
|
132
|
+
i = end
|
|
133
|
+
if buf.strip():
|
|
134
|
+
parts.append(buf)
|
|
135
|
+
return [p.strip() for p in parts if p.strip()]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _visual_len(s: str) -> int:
|
|
139
|
+
"""Approximate spoken length: CJK chars count double (≈ a Latin word)."""
|
|
140
|
+
n = 0
|
|
141
|
+
for ch in s:
|
|
142
|
+
n += 2 if _is_wide(ch) else 1
|
|
143
|
+
return n
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _is_wide(ch: str) -> bool:
|
|
147
|
+
o = ord(ch)
|
|
148
|
+
return (
|
|
149
|
+
0x1100 <= o <= 0x115F
|
|
150
|
+
or 0x2E80 <= o <= 0xA4CF
|
|
151
|
+
or 0xAC00 <= o <= 0xD7A3
|
|
152
|
+
or 0xF900 <= o <= 0xFAFF
|
|
153
|
+
or 0xFE30 <= o <= 0xFE4F
|
|
154
|
+
or 0xFF00 <= o <= 0xFF60
|
|
155
|
+
or 0xFFE0 <= o <= 0xFFE6
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _split_long(sentence: str, max_len: int) -> List[str]:
|
|
160
|
+
if _visual_len(sentence) <= max_len:
|
|
161
|
+
return [sentence]
|
|
162
|
+
# break at the clause punctuation closest to the middle
|
|
163
|
+
best = None
|
|
164
|
+
mid = len(sentence) / 2
|
|
165
|
+
for idx, ch in enumerate(sentence[:-1]):
|
|
166
|
+
if ch in _CLAUSE:
|
|
167
|
+
if best is None or abs(idx - mid) < abs(best - mid):
|
|
168
|
+
best = idx
|
|
169
|
+
if best is None:
|
|
170
|
+
# fall back to the space closest to the middle
|
|
171
|
+
for idx, ch in enumerate(sentence):
|
|
172
|
+
if ch == " " and (best is None or abs(idx - mid) < abs(best - mid)):
|
|
173
|
+
best = idx
|
|
174
|
+
if best is None:
|
|
175
|
+
# hard split (e.g. one huge CJK run with no punctuation)
|
|
176
|
+
cut = max(1, int(mid))
|
|
177
|
+
return _split_long(sentence[:cut], max_len) + _split_long(sentence[cut:], max_len)
|
|
178
|
+
left, right = sentence[: best + 1].strip(), sentence[best + 1 :].strip()
|
|
179
|
+
return [p for p in _split_long(left, max_len) + _split_long(right, max_len) if p]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def split_script(
|
|
183
|
+
text: str,
|
|
184
|
+
*,
|
|
185
|
+
markdown: bool = True,
|
|
186
|
+
max_len: int = 120,
|
|
187
|
+
sentence_pause_ms: int = 250,
|
|
188
|
+
paragraph_pause_ms: int = 700,
|
|
189
|
+
) -> List[Segment]:
|
|
190
|
+
"""Split a script into :class:`Segment` objects ready for synthesis."""
|
|
191
|
+
if markdown:
|
|
192
|
+
text = strip_markdown(text)
|
|
193
|
+
text = text.replace("\r\n", "\n")
|
|
194
|
+
paragraphs = re.split(r"\n\s*\n", text)
|
|
195
|
+
segments: List[Segment] = []
|
|
196
|
+
for p_idx, par in enumerate(paragraphs):
|
|
197
|
+
par = " ".join(l.strip() for l in par.splitlines() if l.strip())
|
|
198
|
+
if not par:
|
|
199
|
+
continue
|
|
200
|
+
# walk through pause directives inside the paragraph
|
|
201
|
+
pos = 0
|
|
202
|
+
chunks: List[tuple] = [] # (text, extra_pause)
|
|
203
|
+
for m in _PAUSE_RE.finditer(par):
|
|
204
|
+
chunks.append((par[pos : m.start()], _parse_pause(m)))
|
|
205
|
+
pos = m.end()
|
|
206
|
+
chunks.append((par[pos:], 0))
|
|
207
|
+
par_segments: List[Segment] = []
|
|
208
|
+
for chunk_text, extra in chunks:
|
|
209
|
+
pieces: List[str] = []
|
|
210
|
+
for sent in _sentences(chunk_text):
|
|
211
|
+
pieces.extend(_split_long(sent, max_len))
|
|
212
|
+
for piece in pieces:
|
|
213
|
+
par_segments.append(Segment(piece, sentence_pause_ms, p_idx))
|
|
214
|
+
if extra:
|
|
215
|
+
if par_segments:
|
|
216
|
+
par_segments[-1].pause_after_ms += extra
|
|
217
|
+
elif segments:
|
|
218
|
+
segments[-1].pause_after_ms += extra
|
|
219
|
+
if par_segments:
|
|
220
|
+
par_segments[-1].pause_after_ms = max(
|
|
221
|
+
par_segments[-1].pause_after_ms, paragraph_pause_ms
|
|
222
|
+
)
|
|
223
|
+
segments.extend(par_segments)
|
|
224
|
+
if segments:
|
|
225
|
+
segments[-1].pause_after_ms = 0
|
|
226
|
+
return [s for s in segments if s.text]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def iter_words(text: str) -> Iterable[str]:
|
|
230
|
+
"""Tokenize for captioning: Latin words stay whole, CJK splits per char."""
|
|
231
|
+
for tok in re.findall(r"[A-Za-z0-9'’\-]+[.,!?;:]*|[^\sA-Za-z0-9]", text):
|
|
232
|
+
yield tok
|
headvox/synth.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Core pipeline: script -> per-segment TTS -> one audio track + captions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import asdict, dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable, List, Optional, Union
|
|
12
|
+
|
|
13
|
+
from .backends import SegmentAudio, TTSBackend, WordMark, get_backend
|
|
14
|
+
from .captions import Cue, build_cues, write_srt, write_vtt
|
|
15
|
+
from .script import Script, Segment
|
|
16
|
+
|
|
17
|
+
SAMPLE_RATE = 24_000
|
|
18
|
+
BYTES_PER_MS = SAMPLE_RATE * 2 // 1000 # mono s16le
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class VoiceSettings:
|
|
23
|
+
voice: Optional[str] = None
|
|
24
|
+
rate_pct: float = 0.0 # +10 -> 10% faster
|
|
25
|
+
pitch_hz: float = 0.0
|
|
26
|
+
volume_pct: float = 0.0
|
|
27
|
+
|
|
28
|
+
def key(self) -> str:
|
|
29
|
+
return json.dumps(asdict(self), sort_keys=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class TimedSegment:
|
|
34
|
+
index: int
|
|
35
|
+
text: str
|
|
36
|
+
start_ms: int
|
|
37
|
+
end_ms: int
|
|
38
|
+
pause_after_ms: int
|
|
39
|
+
words: List[WordMark] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class SynthResult:
|
|
44
|
+
audio_path: Path
|
|
45
|
+
srt_path: Path
|
|
46
|
+
vtt_path: Path
|
|
47
|
+
manifest_path: Path
|
|
48
|
+
duration_ms: int
|
|
49
|
+
segments: List[TimedSegment]
|
|
50
|
+
cues: List[Cue]
|
|
51
|
+
video_path: Optional[Path] = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------- ffmpeg
|
|
55
|
+
|
|
56
|
+
def _need(tool: str) -> str:
|
|
57
|
+
path = shutil.which(tool)
|
|
58
|
+
if not path:
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
f"{tool} not found on PATH. HeadVox uses ffmpeg/ffprobe for audio; "
|
|
61
|
+
"install it from https://ffmpeg.org or your package manager."
|
|
62
|
+
)
|
|
63
|
+
return path
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _decode_to_pcm(data: bytes, fmt: str) -> bytes:
|
|
67
|
+
"""Decode any audio blob to mono 24 kHz s16le PCM."""
|
|
68
|
+
ffmpeg = _need("ffmpeg")
|
|
69
|
+
proc = subprocess.run(
|
|
70
|
+
[ffmpeg, "-hide_banner", "-loglevel", "error", "-f", fmt, "-i", "pipe:0",
|
|
71
|
+
"-ac", "1", "-ar", str(SAMPLE_RATE), "-f", "s16le", "pipe:1"],
|
|
72
|
+
input=data, capture_output=True, check=False,
|
|
73
|
+
)
|
|
74
|
+
if proc.returncode != 0:
|
|
75
|
+
raise RuntimeError(f"ffmpeg could not decode {fmt} audio: {proc.stderr.decode(errors='ignore')}")
|
|
76
|
+
return proc.stdout
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _trim_silence_tail(pcm: bytes, threshold: int = 300, keep_ms: int = 60) -> bytes:
|
|
80
|
+
"""Drop trailing near-silence some TTS engines pad onto each clip, so the
|
|
81
|
+
pauses HeadVox inserts are the pauses the listener actually hears."""
|
|
82
|
+
import array
|
|
83
|
+
|
|
84
|
+
a = array.array("h")
|
|
85
|
+
a.frombytes(pcm[: len(pcm) // 2 * 2])
|
|
86
|
+
end = len(a)
|
|
87
|
+
while end > 0 and abs(a[end - 1]) < threshold:
|
|
88
|
+
end -= 1
|
|
89
|
+
end = min(len(a), end + SAMPLE_RATE * keep_ms // 1000)
|
|
90
|
+
return a[:end].tobytes()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _encode(pcm: bytes, out: Path) -> None:
|
|
94
|
+
ffmpeg = _need("ffmpeg")
|
|
95
|
+
codec = ["-c:a", "libmp3lame", "-b:a", "128k"] if out.suffix.lower() == ".mp3" else []
|
|
96
|
+
if out.suffix.lower() == ".m4a":
|
|
97
|
+
codec = ["-c:a", "aac", "-b:a", "160k"]
|
|
98
|
+
proc = subprocess.run(
|
|
99
|
+
[ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-f", "s16le", "-ar", str(SAMPLE_RATE),
|
|
100
|
+
"-ac", "1", "-i", "pipe:0", *codec, str(out)],
|
|
101
|
+
input=pcm, capture_output=True, check=False,
|
|
102
|
+
)
|
|
103
|
+
if proc.returncode != 0:
|
|
104
|
+
raise RuntimeError(f"ffmpeg encode failed: {proc.stderr.decode(errors='ignore')}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------- cache
|
|
108
|
+
|
|
109
|
+
class _Cache:
|
|
110
|
+
def __init__(self, root: Optional[Path]) -> None:
|
|
111
|
+
self.root = root
|
|
112
|
+
if root:
|
|
113
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
|
|
115
|
+
def _key(self, backend: str, text: str, settings: VoiceSettings) -> str:
|
|
116
|
+
h = hashlib.sha256(f"{backend}\x00{settings.key()}\x00{text}".encode()).hexdigest()
|
|
117
|
+
return h[:32]
|
|
118
|
+
|
|
119
|
+
def get(self, backend, text, settings) -> Optional[SegmentAudio]:
|
|
120
|
+
if not self.root:
|
|
121
|
+
return None
|
|
122
|
+
k = self._key(backend, text, settings)
|
|
123
|
+
meta, blob = self.root / f"{k}.json", self.root / f"{k}.bin"
|
|
124
|
+
if not (meta.exists() and blob.exists()):
|
|
125
|
+
return None
|
|
126
|
+
m = json.loads(meta.read_text("utf-8"))
|
|
127
|
+
return SegmentAudio(
|
|
128
|
+
blob.read_bytes(), m["format"], m["duration_ms"], [WordMark(**w) for w in m["words"]]
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def put(self, backend, text, settings, seg: SegmentAudio) -> None:
|
|
132
|
+
if not self.root:
|
|
133
|
+
return
|
|
134
|
+
k = self._key(backend, text, settings)
|
|
135
|
+
(self.root / f"{k}.bin").write_bytes(seg.audio)
|
|
136
|
+
(self.root / f"{k}.json").write_text(
|
|
137
|
+
json.dumps({"format": seg.audio_format, "duration_ms": seg.duration_ms,
|
|
138
|
+
"words": [asdict(w) for w in seg.words]}, ensure_ascii=False),
|
|
139
|
+
"utf-8",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------- main API
|
|
144
|
+
|
|
145
|
+
def synthesize(
|
|
146
|
+
script: Union[Script, str],
|
|
147
|
+
out_dir: Union[str, Path],
|
|
148
|
+
*,
|
|
149
|
+
backend: Union[str, TTSBackend] = "edge",
|
|
150
|
+
settings: Optional[VoiceSettings] = None,
|
|
151
|
+
name: str = "voiceover",
|
|
152
|
+
audio_format: str = "mp3",
|
|
153
|
+
cache_dir: Optional[Union[str, Path]] = None,
|
|
154
|
+
caption_max_chars: int = 42,
|
|
155
|
+
caption_max_ms: int = 5000,
|
|
156
|
+
lead_in_ms: int = 150,
|
|
157
|
+
trim_tts_padding: bool = True,
|
|
158
|
+
backend_options: Optional[dict] = None,
|
|
159
|
+
progress: Optional[Callable[[int, int, Segment], None]] = None,
|
|
160
|
+
) -> SynthResult:
|
|
161
|
+
"""Render a script into ``<out_dir>/<name>.{mp3,srt,vtt,json}``."""
|
|
162
|
+
if isinstance(script, str):
|
|
163
|
+
script = Script.from_text(script)
|
|
164
|
+
if not script.segments:
|
|
165
|
+
raise ValueError("script is empty: nothing to say")
|
|
166
|
+
settings = settings or VoiceSettings()
|
|
167
|
+
tts = get_backend(backend, **(backend_options or {})) if isinstance(backend, str) else backend
|
|
168
|
+
out = Path(out_dir)
|
|
169
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
cache = _Cache(Path(cache_dir) if cache_dir else None)
|
|
171
|
+
|
|
172
|
+
pcm = bytearray(b"\x00\x00" * (SAMPLE_RATE * lead_in_ms // 1000))
|
|
173
|
+
timed: List[TimedSegment] = []
|
|
174
|
+
total = len(script.segments)
|
|
175
|
+
for i, seg in enumerate(script.segments):
|
|
176
|
+
if progress:
|
|
177
|
+
progress(i, total, seg)
|
|
178
|
+
clip = cache.get(tts.name, seg.text, settings)
|
|
179
|
+
if clip is None:
|
|
180
|
+
clip = tts.synthesize_segment(seg.text, settings)
|
|
181
|
+
if not clip.audio:
|
|
182
|
+
raise RuntimeError(f"backend {tts.name!r} returned no audio for: {seg.text!r}")
|
|
183
|
+
cache.put(tts.name, seg.text, settings, clip)
|
|
184
|
+
clip_pcm = _decode_to_pcm(clip.audio, clip.audio_format)
|
|
185
|
+
if trim_tts_padding:
|
|
186
|
+
last_word = clip.words[-1].end_ms if clip.words else 0
|
|
187
|
+
trimmed = _trim_silence_tail(clip_pcm)
|
|
188
|
+
# never cut into the last spoken word
|
|
189
|
+
min_len = (last_word + 40) * BYTES_PER_MS
|
|
190
|
+
clip_pcm = trimmed if len(trimmed) >= min_len else clip_pcm[: max(len(trimmed), min_len)]
|
|
191
|
+
start_ms = len(pcm) // BYTES_PER_MS
|
|
192
|
+
pcm += clip_pcm
|
|
193
|
+
end_ms = len(pcm) // BYTES_PER_MS
|
|
194
|
+
words = [WordMark(w.text, start_ms + w.start_ms, min(end_ms, start_ms + w.end_ms)) for w in clip.words]
|
|
195
|
+
timed.append(TimedSegment(i, seg.text, start_ms, end_ms, seg.pause_after_ms, words))
|
|
196
|
+
pcm += b"\x00\x00" * (SAMPLE_RATE * seg.pause_after_ms // 1000)
|
|
197
|
+
pcm += b"\x00\x00" * (SAMPLE_RATE * 300 // 1000) # gentle tail
|
|
198
|
+
if progress:
|
|
199
|
+
progress(total, total, script.segments[-1])
|
|
200
|
+
|
|
201
|
+
audio_path = out / f"{name}.{audio_format}"
|
|
202
|
+
_encode(bytes(pcm), audio_path)
|
|
203
|
+
duration_ms = len(pcm) // BYTES_PER_MS
|
|
204
|
+
|
|
205
|
+
cues = build_cues(timed, max_chars=caption_max_chars, max_ms=caption_max_ms)
|
|
206
|
+
srt_path, vtt_path = out / f"{name}.srt", out / f"{name}.vtt"
|
|
207
|
+
write_srt(cues, srt_path)
|
|
208
|
+
write_vtt(cues, vtt_path)
|
|
209
|
+
|
|
210
|
+
manifest_path = out / f"{name}.json"
|
|
211
|
+
manifest = {
|
|
212
|
+
"tool": "headvox",
|
|
213
|
+
"backend": tts.name,
|
|
214
|
+
"settings": asdict(settings),
|
|
215
|
+
"audio": audio_path.name,
|
|
216
|
+
"duration_ms": duration_ms,
|
|
217
|
+
"segments": [
|
|
218
|
+
{"index": t.index, "text": t.text, "start_ms": t.start_ms, "end_ms": t.end_ms,
|
|
219
|
+
"words": [asdict(w) for w in t.words]}
|
|
220
|
+
for t in timed
|
|
221
|
+
],
|
|
222
|
+
"cues": [asdict(c) for c in cues],
|
|
223
|
+
}
|
|
224
|
+
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), "utf-8")
|
|
225
|
+
return SynthResult(audio_path, srt_path, vtt_path, manifest_path, duration_ms, timed, cues)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def probe_duration_ms(path: Union[str, Path]) -> int:
|
|
229
|
+
ffprobe = _need("ffprobe")
|
|
230
|
+
p = subprocess.run(
|
|
231
|
+
[ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(path)],
|
|
232
|
+
capture_output=True, text=True, check=True,
|
|
233
|
+
)
|
|
234
|
+
return int(round(float(p.stdout.strip()) * 1000))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def default_cache_dir() -> Path:
|
|
238
|
+
base = os.environ.get("XDG_CACHE_HOME") or os.path.join(Path.home(), ".cache")
|
|
239
|
+
return Path(base) / "headvox"
|
|
240
|
+
|
headvox/video.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Put the voiceover onto a talking-head (or b-roll) video with ffmpeg."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, Union
|
|
8
|
+
|
|
9
|
+
from .synth import _need, probe_duration_ms
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _has_filter(name: str) -> bool:
|
|
13
|
+
ffmpeg = shutil.which("ffmpeg")
|
|
14
|
+
if not ffmpeg:
|
|
15
|
+
return False
|
|
16
|
+
p = subprocess.run([ffmpeg, "-hide_banner", "-filters"], capture_output=True, text=True)
|
|
17
|
+
return any(line.split()[1:2] == [name] for line in p.stdout.splitlines() if line.strip())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _escape_filter_path(p: Path) -> str:
|
|
21
|
+
s = str(p.resolve()).replace("\\", "/")
|
|
22
|
+
return s.replace(":", r"\:").replace("'", r"\'")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def mux(
|
|
26
|
+
video: Union[str, Path],
|
|
27
|
+
voiceover: Union[str, Path],
|
|
28
|
+
out: Union[str, Path],
|
|
29
|
+
*,
|
|
30
|
+
subtitles: Optional[Union[str, Path]] = None,
|
|
31
|
+
burn: bool = False,
|
|
32
|
+
music: Optional[Union[str, Path]] = None,
|
|
33
|
+
music_volume: float = 0.12,
|
|
34
|
+
fit: str = "loop",
|
|
35
|
+
) -> Path:
|
|
36
|
+
"""Combine ``video`` with ``voiceover``.
|
|
37
|
+
|
|
38
|
+
fit:
|
|
39
|
+
* ``loop`` — loop the video until the voiceover ends (good for short
|
|
40
|
+
avatar clips or b-roll).
|
|
41
|
+
* ``audio`` — cut at whichever ends first.
|
|
42
|
+
subtitles + burn=False embeds a soft subtitle track (toggle in players);
|
|
43
|
+
burn=True renders them into the picture (needs ffmpeg built with libass).
|
|
44
|
+
music is looped under the voice and ducked with sidechain compression.
|
|
45
|
+
"""
|
|
46
|
+
ffmpeg = _need("ffmpeg")
|
|
47
|
+
video, voiceover, out = Path(video), Path(voiceover), Path(out)
|
|
48
|
+
dur_s = probe_duration_ms(voiceover) / 1000.0
|
|
49
|
+
|
|
50
|
+
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y"]
|
|
51
|
+
if fit == "loop":
|
|
52
|
+
cmd += ["-stream_loop", "-1"]
|
|
53
|
+
cmd += ["-i", str(video), "-i", str(voiceover)]
|
|
54
|
+
n_inputs = 2
|
|
55
|
+
if music:
|
|
56
|
+
cmd += ["-stream_loop", "-1", "-i", str(music)]
|
|
57
|
+
n_inputs += 1
|
|
58
|
+
sub_input = None
|
|
59
|
+
if subtitles and not burn:
|
|
60
|
+
cmd += ["-i", str(subtitles)]
|
|
61
|
+
sub_input = n_inputs
|
|
62
|
+
n_inputs += 1
|
|
63
|
+
|
|
64
|
+
filters = []
|
|
65
|
+
vmap = "0:v:0"
|
|
66
|
+
if subtitles and burn:
|
|
67
|
+
if not _has_filter("subtitles"):
|
|
68
|
+
raise RuntimeError("burning subtitles needs ffmpeg built with libass; use soft subtitles instead")
|
|
69
|
+
filters.append(f"[0:v]subtitles='{_escape_filter_path(Path(subtitles))}'[v]")
|
|
70
|
+
vmap = "[v]"
|
|
71
|
+
amap = "1:a:0"
|
|
72
|
+
if music:
|
|
73
|
+
filters.append(f"[2:a]volume={music_volume}[bg]")
|
|
74
|
+
filters.append("[bg][1:a]sidechaincompress=threshold=0.05:ratio=8:attack=20:release=400[duck]")
|
|
75
|
+
filters.append("[1:a][duck]amix=inputs=2:duration=first:dropout_transition=0[a]")
|
|
76
|
+
amap = "[a]"
|
|
77
|
+
if filters:
|
|
78
|
+
cmd += ["-filter_complex", ";".join(filters)]
|
|
79
|
+
cmd += ["-map", vmap, "-map", amap]
|
|
80
|
+
if sub_input is not None:
|
|
81
|
+
cmd += ["-map", f"{sub_input}:s:0"]
|
|
82
|
+
vcodec = ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p"]
|
|
83
|
+
cmd += vcodec + ["-c:a", "aac", "-b:a", "160k"]
|
|
84
|
+
if sub_input is not None:
|
|
85
|
+
cmd += ["-c:s", "mov_text" if out.suffix.lower() in (".mp4", ".mov", ".m4v") else "srt"]
|
|
86
|
+
cmd += ["-t", f"{dur_s:.3f}"] if fit == "loop" else ["-shortest"]
|
|
87
|
+
cmd += ["-movflags", "+faststart", str(out)]
|
|
88
|
+
p = subprocess.run(cmd, capture_output=True, text=True)
|
|
89
|
+
if p.returncode != 0:
|
|
90
|
+
raise RuntimeError(f"ffmpeg mux failed:\n{p.stderr}")
|
|
91
|
+
return out
|
|
@@ -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.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
headvox/__init__.py,sha256=3l5lIathY6eDxXrNeOjviV23v2Ymso9cAo2BplSxe6M,969
|
|
2
|
+
headvox/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
headvox/captions.py,sha256=eec3xfhZYNQyxeC_DOgl_5sPf3zW3Tp44dl1UZEI7go,4959
|
|
4
|
+
headvox/cli.py,sha256=CoaQaybkrNJKUNizKr6M5QI1MQiHbUMXcNrb1UBGwYo,5749
|
|
5
|
+
headvox/script.py,sha256=-sbdzSNfR6Y4xNQTv77VmLeUbSx4IXV5Jl-waf57mJs,7951
|
|
6
|
+
headvox/synth.py,sha256=Rb_qp1HH_ZVfnEV2xbH-Gw3vOvqHfWrta7_rNgLYWME,8720
|
|
7
|
+
headvox/video.py,sha256=tMr5o2AyjtXPolZcanw51ZZ7Jlkr656XpnEcNnSJ_aU,3377
|
|
8
|
+
headvox/backends/__init__.py,sha256=SBCOw96D-A2LF29Hz6l5eaDQtzoj8LHsMkj39d-yJfk,1927
|
|
9
|
+
headvox/backends/edge.py,sha256=DE8tQBmdyIb_bJuwgRfp9WZsHWfi4oXfWhCIuczaTlY,2385
|
|
10
|
+
headvox/backends/sine.py,sha256=QwnPk10fRZkIhEnfcn9tLh0JDinEFIWwTzfssd-_Q8U,2098
|
|
11
|
+
headvox-0.1.0.dist-info/licenses/LICENSE,sha256=EHUdO9_J8eAO-BfY6sL-fv8iTAVy6nYBEETrkiPMAzM,1077
|
|
12
|
+
headvox-0.1.0.dist-info/METADATA,sha256=hYzosNdyoLs5eaOVMk0l98EAoBIjEoB1p3A9x_bMZlo,6828
|
|
13
|
+
headvox-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
14
|
+
headvox-0.1.0.dist-info/entry_points.txt,sha256=O41NzOvsII-JtoWTYf-HK-BGD1ZUhHfKtbCTvh7SZfM,45
|
|
15
|
+
headvox-0.1.0.dist-info/top_level.txt,sha256=DzKGUvDHbft-TiPJtoYdqagkSaY9od379BiWMvB48Us,8
|
|
16
|
+
headvox-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
headvox
|