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
5 changes: 5 additions & 0 deletions .changeset/stt-only-voice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@smooai/chat-widget': minor
---

Voice: speak-and-read mode + seamless voice→text continuity (SMOODEV-2674). A speaker toggle beside the mic lets visitors turn agent speech off — sessions start STT-only (`tts:false` on the browser-voice start frame; the server skips TTS entirely) while replies still arrive as chat bubbles. New `voice.tts` config sets the default. Starting voice now connects the text session first, so a voice-first visitor lands in a real conversation that continues seamlessly when they switch back to typing.
11 changes: 9 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ export interface ChatWidgetVoiceConfig {
enabled?: boolean;
/** Browser-voice WS endpoint. Defaults to the hosted SmooAI voice service. */
url?: string;
/**
* Whether the agent SPEAKS its replies (SMOODEV-2674). `false` starts voice
* sessions in STT-only mode: the visitor talks, the replies arrive as text
* only. The visitor can flip this per-session with the speaker toggle.
* Default `true`.
*/
tts?: boolean;
}

export interface ChatWidgetTheme {
Expand Down Expand Up @@ -175,7 +182,7 @@ export type ResolvedTheme = Required<Omit<ChatWidgetTheme, 'chatBubbleInbound' |

export type ResolvedConfig = Required<Omit<ChatWidgetConfig, 'theme' | 'userName' | 'userEmail' | 'userPhone' | 'authContext' | 'logoUrl' | 'voice'>> & {
theme: ResolvedTheme;
voice: { enabled: boolean; url: string };
voice: { enabled: boolean; url: string; tts: boolean };
userName?: string;
userEmail?: string;
userPhone?: string;
Expand Down Expand Up @@ -221,7 +228,7 @@ export function resolveConfig(config: ChatWidgetConfig): ResolvedConfig {
allowChatRestore: config.allowChatRestore ?? true,
allowAnonymous: config.allowAnonymous ?? false,
showToolActivity: config.showToolActivity ?? false,
voice: { enabled: config.voice?.enabled ?? false, url: config.voice?.url ?? DEFAULT_VOICE_URL },
voice: { enabled: config.voice?.enabled ?? false, url: config.voice?.url ?? DEFAULT_VOICE_URL, tts: config.voice?.tts ?? true },
theme: {
text: theme.text ?? '#f8fafc',
background: theme.background ?? '#040d30',
Expand Down
64 changes: 63 additions & 1 deletion src/element.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,17 +554,79 @@ describe('voice config gating (SMOODEV-2534)', () => {
disconnect: () => {},
};
await priv.startVoice();
const session = priv.voiceSession as unknown as { opts: { url?: string; agentId: string; conversationId?: string } } | null;
const session = priv.voiceSession as unknown as { opts: { url?: string; agentId: string; conversationId?: string; tts?: boolean } } | null;
expect(session).not.toBeNull();
expect(session!.opts.url).toBe('wss://voice.test/ws');
expect(session!.opts.agentId).toBe('a1');
// The text thread's conversation id rides along so voice resumes it.
expect(session!.opts.conversationId).toBe('conv-42');
// Agent speech defaults ON.
expect(session!.opts.tts).toBe(true);
const mic = sr.querySelector('.composer .mic') as HTMLButtonElement;
expect(mic.classList.contains('active')).toBe(true);
expect(mic.getAttribute('aria-pressed')).toBe('true');
expect(mic.getAttribute('aria-label')).toBe('Stop voice');
}

it('renders the agent-speech toggle; flipping it starts STT-only sessions (SMOODEV-2674)', async () => {
const origStart = VoiceSession.prototype.start;
VoiceSession.prototype.start = () => Promise.resolve();
try {
const el = mountCfg({ voice: { enabled: true, url: 'wss://voice.test/ws' } });
const sr = el.shadowRoot!;
const speech = sr.querySelector('.composer .speech') as HTMLButtonElement;
expect(speech).not.toBeNull();
expect(speech.getAttribute('aria-pressed')).toBe('true'); // speaks by default
speech.click();
expect(speech.getAttribute('aria-pressed')).toBe('false');
expect(speech.classList.contains('off')).toBe(true);
const priv = el as unknown as { startVoice: () => Promise<void>; voiceSession: { opts: { tts?: boolean } } | null; controller: unknown };
priv.controller = { currentConversationId: 'conv-42', appendLocalMessage: () => {}, connect: () => Promise.resolve(), disconnect: () => {} };
await priv.startVoice();
expect(priv.voiceSession!.opts.tts).toBe(false);
} finally {
VoiceSession.prototype.start = origStart;
}
});

it('voice.tts:false config starts sessions STT-only by default', () => {
const sr = mountCfg({ voice: { enabled: true, tts: false } }).shadowRoot!;
const speech = sr.querySelector('.composer .speech') as HTMLButtonElement;
expect(speech.getAttribute('aria-pressed')).toBe('false');
expect(speech.classList.contains('off')).toBe(true);
});

it('voice-first: startVoice connects the text session so voice joins a real thread (SMOODEV-2674)', async () => {
const origStart = VoiceSession.prototype.start;
VoiceSession.prototype.start = () => Promise.resolve();
try {
const el = mountCfg({ voice: { enabled: true, url: 'wss://voice.test/ws' } });
const priv = el as unknown as {
startVoice: () => Promise<void>;
voiceSession: { opts: { conversationId?: string } } | null;
controller: unknown;
};
// No conversation yet; connect() establishes one (the voice-first path).
let connected = false;
const stub = {
get currentConversationId() {
return connected ? 'conv-fresh' : null;
},
appendLocalMessage: () => {},
connect: () => {
connected = true;
return Promise.resolve();
},
disconnect: () => {},
};
priv.controller = stub;
await priv.startVoice();
expect(connected).toBe(true);
expect(priv.voiceSession!.opts.conversationId).toBe('conv-fresh');
} finally {
VoiceSession.prototype.start = origStart;
}
});
});

describe('Rich Interactions card registry', () => {
Expand Down
50 changes: 43 additions & 7 deletions src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ const ICON = {
tool: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.7 6.3a3.5 3.5 0 0 0-4.6 4.3l-5 5a1.6 1.6 0 0 0 2.3 2.3l5-5a3.5 3.5 0 0 0 4.3-4.6l-2 2-1.7-.3-.3-1.7 2-2Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>`,
/** Voice toggle — a microphone. */
mic: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="9" y="3.5" width="6" height="11" rx="3" stroke="currentColor" stroke-width="1.7"/><path d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v2.5M9 20.5h6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`,
/** Agent-speech toggle ON — a speaker with waves. */
speaker: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 9.5v5h3l4 3.5v-12l-4 3.5H5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M15.5 9.5a4 4 0 0 1 0 5M17.8 7.5a7 7 0 0 1 0 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
/** Agent-speech toggle OFF — a muted speaker. */
speakerOff: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 9.5v5h3l4 3.5v-12l-4 3.5H5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="m15.5 9.5 5 5M20.5 9.5l-5 5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
} as const;

/**
Expand Down Expand Up @@ -281,7 +285,10 @@ export class SmoothAgentChatElement extends HTMLElement {
/** True while the pre-chat identity gate is showing (blocks premature connect). */
private gating = false;
/** Voice config (SMOODEV-2534) — enabled=false renders zero voice UI. */
private voiceCfg: { enabled: boolean; url: string } = { enabled: false, url: '' };
private voiceCfg: { enabled: boolean; url: string; tts: boolean } = { enabled: false, url: '', tts: true };
/** Agent speech on/off (SMOODEV-2674) — applies at the next voice session start. */
private voiceTtsOn = true;
private speechBtn: HTMLButtonElement | null = null;
/** Live voice session, or null when voice is off. */
private voiceSession: VoiceSession | null = null;

Expand Down Expand Up @@ -538,8 +545,9 @@ export class SmoothAgentChatElement extends HTMLElement {
const footerHtml = footerInner ? `<div class="footer">${footerInner}</div>` : '';
// Voice (SMOODEV-2534): mic toggle in the composer, only when enabled.
this.voiceCfg = resolved.voice;
this.voiceTtsOn = resolved.voice.tts;
const micHtml = resolved.voice.enabled
? `<button class="mic" type="button" aria-label="Start voice" aria-pressed="false" title="Talk to ${escapeHtml(resolved.agentName)}">${ICON.mic}</button>`
? `<button class="speech${this.voiceTtsOn ? '' : ' off'}" type="button" aria-label="Agent voice" aria-pressed="${this.voiceTtsOn}" title="${this.voiceTtsOn ? 'Agent speaks replies — click for speak-and-read (no agent audio)' : 'Agent voice off — replies are text only. Click to hear the agent'}">${this.voiceTtsOn ? ICON.speaker : ICON.speakerOff}</button><button class="mic" type="button" aria-label="Start voice" aria-pressed="false" title="Talk to ${escapeHtml(resolved.agentName)}">${ICON.mic}</button>`
: '';
const chatHtml = `
<div class="messages"></div>
Expand Down Expand Up @@ -583,12 +591,14 @@ export class SmoothAgentChatElement extends HTMLElement {
this.inputEl = container.querySelector('textarea');
this.sendBtn = container.querySelector('.send');
this.micBtn = container.querySelector('.mic');
this.speechBtn = container.querySelector('.speech');
this.interruptEl = container.querySelector('.interrupt');

this.launcherEl?.addEventListener('click', () => this.openChat());
container.querySelector('.close')?.addEventListener('click', () => this.closeChat());
this.sendBtn?.addEventListener('click', () => this.submit());
this.micBtn?.addEventListener('click', () => this.toggleVoice());
this.speechBtn?.addEventListener('click', () => this.toggleAgentSpeech());
this.inputEl?.addEventListener('input', () => this.autosize());
this.inputEl?.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' && !ev.shiftKey) {
Expand Down Expand Up @@ -1573,18 +1583,44 @@ export class SmoothAgentChatElement extends HTMLElement {
void this.startVoice();
}

/**
* Flip agent speech on/off (SMOODEV-2674). Session-scoped: the choice is
* sent in the voice start frame (`tts:false` = STT-only, the server skips
* TTS entirely), so flipping mid-session takes effect on the NEXT session —
* the titles say so. Mid-session, turning speech off also cuts any audio
* that is currently playing.
*/
private toggleAgentSpeech(): void {
this.voiceTtsOn = !this.voiceTtsOn;
if (!this.voiceTtsOn && this.voiceSession?.isSpeaking) this.voiceSession.interrupt();
const btn = this.speechBtn;
if (!btn) return;
btn.classList.toggle('off', !this.voiceTtsOn);
btn.setAttribute('aria-pressed', String(this.voiceTtsOn));
btn.title = this.voiceTtsOn
? 'Agent speaks replies — click for speak-and-read (no agent audio)'
: this.voiceSession
? 'Agent voice off for the next session — replies stay text only'
: 'Agent voice off — replies are text only. Click to hear the agent';
btn.innerHTML = this.voiceTtsOn ? ICON.speaker : ICON.speakerOff; // static, trusted
}

private async startVoice(): Promise<void> {
if (!this.controller || this.voiceSession || !this.voiceCfg.enabled) return;
const config = this.readConfig();
if (!config) return;
// Best-effort: join the text thread when one exists. If the text session
// hasn't connected yet, voice starts a fresh thread (the frozen protocol
// never returns the voice-created conversation id, so it can't be adopted
// for later text turns — tracked as a follow-up on the server protocol).
// Voice and text share ONE conversation. If the text session hasn't
// connected yet (voice-first visitor), connect it now — voice turns then
// land in a REAL conversation the text thread already owns, so leaving
// voice continues seamlessly in text (SMOODEV-2674). connect() is
// idempotent; on failure voice still starts (server mints a thread).
if (!this.controller.currentConversationId) {
await this.controller.connect().catch(() => {});
}
const conversationId = this.controller.currentConversationId ?? undefined;
const controller = this.controller;
const session = new VoiceSession(
{ url: this.voiceCfg.url, agentId: config.agentId, conversationId },
{ url: this.voiceCfg.url, agentId: config.agentId, conversationId, tts: this.voiceTtsOn },
{
onTranscriptPartial: (text) => {
// Live partial transcript in the input area while listening.
Expand Down
12 changes: 7 additions & 5 deletions src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,8 +555,9 @@ ${
.send:active { transform: scale(.94); }
.send:disabled { opacity: .4; cursor: default; transform: none; box-shadow: none; }

/* Voice mic toggle (SMOODEV-2534) — ghost twin of .send; lights up while live. */
.mic {
/* Voice mic toggle (SMOODEV-2534) — ghost twin of .send; lights up while live.
.speech (SMOODEV-2674) is the agent-speech on/off twin next to it. */
.mic, .speech {
width: 38px; height: 38px;
flex: none;
border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
Expand All @@ -569,9 +570,10 @@ ${
color: color-mix(in srgb, var(--sac-text) 65%, transparent);
transition: transform .2s var(--sac-ease), color .2s ease, background .25s ease, box-shadow .25s ease;
}
.mic svg { width: 18px; height: 18px; }
.mic:hover { color: var(--sac-text); transform: translateY(-1px) scale(1.05); }
.mic:active { transform: scale(.94); }
.mic svg, .speech svg { width: 18px; height: 18px; }
.mic:hover, .speech:hover { color: var(--sac-text); transform: translateY(-1px) scale(1.05); }
.mic:active, .speech:active { transform: scale(.94); }
.speech.off { opacity: .55; }
.mic.active {
border-color: transparent;
background: linear-gradient(150deg, var(--sac-primary), var(--sac-primary-2));
Expand Down
14 changes: 13 additions & 1 deletion src/voice-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ class MockPlayer implements VoicePlayer {

/** Build a session wired to mocks. `harness.mic(samples, rate)` injects a mic frame. */
function makeSession(
opts: { conversationId?: string; token?: string; bargeInThreshold?: number } = {},
opts: { conversationId?: string; token?: string; bargeInThreshold?: number; tts?: boolean } = {},
events: VoiceSessionEvents = {},
): { session: VoiceSession; ws: MockVoiceSocket; player: MockPlayer; mic: (samples: Float32Array, rate: number) => void; captureStopped: () => boolean } {
const ws = new MockVoiceSocket();
Expand Down Expand Up @@ -148,6 +148,18 @@ describe('VoiceSession protocol framing', () => {
expect(ws.jsonFrames()[0]).toEqual({ type: 'start', agent_id: 'agent-123', conversation_id: 'conv-9', token: 'jwt-abc' });
});

it('sends tts:false in the start frame for STT-only sessions (SMOODEV-2674)', async () => {
const { session, ws } = makeSession({ tts: false });
await session.start();
ws.open();
expect(ws.jsonFrames()[0]).toEqual({ type: 'start', agent_id: 'agent-123', tts: false });
// Default (tts unset/true) omits the field — frozen-protocol compatible.
const { session: s2, ws: ws2 } = makeSession({ tts: true });
await s2.start();
ws2.open();
expect(ws2.jsonFrames()[0]).toEqual({ type: 'start', agent_id: 'agent-123' });
});

it('streams mic frames as 16 kHz Int16 binary after the start frame', async () => {
const { session, ws, mic } = makeSession();
await session.start();
Expand Down
7 changes: 7 additions & 0 deletions src/voice-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,12 @@ export interface VoiceSessionOptions {
conversationId?: string;
/** Optional JWT for authenticated contexts (public agents auth by Origin). */
token?: string;
/**
* Agent speech (SMOODEV-2674). `false` = STT-only: the visitor speaks and
* READS the replies — the server skips TTS entirely (no audio frames, no
* speaking events) and only streams `reply_text`. Default `true`.
*/
tts?: boolean;
/**
* RMS level (0..1) above which a mic frame counts as speech for barge-in
* while the agent is speaking. Default 0.02 — comfortably above room noise
Expand Down Expand Up @@ -392,6 +398,7 @@ export class VoiceSession {
const start: Record<string, unknown> = { type: 'start', agent_id: this.opts.agentId };
if (this.opts.conversationId) start.conversation_id = this.opts.conversationId;
if (this.opts.token) start.token = this.opts.token;
if (this.opts.tts === false) start.tts = false;
ws.send(JSON.stringify(start));
this.state = 'active';
});
Expand Down
Loading