g-speech 0.1.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.
- g_speech-0.1.0.dist-info/METADATA +297 -0
- g_speech-0.1.0.dist-info/RECORD +20 -0
- g_speech-0.1.0.dist-info/WHEEL +5 -0
- g_speech-0.1.0.dist-info/entry_points.txt +2 -0
- g_speech-0.1.0.dist-info/licenses/LICENSE +21 -0
- g_speech-0.1.0.dist-info/top_level.txt +1 -0
- gspeech/__init__.py +66 -0
- gspeech/__main__.py +5 -0
- gspeech/_cache.py +149 -0
- gspeech/audio.py +131 -0
- gspeech/cli.py +100 -0
- gspeech/client.py +243 -0
- gspeech/config.py +178 -0
- gspeech/exceptions.py +17 -0
- gspeech/player.py +477 -0
- gspeech/preloader_thread.py +52 -0
- gspeech/py.typed +1 -0
- gspeech/speech.py +150 -0
- gspeech/speech_segment.py +20 -0
- gspeech/version.py +24 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: g-speech
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Interruptible text-to-speech using the Google Translate TTS API.
|
|
5
|
+
Author-email: Karim Aziiev <karim.aziiev@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/KarimAziev/gspeech
|
|
8
|
+
Project-URL: Issues, https://github.com/KarimAziev/gspeech/issues
|
|
9
|
+
Project-URL: Repository, https://github.com/KarimAziev/gspeech
|
|
10
|
+
Project-URL: Changelog, https://github.com/KarimAziev/gspeech/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: speech,audio,synthesis,voice,google,tts
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: miniaudio>=1.61
|
|
28
|
+
Requires-Dist: platformdirs>=4.3
|
|
29
|
+
Requires-Dist: requests>=2.31
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: coverage[toml]>=7.10; extra == "dev"
|
|
32
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
33
|
+
Requires-Dist: pyright; extra == "dev"
|
|
34
|
+
Requires-Dist: ruff; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# About
|
|
38
|
+
|
|
39
|
+
[](https://github.com/KarimAziev/gspeech/actions/workflows/ci.yml)
|
|
40
|
+
[](https://codecov.io/gh/KarimAziev/gspeech)
|
|
41
|
+
|
|
42
|
+
`gspeech` synthesizes speech with the Google Translate TTS endpoint and plays it
|
|
43
|
+
through a small, cross-platform
|
|
44
|
+
[Miniaudio](https://github.com/irmen/pyminiaudio) backend. Playback works on
|
|
45
|
+
Raspberry Pi, Linux, macOS, and Windows.
|
|
46
|
+
|
|
47
|
+
The Google Translate endpoint is undocumented and is not an official Google API.
|
|
48
|
+
Applications should therefore handle synthesis failures and should not rely on
|
|
49
|
+
the endpoint for safety-critical announcements.
|
|
50
|
+
|
|
51
|
+
**Table of Contents**
|
|
52
|
+
|
|
53
|
+
> - [About](#about)
|
|
54
|
+
> - [Installation](#installation)
|
|
55
|
+
> - [Usage](#usage)
|
|
56
|
+
> - [Command line](#command-line)
|
|
57
|
+
> - [Python API](#python-api)
|
|
58
|
+
> - [Interruption and queuing](#interruption-and-queuing)
|
|
59
|
+
> - [Synthesis and cache configuration](#synthesis-and-cache-configuration)
|
|
60
|
+
> - [Logging](#logging)
|
|
61
|
+
> - [Errors and shutdown](#errors-and-shutdown)
|
|
62
|
+
> - [Development](#development)
|
|
63
|
+
|
|
64
|
+
## Installation
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install g-speech
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
### Command line
|
|
73
|
+
|
|
74
|
+
Speak text:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
gspeech "Hello, world"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Select a language:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
gspeech -l fr "Bonjour tout le monde"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Save MP3 data instead of playing it:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
gspeech -o hello.mp3 "Hello, world"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Read and speak standard input one line at a time:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
some-command | gspeech -
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The module entry point is equivalent:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python -m gspeech "Hello, world"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Press `Ctrl+C` to interrupt CLI playback.
|
|
105
|
+
|
|
106
|
+
Use `-v` for lifecycle messages, `-vv` for diagnostic logging, and `--version`
|
|
107
|
+
to print the installed version:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
gspeech -vv "Hello, world"
|
|
111
|
+
gspeech --version
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Only the CLI treats `-` as standard input. In the Python API,
|
|
115
|
+
`Speech("-", "en")` synthesizes a literal hyphen.
|
|
116
|
+
|
|
117
|
+
### Python API
|
|
118
|
+
|
|
119
|
+
The familiar blocking API remains available:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from gspeech import Speech
|
|
123
|
+
|
|
124
|
+
result = Speech("Hello, world", "en").play()
|
|
125
|
+
print(result.status)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Use a shared `SpeechPlayer` for interruptible application playback:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from gspeech import SpeechPlayer
|
|
132
|
+
|
|
133
|
+
with SpeechPlayer() as player:
|
|
134
|
+
first = player.speak("This announcement is no longer relevant.")
|
|
135
|
+
|
|
136
|
+
# The default "replace" policy interrupts first and plays second.
|
|
137
|
+
second = player.speak("Turn right now.")
|
|
138
|
+
|
|
139
|
+
print(first.wait().status) # SpeechStatus.INTERRUPTED
|
|
140
|
+
print(second.wait().status) # SpeechStatus.COMPLETED
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
#### Interruption and queuing
|
|
144
|
+
|
|
145
|
+
`SpeechPlayer.speak()` is non-blocking and returns a thread-safe
|
|
146
|
+
`SpeechHandle`. The default `replace` policy:
|
|
147
|
+
|
|
148
|
+
- interrupts the active request;
|
|
149
|
+
- discards older pending requests; and
|
|
150
|
+
- starts the newest request as soon as a download worker is available.
|
|
151
|
+
|
|
152
|
+
Cancellation is observed while downloading as well as during audio playback.
|
|
153
|
+
Synchronous HTTP requests cannot be forcibly killed, so an abandoned download
|
|
154
|
+
may continue in the bounded background download pool until its configured
|
|
155
|
+
timeout. The pool is bounded to avoid unbounded thread growth on small systems.
|
|
156
|
+
|
|
157
|
+
Stop active and pending speech explicitly:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
player.stop()
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Wait until active playback acknowledges cancellation:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
player.stop(wait=True, timeout=1)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Queue speech instead of replacing it:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from gspeech import SpeechPolicy
|
|
173
|
+
|
|
174
|
+
player.speak("First", policy=SpeechPolicy.ENQUEUE)
|
|
175
|
+
player.speak("Second", policy=SpeechPolicy.ENQUEUE)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Always close a long-lived player, either explicitly or with a context manager:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
player.close()
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`SpeechPlayer` closes its audio backend and synthesizer, including injected
|
|
185
|
+
implementations. `Speech.write_to()` does not close an injected synthesizer
|
|
186
|
+
because the caller owns that standalone dependency.
|
|
187
|
+
|
|
188
|
+
#### Synthesis and cache configuration
|
|
189
|
+
|
|
190
|
+
`GoogleTranslateTTSClient` owns HTTP sessions, timeouts, and the persistent
|
|
191
|
+
audio cache:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from gspeech import GoogleTranslateTTSClient, SpeechPlayer
|
|
195
|
+
|
|
196
|
+
client = GoogleTranslateTTSClient(
|
|
197
|
+
timeout=(3.05, 5.0), # connect timeout, read timeout
|
|
198
|
+
cache_ttl_seconds=7 * 24 * 60 * 60,
|
|
199
|
+
cache_max_entries=500,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
with SpeechPlayer(synthesizer=client) as player:
|
|
203
|
+
player.play("Hello", "en").raise_for_error()
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Disable persistent caching:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
client = GoogleTranslateTTSClient(cache_enabled=False)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Choose another cache directory or clear it:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
client = GoogleTranslateTTSClient(cache_dir="/var/cache/my-robot/gspeech")
|
|
216
|
+
client.clear_cache()
|
|
217
|
+
client.close()
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Cache keys are SHA-256 digests and do not contain the original speech text.
|
|
221
|
+
Audio data is stored in a small SQLite database under the platform-specific user
|
|
222
|
+
cache directory by default.
|
|
223
|
+
|
|
224
|
+
#### Logging
|
|
225
|
+
|
|
226
|
+
Library loggers use the `gspeech` namespace and do not configure application
|
|
227
|
+
logging. Applications can enable them normally:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
import logging
|
|
231
|
+
|
|
232
|
+
logging.basicConfig(level=logging.INFO)
|
|
233
|
+
logging.getLogger("gspeech").setLevel(logging.DEBUG)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Lifecycle logs include request IDs, language, character counts, segment numbers,
|
|
237
|
+
statuses, and elapsed times. They intentionally omit speech text and complete
|
|
238
|
+
provider URLs.
|
|
239
|
+
|
|
240
|
+
#### Errors and shutdown
|
|
241
|
+
|
|
242
|
+
The public exception hierarchy is:
|
|
243
|
+
|
|
244
|
+
```text
|
|
245
|
+
GSpeechError
|
|
246
|
+
├── SynthesisError
|
|
247
|
+
├── PlaybackError
|
|
248
|
+
└── PlayerClosedError
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Asynchronous failures are recorded in `SpeechResult`:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
result = player.play("Hello")
|
|
255
|
+
result.raise_for_error()
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Interruption is a normal terminal status rather than an exception:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
from gspeech import SpeechStatus
|
|
262
|
+
|
|
263
|
+
if result.status is SpeechStatus.INTERRUPTED:
|
|
264
|
+
...
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Use a context manager when possible. A long-lived service should call
|
|
268
|
+
`player.close()` during application shutdown.
|
|
269
|
+
|
|
270
|
+
Language choices for an API or user interface are available without exposing a
|
|
271
|
+
mutable package-global list:
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
from gspeech import available_languages
|
|
275
|
+
|
|
276
|
+
options = available_languages()
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Development
|
|
280
|
+
|
|
281
|
+
Create a virtual environment, install the development extra, and run the local
|
|
282
|
+
CI-equivalent checks:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
python -m venv .venv
|
|
286
|
+
. .venv/bin/activate
|
|
287
|
+
python -m pip install --editable ".[dev]"
|
|
288
|
+
make all
|
|
289
|
+
python -m coverage run -m unittest discover
|
|
290
|
+
python -m coverage report
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
The real provider test is opt-in and downloads audio without playing it:
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
GSPEECH_RUN_INTEGRATION_TESTS=1 python -m unittest tests.test_integration
|
|
297
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
g_speech-0.1.0.dist-info/licenses/LICENSE,sha256=N9DLsoErrYbT3HG24DVyxJbB2EUJZs4dxP-H5HlyLgg,1069
|
|
2
|
+
gspeech/__init__.py,sha256=gwp3dJsmUdEQij1rE4c6pmrlL1p1o_QdNacGH8Lo3-0,1397
|
|
3
|
+
gspeech/__main__.py,sha256=LHG3e3txRVEUzW0XVkbSP6W1bNKJWBPwGpc3D5gngMU,93
|
|
4
|
+
gspeech/_cache.py,sha256=3a7sdsLaUZ6giwhkocPxAmipMMOlG3HQWW01YKpqkBg,4582
|
|
5
|
+
gspeech/audio.py,sha256=FnIUP20ySU0gsdiDZgjPtSWOZX3zNvFqW5VGTkAH8ZQ,4283
|
|
6
|
+
gspeech/cli.py,sha256=x-iLTkoX0BBg7yO_7Ep1jVsrTrB9_C9FPiLRG6-4lmg,2804
|
|
7
|
+
gspeech/client.py,sha256=YD_X0r0tS2vV1IejlCS0K5_vObzPzjB10XPmKha_7eY,7692
|
|
8
|
+
gspeech/config.py,sha256=75Xdkcf1wgZyN6K7TsGeTio8HHFADdmmJpBDDLpOU14,3392
|
|
9
|
+
gspeech/exceptions.py,sha256=GAP9xIG_NKf76IdJuXx1afcBeEAPpOkW-O526OjDSfw,402
|
|
10
|
+
gspeech/player.py,sha256=LHrbIy4QAl8AFz1_1UNvUZ69yuEd60iGq9adXhjpVl4,15674
|
|
11
|
+
gspeech/preloader_thread.py,sha256=y9hkfmuCL2Pfack7ROAxS-zC57ujoGQTlysIsQ5jVM4,1690
|
|
12
|
+
gspeech/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
13
|
+
gspeech/speech.py,sha256=nxqpuSa1h9DyVhMWtSdacqYtWOKudX0oy4H-VYo14bQ,4988
|
|
14
|
+
gspeech/speech_segment.py,sha256=OPNy5UzdG4yTNfwktvtfkgJnZO-JonxrVftF89eVQHI,407
|
|
15
|
+
gspeech/version.py,sha256=mz2dhmhl1m1JQj8ScOzgfl_mJYXZQwwaUCNMvuwCGZ4,528
|
|
16
|
+
g_speech-0.1.0.dist-info/METADATA,sha256=nOSri4iUy5BypQ8p2BkZFdtCYd1EckyEuyZ1hkEI8TQ,7940
|
|
17
|
+
g_speech-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
18
|
+
g_speech-0.1.0.dist-info/entry_points.txt,sha256=lWDP2cGJ0ZS8YCmo1ssVHJsZkbtDjSkzN4-Uas8qqzU,45
|
|
19
|
+
g_speech-0.1.0.dist-info/top_level.txt,sha256=EG8pNeInM-XxgNgNhuK-kUdZkvrDChmUIl-sU_Vwv_k,8
|
|
20
|
+
g_speech-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Karim Aziiev
|
|
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 @@
|
|
|
1
|
+
gspeech
|
gspeech/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Interruptible text-to-speech synthesis and playback."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from gspeech._cache import AudioCache
|
|
6
|
+
from gspeech.audio import AudioBackend, MiniaudioBackend
|
|
7
|
+
from gspeech.client import (
|
|
8
|
+
GoogleTranslateTTSClient,
|
|
9
|
+
HTTPResponse,
|
|
10
|
+
HTTPSession,
|
|
11
|
+
Synthesizer,
|
|
12
|
+
)
|
|
13
|
+
from gspeech.config import (
|
|
14
|
+
LANGUAGES_OPTIONS,
|
|
15
|
+
SUPPORTED_LANGUAGES,
|
|
16
|
+
LanguageCode,
|
|
17
|
+
LanguageOption,
|
|
18
|
+
available_languages,
|
|
19
|
+
)
|
|
20
|
+
from gspeech.exceptions import (
|
|
21
|
+
GSpeechError,
|
|
22
|
+
PlaybackError,
|
|
23
|
+
PlayerClosedError,
|
|
24
|
+
SynthesisError,
|
|
25
|
+
)
|
|
26
|
+
from gspeech.player import (
|
|
27
|
+
SpeechHandle,
|
|
28
|
+
SpeechPlayer,
|
|
29
|
+
SpeechPolicy,
|
|
30
|
+
SpeechResult,
|
|
31
|
+
SpeechStatus,
|
|
32
|
+
)
|
|
33
|
+
from gspeech.speech import Speech
|
|
34
|
+
from gspeech.speech_segment import SpeechSegment
|
|
35
|
+
from gspeech.version import version
|
|
36
|
+
|
|
37
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
38
|
+
|
|
39
|
+
__version__ = version
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"LANGUAGES_OPTIONS",
|
|
43
|
+
"SUPPORTED_LANGUAGES",
|
|
44
|
+
"AudioBackend",
|
|
45
|
+
"AudioCache",
|
|
46
|
+
"GSpeechError",
|
|
47
|
+
"GoogleTranslateTTSClient",
|
|
48
|
+
"HTTPResponse",
|
|
49
|
+
"HTTPSession",
|
|
50
|
+
"LanguageCode",
|
|
51
|
+
"LanguageOption",
|
|
52
|
+
"MiniaudioBackend",
|
|
53
|
+
"PlaybackError",
|
|
54
|
+
"PlayerClosedError",
|
|
55
|
+
"Speech",
|
|
56
|
+
"SpeechHandle",
|
|
57
|
+
"SpeechPlayer",
|
|
58
|
+
"SpeechPolicy",
|
|
59
|
+
"SpeechResult",
|
|
60
|
+
"SpeechSegment",
|
|
61
|
+
"SpeechStatus",
|
|
62
|
+
"SynthesisError",
|
|
63
|
+
"Synthesizer",
|
|
64
|
+
"__version__",
|
|
65
|
+
"available_languages",
|
|
66
|
+
]
|
gspeech/__main__.py
ADDED
gspeech/_cache.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Small, privacy-preserving audio caches used internally by gspeech."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AudioCache(Protocol):
|
|
15
|
+
"""Storage interface used by the speech synthesizer."""
|
|
16
|
+
|
|
17
|
+
def get(self, key: str) -> bytes | None:
|
|
18
|
+
"""Return cached audio, or ``None`` when the key is absent or expired."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def set(self, key: str, audio_data: bytes) -> None:
|
|
22
|
+
"""Store encoded audio under an opaque key."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def clear(self) -> None:
|
|
26
|
+
"""Remove every cached item."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
def close(self) -> None:
|
|
30
|
+
"""Release resources owned by the cache."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NullAudioCache:
|
|
35
|
+
"""No-op cache used when persistence is disabled or unavailable."""
|
|
36
|
+
|
|
37
|
+
def get(self, key: str) -> None:
|
|
38
|
+
"""Always report a cache miss."""
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
def set(self, key: str, audio_data: bytes) -> None:
|
|
42
|
+
"""Discard audio data."""
|
|
43
|
+
|
|
44
|
+
def clear(self) -> None:
|
|
45
|
+
"""Do nothing."""
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
"""Do nothing."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SQLiteAudioCache:
|
|
52
|
+
"""Thread-safe SQLite cache whose keys contain no original speech text."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
path: Path,
|
|
57
|
+
*,
|
|
58
|
+
ttl_seconds: float,
|
|
59
|
+
max_entries: int,
|
|
60
|
+
) -> None:
|
|
61
|
+
if ttl_seconds <= 0:
|
|
62
|
+
raise ValueError("ttl_seconds must be greater than zero")
|
|
63
|
+
if max_entries <= 0:
|
|
64
|
+
raise ValueError("max_entries must be greater than zero")
|
|
65
|
+
|
|
66
|
+
self.path = path
|
|
67
|
+
self.ttl_seconds = ttl_seconds
|
|
68
|
+
self.max_entries = max_entries
|
|
69
|
+
self._lock = threading.RLock()
|
|
70
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
with self._connection() as connection:
|
|
72
|
+
connection.execute("PRAGMA journal_mode=WAL")
|
|
73
|
+
connection.execute(
|
|
74
|
+
"""
|
|
75
|
+
CREATE TABLE IF NOT EXISTS audio_cache (
|
|
76
|
+
key TEXT PRIMARY KEY,
|
|
77
|
+
audio BLOB NOT NULL,
|
|
78
|
+
created_at REAL NOT NULL,
|
|
79
|
+
accessed_at REAL NOT NULL
|
|
80
|
+
)
|
|
81
|
+
"""
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _connect(self) -> sqlite3.Connection:
|
|
85
|
+
return sqlite3.connect(self.path, timeout=5)
|
|
86
|
+
|
|
87
|
+
@contextmanager
|
|
88
|
+
def _connection(self) -> Iterator[sqlite3.Connection]:
|
|
89
|
+
connection = self._connect()
|
|
90
|
+
try:
|
|
91
|
+
with connection:
|
|
92
|
+
yield connection
|
|
93
|
+
finally:
|
|
94
|
+
connection.close()
|
|
95
|
+
|
|
96
|
+
def get(self, key: str) -> bytes | None:
|
|
97
|
+
now = time.time()
|
|
98
|
+
with self._lock, self._connection() as connection:
|
|
99
|
+
row = connection.execute(
|
|
100
|
+
"SELECT audio, created_at FROM audio_cache WHERE key = ?",
|
|
101
|
+
(key,),
|
|
102
|
+
).fetchone()
|
|
103
|
+
if row is None:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
audio_data, created_at = row
|
|
107
|
+
if now - float(created_at) > self.ttl_seconds:
|
|
108
|
+
connection.execute("DELETE FROM audio_cache WHERE key = ?", (key,))
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
connection.execute(
|
|
112
|
+
"UPDATE audio_cache SET accessed_at = ? WHERE key = ?",
|
|
113
|
+
(now, key),
|
|
114
|
+
)
|
|
115
|
+
return bytes(audio_data)
|
|
116
|
+
|
|
117
|
+
def set(self, key: str, audio_data: bytes) -> None:
|
|
118
|
+
now = time.time()
|
|
119
|
+
with self._lock, self._connection() as connection:
|
|
120
|
+
connection.execute(
|
|
121
|
+
"""
|
|
122
|
+
INSERT INTO audio_cache (key, audio, created_at, accessed_at)
|
|
123
|
+
VALUES (?, ?, ?, ?)
|
|
124
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
125
|
+
audio = excluded.audio,
|
|
126
|
+
created_at = excluded.created_at,
|
|
127
|
+
accessed_at = excluded.accessed_at
|
|
128
|
+
""",
|
|
129
|
+
(key, audio_data, now, now),
|
|
130
|
+
)
|
|
131
|
+
connection.execute(
|
|
132
|
+
"""
|
|
133
|
+
DELETE FROM audio_cache
|
|
134
|
+
WHERE key IN (
|
|
135
|
+
SELECT key
|
|
136
|
+
FROM audio_cache
|
|
137
|
+
ORDER BY accessed_at DESC
|
|
138
|
+
LIMIT -1 OFFSET ?
|
|
139
|
+
)
|
|
140
|
+
""",
|
|
141
|
+
(self.max_entries,),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def clear(self) -> None:
|
|
145
|
+
with self._lock, self._connection() as connection:
|
|
146
|
+
connection.execute("DELETE FROM audio_cache")
|
|
147
|
+
|
|
148
|
+
def close(self) -> None:
|
|
149
|
+
"""Connections are short-lived, so there is nothing to release."""
|