TTS & Voices
TTS keeps advancing at a rapid pace.
Most providers can clone a voice from 30 seconds of audio, so the same voice can be taught to several providers and follow the router when it fails over.
Some models focus on speed for voice AI, and others on how good they sound.
TTS router yaml
Here's an example of setting up your first router. It tries ElevenLabs Flash first, then any Cartesia voice, and ends with the en-low-latency group:
name: switchboard
tts:
providers: [elevenlabs/eleven_flash_v2_5, cartesia, en-low-latency]You can sync your new router like this:
curl -fsSL https://getstream.io/cli/beta/install.sh | bash # the agents commands are in the beta CLI
getstream agents sync --router=switchboardAnd then use it in your code as shown below.
Speak live
import { Socket, text } from "@stream-io/calypso";
const voice = await Socket.open(
client.backend,
await client.backend.socketURL("/v1/tts/stream"),
);
try {
voice.send({ type: "start", config_id: "switchboard", tts: {} });
voice.send({
type: "speak",
text: "Thanks for calling. How can I help?",
final: true,
});
for await (const chunk of voice.messages()) {
if (chunk instanceof Uint8Array) {
process.stdout.write(chunk.subarray(8)); // 16-bit PCM, after an 8-byte header naming the sample rate
continue;
}
if (chunk.type === "synthesis_complete") break;
if (chunk.type === "error")
throw new Error(`speaking: ${text(chunk, "error")}`);
}
} finally {
voice.close();
}import sys
from vision_agents.plugins import stream
router = stream.Client().router("switchboard")
async with router.tts.realtime() as voice:
async for chunk in await voice.stream_audio("Thanks for calling. How can I help?"):
sys.stdout.buffer.write(chunk.samples.tobytes()) # 16-bit PCM at chunk.sample_raterouter = api.router("switchboard")
router.tts.realtime do |voice|
voice.speak("Thanks for calling. How can I help?") do |chunk|
$stdout.write(chunk.pcm) # 16-bit PCM at chunk.sample_rate
end
end$voice = $client->router('switchboard')->tts->realtime();
try {
$voice->speak('Thanks for calling. How can I help?');
foreach ($voice->frames() as $frame) {
if (is_string($frame)) {
fwrite(STDOUT, substr($frame, 8)); // 16-bit PCM after an 8-byte header naming the sample rate
continue;
}
if ($frame['type'] === 'synthesis_complete') {
break;
}
}
} finally {
$voice->close();
}voice, err := client.Router("switchboard").TTS().Realtime(ctx, nil)
if err != nil {
return err
}
defer voice.Close()
if err := voice.Speak("Thanks for calling. How can I help?"); err != nil {
return err
}
for chunk := range voice.Audio() {
if chunk.Done {
break
}
if chunk.Error != "" {
return fmt.Errorf("speaking: %s", chunk.Error)
}
os.Stdout.Write(chunk.Samples) // 16-bit PCM at chunk.SampleRate
}await using var voice = await client.Router("switchboard").Tts.RealtimeAsync();
using var stdout = Console.OpenStandardOutput();
await voice.SpeakAsync("Thanks for calling. How can I help?");
await foreach (var chunk in voice.ReadAllAsync())
{
if (chunk.Done)
{
break;
}
if (chunk.Error != "")
{
throw new InvalidOperationException($"speaking: {chunk.Error}");
}
await stdout.WriteAsync(chunk.Samples); // 16-bit PCM at chunk.SampleRate
}use std::io::Write;
use vision_agents::types::TtsOptions;
let mut voice = client
.router("switchboard")
.voice(TtsOptions::default())
.await?;
let mut out = std::io::stdout();
voice
.speak("Thanks for calling. How can I help?", |audio| {
let _ = out.write_all(&audio.pcm); // 16-bit PCM at audio.sample_rate
})
.await?;
voice.close().await;Interrupting the voice stops the current utterance, which is what barge-in sends when the caller starts talking over the agent.
Make a recording
To check that speech works on an app, ask for the recording inline. The audio comes back, base64, in the 202 response, and nothing is stored:
const file = await client.post("/v1/tts/recordings", {
body: { config_id: "switchboard", text: "Testing, one, two.", inline: true },
});
console.log(file.status, file.voice, file.error ?? "");import base64
from vision_agents.plugins import stream
file = await stream.Client().router("switchboard").tts.recording(
"Testing, one, two.", inline=True
)
print(file.voice, len(base64.b64decode(file.audio)), "bytes")An inline recording takes at most 16000 characters and no callback, and it must finish within 90 seconds. A synthesis that fails is still a 202, with status: failed and the reason in error. Python raises it as a RuntimeError. voice is the voice that spoke, the provider's default included, when the provider can say.
Without inline, a recording is a job that stores the file. Poll it until it is done:
let file = await client.post("/v1/tts/recordings", {
body: {
config_id: "switchboard",
text: "Chapter one.",
options: { format: "mp3_44100_128" },
},
});
while (file.status === "queued" || file.status === "running") {
await new Promise((resolve) => setTimeout(resolve, 1000));
file = await client.get("/v1/tts/recordings/{id}", { path: { id: file.id } });
}
console.log(file.url);from vision_agents.plugins import stream
file = await stream.Client().router("switchboard").tts.recording(
"Chapter one.", format="mp3_44100_128"
)
print(file.url)router = api.router("switchboard")
file = router.tts.recording("Chapter one.", format: "mp3_44100_128")
puts file["url"]use GetStream\VisionAgents\Generated\TtsOptions;
$file = $client->router('switchboard')->tts->recording('Chapter one.', new TtsOptions(format: 'mp3_44100_128'));
echo $file->url, "\n";format := "mp3_44100_128"
file, err := client.Router("switchboard").TTS().Recording(ctx, "Chapter one.",
&acceleration.TtsOptions{Format: &format})
if err != nil {
return err
}
fmt.Println(*file.Url)var file = await client.Router("switchboard").Tts.RecordingAsync("Chapter one.",
new TtsOptions { Format = "mp3_44100_128" });
Console.WriteLine(file.Url);use vision_agents::types::TtsOptions;
let file = client
.router("switchboard")
.record(
"Chapter one.",
TtsOptions {
format: Some("mp3_44100_128".into()),
..Default::default()
},
None,
)
.await?;
println!("{}", file.url.unwrap_or_default());Use mp3_44100_128 for a file somebody downloads and ulaw_8000 for telephony.
Only ElevenLabs models record. The other models only stream. A recording on models that only stream, such as a pinned cartesia/sonic-preview, is refused with a 400 that names the models that record. A route that reaches ElevenLabs, such as tts-quality, records through ElevenLabs. can_record on each candidate of GET /v1/tts/routes and GET /v1/tts/routes/{target} says which models can record.
Voices
An agent speaks in one of two kinds of voice: a stock voice from a provider's library, or a voice of your own, cloned from a recording. A cloned voice is stored once in Stream and taught to every text-to-speech provider that can clone, so the router can still fail over between providers in the middle of a call without the agent changing voice.
Pick a stock voice
GET /v1/agents/voices/library lists the voices each provider publishes, by provider and then name, with whatever the vendor says about each: gender, accent, language and tags. Add ?provider=elevenlabs for one provider's. GET /v1/agents/voices/library/{provider}/{voice}/preview plays the sample the vendor published; nothing is synthesised, so browsing costs nothing.
Put the voice's id in the agent's voice setting. A provider that cannot be reached is listed under unavailable rather than emptying the list.
The library has ElevenLabs, Cartesia and Inworld. Cartesia's voices are all listed. Fish Audio voices cannot be browsed through the router: find one in the fish.audio Voice Library and put its model id, which Fish's API calls reference_id, in voice.
voice names a voice from the voice catalogue, which resolves it to the provider that has it. A name no provider has is a 404, not a routing failure.
To give one vendor a voice of its own, name it in that vendor's overwrites, such as {"cartesia": {"voice_id": "<id>"}}. A voice named only there is spoken only by that vendor, in that voice. If that vendor cannot serve the request, the request is refused. Another vendor never speaks it in its own default voice. A top-level voice does not tie the request to one vendor. Name a voice in one of the two places, not both: with both, which one is spoken depends on the vendor.
The voice that spoke, the provider's default included, is voice on the started frame of /v1/tts/stream and on a recording, when the provider can say.
Clone a voice
A voice of your own takes four steps:
- Create it with a name. It starts empty.
- Add a recording. Thirty seconds of clean speech from one speaker is plenty. Add a transcript of what is said if you have one; the providers that use it clone more faithfully.
- Prepare it. Each provider is sent the recordings and hands back its own copy of the voice.
- Preview it through a provider, to hear what a caller will hear.
These calls are backend-only: they need your API secret, so a phone or browser cannot make them. No SDK wraps them yet, so each example uses its SDK's plain HTTP client.
import { readFile, writeFile } from "node:fs/promises";
import { Client } from "@stream-io/calypso";
const api = new Client();
const voice = await api.post("/v1/agents/voices", {
body: { name: "founder", description: "Jo, for the welcome line" },
});
await api.post("/v1/agents/voices/{id}/samples", {
path: { id: voice.id },
body: {
audio: (await readFile("founder.wav")).toString("base64"),
filename: "founder.wav",
transcript:
"Hi, I'm Jo, and I started this company to make support calls less painful.",
},
});
const prepared = await api.post("/v1/agents/voices/{id}/prepare", {
path: { id: voice.id },
body: {},
});
for (const binding of prepared.bindings ?? []) {
console.log(binding.provider, binding.state, binding.error ?? "");
}
const preview = await api.post("/v1/agents/voices/{id}/preview", {
path: { id: voice.id },
body: { provider: "elevenlabs", text: "Thanks for calling. How can I help?" },
});
await writeFile("preview.mp3", Buffer.from(preview.audio, "base64"));import base64
from pathlib import Path
import httpx
from vision_agents.plugins import stream
backend = stream.Client().backend
async with httpx.AsyncClient(base_url=backend.url, headers=backend.headers) as http:
voice = (
await http.post(
"/v1/agents/voices",
json={"name": "founder", "description": "Jo, for the welcome line"},
)
).raise_for_status().json()
(
await http.post(
f"/v1/agents/voices/{voice['id']}/samples",
json={
"audio": base64.b64encode(Path("founder.wav").read_bytes()).decode(),
"filename": "founder.wav",
"transcript": "Hi, I'm Jo, and I started this company to make support calls less painful.",
},
)
).raise_for_status()
prepared = (
await http.post(f"/v1/agents/voices/{voice['id']}/prepare", json={})
).raise_for_status().json()
for binding in prepared["bindings"]:
print(binding["provider"], binding["state"], binding.get("error", ""))
preview = (
await http.post(
f"/v1/agents/voices/{voice['id']}/preview",
json={"provider": "elevenlabs", "text": "Thanks for calling. How can I help?"},
)
).raise_for_status().json()
Path("preview.mp3").write_bytes(base64.b64decode(preview["audio"]))require "base64"
require "getstream/vision_agents"
api = GetStream::VisionAgents::Client.new(api_key: ENV["STREAM_API_KEY"], api_secret: ENV["STREAM_API_SECRET"])
voice = api.post("/v1/agents/voices", body: { name: "founder", description: "Jo, for the welcome line" })
api.post("/v1/agents/voices/{id}/samples", path: { id: voice["id"] }, body: {
audio: Base64.strict_encode64(File.binread("founder.wav")),
filename: "founder.wav",
transcript: "Hi, I'm Jo, and I started this company to make support calls less painful."
})
prepared = api.post("/v1/agents/voices/{id}/prepare", path: { id: voice["id"] }, body: {})
prepared["bindings"].each { |binding| puts "#{binding["provider"]} #{binding["state"]} #{binding["error"]}" }
preview = api.post("/v1/agents/voices/{id}/preview", path: { id: voice["id"] },
body: { provider: "elevenlabs", text: "Thanks for calling. How can I help?" })
File.binwrite("preview.mp3", Base64.decode64(preview["audio"]))use GetStream\VisionAgents\Backend;
use GetStream\VisionAgents\Client;
$client = new Client(new Backend(apiKey: $key, apiSecret: $secret, authenticate: true));
$voice = $client->post('/v1/agents/voices', body: ['name' => 'founder', 'description' => 'Jo, for the welcome line']);
$id = ['id' => $voice['id']];
$client->post('/v1/agents/voices/{id}/samples', $id, body: [
'audio' => base64_encode(file_get_contents('founder.wav')),
'filename' => 'founder.wav',
'transcript' => "Hi, I'm Jo, and I started this company to make support calls less painful.",
]);
$prepared = $client->post('/v1/agents/voices/{id}/prepare', $id, body: []);
foreach ($prepared['bindings'] as $binding) {
echo $binding['provider'], ' ', $binding['state'], ' ', $binding['error'] ?? '', "\n";
}
$preview = $client->post('/v1/agents/voices/{id}/preview', $id, body: [
'provider' => 'elevenlabs',
'text' => 'Thanks for calling. How can I help?',
]);
file_put_contents('preview.mp3', base64_decode($preview['audio']));api, err := stream.Backend{}.Client()
if err != nil {
log.Fatal(err)
}
description := "Jo, for the welcome line"
created, err := api.CreateVoiceWithResponse(ctx, acceleration.VoiceRequest{Name: "founder", Description: &description})
if err != nil {
log.Fatal(err)
}
if created.JSON201 == nil {
log.Fatalf("creating the voice: %s", created.Status())
}
voice := created.JSON201
audio, err := os.ReadFile("founder.wav")
if err != nil {
log.Fatal(err)
}
filename := "founder.wav"
transcript := "Hi, I'm Jo, and I started this company to make support calls less painful."
if _, err := api.AddVoiceSampleWithResponse(ctx, voice.Id, acceleration.VoiceSampleRequest{
Audio: audio, Filename: &filename, Transcript: &transcript,
}); err != nil {
log.Fatal(err)
}
prepared, err := api.PrepareVoiceWithResponse(ctx, voice.Id, acceleration.PrepareVoiceRequest{})
if err != nil {
log.Fatal(err)
}
if prepared.JSON200 == nil {
log.Fatalf("preparing the voice: %s", prepared.Status())
}
for _, binding := range *prepared.JSON200.Bindings {
fmt.Println(binding.Provider, binding.State)
}
line := "Thanks for calling. How can I help?"
preview, err := api.PreviewVoiceWithResponse(ctx, voice.Id, acceleration.VoicePreviewRequest{Provider: "elevenlabs", Text: &line})
if err != nil {
log.Fatal(err)
}
if preview.JSON200 == nil {
log.Fatalf("previewing the voice: %s", preview.Status())
}
if err := os.WriteFile("preview.mp3", preview.JSON200.Audio, 0o644); err != nil {
log.Fatal(err)
}using GetStream.VisionAgents;
using GetStream.VisionAgents.Models;
using Voice = GetStream.VisionAgents.Models.Voice;
using var api = new VisionAgentsClient(new VisionAgentsOptions { ApiKey = key, ApiSecret = secret, Authenticate = true });
var voice = await api.PostAsync<Voice>("/v1/agents/voices",
new VoiceRequest { Name = "founder", Description = "Jo, for the welcome line" });
await api.PostAsync<Voice>($"/v1/agents/voices/{voice.Id}/samples", new VoiceSampleRequest
{
Audio = await File.ReadAllBytesAsync("founder.wav"),
Filename = "founder.wav",
Transcript = "Hi, I'm Jo, and I started this company to make support calls less painful.",
});
var prepared = await api.PostAsync<Voice>($"/v1/agents/voices/{voice.Id}/prepare", new PrepareVoiceRequest());
foreach (var binding in prepared.Bindings ?? [])
{
Console.WriteLine($"{binding.Provider} {binding.State} {binding.Error}");
}
var preview = await api.PostAsync<VoicePreview>($"/v1/agents/voices/{voice.Id}/preview",
new VoicePreviewRequest { Provider = "elevenlabs", Text = "Thanks for calling. How can I help?" });
await File.WriteAllBytesAsync("preview.mp3", preview.Audio);use base64::{Engine, engine::general_purpose::STANDARD};
use vision_agents::Client;
use vision_agents::types::{PrepareVoiceRequest, VoicePreviewRequest, VoiceRequest, VoiceSampleRequest};
let api = Client::from_env()?;
let voice = api
.create_voice(&VoiceRequest {
name: "founder".into(),
description: Some("Jo, for the welcome line".into()),
})
.await?;
api.add_voice_sample(
&voice.id,
&VoiceSampleRequest {
audio: STANDARD.encode(std::fs::read("founder.wav")?),
filename: Some("founder.wav".into()),
transcript: Some("Hi, I'm Jo, and I started this company to make support calls less painful.".into()),
..Default::default()
},
)
.await?;
let prepared = api.prepare_voice(&voice.id, &PrepareVoiceRequest::default()).await?;
for binding in prepared.bindings.iter().flatten() {
println!("{} {:?} {}", binding.provider, binding.state, binding.error.clone().unwrap_or_default());
}
let preview = api
.preview_voice(
&voice.id,
&VoicePreviewRequest {
provider: "elevenlabs".into(),
text: Some("Thanks for calling. How can I help?".into()),
},
)
.await?;
std::fs::write("preview.mp3", STANDARD.decode(preview.audio)?)?;The name is how the agent will ask for the voice, and it is unique among your voices. Send the recording's filename with its extension: that is how a provider knows what format it was given.
Preparing reports one binding per provider: ready when that provider can speak in the voice, failed with the reason when it refused the recordings. One provider refusing leaves the others usable, and the router simply does not pick that provider for this voice.
prepare teaches every provider this deployment can clone with. Pass providers to choose, for example {"providers": ["elevenlabs", "cartesia"]}; GET /v1/agents/voices/providers lists the ones available. Today they are Cartesia, ElevenLabs, Fish Audio and Inworld.
From a terminal, the CLI makes the same calls. A recording's base64 can be too long for the command line, so --request reads the JSON from a file with @<file>, or from stdin with -:
base64 < founder.wav | tr -d '\n' | jq -Rs '{audio: ., filename: "founder.wav"}' |
getstream agents api AddVoiceSample --id <id> --request -Only clone a voice you have the speaker's permission to use. Each provider's terms apply to its copy.
Speak in it
Name the voice in the agent's voice setting with the custom: prefix:
# agents/myagent/agent.yaml
tts: elevenlabs/eleven_v3_conversational
voice: custom:founderThe prefix says the voice is one of yours. Without it, a name is looked for among your voices first and otherwise passed to the provider as one of its stock voices. With it, a name that is not yours is refused, so a typo cannot quietly become a stranger's voice from a provider's catalogue.
For each call the router asks the provider it routes to for that provider's copy of the voice. If the provider it would pick was never taught the voice, it moves on to one that was, so a voice prepared with two providers can still fail over.
The same name works wherever a voice is named: in the voice option when a session is opened, and in the session settings that swap the voice of a running call.
Change or remove a voice
| Call | What it does |
|---|---|
GET /v1/agents/voices |
Your voices, newest first |
GET /v1/agents/voices/{id} |
One voice, with its recordings and each provider's binding |
PUT /v1/agents/voices/{id} |
Renames it. What it sounds like does not change |
POST /v1/agents/voices/{id}/samples |
Adds another recording. It is not used until you prepare the voice again |
POST /v1/agents/voices/{id}/prepare |
Teaches the providers again, from every recording. Each provider's previous copy is replaced and removed from it |
DELETE /v1/agents/voices/{id} |
Removes the voice from every provider, then its recordings. Past calls still name it |
Recordings are kept, so a provider added later can be taught the voice without asking the speaker to record again. On a self-hosted router they live in the bucket named by voices_bucket_url; without one, voices of your own are unavailable. See Self-hosting.
Options
| Option | What it asks for |
|---|---|
voice |
The voice to speak with. |
languages |
The language to speak. Some providers infer it from the text when it is absent. |
speed |
Speaking rate, 1 being normal. Each provider has its own range, and a speed outside it is refused. |
volume |
Loudness, 1 being the voice's own. |
emotion, style |
Affect or delivery style, for the providers that take one. |
stability |
How much the voice may vary between chunks. Higher is flatter and more consistent. |
similarity |
How closely a cloned voice tracks its reference. |
format |
The audio encoding of a recording. Live sockets are always PCM. |
pronunciations |
How to say words the voice gets wrong, keyed by the word. |
chunk_schedule |
How much text to buffer before speaking. |
What varies between providers
- The best voices have the fewest settings. ElevenLabs v3 conversational takes one setting,
stability, and only on the first message. Everything else it can do is said in the text, as audio tags such as[whispers]. - Speed ranges do not overlap. Cartesia takes 0.6 to 1.5, Inworld 0.5 to 1.5 and ElevenLabs flash 0.7 to 1.2.
speedis not one number every voice accepts. - "Sound urgent" has three shapes. A fixed list of emotions, English only (Cartesia); free prose in brackets that persists until reset (Inworld); tags the model acts out as it reads them (ElevenLabs v3). An
emotionstring only reaches the first. - Live voices take few options. Today the live models serve a target, a voice and a language. To steer one further, use its
overwrites, for exampleinworld: {delivery_mode: STABLE}.