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

Use speak2mary for MaryTTS integration and enable sound effects #30805

Merged
merged 2 commits into from Jan 23, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion homeassistant/components/marytts/manifest.json
Expand Up @@ -2,7 +2,9 @@
"domain": "marytts",
"name": "MaryTTS",
"documentation": "https://www.home-assistant.io/integrations/marytts",
"requirements": [],
"requirements": [
"speak2mary==1.4.0"
],
"dependencies": [],
"codeowners": []
}
87 changes: 37 additions & 50 deletions homeassistant/components/marytts/tts.py
@@ -1,31 +1,29 @@
"""Support for the MaryTTS service."""
import asyncio
import logging
import re

import aiohttp
import async_timeout
from speak2mary import MaryTTS
import voluptuous as vol

from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.const import CONF_EFFECT, CONF_HOST, CONF_PORT
import homeassistant.helpers.config_validation as cv

_LOGGER = logging.getLogger(__name__)

SUPPORT_LANGUAGES = ["de", "en-GB", "en-US", "fr", "it", "lb", "ru", "sv", "te", "tr"]

SUPPORT_CODEC = ["aiff", "au", "wav"]

CONF_VOICE = "voice"
CONF_CODEC = "codec"

SUPPORT_LANGUAGES = MaryTTS.supported_locales()
SUPPORT_CODEC = MaryTTS.supported_codecs()
SUPPORT_OPTIONS = [CONF_EFFECT]
SUPPORT_EFFECTS = MaryTTS.supported_effects().keys()

DEFAULT_HOST = "localhost"
DEFAULT_PORT = 59125
DEFAULT_LANG = "en-US"
DEFAULT_LANG = "en_US"
DEFAULT_VOICE = "cmu-slt-hsmm"
DEFAULT_CODEC = "wav"
DEFAULT_CODEC = "WAVE_FILE"
DEFAULT_EFFECTS = {}

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
Expand All @@ -34,6 +32,9 @@
vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES),
vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): cv.string,
vol.Optional(CONF_CODEC, default=DEFAULT_CODEC): vol.In(SUPPORT_CODEC),
vol.Optional(CONF_EFFECT, default=DEFAULT_EFFECTS): {
vol.All(cv.string, vol.In(SUPPORT_EFFECTS)): cv.string
},
}
)

Expand All @@ -49,57 +50,43 @@ class MaryTTSProvider(Provider):
def __init__(self, hass, conf):
"""Init MaryTTS TTS service."""
self.hass = hass
self._host = conf.get(CONF_HOST)
self._port = conf.get(CONF_PORT)
self._codec = conf.get(CONF_CODEC)
self._voice = conf.get(CONF_VOICE)
self._language = conf.get(CONF_LANG)
self._mary = MaryTTS(
conf.get(CONF_HOST),
conf.get(CONF_PORT),
conf.get(CONF_CODEC),
conf.get(CONF_LANG),
conf.get(CONF_VOICE),
)
self._effects = conf.get(CONF_EFFECT)
self.name = "MaryTTS"

@property
def default_language(self):
"""Return the default language."""
return self._language
return self._mary.locale

@property
def supported_languages(self):
"""Return list of supported languages."""
return SUPPORT_LANGUAGES

async def async_get_tts_audio(self, message, language, options=None):
"""Load TTS from MaryTTS."""
websession = async_get_clientsession(self.hass)

actual_language = re.sub("-", "_", language)

try:
with async_timeout.timeout(10):
url = f"http://{self._host}:{self._port}/process?"

audio = self._codec.upper()
if audio == "WAV":
audio = "WAVE"
@property
def default_options(self):
"""Return dict include default options."""
return {CONF_EFFECT: DEFAULT_EFFECTS}
Copy link
Member

Choose a reason for hiding this comment

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

Why is that static? The user can overwrite the defaults per initial config

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Jup, you are correct. With the latest commit, the defaults are the initial effect settings


url_param = {
"INPUT_TEXT": message,
"INPUT_TYPE": "TEXT",
"AUDIO": audio,
"VOICE": self._voice,
"OUTPUT_TYPE": "AUDIO",
"LOCALE": actual_language,
}
@property
def supported_options(self):
"""Return a list of supported options."""
return SUPPORT_OPTIONS

request = await websession.get(url, params=url_param)
async def async_get_tts_audio(self, message, language, options=None):
"""Load TTS from MaryTTS."""
effects = self._effects

if request.status != 200:
_LOGGER.error(
"Error %d on load url %s", request.status, request.url
)
return (None, None)
data = await request.read()
if options is not None and CONF_EFFECT in options:
effects.update(options[CONF_EFFECT])
Copy link
Member

Choose a reason for hiding this comment

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

that means, you will be never able to overwrite the effects from config. Maybe:
effects = options[CONF_EFFECT]?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I understood the options parameter as additional options during the service call.
Can you explain where the options parameter get its value?

I intended to use the config from yaml and let it be overwritten by service call options.

Copy link
Member

Choose a reason for hiding this comment

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

options are they from the service call. I want only prevent to have a bug :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since the file config is coming as default, it will change it to your suggestion.


except (asyncio.TimeoutError, aiohttp.ClientError):
_LOGGER.error("Timeout for MaryTTS API")
return (None, None)
data = self._mary.speak(message, effects)

