diff --git a/src/stores/transcriptionStore.ts b/src/stores/transcriptionStore.ts new file mode 100644 index 0000000..4e7b639 --- /dev/null +++ b/src/stores/transcriptionStore.ts @@ -0,0 +1,68 @@ +import { create } from 'zustand'; + +export interface TranscriptionSegment { + text: string; + isFinal: boolean; + start: number; + end: number; +} + +interface TranscriptionState { + isRecording: boolean; + isConnecting: boolean; + completedSegments: TranscriptionSegment[]; + currentSegment: TranscriptionSegment | null; + wordCount: number; + elapsedSeconds: number; + + startSession: () => void; + stopSession: () => void; + saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => void; + resetSession: () => void; + tickElapsed: () => void; +} + +export const useTranscriptionStore = create((set, get) => ({ + isRecording: false, + isConnecting: false, + completedSegments: [], + currentSegment: null, + wordCount: 0, + elapsedSeconds: 0, + + startSession: () => { + set({ isRecording: true, isConnecting: false, elapsedSeconds: 0 }); + }, + + stopSession: () => { + set({ isRecording: false, isConnecting: false }); + }, + + saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => { + const { completedSegments, currentSegment } = get(); + + if (isFinal) { + // Final segment — move current to completed, clear current + const newCompleted = [...completedSegments]; + if (currentSegment && currentSegment.text.trim()) { + newCompleted.push({ ...currentSegment, isFinal: true }); + } + const newWordCount = newCompleted.reduce( + (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, + 0 + ); + set({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount }); + } else { + // In-progress segment + set({ currentSegment: { text, isFinal: false, ...metadata } }); + } + }, + + resetSession: () => { + set({ isRecording: false, isConnecting: false, completedSegments: [], currentSegment: null, wordCount: 0, elapsedSeconds: 0 }); + }, + + tickElapsed: () => { + set((state) => ({ elapsedSeconds: state.elapsedSeconds + 1 })); + }, +})); diff --git a/src/utils/tldraw/ui-overrides/components/shared/BasePanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/BasePanel.tsx index b1b2cae..decb5f1 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/BasePanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/BasePanel.tsx @@ -23,7 +23,8 @@ import { Search as SearchIcon, Navigation as NavigationIcon, Save as NodeIcon, - Assignment as ExamIcon + Assignment as ExamIcon, + Mic as MicIcon } from '@mui/icons-material'; import { CCShapesPanel } from './CCShapesPanel'; import { CCSlidesPanel } from './CCSlidesPanel'; @@ -33,6 +34,7 @@ import { CCYoutubePanel } from './CCYoutubePanel'; import { CCGraphPanel } from './CCGraphPanel'; import { CCExamMarkerPanel } from './CCExamMarkerPanel'; import { CCSearchPanel } from './CCSearchPanel' +import { CCTranscriptionPanel } from './CCTranscriptionPanel' import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles'; import './panel.css'; // import { CCNavigationPanel } from './navigation/CCNavigationPanel'; @@ -47,6 +49,7 @@ export const PANEL_TYPES = { { id: 'node-snapshot', label: 'Node', order: 20 }, { id: 'files', label: 'Files', order: 25 }, { id: 'cc-shapes', label: 'Shapes', order: 30 }, + { id: 'transcription', label: 'Transcription', order: 35 }, { id: 'slides', label: 'Slides', order: 40 }, { id: 'youtube', label: 'YouTube', order: 50 }, { id: 'graph', label: 'Graph', order: 60 }, @@ -208,6 +211,8 @@ export const BasePanel: React.FC = ({ return ; case 'cc-shapes': return ; + case 'transcription': + return ; case 'slides': return ; case 'youtube': @@ -231,6 +236,8 @@ export const BasePanel: React.FC = ({ switch (panelId) { case 'cabinets': return 'Manage file cabinets'; + case 'transcription': + return 'Record and transcribe lessons'; case 'cc-shapes': return 'Add shapes and elements to your canvas'; case 'slides': @@ -260,6 +267,8 @@ export const BasePanel: React.FC = ({ switch (currentPanelType) { case 'cabinets': return ; + case 'transcription': + return ; case 'files': return ; case 'cc-shapes': diff --git a/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx new file mode 100644 index 0000000..0197d80 --- /dev/null +++ b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx @@ -0,0 +1,163 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Mic as MicIcon, Stop as StopIcon } from "@mui/icons-material"; +import { useTranscriptionStore, TranscriptionSegment } from "../../../../../stores/transcriptionStore"; +import { TranscriptionService } from "../../../cc-base/cc-transcription/transcriptionService"; +import "./panel.css"; + +const formatTime = (seconds: number): string => { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return m.toString().padStart(2, "0") + ":" + s.toString().padStart(2, "0"); +}; + +export const CCTranscriptionPanel: React.FC = () => { + const { + isRecording, + completedSegments, + currentSegment, + wordCount, + elapsedSeconds, + startSession, + stopSession, + saveSegment, + resetSession, + tickElapsed, + } = useTranscriptionStore(); + + const [sessionName] = useState("Untitled Session"); + const serviceRef = useRef(null); + const timerRef = useRef | null>(null); + + useEffect(() => { + if (isRecording) { + timerRef.current = setInterval(tickElapsed, 1000); + } else if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [isRecording, tickElapsed]); + + const handleStart = async () => { + try { + startSession(); + const service = new TranscriptionService(); + service.setTranscriptionCallback((text, isFinal, metadata) => { + saveSegment(text, isFinal, metadata); + }); + await service.startTranscription(); + serviceRef.current = service; + } catch (error) { + console.error("Failed to start transcription:", error); + stopSession(); + } + }; + + const handleStop = () => { + if (serviceRef.current) { + serviceRef.current.stopTranscription(); + serviceRef.current = null; + } + stopSession(); + }; + + useEffect(() => { + return () => { + if (serviceRef.current) { + serviceRef.current.stopTranscription(); + serviceRef.current = null; + } + }; + }, []); + + return ( +
+
+
+ + {sessionName} + +
+ {wordCount} words + {formatTime(elapsedSeconds)} +
+
+
+ +
+ +
+ +
+ +
+ +
+
Live Feed
+ + {completedSegments.map((seg, i) => ( +
+ {seg.text} +
+ ))} + + {currentSegment && ( +
+ {currentSegment.text || "Listening..."} +
+ )} + + {!isRecording && completedSegments.length === 0 && !currentSegment && ( +
+ Press Start Recording to begin transcription +
+ )} +
+
+ ); +};