Translate & Pronounce Popup

AnkiWeb addon 637051768

Select a word, phrase, or sentence while reviewing to see a popup with translation, example sentences, and speech, with cached results and multiple providers.
AI-generated summary; may contain mistakes.

sync-integrationdictionary

Open on AnkiWeb GitHub Ask about alternatives

AnkiWeb

Rating
0 (πŸ‘ 0 Β· πŸ‘Ž 0)
Updated
2026-08-19
Anki versions
25.09.4~
Description language
en

Maintenance

active

  • Last update or commit was 43 days before the snapshot (2026-08-19).
  • The repository has 8 test files.

Will it work on my Anki?

Version branches
Min AnkiMax AnkiUpdated
25.0225.09.4+2026-08-19
History across monthly snapshots

Loading…

Similar addons

DictOver Addon Anki: Popup Lookup

Provides instant popup translation on text highlight and double-click dictionary lookups in Anki, supporting nine languages, audio pronunciation, contextual details, and Ctrl-click inline field editing.

note-managementdictionary

Rating 5 πŸ‘ 5 Β· πŸ‘Ž 0 ⭐ 5 Anki 2.1.1~ Updated 2026-08-29
QuickDict English Pop-up Dictionary (EN→PT-BR)

Provides instant English dictionary popups in Anki with Portuguese (BR) translation, definitions, audio, phonetics, synonyms, offline cache, dark mode, and previewer support.

dictionary

Rating 1 πŸ‘ 1 Β· πŸ‘Ž 0 ⭐ 0 Anki 25.09.2~ Updated 2026-03-31
Instant Context Search: Google Images, Reverso & More

Lets you right-click selected card text to instantly search Google Images, Reverso, WordReference, Google Translate, Wikipedia, Wiktionary, and Cambridge Dictionary in your browser.

dictionary

Rating 1 πŸ‘ 1 Β· πŸ‘Ž 0 Anki 2.1.1~ Updated 2025-12-06

README

Translate & Pronounce Popup

An Anki Desktop add-on for Windows 11. Select a word, phrase, or sentence while reviewing and a compact popup appears next to it, already showing the translation β€” plus real example sentences so you can see the word in use.

The header carries three icon buttons (translate, speaker, clipboard) and a close button. All have tooltips and ARIA labels.

By default the lookup runs automatically on selection, which means the selected text is transmitted as soon as you select it. auto_translate and auto_pronounce turn that off if you would rather click.

The entire interface is in English.


Contents

  1. Requirements and version assumptions
  2. Installation
  3. Configuration
  4. Architecture
  5. Automated tests
  6. Manual testing checklist
  7. Known limitations
  8. Packaging as .ankiaddon
  9. Licence

Requirements and version assumptions

Developed and verified against:

Component Version
Anki 25.09.4 (int_version 250904)
Qt / PyQt Qt 6.8, PyQt6, QtWebEngine 6.8
Python 3.13.5 (bundled with Anki)
requests 2.32.4 (bundled with Anki β€” no extra dependency added)

manifest.json declares min_point_version: 250200 (Anki 25.02). Only 25.09.4 was actually tested; the floor is a conservative estimate, not a verified claim.

Every Anki API used is a documented, currently supported one β€” no deprecated hooks:

API Purpose
gui_hooks.webview_will_set_content Inject CSS/JS into the reviewer and previewer
gui_hooks.webview_did_receive_js_message Receive pycmd() calls
gui_hooks.state_shortcuts_will_change / focus_did_change Keep speech keys alive when reviewer focus moves to Qt
AddonManager.setWebExports Serve web/ under /_addons/
AddonManager.getConfig / setConfigUpdatedAction Configuration
AddonManager.get_logger Add-on-scoped logging
aqt.operations.QueryOp Run network calls off the UI thread

Version-specific behaviour that was verified empirically, not assumed:

  • speechSynthesis exists in Anki's QtWebEngine build (it is not enabled in every Qt build). Confirmed: typeof window.speechSynthesis === "object".
  • Voices load asynchronously β€” getVoices() returned [] for roughly 3–4 seconds after page load. The add-on warms the list at startup and waits for voiceschanged.
  • speak() needs a transient user gesture. A real click works; a synthetic one fails with not-allowed. Warming the voice list keeps the click-to-speak path inside Chromium's activation window.
  • Escape has no reviewer binding in 25.09.4 (Reviewer._shortcutKeys() contains no Escape entry, and there is no Key_Escape handler in aqt/main.py), so the popup can close on Escape without stealing an Anki shortcut.
  • The reviewer page is built once per session β€” cards are swapped by calling _showQuestion() in JS, so the injected script persists across cards and must survive them. It does.

