Add Google Gemini TTS as alternative to Edge TTS

- Store Gemini API key globally in appConfig (single key for all users)
- Add /api/settings/gemini GET/POST/DELETE routes for API key management
- Backend: add tts_engine parameter ("edge" or "gemini") to FFmpeg service
- Backend: add generate_tts_gemini() using Google Cloud TTS REST API
- Frontend: Settings page shows Google Gemini API key input card
- Frontend: new-reel, mobile/new-reel, remotion-video, mobile/remotion-video
  pages now have Edge/Gemini engine toggle and French voice selector
- Fix tts-preview route to extract ttsVoice from req.body instead of
  undefined voice variable
This commit is contained in:
Michael committed 2026-05-19 12:17:42 +02:00
1 parent 59bc56aedc
commit 72b13b3e42
12 files changed
+605 -29

No files matched your search

+82 -3
View File
@@ -58,6 +58,29 @@ export default function MobileNewReel() {
// État TTS
const [ttsEnabled, setTtsEnabled] = useState(false);
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
// French Gemini voices
const geminiVoices = [
{ label: 'Français - Standard A (Femme)', value: 'fr-FR-Standard-A' },
{ label: 'Français - Standard B (Homme)', value: 'fr-FR-Standard-B' },
{ label: 'Français - Standard C (Femme)', value: 'fr-FR-Standard-C' },
{ label: 'Français - Standard D (Homme)', value: 'fr-FR-Standard-D' },
{ label: 'Français - Wavenet A (Femme)', value: 'fr-FR-Wavenet-A' },
{ label: 'Français - Wavenet B (Homme)', value: 'fr-FR-Wavenet-B' },
{ label: 'Français - Wavenet C (Femme)', value: 'fr-FR-Wavenet-C' },
{ label: 'Français - Wavenet D (Homme)', value: 'fr-FR-Wavenet-D' },
];
// French Edge TTS voices
const edgeVoices = [
{ label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' },
{ label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' },
{ label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' },
{ label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' },
{ label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' },
];
// État audio preview
const [isPlaying, setIsPlaying] = useState<string | null>(null);
@@ -82,14 +105,15 @@ export default function MobileNewReel() {
const timer = setTimeout(() => {
apiRequest('POST', '/api/reels/sync-info', {
text: overlayText,
voice: "fr-FR-VivienneMultilingualNeural",
ttsEngine,
ttsVoice,
})
.then(r => r.json())
.then(data => setSyncInfo(data))
.catch(() => setSyncInfo(null));
}, 800);
return () => clearTimeout(timer);
}, [overlayText, ttsEnabled]);
}, [overlayText, ttsEnabled, ttsEngine, ttsVoice]);
const { data: pages = [] } = useQuery<SocialPage[]>({
@@ -230,6 +254,8 @@ export default function MobileNewReel() {
stabilize: stabilize,
drawText: true,
ttsEnabled,
ttsEngine,
ttsVoice,
enableEndingEffect,
});
};
@@ -450,7 +476,58 @@ export default function MobileNewReel() {
<Mic className="w-4 h-4" />
<span>TTS — voix activée</span>
</div>
<div className="mt-3">
{/* TTS Engine Selector */}
<div className="flex items-center gap-2 mt-2">
<Label className="text-xs font-medium">Moteur:</Label>
<div className="flex gap-1">
<Button
size="sm"
variant={ttsEngine === 'edge' ? 'default' : 'outline'}
className="h-7 text-xs px-2"
onClick={() => {
setTtsEngine('edge');
setTtsVoice('fr-FR-VivienneMultilingualNeural');
}}
>
Edge
</Button>
<Button
size="sm"
variant={ttsEngine === 'gemini' ? 'default' : 'outline'}
className="h-7 text-xs px-2"
onClick={() => {
setTtsEngine('gemini');
setTtsVoice('fr-FR-Standard-A');
}}
>
Gemini
</Button>
</div>
</div>
{/* Voice Selector */}
<div className="flex items-center gap-2 mt-2">
<Label className="text-xs font-medium shrink-0">Voix:</Label>
<Select value={ttsVoice} onValueChange={setTtsVoice}>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ttsEngine === 'edge' ? (
edgeVoices.map(v => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))
) : (
geminiVoices.map(v => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="mt-2">
<Button
size="sm"
variant="secondary"
@@ -461,6 +538,8 @@ export default function MobileNewReel() {
try {
const response = await apiRequest('POST', '/api/reels/tts-preview', {
text: textToTest,
ttsEngine,
ttsVoice,
});
const data = await response.json();
if (data.success && data.audioBase64) {
+57 -1
View File
@@ -31,6 +31,29 @@ export default function MobileRemotionVideoPage() {
const [productInfo, setProductInfo] = useState("");
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
// French Gemini voices
const geminiVoices = [
{ label: 'Français - Standard A (Femme)', value: 'fr-FR-Standard-A' },
{ label: 'Français - Standard B (Homme)', value: 'fr-FR-Standard-B' },
{ label: 'Français - Standard C (Femme)', value: 'fr-FR-Standard-C' },
{ label: 'Français - Standard D (Homme)', value: 'fr-FR-Standard-D' },
{ label: 'Français - Wavenet A (Femme)', value: 'fr-FR-Wavenet-A' },
{ label: 'Français - Wavenet B (Homme)', value: 'fr-FR-Wavenet-B' },
{ label: 'Français - Wavenet C (Femme)', value: 'fr-FR-Wavenet-C' },
{ label: 'Français - Wavenet D (Homme)', value: 'fr-FR-Wavenet-D' },
];
// French Edge TTS voices
const edgeVoices = [
{ label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' },
{ label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' },
{ label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' },
{ label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' },
{ label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' },
];
const [musicFile, setMusicFile] = useState<File | null>(null);
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
const [musicVolume, setMusicVolume] = useState(0.3);
@@ -111,7 +134,7 @@ export default function MobileRemotionVideoPage() {
const ttsText = stripForTTS(overlayText);
if (!ttsText) return;
try {
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText });
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText, ttsEngine, ttsVoice });
const data = await r.json();
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).play();
} catch { toast({ title: "Erreur prévisualisation voix", variant: "destructive" }); }
@@ -312,6 +335,39 @@ export default function MobileRemotionVideoPage() {
{ttsEnabled && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">TTS — voix activée</p>
{/* TTS Engine Selector */}
<div className="flex items-center gap-2">
<Label className="text-xs font-medium">Moteur:</Label>
<div className="flex gap-1">
<Button size="sm" variant={ttsEngine === 'edge' ? 'default' : 'outline'} className="h-7 text-xs px-2"
onClick={() => { setTtsEngine('edge'); setTtsVoice('fr-FR-VivienneMultilingualNeural'); }}>
Edge
</Button>
<Button size="sm" variant={ttsEngine === 'gemini' ? 'default' : 'outline'} className="h-7 text-xs px-2"
onClick={() => { setTtsEngine('gemini'); setTtsVoice('fr-FR-Standard-A'); }}>
Gemini
</Button>
</div>
</div>
{/* Voice Selector */}
<div className="flex items-center gap-2">
<Label className="text-xs font-medium shrink-0">Voix:</Label>
<Select value={ttsVoice} onValueChange={setTtsVoice}>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ttsEngine === 'edge' ? (
edgeVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
) : (
geminiVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
)}
</SelectContent>
</Select>
</div>
<Button size="sm" variant="outline" className="w-full" onClick={handleTtsPreview} disabled={!overlayText}>
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
</Button>
+83 -3
View File
@@ -78,6 +78,31 @@ export default function NewReel() {
warnings: string[];
} | null>(null);
// TTS Engine & Voice
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
// French Gemini voices
const geminiVoices = [
{ label: 'Français - Standard A (Femme)', value: 'fr-FR-Standard-A' },
{ label: 'Français - Standard B (Homme)', value: 'fr-FR-Standard-B' },
{ label: 'Français - Standard C (Femme)', value: 'fr-FR-Standard-C' },
{ label: 'Français - Standard D (Homme)', value: 'fr-FR-Standard-D' },
{ label: 'Français - Wavenet A (Femme)', value: 'fr-FR-Wavenet-A' },
{ label: 'Français - Wavenet B (Homme)', value: 'fr-FR-Wavenet-B' },
{ label: 'Français - Wavenet C (Femme)', value: 'fr-FR-Wavenet-C' },
{ label: 'Français - Wavenet D (Homme)', value: 'fr-FR-Wavenet-D' },
];
// French Edge TTS voices
const edgeVoices = [
{ label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' },
{ label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' },
{ label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' },
{ label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' },
{ label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' },
];
// Enable TTS by default on mobile
useEffect(() => {
const isMobile = window.innerWidth < 768;
@@ -86,7 +111,7 @@ export default function NewReel() {
}
}, []);
// Auto-calculate TTS sync when text changes (edge_tts fallback)
// Auto-calculate TTS sync when text changes
useEffect(() => {
if (!ttsEnabled || !overlayText.trim()) {
setSyncInfo(null);
@@ -95,14 +120,14 @@ export default function NewReel() {
const timer = setTimeout(() => {
apiRequest('POST', '/api/reels/sync-info', {
text: overlayText,
voice: "fr-FR-VivienneMultilingualNeural",
voice: ttsVoice,
})
.then(r => r.json())
.then(data => setSyncInfo(data))
.catch(() => setSyncInfo(null));
}, 800);
return () => clearTimeout(timer);
}, [overlayText, ttsEnabled]);
}, [overlayText, ttsEnabled, ttsVoice]);
// État audio preview
@@ -360,6 +385,8 @@ export default function NewReel() {
scheduledFor: scheduledDate?.toISOString(),
musicVolume: musicVolume[0] / 100,
ttsEnabled,
ttsEngine,
ttsVoice,
drawText,
stabilize: stabilize,
enableEndingEffect,
@@ -765,6 +792,57 @@ export default function NewReel() {
<span>TTS — voix activée</span>
</div>
{/* TTS Engine Selector */}
<div className="flex items-center gap-4 mt-3">
<Label className="text-sm font-medium">Moteur:</Label>
<div className="flex gap-2">
<Button
size="sm"
variant={ttsEngine === 'edge' ? 'default' : 'outline'}
onClick={() => {
setTtsEngine('edge');
setTtsVoice('fr-FR-VivienneMultilingualNeural');
}}
>
Edge TTS
</Button>
<Button
size="sm"
variant={ttsEngine === 'gemini' ? 'default' : 'outline'}
onClick={() => {
setTtsEngine('gemini');
setTtsVoice('fr-FR-Standard-A');
}}
>
Gemini TTS
</Button>
</div>
</div>
{/* Voice Selector */}
<div className="flex items-center gap-3 mt-3">
<Label className="text-sm font-medium shrink-0">Voix:</Label>
<Select
value={ttsVoice}
onValueChange={setTtsVoice}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ttsEngine === 'edge' ? (
edgeVoices.map(v => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))
) : (
geminiVoices.map(v => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="mt-3 flex gap-2">
<Button
size="sm"
@@ -776,6 +854,8 @@ export default function NewReel() {
try {
const response = await apiRequest('POST', '/api/reels/tts-preview', {
text: textToTest,
ttsEngine,
ttsVoice,
});
const data = await response.json();
if (data.success && data.audioBase64) {
+57 -1
View File
@@ -32,6 +32,29 @@ export default function RemotionVideoPage() {
const [productInfo, setProductInfo] = useState("");
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
// French Gemini voices
const geminiVoices = [
{ label: 'Français - Standard A (Femme)', value: 'fr-FR-Standard-A' },
{ label: 'Français - Standard B (Homme)', value: 'fr-FR-Standard-B' },
{ label: 'Français - Standard C (Femme)', value: 'fr-FR-Standard-C' },
{ label: 'Français - Standard D (Homme)', value: 'fr-FR-Standard-D' },
{ label: 'Français - Wavenet A (Femme)', value: 'fr-FR-Wavenet-A' },
{ label: 'Français - Wavenet B (Homme)', value: 'fr-FR-Wavenet-B' },
{ label: 'Français - Wavenet C (Femme)', value: 'fr-FR-Wavenet-C' },
{ label: 'Français - Wavenet D (Homme)', value: 'fr-FR-Wavenet-D' },
];
// French Edge TTS voices
const edgeVoices = [
{ label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' },
{ label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' },
{ label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' },
{ label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' },
{ label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' },
];
const [musicFile, setMusicFile] = useState<File | null>(null);
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
const [musicVolume, setMusicVolume] = useState(0.3);
@@ -78,7 +101,7 @@ export default function RemotionVideoPage() {
const ttsText = overlayText.replace(/#\w+/g, '').replace(/[\uD800-\uDFFF\u2600-\u27BF]/g, '').replace(/\s+/g, ' ').trim();
if (!ttsText) return;
try {
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText });
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText, ttsEngine, ttsVoice });
const data = await r.json();
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).play();
} catch { toast({ title: "Erreur prévisualisation voix", variant: "destructive" }); }
@@ -253,6 +276,39 @@ export default function RemotionVideoPage() {
{ttsEnabled && (
<div className="space-y-3 p-3 bg-muted/30 rounded-lg border">
<p className="text-sm text-muted-foreground">TTS — voix activée</p>
{/* TTS Engine Selector */}
<div className="flex items-center gap-2">
<Label className="text-xs font-medium">Moteur:</Label>
<div className="flex gap-1">
<Button size="sm" variant={ttsEngine === 'edge' ? 'default' : 'outline'} className="h-7 text-xs"
onClick={() => { setTtsEngine('edge'); setTtsVoice('fr-FR-VivienneMultilingualNeural'); }}>
Edge
</Button>
<Button size="sm" variant={ttsEngine === 'gemini' ? 'default' : 'outline'} className="h-7 text-xs"
onClick={() => { setTtsEngine('gemini'); setTtsVoice('fr-FR-Standard-A'); }}>
Gemini
</Button>
</div>
</div>
{/* Voice Selector */}
<div className="flex items-center gap-2">
<Label className="text-xs font-medium shrink-0">Voix:</Label>
<Select value={ttsVoice} onValueChange={setTtsVoice}>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ttsEngine === 'edge' ? (
edgeVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
) : (
geminiVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
)}
</SelectContent>
</Select>
</div>
<Button size="sm" variant="outline" className="w-full" onClick={handleTtsPreview} disabled={!overlayText}>
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
</Button>
+84
View File
@@ -30,6 +30,7 @@ export default function Settings() {
const [ffmpegApiUrl, setFfmpegApiUrl] = useState("");
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
const [externalApiKey, setExternalApiKey] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState("");
const { toast } = useToast();
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
@@ -60,6 +61,11 @@ export default function Settings() {
enabled: isAdmin,
});
const { data: geminiConfig } = useQuery({
queryKey: ['/api/settings/gemini'],
enabled: isAdmin,
});
useEffect(() => {
if (cloudinaryConfig) {
setCloudName((cloudinaryConfig as any).cloudName || "");
@@ -86,9 +92,16 @@ export default function Settings() {
}
}, [ffmpegConfig]);
useEffect(() => {
if (geminiConfig) {
// Don't pre-fill - user must enter key manually
}
}, [geminiConfig]);
// Vérifier si une config OpenRouter existe déjà
const hasExistingConfig = !!openrouterConfig;
const hasExistingFfmpegConfig = !!ffmpegConfig;
const hasExistingGeminiConfig = !!(geminiConfig as any)?.configured;
const saveCloudinaryMutation = useMutation({
mutationFn: () => {
@@ -157,6 +170,31 @@ export default function Settings() {
},
});
const saveGeminiMutation = useMutation({
mutationFn: () => {
const payload: any = {};
if (geminiApiKey && geminiApiKey.trim() !== "") {
payload.apiKey = geminiApiKey;
}
return apiRequest('POST', '/api/settings/gemini', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/settings/gemini'] });
toast({
title: "Configuration sauvegardée",
description: "Votre clé API Gemini a été enregistrée",
});
setGeminiApiKey("");
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de sauvegarder la configuration Gemini",
variant: "destructive",
});
},
});
const uploadLogoMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
@@ -521,6 +559,52 @@ export default function Settings() {
</CardContent>
</Card>
{/* Google Gemini TTS */}
{isAdmin && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
<Mic className="w-5 h-5" />
Google Gemini TTS
</CardTitle>
<CardDescription>
Configurez votre clé API Google pour utiliser Gemini TTS dans les Reels
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="geminiApiKey">Clé API Google Gemini</Label>
<Input
id="geminiApiKey"
type="password"
value={geminiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)}
placeholder={hasExistingGeminiConfig ? "••••••••••••••••" : "AIza..."}
data-testid="input-gemini-api-key"
/>
<p className="text-xs text-muted-foreground">
{hasExistingGeminiConfig ? (
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée</span>
) : (
"Obtenez une clé API sur console.cloud.google.com → APIs & Services → Credentials"
)}
</p>
</div>
<Button
onClick={() => saveGeminiMutation.mutate()}
disabled={
saveGeminiMutation.isPending ||
(!hasExistingGeminiConfig && !geminiApiKey)
}
data-testid="button-save-gemini"
className="w-full"
>
{saveGeminiMutation.isPending ? "Enregistrement..." : "Enregistrer Gemini TTS"}
</Button>
</CardContent>
</Card>
)}
{isAdmin && (
<Card className="rounded-2xl border-border/50 shadow-lg">