Transkrip real-time tiba dua kali: pertama sebagai draf yang terus berubah, lalu sebagai baris final yang tidak lagi berubah. Tampilkan draf sebagai ekor yang bisa diganti dan baris final sebagai riwayat, dan sisanya dari tampilan keterangan langsung hanyalah perekat teknis.
Sesi real-time adalah hal terkecil yang dilakukan API VocalStack. Server Anda membuat satu panggilan HTTP dan menerima streamUrl yang sudah ditandatangani. Klien Anda membuka WebSocket ke URL itu, mengirim audio mentah saat ditangkap, dan membaca transkrip kembali sebagai frame teks JSON, hanya beberapa saat di belakang pembicara. Saat audio berakhir, Anda mengirim eof; server menuntaskan sisa datanya, melaporkan apa yang diukur selama sesi, lalu menutup koneksi.
Tidak ada yang disimpan. Transkrip hanya ada selama dalam perjalanan di jaringan, dan itulah yang dibutuhkan tampilan keterangan, overlay rapat, atau fitur suara di aplikasi Anda sendiri. Jika Anda ingin kata-katanya disimpan, dibagikan, dan diekspor setelahnya, Anda membutuhkan transkripsi langsung dan siaran sebagai gantinya; bagian terakhir menunjuk ke sana.
Bentuk ini sama dengan yang dipakai sebagian besar API ucapan streaming: pertukaran kredensial lewat HTTP, socket berumur panjang dengan audio masuk dan JSON keluar, serta cara membedakan teks sementara dari teks final. Namanya berbeda antar-API; bagian-bagian yang bergerak jarang berbeda. Berikut urutannya:
POST /v1/realtime/sessions dengan kunci API Anda, di server Anda. Jawabannya adalah { sessionId, streamUrl, expiresAt }.streamUrl.session.loading sampai session.ready.transcript.draft (ganti ekor) dan transcript.committed (tambahkan baris final).{"type":"eof"}; baca session.done.Hanya langkah 1 yang membutuhkan kunci Anda, jadi langkah itu dijalankan di server Anda.
Buat kunci di Developers, API keys dengan izin Write. Kunci ditujukan untuk server dan skrip: API menolak kunci yang dikirim dari halaman web atau di dalam URL, jadi Anda membuat sesi di balik rute milik Anda sendiri, dan halaman hanya pernah melihat streamUrl.
URL membawa tokennya sendiri karena satu keterbatasan browser yang perlu diketahui secara umum. Konstruktor WebSocket di browser tidak bisa mengatur header permintaan, sehingga tidak ada header Authorization tempat kunci bisa diletakkan; setiap API ucapan yang menghadap browser mengautentikasi socket-nya dengan token berumur pendek di dalam URL (atau diselipkan dalam nama subprotokol). Berumur pendek adalah intinya: URL berakhir di log dan riwayat, jadi apa pun yang dibawanya harus kedaluwarsa. Hubungkan sebelum expiresAt.
SDK diunduh dari area Developers, bukan dari registri paket; bagian Install di quickstart menampilkan perintah khusus lingkungan ini. Dengan SDK TypeScript, rute server terlihat seperti ini:
// 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 };
}
Tanpa SDK, cukup satu fetch. Yang ini juga meminta bahasa Spanyol dan mematikan perakitan kalimat, sesuai dengan contoh jaringan di bawah:
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 };
Setiap opsi punya nilai default. Yang perlu diketahui sejak awal adalah spokenLanguage (jika dihilangkan, bahasanya dideteksi), translate (daftar bahasa tujuan), speakerLabels dan latencyMode; sisanya akan dibahas saat dibutuhkan di bawah.
Serahkan streamUrl ke apa pun yang menangkap audio; mulai dari sini, tidak ada kunci yang terlibat.
Hubungkan ke streamUrl apa adanya, tanpa header. Selama sesi dimulai, server mengirim heartbeat session.loading. Setelah itu datang session.ready. Jangan kirim audio sebelum itu. Audio yang dikirim terlalu awal tidak disimpan untuk nanti; server memperlakukannya sebagai kesalahan, mengirim frame error, dan menutup socket.
Setelah socket terbuka, setiap kegagalan berbentuk frame tersebut: {"type":"error","code":"…","message":"…","retryable":true|false}. code adalah daftar tertutup (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message untuk log Anda, dan retryable memberi tahu apakah sesi baru layak dicoba. Tangani sejak frame pertama:
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;
}
});
Klien mentah harus menangani dua hal lagi sendiri; SDK sudah melakukannya. Pertama, upgrade yang ditolak membawa amplop error HTTP API yang biasa: 401 invalid_token, 410 session_expired, atau 503 unavailable dengan header Retry-After yang harus dipatuhi.
Kedua, kejenuhan terlihat seperti keheningan, bukan error. Jika tidak ada frame sama sekali dalam 30 detik, perlakukan sebagai capacity_exceeded dan hubungkan lagi; sesi baru biasanya berhasil. Jika frame loading terus datang tetapi session.ready tidak pernah datang, perlakukan sebagai engine_unavailable, yang juga bisa dicoba ulang. Di SDK, RealtimeSession.connect selesai saat session.ready dan gagal setelah readyDeadlineMs dengan kode yang tepat.
Setelah session.ready datang, server mengharapkan audio dengan kecepatan saat diucapkan.
Audio dikirim sebagai frame biner PCM mentah: float 32-bit, little-endian, 16 kHz, mono (pcm_f32le, format audio default sesi). Kirim saat ditangkap, dalam potongan sekitar 100 sampai 250 ms, tanpa menunggu transkrip di antara potongan.
Hitungan byte-nya layak dilakukan sekali. 16.000 sampel per detik dengan 4 byte masing-masing menjadi 64.000 byte per detik, jadi 100 ms adalah 6.400 byte dan 250 ms adalah 16.000. Model ucapan umumnya dilatih dengan mono 16 kHz, itulah sebabnya begitu banyak API real-time memintanya, sementara mikrofon dan browser menangkap pada 44,1 atau 48 kHz dan sering dalam stereo. Harus ada yang melakukan resampling audio dan menggabungkan kanal menjadi satu sebelum byte dikirim: di sini itu ffmpeg, dan di browser itu adalah helper mikrofon SDK. Mengirim audio 48 kHz tetapi menyatakan 16 kHz tidak menimbulkan kegagalan; hasilnya ucapan ditranskripsi seperti gerak lambat, dengan hasil yang buruk.
Di Node, ffmpeg adalah perangkat tangkap yang paling sederhana:
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 tidak pernah memblokir; ia mengantre. bufferedAmount adalah ukuran antrean itu, dan pada tautan yang sehat nilainya mendekati nol. Audio langsung tidak bisa dijeda, jadi respons yang berguna terhadap antrean yang membesar adalah menyadarinya, bukan membuang frame. Server juga menerapkan backpressure. Ia menerima audio beberapa detik lebih awal dari waktu nyata (limits.audioBufferS), lalu berhenti membaca sampai ia tersusul. Dalam arah sebaliknya, komit diantrekan tanpa kehilangan sementara draf digabungkan, sehingga pembaca yang lambat melihat lebih sedikit draf yang lebih segar. Klien yang tidak bisa mengikuti bahkan dengan komit akan menerima frame error dengan slow_consumer dan retryable: false, lalu diputus; jika Anda melihatnya, handler pesan Anda terlalu banyak bekerja.
Di browser, streamMicrophone dari @vocalstack/sdk/mic meminta akses mikrofon, melakukan resampling ke 16 kHz, dan mengirim potongan bersama frame clock, yang memungkinkan server menandai celah saat browser membatasi tab. Halaman mendapatkan sesi dari rute Anda dan tidak pernah melihat kuncinya:
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, '');
}
}
}
Signature render(committed, tail) itu adalah seluruh pola UI-nya; bagian berikutnya menjelaskan alasannya.
Dua jenis frame membentuk transkrip, dan mencampuradukkannya adalah kesalahan yang paling sering terjadi.
Pertama, mengapa ada dua jenis. Model ucapan memutuskan sebagian dari apa sebuah kata berdasarkan kata-kata yang mengikutinya: "I scream" dan "ice cream" (dalam bahasa Inggris terdengar sama) sampai kalimatnya berlanjut. Jadi pengenal real-time mengeluarkan tebakan lebih awal, lalu merevisinya saat lebih banyak audio datang, dan baru menetap setelah cukup mendengar. Setiap API ucapan streaming mengekspos hal ini dengan cara tertentu. Ada yang menyebut tebakan itu hasil "sementara" atau "parsial" dan menandai yang sudah menetap dengan is_final; kosakatanya bervariasi, disiplinnya tidak. Di sini, tebakan adalah draf dan teks yang menetap adalah komit.
transcript.draft adalah ekor langsung: kata-kata yang terdengar sejak komit terakhir, yang masih berubah. Setiap draf adalah penggantian penuh untuk draf sebelumnya. Gambar ulang ekor dengan segments-nya; jangan pernah menambahkannya. Draf tidak membawa seq secara sengaja, jadi jangan mencoba mencocokkannya dengan komit yang akan menjadinya.
transcript.committed adalah final: satu segment dengan seq (0, 1, 2, tanpa celah), start, end, text, speaker dan words dengan waktu per kata. Tambahkan ke daftar dan buang draf yang dicakupnya; draf berikutnya dimulai di tempat komit ini berakhir.
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
UI yang menambahkan draf akan menampilkan teks yang terduplikasi dan berkedip. Tampilan keterangan Anda akan tersendat, sesuatu yang tidak pernah dialami pembicara. Tampilkan semua segmen yang sudah dikomit, lalu draf terbaru setelahnya, dan hapus draf saat komit datang. Sebuah komit bisa berisi kata-kata yang tidak pernah muncul di draf mana pun.
Berikut beberapa detik ucapan di jaringan, dari contoh fetch di atas (translate: ["es"], perakitan kalimat dimatikan):
{"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}]}
Ada dua frame lagi di sekitar ini. transcript.amended memperbaiki segmen yang sudah dikomit di tempatnya (seq sama, text atau speaker baru) ketika sebuah kata di dalamnya telah dikoreksi; terapkan ke baris yang sudah Anda gambar. transcript.words mengalirkan waktu per kata untuk ekor tepat sebelum dikomit. Ini membutuhkan events.words, yang aktif secara default; komit selalu menyertakan words-nya, apa pun pengaturan opsi event-nya. Terakhir, abaikan jenis frame yang tidak Anda kenal: yang baru ditambahkan tanpa pemberitahuan.
Berikut loop pembacaan SDK Python, dengan terminal sebagai tampilan keterangan. Perakitan kalimat juga dimatikan di sini, sehingga terjemahan datang sebagai transcript.translated, dan frame error muncul sebagai VocalStackError yang dilempar dari loop:
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())
Terjemahan per segmen, seperti di atas, adalah bentuk sederhana. Bentuk yang lebih disukai pembaca mendapat bagiannya sendiri.
translate: ["es", "fr"] meminta terjemahan selama sesi berjalan. Cara datangnya bergantung pada perakitan kalimat (sentences: { enabled: true }), yang aktif secara default.
Dengan kalimat dimatikan, setiap komit diikuti sebentar kemudian oleh frame transcript.translated yang menyebut seq-nya dan membawa translations yang dikunci per bahasa; lampirkan ke segmen itu. Itulah yang dipakai contoh-contoh sejauh ini. Cara ini sederhana dan terbaca seperti log komit: satu segmen, satu terjemahan, batas kalimat berada di mana pun pembicara mengambil napas.
Dengan kalimat aktif, server merakit komit menjadi kalimat utuh dan menerjemahkannya, yang jauh lebih enak dibaca. Setiap frame transcript.sentence adalah penggantian penuh untuk satu kalimat, dikunci oleh sentenceId. status-nya berpindah dari open ke closed (atau dropped, yang berarti buang), dan translations-nya bersifat sementara selama masih open dan final setelah ditutup. Lakukan upsert berdasarkan sentenceId, jangan pernah menambahkan. SDK TypeScript menyediakan applySentence persis untuk ini:
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}`);
}
Terjemahan mengikuti transkrip dalam sekitar satu atau dua detik. Jika Anda juga ingin diucapkan, tambahkan voice: ["es"] (ini membutuhkan kalimat). Terjemahan bahasa Spanyol untuk setiap kalimat yang sudah ditutup kemudian datang sebagai frame transcript.voice, membawa klip sebagai base64 Ogg/Opus pada 24 kHz mono. Kalimat panjang datang dalam potongan (part dari parts) untuk diputar berurutan. Teks sementara tidak pernah diucapkan. Panduan terjemahan langsung membahas suara secara rinci, dan ke mana perginya detik-detik itu menjelaskan rantai penundaannya.
Bahasa tujuan tidak dikunci saat sesi dibuat.
Frame teks session.update mengubah spokenLanguage, translate, speakerLabels, latencyMode, voice atau voiceByLang mulai dari titik itu; kolom yang Anda tinggalkan tidak berubah.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK menyediakannya sebagai session.update(…). translate menggantikan seluruh daftar, dan [] mematikan terjemahan. Segmen yang sudah terkirim tidak ditranskripsi ulang atau diterjemahkan ulang, dan jika Anda menyalakan label pembicara, pembicara hanya diberi label mulai dari titik itu. Yang sudah diucapkan tetap diucapkan; pengaturan hanya mengatur apa yang datang berikutnya.
Saat audio berakhir, kirim {"type":"eof"} (session.eof() di SDK). Server mentranskripsi apa yang masih ditahannya, mengirim komit terakhir, lalu session.done dan menutup. Mengirim cancel sebagai gantinya berhenti seketika dan membuang sisanya.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason adalah eof, max_duration (sesi mencapai durasi maksimum paket dan menjalankan flush yang sama) atau cancelled. stats diukur oleh server: audioS adalah audio yang Anda kirim, processingS adalah waktu nyata yang dijalani sesi, dan realtimeFactor adalah rasionya. Sesi langsung biasanya berada sedikit di bawah 1, karena waktu nyata mencakup flush terakhir setelah eof Anda. Angka yang jauh di bawah itu berarti sesi memakan waktu jauh lebih lama daripada durasi audionya; tempat pertama untuk memeriksa adalah seberapa cepat audio datang. Untuk file, angka yang sama menunjukkan berapa kali lebih cepat dari pemutaran transkripnya kembali.
Sesi berlangsung paling lama limits.maxDurationS dari GET /v1/realtime/capabilities, dan audionya menghabiskan kredit Anda per detik dengan tarif langsung paket Anda, dengan minimum 5 menit yang sama seperti transkripsi apa pun: setiap sesi yang menerima audio ditagih minimal 5 menit (harga). Socket yang sama juga bisa menerima satu file utuh: buat sesi dengan input: { kind: "file" }, kirim byte file itu sendiri (mp3, wav, flac, ogg, m4a) dan eof, dan transkripnya kembali jauh lebih cepat daripada pemutaran audio, dengan frame transcript.progress di sepanjang jalan.
Tidak ada fitur lanjutkan. Koneksi yang terputus mengakhiri sesi; menyambung ulang berarti membuat sesi baru, yang ditagih dengan minimumnya sendiri, dan mengalirkan lagi dari sumber langsung Anda, dengan seq kembali ke 0. Menyambung ulang adalah pola standar untuk API socket apa pun, dan layak dibuat benar sekali saja:
retryable: true pada frame error, 503 dengan Retry-After-nya, atau 30 detik tanpa frame, yang mewakili capacity_exceeded. invalid_token dan session_expired berarti streamUrl baru dari server Anda, bukan percobaan lain dengan yang lama.Jaga rute pembuat sesi tetap murah dipanggil dan perlakukan penutupan sebagai awal baru. Tidak adanya fitur lanjutkan adalah harga dari tidak menyimpan apa pun; jika transkrip harus bertahan lebih lama dari socket, simpanlah.
Jika Anda ingin aliran yang sama disimpan sebagai rekaman, dikoreksi setelahnya, diekspor, dan dibaca oleh audiens dengan bahasa mereka sendiri, publikasikan saja ke transkripsi langsung. Panduan go live memandu socket PCM yang sama melalui transkripsi dan tautan siaran, dan panduan listen menunjukkan sisi pelanggan, tempat setiap pendengar memilih bahasa dan peserta yang terlambat mengejar dari kursor lastSeq. Bagaimana tampilannya untuk sebuah ruangan adalah pokok bahasan satu tautan, semua bahasa.
Untuk file yang ingin disimpan, rute tersimpan adalah transkripsi file dengan API. Referensi frame lengkap, dengan setiap kolom, ada di panduan streaming mikrofon dalam referensi API.