Camlin Speech API

Text-to-Speech — Camlin Voice

Camlin Voice is the synthesis half of Camlin Speech. It does two things in one call: it returns spoken audio, and it returns the lip-sync data to put a face on that audio. The voices are Australian because the product is Australian — recorded in our Sydney studio, tuned for the vocabulary our callers actually use, with difficult words pronounced from a hand-checked dictionary rather than a guess. The API reference is first; the avatar side gets the long treatment in Driving an avatar.

Endpoints

POST/api/v1/tts/synthesize
GET/api/v1/tts/audio/{token}.wav
GET/api/v1/tts/voices
GET/api/v1/tts/healthz

Every example on this page was run against the live service before it was written down.

Authentication

The launch mechanism is API keys (cs_live_… production, cs_test_… sandbox), sent as Authorization: Bearer cs_live_… or the x-api-key alias. Keys are scoped per product — tts for synthesis and viseme for the lip-sync track, which is sold and metered as its own engine — with per-key token-bucket rate limits (RateLimit-* response headers, 429 + Retry-After when exceeded). Auth is live (enforced since 5 August 2026). The full scope table, retention commitments, and enablement status are documented on the Speech-to-Text page.

  • Without the viseme scope, blend_data/blendData come back as empty arrays (an explicit "visemes": true is refused with 403).
  • x-camlin-tts-backend, backend selections that route to third-party providers, and debug: true require the ops scope.
  • Voices, healthz, and audio-token fetches need no key. Everything else does — there is no anonymous demo tier; synthesis without a key is refused with 401 {"detail": "API key required; send Authorization: Bearer cs_…"}.
  • Retention: usage metadata 90 days; synthesised audio lives only in the short-lived token store, and neither your text nor the audio is retained beyond that unless your tenant opts in.

Synthesize Speech

One request, one clip. There is no streaming synthesis today — the response arrives when the whole clip is rendered, which for a typical one- or two-sentence prompt is a couple of seconds. If you are building an IVR or agent that speaks in turns, synthesise per sentence rather than per paragraph: you get audio on the caller's ear sooner and the viseme payloads stay small.

json
{
  "text": "Thanks for calling Camlin. How can I help today?",
  "voice": "camlin-jeremy-au-male-v2",
  "language": "en-AU",
  "visemes": true,
  "options": {
    "length_scale": 1.0,
    "sentence_silence": 0.25,
    "viseme_fps": 30
  }
}
FieldRequiredDescription
textYesPlain text or speech markup (below). Limit: 10,000 characters — but see the per-sentence advice above before you go anywhere near it.
voiceNoVoice ID from /api/v1/tts/voices. Defaults to the production voice (camlin-jeremy-au-male-v2 at the time of writing — confirm via /api/v1/tts/healthz). The voice selects the engine family; you do not need to send backend.
languageNoLanguage hint. Use en-AU unless directed otherwise.
visemesNoLip-sync track control. Default: included when your key carries the viseme scope. false skips it for a smaller payload; explicit true without the scope is a 403.
backendNoEngine family assertion. Omit for normal production traffic — the voice id already selects the family, and a backend that disagrees with the voice is a 400 naming the family the voice actually lives on. Third-party-provider backends require the ops scope.
optionsNoPer-request voice controls, below. All optional; the defaults are the ones the demos use.

Voice Options

The four controls worth knowing are pace, sentence pause, volume, and the viseme frame rate. The two noise scales exist for repeatability tuning and most integrations never touch them.

OptionRangeEffect
length_scale0.6..1.6Pace. Lower is faster; higher is slower. 1.0 is the recorded pace.
sentence_silence0..2Pause in seconds after sentence boundaries. 0.25 reads naturally for prompts; go higher for menus a caller has to keep up with.
volume0.2..2.0Output volume multiplier.
viseme_fps15..60Frame rate for the lip-sync track. The service renders at 60 fps; lower values decimate the stream. At 60 fps the track is roughly 700 KB of JSON per minute of audio, so web avatars typically want 24–30 — visually indistinguishable at half the weight.
noise_scale0.1..1.2Voice variation. Lower is steadier, higher is livelier.
noise_w_scale0.1..1.4Pronunciation and timing variation. Lower is more repeatable across renders of the same text.

Speech Markup

text accepts inline speech markup as well as plain text. A full <speak> document works, and bare markup fragments are accepted too — the service wraps them automatically.

