All checks were successful
ci/woodpecker/push/web Pipeline was successful
Implements the SpeechSettings component with four sections: - STT settings (enable/disable, language preference) - TTS settings (enable/disable, voice selector, tier preference, auto-play, speed control) - Voice preview with test button - Provider status with health indicators Also adds Slider UI component and getHealthStatus API client function. 30 unit tests covering all sections, toggles, voice loading, and PDA-friendly design. Fixes #404 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
/**
|
|
* SpeechSettings Component
|
|
*
|
|
* Settings page for configuring speech preferences per workspace.
|
|
* Includes STT settings, TTS settings, voice preview, and provider status.
|
|
*
|
|
* Follows PDA-friendly design: calm colors, no aggressive language.
|
|
*
|
|
* Issue #404
|
|
*/
|
|
|
|
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import type { ReactElement } from "react";
|
|
import { Card, CardHeader, CardContent, CardTitle, CardDescription } from "@/components/ui/card";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import {
|
|
Select,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
SelectContent,
|
|
SelectItem,
|
|
} from "@/components/ui/select";
|
|
import { getVoices, getHealthStatus } from "@/lib/api/speech";
|
|
import type { VoiceInfo, HealthResponse } from "@/lib/api/speech";
|
|
import { useTextToSpeech } from "@/hooks/useTextToSpeech";
|
|
|
|
/** Supported languages for STT */
|
|
const STT_LANGUAGES = [
|
|
{ value: "en", label: "English" },
|
|
{ value: "es", label: "Spanish" },
|
|
{ value: "fr", label: "French" },
|
|
{ value: "de", label: "German" },
|
|
{ value: "it", label: "Italian" },
|
|
{ value: "pt", label: "Portuguese" },
|
|
{ value: "ja", label: "Japanese" },
|
|
{ value: "zh", label: "Chinese" },
|
|
{ value: "ko", label: "Korean" },
|
|
{ value: "auto", label: "Auto-detect" },
|
|
];
|
|
|
|
/** TTS tier options */
|
|
const TIER_OPTIONS = [
|
|
{ value: "default", label: "Default" },
|
|
{ value: "premium", label: "Premium" },
|
|
{ value: "fallback", label: "Fallback" },
|
|
];
|
|
|
|
/** Sample text for voice preview */
|
|
const PREVIEW_TEXT = "Hello, this is a preview of the selected voice. How does it sound?";
|
|
|
|
/**
|
|
* SpeechSettings provides a comprehensive settings interface for
|
|
* configuring speech-to-text and text-to-speech preferences.
|
|
*/
|
|
export function SpeechSettings(): ReactElement {
|
|
// STT state
|
|
const [sttEnabled, setSttEnabled] = useState(true);
|
|
const [sttLanguage, setSttLanguage] = useState("en");
|
|
|
|
// TTS state
|
|
const [ttsEnabled, setTtsEnabled] = useState(true);
|
|
const [selectedVoice, setSelectedVoice] = useState("");
|
|
const [selectedTier, setSelectedTier] = useState("default");
|
|
const [autoPlay, setAutoPlay] = useState(false);
|
|
const [speed, setSpeed] = useState(1.0);
|
|
|
|
// Data state
|
|
const [voices, setVoices] = useState<VoiceInfo[]>([]);
|
|
const [voicesError, setVoicesError] = useState<string | null>(null);
|
|
const [healthData, setHealthData] = useState<HealthResponse["data"] | null>(null);
|
|
const [healthError, setHealthError] = useState<string | null>(null);
|
|
|
|
// Preview hook
|
|
const {
|
|
synthesize,
|
|
audioUrl,
|
|
isLoading: isPreviewLoading,
|
|
error: previewError,
|
|
} = useTextToSpeech();
|
|
|
|
/**
|
|
* Fetch available voices from the API
|
|
*/
|
|
const fetchVoices = useCallback(async (): Promise<void> => {
|
|
try {
|
|
setVoicesError(null);
|
|
const response = await getVoices();
|
|
setVoices(response.data);
|
|
|
|
// Select the first default voice if none selected
|
|
if (response.data.length > 0 && !selectedVoice) {
|
|
const defaultVoice = response.data.find((v) => v.isDefault);
|
|
const firstVoice = response.data[0];
|
|
setSelectedVoice(defaultVoice?.id ?? firstVoice?.id ?? "");
|
|
}
|
|
} catch {
|
|
setVoicesError("Unable to load voices. Please try again later.");
|
|
}
|
|
}, [selectedVoice]);
|
|
|
|
/**
|
|
* Fetch health status from the API
|
|
*/
|
|
const fetchHealth = useCallback(async (): Promise<void> => {
|
|
try {
|
|
setHealthError(null);
|
|
const response = await getHealthStatus();
|
|
setHealthData(response.data);
|
|
} catch {
|
|
setHealthError("Unable to check provider status. Please try again later.");
|
|
}
|
|
}, []);
|
|
|
|
// Fetch voices and health on mount
|
|
useEffect(() => {
|
|
void fetchVoices();
|
|
void fetchHealth();
|
|
}, [fetchVoices, fetchHealth]);
|
|
|
|
/**
|
|
* Handle voice preview test
|
|
*/
|
|
const handleTestVoice = useCallback(async (): Promise<void> => {
|
|
const options: Record<string, string | number> = {
|
|
speed,
|
|
tier: selectedTier,
|
|
};
|
|
if (selectedVoice) {
|
|
options.voice = selectedVoice;
|
|
}
|
|
await synthesize(PREVIEW_TEXT, options);
|
|
}, [synthesize, selectedVoice, speed, selectedTier]);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h2 className="text-2xl font-semibold text-gray-900">Speech Settings</h2>
|
|
<p className="text-sm text-gray-600 mt-1">
|
|
Configure voice input and output preferences for your workspace
|
|
</p>
|
|
</div>
|
|
|
|
{/* STT Settings */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Speech-to-Text</CardTitle>
|
|
<CardDescription>Configure voice input preferences</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{/* Enable STT Toggle */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="stt-enabled">Enable Speech-to-Text</Label>
|
|
<p className="text-xs text-gray-500">
|
|
Allow voice input for text fields and commands
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="stt-enabled"
|
|
checked={sttEnabled}
|
|
onCheckedChange={setSttEnabled}
|
|
aria-label="Enable Speech-to-Text"
|
|
/>
|
|
</div>
|
|
|
|
{/* Language Preference */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="stt-language">Language</Label>
|
|
<Select value={sttLanguage} onValueChange={setSttLanguage}>
|
|
<SelectTrigger id="stt-language" className="w-full">
|
|
<SelectValue placeholder="Select language" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{STT_LANGUAGES.map((lang) => (
|
|
<SelectItem key={lang.value} value={lang.value}>
|
|
{lang.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* TTS Settings */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Text-to-Speech</CardTitle>
|
|
<CardDescription>Configure voice output preferences</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{/* Enable TTS Toggle */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="tts-enabled">Enable Text-to-Speech</Label>
|
|
<p className="text-xs text-gray-500">
|
|
Allow reading content aloud with synthesized voice
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="tts-enabled"
|
|
checked={ttsEnabled}
|
|
onCheckedChange={setTtsEnabled}
|
|
aria-label="Enable Text-to-Speech"
|
|
/>
|
|
</div>
|
|
|
|
{/* Default Voice Selector */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tts-voice">Default Voice</Label>
|
|
{voicesError ? (
|
|
<p className="text-sm text-amber-600">{voicesError}</p>
|
|
) : (
|
|
<Select value={selectedVoice} onValueChange={setSelectedVoice}>
|
|
<SelectTrigger id="tts-voice" className="w-full">
|
|
<SelectValue placeholder="Select a voice" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{voices.map((voice) => (
|
|
<SelectItem key={voice.id} value={voice.id}>
|
|
{voice.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
|
|
{/* Provider Tier Preference */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tts-tier">Provider Tier</Label>
|
|
<p className="text-xs text-gray-500">
|
|
Choose the preferred quality tier for voice synthesis
|
|
</p>
|
|
<Select value={selectedTier} onValueChange={setSelectedTier}>
|
|
<SelectTrigger id="tts-tier" className="w-full">
|
|
<SelectValue placeholder="Select tier" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{TIER_OPTIONS.map((tier) => (
|
|
<SelectItem key={tier.value} value={tier.value}>
|
|
{tier.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Auto-play Toggle */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="tts-autoplay">Auto-play</Label>
|
|
<p className="text-xs text-gray-500">
|
|
Automatically play TTS responses when received
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="tts-autoplay"
|
|
checked={autoPlay}
|
|
onCheckedChange={setAutoPlay}
|
|
aria-label="Auto-play"
|
|
/>
|
|
</div>
|
|
|
|
{/* Speed Control */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="tts-speed">Speed</Label>
|
|
<span className="text-sm text-gray-600">{speed.toFixed(1)}x</span>
|
|
</div>
|
|
<Slider
|
|
id="tts-speed"
|
|
min={0.5}
|
|
max={2}
|
|
step={0.1}
|
|
value={[speed]}
|
|
onValueChange={(values) => {
|
|
const newSpeed = values[0];
|
|
if (newSpeed !== undefined) {
|
|
setSpeed(newSpeed);
|
|
}
|
|
}}
|
|
/>
|
|
<div className="flex justify-between text-xs text-gray-400">
|
|
<span>0.5x</span>
|
|
<span>1.0x</span>
|
|
<span>2.0x</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Voice Preview */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Voice Preview</CardTitle>
|
|
<CardDescription>Preview the selected voice with sample text</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-gray-600 italic">“{PREVIEW_TEXT}”</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => void handleTestVoice()}
|
|
disabled={isPreviewLoading}
|
|
aria-label="Test voice"
|
|
>
|
|
{isPreviewLoading ? "Synthesizing..." : "Test Voice"}
|
|
</Button>
|
|
{previewError && <p className="text-sm text-amber-600">{previewError}</p>}
|
|
{audioUrl && (
|
|
<audio controls src={audioUrl} className="w-full mt-2">
|
|
<track kind="captions" />
|
|
</audio>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Provider Status */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Provider Status</CardTitle>
|
|
<CardDescription>Current availability of speech service providers</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3" data-testid="provider-status">
|
|
{healthError ? (
|
|
<p className="text-sm text-amber-600">{healthError}</p>
|
|
) : healthData ? (
|
|
<>
|
|
{/* STT Provider */}
|
|
<div className="flex items-center justify-between py-2">
|
|
<span className="text-sm text-gray-700">Speech-to-Text Provider</span>
|
|
<div className="flex items-center gap-2">
|
|
{healthData.stt.available ? (
|
|
<>
|
|
<span
|
|
className="inline-block h-2.5 w-2.5 rounded-full bg-green-500"
|
|
data-testid="status-active"
|
|
aria-label="Active"
|
|
/>
|
|
<span className="text-sm text-gray-600">Active</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span
|
|
className="inline-block h-2.5 w-2.5 rounded-full bg-gray-400"
|
|
data-testid="status-inactive"
|
|
aria-label="Inactive"
|
|
/>
|
|
<span className="text-sm text-gray-600">Inactive</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* TTS Provider */}
|
|
<div className="flex items-center justify-between py-2">
|
|
<span className="text-sm text-gray-700">Text-to-Speech Provider</span>
|
|
<div className="flex items-center gap-2">
|
|
{healthData.tts.available ? (
|
|
<>
|
|
<span
|
|
className="inline-block h-2.5 w-2.5 rounded-full bg-green-500"
|
|
data-testid="status-active"
|
|
aria-label="Active"
|
|
/>
|
|
<span className="text-sm text-gray-600">Active</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span
|
|
className="inline-block h-2.5 w-2.5 rounded-full bg-gray-400"
|
|
data-testid="status-inactive"
|
|
aria-label="Inactive"
|
|
/>
|
|
<span className="text-sm text-gray-600">Inactive</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-gray-500">Checking provider status...</p>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default SpeechSettings;
|