TrangoPlayer

TrangoPlayer is a desktop video player built for learning a language by watching videos with subtitles.

You open a video together with a subtitle file, and TrangoPlayer can play it in two ways:

  • Normal mode — a regular video player: continuous playback with a scrub bar, like any other video app.
  • Sentence-by-sentence mode — built specifically for language learners. The video is driven by the subtitle timing instead of the clock: one key jumps to the next line, another replays the line you're on as many times as you want, and a toggle reveals a translated line underneath the original so you can check your understanding without leaving the video.

If a video doesn't have a subtitle file yet, TrangoPlayer can generate one automatically (using speech recognition that runs entirely on your own computer), and it can look up a word-by-word translation and pronunciation guide for whichever sentence is currently on screen.

Where to go next

  • Getting Started — install TrangoPlayer and open your first video.
  • Using TrangoPlayer — the features above, one page each: playback modes, keyboard shortcuts, subtitles, automatic subtitle generation, word-by-word analysis, and settings.
  • Developer Guide — for anyone working on TrangoPlayer's own code: architecture, design decisions, and the third-party libraries it's built on.

Everything runs on your computer

TrangoPlayer doesn't send your videos, subtitles, or viewing activity anywhere. Subtitle generation and word analysis both use software that runs locally — nothing is uploaded to a cloud service. See Generating subtitles automatically and Word-by-word analysis for what that software is and how to install it.

Installation

Debian/Ubuntu: download the .deb

Every release publishes a .deb package — grab the latest one from the GitHub Releases page and install it:

sudo apt install ./trango_<version>-1_amd64.deb

This pulls in libmpv2 and the other runtime libraries automatically. For other platforms, or to build from source, continue below.

Building from source

1. Install the Rust toolchain

If you don't already have Rust, install it via rustup. TrangoPlayer needs Rust 1.97 or newer.

2. Install libmpv

