Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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: 3 additions & 2 deletions src/components/chat/view/ChatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useChatProviderState } from '../hooks/useChatProviderState';
import { useChatSessionState } from '../hooks/useChatSessionState';
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
import { useChatComposerState } from '../hooks/useChatComposerState';
import { TtsProvider, useTts } from '../../../contexts/TtsContext';
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';

Expand Down Expand Up @@ -272,7 +273,7 @@ function ChatInterface({
}

return (
<>
<TtsProvider chatMessages={chatMessages}>
<div className="flex h-full flex-col">
<ChatMessagesPane
scrollContainerRef={scrollContainerRef}
Expand Down Expand Up @@ -392,7 +393,7 @@ function ChatInterface({
</div>

<QuickSettingsPanel />
</>
</TtsProvider>
);
}

Expand Down
28 changes: 28 additions & 0 deletions src/components/chat/view/subcomponents/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import type {
SetStateAction,
TouchEvent,
} from 'react';
import { Volume2, VolumeX, StopCircle } from 'lucide-react';
import MicButton from '../../../mic-button/view/MicButton';
import { useTts } from '../../../../contexts/TtsContext';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
import CommandMenu from './CommandMenu';
import ClaudeStatus from './ClaudeStatus';
Expand Down Expand Up @@ -150,7 +152,9 @@ export default function ChatComposer({
sendByCtrlEnter,
onTranscript,
}: ChatComposerProps) {
const tts = useTts();
const { t } = useTranslation('chat');
const { t: tSettings } = useTranslation('settings');
const textareaRect = textareaRef.current?.getBoundingClientRect();
const commandMenuPosition = {
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
Expand Down Expand Up @@ -326,6 +330,30 @@ export default function ChatComposer({
<MicButton onTranscript={onTranscript} className="h-10 w-10 sm:h-10 sm:w-10" />
</div>

{tts && tts.availableVoices.length > 0 && (
<button
type="button"
onClick={tts.isSpeaking ? tts.stop : tts.toggle}
className={`absolute right-14 top-1/2 -translate-y-1/2 transform rounded-xl p-2 transition-colors sm:right-[60px] ${
tts.enabled
? tts.isSpeaking
? 'bg-primary/20 text-primary'
: 'text-primary hover:bg-accent/60'
: 'text-muted-foreground hover:bg-accent/60'
}`}
aria-label={tts.isSpeaking ? tSettings('quickSettings.tts.button.stopSpeaking') : tts.enabled ? tSettings('quickSettings.tts.button.ttsOn') : tSettings('quickSettings.tts.button.ttsOff')}
title={tts.isSpeaking ? tSettings('quickSettings.tts.button.stopSpeaking') : tts.enabled ? tSettings('quickSettings.tts.button.ttsOn') : tSettings('quickSettings.tts.button.ttsOff')}
>
{tts.isSpeaking ? (
<StopCircle className="h-5 w-5" />
) : tts.enabled ? (
<Volume2 className="h-5 w-5" />
) : (
<VolumeX className="h-5 w-5" />
)}
</button>
)}

<button
type="submit"
disabled={!input.trim() || isLoading}
Expand Down
30 changes: 30 additions & 0 deletions src/components/quick-settings-panel/view/QuickSettingsContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,35 @@ import type {
} from '../types';
import QuickSettingsSection from './QuickSettingsSection';
import QuickSettingsToggleRow from './QuickSettingsToggleRow';
import QuickSettingsTtsSection from './QuickSettingsTtsSection';
import QuickSettingsWhisperSection from './QuickSettingsWhisperSection';
import { useTts } from '../../../contexts/TtsContext';

function QuickSettingsTtsWrapper() {
const tts = useTts();
if (!tts) return null;
if (tts.availableVoices.length === 0) return null;

return (
<QuickSettingsTtsSection
enabled={tts.enabled}
onToggle={tts.toggle}
rate={tts.rate}
onRateChange={tts.setRate}
pitch={tts.pitch}
onPitchChange={tts.setPitch}
voiceURI={tts.voiceURI}
onVoiceChange={tts.setVoiceURI}
lang={tts.lang}
onLangChange={tts.setLang}
filteredVoices={tts.filteredVoices}
availableLanguages={tts.availableLanguages}
onTestVoice={tts.testVoice}
isSpeaking={tts.isSpeaking}
onStop={tts.stop}
/>
);
}

type QuickSettingsContentProps = {
isDarkMode: boolean;
Expand Down Expand Up @@ -76,6 +104,8 @@ export default function QuickSettingsContent({
</p>
</QuickSettingsSection>

<QuickSettingsTtsWrapper />

<QuickSettingsWhisperSection />
</div>
);
Expand Down
202 changes: 202 additions & 0 deletions src/components/quick-settings-panel/view/QuickSettingsTtsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { useTranslation } from 'react-i18next';
import { Volume2, VolumeX, Play } from 'lucide-react';
import type { VoiceInfo } from '../../../hooks/useSpeechOutput';
import { SETTING_ROW_CLASS } from '../constants';
import QuickSettingsSection from './QuickSettingsSection';

type QuickSettingsTtsSectionProps = {
enabled: boolean;
onToggle: () => void;
rate: number;
onRateChange: (rate: number) => void;
pitch: number;
onPitchChange: (pitch: number) => void;
voiceURI: string;
onVoiceChange: (voiceURI: string) => void;
lang: string;
onLangChange: (lang: string) => void;
filteredVoices: VoiceInfo[];
availableLanguages: string[];
onTestVoice: () => void;
isSpeaking: boolean;
onStop: () => void;
};

export default function QuickSettingsTtsSection({
enabled,
onToggle,
rate,
onRateChange,
pitch,
onPitchChange,
voiceURI,
onVoiceChange,
lang,
onLangChange,
filteredVoices,
availableLanguages,
onTestVoice,
isSpeaking,
onStop,
}: QuickSettingsTtsSectionProps) {
const { t } = useTranslation('settings');

return (
<QuickSettingsSection title={t('quickSettings.tts.sectionTitle')}>
{/* Enable/Disable toggle */}
<div className={SETTING_ROW_CLASS}>
<span id="tts-enabled-label" className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
{enabled ? (
<Volume2 className="h-4 w-4 text-gray-600 dark:text-gray-400" />
) : (
<VolumeX className="h-4 w-4 text-gray-600 dark:text-gray-400" />
)}
{t('quickSettings.tts.enabled')}
</span>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-labelledby="tts-enabled-label"
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition-transform ${
enabled ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
</div>

{enabled && (
<>
{/* Language filter */}
<div className="space-y-1 px-1">
<label htmlFor="tts-lang-select" className="text-xs text-gray-500 dark:text-gray-400">
{t('quickSettings.tts.language')}
</label>
<select
id="tts-lang-select"
value={lang}
onChange={(e) => {
onLangChange(e.target.value);
onVoiceChange('');
}}
className="w-full rounded-md border border-gray-300 bg-white px-2 py-1.5 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
<option value="">{t('quickSettings.tts.allLanguages')}</option>
{availableLanguages.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</select>
</div>

{/* Voice selection */}
<div className="space-y-1 px-1">
<label htmlFor="tts-voice-select" className="text-xs text-gray-500 dark:text-gray-400">
{t('quickSettings.tts.voice', { count: filteredVoices.length })}
</label>
<select
id="tts-voice-select"
value={voiceURI}
onChange={(e) => onVoiceChange(e.target.value)}
className="w-full rounded-md border border-gray-300 bg-white px-2 py-1.5 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
<option value="">{t('quickSettings.tts.voiceAuto')}</option>
{filteredVoices.map((v) => (
<option key={v.voiceURI} value={v.voiceURI}>
{v.name} ({v.lang}){v.localService ? '' : ` [${t('quickSettings.tts.network')}]`}
</option>
))}
</select>
</div>

{/* Rate slider */}
<div className="space-y-1 px-1">
<div className="flex items-center justify-between">
<label htmlFor="tts-rate-input" className="text-xs text-gray-500 dark:text-gray-400">
{t('quickSettings.tts.speed')}
</label>
<span className="text-xs font-mono text-gray-500 dark:text-gray-400">
{rate.toFixed(1)}x
</span>
</div>
<input
id="tts-rate-input"
type="range"
min="0.5"
max="3.0"
step="0.1"
value={rate}
onChange={(e) => onRateChange(parseFloat(e.target.value))}
className="w-full accent-blue-600"
/>
<div className="flex justify-between text-[10px] text-gray-400">
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
<span>3.0x</span>
</div>
</div>

{/* Pitch slider */}
<div className="space-y-1 px-1">
<div className="flex items-center justify-between">
<label htmlFor="tts-pitch-input" className="text-xs text-gray-500 dark:text-gray-400">
{t('quickSettings.tts.pitch')}
</label>
<span className="text-xs font-mono text-gray-500 dark:text-gray-400">
{pitch.toFixed(1)}
</span>
</div>
<input
id="tts-pitch-input"
type="range"
min="0.5"
max="2.0"
step="0.1"
value={pitch}
onChange={(e) => onPitchChange(parseFloat(e.target.value))}
className="w-full accent-blue-600"
/>
<div className="flex justify-between text-[10px] text-gray-400">
<span>{t('quickSettings.tts.pitchLow')}</span>
<span>{t('quickSettings.tts.pitchNormal')}</span>
<span>{t('quickSettings.tts.pitchHigh')}</span>
</div>
</div>

{/* Test / Stop button */}
<div className="px-1">
<button
type="button"
onClick={isSpeaking ? onStop : onTestVoice}
className={`flex w-full items-center justify-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
isSpeaking
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50'
}`}
>
{isSpeaking ? (
<>
<VolumeX className="h-4 w-4" />
{t('quickSettings.tts.stop')}
</>
) : (
<>
<Play className="h-4 w-4" />
{t('quickSettings.tts.testVoice')}
</>
)}
</button>
</div>
</>
)}
</QuickSettingsSection>
);
}
49 changes: 49 additions & 0 deletions src/contexts/TtsContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createContext, useContext, type ReactNode } from 'react';
import { useSpeechOutput } from '../hooks/useSpeechOutput';
import type { VoiceInfo } from '../hooks/useSpeechOutput';

type TtsContextValue = {
enabled: boolean;
toggle: () => void;
rate: number;
setRate: (rate: number) => void;
pitch: number;
setPitch: (pitch: number) => void;
voiceURI: string;
setVoiceURI: (uri: string) => void;
lang: string;
setLang: (lang: string) => void;
isSpeaking: boolean;
speak: (text: string) => void;
stop: () => void;
testVoice: () => void;
availableVoices: VoiceInfo[];
filteredVoices: VoiceInfo[];
availableLanguages: string[];
};

const TtsContext = createContext<TtsContextValue | null>(null);

type ChatMessage = {
type: string;
content?: string;
isStreaming?: boolean;
isToolUse?: boolean;
isInteractivePrompt?: boolean;
[key: string]: unknown;
};

export function TtsProvider({
chatMessages,
children,
}: {
chatMessages: ChatMessage[];
children: ReactNode;
}) {
const tts = useSpeechOutput(chatMessages);
return <TtsContext.Provider value={tts}>{children}</TtsContext.Provider>;
}

export function useTts(): TtsContextValue | null {
return useContext(TtsContext);
}
Loading