tts-pace 0.3.0__tar.gz

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.
Files changed (55) hide show
  1. tts_pace-0.3.0/.env.example +7 -0
  2. tts_pace-0.3.0/.github/ISSUE_TEMPLATE/bug.yml +22 -0
  3. tts_pace-0.3.0/.github/ISSUE_TEMPLATE/question.yml +11 -0
  4. tts_pace-0.3.0/.github/workflows/ci.yml +20 -0
  5. tts_pace-0.3.0/.gitignore +43 -0
  6. tts_pace-0.3.0/.python-version +1 -0
  7. tts_pace-0.3.0/CHANGELOG.md +54 -0
  8. tts_pace-0.3.0/LICENSE +21 -0
  9. tts_pace-0.3.0/PKG-INFO +401 -0
  10. tts_pace-0.3.0/README.ko.md +373 -0
  11. tts_pace-0.3.0/README.md +381 -0
  12. tts_pace-0.3.0/SECURITY.md +7 -0
  13. tts_pace-0.3.0/docs/images/01-demo.png +0 -0
  14. tts_pace-0.3.0/docs/images/02-count.png +0 -0
  15. tts_pace-0.3.0/docs/images/03-measure.png +0 -0
  16. tts_pace-0.3.0/docs/images/04-fit.png +0 -0
  17. tts_pace-0.3.0/docs/images/05-quickstart.png +0 -0
  18. tts_pace-0.3.0/examples/quickstart.py +16 -0
  19. tts_pace-0.3.0/pyproject.toml +40 -0
  20. tts_pace-0.3.0/scripts/make_cmudict_sample.py +47 -0
  21. tts_pace-0.3.0/src/tts_pace/__init__.py +32 -0
  22. tts_pace-0.3.0/src/tts_pace/audio.py +96 -0
  23. tts_pace-0.3.0/src/tts_pace/cache.py +85 -0
  24. tts_pace-0.3.0/src/tts_pace/cli.py +194 -0
  25. tts_pace-0.3.0/src/tts_pace/count.py +99 -0
  26. tts_pace-0.3.0/src/tts_pace/demo.py +60 -0
  27. tts_pace-0.3.0/src/tts_pace/en_syllables.py +99 -0
  28. tts_pace-0.3.0/src/tts_pace/engines/__init__.py +9 -0
  29. tts_pace-0.3.0/src/tts_pace/engines/base.py +36 -0
  30. tts_pace-0.3.0/src/tts_pace/engines/callable.py +56 -0
  31. tts_pace-0.3.0/src/tts_pace/engines/mock.py +113 -0
  32. tts_pace-0.3.0/src/tts_pace/engines/piper.py +69 -0
  33. tts_pace-0.3.0/src/tts_pace/engines/say.py +76 -0
  34. tts_pace-0.3.0/src/tts_pace/errors.py +27 -0
  35. tts_pace-0.3.0/src/tts_pace/fit.py +406 -0
  36. tts_pace-0.3.0/src/tts_pace/measure.py +149 -0
  37. tts_pace-0.3.0/src/tts_pace/normalize.py +172 -0
  38. tts_pace-0.3.0/src/tts_pace/py.typed +0 -0
  39. tts_pace-0.3.0/src/tts_pace/runtime.py +112 -0
  40. tts_pace-0.3.0/src/tts_pace/tempo.py +130 -0
  41. tts_pace-0.3.0/tests/conftest.py +30 -0
  42. tts_pace-0.3.0/tests/fixtures/CMUDICT-LICENSE +33 -0
  43. tts_pace-0.3.0/tests/fixtures/cmudict_sample.tsv +2000 -0
  44. tts_pace-0.3.0/tests/test_cache.py +75 -0
  45. tts_pace-0.3.0/tests/test_cli.py +192 -0
  46. tts_pace-0.3.0/tests/test_count.py +67 -0
  47. tts_pace-0.3.0/tests/test_count_en_accuracy.py +30 -0
  48. tts_pace-0.3.0/tests/test_engines.py +106 -0
  49. tts_pace-0.3.0/tests/test_fit.py +86 -0
  50. tts_pace-0.3.0/tests/test_measure.py +58 -0
  51. tts_pace-0.3.0/tests/test_real.py +30 -0
  52. tts_pace-0.3.0/tests/test_runtime.py +93 -0
  53. tts_pace-0.3.0/tests/test_target_duration.py +183 -0
  54. tts_pace-0.3.0/tests/test_tempo.py +52 -0
  55. tts_pace-0.3.0/uv.lock +156 -0
