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/voice-input-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@smooai/chat-widget': minor
---

Voice input/output (SMOODEV-2534): mic capture + TTS playback over the browser-voice WebSocket, shipped dark behind the new `voice: { enabled, url? }` config option (OFF by default — zero UI when off). Adds `VoiceSession` (framework-free WS lifecycle, getUserMedia → AudioWorklet/ScriptProcessor → 16 kHz linear16 mic streaming, gapless PCM playback, RMS barge-in with `interrupt` + playback flush), an Aurora Glass mic toggle in the composer with listening/speaking indicators and a live partial transcript in the input, and voice turns (`transcript_final` / `reply_text`) rendered through the normal chat message path. The text session's conversation id is passed as `conversation_id` so voice resumes the same thread.
19 changes: 18 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@
* {@link mountChatWidget} / `element.configure(...)`).
*/
import { safeHttpUrl } from './markdown.js';
import { DEFAULT_VOICE_URL } from './voice-session.js';

/**
* Browser voice input/output (SMOODEV-2534). OFF by default — when disabled the
* widget renders zero voice UI. When enabled, a mic toggle appears in the
* composer and speech flows over the browser-voice WebSocket.
*/
export interface ChatWidgetVoiceConfig {
/** Turn the voice feature on. Default `false`. */
enabled?: boolean;
/** Browser-voice WS endpoint. Defaults to the hosted SmooAI voice service. */
url?: string;
}

