openai: add AI stack (litellm + langfuse + pipecat + silero-tts) and oO aliases
- LiteLLM proxy with langfuse callbacks, postgres backends, and OpenRouter fallbacks. - Langfuse observability UI. - Pipecat voice pipeline (LiveKit + STT + TTS + LLM) and Silero TTS build contexts. - Ollama tuned for GPU (OLLAMA_NUM_GPU=999, mem_limit=4g, max 2 loaded models). - open-webui wired to litellm + faster-whisper + silero for voice. - litellm-config.yaml publishes oO's model aliases (tip-generator, embedder, judge) pointing at the host ollama on :11434 so ml/serving can call them via LiteLLM. .env skipped (secrets). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -13,13 +13,10 @@ services:
|
|||||||
- OLLAMA_MAX_LOADED_MODELS=2
|
- OLLAMA_MAX_LOADED_MODELS=2
|
||||||
# One GPU inference at a time — prevents compute contention between models
|
# One GPU inference at a time — prevents compute contention between models
|
||||||
- OLLAMA_NUM_PARALLEL=1
|
- OLLAMA_NUM_PARALLEL=1
|
||||||
deploy:
|
# Force all layers to GPU — fail instead of falling back to CPU
|
||||||
resources:
|
- OLLAMA_NUM_GPU=999
|
||||||
reservations:
|
runtime: nvidia
|
||||||
devices:
|
mem_limit: 4g
|
||||||
- driver: nvidia
|
|
||||||
count: all
|
|
||||||
capabilities: [gpu]
|
|
||||||
|
|
||||||
ollama-cpu:
|
ollama-cpu:
|
||||||
image: ollama/ollama
|
image: ollama/ollama
|
||||||
@@ -37,6 +34,8 @@ services:
|
|||||||
- "3125:8080"
|
- "3125:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/ssd/ai/open-webui:/app/backend/data
|
- /mnt/ssd/ai/open-webui:/app/backend/data
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
restart: always
|
restart: always
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
@@ -47,6 +46,78 @@ services:
|
|||||||
capabilities: [gpu]
|
capabilities: [gpu]
|
||||||
environment:
|
environment:
|
||||||
- ANTHROPIC_API_KEY=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
|
- ANTHROPIC_API_KEY=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
|
||||||
|
- OPENAI_API_BASE_URL=http://host.docker.internal:4000/v1
|
||||||
|
- OPENAI_API_KEY=dummy
|
||||||
|
# STT — Faster-Whisper large-v3-turbo
|
||||||
|
- AUDIO_STT_ENGINE=openai
|
||||||
|
- AUDIO_STT_OPENAI_API_BASE_URL=http://host.docker.internal:8880/v1
|
||||||
|
- AUDIO_STT_OPENAI_API_KEY=dummy
|
||||||
|
- AUDIO_STT_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
|
||||||
|
# TTS — Silero v4
|
||||||
|
- AUDIO_TTS_ENGINE=openai
|
||||||
|
- AUDIO_TTS_OPENAI_API_BASE_URL=http://host.docker.internal:8881/v1
|
||||||
|
- AUDIO_TTS_OPENAI_API_KEY=dummy
|
||||||
|
- AUDIO_TTS_MODEL=silero
|
||||||
|
- AUDIO_TTS_VOICE=onyx
|
||||||
|
|
||||||
|
litellm-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: litellm-db
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=litellm
|
||||||
|
- POSTGRES_USER=litellm
|
||||||
|
- POSTGRES_PASSWORD=litellm
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/litellm/postgres:/var/lib/postgresql/data
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
litellm:
|
||||||
|
image: ghcr.io/berriai/litellm:main-latest
|
||||||
|
container_name: litellm
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
volumes:
|
||||||
|
- ./litellm-config.yaml:/app/config.yaml
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://litellm:litellm@litellm-db:5432/litellm
|
||||||
|
- LITELLM_MASTER_KEY=sk-fjQC1BxAiGFSMs
|
||||||
|
- LANGFUSE_PUBLIC_KEY=${LANGFUSE_PUBLIC_KEY:-changeme}
|
||||||
|
- LANGFUSE_SECRET_KEY=${LANGFUSE_SECRET_KEY:-changeme}
|
||||||
|
- LANGFUSE_HOST=http://langfuse:3000
|
||||||
|
- OPENROUTER_API_KEY=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
|
||||||
|
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
depends_on:
|
||||||
|
- litellm-db
|
||||||
|
- langfuse
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
langfuse-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: langfuse-db
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=langfuse
|
||||||
|
- POSTGRES_USER=langfuse
|
||||||
|
- POSTGRES_PASSWORD=langfuse
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/langfuse/postgres:/var/lib/postgresql/data
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
langfuse:
|
||||||
|
image: ghcr.io/langfuse/langfuse:2
|
||||||
|
container_name: langfuse
|
||||||
|
ports:
|
||||||
|
- "3200:3000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://langfuse:langfuse@langfuse-db:5432/langfuse
|
||||||
|
- NEXTAUTH_URL=https://lf.alogins.net
|
||||||
|
- NEXTAUTH_SECRET=532a746b24ac40afa39f9d317031cab94d4d6881107ea3b1209b28020f1a9761
|
||||||
|
- SALT=7927b3b0092afe4542274940b557becea6418a5fed79f7acd25c3a789349fdc9
|
||||||
|
- AUTH_DISABLE_SIGNUP=true
|
||||||
|
depends_on:
|
||||||
|
- langfuse-db
|
||||||
|
restart: always
|
||||||
|
|
||||||
searxng:
|
searxng:
|
||||||
image: docker.io/searxng/searxng:latest
|
image: docker.io/searxng/searxng:latest
|
||||||
@@ -67,3 +138,48 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/ssd/dbs/qdrant:/qdrant/storage:z
|
- /mnt/ssd/dbs/qdrant:/qdrant/storage:z
|
||||||
|
|
||||||
|
faster-whisper:
|
||||||
|
image: fedirz/faster-whisper-server:latest-cuda
|
||||||
|
container_name: faster-whisper
|
||||||
|
runtime: nvidia
|
||||||
|
ports:
|
||||||
|
- "8880:8000"
|
||||||
|
environment:
|
||||||
|
- WHISPER__MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
|
||||||
|
- WHISPER__INFERENCE_DEVICE=cuda
|
||||||
|
- WHISPER__COMPUTE_TYPE=int8
|
||||||
|
- WHISPER__LANGUAGE=ru
|
||||||
|
- NVIDIA_VISIBLE_DEVICES=all
|
||||||
|
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/ai/faster-whisper:/root/.cache/huggingface
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
silero-tts:
|
||||||
|
build: ./silero-tts
|
||||||
|
container_name: silero-tts
|
||||||
|
ports:
|
||||||
|
- "8881:8881"
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/ai/silero-tts:/cache/torch
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
pipecat:
|
||||||
|
build: ./pipecat
|
||||||
|
container_name: pipecat
|
||||||
|
ports:
|
||||||
|
- "8882:8882"
|
||||||
|
environment:
|
||||||
|
- LIVEKIT_URL=ws://host.docker.internal:7880
|
||||||
|
- LIVEKIT_PUBLIC_URL=wss://lk.alogins.net
|
||||||
|
- LIVEKIT_API_KEY=devkey
|
||||||
|
- LIVEKIT_SECRET=ef3ef4b903ca8469b09b2dd7ab6af529c4d2f3c95668f53832fc351cf67777a9
|
||||||
|
- ADOLF_URL=http://host.docker.internal:8000/v1
|
||||||
|
- STT_URL=http://host.docker.internal:8880/v1
|
||||||
|
- TTS_URL=http://host.docker.internal:8881/v1
|
||||||
|
- STT_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
|
||||||
|
- TTS_VOICE=onyx
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
|||||||
120
openai/litellm-config.yaml
Normal file
120
openai/litellm-config.yaml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
model_list:
|
||||||
|
# ── oO aliases (used by ml/serving; see oO/CLAUDE.md AI stack) ──────────
|
||||||
|
- model_name: tip-generator
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/qwen2.5:1.5b
|
||||||
|
api_base: http://host.docker.internal:11434
|
||||||
|
|
||||||
|
- model_name: embedder
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/nomic-embed-text
|
||||||
|
api_base: http://host.docker.internal:11434
|
||||||
|
|
||||||
|
- model_name: judge
|
||||||
|
litellm_params:
|
||||||
|
model: anthropic/claude-haiku-4-5-20251001
|
||||||
|
api_key: os.environ/ANTHROPIC_API_KEY
|
||||||
|
|
||||||
|
# ── raw model exposure ─────────────────────────────────────────────────
|
||||||
|
- model_name: ollama/qwen3.5:4b
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/qwen3.5:4b
|
||||||
|
api_base: http://host.docker.internal:11436
|
||||||
|
|
||||||
|
- model_name: ollama/qwen3:8b
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/qwen3:8b
|
||||||
|
api_base: http://host.docker.internal:11436
|
||||||
|
|
||||||
|
- model_name: ollama/qwen2.5:1.5b
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/qwen2.5:1.5b
|
||||||
|
api_base: http://host.docker.internal:11436
|
||||||
|
|
||||||
|
- model_name: ollama/qwen2.5:0.5b
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/qwen2.5:0.5b
|
||||||
|
api_base: http://host.docker.internal:11436
|
||||||
|
|
||||||
|
- model_name: ollama/gemma3:4b
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/gemma3:4b
|
||||||
|
api_base: http://host.docker.internal:11436
|
||||||
|
|
||||||
|
- model_name: ollama/gemma3:1b
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/gemma3:1b
|
||||||
|
api_base: http://host.docker.internal:11435
|
||||||
|
|
||||||
|
- model_name: ollama/nomic-embed-text
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/nomic-embed-text
|
||||||
|
api_base: http://host.docker.internal:11435
|
||||||
|
|
||||||
|
# OpenRouter free-tier models
|
||||||
|
- model_name: meta-llama/llama-3.3-70b-instruct:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/meta-llama/llama-3.3-70b-instruct:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: meta-llama/llama-3.2-3b-instruct:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/meta-llama/llama-3.2-3b-instruct:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: deepseek/deepseek-r1:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/deepseek/deepseek-r1:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: qwen/qwen3-4b:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/qwen/qwen3-4b:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: qwen/qwen3-coder:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/qwen/qwen3-coder:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: google/gemma-3-27b-it:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/google/gemma-3-27b-it:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: google/gemma-3-12b-it:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/google/gemma-3-12b-it:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: mistralai/mistral-small-3.1-24b-instruct:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/mistralai/mistral-small-3.1-24b-instruct:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: nvidia/nemotron-3-super-120b-a12b:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/nvidia/nemotron-3-super-120b-a12b:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: openai/gpt-oss-120b:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/openai/gpt-oss-120b:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: minimax/minimax-m2.5:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/minimax/minimax-m2.5:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: nousresearch/hermes-3-llama-3.1-405b:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/nousresearch/hermes-3-llama-3.1-405b:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
litellm_settings:
|
||||||
|
success_callback: ["langfuse"]
|
||||||
|
failure_callback: ["langfuse"]
|
||||||
|
drop_params: true
|
||||||
|
fallbacks:
|
||||||
|
- deepseek/deepseek-r1:free: ["ollama/qwen3.5:4b"]
|
||||||
18
openai/pipecat/Dockerfile
Normal file
18
openai/pipecat/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends gcc g++ && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# CPU torch first — prevents silero-vad from pulling in the CUDA variant
|
||||||
|
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
"pipecat-ai[openai,livekit,silero]" \
|
||||||
|
"livekit-api" \
|
||||||
|
fastapi \
|
||||||
|
"uvicorn[standard]"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8882
|
||||||
|
CMD ["uvicorn", "bot:app", "--host", "0.0.0.0", "--port", "8882"]
|
||||||
228
openai/pipecat/bot.py
Normal file
228
openai/pipecat/bot.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from livekit import api as lkapi
|
||||||
|
|
||||||
|
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||||
|
from pipecat.audio.vad.vad_analyzer import VADParams
|
||||||
|
from pipecat.frames.frames import TextFrame
|
||||||
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
|
from pipecat.pipeline.runner import PipelineRunner
|
||||||
|
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||||
|
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
||||||
|
from pipecat.processors.frame_processor import FrameProcessor, FrameDirection
|
||||||
|
from pipecat.services.openai.llm import OpenAILLMService
|
||||||
|
from pipecat.services.openai.stt import OpenAISTTService
|
||||||
|
from pipecat.services.openai.tts import OpenAITTSService
|
||||||
|
from pipecat.transports.livekit.transport import LiveKitTransport, LiveKitParams
|
||||||
|
|
||||||
|
|
||||||
|
# ── TTS text normalizer ──────────────────────────────────────────────────────
|
||||||
|
# Replaces symbols and abbreviations with spoken Russian words so Silero TTS
|
||||||
|
# doesn't truncate on unknown characters.
|
||||||
|
|
||||||
|
_NORM_RULES: list[tuple[re.Pattern, str]] = [
|
||||||
|
# Temperature: +12°C / -5°С / 12 °C → плюс двенадцать градусов цельсия
|
||||||
|
(re.compile(r"([+-]?\d+)\s*°\s*[CСcс]", re.IGNORECASE), r"\1 градусов цельсия"),
|
||||||
|
# Bare degree sign: 90° → 90 градусов
|
||||||
|
(re.compile(r"(\d+)\s*°"), r"\1 градусов"),
|
||||||
|
# Percent
|
||||||
|
(re.compile(r"(\d+)\s*%"), r"\1 процентов"),
|
||||||
|
# Speed: m/s, м/с, km/h, км/ч
|
||||||
|
(re.compile(r"\bm/s\b", re.IGNORECASE), "метров в секунду"),
|
||||||
|
(re.compile(r"\bм/с\b"), "метров в секунду"),
|
||||||
|
(re.compile(r"\bkm/h\b", re.IGNORECASE), "километров в час"),
|
||||||
|
(re.compile(r"\bкм/ч\b"), "километров в час"),
|
||||||
|
# Currency
|
||||||
|
(re.compile(r"\$\s*(\d+)"), r"\1 долларов"),
|
||||||
|
(re.compile(r"(\d+)\s*\$"), r"\1 долларов"),
|
||||||
|
(re.compile(r"€\s*(\d+)"), r"\1 евро"),
|
||||||
|
(re.compile(r"(\d+)\s*€"), r"\1 евро"),
|
||||||
|
(re.compile(r"(\d+)\s*₽"), r"\1 рублей"),
|
||||||
|
# Plus/minus signs before numbers
|
||||||
|
(re.compile(r"\+(\d)"), r"плюс \1"),
|
||||||
|
(re.compile(r"-(\d)"), r"минус \1"),
|
||||||
|
# Common abbreviations
|
||||||
|
(re.compile(r"\bкг\b"), "килограмм"),
|
||||||
|
(re.compile(r"\bг\b(?=\s|$)"), "грамм"),
|
||||||
|
(re.compile(r"\bмм\b"), "миллиметров"),
|
||||||
|
(re.compile(r"\bсм\b"), "сантиметров"),
|
||||||
|
(re.compile(r"\bкм\b"), "километров"),
|
||||||
|
# Strip remaining special chars that TTS can't handle
|
||||||
|
(re.compile(r"[°•·†‡§¶©®™«»<>{}[\]|\\~^`]"), ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_for_tts(text: str) -> str:
|
||||||
|
"""Replace symbols with spoken Russian equivalents."""
|
||||||
|
for pattern, replacement in _NORM_RULES:
|
||||||
|
text = pattern.sub(replacement, text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
class TTSTextNormalizer(FrameProcessor):
|
||||||
|
"""Intercepts TextFrames between LLM and TTS, normalizing symbols to words."""
|
||||||
|
|
||||||
|
async def process_frame(self, frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
if isinstance(frame, TextFrame):
|
||||||
|
original = frame.text
|
||||||
|
normalized = normalize_for_tts(original)
|
||||||
|
if normalized != original:
|
||||||
|
logger.debug(f"TTSTextNormalizer: {original!r} → {normalized!r}")
|
||||||
|
await self.push_frame(TextFrame(text=normalized), direction)
|
||||||
|
else:
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
LIVEKIT_URL = os.getenv("LIVEKIT_URL", "ws://host.docker.internal:7880") # bot connects here
|
||||||
|
LIVEKIT_PUBLIC_URL = os.getenv("LIVEKIT_PUBLIC_URL", "wss://lk.alogins.net") # browser connects here
|
||||||
|
LIVEKIT_API_KEY = os.getenv("LIVEKIT_API_KEY", "devkey")
|
||||||
|
LIVEKIT_SECRET = os.getenv("LIVEKIT_SECRET", "")
|
||||||
|
ADOLF_URL = os.getenv("ADOLF_URL", "http://host.docker.internal:8000/v1")
|
||||||
|
STT_URL = os.getenv("STT_URL", "http://host.docker.internal:8880/v1")
|
||||||
|
TTS_URL = os.getenv("TTS_URL", "http://host.docker.internal:8881/v1")
|
||||||
|
STT_MODEL = os.getenv("STT_MODEL", "deepdml/faster-whisper-large-v3-turbo-ct2")
|
||||||
|
TTS_VOICE = os.getenv("TTS_VOICE", "onyx")
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = "You are Adolf, a helpful voice assistant. Keep replies concise — 1-3 sentences. No markdown."
|
||||||
|
|
||||||
|
app = FastAPI(title="Pipecat Voice Bot")
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
# ── LiveKit helpers ───────────────────────────────────────────────────────────
|
||||||
|
def _lk_token(room: str, identity: str, is_bot: bool = False) -> str:
|
||||||
|
grants = lkapi.VideoGrants(
|
||||||
|
room_join=True,
|
||||||
|
room=room,
|
||||||
|
can_publish=True,
|
||||||
|
can_subscribe=True,
|
||||||
|
can_publish_data=True,
|
||||||
|
)
|
||||||
|
token = (
|
||||||
|
lkapi.AccessToken(LIVEKIT_API_KEY, LIVEKIT_SECRET)
|
||||||
|
.with_identity(identity)
|
||||||
|
.with_name("Adolf Bot" if is_bot else identity)
|
||||||
|
.with_grants(grants)
|
||||||
|
)
|
||||||
|
return token.to_jwt()
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_room(room_name: str) -> None:
|
||||||
|
lk = lkapi.LiveKitAPI(LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_SECRET)
|
||||||
|
try:
|
||||||
|
await lk.room.create_room(
|
||||||
|
lkapi.CreateRoomRequest(name=room_name, empty_timeout=300, max_participants=5)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await lk.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pipecat pipeline ──────────────────────────────────────────────────────────
|
||||||
|
async def _run_bot(room_name: str) -> None:
|
||||||
|
bot_token = _lk_token(room_name, "pipecat-bot", is_bot=True)
|
||||||
|
|
||||||
|
transport = LiveKitTransport(
|
||||||
|
url=LIVEKIT_URL,
|
||||||
|
token=bot_token,
|
||||||
|
room_name=room_name,
|
||||||
|
params=LiveKitParams(
|
||||||
|
audio_in_enabled=True,
|
||||||
|
audio_out_enabled=True,
|
||||||
|
vad_enabled=True,
|
||||||
|
vad_analyzer=SileroVADAnalyzer(params=VADParams(
|
||||||
|
stop_secs=0.8, # wait 0.8s of silence before end-of-speech
|
||||||
|
start_secs=0.2, # start speech detection after 0.2s
|
||||||
|
confidence=0.85, # high confidence to avoid triggering on ambient noise
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
stt = OpenAISTTService(
|
||||||
|
api_key="dummy",
|
||||||
|
base_url=STT_URL,
|
||||||
|
model=STT_MODEL,
|
||||||
|
language="ru",
|
||||||
|
)
|
||||||
|
|
||||||
|
llm = OpenAILLMService(
|
||||||
|
api_key="dummy",
|
||||||
|
base_url=ADOLF_URL,
|
||||||
|
model="adolf-light",
|
||||||
|
)
|
||||||
|
|
||||||
|
tts = OpenAITTSService(
|
||||||
|
api_key="dummy",
|
||||||
|
base_url=TTS_URL,
|
||||||
|
model="silero",
|
||||||
|
voice=TTS_VOICE,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||||
|
context = OpenAILLMContext(messages)
|
||||||
|
context_aggregator = llm.create_context_aggregator(context)
|
||||||
|
|
||||||
|
normalizer = TTSTextNormalizer()
|
||||||
|
|
||||||
|
pipeline = Pipeline([
|
||||||
|
transport.input(),
|
||||||
|
stt,
|
||||||
|
context_aggregator.user(),
|
||||||
|
llm,
|
||||||
|
normalizer,
|
||||||
|
tts,
|
||||||
|
transport.output(),
|
||||||
|
context_aggregator.assistant(),
|
||||||
|
])
|
||||||
|
|
||||||
|
task = PipelineTask(pipeline, params=PipelineParams(allow_interruptions=False))
|
||||||
|
|
||||||
|
@transport.event_handler("on_participant_disconnected")
|
||||||
|
async def on_disconnect(transport, participant):
|
||||||
|
identity = participant if isinstance(participant, str) else getattr(participant, "identity", str(participant))
|
||||||
|
logger.info(f"Participant {identity} left — stopping bot")
|
||||||
|
await task.cancel()
|
||||||
|
|
||||||
|
runner = PipelineRunner()
|
||||||
|
logger.info(f"Bot starting in room={room_name}")
|
||||||
|
await runner.run(task)
|
||||||
|
logger.info(f"Bot done in room={room_name}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── API ───────────────────────────────────────────────────────────────────────
|
||||||
|
class ConnectResponse(BaseModel):
|
||||||
|
room: str
|
||||||
|
token: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/connect", response_model=ConnectResponse)
|
||||||
|
async def connect():
|
||||||
|
room_name = f"voice-{uuid.uuid4().hex[:6]}"
|
||||||
|
await _create_room(room_name)
|
||||||
|
user_token = _lk_token(room_name, "user")
|
||||||
|
asyncio.create_task(_run_bot(room_name))
|
||||||
|
return ConnectResponse(room=room_name, token=user_token, url=LIVEKIT_PUBLIC_URL)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def index():
|
||||||
|
with open("static/index.html") as f:
|
||||||
|
return f.read()
|
||||||
241
openai/pipecat/static/index.html
Normal file
241
openai/pipecat/static/index.html
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Adolf Voice</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
width: 360px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.4rem; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
.subtitle { color: #666; font-size: 0.85rem; margin-bottom: 32px; }
|
||||||
|
#orb {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, #3a3a3a 0%, #1a1a1a 100%);
|
||||||
|
border: 2px solid #333;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
#orb.listening {
|
||||||
|
background: radial-gradient(circle, #1e3a5f 0%, #0d1f33 100%);
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 20px #3b82f640;
|
||||||
|
animation: pulse-blue 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
#orb.speaking {
|
||||||
|
background: radial-gradient(circle, #1e4034 0%, #0d2018 100%);
|
||||||
|
border-color: #22c55e;
|
||||||
|
box-shadow: 0 0 20px #22c55e40;
|
||||||
|
animation: pulse-green 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
#orb.thinking {
|
||||||
|
background: radial-gradient(circle, #3a2e1e 0%, #1a160d 100%);
|
||||||
|
border-color: #f59e0b;
|
||||||
|
box-shadow: 0 0 20px #f59e0b40;
|
||||||
|
animation: pulse-amber 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
#orb.user-speaking {
|
||||||
|
background: radial-gradient(circle, #3a1e3a 0%, #1a0d1a 100%);
|
||||||
|
border-color: #a855f7;
|
||||||
|
box-shadow: 0 0 20px #a855f740;
|
||||||
|
animation: pulse-purple 0.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse-blue { 0%,100%{box-shadow:0 0 20px #3b82f640} 50%{box-shadow:0 0 35px #3b82f680} }
|
||||||
|
@keyframes pulse-green { 0%,100%{box-shadow:0 0 20px #22c55e40} 50%{box-shadow:0 0 35px #22c55e80} }
|
||||||
|
@keyframes pulse-amber { 0%,100%{box-shadow:0 0 20px #f59e0b40} 50%{box-shadow:0 0 35px #f59e0b80} }
|
||||||
|
@keyframes pulse-purple { 0%,100%{box-shadow:0 0 20px #a855f740} 50%{box-shadow:0 0 35px #a855f780} }
|
||||||
|
#status {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
#transcript {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
min-height: 2.4em;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-style: italic;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
#transcript .user-text { color: #7ba8d4; font-style: normal; }
|
||||||
|
#transcript .bot-text { color: #6ab88a; font-style: normal; }
|
||||||
|
#btn {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
padding: 10px 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
#btn:hover { background: #333; }
|
||||||
|
#btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
#btn.active { border-color: #ef4444; color: #ef4444; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Adolf</h1>
|
||||||
|
<p class="subtitle">Voice assistant</p>
|
||||||
|
<div id="orb" onclick="toggle()">🎙️</div>
|
||||||
|
<div id="status">Press to connect</div>
|
||||||
|
<div id="transcript"></div>
|
||||||
|
<button id="btn" onclick="toggle()">Connect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
let room = null;
|
||||||
|
let audioCtx = null;
|
||||||
|
|
||||||
|
// Unlock browser autoplay — must happen on first user gesture
|
||||||
|
function unlockAudio() {
|
||||||
|
if (!audioCtx) {
|
||||||
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUI(state, msg) {
|
||||||
|
const orb = document.getElementById('orb');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const btn = document.getElementById('btn');
|
||||||
|
orb.className = state || '';
|
||||||
|
status.textContent = msg;
|
||||||
|
if (state === null) {
|
||||||
|
btn.textContent = 'Connect';
|
||||||
|
btn.classList.remove('active');
|
||||||
|
orb.textContent = '🎙️';
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Disconnect';
|
||||||
|
btn.classList.add('active');
|
||||||
|
orb.textContent = state === 'thinking' ? '💭' :
|
||||||
|
state === 'speaking' ? '🔊' :
|
||||||
|
state === 'user-speaking' ? '🗣️' : '🎙️';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTranscript(role, text) {
|
||||||
|
const div = document.getElementById('transcript');
|
||||||
|
const cls = role === 'user' ? 'user-text' : 'bot-text';
|
||||||
|
const prefix = role === 'user' ? 'You: ' : 'Adolf: ';
|
||||||
|
div.innerHTML = `<span class="${cls}">${prefix}${text}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
unlockAudio();
|
||||||
|
if (room) {
|
||||||
|
room.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn').disabled = true;
|
||||||
|
setUI('thinking', 'Connecting…');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/connect', { method: 'POST' });
|
||||||
|
const { token, url } = await res.json();
|
||||||
|
|
||||||
|
room = new LivekitClient.Room({ adaptiveStream: true, dynacast: true });
|
||||||
|
|
||||||
|
room.on(LivekitClient.RoomEvent.Connected, () => {
|
||||||
|
setUI('listening', 'Listening…');
|
||||||
|
document.getElementById('btn').disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(LivekitClient.RoomEvent.Disconnected, () => {
|
||||||
|
setUI(null, 'Press to connect');
|
||||||
|
document.getElementById('btn').disabled = false;
|
||||||
|
document.getElementById('transcript').innerHTML = '';
|
||||||
|
room = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(LivekitClient.RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||||
|
if (!room) return;
|
||||||
|
const botSpeaking = speakers.some(s => s.identity === 'pipecat-bot');
|
||||||
|
const userSpeaking = speakers.some(s => s.identity === 'user');
|
||||||
|
if (botSpeaking) {
|
||||||
|
setUI('speaking', 'Adolf is speaking…');
|
||||||
|
} else if (userSpeaking) {
|
||||||
|
setUI('user-speaking', 'Listening to you…');
|
||||||
|
} else {
|
||||||
|
setUI('listening', 'Listening…');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach remote audio so browser plays it
|
||||||
|
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track, pub, participant) => {
|
||||||
|
if (track.kind === 'audio') {
|
||||||
|
// Remove old element if any
|
||||||
|
const old = document.getElementById(`audio-${participant.identity}`);
|
||||||
|
if (old) old.remove();
|
||||||
|
const el = track.attach();
|
||||||
|
el.id = `audio-${participant.identity}`;
|
||||||
|
el.autoplay = true;
|
||||||
|
// Resume audio context on attach to beat autoplay restrictions
|
||||||
|
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
|
||||||
|
document.body.appendChild(el);
|
||||||
|
el.play().catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(LivekitClient.RoomEvent.TrackUnsubscribed, (track) => {
|
||||||
|
track.detach().forEach(el => el.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(LivekitClient.RoomEvent.ParticipantConnected, (p) => {
|
||||||
|
if (p.identity === 'pipecat-bot') {
|
||||||
|
setUI('listening', 'Listening…');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Data messages from bot (transcripts/events if pipecat sends them)
|
||||||
|
room.on(LivekitClient.RoomEvent.DataReceived, (data, participant) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(new TextDecoder().decode(data));
|
||||||
|
if (msg.type === 'transcript' && msg.role === 'user') addTranscript('user', msg.text);
|
||||||
|
if (msg.type === 'transcript' && msg.role === 'bot') addTranscript('bot', msg.text);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const wsUrl = url.replace(/^http/, 'ws');
|
||||||
|
await room.connect(wsUrl, token);
|
||||||
|
await room.localParticipant.setMicrophoneEnabled(true);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setUI(null, 'Error: ' + err.message);
|
||||||
|
document.getElementById('btn').disabled = false;
|
||||||
|
room = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
122
openai/pipecat/test_pipeline.py
Normal file
122
openai/pipecat/test_pipeline.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
End-to-end pipeline test:
|
||||||
|
1. Call /connect to get a room + token
|
||||||
|
2. Join the LiveKit room as a Python client
|
||||||
|
3. Publish TTS audio (pre-generated) as microphone input
|
||||||
|
4. Capture bot's audio response and save to file
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import wave
|
||||||
|
import struct
|
||||||
|
import httpx
|
||||||
|
import numpy as np
|
||||||
|
from livekit import rtc
|
||||||
|
|
||||||
|
PIPECAT_URL = "http://localhost:8882"
|
||||||
|
TTS_URL = "http://host.docker.internal:8881"
|
||||||
|
OUTPUT_FILE = "/tmp/bot_response.wav"
|
||||||
|
SAMPLE_RATE = 48000
|
||||||
|
NUM_CHANNELS = 1
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_tts_pcm(text: str) -> bytes:
|
||||||
|
"""Get WAV audio from Silero TTS, return raw PCM int16 bytes."""
|
||||||
|
async with httpx.AsyncClient(timeout=30) as c:
|
||||||
|
r = await c.post(f"{TTS_URL}/v1/audio/speech", json={
|
||||||
|
"input": text, "voice": "onyx", "response_format": "wav"
|
||||||
|
})
|
||||||
|
r.raise_for_status()
|
||||||
|
# Skip WAV header (44 bytes) to get raw PCM
|
||||||
|
return r.content[44:]
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Step 1 — create room
|
||||||
|
print("[test] Creating room...")
|
||||||
|
async with httpx.AsyncClient() as c:
|
||||||
|
r = await c.post(f"{PIPECAT_URL}/connect")
|
||||||
|
r.raise_for_status()
|
||||||
|
creds = r.json()
|
||||||
|
print(f"[test] Room: {creds['room']} URL: {creds['url']}")
|
||||||
|
|
||||||
|
# Step 2 — generate test audio
|
||||||
|
test_phrase = "Привет! Как тебя зовут?"
|
||||||
|
print(f"[test] Generating TTS for: {test_phrase!r}")
|
||||||
|
pcm_bytes = await generate_tts_pcm(test_phrase)
|
||||||
|
print(f"[test] TTS PCM: {len(pcm_bytes)} bytes (~{len(pcm_bytes)//(SAMPLE_RATE*2):.1f}s)")
|
||||||
|
|
||||||
|
# Step 3 — join room
|
||||||
|
room = rtc.Room()
|
||||||
|
received_frames: list[bytes] = []
|
||||||
|
|
||||||
|
@room.on("track_subscribed")
|
||||||
|
def on_track(track, pub, participant):
|
||||||
|
if track.kind == rtc.TrackKind.KIND_AUDIO and participant.identity == "pipecat-bot":
|
||||||
|
print(f"[test] Subscribed to bot audio track")
|
||||||
|
audio_stream = rtc.AudioStream(track, sample_rate=SAMPLE_RATE, num_channels=NUM_CHANNELS)
|
||||||
|
asyncio.ensure_future(_collect_audio(audio_stream, received_frames))
|
||||||
|
|
||||||
|
ws_url = creds["url"].replace("https://", "wss://").replace("http://", "ws://")
|
||||||
|
# Connect internally via host.docker.internal
|
||||||
|
internal_url = "ws://host.docker.internal:7880"
|
||||||
|
print(f"[test] Connecting to LiveKit at {internal_url}...")
|
||||||
|
await room.connect(internal_url, creds["token"])
|
||||||
|
print(f"[test] Connected. Waiting for bot to join...")
|
||||||
|
|
||||||
|
# Wait for bot participant
|
||||||
|
for _ in range(20):
|
||||||
|
if any(p.identity == "pipecat-bot" for p in room.remote_participants.values()):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
print(f"[test] Participants: {[p.identity for p in room.remote_participants.values()]}")
|
||||||
|
|
||||||
|
# Step 4 — publish audio as microphone
|
||||||
|
print("[test] Publishing audio track...")
|
||||||
|
source = rtc.AudioSource(SAMPLE_RATE, NUM_CHANNELS)
|
||||||
|
local_track = rtc.LocalAudioTrack.create_audio_track("microphone", source)
|
||||||
|
opts = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE)
|
||||||
|
await room.local_participant.publish_track(local_track, opts)
|
||||||
|
|
||||||
|
# Send PCM in 20ms chunks
|
||||||
|
chunk_samples = SAMPLE_RATE * 20 // 1000 # 960 samples per chunk
|
||||||
|
chunk_bytes = chunk_samples * 2 # int16
|
||||||
|
print(f"[test] Sending {len(pcm_bytes) // chunk_bytes} audio chunks...")
|
||||||
|
for i in range(0, len(pcm_bytes), chunk_bytes):
|
||||||
|
chunk = pcm_bytes[i:i + chunk_bytes]
|
||||||
|
if len(chunk) < chunk_bytes:
|
||||||
|
chunk = chunk + b'\x00' * (chunk_bytes - len(chunk))
|
||||||
|
samples = np.frombuffer(chunk, dtype=np.int16)
|
||||||
|
frame = rtc.AudioFrame(
|
||||||
|
data=samples.tobytes(),
|
||||||
|
sample_rate=SAMPLE_RATE,
|
||||||
|
num_channels=NUM_CHANNELS,
|
||||||
|
samples_per_channel=chunk_samples,
|
||||||
|
)
|
||||||
|
await source.capture_frame(frame)
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
|
||||||
|
print("[test] Audio sent. Waiting for bot response (up to 30s)...")
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
|
||||||
|
await room.disconnect()
|
||||||
|
|
||||||
|
# Step 5 — save response
|
||||||
|
if received_frames:
|
||||||
|
total = b"".join(received_frames)
|
||||||
|
print(f"[test] Received {len(total)} bytes of bot audio ({len(total)//(SAMPLE_RATE*2):.1f}s)")
|
||||||
|
with wave.open(OUTPUT_FILE, "wb") as wf:
|
||||||
|
wf.setnchannels(NUM_CHANNELS)
|
||||||
|
wf.setsampwidth(2)
|
||||||
|
wf.setframerate(SAMPLE_RATE)
|
||||||
|
wf.writeframes(total)
|
||||||
|
print(f"[test] Saved to {OUTPUT_FILE}")
|
||||||
|
else:
|
||||||
|
print("[test] No audio received from bot!")
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_audio(stream: rtc.AudioStream, buf: list):
|
||||||
|
async for event in stream:
|
||||||
|
buf.append(bytes(event.frame.data))
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
15
openai/silero-tts/Dockerfile
Normal file
15
openai/silero-tts/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# CPU-only torch keeps image ~500MB vs ~2GB for CUDA
|
||||||
|
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
RUN pip install --no-cache-dir fastapi uvicorn scipy numpy pydub omegaconf
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server.py .
|
||||||
|
|
||||||
|
ENV TORCH_HOME=/cache/torch
|
||||||
|
|
||||||
|
EXPOSE 8881
|
||||||
|
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8881"]
|
||||||
153
openai/silero-tts/server.py
Normal file
153
openai/silero-tts/server.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import io
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import scipy.io.wavfile as wavfile
|
||||||
|
from pydub import AudioSegment
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(title="Silero TTS")
|
||||||
|
|
||||||
|
# ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
SAMPLE_RATE = 24000
|
||||||
|
MAX_CHUNK = 800 # chars per Silero call
|
||||||
|
|
||||||
|
# Model identifiers (passed as `speaker` to torch.hub.load — selects model file)
|
||||||
|
MODEL_ID = {"ru": "v3_1_ru", "en": "v3_en"}
|
||||||
|
|
||||||
|
# Silero speakers (passed to apply_tts)
|
||||||
|
RU_SPEAKERS = ["aidar", "baya", "kseniya", "xenia", "eugene"]
|
||||||
|
EN_SPEAKERS = [f"en_{i}" for i in range(10)]
|
||||||
|
|
||||||
|
# OpenAI voice → Silero speaker
|
||||||
|
VOICE_MAP = {
|
||||||
|
"ru": {"alloy": "eugene", "echo": "aidar", "fable": "baya",
|
||||||
|
"onyx": "eugene", "nova": "kseniya", "shimmer": "xenia"},
|
||||||
|
"en": {"alloy": "en_3", "echo": "en_1", "fable": "en_2",
|
||||||
|
"onyx": "en_3", "nova": "en_4", "shimmer": "en_5"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Model cache ───────────────────────────────────────────────────────────────
|
||||||
|
_models: dict[str, object] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_model(language: str):
|
||||||
|
if language not in _models:
|
||||||
|
logger.info(f"Loading Silero model {MODEL_ID[language]} lang={language} device={DEVICE}")
|
||||||
|
model, _ = torch.hub.load(
|
||||||
|
repo_or_dir="snakers4/silero-models",
|
||||||
|
model="silero_tts",
|
||||||
|
language=language,
|
||||||
|
speaker=MODEL_ID[language],
|
||||||
|
trust_repo=True,
|
||||||
|
)
|
||||||
|
model.to(DEVICE)
|
||||||
|
_models[language] = model
|
||||||
|
logger.info(f"Model ready: lang={language}")
|
||||||
|
return _models[language]
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def preload():
|
||||||
|
"""Preload both language models to avoid cold-start on first request."""
|
||||||
|
for lang in ("ru", "en"):
|
||||||
|
try:
|
||||||
|
_get_model(lang)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Preload failed for lang={lang}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
def _is_russian(text: str) -> bool:
|
||||||
|
return bool(re.search(r"[а-яёА-ЯЁ]", text))
|
||||||
|
|
||||||
|
|
||||||
|
def _split_sentences(text: str) -> list[str]:
|
||||||
|
"""Split on sentence boundaries, keeping chunks under MAX_CHUNK chars."""
|
||||||
|
if len(text) <= MAX_CHUNK:
|
||||||
|
return [text]
|
||||||
|
parts = re.split(r"(?<=[.!?;])\s+", text.strip())
|
||||||
|
chunks, cur = [], ""
|
||||||
|
for part in parts:
|
||||||
|
if len(cur) + len(part) + 1 <= MAX_CHUNK:
|
||||||
|
cur = f"{cur} {part}" if cur else part
|
||||||
|
else:
|
||||||
|
if cur:
|
||||||
|
chunks.append(cur)
|
||||||
|
# If single part is too long, split mid-word as last resort
|
||||||
|
cur = part[:MAX_CHUNK] if len(part) > MAX_CHUNK else part
|
||||||
|
if cur:
|
||||||
|
chunks.append(cur)
|
||||||
|
return chunks or [text[:MAX_CHUNK]]
|
||||||
|
|
||||||
|
|
||||||
|
def _to_bytes(audio: torch.Tensor, fmt: str) -> bytes:
|
||||||
|
pcm = (audio.cpu().numpy() * 32767).astype(np.int16)
|
||||||
|
if fmt == "pcm":
|
||||||
|
return pcm.tobytes()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wavfile.write(buf, SAMPLE_RATE, pcm)
|
||||||
|
if fmt == "wav":
|
||||||
|
return buf.getvalue()
|
||||||
|
seg = AudioSegment.from_wav(io.BytesIO(buf.getvalue()))
|
||||||
|
out = io.BytesIO()
|
||||||
|
seg.export(out, format="mp3")
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# ── API ───────────────────────────────────────────────────────────────────────
|
||||||
|
class SpeechRequest(BaseModel):
|
||||||
|
model: str = "silero"
|
||||||
|
input: str
|
||||||
|
voice: str = "alloy"
|
||||||
|
response_format: str = "mp3"
|
||||||
|
speed: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "device": DEVICE}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/models")
|
||||||
|
async def list_models():
|
||||||
|
return {
|
||||||
|
"object": "list",
|
||||||
|
"data": [{"id": "silero", "object": "model", "owned_by": "silero"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/audio/speech")
|
||||||
|
async def speech(req: SpeechRequest):
|
||||||
|
text = req.input.strip()
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(status_code=400, detail="input is empty")
|
||||||
|
|
||||||
|
language = "ru" if _is_russian(text) else "en"
|
||||||
|
vm = VOICE_MAP[language]
|
||||||
|
speaker = vm.get(req.voice, vm["alloy"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
model = _get_model(language)
|
||||||
|
chunks = _split_sentences(text)
|
||||||
|
parts = [
|
||||||
|
model.apply_tts(text=chunk, speaker=speaker, sample_rate=SAMPLE_RATE)
|
||||||
|
for chunk in chunks
|
||||||
|
]
|
||||||
|
audio = parts[0] if len(parts) == 1 else torch.cat(parts)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TTS error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
fmt = req.response_format.lower()
|
||||||
|
audio_bytes = _to_bytes(audio, fmt)
|
||||||
|
media_types = {"wav": "audio/wav", "pcm": "audio/pcm", "mp3": "audio/mpeg"}
|
||||||
|
media_type = media_types.get(fmt, "audio/mpeg")
|
||||||
|
return Response(content=audio_bytes, media_type=media_type)
|
||||||
Reference in New Issue
Block a user