2019-11-23 22:12:02 +00:00
|
|
|
"""Provide functionality for TTS."""
|
2016-12-13 07:23:08 +00:00
|
|
|
import asyncio
|
2017-01-21 07:35:18 +00:00
|
|
|
import functools as ft
|
2016-12-13 07:23:08 +00:00
|
|
|
import hashlib
|
2017-11-29 10:04:28 +00:00
|
|
|
import io
|
2016-12-27 16:01:22 +00:00
|
|
|
import logging
|
2016-12-13 07:23:08 +00:00
|
|
|
import mimetypes
|
|
|
|
import os
|
|
|
|
import re
|
2020-06-15 11:33:26 +00:00
|
|
|
from typing import Dict, Optional
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
from aiohttp import web
|
2019-10-13 02:59:31 +00:00
|
|
|
import mutagen
|
2020-10-06 15:49:14 +00:00
|
|
|
from mutagen.id3 import ID3FileType, TextFrame as ID3Text
|
2016-12-13 07:23:08 +00:00
|
|
|
import voluptuous as vol
|
|
|
|
|
|
|
|
from homeassistant.components.http import HomeAssistantView
|
2019-02-08 22:18:18 +00:00
|
|
|
from homeassistant.components.media_player.const import (
|
2019-07-31 19:25:30 +00:00
|
|
|
ATTR_MEDIA_CONTENT_ID,
|
|
|
|
ATTR_MEDIA_CONTENT_TYPE,
|
2019-10-13 02:59:31 +00:00
|
|
|
DOMAIN as DOMAIN_MP,
|
2019-07-31 19:25:30 +00:00
|
|
|
MEDIA_TYPE_MUSIC,
|
|
|
|
SERVICE_PLAY_MEDIA,
|
|
|
|
)
|
2020-04-09 19:43:42 +00:00
|
|
|
from homeassistant.const import (
|
|
|
|
ATTR_ENTITY_ID,
|
|
|
|
CONF_PLATFORM,
|
|
|
|
HTTP_BAD_REQUEST,
|
|
|
|
HTTP_NOT_FOUND,
|
|
|
|
HTTP_OK,
|
|
|
|
)
|
2017-11-29 10:04:28 +00:00
|
|
|
from homeassistant.core import callback
|
2016-12-13 07:23:08 +00:00
|
|
|
from homeassistant.exceptions import HomeAssistantError
|
2019-11-05 21:39:15 +00:00
|
|
|
from homeassistant.helpers import config_per_platform, discovery
|
2016-12-13 07:23:08 +00:00
|
|
|
import homeassistant.helpers.config_validation as cv
|
2020-05-08 19:53:28 +00:00
|
|
|
from homeassistant.helpers.network import get_url
|
2019-08-12 03:38:18 +00:00
|
|
|
from homeassistant.helpers.typing import HomeAssistantType
|
2017-11-29 10:04:28 +00:00
|
|
|
from homeassistant.setup import async_prepare_setup_platform
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2019-08-12 03:38:18 +00:00
|
|
|
# mypy: allow-untyped-defs, no-check-untyped-defs
|
|
|
|
|
2016-12-13 07:23:08 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
ATTR_CACHE = "cache"
|
|
|
|
ATTR_LANGUAGE = "language"
|
|
|
|
ATTR_MESSAGE = "message"
|
|
|
|
ATTR_OPTIONS = "options"
|
|
|
|
ATTR_PLATFORM = "platform"
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2020-08-21 15:17:36 +00:00
|
|
|
BASE_URL_KEY = "tts_base_url"
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
CONF_BASE_URL = "base_url"
|
|
|
|
CONF_CACHE = "cache"
|
|
|
|
CONF_CACHE_DIR = "cache_dir"
|
|
|
|
CONF_LANG = "language"
|
|
|
|
CONF_SERVICE_NAME = "service_name"
|
|
|
|
CONF_TIME_MEMORY = "time_memory"
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
DEFAULT_CACHE = True
|
2019-07-31 19:25:30 +00:00
|
|
|
DEFAULT_CACHE_DIR = "tts"
|
2016-12-13 07:23:08 +00:00
|
|
|
DEFAULT_TIME_MEMORY = 300
|
2019-07-31 19:25:30 +00:00
|
|
|
DOMAIN = "tts"
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
MEM_CACHE_FILENAME = "filename"
|
|
|
|
MEM_CACHE_VOICE = "voice"
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
SERVICE_CLEAR_CACHE = "clear_cache"
|
|
|
|
SERVICE_SAY = "say"
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
_RE_VOICE_FILE = re.compile(r"([a-f0-9]{40})_([^_]+)_([^_]+)_([a-z_]+)\.[a-z0-9]{3,4}")
|
|
|
|
KEY_PATTERN = "{0}_{1}_{2}_{3}"
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2019-04-16 21:27:07 +00:00
|
|
|
|
|
|
|
def _deprecated_platform(value):
|
|
|
|
"""Validate if platform is deprecated."""
|
2019-07-31 19:25:30 +00:00
|
|
|
if value == "google":
|
2019-04-16 21:27:07 +00:00
|
|
|
raise vol.Invalid(
|
2019-07-31 19:25:30 +00:00
|
|
|
"google tts service has been renamed to google_translate,"
|
|
|
|
" please update your configuration."
|
|
|
|
)
|
2019-04-16 21:27:07 +00:00
|
|
|
return value
|
|
|
|
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
|
|
|
|
{
|
|
|
|
vol.Required(CONF_PLATFORM): vol.All(cv.string, _deprecated_platform),
|
|
|
|
vol.Optional(CONF_CACHE, default=DEFAULT_CACHE): cv.boolean,
|
|
|
|
vol.Optional(CONF_CACHE_DIR, default=DEFAULT_CACHE_DIR): cv.string,
|
|
|
|
vol.Optional(CONF_TIME_MEMORY, default=DEFAULT_TIME_MEMORY): vol.All(
|
|
|
|
vol.Coerce(int), vol.Range(min=60, max=57600)
|
|
|
|
),
|
|
|
|
vol.Optional(CONF_BASE_URL): cv.string,
|
|
|
|
vol.Optional(CONF_SERVICE_NAME): cv.string,
|
|
|
|
}
|
|
|
|
)
|
2019-02-05 05:52:19 +00:00
|
|
|
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE.extend(PLATFORM_SCHEMA.schema)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
SCHEMA_SERVICE_SAY = vol.Schema(
|
|
|
|
{
|
|
|
|
vol.Required(ATTR_MESSAGE): cv.string,
|
|
|
|
vol.Optional(ATTR_CACHE): cv.boolean,
|
2020-02-04 22:42:07 +00:00
|
|
|
vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids,
|
2019-07-31 19:25:30 +00:00
|
|
|
vol.Optional(ATTR_LANGUAGE): cv.string,
|
|
|
|
vol.Optional(ATTR_OPTIONS): dict,
|
|
|
|
}
|
|
|
|
)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({})
|
|
|
|
|
|
|
|
|
2018-04-17 13:24:54 +00:00
|
|
|
async def async_setup(hass, config):
|
2017-04-23 07:24:53 +00:00
|
|
|
"""Set up TTS."""
|
2016-12-13 07:23:08 +00:00
|
|
|
tts = SpeechManager(hass)
|
|
|
|
|
|
|
|
try:
|
2017-04-24 03:41:09 +00:00
|
|
|
conf = config[DOMAIN][0] if config.get(DOMAIN, []) else {}
|
2016-12-13 07:23:08 +00:00
|
|
|
use_cache = conf.get(CONF_CACHE, DEFAULT_CACHE)
|
|
|
|
cache_dir = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR)
|
2016-12-14 21:32:20 +00:00
|
|
|
time_memory = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY)
|
2020-05-08 19:53:28 +00:00
|
|
|
base_url = conf.get(CONF_BASE_URL) or get_url(hass)
|
2020-08-21 15:17:36 +00:00
|
|
|
hass.data[BASE_URL_KEY] = base_url
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2018-09-10 09:50:25 +00:00
|
|
|
await tts.async_init_cache(use_cache, cache_dir, time_memory, base_url)
|
2020-08-21 15:17:36 +00:00
|
|
|
except (HomeAssistantError, KeyError):
|
|
|
|
_LOGGER.exception("Error on cache init")
|
2016-12-13 07:23:08 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
hass.http.register_view(TextToSpeechView(tts))
|
2018-04-17 13:24:54 +00:00
|
|
|
hass.http.register_view(TextToSpeechUrlView(tts))
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2019-11-05 21:39:15 +00:00
|
|
|
async def async_setup_platform(p_type, p_config=None, discovery_info=None):
|
2017-05-02 20:47:20 +00:00
|
|
|
"""Set up a TTS platform."""
|
2019-11-05 21:39:15 +00:00
|
|
|
if p_config is None:
|
|
|
|
p_config = {}
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type)
|
2016-12-13 07:23:08 +00:00
|
|
|
if platform is None:
|
|
|
|
return
|
|
|
|
|
|
|
|
try:
|
2019-07-31 19:25:30 +00:00
|
|
|
if hasattr(platform, "async_get_engine"):
|
2019-11-05 21:39:15 +00:00
|
|
|
provider = await platform.async_get_engine(
|
|
|
|
hass, p_config, discovery_info
|
|
|
|
)
|
2016-12-13 07:23:08 +00:00
|
|
|
else:
|
2020-04-02 16:55:34 +00:00
|
|
|
provider = await hass.async_add_executor_job(
|
2019-11-05 21:39:15 +00:00
|
|
|
platform.get_engine, hass, p_config, discovery_info
|
|
|
|
)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
if provider is None:
|
2017-04-23 07:24:53 +00:00
|
|
|
_LOGGER.error("Error setting up platform %s", p_type)
|
2016-12-13 07:23:08 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
tts.async_register_engine(p_type, provider, p_config)
|
|
|
|
except Exception: # pylint: disable=broad-except
|
2018-09-02 10:51:36 +00:00
|
|
|
_LOGGER.exception("Error setting up platform: %s", p_type)
|
2016-12-13 07:23:08 +00:00
|
|
|
return
|
|
|
|
|
2018-04-17 13:24:54 +00:00
|
|
|
async def async_say_handle(service):
|
2016-12-13 07:23:08 +00:00
|
|
|
"""Service handle for say."""
|
2020-02-04 22:42:07 +00:00
|
|
|
entity_ids = service.data[ATTR_ENTITY_ID]
|
2016-12-13 07:23:08 +00:00
|
|
|
message = service.data.get(ATTR_MESSAGE)
|
|
|
|
cache = service.data.get(ATTR_CACHE)
|
2016-12-27 16:01:22 +00:00
|
|
|
language = service.data.get(ATTR_LANGUAGE)
|
2017-01-21 07:35:18 +00:00
|
|
|
options = service.data.get(ATTR_OPTIONS)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
try:
|
2018-04-17 13:24:54 +00:00
|
|
|
url = await tts.async_get_url(
|
2019-07-31 19:25:30 +00:00
|
|
|
p_type, message, cache=cache, language=language, options=options
|
2017-01-21 07:35:18 +00:00
|
|
|
)
|
2016-12-13 07:23:08 +00:00
|
|
|
except HomeAssistantError as err:
|
2018-09-02 10:51:36 +00:00
|
|
|
_LOGGER.error("Error on init TTS: %s", err)
|
2016-12-13 07:23:08 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
data = {
|
|
|
|
ATTR_MEDIA_CONTENT_ID: url,
|
|
|
|
ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC,
|
2019-03-11 11:31:36 +00:00
|
|
|
ATTR_ENTITY_ID: entity_ids,
|
2016-12-13 07:23:08 +00:00
|
|
|
}
|
|
|
|
|
2018-04-17 13:24:54 +00:00
|
|
|
await hass.services.async_call(
|
2020-06-26 00:09:52 +00:00
|
|
|
DOMAIN_MP,
|
|
|
|
SERVICE_PLAY_MEDIA,
|
|
|
|
data,
|
|
|
|
blocking=True,
|
|
|
|
context=service.context,
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2019-08-23 16:53:33 +00:00
|
|
|
service_name = p_config.get(CONF_SERVICE_NAME, f"{p_type}_{SERVICE_SAY}")
|
2016-12-13 07:23:08 +00:00
|
|
|
hass.services.async_register(
|
2019-07-31 19:25:30 +00:00
|
|
|
DOMAIN, service_name, async_say_handle, schema=SCHEMA_SERVICE_SAY
|
|
|
|
)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
setup_tasks = [
|
|
|
|
async_setup_platform(p_type, p_config)
|
|
|
|
for p_type, p_config in config_per_platform(config, DOMAIN)
|
|
|
|
]
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
if setup_tasks:
|
2019-05-23 04:09:59 +00:00
|
|
|
await asyncio.wait(setup_tasks)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2019-11-05 21:39:15 +00:00
|
|
|
async def async_platform_discovered(platform, info):
|
|
|
|
"""Handle for discovered platform."""
|
|
|
|
await async_setup_platform(platform, discovery_info=info)
|
|
|
|
|
|
|
|
discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered)
|
|
|
|
|
2018-04-17 13:24:54 +00:00
|
|
|
async def async_clear_cache_handle(service):
|
2016-12-13 07:23:08 +00:00
|
|
|
"""Handle clear cache service call."""
|
2018-04-17 13:24:54 +00:00
|
|
|
await tts.async_clear_cache()
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
hass.services.async_register(
|
2019-07-31 19:25:30 +00:00
|
|
|
DOMAIN,
|
|
|
|
SERVICE_CLEAR_CACHE,
|
|
|
|
async_clear_cache_handle,
|
|
|
|
schema=SCHEMA_SERVICE_CLEAR_CACHE,
|
|
|
|
)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
2020-06-15 11:33:26 +00:00
|
|
|
def _hash_options(options: Dict) -> str:
|
|
|
|
"""Hashes an options dictionary."""
|
|
|
|
opts_hash = hashlib.blake2s(digest_size=5)
|
|
|
|
for key, value in sorted(options.items()):
|
|
|
|
opts_hash.update(str(key).encode())
|
|
|
|
opts_hash.update(str(value).encode())
|
|
|
|
|
|
|
|
return opts_hash.hexdigest()
|
|
|
|
|
|
|
|
|
2018-07-20 08:45:20 +00:00
|
|
|
class SpeechManager:
|
2016-12-13 07:23:08 +00:00
|
|
|
"""Representation of a speech store."""
|
|
|
|
|
|
|
|
def __init__(self, hass):
|
|
|
|
"""Initialize a speech store."""
|
|
|
|
self.hass = hass
|
|
|
|
self.providers = {}
|
|
|
|
|
2016-12-18 19:26:40 +00:00
|
|
|
self.use_cache = DEFAULT_CACHE
|
|
|
|
self.cache_dir = DEFAULT_CACHE_DIR
|
|
|
|
self.time_memory = DEFAULT_TIME_MEMORY
|
2018-09-10 09:50:25 +00:00
|
|
|
self.base_url = None
|
2016-12-13 07:23:08 +00:00
|
|
|
self.file_cache = {}
|
|
|
|
self.mem_cache = {}
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
async def async_init_cache(self, use_cache, cache_dir, time_memory, base_url):
|
2016-12-13 07:23:08 +00:00
|
|
|
"""Init config folder and load file cache."""
|
|
|
|
self.use_cache = use_cache
|
|
|
|
self.time_memory = time_memory
|
2018-09-10 09:50:25 +00:00
|
|
|
self.base_url = base_url
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
try:
|
2020-04-02 16:55:34 +00:00
|
|
|
self.cache_dir = await self.hass.async_add_executor_job(
|
|
|
|
_init_tts_cache_dir, self.hass, cache_dir
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2016-12-13 07:23:08 +00:00
|
|
|
except OSError as err:
|
2020-08-28 11:50:32 +00:00
|
|
|
raise HomeAssistantError(f"Can't init cache dir {err}") from err
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
try:
|
2020-04-02 16:55:34 +00:00
|
|
|
cache_files = await self.hass.async_add_executor_job(
|
|
|
|
_get_cache_files, self.cache_dir
|
|
|
|
)
|
2016-12-13 07:23:08 +00:00
|
|
|
except OSError as err:
|
2020-08-28 11:50:32 +00:00
|
|
|
raise HomeAssistantError(f"Can't read cache dir {err}") from err
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
if cache_files:
|
|
|
|
self.file_cache.update(cache_files)
|
|
|
|
|
2018-04-17 13:24:54 +00:00
|
|
|
async def async_clear_cache(self):
|
2016-12-13 07:23:08 +00:00
|
|
|
"""Read file cache and delete files."""
|
|
|
|
self.mem_cache = {}
|
|
|
|
|
|
|
|
def remove_files():
|
|
|
|
"""Remove files from filesystem."""
|
2020-04-02 16:55:34 +00:00
|
|
|
for filename in self.file_cache.values():
|
2016-12-13 07:23:08 +00:00
|
|
|
try:
|
2016-12-18 19:26:40 +00:00
|
|
|
os.remove(os.path.join(self.cache_dir, filename))
|
2017-02-16 15:13:33 +00:00
|
|
|
except OSError as err:
|
2019-07-31 19:25:30 +00:00
|
|
|
_LOGGER.warning("Can't remove cache file '%s': %s", filename, err)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2020-04-02 16:55:34 +00:00
|
|
|
await self.hass.async_add_executor_job(remove_files)
|
2016-12-13 07:23:08 +00:00
|
|
|
self.file_cache = {}
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def async_register_engine(self, engine, provider, config):
|
|
|
|
"""Register a TTS provider."""
|
|
|
|
provider.hass = self.hass
|
2017-02-07 11:07:11 +00:00
|
|
|
if provider.name is None:
|
|
|
|
provider.name = engine
|
2016-12-13 07:23:08 +00:00
|
|
|
self.providers[engine] = provider
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
async def async_get_url(
|
|
|
|
self, engine, message, cache=None, language=None, options=None
|
|
|
|
):
|
2016-12-13 07:23:08 +00:00
|
|
|
"""Get URL for play message.
|
|
|
|
|
|
|
|
This method is a coroutine.
|
|
|
|
"""
|
2017-01-11 15:31:16 +00:00
|
|
|
provider = self.providers[engine]
|
2019-07-31 19:25:30 +00:00
|
|
|
msg_hash = hashlib.sha1(bytes(message, "utf-8")).hexdigest()
|
2017-01-21 07:35:18 +00:00
|
|
|
use_cache = cache if cache is not None else self.use_cache
|
2017-01-11 15:31:16 +00:00
|
|
|
|
2017-04-23 07:24:53 +00:00
|
|
|
# Languages
|
2017-01-11 15:31:16 +00:00
|
|
|
language = language or provider.default_language
|
2019-07-31 19:25:30 +00:00
|
|
|
if language is None or language not in provider.supported_languages:
|
2019-08-23 16:53:33 +00:00
|
|
|
raise HomeAssistantError(f"Not supported language {language}")
|
2017-01-11 15:31:16 +00:00
|
|
|
|
2017-04-23 07:24:53 +00:00
|
|
|
# Options
|
2017-02-07 11:07:11 +00:00
|
|
|
if provider.default_options and options:
|
2017-07-07 05:20:49 +00:00
|
|
|
merged_options = provider.default_options.copy()
|
|
|
|
merged_options.update(options)
|
|
|
|
options = merged_options
|
2017-01-21 07:35:18 +00:00
|
|
|
options = options or provider.default_options
|
2020-04-02 16:55:34 +00:00
|
|
|
|
2017-01-21 07:35:18 +00:00
|
|
|
if options is not None:
|
2019-07-31 19:25:30 +00:00
|
|
|
invalid_opts = [
|
|
|
|
opt_name
|
|
|
|
for opt_name in options.keys()
|
|
|
|
if opt_name not in (provider.supported_options or [])
|
|
|
|
]
|
2017-01-21 07:35:18 +00:00
|
|
|
if invalid_opts:
|
2019-08-23 16:53:33 +00:00
|
|
|
raise HomeAssistantError(f"Invalid options found: {invalid_opts}")
|
2020-06-15 11:33:26 +00:00
|
|
|
options_key = _hash_options(options)
|
2017-01-21 07:35:18 +00:00
|
|
|
else:
|
2019-07-31 19:25:30 +00:00
|
|
|
options_key = "-"
|
2017-01-21 07:35:18 +00:00
|
|
|
|
2020-08-25 12:23:21 +00:00
|
|
|
key = KEY_PATTERN.format(
|
|
|
|
msg_hash, language.replace("_", "-"), options_key, engine
|
|
|
|
).lower()
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2017-04-23 07:24:53 +00:00
|
|
|
# Is speech already in memory
|
2016-12-13 07:23:08 +00:00
|
|
|
if key in self.mem_cache:
|
|
|
|
filename = self.mem_cache[key][MEM_CACHE_FILENAME]
|
2017-04-23 07:24:53 +00:00
|
|
|
# Is file store in file cache
|
2016-12-13 07:23:08 +00:00
|
|
|
elif use_cache and key in self.file_cache:
|
|
|
|
filename = self.file_cache[key]
|
2018-10-02 09:03:09 +00:00
|
|
|
self.hass.async_create_task(self.async_file_to_mem(key))
|
2017-04-23 07:24:53 +00:00
|
|
|
# Load speech from provider into memory
|
2016-12-13 07:23:08 +00:00
|
|
|
else:
|
2018-04-17 13:24:54 +00:00
|
|
|
filename = await self.async_get_tts_audio(
|
2019-07-31 19:25:30 +00:00
|
|
|
engine, key, message, use_cache, language, options
|
|
|
|
)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2019-08-23 16:53:33 +00:00
|
|
|
return f"{self.base_url}/api/tts_proxy/{filename}"
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
async def async_get_tts_audio(self, engine, key, message, cache, language, options):
|
2016-12-13 07:23:08 +00:00
|
|
|
"""Receive TTS and store for view in cache.
|
|
|
|
|
|
|
|
This method is a coroutine.
|
|
|
|
"""
|
|
|
|
provider = self.providers[engine]
|
2019-07-31 19:25:30 +00:00
|
|
|
extension, data = await provider.async_get_tts_audio(message, language, options)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
if data is None or extension is None:
|
2019-08-23 16:53:33 +00:00
|
|
|
raise HomeAssistantError(f"No TTS from {engine} for '{message}'")
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2017-04-23 07:24:53 +00:00
|
|
|
# Create file infos
|
2019-11-23 22:12:02 +00:00
|
|
|
filename = f"{key}.{extension}".lower()
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2020-08-25 12:23:21 +00:00
|
|
|
# Validate filename
|
|
|
|
if not _RE_VOICE_FILE.match(filename):
|
|
|
|
raise HomeAssistantError(
|
|
|
|
f"TTS filename '{filename}' from {engine} is invalid!"
|
|
|
|
)
|
2017-02-07 11:07:11 +00:00
|
|
|
|
2017-04-23 07:24:53 +00:00
|
|
|
# Save to memory
|
2020-08-25 12:23:21 +00:00
|
|
|
data = self.write_tags(filename, data, provider, message, language, options)
|
2016-12-13 07:23:08 +00:00
|
|
|
self._async_store_to_memcache(key, filename, data)
|
|
|
|
|
|
|
|
if cache:
|
2019-07-31 19:25:30 +00:00
|
|
|
self.hass.async_create_task(self.async_save_tts_audio(key, filename, data))
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
return filename
|
|
|
|
|
2018-04-17 13:24:54 +00:00
|
|
|
async def async_save_tts_audio(self, key, filename, data):
|
2016-12-13 07:23:08 +00:00
|
|
|
"""Store voice data to file and file_cache.
|
|
|
|
|
|
|
|
This method is a coroutine.
|
|
|
|
"""
|
|
|
|
voice_file = os.path.join(self.cache_dir, filename)
|
|
|
|
|
|
|
|
def save_speech():
|
|
|
|
"""Store speech to filesystem."""
|
2019-07-31 19:25:30 +00:00
|
|
|
with open(voice_file, "wb") as speech:
|
2016-12-13 07:23:08 +00:00
|
|
|
speech.write(data)
|
|
|
|
|
|
|
|
try:
|
2020-04-02 16:55:34 +00:00
|
|
|
await self.hass.async_add_executor_job(save_speech)
|
2016-12-13 07:23:08 +00:00
|
|
|
self.file_cache[key] = filename
|
2020-04-02 16:55:34 +00:00
|
|
|
except OSError as err:
|
|
|
|
_LOGGER.error("Can't write %s: %s", filename, err)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2018-04-17 13:24:54 +00:00
|
|
|
async def async_file_to_mem(self, key):
|
2016-12-13 07:23:08 +00:00
|
|
|
"""Load voice from file cache into memory.
|
|
|
|
|
|
|
|
This method is a coroutine.
|
|
|
|
"""
|
|
|
|
filename = self.file_cache.get(key)
|
|
|
|
if not filename:
|
2019-08-23 16:53:33 +00:00
|
|
|
raise HomeAssistantError(f"Key {key} not in file cache!")
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
voice_file = os.path.join(self.cache_dir, filename)
|
|
|
|
|
|
|
|
def load_speech():
|
|
|
|
"""Load a speech from filesystem."""
|
2019-07-31 19:25:30 +00:00
|
|
|
with open(voice_file, "rb") as speech:
|
2016-12-13 07:23:08 +00:00
|
|
|
return speech.read()
|
|
|
|
|
|
|
|
try:
|
2020-04-02 16:55:34 +00:00
|
|
|
data = await self.hass.async_add_executor_job(load_speech)
|
2020-08-28 11:50:32 +00:00
|
|
|
except OSError as err:
|
2017-01-21 01:09:03 +00:00
|
|
|
del self.file_cache[key]
|
2020-08-28 11:50:32 +00:00
|
|
|
raise HomeAssistantError(f"Can't read {voice_file}") from err
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
self._async_store_to_memcache(key, filename, data)
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def _async_store_to_memcache(self, key, filename, data):
|
|
|
|
"""Store data to memcache and set timer to remove it."""
|
2019-07-31 19:25:30 +00:00
|
|
|
self.mem_cache[key] = {MEM_CACHE_FILENAME: filename, MEM_CACHE_VOICE: data}
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
@callback
|
|
|
|
def async_remove_from_mem():
|
|
|
|
"""Cleanup memcache."""
|
2020-06-02 23:46:17 +00:00
|
|
|
self.mem_cache.pop(key, None)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
self.hass.loop.call_later(self.time_memory, async_remove_from_mem)
|
|
|
|
|
2018-04-17 13:24:54 +00:00
|
|
|
async def async_read_tts(self, filename):
|
2016-12-13 07:23:08 +00:00
|
|
|
"""Read a voice file and return binary.
|
|
|
|
|
|
|
|
This method is a coroutine.
|
|
|
|
"""
|
|
|
|
record = _RE_VOICE_FILE.match(filename.lower())
|
|
|
|
if not record:
|
|
|
|
raise HomeAssistantError("Wrong tts file format!")
|
|
|
|
|
2016-12-27 16:01:22 +00:00
|
|
|
key = KEY_PATTERN.format(
|
2019-07-31 19:25:30 +00:00
|
|
|
record.group(1), record.group(2), record.group(3), record.group(4)
|
|
|
|
)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
if key not in self.mem_cache:
|
|
|
|
if key not in self.file_cache:
|
2019-08-23 16:53:33 +00:00
|
|
|
raise HomeAssistantError(f"{key} not in cache!")
|
2018-04-17 13:24:54 +00:00
|
|
|
await self.async_file_to_mem(key)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
content, _ = mimetypes.guess_type(filename)
|
2019-11-23 22:12:02 +00:00
|
|
|
return content, self.mem_cache[key][MEM_CACHE_VOICE]
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2017-02-07 11:07:11 +00:00
|
|
|
@staticmethod
|
|
|
|
def write_tags(filename, data, provider, message, language, options):
|
|
|
|
"""Write ID3 tags to file.
|
|
|
|
|
|
|
|
Async friendly.
|
|
|
|
"""
|
|
|
|
|
|
|
|
data_bytes = io.BytesIO(data)
|
|
|
|
data_bytes.name = filename
|
|
|
|
data_bytes.seek(0)
|
|
|
|
|
|
|
|
album = provider.name
|
|
|
|
artist = language
|
|
|
|
|
2020-05-08 22:10:17 +00:00
|
|
|
if options is not None and options.get("voice") is not None:
|
|
|
|
artist = options.get("voice")
|
2017-02-07 11:07:11 +00:00
|
|
|
|
|
|
|
try:
|
2020-09-29 08:19:26 +00:00
|
|
|
tts_file = mutagen.File(data_bytes)
|
2017-02-07 11:07:11 +00:00
|
|
|
if tts_file is not None:
|
2020-10-06 15:49:14 +00:00
|
|
|
if isinstance(tts_file, ID3FileType):
|
2020-10-04 09:59:53 +00:00
|
|
|
tts_file["artist"] = ID3Text(encoding=3, text=artist)
|
|
|
|
tts_file["album"] = ID3Text(encoding=3, text=album)
|
|
|
|
tts_file["title"] = ID3Text(encoding=3, text=message)
|
|
|
|
else:
|
|
|
|
tts_file["artist"] = artist
|
|
|
|
tts_file["album"] = album
|
|
|
|
tts_file["title"] = message
|
2017-02-07 11:07:11 +00:00
|
|
|
tts_file.save(data_bytes)
|
|
|
|
except mutagen.MutagenError as err:
|
|
|
|
_LOGGER.error("ID3 tag error: %s", err)
|
|
|
|
|
|
|
|
return data_bytes.getvalue()
|
|
|
|
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2018-07-20 08:45:20 +00:00
|
|
|
class Provider:
|
2017-05-02 20:47:20 +00:00
|
|
|
"""Represent a single TTS provider."""
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2019-08-12 03:38:18 +00:00
|
|
|
hass: Optional[HomeAssistantType] = None
|
|
|
|
name: Optional[str] = None
|
2016-12-13 07:23:08 +00:00
|
|
|
|
2017-01-11 15:31:16 +00:00
|
|
|
@property
|
|
|
|
def default_language(self):
|
2017-05-02 20:47:20 +00:00
|
|
|
"""Return the default language."""
|
2017-01-11 15:31:16 +00:00
|
|
|
return None
|
|
|
|
|
|
|
|
@property
|
|
|
|
def supported_languages(self):
|
2017-05-02 20:47:20 +00:00
|
|
|
"""Return a list of supported languages."""
|
2017-01-11 15:31:16 +00:00
|
|
|
return None
|
|
|
|
|
2017-01-21 07:35:18 +00:00
|
|
|
@property
|
|
|
|
def supported_options(self):
|
2017-05-02 20:47:20 +00:00
|
|
|
"""Return a list of supported options like voice, emotionen."""
|
2017-01-21 07:35:18 +00:00
|
|
|
return None
|
|
|
|
|
|
|
|
@property
|
|
|
|
def default_options(self):
|
2017-05-02 20:47:20 +00:00
|
|
|
"""Return a dict include default options."""
|
2017-01-21 07:35:18 +00:00
|
|
|
return None
|
|
|
|
|
|
|
|
def get_tts_audio(self, message, language, options=None):
|
2016-12-13 07:23:08 +00:00
|
|
|
"""Load tts audio file from provider."""
|
|
|
|
raise NotImplementedError()
|
|
|
|
|
2020-01-29 21:59:45 +00:00
|
|
|
async def async_get_tts_audio(self, message, language, options=None):
|
2016-12-13 07:23:08 +00:00
|
|
|
"""Load tts audio file from provider.
|
|
|
|
|
|
|
|
Return a tuple of file extension and data as bytes.
|
|
|
|
"""
|
2020-04-02 16:55:34 +00:00
|
|
|
return await self.hass.async_add_executor_job(
|
2019-07-31 19:25:30 +00:00
|
|
|
ft.partial(self.get_tts_audio, message, language, options=options)
|
|
|
|
)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
|
2020-04-02 16:55:34 +00:00
|
|
|
def _init_tts_cache_dir(hass, cache_dir):
|
|
|
|
"""Init cache folder."""
|
|
|
|
if not os.path.isabs(cache_dir):
|
|
|
|
cache_dir = hass.config.path(cache_dir)
|
|
|
|
if not os.path.isdir(cache_dir):
|
|
|
|
_LOGGER.info("Create cache dir %s", cache_dir)
|
|
|
|
os.mkdir(cache_dir)
|
|
|
|
return cache_dir
|
|
|
|
|
|
|
|
|
|
|
|
def _get_cache_files(cache_dir):
|
|
|
|
"""Return a dict of given engine files."""
|
|
|
|
cache = {}
|
|
|
|
|
|
|
|
folder_data = os.listdir(cache_dir)
|
|
|
|
for file_data in folder_data:
|
|
|
|
record = _RE_VOICE_FILE.match(file_data)
|
|
|
|
if record:
|
|
|
|
key = KEY_PATTERN.format(
|
2020-04-08 16:47:38 +00:00
|
|
|
record.group(1), record.group(2), record.group(3), record.group(4)
|
2020-04-02 16:55:34 +00:00
|
|
|
)
|
|
|
|
cache[key.lower()] = file_data.lower()
|
|
|
|
return cache
|
|
|
|
|
|
|
|
|
2018-04-17 13:24:54 +00:00
|
|
|
class TextToSpeechUrlView(HomeAssistantView):
|
|
|
|
"""TTS view to get a url to a generated speech file."""
|
|
|
|
|
|
|
|
requires_auth = True
|
2019-07-31 19:25:30 +00:00
|
|
|
url = "/api/tts_get_url"
|
|
|
|
name = "api:tts:geturl"
|
2018-04-17 13:24:54 +00:00
|
|
|
|
|
|
|
def __init__(self, tts):
|
|
|
|
"""Initialize a tts view."""
|
|
|
|
self.tts = tts
|
|
|
|
|
2020-05-26 14:28:22 +00:00
|
|
|
async def post(self, request: web.Request) -> web.Response:
|
2018-04-17 13:24:54 +00:00
|
|
|
"""Generate speech and provide url."""
|
|
|
|
try:
|
|
|
|
data = await request.json()
|
|
|
|
except ValueError:
|
2020-04-09 19:43:42 +00:00
|
|
|
return self.json_message("Invalid JSON specified", HTTP_BAD_REQUEST)
|
2018-04-17 13:24:54 +00:00
|
|
|
if not data.get(ATTR_PLATFORM) and data.get(ATTR_MESSAGE):
|
2020-04-09 19:43:42 +00:00
|
|
|
return self.json_message(
|
|
|
|
"Must specify platform and message", HTTP_BAD_REQUEST
|
|
|
|
)
|
2018-04-17 13:24:54 +00:00
|
|
|
|
|
|
|
p_type = data[ATTR_PLATFORM]
|
|
|
|
message = data[ATTR_MESSAGE]
|
|
|
|
cache = data.get(ATTR_CACHE)
|
|
|
|
language = data.get(ATTR_LANGUAGE)
|
|
|
|
options = data.get(ATTR_OPTIONS)
|
|
|
|
|
|
|
|
try:
|
|
|
|
url = await self.tts.async_get_url(
|
2019-07-31 19:25:30 +00:00
|
|
|
p_type, message, cache=cache, language=language, options=options
|
2018-04-17 13:24:54 +00:00
|
|
|
)
|
2020-04-08 16:47:38 +00:00
|
|
|
resp = self.json({"url": url}, HTTP_OK)
|
2018-04-17 13:24:54 +00:00
|
|
|
except HomeAssistantError as err:
|
|
|
|
_LOGGER.error("Error on init tts: %s", err)
|
2020-04-09 19:43:42 +00:00
|
|
|
resp = self.json({"error": err}, HTTP_BAD_REQUEST)
|
2018-04-17 13:24:54 +00:00
|
|
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
2016-12-13 07:23:08 +00:00
|
|
|
class TextToSpeechView(HomeAssistantView):
|
2017-05-02 20:47:20 +00:00
|
|
|
"""TTS view to serve a speech audio."""
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
requires_auth = False
|
2019-07-31 19:25:30 +00:00
|
|
|
url = "/api/tts_proxy/{filename}"
|
|
|
|
name = "api:tts:speech"
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
def __init__(self, tts):
|
|
|
|
"""Initialize a tts view."""
|
|
|
|
self.tts = tts
|
|
|
|
|
2020-05-26 14:28:22 +00:00
|
|
|
async def get(self, request: web.Request, filename: str) -> web.Response:
|
2016-12-13 07:23:08 +00:00
|
|
|
"""Start a get request."""
|
|
|
|
try:
|
2018-04-17 13:24:54 +00:00
|
|
|
content, data = await self.tts.async_read_tts(filename)
|
2016-12-13 07:23:08 +00:00
|
|
|
except HomeAssistantError as err:
|
|
|
|
_LOGGER.error("Error on load tts: %s", err)
|
2020-04-08 22:57:47 +00:00
|
|
|
return web.Response(status=HTTP_NOT_FOUND)
|
2016-12-13 07:23:08 +00:00
|
|
|
|
|
|
|
return web.Response(body=data, content_type=content)
|
2020-08-21 15:17:36 +00:00
|
|
|
|
|
|
|
|
|
|
|
def get_base_url(hass):
|
|
|
|
"""Get base URL."""
|
|
|
|
return hass.data[BASE_URL_KEY]
|