mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
- 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
230 lines
9.3 KiB
Python
230 lines
9.3 KiB
Python
"""Voix calée sur un fichier SRT.
|
|
|
|
Tout le texte est lu d'une seule traite, puis la voix est découpée phrase par
|
|
phrase au point le plus calme de chaque pause, et chaque morceau est posé à l'instant de son
|
|
sous-titre. Une lecture unique garde la même intonation, le même rythme et le
|
|
même niveau d'un bout à l'autre ; lire chaque sous-titre séparément donnait
|
|
une voix différente à chaque phrase, et le découpage aux bornes estimées par
|
|
Whisper rognait des syllabes.
|
|
|
|
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é.
|
|
|
|
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
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from . import proc, quality, tts
|
|
from .align import Word, display_tokens
|
|
from .text import clean_text
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# 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"[.!?…]$")
|
|
|
|
|
|
@dataclass
|
|
class Cue:
|
|
start: float
|
|
end: float
|
|
text: str
|
|
|
|
|
|
@dataclass
|
|
class Segment:
|
|
"""Morceau de la voix lue, à poser à l'instant d'un sous-titre."""
|
|
|
|
source_start: float
|
|
source_end: float
|
|
words: list[Word]
|
|
|
|
|
|
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:
|
|
"""Chaque sous-titre finit par une ponctuation : la voix y marque une pause,
|
|
ce qui laisse un silence où couper."""
|
|
text = clean_text(text)
|
|
return text if _SENTENCE_END.search(text) else f"{text}."
|
|
|
|
|
|
def split_by_cue(words: list[Word], cue_texts: list[str]) -> list[list[Word]] | None:
|
|
"""Répartit les mots de la lecture complète entre les sous-titres
|
|
(les mots affichés suivent l'ordre du texte). None si les comptes divergent."""
|
|
counts = [len(display_tokens(t)) for t in cue_texts]
|
|
if sum(counts) != len(words) or 0 in counts:
|
|
return None
|
|
groups, index = [], 0
|
|
for count in counts:
|
|
groups.append(words[index : index + count])
|
|
index += count
|
|
return groups
|
|
|
|
|
|
def segments_from_reading(groups: list[list[Word]], envelope: quality.Envelope) -> list[Segment] | None:
|
|
"""Découpe la lecture complète entre les phrases, au point le plus calme de
|
|
chaque pause, puis retire le silence autour de chaque phrase en gardant une
|
|
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, after=cuts[-1]))
|
|
cuts.append(envelope.duration)
|
|
if any(b <= a for a, b in zip(cuts, cuts[1:])):
|
|
return None
|
|
|
|
segments = []
|
|
for i, group in enumerate(groups):
|
|
seg_start, seg_end = envelope.speech_span(cuts[i], cuts[i + 1])
|
|
segments.append(Segment(seg_start, seg_end, group))
|
|
return segments
|
|
|
|
|
|
async def synthesize_cues(
|
|
*,
|
|
cues: list[Cue],
|
|
engine: str | None,
|
|
voice: str | None,
|
|
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, 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
|
|
script = " ".join(texts)
|
|
reading = await tts.synthesize(text=script, display_text=script, workdir=workdir, **options)
|
|
envelope = await quality.load_envelope(reading.path)
|
|
groups = split_by_cue(reading.words, texts)
|
|
segments = segments_from_reading(groups, envelope) if groups else None
|
|
|
|
if segments:
|
|
sources = [(reading.path, seg) for seg in segments]
|
|
warnings = list(reading.warnings)
|
|
used_engine, used_voice = reading.engine, reading.voice
|
|
else:
|
|
# 2. Repli : chaque sous-titre lu séparément, avec la voix de la lecture complète
|
|
log.warning("Lecture SRT non répartie entre les sous-titres : lecture phrase par phrase")
|
|
sources, warnings = [], list(reading.warnings)
|
|
used_engine, used_voice = reading.engine, reading.voice
|
|
for index, text in enumerate(texts):
|
|
cue_dir = workdir / f"cue_{index:03d}"
|
|
cue_dir.mkdir(exist_ok=True)
|
|
track = await tts.synthesize(
|
|
text=text,
|
|
display_text=text,
|
|
workdir=cue_dir,
|
|
**{**options, "engine": used_engine, "voice": used_voice or voice},
|
|
)
|
|
warnings += [w for w in track.warnings if w not in warnings]
|
|
cue_envelope = await quality.load_envelope(track.path)
|
|
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, start, (source, segment)) in enumerate(zip(cues, starts, sources)):
|
|
fitted = workdir / f"segment_{index:03d}.wav"
|
|
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
|
|
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)
|
|
duration = (await proc.probe(output)).duration
|
|
log.info(
|
|
"Voix SRT prête : %s/%s, %d sous-titres, %.1f s (%s)",
|
|
used_engine,
|
|
used_voice,
|
|
len(cues),
|
|
duration,
|
|
"lecture unique" if segments else "phrase par phrase",
|
|
)
|
|
return tts.VoiceTrack(output, duration, _monotonic(words), used_engine, used_voice, warnings)
|
|
|
|
|
|
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"
|
|
)
|
|
return [
|
|
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(source),
|
|
"-af", filters, "-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(output),
|
|
] # fmt: skip
|
|
|
|
|
|
def mix_command(segments: list[tuple[Path, float]], output: Path) -> list[str]:
|
|
"""Pose chaque segment de voix à son instant, sur une seule piste."""
|
|
command = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"]
|
|
for path, _ in segments:
|
|
command += ["-i", str(path)]
|
|
graph = [f"[{i}:a]adelay={int(round(start * 1000))}:all=1[s{i}]" for i, (_, start) in enumerate(segments)]
|
|
labels = "".join(f"[s{i}]" for i in range(len(segments)))
|
|
graph.append(
|
|
f"{labels}amix=inputs={len(segments)}:duration=longest:dropout_transition=0:normalize=0[voice]"
|
|
)
|
|
return command + [
|
|
"-filter_complex", ";".join(graph), "-map", "[voice]",
|
|
"-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(output),
|
|
] # fmt: skip
|
|
|
|
|
|
def _monotonic(words: list[Word]) -> list[Word]:
|
|
for prev, cur in zip(words, words[1:]):
|
|
cur.start = max(cur.start, prev.start + 0.01)
|
|
cur.end = max(cur.end, cur.start + 0.05)
|
|
return words
|