|
16 | 16 | Test: Mute/Unmute functionality for Speech-to-Speech |
17 | 17 | |
18 | 18 | 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 |
23 | 26 | --> |
24 | 27 | <script type="module"> |
25 | 28 | import { setupMockMediaDevices } from '/assets/esm/speechToSpeech/mockMediaDevices.js'; |
|
51 | 54 | return bytes.every(byte => byte === 0); |
52 | 55 | } |
53 | 56 |
|
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 | + }; |
63 | 64 |
|
64 | 65 | const audioChunks = []; |
65 | 66 | let currentVoiceState = 'idle'; |
| 67 | + let currentMicrophoneMuted = false; |
66 | 68 |
|
67 | 69 | // Setup Web Chat with Speech-to-Speech |
68 | 70 | const { directLine, store } = testHelpers.createDirectLineEmulator(); |
69 | 71 | directLine.setCapability('getVoiceConfiguration', { sampleRate: 24000, chunkIntervalMs: 100 }, { emitEvent: false }); |
70 | 72 |
|
71 | | - // Track voiceState changes |
| 73 | + // Track voiceState and microphoneMuted changes |
72 | 74 | store.subscribe(() => { |
73 | 75 | currentVoiceState = store.getState().voice?.voiceState || 'idle'; |
| 76 | + currentMicrophoneMuted = store.getState().voice?.microphoneMuted || false; |
74 | 77 | }); |
75 | 78 |
|
76 | 79 | // Intercept postActivity to capture outgoing voice chunks |
|
79 | 82 | if (activity.name === 'media.chunk' && activity.type === 'event') { |
80 | 83 | audioChunks.push({ |
81 | 84 | content: activity.value?.content, |
82 | | - voiceState: currentVoiceState |
| 85 | + voiceState: currentVoiceState, |
| 86 | + microphoneMuted: currentMicrophoneMuted |
83 | 87 | }); |
84 | 88 | } |
85 | 89 | return originalPostActivity(activity); |
|
103 | 107 |
|
104 | 108 | // Helper to get voice state from store |
105 | 109 | const getVoiceState = () => store.getState().voice?.voiceState; |
| 110 | + const getMicrophoneMuted = () => store.getState().voice?.microphoneMuted; |
106 | 111 |
|
107 | 112 | render( |
108 | 113 | <FluentThemeProvider variant="fluent"> |
|
119 | 124 | const micButton = document.querySelector(`[data-testid="${testIds.sendBoxMicrophoneButton}"]`); |
120 | 125 | expect(micButton).toBeTruthy(); |
121 | 126 |
|
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 ===== |
123 | 128 | expect(getVoiceState()).toBe('idle'); |
124 | 129 | expect(muteControlRef.muted).toBe(false); |
125 | 130 |
|
126 | 131 | muteControlRef.setMuted(true); |
127 | 132 | await new Promise(r => setTimeout(r, 100)); |
128 | 133 |
|
129 | 134 | expect(getVoiceState()).toBe('idle'); // Still idle, not muted |
130 | | - expect(muteControlRef.muted).toBe(false); |
| 135 | + expect(muteControlRef.muted).toBe(true); |
131 | 136 |
|
132 | | - // ===== TEST 2: Start recording → listening state ===== |
| 137 | + // ===== TEST 2: Start recording → listening state, microphoneMuted resets to false ===== |
133 | 138 | await host.click(micButton); |
134 | 139 |
|
135 | 140 | await pageConditions.became( |
|
138 | 143 | 2000 |
139 | 144 | ); |
140 | 145 |
|
| 146 | + // Starting recording resets microphoneMuted to false. |
| 147 | + // This ensures a clean slate - recording always starts unmuted. |
| 148 | + expect(muteControlRef.muted).toBe(false); |
| 149 | + |
141 | 150 | // Wait for some listening chunks |
142 | 151 | await pageConditions.became( |
143 | 152 | '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, |
145 | 154 | 2000 |
146 | 155 | ); |
147 | 156 |
|
148 | | - // ===== TEST 3: Mute from listening state → muted state ===== |
| 157 | + // ===== TEST 3: Mute while listening → microphoneMuted true, voiceState stays listening ===== |
149 | 158 | muteControlRef.setMuted(true); |
150 | 159 |
|
151 | 160 | await pageConditions.became( |
152 | | - 'Voice state is muted', |
153 | | - () => getVoiceState() === 'muted', |
| 161 | + 'microphoneMuted is true', |
| 162 | + () => getMicrophoneMuted() === true, |
154 | 163 | 1000 |
155 | 164 | ); |
156 | 165 |
|
157 | 166 | expect(muteControlRef.muted).toBe(true); |
| 167 | + expect(getVoiceState()).toBe('listening'); // voiceState stays listening |
158 | 168 |
|
159 | 169 | // Wait for muted chunks |
160 | 170 | await pageConditions.became( |
161 | 171 | 'At least 2 muted chunks received', |
162 | | - () => audioChunks.filter(c => c.voiceState === 'muted').length >= 2, |
| 172 | + () => audioChunks.filter(c => c.microphoneMuted).length >= 2, |
163 | 173 | 2000 |
164 | 174 | ); |
165 | 175 |
|
166 | 176 | // ===== 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); |
168 | 178 | expect(mutedChunks.length).toBeGreaterThanOrEqual(2); |
169 | 179 | for (const chunk of mutedChunks) { |
170 | 180 | expect(isAudioAllZeros(chunk.content)).toBe(true); |
171 | 181 | } |
172 | 182 |
|
173 | | - // ===== TEST 5: Unmute → back to listening state ===== |
| 183 | + // ===== TEST 5: Unmute → microphoneMuted false ===== |
174 | 184 | muteControlRef.setMuted(false); |
175 | 185 |
|
176 | 186 | await pageConditions.became( |
177 | | - 'Voice state is listening after unmute', |
178 | | - () => getVoiceState() === 'listening', |
| 187 | + 'microphoneMuted is false after unmute', |
| 188 | + () => getMicrophoneMuted() === false, |
179 | 189 | 1000 |
180 | 190 | ); |
181 | 191 |
|
|
190 | 200 | ); |
191 | 201 |
|
192 | 202 | // ===== 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); |
194 | 204 | expect(listeningChunks.length).toBeGreaterThanOrEqual(4); // At least 2 before mute + 2 after unmute |
195 | 205 |
|
196 | 206 | // Verify listening audio is non-zero (real audio) |
197 | 207 | for (const chunk of listeningChunks) { |
198 | | - expect(hasNonZeroAudio(chunk.content)).toBe(true); |
| 208 | + expect(isAudioAllZeros(chunk.content)).toBe(false); |
199 | 209 | } |
200 | 210 |
|
201 | | - // ===== TEST 7: Stop recording ===== |
| 211 | + // ===== TEST 7: Stop recording → microphoneMuted resets to false ===== |
202 | 212 | await host.click(micButton); |
203 | 213 |
|
204 | 214 | await pageConditions.became( |
|
207 | 217 | 2000 |
208 | 218 | ); |
209 | 219 |
|
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); |
211 | 265 | }); |
212 | 266 | </script> |
213 | 267 | </body> |
|
0 commit comments