DocsLive transcription

Live transcription

Stream microphone or tab audio to transcript1 over a WebSocket and get subtitle lines, with optional real-time translation, back as people speak.

Live transcription turns an audio stream into subtitle lines while people are still speaking. Your client sends raw PCM audio over a WebSocket, and transcript1 sends back lines that start as a partial guess and become final. Add translate_to and every line carries its translation too. When the session ends, the result is saved as a normal transcription.

There are no separate live plans: live transcription uses the same minute balance as everything else. See Billing for how connected time is counted.

When to use live

Use live whenUse a normal transcription when
The audio is still being produced: a call, a talk, a stream, a video you are watching.You have a link or a finished file.
You need subtitles within a second or two of the speech.You want the best accuracy and can wait for the result.
You want real-time translation next to each line.You want a translation of a finished transcript, or several languages.

Live takes audio only, as raw PCM. It does not accept video links, uploads, WebM, Opus or MP3. Capture the audio in your client and convert it to PCM first. Capture audio in a browser shows how.

Quickstart with the SDK

Install SDK 1.5.0 or newer: npm i @transcript1/sdk@latest. t1.live.start creates the session, connects and resolves once the session has started, so you can send audio right away.

import { createReadStream } from 'node:fs';
import { setTimeout as sleep } from 'node:timers/promises';
import { Transcript1 } from '@transcript1/sdk';
 
const t1 = new Transcript1(); // reads TRANSCRIPT1_API_KEY
 
const live = await t1.live.start({ language: 'en', translateTo: 'es', sampleRate: 16000 });
 
live.on('line', line => {
  const mark = line.final ? 'final  ' : 'partial';
  console.log(mark, line.text, '→', line.translation?.text ?? '');
});
live.on('warning', warning => console.warn(warning.code));
 
// speech.pcm: 16 kHz, mono, signed 16-bit little-endian, no header
for await (const chunk of createReadStream('./speech.pcm', { highWaterMark: 3200 })) {
  live.send(chunk);
  await sleep(100); // 3,200 bytes are 100 ms of audio: send at real-time speed
}
 
const result = await live.end();
console.log(result.reason, result.transcriptionId, result.minutesCharged);

Lines update in place. Replace a line you already have when one with the same id arrives, or read all of them from live.lines. end() finishes the session, waits for the last lines and saves the transcript. See Node.js SDK for every method and event.

Quickstart with a raw WebSocket

Without the SDK there are two steps: create a session over HTTPS, then open the WebSocket with the single-use token that came back.

Create a session

curl -X POST https://api.transcript1.com/v1/live/sessions \
  -H "Authorization: Bearer $TRANSCRIPT1_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"language": "en", "translate_to": "es", "sample_rate": 16000}'

The API answers 201:

{
  "id": "lv_8fK2Lm0PqRsTuVwXyZaBcD",
  "status": "waiting",
  "transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
  "websocket_url": "wss://api.transcript1.com/v1/live/sessions/lv_8fK2Lm0PqRsTuVwXyZaBcD/stream",
  "token": "Zk3x...",
  "token_expires_at": "2026-11-02T12:01:00Z",
  "rate": 1
}

transcription_id is null when save is false. The token is single-use and expires after 60 seconds, so connect right away. rate is the number of balance minutes charged per started minute of this session; see Billing.

The request body takes these fields. All are optional, and the scope is transcriptions:write:

FieldDefaultRule
languageautoauto, or a two-letter code that live transcription supports.
translate_tononeA two-letter code. One target language per session. See Translate live.
diarizefalseLabel speakers. Some setups cannot label speakers, for example together with language: "auto", and answer 422. Check capabilities.
audio_formatpcm_s16leThe only value. The field exists so formats can be added later.
sample_rate16000An integer from 8000 to 48000. Mono only.
savetruefalse keeps nothing after the session ends. See Saved transcripts.

Connect and stream

Open the WebSocket with the token as the second subprotocol. This works in browsers and in Node 22+ without custom headers. Wait for session.started before you send audio: binary frames sent earlier are dropped, and you get an audio_dropped warning.

import { createReadStream } from 'node:fs';
import { setTimeout as sleep } from 'node:timers/promises';
 
const session = await fetch('https://api.transcript1.com/v1/live/sessions', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.TRANSCRIPT1_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ language: 'en', translate_to: 'es', sample_rate: 16000 }),
}).then(response => response.json());
 
