/** * SyncEngine - Motor de sincronização inteligente entre áudio e vídeo * * Detecta: * 1. Início da fala no áudio (VAD - Voice Activity Detection) * 2. Início do movimento labial no vídeo (diferença entre frames) * 3. Alinha os dois e ajusta FPS para sincronizar * * Opcionalmente usa Vosk para alinhamento fonético preciso */ import { VoskEngine, VoskResult, WordAlignment } from './VoskEngine'; export interface SyncConfig { /** Sample rate do áudio (default: 24000) */ audioSampleRate: number; /** FPS original do vídeo (default: 25) */ videoFps: number; /** Threshold RMS para detectar fala (0-1, default: 0.02) */ audioThreshold: number; /** Threshold de diferença de frames para detectar movimento (0-1, default: 0.05) */ videoThreshold: number; /** Janela de análise em ms (default: 50) */ analysisWindowMs: number; /** Usar Vosk para alinhamento fonético (default: false) */ useVosk: boolean; /** URL do modelo Vosk (opcional) */ voskModelUrl?: string; /** Callback para debug */ onDebug?: (info: SyncDebugInfo) => void; } export interface SyncDebugInfo { audioStartMs: number | null; audioEndMs: number | null; audioSpeechDuration: number | null; videoStartFrame: number | null; videoEndFrame: number | null; videoSpeechFrames: number | null; adjustedFps: number; syncStrategy: 'stretch' | 'skip' | 'repeat' | 'normal'; /** Resultado do Vosk (se habilitado) */ voskResult?: VoskResult; /** Alinhamento por palavra (se Vosk habilitado) */ wordAlignments?: WordAlignment[]; } export interface SyncResult { /** FPS ajustado para sincronização */ adjustedFps: number; /** Delay inicial antes de começar o vídeo (ms) */ videoDelayMs: number; /** Delay inicial antes de começar o áudio (ms) */ audioDelayMs: number; /** Frames para pular no início */ skipFramesStart: number; /** Frames para pular no final */ skipFramesEnd: number; /** Mapa de frames: índice original -> índice a renderizar (para repetir/pular) */ frameMap: number[]; /** Informações de debug */ debug: SyncDebugInfo; } export class SyncEngine { private config: SyncConfig; private voskEngine: VoskEngine | null = null; private voskReady: boolean = false; constructor(config: Partial = {}) { this.config = { audioSampleRate: config.audioSampleRate ?? 24000, videoFps: config.videoFps ?? 25, audioThreshold: config.audioThreshold ?? 0.02, videoThreshold: config.videoThreshold ?? 0.05, analysisWindowMs: config.analysisWindowMs ?? 50, useVosk: config.useVosk ?? false, voskModelUrl: config.voskModelUrl, onDebug: config.onDebug, }; // Inicializar Vosk se habilitado if (this.config.useVosk) { this.initVosk(); } } /** * Inicializa o motor Vosk (carrega modelo em background) */ private async initVosk(): Promise { try { this.voskEngine = new VoskEngine({ modelUrl: this.config.voskModelUrl, sampleRate: 16000, // Vosk usa 16kHz onModelProgress: (progress) => { console.log(`Vosk model loading: ${(progress * 100).toFixed(1)}%`); }, }); await this.voskEngine.loadModel(); this.voskReady = true; console.log('Vosk engine ready'); } catch (e) { console.warn('Failed to initialize Vosk, falling back to VAD:', e); this.voskEngine = null; this.voskReady = false; } } /** * Aguarda o Vosk estar pronto */ async waitForVosk(timeoutMs: number = 30000): Promise { if (!this.config.useVosk) return false; if (this.voskReady) return true; const start = Date.now(); while (Date.now() - start < timeoutMs) { if (this.voskReady) return true; await new Promise(r => setTimeout(r, 100)); } return this.voskReady; } /** * Analisa áudio usando Vosk para obter timestamps precisos por palavra */ async analyzeAudioWithVosk(audioSamples: Float32Array): Promise { if (!this.voskEngine || !this.voskReady) { return null; } try { return await this.voskEngine.processAudio(audioSamples, this.config.audioSampleRate); } catch (e) { console.error('Vosk analysis failed:', e); return null; } } /** * Analisa o áudio e detecta início/fim da fala */ analyzeAudio(audioSamples: Float32Array): { startMs: number; endMs: number; durationMs: number } { const sampleRate = this.config.audioSampleRate; const windowSize = Math.floor(sampleRate * this.config.analysisWindowMs / 1000); const threshold = this.config.audioThreshold; let startSample = 0; let endSample = audioSamples.length; // Detectar início da fala (primeiro ponto acima do threshold) for (let i = 0; i < audioSamples.length - windowSize; i += windowSize) { const rms = this.calculateRMS(audioSamples, i, windowSize); if (rms > threshold) { // Voltar um pouco para não cortar o início startSample = Math.max(0, i - windowSize); break; } } // Detectar fim da fala (último ponto acima do threshold) for (let i = audioSamples.length - windowSize; i >= startSample; i -= windowSize) { const rms = this.calculateRMS(audioSamples, i, windowSize); if (rms > threshold) { // Avançar um pouco para não cortar o final endSample = Math.min(audioSamples.length, i + windowSize * 2); break; } } const startMs = (startSample / sampleRate) * 1000; const endMs = (endSample / sampleRate) * 1000; return { startMs, endMs, durationMs: endMs - startMs, }; } /** * Analisa os frames e detecta início/fim do movimento labial * Usa diferença média entre frames consecutivos */ async analyzeVideo(frames: HTMLImageElement[] | ImageBitmap[]): Promise<{ startFrame: number; endFrame: number; speechFrames: number }> { if (frames.length < 2) { return { startFrame: 0, endFrame: frames.length - 1, speechFrames: frames.length }; } const differences: number[] = []; const canvas = new OffscreenCanvas(64, 64); // Pequeno para performance const ctx = canvas.getContext('2d')!; // Calcular diferença entre frames consecutivos let prevData: Uint8ClampedArray | null = null; for (const frame of frames) { ctx.drawImage(frame, 0, 0, 64, 64); const imageData = ctx.getImageData(0, 0, 64, 64); const data = imageData.data; if (prevData) { let diff = 0; // Comparar apenas canal de luminância (mais rápido) for (let i = 0; i < data.length; i += 4) { const lum1 = (prevData[i] + prevData[i + 1] + prevData[i + 2]) / 3; const lum2 = (data[i] + data[i + 1] + data[i + 2]) / 3; diff += Math.abs(lum1 - lum2); } differences.push(diff / (data.length / 4) / 255); // Normalizar 0-1 } prevData = new Uint8ClampedArray(data); } // Encontrar primeiro frame com movimento significativo const threshold = this.config.videoThreshold; let startFrame = 0; let endFrame = frames.length - 1; for (let i = 0; i < differences.length; i++) { if (differences[i] > threshold) { startFrame = i; break; } } for (let i = differences.length - 1; i >= startFrame; i--) { if (differences[i] > threshold) { endFrame = i + 1; // +1 porque differences[i] é entre frame i e i+1 break; } } return { startFrame, endFrame, speechFrames: endFrame - startFrame + 1, }; } /** * Calcula a sincronização entre áudio e vídeo */ async calculateSync( audioSamples: Float32Array, frames: HTMLImageElement[] | ImageBitmap[], totalAudioDurationMs: number ): Promise { // Tentar análise com Vosk primeiro (mais preciso) let voskResult: VoskResult | null = null; let audioAnalysis: { startMs: number; endMs: number; durationMs: number }; if (this.config.useVosk && this.voskReady) { voskResult = await this.analyzeAudioWithVosk(audioSamples); if (voskResult && voskResult.words.length > 0) { // Usar timestamps do Vosk (mais precisos) audioAnalysis = { startMs: voskResult.speechStartMs, endMs: voskResult.speechEndMs, durationMs: voskResult.speechDurationMs, }; console.log('Using Vosk for audio analysis:', voskResult.text); } else { // Fallback para VAD audioAnalysis = this.analyzeAudio(audioSamples); } } else { // Usar VAD padrão audioAnalysis = this.analyzeAudio(audioSamples); } // Analisar vídeo const videoAnalysis = await this.analyzeVideo(frames); const totalFrames = frames.length; const originalFps = this.config.videoFps; // Durações const audioSpeechDurationMs = audioAnalysis.durationMs; const videoSpeechDurationMs = (videoAnalysis.speechFrames / originalFps) * 1000; const totalVideoDurationMs = (totalFrames / originalFps) * 1000; // Calcular ratio de velocidade const speedRatio = audioSpeechDurationMs / videoSpeechDurationMs; // Determinar estratégia let syncStrategy: 'stretch' | 'skip' | 'repeat' | 'normal' = 'normal'; let adjustedFps = originalFps; if (Math.abs(speedRatio - 1) > 0.05) { // Mais de 5% de diferença if (speedRatio > 1) { // Áudio mais longo que vídeo - diminuir FPS (frames mais lentos) syncStrategy = 'stretch'; adjustedFps = originalFps / speedRatio; } else { // Vídeo mais longo que áudio - aumentar FPS (frames mais rápidos) syncStrategy = 'skip'; adjustedFps = originalFps / speedRatio; } } // Limitar FPS ajustado para valores razoáveis adjustedFps = Math.max(10, Math.min(60, adjustedFps)); // Calcular delays para alinhar início da fala const videoStartMs = (videoAnalysis.startFrame / originalFps) * 1000; const audioStartMs = audioAnalysis.startMs; let videoDelayMs = 0; let audioDelayMs = 0; if (audioStartMs > videoStartMs) { // Áudio começa depois - atrasar vídeo videoDelayMs = audioStartMs - videoStartMs; } else { // Vídeo começa depois - atrasar áudio audioDelayMs = videoStartMs - audioStartMs; } // Criar mapa de frames (para repetição/skip se necessário) const frameMap = this.createFrameMap( totalFrames, videoAnalysis.startFrame, videoAnalysis.endFrame, speedRatio ); const debug: SyncDebugInfo = { audioStartMs: audioAnalysis.startMs, audioEndMs: audioAnalysis.endMs, audioSpeechDuration: audioAnalysis.durationMs, videoStartFrame: videoAnalysis.startFrame, videoEndFrame: videoAnalysis.endFrame, videoSpeechFrames: videoAnalysis.speechFrames, adjustedFps, syncStrategy, voskResult: voskResult ?? undefined, wordAlignments: voskResult?.words, }; this.config.onDebug?.(debug); return { adjustedFps, videoDelayMs, audioDelayMs, skipFramesStart: videoAnalysis.startFrame, skipFramesEnd: totalFrames - videoAnalysis.endFrame - 1, frameMap, debug, }; } /** * Cria um mapa de frames para sincronização * Pode repetir ou pular frames para ajustar à duração do áudio */ private createFrameMap( totalFrames: number, startFrame: number, endFrame: number, speedRatio: number ): number[] { const speechFrames = endFrame - startFrame + 1; const targetFrames = Math.round(speechFrames * speedRatio); const frameMap: number[] = []; // Frames antes da fala (manter original) for (let i = 0; i < startFrame; i++) { frameMap.push(i); } // Frames durante a fala (ajustar) if (Math.abs(speedRatio - 1) > 0.05) { for (let i = 0; i < targetFrames; i++) { const sourceFrame = startFrame + Math.floor((i / targetFrames) * speechFrames); frameMap.push(Math.min(sourceFrame, endFrame)); } } else { // Sem ajuste necessário for (let i = startFrame; i <= endFrame; i++) { frameMap.push(i); } } // Frames depois da fala (manter original) for (let i = endFrame + 1; i < totalFrames; i++) { frameMap.push(i); } return frameMap; } /** * Calcula RMS (Root Mean Square) de uma janela de áudio */ private calculateRMS(samples: Float32Array, start: number, length: number): number { let sum = 0; const end = Math.min(start + length, samples.length); for (let i = start; i < end; i++) { sum += samples[i] * samples[i]; } return Math.sqrt(sum / (end - start)); } /** * Versão simplificada para sincronização em tempo real * Usa apenas as durações totais, sem análise de conteúdo */ calculateSimpleSync( totalAudioDurationMs: number, totalVideoFrames: number ): { adjustedFps: number; frameInterval: number } { const originalFps = this.config.videoFps; const videoDurationMs = (totalVideoFrames / originalFps) * 1000; // Calcular FPS ajustado const speedRatio = totalAudioDurationMs / videoDurationMs; let adjustedFps = originalFps / speedRatio; // Limitar para valores razoáveis adjustedFps = Math.max(10, Math.min(60, adjustedFps)); return { adjustedFps, frameInterval: 1000 / adjustedFps, }; } } export default SyncEngine;