Initial commit

This commit is contained in:
Stefan Caramizoiu 2026-04-01 11:14:26 +03:00
commit d7a7d2cafd
17 changed files with 2274 additions and 0 deletions

39
.gitignore vendored Normal file
View file

@ -0,0 +1,39 @@
# Virtual environment
venv/
# Python
__pycache__/
*.py[cod]
*.pyo
*.egg-info/
dist/
build/
# Environment variables (contains secrets)
backend/.env
# Piper voice models (large binary files)
backend/voices/*.onnx
backend/voices/*.onnx.json
# Whisper cached models
backend/whisper_models/
# OS files
Thumbs.db
Desktop.ini
.DS_Store
# IDE
.vscode/
.idea/
*.swp
*.swo
# TTS temp files
*.wav
*.mp3
# Test files
backend/test_tts.wav
backend/test_setup.py

130
README.md Normal file
View file

@ -0,0 +1,130 @@
# EcoBot - Asistent Vocal AI pentru Scoala Verde
EcoBot este o aplicatie AI care ruleaza local pe calculatorul tau. Ii vorbesti, iar el iti raspunde cu voce, in timp ce un caracter animat se misca pe ecran.
## Cum functioneaza
```
Microfon → faster-whisper (voce → text)
→ LM Studio / LLM local (generare raspuns)
→ Edge TTS / Piper TTS (text → voce)
→ Redare audio + caracter animat
```
## Cerinte
- **Python 3.10+**
- **LM Studio** (sau alt server LLM compatibil OpenAI API)
- **Windows 10/11** (testat pe Windows 11)
- **Conexiune internet** (doar pentru Edge TTS si prima descarcare Whisper)
### Hardware recomandat
| Componenta | Minim | Recomandat |
|-----------|-------|------------|
| RAM | 8 GB | 16+ GB |
| GPU | - | NVIDIA 4+ GB VRAM |
| CPU | Orice modern | 6+ cores |
## Instalare
### 1. Cloneaza repo-ul
```bash
git clone https://github.com/<user>/scoala-verde.git
cd scoala-verde
```
### 2. Creeaza mediul virtual si instaleaza dependintele
```bash
python -m venv venv
venv\Scripts\activate
pip install -r backend/requirements.txt
```
### 3. Configureaza LM Studio
1. Deschide LM Studio
2. Descarca un model (recomandat: **Llama 3.1 8B Instruct** sau **Mistral 7B Instruct**)
3. Du-te la **Local Server** → incarca modelul → **Start Server**
### 4. Configureaza variabilele de mediu
```bash
copy backend\.env.example backend\.env
```
Editeaza `backend/.env` cu setarile tale (portul LM Studio, modelul Whisper, vocea TTS, etc.)
### 5. (Optional) Descarca vocea Piper pentru mod offline
Descarca fisierele vocii romanesti de la [Piper Voices](https://github.com/rhasspy/piper/blob/master/VOICES.md) si pune `.onnx` + `.onnx.json` in `backend/voices/`.
## Pornire
Dublu-click pe `start.bat` sau:
```bash
venv\Scripts\activate
cd backend
python main.py
```
Deschide browserul la **http://localhost:8000**
## Utilizare
- **Butonul albastru** — activeaza ascultarea continua. Spune **"EcoBot"** ca sa activezi, apoi pune intrebarea
- **Butonul verde** — tine apasat si vorbeste direct
- **Butonul ⚙** — setari audio (selectie microfon/difuzor, amplificare, test)
## Structura proiectului
```
scoala-verde/
├── backend/
│ ├── main.py # FastAPI server (WebSocket)
│ ├── stt.py # Speech-to-Text (faster-whisper)
│ ├── llm.py # Comunicare LM Studio API
│ ├── tts.py # Text-to-Speech (Edge TTS / Piper)
│ ├── voices/ # Modele voce Piper (.onnx)
│ ├── requirements.txt
│ ├── .env # Configuratie locala (nu se comite)
│ └── .env.example # Template configuratie
├── frontend/
│ ├── index.html # Pagina principala
│ ├── css/style.css
│ └── js/
│ ├── app.js # Logica principala + WebSocket
│ ├── audio.js # Captura microfon + redare audio
│ ├── character.js # Caracter animat (copac)
│ └── settings.js # Pagina setari audio
├── dev/ # Documentatie dezvoltare
├── start.bat # Script pornire Windows
└── README.md
```
## Configuratie (.env)
| Variabila | Default | Descriere |
|-----------|---------|-----------|
| `LLM_BASE_URL` | `http://localhost:1234/v1` | URL-ul serverului LLM |
| `LLM_MODEL` | `local-model` | Numele modelului in LM Studio |
| `WHISPER_MODEL` | `small` | Dimensiunea modelului Whisper (tiny/small/medium) |
| `WHISPER_DEVICE` | `cuda` | Dispozitiv Whisper (cuda/cpu) |
| `TTS_ENGINE` | `edge` | Engine TTS: `edge` (online) sau `piper` (offline) |
| `TTS_VOICE` | `ro-RO-EmilNeural` | Vocea Edge TTS |
| `SERVER_PORT` | `8000` | Portul serverului |
## Tehnologii
- **[faster-whisper](https://github.com/SYSTRAN/faster-whisper)** — Speech-to-Text
- **[LM Studio](https://lmstudio.ai/)** — Server LLM local
- **[Edge TTS](https://github.com/rany2/edge-tts)** — Text-to-Speech (Microsoft Neural)
- **[Piper TTS](https://github.com/rhasspy/piper)** — Text-to-Speech offline
- **[FastAPI](https://fastapi.tiangolo.com/)** — Backend Python
## Licenta
MIT

33
backend/.env.example Normal file
View file

@ -0,0 +1,33 @@
# ================================
# EcoBot - Configuration
# ================================
# Hugging Face (for downloading Whisper model on first run)
HF_TOKEN=your_token_here
# LM Studio / LLM API
LLM_BASE_URL=http://localhost:1234/v1
LLM_API_KEY=not-needed
LLM_MODEL=local-model
LLM_MAX_TOKENS=200
LLM_TEMPERATURE=0.7
# Whisper STT
WHISPER_MODEL=small
WHISPER_LANGUAGE=ro
WHISPER_DEVICE=cuda
WHISPER_COMPUTE_TYPE=float16
#WHISPER_DEVICE=cpu
#WHISPER_COMPUTE_TYPE=int8
# Text-to-Speech
# TTS_ENGINE: "edge" (online, calitate superioara) sau "piper" (offline)
TTS_ENGINE=edge
# Voci Edge: ro-RO-EmilNeural (barbat), ro-RO-AlinaNeural (femeie)
TTS_VOICE=ro-RO-EmilNeural
# Server
SERVER_HOST=0.0.0.0
SERVER_PORT=8000

280
backend/diacritice.py Normal file
View file

@ -0,0 +1,280 @@
"""
Post-processing module that adds Romanian diacritics to text.
Maps common words without diacritics to their correct forms.
"""
import re
# Common Romanian words: without diacritics -> with diacritics
# Only includes words where the diacriticed form is unambiguous
WORD_MAP = {
# A
"adevarata": "adevărată",
"adevarate": "adevărate",
"adevarat": "adevărat",
"adevaratii": "adevărații",
"adancime": "adâncime",
"ajuta": "ajuta",
"analfabet": "analfabet",
"aceasta": "aceasta",
"afara": "afară",
"altadata": "altădată",
"amandoi": "amândoi",
"amanare": "amânare",
"amintiri": "amintiri",
"anumita": "anumită",
"anumite": "anumite",
"apa": "apă",
"apoi": "apoi",
"asa": "așa",
"asadar": "așadar",
"asemanare": "asemănare",
"asemenea": "asemenea",
"asupra": "asupra",
"astazi": "astăzi",
"asteapta": "așteaptă",
"asteptare": "așteptare",
"ati": "ați",
"atata": "atâta",
"atatia": "atâția",
"atunci": "atunci",
# B
"baiatul": "băiatul",
"baiat": "băiat",
"baieti": "băieți",
"baietii": "băieții",
"batran": "bătrân",
"batrana": "bătrână",
"batranete": "bătrânețe",
"batrani": "bătrâni",
"buna": "bună",
# C
"cand": "când",
"candva": "cândva",
"cat": "cât",
"cata": "câtă",
"cate": "câte",
"cati": "câți",
"cativa": "câțiva",
"cateva": "câteva",
"catre": "către",
"caldura": "căldură",
"calduros": "călduros",
"calatorie": "călătorie",
"calatori": "călători",
"cautare": "căutare",
"cautam": "căutăm",
"casa": "casă",
"casuta": "căsuță",
"compasiune": "compasiune",
"constientiza": "conștientiza",
"constiinta": "conștiință",
"constient": "conștient",
"copacii": "copacii",
"curateniei": "curățeniei",
"curatenie": "curățenie",
# D
"deasupra": "deasupra",
"deseuri": "deșeuri",
"deseurile": "deșeurile",
"dimineata": "dimineață",
"disparitia": "dispariția",
"disparitie": "dispariție",
"dupa": "după",
# E
"emotie": "emoție",
"emotii": "emoții",
# F
"facand": "făcând",
"facuta": "făcută",
"fara": "fără",
"fiinta": "ființă",
"fiinte": "ființe",
"folosesti": "folosești",
"folosim": "folosim",
"functie": "funcție",
"functioneaza": "funcționează",
# G
"gasesti": "găsești",
"gasim": "găsim",
"gasit": "găsit",
"gandeste": "gândește",
"gandire": "gândire",
"ganduri": "gânduri",
"gradina": "grădină",
"gradinile": "grădinile",
# I
"iarasi": "iarăși",
"iata": "iată",
"inainte": "înainte",
"inaintea": "înaintea",
"incalzire": "încălzire",
"incalzirea": "încălzirea",
"incepe": "începe",
"incepem": "începem",
"inceput": "început",
"incerca": "încerca",
"inchide": "închide",
"inchis": "închis",
"inconjuratoare": "înconjurătoare",
"indata": "îndată",
"indeajuns": "îndeajuns",
"indrazneala": "îndrăzneală",
"informatii": "informații",
"informatie": "informație",
"inima": "inimă",
"inseamna": "înseamnă",
"insemnatate": "însemnătate",
"intelege": "înțelege",
"intelegem": "înțelegem",
"inteles": "înțeles",
"intotdeauna": "întotdeauna",
"intoarcere": "întoarcere",
"intr-un": "într-un",
"intr-o": "într-o",
"intrebare": "întrebare",
"intrebari": "întrebări",
"invatam": "învățăm",
"invata": "învață",
"invatare": "învățare",
# J
"jumatate": "jumătate",
# L
"lantul": "lanțul",
"legatura": "legătură",
"lumina": "lumină",
# M
"mancare": "mâncare",
"mancarea": "mâncarea",
"masina": "mașină",
"masini": "mașini",
"masinile": "mașinile",
"mediul": "mediul",
"mostenire": "moștenire",
"mostenirea": "moștenirea",
"muntii": "munții",
"munti": "munți",
# N
"natiune": "națiune",
"natura": "natură",
"naturii": "naturii",
# O
"oamenii": "oamenii",
"oras": "oraș",
"orasele": "orașele",
"orase": "orașe",
# P
"padure": "pădure",
"paduri": "păduri",
"padurile": "pădurile",
"padurea": "pădurea",
"pamant": "pământ",
"pamantul": "pământul",
"pana": "până",
"pasari": "păsări",
"pasarile": "păsările",
"pasare": "pasăre",
"planteaza": "plantează",
"plantam": "plantăm",
"poluare": "poluare",
"poluarea": "poluarea",
"populatie": "populație",
"populatia": "populația",
"povesteste": "povestește",
"protejeaza": "protejează",
"protejam": "protejăm",
"protectie": "protecție",
"protectia": "protecția",
"putina": "puțină",
"putin": "puțin",
# R
"ramanem": "rămânem",
"ramane": "rămâne",
"ramas": "rămas",
"raspuns": "răspuns",
"raspunsul": "răspunsul",
"raspunsuri": "răspunsuri",
"raspunde": "răspunde",
"rauri": "râuri",
"raurile": "râurile",
"rau": "râu",
"reciclare": "reciclare",
"reciclam": "reciclăm",
"reciclarea": "reciclarea",
"reducerea": "reducerea",
"reutilizare": "reutilizare",
"reutilizam": "reutilizăm",
# S
"sansa": "șansă",
"sanse": "șanse",
"scoala": "școală",
"scolile": "școlile",
"scoalele": "școlile",
"seara": "seară",
"siguranta": "siguranță",
"situatie": "situație",
"solutie": "soluție",
"solutii": "soluții",
"stim": "știm",
"stie": "știe",
"stiinta": "știință",
"stiintific": "științific",
"stiati": "știați",
"sustenabilitate": "sustenabilitate",
# T
"tara": "țară",
"tarile": "țările",
"tari": "țări",
"temperaturi": "temperaturi",
"trebuie": "trebuie",
# U
"unda": "undă",
"uneori": "uneori",
"usoara": "ușoară",
"usor": "ușor",
# V
"vant": "vânt",
"vantul": "vântul",
"viata": "viață",
"vietuitoare": "viețuitoare",
"vietuitoarele": "viețuitoarele",
}
def add_diacritics(text: str) -> str:
"""Add Romanian diacritics to text using word-level replacement."""
def replace_word(match):
word = match.group(0)
lower = word.lower()
if lower in WORD_MAP:
replacement = WORD_MAP[lower]
# Preserve original capitalization
if word[0].isupper():
replacement = replacement[0].upper() + replacement[1:]
if word.isupper():
replacement = replacement.upper()
return replacement
return word
# Match whole words only
return re.sub(r'\b[a-zA-ZăâîșțĂÂÎȘȚ]+\b', replace_word, text)

58
backend/llm.py Normal file
View file

@ -0,0 +1,58 @@
import os
from openai import OpenAI
from diacritice import add_diacritics
client = OpenAI(
base_url=os.getenv("LLM_BASE_URL", "http://localhost:1234/v1"),
api_key=os.getenv("LLM_API_KEY", "not-needed"),
)
LLM_MODEL = os.getenv("LLM_MODEL", "local-model")
LLM_MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "200"))
LLM_TEMPERATURE = float(os.getenv("LLM_TEMPERATURE", "0.7"))
SYSTEM_PROMPT = """You are EcoBot, a friendly assistant for "Scoala Verde" (Green School).
You MUST reply ONLY in Romanian language. Never reply in English or any other language.
You are passionate about ecology, nature and environmental protection.
Keep answers short (maximum 2-3 sentences) because they are read aloud.
Do not use emojis or special characters. Speak naturally, like talking to a friend.
CRITICAL: You MUST use proper Romanian diacritics in every word: ă, â, î, ș, ț (lowercase) and Ă, Â, Î, Ș, Ț (uppercase). Never write without diacritics.
Example responses:
- User: "Ce este reciclarea?" -> "Reciclarea înseamnă să transformăm deșeurile în materiale noi. Așa protejăm natura și economisim resurse."
- User: "Buna ziua" -> "Bună ziua! Sunt EcoBot, asistentul tău verde. Cu ce te pot ajuta?"
- User: "Hello" -> "Bună! Eu sunt EcoBot. Cum te pot ajuta să protejăm natura?"
"""
# Fixed responses for common greetings (LLM tends to mess these up)
FIXED_RESPONSES = {
"buna": "Bună! Sunt EcoBot, asistentul tău verde. Cu ce te pot ajuta?",
"buna ziua": "Bună ziua! Sunt EcoBot, asistentul tău verde. Cu ce te pot ajuta?",
"salut": "Salut! Sunt EcoBot. Cu ce te pot ajuta azi?",
"hello": "Bună! Eu sunt EcoBot. Cum te pot ajuta să protejăm natura?",
"hi": "Bună! Sunt EcoBot. Întreabă-mă orice despre ecologie!",
"hey": "Bună! Sunt EcoBot. Cu ce te pot ajuta?",
"ciao": "Bună! Sunt EcoBot, asistentul tău verde. Ce vrei să afli?",
"buna seara": "Bună seara! Sunt EcoBot. Cu ce te pot ajuta?",
"buna dimineata": "Bună dimineața! Sunt EcoBot. Cu ce te pot ajuta?",
}
def get_response(user_message: str) -> str:
# Check for fixed responses first
clean = user_message.lower().strip().rstrip(".!?")
if clean in FIXED_RESPONSES:
return FIXED_RESPONSES[clean]
response = client.chat.completions.create(
model=LLM_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"[Răspunde în limba română] {user_message}"},
],
max_tokens=LLM_MAX_TOKENS,
temperature=LLM_TEMPERATURE,
)
text = response.choices[0].message.content.strip()
return add_diacritics(text)

132
backend/main.py Normal file
View file

@ -0,0 +1,132 @@
import os
import json
import base64
from pathlib import Path
from contextlib import asynccontextmanager
from dotenv import load_dotenv
# Load .env before importing modules that use env vars
load_dotenv()
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from stt import transcribe, load_model as load_whisper
from llm import get_response
from tts import synthesize, set_voice, TTS_ENGINE
# Paths
BASE_DIR = Path(__file__).parent.parent
FRONTEND_DIR = BASE_DIR / "frontend"
VOICES_DIR = BASE_DIR / "backend" / "voices"
@asynccontextmanager
async def lifespan(app):
# Startup
print("Loading Whisper model...")
load_whisper()
print("Whisper model loaded.")
voice_files = list(VOICES_DIR.glob("*.onnx")) if VOICES_DIR.exists() else []
if voice_files:
set_voice(str(voice_files[0]))
print(f"TTS voice loaded: {voice_files[0].name}")
else:
print("WARNING: No voice model found in backend/voices/")
print("Download a voice from: https://github.com/rhasspy/piper/blob/master/VOICES.md")
print("Place the .onnx and .onnx.json files in backend/voices/")
yield
# Shutdown
print("Server shutting down.")
app = FastAPI(title="EcoBot - Scoala Verde", lifespan=lifespan)
# Serve frontend
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
@app.get("/")
async def index():
return FileResponse(str(FRONTEND_DIR / "index.html"))
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
await ws.accept()
print("Client connected")
try:
while True:
# Receive audio data from browser
data = await ws.receive_text()
message = json.loads(data)
if message["type"] == "audio":
audio_bytes = base64.b64decode(message["data"])
is_wake_mode = message.get("mode") == "wake"
# Step 1: Speech to Text
await ws.send_text(json.dumps({"type": "status", "text": "Ascult..."}))
text = transcribe(audio_bytes)
if not text:
await ws.send_text(json.dumps({"type": "status", "text": "Nu am inteles. Incearca din nou."}))
continue
# Wake word detection
if is_wake_mode:
text_lower = text.lower().strip()
wake_words = ["ecobot", "eco bot", "eco-bot", "hello bot", "helo bot"]
detected = any(w in text_lower for w in wake_words)
await ws.send_text(json.dumps({
"type": "wake_detected" if detected else "wake_not_detected",
"text": text,
}))
continue
await ws.send_text(json.dumps({"type": "user_text", "text": text}))
# Step 2: Get AI response
await ws.send_text(json.dumps({"type": "status", "text": "Ma gandesc..."}))
response_text = get_response(text)
await ws.send_text(json.dumps({"type": "bot_text", "text": response_text}))
# Step 3: Text to Speech
await ws.send_text(json.dumps({"type": "status", "text": "Pregatesc raspunsul..."}))
print(f"[TTS] Engine: {TTS_ENGINE}")
print(f"[TTS] Text to synthesize: {response_text.encode('ascii', 'replace').decode()}")
try:
audio_response = await synthesize(response_text)
print(f"[TTS] OK - {len(audio_response)} bytes")
audio_b64 = base64.b64encode(audio_response).decode("utf-8")
audio_mime = "audio/mpeg" if TTS_ENGINE == "edge" else "audio/wav"
await ws.send_text(json.dumps({
"type": "audio_response",
"data": audio_b64,
"text": response_text,
"mime": audio_mime,
}))
except Exception as e:
err_msg = str(e).encode('ascii', 'replace').decode()
print(f"[TTS] ERROR: {type(e).__name__}: {err_msg}")
import traceback
traceback.print_exc()
await ws.send_text(json.dumps({
"type": "text_only_response",
"text": response_text,
}))
except WebSocketDisconnect:
print("Client disconnected")
if __name__ == "__main__":
import uvicorn
host = os.getenv("SERVER_HOST", "0.0.0.0")
port = int(os.getenv("SERVER_PORT", "8000"))
uvicorn.run(app, host=host, port=port)

8
backend/requirements.txt Normal file
View file

@ -0,0 +1,8 @@
fastapi==0.115.0
uvicorn[standard]==0.30.0
websockets==13.0
faster-whisper==1.0.3
openai==1.50.0
piper-tts==1.2.0
python-multipart==0.0.9
python-dotenv==1.0.1

32
backend/stt.py Normal file
View file

@ -0,0 +1,32 @@
import os
import tempfile
from faster_whisper import WhisperModel
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cuda")
WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "float16")
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "ro")
model = None
def load_model():
global model
if model is None:
model = WhisperModel(WHISPER_MODEL, device=WHISPER_DEVICE, compute_type=WHISPER_COMPUTE_TYPE)
return model
def transcribe(audio_bytes: bytes) -> str:
whisper = load_model()
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
f.write(audio_bytes)
tmp_path = f.name
try:
segments, _ = whisper.transcribe(tmp_path, language=WHISPER_LANGUAGE)
text = " ".join(seg.text for seg in segments).strip()
return text
finally:
os.unlink(tmp_path)

80
backend/tts.py Normal file
View file

@ -0,0 +1,80 @@
import os
import subprocess
import tempfile
import shutil
import edge_tts
# TTS_ENGINE: "edge" (default) or "piper"
TTS_ENGINE = os.getenv("TTS_ENGINE", "edge")
# --- Edge TTS config ---
# Romanian voices: ro-RO-EmilNeural (male), ro-RO-AlinaNeural (female)
EDGE_VOICE = os.getenv("TTS_VOICE", "ro-RO-EmilNeural")
# --- Piper TTS config ---
PIPER_VOICE_MODEL = None
def _find_piper_exe():
piper_path = shutil.which("piper")
if piper_path:
return piper_path
venv_piper = os.path.join(os.path.dirname(os.path.dirname(__file__)), "venv", "Scripts", "piper.exe")
if os.path.exists(venv_piper):
return venv_piper
return "piper"
PIPER_EXE = _find_piper_exe()
def set_voice(model_path: str):
"""Set Piper voice model path (called from main.py startup)."""
global PIPER_VOICE_MODEL
PIPER_VOICE_MODEL = model_path
async def synthesize(text: str) -> bytes:
if TTS_ENGINE == "piper":
return _synthesize_piper(text)
else:
return await _synthesize_edge(text)
# --- Edge TTS ---
async def _synthesize_edge(text: str) -> bytes:
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
tmp_path = f.name
try:
communicate = edge_tts.Communicate(text, EDGE_VOICE)
await communicate.save(tmp_path)
with open(tmp_path, "rb") as f:
return f.read()
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
# --- Piper TTS ---
def _synthesize_piper(text: str) -> bytes:
if PIPER_VOICE_MODEL is None:
raise RuntimeError("No Piper voice model configured. Place .onnx files in backend/voices/")
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
out_path = f.name
try:
process = subprocess.run(
[PIPER_EXE, "--model", PIPER_VOICE_MODEL, "--output_file", out_path],
input=text.encode("utf-8"),
capture_output=True,
timeout=30,
)
if process.returncode != 0:
raise RuntimeError(f"Piper TTS failed: {process.stderr.decode()}")
with open(out_path, "rb") as f:
return f.read()
finally:
if os.path.exists(out_path):
os.unlink(out_path)

0
backend/voices/.gitkeep Normal file
View file

388
frontend/css/style.css Normal file
View file

@ -0,0 +1,388 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: linear-gradient(135deg, #1a472a 0%, #2d5a27 50%, #1a472a 100%);
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, sans-serif;
display: flex;
justify-content: center;
align-items: center;
color: #e0f0e0;
}
#app {
text-align: center;
padding: 20px;
max-width: 500px;
width: 100%;
position: relative;
}
h1 {
font-size: 2.5em;
color: #7ddf64;
text-shadow: 0 2px 10px rgba(125, 223, 100, 0.3);
margin-bottom: 5px;
}
.subtitle {
color: #a0c8a0;
margin-bottom: 30px;
font-size: 1.1em;
}
#character-canvas {
display: block;
margin: 0 auto 20px;
border-radius: 20px;
background: rgba(0, 0, 0, 0.2);
}
#status {
font-size: 1.1em;
color: #a0c8a0;
margin-bottom: 20px;
min-height: 1.5em;
transition: color 0.3s;
}
#status.active {
color: #7ddf64;
}
#conversation {
margin-bottom: 20px;
min-height: 60px;
}
.bubble {
padding: 12px 18px;
border-radius: 16px;
margin: 8px 0;
max-width: 90%;
text-align: left;
font-size: 0.95em;
line-height: 1.4;
transition: opacity 0.3s;
}
.bubble.hidden {
display: none;
}
.bubble.user {
background: rgba(125, 223, 100, 0.15);
border: 1px solid rgba(125, 223, 100, 0.3);
margin-left: auto;
margin-right: 0;
}
.bubble.bot {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
margin-left: 0;
margin-right: auto;
}
#record-btn {
background: linear-gradient(145deg, #4CAF50, #2d7a32);
border: none;
color: white;
padding: 18px 40px;
font-size: 1.1em;
border-radius: 50px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 10px;
transition: all 0.2s;
box-shadow: 0 4px 15px rgba(76, 175, 80, 0.3);
user-select: none;
}
#record-btn:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.5);
}
#record-btn:active, #record-btn.recording {
background: linear-gradient(145deg, #f44336, #c62828);
box-shadow: 0 4px 15px rgba(244, 67, 54, 0.4);
transform: scale(0.98);
}
#record-btn.recording #btn-text {
content: "Inregistrez...";
}
#btn-icon, #wake-icon {
font-size: 1.4em;
}
#controls {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
#wake-btn {
background: linear-gradient(145deg, #1565C0, #0D47A1);
border: none;
color: white;
padding: 14px 30px;
font-size: 1em;
border-radius: 50px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 10px;
transition: all 0.2s;
box-shadow: 0 4px 15px rgba(21, 101, 192, 0.3);
user-select: none;
}
#wake-btn:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(21, 101, 192, 0.5);
}
#wake-btn.listening {
background: linear-gradient(145deg, #E65100, #BF360C);
box-shadow: 0 4px 15px rgba(230, 81, 0, 0.4);
animation: pulse-wake 2s infinite;
}
@keyframes pulse-wake {
0%, 100% { box-shadow: 0 4px 15px rgba(230, 81, 0, 0.4); }
50% { box-shadow: 0 4px 25px rgba(230, 81, 0, 0.7); }
}
/* Settings button */
#settings-btn {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #a0c8a0;
font-size: 1.5em;
width: 45px;
height: 45px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s;
}
#settings-btn:hover {
background: rgba(255, 255, 255, 0.2);
color: #7ddf64;
transform: rotate(30deg);
}
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(5px);
}
.modal.hidden {
display: none;
}
.modal-content {
background: linear-gradient(145deg, #1e3a1e, #2a4a2a);
border: 1px solid rgba(125, 223, 100, 0.2);
border-radius: 20px;
padding: 30px;
max-width: 450px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
}
.modal-header h2 {
color: #7ddf64;
font-size: 1.4em;
}
.modal-close {
background: none;
border: none;
color: #a0c8a0;
font-size: 1.8em;
cursor: pointer;
padding: 0 5px;
line-height: 1;
}
.modal-close:hover {
color: #f44336;
}
.setting-group {
margin-bottom: 25px;
}
.setting-group label {
display: block;
color: #a0c8a0;
font-size: 0.9em;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 1px;
}
.setting-group select {
width: 100%;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(125, 223, 100, 0.3);
border-radius: 10px;
color: #e0f0e0;
font-size: 0.95em;
cursor: pointer;
outline: none;
}
.setting-group select:focus {
border-color: #7ddf64;
}
.setting-group select option {
background: #1e3a1e;
color: #e0f0e0;
}
.sub-label {
margin-top: 12px;
font-size: 0.85em !important;
text-transform: none !important;
letter-spacing: 0 !important;
}
#gain-value {
color: #7ddf64;
font-weight: bold;
}
.slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: rgba(0, 0, 0, 0.3);
border-radius: 3px;
outline: none;
margin: 8px 0;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #7ddf64;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #7ddf64;
cursor: pointer;
border: none;
}
.test-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
}
.test-btn {
background: rgba(76, 175, 80, 0.2);
border: 1px solid rgba(76, 175, 80, 0.4);
color: #7ddf64;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 0.85em;
white-space: nowrap;
transition: all 0.2s;
}
.test-btn:hover {
background: rgba(76, 175, 80, 0.35);
}
.test-btn.active {
background: rgba(244, 67, 54, 0.2);
border-color: rgba(244, 67, 54, 0.4);
color: #f44336;
}
.level-bar {
flex: 1;
height: 12px;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.level-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #4CAF50, #7ddf64, #f44336);
border-radius: 6px;
transition: width 0.05s;
}
.test-status {
color: #a0c8a0;
font-size: 0.8em;
margin-top: 6px;
min-height: 1.2em;
}
.save-btn {
width: 100%;
padding: 12px;
background: linear-gradient(145deg, #4CAF50, #2d7a32);
border: none;
color: white;
font-size: 1em;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 15px rgba(76, 175, 80, 0.3);
}
.save-btn:hover {
transform: scale(1.02);
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.5);
}

