import React, { useRef, useEffect, useState, useCallback } from 'react'; import { ArrowDownTrayIcon } from '@heroicons/react/24/outline'; import { useTranscriptionStore } from '../stores/transcriptionStore'; interface MinimapTimelineProps { audioRef: React.RefObject; videoRef: React.RefObject; canvasTimelineRef: React.RefObject; // Container that scrolls } export default function MinimapTimeline({ audioRef, videoRef, canvasTimelineRef }: MinimapTimelineProps) { const canvasRef = useRef(null); const containerRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [dragStartX, setDragStartX] = useState(0); const [dragStartScrollLeft, setDragStartScrollLeft] = useState(0); const [waveformData, setWaveformData] = useState([]); const [viewport, setViewport] = useState({ start: 0, end: 30, visible: false }); const { transcription, preprocessedAudio, currentTime, } = useTranscriptionStore(); // Constants const MINIMAP_HEIGHT = 80; const PIXELS_PER_SECOND = 300; // Match the CanvasTimeline scaling // Get media duration const getMediaDuration = useCallback(() => { const audioElement = audioRef.current; const videoElement = videoRef.current; if (audioElement && !isNaN(audioElement.duration)) { return audioElement.duration; } if (videoElement && !isNaN(videoElement.duration)) { return videoElement.duration; } return transcription?.total_duration || 0; }, [audioRef, videoRef, transcription]); const mediaDuration = getMediaDuration(); // Canvas width based on container const [canvasWidth, setCanvasWidth] = useState(800); // Update canvas width on resize useEffect(() => { const updateCanvasWidth = () => { if (containerRef.current) { setCanvasWidth(containerRef.current.clientWidth); } }; updateCanvasWidth(); window.addEventListener('resize', updateCanvasWidth); return () => window.removeEventListener('resize', updateCanvasWidth); }, []); // Track Canvas Timeline scroll position and calculate viewport const updateViewportFromScroll = useCallback(() => { const canvasContainer = canvasTimelineRef.current; if (!canvasContainer || mediaDuration === 0) return; const scrollLeft = canvasContainer.scrollLeft; const containerWidth = canvasContainer.clientWidth; const totalCanvasWidth = mediaDuration * PIXELS_PER_SECOND; // Calculate what time range is currently visible const startTime = (scrollLeft / totalCanvasWidth) * mediaDuration; const endTime = ((scrollLeft + containerWidth) / totalCanvasWidth) * mediaDuration; setViewport({ start: Math.max(0, startTime), end: Math.min(mediaDuration, endTime), visible: true }); }, [canvasTimelineRef, mediaDuration]); // Listen for scroll events on the Canvas Timeline container useEffect(() => { const canvasContainer = canvasTimelineRef.current; if (!canvasContainer) return; const handleScroll = () => { updateViewportFromScroll(); }; const handleLoadOrResize = () => { // Update viewport when container size changes updateViewportFromScroll(); }; // Initial viewport calculation updateViewportFromScroll(); canvasContainer.addEventListener('scroll', handleScroll); window.addEventListener('resize', handleLoadOrResize); return () => { canvasContainer.removeEventListener('scroll', handleScroll); window.removeEventListener('resize', handleLoadOrResize); }; }, [updateViewportFromScroll]); // Generate waveform data from preprocessed audio const generateWaveformFromPreprocessedAudio = useCallback(async () => { if (!preprocessedAudio?.data) { console.log('No preprocessed audio data available'); return; } try { console.log('Generating waveform from preprocessed audio data'); // Decode base64 audio data const audioBytes = atob(preprocessedAudio.data); const audioArrayBuffer = new ArrayBuffer(audioBytes.length); const audioUint8Array = new Uint8Array(audioArrayBuffer); for (let i = 0; i < audioBytes.length; i++) { audioUint8Array[i] = audioBytes.charCodeAt(i); } // Create audio context and decode the WAV data const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); const audioBuffer = await audioContext.decodeAudioData(audioArrayBuffer); // Extract audio data const channelData = audioBuffer.getChannelData(0); const samples = Math.min(800, canvasWidth); // Limit samples for performance const blockSize = Math.floor(channelData.length / samples); const waveform: number[] = []; for (let i = 0; i < samples; i++) { const start = i * blockSize; const end = Math.min(start + blockSize, channelData.length); let sum = 0; for (let j = start; j < end; j++) { sum += Math.abs(channelData[j]); } waveform.push(sum / (end - start)); } // Normalize waveform const max = Math.max(...waveform); const normalizedWaveform = max > 0 ? waveform.map(val => val / max) : waveform; setWaveformData(normalizedWaveform); console.log(`Generated waveform with ${normalizedWaveform.length} samples from preprocessed audio`); } catch (error) { console.error('Error generating waveform from preprocessed audio:', error); // Fallback to segment-based visualization generateFallbackWaveform(); } }, [preprocessedAudio, canvasWidth]); // Fallback waveform generation from segment data const generateFallbackWaveform = useCallback(() => { if (!transcription?.aligned_segments || mediaDuration === 0) return; console.log('Using fallback waveform generation from segments'); const segments = transcription.aligned_segments; const samples = Math.min(400, canvasWidth / 2); const bars = new Array(samples).fill(0); // Create waveform based on speech activity in segments segments.forEach(segment => { const startIndex = Math.floor((segment.start / mediaDuration) * samples); const endIndex = Math.ceil((segment.end / mediaDuration) * samples); for (let i = startIndex; i < Math.min(endIndex, samples); i++) { // Use segment text length and duration to estimate intensity const intensity = Math.min(1.0, segment.text.length / 50 + 0.3); bars[i] = Math.max(bars[i], intensity * (0.7 + Math.random() * 0.3)); } }); setWaveformData(bars); console.log(`Generated fallback waveform with ${bars.length} samples`); }, [transcription, mediaDuration, canvasWidth]); // Generate waveform when preprocessed audio becomes available useEffect(() => { if (preprocessedAudio?.data) { generateWaveformFromPreprocessedAudio(); } else if (transcription?.aligned_segments) { // Use fallback if we have segments but no preprocessed audio generateFallbackWaveform(); } }, [preprocessedAudio, generateWaveformFromPreprocessedAudio, generateFallbackWaveform]); // Draw the minimap const draw = useCallback(() => { const canvas = canvasRef.current; if (!canvas || mediaDuration === 0) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const { width, height } = canvas; // Clear canvas ctx.clearRect(0, 0, width, height); // Draw background ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0, 0, width, height); // Draw waveform if (waveformData.length > 0) { ctx.fillStyle = '#4a5568'; const barWidth = width / waveformData.length; waveformData.forEach((amplitude, index) => { const barHeight = amplitude * (height - 20); const x = index * barWidth; const y = (height - barHeight) / 2; ctx.fillRect(x, y, Math.max(1, barWidth - 1), barHeight); }); } // Draw segments as colored bars if (transcription?.aligned_segments) { transcription.aligned_segments.forEach((segment, index) => { const startX = (segment.start / mediaDuration) * width; const endX = (segment.end / mediaDuration) * width; const segmentWidth = endX - startX; // Alternate colors for segments ctx.fillStyle = index % 2 === 0 ? '#3182ce' : '#38a169'; ctx.fillRect(startX, height - 4, segmentWidth, 4); }); } // Draw current time indicator const currentTimeX = (currentTime / mediaDuration) * width; ctx.strokeStyle = '#f56565'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(currentTimeX, 0); ctx.lineTo(currentTimeX, height); ctx.stroke(); // Draw viewport region (what's visible in Canvas Timeline) if (viewport.visible) { const viewportStartX = (viewport.start / mediaDuration) * width; const viewportEndX = (viewport.end / mediaDuration) * width; // Draw viewport selection area (visible region highlight) ctx.fillStyle = 'rgba(66, 153, 225, 0.3)'; ctx.fillRect(viewportStartX, 0, viewportEndX - viewportStartX, height); // Draw left boundary line (start of visible area) ctx.strokeStyle = '#4299e1'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(viewportStartX, 0); ctx.lineTo(viewportStartX, height); ctx.stroke(); // Draw right boundary line (end of visible area) ctx.beginPath(); ctx.moveTo(viewportEndX, 0); ctx.lineTo(viewportEndX, height); ctx.stroke(); // Draw border around visible area ctx.strokeStyle = '#4299e1'; ctx.lineWidth = 1; ctx.strokeRect(viewportStartX, 0, viewportEndX - viewportStartX, height); } }, [waveformData, transcription, currentTime, viewport, mediaDuration]); // Update canvas size and redraw useEffect(() => { const canvas = canvasRef.current; if (canvas) { canvas.width = canvasWidth; canvas.height = MINIMAP_HEIGHT; draw(); } }, [canvasWidth, draw]); // Redraw when dependencies change useEffect(() => { draw(); }, [draw]); // Utility function to get time from X coordinate const getTimeFromX = useCallback((x: number) => { return (x / canvasWidth) * mediaDuration; }, [canvasWidth, mediaDuration]); // Check if clicking inside the viewport region const isClickingViewport = useCallback((x: number) => { if (!viewport.visible) return false; const viewportStartX = (viewport.start / mediaDuration) * canvasWidth; const viewportEndX = (viewport.end / mediaDuration) * canvasWidth; return x >= viewportStartX && x <= viewportEndX; }, [viewport, mediaDuration, canvasWidth]); // Scroll Canvas Timeline to show specific time const scrollToTime = useCallback((time: number) => { const canvasContainer = canvasTimelineRef.current; if (!canvasContainer) return; const totalCanvasWidth = mediaDuration * PIXELS_PER_SECOND; const targetScrollLeft = Math.max(0, (time / mediaDuration) * totalCanvasWidth); canvasContainer.scrollLeft = targetScrollLeft; }, [canvasTimelineRef, mediaDuration]); // Mouse event handlers const handleMouseDown = useCallback((e: React.MouseEvent) => { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; const x = e.clientX - rect.left; if (isClickingViewport(x)) { // Start dragging the viewport setIsDragging(true); setDragStartX(x); const canvasContainer = canvasTimelineRef.current; if (canvasContainer) { setDragStartScrollLeft(canvasContainer.scrollLeft); } } else { // Click outside viewport - jump to that position const clickTime = getTimeFromX(x); scrollToTime(clickTime); } }, [isClickingViewport, canvasTimelineRef, getTimeFromX, scrollToTime]); const handleMouseMove = useCallback((e: React.MouseEvent) => { if (!isDragging) return; const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; const x = e.clientX - rect.left; const deltaX = x - dragStartX; const canvasContainer = canvasTimelineRef.current; if (!canvasContainer) return; // Convert deltaX to scroll delta const totalCanvasWidth = mediaDuration * PIXELS_PER_SECOND; const scrollDelta = (deltaX / canvasWidth) * totalCanvasWidth; const newScrollLeft = Math.max(0, Math.min( dragStartScrollLeft + scrollDelta, canvasContainer.scrollWidth - canvasContainer.clientWidth )); canvasContainer.scrollLeft = newScrollLeft; }, [isDragging, dragStartX, dragStartScrollLeft, canvasTimelineRef, mediaDuration, canvasWidth]); const handleMouseUp = useCallback(() => { setIsDragging(false); }, []); // Add global mouse event listeners when dragging useEffect(() => { if (isDragging) { const handleGlobalMouseMove = (e: MouseEvent) => { handleMouseMove(e as any); }; const handleGlobalMouseUp = () => { handleMouseUp(); }; document.addEventListener('mousemove', handleGlobalMouseMove); document.addEventListener('mouseup', handleGlobalMouseUp); return () => { document.removeEventListener('mousemove', handleGlobalMouseMove); document.removeEventListener('mouseup', handleGlobalMouseUp); }; } }, [isDragging, handleMouseMove, handleMouseUp]); // Change cursor based on hover position const handleMouseHover = useCallback((e: React.MouseEvent) => { if (isDragging) return; const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; const x = e.clientX - rect.left; const canvas = canvasRef.current; if (!canvas) return; if (isClickingViewport(x)) { canvas.style.cursor = 'move'; } else { canvas.style.cursor = 'pointer'; } }, [isDragging, isClickingViewport]); // Download preprocessed audio as WAV file const downloadPreprocessedAudio = useCallback(() => { if (!preprocessedAudio?.data) { console.error('No preprocessed audio data available'); return; } try { // Decode base64 audio data const audioBytes = atob(preprocessedAudio.data); const audioArrayBuffer = new ArrayBuffer(audioBytes.length); const audioUint8Array = new Uint8Array(audioArrayBuffer); for (let i = 0; i < audioBytes.length; i++) { audioUint8Array[i] = audioBytes.charCodeAt(i); } // Create blob and download const blob = new Blob([audioUint8Array], { type: 'audio/wav' }); const url = URL.createObjectURL(blob); // Get original filename without extension const { file } = useTranscriptionStore.getState(); const originalName = file?.name?.replace(/\.[^/.]+$/, '') || 'audio'; const filename = `${originalName}_preprocessed_16khz_mono_normalized.wav`; // Create download link const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); // Clean up URL URL.revokeObjectURL(url); console.log(`Downloaded preprocessed audio: ${filename}`); } catch (error) { console.error('Error downloading preprocessed audio:', error); } }, [preprocessedAudio]); if (!transcription || mediaDuration === 0) { return null; } return (
Overview - Full Timeline ({Math.round(mediaDuration)}s) {preprocessedAudio ? ' • Preprocessed Waveform' : ' • Segment-Based View'} {preprocessedAudio && (
)}
{viewport.visible && ( Visible: {viewport.start.toFixed(1)}s - {viewport.end.toFixed(1)}s ({Math.round(viewport.end - viewport.start)}s view) )}
); }