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 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,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ headvox = headvox.cli:main
@@ -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