fix: precise TTS word-boundary timing and remove wasted sync calculations

- ffmpeg-service/main.py: replace ffsubsync path with exact word-boundary timing from edge_tts for TTS subtitles. Karaoke styling preserved.
- server/services/ttsSync.ts: fix word count to match TTS-cleaned text, remove artificial punctuationPause subtraction, strip punctuation tokens from count.
- server/routes/reels.ts: remove redundant ttsSyncService calls in preview/background; word_duration is ignored by Python, these only wasted TTS generations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.7 committed 2026-05-04 13:13:55 +02:00
1 parent 9ce735dc23
commit 37aba33425
3 files changed
+43 -68

No files matched your search

+30 -17
View File
@@ -291,16 +291,16 @@ async def generate_tts_with_subs(
# Use display_text for subtitle content if provided # Use display_text for subtitle content if provided
text_to_display = display_text if display_text else text text_to_display = display_text if display_text else text
print("⚠️ Using ffsubsync for precise synchronization as requested by user.") print("🎯 Using precise word-boundary timing from TTS engine")
unsynced_srt_path = audio_path.with_suffix(".unsynced.srt") generate_ass_from_word_boundaries(
synced_srt_path = audio_path.with_suffix(".synced.srt") word_boundaries,
text_to_display,
# Subtitle syncing pipeline using ffsubsync for the generated TTS voice ass_path,
generate_unsynced_srt(text_to_display, unsynced_srt_path, total_duration=audio_duration) font_size=65,
run_ffsubsync(audio_path, unsynced_srt_path, synced_srt_path) total_duration=audio_duration,
convert_srt_to_ass(synced_srt_path, ass_path, font_size=65, delay=delay) delay=delay,
)
print(f"✅ TTS synchronisation completed with ffsubsync") print(f"✅ TTS synchronisation completed with word-boundary timing")
return return
else: else:
print(f"⚠️ Audio file empty or missing with voice: {attempt_voice}") print(f"⚠️ Audio file empty or missing with voice: {attempt_voice}")
@@ -322,19 +322,22 @@ def generate_ass_from_word_boundaries(
): ):
"""Generate ASS subtitles using precise word-level timing from TTS engine. """Generate ASS subtitles using precise word-level timing from TTS engine.
Groups words into readable chunks (~5 words or at punctuation) and uses Groups words into readable chunks (~3 words or at punctuation) and uses
the real start/end timestamps from the TTS engine for each chunk. the real start/end timestamps from the TTS engine for each chunk.
Includes karaoke fill tags for word-level visual highlighting.
""" """
if not word_boundaries: if not word_boundaries:
return return
# Group word boundaries into chunks of ~5 words, or split at punctuation # Group word boundaries into chunks of ~3 words, or split at punctuation
chunks = [] chunks = []
current_words = [] current_words = []
current_boundaries = []
current_start = word_boundaries[0]["offset"] current_start = word_boundaries[0]["offset"]
for i, wb in enumerate(word_boundaries): for i, wb in enumerate(word_boundaries):
current_words.append(wb["text"]) current_words.append(wb["text"])
current_boundaries.append(wb)
is_last = i == len(word_boundaries) - 1 is_last = i == len(word_boundaries) - 1
# Split at punctuation or every 3 words (tighter sync with voice) # Split at punctuation or every 3 words (tighter sync with voice)
ends_sentence = wb["text"].rstrip().endswith((".", "!", "?", ":", ",")) ends_sentence = wb["text"].rstrip().endswith((".", "!", "?", ":", ","))
@@ -345,12 +348,14 @@ def generate_ass_from_word_boundaries(
chunk_end = wb["offset"] + wb["duration"] chunk_end = wb["offset"] + wb["duration"]
chunks.append( chunks.append(
{ {
"text": " ".join(current_words), "words": current_words,
"boundaries": current_boundaries,
"start": current_start + delay, "start": current_start + delay,
"end": chunk_end + delay, "end": chunk_end + delay,
} }
) )
current_words = [] current_words = []
current_boundaries = []
# Next chunk starts at the next word's offset # Next chunk starts at the next word's offset
if not is_last: if not is_last:
current_start = word_boundaries[i + 1]["offset"] current_start = word_boundaries[i + 1]["offset"]
@@ -364,7 +369,7 @@ def generate_ass_from_word_boundaries(
for i in range(len(chunks) - 1): for i in range(len(chunks) - 1):
chunks[i]["end"] = max(chunks[i]["end"], chunks[i + 1]["start"] + 0.05) chunks[i]["end"] = max(chunks[i]["end"], chunks[i + 1]["start"] + 0.05)
# ASS Header # ASS Header (same karaoke style as convert_srt_to_ass)
header = f"""[Script Info] header = f"""[Script Info]
ScriptType: v4.00+ ScriptType: v4.00+
PlayResX: 1080 PlayResX: 1080
@@ -373,7 +378,7 @@ ScaledBorderAndShadow: yes
[V4+ Styles] [V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Sans,{font_size},&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,4,2,5,50,50,0,1 Style: Default,Sans,{font_size},&H0000FFFF,&H00FFFFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,4,2,5,50,50,0,1
[Events] [Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
@@ -383,8 +388,16 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
for chunk in chunks: for chunk in chunks:
start_ts = format_ass_time(chunk["start"]) start_ts = format_ass_time(chunk["start"])
end_ts = format_ass_time(chunk["end"]) end_ts = format_ass_time(chunk["end"])
sanitized = chunk["text"].replace("{", "(").replace("}", ")")
events += f"Dialogue: 0,{start_ts},{end_ts},Default,,0,0,0,,{sanitized}\n" karaoke_parts = []
for wb in chunk["boundaries"]:
# duration in centiseconds, minimum 10cs to avoid zero
w_dur_cs = max(10, int(wb["duration"] * 100))
sanitized = wb["text"].replace("{", "(").replace("}", ")")
karaoke_parts.append(f"{{\\kf{w_dur_cs}}}{sanitized}")
karaoke_text = " ".join(karaoke_parts)
events += f"Dialogue: 0,{start_ts},{end_ts},Default,,0,0,0,,{karaoke_text}\n"
with open(ass_path, "w", encoding="utf-8") as f: with open(ass_path, "w", encoding="utf-8") as f:
f.write(header + events) f.write(header + events)
+2 -22
View File
@@ -350,17 +350,7 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
console.error('Error fetching watermark configuration', e); console.error('Error fetching watermark configuration', e);
} }
// Calculer le word_duration synchronisé si TTS activé const finalWordDuration = wordDuration;
let finalWordDuration = wordDuration;
if (ttsEnabled && overlayText) {
try {
const sync = await ttsSyncService.calculateSyncTiming(overlayText, ttsVoice);
finalWordDuration = sync.wordDuration;
console.log(`🎯 TTS Sync (preview): ${sync.wordCount} mots, ${sync.audioDuration.toFixed(2)}s audio, word_duration=${finalWordDuration.toFixed(3)}s`);
} catch (syncErr) {
console.warn('⚠️ TTS sync failed (preview), using default word_duration:', syncErr);
}
}
// Traiter la vidéo via FFmpeg // Traiter la vidéo via FFmpeg
const result = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), { const result = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
@@ -593,17 +583,7 @@ async function processReelBackground(
console.error('Error fetching watermark configuration', e); console.error('Error fetching watermark configuration', e);
} }
// Calculer le word_duration synchronisé si TTS activé const finalWordDuration = wordDuration ?? 0.6;
let finalWordDuration = wordDuration ?? 0.6;
if (ttsEnabled && overlayText && ttsVoice) {
try {
const sync = await ttsSyncService.calculateSyncTiming(overlayText, ttsVoice);
finalWordDuration = sync.wordDuration;
console.log(`🎯 TTS Sync (background): ${sync.wordCount} mots, ${sync.audioDuration.toFixed(2)}s audio, word_duration=${finalWordDuration.toFixed(3)}s`);
} catch (syncErr) {
console.warn('⚠️ TTS sync failed (background), using default word_duration:', syncErr);
}
}
const ffmpegResult = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), { const ffmpegResult = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
text: overlayText, text: overlayText,
+11 -29
View File
@@ -16,11 +16,10 @@ export class TtsSyncService {
* avec la durée réelle de la voix TTS générée. * avec la durée réelle de la voix TTS générée.
*/ */
async calculateSyncTiming(text: string, voice: string): Promise<SyncTiming> { async calculateSyncTiming(text: string, voice: string): Promise<SyncTiming> {
const cleanTtsText = this.cleanForTts(text); const cleanText = this.cleanText(text);
const cleanDisplayText = this.cleanForDisplay(text);
// 1. Générer le TTS preview et mesurer sa durée exacte // 1. Générer le TTS preview et mesurer sa durée exacte
const ttsResult = await ffmpegService.previewTTS(cleanTtsText, voice); const ttsResult = await ffmpegService.previewTTS(cleanText, voice);
if (!ttsResult.success || !ttsResult.audioBase64) { if (!ttsResult.success || !ttsResult.audioBase64) {
throw new Error('TTS preview failed: ' + (ttsResult.error || 'unknown')); throw new Error('TTS preview failed: ' + (ttsResult.error || 'unknown'));
} }
@@ -29,14 +28,11 @@ export class TtsSyncService {
const metadata = await musicMetadata.parseBuffer(audioBuffer, 'audio/mpeg'); const metadata = await musicMetadata.parseBuffer(audioBuffer, 'audio/mpeg');
const audioDuration = metadata.format.duration || 0; const audioDuration = metadata.format.duration || 0;
// 2. Analyser le texte // 2. Analyser le texte (compte les mots réellement lus par la voix)
const words = cleanDisplayText.split(/\s+/).filter(w => w.length > 0); const wordCount = this.calculateWordCount(cleanText);
const wordCount = words.length;
const punctuationPause = this.calculatePunctuationPauses(cleanDisplayText);
// 3. Calculer le word_duration ajusté // 3. Calculer le word_duration
const effectiveDuration = Math.max(audioDuration - punctuationPause, 0.5); const wordDuration = wordCount > 0 ? audioDuration / wordCount : 0.6;
const wordDuration = wordCount > 0 ? effectiveDuration / wordCount : 0.6;
// 4. Validation // 4. Validation
const warnings: string[] = []; const warnings: string[] = [];
@@ -52,13 +48,13 @@ export class TtsSyncService {
wordDuration, wordDuration,
audioDuration, audioDuration,
wordCount, wordCount,
punctuationPause, punctuationPause: 0,
isHealthy, isHealthy,
warnings, warnings,
}; };
} }
private cleanForTts(text: string): string { private cleanText(text: string): string {
return text return text
.replace(/#\w+/g, '') .replace(/#\w+/g, '')
.replace(/https?:\/\/\S+/g, '') .replace(/https?:\/\/\S+/g, '')
@@ -66,23 +62,9 @@ export class TtsSyncService {
.trim(); .trim();
} }
private cleanForDisplay(text: string): string { private calculateWordCount(text: string): number {
return text.trim(); const tokens = text.split(/\s+/).filter(w => w.length > 0);
} return tokens.filter(w => /[a-zA-Z0-9À-ſ]/.test(w)).length;
private calculatePunctuationPauses(text: string): number {
let pause = 0;
const chars = text.split('');
for (const c of chars) {
if (',;'.includes(c)) pause += 0.3;
else if ('.!?'.includes(c)) pause += 0.6;
else if (':'.includes(c)) pause += 0.4;
else if (c === '\n') pause += 0.3;
}
// "..." compte comme un seul point mais pause plus longue
const ellipsisCount = (text.match(/\.\.\./g) || []).length;
pause += ellipsisCount * 0.3; // bonus pour les points de suspension
return pause;
} }
} }