वेब ऐप्स बनाना
यह ब्राउज़र में एक realtime agent डालने के लिए व्यावहारिक मार्गदर्शिका है: server-side bridge पैटर्न, WebSocket प्रोटोकॉल, और वेब ऑडियो कोड जो माइक को कैप्चर करता है और agent को बिना किसी रुकावट के चलाता है। इस खंड में हर वेब उदाहरण ठीक इसी तरह बनाया गया है; customer_service सर्वर संदर्भ कार्यान्वयन है।
server-side bridge क्यों
एक ब्राउज़र प्रदाता के realtime WebSocket को सीधे नहीं रख सकता है:
- आपका
OPENAI_API_KEY/GEMINI_API_KEYहर क्लाइंट को भेजा जाएगा, - tools ब्राउज़र में चलेंगे, आपके डेटा और क्रेडेंशियल से दूर,
- आप क्लाइंट कोड में एक प्रदाता के वायर फॉर्मेट तक सीमित रहेंगे।
तो ब्राउज़र एक पतला ऑडियो/वीडियो डिवाइस है और आपका Rust सर्वर सेशन का मालिक है:
browser ──mic PCM16 + camera JPEG (base64 over your WS)──▶ Axum /ws
browser ◀──agent PCM16 + transcripts + tool events─────── IntegratedRealtimeRunner ──▶ provider
कुंजी सर्वर पर रहती है, tools सर्वर पर चलते हैं, और आप क्लाइंट को छुए बिना प्रति कनेक्शन (/ws?provider=openai|gemini) प्रदाता बदल सकते हैं।
WebSocket प्रोटोकॉल
एक छोटा JSON प्रोटोकॉल आपके अपने WebSocket पर चलता है। ब्राउज़र → सर्वर:
type | फ़ील्ड | अर्थ |
|---|---|---|
input_audio | audio (base64 PCM16) | माइक ऑडियो का एक खंड |
video_frame | mime, data (base64) | एक कैमरा फ़्रेम |
text | text | एक टाइप किया गया चैट संदेश |
hangup | — | सत्र समाप्त करें |
सर्वर → ब्राउज़र:
type | फ़ील्ड | इस रूप में प्रस्तुत करें |
|---|---|---|
ready | provider, input_rate, output_rate | निर्धारित ऑडियो दरें — अपने AudioContexts बनाएँ |
audio | audio (base64 PCM16) | निर्बाध प्लेबैक के लिए पंक्तिबद्ध करें |
agent_transcript | delta | एजेंट के बबल में जोड़ें |
user_transcript_delta | delta | उपयोगकर्ता के भाषण का लाइव कैप्शन |
user_transcript | text | अंतिम उपयोगकर्ता प्रतिलेख |
user_speaking / user_stopped | — | VAD स्थिति (एक माइक संकेतक को नियंत्रित करें; बार्ज-इन के लिए user_speaking पर प्लेबैक फ्लश करें) |
tool | name, args | एक "टूल चल रहा है..." चिप |
response_done | — | बारी समाप्त |
error | message | त्रुटि दिखाएँ |
यह ServerEvent पर एक पतला, ऐप-परिभाषित मैपिंग है — नीचे server_event_to_client_json देखें।
सर्वर: Axum ब्रिज
हैंडलर एक IntegratedRealtimeRunner बनाता है, कनेक्ट करता है, ready भेजता है, फिर दो समवर्ती लूप चलाता है — आउटबाउंड (रीयलटाइम इवेंट → ब्राउज़र) और इनबाउंड (ब्राउज़र → सेशन) — जो tokio::select! से जुड़े होते हैं।
async fn handle_ws(socket: WebSocket, provider: Provider) {
let session_id = uuid::Uuid::new_v4().to_string();
let (mut sender, mut receiver) = socket.split();
let runner = Arc::new(build_runner(provider, &session_id).await.unwrap());
runner.connect().await.unwrap();
// Negotiate audio rates to the browser BEFORE any audio flows.
let (input_rate, output_rate) = provider.audio_rates();
sender.send(Message::Text(json!({
"type": "ready", "provider": provider.name(),
"input_rate": input_rate, "output_rate": output_rate,
}).to_string().into())).await.ok();
// Outbound: realtime events → browser.
let out_runner = runner.clone();
let outbound = async move {
while let Some(event) = out_runner.next_event().await {
if let Ok(ev) = event {
if let Some(payload) = server_event_to_client_json(ev) {
if sender.send(Message::Text(payload.to_string().into())).await.is_err() { break; }
}
}
}
};
// Inbound: browser mic/camera/text → session.
let in_runner = runner.clone();
let inbound = async move {
while let Some(Ok(Message::Text(text))) = receiver.next().await {
match serde_json::from_str::<ClientMsg>(&text) {
Ok(ClientMsg::InputAudio { audio }) => { in_runner.send_audio(&audio).await.ok(); }
Ok(ClientMsg::VideoFrame { mime, data }) => { in_runner.send_video_frame(&mime, &data).await.ok(); }
Ok(ClientMsg::Text { text }) => {
if in_runner.send_text(&text).await.is_ok() { in_runner.create_response().await.ok(); }
}
Ok(ClientMsg::Hangup) => break,
_ => {}
}
}
};
tokio::select! { _ = outbound => {}, _ = inbound => {} }
runner.close().await.ok();
}
दो इनपुट विषमताओं पर ध्यान दें जिन्हें आपको सही करना होगा:
- टेक्स्ट को
create_response()की आवश्यकता है — उत्तर को ट्रिगर करने के लिए कोई VAD नहीं है (देखें Architecture: turn lifecycle)। सर्वर VAD के तहत ऑडियो ऐसा नहीं करता है। - वीडियो त्रुटियां गैर-घातक हैं — लॉग करें और जारी रखें; एक गिरा हुआ फ्रेम कॉल को खत्म नहीं करना चाहिए।
सर्वर इवेंट्स की मैपिंग
आउटबाउंड लूप प्रत्येक प्रदाता-अज्ञेयवादी ServerEvent को ऊपर दिए गए कॉम्पैक्ट क्लाइंट JSON में परिवर्तित करता है, उन इवेंट्स के लिए None लौटाता है जिन्हें UI अनदेखा करता है:
fn server_event_to_client_json(event: ServerEvent) -> Option<serde_json::Value> {
match event {
ServerEvent::AudioDelta { delta, .. } =>
Some(json!({ "type": "audio", "audio": BASE64.encode(&delta) })),
ServerEvent::TranscriptDelta { delta, .. } =>
Some(json!({ "type": "agent_transcript", "delta": delta })),
ServerEvent::InputTranscriptDelta { delta, .. } =>
Some(json!({ "type": "user_transcript_delta", "delta": delta })),
ServerEvent::SpeechStarted { .. } => Some(json!({ "type": "user_speaking" })),
ServerEvent::FunctionCallDone { name, arguments, .. } =>
Some(json!({ "type": "tool", "name": name, "args": arguments })),
ServerEvent::ResponseDone { .. } => Some(json!({ "type": "response_done" })),
ServerEvent::Error { error, .. } => Some(json!({ "type": "error", "message": error.message })),
_ => None, // ServerEvent is #[non_exhaustive]
}
}
ब्राउज़र: माइक को PCM16 के रूप में कैप्चर करना
प्रदाता बातचीत की गई input_rate पर रॉ PCM16 मोनो चाहता है। उस दर पर एक AudioContext के साथ कैप्चर करें, फ्लोट सैंपल्स को 16-बिट तक डाउनसैंपल करें, उन्हें base64 करें, और भेजें:
let inputRate, outputRate;
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'ready') { inputRate = msg.input_rate; outputRate = msg.output_rate; startMic(); }
// …handle audio / transcripts / tool / etc.
};
async function startMic() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const ctx = new AudioContext({ sampleRate: inputRate });
const src = ctx.createMediaStreamSource(stream);
const node = ctx.createScriptProcessor(4096, 1, 1); // or an AudioWorklet
node.onaudioprocess = (ev) => {
const f32 = ev.inputBuffer.getChannelData(0);
const pcm16 = new Int16Array(f32.length);
for (let i = 0; i < f32.length; i++) {
const s = Math.max(-1, Math.min(1, f32[i]));
pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
ws.send(JSON.stringify({ type: 'input_audio', audio: bytesToBase64(new Uint8Array(pcm16.buffer)) }));
};
src.connect(node); node.connect(ctx.destination);
}
ब्राउज़र: गैपलेस प्लेबैक + बर्ज-इन
एजेंट का ऑडियो output_rate पर PCM16 चंक्स की एक स्ट्रीम के रूप में आता है। प्रत्येक को फ्लोट में डीकोड करें, इसे प्लेबैक AudioContext पर बैक-टू-बैक शेड्यूल करें, और एक रनिंग कर्सर रखें ताकि चंक्स ओवरलैप न हों या गैप न हों:
const playCtx = new AudioContext({ sampleRate: outputRate });
let playHead = 0;
const sources = [];
function playChunk(base64) {
const bytes = base64ToBytes(base64);
const pcm16 = new Int16Array(bytes.buffer);
const buf = playCtx.createBuffer(1, pcm16.length, outputRate);
const ch = buf.getChannelData(0);
for (let i = 0; i < pcm16.length; i++) ch[i] = pcm16[i] / 0x8000;
const node = playCtx.createBufferSource();
node.buffer = buf;
node.connect(playCtx.destination);
const startAt = Math.max(playCtx.currentTime, playHead);
node.start(startAt);
playHead = startAt + buf.duration;
sources.push(node);
}
// Barge-in: when the user starts speaking, stop the agent immediately.
function flushPlayback() {
for (const n of sources) { try { n.stop(); } catch {} }
sources.length = 0;
playHead = 0;
}
user_speaking मैसेज पर flushPlayback() को कॉल करें ताकि उपयोगकर्ता के बाधित होते ही एजेंट बात करना बंद कर दे — एक वास्तविक बातचीत का स्वाभाविक अनुभव।
कैमरा फ्रेम्स
कैनवास कैप्चर लूप के लिए Multimodal देखें — <video> को एक कैनवास पर ड्रा करें, toDataURL('image/jpeg', 0.6), उपसर्ग को हटा दें, और प्रदाता-उपयुक्त ताल (~700 ms Gemini, ~2.5 s OpenAI) पर एक video_frame मैसेज भेजें।
एक चेकलिस्ट
- कुंजी + उपकरण सर्वर पर रहते हैं, कभी ब्राउज़र पर नहीं।
- ऑडियो से पहले
input_rate/output_rateके साथreadyभेजें; उन दरों परAudioContexts बनाएं। - माइक को
input_rateपर PCM16 मोनो के रूप में कैप्चर करें। -
output_rateपर शेड्यूलिंग कर्सर के साथ एजेंट ऑडियो को गैपलेसली चलाएं। -
user_speaking(बर्ज-इन) पर प्लेबैक फ्लश करें। -
send_textके बादcreate_response()को कॉल करें (VAD ऑडियो के लिए नहीं)। -
video_frameभेजने की त्रुटियों को गैर-घातक मानें। -
ServerEventसे मेल खाने पर_ => {}आर्म करें (यह#[non_exhaustive]है)।
अगला: Examples →