mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-26 02:36:37 +00:00
Use assist_pipeline in voice command dialog (#16257)
* Remove speech recognition, add basic pipeline support * Add basic voice support * cleanup * only use tts if pipeline supports it * Update ha-voice-command-dialog.ts * Fix types * handle stop during stt * Revert "Fix types" This reverts commit 741781e392048d2e29594e388386bebce70ff68e. * active read only * Update ha-voice-command-dialog.ts --------- Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
This commit is contained in:
parent
85a27e8bb1
commit
1ded47d368
@ -1,7 +0,0 @@
|
|||||||
export const SpeechRecognition =
|
|
||||||
window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
||||||
export const SpeechGrammarList =
|
|
||||||
window.SpeechGrammarList || window.webkitSpeechGrammarList;
|
|
||||||
export const SpeechRecognitionEvent =
|
|
||||||
// @ts-expect-error
|
|
||||||
window.SpeechRecognitionEvent || window.webkitSpeechRecognitionEvent;
|
|
@ -9,7 +9,7 @@ import {
|
|||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { fireEvent } from "../common/dom/fire_event";
|
import { fireEvent } from "../common/dom/fire_event";
|
||||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||||
import { AssistPipeline, fetchAssistPipelines } from "../data/assist_pipeline";
|
import { AssistPipeline, listAssistPipelines } from "../data/assist_pipeline";
|
||||||
import { HomeAssistant } from "../types";
|
import { HomeAssistant } from "../types";
|
||||||
import "./ha-list-item";
|
import "./ha-list-item";
|
||||||
import "./ha-select";
|
import "./ha-select";
|
||||||
@ -71,7 +71,7 @@ export class HaAssistPipelinePicker extends LitElement {
|
|||||||
changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>
|
changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>
|
||||||
): void {
|
): void {
|
||||||
super.firstUpdated(changedProperties);
|
super.firstUpdated(changedProperties);
|
||||||
fetchAssistPipelines(this.hass).then((pipelines) => {
|
listAssistPipelines(this.hass).then((pipelines) => {
|
||||||
this._pipelines = pipelines.pipelines;
|
this._pipelines = pipelines.pipelines;
|
||||||
this._preferredPipeline = pipelines.preferred_pipeline;
|
this._preferredPipeline = pipelines.preferred_pipeline;
|
||||||
});
|
});
|
||||||
|
@ -210,14 +210,15 @@ export const processEvent = (
|
|||||||
return run;
|
return run;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runAssistPipeline = (
|
export const runDebugAssistPipeline = (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
callback: (event: PipelineRun) => void,
|
callback: (run: PipelineRun) => void,
|
||||||
options: PipelineRunOptions
|
options: PipelineRunOptions
|
||||||
) => {
|
) => {
|
||||||
let run: PipelineRun | undefined;
|
let run: PipelineRun | undefined;
|
||||||
|
|
||||||
const unsubProm = hass.connection.subscribeMessage<PipelineRunEvent>(
|
const unsubProm = runAssistPipeline(
|
||||||
|
hass,
|
||||||
(updateEvent) => {
|
(updateEvent) => {
|
||||||
run = processEvent(run, updateEvent, options);
|
run = processEvent(run, updateEvent, options);
|
||||||
|
|
||||||
@ -229,15 +230,22 @@ export const runAssistPipeline = (
|
|||||||
callback(run);
|
callback(run);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
options
|
||||||
...options,
|
|
||||||
type: "assist_pipeline/run",
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return unsubProm;
|
return unsubProm;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const runAssistPipeline = (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
callback: (event: PipelineRunEvent) => void,
|
||||||
|
options: PipelineRunOptions
|
||||||
|
) =>
|
||||||
|
hass.connection.subscribeMessage<PipelineRunEvent>(callback, {
|
||||||
|
...options,
|
||||||
|
type: "assist_pipeline/run",
|
||||||
|
});
|
||||||
|
|
||||||
export const listAssistPipelineRuns = (
|
export const listAssistPipelineRuns = (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
pipeline_id: string
|
pipeline_id: string
|
||||||
@ -262,7 +270,7 @@ export const getAssistPipelineRun = (
|
|||||||
pipeline_run_id,
|
pipeline_run_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fetchAssistPipelines = (hass: HomeAssistant) =>
|
export const listAssistPipelines = (hass: HomeAssistant) =>
|
||||||
hass.callWS<{
|
hass.callWS<{
|
||||||
pipelines: AssistPipeline[];
|
pipelines: AssistPipeline[];
|
||||||
preferred_pipeline: string | null;
|
preferred_pipeline: string | null;
|
||||||
@ -270,6 +278,12 @@ export const fetchAssistPipelines = (hass: HomeAssistant) =>
|
|||||||
type: "assist_pipeline/pipeline/list",
|
type: "assist_pipeline/pipeline/list",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getAssistPipeline = (hass: HomeAssistant, pipeline_id?: string) =>
|
||||||
|
hass.callWS<AssistPipeline>({
|
||||||
|
type: "assist_pipeline/pipeline/get",
|
||||||
|
pipeline_id,
|
||||||
|
});
|
||||||
|
|
||||||
export const createAssistPipeline = (
|
export const createAssistPipeline = (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
pipeline: AssistPipelineMutableParams
|
pipeline: AssistPipelineMutableParams
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable lit/prefer-static-styles */
|
|
||||||
import "@material/mwc-button/mwc-button";
|
import "@material/mwc-button/mwc-button";
|
||||||
import {
|
import {
|
||||||
mdiClose,
|
mdiClose,
|
||||||
@ -11,28 +10,28 @@ import {
|
|||||||
CSSResultGroup,
|
CSSResultGroup,
|
||||||
html,
|
html,
|
||||||
LitElement,
|
LitElement,
|
||||||
PropertyValues,
|
|
||||||
nothing,
|
nothing,
|
||||||
|
PropertyValues,
|
||||||
} from "lit";
|
} from "lit";
|
||||||
import { customElement, property, query, state } from "lit/decorators";
|
import { customElement, property, query, state } from "lit/decorators";
|
||||||
import { classMap } from "lit/directives/class-map";
|
import { LocalStorage } from "../../common/decorators/local-storage";
|
||||||
import { fireEvent } from "../../common/dom/fire_event";
|
import { fireEvent } from "../../common/dom/fire_event";
|
||||||
import { SpeechRecognition } from "../../common/dom/speech-recognition";
|
|
||||||
import "../../components/ha-dialog";
|
import "../../components/ha-dialog";
|
||||||
import type { HaDialog } from "../../components/ha-dialog";
|
|
||||||
import "../../components/ha-header-bar";
|
import "../../components/ha-header-bar";
|
||||||
import "../../components/ha-icon-button";
|
import "../../components/ha-icon-button";
|
||||||
import "../../components/ha-textfield";
|
import "../../components/ha-textfield";
|
||||||
import type { HaTextField } from "../../components/ha-textfield";
|
import type { HaTextField } from "../../components/ha-textfield";
|
||||||
import {
|
import {
|
||||||
AgentInfo,
|
AssistPipeline,
|
||||||
getAgentInfo,
|
getAssistPipeline,
|
||||||
prepareConversation,
|
runAssistPipeline,
|
||||||
processConversationInput,
|
} from "../../data/assist_pipeline";
|
||||||
} from "../../data/conversation";
|
import { AgentInfo, getAgentInfo } from "../../data/conversation";
|
||||||
import { haStyleDialog } from "../../resources/styles";
|
import { haStyleDialog } from "../../resources/styles";
|
||||||
import type { HomeAssistant } from "../../types";
|
import type { HomeAssistant } from "../../types";
|
||||||
|
import { AudioRecorder } from "../../util/audio-recorder";
|
||||||
import { documentationUrl } from "../../util/documentation-url";
|
import { documentationUrl } from "../../util/documentation-url";
|
||||||
|
import { showAlertDialog } from "../generic/show-dialog-box";
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
who: string;
|
who: string;
|
||||||
@ -40,49 +39,53 @@ interface Message {
|
|||||||
error?: boolean;
|
error?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Results {
|
|
||||||
transcript: string;
|
|
||||||
final: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@customElement("ha-voice-command-dialog")
|
@customElement("ha-voice-command-dialog")
|
||||||
export class HaVoiceCommandDialog extends LitElement {
|
export class HaVoiceCommandDialog extends LitElement {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
@property() public results: Results | null = null;
|
@state() private _conversation?: Message[];
|
||||||
|
|
||||||
@state() private _conversation: Message[] = [
|
|
||||||
{
|
|
||||||
who: "hass",
|
|
||||||
text: "",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
@state() private _opened = false;
|
@state() private _opened = false;
|
||||||
|
|
||||||
|
@LocalStorage("AssistPipelineId", true, false) private _pipelineId?: string;
|
||||||
|
|
||||||
|
@state() private _pipeline?: AssistPipeline;
|
||||||
|
|
||||||
@state() private _agentInfo?: AgentInfo;
|
@state() private _agentInfo?: AgentInfo;
|
||||||
|
|
||||||
@state() private _showSendButton = false;
|
@state() private _showSendButton = false;
|
||||||
|
|
||||||
@query("#scroll-container") private _scrollContainer!: HaDialog;
|
@query("#scroll-container") private _scrollContainer!: HTMLDivElement;
|
||||||
|
|
||||||
@query("#message-input") private _messageInput!: HaTextField;
|
@query("#message-input") private _messageInput!: HaTextField;
|
||||||
|
|
||||||
private recognition!: SpeechRecognition;
|
|
||||||
|
|
||||||
private _conversationId: string | null = null;
|
private _conversationId: string | null = null;
|
||||||
|
|
||||||
|
private _audioRecorder?: AudioRecorder;
|
||||||
|
|
||||||
|
private _audioBuffer?: Int16Array[];
|
||||||
|
|
||||||
|
private _stt_binary_handler_id?: number | null;
|
||||||
|
|
||||||
public async showDialog(): Promise<void> {
|
public async showDialog(): Promise<void> {
|
||||||
|
this._conversation = [
|
||||||
|
{
|
||||||
|
who: "hass",
|
||||||
|
text: this.hass.localize("ui.dialogs.voice_command.how_can_i_help"),
|
||||||
|
},
|
||||||
|
];
|
||||||
this._opened = true;
|
this._opened = true;
|
||||||
this._agentInfo = await getAgentInfo(this.hass);
|
await this.updateComplete;
|
||||||
this._scrollMessagesBottom();
|
this._scrollMessagesBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async closeDialog(): Promise<void> {
|
public async closeDialog(): Promise<void> {
|
||||||
this._opened = false;
|
this._opened = false;
|
||||||
if (this.recognition) {
|
this._agentInfo = undefined;
|
||||||
this.recognition.abort();
|
this._conversation = undefined;
|
||||||
}
|
this._conversationId = null;
|
||||||
|
this._audioRecorder?.close();
|
||||||
|
this._audioRecorder = undefined;
|
||||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,6 +93,7 @@ export class HaVoiceCommandDialog extends LitElement {
|
|||||||
if (!this._opened) {
|
if (!this._opened) {
|
||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
const supportsSTT = this._pipeline?.stt_engine && AudioRecorder.isSupported;
|
||||||
return html`
|
return html`
|
||||||
<ha-dialog
|
<ha-dialog
|
||||||
open
|
open
|
||||||
@ -123,25 +127,13 @@ export class HaVoiceCommandDialog extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
<div class="messages">
|
<div class="messages">
|
||||||
<div class="messages-container" id="scroll-container">
|
<div class="messages-container" id="scroll-container">
|
||||||
${this._conversation.map(
|
${this._conversation!.map(
|
||||||
(message) => html`
|
(message) => html`
|
||||||
<div class=${this._computeMessageClasses(message)}>
|
<div class=${this._computeMessageClasses(message)}>
|
||||||
${message.text}
|
${message.text}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
)}
|
)}
|
||||||
${this.results
|
|
||||||
? html`
|
|
||||||
<div class="message user">
|
|
||||||
<span
|
|
||||||
class=${classMap({
|
|
||||||
interimTranscript: !this.results.final,
|
|
||||||
})}
|
|
||||||
>${this.results.transcript}</span
|
|
||||||
>${!this.results.final ? "…" : ""}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: ""}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="input" slot="primaryAction">
|
<div class="input" slot="primaryAction">
|
||||||
@ -166,9 +158,9 @@ export class HaVoiceCommandDialog extends LitElement {
|
|||||||
>
|
>
|
||||||
</ha-icon-button>
|
</ha-icon-button>
|
||||||
`
|
`
|
||||||
: SpeechRecognition
|
: supportsSTT
|
||||||
? html`
|
? html`
|
||||||
${this.results
|
${this._audioRecorder?.active
|
||||||
? html`
|
? html`
|
||||||
<div class="bouncer">
|
<div class="bouncer">
|
||||||
<div class="double-bounce1"></div>
|
<div class="double-bounce1"></div>
|
||||||
@ -205,15 +197,18 @@ export class HaVoiceCommandDialog extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected firstUpdated(changedProps: PropertyValues) {
|
protected willUpdate(changedProperties: PropertyValues): void {
|
||||||
super.updated(changedProps);
|
if (!this.hasUpdated || changedProperties.has("_pipelineId")) {
|
||||||
this._conversation = [
|
this._getPipeline();
|
||||||
{
|
}
|
||||||
who: "hass",
|
}
|
||||||
text: this.hass.localize("ui.dialogs.voice_command.how_can_i_help"),
|
|
||||||
},
|
private async _getPipeline() {
|
||||||
];
|
this._pipeline = await getAssistPipeline(this.hass, this._pipelineId);
|
||||||
prepareConversation(this.hass, this.hass.language);
|
this._agentInfo = await getAgentInfo(
|
||||||
|
this.hass,
|
||||||
|
this._pipeline.conversation_engine
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected updated(changedProps: PropertyValues) {
|
protected updated(changedProps: PropertyValues) {
|
||||||
@ -224,7 +219,7 @@ export class HaVoiceCommandDialog extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _addMessage(message: Message) {
|
private _addMessage(message: Message) {
|
||||||
this._conversation = [...this._conversation, message];
|
this._conversation = [...this._conversation!, message];
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handleKeyUp(ev: KeyboardEvent) {
|
private _handleKeyUp(ev: KeyboardEvent) {
|
||||||
@ -253,75 +248,7 @@ export class HaVoiceCommandDialog extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _initRecognition() {
|
|
||||||
this.recognition = new SpeechRecognition();
|
|
||||||
this.recognition.interimResults = true;
|
|
||||||
this.recognition.lang = this.hass.language;
|
|
||||||
this.recognition.continuous = false;
|
|
||||||
|
|
||||||
this.recognition.addEventListener("start", () => {
|
|
||||||
this.results = {
|
|
||||||
final: false,
|
|
||||||
transcript: "",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
this.recognition.addEventListener("nomatch", () => {
|
|
||||||
this._addMessage({
|
|
||||||
who: "user",
|
|
||||||
text: `<${this.hass.localize(
|
|
||||||
"ui.dialogs.voice_command.did_not_understand"
|
|
||||||
)}>`,
|
|
||||||
error: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
this.recognition.addEventListener("error", (event) => {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
console.error("Error recognizing text", event);
|
|
||||||
this.recognition!.abort();
|
|
||||||
// @ts-ignore
|
|
||||||
if (event.error !== "aborted" && event.error !== "no-speech") {
|
|
||||||
const text =
|
|
||||||
this.results && this.results.transcript
|
|
||||||
? this.results.transcript
|
|
||||||
: `<${this.hass.localize(
|
|
||||||
"ui.dialogs.voice_command.did_not_hear"
|
|
||||||
)}>`;
|
|
||||||
this._addMessage({ who: "user", text, error: true });
|
|
||||||
}
|
|
||||||
this.results = null;
|
|
||||||
});
|
|
||||||
this.recognition.addEventListener("end", () => {
|
|
||||||
// Already handled by onerror
|
|
||||||
if (this.results == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const text = this.results.transcript;
|
|
||||||
this.results = null;
|
|
||||||
if (text) {
|
|
||||||
this._processText(text);
|
|
||||||
} else {
|
|
||||||
this._addMessage({
|
|
||||||
who: "user",
|
|
||||||
text: `<${this.hass.localize(
|
|
||||||
"ui.dialogs.voice_command.did_not_hear"
|
|
||||||
)}>`,
|
|
||||||
error: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.recognition.addEventListener("result", (event) => {
|
|
||||||
const result = event.results[0];
|
|
||||||
this.results = {
|
|
||||||
transcript: result[0].transcript,
|
|
||||||
final: result.isFinal,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _processText(text: string) {
|
private async _processText(text: string) {
|
||||||
if (this.recognition) {
|
|
||||||
this.recognition.abort();
|
|
||||||
}
|
|
||||||
this._addMessage({ who: "user", text });
|
this._addMessage({ who: "user", text });
|
||||||
const message: Message = {
|
const message: Message = {
|
||||||
who: "hass",
|
who: "hass",
|
||||||
@ -330,21 +257,33 @@ export class HaVoiceCommandDialog extends LitElement {
|
|||||||
// To make sure the answer is placed at the right user text, we add it before we process it
|
// To make sure the answer is placed at the right user text, we add it before we process it
|
||||||
this._addMessage(message);
|
this._addMessage(message);
|
||||||
try {
|
try {
|
||||||
const response = await processConversationInput(
|
const unsub = await runAssistPipeline(
|
||||||
this.hass,
|
this.hass,
|
||||||
text,
|
(event) => {
|
||||||
this._conversationId,
|
if (event.type === "intent-end") {
|
||||||
this.hass.language
|
this._conversationId = event.data.intent_output.conversation_id;
|
||||||
);
|
const plain = event.data.intent_output.response.speech?.plain;
|
||||||
this._conversationId = response.conversation_id;
|
|
||||||
const plain = response.response.speech?.plain;
|
|
||||||
if (plain) {
|
if (plain) {
|
||||||
message.text = plain.speech;
|
message.text = plain.speech;
|
||||||
} else {
|
|
||||||
message.text = "<silence>";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.requestUpdate("_conversation");
|
this.requestUpdate("_conversation");
|
||||||
|
unsub();
|
||||||
|
}
|
||||||
|
if (event.type === "error") {
|
||||||
|
message.text = event.data.message;
|
||||||
|
message.error = true;
|
||||||
|
this.requestUpdate("_conversation");
|
||||||
|
unsub();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start_stage: "intent",
|
||||||
|
input: { text },
|
||||||
|
end_stage: "intent",
|
||||||
|
pipeline: this._pipelineId,
|
||||||
|
conversation_id: this._conversationId,
|
||||||
|
}
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
message.text = this.hass.localize("ui.dialogs.voice_command.error");
|
message.text = this.hass.localize("ui.dialogs.voice_command.error");
|
||||||
message.error = true;
|
message.error = true;
|
||||||
@ -353,37 +292,152 @@ export class HaVoiceCommandDialog extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _toggleListening() {
|
private _toggleListening() {
|
||||||
if (!this.results) {
|
if (!this._audioRecorder?.active) {
|
||||||
this._startListening();
|
this._startListening();
|
||||||
} else {
|
} else {
|
||||||
this._stopListening();
|
this._stopListening();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _startListening() {
|
||||||
|
if (!this._audioRecorder) {
|
||||||
|
this._audioRecorder = new AudioRecorder((audio) => {
|
||||||
|
if (this._audioBuffer) {
|
||||||
|
this._audioBuffer.push(audio);
|
||||||
|
} else {
|
||||||
|
this._sendAudioChunk(audio);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this._audioBuffer = [];
|
||||||
|
const userMessage: Message = {
|
||||||
|
who: "user",
|
||||||
|
text: "…",
|
||||||
|
};
|
||||||
|
this._audioRecorder.start().then(() => {
|
||||||
|
this._addMessage(userMessage);
|
||||||
|
this.requestUpdate("_audioRecorder");
|
||||||
|
});
|
||||||
|
const hassMessage: Message = {
|
||||||
|
who: "hass",
|
||||||
|
text: "…",
|
||||||
|
};
|
||||||
|
// To make sure the answer is placed at the right user text, we add it before we process it
|
||||||
|
try {
|
||||||
|
const unsub = await runAssistPipeline(
|
||||||
|
this.hass,
|
||||||
|
(event) => {
|
||||||
|
if (event.type === "run-start") {
|
||||||
|
this._stt_binary_handler_id =
|
||||||
|
event.data.runner_data.stt_binary_handler_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we start STT stage, the WS has a binary handler
|
||||||
|
if (event.type === "stt-start" && this._audioBuffer) {
|
||||||
|
// Send the buffer over the WS to the STT engine.
|
||||||
|
for (const buffer of this._audioBuffer) {
|
||||||
|
this._sendAudioChunk(buffer);
|
||||||
|
}
|
||||||
|
this._audioBuffer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop recording if the server is done with STT stage
|
||||||
|
if (event.type === "stt-end") {
|
||||||
|
this._stt_binary_handler_id = undefined;
|
||||||
|
this._stopListening();
|
||||||
|
userMessage.text = event.data.stt_output.text;
|
||||||
|
this.requestUpdate("_conversation");
|
||||||
|
// To make sure the answer is placed at the right user text, we add it before we process it
|
||||||
|
this._addMessage(hassMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "intent-end") {
|
||||||
|
this._conversationId = event.data.intent_output.conversation_id;
|
||||||
|
const plain = event.data.intent_output.response.speech?.plain;
|
||||||
|
if (plain) {
|
||||||
|
hassMessage.text = plain.speech;
|
||||||
|
}
|
||||||
|
this.requestUpdate("_conversation");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "tts-end") {
|
||||||
|
const url = event.data.tts_output.url;
|
||||||
|
const audio = new Audio(url);
|
||||||
|
audio.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "run-end") {
|
||||||
|
unsub();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "error") {
|
||||||
|
this._stt_binary_handler_id = undefined;
|
||||||
|
if (userMessage.text === "…") {
|
||||||
|
userMessage.text = event.data.message;
|
||||||
|
userMessage.error = true;
|
||||||
|
} else {
|
||||||
|
hassMessage.text = event.data.message;
|
||||||
|
hassMessage.error = true;
|
||||||
|
}
|
||||||
|
this._stopListening();
|
||||||
|
this.requestUpdate("_conversation");
|
||||||
|
unsub();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start_stage: "stt",
|
||||||
|
end_stage: this._pipeline?.tts_engine ? "tts" : "intent",
|
||||||
|
input: { sample_rate: this._audioRecorder.sampleRate! },
|
||||||
|
pipeline: this._pipelineId,
|
||||||
|
conversation_id: this._conversationId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
await showAlertDialog(this, {
|
||||||
|
title: "Error starting pipeline",
|
||||||
|
text: err.message || err,
|
||||||
|
});
|
||||||
|
this._stopListening();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _stopListening() {
|
private _stopListening() {
|
||||||
if (this.recognition) {
|
this._audioRecorder?.stop();
|
||||||
this.recognition.stop();
|
this.requestUpdate("_audioRecorder");
|
||||||
|
// We're currently STTing, so finish audio
|
||||||
|
if (this._stt_binary_handler_id) {
|
||||||
|
if (this._audioBuffer) {
|
||||||
|
for (const chunk of this._audioBuffer) {
|
||||||
|
this._sendAudioChunk(chunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Send empty message to indicate we're done streaming.
|
||||||
|
this._sendAudioChunk(new Int16Array());
|
||||||
|
}
|
||||||
|
this._audioBuffer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private _startListening() {
|
private _sendAudioChunk(chunk: Int16Array) {
|
||||||
if (!this.recognition) {
|
this.hass.connection.socket!.binaryType = "arraybuffer";
|
||||||
this._initRecognition();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.results) {
|
// eslint-disable-next-line eqeqeq
|
||||||
|
if (this._stt_binary_handler_id == undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Turn into 8 bit so we can prefix our handler ID.
|
||||||
|
const data = new Uint8Array(1 + chunk.length * 2);
|
||||||
|
data[0] = this._stt_binary_handler_id;
|
||||||
|
data.set(new Uint8Array(chunk.buffer), 1);
|
||||||
|
|
||||||
this.results = {
|
this.hass.connection.socket!.send(data);
|
||||||
transcript: "",
|
|
||||||
final: false,
|
|
||||||
};
|
|
||||||
this.recognition!.start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _scrollMessagesBottom() {
|
private _scrollMessagesBottom() {
|
||||||
this._scrollContainer.scrollTo(0, 99999);
|
const scrollContainer = this._scrollContainer;
|
||||||
|
if (!scrollContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scrollContainer.scrollTo(0, 99999);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _computeMessageClasses(message: Message) {
|
private _computeMessageClasses(message: Message) {
|
||||||
@ -512,10 +566,6 @@ export class HaVoiceCommandDialog extends LitElement {
|
|||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.interimTranscript {
|
|
||||||
color: var(--secondary-text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bouncer {
|
.bouncer {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
|
@ -12,7 +12,7 @@ import "../../../components/ha-button";
|
|||||||
import {
|
import {
|
||||||
createAssistPipeline,
|
createAssistPipeline,
|
||||||
deleteAssistPipeline,
|
deleteAssistPipeline,
|
||||||
fetchAssistPipelines,
|
listAssistPipelines,
|
||||||
updateAssistPipeline,
|
updateAssistPipeline,
|
||||||
AssistPipeline,
|
AssistPipeline,
|
||||||
setAssistPipelinePreferred,
|
setAssistPipelinePreferred,
|
||||||
@ -33,7 +33,7 @@ export class AssistPref extends LitElement {
|
|||||||
protected firstUpdated(changedProps: PropertyValues) {
|
protected firstUpdated(changedProps: PropertyValues) {
|
||||||
super.firstUpdated(changedProps);
|
super.firstUpdated(changedProps);
|
||||||
|
|
||||||
fetchAssistPipelines(this.hass).then((pipelines) => {
|
listAssistPipelines(this.hass).then((pipelines) => {
|
||||||
this._pipelines = pipelines.pipelines;
|
this._pipelines = pipelines.pipelines;
|
||||||
this._preferred = pipelines.preferred_pipeline;
|
this._preferred = pipelines.preferred_pipeline;
|
||||||
});
|
});
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
import { css, html, LitElement, TemplateResult } from "lit";
|
import { css, html, LitElement, TemplateResult } from "lit";
|
||||||
import { customElement, property, query, state } from "lit/decorators";
|
import { customElement, property, query, state } from "lit/decorators";
|
||||||
import { extractSearchParam } from "../../../../common/url/search-params";
|
import { extractSearchParam } from "../../../../common/url/search-params";
|
||||||
|
import "../../../../components/ha-assist-pipeline-picker";
|
||||||
import "../../../../components/ha-button";
|
import "../../../../components/ha-button";
|
||||||
import "../../../../components/ha-checkbox";
|
import "../../../../components/ha-checkbox";
|
||||||
import type { HaCheckbox } from "../../../../components/ha-checkbox";
|
import type { HaCheckbox } from "../../../../components/ha-checkbox";
|
||||||
import "../../../../components/ha-formfield";
|
import "../../../../components/ha-formfield";
|
||||||
import "../../../../components/ha-assist-pipeline-picker";
|
|
||||||
import "../../../../components/ha-textfield";
|
import "../../../../components/ha-textfield";
|
||||||
import type { HaTextField } from "../../../../components/ha-textfield";
|
import type { HaTextField } from "../../../../components/ha-textfield";
|
||||||
import {
|
import {
|
||||||
PipelineRun,
|
PipelineRun,
|
||||||
PipelineRunOptions,
|
PipelineRunOptions,
|
||||||
runAssistPipeline,
|
runDebugAssistPipeline,
|
||||||
} from "../../../../data/assist_pipeline";
|
} from "../../../../data/assist_pipeline";
|
||||||
import {
|
import {
|
||||||
showAlertDialog,
|
showAlertDialog,
|
||||||
@ -20,6 +20,7 @@ import {
|
|||||||
import "../../../../layouts/hass-subpage";
|
import "../../../../layouts/hass-subpage";
|
||||||
import { haStyle } from "../../../../resources/styles";
|
import { haStyle } from "../../../../resources/styles";
|
||||||
import type { HomeAssistant } from "../../../../types";
|
import type { HomeAssistant } from "../../../../types";
|
||||||
|
import { AudioRecorder } from "../../../../util/audio-recorder";
|
||||||
import { fileDownload } from "../../../../util/file_download";
|
import { fileDownload } from "../../../../util/file_download";
|
||||||
import "./assist-render-pipeline-run";
|
import "./assist-render-pipeline-run";
|
||||||
|
|
||||||
@ -81,7 +82,13 @@ export class AssistPipelineRunDebug extends LitElement {
|
|||||||
<ha-button raised @click=${this._runTextPipeline}>
|
<ha-button raised @click=${this._runTextPipeline}>
|
||||||
Run Text Pipeline
|
Run Text Pipeline
|
||||||
</ha-button>
|
</ha-button>
|
||||||
<ha-button raised @click=${this._runAudioPipeline}>
|
<ha-button
|
||||||
|
raised
|
||||||
|
@click=${this._runAudioPipeline}
|
||||||
|
.disabled=${!window.isSecureContext ||
|
||||||
|
// @ts-ignore-next-line
|
||||||
|
!(window.AudioContext || window.webkitAudioContext)}
|
||||||
|
>
|
||||||
Run Audio Pipeline
|
Run Audio Pipeline
|
||||||
</ha-button>
|
</ha-button>
|
||||||
`
|
`
|
||||||
@ -173,21 +180,16 @@ export class AssistPipelineRunDebug extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _runAudioPipeline() {
|
private async _runAudioPipeline() {
|
||||||
// @ts-ignore-next-line
|
const audioRecorder = new AudioRecorder((data) => {
|
||||||
const context = new (window.AudioContext || window.webkitAudioContext)();
|
if (this._audioBuffer) {
|
||||||
let stream: MediaStream;
|
this._audioBuffer.push(data);
|
||||||
try {
|
} else {
|
||||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
this._sendAudioChunk(data);
|
||||||
} catch (err) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await context.audioWorklet.addModule(
|
this._audioBuffer = [];
|
||||||
new URL("./recorder.worklet.js", import.meta.url)
|
audioRecorder.start();
|
||||||
);
|
|
||||||
|
|
||||||
const source = context.createMediaStreamSource(stream);
|
|
||||||
const recorder = new AudioWorkletNode(context, "recorder.worklet");
|
|
||||||
|
|
||||||
this.hass.connection.socket!.binaryType = "arraybuffer";
|
this.hass.connection.socket!.binaryType = "arraybuffer";
|
||||||
|
|
||||||
@ -195,6 +197,7 @@ export class AssistPipelineRunDebug extends LitElement {
|
|||||||
|
|
||||||
let stopRecording: (() => void) | undefined = () => {
|
let stopRecording: (() => void) | undefined = () => {
|
||||||
stopRecording = undefined;
|
stopRecording = undefined;
|
||||||
|
audioRecorder.close();
|
||||||
// We're currently STTing, so finish audio
|
// We're currently STTing, so finish audio
|
||||||
if (run?.stage === "stt" && run.stt!.done === false) {
|
if (run?.stage === "stt" && run.stt!.done === false) {
|
||||||
if (this._audioBuffer) {
|
if (this._audioBuffer) {
|
||||||
@ -206,20 +209,6 @@ export class AssistPipelineRunDebug extends LitElement {
|
|||||||
this._sendAudioChunk(new Int16Array());
|
this._sendAudioChunk(new Int16Array());
|
||||||
}
|
}
|
||||||
this._audioBuffer = undefined;
|
this._audioBuffer = undefined;
|
||||||
stream.getTracks()[0].stop();
|
|
||||||
context.close();
|
|
||||||
};
|
|
||||||
this._audioBuffer = [];
|
|
||||||
source.connect(recorder).connect(context.destination);
|
|
||||||
recorder.port.onmessage = (e) => {
|
|
||||||
if (!stopRecording) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this._audioBuffer) {
|
|
||||||
this._audioBuffer.push(e.data);
|
|
||||||
} else {
|
|
||||||
this._sendAudioChunk(e.data);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await this._doRunPipeline(
|
await this._doRunPipeline(
|
||||||
@ -260,7 +249,7 @@ export class AssistPipelineRunDebug extends LitElement {
|
|||||||
start_stage: "stt",
|
start_stage: "stt",
|
||||||
end_stage: "tts",
|
end_stage: "tts",
|
||||||
input: {
|
input: {
|
||||||
sample_rate: context.sampleRate,
|
sample_rate: audioRecorder.sampleRate!,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -273,7 +262,7 @@ export class AssistPipelineRunDebug extends LitElement {
|
|||||||
this._finished = false;
|
this._finished = false;
|
||||||
let added = false;
|
let added = false;
|
||||||
try {
|
try {
|
||||||
await runAssistPipeline(
|
await runDebugAssistPipeline(
|
||||||
this.hass,
|
this.hass,
|
||||||
(updatedRun) => {
|
(updatedRun) => {
|
||||||
if (added) {
|
if (added) {
|
||||||
|
88
src/util/audio-recorder.ts
Normal file
88
src/util/audio-recorder.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
export class AudioRecorder {
|
||||||
|
private _active = false;
|
||||||
|
|
||||||
|
private _callback: (data: Int16Array) => void;
|
||||||
|
|
||||||
|
private _context: AudioContext | undefined;
|
||||||
|
|
||||||
|
private _stream: MediaStream | undefined;
|
||||||
|
|
||||||
|
constructor(callback: (data: Int16Array) => void) {
|
||||||
|
this._callback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get active() {
|
||||||
|
return this._active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get sampleRate() {
|
||||||
|
return this._context?.sampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static get isSupported() {
|
||||||
|
return (
|
||||||
|
window.isSecureContext &&
|
||||||
|
// @ts-ignore-next-line
|
||||||
|
(window.AudioContext || window.webkitAudioContext)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start() {
|
||||||
|
this._active = true;
|
||||||
|
|
||||||
|
if (!this._context || !this._stream) {
|
||||||
|
await this._createContext();
|
||||||
|
} else {
|
||||||
|
this._context.resume();
|
||||||
|
this._stream.getTracks()[0].enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._context || !this._stream) {
|
||||||
|
this._active = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = this._context.createMediaStreamSource(this._stream);
|
||||||
|
const recorder = new AudioWorkletNode(this._context, "recorder.worklet");
|
||||||
|
|
||||||
|
source.connect(recorder).connect(this._context.destination);
|
||||||
|
recorder.port.onmessage = (e) => {
|
||||||
|
if (!this._active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._callback(e.data);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop() {
|
||||||
|
this._active = false;
|
||||||
|
if (this._stream) {
|
||||||
|
this._stream.getTracks()[0].enabled = false;
|
||||||
|
}
|
||||||
|
await this._context?.suspend();
|
||||||
|
}
|
||||||
|
|
||||||
|
public close() {
|
||||||
|
this._active = false;
|
||||||
|
this._stream?.getTracks()[0].stop();
|
||||||
|
this._context?.close();
|
||||||
|
this._stream = undefined;
|
||||||
|
this._context = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _createContext() {
|
||||||
|
try {
|
||||||
|
// @ts-ignore-next-line
|
||||||
|
this._context = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
this._stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._context.audioWorklet.addModule(
|
||||||
|
new URL("./recorder.worklet.js", import.meta.url)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user