-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Speech to speech: Mute functionality #5688
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
Changes from all commits
3c5d7e7
aec1249
14da8a4
f1259fb
f35f158
a8e96b8
f266871
ab82cb6
c08c225
c70f64f
b68593f
4bee074
43d57f7
60b8e5b
101303b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -32,6 +32,7 @@ export function setupMockMediaDevices() { | |||||
| const node = context.createGain(); | ||||||
| const channel = new MessageChannel(); | ||||||
| let recording = false; | ||||||
| let muted = false; | ||||||
| let intervalId = null; | ||||||
|
|
||||||
| node.port = channel.port1; | ||||||
|
|
@@ -42,13 +43,21 @@ export function setupMockMediaDevices() { | |||||
| channel.port2.onmessage = ({ data }) => { | ||||||
| if (data.command === 'START') { | ||||||
| recording = true; | ||||||
| muted = false; | ||||||
| const bufferSize = options?.processorOptions?.bufferSize || 2400; | ||||||
|
|
||||||
| // Send chunks at ~100ms intervals while recording | ||||||
| // Use port2.postMessage so port1.onmessage (set by real code) receives it | ||||||
| intervalId = setInterval(() => { | ||||||
| if (recording) { | ||||||
| channel.port2.postMessage({ eventType: 'audio', audioData: new Float32Array(bufferSize) }); | ||||||
| // Float32Array defaults to zeros (silent), fill with sine wave when not muted | ||||||
| const audioData = new Float32Array(bufferSize); | ||||||
| if (!muted) { | ||||||
| for (let i = 0; i < bufferSize; i++) { | ||||||
| audioData[+i] = Math.sin(i * 0.1) * 0.5; | ||||||
|
||||||
| audioData[+i] = Math.sin(i * 0.1) * 0.5; | |
| audioData[i] = Math.sin(i * 0.1) * 0.5; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,214 @@ | ||
| <!doctype html> | ||
| <html lang="en-US"> | ||
| <head> | ||
| <link href="/assets/index.css" rel="stylesheet" type="text/css" /> | ||
| <script crossorigin="anonymous" src="https://unpkg.com/@babel/standalone@7.8.7/babel.min.js"></script> | ||
| <script crossorigin="anonymous" src="https://unpkg.com/react@16.8.6/umd/react.production.min.js"></script> | ||
| <script crossorigin="anonymous" src="https://unpkg.com/react-dom@16.8.6/umd/react-dom.production.min.js"></script> | ||
| <script crossorigin="anonymous" src="/test-harness.js"></script> | ||
| <script crossorigin="anonymous" src="/test-page-object.js"></script> | ||
| <script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script> | ||
| <script crossorigin="anonymous" src="/__dist__/botframework-webchat-fluent-theme.production.min.js"></script> | ||
| </head> | ||
| <body> | ||
| <main id="webchat"></main> | ||
| <!-- | ||
| Test: Mute/Unmute functionality for Speech-to-Speech | ||
|
|
||
| This test validates: | ||
| 1. Listening state can transition to muted and back to listening | ||
| 2. Other states (idle) cannot transition to muted | ||
| 3. Muted chunks contain all zeros (silent audio) | ||
| 4. Uses useVoiceRecordingMuted hook via Composer pattern for mute/unmute control | ||
| --> | ||
| <script type="module"> | ||
| import { setupMockMediaDevices } from '/assets/esm/speechToSpeech/mockMediaDevices.js'; | ||
| import { setupMockAudioPlayback } from '/assets/esm/speechToSpeech/mockAudioPlayback.js'; | ||
|
|
||
| setupMockMediaDevices(); | ||
| setupMockAudioPlayback(); | ||
| </script> | ||
| <script type="text/babel"> | ||
| run(async function () { | ||
| const { | ||
| React: { useEffect }, | ||
| ReactDOM: { render }, | ||
| WebChat: { | ||
| FluentThemeProvider, | ||
| testIds, | ||
| hooks: { useVoiceRecordingMuted }, | ||
| Components: { Composer, BasicWebChat } | ||
| } | ||
| } = window; | ||
|
|
||
| // Helper to decode base64 audio and check if all zeros | ||
| function isAudioAllZeros(base64Content) { | ||
| const binaryString = atob(base64Content); | ||
| const bytes = new Uint8Array(binaryString.length); | ||
| for (let i = 0; i < binaryString.length; i++) { | ||
| bytes[i] = binaryString.charCodeAt(i); | ||
| } | ||
| return bytes.every(byte => byte === 0); | ||
| } | ||
|
|
||
| // Helper to check if audio has non-zero data (real audio) | ||
| function hasNonZeroAudio(base64Content) { | ||
| const binaryString = atob(base64Content); | ||
| const bytes = new Uint8Array(binaryString.length); | ||
| for (let i = 0; i < binaryString.length; i++) { | ||
| bytes[i] = binaryString.charCodeAt(i); | ||
| } | ||
| return bytes.some(byte => byte !== 0); | ||
| } | ||
|
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. Aren't they simple negation of each other? 🤣
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. It's okay to work on it when we touch it. |
||
|
|
||
| const audioChunks = []; | ||
| let currentVoiceState = 'idle'; | ||
|
|
||
| // Setup Web Chat with Speech-to-Speech | ||
| const { directLine, store } = testHelpers.createDirectLineEmulator(); | ||
| directLine.setCapability('getVoiceConfiguration', { sampleRate: 24000, chunkIntervalMs: 100 }, { emitEvent: false }); | ||
|
|
||
| // Track voiceState changes | ||
| store.subscribe(() => { | ||
| currentVoiceState = store.getState().voice?.voiceState || 'idle'; | ||
| }); | ||
|
|
||
| // Intercept postActivity to capture outgoing voice chunks | ||
| const originalPostActivity = directLine.postActivity.bind(directLine); | ||
| directLine.postActivity = activity => { | ||
| if (activity.name === 'media.chunk' && activity.type === 'event') { | ||
| audioChunks.push({ | ||
| content: activity.value?.content, | ||
| voiceState: currentVoiceState | ||
| }); | ||
| } | ||
| return originalPostActivity(activity); | ||
| }; | ||
|
|
||
| // Component to expose hook functions for testing (inside Composer context) | ||
| let muteControlRef = { setMuted: null, muted: false }; | ||
|
|
||
| const MuteController = () => { | ||
| const [muted, setMuted] = useVoiceRecordingMuted(); | ||
|
|
||
| useEffect(() => { | ||
| muteControlRef.setMuted = setMuted; | ||
| }, [setMuted]); | ||
|
|
||
| // Update muted on every render to capture latest value | ||
| muteControlRef.muted = muted; | ||
|
|
||
| return false; | ||
| }; | ||
|
|
||
| // Helper to get voice state from store | ||
| const getVoiceState = () => store.getState().voice?.voiceState; | ||
|
|
||
| render( | ||
| <FluentThemeProvider variant="fluent"> | ||
| <Composer directLine={directLine} store={store}> | ||
| <BasicWebChat /> | ||
| <MuteController /> | ||
| </Composer> | ||
| </FluentThemeProvider>, | ||
| document.getElementById('webchat') | ||
| ); | ||
|
|
||
| await pageConditions.uiConnected(); | ||
|
|
||
| const micButton = document.querySelector(`[data-testid="${testIds.sendBoxMicrophoneButton}"]`); | ||
| expect(micButton).toBeTruthy(); | ||
|
|
||
| // ===== TEST 1: Muting from idle state should be no-op ===== | ||
| expect(getVoiceState()).toBe('idle'); | ||
| expect(muteControlRef.muted).toBe(false); | ||
|
|
||
| muteControlRef.setMuted(true); | ||
| await new Promise(r => setTimeout(r, 100)); | ||
|
|
||
| expect(getVoiceState()).toBe('idle'); // Still idle, not muted | ||
| expect(muteControlRef.muted).toBe(false); | ||
|
|
||
| // ===== TEST 2: Start recording → listening state ===== | ||
| await host.click(micButton); | ||
|
|
||
| await pageConditions.became( | ||
| 'Voice state is listening', | ||
| () => getVoiceState() === 'listening', | ||
| 2000 | ||
| ); | ||
|
|
||
| // Wait for some listening chunks | ||
| await pageConditions.became( | ||
| 'At least 2 listening chunks received', | ||
| () => audioChunks.filter(c => c.voiceState === 'listening').length >= 2, | ||
| 2000 | ||
| ); | ||
|
|
||
| // ===== TEST 3: Mute from listening state → muted state ===== | ||
| muteControlRef.setMuted(true); | ||
|
|
||
| await pageConditions.became( | ||
| 'Voice state is muted', | ||
| () => getVoiceState() === 'muted', | ||
| 1000 | ||
| ); | ||
|
|
||
| expect(muteControlRef.muted).toBe(true); | ||
|
|
||
| // Wait for muted chunks | ||
| await pageConditions.became( | ||
| 'At least 2 muted chunks received', | ||
| () => audioChunks.filter(c => c.voiceState === 'muted').length >= 2, | ||
| 2000 | ||
| ); | ||
|
|
||
| // ===== TEST 4: Verify muted chunks are all zeros ===== | ||
| const mutedChunks = audioChunks.filter(c => c.voiceState === 'muted'); | ||
| expect(mutedChunks.length).toBeGreaterThanOrEqual(2); | ||
| for (const chunk of mutedChunks) { | ||
| expect(isAudioAllZeros(chunk.content)).toBe(true); | ||
| } | ||
|
|
||
| // ===== TEST 5: Unmute → back to listening state ===== | ||
| muteControlRef.setMuted(false); | ||
|
|
||
| await pageConditions.became( | ||
| 'Voice state is listening after unmute', | ||
| () => getVoiceState() === 'listening', | ||
| 1000 | ||
| ); | ||
|
|
||
| expect(muteControlRef.muted).toBe(false); | ||
|
|
||
| // Wait for more chunks after unmute | ||
| const chunksBeforeCheck = audioChunks.length; | ||
| await pageConditions.became( | ||
| 'New chunks received after unmute', | ||
| () => audioChunks.length > chunksBeforeCheck + 1, | ||
| 2000 | ||
| ); | ||
|
|
||
| // ===== TEST 6: Verify listening chunks contain real (non-zero) audio ===== | ||
| const listeningChunks = audioChunks.filter(c => c.voiceState === 'listening'); | ||
| expect(listeningChunks.length).toBeGreaterThanOrEqual(4); // At least 2 before mute + 2 after unmute | ||
|
|
||
| // Verify listening audio is non-zero (real audio) | ||
| for (const chunk of listeningChunks) { | ||
| expect(hasNonZeroAudio(chunk.content)).toBe(true); | ||
| } | ||
|
|
||
| // ===== TEST 7: Stop recording ===== | ||
| await host.click(micButton); | ||
|
|
||
| await pageConditions.became( | ||
| 'Voice state is idle after stop', | ||
| () => getVoiceState() === 'idle', | ||
| 2000 | ||
| ); | ||
|
|
||
| expect(muteControlRef.muted).toBe(false); | ||
| }); | ||
| </script> | ||
| </body> | ||
| </html> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { muteVoiceRecording, unmuteVoiceRecording } from 'botframework-webchat-core'; | ||
| import { useCallback } from 'react'; | ||
| import { useDispatch, useSelector } from './internal/WebChatReduxContext'; | ||
|
|
||
| /** | ||
| * Hook to get and set voice recording mute state in speech-to-speech mode. | ||
| */ | ||
| export default function useVoiceRecordingMuted(): readonly [boolean, (muted: boolean) => void] { | ||
| const dispatch = useDispatch(); | ||
| const value = useSelector(({ voice }) => voice.voiceState === 'muted'); | ||
|
|
||
| const setter = useCallback( | ||
| (muted: boolean) => { | ||
| dispatch(muted ? muteVoiceRecording() : unmuteVoiceRecording()); | ||
| }, | ||
| [dispatch] | ||
| ); | ||
|
|
||
| return Object.freeze([value, setter]); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changelog entry says mute/unmute was added "via
useRecorderhook", butuseRecorderis an internal/private implementation detail underproviders/SpeechToSpeech/private. For consumers, the new public surface appears to beuseVoiceRecordingMuted(and/or themuteVoiceRecording/unmuteVoiceRecordingactions). Consider rewording this entry to reference the public API to avoid confusing integrators.