업로드는 전사본이 아니라 작업을 반환합니다. 모든 생성 요청에 Idempotency-Key를 붙여, 재시도가 두 번째 전사를 만들거나 두 번 청구하는 일이 없도록 하세요. 그런 다음 작업이 완료될 때까지 폴링하고, 같은 ID로 문장, 단어 또는 내보내기 파일을 읽으세요.
파일 전사는 VocalStack API가 수행하는 가장 단순한 작업이며, 모든 비동기 작업과 같은 형태를 띱니다. 업로드하고, 기다리고, 읽는 것이죠. 업로드 요청에는 즉시 전사 항목과 그 작업(job)이 응답으로 돌아오지만, 아직 단어는 없습니다. 프로그램이 작업 상태를 조회하는 동안 서버는 오디오가 재생되는 속도보다 몇 배 빠르게 파일을 전사합니다. 상태가 done이 되면 화자와 시간 정보가 담긴 문장 단위 전사본을 읽거나, 네 가지 형식 중 하나의 파일로 내보낼 수 있습니다. 원래 언어로 된 전사본이든 번역본이든 상관없습니다.
이 구조에 속한 것은 모두 다른 배치 API에도 그대로 적용됩니다. ID를 반환하는 생성 요청, 조회하는 상태, 나중에 가져오는 결과가 그렇습니다. VocalStack에만 해당하는 것은 필드 이름뿐이며, 이 튜토리얼은 TypeScript와 Python SDK, 그리고 필요한 곳에서는 HTTP 호출을 함께 살펴봅니다.
대시보드에서 개발자 → API 키를 열고 Read와 Write 권한을 가진 키를 만드세요. 키는 한 번만 표시되므로 환경 변수에 복사해 두세요:
export VOCALSTACK_API_KEY="<your API key>"
키는 서버와 스크립트를 위한 것입니다. 브라우저로 배포되는 코드에 키를 넣으면 키도 함께 노출되므로, 페이지 코드와 URL에는 넣지 마세요. URL은 로그에 남기 때문입니다. 브라우저에서 스트리밍하려면 서버가 그 브라우저를 위한 실시간 세션을 만들어야 합니다. 이는 다른 튜토리얼에서 다룹니다.
모든 호출은 키를 Bearer 토큰으로 사용해 인증합니다:
Authorization: Bearer vs_key_...
키를 환경 변수에 저장했으니, 다음은 이 키를 보낼 클라이언트를 준비할 차례입니다.
SDK는 두 가지가 있습니다. 각 SDK에는 VocalStack 클라이언트 하나가 있고, 메서드는 리소스별로 묶여 있습니다. vs.transcriptions, vs.jobs, vs.recordings, vs.realtime 등이 그 예입니다. TypeScript SDK는 Node 20 이상과 브라우저에서 동작하며, Python SDK는 asyncio를 기반으로 하고 Python 3.10 이상이 필요합니다.
SDK는 npm이나 PyPI에 올라가 있지 않습니다. API가 모든 계정에 빌드 파일을 제공합니다. 환경에 맞게 채워진 설치 명령은 API 레퍼런스의 빠른 시작 중 Install 섹션에 있으며, 이 명령은 키를 API 호스트로만 전송합니다. 아무것도 설치하고 싶지 않다면, 아래 HTTP 호출은 curl과 jq만 있으면 됩니다.
어떤 방법을 선택하든 첫 번째 호출은 업로드입니다.
POST /v1/transcriptions는 파일을 multipart/form-data로 받습니다. file 파트와, JSON을 담는 선택적 options 파트를 보냅니다. 응답은 201과 함께 전사 항목을 돌려줍니다. 그중 job.recordingId는 이 파일로 만든 녹음의 ID이며, 이후의 모든 호출은 이 ID로 지정합니다.
첫 실행에서 중요한 설정은 두 가지이며, 모두 options.settings 안에 있습니다:
spokenLanguage: en 같은 언어 코드입니다. 생략하면 언어를 자동으로 감지합니다. 언어를 직접 지정하면 잡음이 많은 녹음이나 강한 억양에서 정확도가 높아집니다.speakerLabels: auto, on, off 중 하나입니다. on이면 모든 문장에 화자 번호가 붙고, auto이면 두 번째 화자가 감지된 뒤에야 레이블이 표시됩니다.대부분의 오디오·비디오 파일을 받습니다. mp3, wav, flac, ogg, m4a, mp4 등이 가능합니다. 비디오는 오디오만 남깁니다. 음성 인식은 16kHz 모노에서 동작하고 들어온 모든 파일은 도착 즉시 그 형식으로 리샘플링되므로, 형식은 정확도에 거의 영향을 주지 않습니다. 한 사람이 말하는 48kHz 스테레오 WAV는 대부분의 용량을 고음질의 무음을 기록하는 데 씁니다. 같은 강연을 담은 64kbps 모노 MP3나 Opus 파일과 비교하면 전사 정확도는 똑같지만, 용량은 약 20배 크므로 업로드에도 20배 더 오래 걸립니다. 업로드 전에 변환한다면 모노와 적당한 비트레이트, 이 두 가지 설정만 바꿔 볼 가치가 있습니다.
모든 업로드에 Idempotency-Key를 보내고, 그 이유를 이해해 두세요. 네트워크 시간 초과로는 서버가 무엇을 했는지 알 수 없습니다. 요청을 아예 받지 못했을 수도 있고, 응답이 유실되기 전에 파일을 저장하고 작업을 큐에 넣었을 수도 있습니다. 따라서 무턱대고 재시도하면 전사가 두 번 만들어지고 요금도 두 번 청구될 위험이 있습니다. 이 키가 그 문제를 해결합니다. 24시간 안에 같은 키와 같은 본문으로 요청이 반복되면, 서버는 아무것도 새로 만들지 않고 처음 응답을 돌려줍니다. 이렇게 '최소 한 번'을 '사실상 한 번'으로 바꿉니다. 그러므로 원칙은 논리적 업로드 하나당 키 하나이며, 첫 시도 전에 생성하고 모든 재시도에서 재사용하는 것입니다. 시도마다 새 키를 쓰면 재시도가 매번 새 업로드가 되어 이 헤더는 아무 보호도 하지 못합니다.
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
같은 호출을 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)
201 응답은 단어가 하나도 전사되기 전에 돌아옵니다. 손에 쥔 것은 작업 ID이므로, 다음 단계는 그 작업이 끝나기를 기다리는 것입니다.
작업은 queued → transcribing → done 순서로 진행되며, 실패하면 failed 상태가 되고 error 메시지와, 원인을 기계가 읽을 수 있는 경우 errorCode가 함께 옵니다. 네 번째 상태인 cropping은 대시보드의 자르기 단계처럼 누군가 녹음의 어느 구간을 전사할지 고를 때까지 작업이 대기 중이라는 뜻입니다. 일반 업로드는 이 상태에 들어가지 않지만, jobs.wait는 작업이 이 상태에 도달하면 반환합니다. 상태가 done 또는 failed가 될 때까지 GET /v1/recordings/{id}/job을 조회하세요.
웹훅은 없습니다. 이는 의도된 선택입니다. 웹훅을 쓰려면 여러분 쪽에 공개 HTTPS 엔드포인트, 다른 누구도 호출하지 못하도록 하는 서명 검증, 엔드포인트가 다운됐을 때를 위한 재시도 정책이 필요합니다. 1분 남짓 걸리는 작업에는 그만큼 복잡한 배관 작업이 필요한 셈입니다. 폴링에는 이런 것이 전혀 필요 없습니다. 여러분의 스크립트는 뒷좌석의 아이처럼 2초마다 '다 됐어?'라고 물어보면 됩니다. 다만 아이와 달리 백분율 진행률을 돌려받습니다.
이미 열려 있는 웹 페이지라면 대신 전사의 이벤트 소켓을 구독할 수 있습니다(WS /v1/transcriptions/{id}/events, 작업이 진행되면 job.progress 프레임이 옵니다). 스크립트에는 폴링이 알맞은 도구입니다.
폴링은 SDK가 처리합니다. vs.jobs.wait는 2초마다 작업을 조회하고, 조회할 때마다 결과를 onProgress(Python에서는 on_progress)로 넘깁니다. 작업의 progress에는 pct(0~100)와 지금까지 전사된 미디어 초 단위 processedS가 담깁니다. 아래 프로그램은 상태나 백분율이 바뀔 때만 한 줄을 출력합니다.
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}")
wait는 failed일 때 예외를 던지지 않고 작업을 반환하며, 확인은 여러분이 직접 해야 합니다. 강연 정도라면 2초가 적당합니다. 3시간짜리 녹음이나 수백 개의 배치라면 간격(intervalMs, interval_s)을 넓히거나, 작업이 진행되는 동안 점점 간격을 늘리세요. 처음 몇 번의 조회가 가장 많은 정보를 주고, 이후의 조회는 대부분 서버가 아직 바쁘다는 것을 확인해 줄 뿐입니다. SDK 없이 같은 루프를 직접 작성하려면 다음과 같습니다:
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
상태가 done으로 바뀌면 전사본이 준비된 것입니다.
GET /v1/recordings/{id}/transcript는 전체 전사본을 하나의 문서로 응답합니다:
sentences: 사용자에게 보여 줄 문장들입니다. 각 문장에는 text, speaker, 초 단위의 start와 end가 있습니다.segments: 엔진이 나눈 줄들이며, 각 줄의 words에 단어 단위 시간 정보가 있습니다(모든 단어에 text, start, end가 있습니다).speakers: 화자 번호마다 항목 하나씩이며, 각 항목에 name이 있습니다.화자 번호는 0부터 시작합니다. speaker 값이 -1이면 해당 녹음에서 화자 레이블이 꺼져 있었다는 뜻입니다. ?lang=es를 덧붙이면 스페인어로 번역된 전사본을 읽을 수 있으며, 문장도 그 언어로 돌아옵니다.
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'
두 가지 시간 단위는 각각 다른 역할을 맡습니다. 문장은 읽기의 단위이므로 자막이나 전사 페이지에 보여 주는 것이 문장입니다. 단어는 가리키기의 단위이므로, 가라오케 스타일 하이라이트, 검색 결과, 클립 자르기에서는 대신 segments[].words를 순회합니다. 내보내기 형식은 모두 문장을 기반으로 만들어집니다.
GET /v1/recordings/{id}/export는 실제 파일을 반환합니다. format은 srt, json, xlsx, pdf 중 하나이며, 파일에는 수정 사항이 모두 반영됩니다. 번역본 파일을 원하면 lang=을 덧붙이세요. 그러면 내보내기는 그 언어로 작성되며, 문장당 한 줄씩 나오고, 아직 번역되지 않은 문장은 먼저 번역됩니다.
SRT는 눈으로 익혀 둘 가치가 있습니다. 거의 모든 플레이어가 지원하는 자막 형식이고 일반 텍스트이기 때문입니다. 파일은 빈 줄로 구분된 큐(cue)의 목록이며, 각 큐는 번호, 시간 범위, 텍스트로 이루어집니다. 타임스탬프는 밀리초 앞에 쉼표를 쓰는데, 이는 이 형식이 끝내 버리지 못한 프랑스식 관습입니다:
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
큐 하나에 문장 하나가 가장 알맞은 단위입니다. 그래야 각 큐를 화면에 떠 있는 시간 안에 읽을 수 있고, 생각이 중간에 잘리지 않습니다. 녹음에 화자 레이블이 있었다면, 각 큐의 텍스트는 화자 이름과 콜론으로 시작합니다.
TypeScript SDK에서 vs.recordings.export는 Response를 반환하므로 arrayBuffer()로 바이트를 읽으세요. Python에서는 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
전사 결과는 저장되므로 대시보드의 Transcriptions 아래에 나타나고, 링크로 공유할 수 있으며, 나중에 언제든 다시 읽을 수 있습니다. 이것이 핵심 흐름인 생성, 대기, 읽기, 내보내기입니다. 이 튜토리얼의 나머지 부분에서는 변형된 경우를 다룹니다. 이미 온라인에 있는 미디어, 마주치게 될 오류, 그리고 파일의 비용입니다.
미디어가 이미 온라인에 있다면 링크를 API에 넘기세요. API가 파일을 직접 가져옵니다. 공개된 http(s) 주소(mp3, 비디오, 팟캐스트 에피소드)나 YouTube 영상이면 됩니다. 호출은 같은 POST /v1/transcriptions이며, multipart 업로드 대신 JSON 본문 { "url": "..." }을 보내고, name과 settings는 앞에서와 같습니다. SDK에서는 vs.transcriptions.createFromUrl(url)(TypeScript)과 vs.transcriptions.create(url=...)(Python)이 같은 요청을 보냅니다.
무언가를 만들기 전에 GET /v1/transcriptions/probe?url=로 링크가 무엇인지 확인할 수 있습니다. 응답의 kind는 세 가지 중 하나입니다:
file: 끝이 있는 녹음입니다. 여기서 전사하세요.live: YouTube 라이브 스트림, 끝나지 않는 라디오 스트림, 또는 끝이 없는 HLS 재생 목록입니다. 라이브 링크는 끝나지 않으므로 파일로 전사할 수 없고, 대신 실시간 전사의 소스로 사용하세요.unknown: 서버가 판단하지 못했습니다.링크가 미디어로 전혀 열리지 않는 경우(예를 들어 웹 페이지), ok는 false가 되고 problem에 그 이유가 한 문장으로 나옵니다. 프로브는 선택 사항이며, 분당 60회로 제한됩니다.
링크 작업은 transcribing과 done으로 넘어가기 전에 fetching으로 시작합니다. 가져오는 동안 progress.phase가 서버가 무엇을 하고 있는지 알려 줍니다. 데이터가 도착하는 동안은 fetching이며(상대 측에서 총량을 알려 주면 fetchedBytes와 fetchTotalBytes도 함께 옵니다), 비디오에서 오디오를 추출하는 동안은 extracting입니다. progress.phasePct는 그 단계가 얼마나 진행됐는지 보여 줍니다. 긴 영상은 시간이 걸리므로 몇 초마다 폴링하세요. 이를 위해 vs.jobs.wait는 intervalMs(Python에서는 interval_s)를 받습니다.
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}%`);
},
});
레퍼런스의 링크 전사하기 가이드에는 두 언어와 HTTP로 된 전체 프로그램이 있습니다. 링크에서 가져오는 방식은 실패할 경우가 더 많으므로, 실제로 마주치게 될 오류를 정리했습니다.
실패한 호출은 모두 하나의 JSON 봉투로 응답합니다. 분기 처리에 쓸 문자열은 code, HTTP 상태는 status입니다. message는 사람을 위한 설명이고, requestId는 지원팀 문의에 씁니다. data 아래의 retryable은 같은 요청을 다시 보내면 도움이 되는지 알려 주며, details에는 해당 코드에 필요한 정보가 담깁니다. SDK는 같은 필드를 가진 VocalStackError라는 오류 타입 하나를 발생시킵니다.
첫날부터 처리해 둘 가치가 있는 코드는 세 가지입니다.
402 insufficient_credit: 계정의 크레딧이 모두 소진되었으며, 아무 것도 청구되지 않았습니다. details.balance는 달러 단위의 잔액이고, details.topUpUrl은 크레딧을 충전하는 곳입니다. 길이를 아는 파일처럼 비용을 미리 알 수 있었던 경우에는 details.required에 필요했던 금액이 나옵니다.429 rate_limited: Retry-After 초만큼 기다리세요. details.metric이 creates_per_hour이면 순간적인 호출 한도가 아니라 요금제의 생성 속도(시간당 새 전사 수)에 도달한 것입니다. limit과 resetsAt은 상한이 얼마인지, 그리고 언제 다시 여유가 생기는지 알려 줍니다.503 unavailable: 서비스가 잠시 사용할 수 없는 상태입니다. Retry-After 이후에 요청을 다시 보내세요.SDK는 429와 503에 대해 Retry-After 이후 기본적으로 두 번까지 재시도합니다(클라이언트의 maxRetries / max_retries). 다만 503은 두 번 보내도 안전한 호출, 즉 GET이나 Idempotency-Key가 있는 생성 요청에서만 재시도합니다. 키가 없는 생성 요청은 여러분에게 맡겨집니다. SDK는 첫 시도가 성공했는지 알 수 없기 때문입니다. 이것이 모든 업로드에 키를 달아야 하는 실질적인 이유입니다.
모든 응답에는 RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset 헤더도 함께 옵니다. 그래서 바쁜 파이프라인은 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…"
}
코드 전체 표는 오류, 재시도 및 제한에 있습니다. 그 402는 금액과 직접 관련된 유일한 오류이므로, 파일 한 건의 비용을 살펴보겠습니다.
파일은 오디오 1시간당 $0.20이며, 초 단위로 과금됩니다($0.0033/분). 최소 과금 시간은 5분입니다. 즉 30분짜리 파일은 $0.10입니다. 진행 표시줄이 차는 동안 마시게 될 커피보다 저렴한 10센트입니다. 요금은 모든 요금제에서 같고, API를 통해서든 대시보드에서든 동일합니다. 새 계정에는 일회성 $5 가입 크레딧이 주어지며, 카드 등록은 필요하지 않습니다.
이 튜토리얼과 관련된 요금제 제한은 세 가지입니다.
GET /v1/realtime/capabilities가 limits.maxUploadBytes로 알려 줍니다. 둘 중 작은 값이 적용되며, 그보다 큰 파일은 전사가 시작되기 전에 413으로 거부됩니다. 긴 녹음이라면 WAV 대신 압축된 모노 파일을 업로드해야 할 이유가 하나 더 생기는 셈입니다.GET /v1/billing은 언제든지 요금제, 잔액, 제한을 보여 줍니다. 전체 표는 요금제 페이지에 있습니다.
여기서부터 다음 단계는, 파일이 끝나기를 기다릴 수 없다면 실시간으로 오디오 스트리밍하기이고, 같은 계정에 MCP 서버를 통해 AI 에이전트를 연결하기입니다. 그러면 위의 코드 없이도 에이전트가 대신 전사하고 검색해 줍니다.