Guide · async

Transcribe a link

The server fetches a public URL or a YouTube video, then transcribes it like a file. Asynchronous, with a fetch phase first.

Give the API a link instead of a file and it fetches the media itself: a public http(s) address (an mp3, a video, a podcast episode) or a YouTube video. The rest is Transcribe a file: a job to wait on, then the transcript.

  1. Check the link

    GET /v1/transcriptions/probe says what a link is before anything is stored: kind is file (transcribe it here), live (a YouTube live, an endless radio stream, an HLS playlist with no end) or unknown, and ok is false with a problem sentence when the link did not open as media (a web page, an error). A live link never ends, so it is not a file: record it with a live transcription whose source is the link instead (Go live).

    The probe is optional, and limited to 60 calls a minute. Links to private, loopback or cloud metadata addresses are refused.

  2. Create the transcription

    POST /v1/transcriptions with JSON { "url": "…" } (and optional name and settings, as for a file) answers 201 at once; the server fetches the link afterwards. vs.transcriptions.createFromUrl(url) is the same call. An Idempotency-Key makes a retried create return the first transcription instead of a second one.

  3. Wait for the fetch and the transcription

    A link job starts in fetching, then moves to transcribing and done (or failed, with error). While it fetches, progress.phase is fetching (bytes arriving: progress.fetchedBytes of progress.fetchTotalBytes, when the other end says) or extracting (the audio is being taken out of a video), and progress.phasePct is how far that phase is. A long video takes a while to fetch; poll GET /v1/recordings/{id}/job every few seconds (vs.jobs.wait does) rather than in a tight loop.

  4. Read the transcript

    GET /v1/recordings/{id}/transcript, as for a file: sentences, segments with word timings, speakers, and lang= for a translation. The transcription keeps the link it came from (job.source).

TypeScript SDK

link.ts

import { VocalStack } from '@vocalstack/sdk';

const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY });
const url = process.argv[2] ?? 'https://raw.githubusercontent.com/openai/whisper/main/tests/jfk.flac';

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 tx = await vs.transcriptions.createFromUrl(url, { name: 'From a link' }, { idempotencyKey: crypto.randomUUID() });

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}%`);
  },
});
if (job.state !== 'done') throw new Error(`transcription ${job.state}: ${job.error ?? ''}`);

const { sentences } = await vs.recordings.transcript(job.recordingId);
console.log(sentences.map((s) => s.text).join(' '));

Python SDK

link.py

import asyncio
import os
import sys
import uuid

from vocalstack import VocalStack

URL = sys.argv[1] if len(sys.argv) > 1 else "https://raw.githubusercontent.com/openai/whisper/main/tests/jfk.flac"


def show(job) -> None:
    p = job.progress
    if job.state == "fetching":
        print(f"{p.phase or 'fetching'} {p.phase_pct or 0}%")
    else:
        print(f"{job.state} {p.pct}%")


async def main() -> None:
    async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
        probe = await vs.transcriptions.probe(URL)
        if not probe.ok:
            raise SystemExit(f"that link did not open: {probe.problem}")
        if probe.kind == "live":
            raise SystemExit("a live link: record it with a live transcription instead")

        tx = await vs.transcriptions.create(url=URL, name="From a link", idempotency_key=str(uuid.uuid4()))
        assert tx.job is not None

        job = await vs.jobs.wait(tx.job.recording_id, interval_s=3, on_progress=show)
        if job.state != "done":
            raise SystemExit(f"transcription {job.state}: {job.error}")

        snapshot = await vs.recordings.transcript(job.recording_id)
        print(" ".join(s.text for s in snapshot.sentences))


asyncio.run(main())

HTTP + WebSocket

link.sh

#!/usr/bin/env bash
# curl and jq, no SDK.
set -euo pipefail
API=https://api.vocalstack.com
AUTH="Authorization: Bearer $VOCALSTACK_API_KEY"
URL=${1:-https://raw.githubusercontent.com/openai/whisper/main/tests/jfk.flac}

curl -sSfG "$API/v1/transcriptions/probe" -H "$AUTH" --data-urlencode "url=$URL" | jq '{kind, ok, problem}'

REC=$(jq -n --arg url "$URL" '{url: $url, name: "From a link"}' \
  | curl -sSf "$API/v1/transcriptions" -H "$AUTH" -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" -d @- \
  | jq -r .job.recordingId)

while :; do
  JOB=$(curl -sSf "$API/v1/recordings/$REC/job" -H "$AUTH")
  STATE=$(jq -r .state <<<"$JOB")
  jq -r '"\(.state) \(.progress.phase // "") \(.progress.phasePct // .progress.pct)%"' <<<"$JOB"
  case $STATE in
    done) break ;;
    failed) jq -r .error <<<"$JOB" >&2; exit 1 ;;
  esac
  sleep 3
done

curl -sSf "$API/v1/recordings/$REC/transcript" -H "$AUTH" | jq -r '[.sentences[].text] | join(" ")'