diff --git a/apps/web/src/components/speech/SpeechSettings.test.tsx b/apps/web/src/components/speech/SpeechSettings.test.tsx new file mode 100644 index 0000000..735ba24 --- /dev/null +++ b/apps/web/src/components/speech/SpeechSettings.test.tsx @@ -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 { + 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(); + + await waitFor(() => { + expect(screen.getByText("Speech Settings")).toBeInTheDocument(); + }); + }); + + it("should render the STT settings section", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Speech-to-Text")).toBeInTheDocument(); + }); + }); + + it("should render the TTS settings section", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Text-to-Speech")).toBeInTheDocument(); + }); + }); + + it("should render the provider status section", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Provider Status")).toBeInTheDocument(); + }); + }); + + it("should render all four section cards", async () => { + render(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByText("Language")).toBeInTheDocument(); + }); + }); + + it("should toggle STT enabled state when clicked", async () => { + const user = userEvent.setup(); + render(); + + 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(); + + await waitFor(() => { + const ttsToggle = screen.getByRole("switch", { name: /enable text-to-speech/i }); + expect(ttsToggle).toBeInTheDocument(); + }); + }); + + it("should render a voice selector", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Default Voice")).toBeInTheDocument(); + }); + }); + + it("should render a tier preference selector", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Provider Tier")).toBeInTheDocument(); + }); + }); + + it("should render an auto-play toggle", async () => { + render(); + + await waitFor(() => { + const autoPlayToggle = screen.getByRole("switch", { name: /auto-play/i }); + expect(autoPlayToggle).toBeInTheDocument(); + }); + }); + + it("should render a speed control slider", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Speed")).toBeInTheDocument(); + const slider = screen.getByRole("slider"); + expect(slider).toBeInTheDocument(); + }); + }); + + it("should display the current speed value", async () => { + render(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(mockGetVoices).toHaveBeenCalled(); + }); + }); + + it("should display voice options after fetching", async () => { + const user = userEvent.setup(); + render(); + + 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(); + + await waitFor(() => { + expect(screen.getByText(/unable to load voices/i)).toBeInTheDocument(); + }); + }); + }); + + describe("voice preview", () => { + it("should render a voice preview section", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Voice Preview")).toBeInTheDocument(); + }); + }); + + it("should render a test button for voice preview", async () => { + render(); + + 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(); + + await waitFor(() => { + expect(mockGetHealthStatus).toHaveBeenCalled(); + }); + }); + + it("should display STT provider status", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Speech-to-Text Provider")).toBeInTheDocument(); + }); + }); + + it("should display TTS provider status", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Text-to-Speech Provider")).toBeInTheDocument(); + }); + }); + + it("should show active indicator when provider is available", async () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); + }); + }); +}); diff --git a/apps/web/src/components/speech/SpeechSettings.tsx b/apps/web/src/components/speech/SpeechSettings.tsx new file mode 100644 index 0000000..7cad797 --- /dev/null +++ b/apps/web/src/components/speech/SpeechSettings.tsx @@ -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([]); + const [voicesError, setVoicesError] = useState(null); + const [healthData, setHealthData] = useState(null); + const [healthError, setHealthError] = useState(null); + + // Preview hook + const { + synthesize, + audioUrl, + isLoading: isPreviewLoading, + error: previewError, + } = useTextToSpeech(); + + /** + * Fetch available voices from the API + */ + const fetchVoices = useCallback(async (): Promise => { + 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 => { + 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 => { + const options: Record = { + speed, + tier: selectedTier, + }; + if (selectedVoice) { + options.voice = selectedVoice; + } + await synthesize(PREVIEW_TEXT, options); + }, [synthesize, selectedVoice, speed, selectedTier]); + + return ( +
+
+

Speech Settings

+

+ Configure voice input and output preferences for your workspace +

+
+ + {/* STT Settings */} + + + Speech-to-Text + Configure voice input preferences + + +
+ {/* Enable STT Toggle */} +
+
+ +

+ Allow voice input for text fields and commands +

+
+ +
+ + {/* Language Preference */} +
+ + +
+
+
+
+ + {/* TTS Settings */} + + + Text-to-Speech + Configure voice output preferences + + +
+ {/* Enable TTS Toggle */} +
+
+ +

+ Allow reading content aloud with synthesized voice +

+
+ +
+ + {/* Default Voice Selector */} +
+ + {voicesError ? ( +

{voicesError}

+ ) : ( + + )} +
+ + {/* Provider Tier Preference */} +
+ +

+ Choose the preferred quality tier for voice synthesis +

+ +
+ + {/* Auto-play Toggle */} +
+
+ +

+ Automatically play TTS responses when received +

+
+ +
+ + {/* Speed Control */} +
+
+ + {speed.toFixed(1)}x +
+ { + const newSpeed = values[0]; + if (newSpeed !== undefined) { + setSpeed(newSpeed); + } + }} + /> +
+ 0.5x + 1.0x + 2.0x +
+
+
+
+
+ + {/* Voice Preview */} + + + Voice Preview + Preview the selected voice with sample text + + +
+

“{PREVIEW_TEXT}”

+ + {previewError &&

{previewError}

} + {audioUrl && ( + + )} +
+
+
+ + {/* Provider Status */} + + + Provider Status + Current availability of speech service providers + + +
+ {healthError ? ( +

{healthError}

+ ) : healthData ? ( + <> + {/* STT Provider */} +
+ Speech-to-Text Provider +
+ {healthData.stt.available ? ( + <> + + Active + + ) : ( + <> + + Inactive + + )} +
+
+ + {/* TTS Provider */} +
+ Text-to-Speech Provider +
+ {healthData.tts.available ? ( + <> + + Active + + ) : ( + <> + + Inactive + + )} +
+
+ + ) : ( +

Checking provider status...

+ )} +
+
+
+
+ ); +} + +export default SpeechSettings; diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx new file mode 100644 index 0000000..6241e6a --- /dev/null +++ b/apps/web/src/components/ui/slider.tsx @@ -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( + ( + { + id, + min = 0, + max = 100, + step = 1, + value, + defaultValue, + onValueChange, + disabled, + className = "", + }, + ref + ) => { + const currentValue = value?.[0] ?? defaultValue?.[0] ?? min; + + return ( + { + 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"; diff --git a/apps/web/src/lib/api/speech.ts b/apps/web/src/lib/api/speech.ts index cf5aeef..fc402de 100644 --- a/apps/web/src/lib/api/speech.ts +++ b/apps/web/src/lib/api/speech.ts @@ -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 { - return apiGet("/api/speech/voices"); +export async function getVoices(tier?: SpeechTier): Promise { + const endpoint = tier ? `/api/speech/voices?tier=${tier}` : "/api/speech/voices"; + return apiGet(endpoint); +} + +/** + * Fetch health status of speech providers (STT and TTS) + */ +export async function getHealthStatus(): Promise { + return apiGet("/api/speech/health"); } /**