feat(voice): add voice input/output support with multiple providers (#281)

* feat(voice): add voice input/output support with multiple providers

- Add BrowserVoiceButton component for Web Speech API voice input
- Add VoiceProvider context for managing voice state across the app
- Add TTS (Text-to-Speech) support with browser, macOS Say, and OpenAI providers
- Add message TTS buttons to read assistant messages aloud
- Add VoiceSettings page in OpenChamber settings
- Add server endpoints for TTS and summarization services
- Include slider component for voice rate/pitch/volume controls
- Add hidden session support for background voice operations
- Add Caddyfile for HTTPS support (required for microphone access)

* fix: Build errors fixed and removed outdated ElevenLabs test code.

* refactor(voice): use zen API with gpt-5-nano for TTS summarization

Replace the hidden session + OpenCode SDK approach with direct calls
to the opencode.ai zen API (same pattern used for commit message and
PR description generation).

- Rewrite summarization-service.js to call zen/v1/responses with gpt-5-nano
- Remove hidden session logic (hiddenSession.ts, sessionStore filtering)
- Remove summarizeModel setting and model selector from VoiceSettings
- Simplify client-side summarize.ts to no longer pass model params
- Clean up callers in useMessageTTS and useBrowserVoice

* fix(voice): remove false 'voice not supported' warning in settings

Mobile Safari does support voice but the isSupported check was
incorrectly flagging it. Remove the warning banner entirely.

* feat(voice): add configurable summary length limit for TTS output

Add a slider (50-2000 chars) in voice settings to control max summary
length. The limit is passed through the summarize endpoint and speak
endpoint to the zen API prompt, with token budget scaled accordingly.

* fix(voice): add diagnostic logging and sanitize TTS fallback

Add console logging throughout the summarization flow (client + server)
to trace why text may not be summarized. Fix silent error swallowing in
/api/tts/speak. Always apply sanitizeForTTS even when summarization is
disabled so raw markdown/code is never spoken verbatim.

* fix(voice): fix token budget starving model of output tokens

max_output_tokens includes both reasoning and output tokens. With
effort:'low', reasoning alone consumes ~128 tokens, so a budget of
100 left zero tokens for the actual summary text. Use a fixed 1000
token budget (matching commit message generation) and control output
length via the prompt's character limit instruction instead.

* chore(voice): remove diagnostic logging from summarization flow

* fix(voice): don't request mic permission on mobile page load

Remove the useEffect that pre-requested microphone permission when the
BrowserVoiceButton component mounted on mobile. This caused an unwanted
permission prompt immediately on page load before the user tapped the
mic icon. Permission is now only requested on explicit user interaction.

* fix(voice): remove unused BrowserVoiceButton binding

* fix(voice): desktop mic flow + non-continuous draft mode

* fix(voice): stabilize continuous loop and polish controls

* feat(settings): mark voice section experimental

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
gsxdsm
2026-02-09 23:55:10 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 6776ac31c2
commit 1ed5316ac7
40 changed files with 5518 additions and 736 deletions
+12 -11
View File
@@ -8,6 +8,7 @@ import {
RiFileUploadLine,
RiSendPlane2Line,
} from '@remixicon/react';
import { BrowserVoiceButton } from '@/components/voice';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
@@ -1438,6 +1439,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const iconSizeClass = isMobile ? 'h-[18px] w-[18px]' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]');
const iconButtonBaseClass = 'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0';
const footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass);
// Send button - respects queue mode setting
const sendButton = (
@@ -1472,8 +1474,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
handlePrimaryAction();
}}
className={cn(
iconButtonBaseClass,
buttonSizeClass,
footerIconButtonClass,
canSend && (currentSessionId || newSessionDraftOpen)
? 'text-primary hover:text-primary'
: 'opacity-30'
@@ -1514,8 +1515,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
handleQueueMessage();
}}
className={cn(
iconButtonBaseClass,
buttonSizeClass,
footerIconButtonClass,
'absolute bottom-full left-1/2 -translate-x-1/2 mb-1',
hasContent && currentSessionId
? 'text-primary hover:text-primary'
@@ -1533,8 +1533,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
type="button"
onClick={handleAbort}
className={cn(
iconButtonBaseClass,
buttonSizeClass,
footerIconButtonClass,
'text-[var(--status-error)] hover:text-[var(--status-error)]'
)}
aria-label="Stop generating"
@@ -1586,7 +1585,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(iconButtonBaseClass, isMobile && 'h-7 w-7')}
className={footerIconButtonClass}
title="Add attachment"
aria-label="Add attachment"
>
@@ -1622,7 +1621,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
<button
type='button'
onClick={onOpenSettings}
className={cn(iconButtonBaseClass, isMobile && 'h-7 w-7')}
className={footerIconButtonClass}
title='Model and agent settings'
aria-label='Model and agent settings'
>
@@ -1631,13 +1630,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
) : null;
const attachmentsControls = (
<div className="flex items-center gap-x-1">
<div className="flex items-center gap-x-1.5">
{isMobile ? (
<button
type="button"
className={cn(
iconButtonBaseClass,
'h-7 w-7 rounded-md text-muted-foreground',
footerIconButtonClass,
'rounded-md text-muted-foreground',
'hover:bg-interactive-hover/40 hover:text-foreground'
)}
onPointerDownCapture={(event) => {
@@ -1873,6 +1872,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
<MobileAgentButton onCycleAgent={handleCycleAgent} onOpenAgentPanel={() => setMobileControlsPanel('agent')} className="min-w-0 flex-shrink" />
</div>
<div className="flex items-center gap-x-1 flex-shrink-0">
<BrowserVoiceButton />
{actionButtons}
</div>
</div>
@@ -1898,6 +1898,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
</div>
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
<ModelControls className={cn('flex-1 min-w-0 justify-end')} />
<BrowserVoiceButton />
{actionButtons}
</div>
</>
@@ -14,7 +14,7 @@ import { isEmptyTextPart, extractTextContent } from './partUtils';
import { FadeInOnReveal } from './FadeInOnReveal';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine } from '@remixicon/react';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiVolumeUpLine, RiStopLine } from '@remixicon/react';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
@@ -24,6 +24,8 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
import { useMessageTTS } from '@/hooks/useMessageTTS';
import { useConfigStore } from '@/stores/useConfigStore';
import { TextSelectionMenu } from './TextSelectionMenu';
const formatTurnDuration = (durationMs: number): string => {
@@ -317,6 +319,19 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
const hasStopFinish = messageFinish === 'stop';
// TTS for message playback
const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS();
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
const voiceProvider = useConfigStore((state) => state.voiceProvider);
const readAloudTooltip = React.useMemo(() => {
if (isTTSPlaying) {
return 'Stop speaking';
}
const providerLabel = voiceProvider === 'browser' ? 'Browser' : voiceProvider === 'openai' ? 'OpenAI' : 'Say';
return `Read aloud (${providerLabel} voice)`;
}, [isTTSPlaying, voiceProvider]);
const hasTools = toolParts.length > 0;
@@ -503,6 +518,24 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
[assistantTextParts, openMultiRunLauncherWithPrompt]
);
const handleTTSClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
if (isTTSPlaying) {
stopTTS();
return;
}
const messageText = flattenAssistantTextParts(assistantTextParts);
if (messageText.trim()) {
void playTTS(messageText);
}
},
[assistantTextParts, isTTSPlaying, playTTS, stopTTS]
);
React.useEffect(() => {
return () => {
clearCopyHintTimeout();
@@ -882,6 +915,31 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<TooltipContent sideOffset={6}>Start new multi-run from this answer</TooltipContent>
</Tooltip>
{showMessageTTSButtons && hasCopyableText && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'h-8 w-8 bg-transparent hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
isTTSPlaying ? 'text-green-500' : 'text-muted-foreground hover:text-foreground'
)}
aria-label={isTTSPlaying ? 'Stop speaking' : 'Read aloud'}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleTTSClick}
>
{isTTSPlaying ? (
<RiStopLine className="h-3.5 w-3.5" />
) : (
<RiVolumeUpLine className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{readAloudTooltip}</TooltipContent>
</Tooltip>
)}
{onCopyMessage && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>