Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d132592
initial no-op s2s core implementation
pranavjoshi001 Dec 12, 2025
a982457
minor
pranavjoshi001 Dec 12, 2025
0978e7d
Merge branch 'main' into feature/core-s2s-composer
pranavjoshi001 Dec 12, 2025
08c7a76
Merge branch 'main' into feature/core-s2s-composer
pranavjoshi001 Dec 17, 2025
27a1cb4
Merge branch 'main' into feature/core-s2s-composer
pranavjoshi001 Jan 7, 2026
6437ee1
Merge branch 'feature/core-s2s-composer' of https://github.com/pranav…
pranavjoshi001 Jan 7, 2026
9ddc63c
refactor to align close to activity structure
pranavjoshi001 Jan 7, 2026
0838e44
refactor composer to not use direct state inside effect
pranavjoshi001 Jan 8, 2026
4036a03
Merge branch 'main' into feature/core-s2s-composer
pranavjoshi001 Jan 13, 2026
9be0bcb
Merge branch 'main' into feature/core-s2s-composer
pranavjoshi001 Jan 14, 2026
a3b2c8b
more implementation chunk
pranavjoshi001 Jan 14, 2026
e31a8f7
minor refactor
pranavjoshi001 Jan 15, 2026
cf9d2f5
Mic Implementation and animation in fluent theme
Jan 15, 2026
af1dd65
test case added
pranavjoshi001 Jan 15, 2026
ce9f6c5
Merge branch 'main' into feature/core-s2s-composer
pranavjoshi001 Jan 16, 2026
8fac1b3
screenshot added
pranavjoshi001 Jan 16, 2026
e01130a
Merge branch 'feature/core-s2s-composer' of https://github.com/pranav…
pranavjoshi001 Jan 16, 2026
0dcbd63
refactor
pranavjoshi001 Jan 20, 2026
a1e7790
increase sec to capture more outgoing event in test file
pranavjoshi001 Jan 20, 2026
887fcf6
changelog updated
pranavjoshi001 Jan 20, 2026
1bad68e
Merge branch 'main' into feature/core-s2s-composer
pranavjoshi001 Jan 22, 2026
f2f3da9
refactor code as per code review
pranavjoshi001 Jan 22, 2026
dc6c490
Merge branch 'feature/core-s2s-composer' of https://github.com/pranav…
pranavjoshi001 Jan 22, 2026
9370012
remove not needed files
pranavjoshi001 Jan 22, 2026
1a90b20
test case updated
pranavjoshi001 Jan 22, 2026
aceca00
refactor as per comment
pranavjoshi001 Jan 23, 2026
37b9779
Merge branch 'main' into feature/core-s2s-composer
pranavjoshi001 Jan 23, 2026
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 @@ -149,6 +149,7 @@ Breaking changes in this release:
- Breakpoint: open <kbd>F12</kbd>, select the subject in Element pane, type `$0.webChat.breakpoint.incomingActivity`
- The `botframework-webchat` package now uses CSS modules for styling purposes, in PR [#5666](https://github.com/microsoft/BotFramework-WebChat/pull/5666), in PR [#5677](https://github.com/microsoft/BotFramework-WebChat/pull/5677) by [@OEvgeny](https://github.com/OEvgeny)
- 👷🏻 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 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)

### Changed

Expand Down
23 changes: 23 additions & 0 deletions __tests__/assets/esm/speechToSpeech/mockAudioPlayback.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/* global AudioContext */

/**
* Mocks AudioContext.createBuffer to return buffers with minimum duration.
*
*/
export function setupMockAudioPlayback() {
const originalCreateBuffer = AudioContext.prototype.createBuffer;

AudioContext.prototype.createBuffer = function (numberOfChannels, length, sampleRate) {
// Ensure minimum duration of 0.5 seconds for testing
const minSamples = Math.floor(sampleRate * 0.5);
const actualLength = Math.max(length, minSamples);

return originalCreateBuffer.call(this, numberOfChannels, actualLength, sampleRate);
};

return {
restore: () => {
AudioContext.prototype.createBuffer = originalCreateBuffer;
}
};
}
32 changes: 32 additions & 0 deletions __tests__/assets/esm/speechToSpeech/mockMediaDevices.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/* global AudioContext, navigator */

