feat: VoiceCoach v2.1 - GPU local, Ollama LLM, settings panel, token server separation
- pipeline.py: switched LLM from OpenClaw/Claude to local Ollama, added settings loading, sentence aggregator, async SpeechBrain, tuned VAD - main.py: simplified (no more token server thread, just pipeline) - token_server.py: NEW - standalone token + settings API server - docker-compose.yml: all services on GPU machine, added token-server service, CUDA speaches, Ollama LLM - web-client/index.html: added settings panel with sliders - web-client/app.js: settings management, localStorage persistence, push to server - web-client/style.css: settings panel styles - web-client/nginx.conf: proxy to token-server instead of pipecat-agent - patch_livekit.py: updated with proper indentation-safe patching
This commit is contained in:
+70
-33
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
# LiveKit WebRTC Server (unchanged)
|
# LiveKit WebRTC Server
|
||||||
livekit-server:
|
livekit-server:
|
||||||
image: livekit/livekit-server:latest
|
image: livekit/livekit-server:latest
|
||||||
container_name: voicecoach-livekit
|
container_name: voicecoach-livekit
|
||||||
@@ -27,24 +27,27 @@ services:
|
|||||||
max-size: "50m"
|
max-size: "50m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# Speaches: self-hosted Whisper STT + Kokoro TTS (v2 - replaces Piper + in-process Whisper)
|
# Speaches: GPU-accelerated Whisper STT + Kokoro TTS
|
||||||
speaches:
|
speaches:
|
||||||
image: ghcr.io/speaches-ai/speaches:latest-cpu
|
image: ghcr.io/speaches-ai/speaches:latest-cuda
|
||||||
container_name: voicecoach-speaches
|
container_name: voicecoach-speaches-gpu
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- WHISPER__MODEL=Systran/faster-whisper-large-v3-turbo
|
- WHISPER__INFERENCE_DEVICE=cuda
|
||||||
- WHISPER__INFERENCE_DEVICE=cpu
|
- WHISPER__COMPUTE_TYPE=float16
|
||||||
- WHISPER__COMPUTE_TYPE=int8
|
- WHISPER__TTL=-1
|
||||||
- TTS__MODEL=kokoro
|
|
||||||
- TTS__DEFAULT_VOICE=af_nova
|
|
||||||
- TTS__LANGUAGE=en-us
|
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/docker-data/configs/voicecoach/speaches-models:/root/.cache
|
- speaches-models:/home/ubuntu/.cache
|
||||||
networks:
|
networks:
|
||||||
- voicecoach
|
- voicecoach
|
||||||
mem_limit: 3G
|
deploy:
|
||||||
memswap_limit: 3G
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
mem_limit: 4G
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=false"
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -52,29 +55,28 @@ services:
|
|||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 120s
|
start_period: 180s
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
max-size: "50m"
|
max-size: "50m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# SpeechBrain Emotion Analysis (unchanged, internal only)
|
# SpeechBrain Emotion Analysis
|
||||||
speechbrain-service:
|
speechbrain-service:
|
||||||
|
image: voicecoach-speechbrain:latest
|
||||||
build:
|
build:
|
||||||
context: ./speechbrain-service
|
context: ./speechbrain-service
|
||||||
image: voicecoach-speechbrain:latest
|
|
||||||
container_name: voicecoach-speechbrain
|
container_name: voicecoach-speechbrain
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/docker-data/configs/voicecoach/speechbrain-models:/app/models
|
- speechbrain-models:/app/models
|
||||||
- /mnt/docker-data/configs/voicecoach/clips:/data/clips
|
- voicecoach-clips:/data/clips
|
||||||
environment:
|
environment:
|
||||||
- MODEL_DIR=/app/models
|
- MODEL_DIR=/app/models
|
||||||
networks:
|
networks:
|
||||||
- voicecoach
|
- voicecoach
|
||||||
mem_limit: 3G
|
mem_limit: 3G
|
||||||
memswap_limit: 3G
|
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=false"
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
logging:
|
logging:
|
||||||
@@ -83,7 +85,7 @@ services:
|
|||||||
max-size: "50m"
|
max-size: "50m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# OpenClaw VoiceCoach Agent (v2 - LLM gateway with memory/skills)
|
# OpenClaw VoiceCoach Agent (LLM gateway -> Claude)
|
||||||
voicecoach-openclaw:
|
voicecoach-openclaw:
|
||||||
image: margaret-openclaw:latest
|
image: margaret-openclaw:latest
|
||||||
container_name: voicecoach-openclaw
|
container_name: voicecoach-openclaw
|
||||||
@@ -97,12 +99,11 @@ services:
|
|||||||
- OPENCLAW_STATE_DIR=/home/glyptic/.voicecoach
|
- OPENCLAW_STATE_DIR=/home/glyptic/.voicecoach
|
||||||
- OPENCLAW_CONFIG_PATH=/home/glyptic/.voicecoach/openclaw.json
|
- OPENCLAW_CONFIG_PATH=/home/glyptic/.voicecoach/openclaw.json
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/infrastructure/voicecoach:/home/glyptic/.voicecoach
|
- ./voicecoach:/home/glyptic/.voicecoach
|
||||||
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
|
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
|
||||||
networks:
|
networks:
|
||||||
- voicecoach
|
- voicecoach
|
||||||
mem_limit: 2G
|
mem_limit: 2G
|
||||||
memswap_limit: 2G
|
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=false"
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
logging:
|
logging:
|
||||||
@@ -111,11 +112,11 @@ services:
|
|||||||
max-size: "50m"
|
max-size: "50m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# Pipecat Voice Pipeline Agent (v2 - uses Speaches + OpenClaw gateway)
|
# Pipecat Voice Pipeline Agent
|
||||||
pipecat-agent:
|
pipecat-agent:
|
||||||
|
image: voicecoach-pipecat:latest
|
||||||
build:
|
build:
|
||||||
context: ./pipecat-agent
|
context: ./pipecat-agent
|
||||||
image: voicecoach-pipecat:latest
|
|
||||||
container_name: voicecoach-pipecat
|
container_name: voicecoach-pipecat
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
@@ -125,22 +126,22 @@ services:
|
|||||||
- LIVEKIT_PUBLIC_URL=wss://voice.hobbs.farm
|
- LIVEKIT_PUBLIC_URL=wss://voice.hobbs.farm
|
||||||
- SPEACHES_URL=http://speaches:8000
|
- SPEACHES_URL=http://speaches:8000
|
||||||
- SPEECHBRAIN_URL=http://speechbrain-service:8001
|
- SPEECHBRAIN_URL=http://speechbrain-service:8001
|
||||||
- OPENCLAW_URL=http://voicecoach-openclaw:3100
|
- LLM_BASE_URL=http://host.docker.internal:11434/v1
|
||||||
- OPENCLAW_AGENT_ID=voicecoach
|
- LLM_MODEL=llama3.1:8b
|
||||||
- WHISPER_MODEL=Systran/faster-whisper-large-v3-turbo
|
- WHISPER_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
|
||||||
- KOKORO_VOICE=af_nova
|
- KOKORO_VOICE=af_nova
|
||||||
- CLIPS_DIR=/data/clips/pending
|
- CLIPS_DIR=/data/clips/pending
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/docker-data/configs/voicecoach/clips:/data/clips
|
- voicecoach-clips:/data/clips
|
||||||
depends_on:
|
depends_on:
|
||||||
- livekit-server
|
- livekit-server
|
||||||
- speaches
|
- speaches
|
||||||
- speechbrain-service
|
- speechbrain-service
|
||||||
- voicecoach-openclaw
|
|
||||||
networks:
|
networks:
|
||||||
- voicecoach
|
- voicecoach
|
||||||
mem_limit: 1G
|
mem_limit: 1G
|
||||||
memswap_limit: 1G
|
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=false"
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
logging:
|
logging:
|
||||||
@@ -149,17 +150,47 @@ services:
|
|||||||
max-size: "50m"
|
max-size: "50m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# Web Client (unchanged)
|
# Token Server + Settings API (separate from pipeline so it never goes down)
|
||||||
|
token-server:
|
||||||
|
image: voicecoach-pipecat:latest
|
||||||
|
container_name: voicecoach-token-server
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- LIVEKIT_PUBLIC_URL=wss://voice.hobbs.farm
|
||||||
|
- SETTINGS_PATH=/data/settings.json
|
||||||
|
command: ["python", "token_server.py"]
|
||||||
|
volumes:
|
||||||
|
- voicecoach-settings:/data
|
||||||
|
networks:
|
||||||
|
- voicecoach
|
||||||
|
mem_limit: 256M
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8080/health').raise_for_status()"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# Web Client
|
||||||
voicecoach-web:
|
voicecoach-web:
|
||||||
|
image: voicecoach-web:latest
|
||||||
build:
|
build:
|
||||||
context: ./web-client
|
context: ./web-client
|
||||||
image: voicecoach-web:latest
|
|
||||||
container_name: voicecoach-web
|
container_name: voicecoach-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8085:80"
|
- "8085:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- pipecat-agent
|
- token-server
|
||||||
networks:
|
networks:
|
||||||
- voicecoach
|
- voicecoach
|
||||||
mem_limit: 128M
|
mem_limit: 128M
|
||||||
@@ -174,3 +205,9 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
voicecoach:
|
voicecoach:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
speaches-models:
|
||||||
|
speechbrain-models:
|
||||||
|
voicecoach-clips:
|
||||||
|
voicecoach-settings:
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ RUN apt-get update && apt-get install -y libsndfile1 ffmpeg && \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Patch pipecat LiveKit transport bugs
|
||||||
|
COPY patch_livekit.py /tmp/patch_livekit.py
|
||||||
|
RUN python3 /tmp/patch_livekit.py
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s \
|
||||||
|
|||||||
+8
-56
@@ -1,18 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
VoiceCoach pipeline entry point -- v2.
|
VoiceCoach pipeline entry point.
|
||||||
Runs token server + pipeline with unique identity per session.
|
Token server runs as a separate container — this file only runs the pipeline.
|
||||||
|
Pipeline exits on session end; Docker restarts the container for a fresh state.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from datetime import timedelta
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
import uvicorn
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from livekit import api as livekit_api
|
from livekit import api as livekit_api
|
||||||
|
|
||||||
from pipeline import run_voice_coach_pipeline
|
from pipeline import run_voice_coach_pipeline
|
||||||
@@ -23,68 +20,23 @@ logger = structlog.get_logger()
|
|||||||
LIVEKIT_API_KEY = os.environ["LIVEKIT_API_KEY"]
|
LIVEKIT_API_KEY = os.environ["LIVEKIT_API_KEY"]
|
||||||
LIVEKIT_API_SECRET = os.environ["LIVEKIT_API_SECRET"]
|
LIVEKIT_API_SECRET = os.environ["LIVEKIT_API_SECRET"]
|
||||||
LIVEKIT_WS_URL = os.getenv("LIVEKIT_URL", "ws://livekit-server:7880")
|
LIVEKIT_WS_URL = os.getenv("LIVEKIT_URL", "ws://livekit-server:7880")
|
||||||
LIVEKIT_PUBLIC_URL = os.getenv("LIVEKIT_PUBLIC_URL", "wss://voice.hobbs.farm")
|
|
||||||
OPENCLAW_TOKEN = os.getenv("OPENCLAW_TOKEN", "")
|
|
||||||
|
|
||||||
# --- Token HTTP server ---
|
|
||||||
|
|
||||||
token_app = FastAPI(title="VoiceCoach Token Service")
|
|
||||||
|
|
||||||
|
|
||||||
class TokenRequest(BaseModel):
|
|
||||||
identity: str = "jeremy"
|
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
|
||||||
token: str
|
|
||||||
url: str
|
|
||||||
|
|
||||||
|
|
||||||
@token_app.get("/health")
|
|
||||||
async def health():
|
|
||||||
return {"status": "ok", "version": "2.0"}
|
|
||||||
|
|
||||||
|
|
||||||
@token_app.post("/token")
|
|
||||||
async def get_token(req: TokenRequest):
|
|
||||||
token = livekit_api.AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
|
|
||||||
token.with_grants(livekit_api.VideoGrants(
|
|
||||||
room_join=True, room="voicecoach",
|
|
||||||
can_publish=True, can_subscribe=True,
|
|
||||||
))
|
|
||||||
token.identity = req.identity
|
|
||||||
return TokenResponse(token=token.to_jwt(), url=LIVEKIT_PUBLIC_URL)
|
|
||||||
|
|
||||||
|
|
||||||
def run_token_server():
|
|
||||||
uvicorn.run(token_app, host="0.0.0.0", port=8080, log_level="info")
|
|
||||||
|
|
||||||
|
|
||||||
# --- Main ---
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
server_thread = threading.Thread(target=run_token_server, daemon=True)
|
|
||||||
server_thread.start()
|
|
||||||
logger.info("Token server started on :8080")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
agent_identity = f"voicecoach-agent-{uuid.uuid4().hex[:8]}"
|
agent_identity = f"voicecoach-agent-{uuid.uuid4().hex[:8]}"
|
||||||
token = livekit_api.AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
|
token = livekit_api.AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
|
||||||
token.with_grants(livekit_api.VideoGrants(room_join=True, room="voicecoach"))
|
token.with_grants(livekit_api.VideoGrants(room_join=True, room="voicecoach"))
|
||||||
token.identity = agent_identity
|
token.identity = agent_identity
|
||||||
|
token.ttl = timedelta(hours=24)
|
||||||
livekit_token = token.to_jwt()
|
livekit_token = token.to_jwt()
|
||||||
|
|
||||||
logger.info("Starting VoiceCoach v2 pipeline", identity=agent_identity)
|
logger.info("Starting VoiceCoach pipeline", identity=agent_identity)
|
||||||
try:
|
|
||||||
await run_voice_coach_pipeline(
|
await run_voice_coach_pipeline(
|
||||||
livekit_url=LIVEKIT_WS_URL,
|
livekit_url=LIVEKIT_WS_URL,
|
||||||
livekit_token=livekit_token,
|
livekit_token=livekit_token,
|
||||||
openclaw_token=OPENCLAW_TOKEN,
|
openclaw_token="",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
logger.info("Pipeline exited")
|
||||||
logger.error("Pipeline error, restarting in 35s", error=str(e))
|
|
||||||
|
|
||||||
await asyncio.sleep(35)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Patch pipecat LiveKit transport bugs at build time."""
|
||||||
|
import pipecat.transports.services.livekit as lk_mod
|
||||||
|
|
||||||
|
lk_path = lk_mod.__file__
|
||||||
|
print(f"Patching {lk_path}")
|
||||||
|
content = open(lk_path).read()
|
||||||
|
|
||||||
|
# FIX 1: Remove direct push_frame(pipecat_audio_frame) that bypasses VAD
|
||||||
|
old1 = "await self.push_frame(pipecat_audio_frame)"
|
||||||
|
if old1 in content:
|
||||||
|
content = content.replace(old1, "pass # PATCHED: removed VAD bypass")
|
||||||
|
print("FIX 1: Removed push_frame(pipecat_audio_frame) VAD bypass")
|
||||||
|
|
||||||
|
# FIX 2: Wrap the on_connected call in connect() method with try/except
|
||||||
|
# The line is: " await self._callbacks.on_connected()" (12 spaces)
|
||||||
|
# We replace it with a try/except at the same indentation level
|
||||||
|
old2 = " await self._callbacks.on_connected()\n"
|
||||||
|
new2 = (
|
||||||
|
" try:\n"
|
||||||
|
" await self._callbacks.on_connected()\n"
|
||||||
|
" except Exception:\n"
|
||||||
|
" pass # PATCHED: suppress duplicate callback\n"
|
||||||
|
)
|
||||||
|
if old2 in content:
|
||||||
|
content = content.replace(old2, new2)
|
||||||
|
print("FIX 2: Wrapped on_connected with try/except")
|
||||||
|
|
||||||
|
open(lk_path, "w").write(content)
|
||||||
|
|
||||||
|
# Verify syntax
|
||||||
|
import py_compile
|
||||||
|
try:
|
||||||
|
py_compile.compile(lk_path, doraise=True)
|
||||||
|
print("Syntax check: PASS")
|
||||||
|
except py_compile.PyCompileError as e:
|
||||||
|
print(f"Syntax check: FAIL - {e}")
|
||||||
|
# Show the problematic area
|
||||||
|
lines = open(lk_path).readlines()
|
||||||
|
for i in range(130, min(145, len(lines))):
|
||||||
|
print(f" {i+1}: {lines[i]}", end="")
|
||||||
|
|
||||||
|
# FIX 3: Guard set_result on already-resolved Future in livekit SDK
|
||||||
|
try:
|
||||||
|
import livekit.rtc.room as room_mod
|
||||||
|
room_path = room_mod.__file__
|
||||||
|
room_content = open(room_path).read()
|
||||||
|
if ("self._first_sid_future.set_result" in room_content and
|
||||||
|
".done()" not in room_content.split("_first_sid_future")[1][:200]):
|
||||||
|
room_content = room_content.replace(
|
||||||
|
"self._first_sid_future.set_result",
|
||||||
|
"not self._first_sid_future.done() and self._first_sid_future.set_result"
|
||||||
|
)
|
||||||
|
open(room_path, "w").write(room_content)
|
||||||
|
print(f"FIX 3: Patched {room_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FIX 3: Skipped: {e}")
|
||||||
|
|
||||||
|
print("All patches complete")
|
||||||
+291
-111
@@ -1,32 +1,39 @@
|
|||||||
"""
|
"""
|
||||||
Pipecat pipeline -- v2 fully self-hosted.
|
Pipecat pipeline -- v2.1 optimized for low latency.
|
||||||
|
|
||||||
STT: Speaches (faster-whisper) via OpenAI-compatible /v1/audio/transcriptions
|
STT: Speaches (faster-whisper) via /v1/audio/transcriptions (utterance-level)
|
||||||
LLM: OpenClaw VoiceCoach agent via OpenAI-compatible gateway
|
LLM: OpenClaw VoiceCoach agent via OpenAI-compatible gateway (streaming)
|
||||||
TTS: Speaches (Kokoro 82M) via OpenAI-compatible /v1/audio/speech
|
TTS: Speaches (Kokoro 82M) via OpenAI-compatible /v1/audio/speech (sentence-level)
|
||||||
|
|
||||||
|
Optimizations over v2.0:
|
||||||
|
- Sentence aggregator between LLM and TTS (stream first sentence while LLM generates rest)
|
||||||
|
- SpeechBrain runs async in background (non-blocking, one-turn-behind vocal state)
|
||||||
|
- VAD stop_secs reduced from 0.8 to 0.4
|
||||||
|
- Whisper model default changed to large-v3-turbo (faster per-token on CPU/GPU)
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
import wave
|
import wave
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import AsyncGenerator, Optional
|
from typing import Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
AudioRawFrame, EndFrame, ErrorFrame, Frame, TextFrame, TranscriptionFrame,
|
AudioRawFrame, EndFrame, TextFrame, TranscriptionFrame,
|
||||||
UserStartedSpeakingFrame, UserStoppedSpeakingFrame,
|
TTSSpeakFrame, UserStartedSpeakingFrame, UserStoppedSpeakingFrame,
|
||||||
|
ErrorFrame, TTSAudioRawFrame, TTSStartedFrame, TTSStoppedFrame,
|
||||||
)
|
)
|
||||||
from pipecat.pipeline.pipeline import Pipeline
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
from pipecat.pipeline.runner import PipelineRunner
|
from pipecat.pipeline.runner import PipelineRunner
|
||||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||||
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
||||||
from pipecat.processors.frame_processor import FrameProcessor, FrameDirection
|
from pipecat.processors.frame_processor import FrameProcessor, FrameDirection
|
||||||
from pipecat.services.ai_services import STTService
|
|
||||||
from pipecat.services.openai import OpenAILLMService, OpenAITTSService
|
from pipecat.services.openai import OpenAILLMService, OpenAITTSService
|
||||||
from pipecat.transports.services.livekit import LiveKitTransport, LiveKitParams
|
from pipecat.transports.services.livekit import LiveKitTransport, LiveKitParams
|
||||||
|
|
||||||
@@ -41,78 +48,130 @@ from prompt_builder import build_user_message, VocalState
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SpeachesTTSService — Kokoro TTS via Speaches OpenAI-compatible API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SpeachesTTSService(OpenAITTSService):
|
||||||
|
"""OpenAITTSService subclass that properly uses base_url and bypasses
|
||||||
|
the hardcoded VALID_VOICES dict for Kokoro voice IDs."""
|
||||||
|
|
||||||
|
def __init__(self, *, api_key=None, base_url=None, voice="alloy",
|
||||||
|
model="tts-1", sample_rate=24000, **kwargs):
|
||||||
|
from pipecat.services.ai_services import TTSService
|
||||||
|
TTSService.__init__(self, sample_rate=sample_rate, **kwargs)
|
||||||
|
self._settings = {"sample_rate": sample_rate}
|
||||||
|
self.set_model_name(model)
|
||||||
|
self.set_voice(voice)
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
self._client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||||
|
|
||||||
|
async def run_tts(self, text: str):
|
||||||
|
logger.debug("TTS: [%s]", text[:80])
|
||||||
|
try:
|
||||||
|
await self.start_ttfb_metrics()
|
||||||
|
async with self._client.audio.speech.with_streaming_response.create(
|
||||||
|
input=text or " ",
|
||||||
|
model=self.model_name,
|
||||||
|
voice=self._voice_id,
|
||||||
|
response_format="pcm",
|
||||||
|
) as r:
|
||||||
|
if r.status_code != 200:
|
||||||
|
error = await r.text()
|
||||||
|
logger.error("TTS error: %s %s", r.status_code, error)
|
||||||
|
yield ErrorFrame(f"TTS error: {r.status_code}")
|
||||||
|
return
|
||||||
|
await self.start_tts_usage_metrics(text)
|
||||||
|
yield TTSStartedFrame()
|
||||||
|
async for chunk in r.iter_bytes(8192):
|
||||||
|
if chunk:
|
||||||
|
await self.stop_ttfb_metrics()
|
||||||
|
yield TTSAudioRawFrame(chunk, 24000, 1)
|
||||||
|
yield TTSStoppedFrame()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("TTS error: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sentence Aggregator — buffers LLM streaming tokens into sentences for TTS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SENTENCE_END = re.compile(r'[.!?]\s')
|
||||||
|
|
||||||
|
|
||||||
|
class SentenceAggregator(FrameProcessor):
|
||||||
|
"""Buffers streaming TextFrames from LLM and emits one TextFrame per
|
||||||
|
complete sentence. This lets TTS start on the first sentence while
|
||||||
|
the LLM is still generating the rest."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._buffer = ""
|
||||||
|
|
||||||
|
async def process_frame(self, frame, direction: FrameDirection):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
|
||||||
|
if isinstance(frame, TextFrame):
|
||||||
|
self._buffer += frame.text
|
||||||
|
while True:
|
||||||
|
match = SENTENCE_END.search(self._buffer)
|
||||||
|
if match:
|
||||||
|
sentence = self._buffer[:match.end()].strip()
|
||||||
|
self._buffer = self._buffer[match.end():]
|
||||||
|
if sentence:
|
||||||
|
logger.info("Sentence -> TTS: %s", sentence[:80])
|
||||||
|
await self.push_frame(TextFrame(text=sentence), direction)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
elif isinstance(frame, EndFrame):
|
||||||
|
if self._buffer.strip():
|
||||||
|
logger.info("Flush -> TTS: %s", self._buffer.strip()[:80])
|
||||||
|
await self.push_frame(TextFrame(text=self._buffer.strip()), direction)
|
||||||
|
self._buffer = ""
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
else:
|
||||||
|
# Flush on any non-text frame (LLM response end, etc.)
|
||||||
|
if self._buffer.strip() and not isinstance(frame, AudioRawFrame):
|
||||||
|
logger.info("Flush -> TTS: %s", self._buffer.strip()[:80])
|
||||||
|
await self.push_frame(TextFrame(text=self._buffer.strip()), direction)
|
||||||
|
self._buffer = ""
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
SPEACHES_URL = os.getenv("SPEACHES_URL", "http://speaches:8000")
|
SPEACHES_URL = os.getenv("SPEACHES_URL", "http://speaches:8000")
|
||||||
OPENCLAW_URL = os.getenv("OPENCLAW_URL", "http://voicecoach-openclaw:3100")
|
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "http://host.docker.internal:11434/v1")
|
||||||
|
LLM_MODEL = os.getenv("LLM_MODEL", "llama3.1:8b")
|
||||||
SPEECHBRAIN_URL = os.getenv("SPEECHBRAIN_URL", "http://speechbrain-service:8001")
|
SPEECHBRAIN_URL = os.getenv("SPEECHBRAIN_URL", "http://speechbrain-service:8001")
|
||||||
|
|
||||||
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-large-v3-turbo")
|
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "deepdml/faster-whisper-large-v3-turbo-ct2")
|
||||||
|
KOKORO_MODEL = os.getenv("KOKORO_MODEL", "speaches-ai/Kokoro-82M-v1.0-ONNX-fp16")
|
||||||
KOKORO_VOICE = os.getenv("KOKORO_VOICE", "af_nova")
|
KOKORO_VOICE = os.getenv("KOKORO_VOICE", "af_nova")
|
||||||
OPENCLAW_AGENT_ID = os.getenv("OPENCLAW_AGENT_ID", "voicecoach")
|
|
||||||
|
|
||||||
CLIPS_DIR = Path(os.getenv("CLIPS_DIR", "/data/clips/pending"))
|
CLIPS_DIR = Path(os.getenv("CLIPS_DIR", "/data/clips/pending"))
|
||||||
UNCERTAINTY_THRESHOLD = 0.60
|
UNCERTAINTY_THRESHOLD = 0.60
|
||||||
MIN_CLIP_DURATION = 1.0
|
MIN_CLIP_DURATION = 1.0
|
||||||
|
|
||||||
|
|
||||||
class SpeachesSTTService(STTService):
|
# ---------------------------------------------------------------------------
|
||||||
"""STT via Speaches OpenAI-compatible /v1/audio/transcriptions endpoint.
|
# SpeachesSTTProcessor — buffers full utterance, sends once to Speaches
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pipecat-ai 0.0.48 does not include OpenAISTTService; this thin wrapper
|
class SpeachesSTTProcessor(FrameProcessor):
|
||||||
calls the Speaches HTTP API directly using the STTService base class.
|
"""Buffers audio during speech, sends complete utterance to Speaches STT."""
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, base_url: str, model: str, language: str = "en", **kwargs):
|
def __init__(self, base_url: str, model: str, language: str = "en", **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._base_url = base_url.rstrip("/")
|
self._base_url = base_url.rstrip("/")
|
||||||
self._model = model
|
self._model = model
|
||||||
self._language = language
|
self._language = language
|
||||||
self._http = httpx.AsyncClient(timeout=30.0)
|
self._http = httpx.AsyncClient(timeout=60.0)
|
||||||
|
|
||||||
async def set_model(self, model: str):
|
|
||||||
self._model = model
|
|
||||||
|
|
||||||
async def set_language(self, language):
|
|
||||||
self._language = str(language)
|
|
||||||
|
|
||||||
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
|
|
||||||
if not audio:
|
|
||||||
return
|
|
||||||
# Wrap raw PCM in a WAV container for the API
|
|
||||||
buf = io.BytesIO()
|
|
||||||
with wave.open(buf, "wb") as wf:
|
|
||||||
wf.setnchannels(1)
|
|
||||||
wf.setsampwidth(2)
|
|
||||||
wf.setframerate(16000)
|
|
||||||
wf.writeframes(audio)
|
|
||||||
wav_bytes = buf.getvalue()
|
|
||||||
|
|
||||||
try:
|
|
||||||
r = await self._http.post(
|
|
||||||
f"{self._base_url}/v1/audio/transcriptions",
|
|
||||||
files={"file": ("audio.wav", wav_bytes, "audio/wav")},
|
|
||||||
data={"model": self._model, "language": self._language, "response_format": "json"},
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
text = r.json().get("text", "").strip()
|
|
||||||
if text:
|
|
||||||
logger.info("STT transcription: %s", text[:100])
|
|
||||||
yield TranscriptionFrame(text=text, user_id="user", timestamp=datetime.utcnow().isoformat())
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Speaches STT error: %s", e)
|
|
||||||
yield ErrorFrame(error=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
class VocalStateInjector(FrameProcessor):
|
|
||||||
"""Buffers audio per utterance, sends to SpeechBrain for analysis,
|
|
||||||
enriches TranscriptionFrame with [VOCAL_STATE] metadata,
|
|
||||||
and saves clips for active labeling."""
|
|
||||||
|
|
||||||
def __init__(self, session_id: str, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._session_id = session_id
|
|
||||||
self._audio_buffer: list[bytes] = []
|
self._audio_buffer: list[bytes] = []
|
||||||
self._is_speaking = False
|
self._is_speaking = False
|
||||||
self._http = httpx.AsyncClient(timeout=3.0)
|
|
||||||
|
|
||||||
async def process_frame(self, frame, direction: FrameDirection):
|
async def process_frame(self, frame, direction: FrameDirection):
|
||||||
await super().process_frame(frame, direction)
|
await super().process_frame(frame, direction)
|
||||||
@@ -120,34 +179,113 @@ class VocalStateInjector(FrameProcessor):
|
|||||||
if isinstance(frame, UserStartedSpeakingFrame):
|
if isinstance(frame, UserStartedSpeakingFrame):
|
||||||
self._is_speaking = True
|
self._is_speaking = True
|
||||||
self._audio_buffer = []
|
self._audio_buffer = []
|
||||||
logger.info("VAD: user started speaking")
|
logger.info("STT: user started speaking")
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
elif isinstance(frame, AudioRawFrame):
|
||||||
|
if self._is_speaking:
|
||||||
|
self._audio_buffer.append(frame.audio)
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
elif isinstance(frame, UserStoppedSpeakingFrame):
|
||||||
|
self._is_speaking = False
|
||||||
|
logger.info("STT: user stopped, %d chunks", len(self._audio_buffer))
|
||||||
|
if self._audio_buffer:
|
||||||
|
audio_bytes = b"".join(self._audio_buffer)
|
||||||
|
self._audio_buffer = []
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with wave.open(buf, "wb") as wf:
|
||||||
|
wf.setnchannels(1)
|
||||||
|
wf.setsampwidth(2)
|
||||||
|
wf.setframerate(16000)
|
||||||
|
wf.writeframes(audio_bytes)
|
||||||
|
wav_bytes = buf.getvalue()
|
||||||
|
try:
|
||||||
|
logger.info("STT: sending %d bytes...", len(wav_bytes))
|
||||||
|
r = await self._http.post(
|
||||||
|
f"{self._base_url}/v1/audio/transcriptions",
|
||||||
|
files={"file": ("utterance.wav", wav_bytes, "audio/wav")},
|
||||||
|
data={
|
||||||
|
"model": self._model,
|
||||||
|
"language": self._language,
|
||||||
|
"response_format": "json",
|
||||||
|
"vad_filter": "false",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
text = r.json().get("text", "").strip()
|
||||||
|
if text:
|
||||||
|
logger.info("STT: %r", text)
|
||||||
|
await self.push_frame(
|
||||||
|
TranscriptionFrame(text=text, user_id="jeremy",
|
||||||
|
timestamp=datetime.utcnow().isoformat()),
|
||||||
|
direction,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("STT: empty transcription")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("STT error: %s", e)
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
else:
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# VocalStateInjector — async SpeechBrain (non-blocking, one-turn-behind)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class VocalStateInjector(FrameProcessor):
|
||||||
|
"""Enriches TranscriptionFrame with [VOCAL_STATE] from SpeechBrain.
|
||||||
|
Analysis runs in background — uses previous turn's result to avoid
|
||||||
|
blocking the critical path."""
|
||||||
|
|
||||||
|
def __init__(self, session_id: str, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._session_id = session_id
|
||||||
|
self._audio_buffer: list[bytes] = []
|
||||||
|
self._is_speaking = False
|
||||||
|
self._http = httpx.AsyncClient(timeout=3.0)
|
||||||
|
self._last_vocal_state: Optional[VocalState] = None
|
||||||
|
|
||||||
|
async def process_frame(self, frame, direction: FrameDirection):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
|
||||||
|
if isinstance(frame, UserStartedSpeakingFrame):
|
||||||
|
self._is_speaking = True
|
||||||
|
self._audio_buffer = []
|
||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
elif isinstance(frame, AudioRawFrame) and self._is_speaking:
|
elif isinstance(frame, AudioRawFrame) and self._is_speaking:
|
||||||
self._audio_buffer.append(frame.audio)
|
self._audio_buffer.append(frame.audio)
|
||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
elif isinstance(frame, UserStoppedSpeakingFrame):
|
elif isinstance(frame, UserStoppedSpeakingFrame):
|
||||||
self._is_speaking = False
|
self._is_speaking = False
|
||||||
logger.info("VAD: user stopped speaking, buffer: %d chunks", len(self._audio_buffer))
|
|
||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
elif isinstance(frame, TranscriptionFrame):
|
elif isinstance(frame, TranscriptionFrame):
|
||||||
wav_bytes = self._flush_audio_to_wav()
|
# Use PREVIOUS turn's vocal state (non-blocking)
|
||||||
self._audio_buffer = []
|
vocal_state = self._last_vocal_state
|
||||||
|
|
||||||
vocal_state = None
|
|
||||||
if wav_bytes:
|
|
||||||
vocal_state, analysis = await self._analyze(wav_bytes)
|
|
||||||
if analysis and self._clip_worth_saving(analysis, wav_bytes):
|
|
||||||
await self._save_clip(wav_bytes, analysis)
|
|
||||||
|
|
||||||
enriched_text = build_user_message(frame.text, vocal_state)
|
enriched_text = build_user_message(frame.text, vocal_state)
|
||||||
logger.info("Enriched transcription: %s", enriched_text[:200])
|
logger.info("-> LLM: %s", enriched_text[:120])
|
||||||
await self.push_frame(
|
await self.push_frame(
|
||||||
TranscriptionFrame(text=enriched_text, user_id=frame.user_id, timestamp=frame.timestamp),
|
TranscriptionFrame(text=enriched_text, user_id=frame.user_id,
|
||||||
|
timestamp=frame.timestamp),
|
||||||
direction,
|
direction,
|
||||||
)
|
)
|
||||||
|
# Fire background analysis for NEXT turn
|
||||||
|
wav_bytes = self._flush_audio_to_wav()
|
||||||
|
self._audio_buffer = []
|
||||||
|
if wav_bytes:
|
||||||
|
asyncio.create_task(self._analyze_background(wav_bytes))
|
||||||
else:
|
else:
|
||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
async def _analyze_background(self, wav_bytes: bytes):
|
||||||
|
"""Run SpeechBrain in background, store result for next turn."""
|
||||||
|
try:
|
||||||
|
vocal_state, analysis = await self._analyze(wav_bytes)
|
||||||
|
self._last_vocal_state = vocal_state
|
||||||
|
if analysis and self._clip_worth_saving(analysis, wav_bytes):
|
||||||
|
await self._save_clip(wav_bytes, analysis)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Background SpeechBrain failed: %s", e)
|
||||||
|
|
||||||
def _flush_audio_to_wav(self) -> Optional[bytes]:
|
def _flush_audio_to_wav(self) -> Optional[bytes]:
|
||||||
if not self._audio_buffer:
|
if not self._audio_buffer:
|
||||||
return None
|
return None
|
||||||
@@ -180,12 +318,11 @@ class VocalStateInjector(FrameProcessor):
|
|||||||
d,
|
d,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("SpeechBrain unavailable: %s -- continuing without vocal state", e)
|
logger.warning("SpeechBrain unavailable: %s", e)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
def _clip_worth_saving(self, analysis: dict, wav_bytes: bytes) -> bool:
|
def _clip_worth_saving(self, analysis: dict, wav_bytes: bytes) -> bool:
|
||||||
duration = len(wav_bytes) / (16000 * 2)
|
return len(wav_bytes) / (16000 * 2) >= MIN_CLIP_DURATION
|
||||||
return duration >= MIN_CLIP_DURATION
|
|
||||||
|
|
||||||
async def _save_clip(self, wav_bytes: bytes, analysis: dict):
|
async def _save_clip(self, wav_bytes: bytes, analysis: dict):
|
||||||
try:
|
try:
|
||||||
@@ -193,8 +330,7 @@ class VocalStateInjector(FrameProcessor):
|
|||||||
clip_id = f"{self._session_id}_{uuid.uuid4().hex[:8]}"
|
clip_id = f"{self._session_id}_{uuid.uuid4().hex[:8]}"
|
||||||
(CLIPS_DIR / f"{clip_id}.wav").write_bytes(wav_bytes)
|
(CLIPS_DIR / f"{clip_id}.wav").write_bytes(wav_bytes)
|
||||||
meta = {
|
meta = {
|
||||||
"clip_id": clip_id,
|
"clip_id": clip_id, "session_id": self._session_id,
|
||||||
"session_id": self._session_id,
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
"duration_seconds": len(wav_bytes) / (16000 * 2),
|
"duration_seconds": len(wav_bytes) / (16000 * 2),
|
||||||
"speechbrain_emotion": analysis.get("emotion"),
|
"speechbrain_emotion": analysis.get("emotion"),
|
||||||
@@ -203,22 +339,70 @@ class VocalStateInjector(FrameProcessor):
|
|||||||
"pitch_mean_hz": analysis.get("pitch_mean_hz"),
|
"pitch_mean_hz": analysis.get("pitch_mean_hz"),
|
||||||
"energy_rms": analysis.get("energy_rms"),
|
"energy_rms": analysis.get("energy_rms"),
|
||||||
"speaking_rate_approx": analysis.get("speaking_rate_approx"),
|
"speaking_rate_approx": analysis.get("speaking_rate_approx"),
|
||||||
"labeled": False,
|
"labeled": False, "confirmed_label": None,
|
||||||
"confirmed_label": None,
|
|
||||||
"uncertain": analysis.get("emotion_confidence", 1.0) < UNCERTAINTY_THRESHOLD,
|
"uncertain": analysis.get("emotion_confidence", 1.0) < UNCERTAINTY_THRESHOLD,
|
||||||
}
|
}
|
||||||
(CLIPS_DIR / f"{clip_id}.json").write_text(json.dumps(meta, indent=2))
|
(CLIPS_DIR / f"{clip_id}.json").write_text(json.dumps(meta, indent=2))
|
||||||
logger.info("Saved clip: %s", clip_id)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to save clip: %s", e)
|
logger.warning("Failed to save clip: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pre-warm
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _prewarm_speaches():
|
||||||
|
"""Pre-warm Whisper and Kokoro models so first request is fast."""
|
||||||
|
client = httpx.AsyncClient(timeout=120.0)
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
sr = 16000
|
||||||
|
audio = np.zeros(sr, dtype=np.int16)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with wave.open(buf, "wb") as wf:
|
||||||
|
wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(sr)
|
||||||
|
wf.writeframes(audio.tobytes())
|
||||||
|
r = await client.post(
|
||||||
|
f"{SPEACHES_URL}/v1/audio/transcriptions",
|
||||||
|
files={"file": ("warmup.wav", buf.getvalue(), "audio/wav")},
|
||||||
|
data={"model": WHISPER_MODEL, "language": "en",
|
||||||
|
"response_format": "json", "vad_filter": "false"},
|
||||||
|
)
|
||||||
|
logger.info("Pre-warm Whisper: %s", r.status_code)
|
||||||
|
r2 = await client.post(
|
||||||
|
f"{SPEACHES_URL}/v1/audio/speech",
|
||||||
|
json={"model": KOKORO_MODEL, "input": "Ready.", "voice": KOKORO_VOICE},
|
||||||
|
)
|
||||||
|
logger.info("Pre-warm Kokoro: %s", r2.status_code)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Pre-warm failed: %s", e)
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pipeline assembly
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def run_voice_coach_pipeline(
|
async def run_voice_coach_pipeline(
|
||||||
livekit_url: str,
|
livekit_url: str,
|
||||||
livekit_token: str,
|
livekit_token: str,
|
||||||
openclaw_token: str,
|
openclaw_token: str,
|
||||||
):
|
):
|
||||||
session_id = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
session_id = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||||
|
await _prewarm_speaches()
|
||||||
|
|
||||||
|
# Load settings from token server
|
||||||
|
settings = {"confidence": 0.7, "min_volume": 0.3, "start_secs": 0.4,
|
||||||
|
"stop_secs": 0.5, "max_tokens": 150, "tts_voice": "af_nova"}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
r = await client.get("http://token-server:8080/settings")
|
||||||
|
if r.status_code == 200:
|
||||||
|
settings.update(r.json())
|
||||||
|
logger.info("Loaded settings: %s", settings)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not load settings, using defaults: %s", e)
|
||||||
|
|
||||||
transport = LiveKitTransport(
|
transport = LiveKitTransport(
|
||||||
url=livekit_url,
|
url=livekit_url,
|
||||||
@@ -229,41 +413,38 @@ async def run_voice_coach_pipeline(
|
|||||||
audio_out_enabled=True,
|
audio_out_enabled=True,
|
||||||
vad_enabled=True,
|
vad_enabled=True,
|
||||||
vad_analyzer=SileroVADAnalyzer(params=VADParams(
|
vad_analyzer=SileroVADAnalyzer(params=VADParams(
|
||||||
confidence=0.5,
|
confidence=float(settings["confidence"]),
|
||||||
start_secs=0.2,
|
start_secs=float(settings["start_secs"]),
|
||||||
stop_secs=0.8,
|
stop_secs=float(settings["stop_secs"]),
|
||||||
min_volume=0.1,
|
min_volume=float(settings["min_volume"]),
|
||||||
)),
|
)),
|
||||||
vad_audio_passthrough=True,
|
vad_audio_passthrough=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# STT: Speaches / faster-whisper via OpenAI-compatible API
|
stt = SpeachesSTTProcessor(base_url=SPEACHES_URL, model=WHISPER_MODEL, language="en")
|
||||||
stt = SpeachesSTTService(
|
|
||||||
base_url=SPEACHES_URL,
|
|
||||||
model=WHISPER_MODEL,
|
|
||||||
language="en",
|
|
||||||
)
|
|
||||||
|
|
||||||
vocal_injector = VocalStateInjector(session_id=session_id)
|
vocal_injector = VocalStateInjector(session_id=session_id)
|
||||||
|
sentence_agg = SentenceAggregator()
|
||||||
|
|
||||||
|
from prompt_builder import build_system_prompt
|
||||||
|
system_prompt = build_system_prompt()
|
||||||
|
|
||||||
# LLM: OpenClaw VoiceCoach agent via OpenAI-compatible gateway
|
|
||||||
llm = OpenAILLMService(
|
llm = OpenAILLMService(
|
||||||
api_key=openclaw_token,
|
api_key="ollama",
|
||||||
base_url=f"{OPENCLAW_URL}/v1",
|
base_url=LLM_BASE_URL,
|
||||||
model=f"openclaw/{OPENCLAW_AGENT_ID}",
|
model=LLM_MODEL,
|
||||||
max_tokens=512,
|
max_tokens=int(settings["max_tokens"]),
|
||||||
)
|
)
|
||||||
|
context = OpenAILLMContext(messages=[
|
||||||
context = OpenAILLMContext(messages=[])
|
{"role": "system", "content": system_prompt},
|
||||||
|
])
|
||||||
context_aggregator = llm.create_context_aggregator(context)
|
context_aggregator = llm.create_context_aggregator(context)
|
||||||
|
|
||||||
# TTS: Speaches / Kokoro via OpenAI-compatible API
|
tts = SpeachesTTSService(
|
||||||
tts = OpenAITTSService(
|
|
||||||
api_key="speaches",
|
api_key="speaches",
|
||||||
base_url=f"{SPEACHES_URL}/v1",
|
base_url=f"{SPEACHES_URL}/v1",
|
||||||
model="kokoro",
|
model=KOKORO_MODEL,
|
||||||
voice=KOKORO_VOICE,
|
voice=settings.get("tts_voice", KOKORO_VOICE),
|
||||||
)
|
)
|
||||||
|
|
||||||
pipeline = Pipeline([
|
pipeline = Pipeline([
|
||||||
@@ -272,25 +453,24 @@ async def run_voice_coach_pipeline(
|
|||||||
vocal_injector,
|
vocal_injector,
|
||||||
context_aggregator.user(),
|
context_aggregator.user(),
|
||||||
llm,
|
llm,
|
||||||
|
sentence_agg, # Fix 1: aggregate sentences before TTS
|
||||||
tts,
|
tts,
|
||||||
transport.output(),
|
transport.output(),
|
||||||
context_aggregator.assistant(),
|
context_aggregator.assistant(),
|
||||||
])
|
])
|
||||||
|
|
||||||
task = PipelineTask(
|
task = PipelineTask(pipeline, params=PipelineParams(allow_interruptions=True))
|
||||||
pipeline,
|
|
||||||
params=PipelineParams(allow_interruptions=True),
|
|
||||||
)
|
|
||||||
runner = PipelineRunner()
|
runner = PipelineRunner()
|
||||||
|
|
||||||
@transport.event_handler("on_first_participant_joined")
|
@transport.event_handler("on_first_participant_joined")
|
||||||
async def on_first_participant_joined(transport, participant):
|
async def on_first_participant_joined(transport, participant):
|
||||||
logger.info("Participant joined: %s", participant)
|
logger.info("Participant joined: %s", participant)
|
||||||
await task.queue_frames([TextFrame("session_start")])
|
await task.queue_frames([TTSSpeakFrame("VoiceCoach connected. How are you doing today?")])
|
||||||
|
|
||||||
@transport.event_handler("on_participant_left")
|
@transport.event_handler("on_participant_left")
|
||||||
async def on_participant_left(transport, participant, reason):
|
async def on_participant_left(transport, participant, reason):
|
||||||
logger.info("Session ending: %s", session_id)
|
logger.info("Session ending: %s (reason: %s)", session_id, reason)
|
||||||
await task.queue_frames([EndFrame()])
|
await task.cancel()
|
||||||
|
|
||||||
await runner.run(task)
|
await runner.run(task)
|
||||||
|
logger.info("Pipeline runner exited for session %s", session_id)
|
||||||
|
|||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
Piper TTS client for Pipecat pipeline.
|
||||||
|
Calls the self-hosted Piper HTTP service.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import wave
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PiperTTSClient:
|
||||||
|
"""HTTP client for the Piper TTS service."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str = "http://piper-tts:8003"):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self._client = httpx.AsyncClient(timeout=10.0)
|
||||||
|
|
||||||
|
async def synthesize(self, text: str) -> bytes:
|
||||||
|
"""Convert text to WAV audio bytes via Piper service."""
|
||||||
|
try:
|
||||||
|
response = await self._client.post(
|
||||||
|
f"{self.base_url}/synthesize",
|
||||||
|
json={"text": text, "length_scale": 0.95, "sentence_silence": 0.15},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Piper TTS failed: {e}")
|
||||||
|
return b""
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
await self._client.aclose()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Standalone LiveKit token server + settings API.
|
||||||
|
Runs independently from the pipeline so it never goes down when the pipeline crashes.
|
||||||
|
Settings are stored in a JSON file so the pipeline can read them on startup.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from livekit import api as livekit_api
|
||||||
|
|
||||||
|
LIVEKIT_API_KEY = os.environ["LIVEKIT_API_KEY"]
|
||||||
|
LIVEKIT_API_SECRET = os.environ["LIVEKIT_API_SECRET"]
|
||||||
|
LIVEKIT_PUBLIC_URL = os.getenv("LIVEKIT_PUBLIC_URL", "wss://voice.hobbs.farm")
|
||||||
|
SETTINGS_PATH = Path(os.getenv("SETTINGS_PATH", "/data/settings.json"))
|
||||||
|
|
||||||
|
app = FastAPI(title="VoiceCoach Token + Settings Service")
|
||||||
|
|
||||||
|
DEFAULTS = {
|
||||||
|
"confidence": 0.7,
|
||||||
|
"min_volume": 0.3,
|
||||||
|
"start_secs": 0.4,
|
||||||
|
"stop_secs": 0.5,
|
||||||
|
"max_tokens": 150,
|
||||||
|
"tts_voice": "af_nova",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings() -> dict:
|
||||||
|
try:
|
||||||
|
if SETTINGS_PATH.exists():
|
||||||
|
return {**DEFAULTS, **json.loads(SETTINGS_PATH.read_text())}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {**DEFAULTS}
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(settings: dict):
|
||||||
|
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
SETTINGS_PATH.write_text(json.dumps(settings, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRequest(BaseModel):
|
||||||
|
identity: str = "jeremy"
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseModel):
|
||||||
|
confidence: Optional[float] = None
|
||||||
|
min_volume: Optional[float] = None
|
||||||
|
start_secs: Optional[float] = None
|
||||||
|
stop_secs: Optional[float] = None
|
||||||
|
max_tokens: Optional[int] = None
|
||||||
|
tts_voice: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "version": "2.1"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/token")
|
||||||
|
async def get_token(req: TokenRequest):
|
||||||
|
token = livekit_api.AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
|
||||||
|
token.with_grants(livekit_api.VideoGrants(
|
||||||
|
room_join=True, room="voicecoach",
|
||||||
|
can_publish=True, can_subscribe=True,
|
||||||
|
))
|
||||||
|
token.identity = req.identity
|
||||||
|
return TokenResponse(token=token.to_jwt(), url=LIVEKIT_PUBLIC_URL)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/settings")
|
||||||
|
async def get_settings():
|
||||||
|
return load_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/settings")
|
||||||
|
async def update_settings(s: Settings):
|
||||||
|
current = load_settings()
|
||||||
|
update = {k: v for k, v in s.dict().items() if v is not None}
|
||||||
|
current.update(update)
|
||||||
|
save_settings(current)
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8080, log_level="info")
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Local STT service using faster-whisper.
|
||||||
|
Replaces Deepgram with fully self-hosted speech-to-text.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import wave
|
||||||
|
import numpy as np
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalWhisperSTT:
|
||||||
|
"""Wraps faster-whisper for use in the Pipecat pipeline."""
|
||||||
|
|
||||||
|
def __init__(self, model_size: str = "medium", device: str = "cpu", compute_type: str = "int8"):
|
||||||
|
logger.info(f"Loading faster-whisper model: {model_size} on {device}")
|
||||||
|
self.model = WhisperModel(model_size, device=device, compute_type=compute_type)
|
||||||
|
logger.info("faster-whisper model loaded.")
|
||||||
|
|
||||||
|
def transcribe(self, audio_bytes: bytes, sample_rate: int = 16000) -> str:
|
||||||
|
"""Transcribe raw PCM audio bytes to text."""
|
||||||
|
audio_array = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
|
||||||
|
segments, info = self.model.transcribe(
|
||||||
|
audio_array,
|
||||||
|
beam_size=5,
|
||||||
|
language="en",
|
||||||
|
vad_filter=True,
|
||||||
|
vad_parameters=dict(min_silence_duration_ms=500),
|
||||||
|
)
|
||||||
|
|
||||||
|
text = " ".join(seg.text.strip() for seg in segments)
|
||||||
|
return text
|
||||||
+141
-19
@@ -1,17 +1,119 @@
|
|||||||
const LIVEKIT_URL = "wss://voice.hobbs.farm";
|
const LIVEKIT_URL = "wss://voice.hobbs.farm";
|
||||||
let room = null;
|
let room = null;
|
||||||
|
|
||||||
|
// --- Settings ---
|
||||||
|
const DEFAULTS = {
|
||||||
|
confidence: 0.7, min_volume: 0.3, start_secs: 0.4,
|
||||||
|
stop_secs: 0.5, max_tokens: 150, tts_voice: "af_nova"
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSettings() {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem("vc_settings"));
|
||||||
|
return saved ? { ...DEFAULTS, ...saved } : { ...DEFAULTS };
|
||||||
|
} catch (e) { return { ...DEFAULTS }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSettingsUI() {
|
||||||
|
const s = getSettings();
|
||||||
|
for (const [key, val] of Object.entries(s)) {
|
||||||
|
const input = document.getElementById("set-" + key);
|
||||||
|
const display = document.getElementById("val-" + key);
|
||||||
|
if (input) input.value = val;
|
||||||
|
if (display) display.textContent = val;
|
||||||
|
}
|
||||||
|
// Bind live value display
|
||||||
|
document.querySelectorAll(".settings-body input[type=range]").forEach(function(el) {
|
||||||
|
el.addEventListener("input", function() {
|
||||||
|
const key = el.id.replace("set-", "");
|
||||||
|
document.getElementById("val-" + key).textContent = el.value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
var sel = document.getElementById("set-tts_voice");
|
||||||
|
if (sel) sel.addEventListener("change", function() {
|
||||||
|
document.getElementById("val-tts_voice").textContent = sel.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
var s = {};
|
||||||
|
for (var key of Object.keys(DEFAULTS)) {
|
||||||
|
var el = document.getElementById("set-" + key);
|
||||||
|
if (el) s[key] = el.tagName === "SELECT" ? el.value : parseFloat(el.value);
|
||||||
|
}
|
||||||
|
localStorage.setItem("vc_settings", JSON.stringify(s));
|
||||||
|
// Push to server
|
||||||
|
fetch("/api/settings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(s),
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) log("Settings saved and applied");
|
||||||
|
else log("Settings saved locally but server update failed", "warn");
|
||||||
|
}).catch(function() {
|
||||||
|
log("Settings saved locally (server unreachable)", "warn");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSettings() {
|
||||||
|
localStorage.removeItem("vc_settings");
|
||||||
|
loadSettingsUI();
|
||||||
|
saveSettings();
|
||||||
|
log("Settings reset to defaults");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSettings() {
|
||||||
|
var body = document.getElementById("settingsBody");
|
||||||
|
var toggle = document.getElementById("settingsToggle");
|
||||||
|
if (body.style.display === "none") {
|
||||||
|
body.style.display = "block";
|
||||||
|
toggle.textContent = "-";
|
||||||
|
} else {
|
||||||
|
body.style.display = "none";
|
||||||
|
toggle.textContent = "+";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Logging ---
|
||||||
|
function log(msg, level) {
|
||||||
|
var el = document.getElementById("logOutput");
|
||||||
|
var time = new Date().toLocaleTimeString();
|
||||||
|
var cls = level === "error" ? "log-error" : level === "warn" ? "log-warn" : "log-info";
|
||||||
|
el.innerHTML += '<div class="' + cls + '"><span class="log-time">' + time + '</span> ' + msg + '</div>';
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLog() {
|
||||||
|
document.getElementById("logOutput").innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Connection ---
|
||||||
async function connect() {
|
async function connect() {
|
||||||
setStatus("connecting", "Connecting...");
|
setStatus("connecting", "Connecting...");
|
||||||
|
log("Requesting token...");
|
||||||
|
|
||||||
|
// Push current settings before connecting
|
||||||
|
var settings = getSettings();
|
||||||
try {
|
try {
|
||||||
// Request a token from the token service
|
await fetch("/api/settings", {
|
||||||
const response = await fetch("/api/token", {
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
});
|
||||||
|
log("Settings pushed: confidence=" + settings.confidence + " vol=" + settings.min_volume);
|
||||||
|
} catch (e) {
|
||||||
|
log("Could not push settings", "warn");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var response = await fetch("/api/token", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ identity: "jeremy-" + Date.now() }),
|
body: JSON.stringify({ identity: "jeremy-" + Date.now() }),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error("Failed to get token");
|
if (!response.ok) throw new Error("Token " + response.status);
|
||||||
const { token } = await response.json();
|
var data = await response.json();
|
||||||
|
log("Token received");
|
||||||
|
|
||||||
room = new LivekitClient.Room({
|
room = new LivekitClient.Room({
|
||||||
audioCaptureDefaults: {
|
audioCaptureDefaults: {
|
||||||
@@ -21,57 +123,77 @@ async function connect() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
room.on(LivekitClient.RoomEvent.Connected, () => {
|
room.on(LivekitClient.RoomEvent.Connected, function() {
|
||||||
setStatus("connected", "Connected");
|
setStatus("connected", "Connected");
|
||||||
document.getElementById("connectBtn").style.display = "none";
|
document.getElementById("connectBtn").style.display = "none";
|
||||||
document.getElementById("disconnectBtn").style.display = "block";
|
document.getElementById("disconnectBtn").style.display = "block";
|
||||||
|
log("Connected to LiveKit");
|
||||||
});
|
});
|
||||||
|
|
||||||
room.on(LivekitClient.RoomEvent.Disconnected, () => {
|
room.on(LivekitClient.RoomEvent.Disconnected, function(reason) {
|
||||||
setStatus("disconnected", "Disconnected");
|
setStatus("disconnected", "Disconnected");
|
||||||
document.getElementById("connectBtn").style.display = "block";
|
document.getElementById("connectBtn").style.display = "block";
|
||||||
document.getElementById("disconnectBtn").style.display = "none";
|
document.getElementById("disconnectBtn").style.display = "none";
|
||||||
|
log("Disconnected" + (reason ? ": " + reason : ""), "warn");
|
||||||
});
|
});
|
||||||
|
|
||||||
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track) => {
|
room.on(LivekitClient.RoomEvent.ParticipantConnected, function(p) {
|
||||||
|
log("Agent joined: " + p.identity);
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(LivekitClient.RoomEvent.TrackSubscribed, function(track, pub, p) {
|
||||||
if (track.kind === LivekitClient.Track.Kind.Audio) {
|
if (track.kind === LivekitClient.Track.Kind.Audio) {
|
||||||
const element = track.attach();
|
var el = track.attach();
|
||||||
document.body.appendChild(element);
|
document.body.appendChild(el);
|
||||||
element.play();
|
el.play().catch(function(e) { log("Audio play error: " + e.message, "error"); });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await room.connect(LIVEKIT_URL, token);
|
room.on(LivekitClient.RoomEvent.MediaDevicesError, function(err) {
|
||||||
|
log("Mic error: " + err.message, "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
await room.connect(data.url || LIVEKIT_URL, data.token);
|
||||||
await room.localParticipant.setMicrophoneEnabled(true);
|
await room.localParticipant.setMicrophoneEnabled(true);
|
||||||
|
log("Mic enabled. Speak now.");
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus("disconnected", "Error: " + err.message);
|
setStatus("disconnected", "Error: " + err.message);
|
||||||
console.error(err);
|
log("Error: " + err.message, "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disconnect() {
|
async function disconnect() {
|
||||||
if (room) {
|
if (room) { await room.disconnect(); room = null; }
|
||||||
await room.disconnect();
|
|
||||||
room = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMode(mode) {
|
function setMode(mode) {
|
||||||
document.getElementById("modeDisplay").textContent = "Mode: " + mode;
|
document.getElementById("modeDisplay").textContent = "Mode: " + mode;
|
||||||
|
log("Mode: " + mode);
|
||||||
if (room && room.localParticipant) {
|
if (room && room.localParticipant) {
|
||||||
const encoder = new TextEncoder();
|
|
||||||
room.localParticipant.publishData(
|
room.localParticipant.publishData(
|
||||||
encoder.encode(JSON.stringify({ type: "mode_change", mode })),
|
new TextEncoder().encode(JSON.stringify({ type: "mode_change", mode })),
|
||||||
{ reliable: true }
|
{ reliable: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(state, text) {
|
function setStatus(state, text) {
|
||||||
const el = document.getElementById("status");
|
var el = document.getElementById("status");
|
||||||
el.className = "status " + state;
|
el.className = "status " + state;
|
||||||
el.textContent = text;
|
el.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("connectBtn").addEventListener("click", connect);
|
document.getElementById("connectBtn").addEventListener("click", connect);
|
||||||
document.getElementById("disconnectBtn").addEventListener("click", disconnect);
|
document.getElementById("disconnectBtn").addEventListener("click", disconnect);
|
||||||
|
|
||||||
|
// Init
|
||||||
|
loadSettingsUI();
|
||||||
|
// Push saved settings on load
|
||||||
|
fetch("/api/settings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(getSettings()),
|
||||||
|
}).catch(function() {});
|
||||||
|
|
||||||
|
log("VoiceCoach loaded. Ready.");
|
||||||
|
|||||||
@@ -30,6 +30,60 @@
|
|||||||
<button class="mode-btn" onclick="setMode('practice mode')">Practice</button>
|
<button class="mode-btn" onclick="setMode('practice mode')">Practice</button>
|
||||||
<button class="mode-btn" onclick="setMode('briefing mode')">Briefing</button>
|
<button class="mode-btn" onclick="setMode('briefing mode')">Briefing</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-panel">
|
||||||
|
<div class="settings-header" onclick="toggleSettings()">
|
||||||
|
<span>Settings</span>
|
||||||
|
<span id="settingsToggle">+</span>
|
||||||
|
</div>
|
||||||
|
<div id="settingsBody" class="settings-body" style="display:none">
|
||||||
|
<div class="setting-group">
|
||||||
|
<label>VAD Confidence <span class="setting-val" id="val-confidence">0.7</span></label>
|
||||||
|
<input type="range" id="set-confidence" min="0.3" max="0.95" step="0.05" value="0.7">
|
||||||
|
<div class="setting-hint">Higher = less false triggers from noise</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<label>Min Volume <span class="setting-val" id="val-min_volume">0.3</span></label>
|
||||||
|
<input type="range" id="set-min_volume" min="0.05" max="0.6" step="0.05" value="0.3">
|
||||||
|
<div class="setting-hint">Higher = ignores quieter sounds</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<label>Start Delay <span class="setting-val" id="val-start_secs">0.4</span>s</label>
|
||||||
|
<input type="range" id="set-start_secs" min="0.1" max="0.8" step="0.1" value="0.4">
|
||||||
|
<div class="setting-hint">Sustained speech needed before trigger</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<label>Stop Delay <span class="setting-val" id="val-stop_secs">0.5</span>s</label>
|
||||||
|
<input type="range" id="set-stop_secs" min="0.2" max="1.5" step="0.1" value="0.5">
|
||||||
|
<div class="setting-hint">Silence before turn ends (higher = less cutoff)</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<label>LLM Max Tokens <span class="setting-val" id="val-max_tokens">150</span></label>
|
||||||
|
<input type="range" id="set-max_tokens" min="50" max="500" step="25" value="150">
|
||||||
|
<div class="setting-hint">Longer = more detailed responses, slower</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<label>TTS Voice <span class="setting-val" id="val-tts_voice">af_nova</span></label>
|
||||||
|
<select id="set-tts_voice">
|
||||||
|
<option value="af_nova">Nova (Female, American)</option>
|
||||||
|
<option value="af_sky">Sky (Female, American)</option>
|
||||||
|
<option value="am_adam">Adam (Male, American)</option>
|
||||||
|
<option value="bf_emma">Emma (Female, British)</option>
|
||||||
|
<option value="bm_george">George (Male, British)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-small" onclick="saveSettings()">Apply Settings</button>
|
||||||
|
<button class="btn btn-small btn-reset" onclick="resetSettings()">Reset Defaults</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="log-panel">
|
||||||
|
<div class="log-header">
|
||||||
|
<span>Log</span>
|
||||||
|
<button class="log-clear" onclick="clearLog()">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div id="logOutput" class="log-output"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://pipecat-agent:8080/;
|
proxy_pass http://token-server:8080/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,3 +86,125 @@ h1 { font-size: 24px; font-weight: 600; }
|
|||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
.mode-btn:hover { background: #1a2744; border-color: #4a6cf7; }
|
.mode-btn:hover { background: #1a2744; border-color: #4a6cf7; }
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
margin-top: 16px;
|
||||||
|
background: #0d1117;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.settings-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #161b22;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.settings-header:hover { color: #ccc; }
|
||||||
|
.settings-body { padding: 12px; }
|
||||||
|
.setting-group { margin-bottom: 14px; }
|
||||||
|
.setting-group label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.setting-val {
|
||||||
|
color: #4a6cf7;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'SF Mono', monospace;
|
||||||
|
}
|
||||||
|
.setting-group input[type=range] {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.setting-group input[type=range]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4a6cf7;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.setting-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #16213e;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.setting-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.btn-small {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
width: auto;
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.btn-reset {
|
||||||
|
background: #333;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.btn-reset:hover { background: #444; }
|
||||||
|
|
||||||
|
.log-panel {
|
||||||
|
margin-top: 16px;
|
||||||
|
background: #0d1117;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.log-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #161b22;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.log-clear {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.log-clear:hover { border-color: #666; color: #ccc; }
|
||||||
|
.log-output {
|
||||||
|
padding: 8px 12px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.log-output div { word-break: break-all; }
|
||||||
|
.log-time { color: #555; margin-right: 6px; }
|
||||||
|
.log-info { color: #8b949e; }
|
||||||
|
.log-warn { color: #d29922; }
|
||||||
|
.log-error { color: #f85149; }
|
||||||
|
|||||||
Reference in New Issue
Block a user