Gerçek zamanlı bir transkript iki kez gelir: önce sürekli değişen bir taslak olarak, sonra değişmeyen kesinleşmiş bir satır olarak. Taslağı değiştirilebilir bir kuyruk, kesinleşmiş satırı ise geçmiş olarak gösterin; canlı altyazı görünümünün geri kalanı yalnızca altyapı işidir.
Gerçek zamanlı bir oturum, VocalStack API'sinin yaptığı en küçük iştir. Sunucunuz tek bir HTTP çağrısı yapar ve imzalı bir streamUrl alır. İstemciniz bu URL'ye bir WebSocket açar, ham ses verisini yakalandığı anda gönderir ve transkripti konuşmacıdan birkaç an sonra JSON metin çerçeveleri olarak okur. Ses bittiğinde eof gönderirsiniz; sunucu kalan veriyi boşaltır, oturumun ölçtüklerini bildirir ve bağlantıyı kapatır.
Hiçbir şey saklanmaz. Transkript yalnızca ağ üzerinde var olur; bunu bir altyazı görünümü, bir toplantı katmanı ya da kendi uygulamanızdaki bir sesli özellik ister. Sözcüklerin sonradan saklanmasını, paylaşılmasını ve dışa aktarılmasını istiyorsanız canlı transkripsiyon ve yayın kullanmanız gerekir; son bölüm oraya işaret eder.
Bu, çoğu akış konuşma API'sinin ortak yapısıdır: HTTP üzerinden bir kimlik değişimi, ses girip JSON çıktığı uzun ömürlü bir soket ve geçici metni kesinleşmiş metinden ayırmanın bir yolu. Adlar bir API'den diğerine değişir; hareketli parçalar ise nadiren değişir. Burada sırayla şunlar var:
POST /v1/realtime/sessions. Yanıt { sessionId, streamUrl, expiresAt } olur.streamUrl adresine bir WebSocket açın.session.ready gelene kadar session.loading kalp atışlarını bekleyin.transcript.draft (kuyruğu değiştir) ve transcript.committed (kesinleşmiş satır ekle) çerçevelerini okuyun.{"type":"eof"} gönderin; session.done çerçevesini okuyun.Yalnızca 1. adım anahtarınızı gerektirir, bu yüzden sunucunuzda gerçekleşir.
Geliştiriciler, API anahtarları bölümünde Write izniyle bir anahtar oluşturun. Anahtar sunucular ve betikler içindir: API, bir web sayfasından ya da bir URL içinde gönderilen anahtarı reddeder. Bu yüzden oturumu kendi rotanızın arkasında oluşturursunuz; sayfa yalnızca streamUrl görür.
URL kendi belirtecini taşır; bunun nedeni genel olarak bilmeye değer bir tarayıcı sınırlamasıdır. Tarayıcının WebSocket kurucusu istek üstbilgisi ayarlayamaz, bu yüzden anahtar koyabileceğiniz bir Authorization üstbilgisi yoktur. Bunun yerine tarayıcıya açık her konuşma API'si soketini, URL içine yerleştirilmiş (ya da bir alt protokol adına gizlenmiş) kısa ömürlü bir belirteçle doğrular. Kısa ömür bilinçli bir tercihtir: URL'ler günlüklere ve geçmişe düşer, bu yüzden taşıdığının sona ermesi gerekir. expiresAt dolmadan bağlanın.
SDK'lar bir paket kayıt defterinden değil, Geliştiriciler alanından indirilir; hızlı başlangıç kılavuzunun Kurulum bölümü bu ortamın kendi komutlarını gösterir. TypeScript SDK'sı ile bir sunucu rotası şöyle görünür:
// On your server, behind a route of yours (POST /api/transcribe, say):
import { VocalStack } from '@vocalstack/sdk';
const vs = new VocalStack({ baseUrl: '{{api}}', apiKey: process.env.VOCALSTACK_API_KEY });
export async function createStream(): Promise<{ sessionId: string; streamUrl: string }> {
const { sessionId, streamUrl } = await vs.realtime.createSession({ spokenLanguage: 'en' });
return { sessionId, streamUrl };
}
SDK kullanmadan bu tek bir fetch çağrısıdır. Bu örnek de İspanyolca ister ve cümle birleştirmeyi kapatır; aşağıda verilen kablo örneğiyle eşleşir:
const res = await fetch('{{api}}/v1/realtime/sessions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.VOCALSTACK_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ spokenLanguage: 'en', translate: ['es'], sentences: { enabled: false } }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); // the error envelope: { code, message, … }
const { streamUrl } = (await res.json()) as { sessionId: string; streamUrl: string; expiresAt: string };
Her seçeneğin bir varsayılanı vardır. Başta bilmeye değer olanlar spokenLanguage (bunu atlarsanız dil algılanır), translate (hedef dillerin listesi), speakerLabels ve latencyMode seçenekleridir; geri kalanları aşağıda ihtiyaç duyulduğunda gelir.
streamUrl'yi sesi yakalayan her şeye verin; bundan sonra hiçbir anahtar işin içinde değildir.
streamUrl'ye olduğu gibi, başlık koymadan bağlanın. Oturum başlarken sunucu session.loading kalp atışları gönderir. Ardından session.ready gelir. Bundan önce ses göndermeyin. Erken gönderilen ses sonradan kullanılmak üzere saklanmaz; sunucu bunu hata sayar, bir error çerçevesi gönderir ve soketi kapatır.
Soket açıldıktan sonra her hata bu çerçevenin biçimini alır: {"type":"error","code":"…","message":"…","retryable":true|false}. code kapalı bir listedir (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message günlükleriniz içindir, retryable ise yeni bir oturumun denemeye değip değmeyeceğini söyler. İlk çerçeveden itibaren ele alın:
const ws = new WebSocket(streamUrl); // the token is in the URL: no header
ws.binaryType = 'arraybuffer';
ws.addEventListener('message', (m: MessageEvent) => {
if (typeof m.data !== 'string') return;
const f = JSON.parse(m.data);
switch (f.type) {
case 'session.loading':
console.error('starting…');
break;
case 'session.ready':
startMicrophone(); // send audio only after session.ready
break;
case 'error':
console.error(`${f.code}: ${f.message}`);
if (f.retryable) scheduleReconnect(); // a new session, with backoff; see "Stop"
break;
}
});
Ham bir istemcinin iki şeyi daha kendisinin ele alması gerekir; SDK'lar bunu zaten yapar. Birincisi, reddedilen bir yükseltme API'nin olağan HTTP hata zarfını taşır: 401 invalid_token, 410 session_expired ya da uyulması gereken bir Retry-After üstbilgisiyle 503 unavailable.
İkincisi, doygunluk bir hata gibi değil, sessizlik gibi görünür. 30 saniye içinde hiç çerçeve gelmezse bunu capacity_exceeded sayın ve yeniden bağlanın; yeni bir oturum genellikle geçer. Yükleme çerçeveleri gelmeye devam eder ama session.ready hiç gelmezse bunu engine_unavailable sayın; bu da yeniden denenebilir bir durumdur. SDK'larda RealtimeSession.connect, session.ready geldiğinde çözülür ve readyDeadlineMs süresi dolunca doğru kodla bir hata verir.
session.ready geldiğinde sunucu sesin konuşulduğu hızda gelmesini bekler.
Ses, ikili çerçeveler olarak ham PCM biçiminde gider: 32 bit kayan nokta, little-endian, 16 kHz, mono (pcm_f32le, oturumun varsayılan audio biçimi). Yakalandığı anda, yaklaşık 100 ila 250 ms'lik parçalar halinde gönderin; parçalar arasında transkriptin gelmesini beklemeyin.
Bayt hesabını bir kez yapmak işe yarar. Saniyede 16.000 örnek, her biri 4 bayt, saniyede 64.000 bayt eder; yani 100 ms 6.400 bayt, 250 ms ise 16.000 bayttır. Konuşma modelleri genellikle 16 kHz mono ile eğitilir; birçok gerçek zamanlı API'nin bunu istemesinin nedeni de budur. Mikrofonlar ve tarayıcılar ise 44,1 ya da 48 kHz'te ve çoğu zaman stereo olarak yakalar. Baytlar gönderilmeden önce bir şeyin sesi yeniden örneklemesi ve kanalları tek kanala indirmesi gerekir: burada bu işi ffmpeg yapar, tarayıcıda ise SDK'nın mikrofon yardımcısı. 48 kHz ses gönderip 16 kHz olduğunu bildirmek hata vermez; ama yavaş çekilmiş, kötü bir transkript üretir.
Node'da en basit yakalama aygıtı ffmpeg'dir:
import { spawn } from 'node:child_process';
function startMicrophone(): void {
// Raw PCM: f32le, 16 kHz, mono, as binary frames. macOS: -f avfoundation -i :0, Linux: -f pulse -i default.
const mic = spawn('ffmpeg', ['-loglevel', 'error', '-f', 'avfoundation', '-i', ':0', '-f', 'f32le', '-ac', '1', '-ar', '16000', '-']);
mic.stdout.on('data', (chunk: Buffer) => {
// Backpressure: bytes the socket has accepted but not yet put on the wire.
// Two seconds of audio (128,000 bytes) queued means the link, not the server, is behind.
if (ws.bufferedAmount > 128_000) console.error(`link behind by ${ws.bufferedAmount} bytes`);
ws.send(new Uint8Array(chunk));
});
mic.on('close', () => ws.send(JSON.stringify({ type: 'eof' })));
process.once('SIGINT', () => mic.kill());
}
ws.send hiç beklemez; sıraya koyar. bufferedAmount o kuyruğun boyutudur ve sağlıklı bir bağlantıda sıfıra yakın seyreder. Canlı ses duraklatılamaz; büyüyen bir kuyruğa en yararlı tepki onu fark etmektir, çerçeveleri düşürmek değil. Sunucu da geri basınç uygular. Sesi gerçek zamandan birkaç saniye önde kabul eder (limits.audioBufferS), sonra yetişene kadar okumayı durdurur. Geri yönde kesinleşmiş satırlar kayıpsız sıraya girer, taslaklar ise birleştirilir; böylece yavaş okuyan bir istemci daha az ama daha taze taslak görür. Kesinleşmiş satırlara bile yetişemeyen bir istemci slow_consumer koduyla ve retryable: false ile bir error çerçevesi alır ve bağlantısı kesilir. Bunu görüyorsanız çerçeve işleyicinizin çok fazla iş yaptığı anlamına gelir.
Bir tarayıcıda @vocalstack/sdk/mic paketindeki streamMicrophone mikrofon izni ister, sesi 16 kHz'e yeniden örnekler ve parçaları, tarayıcı sekmeyi kıstığında sunucunun boşlukları işaretlemesine yarayan clock çerçeveleriyle birlikte gönderir. Sayfa oturumu rotanızdan alır ve anahtarı hiçbir zaman görmez:
import { RealtimeSession } from '@vocalstack/sdk';
import { streamMicrophone } from '@vocalstack/sdk/mic';
export async function transcribeMicrophone(
stream: { sessionId: string; streamUrl: string },
render: (committed: string, tail: string) => void,
): Promise<void> {
const session = await RealtimeSession.connect(stream);
const mic = await streamMicrophone(session); // asks for the microphone, sends 16 kHz PCM
document.querySelector('#stop')?.addEventListener('click', () => {
void mic.stop();
session.eof();
});
let committed = '';
for await (const ev of session) {
if (ev.type === 'transcript.draft') render(committed, ev.segments.map((s) => s.text).join(' '));
if (ev.type === 'transcript.committed') {
committed += `${ev.segment.text} `;
render(committed, '');
}
}
}
render(committed, tail) imzası UI desenin tamamıdır; bir sonraki bölüm nedenini açıklar.
Transkripti oluşturan iki çerçeve türü vardır ve bunları karıştırmak en yaygın hatadır.
Önce neden iki tür olduğunu açıklayalım. Bir konuşma modeli bir sözcüğün ne olduğuna kısmen onu izleyen sözcüklerden karar verir: "I scream" ile "ice cream" cümle devam edene kadar aynı biçimde duyulur. Bu yüzden gerçek zamanlı bir tanıyıcı önce erken bir tahmin üretir, ardından daha fazla ses geldikçe onu düzeltir ve yeterince dinledikten sonra ancak yerleşir. Her akış konuşma API'si bunu bir biçimde sunar. Bazı API'ler tahminlere "ara" ya da "kısmi" sonuç, yerleşenlere ise is_final işareti koyar; terimler değişir, disiplin değişmez. Burada tahmin bir taslak, yerleşmiş metin ise bir kesinleşmedir.
transcript.draft canlı kuyruktur: son kesinleşmeden bu yana duyulan, hâlâ değişen sözcükler. Her taslak bir önceki taslağın tam yerine geçer. Kuyruğu segments'iyle yeniden çizin; asla eklemeyin. Taslaklar tasarım gereği bir seq taşımaz, bu yüzden onları ileride dönüşecekleri kesinleşmelerle eşleştirmeye çalışmayın.
transcript.committed kesinleşmiştir: seq (0, 1, 2, boşluksuz), start, end, text, speaker ve sözcük başına zamanlamalarıyla words içeren tek bir segment. Ekleyin ve kapsadığı taslağı kaldırın; bir sonraki taslak bu kesinleşmenin bittiği yerden başlar.
committed: [seq 0] [seq 1]
draft: "how are" ← frame 1
draft: "how are you do" ← frame 2 REPLACES frame 1
committed: [seq 0] [seq 1] [seq 2 "How are you doing?"]
← the commit consumes the tail; drop the draft
Taslakları ekleyen bir arayüz yinelenmiş, titreyen metin gösterir. Konuşmacının hiç yapmadığı bir takılma, altyazı görünümünüze girer. Tüm kesinleşmiş segmentleri çizin, ardından en son taslağı onların arkasına koyun ve bir kesinleşme geldiğinde taslağı temizleyin. Bir kesinleşme, hiçbir taslakta görünmemiş sözcükler de içerebilir.
Yukarıdaki fetch örneğinden (translate: ["es"], cümle birleştirme kapalı) birkaç saniyelik konuşmanın kabloda nasıl göründüğü şöyledir:
{"type":"session.loading"}
{"type":"session.ready"}
{"type":"transcript.draft","segments":[{"start":0.0,"text":"Good","speaker":0,"tentative":true}]}
{"type":"transcript.draft","segments":[{"start":0.0,"text":"Good morning every","speaker":0,"tentative":true}]}
{"type":"transcript.committed","segment":{"seq":0,"start":0.0,"end":1.4,"text":"Good morning, everyone.","speaker":0,"words":[{"start":0.0,"end":0.3,"text":"Good"},{"start":0.3,"end":0.8,"text":"morning,"},{"start":0.8,"end":1.4,"text":"everyone."}]}}
{"type":"transcript.translated","seq":0,"translations":{"es":"Buenos días a todos."}}
{"type":"transcript.draft","segments":[{"start":1.6,"text":"Thanks for","speaker":0,"tentative":true}]}
Bunların etrafında iki çerçeve daha var. transcript.amended, içindeki bir sözcük düzeltildiğinde kesinleşmiş bir segmenti yerinde düzeltir (aynı seq, yeni text ya da speaker); zaten çizdiğiniz satıra uygulayın. transcript.words, kesinleşmeden hemen önce taslağın sözcük zamanlamalarını akıtır. Bunun için events.words gerekir; bu seçenek varsayılan olarak açıktır. Bir kesinleşme ise etkinlik seçenekleri ne derse desin her zaman kendi words alanını taşır. Son olarak, tanımadığınız çerçeve türlerini yok sayın: yeni türler haber verilmeden eklenir.
Python SDK'sının okuma döngüsü şöyle; terminal altyazı görünümü olarak kullanılıyor. Burada da cümle birleştirme kapalı, bu yüzden çeviriler transcript.translated olarak gelir ve error çerçevesi döngüden fırlatılan bir VocalStackError olarak ortaya çıkar:
import asyncio
import os
from vocalstack import RealtimeSession, VocalStack, VocalStackError
MIC = ["ffmpeg", "-loglevel", "error", "-f", "avfoundation", "-i", ":0",
"-f", "f32le", "-ac", "1", "-ar", "16000", "-"]
async def main() -> None:
async with VocalStack("{{api}}", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
created = await vs.realtime.create_session(
spoken_language="en", translate=["es"], sentences={"enabled": False}
)
session = await RealtimeSession.connect(created.session_id, created.stream_url)
mic = await asyncio.create_subprocess_exec(*MIC, stdout=asyncio.subprocess.PIPE)
async def feed() -> None:
assert mic.stdout is not None
while chunk := await mic.stdout.read(8000): # 125 ms of audio
await session.send_audio(chunk)
await session.eof() # the server flushes, then sends session.done
feeder = asyncio.create_task(feed())
try:
async with session:
async for event in session:
if event.type == "transcript.draft":
# The live tail: replace it, never append it.
tail = " ".join(s.text for s in event.segments)
print(f"\r\x1b[K\x1b[2m{tail}\x1b[0m", end="", flush=True)
elif event.type == "transcript.committed":
# Final: append it (seq 0, 1, 2...), and the tail it covers is gone.
print(f"\r\x1b[K{event.segment.text}", flush=True)
elif event.type == "transcript.translated":
print(f" es: {event.translations.get('es')}")
await feeder
print(f"done ({session.done_reason})", session.stats)
except VocalStackError as e:
# An `error` frame mid-stream, or a refused connect.
print(f"\nerror {e.code}: {e.message} (retryable: {e.retryable})")
finally:
if mic.returncode is None:
mic.kill()
asyncio.run(main())
Segment başına çeviri, yukarıda olduğu gibi en basit biçimdir. Okuyucuların tercih ettiği biçim kendi bölümünü hak ediyor.
translate: ["es", "fr"] çevirileri oturum ilerledikçe ister. Bunların nasıl geleceği, varsayılan olarak açık olan cümle birleştirmeye (sentences: { enabled: true }) bağlıdır.
Cümleler kapalıyken her kesinleşmenin hemen ardından, seq değerini belirten ve translations değerlerini dile göre anahtarlayan bir transcript.translated çerçevesi gelir; bunları o segmente ekleyin. Şimdiye kadarki örneklerin kullandığı budur. Basittir ve bir kesinleşme günlüğü gibi okunur: bir segment, bir çeviri ve konuşmacının nefes almak için durduğu her yerde bir cümle sınırı.
Cümleler açıkken sunucu kesinleşmeleri tam cümlelere birleştirir ve onları çevirir; bu çok daha iyi okunur. Her transcript.sentence çerçevesi, sentenceId ile anahtarlanmış tek bir cümlenin tam yerine geçer. status open durumundan closed durumuna (ya da atılması gerektiğini söyleyen dropped durumuna) geçer; translations açıkken geçicidir, kapandığında kesinleşir. sentenceId'ye göre ekleyin ya da güncelleyin, asla eklemeyin. TypeScript SDK'sı tam bunun için applySentence sunar:
import { applySentence, type SentenceSegment } from '@vocalstack/sdk';
let sentences: SentenceSegment[] = [];
for await (const ev of session) {
if (ev.type !== 'transcript.sentence') continue;
sentences = applySentence(sentences, ev.sentence); // upsert by sentenceId
if (ev.sentence.status === 'closed') console.log(`${ev.sentence.text}\n es: ${ev.sentence.translations?.es}`);
}
Çeviriler transkripti bir iki saniye geriden izler. Bunların sesli okunmasını istiyorsanız voice: ["es"] ekleyin (cümleler açık olmalı). Kapanan her cümlenin İspanyolcası, 24 kHz mono base64 Ogg/Opus klip olarak bir transcript.voice çerçevesiyle gelir. Uzun bir cümle parçalar halinde gelir (parts içinde part), bunlar art arda oynatılır. Geçici metin asla seslendirilmez. Canlı çeviri kılavuzu sesleri ayrıntılı anlatır; saniyelerin nereye gittiği ise gecikme zincirini açıklar.
Hedef diller oluşturulduğunda sabitlenmez.
Bir session.update metin çerçevesi, o andan itibaren spokenLanguage, translate, speakerLabels, latencyMode, voice ya da voiceByLang ayarlarını değiştirir; bıraktığınız alanlar değişmeden kalır.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK'lar bunu session.update(…) olarak sunar. translate tüm listeyi değiştirir ve [] çeviriyi kapatır. Daha önce iletilmiş segmentler yeniden transkript edilmez ya da yeniden çevrilmez; konuşmacı etiketlerini açarsanız konuşmacılar yalnızca o andan itibaren etiketlenir. Söylenen şey söylenmiş kalır; ayarlar yalnızca bundan sonrasını yönetir.
Ses bittiğinde {"type":"eof"} gönderin (SDK'larda session.eof()). Sunucu elinde tuttuğu veriyi transkript eder, son kesinleşmeleri gönderir, ardından session.done gönderir ve kapatır. Bunun yerine cancel gönderirseniz hemen durur ve kalanı atar.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason şunlardan biridir: eof, max_duration (oturum planın azami süresine ulaştı ve aynı boşaltma işlemi çalıştı) ya da cancelled. stats sunucu tarafından ölçülür: audioS gönderdiğiniz sestir, processingS oturumun çalıştığı duvar saati süresidir, realtimeFactor ise ikisinin oranıdır. Canlı bir oturum 1'in biraz altında sonuçlanır; çünkü duvar saati, eof gönderiminizden sonraki son boşaltmayı da içerir. Bunun belirgin biçimde altında bir değer, oturumun sesin süresinden önemli ölçüde uzun sürdüğü anlamına gelir; ilk bakılacak yer sesin ne kadar hızlı geldiğidir. Bir dosya için aynı sayı, transkriptin oynatma süresinden kaç kat hızlı geri geldiğini gösterir.
Bir oturum en fazla GET /v1/realtime/capabilities içindeki limits.maxDurationS kadar sürer; sesi kredinizi planınızın canlı oranından saniye başına harcar ve her transkripsiyonla aynı 5 dakikalık asgari süreye tabidir: ses alan her oturum en az 5 dakika üzerinden faturalanır (fiyatlandırma). Aynı soket tüm bir dosyayı da alabilir: oturumu input: { kind: "file" } ile oluşturun, dosyanın kendi baytlarını (mp3, wav, flac, ogg, m4a) ve eof gönderin; transkript, ses oynatılma hızının çok katı hızda, yol boyunca transcript.progress çerçeveleriyle birlikte geri gelir.
Devam etme yoktur. Bağlantı koparsa oturum biter; yeniden bağlanmak yeni bir oturum oluşturmak demektir. Yeni oturum kendi asgari süresiyle faturalandırılır ve canlı kaynağınızdan baştan akıtmanız gerekir; seq yeniden 0'dan başlar. Yeniden bağlanma bütün soket API'leri için standart bir kalıptır ve bir kez doğru yapmaya değer:
error çerçevesinde retryable: true, Retry-After ile bir 503 ya da capacity_exceeded anlamına gelen 30 saniye boyunca çerçeve gelmemesi. invalid_token ve session_expired eski URL ile yeniden denemek anlamına gelmez; sunucunuzdan yeni bir streamUrl almak gerekir.Oturum oluşturan rotayı çağrılması ucuz tutun ve bir kapanmayı yeni bir başlangıç olarak ele alın. Devam etmemek, hiçbir şeyi saklamamanın bedelidir; transkriptin soketten uzun yaşaması gerekiyorsa onu saklayın.
Aynı akışın bir kayıt olarak saklanmasını, sonradan düzeltilmesini, dışa aktarılmasını ve bir dinleyici kitlesinin kendi dillerinde okumasını istediğinizde, bunun yerine onu bir canlı transkripsiyona yayımlayın. Canlı yayına alma kılavuzu, aynı PCM soketinin bir transkripsiyon ve bir yayın bağlantısı üzerinden nasıl geçtiğini gösterir; dinleme kılavuzu ise abone tarafını gösterir: her dinleyici bir dil seçer ve geç katılan biri bir lastSeq imleciyle yakalar. Bir oda için bunun neye benzediği, tek bağlantı, her dil yazısının konusudur.
Saklamak istediğiniz bir dosya için kalıcı yol, API ile bir dosyayı transkript etme yazısıdır. Her alanı içeren tam çerçeve başvurusu ise API başvurusundaki Bir mikrofonu akıtma kılavuzudur.