const ws = new WebSocket(session.websocket_url, ['t1-live', session.token]);
const lines = new Map();
 
ws.addEventListener('message', async message => {
  const event = JSON.parse(message.data);
 
  if (event.type === 'session.started') {
    for await (const chunk of createReadStream('./speech.pcm', { highWaterMark: 3200 })) {
      ws.send(chunk);
      await sleep(100);
    }
    ws.send(JSON.stringify({ type: 'end' }));
  } else if (event.type === 'line') {
    lines.set(event.line.id, event.line); // the full current state: replace by id
    console.log(event.line.final, event.line.text, event.line.translation?.text);
  } else if (event.type === 'session.ended') {
    console.log('ended:', event.reason, event.minutes_charged);
  }
});
 
ws.addEventListener('close', event => console.log('closed', event.code));

On Node 20 there is no built-in WebSocket: use the ws package, which has the same new WebSocket(url, protocols) form.

The server answers a valid token with the t1-live subprotocol and nothing else. A bad token never fails the handshake: the socket opens, you get an error event and the socket closes with a 4xxx code, so a browser shows the real reason instead of a generic 1006. See Close codes.

Capture audio in a browser

Live audio must be 16-bit signed little-endian mono PCM at the sample_rate of the session. Browsers give you floating-point samples, so convert them in an AudioWorklet. This snippet captures the microphone as 16 kHz PCM in 100 ms chunks and sends it with the browser SDK entry. getAccessToken returns an OAuth access token; never put an API key in a browser. See Security.

import { createLiveClient } from '@transcript1/sdk/live';
 
const workletSource = `
class Pcm16Processor extends AudioWorkletProcessor {
  constructor() {
    super();
    this.buffer = new Int16Array(1600); // 100 ms at 16 kHz
    this.filled = 0;
  }
 
  process(inputs) {
    const channel = inputs[0][0];
    if (!channel) return true;
    for (let i = 0; i < channel.length; i++) {
      const sample = Math.max(-1, Math.min(1, channel[i]));
      this.buffer[this.filled++] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
      if (this.filled === this.buffer.length) {
        this.port.postMessage(this.buffer.slice());
        this.filled = 0;
      }
    }
    return true;
  }
}
registerProcessor('pcm16', Pcm16Processor);
`;
 
const client = createLiveClient({ getAccessToken: () => myApp.getOAuthAccessToken() });
 
const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1 } });
const context = new AudioContext({ sampleRate: 16000 });
const moduleUrl = URL.createObjectURL(new Blob([workletSource], { type: 'text/javascript' }));
await context.audioWorklet.addModule(moduleUrl);
 
// the context's real rate: a browser that cannot do 16 kHz picks its own
const live = await client.start({ language: 'auto', sampleRate: context.sampleRate });
live.on('line', renderLine);
 
const microphone = context.createMediaStreamSource(stream);
const capture = new AudioWorkletNode(context, 'pcm16');
capture.port.onmessage = event => live.send(event.data); // an Int16Array
const mute = context.createGain();
mute.gain.value = 0;
microphone.connect(capture).connect(mute).connect(context.destination);
 
async function stop() {
  stream.getTracks().forEach(track => track.stop());
  await context.close();
  return live.end();
}

The worklet is written for 16 kHz. If the context runs at another rate, the chunk is no longer 100 ms, but the PCM stays correct because sampleRate matches the context. To capture the sound of a tab or a video instead of the microphone, create the MediaStream with getDisplayMedia or from a media element's captureStream() and connect it the same way.

Translate live

Add translate_to to the session and transcript1 translates every line as it is spoken.

  • One target language per session. Add more languages to the saved transcript afterwards with the translations endpoint.
  • Every line has an original (text, final) and a translation object (language, text, final). translation is null when translate_to is not set.
  • The original and the translation become final independently: a line can have a final original while its translation is still changing. Draw a line as final once both are.
  • A line that is already in the target language uses its own text as the translation, and it is final when the line closes.
  • A line closes when the speaker pauses, the speaker changes, a pause between words is longer than 0.9 seconds, the line reaches two subtitle rows (84 characters) or 15 seconds, or you send finalize. With translation on, a line can take about 3 seconds longer to close while its translation arrives. In that case the translation can stay open (final: false) and keep growing after the original is final.
  • Translation is free. It uses no minutes.

