marcosremar2's picture
Add SDK and improve video transition synchronization
3acaae2
Raw
History Blame Contribute Delete
13.6 kB
/**
* 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<SyncConfig> = {}) {
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<void> {
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<boolean> {
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<VoskResult | null> {
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<SyncResult> {
// 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;