feat: M13-SpeechServices — TTS & STT integration #409
439
apps/web/src/components/speech/SpeechSettings.test.tsx
Normal file
439
apps/web/src/components/speech/SpeechSettings.test.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* @file SpeechSettings.test.tsx
|
||||
* @description Tests for the SpeechSettings component
|
||||
*
|
||||
* Validates all settings sections: STT, TTS, Voice Preview, Provider Status.
|
||||
* Follows TDD: tests written before implementation.
|
||||
*
|
||||
* Issue #404
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SpeechSettings } from "./SpeechSettings";
|
||||
|
||||
// Mock the speech API
|
||||
const mockGetVoices = vi.fn();
|
||||
const mockGetHealthStatus = vi.fn();
|
||||
const mockSynthesizeSpeech = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api/speech", () => ({
|
||||
getVoices: (...args: unknown[]): unknown => mockGetVoices(...args) as unknown,
|
||||
getHealthStatus: (...args: unknown[]): unknown => mockGetHealthStatus(...args) as unknown,
|
||||
synthesizeSpeech: (...args: unknown[]): unknown => mockSynthesizeSpeech(...args) as unknown,
|
||||
}));
|
||||
|
||||
// Mock the useTextToSpeech hook for voice preview
|
||||
const mockSynthesize = vi.fn();
|
||||
|
||||
vi.mock("@/hooks/useTextToSpeech", () => ({
|
||||
useTextToSpeech: vi.fn(() => ({
|
||||
synthesize: mockSynthesize,
|
||||
audioUrl: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
play: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
isPlaying: false,
|
||||
duration: 0,
|
||||
currentTime: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock HTMLAudioElement for AudioPlayer used inside preview
|
||||
class MockAudio {
|
||||
src = "";
|
||||
currentTime = 0;
|
||||
duration = 60;
|
||||
paused = true;
|
||||
playbackRate = 1;
|
||||
volume = 1;
|
||||
onended: (() => void) | null = null;
|
||||
ontimeupdate: (() => void) | null = null;
|
||||
onloadedmetadata: (() => void) | null = null;
|
||||
onerror: ((e: unknown) => void) | null = null;
|
||||
|
||||
play(): Promise<void> {
|
||||
this.paused = false;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.paused = true;
|
||||
}
|
||||
|
||||
addEventListener(): void {
|
||||
// no-op
|
||||
}
|
||||
|
||||
removeEventListener(): void {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal("Audio", MockAudio);
|
||||
|
||||
// Default mock responses
|
||||
const mockVoicesResponse = {
|
||||
data: [
|
||||
{ id: "voice-1", name: "Alloy", language: "en", tier: "default", isDefault: true },
|
||||
{ id: "voice-2", name: "Nova", language: "en", tier: "default", isDefault: false },
|
||||
{ id: "voice-3", name: "Premium Voice", language: "en", tier: "premium", isDefault: true },
|
||||
],
|
||||
};
|
||||
|
||||
const mockHealthResponse = {
|
||||
data: {
|
||||
stt: { available: true },
|
||||
tts: { available: true },
|
||||
},
|
||||
};
|
||||
|
||||
describe("SpeechSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetVoices.mockResolvedValue(mockVoicesResponse);
|
||||
mockGetHealthStatus.mockResolvedValue(mockHealthResponse);
|
||||
mockSynthesizeSpeech.mockResolvedValue(new Blob());
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("should render the speech settings heading", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Speech Settings")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the STT settings section", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Speech-to-Text")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the TTS settings section", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Text-to-Speech")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the provider status section", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Provider Status")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render all four section cards", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Speech-to-Text")).toBeInTheDocument();
|
||||
expect(screen.getByText("Text-to-Speech")).toBeInTheDocument();
|
||||
expect(screen.getByText("Voice Preview")).toBeInTheDocument();
|
||||
expect(screen.getByText("Provider Status")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("STT settings", () => {
|
||||
it("should render an enable/disable toggle for STT", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
const sttToggle = screen.getByRole("switch", { name: /enable speech-to-text/i });
|
||||
expect(sttToggle).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render a language preference dropdown", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Language")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should toggle STT enabled state when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("switch", { name: /enable speech-to-text/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const sttToggle = screen.getByRole("switch", { name: /enable speech-to-text/i });
|
||||
// Default should be checked (enabled)
|
||||
expect(sttToggle).toBeChecked();
|
||||
|
||||
await user.click(sttToggle);
|
||||
expect(sttToggle).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TTS settings", () => {
|
||||
it("should render an enable/disable toggle for TTS", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
const ttsToggle = screen.getByRole("switch", { name: /enable text-to-speech/i });
|
||||
expect(ttsToggle).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render a voice selector", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Default Voice")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render a tier preference selector", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Provider Tier")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render an auto-play toggle", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
const autoPlayToggle = screen.getByRole("switch", { name: /auto-play/i });
|
||||
expect(autoPlayToggle).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render a speed control slider", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Speed")).toBeInTheDocument();
|
||||
const slider = screen.getByRole("slider");
|
||||
expect(slider).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display the current speed value", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
// The speed display label shows "1.0x" next to the Speed label
|
||||
const speedLabels = screen.getAllByText("1.0x");
|
||||
expect(speedLabels.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should toggle TTS enabled state when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("switch", { name: /enable text-to-speech/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const ttsToggle = screen.getByRole("switch", { name: /enable text-to-speech/i });
|
||||
expect(ttsToggle).toBeChecked();
|
||||
|
||||
await user.click(ttsToggle);
|
||||
expect(ttsToggle).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should toggle auto-play state when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("switch", { name: /auto-play/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const autoPlayToggle = screen.getByRole("switch", { name: /auto-play/i });
|
||||
// Default should be unchecked
|
||||
expect(autoPlayToggle).not.toBeChecked();
|
||||
|
||||
await user.click(autoPlayToggle);
|
||||
expect(autoPlayToggle).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("voice selector", () => {
|
||||
it("should fetch voices on mount", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetVoices).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display voice options after fetching", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetVoices).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Open the voice selector by clicking the trigger button (id="tts-voice")
|
||||
const voiceButton = document.getElementById("tts-voice");
|
||||
expect(voiceButton).toBeTruthy();
|
||||
if (!voiceButton) throw new Error("Voice button not found");
|
||||
await user.click(voiceButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alloy")).toBeInTheDocument();
|
||||
expect(screen.getByText("Nova")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle API error gracefully when fetching voices", async () => {
|
||||
mockGetVoices.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/unable to load voices/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("voice preview", () => {
|
||||
it("should render a voice preview section", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Voice Preview")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render a test button for voice preview", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
const testButton = screen.getByRole("button", { name: /test voice/i });
|
||||
expect(testButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider status", () => {
|
||||
it("should fetch health status on mount", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetHealthStatus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display STT provider status", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Speech-to-Text Provider")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display TTS provider status", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Text-to-Speech Provider")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show active indicator when provider is available", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
const statusSection = screen.getByTestId("provider-status");
|
||||
const activeIndicators = within(statusSection).getAllByTestId("status-active");
|
||||
expect(activeIndicators.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("should show inactive indicator when provider is unavailable", async () => {
|
||||
mockGetHealthStatus.mockResolvedValueOnce({
|
||||
data: {
|
||||
stt: { available: false },
|
||||
tts: { available: true },
|
||||
},
|
||||
});
|
||||
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
const statusSection = screen.getByTestId("provider-status");
|
||||
const inactiveIndicators = within(statusSection).getAllByTestId("status-inactive");
|
||||
expect(inactiveIndicators.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle health check error gracefully", async () => {
|
||||
mockGetHealthStatus.mockRejectedValueOnce(new Error("Service unavailable"));
|
||||
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/unable to check provider status/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PDA-friendly design", () => {
|
||||
it("should not use aggressive red colors", async () => {
|
||||
const { container } = render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Speech Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const allElements = container.querySelectorAll("*");
|
||||
allElements.forEach((el) => {
|
||||
const className = el.className;
|
||||
if (typeof className === "string") {
|
||||
expect(className).not.toMatch(/bg-red-|text-red-|border-red-/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should not use demanding language", async () => {
|
||||
const { container } = render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Speech Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const text = container.textContent;
|
||||
const demandingWords = [
|
||||
"OVERDUE",
|
||||
"URGENT",
|
||||
"MUST DO",
|
||||
"CRITICAL",
|
||||
"REQUIRED",
|
||||
"YOU NEED TO",
|
||||
];
|
||||
for (const word of demandingWords) {
|
||||
expect(text.toUpperCase()).not.toContain(word);
|
||||
}
|
||||
});
|
||||
|
||||
it("should use descriptive section headers", async () => {
|
||||
render(<SpeechSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for descriptive subtext under section headers
|
||||
expect(screen.getByText("Configure voice input preferences")).toBeInTheDocument();
|
||||
expect(screen.getByText("Configure voice output preferences")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
404
apps/web/src/components/speech/SpeechSettings.tsx
Normal file
404
apps/web/src/components/speech/SpeechSettings.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* 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;
|
||||
55
apps/web/src/components/ui/slider.tsx
Normal file
55
apps/web/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface SliderProps {
|
||||
id?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
value?: number[];
|
||||
defaultValue?: number[];
|
||||
onValueChange?: (value: number[]) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
disabled,
|
||||
className = "",
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const currentValue = value?.[0] ?? defaultValue?.[0] ?? min;
|
||||
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
role="slider"
|
||||
ref={ref}
|
||||
id={id}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={currentValue}
|
||||
onChange={(e) => {
|
||||
onValueChange?.([parseFloat(e.target.value)]);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={currentValue}
|
||||
className={`w-full h-2 rounded-lg appearance-none cursor-pointer bg-gray-200 accent-blue-500 ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Slider.displayName = "Slider";
|
||||
@@ -6,12 +6,16 @@
|
||||
import { apiGet } from "./client";
|
||||
import { API_BASE_URL } from "../config";
|
||||
|
||||
export type SpeechTier = "default" | "premium" | "fallback";
|
||||
|
||||
export interface VoiceInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
language: string;
|
||||
gender?: string;
|
||||
preview_url?: string;
|
||||
tier?: SpeechTier;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface SynthesizeOptions {
|
||||
@@ -26,11 +30,31 @@ export interface VoicesResponse {
|
||||
data: VoiceInfo[];
|
||||
}
|
||||
|
||||
export interface ProviderHealth {
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
data: {
|
||||
stt: ProviderHealth;
|
||||
tts: ProviderHealth;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available TTS voices
|
||||
* Optionally filter by tier (default, premium, fallback)
|
||||
*/
|
||||
export async function getVoices(): Promise<VoicesResponse> {
|
||||
return apiGet<VoicesResponse>("/api/speech/voices");
|
||||
export async function getVoices(tier?: SpeechTier): Promise<VoicesResponse> {
|
||||
const endpoint = tier ? `/api/speech/voices?tier=${tier}` : "/api/speech/voices";
|
||||
return apiGet<VoicesResponse>(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch health status of speech providers (STT and TTS)
|
||||
*/
|
||||
export async function getHealthStatus(): Promise<HealthResponse> {
|
||||
return apiGet<HealthResponse>("/api/speech/health");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user