Skip to content

Speech to text and text to speech

hal0 exposes two voice directions through OpenAI-compatible endpoints:

  • Speech-to-text (POST /v1/audio/transcriptions) — whisper-v3:turbo served by FastFlowLM (FLM) on the XDNA NPU, co-loaded with the chat model in one FLM process (the “NPU trio”: chat + STT + embed).
  • Text-to-speech (POST /v1/audio/speech) — served from the single canonical tts slot, which can run either engine: Kokoro-82M ONNX on CPU, or Qwen3-TTS CustomVoice on the GPU (ROCm, native gfx1151). The voice.tts capability picker is an engine switch — choosing a device swaps which provider the tts slot runs, with no separate slot to configure.

Both are children of the voice capability: voice.stt maps to the stt slot and voice.tts maps to the tts slot.

Voice is opt-in. Enable each direction from the dashboard’s capability picker, or by POSTing a partial selection to the capability API. The body accepts any subset of { backend, provider, model, enabled }:

Terminal window
curl -X POST http://localhost:8080/api/capabilities/voice/stt \
-H 'Content-Type: application/json' \
-d '{"enabled": true, "backend": "npu"}'

The response is { "ok": true, "selection": { ...current selection... } }.

voice.tts isn’t a model picker in the usual sense — it’s a switch between two engines that both serve the same tts slot and the same POST /v1/audio/speech contract:

DeviceProviderSlot profileRuns on
cpukokorottsCPU (ONNX)
gpu-rocmqwen3ttstts-qwen3Strix Halo iGPU (ROCm, native gfx1151)

Selecting a device rewrites which profile/provider the tts slot loads — the slot itself doesn’t move. Qwen3-TTS weights are operator-staged (not pulled through the hal0 registry) and the slot mounts a writable cache directory for the MIOpen kernel DB and HuggingFace codec/tokenizer cache.

Enabling voice.stt with backend=npu does not spawn a standalone process. It drives the FLM trio — one flm serve anchor process serving chat, transcription (whisper-v3:turbo), and embeddings together. The orchestrator toggles the anchor’s [npu].asr flag (which renders as --asr 1 on the FLM command line) and writes a type=transcription slot record for dispatch gating. The anchor is not auto-restarted — the response carries pending_reload: true, and the change only takes effect once you reload the FLM anchor yourself (Settings → NPU’s restart control, or hal0 slot restart <anchor-slot>). NPU transcription requires the FLM chat anchor to be loaded.

Send a multipart upload with the audio file and a required model field. The raw multipart bytes are forwarded unchanged to the upstream:

Terminal window
curl -X POST http://localhost:8080/v1/audio/transcriptions \
-F 'file=@recording.wav' \
-F 'model=<your-stt-model>'

The model form field is required; omitting it returns 400 (request.missing_model) rather than a misleading 404.

Send a JSON body with model and input. voice, speed, and response_format are optional — see request defaults below for what happens when you omit them:

Terminal window
curl -X POST http://localhost:8080/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{
"model": "<your-tts-model>",
"input": "Hello from hal0.",
"voice": "<voice-id>"
}' \
--output speech.wav

As with transcription, model is required — a missing or empty model returns 400 (request.missing_model).

The tts slot can carry persisted request defaults that /v1/audio/speech seeds into a request whenever the body omits the matching field — an explicit value in the request always wins:

  • default_voice — voice id injected when the request omits voice. When unset, the request falls through to the serving engine’s own default (Kokoro’s own default is af_bella).
  • default_speed — playback speed (0.254.0) injected when the request omits speed. When unset, the engine default (1.0) applies.
  • default_response_format — one of mp3 / wav / opus / flac / pcm, injected when the request omits response_format. When unset, the engine default (mp3) applies.

These are set from the dashboard’s Settings → Voice panel (which PUTs the tts slot config) and take effect immediately — no container restart. Slot selection for the seed prefers the tts slot bound to the requested model, then the per-type default tts slot, then the seeded tts slot name.

The dashboard’s voice picker also reads a live voice list from GET /api/slots/tts/voices, which proxies the serving engine’s own GET /v1/audio/voices (both Kokoro and Qwen3-TTS expose it). If the tts slot is cold or unreachable, the route fails soft with { "voices": [], "source": "offline" } and the picker falls back to a built-in Kokoro seed list; a previously-saved voice that’s missing from the live list stays selectable.