faster-tts 0.0.1__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.
faster_tts/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """CTranslate2 text-to-speech through the public Model and SynthesisResult API.
2
+
3
+ See README.md for setup and examples. Engine-specific helpers are internal.
4
+ Importing the package does not download checkpoints or initialize a model.
5
+ """
6
+
7
+ from .model import Model, SynthesisResult
8
+
9
+ __all__ = ["Model", "SynthesisResult"]
faster_tts/__main__.py ADDED
@@ -0,0 +1,121 @@
1
+ """Speech synthesis: python -m faster_tts [MODEL] --text TEXT --output out.wav.
2
+
3
+ MODEL defaults to jordimas/drama-3-ct2. Accepts drama-3, a converted Hub repository
4
+ ID (optionally prefixed with hf://), or a local converted directory.
5
+ """
6
+
7
+ import argparse
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from .cache import DEFAULT_MODEL
12
+ from .model import Model, _save_wav
13
+
14
+
15
+ def _cache_budget(value):
16
+ if value in ("auto", "uncapped"):
17
+ return value
18
+ try:
19
+ from .memory import validate_cache_budget
20
+
21
+ result = float(value)
22
+ validate_cache_budget(result)
23
+ return result
24
+ except ValueError as exc:
25
+ raise argparse.ArgumentTypeError(
26
+ 'expected "auto", "uncapped", or a finite positive GB budget'
27
+ ) from exc
28
+
29
+
30
+ def main():
31
+ parser = argparse.ArgumentParser(description=__doc__)
32
+ parser.add_argument(
33
+ "model",
34
+ nargs="?",
35
+ default=DEFAULT_MODEL,
36
+ help="model name, Hub repository ID, or local directory (default: %(default)s)",
37
+ )
38
+ parser.add_argument("--revision", help="Hub revision; prefer a pinned commit")
39
+ parser.add_argument(
40
+ "--cache-dir", type=Path, help="Hugging Face snapshot cache directory"
41
+ )
42
+ parser.add_argument(
43
+ "--local-files-only",
44
+ action="store_true",
45
+ help="resolve Hub assets only from the local cache",
46
+ )
47
+ parser.add_argument("--text", required=True)
48
+ parser.add_argument("--output", required=True, type=Path)
49
+ parser.add_argument("--max-duration-s", type=float, default=24.0)
50
+ parser.add_argument("--threads", type=int, default=8)
51
+ parser.add_argument("--device", choices=("cpu", "cuda", "auto"), default="cpu")
52
+ parser.add_argument("--device-index", type=int, default=0)
53
+ parser.add_argument("--compute-type", default="default")
54
+ parser.add_argument(
55
+ "--gpu-expert-cache-gb",
56
+ type=_cache_budget,
57
+ help='GPU expert cache: decimal GB, "auto", or "uncapped"; defaults to environment or auto',
58
+ )
59
+ parser.add_argument("--temperature", type=float, default=0.7)
60
+ parser.add_argument("--top-k", type=int, default=30)
61
+ parser.add_argument("--top-p", type=float, default=0.7)
62
+ parser.add_argument("--seed", type=int, default=0)
63
+ parser.add_argument(
64
+ "--no-split-sentences",
65
+ action="store_true",
66
+ help="diagnostic single-pass generation",
67
+ )
68
+ parser.add_argument(
69
+ "--greedy",
70
+ action="store_true",
71
+ help="diagnostic greedy decoding instead of sampling",
72
+ )
73
+ parser.add_argument(
74
+ "--directed-spans",
75
+ action="store_true",
76
+ help="opt-in Drama 3 cue/turn splitting and bounded breathing events",
77
+ )
78
+ args = parser.parse_args()
79
+ if args.directed_spans and args.no_split_sentences:
80
+ parser.error("--directed-spans requires sentence splitting")
81
+ engine_options = {}
82
+ if args.gpu_expert_cache_gb is not None:
83
+ engine_options["gpu_expert_cache_gb"] = args.gpu_expert_cache_gb
84
+ model = Model(
85
+ args.model,
86
+ intra_threads=args.threads,
87
+ compute_type=args.compute_type,
88
+ device=args.device,
89
+ device_index=args.device_index,
90
+ revision=args.revision,
91
+ cache_dir=args.cache_dir,
92
+ local_files_only=args.local_files_only,
93
+ engine_options=engine_options or None,
94
+ )
95
+ started = time.perf_counter()
96
+ result = model.synthesize(
97
+ args.text,
98
+ max_duration_s=args.max_duration_s,
99
+ temperature=args.temperature,
100
+ top_k=args.top_k,
101
+ top_p=args.top_p,
102
+ seed=args.seed,
103
+ greedy=args.greedy,
104
+ split_sentences=not args.no_split_sentences,
105
+ **({"engine_options": {"directed_spans": True}} if args.directed_spans else {}),
106
+ )
107
+ inference_seconds = time.perf_counter() - started
108
+ _save_wav(result, args.output)
109
+ print(
110
+ f"Saved {result.audio.size / result.sample_rate:.3f}s to {args.output}; "
111
+ f"finish_reason={result.finish_reason}; decoding={'greedy' if args.greedy else 'sampled'}"
112
+ )
113
+ print(f"Inference time (excluding model loading): {inference_seconds:.3f}s")
114
+ if result.finish_reason != "eos":
115
+ raise SystemExit(
116
+ "Incomplete synthesis: frame limit reached; the saved audio may be truncated."
117
+ )
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
faster_tts/base.py ADDED
@@ -0,0 +1,82 @@
1
+ """Engine registration and shared helpers; engine-specific code lives in engines/."""
2
+
3
+ from copy import deepcopy
4
+ from importlib import import_module
5
+
6
+ import numpy as np
7
+
8
+ _ENGINE_REGISTRY: dict[str, type] = {}
9
+
10
+
11
+ def register_engine(model_type):
12
+ """Decorator that registers a :class:`BaseModel` subclass by ``model_type``.
13
+
14
+ ``model_type`` is matched against the ``model_type`` field of the converted
15
+ checkpoint's ``config.json``. Re-registering an existing key replaces the
16
+ previous binding so engines can be reloaded during tests.
17
+ """
18
+ if not isinstance(model_type, str) or not model_type:
19
+ raise ValueError("model_type must be a nonempty string")
20
+
21
+ def decorator(cls):
22
+ _ENGINE_REGISTRY[model_type] = cls
23
+ cls.engine_model_type = model_type
24
+ return cls
25
+
26
+ return decorator
27
+
28
+
29
+ def get_engine_class(model_type):
30
+ """Look up the engine class previously registered for ``model_type``."""
31
+ import_module(".engines.drama3", __package__)
32
+ if not isinstance(model_type, str):
33
+ raise ValueError("Converted checkpoint must specify a string model_type") # noqa: TRY004 - invalid checkpoint data
34
+ try:
35
+ return _ENGINE_REGISTRY[model_type]
36
+ except KeyError:
37
+ raise ValueError(
38
+ f"No CT2 TTS engine registered for model_type={model_type!r}; "
39
+ f"known engines: {sorted(_ENGINE_REGISTRY)}"
40
+ ) from None
41
+
42
+
43
+ def _positive_int(value, name):
44
+ if (
45
+ not isinstance(value, int)
46
+ or isinstance(value, bool)
47
+ or not 1 <= value <= np.iinfo(np.int32).max
48
+ ):
49
+ raise ValueError(f"{name} must be a positive int32 integer")
50
+
51
+
52
+ class BaseModel:
53
+ """Shared surface across CT2 TTS engines.
54
+
55
+ Subclasses set ``self._info`` in their constructor and implement
56
+ ``validate_checkpoint(directory, config)`` and ``synthesize``. Constructors
57
+ receive a resolved directory, checkpoint metadata, and the public loader's
58
+ settings. Engine-specific knobs are declared in ``init_options`` and
59
+ ``synthesize_options`` as frozensets of accepted keys; the dispatcher
60
+ validates ``engine_options`` against these and raises ``ValueError`` on
61
+ unknown keys. Return a SynthesisResult with engine-specific codec
62
+ dimensions. Declare supported features in ``capabilities``. The base class
63
+ adds engine identity and capabilities to an independent copy of ``_info``.
64
+ """
65
+
66
+ engine_model_type: str
67
+ capabilities = ("synthesis",)
68
+ init_options: frozenset = frozenset()
69
+ synthesize_options: frozenset = frozenset()
70
+
71
+ def info(self):
72
+ """Copy engine metadata and add its registered identity and capabilities.
73
+
74
+ Returns:
75
+ dict: Independent deep copy of `_info`, plus `engine` and
76
+ `capabilities`. Public fields are documented in `Model.info`.
77
+ """
78
+ return dict(
79
+ deepcopy(self._info),
80
+ engine=self.engine_model_type,
81
+ capabilities=self.capabilities,
82
+ )
faster_tts/cache.py ADDED
@@ -0,0 +1,123 @@
1
+ """Resolve local converted models, model names, or Hugging Face snapshots.
2
+
3
+ Repository IDs accept an optional ``hf://`` prefix. Explicit local paths never
4
+ trigger downloads. This module does not load an inference engine.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ from importlib import import_module
11
+ from pathlib import Path
12
+
13
+ from .base import get_engine_class
14
+
15
+ DEFAULT_MODEL = "jordimas/drama-3-ct2"
16
+ MODEL_ALIASES = {"drama-3": DEFAULT_MODEL}
17
+
18
+
19
+ ALLOW_PATTERNS = (
20
+ "model.bin",
21
+ "config.json",
22
+ "tokenizer.json",
23
+ "tokenizer_config.json",
24
+ "special_tokens_map.json",
25
+ "conversion.json",
26
+ "LICENSE",
27
+ "LICENSE.*",
28
+ "LICENCE",
29
+ "LICENCE.*",
30
+ "NOTICE",
31
+ "NOTICE.*",
32
+ "README.md",
33
+ )
34
+
35
+
36
+ def _validate(directory):
37
+ directory = Path(directory).expanduser().resolve()
38
+ for name in ("model.bin", "config.json"):
39
+ path = directory / name
40
+ if not path.is_file() or path.stat().st_size == 0:
41
+ raise FileNotFoundError(
42
+ f"Missing or empty converted model asset: {path}. "
43
+ "Provide a complete CTranslate2-converted checkpoint; official "
44
+ "safetensors and GGUF checkpoints cannot be loaded directly."
45
+ )
46
+ try:
47
+ config = json.loads((directory / "config.json").read_text(encoding="utf-8"))
48
+ except (ValueError, UnicodeError) as exc:
49
+ raise ValueError(
50
+ f"Invalid converted model config: {directory / 'config.json'}"
51
+ ) from exc
52
+ if not isinstance(config, dict):
53
+ raise ValueError(f"Invalid converted model config: {directory / 'config.json'}") # noqa: TRY004 - invalid checkpoint data
54
+ engine_class = get_engine_class(config.get("model_type"))
55
+ engine_class.validate_checkpoint(directory, config)
56
+ return directory
57
+
58
+
59
+ def resolve_model_path(
60
+ model=DEFAULT_MODEL, *, revision=None, cache_dir=None, local_files_only=False
61
+ ):
62
+ """Return a validated local converted checkpoint directory as a ``Path``.
63
+
64
+ Accept a local directory, ``drama-3``, or a Hub repository ID (with optional
65
+ ``hf://`` prefix). Existing directories take precedence. Path objects and
66
+ explicit paths (absolute, ./, ../, ~/) stay local. revision, cache_dir and
67
+ local_files_only pass to snapshot_download; no fallback model is loaded.
68
+
69
+ Validation checks required nonempty assets and the supported config envelope,
70
+ not the full binary payload, tokenizer vocabulary, or numerical correctness;
71
+ the matching native loader performs those checks when loading the model.
72
+ """
73
+ if not isinstance(model, (str, os.PathLike)):
74
+ raise TypeError(
75
+ "model must be a local path, model name, or organization/repository string"
76
+ )
77
+ if not isinstance(local_files_only, bool):
78
+ raise TypeError("local_files_only must be a bool")
79
+ if revision is not None and (not isinstance(revision, str) or not revision.strip()):
80
+ raise ValueError("revision must be a nonempty string when provided")
81
+ local = Path(model).expanduser()
82
+ if local.exists():
83
+ if not local.is_dir():
84
+ raise NotADirectoryError(f"Model path is not a directory: {local}")
85
+ return _validate(local)
86
+ if not isinstance(model, str) or model.startswith(("/", "./", "../", "~/")):
87
+ raise FileNotFoundError(f"Local model directory does not exist: {local}")
88
+ explicit_hub = model.startswith("hf://")
89
+ repository = (
90
+ model[len("hf://") :] if explicit_hub else MODEL_ALIASES.get(model, model)
91
+ )
92
+ if not re.fullmatch(
93
+ r"[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*", repository
94
+ ):
95
+ raise ValueError(
96
+ "Use a known model name (drama-3), organization/repository "
97
+ "with revision supplied separately, or an explicit local path"
98
+ )
99
+ try:
100
+ hub = import_module("huggingface_hub")
101
+ except ImportError as exc:
102
+ raise ImportError(
103
+ "Hub model resolution requires the huggingface-hub package. "
104
+ "Install it with: pip install huggingface-hub ; or provide an existing local converted model."
105
+ ) from exc
106
+ try:
107
+ snapshot = hub.snapshot_download(
108
+ repo_id=repository,
109
+ revision=revision,
110
+ cache_dir=os.fspath(Path(cache_dir).expanduser())
111
+ if cache_dir is not None
112
+ else None,
113
+ local_files_only=local_files_only,
114
+ allow_patterns=list(ALLOW_PATTERNS),
115
+ )
116
+ except Exception as exc:
117
+ mode = "local cache" if local_files_only else "Hugging Face Hub"
118
+ raise RuntimeError(
119
+ f"Cannot resolve converted model {model} from {mode} "
120
+ f"at revision {revision or 'default'}. Check the repository, access, "
121
+ "revision and cache availability; no fallback model was loaded."
122
+ ) from exc
123
+ return _validate(snapshot)
faster_tts/chunking.py ADDED
@@ -0,0 +1,252 @@
1
+ """Conservative Unicode sentence and clause boundaries for synthesis."""
2
+
3
+ import re
4
+ from itertools import pairwise
5
+
6
+ _ABBREVIATIONS = frozenset(
7
+ (
8
+ "mr",
9
+ "mrs",
10
+ "ms",
11
+ "dr",
12
+ "dra",
13
+ "sr",
14
+ "sra",
15
+ "srta",
16
+ "prof",
17
+ "jr",
18
+ "vs",
19
+ "etc",
20
+ "approx",
21
+ "no",
22
+ "núm",
23
+ "ud",
24
+ "uds",
25
+ )
26
+ )
27
+ # Period abbreviations are inherently ambiguous without language context. Keep
28
+ # familiar forms and short initials together; unknown forms remain heuristic.
29
+ _ABBREVIATIONS = _ABBREVIATIONS | frozenset(
30
+ (
31
+ "mme",
32
+ "mmes",
33
+ "mlle",
34
+ "mlles",
35
+ "ém",
36
+ "av",
37
+ "bd",
38
+ "bzw",
39
+ "ca",
40
+ "dipl",
41
+ "ing",
42
+ "nr",
43
+ "str",
44
+ "vgl",
45
+ "z.b",
46
+ "d.h",
47
+ "sig",
48
+ "sig.ra",
49
+ "dott",
50
+ "dott.ssa",
51
+ "ecc",
52
+ "ex",
53
+ "p.ex",
54
+ "sra",
55
+ "sta",
56
+ "sant",
57
+ "pàg",
58
+ "pág",
59
+ "tel",
60
+ "г",
61
+ "гг",
62
+ "ул",
63
+ "д",
64
+ "им",
65
+ "рис",
66
+ "стр",
67
+ "т.д",
68
+ "т.п",
69
+ )
70
+ )
71
+ _CLOSERS = r"[\"\'”’»))\]}」』】》]*"
72
+ # These marks can end sentences without whitespace (e.g. Chinese and Japanese).
73
+ _TERMINALS = "。!?؟۔।॥።፧፨։။᙮꓿"
74
+ _END = re.compile(
75
+ r"[.!?]+" + _CLOSERS + r"(?=\s|$)|[" + _TERMINALS + r"]+" + _CLOSERS + r"|\n+"
76
+ )
77
+ _CLAUSE = re.compile(r"[,;:](?=\s)|[,、;:،؛]")
78
+
79
+
80
+ def split_text(text):
81
+ """Keep abbreviations together and preserve a single leading speaker tag.
82
+
83
+ This is a Unicode punctuation heuristic, not a linguistic parser. Text in
84
+ any language is accepted; text without clear boundaries stays together.
85
+ Long sentences group at clause punctuation with a 200-character soft target.
86
+ Sentence-attached bracket cues are kept when every sentence has its own cue.
87
+ Other markup stays intact. Spoken spans retain their punctuation and internal
88
+ whitespace; a leading speaker tag is repeated on each generated chunk.
89
+ """
90
+ if not isinstance(text, str):
91
+ raise TypeError("text must be a string")
92
+ original = text
93
+ speaker = re.match(r"[ \t\r\n]*(<\|speaker:(?:[0-9]|1[0-5])\|>)[ \t\r\n]*", text)
94
+ if speaker:
95
+ text = text[speaker.end() :]
96
+ if any(mark in text for mark in ("<", ">")):
97
+ return [original]
98
+ cued = "[" in text or "]" in text
99
+ boundaries = text
100
+ if cued:
101
+ if speaker:
102
+ return [original]
103
+ boundaries = re.sub(r"\[[^\[\]<>]+\]", lambda match: " " * len(match[0]), text)
104
+ if "[" in boundaries or "]" in boundaries:
105
+ return [original]
106
+ chunks, start = [], 0
107
+ for match in _END.finditer(boundaries):
108
+ if match.group().startswith("."):
109
+ prefix = boundaries[: match.start()]
110
+ word = re.search(r"([\w.]+)$", prefix)
111
+ word = word.group(1) if word else ""
112
+ if (
113
+ word.casefold() in _ABBREVIATIONS
114
+ or len(word) == 1
115
+ and word.isalpha()
116
+ or re.fullmatch(r"(?:[^\W\d_]\.)+[^\W\d_]", word)
117
+ ):
118
+ continue
119
+ chunk = text[start : match.end()].strip()
120
+ if chunk:
121
+ chunks.append(chunk)
122
+ start = match.end()
123
+ tail = text[start:].strip()
124
+ if tail:
125
+ chunks.append(tail)
126
+ if cued:
127
+ # A cue's scope across sentences is unspecified: only split when each
128
+ # sentence carries exactly one leading cue of its own.
129
+ if not all(
130
+ re.match(r"^\[[^\[\]<>]+\]\s+[^\[\]]", chunk) and chunk.count("[") == 1
131
+ for chunk in chunks
132
+ ):
133
+ return [original]
134
+ return chunks if len(chunks) > 1 else [original]
135
+ bounded = []
136
+ for chunk in chunks:
137
+ start = previous = 0
138
+ ends = [match.end() for match in _CLAUSE.finditer(chunk)] + [len(chunk)]
139
+ for end in ends:
140
+ if end - start > 200 and previous > start:
141
+ bounded.append(chunk[start:previous].strip())
142
+ start = previous
143
+ previous = end
144
+ bounded.append(chunk[start:].strip())
145
+ chunks = bounded
146
+ if speaker:
147
+ return (
148
+ [speaker[1] + " " + chunk for chunk in chunks]
149
+ if len(chunks) > 1
150
+ else [original]
151
+ )
152
+ return chunks or [text]
153
+
154
+
155
+ _CUE = re.compile(r"\[[^\[\]<>\n]+\]")
156
+ _SPEAKER = re.compile(r"<\|speaker:(?:[0-9]|1[0-5])\|>")
157
+ _BREATH = re.compile(r"\[breathing\]", re.IGNORECASE)
158
+ _TERMINAL = re.compile(r"[.!?" + _TERMINALS + r"]" + _CLOSERS + r"$")
159
+
160
+
161
+ def _terminal_boundary(text):
162
+ if "\n" in text[len(text.rstrip()) :]:
163
+ return True
164
+ value = text.rstrip()
165
+ terminal = _TERMINAL.search(value)
166
+ if terminal is None:
167
+ return False
168
+ if value[terminal.start()] == ".":
169
+ word = re.search(r"([\w.]+)$", value[: terminal.start()])
170
+ word = word[1] if word else ""
171
+ if (
172
+ word.casefold() in _ABBREVIATIONS
173
+ or len(word) == 1
174
+ and word.isalpha()
175
+ or re.fullmatch(r"(?:[^\W\d_]\.)+[^\W\d_]", word)
176
+ ):
177
+ return False
178
+ return True
179
+
180
+
181
+ def _directed_chunks(text):
182
+ # Retain ambiguous scopes and malformed directions as a single span.
183
+ cues = list(_CUE.finditer(text))
184
+ masked = _CUE.sub("", text)
185
+ if "<" in text or ">" in text or "[" in masked or "]" in masked:
186
+ return [text]
187
+ if not cues:
188
+ return split_text(text)
189
+ prefix = text[: cues[0].start()]
190
+ if prefix.strip() and (
191
+ not any(c.isalnum() for c in prefix) or not _terminal_boundary(prefix)
192
+ ):
193
+ return [text]
194
+ if len(cues) < 2 and not prefix.strip():
195
+ return split_text(text)
196
+ for index, cue in enumerate(cues):
197
+ end = cues[index + 1].start() if index + 1 < len(cues) else len(text)
198
+ spoken = text[cue.end() : end]
199
+ if (
200
+ not any(c.isalpha() for c in cue[0][1:-1])
201
+ or not any(c.isalnum() for c in spoken)
202
+ or index + 1 < len(cues)
203
+ and not _terminal_boundary(spoken)
204
+ ):
205
+ return [text]
206
+ starts = [c.start() for c in cues] + [len(text)]
207
+ return ([prefix.strip()] if prefix.strip() else []) + [
208
+ text[a:b].strip() for a, b in pairwise(starts)
209
+ ]
210
+
211
+
212
+ def directed_segments(text):
213
+ """Plan opt-in speech/event spans, retaining ambiguous markup intact.
214
+
215
+ Only explicit breathing between complete speech clauses is an event.
216
+ Speaker turns keep their original tag; cue scopes are never inferred.
217
+ Returns a list of `(kind, text)` pairs without generating audio.
218
+ """
219
+ if not isinstance(text, str):
220
+ raise TypeError("text must be a string")
221
+ # Unknown angle markup must be validated by the prompt builder unchanged.
222
+ if "<" in _SPEAKER.sub("", text) or ">" in _SPEAKER.sub("", text):
223
+ return [("speech", text)]
224
+ turns = [
225
+ t.strip()
226
+ for t in re.split(r"(?=<\|speaker:(?:[0-9]|1[0-5])\|>)", text)
227
+ if t.strip()
228
+ ]
229
+ segments = []
230
+ for turn in turns:
231
+ leading = _SPEAKER.match(turn)
232
+ tag = leading[0] + " " if leading else ""
233
+ body = turn[leading.end() :].strip() if leading else turn
234
+ events = list(_BREATH.finditer(body))
235
+ if events:
236
+ masked = _CUE.sub("", body)
237
+ clauses = _BREATH.split(body)
238
+ if (
239
+ "[" not in masked
240
+ and "]" not in masked
241
+ and all(
242
+ any(c.isalnum() for c in _CUE.sub("", clause)) for clause in clauses
243
+ )
244
+ and all(_terminal_boundary(clause) for clause in clauses[:-1])
245
+ ):
246
+ for index, clause in enumerate(clauses):
247
+ segments.append(("speech", tag + clause.strip()))
248
+ if index < len(events):
249
+ segments.append(("event", tag + events[index][0]))
250
+ continue
251
+ segments.extend(("speech", tag + chunk) for chunk in _directed_chunks(body))
252
+ return segments or [("speech", text)]
@@ -0,0 +1 @@
1
+ """Internal model engines; public calls go through faster_tts.Model."""