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.
@@ -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
+ [![CI](https://github.com/KarimAziev/gspeech/actions/workflows/ci.yml/badge.svg)](https://github.com/KarimAziev/gspeech/actions/workflows/ci.yml)
40
+ [![codecov](https://codecov.io/gh/KarimAziev/gspeech/graph/badge.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gspeech = gspeech.cli:main
@@ -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
@@ -0,0 +1,5 @@
1
+ """Support ``python -m gspeech``."""
2
+
3
+ from gspeech.cli import main
4
+
5
+ raise SystemExit(main())
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."""