Files
voicebox/backend/routes/speak.py
T
James PineandClaude Opus 4.7 0cef2c9fe1 feat(mcp): local MCP server exposes voicebox.* tools to AI agents
Mounts FastMCP at /mcp (Streamable HTTP) so Claude Code, Cursor,
Windsurf, and the VS Code MCP extensions can call voicebox.speak,
voicebox.transcribe, voicebox.list_captures, and voicebox.list_profiles
against the running Voicebox server.

Backend
- new backend/mcp_server package (tools, middleware, profile resolve,
  pub/sub events); named mcp_server to avoid shadowing the installed mcp
  PyPI package FastMCP imports internally
- app.py migrated from @app.on_event to lifespan= so FastMCP's session
  manager cohabits with Voicebox's startup/shutdown
- new MCPClientBinding table + /mcp/bindings CRUD; ClientIdMiddleware
  reads X-Voicebox-Client-Id into a ContextVar and stamps last_seen_at
- profile resolution precedence: explicit -> per-client binding ->
  capture_settings.default_playback_voice_id
- POST /speak REST wrapper for non-MCP callers (shell, ACP, A2A)
- GET /events/speak SSE broadcasts speak-start / speak-end so the pill
  surfaces agent-initiated speech
- backend/mcp_shim proxy (plain httpx) for stdio-only MCP clients
- PyInstaller spec updates + new --shim build target (~18 MB)

Frontend
- Settings -> MCP page with HTTP / stdio / claude-mcp-add copy snippets,
  default voice picker, per-client bindings table, connection status
- useMCPBindings, useSpeakEvents hooks
- CapturePill gains 'speaking' state; DictateWindow subscribes to SSE
  and emits dictate:show so the Rust side surfaces the pill window

Native
- tauri.conf.json externalBin now includes voicebox-mcp
- show_dictate_window helper + dictate:show listener in main.rs
- (also in this commit: InputMonitoringGate UX, hotkey_monitor tweaks,
  landing footer/navbar updates, new overview docs for captures /
  dictation / mcp-server / voice-personalities)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-22 22:05:30 -07:00

94 lines
2.8 KiB
Python

"""POST /speak — REST wrapper around voicebox.speak for non-MCP callers.
Shell scripts, ACP, A2A, or any agent that doesn't speak MCP can hit this
endpoint to play text through a cloned voice. Uses the same profile
resolution and generation pipeline as the MCP tool, so per-client
bindings (via X-Voicebox-Client-Id) work identically.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from .. import models
from ..database import get_db
from ..mcp_server import events as mcp_events
from ..mcp_server.resolve import resolve_profile
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/speak", response_model=models.GenerationResponse)
async def speak(
data: models.SpeakRequest,
request: Request,
db: Session = Depends(get_db),
):
"""Speak text in a voice profile. Mirrors voicebox.speak (MCP).
Response shape matches POST /generate — a ``GenerationResponse`` with
``status="generating"`` and an ``id`` the caller polls at
``GET /generate/{id}/status``.
"""
client_id = request.headers.get("X-Voicebox-Client-Id")
profile = resolve_profile(data.profile, client_id, db)
if profile is None:
if data.profile:
raise HTTPException(
status_code=404,
detail=f"Voice profile '{data.profile}' not found.",
)
raise HTTPException(
status_code=400,
detail=(
"No voice profile resolved. Pass `profile` (name or id), "
"or configure a default in Voicebox → Settings → MCP."
),
)
# Persona path if intent requested AND profile has a personality prompt.
if data.intent is not None and profile.personality:
from .profiles import speak_in_character
generation = await speak_in_character(
profile.id,
models.PersonalitySpeakRequest(
text=data.text,
persist=True,
language=data.language,
engine=data.engine,
intent=data.intent,
),
db,
)
else:
# Plain TTS path — matches POST /generate.
from .generations import generate_speech
generation = await generate_speech(
models.GenerationRequest(
profile_id=profile.id,
text=data.text,
language=data.language or "en",
engine=data.engine or "qwen",
),
db,
)
mcp_events.publish(
"speak-start",
{
"generation_id": getattr(generation, "id", None),
"profile_name": profile.name,
"source": "rest",
"client_id": client_id,
},
)
return generation