Guide · async

Transcribe a batch of files

Send many files or links as one batch, start it once, and read every transcript when it is done. Cheaper than one transcription per file.

A batch is several files transcribed together, for less than a transcription each: the batch counts once against the plan's transcriptions created per hour, and it is billed by the second of audio with one minimum charge for the whole batch, not one per file. Each file is still a transcription of its own (its batchId names the batch), with its job, transcript, audio and exports, and speaker labels when the batch asks for them. A batch's files never get a summary, a title, a share link or spoken translations.

Create the batch, add each file or link, start it, then wait for it to be done.

  1. Create the batch

    POST /v1/batches answers 201 with an open batch. The body is optional: a name (by default the batch is named for its date) and settings every file runs with, spokenLanguage (absent: detected per file), speakerLabels (off by default, auto or on) and latencyMode.

  2. Add the files

    POST /v1/batches/{id}/files takes one file or link per call, the way POST /v1/transcriptions does: multipart/form-data with a file part, or JSON with a url. Of the options, only name, startS, endS and durationS apply (the settings are the batch's). The answer is the file's transcription; its job waits for the batch's start. In the SDKs, adding a file takes either a file or a url, and adding a URL is the link shorthand (addFile / addUrl in TypeScript and PHP, add_file / add_url in Python).

    A batch holds up to the plan's files per batch (limits.filesPerBatch in GET /v1/billing/plans); one more is refused with invalid_state. Send the rest in another batch.

  3. Start it

    POST /v1/batches/{id}/start seals the batch (no more files) and queues its files, longest first: as many run at once as the plan allows transcriptions at once. The account needs credit for more than one minimum charge; each file's length is checked against the balance as it starts. A batch that was never started can be deleted with DELETE /v1/batches/{id} (nothing was charged); a started one is stopped with POST /v1/batches/{id}/stop.

  4. Wait for it

    GET /v1/batches/{id} answers with the batch, its counts (queued, running, done, failed, stopped) and its files, each a transcription with its job. The batch is running until every file has finished or failed, then done. The SDKs' batches.wait reads it every 5 s until the batch is closed (finishedAt: a stopped batch waits for a file it was still running); a failed file does not fail the wait, so check counts.failed.

  5. Read the transcripts

    Each file's transcript is GET /v1/recordings/{id}/transcript with its job.recordingId, as for a single file. GET /v1/batches/{id}/export returns every finished file's transcript in one format (srt, json, xlsx or pdf) as one zip.

TypeScript SDK

batch.ts

import { writeFile } from 'node:fs/promises';
import { VocalStack } from '@vocalstack/sdk';

const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY });

const batch = await vs.batches.create({ name: 'Interviews', settings: { spokenLanguage: 'en', speakerLabels: 'auto' } });

await vs.batches.addFile(batch.id, { file: './interview-1.mp3' });
await vs.batches.addFile(batch.id, { file: './interview-2.mp3' });
await vs.batches.addUrl(batch.id, 'https://raw.githubusercontent.com/openai/whisper/main/tests/jfk.flac', { name: 'JFK' });

await vs.batches.start(batch.id);

const done = await vs.batches.wait(batch.id, {
  onProgress: (b) => console.log(`${b.state}: ${b.counts.done} done, ${b.counts.running} running, ${b.counts.queued} queued`),
});
if (done.counts.failed > 0) console.log(`${done.counts.failed} file(s) failed`);

for (const tx of done.files ?? []) {
  if (tx.job?.state !== 'done') continue;
  const { sentences } = await vs.recordings.transcript(tx.job.recordingId);
  console.log(`${tx.name}: ${sentences.map((s) => s.text).join(' ')}`);
}
const zip = await vs.batches.export(batch.id, 'srt');
await writeFile('interviews.zip', Buffer.from(await zip.arrayBuffer()));

Python SDK

batch.py

import asyncio
import os

from vocalstack import VocalStack, models


def show(b: models.Batch) -> None:
    print(f"{b.state}: {b.counts.done} done, {b.counts.running} running, {b.counts.queued} queued")


