Hobby projects → mlsubgen
Project · Open source · Speech recognition · Local AI · October 2026

mlsubgen — subtitles for every video in the house, made at home and now open source

Our library has films and series in Japanese that never came with subtitles, and a household that watches in English and in Thai. This is the tool we built on the AI appliance to fix that: it listens to a video, works out what language is being spoken and where, transcribes it with two speech engines that check each other, translates the result into whichever of 45 languages you tick, and drops a subtitle file next to the video — overnight, unattended, and without a single byte leaving the building. As of October 2026 it is on GitHub for anyone with an NVIDIA card to run.

github.com/dodgypast/mlsubgen Apache-2.0 · Python · Docker or host install · 8 GB GPU and up

What it does

Run it yourself

Linux with an NVIDIA GPU, ffmpeg, Docker with the NVIDIA Container Toolkit, and Ollama on the same machine for the translator. The only thing to fill in is where your videos are.
git clone https://github.com/dodgypast/mlsubgen.git && cd mlsubgen
cp .env.example .env                  # MEDIA_DIR = your videos; your uid/gid; default languages
mkdir -p data models                  # state and the model cache, created by you so the containers can write them
docker compose up -d --build          # ~14 GB image (CUDA torch and the speech stacks), 10-20 minutes the first time
docker compose run --rm worker pull   # the speech models (~8 GB) and the translators your card can hold
docker compose run --rm worker pull speakers   # optional: the two speaker models (33 MB) for --speakers

Then open http://<host>:8790, queue a folder, and watch the log. There is a host install too (setup.sh, user units for the worker and the web UI), a command line for everything the browser does, and mlsubgen --help that explains itself. The card decides the rest:

GPUprofilewhat changes
24 GBfullboth speech engines resident; the 27–31B translators we use here
12–20 GB12gbwhisper in int8; a 12B translator (7 GB) for every language pair
8 GB8gbone speech engine on the card at a time; a 4B translator for every pair
Picked from the GPU’s memory at start, or forced with MLSUBGEN_PROFILE. The smaller translators are noticeably weaker, most of all for Japanese to English; mlsubgen bench runs the speech stage once and every translator on the same lines, side by side, so you can see by how much on your own material before a long run.
About the code. It was written with the help of Claude (Anthropic’s model), reviewed line by line and run in daily use on our own library — a hobby project maintained by one person, not a product. Issues on GitHub get answered; the pinned dependencies in the repository are the reference environment, and “works for me” means works there.

The finding that made it work

The first versions transcribed beautifully and still missed whole conversations. It took a measuring tool rather than a better model to find out why: a voice-activity detector was deciding which parts of the audio the speech engines were allowed to hear, and on animation with a music bed it simply does not register a lot of dialogue. Thirty seconds of an argument, gone, because the detector called it music. Every downstream improvement — better engines, better merging — could only work on what it was given.

So the detector was demoted. The engines now hear the whole timeline, cut into thirty-second pieces at the quietest moments, skipping only stretches that are silent by energy. The detector keeps two advisory jobs — choosing speech-dense windows for language identification, and suggesting cut points — and never again decides what gets transcribed. Anything the engines invent over music is dealt with where it should be: by two engines disagreeing, by a referee that knows what a looping artefact looks like, and by a gate that checks a doubtful line has sound under it before keeping it.

On the episode that exposed the problem, the change recovered eight minutes of dialogue the engines had never been allowed to hear.

Thirty seconds is a rule, not a preference. Every published integration of the Japanese engine feeds it thirty-second pieces, and its own benchmarks were run that way. Longer pieces make the decoder skim or fall silent over music, and make its forced aligner collapse whole runs of words onto a single timestamp — on a sparse film, half the words. Short, speech-dense pieces keep both on track, and decode faster into the bargain.

How a run works

Point it at a folder and it works in rounds of ten files, so subtitles appear every round rather than at the end of the night. For each file:

Rules that came from measurement, not opinion

Each of these was a change made after a tool showed the size of the problem, and each one is checkable again on any file by re-running the tool.

What it runs on here

Where it fits

The GPU box in the rack does the listening; its sibling, the N5 AI MAX, runs the house’s voice assistant on its own hardware, so an overnight subtitling job never competes with someone asking for the lights. Subtitling is simply the best use of a 24 GB card while everyone is asleep, and the same rules apply as to everything else we run — measure before changing, fix at the layer that can hold the fix, and never let a component that cannot be trusted make a decision for one that can. If your library has the same problem, the code is on GitHub; if your house does, write to us.