Using Speech Revolutions with Supabase

If your users upload audio to Supabase Storage, you can transcribe it without downloading a byte: create a signed URL for the object, hand it to Speech Revolutions, and write the transcript into a Postgres table. Optionally stream progress to the browser over Supabase Realtime. All Speech Revolutions and service-role keys stay server-side.

Use the service role key on the server only

Signed-URL creation and privileged table writes use the Supabase service role key — keep it (and SPEECHREVOLUTIONS_API_KEY) on your server. The browser only ever uses the anon key and reads rows through Row Level Security.

The table

A row per job: the storage path, a status, a 0–100 progress number, and the transcript text once it lands.

schema.sql
create table transcriptions (
  id          uuid primary key default gen_random_uuid(),
  storage_path text not null,
  job_id       text,
  status       text not null default 'processing', -- processing|completed|failed
  percent      real not null default 0,            -- overall 0-100
  text         text,
  created_at   timestamptz not null default now()
);

-- so the browser can subscribe to its own rows over Realtime
alter publication supabase_realtime add table transcriptions;

Transcribe a file in Supabase Storage

Create a signed URL for the uploaded object (valid long enough to outlast transcription) and pass it to transcribe() — the SDK auto-detects the URL and streams the audio directly from Supabase. Then upsert the transcript into the table.

import { createClient } from "@supabase/supabase-js";
import { SpeechRevolutions } from "speechrevolutions";

// Server-side: service role key, never shipped to the browser.
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
const stt = new SpeechRevolutions(); // SPEECHREVOLUTIONS_API_KEY

export async function transcribeFromStorage(id: string, storagePath: string) {
  // 1. Signed URL so Speech Revolutions can read the private object.
  const { data, error } = await supabase.storage
    .from("audio")
    .createSignedUrl(storagePath, 3600); // seconds — outlast the transcription
  if (error) throw error;

  // 2. Pass the URL straight to Speech Revolutions (auto-detected as a URL).
  const result = await stt.transcribe(data.signedUrl, {
    speakerLabels: true,
    onProgress: (e) =>
      supabase
        .from("transcriptions")
        .update({ percent: e.percent ?? 0 })
        .eq("id", id), // drives Realtime updates (see below)
  });

  // 3. Store the transcript in Postgres.
  await supabase
    .from("transcriptions")
    .update({ status: "completed", percent: 100, text: result.text })
    .eq("id", id);
}

Optional: live progress over Realtime

Because the on_progress/onProgress callback writes percent back to the row, the browser can subscribe to that row over Supabase Realtime and update a progress bar with no polling. The percentage comes straight from the SDK callback.

progress-subscription.ts
// Browser: anon key + RLS. No Speech Revolutions key here.
const channel = supabase
  .channel("job")
  .on(
    "postgres_changes",
    {
      event: "UPDATE",
      schema: "public",
      table: "transcriptions",
      filter: `id=eq.${rowId}`,
    },
    (payload) => {
      const { percent, status, text } = payload.new;
      setPercent(percent); // 0-100 from the SDK callback
      if (status === "completed") setText(text);
    },
  )
  .subscribe();

Longer files

For long recordings, use submit() with a callback_url instead of blocking on transcribe(), and update the row from a signed webhook handler (verify X-SR-Signature). The Next.js and FastAPI guides show complete receivers, and Live progress for web apps covers the callback-to-bar weighting.