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 +32 -0
- tts_pace/audio.py +96 -0
- tts_pace/cache.py +85 -0
- tts_pace/cli.py +194 -0
- tts_pace/count.py +99 -0
- tts_pace/demo.py +60 -0
- tts_pace/en_syllables.py +99 -0
- tts_pace/engines/__init__.py +9 -0
- tts_pace/engines/base.py +36 -0
- tts_pace/engines/callable.py +56 -0
- tts_pace/engines/mock.py +113 -0
- tts_pace/engines/piper.py +69 -0
- tts_pace/engines/say.py +76 -0
- tts_pace/errors.py +27 -0
- tts_pace/fit.py +406 -0
- tts_pace/measure.py +149 -0
- tts_pace/normalize.py +172 -0
- tts_pace/py.typed +0 -0
- tts_pace/runtime.py +112 -0
- tts_pace/tempo.py +130 -0
- tts_pace-0.3.0.dist-info/METADATA +401 -0
- tts_pace-0.3.0.dist-info/RECORD +25 -0
- tts_pace-0.3.0.dist-info/WHEEL +4 -0
- tts_pace-0.3.0.dist-info/entry_points.txt +2 -0
- tts_pace-0.3.0.dist-info/licenses/LICENSE +21 -0
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)
|
tts_pace/en_syllables.py
ADDED
|
@@ -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
|