79
frontend/index.html Normal file
View file

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EcoBot - Scoala Verde</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div id="app">
<h1>EcoBot</h1>
<p class="subtitle">Asistentul tau verde</p>
<button id="settings-btn" title="Setari audio"></button>
<canvas id="character-canvas" width="400" height="400"></canvas>
<div id="status">Apasa butonul si vorbeste</div>
<div id="conversation">
<div id="user-text" class="bubble user hidden"></div>
<div id="bot-text" class="bubble bot hidden"></div>
</div>
<div id="controls">
<button id="wake-btn" title="Activeaza ascultarea continua">
<span id="wake-icon">👂</span>
<span id="wake-text">Spune "EcoBot" pentru a activa</span>
</button>
<button id="record-btn">
<span id="btn-icon">🎤</span>
<span id="btn-text">Sau tine apasat si vorbeste</span>
</button>
</div>
</div>
<!-- Settings Modal -->
<div id="settings-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Setari Audio</h2>
<button id="settings-close" class="modal-close">&times;</button>
</div>
<div class="setting-group">
<label for="mic-select">Microfon</label>
<select id="mic-select"></select>
<label for="mic-gain" class="sub-label">Amplificare microfon: <span id="gain-value">3.0</span>x</label>
<input type="range" id="mic-gain" min="1" max="10" step="0.5" value="3.0" class="slider">
<div class="test-row">
<button id="mic-test-btn" class="test-btn">Testeaza microfonul</button>
<div id="mic-level-bar" class="level-bar">
<div id="mic-level" class="level-fill"></div>
</div>
</div>
<p id="mic-test-status" class="test-status"></p>
</div>
<div class="setting-group">
<label for="speaker-select">Difuzor</label>
<select id="speaker-select"></select>
<div class="test-row">
<button id="speaker-test-btn" class="test-btn">Testeaza difuzorul</button>
</div>
<p id="speaker-test-status" class="test-status"></p>
</div>
<button id="settings-save" class="save-btn">Salveaza si inchide</button>
</div>
</div>
<script src="/static/js/character.js"></script>
<script src="/static/js/audio.js"></script>
<script src="/static/js/settings.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>

