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
47 changes: 37 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,17 @@ The public landing page is in [`web/`](web/) and deploys to
[vocagateway.vocahq.com](https://vocagateway.vocahq.com/).

Install it once on a machine you control, then pair phone clients to that host.
Desktop clients are Planned. You can self-host on macOS or Linux, or use Docker
Compose on Linux `amd64`/`arm64`. There is no Voca account and no hosted Voca
cloud.
Desktop embed is Planned. A shipped VocaLinux can already point its `remote_api`
engine at `POST /v1/audio/transcriptions` on this host. You can self-host on
macOS or Linux, or use Docker Compose on Linux `amd64`/`arm64`. There is no Voca
account and no hosted Voca cloud.

The gateway takes bounded recordings from
[vocaphone](https://github.com/VocaHQ/vocaphone) (iOS/Android). Linux, macOS,
and Windows desktop apps come later. FFmpeg normalizes the audio, a local speech
engine transcribes it, and the gateway returns an idempotent transcript. The
authenticated HTMX WebUI covers setup, model management, engine selection,
microphone testing, and operational status.
[vocaphone](https://github.com/VocaHQ/vocaphone) (iOS/Android), and from
VocaLinux when that app is set to `remote_api`. FFmpeg normalizes the audio, a
local speech engine transcribes it, and the gateway returns an idempotent
transcript. The authenticated HTMX WebUI covers setup, model management, engine
selection, microphone testing, and operational status.

Gateway mode is not on-device processing. Audio leaves the client and travels to
the machine you chose. Prefer a trusted LAN, Tailscale, or HTTPS. Never expose
Expand All @@ -54,7 +55,7 @@ contract is in [configuration.md](docs/configuration.md).
## The Voca family

Directory: [vocahq.com](https://vocahq.com). [VocaPhone](https://vocaphone.vocahq.com)
is the live consumer. Desktop gateway integration stays Planned.
is the live consumer. Embedding this gateway in a desktop app stays Planned.

| Product | Status | Website | Source |
| --- | --- | --- | --- |
Expand All @@ -69,7 +70,33 @@ is the live consumer. Desktop gateway integration stays Planned.
| Project | How it uses this gateway |
| --- | --- |
| [vocaphone](https://github.com/VocaHQ/vocaphone) | Live consumer. Git submodule at `server/` for the iOS/Android clients |
| [vocalinux](https://github.com/VocaHQ/vocalinux) / [vocamac](https://github.com/VocaHQ/vocamac) / [vocawin](https://github.com/VocaHQ/vocawin) | Planned: ship and start the headless server from the desktop app |
| [vocalinux](https://github.com/VocaHQ/vocalinux) | `remote_api` can POST audio to `/v1/audio/transcriptions` on this host. Embedding the gateway in the app is still Planned. |
| [vocamac](https://github.com/VocaHQ/vocamac) / [vocawin](https://github.com/VocaHQ/vocawin) | Planned: ship and start the headless server from the desktop app |

### VocaLinux `remote_api`

A running VocaLinux can treat this host as an OpenAI transcription server. Set
the engine to `remote_api`. Server URL is the gateway origin, for example
`http://192.168.1.20:8765`. API Endpoint must be OpenAI
`/v1/audio/transcriptions`, not VocaLinux's default `/inference`. API Key is the
gateway bearer token. The Model field is ignored; the engine you loaded in the
WebUI is what runs.

```sh
curl -H "Authorization: Bearer $TOKEN" -F file=@sample.wav -F model=whisper-1 \
http://127.0.0.1:8765/v1/audio/transcriptions
```

VocaLinux's Test Connection is `GET /` on that origin, which is the
unauthenticated WebUI, so a bad key can still look green. The first dictation is
the real check. The client times out after 30 seconds, and a cold model load can
miss that. Default concurrency is one in-flight transcription; a busy gateway
returns 503. The gateway speaks HTTP on the LAN by default. HTTPS needs a
certificate the desktop OS trusts.

This is still optional self-hosted compute. Audio leaves the desktop and travels
to the gateway host. It is not on-device transcription, and this endpoint does
not stream.

Clone with submodules when working from a consumer:

Expand Down
2 changes: 2 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
pairing,
sessions,
streaming,
transcriptions,
)
from app.runtime_config import RuntimeConfig
from app.schemas import ErrorDetail, ErrorEnvelope
Expand Down Expand Up @@ -182,6 +183,7 @@ async def api_docs() -> HTMLResponse:

app.include_router(health.router)
app.include_router(sessions.router)
app.include_router(transcriptions.router)
app.include_router(streaming.router)
app.include_router(admin_status.router)
app.include_router(admin_tokens.router)
Expand Down
163 changes: 163 additions & 0 deletions app/routes/transcriptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
from __future__ import annotations

import re
from collections.abc import Callable, Coroutine
from pathlib import Path
from typing import Annotated, Any
from uuid import uuid4

from fastapi import APIRouter, Depends, File, Form, Request, Response, UploadFile
from fastapi.routing import APIRoute

from app.audio import ALLOWED_AUDIO_TYPES, atomic_upload_path, complete_atomic_upload
from app.context import GatewayContext, get_context, require_token
from app.errors import APIProblem
from app.schemas import OpenAITranscriptionResponse

_LANGUAGE_PATTERN = re.compile(r"^[A-Za-z-]+$|^auto$")
_TRUTHY = frozenset({"true", "1", "yes"})
_MAX_LANGUAGE_LENGTH = 20
_READ_CHUNK = 64 * 1024
# Multipart wrapping (boundaries, disposition headers) sits on top of the audio
# bytes. Slack lets a file at the cap through the header check; the copy loop
# still enforces maximum_upload_bytes on the file itself.
_MULTIPART_WRAP_SLACK = 65536


def reject_oversized_multipart(
request: Request, ctx: GatewayContext = Depends(get_context)
) -> None:
raw = request.headers.get("content-length")
if raw is None:
raise APIProblem(411, "length_required", "Content-Length is required.")
try:
length = int(raw)
except ValueError as error:
raise APIProblem(400, "invalid_content_length", "Content-Length is invalid.") from error
if length < 0:
raise APIProblem(400, "invalid_content_length", "Content-Length is invalid.")
if length > ctx.settings.maximum_upload_bytes + _MULTIPART_WRAP_SLACK:
raise APIProblem(413, "audio_too_large", "The recording exceeds the upload limit.")


class _EarlyUploadLimitRoute(APIRoute):
"""Run auth and the Content-Length cap before FastAPI spools multipart.

`get_request_handler` calls `request.form()` before it solves router
dependencies whenever the endpoint has File()/Form() parameters. A 413
raised from a dependency would still parse the body. Wrapping the handler
is what fails a missing, invalid, or oversized Content-Length closed
without reading the body.
"""

def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
original = super().get_route_handler()

async def handler(request: Request) -> Response:
ctx = get_context(request)
if not ctx.token_is_valid(request.headers.get("authorization")):
raise APIProblem(401, "unauthorized", "A valid bearer token is required.")
reject_oversized_multipart(request, ctx)
return await original(request)

return handler


router = APIRouter(
route_class=_EarlyUploadLimitRoute,
dependencies=[Depends(require_token), Depends(reject_oversized_multipart)],
)


def _audio_suffix(content_type: str | None, filename: str | None) -> str:
normalized = (content_type or "").split(";", maxsplit=1)[0].strip().lower()
if not normalized:
filename_suffix = Path(filename or "").suffix.lower()
for mime, allowed_suffix in ALLOWED_AUDIO_TYPES.items():
if allowed_suffix == filename_suffix:
normalized = mime
break
suffix = ALLOWED_AUDIO_TYPES.get(normalized)
if suffix is None:
raise APIProblem(415, "unsupported_audio_type", "This audio type is not supported.")
return suffix


def _normalize_language(language: str | None) -> str:
value = (language or "").strip()
if not value or value.lower() == "auto":
return "auto"
if len(value) > _MAX_LANGUAGE_LENGTH or _LANGUAGE_PATTERN.fullmatch(value) is None:
raise APIProblem(422, "invalid_language", "Language must be auto or a language tag.")
return value


def _reject_unsupported_format(response_format: str | None) -> None:
if response_format is None:
return
if response_format.strip().lower() in {"", "json"}:
return
raise APIProblem(
400,
"unsupported_response_format",
"Only the json response format is supported.",
)


def _reject_streaming(stream: str | None) -> None:
if (stream or "").strip().lower() in _TRUTHY:
raise APIProblem(
400,
"streaming_not_supported",
"Streaming transcription is not supported on this endpoint.",
)


@router.post("/v1/audio/transcriptions", response_model=OpenAITranscriptionResponse)
async def create_transcription(
file: Annotated[UploadFile, File()],
Comment thread
greptile-apps[bot] marked this conversation as resolved.
model: Annotated[str | None, Form()] = None,
language: Annotated[str | None, Form()] = None,
response_format: Annotated[str | None, Form()] = None,
stream: Annotated[str | None, Form()] = None,
ctx: GatewayContext = Depends(get_context),
) -> OpenAITranscriptionResponse:
# OpenAI clients send `model`. The engine loaded in the WebUI is what runs.
_ = model
try:
if not file.filename:
raise APIProblem(400, "missing_file", "An audio file is required.")
_reject_unsupported_format(response_format)
_reject_streaming(stream)
suffix = _audio_suffix(file.content_type, file.filename)
chosen_language = _normalize_language(language)

upload_dir = ctx.settings.data_dir / "transcriptions"
temporary, final = atomic_upload_path(upload_dir, str(uuid4()), suffix)
received = 0
maximum_upload_bytes = ctx.settings.maximum_upload_bytes
try:
with temporary.open("wb") as output:
while True:
chunk = await file.read(_READ_CHUNK)
if not chunk:
break
received += len(chunk)
if received > maximum_upload_bytes:
raise APIProblem(
413, "audio_too_large", "The recording exceeds the upload limit."
)
output.write(chunk)
if received < 128:
raise APIProblem(422, "audio_empty", "The recording is empty.")
complete_atomic_upload(temporary, final)
except BaseException:
temporary.unlink(missing_ok=True)
raise
try:
result = await ctx.service.transcribe_adhoc(final, chosen_language)
finally:
final.unlink(missing_ok=True)
return OpenAITranscriptionResponse(text=result.transcript)
finally:
await file.close()
4 changes: 4 additions & 0 deletions app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,10 @@ class TestTranscriptionResponse(BaseModel):
peak_memory_mb: float | None


class OpenAITranscriptionResponse(BaseModel):
text: str


class ErrorDetail(BaseModel):
code: str
message: str
Expand Down
17 changes: 17 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ Deprecated CLI console-script aliases (`vocaphone-server`, `vocaphone-token`,
`vocaphone-status`, `vocaphone-diagnostics`, `vocaphone-cleanup`) still resolve to
the same entry points as `vocagateway*` for one cycle; prefer the new names.

## VocaLinux remote_api

A shipped VocaLinux can POST dictation to this gateway over the OpenAI
transcription path. Set the engine to `remote_api`, the server URL to the
gateway origin, the API endpoint to `/v1/audio/transcriptions` (not
`/inference`), and the API key to the gateway bearer token. The model field is
ignored; the WebUI's loaded engine runs.

The phone pairing contract is unchanged. This path does not create a session and
does not stream. Audio still travels to the gateway host, so it is not on-device
processing.

VocaLinux Test Connection is unauthenticated `GET /`, so it can look green with
a bad key. First dictation is the real check. The client timeout is 30 seconds.
Default concurrency is 1 (busy returns 503). LAN HTTP is the gateway default;
HTTPS needs a certificate the desktop OS trusts.

## Related docs

- [README](../README.md) — quick starts and full configuration table
Expand Down
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def __init__(self, transcript: str = "hello from the local model") -> None:
self.transcript = transcript
self.calls = 0
self.health_calls = 0
self.last_options: TranscriptionOptions | None = None

async def health(self) -> EngineHealth:
self.health_calls += 1
Expand All @@ -29,6 +30,7 @@ async def health(self) -> EngineHealth:
async def transcribe(self, audio_path: Path, options: TranscriptionOptions) -> str:
assert audio_path.is_file()
self.calls += 1
self.last_options = options
return self.transcript


Expand Down
Loading