/**
* Mocks navigator.mediaDevices.getUserMedia for testing speechToSpeech functionality.
*/
export function setupMockMediaDevices() {
if (!navigator.mediaDevices) {
navigator.mediaDevices = {};
}

navigator.mediaDevices.getUserMedia = constraints => {
const audioContext = new AudioContext({ sampleRate: constraints?.audio?.sampleRate || 24000 });
const oscillator = audioContext.createOscillator();
const destination = audioContext.createMediaStreamDestination();

oscillator.connect(destination);
oscillator.start();

const { stream } = destination;

stream.getTracks().forEach(track => {
const originalStop = track.stop.bind(track);
track.stop = () => {
oscillator.stop();
audioContext.close();
originalStop();
};
});

return stream;
};
}
190 changes: 190 additions & 0 deletions __tests__/html2/speechToSpeech/barge.in.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<!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: Barge-in scenario with full state cycle

Flow:
1. User starts recording → "Listening..."
2. Bot sends audio chunks → "Talk to interrupt..." (bot speaking)
3. User barges in (server detects) → "Listening..." (user speaking)
4. Server processes → "Processing..."
5. Bot responds with new audio → "Talk to interrupt..." (bot speaking again)
6. User toggles mic off
-->
<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,
ReactDOM: { render },
WebChat: { FluentThemeProvider, ReactWebChat, testIds }
} = window;

const { directLine, store } = testHelpers.createDirectLineEmulator();

render(
<FluentThemeProvider variant="fluent">
<ReactWebChat
directLine={directLine}
store={store}
styleOptions={{
showMicrophoneButton: true
}}
/>
</FluentThemeProvider>,
document.getElementById('webchat')
);

await pageConditions.uiConnected();

const micButton = document.querySelector(`[data-testid="${testIds.sendBoxMicrophoneButton}"]`);
const textArea = document.querySelector(`[data-testid="${testIds.sendBoxTextBox}"]`);
expect(micButton).toBeTruthy();
expect(textArea).toBeTruthy();

// Start recording
await host.click(micButton);

await pageConditions.became(
'Recording started',
() => micButton.getAttribute('aria-label')?.includes('Microphone on'),
1000
);

// VERIFY: State is "listening"
await pageConditions.became(
'State: listening → Placeholder: "Listening..."',
() => textArea.getAttribute('placeholder') === 'Listening...',
2000
);

// Bot starts speaking (sends audio chunks)
await directLine.emulateIncomingVoiceActivity({
type: 'event',
name: 'stream.chunk',
from: { role: 'bot' },
payload: { voice: { content: 'AAAAAA==' } }
});

await directLine.emulateIncomingVoiceActivity({
type: 'event',
name: 'stream.chunk',
from: { role: 'bot' },
payload: { voice: { content: 'AAAAAA==' } }
});

// VERIFY: State is "bot_speaking" (isPlaying = true)
await pageConditions.became(
'State: bot_speaking → Placeholder: "Talk to interrupt..."',
() => textArea.getAttribute('placeholder') === 'Talk to interrupt...',
1000
);

// VERIFY: Mic button has pulse animation during bot speaking
expect(micButton.className).toMatch(/with-pulse/);

// User barges in (server detects user speech)
await directLine.emulateIncomingVoiceActivity({
type: 'event',
name: 'session.update',
from: { role: 'bot' },
payload: { voice: { session: 'request.detected' } }
});

