vocalbin 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.
vocalbin/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ from .clients import OpenAISpeechToText, OpenAITextToSpeech
2
+ from .credentials import OpenAICredentials
3
+ from .models import (
4
+ SpeechToTextFormat,
5
+ SpeechToTextModel,
6
+ SpeechToTextRequest,
7
+ SpeechToTextResponse,
8
+ TextToSpeechFormat,
9
+ TextToSpeechModel,
10
+ TextToSpeechRequest,
11
+ TextToSpeechResponse,
12
+ TextToSpeechVoice,
13
+ TimestampGranularity,
14
+ )
15
+ from .ports import SpeechToText, TextToSpeech
16
+
17
+ __all__ = [
18
+ "OpenAICredentials",
19
+ "OpenAISpeechToText",
20
+ "OpenAITextToSpeech",
21
+ "SpeechToText",
22
+ "SpeechToTextFormat",
23
+ "SpeechToTextModel",
24
+ "SpeechToTextRequest",
25
+ "SpeechToTextResponse",
26
+ "TextToSpeech",
27
+ "TextToSpeechFormat",
28
+ "TextToSpeechModel",
29
+ "TextToSpeechRequest",
30
+ "TextToSpeechResponse",
31
+ "TextToSpeechVoice",
32
+ "TimestampGranularity",
33
+ ]
vocalbin/clients.py ADDED
@@ -0,0 +1,131 @@
1
+ from types import TracebackType
2
+ from typing import Any, Self, cast
3
+
4
+ from openai import AsyncOpenAI, omit
5
+ from openai.types.audio import TranscriptionCreateResponse
6
+
7
+ from vocalbin.credentials import OpenAICredentials
8
+ from vocalbin.models import (
9
+ SpeechToTextRequest,
10
+ SpeechToTextResponse,
11
+ TextToSpeechRequest,
12
+ TextToSpeechResponse,
13
+ )
14
+ from vocalbin.ports import SpeechToText, TextToSpeech
15
+
16
+ _CONTENT_TYPES = {
17
+ "mp3": "audio/mpeg",
18
+ "opus": "audio/opus",
19
+ "aac": "audio/aac",
20
+ "flac": "audio/flac",
21
+ "wav": "audio/wav",
22
+ "pcm": "audio/pcm",
23
+ }
24
+
25
+ type TranscriptionResult = TranscriptionCreateResponse | str
26
+
27
+
28
+ class _OpenAIClientOwner:
29
+ def __init__(self, api_key: str | None, client: AsyncOpenAI | None) -> None:
30
+ if api_key is not None and client is not None:
31
+ raise ValueError("Pass either 'api_key' or 'client', not both.")
32
+ if client is not None:
33
+ self.client = client
34
+ else:
35
+ resolved_api_key = (
36
+ api_key
37
+ if api_key is not None
38
+ else OpenAICredentials().api_key.get_secret_value()
39
+ )
40
+ self.client = AsyncOpenAI(api_key=resolved_api_key)
41
+ self._owns_client = client is None
42
+
43
+ async def aclose(self) -> None:
44
+ if self._owns_client:
45
+ await self.client.close()
46
+
47
+ async def __aenter__(self) -> Self:
48
+ return self
49
+
50
+ async def __aexit__(
51
+ self,
52
+ exc_type: type[BaseException] | None,
53
+ exc_value: BaseException | None,
54
+ traceback: TracebackType | None,
55
+ ) -> None:
56
+ await self.aclose()
57
+
58
+
59
+ class OpenAITextToSpeech(_OpenAIClientOwner, TextToSpeech):
60
+ def __init__(
61
+ self,
62
+ api_key: str | None = None,
63
+ *,
64
+ client: AsyncOpenAI | None = None,
65
+ ) -> None:
66
+ super().__init__(api_key, client)
67
+
68
+ async def synthesize(self, request: TextToSpeechRequest) -> TextToSpeechResponse:
69
+ result = await self.client.audio.speech.create(
70
+ input=request.text,
71
+ model=request.model,
72
+ voice=request.voice,
73
+ instructions=request.instructions if request.instructions is not None else omit,
74
+ response_format=request.response_format,
75
+ speed=request.speed if request.speed is not None else omit,
76
+ )
77
+
78
+ return TextToSpeechResponse(
79
+ audio=result.content,
80
+ model=request.model,
81
+ voice=request.voice,
82
+ response_format=request.response_format,
83
+ content_type=_CONTENT_TYPES[request.response_format],
84
+ )
85
+
86
+
87
+ class OpenAISpeechToText(_OpenAIClientOwner, SpeechToText):
88
+ def __init__(
89
+ self,
90
+ api_key: str | None = None,
91
+ *,
92
+ client: AsyncOpenAI | None = None,
93
+ ) -> None:
94
+ super().__init__(api_key, client)
95
+
96
+ async def transcribe(self, request: SpeechToTextRequest) -> SpeechToTextResponse:
97
+ params = request.to_openai_params()
98
+
99
+ if request.audio is not None:
100
+ file = (request.filename, request.audio)
101
+ result = cast(
102
+ TranscriptionResult,
103
+ await self.client.audio.transcriptions.create(file=file, **params),
104
+ )
105
+ else:
106
+ audio_path = request.audio_path
107
+ assert audio_path is not None
108
+ with audio_path.open("rb") as audio_file:
109
+ result = cast(
110
+ TranscriptionResult,
111
+ await self.client.audio.transcriptions.create(file=audio_file, **params),
112
+ )
113
+
114
+ return SpeechToTextResponse(
115
+ text=_extract_text(result),
116
+ model=request.model,
117
+ response_format=request.response_format,
118
+ raw=_serialize_result(result),
119
+ )
120
+
121
+
122
+ def _extract_text(result: TranscriptionResult) -> str:
123
+ if isinstance(result, str):
124
+ return result
125
+ return result.text
126
+
127
+
128
+ def _serialize_result(result: TranscriptionResult) -> dict[str, Any] | str:
129
+ if isinstance(result, str):
130
+ return result
131
+ return result.model_dump(mode="python")
@@ -0,0 +1,8 @@
1
+ from pydantic import Field, SecretStr
2
+ from pydantic_settings import BaseSettings, SettingsConfigDict
3
+
4
+
5
+ class OpenAICredentials(BaseSettings):
6
+ model_config = SettingsConfigDict(env_prefix="OPENAI_", extra="ignore")
7
+
8
+ api_key: SecretStr = Field(min_length=1)
vocalbin/models.py ADDED
@@ -0,0 +1,230 @@
1
+ from enum import StrEnum
2
+ from pathlib import Path
3
+ from typing import Any, Literal, Self
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
6
+
7
+
8
+ class SpeechToTextModel(StrEnum):
9
+ GPT_4O_TRANSCRIBE = "gpt-4o-transcribe"
10
+ GPT_4O_MINI_TRANSCRIBE = "gpt-4o-mini-transcribe"
11
+ GPT_4O_TRANSCRIBE_DIARIZE = "gpt-4o-transcribe-diarize"
12
+ WHISPER_1 = "whisper-1"
13
+
14
+
15
+ class SpeechToTextFormat(StrEnum):
16
+ JSON = "json"
17
+ TEXT = "text"
18
+ SRT = "srt"
19
+ VERBOSE_JSON = "verbose_json"
20
+ VTT = "vtt"
21
+ DIARIZED_JSON = "diarized_json"
22
+
23
+
24
+ class TimestampGranularity(StrEnum):
25
+ WORD = "word"
26
+ SEGMENT = "segment"
27
+
28
+
29
+ class TextToSpeechModel(StrEnum):
30
+ GPT_4O_MINI_TTS = "gpt-4o-mini-tts"
31
+ TTS_1 = "tts-1"
32
+ TTS_1_HD = "tts-1-hd"
33
+
34
+
35
+ class TextToSpeechVoice(StrEnum):
36
+ ALLOY = "alloy"
37
+ ASH = "ash"
38
+ BALLAD = "ballad"
39
+ CORAL = "coral"
40
+ ECHO = "echo"
41
+ FABLE = "fable"
42
+ NOVA = "nova"
43
+ ONYX = "onyx"
44
+ SAGE = "sage"
45
+ SHIMMER = "shimmer"
46
+ VERSE = "verse"
47
+ MARIN = "marin"
48
+ CEDAR = "cedar"
49
+
50
+
51
+ class TextToSpeechFormat(StrEnum):
52
+ MP3 = "mp3"
53
+ OPUS = "opus"
54
+ AAC = "aac"
55
+ FLAC = "flac"
56
+ WAV = "wav"
57
+ PCM = "pcm"
58
+
59
+
60
+ class SpeechToTextRequest(BaseModel):
61
+ model_config = ConfigDict(extra="forbid")
62
+
63
+ audio_path: Path | None = None
64
+ audio: bytes | None = Field(default=None, min_length=1, repr=False)
65
+ filename: str = Field(default="utterance.wav", min_length=1)
66
+ model: SpeechToTextModel = SpeechToTextModel.GPT_4O_TRANSCRIBE
67
+ response_format: SpeechToTextFormat = SpeechToTextFormat.JSON
68
+ language: str | None = Field(
69
+ default=None,
70
+ description="Optional ISO-639-1 language code such as 'de' or 'en'.",
71
+ )
72
+ prompt: str | None = None
73
+ temperature: float | None = Field(default=None, ge=0, le=1)
74
+ timestamp_granularities: list[TimestampGranularity] | None = None
75
+ include: list[Literal["logprobs"]] | None = None
76
+ chunking_strategy: Literal["auto"] | dict[str, Any] | None = None
77
+ extra_body: dict[str, Any] | None = None
78
+
79
+ @field_validator("audio_path")
80
+ @classmethod
81
+ def audio_path_must_be_a_file(cls, value: Path | None) -> Path | None:
82
+ if value is None:
83
+ return None
84
+ if not value.exists():
85
+ raise ValueError(f"Audio file does not exist: {value}")
86
+ if not value.is_file():
87
+ raise ValueError(f"Audio path is not a file: {value}")
88
+ return value
89
+
90
+ @field_validator("filename")
91
+ @classmethod
92
+ def filename_must_not_be_blank(cls, value: str) -> str:
93
+ if not value.strip():
94
+ raise ValueError("filename must not be blank")
95
+ return value
96
+
97
+ @field_validator("language")
98
+ @classmethod
99
+ def language_must_be_iso_639_1(cls, value: str | None) -> str | None:
100
+ if value is None:
101
+ return None
102
+ normalized = value.strip().lower()
103
+ if len(normalized) != 2 or not normalized.isalpha() or not normalized.isascii():
104
+ raise ValueError("language must be an ISO-639-1 code like 'de' or 'en'")
105
+ return normalized
106
+
107
+ @model_validator(mode="after")
108
+ def exactly_one_audio_source(self) -> Self:
109
+ if (self.audio_path is None) == (self.audio is None):
110
+ raise ValueError("Provide exactly one of 'audio_path' or 'audio'.")
111
+ return self
112
+
113
+ @model_validator(mode="after")
114
+ def validate_model_capabilities(self) -> Self:
115
+ gpt_transcribe_models = {
116
+ SpeechToTextModel.GPT_4O_TRANSCRIBE,
117
+ SpeechToTextModel.GPT_4O_MINI_TRANSCRIBE,
118
+ }
119
+
120
+ if self.model in gpt_transcribe_models:
121
+ supported = {SpeechToTextFormat.JSON, SpeechToTextFormat.TEXT}
122
+ if self.response_format not in supported:
123
+ raise ValueError(f"{self.model} supports only response_format json or text")
124
+
125
+ if self.model == SpeechToTextModel.GPT_4O_TRANSCRIBE_DIARIZE:
126
+ supported = {
127
+ SpeechToTextFormat.JSON,
128
+ SpeechToTextFormat.TEXT,
129
+ SpeechToTextFormat.DIARIZED_JSON,
130
+ }
131
+ if self.response_format not in supported:
132
+ raise ValueError(
133
+ f"{self.model} supports only response_format json, text, or diarized_json"
134
+ )
135
+ if self.prompt is not None:
136
+ raise ValueError(f"{self.model} does not support prompt")
137
+ if self.include is not None:
138
+ raise ValueError(f"{self.model} does not support include/logprobs")
139
+ if self.timestamp_granularities is not None:
140
+ raise ValueError(f"{self.model} does not support timestamp_granularities")
141
+
142
+ if self.model == SpeechToTextModel.WHISPER_1:
143
+ supported = {
144
+ SpeechToTextFormat.JSON,
145
+ SpeechToTextFormat.TEXT,
146
+ SpeechToTextFormat.SRT,
147
+ SpeechToTextFormat.VERBOSE_JSON,
148
+ SpeechToTextFormat.VTT,
149
+ }
150
+ if self.response_format not in supported:
151
+ raise ValueError(
152
+ f"{self.model} supports only response_format json, text, srt, "
153
+ "verbose_json, or vtt"
154
+ )
155
+
156
+ if self.timestamp_granularities is not None:
157
+ if self.model != SpeechToTextModel.WHISPER_1:
158
+ raise ValueError("timestamp_granularities is supported only by whisper-1")
159
+ if self.response_format != SpeechToTextFormat.VERBOSE_JSON:
160
+ raise ValueError("timestamp_granularities requires response_format='verbose_json'")
161
+
162
+ if self.include is not None:
163
+ if self.model not in gpt_transcribe_models:
164
+ raise ValueError("include/logprobs is supported only by GPT transcription models")
165
+ if self.response_format != SpeechToTextFormat.JSON:
166
+ raise ValueError("include/logprobs requires response_format='json'")
167
+
168
+ return self
169
+
170
+ def to_openai_params(self) -> dict[str, Any]:
171
+ return self.model_dump(
172
+ exclude={"audio_path", "audio", "filename"},
173
+ exclude_none=True,
174
+ mode="json",
175
+ )
176
+
177
+
178
+ class SpeechToTextResponse(BaseModel):
179
+ model_config = ConfigDict(extra="allow")
180
+
181
+ text: str
182
+ model: SpeechToTextModel
183
+ response_format: SpeechToTextFormat
184
+ raw: dict[str, Any] | str
185
+
186
+
187
+ class TextToSpeechRequest(BaseModel):
188
+ model_config = ConfigDict(extra="forbid")
189
+
190
+ text: str = Field(min_length=1, max_length=4096)
191
+ model: TextToSpeechModel = TextToSpeechModel.GPT_4O_MINI_TTS
192
+ voice: TextToSpeechVoice = TextToSpeechVoice.MARIN
193
+ response_format: TextToSpeechFormat = TextToSpeechFormat.MP3
194
+ instructions: str | None = None
195
+ speed: float | None = Field(default=None, ge=0.25, le=4.0)
196
+
197
+ @field_validator("text")
198
+ @classmethod
199
+ def text_must_not_be_blank(cls, value: str) -> str:
200
+ if not value.strip():
201
+ raise ValueError("text must not be blank")
202
+ return value
203
+
204
+ @model_validator(mode="after")
205
+ def validate_model_capabilities(self) -> Self:
206
+ legacy_models = {TextToSpeechModel.TTS_1, TextToSpeechModel.TTS_1_HD}
207
+ legacy_voices = {
208
+ TextToSpeechVoice.ALLOY,
209
+ TextToSpeechVoice.ASH,
210
+ TextToSpeechVoice.CORAL,
211
+ TextToSpeechVoice.ECHO,
212
+ TextToSpeechVoice.FABLE,
213
+ TextToSpeechVoice.ONYX,
214
+ TextToSpeechVoice.NOVA,
215
+ TextToSpeechVoice.SAGE,
216
+ TextToSpeechVoice.SHIMMER,
217
+ }
218
+ if self.model in legacy_models and self.voice not in legacy_voices:
219
+ raise ValueError(f"{self.model} does not support voice '{self.voice}'")
220
+ if self.model in legacy_models and self.instructions is not None:
221
+ raise ValueError(f"{self.model} does not support instructions")
222
+ return self
223
+
224
+
225
+ class TextToSpeechResponse(BaseModel):
226
+ audio: bytes
227
+ model: TextToSpeechModel
228
+ voice: TextToSpeechVoice
229
+ response_format: TextToSpeechFormat
230
+ content_type: str
vocalbin/ports.py ADDED
@@ -0,0 +1,18 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from vocalbin.models import (
4
+ SpeechToTextRequest,
5
+ SpeechToTextResponse,
6
+ TextToSpeechRequest,
7
+ TextToSpeechResponse,
8
+ )
9
+
10
+
11
+ class SpeechToText(ABC):
12
+ @abstractmethod
13
+ async def transcribe(self, request: SpeechToTextRequest) -> SpeechToTextResponse: ...
14
+
15
+
16
+ class TextToSpeech(ABC):
17
+ @abstractmethod
18
+ async def synthesize(self, request: TextToSpeechRequest) -> TextToSpeechResponse: ...
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: vocalbin
3
+ Version: 0.1.0
4
+ Summary: Typed async wrappers for OpenAI speech-to-text and text-to-speech APIs
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: openai>=2.46.0
8
+ Requires-Dist: pydantic-settings>=2.14.2
9
+ Requires-Dist: pydantic>=2.13.4
10
+ Description-Content-Type: text/markdown
11
+
12
+ # 🎙️ vocalbin
13
+
14
+ ![vocalbin — typed, async voice for OpenAI](static/banner.png)
15
+
16
+ `vocalbin` is a small, typed, asynchronous wrapper around OpenAI's speech-to-text
17
+ and text-to-speech endpoints. It validates model capabilities up front, normalizes
18
+ responses without discarding raw data, and stays independent of any
19
+ application-specific settings or domain code.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ uv add vocalbin
25
+ ```
26
+
27
+ Set `OPENAI_API_KEY` in the environment, or pass an API key directly when creating
28
+ a service. The default path reads the environment through `OpenAICredentials`:
29
+
30
+ ```python
31
+ from vocalbin import OpenAICredentials
32
+
33
+ credentials = OpenAICredentials()
34
+ api_key = credentials.api_key.get_secret_value()
35
+ ```
36
+
37
+ An explicit `api_key` takes precedence over the environment. An injected
38
+ `AsyncOpenAI` client does not load credentials at all.
39
+
40
+ ## Speech to text
41
+
42
+ ```python
43
+ from pathlib import Path
44
+
45
+ from vocalbin import OpenAISpeechToText, SpeechToTextRequest
46
+
47
+
48
+ async def transcribe() -> str:
49
+ async with OpenAISpeechToText() as speech_to_text:
50
+ response = await speech_to_text.transcribe(
51
+ SpeechToTextRequest(audio_path=Path("speech.wav"), language="de")
52
+ )
53
+ return response.text
54
+ ```
55
+
56
+ Audio can also be supplied directly as bytes; `filename` only sets the multipart
57
+ upload name:
58
+
59
+ ```python
60
+ request = SpeechToTextRequest(audio=audio_bytes, filename="speech.wav")
61
+ ```
62
+
63
+ Every request carries the transcript on `response.text` and the untouched provider
64
+ payload on `response.raw` (a `dict` for JSON-like formats, a `str` for `text`,
65
+ `srt` and `vtt`).
66
+
67
+ ## Text to speech
68
+
69
+ ```python
70
+ from vocalbin import (
71
+ OpenAITextToSpeech,
72
+ TextToSpeechFormat,
73
+ TextToSpeechRequest,
74
+ TextToSpeechVoice,
75
+ )
76
+
77
+
78
+ async def synthesize() -> bytes:
79
+ async with OpenAITextToSpeech() as text_to_speech:
80
+ response = await text_to_speech.synthesize(
81
+ TextToSpeechRequest(
82
+ text="Hallo aus vocalbin!",
83
+ voice=TextToSpeechVoice.MARIN,
84
+ response_format=TextToSpeechFormat.MP3,
85
+ instructions="Sprich ruhig und freundlich.",
86
+ )
87
+ )
88
+ return response.audio
89
+ ```
90
+
91
+ `response.content_type` gives the matching MIME type (e.g. `audio/mpeg`).
92
+
93
+ ## Supported models, voices and formats
94
+
95
+ **Speech to text** — `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`,
96
+ `gpt-4o-transcribe-diarize`, `whisper-1`. Response formats and options are
97
+ validated per model (for example, `timestamp_granularities` require `whisper-1`
98
+ with `verbose_json`, and `include=["logprobs"]` requires a GPT transcription model
99
+ with `json`).
100
+
101
+ **Text to speech** — `gpt-4o-mini-tts`, `tts-1`, `tts-1-hd`; output formats `mp3`,
102
+ `opus`, `aac`, `flac`, `wav`, `pcm`. The legacy `tts-1`/`tts-1-hd` models accept
103
+ only the legacy voices and do not support `instructions`.
104
+
105
+ ## Examples
106
+
107
+ The [`examples/`](examples/) directory holds runnable, integration-testable scripts
108
+ that exercise every model/voice/format combination and double as documentation.
109
+ With a valid `OPENAI_API_KEY` set:
110
+
111
+ ```bash
112
+ uv run python examples/text_to_speech.py # every TTS model, voice and format
113
+ uv run python examples/speech_to_text.py # every STT model and response format
114
+ uv run python examples/round_trip.py # synthesize -> transcribe, self-checking
115
+ uv run python examples/shared_client.py # one AsyncOpenAI client for both services
116
+ ```
117
+
118
+ Generated audio and transcripts are written to `examples/output/` (git-ignored).
119
+ `speech_to_text.py` synthesizes its own `sample.wav` on first run, so it needs no
120
+ external audio file.
121
+
122
+ ## Bring your own client
123
+
124
+ Both concrete services accept an existing `AsyncOpenAI` instance via `client=`,
125
+ which lets you share one configured client (custom `base_url`, timeouts, retries)
126
+ across both services. Injected clients remain owned by the caller and are not
127
+ closed by `vocalbin`:
128
+
129
+ ```python
130
+ from openai import AsyncOpenAI
131
+
132
+ from vocalbin import OpenAISpeechToText, OpenAITextToSpeech
133
+
134
+ client = AsyncOpenAI()
135
+ tts = OpenAITextToSpeech(client=client)
136
+ stt = OpenAISpeechToText(client=client)
137
+ # ... use both, then close it yourself:
138
+ await client.close()
139
+ ```
140
+
141
+ ## Ports
142
+
143
+ The provider-independent `SpeechToText` and `TextToSpeech` ports are abstract base
144
+ classes (`vocalbin/ports.py`). They mark the boundary of the library, so callers
145
+ can depend on the interface rather than the OpenAI implementation.
146
+
147
+ ## Development
148
+
149
+ ```bash
150
+ uv sync
151
+ uv run pytest
152
+ ```
@@ -0,0 +1,9 @@
1
+ vocalbin/__init__.py,sha256=WjVUZtzKobX1tgQhoVnDJ_6NCz_cIevS9hjeaPOCQNs,815
2
+ vocalbin/clients.py,sha256=UIJoFPW7WbPIe0zEpjVwoxE5JHGVF9TFCmWbubfyn9I,4086
3
+ vocalbin/credentials.py,sha256=0yv8mkRpmf_hyZyCwPpRgrF674CFKxm7DGhGBwg73V8,264
4
+ vocalbin/models.py,sha256=VADhgJjJkW_kt7l2J3_u7so64lzYdko4jzID-d_4drI,8036
5
+ vocalbin/ports.py,sha256=RrJRhF8pMSwTqv-TNKOBMzipy6GqepeceLepszAxcRQ,445
6
+ vocalbin-0.1.0.dist-info/METADATA,sha256=mJ5YBhVIpRNkUmPkXn2DUENlqQOiEYP1rT6cl6YTeUc,4748
7
+ vocalbin-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
+ vocalbin-0.1.0.dist-info/licenses/LICENSE,sha256=pcOfaJZcA1iM-OJrov7e6muy7zqtVvbl4CMAcyFD6rU,1069
9
+ vocalbin-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mathisarends
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.