tts-pace 0.3.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.
tts_pace/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """tts-pace: measure speaking rate and fit TTS output to a target pace.
2
+
3
+ Public API: count_syllables, measure, retime, fit, fit_audio, AudioClip, the engine adapters
4
+ (MockEngine, CallableEngine, SayEngine, PiperEngine), Limiter and the error classes.
5
+ Everything else (modules with a leading underscore, helpers inside modules) is internal.
6
+ """
7
+
8
+ from .audio import AudioClip
9
+ from .count import SyllableCount, UnsupportedCharsWarning, count_syllables
10
+ from .engines import CallableEngine, EngineInfo, MockEngine, PiperEngine, SayEngine, SpeedEngine
11
+ from .errors import (
12
+ AudioFormatError,
13
+ BusyError,
14
+ EngineError,
15
+ MeasurementError,
16
+ TtsPaceError,
17
+ UnsupportedTextError,
18
+ )
19
+ from .fit import Candidate, FitResult, fit, fit_audio
20
+ from .measure import Measurement, measure
21
+ from .runtime import Limiter
22
+ from .tempo import retime
23
+
24
+ __version__ = "0.3.0"
25
+
26
+ __all__ = [
27
+ "AudioClip", "AudioFormatError", "BusyError", "CallableEngine", "Candidate", "EngineError",
28
+ "EngineInfo", "FitResult", "Limiter", "Measurement", "MeasurementError", "MockEngine",
29
+ "PiperEngine", "SayEngine", "SpeedEngine", "SyllableCount", "TtsPaceError",
30
+ "UnsupportedCharsWarning", "UnsupportedTextError", "__version__", "count_syllables", "fit",
31
+ "fit_audio", "measure", "retime",
32
+ ]
tts_pace/audio.py ADDED
@@ -0,0 +1,96 @@
1
+ """Mono float audio and WAV reading/writing (standard library `wave` + numpy)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import wave
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+
11
+ from .errors import AudioFormatError, EngineError
12
+ from .runtime import find_ffmpeg, run_command, run_dir
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class AudioClip:
17
+ """Mono audio: ``samples`` is float32 in [-1, 1]."""
18
+
19
+ samples: np.ndarray
20
+ sample_rate: int
21
+
22
+ def __post_init__(self) -> None:
23
+ if self.sample_rate <= 0:
24
+ raise ValueError("sample_rate must be positive")
25
+ if self.samples.ndim != 1:
26
+ raise ValueError("samples must be a 1-D (mono) array")
27
+ if self.samples.dtype != np.float32:
28
+ object.__setattr__(self, "samples", self.samples.astype(np.float32))
29
+
30
+ @property
31
+ def duration(self) -> float:
32
+ return len(self.samples) / self.sample_rate
33
+
34
+ @classmethod
35
+ def from_wav(cls, path: str | Path) -> AudioClip:
36
+ """Read a WAV (8/16/24/32-bit PCM). Other formats are decoded with ffmpeg when available."""
37
+ p = Path(path)
38
+ if not p.exists():
39
+ raise AudioFormatError(f"Audio file not found: {p}")
40
+ try:
41
+ return _read_pcm_wav(p)
42
+ except (wave.Error, EOFError):
43
+ return _decode_with_ffmpeg(p)
44
+
45
+ def to_wav(self, path: str | Path) -> Path:
46
+ """Write 16-bit PCM WAV and return the path."""
47
+ p = Path(path)
48
+ p.parent.mkdir(parents=True, exist_ok=True)
49
+ pcm = np.clip(self.samples, -1.0, 1.0)
50
+ data = (pcm * 32767.0).round().astype("<i2").tobytes()
51
+ with wave.open(str(p), "wb") as w:
52
+ w.setnchannels(1)
53
+ w.setsampwidth(2)
54
+ w.setframerate(self.sample_rate)
55
+ w.writeframes(data)
56
+ return p
57
+
58
+
59
+ def _read_pcm_wav(p: Path) -> AudioClip:
60
+ with wave.open(str(p), "rb") as w:
61
+ ch, width, rate, n = w.getnchannels(), w.getsampwidth(), w.getframerate(), w.getnframes()
62
+ raw = w.readframes(n)
63
+ if n == 0:
64
+ raise AudioFormatError(f"{p} has no audio frames.")
65
+ if width == 1:
66
+ x = (np.frombuffer(raw, dtype=np.uint8).astype(np.float32) - 128.0) / 128.0
67
+ elif width == 2:
68
+ x = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0
69
+ elif width == 3:
70
+ b = np.frombuffer(raw, dtype=np.uint8).reshape(-1, 3).astype(np.int32)
71
+ v = b[:, 0] | (b[:, 1] << 8) | (b[:, 2] << 16)
72
+ v = np.where(v >= 1 << 23, v - (1 << 24), v)
73
+ x = v.astype(np.float32) / float(1 << 23)
74
+ elif width == 4:
75
+ x = np.frombuffer(raw, dtype="<i4").astype(np.float32) / float(1 << 31)
76
+ else:
77
+ raise AudioFormatError(f"Unsupported sample width {width} bytes in {p}.")
78
+ if ch > 1:
79
+ x = x.reshape(-1, ch).mean(axis=1) # mix down to mono
80
+ return AudioClip(x.astype(np.float32), rate)
81
+
82
+
83
+ def _decode_with_ffmpeg(p: Path) -> AudioClip:
84
+ ffmpeg = find_ffmpeg()
85
+ if ffmpeg is None:
86
+ raise AudioFormatError(
87
+ f"{p} is not a PCM WAV file and ffmpeg was not found to decode it. "
88
+ "Convert it to 16-bit WAV, or install ffmpeg (or set TTS_PACE_FFMPEG)."
89
+ )
90
+ with run_dir() as tmp:
91
+ out = tmp / "decoded.wav"
92
+ try:
93
+ run_command([ffmpeg, "-v", "error", "-y", "-i", str(p), "-ac", "1", "-c:a", "pcm_s16le", str(out)])
94
+ except EngineError as exc:
95
+ raise AudioFormatError(f"{p} is not an audio file ffmpeg can read; convert it to 16-bit WAV. (ffmpeg said: {exc})") from exc
96
+ return _read_pcm_wav(out)
tts_pace/cache.py ADDED
@@ -0,0 +1,85 @@
1
+ """Deterministic result cache: key = hash of everything that changes the output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ from pathlib import Path
10
+
11
+ from .audio import AudioClip
12
+
13
+ CACHE_VERSION = "1"
14
+
15
+
16
+ def default_cache_dir() -> Path:
17
+ """``$TTS_PACE_CACHE_DIR``, else ``$XDG_CACHE_HOME/tts-pace``, else ``~/.cache/tts-pace``.
18
+
19
+ Never inside the current directory, so the cache cannot leak into a git work tree.
20
+ """
21
+ explicit = os.environ.get("TTS_PACE_CACHE_DIR")
22
+ if explicit:
23
+ return Path(explicit).expanduser()
24
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
25
+ return Path(base) / "tts-pace"
26
+
27
+
28
+ def make_key(parts: dict[str, object]) -> str:
29
+ """sha256 over canonical JSON of ``parts`` (plus the cache format version)."""
30
+ blob = json.dumps({"v": CACHE_VERSION, **parts}, sort_keys=True, ensure_ascii=False, default=str)
31
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
32
+
33
+
34
+ def audio_hash(clip: AudioClip) -> str:
35
+ """Hash of the audio content (used as the key for engines that are not deterministic)."""
36
+ h = hashlib.sha256()
37
+ h.update(str(clip.sample_rate).encode())
38
+ h.update(clip.samples.tobytes())
39
+ return h.hexdigest()
40
+
41
+
42
+ class ResultCache:
43
+ """Stores the best WAV and the report JSON per key. Oldest entries are removed beyond ``max_entries``."""
44
+
45
+ def __init__(self, directory: str | Path, max_entries: int = 256) -> None:
46
+ if max_entries < 1:
47
+ raise ValueError("max_entries must be >= 1")
48
+ self.directory = Path(directory)
49
+ self.max_entries = max_entries
50
+
51
+ def _paths(self, key: str) -> tuple[Path, Path]:
52
+ return self.directory / f"{key}.json", self.directory / f"{key}.wav"
53
+
54
+ def get(self, key: str) -> tuple[dict[str, object], Path] | None:
55
+ report_path, wav_path = self._paths(key)
56
+ if not (report_path.exists() and wav_path.exists()):
57
+ return None
58
+ try:
59
+ return json.loads(report_path.read_text(encoding="utf-8")), wav_path
60
+ except (OSError, ValueError):
61
+ return None # a damaged entry counts as a miss and is rewritten
62
+
63
+ def put(self, key: str, report: dict[str, object], clip: AudioClip) -> Path:
64
+ self.directory.mkdir(parents=True, exist_ok=True)
65
+ report_path, wav_path = self._paths(key)
66
+ tmp_wav = wav_path.with_suffix(".wav.tmp")
67
+ clip.to_wav(tmp_wav)
68
+ os.replace(tmp_wav, wav_path)
69
+ tmp_json = report_path.with_suffix(".json.tmp")
70
+ tmp_json.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
71
+ os.replace(tmp_json, report_path) # the json appears last: it marks a complete entry
72
+ self._evict()
73
+ return wav_path
74
+
75
+ def _evict(self) -> None:
76
+ entries = sorted(self.directory.glob("*.json"), key=lambda p: p.stat().st_mtime)
77
+ for old in entries[: max(0, len(entries) - self.max_entries)]:
78
+ old.unlink(missing_ok=True)
79
+ old.with_suffix(".wav").unlink(missing_ok=True)
80
+
81
+ def copy_wav(self, key: str, dest: str | Path) -> Path:
82
+ d = Path(dest)
83
+ d.parent.mkdir(parents=True, exist_ok=True)
84
+ shutil.copyfile(self._paths(key)[1], d)
85
+ return d
tts_pace/cli.py ADDED
@@ -0,0 +1,194 @@
1
+ """Command line interface: demo, count, measure, retime, fit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .cache import default_cache_dir
12
+ from .count import count_syllables
13
+ from .demo import MOCK_BANNER, format_table, run_demo
14
+ from .engines import MockEngine, PiperEngine, SayEngine
15
+ from .errors import TtsPaceError
16
+ from .fit import FitResult, fit, fit_audio
17
+ from .measure import measure
18
+
19
+
20
+ def _text_arg(args: argparse.Namespace) -> str:
21
+ if getattr(args, "text", None) is not None:
22
+ return args.text
23
+ if getattr(args, "text_file", None):
24
+ try:
25
+ return Path(args.text_file).read_text(encoding="utf-8")
26
+ except OSError as exc:
27
+ raise ValueError(f"Cannot read --text-file {args.text_file}: {exc}") from exc
28
+ raise ValueError("Give the script with --text TEXT or --text-file FILE.")
29
+
30
+
31
+ def _add_text(p: argparse.ArgumentParser, required: bool = True) -> None:
32
+ g = p.add_mutually_exclusive_group(required=required)
33
+ g.add_argument("--text", help="the spoken text")
34
+ g.add_argument("--text-file", help="file containing the spoken text (UTF-8)")
35
+
36
+
37
+ def _add_target(p: argparse.ArgumentParser) -> None:
38
+ g = p.add_mutually_exclusive_group(required=True)
39
+ g.add_argument("--target", type=float, help="target syllables per second")
40
+ g.add_argument("--target-duration", type=float, metavar="SECONDS",
41
+ help="target total length in seconds, pauses included")
42
+
43
+
44
+ def _print_result(r: FitResult, out: str | None) -> None:
45
+ print(f"base {r.base.measurement.sps:.2f} sps")
46
+ for c in r.candidates[1:]:
47
+ print(f"{c.kind:<13} speed x{c.speed:.3f} tempo x{c.tempo:.3f} -> {c.measurement.sps:.2f} sps err {c.err_pct:.2f}%")
48
+ print(f"best {r.route}: {r.best.measurement.sps:.2f} sps (target {r.target_sps:g}, err {r.best.err_pct:.2f}%)")
49
+ if r.target_duration is not None: # length mode only
50
+ print(f"length {r.best.measurement.total_s:.2f}s (target {r.target_duration:.2f}s, err {r.length_err_pct:.2f}%)")
51
+ print(f"cache {'hit' if r.cache_hit else 'miss'}")
52
+ if out:
53
+ print(f"wrote {out}")
54
+ for w, a in zip(r.warnings, r.advice):
55
+ print(f"warning: {w} - {a}", file=sys.stderr)
56
+
57
+
58
+ def cmd_demo(args: argparse.Namespace) -> int:
59
+ print(MOCK_BANNER)
60
+ results, report_path = run_demo(args.out)
61
+ print(format_table(results))
62
+ print(f"\nwrote {args.out}/ (WAV files + report.json: {report_path})")
63
+ return 0
64
+
65
+
66
+ def cmd_count(args: argparse.Namespace) -> int:
67
+ c = count_syllables(args.text, args.lang)
68
+ if args.json:
69
+ print(json.dumps(c.to_dict(), ensure_ascii=False))
70
+ else:
71
+ print(f"spoken form : {c.normalized_text}")
72
+ print(f"syllables : {c.total} {c.by_script}")
73
+ if c.unsupported:
74
+ print(f"unsupported : {' '.join(c.unsupported)}")
75
+ return 0
76
+
77
+
78
+ def cmd_measure(args: argparse.Namespace) -> int:
79
+ text = _text_arg(args) if (args.text is not None or args.text_file) else None
80
+ m = measure(args.wav, text, silence_db=args.silence_db, min_silence=args.min_silence, backend=args.backend)
81
+ if args.json:
82
+ print(json.dumps(m.to_dict()))
83
+ else:
84
+ print(f"total {m.total_s:.2f}s silence {m.silence_s:.2f}s ({m.gaps} gaps) speech {m.net_s:.2f}s")
85
+ if m.sps is not None:
86
+ print(f"{m.syllables} syllables -> {m.sps:.2f} syllables/second of pure speech")
87
+ return 0
88
+
89
+
90
+ def cmd_retime(args: argparse.Namespace) -> int:
91
+ r = fit_audio(args.wav, _text_arg(args), args.target, out=args.out, target_duration=args.target_duration)
92
+ if args.json:
93
+ print(json.dumps(r.report(), ensure_ascii=False))
94
+ else:
95
+ _print_result(r, args.out)
96
+ return 0
97
+ for w, a in zip(r.warnings, r.advice):
98
+ print(f"warning: {w} - {a}", file=sys.stderr)
99
+ return 0 # reaching the tempo limit is a warning, not a failure
100
+
101
+
102
+ def cmd_fit(args: argparse.Namespace) -> int:
103
+ text = _text_arg(args)
104
+ if args.engine == "mock":
105
+ preset = args.lang or ("ko" if any("가" <= ch <= "힣" for ch in text) else "en")
106
+ engine = MockEngine(preset)
107
+ # With --json stdout must hold the JSON only, so the practice-mode notice goes to stderr.
108
+ print(MOCK_BANNER, file=sys.stderr if args.json else sys.stdout)
109
+ elif args.engine == "say":
110
+ engine = SayEngine(args.voice)
111
+ else:
112
+ engine = PiperEngine(args.voice)
113
+ cache_dir = args.cache_dir if args.cache_dir else (False if args.no_cache else default_cache_dir())
114
+ r = fit(text, args.target, engine, out=args.out, cache_dir=cache_dir, target_duration=args.target_duration)
115
+ if args.json:
116
+ print(json.dumps(r.report(), ensure_ascii=False))
117
+ for w, a in zip(r.warnings, r.advice):
118
+ print(f"warning: {w} - {a}", file=sys.stderr)
119
+ if args.report:
120
+ r.save_report(args.report)
121
+ return 0
122
+ _print_result(r, args.out)
123
+ if args.report:
124
+ r.save_report(args.report)
125
+ print(f"report {args.report}")
126
+ return 0
127
+
128
+
129
+ def build_parser() -> argparse.ArgumentParser:
130
+ p = argparse.ArgumentParser(prog="tts-pace", description="Measure and fit TTS speaking rate (syllables/second).")
131
+ p.add_argument("--version", action="version", version=f"tts-pace {__version__}")
132
+ sub = p.add_subparsers(dest="command", required=True)
133
+
134
+ d = sub.add_parser("demo", help="run four fits with the practice (mock) engine")
135
+ d.add_argument("--out", default="tts-pace-demo", help="output folder (default: ./tts-pace-demo)")
136
+ d.set_defaults(func=cmd_demo)
137
+
138
+ c = sub.add_parser("count", help="show the spoken form and syllable count of a text")
139
+ c.add_argument("text")
140
+ c.add_argument("--lang", choices=["auto", "ko", "en", "ja"], default="auto")
141
+ c.add_argument("--json", action="store_true")
142
+ c.set_defaults(func=cmd_count)
143
+
144
+ m = sub.add_parser("measure", help="measure a WAV file")
145
+ m.add_argument("wav")
146
+ _add_text(m, required=False)
147
+ m.add_argument("--silence-db", type=float, default=-60.0)
148
+ m.add_argument("--min-silence", type=float, default=0.6)
149
+ m.add_argument("--backend", choices=["auto", "numpy", "ffmpeg"], default="auto")
150
+ m.add_argument("--json", action="store_true")
151
+ m.set_defaults(func=cmd_measure)
152
+
153
+ r = sub.add_parser("retime", help="change an existing recording's tempo toward a target rate (0.85-1.15 limit)")
154
+ r.add_argument("wav")
155
+ _add_text(r)
156
+ _add_target(r)
157
+ r.add_argument("-o", "--out", required=True)
158
+ r.add_argument("--json", action="store_true")
159
+ r.set_defaults(func=cmd_retime)
160
+
161
+ f = sub.add_parser("fit", help="synthesize with an engine and fit to a target rate")
162
+ f.add_argument("--engine", choices=["mock", "say", "piper"], default="mock")
163
+ _add_text(f)
164
+ _add_target(f)
165
+ f.add_argument("-o", "--out", required=True)
166
+ f.add_argument("--voice", help="say voice name, or Piper .onnx path")
167
+ f.add_argument("--lang", choices=["ko", "en"], help="mock engine preset (default: guessed from the text)")
168
+ f.add_argument("--cache-dir", help=f"result cache folder (default: {default_cache_dir()})")
169
+ f.add_argument("--no-cache", action="store_true")
170
+ f.add_argument("--report", help="write the report JSON here")
171
+ f.add_argument("--json", action="store_true", help="print the report JSON to stdout (notices go to stderr)")
172
+ f.set_defaults(func=cmd_fit)
173
+ return p
174
+
175
+
176
+ def main(argv: list[str] | None = None) -> int:
177
+ args = build_parser().parse_args(argv)
178
+ try:
179
+ return int(args.func(args))
180
+ except (TtsPaceError, ValueError) as exc:
181
+ print(f"error: {exc}", file=sys.stderr)
182
+ return 2
183
+ except OSError as exc:
184
+ # File-system problems (unwritable output path, missing folder, ...) get a short hint, not a traceback.
185
+ target = exc.filename if exc.filename is not None else "the output path"
186
+ print(
187
+ f"error: cannot write {target}: {exc.strerror or exc}. Choose a writable folder with -o.",
188
+ file=sys.stderr,
189
+ )
190
+ return 2
191
+
192
+
193
+ if __name__ == "__main__":
194
+ raise SystemExit(main())
tts_pace/count.py ADDED
@@ -0,0 +1,99 @@
1
+ """Syllable counting from text: Korean blocks, English heuristic, Japanese morae."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import warnings
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+
10
+ from .en_syllables import count_en_word
11
+ from .errors import UnsupportedTextError
12
+ from .normalize import normalize_text
13
+
14
+ _SMALL_KANA = set("ゃゅょぁぃぅぇぉゎャュョァィゥェォヮ") # glue onto the previous kana (1 mora together)
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class SyllableCount:
19
+ """Result of count_syllables().
20
+
21
+ total: syllables (Korean/English) plus morae (Japanese) that were counted.
22
+ by_script: counts per script, keys 'ko', 'en', 'ja'.
23
+ normalized_text: the spoken form that was counted (digits and acronyms expanded).
24
+ unsupported: characters or tokens that could not be counted (e.g. kanji).
25
+ """
26
+
27
+ total: int
28
+ by_script: dict[str, int] = field(default_factory=lambda: {"ko": 0, "en": 0, "ja": 0})
29
+ normalized_text: str = ""
30
+ unsupported: tuple[str, ...] = ()
31
+
32
+ def to_dict(self) -> dict[str, object]:
33
+ return {
34
+ "total": self.total,
35
+ "by_script": dict(self.by_script),
36
+ "normalized_text": self.normalized_text,
37
+ "unsupported": list(self.unsupported),
38
+ }
39
+
40
+
41
+ class UnsupportedCharsWarning(UserWarning):
42
+ """Some characters (kanji, digits in Japanese text) were not counted."""
43
+
44
+
45
+ def count_normalized(spoken: str) -> tuple[dict[str, int], list[str]]:
46
+ """Count an already normalized text. Returns (by_script, unsupported characters)."""
47
+ by = {"ko": 0, "en": 0, "ja": 0}
48
+ unsupported: list[str] = []
49
+ for m in re.finditer(r"[가-힣]|[A-Za-z][A-Za-z'’]*|[぀-ヿ]|[一-鿿㐀-䶿]", spoken):
50
+ tok = m.group(0)
51
+ c = tok[0]
52
+ if "가" <= c <= "힣":
53
+ by["ko"] += 1
54
+ elif c.isascii() and c.isalpha():
55
+ by["en"] += count_en_word(tok)
56
+ elif "぀" <= c <= "ヿ":
57
+ if c in _SMALL_KANA or c in "・゛゜゠":
58
+ continue # part of the previous mora (きょ = 1) or punctuation
59
+ by["ja"] += 1 # a kana, っ, ん and the long-vowel mark ー each count as one mora
60
+ else:
61
+ unsupported.append(tok)
62
+ return by, unsupported
63
+
64
+
65
+ def count_syllables(
66
+ text: str,
67
+ lang: str = "auto",
68
+ normalize: Mapping[str, str] | None = None,
69
+ ) -> SyllableCount:
70
+ """Count the syllables of ``text`` the way a TTS engine would read it.
71
+
72
+ ``lang`` ('auto', 'ko', 'en', 'ja') only decides how digits and ACRONYMS are read;
73
+ letters are always counted by their own script. ``normalize`` maps text to a spoken
74
+ form before anything else, e.g. ``{"2026": "twenty twenty six"}``.
75
+
76
+ Raises UnsupportedTextError when nothing countable remains.
77
+ """
78
+ if not isinstance(text, str):
79
+ raise TypeError(f"text must be str, got {type(text).__name__}")
80
+ spoken, bad_tokens = normalize_text(text, lang, normalize)
81
+ by, bad_chars = count_normalized(spoken)
82
+ unsupported = tuple(bad_tokens + bad_chars)
83
+ total = sum(by.values())
84
+ if total == 0:
85
+ raise UnsupportedTextError(
86
+ "No countable syllables found in the text"
87
+ + (f" (unsupported: {', '.join(unsupported)})" if unsupported else "")
88
+ + ". Provide Hangul, kana or Latin letters, or map the text to a spoken form "
89
+ "with normalize={...}."
90
+ )
91
+ if unsupported:
92
+ warnings.warn(
93
+ f"{len(unsupported)} unsupported token(s) were not counted: {', '.join(unsupported[:5])}"
94
+ f"{'...' if len(unsupported) > 5 else ''}. Counts will be low; use normalize={{...}} "
95
+ "to give a kana/Hangul reading.",
96
+ UnsupportedCharsWarning,
97
+ stacklevel=2,
98
+ )
99
+ return SyllableCount(total, by, spoken, unsupported)
tts_pace/demo.py ADDED
@@ -0,0 +1,60 @@
1
+ """`tts-pace demo`: four fits with the practice (mock) engine, no keys or real TTS needed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from .engines.mock import MockEngine
9
+ from .fit import FitResult, fit
10
+
11
+ MOCK_BANNER = "[mock] 연습용 목(mock) 엔진 — 실제 TTS 아님 (practice engine, not a real TTS)"
12
+
13
+ EN_TEXT = ("Today we learn how to match the speaking rate of a synthetic voice. "
14
+ "It is a short lesson about pace and silence.")
15
+ KO_TEXT = ("오늘은 말하기 속도를 맞추는 방법을 알아봅니다. "
16
+ "목표 속도에 가깝게 음성을 다시 만들고 마지막에 조금만 다듬습니다.")
17
+
18
+ # (name, text, mock preset, target syllables/second, what it shows)
19
+ CASES = [
20
+ ("en far", EN_TEXT, "en", 4.0, "far target: re-synthesize, then tempo"),
21
+ ("en near", EN_TEXT, "en", 4.9, "near target: tempo only"),
22
+ ("ko near", KO_TEXT, "ko", 5.7, "near target: tempo only"),
23
+ ("ko floor", KO_TEXT, "ko", 4.0, "knob stops at its floor: limit shown"),
24
+ ]
25
+
26
+
27
+ def run_demo(out_dir: str | Path = "tts-pace-demo") -> tuple[list[tuple[str, FitResult]], Path]:
28
+ """Run the four cases, write WAVs and report.json, return the results."""
29
+ out = Path(out_dir)
30
+ out.mkdir(parents=True, exist_ok=True)
31
+ (out / "en.txt").write_text(EN_TEXT, encoding="utf-8")
32
+ (out / "ko.txt").write_text(KO_TEXT, encoding="utf-8")
33
+ results: list[tuple[str, FitResult]] = []
34
+ for name, text, preset, target, _ in CASES:
35
+ engine = MockEngine(preset)
36
+ slug = name.replace(" ", "_")
37
+ # numpy backends: same result whether or not ffmpeg is installed
38
+ result = fit(text, target, engine, out=out / f"{slug}.wav", tempo_backend="numpy",
39
+ measure_backend="numpy", cache_dir=False, keep_candidates=True)
40
+ results.append((name, result))
41
+
42
+ report = {
43
+ "mock": True,
44
+ "mock_note": "practice (mock) engine, not a real TTS",
45
+ "cases": [{"name": n, **r.report()} for n, r in results],
46
+ }
47
+ report_path = out / "report.json"
48
+ report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
49
+ return results, report_path
50
+
51
+
52
+ def format_table(results: list[tuple[str, FitResult]]) -> str:
53
+ head = f"{'case':<9} {'base sps':>8} {'route':<14} {'final sps':>9} {'target':>6} {'err':>7} warnings"
54
+ lines = [head, "-" * len(head)]
55
+ for name, r in results:
56
+ lines.append(
57
+ f"{name:<9} {r.base.measurement.sps:>8.2f} {r.route:<14} {r.best.measurement.sps:>9.2f} "
58
+ f"{r.target_sps:>6.1f} {r.best.err_pct:>6.2f}% {', '.join(r.warnings) or '-'}"
59
+ )
60
+ return "\n".join(lines)
@@ -0,0 +1,99 @@
1
+ """English syllable estimate: vowel-group heuristic plus a small exception dictionary.
2
+
3
+ This is an approximation (see README, limits). The exception dictionary below is
4
+ hand-written (< 300 words) and was NOT extracted from the evaluation fixture.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ # Words the rules get wrong that are common in narration, plus spelled-out letter names
12
+ # (used for acronyms like "TTS" -> "tee tee ess").
13
+ EXCEPTIONS: dict[str, int] = {
14
+ # letter names used when acronyms are spelled out
15
+ "ay": 1, "bee": 1, "see": 1, "dee": 1, "ee": 1, "eff": 1, "jee": 1, "aitch": 1,
16
+ "eye": 1, "jay": 1, "kay": 1, "ell": 1, "em": 1, "en": 1, "oh": 1, "pee": 1,
17
+ "cue": 1, "ar": 1, "ess": 1, "tee": 1, "you": 1, "vee": 1, "ex": 1, "why": 1, "zee": 1,
18
+ # numbers and very common irregular words
19
+ "one": 1, "two": 1, "three": 1, "four": 1, "five": 1, "six": 1, "seven": 2, "eight": 1,
20
+ "nine": 1, "ten": 1, "eleven": 3, "twelve": 1, "thirteen": 2, "fourteen": 2, "fifteen": 2,
21
+ "sixteen": 2, "seventeen": 3, "eighteen": 2, "nineteen": 2, "twenty": 2, "thirty": 2,
22
+ "forty": 2, "fifty": 2, "sixty": 2, "seventy": 3, "eighty": 2, "ninety": 2, "hundred": 2,
23
+ "thousand": 2, "million": 2, "billion": 2, "zero": 2, "point": 1, "percent": 2,
24
+ "the": 1, "a": 1, "i": 1, "area": 3, "idea": 3, "real": 1, "really": 2, "being": 2,
25
+ "doing": 2, "going": 2, "seeing": 2, "people": 2, "business": 2, "every": 2, "everyone": 3,
26
+ "everything": 3, "different": 3, "interesting": 3, "favorite": 3, "comfortable": 3,
27
+ "vegetable": 3, "camera": 3, "family": 3, "chocolate": 3, "evening": 2, "usually": 3,
28
+ "actually": 4, "especially": 4, "beautiful": 3, "science": 2, "scientist": 3, "quiet": 2,
29
+ "poem": 2, "poet": 2, "create": 2, "creating": 3, "creative": 3, "video": 3, "audio": 3,
30
+ "radio": 3, "studio": 3, "ratio": 3, "piano": 3, "violin": 3, "diet": 2, "client": 2,
31
+ "giant": 2, "lion": 2, "iron": 2, "fire": 1, "hour": 1, "our": 1, "flower": 2, "power": 2,
32
+ "tower": 2, "shower": 2, "wear": 1, "where": 1, "there": 1, "were": 1, "here": 1,
33
+ "queue": 1, "league": 1, "tongue": 1, "ocean": 2, "island": 2, "stomach": 2, "orange": 2,
34
+ "average": 3, "aware": 2, "someone": 2, "anyone": 3, "anything": 3, "something": 2,
35
+ "nothing": 2, "everybody": 4, "somebody": 3, "anybody": 4, "nobody": 3, "maybe": 2,
36
+ "already": 3, "also": 2, "always": 2, "again": 2, "against": 2, "because": 2, "before": 2,
37
+ "between": 2, "become": 2, "behind": 2, "below": 2, "beyond": 2, "world": 1, "word": 1,
38
+ "work": 1, "year": 1, "years": 1, "your": 1, "yours": 1, "speech": 1, "speaking": 2,
39
+ "rate": 1, "speed": 1, "pace": 1, "tempo": 2, "pitch": 1, "voice": 1, "voices": 2,
40
+ "engine": 2, "engines": 3, "synthesize": 3, "synthesis": 3, "syllable": 3, "syllables": 3,
41
+ "measure": 2, "measures": 2, "measured": 2, "text": 1, "texts": 1, "narration": 3,
42
+ "tutorial": 4, "lesson": 2, "learn": 1, "learning": 2, "listen": 2, "listening": 3,
43
+ "language": 3, "english": 2, "korean": 3, "japanese": 3, "welcome": 2, "today": 2,
44
+ "tomorrow": 3, "yesterday": 3, "morning": 2, "night": 1, "hello": 2, "thanks": 1,
45
+ "okay": 2, "ok": 2, "yes": 1, "no": 1, "quick": 1, "quickly": 2, "slow": 1, "slowly": 2,
46
+ "fast": 1, "faster": 2, "slower": 2, "target": 2, "closer": 2, "close": 1, "closed": 1,
47
+ "loop": 1, "result": 2, "results": 2, "silence": 2, "silent": 2, "pause": 1, "pauses": 2,
48
+ "sentence": 2, "sentences": 3, "word": 1, "words": 1, "minute": 2, "minutes": 2,
49
+ "second": 2, "seconds": 2, "average": 3, "per": 1, "per-second": 3, "over": 2, "under": 2,
50
+ "using": 2, "used": 1, "uses": 2, "use": 1, "make": 1, "makes": 1, "made": 1, "making": 2,
51
+ "take": 1, "takes": 1, "taken": 2, "give": 1, "gives": 1, "given": 2, "have": 1, "has": 1,
52
+ "are": 1, "is": 1, "was": 1, "be": 1, "been": 1, "to": 1, "of": 1, "and": 1, "or": 1,
53
+ "in": 1, "on": 1, "at": 1, "it": 1, "its": 1, "this": 1, "that": 1, "these": 2, "those": 1,
54
+ }
55
+
56
+ _VOWELS = "aeiouy"
57
+ # ti/ci/si before a vowel sound make a "sh" consonant (nation, social, passion).
58
+ _SH = re.compile(r"(?:[tcs]i)(?=o|a[ln]|ou|en)")
59
+ # Vowel pairs that are normally two syllables (create, radio, duo).
60
+ _HIATUS = ("ia", "io", "iu", "eo", "ua", "uo", "ii", "ao")
61
+
62
+
63
+ def count_en_word(word: str) -> int:
64
+ """Estimate the syllable count of one English word (letters and apostrophes only)."""
65
+ w = re.sub(r"[^a-z]", "", word.lower())
66
+ if not w:
67
+ return 0
68
+ if w in EXCEPTIONS:
69
+ return EXCEPTIONS[w]
70
+ return max(_heuristic(w), 1)
71
+
72
+
73
+ def _heuristic(w: str) -> int:
74
+ s = w
75
+ s = s.replace("qu", "q") # u after q is a consonant glide
76
+ s = re.sub(r"gu(?=[aeiy])", "g", s)
77
+ if s.startswith("y") and len(s) > 1 and s[1] in _VOWELS:
78
+ s = "j" + s[1:] # initial y is a consonant (yes, young)
79
+ s = _SH.sub("sh", s)
80
+ # Silent endings: -e (but not consonant+le), -es, -ed.
81
+ if re.search(r"[^aeiouy]le$", s):
82
+ pass # table, little: "le" is a syllable
83
+ elif re.search(r"(?:sh|ch|[sxzcg])es$", s):
84
+ pass # boxes, watches: "es" is pronounced
85
+ elif re.search(r"[^aeiouy]es$", s) and len(s) > 3:
86
+ s = s[:-2]
87
+ elif re.search(r"[td]ed$", s):
88
+ pass # wanted, needed
89
+ elif re.search(r"[^aeiouy]ed$", s) and len(s) > 3:
90
+ s = s[:-2]
91
+ elif re.search(r"[^aeiouy]e$", s) and len(s) > 2:
92
+ s = s[:-1]
93
+ groups = re.findall(r"[aeiouy]+", s)
94
+ count = len(groups)
95
+ for g in groups:
96
+ for pair in _HIATUS:
97
+ if pair in g:
98
+ count += 1
99
+ return count