Skip to main content

Transcriptions (bridge-based)

The transcription proxy is a real-time transcription service that the Jitsi Videobridge (JVB) connects to over a WebSocket. The bridge forwards each participant's Opus audio to the service, which decodes it and relays it to a speech-to-text backend (OpenAI, Deepgram, Google Gemini or xAI). Transcription results are streamed back and injected into the conference.

The reference implementation is opus-transcriber-proxy.

This is the bridge-based transcription path (JVB → transcription service), as opposed to the older Jigasi-based transcriber. Jicofo decides when transcription is active and tells the bridge which URL to connect to.

Replaces Jigasi transcription

This bridge-based path replaces transcription via Jigasi. The Jigasi transcription support is deprecated and will be removed in a future release. New deployments should use the bridge-based path; existing Jigasi-transcription deployments should plan to migrate.

                                   ┌────────────────────────┐
participant audio ──Opus──► JVB │ ──WebSocket──► trans- │ ──► OpenAI / Deepgram /
│ cription service │ Gemini / xAI
transcription-result ◄─────────── │ ◄──
└────────────────────────┘

Four pieces must be configured:

  1. Transcription service — the proxy itself.
  2. Prosody — enable transcription on rooms (this guide includes a sample module that forces it on for all rooms).
  3. Jicofo — point the bridge at the service URL and (optionally) attach auth headers.
  4. config.js — enable transcription in the jitsi-meet client.

1. Running the transcription service

The service is a Node.js application. It is configured entirely through environment variables (no config file; a .env file is read if present). It listens on port 8080 by default and exposes a WebSocket endpoint at /transcribe.

At minimum you must set the API key for at least one backend and pick a provider priority. Example for OpenAI:

PROVIDERS_PRIORITY=openai
OPENAI_API_KEY=<your-key>
OPENAI_MODEL=gpt-4o-mini-transcribe
PORT=8080

Other backends (Deepgram, Google Gemini, xAI) are selected the same way. The full set of environment variables and per-provider options is documented in the project's README.md and BACKENDS.md — they are not reproduced here.

Custom OpenAI-compatible endpoints

The service can talk to any OpenAI-compatible transcription API instead of OpenAI itself — e.g. an in-house / self-hosted model exposing the OpenAI realtime transcription protocol. Enable the custom provider (ENABLE_OPENAI_CUSTOM_PROVIDER=true) and point the connection at your endpoint (per-connection provider=openai_custom + openaiCustomUrl=..., with the key in the X-Custom-Openai-Api-Key header). See the project's README.md / BACKENDS.md for the exact parameters.

As a standalone container

Prebuilt multi-arch images are published to Docker Hub as jitsi/opus-transcriber-proxy (latest, plus per-commit SHA tags). Just pull and run — passing configuration as environment variables:

docker run -d --name transcriber \
-p 9090:8080 \
-e PROVIDERS_PRIORITY=openai \
-e OPENAI_API_KEY=<your-key> \
-e OPENAI_MODEL=gpt-4o-mini-transcribe \
jitsi/opus-transcriber-proxy:latest

Or with an .env file:

docker run -d --name transcriber \
-p 9090:8080 \
--env-file .env \
jitsi/opus-transcriber-proxy:latest
Opus backend

The image ships both Opus backends and selects one at runtime via the OPUS_BACKEND environment variable — wasm (default) or native (the compiled libopus addon).

To build the image yourself instead of using the published one, see the build instructions in the project's README.md.

The bridge then connects to ws://<host>:9090/transcribe. Put it behind a TLS reverse proxy if the bridge reaches it over wss://.

Quick connectivity check:

wscat -c "ws://localhost:9090/transcribe?sessionId=test&sendBack=true"

In Cloudflare (CF)

The service can run as a Cloudflare Container fronted by a Worker (which handles the WebSocket upgrade and routing). Configuration lives in wrangler.jsonc / wrangler-container.jsonc; see CLOUDFLARE_DEPLOYMENT.md and CONTAINER_ROUTING.md in the repo for the full details.

# check out the libopus submodule (one-time; the image builds it)
git submodule update --init src/OpusDecoder/opus

# install worker deps and authenticate
cd worker
npm install
npx wrangler login

# set backend API keys as Worker secrets
npx wrangler secret put OPENAI_API_KEY --config ../wrangler-container.jsonc

# deploy (builds locally, then deploys)
cd ..
npm run cf:deploy

After deploy (allow a few minutes) the endpoint is the Worker URL, e.g.:

wss://<worker-name>.<ACCOUNT>.workers.dev/transcribe

Pick a routing mode via the ROUTING_MODE var in wrangler.jsonc. For Jitsi (many short sessions) pool is recommended — it keeps a set of warm containers and minimizes cold starts:

"vars": {
"ROUTING_MODE": "pool",
"CONTAINER_POOL_SIZE": "10"
}

Other modes: session (one container per session), shared (single container, dev only), autoscale.