TrangoPlayer uses libmpv (the mpv media player's playback engine) for video decoding, and needs its development headers installed to build:

  • Debian/Ubuntu: sudo apt install libmpv-dev
  • Fedora/RHEL: sudo dnf install mpv-libs-devel
  • Arch: sudo pacman -S mpv
  • macOS: brew install mpv

3. Get the source and build

git clone <repository-url>
cd trango
cargo build --release

The first build compiles the whole workspace and takes a few minutes; later builds are much faster.

4. Run it

cargo run --release -p trango

This opens TrangoPlayer with an empty window — see Opening your first video for what to do next.

Optional tools

Two features need extra software installed separately, only if you want to use them — TrangoPlayer works fully without either:

Opening your first video

From the command line

cargo run --release -p trango -- <path/to/video> [path/to/subs.srt] [path/to/subs.translation.srt]

All three arguments are optional:

  • With no arguments, TrangoPlayer opens an empty window — use the top bar's "Open video…" button to pick a file.
  • With just a video path, TrangoPlayer looks for a subtitle file with the same name next to it (video.mp4video.srt) and links it automatically if found.
  • The second argument links a specific subtitle file explicitly.
  • The third argument links a second subtitle file as a translation track, shown alongside the original (see Subtitles).

From inside the app

Click "Open video…" in the top bar. This opens an in-app file browser (not your operating system's native file picker) starting in the folder of the last video you opened. Navigate into subfolders with the listed folder rows, or go up with the "‥ Up" row, then select a video and click "Open".

Opening a video this way also tries to auto-match a same-name subtitle file, exactly like the command-line path above.

What happens next

TrangoPlayer always opens paused — nothing plays automatically, no matter how you opened the video or whether a subtitle was found. Press Space to start playback. See Playback modes for what Space, and the rest of the keyboard shortcuts, do in each mode.

If no subtitle file was found, the video still plays fine in Normal mode; Sentence-by-sentence mode needs a subtitle to know where the sentence boundaries are. You can link one — or generate one automatically — from the "Subtitles…" button, covered in Subtitles.

Playback modes

The top bar has two independent choices, each its own segmented control: which source is active (Video or Audio), and how navigation behaves (Normal or Sentence by sentence). Any combination of the two works.

Sentence by sentence

This is the mode built for language learning, and the one TrangoPlayer starts in by default. Playback is driven by the subtitle's cue timing instead of the clock:

  • Right Arrow jumps to the start of the next subtitle line and pauses there.
  • Left Arrow jumps to the start of the previous line, same way.
  • Clicking a row in the sentence list (the scrollable list on the right) jumps straight to that line, exactly like the arrow keys.
  • Space plays the line you're currently on, from its start to its end, then pauses automatically. Press it again and it replays the same line from the start — it never advances to the next one on its own.

Nothing plays until you press Space. Jumping between lines with the arrow keys or the sentence list only moves the playhead and leaves playback paused — this is deliberate, so you can look at a line as long as you like before deciding to hear it.

This mode needs a subtitle file to know where the sentence boundaries are. See Subtitles for linking or generating one.

Normal

Continuous playback with a scrub bar, closer to an ordinary video player. Space still works here — it's a plain play/pause toggle, with no per-line seeking or auto-pausing. Click or drag the scrub bar to seek to any point.

Video / Audio source

Video plays a loaded video file through the scrub bar and speed slider, same as always. Audio shows a Rec/Stop panel instead of a picture, but plays a loaded .wav file through that same scrub bar and speed slider once one is loaded — there's just no picture to go with it. The sentence list and Ctrl+A word analysis still work on whatever subtitle is linked, regardless of which source is selected.

The top bar's Open button opens the same in-app file browser in both sources: in Video it lists video files, in Audio it lists .wav recordings, from whichever folder you last opened one of that kind from. Either way, a same-name .srt next to the picked file is linked automatically, same as opening a video always has.

Ctrl+Space, in the Audio source — or the Audio panel's Rec/Stop button — starts capturing your system's own audio output (whatever is currently playing on your PC, e.g. a browser video) to a single WAV file; pressing it again stops the recording. The panel shows the recording state and a default <date>_<time>.wav filename while it runs; once stopped, you can rename the file by editing that field and pressing Enter, and the finished recording loads straight into the scrub bar for playback, just like opening it with "Open" would. A failed start/stop (e.g. pactl/ffmpeg aren't installed) shows an explanatory message instead of silently doing nothing. See Settings for where the monitor source and recording folder come from and how to override them, and note recording only works on Linux with PulseAudio/PipeWire (see the developer docs' architecture section for why).

Playback speed

A speed slider sits below the scrub bar, always visible in the Video source. Its right edge is normal speed (1.0x) — dragging it left only slows the video down, in steps down to 0.5x, marked "0.5x"/"0.75x"/"1.0x" along the track. Useful for hearing a fast line more clearly without losing per-sentence navigation in Sentence by sentence mode.

Common to all combinations

  • Ctrl+T toggles a translated line underneath the current sentence, if a translation subtitle is linked. Purely visual — it doesn't affect playback. See Subtitles.
  • Ctrl+A looks up a word-by-word breakdown of the current sentence. See Word-by-word analysis.

The bottom hint bar always shows whichever of these shortcuts actually do something in the navigation mode you're currently in.

For the full shortcut list, see Keyboard shortcuts.

Keyboard shortcuts

KeyEffectMode
SpacePlay/pause. In Sentence by sentence with a line in focus: play that line's span, then auto-pause at its end; press again to replay it from the start. Otherwise: plain play/pause toggle.All
Right ArrowJump to the next subtitle line's start and pause thereSentence by sentence
Left ArrowJump to the previous subtitle line's start and pause thereSentence by sentence
Ctrl+TShow/hide the translated line under the current sentenceAll
Ctrl+ALook up a word-by-word translation for the current sentenceAll
Ctrl+SpaceStart/stop recording system audio to a WAV fileAudio source

Clicking a row in the sentence list does the same thing as Right/Left Arrow — jump to that line's start, paused — for whichever row you click.

Debugging

--debug is a command-line flag, not a keyboard shortcut, but is worth knowing about if a feature isn't behaving as expected:

cargo run --release -p trango -- --debug video.mp4 subs.srt

It can go anywhere among the other arguments. It turns on detailed logging for TrangoPlayer's own code — useful mainly when diagnosing word analysis issues, since it logs the exact prompt sent to Ollama and the raw response received.

Subtitles

TrangoPlayer works with two subtitle tracks per video: an original subtitle (the language you're learning) and an optional translation subtitle (shown alongside it).

Original subtitle

Opening a video automatically looks for a subtitle file with the same name next to it (video.mp4video.srt) and links it if found — see Opening your first video.

If none is found, open the "Subtitles…" dialog from the top bar. Its first section shows either the linked file, or — if none was found — an empty state with a "Generate subtitles" button that transcribes the video's audio automatically. See Generating subtitles automatically.

Translation subtitle

The dialog's second section lets you link a second .srt file as a translation track, using the same in-app file browser as elsewhere in TrangoPlayer, scoped to .srt files next to the video. Picking one merges it in immediately.

The two tracks don't need to have the same number of lines — TrangoPlayer matches them up by comparing each line's timing, not its position in the file, so a hand-timed original and a machine-generated translation still line up correctly.

Showing the translation

Once a translation is linked, the current-sentence card's toggle switch (or the Ctrl+T keyboard shortcut) shows or hides the translated line underneath the original. It's off by default, works in both playback modes, and is purely visual — it never affects what's playing.

Generating subtitles automatically

If a video has no subtitle file, the "Subtitles…" dialog's "Generate subtitles" button transcribes the video's audio into a subtitle file automatically, using whisper.cpp's whisper-cli speech-recognition tool. This runs entirely on your own computer — nothing is uploaded anywhere. The same button works the same way in the Audio source, for a recorded or opened audio file.

TrangoPlayer doesn't bundle whisper-cli itself, so it needs to be installed and reachable separately. Note that this is not the openai-whisper Python package (whose CLI is whisper, with different flags) — it specifically means whisper.cpp's own whisper-cli binary.

ffmpeg is also required for video. whisper-cli only reads a handful of raw audio formats — not video containers like .mp4/.mkv — so TrangoPlayer extracts the video's audio to a temporary file with ffmpeg first. This happens automatically, but ffmpeg needs to be installed and on your PATH. It's extremely commonly preinstalled, or a one-line install: sudo apt install ffmpeg / brew install ffmpeg / the official builds for Windows. Generating subtitles for the Audio source's recordings doesn't need ffmpeg — they're already audio, so whisper-cli reads them directly.

Installing whisper-cli

Linux: Debian/Ubuntu ship a package:

sudo apt install whisper.cpp

This installs whisper-cli straight onto PATH — no build step needed. If your distro doesn't package it (or you want a newer version), build from source instead — no unusual dependencies, just a C++ toolchain and CMake:

git clone https://github.com/ggml-org/whisper.cpp
cd whisper.cpp
cmake -B build
cmake --build build --config Release

This produces build/bin/whisper-cli. Copy or symlink it onto your PATH (e.g. ~/.local/bin), or point TrangoPlayer at it directly — see Settings.

Windows: whisper.cpp's GitHub Releases page publishes prebuilt Windows binaries (no build toolchain needed) — download the archive matching your CPU/GPU setup and extract whisper-cli.exe somewhere convenient. Building from source works the same way as Linux, using CMake with Visual Studio's toolchain, if you'd rather build it yourself.

Getting a model

whisper-cli also needs a ggml/gguf model file, downloaded separately — whisper.cpp's repo includes a models/download-ggml-model.sh script for fetching one (e.g. ./models/download-ggml-model.sh medium for a mid-sized multilingual model). Larger models transcribe more accurately but take longer and use more memory.

Model size matters a lot for anything other than English. Whisper's smaller models (tiny/base/small) are trained on mostly English data, so quality for lower-resource languages — Hebrew is a good example — drops noticeably compared to English. For non-English language-learning videos, prefer medium or large-v3 (both multilingual — don't use an .en-suffixed model, those are English-only and won't transcribe anything else). English-only content can still use the smaller, faster base.en/small.en models fine.

Where you put the downloaded file doesn't matter much — TrangoPlayer's model picker (below) can browse to wherever it ends up, but dropping it in ~/whisper.cpp/models/ (if you built from source there) or ./models (relative to wherever you run TrangoPlayer from) means the picker finds it automatically without any manual navigation.

Picking a model in TrangoPlayer

The Subtitles dialog's "select a whisper model…" row opens an in-app folder browser (not your operating system's file picker) scoped to .bin/.gguf files. It starts in whichever likely folder it finds first, but you can navigate anywhere. The pick is remembered across restarts (see Settings). The language passed to whisper-cli is inferred automatically from the model's filename (whisper.cpp's own .en-suffix convention) — there's no separate language setting to configure.

"Generate subtitles" stays disabled until a model is selected. If whisper-cli itself can't be found, or a transcription run otherwise fails, the dialog shows a message explaining what went wrong rather than a generic failure.

Word-by-word analysis

Ctrl+A breaks down the sentence currently shown in the current-sentence card word by word, showing a translation and a pronunciation guide for each word. It uses a locally running Ollama instance — like whisper-cli, Ollama runs entirely on your own computer and isn't bundled with TrangoPlayer, so it needs to be installed separately.

Setting up Ollama

  1. Install Ollama from ollama.com.
  2. Make sure it's running (ollama serve, or however your install starts it).
  3. Pull at least one model: ollama pull llama3.1 (or any model you prefer).

TrangoPlayer talks to Ollama's default local address, http://localhost:11434 — no configuration needed if Ollama is running with its own defaults.

Picking a model and target language

The Subtitles dialog's "Ollama model" row opens a picker listing whatever models ollama list would show. The pick is remembered across restarts, the same way the whisper model is (see Settings).

The "Target language" field next to it (defaults to "English") is what translations and pronunciations are produced in — type any language name. It saves as you type and is remembered across restarts. Changing it only affects sentences analyzed after the change; sentences already analyzed keep whatever language they were analyzed in until re-analyzed (delete the cache file described below to force re-analysis in a new language).

Using it

Ctrl+A works in both Normal and Sentence-by-sentence mode, on whichever sentence the current-sentence card is showing — in Normal mode, the card automatically follows along as the video plays, so Ctrl+A always analyzes the line currently on screen, not whatever line happened to be current when you switched into Normal mode. The first time a given sentence is analyzed, it calls Ollama (a few seconds, depending on the model and machine); every time after that — including across restarts — it's instant, since the result is cached to a <subtitle-name>.wordanalysis.json file right next to the subtitle file (e.g. movie.srtmovie.wordanalysis.json).

"Analyze all sentences" (also in the Subtitles dialog, next to the Ollama model row) runs the same analysis for every sentence in the currently linked subtitle in one background pass — useful for pre-analyzing a whole video before watching it, rather than one sentence at a time via Ctrl+A. It writes to the same cache file, skipping sentences already analyzed, so it's safe to stop partway through (close the app, or just decide you have enough) and pick up later — including after adding individual Ctrl+A analyses in between. A sentence that fails to analyze is retried a few times before the run moves on to the next one; if it still fails, it's saved with a blank analysis rather than stopping the whole run — re-run Ctrl+A on that sentence later to fill it in.

Both features need a subtitle to be linked and an Ollama model selected first; TrangoPlayer shows a clear inline message rather than a generic error if either is missing.

Hebrew pronunciation

For Hebrew sentences specifically, the pronunciation guide isn't guessed by Ollama — small local models transliterate Hebrew unreliably even when they translate it correctly. Instead TrangoPlayer runs a separate niqud (vowel-point) diacritization model (Phonikud) directly and derives the pronunciation deterministically from its output. Hebrew sentences are detected automatically from their script — nothing to configure there.

The model itself needs a one-time setup: download phonikud-1.0.int8.onnx and tokenizer.json into the same folder, then point Settings → "HEBREW NIQUD MODEL (.ONNX)" at the .onnx file. If no model is configured, or loading it fails, Ctrl+A/"Analyze all sentences" still work exactly as before, just with Ollama's own (less accurate) pronunciation guess for Hebrew lines.

Also needs ONNX Runtime itself installed — TrangoPlayer's .deb package pulls in Ubuntu/Debian's libonnxruntime1.23 automatically, so this needs no action if you installed that way. Installed some other way? Install libonnxruntime1.23 (or newer) yourself; TrangoPlayer finds it in the usual system library locations without any extra configuration — see ort for why this is a separate runtime dependency rather than bundled.

If a model returns bad or empty analyses

Run with the --debug flag to see exactly what prompt was sent to Ollama and the raw text it returned:

cargo run --release -p trango -- --debug video.mp4 subs.srt

This is the most common way to diagnose a model returning nothing: some reasoning-capable models (e.g. the qwen3 family) can spend their whole generation budget "thinking" instead of answering unless told not to. TrangoPlayer already asks models not to do this, but if a similar issue turns up with a different model, the debug log shows the raw response that failed to parse. See Keyboard shortcuts for more on --debug.

Settings

The gear icon in the top bar opens the Settings screen, showing and editing everything TrangoPlayer remembers between runs in one place.

What's remembered

Stored in a small config file ($XDG_CONFIG_HOME/trango/config.toml, falling back to $HOME/.config/trango/config.toml), written whenever you change one of these:

  • The folder the last video you opened was in — so "Open" starts there next time you're in the Video source.
  • The whisper model you last picked (see Generating subtitles automatically).
  • The Ollama model and target language you last picked (see Word-by-word analysis).
  • The folder your last Audio-source recording was opened from or saved to — new recordings, and "Open" in the Audio source, both default there too (see Playback modes). The Audio source's placeholder panel always shows this folder ("Saving to: …"), and starting a recording into a folder that no longer exists shows an error instead of silently failing.

Every field in the Settings screen is editable, and saves immediately — no separate "Save" button:

  • Video folder, audio recording folder — plain text fields; type a path and it's used from then on.
  • Whisper model, Ollama model, Hebrew niqud model — clicking the current value (or "select a model…") opens a picker dialog rather than typing a path, guaranteeing a valid, absolute path.
  • Word analysis target language — the same field as the Subtitles dialog's language box; editing it in either place updates the other.
  • Audio monitor source — overrides the Audio source's Ctrl+Space recording's autodetection of which PulseAudio/PipeWire "monitor" source captures your system's audio output (normally asks pactl for the default sink). Set this if autodetection picks the wrong device — for example, you have multiple audio outputs and want to record from a non-default one — to the exact source name (check pactl list sources short) to skip autodetection entirely. Empty means "keep autodetecting".
  • Hebrew niqud model (.onnx) — points at a downloaded niqud diacritization model file, used to correct Hebrew pronunciation guides in word analysis. Not set means Hebrew falls back to Ollama's own (less accurate) guess. A new pick only takes effect after restarting TrangoPlayer — the dialog says so once you've picked one.

If this file is missing or unreadable, TrangoPlayer just starts with nothing remembered rather than failing to open — losing a remembered setting is far less disruptive than the app refusing to start.

Locating external tools

whisper-cli and ffmpeg (see Generating subtitles automatically) are found on your PATH by default. If you've installed either somewhere that isn't on PATH, point TrangoPlayer at it directly with an environment variable:

  • TRANGO_WHISPER_CLI_PATH — path to the whisper-cli binary.
  • TRANGO_FFMPEG_PATH — path to the ffmpeg binary, used both for subtitle generation and for the Audio source's system audio capture.

These are environment variables rather than settings inside the app because they're one-time system install paths that rarely change, unlike the model choices above, which you might switch often.

Debug logging

The --debug command-line flag turns on detailed logging, mainly useful for diagnosing word analysis issues. See Keyboard shortcuts for details.

Developer Guide

This section is for anyone working on TrangoPlayer's own source code, rather than just using the app. It covers how the codebase is put together, why specific implementation decisions were made, and the third-party libraries it depends on.

A few other documents outside this book are worth knowing about:

  • The repository root SPEC.md is the original product handoff spec — views, states, and interactions. STYLE.md holds the accompanying visual design reference and design tokens.
  • CLAUDE.md covers the development workflow: TDD, the scripts/ helpers, git workflow, and Rust conventions used throughout the codebase.
  • TODO.md is the step-by-step development roadmap the project was built against.

In this section

  • Architecture — crate structure, the state model, and how video playback is embedded in the UI.
  • Design decisions — a running log of implementation decisions and the bugs/tradeoffs that shaped them, for behavior not already covered by the handoff spec.
  • Technology choices — one page per notable dependency: why it was chosen and how it's used here.

Architecture

TrangoPlayer is a Cargo workspace split into five crates so that most of the business logic — subtitle parsing, cue navigation, Ollama's HTTP/JSON handling, system audio capture — is testable without pulling in the heavier Slint/libmpv dependencies.

  • Crate structure — the five crates, what each one owns, and how they depend on each other.
  • Video playback — how libmpv's render API is embedded inside the Slint window without mpv opening a window of its own.
  • System audio capture — how the Audio source records the system's own audio output, and why it's Linux/ PulseAudio-PipeWire only for now.
  • Testing — what's covered by fast unit tests, what the end-to-end suite exercises, and what's deliberately left to manual testing.

Crate structure

trango is a Cargo workspace with six members, all inheriting version, edition, and rust-version from [workspace.package] in the root Cargo.toml.

crates/subtitle (library)

Cue { index, start, end, text, translation } and SubtitleError (see thiserror). parse_srt(&str) -> Result<Vec<Cue>, SubtitleError> parses .srt content (strips a BOM, normalizes line endings), tested against fixtures in crates/subtitle/tests/fixtures/. merge_translation(original, translation) attaches a translation track by timing overlap, not index — each original cue takes the translation cue with the most overlap — since the two tracks may not have matching cue counts (e.g. a hand-timed original paired with an STT-generated translation). No Slint/libmpv dependency.

crates/playback-state (library)

Depends on subtitle. PlaybackMode (Normal | SentenceBySentence, default SentenceBySentence) and MediaSource (Video | Audio, default Video) are independent enums — which source is active and how navigation behaves are separate choices. PlayerState { mode, media_source, cues, current_cue_index, show_translation }; set_mode(mode)/ set_media_source(source) switch directly to either value.

Cue navigation is pure logic returning a SeekCommand/PlaySpanCommand — "what the player should do" — rather than driving mpv directly: next_cue/previous_cue move the cursor and return a command (None at either end); repeat_current_cue never moves the cursor, always returning the same command for the same cue; jump_to_cue(index) backs the sentence list's row clicks, sharing the same command logic so clicks behave exactly like arrow navigation.

format_time(seconds) -> String formats MM:SS/H:MM:SS, clamping non-finite/negative input to 00:00. sync_cue_to_time(time) finds the latest cue starting at-or-before time, driving Normal mode's live sentence tracking (see Video playback).

No I/O, no UI — TDD'd standalone.

crates/word-analysis (library)

Word-by-word sentence analysis, split out the same way for testability without Slint/libmpv. WordEntry/WordAnalysis (entry.rs) is the data model. cache.rs persists analyses to a JSON sidecar (subs.srtsubs.wordanalysis.json), AnalysisCache { model, entries: HashMap<u32, WordAnalysis> } keyed by Cue::index; a missing/corrupt cache loads as empty rather than erroring. ollama.rs's OllamaClient trait (list_models, analyze_sentence) lets tests swap in a fake instead of a real server; HttpOllamaClient talks to http://localhost:11434 via ureq (GET /api/tags, POST /api/generate with stream: false/format: "json"), defensively stripping a ```json fence some models add. Prompt-building and response-parsing are plain functions tested with canned strings; HttpOllamaClient itself is tested against a local mock HTTP server (TcpListener on a random port).

crates/audio-capture (library)

System audio capture for the Audio source (see System audio capture). AudioCapture runs ffmpeg -f pulse as a subprocess (start/stop, the latter asking ffmpeg to quit gracefully via stdin before falling back to killing it), and default_monitor_source asks pactl for the default sink's matching .monitor source. Same external-process-via-Command pattern as subtitle::WhisperCliGenerator, tested against fake shell-script binaries the same way. No Slint/libmpv dependency.

crates/niqud (library)

Hebrew niqud diacritization and a deterministic Latin pronunciation guide, replacing Ollama's unreliable LLM-guessed pronunciation for Hebrew sentences only (see specs.md's "Hebrew pronunciation" entry and ort). hebrew_detect::contains_hebrew gates the whole pipeline per sentence (Unicode block U+0590-U+05FF) — sentences in other languages never invoke it. tokenizer.rs/decode.rs are pure functions (tokenizing, and reconstructing the diacritized string from a model run's logits) tested against fixtures with no model file needed; onnx_client::OnnxNiqudClient ties them to a real ort::SessionClone, so the loaded model is reused for the process's lifetime rather than per call. No Slint/libmpv dependency, same testability split as word-analysis.

crates/app (binary, package trango)

Ties Slint, libmpv, and the library crates together. Package name trango (binary name), directory crates/app; UI-facing product name is TrangoPlayer.

main.rs initializes tracing, opens the Slint window (app-window.slint), and always calls video_player::VideoPlayer::attach once at startup — even with no CLI video path — because Slint's RenderingSetup notification only ever fires once per window (see Video playback for why this can't be deferred). A CLI video argument starts loading immediately; otherwise the video area stays a placeholder until one is picked via the top bar's "Open" button (open_media_dialog.rs: lists a folder's subfolders/video-or-audio files as rows depending on the active source, auto-matches a same-stem .srt, only file rows are selectable) or a second/third CLI argument (subs.srt, and a translation subs.en.srt merged via subtitle::merge_translation). A subtitle or translation file that can't be read/parsed is logged and skipped rather than blocking video playback.