async def main() -> None:
    async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
        batch = await vs.batches.create(name="Interviews", settings={"spokenLanguage": "en", "speakerLabels": "auto"})

        await vs.batches.add_file(batch.id, file="./interview-1.mp3")
        await vs.batches.add_file(batch.id, file="./interview-2.mp3")
        await vs.batches.add_url(batch.id, "https://raw.githubusercontent.com/openai/whisper/main/tests/jfk.flac", name="JFK")

        await vs.batches.start(batch.id)

        done = await vs.batches.wait(batch.id, on_progress=show)
        if done.counts.failed:
            print(f"{done.counts.failed} file(s) failed")

        for tx in done.files or []:
            if tx.job is None or tx.job.state != "done":
                continue
            snapshot = await vs.recordings.transcript(tx.job.recording_id)
            print(f"{tx.name}: " + " ".join(s.text for s in snapshot.sentences))
        with open("interviews.zip", "wb") as f:
            f.write(await vs.batches.export(batch.id, "srt"))


asyncio.run(main())

PHP SDK

batch.php

<?php
require 'vendor/autoload.php';

use VocalStack\VocalStack;

$vs = new VocalStack(apiKey: getenv('VOCALSTACK_API_KEY'), baseUrl: 'https://api.vocalstack.com');

$batch = $vs->batches->create(['name' => 'Interviews', 'settings' => ['spokenLanguage' => 'en', 'speakerLabels' => 'auto']]);

$vs->batches->addFile($batch['id'], file: './interview-1.mp3');
$vs->batches->addFile($batch['id'], file: './interview-2.mp3');
$vs->batches->addUrl($batch['id'], 'https://raw.githubusercontent.com/openai/whisper/main/tests/jfk.flac', ['name' => 'JFK']);

$vs->batches->start($batch['id']);

$done = $vs->batches->wait($batch['id'], onProgress: function (array $b): void {
    $c = $b['counts'];
    echo "{$b['state']}: {$c['done']} done, {$c['running']} running, {$c['queued']} queued\n";
});
if ($done['counts']['failed'] > 0) {
    echo "{$done['counts']['failed']} file(s) failed\n";
}

foreach ($done['files'] ?? [] as $tx) {
    if (($tx['job']['state'] ?? null) !== 'done') {
        continue;
    }
    $snapshot = $vs->recordings->transcript($tx['job']['recordingId']);
    echo $tx['name'], ': ', implode(' ', array_column($snapshot['sentences'], 'text')), "\n";
}
file_put_contents('interviews.zip', $vs->batches->export($batch['id'], 'srt'));  // the zip's bytes

HTTP + WebSocket

batch.sh

#!/usr/bin/env bash
# curl and jq, no SDK.
set -euo pipefail
API=https://api.vocalstack.com
AUTH="Authorization: Bearer $VOCALSTACK_API_KEY"

BATCH=$(curl -sSf "$API/v1/batches" -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"name":"Interviews","settings":{"spokenLanguage":"en","speakerLabels":"auto"}}' | jq -r .id)

curl -sSf "$API/v1/batches/$BATCH/files" -H "$AUTH" -F file=@interview-1.mp3 > /dev/null
curl -sSf "$API/v1/batches/$BATCH/files" -H "$AUTH" -F file=@interview-2.mp3 > /dev/null
curl -sSf "$API/v1/batches/$BATCH/files" -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"url":"https://raw.githubusercontent.com/openai/whisper/main/tests/jfk.flac","name":"JFK"}' > /dev/null

curl -sSf -X POST "$API/v1/batches/$BATCH/start" -H "$AUTH" | jq -r .state

while :; do
  B=$(curl -sSf "$API/v1/batches/$BATCH" -H "$AUTH")
  jq -r '"\(.state): \(.counts.done) done, \(.counts.running) running, \(.counts.queued) queued"' <<<"$B"
  case $(jq -r .state <<<"$B") in done|stopped) break ;; esac
  sleep 5
done

for REC in $(jq -r '.files[] | select(.job.state == "done") | .job.recordingId' <<<"$B"); do
  curl -sSf "$API/v1/recordings/$REC/transcript" -H "$AUTH" | jq -r '[.sentences[].text] | join(" ")'
done
curl -sSf "$API/v1/batches/$BATCH/export?format=srt" -H "$AUTH" -o interviews.zip