Cloudflare Access (Zero Trust)

If you protect the Worker with Cloudflare Access, the bridge must present service-token headers (CF-Access-Client-Id / CF-Access-Client-Secret). Those are configured on the Jicofo side — see Cloudflare Zero Trust auth below.


2. Prosody configuration

Transcription is gated by per-room metadata stored by mod_room_metadata_component under room.jitsiMetadata. The relevant key is asyncTranscription — when true, transcription runs without depending on an active client requesting it. (It is also gated by the room's recording.isTranscribingEnabled; both must be true for Jicofo to start transcribing.)

asyncTranscription is a server-controlled key: clients are explicitly forbidden from setting it (it is in the blocked_metadata_keys list of mod_room_metadata_component.lua). So it must be set server-side.

Sample module: force asyncTranscription=true for all rooms

Create mod_force_async_transcription.lua and place it on the prosody plugin path (e.g. /usr/share/jitsi-meet/prosody-plugins/ or whatever your plugin_paths points to):

-- mod_force_async_transcription.lua
-- Forces asyncTranscription=true on every room's metadata.
-- Enable on the main MUC component (e.g. conference.<domain>).

local util = module:require 'util';
local is_healthcheck_room = util.is_healthcheck_room;

module:hook('muc-room-created', function(event)
local room = event.room;

if is_healthcheck_room(room.jid) then
return;
end

-- mod_room_metadata_component initializes this table at priority -1,
-- so run after it.
if not room.jitsiMetadata then
room.jitsiMetadata = {};
end

room.jitsiMetadata.asyncTranscription = true;

module:log('info', 'Forced asyncTranscription=true for room %s', room.jid);
end, -2); -- priority -2: after room_metadata_component (-1)

The -2 priority ensures the hook runs after mod_room_metadata_component (which runs at -1 and creates room.jitsiMetadata).

Enable it on the MUC component in prosody.cfg.lua:

Component "conference.example.com" "muc"
modules_enabled = {
-- ... existing modules ...
"muc_meeting_id";
"force_async_transcription";
}

Restart prosody afterward.

systemctl restart prosody
note

This only forces the asyncTranscription flag. For transcription to actually start, the room must also have transcription enabled in the recording metadata. To unconditionally transcribe every room you would also force that flag on — extend the module to set the recording metadata as well, or enable transcription through the normal UI/config path.


3. Jicofo configuration

Jicofo builds the transcriber WebSocket URL from a template and hands it (plus any HTTP headers) to the bridge over Colibri2. Configuration is under jicofo.transcription in jicofo.conf (HOCON).

KeyTypeDefaultPurpose
jicofo.transcription.url-templatestringWebSocket URL template for the transcription service. Supports {{MEETING_ID}} (required) and {{REGION}} (optional, bridge region).
jicofo.transcription.http-headersmap{}HTTP headers sent on the WebSocket connect.
jicofo.transcription.ping.enabledbooleantrueSend WebSocket pings to keep the connection alive.
jicofo.transcription.ping.intervalduration10 secondsInterval between pings.
jicofo.transcription.ping.timeoutduration3 secondsPong wait timeout.

The url-template placeholders are substituted at runtime: {{MEETING_ID}} from the conference, {{REGION}} from the transcribing bridge's region (empty string if the bridge has no region).

Basic configuration pointing at a standalone service:

jicofo {
transcription {
url-template = "ws://localhost:9090/transcribe?sessionId={{MEETING_ID}}&sendBack=true"

ping {
enabled = true
interval = 10 seconds
timeout = 3 seconds
}
}
}

Restart Jicofo to apply the new configuration:

systemctl restart jicofo

With a regionalized Cloudflare deployment:

jicofo.transcription.url-template =
"wss://{{REGION}}.<worker-name>.example.workers.dev/transcribe?sessionId={{MEETING_ID}}"

Cloudflare Zero Trust auth

If the service is behind Cloudflare Access, attach a service token via http-headers. These headers are sent on every WebSocket connect:

jicofo.transcription {
url-template = "wss://<worker-name>.example.workers.dev/transcribe?sessionId={{MEETING_ID}}"

http-headers {
"CF-Access-Client-Id" = "<service-token-client-id>.access"
"CF-Access-Client-Secret" = "<service-token-client-secret>"
}
}

http-headers is general-purpose — any header works (e.g. an Authorization: Bearer <token>). Per-conference headers and extra URL query params can also be supplied through the room_metadata component (transcription.httpHeaders / transcription.urlParams); those are merged over the base config, with the per-room values taking precedence.

note

The transcriber URL is fixed once a Colibri session is created — Jicofo rejects changing to a different URL mid-conference. If transcription is enabled but no url-template is configured, Jicofo logs "Transcription enabled, but no URL is configured" and does nothing.


4. Client configuration (config.js)

Transcription must also be enabled in the jitsi-meet client configuration. Add the following to your config.js:

transcription: {
enabled: true,
},