Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ElevenLabs text-to-speech integration #115645

Merged
merged 30 commits into from
Jul 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0d8ce61
Add ElevenLabs text-to-speech integration
sorgfresser Apr 15, 2024
195d1ca
Remove commented out code
sorgfresser Apr 15, 2024
94edd8c
Use model_id instead of model_name for elevenlabs api
sorgfresser Apr 15, 2024
29035b6
Apply suggestions from code review
sorgfresser Apr 16, 2024
bde8a62
Use async client instead of sync
sorgfresser Apr 20, 2024
c205b18
Add ElevenLabs code owner
sorgfresser Apr 20, 2024
6bd9268
Apply suggestions from code review
sorgfresser May 24, 2024
28f50bf
Set entity title to voice
sorgfresser May 24, 2024
3b9751b
Rename to elevenlabs
sorgfresser May 24, 2024
96a37b1
Apply suggestions from code review
sorgfresser May 29, 2024
5449e08
Allow multiple voices and options flow
synesthesiam Jun 7, 2024
7502be1
Sort default voice at beginning
sorgfresser Jun 8, 2024
b6a7f8e
Rework config flow to include default model and reloading on options …
sorgfresser Jun 22, 2024
f33e6dc
Add error to strings
sorgfresser Jun 22, 2024
6586f43
Add ElevenLabsData and suggestions from code review
sorgfresser Jun 30, 2024
c92f0ea
Shorten options and config flow
sorgfresser Jul 6, 2024
eaab9bb
Fix comments
joostlek Jul 27, 2024
8b21925
Merge branch 'dev' into elevenlabs
joostlek Jul 27, 2024
19fb2ff
Fix comments
joostlek Jul 27, 2024
d7bbbd2
Add wip
sorgfresser Jul 30, 2024
990c6f2
Fix
sorgfresser Jul 30, 2024
25879d7
Cleanup
sorgfresser Jul 30, 2024
1f34556
Bump elevenlabs version
sorgfresser Jul 30, 2024
8824ab8
Add data description
sorgfresser Jul 30, 2024
f6d6641
Merge branch 'dev' into elevenlabs
joostlek Jul 31, 2024
8608aa2
Merge branch 'dev' into elevenlabs
sorgfresser Jul 31, 2024
b619c75
Merge branch 'dev' into elevenlabs
cdce8p Jul 31, 2024
4cdc67a
Merge branch 'dev' into elevenlabs
joostlek Jul 31, 2024
b36a3f1
Merge branch 'dev' into elevenlabs
joostlek Jul 31, 2024
2773a00
Fix
joostlek Jul 31, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ homeassistant.components.ecowitt.*
homeassistant.components.efergy.*
homeassistant.components.electrasmart.*
homeassistant.components.electric_kiwi.*
homeassistant.components.elevenlabs.*
homeassistant.components.elgato.*
homeassistant.components.elkm1.*
homeassistant.components.emulated_hue.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,8 @@ build.json @home-assistant/supervisor
/tests/components/electrasmart/ @jafar-atili
/homeassistant/components/electric_kiwi/ @mikey0000
/tests/components/electric_kiwi/ @mikey0000
/homeassistant/components/elevenlabs/ @sorgfresser
/tests/components/elevenlabs/ @sorgfresser
/homeassistant/components/elgato/ @frenck
/tests/components/elgato/ @frenck
/homeassistant/components/elkm1/ @gwww @bdraco
Expand Down
71 changes: 71 additions & 0 deletions homeassistant/components/elevenlabs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""The ElevenLabs text-to-speech integration."""

from __future__ import annotations

from dataclasses import dataclass

from elevenlabs import Model
from elevenlabs.client import AsyncElevenLabs
from elevenlabs.core import ApiError

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError

from .const import CONF_MODEL

PLATFORMS: list[Platform] = [Platform.TTS]


async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None:
"""Get ElevenLabs model from their API by the model_id."""
models = await client.models.get_all()
for maybe_model in models:
if maybe_model.model_id == model_id:
return maybe_model
return None


@dataclass(kw_only=True, slots=True)
class ElevenLabsData:
"""ElevenLabs data type."""

client: AsyncElevenLabs
frenck marked this conversation as resolved.
Show resolved Hide resolved
model: Model


type EleventLabsConfigEntry = ConfigEntry[ElevenLabsData]


async def async_setup_entry(hass: HomeAssistant, entry: EleventLabsConfigEntry) -> bool:
"""Set up ElevenLabs text-to-speech from a config entry."""
entry.add_update_listener(update_listener)
client = AsyncElevenLabs(api_key=entry.data[CONF_API_KEY])
model_id = entry.options[CONF_MODEL]
try:
model = await get_model_by_id(client, model_id)
except ApiError as err:
raise ConfigEntryError("Auth failed") from err

if model is None or (not model.languages):
raise ConfigEntryError("Model could not be resolved")

entry.runtime_data = ElevenLabsData(client=client, model=model)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True


async def async_unload_entry(
hass: HomeAssistant, entry: EleventLabsConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)


async def update_listener(
hass: HomeAssistant, config_entry: EleventLabsConfigEntry
) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id)
145 changes: 145 additions & 0 deletions homeassistant/components/elevenlabs/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Config flow for ElevenLabs text-to-speech integration."""

