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 +33 -0
- vocalbin/clients.py +131 -0
- vocalbin/credentials.py +8 -0
- vocalbin/models.py +230 -0
- vocalbin/ports.py +18 -0
- vocalbin-0.1.0.dist-info/METADATA +152 -0
- vocalbin-0.1.0.dist-info/RECORD +9 -0
- vocalbin-0.1.0.dist-info/WHEEL +4 -0
- vocalbin-0.1.0.dist-info/licenses/LICENSE +21 -0
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")
|
vocalbin/credentials.py
ADDED
|
@@ -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
|
+

|
|
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,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.
|