wire_player_state creates the shared Rc<RefCell<PlayerState>> (UI-thread-only, so no Send/Sync needed) and wires select-mode/toggle-translation to PlayerState's methods, mirroring the result back into AppWindow properties the top bar/translation toggle read directly.

Why six crates instead of one

Splitting subtitle, playback-state, word-analysis, audio-capture, and niqud out of the binary keeps most business logic testable without Slint/libmpv, and keeps files small (CLAUDE.md: aim for ~200 lines/file).

Video playback (libmpv render-API embedding)

crates/app/src/video_player.rs (+ gl_proc_address_bridge/ gl_video_surface submodules) embeds libmpv video playback inside the Slint window, without mpv creating a window of its own. See libmpv2 for the crate choice.

Mechanism

  1. Mpv::with_initializer creates an mpv core with vo=libmpv (output only via the render API) and keep-open=yes (stays loaded and paused on the last frame at EOF — see below).
  2. VideoPlayer::attach registers a closure via slint::Window::set_rendering_notifier, called at RenderingSetup, BeforeRendering, AfterRendering, RenderingTeardown.
  3. On RenderingSetup (fires once, on the window's first rendered frame), setup_render_context creates an mpv::render::RenderContext sharing Slint's GL context, wires mpv's "frame ready" callback to request a Slint redraw, and issues loadfile.
  4. On every BeforeRendering, render_frame draws the current video frame into an offscreen surface sized to the video frame box, then blits it into the window's own framebuffer — Slint paints its own scene on top afterward.

Why the video shows through

Slint doesn't clear-then-redraw the whole backbuffer each frame — whatever was already there stays visible wherever Slint paints nothing opaque. app-window.slint's root Window has no background, and video-frame (the video column's Rectangle) is only filled before a video loads; every other element (top bar, scrub bar, sentence panel) keeps its own opaque background. So with a video loaded, mpv's BeforeRendering draw is the only thing painting video-frame's box.

Confining mpv to the video frame box

mpv's render call always draws at (0, 0) of the given framebuffer, scaled to fill it — there's no way to offset it into a sub-rectangle of a larger, already-bound framebuffer. render_frame (gl_video_surface.rs) works around this by rendering mpv into its own offscreen texture-backed VideoSurface, sized to video-frame's current on-screen box (read off AppWindow's video-frame-x/-y/-width/-height properties, converted to physical pixels), then glBlitFramebuffers that into the real framebuffer at the right position (flipping Y, since Slint measures from the top and OpenGL's blit destination is bottom-left-origin). The surface is only recreated when its physical size changes. GlFns resolves the handful of FBO/blit GL functions needed once, at RenderingSetup; if that fails (no FBO support), render_frame falls back to filling the whole window rather than not rendering at all.

Rounding video-frame's corners to match the design mock is a separate, still-open step (would need stencil/scissor clipping of the blitted rectangle). This box-confinement only works if video-frame's own layout box actually grows with the window — see app-window.slint's content column's width: 100%; height: 100%;.

Scrub bar: polling, not events

attach starts a slint::Timer (SCRUB_BAR_POLL_INTERVAL, 33ms) that reads mpv's time-pos/duration via Mpv::get_property, formats them with playback_state::format_time, and writes current-time-label/ duration-label/scrub-progress onto the window. Plain polling was chosen over mpv's observe_property API since two properties on a fixed interval don't need a second event source, and Timer callbacks already run on the UI thread (no invoke_from_event_loop handoff needed). Before mpv starts decoding, both properties return Err, treated as 00:00/0.0 rather than an error.

Current-sentence card: syncing to time-pos

The same timer tick calls sync_current_sentence, which — only in SentenceBySentence mode — moves current_cue_index via PlayerState::sync_cue_to_time and refreshes the current-sentence card and sentence list (only rebuilding the list when the index actually changed). In Normal mode it's a no-op; Normal mode has its own separate live-sync mechanism (sync_current_sentence_normal_mode, see Design decisions). Cue lookup itself is plain Duration arithmetic, unit-tested in playback-state without mpv/Slint.

Keyboard navigation and the sentence list

key-pressed on the root FocusScope handles Ctrl+T unconditionally (purely visual); Right/Left/Space only act while sentence-mode-active. All three, plus sentence-list row clicks (jump-to-cue), funnel through apply_navigation_result (main.rs): run the PlayerState navigation method, mirror the resulting cue into the sentence card/list regardless of outcome, then hand any SeekCommand to VideoPlayer::apply_seek_command — which issues mpv's seek ... absolute, unpauses, and (if the command has an end) arms pause_at, cleared by the poll timer's apply_pending_pause once time-pos reaches it (there's no mpv "play until timestamp" command). Sharing one code path is what makes row clicks behave identically to arrow-key navigation. SentenceListCard scrolls the current row into view itself, in Slint, via a changed current-index handler.

Starting paused at the first cue

PlaybackMode::default() is SentenceBySentence, so a freshly loaded video needs to start paused at the first cue rather than playing immediately. Right after loadfile, pause_and_arm_start_seek pauses mpv (safe pre-load) and — only with cues loaded in SentenceBySentence mode — records the first cue's start as pending_start_seek. The seek itself can't happen in the same call (seek right after loadfile fails with Raw(-12), the core still being idle) — it's deferred to apply_pending_start_seek, run on the poll timer once time-pos becomes readable.

EOF leaves the core idle unless keep-open is set

Without keep-open=yes, mpv unloads the file entirely at EOF, returning to the same idle state that rejects every seek with Raw(-12) — permanently breaking Space/navigation/scrub-bar for the rest of the session once a video played to its end. keep-open=yes fixes this at the source; the subtitle-generation reload workaround (see Design decisions) predates this fix and is now a bonus (re-arming the start-of-playback seek) rather than the only recovery path.

Staying loaded-but-paused at EOF still means unpausing without seeking does nothing (time-pos immediately re-hits the same EOF). Normal mode's/Audio's unbounded VideoPlayer::toggle_playback checks mpv's eof-reached property and seeks back to 0 first when set, so Space replays from the start instead of looking like a no-op once a file has played through.

attach always runs at startup

attach is called exactly once, unconditionally, right after AppWindow is created — even with no CLI video path — because RenderingSetup fires once per window, on its first rendered frame, not once per set_rendering_notifier call. An earlier, lazy version (attaching only once a video was picked via the dialog) broke the no-CLI-argument path: by the time a user picked a file, several frames (the dialog's own UI) had already rendered, RenderingSetup had already fired-and-gone, and the render context — and its loadfile — never got created, permanently idling the core.

load_file (used by both attach's own startup load and the public VideoPlayer::load_video, called from open_selected_media when a video is picked later) handles the loadfile call, video-loaded, and the sentence-by-sentence start-seek arming either way. open_selected_media always resolves subtitles before calling load_video, since the start-pause needs player_state.cues to already reflect the new video, not the previous one.

Why this needs manual/visual testing

None of this is meaningfully unit-testable: the render path only exists with a real OpenGL context (a real windowing backend + display connection, not guaranteed in CI), and correctness is about pixels actually appearing on screen — render() returning Ok(()) says nothing about whether a frame was ever actually visible. This was confirmed the hard way once (an early screenshot showed a plain dark frame with no errors logged, indistinguishable from "not rendering" until content — the video's own subtitles/overlay — was compared against later screenshots). This step, and the rest of the video/UI integration work, is verified by compiling, running trango against a real video, and looking at the window.

System audio capture

crates/audio-capture's AudioCapture captures the system's own audio output for the Audio source's Ctrl+Space shortcut (TODO.md Vaihe 26) — the foundation for live subtitle generation without a loaded video (see docs/src/developer/specs.md's "Audio source: system-audio capture" for why this captures locally playing audio instead of downloading/scraping from a source like YouTube).

How it works

ffmpeg -f pulse -i <monitor-source> -ar 16000 -ac 1 <output-path> runs as a subprocess, the same external-process pattern subtitle::WhisperCliGenerator uses for whisper-cli/audio extraction — no new Cargo dependency. ffmpeg writes a single 16kHz mono WAV file straight to output-path — the format whisper-cli reads directly, matching extract_audio's own settings, so a later "Generate subtitles" pass (TODO.md Vaihe 29) needs no separate extraction step. pactl get-default-sink finds the system's default output device; PulseAudio/ PipeWire's <sink>.monitor naming convention gives the matching input source that captures whatever that sink is currently playing, rather than a microphone. AudioCapture::stop asks ffmpeg to quit gracefully by writing q to its stdin before falling back to killing it after a timeout — killing it outright would leave the WAV header's size field wrong, since ffmpeg only finalizes it on a clean exit.

Linux/PulseAudio-PipeWire only

pactl and ffmpeg -f pulse have no equivalent wired up on Windows or macOS — this is an explicit exception to trango's usual std::process::Command-based approach working identically on both platforms (CLAUDE.md), since audio capture is far more platform-specific than running an external CLI tool. Windows (WASAPI loopback) and macOS (Core Audio, which has no built-in loopback device) would each need their own capture mechanism entirely. Not implemented; revisit if trango needs to support those platforms.

Autodetection can also be wrong for setups with multiple audio outputs — crates/app/src/config.rs's audio_monitor_source overrides it with an exact source name (see docs/src/usage/settings.md).

Recording, not live transcription

crates/app/src/system_audio_capture.rs toggles AudioCapture start/stop — no per-segment processing happens while a recording is in progress. Each recording gets a default <date>_<time>.wav filename (local time), written into config.rs's audio_recording_folder (the last folder used, falling back to the current working directory the first time). The filename is locked while recording; once stopped, editing the Audio panel's filename field and pressing Enter renames the file on disk (rejecting anything that isn't a plain filename, so it can't be moved outside the recording folder). TODO.md Vaihe 28 onward add opening/ playing a finished recording and (Vaihe 29) a "Generate subtitles" pass over it, reusing the same WhisperCliGenerator path video files already use.

Testing

Unit tests

Every crate carries its own #[cfg(test)] mod tests alongside the code they cover (crates/subtitle, crates/playback-state, crates/app), plus crates/subtitle/tests/srt_parsing.rs reading real .srt fixtures from crates/subtitle/tests/fixtures/. These stay fast and isolated: no libmpv core, no real video file, and no window ever shown on screen — crates/app's tests do construct a real AppWindow, which alone needs a windowing backend (see slint), satisfied in CI via xvfb-run.

E2E: crates/app/tests/e2e_sentence_navigation.rs

The first end-to-end test, added in Vaihe 13. Unlike the unit tests above, it exercises real subtitle parsing and real cue navigation together, against the checked-in fixtures in test-media/sample/ (see test-media/README.md) instead of hand-built Cue literals:

  • parse_srt reads and parses the real sample.srt file from disk.
  • The resulting Vec<Cue> is loaded into a real PlayerState, then walked forward with next_cue() to the last cue, back with previous_cue() to the first, and finally repeat_current_cue() is called twice — at each step the cursor position and the returned SeekCommand's start/end are checked against the fixture's actual timings, not fabricated values.
  • A separate test confirms the paired sample.mp4 video fixture exists on disk and is non-empty, tying the video file to the subtitle track this suite exercises.
  • Two more tests (TODO.md Vaihe 30) repeat the cue-navigation and sync_cue_to_time walks with PlayerState::media_source set to Audio, confirming cue-based features never depended on a video actually being loaded — see docs/src/developer/specs.md.

What this suite deliberately does not cover

  • libmpv rendering/decoding. The E2E test never opens sample.mp4 through mpv or drives video_player::VideoPlayer. docs/src/developer/architecture/ video-playback.md explains why: the render path only exists once Slint has a real OpenGL context backed by a real windowing/display connection, which isn't guaranteed available where cargo test runs, and correctness there is about pixels actually appearing on screen — something cargo test has no way to observe. That verification stays manual (cargo run -p trango -- test-media/sample/sample.mp4).
  • Pixel-level UI/screenshot testing. No screenshot comparison against sketch/design_reference.dc.html is automated at this stage (see TODO.md Vaihe 22, done manually).
  • The Slint window itself. crates/app/src/main.rs's own tests already cover AppWindow property wiring (e.g. sentence-mode-active); the E2E suite here stays below that layer, at the subtitle/playback-state level.

scripts/test.sh runs this suite as part of the normal workspace test run — no separate invocation is needed.

Design decisions

Implementation decisions for app behavior beyond SPEC.md's handoff spec — either left open there, or found through real usage/testing.

Open Video dialog: folder navigation

Opens on a default folder (CLI video's parent, else config.toml's remembered video_folder, else cwd) but isn't limited to it — an "‥ Up" row and subfolder clicks navigate in place (open_media_dialog::list_folder_entries). Chosen over a native OS picker to stay consistent with SPEC.md's "no OS-native file picker" direction.

Open Subtitles dialog: no OS drag-and-drop

SPEC.md specs the translation link as an OS drag-and-drop target, but Slint 1.17.1's winit backend doesn't relay external file drops to DropArea at all (see slint). Instead, a small in-app file picker (FileListDialog, shared with the Open Video dialog) scoped to the video's own folder's .srt files links a translation, re-merging cues immediately. SPEC.md's "(DE)"/"(EN)" language-code labels are generic "Original subtitle"/"Translation" instead, since trango doesn't track subtitle language.

Subtitle generation: stub, then whisper-cli

The subtitle crate's SubtitleGenerator trait (fn generate(&self, video_path) -> Result<PathBuf, SubtitleError>) captured the shape before any STT dependency was added (StubSubtitleGenerator wrote a fixed placeholder cue). WhisperCliGenerator (crates/subtitle/src/generate.rs) now runs whisper.cpp's whisper-cli binary as an external process (std::process::Command) rather than a Rust binding crate like whisper-rs — no new Cargo dependency, and -osrt already writes a ready-made .srt. Tests fake the binary with small POSIX shell scripts standing in for its -of/-osrt contract.

Audio extraction via ffmpeg. whisper-cli only reads raw audio (flac/mp3/ogg/wav), not video containers — and silently exits 0 while writing nothing when given one. WhisperCliGenerator::generate now extracts the video's audio to a temp 16kHz mono WAV with ffmpeg first, then runs whisper-cli against that. extract_audio and run_whisper_cli are separately testable against fake binaries; run_command retries briefly on ETXTBSY (freshly written test binaries occasionally racing exec).

TODO.md Vaihe 29 reuses this same generate for the Audio source's "Generate subtitles" (same button/dialog as the Video source — no new call site was needed, since Vaihe 28 already generalized CurrentMedia to hold either a video or a recorded/opened .wav path). generate skips extract_audio when its input is already a .wav — the only extension the Audio source ever loads — and hands it to whisper-cli directly.

Background thread, not the UI thread. Real transcription can take minutes; spawn_generate runs it on std::thread::spawn, reporting back via slint::invoke_from_event_loop (state behind Rc/RefCell isn't Send, so only a Weak<AppWindow> + owned Result cross the thread boundary — mirroring video_player.rs's load_file).

Model selection: UI + autodiscovery, persisted to TOML

Replaced an env var (TRANGO_WHISPER_MODEL_PATH) with an in-app picker, since models are switched more often than the CLI binary path is — the Open Subtitles dialog's model row opens a FileListDialog scoped to .bin/.gguf files (model_picker.rs). default_start_folder tries the config's remembered folder, then a few well-known whisper.cpp model locations, then cwd — no OS-specific magic. The pick persists to config.rs's $XDG_CONFIG_HOME/trango/config.toml (trango's first persistent settings file, added with user approval per CLAUDE.md). model_picker::language_flag infers -l en vs -l auto from whisper.cpp's .en filename convention. Smaller models transcribe non-English audio much worse than English — usage docs recommend medium/large-v3 for anything else.

Generating subtitles for an open video reloads it

(Superseded as the sole fix by keep-open=yes — see Video playback — but still done, since it also re-arms the sentence-by-sentence start seek.) Generating subtitles for an already-playing video can let it reach EOF mid-generation, leaving mpv's core idle and every subsequent seek failing (Raw(-12)). Fix: after linking the generated subtitle, wire_open_subtitles_dialog also reloads the video via VideoPlayer::load_video. Since a real VideoPlayer can't be constructed in main.rs's tests, the handler takes a reload_video closure instead of the player directly, so tests can assert the reload without a real mpv instance.

No mode autoplays — only Space starts/stops playback

Initially every navigation action (next_cue/previous_cue/ jump_to_cue/repeat_current_cue) auto-played through to the cue's end and paused. This broke replay: real STT output commonly produces contiguous cues (cue N's end == cue N+1's start), so the moment mpv auto-paused at N's end, sync_current_sentence immediately reclassified the cursor onto N+1, and Space then replayed the wrong sentence.

Fix: navigation only seeks and leaves mpv paused; Space is the only thing that starts/stops playback, as a toggle. This needed a type split in playback-state: SeekCommand { start } (navigation) vs. PlaySpanCommand { start, end } (repeat). Whether a span should start or interrupt playback needs live mpv state, so that decision moved to video_player.rs's toggle_play_span. sync_current_sentence was further restricted to only re-derive the cursor while pause_at is actually armed, so a paused cursor never gets silently reclassified — and pause_and_arm_start_seek now unconditionally pauses on load, only conditionally arming a start-of-playback seek, so a video with no subtitle also opens paused.

sync_current_sentence removed entirely

The pause_at-gated fix above still had a same-tick race: sync_current_sentence and apply_pending_pause run in the same poll tick, sync first — so on the tick time-pos reaches a cue's end, pause_at hasn't cleared yet and the cursor still gets reclassified onto the contiguous next cue before the pause lands. Since every play action is now a bounded, already-known-cue span (toggle_play_span), there was no remaining case needing live rediscovery of the cursor from time-pos — so the function, its poll call, and PlayerState::sync_cue_to_time were deleted outright rather than patched again. (Both Normal mode continuous playback and scrub-bar dragging later needed the same kind of live tracking — see "Normal mode: live time-pos syncing" below — but were designed fresh against their own seek model, not by reviving this.)

Space works in every mode

Right/Left/Space were all gated behind sentence-mode-active, a leftover from before autoplay-on-open was removed. Right/Left stay gated (no Normal-mode cue navigation exists), but Space now works unconditionally: repeat_current_cue returning Some (a cue in focus, SentenceBySentence) plays that cue's bounded span via toggle_play_span; returning None (Normal mode, or no subtitle) instead calls the new unbounded VideoPlayer::toggle_playback. First pass missed that current_cue_index is set regardless of mode, so a subtitle linked while in Normal mode wrongly routed Space to the bounded path — fixed by adding a mode check directly to repeat_current_cue itself.

Word analysis: local Ollama, not a cloud API

Word-by-word translation + pronunciation for the on-screen sentence uses Ollama (localhost:11434) for the same reason whisper-cli was chosen for subtitle generation: no upload, no per-call cost, on-device. Ollama is an external program, not a Cargo dependency.

  • Crate split: HTTP/JSON/cache logic lives in crates/word-analysis, free of Slint/libmpv, mirroring subtitle/playback-state. The app-local wiring module crates/app/src/word_analysis.rs shadows the extern crate word_analysis at main.rs's crate root; call sites needing the crate use a leading ::word_analysis::....
  • HTTP client: ureq, not reqwest — no async runtime elsewhere in trango; see ureq.
  • Prompt/response: build_prompt asks for {"words": [{"word", "translation", "pronunciation"}]} with Ollama's format: "json" and stream: false; parse_analysis_response strips a defensive ```json fence some models still add.
  • Cache: one JSON sidecar per subtitle (subs.srtsubs.wordanalysis.json), keyed by Cue::index (AnalysisCache { model, entries }) so a shifted line doesn't reuse a stale entry. A missing/corrupt cache becomes empty rather than an error. Ctrl+A (single sentence) and "Analyze all sentences" (batch, saving incrementally after every cue so a stopped run loses no progress) share this same file.
  • Model + target language: an "Ollama model" row reuses the FileListDialog chrome, backed by a network call (GET /api/tags) run on a background thread. Target language is free text (not a fixed list, per user preference — trango's first LineEdit), saved to config on every keystroke, defaulting to "English" only in code, not in TrangoConfig::default().

Word analysis: "think": false, and debug logging

Reasoning models (e.g. the qwen3 family) can spend their whole generation budget on internal "thinking" and return an empty response, which serde_json fails to parse with a confusing zero-length-input error. GenerateRequest now sets "think": false; analyze_sentence also checks for an empty response explicitly, returning a clear OllamaError::InvalidResponse instead of forwarding the parse error.

Diagnosing this needed the raw prompt/response logged at tracing::debug!, which surfaced that tracing-subscriber's env-filter feature had never actually been enabled — RUST_LOG filtering had silently never worked. Rather than just enabling it and relying on RUST_LOG, the user asked for a CLI flag instead (per CLAUDE.md's env-var-vs-flag convention): --debug (extract_debug_flag) now builds a fixed "info,trango=debug,word_analysis=debug" filter; RUST_LOG still works underneath as a finer-grained escape hatch.

Normal mode's hint bar content

The bottom HintBar used to be gated behind sentence-mode-active entirely, so Normal mode showed no shortcut reminders even though Space/Ctrl+T/Ctrl+A already worked there. HintBar now takes sentence-mode-active as an input and shows a mode-dependent subset of the same five entries (Right/Left only in SentenceBySentence; Space's label switches between "repeat sentence"/"play-pause"), always instantiated. The five near-identical labels were factored into a HintLabel sub-component.

Scrub bar drag-to-seek

A TouchArea (24px tall, taller than the 4px visible track) inside ScrubBar computes the pointer's fraction across the track on clicked/moved-while-pressed, firing seek-requested(float)video_player::VideoPlayer::seek_to_fraction. Unlike cue-navigation seeks, this never touches pause (a drag shouldn't start or stop playback) and clamps the fraction (which can overshoot 0.0..1.0 on an out-of-bounds drag) in a small pure, unit-tested seek_target_secs helper. It clears any armed pause_at, same as other seeks.

Normal mode: live time-pos syncing

The last open item: Ctrl+A in Normal mode showed a stale sentence once playback moved past whatever cue was current when the mode was entered, since nothing re-derives current_cue_index from live time-pos outside SentenceBySentence. Designed fresh rather than reviving the removed sync_current_sentence (see above): PlayerState::sync_cue_to_time returns whether the cursor changed; video_player.rs's new sync_current_sentence_normal_mode (a no-op outside Normal mode) runs on every poll tick and mirrors a changed cue into the sentence card/list. Normal mode never arms pause_at, so the same-tick race that killed the original mechanism structurally can't happen here. Scope: only live tracking — whether the sentence panel should even show in Normal mode is still open.

Current-sentence card: bounded, scrollable sentence text

The original-language Text had no bounded height, so CurrentSentenceCard's vertical-stretch: 0 asked its VerticalLayout parent for exactly the wrapped text's natural height — whenever the sentence panel column ran short on room, the layout could squeeze the card below that, clipping the bottom line(s) instead of showing or scrolling to them. Fixed the same way translation-height already fixes the same class of bug for the translation line below it: a fixed-height ScrollView (sentence-height, 150px ≈ 4 lines) so long sentences scroll instead of clipping. This does not fix mixed-script bidi rendering glitches — see "Known limitation: bidi text wrapping" below, which turned out to be the actual cause of the originally reported bug report this investigation started from.

Known limitation: bidi text wrapping (Slint/femtovg)

A cue mixing Hebrew (RTL) with an embedded Latin word (e.g. "co-working") renders garbled characters at the line-wrap boundary when the Latin word falls across a wrap point — not a height/clipping issue (ruled out above), but character-level bidi reordering going wrong in Slint's text shaping. video_player.rs requires Slint's OpenGL (femtovg) renderer for the mpv render context, so switching renderer isn't an available workaround. Slint's RTL/bidi support is itself incomplete upstream — see slint-ui/slint#2294 and #7267. Accepted as a known limitation for now; no in-repo workaround attempted. Revisit if Slint's bidi support improves, or if this affects enough real subtitle content to justify manually inserting Unicode directional-isolate marks (U+2066/U+2069) around embedded Latin runs before handing cue text to sentence_card.rs.

Audio source: system-audio capture, not YouTube download/caption scraping

Live subtitle recording without a video (TODO.md Vaihe 25–31) needs some source of audio/text to transcribe. Two alternatives were considered and rejected for copyright reasons: playing/downloading the source video directly (e.g. via yt-dlp + mpv's ytdl_hook), and scraping a site's already-generated captions (e.g. yt-dlp --write-auto-sub --skip-download). Both would have trango fetch copyrighted content from a third party. Instead, Vaihe 26 onward capture the system's own audio output — whatever is already playing locally, from any source — and never persist more than the resulting .srt; no video/audio file trango didn't already have is ever downloaded or saved.

System audio capture: pactl's default-sink monitor, graceful ffmpeg stop

TODO.md Vaihe 26 needed a monitor source to feed ffmpeg -f pulse -i. Rather than parsing pactl list sources for whichever ones end in .monitor (several, if multiple outputs exist — ambiguous to pick between), AudioCapture::default_monitor_source asks pactl get-default-sink and appends .monitor itself, since PulseAudio/ PipeWire guarantee that naming convention. config.rs's audio_monitor_source overrides this for setups where the default sink isn't the one to capture.

Killing ffmpeg outright (SIGKILL) leaves the WAV header's size field wrong, since ffmpeg only finalizes it on a clean exit. AudioCapture::stop instead writes q to ffmpeg's stdin — the same key it reads interactively to quit gracefully — and only falls back to kill() after graceful_stop_timeout (a test-injectable field; production uses 5s).

A missing pactl/ffmpeg install only showed up in the log (usually invisible to a user running the packaged app), making Ctrl+Space look like it silently did nothing. system_audio_capture::wire_audio_capture now also mirrors every start/stop outcome into audio-capture-error-message (AppWindow property, shown in the Audio source's placeholder), cleared on success — a small, targeted piece of Vaihe 29's UI pulled forward, without building the full rec/stop control it also adds.

MediaSource, split out from PlaybackMode

Which source is active (video file vs. audio) and how navigation behaves (Normal vs. Sentence by sentence) are independent choices, so a single PlaybackMode enum can't express both — a three-way mode would have no way to select "audio source" and "Sentence by sentence" together. playback_state::MediaSource (Video/Audio) exists alongside the original two-variant PlaybackMode; PlayerState holds both fields independently. The top bar mirrors this with two separate segmented-control groups — Video/Audio and Normal/Sentence-by-sentence — rather than one combined control (not in the mock, sketch/design_reference.dc.html#1c, which only showed the mode pair). The video area's Rectangle stays unconditionally instantiated in the Audio source too (so video-frame-x/-y/-width/-height, read every frame by video_player.rs, keep resolving) — the Audio placeholder is an overlay child inside it, not a swapped-out sibling.

System audio capture reverted to a single WAV file, not live segmentation

An earlier version of Vaihe 26 had ffmpeg stream raw PCM to its stdout so a VadSegmenter could chop it into speech segments for per-segment whisper-cli transcription, growing the sentence list live. That approach (webrtc-vad, vad.rs, live_transcription.rs) was removed: it added real complexity (FFI, a non-Send VAD instance, a channel draining onto the UI thread) for transcription quality no better than running whisper-cli once over the finished recording. AudioCapture now just has ffmpeg write directly to a WAV file; TODO.md Vaihe 29 runs "Generate subtitles" over that file as a whole, the same WhisperCliGenerator path video files use.

Recording filename: chrono dependency, rename only after stop

TODO.md Vaihe 27's default filename needs a local (not UTC) date+time — the std library has no timezone-aware formatting for SystemTime, so chrono was added to crates/app rather than hand-rolling civil-date math. The filename is locked while a recording is in progress and only renamable afterwards, matching a normal recorder's behavior; the rename handler rejects any value that isn't a single plain path component so a pasted value containing / or .. can't move the file outside its recording folder.

Validated: cue-based features never depended on video

TODO.md Vaihe 30 asked whether sentence list, Ctrl+A word analysis, and the translation toggle secretly assumed a video was loaded. They don't: sentence_card.rs/sentence_list.rs/word_analysis.rs and playback_state::PlayerState's cue navigation only ever read cues/current_cue_index/show_translation, never MediaSource or anything video-specific — confirmed by grepping for MediaSource outside main.rs's own source-selection code and video_player.rs. No code changed; crates/app/tests/e2e_sentence_navigation.rs and main.rs's test_app_window_properties gained tests that switch to the Audio source mid-run and repeat the same navigation/Ctrl+A/sentence-list assertions, locking the guarantee in against regressions.

Source switch pauses playback and gates controls by the loaded file's kind

Video and Audio share one video_player::VideoPlayer/mpv instance — the top bar's source buttons only ever swapped which panel was visible, never touched playback. That meant switching sources left whatever was playing running audibly behind the hidden panel, and a loaded video's ScrubBar could appear in the Audio panel (or its picture show through) just because some file happened to be loaded, regardless of kind. Fixed two ways: the Video/Audio segment buttons now call a new pause-playback callback (video_player::VideoPlayer::pause()) before select-media-source, and AppWindow::media-ready (video-loaded && loaded-media-source == media-source) gates the mpv underlay/ScrubBar/SpeedSlider/Audio placeholder so they only activate once the actually-loaded file's kind matches the visible panel. loaded-media-source is set in open_selected_media, the single choke point both the Open dialog and the post-recording auto-load go through. Two independent player "slots" (each source remembering its own loaded file/position) was considered and rejected as unnecessarily large for the actual complaint — the one shared mpv instance never caused problems the previous UI just failed to gate against.

Sentence card/list and Ctrl+A also gated by panel_content_ready

Once playback controls were gated by media-ready above, the same complaint showed up one layer up: switching to a not-yet-loaded Audio panel left the Video source's current-sentence card and sentence list sitting on screen untouched, and Ctrl+A would still analyze that stale sentence — the "Validated: cue-based features never depended on video" decision above had made this a deliberate, tested guarantee (switching source never touches cues), which now reads as the bug rather than the fix. Revised: main.rs's panel_content_ready (same media-source != Audio || media-ready condition as the Slint gate) decides what the sentence card/list display, blanking it to an empty PlayerState's placeholder in on_select_media_source when the newly-selected panel isn't ready, and restoring the real one when switching back to a source that is. The Ctrl+A handler checks the same condition before reading current_cue_index, so it reports "No sentence is currently in focus" rather than reusing the other source's cache entry. PlayerState.cues itself is never touched — only what's displayed/analyzed — so cue navigation's source-independence (the e2e_sentence_navigation.rs guarantee) still holds unchanged; only the two Slint-facing consumers that read the current cue for on-screen display now also check which panel is showing it.

CI: PR checks and .deb release automation

Pull requests against master run .github/workflows/ci.yml: fmt + clippy (scripts/check.sh), the test suite (scripts/test.sh), and a release build, as three separate jobs so failures are easy to tell apart.

.github/workflows/release-deb.yml builds and publishes a .deb as a GitHub Release whenever master's workspace Cargo.toml changes — in practice, every merged PR, since versioning bumps on every commit. A check-version job guards against re-publishing a version that already has a release (e.g. a Cargo.toml change that touched something other than the version field). Packaging uses cargo-deb, configured via [package.metadata.deb] in crates/app/Cargo.toml; runtime Depends are left at the default $auto so dpkg-shlibdeps derives them from the built binary's actual shared-library links instead of being hand-maintained.

Hebrew pronunciation: native ort inference, not the Ollama prompt

Ollama's own pronunciation field is unreliable for Hebrew even with a Hebrew-capable model — small LLMs mistransliterate niqud/dagesh distinctions (e.g. שכב → "shkach" instead of "sha-khav"), and re-feeding niqud text through the LLM wouldn't fix this: BPE tokenization splits Hebrew combining diacritics unpredictably, so the same unreliability just moves one step later. Instead crates/niqud's OnnxNiqudClient runs Phonikud's niqud model directly via ort (ONNX Runtime bindings), and a deterministic Rust table (transliterate.rs) converts the resulting niqud text to a hyphenated Latin guide — no further LLM call. Gated automatically by contains_hebrew (Unicode block U+0590–U+05FF); other languages are untouched. Ollama still handles translation (a real semantic task) and its pronunciation guess is kept as a fallback if no niqud model is configured, loading fails, or the word counts don't align (tracing::warn, never a hard failure).

Native Rust, not a Python subprocess. An earlier version shelled out to a Python/phonikud-onnx CLI wrapper; it worked but accumulated real operational hackiness (a venv whose activation state depends on whatever shell happens to launch trango, on top of CPU-pinning/offline-mode workarounds). Reimplementing natively turned out tractable because the model's I/O contract and the dicta-il/dictabert-large-char-menaked tokenizer were both fully reverse-engineered during that first implementation: despite being stored in HuggingFace's "WordPiece" format, the tokenizer is actually character-level (its pre_tokenizer splits into individual characters first, so no subword merging ever happens) — a flat char→id vocab parsed straight from tokenizer.json is enough, no tokenizers crate dependency needed. decode.rs ports phonikud_onnx's Python reconstruction loop (argmax over nikud_logits/shin_logits, threshold over additional_logits's stress/vocal-shva/prefix classifiers) directly.

Pitfalls found in the model's output that both the decode loop and the transliteration table depend on: beyond standard nikud/dagesh/shin-dot marks, the model also emits a | (U+007C) after prefix letters (ו/ב/כ/ל/מ/ש) marking a morpheme boundary, and a meteg (U+05BD) combined with shva distinguishes vocal shva ("e", pronounced) from silent shva — both undocumented in Phonikud's own API but load-bearing for correct syllabification.

Build vs. runtime linking. ort's default download-binaries feature fetches a prebuilt ONNX Runtime binary at compile time over the network — unacceptable for offline/CI builds. crates/niqud/Cargo.toml instead uses load-dynamic (loads libonnxruntime.so at runtime) plus api-23: the crate's default feature set requests API 24, which hangs indefinitely (not a clean error) against Ubuntu's apt-packaged libonnxruntime1.23 — api-23 works correctly against that same package, confirmed by comparing its output against Python onnxruntime's for identical input (matching to a few significant digits; a newer runtime like pip's onnxruntime 1.27 matches exactly).

No ORT_DYLIB_PATH needed for a normal install: crates/app/Cargo.toml depends on libonnxruntime1.23 directly ($auto/dpkg-shlibdeps can't detect it, since load-dynamic means no link-time ELF reference exists for it to find), and crates/niqud/src/dylib.rs scans the usual Debian/Ubuntu library directories at runtime for a match — see ort for the hang-avoidance details this needed. Model/tokenizer files are still a manual download (accepted tradeoff, not automatable the way the library dependency is — too large to bundle in the .deb) — see docs/src/usage/word-analysis.md.

GPU checked, CPU kept deliberately. onnxruntime's CUDA provider silently falls back to CPU if system cuDNN is missing (no hard error — easy to miss). Measured explicitly on real hardware (RTX 5070 Ti): inference is already ~16ms on plain CPU for this int8 model, so GPU wouldn't help. OnnxNiqudClient requests CPUExecutionProvider explicitly (not a silent default).

Hebrew prefix particles: a parts breakdown, not split top-level entries

Real use surfaced two problems with Hebrew's single-letter prefix particles (ו/ה/ב/כ/ל/מ/ש, written attached to the following word with no space, e.g. לסרטים = ל + סרטים). A first attempt asked Ollama to always split such a word into two separate top-level word entries (own "word"/"translation"/"pronunciation" each). That was wrong on two counts:

  • Not how it sounds. A prefixed word is pronounced as one fused unit in speech (e.g. "לסרטים" as "le-sratim"), not two separate sounds — but the user wants exactly that fused pronunciation, matching what's actually heard, not a per-morpheme guess.
  • Broke niqud alignment. apply_niqud_pronunciation's word-count check compares Ollama's word list against niqud's own, which only ever splits on whitespace — so splitting a prefixed word into two Ollama entries mismatched niqud's one, and the mismatch fallback (keep Ollama's own pronunciation guess) applied far more often than it should have.

Fixed by keeping "word"/"pronunciation" as the whole combined form (restoring niqud's 1:1 alignment as a side effect — no changes needed to the niqud pipeline itself) and moving the morpheme breakdown into a new optional "parts" array on each WordEntry (word-analysis/src/ entry.rs's WordPart, #[serde(default, skip_serializing_if = "Vec::is_empty")] so it's absent from JSON for the overwhelming majority of words that have nothing to break down). ollama.rs's HEBREW_PREFIX_GUIDANCE (gated on a contains_hebrew check duplicated from niqud rather than adding a crate dependency for one predicate) asks for this with a concrete worked example. The Ctrl+A popup (WordAnalysisRow's parts-label) shows the breakdown as a small second line under the translation, e.g. "ל = to · סרטים = movies", only when non-empty.

Hebrew prefix particles: merging by niqud's boundaries, not by exact text

The prompt guidance above isn't followed consistently — real captured output for one sentence correctly fused one prefixed word but still split two others into separate top-level entries in the same response. An earlier fix compared Ollama's and niqud's word lists via an LCS match on exact text equality, correcting pronunciation wherever both sides matched verbatim. That can't fix a split word: a split fragment's text ("ו", "אמר") never equals the fused token niqud returns ("ואמר"), so it stayed both visually split and mispronounced.

hebrew_word_merge::merge_by_niqud_boundaries (crates/app/src/ hebrew_word_merge.rs) fixes this instead by trusting niqud's whitespace-only tokenization as the word boundary, and growing a window of consecutive Ollama entries (smallest first) until their concatenated text matches niqud's current word — merging whichever entries were consumed into one WordEntry, joining their translations with a space and rebuilding parts from them. Runs once, inside apply_niqud_pronunciation, before the analysis is cached — the Ctrl+A popup and cache file only ever see the already-reconciled result.

Hebrew word analysis: niqud's word list feeds the Ollama prompt, not the other way around

Even with the merge above, Ollama's own word count kept drifting from niqud's in real use (e.g. logged as ollama_words=31 niqud_words=30 on a real subtitle line) — asking a token-based LLM to reproduce an exact word count/order for a sentence it segments itself is inherently unreliable, no matter how the prompt wording is tuned.

word_analysis::analyze_sentence (crates/app/src/word_analysis.rs) now calls niqud before Ollama for a Hebrew sentence, and passes its whitespace-split words to OllamaClient::analyze_words (crates/ word-analysis/src/ollama.rs) as a fixed JSON array the model fills in translation/pronunciation/parts for — never asking it to decide word boundaries itself. merge_by_niqud_boundaries still runs afterward as a safety net for the rarer case where Ollama's response doesn't match the given list either. Non-Hebrew sentences are unaffected — they still use analyze_sentence's free-text prompt, since there's no niqud tokenization to pre-split them with.

Technology choices

One page per notable third-party dependency: why it was chosen over the alternatives, and how it's used in this project specifically. Every new dependency gets a page here when it's added (see CLAUDE.md).

tracing

Structured logging framework, paired with tracing-subscriber for terminal output. Used instead of println! per CLAUDE.md's conventions (levels: trace/debug/info/warn/error). Chosen as the ecosystem standard, with a ready-made formatter.

main.rs's init_logging(debug: bool) installs the subscriber once, first thing in main. debug comes from the --debug CLI flag (extract_debug_flag), not an environment variable — CLAUDE.md prefers a flag/config.toml over env vars for per-run settings. With --debug, the filter is fixed to "info,trango=debug,word_analysis=debug"; otherwise RUST_LOG still works as a lower-level escape hatch, defaulting to info.

Pitfalls

  • init_logging() must run before any tracing::*! calls, or events are dropped silently.
  • The env-filter feature (needed for both --debug and RUST_LOG) isn't in tracing-subscriber's default features — enabled explicitly in crates/app/Cargo.toml.
  • Plain RUST_LOG=debug also enables chatty dependency logs (winit especially) — scope it like --debug does, e.g. RUST_LOG=trango=debug,word_analysis=debug.

thiserror

Derive macro generating std::error::Error/Display impls for error enums. Required by CLAUDE.md for library crates (vs. anyhow in the binary/tests). Used for SubtitleError (crates/subtitle/src/error.rs): InvalidFormat, IoError (#[from] std::io::Error), InvalidTiming { index, start, end } — the standard low-boilerplate way to define typed, matchable error enums without hand-writing Display/Error impls.

Pitfall

#[from] generates a From impl — only one variant per source error type can use it, since the conversion must stay unambiguous.

slint

Declarative GUI toolkit for Rust — SPEC.md's handoff spec names it explicitly ("Rust + Slint + libmpv"), so no alternative was considered. UI layout/styling live in .slint markup (crates/app/ui/app-window.slint), compiled at build time via slint-build (build.rs) into generated Rust types main.rs instantiates with slint::include_modules!().

Pitfalls

  • .slint files aren't Rust — cargo fmt/clippy don't touch them; no linting is wired into scripts/check.sh.
  • Slint's winit backend allows only one platform/event-loop init per process, bound to its creating thread — a second AppWindow::new() on another thread (e.g. a second #[test]) fails ("platform was initialized in another thread"). All assertions needing a real AppWindow must live in one test function.
  • AppWindow::new() needs a working windowing backend + X11/Wayland connection to construct at all (winit initializes its event loop immediately, even without ever showing the window) — it's not display-free. CI runs scripts/test.sh under xvfb-run for this reason (.github/workflows/ci.yml). Property-wiring tests never call .show(), so no compositor/renderer work happens beyond that; actual pixels-on-screen checks stay manual (cargo run -p trango).
  • Custom fonts ("Inter", "JetBrains Mono") only render if installed as system fonts; Slint falls back silently otherwise — no fonts are bundled.
  • HorizontalLayout/VerticalLayout default to cross-axis-alignment: stretch — children fill the full cross-axis extent unless a layout sets cross-axis-alignment: center (as the top bar does).
  • No dashed-border support — dashed empty-state rows are approximated with a solid muted border instead.
  • DropArea/DragArea don't relay OS file drops — only in-app DragArea sources fire dropped (confirmed by grepping i-slint-backend-winit 1.17.1 for WindowEvent::DroppedFile handling: there is none), and DataTransfer has no file/path payload type yet (tracking issue). This is why subtitle/translation linking uses an in-app file picker instead of drag-and-drop — see Design decisions.

libmpv2

Rust binding to libmpv's OpenGL render API, letting mpv draw video frames into our own GL context instead of opening its own window — needed to embed video inside the Slint window (SPEC.md: "Rust + Slint + libmpv").

The original libmpv crate is unmaintained and lacks the render API; libmpv2 is an actively maintained fork with a complete render module. libmpv2-sys links against the system's libmpv.so directly using pre-generated bindings (no bindgen) — libmpv-dev/mpv-libs-devel must be installed to build.

crates/app/src/video_player.rs owns the Mpv core and RenderContext, tied to Slint's window via set_rendering_notifier — see Video playback for the mechanism.

Pitfalls

  • RenderContext<'a> borrows from Mpv; video_player.rs sidesteps the resulting self-reference with a deliberate Box::leak (fine — one player per process).
  • OpenGLInitParams::get_proc_address wants a plain fn pointer, not a closure — gl_proc_address_bridge.rs bridges Slint's borrowed loader closure through a thread-local.
  • set_update_callback's closure runs on an mpv-internal thread and must not call mpv/GL APIs — it only signals via slint::invoke_from_event_loop.
  • mpv's render call always draws at (0, 0) of the given framebuffer with no offset parameter — confining it to a sub-rectangle needs an offscreen FBO (see video-playback.md).

serde

Rust's standard (de)serialization framework — #[derive(Serialize, Deserialize)] generates the conversion code a format crate (here toml) reads/writes against. Used for TrangoConfig (crates/app/src/config.rs), trango's persisted settings (whisper model, Ollama model, etc.). Chosen as the ecosystem's de facto standard: every format crate targets serde's traits, keeping the config format swappable later.

No pitfalls encountered — a small, single-struct config with no versioning concerns.

toml

Parses/produces TOML, a simple hand-editable config format, into/from serde types. Used in crates/app/src/config.rs to read/write TrangoConfig at $XDG_CONFIG_HOME/trango/config.toml. Chosen for the same reason Cargo.toml itself uses it: easy for a user to open and fix by hand, unlike JSON or a binary format; integrates directly with serde, which trango already needed for this feature.

Pitfall

A missing or corrupt config file falls back to defaults rather than erroring — a persisted setting is a convenience, not something trango should refuse to start over.

ureq

Small synchronous HTTP client. Used in crates/word-analysis/src/ollama.rs's HttpOllamaClient (json feature, for send_json/read_json) to talk to a local Ollama instance. Chosen because trango has no async runtime anywhere — background work runs via plain std::thread::spawn, so a blocking client needs no new execution model; reqwest would have meant adding tokio just for this one feature.

Pitfall

Non-2xx responses become Err(ureq::Error::StatusCode(_)) rather than a returned response — map_ureq_error uses this to distinguish "Ollama responded with an error" from "couldn't reach Ollama at all".

serde_json

Standard JSON (de)serializer for serde-derived types. Used for the word-analysis cache sidecar (crates/word-analysis/src/cache.rs) and to parse Ollama's JSON response envelope plus the model's own JSON reply nested inside it (ollama.rs). Chosen because trango already uses serde, and it's what ureq's json feature uses internally.

Pitfall

AnalysisCache::entries is HashMap<u32, WordAnalysis>serde_json stringifies integer map keys, so the file's entries keys read as "0", "1", ... by hand, though it round-trips fine.

chrono

Date/time handling with timezone support. Used in crates/app/src/system_audio_capture.rs to build the default recording filename (<date>_<time>.wav, e.g. 2026-07-17_18-42-05.wav) from the local wall-clock time. Chosen because the std library's SystemTime has no timezone-aware formatting at all — chrono is the most widely used crate that does, and trango only needs its Local::now() + DateTime::format slice of it.

Pitfall

Local::now() isn't called directly inside the filename-building function — it's passed in as a parameter (DateTime<Local>) so tests can assert on a fixed timestamp instead of a moving target.

ort

Rust bindings for ONNX Runtime, used in crates/niqud/src/onnx_client.rs to run the Hebrew niqud diacritization model directly — no Python, no subprocess (see specs.md's "Hebrew pronunciation" entry for why a Python CLI wrapper was tried first and replaced). Chosen over reimplementing ONNX inference from scratch, obviously; tokenizers was not added alongside it, since the specific model's tokenizer turned out to be simple enough (character-level) to reimplement directly in tokenizer.rs.

Pitfalls

Build-time vs. runtime linking. ort's default download-binaries feature fetches a prebuilt ONNX Runtime binary over the network at compile time — breaks offline/CI builds. crates/niqud/Cargo.toml uses load-dynamic instead: libonnxruntime.so is loaded at runtime, via the ORT_DYLIB_PATH env var or the system dynamic linker's normal search path. This makes the shared library a runtime dependency the user installs separately (e.g. Ubuntu's libonnxruntime1.23 package), consistent with whisper-cli/ffmpeg/Ollama already being external runtime dependencies.

API version must be pinned explicitly and conservatively. With default-features = false, no api-XX feature is enabled by default, which fails to compile against parts of ort's own code. The crate's own default feature set requests api-24 — against Ubuntu's apt-packaged libonnxruntime1.23, that hangs indefinitely rather than erroring. api-23 works correctly against the same library. Always verify a chosen api-XX against the actual runtime version being targeted; a mismatch's failure mode isn't guaranteed to be a clean error.

A missing/incompatible dylib can hang instead of erroring, even with the right api-XX. Session::builder() was observed hanging indefinitely (not just slow) both for an api-24/libonnxruntime1.23 mismatch and when no dylib could be resolved at all — dlopen failures aren't guaranteed to surface as a fast, clean Result::Err here. Two mitigations, both in crates/niqud/src/dylib.rs/main.rs, so a normal user never has to know any of this:

  • dylib::ensure_ort_initialized resolves a dylib path itself (ORT_DYLIB_PATH if set, else scanning the usual Debian/Ubuntu library directories for libonnxruntime.so*) and returns a clean error immediately, without ever calling into ort, if nothing is found — sidesteps the "nothing found" hang entirely by construction.
  • main.rs's niqud_client_from_config still runs the actual load on a background thread with a bounded recv_timeout (NIQUD_LOAD_TIMEOUT), since a found-but-incompatible dylib can still hang inside ort itself — this is defense-in-depth for a failure mode whose root cause wasn't fully diagnosed, not a targeted fix.