Skip to content

Commit 6772e46

Browse files
committed
release of v3
* major update * Async HTTP Requests: Switched from synchronous requests to aiohttp for better response. Chime option: Added option to play a chime prior the TTS voice, useful for announcements. Added options flow: voice, speed and chime are now configurable on the existing entries. Unique IDs: Integration now will create unique id's even for the same TTS engine.
1 parent eb63679 commit 6772e46

File tree

11 files changed

+401
-101
lines changed

11 files changed

+401
-101
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ The OpenAI TTS component for Home Assistant makes it possible to use the OpenAI
1111

1212
- Text-to-Speech conversion using OpenAI's API
1313
- Support for multiple languages and voices
14+
- Chime option. Usefull for announcements.
1415
- Customizable speech model (check https://platform.openai.com/docs/guides/text-to-speech for supported voices and models)
1516
- Integration with Home Assistant's assistant, automations and scripts
1617

17-
## YouTube sample video
18+
## YouTube sample video (its not a tutorial!)
1819

1920
[![OpenAI TTS Demo](https://img.youtube.com/vi/oeeypI_X0qs/0.jpg)](https://www.youtube.com/watch?v=oeeypI_X0qs)
2021

custom_components/openai_tts/config_flow.py

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,26 @@
1-
"""Config flow for OpenAI text-to-speech custom component."""
1+
"""
2+
Config flow for OpenAI TTS.
3+
"""
24
from __future__ import annotations
35
from typing import Any
46
import voluptuous as vol
57
import logging
68
from urllib.parse import urlparse
9+
import uuid
710

811
from homeassistant import data_entry_flow
9-
from homeassistant.config_entries import ConfigFlow
12+
from homeassistant.config_entries import ConfigFlow, OptionsFlow
1013
from homeassistant.helpers.selector import selector
1114
from homeassistant.exceptions import HomeAssistantError
1215

1316
from .const import CONF_API_KEY, CONF_MODEL, CONF_VOICE, CONF_SPEED, CONF_URL, DOMAIN, MODELS, VOICES, UNIQUE_ID
1417

1518
_LOGGER = logging.getLogger(__name__)
1619

17-
def generate_unique_id(user_input: dict) -> str:
18-
"""Generate a unique id from user input."""
19-
url = urlparse(user_input[CONF_URL])
20-
return f"{url.hostname}_{user_input[CONF_MODEL]}_{user_input[CONF_VOICE]}"
20+
def generate_entry_id() -> str:
21+
return str(uuid.uuid4())
2122

2223
async def validate_user_input(user_input: dict):
23-
"""Validate user input fields."""
2424
if user_input.get(CONF_MODEL) is None:
2525
raise ValueError("Model is required")
2626
if user_input.get(CONF_VOICE) is None:
@@ -32,7 +32,14 @@ class OpenAITTSConfigFlow(ConfigFlow, domain=DOMAIN):
3232
data_schema = vol.Schema({
3333
vol.Optional(CONF_API_KEY): str,
3434
vol.Optional(CONF_URL, default="https://api.openai.com/v1/audio/speech"): str,
35-
vol.Optional(CONF_SPEED, default=1.0): vol.Coerce(float),
35+
vol.Optional(CONF_SPEED, default=1.0): selector({
36+
"number": {
37+
"min": 0.25,
38+
"max": 4.0,
39+
"step": 0.05,
40+
"mode": "slider"
41+
}
42+
}),
3643
vol.Required(CONF_MODEL, default="tts-1"): selector({
3744
"select": {
3845
"options": MODELS,
@@ -52,17 +59,19 @@ class OpenAITTSConfigFlow(ConfigFlow, domain=DOMAIN):
5259
})
5360

5461
async def async_step_user(self, user_input: dict[str, Any] | None = None):
55-
"""Handle the initial step."""
5662
errors = {}
5763
if user_input is not None:
5864
try:
5965
await validate_user_input(user_input)
60-
unique_id = generate_unique_id(user_input)
61-
user_input[UNIQUE_ID] = unique_id
62-
await self.async_set_unique_id(unique_id)
63-
self._abort_if_unique_id_configured()
66+
# Generate a random unique id so multiple integrations can be added.
67+
entry_id = generate_entry_id()
68+
user_input[UNIQUE_ID] = entry_id
69+
await self.async_set_unique_id(entry_id)
6470
hostname = urlparse(user_input[CONF_URL]).hostname
65-
return self.async_create_entry(title=f"OpenAI TTS ({hostname}, {user_input[CONF_MODEL]}, {user_input[CONF_VOICE]})", data=user_input)
71+
return self.async_create_entry(
72+
title=f"OpenAI TTS ({hostname}, {user_input[CONF_MODEL]})",
73+
data=user_input
74+
)
6675
except data_entry_flow.AbortFlow:
6776
return self.async_abort(reason="already_configured")
6877
except HomeAssistantError as e:
@@ -71,7 +80,51 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None):
7180
except ValueError as e:
7281
_LOGGER.exception(str(e))
7382
errors["base"] = str(e)
74-
except Exception as e: # pylint: disable=broad-except
83+
except Exception as e:
7584
_LOGGER.exception(str(e))
7685
errors["base"] = "unknown_error"
77-
return self.async_show_form(step_id="user", data_schema=self.data_schema, errors=errors, description_placeholders=user_input)
86+
return self.async_show_form(
87+
step_id="user",
88+
data_schema=self.data_schema,
89+
errors=errors,
90+
description_placeholders=user_input
91+
)
92+
93+
@staticmethod
94+
def async_get_options_flow(config_entry):
95+
return OpenAITTSOptionsFlow()
96+
97+
class OpenAITTSOptionsFlow(OptionsFlow):
98+
"""Handle options flow for OpenAI TTS."""
99+
async def async_step_init(self, user_input: dict | None = None):
100+
if user_input is not None:
101+
return self.async_create_entry(title="", data=user_input)
102+
options_schema = vol.Schema({
103+
vol.Optional(
104+
"chime",
105+
default=self.config_entry.options.get("chime", self.config_entry.data.get("chime", False))
106+
): selector({"boolean": {}}),
107+
vol.Optional(
108+
CONF_SPEED,
109+
default=self.config_entry.options.get(CONF_SPEED, self.config_entry.data.get(CONF_SPEED, 1.0))
110+
): selector({
111+
"number": {
112+
"min": 0.25,
113+
"max": 4.0,
114+
"step": 0.05,
115+
"mode": "slider"
116+
}
117+
}),
118+
vol.Optional(
119+
CONF_VOICE,
120+
default=self.config_entry.options.get(CONF_VOICE, self.config_entry.data.get(CONF_VOICE, "shimmer"))
121+
): selector({
122+
"select": {
123+
"options": VOICES,
124+
"mode": "dropdown",
125+
"sort": True,
126+
"custom_value": True
127+
}
128+
})
129+
})
130+
return self.async_show_form(step_id="init", data_schema=options_schema)

custom_components/openai_tts/const.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@
88
CONF_URL = 'url'
99
UNIQUE_ID = 'unique_id'
1010
MODELS = ["tts-1", "tts-1-hd"]
11-
VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]
11+
VOICES = ["alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer"]

custom_components/openai_tts/manifest.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
"documentation": "https://github.com/sfortis/openai_tts/",
1010
"iot_class": "cloud_polling",
1111
"issue_tracker": "https://github.com/sfortis/openai_tts/issues",
12-
"requirements": [
13-
"requests>=2.25.1"
14-
],
15-
"version": "0.2.2"
12+
"requirements": [],
13+
"version": "0.3.0b0"
1614
}
Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,90 @@
1-
import requests
1+
"""
2+
TTS Engine for OpenAI TTS.
3+
"""
4+
import asyncio
5+
import threading
6+
import logging
7+
import aiohttp
28

3-
class OpenAITTSEngine:
9+
_LOGGER = logging.getLogger(__name__)
10+
11+
class AudioResponse:
12+
"""A simple response wrapper with a 'content' attribute to hold audio bytes."""
13+
def __init__(self, content: bytes):
14+
self.content = content
415

5-
def __init__(self, api_key: str, voice: str, model: str, speed: int, url: str):
16+
class OpenAITTSEngine:
17+
def __init__(self, api_key: str, voice: str, model: str, speed: float, url: str):
618
self._api_key = api_key
719
self._voice = voice
820
self._model = model
921
self._speed = speed
1022
self._url = url
1123

12-
def get_tts(self, text: str):
13-
""" Makes request to OpenAI TTS engine to convert text into audio"""
14-
headers: dict = {"Authorization": f"Bearer {self._api_key}"} if self._api_key else {}
15-
data: dict = {
24+
# Create a dedicated event loop running in a background thread.
25+
self._loop = asyncio.new_event_loop()
26+
self._session = None
27+
self._thread = threading.Thread(target=self._start_loop, daemon=True)
28+
self._thread.start()
29+
# Initialize the aiohttp session in the background event loop.
30+
asyncio.run_coroutine_threadsafe(self._init_session(), self._loop).result()
31+
32+
def _start_loop(self):
33+
asyncio.set_event_loop(self._loop)
34+
self._loop.run_forever()
35+
36+
async def _init_session(self):
37+
# Create a persistent aiohttp session for reuse.
38+
self._session = aiohttp.ClientSession()
39+
40+
async def _async_get_tts(self, text: str, speed: float, voice: str) -> AudioResponse:
41+
headers = {"Authorization": f"Bearer {self._api_key}"} if self._api_key else {}
42+
data = {
1643
"model": self._model,
1744
"input": text,
18-
"voice": self._voice,
45+
"voice": voice,
1946
"response_format": "wav",
20-
"speed": self._speed
47+
"speed": speed,
48+
"stream": True
2149
}
22-
return requests.post(self._url, headers=headers, json=data)
50+
# Use separate timeouts for connecting and reading.
51+
timeout = aiohttp.ClientTimeout(total=None, sock_connect=5, sock_read=25)
52+
async with self._session.post(self._url, headers=headers, json=data, timeout=timeout) as resp:
53+
resp.raise_for_status()
54+
audio_chunks = []
55+
# Optimize the chunk size to 4096 bytes.
56+
async for chunk in resp.content.iter_chunked(4096):
57+
if chunk:
58+
audio_chunks.append(chunk)
59+
audio_data = b"".join(audio_chunks)
60+
return AudioResponse(audio_data)
61+
62+
def get_tts(self, text: str, speed: float = None, voice: str = None) -> AudioResponse:
63+
"""Synchronous wrapper that runs the asynchronous TTS request on a dedicated event loop.
64+
If 'speed' or 'voice' are provided, they override the stored values.
65+
"""
66+
try:
67+
if speed is None:
68+
speed = self._speed
69+
if voice is None:
70+
voice = self._voice
71+
future = asyncio.run_coroutine_threadsafe(self._async_get_tts(text, speed, voice), self._loop)
72+
return future.result()
73+
except Exception as e:
74+
_LOGGER.error("Error in asynchronous get_tts: %s", e)
75+
raise e
76+
77+
def close(self):
78+
"""Clean up the aiohttp session and event loop on shutdown."""
79+
if self._session:
80+
asyncio.run_coroutine_threadsafe(self._session.close(), self._loop).result()
81+
self._loop.call_soon_threadsafe(self._loop.stop())
2382

2483
@staticmethod
2584
def get_supported_langs() -> list:
26-
"""Returns list of supported languages. Note: the model determines the provides language automatically."""
27-
return ["af", "ar", "hy", "az", "be", "bs", "bg", "ca", "zh", "hr", "cs", "da", "nl", "en", "et", "fi", "fr", "gl", "de", "el", "he", "hi", "hu", "is", "id", "it", "ja", "kn", "kk", "ko", "lv", "lt", "mk", "ms", "mr", "mi", "ne", "no", "fa", "pl", "pt", "ro", "ru", "sr", "sk", "sl", "es", "sw", "sv", "tl", "ta", "th", "tr", "uk", "ur", "vi", "cy"]
85+
return [
86+
"af", "ar", "hy", "az", "be", "bs", "bg", "ca", "zh", "hr", "cs", "da", "nl", "en",
87+
"et", "fi", "fr", "gl", "de", "el", "he", "hi", "hu", "is", "id", "it", "ja", "kn",
88+
"kk", "ko", "lv", "lt", "mk", "ms", "mr", "mi", "ne", "no", "fa", "pl", "pt", "ro",
89+
"ru", "sr", "sk", "sl", "es", "sw", "sv", "tl", "ta", "th", "tr", "uk", "ur", "vi", "cy"
90+
]

custom_components/openai_tts/strings.json

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
"step": {
44
"user": {
55
"title": "Add text-to-speech engine",
6-
"description": "Provide configuration data. See documentation for further info.",
6+
"description": "See documentation for further info.",
77
"data": {
8-
"api_key": "Enter OpenAI API key.",
9-
"speed": "Enter speed of the speech",
10-
"model": "Select model to be used.",
11-
"voice": "Select voice.",
8+
"api_key": "Enter OpenAI API key",
9+
"speed": "Speed (0.25 to 4.0, where 1.0 is default)",
10+
"model": "Select model",
11+
"voice": "Select voice",
1212
"url": "Enter the OpenAI-compatible endpoint. Optionally include a port number."
1313
}
1414
}
@@ -20,5 +20,17 @@
2020
"abort": {
2121
"already_configured": "This voice and endpoint are already configured."
2222
}
23+
},
24+
"options": {
25+
"step": {
26+
"init": {
27+
"title": "Configure TTS options",
28+
"data": {
29+
"chime": "Enable chime before speech (useful for announcements)",
30+
"speed": "Set speed (0.25 to 4.0)",
31+
"voice": "Select voice"
32+
}
33+
}
34+
}
2335
}
2436
}

custom_components/openai_tts/translations/cs.json

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,35 @@
22
"config": {
33
"step": {
44
"user": {
5-
"title": "Přidej engine pro převod textu na řeč",
6-
"description": "Vlož konfigurační data. Pro detaily se podívej na dokumentaci",
5+
"title": "Přidat TTS engine",
6+
"description": "Více informací naleznete v dokumentaci.",
77
"data": {
8-
"api_key": "Vlož OpenAI API klíč.",
9-
"speed": "Vlož rychlost řeči.",
10-
"model": "Vyber model k použití.",
11-
"voice": "Vyber hlas.",
12-
"url": "Zadejte koncový bod kompatibilní s OpenAI. Volitelně uveďte číslo portu."
8+
"api_key": "Zadejte OpenAI API klíč",
9+
"speed": "Rychlost (0,25 až 4,0, kde 1,0 je výchozí)",
10+
"model": "Vyberte model",
11+
"voice": "Vyberte hlas",
12+
"url": "Zadejte OpenAI-kompatibilní endpoint (Volitelně uveďte číslo portu)"
1313
}
1414
}
1515
},
1616
"error": {
17-
"wrong_api_key": "Nebyl poskytnut správný API klíč.",
18-
"already_configured": "Tento hlas je již nastaven."
17+
"wrong_api_key": "Neplatný API klíč. Zadejte prosím platný API klíč.",
18+
"already_configured": "Tento hlas a tento endpoint jsou již nakonfigurovány."
1919
},
2020
"abort": {
21-
"already_configured": "Tento hlas je již nastaven."
21+
"already_configured": "Tento hlas a tento endpoint jsou již nakonfigurovány."
22+
}
23+
},
24+
"options": {
25+
"step": {
26+
"init": {
27+
"title": "Nastavení TTS možností",
28+
"data": {
29+
"chime": "Povolit zvukový signál před řečí (užitečné pro oznámení)",
30+
"speed": "Nastavit rychlost (0,25 až 4,0)",
31+
"voice": "Vyberte hlas"
32+
}
33+
}
2234
}
2335
}
2436
}

custom_components/openai_tts/translations/de.json

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,35 @@
22
"config": {
33
"step": {
44
"user": {
5-
"title": "Füge eine Text zu Sprache Engine hinzu",
6-
"description": "Gib Konfigurationsdaten ein. Schau in die Dokumentation für weitere Informationen.",
5+
"title": "Text-to-Speech Engine hinzufügen",
6+
"description": "Weitere Informationen finden Sie in der Dokumentation.",
77
"data": {
8-
"api_key": "Gib den OpenAI API Schlüssel ein.",
9-
"speed": "Gib die Geschwindigkeit der Sprache ein",
10-
"model": "Wähle das zu verwendende Modell.",
11-
"voice": "Wähle eine Stimme.",
12-
"url": "Gib den OpenAI-kompatiblen Endpunkt ein. Optional kann eine Portnummer angegeben werden."
8+
"api_key": "Geben Sie den OpenAI API-Schlüssel ein",
9+
"speed": "Geschwindigkeit (0.25 bis 4.0, wobei 1.0 Standard ist)",
10+
"model": "Wählen Sie das Modell aus",
11+
"voice": "Wählen Sie die Stimme aus",
12+
"url": "Geben Sie den OpenAI-kompatiblen Endpunkt ein (Optional können Sie eine Portnummer angeben)"
1313
}
1414
}
1515
},
1616
"error": {
17-
"wrong_api_key": "Ungültiger API Schlüssel. Bitte gib einen gültigen API Schlüssel ein.",
18-
"already_configured": "Diese Stimme und Endpunkt sind bereits konfiguriert."
17+
"wrong_api_key": "Ungültiger API-Schlüssel. Bitte geben Sie einen gültigen API-Schlüssel ein.",
18+
"already_configured": "Diese Stimme und dieser Endpunkt sind bereits konfiguriert."
1919
},
2020
"abort": {
21-
"already_configured": "Diese Stimme und Endpunkt sind bereits konfiguriert."
21+
"already_configured": "Diese Stimme und dieser Endpunkt sind bereits konfiguriert."
22+
}
23+
},
24+
"options": {
25+
"step": {
26+
"init": {
27+
"title": "TTS-Optionen konfigurieren",
28+
"data": {
29+
"chime": "Chime vor der Sprache aktivieren (nützlich für Ansagen)",
30+
"speed": "Geschwindigkeit einstellen (0.25 bis 4.0)",
31+
"voice": "Stimme auswählen"
32+
}
33+
}
2234
}
2335
}
2436
}

0 commit comments

Comments
 (0)