// VERIFY: State changes to "user_speaking" - bot audio stopped
await pageConditions.became(
'State: user_speaking → Placeholder: "Listening…" (barge-in worked)',
() => textArea.getAttribute('placeholder') === 'Listening...',
1000
);

// VERIFY: Mic button still has pulse animation during user speaking
expect(micButton.className).toMatch(/with-pulse/);

// Server processes the user's interrupted request
await directLine.emulateIncomingVoiceActivity({
type: 'event',
name: 'session.update',
from: { role: 'bot' },
payload: { voice: { session: 'request.processing' } }
});

// VERIFY: State is "processing"
await pageConditions.became(
'State: processing → Placeholder: "Processing…"',
() => textArea.getAttribute('placeholder') === 'Processing...',
1000
);

// User transcript arrives
await directLine.emulateIncomingActivity({
type: 'event',
name: 'stream.end',
from: { role: 'bot' },
text: 'Stop! Change my destination.',
payload: { voice: { transcription: 'Stop! Change my destination.', origin: 'user' } }
});

await pageConditions.numActivitiesShown(1);

// Bot responds with new audio
await directLine.emulateIncomingVoiceActivity({
type: 'event',
name: 'stream.chunk',
from: { role: 'bot' },
payload: { voice: { content: 'AAAAAA==' } }
});

// VERIFY: State is "bot_speaking" again
await pageConditions.became(
'State: bot_speaking → Placeholder: "Talk to interrupt..." (bot responding)',
() => textArea.getAttribute('placeholder') === 'Talk to interrupt...',
1000
);

// Bot transcript arrives
await directLine.emulateIncomingActivity({
type: 'event',
name: 'stream.end',
from: { role: 'bot' },
text: 'Sure, where would you like to go instead?',
payload: { voice: { transcription: 'Sure, where would you like to go instead?', origin: 'agent' } }
});

await pageConditions.numActivitiesShown(2);

// Verify both messages appear
const activities = pageElements.activityContents();
expect(activities[0]).toHaveProperty('textContent', 'Stop! Change my destination.');
expect(activities[1]).toHaveProperty('textContent', 'Sure, where would you like to go instead?');

// Toggle mic off
await host.click(micButton);

await pageConditions.became(
'Recording stopped',
() => micButton.getAttribute('aria-label')?.includes('Microphone off'),
1000
);
});
</script>
</body>
</html>
64 changes: 64 additions & 0 deletions __tests__/html2/speechToSpeech/basic.sendbox.with.mic.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<!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>
<script type="text/babel">
run(async function () {
const {
React,
ReactDOM: { render },
WebChat: { FluentThemeProvider, ReactWebChat, testIds }
} = window;

// GIVEN: Web Chat with Fluent Theme and microphone button enabled
const { directLine, store } = testHelpers.createDirectLineEmulator();

render(
<FluentThemeProvider variant="fluent">
<ReactWebChat
directLine={directLine}
store={store}
styleOptions={{
disableFileUpload: true,
showMicrophoneButton: true,
hideTelephoneKeypadButton: false,
}}
/>
</FluentThemeProvider>,
document.getElementById('webchat')
);

await pageConditions.uiConnected();

// THEN: Microphone button should be present
const micButton = document.querySelector(`[data-testid="${testIds.sendBoxMicrophoneButton}"]`);
expect(micButton).toBeTruthy();

// THEN: Telephone keypad button should be present
const keypadButton = document.querySelector(`[data-testid="${testIds.sendBoxTelephoneKeypadToolbarButton}"]`);
expect(keypadButton).toBeTruthy();

// THEN: Text counter should NOT be present
const textCounter = document.querySelector('.sendbox__text-counter');
expect(textCounter).toBeFalsy();

// THEN: Send button should NOT be present
const sendButton = document.querySelector(`[data-testid="${testIds.sendBoxSendButton}"]`);
expect(sendButton).toBeFalsy();

// THEN: Should show sendbox with microphone and keypad buttons
await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading