Add Vision to Your Voice Assistant
Builds on: Tutorial 1: Build Your First Voice Assistant
This tutorial adds vision capabilities to your voice assistant. By the end, the AI will be able to see what you show it via camera or screen share.
What you'll add:
- Camera sharing so the AI can see you or objects you show
- Screen sharing so the AI can see your display
- Visual context sent automatically with your speech
Time: ~10 minutes
Starting Point
Choose your starting point:
Option A: Continue from Tutorial 1
If you completed Tutorial 1, continue with your existing code.
Option B: Use Starter Code
Click to expand starter code
Create the same project structure from Tutorial 1:
voice-assistant/
├── server.ts
├── package.json
├── tsconfig.json
├── .env
└── client/
├── index.html
├── package.json
├── vite.config.ts
└── src/
├── main.tsx
└── App.tsx
Root package.json:
{
"name": "voice-assistant",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "tsx server.ts",
"dev": "concurrently \"tsx watch server.ts\" \"npm run dev --prefix client\""
},
"dependencies": {
"@llmrtc/llmrtc-backend": "^1.3.0",
"dotenv": "^16.4.5"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"concurrently": "^9.0.0"
}
}
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["server.ts"]
}
.env:
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
server.ts:
import { config } from 'dotenv';
config();
import {
LLMRTCServer,
AnthropicLLMProvider,
OpenAIWhisperProvider,
OpenAITTSProvider
} from '@llmrtc/llmrtc-backend';
if (!process.env.OPENAI_API_KEY) {
console.error('Error: OPENAI_API_KEY is required');
process.exit(1);
}
if (!process.env.ANTHROPIC_API_KEY) {
console.error('Error: ANTHROPIC_API_KEY is required');
process.exit(1);
}
const server = new LLMRTCServer({
providers: {
llm: new AnthropicLLMProvider({
apiKey: process.env.ANTHROPIC_API_KEY,
model: 'claude-sonnet-5'
}),
stt: new OpenAIWhisperProvider({
apiKey: process.env.OPENAI_API_KEY
}),
tts: new OpenAITTSProvider({
apiKey: process.env.OPENAI_API_KEY,
voice: 'nova'
})
},
port: 8787,
streamingTTS: true,
systemPrompt: `You are a helpful voice assistant. Keep your responses concise
and conversational - typically 1-2 sentences. Speak naturally as if having
a real conversation.`
});
server.on('listening', ({ host, port }) => {
console.log(`Server running at http://${host}:${port}`);
});
await server.start();
client/package.json:
{
"name": "voice-assistant-client",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@llmrtc/llmrtc-web-client": "^1.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.0",
"vite": "^5.4.10"
}
}
client/vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { port: 5173 }
});
client/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Voice Assistant</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
#root { width: 100%; max-width: 600px; padding: 2rem; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
client/src/main.tsx:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
client/src/App.tsx: See Tutorial 1 for the full App.tsx code.
Backend Changes
No backend changes are required for vision. Claude (and GPT-5.2) natively support vision - they can process images directly without a separate vision provider.
The server automatically forwards image attachments from the client to the LLM.
This tutorial uses Claude (claude-sonnet-5), which supports vision natively. If you switch to a different LLM, ensure it supports image inputs. Most modern models (GPT-5.2, Gemini 2.5) do.
Local Vision Options:
- Use
OllamaLLMProviderwith a vision model likegemma3orllava- vision support is auto-detected - Use a separate
LlavaVisionProvideralongside a text-only LLM
See Local Ollama - Multimodal Support for details.
Frontend Changes
All the vision magic happens in the frontend. We'll add camera and screen share toggles.
Update App.tsx
Open client/src/App.tsx and make these changes:
1. Add New Imports and Types
At the top of the file, update the imports:
import { useState, useEffect, useRef } from 'react';
import { LLMRTCWebClient } from '@llmrtc/llmrtc-web-client';
import type { FrameCaptureController } from '@llmrtc/llmrtc-web-client';
2. Add State for Video Streams
Inside your App component, add state for camera and screen:
export default function App() {
// Existing state...
const [status, setStatus] = useState<Status>('disconnected');
const [transcript, setTranscript] = useState('');
const [response, setResponse] = useState('');
const [error, setError] = useState<string | null>(null);
// ADD: Video state
const [cameraStream, setCameraStream] = useState<MediaStream | null>(null);
const [screenStream, setScreenStream] = useState<MediaStream | null>(null);
// Existing refs...
const clientRef = useRef<LLMRTCWebClient | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
// ADD: Frame capture controller refs
const cameraCtrlRef = useRef<FrameCaptureController | null>(null);
const screenCtrlRef = useRef<FrameCaptureController | null>(null);
const videoPreviewRef = useRef<HTMLVideoElement | null>(null);
3. Add Toggle Functions
After your useEffect, add functions to toggle camera and screen:
// Toggle camera on/off
async function toggleCamera() {
if (cameraStream) {
// Stop camera
cameraCtrlRef.current?.stop();
cameraStream.getTracks().forEach(track => track.stop());
setCameraStream(null);
if (videoPreviewRef.current) {
videoPreviewRef.current.srcObject = null;
}
} else {
try {
// Start camera
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480 }
});
setCameraStream(stream);
// Show preview
if (videoPreviewRef.current) {
videoPreviewRef.current.srcObject = stream;
}
// Share with server - captures frame every 1000ms
cameraCtrlRef.current = clientRef.current?.shareVideo(stream, 1000) ?? null;
} catch (err) {
setError('Camera access denied');
}
}
}
// Toggle screen share on/off
async function toggleScreen() {
if (screenStream) {
// Stop screen share
screenCtrlRef.current?.stop();
screenStream.getTracks().forEach(track => track.stop());
setScreenStream(null);
} else {
try {
// Start screen share
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true
});
setScreenStream(stream);
// Handle user clicking "Stop sharing" in browser UI
stream.getVideoTracks()[0].onended = () => {
screenCtrlRef.current?.stop();
setScreenStream(null);
};
// Share with server - captures frame every 1000ms
screenCtrlRef.current = clientRef.current?.shareScreen(stream, 1000) ?? null;
} catch (err) {
// User cancelled the screen picker
console.log('Screen share cancelled');
}
}
}
4. Add Video Controls to UI
In your JSX, add the control buttons and video preview. Add this right after the status indicator:
{/* Video Controls */}
<div style={{
display: 'flex',
gap: '1rem',
justifyContent: 'center',
marginBottom: '1.5rem'
}}>
<button
onClick={toggleCamera}
disabled={status === 'disconnected' || status === 'connecting'}
style={{
padding: '0.75rem 1.5rem',
borderRadius: '0.5rem',
border: 'none',
background: cameraStream ? '#e74c3c' : '#3498db',
color: 'white',
cursor: 'pointer',
fontSize: '0.9rem',
opacity: status === 'disconnected' || status === 'connecting' ? 0.5 : 1
}}
>
{cameraStream ? 'Stop Camera' : 'Start Camera'}
</button>
<button
onClick={toggleScreen}
disabled={status === 'disconnected' || status === 'connecting'}
style={{
padding: '0.75rem 1.5rem',
borderRadius: '0.5rem',
border: 'none',
background: screenStream ? '#e74c3c' : '#9b59b6',
color: 'white',
cursor: 'pointer',
fontSize: '0.9rem',
opacity: status === 'disconnected' || status === 'connecting' ? 0.5 : 1
}}
>
{screenStream ? 'Stop Sharing' : 'Share Screen'}
</button>
</div>
{/* Camera Preview */}
{cameraStream && (
<div style={{
marginBottom: '1.5rem',
borderRadius: '1rem',
overflow: 'hidden',
background: '#000'
}}>
<video
ref={videoPreviewRef}
autoPlay
playsInline
muted
style={{
width: '100%',
maxHeight: '300px',
objectFit: 'cover'
}}
/>
</div>
)}
5. Clean Up on Unmount
Update the cleanup in your useEffect to stop video streams:
// Cleanup on unmount
return () => {
client.close();
if (audioRef.current) {
audioRef.current.srcObject = null;
}
// ADD: Clean up video streams
cameraCtrlRef.current?.stop();
screenCtrlRef.current?.stop();
};
Complete Updated App.tsx
Here's the complete updated App.tsx with vision support:
Click to expand full App.tsx
import { useState, useEffect, useRef } from 'react';
import { LLMRTCWebClient } from '@llmrtc/llmrtc-web-client';
import type { FrameCaptureController } from '@llmrtc/llmrtc-web-client';
type Status = 'disconnected' | 'connecting' | 'idle' | 'listening' | 'thinking' | 'speaking';
export default function App() {
const [status, setStatus] = useState<Status>('disconnected');
const [transcript, setTranscript] = useState('');
const [response, setResponse] = useState('');
const [error, setError] = useState<string | null>(null);
// Video state
const [cameraStream, setCameraStream] = useState<MediaStream | null>(null);
const [screenStream, setScreenStream] = useState<MediaStream | null>(null);
// Refs
const clientRef = useRef<LLMRTCWebClient | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const cameraCtrlRef = useRef<FrameCaptureController | null>(null);
const screenCtrlRef = useRef<FrameCaptureController | null>(null);
const videoPreviewRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
const client = new LLMRTCWebClient({
signallingUrl: 'ws://localhost:8787'
});
clientRef.current = client;
// Connection events
client.on('stateChange', (state) => {
if (state === 'connected') setStatus('idle');
else if (state === 'connecting') setStatus('connecting');
else if (state === 'disconnected' || state === 'failed') setStatus('disconnected');
});
// VAD events
client.on('speechStart', () => {
setStatus('listening');
setResponse('');
});
client.on('speechEnd', () => {
setStatus('thinking');
});
// Transcription
client.on('transcript', (text) => {
setTranscript(text);
});
// LLM response
client.on('llmChunk', (chunk) => {
setResponse((prev) => prev + chunk);
});
// TTS events
client.on('ttsTrack', (stream) => {
if (!audioRef.current) {
audioRef.current = new Audio();
}
audioRef.current.srcObject = stream;
audioRef.current.play().catch((err) => {
setError('Click anywhere to enable audio playback');
});
});
client.on('ttsStart', () => setStatus('speaking'));
client.on('ttsComplete', () => setStatus('idle'));
client.on('ttsCancelled', () => setStatus('listening'));
// Errors
client.on('error', (err) => {
setError(err.message);
setTimeout(() => setError(null), 5000);
});
// Connect
async function connect() {
try {
await client.start();
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true }
});
await client.shareAudio(stream);
setStatus('idle');
} catch (err) {
setError(err instanceof Error ? err.message : 'Connection failed');
setStatus('disconnected');
}
}
connect();
return () => {
client.close();
if (audioRef.current) audioRef.current.srcObject = null;
cameraCtrlRef.current?.stop();
screenCtrlRef.current?.stop();
};
}, []);
// Toggle camera
async function toggleCamera() {
if (cameraStream) {
cameraCtrlRef.current?.stop();
cameraStream.getTracks().forEach(track => track.stop());
setCameraStream(null);
if (videoPreviewRef.current) videoPreviewRef.current.srcObject = null;
} else {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480 }
});
setCameraStream(stream);
if (videoPreviewRef.current) videoPreviewRef.current.srcObject = stream;
cameraCtrlRef.current = clientRef.current?.shareVideo(stream, 1000) ?? null;
} catch (err) {
setError('Camera access denied');
}
}
}
// Toggle screen share
async function toggleScreen() {
if (screenStream) {
screenCtrlRef.current?.stop();
screenStream.getTracks().forEach(track => track.stop());
setScreenStream(null);
} else {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
setScreenStream(stream);
stream.getVideoTracks()[0].onended = () => {
screenCtrlRef.current?.stop();
setScreenStream(null);
};
screenCtrlRef.current = clientRef.current?.shareScreen(stream, 1000) ?? null;
} catch (err) {
console.log('Screen share cancelled');
}
}
}
const statusColors: Record<Status, string> = {
disconnected: '#666',
connecting: '#f39c12',
idle: '#27ae60',
listening: '#3498db',
thinking: '#9b59b6',
speaking: '#e74c3c'
};
const statusLabels: Record<Status, string> = {
disconnected: 'Disconnected',
connecting: 'Connecting...',
idle: 'Ready - Start speaking',
listening: 'Listening...',
thinking: 'Thinking...',
speaking: 'Speaking...'
};
return (
<div style={{ textAlign: 'center' }}>
<h1 style={{ marginBottom: '2rem', fontSize: '1.75rem', fontWeight: 600 }}>
Voice Assistant
</h1>
{/* Status Indicator */}
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem 1.5rem',
borderRadius: '2rem',
background: 'rgba(255,255,255,0.1)',
marginBottom: '1.5rem'
}}>
<div style={{
width: '12px',
height: '12px',
borderRadius: '50%',
background: statusColors[status],
boxShadow: `0 0 10px ${statusColors[status]}`
}} />
<span style={{ fontSize: '0.95rem' }}>{statusLabels[status]}</span>
</div>
{/* Video Controls */}
<div style={{
display: 'flex',
gap: '1rem',
justifyContent: 'center',
marginBottom: '1.5rem'
}}>
<button
onClick={toggleCamera}
disabled={status === 'disconnected' || status === 'connecting'}
style={{
padding: '0.75rem 1.5rem',
borderRadius: '0.5rem',
border: 'none',
background: cameraStream ? '#e74c3c' : '#3498db',
color: 'white',
cursor: 'pointer',
fontSize: '0.9rem',
opacity: status === 'disconnected' || status === 'connecting' ? 0.5 : 1
}}
>
{cameraStream ? 'Stop Camera' : 'Start Camera'}
</button>
<button
onClick={toggleScreen}
disabled={status === 'disconnected' || status === 'connecting'}
style={{
padding: '0.75rem 1.5rem',
borderRadius: '0.5rem',
border: 'none',
background: screenStream ? '#e74c3c' : '#9b59b6',
color: 'white',
cursor: 'pointer',
fontSize: '0.9rem',
opacity: status === 'disconnected' || status === 'connecting' ? 0.5 : 1
}}
>
{screenStream ? 'Stop Sharing' : 'Share Screen'}
</button>
</div>
{/* Camera Preview */}
{cameraStream && (
<div style={{
marginBottom: '1.5rem',
borderRadius: '1rem',
overflow: 'hidden',
background: '#000'
}}>
<video
ref={videoPreviewRef}
autoPlay
playsInline
muted
style={{
width: '100%',
maxHeight: '300px',
objectFit: 'cover'
}}
/>
</div>
)}
{/* Transcript Panel */}
<div style={{
background: 'rgba(255,255,255,0.05)',
borderRadius: '1rem',
padding: '1.5rem',
marginBottom: '1rem',
minHeight: '100px',
textAlign: 'left'
}}>
<div style={{
fontSize: '0.75rem',
color: '#888',
marginBottom: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
You said
</div>
<div style={{ fontSize: '1.1rem', lineHeight: 1.5 }}>
{transcript || (
<span style={{ color: '#555', fontStyle: 'italic' }}>
Start speaking to see your transcript...
</span>
)}
</div>
</div>
{/* Response Panel */}
<div style={{
background: 'rgba(255,255,255,0.05)',
borderRadius: '1rem',
padding: '1.5rem',
minHeight: '150px',
textAlign: 'left'
}}>
<div style={{
fontSize: '0.75rem',
color: '#888',
marginBottom: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
Assistant
</div>
<div style={{ fontSize: '1.1rem', lineHeight: 1.6 }}>
{response || (
<span style={{ color: '#555', fontStyle: 'italic' }}>
The assistant will respond here...
</span>
)}
</div>
</div>
{/* Error Message */}
{error && (
<div style={{
marginTop: '1rem',
padding: '0.75rem 1rem',
background: 'rgba(231, 76, 60, 0.2)',
border: '1px solid rgba(231, 76, 60, 0.3)',
borderRadius: '0.5rem',
fontSize: '0.9rem',
color: '#e74c3c'
}}>
{error}
</div>
)}
</div>
);
}
Test It
- Start the app:
npm run dev - Open http://localhost:5173
- Click "Start Camera" and allow access
- Say "What do you see?" or "Describe what's in front of you"
- The AI will describe what it sees in the camera frame
Try also:
- Hold up an object and ask "What am I holding?"
- Click "Share Screen" and ask "What's on my screen?"
- Ask for help with something visible: "Can you read this text?"
How It Works
Key points:
- Frames captured periodically -
shareVideo(stream, 1000)captures a frame every 1000ms - Latest frame sent with speech - When you stop speaking, the most recent frame is attached
- Image as attachment - LLM receives both your transcript and the image
- No separate API call - Vision is processed in the same LLM request
Customizing Frame Capture
Capture Interval
Adjust how often frames are captured:
// Capture every 500ms (more responsive, more processing)
client.shareVideo(stream, 500);
// Capture every 2000ms (less responsive, less processing)
client.shareVideo(stream, 2000);
Manual Frame Capture
Get the last captured frame programmatically:
const controller = client.shareVideo(stream, 1000);
// Later: get the last frame as base64
const frame = controller.getLastFrame();
if (frame) {
console.log('Frame captured:', frame.substring(0, 50) + '...');
}
Troubleshooting
Camera Not Working
Problem: Camera button does nothing or shows error.
Solutions:
- Check the page is served over HTTPS or localhost
- Verify camera permissions in browser settings
- Check another app isn't using the camera
Screen Share Cancelled
Problem: Screen share window closes immediately.
Solution: This is normal if you clicked Cancel in the browser's screen picker. Try again and select a screen/window.
AI Doesn't See the Image
Problem: AI responds but doesn't mention what it sees.
Solutions:
- Verify your LLM supports vision (Claude, GPT-5.2 do)
- Make sure camera/screen is active before speaking
- Try being explicit: "Look at my camera and describe what you see"
Next Steps
- Tutorial 3: Voice with Tools - Add actions like weather lookup
- Video and Vision Guide - Advanced options (resolution, format)
- Vision Provider Reference - Configure dedicated vision models
Related Documentation
- Video and Vision - Full vision API reference
- Web Client Events - All client events
- Concepts: Vision - How vision works