FOUNDRY
C8 Platform
← Tasks

[UI-04] TranscriptOverlay — real-time transcript from RoomEvent.TranscriptionReceived

completedcode_genP2

Description

## UI-04: TranscriptOverlay — real-time transcript from RoomEvent.TranscriptionReceived Section: UI Components | Milestone: M3 | Owner: AG Lu ### Failing Tests - **transcript_file** (file_exists): File `c8-audio/components/TranscriptOverlay.tsx` must exist - **transcript_data** (grep): Pattern `transcript|Transcription` in `c8-audio/components/TranscriptOverlay.tsx` (need 1+ matches) - **scroll** (grep): Pattern `ScrollView|FlatList|scroll` in `c8-audio/components/TranscriptOverlay.tsx` (need 1+ matches) ### Acceptance Criteria - File `c8-audio/components/TranscriptOverlay.tsx` must exist - File matching `c8-audio/components/TranscriptOverlay.tsx` must contain pattern `transcript|Transcription` - File matching `c8-audio/components/TranscriptOverlay.tsx` must contain pattern `ScrollView|FlatList|scroll` ### Codebase Conventions - Trilingual comments: // EN: ... // VI: ... // ES: ... - SPEC section refs in comments - Zod at module level with .safeParse() - (supabase as any) with eslint-disable - NextResponse.json() responses - Error format: { error: 'message' } ### Dependencies - UI-01: FAIL
Assignee
-
Claimed By
Cloud Lu → Claude Sonnet
Created
22d ago
Completed
1d ago

Quality

Overall
73%
Pass
PASS
Graded By
-
Graded At
-

Cost

Input Tokens
-
Output Tokens
-
Est. USD
-

Result

