lingolix 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.
lingolix/__init__.py ADDED
@@ -0,0 +1,326 @@
1
+ """Lingolix pronunciation API client.
2
+
3
+ from lingolix import Lingolix
4
+
5
+ with Lingolix() as client: # key from $LINGOLIX_API_KEY
6
+ r = client.check("audio.wav", sentence="Guten Morgen", language="de")
7
+ print(r.accuracy)
8
+
9
+ Async is the same with ``AsyncLingolix`` and ``await``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import mimetypes
16
+ import os
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, BinaryIO, Literal, TypeVar, Union
19
+
20
+ import httpx
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = [
25
+ "Lingolix",
26
+ "AsyncLingolix",
27
+ "PronunciationResult",
28
+ "Word",
29
+ "Syllable",
30
+ "LingolixError",
31
+ "AuthenticationError",
32
+ "SubscriptionError",
33
+ "InvalidAudioError",
34
+ "QuotaExceededError",
35
+ "ServiceUnavailableError",
36
+ "LANGUAGES",
37
+ "Pitch",
38
+ "MAX_UPLOAD_BYTES",
39
+ "__version__",
40
+ ]
41
+
42
+ DEFAULT_BASE_URL = "https://api.lingolix.com"
43
+ CHECK_PATH = "/api/pronunciation/v3/check"
44
+
45
+ #: Mirrors the server limit in the backend's pronunciation API.
46
+ MAX_UPLOAD_BYTES = 10 * 1024 * 1024
47
+
48
+ #: Languages accepted by the v3 endpoint.
49
+ LANGUAGES = (
50
+ "bg", "cs", "de", "el", "en", "es", "fi", "fr",
51
+ "hu", "it", "ja", "ko", "nl", "pl", "pt", "sv", "uk",
52
+ )
53
+
54
+ AudioInput = Union[str, "os.PathLike[str]", bytes, bytearray, BinaryIO]
55
+
56
+ #: Relative pitch of a syllable.
57
+ Pitch = Literal["high", "flat", "low", "unknown"]
58
+
59
+ T = TypeVar("T")
60
+
61
+
62
+ # --------------------------------------------------------------------------- errors
63
+
64
+
65
+ class LingolixError(Exception):
66
+ """Base class for every error this client raises."""
67
+
68
+ def __init__(
69
+ self,
70
+ message: str,
71
+ *,
72
+ status: int | None = None,
73
+ detail: Any = None,
74
+ ) -> None:
75
+ super().__init__(message)
76
+ self.message = message
77
+ self.status = status
78
+ self.detail = detail
79
+
80
+
81
+ class AuthenticationError(LingolixError):
82
+ """The API key is missing, malformed or unknown (HTTP 401)."""
83
+
84
+
85
+ class SubscriptionError(LingolixError):
86
+ """No active API subscription for this key (HTTP 403)."""
87
+
88
+
89
+ class InvalidAudioError(LingolixError):
90
+ """The audio could not be decoded, or was too large (HTTP 400 / 413)."""
91
+
92
+
93
+ class QuotaExceededError(LingolixError):
94
+ """The monthly quota is exhausted (HTTP 429)."""
95
+
96
+ def __init__(self, message: str, *, status: int | None = None, detail: Any = None) -> None:
97
+ super().__init__(message, status=status, detail=detail)
98
+ d = detail if isinstance(detail, dict) else {}
99
+ self.error: str | None = d.get("error")
100
+ self.plan: str | None = d.get("plan")
101
+ self.upgrade_url: str | None = d.get("upgrade_url")
102
+ self.remaining_minutes: float | None = d.get("remaining_minutes")
103
+ self.quota_percentage: float | None = d.get("quota_percentage")
104
+
105
+
106
+ class ServiceUnavailableError(LingolixError):
107
+ """The scoring service is temporarily unreachable (HTTP 503)."""
108
+
109
+
110
+ _ERRORS: dict[int, type[LingolixError]] = {
111
+ 400: InvalidAudioError,
112
+ 401: AuthenticationError,
113
+ 403: SubscriptionError,
114
+ 413: InvalidAudioError,
115
+ 429: QuotaExceededError,
116
+ 503: ServiceUnavailableError,
117
+ }
118
+
119
+
120
+ # --------------------------------------------------------------------------- results
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class Syllable:
125
+ """One syllable of a word, with expected vs. detected pronunciation."""
126
+
127
+ text: str
128
+ expected_ipa: str
129
+ detected_ipa: str
130
+ accuracy: float
131
+ completeness: float
132
+ pitch: Pitch
133
+ duration_ms: float
134
+ start_ms: float
135
+ end_ms: float
136
+ is_missing: bool
137
+ is_extra: bool
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class Word:
142
+ """One word, broken into syllables."""
143
+
144
+ text: str
145
+ syllables: list[Syllable]
146
+ accuracy: float
147
+ completeness: float
148
+ start_ms: float
149
+ end_ms: float
150
+ char_start: int = -1
151
+ char_end: int = -1
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class PronunciationResult:
156
+ """The scored utterance.
157
+
158
+ ``text`` is the reference the audio was scored against: the ``sentence`` you
159
+ passed, or the transcription when you passed none.
160
+ """
161
+
162
+ text: str
163
+ speaking_rate: float
164
+ accuracy: float
165
+ completeness: float
166
+ words: list[Word] = field(default_factory=list)
167
+
168
+
169
+ def _mk(cls: type[T], data: dict[str, Any]) -> T:
170
+ # ponytail: drop unknown keys so a new backend field doesn't break installed clients
171
+ names = {f.name for f in dataclasses.fields(cls)} # type: ignore[arg-type]
172
+ return cls(**{k: v for k, v in data.items() if k in names})
173
+
174
+
175
+ def _to_result(data: dict[str, Any]) -> PronunciationResult:
176
+ words = [
177
+ _mk(Word, {**w, "syllables": [_mk(Syllable, s) for s in w.get("syllables", [])]})
178
+ for w in data.get("words", [])
179
+ ]
180
+ return _mk(PronunciationResult, {**data, "words": words})
181
+
182
+
183
+ # --------------------------------------------------------------------------- clients
184
+
185
+
186
+ class _BaseClient:
187
+ def __init__(
188
+ self,
189
+ api_key: str | None = None,
190
+ *,
191
+ base_url: str | None = None,
192
+ timeout: float | httpx.Timeout | None = None,
193
+ ) -> None:
194
+ key = api_key or os.environ.get("LINGOLIX_API_KEY")
195
+ if not key:
196
+ raise LingolixError(
197
+ "No API key. Pass Lingolix(api_key=...) or set the LINGOLIX_API_KEY "
198
+ "environment variable. Create a key at https://lingolix.com/api-portal"
199
+ )
200
+ self.api_key = key
201
+ self.base_url = (
202
+ base_url or os.environ.get("LINGOLIX_BASE_URL") or DEFAULT_BASE_URL
203
+ ).rstrip("/")
204
+ # Scoring is slow; a short default timeout would just cause spurious failures.
205
+ self.timeout = timeout if timeout is not None else httpx.Timeout(120.0, connect=10.0)
206
+
207
+ @property
208
+ def _headers(self) -> dict[str, str]:
209
+ return {
210
+ "Authorization": f"Bearer {self.api_key}",
211
+ "User-Agent": f"lingolix-python/{__version__}",
212
+ }
213
+
214
+ @staticmethod
215
+ def _multipart(
216
+ audio: AudioInput, sentence: str | None, language: str
217
+ ) -> tuple[dict[str, Any], dict[str, str]]:
218
+ if isinstance(audio, (bytes, bytearray)):
219
+ content: Any = bytes(audio)
220
+ name, mime = "audio.wav", "application/octet-stream"
221
+ elif isinstance(audio, (str, os.PathLike)):
222
+ path = os.fspath(audio)
223
+ with open(path, "rb") as fh:
224
+ content = fh.read()
225
+ name = os.path.basename(path) or "audio.wav"
226
+ mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
227
+ else: # file-like
228
+ content = audio.read()
229
+ name = os.path.basename(getattr(audio, "name", "") or "") or "audio.wav"
230
+ mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
231
+
232
+ if len(content) > MAX_UPLOAD_BYTES:
233
+ raise InvalidAudioError(
234
+ f"Audio is {len(content) / 1048576:.1f} MiB, over the "
235
+ f"{MAX_UPLOAD_BYTES // 1048576} MiB limit. Send a compressed format "
236
+ "(MP3, Opus/WebM, AAC) instead of WAV — a single utterance is well "
237
+ "under 1 MiB when encoded."
238
+ )
239
+
240
+ data = {"language_code": language}
241
+ if sentence is not None:
242
+ data["sentence"] = sentence
243
+ return {"speechdata": (name, content, mime)}, data
244
+
245
+ @staticmethod
246
+ def _parse(response: httpx.Response) -> PronunciationResult:
247
+ if response.status_code == 200:
248
+ return _to_result(response.json())
249
+
250
+ try:
251
+ # FastAPI puts a string here normally, and a dict for the 429 quota payload.
252
+ detail = response.json().get("detail")
253
+ except ValueError:
254
+ detail = response.text or None
255
+
256
+ if isinstance(detail, dict):
257
+ message = detail.get("message") or detail.get("error") or str(detail)
258
+ else:
259
+ message = detail or f"HTTP {response.status_code}"
260
+
261
+ cls = _ERRORS.get(response.status_code, LingolixError)
262
+ raise cls(str(message), status=response.status_code, detail=detail)
263
+
264
+
265
+ class Lingolix(_BaseClient):
266
+ """Synchronous client. Usable as a context manager."""
267
+
268
+ def __init__(self, api_key: str | None = None, **kwargs: Any) -> None:
269
+ super().__init__(api_key, **kwargs)
270
+ self._client = httpx.Client(
271
+ base_url=self.base_url, headers=self._headers, timeout=self.timeout
272
+ )
273
+
274
+ def check(
275
+ self,
276
+ audio: AudioInput,
277
+ *,
278
+ sentence: str | None = None,
279
+ language: str = "ja",
280
+ ) -> PronunciationResult:
281
+ """Score ``audio``.
282
+
283
+ Pass ``sentence`` to score against known text; omit it to have the audio
284
+ transcribed first and scored against its own transcription.
285
+ """
286
+ files, data = self._multipart(audio, sentence, language)
287
+ return self._parse(self._client.post(CHECK_PATH, files=files, data=data))
288
+
289
+ def close(self) -> None:
290
+ self._client.close()
291
+
292
+ def __enter__(self) -> Lingolix:
293
+ return self
294
+
295
+ def __exit__(self, *exc: Any) -> None:
296
+ self.close()
297
+
298
+
299
+ class AsyncLingolix(_BaseClient):
300
+ """Asynchronous client. Usable as an async context manager."""
301
+
302
+ def __init__(self, api_key: str | None = None, **kwargs: Any) -> None:
303
+ super().__init__(api_key, **kwargs)
304
+ self._client = httpx.AsyncClient(
305
+ base_url=self.base_url, headers=self._headers, timeout=self.timeout
306
+ )
307
+
308
+ async def check(
309
+ self,
310
+ audio: AudioInput,
311
+ *,
312
+ sentence: str | None = None,
313
+ language: str = "ja",
314
+ ) -> PronunciationResult:
315
+ """Score ``audio``. See :meth:`Lingolix.check`."""
316
+ files, data = self._multipart(audio, sentence, language)
317
+ return self._parse(await self._client.post(CHECK_PATH, files=files, data=data))
318
+
319
+ async def aclose(self) -> None:
320
+ await self._client.aclose()
321
+
322
+ async def __aenter__(self) -> AsyncLingolix:
323
+ return self
324
+
325
+ async def __aexit__(self, *exc: Any) -> None:
326
+ await self.aclose()
lingolix/py.typed ADDED
File without changes
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.5
2
+ Name: lingolix
3
+ Version: 0.1.0
4
+ Summary: Python client for the Lingolix pronunciation scoring API
5
+ Project-URL: Homepage, https://lingolix.com
6
+ Project-URL: Documentation, https://lingolix.com/api-portal/docs
7
+ Project-URL: Source, https://github.com/lingolix/lingolix-sdk
8
+ Project-URL: Issues, https://github.com/lingolix/lingolix-sdk/issues
9
+ Author-email: Lingolix <info@lingolix.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ipa,language-learning,phonetics,pronunciation,speech
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: httpx>=0.27
24
+ Description-Content-Type: text/markdown
25
+
26
+ # lingolix
27
+
28
+ Python client for the [Lingolix](https://lingolix.com) pronunciation scoring API. Send a
29
+ recording, get back per-syllable accuracy with expected vs. detected IPA, pitch and
30
+ timing — in 17 languages.
31
+
32
+ Get an API key at [lingolix.com/api-portal](https://lingolix.com/api-portal), then set
33
+ `LINGOLIX_API_KEY` in your environment. Keep it server-side — never ship it to a browser.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install lingolix # or: uv add lingolix
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ```python
44
+ from lingolix import Lingolix
45
+
46
+ with Lingolix() as client: # key from $LINGOLIX_API_KEY
47
+ r = client.check("audio.wav", sentence="Guten Morgen", language="de")
48
+
49
+ print(r.accuracy, r.completeness, r.speaking_rate)
50
+ for word in r.words:
51
+ for s in word.syllables:
52
+ print(s.text, s.expected_ipa, "→", s.detected_ipa, s.pitch, round(s.accuracy, 2))
53
+ ```
54
+
55
+ Async is the same class with an `Async` prefix:
56
+
57
+ ```python
58
+ from lingolix import AsyncLingolix
59
+
60
+ async with AsyncLingolix() as client:
61
+ r = await client.check(audio_bytes, sentence="こんにちは", language="ja")
62
+ ```
63
+
64
+ `check()` takes a path, `bytes`, or any file-like object. Drop `sentence` and the audio is
65
+ transcribed first, then scored against its own transcription — useful for free-form speech.
66
+
67
+ ## Result shape
68
+
69
+ | Field | Meaning |
70
+ | --- | --- |
71
+ | `text` | What the audio was scored against — your `sentence`, or the transcription |
72
+ | `accuracy` | Overall pronunciation accuracy, 0–1 |
73
+ | `completeness` | How much of the expected utterance was actually said, 0–1 |
74
+ | `speaking_rate` | Syllables per second |
75
+ | `words[]` | `text`, `accuracy`, `completeness`, `start_ms`, `end_ms`, `char_start`, `char_end` |
76
+ | `words[].syllables[]` | `text`, `expected_ipa`, `detected_ipa`, `accuracy`, `completeness`, `pitch`, `duration_ms`, `start_ms`, `end_ms`, `is_missing`, `is_extra` |
77
+
78
+ `pitch` is one of `"high"`, `"flat"`, `"low"`, `"unknown"`.
79
+
80
+ ## Errors
81
+
82
+ Every failure is a subclass of `LingolixError` carrying `.status` and `.detail`.
83
+
84
+ | Class | HTTP | Cause |
85
+ | --- | --- | --- |
86
+ | `InvalidAudioError` | 400, 413 | Undecodable audio, or over the 10 MiB limit |
87
+ | `AuthenticationError` | 401 | Missing, malformed or unknown API key |
88
+ | `SubscriptionError` | 403 | No active API subscription |
89
+ | `QuotaExceededError` | 429 | Monthly quota exhausted |
90
+ | `ServiceUnavailableError` | 503 | Scoring service temporarily down |
91
+
92
+ `QuotaExceededError` also carries `plan`, `remaining_minutes`, `quota_percentage` and
93
+ `upgrade_url`:
94
+
95
+ ```python
96
+ from lingolix import Lingolix, QuotaExceededError
97
+
98
+ try:
99
+ r = Lingolix().check("audio.wav", language="en")
100
+ except QuotaExceededError as e:
101
+ print(f"{e.plan} plan is out of minutes — upgrade at {e.upgrade_url}")
102
+ ```
103
+
104
+ ## Languages
105
+
106
+ `bg cs de el en es fi fr hu it ja ko nl pl pt sv uk` — the default is `ja`.
107
+
108
+ ## Audio
109
+
110
+ Anything ffmpeg can read: WAV, MP3, M4A, OGG/Opus, FLAC, WebM. Uploads are capped at
111
+ 10 MiB, checked client-side before sending. Prefer a compressed format — a single utterance
112
+ is well under 1 MiB as MP3 or Opus.
113
+
114
+ ## Links
115
+
116
+ - [TypeScript client](https://www.npmjs.com/package/@lingolix/sdk)
117
+ - [API documentation](https://lingolix.com/api-portal/docs)
118
+ - [Pricing](https://lingolix.com/api-portal/pricing)
119
+ - [Source and issues](https://github.com/lingolix/lingolix-sdk)
120
+
121
+ MIT licensed.
@@ -0,0 +1,6 @@
1
+ lingolix/__init__.py,sha256=R7FVS4ANDOBz145vAhClAICFU6RicJwMWWW6NYH2-eo,9981
2
+ lingolix/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ lingolix-0.1.0.dist-info/METADATA,sha256=eSvS1X10Eotsym_Tu-qbPOIiASweG5z-N1LsngoQPDc,4367
4
+ lingolix-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
5
+ lingolix-0.1.0.dist-info/licenses/LICENSE,sha256=lNSXxmSbu2hVRSpqHGQyBK52fDWQf-tnlNjMcfEM_7M,1065
6
+ lingolix-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lingolix
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.