Qwen TTS : serveur GPU distant (Unraid) réglé dans les paramètres

- qwen-tts : GPU NVIDIA détecté automatiquement (bfloat16), image CUDA 12.8
  (RTX 50xx comprises) via TORCH_VARIANT=cu128, docker-compose.gpu.yml
  dédié (clé obligatoire, choix de la carte) et guide d'installation Unraid.
- Paramètres → « Qwen TTS (voix locale) » : adresse + clé du service, test
  de connexion (modèle et carte graphique affichés), stockés en base
  (app_config.qwen_tts_url / qwen_tts_api_key) et transmis à ffmpeg-api à
  chaque voix ; QWEN_TTS_URL reste un défaut.
- Le service qwen-tts CPU du docker-compose principal devient optionnel
  (profil qwen-local).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Upu97wMmsRkBoj6iVM4rH6
This commit is contained in:
Claude committed 2026-09-29 09:57:32 +00:00
1 parent e312733d43
commit fdace9d8d5
24 files changed
+494 -51

No files matched your search

@@ -0,0 +1,156 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Cpu, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, getErrorMessage, queryClient } from "@/lib/queryClient";
interface QwenConfig {
url: string;
hasApiKey: boolean;
}
interface QwenTestResult {
ok: boolean;
error?: string;
model?: string;
device?: string;
voices?: number;
}
/**
* Réglages du service Qwen3-TTS : voix locale gratuite, idéalement sur un
* serveur avec carte graphique (ex. Unraid). C'est le service FFmpeg qui
* l'appelle : le test vérifie donc la connexion depuis celui-ci.
*/
export function QwenSettingsCard() {
const { toast } = useToast();
const [url, setUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [test, setTest] = useState<QwenTestResult | null>(null);
const { data: config } = useQuery<QwenConfig>({ queryKey: ["/api/settings/qwen"] });
useEffect(() => {
if (config) setUrl(config.url);
}, [config]);
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["/api/settings/qwen"] });
// Le choix du moteur « Qwen » dépend de la disponibilité du service
queryClient.invalidateQueries({ queryKey: ["/api/reels/voices"] });
};
const save = useMutation({
mutationFn: () => apiRequest("POST", "/api/settings/qwen", { url, apiKey }),
onSuccess: () => {
refresh();
setApiKey("");
toast({ title: "Configuration sauvegardée", description: "Service Qwen TTS enregistré" });
},
onError: (error) =>
toast({
title: "Erreur",
description: getErrorMessage(error, "Impossible de sauvegarder la configuration Qwen"),
variant: "destructive",
}),
});
const remove = useMutation({
mutationFn: () => apiRequest("DELETE", "/api/settings/qwen"),
onSuccess: () => {
refresh();
setUrl("");
setApiKey("");
setTest(null);
toast({ title: "Configuration supprimée", description: "Service Qwen TTS retiré" });
},
});
const runTest = useMutation({
mutationFn: async () => {
const response = await apiRequest("POST", "/api/settings/qwen/test", { url, apiKey });
return (await response.json()) as QwenTestResult;
},
onSuccess: setTest,
onError: (error) => setTest({ ok: false, error: getErrorMessage(error, "Test impossible") }),
});
return (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
<Cpu className="w-5 h-5" />
Qwen TTS (voix locale)
</CardTitle>
<CardDescription>
Voix française gratuite et expressive, générée par votre propre serveur (idéalement avec carte
graphique NVIDIA, ex. Unraid). Voir qwen-tts/docker-compose.gpu.yml.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="qwenTtsUrl">Adresse du service</Label>
<Input
id="qwenTtsUrl"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="http://192.168.1.20:8001"
data-testid="input-qwen-url"
/>
</div>
<div className="space-y-2">
<Label htmlFor="qwenTtsApiKey">Clé d'accès (API_KEY du service)</Label>
<Input
id="qwenTtsApiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={config?.hasApiKey ? "••••••••••••••••" : "Clé définie sur le serveur Qwen"}
data-testid="input-qwen-api-key"
/>
{config?.hasApiKey && (
<p className="text-xs text-green-600 dark:text-green-400">✓ Clé enregistrée (laisser vide pour la garder)</p>
)}
</div>
{test && (
<p className={`text-sm ${test.ok ? "text-green-600 dark:text-green-400" : "text-destructive"}`}>
{test.ok
? `✓ Connecté : ${test.device ?? "?"} — ${test.model ?? ""} (${test.voices ?? 0} voix)`
: `✗ ${test.error}`}
</p>
)}
<div className="flex flex-col gap-2 sm:flex-row">
<Button
variant="secondary"
className="flex-1"
onClick={() => runTest.mutate()}
disabled={runTest.isPending || (!url && !config?.url)}
data-testid="button-test-qwen"
>
{runTest.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Tester la connexion
</Button>
<Button
className="flex-1"
onClick={() => save.mutate()}
disabled={save.isPending || !url}
data-testid="button-save-qwen"
>
{save.isPending ? "Enregistrement..." : "Enregistrer Qwen TTS"}
</Button>
{config?.url && (
<Button variant="outline" onClick={() => remove.mutate()} disabled={remove.isPending}>
Retirer
</Button>
)}
</div>
</CardContent>
</Card>
);
}
+1 -1
View File
@@ -148,7 +148,7 @@ export function VoicePicker({ value, onChange, sampleText, compact = false, onPr
className={compact ? "h-7 text-xs" : undefined}
variant={value.engine === "qwen" ? "default" : "outline"}
disabled={!qwenAvailable && value.engine !== "qwen"}
title={qwenAvailable ? "Voix locale, gratuite, plus lente à générer" : "Service Qwen TTS non démarré"}
title={qwenAvailable ? "Voix locale, gratuite, plus lente à générer" : "Service Qwen TTS non configuré ou injoignable (Paramètres → Qwen TTS)"}
onClick={() => setEngine("qwen")}
>
Qwen (locale)