Speech Server
Native Apple runtime · PocketTTS
PocketTTS adds native CoreML speech synthesis to speech-swift. All stages default to CPU-only execution. The English Alba preset streams 1,920-sample frames at 24 kHz (80 ms per frame).
Use pocket-tts with voice alba. Streaming HTTP requires PCM; WebSocket emits audio deltas. The existing default models are unchanged.
curl http://localhost:8080/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{"model":"pocket-tts","voice":"alba","input":"Hello world.","response_format":"pcm","stream":true}' \
-o hello.pcmspeech-server is a local HTTP + WebSocket server that exposes every Soniqo model through a simple REST API, plus an OpenAI Realtime API-compatible WebSocket at /v1/realtime. It ships in the same Homebrew bottle as the speech CLI — brew install speech drops both on your PATH.
Install and run
brew install speech
speech-server --port 8080
# Starting server on http://127.0.0.1:8080
# Endpoints:
# POST /transcribe - Speech-to-text (WAV body or JSON with audio_base64)
# POST /speak - Text-to-speech (JSON: {text, engine?, language?})
# POST /respond - Speech-to-speech (WAV body, voice/max_steps via query)
# POST /enhance - Speech enhancement (WAV body)
# GET /health - Health check
# WS /v1/realtime - OpenAI Realtime API (JSON events, base64 PCM16 audio)
Command-line flags
| Flag | Default | Description |
|---|---|---|
--host | 127.0.0.1 | Bind address. Change to 0.0.0.0 to expose on LAN. |
--port | 8080 | TCP port. |
--preload | off | Load all models eagerly at startup. Slower boot (~30–60 s) but zero first-request latency. |
Models are downloaded on first use and cached in ~/Library/Caches/qwen3-speech/. The first request for a given model pays the download + load cost (30 s – 2 min depending on model size); subsequent requests are warm.
REST endpoints
POST /transcribe — Speech-to-text
Accepts either a raw WAV body or a JSON envelope with base64-encoded audio.
# WAV body (preferred — lower overhead)
curl -X POST http://127.0.0.1:8080/transcribe \
-H "Content-Type: audio/wav" \
--data-binary @recording.wav
# JSON with base64
curl -X POST http://127.0.0.1:8080/transcribe \
-H "Content-Type: application/json" \
-d '{"audio_base64":"'"$(base64 -i recording.wav)"'","language":"en"}'
Response: {"text": "…", "language": "en", "confidence": 0.93}.
POST /speak — Text-to-speech
curl -X POST http://127.0.0.1:8080/speak \
-H "Content-Type: application/json" \
-d '{"text":"Hello, world!","engine":"kokoro","language":"en"}' \
--output hello.wav
Response body is a WAV blob. Supported engine values: qwen3 (default), cosyvoice, kokoro.
POST /respond — Speech-to-speech
curl -X POST "http://127.0.0.1:8080/respond?voice=en_female_calm&max_steps=256" \
-H "Content-Type: audio/wav" \
--data-binary @question.wav \
--output answer.wav
Runs PersonaPlex 7B — speech in, speech out. Transcript is returned as an X-Response-Text header. See PersonaPlex guide for voice preset names.
POST /enhance — Speech enhancement
curl -X POST http://127.0.0.1:8080/enhance \
-H "Content-Type: audio/wav" \
--data-binary @noisy.wav \
--output clean.wav
DeepFilterNet3 at 48 kHz. Input is resampled if needed.
GET /health — Liveness probe
curl http://127.0.0.1:8080/health
# {"status":"ok"}
The HTTP and realtime WebSocket tests wait for /health to return HTTP 200 with {"status":"ok"}, with a 10-second startup deadline. Startup errors fail test setup, and teardown awaits server shutdown before reusing its port.
WebSocket: /v1/realtime
Drop-in compatible with the OpenAI Realtime API — the same JSON event schema (session.update, input_audio_buffer.append, response.create, response.audio.delta, …) with base64-encoded PCM16 audio at 24 kHz. Clients written against OpenAI's Realtime SDK work against speech-server without code changes (just switch the WebSocket URL).
Automatic turns with server VAD
Manual input_audio_buffer.commit remains the default. Set session.turn_detection to server_vad to run Silero VAD on the server and automatically transcribe each completed speech turn. Prefix audio is retained, idle buffering stays bounded, and a continuously active turn is force-closed at the configured maximum.
ws.send(JSON.stringify({
type: "session.update",
session: {
turn_detection: {
type: "server_vad",
threshold: 0.5,
prefix_padding_ms: 300,
silence_duration_ms: 500,
max_turn_duration_ms: 120000
}
}
}));
// Event order for each detected turn:
// input_audio_buffer.speech_started
// input_audio_buffer.speech_stopped
// input_audio_buffer.committed
// conversation.item.input_audio_transcription.completed
// Set turn_detection to null (or { type: "none" }) for manual commits.
JavaScript example
const ws = new WebSocket("ws://127.0.0.1:8080/v1/realtime");
ws.onopen = () => {
ws.send(JSON.stringify({
type: "session.update",
session: { modalities: ["audio", "text"] }
}));
// Stream PCM16 mono 24kHz audio from the mic:
const audioBase64 = await capturePCM16Chunk();
ws.send(JSON.stringify({
type: "input_audio_buffer.append",
audio: audioBase64
}));
ws.send(JSON.stringify({ type: "response.create" }));
};
ws.onmessage = ev => {
const msg = JSON.parse(ev.data);
if (msg.type === "response.audio.delta") {
playPCM16Base64(msg.delta);
}
};
Python example
import asyncio, base64, json, wave, websockets
async def main():
async with websockets.connect("ws://127.0.0.1:8080/v1/realtime") as ws:
await ws.send(json.dumps({"type": "session.update",
"session": {"modalities": ["audio", "text"]}}))
with wave.open("question.wav", "rb") as wav:
pcm16 = wav.readframes(wav.getnframes())
await ws.send(json.dumps({"type": "input_audio_buffer.append",
"audio": base64.b64encode(pcm16).decode()}))
await ws.send(json.dumps({"type": "response.create"}))
async for raw in ws:
msg = json.loads(raw)
if msg["type"] == "response.audio.delta":
open("answer.pcm", "ab").write(base64.b64decode(msg["delta"]))
elif msg["type"] == "response.done":
break
asyncio.run(main())
Deployment notes
- No auth by default.
speech-serverbinds127.0.0.1and trusts every caller. Put it behind a reverse proxy (Caddy, nginx, tailscale) if you expose it beyond localhost. - Models are lazy-loaded. First request for
/transcribetriggers the ASR download + load (~700 MB, 30–60 s). Use--preloadfor zero cold-start but slower boot. - No streaming transcription yet.
POST /transcribeexpects the full WAV up front. Use/v1/realtimefor streaming. - Systemd / launchd. No service unit ships today. A plain
nohup speech-server &or your usual process supervisor works.
Source
- Sources/AudioServer — Hummingbird-based HTTP router, lazy model registry,
/v1/realtimeWebSocket handler. - Sources/AudioServerCLI —
@mainentry point, argument parser. - Upstream: OpenAI Realtime API reference.
Local native inference
For 12B, repeated adjacent tool-call closing markers immediately after a complete, validated call are consumed with that call. They do not create another call or change its arguments. Standalone markers and malformed arguments remain errors; E4B retains strict handling.
To retain failed native chat requests, set failureCaptureDirectory in the inference manifest. Capture is off by default. The directory must deny group and other access; new directories use 0700 and failure files use 0600. Files include the exact request body, request ID and error. Completed 12B generations also retain the rendered prompt, token IDs, raw output and stop reason before parsing. E4B failures have no raw generation record. These local files contain conversation and tool content; HTTP errors and successful requests do not include them.
Optional agentDirectory loads a staged Gemma 4 12B Unified INT4 text model alongside E4B. Use model: "gemma4-12b-it" for chat with native tools, result history, and cancellation. E4B remains available for other requests. Gemma4UnifiedChat uses the pinned MLX text implementation and releases each request’s KV cache. Response parsing removes channel delimiters from returned text. Orphaned tool-call terminators are rejected as malformed responses. Failed non-streaming requests log their request ID, operation, and underlying error in the server process. For Gemma 4 12B, tool-message content preserves JSON as text, matching the checkpoint chat template.
Source builds accept --inference-manifest models.json to load staged Gemma 4 E4B INT4, VoxCPM2, full Whisper Large-v3, and Silero VAD. This service starts offline and rejects incompatible model identities.
The native service serializes chat, synthesis, and transcription; lightweight VAD has separate stream state. Gemma prefill evaluates each 256-token batch before starting the next, request KV state is released, and the MLX cache is capped at 128 MiB with a 20 GiB allocator limit.
Speech with stream: true returns NDJSON audio events at 48 kHz, followed by done or error. Cancel by requestID at /v1/inference/cancel. GET /v1/inference/capabilities and /v1/inference/provenance report model identities, parameter dtypes, asset pins, and memory counters.
speech-server --inference-manifest models.json --port 8080{
"runtimeRevision": "your-source-revision",
"gemmaDirectory": "/models/gemma4-e4b-it-int4",
"voxDirectory": "/models/voxcpm2-unquantized",
"whisperDirectory": "/models/whisper-large-v3-coreml",
"vadDirectory": "/models/silero-vad-coreml"
}Optional model ownership
SpeechRuntime provides shared loading, cancellable FIFO admission and serialized execution. Qwen3ASRRuntime selects MLX or full CoreML through explicit descriptors; native APIs and CLI flags remain available.
The audio server holds one execution permit through each complete request, including streamed output, with up to 32 requests waiting. ASR failures produce HTTP errors or WebSocket error events; valid empty transcripts remain successful. Playback preserves overflow samples. Cached models and pending playback audio still occupy memory.
Voxtral TTS
Voxtral 4B TTS 2603 runs locally on Apple Silicon through the VoxtralTTS module: 20 preset voices, nine languages, and 24 kHz mono speech.
curl http://localhost:8080/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{"model":"voxtral-tts","input":"Hello, welcome to Voxtral.","voice":"casual_male","response_format":"wav"}' \
-o hello.wavPreset voices only; reference-audio cloning is unavailable. Audio output streams through the SDK, CLI, WebSocket and HTTP PCM. Supply the complete input text up front. Model weights use CC BY-NC 4.0.
Gemma 4 12B prompt prefill
The native inference service loads the staged Gemma 4 12B Unified INT4 model using agentDirectory. Select it for chat with the model ID gemma4-12b-it.
Gemma 4 12B prefill fills KV state in batches of up to 256 tokens and computes the first sampling logits from the final prompt token. Intermediate vocabulary projections are skipped. The request’s KV cache is released after completion or cancellation.
One unchanged system/tool prefix can remain cached in complete 256-token blocks. Each request extends a separate working copy. User messages, conversation history, and tool results never enter the retained snapshot. Set SPEECH_SWIFT_GEMMA4_PREFIX_CACHE_MB to limit retained MiB, or 0 to disable reuse. clearPrefixCache() releases the snapshot. HTTP reports reused tokens in cachedPrefixTokens; provenance records gemma4_12b_prefix_cache_budget_bytes. Cold requests still process the full prompt.
Native Gemma tool history keeps tool responses and an assistant continuation inside the same model turn. A following user message closes that model turn before opening the next user turn. Send each result with its original toolCallID.