Installation

Option A β€” install the packaged file

  1. Build it (see Packaging) or use the supplied anki_translate_popup.ankiaddon.
  2. In Anki: Tools β†’ Add-ons β†’ Install from file…
  3. Select anki_translate_popup.ankiaddon.
  4. Restart Anki.

Option B β€” install from source (for development)

Copy or symlink the anki_translate_popup folder into your add-ons directory:

%APPDATA%\Anki2\addons21\anki_translate_popup

A symlink lets you edit in place (run as Administrator, or with Developer Mode on):

New-Item -ItemType SymbolicLink `
  -Path "$env:APPDATA\Anki2\addons21\anki_translate_popup" `
  -Target "C:\path\to\anki_translate_popup"

Restart Anki. Confirm it loaded via Tools β†’ Add-ons β€” it appears as Translate & Pronounce Popup.

Verify it works

Start reviewing any card, select some text with the mouse, and the popup should appear. Translate and Pronounce both work immediately β€” the default provider needs no API key. See the Terms-of-Service caveat under Configuration.


Configuration

Tools β†’ Add-ons β†’ Translate & Pronounce Popup β†’ Config.

Full per-option documentation is in config.md, shown next to the editor in Anki's config dialog. Changes take effect immediately β€” no restart.

Default: no setup at all

Out of the box translation_provider is "google_unofficial", which needs no API key and no signup. German β†’ English works the moment you install it.

The trade-off, stated plainly: that endpoint is undocumented and unsupported. Google can rate-limit or break it without notice, and using it may breach their Terms of Service. Translation quality for German is also a little below DeepL. If either matters to you, switch provider below.

Better quality (DeepL)

  1. Get a free API key at https://www.deepl.com/pro-api (free keys end in :fx).
  2. Set "api_key": "your-key-here:fx" and "translation_provider": "deepl".

The free and paid DeepL hosts differ; the add-on picks the right one from the :fx suffix automatically.

Belt and braces (both)

fallback_provider retries with a second backend when the first fails β€” no key, quota gone, rate-limited, offline, or the unofficial endpoint broken:

"translation_provider": "deepl",
"fallback_provider": "google_unofficial"

DeepL quality while your quota lasts, Google keeping it working when it doesn't. Reverse the two for free-by-default with DeepL as insurance.

When the fallback answers, the popup shows via Google (fallback) β€” you are always told which service received your text, and each provider caches separately so results never cross over. Disabled by default ("").

Fully private setup (LibreTranslate)

Run LibreTranslate locally, then:

{
  "translation_provider": "libretranslate",
  "libretranslate_endpoint": "http://localhost:5000"
}

No text leaves your machine.

Privacy summary

With the default auto_translate: true, selected text is transmitted as soon as you select it. auto_pronounce fetches online audio: with the default tts_provider: google_unofficial, nothing is spoken offline, because a card cannot use a system voice at all and a split between the two produced two different voices for the same word. Turn both off for click-to-act behavior. Copy is always local. DeepL and LibreTranslate probe their language-list API when the reviewer opens; this sends no card text, though DeepL authenticates the probe with your API key.

One case sends text you never selected: with both source_language and the side's voice language on auto, card auto-pronounce asks the provider to identify the card side, because nothing in the configuration names its language. A 200-character sample goes, the answer is cached so a card costs at most one detection, and naming a real source_language β€” or turning off auto_pronounce_card β€” stops it entirely.

Provider Where text goes
google_unofficial (default) translate.googleapis.com. Undocumented endpoint, no service agreement, no stated retention policy. May breach Google's Terms of Service. Set enable_google_unofficial: false to block it entirely.
deepl api.deepl.com / api-free.deepl.com over HTTPS. DeepL states API text is not used for training and is deleted after translation.
libretranslate Whichever endpoint you configure. Self-host for full privacy.

Your API key is stored by Anki in meta.json in the add-on folder, in plain text. It is never logged and never sent to the reviewer page β€” verified by test.


Architecture

