From 5bf9e5344967091f37a7d9bd760613c31593534d Mon Sep 17 00:00:00 2001 From: K Car Date: Thu, 10 Jul 2025 09:25:19 +0100 Subject: [PATCH] updates --- .dockerignore | 7 + .env | 6 +- Dockerfile | 17 +- docker-compose.yml | 8 + nginx/nginx-macos-prod.conf | 55 --- nginx/{nginx-win-prod.conf => nginx-ssl.conf} | 0 nginx/{nginx-macos-dev.conf => nginx.conf} | 6 +- src/App.tsx | 402 ++++++++++++++++++ src/env.d.ts | 13 + src/index.css | 9 + src/main.tsx | 10 + 11 files changed, 457 insertions(+), 76 deletions(-) create mode 100644 .dockerignore create mode 100644 docker-compose.yml delete mode 100644 nginx/nginx-macos-prod.conf rename nginx/{nginx-win-prod.conf => nginx-ssl.conf} (100%) rename nginx/{nginx-macos-dev.conf => nginx.conf} (92%) create mode 100644 src/App.tsx create mode 100644 src/env.d.ts create mode 100644 src/index.css create mode 100644 src/main.tsx diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fcf7a34 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.git +Dockerfile +docker-compose.yml +nginx/ +*.log +.env \ No newline at end of file diff --git a/.env b/.env index 1237fb0..84f5873 100644 --- a/.env +++ b/.env @@ -1,5 +1 @@ -VITE_WHISPERLIVE_URL=wss://whisperlive.classroomcopilot.ai -VITE_APP_URL=whisperlive.classroomcopilot.ai -VITE_APP_PROTOCOL=https -VITE_APP_NAME=ClassroomCopilotLive -VITE_DEV=false \ No newline at end of file +VITE_WHISPERLIVE_URL=wss://whisperlive.kevlarai.com \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 1d3eec6..7532bab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,16 +9,13 @@ RUN corepack prepare yarn@4.8.0 --activate # Copy package files COPY package.json yarn.lock ./ -COPY whisperlive-frontend/package.json ./whisperlive-frontend/ - -# Now run yarn install RUN yarn install # Copy source files -COPY whisperlive-frontend ./whisperlive-frontend +COPY . ./ # Build the application -RUN yarn workspace whisperlive-frontend build +RUN yarn build # Production stage FROM nginx:alpine @@ -26,17 +23,11 @@ FROM nginx:alpine # Create SSL directory RUN mkdir -p /etc/nginx/ssl -# Create a win/macos switcher -ARG BUILD_OS -ENV BUILD_OS=${BUILD_OS} -ARG NGINX_MODE -ENV NGINX_MODE=${NGINX_MODE} - # Copy nginx configuration -COPY whisperlive-frontend/nginx/nginx-${BUILD_OS}-${NGINX_MODE:-dev}.conf /etc/nginx/conf.d/default.conf +COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf # Copy built files from builder -COPY --from=builder /app/whisperlive-frontend/dist /usr/share/nginx/html +COPY --from=builder /app/dist /usr/share/nginx/html # Start nginx CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b871329 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + whisperlive-frontend: + container_name: whisperlive-frontend + build: + context: . + dockerfile: ./Dockerfile + ports: + - "80:80" \ No newline at end of file diff --git a/nginx/nginx-macos-prod.conf b/nginx/nginx-macos-prod.conf deleted file mode 100644 index e97326d..0000000 --- a/nginx/nginx-macos-prod.conf +++ /dev/null @@ -1,55 +0,0 @@ -server { - listen 5054; - server_name localhost; - return 301 https://$server_name$request_uri; -} - -server { - listen 5055 ssl; - server_name localhost; - root /usr/share/nginx/html; - index index.html; - - # SSL configuration - ssl_certificate /etc/nginx/ssl/fullchain.pem; - ssl_certificate_key /etc/nginx/ssl/privkey.pem; - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; - ssl_prefer_server_ciphers on; - ssl_session_cache shared:SSL:10m; - ssl_session_timeout 10m; - - # Enable gzip compression - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - - # Security headers - add_header X-Frame-Options "SAMEORIGIN"; - add_header X-XSS-Protection "1; mode=block"; - add_header X-Content-Type-Options "nosniff"; - add_header Referrer-Policy "strict-origin-when-cross-origin"; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' ws: wss:;"; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - - location / { - try_files $uri $uri/ /index.html; - } - - # Cache static assets - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { - expires 1y; - add_header Cache-Control "public, no-transform"; - } - - # WebSocket proxy for WhisperLive server - location /ws { - proxy_pass https://whisperlive.classroomcopilot.ai; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} \ No newline at end of file diff --git a/nginx/nginx-win-prod.conf b/nginx/nginx-ssl.conf similarity index 100% rename from nginx/nginx-win-prod.conf rename to nginx/nginx-ssl.conf diff --git a/nginx/nginx-macos-dev.conf b/nginx/nginx.conf similarity index 92% rename from nginx/nginx-macos-dev.conf rename to nginx/nginx.conf index b1cd048..0611bb8 100644 --- a/nginx/nginx-macos-dev.conf +++ b/nginx/nginx.conf @@ -1,6 +1,6 @@ server { - listen 5054; - server_name localhost; + listen 80; + server_name whisperlive-frontend.kevlarai.com; root /usr/share/nginx/html; index index.html; @@ -29,7 +29,7 @@ server { # WebSocket proxy for WhisperLive server location /ws { - proxy_pass https://whisperlive-macos:5050; + proxy_pass https://whisperlive.kevlarai.com; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..84b3d36 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,402 @@ +import { useState } from 'react' +import { Tab } from '@headlessui/react' +import { MicrophoneIcon, StopIcon, ArrowUpTrayIcon } from '@heroicons/react/24/solid' + + +const wsUrl = import.meta.env.VITE_WHISPERLIVE_URL +console.log('wsUrl', wsUrl) +console.log('process.env', import.meta.env) +interface WhisperLiveOptions { + language: string | null + task: 'transcribe' | 'translate' + model: 'tiny.en' | 'base.en' | 'small.en' | 'medium.en' | 'large' + useVad: boolean +} + +function classNames(...classes: string[]) { + return classes.filter(Boolean).join(' ') +} + +// Generate a UUID v4 +function generateUUID(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + console.log(v.toString(16)) + return v.toString(16); + }); +} + +export default function App() { + const [isRecording, setIsRecording] = useState(false) + const [transcript, setTranscript] = useState('') + const [segments, setSegments] = useState>([]) + const [currentSegment, setCurrentSegment] = useState<{text: string, completed: boolean} | null>(null) + const [options, setOptions] = useState({ + language: null, // Auto-detect + task: 'transcribe', + model: 'base.en', + useVad: true + }) + + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const wsUrl = import.meta.env.VITE_WHISPERLIVE_URL + const socket = new WebSocket(`${wsUrl}/ws`) + + console.log(socket) + socket.onopen = () => { + socket.send(JSON.stringify({ + uid: generateUUID(), + ...options + })) + } + + socket.onmessage = (event) => { + console.log(event) + const data = JSON.parse(event.data) + if (data.status === 'WAIT') { + console.log('data', data) + alert(data.message) + return + } + if (data.language && options.language === null) { + console.log('data.language', data.language) + setOptions(prev => ({ ...prev, language: data.language })) + return + } + if (data.message === 'DISCONNECT') { + console.log('data.message', data.message) + setIsRecording(false) + return + } + + // Handle transcription segments + if (data.segments) { + console.log('data.segments', data.segments) + // Process completed segments + const completedSegments = data.segments.slice(0, -1).map((segment: { text: string }) => ({ + text: segment.text, + completed: true + })) + + // Process current (incomplete) segment + const newCurrentSegment = data.segments.length > 0 ? { + text: data.segments[data.segments.length - 1].text, + completed: false + } : null + + // Update segments state + setSegments(completedSegments) + + // Update current segment state + setCurrentSegment(newCurrentSegment) + + // Update transcript with all completed segments + const fullTranscript = completedSegments.map((s: { text: string }) => s.text).join(' ') + setTranscript(fullTranscript) + } + } + + console.log('audioContext') + const audioContext = new AudioContext() + console.log('audioContext', audioContext) + const source = audioContext.createMediaStreamSource(stream) + console.log('source', source) + + // Create and load the audio worklet + await audioContext.audioWorklet.addModule('audioProcessor.js') + const workletNode = new AudioWorkletNode(audioContext, 'audio-processor') + + workletNode.port.onmessage = (e) => { + if (!socket || socket.readyState !== WebSocket.OPEN) return + const audioData16kHz = resampleTo16kHZ(e.data, audioContext.sampleRate) + socket.send(audioData16kHz) + } + + source.connect(workletNode) + workletNode.connect(audioContext.destination) + setIsRecording(true) + } catch (err) { + console.error('Error starting recording:', err) + alert('Error starting recording. Please check your microphone permissions.') + } + } + + const stopRecording = () => { + setIsRecording(false) + // Close WebSocket connection and stop audio recording + } + + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + const reader = new FileReader() + reader.onload = async (e) => { + const arrayBuffer = e.target?.result as ArrayBuffer + const audioContext = new AudioContext() + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer) + + // Process audio file in chunks + const socket = new WebSocket(`wss://whisperlive.classroomcopilot.ai/ws`) + + socket.onopen = () => { + socket.send(JSON.stringify({ + uid: generateUUID(), + ...options + })) + } + + socket.onmessage = (event) => { + const data = JSON.parse(event.data) + if (data.status === 'WAIT') { + alert(data.message) + return + } + if (data.language && options.language === null) { + setOptions(prev => ({ ...prev, language: data.language })) + return + } + if (data.message === 'DISCONNECT') { + return + } + + // Handle transcription segments + if (data.segments) { + // Process completed segments + const completedSegments = data.segments.slice(0, -1).map((segment: { text: string }) => ({ + text: segment.text, + completed: true + })) + + // Process current (incomplete) segment + const newCurrentSegment = data.segments.length > 0 ? { + text: data.segments[data.segments.length - 1].text, + completed: false + } : null + + // Update segments state + setSegments(completedSegments) + + // Update current segment state + setCurrentSegment(newCurrentSegment) + + // Update transcript with all completed segments + const fullTranscript = completedSegments.map((s: { text: string }) => s.text).join('') + setTranscript(fullTranscript) + } + } + + // Process audio in chunks + const chunkSize = 4096 + const data = audioBuffer.getChannelData(0) + for (let i = 0; i < data.length; i += chunkSize) { + const chunk = data.slice(i, i + chunkSize) + const audioData16kHz = resampleTo16kHZ(chunk, audioBuffer.sampleRate) + socket.send(audioData16kHz) + } + } + reader.readAsArrayBuffer(file) + } + + function resampleTo16kHZ(audioData: Float32Array, origSampleRate: number): Float32Array { + const targetLength = Math.round(audioData.length * (16000 / origSampleRate)) + const resampledData = new Float32Array(targetLength) + + const springFactor = (audioData.length - 1) / (targetLength - 1) + resampledData[0] = audioData[0] + resampledData[targetLength - 1] = audioData[audioData.length - 1] + + for (let i = 1; i < targetLength - 1; i++) { + const index = i * springFactor + const leftIndex = Math.floor(index) + const rightIndex = Math.ceil(index) + const fraction = index - leftIndex + resampledData[i] = audioData[leftIndex] + (audioData[rightIndex] - audioData[leftIndex]) * fraction + } + + return resampledData + } + + return ( +
+
+

