Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ metadata and the backend fallback mirror it. Archived Tauri manifests stay froze
- The OpenAI-compatible API adds `GET /v1/models` and `POST /v1/audio/translations` (multilingual Whisper models; turbo models are refused) (#2290)
- Speech requests accept `stream_format` (`audio` or `sse`) and OpenAI's `{"id": ...}` voice object (#2290)
- Transcriptions pass `language`, `prompt` and `temperature` to Whisper engines and return `words` with `timestamp_granularities[]=word` (#2290)
- Integrations: copyable setup for Codex CLI (`config.toml`), any MCP client (HTTP or stdio), the VoiceStudio API (`curl` and OpenAI SDK), and Docker/GHCR (#2289)

### Fixed

Expand All @@ -34,6 +35,9 @@ metadata and the backend fallback mirror it. Archived Tauri manifests stay froze
- OpenAI-compatible routes return errors in OpenAI's format, with 400 for invalid requests (#2290)
- Rendered audiobook chapters stay reusable after the data folder moves, and a power-off no longer empties the resume point or tears chapter audio (#2279) — thanks @castlecreati!
- An audiobook chapter-cache miss now logs which input changed since the cached render (#2279)
- MCP clients pointed at `/mcp` connect on Docker and source builds instead of getting HTTP 405 (#2289)
- MCP tools and `backend.speech_client` follow a backend moved with `OMNIVOICE_PORT` instead of assuming port 3900 (#2289)
- Integration cards show real capabilities: "Works with VoiceStudio" only where a setup exists, "External link" everywhere else (#2289)

### Bug reporters

Expand Down
11 changes: 11 additions & 0 deletions backend/core/spa_inject.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,19 @@
from __future__ import annotations

import json
import os
import re


def frontend_dist_dir() -> str:
"""The built SPA served at "/" when it exists (Docker, source builds).

Resolved relative to the backend package root, exactly where ``main``
looks — one seam so tests can serve a real SPA mount without building it.
"""
backend_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(backend_root, "..", "frontend", "dist")

# Operator-controlled value, but validate to a plain http(s) URL with no
# whitespace, quotes, or angle brackets so it can never break out of the
# injected <script> element.
Expand Down
7 changes: 4 additions & 3 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -780,11 +780,12 @@ def _phase_a_finalize() -> None:
app.mount("/demo_audio", StaticFiles(directory=_demo_dir), name="demo_audio")

# SPA shell LAST so the "/" StaticFiles mount can't shadow any router.
_frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
from core.spa_inject import frontend_dist_dir, is_valid_public_api_base, inject_api_base

_frontend_path = frontend_dist_dir()
if os.path.exists(_frontend_path):
# Runtime API-base override (Docker / reverse-proxy): inject
# OMNIVOICE_PUBLIC_API_BASE into index.html; unset → untouched.
from core.spa_inject import is_valid_public_api_base, inject_api_base

_public_api_base = os.environ.get("OMNIVOICE_PUBLIC_API_BASE", "").strip().rstrip("/")
_index_path = os.path.join(_frontend_path, "index.html")
Expand Down Expand Up @@ -815,7 +816,7 @@ def _index_html():

@app.get("/", include_in_schema=False)
def _dev_fallback():
return RedirectResponse(url="http://localhost:3901")
return RedirectResponse(url=f"http://localhost:{_ui_port()}")

# An early /docs or /openapi.json hit may have cached a schema without
# the routers — bust it so the next request rebuilds the full one.
Expand Down
77 changes: 69 additions & 8 deletions backend/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,8 +389,15 @@ def _ensure_mcp():
raise ImportError(msg) from e


def create_mcp_server():
"""Build and return the FastMCP server instance."""
def create_mcp_server(app=None):
"""Build and return the FastMCP server instance.

``app`` is the backend ASGI app this server is mounted on. When given,
tool calls reach the API in-process as a loopback caller — independent of
the bind host/port and never challenged by the share-PIN / API-key gates
(which a concrete LAN ``OMNIVOICE_BIND_HOST`` would otherwise trigger).
Standalone runs (no app) call the backend over HTTP.
"""
FastMCP = _ensure_mcp()
mcp = FastMCP(
"VoiceStudio",
Expand Down Expand Up @@ -430,21 +437,38 @@ def create_mcp_server():
# ── Helpers ─────────────────────────────────────────────────────────

def _api_base() -> str:
return os.environ.get("OMNIVOICE_API_URL", "http://localhost:3900")
# Follows the backend's real bind host/port (OMNIVOICE_PORT), not a
# hard-coded 3900 — Electron moves the port via OMNIVOICE_PORT only.
# Also the public base of the `audio_url` returned in files mode.
from services.network_share import backend_self_url
return backend_self_url()

async def _api_get(path: str):
def _client(timeout: float):
import httpx
async with httpx.AsyncClient(base_url=_api_base(), timeout=30) as c:
# OMNIVOICE_API_URL is an explicit "send tool calls there" override.
if app is not None and not os.environ.get("OMNIVOICE_API_URL", "").strip():
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app, client=("127.0.0.1", 0)),
base_url="http://127.0.0.1",
timeout=timeout,
)
from services.network_share import backend_auth_headers
base = _api_base()
return httpx.AsyncClient(
base_url=base, timeout=timeout, headers=backend_auth_headers(base)
)

async def _api_get(path: str):
async with _client(30) as c:
r = await c.get(path)
r.raise_for_status()
return r.json()

async def _api_post_form(
path: str, data: dict, files: dict | None = None, *, timeout: float | None = None
):
import httpx
wait = _post_timeout_s() if timeout is None else timeout
async with httpx.AsyncClient(base_url=_api_base(), timeout=wait) as c:
async with _client(wait) as c:
r = await c.post(path, data=data, files=files or {})
r.raise_for_status()
return r
Expand Down Expand Up @@ -692,6 +716,28 @@ async def clone_voice(
return mcp


class _BareMcpPath:
"""ASGI endpoint for exactly ``/mcp``: re-dispatch as ``/mcp/``.

A class instance (not a function) so Starlette's ``Route`` treats it as a
raw ASGI app with no method restriction — GET (SSE), POST and DELETE all
reach the Streamable-HTTP transport. Re-entering the router keeps the
mount's own path/root_path handling instead of re-implementing it.
"""

def __init__(self, router) -> None:
self._router = router

async def __call__(self, scope, receive, send) -> None:
scope = dict(scope)
scope["path"] = scope["path"] + "/"
raw = scope.get("raw_path")
if isinstance(raw, (bytes, bytearray)):
path, sep, query = bytes(raw).partition(b"?")
scope["raw_path"] = path + b"/" + sep + query
await self._router(scope, receive, send)


def mount_mcp(app) -> bool:
"""Best-effort sub-mount of the MCP Streamable-HTTP app at /mcp.

Expand All @@ -702,10 +748,18 @@ def mount_mcp(app) -> bool:
boundary, #1143).
"""
try:
mcp = create_mcp_server()
mcp = create_mcp_server(app)
mcp_app = mcp.streamable_http_app()
app.state.mcp_session_manager = mcp.session_manager
app.mount("/mcp", mcp_app)
# The mount only matches "/mcp/..."; a bare "/mcp" would fall through
# to the SPA StaticFiles mount at "/" (405 on POST) or, without a
# built SPA, to a 307 that many MCP clients won't re-POST. Serve the
# published "/mcp" URL directly, for every method, ahead of "/".
from starlette.routing import Route
app.router.routes.append(
Route("/mcp", endpoint=_BareMcpPath(app.router), include_in_schema=False)
)
logger.info("MCP app mounted at /mcp")
return True
except (Exception, SystemExit) as err: # noqa: BLE001
Expand All @@ -727,6 +781,13 @@ def main():
)
args = parser.parse_args()

# `python -m backend.mcp_server` runs from the repo root, where the
# backend's own packages (services.*, core.*) are not importable; the
# embedded mount runs with backend/ on sys.path already.
_backend_dir = os.path.dirname(os.path.abspath(__file__))
if _backend_dir not in sys.path:
sys.path.insert(0, _backend_dir)

try:
mcp = create_mcp_server()
except ImportError as e:
Expand Down
43 changes: 37 additions & 6 deletions backend/mcp_shim/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@
on the running VoiceStudio backend) and stream the server's response back.

Environment variables:
OMNIVOICE_URL full backend base URL (http or https, may carry a
path prefix, e.g. https://gpu-box/voicestudio). Wins
over OMNIVOICE_HOST / OMNIVOICE_PORT.
OMNIVOICE_PORT backend port (default 3900).
OMNIVOICE_HOST host (default 127.0.0.1).
OMNIVOICE_API_KEY sent as a Bearer token when set (remote backends that
require an API key; loopback never needs one). Refused
for a remote plain-http URL: use https:// there.
OMNIVOICE_CLIENT_ID forwarded as X-OmniVoice-Client-Id on every request
(drives per-agent voice binding).

Expand All @@ -25,10 +31,12 @@
from __future__ import annotations

import asyncio
import ipaddress
import json
import os
import sys
from typing import Any
from urllib.parse import urlsplit

import httpx

Expand All @@ -43,17 +51,34 @@ def _err(msg: str) -> None:


def _base_url() -> tuple[str, str]:
host = os.environ.get("OMNIVOICE_HOST", "127.0.0.1")
port = int(os.environ.get("OMNIVOICE_PORT", str(DEFAULT_PORT)))
return f"http://{host}:{port}/mcp/", f"http://{host}:{port}/health"
base = os.environ.get("OMNIVOICE_URL", "").strip().rstrip("/")
if not base:
host = os.environ.get("OMNIVOICE_HOST", "127.0.0.1")
port = int(os.environ.get("OMNIVOICE_PORT", str(DEFAULT_PORT)))
base = f"http://{host}:{port}"
return f"{base}/mcp/", f"{base}/health"


def _credentials_allowed(url: str) -> bool:
"""A key may go over https, or to loopback; never plain http elsewhere."""
target = urlsplit(url)
host = (target.hostname or "").strip("[]")
if target.scheme.lower() == "https" or host == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False


async def _wait_for_backend(client: httpx.AsyncClient, health_url: str) -> bool:
async def _wait_for_backend(
client: httpx.AsyncClient, health_url: str, headers: dict[str, str] | None = None
) -> bool:
loop = asyncio.get_running_loop()
deadline = loop.time() + HEALTH_TIMEOUT_S
while loop.time() < deadline:
try:
r = await client.get(health_url, timeout=2.0)
r = await client.get(health_url, headers=headers, timeout=2.0)
if r.status_code == 200:
return True
except Exception:
Expand Down Expand Up @@ -142,11 +167,17 @@ async def _run() -> int:
client_id = os.environ.get("OMNIVOICE_CLIENT_ID")
if client_id:
forward_headers[CLIENT_ID_HEADER] = client_id
api_key = os.environ.get("OMNIVOICE_API_KEY", "").strip()
if api_key:
if not _credentials_allowed(url):
_err("remote VoiceStudio API keys require https:// (set OMNIVOICE_URL)")
return 1
forward_headers["Authorization"] = f"Bearer {api_key}"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

session_id: list[str | None] = [None]

async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client:
if not await _wait_for_backend(client, health_url):
if not await _wait_for_backend(client, health_url, forward_headers):
_err(f"timed out waiting for VoiceStudio at {health_url} — is the app running?")
return 2
try:
Expand Down
57 changes: 57 additions & 0 deletions backend/services/network_share.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
Loopback-only by default: nothing binds 0.0.0.0 until enable() is called.
"""
import asyncio
import ipaddress
import logging
import os
import secrets
Expand Down Expand Up @@ -38,6 +39,62 @@ def backend_port() -> int:
return _DEFAULT_BACKEND_PORT


def backend_self_url() -> str:
"""Base URL for in-process callers that reach this backend over HTTP.

``OMNIVOICE_API_URL`` wins when set (reverse proxy, remote worker).
Otherwise the URL follows the host and port the backend actually binds —
``OMNIVOICE_BIND_HOST`` + ``OMNIVOICE_PORT``, the same variables uvicorn
and the desktop shells use — so a backend moved off 3900 never calls back
into a stale default port. Wildcard binds are reached over loopback,
which the auth gates never challenge.
"""
override = os.environ.get("OMNIVOICE_API_URL", "").strip().rstrip("/")
if override:
return override
host = os.environ.get("OMNIVOICE_BIND_HOST", "127.0.0.1").strip()
try:
ip = ipaddress.ip_address(host.strip("[]"))
except ValueError:
ip = None # a hostname ("localhost", a LAN name): use it as given
if not host or host == "localhost":
host = "127.0.0.1"
elif ip is not None and ip.is_unspecified:
# A wildcard bind also listens on loopback; reach it there.
host = "::1" if ip.version == 6 else "127.0.0.1"
if ":" in host and not host.startswith("["):
host = f"[{host}]"
return f"http://{host}:{backend_port()}"
Comment thread
greptile-apps[bot] marked this conversation as resolved.


def backend_auth_headers(base_url: str) -> dict:
"""Bearer header for an HTTP caller of this backend, if one is warranted.

``OMNIVOICE_API_KEY`` is sent only over https or to a loopback host: a
remote plain-http target would expose the master key on the wire (the
same rule ``backend.speech_client`` enforces). Loopback never needs it,
but sending it there is harmless.
"""
from urllib.parse import urlsplit

key = os.environ.get("OMNIVOICE_API_KEY", "").strip()
if not key:
return {}
target = urlsplit(base_url)
host = (target.hostname or "").strip("[]")
try:
loopback = host == "localhost" or ipaddress.ip_address(host).is_loopback
except ValueError:
loopback = False
if target.scheme.lower() != "https" and not loopback:
logger.warning(
"OMNIVOICE_API_KEY not sent to %s: remote API keys require https://",
f"{target.scheme}://{target.hostname}",
)
return {}
return {"Authorization": f"Bearer {key}"}


def share_port_base() -> int:
"""The first port LAN sharing tries to bind on 0.0.0.0.

Expand Down
14 changes: 13 additions & 1 deletion backend/speech_client/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@
DEFAULT_ENGINE_URL = "http://127.0.0.1:3900"


def _default_engine_url() -> str:
"""VOICESTUDIO_URL, else the backend's OMNIVOICE_PORT on loopback."""
explicit = os.environ.get("VOICESTUDIO_URL", "").strip()
if explicit:
return explicit
try:
port = int(os.environ.get("OMNIVOICE_PORT", ""))
except ValueError:
return DEFAULT_ENGINE_URL
return f"http://127.0.0.1:{port}" if 0 < port <= 65535 else DEFAULT_ENGINE_URL


class SpeechClientError(RuntimeError):
pass

Expand Down Expand Up @@ -238,7 +250,7 @@ def _parser() -> argparse.ArgumentParser:
)
parser.add_argument(
"--engine-url",
default=os.environ.get("VOICESTUDIO_URL", DEFAULT_ENGINE_URL),
default=_default_engine_url(),
)
subparsers = parser.add_subparsers(dest="command", required=True)
for command in ("status", "capabilities", "start", "stop", "toggle"):
Expand Down
2 changes: 2 additions & 0 deletions docs/agentic-voice.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ WebSocket, which VoiceStudio does not implement.
Running VoiceStudio on a [remote GPU box](remote-gpu.md)? Append `/v1` to that
backend's service-root URL for the OpenAI client's `base_url`, and pass its
`OMNIVOICE_API_KEY` as the `api_key` — the same bearer the rest of the app uses.
Only send the key over https (for example Tailscale Serve); over plain http it
crosses the network in clear text.
Keep the unmodified service root for `/.well-known/voicestudio-speech`
discovery, and keep the backend on your tailnet, not the open internet.

Expand Down
Loading
Loading