Skip to content

Commit 482cb4c

Browse files
Speech to speech: mute/unmute independent from voice state (#5794)
* mute independent from voice state * fix review comment * review comment fixed --------- Co-authored-by: Eugene <EOlonov@gmail.com>
1 parent 7f6d94b commit 482cb4c

File tree

5 files changed

+105
-52
lines changed

5 files changed

+105
-52
lines changed

__tests__/html2/speechToSpeech/mute.unmute.html

Lines changed: 85 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@
1616
Test: Mute/Unmute functionality for Speech-to-Speech
1717
1818
This test validates:
19-
1. Listening state can transition to muted and back to listening
20-
2. Other states (idle) cannot transition to muted
21-
3. Muted chunks contain all zeros (silent audio)
22-
4. Uses useVoiceRecordingMuted hook via Composer pattern for mute/unmute control
19+
1. Mute is independent of voiceState - can be toggled anytime
20+
2. Starting recording while muted should reset and start recording always unmuted.
21+
3. When muted during listening, chunks contain all zeros (silent audio)
22+
4. When unmuted, chunks contain real audio
23+
5. Mute resets to false when recording stops
24+
6. Stopping while muted does NOT re-acquire microphone (cleanup order test)
25+
7. Uses useVoiceRecordingMuted hook for mute/unmute control
2326
-->
2427
<script type="module">
2528
import { setupMockMediaDevices } from '/assets/esm/speechToSpeech/mockMediaDevices.js';
@@ -51,26 +54,26 @@
5154
return bytes.every(byte => byte === 0);
5255
}
5356

54-
// Helper to check if audio has non-zero data (real audio)
55-
function hasNonZeroAudio(base64Content) {
56-
const binaryString = atob(base64Content);
57-
const bytes = new Uint8Array(binaryString.length);
58-
for (let i = 0; i < binaryString.length; i++) {
59-
bytes[i] = binaryString.charCodeAt(i);
60-
}
61-
return bytes.some(byte => byte !== 0);
62-
}
57+
// Wrap getUserMedia to track call count for cleanup order test
58+
let getUserMediaCallCount = 0;
59+
const originalGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
60+
navigator.mediaDevices.getUserMedia = (...args) => {
61+
getUserMediaCallCount++;
62+
return originalGetUserMedia(...args);
63+
};
6364

6465
const audioChunks = [];
6566
let currentVoiceState = 'idle';
67+
let currentMicrophoneMuted = false;
6668

6769
// Setup Web Chat with Speech-to-Speech
6870
const { directLine, store } = testHelpers.createDirectLineEmulator();
6971
directLine.setCapability('getVoiceConfiguration', { sampleRate: 24000, chunkIntervalMs: 100 }, { emitEvent: false });
7072

71-
// Track voiceState changes
73+
// Track voiceState and microphoneMuted changes
7274
store.subscribe(() => {
7375
currentVoiceState = store.getState().voice?.voiceState || 'idle';
76+
currentMicrophoneMuted = store.getState().voice?.microphoneMuted || false;
7477
});
7578

7679
// Intercept postActivity to capture outgoing voice chunks
@@ -79,7 +82,8 @@
7982
if (activity.name === 'media.chunk' && activity.type === 'event') {
8083
audioChunks.push({
8184
content: activity.value?.content,
82-
voiceState: currentVoiceState
85+
voiceState: currentVoiceState,
86+
microphoneMuted: currentMicrophoneMuted
8387
});
8488
}
8589
return originalPostActivity(activity);
@@ -103,6 +107,7 @@
103107

104108
// Helper to get voice state from store
105109
const getVoiceState = () => store.getState().voice?.voiceState;
110+
const getMicrophoneMuted = () => store.getState().voice?.microphoneMuted;
106111