@@ -0,0 +1,7 @@
1
+ # No API key is needed. All variables are optional.
2
+ # Where fit() stores cached results when caching is enabled (CLI default: $XDG_CACHE_HOME/tts-pace or ~/.cache/tts-pace; the library fit() caches only when cache_dir is given or this variable is set)
3
+ TTS_PACE_CACHE_DIR=
4
+ # Path to the ffmpeg executable (default: search PATH; without it the numpy backends are used)
5
+ TTS_PACE_FFMPEG=
6
+ # Path to a Piper voice .onnx file (only for `--engine piper`)
7
+ TTS_PACE_PIPER_VOICE=
@@ -0,0 +1,22 @@
1
+ name: 버그 / Bug
2
+ description: README대로 했는데 다르게 동작해요 / Something does not work as the README says
3
+ labels: [bug]
4
+ body:
5
+ - type: textarea
6
+ id: what
7
+ attributes:
8
+ label: 무엇이 일어났나요? / What happened?
9
+ description: 실행한 명령과 나온 메시지를 그대로 붙여 주세요. / Paste the command and the output.
10
+ validations:
11
+ required: true
12
+ - type: textarea
13
+ id: expected
14
+ attributes:
15
+ label: 기대한 결과 / Expected
16
+ - type: input
17
+ id: env
18
+ attributes:
19
+ label: 환경 / Environment
20
+ placeholder: "macOS 15 (M3) · Ubuntu 24.04 · Windows 11 / uv 0.x / mock or real mode"
21
+ validations:
22
+ required: true
@@ -0,0 +1,11 @@
1
+ name: 질문 / Question
2
+ description: 이해가 안 되는 부분이나 더 알고 싶은 점 / Something unclear or worth explaining
3
+ labels: [question]
4
+ body:
5
+ - type: textarea
6
+ id: question
7
+ attributes:
8
+ label: 질문 / Question
9
+ description: README의 어느 부분(절 제목)에 대한 질문인지 알려 주세요. / Which README section is this about?
10
+ validations:
11
+ required: true
@@ -0,0 +1,20 @@
1
+ # 키·모델 없이 mock 모드로 전체 흐름을 검증한다.
2
+ name: ci
3
+ on:
4
+ push:
5
+ pull_request:
6
+ schedule:
7
+ - cron: "17 3 * * 1" # 매주 월요일 — 의존성·런타임이 바뀌어 깨지지 않았는지 정기 확인
8
+ workflow_dispatch:
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: astral-sh/setup-uv@v6
15
+ - run: uv python install
16
+ - run: uv sync --locked
17
+ - run: uv run pytest -q
18
+ env:
19
+ DEMO_MODE: mock
20
+ - run: uv run tts-pace demo
@@ -0,0 +1,43 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .mypy_cache/
8
+ *.egg-info/
9
+
10
+ # Node
11
+ /node_modules/
12
+ /dist/
13
+ /.next/
14
+
15
+ # 실행 산출물·모델·데이터 캐시
16
+ /runs/
17
+ /outputs/
18
+ /.cache/
19
+ *.safetensors
20
+ *.gguf
21
+ *.bin
22
+
23
+ # 환경·비밀
24
+ .env
25
+ .env.*
26
+ !.env.example
27
+
28
+ # OS·에디터
29
+ .DS_Store
30
+ .vscode/
31
+ .idea/
32
+
33
+ # Flutter·Dart
34
+ .dart_tool/
35
+ /build/
36
+ .flutter-plugins
37
+ .flutter-plugins-dependencies
38
+ /coverage/
39
+
40
+ # tts-pace
41
+ /tts-pace-demo/
42
+ /quickstart.wav
43
+ /*.wav
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,54 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.3.0] - 2026-11-11
8
+
9
+ ### Added
10
+ - `tts-pace fit --json` prints the report JSON (same structure as `retime --json`) to stdout as a single line.
11
+ The practice-mode `[mock]` notice and warnings go to stderr; errors still exit 2 with an empty stdout.
12
+
13
+ ### Known issues
14
+ - Tests for `--json` do not yet cover `--lang` or `--target-duration` with `--cache-dir`.
15
+ - On a `--json` error in mock mode, the first stderr line is the `[mock]` notice, followed by the `error:` line (stdout stays empty, exit 2).
16
+
17
+ ### Unchanged
18
+ - Without `--json`, output, reports and exit codes are identical to 0.2.0.
19
+
20
+ ## [0.2.0] - 2026-11-11
21
+
22
+ ### Added
23
+ - `target_duration` (seconds, total length including pauses) for `fit()` and `fit_audio()`, and
24
+ `--target-duration SECONDS` for `tts-pace fit` and `retime` (mutually exclusive with `--target`).
25
+ Reports gain `target_duration`, `result_total_s` and `length_err_pct` in this mode only.
26
+ - `target_sps` may be `None` when `target_duration` is given.
27
+
28
+ ### Known issues
29
+ - With `--target-duration 0` (or negative) the `error:` line is followed by the mock-engine banner; exit code is still 2.
30
+ - Some length-mode tests assert loosely and are to be tightened.
31
+
32
+ ### Unchanged
33
+ - Without the new option, output, reports and cache keys are identical to 0.1.1.
34
+
35
+ ## [0.1.1] - 2026-11-10
36
+
37
+ ### Changed
38
+ - README: replaced the feature comparison table with a short "when to use" section (documentation only).
39
+
40
+ ## [0.1.0] - 2026-11-09
41
+
42
+ ### Added
43
+ - Syllable counting for Korean (blocks), English (vowel-group heuristic plus a small exception
44
+ dictionary) and Japanese (morae), with spoken-form normalization of numbers and acronyms.
45
+ - Articulation-rate measurement: syllables divided by speech time without long silences
46
+ (numpy backend, optional ffmpeg `silencedetect` backend).
47
+ - Pitch-preserving tempo change limited to 0.85-1.15 (numpy WSOLA, optional ffmpeg `atempo`).
48
+ - Closed-loop `fit()` and engine-free `fit_audio()` with warnings `knob_response_weak`,
49
+ `knob_clamped`, `tempo_clamped`, `unsupported_chars`.
50
+ - Deterministic result cache with an entry limit.
51
+ - Engine adapters: `MockEngine` (practice engine, not a real TTS), `CallableEngine`, `SayEngine`,
52
+ `PiperEngine`.
53
+ - Concurrency `Limiter`, per-run temp folders, subprocess timeouts.
54
+ - `tts-pace` CLI: `demo`, `count`, `measure`, `retime`, `fit`.
tts_pace-0.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LeeGyeongPil
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,401 @@
1
+ Metadata-Version: 2.5
2
+ Name: tts-pace
3
+ Version: 0.3.0
4
+ Summary: Measure speaking rate (syllables per second of pure speech) and fit TTS output to a target pace: engine knob re-synthesis plus a bounded, pitch-preserving tempo change.
5
+ Project-URL: Homepage, https://github.com/LeeGyeongPil/tts-pace
6
+ Project-URL: Issues, https://github.com/LeeGyeongPil/tts-pace/issues
7
+ Author: LeeGyeongPil
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: audio,dubbing,speaking-rate,speech,syllables,tts
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: numpy>=2.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # tts-pace
22
+
23
+ **English** | [한국어](https://github.com/LeeGyeongPil/tts-pace/blob/v0.3.0/README.ko.md)
24
+
25
+ A small Python library and CLI that measures the speaking rate of speech audio (syllables per second of *pure speech*, silences excluded) and fits a TTS engine's output to a target pace: first with the engine's own speed knob, then with a bounded, pitch-preserving tempo change. Use it when a line must finish within a set time, as in video dubbing, narration or reading aloud; you can also give a target total length in seconds (`--target-duration`) instead of a pace. One runtime dependency (numpy), no API key, and a practice (mock) engine so you can try everything offline. Install: `uv add git+https://github.com/LeeGyeongPil/tts-pace`.
26
+
27
+ By analogy, a TTS speed setting is a faucet with no markings: you cannot tell whether turning it further gives proportionally more water. tts-pace fits a meter to the faucet and turns it a little again by hand while reading the dial.
28
+
29
+ ![Demo run result: a table of fitting to the target pace in four cases](https://raw.githubusercontent.com/LeeGyeongPil/tts-pace/v0.3.0/docs/images/01-demo.png)
30
+
31
+ *Result of running `tts-pace demo` (practice mock engine). Three of the four cases are within 0.5% error, and the last line is a case that hit a limit and raised warnings.*
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ # To use as a library (add to your project)
37
+ uv add git+https://github.com/LeeGyeongPil/tts-pace
38
+
39
+ # To clone the repository and try it right away
40
+ git clone https://github.com/LeeGyeongPil/tts-pace && cd tts-pace && uv sync
41
+ ```
42
+
43
+ - Requirements: Python 3.12 or later, [uv](https://docs.astral.sh/uv/) (if missing, `curl -LsSf https://astral.sh/uv/install.sh | sh`). On a minimal Linux container you only need `git` and `curl` in addition.
44
+ - The runtime dependency is **numpy alone**. ffmpeg, `say` and Piper are optional.
45
+ - No API key is needed. All environment variables are optional and are listed in [.env.example](https://github.com/LeeGyeongPil/tts-pace/blob/v0.3.0/.env.example).
46
+
47
+ ## Quick start
48
+
49
+ This is `examples/quickstart.py`. It runs as is when copied.
50
+
51
+ ```python
52
+ from tts_pace import MockEngine, fit
53
+
54
+ result = fit(
55
+ "Today we learn how to match the speaking rate of a synthetic voice.",
56
+ target_sps=4.0,
57
+ engine=MockEngine("en"),
58
+ out="quickstart.wav",
59
+ )
60
+ print(f"path={result.best.path} err={result.best.err_pct:.2f}% (mock engine, route={result.route})")
61
+ ```
62
+
63
+ ```bash
64
+ uv run python examples/quickstart.py
65
+ ```
66
+
67
+ ```text
68
+ path=quickstart.wav err=0.11% (mock engine, route=resynth+tempo)
69
+ ```
70
+
71
+ ![quickstart.py run result](https://raw.githubusercontent.com/LeeGyeongPil/tts-pace/v0.3.0/docs/images/05-quickstart.png)
72
+
73
+ *`MockEngine` is a **practice mock engine**. It is not real speech; it merely produces short sound chunks and pauses, one chunk per syllable, but the measurement and tempo-change code goes through exactly the same path as with a real one. Real TTS is swapped in with `SayEngine`, `PiperEngine` or `CallableEngine` (see [API](#api) below).*
74
+
75
+ ## Core concepts
76
+
77
+ 1. **Speaking rate (sps)**: the number of syllables divided by the *speech time with pauses removed* (syllables per second). Dividing by the character count or by the total length makes the value wobble because of pauses between sentences, so silences lasting 0.6 seconds or more are subtracted from the time.
78
+ 2. **Speed knob**: the "faster/slower" setting a TTS engine offers. But even if you give 1.2x, there is no guarantee the actual speed becomes 1.2x (it is non-linear and stops at a floor). So it **measures to confirm**.
79
+ 3. **Pitch-preserving tempo change**: post-processing that changes only the playback speed without changing the voice's pitch. Large changes damage the sound, so it is limited to 0.85-1.15x and used only for the final fine adjustment.
80
+ 4. **Closed-loop control**: "synthesize → measure → compare → correct" runs as a closed loop, in the same way an air conditioner measures the temperature and adjusts its power.
81
+
82
+ ```mermaid
83
+ flowchart LR
84
+ T["Text"] --> C["Count syllables"]
85
+ T --> S["Synthesize with engine"]
86
+ S --> M["Measure: syllables / speech time"]
87
+ C --> M
88
+ M --> D{"Close to the target?"}
89
+ D -- Yes --> P["Tempo change only"]
90
+ D -- No --> R["Resynthesize once with the speed knob"]
91
+ R --> P
92
+ P --> O["Result WAV + report"]
93
+ ```
94
+
95
+ Walking through the diagram step by step:
96
+
97
+ - **Counting syllables**: after converting numbers and abbreviations to how they are read aloud (`2026` → `이천이십육`, "two thousand twenty-six"), it counts Hangul blocks, English vowel groups and Japanese morae.
98
+ - **Measuring**: dividing by the speech time with pauses removed gives sps.
99
+ - **Deciding**: if the error of the base synthesis is within 10%, it leaves the knob alone and only changes the tempo (`tempo-only`). Otherwise it adjusts the knob once and resynthesizes (`resynth`), then closes the remaining error with the tempo (`resynth+tempo`).
100
+ - **Result**: it picks the candidate closest to the target, and if a limit was hit it returns a warning and advice text along with it.
101
+
102
+ ## Step-by-step walkthrough
103
+
104
+ Run these in order from the folder where you cloned the repository (all without keys, with the mock engine, in about 0.2 seconds each).
105
+
106
+ ### 1. Run the demo in one go
107
+
108
+ ```bash
109
+ uv run tts-pace demo
110
+ ```
111
+
112
+ ```text
113
+ [mock] 연습용 목(mock) 엔진 — 실제 TTS 아님 (practice engine, not a real TTS)
114
+ case base sps route final sps target err warnings
115
+ ---------------------------------------------------------------------
116
+ en far 4.61 resynth+tempo 4.00 4.0 0.09% -
117
+ en near 4.61 tempo-only 4.89 4.9 0.31% -
118
+ ko near 5.40 tempo-only 5.69 5.7 0.23% -
119
+ ko floor 5.40 resynth+tempo 4.16 4.0 3.95% knob_response_weak, tempo_clamped
120
+
121
+ wrote tts-pace-demo/ (WAV files + report.json: tts-pace-demo/report.json)
122
+ ```
123
+
124
+ (The first line's Korean part means "practice mock engine, not a real TTS".)
125
+
126
+ What it means: `base sps` is the speed of the first synthesis, `route` is the method used to fit, and `err` is the difference from the target. `en far` had a distant target so it used the knob as well, while `en near` and `ko near` were close and used the tempo only. `ko floor` is a case where this fake Korean engine is built so that it cannot get slower than its speed floor, so 3.95% error remains and warnings are attached. The generated WAV files and `report.json` are saved in `tts-pace-demo/`.
127
+
128
+ ### 2. Counting syllables
129
+
130
+ ```bash
131
+ uv run tts-pace count "2026년 TTS 속도를 맞춥니다"
132
+ ```
133
+
134
+ ```text
135
+ spoken form : 이천이십육년 티티에스 속도를 맞춥니다
136
+ syllables : 17 {'ko': 17, 'en': 0, 'ja': 0}
137
+ ```
138
+
139
+ (The input means "We adjust the TTS speed in 2026".)
140
+
141
+ ![count run result](https://raw.githubusercontent.com/LeeGyeongPil/tts-pace/v0.3.0/docs/images/02-count.png)
142
+
143
+ *After numbers and abbreviations are converted to how they are read, 17 syllables are counted.*
144
+
145
+ What it means: TTS reads `2026` as "이천이십육", so counting the characters literally would skew the speed. It counts based on the form converted to how it is read (`spoken form`).
146
+
147
+ ### 3. Measuring the speaking rate of a recording
148
+
149
+ ```bash
150
+ uv run tts-pace measure tts-pace-demo/en_far_base.wav --text-file tts-pace-demo/en.txt
151
+ ```
152
+
153
+ ```text
154
+ total 8.00s silence 1.71s (2 gaps) speech 6.30s
155
+ 29 syllables -> 4.61 syllables/second of pure speech
156
+ ```
157
+
158
+ ![measure run result](https://raw.githubusercontent.com/LeeGyeongPil/tts-pace/v0.3.0/docs/images/03-measure.png)
159
+
160
+ *Of the total 8.00 seconds, 1.71 seconds of pauses are removed, and 29 syllables were spoken in 6.30 seconds, so it is 4.61 sps.*
161
+
162
+ What it means: dividing by the total length (8.00 seconds) would give a slow-looking 3.6 sps or so, but the pauses between sentences are not speaking rate. The value with pauses removed is the basis for comparison.
163
+
164
+ ### 4. Fitting to a target (a case that raises warnings)
165
+
166
+ ```bash
167
+ uv run tts-pace fit --engine mock --lang ko \
168
+ --text "오늘은 말하기 속도를 맞추는 방법을 알아봅니다." \
169
+ --target 4.0 -o ko.wav --no-cache
170
+ ```
171
+
172
+ ```text
173
+ warning: knob_response_weak - The engine speed knob changed the pace much less than asked (it may stop at a floor or scale non-linearly). Try another voice or engine setting, or edit the text.
174
+ warning: tempo_clamped - The tempo change hit its limit, so the target was not reached. Shorten or lengthen the text, or choose an engine setting closer to the target pace.
175
+ [mock] 연습용 목(mock) 엔진 — 실제 TTS 아님 (practice engine, not a real TTS)
176
+ base 5.40 sps
177
+ resynth speed x0.740 tempo x1.000 -> 4.89 sps err 22.23%
178
+ resynth+tempo speed x0.740 tempo x0.850 -> 4.16 sps err 4.08%
179
+ best resynth+tempo: 4.16 sps (target 4, err 4.08%)
180
+ cache miss
181
+ wrote ko.wav
182
+ ```
183
+
184
+ (The input text means "Today we learn how to match the speaking pace.")
185
+
186
+ ![fit run result](https://raw.githubusercontent.com/LeeGyeongPil/tts-pace/v0.3.0/docs/images/04-fit.png)
187
+
188
+ *When aiming for 4.0 sps from 5.40 sps: even lowering the speed knob to 0.74x only brings it down to 4.89 sps, and even using the tempo up to its limit (0.85x) it stops at 4.16 sps.*
189
+
190
+ What it means: the knob was set to 0.74x but the speed did not drop by that much (`knob_response_weak`). The tempo change also hit the 0.85x floor (`tempo_clamped`). In such a case the tool does not stretch things by force; it returns the result closest to the target along with warnings. As advised, you need to adjust the sentence length or the target.
191
+
192
+ Instead of a speed, you can also give the target as "how many seconds in total, pauses included". `--target` and `--target-duration` cannot be used together, and one of them must be given (otherwise exit code 2).
193
+
194
+ ```bash
195
+ uv run tts-pace fit --engine mock --text "Today we learn how to match the rate." \
196
+ --target-duration 4 -o o.wav --no-cache
197
+ ```
198
+
199
+ ```text
200
+ warning: tempo_clamped - The tempo change hit its limit, so the target was not reached. Shorten or lengthen the text, or choose an engine setting closer to the target pace.
201
+ [mock] 연습용 목(mock) 엔진 — 실제 TTS 아님 (practice engine, not a real TTS)
202
+ base 4.60 sps
203
+ resynth speed x0.714 tempo x1.000 -> 3.77 sps err 14.60%
204
+ resynth+tempo speed x0.714 tempo x0.850 -> 3.22 sps err 2.15%
205
+ best resynth+tempo: 3.22 sps (target 3.2864, err 2.15%)
206
+ length 3.85s (target 4.00s, err 3.85%)
207
+ cache miss
208
+ wrote o.wav
209
+ ```
210
+
211
+ What it means: it stopped at 3.85 seconds for a 4.00-second target. The tempo hit the 0.85x floor (`tempo_clamped`), and the `length` line tells you the remaining difference. The `length` line appears only when `--target-duration` is used.
212
+
213
+ The `target 3.2864` on the `best` line is the target sps obtained by converting the 4-second target length into this sentence's speaking rate with pauses excluded (that is why it has a different number of decimal places from the other lines).
214
+
215
+ For use in scripts, add `--json` (since 0.3.0). Only one line of report JSON goes to standard output, and warnings and the `[mock]` notice go to standard error. It is the same content as what is saved in the `--report` file.
216
+
217
+ ```bash
218
+ uv run tts-pace fit --engine mock --text "Today we learn pace." \
219
+ --target 4 -o o.wav --no-cache --json 2>/dev/null
220
+ ```
221
+
222
+ ```text
223
+ {"version": 1, "engine": "mock-en", "mock": true, "mock_note": "practice (mock) engine, not a real TTS", "target_sps": 4.0, ...
224
+ ```
225
+
226
+ (Only the beginning of the output is shown. It continues with `syllables`, `route`, `best`, `candidates`, `warnings`, `cache_hit` and so on.)
227
+
228
+ The cache defaults differ between the CLI and the library. The CLI caches in `~/.cache/tts-pace` by default, and the library's `fit()` does not cache unless you give `cache_dir` (or the environment variable `TTS_PACE_CACHE_DIR`).
229
+
230
+ If you use `--cache-dir "$HOME/.cache/tts-pace"` instead of `--no-cache` in the same command (the default cache location is `~/.cache/tts-pace`, changeable with `TTS_PACE_CACHE_DIR`), `cache hit` appears from the second run and synthesis is skipped.
231
+
232
+ ### 5. Fitting an existing recording with the tempo alone
233
+
234
+ ```bash
235
+ uv run tts-pace retime tts-pace-demo/en_far_base.wav --text-file tts-pace-demo/en.txt --target 4.2 -o slower.wav
236
+ ```
237
+
238
+ To fit by total length, use `--target-duration 9` instead of `--target 4.2`. This file is originally about 8 seconds, so this stretches it in the slower (longer) direction.
239
+
240
+ ```bash
241
+ uv run tts-pace retime tts-pace-demo/en_far_base.wav --text-file tts-pace-demo/en.txt --target-duration 9 -o slower.wav
242
+ ```
243
+
244
+ ```text
245
+ base 4.61 sps
246
+ tempo-only speed x1.000 tempo x0.889 -> 4.10 sps err 0.01%
247
+ best tempo-only: 4.10 sps (target 4.09641, err 0.01%)
248
+ length 8.97s (target 9.00s, err 0.30%)
249
+ cache miss
250
+ wrote slower.wav
251
+ ```
252
+
253
+ This is the command to use when you have only a file and no engine. If the target is too far (for example `--target 2.0`), it stops at the 0.85x floor and raises a `tempo_clamped` warning. The exit code is still 0 in that case, so in automation check for warnings in the `warnings` field of the `--json` output (both `retime --json` and `fit --json`) or in the report.
254
+
255
+ ## API
256
+
257
+ These are the public names exported by `tts_pace`. Do not use module-internal functions that are not listed here.
258
+
259
+ | Name | What it does | Main arguments | Returns |
260
+ |---|---|---|---|
261
+ | `count_syllables(text, lang="auto", normalize=None)` | Syllable count based on how the text is read | `lang`: `auto/ko/en/ja` (affects only the reading of numbers and abbreviations), `normalize`: `{"2026": "twenty twenty six"}` | `SyllableCount` (`total`, `by_script`, `normalized_text`, `unsupported`) |
262
+ | `measure(audio, text=None, syllables=None, *, silence_db=-60.0, min_silence=0.6, backend="auto", limiter=None)` | Measures speech time with pauses removed, and speaking rate | `audio`: a WAV path or an `AudioClip`; `text` and `syllables` cannot be given together | `Measurement` (`total_s`, `silence_s`, `net_s`, `gaps`, `sps`) |
263
+ | `retime(audio, factor, out=None, backend="auto", clamp=False, *, tempo_range=(0.85, 1.15), limiter=None)` | Changes speed while keeping pitch (1.1 = 10% faster) | A `factor` outside the range raises `ValueError`; with `clamp=True` it is cut to the boundary | `AudioClip` |
264
+ | `fit(text, target_sps, engine, out=None, *, target_duration=None, tolerance=0.10, tempo_range, cache_dir=None, lang="auto", normalize=None, limiter=None, keep_candidates=False, silence_db, min_silence, tempo_backend, measure_backend)` | Closed loop of synthesize → measure → knob/tempo correction | `engine`: `SpeedEngine`; `cache_dir`: a folder, `True` (default location), `False` (do not use), `None` (default: caches only when the environment variable `TTS_PACE_CACHE_DIR` is set); `target_duration`: total length in seconds including pauses. Give only one of it and `target_sps` (length mode uses `target_sps=None`); giving both, neither, or a value of 0 or less raises `ValueError`; `limiter`: a `Limiter` to cap concurrent runs; `silence_db`, `min_silence`: pause-detection thresholds (same as in `measure`); `tempo_backend`, `measure_backend`: the method used for tempo change and decoding (default `auto`) | `FitResult` (`best`, `base`, `candidates`, `route`, `warnings`, `advice`, `cache_hit`, `save_report()`) |
265
+ | `fit_audio(audio, text, target_sps, out=None, *, target_duration=None, tolerance=0.10, tempo_range, silence_db, min_silence, tempo_backend, measure_backend, limiter=None, lang="auto", normalize=None)` | Fits an existing recording with the tempo alone, with no engine | Same | `FitResult` |
266
+ | `MockEngine(preset="en")` | Practice mock engine (`"en"`/`"ko"`; `base_sps`, `elasticity`, `silent_floor`, `jitter`, `seed` are adjustable) | | Engine |
267
+ | `CallableEngine(fn, *, speed_range=(0.5, 2.0), deterministic=False, cache_id=None, name="callable", has_speed_knob=True)` | Wraps a single Python function as an engine | `fn(text, speed)` returns one of `AudioClip`, a WAV path, or `(samples, sample_rate)` | Engine |
268
+ | `SayEngine(voice=None, *, base_wpm=175, speed_range=(0.86, 1.6))` | Calls macOS `say` | macOS only | Engine |
269
+ | `PiperEngine(voice=None, *, speed_range=(0.6, 1.7), sentence_silence=0.8)` | Calls Piper (runs the `piper` the user installed as a separate process) | `voice`: an `.onnx` path or `TTS_PACE_PIPER_VOICE` | Engine |
270
+ | `Limiter(max_concurrent=2, max_waiting=8)` | Limits the number of concurrent synthesis, tempo and ffmpeg runs | If there is no slot and the queue is full, `BusyError` | Context manager `slot()` |
271
+ | `AudioClip`, `Measurement`, `SyllableCount`, `Candidate`, `FitResult`, `EngineInfo`, `SpeedEngine` | Data types and the engine protocol | | |
272
+ | Exceptions | `TtsPaceError` (common parent), `EngineError`, `AudioFormatError`, `MeasurementError`, `UnsupportedTextError`, `BusyError`. Invalid arguments raise `ValueError` | | |
273
+ | Warning | `UnsupportedCharsWarning` (a warning class emitted through Python's `warnings` module, when there are characters that cannot be counted) | | |
274
+
275
+ Warnings returned by `fit()`: `knob_response_weak` (the knob does not respond as expected), `knob_clamped` (the requested speed was outside the engine's range and was cut), `tempo_clamped` (the tempo limit was hit), `unsupported_chars` (there were characters that could not be counted).
276
+
277
+ Command line: `tts-pace {demo,count,measure,retime,fit}`. See each command's options with `uv run tts-pace <command> --help`.
278
+
279
+ ## Usage examples
280
+
281
+ ### 1. Measuring and fitting the speed of an existing recording
282
+
283
+ ```python
284
+ from tts_pace import count_syllables, measure, fit_audio
285
+
286
+ text = "Today we learn how to match the speaking rate of a synthetic voice. It is a short lesson about pace and silence."
287
+ n = count_syllables(text)
288
+ print("syllables:", n.total, n.by_script)
289
+
290
+ m = measure("tts-pace-demo/en_far_base.wav", text=text)
291
+ print(f"total {m.total_s:.2f}s, silence {m.silence_s:.2f}s, speech {m.net_s:.2f}s -> {m.sps:.2f} sps")
292
+
293
+ r = fit_audio("tts-pace-demo/en_far_base.wav", text, target_sps=4.2, out="slower.wav")
294
+ print(r.route, f"{r.best.measurement.sps:.2f} sps", f"err {r.best.err_pct:.2f}%", r.warnings)
295
+ ```
296
+
297
+ ```text
298
+ syllables: 29 {'ko': 0, 'en': 29, 'ja': 0}
299
+ total 8.00s, silence 1.71s, speech 6.30s -> 4.61 sps
300
+ tempo-only 4.19 sps err 0.14% ()
301
+ ```
302
+
303
+ (`tts-pace-demo/en_far_base.wav` is created by `uv run tts-pace demo`.)
304
+
305
+ ### 2. Connecting the TTS you use
306
+
307
+ `CallableEngine` needs only a single `fn(text, speed)` function. The `my_tts` below is a placeholder that makes fake sound for illustration. In practice you would call a cloud API or a local model here, and convert `speed` (1.0 = default, larger is faster) into that engine's own unit.
308
+
309
+ ```python
310
+ import numpy as np
311
+ from tts_pace import CallableEngine, fit
312
+
313
+ def my_tts(text: str, speed: float):
314
+ # Stand-in for a real TTS call: one 0.1 s tone per character; faster speed -> shorter tones.
315
+ sr = 16000
316
+ tone = np.sin(2 * np.pi * 220 * np.arange(int(sr * 0.1 / speed)) / sr) * 0.5
317
+ gap = np.zeros(int(sr * 0.02 / speed))
318
+ return np.concatenate([np.concatenate([tone, gap]) for _ in text]).astype(np.float32), sr
319
+
320
+ engine = CallableEngine(my_tts, speed_range=(0.5, 2.0), name="my-tts")
321
+ r = fit("Hello there friend", target_sps=3.0, engine=engine, out="mine.wav")
322
+ print(r.route, f"{r.best.measurement.sps:.2f} sps", [round(c.speed, 2) for c in r.candidates])
323
+ ```
324
+
325
+ ```text
326
+ resynth 3.00 sps [1.0, 1.62]
327
+ ```
328
+
329
+ What it means: it measured the first synthesis (speed 1.0), resynthesized once at speed 1.62, and hit 3.00 sps. If your engine always produces the same sound for the same input, give `deterministic=True` and `cache_id="my-engine:voice"` to use the result cache.
330
+
331
+ ### 3. Real engines (macOS `say`, Piper)
332
+
333
+ ```python
334
+ from tts_pace import SayEngine, fit
335
+ r = fit("오늘은 속도를 맞춥니다.", target_sps=4.5, engine=SayEngine("Yuna"), out="say.wav")
336
+ ```
337
+
338
+ (The text means "Today we match the speed.")
339
+
340
+ > When this README was written, `SayEngine` and `PiperEngine` had **not been verified with real speech.** Command construction and error handling were tested, but the tests that call real engines (`pytest -m real`) are excluded from the default run. Real engines are shared resources and can be slow, and `say -r` was observed not to respond below about 150 wpm, so the default range was limited to 0.86-1.6x.
341
+
342
+ ## When to use it
343
+
344
+ - **When tts-pace fits**: when you must fit a target pace in "syllables per second with pauses removed", as in dubbing or narration (a target total length can be given with `--target-duration`), or when you want to measure the speed of an existing recording and adjust only the tempo (0.85-1.15x) slightly. Installation takes only numpy.
345
+ - **When another tool is better**: if you only need to speed things up by a fixed ratio, the TTS engine's own speed option or [ffmpeg `atempo`](https://ffmpeg.org/ffmpeg-filters.html#atempo) is enough. If you need speech synthesis itself, use a TTS engine such as [Piper](https://github.com/OHF-Voice/piper1-gpl).
346
+ - tts-pace is a measurement and correction layer that does not synthesize speech itself.
347
+
348
+ ## What we learned
349
+
350
+ These are values measured directly. Measured on 2026-11-09, with piper-tts 1.8.0 (English) and the macOS `say` voice Yuna (Korean). These values are the basis of the defaults.
351
+
352
+ | Item | Measured value | So tts-pace |
353
+ |---|---|---|
354
+ | Proportionality of Piper `length_scale` | At noise 0: 0.7→6.49, 0.85→5.24, 1.0→4.88, 1.2→4.43, 1.5→3.59 sps. From 1.0 to 1.2 it changes only about 55% of what was expected (1/1.2x = 4.07 sps) | Does not stop after one knob adjustment; it measures again and closes the rest with the tempo. `MockEngine("en")`'s `elasticity=0.6` (speed^0.6) imitates this non-linearity |
355
+ | `say -r` floor | For Yuna, `-r 120` and `-r 150` give the same result (7.531 seconds, 4.91 sps). 175→220→280 is nearly proportional (5.40→6.68→8.13 sps) | Limits `SayEngine`'s default range to 0.86-1.6x and raises `knob_response_weak` when the knob does not respond. `MockEngine("ko")`'s `silent_floor=0.9` imitates this "quiet floor" |
356
+ | Non-determinism of Piper defaults | Synthesizing the same sentence twice gives different file hashes and lengths (2.566 s / 2.601 s). With `--noise-scale 0 --noise-w-scale 0` they are the same | `PiperEngine` fixes noise at 0 to protect caching and reproducibility (the base speaking rate also changes from about 4.6 to 4.88 sps) |
357
+ | Accuracy of English syllable counting | On 3,000 random CMUdict words, the `syllables` package scores 78.47% (measured in the preliminary survey). This library's rules score 89.6% on a sample of 1,994 words from the same dictionary (test `tests/test_count_en_accuracy.py`); the samples differ, so it is not a strict comparison | States in [Limitations](#limitations) that English is an estimate |
358
+ | Pause detection | The pauses between sentences from Piper `--sentence-silence 0.8` (about 0.85-0.99 seconds) are detected and excluded. `say` Korean has pauses shorter than 0.6 seconds, so there are 0 | Sets the pause threshold at 0.6 seconds |
359
+
360
+ Results of fitting with real engines (error against the target): English Piper at a 4.0 sps target reached 0.17% with resynthesis + tempo; Korean `say` at a 5.0 sps target reached 0.16% with the tempo alone; Korean `say` at a 4.0 sps target hit the 0.85x tempo floor and ended at 4.5%. These figures were obtained by running real Piper and `say` directly during the survey before the design. They were not re-verified with the finished `SayEngine` and `PiperEngine` adapters, and as noted in [Usage example 3](#3-real-engines-macos-say-piper), they are not in the default tests either.
361
+
362
+ ## Limitations
363
+
364
+ - English syllables are a rule-based estimate (89.6% word-level agreement on a sample of 1,994 CMUdict words). Korean is counted by character block and Japanese by mora.
365
+ - The tempo change is limited to 0.85-1.15x. Differences beyond that must be solved with the knob or the sentence length.
366
+ - The length for `--target-duration` is the total length including pauses, and it is hit exactly only when the required tempo is within 0.85-1.15x. Otherwise it stops nearby with a warning.
367
+ - Known limitations (as of 0.3.0): giving `--target-duration` a value of 0 or less prints the mock-engine notice line after the `error:` line (the exit code is still the correct 2). Some of the length-mode tests have weak value-unit checks and will be strengthened later. The `--json` tests do not yet cover the combinations of `--lang`, and `--target-duration` with `--cache-dir`. When `--json` hits an error (in mock mode), the first line of standard error is the `[mock]` notice and the `error:` line comes after it (standard output is empty and the exit code is 2).
368
+ - The mock engine is for practicing the measurement and correction logic and does not represent the quality or speed characteristics of real speech.
369
+
370
+ ## Versions and compatibility
371
+
372
+ - Follows Semantic Versioning (SemVer). During 0.x the API may change in minor versions, and any change is recorded in [CHANGELOG.md](https://github.com/LeeGyeongPil/tts-pace/blob/v0.3.0/CHANGELOG.md).
373
+ - Python 3.12 or later.
374
+
375
+ ## Troubleshooting
376
+
377
+ | Symptom | Cause | Fix |
378
+ |---|---|---|
379
+ | `knob_response_weak` warning | The engine's speed knob stops at its floor or ceiling, or is non-linear | Use a different voice or engine setting, or edit the sentence |
380
+ | `tempo_clamped` warning | The tempo needed to reach the target (speed or `--target-duration` length) is outside 0.85-1.15x | Adjust the sentence length or the target speed or length |
381
+ | `No countable syllables found` error | There are only symbols or only unsupported characters, so nothing can be counted | Tell it how the text is read with `normalize={"2026": "twenty twenty six"}` |
382
+ | `SayEngine only works on macOS` | `say` is macOS only | Use `CallableEngine` or `PiperEngine` |
383
+ | `PiperEngine needs a voice .onnx file` | No voice file was specified | Set `voice=...` or `TTS_PACE_PIPER_VOICE` |
384
+
385
+ ## Development and contributing
386
+
387
+ ```bash
388
+ uv sync
389
+ uv run pytest -q # 99 passed, 2 deselected (tests that need a real engine are excluded by default)
390
+ ```
391
+
392
+ ## Learn more
393
+
394
+ - Piper TTS: https://github.com/OHF-Voice/piper1-gpl (checked 2026-11-09)
395
+ - A discussion of adjusting Piper's speaking rate: https://community.home-assistant.io/t/how-to-set-piper-speaking-rate/662014 (checked 2026-11-09)
396
+
397
+ ## License
398
+
399
+ MIT. Piper is GPL-licensed, so it is not included in this package; the `piper` the user installed is called as a separate process.
400
+
401
+ `tests/fixtures/cmudict_sample.tsv` is a sample of the CMU Pronouncing Dictionary (BSD 2-clause, `tests/fixtures/CMUDICT-LICENSE`).