json
{ "text": "Your file is ready.<break time=\"700ms\"/> Shall I continue?" }
MarkupEffect
<break time="700ms"/>Inserts an exact pause, anywhere in a sentence. Pauses of 250 ms or more are spliced precisely; shorter ones read like a comma.
<say-as interpret-as="characters">QF12</say-as>Spells a code out — letters as letters, digits one at a time. "digits" works as an alias.
<say-as interpret-as="time">14:30</say-as>Reads a clock time naturally — "two thirty PM". 24-hour and am/pm forms both work; times in plain text are picked up automatically too.
<say-as interpret-as="date">15/04/2026</say-as>Reads a date day-first (Australian convention) — "the fifteenth of April twenty twenty-six". The year is optional; use format="mdy" or "ymd" for other orders, and ISO 2026-04-15 is recognised automatically.
<say-as interpret-as="currency">$1,423.17</say-as>Reads dollars and cents; the $ is optional inside the tag.
<say-as interpret-as="telephone">0412 345 678</say-as>Reads AU numbers in standard groups; anything else digit-by-digit in its written groups. "phone" works as an alias.
<say-as interpret-as="cardinal|ordinal">42</say-as>Reads the number in the named style ("forty-two" / "forty-second"). "number" is an alias for cardinal.
<say-as interpret-as="…anything else…">Values we do not render (unit, fraction, …) are spoken as ordinary prose — never spelled out letter by letter, never an error.
<sub alias="Service New South Wales">Service NSW</sub>Speaks the alias in place of the wrapped text.
<phoneme alphabet="ipa" ph="kəˈnaʊndɹə">Canowindra</phoneme>Speaks the wrapped word with the exact IPA pronunciation given. Overrides the built-in pronunciation dictionary. alphabet="ipa" is required.
<prosody>, <voice>Accepted for compatibility but not rendered by the current production voices.
  • Plain dollar amounts, dates, clock times, phone numbers, and acronyms are normalised automatically even without markup; the tags exist for the cases prose leaves ambiguous (a date with no year, a bare amount with no $).
  • Malformed markup returns 400 with a parse message; it never produces a broken clip.
  • A bare & inside markup is tolerated and escaped for you.
  • Lip-sync frames stay aligned when markup is used, including across inserted pauses.

Headers

HeaderRequiredDescription
Authorization: Bearer cs_…Yes (keyed callers)Your API key; x-api-key is an accepted alias.
Content-Type: application/jsonYesJSON request body.
x-camlin-tts-voiceNoOverrides voice for this request.
x-camlin-tts-languageNoOverrides language for this request.
x-camlin-tts-backendNoOperational override; requires the ops scope. Omit for normal production traffic.

Header values win over body values when both are supplied.

Response Schema

Verified against the live service, 5 Aug 2026 (blend_data truncated — the real response has one frame per grid instant for the full clip):

json
{
  "text": "Thanks for calling Camlin. How can I help today?",
  "voice": "camlin-jeremy-au-male-v2",
  "backend": "camlin-neural",
  "audio_url": "/api/v1/tts/audio/N0nrZasMN5LejSmzuTd9hw.wav",
  "audio_format": "wav",
  "audio_sample_rate": 22050,
  "audio_duration_s": 3.808,
  "blend_data": [
    { "audioOffset": 0.0, "blendshapes": [0.0, 0.0, "… 52 floats …"] },
    { "audioOffset": 33.333, "blendshapes": [0.0, 0.0, "… 52 floats …"] }
  ],
  "blendData": [ "… same frames, camel-case mirror …" ],
  "viseme_format": "arkit-52@30fps",
  "timing": { "source": "estimated" }
}
FieldTypeDescription
audio_urlstringRelative URL for generated WAV bytes. Resolve against CAMLIN_SPEECH_URL and fetch promptly — tokens are short-lived.
audio_sample_ratenumberSample rate in Hz. Varies by voice (the Camlin voices currently emit 22,050 Hz) — read it from the response rather than assuming.
audio_duration_snumberClip length in seconds.
blend_dataarrayLip-sync frames using { audioOffset, blendshapes }. Empty without the viseme scope or with "visemes": false.
blendDataarrayCamel-case mirror of blend_data for browser clients. Same frames; use whichever suits your casing convention.
viseme_formatstringSelf-describing track tag, arkit-52@{fps}fps. Present only when the track shipped.
timingobjectWhere frame timing came from: source is "model" (engine-predicted phoneme durations) or "estimated" (text heuristic scaled to the clip length). Voices with a duration model also include phonemes as { symbol, start_ms, end_ms } ARPAbet spans ("_" is silence). See Timing fidelity under Driving an avatar.
backendstringOperational route used by the service (camlin and camlin-neural are the first-party families). Diagnostics only — do not branch user logic on it.