anki_translate_popup/
β”œβ”€β”€ __init__.py          Anki hooks, bridge, threading, wiring
β”œβ”€β”€ config.py            Typed + validated view over Anki's config dict
β”œβ”€β”€ config.json          Defaults
β”œβ”€β”€ config.md            User-facing option documentation
β”œβ”€β”€ manifest.json        Add-on metadata
β”œβ”€β”€ cache.py             SQLite translation/example cache with TTL
β”œβ”€β”€ tts.py               Online speech, for languages with no usable voice
β”œβ”€β”€ examples.py          Usage examples from the Tatoeba corpus
β”œβ”€β”€ translation/
β”‚   β”œβ”€β”€ base.py          Translator ABC, error hierarchy, shared HTTP
β”‚   β”œβ”€β”€ deepl.py         DeepL API v2
β”‚   β”œβ”€β”€ libretranslate.py
β”‚   └── google_unofficial.py   Opt-in only, isolated
β”œβ”€β”€ web/
β”‚   β”œβ”€β”€ reviewer.js      Selection, popup, speech, clipboard
β”‚   └── reviewer.css     Popup styling, light + dark
└── tests/               Unit tests with all network calls stubbed

Request flow

user selects text
      β”‚  (nothing is sent anywhere)
      β–Ό
reviewer.js shows the popup
      β”‚
      β”‚  user presses Translate
      β–Ό
pycmd("anki_translate_popup:translate:{id,text}")
      β”‚
      β–Ό
webview_did_receive_js_message  ─── main thread, returns immediately
      β”‚
      β–Ό
QueryOp(...).without_collection().run_in_background()
      β”‚
      β”‚  ── worker thread ──────────────────────────
      β”‚     cache lookup β†’ provider HTTP call β†’ cache store
      β”‚  ─────────────────────────────────────────
      β–Ό
success/failure callback (main thread)
      β”‚
      β–Ό
web.eval("...onTranslationResponse({...})")
      β”‚
      β–Ό
popup renders result or error via textContent

Design decisions

Why one implementation covers reviewer and previewer. The browser's previewer renders a card with the same Reviewer.revHtml() the reviewer uses, so the injected JS and CSS work unchanged; the two only differ in which attribute holds the webview (Reviewer.web vs Previewer._web), which _webview_for() resolves. The answer-button bar is excluded because it holds no card text, and the card-layout and note editors because they are text-editing surfaces where a selection popup would fight with typing.

Why the UI thread never blocks. Translation, Tatoeba, TTS, provider language-capability probes, and SQLite work happen inside QueryOp.op, which Anki runs on a worker thread. without_collection() keeps them from being serialised behind collection operations. Bridge handlers return immediately.

Why injection is safe. reviewer.js writes every external value β€” the selection, the translation, error strings β€” with textContent, never innerHTML. The only innerHTML assignment is a constant skeleton with no interpolation. On the Python side, _js_json() uses ensure_ascii=True (which escapes umlauts and the U+2028/U+2029 separators that would otherwise terminate a JS string literal) and rewrites </ as <\/ so a value containing </script> cannot close the injected tag early. Both are covered by tests.

Why the CSS is full of !important. The popup lives in the same document as the card, so the card template's CSS applies to it. Templates routinely use broad selectors with !important (div { color: hotpink !important }), which beat an add-on rule at any specificity. The stylesheet re-states every inherited typography and colour property so the popup looks identical on every deck.

