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:
parent
e1e83d7d93
commit
02a8bcb19f
2 changed files with 101 additions and 65 deletions
|
|
@ -91,6 +91,19 @@ export interface ConfigUpdateRequest {
|
|||
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
|
||||
export interface TeamsbotSSEEvent {
|
||||
type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState';
|
||||
|
|
@ -189,6 +202,32 @@ export async function testVoice(
|
|||
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.
|
||||
* Returns the EventSource instance for the caller to manage.
|
||||
|
|
|
|||
|
|
@ -1,54 +1,18 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
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 styles from './Teamsbot.module.css';
|
||||
|
||||
// Common Google TTS voices grouped by language
|
||||
const VOICE_PRESETS: Record<string, Array<{ id: string; label: string }>> = {
|
||||
'de-DE': [
|
||||
{ id: 'de-DE-Wavenet-A', label: 'Frau A (Wavenet)' },
|
||||
{ id: 'de-DE-Wavenet-B', label: 'Mann B (Wavenet)' },
|
||||
{ id: 'de-DE-Wavenet-C', label: 'Frau C (Wavenet)' },
|
||||
{ id: 'de-DE-Wavenet-D', label: 'Mann D (Wavenet)' },
|
||||
{ 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?',
|
||||
};
|
||||
/** Format voice name for display: "de-DE-Wavenet-A" -> "Wavenet A" + gender */
|
||||
function _formatVoiceName(voice: VoiceOption): string {
|
||||
const parts = voice.name.split('-');
|
||||
const type = parts[2] || ''; // Wavenet, Neural2, Standard, etc.
|
||||
const variant = parts[3] || '';
|
||||
const gender = voice.ssmlGender === 'FEMALE' ? 'Frau' : voice.ssmlGender === 'MALE' ? 'Mann' : '';
|
||||
return `${gender} ${variant} (${type})`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* TeamsbotSettingsView - Bot configuration for a feature instance.
|
||||
|
|
@ -68,13 +32,26 @@ export const TeamsbotSettingsView: React.FC = () => {
|
|||
// Form state
|
||||
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 () => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await teamsbotApi.getConfig(instanceId);
|
||||
setConfig(result.config);
|
||||
setFormData(result.config);
|
||||
const [configResult, languagesResult] = await Promise.all([
|
||||
teamsbotApi.getConfig(instanceId),
|
||||
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);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Fehler beim Laden der Konfiguration');
|
||||
|
|
@ -110,12 +87,21 @@ export const TeamsbotSettingsView: React.FC = () => {
|
|||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const _handleLanguageChange = (language: string) => {
|
||||
const _handleLanguageChange = async (language: string) => {
|
||||
_updateField('language', language);
|
||||
// Auto-select first voice for the new language
|
||||
const voices = VOICE_PRESETS[language] || [];
|
||||
if (voices.length > 0 && !voices.find(v => v.id === formData.voiceId)) {
|
||||
_updateField('voiceId', voices[0].id);
|
||||
// Load voices for the new language dynamically
|
||||
setLoadingVoices(true);
|
||||
try {
|
||||
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);
|
||||
try {
|
||||
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);
|
||||
|
||||
if (result.success && result.audio) {
|
||||
|
|
@ -233,14 +220,19 @@ export const TeamsbotSettingsView: React.FC = () => {
|
|||
value={formData.language || 'de-DE'}
|
||||
onChange={(e) => _handleLanguageChange(e.target.value)}
|
||||
>
|
||||
<option value="de-DE">Deutsch (Deutschland)</option>
|
||||
<option value="de-CH">Deutsch (Schweiz)</option>
|
||||
<option value="en-US">English (US)</option>
|
||||
<option value="en-GB">English (UK)</option>
|
||||
<option value="fr-FR">Francais</option>
|
||||
<option value="it-IT">Italiano</option>
|
||||
{languages.length > 0 ? (
|
||||
languages.map(lang => (
|
||||
<option key={lang.code} value={lang.code}>{lang.name} ({lang.code})</option>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<option value="de-DE">Deutsch (Deutschland)</option>
|
||||
<option value="en-US">English (US)</option>
|
||||
<option value="fr-FR">Francais</option>
|
||||
</>
|
||||
)}
|
||||
</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 className={styles.formGroup}>
|
||||
|
|
@ -251,16 +243,19 @@ export const TeamsbotSettingsView: React.FC = () => {
|
|||
style={{ flex: 1 }}
|
||||
value={formData.voiceId || ''}
|
||||
onChange={(e) => _updateField('voiceId', e.target.value)}
|
||||
disabled={loadingVoices}
|
||||
>
|
||||
<option value="">Standard-Stimme</option>
|
||||
{(VOICE_PRESETS[formData.language || 'de-DE'] || []).map(voice => (
|
||||
<option key={voice.id} value={voice.id}>{voice.label} ({voice.id})</option>
|
||||
{voices.map(voice => (
|
||||
<option key={voice.name} value={voice.name}>
|
||||
{_formatVoiceName(voice)} - {voice.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className={styles.testButton || styles.saveButton}
|
||||
onClick={_handleTestVoice}
|
||||
disabled={testingVoice}
|
||||
disabled={testingVoice || loadingVoices}
|
||||
title="Stimme testen"
|
||||
style={{ minWidth: '44px', padding: '8px 12px', display: 'flex', alignItems: 'center', gap: '6px' }}
|
||||
>
|
||||
|
|
@ -268,7 +263,9 @@ export const TeamsbotSettingsView: React.FC = () => {
|
|||
Test
|
||||
</button>
|
||||
</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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue