diff --git a/src/dialogs/tts-try/dialog-tts-try.ts b/src/dialogs/tts-try/dialog-tts-try.ts new file mode 100644 index 0000000000..025d647d38 --- /dev/null +++ b/src/dialogs/tts-try/dialog-tts-try.ts @@ -0,0 +1,211 @@ +import { mdiPlayCircleOutline } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { LocalStorage } from "../../common/decorators/local-storage"; +import { fireEvent } from "../../common/dom/fire_event"; +import "../../components/ha-button"; +import { createCloseHeading } from "../../components/ha-dialog"; +import "../../components/ha-textarea"; +import type { HaTextArea } from "../../components/ha-textarea"; +import { convertTextToSpeech } from "../../data/tts"; +import { HomeAssistant } from "../../types"; +import { showAlertDialog } from "../generic/show-dialog-box"; +import { TTSTryDialogParams } from "./show-dialog-tts-try"; +import "../../components/ha-circular-progress"; + +@customElement("dialog-tts-try") +export class TTSTryDialog extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _loadingExample = false; + + @state() private _params?: TTSTryDialogParams; + + @state() private _valid = false; + + @query("#message") private _messageInput?: HaTextArea; + + @LocalStorage("ttsTryMessages", false, false) private _messages?: Record< + string, + string + >; + + private _audio?: HTMLAudioElement; + + public showDialog(params: TTSTryDialogParams) { + this._params = params; + this._valid = Boolean(this._defaultMessage); + } + + public closeDialog() { + this._params = undefined; + if (this._audio) { + this._audio.pause(); + this._audio.removeEventListener("ended", this._audioEnded); + this._audio.removeEventListener("canplaythrough", this._audioCanPlay); + this._audio.removeEventListener("playing", this._audioPlaying); + this._audio.removeEventListener("error", this._audioError); + this._audio = undefined; + } + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private get _defaultMessage() { + const language = this._params!.language?.substring(0, 2); + const userLanguage = this.hass.locale.language.substring(0, 2); + // Load previous message in the right language + if (language && this._messages?.[language]) { + return this._messages[language]; + } + // Only display example message if it's interface language + if (language === userLanguage) { + return this.hass.localize("ui.dialogs.tts-try.message_example"); + } + return ""; + } + + protected render() { + if (!this._params) { + return nothing; + } + return html` + + + + ${this._loadingExample + ? html` + + ` + : html` + + + + `} + + `; + } + + private async _inputChanged() { + this._valid = Boolean(this._messageInput?.value); + } + + private async _playExample() { + const message = this._messageInput?.value; + if (!message) { + return; + } + + const platform = this._params!.engine; + const language = this._params!.language; + const voice = this._params!.voice; + + if (language) { + this._messages = { + ...this._messages, + [language.substring(0, 2)]: message, + }; + } + + this._loadingExample = true; + + if (!this._audio) { + this._audio = new Audio(); + this._audio.addEventListener("ended", this._audioEnded); + this._audio.addEventListener("canplaythrough", this._audioCanPlay); + this._audio.addEventListener("playing", this._audioPlaying); + this._audio.addEventListener("error", this._audioError); + } + let url; + try { + const result = await convertTextToSpeech(this.hass, { + platform, + message, + language, + options: { voice }, + }); + url = result.path; + } catch (err: any) { + this._loadingExample = false; + showAlertDialog(this, { + text: `Unable to load example. ${err.error || err.body || err}`, + warning: true, + }); + return; + } + this._audio.src = url; + } + + private _audioCanPlay = () => { + this._audio?.play(); + }; + + private _audioPlaying = () => { + this._loadingExample = false; + }; + + private _audioError = () => { + showAlertDialog(this, { title: "Error playing audio." }); + this._loadingExample = false; + this._audio?.removeAttribute("src"); + }; + + private _audioEnded = () => { + this._audio?.removeAttribute("src"); + }; + + static get styles(): CSSResultGroup { + return css` + ha-dialog { + --mdc-dialog-max-width: 500px; + } + ha-textarea, + ha-select { + width: 100%; + } + ha-select { + margin-top: 8px; + } + .loading { + height: 36px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-tts-try": TTSTryDialog; + } +} diff --git a/src/dialogs/tts-try/show-dialog-tts-try.ts b/src/dialogs/tts-try/show-dialog-tts-try.ts new file mode 100644 index 0000000000..d69311f748 --- /dev/null +++ b/src/dialogs/tts-try/show-dialog-tts-try.ts @@ -0,0 +1,21 @@ +import { fireEvent } from "../../common/dom/fire_event"; + +export interface TTSTryDialogParams { + engine: string; + language?: string; + voice?: string; +} + +export const loadTTSTryDialog = () => import("./dialog-tts-try"); + +export const showTTSTryDialog = ( + element: HTMLElement, + dialogParams: TTSTryDialogParams +): void => { + fireEvent(element, "show-dialog", { + addHistory: false, + dialogTag: "dialog-tts-try", + dialogImport: loadTTSTryDialog, + dialogParams, + }); +}; diff --git a/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-tts.ts b/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-tts.ts index be72b4913f..d4cb7991d4 100644 --- a/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-tts.ts +++ b/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-tts.ts @@ -1,10 +1,12 @@ -import { css, CSSResultGroup, html, LitElement } from "lit"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { LocalizeKeys } from "../../../../common/translations/localize"; -import { AssistPipeline } from "../../../../data/assist_pipeline"; -import { HomeAssistant } from "../../../../types"; +import "../../../../components/ha-button"; import "../../../../components/ha-form/ha-form"; +import { AssistPipeline } from "../../../../data/assist_pipeline"; +import { showTTSTryDialog } from "../../../../dialogs/tts-try/show-dialog-tts-try"; +import { HomeAssistant } from "../../../../types"; @customElement("assist-pipeline-detail-tts") export class AssistPipelineDetailTTS extends LitElement { @@ -38,8 +40,6 @@ export class AssistPipelineDetailTTS extends LitElement { }, } : { name: "", type: "constant" }, - - { name: "", type: "constant" }, { name: "tts_voice", selector: { @@ -63,25 +63,61 @@ export class AssistPipelineDetailTTS extends LitElement { protected render() { return html`
-
-

Text-to-speech

-

- When you are using the pipeline as a voice assistant, the - text-to-speech engine turns the conversation text responses into - audio. -

+
+
+

Text-to-speech

+

+ When you are using the pipeline as a voice assistant, the + text-to-speech engine turns the conversation text responses into + audio. +

+
+ +
+ + ${ + this.data?.tts_engine + ? html`` + : nothing + }
-
`; } + private async _preview() { + if (!this.data) return; + + const engine = this.data.tts_engine; + const language = this.data.tts_language || undefined; + const voice = this.data.tts_voice || undefined; + + if (!engine) return; + + showTTSTryDialog(this, { + engine, + language, + voice, + }); + } + private _supportedLanguagesChanged(ev) { this._supportedLanguages = ev.detail.value; } @@ -91,7 +127,8 @@ export class AssistPipelineDetailTTS extends LitElement { .section { border: 1px solid var(--divider-color); border-radius: 8px; - box-sizing: border-box; + } + .content { padding: 16px; } .intro { @@ -110,6 +147,10 @@ export class AssistPipelineDetailTTS extends LitElement { margin-top: 0; margin-bottom: 0; } + .footer { + border-top: 1px solid var(--divider-color); + padding: 8px 16px; + } `; } } diff --git a/src/translations/en.json b/src/translations/en.json index 1da760f2c0..2b65cf3942 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1330,6 +1330,13 @@ "enter_code": { "title": "Enter code", "input_label": "Code" + }, + "tts-try": { + "header": "Try Text to Speech", + "message": "Message", + "message_example": "Hello. How can I assist?", + "message_placeholder": "Enter a sentence to speak.", + "play": "Play" } }, "duration": { @@ -2035,6 +2042,7 @@ "update_assistant_action": "Update", "add_assistant_title": "Add assistant", "add_assistant_action": "Create", + "try_tts": "Try voice", "form": { "name": "Name", "conversation_engine": "Conversation agent",