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:
- Automatic subtitle generation needs
whisper-cliandffmpeg, see Generating subtitles automatically. - Word-by-word analysis needs a locally running Ollama instance, see Word-by-word analysis.
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.mp4→video.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
| Key | Effect | Mode |
|---|---|---|
| Space | Play/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 Arrow | Jump to the next subtitle line's start and pause there | Sentence by sentence |
| Left Arrow | Jump to the previous subtitle line's start and pause there | Sentence by sentence |
| Ctrl+T | Show/hide the translated line under the current sentence | All |
| Ctrl+A | Look up a word-by-word translation for the current sentence | All |
| Ctrl+Space | Start/stop recording system audio to a WAV file | Audio 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.mp4 → video.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
- Install Ollama from ollama.com.
- Make sure it's running (
ollama serve, or however your install starts it). - 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.srt → movie.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
pactlfor 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 (checkpactl 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 thewhisper-clibinary.TRANGO_FFMPEG_PATH— path to theffmpegbinary, 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.mdis the original product handoff spec — views, states, and interactions.STYLE.mdholds the accompanying visual design reference and design tokens. CLAUDE.mdcovers the development workflow: TDD, thescripts/helpers, git workflow, and Rust conventions used throughout the codebase.TODO.mdis 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.srt → subs.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::Session
— Clone, 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
Mpv::with_initializercreates an mpv core withvo=libmpv(output only via the render API) andkeep-open=yes(stays loaded and paused on the last frame at EOF — see below).VideoPlayer::attachregisters a closure viaslint::Window::set_rendering_notifier, called atRenderingSetup,BeforeRendering,AfterRendering,RenderingTeardown.- On
RenderingSetup(fires once, on the window's first rendered frame),setup_render_contextcreates anmpv::render::RenderContextsharing Slint's GL context, wires mpv's "frame ready" callback to request a Slint redraw, and issuesloadfile. - On every
BeforeRendering,render_framedraws 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_srtreads and parses the realsample.srtfile from disk.- The resulting
Vec<Cue>is loaded into a realPlayerState, then walked forward withnext_cue()to the last cue, back withprevious_cue()to the first, and finallyrepeat_current_cue()is called twice — at each step the cursor position and the returnedSeekCommand'sstart/endare checked against the fixture's actual timings, not fabricated values. - A separate test confirms the paired
sample.mp4video fixture exists on disk and is non-empty, tying the video file to the subtitle track this suite exercises. - Two more tests (
TODO.mdVaihe 30) repeat the cue-navigation andsync_cue_to_timewalks withPlayerState::media_sourceset toAudio, confirming cue-based features never depended on a video actually being loaded — seedocs/src/developer/specs.md.
What this suite deliberately does not cover
- libmpv rendering/decoding. The E2E test never opens
sample.mp4through mpv or drivesvideo_player::VideoPlayer.docs/src/developer/architecture/ video-playback.mdexplains 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 wherecargo testruns, and correctness there is about pixels actually appearing on screen — somethingcargo testhas 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.htmlis automated at this stage (seeTODO.mdVaihe 22, done manually). - The Slint window itself.
crates/app/src/main.rs's own tests already coverAppWindowproperty wiring (e.g.sentence-mode-active); the E2E suite here stays below that layer, at thesubtitle/playback-statelevel.
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, mirroringsubtitle/playback-state. The app-local wiring modulecrates/app/src/word_analysis.rsshadows the extern crateword_analysisatmain.rs's crate root; call sites needing the crate use a leading::word_analysis::.... - HTTP client:
ureq, notreqwest— no async runtime elsewhere in trango; see ureq. - Prompt/response:
build_promptasks for{"words": [{"word", "translation", "pronunciation"}]}with Ollama'sformat: "json"andstream: false;parse_analysis_responsestrips a defensive```jsonfence some models still add. - Cache: one JSON sidecar per subtitle (
subs.srt→subs.wordanalysis.json), keyed byCue::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
FileListDialogchrome, 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 firstLineEdit), saved to config on every keystroke, defaulting to"English"only in code, not inTrangoConfig::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 anytracing::*!calls, or events are dropped silently.- The
env-filterfeature (needed for both--debugandRUST_LOG) isn't intracing-subscriber's default features — enabled explicitly incrates/app/Cargo.toml. - Plain
RUST_LOG=debugalso enables chatty dependency logs (winitespecially) — scope it like--debugdoes, 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
.slintfiles aren't Rust —cargo fmt/clippydon't touch them; no linting is wired intoscripts/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 realAppWindowmust 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 runsscripts/test.shunderxvfb-runfor 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/VerticalLayoutdefault tocross-axis-alignment: stretch— children fill the full cross-axis extent unless a layout setscross-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/DragAreadon't relay OS file drops — only in-appDragAreasources firedropped(confirmed by greppingi-slint-backend-winit1.17.1 forWindowEvent::DroppedFilehandling: there is none), andDataTransferhas 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 fromMpv;video_player.rssidesteps the resulting self-reference with a deliberateBox::leak(fine — one player per process).OpenGLInitParams::get_proc_addresswants a plainfnpointer, not a closure —gl_proc_address_bridge.rsbridges 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 viaslint::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_initializedresolves a dylib path itself (ORT_DYLIB_PATHif set, else scanning the usual Debian/Ubuntu library directories forlibonnxruntime.so*) and returns a clean error immediately, without ever calling intoort, if nothing is found — sidesteps the "nothing found" hang entirely by construction.main.rs'sniqud_client_from_configstill runs the actual load on a background thread with a boundedrecv_timeout(NIQUD_LOAD_TIMEOUT), since a found-but-incompatible dylib can still hang insideortitself — this is defense-in-depth for a failure mode whose root cause wasn't fully diagnosed, not a targeted fix.