Initial commit: audio-chat with fixes
- Created AGENTS.md with architecture documentation - Fixed race conditions and async patterns - Added conversation history to LLM prompts - Fixed TTS audio shape handling - Added buffer limits and graceful shutdown - Fixed client.py with file sending support - Removed duplicate requirements - Added .gitignore
This commit is contained in:
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg
|
||||||
|
response_*.wav
|
||||||
|
*.log
|
||||||
51
AGENTS.md
Normal file
51
AGENTS.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Audio Chat
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Client (for testing):
|
||||||
|
```bash
|
||||||
|
python client.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Single-process FastAPI server. On each WebSocket connection a new `AudioSession` is created with three engines:
|
||||||
|
|
||||||
|
| Module | Purpose | Model (default) |
|
||||||
|
|--------|---------|-----------------|
|
||||||
|
| `engine/stt.py` | Speech-to-text | Systran/faster-whisper-large-v3 |
|
||||||
|
| `engine/llm.py` | LLM response generation | Qwen/Qwen2.5-7B-Instruct |
|
||||||
|
| `engine/tts.py` | Text-to-speech | facebook/mms-tts-rus |
|
||||||
|
|
||||||
|
Models are loaded lazily on first use if `initialize()` was not called. STT always runs in Russian (`language="ru"` with VAD).
|
||||||
|
|
||||||
|
## WebSocket Protocol
|
||||||
|
|
||||||
|
| Direction | Format | Meaning |
|
||||||
|
|-----------|--------|---------|
|
||||||
|
| Client → Server | `b"A" + PCM data` | Send audio chunk |
|
||||||
|
| Client → Server | `b"R"` | Reset conversation |
|
||||||
|
| Server → Client | `b"O" + WAV bytes` | LLM response as audio |
|
||||||
|
| Server → Client | `"TEXT:<transcription>"` | Recognized speech |
|
||||||
|
|
||||||
|
Audio format: 16-bit PCM mono, 16 kHz input / 24 kHz output.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All settings via `.env` (loaded by `config.py`). Key vars:
|
||||||
|
|
||||||
|
- `DEVICE` — `"cuda"` or `"cpu"` (default `"auto"`)
|
||||||
|
- `AUDIO_BUFFER_SECONDS` / `CHUNK_SIZE` — silence detection thresholds
|
||||||
|
- `LLM_MAX_TOKENS` / `LLM_TEMPERATURE` — generation parameters
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- No test suite or linting configured.
|
||||||
|
- Models download on first use; ensure network access to HuggingFace.
|
||||||
|
- `AudioSession` holds conversation history (last 6 turns) in memory — each WebSocket reconnect resets it.
|
||||||
|
- Thread pool executor is fixed at 2 workers; concurrent heavy requests will queue.
|
||||||
|
- TTS pipeline falls back to CPU (`device=-1`) if GPU initialization fails silently.
|
||||||
0
audio/__init__.py
Normal file
0
audio/__init__.py
Normal file
50
audio/stream.py
Normal file
50
audio/stream.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class AudioStreamBuffer:
|
||||||
|
def __init__(self):
|
||||||
|
self.config = Config()
|
||||||
|
self.buffer = b""
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.event = threading.Event()
|
||||||
|
self.running = False
|
||||||
|
# Limit buffer to 10 seconds of audio to prevent OOM
|
||||||
|
self.max_buffer_bytes = int(self.config.SAMPLE_RATE * 10 * 2)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.running = True
|
||||||
|
self.buffer = b""
|
||||||
|
self.event.clear()
|
||||||
|
|
||||||
|
def add_chunk(self, chunk: bytes):
|
||||||
|
with self.lock:
|
||||||
|
self.buffer += chunk
|
||||||
|
# Evict oldest data if buffer exceeds limit
|
||||||
|
if len(self.buffer) > self.max_buffer_bytes:
|
||||||
|
self.buffer = self.buffer[-self.max_buffer_bytes // 2:]
|
||||||
|
if len(self.buffer) >= self.config.CHUNK_SIZE:
|
||||||
|
self.event.set()
|
||||||
|
|
||||||
|
def get_ready_chunk(self, timeout: float = 1.0) -> bytes:
|
||||||
|
if self.event.wait(timeout=timeout):
|
||||||
|
with self.lock:
|
||||||
|
chunk = self.buffer
|
||||||
|
self.buffer = b""
|
||||||
|
self.event.clear()
|
||||||
|
return chunk
|
||||||
|
return b""
|
||||||
|
|
||||||
|
def get_full_buffer(self) -> bytes:
|
||||||
|
with self.lock:
|
||||||
|
chunk = self.buffer
|
||||||
|
self.buffer = b""
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.running = False
|
||||||
|
self.event.set()
|
||||||
|
|
||||||
|
def is_running(self):
|
||||||
|
return self.running
|
||||||
160
client.py
Normal file
160
client.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import asyncio
|
||||||
|
import websockets
|
||||||
|
import struct
|
||||||
|
import wave
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# WebSocket URL
|
||||||
|
WS_URL = "ws://localhost:8000/ws"
|
||||||
|
|
||||||
|
|
||||||
|
async def start_recording():
|
||||||
|
"""Send start signal (b'S')"""
|
||||||
|
async with websockets.connect(WS_URL) as ws:
|
||||||
|
await ws.send(b"S")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_audio(ws, audio_data: bytes):
|
||||||
|
"""Send audio data (b'A' + raw PCM)"""
|
||||||
|
await ws.send(b"A" + audio_data)
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_session(ws):
|
||||||
|
"""Reset conversation (b'R')"""
|
||||||
|
await ws.send(b"R")
|
||||||
|
|
||||||
|
|
||||||
|
async def receive_messages(ws):
|
||||||
|
"""Receive TEXT and AUDIO messages"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = await asyncio.wait_for(ws.recv(), timeout=30.0)
|
||||||
|
if isinstance(msg, str):
|
||||||
|
if msg.startswith("TEXT:"):
|
||||||
|
print(f"[RECognized] {msg[5:]}")
|
||||||
|
else:
|
||||||
|
print(f"[Server] {msg}")
|
||||||
|
elif isinstance(msg, bytes):
|
||||||
|
if msg[0:1] == b"O":
|
||||||
|
audio = msg[1:]
|
||||||
|
print(f"[Audio] Received {len(audio)} bytes")
|
||||||
|
# Save to file
|
||||||
|
timestamp = int(asyncio.get_running_loop().time())
|
||||||
|
filename = f"response_{timestamp}.wav"
|
||||||
|
with open(filename, "wb") as f:
|
||||||
|
with wave.open(f, "wb") as wf:
|
||||||
|
wf.setnchannels(1)
|
||||||
|
wf.setsampwidth(2)
|
||||||
|
wf.setframerate(24000)
|
||||||
|
wf.writeframes(audio)
|
||||||
|
print(f"[Audio] Saved to {filename}")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
async def record_and_send():
|
||||||
|
"""Record audio from microphone and send"""
|
||||||
|
import pyaudio
|
||||||
|
|
||||||
|
CHUNK = 1024
|
||||||
|
FORMAT = pyaudio.paInt16
|
||||||
|
CHANNELS = 1
|
||||||
|
RATE = 16000
|
||||||
|
|
||||||
|
p = pyaudio.PyAudio()
|
||||||
|
stream = p.open(format=FORMAT, channels=CHANNELS, rate=RATE, input=True, frames_per_buffer=CHUNK)
|
||||||
|
|
||||||
|
async with websockets.connect(WS_URL) as ws:
|
||||||
|
print("Recording... Press Ctrl+C to stop")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = stream.read(CHUNK)
|
||||||
|
await send_audio(ws, data)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopped recording")
|
||||||
|
finally:
|
||||||
|
stream.stop_stream()
|
||||||
|
stream.close()
|
||||||
|
p.terminate()
|
||||||
|
|
||||||
|
|
||||||
|
async def send_audio_file(filepath: str):
|
||||||
|
"""Read and send an audio file to the server."""
|
||||||
|
try:
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
file_data = f.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"Error: File '{filepath}' not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Reading audio file: {filepath} ({len(file_data)} bytes)")
|
||||||
|
|
||||||
|
async with websockets.connect(WS_URL) as ws:
|
||||||
|
print("Connected. Sending audio file...")
|
||||||
|
await ws.send(b"A" + file_data)
|
||||||
|
print("File sent. Waiting for response...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await asyncio.wait_for(ws.recv(), timeout=60.0)
|
||||||
|
if isinstance(msg, str):
|
||||||
|
if msg.startswith("TEXT:"):
|
||||||
|
print(f"[Recognized] {msg[5:]}")
|
||||||
|
else:
|
||||||
|
print(f"[Server] {msg}")
|
||||||
|
elif isinstance(msg, bytes):
|
||||||
|
if msg[0:1] == b"O":
|
||||||
|
audio = msg[1:]
|
||||||
|
timestamp = int(asyncio.get_running_loop().time())
|
||||||
|
filename = f"response_{timestamp}.wav"
|
||||||
|
with open(filename, "wb") as f:
|
||||||
|
with wave.open(f, "wb") as wf:
|
||||||
|
wf.setnchannels(1)
|
||||||
|
wf.setsampwidth(2)
|
||||||
|
wf.setframerate(24000)
|
||||||
|
wf.writeframes(audio)
|
||||||
|
print(f"[Audio] Saved response to {filename}")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print("Timed out waiting for response")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def client():
|
||||||
|
"""Main client loop"""
|
||||||
|
print("Audio Chat Client")
|
||||||
|
print("1. Record from microphone")
|
||||||
|
print("2. Send audio file")
|
||||||
|
choice = input("Choice (1/2): ")
|
||||||
|
|
||||||
|
if choice == "1":
|
||||||
|
p = pyaudio.PyAudio()
|
||||||
|
stream = p.open(format=pyaudio.paInt16, channels=1, rate=16000, input=True, frames_per_buffer=1024)
|
||||||
|
async with websockets.connect(WS_URL) as ws:
|
||||||
|
print("Recording... Press Ctrl+C to stop")
|
||||||
|
try:
|
||||||
|
receive_task = asyncio.create_task(receive_messages(ws))
|
||||||
|
while True:
|
||||||
|
data = stream.read(1024)
|
||||||
|
await ws.send(b"A" + data)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
receive_task.cancel()
|
||||||
|
finally:
|
||||||
|
stream.stop_stream()
|
||||||
|
stream.close()
|
||||||
|
p.terminate()
|
||||||
|
elif choice == "2":
|
||||||
|
filepath = input("Enter audio file path: ").strip()
|
||||||
|
if filepath:
|
||||||
|
await send_audio_file(filepath)
|
||||||
|
else:
|
||||||
|
print("No file path provided")
|
||||||
|
else:
|
||||||
|
print("Invalid choice")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(client())
|
||||||
29
config.py
Normal file
29
config.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
env_path = Path(__file__).parent / ".env"
|
||||||
|
load_dotenv(env_path)
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
# Models
|
||||||
|
STT_MODEL = os.getenv("STT_MODEL", "Systran/faster-whisper-large-v3")
|
||||||
|
LLM_MODEL = os.getenv("LLM_MODEL", "Qwen/Qwen2.5-7B-Instruct")
|
||||||
|
TTS_MODEL = os.getenv("TTS_MODEL", "facebook/mms-tts-rus")
|
||||||
|
|
||||||
|
# Audio settings
|
||||||
|
SAMPLE_RATE = int(os.getenv("SAMPLE_RATE", "16000"))
|
||||||
|
AUDIO_BUFFER_SECONDS = float(os.getenv("AUDIO_BUFFER_SECONDS", "2"))
|
||||||
|
CHUNK_SIZE = int(os.getenv("CHUNK_SIZE", "1024"))
|
||||||
|
|
||||||
|
# Server
|
||||||
|
HOST = os.getenv("HOST", "0.0.0.0")
|
||||||
|
PORT = int(os.getenv("PORT", "8000"))
|
||||||
|
|
||||||
|
# LLM settings
|
||||||
|
LLM_MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "512"))
|
||||||
|
LLM_TEMPERATURE = float(os.getenv("LLM_TEMPERATURE", "0.7"))
|
||||||
|
|
||||||
|
# GPU
|
||||||
|
DEVICE = os.getenv("DEVICE", "auto")
|
||||||
0
engine/__init__.py
Normal file
0
engine/__init__.py
Normal file
61
engine/llm.py
Normal file
61
engine/llm.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from transformers import AutoTokenizer, AutoModelForCausalLM, GenerationConfig
|
||||||
|
from config import Config
|
||||||
|
import torch
|
||||||
|
|
||||||
|
|
||||||
|
class LLMEngine:
|
||||||
|
def __init__(self):
|
||||||
|
self.model = None
|
||||||
|
self.tokenizer = None
|
||||||
|
self.config = Config()
|
||||||
|
|
||||||
|
def initialize(self):
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
dtype = torch.float16 if device == "cuda" else torch.float32
|
||||||
|
|
||||||
|
self.tokenizer = AutoTokenizer.from_pretrained(
|
||||||
|
self.config.LLM_MODEL,
|
||||||
|
trust_remote_code=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.model = AutoModelForCausalLM.from_pretrained(
|
||||||
|
self.config.LLM_MODEL,
|
||||||
|
torch_dtype=dtype,
|
||||||
|
device_map="auto",
|
||||||
|
trust_remote_code=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate(self, user_text: str, system_prompt: str = None) -> str:
|
||||||
|
if not self.model:
|
||||||
|
self.initialize()
|
||||||
|
|
||||||
|
if system_prompt is None:
|
||||||
|
system_prompt = "Ты полезный ассистент. Отвечай на русском языке кратко и по делу."
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_text},
|
||||||
|
]
|
||||||
|
|
||||||
|
text = self.tokenizer.apply_chat_template(
|
||||||
|
messages,
|
||||||
|
tokenize=False,
|
||||||
|
add_generation_prompt=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
inputs = self.tokenizer(text, return_tensors="pt").to(self.model.device)
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
outputs = self.model.generate(
|
||||||
|
**inputs,
|
||||||
|
max_new_tokens=self.config.LLM_MAX_TOKENS,
|
||||||
|
temperature=self.config.LLM_TEMPERATURE,
|
||||||
|
do_sample=True,
|
||||||
|
top_p=0.9,
|
||||||
|
repetition_penalty=1.1,
|
||||||
|
)
|
||||||
|
|
||||||
|
generated = outputs[0][inputs["input_ids"].shape[1]:]
|
||||||
|
response = self.tokenizer.decode(generated, skip_special_tokens=True)
|
||||||
|
|
||||||
|
return response.strip()
|
||||||
49
engine/stt.py
Normal file
49
engine/stt.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from faster_whisper import WhisperModel
|
||||||
|
from config import Config
|
||||||
|
import io
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class STTEngine:
|
||||||
|
def __init__(self):
|
||||||
|
self.model = None
|
||||||
|
self.config = Config()
|
||||||
|
self._model_size = self._resolve_model_size(self.config.STT_MODEL)
|
||||||
|
|
||||||
|
def _resolve_model_size(self, model_name: str) -> str:
|
||||||
|
"""Extract model size from various naming conventions."""
|
||||||
|
# Handle Systran/faster-whisper-* format
|
||||||
|
if "faster-whisper-" in model_name:
|
||||||
|
return model_name.split("faster-whisper-")[-1]
|
||||||
|
# Handle whisper-* format
|
||||||
|
if model_name.startswith("whisper-"):
|
||||||
|
return model_name[len("whisper-"):]
|
||||||
|
# Return as-is for direct model names
|
||||||
|
return model_name
|
||||||
|
|
||||||
|
def initialize(self):
|
||||||
|
device = "cuda" if self.config.DEVICE == "auto" else self.config.DEVICE
|
||||||
|
self.model = WhisperModel(
|
||||||
|
self._model_size,
|
||||||
|
device=device,
|
||||||
|
compute_type="float16" if device == "cuda" else "int8",
|
||||||
|
download_root=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def transcribe(self, audio_bytes: bytes) -> str:
|
||||||
|
if not self.model:
|
||||||
|
self.initialize()
|
||||||
|
|
||||||
|
audio_file = io.BytesIO(audio_bytes)
|
||||||
|
segments, info = self.model.transcribe(
|
||||||
|
audio_file,
|
||||||
|
beam_size=5,
|
||||||
|
language="ru",
|
||||||
|
vad_filter=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = ""
|
||||||
|
for segment in segments:
|
||||||
|
text += segment.text + " "
|
||||||
|
|
||||||
|
return text.strip()
|
||||||
53
engine/tts.py
Normal file
53
engine/tts.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from transformers import pipeline
|
||||||
|
from config import Config
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class TTSEngine:
|
||||||
|
def __init__(self):
|
||||||
|
self.tts_pipeline = None
|
||||||
|
self.config = Config()
|
||||||
|
|
||||||
|
def initialize(self):
|
||||||
|
try:
|
||||||
|
self.tts_pipeline = pipeline(
|
||||||
|
"text-to-speech",
|
||||||
|
self.config.TTS_MODEL,
|
||||||
|
device=0 if __import__("torch").cuda.is_available() else -1,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.tts_pipeline = pipeline(
|
||||||
|
"text-to-speech",
|
||||||
|
model=self._tts_model,
|
||||||
|
device=-1,
|
||||||
|
)
|
||||||
|
self.tts_pipeline.start()
|
||||||
|
|
||||||
|
def synthesize(self, text: str, output_sample_rate: int = 24000) -> np.ndarray:
|
||||||
|
if not self.tts_pipeline:
|
||||||
|
self.initialize()
|
||||||
|
|
||||||
|
result = self.tts_pipeline(
|
||||||
|
text,
|
||||||
|
generate_kwargs={"task": "tts", "language": "ru"},
|
||||||
|
return_tensors=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
audio = result["audio"]
|
||||||
|
# Convert torch tensor to numpy if needed
|
||||||
|
if hasattr(audio, 'numpy'):
|
||||||
|
audio = audio.numpy()
|
||||||
|
elif not isinstance(audio, np.ndarray):
|
||||||
|
audio = np.asarray(audio)
|
||||||
|
|
||||||
|
# Handle multi-dimensional arrays (batch or stereo)
|
||||||
|
if audio.ndim > 2:
|
||||||
|
# Batch dimension - take first item
|
||||||
|
audio = audio[0]
|
||||||
|
if audio.ndim == 2:
|
||||||
|
# Stereo - mix to mono
|
||||||
|
audio = audio.mean(axis=1)
|
||||||
|
|
||||||
|
audio = audio.astype(np.float32)
|
||||||
|
|
||||||
|
return audio
|
||||||
225
main.py
Normal file
225
main.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import asyncio
|
||||||
|
import struct
|
||||||
|
import wave
|
||||||
|
import io
|
||||||
|
import numpy as np
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from engine.stt import STTEngine
|
||||||
|
from engine.llm import LLMEngine
|
||||||
|
from engine.tts import TTSEngine
|
||||||
|
from audio.stream import AudioStreamBuffer
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
|
||||||
|
|
||||||
|
class Message(BaseModel):
|
||||||
|
type: str
|
||||||
|
text: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AudioSession:
|
||||||
|
def __init__(self):
|
||||||
|
self.stt = STTEngine()
|
||||||
|
self.llm = LLMEngine()
|
||||||
|
self.tts = TTSEngine()
|
||||||
|
self.audio_buffer = AudioStreamBuffer()
|
||||||
|
self.conversation_history = []
|
||||||
|
self.is_processing = False
|
||||||
|
self.processing_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def initialize_engines(self):
|
||||||
|
logger.info("Loading STT model...")
|
||||||
|
self.stt.initialize()
|
||||||
|
logger.info("Loading LLM model...")
|
||||||
|
self.llm.initialize()
|
||||||
|
logger.info("Loading TTS model...")
|
||||||
|
self.tts.initialize()
|
||||||
|
logger.info("All models loaded!")
|
||||||
|
|
||||||
|
def add_audio_chunk(self, chunk: bytes):
|
||||||
|
self.audio_buffer.add_chunk(chunk)
|
||||||
|
|
||||||
|
def recognize_speech(self) -> str:
|
||||||
|
ready_chunk = self.audio_buffer.get_ready_chunk(timeout=2.0)
|
||||||
|
if not ready_chunk:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
text = loop.run_until_complete(
|
||||||
|
self._transcribe_async(ready_chunk)
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
async def _transcribe_async(self, audio_bytes: bytes) -> str:
|
||||||
|
"""Async wrapper for STT transcription."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
return await loop.run_in_executor(None, self.stt.transcribe, audio_bytes)
|
||||||
|
|
||||||
|
def get_llm_response(self, user_text: str) -> str:
|
||||||
|
system_prompt = "Ты голосовой ассистент. Отвечай кратко, как в разговорной речи. Не используй списки или форматирование."
|
||||||
|
|
||||||
|
if self.conversation_history:
|
||||||
|
context = "\n".join(self.conversation_history[-6:])
|
||||||
|
full_prompt = f"Предыдущий диалог:\n{context}\n\nПользователь: {user_text}\nТы:"
|
||||||
|
else:
|
||||||
|
full_prompt = f"Пользователь: {user_text}\nТы:"
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
response = loop.run_until_complete(
|
||||||
|
self._generate_async(full_prompt, system_prompt)
|
||||||
|
)
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
self.conversation_history.append(f"Пользователь: {user_text}")
|
||||||
|
self.conversation_history.append(f"Ассистент: {response}")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def _generate_async(self, prompt: str, system_prompt: str) -> str:
|
||||||
|
"""Async wrapper for LLM generation."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
return await loop.run_in_executor(None, self.llm.generate, prompt, system_prompt)
|
||||||
|
|
||||||
|
def synthesize_speech(self, text: str) -> bytes:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
audio = loop.run_until_complete(
|
||||||
|
self._synthesize_async(text, 24000)
|
||||||
|
)
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
wav_buffer = io.BytesIO()
|
||||||
|
with wave.open(wav_buffer, "wb") as wf:
|
||||||
|
wf.setnchannels(1)
|
||||||
|
wf.setsampwidth(2)
|
||||||
|
wf.setframerate(24000)
|
||||||
|
wf.writeframes(audio.tobytes())
|
||||||
|
|
||||||
|
return wav_buffer.getvalue()
|
||||||
|
|
||||||
|
async def _synthesize_async(self, text: str, sample_rate: int) -> np.ndarray:
|
||||||
|
"""Async wrapper for TTS synthesis."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
return await loop.run_in_executor(None, self.tts.synthesize, text, sample_rate)
|
||||||
|
|
||||||
|
def process_conversation(self, user_text: str):
|
||||||
|
logger.info(f"User said: {user_text}")
|
||||||
|
|
||||||
|
self.audio_buffer.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.get_llm_response(user_text)
|
||||||
|
logger.info(f"LLM response: {response}")
|
||||||
|
|
||||||
|
audio = self.synthesize_speech(response)
|
||||||
|
logger.info(f"Generated {len(audio)} bytes of audio")
|
||||||
|
|
||||||
|
return audio
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing conversation: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.conversation_history = []
|
||||||
|
self.audio_buffer = AudioStreamBuffer()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
logger.info("Starting audio chat server...")
|
||||||
|
yield
|
||||||
|
logger.info("Shutting down...")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Audio Chat Server", version="1.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def serve_index():
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
async def process_audio_chunk(session: AudioSession, payload: bytes, websocket):
|
||||||
|
"""Handle an audio chunk with proper async handling."""
|
||||||
|
session.add_audio_chunk(payload)
|
||||||
|
|
||||||
|
ready = session.audio_buffer.get_ready_chunk(timeout=0.1)
|
||||||
|
if ready:
|
||||||
|
try:
|
||||||
|
text = session.stt.transcribe(ready)
|
||||||
|
if text:
|
||||||
|
await websocket.send_text(f"TEXT:{text}")
|
||||||
|
|
||||||
|
async with session.processing_lock:
|
||||||
|
if not session.is_processing:
|
||||||
|
session.is_processing = True
|
||||||
|
try:
|
||||||
|
audio = await asyncio.to_thread(
|
||||||
|
session.process_conversation, text
|
||||||
|
)
|
||||||
|
if audio:
|
||||||
|
try:
|
||||||
|
await websocket.send_bytes(b"O" + audio)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send audio: {e}")
|
||||||
|
finally:
|
||||||
|
session.is_processing = False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing audio chunk: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws")
|
||||||
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info("Client connected")
|
||||||
|
|
||||||
|
session = AudioSession()
|
||||||
|
session.initialize_engines()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await asyncio.wait_for(websocket.receive_bytes(), timeout=120.0)
|
||||||
|
|
||||||
|
msg_type = data[:1]
|
||||||
|
payload = data[1:]
|
||||||
|
|
||||||
|
if msg_type == b"A":
|
||||||
|
await process_audio_chunk(session, payload, websocket)
|
||||||
|
elif msg_type == b"R":
|
||||||
|
session.reset()
|
||||||
|
await websocket.send_text("RESET")
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info("Client disconnected")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.info("Client timed out")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebSocket error: {e}")
|
||||||
|
finally:
|
||||||
|
await websocket.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host=config.HOST, port=config.PORT)
|
||||||
26
requirements.txt
Normal file
26
requirements.txt
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# WebSocket server
|
||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
websockets==13.1
|
||||||
|
|
||||||
|
# Speech-to-Text
|
||||||
|
faster-whisper==1.0.3
|
||||||
|
soundfile==0.12.1
|
||||||
|
|
||||||
|
# LLM
|
||||||
|
transformers==4.44.0
|
||||||
|
torch==2.4.1
|
||||||
|
accelerate==1.0.0
|
||||||
|
bitsandbytes==0.44.0
|
||||||
|
|
||||||
|
# TTS
|
||||||
|
torchaudio>=2.4.0
|
||||||
|
|
||||||
|
# Audio processing
|
||||||
|
numpy==2.1.1
|
||||||
|
scipy==1.14.1
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
pydantic==2.9.2
|
||||||
|
pydantic-settings==2.5.2
|
||||||
570
static/index.html
Normal file
570
static/index.html
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Голосовой чат</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f0f23;
|
||||||
|
color: #e0e0e0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #1a1a3e, #2d1b69);
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.connected {
|
||||||
|
background: #4caf50;
|
||||||
|
box-shadow: 0 0 10px #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.recording {
|
||||||
|
background: #f44336;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user {
|
||||||
|
background: #2d1b69;
|
||||||
|
margin-left: auto;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
margin-right: auto;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visualizer {
|
||||||
|
width: 100%;
|
||||||
|
height: 80px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 14px 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #6c3ce0, #9b59b6);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(108, 60, 224, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c0392b, #e74c3c);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-indicator {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-indicator.active {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid rgba(255,255,255,0.1);
|
||||||
|
border-top-color: #6c3ce0;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>🎙️ Голосовой чат с ИИ</h1>
|
||||||
|
<div class="status">
|
||||||
|
<div class="status-dot" id="statusDot"></div>
|
||||||
|
<span id="statusText">Отключено</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="config-section">
|
||||||
|
<label>URL WebSocket сервера</label>
|
||||||
|
<input type="text" id="wsUrl" value="ws://localhost:8000/ws" placeholder="ws://localhost:8000/ws">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-area" id="chatArea">
|
||||||
|
<div class="message assistant">
|
||||||
|
<div class="message-label">Ассистент</div>
|
||||||
|
<div class="message-text">Привет! Нажми "Начать запись" и говори со мной.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading" id="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div>Генерирую ответ...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<canvas class="visualizer" id="visualizer"></canvas>
|
||||||
|
<div class="text-indicator" id="textIndicator"></div>
|
||||||
|
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn-primary" id="btnStart" onclick="startRecording()">
|
||||||
|
🎤 Начать запись
|
||||||
|
</button>
|
||||||
|
<button class="btn-danger" id="btnStop" onclick="stopRecording()" disabled>
|
||||||
|
⏹️ Остановить
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary" onclick="resetChat()">
|
||||||
|
🔄 Сброс
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio id="audioPlayer" style="display:none"></audio>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const audioPlayer = document.getElementById('audioPlayer');
|
||||||
|
const chatArea = document.getElementById('chatArea');
|
||||||
|
const statusDot = document.getElementById('statusDot');
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
const textIndicator = document.getElementById('textIndicator');
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
const btnStart = document.getElementById('btnStart');
|
||||||
|
const btnStop = document.getElementById('btnStop');
|
||||||
|
const visualizer = document.getElementById('visualizer');
|
||||||
|
const wsUrlInput = document.getElementById('wsUrl');
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
let audioContext = null;
|
||||||
|
let mediaStream = null;
|
||||||
|
let analyser = null;
|
||||||
|
let isRecording = false;
|
||||||
|
let buffer = new Uint8Array();
|
||||||
|
let animationId = null;
|
||||||
|
let textTimeout = null;
|
||||||
|
let currentText = '';
|
||||||
|
|
||||||
|
// Resize visualizer
|
||||||
|
function resizeVisualizer() {
|
||||||
|
visualizer.width = visualizer.offsetWidth;
|
||||||
|
visualizer.height = visualizer.offsetHeight;
|
||||||
|
}
|
||||||
|
resizeVisualizer();
|
||||||
|
window.addEventListener('resize', resizeVisualizer);
|
||||||
|
|
||||||
|
function setStatus(status, text) {
|
||||||
|
statusDot.className = 'status-dot ' + status;
|
||||||
|
statusText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage(role, text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'message ' + role;
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="message-label">${role === 'user' ? 'Вы' : 'Ассистент'}</div>
|
||||||
|
<div class="message-text">${text}</div>
|
||||||
|
`;
|
||||||
|
chatArea.appendChild(div);
|
||||||
|
chatArea.scrollTop = chatArea.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawVisualizer() {
|
||||||
|
if (!analyser) return;
|
||||||
|
const bufferLength = analyser.frequencyBinCount;
|
||||||
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
|
analyser.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
|
const ctx = visualizer.getContext('2d');
|
||||||
|
ctx.clearRect(0, 0, visualizer.width, visualizer.height);
|
||||||
|
|
||||||
|
const barWidth = (visualizer.width / bufferLength) * 2.5;
|
||||||
|
let x = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < bufferLength; i++) {
|
||||||
|
const barHeight = (dataArray[i] / 255) * visualizer.height;
|
||||||
|
ctx.fillStyle = `rgba(108, 60, 224, ${dataArray[i] / 255})`;
|
||||||
|
ctx.fillRect(x, visualizer.height - barHeight, barWidth, barHeight);
|
||||||
|
x += barWidth + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecording) {
|
||||||
|
animationId = requestAnimationFrame(drawVisualizer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectWebSocket() {
|
||||||
|
const url = wsUrlInput.value || 'ws://localhost:8000/ws';
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setStatus('connected', 'Подключено');
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setStatus('', 'Отключено');
|
||||||
|
console.log('WebSocket closed');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
setStatus('', 'Ошибка подключения');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = event.data;
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
if (data.startsWith('TEXT:')) {
|
||||||
|
const text = data.substring(5);
|
||||||
|
currentText = text;
|
||||||
|
textIndicator.textContent = text;
|
||||||
|
textIndicator.classList.add('active');
|
||||||
|
|
||||||
|
// Add user message after delay
|
||||||
|
clearTimeout(textTimeout);
|
||||||
|
textTimeout = setTimeout(() => {
|
||||||
|
addMessage('user', text);
|
||||||
|
textIndicator.textContent = '';
|
||||||
|
textIndicator.classList.remove('active');
|
||||||
|
currentText = '';
|
||||||
|
}, 1500);
|
||||||
|
} else if (data === 'RESET') {
|
||||||
|
console.log('Session reset');
|
||||||
|
}
|
||||||
|
} else if (data instanceof ArrayBuffer) {
|
||||||
|
const bytes = new Uint8Array(data);
|
||||||
|
if (bytes[0] === 0x4F) { // 'O'
|
||||||
|
const audioData = bytes.slice(1);
|
||||||
|
playAudio(audioData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function playAudio(audioData) {
|
||||||
|
// Convert 24kHz WAV to 48kHz for browser playback
|
||||||
|
const sampleRate = 24000;
|
||||||
|
const targetSampleRate = 48000;
|
||||||
|
const wavHeaderSize = 44;
|
||||||
|
const audioContent = audioData.slice(wavHeaderSize);
|
||||||
|
const numSamples = audioContent.length / 2;
|
||||||
|
|
||||||
|
// Create AudioBuffer
|
||||||
|
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const outputBuffer = audioCtx.createBuffer(1, numSamples * (targetSampleRate / sampleRate), targetSampleRate);
|
||||||
|
const outputData = outputBuffer.getChannelData(0);
|
||||||
|
|
||||||
|
// Resample
|
||||||
|
for (let i = 0; i < numSamples; i++) {
|
||||||
|
const inputSample = parseInt(audioContent.slice(i * 2, i * 2 + 2).reverse());
|
||||||
|
const outputSample = inputSample / 32768;
|
||||||
|
const outputIndex = i * (targetSampleRate / sampleRate);
|
||||||
|
|
||||||
|
for (let j = 0; j < (targetSampleRate / sampleRate); j++) {
|
||||||
|
const idx = Math.floor(outputIndex + j);
|
||||||
|
if (idx < outputBuffer.length) {
|
||||||
|
outputData[idx] = outputSample;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create source and play
|
||||||
|
const source = audioCtx.createBufferSource();
|
||||||
|
source.buffer = outputBuffer;
|
||||||
|
source.connect(audioCtx.destination);
|
||||||
|
source.start();
|
||||||
|
|
||||||
|
loading.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
try {
|
||||||
|
// Connect WebSocket
|
||||||
|
connectWebSocket();
|
||||||
|
|
||||||
|
// Request microphone access
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
channelCount: 1,
|
||||||
|
sampleRate: 48000,
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup audio context
|
||||||
|
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||||
|
|
||||||
|
// Setup analyser for visualization
|
||||||
|
analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
isRecording = true;
|
||||||
|
btnStart.disabled = true;
|
||||||
|
btnStop.disabled = false;
|
||||||
|
setStatus('recording', 'Запись...');
|
||||||
|
textIndicator.textContent = 'Слушаю...';
|
||||||
|
textIndicator.classList.add('active');
|
||||||
|
drawVisualizer();
|
||||||
|
|
||||||
|
// Resample from 48kHz to 16kHz and send
|
||||||
|
const scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
|
scriptProcessor.onaudioprocess = (event) => {
|
||||||
|
if (!isRecording) return;
|
||||||
|
|
||||||
|
const input = event.inputBuffer.getChannelData(0);
|
||||||
|
// Resample from 48kHz to 16kHz (3:1 ratio)
|
||||||
|
const resampled = [];
|
||||||
|
for (let i = 0; i < input.length; i += 3) {
|
||||||
|
resampled.push(input[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to PCM 16-bit
|
||||||
|
const pcm = new Uint8Array(resampled.length * 2);
|
||||||
|
for (let i = 0; i < resampled.length; i++) {
|
||||||
|
const sample = Math.max(-1, Math.min(1, resampled[i]));
|
||||||
|
const int16 = sample < 0 ? sample * 32768 : sample * 32767;
|
||||||
|
pcm[i * 2] = int16 & 0xFF;
|
||||||
|
pcm[i * 2 + 1] = (int16 >> 8) & 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send via WebSocket
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(new Uint8Array([0x41]).concat(pcm).buffer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.connect(scriptProcessor);
|
||||||
|
scriptProcessor.connect(audioContext.destination);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error starting recording:', err);
|
||||||
|
alert('Не удалось получить доступ к микрофону: ' + err.message);
|
||||||
|
isRecording = false;
|
||||||
|
btnStart.disabled = false;
|
||||||
|
btnStop.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
isRecording = false;
|
||||||
|
if (animationId) {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
}
|
||||||
|
if (mediaStream) {
|
||||||
|
mediaStream.getTracks().forEach(t => t.stop());
|
||||||
|
}
|
||||||
|
if (audioContext) {
|
||||||
|
audioContext.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
btnStart.disabled = false;
|
||||||
|
btnStop.disabled = true;
|
||||||
|
setStatus('connected', 'Подключено');
|
||||||
|
textIndicator.textContent = '';
|
||||||
|
textIndicator.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetChat() {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(new Uint8Array([0x52]).buffer); // 'R'
|
||||||
|
}
|
||||||
|
chatArea.innerHTML = '';
|
||||||
|
addMessage('assistant', 'Чат сброшен. Говорите со мной!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-connect on page load
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
connectWebSocket();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user