صدا، زنده و دوطرفه
این سرویس همان Realtime API است، نه زنجیرهٔ STT → LLM → TTS. رلهٔ ما دو سوکت را نگه میدارد و فریمها را دستنخورده رد میکند؛ کلاینتِ تو مستقیماً با پروتکلِ Realtime حرف میزند. به همین دلیل تشخیصِ پایانِ صحبت، قطعکردنِ حرفِ مدل، رونوشتِ زنده و function calling رفتارِ اصلیِ مدلاند و ما چیزی را بازسازی نکردهایم.
صدا از سرورِ لندنِ ما عبور میکند چون مسیرِ مستقیمِ مرورگر به api.openai.com از ایران باز نیست — و همین دلیلِ نبودِ WebRTC است.
کلید روی سرور، سکرت روی کلاینت
سوکت عمداً کلیدِ API را نمیپذیرد. سرورِ تو با کلیدش یک کلاینتسکرتِ کوتاهعمر میگیرد و آن را به فرانتاندِ خودش میدهد؛ آنچه در مرورگر قرار میگیرد فقط اجازهٔ بازکردنِ یک تماس است و دو دقیقه بعد میمیرد. گمشدنِ یک سکرت یعنی یک پنجرهٔ کوتاه روی یک حساب؛ گمشدنِ کلید یعنی کیفِ پول.
curl -X POST https://1xai.ir/v1/realtime/client_secret \
-H "Authorization: Bearer $ONEXAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "gpt-realtime-mini"}' # optional; default gpt-realtime
# {
# "client_secret": { "value": "ek_...", "expires_at": 1756240000 },
# "model": "gpt-realtime-mini",
# "url": "wss://1xai.ir/rt/voice"
# }# Your backend. The API key lives here and only here.
import httpx
from fastapi import FastAPI
app = FastAPI()
@app.post("/voice-token")
async def voice_token():
async with httpx.AsyncClient() as http:
r = await http.post(
"https://1xai.ir/v1/realtime/client_secret",
headers={"Authorization": f"Bearer {ONEXAI_API_KEY}"},
)
r.raise_for_status()
return r.json() # hand straight to your front endبازکردنِ تماس
مرورگر نمیتواند روی دستدادنِ WebSocket هدر بگذارد، پس سکرت در کوئریاسترینگ میرود؛ کلاینتِ سمتِ سرور هدر دارد و باید از هدر استفاده کند.
// Your page. Never sees the API key.
const { client_secret, url } = await fetch("/voice-token", { method: "POST" })
.then((r) => r.json());
const ws = new WebSocket(`${url}?client_secret=${client_secret.value}`);
ws.onopen = () => {
// Configure the session: voice, Persian transcription, turn detection.
ws.send(JSON.stringify({
type: "session.update",
session: {
instructions: "به فارسی و کوتاه پاسخ بده.",
audio: {
input: {
format: "pcm16",
transcription: { model: "whisper-1", language: "fa" },
// Server-side VAD: the model decides when you stopped talking,
// and interrupting it cancels its reply (barge-in).
turn_detection: { type: "server_vad", interrupt_response: true },
},
output: { format: "pcm16", voice: "alloy" },
},
tools: [{
type: "function",
name: "lookup_order",
description: "Look up an order in the ERP",
parameters: {
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
},
}],
},
}));
};
ws.onmessage = (e) => {
const ev = JSON.parse(e.data);
switch (ev.type) {
case "conversation.item.input_audio_transcription.completed":
console.log("user said:", ev.transcript); // live transcript
break;
case "response.output_audio.delta":
playPCM(atob(ev.delta)); // model speaking
break;
case "response.function_call_arguments.done":
callYourERP(ev.name, JSON.parse(ev.arguments)); // function calling
break;
case "error":
// insufficient_funds arrives here before the socket closes
console.error(ev.error);
break;
}
};
// Microphone in: base64 PCM16 frames, as they are captured.
function sendMic(pcm16) {
ws.send(JSON.stringify({ type: "input_audio_buffer.append", audio: pcm16 }));
}// Server-side client (no browser). Header instead of query string —
// query strings reach access logs, headers do not.
import WebSocket from "ws";
const ws = new WebSocket("wss://1xai.ir/rt/voice", {
headers: { Authorization: `Bearer ${clientSecret}` },
});Gemini Live
برای gemini-3.1-flash-live-preview یا gemini-3.8-live همان کلاینتسکرت را با model مدلِ Gemini بگیر؛ پاسخ، آدرسِ wss://1xai.ir/rt/gemini را برمیگرداند. روی این سوکت پروتکلِ خودِ Gemini Live API را حرف بزن: اولین پیام setup است و مدلِ آن با مدلِ سکرت جایگزین میشود. ابزارهای جستوجوی گوگل، نقشه و URL context حذف میشوند؛ function calling و اجرای کد میمانند. هزینهٔ هر نوبت با رسیدنِ usageMetadata همان نوبت تسویه میشود.
// Gemini Live: mint with {"model": "gemini-3.1-flash-live-preview"}
// (or "gemini-3.8-live"); the answer's url is wss://1xai.ir/rt/gemini.
// Then speak Google's Live API (BidiGenerateContent) as-is.
const ws = new WebSocket(`${url}?client_secret=${client_secret.value}`);
ws.onopen = () => {
// The first message must be the setup. Its "model" is replaced with the
// secret's; Google Search, Maps and URL context tools are removed
// (function calling and code execution are kept).
ws.send(JSON.stringify({
setup: {
model: "models/gemini-3.1-flash-live-preview",
generationConfig: { responseModalities: ["AUDIO"] },
systemInstruction: { parts: [{ text: "به فارسی و کوتاه پاسخ بده." }] },
inputAudioTranscription: {},
outputAudioTranscription: {},
},
}));
};
// Microphone in: base64 PCM16, 16 kHz mono.
function sendMic(pcm16) {
ws.send(JSON.stringify({
realtimeInput: { audio: { data: pcm16, mimeType: "audio/pcm;rate=16000" } },
}));
}
ws.onmessage = async (e) => {
const text = typeof e.data === "string" ? e.data : await e.data.text();
const msg = JSON.parse(text);
for (const part of msg.serverContent?.modelTurn?.parts ?? []) {
if (part.inlineData) playPCM(atob(part.inlineData.data)); // 24 kHz PCM16
}
// usageMetadata arrives with each turnComplete; that turn is billed then.
};محدودیتها و هزینه
- مدل
- هنگامِ گرفتنِ کلاینتسکرت با model انتخاب میشود: gpt-realtime (پیشفرض)، gpt-realtime-mini، gpt-realtime-2.1-mini، gemini-3.1-flash-live-preview و gemini-3.8-live. سکرت فقط همان مدل را باز میکند و کلاینت بعداً نمیتواند عوضش کند.
- ترابری
- WebSocket — بدون WebRTC.
- سقفِ زمانِ تماس
- سی دقیقه؛ پس از آن سوکت بسته میشود.
- عمرِ کلاینتسکرت
- دو دقیقه از لحظهٔ صدور — فقط برای بازکردنِ سوکت، نه برای طولِ تماس.
- حداقلِ اعتبار
- ۲٬۰۰۰ تومان برای شروعِ تماس. تماس در حینِ اجرا تسویه میشود و با تمامشدنِ اعتبار با رویدادِ insufficient_funds بسته میشود.
- هزینه
- توکنِ صوتی جدا از متن و با نرخِ صوتیِ ارائهدهنده بهعلاوهٔ ۲۰٪ حساب میشود و چند برابرِ متن است. صدای کششده (گفتوگوی قبلی که هر نوبت دوباره خوانده میشود) با نرخِ کشِ ارائهدهنده حساب میشود؛ روی gpt-realtime یعنی ۸۰ برابر ارزانتر از صدای تازه. رونوشتِ صدای ورودی (اگر روشنش کنی) جدا و با نرخِ مدلِ رونوشت حساب میشود؛ مدلهای مجاز: whisper-1، gpt-4o-transcribe، gpt-4o-mini-transcribe، gpt-realtime-whisper و gpt-live-transcribe. تسویه بعد از هر پاسخِ مدل انجام میشود، نه در پایانِ تماس. نرخِ لحظهای در صفحهٔ قیمتها است.
- هر کاربر، یک نشست
- هر سوکت نشستِ مستقلِ خودش را دارد؛ چیزی بین تماسها مشترک نیست.
- این سرویس Realtime واقعی است یا زنجیرهٔ STT → LLM → TTS؟
- واقعی است. رلهٔ ما فریمها را دستنخورده بین اپ تو و Realtime API رد میکند؛ هیچ تبدیل متنبهگفتاری در میانه نیست. یعنی VAD، قطعکردنِ صحبتِ مدل، رونوشتِ زنده و function calling همانهایی هستند که خودِ مدل ارائه میدهد، نه شبیهسازیِ ما.
- چرا باید کلاینتسکرت بگیرم و کلید API را مستقیم روی سوکت نفرستم؟
- چون کلیدِ API از کیفِ پولِ تو خرج میکند و اگر در جاوااسکریپتِ سمتِ کاربر قرار بگیرد، در devtools هر بازدیدکننده هم هست. سوکت عمداً کلیدِ خام را نمیپذیرد و فقط مقدارِ ek_ را قبول میکند؛ عمرِ آن دو دقیقه است و تنها یک کار میکند: بازکردنِ یک تماس.
- WebRTC هم پشتیبانی میشود؟
- خیر — فقط WebSocket. WebRTC اتصالِ مستقیمِ مرورگر به سرورِ ارائهدهنده است و دقیقاً همان چیزی است که از داخلِ ایران کار نمیکند؛ صدا باید از سرورِ ما عبور کند.
- حداکثر زمان هر تماس چقدر است؟
- سی دقیقه. تماسی که از این بیشتر طول بکشد در عمل یک تبِ فراموششده است، نه گفتوگو — و بدون سقف تا خالیشدنِ کیفِ پول هزینه میتراشد.
- کدام مدلها را میتوانم انتخاب کنم؟
- gpt-realtime (پیشفرض)، gpt-realtime-mini و gpt-realtime-2.1-mini روی /rt/voice با پروتکلِ OpenAI؛ و gemini-3.1-flash-live-preview و gemini-3.8-live روی /rt/gemini با پروتکلِ Gemini Live. مدل را موقعِ گرفتنِ کلاینتسکرت با فیلدِ model انتخاب کن؛ سکرت فقط همان مدل را باز میکند. نسخههای mini صدا را حدودِ یکسومِ gpt-realtime و متن را حدودِ یکششمِ آن حساب میکنند.
- هزینهٔ صدا چطور حساب میشود؟
- توکنهای صوتی جدا از متن و با نرخِ صوتیِ خودِ ارائهدهنده بهعلاوهٔ ۲۰٪ حساب میشوند؛ صدا چند برابرِ متن است. هزینه بعد از هر پاسخِ مدل تسویه میشود، نه در پایانِ تماس، و نرخِ لحظهای در صفحهٔ قیمتها هست.
سؤالی ماند؟ بنویس یا مرجعِ کاملِ رویدادها را در مستندات ببین.