WhisperLive Transcription

+ + + + + classNames( + 'w-full rounded-lg py-2.5 text-sm font-medium leading-5', + 'ring-white ring-opacity-60 ring-offset-2 ring-offset-blue-400 focus:outline-none focus:ring-2', + selected + ? 'bg-white shadow text-blue-700' + : 'text-blue-100 hover:bg-white/[0.12] hover:text-white' + ) + } + > + Record & Transcribe + + + classNames( + 'w-full rounded-lg py-2.5 text-sm font-medium leading-5', + 'ring-white ring-opacity-60 ring-offset-2 ring-offset-blue-400 focus:outline-none focus:ring-2', + selected + ? 'bg-white shadow text-blue-700' + : 'text-blue-100 hover:bg-white/[0.12] hover:text-white' + ) + } + > + Upload Audio + + + + + +
+
+ +
+
+
+ + +
+
+ +
+
+
+
+
+ +
+

Transcript

+
+ {transcript || 'No transcript yet...'} +
+
+ +
+

Segments

+
+ {segments.length > 0 || currentSegment ? ( +
+ {segments.map((segment, index) => ( +
+ {segment.text} + + {segment.completed ? '✓' : '...'} + +
+ ))} + {currentSegment && ( +
+ {currentSegment.text} + + Currently transcribing... + +
+ )} +
+ ) : ( + 'No segments yet...' + )} +
+
+ +
+

Options

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..4eb28ee --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,13 @@ +/// + +interface ImportMetaEnv { + readonly VITE_WHISPERLIVE_URL: string + readonly VITE_APP_URL: string + readonly VITE_APP_PROTOCOL: string + readonly VITE_APP_NAME: string + readonly VITE_DEV: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} \ No newline at end of file diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..ed6d68d --- /dev/null +++ b/src/index.css @@ -0,0 +1,9 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-gray-50 text-gray-900; + } +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..10ece32 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) \ No newline at end of file