Một bản phiên âm thời gian thực đến hai lần: trước hết là bản nháp liên tục thay đổi, sau đó là một dòng chốt không đổi nữa. Hãy hiển thị bản nháp như một phần đuôi có thể thay thế và dòng đã chốt như lịch sử; phần còn lại của khung phụ đề trực tiếp chỉ là hạ tầng kết nối.
Một phiên thời gian thực là việc nhỏ nhất mà API VocalStack làm. Máy chủ của bạn gửi một lệnh HTTP và nhận lại một streamUrl đã được ký. Ứng dụng phía khách mở một WebSocket tới URL đó, gửi âm thanh thô ngay khi được thu, và đọc bản phiên âm về dưới dạng các khung văn bản JSON, chỉ chậm hơn người nói vài khoảnh khắc. Khi âm thanh kết thúc, bạn gửi eof; máy chủ xả nốt phần còn lại, báo cáo các số liệu đo được của phiên, rồi đóng kết nối.
Không có gì được lưu lại. Bản phiên âm chỉ tồn tại trên đường truyền, đúng với những gì khung phụ đề, lớp phủ cuộc họp hay tính năng giọng nói trong ứng dụng riêng của bạn cần. Nếu bạn muốn giữ lại, chia sẻ và xuất các nội dung sau đó, bạn nên dùng phiên âm trực tiếp và buổi phát sóng; mục cuối cùng sẽ chỉ đường tới đó.
Cấu trúc này giống với hầu hết các API nhận dạng giọng nói dạng luồng: trao đổi thông tin xác thực qua HTTP, một socket sống lâu với âm thanh đi vào và JSON đi ra, và một cách phân biệt văn bản tạm thời với văn bản đã chốt. Tên gọi thay đổi từ API này sang API khác; các thành phần chính thì hầu như không đổi. Dưới đây là các bước, theo thứ tự:
POST /v1/realtime/sessions cùng khóa API của bạn, trên máy chủ của bạn. Phản hồi là { sessionId, streamUrl, expiresAt }.streamUrl.session.loading cho tới khi nhận được session.ready.transcript.draft (thay thế phần đuôi) và transcript.committed (thêm một dòng chốt).{"type":"eof"}; đọc session.done.Chỉ bước 1 cần khóa của bạn, nên bước đó chạy trên máy chủ của bạn.
Tạo khóa trong mục Developers, API keys với quyền Write. Khóa dành cho máy chủ và script: API từ chối khóa được gửi từ trang web hoặc nằm trong URL, vì vậy bạn tạo phiên phía sau một route riêng của mình, và trang web chỉ bao giờ thấy streamUrl.
URL mang theo token riêng vì một giới hạn của trình duyệt, điều đáng biết nói chung. Hàm khởi tạo WebSocket của trình duyệt không thể đặt tiêu đề yêu cầu, nên không có tiêu đề Authorization nào để đặt khóa vào; mọi API giọng nói hướng tới trình duyệt đều xác thực socket bằng một token có thời hạn ngắn trong URL (hoặc nhét vào tên subprotocol). Điểm mấu chốt là thời hạn ngắn: URL cuối cùng sẽ nằm trong nhật ký và lịch sử, nên những gì nó mang theo phải hết hạn. Hãy kết nối trước expiresAt.
Các SDK được tải xuống từ khu vực Developers, không phải từ kho gói; phần Install trong hướng dẫn nhanh hiển thị các lệnh riêng của môi trường này. Với SDK TypeScript, một route trên máy chủ trông như sau:
// 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 };
}
Không dùng SDK thì chỉ cần một fetch. Ví dụ này cũng yêu cầu tiếng Tây Ban Nha và tắt tính năng ghép câu, khớp với mẫu trên đường truyền bên dưới:
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 };
Mọi tùy chọn đều có giá trị mặc định. Những tùy chọn nên biết ngay từ đầu là spokenLanguage (bỏ qua thì ngôn ngữ sẽ được tự động nhận diện), translate (danh sách ngôn ngữ đích), speakerLabels và latencyMode; các tùy chọn còn lại sẽ xuất hiện khi cần ở phần bên dưới.
Chuyển streamUrl cho bất cứ thứ gì đang thu âm thanh; từ đây trở đi, không còn khóa nào liên quan.
Kết nối tới streamUrl như vậy, không kèm tiêu đề nào. Trong lúc phiên khởi động, máy chủ gửi các tín hiệu session.loading. Sau đó là session.ready. Đừng gửi âm thanh trước đó. Âm thanh gửi sớm sẽ không được giữ lại; máy chủ coi đó là lỗi, gửi một khung error và đóng socket.
Khi socket đã mở, mọi lỗi đều có dạng của khung đó: {"type":"error","code":"…","message":"…","retryable":true|false}. code là một danh sách đóng (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message dành cho nhật ký của bạn, còn retryable cho biết có đáng thử một phiên mới hay không. Hãy xử lý ngay từ khung đầu tiê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;
}
});
Một client thô phải tự xử lý thêm hai điều; các SDK đã làm sẵn. Thứ nhất, một yêu cầu nâng cấp bị từ chối sẽ mang theo phong bì lỗi HTTP thông thường của API: 401 invalid_token, 410 session_expired, hoặc 503 unavailable kèm tiêu đề Retry-After cần tuân theo.
Thứ hai, tình trạng quá tải trông như im lặng, chứ không phải lỗi. Nếu trong 30 giây không có khung nào đến, hãy coi đó là capacity_exceeded và kết nối lại; một phiên mới thường sẽ vào được. Nếu các khung loading vẫn tiếp tục đến nhưng session.ready không bao giờ đến, hãy coi đó là engine_unavailable, lỗi này cũng có thể thử lại. Trong các SDK, RealtimeSession.connect hoàn tất khi có session.ready và thất bại sau readyDeadlineMs với mã đúng.
Khi session.ready đến, máy chủ mong âm thanh được gửi với nhịp độ đúng như lúc được nói.
Âm thanh được gửi đi dưới dạng các khung nhị phân PCM thô: float 32-bit, little-endian, 16 kHz, mono (pcm_f32le, định dạng audio mặc định của phiên). Gửi ngay khi được thu, theo từng đoạn khoảng 100 đến 250 ms, mà không chờ bản phiên âm giữa các đoạn.
Đáng để tính toán số byte một lần. 16,000 mẫu mỗi giây, mỗi mẫu 4 byte, là 64,000 byte mỗi giây, vậy 100 ms là 6,400 byte và 250 ms là 16,000 byte. Các mô hình giọng nói thường được huấn luyện trên âm thanh mono 16 kHz, đó là lý do nhiều API thời gian thực yêu cầu định dạng này, trong khi micro và trình duyệt thu âm ở 44.1 hoặc 48 kHz và thường là stereo. Cần có một bước lấy mẫu lại âm thanh và gộp các kênh thành một trước khi các byte được gửi đi: ở đây là ffmpeg, còn trong trình duyệt là hàm hỗ trợ micro của SDK. Gửi âm thanh 48 kHz nhưng khai báo 16 kHz thì không báo lỗi; nó sẽ phiên âm giọng nói như đang quay chậm, và kết quả rất tệ.
Trên Node, ffmpeg là thiết bị thu âm đơn giản nhất:
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 không bao giờ chặn; nó xếp hàng. bufferedAmount là kích thước của hàng đợi đó, và trên một đường truyền khỏe mạnh nó luôn gần bằng không. Âm thanh trực tiếp không thể tạm dừng, nên cách phản ứng hữu ích khi hàng đợi tăng là nhận ra điều đó, chứ không phải bỏ khung. Máy chủ cũng áp dụng cơ chế backpressure. Nó chấp nhận âm thanh đi trước thời gian thực vài giây (limits.audioBufferS), rồi ngừng đọc cho tới khi theo kịp. Chiều ngược lại, các đoạn chốt được xếp hàng không mất mát, trong khi các bản nháp được gộp lại, nên một bên đọc chậm sẽ thấy ít bản nháp hơn nhưng mới hơn. Client không thể theo kịp ngay cả với các đoạn chốt sẽ nhận một khung error với slow_consumer và retryable: false, rồi bị ngắt kết nối; nếu bạn thấy lỗi này, nghĩa là trình xử lý tin nhắn của bạn đang làm quá nhiều việc.
Trong trình duyệt, streamMicrophone từ @vocalstack/sdk/mic yêu cầu quyền micro, lấy mẫu lại xuống 16 kHz, và gửi các đoạn cùng với khung clock, giúp máy chủ đánh dấu các khoảng trống khi trình duyệt giới hạn tab. Trang web nhận phiên từ route của bạn và không bao giờ thấy khóa:
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, '');
}
}
}
Chữ ký render(committed, tail) đó chính là toàn bộ mẫu giao diện; mục tiếp theo giải thích lý do.
Hai loại khung dựng nên bản phiên âm, và nhầm lẫn giữa chúng là lỗi phổ biến nhất.
Trước hết, vì sao lại có hai loại. Một mô hình giọng nói quyết định một từ là gì phần nào dựa vào các từ theo sau nó: "I scream" và "ice cream" nghe giống hệt nhau cho đến khi câu tiếp tục. Vì vậy bộ nhận dạng thời gian thực đưa ra một phỏng đoán sớm, rồi sửa lại khi có thêm âm thanh, và chỉ ổn định khi đã nghe đủ. Mọi API nhận dạng giọng nói dạng luồng đều để lộ điều này bằng cách nào đó. Một số gọi các phỏng đoán là kết quả "tạm thời" hoặc "một phần" và đánh dấu các kết quả đã ổn định bằng is_final; từ vựng khác nhau, nhưng nguyên tắc thì không. Ở đây, phỏng đoán là bản nháp, còn văn bản đã ổn định là đoạn chốt.
transcript.draft là phần đuôi trực tiếp: các từ đã nghe kể từ lần chốt trước, vẫn đang thay đổi. Mỗi bản nháp thay thế hoàn toàn bản trước đó. Vẽ lại phần đuôi bằng segments của nó; đừng bao giờ thêm vào. Theo thiết kế, bản nháp không mang seq, nên đừng cố ghép chúng với các đoạn chốt mà chúng sẽ trở thành.
transcript.committed là kết quả cuối: một segment với seq (0, 1, 2, liên tục không đứt quãng), start, end, text, speaker và words kèm thời gian từng từ. Hãy thêm nó vào và bỏ bản nháp mà nó bao phủ; bản nháp kế tiếp bắt đầu từ nơi đoạn chốt này kết thúc.
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
Giao diện nào thêm bản nháp vào sẽ hiển thị văn bản bị lặp và nhấp nháy. Khung phụ đề của bạn sẽ bị giật mà người nói chưa từng bị. Hãy hiển thị tất cả các đoạn đã chốt, rồi đến bản nháp mới nhất phía sau chúng, và xóa bản nháp khi có đoạn chốt đến. Một đoạn chốt có thể chứa những từ chưa từng xuất hiện trong bản nháp nào.
Dưới đây là vài giây lời nói trên đường truyền, từ ví dụ fetch ở trên (translate: ["es"], tắt ghép câu):
{"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}]}
Còn hai khung nữa nằm xung quanh các khung này. transcript.amended sửa một đoạn đã chốt tại chỗ (cùng seq, với text hoặc speaker mới) khi một từ trong đó đã được sửa; hãy áp dụng nó lên dòng bạn đã vẽ. transcript.words truyền thời gian từng từ cho phần đuôi ngay trước khi nó được chốt. Nó cần events.words, mặc định đã bật; một đoạn chốt luôn đính kèm words của nó, bất kể tùy chọn sự kiện là gì. Cuối cùng, hãy bỏ qua các loại khung bạn không biết: loại mới được thêm vào mà không báo trước.
Đây là vòng lặp đọc của SDK Python, với terminal làm khung phụ đề. Ở đây cũng tắt ghép câu, nên các bản dịch đến dưới dạng transcript.translated, và khung error xuất hiện như một VocalStackError được ném ra khỏi vòng lặp:
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())
Dịch theo từng đoạn, như trên, là cách đơn giản. Cách mà người đọc ưa dùng hơn có một mục riêng.
translate: ["es", "fr"] yêu cầu dịch trong lúc phiên diễn ra. Cách các bản dịch đến phụ thuộc vào tính năng ghép câu (sentences: { enabled: true }), vốn được bật mặc định.
Khi tắt ghép câu, mỗi đoạn chốt được theo sau một lát bởi một khung transcript.translated nêu seq của nó và mang translations được khóa theo ngôn ngữ; hãy gắn chúng vào đoạn đó. Đây là cách các ví dụ đến nay đã dùng. Cách này đơn giản và đọc giống như một nhật ký chốt: một đoạn, một bản dịch, ranh giới câu nằm ở bất cứ chỗ nào người nói lấy hơi.
Khi bật ghép câu, máy chủ ghép các đoạn chốt thành những câu hoàn chỉnh rồi dịch các câu đó, cách đọc dễ hơn nhiều. Mỗi khung transcript.sentence thay thế hoàn toàn một câu, được khóa theo sentenceId. status của nó chuyển từ open sang closed (hoặc dropped, nghĩa là bỏ đi), và translations của nó chỉ là tạm thời khi đang open và trở thành kết quả cuối khi đóng. Hãy upsert theo sentenceId, đừng bao giờ thêm vào. SDK TypeScript có sẵn applySentence chính cho việc này:
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}`);
}
Bản dịch theo sau bản phiên âm chậm một hai giây. Nếu bạn muốn chúng được đọc thành tiếng, hãy thêm voice: ["es"] (cần bật ghép câu). Tiếng Tây Ban Nha của mỗi câu đã đóng sau đó sẽ đến dưới dạng một khung transcript.voice, mang đoạn âm thanh dạng base64 Ogg/Opus 24 kHz mono. Một câu dài đến thành nhiều phần (part trong parts) để phát liên tiếp. Văn bản tạm thời không bao giờ được đọc thành tiếng. Hướng dẫn dịch trực tiếp trình bày chi tiết về giọng đọc, còn số giây đi đâu giải thích chuỗi độ trễ.
Ngôn ngữ đích không được cố định khi tạo phiên.
Một khung văn bản session.update thay đổi spokenLanguage, translate, speakerLabels, latencyMode, voice hoặc voiceByLang từ thời điểm đó trở đi; các trường bạn bỏ qua thì giữ nguyên.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
Các SDK cung cấp nó dưới dạng session.update(…). translate thay thế toàn bộ danh sách, còn [] tắt dịch. Các đoạn đã gửi đi sẽ không được phiên âm hay dịch lại, và nếu bạn bật nhãn người nói, người nói chỉ được gắn nhãn từ thời điểm đó. Những gì đã nói thì vẫn giữ nguyên; các cài đặt chỉ chi phối phần sau.
Khi âm thanh kết thúc, gửi {"type":"eof"} (session.eof() trong các SDK). Máy chủ phiên âm phần nó còn giữ, gửi các đoạn chốt cuối cùng, rồi session.done và đóng kết nối. Gửi cancel thay vào đó sẽ dừng ngay và bỏ phần còn lại.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason là eof, max_duration (phiên đã chạm độ dài tối đa của gói và chạy cùng bước xả như vậy) hoặc cancelled. stats do máy chủ đo: audioS là âm thanh bạn đã gửi, processingS là thời gian thực tế phiên đã chạy, còn realtimeFactor là tỷ số giữa chúng. Một phiên trực tiếp thường đạt ngay dưới 1, vì thời gian thực tế bao gồm bước xả cuối sau eof của bạn. Một con số thấp hơn đáng kể nghĩa là phiên mất nhiều thời gian hơn rõ rệt so với độ dài âm thanh; nơi đầu tiên nên xem là âm thanh đến nhanh đến mức nào. Với một tệp, con số tương tự cho biết bản phiên âm được trả về nhanh gấp bao nhiêu lần so với thời lượng phát.
Một phiên kéo dài tối đa limits.maxDurationS lấy từ GET /v1/realtime/capabilities, và âm thanh của nó tiêu tín dụng của bạn theo từng giây với mức giá trực tiếp của gói bạn, cùng mức tối thiểu 5 phút như mọi bản phiên âm khác: mỗi phiên nhận âm thanh được tính phí tối thiểu 5 phút (bảng giá). Cùng socket đó cũng nhận nguyên một tệp: tạo phiên với input: { kind: "file" }, gửi chính các byte của tệp (mp3, wav, flac, ogg, m4a) và eof, và bản phiên âm sẽ trả về nhanh hơn thời lượng phát của âm thanh nhiều lần, kèm các khung transcript.progress trên đường đi.
Không có cơ chế tiếp tục. Kết nối bị rớt sẽ kết thúc phiên; kết nối lại nghĩa là tạo một phiên mới, được tính phí tối thiểu riêng, và truyền lại từ nguồn trực tiếp của bạn, với seq quay về 0. Việc kết nối lại tự nó là mẫu chuẩn cho mọi API socket, và đáng làm đúng một lần:
retryable: true trên khung error, một 503 kèm Retry-After, hoặc 30 giây không có khung nào, tương ứng với capacity_exceeded. invalid_token và session_expired có nghĩa là cần một streamUrl mới từ máy chủ của bạn, chứ không phải thử lại với URL cũ.Giữ route tạo phiên thật nhẹ để gọi, và coi mỗi lần đóng là một khởi đầu mới. Không có tiếp tục là cái giá của việc không lưu trữ gì; nếu bản phiên âm phải tồn tại lâu hơn socket, hãy tự lưu nó.
Khi bạn muốn cùng luồng đó được lưu thành bản ghi, hiệu chỉnh sau đó, xuất ra, và được khán giả đọc bằng ngôn ngữ của họ, hãy xuất bản nó thành một phiên âm trực tiếp. Hướng dẫn phát trực tiếp hướng dẫn cách dùng cùng socket PCM đó qua một bản phiên âm và một liên kết buổi phát sóng, còn hướng dẫn nghe trình bày phía người đăng ký, nơi mỗi người nghe chọn một ngôn ngữ và người đến muộn bắt kịp từ con trỏ lastSeq. Điều đó trông như thế nào trong một phòng là chủ đề của một liên kết, mọi ngôn ngữ.
Với một tệp bạn muốn giữ lại, lối đi được lưu trữ là phiên âm một tệp bằng API. Tài liệu đầy đủ về khung, bao gồm mọi trường, là hướng dẫn Phát luồng từ micro trong tài liệu tham khảo API.