From 75f228418dead4631f5c49509533168e66e072af Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 13 Jan 2021 11:07:03 +0100 Subject: [PATCH] Allow configuring default TTS voice for cloud (#8148) --- src/data/cloud.ts | 9 + src/data/tts.ts | 12 + .../config/cloud/account/cloud-account.js | 7 + .../config/cloud/account/cloud-tts-pref.ts | 310 ++++++++++++++++++ src/translations/en.json | 7 + 5 files changed, 345 insertions(+) create mode 100644 src/data/tts.ts create mode 100644 src/panels/config/cloud/account/cloud-tts-pref.ts diff --git a/src/data/cloud.ts b/src/data/cloud.ts index e261805f50..3aa285be0d 100644 --- a/src/data/cloud.ts +++ b/src/data/cloud.ts @@ -41,6 +41,7 @@ export interface CloudPreferences { }; alexa_report_state: boolean; google_report_state: boolean; + tts_default_voice: [string, string]; } export type CloudStatusLoggedIn = CloudStatusBase & { @@ -113,6 +114,7 @@ export const updateCloudPref = ( google_report_state?: CloudPreferences["google_report_state"]; google_default_expose?: CloudPreferences["google_default_expose"]; google_secure_devices_pin?: CloudPreferences["google_secure_devices_pin"]; + tts_default_voice?: CloudPreferences["tts_default_voice"]; } ) => hass.callWS({ @@ -144,3 +146,10 @@ export const updateCloudAlexaEntityConfig = ( entity_id: entityId, ...values, }); + +export interface CloudTTSInfo { + languages: Array<[string, string]>; +} + +export const getCloudTTSInfo = (hass: HomeAssistant) => + hass.callWS({ type: "cloud/tts/info" }); diff --git a/src/data/tts.ts b/src/data/tts.ts new file mode 100644 index 0000000000..23404087b9 --- /dev/null +++ b/src/data/tts.ts @@ -0,0 +1,12 @@ +import { HomeAssistant } from "../types"; + +export const convertTextToSpeech = ( + hass: HomeAssistant, + data: { + platform: string; + message: string; + cache?: boolean; + language?: string; + options?: Record; + } +) => hass.callApi<{ url: string }>("POST", "tts_get_url", data); diff --git a/src/panels/config/cloud/account/cloud-account.js b/src/panels/config/cloud/account/cloud-account.js index 2359d24067..1739d23bab 100644 --- a/src/panels/config/cloud/account/cloud-account.js +++ b/src/panels/config/cloud/account/cloud-account.js @@ -16,6 +16,7 @@ import "../../ha-config-section"; import "./cloud-alexa-pref"; import "./cloud-google-pref"; import "./cloud-remote-pref"; +import "./cloud-tts-pref"; import "./cloud-webhooks"; /* @@ -133,6 +134,12 @@ class CloudAccount extends EventsMixin(LocalizeMixin(PolymerElement)) { dir="[[_rtlDirection]]" > + + lang === defaultVoice[0] + ); + const defaultGenderEntryIndex = genders.findIndex( + ([gender]) => gender === defaultVoice[1] + ); + + return html` + +
+ + +  Example + +
+
+ ${this.hass.localize( + "ui.panel.config.cloud.account.tts.info", + "service", + '"tts.cloud_say"' + )} +

+ + + + ${languages.map( + ([key, label]) => + html`${label}` + )} + + + + + + ${genders.map( + ([key, label]) => + html`${label}` + )} + + +
+
+ `; + } + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + getCloudTTSInfo(this.hass).then((info) => { + this.ttsInfo = info; + }); + } + + protected updated(changedProps) { + super.updated(changedProps); + if (changedProps.has("cloudStatus")) { + this.savingPreferences = false; + } + } + + private getLanguages = memoizeOne((info?: CloudTTSInfo) => { + const languages: Array<[string, string]> = []; + + if (!info) { + return languages; + } + + const seen = new Set(); + for (const [lang] of info.languages) { + if (seen.has(lang)) { + continue; + } + seen.add(lang); + + let label = lang; + + if (lang in translationMetadata.translations) { + label = translationMetadata.translations[lang].nativeName; + } else { + const [langFamily, dialect] = lang.split("-"); + if (langFamily in translationMetadata.translations) { + label = `${translationMetadata.translations[langFamily].nativeName}`; + + if (langFamily.toLowerCase() !== dialect.toLowerCase()) { + label += ` (${dialect})`; + } + } + } + + languages.push([lang, label]); + } + return languages.sort((a, b) => caseInsensitiveCompare(a[1], b[1])); + }); + + private getSupportedGenders = memoizeOne( + (language: string, info?: CloudTTSInfo) => { + const genders: Array<[string, string]> = []; + + if (!info) { + return genders; + } + + for (const [curLang, gender] of info.languages) { + if (curLang === language) { + genders.push([ + gender, + this.hass.localize(`ui.panel.config.cloud.account.tts.${gender}`) || + gender, + ]); + } + } + + return genders.sort((a, b) => caseInsensitiveCompare(a[1], b[1])); + } + ); + + async _playExample() { + this.loadingExample = true; + const defaultVoice = this.cloudStatus!.prefs.tts_default_voice; + // Our example sentence is English. If user uses English voice, use that + // for example. + let language; + let gender; + if (defaultVoice[0].split("-")[0] === "en") { + language = defaultVoice[0]; + gender = defaultVoice[1]; + } else { + language = "en-US"; + gender = "female"; + } + + let url; + try { + const result = await convertTextToSpeech(this.hass, { + platform: "cloud", + message: `Hello ${ + this.hass.user!.name + }, you can play any text on any supported media player!`, + language, + options: { gender }, + }); + url = result.url; + } catch (err) { + this.loadingExample = false; + // eslint-disable-next-line no-console + console.error(err); + showAlertDialog(this, { + text: `Unable to load example. ${err}`, + warning: true, + }); + return; + } + const audio = new Audio(url); + audio.play(); + audio.addEventListener("playing", () => { + this.loadingExample = false; + }); + } + + async _handleLanguageChange(ev) { + this.savingPreferences = true; + const langLabel = ev.currentTarget.value; + const languages = this.getLanguages(this.ttsInfo); + const language = languages.find((item) => item[1] === langLabel)![0]; + + const curGender = this.cloudStatus!.prefs.tts_default_voice[1]; + const genders = this.getSupportedGenders(language, this.ttsInfo); + const newGender = genders.find((item) => item[0] === curGender) + ? curGender + : genders[0][0]; + + try { + await updateCloudPref(this.hass, { + tts_default_voice: [language, newGender], + }); + fireEvent(this, "ha-refresh-cloud-status"); + } catch (err) { + this.savingPreferences = false; + // eslint-disable-next-line no-console + console.error(err); + showAlertDialog(this, { + text: `Unable to save default language. ${err}`, + warning: true, + }); + } + } + + async _handleGenderChange(ev) { + this.savingPreferences = true; + const language = this.cloudStatus!.prefs.tts_default_voice[0]; + const genderLabel = ev.currentTarget.value; + const genders = this.getSupportedGenders(language, this.ttsInfo); + const gender = genders.find((item) => item[1] === genderLabel)![0]; + + try { + await updateCloudPref(this.hass, { + tts_default_voice: [language, gender], + }); + fireEvent(this, "ha-refresh-cloud-status"); + } catch (err) { + this.savingPreferences = false; + // eslint-disable-next-line no-console + console.error(err); + showAlertDialog(this, { + text: `Unable to save default gender. ${err}`, + warning: true, + }); + } + } + + static get styles(): CSSResult { + return css` + a { + color: var(--primary-color); + } + .example { + position: absolute; + right: 16px; + top: 16px; + } + :host([dir="rtl"]) .example { + right: auto; + left: 24px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "cloud-tts-pref": CloudTTSPref; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index fc1d6bcedc..1c3c097a75 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1655,6 +1655,13 @@ "connected": "Connected", "not_connected": "Not Connected", "fetching_subscription": "Fetching subscription…", + "tts": { + "title": "Text to Speech", + "info": "Bring personality to your home by having it speak to you by using our Text-to-Speech services. You can use this in automations and scripts by using the {service} service.", + "default_language": "Default language to use", + "male": "Male", + "female": "Female" + }, "remote": { "title": "Remote Control", "access_is_being_prepared": "Remote access is being prepared. We will notify you when it's ready.",