feat: add adaptive TTS text-to-speech synchronization for Reels with real-time sync info widget

Calculate optimal word_duration based on actual TTS audio duration and text punctuation.

- server/services/ttsSync.ts: new service to measure TTS audio and compute sync timing

- server/routes/reels.ts: integrate sync into preview and background processing

- client: auto-calculate sync info widget in desktop and mobile Reel creation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.7 committed 2026-05-04 12:35:04 +02:00
1 parent 9913c307ed
commit 9ce735dc23
5 files changed
+1359 -3

No files matched your search

+45 -2
View File
@@ -14,6 +14,7 @@ import { db } from '../db';
import { cloudinaryConfig } from '@shared/schema';
import { eq } from 'drizzle-orm';
import { ttsSyncService } from '../services/ttsSync';
export const reelsRouter = Router();
// ============================================
@@ -349,13 +350,25 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
console.error('Error fetching watermark configuration', e);
}
// Calculer le word_duration synchronisé si TTS activé
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
const result = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
text: overlayText,
musicUrl: finalMusicUrl,
ttsEnabled,
ttsVoice,
wordDuration,
wordDuration: finalWordDuration,
fontSize,
musicVolume,
drawText,
@@ -405,6 +418,24 @@ reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
}
});
/**
* Calculer la synchronisation texte/voix TTS
* POST /api/reels/sync-info
*/
reelsRouter.post('/reels/sync-info', async (req: Request, res: Response) => {
try {
const { text, voice } = req.body;
if (!text || !voice) {
return res.status(400).json({ error: 'Texte et voix requis' });
}
const sync = await ttsSyncService.calculateSyncTiming(text, voice);
res.json(sync);
} catch (error) {
console.error('❌ Error calculating sync:', error);
res.status(500).json({ error: 'Erreur de calcul de synchronisation' });
}
});
/**
* Traitement d'arrière-plan pour les Reels
* Gère le pipeline FFmpeg -> Cloudinary -> Facebook de manière asynchrone
@@ -562,12 +593,24 @@ async function processReelBackground(
console.error('Error fetching watermark configuration', e);
}
// Calculer le word_duration synchronisé si TTS activé
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), {
text: overlayText,
musicUrl: finalMusicUrl,
ttsEnabled,
ttsVoice,
wordDuration,
wordDuration: finalWordDuration,
fontSize,
musicVolume,
drawText,
+89
View File
@@ -0,0 +1,89 @@
import { ffmpegService } from './ffmpeg';
import * as musicMetadata from 'music-metadata';
export interface SyncTiming {
wordDuration: number;
audioDuration: number;
wordCount: number;
punctuationPause: number;
isHealthy: boolean;
warnings: string[];
}
export class TtsSyncService {
/**
* Calcule le word_duration optimal pour synchroniser l'affichage du texte
* avec la durée réelle de la voix TTS générée.
*/
async calculateSyncTiming(text: string, voice: string): Promise<SyncTiming> {
const cleanTtsText = this.cleanForTts(text);
const cleanDisplayText = this.cleanForDisplay(text);
// 1. Générer le TTS preview et mesurer sa durée exacte
const ttsResult = await ffmpegService.previewTTS(cleanTtsText, voice);
if (!ttsResult.success || !ttsResult.audioBase64) {
throw new Error('TTS preview failed: ' + (ttsResult.error || 'unknown'));
}
const audioBuffer = Buffer.from(ttsResult.audioBase64, 'base64');
const metadata = await musicMetadata.parseBuffer(audioBuffer, 'audio/mpeg');
const audioDuration = metadata.format.duration || 0;
// 2. Analyser le texte
const words = cleanDisplayText.split(/\s+/).filter(w => w.length > 0);
const wordCount = words.length;
const punctuationPause = this.calculatePunctuationPauses(cleanDisplayText);
// 3. Calculer le word_duration ajusté
const effectiveDuration = Math.max(audioDuration - punctuationPause, 0.5);
const wordDuration = wordCount > 0 ? effectiveDuration / wordCount : 0.6;
// 4. Validation
const warnings: string[] = [];
const isHealthy = wordDuration >= 0.25 && wordDuration <= 1.5;
if (wordDuration < 0.25) {
warnings.push('Texte trop long : les mots défileront très vite. Envisagez de raccourcir.');
}
if (wordDuration > 1.2) {
warnings.push('Texte très court : affichage lent.');
}
return {
wordDuration,
audioDuration,
wordCount,
punctuationPause,
isHealthy,
warnings,
};
}
private cleanForTts(text: string): string {
return text
.replace(/#\w+/g, '')
.replace(/https?:\/\/\S+/g, '')
.replace(/[\uD83C-\uD83E][\uDC00-\uDFFF]|[☀-⛿✀-➿]/g, '')
.trim();
}
private cleanForDisplay(text: string): string {
return text.trim();
}
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;
}
}
export const ttsSyncService = new TtsSyncService();