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