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

Google Cloud Platform component (TTS) #23629

Merged
merged 28 commits into from Jun 6, 2019
Merged
Changes from 25 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b8d3ad5
Added Google Cloud TTS service component feature
lufton May 2, 2019
a0eee36
Added Neutral voice gender
lufton May 2, 2019
ca9f74d
Added line break at the end of files
lufton May 2, 2019
3366917
Updated CODEOWNERS, reqirements_all.txt and .coveragerc
lufton May 2, 2019
886afc3
Fixed some ci/circleci: static-check errors
lufton May 2, 2019
bdef472
Fixed some ci/circleci: static-check error
lufton May 2, 2019
9069ca1
Fixed some ci/circleci: pylint errors
lufton May 2, 2019
398704c
Fixed some ci/circleci: pylint errors
lufton May 2, 2019
4b84ad4
* made supported_options const
lufton May 3, 2019
43d12ad
Fixed import order
lufton May 3, 2019
82b7b73
* Component renamed
lufton May 3, 2019
58fd922
Changed folder name in .coveragerc
lufton May 3, 2019
4ffb391
* Removed whitespaces in blank lines
lufton May 3, 2019
6ca61bd
Removed whitespaces in blank lines
lufton May 3, 2019
c6efab9
ci/circleci: static-check
lufton May 3, 2019
885bd53
Fixed requirements_all.txt
lufton May 3, 2019
e9c96ea
Added speed, pitch and gain parameters
lufton May 3, 2019
9916fe7
Added speed, pitch and gain as supported options
lufton May 3, 2019
d69de61
Split too long line
lufton May 3, 2019
2a1d81b
* Added profiles parameter
lufton May 5, 2019
11f4708
Fixes
lufton May 5, 2019
d116e8a
Fixes
lufton May 5, 2019
40cfc8e
Fixes
lufton May 5, 2019
0fb51af
Fixes
lufton May 5, 2019
e0274df
Fixes
lufton May 5, 2019
b3e66ad
Changed options validation
lufton May 14, 2019
0e27501
Added ToggleEntity save and restore state mechanism
lufton May 18, 2019
a80fa1d
Revert "Added ToggleEntity save and restore state mechanism"
lufton May 18, 2019
File filter...
Filter file types
Jump to…
Jump to file or symbol
Failed to load files and symbols.

Always

Just for now

