tahaListens commited on
Commit
435f0df
·
1 Parent(s): f8b1225

Production: Integrated Vite-React-Tailwind-Typescript Frontend with FastAPI and Qwen

Browse files
.gitignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ .venv/
4
+ .env
5
+ *.pyc
6
+
7
+ # Node / React
8
+ frontend/node_modules/
9
+ frontend/dist/
10
+ .eslintcache
11
+
12
+ # AI Models & OS
13
+ models/
14
+ .DS_Store
15
+ Thumbs.db
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.12
agent.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer, SpeechT5Processor, SpeechT5Tokenizer, SpeechT5ForTextToSpeech, SpeechT5HifiGan
3
+ from faster_whisper import WhisperModel
4
+ import asyncio
5
+ import numpy as np
6
+ from dotenv import load_dotenv
7
+ import re
8
+ from threading import Thread
9
+ from datasets import load_dataset
10
+ load_dotenv()
11
+
12
+ print('trace 0')
13
+
14
+ class TeacherAgent:
15
+ def __init__(self):
16
+
17
+ # 1. Initialize the "Gatekeeper" (VAD)
18
+ # Silero is tiny and runs perfectly on your Intel CPU
19
+ self.vad_model, utils = torch.hub.load(repo_or_dir='snakers4/silero-vad', model='silero_vad')
20
+ print("vad model loaded")
21
+ (_, self.get_speech_timestamps, _, _, _) = utils
22
+ # Setup Ears
23
+ self.whisper_model = WhisperModel("base", device="cpu", compute_type="int8")
24
+ print("trace 1")
25
+ #Setup Brain
26
+ self.brain_context= "You are a helpful Arabic teacher. Answer in short sentences."
27
+ model_name = "Qwen/Qwen2.5-1.5B-Instruct"
28
+ self.tokenizer = AutoTokenizer.from_pretrained(model_name)
29
+ self.brain_model = AutoModelForCausalLM.from_pretrained(model_name, device_map="cpu", torch_dtype="auto")
30
+ print("Brain model loaded")
31
+
32
+ #Setup Mouth
33
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
34
+ # model_id = "MBZUAI/speecht5_tts_clartts_ar"
35
+ model_id= "microsoft/speecht5_tts"
36
+ tokenizer_tts = SpeechT5Tokenizer.from_pretrained(model_id)
37
+ self.processor = SpeechT5Processor.from_pretrained(model_id, tokenizer=tokenizer_tts )
38
+ self.mouth_model = SpeechT5ForTextToSpeech.from_pretrained(model_id, token=True).to(self.device)
39
+ self.vocoder = SpeechT5HifiGan.from_pretrained("microsoft/speecht5_hifigan").to(self.device)
40
+
41
+ # 2. Load a 'Speaker Embedding' (This gives the voice a specific tone)
42
+ # We use a default one from a dataset for now
43
+ embeddings_dataset = load_dataset("herwoww/arabic_xvector_embeddings", split="validation")
44
+ self.speaker_embeddings = torch.tensor(embeddings_dataset[105]["speaker_embeddings"]).unsqueeze(0).to(self.device)
45
+
46
+
47
+ print("✅ Models loaded and ready.")
48
+ self.input_queue = asyncio.Queue() # Raw audio blobs from browser
49
+ self.text_queue = asyncio.Queue() # Transcribed text -> Brain
50
+ self.voice_queue = asyncio.Queue() # Text to speak -> Mouth
51
+
52
+ self.is_ready= True
53
+
54
+ async def start_workers(self, websocket):
55
+ """This replaces your old main()"""
56
+ await asyncio.gather(
57
+ self.ears_worker(websocket), # Pulls from WebSocket, puts in text_queue
58
+ self.brain_worker(), # Pulls from text_queue, puts in voice_queue
59
+ self.mouth_worker(websocket) # Pulls from voice_queue, sends to WebSocket
60
+ )
61
+
62
+ #Ears
63
+ async def ears_worker(self, websocket):
64
+ """The 'Ears' of the Agent: Captures and filters live mic audio."""
65
+ while True:
66
+ try:
67
+ # Receive audio chunk from browser
68
+ audio_bytes = await websocket.receive_bytes()
69
+ # Whisper logic here... (convert bytes -> transcribe)
70
+ audio_np = np.frombuffer(audio_bytes, np.int16).astype(np.float32) / 32768.0
71
+ segments, _ = self.whisper_model.transcribe(audio_np)
72
+ user_text = " ".join([s.text for s in segments]).strip()
73
+
74
+ if user_text:
75
+ print(f"👤 User: {user_text}")
76
+ await self.text_queue.put(user_text)
77
+ except Exception as e:
78
+ print(f"Ears Error: {e}")
79
+ break
80
+ #Brain
81
+ async def brain_worker(self):
82
+ """The 'Brain': Processes your text and streams sentences to the Mouth."""
83
+ while True:
84
+ user_text = await self.text_queue.get()
85
+
86
+ # Prepare the conversation for Qwen
87
+ messages = [{"role": "system", "content": self.brain_context},
88
+ {"role": "user", "content": user_text}]
89
+
90
+ input_text = self.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
91
+ inputs = self.tokenizer([input_text], return_tensors="pt")
92
+
93
+ # Setup the Streamer
94
+ streamer = TextIteratorStreamer(self.tokenizer, skip_prompt=True, skip_special_tokens=True)
95
+ generation_kwargs = dict(inputs, streamer=streamer, max_new_tokens=512)
96
+
97
+ # Start generation in a separate thread so it doesn't block our loop
98
+ thread = Thread(target=self.brain_model.generate, kwargs=generation_kwargs)
99
+ thread.start()
100
+
101
+ sentence_buffer = ""
102
+ for new_text in streamer:
103
+ sentence_buffer += new_text
104
+
105
+ # Use Regex to find the end of a sentence (. ! ? or Arabic equivalents)
106
+ if re.search(r'[.!?؟]\s', sentence_buffer):
107
+ # Split and send the finished sentence to the Mouth belt
108
+ parts = re.split(r'([.!?؟]\s)', sentence_buffer)
109
+ finished_sentence = "".join(parts[:-1]).strip()
110
+ if finished_sentence:
111
+ print(finished_sentence)
112
+ await self.voice_queue.put(finished_sentence)
113
+ sentence_buffer = parts[-1] # Keep the partial sentence for next time
114
+
115
+ # Send any leftover text after the stream ends
116
+ if sentence_buffer.strip():
117
+ await self.voice_queue.put(sentence_buffer.strip())
118
+
119
+ self.text_queue.task_done()
120
+
121
+ async def mouth_worker(self, websocket):
122
+ while True:
123
+ text_to_speak = await self.voice_queue.get()
124
+ inputs = self.processor(text=text_to_speak, return_tensors="pt")
125
+
126
+ with torch.no_grad():
127
+ speech = self.mouth_model.generate_speech(inputs["input_ids"], self.speaker_embeddings, vocoder=self.vocoder)
128
+
129
+ # Convert to float32 bytes for the browser to play
130
+ audio_data = speech.cpu().numpy().astype(np.float32).tobytes()
131
+ await websocket.send_bytes(audio_data)
132
+ self.voice_queue.task_done()
133
+
134
+
135
+
app.py CHANGED
@@ -1,11 +1,37 @@
1
- from fastapi import FastAPI
 
 
 
 
2
 