export interface ChatWidgetTheme {
/** Foreground text color for the widget chrome. */
Expand Down Expand Up @@ -151,15 +164,18 @@ export interface ChatWidgetConfig {
* surfaces where seeing what the agent did mid-turn is valuable.
*/
showToolActivity?: boolean;
/** Browser voice input/output. OFF by default (zero UI when off). */
voice?: ChatWidgetVoiceConfig;
/** Theme overrides. */
theme?: ChatWidgetTheme;
}

/** The fully-resolved theme (canonical keys only — aliases are folded in). */
export type ResolvedTheme = Required<Omit<ChatWidgetTheme, 'chatBubbleInbound' | 'chatBubbleInboundText' | 'chatBubbleOutbound' | 'chatBubbleOutboundText'>>;

export type ResolvedConfig = Required<Omit<ChatWidgetConfig, 'theme' | 'userName' | 'userEmail' | 'userPhone' | 'authContext' | 'logoUrl'>> & {
export type ResolvedConfig = Required<Omit<ChatWidgetConfig, 'theme' | 'userName' | 'userEmail' | 'userPhone' | 'authContext' | 'logoUrl' | 'voice'>> & {
theme: ResolvedTheme;
voice: { enabled: boolean; url: string };
userName?: string;
userEmail?: string;
userPhone?: string;
Expand Down Expand Up @@ -205,6 +221,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 },
theme: {
text: theme.text ?? '#f8fafc',
background: theme.background ?? '#040d30',
Expand Down
24 changes: 23 additions & 1 deletion src/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,8 @@ export class ConversationController {
private readonly store: StoreApi<WidgetStore>;
private client: SmoothAgentClient | null = null;
private sessionId: string | null = null;
/** Conversation id of the live session (create or resume) — lets voice join the same thread. */
private conversationId: string | null = null;
private readonly messages: ChatMessage[] = [];
private status: ConnectionStatus = 'idle';
private seq = 0;
Expand Down Expand Up @@ -384,6 +386,23 @@ export class ConversationController {
return this.status;
}

/** Conversation id of the live session, or null before connect (voice passes this as `conversation_id`). */
get currentConversationId(): string | null {
return this.conversationId;
}

/**
* Append an already-finalized message to the transcript and emit — the voice
* path reuses this so `transcript_final` (user) and `reply_text` (assistant)
* turns land in the same message list / render pipeline as typed chat.
*/
appendLocalMessage(role: Role, text: string): void {
const trimmed = text.trim();
if (!trimmed) return;
this.messages.push({ id: this.nextId(role === 'user' ? 'u' : 'a'), role, text: trimmed, streaming: false });
this.emitMessages();
}

/** The persisted store, exposed so the view can read identity for the pre-chat gate. */
getStore(): StoreApi<WidgetStore> {
return this.store;
Expand Down Expand Up @@ -690,6 +709,7 @@ export class ConversationController {
...(metadata ? { metadata } : {}),
});
this.sessionId = session.sessionId;
this.conversationId = session.conversationId ?? null;
this.store.getState().setSessionId(session.sessionId);
}

Expand All @@ -699,7 +719,7 @@ export class ConversationController {
*/
private async tryResume(sessionId: string): Promise<boolean> {
if (!this.client) return false;
let snap: { status?: 'active' | 'idle' | 'ended' };
let snap: { status?: 'active' | 'idle' | 'ended'; conversationId?: string };
try {
snap = await this.client.getSession({ sessionId });
} catch {
Expand All @@ -708,6 +728,7 @@ export class ConversationController {
if (snap.status === 'ended') return false;

this.sessionId = sessionId;
this.conversationId = snap.conversationId ?? null;
await this.hydrateHistory(sessionId);
return true;
}
Expand Down Expand Up @@ -1052,6 +1073,7 @@ export class ConversationController {
this.client?.disconnect('widget closed');
this.client = null;
this.sessionId = null;
this.conversationId = null;
this.activeRequestId = null;
// A full teardown ends the controller lifecycle: a subsequent connect() is a
// genuine re-open and may resume again, so re-arm the resume probe.
Expand Down
77 changes: 77 additions & 0 deletions src/element.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from 'vitest';
import { defineChatWidget, ELEMENT_TAG, INTERACTION_CARDS, safeHttpUrl } from './element.js';
import { type Interrupt, SUPPORTED_INTERACTION_CAPABILITIES } from './conversation.js';
import { VoiceSession } from './voice-session.js';

describe('safeHttpUrl', () => {
it('accepts absolute http(s) URLs and returns the normalized href', () => {
Expand Down Expand Up @@ -470,6 +471,82 @@ describe('fullpage container sizing (composer-clip regression)', () => {
});
});

describe('voice config gating (SMOODEV-2534)', () => {
afterEach(() => {
document.body.innerHTML = '';
});

function mountCfg(cfg: Record<string, unknown>): HTMLElement {
defineChatWidget();
const el = document.createElement(ELEMENT_TAG) as HTMLElement & { configure: (c: Record<string, unknown>) => void };
el.setAttribute('endpoint', 'wss://e/ws');
el.setAttribute('agent-id', 'a1');
el.configure(cfg);
document.body.appendChild(el);
return el;
}

it('renders NO mic button by default (voice OFF = zero voice UI)', () => {
const sr = mountCfg({}).shadowRoot!;
expect(sr.querySelector('.composer .send')).not.toBeNull();
expect(sr.querySelector('.composer .mic')).toBeNull();
});

it('renders no mic button when voice is explicitly disabled', () => {
const sr = mountCfg({ voice: { enabled: false } }).shadowRoot!;
expect(sr.querySelector('.composer .mic')).toBeNull();
});

it('renders the mic toggle in the composer when voice is enabled', () => {
const sr = mountCfg({ voice: { enabled: true } }).shadowRoot!;
const mic = sr.querySelector('.composer .mic') as HTMLButtonElement | null;
expect(mic).not.toBeNull();
expect(mic?.getAttribute('aria-pressed')).toBe('false');
expect(mic?.getAttribute('aria-label')).toBe('Start voice');
expect(mic?.querySelector('svg')).not.toBeNull();
});

it('starting voice passes the configured url + agentId and lights the button', async () => {
// No real audio/WS in jsdom — stub the session's start; the element's
// wiring (options threaded, UI state) is what's under test here.
const origStart = VoiceSession.prototype.start;
VoiceSession.prototype.start = () => Promise.resolve();
try {
await runStartVoiceScenario();
} finally {
VoiceSession.prototype.start = origStart;
}
});

async function runStartVoiceScenario(): Promise<void> {
const el = mountCfg({ voice: { enabled: true, url: 'wss://voice.test/ws' } });
const sr = el.shadowRoot!;
const priv = el as unknown as {
startVoice: () => Promise<void>;
voiceSession: { stop: () => void } | null;
controller: unknown;
};
// Stub the controller seam (same pattern as the other element tests).
priv.controller = {
currentConversationId: 'conv-42',
appendLocalMessage: () => {},
connect: () => Promise.resolve(),
disconnect: () => {},
};
await priv.startVoice();
const session = priv.voiceSession as unknown as { opts: { url?: string; agentId: string; conversationId?: string } } | 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');
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');
}
});

describe('Rich Interactions card registry', () => {
afterEach(() => {
document.body.innerHTML = '';
Expand Down
Loading
Loading