---
title: Voice API
path: reference/api/voice
status: published
---

```json frontmatter
{
  "title": "Voice",
  "audience": "developer",
  "summary": "Voice catalog, TTS synthesis, STT transcription, voice sessions, and recording state.",
  "sort_order": 12
}
```

# Voice API

Endpoints for ScaiWave's voice features: voice catalog, text-to-speech,
speech-to-text, voice call sessions, and on/off-the-record state.

---

## Voice catalog

### `GET /v1/voices`

List voices the caller can see (filtered by the tenant allowlist
when one is active).

**Query params:**
- `refresh` (bool, default false) — bypass the 15-min server cache.

**Response:**
```json
{
  "data": [
    {
      "voice_id": "vc_abc",
      "display_name": "Brand Voice",
      "language_primary": "en",
      "language_supported": ["en", "nl", "de"],
      "gender_hint": "female",
      "embedding_status": "ready",
      "scope": "tenant"
    }
  ],
  "meta": {
    "permission_denied": false,
    "count": 5,
    "allowlist_active": true,
    "default_voice_id": "vc_abc",
    "user_voice_id": "vc_xyz"
  }
}
```

When `permission_denied` is true, the user's role doesn't include
`scaispeak:voice.read` — render an "ask your admin" empty state.

---

## TTS — Speak text

### `POST /v1/tts/speak`

Synthesize text and return audio bytes inline.

**Body:**
```json
{
  "text": "Hello, how are you?",
  "voice_id": "vc_abc",
  "speed": 1.0,
  "response_format": "mp3",
  "instructions": "cheerful",
  "normalize_text": true,
  "language_hint": "en"
}
```

Only `text` is required. `voice_id` defaults through the resolution
chain (user pref → tenant default → first available).

**Response:** Raw audio bytes with `Content-Type: audio/mpeg` (or
whatever format was requested). Headers:
- `X-ScaiWave-Voice-Id` — the voice actually used.
- `X-ScaiWave-Audio-Duration-Ms` — clip length.

Long text (>500 chars) goes through ScaiSpeak's async job path
internally — the endpoint blocks until done (~30s max). The client
gets audio back either way.

**Errors:**
| Code | Status | Meaning |
|---|---|---|
| `SW_VOICE_PERMISSION_DENIED` | 403 | Missing `scaispeak:synthesize` |
| `SW_VOICE_NO_VOICE_AVAILABLE` | 404 | No usable voice in catalog |
| `SW_VOICE_BACKEND_UNAVAILABLE` | 503 | TTS engine down |
| `SW_VOICE_EMPTY_TEXT` | 400 | Text was empty |

---

## STT — Transcribe audio

### `POST /v1/stt/transcribe`

Transcribe a short audio clip.

**Body:** `multipart/form-data`
- `file` — WAV audio (16 kHz mono PCM required)
- `language_hint` (optional) — ISO 639-1

**Response:**
```json
{
  "data": {
    "text": "Hello, how are you?",
    "language_detected": "en",
    "duration_ms": 2300,
    "segments": [
      {"text": "Hello,", "start_s": 0.0, "end_s": 0.8, "confidence": 0.95},
      {"text": "how are you?", "start_s": 0.8, "end_s": 2.3, "confidence": 0.92}
    ]
  }
}
```

Max upload: 8 MiB. Clips longer than ~5 min go through ScaiEcho's
async job path (transparent to the caller).

---

## User voice preference

### `GET /v1/users/me/voice-preference`

Read the user's voice preferences.

**Response:**
```json
{
  "data": {
    "voice_id": "vc_abc",
    "voice_style": "warm and professional, calm pace",
    "voice_speed": 1.2
  }
}
```

All fields may be `null` if the user has not set a preference.

### `PUT /v1/users/me/voice-preference`

Update the user's voice preferences. All fields are optional — only
the fields present are written (partial update).

**Body:**
```json
{
  "voice_id": "vc_abc",
  "voice_style": "warm and professional, calm pace",
  "voice_speed": 1.2
}
```

| Field | Type | Description |
|---|---|---|
| `voice_id` | string \| null | Default voice. `null` to clear (falls back to tenant default). |
| `voice_style` | string \| null | Free-text TTS style instructions passed to ScaiVoice session create (e.g. "warm and professional, calm pace"). `null` to clear (uses ScaiVoice default). |
| `voice_speed` | float \| null | TTS playback speed, clamped to 0.5–2.0. `null` to clear (uses 1.0). |

Preferences are stored in the user's `notification_prefs` JSON
(no migration needed).

---

## Tenant voice config (admin)

### `GET /v1/admin/voice-config`

Read the tenant's voice allowlist + default. Returns the full
unfiltered catalog for admin selection.

### `PUT /v1/admin/voice-config`

Update allowlist and/or default. Partial — only the fields present
are written.

**Body:**
```json
{
  "allowed_voice_ids": ["vc_abc", "vc_def"],
  "default_voice_id": "vc_abc"
}
```

Set `allowed_voice_ids` to `null` to clear the allowlist (all
voices visible). Set to `[]` to hide all voices from non-admins.

---

## Voice recording state

### `GET /v1/rooms/{room_id}/voice-recording`

Current on/off-the-record state for the room's voice call.
Defaults to `true` (on-record).

### `PUT /v1/rooms/{room_id}/voice-recording`

Flip the on/off-the-record flag. Requires room membership.

**Body:**
```json
{"on_the_record": false}
```

Broadcasts `swp.room.voice_recording` on the room's WS channel so
all connected clients update live.

---

## Voice sessions (internal)

### `POST /v1/voice-sessions`

Create a voice session (called by the voice bot worker, not directly
by browser clients). See [Voice mode architecture](/docs/scaiwave/concepts/voice-mode)
for the full flow.

### `POST /v1/voice-agent/turn`

ScaiVoice cognition callback — server-to-server, authenticated via
HMAC session token. Not callable by browser clients.

---

## WebSocket events

| Event | When |
|---|---|
| `swp.room.voice_recording` | On/off-record flag changed (user toggle or LLM marker) |
| `swp.room.message` (msgtype `swp.voice_transcript`) | User's speech transcribed and persisted (on-record) |
| `swp.room.message` (msgtype `swp.voice_reply`) | AI's voice reply persisted (on-record) |
