diff --git a/src/App.tsx b/src/App.tsx index c6fecb4..821e327 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import { LanguageProvider } from './providers/language/LanguageContext'; import { ToastProvider } from './contexts/ToastContext'; import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext'; import { FileProvider } from './contexts/FileContext'; +import { VoiceCatalogProvider } from './contexts/VoiceCatalogContext'; import { MainLayout } from './layouts/MainLayout'; import { FeatureLayout } from './layouts/FeatureLayout'; import { DashboardPage } from './pages/Dashboard'; @@ -72,6 +73,7 @@ function App() { + @@ -231,6 +233,7 @@ function App() { + diff --git a/src/api/teamsbotApi.ts b/src/api/teamsbotApi.ts index 607d998..6942d7a 100644 --- a/src/api/teamsbotApi.ts +++ b/src/api/teamsbotApi.ts @@ -102,18 +102,10 @@ export interface ConfigUpdateRequest { debugMode?: boolean; } -// Voice/Language Types (from Google TTS API) -export interface VoiceLanguage { - code: string; - name: string; -} - -export interface VoiceOption { - name: string; - languageCodes: string[]; - ssmlGender: string; - naturalSampleRateHertz: number; -} +// Voice option type re-exported from the central voice catalog API. +// The legacy teamsbot-specific {code,name} language type is gone — consumers +// should use VoiceLanguage from voiceCatalogApi (catalog SSOT). +export type { VoiceOption } from './voiceCatalogApi'; // Auth Detection Test Types export interface StepScreenshot { @@ -313,25 +305,19 @@ export async function testVoice( } /** - * Fetch available TTS languages from Google Cloud. - * Returns array of language codes (e.g. ["de-DE", "en-US", ...]) + * Fetch the curated voice/language catalog (single source of truth). + * Re-exports the central voiceCatalogApi.fetchVoiceCatalog so legacy + * teamsbot consumers stay on one import surface. */ -export async function fetchLanguages(): Promise { - try { - const response = await api.get('/voice-google/languages'); - return response.data?.languages || []; - } catch { - return []; - } -} +export { fetchVoiceCatalog as fetchLanguages } from './voiceCatalogApi'; /** * Fetch available TTS voices for a language from Google Cloud. */ export async function fetchVoices(languageCode: string): Promise { try { - const response = await api.get('/voice-google/voices', { - params: { languageCode }, + const response = await api.get('/api/voice/voices', { + params: { language: languageCode }, }); return response.data?.voices || []; } catch { diff --git a/src/api/voiceCatalogApi.ts b/src/api/voiceCatalogApi.ts new file mode 100644 index 0000000..1bdf731 --- /dev/null +++ b/src/api/voiceCatalogApi.ts @@ -0,0 +1,47 @@ +/** + * Voice / Language Catalog API. + * + * Single source of truth for every voice-language picker, default-voice + * lookup, and ISO ⇄ BCP-47 mapping in the frontend. Mirrors + * gateway/modules/shared/voiceCatalog.py 1:1. + * + * Hard-coded language lists or ad-hoc maps in components are forbidden — + * consume `useVoiceCatalog()` instead. + */ + +import api from '../api'; + +export interface VoiceLanguage { + bcp47: string; + iso: string; + label: string; + flag: string; + defaultVoice: string | null; +} + +export interface VoiceOption { + name: string; + languageCodes: string[]; + ssmlGender: string; + naturalSampleRateHertz: number; +} + +interface CatalogResponse { + languages: VoiceLanguage[]; +} + +interface VoicesResponse { + voices: VoiceOption[]; +} + +export async function fetchVoiceCatalog(): Promise { + const response = await api.get('/api/voice/languages'); + return response.data?.languages ?? []; +} + +export async function fetchVoicesForLanguage(bcp47: string): Promise { + const response = await api.get('/api/voice/voices', { + params: { language: bcp47 }, + }); + return response.data?.voices ?? []; +} diff --git a/src/components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.tsx b/src/components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.tsx index 8837a55..837a865 100644 --- a/src/components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.tsx +++ b/src/components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.tsx @@ -1,67 +1,29 @@ /** * VoiceLanguageSelect - * - * Reusable component for selecting voice/speech recognition language. - * Defaults to user's profile language. - * Can be used for speech-to-text, text-to-speech, and translation features. + * + * Reusable picker for voice/speech-recognition language. Reads the language + * list from the central VoiceCatalog (single source of truth) — never + * hard-coded here. */ import React from 'react'; import { useLanguage } from '../../../providers/language/LanguageContext'; +import { useVoiceCatalog, useDefaultVoiceLocale } from '../../../contexts/VoiceCatalogContext'; +import type { VoiceLanguage } from '../../../api/voiceCatalogApi'; import styles from './VoiceLanguageSelect.module.css'; -// Voice language options with full locale codes for Google Cloud Speech -export interface VoiceLanguageOption { - code: string; // Full locale code (e.g., 'de-DE') - label: string; // Display label - shortCode: string; // Short code for mapping (e.g., 'de') - flag?: string; // Optional flag emoji -} - -// Supported languages for speech recognition -export const voiceLanguages: VoiceLanguageOption[] = [ - { code: 'de-DE', label: 'Deutsch', shortCode: 'de', flag: '🇩🇪' }, - { code: 'de-CH', label: 'Deutsch (Schweiz)', shortCode: 'de', flag: '🇨🇭' }, - { code: 'en-US', label: 'English (US)', shortCode: 'en', flag: '🇺🇸' }, - { code: 'en-GB', label: 'English (UK)', shortCode: 'en', flag: '🇬🇧' }, - { code: 'fr-FR', label: 'Français', shortCode: 'fr', flag: '🇫🇷' }, - { code: 'fr-CH', label: 'Français (Suisse)', shortCode: 'fr', flag: '🇨🇭' }, - { code: 'it-IT', label: 'Italiano', shortCode: 'it', flag: '🇮🇹' }, - { code: 'it-CH', label: 'Italiano (Svizzera)', shortCode: 'it', flag: '🇨🇭' }, - { code: 'es-ES', label: 'Español', shortCode: 'es', flag: '🇪🇸' }, - { code: 'pt-BR', label: 'Português', shortCode: 'pt', flag: '🇧🇷' }, -]; - -// Map user profile language (short code) to default voice language (full code) -const profileToVoiceLanguage: Record = { - 'de': 'de-DE', - 'en': 'en-US', - 'fr': 'fr-FR', - 'it': 'it-IT', - 'es': 'es-ES', - 'pt': 'pt-BR', -}; +export type VoiceLanguageOption = VoiceLanguage; export interface VoiceLanguageSelectProps { value: string; onChange: (languageCode: string) => void; disabled?: boolean; - compact?: boolean; // Compact mode shows only flag/short code - showFlags?: boolean; // Show flag emojis + compact?: boolean; + showFlags?: boolean; className?: string; title?: string; } -/** - * Get the default voice language based on user's profile language - */ -export const getDefaultVoiceLanguage = (profileLanguage?: string): string => { - if (profileLanguage && profileToVoiceLanguage[profileLanguage]) { - return profileToVoiceLanguage[profileLanguage]; - } - return 'de-DE'; // Default fallback -}; - export const VoiceLanguageSelect: React.FC = ({ value, onChange, @@ -71,23 +33,25 @@ export const VoiceLanguageSelect: React.FC = ({ className = '', title = 'Sprache für Spracherkennung', }) => { + const { languages, isLoading } = useVoiceCatalog(); + const handleChange = (e: React.ChangeEvent) => { onChange(e.target.value); }; - + return (
@@ -96,37 +60,34 @@ export const VoiceLanguageSelect: React.FC = ({ }; /** - * Hook to manage voice language state with user profile default + * Hook to manage voice language state with user profile default. + * Initial value falls back to the catalog-derived default for the profile language. */ export const useVoiceLanguage = (initialValue?: string) => { const { currentLanguage } = useLanguage(); - - // Track if user has manually changed the language + const { languages } = useVoiceCatalog(); + const defaultLocale = useDefaultVoiceLocale(currentLanguage); + const hasManuallyChanged = React.useRef(false); - - // Initialize with user's profile language (or provided initial value) const [voiceLanguage, setVoiceLanguage] = React.useState( - initialValue || getDefaultVoiceLanguage(currentLanguage) + initialValue || defaultLocale, ); - - // Update voice language when user profile language changes (only if not manually set) + React.useEffect(() => { if (!initialValue && !hasManuallyChanged.current) { - const newDefault = getDefaultVoiceLanguage(currentLanguage); - setVoiceLanguage(newDefault); + setVoiceLanguage(defaultLocale); } - }, [currentLanguage, initialValue]); - - // Wrapper to track manual changes + }, [defaultLocale, initialValue]); + const handleSetVoiceLanguage = React.useCallback((newLanguage: string) => { hasManuallyChanged.current = true; setVoiceLanguage(newLanguage); }, []); - + return { voiceLanguage, setVoiceLanguage: handleSetVoiceLanguage, - voiceLanguages, + voiceLanguages: languages, }; }; diff --git a/src/components/UiComponents/VoiceLanguageSelect/index.ts b/src/components/UiComponents/VoiceLanguageSelect/index.ts index eecae14..0e5ab24 100644 --- a/src/components/UiComponents/VoiceLanguageSelect/index.ts +++ b/src/components/UiComponents/VoiceLanguageSelect/index.ts @@ -1,8 +1,6 @@ -export { - VoiceLanguageSelect, - useVoiceLanguage, - getDefaultVoiceLanguage, - voiceLanguages, +export { + VoiceLanguageSelect, + useVoiceLanguage, type VoiceLanguageOption, - type VoiceLanguageSelectProps + type VoiceLanguageSelectProps, } from './VoiceLanguageSelect'; diff --git a/src/contexts/VoiceCatalogContext.tsx b/src/contexts/VoiceCatalogContext.tsx new file mode 100644 index 0000000..0150460 --- /dev/null +++ b/src/contexts/VoiceCatalogContext.tsx @@ -0,0 +1,151 @@ +/** + * VoiceCatalogContext + * + * Loads the central voice/language catalog from the backend exactly once and + * makes it available to every component via `useVoiceCatalog()`. + * + * Provides convenience helpers for ISO ⇄ BCP-47 lookups and curated default + * voices, mirroring the backend `voiceCatalog` API. Components MUST NOT keep + * their own static language lists. + */ + +import React, { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { fetchVoiceCatalog, VoiceLanguage } from '../api/voiceCatalogApi'; + +interface VoiceCatalogContextType { + languages: VoiceLanguage[]; + isLoading: boolean; + error: string | null; + getByBcp47: (code: string | null | undefined) => VoiceLanguage | undefined; + getByIso: (iso: string | null | undefined) => VoiceLanguage | undefined; + isoToBcp47: (iso: string | null | undefined) => string | undefined; + getDefaultVoice: (bcp47: string | null | undefined) => string | null; +} + +const VoiceCatalogContext = createContext(undefined); + +interface VoiceCatalogProviderProps { + children: ReactNode; +} + +export const VoiceCatalogProvider: React.FC = ({ children }) => { + const [languages, setLanguages] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const data = await fetchVoiceCatalog(); + if (!cancelled) { + setLanguages(data); + setError(null); + } + } catch (err: any) { + if (!cancelled) { + setError(err?.message || 'Failed to load voice catalog'); + setLanguages([]); + } + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const byBcp47 = useMemo(() => { + const map = new Map(); + for (const v of languages) map.set(v.bcp47.toLowerCase(), v); + return map; + }, [languages]); + + const byIso = useMemo(() => { + const map = new Map(); + for (const v of languages) { + if (!map.has(v.iso.toLowerCase())) map.set(v.iso.toLowerCase(), v); + } + return map; + }, [languages]); + + const getByBcp47 = useCallback( + (code: string | null | undefined) => + code ? byBcp47.get(code.trim().toLowerCase()) : undefined, + [byBcp47], + ); + + const getByIso = useCallback( + (iso: string | null | undefined) => + iso ? byIso.get(iso.trim().toLowerCase()) : undefined, + [byIso], + ); + + const isoToBcp47 = useCallback( + (iso: string | null | undefined): string | undefined => { + if (!iso) return undefined; + const trimmed = iso.trim(); + if (!trimmed) return undefined; + if (trimmed.includes('-')) { + const canonical = byBcp47.get(trimmed.toLowerCase()); + return canonical ? canonical.bcp47 : trimmed; + } + const entry = byIso.get(trimmed.toLowerCase()); + if (entry) return entry.bcp47; + return `${trimmed.toLowerCase()}-${trimmed.toUpperCase()}`; + }, + [byBcp47, byIso], + ); + + const getDefaultVoice = useCallback( + (bcp47: string | null | undefined): string | null => { + const entry = getByBcp47(bcp47); + return entry?.defaultVoice ?? null; + }, + [getByBcp47], + ); + + const value = useMemo( + () => ({ + languages, + isLoading, + error, + getByBcp47, + getByIso, + isoToBcp47, + getDefaultVoice, + }), + [languages, isLoading, error, getByBcp47, getByIso, isoToBcp47, getDefaultVoice], + ); + + return ( + {children} + ); +}; + +export const useVoiceCatalog = (): VoiceCatalogContextType => { + const ctx = useContext(VoiceCatalogContext); + if (!ctx) { + throw new Error('useVoiceCatalog must be used within VoiceCatalogProvider'); + } + return ctx; +}; + +/** + * Map a profile language (ISO short code) to a default voice locale. + * Returns the catalog's BCP-47 for the ISO if available, else falls back to + * `de-DE` so the UI always has a deterministic starting value. + */ +export const useDefaultVoiceLocale = (profileLanguage?: string | null): string => { + const { isoToBcp47 } = useVoiceCatalog(); + return isoToBcp47(profileLanguage) || 'de-DE'; +}; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 6f63791..ead846c 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -11,6 +11,7 @@ import { setUserDataCache, getUserDataCache } from '../utils/userCache'; import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; import { useApiRequest } from '../hooks/useApi'; +import { useVoiceCatalog } from '../contexts/VoiceCatalogContext'; import styles from './Settings.module.css'; // ============================================================================= @@ -92,6 +93,7 @@ interface VoiceMapEntry { language: string; voiceName: string; } const VoiceSettingsTab: React.FC = () => { const { t } = useLanguage(); const { request } = useApiRequest(); + const { languages: voiceCatalog } = useVoiceCatalog(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -100,7 +102,6 @@ const VoiceSettingsTab: React.FC = () => { const [success, setSuccess] = useState(null); const [sttLanguage, setSttLanguage] = useState('de-DE'); - const [languages, setLanguages] = useState([]); const [voiceMap, setVoiceMap] = useState([]); const [addLanguage, setAddLanguage] = useState('de-DE'); @@ -111,13 +112,7 @@ const VoiceSettingsTab: React.FC = () => { const _loadSettings = useCallback(async () => { setLoading(true); try { - const [prefsData, languagesData] = await Promise.all([ - request({ url: '/api/voice/preferences', method: 'get' }), - request({ url: '/api/voice/languages', method: 'get' }), - ]); - - const langList = (languagesData as any)?.languages || []; - setLanguages(langList); + const prefsData = await request({ url: '/api/voice/preferences', method: 'get' }); const prefs = prefsData as any; setSttLanguage(prefs?.sttLanguage || 'de-DE'); @@ -203,16 +198,9 @@ const VoiceSettingsTab: React.FC = () => { }, [request]); const _getLanguageName = useCallback((code: string) => { - const found = languages.find((l: any) => (l.code || l) === code); - return found?.name || found?.code || code; - }, [languages]); - - const _defaultLangs = [ - { code: 'de-DE', name: 'Deutsch' }, { code: 'en-US', name: 'English (US)' }, - { code: 'fr-FR', name: 'Francais' }, { code: 'it-IT', name: 'Italiano' }, - { code: 'es-ES', name: 'Espanol' }, - ]; - const _displayLanguages = languages.length > 0 ? languages : _defaultLangs; + const entry = voiceCatalog.find(l => l.bcp47.toLowerCase() === code.toLowerCase()); + return entry ? `${entry.flag ? entry.flag + ' ' : ''}${entry.label}` : code; + }, [voiceCatalog]); if (loading) return
{t('Einstellungen werden geladen')}
; @@ -230,8 +218,10 @@ const VoiceSettingsTab: React.FC = () => {
@@ -274,8 +264,10 @@ const VoiceSettingsTab: React.FC = () => {
diff --git a/src/pages/views/teamsbot/TeamsbotSettingsView.tsx b/src/pages/views/teamsbot/TeamsbotSettingsView.tsx index ed2ca21..6bd6ac3 100644 --- a/src/pages/views/teamsbot/TeamsbotSettingsView.tsx +++ b/src/pages/views/teamsbot/TeamsbotSettingsView.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import * as teamsbotApi from '../../../api/teamsbotApi'; import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption } from '../../../api/teamsbotApi'; +import type { VoiceLanguage } from '../../../api/voiceCatalogApi'; import { FaPlay, FaSpinner } from 'react-icons/fa'; import styles from './Teamsbot.module.css'; @@ -36,8 +37,8 @@ export const TeamsbotSettingsView: React.FC = () => { // Form state const [formData, setFormData] = useState({}); - // Dynamic voice data from Google TTS API - const [languages, setLanguages] = useState([]); + // Voice catalog (single source of truth) + dynamic voices for the selected language + const [languages, setLanguages] = useState([]); const [voices, setVoices] = useState([]); const [loadingVoices, setLoadingVoices] = useState(false); @@ -247,19 +248,13 @@ export const TeamsbotSettingsView: React.FC = () => { value={formData.language || 'de-DE'} onChange={(e) => _handleLanguageChange(e.target.value)} > - {languages.length > 0 ? ( - languages.map((langCode, idx) => ( - - )) - ) : ( - <> - - - - - )} + {languages.map(lang => ( + + ))} - Sprache fuer Captions und Sprachausgabe ({languages.length} Sprachen verfuegbar) + {t('Sprache für Captions und Sprachausgabe')} ({languages.length} {t('Sprachen verfügbar')})
diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index a06eb7b..9c4ee92 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -11,21 +11,7 @@ import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture'; import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace'; import { useLanguage } from '../../../providers/language/LanguageContext'; - -const _STT_LANGUAGES = [ - { code: 'de-DE', label: 'Deutsch' }, - { code: 'en-US', label: 'English (US)' }, - { code: 'en-GB', label: 'English (UK)' }, - { code: 'fr-FR', label: 'Francais' }, - { code: 'it-IT', label: 'Italiano' }, - { code: 'es-ES', label: 'Espanol' }, - { code: 'pt-BR', label: 'Portugues' }, - { code: 'nl-NL', label: 'Nederlands' }, - { code: 'pl-PL', label: 'Polski' }, - { code: 'ru-RU', label: 'Russkij' }, - { code: 'ja-JP', label: 'Japanese' }, - { code: 'zh-CN', label: 'Chinese' }, -]; +import { useVoiceCatalog } from '../../../contexts/VoiceCatalogContext'; interface PendingFile { fileId: string; @@ -88,6 +74,7 @@ export const WorkspaceInput: React.FC = ({ instanceId: _ins onDraftAppendConsumed, }) => { const { t } = useLanguage(); + const { languages: voiceCatalogLanguages } = useVoiceCatalog(); const [prompt, setPrompt] = useState(''); const [showAutocomplete, setShowAutocomplete] = useState(false); const [autocompleteFilter, setAutocompleteFilter] = useState(''); @@ -617,23 +604,23 @@ export const WorkspaceInput: React.FC = ({ instanceId: _ins borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20, maxHeight: 240, overflowY: 'auto', minWidth: 160, }}> - {_STT_LANGUAGES.map(lang => ( + {voiceCatalogLanguages.map(lang => (
{ - setVoiceLanguage(lang.code); + setVoiceLanguage(lang.bcp47); setShowLangPicker(false); - fetch('/api/voice/preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.code }) }).catch(() => {}); + fetch('/api/voice/preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.bcp47 }) }).catch(() => {}); }} style={{ padding: '8px 12px', cursor: 'pointer', fontSize: 13, - background: lang.code === voiceLanguage ? 'var(--primary-color, #F25843)' : 'transparent', - color: lang.code === voiceLanguage ? '#fff' : 'var(--text-primary, #333)', + background: lang.bcp47 === voiceLanguage ? 'var(--primary-color, #F25843)' : 'transparent', + color: lang.bcp47 === voiceLanguage ? '#fff' : 'var(--text-primary, #333)', }} - onMouseEnter={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)'; }} - onMouseLeave={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = ''; }} + onMouseEnter={e => { if (lang.bcp47 !== voiceLanguage) e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)'; }} + onMouseLeave={e => { if (lang.bcp47 !== voiceLanguage) e.currentTarget.style.background = ''; }} > - {lang.label} ({lang.code}) + {lang.flag ? `${lang.flag} ` : ''}{lang.label} ({lang.bcp47})
))}