107112
render(
108113
<FluentThemeProvider variant="fluent">
@@ -119,17 +124,17 @@
119124
const micButton = document.querySelector(`[data-testid="${testIds.sendBoxMicrophoneButton}"]`);
120125
expect(micButton).toBeTruthy();
121126

122-
// ===== TEST 1: Muting from idle state should be no-op =====
127+
// ===== TEST 1: Muting from idle state should work as independent from voice state =====
123128
expect(getVoiceState()).toBe('idle');
124129
expect(muteControlRef.muted).toBe(false);
125130

126131
muteControlRef.setMuted(true);
127132
await new Promise(r => setTimeout(r, 100));
128133

129134
expect(getVoiceState()).toBe('idle'); // Still idle, not muted
130-
expect(muteControlRef.muted).toBe(false);
135+
expect(muteControlRef.muted).toBe(true);
131136

132-
// ===== TEST 2: Start recording → listening state =====
137+
// ===== TEST 2: Start recording → listening state, microphoneMuted resets to false =====
133138
await host.click(micButton);
134139

135140
await pageConditions.became(
@@ -138,44 +143,49 @@
138143
2000
139144
);
140145

146+
// Starting recording resets microphoneMuted to false.
147+
// This ensures a clean slate - recording always starts unmuted.
148+
expect(muteControlRef.muted).toBe(false);
149+
141150
// Wait for some listening chunks
142151
await pageConditions.became(
143152
'At least 2 listening chunks received',
144-
() => audioChunks.filter(c => c.voiceState === 'listening').length >= 2,
153+
() => audioChunks.filter(c => c.voiceState === 'listening' && !c.microphoneMuted).length >= 2,
145154
2000
146155
);
147156

148-
// ===== TEST 3: Mute from listening state → muted state =====
157+
// ===== TEST 3: Mute while listening → microphoneMuted true, voiceState stays listening =====
149158
muteControlRef.setMuted(true);
150159

151160
await pageConditions.became(
152-
'Voice state is muted',
153-
() => getVoiceState() === 'muted',
161+
'microphoneMuted is true',
162+
() => getMicrophoneMuted() === true,
154163
1000
155164
);
156165

157166
expect(muteControlRef.muted).toBe(true);
167+
expect(getVoiceState()).toBe('listening'); // voiceState stays listening
158168

159169
// Wait for muted chunks
160170
await pageConditions.became(
161171
'At least 2 muted chunks received',
162-
() => audioChunks.filter(c => c.voiceState === 'muted').length >= 2,
172+
() => audioChunks.filter(c => c.microphoneMuted).length >= 2,
163173
2000
164174
);
165175

166176
// ===== TEST 4: Verify muted chunks are all zeros =====
167-
const mutedChunks = audioChunks.filter(c => c.voiceState === 'muted');
177+
const mutedChunks = audioChunks.filter(c => c.microphoneMuted);
168178
expect(mutedChunks.length).toBeGreaterThanOrEqual(2);
169179
for (const chunk of mutedChunks) {
170180
expect(isAudioAllZeros(chunk.content)).toBe(true);
171181
}
172182

173-
// ===== TEST 5: Unmute → back to listening state =====
183+
// ===== TEST 5: Unmute → microphoneMuted false =====
174184
muteControlRef.setMuted(false);
175185

176186
await pageConditions.became(
177-
'Voice state is listening after unmute',
178-
() => getVoiceState() === 'listening',
187+
'microphoneMuted is false after unmute',
188+
() => getMicrophoneMuted() === false,
179189
1000
180190
);
181191

@@ -190,15 +200,15 @@
190200
);
191201

192202
// ===== TEST 6: Verify listening chunks contain real (non-zero) audio =====
193-
const listeningChunks = audioChunks.filter(c => c.voiceState === 'listening');
203+
const listeningChunks = audioChunks.filter(c => c.voiceState === 'listening' && !c.microphoneMuted);
194204
expect(listeningChunks.length).toBeGreaterThanOrEqual(4); // At least 2 before mute + 2 after unmute
195205

196206
// Verify listening audio is non-zero (real audio)
197207
for (const chunk of listeningChunks) {
198-
expect(hasNonZeroAudio(chunk.content)).toBe(true);
208+
expect(isAudioAllZeros(chunk.content)).toBe(false);
199209
}
200210

201-
// ===== TEST 7: Stop recording =====
211+
// ===== TEST 7: Stop recording → microphoneMuted resets to false =====
202212
await host.click(micButton);
203213

204214
await pageConditions.became(
@@ -207,7 +217,51 @@
207217
2000
208218
);
209219

210-
expect(muteControlRef.muted).toBe(false);
220+
expect(muteControlRef.muted).toBe(false); // microphoneMuted resets on stop
221+
222+
// ===== TEST 8: Stopping while muted should NOT re-acquire microphone =====
223+
// This test verifies the effect cleanup order in VoiceRecorderBridge:
224+
// Recording effect cleanup must run BEFORE mute effect cleanup.
225+
// Otherwise, unmute cleanup would call acquireAndConnectMediaStream().
226+
227+
// Start fresh recording
228+
await host.click(micButton);
229+
await pageConditions.became(
230+
'Voice state is listening for cleanup test',
231+
() => getVoiceState() === 'listening',
232+
2000
233+
);
234+
235+
// Record the getUserMedia call count before mute
236+
const callCountBeforeMute = getUserMediaCallCount;
237+
238+
// Mute (this will stop the MediaStream)
239+
muteControlRef.setMuted(true);
240+
await pageConditions.became(
241+
'Muted for cleanup test',
242+
() => getMicrophoneMuted() === true,
243+
1000
244+
);
245+
246+
// Record call count after mute (should be same, mute stops mic but doesn't acquire)
247+
const callCountAfterMute = getUserMediaCallCount;
248+
expect(callCountAfterMute).toBe(callCountBeforeMute);
249+
250+
// Stop recording while still muted
251+
await host.click(micButton);
252+
await pageConditions.became(
253+
'Voice state is idle after stopping while muted',
254+
() => getVoiceState() === 'idle',
255+
2000
256+
);
257+
258+
// Wait a bit to ensure any erroneous async mic acquisition would have happened
259+
await new Promise(r => setTimeout(r, 300));
260+
261+
// Verify getUserMedia was NOT called again
262+
// If cleanup order was wrong, unmute would have called acquireAndConnectMediaStream()
263+
const callCountAfterStop = getUserMediaCallCount;
264+
expect(callCountAfterStop).toBe(callCountAfterMute);
211265
});
212266
</script>
213267
</body>

