- Rust 99.5%
- Shell 0.3%
- Dockerfile 0.2%
|
Some checks failed
CI / test (push) Failing after 3m43s
Docker Build & Release / image (push) Successful in 10m33s
Docker Build & Release / image-arm64 (push) Successful in 28m23s
Docker Build & Release / manifest (push) Successful in 32s
Docker Build & Release / packages (push) Successful in 1m6s
The steal_release_repromotion 'allocator regression' was no such thing: the scenario's membership-only phantoms are exactly what the #25 stale-membership reaper exists to retract (an m.call.member with no LiveKit participant), and under the compressed 3s reap_grace it emptied Alice's and Bob's memberships ~4s after the subscriber connected — racing the steal→release→re-promotion choreography (~5-10s). Lose the race early and Bob is gone from overflow before Carol leaves (re-promotion timeout); lose it late and the cascading releases empty channel A under the final assert — both #124-era shapes, and the 07-19 'memberships vanish ~3s after join' observation that was attributed to homeserver 502s. The cross-channel re-promotion logic was innocent all along ('promoted overflow speaker into freed pool bot (cross-channel)' fires correctly the moment the phantoms survive long enough to need it). Fix: a per-scenario [timing]-override hook in the harness config generator (extra_timing, mirroring #99's extra_limits; explicit [timing] keys layer per-key on the NVB_TIMING preset by design) — the scenario now runs its bridge-under-test with reap_grace_ms=300000. The reaper's own behavior stays covered by reap_release under --profile real. Verified: 4/5 standalone passes (was 0/2 ×2 attempts); the single miss is the pre-existing Discord channel-hop propagation flake (bridge-side steal executed, gateway cache never showed the hop), which has its own quarantine/failover machinery and retry tolerance. Release 0.3.1: the #127 tombstone/recorder fixes ( |
||
|---|---|---|
| .forgejo/workflows | ||
| crates | ||
| docs | ||
| packaging | ||
| scripts | ||
| vendor/songbird | ||
| .dockerignore | ||
| .gitignore | ||
| appservice-registration.yaml | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| config.example.toml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| e2e.example.toml | ||
| LICENSE | ||
| README.md | ||
Nether Voice Bridge
A bidirectional voice bridge between Discord voice channels and Matrix (MatrixRTC / Element Call) room calls. Talk in Discord and be heard in a Matrix call — and vice-versa — with every speaker showing up as their own named participant on the far side.
A single Rust binary on a tokio runtime; one process can bridge many channel↔room pairs at once.
Warning
Heavy active development — pre-1.0. This project is vibe-coded and has not yet had a human code review. It works in day-to-day use on the author's homeserver, but expect rough edges, breaking changes, and bugs. Run it at your own risk, and please report what breaks. Come say hi / get help in the Matrix space
#nether-voicebridge:nether.imor on the Discord server.
Two ways to get a bridge
Use the hosted instance (recommended). We run a public instance on
nether.im, currently in open beta — free, self-serve, nothing to install:
DM @voicebridge_as:nether.im and link your own room in a few minutes.
Start with the wiki guide at
nether.wiki/matrix/voice-bridge,
or the in-repo hosted-instance notes and
self-serve walkthrough. It is a beta —
you may hit rough edges, and we're grateful to everyone testing it with us;
tell us what breaks in
#nether-voicebridge-help:nether.im.
Or self-host it. Everything below — requirements, quickstart, Docker, packages, configuration — is for running your own instance on your own homeserver. The same self-serve DM flow works on your instance too, once you enable open registration.
Community
Development happens in the open on both platforms — fittingly, kept in sync by a bridge (the mautrix-discord text bridge; this project handles the voice side). The Matrix development-space rooms are bridged to the matching channels on the Discord server. Same conversation on both sides; join from whichever platform you prefer.
Screenshots
Left: an Element Call with the bridged participants. Right: the same conversation in the Discord voice channel — each side shows the other's speakers as their own named participants.
What it does
- Both directions, fully attributed. Matrix speakers appear in Discord as individually-controllable puppet bots (named after the Matrix user, mutable per-user). Discord speakers appear in the Matrix call as appservice ghosts (named + avatared after the Discord user).
- Mute carries across. Mute state is mirrored both ways — mute yourself in the Matrix call and your Discord puppet goes muted too, and a muted Discord speaker shows muted in Element Call.
- See who's waiting. Discord users show up in the Matrix call as ghosts even when no Matrix user is in the call (membership only, no media) — so Matrix users can tell at a glance whether a room is worth joining. The moment a Matrix user joins, every present ghost goes live (audio + E2EE keys) before anyone even speaks; no bot sits idle in a Discord voice channel just to show presence.
- Multiple bridges, one process. Each
[[bridge]]pairs one Discord channel with one Matrix room; add more blocks to bridge more pairs. The Discord puppet bots are a single shared pool, allocated across channels on demand. - Real Discord E2EE. The bots are legitimate DAVE (MLS) participants, so audio decrypts correctly — no non-E2EE fallback.
- Optional Matrix media E2EE. For end-to-end-encrypted Matrix rooms, the
bridge derives Element Call's HKDF frame keys and (with
key_transport = "olm") delivers per-ghost keys via Olm — true E2EE the homeserver can't read. - On-demand lifecycle. A bridge opens when the first human appears on either side and tears down to baseline when the last human leaves both.
- Self-healing call roster. On homeservers without
MSC4140 delayed
events, a client that crashes or fails to connect can leave a "phantom"
m.call.memberthat never expires. The bridge spots call members with no live media after a grace period and clears them, so the roster doesn't fill with participants who aren't really there.
Future directions
The original north star — hosted, mautrix-style bridging, where a server
owner invites the shared bot pool and configures everything over DM commands
without installing anything — has shipped: runtime provisioning with tenant
state that survives restarts (#4),
the per-(bot, guild) shared puppet pool (#46),
lean send-only ghost crypto (#44),
the management bot with
two-sided link auth,
open registration, and the
abuse controls (#43,
#47). It's live as the
hosted instance, currently in open beta.
What genuinely remains:
- Clean up and review. An independent human code review / security audit — especially of the key-handling and echo-exclusion paths — plus a simplification pass, before picking the next big thing. (#41)
- Per-tenant process isolation. Tenants currently share one process; a crashing bridge shouldn't be able to take its neighbors down. Fine for the beta, a real decision at scale.
Further out
- Video relay (webcam / screen share). Carrying video — and screen/app share with its audio — between Discord and Element Call, not just voice. This requires driving Discord with self-bots (ToS violation) to relay video streams. (#8)
- Soundboard support. Bridging Discord's soundboard clips (and/or a Matrix-side equivalent) through the audio core.
- Interop with mautrix-discord ghosts. Where a room already runs the mautrix-discord text/chat bridge, exploring whether voice ghosts could align with or reuse its existing Discord-user ghosts, rather than presenting a second, separate set of puppets/ghosts. (#12)
The live roadmap board is at the project's Projects tab. If any of this matters to you, say so in the community room, on Discord, or on the issue tracker (below) — interest is what moves an idea up the list.
How it works
Matrix speakers → Discord runs through a shared puppet pool (one floating "anchor" bot captures the channel; extra bots become dedicated per-speaker puppets). Discord speakers → Matrix runs through an appservice ghost per speaker, each its own LiveKit participant. A shared, SDK-decoupled audio core handles the two clocks (Discord 20 ms / LiveKit 10 ms), reframing, mixing, and per-stream jitter buffering. Echo/loop prevention excludes all bridge-owned identities from capture on both sides.
The full design and the phased build history live in:
docs/nether-voicebridge-architecture.md— architecture & rationaledocs/nether-voicebridge-build-plan.md— phase-by-phase build plandocs/docker-usage.md— running under Docker + CI notesdocs/packaging.md—.deb/.rpmpackages + systemd service
Requirements
Don't want to self-host? See Use the hosted instance to bridge your channel on the project's shared deployment instead.
- A Matrix homeserver with MatrixRTC / Element Call working (lk-jwt-service + a LiveKit SFU). The bridge is just another call participant — it does not deploy or manage the RTC infrastructure.
- One or more Discord bot applications (the puppet pool), each invited to your
guild(s) with Connect, Speak, Use Voice Activity, Change Nickname. See
docs/discord-bot-setup.mdfor a full walkthrough. - An appservice registration on the homeserver for the bridge bot and the
Matrix-side ghosts (see
appservice-registration.yaml). Its users-namespace regex stem must equal the bridge'ssender_localpart, covering both the@{stem}_botbot and the@{stem}_<id>ghosts. - Per bridged room: the Matrix room must allow power level 0 for the
org.matrix.msc3401.call.memberstate event (the standard Element Call setup), or Discord→Matrix ghosts can't post their membership. See the architecture doc. - Per bridged room (non-public only): on the appservice-auth path the bot
joins as a full member via the appservice, so the room needs no
guest_access— but a restricted or invite-only room still requires the bot user to be invited (or a member of the room's allow-space) first.
Quickstart (from source)
cp config.example.toml config.toml # then fill in your tokens/IDs
cargo run # reads ./config.toml (override with CONFIG_PATH)
config.example.toml documents every field. Never commit config.toml — it
holds bot tokens and Matrix appservice tokens.
Docker
cp config.example.toml config.toml # fill in - or set environment variables
docker compose up -d
docker compose logs -f
Environment Variables
| Variable | Required | Default | Notes |
|---|---|---|---|
NETHER_PUPPET_TOKENS |
✓ | Comma-separated Discord bot tokens | |
NETHER_DISCORD_GUILD_ID |
✓ | ||
NETHER_DISCORD_CHANNEL_ID |
✓ | ||
NETHER_MATRIX_HOMESERVER_URL |
✓ | ||
NETHER_MATRIX_ROOM_ID |
✓ | Internal room ID (!abc:nether.im) |
|
NETHER_MATRIX_BOT_USER_ID |
one of¹ | Full bot MXID | |
NETHER_MATRIX_SENDER_LOCALPART |
one of¹ | Bot localpart; derives MXID from homeserver domain | |
NETHER_MATRIX_DEVICE_ID |
(auto-derived) | Derived from the bridge name when unset | |
NETHER_MATRIX_APPSERVICE_ID |
✓ | ||
NETHER_MATRIX_APPSERVICE_TOKEN |
✓ | ||
NETHER_PUPPET_NICKNAME_PREFIX |
[Matrix] |
Set to empty string to disable | |
NETHER_BRIDGE_NAME |
bridge-0 |
Label used in logs; also the store-path leaf | |
NETHER_DISCORD_CAPTURE_WAV |
false |
Debug only — writes unbounded WAV files | |
NETHER_MATRIX_STORE_PATH |
(auto-derived) | Derived as /app/data/<bridge-name> on the volume; rarely set |
|
NETHER_MATRIX_BOT_DISPLAY_NAME |
|||
NETHER_MATRIX_BOT_AVATAR_PATH |
Path inside the container | ||
NETHER_LIVEKIT_MEDIA_E2EE |
(auto-detected) | Detected from the room's m.room.encryption; set true/false only to force |
|
NETHER_LIVEKIT_KEY_TRANSPORT |
(auto-selected) | olm for encrypted rooms, plaintext otherwise; set only to force |
|
NETHER_LIVEKIT_LK_JWT_URL |
(auto-derived) | Override lk-jwt-service endpoint | |
NETHER_LIVEKIT_URL |
(auto-derived) | Override LiveKit SFU WebSocket URL |
¹ At least one of BOT_USER_ID or SENDER_LOCALPART is required. When only
SENDER_LOCALPART is set, the bot MXID is derived as
@{localpart}_bot:{homeserver} (the _bot suffix avoids the appservice's bare
sender user, which Synapse blocks from sync). SENDER_LOCALPART is also the stem
of the ghost MXIDs (@{localpart}_<id>), so it must equal the stem of your
appservice users-namespace regex — set the regex to @{localpart}_.* so it covers
both the _bot user and the ghosts. Set BOT_USER_ID explicitly only to override
the derived bot (it must still be in that namespace, or be the registration's own
sender user).
The matrix-sdk store persists on a named volume; store paths are auto-derived
under /app/data so no store_path config is needed. See
docs/docker-usage.md for the full guide, multi-arch
builds, and CI/runner notes.
Install (Debian/Ubuntu & Fedora/RHEL)
Pre-built .deb and .rpm packages are attached to each
release. They install the
binary to /usr/bin, a systemd unit, and an example config, and create a dedicated
nether-voicebridge system user.
# Debian / Ubuntu
sudo apt install ./nether-voicebridge_<version>_amd64.deb
# Fedora / RHEL
sudo dnf install ./nether-voicebridge-<version>-1.x86_64.rpm
Then configure and start it:
# Create the live config from the shipped example and fill in your tokens/IDs.
sudo cp /etc/nether-voicebridge/config.example.toml /etc/nether-voicebridge/config.toml
sudo chown root:nether-voicebridge /etc/nether-voicebridge/config.toml
sudo chmod 640 /etc/nether-voicebridge/config.toml # holds bot/appservice secrets
sudoedit /etc/nether-voicebridge/config.toml
sudo systemctl enable --now nether-voicebridge
journalctl -u nether-voicebridge -f
The service reads /etc/nether-voicebridge/config.toml and stores matrix-sdk data
(Olm crypto + state) under /var/lib/nether-voicebridge. See
docs/packaging.md for the package layout and how to build
the packages yourself (packaging/build-packages.sh).
Standalone binary (other distros)
For distros without .deb/.rpm (Arch, Gentoo, NixOS, …), each
release also ships a
self-contained tarball — the binary plus config.example.toml, the systemd unit,
LICENSE, and this README — alongside a SHA256SUMS file:
ver=<version> # e.g. 0.2.0
arch=x86_64 # or aarch64
base=https://nether.codes/dark/nether-voicebridge/releases/download/v$ver
curl -LO $base/nether-voicebridge-$ver-$arch-linux.tar.gz
curl -LO $base/SHA256SUMS
sha256sum -c SHA256SUMS --ignore-missing # verify
tar xzf nether-voicebridge-$ver-$arch-linux.tar.gz
The binary is built against glibc 2.36 (dynamically linked to libopus), so it needs your distro's
libopus+ca-certificatesand a glibc host — it won't run on Alpine/musl or much older glibc. There, prefer the Docker image, which carries its own runtime.
Configuration notes
store_pathis optional — omitted, it auto-derives to<data_dir>/<bridge>(data_dirdefaults to./nether-store; the Docker image setsNVB_DATA_DIR=/app/data). Each bridge gets a distinct store.sender_localpartis the appservice namespace stem — it derives the bot (@{stem}_bot) and the ghosts (@{stem}_<id>), so it must equal the stem of your registration'snamespaces.usersregex (defaultnether-voicebridge). Set an explicitbot_user_idonly to override the derived bot. Upgrading an existing deployment needs no appservice re-registration unless you change this stem — see the CHANGELOG "Upgrading" note.- LiveKit endpoints are auto-derived from the homeserver's
.well-knownrtc_fociand the lk-jwt response — not configured by hand. media_e2eeis auto-detected per bridge from the room'sm.room.encryptionstate (encrypted ⇒ on with per-ghost Olm, unencrypted ⇒ off); it's an optional override now — set it only to force a value.key_transportlikewise auto-selects (olmfor an encrypted room, else plaintext).
Runtime bridges & the management bot
Bridges don't have to be static config. Two ways to add/remove them at runtime:
tenants.toml(underdata_dir): the same[[bridge]]schema (explicitnamerequired,server = "…"references the static config's profiles). Reload with SIGHUP (systemctl reload nether-voicebridge); the running set is reconciled — additions start, removals stop cleanly, changed entries restart. Pre-flight edits withnether-voicebridge --check-tenants.- The management bot (
[management]in config.toml): allowlisted operators DM the bridge's bot user on Matrix and drive the same mechanism with commands —link <name> <room-id> <guild-id> <channel-id>,unlink <name>,list,ban/unban/bans,report/reports(see Abuse controls),help. The bot writestenants.tomland triggers the reconcile in-process, rolling the file back if validation rejects it, so bot edits and hand edits never conflict. It only accepts room invites from allowlisted MXIDs. With[management]set, a config with zero[[bridge]]blocks is valid — bridges can be created entirely over DM. Requires at least one[server.<name>]profile; seeconfig.example.toml.
The usual room prerequisites apply to every bridge however it's created: the
Matrix room must allow PL 0 for org.matrix.msc3401.call.member, restricted
rooms need the bot invited, and the puppet bots must be invited to the Discord
guild. The management bot's link verifies all of this up front — it joins the
room, reads the raw power levels, and confirms MatrixRTC discovery before
writing anything, replying with a specific fix if a prerequisite is missing
(puppet-bot guild membership is a warning only, since bots learn their guilds
as they connect). Note the bot must also be able to speak in the room you DM
it from: in a room whose power levels block PL-0 message sends it stays mute.
Two-sided link auth
By default an allowlisted operator may link any joinable room to any channel
the puppet bots can reach. For a hosted/multi-tenant deployment, two optional
[management] knobs make a link require consent from both sides:
require_room_authority = true— the person runninglinkmust be a joined member of the target Matrix room with an explicit individual moderator grant: ausers[them]power-level entry above the room'susers_defaultand at least 50 (the Element "Moderator" level), not just an allowlisted operator. Keying on an explicit above-default grant (rather thanstate_default) means a room can't authorize every member by loweringstate_defaultor raisingusers_default(#86).require_discord_approval = true— alinkthat passes every probe is held pending (nothing written) and answered with a one-time code. Someone with the Manage Server permission in the target Discord guild must run/bridge approve code:<code>withinapproval_timeout_secs(default 600 s); only then is the bridge created, and both sides are notified. The slash command is registered by the puppet bot atapproval_bot_index(default 0, the first[puppets]token) — (re-)invite that bot's application with theapplications.commandsOAuth scope (in addition tobot) or the command will not appear. Separate the scopes with%20, not+(&scope=bot%20applications.commands— the+form can silently drop the second scope). Discord hides the command from members without Manage Server, and the bridge re-checks the invoker's permission server-side.
Open registration
With both two-sided knobs on, open_registration = true turns the management
bot into a self-serve service: any Matrix user (not just the allow list) can
DM the bot — direct invites only, so no one can pull it into arbitrary rooms —
and use a scoped command set: link (room authority + Discord approval are
mandatory), unlink and list limited to bridges they created, report (flag
an abusive bridge — see Abuse controls), and help.
Operator commands (ban/unban/bans, reports, unscoped unlink/list) remain
allowlist-only, deny-listed users are refused at the door, and
[limits] max_tenants_per_owner caps how many bridges one user may own
(pending approvals count). Config validation refuses open_registration
without both require_room_authority and require_discord_approval — without
those, any Matrix user could bridge rooms and guilds they don't control.
Invite-only middle tier (#82). To run a closed beta rather than a fully
public instance, set [management] self_serve_allow — then only those MXIDs
(plus operators) get the self-serve tier; everyone else is ignored. The config
value seeds <data_dir>/self_serve_allow.toml once; thereafter operators edit
it live over DM with serve-allow <@mxid> / serve-remove <@mxid> (and
serve-open to reopen to everyone, serve-list to show it). Unset ⇒ open to
every Matrix user.
For a hosted instance, set privacy_policy_url — it is appended to the link
and approval disclosure texts, and the /bridge approve outcome is posted
publicly in the channel where it was run, so every bridged server has a
visible record of the link and its approver. A hostable policy template lives
at docs/privacy-policy.md, and docs/nether-voicebridge-verification-runbook.md
covers the 100-server App Verification gate. Point end users at
docs/self-serve-bridging.md — the step-by-step walkthrough of the DM link
flow and the approval code, written for the people doing the linking — and set
docs_url to wherever you host that walkthrough: it is appended to the bot's
DM greeting, help, and the errors the guide explains, so someone who
discovers the bot cold can find their way to the instructions (#92). An
optional instance_notice string is likewise appended to the greeting and
help — the place for an open-beta disclaimer and your help-room address
(#94).
Operator-only observability and outreach commands (#94, see the
operations runbook): status renders the puppet
pool's per-bot guild footprint against Discord's 100-server verification gate;
stats shows per-bridge uptime/owner and process memory; msg <@mxid> <text>
DMs one user as the bot; announce <text> + announce confirm mass-DMs every
bridge owner (sparingly — it's for service notices).
Abuse controls
Voice content is unmoderatable by design (never recorded or transcribed), so the bridge's abuse tooling is reactive: a durable audit trail plus a report path that feeds the deny-list.
- Audit trail — every lifecycle-security event (
link,approve,unlink,ban/unban,report, and invite accept/reject decisions) is appended as one JSON line to<data_dir>/audit.jsonl, recording who, which guild/room, when, and — for approvals — the approver. An operator can review it over DM without host access withreports [count], which renders the most recent reports each next to the exactbancommand to run. report <owner|guild|room> <value> [reason]— available to self-serve users too (the people most likely to be harmed), this files an abuse report to the audit trail. It is deliberately advisory: a report never removes a bridge on its own (auto-acting on untrusted input would itself be an abuse vector) — an operator reviews it and decides whether toban. Reports are rate-limited (shared with themgmt_cooldown_secsbudget) so they can't flood the trail, and free-text reasons are length-capped.ban <owner|guild|room> <value>— operator-only; deny-lists the target and tears down every matching tenant through the normal reconcile path. Banned owners lose self-serve access entirely; the deny-list is re-checked at approval time so a ban that lands while a link is pending still blocks it.
For the operator's side of the story — proactive monitoring of the audit trail and egress, backups, the moderation recipes, and the periodic check-in ritual — see the operations runbook.
Community & contributing
- Hosted-instance help:
#nether-voicebridge-help:nether.im— using the nether.im bridge, beta feedback, "something broke". - Development space:
#nether-voicebridge:nether.im— where the work happens. - Issues and roadmap: the project's Forgejo repo at nether.codes/dark/nether-voicebridge — see the roadmap board.
License
Licensed under the GNU Affero General Public License v3.0 or later
(AGPL-3.0-or-later). See LICENSE for the full text.