@@ -220,6 +220,7 @@ omit =
homeassistant/components/goalfeed/*
homeassistant/components/gogogate2/cover.py
homeassistant/components/google/*
homeassistant/components/google_cloud/tts.py
homeassistant/components/google_maps/device_tracker.py
homeassistant/components/google_travel_time/sensor.py
homeassistant/components/googlehome/*
@@ -86,6 +86,7 @@ homeassistant/components/gearbest/* @HerrHofrat
homeassistant/components/gitter/* @fabaff
homeassistant/components/glances/* @fabaff
homeassistant/components/gntp/* @robbiet480
homeassistant/components/google_cloud/* @lufton
homeassistant/components/google_translate/* @awarecan
homeassistant/components/google_travel_time/* @robbiet480
homeassistant/components/googlehome/* @ludeeus
@@ -0,0 +1 @@
"""The google_cloud component."""
@@ -0,0 +1,12 @@
{
"domain": "google_cloud",
"name": "Google Cloud Platform",
"documentation": "https://www.home-assistant.io/components/google_cloud",
"requirements": [
"google-cloud-texttospeech==0.4.0"
],
"dependencies": [],
"codeowners": [
"@lufton"
]
}
@@ -0,0 +1,236 @@
"""Support for the Google Cloud TTS service."""
import logging
import os
import re
import asyncio
This conversation was marked as resolved by lufton

This comment has been minimized.

Copy link
@awarecan

awarecan May 10, 2019

Contributor

Add a blank line between built-in lib and 3rd party lib

import async_timeout
import voluptuous as vol
from google.cloud import texttospeech
from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider
This conversation was marked as resolved by lufton

This comment has been minimized.

Copy link
@awarecan

awarecan May 10, 2019

Contributor

Add a blank line between 3rd party lib and homeassistant

import homeassistant.helpers.config_validation as cv

_LOGGER = logging.getLogger(__name__)

CONF_KEY_FILE = 'key_file'
CONF_GENDER = 'gender'
CONF_VOICE = 'voice'
CONF_ENCODING = 'encoding'
CONF_SPEED = 'speed'
CONF_PITCH = 'pitch'
CONF_GAIN = 'gain'
CONF_PROFILES = 'profiles'

SUPPORTED_LANGUAGES = [
'da-DK', 'de-DE', 'en-AU', 'en-GB', 'en-US', 'es-ES', 'fr-CA', 'fr-FR',
'it-IT', 'ja-JP', 'ko-KR', 'nb-NO', 'nl-NL', 'pl-PL', 'pt-BR', 'pt-PT',
'ru-RU', 'sk-SK', 'sv-SE', 'tr-TR', 'uk-UA',
]
DEFAULT_LANG = 'en-US'

DEFAULT_GENDER = 'NEUTRAL'

VOICE_REGEX = r'[a-z]{2}-[A-Z]{2}-(Standard|Wavenet)-[A-Z]|'
DEFAULT_VOICE = ''

DEFAULT_ENCODING = 'OGG_OPUS'

MIN_SPEED = 0.25
MAX_SPEED = 4.0
DEFAULT_SPEED = 1.0

MIN_PITCH = -20.0
MAX_PITCH = 20.0
DEFAULT_PITCH = 0

MIN_GAIN = -96.0
MAX_GAIN = 16.0
DEFAULT_GAIN = 0

SUPPORTED_PROFILES = [
"wearable-class-device",
"handset-class-device",
"headphone-class-device",
"small-bluetooth-speaker-class-device",
"medium-bluetooth-speaker-class-device",
"large-home-entertainment-class-device",
"large-automotive-class-device",
"telephony-class-application",
]

SUPPORTED_OPTIONS = [
CONF_VOICE,
CONF_GENDER,
CONF_ENCODING,
CONF_SPEED,
CONF_PITCH,
CONF_GAIN,
CONF_PROFILES,
]

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Optional(CONF_KEY_FILE): cv.string,
vol.Optional(CONF_LANG, default=DEFAULT_LANG):
vol.In(SUPPORTED_LANGUAGES),
vol.Optional(CONF_GENDER, default=DEFAULT_GENDER):
vol.All(vol.Upper, vol.In(
texttospeech.enums.SsmlVoiceGender.__members__
)),
vol.Optional(CONF_VOICE, default=DEFAULT_VOICE):
cv.matches_regex(VOICE_REGEX),
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING):
vol.All(vol.Upper, vol.In(
texttospeech.enums.AudioEncoding.__members__
)),
vol.Optional(CONF_SPEED, default=DEFAULT_SPEED):
vol.All(vol.Coerce(float), vol.Clamp(min=MIN_SPEED, max=MAX_SPEED)),
vol.Optional(CONF_PITCH, default=DEFAULT_PITCH):
vol.All(vol.Coerce(float), vol.Clamp(min=MIN_PITCH, max=MAX_PITCH)),
vol.Optional(CONF_GAIN, default=DEFAULT_GAIN):
vol.All(vol.Coerce(float), vol.Clamp(min=MIN_GAIN, max=MAX_GAIN)),
vol.Optional(CONF_PROFILES, default=[]):
vol.All(cv.ensure_list, [vol.In(SUPPORTED_PROFILES)]),
})


async def async_get_engine(hass, config):
"""Set up Google Cloud TTS component."""
key_file = config.get(CONF_KEY_FILE)
if key_file:
key_file = hass.config.path(key_file)

This comment has been minimized.

Copy link
@awarecan

awarecan May 10, 2019

Contributor

I still suggest key_file use absolute path

This comment has been minimized.

Copy link
@lufton

lufton May 11, 2019

Author Contributor

Can you please explain than how user would reference that? Is there any way to reference config folder in configuration.yaml? Something like:

tts:
  - platform: google_cloud
    key_file: "<config>/myfile.json"

Or is user even able to put file elsewhere?
I'm using Hass.io that's why I'm asking. Because I only have access to config folder.

This comment has been minimized.

Copy link
@balloob

balloob Jun 6, 2019

Member

I suggest we keep hass.config.path. It means that if we specify a relative path, it is resolved relatively to the config dir. If it's an absolute path, it remains absolute.

if not os.path.isfile(key_file):
_LOGGER.error("File %s doesn't exist", key_file)
return None

return GoogleCloudTTSProvider(
hass,
key_file,
config.get(CONF_LANG),
config.get(CONF_GENDER),
config.get(CONF_VOICE),
config.get(CONF_ENCODING),
config.get(CONF_SPEED),
config.get(CONF_PITCH),
config.get(CONF_GAIN),
config.get(CONF_PROFILES)
)


class GoogleCloudTTSProvider(Provider):
"""The Google Cloud TTS API provider."""

def __init__(
self,
hass,
key_file=None,
language=DEFAULT_LANG,
gender=DEFAULT_GENDER,
voice=DEFAULT_VOICE,
encoding=DEFAULT_ENCODING,
speed=1.0,
pitch=0,
gain=0,
profiles=None
):
"""Init Google Cloud TTS service."""
self.hass = hass
self.name = 'Google Cloud TTS'
self._language = language
self._gender = gender
self._voice = voice
self._encoding = encoding
self._speed = speed
self._pitch = pitch
self._gain = gain
self._profiles = profiles

if key_file:
self._client = texttospeech \
.TextToSpeechClient.from_service_account_json(key_file)
else:
self._client = texttospeech.TextToSpeechClient()

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

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

@property
def supported_options(self):
"""Return a list of supported options."""
return SUPPORTED_OPTIONS

@property
def default_options(self):
"""Return a dict including default options."""
return {
CONF_GENDER: self._gender,
CONF_VOICE: self._voice,
CONF_ENCODING: self._encoding,
CONF_SPEED: self._speed,
CONF_PITCH: self._pitch,
CONF_GAIN: self._gain,
CONF_PROFILES: self._profiles
}

async def async_get_tts_audio(self, message, language, options=None):
"""Load TTS from google."""
_gender = options.get(CONF_GENDER).upper()
This conversation was marked as resolved by lufton

This comment has been minimized.

Copy link
@awarecan

awarecan May 10, 2019

Contributor

You can define an options_schema to validate input. If input option is not valid, an error should log, and return None

This comment has been minimized.

Copy link
@lufton

lufton May 11, 2019

Author Contributor

Can you show me an example or any kind of reference on how to do that?
I tried:

options_schema = vol.Schema({
    vol.Optional(CONF_GENDER, default=self._gender):
        vol.All(vol.Upper, vol.In(
            texttospeech.enums.SsmlVoiceGender.__members__
        )),
    ...
})

options_schema(options)

But this didn't update options. Gender is still lowercase and not set to default.

This comment has been minimized.

Copy link
@awarecan

awarecan May 14, 2019

Contributor
options = options_schema(options)

This comment has been minimized.

Copy link
@lufton

lufton May 14, 2019

Author Contributor

Thanks. That helped )

if _gender not in texttospeech.enums.SsmlVoiceGender.__members__:
_gender = self._gender

_voice = options.get(CONF_VOICE) or self._voice
if not re.match(VOICE_REGEX, _voice):
_voice = self._voice
if _voice and not _voice.startswith(language):
language = _voice[:5]

_encoding = options.get(CONF_ENCODING).upper()
if _encoding not in texttospeech.enums.AudioEncoding.__members__:
_encoding = self._encoding

_speed = options.get(CONF_SPEED)
_pitch = options.get(CONF_PITCH)
_gain = options.get(CONF_GAIN)
_profiles = options.get(CONF_PROFILES)

try:
# pylint: disable=no-member
synthesis_input = texttospeech.types.SynthesisInput(
text=message
)

voice = texttospeech.types.VoiceSelectionParams(
language_code=language,
ssml_gender=texttospeech.enums.SsmlVoiceGender[_gender],
name=_voice
)

audio_config = texttospeech.types.AudioConfig(
audio_encoding=texttospeech.enums.AudioEncoding[_encoding],
speaking_rate=max(min(_speed, MAX_SPEED), MIN_SPEED),
pitch=max(min(_pitch, MAX_PITCH), MIN_PITCH),
volume_gain_db=max(min(_gain, MAX_GAIN), MIN_GAIN),
effects_profile_id=_profiles or [],
This conversation was marked as resolved by awarecan

This comment has been minimized.

Copy link
@awarecan

awarecan May 10, 2019

Contributor

I think profile should be an input. So that user may choose apply different profile when the audio generated target different media player.

This comment has been minimized.

Copy link
@lufton

lufton May 11, 2019

Author Contributor

But isn't it?
It's included in platform schema:

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
    ...
    vol.Optional(CONF_PROFILES, default=[]):
        vol.All(cv.ensure_list, [vol.In(SUPPORTED_PROFILES)]),
})

Later it is referenced by async_get_engine:

async def async_get_engine(hass, config):
    ...
    return GoogleCloudTTSProvider(
        ...
        config.get(CONF_PROFILES)
    )

Default option is set to platform config option:

    def default_options(self):
        return {
            ...
            CONF_PROFILES: self._profiles
        }

So

    async def async_get_tts_audio(self, message, language, options=None):
        ...
        _profiles = options.get(CONF_PROFILES)

Should return user option or default option (witch should be equal to platform config option) or None.

)
# pylint: enable=no-member

This comment has been minimized.

Copy link
@awarecan

awarecan May 10, 2019

Contributor

Used anyway?

This comment has been minimized.

Copy link
@lufton

lufton May 11, 2019

Author Contributor

Disable before and re-enable after used those "not existing" methods.


with async_timeout.timeout(10, loop=self.hass.loop):
response = await self.hass.async_add_executor_job(
self._client.synthesize_speech,
synthesis_input,
voice,
audio_config
)
return _encoding, response.audio_content

except asyncio.TimeoutError as ex:
_LOGGER.error("Timeout for Google Cloud TTS call: %s", ex)
except Exception as ex: # pylint: disable=broad-except
_LOGGER.error("Error occured during Google Cloud TTS call: %s", ex)
This conversation was marked as resolved by lufton

This comment has been minimized.

Copy link
@awarecan

awarecan May 10, 2019

Contributor

use _LOGGER.exception to log stack trace.

This comment has been minimized.

Copy link
@lufton

lufton May 14, 2019

Author Contributor

Thanks


return None, None
@@ -499,6 +499,9 @@ google-api-python-client==1.6.4
# homeassistant.components.google_pubsub
google-cloud-pubsub==0.39.1

# homeassistant.components.google_cloud
google-cloud-texttospeech==0.4.0

# homeassistant.components.googlehome
googledevices==1.0.2

ProTip! Use n and p to navigate between commits in a pull request.
You can’t perform that action at this time.