Which target languages and language pairs are available depends on the live speech provider. Read capabilities instead of hardcoding rules. An unsupported target returns 422 unsupported_translation. For example, a provider can require an explicit language instead of auto, or allow only pairs that include English.

WebSocket protocol

Connecting

new WebSocket(websocket_url, ['t1-live', token]);

The token travels as a WebSocket subprotocol. It never appears in a URL or an access log. Send no Authorization header: the token is the credential. The server pings every 30 seconds; browsers and the ws package answer automatically. A socket that sends no pong for 60 seconds is dropped.

A valid connection to a session that already has a socket replaces the old one. See Resume and takeover.

Client messages

MessageMeaning
Binary frameAudio: PCM s16le mono at the session's sample_rate. Each frame has an even length and is at most 256 KB. About 100 ms per frame is a good size.
{"type":"finalize"}Close the current line now, for example when the video is paused.
{"type":"end"}Finish the session and save it. The server answers session.ended and closes the socket.
Anything elseA non-fatal error event with the code invalid_message.

An odd-length or oversized frame is a protocol violation and closes the socket with 4008.

Start sending audio after session.started. A client that sends audio faster than real time is billed for the audio time instead of the connected time (see Billing), so send at the speed the audio is produced.

Server events

Every event is a JSON text frame with a type.

EventSent when
session.startedThe connection was accepted. Send audio from now on.
lineA subtitle line was created or changed.
usageAfter every charge.
warningSomething needs attention but the session continues.
errorA non-fatal problem, or the reason for an immediate close.
reconnectThe server is about to restart. Resume.
session.endedThe session is over and saved. The last event before a normal close.

session.started says whether this is a resume and how much audio the session has already processed:

{
  "type": "session.started",
  "session_id": "lv_8fK2Lm0PqRsTuVwXyZaBcD",
  "transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
  "resumed": false,
  "offset_ms": 0
}

line carries the full current state of one line, so replace the line you have with the same id. See The line object.

{
  "type": "line",
  "line": {
    "id": 12,
    "start_ms": 53120,
    "end_ms": 56040,
    "speaker": null,
    "language": "en",
    "text": "Welcome back to the stream",
    "final": true,
    "translation": { "language": "es", "text": "Bienvenidos de nuevo", "final": false }
  }
}

usage is sent after each charge, with the connected time, the audio time, the balance minutes charged so far and the minutes you have left:

{
  "type": "usage",
  "connected_ms": 61240,
  "audio_ms": 60100,
  "minutes_charged": 2,
  "minutes_remaining": 898
}

warning has a code:

codeMeaning
balance_lowThe next charge would fail. The session ends with balance_exhausted when it comes due. Not sent while auto top-up is on.
audio_droppedAudio arrived before the session was ready, or too much audio arrived while the speech provider connected. The oldest audio was dropped.
idle_timeout_soonNo audio for 4 minutes. The session ends after 5 minutes without audio. Send audio to keep it.
{ "type": "warning", "code": "idle_timeout_soon" }

error has a code and a message. These codes can arrive on a live socket:

codeFatalMeaning
invalid_messageNoA text frame that is not finalize or end.
invalid_tokenYesThe token is invalid, expired or already used. The socket closes with 4001.
not_foundYesThe session does not exist. The socket closes with 4004.
session_endedYesThe session has already ended. The socket closes with 4004.
{ "type": "error", "code": "invalid_message", "message": "Expected {\"type\":\"finalize\"} or {\"type\":\"end\"}" }

reconnect comes before the server closes the socket with 1012, for example during a deploy. Fetch a new token and resume.

{ "type": "reconnect", "reason": "server_restart" }

session.ended is the last event of every session. The numbers are the final totals:

{
  "type": "session.ended",
  "reason": "client_ended",
  "transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
  "minutes_charged": 3,
  "connected_ms": 142500,
  "audio_ms": 141900
}

The line object

FieldMeaning
idA 0-based number that grows by one for each line. It is the segment index in the saved transcript.
start_ms, end_msStart and end in session audio time. This time carries on across reconnects.
speakerThe speaker label when diarize is on, otherwise null. Labels are the same as in normal transcripts.
languageThe detected or given language of the line, or null when it is not known yet.
textThe original text so far.
finaltrue once the original text will no longer change.
translationnull without translate_to, otherwise language, text and final.

Show lines with final: false in a lighter style: their text can still change. The text of a line with final: true never changes again, and a final line is never removed.

