← Index Case Study № 02 DisDubs
Recording

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.com
Year
2026
Role
Solo build
Platform
Discord Activity · Steam
Status
Live

The 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

  1. 6,131 Servers have opened it first month after launch
  2. 241,502 Members reached summed at last connect
  3. 4,322 Servers finished a dub 71% of everyone who opened it
  4. 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.

  1. The scene picker, showing cover art for the public catalogue
    01

    Pick a scene

    The public shelf, plus whatever the server has uploaded itself.

  2. The casting screen, assigning characters to people in the room
    02

    Hand out parts

    Every character goes to somebody, or to nobody and stays original.

  3. A clapperboard countdown over the scene, showing take 01
    03

    Slate

    A clapper, a count of three, and the take number everyone is on.

  4. The take running, with the script scrolling over the clip
    04

    Roll

    The clip plays, the script scrolls, each line lights up in turn.

  5. The booth, showing one line’s waveform against the original
    05

    Fix a line

    One flubbed line costs one line. Redo it against the original.

  6. The screening view, playing the finished dub back to the room
    06

    Premiere

    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.

The booth screen inside Discord, showing one line’s waveform, a record button, and a red BOT LISTENING badge
The badge in the corner reads bot listening. It is the hardest thing about this app to explain in words, and the only screenshot that shows it.

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.

Naive versus timeline-aligned recording Recorded naively, each performer's speech is packed against the start of their file, so the three tracks disagree about when anything happened. Aligned, silence is padded back in, so the same byte offset is the same instant in the take for all three. As the packets arrive every pause gone, three tracks that disagree Tanner Priya Marcus Written through AlignedPcmWriter silence padded back, so the tape means something Tanner Priya Marcus byte offset N: the same instant in all three files

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.

The take sheet, showing a performer’s waveform beneath the original, a drift slider, and a button reading apply to every take of mine
Because takes are isolated, a late entry is just a drag on the waveform. If somebody was late on everything, which is what a bad connection looks like, one nudge fixes all of their takes at once.

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.

46

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.

7

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.

2 hours Raw takes deleted A published promise, with a job that keeps it
6 hours Finished dubs deleted Download it, or lose it. The export button is right there
180 days Unused uploads swept A pack nobody has picked in half a year frees its slot
Monday Programme changeover The weekly rotation of the public shelf, at 00:00 UTC
On SIGTERM Rooms snapshot to disk A redeploy mid-session puts the settled state back

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.

  1. Installed Discord’s own count
    6,080
  2. Opened it a server appeared in a hello
    6,131
  3. …with the bot in the bot was already in the server
    5,027 82%
  4. …recorded a take produced at least one track
    4,480 89%
  5. …finished a dub a render landed
    4,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.

100+ Servers paying for one of the two tiers a month after launch. None of them for access to a single 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.

— Fin —