Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ Breaking changes in this release:
- 👷🏻 Added `npm run build-browser` script for building test harness package only, in PR [#5667](https://github.com/microsoft/BotFramework-WebChat/pull/5667), by [@compulim](https://github.com/compulim)
- Added pull-based capabilities system for dynamically discovering adapter capabilities at runtime, in PR [#5679](https://github.com/microsoft/BotFramework-WebChat/pull/5679), by [@pranavjoshi001](https://github.com/pranavjoshi001)
- Added Speech-to-Speech (S2S) support for real-time voice conversations, in PR [#5654](https://github.com/microsoft/BotFramework-WebChat/pull/5654), by [@pranavjoshi](https://github.com/pranavjoshi001)
- 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)
Copy link

Copilot AI Mar 6, 2026

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 useRecorder hook", but useRecorder is an internal/private implementation detail under providers/SpeechToSpeech/private. For consumers, the new public surface appears to be useVoiceRecordingMuted (and/or the muteVoiceRecording/unmuteVoiceRecording actions). Consider rewording this entry to reference the public API to avoid confusing integrators.

Suggested change
- 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 core mute/unmute functionality for speech-to-speech via `useVoiceRecordingMuted` hook and `muteVoiceRecording` / `unmuteVoiceRecording` actions (silent chunks keep server connection alive), in PR [#5688](https://github.com/microsoft/BotFramework-WebChat/pull/5688), by [@pranavjoshi](https://github.com/pranavjoshi001)

Copilot uses AI. Check for mistakes.

### Changed

Expand Down
15 changes: 14 additions & 1 deletion __tests__/assets/esm/speechToSpeech/mockMediaDevices.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the mock sine-wave generator, audioData[+i] is equivalent to audioData[i] but the unary + is unnecessary and makes the intent harder to read. Consider using audioData[i] here.

Suggested change
audioData[+i] = Math.sin(i * 0.1) * 0.5;
audioData[i] = Math.sin(i * 0.1) * 0.5;

Copilot uses AI. Check for mistakes.
}
}
channel.port2.postMessage({ eventType: 'audio', audioData });
}
}, 100);
} else if (data.command === 'STOP') {
Expand All @@ -57,6 +66,10 @@ export function setupMockMediaDevices() {
clearInterval(intervalId);
intervalId = null;
}
} else if (data.command === 'MUTE') {
muted = true;
} else if (data.command === 'UNMUTE') {
muted = false;
}
};

Expand Down
214 changes: 214 additions & 0 deletions __tests__/html2/speechToSpeech/mute.unmute.html
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);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't they simple negation of each other? 🤣

Copy link
Contributor

Choose a reason for hiding this comment

The 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>
1 change: 1 addition & 0 deletions packages/api/src/boot/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export {
useUIState,
useUserID,
useUsername,
useVoiceRecordingMuted,
useVoiceSelector,
useVoiceState
} from '../hooks/index';
2 changes: 2 additions & 0 deletions packages/api/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import useTrackTiming from './useTrackTiming';
import useUIState from './useUIState';
import useUserID from './useUserID';
import useUsername from './useUsername';
import useVoiceRecordingMuted from './useVoiceRecordingMuted';
import useVoiceSelector from './useVoiceSelector';
import useVoiceState from './useVoiceState';

Expand Down Expand Up @@ -155,6 +156,7 @@ export {
useUIState,
useUserID,
useUsername,
useVoiceRecordingMuted,
useVoiceSelector,
useVoiceState
};
20 changes: 20 additions & 0 deletions packages/api/src/hooks/useVoiceRecordingMuted.ts
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]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function VoiceRecorderBridge(): null {
const [voiceState] = useVoiceState();
const postVoiceActivity = usePostVoiceActivity();

const muted = voiceState === 'muted';
// Derive recording state from voiceState - recording is active when not idle
const recording = voiceState !== 'idle';

Expand All @@ -29,7 +30,13 @@ export function VoiceRecorderBridge(): null {
[postVoiceActivity]
);

const { record } = useRecorder(handleAudioChunk);
const { mute, record } = useRecorder(handleAudioChunk);

useEffect(() => {
if (muted) {
return mute();
}
}, [muted, mute]);

useEffect(() => {
if (recording) {
Expand Down
Loading
Loading