Транскрыпт прыходзіць двойчы: спачатку як чарнавік, які пастаянна мяняецца, потым як канчатковы радок, які не мяняецца. Выводзьце чарнавік як хвост, які можна замяніць, а канчатковы радок як гісторыю, і ўсё астатняе ў жывым падпісе — толькі праводка.
Сеанс у рэальным часе — найменшая аперацыя, якую выконвае API VocalStack. Ваш сервер робіць адзін HTTP-выклік і атрымлівае подпісаны streamUrl. Ваш кліент адкрывае WebSocket па гэтым URL, адпраўляе сырое аўдыё па меры запісу і чытае транскрыпт назад як JSON-тэкставыя кадры, якія ідуць за прамоўцам на лічаныя імгненні. Калі аўдыё заканчваецца, вы адпраўляеце eof; сервер дабірае тое, што засталося, паведамляе, што вымераў за сеанс, і закрываецца.
Нічога не захоўваецца. Транскрыпт існуе толькі ў злучэнні, і гэта менавіта тое, што трэба прагляду субцітраў, накладцы на сустрэчу або галасавой функцыі ў вашай праграме. Калі вам патрэбна, каб словы захоўваліся, распаўсюджваліся і экспартаваліся пасля, варта скарыстаць транскрыбаванне ў рэальным часе і трансляцыю; апошні раздзел паказвае туды.
Форма тая, якую маюць большасць патокавых API распазнавання маўлення: абмен уліковых дадзеных праз HTTP, доўгатрывалы сокет, па якім аўдыё ідзе ўнутр, а JSON выходзіць вонкі, і некаторы спосаб адрозніць папярэдні тэкст ад канчатковага. Назвы ў розных API розныя; рухомыя часткі амаль заўсёды тыя самыя. Вось яны па парадку:
POST /v1/realtime/sessions з вашым ключом API на вашым серверы. Адказ — { sessionId, streamUrl, expiresAt }.streamUrl.session.loading, да session.ready.transcript.draft (замяніць хвост) і transcript.committed (дадаць канчатковы радок).{"type":"eof"}; чытайце session.done.Толькі крок 1 патрабуе вашага ключа, таму ён адбываецца на вашым серверы.
Стварыце ключ у раздзеле Developers, API keys з правам Write. Ключ прызначаны для серваў і скрыптоў: API адхіляе ключ, адпраўлены з вэб-старонкі або ў URL, таму вы стварайце сеанс за маршрутам сваёй праграмы, а старонка бачыць толькі streamUrl.
URL нясе ўласны токен з-за абмежавання браўзэра, якое варта ведаць наогул. Канструктар браўзэрнага WebSocket не можа задаць загалоўкі запытаў, таму загалоўка Authorization няма, куды паставіць ключ; кожнае браўзэрнае маўленчае API аўтэнтыфікуе сокет кароткатэрміновым токенам у URL (або схаваным у назве падпратакола). Кароткатэрміновасць — у гэтым і справа: URL трапляе ў логі і гісторыю, таму тое, што ён нясе, мусіць заканчвацца. Падключайцеся да часу expiresAt.
SDK спампоўваюцца з раздзела Developers, а не з пакетнага рэестра; раздзел Install у кароткім кіраўніцтве паказвае каманды для гэтага асяроддзя. З TypeScript SDK маршрут сервера выглядае так:
// 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 гэта адзін fetch. Гэты прыклад таксама запытвае іспанскую мову і выключае зборку сказаў, у адпаведнасці з прыкладам злучэння ніжэй:
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 };
Кожны параметр мае значэнне па змаўчанні. Найбольш карысныя на старце: spokenLanguage (калі не пазначыць, мова вызначаецца аўтаматычна), translate (спіс мэтавых моў), speakerLabels і latencyMode; астатнія з’яўляюцца па меры патрэбы ніжэй.
Перадайце streamUrl таму, што захоплівае аўдыё; далей ключ не патрэбны.
Падключайцеся да streamUrl як ёсць, без загалоўкаў. Пакуль сеанс запускаецца, сервер шле пульсавыя кадры session.loading. Потым прыходзіць session.ready. Аўдыё дасылайце толькі пасля яго. Аўдыё, адпраўленае раней, не захоўваецца для пазнейшага; сервер лічыць гэта памылкай, адпраўляе кадр error і закрывае сокет.
Пасля адкрыцця сокета кожная памылка мае форму гэтага кадра: {"type":"error","code":"…","message":"…","retryable":true|false}. code — закрыты спіс (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message прызначаны для вашых логаў, а retryable кажа, ці варта спрабаваць новы сеанс. Апрацоўвайце яе з першага кадра:
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;
}
});
Сырым кліентам трэба самім апрацаваць яшчэ два выпадкі; SDK гэта робяць за вас. Па-першае, адхілены апгрэйд нясе звычайны HTTP-канверт памылкі API: 401 invalid_token, 410 session_expired або 503 unavailable з загалоўкам Retry-After, які трэба выконваць.
Па-другое, перагрузка выглядае як цішыня, а не як памылка. Калі за 30 секунд не прыйшоў ніводны кадр, разглядайце гэта як capacity_exceeded і падключайцеся зноў; новы сеанс звычайна праходзіць. Калі кадры loading ідуць, а session.ready не прыходзіць, разглядайце гэта як engine_unavailable, што таксама дазваляе паўтор. У SDK RealtimeSession.connect вяртаецца пры session.ready і завяршаецца з патрэбным кодам праз readyDeadlineMs.
Пасля таго, як прыходзіць session.ready, сервер чакае аўдыё ў тым тэмпе, у якім яго прамаўляюць.
Аўдыё ідзе бінарнымі кадрамі сырога PCM: 32-бітны float, little-endian, 16 кГц, монаральны (pcm_f32le, стандартны фармат audio сеанса). Адпраўляйце па меры захопу, кавалкамі па 100–250 мс, не чакаючы транскрыпта паміж кавалкамі.
Варта адзін раз зрабіць арыфметыку з байтамі. 16 000 адлікаў за секунду па 4 байты кожны — гэта 64 000 байт за секунду, таму 100 мс — гэта 6 400 байт, а 250 мс — 16 000. Мадэлі маўлення звычайна навучаюцца на монаральным аўдыё 16 кГц, таму так многія патокавыя API просяць менавіта гэты фармат, тады як мікрафоны і браўзэры запісваюць на 44,1 або 48 кГц і часта ў стэрэа. Нешта павінна перадыскрэтызаваць аўдыё і звесці каналы ў адзін, перш чым байты пойдуць: тут гэта ffmpeg, а ў браўзэры — хэлпер мікрафона з SDK. Адпраўка аўдыё 48 кГц з абвяшчэннем 16 кГц не дае памылкі, але транскрыбуе маўленне так, нібы яно запаволенае і дрэннае.
У Node ffmpeg — найпрасцейшая крыніца захопу:
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 ніколі не блакуецца; ён ставіць у чаргу. bufferedAmount — гэта памер гэтай чаргі, і на здаровым злучэнні ён амаль нулявы. Жывое аўдыё нельга паставіць на паўзу, таму карысная рэакцыя на растучую чаргу — заўважыць яе, а не адкідаць кадры. Сервер таксама прымяняе зваротны ціск. Ён прымае аўдыё на некалькі секунд наперад ад рэальнага часу (limits.audioBufferS), потым перастае чытаць, пакуль не даганяе. На зваротным шляху фіксацыі ставяцца ў чаргу без страт, а чарнавікі аб’ядноўваюцца, таму павольны чытач бачыць менш, але свежэйшых чарнавікоў. Кліент, які не паспявае нават за фіксацыямі, атрымлівае кадр error з slow_consumer і retryable: false і адключаецца; калі вы бачыце гэта, ваш апрацоўшчык паведамленняў робіць занадта шмат.
У браўзэры streamMicrophone з @vocalstack/sdk/mic запытвае мікрафон, перадыскрэтызуе на 16 кГц і адпраўляе кавалкі разам з кадрамі clock, якія дазваляюць серверу адзначыць прабелы, калі браўзэр прытрымлівае карткі. Старонка атрымлівае сеанс з вашага маршруту і ніколі не бачыць ключа:
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) — увесь шаблон інтэрфейсу; наступны раздзел тлумачыць чаму.
Два тыпы кадраў складаюць транскрыпт, і пераблытаць іх — самая частая памылка.
Спачатку тлумачэнне, чаму іх два. Мадэль маўлення вызначае, што было сказана, часткова па словах, якія ідуць пасля: «I scream» і «ice cream» гучаць аднолькава, пакуль сказ не скончыцца. Таму рэчаіснасны распазнавальнік выдае здагадку рана, потым папраўляе яе, калі прыходзіць больш аўдыё, і канчаткова ўсталёўваецца толькі тады, калі пачуў дастаткова. Кожнае патокавае маўленчае API гэта так ці інакш паказвае. Некаторыя называюць здагадкі «інтэрым» або «частковымі» вынікамі і пазначаюць устаноўленыя як is_final; словы розныя, дысцыпліна тая ж. Тут здагадка — гэта чарнавік, а ўсталяваны тэкст — фіксацыя.
transcript.draft — гэта жывы хвост: словы, пачутыя пасля апошняй фіксацыі, якія яшчэ мяняюцца. Кожны чарнавік цалкам замяняе папярэдні. Перамалёўвайце хвост па яго segments; ніколі не дапісвайце яго. Чарнавікі па задуму не нясуць seq, таму не спрабуйце супаставіць іх з фіксацыямі, у якія яны ператвараюцца.
transcript.committed — канчатковы: адзін segment з seq (0, 1, 2, без прабелаў), start, end, text, speaker і words з таймінгамі па словах. Дадайце яго і выдаліце чарнавік, які ён пакрывае; наступны чарнавік пачынаецца там, дзе скончылася гэтая фіксацыя.
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
Інтэрфейс, які дапісвае чарнавікі, паказвае дублікаваны, мігцяць тэкст. Ваш прагляд субцітраў пачынае заікацца, чаго прамоўца ніколі не рабіў. Выводзьце ўсе канчатковыя сегменты, потым апошні чарнавік пасля іх, і ачышчайце чарнавік, калі прыходзіць фіксацыя. Фіксацыя можа ўтрымліваць словы, якіх ні ў адным чарнавіку не было.
Вось некалькі секунд маўлення па злучэнні, з прыкладу fetch вышэй (translate: ["es"], зборка сказаў выключана):
{"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}]}
Яшчэ два кадры існуюць вакол гэтых. transcript.amended папраўляе фіксаваны сегмент на месцы (той жа seq, новы text або speaker), калі слова ў ім было выпраўлена; прымяніце яго да радка, які вы ўжо намалявалі. transcript.words перадае таймінгі слоў для хваста перад самай фіксацыяй. Ён патрабуе events.words, які ўключаны па змаўчанні; фіксацыя заўсёды ўтрымлівае свае words, незалежна ад параметраў падзей. Нарэшце, ігнаруйце невядомыя тыпы кадраў: новыя дадаюцца без папярэджання.
Вось цыкл чытання з Python SDK, дзе тэрмінал — гэта прагляд субцітраў. Зборка сказаў таксама выключана, таму пераклады прыходзяць як transcript.translated, а кадр error падымаецца з цыкла як VocalStackError:
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())
Перакладанне па сегментах, як вышэй, — простая форма. Форма, якую чытачы аддаюць перавагу, мае асобны раздзел.
translate: ["es", "fr"] просіць пераклады па ходзе сеанса. Як яны прыходзяць, залежыць ад зборкі сказаў (sentences: { enabled: true }), якая ўключана па змаўчанні.
Калі зборка сказаў выключана, кожную фіксацыю праз імгненне ідзе кадр transcript.translated, які называе яе seq і нясе translations па мовах; прымацуйце іх да гэтага сегмента. Менавіта так працуюць прыклады вышэй. Гэта проста, і выглядае як журнал фіксацый: адзін сегмент, адзін пераклад, мяжа сказа там, дзе прамоўца перавёў дых.
Калі зборка сказаў уключана, сервер збірае фіксацыі ў цэлыя сказы і перакладае іх, што чытаецца значна лепш. Кожны кадр transcript.sentence цалкам замяняе адзін сказ, ідэнтыфікаваны па sentenceId. Яго status ідзе ад open да closed (або dropped, што азначае адкінуць), а translations папярэднія, пакуль сказ адкрыты, і канчатковыя, калі ён закрыты. Абнаўляйце па sentenceId, ніколі не дапісвайце. TypeScript SDK мае applySentence менавіта для гэтага:
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}`);
}
Пераклады ідуць за транскрыптам на секунду-дзве. Калі хочаце, каб іх таксама агучвалі, дадайце voice: ["es"] (патрабуе сказаў). Іспанскі для кожнага закрытага сказа тады прыходзіць як кадр transcript.voice, які нясе кліп у base64 Ogg/Opus з 24 кГц монаральна. Доўгі сказ прыходзіць часткамі (part з parts), каб прайграць іх запар. Папярэдні тэкст ніколі не агучваецца. Кіраўніцтва па жывым перакладзе падрабязна апісвае галасы, а куды знікаюць секунды тлумачыць ланцужок затрымкі.
Мэтавыя мовы не фіксуюцца пры стварэнні.
Тэкставы кадр session.update мяняе spokenLanguage, translate, speakerLabels, latencyMode, voice або voiceByLang з таго моманту; палі, якія вы не паказалі, не змяняюцца.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK паказвае гэта як session.update(…). translate замяняе ўвесь спіс, а [] выключае пераклад. Ужо дастаўленыя сегменты не перарыхтоўваюцца і не перакладаюцца нанова, а калі ўключыце пазнаку спікераў, спікеры пазначаюцца толькі з гэтага моманту. Тое, што сказана, застаецца сказаным; налады вызначаюць толькі тое, што будзе далей.
Калі аўдыё скончылася, адпраўце {"type":"eof"} (session.eof() у SDK). Сервер транскрыбуе тое, што яшчэ ўтрымлівае, адпраўляе апошнія фіксацыі, потым session.done, і закрываецца. Адпраўка cancel замест гэтага спыняе адразу і адкідае рэшту.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason — гэта eof, max_duration (сеанс дасягнуў максімальнай даўжыні па тарыфе і прайшоў тое ж завяршэнне) або cancelled. stats вымяраецца серверам: audioS — гэта аўдыё, якое вы адправілі, processingS — час насценнага гадзінніка, за які сеанс ішоў, а realtimeFactor — іх стаўленне. Жывы сеанс дае трохі менш за 1, бо час насценнага гадзінніка ўключае фінальнае дабіранне пасля вашага eof. Лік значна ніжэйшы за гэта азначае, што сеанс ішоў заўважна даўжэй, чым трываў сам аўдыё; першае, на што глядзіце, — як хутка прыходзіла аўдыё. Для файла гэты лік паказвае, у колькі разоў транскрыпт вярнуўся хутчэй за прайграванне.
Сеанс доўжыцца не больш за limits.maxDurationS з GET /v1/realtime/capabilities, а яго аўдыё спісваецца з вашага крэдыту па жывой стаўцы вашага тарыфу, з той жа мінімальнай аплатай у 5 хвілін, што і для любой транскрыпцыі: кожны сеанс, які атрымаў аўдыё, аплачваецца не менш чым за 5 хвілін (цэны). Той самы сокет таксама прымае цэлы файл: стварыце сеанс з input: { kind: "file" }, адпраўце байты самога файла (mp3, wav, flac, ogg, m4a) і eof, і транскрыпт вернецца ў разы хутчэй за прайграванне аўдыё, з кадрамі transcript.progress па шляху.
Аднаўлення няма. Разарваная сувязь завяршае сеанс; перападключэнне азначае стварэнне новага сеанса, які аплачваецца сваім мінімумам, і паўторную трансляцыю з вашай жывой крыніцы, з seq, які зноў пачынаецца з 0. Само перападключэнне — стандартны шаблон для любога сокет-API, і варта яго правільна зрабіць адзін раз:
retryable: true у кадры error, 503 з яго Retry-After, або 30 секунд без кадра, што азначае capacity_exceeded. invalid_token і session_expired азначаюць новы streamUrl з вашага сервера, а не яшчэ адну спробу са старым.Трымайце маршрут, які стварае сеанс, танным для выкліку і ставіцеся да закрыцця як да новага старту. Адсутнасць аднаўлення — цана за тое, што нічога не захоўваецца; калі транскрыпт павінен перажыць сокет, захавайце яго самі.
Калі хочаце, каб той жа паток захоўваўся як запіс, правіўся пасля, экспартаваўся і чытаўся аўдыторыяй на іх уласных мовах, публікуйце яго ў транскрыбаванні ў рэальным часе. Як выйсці ў эфір праводзіць той жа сокет PCM праз транскрыпцыю і спасылку трансляцыі, а кіраўніцтва для слухачоў паказвае бок падпісніка, дзе кожны слухач выбірае мову, а позні далучэнец даганяе з курсора lastSeq. Тое, як гэта выглядае для аднаго пакоя, разглядаецца ў артыкуле «Адна спасылка, кожная мова».
Для захаванага файла стаўны маршрут — транскрыбаваць файл праз API. Поўны даведнік па кадрах, з кожным полем, — кіраўніцтва Трансляцыя з мікрафона у даведніку API.