Add voice¶
Speech in and speech out over your chanx websocket: microphone audio becomes live captions, text becomes audio every listener hears, and optionally an agent answers out loud. Provider API keys stay on your server, and switching provider is one base class.
The pieces:
| Kit | Side | What it does |
|---|---|---|
fake-voice, deepgram, elevenlabs, openai |
server | a provider, with a speech-to-text part (stt) and a text-to-speech part (tts) |
audio-stream-in, audio-stream-out |
server | the contracts and plumbing; installed with a provider |
transcriber, player |
browser | microphone and captions, playback |
voice-agent (both sides) |
both | an AG-UI agent you talk to |
This page assumes both registries are set up (Getting started, UI kits). The starter template has them already.
1. Try it with no account¶
fake-voice transcribes any speech as a scripted sentence and speaks text as a tone
per word, so the whole path works before you sign up anywhere.
Mount the topics on a consumer:
from chanx.core.decorators import channel
from chanx.fast_channels.websocket import AsyncJsonWebsocketConsumer
from .ws_kits.audio_stream_in import TranscriptTopic
from .ws_kits.fake_voice.synthesizer import FakeSynthesizerTopic
from .ws_kits.fake_voice.transcriber import FakeTranscriberTopic
class MicTopic(FakeTranscriberTopic):
async def authorize(self, **params: str) -> bool:
return user_owns_session(self.scope, params["session"])
class VoiceTopic(FakeSynthesizerTopic):
pass
@channel(name="voice")
class VoiceConsumer(AsyncJsonWebsocketConsumer):
channel_layer_alias = "default"
topics = [MicTopic, TranscriptTopic, VoiceTopic]
Route it (/ws/voice), regenerate the client (npm run --prefix web gen), and use it:
import { Recorder, TranscriptView, useTranscriber } from '@/chanx-kit/transcriber/react';
import { Player, usePlayer } from '@/chanx-kit/player/react';
import '@/chanx-kit/transcriber/transcriber.css';
import '@/chanx-kit/player/player.css';
import { voice } from '@/generated';
export function Voice({ session }: { session: string }) {
const mic = useTranscriber(voice, voice.topics.micTopic.with({ session }));
const speech = usePlayer(voice, voice.topics.voiceTopic.with({ session }));
return (
<>
<Recorder
recording={mic.recording}
level={mic.level}
error={mic.error}
onStart={mic.start}
onStop={mic.stop}
disabled={mic.status !== 'open'}
/>
<TranscriptView utterances={mic.utterances} speaking={mic.speaking} />
<Player {...speech} onUnlock={speech.unlock} onStop={speech.stop} />
<button onClick={() => { void speech.unlock(); speech.speak('Hello there.'); }}>
Speak
</button>
</>
);
}
Start recording from a click: browsers only open the microphone and play audio after a user gesture.
2. Switch to a real provider¶
Install the provider and change the two base classes. The browser code does not change.
from .ws_kits.deepgram.synthesizer import DeepgramSynthesizerTopic
from .ws_kits.deepgram.transcriber import DeepgramTranscriberTopic
class MicTopic(DeepgramTranscriberTopic): ...
class VoiceTopic(DeepgramSynthesizerTopic): ...
Set the key in the server's environment. It is read when used, so a .env loaded at
startup works:
| Provider | Key | Speech to text | Text to speech |
|---|---|---|---|
deepgram |
DEEPGRAM_API_KEY |
Nova, live | Aura |
elevenlabs |
ELEVENLABS_API_KEY |
Scribe realtime | streaming voices (premade ones on the free tier) |
openai |
OPENAI_API_KEY |
realtime transcription | gpt-4o-mini-tts |
Only one side, or two providers¶
Each provider installs both parts by default. Keep one with --only, which also
leaves out the audio kit only the other part needs. It applies to the kits that have
that part, so the UI kit can come in the same command:
uvx copit add @chanx-kit/deepgram @chanx-kit-ui/transcriber --only stt # captions
uvx copit add @chanx-kit/elevenlabs @chanx-kit-ui/player --only tts # ElevenLabs speaks
Two providers each with their own part take two commands, as above. Mixing registries
with --only needs copit 0.9.1.
3. Talk to an agent¶
The voice-agent kit connects the transcriber to an AG-UI agent and the agent's reply to the synthesizer: each utterance starts a run once the speaker pauses, the reply is spoken sentence by sentence while it streams, and speaking over it interrupts.
from .ws_kits.voice_agent import VoiceAgentTopic, VoiceTranscriberTopic
class Agent(VoiceAgentTopic):
synthesizer = VoiceTopic
async def run_agent(self, run_input):
async for event in my_agent(run_input.messages):
yield event
class Ears(VoiceTranscriberTopic, DeepgramTranscriberTopic):
agent_topic = Agent
@channel(name="assistant")
class AssistantConsumer(AsyncJsonWebsocketConsumer):
channel_layer_alias = "default"
topics = [Ears, Agent, VoiceTopic]
import { VoiceAgent, useVoiceAgent } from '@/chanx-kit/voice-agent/react';
const agent = useVoiceAgent(assistant, {
transcriber: assistant.topics.earsTopic.with({ session }),
synthesizer: assistant.topics.voiceTopic.with({ session }),
thread: assistant.topics.agentTopic.with({ thread_id: session }),
});
return <VoiceAgent agent={agent} />;
Barge-in needs the transcriber to report speech starting: Deepgram and ElevenLabs do,
OpenAI does with turn_detection = {"type": "server_vad"} and model =
"gpt-4o-transcribe". Without headphones, the browser's echo cancellation keeps the
agent from hearing itself.
Good to know¶
- Limits. Providers bill per second of audio, so transcriber streams are bounded:
max_session_seconds,idle_timeout_secondsandmax_sessions_per_useron the topic,Noneto turn one off. Text to speak is capped bymax_text_chars. - Several processes. Replay for late joiners, playback marks, the agent's memory and interruption live in the process holding the socket. Implement the kits' store protocols against Redis before running more than one worker.
- Other providers.
pipecat-bridgeputs any Pipecat speech-to-text service behind the same contract. - Your own provider. Subclass
TranscriberTopicorSynthesizerTopicand run the contract's shared test suite against it; seeaudio-stream-in.