Reaalajas transkript saabub kaks korda: esmalt mustandina, mis muutub pidevalt, seejärel lõpliku reana, mis enam ei muutu. Kuva mustand asendatava sabana ja lõplik rida ajaloona ning ülejäänu reaalajas subtiitrivaate juures on lihtsalt tehniline taustatöö.
Reaalajas seanss on kõige väiksem asi, mida VocalStack API teeb. Sinu server teeb ühe HTTP-päringu ja saab vastuseks allkirjastatud streamUrl-i. Sinu klient avab WebSocketi selle URL-iga, saadab toorheli nagu seda jäädvustatakse ning loeb transkripti tagasi JSON-tekstikaadritena, mis jõuavad kõneleja järel mõne hetkega. Kui heli lõpeb, saadad eof-i, server tühjendab puhvri, teatab seansi mõõdetud näitajad ja sulgub.
Midagi ei salvestata. Transkript eksisteerib ainult võrgus, ja just seda soovib subtiitrivaade, koosolekuülekate või häälfunktsioon sinu enda rakenduses. Kui tahad sõnad hiljem alles hoida, jagada ja eksportida, vajad hoopis reaalajas transkribeerimist ja ülekannet; viimane jagu osutab sinna.
Kuju on sama, mida enamik voogedastavaid kõnetuvastuse API-sid kasutab: võtmevahetus HTTP kaudu, pikaealine ühendus, kus heli läheb sisse ja JSON tuleb välja, ning mingi viis eristada esialgset teksti lõplikust. Nimed erinevad API-ti, liikuvad osad aga harva. Siin järjekorras:
POST /v1/realtime/sessions sinu API-võtmega sinu serveril. Vastus on { sessionId, streamUrl, expiresAt }.streamUrl-ile.session.loading südamelöökide, kuni tuleb session.ready.transcript.draft (asenda saba) ja transcript.committed (lisa lõplik rida).{"type":"eof"}; loe session.done.Ainult samm 1 vajab sinu võtit, seega toimub see sinu serveril.
Loo võti jaotises Developers, API keys õigusega Write. Võti on mõeldud serveritele ja skriptidele: API lükkab tagasi võtme, mis on saadetud veebilehelt või URL-is, seega loo seanss oma marsruudi taga ja leht näeb ainult streamUrl-i.
URL kannab oma žetooni brauseri piirangu tõttu, mida tasub üldiselt teada. Brauseri WebSocket-konstruktor ei saa määrata päisepäiseid, seega ei ole kuhugi panna Authorization-päist ega võtit; iga brauseripoolne kõneliides autentib oma ühenduse lühiajalise žetooniga URL-is (või alamprotokolli nimes). Lühiajalisus ongi mõte: URL satub logidesse ja ajalukku, seega peab selles olev aeguma. Ühendu enne expiresAt-i.
SDK-d laaditakse alla alast Developers, mitte paketiregistrist; alustusjuhendi jaotis Install näitab selle keskkonna enda käske. TypeScripti SDK-ga näeb serverimarsruut välja selline:
// 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 };
}
Ilma SDK-ta on see üks fetch. See küsib ka hispaania keelt ja lülitab lausekokkupanemise välja, vastates allpool olevale traadinäidisele:
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 };
Igal valikul on vaikeväärtus. Algusest tasub teada neid: spokenLanguage (kui jätad välja, tuvastatakse keel), translate (sihtkeelte loend), speakerLabels ja latencyMode; ülejäänud tulevad allpool juttu, kui neid vaja läheb.
Anna streamUrl edasi sellele, mis heli jäädvustab; sellest hetkest pole ühtegi võtit enam vaja.
Ühenda streamUrl-iga täpselt sellisena, ilma päiseta. Seansi käivitumise ajal saadab server session.loading südamelööke. Seejärel tuleb session.ready. Enne seda ära saada ühtegi heli. Varakult saadetud heli ei hoita järele; server käsitleb seda veana, saadab error-kaadri ja sulgeb ühenduse.
Kui ühendus on avatud, on iga tõrke kuju see kaader: {"type":"error","code":"…","message":"…","retryable":true|false}. code on suletud loend (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message on mõeldud sinu logidele ja retryable ütleb, kas uue seansi proovimine on mõttekas. Käsitle seda esimesest kaadrist alates:
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;
}
});
Toorklient peab ise käsitlema veel kaks asja; SDK-d teevad seda juba. Esiteks kannab tagasilükatud ühendus API tavapärast HTTP-veaümbrist: 401 invalid_token, 410 session_expired või 503 unavailable koos Retry-After päisega, mida tuleb arvestada.
Teiseks näeb küllastus välja nagu vaikus, mitte nagu viga. Kui 30 sekundi jooksul ei saabu üldse ühtegi kaadrit, käsitle seda kui capacity_exceeded ja ühendu uuesti; uus seanss tavaliselt läbib. Kui laadimiskaadrid tulevad, aga session.ready ei tule kunagi, käsitle seda kui engine_unavailable-it, mis on samuti korratav. SDK-des RealtimeSession.connect naaseb edukalt session.ready juures ja nurjub pärast readyDeadlineMs-i õige koodiga.
Kui session.ready on saabunud, ootab server heli selles tempos, millega seda räägitakse.
Heli läheb sisse binaarkaadritena toorest PCM-ist: 32-bitine ujuvkoma, little-endian, 16 kHz, mono (pcm_f32le, seansi vaikimisi audio-formaat). Saada see nii, nagu seda jäädvustatakse, umbes 100–250 ms tükkidena, ootamata transkripti tükkide vahel.
Baitide arvutuse võib korra läbi teha. 16 000 proovi sekundis, 4 baiti proovi kohta, on 64 000 baiti sekundis; seega 100 ms on 6400 baiti ja 250 ms on 16 000. Kõnemudeleid treenitakse tavaliselt 16 kHz monol, mistõttu nii paljud reaalajas API-d seda küsivad, samas kui mikrofonid ja brauserid salvestavad 44,1 või 48 kHz ja sageli stereos. Enne baitide saatmist peab keegi heli ümber proovivõtma ja kanalid ühte liitma: siin on selleks ffmpeg, brauseris SDK mikrofoniabiline. 48 kHz heli saatmine ja 16 kHz deklareerimine ei nurju; see transkribeerib kõne nagu aeglustatud videos, ja halvasti.
Node.js-is on ffmpeg kõige lihtsam helisisend:
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 ei blokeeru; see järjestab. bufferedAmount on selle järjekorra suurus ja tervel ühendusel püsib see peaaegu nullis. Elavat heli ei saa peatada, seega kasvava järjekorra kasulik vastus on seda märgata, mitte kaadreid visata. Server rakendab samuti tagasisurvet. Ta võtab heli mõned sekundid reaalajast ette (limits.audioBufferS) ja seejärel lõpetab lugemise, kuni on järele jõudnud. Tagasiteel järjestatakse kinnitused kaotuseta, samal ajal kui mustandid koondatakse, nii et aeglane lugeja näeb vähem ja värskemaid mustandeid. Klient, kes ei jõua isegi kinnitustega, saab error-kaadri koodiga slow_consumer ja retryable: false ning ühendus katkeb; kui seda näed, teeb su sõnumikäsitleja liiga palju tööd.
Brauseris küsib @vocalstack/sdk/mic-ist streamMicrophone mikrofoni, proovivõtab 16 kHz-ni ja saadab tükid koos clock-kaadritega, mis laseb serveril märkida lüngad, kui brauser kaardi kiirust piirab. Leht saab seansi sinu marsruudilt ega näe kunagi võtit:
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, '');
}
}
}
See render(committed, tail)-signatuur on kogu kasutajaliidese muster; järgmine jagu selgitab, miks.
Transkripti moodustavad kaks kaadritüüpi ning nende segiajamine on kõige sagedasem viga.
Esiteks, miks neid üldse kaks on. Kõnemudel otsustab osaliselt selle järgi, mis sõnad järgnevad: „I scream” ja „ice cream” kõlavad ühtemoodi, kuni lause edasi läheb. Seetõttu annab reaalajas tuvastaja varakult oletuse ja täpsustab seda, kui saab rohkem heli, ning alles siis, kui on piisavalt kuulnud, stabiliseerub. Iga voogedastav kõneliides avab selle kuidagi. Mõned nimetavad oletusi „vahetulemusteks” või „osalisteks” ja märgivad stabiilsed is_final-iga; sõnavara erineb, distsipliin mitte. Siin on oletus mustand ja stabiilne tekst kinnitus.
transcript.draft on elav saba: sõnad, mis on kuuldud viimasest kinnitusest saadik ja mis muutuvad veel. Iga mustand asendab eelmist täielikult. Joonista saba uuesti selle segments-iga; ära kunagi lisa seda. Mustanditel ei ole tahtlikult seq-i, seega ära proovi neid kinnituste külge sobitada, milleks nad saavad.
transcript.committed on lõplik: üks segment koos seq-iga (0, 1, 2, ilma lünkadeta), start, end, text, speaker ja words sõnade ajastustega. Lisa see ja loobu mustandist, mida see katab; järgmine mustand algab sealt, kus see kinnitus lõppes.
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
Mustandite lisamine tekitab korduva ja vilkuva teksti. Sinu subtiitrivaatesse tekib katkendlikkus, mida kõnelejal polnud. Joonista kõik kinnitatud segmendid, siis viimane mustand nende järele, ja tühjenda mustand, kui kinnitus saabub. Kinnitus võib sisaldada sõnu, mis ei ilmunud üheski mustandis.
Siin on mõned sekundid kõnet traadil eespool toodud fetch-näitest (translate: ["es"], lausekokkupanek välja lülitatud):
{"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}]}
Lisaks on veel kaks kaadrit. transcript.amended parandab kinnitatud segmenti kohapeal (sama seq, uus text või speaker), kui selle sõna on parandatud; rakenda see juba joonistatud reale. transcript.words voogesitab sõnade ajastused sabale vahetult enne kinnitamist. See vajab events.words-i, mis on vaikimisi sisse lülitatud; kinnitus sisaldab alati oma words-i, olenemata sündmuste valikutest. Lõpuks ignoreeri tundmatuid kaadritüüpe: uusi lisatakse ilma teatamata.
Siin on Pythoni SDK lugemistsükkel, terminal kui subtiitrivaade. Ka siin on lausekokkupanek välja lülitatud, seega tõlked saabuvad transcript.translated-ina, ja error-kaader jõuab tsüklist välja VocalStackError-ina:
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())
Segmendipõhine tõlge, nagu ülal, on lihtne vorm. Vorm, mida lugejad eelistavad, saab eraldi jao.
translate: ["es", "fr"] palub tõlkeid seansi käigus. Kuidas need saabuvad, sõltub lausekokkupanekust (sentences: { enabled: true }), mis on vaikimisi sisse lülitatud.
Kui lausekokkupanek on välja lülitatud, järgneb igale kinnitusele hetk hiljem transcript.translated-kaader, mis nimetab selle seq-i ja kannab translations-it keele järgi; kinnita need selle segmendiga. Seni on näited just seda kasutanud. See on lihtne ja loeb nagu kinnituste logi: üks segment, üks tõlge, lausepiir seal, kus kõneleja hingas.
Lausekokkupanekuga koondab server kinnitused tervikliteks lauseteks ja tõlgib need, mis loeb palju paremini. Iga transcript.sentence-kaader on ühe lause täielik asendus, mida võtab võti sentenceId. Selle status läheb open-ist closed-ini (või dropped-ini, mis tähendab, et see tuleb hüljata), ning selle translations on esialgsed, kuni lause on avatud, ja lõplikud pärast sulgemist. Uuenda sentenceId järgi (lisa või asenda), ära kunagi lisa lõppu. TypeScripti SDK sisaldab just selleks applySentence-i:
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}`);
}
Tõlked jäävad transkriptist sekund-kaks maha. Kui tahad neid ka kõnena, lisa voice: ["es"] (vajab lauseid). Iga suletud lause hispaaniakeelne tõlge saabub siis transcript.voice-kaadrina, mis kannab klippi base64 Ogg/Opus-ina 24 kHz monos. Pikk lause tuleb tükkidena (part kogusest parts), et neid järjest mängida. Esialgset teksti ei räägita kunagi. Reaalajas tõlke juhend käsitleb hääli põhjalikult ning kuhu sekundid lähevad selgitab viivitusahela.
Sihtkeeli ei fikseerita loomisel.
session.update tekstikaader muudab spokenLanguage, translate, speakerLabels, latencyMode, voice või voiceByLang väärtust sellest hetkest alates; väljad, mille jätad välja, jäävad muutumatuks.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK-d pakuvad seda kui session.update(…). translate asendab kogu loendi ja [] lülitab tõlke välja. Juba edastatud segmente ei transkribeerita ega tõlgita uuesti, ja kui lülitad kõneleja sildid sisse, märgitakse kõnelejad alates sellest hetkest. Öeldu jääb öelduks; seaded mõjutavad ainult seda, mis järgneb.
Kui heli lõpeb, saada {"type":"eof"} (SDK-des session.eof()). Server transkribeerib, mis tal veel on, saadab viimased kinnitused, seejärel session.done ja sulgub. Kui saadad selle asemel cancel, peatub see kohe ja ülejäänu jääb kõrvale.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason on eof, max_duration (seanss jõudis paketi maksimaalse pikkuseni ja jooksis sama lõpetamise) või cancelled. stats mõõdab server: audioS on heli, mille saatsid, processingS on seina kell, mille seanss kestis, ja realtimeFactor on nende suhe. Reaalajas seanss jääb napilt alla 1, sest seina kell hõlmab lõplikku tühjendamist pärast sinu eof-i. Oluliselt sellest madalam arv tähendab, et seanss kestis märgatavalt kauem kui heli; esimene koht, kuhu vaadata, on see, kui kiiresti heli saabus. Failide puhul ütleb sama arv, mitu korda kiiremini kui esitus transkript tagasi tuli.
Seanss kestab kuni limits.maxDurationS väärtuseni, mille leiad GET /v1/realtime/capabilities-st, ning selle heli kulutab sinu krediiti sekundi kaupa sinu paketi reaalajas-hinnaga, sama 5-minutilise miinimumiga nagu iga transkribeerimine: iga seanss, mis heli vastu võtab, arveldatakse vähemalt 5 minuti eest (hinnad). Sama socket võtab vastu ka terve faili: loo seanss input: { kind: "file" }-ga, saada faili enda baidid (mp3, wav, flac, ogg, m4a) ja eof, ning transkript tuleb tagasi mitu korda kiiremini kui heli mängib, koos vahepealsete transcript.progress-kaadritega.
Jätkamist ei ole. Katkenud ühendus lõpetab seansi; uuesti ühendamine tähendab uue seansi loomist, mille arveldatakse omaette miinimumiga, ja voogedastamist uuesti sinu elavast allikast, seq-i algusest 0-st. Uuesti ühendamine on tavaline muster iga socket-API jaoks ja seda tasub kord õigesti teha:
retryable: true error-kaadril, 503 koos Retry-After-ga või 30 sekundit ilma kaadrita, mis tähendab capacity_exceeded. invalid_token ja session_expired tähendavad uut streamUrl-i sinu serverist, mitte uut katset vana URL-iga.Hoia seansi loov marsruut odav kutsuda ja käsitle sulgemist uue algusena. Jätkamise puudumine on hind, mida maksad selle eest, et midagi ei salvestata; kui transkript peab ühendusest kauem elama, hoia see alles.
Kui tahad sama voo salvestatud, hiljem parandatud, eksporditud ja mitmes keeles publikule loetavana, avalda see reaalajas transkribeerimisena. Mine elavaks juhend käib sama PCM-ühenduse läbi transkribeerimise ja ülekandelingi ning kuulamisjuhend näitab tellija poolt, kus iga kuulaja valib keele ja hilinev liituja jõuab järele lastSeq-kursorist. Kuidas see ruumi jaoks välja näeb, on artikli üks link, kõik keeled teema.
Failile, mida tahad alles hoida, on salvestatud marsruut faili transkribeerimine VocalStack API-ga. Täielik kaadriviide, kõigi väljadega, on API-viite juhend mikrofoni voogedastamine.