3
- app = FastAPI()
 
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  @app.get("/")
6
- def home():
7
- return {
8
- "status": "Teacher Agent is Online",
9
- "mode": "Docker Deployment",
10
- "message": "Marhaban! Ready to learn Arabic?"
11
- }
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
2
+ from fastapi.responses import FileResponse
3
+ from agent import TeacherAgent
4
+ from contextlib import asynccontextmanager
5
+ import asyncio
6
 
7
+ # 1. Global Agent Variable
8
+ teacher_agent = None
9
 
10
+ @asynccontextmanager
11
+ async def lifespan(app: FastAPI):
12
+ # This runs when the server starts
13
+ global teacher_agent
14
+ print("🚀 Initializing Teacher Agent Models...")
15
+ teacher_agent = TeacherAgent()
16
+ yield
17
+ # This runs when the server shuts down
18
+ del teacher_agent
19
+
20
+ app = FastAPI(lifespan=lifespan)
21
+
22
+ # 2. Simple HTML Frontend (Temporary for testing)
23
  @app.get("/")
24
+ async def get():
25
+ return FileResponse("index.html")
26
+
27
+ # 3. The WebSocket "Bridge"
28
+ @app.websocket("/ws")
29
+ async def websocket_endpoint(websocket: WebSocket):
30
+ await websocket.accept()
31
+ try:
32
+ # Launch the three workers (Ears, Brain, Mouth) for this specific user
33
+ await teacher_agent.start_workers(websocket)
34
+ except WebSocketDisconnect:
35
+ print("❌ Client disconnected")
36
+ except Exception as e:
37
+ print(f"⚠️ Error: {e}")
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress.
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
17
+
18
+ ```js
19
+ export default defineConfig([
20
+ globalIgnores(['dist']),
21
+ {
22
+ files: ['**/*.{ts,tsx}'],
23
+ extends: [
24
+ // Other configs...
25
+
26
+ // Remove tseslint.configs.recommended and replace with this
27
+ tseslint.configs.recommendedTypeChecked,
28
+ // Alternatively, use this for stricter rules
29
+ tseslint.configs.strictTypeChecked,
30
+ // Optionally, add this for stylistic rules
31
+ tseslint.configs.stylisticTypeChecked,
32
+
33
+ // Other configs...
34
+ ],
35
+ languageOptions: {
36
+ parserOptions: {
37
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
38
+ tsconfigRootDir: import.meta.dirname,
39
+ },
40
+ // other options...
41
+ },
42
+ },
43
+ ])
44
+ ```
45
+
46
+ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
47
+
48
+ ```js
49
+ // eslint.config.js
50
+ import reactX from 'eslint-plugin-react-x'
51
+ import reactDom from 'eslint-plugin-react-dom'
52
+
53
+ export default defineConfig([
54
+ globalIgnores(['dist']),
55
+ {
56
+ files: ['**/*.{ts,tsx}'],
57
+ extends: [
58
+ // Other configs...
59
+ // Enable lint rules for React
60
+ reactX.configs['recommended-typescript'],
61
+ // Enable lint rules for React DOM
62
+ reactDom.configs.recommended,
63
+ ],
64
+ languageOptions: {
65
+ parserOptions: {
66
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
67
+ tsconfigRootDir: import.meta.dirname,
68
+ },
69
+ // other options...
70
+ },
71
+ },
72
+ ])
73
+ ```
frontend/eslint.config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { defineConfig, globalIgnores } from 'eslint/config'
7
+
8
+ export default defineConfig([
9
+ globalIgnores(['dist']),
10
+ {
11
+ files: ['**/*.{ts,tsx}'],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs.flat.recommended,
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ])
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>frontend</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "react": "^19.2.0",
14
+ "react-dom": "^19.2.0"
15
+ },
16
+ "devDependencies": {
17
+ "@eslint/js": "^9.39.1",
18
+ "@types/node": "^24.10.1",
19
+ "@types/react": "^19.2.7",
20
+ "@types/react-dom": "^19.2.3",
21
+ "@vitejs/plugin-react-swc": "^4.2.2",
22
+ "autoprefixer": "^10.4.24",
23
+ "eslint": "^9.39.1",
24
+ "eslint-plugin-react-hooks": "^7.0.1",
25
+ "eslint-plugin-react-refresh": "^0.4.24",
26
+ "globals": "^16.5.0",
27
+ "postcss": "^8.5.6",
28
+ "tailwindcss": "^4.1.18",
29
+ "typescript": "~5.9.3",
30
+ "typescript-eslint": "^8.48.0",
31
+ "vite": "^7.3.1"
32
+ }
33
+ }
frontend/public/vite.svg ADDED
frontend/src/App.css ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #root {
2
+ max-width: 1280px;
3
+ margin: 0 auto;
4
+ padding: 2rem;
5
+ text-align: center;
6
+ }
7
+
8
+ .logo {
9
+ height: 6em;
10
+ padding: 1.5em;
11
+ will-change: filter;
12
+ transition: filter 300ms;
13
+ }
14
+ .logo:hover {
15
+ filter: drop-shadow(0 0 2em #646cffaa);
16
+ }
17
+ .logo.react:hover {
18
+ filter: drop-shadow(0 0 2em #61dafbaa);
19
+ }
20
+
21
+ @keyframes logo-spin {
22
+ from {
23
+ transform: rotate(0deg);
24
+ }
25
+ to {
26
+ transform: rotate(360deg);
27
+ }
28
+ }
29
+
30
+ @media (prefers-reduced-motion: no-preference) {
31
+ a:nth-of-type(2) .logo {
32
+ animation: logo-spin infinite 20s linear;
33
+ }
34
+ }
35
+
36
+ .card {
37
+ padding: 2em;
38
+ }
39
+
40
+ .read-the-docs {
41
+ color: #888;
42
+ }
frontend/src/App.tsx ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef } from 'react'
2
+
3
+ interface ChatMessage {
4
+ role: 'user' | 'ai';
5
+ text: string;
6
+ }
7
+
8
+ function App() {
9
+ const [status, setStatus] = useState<'Offline' | 'Online' | 'Connecting'>('Offline');
10
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
11
+ const [isRecording, setIsRecording] = useState(false);
12
+
13
+ const ws = useRef<WebSocket | null>(null);
14
+ const audioCtx = useRef<AudioContext | null>(null);
15
+ const processor = useRef<ScriptProcessorNode | null>(null);
16
+ const input = useRef<MediaStreamAudioSourceNode | null>(null);
17
+
18
+ // 1. Initialize WebSocket Connection
19
+ useEffect(() => {
20
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
21
+ // The /ws path works because of our proxy in vite.config.ts
22
+ const socket = new WebSocket(`${protocol}//${window.location.host}/ws`);
23
+
24
+ socket.onopen = () => setStatus('Online');
25
+ socket.onclose = () => setStatus('Offline');
26
+
27
+ socket.onmessage = async (event) => {
28
+ if (event.data instanceof Blob) {
29
+ // Handle incoming AI audio
30
+ playAudioBlob(event.data);
31
+ } else {
32
+ // Handle incoming text transcript
33
+ const data = JSON.parse(event.data);
34
+ setMessages(prev => [...prev, { role: 'ai', text: data.text }]);
35
+ }
36
+ };
37
+
38
+ ws.current = socket;
39
+ return () => socket.close();
40
+ }, []);
41
+
42
+ // 2. Microphone Logic
43
+ const startRecording = async () => {
44
+ try {
45
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
46
+ audioCtx.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 16000 });
47
+
48
+ input.current = audioCtx.current.createMediaStreamSource(stream);
49
+ // Create a processor that grabs audio in chunks of 4096 samples
50
+ processor.current = audioCtx.current.createScriptProcessor(4096, 1, 1);
51
+
52
+ processor.current.onaudioprocess = (e) => {
53
+ const inputData = e.inputBuffer.getChannelData(0);
54
+ // Convert Float32 to Int16 for faster-whisper compatibility
55
+ const pcmData = new Int16Array(inputData.length);
56
+ for (let i = 0; i < inputData.length; i++) {
57
+ pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 0x7FFF;
58
+ }
59
+
60
+ if (ws.current?.readyState === WebSocket.OPEN) {
61
+ ws.current.send(pcmData.buffer);
62
+ }
63
+ };
64
+
65
+ input.current.connect(processor.current);
66
+ processor.current.connect(audioCtx.current.destination);
67
+ setIsRecording(true);
68
+ } catch (err) {
69
+ console.error("Mic access denied:", err);
70
+ }
71
+ };
72
+
73
+ const stopRecording = () => {
74
+ processor.current?.disconnect();
75
+ input.current?.disconnect();
76
+ setIsRecording(false);
77
+ };
78
+
79
+ const playAudioBlob = async (blob: Blob) => {
80
+ const context = new AudioContext();
81
+ const arrayBuffer = await blob.arrayBuffer();
82
+ const audioBuffer = await context.decodeAudioData(arrayBuffer);
83
+ const source = context.createBufferSource();
84
+ source.buffer = audioBuffer;
85
+ source.connect(context.destination);
86
+ source.start();
87
+ };
88
+
89
+ return (
90
+ <div className="min-h-screen bg-slate-900 text-white p-8 flex flex-col items-center">
91
+ <div className="w-full max-w-2xl bg-slate-800 rounded-3xl shadow-2xl border border-slate-700 overflow-hidden">
92
+ {/* Header */}
93
+ <div className="p-6 bg-slate-800 border-b border-slate-700 flex justify-between items-center">
94
+ <div>
95
+ <h1 className="text-2xl font-bold text-emerald-400">Arabic Teacher AI</h1>
96
+ <p className="text-sm text-slate-400">Level: Beginner</p>
97
+ </div>
98
+ <div className={`flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium ${status === 'Online' ? 'bg-emerald-500/10 text-emerald-400' : 'bg-rose-500/10 text-rose-400'}`}>
99
+ <div className={`w-2 h-2 rounded-full ${status === 'Online' ? 'bg-emerald-500 animate-pulse' : 'bg-rose-500'}`} />
100
+ {status}
101
+ </div>
102
+ </div>
103
+
104
+ {/* Chat Area */}
105
+ <div className="h-[400px] overflow-y-auto p-6 space-y-4 flex flex-col">
106
+ {messages.map((m, i) => (
107
+ <div key={i} className={`p-4 rounded-2xl max-w-[80%] ${m.role === 'ai' ? 'bg-slate-700 self-start text-emerald-50' : 'bg-emerald-600 self-end'}`}>
108
+ {m.text}
109
+ </div>
110
+ ))}
111
+ </div>
112
+
113
+ {/* Controls */}
114
+ <div className="p-8 bg-slate-800/50 flex flex-col items-center border-t border-slate-700">
115
+ <button
116
+ onMouseDown={startRecording}
117
+ onMouseUp={stopRecording}
118
+ onMouseLeave={stopRecording}
119
+ className={`w-20 h-20 rounded-full flex items-center justify-center transition-all shadow-xl active:scale-90 ${isRecording ? 'bg-rose-500 animate-pulse scale-110' : 'bg-emerald-500 hover:bg-emerald-400'}`}
120
+ >
121
+ <svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
122
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m8 0h-3m4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
123
+ </svg>
124
+ </button>
125
+ <p className="mt-4 text-slate-400 text-sm font-medium uppercase tracking-widest">
126
+ {isRecording ? "Listening..." : "Hold to Speak"}
127
+ </p>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ )
132
+ }
133
+
134
+ export default App
frontend/src/assets/react.svg ADDED
frontend/src/index.css ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
3
+ line-height: 1.5;
4
+ font-weight: 400;
5
+
6
+ color-scheme: light dark;
7
+ color: rgba(255, 255, 255, 0.87);
8
+ background-color: #242424;
9
+
10
+ font-synthesis: none;
11
+ text-rendering: optimizeLegibility;
12
+ -webkit-font-smoothing: antialiased;
13
+ -moz-osx-font-smoothing: grayscale;
14
+ }
15
+
16
+ a {
17
+ font-weight: 500;
18
+ color: #646cff;
19
+ text-decoration: inherit;
20
+ }
21
+ a:hover {
22
+ color: #535bf2;
23
+ }
24
+
25
+ body {
26
+ margin: 0;
27
+ display: flex;
28
+ place-items: center;
29
+ min-width: 320px;
30
+ min-height: 100vh;
31
+ }
32
+
33
+ h1 {
34
+ font-size: 3.2em;
35
+ line-height: 1.1;
36
+ }
37
+
38
+ button {
39
+ border-radius: 8px;
40
+ border: 1px solid transparent;
41
+ padding: 0.6em 1.2em;
42
+ font-size: 1em;
43
+ font-weight: 500;
44
+ font-family: inherit;
45
+ background-color: #1a1a1a;
46
+ cursor: pointer;
47
+ transition: border-color 0.25s;
48
+ }
49
+ button:hover {
50
+ border-color: #646cff;
51
+ }
52
+ button:focus,
53
+ button:focus-visible {
54
+ outline: 4px auto -webkit-focus-ring-color;
55
+ }
56
+
57
+ @media (prefers-color-scheme: light) {
58
+ :root {
59
+ color: #213547;
60
+ background-color: #ffffff;
61
+ }
62
+ a:hover {
63
+ color: #747bff;
64
+ }
65
+ button {
66
+ background-color: #f9f9f9;
67
+ }
68
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.tsx'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
frontend/tsconfig.app.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "types": ["vite/client"],
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "erasableSyntaxOnly": true,
24
+ "noFallthroughCasesInSwitch": true,
25
+ "noUncheckedSideEffectImports": true
26
+ },
27
+ "include": ["src"]
28
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
frontend/tsconfig.node.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "types": ["node"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "erasableSyntaxOnly": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedSideEffectImports": true
24
+ },
25
+ "include": ["vite.config.ts"]
26
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react-swc'
3
+
4
+ // https://vite.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ server: {
8
+ proxy: {
9
+ // Proxying WebSocket requests to the FastAPI backend
10
+ '/ws': {
11
+ target: 'ws://localhost:8000',
12
+ ws: true,
13
+ },
14
+ // Optional: Proxy standard API calls if you add them later
15
+ '/api': 'http://localhost:8000'
16
+ }
17
+ }
18
+ })
main.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ def main():
2
+ print("Hello from language-teacher!")
3
+
4
+
5
+ if __name__ == "__main__":
6
+ main()
pyproject.toml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "language-teacher"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "accelerate>=1.12.0",
9
+ "datasets>=4.5.0",
10
+ "dotenv>=0.9.9",
11
+ "fastapi>=0.128.7",
12
+ "faster-whisper>=1.2.1",
13
+ "numpy>=2.4.2",
14
+ "pyaudio>=0.2.14",
15
+ "pydantic>=2.12.5",
16
+ "python-dotenv>=1.2.1",
17
+ "sentencepiece>=0.2.1",
18
+ "soundfile>=0.13.1",
19
+ "torch>=2.10.0",
20
+ "torchaudio>=2.10.0",
21
+ "transformers>=5.1.0",
22
+ "uvicorn>=0.40.0",
23
+ "websockets>=16.0",
24
+ ]
requirements.txt CHANGED
The diff for this file is too large to render. See raw diff
 
uv.lock ADDED
The diff for this file is too large to render. See raw diff