commit d7a7d2cafdc4d6e4819027fa73cbd05f08ef518a Author: Stefan Caramizoiu Date: Wed Apr 1 11:14:26 2026 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b815a0e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..8cfd539 --- /dev/null +++ b/README.md @@ -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//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 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..da52fd2 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/diacritice.py b/backend/diacritice.py new file mode 100644 index 0000000..2868a83 --- /dev/null +++ b/backend/diacritice.py @@ -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) diff --git a/backend/llm.py b/backend/llm.py new file mode 100644 index 0000000..48115be --- /dev/null +++ b/backend/llm.py @@ -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) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..7b7c1fc --- /dev/null +++ b/backend/main.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..643c9bc --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/stt.py b/backend/stt.py new file mode 100644 index 0000000..7b8cc85 --- /dev/null +++ b/backend/stt.py @@ -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) diff --git a/backend/tts.py b/backend/tts.py new file mode 100644 index 0000000..45be575 --- /dev/null +++ b/backend/tts.py @@ -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) diff --git a/backend/voices/.gitkeep b/backend/voices/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..aa54874 --- /dev/null +++ b/frontend/css/style.css @@ -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); +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2a171fe --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,79 @@ + + + + + + EcoBot - Scoala Verde + + + +
+

EcoBot

+

Asistentul tau verde

+ + + + + +
Apasa butonul si vorbeste
+ +
+ + +
+ +
+ + +
+
+ + + + + + + + + + diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..a95e846 --- /dev/null +++ b/frontend/js/app.js @@ -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(); diff --git a/frontend/js/audio.js b/frontend/js/audio.js new file mode 100644 index 0000000..203ceb4 --- /dev/null +++ b/frontend/js/audio.js @@ -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(); + }); + } +} diff --git a/frontend/js/character.js b/frontend/js/character.js new file mode 100644 index 0000000..bfeb52b --- /dev/null +++ b/frontend/js/character.js @@ -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(); + } +} diff --git a/frontend/js/settings.js b/frontend/js/settings.js new file mode 100644 index 0000000..2dbf0f8 --- /dev/null +++ b/frontend/js/settings.js @@ -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 = ''; + } +} diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..c6c6bce --- /dev/null +++ b/start.bat @@ -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