Allow configuring default TTS voice for cloud (#8148)

This commit is contained in:
Paulus Schoutsen 2021-01-13 11:07:03 +01:00 committed by GitHub
parent 41f8b0d19b
commit 75f228418d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 345 additions and 0 deletions

View File

@ -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<CloudTTSInfo>({ type: "cloud/tts/info" });

12
src/data/tts.ts Normal file
View File

@ -0,0 +1,12 @@
import { HomeAssistant } from "../types";
export const convertTextToSpeech = (
hass: HomeAssistant,
data: {
platform: string;
message: string;
cache?: boolean;
language?: string;
options?: Record<string, unknown>;
}
) => hass.callApi<{ url: string }>("POST", "tts_get_url", data);

View File

@ -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]]"
></cloud-remote-pref>
<cloud-tts-pref
hass="[[hass]]"
cloud-status="[[cloudStatus]]"
dir="[[_rtlDirection]]"
></cloud-tts-pref>
<cloud-alexa-pref
hass="[[hass]]"
cloud-status="[[cloudStatus]]"

View File

@ -0,0 +1,310 @@
import "@polymer/paper-dropdown-menu/paper-dropdown-menu-light";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-listbox/paper-listbox";
import "@material/mwc-button";
import { mdiPlayCircleOutline } from "@mdi/js";
import {
css,
CSSResult,
customElement,
html,
internalProperty,
LitElement,
property,
TemplateResult,
} from "lit-element";
import "../../../../components/ha-card";
import "../../../../components/ha-switch";
import "../../../../components/ha-svg-icon";
import {
CloudStatusLoggedIn,
CloudTTSInfo,
getCloudTTSInfo,
updateCloudPref,
} from "../../../../data/cloud";
import type { HomeAssistant } from "../../../../types";
import { convertTextToSpeech } from "../../../../data/tts";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import { translationMetadata } from "../../../../resources/translations-metadata";
import { caseInsensitiveCompare } from "../../../../common/string/compare";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
@customElement("cloud-tts-pref")
export class CloudTTSPref extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public cloudStatus?: CloudStatusLoggedIn;
@internalProperty() private loadingExample = false;
@internalProperty() private savingPreferences = false;
@internalProperty() private ttsInfo?: CloudTTSInfo;
protected render(): TemplateResult {
if (!this.cloudStatus) {
return html``;
}
const languages = this.getLanguages(this.ttsInfo);
const defaultVoice = this.cloudStatus.prefs.tts_default_voice;
const genders = this.getSupportedGenders(defaultVoice[0], this.ttsInfo);
const defaultLangEntryIndex = languages.findIndex(
([lang]) => lang === defaultVoice[0]
);
const defaultGenderEntryIndex = genders.findIndex(
([gender]) => gender === defaultVoice[1]
);
return html`
<ha-card
header=${this.hass.localize("ui.panel.config.cloud.account.tts.title")}
>
<div class="example">
<mwc-button
@click=${this._playExample}
.disabled=${this.loadingExample}
>
<ha-svg-icon .path=${mdiPlayCircleOutline}></ha-svg-icon>
&nbsp;Example
</mwc-button>
</div>
<div class="card-content">
${this.hass.localize(
"ui.panel.config.cloud.account.tts.info",
"service",
'"tts.cloud_say"'
)}
<br /><br />
<paper-dropdown-menu-light
.label=${this.hass.localize(
"ui.panel.config.cloud.account.tts.default_language"
)}
.value=${defaultLangEntryIndex !== -1
? languages[defaultLangEntryIndex][1]
: ""}
.disabled=${this.savingPreferences}
@iron-select=${this._handleLanguageChange}
>
<paper-listbox
slot="dropdown-content"
.selected=${defaultLangEntryIndex}
>
${languages.map(
([key, label]) =>
html`<paper-item .value=${key}>${label}</paper-item>`
)}
</paper-listbox>
</paper-dropdown-menu-light>
<paper-dropdown-menu-light
.value=${defaultGenderEntryIndex !== -1
? genders[defaultGenderEntryIndex][1]
: ""}
.disabled=${this.savingPreferences}
@iron-select=${this._handleGenderChange}
>
<paper-listbox
slot="dropdown-content"
.selected=${defaultGenderEntryIndex}
>
${genders.map(
([key, label]) =>
html`<paper-item .value=${key}>${label}</paper-item>`
)}
</paper-listbox>
</paper-dropdown-menu-light>
</div>
</ha-card>
`;
}
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<string>();
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;
}
}

View File

@ -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.",