307
frontend/js/app.js Normal file
View file

@ -0,0 +1,307 @@
const character = new Character('character-canvas');
const audioHandler = new AudioHandler();
const recordBtn = document.getElementById('record-btn');
const btnText = document.getElementById('btn-text');
const wakeBtn = document.getElementById('wake-btn');
const wakeText = document.getElementById('wake-text');
const statusEl = document.getElementById('status');
const userTextEl = document.getElementById('user-text');
const botTextEl = document.getElementById('bot-text');
let ws = null;
let wakeListening = false;
let wakeRecorder = null;
let wakeStream = null;
let processingAudio = false;
let waitingForWakeResponse = false;
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
ws.onopen = () => {
statusEl.textContent = 'Conectat! Spune "EcoBot" sau apasa butonul.';
};
ws.onclose = () => {
statusEl.textContent = 'Deconectat. Se reconecteaza...';
setTimeout(connectWebSocket, 2000);
};
ws.onmessage = async (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'status':
statusEl.textContent = message.text;
if (message.text.includes('Ascult')) {
character.setState('listening');
} else if (message.text.includes('gandesc')) {
character.setState('thinking');
}
break;
case 'user_text':
userTextEl.textContent = `Tu: ${message.text}`;
userTextEl.classList.remove('hidden');
break;
case 'bot_text':
botTextEl.textContent = `EcoBot: ${message.text}`;
botTextEl.classList.remove('hidden');
break;
case 'audio_response':
character.setState('talking');
statusEl.textContent = 'Vorbesc...';
await audioHandler.playAudioFromBase64(message.data, message.mime || 'audio/mpeg');
character.setState('idle');
processingAudio = false;
resumeWakeIfActive();
break;
case 'text_only_response':
botTextEl.textContent = `EcoBot: ${message.text}`;
botTextEl.classList.remove('hidden');
character.setState('talking');
await new Promise(r => setTimeout(r, 2000));
character.setState('idle');
processingAudio = false;
resumeWakeIfActive();
break;
case 'wake_detected':
waitingForWakeResponse = false;
statusEl.textContent = 'Te-am auzit! Vorbeste acum...';
character.setState('listening');
await recordAndSendQuestion();
break;
case 'wake_not_detected':
waitingForWakeResponse = false;
// Only start next chunk after this one is done
if (wakeListening && !processingAudio) {
startWakeLoop();
}
break;
}
};
}
function resumeWakeIfActive() {
if (wakeListening) {
statusEl.textContent = 'Ascult... Spune "EcoBot" pentru a activa.';
startWakeLoop();
} else {
statusEl.textContent = 'Spune "EcoBot" sau apasa butonul.';
}
}
// ---- Wake Word Mode ----
function toggleWakeMode() {
if (wakeListening) {
stopWakeMode();
} else {
startWakeMode();
}
}
function startWakeMode() {
wakeListening = true;
waitingForWakeResponse = false;
wakeBtn.classList.add('listening');
wakeText.textContent = 'Ascult... (click pentru a opri)';
statusEl.textContent = 'Ascult... Spune "EcoBot" pentru a activa.';
character.setState('idle');
startWakeLoop();
}
function stopWakeMode() {
wakeListening = false;
waitingForWakeResponse = false;
wakeBtn.classList.remove('listening');
wakeText.textContent = 'Spune "EcoBot" pentru a activa';
statusEl.textContent = 'Spune "EcoBot" sau apasa butonul.';
stopWakeRecorder();
}
async function startWakeLoop() {
// Don't start a new recording if we're still waiting for the previous response
if (!wakeListening || processingAudio || waitingForWakeResponse) return;
const micId = audioHandler.selectedMicId;
const constraints = {
audio: micId
? { deviceId: { exact: micId }, autoGainControl: true, noiseSuppression: true }
: { autoGainControl: true, noiseSuppression: true },
};
try {
wakeStream = await navigator.mediaDevices.getUserMedia(constraints);
wakeRecorder = new MediaRecorder(wakeStream, { mimeType: 'audio/webm' });
const chunks = [];
wakeRecorder.ondataavailable = (e) => chunks.push(e.data);
wakeRecorder.onstop = async () => {
if (!wakeListening) return;
// Mark as waiting — don't record another chunk until server responds
waitingForWakeResponse = true;
statusEl.textContent = 'Procesez...';
const blob = new Blob(chunks, { type: 'audio/webm' });
const wavBlob = await audioHandler.convertToWav(blob);
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(',')[1];
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'audio', data: base64, mode: 'wake' }));
}
};
reader.readAsDataURL(wavBlob);
};
wakeRecorder.start();
statusEl.textContent = 'Ascult... Spune "EcoBot"';
// Record 3 second chunks
setTimeout(() => {
if (wakeRecorder && wakeRecorder.state === 'recording') {
wakeRecorder.stop();
if (wakeStream) {
wakeStream.getTracks().forEach(t => t.stop());
}
}
}, 3000);
} catch (e) {
console.error('Wake mode error:', e);
statusEl.textContent = 'Eroare microfon: ' + e.message;
stopWakeMode();
}
}
function stopWakeRecorder() {
if (wakeRecorder && wakeRecorder.state === 'recording') {
wakeRecorder.stop();
}
if (wakeStream) {
wakeStream.getTracks().forEach(t => t.stop());
wakeStream = null;
}
wakeRecorder = null;
}
async function recordAndSendQuestion() {
processingAudio = true;
stopWakeRecorder();
const micId = audioHandler.selectedMicId;
const constraints = {
audio: micId
? { deviceId: { exact: micId }, autoGainControl: true, noiseSuppression: true }
: { autoGainControl: true, noiseSuppression: true },
};
try {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
const chunks = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.onstop = async () => {
stream.getTracks().forEach(t => t.stop());
const blob = new Blob(chunks, { type: 'audio/webm' });
const wavBlob = await audioHandler.convertToWav(blob);
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(',')[1];
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'audio', data: base64 }));
}
};
reader.readAsDataURL(wavBlob);
};
statusEl.textContent = 'Te ascult... (5 secunde)';
recorder.start();
setTimeout(() => {
if (recorder.state === 'recording') {
recorder.stop();
statusEl.textContent = 'Procesez...';
}
}, 5000);
} catch (e) {
console.error('Record error:', e);
processingAudio = false;
if (wakeListening) startWakeLoop();
}
}
// ---- Manual Record (hold button) ----
async function startRecording() {
stopWakeRecorder();
await audioHandler.init();
audioHandler.startRecording();
recordBtn.classList.add('recording');
btnText.textContent = 'Inregistrez... elibereaza pentru a trimite';
character.setState('listening');
statusEl.textContent = 'Te ascult...';
}
async function stopRecording() {
const audioBlob = await audioHandler.stopRecording();
recordBtn.classList.remove('recording');
btnText.textContent = 'Sau tine apasat si vorbeste';
processingAudio = true;
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(',')[1];
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'audio', data: base64 }));
}
};
reader.readAsDataURL(audioBlob);
}
// ---- Event Listeners ----
wakeBtn.addEventListener('click', () => toggleWakeMode());
recordBtn.addEventListener('mousedown', (e) => {
e.preventDefault();
startRecording();
});
recordBtn.addEventListener('mouseup', (e) => {
e.preventDefault();
if (audioHandler.isRecording) stopRecording();
});
recordBtn.addEventListener('mouseleave', (e) => {
if (audioHandler.isRecording) stopRecording();
});
recordBtn.addEventListener('touchstart', (e) => {
e.preventDefault();
startRecording();
});
recordBtn.addEventListener('touchend', (e) => {
e.preventDefault();
if (audioHandler.isRecording) stopRecording();
});
// Settings
const settings = new SettingsManager(audioHandler);
// Start
connectWebSocket();