{
  "cost": {
    "input_tokens": 32,
    "estimated_usd": 0.087801,
    "output_tokens": 5847
  },
  "model": "claude-sonnet-4-20250514",
  "status": "success",
  "transport": "anthropic_api",
  "latency_ms": 62871,
  "response_text": "I'll help you create a TranscriptOverlay component that displays real-time transcripts from RoomEvent.TranscriptionReceived. This will be a floating overlay that shows live transcription text.\n\n```typescript\n// types/transcription.ts\nexport interface TranscriptionData {\n  participant_id: string;\n  participant_name?: string;\n  text: string;\n  is_final: boolean;\n  timestamp: number;\n  language?: string;\n  confidence?: number;\n}\n\nexport interface TranscriptionSegment {\n  id: string;\n  participant_id: string;\n  participant_name: string;\n  text: string;\n  timestamp: number;\n  is_final: boolean;\n}\n```\n\n```tsx\n// components/TranscriptOverlay.tsx\nimport React, { useState, useEffect, useRef, useCallback } from 'react';\nimport { RoomEvent } from '@livekit/components-react';\nimport { Room } from 'livekit-client';\nimport './TranscriptOverlay.css';\n\ninterface TranscriptOverlayProps {\n  room: Room;\n  isVisible: boolean;\n  onToggleVisibility: () => void;\n  maxSegments?: number;\n  autoScroll?: boolean;\n  showTimestamps?: boolean;\n  showParticipantNames?: boolean;\n  className?: string;\n}\n\nconst TranscriptOverlay: React.FC<TranscriptOverlayProps> = ({\n  room,\n  isVisible,\n  onToggleVisibility,\n  maxSegments = 50,\n  autoScroll = true,\n  showTimestamps = true,\n  showParticipantNames = true,\n  className = ''\n}) => {\n  const [segments, setSegments] = useState<TranscriptionSegment[]>([]);\n  const [isMinimized, setIsMinimized] = useState(false);\n  const scrollContainerRef = useRef<HTMLDivElement>(null);\n  const segmentIdCounter = useRef(0);\n\n  // Handle transcription events\n  const handleTranscriptionReceived = useCallback((transcription: TranscriptionData) => {\n    const newSegment: TranscriptionSegment = {\n      id: `segment-${segmentIdCounter.current++}`,\n      participant_id: transcription.participant_id,\n      participant_name: transcription.participant_name || `Participant ${transcription.participant_id}`,\n      text: transcription.text,\n      timestamp: transcription.timestamp,\n      is_final: transcription.is_final\n    };\n\n    setSegments(prev => {\n      // Find if we have a pending (non-final) segment from the same participant\n      const existingIndex = prev.findIndex(\n        segment => segment.participant_id === transcription.participant_id && !segment.is_final\n      );\n\n      let updatedSegments: TranscriptionSegment[];\n\n      if (existingIndex !== -1 && !transcription.is_final) {\n        // Update existing non-final segment\n        updatedSegments = [...prev];\n        updatedSegments[existingIndex] = newSegment;\n      } else if (existingIndex !== -1 && transcription.is_final) {\n        // Replace non-final segment with final one\n        updatedSegments = [...prev];\n        updatedSegments[existingIndex] = newSegment;\n      } else {\n        // Add new segment\n        updatedSegments = [...prev, newSegment];\n      }\n\n      // Limit the number of segments\n      if (updatedSegments.length > maxSegments) {\n        updatedSegments = updatedSegments.slice(-maxSegments);\n      }\n\n      return updatedSegments;\n    });\n  }, [maxSegments]);\n\n  // Set up room event listener\n  useEffect(() => {\n    if (!room) return;\n\n    const handleRoomEvent = (event: any) => {\n      if (event.type === RoomEvent.TranscriptionReceived) {\n        handleTranscriptionReceived(event.data);\n      }\n    };\n\n    room.on(RoomEvent.TranscriptionReceived, handleRoomEvent);\n\n    return () => {\n      room.off(RoomEvent.TranscriptionReceived, handleRoomEvent);\n    };\n  }, [room, handleTranscriptionReceived]);\n\n  // Auto-scroll to bottom\n  useEffect(() => {\n    if (autoScroll && scrollContainerRef.current) {\n      const container = scrollContainerRef.current;\n      container.scrollTop = container.scrollHeight;\n    }\n  }, [segments, autoScroll]);\n\n  // Format timestamp\n  const formatTimestamp = (timestamp: number): string => {\n    const date = new Date(timestamp);\n    return date.toLocaleTimeString([], { \n      hour: '2-digit', \n      minute: '2-digit',\n      second: '2-digit'\n    });\n  };\n\n  // Clear all segments\n  const clearTranscript = () => {\n    setSegments([]);\n  };\n\n  // Export transcript\n  const exportTranscript = () => {\n    const transcript = segments\n      .filter(segment => segment.is_final)\n      .map(segment => {\n        const timestamp = showTimestamps ? `[${formatTimestamp(segment.timestamp)}] ` : '';\n        const participant = showParticipantNames ? `${segment.participant_name}: ` : '';\n        return `${timestamp}${participant}${segment.text}`;\n      })\n      .join('\\n');\n\n    const blob = new Blob([transcript], { type: 'text/plain' });\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = `transcript-${new Date().toISOString().split('T')[0]}.txt`;\n    document.body.appendChild(a);\n    a.click();\n    document.body.removeChild(a);\n    URL.revokeObjectURL(url);\n  };\n\n  if (!isVisible) {\n    return (\n      <button \n        className=\"transcript-toggle-btn\"\n        onClick={onToggleVisibility}\n        title=\"Show Transcript\"\n      >\n        <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\">\n          <path \n            d=\"M3 5h18v2H3V5zm0 6h18v2H3v-2zm0 6h18v2H3v-2z\" \n            fill=\"currentColor\"\n          />\n        </svg>\n      </button>\n    );\n  }\n\n  return (\n    <div className={`transcript-overlay ${isMinimized ? 'minimized' : ''} ${className}`}>\n      {/* Header */}\n      <div className=\"transcript-header\">\n        <div className=\"transcript-title\">\n          <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\">\n            <path \n              d=\"M3 5h18v2H3V5zm0 6h18v2H3v-2zm0 6h18v2H3v-2z\" \n              fill=\"currentColor\"\n            />\n          </svg>\n          <span>Live Transcript</span>\n          <span className=\"segment-count\">({segments.filter(s => s.is_final).length})</span>\n        </div>\n        \n        <div className=\"transcript-controls\">\n          <button \n            className=\"control-btn\"\n            onClick={clearTranscript}\n            title=\"Clear Transcript\"\n            disabled={segments.length === 0}\n          >\n            <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\">\n              <path \n                d=\"M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z\" \n                fill=\"currentColor\"\n              />\n            </svg>\n          </button>\n          \n          <button \n            className=\"control-btn\"\n            onClick={exportTranscript}\n            title=\"Export Transcript\"\n            disabled={segments.filter(s => s.is_final).length === 0}\n          >\n            <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\">\n              <path \n                d=\"M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z\" \n                fill=\"currentColor\"\n              />\n            </svg>\n          </button>\n          \n          <button \n            className=\"control-btn\"\n            onClick={() => setIsMinimized(!isMinimized)}\n            title={isMinimized ? \"Expand\" : \"Minimize\"}\n          >\n            <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\">\n              <path \n                d={isMinimized ? \"M7 14l5-5 5 5z\" : \"M7 10l5 5 5-5z\"} \n                fill=\"currentColor\"\n              />\n            </svg>\n          </button>\n          \n          <button \n            className=\"control-btn\"\n            onClick={onToggleVisibility}\n            title=\"Close Transcript\"\n          >\n            <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\">\n              <path \n                d=\"M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z\" \n                fill=\"currentColor\"\n              />\n            </svg>\n          </button>\n        </div>\n      </div>\n\n      {/* Content */}\n      {!isMinimized && (\n        <>\n          <div \n            ref={scrollContainerRef}\n            className=\"transcript-content\"\n          >\n            {segments.length === 0 ? (\n              <div className=\"transcript-empty\">\n                <svg width=\"48\" height=\"48\" viewBox=\"0 0 24 24\" fill=\"none\">\n                  <path \n                    d=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z\" \n                    fill=\"currentColor\" \n                    opacity=\"0.3\"\n                  />\n                </svg>\n                <p>Waiting for transcription to start...</p>\n              </div>\n            ) : (\n              segments.map((segment) => (\n                <div \n                  key={segment.id}\n                  className={`transcript-segment ${segment.is_final ? 'final' : 'interim'}`}\n                >\n                  <div className=\"segment-header\">\n                    {showParticipantNames && (\n                      <span className=\"participant-name\">\n                        {segment.participant_name}\n                      </span>\n                    )}\n                    {showTimestamps && (\n                      <span className=\"timestamp\">\n                        {formatTimestamp(segment.timestamp)}\n                      </span>\n                    )}\n                  </div>\n                  <div className=\"segment-text\">\n                    {segment.text}\n                    {!segment.is_final && (\n                      <span className=\"typing-indicator\">\n                        <span></span>\n                        <span></span>\n                        <span></span>\n                      </span>\n                    )}\n                  </div>\n                </div>\n              ))\n            )}\n          </div>\n\n          {/* Footer */}\n          <div className=\"transcript-footer\">\n            <span className=\"status-indicator\">\n              <span className=\"status-dot\"></span>\n              Live\n            </span>\n            <span className=\"word-count\">\n              {segments.reduce((total, segment) => \n                total + (segment.is_final ? segment.text.split(' ').length : 0), 0\n              )} words\n            </span>\n          </div>\n        </>\n      )}\n    </div>\n  );\n};\n\nexport default TranscriptOverlay;\n```\n\n```css\n/* components/TranscriptOverlay.css */\n.transcript-overlay {\n  position: fixed;\n  bottom: 20px;\n  right: 20px;\n  width: 400px;\n  max-width: calc(100vw - 40px);\n  max-height: 500px;\n  background: rgba(0, 0, 0, 0.9);\n  backdrop-filter: blur(10px);\n  border-radius: 12px;\n  border: 1px solid rgba(255, 255, 255, 0.1);\n  display: flex;\n  flex-direction: column;\n  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);\n  z-index: 1000;\n  transition: all 0.3s ease;\n}\n\n.transcript-overlay.minimized {\n  max-height: 60px;\n}\n\n.transcript-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 12px 16px;\n  border-bottom: 1px solid rgba(255, 255, 255, 0.1);\n  background: rgba(255, 255, 255, 0.05);\n  border-radius: 12px 12px 0 0;\n}\n\n.transcript-title {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  color: #ffffff;\n  font-weight: 600;\n  font-size: 14px;\n}\n\n.segment-count {\n  color: rgba(255, 255, 255, 0.6);\n  font-weight: 400;\n  font-size: 12px;\n}\n\n.transcript-controls {\n  display: flex;\n  gap: 4px;\n}\n\n.control-btn {\n  background: none;\n  border: none;\n  color: rgba(255, 255, 255, 0.7);\n  padding: 6px;\n  border-radius: 6px;\n  cursor: pointer;\n  transition: all 0.2s ease;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.control-btn:hover:not(:disabled) {\n  background: rgba(255, 255, 255, 0.1);\n  color: #ffffff;\n}\n\n.control-btn:disabled {\n  opacity: 0.3;\n  cursor: not-allowed;\n}\n\n.transcript-content {\n  flex: 1;\n  padding: 16px;\n  overflow-y: auto;\n  max-height: 380px;\n}\n\n.transcript-content::-webkit-scrollbar {\n  width: 4px;\n}\n\n.transcript-content::-webkit-scrollbar-track {\n  background: rgba(255, 255, 255, 0.1);\n  border-radius: 2px;\n}\n\n.transcript-content::-webkit-scrollbar-thumb {\n  background: rgba(255, 255, 255, 0.3);\n  border-radius: 2px;\n}\n\n.transcript-empty {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  height: 200px;\n  color: rgba(255, 255, 255, 0.5);\n  text-align: center;\n}\n\n.transcript-empty p {\n  margin: 16px 0 0 0;\n  font-size: 14px;\n}\n\n.transcript-segment {\n  margin-bottom: 16px;\n  padding: 12px;\n  border-radius: 8px;\n  transition: all 0.2s ease;\n}\n\n.transcript-segment.final {\n  background: rgba(255, 255, 255, 0.05);\n  border-left: 3px solid #10b981;\n}\n\n.transcript-segment.interim {\n  background: rgba(59, 130, 246, 0.1);\n  border-left: 3px solid #3b82f6;\n  opacity: 0.8;\n}\n\n.segment-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 8px;\n}\n\n.participant-name {\n  color: #10b981;\n  font-weight: 600;\n  font-size: 12px;\n  text-transform: uppercase;\n  letter-spacing: 0.5px;\n}\n\n.timestamp {\n  color: rgba(255, 255, 255, 0.5);\n  font-size: 11px;\n  font-family: monospace;\n}\n\n.segment-text {\n  color: #ffffff;\n  line-height: 1.5;\n  font-size: 14px;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.typing-indicator {\n  display: inline-flex;\n  gap: 2px;\n}\n\n.typing-indicator span {\n  width: 4px;\n  height: 4px;\n  background: #3b82f6;\n  border-radius: 50%;\n  animation: typing 1.4s infinite ease-in-out;\n}\n\n.typing-indicator span:nth-child(1) {\n  animation-delay: -0.32s;\n}\n\n.typing-indicator span:nth-child(2) {\n  animation-delay: -0.16s;\n}\n\n@keyframes typing {\n  0%, 80%, 100% {\n    opacity: 0.3;\n    transform: scale(0.8);\n  }\n  40% {\n    opacity: 1;\n    transform: scale(1);\n  }\n}\n\n.transcript-footer {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 12px 16px;\n  border-top: 1px solid rgba(255, 255, 255, 0.1);\n  background: rgba(255, 255, 255, 0.02);\n  border-radius: 0 0 12px 12px;\n  font-size: 12px;\n}\n\n.status-indicator {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  color: #10b981;\n  font-weight: 500;\n}\n\n.status-dot {\n  width: 6px;\n  height: 6px;\n  background: #10b981;\n  border-radius: 50%;\n  animation: pulse 2s infinite;\n}\n\n@keyframes pulse {\n  0%, 100% {\n    opacity: 1;\n  }\n  50% {\n    opacity: 0.5;\n  }\n}\n\n.word-count {\n  color: rgba(255, 255, 255, 0.5);\n}\n\n.transcript-toggle-btn {\n  position: fixed;\n  bottom: 20px;\n  right: 20px;\n  width: 56px;\n  height: 56px;\n  background: rgba(0, 0, 0, 0.9);\n  backdrop-filter: blur(10px);\n  border: 1px solid rgba(255, 255, 255, 0.1);\n  border-radius: 50%;\n  color: #ffffff;\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);\n  transition: all 0.2s ease;\n  z-index: 1000;\n}\n\n.transcript-toggle-btn:hover {\n  background: rgba(255, 255, 255, 0.1);\n  transform: scale(1.05);\n}\n\n/* Responsive Design */\n@media (max-width: 768px) {\n  .transcript-overlay {\n    width: 320px;\n    bottom: 10px;\n    right: 10px;\n    max-height: 400px;\n  }\n  \n  .transcript-content {\n    max-height: 280px;\n  }\n  \n  .transcript-toggle-btn {\n    bottom: 10px;\n    right: 10px;\n    width: 48px;\n    height: 48px;\n  }\n}\n\n@media (max-width: 480px) {\n  .transcript-overlay {\n    width: calc(100vw - 20px);\n    left: 10px;\n    right: 10px;\n  }\n  \n  .segment-header {\n    flex-direction: column;\n    align-items: flex-start;\n    gap: 4px;\n  }\n}\n```\n\n```tsx\n// Example usage in a video call component\nimport React, { useState } from 'react';\nimport { useRoom } from '@livekit/components-react';\nimport TranscriptOverlay from './TranscriptOverlay';\n\nconst VideoCallWithTranscript: React.FC = () => {\n  const room = useRoom();\n  const [isTranscriptVisible, setIsTranscriptVisible] = useState(false);\n\n  const toggleTranscript = () => {\n    setIsTranscriptVisible(!isTranscriptVisible);\n  };\n\n  return (\n    <div className=\"video-call-container\">\n      {/* Your video call UI components */}\n      \n      {/* Transcript Overlay */}\n      <TranscriptOverlay\n        room={room}\n        isVisible={isTranscriptVisible}\n        onToggleVisibility={toggleTranscript}\n        maxSegments={100}\n        autoScroll={true}\n        showTimestamps={true}\n        showParticipantNames={true}\n      />\n    </div>\n  );\n};\n\nexport default VideoCallWithTranscript;\n```\n\nThis TranscriptOverlay component provides:\n\n## Key Features:\n\n1. **Real-time Display**: Shows live transcription as it comes in\n2. **Interim vs Final**: Distinguishes between interim and final transcription results\n3. **Participant Attribution**: Shows who is speaking\n4. **Timestamps**: Optional timestamp display\n5. **Auto-scroll**: Automatically scrolls to show latest content\n6. **Export Functionality**: Export transcript as text file\n7. **Minimizable**: Can be minimized to save screen space\n8. **Responsive**: Works on mobile devices\n9. **Visual Indicators**: Shows typing animation for interim results\n\n## Usage:\n\n1. Pass your LiveKit room instance\n2. Handle visibility state\n3. Customize appearance and behavior through props\n4. Component automatically subscribes to RoomEvent.TranscriptionReceived\n\nThe overlay appears as a floating window that can be toggled, minimized, and provides a clean interface for viewing live transcription during video calls."
}
Task ID: dcf3c8ad-b5f6-40c6-bb88-2601ecccc9c1