The SDK uses camelCase for line fields: startMs, endMs.

End reasons

session.ended and GET /v1/live/sessions/:id give an end_reason:

ReasonMeaning
client_endedYou sent end.
balance_exhaustedThe next charge failed. The session ends on a minute boundary and is saved.
idle_timeoutNo audio for 5 minutes.
max_durationThe session was connected for 300 minutes. Start a new one.
disconnectedThe client was gone for more than 2 minutes without resuming.
upstream_errorThe speech provider failed.
internal_errorSomething went wrong on our side.
not_connectedThe session was never connected within 5 minutes. It is never charged and is visible only through GET.

Close codes

CodeMeaning
1000Normal close. It always follows session.ended.
1011Unexpected server error. The session is recovered by the server: resume with a new token.
1012Server restart. It follows reconnect: resume with a new token.
4001The token is invalid, expired or already used.
4004The session was not found or has already ended.
4008Protocol violation: an odd-length or oversized binary frame.
4010Replaced by a newer connection to the same session.

While the API restarts, a new WebSocket request can be answered with HTTP 503. Retry shortly.

Resume and takeover

A session survives a dropped network, an API deploy and a crash. For 2 minutes after a drop it waits for you; after that it ends as disconnected and is saved.

  1. Call POST /v1/live/sessions/:id/token. The response is token, token_expires_at and websocket_url, the same fields as when you created the session. It fails with 409 session_ended once the session has ended.
  2. Open a new WebSocket with that token.
  3. session.started arrives with resumed: true. offset_ms is the amount of audio the session has already processed: continue with the audio from that point. Line ids continue.

Lines that were already final are kept. The in-progress tail at the moment of the drop is lost, so replay a few seconds of audio from offset_ms if you buffer it. Reconnect gaps are not billed.

Takeover is the same call. If the server still thinks a socket is open, a new valid connection replaces it, and the old socket is closed with 4010 after its lines are saved. A resume is never blocked by the limit on simultaneous sessions.

The SDK does all of this for you: on a drop or a reconnect event it fetches a token and reconnects with a backoff of 0.5 to 8 seconds for up to 2 minutes, and replays the last 10 seconds of audio sent during the gap.

Billing

Live transcription draws from the same minute balance as batch transcriptions.

  • Connected time is billed, from the moment the socket is accepted until it closes. Silence and a paused video count. The time between a drop and a resume does not.
  • Per started minute, minimum 1. The first minute is charged when the first connection is accepted, and each following minute when it starts.
  • Faster than real time is billed as audio time. If the audio you send is longer than the connected time, the audio time is billed instead.
  • The rate is read from the API. One started minute costs rate balance minutes, rounded up. The rate is usually 1 and can have decimals. Read the live rate from rates.live in GET /v1/account, from rate in GET /v1/live/capabilities and from rate in the session you create. The rate of a session stays fixed once it is created.
  • Translation is free.
  • Never connected means not charged. A session that is not connected within 5 minutes ends as not_connected.
  • Failures are refunded. If a session ends with upstream_error or internal_error before it produced a single line, every charge is returned. An empty session caused by silence is charged and saved empty.
  • When the balance runs out and auto top-up is on, transcript1 charges your saved card while the session keeps streaming, and the session continues. If auto top-up is off or paused, or the charge fails, the session finalizes, saves and ends with balance_exhausted on a minute boundary. The balance_low warning comes first, unless auto top-up is on.

Creating a session returns 402 insufficient_minutes when your usable balance is lower than one started minute at the current rate. If auto top-up is on, transcript1 tries to top up first. See Billing and limits.

Limits

FreeMonthlyAnnual
Simultaneous live sessions123
  • A session counts while it is waiting, connected or disconnected (within its 2-minute window). Over the limit, POST /v1/live/sessions returns 429 too_many_live_sessions with Retry-After: 30.
  • Live sessions do not count toward your active job limit.
  • A session lasts at most 300 minutes of connected time. It then ends with max_duration.
  • A session with no audio for 5 minutes ends with idle_timeout; you get idle_timeout_soon after 4 minutes. A paused stream still bills connected time and counts as idle, so end the session if the pause is long.
  • transcript1 limits all live sessions together. When they are all in use, POST /v1/live/sessions returns 503 live_capacity, which is retryable. Wait for Retry-After (30 seconds) and try again. This is checked only when a session is created, never in the middle of one.
  • Creating sessions and fetching tokens count toward your requests per minute.
  • Audio is raw PCM only: pcm_s16le, mono, 8000 to 48000 Hz.