return (self._codec, data)
return self._mary.codec, data
3 changes: 3 additions & 0 deletions requirements_all.txt
Expand Up @@ -1871,6 +1871,9 @@ somecomfort==0.5.2
# homeassistant.components.somfy_mylink
somfy-mylink-synergy==1.0.6

# homeassistant.components.marytts
speak2mary==1.4.0

# homeassistant.components.speedtestdotnet
speedtest-cli==2.1.2

Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Expand Up @@ -599,6 +599,9 @@ solaredge==0.0.2
# homeassistant.components.honeywell
somecomfort==0.5.2

# homeassistant.components.marytts
speak2mary==1.4.0

# homeassistant.components.recorder
# homeassistant.components.sql
sqlalchemy==1.3.12
Expand Down
82 changes: 54 additions & 28 deletions tests/components/marytts/test_tts.py
@@ -1,17 +1,19 @@
"""The tests for the MaryTTS speech platform."""
import asyncio
import os
import shutil
from urllib.parse import urlencode

from mock import Mock, patch

from homeassistant.components.media_player.const import (
ATTR_MEDIA_CONTENT_ID,
DOMAIN as DOMAIN_MP,
SERVICE_PLAY_MEDIA,
)
import homeassistant.components.tts as tts
from homeassistant.setup import setup_component

from tests.common import assert_setup_component, get_test_home_assistant, mock_service
from tests.components.tts.test_init import mutagen_mock # noqa: F401


class TestTTSMaryTTSPlatform:
Expand All @@ -21,14 +23,15 @@ def setup_method(self):
"""Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()

self.url = "http://localhost:59125/process?"
self.url_param = {
self.host = "localhost"
self.port = 59125
self.params = {
"INPUT_TEXT": "HomeAssistant",
"INPUT_TYPE": "TEXT",
"AUDIO": "WAVE",
"VOICE": "cmu-slt-hsmm",
"OUTPUT_TYPE": "AUDIO",
"LOCALE": "en_US",
"AUDIO": "WAVE_FILE",
"VOICE": "cmu-slt-hsmm",
}

def teardown_method(self):
Expand All @@ -46,60 +49,83 @@ def test_setup_component(self):
with assert_setup_component(1, tts.DOMAIN):
setup_component(self.hass, tts.DOMAIN, config)

def test_service_say(self, aioclient_mock):
def test_service_say(self):
"""Test service call say."""
calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)

aioclient_mock.get(self.url, params=self.url_param, status=200, content=b"test")
conn = Mock()
response = Mock()
conn.getresponse.return_value = response
response.status = 200
response.read.return_value = b"audio"

config = {tts.DOMAIN: {"platform": "marytts"}}

with assert_setup_component(1, tts.DOMAIN):
setup_component(self.hass, tts.DOMAIN, config)

self.hass.services.call(
tts.DOMAIN, "marytts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
)
with patch("http.client.HTTPConnection", return_value=conn):
self.hass.services.call(
tts.DOMAIN, "marytts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
)
self.hass.block_till_done()

assert len(aioclient_mock.mock_calls) == 1
assert len(calls) == 1
assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find(".wav") != -1
conn.request.assert_called_with("POST", "/process", urlencode(self.params))

def test_service_say_timeout(self, aioclient_mock):
"""Test service call say."""
def test_service_say_with_effect(self):
"""Test service call say with effects."""
calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)

aioclient_mock.get(
self.url, params=self.url_param, status=200, exc=asyncio.TimeoutError()
)
conn = Mock()
response = Mock()
conn.getresponse.return_value = response
response.status = 200
response.read.return_value = b"audio"

config = {tts.DOMAIN: {"platform": "marytts"}}
config = {
tts.DOMAIN: {"platform": "marytts", "effect": {"Volume": "amount:2.0;"}}
}

with assert_setup_component(1, tts.DOMAIN):
setup_component(self.hass, tts.DOMAIN, config)

self.hass.services.call(
tts.DOMAIN, "marytts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
)
with patch("http.client.HTTPConnection", return_value=conn):
self.hass.services.call(
tts.DOMAIN, "marytts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
)
self.hass.block_till_done()

assert len(calls) == 0
assert len(aioclient_mock.mock_calls) == 1
assert len(calls) == 1
assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find(".wav") != -1

def test_service_say_http_error(self, aioclient_mock):
self.params.update(
{"effect_Volume_selected": "on", "effect_Volume_parameters": "amount:2.0;"}
)
conn.request.assert_called_with("POST", "/process", urlencode(self.params))

def test_service_say_http_error(self):
"""Test service call say."""
calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)

aioclient_mock.get(self.url, params=self.url_param, status=403, content=b"test")
conn = Mock()
response = Mock()
conn.getresponse.return_value = response
response.status = 500
response.reason = "test"
response.readline.return_value = "content"

config = {tts.DOMAIN: {"platform": "marytts"}}

with assert_setup_component(1, tts.DOMAIN):
setup_component(self.hass, tts.DOMAIN, config)

self.hass.services.call(
tts.DOMAIN, "marytts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
)
with patch("http.client.HTTPConnection", return_value=conn):
self.hass.services.call(
tts.DOMAIN, "marytts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
)
self.hass.block_till_done()

assert len(calls) == 0
conn.request.assert_called_with("POST", "/process", urlencode(self.params))