Start here
Choose one-shot or streaming
Use HTTP one-shot when the complete text is ready. Use WebSocket streaming when text arrives incrementally or playback should begin before generation finishes. Both paths use the same voices, authentication, formats, and audit ledger.
1. Authenticate
Send your workspace API key as a bearer token.
2. Choose a voice
List available Narya voice IDs and supported formats.
3. Synthesize
Use HTTP for one result or WebSocket for streamed chunks.
Before you begin
voice_id. Do not send model IDs, provider names, provider voice references, or routing policies; Narya resolves those server-side.Environment setup
Keep development and production separate
Choose the environment before configuring credentials. The WebSocket endpoint and API key are an inseparable pair.
| Environment | Base URL / WebSocket | Credential boundary |
|---|---|---|
| Development | https://animated-waffle-dev.narya.ai wss://animated-waffle-dev.narya.ai/v1/tts/stream | Development keys and development-approved voices only. |
| Production | https://animated-waffle.narya.ai wss://animated-waffle.narya.ai/v1/tts/stream | Production keys and production-approved voices only. |
NARYA_TTS_BASE_URL, NARYA_TTS_WS_URL, andNARYA_TTS_API_KEY together.Create your environment file
Start with the development pair below, then replace both endpoint and key when Narya approves production access. Discover voices again after switching environments.
# Use URLs and an API key from the same environment.
NARYA_TTS_BASE_URL=https://animated-waffle-dev.narya.ai
NARYA_TTS_WS_URL=wss://animated-waffle-dev.narya.ai/v1/tts/stream
NARYA_TTS_API_KEY=awp_...
# Choose an exact voice from GET /v1/voices. This is a Narya ID, not an
# upstream provider reference. The examples derive its output format at runtime.
NARYA_VOICE_ID=your_narya_voice_idVoice discovery
List the voices you can use
Voice IDs are stable Narya identifiers scoped to your workspace. The response intentionally does not expose upstream providers, models, or provider voice references.
curl --request GET \
--url "$NARYA_TTS_BASE_URL/v1/voices" \
--header "Authorization: Bearer $NARYA_TTS_API_KEY"{
"voices": [
{
"id": "<voice-id-for-your-workspace>",
"name": "Example voice",
"output_formats": ["opus_48000_64", "pcm_16000", "pcm_44100"],
"capabilities": ["tts", "voice_tags"]
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| voices[].id | string | Yes | Pass this Narya-owned value as voice_id to one-shot or streaming TTS. |
| voices[].name | string | Yes | Human-readable display name. |
| voices[].output_formats | string[] | Yes | Formats this voice can deliver without provider-specific substitution. |
| voices[].capabilities | string[] | Yes | Features supported by this exact voice variant. Check for voice_tags before sending expressive cues. |
opus_48000_64 for modern realtime transport when the selected voice lists it. It preserves full-band audio while reducing bandwidth. Use pcm_44100 when the downstream system explicitly requires uncompressed raw PCM, or pcm_16000 for a 16 kHz constraint. The quickstart derives the format from that exact voice and fails when none is available.voice_id. If a voice is absent here, it is not enabled for this key.voices[].id from your authenticated response; never send the placeholder values shown in protocol examples.5-minute quickstart
Make your first request
The examples discover the selected voice, prefer Opus when it supports Opus, and otherwise use its first advertised format before making one TTS request.
Discover
Choose an exact voice id from GET /v1/voices.
Send
Derive its format, then POST the TTS request.
Save
Write the response body as audio bytes.
# Requires jq. Fails if the exact voice is missing or advertises no formats.
output_format="$(curl --fail-with-body --silent --show-error \
--url "$NARYA_TTS_BASE_URL/v1/voices" \
--header "Authorization: Bearer $NARYA_TTS_API_KEY" \
| jq --arg voice_id "$NARYA_VOICE_ID" --exit-status --raw-output '
[.voices[] | select(.id == $voice_id)][0]
| if . == null then error("Selected voice not found")
elif (.output_formats | index("opus_48000_64")) != null then "opus_48000_64"
elif (.output_formats | length) > 0 then .output_formats[0]
else error("Selected voice has no output_formats")
end
')"
test -n "$output_format" || { echo "Could not select an output format" >&2; exit 1; }
curl --fail-with-body --request POST \
--url "$NARYA_TTS_BASE_URL/v1/tts" \
--header "Authorization: Bearer $NARYA_TTS_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"text": "Hello from Narya.",
"voice_id": "'"$NARYA_VOICE_ID"'",
"output_format": "'"$output_format"'"
}' \
--dump-header response-headers.txt \
--output output.audioimport { writeFile } from 'node:fs/promises'
const baseUrl = process.env.NARYA_TTS_BASE_URL
const apiKey = process.env.NARYA_TTS_API_KEY
const voiceId = process.env.NARYA_VOICE_ID
if (!baseUrl || !apiKey || !voiceId) throw new Error('Missing Narya environment variables')
const voicesResponse = await fetch(`${baseUrl}/v1/voices`, {
headers: { Authorization: `Bearer ${apiKey}` },
})
if (!voicesResponse.ok) throw new Error(`Voice discovery failed: ${voicesResponse.status}`)
const { voices } = await voicesResponse.json() as {
voices: Array<{ id: string; output_formats: string[] }>
}
const voice = voices.find((candidate) => candidate.id === voiceId)
if (!voice) throw new Error(`Voice not found: ${voiceId}`)
const outputFormat = voice.output_formats.includes('opus_48000_64')
? 'opus_48000_64'
: voice.output_formats[0]
if (!outputFormat) throw new Error(`Voice has no output formats: ${voiceId}`)
const response = await fetch(`${baseUrl}/v1/tts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: 'Hello from Narya.',
voice_id: voiceId,
output_format: outputFormat,
}),
})
if (!response.ok) throw new Error(`Narya TTS failed: ${response.status} ${await response.text()}`)
await writeFile('output.audio', Buffer.from(await response.arrayBuffer()))
console.log('request', response.headers.get('x-request-id'))
console.log('audit', response.headers.get('x-audit-id'))output.audio. UseX-Audio-Format to choose the decoder or final filename. An HTTP error returns the same structured error fields used by streaming.Framework guide
Use Narya TTS with Pipecat
Use a small TTSService adapter when Pipecat owns transport, turn-taking, interruption, and playback. The adapter below converts Narya audio events into the raw PCM frames Pipecat expects.
1. Request PCM
Ask Narya for pcm_44100 so no compressed decoder sits in the realtime path.
2. Validate delivery
Require event.format to match the sample-rate-qualified PCM format you requested.
3. Match playback
Set PipelineParams.audio_out_sample_rate to the TTS service sample rate.
python -m pip install "pipecat-ai==1.4.0" "websockets>=15,<17"import base64
import json
import uuid
from collections.abc import AsyncGenerator
from pipecat.frames.frames import ErrorFrame, Frame, TTSAudioRawFrame, TTSStartedFrame, TTSStoppedFrame
from pipecat.services.settings import TTSSettings
from pipecat.services.tts_service import TTSService
from websockets.asyncio.client import connect
class NaryaTTSService(TTSService):
def __init__(self, *, endpoint: str, api_key: str, voice_id: str, sample_rate: int = 44100, **kwargs) -> None:
super().__init__(
sample_rate=sample_rate,
settings=TTSSettings(model="narya", voice=voice_id, language=None),
**kwargs,
)
self.endpoint = endpoint
self.api_key = api_key
self.voice_id = voice_id
self.sample_rate = sample_rate
self.partner_session_id = f"pipecat_{uuid.uuid4()}"
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame | None, None]:
yield TTSStartedFrame()
try:
async with connect(
self.endpoint,
additional_headers={"Authorization": f"Bearer {self.api_key}"},
open_timeout=10,
max_size=None,
) as websocket:
await websocket.send(json.dumps({
"event": "start",
"request": {
"partner_session_id": self.partner_session_id,
"context_id": context_id,
"voice_id": self.voice_id,
"output_format": f"pcm_{self.sample_rate}",
},
}))
await websocket.send(json.dumps({"event": "text", "text": text}))
await websocket.send(json.dumps({"event": "stop"}))
async for raw in websocket:
event = json.loads(raw)
if event["event"] == "audio":
expected = f"pcm_{self.sample_rate}"
if event["format"] != expected:
raise ValueError(f"Expected {expected}, got {event['format']}")
yield TTSAudioRawFrame(
audio=base64.b64decode(event["audio"]),
sample_rate=self.sample_rate,
num_channels=1,
context_id=context_id,
)
elif event["event"] == "error":
yield ErrorFrame(error=f"narya_tts_error:{event['code']}")
break
elif event["event"] == "finish":
break
except Exception as error:
yield ErrorFrame(error=f"narya_tts_transport_error:{error}")
yield TTSStoppedFrame()import os
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
# transport, stt, llm, and context_aggregator come from your existing bot.
output_sample_rate = int(os.getenv("NARYA_TTS_SAMPLE_RATE", "44100"))
tts = NaryaTTSService(
endpoint=os.environ["NARYA_TTS_WS_URL"],
api_key=os.environ["NARYA_TTS_API_KEY"],
voice_id=os.environ["NARYA_VOICE_ID"],
sample_rate=output_sample_rate,
)
pipeline = Pipeline([
transport.input(), stt, context_aggregator.user(), llm,
tts, transport.output(), context_aggregator.assistant(),
])
worker = PipelineWorker(
pipeline,
params=PipelineParams(
audio_in_sample_rate=16000,
audio_out_sample_rate=output_sample_rate,
enable_metrics=True,
enable_usage_metrics=True,
),
)TTSAudioRawFrame carries decoded PCM. Acceptpcm_<sample-rate> or inspect a WAV header. Reject barepcm because its sample rate is ambiguous; decode compressed audio before creating the frame.Security
Authentication and access
Authenticate during the WebSocket upgrade. Permanent keys belong only in trusted server environments.
Server-to-server
Send the key as a bearer token in the WebSocket upgrade request. Store it in your secret manager and rotate it through the Agent Dashboard.
Authorization: Bearer awp_...Browser or mobile
Never embed a permanent key in a website or app bundle. Your trusted backend either proxies TTS itself, or mints a short-lived streaming token (below) and hands only that token to the client.
POST /v1/tts/stream-tokens with the workspace key to receive { token, expires_at, websocket_url }. The token is valid for 2 minutes, opens exactly one /v1/tts/streamconnection (as ?token= or a bearer header), and never exposes the permanent key. Browser connections are additionally checked against a platform Origin allowlist — ask Narya to allowlist your web origin before shipping a browser client.Create a production key
- 1Open the production Agent Dashboard and sign in. Creating keys requires a workspace Owner or Admin role.
- 2Select Integrations in the sidebar (under the Config group). The page is titled API keys.
- 3Enter a descriptive Label such as checkout-voice-prod and select Create key.
- 4Copy the full awp_ value from the "Key created. Copy it now, it won't be shown again." banner. Only its hash is retained.
- 5Transfer it through your approved secret-sharing channel and store it server-side. Revoke compromised keys from the Issued keys list; revocation is immediate.
HTTP guide
One-shot TTS
POST one complete text input and receive audio bytes directly in the response body. Use this for notifications, prerecorded prompts, previews, and any request where the full text is already available.
import { writeFile } from 'node:fs/promises'
const baseUrl = process.env.NARYA_TTS_BASE_URL
const apiKey = process.env.NARYA_TTS_API_KEY
const voiceId = process.env.NARYA_VOICE_ID
if (!baseUrl || !apiKey || !voiceId) throw new Error('Missing Narya environment variables')
const voicesResponse = await fetch(`${baseUrl}/v1/voices`, {
headers: { Authorization: `Bearer ${apiKey}` },
})
if (!voicesResponse.ok) throw new Error(`Voice discovery failed: ${voicesResponse.status}`)
const { voices } = await voicesResponse.json() as {
voices: Array<{ id: string; output_formats: string[] }>
}
const voice = voices.find((candidate) => candidate.id === voiceId)
if (!voice) throw new Error(`Voice not found: ${voiceId}`)
const outputFormat = voice.output_formats.includes('opus_48000_64')
? 'opus_48000_64'
: voice.output_formats[0]
if (!outputFormat) throw new Error(`Voice has no output formats: ${voiceId}`)
const response = await fetch(`${baseUrl}/v1/tts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: 'Hello from Narya.',
voice_id: voiceId,
output_format: outputFormat,
}),
})
if (!response.ok) throw new Error(`Narya TTS failed: ${response.status} ${await response.text()}`)
await writeFile('output.audio', Buffer.from(await response.arrayBuffer()))
console.log('request', response.headers.get('x-request-id'))
console.log('audit', response.headers.get('x-audit-id'))| Field | Type | Required | Description |
|---|---|---|---|
| text | string | Conditional | Complete UTF-8 text, at most 20,000 characters. May be empty only when params.voice_tags is present and the selected voice advertises voice_tags. |
| voice_id | string | Yes | Narya voice ID returned by GET /v1/voices. |
| output_format | enum | Yes | A format listed for this voice. |
| partner_session_id | string | No | Optional external correlation ID. Omit it when you do not have one. |
| context_id | string | No | Your turn or playback-context ID. |
| params.speed | positive number | No | Speaking-speed multiplier when supported by the selected voice. |
| params.voice_tags | string[] | No | One or two provider-neutral emotion or nonverbal cues. Send bare values without square brackets. |
response bodyHTTP 200Content-Type andX-Audio-Format to select the decoder or filename.response headersHTTP 200X-Request-Id, X-Audit-Id,X-Audio-Format, and X-Audio-Duration-Ms.retryable value. Do not save an error body as an audio file.Expressive speech
Emotion tags by provider
Send emotion, delivery, and nonverbal cues separately from spoken text through params.voice_tags. Narya converts them to the selected provider's syntax after resolving the voice.
params.voice_tags, without square brackets. Keep using the Narya voice_id; do not send a provider or model override. Check voices[].capabilities forvoice_tags. When a voice does not support tags, Narya removes structured and inline bracket cues before provider dispatch and speaks the remaining text. A tag-only request still returns invalid_request because no speakable text remains.{
"text": "I understand how frustrating this has been.",
"voice_id": "<voice-id-from-get-v1-voices>",
"output_format": "opus_48000_64",
"params": {
"voice_tags": ["empathetic"]
}
}{
"text": "",
"voice_id": "<voice-id-from-get-v1-voices>",
"output_format": "opus_48000_64",
"params": {
"voice_tags": ["panting", "gasping"]
}
}request.params.voice_tags to thestart event. The tags apply to the complete WebSocket request. For tag-only audio, send start with tags and thenstop without a text event.{
"event": "start",
"request": {
"partner_session_id": "conversation_42",
"voice_id": "<voice-id-from-get-v1-voices>",
"output_format": "opus_48000_64",
"params": {
"voice_tags": ["panting"]
}
}
}Fish Audio S2
Narya adds Fish Audio's square brackets server-side. Send only the inner value. S2 accepts concise natural-language descriptions such asvery excited, so this reference is not a fixed enum.
Basic emotions
happysadangryexcitedcalmnervousconfidentsurprisedsatisfieddelightedscaredworriedupsetfrustrateddepressedempatheticembarrasseddisgustedmovedproudrelaxedgratefulcurioussarcasticAdvanced emotions
disdainfulunhappyanxioushystericalindifferentuncertaindoubtfulconfuseddisappointedregretfulguiltyashamedjealousenvioushopefuloptimisticpessimisticnostalgiclonelyboredcontemptuoussympatheticcompassionatedeterminedresignedDelivery & human sounds
softbreathyin a hurry toneshoutingscreamingwhisperingsoft toneemphasislaughingmoaningchucklingsobbingcrying loudlysighinggroaningpantinggaspingyawningsnoringclear throataudience laughingbackground laughtercrowd laughingpauselong pausebreaklong-breakElevenLabs · eleven_v3
ElevenLabs calls these Audio Tags. They are natural-language instructions rather than an enum, and results vary with the selected voice and its training samples. Narya adds the provider syntax; send only the bare tag value and test it before production use.
Voice & emotion
laughslaughs harderstarts laughingwheezingwhisperssighsexhalessarcasticcuriousexcitedcryingsnortsmischievouslySound effects
gunshotapplauseclappingexplosionswallowsgulpsExperimental
strong X accentsingswoofartwaffle 1.0 beta
Emotion tag support for waffle 1.0 beta is not available yet. Narya removes bracket cues before sending text to this model, so the cue is never read aloud by the appliance.
Protocol guide
Connection lifecycle
A WebSocket carries exactly one synthesis request. Control messages are JSON text frames; audio is returned inside JSON events as base64.
- 1Client
connectOpen the production WSS endpoint with the bearer token. A rejected credential receives anunauthorizederror and close code 1008. - 2Client
startDeclare attribution, the Narya voice ID, and output format exactly once. Narya assigns the request ID and returns it on server events. - 3Client
textSend one or more non-empty text segments unless this is a tag-only request. Segments are appended in wire order. - 4Client
flushOptional. Synthesize the current buffer now and keep the request open for more text. - 5Server
audioReceive zero or more ordered chunks. The first sequence number is 1. - 6Client
stopSynthesize any remaining text, or a tag-only request, and finalize the request. - 7Server
usage → finishRead the terminal metadata, then allow the server to close normally. An error replaces finish when the request fails.
start → (text+ → (flush → text+)*)? → stopfinish or error as final. Do not send more control events afterward.Low-latency input
Stream text without hurting prosody
Use flush at natural phrase boundaries when text arrives incrementally. This starts synthesis without ending the request.
{"event":"text","text":"Your order is ready, "}
{"event":"flush"}
{"event":"text","text":"and it will arrive tomorrow."}
{"event":"stop"}Flush complete phrases
Prefer clauses or sentences over individual LLM tokens. Include spaces and punctuation in the text exactly where they should be spoken.
Balance latency and context
Smaller flushes can reduce time to first audio but give the model less linguistic context. Begin with sentence boundaries, then tune using measured playback latency.
- Each
textevent may contain at most 5,000 characters. - The production request limit is 20,000 characters across all text events.
- An empty buffer on
flushis a no-op. stopflushes the remaining buffer or starts tag-only synthesis; a separate final flush is unnecessary.- Open a new WebSocket for the next utterance; Narya assigns it a new request ID.
Playback guide
Handle audio output correctly
Every audio event includes the delivered format. Decode base64, preserve sequence order, and route chunks using the request or context ID.
{
"event": "audio",
"request_id": "rq_018f...",
"context_id": "assistant_turn_7",
"seq": 1,
"audio": "AAABAAIA...",
"format": "pcm_44100"
}| Requested format | Nominal encoding | Typical use |
|---|---|---|
| pcm_16000 | Raw 16-bit PCM · 16 kHz | Explicitly 16 kHz-constrained pipelines |
| pcm_44100 | Raw 16-bit PCM · 44.1 kHz | Raw PCM compatibility and decoder-free pipelines |
| aac_44100_128 | AAC · 44.1 kHz · 128 kbps | Mobile and media pipelines |
| opus_48000_64 | Ogg Opus · 48 kHz · 64 kbps | Recommended default for modern realtime transport |
event.format for every request and reject unexpected values. Narya does not silently substitute a different format for a voice.Playback rules
- WebSocket messages arrive in order; still use
seqto detect missing or duplicated application-level chunks. - Compressed chunks can be concatenated only when your decoder supports the delivered stream format.
- Raw PCM has no container header. Add the appropriate WAV header only when a file or player requires one.
opus_48000_64is the recommended default when the selected voice supports it; decode the delivered Ogg Opus stream at 48 kHz.- Use
pcm_44100for raw PCM compatibility; it is mono, signed 16-bit little-endian at 44,100 Hz. - Treat bare
pcmas invalid because it does not identify a sample rate. Never infer it from the requested format. - Do not mark playback complete when the socket closes; mark synthesis complete on
finish.
Production guide
Production readiness checklist
A production client needs explicit timeout, retry, observability, and playback decisions around the basic protocol.
Secrets
Keep permanent keys in a server-side secret manager; never log or ship them.
Timeouts
Set a connection timeout and a terminal-event timeout appropriate to your utterance length.
Identifiers
Log the returned request_id with partner_session_id, context_id, and audit_id.
Retry budget
Retry only retryable errors with exponential backoff and jitter.
Replay safety
Prevent completed or partially played speech from replaying unintentionally.
Backpressure
Bound decoded audio queues so slow playback cannot grow memory without limit.
Cancellation
Close the WebSocket when the user interrupts; do not automatically replay cancelled speech.
Format validation
Select decoding from event.format and test every format enabled for your workspace.
API reference
Start request
The first control frame declares the complete request configuration. Send start exactly once per connection.
{
"event": "start",
"request": {
"partner_session_id": "conversation_42",
"context_id": "assistant_turn_7",
"voice_id": "<voice-id-returned-by-GET-/v1/voices>",
"output_format": "pcm_44100"
}
}| Field | Type | Required | Description |
|---|---|---|---|
| event | "start" | Yes | Discriminator for the first control event. |
| request.partner_session_id | string | No | Optional external audit correlation ID, 1–200 characters. It never owns billing or idempotency. |
| request.context_id | string | No | Your turn, message, or playback-context ID. Echoed on audio events when supplied. |
| request.voice_id | string | Yes | Narya voice identifier returned by GET /v1/voices. |
| request.output_format | enum | Yes | Requested audio format. See Handle audio output. |
| request.params.speed | positive number | No | Optional speaking-speed multiplier when supported by the selected voice. |
| request.params.voice_tags | string[] | No | One or two request-wide expressive cues, without square brackets. Required when no text event will be sent. |
start. Narya generates a UUID-based ID for every connection and includes it in every audio, usage, finish, or error event. It owns Narya billing and idempotency. Log it for support, but do not supply or reuse it as a client retry key.API reference
Client events
After start, send text and optional flush events, then one stop event. All client messages must be JSON text frames.
text1 or moretext string to the current buffer. Maximum 5,000 characters per event.flushoptionalstopexactly once| Field | Type | Required | Description |
|---|---|---|---|
| text.event | "text" | Yes | Text-event discriminator. |
| text.text | string | Yes | Non-empty UTF-8 text, at most 5,000 characters. |
| flush.event | "flush" | Yes | Flush-event discriminator. No other fields. |
| stop.event | "stop" | Yes | Stop-event discriminator. No other fields. |
API reference
Server events
A successful request emits audio events, then usage and finish. A failed request emits one terminal error instead of finish.
audio0 or moreusageonce on successfinishterminal successerrorterminal failure{
"event": "usage",
"request_id": "rq_018f...",
"usage": { "chars": 48, "audio_ms": 2760 },
"voice_id": "<voice-id-returned-by-GET-/v1/voices>"
}{
"event": "finish",
"request_id": "rq_018f...",
"reason": "stop",
"cost": { "amount": 0.00072, "currency": "USD", "pass_through": true },
"audit_id": "rq_018f...",
"audio_persisted": false
}{
"event": "error",
"request_id": "rq_018f...",
"code": "provider_unavailable",
"retryable": true,
"detail": "The requested voice is temporarily unavailable."
}| Field | Type | Required | Description |
|---|---|---|---|
| audio.seq | integer | Audio | One-based chunk sequence across the complete request, including multiple flushes. |
| audio.audio | base64 string | Audio | Encoded bytes for this chunk. |
| audio.format | string | Audio | Delivered wire format. Use this value to choose the decoder. |
| usage.usage.chars | integer | Usage | Characters synthesized successfully across all segments. |
| usage.usage.audio_ms | integer | Usage | Generated audio duration when measurable for the selected voice. |
| finish.audit_id | string | Finish | Audit correlation ID to include in support requests. |
| finish.audio_persisted | boolean | Finish | Whether Narya retained the generated audio. API v1 normally returns false. |
| error.retryable | boolean | Error | Authoritative signal for whether a new attempt is safe. |
Reliability reference
Errors, retries, and limits
Use the error event for application decisions and the WebSocket close code for transport diagnostics.
| Code | Retry? | Client action |
|---|---|---|
| unauthorized | No | Correct, rotate, or re-scope the credential before reconnecting. |
| invalid_request | No | Fix JSON, event order, configuration, or input limits. |
| provider_auth_missing | No | Contact Narya or use an approved configured fallback. |
| provider_unavailable | If retryable=true | Open a new connection after exponential backoff with jitter. |
| provider_error | If retryable=true | Retry only when explicitly allowed; otherwise escalate with the audit ID. |
| client_cancelled | No automatic retry | Expected after interruption or disconnect; decide from product intent. |
Retry contract
A retry is a new connection, and Narya assigns it a newrequest_id. Keep the samepartner_session_id only when it remains part of the same conversation. Never replay audio after a finish event.
Close codes
1008 means authorization or control-policy failure.1009 means the control frame is too large. A successful request closes normally after finish.
Release history
Changelog
Contract changes are versioned here so integrations can distinguish additive capabilities from migrations that require client work.
v1.4.0
Capability- Added voices[].capabilities so clients can identify exact variants with voice-tag support.
- Made tag compatibility provider-model specific: Fish S2 and Eleven v3 accept tags; Eleven Multilingual v2 and Waffle do not.
- Unsupported variants now omit structured and inline cues before provider dispatch; tag-only requests remain invalid.
v1.3.0
Additive- Added optional params.voice_tags to one-shot and streaming requests.
- Added tag-only synthesis by sending text: "" with at least one voice tag.
- Added server-side provider syntax conversion and explicit model compatibility errors.
v1.2.0
Contract- Made request_id server-owned for every one-shot and streaming request.
v1.1.0
Endpoints- Added POST /v1/tts and GET /v1/voices with provider-neutral voice IDs.
v1.0.0
Initial- Published the initial authenticated WebSocket streaming contract.
Complete example
Node.js / TypeScript
This production-oriented Node.js client adds timeouts, sequence-aware assembly, delivered-format handling, and terminal-state checks.
npm install ws
npm install --save-dev tsx typescript @types/wsimport { randomUUID } from 'node:crypto'
import { writeFile } from 'node:fs/promises'
import WebSocket from 'ws'
const required = (name: string) => {
const value = process.env[name]
if (!value) throw new Error(`Missing environment variable: ${name}`)
return value
}
const baseUrl = required('NARYA_TTS_BASE_URL')
const apiKey = required('NARYA_TTS_API_KEY')
const voiceId = required('NARYA_VOICE_ID')
const voicesResponse = await fetch(`${baseUrl}/v1/voices`, {
headers: { Authorization: `Bearer ${apiKey}` },
})
if (!voicesResponse.ok) throw new Error(`Voice discovery failed: ${voicesResponse.status}`)
const { voices } = await voicesResponse.json() as {
voices: Array<{ id: string; output_formats: string[] }>
}
const voice = voices.find((candidate) => candidate.id === voiceId)
if (!voice) throw new Error(`Voice not found: ${voiceId}`)
const selectedFormat = voice.output_formats.includes('opus_48000_64')
? 'opus_48000_64'
: voice.output_formats[0]
if (!selectedFormat) throw new Error(`Voice has no output formats: ${voiceId}`)
const ws = new WebSocket(required('NARYA_TTS_WS_URL'), {
headers: { Authorization: `Bearer ${apiKey}` },
handshakeTimeout: 10_000,
})
const chunks = new Map<number, Buffer>()
let format = selectedFormat
let completed = false
const timeout = setTimeout(() => ws.terminate(), 30_000)
ws.on('open', () => {
ws.send(JSON.stringify({
event: 'start',
request: {
partner_session_id: process.env.NARYA_PARTNER_SESSION_ID ?? `session_${randomUUID()}`,
context_id: 'assistant_turn_1',
voice_id: voiceId,
output_format: format,
},
}))
ws.send(JSON.stringify({ event: 'text', text: 'Hello from Narya streaming TTS.' }))
ws.send(JSON.stringify({ event: 'stop' }))
})
ws.on('message', async (raw) => {
const event = JSON.parse(raw.toString())
if (event.event === 'audio') {
chunks.set(event.seq, Buffer.from(event.audio, 'base64'))
format = event.format
} else if (event.event === 'error') {
throw new Error(`${event.code}: ${event.detail}`)
} else if (event.event === 'finish') {
const audio = [...chunks.entries()].sort(([a], [b]) => a - b).map(([, bytes]) => bytes)
const suffix = format.startsWith('opus') ? 'opus' : format.startsWith('aac') ? 'aac' : 'pcm'
await writeFile(`output.${suffix}`, Buffer.concat(audio))
completed = true
clearTimeout(timeout)
console.log('request', event.request_id, 'audit', event.audit_id)
ws.close()
}
})
ws.on('close', () => {
clearTimeout(timeout)
if (!completed) process.exitCode = 1
})NARYA_TTS_BASE_URL=https://animated-waffle-dev.narya.ai \
NARYA_TTS_WS_URL=wss://animated-waffle-dev.narya.ai/v1/tts/stream \
NARYA_TTS_API_KEY=awp_... \
NARYA_VOICE_ID=your_narya_voice_id \
npx tsx stream.ts