Capabilities

GET /v1/live/capabilities describes what live transcription supports right now, so clients never hardcode provider rules. The scope is transcriptions:read.

{
  "available": true,
  "auto_language": true,
  "languages": ["en", "es"],
  "translation": {
    "targets": ["es", "de"],
    "pairs": "any",
    "requires_language": false
  },
  "diarize": true,
  "audio_formats": ["pcm_s16le"],
  "sample_rate": { "min": 8000, "max": 48000 },
  "max_session_minutes": 300,
  "rate": 1
}

The lists are shortened here. Read them from the API.

  • translation.pairs is "any" (any detected language into any target), or a list of objects with from and to.
  • translation.requires_language is true when translation needs an explicit language instead of auto.
  • When live transcription is not configured, the response is available: false with only rate, and creating a session returns 503 live_unavailable.

With the SDK: await t1.live.capabilities().

Read a session

GET /v1/live/sessions/:id returns the state of a session. The scope is transcriptions:read.

curl https://api.transcript1.com/v1/live/sessions/lv_8fK2Lm0PqRsTuVwXyZaBcD \
  -H "Authorization: Bearer $TRANSCRIPT1_API_KEY"
{
  "id": "lv_8fK2Lm0PqRsTuVwXyZaBcD",
  "status": "ended",
  "end_reason": "client_ended",
  "transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
  "options": {
    "language": "en",
    "translate_to": "es",
    "diarize": false,
    "audio_format": "pcm_s16le",
    "sample_rate": 16000,
    "save": true
  },
  "connected_ms": 142500,
  "audio_ms": 141900,
  "minutes_charged": 3,
  "rate": 1,
  "created_at": "2026-11-02T11:58:12Z",
  "ended_at": "2026-11-02T12:00:35Z"
}

status is waiting, connected, disconnected or ended. end_reason is null until the session has ended. With the SDK: await t1.live.get(id).

Errors

Request errors use the usual error envelope. These are the ones that POST /v1/live/sessions and its siblings return:

HTTP statuscodeRetryableWhen
422invalid_requestNoA field is missing or invalid, for example a sample_rate outside 8000 to 48000.
422unsupported_languageNolanguage is not supported for live transcription.
422unsupported_translationNoThe target language, or the language pair, is not supported.
402insufficient_minutesNoYour usable balance is below one started minute at the current rate.
403email_not_verifiedNoVerify your email address first.
429too_many_live_sessionsYesYour plan's simultaneous live sessions are in use. Wait for Retry-After.
503live_capacityYesAll live capacity is in use. Wait for Retry-After (30 seconds).
503live_unavailableNoLive transcription is not available right now.
409session_endedNoPOST /v1/live/sessions/:id/token for a session that has ended.

Problems on the socket arrive as error events and close codes.

Saved transcripts

With save: true (the default), the session is saved as a normal transcription when it ends. The audio is never stored.

  • transcription_id is returned when you create the session. Its status is live while the session runs and completed after it ends. A session that never connected leaves a canceled transcription.
  • The transcription has source: { "platform": "live", "session_id": "lv_…" }. There is no link or file. The dashboard lists it as a live session.
  • Each line becomes one segment, with the same index as the line id. Read the transcript as JSON, text, SRT or VTT like any other transcription: see the API reference.
  • The live translation is saved with it. Read it with the translation parameter, as described in Translation.
  • Add more translation languages later with POST /v1/transcriptions/:id/translations once the transcription is completed. See Translation.

With save: false, nothing is kept after the session ends: there is no transcription and transcription_id is null. You still get every line over the socket and the usual charge applies.

Security

  • The token you get when you create a session is single-use and expires after 60 seconds. It is sent as a WebSocket subprotocol, not in the URL, so it stays out of access logs. A leaked token is useless once used or expired.
  • Use an API key only from a server you control. Never put an API key into a web page or an extension.
  • In a browser or an extension, sign users in with OAuth and pass the OAuth access token to getAccessToken of createLiveClient. The token needs the transcriptions:write scope to start sessions. OAuth access tokens expire after 1 hour, and every resume asks getAccessToken for a fresh one.
  • Your server can also create the session with its API key and hand only the websocket_url and token to the browser. The client then connects directly. It cannot resume without a new token from you.

Updated November 3, 2026