Tải lên trả về một tác vụ, không phải bản phiên âm. Hãy đặt Idempotency-Key cho mọi lệnh tạo để một lần thử lại không bao giờ tạo hay tính phí thêm một bản phiên âm; sau đó thăm dò tác vụ cho đến khi xong, rồi đọc câu, từ hoặc bản xuất từ cùng một mã.
Phiên âm một tệp là việc đơn giản nhất mà API của VocalStack làm, và nó có hình dạng của mọi tác vụ bất đồng bộ: tải lên, chờ, đọc. Phản hồi tải lên trả về ngay một bản phiên âm cùng tác vụ của nó, nhưng chưa có từ nào. Trong khi chương trình của bạn thăm dò tác vụ, máy chủ phiên âm tệp nhanh hơn tốc độ phát âm thanh nhiều lần. Khi trạng thái là done, bạn đọc bản phiên âm dưới dạng các câu kèm người nói và thời gian, hoặc xuất nó thành tệp theo một trong bốn định dạng, bằng ngôn ngữ gốc hoặc đã dịch.
Mọi thứ theo hình dạng này đều áp dụng được cho bất kỳ API xử lý theo lô nào: một lệnh tạo trả về một mã định danh, một trạng thái bạn thăm dò, và một kết quả bạn lấy về sau đó. Chỉ có tên trường là đặc thù của VocalStack. Hướng dẫn này sẽ đi qua chúng với SDK TypeScript và Python, cùng các lệnh HTTP ở những chỗ chúng hữu ích.
Mở Developers → API keys trong Bảng điều khiển và tạo một khóa với quyền Read và Write. Khóa chỉ hiển thị một lần; hãy sao chép vào một biến môi trường:
export VOCALSTACK_API_KEY="<your API key>"
Khóa dành cho máy chủ và các script. Bất cứ thứ gì được đưa lên trình duyệt đều mang theo khóa, vì vậy đừng để khóa trong mã trang hay trong URL, vì URL sẽ nằm lại trong nhật ký. Để truyền trực tiếp từ trình duyệt, máy chủ của bạn tạo một phiên thời gian thực cho trình duyệt đó; đó là một hướng dẫn khác.
Mỗi lệnh gọi xác thực bằng khóa dưới dạng bearer token:
Authorization: Bearer vs_key_...
Khi khóa đã nằm trong môi trường, bước tiếp theo là một client để gửi nó đi.
Có hai SDK. Mỗi SDK có một client, VocalStack, với các phương thức được nhóm theo tài nguyên: vs.transcriptions, vs.jobs, vs.recordings, vs.realtime và một vài cái khác. SDK TypeScript chạy trên Node 20 trở lên và trong trình duyệt; SDK Python được xây dựng trên asyncio và cần Python 3.10 trở lên.
Các SDK không có trên npm hay PyPI. API cung cấp các bản build cho mọi tài khoản. Các lệnh cài đặt, đã điền sẵn cho môi trường của bạn, nằm trong mục Install của bắt đầu nhanh trong tài liệu tham chiếu API, và chúng chỉ gửi khóa của bạn đến máy chủ API. Nếu bạn không muốn cài gì cả, các lệnh HTTP bên dưới chỉ cần curl và jq.
Dù bạn chọn cách nào, lệnh đầu tiên là tải lên.
POST /v1/transcriptions nhận tệp dưới dạng multipart/form-data: một phần file, cùng một phần options tùy chọn chứa JSON. Nó trả lời 201 kèm bản phiên âm. job.recordingId của nó là mã định danh của bản ghi âm được tạo từ tệp, và mọi lệnh gọi sau đó đều dùng mã này.
Hai cài đặt quan trọng cho lần chạy đầu tiên, cả hai nằm trong options.settings:
spokenLanguage: mã ngôn ngữ như en. Nếu bỏ qua, ngôn ngữ sẽ được tự nhận diện. Ghi rõ mã sẽ nâng độ chính xác với bản ghi nhiều tạp âm và giọng nặng.speakerLabels: auto, on hoặc off. Với on, mỗi câu đều mang số người nói; với auto, nhãn chỉ xuất hiện khi đã nghe thấy người nói thứ hai.Hầu hết tệp âm thanh và video đều được chấp nhận: mp3, wav, flac, ogg, m4a, mp4 và nhiều định dạng khác. Với video, chỉ giữ lại phần âm thanh. Định dạng hầu như không ảnh hưởng đến độ chính xác, vì nhận dạng giọng nói làm việc trên âm mono 16 kHz và mọi thứ đều được lấy mẫu lại về mức đó khi nhận. Một tệp WAV stereo 48 kHz ghi giọng của một người nói dành phần lớn dung lượng để mô tả khoảng im lặng với độ trung thực cao. Nó được phiên âm chính xác y như một tệp MP3 hoặc Opus mono 64 kbps của cùng bài nói, nhưng lớn gấp khoảng hai mươi lần, nên tải lên cũng lâu gấp hai mươi lần. Nếu bạn chuyển đổi trước khi tải lên, hai cài đặt đáng thay đổi là mono và một bitrate vừa phải.
Gửi một Idempotency-Key với mọi lần tải lên, và hiểu vì sao. Hết thời gian chờ mạng không cho bạn biết máy chủ đã làm gì: có thể nó chưa bao giờ nhận được yêu cầu, hoặc có thể nó đã lưu tệp và xếp tác vụ vào hàng đợi trước khi câu trả lời bị mất. Do đó, thử lại một cách mù quáng có nguy cơ tạo thêm một bản phiên âm và bị tính phí thêm một lần. Khóa này giải quyết vấn đề đó. Khi một yêu cầu được lặp lại với cùng khóa và cùng nội dung trong vòng 24 giờ, máy chủ trả về câu trả lời đầu tiên thay vì tạo thêm gì, nhờ đó “ít nhất một lần” trở thành “hiệu quả như đúng một lần”. Vì vậy quy tắc là một khóa cho mỗi lần tải lên logic, được tạo trước lần thử đầu tiên và dùng lại ở mọi lần thử lại. Nếu mỗi lần thử dùng một khóa mới, mỗi lần thử lại sẽ thành một lần tải lên mới, và header sẽ chẳng bảo vệ được gì.
import { VocalStack } from '@vocalstack/sdk';
const vs = new VocalStack({ baseUrl: '{{api}}', apiKey: process.env.VOCALSTACK_API_KEY });
const uploadKey = crypto.randomUUID(); // one key for this upload, however many attempts it takes
const tx = await vs.transcriptions.create(
{ file: './talk.mp3', name: 'Talk', settings: { spokenLanguage: 'en', speakerLabels: 'on' } },
{ idempotencyKey: uploadKey },
);
const recordingId = tx.job!.recordingId;
import asyncio
import os
import uuid
from vocalstack import VocalStack
async def main() -> None:
upload_key = str(uuid.uuid4()) # one key for this upload, however many attempts it takes
async with VocalStack("{{api}}", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
tx = await vs.transcriptions.create(
file="./talk.mp3",
name="Talk",
settings={"spokenLanguage": "en", "speakerLabels": "on"},
idempotency_key=upload_key,
)
assert tx.job is not None
recording_id = tx.job.recording_id
Lệnh tương đương với curl:
API={{api}}
AUTH="Authorization: Bearer $VOCALSTACK_API_KEY"
UPLOAD_KEY=$(uuidgen)
REC=$(curl -sSf "$API/v1/transcriptions" -H "$AUTH" -H "Idempotency-Key: $UPLOAD_KEY" \
-F file=@talk.mp3 \
-F 'options={"name":"Talk","settings":{"spokenLanguage":"en","speakerLabels":"on"}}' \
| jq -r .job.recordingId)
Phản hồi 201 quay về trước khi có một từ nào được phiên âm; thứ bạn nắm giữ là một mã tác vụ, nên bước tiếp theo là chờ tác vụ đó.
Một tác vụ chuyển từ queued → transcribing → done, hoặc kết thúc ở failed kèm thông báo error và, khi nguyên nhân có thể đọc bằng máy, một errorCode. Trạng thái thứ tư, cropping, nghĩa là tác vụ bị tạm dừng cho đến khi có người chọn đoạn nào của bản ghi cần phiên âm, giống như bước cắt trong Bảng điều khiển. Một lần tải lên thông thường không bao giờ rơi vào trạng thái này, dù jobs.wait cũng trả về khi tác vụ đạt tới nó. Hãy đọc GET /v1/recordings/{id}/job cho đến khi trạng thái là done hoặc failed.
Không có webhook, và đó là một sự đánh đổi có chủ đích. Webhook cần một endpoint HTTPS công khai ở phía bạn, một bước kiểm tra chữ ký để không ai khác có thể gọi nó, và một chính sách thử lại khi endpoint của bạn ngừng hoạt động. Với một tác vụ chỉ mất một phút, đó là quá nhiều công đoạn phụ trợ. Thăm dò thì không cần gì trong số đó. Script của bạn sẽ hỏi “xong chưa?” mỗi hai giây, giống như đứa trẻ trên ghế sau xe hỏi “đến nơi chưa?”, và khác với đứa trẻ, nó nhận lại được một phần trăm tiến độ.
Một trang web đã mở có thể theo dõi socket sự kiện của bản phiên âm thay thế (WS /v1/transcriptions/{id}/events, một khung job.progress khi tác vụ tiến triển). Với một script, thăm dò là công cụ phù hợp.
SDK tự thực hiện việc thăm dò: vs.jobs.wait đọc tác vụ mỗi 2 giây và chuyển từng lần đọc cho onProgress (on_progress trong Python). Trường progress của tác vụ mang pct (0 đến 100) và processedS, số giây media đã được phiên âm cho đến lúc đó. Các chương trình bên dưới chỉ in một dòng khi trạng thái hoặc phần trăm thay đổi.
let last = '';
const job = await vs.jobs.wait(recordingId, {
onProgress: (j) => {
const line = `${j.state} ${j.progress.pct}%`;
if (line !== last) console.log((last = line));
},
});
if (job.state !== 'done') throw new Error(`transcription ${job.state}: ${job.error ?? ''}`);
last = ""
def on_progress(j) -> None:
nonlocal last
line = f"{j.state} {j.progress.pct}%"
if line != last:
last = line
print(line)
job = await vs.jobs.wait(recording_id, on_progress=on_progress)
if job.state != "done":
raise RuntimeError(f"transcription {job.state}: {job.error}")
Lưu ý rằng wait không ném lỗi khi failed; nó trả về tác vụ và để bạn tự kiểm tra. Hai giây hợp với một bài nói. Với bản ghi ba giờ, hoặc một lô hàng trăm tệp, hãy nới khoảng thời gian (intervalMs, interval_s) hoặc giãn dần khi tác vụ chạy: những lần đọc đầu tiên cho bạn biết nhiều nhất, còn những lần sau chủ yếu chỉ xác nhận rằng máy chủ vẫn đang bận. Nếu không dùng SDK, bạn viết cùng vòng lặp bằng tay:
while :; do
JOB=$(curl -sSf "$API/v1/recordings/$REC/job" -H "$AUTH")
STATE=$(jq -r .state <<<"$JOB")
echo "$STATE $(jq -r .progress.pct <<<"$JOB")%"
case $STATE in
done) break ;;
failed) jq -r .error <<<"$JOB" >&2; exit 1 ;;
esac
sleep 2
done
Khi trạng thái hiển thị done, bản phiên âm đã sẵn sàng.
GET /v1/recordings/{id}/transcript trả về toàn bộ bản phiên âm trong một tài liệu:
sentences: những câu để hiển thị cho người đọc. Mỗi câu có text, speaker, cùng start và end tính bằng giây.segments: các đoạn do engine tạo ra, mỗi đoạn có thời gian theo từng từ trong words (mỗi từ có text, start và end).speakers: một mục cho mỗi số người nói, kèm name của mục đó.Người nói được đánh số từ 0. Giá trị speaker là -1 nghĩa là nhãn người nói đã tắt cho bản ghi đó. Thêm ?lang=es để đọc bản phiên âm đã dịch sang tiếng Tây Ban Nha; các câu sẽ trả về bằng ngôn ngữ đó.
const { sentences } = await vs.recordings.transcript(recordingId);
for (const s of sentences) console.log(s.speaker >= 0 ? `[speaker ${s.speaker}] ${s.text}` : s.text);
snapshot = await vs.recordings.transcript(recording_id)
for s in snapshot.sentences:
print(f"[speaker {s.speaker}] {s.text}" if s.speaker >= 0 else s.text)
curl -sSf "$API/v1/recordings/$REC/transcript" -H "$AUTH" \
| jq -r '.sentences[] | if .speaker >= 0 then "[speaker \(.speaker)] \(.text)" else .text end'
Hai cấp thời gian phục vụ hai việc. Câu là đơn vị để đọc, nên chúng là thứ mà phụ đề hay trang bản phiên âm hiển thị. Từ là đơn vị để chỉ vào, nên phần tô sáng kiểu karaoke, kết quả tìm kiếm hay đoạn cắt clip sẽ duyệt qua segments[].words. Các định dạng xuất đều được xây dựng từ câu.
GET /v1/recordings/{id}/export trả về một tệp thật. format là một trong các giá trị srt, json, xlsx hoặc pdf, và tệp có bao gồm mọi chỉnh sửa. Thêm lang= để có tệp đã dịch: khi đó bản xuất được viết bằng ngôn ngữ đó, mỗi câu một dòng, và câu nào chưa được dịch sẽ được dịch trước.
Nên làm quen với SRT, vì đây là định dạng phụ đề mà hầu hết mọi trình phát đều chấp nhận và nó là văn bản thuần. Một tệp là danh sách các đoạn phụ đề (cue) cách nhau bằng dòng trống, và mỗi cue gồm một bộ đếm, một khoảng thời gian và nội dung. Dấu thời gian dùng dấu phẩy trước phần mili giây, một thói quen kiểu Pháp mà định dạng này chưa bao giờ bỏ:
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
Mỗi cue một câu là độ chi tiết đúng: mỗi cue có thể đọc hết trong thời gian nó hiển thị trên màn hình, và không cue nào cắt ngang giữa ý. Khi bản ghi có nhãn người nói, nội dung của mỗi cue bắt đầu bằng tên người nói và dấu hai chấm.
Trong SDK TypeScript, vs.recordings.export trả về một Response; đọc các byte của nó bằng arrayBuffer(). Trong Python, nó trả về bytes.
import { writeFile } from 'node:fs/promises';
const srt = await vs.recordings.export(recordingId, 'srt');
await writeFile('talk.srt', Buffer.from(await srt.arrayBuffer()));
const pdf = await vs.recordings.export(recordingId, 'pdf', { lang: 'es' });
await writeFile('talk.es.pdf', Buffer.from(await pdf.arrayBuffer()));
srt = await vs.recordings.export(recording_id, "srt")
with open("talk.srt", "wb") as f:
f.write(srt)
pdf = await vs.recordings.export(recording_id, "pdf", lang="es")
with open("talk.es.pdf", "wb") as f:
f.write(pdf)
asyncio.run(main())
curl -sSf "$API/v1/recordings/$REC/export?format=srt" -H "$AUTH" -o talk.srt
curl -sSf "$API/v1/recordings/$REC/export?format=pdf&lang=es" -H "$AUTH" -o talk.es.pdf
Bản phiên âm được lưu lại, nên nó xuất hiện trong mục Transcriptions của Bảng điều khiển, có thể chia sẻ qua liên kết, và có thể đọc lại bất cứ lúc nào sau này. Đó là vòng lặp cốt lõi: tạo, chờ, đọc, xuất. Phần còn lại của hướng dẫn này bàn về các biến thể: media đã có sẵn trên mạng, các lỗi bạn sẽ gặp, và chi phí của một tệp.
Nếu media đã có trên mạng, hãy đưa liên kết cho API và nó sẽ tự tải tệp về: một địa chỉ http(s) công khai (một tệp mp3, một video, một tập podcast) hoặc một video YouTube. Lệnh gọi vẫn là POST /v1/transcriptions, với nội dung JSON { "url": "..." } thay cho tải lên dạng multipart, và name cùng settings như trước. Trong các SDK, vs.transcriptions.createFromUrl(url) (TypeScript) và vs.transcriptions.create(url=...) (Python) gửi cùng yêu cầu.
Trước khi tạo bất cứ thứ gì, GET /v1/transcriptions/probe?url= cho bạn biết liên kết là gì. kind của nó là một trong ba giá trị:
file: một bản ghi có điểm kết thúc; hãy phiên âm ngay tại đây.live: luồng trực tiếp trên YouTube, luồng radio không có hồi kết hoặc danh sách phát HLS không có điểm kết thúc. Liên kết trực tiếp không bao giờ kết thúc, nên không thể phiên âm nó như một tệp; thay vào đó hãy dùng nó làm nguồn cho một phiên âm trực tiếp.unknown: máy chủ không thể xác định.Khi liên kết hoàn toàn không mở được như media (ví dụ một trang web), ok là false và problem giải thích lý do trong một câu. Bước thăm dò này là tùy chọn và được giới hạn 60 lệnh gọi mỗi phút.
Tác vụ từ liên kết bắt đầu ở fetching trước khi chuyển sang transcribing và done. Trong khi tải về, progress.phase cho biết máy chủ đang làm gì: fetching khi các byte đang đến (kèm fetchedBytes và fetchTotalBytes khi phía bên kia cho biết tổng số), hoặc extracting khi âm thanh đang được tách ra khỏi video. progress.phasePct cho biết tác vụ đã đi được bao xa trong giai đoạn đó. Một video dài mất một lúc, nên hãy thăm dò vài giây một lần; vs.jobs.wait nhận intervalMs (interval_s trong Python) cho việc này.
const probe = await vs.transcriptions.probe(url);
if (!probe.ok) throw new Error(`that link did not open: ${probe.problem}`);
if (probe.kind === 'live') throw new Error('a live link: record it with a live transcription instead');
const linkKey = crypto.randomUUID();
const tx = await vs.transcriptions.createFromUrl(url, { name: 'From a link' }, { idempotencyKey: linkKey });
const job = await vs.jobs.wait(tx.job!.recordingId, {
intervalMs: 3000,
onProgress: (j) => {
const p = j.progress;
if (j.state === 'fetching') console.log(`${p.phase ?? 'fetching'} ${p.phasePct ?? 0}%`);
else console.log(`${j.state} ${p.pct}%`);
},
});
Hướng dẫn Phiên âm một liên kết trong tài liệu tham chiếu có chương trình đầy đủ bằng cả hai ngôn ngữ và HTTP. Tải từ liên kết thêm nhiều cách thất bại, nên dưới đây là các lỗi bạn thực sự sẽ gặp.
Mỗi lệnh gọi thất bại trả về một envelope JSON. code là chuỗi để rẽ nhánh xử lý và status là mã trạng thái HTTP; message dành cho con người và requestId dành cho bộ phận hỗ trợ. Trong data, retryable cho biết việc gửi lại cùng yêu cầu có thể giúp ích hay không, còn details chứa mọi thứ mà mã lỗi cần. Các SDK ném ra một loại lỗi, VocalStackError, với cùng các trường.
Có ba mã đáng xử lý ngay từ ngày đầu.
402 insufficient_credit: tín dụng của tài khoản đã hết, và không có khoản nào bị tính phí. details.balance là số dư tính bằng đô la và details.topUpUrl là nơi để nạp thêm tín dụng. Khi chi phí đã được biết trước, như với một tệp có độ dài xác định, details.required cho biết chi phí lẽ ra là bao nhiêu.429 rate_limited: hãy chờ số giây trong Retry-After. Khi details.metric là creates_per_hour, bạn đã chạm giới hạn tốc độ tạo của gói (số bản phiên âm mới mỗi giờ), không phải giới hạn đột biến; limit và resetsAt cho biết mức trần là bao nhiêu và khi nào nó được giải phóng.503 unavailable: dịch vụ tạm thời không khả dụng. Hãy gửi lại yêu cầu sau Retry-After.Các SDK tự thử lại 429 và 503 sau Retry-After, mặc định hai lần (maxRetries / max_retries trên client). Tuy vậy, 503 chỉ được thử lại với lệnh gọi an toàn khi gửi hai lần: một GET, hoặc một lệnh tạo có kèm Idempotency-Key. Lệnh tạo không có khóa thì để bạn tự xử lý, vì SDK không thể biết lần thử đầu tiên có thành công hay không. Đó là lý do thực tế để đặt khóa cho mọi lần tải lên.
Mọi phản hồi cũng mang RateLimit-Limit, RateLimit-Remaining và RateLimit-Reset, nhờ vậy một luồng xử lý bận có thể chậm lại trước khi gặp 429.
{
"code": "insufficient_credit",
"status": 402,
"message": "this account has no credit left; add credit to continue",
"data": {
"retryable": false,
"details": { "balance": -0.42, "required": 0.075, "currency": "usd", "plan": "payg", "topUpUrl": "https://…" }
},
"requestId": "01J…"
}
Toàn bộ bảng mã nằm trong Lỗi, thử lại và giới hạn. Mã 402 là lỗi duy nhất liên quan đến tiền, nên sau đây là chi phí của một tệp.
Tệp có giá $0.20 mỗi giờ âm thanh, tính theo giây ($0.0033 mỗi phút) với mức tối thiểu 5 phút: một tệp 30 phút có giá $0.10. Đó là mười xu, ít hơn ly cà phê bạn uống trong lúc thanh tiến trình chạy đầy. Mức giá giống nhau trên mọi gói, và qua API cũng như trong Bảng điều khiển. Một tài khoản mới bắt đầu với khoản tín dụng đăng ký $5 chỉ một lần, và không cần thẻ.
Ba giới hạn của gói liên quan đến hướng dẫn này.
GET /v1/realtime/capabilities báo cáo dưới dạng limits.maxUploadBytes. Áp dụng giới hạn nhỏ hơn trong hai giới hạn, và một tệp vượt quá sẽ bị từ chối với 413 trước khi bất cứ thứ gì được phiên âm. Với một bản ghi dài, đây là một lý do nữa để tải lên một tệp mono đã nén thay vì WAV.GET /v1/billing báo cáo gói, số dư và các giới hạn của bạn vào bất cứ lúc nào; bảng đầy đủ nằm trên trang bảng giá.
Từ đây, các bước tiếp theo là truyền âm thanh theo thời gian thực khi bạn không thể chờ một tệp xử lý xong, hoặc kết nối một AI agent với cùng tài khoản qua máy chủ MCP, để nó phiên âm và tìm kiếm thay bạn mà không cần bất kỳ đoạn mã nào ở trên.