Why the selection is read from the DOM, not Selection.toString(). toString() returns the rendered text: on a card styled with text-transform: uppercase it hands back GROSS for groß and GRÜSSE for Grüße β€” destroying exactly the German characters this add-on exists to handle. The add-on clones the range instead, strips <style>/<script> (Anki puts the card's stylesheet inside #qa, and textContent would otherwise capture the entire CSS and post it to a paid API), re-inserts line breaks at block boundaries, and collapses whitespace.

Why the cache opens a connection per call. The cache is touched from Anki's worker threads, and a sqlite3 connection cannot be shared across threads. Per-call connections avoid a lock, and the cost is irrelevant for a user-triggered action. Note that sqlite3.Connection used as a context manager commits the transaction but does not close the connection β€” cache.py wraps it so the handle is always released.

Adding a provider. Subclass Translator in translation/, implement translate() / validate() and optionally supported_languages(), then register it in PROVIDERS and build_translator(). The popup, cache, bridge and threading need no changes.

Adding a TTS backend. Subclass TextToSpeech in tts.py and implement synthesize(text, lang) -> bytes; the caching, playback and error plumbing in _synthesize_blocking are backend-agnostic. Audio plays through Anki's own av_player, which uses the bundled mpv, so any format mpv understands works. The browser path is separate: pronounce() in reviewer.js is the only place that touches speechSynthesis, with loadVoices() and pickVoice() split out as testable functions.


Automated tests

314 tests, no network access and no paid API calls β€” every HTTP call is stubbed.

Run from the directory that contains anki_translate_popup:

& "$env:LOCALAPPDATA\AnkiProgramFiles\.venv\Scripts\python.exe" `
  -m unittest discover -s anki_translate_popup/tests -t .

Any Python 3.9+ with requests installed also works:

python -m unittest discover -s anki_translate_popup/tests -t .

Coverage:

File Covers
test_config.py Defaults, deck pairs, provider-filtered pickers, language-code normalisation, auto, type/range errors, API key never reaching the webview
test_cache.py Translation/example read/write and key scoping, Unicode, TTL and row limits, corrupt-database resilience, connection-close regression
test_translation.py Provider parsing/capabilities, DeepL Chinese aliases, auto-detection, malformed responses, HTTP status/network failures, Unicode, provider gating, JS escaping
test_examples.py ISO 639-1β†’3 mapping, spaced/Chinese phrase gating, limits, unsupported languages, HTTP/malformed responses, Unicode and safe markup handling
test_tts.py Word-boundary chunking, hard-splitting overlong words, multi-segment joining, MP3/ID3 sniffing, HTML-error-page rejection, empty body, rate limit, HTTP errors, timeouts, connection failure, Unicode
test_fallback.py Fallback on network/quota/missing-key failures, no fallback when the primary succeeds, both-failed message naming both causes, fallback results cached under the fallback provider, no cache leakage between providers, fallback logged not silent, config validation

Manual testing checklist

Set up a deck with German cards, then work through these.

Selection

  • [ ] Select one German word (Haus) β†’ popup appears next to it
  • [ ] Select a multi-word phrase (das große Haus) β†’ full phrase shown
  • [ ] Select a full sentence β†’ full sentence shown, popup stays on screen
  • [ ] Select text on the question side β†’ works
  • [ ] Select text on the answer side β†’ works
  • [ ] Select text containing Γ€ ΓΆ ΓΌ ß β†’ shown correctly, not mangled
  • [ ] Click without dragging β†’ no popup
  • [ ] Select only whitespace β†’ no popup
  • [ ] Select text spanning two lines/paragraphs β†’ words are not glued together
  • [ ] Select across the whole card (Ctrl+A style drag) β†’ no CSS appears in the popup

Popup behaviour

  • [ ] Popup stays fully inside the window, including near the bottom edge
  • [ ] Press Escape β†’ popup closes
  • [ ] Click outside β†’ popup closes
  • [ ] Click Γ— β†’ popup closes
  • [ ] Hover the speaker and clipboard icons β†’ tooltips read Pronounce and Copy
  • [ ] Click the gear β†’ every switch and all three voice rows are visible at once, no scrolling, ticks matching the current config
  • [ ] Flip a toggle β†’ tick changes, menu stays open, setting persists after reopening the popup
  • [ ] Turn Speak the card as it appears on mid-card β†’ the card on screen is spoken at once, not one card later
  • [ ] Turn it on while the answer is showing, with Also speak the answer off β†’ the card's prompt is spoken, not silence
  • [ ] Turn it off while a card is being spoken β†’ the audio stops there and then
  • [ ] Turn it off β†’ nothing is spoken, and the next card is silent
  • [ ] Reveal the answer, then turn Also speak the answer on β†’ the answer is spoken immediately
  • [ ] Flip a toggle with the browser previewer also open β†’ both screens agree without a restart
  • [ ] Flip one in the gear, then check Tools β†’ Add-ons β†’ Config β†’ the same value is there
  • [ ] Escape with the gear menu open β†’ closes the menu only; a second Escape closes the popup
  • [ ] Open the gear near the bottom of the window β†’ the menu flips above the icon rather than overflowing
  • [ ] Click the clipboard β†’ turns into a tick for ~1s, then back; paste elsewhere to confirm
  • [ ] Select text inside the popup β†’ popup does not reset
  • [ ] Resize the window while open β†’ popup closes cleanly
  • [ ] Answer the card with the popup open β†’ the popup closes with the card
  • [ ] Reveal the answer with the popup open β†’ it closes too, rather than translating the question over the answer
  • [ ] Flip a gear toggle β†’ the popup and its menu stay open
  • [ ] Press x or c with the popup open β†’ it stays open
  • [ ] Click the source code (DE) β†’ dropdown lists the configured languages plus auto
  • [ ] Click the target code (EN) β†’ dropdown lists them without auto
  • [ ] Pick a different language β†’ popup re-translates, does not merely relabel
  • [ ] Click the β†’ β†’ languages swap and the text re-translates
  • [ ] Swap while source is auto β†’ the detected language becomes the target, never auto
  • [ ] Escape with a dropdown open β†’ closes the dropdown only; a second Escape closes the popup
  • [ ] Reopen the popup after changing languages β†’ the current deck's pair persisted
  • [ ] Change deck A to de β†’ en, deck B to es β†’ en, then switch between them β†’ each pair returns
  • [ ] With DeepL/LibreTranslate configured β†’ source and target lists hide unsupported choices
  • [ ] Break the language-list probe β†’ the full configured list remains available
  • [ ] Tab to the language controls and press Enter β†’ dropdown opens (keyboard accessible)
  • [ ] Select text, press Escape, then Ctrl+Shift+T β†’ popup reopens for that selection
  • [ ] Press Ctrl+Shift+T with nothing selected β†’ nothing happens
  • [ ] Preview a card from the browser β†’ popup works there too
  • [ ] Set enable_in_previewer: false β†’ popup no longer appears in the previewer, still works in the reviewer
  • [ ] On a question, press x β†’ the front is spoken, with nothing selected
  • [ ] On the same question, press c β†’ silence; the answer is not out yet
  • [ ] Show the answer, press c β†’ the back is spoken, without repeating the front
  • [ ] Press x then c quickly β†’ the second interrupts the first, they do not queue
  • [ ] Press c twice β†’ it speaks twice; the auto-pronounce dedupe does not swallow it
  • [ ] Review a reversed card β†’ x still speaks the prompt, c still speaks what you were recalling
  • [ ] On a reversed card (English front, German back): x speaks English, c speaks German β€” not the pair's way round
  • [ ] Pin front_speech_language β†’ that side is spoken in it with no identification request
  • [ ] On a type-in-the-answer card, type a word containing x and c β†’ both letters land in the field and nothing is spoken
  • [ ] Set pronounce_answer_shortcut: "" β†’ c does nothing, x still works
  • [ ] With a de β†’ en pair, press x then c β†’ German voice for the front, English voice for the back
  • [ ] Swap the pair in the header, then press x β†’ the voice swaps with it
  • [ ] Gear β†’ Voice for the back β†’ pick a language β†’ it sticks, and the row shows it
  • [ ] Gear β†’ Voice for the back β†’ Auto β†’ follows the pair again
  • [ ] Set speech_language: de-AT with a de β†’ en pair β†’ the front keeps the Austrian voice
  • [ ] Put Gen. on an English back β†’ spoken as "gen", not "Genitiv"

Speech survives leaving the reviewer

  • [ ] While a card is being spoken, press z β†’ audio stops at once
  • [ ] After z, press x β†’ speaks again; z mutes nothing permanently
  • [ ] Let a card auto-pronounce, press z mid-clip β†’ that clip stops too
  • [ ] Sync, return to the card, press x β†’ still speaks
  • [ ] Edit the card, close the editor, press x β†’ still speaks, with the edited text
  • [ ] Open More, pick an action, press x β†’ still speaks
  • [ ] Same three, but let the card auto-pronounce instead of pressing a key β†’ still speaks
  • [ ] Repeat Sync/Edit/More with tts_provider: system β†’ click the card before x; no off-focus fallback sends text online

No interference with Anki

  • [ ] Space / Enter still shows the answer and answers the card
  • [ ] Answer buttons 1–4 still work
  • [ ] e still opens the editor, u still undoes
  • [ ] Clicking a link on a card still follows it
  • [ ] Normal text selection still works when the add-on is disabled

Appearance

  • [ ] Light mode β†’ readable, correct contrast
  • [ ] Dark mode (Tools β†’ Preferences β†’ Appearance) β†’ readable, correct contrast
  • [ ] Open the gear in dark mode β†’ the menu is near-black and clearly a panel above the popup, not part of it
  • [ ] The popup itself is near-black too, one shade above the menu rather than a grey box around it
  • [ ] Gear β†’ Theme β†’ Light while Anki is dark β†’ popup and menu both go light, the card behind is untouched
  • [ ] Gear β†’ Theme β†’ Dark while Anki is light β†’ both go dark
  • [ ] Gear β†’ Theme β†’ Follow Anki, then switch Anki's theme β†’ the popup follows without reopening
  • [ ] Open it in light mode β†’ white, with the same grouping and no heavy borders
  • [ ] Hover a row β†’ it lights briefly rather than snapping; the switches and the pickers read as two groups
  • [ ] Toggle the theme while the popup is open β†’ colours follow immediately
  • [ ] Try a deck with a heavily styled card template β†’ popup is unaffected
  • [ ] Change popup_font_size β†’ takes effect without restarting

Translation

  • [ ] Straight after install, no config β†’ selecting a word translates it with no click
  • [ ] Select a single word β†’ up to 3 example sentences appear under Examples Β· Tatoeba
  • [ ] Select a whole sentence β†’ translation appears, no examples (expected: too long to look up)
  • [ ] Select a word with no corpus match β†’ translation still appears, no examples
  • [ ] Set show_examples: false β†’ no examples section, no Tatoeba request
  • [ ] Set auto_translate: false β†’ nothing is sent until the translate icon is pressed
  • [ ] Set auto_pronounce: false β†’ no audio until the speaker is pressed
  • [ ] Select text near the bottom of the window β†’ popup grows with examples and flips above the selection rather than overflowing
  • [ ] Repeat the same selection β†’ returns instantly, status shows From cache
  • [ ] Switch to deepl with a valid key β†’ translation appears
  • [ ] Switch to deepl with no API key β†’ clear message naming api_key, nothing sent
  • [ ] Set enable_google_unofficial: false while Google is selected β†’ clear message, nothing sent
  • [ ] Set fallback_provider and break the primary (bad key / airplane mode) β†’ translation still appears, labelled via … (fallback)
  • [ ] Break both providers β†’ one message naming both failures
  • [ ] Set fallback_provider to the same value as translation_provider β†’ config error explaining they must differ
  • [ ] No network (turn off Wi-Fi) β†’ Could not reach the translation service
  • [ ] Set request_timeout_seconds: 1 against a slow endpoint β†’ timeout message names the value
  • [ ] Set source_language: "auto" β†’ detected language is displayed and examples still appear
  • [ ] Translate 房子 from zh β†’ translation and a short Tatoeba lookup work
  • [ ] Select a Chinese sentence longer than 8 characters β†’ translation works, example lookup is skipped
  • [ ] With DeepL, target zh / zh-TW β†’ simplified / traditional translation succeeds
  • [ ] Select text containing <b>, &, <script> β†’ shown literally as text, nothing executes, no layout break
  • [ ] Set an invalid config value (e.g. "request_timeout_seconds": "ten") β†’ pressing Translate explains the problem
  • [ ] Select an enormous block of text β†’ refused with a length message
  • [ ] Press Translate twice quickly β†’ only the newest result is displayed

Pronunciation

  • [ ] Show a card and touch nothing β†’ the German side is spoken automatically
  • [ ] Reveal the answer β†’ the answer only is spoken, not the question again
  • [ ] Same card side re-rendering β†’ spoken once, not twice
  • [ ] A card with its own [sound:] audio β†’ Anki's clip plays first, the spoken text follows, neither is cut off
  • [ ] Turn Speak the card as it appears off in the gear β†’ next card is silent
  • [ ] Sync while a card is being spoken β†’ the audio stops, and the card is not spoken again afterwards
  • [ ] Edit a card, or open More, and come back β†’ not spoken again, however long you took
  • [ ] Answer the card and press Again β†’ the question is spoken again: that is a real second showing
  • [ ] Under an auto pair, let the card auto-pronounce, then press x β†’ the same language, not the speech_language fallback
  • [ ] A card whose first line is a bare headword (der Aspekt, -e) with card_speech_scope: first-line β†’ identified from the whole side, spoken as German
  • [ ] Press x on that card β†’ the same language the card was spoken in, not speech_language
  • [ ] source_language: "auto" on an English deck β†’ the card is spoken in English, not the speech_language German
  • [ ] A card reading die Aktie, -n under an auto pair β†’ spoken as German, not as English
  • [ ] A card with a German line then an English line, card_speech_scope: full β†’ each line in its own voice
  • [ ] Gear β†’ Speak only β†’ DE β†’ the English line is skipped, the German still spoken
  • [ ] With that set, press Pronounce on English text β†’ it is spoken: an explicit ask is never filtered
  • [ ] Gear β†’ Speak only β†’ All β†’ both lines spoken again
  • [ ] debug_logging: true β†’ the log names each line and the language chosen for it
  • [ ] A card of three German lines β†’ still one clip, not three
  • [ ] Same card again β†’ spoken with no second detection request (check the log with debug_logging: true)
  • [ ] source_language: "auto" with no network β†’ the card still speaks, using speech_language
  • [ ] Pin front_speech_language to a language β†’ no detection happens at all
  • [ ] Set tts_provider: "system" β†’ card auto-pronounce stops (needs a user gesture the browser will not grant)
  • [ ] Default (tts_provider: "google_unofficial") β†’ every pronunciation says Spoken by Google (online voice), selections and cards alike
  • [ ] A selection and a card of the same word β†’ the same voice for both
  • [ ] Gear β†’ Voice source β†’ System only β†’ card auto-pronounce stops, selections use an installed voice
  • [ ] Set enable_google_unofficial: false β†’ no new audio is fetched, with a message naming the switch
  • [ ] Same text again after disabling it β†’ the clip already in user_files/tts/ still plays
  • [ ] Press Pronounce again on the same text β†’ instant (served from user_files/tts/)
  • [ ] With a German system voice installed β†’ uses it, status names the voice, nothing goes online
  • [ ] Press Pronounce twice quickly β†’ the first stops, no overlap
  • [ ] Select text while the card is being auto-pronounced β†’ the card audio stops, only the selection is heard
  • [ ] Speak a selection, then reveal the answer β†’ the leftover speech stops as the new side appears
  • [ ] Press x while a card's own [sound:] is playing β†’ the clip stops and the side is spoken, not both at once
  • [ ] Close the popup while a card's [sound:] plays β†’ the clip keeps going; the popup only cancels what it started
  • [ ] Pronounce after translating β†’ the original German is spoken, not the English
  • [ ] Set the pair to EN β†’ EN and press Pronounce β†’ an English voice speaks it, not the German one
  • [ ] Set speech_language: "de-AT" with a DE β†’ EN pair β†’ still the Austrian voice, not a bare de one
  • [ ] Source auto, select German, translate, then Pronounce β†’ the detected language picks the voice
  • [ ] Change the pair in the header, then press Pronounce immediately β†’ the new language is used, not the old
  • [ ] Source auto: select a German word, then an English one β†’ each is spoken in its own language, not both in the first
  • [ ] Source auto: the header reads AUTOΒ·DE after a German lookup, and AUTO again on the next selection
  • [ ] Source auto with auto_translate: false β†’ nothing is sent merely to detect a language; speech uses speech_language
  • [ ] Press Escape while the status reads Detecting language… β†’ nothing is spoken when the translation lands
  • [ ] Select the same word twice β†’ the same voice both times, never male one time and female the next
  • [ ] Gear β†’ Prefer a voice β†’ Male β†’ a male voice is used for a language that has one
  • [ ] Gear β†’ Prefer a voice β†’ Female β†’ back to a female voice
  • [ ] A language with only one installed voice β†’ still speaks, whichever gender that voice is
  • [ ] Set preferred_voice to an installed voice β†’ it wins over the gender preference
  • [ ] Set tts_provider: "system" with no German voice β†’ clear message, nothing sent anywhere
  • [ ] Set tts_provider: "system", turn off Wi-Fi β†’ still works if a voice exists
  • [ ] Set tts_provider: "google_unofficial", turn off Wi-Fi β†’ clear could not reach message
  • [ ] Close the popup while online audio is playing β†’ audio stops
  • [ ] Change speech_rate to 0.5 β†’ noticeably slower
  • [ ] Set preferred_voice to an installed voice name β†’ that voice is used
  • [ ] Set preferred_voice to a nonsense name β†’ falls back to speech_language

Persistence

  • [ ] Restart Anki β†’ add-on still loads, settings retained
  • [ ] Restart Anki β†’ previously translated text still returns from cache
  • [ ] Set cache_enabled: false β†’ every translation hits the network
  • [ ] Delete user_files/cache.sqlite β†’ cache rebuilds without error

Known limitations

  1. Narrator "natural voices" are invisible to Anki. Windows 11 has two unrelated voice systems. Classic SAPI5/OneCore voices are visible to every application; Narrator natural voices (MicrosoftWindows.Voice.de-DE.Katja and friends) are reserved for Narrator and are never registered as voice tokens. You can install German speech, see it listed in Settings, and still have no German voice available here. This is a Windows limitation, not an add-on bug β€” and it is one reason tts_provider defaults to google_unofficial rather than to a system voice that may not exist. Use the Add-WindowsCapability command in config.md to install a classic voice if you want system or auto to be worth choosing.
  2. Online TTS is unofficial, and now the default. Every pronunciation goes through it unless you change tts_provider, so the caveats are no longer a fallback's caveats: undocumented endpoint, no service agreement, may break or rate-limit. Audio is cached in user_files/tts/; tts_cache_max_mb limits its size.
  3. Voice enumeration is slow to start. System voices appear 3–4 seconds after the reviewer opens. The list is warmed at startup.
  4. System speech requires a real user gesture (a Chromium rule). The popup's buttons satisfy it; scripted invocation does not. Online TTS is unaffected, because playback goes through Anki's own audio player rather than the webview.
  5. Whitespace is collapsed. Newlines inside a selection become single spaces. This improves cache hits and suits words, phrases and sentences, but a deliberate line break (in a poem, say) is not preserved.
  6. The default provider is unofficial and may break at any time. The Google endpoint is undocumented, unversioned, rate-limited at Google's discretion, and using it may breach their Terms of Service. It ships as the default because it needs no signup; switch to deepl or a self-hosted libretranslate for a supported API, or set enable_google_unofficial: false to block it. Verified working against the live endpoint at build time (~0.2–0.6 s per request), but that is not a guarantee it will keep working.
  7. The API key is stored in plain text in Anki's meta.json, which is how Anki's configuration system works. build_ankiaddon.py deliberately excludes meta.json from the package so a key cannot be shipped by accident.
  8. Reviewer and previewer only. Not the note editor or the card-layout editor: those are text-editing surfaces where a selection popup would fight with typing. Turn the previewer off with enable_in_previewer.
  9. Selection is mouse-driven. Anki's reviewer has no text caret, so Shift+arrow selection is not possible in the first place. lookup_shortcut re-opens the popup for a selection you already made; it cannot create one.
  10. A Qt-side TTS provider would buy nothing. QTextToSpeech was probed on both its Windows engines and reports exactly the same voices the webview already sees β€” sapi gives ['en_US'] (David, Zira) and winrt gives ['en_US'] (David, Zira, Mark). Neither exposes a German voice, so routing speech through Qt would not reach the Narrator natural voices either. Only installing a classic voice, or the online provider, actually helps.
  11. Examples are phrase-level only. Tatoeba is searched for selections of up to 3 words / 40 characters, or 8 characters for unspaced Chinese. Whole sentences are skipped because they return nothing useful.
  12. Off-focus system speech cannot be synthesized through Qt. The Qt fallback after Sync/Edit/More can use online-capable modes, but strict tts_provider: system needs the keypress inside Chromium. Click the card to restore focus; the fallback never weakens the no-network promise.
  13. The pronounce keys cannot detect a language on their own. Under an auto pair, x and c use a detection the card's own auto-pronounce already paid for. With auto_pronounce_card off there is nothing in the cache and they fall back to speech_language, because they are handled on the UI thread where a network call would freeze the reviewer. Pin front_speech_language / back_speech_language, or name a real source_language, if you want them exact without card auto-pronounce.
  14. Auto-lookup changes the privacy posture. With the defaults, selecting text transmits it immediately β€” no button press gates it any more. This was an explicit request; auto_translate, auto_pronounce and show_examples each turn their own request off.

Packaging as .ankiaddon

From the directory containing anki_translate_popup:

& "$env:LOCALAPPDATA\AnkiProgramFiles\.venv\Scripts\python.exe" build_ankiaddon.py

This writes anki_translate_popup.ankiaddon β€” a zip with manifest.json at the archive root (not inside a subfolder), which is what Anki requires.

Deliberately excluded:

Excluded Why
meta.json Holds the user's saved config including the API key
user_files/ Local translation, example, and speech caches
__pycache__/, *.pyc Build artefacts

To do it by hand instead: select the contents of the anki_translate_popup folder (not the folder itself), send to a zip archive, and rename it to .ankiaddon.

Install with Tools β†’ Add-ons β†’ Install from file…

To publish on AnkiWeb, upload the .ankiaddon at https://ankiweb.net/shared/addons/ β€” the package field in manifest.json must stay stable across versions, and human_version should be bumped.

Cutting a release

python release.py does the whole sequence β€” tests, version bump, build, commit, tag, push, GitHub release with the package attached:

python release.py          # 1.0.0 -> 1.0.1
python release.py 1.1.0    # or say which version

Any python on your PATH will do, from any directory. The suite needs Anki's own aqt, which a system Python does not have, so the script locates Anki's bundled interpreter itself rather than making you type its path.

It refuses to start unless the tree is clean, the branch is main, the version is newer than the released one, and no commit since the last tag carries a co-author trailer. Tests run before the bump, so a failure leaves the tree untouched.

AnkiWeb is not automated β€” it has no API. The script ends by printing which file to upload to which listing. Re-upload on the existing listing rather than creating a second one, or nobody receives the update.


Licence

AGPL-3.0-or-later β€” the same licence as Anki itself, which this add-on imports at runtime. The full text is in LICENSE, and a copy ships inside every .ankiaddon build.