diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f0db9993d..ff5e0dd2bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,6 +163,7 @@ Breaking changes in this release: - Added core mute/unmute functionality for speech-to-speech via `useRecorder` hook (silent chunks keep server connection alive), in PR [#5688](https://github.com/microsoft/BotFramework-WebChat/pull/5688), by [@pranavjoshi](https://github.com/pranavjoshi001) - ๐Ÿงช Added incremental streaming Markdown renderer for livestreaming, in PR [#5799](https://github.com/microsoft/BotFramework-WebChat/pull/5799), by [@OEvgeny](https://github.com/OEvgeny) - Fixed streaming Markdown renderer to preserve link reference definitions during incremental rendering and recover on error, in PR [#5808](https://github.com/microsoft/BotFramework-WebChat/pull/5808), by [@OEvgeny](https://github.com/OEvgeny) +- Added multi-modal text + voice experience, in PR [#5817](https://github.com/microsoft/BotFramework-WebChat/pull/5817), by [@pranavjoshi001](https://github.com/pranavjoshi001) ### Changed diff --git a/__tests__/html2/speechToSpeech/barge.in.html b/__tests__/html2/speechToSpeech/barge.in.html index d12f20c51f..a0d4290581 100644 --- a/__tests__/html2/speechToSpeech/barge.in.html +++ b/__tests__/html2/speechToSpeech/barge.in.html @@ -40,8 +40,9 @@ const { directLine, store } = testHelpers.createDirectLineEmulator(); - // Set voice configuration capability to enable microphone button + // Multi-modal experience: server announces audio, consumer opted into voice mode. directLine.setCapability('getVoiceConfiguration', { sampleRate: 24000, chunkIntervalMs: 100 }, { emitEvent: false }); + directLine.setCapability('getIsVoiceModeEnabled', true, { emitEvent: false }); render( diff --git a/__tests__/html2/speechToSpeech/basic.sendbox.with.mic.html b/__tests__/html2/speechToSpeech/basic.sendbox.with.mic.html index 56b2608a7f..88eb66d153 100644 --- a/__tests__/html2/speechToSpeech/basic.sendbox.with.mic.html +++ b/__tests__/html2/speechToSpeech/basic.sendbox.with.mic.html @@ -12,6 +12,13 @@
+ + + + + + + + + +
+ + + + + diff --git a/__tests__/html2/speechToSpeech/multimodal.text.with.voice.html.snap-1.png b/__tests__/html2/speechToSpeech/multimodal.text.with.voice.html.snap-1.png new file mode 100644 index 0000000000..ecca75dcc4 Binary files /dev/null and b/__tests__/html2/speechToSpeech/multimodal.text.with.voice.html.snap-1.png differ diff --git a/__tests__/html2/speechToSpeech/multiple.turns.html b/__tests__/html2/speechToSpeech/multiple.turns.html index 7a5ccc5971..3f3ed16bef 100644 --- a/__tests__/html2/speechToSpeech/multiple.turns.html +++ b/__tests__/html2/speechToSpeech/multiple.turns.html @@ -39,8 +39,9 @@ const { directLine, store } = testHelpers.createDirectLineEmulator(); - // Set voice configuration capability to enable microphone button + // Multi-modal experience: server announces audio, consumer opted into voice mode. directLine.setCapability('getVoiceConfiguration', { sampleRate: 24000, chunkIntervalMs: 100 }, { emitEvent: false }); + directLine.setCapability('getIsVoiceModeEnabled', true, { emitEvent: false }); render( diff --git a/__tests__/html2/speechToSpeech/multiple.turns.html.snap-1.png b/__tests__/html2/speechToSpeech/multiple.turns.html.snap-1.png index 65531ebe9c..22bd88dd58 100644 Binary files a/__tests__/html2/speechToSpeech/multiple.turns.html.snap-1.png and b/__tests__/html2/speechToSpeech/multiple.turns.html.snap-1.png differ diff --git a/__tests__/html2/speechToSpeech/mute.unmute.html b/__tests__/html2/speechToSpeech/mute.unmute.html index 7445c252ef..1b66247b5e 100644 --- a/__tests__/html2/speechToSpeech/mute.unmute.html +++ b/__tests__/html2/speechToSpeech/mute.unmute.html @@ -69,6 +69,7 @@ // Setup Web Chat with Speech-to-Speech const { directLine, store } = testHelpers.createDirectLineEmulator(); directLine.setCapability('getVoiceConfiguration', { sampleRate: 24000, chunkIntervalMs: 100 }, { emitEvent: false }); + directLine.setCapability('getIsVoiceModeEnabled', true, { emitEvent: false }); // Track voiceState and microphoneMuted changes store.subscribe(() => { diff --git a/__tests__/html2/speechToSpeech/outgoing.audio.interval.html b/__tests__/html2/speechToSpeech/outgoing.audio.interval.html index d2167aba2a..b5ee6f223a 100644 --- a/__tests__/html2/speechToSpeech/outgoing.audio.interval.html +++ b/__tests__/html2/speechToSpeech/outgoing.audio.interval.html @@ -41,8 +41,9 @@ const { directLine, store } = testHelpers.createDirectLineEmulator(); - // Set voice configuration capability to enable microphone button + // Multi-modal experience: server announces audio, consumer opted into voice mode. directLine.setCapability('getVoiceConfiguration', { sampleRate: 24000, chunkIntervalMs: 100 }, { emitEvent: false }); + directLine.setCapability('getIsVoiceModeEnabled', true, { emitEvent: false }); // Intercept postActivity to capture outgoing voice chunks const capturedChunks = []; diff --git a/packages/api/src/StyleOptions.ts b/packages/api/src/StyleOptions.ts index 2664030055..41941713da 100644 --- a/packages/api/src/StyleOptions.ts +++ b/packages/api/src/StyleOptions.ts @@ -1021,6 +1021,32 @@ type StyleOptions = { * @default 'auto' */ showMicrophoneButton?: 'auto' | 'hide'; + + /** + * Sound played while voice state is in `'processing'` state. + * + * - `string` โ€” a URL, data URI, or blob URL of the audio to play. + * - `false` โ€” disables the sound cue entirely. + * - `undefined` โ€” uses a bundled default sound (lazily materialized to a `blob:` URL). + * + * @default undefined + */ + voiceProcessingSound?: string | false | undefined; + + /** + * Whether the voice processing sound should loop while in `'processing'` state. + * + * @default true + */ + voiceProcessingSoundLoop?: boolean; + + /** + * Volume of the voice processing sound. A number between `0` (muted) and `1` (full). + * Lets consumers tune loudness so the cue stays audible without overpowering the bot's spoken response or the user's environment. + * + * @default 0.5 + */ + voiceProcessingSoundVolume?: number; }; // StrictStyleOptions is only used internally in Web Chat and for simplifying our code: diff --git a/packages/api/src/defaultStyleOptions.ts b/packages/api/src/defaultStyleOptions.ts index e9ea89c7f9..889523c547 100644 --- a/packages/api/src/defaultStyleOptions.ts +++ b/packages/api/src/defaultStyleOptions.ts @@ -323,7 +323,12 @@ const DEFAULT_OPTIONS: Required = { sendBoxAttachmentBarMaxThumbnail: 3, // Speech-to-speech options - showMicrophoneButton: 'auto' + showMicrophoneButton: 'auto', + + // Voice processing sound (played during voice state is 'processing') + voiceProcessingSound: undefined, + voiceProcessingSoundLoop: true, + voiceProcessingSoundVolume: 0.5 }; export default DEFAULT_OPTIONS; diff --git a/packages/api/src/providers/ActivityTyping/ActivityTypingComposer.tsx b/packages/api/src/providers/ActivityTyping/ActivityTypingComposer.tsx index 6dda9f0e2d..9768dd3c30 100644 --- a/packages/api/src/providers/ActivityTyping/ActivityTypingComposer.tsx +++ b/packages/api/src/providers/ActivityTyping/ActivityTypingComposer.tsx @@ -1,4 +1,8 @@ -import { getActivityLivestreamingMetadata, type WebChatActivity } from 'botframework-webchat-core'; +import { + getActivityLivestreamingMetadata, + isVoiceTranscriptActivity, + type WebChatActivity +} from 'botframework-webchat-core'; import { queryReceivedAtFromActivity } from 'botframework-webchat-core/activity'; import { iteratorFind } from 'iter-fest'; import React, { memo, useCallback, useMemo, type ReactNode } from 'react'; @@ -92,6 +96,9 @@ const ActivityTypingComposer = ({ children }: Props) => { firstReceivedAt: mutableEntry.typingIndicator?.firstReceivedAt ?? receivedAt, lastReceivedAt: receivedAt }); + } else if (isVoiceTranscriptActivity(activity)) { + // Voice transcript (media.end with transcription) clears typing indicator. + mutableEntry.typingIndicator = undefined; } typingState.set(id, Object.freeze(mutableEntry)); diff --git a/packages/api/src/providers/Capabilities/types/Capabilities.ts b/packages/api/src/providers/Capabilities/types/Capabilities.ts index c72d96bd12..df58bad30f 100644 --- a/packages/api/src/providers/Capabilities/types/Capabilities.ts +++ b/packages/api/src/providers/Capabilities/types/Capabilities.ts @@ -2,6 +2,7 @@ * All capabilities are optional as they depend on adapter/server support. */ type Capabilities = Readonly<{ + isVoiceModeEnabled?: boolean | undefined; voiceConfiguration?: VoiceConfiguration | undefined; }>; diff --git a/packages/api/src/providers/SpeechToSpeech/SpeechToSpeechComposer.tsx b/packages/api/src/providers/SpeechToSpeech/SpeechToSpeechComposer.tsx index da6ee79b7e..9971b3de9e 100644 --- a/packages/api/src/providers/SpeechToSpeech/SpeechToSpeechComposer.tsx +++ b/packages/api/src/providers/SpeechToSpeech/SpeechToSpeechComposer.tsx @@ -1,4 +1,5 @@ import React, { type ReactNode } from 'react'; +import { VoiceProcessingSoundBridge } from './private/VoiceProcessingSoundBridge'; import { VoiceHandlerBridge } from './private/VoiceHandlerBridge'; import { VoiceRecorderBridge } from './private/VoiceRecorderBridge'; @@ -8,6 +9,7 @@ import { VoiceRecorderBridge } from './private/VoiceRecorderBridge'; * This component renders invisible bridge components that: * 1. VoiceHandlerBridge - registers audio player functions with Redux * 2. VoiceRecorderBridge - reacts to recording state and manages microphone + * 3. VoiceProcessingSoundBridge - plays processing audio cue while voiceState is 'processing' * * Use the `useVoiceState`, `useStartVoice`, and `useStopVoice` hooks to access state and controls. */ @@ -15,6 +17,7 @@ export const SpeechToSpeechComposer: React.FC<{ readonly children: ReactNode }> + {children} ); diff --git a/packages/api/src/providers/SpeechToSpeech/private/VoiceProcessingSoundBridge.tsx b/packages/api/src/providers/SpeechToSpeech/private/VoiceProcessingSoundBridge.tsx new file mode 100644 index 0000000000..42041b147d --- /dev/null +++ b/packages/api/src/providers/SpeechToSpeech/private/VoiceProcessingSoundBridge.tsx @@ -0,0 +1,71 @@ +import { useEffect, useMemo } from 'react'; + +import getVoiceProcessingSound from './voiceProcessingSound'; +import useShouldShowMicrophoneButton from '../../../hooks/internal/useShouldShowMicrophoneButton'; +import useStyleOptions from '../../../hooks/useStyleOptions'; +import useVoiceState from '../../../hooks/useVoiceState'; + +const DEFAULT_VOLUME = 0.5; +const MIN_VOLUME = 0; +const MAX_VOLUME = 1; + +// Constrain `value` to `[min, max]`. e.g. clamp(2, 0, 1) โ†’ 1; clamp(-0.3, 0, 1) โ†’ 0; clamp(0.4, 0, 1) โ†’ 0.4. +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +const ignoreError = () => { + // Ignore autoplay-policy rejection. +}; + +/** + * Plays the audio from the start; returns a stop function that pauses and rewinds. + */ +const play = (audio: HTMLAudioElement) => { + audio.currentTime = 0; + audio.play().catch(ignoreError); + + return () => { + audio.pause(); + audio.currentTime = 0; + }; +}; + +/** + * Plays a (looping) audio cue while voice mode is `'processing'`. + * + * Style options: + * - `voiceProcessingSound` โ€” URL/data-URI; `false` disables; when unset uses the bundled default. + * - `voiceProcessingSoundLoop` โ€” defaults to `true`. + * - `voiceProcessingSoundVolume` โ€” `0`โ€“`1`, defaults to `0.5`. + * + * Skipped entirely when the microphone button is hidden โ€” no mic means no voice flow to cue. + */ +export const VoiceProcessingSoundBridge = () => { + const [{ voiceProcessingSound, voiceProcessingSoundLoop, voiceProcessingSoundVolume }] = useStyleOptions(); + const [voiceState] = useVoiceState(); + const shouldShowMicrophoneButton = useShouldShowMicrophoneButton(); + + // Resolve the source: explicit `false` disables; an explicit string is used as-is; + // `undefined` falls back to the lazily-created default `blob:` URL. + const source = voiceProcessingSound === false ? undefined : (voiceProcessingSound ?? getVoiceProcessingSound()); + + const audio = useMemo(() => { + if (!shouldShowMicrophoneButton || !source) { + return undefined; + } + + const instance = new Audio(source); + + instance.loop = voiceProcessingSoundLoop ?? true; + instance.volume = clamp(voiceProcessingSoundVolume ?? DEFAULT_VOLUME, MIN_VOLUME, MAX_VOLUME); + + return instance; + }, [shouldShowMicrophoneButton, source, voiceProcessingSoundLoop, voiceProcessingSoundVolume]); + + useEffect(() => { + if (audio && voiceState === 'processing') { + return play(audio); + } + }, [audio, voiceState]); + + return null; +}; diff --git a/packages/api/src/providers/SpeechToSpeech/private/voiceProcessingSound.ts b/packages/api/src/providers/SpeechToSpeech/private/voiceProcessingSound.ts new file mode 100644 index 0000000000..fce0d5b11c --- /dev/null +++ b/packages/api/src/providers/SpeechToSpeech/private/voiceProcessingSound.ts @@ -0,0 +1,33 @@ +// Default voice processing sound (played while voice state is 'processing'). +// Override via styleOptions.voiceProcessingSound. +// +// The asset ships as a base64-encoded OGG/Vorbis clip and is converted to a `blob:` URL +// on first access. This complies with strict CSP (`media-src blob:`) and avoids the cost +// of creating the blob for consumers who supply their own `voiceProcessingSound`. + +const MIME_TYPE = 'audio/ogg'; + +const BASE64 = + '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'; + +let cachedBlobURL: string | undefined; + +/** + * Returns a `blob:` URL for the default processing sound, lazily creating it on first call. + * The blob lives for the page lifetime and is reused across renders. + */ +export default function getVoiceProcessingSound(): string { + if (!cachedBlobURL) { + const binary = atob(BASE64); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < binary.length; i++) { + bytes[+i] = binary.charCodeAt(i); + } + + // eslint-disable-next-line no-restricted-properties + cachedBlobURL = URL.createObjectURL(new Blob([bytes.buffer as ArrayBuffer], { type: MIME_TYPE })); + } + + return cachedBlobURL; +} diff --git a/packages/bundle/src/createDirectLine.ts b/packages/bundle/src/createDirectLine.ts index 98817a5c28..8a2b8c5a05 100644 --- a/packages/bundle/src/createDirectLine.ts +++ b/packages/bundle/src/createDirectLine.ts @@ -5,6 +5,7 @@ type CreateDirectLineOptions = { conversationId?: string; conversationStartProperties?: any; domain?: string; + enableVoiceMode?: boolean; fetch?: typeof window.fetch; pollingInterval?: number; secret?: string; @@ -20,6 +21,7 @@ export default function createDirectLine({ conversationId, conversationStartProperties, domain, + enableVoiceMode, fetch, pollingInterval, secret, @@ -35,6 +37,7 @@ export default function createDirectLine({ conversationId, conversationStartProperties, domain, + enableVoiceMode, fetch, pollingInterval, secret, diff --git a/packages/core/src/sagas/postActivitySaga.ts b/packages/core/src/sagas/postActivitySaga.ts index 6b021ceef6..39113fff76 100644 --- a/packages/core/src/sagas/postActivitySaga.ts +++ b/packages/core/src/sagas/postActivitySaga.ts @@ -36,13 +36,31 @@ import type { WebChatActivity } from '../types/WebChatActivity'; // This value must be equals to or larger than the user-defined `styleOptions.sendTimeout`. const HARD_SEND_TIMEOUT = 300000; +/** + * Checks if the DirectLine adapter supports voice (WebSocket-only mode). + * When voice is enabled, we use optimistic updates without waiting for echo back. + */ +function isVoiceEnabled(directLine: DirectLineJSBotConnection): boolean { + if (typeof directLine.getIsVoiceModeEnabled === 'function') { + try { + const isVoiceEnabled = directLine.getIsVoiceModeEnabled(); + return Boolean(isVoiceEnabled); + } catch { + return false; + } + } + + return false; +} + function* postActivity( directLine: DirectLineJSBotConnection, userID: string, username: string, numActivitiesPosted: number, { meta: { method }, payload: { activity } }: PostActivityAction, - ponyfill: GlobalScopePonyfill + ponyfill: GlobalScopePonyfill, + waitForEchoBack: boolean ) { const attachments = (activity.type === 'message' && activity.attachments) || []; const clientActivityID = uniqueID(); @@ -55,6 +73,8 @@ function* postActivity( // In the future, we should warn if the outgoing activity is not matching the type. let outgoingActivity: WebChatOutgoingActivity = { ...deleteKey(activity, 'id'), + // this is to show timestamp below user transcript. + ...(!waitForEchoBack ? { timestamp: now.toISOString() } : {}), channelData: { // `channelData.state` is being deprecated in favor of `channelData['webchat:send-status']`. // Please refer to #4362 for details. Remove on or after 2024-07-31. @@ -122,57 +142,72 @@ function* postActivity( let echoed: boolean | undefined; try { - // Quirks: We might receive INCOMING_ACTIVITY before the postActivity call completed - // So, we setup expectation first, then postActivity afterward - - const echoBackCall = call(function* () { - for (;;) { - const { - payload: { activity } - }: IncomingActivityAction = yield take(INCOMING_ACTIVITY); - if (activity.channelData?.clientActivityID === clientActivityID && activity.id) { - echoed = true; - - return activity; + if (waitForEchoBack) { + // Quirks: We might receive INCOMING_ACTIVITY before the postActivity call completed + // So, we setup expectation first, then postActivity afterward + + const echoBackCall = call(function* () { + for (;;) { + const { + payload: { activity } + }: IncomingActivityAction = yield take(INCOMING_ACTIVITY); + if (activity.channelData?.clientActivityID === clientActivityID && activity.id) { + echoed = true; + + return activity; + } } - } - }); + }); + + // Timeout could be due to either: + // - Post activity call may take too long time to complete + // - Direct Line service only respond on HTTP after bot respond to Direct Line + // - Activity may take too long time to echo back + + const sendTimeout: number = yield select(sendTimeoutSelector); + + const { + send: { echoBack } + }: { send: { echoBack: WebChatActivity } } = yield race({ + send: all({ + echoBack: echoBackCall, + postActivity: observeOnce(directLine.postActivity(outgoingActivity as DirectLineActivity)) + }), + timeout: call(function* () { + yield call(sleep, sendTimeout, ponyfill); + + // The IMPEDED action is for backward compatibility by changing `channelData.state` to "send failed". + // `channelData.state` is being deprecated in favor of `channelData['webchat:send-status']`. + // Please refer to #4362 for details. Remove on or after 2024-07-31. + yield put({ + type: POST_ACTIVITY_IMPEDED, + meta, + payload: { activity: outgoingActivity } + } as PostActivityImpededAction); + + yield call(sleep, HARD_SEND_TIMEOUT - sendTimeout, ponyfill); + + throw !echoed + ? new Error('timed out while waiting for outgoing message to echo back') + : new Error('timed out while waiting for postActivity to return any values'); + }) + }); - // Timeout could be due to either: - // - Post activity call may take too long time to complete - // - Direct Line service only respond on HTTP after bot respond to Direct Line - // - Activity may take too long time to echo back - - const sendTimeout: number = yield select(sendTimeoutSelector); - - const { - send: { echoBack } - }: { send: { echoBack: WebChatActivity } } = yield race({ - send: all({ - echoBack: echoBackCall, - postActivity: observeOnce(directLine.postActivity(outgoingActivity as DirectLineActivity)) - }), - timeout: call(function* () { - yield call(sleep, sendTimeout, ponyfill); - - // The IMPEDED action is for backward compatibility by changing `channelData.state` to "send failed". - // `channelData.state` is being deprecated in favor of `channelData['webchat:send-status']`. - // Please refer to #4362 for details. Remove on or after 2024-07-31. - yield put({ - type: POST_ACTIVITY_IMPEDED, - meta, - payload: { activity: outgoingActivity } - } as PostActivityImpededAction); - - yield call(sleep, HARD_SEND_TIMEOUT - sendTimeout, ponyfill); - - throw !echoed - ? new Error('timed out while waiting for outgoing message to echo back') - : new Error('timed out while waiting for postActivity to return any values'); - }) - }); + yield put({ + type: POST_ACTIVITY_FULFILLED, + meta, + payload: { activity: echoBack } + } as PostActivityFulfilledAction); + } else { + // Optimistic mode: mark as fulfilled immediately after send + yield observeOnce(directLine.postActivity(outgoingActivity as DirectLineActivity)); - yield put({ type: POST_ACTIVITY_FULFILLED, meta, payload: { activity: echoBack } } as PostActivityFulfilledAction); + yield put({ + type: POST_ACTIVITY_FULFILLED, + meta, + payload: { activity: outgoingActivity } + } as PostActivityFulfilledAction); + } } catch (err) { console.error('botframework-webchat: Failed to post activity to chat adapter.', err); @@ -206,8 +241,11 @@ export default function* postActivitySaga(ponyfill: GlobalScopePonyfill) { }) { let numActivitiesPosted = 0; + // When voice is enabled, use optimistic mode (no echo back wait) + const waitForEchoBack = !isVoiceEnabled(directLine); + yield takeEvery(POST_ACTIVITY, function* postActivityWrapper(action: PostActivityAction) { - yield* postActivity(directLine, userID, username, numActivitiesPosted++, action, ponyfill); + yield* postActivity(directLine, userID, username, numActivitiesPosted++, action, ponyfill, waitForEchoBack); }); }); } diff --git a/packages/core/src/sagas/queueIncomingActivitySaga.ts b/packages/core/src/sagas/queueIncomingActivitySaga.ts index 88717edc3e..3a849f329b 100644 --- a/packages/core/src/sagas/queueIncomingActivitySaga.ts +++ b/packages/core/src/sagas/queueIncomingActivitySaga.ts @@ -8,11 +8,39 @@ import activitiesSelector, { ofType as activitiesOfType } from '../selectors/act import sleep from '../utils/sleep'; import whileConnected from './effects/whileConnected'; +import type { DirectLineJSBotConnection } from '../types/external/DirectLineJSBotConnection'; import type { GlobalScopePonyfill } from '../types/GlobalScopePonyfill'; // We will hold up the replying activity if the originating activity did not arrive, up to 5 seconds. const REPLY_TIMEOUT = 5000; +/** + * Returns whether the DirectLine adapter is operating in voice (bi-directional WebSocket) mode. + * + * In text mode we delay rendering a bot reply until the activity it references via `replyToId` + * appears in the transcript โ€” this keeps the visual order accessible. + * + * Voice mode invalidates that assumption: + * - The client does not send an `activity.id`, and the server-assigned id is never echoed back, + * so `replyToId` on incoming activities never matches anything already in the transcript. + * - Traffic is bi-directional over the same WebSocket, so an incoming activity always arrives + * after the outgoing one โ€” visual order is implicitly correct. + * + * Therefore, when voice mode is enabled we skip the `replyToId` wait to avoid blocking the UI + * for an activity that will never arrive. + */ +function isVoiceEnabled(directLine: DirectLineJSBotConnection): boolean { + if (typeof directLine.getIsVoiceModeEnabled === 'function') { + try { + return Boolean(directLine.getIsVoiceModeEnabled()); + } catch { + return false; + } + } + + return false; +} + function* takeEveryAndSelect(actionType, selector, fn) { // select() will free up the code execution. // If we pair up with takeEvery(), it will allow actions to slip through. @@ -51,7 +79,12 @@ function* waitForActivityId(replyToId, initialActivities) { } } -function* queueIncomingActivity({ userID }: { userID: string }, ponyfill: GlobalScopePonyfill) { +function* queueIncomingActivity( + { directLine, userID }: { directLine: DirectLineJSBotConnection; userID: string }, + ponyfill: GlobalScopePonyfill +) { + const voiceModeEnabled = isVoiceEnabled(directLine); + yield takeEveryAndSelect( QUEUE_INCOMING_ACTIVITY, activitiesSelector, @@ -64,7 +97,7 @@ function* queueIncomingActivity({ userID }: { userID: string }, ponyfill: Global // To speed up the first activity render time, we do not delay the first activity from the bot. // Even if it is the first activity from the bot, the bot might be "replying" to the "conversationUpdate" event. // Thus, the "replyToId" will always be there even it is the first activity in the conversation. - if (replyToId && initialBotActivities.length) { + if (replyToId && initialBotActivities.length && !voiceModeEnabled) { // Either the activity replied to is in the transcript or after timeout. const result = yield race({ _: waitForActivityId(replyToId, initialActivities), diff --git a/packages/core/src/types/internal/WebChatOutgoingActivity.ts b/packages/core/src/types/internal/WebChatOutgoingActivity.ts index 063c63c6ff..0d0eb8d2c8 100644 --- a/packages/core/src/types/internal/WebChatOutgoingActivity.ts +++ b/packages/core/src/types/internal/WebChatOutgoingActivity.ts @@ -49,6 +49,7 @@ type WebChatOutgoingActivity = { locale: string; localTimestamp: string; localTimezone?: string; + timestamp?: string; } & (Type extends 'event' ? OutgoingEventActivityEssence : Type extends 'message' diff --git a/packages/fluent-theme/src/components/activityStatus/VoiceTranscriptActivityStatus.module.css b/packages/fluent-theme/src/components/activityStatus/VoiceTranscriptActivityStatus.module.css index b23245625b..575fea90b1 100644 --- a/packages/fluent-theme/src/components/activityStatus/VoiceTranscriptActivityStatus.module.css +++ b/packages/fluent-theme/src/components/activityStatus/VoiceTranscriptActivityStatus.module.css @@ -8,13 +8,11 @@ margin-block-start: calc(var(--webchat__padding--regular) / 2); } -.voice-transcript-activity-status__agent-label { - color: var(--webchat-colorNeutralForeground3); - font-size: var(--webchat-fontSizeBase100); - font-weight: var(--webchat-fontWeightSemibold); - line-height: var(--webchat-lineHeightBase100); -} - .voice-transcript-activity-status__divider { color: var(--webchat-colorNeutralStroke2); + margin-block-end: var(--webchat-spacingVerticalXXS); +} + +.voice-transcript-activity-status__icon { + margin-block-start: var(--webchat-spacingVerticalXXS); } diff --git a/packages/fluent-theme/src/components/activityStatus/VoiceTranscriptActivityStatus.tsx b/packages/fluent-theme/src/components/activityStatus/VoiceTranscriptActivityStatus.tsx index 28f9202f0b..935dc9f405 100644 --- a/packages/fluent-theme/src/components/activityStatus/VoiceTranscriptActivityStatus.tsx +++ b/packages/fluent-theme/src/components/activityStatus/VoiceTranscriptActivityStatus.tsx @@ -1,39 +1,30 @@ -import { hooks } from 'botframework-webchat'; import { Timestamp } from 'botframework-webchat/component'; import { getVoiceActivityRole, getVoiceActivityText, type WebChatActivity } from 'botframework-webchat/internal'; -import React, { Fragment, memo } from 'react'; +import React, { memo } from 'react'; +import { FluentIcon } from '../icon'; import { useStyles } from '../../styles'; import styles from './VoiceTranscriptActivityStatus.module.css'; -const { useLocalizer } = hooks; - type VoiceTranscriptActivityStatusProps = Readonly<{ activity: WebChatActivity; }>; function VoiceTranscriptActivityStatus({ activity }: VoiceTranscriptActivityStatusProps) { const classNames = useStyles(styles); - const localize = useLocalizer(); const { timestamp } = activity; - const role = getVoiceActivityRole(activity); - const text = getVoiceActivityText(activity); - - const agentLabel = localize('ACTIVITY_STATUS_VOICE_TRANSCRIPT_AGENT_LABEL'); - if (!text) { + if (!getVoiceActivityText(activity)) { return null; } + const icon = getVoiceActivityRole(activity) === 'bot' ? 'audio-playing' : 'microphone-regular'; + return ( - {role === 'bot' && ( - - {agentLabel} - {timestamp && {'|'}} - - )} {timestamp && } + {timestamp && {'|'}} + ); } diff --git a/packages/fluent-theme/src/components/icon/FluentIcon.module.css b/packages/fluent-theme/src/components/icon/FluentIcon.module.css index f6f274c23a..830c9ff253 100644 --- a/packages/fluent-theme/src/components/icon/FluentIcon.module.css +++ b/packages/fluent-theme/src/components/icon/FluentIcon.module.css @@ -50,10 +50,14 @@ --webchat__fluent-icon--mask: url('data:image/svg+xml;utf8,'); } -:global(.webchat) .icon--microphone { +:global(.webchat) .icon--microphone-filled { --webchat__fluent-icon--mask: url('data:image/svg+xml;utf8,'); } +:global(.webchat) .icon--microphone-regular { + --webchat__fluent-icon--mask: url('data:image/svg+xml;utf8,'); +} + :global(.webchat) .icon--audio-playing { --webchat__fluent-icon--mask: url('data:image/svg+xml;utf8,'); } diff --git a/packages/fluent-theme/src/components/sendBox/MicrophoneToolbarButton.tsx b/packages/fluent-theme/src/components/sendBox/MicrophoneToolbarButton.tsx index 946f2a5e88..c9cdc45909 100644 --- a/packages/fluent-theme/src/components/sendBox/MicrophoneToolbarButton.tsx +++ b/packages/fluent-theme/src/components/sendBox/MicrophoneToolbarButton.tsx @@ -47,7 +47,16 @@ function MicrophoneToolbarButton() { onClick={handleMicrophoneClick} type="button" > - + ); } diff --git a/packages/fluent-theme/src/components/sendBox/SendBox.tsx b/packages/fluent-theme/src/components/sendBox/SendBox.tsx index 05fb2bf184..d8417329a6 100644 --- a/packages/fluent-theme/src/components/sendBox/SendBox.tsx +++ b/packages/fluent-theme/src/components/sendBox/SendBox.tsx @@ -77,16 +77,14 @@ function SendBox(props: Props) { const setMessage = props.isPrimary ? setGlobalMessage : setLocalMessage; const isBlueprint = uiState === 'blueprint'; + // Disable text input and send button when mic is active to prevent race conditions and as per design + const isVoiceRecording = recording && showMicrophoneButton; + const [errorMessage, commitLatestError] = useSubmitError({ message, attachments }); const isMessageLengthExceeded = !!maxMessageLength && message.length > maxMessageLength; const shouldShowMessageLength = useMemo( - () => - !isBlueprint && - !telephoneKeypadShown && - !!maxMessageLength && - isFinite(maxMessageLength) && - !showMicrophoneButton, - [isBlueprint, telephoneKeypadShown, maxMessageLength, showMicrophoneButton] + () => !isBlueprint && !telephoneKeypadShown && !!maxMessageLength && isFinite(maxMessageLength), + [isBlueprint, telephoneKeypadShown, maxMessageLength] ); const shouldShowTelephoneKeypad = !isBlueprint && telephoneKeypadShown; @@ -223,9 +221,9 @@ function SendBox(props: Props) { onClick={handleClick} onInput={handleMessageChange} placeholder={ - props.placeholder ?? (showMicrophoneButton ? speechStateMessage : localize('TEXT_INPUT_PLACEHOLDER')) + props.placeholder ?? (isVoiceRecording ? speechStateMessage : localize('TEXT_INPUT_PLACEHOLDER')) } - readOnly={showMicrophoneButton} + readOnly={isVoiceRecording} ref={inputRef} value={message} /> @@ -257,19 +255,17 @@ function SendBox(props: Props) { {!hideTelephoneKeypadButton && } {!disableFileUpload && } + {showMicrophoneButton && } - {showMicrophoneButton ? ( - - ) : ( - - - - )} + + + + {!disableFileUpload && } diff --git a/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js b/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js index c29e9bdc27..7e6ebdbe22 100644 --- a/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js +++ b/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js @@ -38,11 +38,14 @@ export default function createDirectLineEmulator({ autoConnect = true, ponyfill connectionStatusDeferredObservable.next(2); }); + // Generic capabilities storage + const capabilities = new Map(); + const postActivityCallDeferreds = []; const postActivity = outgoingActivity => { // Auto-handle voice activities (continuous sending by mic) without requiring actPostActivity - // Voice activities are fire-and-forget and don't echo back - if (outgoingActivity.type === 'event' && outgoingActivity.name.includes('media')) { + // Voice mode uses fire-and-forget and don't echo back + if (capabilities.get('getIsVoiceModeEnabled')) { const id = uniqueId(); return new Observable(observer => { @@ -118,9 +121,6 @@ export default function createDirectLineEmulator({ autoConnect = true, ponyfill autoConnect && connectedWithResolvers.resolve(); - // Generic capabilities storage - const capabilities = new Map(); - // EventTarget for capability change notifications const eventTarget = new EventTarget();