Fetch Audio

bash
curl "$CAMLIN_SPEECH_URL/api/v1/tts/audio/N0nrZasMN5LejSmzuTd9hw.wav" --output out.wav

Audio tokens live for minutes, not days. Fetch the WAV as soon as you have the response; if you need the clip later, store it yourself.

List Voices

bash
curl "$CAMLIN_SPEECH_URL/api/v1/tts/voices"
json
{
  "voices": [
    { "id": "camlin-jeremy-au-male-v2", "backend": "camlin-neural", "locale": "en-AU", "gender": "male", "label": "Jeremy" }
  ]
}

Abbreviated — the live list is longer. Use id as the request voice. Labels are display text and may change.

Keith and Jeremy are the first-party Camlin voices — recorded in our Sydney studio, tuned for Australian contact-centre vocabulary, and the ones the viseme timing work is built around. The live catalogue also lists en-AU-*Neural entries — hosted third-party voices kept for breadth (female coverage in particular) while the first-party female corpus is in production.

The catalogue is the source of truth and varies by deployment: voices whose model weights are not present on the serving deployment are filtered out rather than advertised, so every listed voice is synthesisable. Fetch /api/v1/tts/voices at startup instead of hardcoding voice IDs.

Errors

StatusMeaningTypical Fix
400Empty text, unsupported voice, unsupported override, out-of-range option, or markup that failed to parse.Validate text length, voice ID, and option ranges; the response body names the offending field.
401Missing, malformed, revoked, or expired API key.Check the Authorization header; rotate the key if revoked.
403Key lacks a required scope (tts, viseme) or an operator-only feature was requested.Ask Camlin to add the scope, or drop the override/debug.
404Audio token not found — usually fetched too late.Re-synthesize; fetch the audio as soon as the response arrives.
429Rate limit exceeded for the key.Honour Retry-After; request a higher tier if sustained.
503Requested route unavailable or not configured.Retry with default routing or use queued fallback.
500Synthesis failed unexpectedly.Retry once, then capture request context for support.

Implementation Recipe

javascript
export async function synthesizeSpeech(text, { voice = "camlin-jeremy-au-male-v2" } = {}) {
  const res = await fetch(`${process.env.CAMLIN_SPEECH_URL}/api/v1/tts/synthesize`, {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.CAMLIN_API_KEY}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      text,
      voice,
      language: "en-AU",
      options: { sentence_silence: 0.25, viseme_fps: 30 },
    }),
  });
  if (!res.ok) throw new Error(`tts failed: HTTP ${res.status}`);
  const clip = await res.json();

  const wavRes = await fetch(`${process.env.CAMLIN_SPEECH_URL}${clip.audio_url}`);
  const wavBytes = await wavRes.arrayBuffer();
  return { clip, wavBytes, lipSync: clip.blendData ?? clip.blend_data ?? [] };
}

Driving an Avatar

This is the part of the API that puts a face on the voice, so it gets the long treatment.

What the viseme channel is

Every synthesis response can carry a second track alongside the audio: blend_data, a time-series of facial poses that make an avatar's mouth move with the speech. Each frame is a 52-float activation vector in ARKit's canonical blendshape order — the de-facto industry format, consumed as-is by ARKit face rigs, most GLB/VRM avatars, Unreal Engine morph targets, and the major hosted viseme toolchains. If your renderer speaks ARKit blendshapes, it speaks this.

json
{ "audioOffset": 16.667, "blendshapes": [0.0, 0.03, 0.01] }
  • audioOffset is milliseconds from the start of the WAV.
  • Each of the 52 values is an activation in 0..1.
  • Frames are emitted on a fixed grid — 60 fps natively (one frame every 16.67 ms), covering the full duration of the clip.

The track is not a by-product; it is generated from the same phoneme timeline as the speech, so a <break time="700ms"/> in your markup closes the mouth for exactly 700 ms.

Channel map

Index rangeChannelsDriven by this stream
0–13eye blink / look / squint / wideNo — always 0.0
14–17jawForward, jawLeft, jawRight, jawOpenYes
18–40mouth (close, funnel, pucker, left/right, smile, frown, dimple, stretch, roll, shrug, press, lowerDown, upperUp)Yes
41–45browsNo — always 0.0
46–48cheeksNo — always 0.0
49–51nose / tongueNo — always 0.0

