Initial commit
This commit is contained in:
commit
d7a7d2cafd
17 changed files with 2274 additions and 0 deletions
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal 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
130
README.md
Normal 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
33
backend/.env.example
Normal 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
280
backend/diacritice.py
Normal 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
58
backend/llm.py
Normal 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
132
backend/main.py
Normal 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
8
backend/requirements.txt
Normal 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
32
backend/stt.py
Normal 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
80
backend/tts.py
Normal 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
0
backend/voices/.gitkeep
Normal file
388
frontend/css/style.css
Normal file
388
frontend/css/style.css
Normal 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
79
frontend/index.html
Normal 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">×</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
307
frontend/js/app.js
Normal 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
172
frontend/js/audio.js
Normal 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
290
frontend/js/character.js
Normal 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
228
frontend/js/settings.js
Normal 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
18
start.bat
Normal 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
|
||||
Loading…
Add table
Reference in a new issue