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 +9 -0
- faster_tts/__main__.py +121 -0
- faster_tts/base.py +82 -0
- faster_tts/cache.py +123 -0
- faster_tts/chunking.py +252 -0
- faster_tts/engines/__init__.py +1 -0
- faster_tts/engines/drama3.py +435 -0
- faster_tts/memory.py +147 -0
- faster_tts/model.py +285 -0
- faster_tts/prompt.py +256 -0
- faster_tts-0.0.1.dist-info/METADATA +199 -0
- faster_tts-0.0.1.dist-info/RECORD +16 -0
- faster_tts-0.0.1.dist-info/WHEEL +5 -0
- faster_tts-0.0.1.dist-info/entry_points.txt +2 -0
- faster_tts-0.0.1.dist-info/licenses/LICENSE +21 -0
- faster_tts-0.0.1.dist-info/top_level.txt +1 -0
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."""
|