Only jaw and mouth channels (14–40) are ever non-zero. That is a deliberate contract, not an omission: expression channels (eyes, brows, cheeks) are reserved for a separate emotion/mood layer so a renderer can mix the two streams — speech from us, mood from your application — without them fighting over the same channels. Blinking and gaze are yours to animate; if we wrote them too, every avatar would blink on our schedule.

Staying in sync with the audio

The one rule: the audio clock is the truth. Drive frame selection from your audio element or audio buffer's playback position, not from a timer you start when playback "should" begin. Autoplay gates, buffering, and device latency all shift the real start by tens of milliseconds, and a mouth that leads the voice reads as broken even when a mouth that follows it by the same margin does not.

The pattern that works (it is exactly what our public Speak demo does):

javascript
// frames = clip.blendData; audio = an HTMLAudioElement playing clip.audio_url
let cursor = 0;
function tick() {
  const nowMs = audio.currentTime * 1000;
  while (cursor + 1 < frames.length && frames[cursor + 1].audioOffset <= nowMs) cursor++;
  applyToRig(frames[cursor].blendshapes);   // your renderer
  if (!audio.paused) requestAnimationFrame(tick);
}
audio.addEventListener("play", () => requestAnimationFrame(tick));
  • Drive from audioOffset, never from frame index. If you request viseme_fps below 60 the stream is decimated by selecting the nearest native frame per grid instant — the offsets stay truthful but the step between them is not perfectly uniform. The viseme_format field (arkit-52@30fps) names the delivered rate.
  • Wait for the audio clock to move before animating. If your renderer runs its own clock from the moment you hand it frames (many do), gate on audio.currentTime clearing a small threshold first, then shift the frame times so avatar-time zero is that audio moment.

Timing fidelity

Frame timing comes from the synthesis engine, and the response's timing.source field says which kind you received. Voices whose engine predicts phoneme durations natively produce frames aligned to the model's own timeline ("source": "model", with the phoneme spans included as timing.phonemes); other voices — including the current production Camlin voices — use a text-driven estimate that is scaled to match the exact clip length ("source": "estimated"). Estimated timing means the mouth always starts and stops with the audio and word-level sync is good; individual phoneme boundaries inside a long word are approximate. Both sources produce the same frame wire format, so your renderer does not care — but if you are evaluating lip-sync quality, do it on the voice you will ship with.

Latency and payload

  • Synthesis is request/response. Audio and visemes arrive together in one JSON body when the clip is done — for a one-sentence prompt, a couple of seconds. There is no per-frame streaming today; a WebSocket viseme stream is on the roadmap alongside streaming synthesis.
  • Viseme generation itself adds effectively nothing to synthesis time — it is computed from the phoneme timeline, not a second model pass.
  • At the native 60 fps the track weighs roughly 700 KB of JSON per minute of audio. Request viseme_fps: 30 (or 24) for web delivery; request "visemes": false when nothing on screen has a face, and the response shrinks to the audio metadata alone.

Bring your own avatar, or use ours

Your avatar. If your rig takes named ARKit blendshapes rather than an ordered vector, map indices to names once — the ordering is the canonical ARKit list, eyeBlinkLeft at index 0 through tongueOut at index 51 — and skip the zero channels while you are at it:

javascript
const named = frames.map((f) => ({
  timeS: f.audioOffset / 1000,
  values: Object.fromEntries(
    f.blendshapes.map((v, i) => [ARKIT_52_NAMES[i], v]).filter(([, v]) => v > 0),
  ),
}));

Handle the empty track: blend_data is an empty array when the key lacks the viseme scope, when the request set "visemes": false, or when lip-sync generation is unavailable. Hold your avatar's neutral/idle mouth pose in that case — do not freeze the last frame.

Ours. Camlin Speech is part of the Camlin Connect platform, which includes a hosted, embeddable 3D avatar. The avatar embed accepts this viseme track directly — the public Speak demo drives its "Ryan" avatar from the exact blendData documented here, with no translation layer. If you want a face without building one, that is the short path; ask us about the avatar embed when you talk licensing.

Operational Notes

  • Production callers should omit backend and let the service route. The first-party families are camlin and camlin-neural; third-party routes exist for breadth and operator use.
  • Legacy and experimental voice routes remain available for controlled internal testing but are not part of the public demo surface.
  • Public implementations should code to the response contract above rather than to the behaviour of any one voice — the catalogue will grow, and the contract is the part that holds still.

See also

Public Speak demo

The live voice + avatar demo — switch to the 3D avatar view to watch this contract driving a face.