DisDubs
A group dubbing game that runs inside Discord. Pick a scene, hand out the parts, and the whole voice channel performs it together while the script scrolls. Built on a platform that won't give it a microphone.
disdubs.comThe obvious design is that the Activity records your microphone. It can't. Everything interesting about this project came out of working around that.
A Discord Activity is a web app in a sandboxed iframe, and that iframe
has no microphone permission.
getUserMedia is rejected outright. WebRTC and
WebTransport are unsupported too. The Social SDK doesn't rescue it
either, since it ships inside a game you distribute yourself and its
voice support stops at mute and volume.
So the recording happens somewhere else. A bot sits in the voice channel and reads the audio Discord is already sending it.
Where it stands, one month after launch
- 6,131 Servers have opened it first month after launch
- 241,502 Members reached summed at last connect
- 4,322 Servers finished a dub 71% of everyone who opened it
- 100+ Paying subscriptions across two tiers
Sixteen rooms are live as this was written, one of them encoding. The box is 60% empty, with 235.8 GB free of 393.2 GB.
How a dub gets made
Six screens, in the order a room moves through them. Some are captured in the Steam build, which is the same app under a different name.
-
01Pick a scene
The public shelf, plus whatever the server has uploaded itself.
-
02Hand out parts
Every character goes to somebody, or to nobody and stays original.
-
03Slate
A clapper, a count of three, and the take number everyone is on.
-
04Roll
The clip plays, the script scrolls, each line lights up in turn.
-
05Fix a line
One flubbed line costs one line. Redo it against the original.
-
06Premiere
Mix down, then watch it back together before anyone leaves.
The bot is the tape deck
A bot in a voice channel can use the @discordjs/voice
receiver, which hands out a separate Opus stream per speaker. It's the
same mechanism Craig has used for multi-track recording for years. One
process runs the HTTP server, the websocket and the bot together, so the
room state machine and the recorder share memory instead of a queue.
Discord client
├── Activity iframe ─── the game: casting, playback, prompter, review
│ │ websocket (the UI, and nothing else)
└── voice channel
│
└── bot ─────── per-speaker Opus → timeline-aligned PCM tracks
│
server: slice per line → ffmpeg mixdown → mp4 The workaround turned out better than the thing it replaced.
- Isolated tracks. One person laughing doesn't ruin somebody else's line, and a retake touches one actor's audio.
- No setup. Nobody grants a permission or picks a device. If the channel can hear you, you're recorded.
- It works on phones. Desktop, mobile and the web client all record identically, because none of them are doing the recording.
The honest trade-off: voice receive is not a documented Discord API. It works, and has for years, but it is not something Discord promises to keep.
Putting speech back where it was spoken
Discord only sends voice packets while somebody is talking. Record that naively and three lines spoken a minute apart come back stuck together, with every pause gone. Each performer pauses differently, so the three tracks stop describing the same minute.
AlignedPcmWriter timestamps every decoded chunk
as it arrives and pads silence to put it back where it was said. Byte
offset N then means the same instant for every performer, so cutting one
line out of one track is just a read at a known offset.
Gaps under about 60 milliseconds are treated as network wobble and written straight through, so a dropped packet doesn't splice silence into the middle of a sentence. Everything downstream is simple because of that one property, which is why it has more tests behind it than anything else in the codebase.
Recording without a shared playhead
When everyone performs at once, they're all at the same point of the clip at the same moment, so one offset converts between tape position and clip time. The other mode has no such thing. One person rehearses the opening while another redoes the last line, each over their own continuous strip of tape.
PlayheadLog closes that gap. Clients report
their position on every play, pause and seek, plus a periodic tick, and
the server keeps a list of runs per performer: from this instant they were
here in the clip, moving forward in real time. Finding a line
means searching back through those runs, newest first, which is why
redoing a line replaces the earlier attempt without anything having to
decide that it should.
A run that only covers part of a line gets refused rather than trimmed. Half a line mixes down as a clipped word and sounds like a bug. No line at all shows up as missing, and the take sheet can tell you about that.
One bot has one voice
A bot account has exactly one voice state per server. That's not a library limit. A bot is a user, and a user is in one voice channel per server. So a server records one channel at a time, and a second room has to be turned away. Getting that refusal right turned a silent mid-take theft of the recorder into a message naming the channel already in use.
Running two rooms at once means running two bot accounts, and the tempting
move is to turn bot into a pool and fix every
call site. Counting them first said otherwise.
Directory calls
The server's name, whether the bot is in it, whether it's ready. Any bot in the server answers these the same way. Re-pointing seventeen log lines at a specific account is churn that can only add bugs.
Voice calls
Join, leave, snapshot, deafen. Only these care which account answers, so only these moved. The original bot stays exactly as it was and keeps being the directory.
The trap that costs a day: @discordjs/voice keys
its connection registry by group and server, and the default group is the
string default. Two accounts joining the same
server write the same key, and the second silently overwrites the first.
Every call passes the bot's own user id as the group instead. Without it
nothing crashes. The wrong bot just walks out of the room mid-take.
What the box does between takes
Rooms live in memory, and the settled part of one snapshots to disk, so a redeploy mid-session puts people back where they were instead of dropping the take. ffmpeg runs in a fixed number of lanes, because the thing that falls over under load is encoding, not the websocket.
The retention rules are the ones worth being careful about, because they're printed on a privacy page people read before trusting the app with their voice. Raw takes are gone in two hours and finished dubs in six. Saying so is easy. The part that matters is the job that does it and the disk graph that shows it has been.
Where people fall out
Every row below the first is measured by the app itself, not estimated.
- Installed Discord’s own count6,080 —
- Opened it a server appeared in a hello6,131 —
- …with the bot in the bot was already in the server5,027 82%
- …recorded a take produced at least one track4,480 89%
- …finished a dub a render landed4,322 96%
More servers have opened it than Discord says have installed it, which looks like a bug and isn't one. Discord's number is approximate and the app's is exact, so the two disagree at the edges. Member reach is labelled at last connect for the same reason. It's a sum taken when each server last handshaked, and it drifts until the next restart. A number that can't be live shouldn't be dressed up as live.
Read from the second row, not the first. An install nobody ever opened isn't a person lost at the bot step, and counting it as one makes the invite look worse than it is while hiding how bad it is among people who actually tried. Read that way, the one real drop is the third row. Nearly a fifth of servers that open DisDubs don't have the bot in them yet, and nothing can be recorded until they do. That makes the invite, not the recording, the piece most worth working on.
Selling compute, not the catalogue
One rule got written down before there was any money, so that a later idea would have to argue with it. Sell hosting and compute, never access to the catalogue. The packs are other people's work and the clips are third-party footage. A subscription pitched as "pay to unlock the scenes" turns a free fan tool into a commercial access gate, which is the worst possible framing if a rights-holder ever looks.
So the catalogue is free, permanently. There's a second reason for that which has nothing to do with licensing. A server that just installed lands in the scene picker, and if it lands on four scenes and a wall of padlocks, the app has spent its only first impression asking for money.
The two paid tiers sell upload slots, longer scenes, a deeper place in the encoding queue, thirty-day storage instead of six hours, and a second recorder bot so two rooms can run at once. Uploads stay private to the server they came from. Making one public means asking, a person looking at it, and it either getting published or declined. Whoever made a pack keeps their credit on the scene.
One codebase, three ways in
The same server and client run in three places. Inside Discord it's an
Activity and the bot records. On Steam it's an Electron shell called
Dubbed, which records locally and ships a scene editor that
pulls dialogue off a clip using a voice-separation model running on the
player's own machine. In a plain browser tab, which is the development path,
the client records itself with MediaRecorder and
the rest of the pipeline is identical. That's why most of the work never
needs a Discord application at all.
One module says what's actually true per platform, and every check reads from it. The alternative is a scatter of platform comparisons that each drift on their own schedule, and a feature that works on a dev machine and silently fails inside Discord.
- Runtime Node 20 · TypeScript · npm workspaces
- Client Discord Embedded App SDK · Vite · no framework
- Voice discord.js 14 · @discordjs/voice receiver
- Audio Timeline-aligned PCM writer · ffmpeg adelay + amix
- Transport One WebSocket, one process, shared memory
- Desktop Electron shell for Steam, with on-device voice separation
- Billing Discord SKUs and entitlements
- Infra Single box behind Caddy · marketing site on Cloudflare
The product is called DisDubs because Discord won't allow "discord" in a bot's name. The repository, the workspaces and the code are all still DiscordDubs. Renaming them would have churned every import in the project for a rule that only applies to a public listing.
DisDubs is live at disdubs.com, and free to add to a server.