feat(teamsbot): dynamic language and voice selection from Google TTS API

- Replaced static VOICE_PRESETS with dynamic API calls to /voice-google/languages and /voice-google/voices
- Languages and voices loaded from Google Cloud TTS at runtime
- Voice dropdown updates automatically when language changes
- Added fetchLanguages() and fetchVoices() API functions with VoiceLanguage/VoiceOption types
- Test button uses bot name in sample text for personalized preview
- Fallback to basic language options if API unavailable

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ValueOn AG 2026-02-15 03:31:15 +01:00
parent e1e83d7d93
commit 02a8bcb19f
2 changed files with 101 additions and 65 deletions

View file

@ -91,6 +91,19 @@ export interface ConfigUpdateRequest {
contextWindowSegments?: number; contextWindowSegments?: number;
} }
// 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;
}
// SSE Event Types // SSE Event Types
export interface TeamsbotSSEEvent { export interface TeamsbotSSEEvent {
type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState'; type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState';
@ -189,6 +202,32 @@ export async function testVoice(
return response.data; return response.data;
} }
/**
* Fetch available TTS languages from Google Cloud.
*/
export async function fetchLanguages(): Promise<VoiceLanguage[]> {
try {
const response = await api.get('/voice-google/languages');
return response.data?.languages || [];
} catch {
return [];
}
}
/**
* Fetch available TTS voices for a language from Google Cloud.
*/
export async function fetchVoices(languageCode: string): Promise<VoiceOption[]> {
try {
const response = await api.get('/voice-google/voices', {
params: { languageCode },
});
return response.data?.voices || [];
} catch {
return [];
}
}
/** /**
* Create an SSE EventSource for live session streaming. * Create an SSE EventSource for live session streaming.
* Returns the EventSource instance for the caller to manage. * Returns the EventSource instance for the caller to manage.

View file

@ -1,54 +1,18 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi'; import * as teamsbotApi from '../../../api/teamsbotApi';
import type { TeamsbotConfig, ConfigUpdateRequest } from '../../../api/teamsbotApi'; import type { TeamsbotConfig, ConfigUpdateRequest, VoiceLanguage, VoiceOption } from '../../../api/teamsbotApi';
import { FaPlay, FaSpinner } from 'react-icons/fa'; import { FaPlay, FaSpinner } from 'react-icons/fa';
import styles from './Teamsbot.module.css'; import styles from './Teamsbot.module.css';
// Common Google TTS voices grouped by language /** Format voice name for display: "de-DE-Wavenet-A" -> "Wavenet A" + gender */
const VOICE_PRESETS: Record<string, Array<{ id: string; label: string }>> = { function _formatVoiceName(voice: VoiceOption): string {
'de-DE': [ const parts = voice.name.split('-');
{ id: 'de-DE-Wavenet-A', label: 'Frau A (Wavenet)' }, const type = parts[2] || ''; // Wavenet, Neural2, Standard, etc.
{ id: 'de-DE-Wavenet-B', label: 'Mann B (Wavenet)' }, const variant = parts[3] || '';
{ id: 'de-DE-Wavenet-C', label: 'Frau C (Wavenet)' }, const gender = voice.ssmlGender === 'FEMALE' ? 'Frau' : voice.ssmlGender === 'MALE' ? 'Mann' : '';
{ id: 'de-DE-Wavenet-D', label: 'Mann D (Wavenet)' }, return `${gender} ${variant} (${type})`.trim();
{ id: 'de-DE-Neural2-A', label: 'Frau A (Neural)' }, }
{ id: 'de-DE-Neural2-B', label: 'Mann B (Neural)' },
],
'de-CH': [
{ id: 'de-DE-Wavenet-A', label: 'Frau A (Wavenet)' },
{ id: 'de-DE-Wavenet-B', label: 'Mann B (Wavenet)' },
],
'en-US': [
{ id: 'en-US-Wavenet-C', label: 'Female C (Wavenet)' },
{ id: 'en-US-Wavenet-D', label: 'Male D (Wavenet)' },
{ id: 'en-US-Neural2-C', label: 'Female C (Neural)' },
{ id: 'en-US-Neural2-D', label: 'Male D (Neural)' },
],
'en-GB': [
{ id: 'en-GB-Wavenet-A', label: 'Female A (Wavenet)' },
{ id: 'en-GB-Wavenet-B', label: 'Male B (Wavenet)' },
],
'fr-FR': [
{ id: 'fr-FR-Wavenet-A', label: 'Femme A (Wavenet)' },
{ id: 'fr-FR-Wavenet-B', label: 'Homme B (Wavenet)' },
{ id: 'fr-FR-Neural2-A', label: 'Femme A (Neural)' },
{ id: 'fr-FR-Neural2-D', label: 'Homme D (Neural)' },
],
'it-IT': [
{ id: 'it-IT-Wavenet-A', label: 'Donna A (Wavenet)' },
{ id: 'it-IT-Wavenet-C', label: 'Uomo C (Wavenet)' },
],
};
const TEST_TEXTS: Record<string, string> = {
'de-DE': 'Hallo, ich bin der AI-Assistent. Wie kann ich Ihnen helfen?',
'de-CH': 'Grüezi, ich bin der AI-Assistent. Wie chan ich Ihne helfe?',
'en-US': 'Hello, I am the AI assistant. How can I help you?',
'en-GB': 'Hello, I am the AI assistant. How may I help you?',
'fr-FR': 'Bonjour, je suis l\'assistant IA. Comment puis-je vous aider?',
'it-IT': 'Ciao, sono l\'assistente AI. Come posso aiutarla?',
};
/** /**
* TeamsbotSettingsView - Bot configuration for a feature instance. * TeamsbotSettingsView - Bot configuration for a feature instance.
@ -68,13 +32,26 @@ export const TeamsbotSettingsView: React.FC = () => {
// Form state // Form state
const [formData, setFormData] = useState<ConfigUpdateRequest>({}); const [formData, setFormData] = useState<ConfigUpdateRequest>({});
// Dynamic voice data from Google TTS API
const [languages, setLanguages] = useState<VoiceLanguage[]>([]);
const [voices, setVoices] = useState<VoiceOption[]>([]);
const [loadingVoices, setLoadingVoices] = useState(false);
const _loadConfig = useCallback(async () => { const _loadConfig = useCallback(async () => {
if (!instanceId) return; if (!instanceId) return;
try { try {
setLoading(true); setLoading(true);
const result = await teamsbotApi.getConfig(instanceId); const [configResult, languagesResult] = await Promise.all([
setConfig(result.config); teamsbotApi.getConfig(instanceId),
setFormData(result.config); teamsbotApi.fetchLanguages(),
]);
setConfig(configResult.config);
setFormData(configResult.config);
setLanguages(languagesResult);
// Load voices for the current language
const lang = configResult.config.language || 'de-DE';
const voicesResult = await teamsbotApi.fetchVoices(lang);
setVoices(voicesResult);
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Fehler beim Laden der Konfiguration'); setError(err.message || 'Fehler beim Laden der Konfiguration');
@ -110,12 +87,21 @@ export const TeamsbotSettingsView: React.FC = () => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData(prev => ({ ...prev, [field]: value }));
}; };
const _handleLanguageChange = (language: string) => { const _handleLanguageChange = async (language: string) => {
_updateField('language', language); _updateField('language', language);
// Auto-select first voice for the new language // Load voices for the new language dynamically
const voices = VOICE_PRESETS[language] || []; setLoadingVoices(true);
if (voices.length > 0 && !voices.find(v => v.id === formData.voiceId)) { try {
_updateField('voiceId', voices[0].id); const voicesResult = await teamsbotApi.fetchVoices(language);
setVoices(voicesResult);
// Auto-select first voice if current voice doesn't match
if (voicesResult.length > 0 && !voicesResult.find(v => v.name === formData.voiceId)) {
_updateField('voiceId', voicesResult[0].name);
}
} catch {
setVoices([]);
} finally {
setLoadingVoices(false);
} }
}; };
@ -124,7 +110,8 @@ export const TeamsbotSettingsView: React.FC = () => {
setTestingVoice(true); setTestingVoice(true);
try { try {
const language = formData.language || 'de-DE'; const language = formData.language || 'de-DE';
const testText = TEST_TEXTS[language] || TEST_TEXTS['de-DE']; const botName = formData.botName || 'AI Assistant';
const testText = `Hallo, ich bin ${botName}. So klinge ich in diesem Meeting.`;
const result = await teamsbotApi.testVoice(instanceId, testText, language, formData.voiceId); const result = await teamsbotApi.testVoice(instanceId, testText, language, formData.voiceId);
if (result.success && result.audio) { if (result.success && result.audio) {
@ -233,14 +220,19 @@ export const TeamsbotSettingsView: React.FC = () => {
value={formData.language || 'de-DE'} value={formData.language || 'de-DE'}
onChange={(e) => _handleLanguageChange(e.target.value)} onChange={(e) => _handleLanguageChange(e.target.value)}
> >
<option value="de-DE">Deutsch (Deutschland)</option> {languages.length > 0 ? (
<option value="de-CH">Deutsch (Schweiz)</option> languages.map(lang => (
<option value="en-US">English (US)</option> <option key={lang.code} value={lang.code}>{lang.name} ({lang.code})</option>
<option value="en-GB">English (UK)</option> ))
<option value="fr-FR">Francais</option> ) : (
<option value="it-IT">Italiano</option> <>
<option value="de-DE">Deutsch (Deutschland)</option>
<option value="en-US">English (US)</option>
<option value="fr-FR">Francais</option>
</>
)}
</select> </select>
<span className={styles.hint}>Sprache fuer Captions und Sprachausgabe</span> <span className={styles.hint}>Sprache fuer Captions und Sprachausgabe ({languages.length} Sprachen verfuegbar)</span>
</div> </div>
<div className={styles.formGroup}> <div className={styles.formGroup}>
@ -251,16 +243,19 @@ export const TeamsbotSettingsView: React.FC = () => {
style={{ flex: 1 }} style={{ flex: 1 }}
value={formData.voiceId || ''} value={formData.voiceId || ''}
onChange={(e) => _updateField('voiceId', e.target.value)} onChange={(e) => _updateField('voiceId', e.target.value)}
disabled={loadingVoices}
> >
<option value="">Standard-Stimme</option> <option value="">Standard-Stimme</option>
{(VOICE_PRESETS[formData.language || 'de-DE'] || []).map(voice => ( {voices.map(voice => (
<option key={voice.id} value={voice.id}>{voice.label} ({voice.id})</option> <option key={voice.name} value={voice.name}>
{_formatVoiceName(voice)} - {voice.name}
</option>
))} ))}
</select> </select>
<button <button
className={styles.testButton || styles.saveButton} className={styles.testButton || styles.saveButton}
onClick={_handleTestVoice} onClick={_handleTestVoice}
disabled={testingVoice} disabled={testingVoice || loadingVoices}
title="Stimme testen" title="Stimme testen"
style={{ minWidth: '44px', padding: '8px 12px', display: 'flex', alignItems: 'center', gap: '6px' }} style={{ minWidth: '44px', padding: '8px 12px', display: 'flex', alignItems: 'center', gap: '6px' }}
> >
@ -268,7 +263,9 @@ export const TeamsbotSettingsView: React.FC = () => {
Test Test
</button> </button>
</div> </div>
<span className={styles.hint}>Google TTS Stimme - klicke Test um eine Vorschau zu hoeren</span> <span className={styles.hint}>
{loadingVoices ? 'Lade Stimmen...' : `${voices.length} Stimmen verfuegbar - klicke Test fuer Vorschau`}
</span>
</div> </div>
</div> </div>