from __future__ import annotations

import logging
from typing import Any

from elevenlabs.client import AsyncElevenLabs
from elevenlabs.core import ApiError
import voluptuous as vol

from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
OptionsFlowWithConfigEntry,
)
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
)

from .const import CONF_MODEL, CONF_VOICE, DEFAULT_MODEL, DOMAIN

USER_STEP_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})


_LOGGER = logging.getLogger(__name__)


async def get_voices_models(api_key: str) -> tuple[dict[str, str], dict[str, str]]:
"""Get available voices and models as dicts."""
client = AsyncElevenLabs(api_key=api_key)
voices = (await client.voices.get_all()).voices
models = await client.models.get_all()
voices_dict = {
voice.voice_id: voice.name
for voice in sorted(voices, key=lambda v: v.name or "")
if voice.name
}
models_dict = {
model.model_id: model.name
for model in sorted(models, key=lambda m: m.name or "")
if model.name and model.can_do_text_to_speech
}
return voices_dict, models_dict


class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there something unique we can fetch to avoid adding the same user account twice?

"""Handle a config flow for ElevenLabs text-to-speech."""

VERSION = 1

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
voices, _ = await get_voices_models(user_input[CONF_API_KEY])
except ApiError:
errors["base"] = "invalid_api_key"
else:
return self.async_create_entry(
title="ElevenLabs",
data=user_input,
options={CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: list(voices)[0]},
)
return self.async_show_form(
step_id="user", data_schema=USER_STEP_SCHEMA, errors=errors
)

@staticmethod
def async_get_options_flow(
config_entry: ConfigEntry,
) -> OptionsFlow:
"""Create the options flow."""
return ElevenLabsOptionsFlow(config_entry)


class ElevenLabsOptionsFlow(OptionsFlowWithConfigEntry):
"""ElevenLabs options flow."""

def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
super().__init__(config_entry)
self.api_key: str = self.config_entry.data[CONF_API_KEY]
# id -> name
self.voices: dict[str, str] = {}
self.models: dict[str, str] = {}

async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
if not self.voices or not self.models:
self.voices, self.models = await get_voices_models(self.api_key)

assert self.models and self.voices

if user_input is not None:
return self.async_create_entry(
title="ElevenLabs",
data=user_input,
)

schema = self.elevenlabs_config_option_schema()
return self.async_show_form(
step_id="init",
data_schema=schema,
)

def elevenlabs_config_option_schema(self) -> vol.Schema:
"""Elevenlabs options schema."""
return self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(
CONF_MODEL,
): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(label=model_name, value=model_id)
for model_id, model_name in self.models.items()
]
)
),
vol.Required(
CONF_VOICE,
): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(label=voice_name, value=voice_id)
for voice_id, voice_name in self.voices.items()
]
)
),
}
),
self.options,
)
7 changes: 7 additions & 0 deletions homeassistant/components/elevenlabs/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Constants for the ElevenLabs text-to-speech integration."""

CONF_VOICE = "voice"
CONF_MODEL = "model"
DOMAIN = "elevenlabs"

DEFAULT_MODEL = "eleven_multilingual_v2"
11 changes: 11 additions & 0 deletions homeassistant/components/elevenlabs/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"domain": "elevenlabs",
"name": "ElevenLabs",
"codeowners": ["@sorgfresser"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/elevenlabs",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["elevenlabs"],
"requirements": ["elevenlabs==1.6.1"]
}
31 changes: 31 additions & 0 deletions homeassistant/components/elevenlabs/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"config": {
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
frenck marked this conversation as resolved.
Show resolved Hide resolved
},
"data_description": {
"api_key": "Your Elevenlabs API key."
}
}
},
"error": {
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]"
}
},
"options": {
"step": {
"init": {
"data": {
"voice": "Voice",
"model": "Model"
frenck marked this conversation as resolved.
Show resolved Hide resolved
},
"data_description": {
"voice": "Voice to use for the TTS.",
"model": "ElevenLabs model to use. Please note that not all models support all languages equally well."
}
}
}
}
}
Loading