A local web UI for batch-subtitling Japanese anime in English. Drop episodes
into a watch folder; whisper transcribes the Japanese (with anime-tuned models
such as anime-whisper), and an LLM translates the subtitles with surrounding
lines as context. Transcription runs on either whisper-ctranslate2 (NVIDIA
CUDA / CPU) or mlx-whisper (Apple Silicon GPU via MLX), so pick whichever
backend matches the machine. It also joins video files (the Concat tab). The
repository and folder are named anime-subtitle-studio.
- Node.js 18+
ffprobeon PATH (comes with ffmpeg — used for duration estimation)- One whisper backend, depending on your machine:
- NVIDIA GPU / CPU:
whisper-ctranslate2(pip install whisper-ctranslate2 "av<16"). SeeWINDOWS_CUDA_SETUP.mdfor a full walkthrough. - Apple Silicon (M-series) GPU:
mlx-whisper(pip install mlx-whisper) — CTranslate2 has no Metal/MPS support, so this is the backend that actually uses the GPU on a Mac. SeeAPPLESILICON_SETUP.mdfor a full walkthrough.
- NVIDIA GPU / CPU:
For a complete machine setup (Python environment, GPU libraries, models, translation), follow the guide for your machine:
- Windows PC (NVIDIA CUDA):
WINDOWS_CUDA_SETUP.md - Mac (Apple Silicon):
APPLESILICON_SETUP.md
Then, or for a quick start:
# 1. Get the code and install dependencies
git clone https://github.com/Failsno/anime-subtitle-studio.git
cd anime-subtitle-studio
npm install
# 2. Start the server
npm start
# 3. Open in browser
# http://localhost:3939For development with auto-restart on file changes:
npm run dev- Watch folder — set the root folder in the top bar or via Settings. All
.mp4files found recursively are added to the queue automatically. - Start — begins processing the queue sequentially. Each file runs the configured backend (
whisper-ctranslate2ormlx-whisper) with the configured parameters. - Pause / Resume — pausing stops the queue after the current segment finishes (the active job keeps running until it completes naturally; use Stop in the queue pane to end it now).
- Settings (gear icon → Settings…) — pick the backend, edit the watch folder, and edit that backend's whisper parameters. Changes are saved to
config.jsonand applied immediately. - Output — subtitles are written to the same directory as each source
.mp4:video.srtin two-stage mode with Keep only English subtitles on (the default: the transcript is deleted and the English file renamed so players load it),video.ja.srt+video.en.srtwith it off, orvideo.srtin single-stage mode. The page plays a chime when the queue finishes or a concat completes. - Re-translate — the EN↻ button on a finished item re-runs only the translation step.
- Queue tools — the top of the queue pane has Start, Pause/Resume, Stop (stops the current job) and Clear done; drag its left edge to widen it; click a queue item to see just its log (errors show under the name); 👁 previews its subtitles (Japanese and English side by side); 📂 opens its folder with the subtitle file selected (also in the preview, and Show file on the Concat tab after a join); retry errors requeues every failed video; hide done declutters the list. The tab title shows progress, the 🔔 button mutes the chime, and Settings can send a desktop notification and auto-start new videos.
- Gear menu — the gear icon (top left) has Settings…, Reboot server and Shut down server. Reboot and Shut down both stop a running job (a finished
.ja.srtis kept) and release the port; reboot brings the page back when the server is up. Closing the terminal window or pressing Ctrl+C twice also shuts down cleanly (one Ctrl+C finishes the current job first).
The Concat tab joins several videos into one before translating (e.g. a release split into parts). Browse to a folder, tick the files in playback order, reorder them on the right if needed, name the output, and press Concat. It's an ffmpeg stream copy (no re-encode, no GPU), so all parts must share codec, resolution and frame rate. The output is saved next to the first file, and if that's inside the watch folder it's queued for translation automatically. Cancel deletes the partial file.
Whisper's built-in translate task is weak for Japanese (large-v3 is worse than
large-v2 at it, and large-v3-turbo can't translate at all). With Settings →
Translation → Two-stage, whisper only transcribes Japanese (video.ja.srt), and
an LLM translates it with surrounding context into video.en.srt.
Any OpenAI-compatible endpoint works:
- OpenRouter: base URL
https://openrouter.ai/api/v1, an API key, and a model id. - llama.cpp: run
llama-server -m model.gguf --port 9931 -ngl 99 --jinjaand usehttp://127.0.0.1:9931/v1(the "llama.cpp (local)" preset). No key is needed. 9931 is llama.cpp's upcoming default port; passing--port 9931explicitly works on both older and newer builds. Or let the app run it: fill in Settings → llama-server auto-start (executable,.ggufmodel, extra args, model load wait). When a video reaches translation and nothing answers at the local base URL, the app starts llama-server and waits up to the load wait (default 15 s) for the model. Shut down server stops it again (also one you started yourself on that port); Reboot leaves it loaded.
Thinking models are fine: <think>…</think> blocks are stripped from replies, but a non-thinking model (or --reasoning-budget 0 on llama-server) is much faster.
Each machine runs the whole pipeline by itself, using its own config.json:
| Windows PC (NVIDIA) | Mac (Apple Silicon) | |
|---|---|---|
| Backend | ctranslate2 |
mlx |
| Transcription | anime-whisper (CTranslate2) with VAD segmentation, --compute_type int8_float16, see WINDOWS_CUDA_SETUP.md |
anime-whisper (MLX) with VAD segmentation, see APPLESILICON_SETUP.md |
| Translation | local llama-server on port 9931 (the app can start it) or OpenRouter |
local llama-server on port 9931 |
| Setup guide | WINDOWS_CUDA_SETUP.md |
APPLESILICON_SETUP.md |
The How to use panel at the top of the Translate and Concat screens (under the tabs) has the same steps for each machine.
anime-whisper (MIT) is fine-tuned on anime and visual-novel dialogue and transcribes non-speech vocalizations that other models skip or hallucinate on. It was trained on short single lines, so it never outputs timestamps. Run through whisper's normal long-form mode, you get one 30-second subtitle per window and lines dropped at window boundaries.
The VAD segmentation setting (both backends) fixes this:
scripts/vad_transcribe.py runs Silero VAD, transcribes each stretch of speech
on its own, and uses the VAD times as cue timings (long cues are split at
Japanese punctuation). It runs with mlx-whisper on the Mac (--engine mlx, on
by default) and faster-whisper on the PC (--engine ct2, off by default; the
anime-whisper preset turns it on). It also cuts hallucinations for regular
whisper models. Never set an initial prompt with anime-whisper; its author warns
that prompts make it hallucinate. On the PC, anime-whisper requires VAD
segmentation: the plain whisper-ctranslate2 CLI crashes building word
timestamps for it.
Settings → --model has a dropdown per backend (hover ⓘ for install steps):
| Preset | Mac (mlx) |
PC (ctranslate2) |
|---|---|---|
| anime-whisper: anime dialogue | ~/models/anime-whisper-mlx |
~/models/anime-whisper-ct2 |
| whisper-ja-1.5B: Japanese large-v3 fine-tune | ~/models/whisper-ja-1.5B-mlx |
~/models/whisper-ja-1.5B-ct2 |
| whisper-ja-anime-v0.3: anime, turbo fine-tune (experimental) | — | ~/models/whisper-ja-anime-v0.3-ct2 |
| kotoba-whisper v2.0 (= v2.1/v2.2 here): Japanese, fast | ~/models/kotoba-whisper-v2.0-mlx |
~/models/kotoba-whisper-v2.0-faster |
| whisper large-v3 / large-v2 | mlx-community/whisper-large-v3-mlx / -v2-mlx (auto-download) |
large-v3 / large-v2 (auto-download) |
| Custom… | any MLX folder or HF repo id | any CTranslate2 folder |
These are whisper-compatible Japanese models. (Qwen3-ASR is a different
architecture and can't run here.) ~ expands to your home
folder on both OSes. Local models live in ~/models/<name>:
- Mac: convert with mlx-examples'
whisper/convert.py, then renamemodel.safetensorstoweights.safetensors. SeeAPPLESILICON_SETUP.md. - PC, anime-whisper (step 5 of
WINDOWS_CUDA_SETUP.md):The folder must containpip install transformers torch ct2-transformers-converter --model litagin/anime-whisper --output_dir ~/models/anime-whisper-ct2 --copy_files preprocessor_config.json --quantization float16 hf download openai/whisper-large-v3 tokenizer.json --local-dir ~/models/anime-whisper-ct2
tokenizer.json. anime-whisper's repo doesn't ship one, and without it faster-whisper falls back to a tokenizer with a different vocabulary, so every special token is off by one and the output is gibberish. Third-party anime-whisper CT2 uploads may have the same problem. - whisper-ja-1.5B (efwkjn/whisper-ja-1.5B):
a full large-v3 fine-tune (32 decoder layers vs anime-whisper's 2), so about
3 GB and slower. The author's benchmarks on visual-novel test sets put it
roughly level with anime-whisper. Unlike anime-whisper it does output
timestamps, but cues that cross a 30-second window boundary can start early,
so its preset also turns VAD segmentation on. Its repo ships
tokenizer.json, so the CT2 conversion needs no extra step. On the PC, skip the conversion: TransWithAI publishes it already converted:On the Mac, convert withhf download TransWithAI/whisper-ja-1.5B-ct2 --local-dir ~/models/whisper-ja-1.5B-ct2--torch-name-or-path efwkjn/whisper-ja-1.5B --mlx-path ~/models/whisper-ja-1.5B-mlx. Note: the model card doesn't state a license. - PC, whisper-ja-anime-v0.3 (efwkjn/whisper-ja-anime-v0.3, experimental):
a large-v3-turbo fine-tune with its own smaller Japanese vocabulary. It runs
in faster-whisper as is (VAD segmentation on):
ct2-transformers-converter --model efwkjn/whisper-ja-anime-v0.3 --output_dir ~/models/whisper-ja-anime-v0.3-ct2 --copy_files tokenizer.json preprocessor_config.json --quantization float16 - PC, kotoba-whisper:
hf download kotoba-tech/kotoba-whisper-v2.0-faster --local-dir ~/models/kotoba-whisper-v2.0-faster(the official build already includestokenizer.json). kotoba v2.1/v2.2 are the same weights plus punctuation/diarization steps that only run in Hugging Face's pipeline, so they transcribe identically here.
Measured results (RTX 5090, VAD segmentation) are in
WINDOWS_CUDA_SETUP.md → Which model?.
whisper-ctranslate2's --model only accepts stock names, so the app passes any
other value as --model_directory.
Full walkthrough, including the CUDA 12 libraries: WINDOWS_CUDA_SETUP.md.
pip install -U whisper-ctranslate2 "ctranslate2>=4.8.1" "av<16"On an RTX 50-series (Blackwell) GPU, use --compute_type int8_float16, not
float16. On these cards float16 can make CTranslate2
decode some 30-second windows into garbage, and the corruption carries over to
the following windows: an RTX 5070 produced 0 subtitles on float16 and 62 on
int8_float16.
CTranslate2 4.6.2 turned INT8 off on these cards, and 4.8.1 turned it back on,
hence the version pin. It's the default for new configs. On older NVIDIA cards
(RTX 20/30/40) float16 is fine and somewhat faster. Never use float32 on a GPU.
faster-whisper 1.2.1 crashes decoding audio with PyAV 19 (open() got an unexpected keyword argument 'metadata_errors'); PyAV 15.1 works. The VAD
script decodes with the ffmpeg CLI instead, so it isn't affected.
On the PC, keep the CUDA 12.x runtime libraries: CTranslate2 is built against
CUDA 12 (up to 12.8) and fails to load (libcublas.so.12 not found) on CUDA 13.
The EN↻ button on a finished item re-translates from the existing .ja.srt
without re-running whisper, so you can try other models or prompts cheaply.
It needs Keep only English subtitles turned off (it's on by default and
deletes the .ja.srt once the English subtitles are written, then renames
video.en.srt to video.srt).
config.json's "backend" field selects which whisper CLI is spawned. The
two backends have different parameter sets since their underlying CLIs
differ — the UI's Settings panel shows the right fields for whichever
backend is selected.
--model large-v3
--device cuda
--compute_type int8_float16
--output_format srt
--language ja
--task translate
--vad_filter True
--vad_threshold 0.28
--vad_min_speech_duration_ms 100
--vad_min_silence_duration_ms 300
--suppress_blank True
--beam_size 4
--temperature 0.0
--no_speech_threshold 0.4
--word_timestamps True
--initial_prompt "Optional prompt to guide transcription style or vocabulary."
Apple Silicon only — uses the GPU via MLX/Metal, since CTranslate2 has no Metal support and would otherwise fall back to CPU. Model is a Hugging Face repo id rather than a plain size name, and there's no device/compute_type or VAD filter (mlx-whisper doesn't have one built in):
--model mlx-community/whisper-large-v3-mlx
--language ja
--task translate
--temperature 0.0
--no-speech-threshold 0.4
--word-timestamps True
--fp16 True
--initial-prompt "Optional prompt to guide transcription style or vocabulary."
Edit either set in config.json (whisperArgs / mlxArgs) or via the Settings panel in the UI.
config.json is created automatically on first run — with defaults chosen
for the OS it's running on (mlx backend on macOS, ctranslate2 elsewhere)
— and is not tracked by git, since it holds machine-specific paths. Copy
config.example.json to get started:
cp config.example.json config.jsonKey settings:
watchFolder— path to scan for.mp4filesport— web UI port (default3939, chosen to stay clear of other Node apps on 3000). Also editable in Settings; applies after gear → Reboot server. ThePORTenvironment variable overrides it.backend—"ctranslate2"or"mlx"whisperExecutable— full path towhisper-ctranslate2if it's not on your PATH (e.g. inside a venv); used whenbackendis"ctranslate2"mlxExecutable— full path tomlx_whisperif it's not on your PATH (e.g. inside a venv); used whenbackendis"mlx"whisperArgs/mlxArgs— the parameter sets described above, one per backend. In two-stage mode--taskis ignored (alwaystranscribe).translation— the LLM translation step:enabled—truefor two-stage,falsefor whisper's built-in translatebaseUrl— OpenAI-compatible endpoint (https://openrouter.ai/api/v1orhttp://127.0.0.1:9931/v1)apiKey— OpenRouter key (blank for llama-server; theOPENROUTER_API_KEYenv var, or the same line in a gitignored.env, is used if blank). Never sent to the browser.model— model id (optional for llama-server, which serves whatever it loaded)batchSize/contextLines— subtitle lines per request, and how many lines before/after are sent as contexttemperature,stylePrompt— blank style prompt uses the built-in oneenglishOnly—true(default) deletesvideo.ja.srtafter a successful translation and renamesvideo.en.srttovideo.srt(replacing an existing one); kept as is if translation failsllamaExecutable,llamaModel,llamaArgs,llamaLoadWait— auto-start llama-server for a localbaseUrl(blank executable = start it yourself);llamaLoadWaitis the most seconds to wait for the model to load (default 15). Settings lists installed.gguffiles (Hugging Face cache,~/models) in a dropdown; picking another one restarts llama-server with it before the next translation, so EN↻ compares models
anime-subtitle-studio/
├── scripts/
│ ├── vad_transcribe.py # Silero VAD + per-segment transcription (mlx-whisper or faster-whisper)
│ └── capture-screenshots.js # Regenerates docs/screenshots/
├── docs/
│ ├── screenshots/ # README images (npm run screenshots)
│ ├── APPLESILICON_SETUP.md # Apple Silicon / mlx-whisper setup walkthrough
│ └── WINDOWS_CUDA_SETUP.md # Windows / NVIDIA CUDA setup walkthrough
├── test/
│ ├── pipeline.test.js # End-to-end tests (npm test)
│ ├── helpers.js
│ ├── fake-llama-server.js # llama-server stand-in for the auto-start tests
│ └── fixtures/ # 59s Japanese speech clip + expected transcript
├── src/
│ └── server.js # Express + WebSocket server, queue runner, file watcher
├── public/
│ └── index.html # Frontend (single file, no build step)
├── config.example.json # Example config — copy to config.json and edit
├── .env.example # Copy to .env for OPENROUTER_API_KEY (gitignored)
├── package.json
└── README.md
npm testThe end-to-end tests in test/pipeline.test.js start real servers on free ports
with temporary configs, so they don't touch your config.json or watch folder.
They use test/fixtures/speech.mp4, a 59-second Japanese clip made with macOS
text-to-speech, with its expected transcript in speech.ja.srt.
| Test | Needs |
|---|---|
| Missing whisper, silent whisper crash, re-translate refused | nothing |
| Translation, EN↻ re-translate, Stop mid-translation | LLM endpoint |
| mlx VAD transcription matches fixture | Mac + mlx venv + ~/models/anime-whisper-mlx |
| ctranslate2 VAD transcription matches fixture | WT_TEST_CT2_EXE + ~/models/anime-whisper-ct2 |
| Full two-stage pipeline | a transcription backend + LLM endpoint |
Tests whose requirements are missing are skipped, with the reason shown. Override the defaults with environment variables:
| Variable | Default |
|---|---|
WT_TEST_LLM_URL |
http://127.0.0.1:9931/v1 (start llama-server first) |
WT_TEST_LLM_KEY / WT_TEST_LLM_MODEL |
blank (set both for OpenRouter) |
WT_TEST_MLX_EXE / WT_TEST_MLX_MODEL |
~/mlx-whisper-env/bin/mlx_whisper / ~/models/anime-whisper-mlx |
WT_TEST_CT2_EXE |
blank: path to whisper-ctranslate2 (on the PC, e.g. where whisper-ctranslate2) |
WT_TEST_CT2_MODEL |
~/models/anime-whisper-ct2 |
Point WT_TEST_MLX_MODEL / WT_TEST_CT2_MODEL at another model folder to check
a new model against the fixture (that's how whisper-ja-1.5B was verified).
| WT_TEST_CT2_DEVICE / WT_TEST_CT2_COMPUTE | cuda / int8_float16 (cpu / float32 on macOS) |
On the PC with OpenRouter (PowerShell):
$env:WT_TEST_CT2_EXE = (Get-Command whisper-ctranslate2).Source
$env:WT_TEST_LLM_URL = "https://openrouter.ai/api/v1"
$env:WT_TEST_LLM_KEY = "<key>"; $env:WT_TEST_LLM_MODEL = "<model id>"
npm testThe images in docs/screenshots/ are generated, not hand-made. After changing
the UI, regenerate them with:
npm run screenshotsIt runs demo servers with temp configs, neutral file names and a fake whisper
that streams the test clip, then captures them with headless Chrome (via
puppeteer-core). Your config.json isn't touched. POSIX only; set
CHROME_PATH if Chrome isn't in the default macOS location.
- No database required — queue state is in memory and rebuilt from the watch folder on restart.
- Files that already have a matching
.srtor.en.srtare shown as done on startup and skipped when processing. - If a
.ja.srtexists without an.en.srt(e.g. translation failed or was stopped), the next run skips transcription and only translates. config.jsonpersists the folder path and all whisper parameters across restarts.- The progress bar estimates completion using segment timestamps from whisper's output and video duration from
ffprobe. Accuracy improves once the total segment count is known. - Adding new
.mp4files to the watch folder while running will automatically append them to the queue.




