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.
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.
// 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.