mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 15:08:13 +02:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
338546662f | ||
|
|
90a9b6be4d | ||
|
|
90f936cf4f | ||
|
|
5f271e0831 | ||
|
|
fdace9d8d5 | ||
|
|
e312733d43 | ||
|
|
44daa88a49 | ||
|
|
4b8fbe6d8e |
No files matched your search
@@ -132,6 +132,21 @@ GEMINI_API_KEY=
|
||||
# demande de reconstruire ffmpeg-api.
|
||||
# WHISPER_MODEL=base
|
||||
#
|
||||
# Voix Qwen3-TTS : gratuite, française, sans quota. Le plus simple : l'installer
|
||||
# sur un serveur avec carte graphique NVIDIA (qwen-tts/docker-compose.gpu.yml,
|
||||
# ex. Unraid) puis régler l'adresse dans Paramètres → Qwen TTS.
|
||||
# Sinon, service CPU sur ce serveur (lent : 5 à 7 fois la durée de la voix) :
|
||||
# docker compose --profile qwen-local up -d avec QWEN_TTS_URL=http://qwen-tts:8001
|
||||
# QWEN_TTS_URL=
|
||||
# Modèle choisi à la construction de l'image :
|
||||
# - Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice (défaut) : ton et émotion pilotés ;
|
||||
# - Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice : ~30 % plus rapide, moins expressif.
|
||||
# QWEN_TTS_MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
|
||||
# Clonage des voix déposées dans qwen-tts/voices (vide pour désactiver).
|
||||
# QWEN_TTS_CLONE_MODEL=Qwen/Qwen3-TTS-12Hz-0.6B-Base
|
||||
# QWEN_TTS_THREADS=0
|
||||
# QWEN_TTS_MEMORY=10g
|
||||
#
|
||||
# Moteur de rendu des Reels vidéo :
|
||||
# - remotion (défaut) : sous-titres animés, logo et effet de fin identiques à
|
||||
# l'aperçu affiché dans l'application ; ~30 à 60 s pour un Reel de 30 s ;
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, Play, Square } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
voicesFor,
|
||||
type TtsEngine,
|
||||
type TtsStyle,
|
||||
type VoiceOption,
|
||||
} from "@shared/voices";
|
||||
import type { TimedWord } from "@shared/captions";
|
||||
|
||||
@@ -76,7 +78,13 @@ export function VoicePicker({ value, onChange, sampleText, compact = false, onPr
|
||||
|
||||
useEffect(() => () => audioRef.current?.pause(), []);
|
||||
|
||||
const voices = voicesFor(value.engine);
|
||||
// Voix Qwen réellement installées (voix clonées comprises) ; moteur grisé si le service local ne répond pas
|
||||
const { data: qwenCatalog } = useQuery<{ qwenAvailable: boolean; qwen: VoiceOption[] }>({
|
||||
queryKey: ["/api/reels/voices"],
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const qwenAvailable = qwenCatalog?.qwenAvailable ?? false;
|
||||
const voices = value.engine === "qwen" && qwenCatalog?.qwen.length ? qwenCatalog.qwen : voicesFor(value.engine);
|
||||
const groups = [
|
||||
{ label: "Voix féminines", items: voices.filter((v) => v.gender === "female") },
|
||||
{ label: "Voix masculines", items: voices.filter((v) => v.gender === "male") },
|
||||
@@ -133,7 +141,18 @@ export function VoicePicker({ value, onChange, sampleText, compact = false, onPr
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className={`${labelClass} w-14 shrink-0`}>Moteur</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
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 configuré ou injoignable (Paramètres → Qwen TTS)"}
|
||||
onClick={() => setEngine("qwen")}
|
||||
>
|
||||
Qwen (locale)
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { ModelCombobox } from "@/components/model-combobox";
|
||||
import { QwenSettingsCard } from "@/components/reels/qwen-settings-card";
|
||||
import { SiTiktok } from "react-icons/si";
|
||||
|
||||
export default function Settings() {
|
||||
@@ -695,6 +696,9 @@ export default function Settings() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Qwen3-TTS (serveur local ou GPU distant) */}
|
||||
{isAdmin && <QwenSettingsCard />}
|
||||
|
||||
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
|
||||
@@ -86,11 +86,49 @@ services:
|
||||
environment:
|
||||
API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||
GEMINI_TTS_MODEL: ${GEMINI_TTS_MODEL:-gemini-2.5-flash-preview-tts}
|
||||
# Service Qwen TTS par défaut. Normalement réglé dans l'application
|
||||
# (Paramètres → Qwen TTS, ex. serveur GPU Unraid) ; pour le service local
|
||||
# ci-dessous : QWEN_TTS_URL=http://qwen-tts:8001
|
||||
QWEN_TTS_URL: ${QWEN_TTS_URL:-}
|
||||
QWEN_TTS_API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||
networks:
|
||||
- internal
|
||||
expose:
|
||||
- "8000"
|
||||
|
||||
# Voix Qwen3-TTS sur ce serveur, en CPU (optionnel, lent : 5 à 7 fois la
|
||||
# durée de la voix sur 4 cœurs). Sur un serveur GPU, préférez
|
||||
# qwen-tts/docker-compose.gpu.yml. Démarrage : docker compose --profile qwen-local up -d
|
||||
qwen-tts:
|
||||
profiles: ["qwen-local"]
|
||||
build:
|
||||
context: ./qwen-tts
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# 1.7B : ton et émotion pilotés par consigne ; 0.6B : ~30 % plus rapide
|
||||
QWEN_TTS_MODEL: ${QWEN_TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice}
|
||||
# Clonage des voix déposées dans qwen-tts/voices (vide : désactivé)
|
||||
QWEN_TTS_CLONE_MODEL: ${QWEN_TTS_CLONE_MODEL-Qwen/Qwen3-TTS-12Hz-0.6B-Base}
|
||||
image: socialflow-qwen-tts:latest
|
||||
pull_policy: build
|
||||
container_name: socialflow-qwen-tts
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||
# Threads de calcul (0 : tous les cœurs)
|
||||
QWEN_TTS_THREADS: ${QWEN_TTS_THREADS:-0}
|
||||
volumes:
|
||||
# Voix clonées ajoutables sans reconstruire l'image
|
||||
- ./qwen-tts/voices:/app/voices:ro
|
||||
networks:
|
||||
- internal
|
||||
expose:
|
||||
- "8001"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${QWEN_TTS_MEMORY:-10g}
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
@@ -116,12 +116,22 @@ def _model():
|
||||
return WhisperModel(config.WHISPER_MODEL, device="cpu", compute_type=config.WHISPER_COMPUTE_TYPE)
|
||||
|
||||
|
||||
# Silence ajouté avant la voix pour Whisper : sur une voix qui démarre
|
||||
# aussitôt, il laissait souvent passer le premier mot (« Alerte »).
|
||||
LEAD_SILENCE = 0.5
|
||||
|
||||
|
||||
def _transcribe_sync(audio: Path) -> list[Word]:
|
||||
import numpy as np
|
||||
from faster_whisper.audio import decode_audio
|
||||
|
||||
samples = decode_audio(str(audio), sampling_rate=16000)
|
||||
padded = np.concatenate([np.zeros(int(16000 * LEAD_SILENCE), dtype=samples.dtype), samples])
|
||||
# Pas de texte attendu en amorce (initial_prompt) ni d'enchaînement sur le
|
||||
# segment précédent : les deux font halluciner Whisper, qui répète la phrase
|
||||
# et masque une fin tronquée. On veut entendre ce qui est réellement dit.
|
||||
segments, _ = _model().transcribe(
|
||||
str(audio),
|
||||
padded,
|
||||
language="fr",
|
||||
word_timestamps=True,
|
||||
condition_on_previous_text=False,
|
||||
@@ -129,7 +139,7 @@ def _transcribe_sync(audio: Path) -> list[Word]:
|
||||
beam_size=5,
|
||||
)
|
||||
return [
|
||||
Word(w.word.strip(), float(w.start), float(w.end))
|
||||
Word(w.word.strip(), max(0.0, w.start - LEAD_SILENCE), max(0.0, w.end - LEAD_SILENCE))
|
||||
for segment in segments
|
||||
for w in (segment.words or [])
|
||||
if w.word.strip()
|
||||
|
||||
@@ -56,6 +56,9 @@ class TtsRequest(BaseModel):
|
||||
tts_engine: str | None = "gemini"
|
||||
tts_style: str | None = None
|
||||
gemini_api_key: str | None = None
|
||||
# Service Qwen TTS réglé dans les paramètres (sinon : variables d'environnement)
|
||||
qwen_tts_url: str | None = None
|
||||
qwen_tts_api_key: str | None = None
|
||||
|
||||
|
||||
class SrtCue(BaseModel):
|
||||
@@ -80,6 +83,8 @@ class ReelRequest(BaseModel):
|
||||
tts_engine: str | None = "gemini"
|
||||
tts_style: str | None = None
|
||||
gemini_api_key: str | None = None
|
||||
qwen_tts_url: str | None = None
|
||||
qwen_tts_api_key: str | None = None
|
||||
draw_text: bool = True
|
||||
stabilize: bool = False
|
||||
enable_ending_effect: bool = True
|
||||
@@ -99,8 +104,14 @@ async def health():
|
||||
|
||||
|
||||
@app.get("/voices", dependencies=[Depends(require_key)])
|
||||
async def list_voices():
|
||||
return voices.catalog()
|
||||
async def list_voices(x_qwen_url: str | None = Header(None), x_qwen_key: str | None = Header(None)):
|
||||
"""Voix par moteur ; l'adresse du service Qwen réglée dans l'application
|
||||
arrive en en-têtes (une clé n'a rien à faire dans une URL)."""
|
||||
qwen_status = await tts.qwen.status(tts.qwen.Target.resolve(x_qwen_url, x_qwen_key))
|
||||
return {
|
||||
**voices.catalog(qwen_status["voices"] if qwen_status else None),
|
||||
"qwen_status": {k: v for k, v in (qwen_status or {}).items() if k != "voices"} or None,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/preview-tts", dependencies=[Depends(require_key)])
|
||||
@@ -187,6 +198,7 @@ async def _synthesize(text: str, display_source: str | None, request, workdir: P
|
||||
style=request.tts_style,
|
||||
gemini_api_key=request.gemini_api_key,
|
||||
workdir=workdir,
|
||||
qwen_target=tts.qwen.Target.resolve(request.qwen_tts_url, request.qwen_tts_api_key),
|
||||
)
|
||||
except Exception as error:
|
||||
log.exception("Voix impossible à générer")
|
||||
@@ -212,6 +224,7 @@ async def _synthesize_srt(cues: list[srt_voice.Cue], request, workdir: Path) ->
|
||||
style=request.tts_style,
|
||||
gemini_api_key=request.gemini_api_key,
|
||||
workdir=workdir,
|
||||
qwen_target=tts.qwen.Target.resolve(request.qwen_tts_url, request.qwen_tts_api_key),
|
||||
)
|
||||
except Exception as error:
|
||||
log.exception("Voix SRT impossible à générer")
|
||||
|
||||
@@ -36,3 +36,11 @@ FPS = 30
|
||||
VOICE_DELAY = 2.0
|
||||
|
||||
SUBTITLE_FONT = os.environ.get("SUBTITLE_FONT", "Montserrat")
|
||||
|
||||
# Service Qwen3-TTS par défaut (service qwen-tts, local ou serveur GPU) ; l'adresse
|
||||
# réglée dans les paramètres de l'application est prioritaire. Vide : aucun.
|
||||
QWEN_TTS_URL = os.environ.get("QWEN_TTS_URL", "").rstrip("/")
|
||||
# Sur CPU, la génération prend plusieurs fois la durée de la voix
|
||||
QWEN_TTS_TIMEOUT = float(os.environ.get("QWEN_TTS_TIMEOUT", "900"))
|
||||
QWEN_TTS_ATTEMPTS = int(os.environ.get("QWEN_TTS_ATTEMPTS", "3"))
|
||||
QWEN_TTS_API_KEY = os.environ.get("QWEN_TTS_API_KEY", "")
|
||||
@@ -9,6 +9,7 @@ pas rogner une syllabe.
|
||||
|
||||
import asyncio
|
||||
import difflib
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
@@ -49,8 +50,8 @@ def check_reading(expected_text: str, spoken: list[Word]) -> ReadingCheck:
|
||||
lettre : les petites erreurs de reconnaissance (« petit » pour « petits »,
|
||||
« week -end » découpé) ne comptent pas comme des mots manquants, alors
|
||||
qu'un mot sauté, une fin tronquée ou une phrase ajoutée se voient."""
|
||||
expected = "".join(normalize(w) for w in expected_text.split())
|
||||
heard = "".join(normalize(w.text) for w in spoken)
|
||||
expected = "".join(comparable_tokens(expected_text.split()))
|
||||
heard = "".join(comparable_tokens([w.text for w in spoken]))
|
||||
if not expected:
|
||||
return ReadingCheck(1.0, 0.0, True)
|
||||
if not heard:
|
||||
@@ -70,13 +71,72 @@ def check_reading(expected_text: str, spoken: list[Word]) -> ReadingCheck:
|
||||
)
|
||||
|
||||
|
||||
# Unités écrites de plusieurs façons par Whisper (« 40 cm » pour « 40 centimètres »)
|
||||
UNITS = {
|
||||
"e", "eur", "euro", "euros", "centime", "centimes", "cm", "centimetre", "centimetres",
|
||||
"mm", "millimetre", "millimetres", "m", "metre", "metres", "km", "kilometre", "kilometres",
|
||||
"g", "gramme", "grammes", "kg", "kilo", "kilos", "kilogramme", "kilogrammes",
|
||||
"l", "litre", "litres", "cl", "centilitre", "centilitres", "ml", "millilitre", "millilitres",
|
||||
"pourcent", "pourcents", "x", "fois",
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
# Nombres écrits en lettres : Whisper les écrit en chiffres (« trente » → « 30 »).
|
||||
# « un »/« une » n'y sont pas : ce sont le plus souvent des articles.
|
||||
NUMBER_WORDS = {
|
||||
"zero", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", "dix",
|
||||
"onze", "douze", "treize", "quatorze", "quinze", "seize", "vingt", "vingts",
|
||||
"trente", "quarante", "cinquante", "soixante", "septante", "octante", "nonante",
|
||||
"cent", "cents", "mille", "million", "millions", "milliard", "milliards",
|
||||
} # fmt: skip
|
||||
# Signe avant un nombre : « +30 » est lu « plus trente »
|
||||
SIGNS = {"plus", "moins"}
|
||||
|
||||
|
||||
# Nombres composés, écrits avec traits d'union puis normalisés (« trentecinq »)
|
||||
_COMPOUND_NUMBER = re.compile(
|
||||
"^(?:" + "|".join(sorted(NUMBER_WORDS | {"et", "un", "une"}, key=len, reverse=True)) + ")+$"
|
||||
)
|
||||
|
||||
|
||||
def _is_number(token: str) -> bool:
|
||||
if any(c.isdigit() for c in token) or token in NUMBER_WORDS:
|
||||
return True
|
||||
return token not in {"un", "une", "et"} and bool(_COMPOUND_NUMBER.match(token))
|
||||
|
||||
|
||||
def comparable_tokens(words: list[str]) -> list[str]:
|
||||
"""Mots normalisés, sans les nombres (en chiffres ou en lettres), leur signe
|
||||
ni l'unité qui les suit : Whisper écrit un prix ou une mesure à sa façon
|
||||
(« 11 € 99 », « 11,99 euros », « 40 cm », « 30 » pour « trente »), ce qui
|
||||
passait pour des mots sautés et relançait la génération."""
|
||||
normalized = [t for t in (normalize(w) for w in words) if t]
|
||||
tokens: list[str] = []
|
||||
after_number = False
|
||||
for index, token in enumerate(normalized):
|
||||
if _is_number(token):
|
||||
after_number = True
|
||||
continue
|
||||
following = normalized[index + 1] if index + 1 < len(normalized) else ""
|
||||
if token in SIGNS and _is_number(following):
|
||||
continue
|
||||
if after_number and token == "pour" and following in {"cent", "cents"}:
|
||||
continue # « trente pour cent » ↔ « 30 % »
|
||||
if after_number and token in UNITS:
|
||||
continue
|
||||
after_number = False
|
||||
tokens.append(token)
|
||||
return tokens
|
||||
|
||||
|
||||
def _missing_words(expected_text: str, spoken: list[Word]) -> list[str]:
|
||||
"""Mots attendus sans équivalent proche dans ce qui a été entendu."""
|
||||
heard = {normalize(w.text) for w in spoken}
|
||||
heard = set(comparable_tokens([w.text for w in spoken]))
|
||||
kept = set(comparable_tokens(expected_text.split()))
|
||||
missing = []
|
||||
for word in expected_text.split():
|
||||
token = normalize(word)
|
||||
if token and not any(difflib.SequenceMatcher(a=token, b=h).ratio() >= 0.7 for h in heard):
|
||||
if token in kept and not any(difflib.SequenceMatcher(a=token, b=h).ratio() >= 0.7 for h in heard):
|
||||
missing.append(word.strip(".,!?;:…"))
|
||||
return missing
|
||||
|
||||
@@ -90,6 +150,10 @@ ACTIVE_RANGE_DB = 38.0
|
||||
# Marges gardées autour de la parole
|
||||
LEAD_PAD = 0.08
|
||||
TAIL_PAD = 0.30
|
||||
# Pause entre deux phrases : au moins 80 ms de silence, cherchée jusqu'à 1,2 s
|
||||
# de la borne estimée par Whisper
|
||||
MIN_PAUSE = 0.08
|
||||
PAUSE_SEARCH = 1.2
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -139,6 +203,19 @@ class Envelope:
|
||||
active = np.nonzero(self.db[first:last] > self.threshold)[0]
|
||||
return (first + int(active[-1]) + 1) * FRAME if len(active) else None
|
||||
|
||||
def pauses(self, min_length: float = MIN_PAUSE) -> list[tuple[float, float]]:
|
||||
"""Silences (sous le seuil d'audibilité) d'au moins min_length secondes."""
|
||||
quiet = self.db <= self.threshold
|
||||
runs, run_start = [], None
|
||||
for i, is_quiet in enumerate([*quiet, False]):
|
||||
if is_quiet and run_start is None:
|
||||
run_start = i
|
||||
elif not is_quiet and run_start is not None:
|
||||
if (i - run_start) * FRAME >= min_length:
|
||||
runs.append((run_start * FRAME, i * FRAME))
|
||||
run_start = None
|
||||
return runs
|
||||
|
||||
def speech_span(self, start: float, end: float) -> tuple[float, float]:
|
||||
"""Parole contenue dans [start, end], avec ses marges, sans en sortir."""
|
||||
onset, offset = self.first_sound(start, end), self.last_sound(start, end)
|
||||
@@ -170,9 +247,26 @@ async def load_envelope(path: Path) -> Envelope:
|
||||
return envelope_from_samples(np.frombuffer(raw, dtype=np.float32), 16000)
|
||||
|
||||
|
||||
def sentence_cut(envelope: Envelope, previous_end: float, next_start: float) -> float:
|
||||
"""Coupe entre deux phrases, au point le plus calme de la pause. Les bornes
|
||||
Whisper sont imprécises : on cherche un peu autour."""
|
||||
low = max(0.0, min(previous_end, next_start) - 0.1)
|
||||
high = max(previous_end, next_start) + 0.2
|
||||
def sentence_cut(envelope: Envelope, previous_end: float, next_start: float, after: float = 0.0) -> float:
|
||||
"""Coupe entre deux phrases, au cœur d'une vraie pause.
|
||||
|
||||
Les bornes Whisper sont imprécises, et fausses quand un mot n'a pas été
|
||||
reconnu (un prix en fin de phrase : sa place est alors estimée) ; couper
|
||||
à la borne estimée tombait parfois au milieu d'une phrase. On choisit donc
|
||||
la pause mesurée la plus proche de la borne estimée, les longues pauses
|
||||
(fin de phrase) étant préférées aux courtes (virgule)."""
|
||||
low = max(after, min(previous_end, next_start) - 0.1)
|
||||
high = max(low + 0.05, max(previous_end, next_start) + 0.2)
|
||||
best: tuple[float, float, float] | None = None
|
||||
for start, end in envelope.pauses():
|
||||
if end <= after + 0.05 or start <= 0.0 or end >= envelope.duration:
|
||||
continue # silences avant la première et après la dernière phrase exclus
|
||||
distance = max(0.0, low - end, start - high)
|
||||
if distance > PAUSE_SEARCH:
|
||||
continue
|
||||
score = distance - (end - start)
|
||||
if best is None or score < best[0]:
|
||||
best = (score, max(start, after), end)
|
||||
if best:
|
||||
return envelope.quietest(best[1], best[2])
|
||||
return envelope.quietest(low, high)
|
||||
@@ -11,8 +11,10 @@ Si la lecture unique ne peut pas être répartie entre les sous-titres, on lit
|
||||
chaque sous-titre séparément (même voix, même graine), en découpant là aussi
|
||||
au niveau sonore mesuré.
|
||||
|
||||
Quand un morceau est plus long que son sous-titre, il est accéléré (sans
|
||||
changer la hauteur de la voix) pour finir à temps.
|
||||
La voix n'est jamais accélérée : quand un morceau est plus long que son
|
||||
sous-titre, il déborde et les morceaux suivants sont décalés d'autant (le
|
||||
minutage SRT n'est qu'un instant de départ au plus tôt). Les sous-titres
|
||||
affichés suivent la voix et la vidéo s'allonge si besoin.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -26,10 +28,10 @@ from .text import clean_text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Débordement toléré sur le silence qui suit un sous-titre avant d'accélérer
|
||||
OVERFLOW_TOLERANCE = 0.25
|
||||
# Au-delà, l'accélération s'entend nettement : l'utilisateur est prévenu
|
||||
AUDIBLE_SPEEDUP = 1.35
|
||||
# Silence ajouté entre deux morceaux décalés (en plus des marges de chaque morceau)
|
||||
MIN_GAP = 0.05
|
||||
# Au-delà de ce retard cumulé sur le minutage SRT, l'utilisateur est prévenu
|
||||
DRIFT_WARNING = 1.0
|
||||
|
||||
_SENTENCE_END = re.compile(r"[.!?…]$")
|
||||
|
||||
@@ -50,31 +52,16 @@ class Segment:
|
||||
words: list[Word]
|
||||
|
||||
|
||||
def cue_windows(cues: list[Cue]) -> list[float]:
|
||||
"""Durée disponible pour lire chaque sous-titre sans empiéter sur le suivant."""
|
||||
windows = []
|
||||
for i, cue in enumerate(cues):
|
||||
limit = cue.end + OVERFLOW_TOLERANCE
|
||||
if i + 1 < len(cues):
|
||||
limit = min(limit, max(cue.end, cues[i + 1].start))
|
||||
windows.append(max(0.1, limit - cue.start))
|
||||
return windows
|
||||
|
||||
|
||||
def speed_factor(duration: float, window: float) -> float:
|
||||
"""Accélération nécessaire pour tenir dans la fenêtre (1 = vitesse normale)."""
|
||||
return max(1.0, duration / window) if window > 0 else 1.0
|
||||
|
||||
|
||||
def atempo_chain(factor: float) -> str:
|
||||
"""Filtre atempo ; découpé en étapes ≤ 2 pour les anciennes versions de FFmpeg."""
|
||||
steps = []
|
||||
remaining = factor
|
||||
while remaining > 2.0:
|
||||
steps.append(2.0)
|
||||
remaining /= 2.0
|
||||
steps.append(remaining)
|
||||
return ",".join(f"atempo={s:.4f}" for s in steps)
|
||||
def place_segments(cues: list[Cue], durations: list[float]) -> list[float]:
|
||||
"""Instant de départ de chaque morceau : celui de son sous-titre, ou juste
|
||||
après le morceau précédent s'il déborde (vitesse naturelle conservée)."""
|
||||
starts: list[float] = []
|
||||
previous_end = 0.0
|
||||
for cue, duration in zip(cues, durations):
|
||||
start = cue.start if not starts else max(cue.start, previous_end + MIN_GAP)
|
||||
starts.append(start)
|
||||
previous_end = start + duration
|
||||
return starts
|
||||
|
||||
|
||||
def as_sentence(text: str) -> str:
|
||||
@@ -103,7 +90,7 @@ def segments_from_reading(groups: list[list[Word]], envelope: quality.Envelope)
|
||||
marge (300 ms après le dernier son : les fins de phrases ne sont pas rognées)."""
|
||||
cuts = [0.0]
|
||||
for previous, following in zip(groups, groups[1:]):
|
||||
cuts.append(quality.sentence_cut(envelope, previous[-1].end, following[0].start))
|
||||
cuts.append(quality.sentence_cut(envelope, previous[-1].end, following[0].start, after=cuts[-1]))
|
||||
cuts.append(envelope.duration)
|
||||
if any(b <= a for a, b in zip(cuts, cuts[1:])):
|
||||
return None
|
||||
@@ -123,13 +110,16 @@ async def synthesize_cues(
|
||||
style: str | None,
|
||||
gemini_api_key: str | None,
|
||||
workdir: Path,
|
||||
qwen_target: tts.qwen.Target | None = None,
|
||||
) -> tts.VoiceTrack:
|
||||
"""Voix complète : mots minutés en secondes depuis le début de la vidéo."""
|
||||
cues = sorted((c for c in cues if clean_text(c.text)), key=lambda c: c.start)
|
||||
if not cues:
|
||||
raise ValueError("Aucun sous-titre lisible dans le fichier SRT")
|
||||
|
||||
options = dict(engine=engine, voice=voice, style=style, gemini_api_key=gemini_api_key)
|
||||
options = dict(
|
||||
engine=engine, voice=voice, style=style, gemini_api_key=gemini_api_key, qwen_target=qwen_target
|
||||
)
|
||||
texts = [as_sentence(c.text) for c in cues]
|
||||
|
||||
# 1. Lecture d'une seule traite, découpée dans les silences
|
||||
@@ -162,26 +152,30 @@ async def synthesize_cues(
|
||||
span = cue_envelope.speech_span(0.0, cue_envelope.duration)
|
||||
sources.append((track.path, Segment(span[0], span[1], track.words)))
|
||||
|
||||
durations = [segment.source_end - segment.source_start for _, segment in sources]
|
||||
starts = place_segments(cues, durations)
|
||||
placed: list[tuple[Path, float]] = []
|
||||
words: list[Word] = []
|
||||
for index, (cue, window, (source, segment)) in enumerate(zip(cues, cue_windows(cues), sources)):
|
||||
factor = speed_factor(segment.source_end - segment.source_start, window)
|
||||
if factor > AUDIBLE_SPEEDUP:
|
||||
warnings.append(
|
||||
f"Sous-titre {index + 1} (« {cue.text[:40]} ») lu {factor:.1f}× plus vite "
|
||||
"pour tenir dans son minutage : raccourcissez-le ou allongez sa durée."
|
||||
)
|
||||
for index, (cue, start, (source, segment)) in enumerate(zip(cues, starts, sources)):
|
||||
fitted = workdir / f"segment_{index:03d}.wav"
|
||||
await proc.run(extract_command(source, segment, factor, fitted), timeout=120)
|
||||
placed.append((fitted, cue.start))
|
||||
await proc.run(extract_command(source, segment, fitted), timeout=120)
|
||||
placed.append((fitted, start))
|
||||
added_period = not _SENTENCE_END.search(clean_text(cue.text))
|
||||
for position, w in enumerate(segment.words):
|
||||
text = w.text
|
||||
if added_period and position == len(segment.words) - 1:
|
||||
text = text.rstrip(".") # point ajouté pour la lecture, pas à afficher
|
||||
start = cue.start + max(0.0, w.start - segment.source_start) / factor
|
||||
end = cue.start + max(0.0, w.end - segment.source_start) / factor
|
||||
words.append(Word(text or w.text, start, min(max(end, start + 0.05), cue.start + window)))
|
||||
word_start = start + max(0.0, w.start - segment.source_start)
|
||||
word_end = start + max(0.0, w.end - segment.source_start)
|
||||
words.append(Word(text or w.text, word_start, max(word_end, word_start + 0.05)))
|
||||
|
||||
drift = max((s - c.start for s, c in zip(starts, cues)), default=0.0)
|
||||
if drift > DRIFT_WARNING:
|
||||
warnings.append(
|
||||
f"Le texte lu dépasse le minutage du SRT : les derniers sous-titres sont décalés "
|
||||
f"de {drift:.1f} s (voix gardée à vitesse normale). Raccourcissez le texte "
|
||||
"ou allongez les durées pour rester calé."
|
||||
)
|
||||
|
||||
output = workdir / "voice_srt.wav"
|
||||
await proc.run(mix_command(placed, output), timeout=300)
|
||||
@@ -197,14 +191,14 @@ async def synthesize_cues(
|
||||
return tts.VoiceTrack(output, duration, _monotonic(words), used_engine, used_voice, warnings)
|
||||
|
||||
|
||||
def extract_command(source: Path, segment: Segment, factor: float, output: Path) -> list[str]:
|
||||
"""Extrait un morceau de voix, avec un fondu de 10 ms aux bords (pas de clic)
|
||||
et l'accélération éventuelle."""
|
||||
def extract_command(source: Path, segment: Segment, output: Path) -> list[str]:
|
||||
"""Extrait un morceau de voix, à vitesse normale, avec un fondu de 10 ms
|
||||
aux bords (pas de clic)."""
|
||||
length = segment.source_end - segment.source_start
|
||||
fade_out = max(0.0, length - 0.01)
|
||||
filters = (
|
||||
f"atrim=start={segment.source_start:.3f}:end={segment.source_end:.3f},asetpts=PTS-STARTPTS,"
|
||||
f"afade=t=in:d=0.01,afade=t=out:st={fade_out:.3f}:d=0.01,{atempo_chain(factor)}"
|
||||
f"afade=t=in:d=0.01,afade=t=out:st={fade_out:.3f}:d=0.01"
|
||||
)
|
||||
return [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(source),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Synthèse vocale : moteur au choix, repli sur Edge, voix traitée et mots minutés."""
|
||||
"""Synthèse vocale : moteur au choix (Qwen local, Gemini, Edge), replis
|
||||
successifs, voix traitée et mots minutés."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .. import align, audio, config, proc, quality
|
||||
from ..align import Word
|
||||
from . import edge, gemini
|
||||
from . import edge, gemini, qwen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,17 +32,43 @@ async def synthesize(
|
||||
style: str | None,
|
||||
gemini_api_key: str | None,
|
||||
workdir: Path,
|
||||
qwen_target: qwen.Target | None = None,
|
||||
) -> VoiceTrack:
|
||||
warnings: list[str] = []
|
||||
processed: Path | None = None
|
||||
spoken: list[Word] = []
|
||||
used_engine, used_voice = "edge", ""
|
||||
engine = engine or "gemini"
|
||||
|
||||
if (engine or "gemini") == "gemini":
|
||||
if engine == "qwen":
|
||||
result = await _checked(
|
||||
"Qwen",
|
||||
lambda attempt: qwen.synthesize(text, voice, style, qwen_target, workdir, attempt),
|
||||
config.QWEN_TTS_ATTEMPTS,
|
||||
text,
|
||||
workdir,
|
||||
warnings,
|
||||
)
|
||||
if result:
|
||||
processed, spoken, used_voice, reading_ok = result
|
||||
used_engine = "qwen"
|
||||
if reading_ok:
|
||||
processed, spoken = await _trim_stray_sounds(processed, spoken, workdir)
|
||||
elif gemini_api_key:
|
||||
engine = "gemini" # repli sur Gemini quand une clé est disponible
|
||||
|
||||
if engine == "gemini" and processed is None:
|
||||
if not gemini_api_key:
|
||||
warnings.append("Clé Gemini absente : voix Edge utilisée à la place.")
|
||||
else:
|
||||
result = await _gemini_checked(text, voice, style, gemini_api_key, workdir, warnings)
|
||||
result = await _checked(
|
||||
"Gemini",
|
||||
lambda attempt: gemini.synthesize(text, voice, style, gemini_api_key, workdir, attempt),
|
||||
config.GEMINI_TTS_ATTEMPTS,
|
||||
text,
|
||||
workdir,
|
||||
warnings,
|
||||
)
|
||||
if result:
|
||||
processed, spoken, used_voice, reading_ok = result
|
||||
used_engine = "gemini"
|
||||
@@ -61,45 +89,67 @@ async def synthesize(
|
||||
return VoiceTrack(processed, duration, words, used_engine, used_voice, warnings)
|
||||
|
||||
|
||||
async def _gemini_checked(
|
||||
text: str, voice: str | None, style: str | None, api_key: str, workdir: Path, warnings: list[str]
|
||||
# En dessous, une lecture imparfaite est écartée au profit du moteur suivant.
|
||||
# Au-dessus, on garde la voix choisie (avec un avertissement) : changer de moteur
|
||||
# pour un mot douteux donnait une voix différente d'une phrase à l'autre.
|
||||
KEEP_IMPERFECT_COVERAGE = 0.7
|
||||
|
||||
|
||||
def is_runaway(duration: float, text: str) -> bool:
|
||||
"""Prise emballée : le modèle a continué à produire du son bien après la fin
|
||||
du texte (vu avec Qwen : 14 s pour une phrase de 34 caractères)."""
|
||||
return duration > max(3.0, len(text) / 6) + 1.5
|
||||
|
||||
|
||||
async def _checked(
|
||||
name: str,
|
||||
generate: Callable[[int], Awaitable[tuple[Path, str]]],
|
||||
attempts: int,
|
||||
text: str,
|
||||
workdir: Path,
|
||||
warnings: list[str],
|
||||
) -> tuple[Path, list[Word], str, bool] | None:
|
||||
"""Voix Gemini dont la lecture a été vérifiée : Whisper réécoute chaque
|
||||
"""Voix dont la lecture a été vérifiée : Whisper réécoute chaque
|
||||
génération ; mots sautés, fin tronquée ou consigne lue à voix haute
|
||||
déclenchent une nouvelle génération. Renvoie None pour se replier sur Edge."""
|
||||
déclenchent une nouvelle génération. Renvoie None pour passer au moteur suivant."""
|
||||
best: tuple[quality.ReadingCheck, Path, list[Word], str] | None = None
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(max(1, config.GEMINI_TTS_ATTEMPTS)):
|
||||
for attempt in range(max(1, attempts)):
|
||||
try:
|
||||
raw, gemini_voice = await gemini.synthesize(text, voice, style, api_key, workdir, attempt)
|
||||
except Exception as error: # noqa: BLE001 — nouvelle tentative, puis repli sur Edge
|
||||
log.warning("Gemini TTS en échec (tentative %d) : %s", attempt + 1, error)
|
||||
raw, engine_voice = await generate(attempt)
|
||||
except Exception as error: # noqa: BLE001 — nouvelle tentative, puis moteur suivant
|
||||
log.warning("%s TTS en échec (tentative %d) : %r", name, attempt + 1, error)
|
||||
last_error = error
|
||||
continue
|
||||
|
||||
processed = workdir / f"voice_gemini_{attempt}.wav"
|
||||
processed = workdir / f"voice_{name.lower()}_{attempt}.wav"
|
||||
await audio.process_voice(raw, processed)
|
||||
duration = (await proc.probe(processed)).duration
|
||||
if is_runaway(duration, text):
|
||||
log.warning("%s : prise emballée (%.1f s pour %d car.), écartée", name, duration, len(text))
|
||||
last_error = RuntimeError(f"voix de {duration:.0f} s pour {len(text)} caractères")
|
||||
continue
|
||||
spoken = await align.transcribe(processed)
|
||||
check = quality.check_reading(text, spoken)
|
||||
log.info("Lecture Gemini (tentative %d) : %s", attempt + 1, check.describe())
|
||||
log.info("Lecture %s (tentative %d) : %s", name, attempt + 1, check.describe())
|
||||
|
||||
if best is None or _score(check) > _score(best[0]):
|
||||
best = (check, processed, spoken, gemini_voice)
|
||||
best = (check, processed, spoken, engine_voice)
|
||||
if check.acceptable:
|
||||
break
|
||||
|
||||
if best is None:
|
||||
warnings.append(f"Gemini indisponible ({last_error}) : voix Edge utilisée à la place.")
|
||||
warnings.append(f"{name} : aucune voix utilisable ({last_error!r}) : voix de secours utilisée.")
|
||||
return None
|
||||
|
||||
check, processed, spoken, gemini_voice = best
|
||||
check, processed, spoken, engine_voice = best
|
||||
if not check.acceptable:
|
||||
if check.coverage < 0.7:
|
||||
warnings.append(f"Gemini a mal lu le texte ({check.describe()}) : voix Edge utilisée à la place.")
|
||||
if check.coverage < KEEP_IMPERFECT_COVERAGE:
|
||||
warnings.append(f"{name} a mal lu le texte ({check.describe()}) : voix de secours utilisée.")
|
||||
return None
|
||||
warnings.append(f"Lecture Gemini imparfaite ({check.describe()}) : écoutez le résultat.")
|
||||
return processed, spoken, gemini_voice, check.acceptable
|
||||
warnings.append(f"Lecture {name} imparfaite ({check.describe()}) : écoutez le résultat.")
|
||||
return processed, spoken, engine_voice, check.acceptable
|
||||
|
||||
|
||||
# Au-delà de la fin du dernier mot, ce n'est plus de la parole
|
||||
@@ -108,7 +158,7 @@ STRAY_BEFORE_FIRST_WORD = 0.4
|
||||
|
||||
|
||||
async def _trim_stray_sounds(path: Path, spoken: list[Word], workdir: Path) -> tuple[Path, list[Word]]:
|
||||
"""Retire les bruits que Gemini ajoute parfois avant le premier ou après le
|
||||
"""Retire les bruits que le modèle ajoute parfois avant le premier ou après le
|
||||
dernier mot. Appelé seulement quand la lecture a été vérifiée : le dernier
|
||||
mot est bien reconnu, on ne coupe donc jamais de parole."""
|
||||
if not spoken:
|
||||
|
||||
@@ -13,8 +13,9 @@ log = logging.getLogger(__name__)
|
||||
|
||||
# Réglages de lecture par style (Edge n'interprète pas de consigne en texte)
|
||||
STYLE_PROSODY = {
|
||||
"dynamic": ("+8%", "+2Hz"),
|
||||
"promo": ("+10%", "+3Hz"),
|
||||
# Débit à peine relevé : au-delà, la voix devient difficile à suivre
|
||||
"dynamic": ("+0%", "+2Hz"),
|
||||
"promo": ("+3%", "+3Hz"),
|
||||
"calm": ("-8%", "-2Hz"),
|
||||
"warm": ("-2%", "+0Hz"),
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ async def _request(model: str, prompt: str, voice: str, api_key: str, seed: int)
|
||||
generation["temperature"] = config.GEMINI_TTS_TEMPERATURE
|
||||
generation["seed"] = seed
|
||||
payload = {"contents": [{"parts": [{"text": prompt}]}], "generationConfig": generation}
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
# 45 s : une génération normale prend 2 à 5 s ; au-delà, Gemini ne répondra plus
|
||||
async with httpx.AsyncClient(timeout=45) as client:
|
||||
# Clé en en-tête : dans l'URL, elle finissait dans les journaux
|
||||
response = await client.post(
|
||||
f"{API_ROOT}/{model}:generateContent",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Synthèse vocale Qwen3-TTS (service qwen-tts, local ou sur un serveur GPU)."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .. import config
|
||||
from ..voices import qwen_instruction, resolve_qwen_voice
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QwenError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Target:
|
||||
"""Service Qwen à appeler : réglé dans les paramètres de l'application,
|
||||
sinon par les variables d'environnement."""
|
||||
|
||||
url: str
|
||||
api_key: str = ""
|
||||
|
||||
@classmethod
|
||||
def resolve(cls, url: str | None = None, api_key: str | None = None) -> "Target | None":
|
||||
if url and url.strip():
|
||||
return cls(url.strip().rstrip("/"), (api_key or "").strip())
|
||||
if config.QWEN_TTS_URL:
|
||||
return cls(config.QWEN_TTS_URL, config.QWEN_TTS_API_KEY)
|
||||
return None
|
||||
|
||||
@property
|
||||
def headers(self) -> dict[str, str]:
|
||||
return {"x-api-key": self.api_key} if self.api_key else {}
|
||||
|
||||
|
||||
async def synthesize(
|
||||
text: str, voice: str | None, style: str | None, target: Target | None, workdir: Path, attempt: int = 0
|
||||
) -> tuple[Path, str]:
|
||||
"""Génère la voix ; renvoie un WAV brut et la voix utilisée."""
|
||||
if target is None:
|
||||
raise QwenError("service Qwen TTS non configuré (Paramètres → Qwen TTS)")
|
||||
qwen_voice = resolve_qwen_voice(voice)
|
||||
payload = {"text": text, "voice": qwen_voice, "instruct": qwen_instruction(style), "language": "French"}
|
||||
log.info("Qwen TTS (%s) : voix=%s style=%s (%d car.)", target.url, qwen_voice, style, len(text))
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=config.QWEN_TTS_TIMEOUT) as client:
|
||||
response = await client.post(f"{target.url}/synthesize", json=payload, headers=target.headers)
|
||||
except httpx.HTTPError as error:
|
||||
raise QwenError(f"service Qwen TTS injoignable : {error!r}") from error
|
||||
if response.status_code != 200:
|
||||
raise QwenError(f"Qwen TTS : HTTP {response.status_code} {response.text[:300]}")
|
||||
raw = workdir / f"qwen_raw_{attempt}.wav"
|
||||
raw.write_bytes(response.content)
|
||||
return raw, response.headers.get("x-voice", qwen_voice)
|
||||
|
||||
|
||||
async def status(target: Target | None) -> dict | None:
|
||||
"""État du service (modèle, carte graphique) et voix proposées, clonées
|
||||
comprises ; None s'il n'est pas configuré ou ne répond pas."""
|
||||
if target is None:
|
||||
return None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
health = await client.get(f"{target.url}/health", headers=target.headers)
|
||||
health.raise_for_status()
|
||||
listed = await client.get(f"{target.url}/voices", headers=target.headers)
|
||||
listed.raise_for_status()
|
||||
return {**health.json(), "voices": listed.json()["voices"]}
|
||||
except Exception as error: # noqa: BLE001 — moteur simplement signalé indisponible
|
||||
log.warning("Service Qwen TTS %s indisponible : %s", target.url, error)
|
||||
return None
|
||||
@@ -52,13 +52,27 @@ EDGE_VOICES: tuple[Voice, ...] = (
|
||||
Voice("fr-FR-HenriNeural", "Henri", "male"),
|
||||
)
|
||||
|
||||
# Voix prédéfinies de Qwen3-TTS (service local). Les voix clonées
|
||||
# (« clone:nom ») sont listées par le service lui-même.
|
||||
QWEN_VOICES: tuple[Voice, ...] = (
|
||||
Voice("serena", "Serena — douce et chaleureuse", "female"),
|
||||
Voice("vivian", "Vivian — vive et lumineuse", "female"),
|
||||
Voice("sohee", "Sohee — expressive", "female"),
|
||||
Voice("ono_anna", "Anna — espiègle", "female"),
|
||||
Voice("aiden", "Aiden — solaire", "male"),
|
||||
Voice("ryan", "Ryan — dynamique", "male"),
|
||||
Voice("uncle_fu", "Fu — grave et posée", "male"),
|
||||
Voice("dylan", "Dylan — jeune et naturelle", "male"),
|
||||
Voice("eric", "Eric — légèrement voilée", "male"),
|
||||
)
|
||||
|
||||
# Consignes de lecture ajoutées au texte envoyé à Gemini.
|
||||
STYLES: dict[str, tuple[str, str]] = {
|
||||
"neutral": ("Neutre", ""),
|
||||
"dynamic": (
|
||||
"Dynamique",
|
||||
"Lis ce texte en français sur un ton dynamique et enthousiaste, avec un rythme "
|
||||
"entraînant, comme une vidéo courte sur les réseaux sociaux",
|
||||
"Lis ce texte en français sur un ton dynamique et enthousiaste, comme une vidéo "
|
||||
"courte sur les réseaux sociaux, à un débit naturel, sans accélérer, en articulant bien",
|
||||
),
|
||||
"warm": (
|
||||
"Chaleureux",
|
||||
@@ -71,12 +85,25 @@ STYLES: dict[str, tuple[str, str]] = {
|
||||
"promo": (
|
||||
"Promo",
|
||||
"Lis ce texte en français comme une annonce promotionnelle énergique, en "
|
||||
"insistant sur les offres et les prix",
|
||||
"insistant sur les offres et les prix, sans accélérer, en articulant bien",
|
||||
),
|
||||
}
|
||||
|
||||
_GEMINI_BY_ID = {v.id.lower(): v for v in GEMINI_VOICES}
|
||||
_EDGE_BY_ID = {v.id: v for v in EDGE_VOICES}
|
||||
_QWEN_BY_ID = {v.id: v for v in QWEN_VOICES}
|
||||
|
||||
# Consignes de ton pour Qwen (le modèle comprend l'anglais mieux que le français)
|
||||
QWEN_STYLES: dict[str, str] = {
|
||||
"neutral": "Speak natural, native French with clear articulation and a relaxed, natural pace.",
|
||||
"dynamic": "Speak native French in an upbeat, enthusiastic and smiling tone, like a social media video, "
|
||||
"at a natural pace without rushing, clearly articulated.",
|
||||
"warm": "Speak native French in a warm, friendly and caring tone, as if advising a friend, "
|
||||
"at a relaxed pace.",
|
||||
"calm": "Speak native French in a calm, soothing and reassuring tone, slowly and clearly.",
|
||||
"promo": "Speak native French like an energetic promotional announcement, emphasizing offers and prices, "
|
||||
"at a natural pace without rushing, clearly articulated.",
|
||||
}
|
||||
|
||||
|
||||
def resolve_gemini_voice(voice: str | None) -> Voice:
|
||||
@@ -87,7 +114,7 @@ def resolve_gemini_voice(voice: str | None) -> Voice:
|
||||
"""
|
||||
if voice and voice.lower() in _GEMINI_BY_ID:
|
||||
return _GEMINI_BY_ID[voice.lower()]
|
||||
if voice and voice.endswith(("-B", "-D")) or voice == "male":
|
||||
if voice and voice.endswith(("-B", "-D")) or voice_gender(voice) == "male":
|
||||
return _GEMINI_BY_ID["charon"]
|
||||
return _GEMINI_BY_ID["kore"]
|
||||
|
||||
@@ -100,6 +127,8 @@ def voice_gender(voice: str | None) -> str:
|
||||
return _GEMINI_BY_ID[voice.lower()].gender
|
||||
if voice in _EDGE_BY_ID:
|
||||
return _EDGE_BY_ID[voice].gender
|
||||
if voice.lower() in _QWEN_BY_ID:
|
||||
return _QWEN_BY_ID[voice.lower()].gender
|
||||
if voice == "male" or voice.endswith(("-B", "-D")):
|
||||
return "male"
|
||||
return "male" if any(name in voice for name in ("Remy", "Henri", "Paul")) else "female"
|
||||
@@ -113,6 +142,20 @@ def resolve_edge_voice(voice: str | None) -> Voice:
|
||||
return next(v for v in EDGE_VOICES if v.gender == gender)
|
||||
|
||||
|
||||
def resolve_qwen_voice(voice: str | None) -> str:
|
||||
"""Voix Qwen à utiliser : voix clonée ou prédéfinie ; une voix d'un autre
|
||||
moteur est remplacée par une voix Qwen du même genre."""
|
||||
if voice and voice.startswith("clone:"):
|
||||
return voice
|
||||
if voice and voice.lower() in _QWEN_BY_ID:
|
||||
return voice.lower()
|
||||
return "aiden" if voice_gender(voice) == "male" else "serena"
|
||||
|
||||
|
||||
def qwen_instruction(style: str | None) -> str:
|
||||
return QWEN_STYLES.get(style or "neutral", QWEN_STYLES["neutral"])
|
||||
|
||||
|
||||
def edge_fallbacks(primary: Voice) -> list[Voice]:
|
||||
"""Voix françaises de secours du même genre (jamais d'anglais)."""
|
||||
same = [v for v in EDGE_VOICES if v.gender == primary.gender and v != primary]
|
||||
@@ -123,8 +166,11 @@ def style_instruction(style: str | None) -> str:
|
||||
return STYLES.get(style or "neutral", STYLES["neutral"])[1]
|
||||
|
||||
|
||||
def catalog() -> dict:
|
||||
def catalog(qwen_voices: list[dict] | None = None) -> dict:
|
||||
"""Voix par moteur ; `qwen_voices` vient du service Qwen (None s'il ne répond pas)."""
|
||||
return {
|
||||
"qwen": qwen_voices or [v.__dict__ for v in QWEN_VOICES],
|
||||
"qwen_available": qwen_voices is not None,
|
||||
"gemini": [v.__dict__ for v in GEMINI_VOICES],
|
||||
"edge": [v.__dict__ for v in EDGE_VOICES],
|
||||
"styles": [{"id": k, "label": v[0]} for k, v in STYLES.items()],
|
||||
|
||||
@@ -71,3 +71,50 @@ def test_skipped_words_are_detected_and_named():
|
||||
assert not check.acceptable
|
||||
assert "bonbons" in check.missing and "effrayante" in check.missing
|
||||
assert "non entendus" in check.describe()
|
||||
|
||||
|
||||
def test_prices_and_units_written_differently_are_not_missing_words():
|
||||
expected = "Alerte léopard ! Le coussin de 40 centimètres à 11,99 €. Le cabas à 13,99 €."
|
||||
heard = _heard("Alerte léopard ! Le coussin de 40 cm à 11 € 99. Le cabas à 13,99 euros.")
|
||||
check = check_reading(expected, heard)
|
||||
assert check.acceptable, check
|
||||
assert check.missing == []
|
||||
|
||||
|
||||
def test_skipped_word_next_to_a_price_is_still_detected():
|
||||
expected = "Le coussin léopard à 11,99 €. Le cabas doux et chaud à 13,99 €."
|
||||
heard = _heard("Le coussin à 11,99 €. Le cabas à 13,99 €.")
|
||||
check = check_reading(expected, heard)
|
||||
assert not check.acceptable
|
||||
assert "léopard" in check.missing
|
||||
|
||||
|
||||
def test_cut_uses_the_real_pause_when_word_timing_is_estimated():
|
||||
# Phrase 1 jusqu'à 2,0 s (dont un prix non reconnu), virgule à 1,0 s,
|
||||
# pause de fin de phrase 2,0–2,4 s. Whisper place la fin de la phrase
|
||||
# et le début de la suivante vers 2,9 s (mots interpolés).
|
||||
env = _envelope([(0.3, -90), (0.7, -20), (0.1, -90), (0.9, -20), (0.4, -90), (1.5, -20), (0.3, -90)])
|
||||
cut = sentence_cut(env, previous_end=2.9, next_start=2.95)
|
||||
assert 2.0 <= cut <= 2.4
|
||||
|
||||
|
||||
def test_cut_prefers_the_sentence_pause_over_a_comma():
|
||||
env = _envelope([(0.3, -90), (1.0, -20), (0.12, -90), (0.8, -20), (0.45, -90), (1.0, -20), (0.3, -90)])
|
||||
# Borne estimée entre la virgule (1,3 s) et la fin de phrase (2,22 s)
|
||||
cut = sentence_cut(env, previous_end=1.7, next_start=1.75)
|
||||
assert 2.22 <= cut <= 2.67
|
||||
|
||||
|
||||
def test_numbers_written_in_letters_are_not_missing_words():
|
||||
expected = "Plus trente secondes chrono, le compteur s'affole : moins vingt-cinq pour cent !"
|
||||
heard = _heard("+30 secondes chrono, le compteur s'affole : -25% !")
|
||||
check = check_reading(expected, heard)
|
||||
assert check.acceptable, check
|
||||
assert check.missing == []
|
||||
|
||||
|
||||
def test_skipped_word_next_to_a_number_in_letters_is_still_detected():
|
||||
expected = "Trente secondes chrono, le compteur s'affole vraiment !"
|
||||
heard = _heard("30 secondes, le compteur s'affole vraiment !")
|
||||
check = check_reading(expected, heard)
|
||||
assert "chrono" in check.missing
|
||||
@@ -7,29 +7,32 @@ from app.quality import Envelope
|
||||
from app.render import RenderPlan
|
||||
from app.srt_voice import (
|
||||
Cue,
|
||||
Segment,
|
||||
as_sentence,
|
||||
atempo_chain,
|
||||
cue_windows,
|
||||
extract_command,
|
||||
mix_command,
|
||||
place_segments,
|
||||
segments_from_reading,
|
||||
speed_factor,
|
||||
split_by_cue,
|
||||
)
|
||||
|
||||
|
||||
def test_window_stops_at_next_cue_and_tolerates_a_short_overflow():
|
||||
cues = [Cue(0.0, 2.0, "a"), Cue(2.1, 4.0, "b"), Cue(6.0, 7.0, "c")]
|
||||
assert cue_windows(cues) == [2.1, 2.15, 1.25]
|
||||
def test_segments_start_at_their_cue_when_they_fit():
|
||||
cues = [Cue(0.0, 2.0, "a"), Cue(2.5, 4.0, "b"), Cue(6.0, 7.0, "c")]
|
||||
assert place_segments(cues, [2.0, 1.0, 1.0]) == [0.0, 2.5, 6.0]
|
||||
|
||||
|
||||
def test_voice_is_sped_up_only_when_too_long():
|
||||
assert speed_factor(1.5, 2.0) == 1.0
|
||||
assert speed_factor(3.0, 2.0) == 1.5
|
||||
def test_overflowing_segment_pushes_the_next_ones_instead_of_speeding_up():
|
||||
cues = [Cue(0.0, 1.0, "a"), Cue(1.0, 2.0, "b"), Cue(5.0, 6.0, "c")]
|
||||
starts = place_segments(cues, [2.0, 2.0, 1.0])
|
||||
assert starts[0] == 0.0
|
||||
assert abs(starts[1] - 2.05) < 1e-9 # après la fin du premier, à vitesse normale
|
||||
assert starts[2] == 5.0 # rattrape le minutage dès que possible
|
||||
|
||||
|
||||
def test_atempo_is_split_into_supported_steps():
|
||||
assert atempo_chain(1.25) == "atempo=1.2500"
|
||||
assert atempo_chain(3.0) == "atempo=2.0000,atempo=1.5000"
|
||||
def test_segments_are_never_sped_up():
|
||||
cmd = extract_command(Path("in.wav"), Segment(1.0, 4.0, []), Path("out.wav"))
|
||||
assert "atempo" not in cmd[cmd.index("-af") + 1]
|
||||
|
||||
|
||||
def test_each_cue_becomes_a_sentence_for_the_reading():
|
||||
|
||||
@@ -21,3 +21,39 @@ def test_edge_fallbacks_are_french_and_same_gender():
|
||||
def test_style_prompt_precedes_text():
|
||||
assert build_prompt("Salut", "neutral") == "Salut"
|
||||
assert build_prompt("Salut", "dynamic").endswith(":\nSalut")
|
||||
|
||||
|
||||
def test_qwen_voice_keeps_clones_and_matches_gender():
|
||||
from app.voices import qwen_instruction, resolve_qwen_voice
|
||||
|
||||
assert resolve_qwen_voice("clone:camille") == "clone:camille"
|
||||
assert resolve_qwen_voice("Ryan") == "ryan"
|
||||
assert resolve_qwen_voice("Charon") == "aiden" # voix Gemini masculine
|
||||
assert resolve_qwen_voice(None) == "serena"
|
||||
assert "French" in qwen_instruction("dynamic")
|
||||
|
||||
|
||||
def test_qwen_voice_falls_back_to_same_gender_elsewhere():
|
||||
assert resolve_edge_voice("uncle_fu").gender == "male"
|
||||
assert resolve_gemini_voice("uncle_fu").id == "Charon"
|
||||
|
||||
|
||||
def test_qwen_target_prefers_app_settings(monkeypatch):
|
||||
from app import config
|
||||
from app.tts.qwen import Target
|
||||
|
||||
monkeypatch.setattr(config, "QWEN_TTS_URL", "http://qwen-tts:8001")
|
||||
monkeypatch.setattr(config, "QWEN_TTS_API_KEY", "env-key")
|
||||
assert Target.resolve("http://unraid:8001/ ", "k") == Target("http://unraid:8001", "k")
|
||||
assert Target.resolve(None, None) == Target("http://qwen-tts:8001", "env-key")
|
||||
monkeypatch.setattr(config, "QWEN_TTS_URL", "")
|
||||
assert Target.resolve("", None) is None
|
||||
|
||||
|
||||
def test_runaway_take_is_detected():
|
||||
from app.tts import is_runaway
|
||||
|
||||
text = "Christelle fonce dans les rayons !" # 34 caractères
|
||||
assert not is_runaway(3.0, text)
|
||||
assert not is_runaway(6.5, text) # lecture lente, mais plausible
|
||||
assert is_runaway(14.2, text) # vu en production : le modèle continue sans fin
|
||||
@@ -0,0 +1,35 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libsndfile1 sox ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# PyTorch : « cpu » (image légère) ou « cu128 » pour les GPU NVIDIA, cartes
|
||||
# RTX 50xx (Blackwell) comprises. torch et torchaudio doivent avoir la même
|
||||
# version, sinon torchaudio ne se charge pas.
|
||||
ARG TORCH_VARIANT=cpu
|
||||
RUN pip install --no-cache-dir torch==2.8.0 torchaudio==2.8.0 \
|
||||
--index-url https://download.pytorch.org/whl/${TORCH_VARIANT}
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Modèles téléchargés au build : aucun accès réseau au premier rendu.
|
||||
# CustomVoice : voix prédéfinies pilotées par une consigne (ton, émotion).
|
||||
# Base : clonage d'une voix de référence (voices/*.wav), chargé seulement s'il y en a.
|
||||
ARG QWEN_TTS_MODEL=Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice
|
||||
ARG QWEN_TTS_CLONE_MODEL=Qwen/Qwen3-TTS-12Hz-0.6B-Base
|
||||
ENV QWEN_TTS_MODEL=${QWEN_TTS_MODEL} \
|
||||
QWEN_TTS_CLONE_MODEL=${QWEN_TTS_CLONE_MODEL} \
|
||||
HF_HOME=/opt/hf-cache \
|
||||
HOME=/tmp
|
||||
RUN python -c "from huggingface_hub import snapshot_download as d; d('${QWEN_TTS_MODEL}'); d('${QWEN_TTS_CLONE_MODEL}') if '${QWEN_TTS_CLONE_MODEL}' else None"
|
||||
ENV HF_HUB_OFFLINE=1
|
||||
|
||||
COPY main.py .
|
||||
COPY voices ./voices
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
@@ -0,0 +1,55 @@
|
||||
# Qwen TTS : voix locale sur un serveur GPU (ex. Unraid)
|
||||
|
||||
Service de synthèse vocale Qwen3-TTS appelé par SocialFlow (via ffmpeg-api).
|
||||
Sur une carte graphique NVIDIA, le gros modèle 1.7B (ton et émotion pilotés)
|
||||
génère une voix bien plus vite qu'en temps réel ; sur CPU, il est 5 à 7 fois plus lent.
|
||||
|
||||
## Installation sur Unraid
|
||||
|
||||
1. **Pilote NVIDIA** : Apps → installer le plugin **Nvidia Driver**, redémarrer,
|
||||
puis vérifier dans un terminal : `nvidia-smi -L` (liste les cartes et leur numéro).
|
||||
2. **Récupérer ce dossier** sur le serveur, par exemple :
|
||||
```bash
|
||||
cd /mnt/user/appdata
|
||||
git clone https://github.com/R0m1k3/Socialflow.git
|
||||
cd Socialflow/qwen-tts
|
||||
```
|
||||
3. **Choisir la carte et la clé** dans un fichier `.env` à côté de `docker-compose.gpu.yml` :
|
||||
```bash
|
||||
QWEN_TTS_API_KEY=une-longue-cle-secrete # openssl rand -hex 32
|
||||
QWEN_TTS_GPU=0 # numéro ou UUID donné par nvidia-smi -L
|
||||
# QWEN_TTS_PORT=8001
|
||||
```
|
||||
Une RTX 3060 12 Go suffit largement (le modèle 1.7B occupe ~5–6 Go) : autant
|
||||
la dédier à la voix et garder la plus grosse carte pour le reste.
|
||||
4. **Construire et lancer** (le premier build télécharge les modèles, quelques Go) :
|
||||
```bash
|
||||
docker compose -f docker-compose.gpu.yml up -d --build
|
||||
docker logs -f socialflow-qwen-tts # « Modèle … chargé sur NVIDIA GeForce … »
|
||||
```
|
||||
5. **Dans SocialFlow** : Paramètres → **Qwen TTS (voix locale)** → adresse
|
||||
`http://IP-DE-L-UNRAID:8001` + la clé → **Tester la connexion** → **Enregistrer**.
|
||||
Le moteur « Qwen (locale) » devient disponible dans le choix des voix.
|
||||
|
||||
## Sécurité
|
||||
|
||||
Le service ne doit pas être exposé sur Internet. Si SocialFlow tourne sur un
|
||||
autre réseau que l'Unraid, reliez les deux par **Tailscale** (ou WireGuard)
|
||||
et utilisez l'adresse Tailscale de l'Unraid (`http://100.x.y.z:8001`).
|
||||
La clé d'accès est obligatoire.
|
||||
|
||||
## Voix clonées (voix française native)
|
||||
|
||||
Déposez dans `voices/` un extrait de 5 à 15 s et sa transcription (voir
|
||||
`voices/README.md`), puis `docker restart socialflow-qwen-tts`. Utilisez
|
||||
uniquement des voix dont vous avez les droits.
|
||||
|
||||
## Réglages
|
||||
|
||||
| Variable | Défaut | Rôle |
|
||||
| --- | --- | --- |
|
||||
| `QWEN_TTS_MODEL` | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | Modèle des voix prédéfinies (build) |
|
||||
| `QWEN_TTS_CLONE_MODEL` | `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | Modèle de clonage, vide pour désactiver (build) |
|
||||
| `QWEN_TTS_GPU` | `0` | Carte NVIDIA utilisée |
|
||||
| `QWEN_TTS_DEVICE` | `auto` | `cuda:0`, `cpu`… (auto : GPU si disponible) |
|
||||
| `QWEN_TTS_DTYPE` | `bfloat16` sur GPU, `float32` sur CPU | Précision du calcul |
|
||||
@@ -0,0 +1,32 @@
|
||||
# Qwen3-TTS sur un serveur avec carte graphique NVIDIA (ex. Unraid).
|
||||
#
|
||||
# Prérequis : pilote NVIDIA + NVIDIA Container Toolkit (sur Unraid : plugin
|
||||
# « Nvidia Driver »). Puis, dans ce dossier :
|
||||
# QWEN_TTS_API_KEY=une-cle-secrete docker compose -f docker-compose.gpu.yml up -d --build
|
||||
# et dans SocialFlow : Paramètres → Qwen TTS → http://IP-DU-SERVEUR:8001 + la clé.
|
||||
services:
|
||||
qwen-tts:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
TORCH_VARIANT: cu128
|
||||
# Sur GPU, le gros modèle : ton et émotion pilotés par consigne
|
||||
QWEN_TTS_MODEL: ${QWEN_TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice}
|
||||
# Clonage des voix déposées dans ./voices (vide : désactivé)
|
||||
QWEN_TTS_CLONE_MODEL: ${QWEN_TTS_CLONE_MODEL-Qwen/Qwen3-TTS-12Hz-1.7B-Base}
|
||||
image: socialflow-qwen-tts:gpu
|
||||
container_name: socialflow-qwen-tts
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Obligatoire : le service est joignable depuis le réseau
|
||||
API_KEY: ${QWEN_TTS_API_KEY:?Définissez QWEN_TTS_API_KEY}
|
||||
# GPU utilisé dans le conteneur (voir NVIDIA_VISIBLE_DEVICES ci-dessous)
|
||||
QWEN_TTS_DEVICE: cuda:0
|
||||
# Choix de la carte : numéro (0, 1) ou UUID donné par nvidia-smi -L
|
||||
NVIDIA_VISIBLE_DEVICES: ${QWEN_TTS_GPU:-0}
|
||||
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||
runtime: nvidia
|
||||
ports:
|
||||
- "${QWEN_TTS_PORT:-8001}:8001"
|
||||
volumes:
|
||||
- ./voices:/app/voices:ro
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Synthèse vocale Qwen3-TTS, appelée par le service ffmpeg-api.
|
||||
|
||||
Tourne sur carte graphique NVIDIA si elle est disponible (bien plus rapide,
|
||||
gros modèle 1.7B), sinon sur CPU. Peut être installé sur un autre serveur
|
||||
(ex. Unraid avec GPU) : l'adresse se règle dans les paramètres de SocialFlow.
|
||||
|
||||
- Voix prédéfinies (modèle CustomVoice) : le ton est donné par une consigne.
|
||||
- Voix clonées : un extrait de référence dans voices/ (modèle Base, chargé
|
||||
seulement si au moins une voix y est déposée).
|
||||
|
||||
Une seule génération à la fois : chacune occupe tous les cœurs (ou le GPU).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
log = logging.getLogger("qwen-tts")
|
||||
|
||||
MODEL = os.environ.get("QWEN_TTS_MODEL", "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice")
|
||||
CLONE_MODEL = os.environ.get("QWEN_TTS_CLONE_MODEL", "Qwen/Qwen3-TTS-12Hz-0.6B-Base")
|
||||
VOICES_DIR = Path(os.environ.get("QWEN_TTS_VOICES_DIR", Path(__file__).parent / "voices"))
|
||||
# « auto » : premier GPU NVIDIA s'il y en a un, sinon CPU (ou « cuda:1 », « cpu »…)
|
||||
_DEVICE_SETTING = os.environ.get("QWEN_TTS_DEVICE", "auto")
|
||||
DEVICE = ("cuda:0" if torch.cuda.is_available() else "cpu") if _DEVICE_SETTING == "auto" else _DEVICE_SETTING
|
||||
# bfloat16 sur GPU (moitié moins de mémoire, plus rapide) ; float32 sur CPU
|
||||
DTYPE = getattr(torch, os.environ.get("QWEN_TTS_DTYPE") or ("bfloat16" if DEVICE.startswith("cuda") else "float32"))
|
||||
THREADS = int(os.environ.get("QWEN_TTS_THREADS", "0")) or os.cpu_count() or 1
|
||||
API_KEY = os.environ.get("API_KEY", "")
|
||||
|
||||
# Voix prédéfinies du modèle CustomVoice (toutes parlent français)
|
||||
PRESETS: dict[str, tuple[str, str]] = {
|
||||
"serena": ("Serena — douce et chaleureuse", "female"),
|
||||
"vivian": ("Vivian — vive et lumineuse", "female"),
|
||||
"sohee": ("Sohee — expressive", "female"),
|
||||
"ono_anna": ("Anna — espiègle", "female"),
|
||||
"aiden": ("Aiden — solaire", "male"),
|
||||
"ryan": ("Ryan — dynamique", "male"),
|
||||
"uncle_fu": ("Fu — grave et posée", "male"),
|
||||
"dylan": ("Dylan — jeune et naturelle", "male"),
|
||||
"eric": ("Eric — légèrement voilée", "male"),
|
||||
}
|
||||
DEFAULT_PRESET = "serena"
|
||||
|
||||
torch.set_num_threads(THREADS)
|
||||
_lock = asyncio.Lock()
|
||||
_models: dict[str, object] = {}
|
||||
_clone_prompts: dict[str, object] = {}
|
||||
|
||||
|
||||
def _load(name: str):
|
||||
if name not in _models:
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
|
||||
started = time.monotonic()
|
||||
_models[name] = Qwen3TTSModel.from_pretrained(_local_path(name), device_map=DEVICE, dtype=DTYPE)
|
||||
log.info("Modèle %s chargé sur %s en %.1f s", name, _device_label(), time.monotonic() - started)
|
||||
return _models[name]
|
||||
|
||||
|
||||
def _local_path(name: str) -> str:
|
||||
"""Dossier du modèle téléchargé au build. Avec un nom de dépôt, transformers
|
||||
interroge l'API Hugging Face au chargement (détection des tokenizers Mistral),
|
||||
ce qui échoue en mode hors ligne ; avec un chemin local, non."""
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
try:
|
||||
return snapshot_download(name, local_files_only=True)
|
||||
except Exception: # noqa: BLE001 — absent du cache : téléchargement normal
|
||||
return name
|
||||
|
||||
|
||||
def _device_label() -> str:
|
||||
if DEVICE.startswith("cuda"):
|
||||
return f"{torch.cuda.get_device_name(torch.device(DEVICE))} ({DEVICE}, {DTYPE})"
|
||||
return f"CPU ({THREADS} threads, {DTYPE})"
|
||||
|
||||
|
||||
def clones() -> dict[str, dict]:
|
||||
"""Voix de référence déposées dans voices/ : nom.wav + nom.txt (+ nom.json)."""
|
||||
found = {}
|
||||
for wav in sorted(VOICES_DIR.glob("*.wav")):
|
||||
transcript = wav.with_suffix(".txt")
|
||||
if not transcript.exists():
|
||||
log.warning("Voix %s ignorée : transcription %s absente", wav.name, transcript.name)
|
||||
continue
|
||||
meta_file = wav.with_suffix(".json")
|
||||
meta = json.loads(meta_file.read_text()) if meta_file.exists() else {}
|
||||
found[wav.stem] = {
|
||||
"id": f"clone:{wav.stem}",
|
||||
"label": meta.get("label", wav.stem.replace("_", " ").title()),
|
||||
"gender": meta.get("gender", "female"),
|
||||
"wav": wav,
|
||||
"text": transcript.read_text().strip(),
|
||||
}
|
||||
return found
|
||||
|
||||
|
||||
def max_tokens(text: str) -> int:
|
||||
"""Plafond de codes audio (12 par seconde de son) : une phrase ne peut plus
|
||||
durer plus de « caractères / 6 + 3 » secondes. Sans lui, le modèle continuait
|
||||
parfois bien après la fin du texte (14 s pour 34 caractères)."""
|
||||
return int(12 * (len(text) / 6 + 3))
|
||||
|
||||
|
||||
def _generate(text: str, voice: str, instruct: str, language: str) -> tuple[bytes, str]:
|
||||
if voice.startswith("clone:") and CLONE_MODEL:
|
||||
name = voice.removeprefix("clone:")
|
||||
clone = clones().get(name)
|
||||
if clone is None:
|
||||
raise HTTPException(status_code=404, detail=f"Voix clonée inconnue : {name}")
|
||||
model = _load(CLONE_MODEL)
|
||||
if name not in _clone_prompts:
|
||||
_clone_prompts[name] = model.create_voice_clone_prompt(
|
||||
ref_audio=str(clone["wav"]), ref_text=clone["text"], x_vector_only_mode=False
|
||||
)
|
||||
wavs, rate = model.generate_voice_clone(
|
||||
text=text,
|
||||
language=language,
|
||||
voice_clone_prompt=_clone_prompts[name],
|
||||
max_new_tokens=max_tokens(text),
|
||||
)
|
||||
used = clone["id"]
|
||||
else:
|
||||
speaker = voice.lower() if voice.lower() in PRESETS else DEFAULT_PRESET
|
||||
kwargs = {"text": text, "language": language, "speaker": speaker, "max_new_tokens": max_tokens(text)}
|
||||
if instruct:
|
||||
kwargs["instruct"] = instruct
|
||||
wavs, rate = _load(MODEL).generate_custom_voice(**kwargs)
|
||||
used = speaker
|
||||
|
||||
buffer = io.BytesIO()
|
||||
sf.write(buffer, wavs[0], rate, format="WAV", subtype="PCM_16")
|
||||
return buffer.getvalue(), used
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
# Chargé au démarrage : la première voix n'attend pas le chargement
|
||||
await asyncio.to_thread(_load, MODEL)
|
||||
log.info("Service prêt, %d voix clonée(s)", len(clones()))
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="SocialFlow Qwen TTS", lifespan=lifespan)
|
||||
|
||||
|
||||
def require_key(x_api_key: str | None = Header(None)) -> None:
|
||||
if API_KEY and x_api_key != API_KEY:
|
||||
raise HTTPException(status_code=401, detail="Invalid API Key")
|
||||
|
||||
|
||||
class SynthesisRequest(BaseModel):
|
||||
text: str
|
||||
voice: str | None = None
|
||||
instruct: str | None = None
|
||||
language: str = "French"
|
||||
|
||||
|
||||
@app.get("/health", dependencies=[Depends(require_key)])
|
||||
async def health():
|
||||
return {"status": "ok", "model": MODEL, "device": _device_label(), "busy": _lock.locked()}
|
||||
|
||||
|
||||
@app.get("/voices", dependencies=[Depends(require_key)])
|
||||
async def voices():
|
||||
presets = [{"id": k, "label": v[0], "gender": v[1]} for k, v in PRESETS.items()]
|
||||
cloned = [{k: c[k] for k in ("id", "label", "gender")} for c in clones().values()] if CLONE_MODEL else []
|
||||
return {"voices": [*cloned, *presets]}
|
||||
|
||||
|
||||
@app.post("/synthesize", dependencies=[Depends(require_key)])
|
||||
async def synthesize(request: SynthesisRequest):
|
||||
text = request.text.strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="Texte vide")
|
||||
async with _lock:
|
||||
started = time.monotonic()
|
||||
audio, used = await asyncio.to_thread(
|
||||
_generate, text, request.voice or DEFAULT_PRESET, (request.instruct or "").strip(), request.language
|
||||
)
|
||||
log.info("Voix %s : %d car. en %.1f s", used, len(text), time.monotonic() - started)
|
||||
return Response(audio, media_type="audio/wav", headers={"X-Voice": used})
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.12
|
||||
uvicorn[standard]==0.34.2
|
||||
pydantic==2.11.4
|
||||
qwen-tts==0.1.1
|
||||
soundfile==0.13.1
|
||||
numpy>=1.26
|
||||
@@ -0,0 +1,17 @@
|
||||
# Voix clonées (Qwen3-TTS)
|
||||
|
||||
Pour une voix française native, déposez ici un court extrait (5 à 15 s,
|
||||
une seule personne, sans musique) et sa transcription exacte :
|
||||
|
||||
```
|
||||
voices/
|
||||
camille.wav # extrait de référence
|
||||
camille.txt # texte prononcé dans l'extrait
|
||||
camille.json # optionnel : {"label": "Camille — chaleureuse", "gender": "female"}
|
||||
```
|
||||
|
||||
La voix apparaît alors dans le choix des voix Qwen sous l'identifiant
|
||||
`clone:camille`. Le dossier peut aussi être monté en volume
|
||||
(`QWEN_TTS_VOICES_DIR`) pour ajouter des voix sans reconstruire l'image.
|
||||
|
||||
Utilisez uniquement des voix dont vous avez les droits.
|
||||
@@ -161,6 +161,13 @@ export async function migrate() {
|
||||
ADD COLUMN IF NOT EXISTS "gemini_api_key" text;
|
||||
`);
|
||||
|
||||
// app_config.qwen_tts_url / qwen_tts_api_key (service Qwen3-TTS)
|
||||
await client.query(`
|
||||
ALTER TABLE "app_config"
|
||||
ADD COLUMN IF NOT EXISTS "qwen_tts_url" text,
|
||||
ADD COLUMN IF NOT EXISTS "qwen_tts_api_key" text;
|
||||
`);
|
||||
|
||||
// media.thumbnail_url (vignette conservée après suppression de la vidéo)
|
||||
await client.query(`
|
||||
ALTER TABLE "media"
|
||||
|
||||
@@ -14,6 +14,7 @@ import { minioService as cloudinaryService, buildMinioUrl } from "./services/min
|
||||
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
|
||||
import type { User, InsertUser, ScheduledPost } from "@shared/schema";
|
||||
import { analyticsRouter } from "./routes/analytics";
|
||||
import { normalizeQwenUrl } from "./services/reels/assets";
|
||||
import { reelsRouter } from "./routes/reels";
|
||||
import { remotionRouter } from "./routes/remotion";
|
||||
import { externalRouter } from "./routes/external";
|
||||
@@ -473,6 +474,64 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Qwen3-TTS : service local ou serveur GPU distant (ex. Unraid)
|
||||
app.get("/api/settings/qwen", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const config = await storage.getAppConfig();
|
||||
res.json({ url: config?.qwenTtsUrl ?? "", hasApiKey: !!config?.qwenTtsApiKey });
|
||||
} catch (error) {
|
||||
console.error("Error fetching Qwen config:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la récupération de la configuration" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/settings/qwen", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const url = normalizeQwenUrl(req.body?.url);
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: "Adresse invalide (ex. http://192.168.1.20:8001)" });
|
||||
}
|
||||
const { apiKey } = req.body;
|
||||
await storage.upsertAppConfig({
|
||||
qwenTtsUrl: url,
|
||||
// Clé laissée vide : on garde celle déjà enregistrée
|
||||
...(typeof apiKey === "string" && apiKey.trim() ? { qwenTtsApiKey: apiKey.trim() } : {}),
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error saving Qwen config:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la sauvegarde de la configuration" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/settings/qwen", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await storage.upsertAppConfig({ qwenTtsUrl: null, qwenTtsApiKey: null });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting Qwen config:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la suppression de la configuration" });
|
||||
}
|
||||
});
|
||||
|
||||
// Teste la connexion depuis le service FFmpeg (c'est lui qui appelle Qwen)
|
||||
app.post("/api/settings/qwen/test", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const config = await storage.getAppConfig();
|
||||
const url = normalizeQwenUrl(req.body?.url) ?? config?.qwenTtsUrl ?? undefined;
|
||||
const apiKey = (typeof req.body?.apiKey === "string" && req.body.apiKey.trim()) || config?.qwenTtsApiKey || undefined;
|
||||
const { ffmpegService } = await import("./services/ffmpeg");
|
||||
const catalog = await ffmpegService.listVoices(url ? { url, apiKey } : undefined);
|
||||
if (!catalog.qwen_available || !catalog.qwen_status) {
|
||||
return res.json({ ok: false, error: "Service Qwen injoignable (adresse, port, clé ou pare-feu)" });
|
||||
}
|
||||
res.json({ ok: true, ...catalog.qwen_status, voices: catalog.qwen.length });
|
||||
} catch (error) {
|
||||
console.error("Error testing Qwen config:", error);
|
||||
res.json({ ok: false, error: error instanceof Error ? error.message : "Test impossible" });
|
||||
}
|
||||
});
|
||||
|
||||
// Analytics Routes
|
||||
app.use("/api/analytics", requireAuth, analyticsRouter);
|
||||
|
||||
|
||||
+22
-1
@@ -9,7 +9,13 @@ import { ffmpegService, FFmpegServiceError } from '../services/ffmpeg';
|
||||
import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel';
|
||||
import { srtText } from '@shared/srt';
|
||||
import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue';
|
||||
import { resolveGeminiApiKey, resolveLogoPath, resolveStoreName } from '../services/reels/assets';
|
||||
import {
|
||||
qwenTargetFromConfig,
|
||||
resolveGeminiApiKey,
|
||||
resolveLogoPath,
|
||||
resolveQwenTarget,
|
||||
resolveStoreName,
|
||||
} from '../services/reels/assets';
|
||||
import { configuredRenderer } from '../services/reels/videoPipeline';
|
||||
import { openRouterService, describeGenerationError } from '../services/openrouter';
|
||||
|
||||
@@ -271,6 +277,20 @@ reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) =>
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Voix disponibles (dont les voix clonées Qwen) et disponibilité du moteur local
|
||||
* GET /api/reels/voices
|
||||
*/
|
||||
reelsRouter.get('/reels/voices', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const catalog = await ffmpegService.listVoices(qwenTargetFromConfig(await storage.getAppConfig()));
|
||||
res.json({ qwenAvailable: catalog.qwen_available, qwen: catalog.qwen, qwenStatus: catalog.qwen_status });
|
||||
} catch (error) {
|
||||
console.error('❌ Error listing voices:', error);
|
||||
res.json({ qwenAvailable: false, qwen: [], qwenStatus: null });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Prévisualiser la voix TTS
|
||||
* POST /api/reels/tts-preview
|
||||
@@ -288,6 +308,7 @@ reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
|
||||
engine: ttsEngine,
|
||||
style: ttsStyle,
|
||||
geminiApiKey: await resolveGeminiApiKey(ttsEngine),
|
||||
qwen: await resolveQwenTarget(ttsEngine),
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -11,12 +11,13 @@ import { Readable } from 'stream';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import type { ReadableStream as WebReadableStream } from 'stream/web';
|
||||
import type { SrtCue } from '@shared/srt';
|
||||
import type { TtsEngine, TtsStyle } from '@shared/voices';
|
||||
import type { TtsEngine, TtsStyle, VoiceOption } from '@shared/voices';
|
||||
|
||||
/** Un rendu long (stabilisation + encodage) peut dépasser plusieurs minutes. */
|
||||
const PROCESS_TIMEOUT_MS = 20 * 60_000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
|
||||
const TTS_TIMEOUT_MS = 3 * 60_000;
|
||||
// Qwen (local, CPU) met plusieurs fois la durée de la voix à la générer
|
||||
const TTS_TIMEOUT_MS = 15 * 60_000;
|
||||
const HEALTH_TIMEOUT_MS = 5_000;
|
||||
|
||||
export interface ReelRenderOptions {
|
||||
@@ -29,6 +30,7 @@ export interface ReelRenderOptions {
|
||||
ttsEngine?: TtsEngine;
|
||||
ttsStyle?: TtsStyle;
|
||||
geminiApiKey?: string;
|
||||
qwen?: QwenTarget;
|
||||
fontSize?: number;
|
||||
musicVolume?: number;
|
||||
drawText?: boolean;
|
||||
@@ -70,6 +72,21 @@ export interface TimedWord {
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Service Qwen TTS (local ou serveur GPU) réglé dans les paramètres. */
|
||||
export interface QwenTarget {
|
||||
url: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export interface VoiceCatalog {
|
||||
qwen: VoiceOption[];
|
||||
qwen_available: boolean;
|
||||
/** Modèle et carte graphique du service Qwen, null s'il ne répond pas. */
|
||||
qwen_status: { model?: string; device?: string; busy?: boolean } | null;
|
||||
gemini: VoiceOption[];
|
||||
edge: VoiceOption[];
|
||||
}
|
||||
|
||||
export interface VoicePreview {
|
||||
audio: Buffer;
|
||||
duration: number;
|
||||
@@ -141,6 +158,8 @@ export class FFmpegService {
|
||||
tts_engine: options.ttsEngine,
|
||||
tts_style: options.ttsStyle,
|
||||
gemini_api_key: options.geminiApiKey,
|
||||
qwen_tts_url: options.qwen?.url,
|
||||
qwen_tts_api_key: options.qwen?.apiKey,
|
||||
font_size: options.fontSize ?? 64,
|
||||
music_volume: options.musicVolume ?? 0.25,
|
||||
draw_text: options.drawText ?? true,
|
||||
@@ -213,6 +232,8 @@ export class FFmpegService {
|
||||
tts_engine: options.ttsEngine,
|
||||
tts_style: options.ttsStyle,
|
||||
gemini_api_key: options.geminiApiKey,
|
||||
qwen_tts_url: options.qwen?.url,
|
||||
qwen_tts_api_key: options.qwen?.apiKey,
|
||||
music_volume: options.musicVolume ?? 0.25,
|
||||
draw_text: options.drawText ?? true,
|
||||
stabilize: options.stabilize ?? false,
|
||||
@@ -285,10 +306,19 @@ export class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Voix proposées par moteur ; `qwen_available` indique si le service local répond. */
|
||||
async listVoices(qwen?: QwenTarget): Promise<VoiceCatalog> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (qwen?.url) headers['X-Qwen-Url'] = qwen.url;
|
||||
if (qwen?.apiKey) headers['X-Qwen-Key'] = qwen.apiKey;
|
||||
const response = await this.call('/voices', { method: 'GET', headers, timeoutMs: 30_000 });
|
||||
return await response.json() as VoiceCatalog;
|
||||
}
|
||||
|
||||
/** Génère la voix seule (aperçu), avec le minutage de chaque mot. */
|
||||
async previewVoice(
|
||||
text: string,
|
||||
options: { voice?: string; engine?: TtsEngine; style?: TtsStyle; geminiApiKey?: string } = {},
|
||||
options: { voice?: string; engine?: TtsEngine; style?: TtsStyle; geminiApiKey?: string; qwen?: QwenTarget } = {},
|
||||
): Promise<VoicePreview> {
|
||||
const response = await this.call('/preview-tts', {
|
||||
method: 'POST',
|
||||
@@ -299,6 +329,8 @@ export class FFmpegService {
|
||||
tts_engine: options.engine,
|
||||
tts_style: options.style,
|
||||
gemini_api_key: options.geminiApiKey,
|
||||
qwen_tts_url: options.qwen?.url,
|
||||
qwen_tts_api_key: options.qwen?.apiKey,
|
||||
}),
|
||||
timeoutMs: TTS_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* magasin), jusqu'ici recopiée dans chaque route.
|
||||
*/
|
||||
|
||||
import type { QwenTarget } from "../ffmpeg";
|
||||
import { storage } from "../../storage";
|
||||
import { buildMinioUrl, resolveInternalUrl } from "../minio";
|
||||
|
||||
@@ -39,13 +40,41 @@ export async function resolveLogoPath(): Promise<string | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Clé Gemini : configuration de l'application, sinon variable d'environnement. */
|
||||
/**
|
||||
* Clé Gemini : configuration de l'application, sinon variable d'environnement.
|
||||
* Aussi transmise avec Qwen, qui se replie sur Gemini si le service local échoue.
|
||||
*/
|
||||
export async function resolveGeminiApiKey(ttsEngine?: string): Promise<string | undefined> {
|
||||
if (ttsEngine !== "gemini") return undefined;
|
||||
if (ttsEngine !== "gemini" && ttsEngine !== "qwen") return undefined;
|
||||
const appConfig = await storage.getAppConfig();
|
||||
return appConfig?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
|
||||
}
|
||||
|
||||
/** Service Qwen TTS réglé dans les paramètres (sinon ffmpeg-api prend QWEN_TTS_URL). */
|
||||
export async function resolveQwenTarget(ttsEngine?: string): Promise<QwenTarget | undefined> {
|
||||
if (ttsEngine !== "qwen") return undefined;
|
||||
return qwenTargetFromConfig(await storage.getAppConfig());
|
||||
}
|
||||
|
||||
/** Adresse http(s) du service Qwen saisie dans les paramètres, sans « / » final ; null si invalide. */
|
||||
export function normalizeQwenUrl(value: unknown): string | null {
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function qwenTargetFromConfig(
|
||||
appConfig: { qwenTtsUrl?: string | null; qwenTtsApiKey?: string | null } | undefined,
|
||||
): QwenTarget | undefined {
|
||||
if (!appConfig?.qwenTtsUrl) return undefined;
|
||||
return { url: appConfig.qwenTtsUrl, apiKey: appConfig.qwenTtsApiKey ?? undefined };
|
||||
}
|
||||
|
||||
/** Nom affiché dans l'outro : celui de la page choisie, sinon de la première page de l'utilisateur. */
|
||||
export async function resolveStoreName(
|
||||
userId: string,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
|
||||
import { ffmpegService } from "../ffmpeg";
|
||||
import { generateVideoThumbnail } from "../thumbnail";
|
||||
import type { JobContext } from "./queue";
|
||||
import { resolveGeminiApiKey, resolveLogoPath } from "./assets";
|
||||
import { resolveGeminiApiKey, resolveLogoPath, resolveQwenTarget } from "./assets";
|
||||
import { REMOTION_TEMP_DIR, localHttpUrl, tempFileUrl, toDataUrl } from "./remotionAssets";
|
||||
import { renderReelComposition } from "./remotionRenderer";
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function runImagesReelJob({ job, progress }: JobContext): Promise<I
|
||||
engine: params.ttsEngine,
|
||||
style: params.ttsStyle,
|
||||
geminiApiKey: await resolveGeminiApiKey(params.ttsEngine),
|
||||
qwen: await resolveQwenTarget(params.ttsEngine),
|
||||
});
|
||||
for (const warning of voice.warnings) console.warn(`⚠️ [Reels] ${warning}`);
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { storage } from "../../storage";
|
||||
import { ffmpegService, type ReelRenderOptions } from "../ffmpeg";
|
||||
import { resolveInternalUrl } from "../minio";
|
||||
import type { JobContext } from "./queue";
|
||||
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl } from "./assets";
|
||||
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl, resolveQwenTarget } from "./assets";
|
||||
import { describePublishFailure, publishReelToPages, storeRenderedVideo } from "./publish";
|
||||
import { REMOTION_TEMP_DIR, tempFileUrl, toDataUrl } from "./remotionAssets";
|
||||
import { renderReelComposition } from "./remotionRenderer";
|
||||
@@ -39,10 +39,11 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
|
||||
throw new Error("Vidéo source introuvable ou invalide");
|
||||
}
|
||||
|
||||
const [musicUrl, logoPath, geminiApiKey] = await Promise.all([
|
||||
const [musicUrl, logoPath, geminiApiKey, qwen] = await Promise.all([
|
||||
resolveMusicUrl(params.musicTrackId, params.musicUrl),
|
||||
resolveLogoPath(),
|
||||
resolveGeminiApiKey(params.ttsEngine),
|
||||
resolveQwenTarget(params.ttsEngine),
|
||||
]);
|
||||
|
||||
const options: ReelRenderOptions = {
|
||||
@@ -54,6 +55,7 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
|
||||
ttsEngine: params.ttsEngine,
|
||||
ttsStyle: params.ttsStyle,
|
||||
geminiApiKey,
|
||||
qwen,
|
||||
fontSize: params.fontSize,
|
||||
musicVolume: params.musicVolume,
|
||||
drawText: params.drawText,
|
||||
|
||||
+2
-2
@@ -210,7 +210,7 @@ export interface IStorage {
|
||||
|
||||
// App Config
|
||||
getAppConfig(): Promise<AppConfig | undefined>;
|
||||
upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey'>>): Promise<AppConfig>;
|
||||
upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey' | 'qwenTtsUrl' | 'qwenTtsApiKey'>>): Promise<AppConfig>;
|
||||
}
|
||||
|
||||
export class DatabaseStorage implements IStorage {
|
||||
@@ -795,7 +795,7 @@ export class DatabaseStorage implements IStorage {
|
||||
return { ...created, clientSecret: decrypt(created.clientSecret) };
|
||||
}
|
||||
|
||||
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey'>>): Promise<AppConfig> {
|
||||
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey' | 'qwenTtsUrl' | 'qwenTtsApiKey'>>): Promise<AppConfig> {
|
||||
const existing = await this.getAppConfig();
|
||||
if (existing) {
|
||||
const [updated] = await db.update(appConfig)
|
||||
|
||||
@@ -260,6 +260,9 @@ export const appConfig = pgTable("app_config", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
externalApiKey: text("external_api_key"),
|
||||
geminiApiKey: text("gemini_api_key"),
|
||||
// Service Qwen3-TTS (local ou serveur GPU distant) : adresse et clé d'accès
|
||||
qwenTtsUrl: text("qwen_tts_url"),
|
||||
qwenTtsApiKey: text("qwen_tts_api_key"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
});
|
||||
|
||||
+18
-3
@@ -3,7 +3,7 @@
|
||||
* Même catalogue que le service Python (ffmpeg-service/app/voices.py).
|
||||
*/
|
||||
|
||||
export const TTS_ENGINES = ["gemini", "edge"] as const;
|
||||
export const TTS_ENGINES = ["qwen", "gemini", "edge"] as const;
|
||||
export type TtsEngine = (typeof TTS_ENGINES)[number];
|
||||
|
||||
export const TTS_STYLES = ["neutral", "dynamic", "warm", "calm", "promo"] as const;
|
||||
@@ -16,7 +16,7 @@ export interface VoiceOption {
|
||||
}
|
||||
|
||||
export const TTS_STYLE_OPTIONS: { id: TtsStyle; label: string; description: string }[] = [
|
||||
{ id: "dynamic", label: "Dynamique", description: "Enthousiaste, rythme entraînant" },
|
||||
{ id: "dynamic", label: "Dynamique", description: "Enthousiaste, débit naturel" },
|
||||
{ id: "warm", label: "Chaleureux", description: "Souriant et proche" },
|
||||
{ id: "promo", label: "Promo", description: "Annonce énergique des offres" },
|
||||
{ id: "calm", label: "Calme", description: "Posé et rassurant" },
|
||||
@@ -64,13 +64,28 @@ export const EDGE_VOICES: VoiceOption[] = [
|
||||
{ id: "fr-FR-HenriNeural", label: "Henri", gender: "male" },
|
||||
];
|
||||
|
||||
/** Voix prédéfinies de Qwen3-TTS (local). Les voix clonées sont listées par le service. */
|
||||
export const QWEN_VOICES: VoiceOption[] = [
|
||||
{ id: "serena", label: "Serena — douce et chaleureuse", gender: "female" },
|
||||
{ id: "vivian", label: "Vivian — vive et lumineuse", gender: "female" },
|
||||
{ id: "sohee", label: "Sohee — expressive", gender: "female" },
|
||||
{ id: "ono_anna", label: "Anna — espiègle", gender: "female" },
|
||||
{ id: "aiden", label: "Aiden — solaire", gender: "male" },
|
||||
{ id: "ryan", label: "Ryan — dynamique", gender: "male" },
|
||||
{ id: "uncle_fu", label: "Fu — grave et posée", gender: "male" },
|
||||
{ id: "dylan", label: "Dylan — jeune et naturelle", gender: "male" },
|
||||
{ id: "eric", label: "Eric — légèrement voilée", gender: "male" },
|
||||
];
|
||||
|
||||
export const DEFAULT_VOICE: Record<TtsEngine, string> = {
|
||||
qwen: "serena",
|
||||
gemini: "Kore",
|
||||
edge: "fr-FR-VivienneMultilingualNeural",
|
||||
};
|
||||
|
||||
export const DEFAULT_TTS_STYLE: TtsStyle = "dynamic";
|
||||
export const DEFAULT_TTS_STYLE: TtsStyle = "neutral";
|
||||
|
||||
export function voicesFor(engine: TtsEngine): VoiceOption[] {
|
||||
if (engine === "qwen") return QWEN_VOICES;
|
||||
return engine === "gemini" ? GEMINI_VOICES : EDGE_VOICES;
|
||||
}
|
||||
Reference in new issue
Block a user