Skip to content
kitn AI/UI

Text to Speech

Make your chat speak. Call a speak() function when a reply finishes streaming — no extra dependencies needed for the browser-native path, and a thin backend route for higher-quality cloud voices.

The Web Speech API is built into every modern browser. Call speak(answer) right before you set chat.loading = false:

import '@kitn.ai/ui/elements';
import { createAssistantStream } from '@kitn.ai/ui/state';
import { readOpenAIStream, toOpenAIMessages } from '@kitn.ai/ui/wire';
function speak(text) {
if (!('speechSynthesis' in window)) return;
const utter = new SpeechSynthesisUtterance(text);
utter.lang = 'en-US';
speechSynthesis.cancel(); // stop any previous utterance
speechSynthesis.speak(utter);
}
const chat = document.getElementById('chat');
chat.addEventListener('kai-submit', async (e) => {
const userText = e.detail.value.trim();
if (!userText) return;
const history = [
...chat.messages,
{ id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: userText }] },
];
chat.messages = history;
chat.loading = true;
// createAssistantStream appends the in-flight assistant message and folds each
// delta onto its parts, so nothing here hand-builds a placeholder.
const stream = createAssistantStream((fn) => { chat.messages = fn(chat.messages); });
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: toOpenAIMessages(history) }),
});
// readOpenAIStream parses the SSE and resolves with the finished turn, so
// turn.text is the whole reply with no accumulator of your own. A non-ok
// response throws WireError before a chunk is read, so there is no res.ok
// check to forget.
const turn = await readOpenAIStream(res, stream);
speak(turn.text); // speak the completed reply
} finally {
stream.done();
chat.loading = false;
}
});

For higher-quality, consistent voices (OpenAI, ElevenLabs, and similar), proxy the TTS call through your backend — never expose provider API keys to the client.

async function speakCloud(text) {
const res = await fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, voice: 'alloy' }),
});
if (!res.ok) return;
const audio = new Audio(URL.createObjectURL(await res.blob()));
audio.play();
}

Replace speak(turn.text) in the streaming handler above with speakCloud(turn.text).

Example /api/tts route (Node.js / Express)

Section titled “Example /api/tts route (Node.js / Express)”
// POST /api/tts — proxies to OpenAI TTS, streams back audio
import OpenAI from 'openai';
const openai = new OpenAI(); // reads OPENAI_API_KEY from environment
app.post('/api/tts', async (req, res) => {
const { text, voice = 'alloy' } = req.body;
const mp3 = await openai.audio.speech.create({
model: 'tts-1',
voice,
input: text,
});
res.setHeader('Content-Type', 'audio/mpeg');
mp3.body.pipe(res);
});

For browser-native TTS, call speechSynthesis.cancel() at the start of each new reply (the code above already does this) or when the user sends a new message.

For cloud TTS, keep a reference to the Audio object and call .pause():

let currentAudio = null;
async function speakCloud(text) {
if (currentAudio) {
currentAudio.pause();
currentAudio = null;
}
const res = await fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, voice: 'alloy' }),
});
if (!res.ok) return;
currentAudio = new Audio(URL.createObjectURL(await res.blob()));
currentAudio.play();
}