# AiSrt by Digital3d.com

A command-line tool to **translate**, **extract**, and **transcribe** subtitles using AI.

AiSrt is a .NET 9.0 console application that translates `.srt` subtitle files via the LLM API of your choice — **OpenAI, Anthropic Claude, Google Gemini, xAI Grok, or a local Ollama server** — extracts subtitle tracks from video files using ffmpeg, and transcribes audio to `.srt` using whisper.cpp.

## Features

- **Translate** — Translate `.srt` subtitle files between 29 languages with context-aware, idiomatic translations. An interrupted translation can be resumed where it left off. Other text subtitle formats (`.ass`, `.ssa`, `.vtt`, `.sub`) are converted to `.srt` automatically.
- **Five backends, your key** — OpenAI, Anthropic Claude, Google Gemini and xAI Grok (paid, each with your own API key), or a **local Ollama server: free, offline, no API key**. Pick one with `--provider openai|anthropic|gemini|xai|ollama` — `claude`, `google` and `grok` work too.
- **Extract** — Extract embedded subtitle tracks from video files (MKV, MP4, etc.) using ffmpeg. After extraction, you can choose to translate any extracted file into another language right away. **Image-based PGS subtitle tracks are read with OCR** using [Tesseract](https://github.com/tesseract-ocr/tesseract), which you need to install yourself (`winget install tesseract-ocr.tesseract` on Windows, `apt install tesseract-ocr` on Linux, `brew install tesseract` on macOS) — AiSrt does not download it for you.
- **Proofread the OCR** — Text read from image subtitles is passed to your translation backend for a second look, which repairs recognition errors (`|` read for `I`, `rn` for `m`) without translating or rewriting anything. **On by default**, using the same provider and key as translation; it sends only the recognised text, never the images. Turn it off with `"Ocr": { "Proofread": { "Enabled": false } }` in `appsettings.json`, choose another provider for it with `"Provider"`, or leave every key unset — with no key to use, the raw OCR text is written and the run carries on.
- **Transcribe** — Transcribe audio from video files to `.srt` subtitles using whisper.cpp with automatic GPU detection. The spoken language is detected by whisper, or imposed with `--audio-language`.
- **Update check** — AiSrt checks in the background whether a newer version is available and shows a short notice before the work starts, under the banner (or right after the list with `--languages`). It waits 1.5 seconds at most for the answer, never waits at all with `--non-interactive`, and stays silent when anything goes wrong.

## Supported Languages (29)

Arabic, Chinese, Czech, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Hungarian, Indonesian, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Romanian, Russian, Spanish, Swedish, Thai, Turkish, Ukrainian, Vietnamese

## Prerequisites

- **For translation, either:**
  - an API key (paid) for one of the four hosted providers, set in its own section of
    `appsettings.json` — `OpenAI:ApiKey`, `Anthropic:ApiKey`, `Gemini:ApiKey` or `Xai:ApiKey`; or
  - [Ollama](https://ollama.com) installed and running locally (free) — start it with `ollama serve`
    and pull the configured model with `ollama pull gpt-oss:20b`. AiSrt checks both at startup and
    tells you exactly which command to run if something is missing.

  Whichever you pick, AiSrt checks it **before** the first paid call: an invalid key, an unknown
  model or an unreachable API stops the run with a message naming the setting to fix.
- **For extraction:** ffmpeg (auto-downloaded if missing)
- **For transcription:** whisper.cpp (auto-downloaded on Windows x64 and Linux x64 — an arm64 URL
  is configured too, but no arm64 release is published, so it only applies to a build from source),
  ffmpeg

  On **macOS**, whisper.cpp publishes no command-line build, so nothing can be downloaded
  automatically: install it once with `brew install whisper-cpp` (and `brew install ffmpeg`) and
  AiSrt will pick both up from your PATH.

## Installation

Download the latest release for your platform:

| Platform | File |
|----------|------|
| Windows x64 | `AiSrt-v{version}-win-x64.zip` |
| Linux x64 | `AiSrt-v{version}-linux-x64.zip` |
| macOS x64 (Intel) | `AiSrt-v{version}-osx-x64.zip` |
| macOS ARM64 (Apple Silicon) | `AiSrt-v{version}-osx-arm64.zip` |

### Windows

Extract the ZIP and run the installer:

```powershell
.\install.ps1
```

This copies `aisrt.exe` and `appsettings.json` to `%LOCALAPPDATA%\AiSrt` and adds it to your PATH.

### Linux / macOS

Extract the ZIP and run the installer:

```bash
chmod +x install.sh
sudo ./install.sh
```

This copies `aisrt` to `/usr/local/bin/` and `appsettings.json` to `~/.config/aisrt/`.

## Usage

```bash
# Translate subtitles (English to French by default)
aisrt "movie.srt"

# Translate English to Spanish
aisrt "movie.srt" -s en -t es

# Translate a French subtitle file to English
aisrt "movie.srt" -s fr -t en

# Show all supported languages
aisrt --languages

# List subtitle streams in a video file
aisrt --streams "movie.mkv"

# Extract subtitles from a video file (interactive selection)
aisrt --extract "movie.mkv"

# Transcribe audio to .srt using whisper.cpp (spoken language detected)
aisrt --transcribe "movie.mkv"

# Transcribe, then translate the transcript — -tt, not -t
aisrt --transcribe "movie.mkv" --translate-to fr

# Translate locally and for free with Ollama
aisrt "movie.srt" -t fr --provider ollama

# Translate with Claude, Gemini or Grok instead (each uses its own key)
aisrt "movie.srt" -t fr --provider claude

# Translate an .ass / .vtt / .sub file (converted to .srt first)
aisrt "movie.ass" -t fr

# Extract one specific track and translate it, without a single question asked
aisrt --extract "movie.mkv" --stream 2 --translate-to fr --non-interactive
```

> **The source language is not detected — it defaults to `en`.** Translating a French file with
> `-t en` and nothing else fails with *"Source and target languages must be different"*, because
> AiSrt assumed the source was English. Always pass `-s` when the input is not English. Neither the
> file name nor its content is used to guess it.

> **`--provider` applies to that one command.** It is not remembered between runs: to use Ollama
> (or Claude, Gemini, Grok) every time, set `Translation:Provider` in `appsettings.json`.

## Scripting (unattended runs)

`--extract` and `--transcribe` ask questions: which track to pull out, which whisper model to use,
whether to translate the result. In a script, those questions have no one to answer them.

Five options remove them. Each one *decides* rather than *asks* — pass them and the corresponding
prompt never appears, interactively or not:

| Option | Replaces |
|--------|----------|
| `--stream <index\|all>` | "Which stream to extract?" — the index is the one shown by `--streams` |
| `--translate-to <code>` | "Translate this file to another language?", after `--extract` **or** `--transcribe` |
| `--model <name>` | "Select a whisper model" (see below) |
| `--audio-language <code>` | whisper's own language detection, when you already know the answer |
| `--non-interactive` | Never reads stdin at all |

For a script that runs `aisrt` many times, set the environment variable `AISRT_UPDATE_ENDPOINT=off`
to skip the update check entirely: no network call, nothing printed.

```bash
# One track, translated to French, no interaction, usable in cron or CI
aisrt --extract "movie.mkv" --stream 2 --translate-to fr --non-interactive
```

**What `--non-interactive` does to each question.** It never invents an answer — it applies the
documented default and says so in the log, naming the option that would have decided:

| Question | Default applied |
|----------|-----------------|
| Which stream to extract | all text-based tracks |
| Translate the result | no — the file is left untranslated |
| Which whisper model | `base.en` (only asked when no model is present) |
| Resume a partial translation | yes, resume |
| Ctrl+C — "are you sure?" | **not asked: it stops immediately**, exit code 2 |

The same defaults already applied when stdin was closed (`< /dev/null`, a pipeline, a container
without a TTY), so `--non-interactive` makes an existing behaviour explicit rather than adding one.
Its real value is that it works even when stdin *is* a terminal, where the process would otherwise
wait forever.

**A value you give explicitly is never quietly ignored.** `--stream 7` on a file whose track 7 does
not exist — or is a bitmap track — fails with exit code 1 and lists the usable indexes, instead of
falling back to "extract everything" the way the interactive prompt does. Same for an unknown
`--model`, an unsupported `--translate-to`, or an option that does not apply to the requested mode
(`--stream` without `--extract`). All of these are checked **before** any probing, downloading or
API call.

**Exit code with `--translate-to`.** `--extract` normally returns 0 even if a follow-up translation
fails, because the extraction itself succeeded and the files are on disk. When the translation was
requested explicitly with `--translate-to`, a failure returns 1: a script has to see that what it
asked for did not happen. The extracted files are still kept. Having *nothing* to translate — no
text track, or a track already in the target language — is not a failure and returns 0.

## Transcribing another language than English

Nothing to do: whisper detects the spoken language and the transcript is named after it.

```bash
aisrt --transcribe "film.mkv"        # French audio gives film.fr.srt
```

The detected language is announced before the transcription starts, and it is what a chained
`--translate-to` reads as its source language. To impose it — because the first thirty seconds are
music, or because you already know — name it yourself, which also skips detection:

```bash
aisrt --transcribe "film.mkv" --audio-language fr
```

> **To translate the result, use `--translate-to` (`-tt`), not `--target` (`-t`).**
> `-t` sets the target of a plain `.srt` translation; alongside `--transcribe` it means nothing and
> is refused rather than ignored. `aisrt --transcribe film.mp4 -tt fr` transcribes, then translates.

Whisper recognises about 99 languages, AiSrt translates 29 of them. A transcript in one of the
others is produced and named normally, but no translation is offered from it.

> **Only speech is transcribed.** Music and other non-speech sounds produce no subtitle at all —
> no `[Musique]` line. That is deliberate: left to describe them, whisper loops over a song and
> repeats the same caption fifteen times in a row. A video with no speech whatsoever therefore
> transcribes to nothing, and says so.

| Model | Size | Languages |
|-------|------|-----------|
| `base.en`, `small.en`, `medium.en` | ~150 MB / ~500 MB / ~1.5 GB | **English only** |
| `base`, `small`, `medium` | ~150 MB / ~500 MB / ~1.5 GB | Any supported language |
| `large-v3` | ~3.0 GB | Any supported language, best accuracy |

The `.en` models are trained on English alone. Given French audio they do not fail — they return
confident nonsense — so AiSrt refuses that combination up front and names the models that would
work, rather than spending minutes of CPU on a result you would have to throw away. They cannot
detect a language either: `--model base.en` on its own transcribes English and says so.

> `--audio-language` is deliberately not called `--language`, which is one letter from
> `--languages` (the list of translation codes) and does something else entirely.

## Output files

- The translated file is written next to the source, named after the target language:
  `movie.srt` → `movie.fr.srt`. An existing language suffix is replaced rather than stacked, so
  `movie.en.srt` gives `movie.fr.srt`, not `movie.en.fr.srt`.
- **Unless that name already holds the translation of a different subtitle track.** Translating both
  `movie.en.srt` and `movie.es.srt` into German would otherwise write `movie.de.srt` twice; the
  second one becomes `movie.es.de.srt` so nothing is lost.
- An existing output file is copied to `.bak.srt` before being rewritten. If that backup cannot be
  written, AiSrt stops rather than overwrite a translation it could not save first.
- The output is flushed after every batch, so an interrupted run keeps what it already translated
  and offers to resume from there on the next run.

## Exit codes

| Code | Meaning |
|------|---------|
| `0` | Success. Also returned by `--extract` when the extraction worked and a follow-up translation failed — the extracted files are on disk. |
| `1` | Failure: file not found, wrong file type for the mode, missing or invalid API key, Ollama server down or model missing, ffmpeg/whisper error, network failure. |
| `2` | Cancelled by you (Ctrl+C, confirmed). Whatever was already written is kept and can be resumed. |

## Configuration

Edit `appsettings.json` to configure:

- **Backend** — `Translation:Provider`: `openai` (default), `anthropic`, `gemini`, `xai` or
  `ollama`. This is the persistent setting; `--provider` overrides it for a single command.
- **API key** — Each provider reads its own: `OpenAI:ApiKey`, `Anthropic:ApiKey`, `Gemini:ApiKey`,
  `Xai:ApiKey` (or an external `.env` file, off by default). Ollama needs none.
- **Model** — One per provider, each with a shipped default:

  | Provider | Setting | Default |
  |---|---|---|
  | OpenAI | `OpenAI:Model` | `gpt-5-mini` |
  | Anthropic | `Anthropic:Model` | `claude-haiku-4-5` |
  | Gemini | `Gemini:Model` | `gemini-3.6-flash` |
  | xAI | `Xai:Model` | `grok-4.5` |
  | Ollama | `Ollama:Model` | `gpt-oss:20b` |

  These defaults are current as of this release, not forever. A model that no longer exists fails at
  startup with a message naming the setting to change — it never fails halfway through a file.
- **Ollama** — `Ollama:BaseUrl` (default `http://localhost:11434`) and `Ollama:Model`
- **Batch size** — Number of subtitle blocks per API call (default: 40, and 10 for Ollama).
  Each provider can override it with its own `{Provider}:BatchSize`.
- **Translation prompts** — Customize the system and user prompts for translation
- **Default languages** — Set default source/target languages
- **OCR** — `Ocr:DefaultLanguage` (used when a track does not declare one), `Ocr:PageSegMode`
  (tesseract `--psm`, default 6) and `Ocr:UpscaleFactor` (image enlargement before reading,
  default 2, capped at 4). A value outside the accepted range is reported and replaced, not used.
- **OCR proofreading** — `Ocr:Proofread:Enabled` (default `true`), `Ocr:Proofread:Provider` (empty
  means the translation provider) and `Ocr:Proofread:BatchSize` (0 means the provider's own).
  Its two prompts are editable like the translation ones.

### Choosing an Ollama model

Local models vary a lot in how faithfully they follow the output format AiSrt needs. Measured
here on the shipped prompt (English → French, 3 runs each):

| Model | Result | Speed |
|---|---|---|
| `gpt-oss:20b` (default) | Clean every run, never needed a retry | Slowest (~14 GB) |
| `llama3.1:8b` | Clean every run, occasionally merged two blocks (recovered automatically) | ~10× faster (~5 GB) |
| `qwen2.5:latest` | Added commentary in Chinese inside the subtitles — not usable | Fast |

`gpt-oss:20b` ships as the default because it was the only one that never dropped a block. If your
machine finds it too slow, `llama3.1:8b` is a reasonable trade.

## CLI Reference

```
aisrt <file> [options]

Arguments:
  <file>    Subtitle file (.srt, .ass, .ssa, .vtt, .sub) or video file

Options:
  -t, --target <code>       Target language of an .srt translation [default: fr]
  -s, --source <code>       Source language of an .srt translation [default: en] — never detected
  -p, --provider <name>     Translation backend: openai | anthropic | gemini | xai | ollama
                            (also used to proofread OCR text)
  -l, --languages           Show all supported languages
  -st, --streams            List subtitle streams in a video file
  -ex, --extract            Extract subtitle tracks from a video file
                            (image-based PGS tracks are read with OCR)
  -tr, --transcribe         Transcribe audio to .srt using whisper.cpp
      --stream <index|all>  With --extract: which track, no prompt
  -tt, --translate-to <c>   With --extract/--transcribe: translate the result
  -m,  --model <name>       With --transcribe: whisper model, no prompt
  -al, --audio-language <c> With --transcribe: spoken language [default: detected]
  -ni, --non-interactive    Never read stdin; defaults apply, Ctrl+C stops at once
  --version                 Show version
  -h, --help                Show help
```

> `--stream` (singular) selects a track to extract; `--streams` (plural) only lists them. One letter
> apart, very different effects.

## License

MIT
Digital3d.com
