Transkrip masa nyata tiba dua kali: mula-mula sebagai draf yang sentiasa berubah, kemudian sebagai baris akhir yang tidak berubah. Paparkan draf sebagai ekor yang boleh digantikan dan baris akhir sebagai sejarah, dan selebihnya kerja sambungan paparan kapsyen langsung ialah sekadar infrastruktur.
Sesi masa nyata ialah perkara terkecil yang dilakukan API VocalStack. Pelayan anda membuat satu panggilan HTTP dan menerima streamUrl yang bertandatangan. Klien anda membuka WebSocket ke URL itu, menghantar audio mentah semasa ia dirakam, dan membaca transkrip kembali sebagai bingkai teks JSON, beberapa saat di belakang penutur. Apabila audio tamat, anda menghantar eof; pelayan membersihkan baki data, melaporkan apa yang diukur oleh sesi itu, dan menutup sambungan.
Tiada apa yang disimpan. Transkrip hanya wujud di wayar, yang diperlukan oleh paparan kapsyen, lapisan mesyuarat atau ciri suara dalam aplikasi anda sendiri. Jika anda mahu perkataan itu disimpan, dikongsi dan dieksport selepas itu, anda memerlukan transkripsi langsung dan siaran sebaliknya; bahagian terakhir menunjukkan ke situ.
Bentuknya ialah bentuk yang paling banyak dikongsi oleh API pertuturan penstriman: pertukaran kelayakan melalui HTTP, soket panjang dengan audio masuk dan JSON keluar, dan cara membezakan teks sementara daripada teks akhir. Nama berbeza dari satu API ke API yang lain; bahagian yang bergerak jarang berbeza. Berikut ialah langkahnya, mengikut urutan:
POST /v1/realtime/sessions dengan kunci API anda, pada pelayan anda. Jawapannya ialah { sessionId, streamUrl, expiresAt }.streamUrl.session.loading sehingga session.ready.transcript.draft (gantikan ekor) dan transcript.committed (tambahkan baris akhir).{"type":"eof"}; baca session.done.Hanya langkah 1 memerlukan kunci anda, jadi ia berlaku di pelayan anda.
Cipta kunci di bawah Developers, API keys dengan kebenaran Write. Kunci ialah untuk pelayan dan skrip: API menolak kunci yang dihantar dari halaman web atau dalam URL, jadi anda mencipta sesi di sebalik laluan anda sendiri, dan halaman hanya pernah melihat streamUrl.
URL itu membawa tokennya sendiri kerana satu batasan pelayar yang berguna untuk difahami secara umum. Pembina WebSocket pelayar tidak boleh menetapkan pengepala permintaan, maka tiada pengepala Authorization untuk meletakkan kunci; setiap API pertuturan yang menghadap pelayar mengesahkan soketnya dengan token jangka pendek dalam URL (atau diselitkan dalam nama subprotokol). Jangka pendek itu penting: URL berakhir dalam log dan sejarah, jadi apa yang dibawanya mesti tamat tempoh. Sambung sebelum expiresAt.
SDK dimuat turun dari kawasan Developers, bukan dari pendaftar pakej; bahagian Install dalam panduan mula pantas menunjukkan arahan persekitaran ini sendiri. Dengan SDK TypeScript, laluan pelayan kelihatan begini:
// 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, ia hanyalah satu fetch. Yang ini juga meminta bahasa Sepanyol dan mematikan pemasangan ayat, sepadan dengan sampel wayar 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 pilihan mempunyai nilai lalai. Yang patut diketahui pada permulaan ialah spokenLanguage (tinggalkannya dan bahasa dikesan secara automatik), translate (senarai bahasa sasaran), speakerLabels dan latencyMode; yang lain muncul apabila diperlukan di bawah.
Serahkan streamUrl kepada apa sahaja yang menangkap audio; dari sini, tiada kunci terlibat.
Sambung ke streamUrl seadanya, tanpa pengepala. Semasa sesi bermula, pelayan menghantar degupan session.loading. Kemudian datang session.ready. Jangan hantar audio sebelumnya. Audio yang dihantar awal tidak disimpan untuk kemudian; pelayan menganggapnya sebagai ralat, menghantar bingkai error dan menutup soket.
Setelah soket dibuka, setiap kegagalan mengambil bentuk bingkai itu: {"type":"error","code":"…","message":"…","retryable":true|false}. code ialah senarai tertutup (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message untuk log anda, dan retryable memberitahu sama ada sesi baharu berbaloi dicuba. Tangani dari bingkai 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 perlu mengendalikan dua perkara lagi sendiri; SDK sudah melakukannya. Pertama, peningkatan (upgrade) yang ditolak membawa sampul ralat HTTP API yang biasa: 401 invalid_token, 410 session_expired, atau 503 unavailable dengan pengepala Retry-After yang perlu dihormati.
Kedua, kesesakan kelihatan seperti senyap, bukan ralat. Jika tiada satu bingkai pun tiba dalam tempoh 30 saat, anggap sebagai capacity_exceeded dan sambung semula; sesi baharu biasanya berjaya. Jika bingkai degupan terus tiba tetapi session.ready tidak pernah tiba, anggap sebagai engine_unavailable, yang juga boleh dicuba semula. Dalam SDK, RealtimeSession.connect diselesaikan pada session.ready dan gagal selepas readyDeadlineMs dengan kod yang betul.
Apabila session.ready tiba, pelayan menjangka audio pada kadar ia dituturkan.
Audio dimasukkan sebagai bingkai binari PCM mentah: float 32-bit, little-endian, 16 kHz, mono (pcm_f32le, format audio lalai sesi). Hantarkannya semasa ia dirakam, dalam bahagian kira-kira 100 hingga 250 ms, tanpa menunggu transkrip antara bahagian.
Pengiraan bait berbaloi dibuat sekali. 16,000 sampel sesaat dengan 4 bait setiap satu ialah 64,000 bait sesaat, jadi 100 ms ialah 6,400 bait dan 250 ms ialah 16,000. Model pertuturan lazimnya dilatih pada 16 kHz mono, itulah sebabnya banyak API masa nyata memintanya, manakala mikrofon dan pelayar merakam pada 44.1 atau 48 kHz dan sering dalam stereo. Sesuatu perlu mengubah sampel semula dan menggabungkan saluran kepada satu sebelum bait dihantar: di sini ialah ffmpeg, dan dalam pelayar ialah pembantu mikrofon SDK. Menghantar audio 48 kHz dan mengisytiharkan 16 kHz tidak gagal; ia mentranskripsikan pertuturan seperti gerak perlahan, dan dengan teruk.
Dalam Node, ffmpeg ialah peranti tangkapan yang paling mudah:
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 menyekat; ia beratur. bufferedAmount ialah saiz baris itu, dan pada pautan yang sihat ia kekal hampir sifar. Audio langsung tidak boleh dijeda, jadi tindak balas yang berguna terhadap baris yang membesar ialah menyedarinya, bukan menggugurkan bingkai. Pelayan juga menggunakan tekanan balik. Ia menerima audio beberapa saat lebih awal daripada masa sebenar (limits.audioBufferS), kemudian berhenti membaca sehingga ia mengejar. Dalam perjalanan balik, komit diberi giliran tanpa kehilangan manakala draf digabungkan, jadi pembaca yang perlahan melihat draf yang lebih sedikit tetapi lebih segar. Klien yang tidak mampu mengikut walaupun dengan komit menerima bingkai error dengan slow_consumer dan retryable: false, dan diputuskan sambungan; jika anda melihatnya, pengendali mesej anda melakukan terlalu banyak kerja.
Dalam pelayar, streamMicrophone dari @vocalstack/sdk/mic meminta mikrofon, menukar sampel kepada 16 kHz, dan menghantar bahagian bersama bingkai clock, yang membolehkan pelayan menanda jurang apabila pelayar melambatkan tab. Halaman mendapat sesi dari laluan anda dan tidak pernah melihat kunci:
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 ialah keseluruhan corak UI; bahagian seterusnya menerangkan sebabnya.
Dua jenis bingkai membina transkrip, dan mencampurkannya ialah kesilapan yang paling biasa.
Pertama, mengapa ada dua. Model pertuturan menentukan apa perkataan itu sebahagiannya daripada perkataan yang mengikutinya: "I scream" dan "ice cream" berbunyi sama sehingga ayat itu berterusan. Maka pengecam masa nyata mengeluarkan tekaan awal, kemudian menyemaknya apabila lebih banyak audio tiba, dan hanya menetap apabila ia telah mendengar cukup. Setiap API pertuturan penstriman mendedahkan ini dengan cara tertentu. Ada yang memanggil tekaan itu hasil "interim" atau "separa" dan menandakan yang telah menetap sebagai is_final; perbendaharaan kata berbeza-beza, tetapi disiplinnya tidak. Di sini tekaan ialah draf dan teks yang menetap ialah komit.
transcript.draft ialah ekor langsung: perkataan yang didengar sejak komit terakhir, yang masih berubah. Setiap draf ialah penggantian penuh bagi draf sebelumnya. Lukis semula ekor dengan segments-nya; jangan sekali-kali menambahkannya. Draf tidak membawa seq mengikut reka bentuk, jadi jangan cuba memadankannya dengan komit yang akhirnya menjadi.
transcript.committed ialah muktamad: satu segment dengan seq (0, 1, 2, tanpa jurang), start, end, text, speaker dan words dengan masa setiap perkataan. Tambahkannya dan buang draf yang diliputinya; draf seterusnya bermula 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 memaparkan teks berganda yang berkelip. Paparan kapsyen anda mendapat gagap yang tidak pernah dialami penutur. Paparkan semua segmen yang dikomit, kemudian draf terkini selepasnya, dan kosongkan draf apabila komit tiba. Sesuatu komit mungkin mengandungi perkataan yang tidak pernah muncul dalam mana-mana draf.
Berikut beberapa saat pertuturan di wayar, daripada contoh fetch di atas (translate: ["es"], pemasangan ayat 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}]}
Dua lagi bingkai berada di sekeliling ini. transcript.amended membetulkan segmen yang dikomit di tempatnya (seq yang sama, text atau speaker baharu) apabila sesuatu perkataan di dalamnya telah diperbetulkan; gunakannya pada baris yang telah anda lukis. transcript.words menstrim masa perkataan untuk ekor sejurus sebelum ia dikomit. Ia memerlukan events.words, yang dihidupkan secara lalai; komit sentiasa menyertakan words-nya, tanpa mengira pilihan acara. Akhirnya, abaikan jenis bingkai yang tidak anda kenali: yang baharu ditambah tanpa notis.
Berikut ialah gelung bacaan SDK Python, dengan terminal sebagai paparan kapsyen. Pemasangan ayat juga dimatikan di sini, jadi terjemahan tiba sebagai transcript.translated, dan bingkai error muncul sebagai VocalStackError yang dibangkitkan keluar dari gelung:
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, ialah bentuk yang paling mudah. Bentuk yang lebih digemari pembaca mendapat bahagian sendiri.
translate: ["es", "fr"] meminta terjemahan semasa sesi berjalan. Cara ia tiba bergantung pada pemasangan ayat (sentences: { enabled: true }), yang dihidupkan secara lalai.
Dengan ayat dimatikan, setiap komit diikuti sebentar kemudian oleh bingkai transcript.translated yang menamakan seq-nya dan membawa translations yang dikunci mengikut bahasa; lampirkannya pada segmen itu. Itulah yang digunakan oleh contoh-contoh sebelum ini. Ia mudah dan menyerupai log komit: satu segmen, satu terjemahan, sempadan ayat di mana sahaja penutur menarik nafas.
Dengan ayat dihidupkan, pelayan memasang komit menjadi ayat yang lengkap dan menterjemahkannya, yang jauh lebih mudah dibaca. Setiap bingkai transcript.sentence ialah penggantian penuh bagi satu ayat, dikunci mengikut sentenceId. status-nya bergerak dari open ke closed (atau dropped, yang bermaksud buang), dan translations-nya bersifat sementara semasa terbuka dan muktamad apabila ditutup. Kemas kini atau sisip mengikut sentenceId, jangan sekali-kali tambahkan. SDK TypeScript menyediakan applySentence untuk tujuan 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 sela sesaat atau dua. Jika anda mahu ia dituturkan juga, tambahkan voice: ["es"] (ia memerlukan ayat). Terjemahan bahasa Sepanyol bagi setiap ayat yang ditutup kemudian tiba sebagai bingkai transcript.voice, membawa klip sebagai Ogg/Opus base64 pada 24 kHz mono. Ayat yang panjang tiba dalam bahagian (part daripada parts) untuk dimainkan semula berturut-turut. Teks sementara tidak pernah dituturkan. Panduan terjemahan langsung meliputi suara secara terperinci, dan ke mana perginya saat-saat itu menerangkan rantaian kelewatan.
Bahasa sasaran tidak ditetapkan semasa penciptaan.
Bingkai teks session.update menukar spokenLanguage, translate, speakerLabels, latencyMode, voice atau voiceByLang dari titik itu seterusnya; medan yang anda tinggalkan tidak berubah.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK mendedahkannya sebagai session.update(…). translate menggantikan keseluruhan senarai, dan [] mematikan terjemahan. Segmen yang telah dihantar tidak ditranskripsi atau diterjemah semula, dan jika anda menghidupkan label penutur, penutur hanya dilabel dari titik itu. Apa yang telah dikatakan kekal begitu; tetapan hanya mengawal apa yang seterusnya.
Apabila audio tamat, hantar {"type":"eof"} (session.eof() dalam SDK). Pelayan mentranskripsikan apa yang masih dipegangnya, menghantar komit terakhir, kemudian session.done dan menutup. Menghantar cancel sebaliknya berhenti serta-merta dan membuang selebihnya.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason ialah eof, max_duration (sesi mencapai panjang maksimum pelan dan menjalankan pembersihan yang sama) atau cancelled. stats diukur oleh pelayan: audioS ialah audio yang anda hantar, processingS ialah masa dinding (wall-clock) sesi berjalan, dan realtimeFactor ialah nisbahnya. Sesi langsung mendarat sedikit di bawah 1, kerana masa dinding merangkumi pembersihan akhir selepas eof anda. Nombor yang jauh lebih rendah bermakna sesi mengambil masa yang ketara lebih lama daripada tempoh audio; tempat pertama untuk melihat ialah betapa pantas audio tiba. Untuk fail, nombor yang sama menunjukkan berapa kali lebih pantas daripada main semula transkrip itu kembali.
Sesi berlangsung paling lama limits.maxDurationS daripada GET /v1/realtime/capabilities, dan audionya membelanjakan kredit anda setiap saat pada kadar langsung pelan anda, dengan minimum 5 minit yang sama seperti mana-mana transkripsi: setiap sesi yang menerima audio dicaj sekurang-kurangnya 5 minit (harga). Soket yang sama juga boleh menerima keseluruhan fail: cipta sesi dengan input: { kind: "file" }, hantar bait fail itu sendiri (mp3, wav, flac, ogg, m4a) dan eof, dan transkrip kembali berkali-kali lebih pantas daripada audio dimainkan, dengan bingkai transcript.progress sepanjang jalan.
Tiada penyambungan semula. Sambungan yang terputus menamatkan sesi; menyambung semula bermakna mencipta sesi baharu, dicaj minimumnya sendiri, dan menstrim semula dari sumber langsung anda, dengan seq kembali ke 0. Penyambungan semula itu sendiri ialah corak standard bagi mana-mana API soket, dan berbaloi dibuat dengan betul sekali:
retryable: true pada bingkai error, 503 dengan Retry-After-nya, atau 30 saat tanpa bingkai, yang mewakili capacity_exceeded. invalid_token dan session_expired bermakna streamUrl baharu dari pelayan anda, bukan percubaan lain dengan yang lama.Simpan laluan yang mencipta sesi supaya murah untuk dipanggil, dan anggap penutupan sebagai permulaan baharu. Tiada penyambungan semula ialah harga untuk tidak menyimpan apa-apa; jika transkrip mesti kekal selepas soket, simpanlah.
Apabila anda mahu aliran yang sama disimpan sebagai rakaman, dibetulkan kemudian, dieksport, dan dibaca oleh khalayak dalam bahasa mereka sendiri, terbitkannya kepada transkripsi langsung. Panduan siaran langsung melalui soket PCM yang sama menerusi transkripsi dan pautan siaran, dan panduan mendengar menunjukkan bahagian pelanggan, di mana setiap pendengar memilih bahasa dan peserta lewat mengejar dari kursor lastSeq. Apa yang kelihatan untuk sebuah bilik ialah subjek satu pautan, setiap bahasa.
Bagi fail yang ingin anda simpan, laluan tersimpan ialah transkripsi fail dengan API. Rujukan bingkai penuh, setiap medan termasuk, ialah panduan Strim mikrofon dalam rujukan API.