172
frontend/js/audio.js Normal file
View file

@ -0,0 +1,172 @@
class AudioHandler {
constructor() {
this.mediaRecorder = null;
this.audioChunks = [];
this.isRecording = false;
this.audioContext = null;
this.selectedMicId = localStorage.getItem('selectedMicId') || null;
this.selectedSpeakerId = localStorage.getItem('selectedSpeakerId') || null;
this.micGain = parseFloat(localStorage.getItem('micGain') || '3.0');
}
async init() {
const constraints = {
audio: this.selectedMicId
? {
deviceId: { exact: this.selectedMicId },
autoGainControl: true,
noiseSuppression: true,
echoCancellation: true,
}
: {
autoGainControl: true,
noiseSuppression: true,
echoCancellation: true,
},
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
// Apply gain boost through Web Audio API
const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(stream);
const gainNode = ctx.createGain();
gainNode.gain.value = this.micGain;
const dest = ctx.createMediaStreamDestination();
source.connect(gainNode);
gainNode.connect(dest);
this._liveAudioCtx = ctx;
this._liveGainNode = gainNode;
this.mediaRecorder = new MediaRecorder(dest.stream, { mimeType: 'audio/webm' });
this.mediaRecorder.ondataavailable = (event) => {
this.audioChunks.push(event.data);
};
}
setMic(deviceId) {
this.selectedMicId = deviceId;
localStorage.setItem('selectedMicId', deviceId);
}
setSpeaker(deviceId) {
this.selectedSpeakerId = deviceId;
localStorage.setItem('selectedSpeakerId', deviceId);
}
setGain(value) {
this.micGain = value;
localStorage.setItem('micGain', value.toString());
if (this._liveGainNode) {
this._liveGainNode.gain.value = value;
}
}
startRecording() {
this.audioChunks = [];
this.mediaRecorder.start();
this.isRecording = true;
}
stopRecording() {
return new Promise((resolve) => {
this.mediaRecorder.onstop = async () => {
const blob = new Blob(this.audioChunks, { type: 'audio/webm' });
const wavBlob = await this.convertToWav(blob);
resolve(wavBlob);
};
this.mediaRecorder.stop();
this.isRecording = false;
});
}
async convertToWav(blob) {
if (!this.audioContext) {
this.audioContext = new AudioContext({ sampleRate: 16000 });
}
const arrayBuffer = await blob.arrayBuffer();
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
const numChannels = 1;
const sampleRate = 16000;
const bitsPerSample = 16;
const offlineCtx = new OfflineAudioContext(numChannels, audioBuffer.duration * sampleRate, sampleRate);
const source = offlineCtx.createBufferSource();
source.buffer = audioBuffer;
source.connect(offlineCtx.destination);
source.start();
const resampled = await offlineCtx.startRendering();
const samples = resampled.getChannelData(0);
// Normalize audio - find peak and scale up
let peak = 0;
for (let i = 0; i < samples.length; i++) {
const abs = Math.abs(samples[i]);
if (abs > peak) peak = abs;
}
const normalizeGain = peak > 0.01 ? 0.9 / peak : 1;
const dataLength = samples.length * (bitsPerSample / 8);
const buffer = new ArrayBuffer(44 + dataLength);
const view = new DataView(buffer);
this.writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + dataLength, true);
this.writeString(view, 8, 'WAVE');
this.writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * numChannels * (bitsPerSample / 8), true);
view.setUint16(32, numChannels * (bitsPerSample / 8), true);
view.setUint16(34, bitsPerSample, true);
this.writeString(view, 36, 'data');
view.setUint32(40, dataLength, true);
let offset = 44;
for (let i = 0; i < samples.length; i++) {
const amplified = samples[i] * normalizeGain;
const s = Math.max(-1, Math.min(1, amplified));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
offset += 2;
}
return new Blob([buffer], { type: 'audio/wav' });
}
writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
playAudioFromBase64(base64Data, mimeType = 'audio/mpeg') {
return new Promise((resolve) => {
const byteChars = atob(base64Data);
const byteArray = new Uint8Array(byteChars.length);
for (let i = 0; i < byteChars.length; i++) {
byteArray[i] = byteChars.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: mimeType });
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
if (this.selectedSpeakerId && audio.setSinkId) {
audio.setSinkId(this.selectedSpeakerId).catch(() => {});
}
audio.onended = () => {
URL.revokeObjectURL(url);
resolve();
};
audio.play();
});
}
}

