-
Notifications
You must be signed in to change notification settings - Fork 10
Chat component - speech to text #1893
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 12 commits
8db6375
10fe0f2
66bbc30
7fb8337
c2352c4
6265c5d
c36e2f1
cd75637
54ffb7d
a0d40d5
208a938
9d3019d
1ecb0a8
86ed658
7a08285
39d078c
09b1c97
d642813
ebda182
300c6c3
1ed7e5f
30508a4
28ca718
bc0ff22
c8a3117
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| import { ContextConsumer, consume } from '@lit/context'; | ||
| import { html, LitElement, nothing, type PropertyValues } from 'lit'; | ||
| import { query, state } from 'lit/decorators.js'; | ||
| import { property, query, state } from 'lit/decorators.js'; | ||
|
github-code-quality[bot] marked this conversation as resolved.
Fixed
|
||
| import { cache } from 'lit/directives/cache.js'; | ||
| import { ifDefined } from 'lit/directives/if-defined.js'; | ||
| import { until } from 'lit/directives/until.js'; | ||
|
|
@@ -16,6 +16,9 @@ import { bindIf, hasFiles, isEmpty, trimmedHtml } from '../common/util.js'; | |
| import IgcIconComponent from '../icon/icon.js'; | ||
| import IgcTextareaComponent from '../textarea/textarea.js'; | ||
| import type { ChatState } from './chat-state.js'; | ||
| import { BackendSttClient } from './extras/stt-client-backend.js'; | ||
| import type { ISttClient } from './extras/stt-client-base.js'; | ||
| import { WebSpeechSttClient } from './extras/stt-client-webspeech.js'; | ||
| import { styles } from './themes/input.base.css.js'; | ||
| import { all } from './themes/input.js'; | ||
| import { styles as shared } from './themes/shared/input/input.common.css.js'; | ||
|
|
@@ -38,6 +41,7 @@ type DefaultInputRenderers = { | |
| inputActionsStart: ChatTemplateRenderer<ChatRenderContext>; | ||
| inputAttachments: ChatTemplateRenderer<ChatInputRenderContext>; | ||
| fileUploadButton: ChatTemplateRenderer<ChatRenderContext>; | ||
| speechToTextButton: ChatTemplateRenderer<ChatRenderContext>; | ||
| sendButton: ChatTemplateRenderer<ChatRenderContext>; | ||
| }; | ||
|
|
||
|
|
@@ -88,6 +92,7 @@ export default class IgcChatInputComponent extends LitElement { | |
|
|
||
| private readonly _defaults: Readonly<DefaultInputRenderers> = Object.freeze({ | ||
| fileUploadButton: () => this._renderFileUploadButton(), | ||
| speechToTextButton: () => this._renderSpeechToTextButton(), | ||
| input: () => this._renderTextArea(), | ||
| inputActions: () => this._renderActionsArea(), | ||
| inputActionsEnd: () => nothing, | ||
|
|
@@ -135,6 +140,109 @@ export default class IgcChatInputComponent extends LitElement { | |
| return this._state.acceptedFileTypes; | ||
| } | ||
|
|
||
| private _sttClient?: ISttClient; | ||
|
|
||
| @state() | ||
| private isRecording = false; | ||
|
|
||
| @state() | ||
| private isStopInProgress = false; | ||
| onPulseSignal = () => { | ||
| const el = this.renderRoot.querySelector<HTMLElement>( | ||
| 'igc-icon-button[part="speech-to-text"]' | ||
| ); | ||
| if (!el) return; | ||
|
ivanvpetrov marked this conversation as resolved.
|
||
|
|
||
| el.classList.add('pulsate'); | ||
| el.addEventListener('animationend', () => el.classList.remove('pulsate'), { | ||
| once: true, | ||
| }); | ||
| }; | ||
|
|
||
| onStartCountdown = (ms: number | null) => { | ||
| if (ms) { | ||
| const circle = this.renderRoot.querySelector<SVGCircleElement>( | ||
| 'svg.countdown-ring .ring-progress' | ||
| ); | ||
| if (!circle) return; | ||
|
|
||
| circle.style.transition = 'none'; | ||
| circle.style.strokeDashoffset = '100'; | ||
| void circle.getBoundingClientRect(); // reflow | ||
|
|
||
| circle.style.transition = `stroke-dashoffset ${ms}ms linear`; | ||
| circle.style.strokeDashoffset = '0'; | ||
| } else { | ||
| const circle = this.renderRoot.querySelector<SVGCircleElement>( | ||
| 'svg.countdown-ring .ring-progress' | ||
| ); | ||
| if (circle) { | ||
| circle.style.transition = 'none'; | ||
| circle.style.strokeDashoffset = '100'; | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| onStopInProgress = () => { | ||
| this.isStopInProgress = true; | ||
| }; | ||
|
|
||
| onTranscript = (text: string) => { | ||
| this._state.inputValue = text; | ||
| }; | ||
|
|
||
| onFinishedTranscribing = (finished: 'auto' | 'manual') => { | ||
| this.isStopInProgress = false; | ||
| this.isRecording = false; | ||
| if (finished === 'auto') { | ||
| this._sendMessage(); | ||
| } | ||
| }; | ||
|
|
||
| private async _toggleMic(): Promise<void> { | ||
| if (!this.isRecording) { | ||
| if (this._state.options?.speechToText?.serviceProvider === 'webspeech') { | ||
| this._sttClient = new WebSpeechSttClient( | ||
| this.onPulseSignal, | ||
| this.onStartCountdown, | ||
| this.onTranscript, | ||
| this.onStopInProgress, | ||
| this.onFinishedTranscribing | ||
| ); | ||
| } else if ( | ||
| this._state.options?.speechToText?.serviceProvider === 'backend' && | ||
| this._state.options?.speechToText?.serviceUri | ||
| ) { | ||
| this._sttClient = new BackendSttClient( | ||
| this._state.options?.speechToText?.serviceUri ?? undefined, | ||
| this.onPulseSignal, | ||
| this.onStartCountdown, | ||
| this.onTranscript, | ||
| this.onStopInProgress, | ||
| this.onFinishedTranscribing | ||
| ); | ||
| } else { | ||
| // console.error('No STT service configured'); | ||
| } | ||
|
|
||
| if (!this._sttClient) { | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| await this._sttClient.start(this._state.options?.speechToText?.lang); | ||
| this.isRecording = true; | ||
| this.isStopInProgress = false; | ||
| } catch { | ||
| this._sttClient = undefined; | ||
| this.isRecording = false; | ||
| this.isStopInProgress = false; | ||
| } | ||
| } else { | ||
| this._sttClient?.stop(); | ||
| } | ||
| } | ||
|
|
||
| constructor() { | ||
| super(); | ||
| addThemingController(this, all, { themeChange: this._adoptPageStyles }); | ||
|
|
@@ -338,7 +446,12 @@ export default class IgcChatInputComponent extends LitElement { | |
| <igc-textarea | ||
| part="text-input" | ||
| aria-label="Chat text input" | ||
| placeholder=${ifDefined(this._state.options?.inputPlaceholder)} | ||
| placeholder=${ifDefined( | ||
| this.isRecording | ||
| ? this._state.options?.speakPlaceholder ?? | ||
| this._state.options?.inputPlaceholder | ||
| : this._state.options?.inputPlaceholder | ||
| )} | ||
|
ivanvpetrov marked this conversation as resolved.
|
||
| resize="auto" | ||
| rows="1" | ||
| .value=${this._userInputState?.inputValue} | ||
|
|
@@ -380,6 +493,40 @@ export default class IgcChatInputComponent extends LitElement { | |
| )}`; | ||
| } | ||
|
|
||
| private _renderSpeechToTextButton() { | ||
| const sttEnabled = this._state.options?.speechToText?.enable; | ||
|
|
||
|
Comment on lines
+497
to
+499
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in d642813. Added unit coverage in |
||
| return html`${cache( | ||
| sttEnabled | ||
| ? html` <div class="stop-btn-wrapper"> | ||
| <label part="speech-to-text"> | ||
| <igc-icon-button | ||
| aria-label="Speech to text" | ||
| variant="flat" | ||
| @click=${this._toggleMic} | ||
| ?disabled=${this.isStopInProgress} | ||
| > | ||
| ${this.isRecording ? '🛑' : '🎤'} | ||
| </igc-icon-button> | ||
| </label> | ||
|
ivanvpetrov marked this conversation as resolved.
Outdated
|
||
| ${this.isRecording && !this.isStopInProgress | ||
| ? html` | ||
| <svg class="countdown-ring" viewBox="0 0 36 36" aria-hidden="true"> | ||
| <circle class="ring-bg" cx="18" cy="18" r="14"></circle> | ||
| <circle | ||
| class="ring-progress" | ||
| cx="18" | ||
| cy="18" | ||
| r="14" | ||
| ></circle> | ||
| </svg> | ||
| ` | ||
| : nothing} | ||
| </div>` | ||
| : nothing | ||
| )}`; | ||
| } | ||
|
|
||
| private _renderSendButton() { | ||
| const enabled = | ||
| this._state.hasInputValue || this._state.hasInputAttachments; | ||
|
|
@@ -403,6 +550,9 @@ export default class IgcChatInputComponent extends LitElement { | |
| <div part="file-upload-container"> | ||
| ${this._getRenderer('fileUploadButton')(ctx)} | ||
| </div> | ||
| <div part="speech-to-text-container"> | ||
| ${this._getRenderer('speechToTextButton')(ctx)} | ||
| </div> | ||
| <div part="input-actions-start"> | ||
| ${this._getRenderer('inputActionsStart')(ctx)} | ||
| </div> | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.