packages/api/src/hooks/useVoiceRecordingMuted.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import { useDispatch, useSelector } from './internal/WebChatReduxContext';
44

55
/**
66
* Hook to get and set voice recording mute state in speech-to-speech mode.
7+
*
8+
* Mute is independent of voice state - it can be toggled at any time.
9+
* When muted, silent audio chunks are sent instead of real audio.
10+
* Mute resets to false when recording stops.
711
*/
812
export default function useVoiceRecordingMuted(): readonly [boolean, (muted: boolean) => void] {
913
const dispatch = useDispatch();
10-
const value = useSelector(({ voice }) => voice.voiceState === 'muted');
14+
const value = useSelector(({ voice }) => voice.microphoneMuted);
1115

1216
const setter = useCallback(
1317
(muted: boolean) => {

packages/api/src/providers/SpeechToSpeech/private/VoiceRecorderBridge.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import { useEffect, useCallback } from 'react';
22
import { useRecorder } from './useRecorder';
33
import usePostVoiceActivity from '../../../hooks/internal/usePostVoiceActivity';
4+
import useVoiceRecordingMuted from '../../../hooks/useVoiceRecordingMuted';
45
import useVoiceState from '../../../hooks/useVoiceState';
56

67
/**
78
* VoiceRecorderBridge is an invisible component that bridges the Redux recording state
89
* with the actual microphone recording functionality.
910
*/
1011
export function VoiceRecorderBridge(): null {
12+
const [muted] = useVoiceRecordingMuted();
1113
const [voiceState] = useVoiceState();
1214
const postVoiceActivity = usePostVoiceActivity();
1315

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

@@ -32,17 +33,17 @@ export function VoiceRecorderBridge(): null {
3233

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

35-
useEffect(() => {
36-
if (muted) {
37-
return mute();
38-
}
39-
}, [muted, mute]);
40-
4136
useEffect(() => {
4237
if (recording) {
4338
return record();
4439
}
4540
}, [record, recording]);
4641

42+
useEffect(() => {
43+
if (muted) {
44+
return mute();
45+
}
46+
}, [muted, mute]);
47+
4748
return null;
4849
}

packages/core/src/actions/setVoiceState.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const VOICE_SET_STATE = 'WEB_CHAT/VOICE_SET_STATE' as const;
22

3-
type VoiceState = 'idle' | 'listening' | 'muted' | 'user_speaking' | 'processing' | 'bot_speaking';
3+
type VoiceState = 'idle' | 'listening' | 'user_speaking' | 'processing' | 'bot_speaking';
44

55
type VoiceSetStateAction = {
66
type: typeof VOICE_SET_STATE;

packages/core/src/reducers/voiceActivity.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,13 @@ type VoiceActivityActions =
2424
| VoiceUnregisterHandlerAction;
2525

2626
interface VoiceActivityState {
27+
microphoneMuted: boolean;
2728
voiceState: VoiceState;
2829
voiceHandlers: Map<string, VoiceHandler>;
2930
}
3031

3132
const DEFAULT_STATE: VoiceActivityState = {
33+
microphoneMuted: false,
3234
voiceState: 'idle',
3335
voiceHandlers: new Map()
3436
};
@@ -39,15 +41,9 @@ export default function voiceActivity(
3941
): VoiceActivityState {
4042
switch (action.type) {
4143
case VOICE_MUTE_RECORDING:
42-
// Only allow muting when in listening state
43-
if (state.voiceState !== 'listening') {
44-
console.warn(`botframework-webchat: Cannot mute from "${state.voiceState}" state, must be "listening"`);
45-
return state;
46-
}
47-
4844
return {
4945
...state,
50-
voiceState: 'muted'
46+
microphoneMuted: true
5147
};
5248

5349
case VOICE_REGISTER_HANDLER: {
@@ -81,23 +77,21 @@ export default function voiceActivity(
8177

8278
return {
8379
...state,
80+
microphoneMuted: false,
8481
voiceState: 'listening'
8582
};
8683

8784
case VOICE_STOP_RECORDING:
8885
return {
8986
...state,
87+
microphoneMuted: false,
9088
voiceState: 'idle'
9189
};
9290

9391
case VOICE_UNMUTE_RECORDING:
94-
if (state.voiceState !== 'muted') {
95-
console.warn(`botframework-webchat: Should not transit from "${state.voiceState}" to "listening"`);
96-
}
97-
9892
return {
9993
...state,
100-
voiceState: 'listening'
94+
microphoneMuted: false
10195
};
10296

10397
default:

0 commit comments

Comments
 (0)