290
frontend/js/character.js Normal file
View file

@ -0,0 +1,290 @@
class Character {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.width = this.canvas.width;
this.height = this.canvas.height;
// Character state
this.state = 'idle'; // idle, listening, thinking, talking
this.mouthOpen = 0;
this.eyeBlink = 0;
this.blinkTimer = 0;
this.bounceOffset = 0;
this.bounceSpeed = 0.02;
this.time = 0;
// Leaf particles
this.leaves = [];
for (let i = 0; i < 5; i++) {
this.leaves.push({
x: Math.random() * this.width,
y: Math.random() * this.height,
size: 5 + Math.random() * 8,
speed: 0.3 + Math.random() * 0.5,
wobble: Math.random() * Math.PI * 2,
});
}
this.animate();
}
setState(state) {
this.state = state;
}
setMouthOpen(value) {
this.mouthOpen = Math.max(0, Math.min(1, value));
}
animate() {
this.time += 1;
this.update();
this.draw();
requestAnimationFrame(() => this.animate());
}
update() {
// Bounce
this.bounceOffset = Math.sin(this.time * this.bounceSpeed) * 5;
// Blink
this.blinkTimer++;
if (this.blinkTimer > 150 + Math.random() * 100) {
this.eyeBlink = 1;
this.blinkTimer = 0;
}
if (this.eyeBlink > 0) {
this.eyeBlink -= 0.15;
if (this.eyeBlink < 0) this.eyeBlink = 0;
}
// Mouth animation when talking
if (this.state === 'talking') {
this.mouthOpen = 0.3 + Math.sin(this.time * 0.3) * 0.3 + Math.sin(this.time * 0.17) * 0.2;
} else if (this.state !== 'talking') {
this.mouthOpen *= 0.9;
}
// Update leaves
this.leaves.forEach(leaf => {
leaf.y += leaf.speed;
leaf.x += Math.sin(leaf.wobble + this.time * 0.02) * 0.5;
leaf.wobble += 0.01;
if (leaf.y > this.height + 10) {
leaf.y = -10;
leaf.x = Math.random() * this.width;
}
});
}
draw() {
const ctx = this.ctx;
const cx = this.width / 2;
const cy = this.height / 2 + 30;
ctx.clearRect(0, 0, this.width, this.height);
// Draw falling leaves
this.drawLeaves(ctx);
const by = cy + this.bounceOffset;
// Body - a friendly tree trunk shape
this.drawBody(ctx, cx, by);
// Eyes
this.drawEyes(ctx, cx, by);
// Mouth
this.drawMouth(ctx, cx, by);
// Leaves on top (hair)
this.drawCrown(ctx, cx, by);
// Status indicator
this.drawStatusGlow(ctx, cx, by);
}
drawBody(ctx, cx, cy) {
// Tree trunk body
ctx.fillStyle = '#8B6914';
ctx.beginPath();
ctx.moveTo(cx - 40, cy + 60);
ctx.quadraticCurveTo(cx - 50, cy - 20, cx - 30, cy - 60);
ctx.quadraticCurveTo(cx, cy - 75, cx + 30, cy - 60);
ctx.quadraticCurveTo(cx + 50, cy - 20, cx + 40, cy + 60);
ctx.closePath();
ctx.fill();
// Lighter bark detail
ctx.fillStyle = '#A07828';
ctx.beginPath();
ctx.moveTo(cx - 25, cy + 60);
ctx.quadraticCurveTo(cx - 30, cy - 10, cx - 15, cy - 50);
ctx.quadraticCurveTo(cx, cy - 60, cx + 15, cy - 50);
ctx.quadraticCurveTo(cx + 30, cy - 10, cx + 25, cy + 60);
ctx.closePath();
ctx.fill();
// Small arms (branches)
ctx.strokeStyle = '#8B6914';
ctx.lineWidth = 6;
ctx.lineCap = 'round';
// Left arm
const armWave = Math.sin(this.time * 0.05) * 5;
ctx.beginPath();
ctx.moveTo(cx - 35, cy - 10);
ctx.quadraticCurveTo(cx - 65, cy - 25 + armWave, cx - 70, cy - 40 + armWave);
ctx.stroke();
// Right arm
ctx.beginPath();
ctx.moveTo(cx + 35, cy - 10);
ctx.quadraticCurveTo(cx + 65, cy - 25 - armWave, cx + 70, cy - 40 - armWave);
ctx.stroke();
// Small leaves on arm tips
ctx.fillStyle = '#4CAF50';
ctx.beginPath();
ctx.ellipse(cx - 73, cy - 43 + armWave, 8, 5, -0.5, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(cx + 73, cy - 43 - armWave, 8, 5, 0.5, 0, Math.PI * 2);
ctx.fill();
// Feet (roots)
ctx.fillStyle = '#8B6914';
ctx.beginPath();
ctx.ellipse(cx - 20, cy + 65, 18, 8, -0.2, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(cx + 20, cy + 65, 18, 8, 0.2, 0, Math.PI * 2);
ctx.fill();
}
drawEyes(ctx, cx, cy) {
const eyeY = cy - 25;
const eyeSpacing = 18;
const eyeHeight = this.eyeBlink > 0.5 ? 2 : 10;
// White of eyes
ctx.fillStyle = '#FFFFFF';
ctx.beginPath();
ctx.ellipse(cx - eyeSpacing, eyeY, 10, eyeHeight, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(cx + eyeSpacing, eyeY, 10, eyeHeight, 0, 0, Math.PI * 2);
ctx.fill();
if (this.eyeBlink <= 0.5) {
// Pupils
ctx.fillStyle = '#2E7D32';
ctx.beginPath();
ctx.ellipse(cx - eyeSpacing + 1, eyeY + 1, 5, 6, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(cx + eyeSpacing + 1, eyeY + 1, 5, 6, 0, 0, Math.PI * 2);
ctx.fill();
// Pupil shine
ctx.fillStyle = '#FFFFFF';
ctx.beginPath();
ctx.arc(cx - eyeSpacing + 3, eyeY - 2, 2, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(cx + eyeSpacing + 3, eyeY - 2, 2, 0, Math.PI * 2);
ctx.fill();
}
}
drawMouth(ctx, cx, cy) {
const mouthY = cy + 5;
const openAmount = this.mouthOpen * 12;
if (openAmount > 1) {
// Open mouth
ctx.fillStyle = '#5D2906';
ctx.beginPath();
ctx.ellipse(cx, mouthY + openAmount / 2, 12, openAmount, 0, 0, Math.PI * 2);
ctx.fill();
// Tongue
ctx.fillStyle = '#E57373';
ctx.beginPath();
ctx.ellipse(cx, mouthY + openAmount, 6, 3, 0, 0, Math.PI);
ctx.fill();
} else {
// Smile
ctx.strokeStyle = '#5D2906';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(cx, mouthY - 5, 12, 0.2 * Math.PI, 0.8 * Math.PI);
ctx.stroke();
}
}
drawCrown(ctx, cx, cy) {
const crownY = cy - 65;
const sway = Math.sin(this.time * 0.03) * 3;
// Main crown
const greens = ['#2E7D32', '#4CAF50', '#66BB6A', '#388E3C'];
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2;
const r = 35 + Math.sin(this.time * 0.02 + i) * 3;
const lx = cx + Math.cos(angle) * r + sway;
const ly = crownY + Math.sin(angle) * 25 - 5;
ctx.fillStyle = greens[i % greens.length];
ctx.beginPath();
ctx.ellipse(lx, ly, 20, 15, angle * 0.3, 0, Math.PI * 2);
ctx.fill();
}
// Center top
ctx.fillStyle = '#4CAF50';
ctx.beginPath();
ctx.ellipse(cx + sway, crownY - 25, 18, 14, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#66BB6A';
ctx.beginPath();
ctx.ellipse(cx + sway + 5, crownY - 30, 12, 10, 0.3, 0, Math.PI * 2);
ctx.fill();
}
drawLeaves(ctx) {
ctx.fillStyle = 'rgba(76, 175, 80, 0.4)';
this.leaves.forEach(leaf => {
ctx.save();
ctx.translate(leaf.x, leaf.y);
ctx.rotate(leaf.wobble + this.time * 0.02);
ctx.beginPath();
ctx.ellipse(0, 0, leaf.size, leaf.size * 0.5, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
});
}
drawStatusGlow(ctx, cx, cy) {
if (this.state === 'idle') return;
const colors = {
listening: 'rgba(244, 67, 54, 0.3)',
thinking: 'rgba(255, 193, 7, 0.3)',
talking: 'rgba(76, 175, 80, 0.3)',
};
const color = colors[this.state] || 'transparent';
const pulse = Math.sin(this.time * 0.1) * 10 + 20;
ctx.beginPath();
ctx.arc(cx, cy - 20, 80 + pulse, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
}
}

228
frontend/js/settings.js Normal file
View file

@ -0,0 +1,228 @@
class SettingsManager {
constructor(audioHandler) {
this.audioHandler = audioHandler;
this.micTestStream = null;
this.micTestAnalyser = null;
this.micTestAnimFrame = null;
this.speakerTestAudio = null;
this.modal = document.getElementById('settings-modal');
this.micSelect = document.getElementById('mic-select');
this.speakerSelect = document.getElementById('speaker-select');
this.micTestBtn = document.getElementById('mic-test-btn');
this.micLevel = document.getElementById('mic-level');
this.micTestStatus = document.getElementById('mic-test-status');
this.speakerTestBtn = document.getElementById('speaker-test-btn');
this.speakerTestStatus = document.getElementById('speaker-test-status');
this.micGainSlider = document.getElementById('mic-gain');
this.gainValueEl = document.getElementById('gain-value');
// Set initial slider value from saved preference
this.micGainSlider.value = audioHandler.micGain;
this.gainValueEl.textContent = audioHandler.micGain.toFixed(1);
document.getElementById('settings-btn').addEventListener('click', () => this.open());
document.getElementById('settings-close').addEventListener('click', () => this.close());
document.getElementById('settings-save').addEventListener('click', () => this.save());
this.modal.addEventListener('click', (e) => {
if (e.target === this.modal) this.close();
});
this.micTestBtn.addEventListener('click', () => this.toggleMicTest());
this.speakerTestBtn.addEventListener('click', () => this.testSpeaker());
this.micGainSlider.addEventListener('input', (e) => {
const val = parseFloat(e.target.value);
this.gainValueEl.textContent = val.toFixed(1);
this.audioHandler.setGain(val);
// Update test gain in real-time
if (this._micTestGainNode) {
this._micTestGainNode.gain.value = val;
}
});
}
async open() {
this.modal.classList.remove('hidden');
await this.loadDevices();
}
close() {
this.stopMicTest();
this.stopSpeakerTest();
this.modal.classList.add('hidden');
}
save() {
const micId = this.micSelect.value;
const speakerId = this.speakerSelect.value;
if (micId) this.audioHandler.setMic(micId);
if (speakerId) this.audioHandler.setSpeaker(speakerId);
this.close();
}
async loadDevices() {
// Request permission first to get labeled devices
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(t => t.stop());
} catch (e) {
this.micTestStatus.textContent = 'Eroare: Nu am acces la microfon. Verifica permisiunile browserului.';
return;
}
const devices = await navigator.mediaDevices.enumerateDevices();
// Microphones
this.micSelect.innerHTML = '';
const mics = devices.filter(d => d.kind === 'audioinput');
mics.forEach(device => {
const opt = document.createElement('option');
opt.value = device.deviceId;
opt.textContent = device.label || `Microfon ${this.micSelect.options.length + 1}`;
if (device.deviceId === this.audioHandler.selectedMicId) {
opt.selected = true;
}
this.micSelect.appendChild(opt);
});
// Speakers
this.speakerSelect.innerHTML = '';
const speakers = devices.filter(d => d.kind === 'audiooutput');
if (speakers.length === 0) {
const opt = document.createElement('option');
opt.textContent = 'Difuzor implicit (browserul nu permite selectia)';
this.speakerSelect.appendChild(opt);
this.speakerSelect.disabled = true;
} else {
this.speakerSelect.disabled = false;
speakers.forEach(device => {
const opt = document.createElement('option');
opt.value = device.deviceId;
opt.textContent = device.label || `Difuzor ${this.speakerSelect.options.length + 1}`;
if (device.deviceId === this.audioHandler.selectedSpeakerId) {
opt.selected = true;
}
this.speakerSelect.appendChild(opt);
});
}
}
async toggleMicTest() {
if (this.micTestStream) {
this.stopMicTest();
return;
}
const deviceId = this.micSelect.value;
try {
this.micTestStream = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: deviceId ? { exact: deviceId } : undefined },
});
const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(this.micTestStream);
const gainNode = ctx.createGain();
gainNode.gain.value = this.audioHandler.micGain;
this._micTestGainNode = gainNode;
this.micTestAnalyser = ctx.createAnalyser();
this.micTestAnalyser.fftSize = 256;
source.connect(gainNode);
gainNode.connect(this.micTestAnalyser);
this.micTestBtn.textContent = 'Opreste testul';
this.micTestBtn.classList.add('active');
this.micTestStatus.textContent = 'Vorbeste in microfon - ar trebui sa vezi bara miscandu-se.';
this._micTestCtx = ctx;
this.updateMicLevel();
} catch (e) {
this.micTestStatus.textContent = `Eroare: ${e.message}`;
}
}
updateMicLevel() {
if (!this.micTestAnalyser) return;
const data = new Uint8Array(this.micTestAnalyser.frequencyBinCount);
this.micTestAnalyser.getByteFrequencyData(data);
let sum = 0;
for (let i = 0; i < data.length; i++) sum += data[i];
const avg = sum / data.length;
const pct = Math.min(100, (avg / 128) * 100);
this.micLevel.style.width = pct + '%';
this.micTestAnimFrame = requestAnimationFrame(() => this.updateMicLevel());
}
stopMicTest() {
if (this.micTestStream) {
this.micTestStream.getTracks().forEach(t => t.stop());
this.micTestStream = null;
}
if (this.micTestAnimFrame) {
cancelAnimationFrame(this.micTestAnimFrame);
this.micTestAnimFrame = null;
}
if (this._micTestCtx) {
this._micTestCtx.close();
this._micTestCtx = null;
}
this.micTestAnalyser = null;
this.micLevel.style.width = '0%';
this.micTestBtn.textContent = 'Testeaza microfonul';
this.micTestBtn.classList.remove('active');
this.micTestStatus.textContent = '';
}
async testSpeaker() {
this.stopSpeakerTest();
// Generate a short test tone
const ctx = new AudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(440, ctx.currentTime);
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 1);
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
// Try to set output device
const speakerId = this.speakerSelect.value;
if (speakerId && ctx.setSinkId) {
try {
await ctx.setSinkId(speakerId);
} catch (e) {
// setSinkId not supported in all browsers
}
}
oscillator.start();
oscillator.stop(ctx.currentTime + 1);
this.speakerTestStatus.textContent = 'Se reda un sunet de test (440 Hz)...';
this._speakerTestCtx = ctx;
setTimeout(() => {
this.speakerTestStatus.textContent = 'Ai auzit sunetul? Daca nu, selecteaza alt difuzor.';
ctx.close();
this._speakerTestCtx = null;
}, 1200);
}
stopSpeakerTest() {
if (this._speakerTestCtx) {
this._speakerTestCtx.close();
this._speakerTestCtx = null;
}
this.speakerTestStatus.textContent = '';
}
}

18
start.bat Normal file
View file

@ -0,0 +1,18 @@
@echo off
chcp 65001 >nul
set PYTHONIOENCODING=utf-8
echo ================================
echo EcoBot - Scoala Verde
echo ================================
echo.
cd /d "%~dp0"
echo Activating virtual environment...
call venv\Scripts\activate
echo Starting server...
cd backend
python main.py
pause