diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 33c98f0..c23508a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,7 +62,7 @@ inside the chooser and read it there every time: a constant settled at import is one no test can stand somewhere else. The tests are split along the same line, and almost none of them are skipped. -697 of the 737 run on any machine, including every line of the Wayland, X11 and +892 of the 935 run on any machine, including every line of the Wayland, X11 and macOS backends: the programs are faked at `shutil.which`, the frameworks at the one function that loads them. A test class says which system it is standing on rather than avoiding the question: @@ -78,17 +78,17 @@ to a chooser cannot quietly break the platform nobody is sitting at. What the systems owe in common is written once as a contract class and subclassed by each of them. -The 40 that do carry `@linux_only` are the ones that would need the real thing: +The 43 that do carry `@linux_only` are the ones that would need the real thing: the `/dev/input` listener, KDE's shortcut file, GNOME's gsettings. Mark a test that way only when faking it would leave nothing to test. A test that quietly stops running on the platform you are porting to protects nothing. ## What a pull request should carry -A change to behaviour comes with a test for it. Adding a provider means a test -that the request goes to the right URL with the right fields; adding a platform -means a test for whatever the parsing of its device list, clipboard or shortcuts -looks like. Adding a setting means both halves of `settings_ui.py`: the round +A change to behaviour comes with a test for it. Adding a provider means a row in +`config.TRANSCRIBERS` and a test that the request goes to the right URL with the +right fields; adding a platform means a test for whatever the parsing of its +device list, clipboard or shortcuts looks like. Adding a setting means both halves of `settings_ui.py`: the round trip in `tests/test_ui.py` is what catches only one of them being written. Match the surrounding code: it is plain Python with no framework, comments diff --git a/README.md b/README.md index 1d36007..29ce1b4 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Dikte -Press `Ctrl+Space`, talk, press again. The recording goes to OpenAI or OpenRouter -for transcription, a model on OpenRouter cleans it up (dropping the *uh*s, the -restarts, the missing punctuation), and the result lands in your clipboard and -is pasted into whatever window you were typing in. +Press `Ctrl+Space`, talk, press again. The recording is transcribed on this +machine by default, a model cleans it up (dropping the *uh*s, the restarts, the +missing punctuation), and the result lands in your clipboard and is pasted into +whatever window you were typing in. Built for KDE Plasma 6 on Wayland. No dependencies beyond system packages: just the Python standard library and PyQt6. @@ -17,7 +17,8 @@ just the Python standard library and PyQt6. | | | |---|---| | API and models | Cleanup rules | -| Audio file | History | +| Agent | Meeting | +| Audio file | Shortcuts | ## Install @@ -25,7 +26,7 @@ just the Python standard library and PyQt6. sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6 systemctl --user enable --now ydotool # needed for auto-paste -./install.sh # or: ./install.sh "Ctrl+Alt+Space" +./install.sh # or: ./install.sh "Meta+Space" "Meta+Shift+Space" dikte # the settings window opens on first run ``` @@ -36,23 +37,27 @@ tools instead: sudo apt install pulseaudio-utils xclip xdotool ffmpeg ``` -`install.sh` adds the `dikte` command, a menu entry and an autostart entry. The -settings window installs a GNOME or KDE global shortcut. +`install.sh` adds the `dikte` command, a menu entry, an autostart entry and the +two global shortcuts, whose keys are its two arguments. `./update.sh` pulls and +puts all of that back, keeping the keys you chose; `./uninstall.sh` takes it away +again and leaves your settings and dictations alone unless you pass `--purge`. -Two keys go in the settings window: **OpenAI** and **OpenRouter**. Speech to text -runs on either one (`gpt-4o-transcribe` by default), cleanup always on -OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key can -cover both. They fall back to `OPENAI_API_KEY` and `OPENROUTER_API_KEY`, and are -stored in `~/.config/dikte/config.json`, mode 600. Cleanup can be switched off, -in which case the raw transcript is pasted, and a thinking model's effort can be -set next to it. +Speech to text and cleanup each pick a provider in the settings window, and both +run here by default, on models of your own. The cloud is the other option: +speech to text on **OpenAI**, **Groq** or **OpenRouter** (`gpt-4o-transcribe`), +cleanup on OpenRouter (`google/gemini-3.5-flash-lite`) or, when either is +installed, on Claude Code or Codex. The keys fall back to `OPENAI_API_KEY`, +`GROQ_API_KEY` and `OPENROUTER_API_KEY`, and are stored in +`~/.config/dikte/config.json`, mode 600. Cleanup can be switched off, in which +case the raw transcript is pasted, and a thinking model's effort can be set next +to it. ## Using it | What | How | | --- | --- | | Start / stop recording | `Ctrl+Space`, or click the tray icon | -| Cancel a recording | Tray menu → *Cancel recording*, or `dikte cancel` | +| Discard the recording | `Ctrl+Alt+Space`, tray menu, or `dikte cancel` | | Speak a command to an agent | Tray menu → *Ask Claude*, or `dikte ask` | | Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` | | Settings | Tray menu → *Settings*, or `dikte settings` | @@ -75,6 +80,12 @@ running. ## What it does +- **It all runs on this machine by default.** Speech to text on whisper.cpp and + cleanup on llama.cpp, neither installed beforehand: the settings window fetches + the program and the model, verifies the sha256 and refuses a download published + without one, then keeps a server alive while you dictate. The graphics card is + reached through CUDA, ROCm or Vulkan where the build allows. No key, no + account, nothing leaving the machine. - **Silence never reaches the API.** Handed near-silence, a transcription model invents a sentence instead of returning nothing ("Thanks for watching", or in Turkish "Altyazı M.K."). A recording is dropped when nothing rose 10 dB above @@ -126,11 +137,11 @@ running. right-click to delete. - **Turkish and English interface**, following the system locale by default. -## The global shortcut needs one logout +## The global shortcuts need one logout -KWin only reads `kglobalshortcutsrc` at startup, so the shortcut `install.sh` +KWin only reads `kglobalshortcutsrc` at startup, so the shortcuts `install.sh` writes will not fire until you log out and back in. Until then, Settings → -Shortcut → **built-in listener** reads `/dev/input` and catches the combination +Shortcuts → **built-in listener** reads `/dev/input` and catches the combination itself. The difference: it does not swallow the key, so `Ctrl+Space` also reaches the focused application (some editors will pop up autocomplete). The listener needs your user in the `input` group: `sudo usermod -aG input $USER`. @@ -144,7 +155,10 @@ ipc.py one request and one reply over the local socket audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting meeting.py channel split, speaker labelling, cleanup, minutes assistant.py running a dictation through Claude Code, Codex or OpenRouter -api.py transcription on either provider, OpenRouter cleanup (stdlib only) +api.py transcription and cleanup requests (stdlib only) +cleanup.py who rewrites the transcript: OpenRouter, here, Claude or Codex +ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving +hub.py what GitHub and Hugging Face have on offer today worker.py transcribe → clean up → clipboard → paste vad.py deciding whether a recording holds speech at all filetranscribe.py file transcription: ffmpeg, chunking, timestamps diff --git a/README.tr.md b/README.tr.md index b6d3917..38c4639 100644 --- a/README.tr.md +++ b/README.tr.md @@ -1,9 +1,8 @@ # Dikte -`Ctrl+Space`'e bas, konuş, tekrar bas. Ses OpenAI'ye ya da OpenRouter'a gidip -yazıya çevrilir, OpenRouter'daki bir model transkripti temizler (ıı'lar, -tekrarlar, eksik noktalama), sonuç panoya kopyalanır ve o an yazdığın pencereye -yapıştırılır. +`Ctrl+Space`'e bas, konuş, tekrar bas. Ses varsayılan olarak bu makinede yazıya +çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama), +sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır. KDE Plasma 6 / Wayland için yazıldı. Sistem paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi ve PyQt6. @@ -17,7 +16,8 @@ sadece Python standart kütüphanesi ve PyQt6. | | | |---|---| | API ve modeller | Temizleme kuralları | -| Ses dosyası | Geçmiş | +| Ajan | Toplantı | +| Ses dosyası | Kısayollar | ## Kurulum @@ -25,7 +25,7 @@ sadece Python standart kütüphanesi ve PyQt6. sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6 systemctl --user enable --now ydotool # otomatik yapıştırma için -./install.sh # ya da: ./install.sh "Ctrl+Alt+Space" +./install.sh # ya da: ./install.sh "Meta+Space" "Meta+Shift+Space" dikte # ilk açılışta ayarlar penceresi gelir ``` @@ -36,13 +36,18 @@ araçlarıyla çalışır: sudo apt install pulseaudio-utils xclip xdotool ffmpeg ``` -`install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik -başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar. +`install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik +başlatmayı ve iki global kısayolu kurar; tuşları da iki argümanı. `./update.sh` +son sürümü çeker ve bunları senin seçtiğin tuşlarla yerine koyar; +`./uninstall.sh` hepsini geri alır, `--purge` demedikçe ayarlarına ve +diktelerine dokunmaz. -Ayarlar penceresinde iki anahtar istenir: **OpenAI** ve **OpenRouter**. Sesi -yazıya çevirme ikisinden birinde çalışır (varsayılan `gpt-4o-transcribe`), -temizleme her zaman OpenRouter'da (`google/gemini-3.5-flash-lite`), yani tek bir -OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY` ve +Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıcı +seçer; ikisi de varsayılan olarak burada, kendi modellerinle çalışır. Bulutu +seçersen sesi yazıya çevirme **OpenAI**, **Groq** ya da **OpenRouter**'da +(varsayılan `gpt-4o-transcribe`), temizleme OpenRouter'da +(`google/gemini-3.5-flash-lite`) ya da kuruluysa Claude Code veya Codex'te +çalışır. Anahtarları boş bırakırsan `OPENAI_API_KEY`, `GROQ_API_KEY` ve `OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json` içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin. @@ -52,7 +57,7 @@ yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebili | Ne | Nasıl | | --- | --- | | Kaydı başlat / bitir | `Ctrl+Space`, ya da tepsi simgesine tıkla | -| Kaydı iptal et | Tepsi menüsü → *Kaydı iptal et*, ya da `dikte cancel` | +| Kaydı iptal et | `Ctrl+Alt+Space`, tepsi menüsü, ya da `dikte cancel` | | Ajana sesle komut ver | Tepsi menüsü → *Claude'a sor*, ya da `dikte ask` | | Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` | | Ayarlar | Tepsi menüsü → *Ayarlar*, ya da `dikte settings` | @@ -75,6 +80,12 @@ olmasını ister. ## Neler yapıyor +- **Her şey varsayılan olarak bu makinede çalışır.** Sesi yazıya çevirme + whisper.cpp, temizleme llama.cpp üzerinde; ikisini de önceden kurman gerekmez: + ayarlar penceresi programı ve modeli indirir, sha256'sını doğrular, + checksum'suz yayınlanmış bir indirmeyi reddeder, sen dikte ettikçe sunucuyu + ayakta tutar. Derleme destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan + üzerinden ulaşılır. Anahtar yok, hesap yok, makineden çıkan bir şey yok. - **Sessizlik API'ye gitmez.** Sessize yakın bir ses verildiğinde model boş dize döndürmez, bir cümle uydurur ("Altyazı M.K.", "Thanks for watching"). *O kaydın kendi* gürültü tabanının 10 dB üstüne en az 0,3 saniye çıkan bir şey @@ -124,11 +135,11 @@ olmasını ister. silebilirsin. - **Türkçe ve İngilizce arayüz**, varsayılan olarak sistem dilini izler. -## Global kısayol için bir kez oturum kapatmak gerekir +## Global kısayollar için bir kez oturum kapatmak gerekir KWin `kglobalshortcutsrc` dosyasını yalnızca açılışta okur, yani `install.sh`'ın -yazdığı kısayol oturumu yeniden açana kadar tetiklenmez. O zamana kadar Ayarlar → -Kısayol → **yerleşik dinleyici** `/dev/input` üzerinden kombinasyonu kendisi +yazdığı kısayollar oturumu yeniden açana kadar tetiklenmez. O zamana kadar Ayarlar → +Kısayollar → **yerleşik dinleyici** `/dev/input` üzerinden kombinasyonu kendisi yakalar. Tek farkı: tuşu yutmaz, yani `Ctrl+Space` odaktaki uygulamaya da iletilir (bazı editörlerde otomatik tamamlama açılabilir). Dinleyici kullanıcının `input` grubunda olmasını gerektirir: `sudo usermod -aG input $USER`. @@ -142,7 +153,10 @@ ipc.py yerel sokette bir istek, bir cevap audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg meeting.py kanal ayırma, konuşmacı etiketi, temizleme, tutanak assistant.py dikteyi Claude Code, Codex ya da OpenRouter'dan geçirme -api.py iki sağlayıcıda transkript + OpenRouter temizleme (yalnız stdlib) +api.py transkript ve temizleme istekleri (yalnız stdlib) +cleanup.py transkripti kim temizler: OpenRouter, burası, Claude ya da Codex +ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma +hub.py GitHub ve Hugging Face'te bugün ne olduğu worker.py transkript → temizleme → pano → yapıştırma vad.py kayıtta gerçekten konuşma var mı kararı filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları diff --git a/api.py b/api.py index 95d794c..1f35e60 100644 --- a/api.py +++ b/api.py @@ -1,34 +1,59 @@ -"""OpenAI and OpenRouter calls, stdlib only. +"""OpenAI, Groq, OpenRouter and this machine, stdlib only. -Transcription runs on either provider: OpenRouter mirrors OpenAI's -/audio/transcriptions endpoint field for field, so one multipart request serves -both and only the key, the base URL and the model id change. Cleanup is always -OpenRouter. +Transcription runs on any of the four: Groq and OpenRouter both mirror OpenAI's +/audio/transcriptions endpoint field for field, and ggml.py starts whisper.cpp +on that same path, so one multipart request serves all of them and only the key, +the base URL and the model id change. llama.cpp answers /chat/completions the way +OpenRouter does, so cleanup here is the same request too. + +What is on this machine has no key, and its base URL is not known until a server +is up, which is the one thing this module has to fill in for it. """ import collections +import contextlib +import http.client import json import mimetypes import os import secrets +import socket +import threading import urllib.error import urllib.request +import ggml from i18n import t APP_URL = "https://github.com/yusufipk/dikte" USER_AGENT = f"dikte/1.0 (+{APP_URL})" OPENAI_URL = "https://api.openai.com/v1" +GROQ_URL = "https://api.groq.com/openai/v1" OPENROUTER_URL = "https://openrouter.ai/api/v1" +# The floor for a local request. The timeouts elsewhere are sized for a hosted +# API, where a slow answer is a bill running; here the only thing being spent is +# time, and a long recording on a machine without a graphics card takes a good +# deal of it. Cutting that off would throw the work away for nothing. +LOCAL_TIMEOUT = 3600 + # Where a transcription request goes; built by config.Config.transcribe_target(). # `service` is the name the user sees in an error, `provider` the one the code # branches on. Target = collections.namedtuple("Target", "provider service api_key base_url model") -def timestamp_model(provider): - """Only whisper-1 returns segment times, and OpenRouter namespaces the id.""" +def timestamp_model(provider, selected=""): + """Which model answers with segment times. + + OpenAI keeps them to whisper-1 and OpenRouter namespaces that id. Everything + Groq transcribes with is a whisper, so the model already chosen does it and + the fallback is only for a provider left on its default. So is everything the + local server runs, whatever the file is called, and there asking for another + model would name one it has never heard of. + """ + if provider in ("groq", "local"): + return selected or "whisper-large-v3-turbo" return "openai/whisper-1" if provider == "openrouter" else "whisper-1" @@ -38,6 +63,142 @@ class ApiError(Exception): self.status = status +class Aborted(Exception): + """A request that was cut off from another thread rather than answered.""" + + +class Aborter: + """A Stop button that reaches the call a worker thread is blocked inside. + + urlopen() hands nothing back until the server has answered, and a whisper on + this machine is minutes away from answering, so a flag read between calls is + a Stop that does nothing until the work it was meant to stop is already + done. What is registered here is cut off where it stands instead. + """ + + def __init__(self): + self._lock = threading.Lock() + self._cancels = [] + self.aborted = False + + def abort(self): + with self._lock: + self.aborted = True + pending, self._cancels = self._cancels, [] + for cancel in pending: + cancel() + + def check(self): + if self.aborted: + raise Aborted + + @contextlib.contextmanager + def holding(self, cancel): + """Run `cancel` if an abort lands while this block is open.""" + with self._lock: + if self.aborted: + raise Aborted + self._cancels.append(cancel) + try: + yield + finally: + with self._lock: + with contextlib.suppress(ValueError): + self._cancels.remove(cancel) + + +class _Sockets: + """The connections one request is using, and whether it may still use any. + + A stop can land at any point of the handful of lines urllib takes to get + from "make a connection" to "wait for the reply", so this keeps the two + halves of the answer together: what is already open is cut, and anything + opened after that is refused rather than quietly left to block. + """ + + def __init__(self): + self._lock = threading.Lock() + self._conns = [] + self._cut = False + + def add(self, conn): + with self._lock: + if self._cut: + raise Aborted + self._conns.append(conn) + + def cut(self): + with self._lock: + self._cut = True + conns = list(self._conns) + for conn in conns: + _stop_using(conn) + + +def _stop_using(conn): + """Take a connection out of use, connected or not. + + A connection whose socket is not open yet would open one on the next line, + so the reconnect is turned off first. One that is open is being read from, + and close() alone leaves that read waiting for bytes which are never coming + now; the shutdown is what makes it return. + """ + conn.auto_open = 0 + sock = getattr(conn, "sock", None) + if sock is not None: + with contextlib.suppress(OSError): + sock.shutdown(socket.SHUT_RDWR) + with contextlib.suppress(OSError): + conn.close() + + +class _TrackedHTTP(urllib.request.HTTPHandler): + """urllib's own handler, handing the connection it opens to `sockets`. + + That connection is what a Stop is applied to, and urlopen() makes it out of + sight, inside the call that is about to block on it. + """ + + def __init__(self, sockets): + super().__init__() + self._sockets = sockets + + def http_open(self, req): + return self.do_open(self._connect, req) + + def _connect(self, host, **kwargs): + conn = http.client.HTTPConnection(host, **kwargs) + self._sockets.add(conn) + return conn + + +class _TrackedHTTPS(urllib.request.HTTPSHandler): + def __init__(self, sockets): + super().__init__() + self._sockets = sockets + + def https_open(self, req): + return self.do_open(self._connect, req, context=self._context) + + def _connect(self, host, **kwargs): + conn = http.client.HTTPSConnection(host, **kwargs) + self._sockets.add(conn) + return conn + + +@contextlib.contextmanager +def _opened(req, timeout, aborter): + """The response, left where `aborter` can cut it off.""" + if aborter is None: + with urllib.request.urlopen(req, timeout=timeout) as resp: + yield resp + return + sockets = _Sockets() + opener = urllib.request.build_opener(_TrackedHTTP(sockets), _TrackedHTTPS(sockets)) + with aborter.holding(sockets.cut), opener.open(req, timeout=timeout) as resp: + yield resp + + def explain(exc, service): """Turn an HTTP status into something the user can act on.""" if exc.status in (401, 403): @@ -53,16 +214,21 @@ def explain(exc, service): return ApiError(f"{service}: {exc}", exc.status) -def _request(url, data, headers, timeout=120): +def _request(url, data, headers, timeout=120, aborter=None): req = urllib.request.Request(url, data=data, headers=headers, method="POST") try: - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _opened(req, timeout, aborter) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as exc: body = exc.read().decode("utf-8", "replace") raise ApiError(f"HTTP {exc.code}: {_extract_error(body)}", exc.code) from exc - except urllib.error.URLError as exc: - raise ApiError(t("Could not connect: {reason}", reason=exc.reason)) from exc + except (OSError, http.client.HTTPException) as exc: + # A socket that went out from under the read is this run being stopped, + # not the network failing. URLError is an OSError, so both land here. + if aborter is not None and aborter.aborted: + raise Aborted from None + raise ApiError(t("Could not connect: {reason}", + reason=getattr(exc, "reason", exc))) from exc except json.JSONDecodeError as exc: raise ApiError(t("Could not parse the response: {error}", error=exc)) from exc @@ -106,7 +272,11 @@ def _multipart(fields, file_field, file_path): def _headers(provider, api_key, content_type=None): - headers = {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT} + headers = {"User-Agent": USER_AGENT} + # A server on this machine has nothing to authorise, and sending it a + # bearer token would only be a made-up one. + if api_key: + headers["Authorization"] = f"Bearer {api_key}" if content_type: headers["Content-Type"] = content_type if provider == "openrouter": @@ -116,17 +286,46 @@ def _headers(provider, api_key, content_type=None): return headers +def serving(server): + """The base URL of a local server, started if it is not up yet. + + It picks its own port, so this is the first moment its address exists. + serve() is idempotent: once it is running this costs nothing. + """ + try: + return server.serve() + except ggml.LocalError as exc: + raise ApiError(str(exc)) from None + + +def local_failure(service, server, exc): + """A server that died mid-request, explained by its own output. + + Without this the message is that the connection dropped, when the reason for + it was printed by the process at the other end. + """ + detail = server.error() + return ApiError(f"{service}: {exc}" + (f" ({detail})" if detail else ""), + exc.status) + + def _transcribe_request(target, wav_path, language, prompt, response_format, - granularity=None, timeout=300): - if not target.api_key: + granularity=None, timeout=300, aborter=None): + if target.provider == "local": + # The timeouts here are sized for a hosted API, where a slow answer is a + # bill running. Locally the only thing being spent is time. + target = target._replace(base_url=serving(ggml.whisper)) + timeout = max(timeout, LOCAL_TIMEOUT) + elif not target.api_key: raise ApiError(t("{service} API key is empty. Add it in Settings.", service=target.service)) fields = [("model", target.model), ("response_format", response_format)] if language and language != "auto": fields.append(("language", language)) # OpenRouter takes the hint field and throws it away, so spare it the bytes. - # The same words still reach the cleanup model as a glossary. - if prompt and target.provider == "openai": + # The same words still reach the cleanup model as a glossary. whisper.cpp + # takes it as the initial prompt, the way OpenAI does. + if prompt and target.provider != "openrouter": fields.append(("prompt", prompt)) if granularity: fields.append(("timestamp_granularities[]", granularity)) @@ -135,29 +334,77 @@ def _transcribe_request(target, wav_path, language, prompt, response_format, return _request( f"{target.base_url.rstrip('/')}/audio/transcriptions", body, _headers(target.provider, target.api_key, ctype), timeout=timeout, + aborter=aborter, ) except ApiError as exc: + if target.provider == "local": + raise local_failure(target.service, ggml.whisper, exc) from None raise explain(exc, target.service) from None -def transcribe(target, wav_path, language="", prompt="", timeout=300): +# Whisper marks the start of a word with a leading space, so a piece of text +# that does not begin with one continues the word before it rather than starting +# a new one. Both helpers below turn on that. +def _continues_a_word(previous, following): + return bool(previous) and not previous[-1:].isspace() and not following[:1].isspace() + + +def _local_text(text): + """whisper.cpp's segments, joined back into the flowing line OpenAI returns. + + Its plain text puts one segment per line, and a segment boundary falls + wherever the tokens fell, which in Turkish lands inside a word about as + often as between two. Nothing takes the line break's place: whisper's own + leading spaces are what separate the words, and a break inside "değ|iller" + has nothing on either side of it worth keeping. + """ + return "".join(text.split("\n")) + + +def _merge_word_splits(segments): + """Fold a segment that begins mid-word into the one it continues. + + The hosted whisper-1 hands back segments cut on sentences; whisper.cpp cuts + them on tokens, and a subtitle cue reading "değ" is not a cue. The times are + joined along with the text, so the merged segment still covers the whole + word. + """ + merged = [] + for seg in segments: + text = seg.get("text") or "" + if merged and _continues_a_word(merged[-1]["text"], text): + merged[-1]["text"] += text + merged[-1]["end"] = seg.get("end") or merged[-1]["end"] + continue + merged.append({"text": text, "start": seg.get("start") or 0.0, + "end": seg.get("end") or 0.0}) + return merged + + +def transcribe(target, wav_path, language="", prompt="", timeout=300, aborter=None): data = _transcribe_request( - target, wav_path, language, prompt, "json", timeout=timeout + target, wav_path, language, prompt, "json", timeout=timeout, aborter=aborter ) - text = (data.get("text") or "").strip() + text = data.get("text") or "" + if target.provider == "local": + text = _local_text(text) + text = text.strip() if not text: raise ApiError(t("Transcript came back empty.")) return text -def transcribe_segments(target, wav_path, language="", prompt="", timeout=300): +def transcribe_segments(target, wav_path, language="", prompt="", timeout=300, + aborter=None): """[(start_seconds, end_seconds, text)] using whisper-1's verbose response.""" data = _transcribe_request( - target._replace(model=timestamp_model(target.provider)), + target._replace(model=timestamp_model(target.provider, target.model)), wav_path, language, prompt, "verbose_json", - granularity="segment", timeout=timeout, + granularity="segment", timeout=timeout, aborter=aborter, ) segments = data.get("segments") or [] + if target.provider == "local": + segments = _merge_word_splits(segments) out = [] for seg in segments: text = (seg.get("text") or "").strip() @@ -166,18 +413,55 @@ def transcribe_segments(target, wav_path, language="", prompt="", timeout=300): end = float(seg.get("end") or 0.0) out.append((start, max(end, start), text)) if not out: - text = (data.get("text") or "").strip() + text = data.get("text") or "" + if target.provider == "local": + text = _local_text(text) + text = text.strip() if not text: raise ApiError(t("Transcript came back empty.")) out = [(0.0, 0.0, text)] return out +def _thinking(payload, provider, reasoning): + """Ask for as much thinking as this provider understands, or for none. + + An empty level means "whatever the model does on its own", so nothing is + sent. The two mean opposite things by that, which is why the setting is kept + per provider: OpenRouter's cleanup models answer straight away, while a local + model that was trained to think will think, and cleanup is punctuation rather + than a job worth thinking about. + """ + if not reasoning: + return + if provider == "local-llm": + # What llama.cpp passes to the chat template. The models that think read + # it; the ones that do not ignore it. + payload["chat_template_kwargs"] = {"enable_thinking": reasoning != "none"} + elif reasoning != "none": + # The thinking itself is never shown, so ask for it to be left out. + payload["reasoning"] = {"effort": reasoning, "exclude": True} + + +def local_ceiling(text): + """How much of a reply is worth waiting for from a model on this machine. + + Cleanup gives back what it was given, near enough, so a reply several times + the length of the transcript is a model that has lost the thread rather than + one doing the job. A small one will happily repeat the transcript until the + context is full, and every one of those tokens is a second of somebody + waiting. A hosted model is left alone: there the same runaway is rare, and a + ceiling would cut the minutes short instead. + """ + return max(512, len(text)) + + def cleanup(text, api_key, model, system_prompt, reasoning="", - base_url=OPENROUTER_URL, timeout=180): - if not api_key: + base_url=OPENROUTER_URL, timeout=180, provider="openrouter", + service="OpenRouter", aborter=None): + if not api_key and provider != "local-llm": raise ApiError(t("{service} API key is empty. Add it in Settings.", - service="OpenRouter")) + service=service)) payload = { "model": model, "temperature": 0, @@ -186,25 +470,30 @@ def cleanup(text, api_key, model, system_prompt, reasoning="", {"role": "user", "content": f"\n{text}\n"}, ], } - # An empty level means "whatever the model does on its own"; anything else is - # one of OpenRouter's efforts. The thinking itself is never shown, so ask for - # it to be left out of the reply. - if reasoning: - payload["reasoning"] = {"effort": reasoning, "exclude": True} + if provider == "local-llm": + payload["max_tokens"] = local_ceiling(text) + _thinking(payload, provider, reasoning) try: data = _request( f"{base_url.rstrip('/')}/chat/completions", json.dumps(payload).encode("utf-8"), - _headers("openrouter", api_key, "application/json"), - timeout=timeout, + _headers(provider, api_key, "application/json"), + timeout=timeout, aborter=aborter, ) except ApiError as exc: - raise explain(exc, "OpenRouter") from None + raise explain(exc, service) from None choices = data.get("choices") or [] if not choices: raise ApiError(_extract_error(json.dumps(data))) - content = ((choices[0].get("message") or {}).get("content") or "").strip() + message = choices[0].get("message") or {} + content = (message.get("content") or "").strip() if not content: + # A thinking model can spend the whole reply on the thinking and leave + # nothing to paste. Worth naming, because the fix is a setting rather + # than a retry: cleanup is not a job that wants thinking. + if message.get("reasoning_content") or message.get("reasoning"): + raise ApiError(t("The cleanup model spent its whole reply on " + "thinking. Set Thinking to \u201cOff\u201d.")) raise ApiError(t("The cleanup model returned an empty reply.")) return content @@ -297,17 +586,22 @@ def openrouter_models(api_key="", transcription=False): return sorted(m["id"] for m in models if m.get("id")) -def openai_models(api_key, base_url=OPENAI_URL): +def openai_models(api_key, base_url=OPENAI_URL, service="OpenAI"): + """The audio models of anything that speaks OpenAI's /models, Groq included. + + `service` is only the name an error is written in, so a Groq key that is + refused says Groq rather than OpenAI. + """ if not api_key: raise ApiError(t("{service} API key is empty. Add it in Settings.", - service="OpenAI")) + service=service)) try: data = _get_json( f"{base_url.rstrip('/')}/models", {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT}, ) except ApiError as exc: - raise explain(exc, "OpenAI") from None + raise explain(exc, service) from None ids = [m["id"] for m in data.get("data", []) if m.get("id")] audio = [i for i in ids if "transcribe" in i or "whisper" in i] return sorted(audio or ids) diff --git a/assistant.py b/assistant.py index 20339bb..f708ab3 100644 --- a/assistant.py +++ b/assistant.py @@ -75,7 +75,10 @@ CODEX_ITEMS = { CLAUDE_EFFORT = {"none": "low", "minimal": "low", "low": "low", "medium": "medium", "high": "high", "xhigh": "xhigh", "max": "max"} -CODEX_EFFORT = {"none": "minimal", "minimal": "minimal", "low": "low", +# "minimal" was Codex's bottom rung until the newer models replaced it with +# "none", and each of them rejects the other's word for it with a 400. "low" is +# the one every model has, so the two lowest rungs land there instead. +CODEX_EFFORT = {"none": "low", "minimal": "low", "low": "low", "medium": "medium", "high": "high", "xhigh": "high", "max": "high"} @@ -421,7 +424,7 @@ def _conclude(found, code, stderr, session, service): if code != 0 and not found["answer"]: if session and _session_missing(stderr): raise _SessionGone() - raise AssistantError(_last_line(stderr) or found["failure"] or t( + raise AssistantError(last_line(stderr) or found["failure"] or t( "{service} exited with code {code}.", service=service, code=code)) if found["failure"] and not found["answer"]: raise AssistantError(found["failure"]) @@ -480,6 +483,11 @@ def _finish(proc): return stderr -def _last_line(text): +def last_line(text): + """The line worth showing out of a CLI's stderr: the last one it wrote. + + Shared with cleanup, which runs the same two programs for a different job + and fails the same way when they are unhappy. + """ lines = [line for line in (text or "").splitlines() if line.strip()] return lines[-1].strip() if lines else "" diff --git a/cleanup.py b/cleanup.py new file mode 100644 index 0000000..b6485b3 --- /dev/null +++ b/cleanup.py @@ -0,0 +1,213 @@ +"""Who rewrites the transcript once it has been heard. + +Normally a small model on OpenRouter: one request, a second, a few tenths of a +cent. A machine with Claude Code or Codex on it is already paying for a model +though, and the subscription that answers "put that in my calendar on Thursday" +can just as well take the "eee"s out of a sentence. No second key, no second +bill. It costs seconds rather than one, because a CLI opens a whole session to +do it, which is the trade. + +Whoever does it, the job is the same one: no tools, no files, no memory of the +last dictation. There is nothing here to look up and nothing to carry over, and +a transcript is text from a microphone rather than an instruction, so the less +the agent can reach while it reads one, the better. +""" + +import os +import shutil +import subprocess +import tempfile + +import api +import assistant +import ggml +from i18n import t + +PROVIDERS = ("openrouter", "local", "claude", "codex") + + +class CleanupError(api.ApiError): + """What a CLI could not do. + + An ApiError because to the chain a cleanup that failed is a cleanup that + failed, whichever way it was run, and every caller already catches one and + keeps the raw transcript. + """ + + +def provider(conf): + chosen = conf["cleanup_provider"] + return chosen if chosen in PROVIDERS else "openrouter" + + +def executable(name): + """The CLI a provider runs, or "" when it needs none.""" + return {"claude": "claude", "codex": "codex"}.get(name, "") + + +def model(conf): + """Which model does the cleaning, for the history and the settings window.""" + name = provider(conf) + if name == "local": + return conf["local_llm_model"] + if name == "claude": + return conf["cleanup_claude_model"].strip() or "haiku" + if name == "codex": + # Codex is left on whatever it is set to unless a model is typed in, so + # here there is only the name of the thing that did it. + return conf["cleanup_codex_model"].strip() or "codex" + return conf["cleanup_model"] + + +def run(text, conf, system_prompt, timeout=180, aborter=None): + """Hand the transcript to whoever is set to clean it up. + + `aborter` is only of use to the two that answer over HTTP; a CLI is stopped + between blocks instead, which is close enough when a block is seconds. + """ + name = provider(conf) + if name == "openrouter": + return api.cleanup( + text, conf.openrouter_key(), conf["cleanup_model"], system_prompt, + reasoning=conf["cleanup_reasoning"], + base_url=conf["openrouter_base_url"], timeout=timeout, + aborter=aborter, + ) + if name == "local": + return _local(text, conf, system_prompt, timeout, aborter) + runner = _claude if name == "claude" else _codex + return runner(text, conf, system_prompt, timeout) + + +def _local(text, conf, system_prompt, timeout, aborter=None): + """llama.cpp, on this machine, answering the request OpenRouter answers. + + No key and no bill, and the address does not exist until the server is up, + which is what starting it here is for. The timeout is the hosted one raised: + the only thing being spent is time. + """ + service = t("Local model") + try: + return api.cleanup( + text, "", conf["local_llm_model"], system_prompt, + reasoning=conf["local_llm_reasoning"], + base_url=api.serving(ggml.llm), + timeout=max(timeout, api.LOCAL_TIMEOUT), + provider="local-llm", service=service, aborter=aborter, + ) + except api.ApiError as exc: + # A server that died mid-request would otherwise report only that the + # connection dropped, when the reason is in its own output. + raise api.local_failure(service, ggml.llm, exc) from None + + +def _wrap(text): + """The same fence the OpenRouter call puts around it: this is the material, + not the instruction, however much of it reads like one.""" + return f"\n{text}\n" + + +# --- Claude Code ---------------------------------------------------------- + +def _claude(text, conf, system_prompt, timeout): + cmd = [ + "claude", "-p", _wrap(text), + # --system-prompt rather than --append-system-prompt: the cleanup rules + # are the whole job, and Claude Code's own instructions are about + # working on a codebase. + "--system-prompt", system_prompt, + "--model", model(conf), + "--output-format", "text", + "--tools", "", # nothing to run + "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', + "--no-session-persistence", # nothing to resume + ] + effort = assistant.CLAUDE_EFFORT.get(conf["cleanup_reasoning"], "") + if effort: + cmd += ["--effort", effort] + + answer = _output(cmd, timeout, "Claude") + if not answer: + raise CleanupError(t("{service} answered with nothing.", service="Claude")) + return answer + + +# --- Codex ---------------------------------------------------------------- + +def _codex(text, conf, system_prompt, timeout): + # Codex takes no system prompt of its own, so the rules ride in front of the + # transcript, kept apart from it so the two are not read as one. + body = f"{system_prompt}\n\n---\n\n{_wrap(text)}" + cmd = [ + "codex", "exec", + "--sandbox", "read-only", # it has no reason to touch the disk + "--skip-git-repo-check", + "--ephemeral", # nothing to resume + "--color", "never", + "-c", 'approval_policy="never"', # there is nobody here to approve + ] + if conf["cleanup_codex_model"].strip(): + cmd += ["-m", conf["cleanup_codex_model"].strip()] + effort = assistant.CODEX_EFFORT.get(conf["cleanup_reasoning"], "") + if effort: + cmd += ["-c", f'model_reasoning_effort="{effort}"'] + + # `codex exec` prints a header, its thinking and a token count around the + # answer; the file it writes on the way out is the answer on its own. + handle, last_message = tempfile.mkstemp(prefix="dikte-cleanup-", suffix=".txt") + os.close(handle) + cmd += ["-o", last_message, body] + try: + _output(cmd, timeout, "Codex") + answer = _read(last_message) + finally: + try: + os.unlink(last_message) + except OSError: + pass + + if not answer: + raise CleanupError(t("{service} answered with nothing.", service="Codex")) + return answer + + +def _read(path): + try: + with open(path, encoding="utf-8", errors="replace") as fh: + return fh.read().strip() + except OSError: + return "" + + +# --- running a CLI -------------------------------------------------------- + +def _output(cmd, timeout, service): + """Run cmd to the end and return what it printed. + + It runs in the home directory rather than wherever the agent is pointed: a + project's instructions have opinions about how text should be written, and + none of them are about this transcript. + """ + binary = cmd[0] + if not shutil.which(binary): + raise CleanupError(t( + "{binary} not found. Install it, or have OpenRouter clean up " + "instead, under Settings → API and models.", binary=binary, + )) + try: + done = subprocess.run( + cmd, cwd=os.path.expanduser("~"), stdin=subprocess.DEVNULL, + capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=timeout, + ) + except subprocess.TimeoutExpired: + raise CleanupError(t("{service} did not finish within {seconds} seconds.", + service=service, seconds=timeout)) from None + except OSError as exc: + raise CleanupError(t("Could not run {binary}: {error}", + binary=binary, error=exc)) from exc + if done.returncode != 0: + raise CleanupError(assistant.last_line(done.stderr) or t( + "{service} exited with code {code}.", + service=service, code=done.returncode)) + return (done.stdout or "").strip() diff --git a/cli.py b/cli.py index f45798c..7992bd0 100644 --- a/cli.py +++ b/cli.py @@ -25,6 +25,7 @@ from PyQt6.QtCore import QCoreApplication, QTimer import api import assistant import audio +import cleanup import config as cfg import filetranscribe import hotkey @@ -44,14 +45,6 @@ GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"} IDEMPOTENT_VERBS = {"cancel", "stop", "quit", "restart", "ask-cancel", "ask-reset", "meeting-cancel"} -# Which desktop entry, name and setting belong to each of the three shortcuts. -SHORTCUTS = { - "toggle": (hotkey.DESKTOP_ID, "Dikte: start/stop recording", "shortcut"), - "ask": (hotkey.ASK_DESKTOP_ID, "Dikte: ask Claude Code", "assistant_shortcut"), - "meeting": (hotkey.MEETING_DESKTOP_ID, "Dikte: start/end a meeting recording", - "meeting_shortcut"), -} - _app = None @@ -662,12 +655,14 @@ def cmd_devices(opts): def cmd_models(opts): conf = cfg.Config() + who = cfg.TRANSCRIBERS[opts.provider] try: - if opts.provider == "openai": - models = api.openai_models(conf.openai_key(), conf["openai_base_url"]) - else: + if opts.provider == "openrouter": models = api.openrouter_models(conf.openrouter_key(), transcription=opts.transcription) + else: + models = api.openai_models(conf.api_key(who.key), conf[who.url], + who.service) except api.ApiError as exc: return fail(opts, exc) return out(opts, {"ok": True, "provider": opts.provider, "models": models}, @@ -677,19 +672,21 @@ def cmd_models(opts): def cmd_test_key(opts): conf = cfg.Config() results = {} - if opts.which in ("openai", "all"): + for name, who in cfg.TRANSCRIBERS.items(): + if opts.which not in (name, "all"): + continue try: - count = len(api.openai_models(conf.openai_key(), conf["openai_base_url"])) - results["openai"] = {"ok": True, - "message": f"connection works, {count} models visible"} + if name == "openrouter": + # The one key that also pays for cleanup, so it reports credit + # rather than a model count. + message = api.openrouter_key_status(conf.openrouter_key()) + else: + count = len(api.openai_models(conf.api_key(who.key), conf[who.url], + who.service)) + message = f"connection works, {count} models visible" + results[name] = {"ok": True, "message": message} except api.ApiError as exc: - results["openai"] = {"ok": False, "message": str(exc)} - if opts.which in ("openrouter", "all"): - try: - results["openrouter"] = {"ok": True, - "message": api.openrouter_key_status(conf.openrouter_key())} - except api.ApiError as exc: - results["openrouter"] = {"ok": False, "message": str(exc)} + results[name] = {"ok": False, "message": str(exc)} everything_ok = all(item["ok"] for item in results.values()) lines = [f"{'✓' if item['ok'] else '✗'} {name}: {item['message']}" for name, item in results.items()] @@ -701,9 +698,9 @@ def cmd_shortcut(opts): conf = cfg.Config() if opts.shortcut == "status": rows = {} - for name, (desktop_id, _label, key) in SHORTCUTS.items(): - rows[name] = {"registered": hotkey.shortcut_status(desktop_id), - "configured": conf[key]} + for name, spec in hotkey.SHORTCUTS.items(): + rows[name] = {"registered": hotkey.shortcut_status(spec.desktop_id), + "configured": conf[spec.setting]} lines = [f"{name:8} {row['registered'] or '(not installed)':16} " f"setting: {row['configured'] or '(none)'}" for name, row in rows.items()] @@ -711,28 +708,29 @@ def cmd_shortcut(opts): return out(opts, {"ok": True, "shortcuts": rows, "listener": conf["evdev_hotkey"]}, "\n".join(lines)) - desktop_id, label, key = SHORTCUTS[opts.which] + spec = hotkey.SHORTCUTS[opts.which] if opts.shortcut == "remove": - hotkey.remove_shortcut(desktop_id) + hotkey.remove_shortcut(spec.desktop_id) return out(opts, {"ok": True, "removed": opts.which}, f"Removed the {opts.which} shortcut.") - combo = (opts.combo or conf[key] or ("Ctrl+Space" if opts.which == "toggle" else "")).strip() + combo = (opts.combo or conf[spec.setting] or spec.fallback).strip() if not combo: return fail(opts, "no combination given and none stored; pass --combo", 2) if not hotkey.valid_shortcut(combo): return fail(opts, f"cannot parse that combination: {combo}", 2) - clashes = hotkey.conflicting_shortcuts(combo, desktop_id) + clashes = hotkey.conflicting_shortcuts(combo, spec.desktop_id) if clashes and not opts.force: return fail(opts, f"{combo} is also used by: {', '.join(clashes[:6])}. " "Pass --force to install it anyway.", 1, conflicts=clashes) ok, message = hotkey.install_shortcut( - combo, ipc.command_for(opts.which), name=label, desktop_id=desktop_id, + combo, ipc.command_for(spec.verb), name=spec.name, + desktop_id=spec.desktop_id, ) if not ok: return fail(opts, message) - conf[key] = combo + conf[spec.setting] = combo try: conf.save() except OSError as exc: @@ -767,16 +765,18 @@ def cmd_status(opts): def cmd_doctor(opts): """What the settings window checks behind its buttons, in one pass.""" conf = cfg.Config() - programs = {name: shutil.which(name) or "" - for name in ("pw-record", "wl-copy", "ydotool", "ffmpeg", - "pactl", "kwriteconfig6", - assistant.executable(assistant.provider(conf)) or "claude")} + wanted = ["pw-record", "wl-copy", "ydotool", "ffmpeg", "pactl", "kwriteconfig6", + assistant.executable(assistant.provider(conf)) or "claude", + cleanup.executable(cleanup.provider(conf))] + programs = {name: shutil.which(name) or "" for name in wanted if name} target = conf.transcribe_target() + cleaner = cleanup.provider(conf) checks = { "programs": programs, "transcription": {"provider": target.provider, "model": target.model, "key": bool(target.api_key)}, - "cleanup": {"enabled": conf["cleanup_enabled"], "model": conf["cleanup_model"], + "cleanup": {"enabled": conf["cleanup_enabled"], "provider": cleaner, + "model": cleanup.model(conf), "key": bool(conf.openrouter_key())}, "agent": {"provider": assistant.provider(conf), "directory": assistant.working_dir(conf)}, @@ -787,8 +787,11 @@ def cmd_doctor(opts): lines += [ f"{'✓' if target.api_key else '✗'} {target.service} key, transcribing on " f"{target.model}", - f"{'✓' if conf.openrouter_key() else '✗'} OpenRouter key, cleaning up on " - f"{conf['cleanup_model']}", + # Cleanup on a CLI needs no key, so what is checked is the program. + (f"{'✓' if conf.openrouter_key() else '✗'} OpenRouter key, cleaning up on " + f"{conf['cleanup_model']}") if cleaner == "openrouter" else + (f"{'✓' if programs[cleanup.executable(cleaner)] else '✗'} " + f"{cleanup.executable(cleaner)}, cleaning up on {cleanup.model(conf)}"), f"{'✓' if checks['running'] else '·'} application " + ("running" if checks["running"] else "not running"), ] @@ -986,30 +989,31 @@ def build_parser(): # --- the machine ------------------------------------------------------ leaf(subs, "devices", "microphones and monitors").set_defaults(func=cmd_devices) models = leaf(subs, "models", "model ids a provider offers") - models.add_argument("--provider", choices=("openrouter", "openai"), + models.add_argument("--provider", choices=tuple(cfg.TRANSCRIBERS), default="openrouter") models.add_argument("--transcription", action="store_true", help="only the speech-to-text ones") models.set_defaults(func=cmd_models) test = leaf(subs, "test-key", "check the API keys") test.add_argument("which", nargs="?", default="all", - choices=("all", "openai", "openrouter")) + choices=("all", *cfg.TRANSCRIBERS)) test.set_defaults(func=cmd_test_key) leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor) - shortcut = leaf(subs, "shortcut", "the KDE global shortcuts") + shortcut = leaf(subs, "shortcut", "the desktop's global shortcuts") inner = shortcut.add_subparsers(dest="shortcut", metavar="") shortcut.set_defaults(func=_needs_subcommand(shortcut)) leaf(inner, "status", "what is registered").set_defaults(func=cmd_shortcut) install = leaf(inner, "install", "register one") install.add_argument("which", nargs="?", default="toggle", - choices=tuple(SHORTCUTS)) + choices=tuple(hotkey.SHORTCUTS)) install.add_argument("--combo", help="e.g. Ctrl+Alt+Space") install.add_argument("--force", action="store_true", help="install it even if something else uses it") install.set_defaults(func=cmd_shortcut) remove = leaf(inner, "remove", "unregister one") - remove.add_argument("which", nargs="?", default="toggle", choices=tuple(SHORTCUTS)) + remove.add_argument("which", nargs="?", default="toggle", + choices=tuple(hotkey.SHORTCUTS)) remove.set_defaults(func=cmd_shortcut) # --- the application -------------------------------------------------- diff --git a/config.py b/config.py index 58018e7..c80bbfb 100644 --- a/config.py +++ b/config.py @@ -1,5 +1,6 @@ """Settings storage, in the place this system keeps a program's settings.""" +import collections import hashlib import json import os @@ -7,8 +8,10 @@ import pathlib import sys import api +import ggml import i18n import paste +from i18n import t def _xdg(var, default): @@ -377,16 +380,53 @@ DEFAULTS = { "ui_language": "auto", # auto | tr | en "openai_api_key": "", "openai_base_url": "https://api.openai.com/v1", + "groq_api_key": "", + "groq_base_url": "https://api.groq.com/openai/v1", "openrouter_api_key": "", "openrouter_base_url": "https://openrouter.ai/api/v1", - "transcribe_provider": "openai", # openai | openrouter + "transcribe_provider": "local", # "local", or a key of TRANSCRIBERS "transcribe_model": "gpt-4o-transcribe", # used when provider is openai + "groq_transcribe_model": "whisper-large-v3-turbo", "openrouter_transcribe_model": "openai/gpt-4o-transcribe", "language": "tr", "transcribe_prompt": "", + + # --- whisper.cpp, on this machine --------------------------------------- + # The program and the model are both fetched from Settings; empty means + # nothing has been downloaded yet, which is what opens Settings on a first + # run. + # Pointed at the suggestion rather than at nothing, so the settings window + # opens with the Download button already on the right model. + "local_model": ggml.SUGGESTED_WHISPER, + "local_threads": 0, # 0 -> whisper.cpp picks + "local_gpu": True, + "local_preload": True, # load the model while Dikte starts, rather + # than on the first dictation + "local_binary": "", # empty -> whichever copy ggml.py finds + "cleanup_enabled": True, + "cleanup_provider": "openrouter", # a name in cleanup.PROVIDERS "cleanup_model": "google/gemini-3.5-flash-lite", + "cleanup_claude_model": "haiku", # Claude Code: an alias, or a full model id + "cleanup_codex_model": "", # empty -> whatever Codex is set to "cleanup_reasoning": "", # empty -> whatever the model does by default + + # --- llama.cpp, on this machine ----------------------------------------- + # Kept apart from the meeting settings on purpose. Cleanup is punctuation + # and filler words, which a small model does in a moment; the minutes are a + # summary of an hour, which it does not. + "local_llm_model": "", # a file name, e.g. gemma-3-4b-it-Q4_K_M.gguf + # Where the model list is read from; the settings window offers the + # publishers ggml.py knows of and takes any other one that is typed in. + "local_llm_repo": ggml.SUGGESTED_LLM[0], + "local_llm_threads": 0, + "local_llm_gpu": True, + "local_llm_context": 8192, + "local_llm_binary": "", + "local_llm_preload": False, # heavier than whisper, so only when asked + # Off rather than empty: a model trained to think will, and 300 tokens of + # reasoning about a comma is 300 tokens of waiting. + "local_llm_reasoning": "none", "cleanup_prompt": "", # empty -> language-specific default "auto_paste": True, "paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac @@ -399,6 +439,10 @@ DEFAULTS = { "min_voiced_seconds": 0.3, "filter_hallucinations": True, "shortcut": "Ctrl+Space", + # Ctrl+Alt+Space rather than Escape: the combination the recording started + # with, one modifier along. Escape belongs to whatever window has focus, and + # while you are dictating something else usually has it. + "cancel_shortcut": "Ctrl+Alt+Space", "evdev_hotkey": False, "overlay_corner": "bottom-left", "keep_audio": False, @@ -453,6 +497,22 @@ LEGACY_PROMPTS = { "154fc5aca1166f00eebda705f848f0391bfbf5fe", # 1.2 English } +# Every provider speech to text can run on, and the four settings that describe +# one. A fifth is a row here rather than another branch in transcribe_target(), +# another key row in the settings window and another line in save and load. The +# order is the order the provider box offers them in. `service` is the name the +# user sees; the environment variable that stands in for an empty key is the +# name of its setting, shouted. +Transcriber = collections.namedtuple("Transcriber", "service key url model") +TRANSCRIBERS = { + "openai": Transcriber("OpenAI", "openai_api_key", "openai_base_url", + "transcribe_model"), + "groq": Transcriber("Groq", "groq_api_key", "groq_base_url", + "groq_transcribe_model"), + "openrouter": Transcriber("OpenRouter", "openrouter_api_key", + "openrouter_base_url", "openrouter_transcribe_model"), +} + # Corners used to be stored with Turkish names. _CORNER_MIGRATION = { "sol-alt": "bottom-left", "sağ-alt": "bottom-right", @@ -501,21 +561,76 @@ class Config: def get(self, key, default=None): return self.data.get(key, DEFAULTS.get(key, default)) + def api_key(self, setting): + """A stored key, or the environment variable that shares its name.""" + return self[setting].strip() or os.environ.get(setting.upper(), "").strip() + def openai_key(self): - """Fall back to the environment when no key is stored.""" - return self["openai_api_key"].strip() or os.environ.get("OPENAI_API_KEY", "").strip() + return self.api_key("openai_api_key") + + def groq_key(self): + return self.api_key("groq_api_key") def openrouter_key(self): - return self["openrouter_api_key"].strip() or os.environ.get("OPENROUTER_API_KEY", "").strip() + return self.api_key("openrouter_api_key") def transcribe_target(self): - """Key, endpoint and model for whichever provider does speech to text.""" - if self["transcribe_provider"] == "openrouter": - return api.Target("openrouter", "OpenRouter", self.openrouter_key(), - self["openrouter_base_url"], - self["openrouter_transcribe_model"]) - return api.Target("openai", "OpenAI", self.openai_key(), - self["openai_base_url"], self["transcribe_model"]) + """Key, endpoint and model for whichever provider does speech to text. + + The local one is not in the table and leaves its base URL empty on + purpose: the server picks a port when it starts, and reading a setting + must not be what launches a process. api.py fills the address in when it + is about to send the request, which is the moment the server is needed + anyway. + """ + name = self["transcribe_provider"] + if name == "local": + return api.Target("local", t("Local whisper"), "", "", + self["local_model"]) + if name not in TRANSCRIBERS: + # A config written by a fork, or by a version that dropped one. The + # shipped default is not in the table, so this names the hosted one + # to land on rather than reading it from there. + name = "openai" + who = TRANSCRIBERS[name] + return api.Target(name, who.service, self.api_key(who.key), + self[who.url], self[who.model]) + + def transcribe_ready(self): + """Whether speech to text could run right now, without opening Settings.""" + if self["transcribe_provider"] == "local": + return self.local_whisper_ready() + return bool(self.transcribe_target().api_key) + + def local_whisper_ready(self): + return bool(ggml.program_path(ggml.WHISPER, self["local_binary"]) + and self["local_model"] + and ggml.have_model(ggml.whisper_model_path(self["local_model"]))) + + def local_llm_ready(self): + return bool(ggml.program_path(ggml.LLAMA, self["local_llm_binary"]) + and self["local_llm_model"] + and ggml.have_model(ggml.llm_model_path(self["local_llm_model"]))) + + def apply_local(self): + """Hand the local settings to the servers, restarting what they change.""" + ggml.whisper.configure( + model=self["local_model"], + threads=int(self["local_threads"]), + gpu=bool(self["local_gpu"]), + binary=self["local_binary"], + ) + ggml.llm.configure( + model=self["local_llm_model"], + threads=int(self["local_llm_threads"]), + gpu=bool(self["local_llm_gpu"]), + binary=self["local_llm_binary"], + context=int(self["local_llm_context"]), + ) + + def uses_local_llm(self): + """Whether anything is set to run the local cleanup model.""" + return self["cleanup_provider"] == "local" def cleanup_prompt(self, with_timestamps=False, with_speakers=False, subtitles=False): diff --git a/dikte.py b/dikte.py index f1b6418..cda7f54 100755 --- a/dikte.py +++ b/dikte.py @@ -7,9 +7,13 @@ terminal talks to. Every verb it answers is in cli.py, which is also what runs command line says "there is no instance to talk to, so be one". """ +import contextlib import json import os +import signal +import socket import sys +import threading # A Wayland client cannot place a window in a screen corner, so the indicator # is drawn through XWayland. @@ -25,7 +29,7 @@ if sys.platform == "darwin": os.environ.get("PATH", "")) if part ) -from PyQt6.QtCore import QTimer, QElapsedTimer # noqa: E402 +from PyQt6.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402 from PyQt6.QtGui import QAction, QIcon # noqa: E402 from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402 from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402 @@ -34,6 +38,7 @@ import assistant # noqa: E402 import audio # noqa: E402 import cli # noqa: E402 import config as cfg # noqa: E402 +import ggml # noqa: E402 import hotkey # noqa: E402 import i18n # noqa: E402 import ipc # noqa: E402 @@ -101,6 +106,9 @@ class Dikte: self.meeting_recorder = audio.MeetingRecorder() self.meetings = MeetingPipeline(self.conf) self.evdev = hotkey.listener() + # Before anything of ours is started: a server from a Dikte that was + # killed outright is still holding a model in memory. + ggml.sweep() self.recorder.level.connect(self._on_level) self.recorder.stopped.connect(self._on_recorded) @@ -161,8 +169,10 @@ class Dikte: self.ask_cancel_action.setEnabled(False) self.menu.addAction(self.ask_cancel_action) - self.cancel_action = QAction(t("Cancel recording"), self.menu) - self.cancel_action.triggered.connect(self.cancel) + self.cancel_action = QAction(t("Discard the recording"), self.menu) + # The inner method, so that a menu click is never mistaken for the KDE + # shortcut echoing the built-in listener's press. + self.cancel_action.triggered.connect(self._cancel) self.cancel_action.setEnabled(False) self.menu.addAction(self.cancel_action) self.menu.addSeparator() @@ -315,6 +325,9 @@ class Dikte: def toggle_meeting(self): self._external("meeting", self._toggle_meeting) + def cancel(self): + self._external("cancel", self._cancel) + def _external(self, name, handler): # The built-in listener sees the key press the instant it happens, so a # toggle arriving right behind one is the KDE shortcut catching up on @@ -335,7 +348,8 @@ class Dikte: if timer is None: timer = self.last_evdev[name] = QElapsedTimer() timer.restart() - handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask} + handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask, + "cancel": self._cancel} handlers.get(name, self._toggle)() def _retire_listener(self): @@ -529,7 +543,7 @@ class Dikte: self.ask_overlay.show_busy(t("Transcribing…")) self.recorder.stop() - def cancel(self): + def _cancel(self): """Throw away whichever recording is running.""" if not self.recording: return @@ -553,7 +567,7 @@ class Dikte: def cancel_ask(self): """Call off the agent, whether it is still recording or already working.""" if self.ask_state == RECORDING: - self.cancel() + self._cancel() elif self.ask_state == BUSY: self.ask_overlay.show_busy(t("Stopping…")) self.ask_pipeline.cancel() @@ -801,10 +815,7 @@ class Dikte: def open_settings(self): if self.settings_window is None: - self.settings_window = SettingsWindow( - self.conf, launch_command(), meeting_command(), self.meetings, - ask_command(), - ) + self.settings_window = SettingsWindow(self.conf, self.meetings) self.settings_window.applied.connect(self._apply_settings) self.settings_window.finished.connect(self._settings_closed) self.settings_window.show() @@ -815,18 +826,52 @@ class Dikte: # Don't drop the object while its own signal is still being delivered. QTimer.singleShot(0, lambda: setattr(self, "settings_window", None)) + def _apply_local(self): + """Pass the local settings on, and hold the models ready if asked to. + + Loading a model takes a second or two for whisper and longer for an LLM. + Doing it while Dikte starts rather than on the first dictation is the + whole reason a server is kept alive instead of running the program once + per recording; the checkboxes are there for the machine whose memory is + wanted elsewhere. + """ + self.conf.apply_local() + wanted = [] + if self.conf["transcribe_provider"] == "local": + if self.conf["local_preload"] and self.conf.local_whisper_ready(): + wanted.append((ggml.whisper, "whisper")) + else: + ggml.whisper.stop() # give the memory back when it is not in use + if self.conf.uses_local_llm(): + if self.conf["local_llm_preload"] and self.conf.local_llm_ready(): + wanted.append((ggml.llm, "llama")) + else: + ggml.llm.stop() + + def warm(): + for server, name in wanted: + try: + server.serve() + except ggml.LocalError as exc: + # Not worth an indicator: the first dictation raises the + # same thing where the user can act on it. + print(f"dikte: {name}: {exc}", file=sys.stderr) + + if wanted: + threading.Thread(target=warm, daemon=True).start() + def _apply_settings(self): self.overlay.corner = self.conf["overlay_corner"] self.ask_overlay.corner = self.conf["overlay_corner"] + self._apply_local() self._build_tray() self._refresh_tray() # Where the desktop has no shortcut registry of its own, the listener is # not the fallback the setting offers to turn on: it is the only way the # keys arrive at all, so it runs whatever the setting says. if self.conf["evdev_hotkey"] or not hotkey.installs_shortcuts(): - self.evdev.start({"toggle": self.conf["shortcut"], - "ask": self.conf["assistant_shortcut"], - "meeting": self.conf["meeting_shortcut"]}) + self.evdev.start({name: self.conf[spec.setting] + for name, spec in hotkey.SHORTCUTS.items()}) else: self.evdev.stop() @@ -850,6 +895,9 @@ class Dikte: self.meeting_recorder.stop() self.overlay.dismiss() self.ask_overlay.dismiss() + # Also on the restart path, which replaces the process without ever + # reaching atexit and would otherwise leave the models in memory. + ggml.stop_all() self.tray.hide() @@ -865,19 +913,6 @@ def _clock(seconds): else f"{minutes}:{secs:02d}") -def launch_command(): - """The command the KDE shortcut will run.""" - return ipc.command_for("toggle") - - -def meeting_command(): - return ipc.command_for("meeting") - - -def ask_command(): - return ipc.command_for("ask") - - def main(): argv = sys.argv[1:] # Anything typed at a terminal is the command line's business, including @@ -888,6 +923,41 @@ def main(): return run_app([arg for arg in argv if arg != "--gui"]) +def install_signal_handlers(app): + """Quit properly on the signals a session sends, rather than dying where we stand. + + Qt spends its time blocked inside C, and a Python signal handler only runs + between bytecodes, so on its own it would not run until the next event + arrived, which for an idle tray icon may be never. set_wakeup_fd writes the + signal number to a socket instead, and a notifier turns that into an event + Qt does deliver. + + Worth the trouble because of what shutdown() does: a logout sends SIGTERM, + and without this a whisper.cpp or llama.cpp server outlives the session + holding its model in memory. SIGKILL cannot be caught at all, which is what + ggml.sweep() is for. + + Returns the objects it made; they have to stay alive to keep working. + """ + reader, writer = socket.socketpair() + reader.setblocking(False) + writer.setblocking(False) + signal.set_wakeup_fd(writer.fileno()) + notifier = QSocketNotifier(reader.fileno(), QSocketNotifier.Type.Read) + + def woken(): + with contextlib.suppress(OSError): + reader.recv(64) + app.quit() # aboutToQuit runs shutdown() + + notifier.activated.connect(woken) + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + # A handler that does nothing, so that the default action, stopping the + # process where it stands, is replaced by the wakeup above. + signal.signal(sig, lambda *_: None) + return reader, writer, notifier + + def run_app(args): command = args[0] if args else "" @@ -895,6 +965,12 @@ def run_app(args): app.setApplicationName("Dikte") app.setDesktopFileName("dikte") app.setQuitOnLastWindowClosed(False) + # Before Dikte is built, because building it is what may start a server, and + # a signal arriving in the middle of that would otherwise take the default + # action and leave the server behind. A signal this early lands in the + # socket and is delivered as soon as the event loop starts. Held in a name + # so that the notifier and its socket outlive this function. + signal_plumbing = install_signal_handlers(app) # noqa: F841 if not QSystemTrayIcon.isSystemTrayAvailable(): print("dikte: no system tray found, running anyway") @@ -943,7 +1019,10 @@ def run_app(args): # No key for the chosen transcription provider means nothing can work yet, # so the settings window is the only useful thing to open. - if command == "settings" or not dikte.conf.transcribe_target().api_key: + # A transcription provider that cannot run yet, whether that is a missing + # API key or a model nobody has downloaded, means nothing can work, so the + # settings window is the only useful thing to open. + if command == "settings" or not dikte.conf.transcribe_ready(): dikte.open_settings() elif command == "toggle": QTimer.singleShot(0, dikte.toggle) diff --git a/docs/settings-agent.webp b/docs/settings-agent.webp new file mode 100644 index 0000000..d693658 Binary files /dev/null and b/docs/settings-agent.webp differ diff --git a/docs/settings-api.webp b/docs/settings-api.webp index afcf3e0..142a435 100644 Binary files a/docs/settings-api.webp and b/docs/settings-api.webp differ diff --git a/docs/settings-audio-file.webp b/docs/settings-audio-file.webp index 3b113f9..f5d77f0 100644 Binary files a/docs/settings-audio-file.webp and b/docs/settings-audio-file.webp differ diff --git a/docs/settings-cleanup.webp b/docs/settings-cleanup.webp index ff87c00..725b0b0 100644 Binary files a/docs/settings-cleanup.webp and b/docs/settings-cleanup.webp differ diff --git a/docs/settings-general.webp b/docs/settings-general.webp index 498ee8e..18bcef7 100644 Binary files a/docs/settings-general.webp and b/docs/settings-general.webp differ diff --git a/docs/settings-history.webp b/docs/settings-history.webp deleted file mode 100644 index 3f108df..0000000 Binary files a/docs/settings-history.webp and /dev/null differ diff --git a/docs/settings-meeting.webp b/docs/settings-meeting.webp new file mode 100644 index 0000000..13b59e4 Binary files /dev/null and b/docs/settings-meeting.webp differ diff --git a/docs/settings-shortcuts.webp b/docs/settings-shortcuts.webp new file mode 100644 index 0000000..73ba99a Binary files /dev/null and b/docs/settings-shortcuts.webp differ diff --git a/filetranscribe.py b/filetranscribe.py index 4839d13..b336e3a 100644 --- a/filetranscribe.py +++ b/filetranscribe.py @@ -17,6 +17,8 @@ import wave from PyQt6.QtCore import QObject, pyqtSignal import api +import cleanup +import ggml from i18n import t CHUNK_SECONDS = 600 # 10 min ≈ 19 MB at 16 kHz mono s16 @@ -28,8 +30,9 @@ MIN_SUBTITLE_SECONDS = 1.5 # how long a cue with no end time of its own stays STAMP_RE = re.compile(r"^\[(?:(\d+):)?(\d{1,2}):(\d{2})\]\s*") -class Cancelled(Exception): - pass +# What a stopped run comes back with, wherever it was stopped: the request that +# was cut off raises it from api, and the steps in between raise it themselves. +Cancelled = api.Aborted class FileTranscriber(QObject): @@ -41,7 +44,9 @@ class FileTranscriber(QObject): super().__init__(parent) self.conf = conf self._thread = None - self._stop = threading.Event() + self._abort = api.Aborter() + # The server on this machine the work is with, when it is with one. + self._local = None @property def busy(self): @@ -50,18 +55,26 @@ class FileTranscriber(QObject): def start(self, path, timestamps, do_cleanup): if self.busy: return - self._stop.clear() + self._abort = api.Aborter() # the last one is spent self._thread = threading.Thread( target=self._work, args=(path, timestamps, do_cleanup), daemon=True ) self._thread.start() def stop(self): - self._stop.set() + """Cut the run off where it stands, rather than at the next step.""" + self._abort.abort() + # Closing the socket is nothing to a server on this machine: it is a + # process of ours, and it would grind on to the end of the chunk with + # nobody left to hand the answer to. Stopping it is what stops the + # work; the next run starts it again. Killing waits on the process, so + # not on the thread the window is drawn from. + local = self._local + if local is not None: + threading.Thread(target=local.stop, daemon=True).start() def _check(self): - if self._stop.is_set(): - raise Cancelled + self._abort.check() def _work(self, path, timestamps, do_cleanup): conf = self.conf @@ -72,7 +85,7 @@ class FileTranscriber(QObject): workdir = tempfile.mkdtemp(prefix="dikte-file-") self.progress.emit(t("Converting audio…")) - wav_path = _to_wav(path, workdir) + wav_path = _to_wav(path, workdir, self._abort) self._check() chunks = split_wav(wav_path, workdir) @@ -80,6 +93,7 @@ class FileTranscriber(QObject): self.progress.emit(t("Splitting into {count} chunks…", count=len(chunks))) target = conf.transcribe_target() + self._local = ggml.whisper if target.provider == "local" else None pieces = [] segments = [] for index, (chunk_path, offset) in enumerate(chunks, start=1): @@ -95,6 +109,7 @@ class FileTranscriber(QObject): chunk_path, language=conf["language"], prompt=conf["transcribe_prompt"], + aborter=self._abort, ) ) pieces = [f"[{format_timestamp(start)}] {line}" @@ -105,6 +120,7 @@ class FileTranscriber(QObject): chunk_path, language=conf["language"], prompt=conf["transcribe_prompt"], + aborter=self._abort, )) text = "\n".join(pieces) if timestamps else " ".join(pieces) @@ -121,23 +137,18 @@ class FileTranscriber(QObject): except (api.ApiError, OSError, subprocess.SubprocessError, wave.Error) as exc: self.failed.emit(str(exc)) finally: + self._local = None if workdir: shutil.rmtree(workdir, ignore_errors=True) def _cleanup(self, text, timestamps): conf = self.conf + self._local = ggml.llm if cleanup.provider(conf) == "local" else None prompt = conf.cleanup_prompt(with_timestamps=timestamps, subtitles=True) out = [] for block in split_text(text, timestamps): self._check() - out.append(api.cleanup( - block, - conf.openrouter_key(), - conf["cleanup_model"], - prompt, - reasoning=conf["cleanup_reasoning"], - base_url=conf["openrouter_base_url"], - )) + out.append(cleanup.run(block, conf, prompt, aborter=self._abort)) return ("\n" if timestamps else "\n\n").join(out) @@ -200,17 +211,34 @@ def to_srt(text, segments): return "\n\n".join(blocks) + "\n" if blocks else "" -def _to_wav(path, workdir): +def _reap(proc): + """Leave nothing running behind a conversion that did not finish.""" + if proc.poll() is None: + proc.kill() + proc.wait() + + +def _to_wav(path, workdir, aborter=None): out = os.path.join(workdir, "audio.wav") - res = subprocess.run( + proc = subprocess.Popen( ["ffmpeg", "-nostdin", "-y", "-i", path, "-vn", "-ac", "1", "-ar", str(RATE), "-c:a", "pcm_s16le", out], - capture_output=True, text=True, + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, ) - if res.returncode != 0 or not os.path.exists(out): - tail = (res.stderr or "").strip().splitlines() + # A two hour film is a minute of ffmpeg, which is a minute of a Stop button + # doing nothing unless the abort reaches the process itself. + with contextlib.ExitStack() as stack: + stack.callback(_reap, proc) + if aborter is not None: + stack.enter_context(aborter.holding(proc.kill)) + _stdout, stderr = proc.communicate() + if aborter is not None: + aborter.check() + if proc.returncode != 0 or not os.path.exists(out): + tail = (stderr or "").strip().splitlines() raise api.ApiError(t("Could not read the file: {error}", - error=tail[-1] if tail else res.returncode)) + error=tail[-1] if tail else proc.returncode)) return out diff --git a/ggml.py b/ggml.py new file mode 100644 index 0000000..d8778b5 --- /dev/null +++ b/ggml.py @@ -0,0 +1,792 @@ +"""Speech to text and cleanup on this machine: whisper.cpp and llama.cpp. + +Two programs, one treatment. Fetch a release from GitHub, unpack it under the +data directory, fetch a model from Hugging Face, then keep one server alive on a +port of its own. Both of them speak the shape api.py already sends to the hosted +providers, so what the rest of Dikte sees is a base URL and nothing else: +whisper-server is started on `--inference-path /v1/audio/transcriptions`, the +exact path api.py builds, and llama-server answers /v1/chat/completions the way +OpenRouter does. + +A server rather than a one-shot run, because the model is the slow part. Loading +a large whisper model takes a second or two while transcribing a few seconds of +speech takes a fraction of one, and an LLM is worse: a server pays that once and +a run per dictation pays it every time. + +Nothing downloaded is trusted for having arrived. Every file is checked against +the sha256 its index published, and the bytes go to a `.part` that is only +renamed once the whole thing is there, so an interrupted download can never be +mistaken for a working one. + +This module imports hub and the string table, and nothing else of Dikte's: it +knows how to fetch a file and how to run a process, and nothing about dictation. +Its errors leave as LocalError and api.py turns them into the ApiError the +interface already knows how to show. +""" + +import atexit +import collections +import ctypes.util +import hashlib +import http.client +import json +import os +import pathlib +import platform +import shutil +import signal +import socket +import subprocess +import tarfile +import threading +import time +import urllib.error +import urllib.request + +import hub +from i18n import t + +HOST = "127.0.0.1" +# The path api.py asks for, so its URL and the server's line up. +INFERENCE_PATH = "/v1/audio/transcriptions" + +DATA_DIR = (pathlib.Path(os.environ.get("XDG_DATA_HOME") + or os.path.expanduser("~/.local/share")) / "dikte") +BIN_DIR = DATA_DIR / "bin" +MODELS_DIR = DATA_DIR / "models" + +# Loading a large model onto a GPU is the slow part of a start, and on a cold +# page cache a large LLM read from a spinning disk is slower still. +STARTUP_TIMEOUT = 180.0 +DOWNLOAD_CHUNK = 1 << 20 + +# `health` is the path that answers only once the model is in memory. whisper +# does not have one and does not need one: it binds its port after the model is +# loaded, so the port opening is the signal. +Program = collections.namedtuple("Program", "name repo binary health") + +WHISPER = Program("whisper", "ggml-org/whisper.cpp", "whisper-server", "") +LLAMA = Program("llama", "ggml-org/llama.cpp", "llama-server", "/health") + +# Where the models are listed. Neither list is written into Dikte: a catalogue +# in the source means a release of Dikte for every model somebody else +# publishes. +WHISPER_MODELS_REPO = "ggerganov/whisper.cpp" +LLM_AUTHOR = "ggml-org" + +# What the whisper repository holds besides models: Core ML encoders for Apple +# hardware and the odd loose file. +WHISPER_PREFIX = "ggml-" +WHISPER_SUFFIX = ".bin" + +# What a GGUF repository holds besides the model: mmproj is the vision half of a +# multimodal model, mtp a draft head for speculative decoding. Neither is a model +# a server can be started on, and offering them is offering a failure. +GGUF_SKIP = ("mmproj", "mtp-") +# Big enough for a 12B at Q4 and far past anything cleanup wants; the point is +# to keep a 400 GB frontier model out of a list somebody might click. +GGUF_MAX_BYTES = 16 << 30 + +# Suggestions, not a catalogue: the list itself is fetched, and these are only +# the rows that float to the top of it. Small instruction-following models, +# because cleanup is punctuation and filler words rather than anything that +# wants thinking about. +SUGGESTED_LLM = ( + "ggml-org/gemma-3-4b-it-GGUF", + "ggml-org/gemma-4-E2B-it-GGUF", + "ggml-org/gemma-4-E4B-it-GGUF", + "ggml-org/SmolLM3-3B-GGUF", +) +# Turbo at q5_0 is smaller than `small` and better than it, which makes the +# usual "start small" advice point at the same file as "start good". +SUGGESTED_WHISPER = "ggml-large-v3-turbo-q5_0.bin" + + +class LocalError(Exception): + pass + + +def human_size(count): + for unit in ("B", "KB", "MB", "GB"): + if count < 1024 or unit == "GB": + return f"{count:.0f} {unit}" if unit == "B" else f"{count:.1f} {unit}" + count /= 1024.0 + return f"{count:.1f} GB" + + +# --- fetching ------------------------------------------------------------- + + +def download(item, target, on_progress=None, should_stop=None, require_hash=True): + """Fetch one hub.Item to `target`. True when it landed, False when stopped. + + The bytes go to a `.part` that is renamed only after both the length and the + hash agree with what the index said. A truncated file would otherwise sit + there looking installed and fail much later, inside a server, as a corrupt + model; a file that is the right length but the wrong content is worse, and + this is a program as often as it is a model. + + A file whose index published no hash is refused rather than taken on trust. + Everything fetched here is either run or parsed by something written in C++, + and GitHub did not always publish a digest: a release old enough to predate + that would otherwise install unchecked, which is the one case where this + would matter most and say least. + """ + target = pathlib.Path(target) + if require_hash and not item.sha256: + raise LocalError(t("{name} is published without a checksum, so there is " + "no way to tell what arrived. Nothing was installed.", + name=item.name)) + part = target.with_name(target.name + ".part") + try: + target.parent.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise LocalError(t("Could not create {path}: {error}", + path=target.parent, error=exc)) from exc + + request = urllib.request.Request(item.url, headers={"User-Agent": hub.USER_AGENT}) + digest = hashlib.sha256() + done = 0 + try: + with urllib.request.urlopen(request, timeout=60) as response: + total = int(response.headers.get("Content-Length") or item.size or 0) + with open(part, "wb") as out: + while True: + if should_stop is not None and should_stop(): + part.unlink(missing_ok=True) + return False + block = response.read(DOWNLOAD_CHUNK) + if not block: + break + out.write(block) + digest.update(block) + done += len(block) + # More than was announced: a body that does not end is the + # one way this loop could run until the disk is full. + if total and done > total: + part.unlink(missing_ok=True) + raise LocalError(t("{name} is longer than it said it " + "would be.", name=item.name)) + if on_progress is not None: + on_progress(done, total) + # A proxy notice or an error page that came back as 200 would otherwise + # be renamed into place and only fail when something tries to read it. + if total and done != total: + part.unlink(missing_ok=True) + raise LocalError(t("The download stopped early ({done} of {total}).", + done=human_size(done), total=human_size(total))) + if item.sha256 and digest.hexdigest() != item.sha256: + part.unlink(missing_ok=True) + raise LocalError(t("{name} does not match its published checksum. " + "Nothing was installed.", name=item.name)) + part.replace(target) + return True + except urllib.error.HTTPError as exc: + part.unlink(missing_ok=True) + exc.close() # it holds the response body open until it is collected + raise LocalError(t("Could not download {name}: HTTP {code}", + name=item.name, code=exc.code)) from exc + except urllib.error.URLError as exc: + part.unlink(missing_ok=True) + raise LocalError(t("Could not download {name}: {error}", + name=item.name, error=exc.reason)) from exc + except OSError as exc: + # A connection cut mid-body arrives here too, and gigabytes in is + # exactly where that happens. + part.unlink(missing_ok=True) + raise LocalError(t("Could not write {name}: {error}", + name=item.name, error=exc)) from exc + + +# --- the programs --------------------------------------------------------- + + +def _arch(): + machine = platform.machine().lower() + if machine in ("aarch64", "arm64"): + return "arm64" + return "x64" + + +def _has_vulkan(): + """Whether a Vulkan loader is installed, which decides which build to fetch. + + llama.cpp publishes no CUDA build for Linux, so Vulkan is what a graphics + card gets here. The build without it is smaller and runs on the CPU, and + fetching the Vulkan one for a machine that cannot load it would only make + the download bigger. + """ + return bool(ctypes.util.find_library("vulkan")) + + +def _wanted_assets(program): + """Asset name endings to accept, best first.""" + arch = _arch() + if program is LLAMA and _has_vulkan(): + return (f"bin-ubuntu-vulkan-{arch}.tar.gz", f"bin-ubuntu-{arch}.tar.gz") + return (f"bin-ubuntu-{arch}.tar.gz",) + + +def _install_record(program): + return BIN_DIR / program.name / "installed.json" + + +def installed_program(program): + """The binary Dikte downloaded, or "" when there is none that still runs.""" + try: + record = json.loads(_install_record(program).read_text(encoding="utf-8")) + path = record.get("binary") or "" + except (OSError, ValueError): + return "" + return path if os.path.isfile(path) and os.access(path, os.X_OK) else "" + + +def installed_version(program): + try: + record = json.loads(_install_record(program).read_text(encoding="utf-8")) + return record.get("tag") or "" + except (OSError, ValueError): + return "" + + +def program_path(program, custom=""): + """Which copy of the program to run, or "" when there is none. + + A system one wins over a downloaded one. The distribution package is built + against whatever the machine has, which on this platform means it may reach + the graphics card, while the release binaries carry CPU backends only. + """ + custom = (custom or "").strip() + if custom: + return custom if os.path.isfile(custom) and os.access(custom, os.X_OK) else "" + return shutil.which(program.binary) or installed_program(program) + + +def system_program(program): + """Whether the program came from the system rather than from Dikte.""" + return bool(shutil.which(program.binary)) + + +def _find_binary(root, name): + for path in sorted(pathlib.Path(root).rglob(name)): + if path.is_file(): + return path + return None + + +def _extract(archive, into): + """Unpack a release tarball, refusing anything that reaches outside `into`. + + The archives lay their libraries next to their binaries and are linked with + an $ORIGIN runpath, so a whole directory is what has to survive the trip and + the binary cannot be lifted out of it. + """ + try: + with tarfile.open(archive, "r:gz") as tar: + try: + tar.extractall(into, filter="data") + except TypeError: # Python without the extraction filters + tar.extractall(into) + except (tarfile.TarError, OSError) as exc: + raise LocalError(t("Could not unpack {name}: {error}", + name=os.path.basename(str(archive)), error=exc)) from exc + + +def install_program(program, tag="", on_progress=None, should_stop=None, + refresh=False): + """Fetch and unpack a release. The path to the binary, or "" when stopped. + + `tag` is empty for whatever the project released last, which is the point: + a version pinned in Dikte's source would mean a release of Dikte every time + whisper.cpp has one. + """ + try: + tag, assets = hub.release(program.repo, tag or "latest", refresh=refresh) + except hub.HubError as exc: + raise LocalError(str(exc)) from exc + + item = None + for ending in _wanted_assets(program): + item = next((a for a in assets if a.name.endswith(ending)), None) + if item: + break + if item is None: + raise LocalError(t("{repo} {tag} has no build for this machine.", + repo=program.repo, tag=tag)) + + into = BIN_DIR / program.name / tag + shutil.rmtree(into, ignore_errors=True) + archive = BIN_DIR / program.name / item.name + try: + if not download(item, archive, on_progress, should_stop): + return "" + _extract(archive, into) + binary = _find_binary(into, program.binary) + if binary is None: + raise LocalError(t("{name} was not in the download.", + name=program.binary)) + binary.chmod(binary.stat().st_mode | 0o111) + _install_record(program).write_text( + json.dumps({"tag": tag, "binary": str(binary)}), encoding="utf-8") + except OSError as exc: + raise LocalError(t("Could not install {name}: {error}", + name=program.name, error=exc)) from exc + finally: + try: + archive.unlink(missing_ok=True) + except OSError: + pass + _drop_old_versions(program, keep=tag) + return str(binary) + + +def _drop_old_versions(program, keep): + """Leave one unpacked release behind, not one per update.""" + root = BIN_DIR / program.name + try: + for path in root.iterdir(): + if path.is_dir() and path.name != keep: + shutil.rmtree(path, ignore_errors=True) + except OSError: + pass + + +# --- the models ----------------------------------------------------------- + + +def whisper_models(refresh=False): + """[hub.Item] for every whisper model on offer, smallest first.""" + try: + files = hub.files(WHISPER_MODELS_REPO, refresh=refresh) + except hub.HubError as exc: + raise LocalError(str(exc)) from exc + models = [f for f in files + if f.name.startswith(WHISPER_PREFIX) and f.name.endswith(WHISPER_SUFFIX) + and f.size > 0] + return sorted(models, key=lambda f: f.size) + + +def llm_repos(refresh=False): + """Repository ids for the GGUF models on offer, suggestions first.""" + try: + found = [r.id for r in hub.repos(author=LLM_AUTHOR, refresh=refresh)] + except hub.HubError: + # A menu rather than a catalogue: with nothing to show, the suggestions + # are still worth showing, and whatever is wrong with the network will + # say so where it matters, when a download is asked for. + found = [] + if not found: + return list(SUGGESTED_LLM) + first = [r for r in SUGGESTED_LLM if r in found] + return first + [r for r in found if r not in first] + + +def llm_quants(repo, refresh=False): + """[hub.Item] for the model files in one GGUF repository, smallest first.""" + try: + files = hub.files(repo, refresh=refresh) + except hub.HubError as exc: + raise LocalError(str(exc)) from exc + out = [] + for item in files: + name = item.name.rsplit("/", 1)[-1] + if not name.endswith(".gguf") or name.startswith(GGUF_SKIP): + continue + # A model split across files needs all of them and a different command + # line; anything cleanup wants fits in one. + if "-of-000" in name or not 0 < item.size <= GGUF_MAX_BYTES: + continue + out.append(item) + return sorted(out, key=lambda f: f.size) + + +def whisper_model_path(name): + return MODELS_DIR / "whisper" / name + + +def llm_model_path(name): + return MODELS_DIR / "llm" / name.rsplit("/", 1)[-1] + + +def have_model(path): + path = pathlib.Path(path) + return path.is_file() and path.stat().st_size > 0 + + +def installed_whisper_models(): + return sorted(p.name for p in (MODELS_DIR / "whisper").glob("*.bin")) + + +def installed_llm_models(): + return sorted(p.name for p in (MODELS_DIR / "llm").glob("*.gguf")) + + +def delete_model(path): + try: + pathlib.Path(path).unlink() + except FileNotFoundError: + pass + except OSError as exc: + raise LocalError(t("Could not delete the model: {error}", error=exc)) from exc + + +# --- one server ----------------------------------------------------------- + + +def _free_port(): + """A port nothing is listening on, handed straight to the server. + + Between closing this socket and the server binding it, something else could + take it; that is why a start retries rather than trusting the number. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind((HOST, 0)) + return sock.getsockname()[1] + + +def _listening(port): + try: + with socket.create_connection((HOST, port), timeout=0.5): + return True + except OSError: + return False + + +def _healthy(port, path): + """Whether the model is in memory, for a server that says so. + + Spoken over http.client rather than urllib because this never leaves the + machine: it is the same question as _listening, one layer up. + """ + connection = http.client.HTTPConnection(HOST, port, timeout=2) + try: + connection.request("GET", path) + # 503 for as long as the model is still being read in. + return connection.getresponse().status == 200 + except (http.client.HTTPException, OSError): + return False + finally: + connection.close() + + +def _tail(path, lines=3): + try: + with open(path, encoding="utf-8", errors="replace") as fh: + found = [line.strip() for line in fh if line.strip()] + except OSError: + return "" + return " | ".join(found[-lines:]) + + +class Server: + """One process, started when something needs it and stopped when nothing does. + + `build` turns the settings into a command line; everything else about + running a server is the same for both programs. + """ + + def __init__(self, program, build, defaults): + self.program = program + self._build = build + self._settings = dict(defaults) + # Two locks on purpose. `_lock` is held for the length of a dictionary + # lookup, so the interface can ask what is running while a model is + # being loaded; `_starting` is held across the start itself, which can + # take a minute and which two threads must not both do. + self._lock = threading.Lock() + self._starting = threading.Lock() + self._proc = None + self._port = 0 + self._log = "" + self._key = None + + # ---- settings -------------------------------------------------------- + + def configure(self, **changes): + """Apply settings. A server started on the old ones is stopped.""" + with self._lock: + for key, value in changes.items(): + if value is not None and key in self._settings: + self._settings[key] = value + stale = self._proc is not None and self._key != self._settings_key() + if stale: + self.stop() + + def settings(self): + with self._lock: + return dict(self._settings) + + def _settings_key(self): + """What a running server would have to be restarted for.""" + return json.dumps(self._settings, sort_keys=True, default=str) + + # ---- process --------------------------------------------------------- + + @property + def running(self): + with self._lock: + return self._proc is not None and self._proc.poll() is None + + def base_url(self): + with self._lock: + return f"http://{HOST}:{self._port}/v1" if self._port else "" + + def error(self): + """The last thing the server printed, for a failure after it started.""" + with self._lock: + log = self._log + return _tail(log) if log else "" + + def serve(self): + """The base URL of a server that is up and running the current settings.""" + ready = self._current_url() + if ready: + return ready + with self._starting: + # Somebody may have started it while this thread waited its turn. + ready = self._current_url() + if ready: + return ready + self.stop() + with self._lock: + settings, key = dict(self._settings), self._settings_key() + proc, port, log = self._launch(settings) + with self._lock: + self._proc, self._port, self._log, self._key = proc, port, log, key + return self.base_url() + + def _current_url(self): + with self._lock: + up = self._proc is not None and self._proc.poll() is None + return (f"http://{HOST}:{self._port}/v1" + if up and self._key == self._settings_key() else "") + + def _launch(self, settings): + args = self._build(settings) # raises LocalError when unusable + last = "" + for _ in range(3): + port = _free_port() + log = DATA_DIR / f"{self.program.name}-server.log" + try: + log.parent.mkdir(parents=True, exist_ok=True) + sink = open(log, "wb") + except OSError as exc: + raise LocalError(t("Could not start {name}: {error}", + name=self.program.name, error=exc)) from exc + try: + with sink: + proc = subprocess.Popen( + args + ["--host", HOST, "--port", str(port)], + stdout=sink, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + ) + except OSError as exc: + raise LocalError(t("Could not start {name}: {error}", + name=self.program.name, error=exc)) from exc + + # Written before it is ready rather than after, so that a kill + # during the model load leaves something for the sweep to find. + self._remember(proc.pid) + try: + ready = self._wait_ready(proc, port) + except BaseException: + # Whatever went wrong while waiting, the process is ours and + # nothing else is left holding a reference to it. Leaving it + # running would leak a loaded model with nobody to ask it + # anything, which is the whole failure this class is careful + # about elsewhere. + self._kill(proc) + self._forget() + raise + if ready: + return proc, port, str(log) + last = _tail(log) + self._forget() + # A port taken between the probe and the bind is the one failure + # worth another go; anything else will fail the same way again. + if "address" not in last.lower() and "bind" not in last.lower(): + break + raise LocalError(t("{name} did not start: {error}", + name=self.program.binary, error=last or t("no output"))) + + def _wait_ready(self, proc, port): + deadline = time.monotonic() + STARTUP_TIMEOUT + while time.monotonic() < deadline: + if proc.poll() is not None: + return False + if _listening(port): + # whisper binds after the model is loaded, so the open port is + # the answer. llama binds first and answers /health with 503 + # until it is ready. + if not self.program.health or _healthy(port, self.program.health): + return True + time.sleep(0.1) + self._kill(proc) + return False + + @staticmethod + def _kill(proc, gently=False): + """Stop a process of ours, and wait for it rather than assume.""" + if proc is None or proc.poll() is not None: + return + if gently: + proc.terminate() + try: + proc.wait(timeout=5) + return + except subprocess.TimeoutExpired: + pass + proc.kill() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + + def stop(self): + with self._lock: + proc, self._proc = self._proc, None + self._port, self._log, self._key = 0, "", None + self._kill(proc, gently=True) + if proc is not None: + self._forget() + + # ---- servers a killed Dikte left behind ------------------------------- + + def _pid_file(self): + return DATA_DIR / f"{self.program.name}-server.pid" + + def _remember(self, pid): + try: + path = self._pid_file() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(str(pid)) + except OSError: + pass # the sweep is a safety net, not something to fail a run over + + def _forget(self): + try: + self._pid_file().unlink() + except OSError: + pass + + def _is_ours(self, pid): + """Whether that pid is still the server this Dikte started. + + Asked because pids are handed out again: by the time anyone looks, the + number could belong to something else entirely, and killing it would be + a good deal worse than the leak being cleaned up. The program name alone + could be somebody else's copy; the name together with Dikte's own data + directory on the command line could not. + """ + try: + blob = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes() + except OSError: + return False + return (self.program.binary.encode() in blob + and str(DATA_DIR).encode() in blob) + + def sweep(self): + """Kill a server a previous Dikte left behind. True when one was found. + + stop() and atexit cover every exit that gets to run code. A SIGKILL does + not, and neither does a session torn down from under it, and the server + would then sit there holding the model with nothing left alive to ask it + anything. + """ + try: + pid = int(self._pid_file().read_text().strip()) + except (OSError, ValueError): + return False + self._forget() + if not self._is_ours(pid): + return False + try: + os.kill(pid, signal.SIGTERM) + except OSError: + return False + return True + + +# --- the two of them ------------------------------------------------------ + + +def _whisper_args(settings): + binary = program_path(WHISPER, settings["binary"]) + if not binary: + raise LocalError(t("whisper.cpp is not installed. Settings → API and " + "models → Download.")) + model = whisper_model_path(settings["model"]) + if not settings["model"] or not have_model(model): + raise LocalError(t("No whisper model has been downloaded yet. " + "Settings → API and models → Download.")) + args = [ + binary, "-m", str(model), + "--inference-path", INFERENCE_PATH, + # Whatever language the request does not name. api.py leaves the field + # out when the language is "auto", and the server's own default is + # English rather than detection. + "-l", "auto", + # Stock phrases invented for near-silence come from non-speech tokens, + # and verbose_json otherwise pays for a language probability sweep + # nothing here reads. + "-sns", "-nlp", + ] + if int(settings["threads"]) > 0: + args += ["-t", str(int(settings["threads"]))] + if not settings["gpu"]: + args.append("-ng") + return args + + +def _llm_args(settings): + binary = program_path(LLAMA, settings["binary"]) + if not binary: + raise LocalError(t("llama.cpp is not installed. Settings → API and " + "models → Download.")) + model = llm_model_path(settings["model"]) + if not settings["model"] or not have_model(model): + raise LocalError(t("No local cleanup model has been downloaded yet. " + "Settings → API and models → Download.")) + args = [binary, "-m", str(model), "-c", str(int(settings["context"]))] + # All of them, or as many as fit: llama.cpp stops offloading when the card + # is full rather than failing, and a build with no GPU backend ignores it. + args += ["-ngl", "99" if settings["gpu"] else "0"] + if int(settings["threads"]) > 0: + args += ["-t", str(int(settings["threads"]))] + return args + + +whisper = Server(WHISPER, _whisper_args, { + "model": "", + "threads": 0, + "gpu": True, + "binary": "", +}) + +llm = Server(LLAMA, _llm_args, { + "model": "", + "threads": 0, + "gpu": True, + "binary": "", + # A dictation and its prompt are short. This is sized for the longest + # cleanup block rather than for a conversation, and it is what the model + # costs in memory beyond its own weights. + "context": 8192, +}) + +SERVERS = (whisper, llm) + + +def sweep(): + """Clean up after a Dikte that was killed outright. True when one was found.""" + return any([server.sweep() for server in SERVERS]) + + +def stop_all(): + for server in SERVERS: + server.stop() + + +# Dikte stops the servers itself on quit and on restart; this catches the paths +# that skip that, such as an unhandled exception on the way out. +atexit.register(stop_all) diff --git a/hotkey.py b/hotkey.py index 5fcc2ac..e7e10ea 100644 --- a/hotkey.py +++ b/hotkey.py @@ -9,6 +9,7 @@ there the listener is not a fallback but the whole mechanism. """ import ast +import collections import ctypes import ctypes.util import glob @@ -27,17 +28,34 @@ from PyQt6.QtCore import QObject, pyqtSignal from i18n import t DESKTOP_ID = "dikte-toggle.desktop" +CANCEL_DESKTOP_ID = "dikte-cancel.desktop" MEETING_DESKTOP_ID = "dikte-meeting.desktop" ASK_DESKTOP_ID = "dikte-ask.desktop" -# The name each binding is started under, and the entry it is installed as. -DESKTOP_IDS = {"toggle": DESKTOP_ID, "meeting": MEETING_DESKTOP_ID, - "ask": ASK_DESKTOP_ID} APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications" DESKTOP_FILE = APPLICATIONS_DIR / DESKTOP_ID SHORTCUTS_FILE = pathlib.Path.home() / ".config/kglobalshortcutsrc" GNOME_MEDIA_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys" GNOME_BINDING_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding" +Shortcut = collections.namedtuple("Shortcut", "verb desktop_id name setting fallback") + +# Every global shortcut in one place, because there are four of them and the +# command line, the settings window and the installer each used to carry their +# own copy of the list. `fallback` is what to register when the setting is +# empty: only the toggle has one, since it is the key the application is +# unusable without. +SHORTCUTS = { + "toggle": Shortcut("toggle", DESKTOP_ID, "Dikte: start/stop recording", + "shortcut", "Ctrl+Space"), + "cancel": Shortcut("cancel", CANCEL_DESKTOP_ID, "Dikte: discard the recording", + "cancel_shortcut", ""), + "ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask Claude Code", + "assistant_shortcut", ""), + "meeting": Shortcut("meeting", MEETING_DESKTOP_ID, + "Dikte: start/end a meeting recording", + "meeting_shortcut", ""), +} + # --- evdev key codes (linux/input-event-codes.h) -------------------------- EV_KEY = 0x01 @@ -318,9 +336,9 @@ class CarbonHotkey(QObject): continue self._registrations.append(reference) self._names[identifier] = name - desktop_id = DESKTOP_IDS.get(name) - if desktop_id: - _REGISTERED[desktop_id] = shortcut + spec = SHORTCUTS.get(name) + if spec: + _REGISTERED[spec.desktop_id] = shortcut return bool(self._registrations) def stop(self): @@ -642,14 +660,18 @@ def remove_kde_shortcut(desktop_id=DESKTOP_ID): (APPLICATIONS_DIR / desktop_id).unlink(missing_ok=True) except OSError: pass - try: - subprocess.run( - ["kwriteconfig6", "--notify", "--file", "kglobalshortcutsrc", - "--group", "services", "--group", desktop_id, "--key", "_launch", "--delete"], - capture_output=True, timeout=10, - ) - except (subprocess.SubprocessError, OSError): - pass + # kwriteconfig6 deletes keys rather than groups, so both of the ones KDE + # keeps in there go and the empty group is left behind harmlessly. + for key in ("_launch", "_k_friendly_name"): + try: + subprocess.run( + ["kwriteconfig6", "--notify", "--file", "kglobalshortcutsrc", + "--group", "services", "--group", desktop_id, + "--key", key, "--delete"], + capture_output=True, timeout=10, + ) + except (subprocess.SubprocessError, OSError): + pass def kde_shortcut_status(desktop_id=DESKTOP_ID): diff --git a/hub.py b/hub.py new file mode 100644 index 0000000..f793d1c --- /dev/null +++ b/hub.py @@ -0,0 +1,186 @@ +"""Where the programs and the models come from: GitHub releases and Hugging Face. + +Both answer plain JSON over HTTPS without a key, and both publish a sha256 for +every file they hand out: GitHub as the asset digest, Hugging Face as the LFS +object id. Nothing that lands on disk is trusted for having arrived, which +matters more here than it usually would, because half of what is fetched is a +program Dikte then runs. + +The lists are read rather than kept. A model catalogue written into the source +means a release of Dikte for every new model, and a pinned whisper.cpp version +means one for every whisper.cpp release; both of those are somebody else's news, +not Dikte's. Answers are cached for a few hours, and a cache that has gone stale +is still a better answer than none when the network is down. + +Nothing here imports the rest of Dikte apart from the string table: this module +knows two websites and nothing about dictation. +""" + +import collections +import json +import os +import pathlib +import time +import urllib.error +import urllib.parse +import urllib.request + +from i18n import t + +GITHUB_API = "https://api.github.com" +HF_API = "https://huggingface.co/api" +HF_FILES = "https://huggingface.co" +USER_AGENT = "dikte/1.0 (+https://github.com/yusufipk/dikte)" + +CACHE_DIR = (pathlib.Path(os.environ.get("XDG_CACHE_HOME") + or os.path.expanduser("~/.cache")) / "dikte") +# Long enough that opening the settings window twice in an evening asks nobody +# anything, short enough that a model published this morning is offered today. +CACHE_TTL = 6 * 3600 + +# `sha256` is empty for the few files neither side stores in LFS; those are the +# small ones, and a checksum is only worth having where there is something to +# check. +Item = collections.namedtuple("Item", "name url size sha256") +Repo = collections.namedtuple("Repo", "id downloads updated") + + +class HubError(Exception): + pass + + +def _get(url, timeout=20): + request = urllib.request.Request(url, headers={ + "User-Agent": USER_AGENT, + "Accept": "application/json", + }) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + exc.close() # it holds the response body open until it is collected + raise HubError(t("{url} answered HTTP {code}.", + url=urllib.parse.urlsplit(url).netloc, code=exc.code)) from exc + except urllib.error.URLError as exc: + raise HubError(t("Could not reach {url}: {error}", + url=urllib.parse.urlsplit(url).netloc, + error=exc.reason)) from exc + except (ValueError, OSError) as exc: + raise HubError(t("Could not read the answer from {url}: {error}", + url=urllib.parse.urlsplit(url).netloc, error=exc)) from exc + + +def _cache_file(key): + safe = "".join(c if c.isalnum() or c in "-._" else "-" for c in key) + return CACHE_DIR / f"{safe}.json" + + +def _read_cache(key, ttl): + """What was stored under this key, or None. `ttl` of 0 ignores the age.""" + path = _cache_file(key) + try: + age = time.time() - path.stat().st_mtime + if ttl and age > ttl: + return None + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def _write_cache(key, payload): + try: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + _cache_file(key).write_text(json.dumps(payload), encoding="utf-8") + except OSError: + pass # a cache that cannot be written is not a failed lookup + + +def _fetch(key, url, ttl=CACHE_TTL, refresh=False): + """The JSON at `url`, from the cache when it is fresh enough. + + A lookup that fails falls back to the cache however old it is: an offline + settings window that shows yesterday's list is worth a great deal more than + one that shows an error. + """ + if not refresh: + cached = _read_cache(key, ttl) + if cached is not None: + return cached + try: + payload = _get(url) + except HubError: + stale = _read_cache(key, 0) + if stale is not None: + return stale + raise + _write_cache(key, payload) + return payload + + +def _digest(value): + """GitHub writes its digests as "sha256:…"; Hugging Face writes the hash.""" + value = (value or "").strip() + return value.split(":", 1)[1] if value.startswith("sha256:") else value + + +def release(repo, tag="latest", refresh=False): + """(tag, [Item]) for one GitHub release, newest when no tag is given.""" + where = "latest" if tag in ("", "latest") else f"tags/{tag}" + data = _fetch(f"gh-{repo}-{tag or 'latest'}", + f"{GITHUB_API}/repos/{repo}/releases/{where}", refresh=refresh) + if not isinstance(data, dict) or not data.get("assets"): + raise HubError(t("{repo} has no downloadable release.", repo=repo)) + assets = [Item(a.get("name") or "", a.get("browser_download_url") or "", + int(a.get("size") or 0), _digest(a.get("digest"))) + for a in data["assets"] if a.get("browser_download_url")] + return data.get("tag_name") or tag, assets + + +def files(repo, revision="main", refresh=False): + """[Item] for every file in a Hugging Face repository. + + The size is there whether or not the file is in LFS; the hash is only there + when it is, which for anything worth downloading it always is. + """ + data = _fetch(f"hf-tree-{repo}-{revision}", + f"{HF_API}/models/{repo}/tree/{revision}?recursive=true", + refresh=refresh) + if not isinstance(data, list): + raise HubError(t("{repo} did not return a file list.", repo=repo)) + out = [] + for entry in data: + if entry.get("type") != "file": + continue + path = entry.get("path") or "" + lfs = entry.get("lfs") or {} + out.append(Item( + path, + f"{HF_FILES}/{repo}/resolve/{revision}/{urllib.parse.quote(path)}", + int(lfs.get("size") or entry.get("size") or 0), + _digest(lfs.get("oid") or lfs.get("sha256")), + )) + return out + + +def repos(author="", search="", limit=40, refresh=False): + """[Repo] of GGUF repositories, newest first. + + Filtered by author on purpose. Hugging Face's own trending list is open to + everyone and reads like it: asking it for the popular GGUF today answers + with a wall of roleplay merges, which is not what a dictation transcript + wants cleaning up. An author is a small enough thing to trust and a large + enough one to keep the list current without Dikte being updated. + """ + query = {"filter": "gguf", "sort": "lastModified", "direction": "-1", + "limit": str(limit)} + if author: + query["author"] = author + if search: + query["search"] = search + url = f"{HF_API}/models?{urllib.parse.urlencode(query)}" + data = _fetch(f"hf-models-{author}-{search}-{limit}", url, refresh=refresh) + if not isinstance(data, list): + raise HubError(t("Hugging Face did not return a model list.")) + return [Repo(m.get("id") or "", int(m.get("downloads") or 0), + m.get("lastModified") or "") + for m in data if m.get("id")] diff --git a/i18n.py b/i18n.py index 68707cb..436f917 100644 --- a/i18n.py +++ b/i18n.py @@ -56,7 +56,7 @@ TR = { "Start recording": "Kaydı başlat", "Stop and transcribe": "Kaydı bitir ve yaz", "Working…": "İşleniyor…", - "Cancel recording": "Kaydı iptal et", + "Discard the recording": "Kaydı iptal et", "Settings…": "Ayarlar…", "Restart": "Yeniden başlat", "Quit": "Çık", @@ -140,6 +140,7 @@ TR = { "Cleanup rules": "Temizleme kuralları", "Audio file": "Ses dosyası", "Shortcut": "Kısayol", + "Shortcuts": "Kısayollar", "History": "Geçmiş", "Save": "Kaydet", "Saved successfully.": "Başarıyla kaydedildi.", @@ -185,6 +186,7 @@ TR = { "Model": "Model", "Provider": "Sağlayıcı", "sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_API_KEY kullanılır)", + "gsk_… (falls back to GROQ_API_KEY)": "gsk_… (boşsa GROQ_API_KEY kullanılır)", "sk-or-… (falls back to OPENROUTER_API_KEY)": "sk-or-… (boşsa OPENROUTER_API_KEY kullanılır)", "Test": "Test et", "Trying…": "Deneniyor…", @@ -192,6 +194,18 @@ TR = { "Connection works. {count} audio models visible.": "Bağlantı tamam. {count} ses modeli görünüyor.", "Clean the transcript with a model": "Transkripti bir modelle temizle", + "OpenRouter is the quickest and the only one that needs nothing installed. " + "Claude Code and Codex clean up on the subscription you already have, " + "without a second key, and take a few seconds longer because each one opens " + "a session to do it.": + "En hızlısı OpenRouter'dır ve kurulu bir program istemeyen tek seçenektir. " + "Claude Code ile Codex, temizliği hâlihazırda ödediğin abonelik üzerinden " + "yapar, ikinci bir anahtar istemez; her biri bunun için bir oturum açtığından " + "birkaç saniye daha uzun sürer.", + "{binary} is not on your PATH, so cleanup would fail and the raw transcript " + "would be pasted. Install it, or pick another one above.": + "{binary} PATH'te değil; temizleme başarısız olur ve ham transkript " + "yapıştırılır. Kur ya da yukarıdan başka birini seç.", "Thinking": "Düşünme", "Model's own default": "Modelin kendi varsayılanı", "Off": "Kapalı", @@ -289,6 +303,13 @@ TR = { "Global kısayol kurulu değil. Toplantı tepsi menüsünden de başlatılabilir.", "No global shortcut installed. The tray menu asks it too.": "Global kısayol kurulu değil. Tepsi menüsünden de soru sorulabilir.", + "No global shortcut installed. The tray menu discards it too.": + "Global kısayol kurulu değil. Kayıt tepsi menüsünden de iptal edilebilir.", + "Start and stop": "Başlat ve bitir", + "Throws the recording away without transcribing it. Works on a dictation " + "and on a command for the agent alike, whichever is running.": + "Kaydı yazıya dökmeden atar. Hangisi çalışıyorsa ona işler: dikteye de, " + "ajana verilen komuta da.", "Shortcut saved: {shortcut}": "Kısayol kaydedildi: {shortcut}", "Could not register the GNOME shortcut: {error}": "GNOME kısayolu kaydedilemedi: {error}", @@ -397,6 +418,14 @@ TR = { "It was not allowed to use: {tools}": "Şunları kullanmasına izin yoktu: {tools}", "The model returned an empty reply.": "Model boş cevap döndürdü.", + # --- cleanup, when a CLI does it ---------------------------------------- + "{binary} not found. Install it, or have OpenRouter clean up instead, " + "under Settings → API and models.": + "{binary} bulunamadı. Kur ya da Ayarlar → API ve modeller sekmesinden " + "temizliği OpenRouter'a bırak.", + "{service} did not finish within {seconds} seconds.": + "{service} {seconds} saniye içinde bitmedi.", + # --- settings: the agent ------------------------------------------------ "Agent": "Ajan", "This shortcut records the same way dictation does, but the transcript is " diff --git a/install.sh b/install.sh index 2cbaaee..c55e5b8 100755 --- a/install.sh +++ b/install.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Dikte installer: dependency check, launchers, KDE shortcut. +# Dikte installer: dependency check, launchers, global shortcuts. set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -8,6 +8,9 @@ BIN_DIR="$HOME/.local/bin" APP_DIR="$HOME/.local/share/applications" AUTOSTART_DIR="$HOME/.config/autostart" SHORTCUT="${1:-Ctrl+Space}" +# Without the colon, so that a second argument given as "" stays empty. That is +# how update.sh says "this one was turned off", as against not saying anything. +CANCEL_SHORTCUT="${2-Ctrl+Alt+Space}" say() { printf ' %s\n' "$1"; } ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } @@ -88,33 +91,47 @@ StartupNotify=false EOF ok "Will start automatically on login" -# 4. KDE global shortcut --------------------------------------------------- -cat > "$APP_DIR/dikte-toggle.desktop" < Shortcut to install the global shortcut." -elif command -v kwriteconfig6 >/dev/null; then - kwriteconfig6 --notify --file kglobalshortcutsrc \ - --group services --group dikte-toggle.desktop \ - --key _launch "$SHORTCUT" - ok "KDE shortcut registered: $SHORTCUT" - warn "KWin only reads this at startup, so the shortcut goes live after your" - say "next login. Until then open Settings → Shortcut and turn on the" - say "built-in listener to use it right away." +register() { # which combination label + if out="$("$PY" "$DIR/dikte.py" shortcut install "$1" --combo "$2" 2>&1)"; then + ok "$3: $2" + else + # One line: the rest of what it has to say about KWin is printed below. + warn "${out%%$'\n'*}" + fi +} + +if python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then + register toggle "$SHORTCUT" "Start and stop" + if [[ -n "$CANCEL_SHORTCUT" ]]; then + register cancel "$CANCEL_SHORTCUT" "Discard the recording" + fi + if [[ "${XDG_CURRENT_DESKTOP:-}" != *[Gg][Nn][Oo][Mm][Ee]* ]]; then + warn "KWin only reads these at startup, so they go live after your next" + say "login. Until then open Settings → Shortcuts and turn on the" + say "built-in listener to use them right away." + fi else - warn "No supported shortcut manager found. Add the shortcut in desktop settings." + warn "PyQt6 is missing, so no shortcut was registered. Install it, then run:" + say "dikte shortcut install toggle --combo '$SHORTCUT'" fi echo ok "Done. Start it with: dikte" -say "The settings window opens on first run; add an OpenAI, Groq or OpenRouter key." +say "The settings window opens on first run: download a speech model, or add" +say "an OpenAI or OpenRouter key instead." echo diff --git a/meeting.py b/meeting.py index c0713c6..c178668 100644 --- a/meeting.py +++ b/meeting.py @@ -25,6 +25,7 @@ import wave from PyQt6.QtCore import QObject, pyqtSignal import api +import cleanup import config as cfg import filetranscribe import vad @@ -208,14 +209,7 @@ class MeetingPipeline(QObject): if len(blocks) > 1: self._say(t("Cleaning up {index}/{count}…", index=index, count=len(blocks))) - out.append(api.cleanup( - block, - conf.openrouter_key(), - conf["cleanup_model"], - prompt, - reasoning=conf["cleanup_reasoning"], - base_url=conf["openrouter_base_url"], - )) + out.append(cleanup.run(block, conf, prompt, timeout=600)) return "\n".join(out) def _write(self, doc_path, minutes, transcript, entry): diff --git a/settings_ui.py b/settings_ui.py index ae418f8..87d2611 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -16,9 +16,12 @@ from PyQt6.QtWidgets import ( import api import assistant import audio +import cleanup import config as cfg import filetranscribe +import ggml import hotkey +import ipc import meeting import paste from filetranscribe import FileTranscriber @@ -30,11 +33,15 @@ LANGUAGES = [ ("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"), ] CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"] -TRANSCRIBE_PROVIDERS = [("OpenAI", "openai"), ("OpenRouter", "openrouter")] +# The provider box offers what config knows how to reach, this machine first. +TRANSCRIBE_PROVIDERS = ([("This machine (whisper.cpp)", "local")] + + [(who.service, name) + for name, who in cfg.TRANSCRIBERS.items()]) # Starting points for the model box; "Fetch model list" replaces them with # whatever the provider offers today. TRANSCRIBE_MODELS = { "openai": ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"], + "groq": ["whisper-large-v3-turbo", "whisper-large-v3"], "openrouter": [ "openai/gpt-4o-transcribe", "openai/gpt-4o-mini-transcribe", "openai/whisper-1", "openai/whisper-large-v3", @@ -47,6 +54,16 @@ CLEANUP_MODELS = [ "google/gemini-2.5-flash-lite", "anthropic/claude-haiku-4.5", "openai/gpt-5-mini", "meta-llama/llama-3.3-70b-instruct", ] +# In the order they answer in. A request to OpenRouter is over in a second, a +# model here takes a little longer and costs nothing, and the two CLIs the agent +# can run on open a whole session to do the smaller job. +CLEANUP_PROVIDERS = [ + ("OpenRouter", "openrouter"), ("This machine (llama.cpp)", "local"), + ("Claude Code", "claude"), ("Codex", "codex"), +] +# Cleaning up a sentence is the lightest thing either of them will ever be +# asked, so the small model comes first. +CLEANUP_CLAUDE_MODELS = ["haiku", "sonnet", "opus", "fable"] # Minutes are a harder job than cleanup: an hour of talk has to be read whole # and turned into decisions, so the starting points are the larger models. MEETING_MODELS = [ @@ -90,9 +107,9 @@ REASONING_LEVELS = [ ("Low", "low"), ("Medium", "medium"), ("High", "high"), ("Very high", "xhigh"), ("Maximum", "max"), ] -# Offered for all three global shortcuts, which keeps them one kind of field -# rather than three. The boxes stay editable: this is a shortlist of -# combinations that are usually free, not the set of ones that work. +# Offered for every global shortcut, which keeps them one kind of field rather +# than four. The boxes stay editable: this is a shortlist of combinations that +# are usually free, not the set of ones that work. SHORTCUTS = [ "Ctrl+Space", "Ctrl+Alt+Space", "Ctrl+Shift+Space", "Meta+Space", "Ctrl+Alt+A", "Ctrl+Alt+D", "Ctrl+Alt+M", "Ctrl+Alt+Q", @@ -110,39 +127,374 @@ AUDIO_FILTER = ("*.mp3 *.wav *.m4a *.ogg *.opus *.flac *.aac *.wma " "*.mp4 *.mkv *.webm *.mov *.avi") +class LocalModelBox(QGroupBox): + """The program, the model, and the two downloads that put them there. + + One class for whisper.cpp and llama.cpp, because the job is the same one + twice: say whether the program is here, offer the models somebody publishes, + fetch the chosen one, and stay usable while a gigabyte arrives. Nothing is + listed in the source; `repos` and `models` are asked at the moment the box is + opened, so a model published this morning is in the list this afternoon. + """ + + _listed = pyqtSignal(list, str) + _quants = pyqtSignal(list, str) + # qint64 rather than int, which is C++'s 32-bit one: a 2.3 GB model is more + # than fits in it, and the count comes out the far side negative. + _progress = pyqtSignal("qint64", "qint64") + _finished = pyqtSignal(str, str) + _installed = pyqtSignal(str, str) + + changed = pyqtSignal() + + def __init__(self, program, title, models, model_path, repos=None, parent=None): + super().__init__(title, parent) + self.program = program + self._models = models # () -> [hub.Item], or (repo) -> [hub.Item] + self._model_path = model_path # (name) -> Path + self._repos = repos # None, or () -> [repo id] + self._downloading = False + self._pending = False + self._stop = False + self._wanted = "" # the model to select once a list arrives + + form = QFormLayout(self) + + self.program_label = QLabel("") + self.program_label.setWordWrap(True) + self.install_button = QPushButton(t("Download")) + self.install_button.clicked.connect(self._install_program) + form.addRow(t("Program"), self._side_by_side(self.program_label, + self.install_button)) + + if self._repos is not None: + self.repo = QComboBox() + self.repo.setEditable(True) + self.repo.setToolTip(t("A Hugging Face repository of GGUF files. The " + "list is fetched; any other one can be typed in.")) + self.repo.currentTextChanged.connect(self._repo_changed) + form.addRow(t("Publisher"), self.repo) + + self.model = QComboBox() + self.download_button = QPushButton(t("Download")) + self.download_button.clicked.connect(self._download) + self.delete_button = QPushButton(t("Delete")) + self.delete_button.clicked.connect(self._delete) + form.addRow(t("Model"), self._side_by_side(self.model, + self.download_button, + self.delete_button)) + self.model.currentIndexChanged.connect(self._model_changed) + + self.status = QLabel("") + self.status.setWordWrap(True) + form.addRow(self.status) + + self._listed.connect(self._on_listed) + self._quants.connect(self._on_listed) + self._progress.connect(self._on_progress) + self._finished.connect(self._on_finished) + self._installed.connect(self._on_installed) + + @staticmethod + def _fit_popup(combo): + """Let the list that drops down be as wide as its longest row. + + A combo box hands its own width to the list under it and elides + whatever does not fit, which lands in the middle of the name: + `ggml-org/Qwen....7B-Base-GGUF` is not a model anybody can choose + between. The box itself stays the width the form gave it. + """ + view = combo.view() + view.setTextElideMode(Qt.TextElideMode.ElideNone) + metrics = combo.fontMetrics() + widest = max((metrics.horizontalAdvance(combo.itemText(row)) + for row in range(combo.count())), default=0) + # Room for the frame and for a scroll bar, which a long list will have. + view.setMinimumWidth(widest + view.verticalScrollBar().sizeHint().width() + 24) + + @staticmethod + def _side_by_side(*widgets): + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + for index, widget in enumerate(widgets): + layout.addWidget(widget, 1 if index == 0 else 0) + holder = QWidget() + holder.setLayout(layout) + return holder + + # ---- what is here ---------------------------------------------------- + + def selected(self): + return self.model.currentData() or "" + + def repository(self): + return self.repo.currentText().strip() if self._repos is not None else "" + + def load(self, model, repo=""): + """Show what is stored. What else is on offer is asked for on the way up. + + Nothing is fetched here: building the settings window is not the same as + opening it, and a list nobody is looking at is not worth a request. What + is already on this disk is shown straight away either way. + """ + self._wanted = model + self._pending = True + self._show_program() + if self._repos is not None: + self.repo.blockSignals(True) + self.repo.clear() + self.repo.addItems(list(ggml.SUGGESTED_LLM)) + self.repo.setCurrentText(repo or ggml.SUGGESTED_LLM[0]) + self.repo.blockSignals(False) + self._fit_popup(self.repo) + self._fill_models([]) + + def showEvent(self, event): + super().showEvent(event) + if self._pending: + self._pending = False + if self._repos is not None: + self._fill_repos(self.repository()) + self._fetch_models(self.repository()) + + def _show_program(self): + path = ggml.program_path(self.program) + if not path: + self.program_label.setText(t("Not installed.")) + self.install_button.setVisible(True) + return + self.install_button.setVisible(not ggml.installed_program(self.program) + and not ggml.system_program(self.program)) + if ggml.system_program(self.program): + # Worth saying which one is running: a distribution package is built + # for this machine and may reach the graphics card, while the + # released binaries carry processor backends only. + self.program_label.setText(t("Installed on the system: {path}", path=path)) + else: + self.program_label.setText( + t("Downloaded, version {version}.", + version=ggml.installed_version(self.program) or "?")) + + # ---- the lists ------------------------------------------------------- + + def _fill_repos(self, current): + def work(): + self._listed.emit([("repos", ggml.llm_repos())], "") + + threading.Thread(target=work, daemon=True).start() + + def _repo_changed(self): + if not self._downloading: + self._fetch_models(self.repository()) + + def _fetch_models(self, repo=""): + self.status.setText(t("Fetching the model list…")) + + def work(): + try: + found = self._models(repo) if self._repos is not None else self._models() + self._quants.emit([("models", found)], "") + except ggml.LocalError as exc: + self._quants.emit([], str(exc)) + + threading.Thread(target=work, daemon=True).start() + + def _on_listed(self, payload, error): + if error: + self.status.setText(error) + self._refresh_buttons() + return + kind, found = payload[0] + if kind == "repos": + current = self.repo.currentText() + self.repo.blockSignals(True) + self.repo.clear() + self.repo.addItems(found) + self.repo.setCurrentText(current) + self.repo.blockSignals(False) + self._fit_popup(self.repo) + return + self._fill_models(found) + + def _fill_models(self, items): + """One row per model, saying what it weighs and whether it is here.""" + wanted = self._wanted or self.selected() + here = [name for name in (self._model_path(i.name).name for i in items)] + self.model.blockSignals(True) + self.model.clear() + for item, name in zip(items, here): + mark = (t("downloaded") if ggml.have_model(self._model_path(item.name)) + else ggml.human_size(item.size)) + self.model.addItem(f"{name} ({mark})", name) + self.model.setItemData(self.model.count() - 1, item, Qt.ItemDataRole.UserRole + 1) + # A model that was downloaded and then dropped from the list upstream is + # still on this disk and still works, so it stays on offer. + for name in self._on_disk(): + if self.model.findData(name) < 0: + self.model.addItem(f"{name} ({t('downloaded')})", name) + # And one that is chosen but not here, because the file was deleted from + # underneath or the settings came from another machine, stays chosen: + # Save reads this box, and a row missing here would quietly empty the + # setting rather than showing that the model needs downloading again. + if wanted and self.model.findData(wanted) < 0: + self.model.addItem(f"{wanted} ({t('not downloaded')})", wanted) + index = self.model.findData(wanted) + self.model.setCurrentIndex(max(index, 0)) + self.model.blockSignals(False) + self._fit_popup(self.model) + self._wanted = "" + self._model_changed() + + def _on_disk(self): + return (ggml.installed_whisper_models() if self.program is ggml.WHISPER + else ggml.installed_llm_models()) + + # ---- fetching -------------------------------------------------------- + + def _install_program(self): + self.install_button.setEnabled(False) + self.program_label.setText(t("Downloading…")) + + def work(): + try: + ggml.install_program(self.program, on_progress=self._report) + self._installed.emit("", "") + except ggml.LocalError as exc: + self._installed.emit("", str(exc)) + + threading.Thread(target=work, daemon=True).start() + + def _on_installed(self, _, error): + self.install_button.setEnabled(True) + self._show_program() + if error: + self.program_label.setText(error) + self.changed.emit() + + def _current_item(self): + return self.model.currentData(Qt.ItemDataRole.UserRole + 1) + + def _download(self): + if self._downloading: + self._stop = True + return + item = self._current_item() + if item is None: + return + self._downloading, self._stop = True, False + self._refresh_buttons() + + def work(): + try: + landed = ggml.download(item, self._model_path(item.name), + on_progress=self._report, + should_stop=lambda: self._stop) + self._finished.emit(item.name if landed else "", "") + except ggml.LocalError as exc: + self._finished.emit("", str(exc)) + + threading.Thread(target=work, daemon=True).start() + + def _report(self, done, total): + self._progress.emit(done, total) + + def _on_progress(self, done, total): + share = f" ({done * 100 // total}%)" if total else "" + text = t("Downloading: {done} of {total}{share}", + done=ggml.human_size(done), total=ggml.human_size(total or done), + share=share) + if self._downloading: + self.status.setText(text) + else: + self.program_label.setText(text) + + def _on_finished(self, name, error): + self._downloading = False + if error: + self.status.setText(error) + elif not name: + self.status.setText(t("Download stopped.")) + self._fill_models_from_current() + self.changed.emit() + + def _fill_models_from_current(self): + """Redraw the rows without asking anybody anything again.""" + items = [self.model.itemData(i, Qt.ItemDataRole.UserRole + 1) + for i in range(self.model.count())] + self._wanted = self.selected() + self._fill_models([i for i in items if i is not None]) + + def _delete(self): + name = self.selected() + if not name or not ggml.have_model(self._model_path(name)): + return + if QMessageBox.question(self, t("Delete model"), + t("Delete {name} from this machine?", name=name)) \ + != QMessageBox.StandardButton.Yes: + return + try: + ggml.delete_model(self._model_path(name)) + except ggml.LocalError as exc: + self.status.setText(str(exc)) + self._fill_models_from_current() + self.changed.emit() + + def _model_changed(self): + self._refresh_buttons() + self.changed.emit() + + def _refresh_buttons(self): + name = self.selected() + here = bool(name) and ggml.have_model(self._model_path(name)) + self.delete_button.setEnabled(here and not self._downloading) + self.download_button.setText(t("Stop") if self._downloading else t("Download")) + self.download_button.setEnabled(self._downloading or (bool(name) and not here)) + if self._downloading: + return + if not name: + self.status.setText(t("Nothing downloaded yet.")) + elif here: + self.status.setText(t("Ready: {name}.", name=name)) + else: + self.status.setText(t("{name} has not been downloaded yet.", name=name)) + + class SettingsWindow(QDialog): applied = pyqtSignal() _models_loaded = pyqtSignal(list, str) _transcribe_models_loaded = pyqtSignal(list, str) - _test_done = pyqtSignal(bool, str) - _or_test_done = pyqtSignal(bool, str) + # Which key was tested, whether it worked, and what to write under it. + _test_done = pyqtSignal(str, bool, str) - def __init__(self, conf, launch_command, meeting_command=None, - meetings=None, ask_command=None, parent=None): + def __init__(self, conf, meetings=None, parent=None): super().__init__(parent) self.conf = conf - self.launch_command = launch_command - self.meeting_command = meeting_command or launch_command - self.ask_command = ask_command or launch_command self.meetings = meetings + # Filled in by _shortcut_row as the tabs are built: which combination + # box, status label and "nothing installed" line belong to each of the + # global shortcuts. One dictionary is what lets install, remove and the + # status line be written once instead of once per key. + self._shortcut_rows = {} # Each provider keeps its own transcription model, so switching the # provider back and forth never overwrites the other one's. - self._models = {"openai": "", "openrouter": ""} + self._models = dict.fromkeys(cfg.TRANSCRIBERS, "") + self._key_fields = {} + self._testers = {} self._shown_provider = "" self.transcriber = FileTranscriber(conf, self) self.setWindowTitle(t("Dikte Settings")) self.resize(680, 640) - tabs = QTabWidget(self) + tabs = self.tabs = QTabWidget(self) tabs.addTab(self._general_tab(), t("General")) - tabs.addTab(self._api_tab(), t("API and models")) + self.api_tab_index = tabs.addTab(self._api_tab(), t("API and models")) tabs.addTab(self._prompt_tab(), t("Cleanup rules")) tabs.addTab(self._assistant_tab(), t("Agent")) tabs.addTab(self._meeting_tab(), t("Meeting")) tabs.addTab(self._minutes_tab(), t("Minutes")) tabs.addTab(self._file_tab(), t("Audio file")) - tabs.addTab(self._shortcut_tab(), t("Shortcut")) + tabs.addTab(self._shortcut_tab(), t("Shortcuts")) tabs.addTab(self._history_tab(), t("History")) # Save keeps the window open, so the window is closed with the titlebar @@ -159,7 +511,6 @@ class SettingsWindow(QDialog): self._models_loaded.connect(self._on_models_loaded) self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded) self._test_done.connect(self._on_test_done) - self._or_test_done.connect(self._on_or_test_done) self.transcriber.progress.connect(self._on_file_progress) self.transcriber.finished.connect(self._on_file_finished) self.transcriber.failed.connect(self._on_file_failed) @@ -168,6 +519,14 @@ class SettingsWindow(QDialog): self.meetings.finished.connect(self._on_minutes_finished) self.meetings.failed.connect(self._on_minutes_failed) self._load() + # Connected after the load, so that filling the boxes in is not taken + # for the user ticking them. + self.file_timestamps.toggled.connect(self._remember_file_choices) + self.file_cleanup.toggled.connect(self._remember_file_choices) + # On a machine where nothing can transcribe yet, this window was opened + # because of that, so open it on the tab that fixes it. + if not conf.transcribe_ready(): + self.tabs.setCurrentIndex(self.api_tab_index) # ---- tabs ---------------------------------------------------------- @@ -259,59 +618,108 @@ class SettingsWindow(QDialog): # them and a key no longer belongs to a single job. keys = QGroupBox(t("Keys")) keys_form = QFormLayout(keys) - self.openai_key = QLineEdit() - self.openai_key.setEchoMode(QLineEdit.EchoMode.Password) - self.openai_key.setPlaceholderText(t("sk-… (falls back to OPENAI_API_KEY)")) - self.test_button = QPushButton(t("Test")) - self.test_button.clicked.connect(self._test_openai) - self.test_label = QLabel("") - self.test_label.setWordWrap(True) - keys_form.addRow("OpenAI", self._row(self.openai_key, self.test_button)) - keys_form.addRow("", self.test_label) - - self.openrouter_key = QLineEdit() - self.openrouter_key.setEchoMode(QLineEdit.EchoMode.Password) - self.openrouter_key.setPlaceholderText(t("sk-or-… (falls back to OPENROUTER_API_KEY)")) - self.or_test_button = QPushButton(t("Test")) - self.or_test_button.clicked.connect(self._test_openrouter) - self.or_test_label = QLabel("") - self.or_test_label.setWordWrap(True) - keys_form.addRow("OpenRouter", self._row(self.openrouter_key, self.or_test_button)) - keys_form.addRow("", self.or_test_label) + self.openai_key = self._key_row( + keys_form, "openai", t("sk-… (falls back to OPENAI_API_KEY)"), + self._test_openai) + self.groq_key = self._key_row( + keys_form, "groq", t("gsk_… (falls back to GROQ_API_KEY)"), + self._test_groq) + self.openrouter_key = self._key_row( + keys_form, "openrouter", t("sk-or-… (falls back to OPENROUTER_API_KEY)"), + self._test_openrouter) outer.addWidget(keys) stt = QGroupBox(t("Speech to text")) stt_form = QFormLayout(stt) self.transcribe_provider = QComboBox() for label, value in TRANSCRIBE_PROVIDERS: - self.transcribe_provider.addItem(label, value) + self.transcribe_provider.addItem(t(label), value) stt_form.addRow(t("Provider"), self.transcribe_provider) + # A hosted provider takes any model id that is typed at it; the local + # one offers what has been published. One row each, and only the rows of + # whoever is chosen are on screen. + self.stt_form = stt_form self.transcribe_model = QComboBox() self.transcribe_model.setEditable(True) self.refresh_transcribe_models = QPushButton(t("Fetch model list")) self.refresh_transcribe_models.clicked.connect(self._load_transcribe_models) - stt_form.addRow(t("Model"), - self._row(self.transcribe_model, self.refresh_transcribe_models)) + self.transcribe_model_row = self._row(self.transcribe_model, + self.refresh_transcribe_models) + stt_form.addRow(t("Model"), self.transcribe_model_row) # A spanning row: in the narrow field column a wrapped label gets a # height that fits one line, and the rest of the text is cut off. self.transcribe_status = QLabel("") self.transcribe_status.setWordWrap(True) stt_form.addRow(self.transcribe_status) + + self.local_whisper = LocalModelBox( + ggml.WHISPER, t("On this machine"), + ggml.whisper_models, ggml.whisper_model_path) + stt_form.addRow(self.local_whisper) + + self.local_gpu = QCheckBox(t("Use the graphics card")) + self.local_gpu.setToolTip( + t("whisper.cpp reaches the card through CUDA, ROCm or Vulkan when the " + "build it is running was made with one. A build without any of them " + "runs on the processor whatever this says.")) + self.local_preload = QCheckBox(t("Load the model when Dikte starts")) + self.local_preload.setToolTip( + t("A large model takes a second or two to load. Loading it up front " + "spends that once instead of on the first dictation, at the cost of " + "the memory it sits in.")) + self.local_threads = QSpinBox() + self.local_threads.setRange(0, 64) + self.local_threads.setSpecialValueText(t("Automatic")) + self.local_options = QWidget() + options_form = QFormLayout(self.local_options) + options_form.setContentsMargins(0, 0, 0, 0) + options_form.addRow("", self.local_gpu) + options_form.addRow("", self.local_preload) + options_form.addRow(t("Threads"), self.local_threads) + stt_form.addRow(self.local_options) + self.transcribe_provider.currentIndexChanged.connect(self._provider_changed) outer.addWidget(stt) orr = QGroupBox(t("Transcript cleanup")) - orr_form = QFormLayout(orr) + orr_form = self.cleanup_form = QFormLayout(orr) self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model")) orr_form.addRow("", self.cleanup_enabled) + self.cleanup_provider = QComboBox() + for label, value in CLEANUP_PROVIDERS: + self.cleanup_provider.addItem(t(label), value) + self.cleanup_provider.setToolTip(t( + "OpenRouter is the quickest and the only one that needs nothing " + "installed. llama.cpp runs here, on a model downloaded below. Claude " + "Code and Codex clean up on the subscription you already have, " + "without a second key, and take a few seconds longer because each " + "one opens a session to do it." + )) + self.cleanup_provider.currentIndexChanged.connect(self._cleanup_provider_changed) + orr_form.addRow(t("Runs on"), self.cleanup_provider) + self.cleanup_model = QComboBox() self.cleanup_model.setEditable(True) self.cleanup_model.addItems(CLEANUP_MODELS) self.refresh_models = QPushButton(t("Fetch model list")) self.refresh_models.clicked.connect(self._load_models) - orr_form.addRow(t("Model"), self._row(self.cleanup_model, self.refresh_models)) + self.cleanup_model_row = self._row(self.cleanup_model, self.refresh_models) + orr_form.addRow(t("Model"), self.cleanup_model_row) + + # One row per provider rather than one box that means a different thing + # in each: an OpenRouter id and a Claude alias do not belong in the same + # field, and only the row of whoever is chosen is on screen. + self.cleanup_claude_model = QComboBox() + self.cleanup_claude_model.setEditable(True) + self.cleanup_claude_model.addItems(CLEANUP_CLAUDE_MODELS) + orr_form.addRow(t("Model"), self.cleanup_claude_model) + + self.cleanup_codex_model = QComboBox() + self.cleanup_codex_model.setEditable(True) + self.cleanup_codex_model.addItems([t("Codex's own default")] + CODEX_MODELS) + orr_form.addRow(t("Model"), self.cleanup_codex_model) self.cleanup_reasoning = QComboBox() for label, value in REASONING_LEVELS: @@ -326,6 +734,33 @@ class SettingsWindow(QDialog): self.models_label = QLabel(t("Runs on OpenRouter.")) self.models_label.setWordWrap(True) orr_form.addRow(self.models_label) + + self.local_llm = LocalModelBox( + ggml.LLAMA, t("On this machine"), + ggml.llm_quants, ggml.llm_model_path, repos=ggml.llm_repos) + orr_form.addRow(self.local_llm) + + self.local_llm_gpu = QCheckBox(t("Use the graphics card")) + self.local_llm_preload = QCheckBox(t("Load the model when Dikte starts")) + self.local_llm_preload.setToolTip( + t("An LLM is slower to load than a whisper model and sits in more " + "memory. Off means it is loaded on the first cleanup instead.")) + self.local_llm_reasoning = QComboBox() + for label, value in REASONING_LEVELS: + self.local_llm_reasoning.addItem(t(label), value) + self.local_llm_reasoning.setToolTip( + t("A model trained to think will think unless it is told not to, and " + "spending 300 tokens of reasoning on a comma is 300 tokens of " + "waiting. Off is what cleanup wants.")) + self.local_llm_options = QWidget() + llm_form = QFormLayout(self.local_llm_options) + + llm_form.setContentsMargins(0, 0, 0, 0) + llm_form.addRow("", self.local_llm_gpu) + llm_form.addRow("", self.local_llm_preload) + llm_form.addRow(t("Thinking"), self.local_llm_reasoning) + orr_form.addRow(self.local_llm_options) + outer.addWidget(orr) outer.addStretch(1) return page @@ -382,15 +817,10 @@ class SettingsWindow(QDialog): how = QGroupBox(t("How it runs")) how_form = QFormLayout(how) - self.assistant_shortcut = self._shortcut_box(t("none")) - how_form.addRow(t("Shortcut"), self._row( - self.assistant_shortcut, - *self._install_buttons(self._install_ask_shortcut, - self._remove_ask_shortcut), - )) - self.assistant_shortcut_status = QLabel("") - self.assistant_shortcut_status.setWordWrap(True) - how_form.addRow(self.assistant_shortcut_status) + self._shortcut_row( + how_form, "ask", t("Shortcut"), + t("No global shortcut installed. The tray menu asks it too."), + ) self.assistant_provider = QComboBox() for label, value in ASSISTANT_PROVIDERS: @@ -652,15 +1082,10 @@ class SettingsWindow(QDialog): )) recording_form.addRow("", self.meeting_keep_audio) - self.meeting_shortcut = self._shortcut_box(t("none")) - recording_form.addRow(t("Shortcut"), self._row( - self.meeting_shortcut, - *self._install_buttons(self._install_meeting_shortcut, - self._remove_meeting_shortcut), - )) - self.meeting_shortcut_status = QLabel("") - self.meeting_shortcut_status.setWordWrap(True) - recording_form.addRow(self.meeting_shortcut_status) + self._shortcut_row( + recording_form, "meeting", t("Shortcut"), + t("No global shortcut installed. The tray menu starts a meeting too."), + ) layout.addWidget(recording) prompt_label = QLabel(t("System instruction given to the minutes model.")) @@ -762,7 +1187,7 @@ class SettingsWindow(QDialog): self.file_run = QPushButton(t("Transcribe")) self.file_run.clicked.connect(self._run_file) self.file_stop = QPushButton(t("Stop")) - self.file_stop.clicked.connect(self.transcriber.stop) + self.file_stop.clicked.connect(self._stop_file) self.file_stop.setEnabled(False) run_row = QHBoxLayout() run_row.addWidget(self.file_run) @@ -801,22 +1226,27 @@ class SettingsWindow(QDialog): def _shortcut_tab(self): page = QWidget() layout = QVBoxLayout(page) + # Both keys in one form, the way the Meeting and Agent tabs already lay + # theirs out. Two forms would give each row a label column of its own, + # and two combination boxes starting at different places read as two + # unrelated settings rather than the pair they are. form = QFormLayout() - self.shortcut = self._shortcut_box("Ctrl+Space") - form.addRow(t("Shortcut"), self.shortcut) + self._shortcut_row( + form, "toggle", t("Start and stop"), + t("No global shortcut installed."), placeholder="Ctrl+Space", + ) + # Stopping is what sends the recording off to be transcribed, and that + # is the step there is no taking back. By the time the tray menu is + # open the sentence you did not mean to dictate is already on its way. + self._shortcut_row( + form, "cancel", t("Discard the recording"), + t("No global shortcut installed. The tray menu discards it too."), + tooltip=t("Throws the recording away without transcribing it. Works " + "on a dictation and on a command for the agent alike, " + "whichever is running."), + ) layout.addLayout(form) - row = QHBoxLayout() - for button in self._install_buttons(self._install_shortcut, - self._remove_shortcut): - row.addWidget(button) - row.addStretch(1) - layout.addLayout(row) - - self.shortcut_status = QLabel("") - self.shortcut_status.setWordWrap(True) - layout.addWidget(self.shortcut_status) - self.evdev_enabled = QCheckBox(t( "Use the built-in listener (/dev/input), for when the {desktop} " "shortcut is not active yet", desktop=hotkey.desktop_name() @@ -925,6 +1355,44 @@ class SettingsWindow(QDialog): box.lineEdit().setPlaceholderText(placeholder) return box + def _key_row(self, form, provider, placeholder, tester): + """A key field, its Test button and the line the answer lands on. + + The field and the pair the answer needs are filed under the provider's + name, so saving, loading and the test handler find them by name rather + than through three attributes each. + """ + field = QLineEdit() + field.setEchoMode(QLineEdit.EchoMode.Password) + field.setPlaceholderText(placeholder) + button = QPushButton(t("Test")) + button.clicked.connect(tester) + answer = QLabel("") + answer.setWordWrap(True) + form.addRow(cfg.TRANSCRIBERS[provider].service, self._row(field, button)) + form.addRow("", answer) + self._key_fields[provider] = field + self._testers[provider] = (button, answer) + return field + + def _shortcut_row(self, form, which, label, missing, placeholder="", + tooltip=""): + """One global shortcut: the combination, Install, Remove, and a line + saying what the desktop has registered. `missing` is what that line + says when nothing is.""" + box = self._shortcut_box(placeholder or t("none")) + if tooltip: + box.setToolTip(tooltip) + form.addRow(label, self._row(box, *self._install_buttons( + lambda: self._install_shortcut(which), + lambda: self._remove_shortcut(which), + ))) + status = QLabel("") + status.setWordWrap(True) + form.addRow(status) + self._shortcut_rows[which] = (box, status, missing) + return box + @staticmethod def _install_buttons(install_handler, remove_handler): """Install and Remove, where this system has somewhere to install into. @@ -969,23 +1437,36 @@ class SettingsWindow(QDialog): self.filter_hallucinations.setChecked(conf["filter_hallucinations"]) self.keep_audio.setChecked(conf["keep_audio"]) - self.openai_key.setText(conf["openai_api_key"]) - self.openrouter_key.setText(conf["openrouter_api_key"]) - self._models = {"openai": conf["transcribe_model"], - "openrouter": conf["openrouter_transcribe_model"]} + for name, who in cfg.TRANSCRIBERS.items(): + self._key_fields[name].setText(conf[who.key]) + self._models[name] = conf[who.model] self._shown_provider = "" self._select_data(self.transcribe_provider, conf["transcribe_provider"]) self._provider_changed() # selecting index 0 fires no signal + self.local_gpu.setChecked(conf["local_gpu"]) + self.local_preload.setChecked(conf["local_preload"]) + self.local_threads.setValue(int(conf["local_threads"])) + self.local_whisper.load(conf["local_model"]) + self.cleanup_enabled.setChecked(conf["cleanup_enabled"]) self.cleanup_model.setCurrentText(conf["cleanup_model"]) + self.cleanup_claude_model.setCurrentText(conf["cleanup_claude_model"]) + self.cleanup_codex_model.setCurrentText( + conf["cleanup_codex_model"] or t("Codex's own default") + ) + self._select_data(self.cleanup_provider, conf["cleanup_provider"]) + self._cleanup_provider_changed() # selecting index 0 fires no signal self._select_data(self.cleanup_reasoning, conf["cleanup_reasoning"]) + self.local_llm_gpu.setChecked(conf["local_llm_gpu"]) + self.local_llm_preload.setChecked(conf["local_llm_preload"]) + self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"]) + self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"]) self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_prompt()) self.file_cleanup_prompt.setPlainText( conf["file_cleanup_prompt"] or cfg.default_file_cleanup_prompt() ) self.transcribe_prompt.setPlainText(conf["transcribe_prompt"]) - self.assistant_shortcut.setCurrentText(conf["assistant_shortcut"]) self._select_data(self.assistant_provider, conf["assistant_provider"]) self.assistant_model.setCurrentText(conf["assistant_model"]) self._select_data(self.assistant_permission, conf["assistant_permission_mode"]) @@ -1014,7 +1495,6 @@ class SettingsWindow(QDialog): self.meeting_cleanup.setChecked(conf["meeting_cleanup"]) self.meeting_max_minutes.setValue(max(5, int(conf["meeting_max_seconds"]) // 60)) self.meeting_keep_audio.setChecked(conf["meeting_keep_audio"]) - self.meeting_shortcut.setCurrentText(conf["meeting_shortcut"]) self.meeting_prompt.setPlainText( conf["meeting_prompt"] or cfg.default_meeting_prompt() ) @@ -1023,14 +1503,14 @@ class SettingsWindow(QDialog): self.file_cleanup.setChecked(conf["file_cleanup"]) self.file_path = "" - self.shortcut.setCurrentText(conf["shortcut"]) + for which, (box, _status, _missing) in self._shortcut_rows.items(): + box.setCurrentText(conf[hotkey.SHORTCUTS[which].setting]) self.evdev_enabled.setChecked(conf["evdev_hotkey"]) self.history_limit.setValue(max(0, int(conf["history_limit"]))) - self._refresh_shortcut_status() - self._refresh_meeting_shortcut_status() - self._refresh_ask_shortcut_status() + for which in self._shortcut_rows: + self._refresh_shortcut_status(which) self._refresh_assistant_status() self._load_history() self._load_minutes() @@ -1050,19 +1530,33 @@ class SettingsWindow(QDialog): conf["filter_hallucinations"] = self.filter_hallucinations.isChecked() conf["keep_audio"] = self.keep_audio.isChecked() - conf["openai_api_key"] = self.openai_key.text().strip() - conf["openrouter_api_key"] = self.openrouter_key.text().strip() - - provider = self.transcribe_provider.currentData() or "openai" - self._models[provider] = self.transcribe_model.currentText().strip() + provider = self.transcribe_provider.currentData() or "local" + if provider in self._models: + self._models[provider] = self.transcribe_model.currentText().strip() conf["transcribe_provider"] = provider - for key, name in (("openai", "transcribe_model"), - ("openrouter", "openrouter_transcribe_model")): - conf[name] = self._models[key].strip() or cfg.DEFAULTS[name] + for name, who in cfg.TRANSCRIBERS.items(): + conf[who.key] = self._key_fields[name].text().strip() + conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model] + conf["local_model"] = self.local_whisper.selected() + conf["local_gpu"] = self.local_gpu.isChecked() + conf["local_preload"] = self.local_preload.isChecked() + conf["local_threads"] = self.local_threads.value() conf["cleanup_enabled"] = self.cleanup_enabled.isChecked() + conf["cleanup_provider"] = self.cleanup_provider.currentData() or "openrouter" conf["cleanup_model"] = self.cleanup_model.currentText().strip() + conf["cleanup_claude_model"] = (self.cleanup_claude_model.currentText().strip() + or cfg.DEFAULTS["cleanup_claude_model"]) + codex_cleanup_model = self.cleanup_codex_model.currentText().strip() + conf["cleanup_codex_model"] = ( + "" if codex_cleanup_model == t("Codex's own default") else codex_cleanup_model + ) conf["cleanup_reasoning"] = self.cleanup_reasoning.currentData() or "" + conf["local_llm_model"] = self.local_llm.selected() + conf["local_llm_repo"] = self.local_llm.repository() + conf["local_llm_gpu"] = self.local_llm_gpu.isChecked() + conf["local_llm_preload"] = self.local_llm_preload.isChecked() + conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or "" # Store an empty prompt when it matches the default, so switching the # interface language also switches the prompt language. @@ -1073,7 +1567,6 @@ class SettingsWindow(QDialog): else file_prompt) conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip() - conf["assistant_shortcut"] = self.assistant_shortcut.currentText().strip() conf["assistant_provider"] = self.assistant_provider.currentData() or "claude" conf["assistant_model"] = (self.assistant_model.currentText().strip() or cfg.DEFAULTS["assistant_model"]) @@ -1113,7 +1606,6 @@ class SettingsWindow(QDialog): conf["meeting_cleanup"] = self.meeting_cleanup.isChecked() conf["meeting_max_seconds"] = self.meeting_max_minutes.value() * 60 conf["meeting_keep_audio"] = self.meeting_keep_audio.isChecked() - conf["meeting_shortcut"] = self.meeting_shortcut.currentText().strip() meeting_prompt = self.meeting_prompt.toPlainText().strip() conf["meeting_prompt"] = ("" if meeting_prompt == cfg.default_meeting_prompt() else meeting_prompt) @@ -1121,7 +1613,12 @@ class SettingsWindow(QDialog): conf["file_timestamps"] = self.file_timestamps.isChecked() conf["file_cleanup"] = self.file_cleanup.isChecked() - conf["shortcut"] = self.shortcut.currentText().strip() or "Ctrl+Space" + # Left empty, only the toggle falls back to a default: the application + # is unusable without it. The other three stay empty, which is what + # turns them off. + for which, (box, _status, _missing) in self._shortcut_rows.items(): + spec = hotkey.SHORTCUTS[which] + conf[spec.setting] = box.currentText().strip() or spec.fallback conf["evdev_hotkey"] = self.evdev_enabled.isChecked() conf["history_limit"] = self.history_limit.value() conf.save() @@ -1143,10 +1640,17 @@ class SettingsWindow(QDialog): def _provider_changed(self): """Swap the model box over to the newly chosen provider's own model.""" - if self._shown_provider: + if self._shown_provider in TRANSCRIBE_MODELS: self._models[self._shown_provider] = self.transcribe_model.currentText().strip() - provider = self.transcribe_provider.currentData() or "openai" + provider = self.transcribe_provider.currentData() or "local" self._shown_provider = provider + local = provider == "local" + self.stt_form.setRowVisible(self.transcribe_model_row, not local) + self.stt_form.setRowVisible(self.transcribe_status, not local) + self.stt_form.setRowVisible(self.local_whisper, local) + self.stt_form.setRowVisible(self.local_options, local) + if local: + return self.transcribe_model.clear() self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider]) self.transcribe_model.setCurrentText(self._models[provider]) @@ -1157,15 +1661,14 @@ class SettingsWindow(QDialog): provider = self.transcribe_provider.currentData() or "openai" self.refresh_transcribe_models.setEnabled(False) self.transcribe_status.setText(t("Fetching model list…")) - openai_key = self.openai_key.text().strip() or self.conf.openai_key() - openrouter_key = self.openrouter_key.text().strip() or self.conf.openrouter_key() - base = self.conf["openai_base_url"] + key, base = self._typed_key(provider) + service = cfg.TRANSCRIBERS[provider].service def work(): try: - models = (api.openrouter_models(openrouter_key, transcription=True) + models = (api.openrouter_models(key, transcription=True) if provider == "openrouter" - else api.openai_models(openai_key, base)) + else api.openai_models(key, base, service)) self._transcribe_models_loaded.emit(models, "") except api.ApiError as exc: self._transcribe_models_loaded.emit([], str(exc)) @@ -1209,42 +1712,51 @@ class SettingsWindow(QDialog): self.models_label.setText(t("{count} models loaded.", count=len(models))) def _test_openai(self): - self.test_button.setEnabled(False) - self.test_label.setText(t("Trying…")) - key = self.openai_key.text().strip() or self.conf.openai_key() - base = self.conf["openai_base_url"] + key, base = self._typed_key("openai") + self._test_key("openai", lambda: t( + "Connection works. {count} audio models visible.", + count=len(api.openai_models(key, base)), + )) - def work(): - try: - models = api.openai_models(key, base) - self._test_done.emit( - True, t("Connection works. {count} audio models visible.", count=len(models)) - ) - except api.ApiError as exc: - self._test_done.emit(False, str(exc)) - - threading.Thread(target=work, daemon=True).start() + def _test_groq(self): + key, base = self._typed_key("groq") + self._test_key("groq", lambda: t( + "Connection works. {count} audio models visible.", + count=len(api.openai_models(key, base, cfg.TRANSCRIBERS["groq"].service)), + )) def _test_openrouter(self): - self.or_test_button.setEnabled(False) - self.or_test_label.setText(t("Trying…")) - key = self.openrouter_key.text().strip() or self.conf.openrouter_key() + key, _ = self._typed_key("openrouter") + self._test_key("openrouter", lambda: api.openrouter_key_status(key)) + + def _typed_key(self, provider): + """(key, base URL) for a provider, preferring what is in the field now.""" + who = cfg.TRANSCRIBERS[provider] + typed = self._key_fields[provider].text().strip() + return typed or self.conf.api_key(who.key), self.conf[who.url] + + def _test_key(self, provider, ask): + """Run `ask` off the interface thread and write its answer under the key. + + `ask` returns the line to show, or raises ApiError with the line to show + instead; either way it is read from a field before the thread starts. + """ + button, answer = self._testers[provider] + button.setEnabled(False) + answer.setText(t("Trying…")) def work(): try: - self._or_test_done.emit(True, api.openrouter_key_status(key)) + self._test_done.emit(provider, True, ask()) except api.ApiError as exc: - self._or_test_done.emit(False, str(exc)) + self._test_done.emit(provider, False, str(exc)) threading.Thread(target=work, daemon=True).start() - def _on_or_test_done(self, ok, message): - self.or_test_button.setEnabled(True) - self.or_test_label.setText(("✓ " if ok else "✗ ") + message) - - def _on_test_done(self, ok, message): - self.test_button.setEnabled(True) - self.test_label.setText(("✓ " if ok else "✗ ") + message) + def _on_test_done(self, provider, ok, message): + button, answer = self._testers[provider] + button.setEnabled(True) + answer.setText(("✓ " if ok else "✗ ") + message) # ---- audio file ------------------------------------------------------ @@ -1259,6 +1771,20 @@ class SettingsWindow(QDialog): self.file_path = path self.file_label.setText(os.path.basename(path)) self.conf["file_last_dir"] = os.path.dirname(path) + self._remember_file_choices() + + def _remember_file_choices(self): + """Keep this tab's choices without waiting for the Save button. + + The two switches and the folder belong to the run rather than to the + form: what was ticked before Transcribe is what the next file wants + too, and Save is at the far end of a window opened to transcribe one + file. Everything else on the tab is a button, so there is nothing here + an unsaved form could be caught by. + """ + self.conf["file_timestamps"] = self.file_timestamps.isChecked() + self.conf["file_cleanup"] = self.file_cleanup.isChecked() + self.conf.save() def _run_file(self): if not getattr(self, "file_path", "") or self.transcriber.busy: @@ -1274,6 +1800,13 @@ class SettingsWindow(QDialog): self.file_cleanup.isChecked(), ) + def _stop_file(self): + # The button goes dead here rather than when the run comes back, so a + # second press cannot land while the first one is still travelling. + self.file_stop.setEnabled(False) + self.file_status.setText(t("Stopping…")) + self.transcriber.stop() + def _on_file_progress(self, message): self.file_status.setText(message) if message == t("Stopped."): @@ -1326,45 +1859,17 @@ class SettingsWindow(QDialog): except OSError as exc: self.file_status.setText(t("Failed: {error}", error=exc)) - # ---- shortcut -------------------------------------------------------- + # ---- shortcuts ------------------------------------------------------- - def _install_shortcut(self): - combo = self.shortcut.currentText().strip() or "Ctrl+Space" - clashes = hotkey.conflicting_shortcuts(combo) - if clashes: - answer = QMessageBox.question( - self, t("Shortcut conflict"), - t("{shortcut} is also used by:\n\n{list}\n\nInstall anyway?", - shortcut=combo, list="\n".join(clashes[:6])), - ) - if answer != QMessageBox.StandardButton.Yes: - return - ok, message = hotkey.install_shortcut(combo, self.launch_command) - QMessageBox.information(self, t("Shortcut"), message) - if ok: - self.conf["shortcut"] = combo - self.conf.save() - self._refresh_shortcut_status() - - def _remove_shortcut(self): - hotkey.remove_shortcut() - self._refresh_shortcut_status() - - def _refresh_shortcut_status(self): - current = hotkey.shortcut_status() - self.shortcut_status.setText( - t("Registered in {desktop}: {shortcut}", - desktop=hotkey.desktop_name(), shortcut=current) if current - else t("No global shortcut installed.") - ) - - def _install_meeting_shortcut(self): - combo = self.meeting_shortcut.currentText().strip() + def _install_shortcut(self, which): + spec = hotkey.SHORTCUTS[which] + box, _status, _missing = self._shortcut_rows[which] + combo = box.currentText().strip() or spec.fallback if not combo: QMessageBox.information(self, t("Shortcut"), t("Type a key combination first.")) return - clashes = hotkey.conflicting_shortcuts(combo, hotkey.MEETING_DESKTOP_ID) + clashes = hotkey.conflicting_shortcuts(combo, spec.desktop_id) if clashes: answer = QMessageBox.question( self, t("Shortcut conflict"), @@ -1374,65 +1879,54 @@ class SettingsWindow(QDialog): if answer != QMessageBox.StandardButton.Yes: return ok, message = hotkey.install_shortcut( - combo, self.meeting_command, name="Dikte: start/end a meeting recording", - desktop_id=hotkey.MEETING_DESKTOP_ID, + combo, ipc.command_for(spec.verb), name=spec.name, + desktop_id=spec.desktop_id, ) QMessageBox.information(self, t("Shortcut"), message) if ok: - self.conf["meeting_shortcut"] = combo + self.conf[spec.setting] = combo self.conf.save() - self._refresh_meeting_shortcut_status() + self._refresh_shortcut_status(which) - def _remove_meeting_shortcut(self): - hotkey.remove_shortcut(hotkey.MEETING_DESKTOP_ID) - self._refresh_meeting_shortcut_status() + def _remove_shortcut(self, which): + hotkey.remove_shortcut(hotkey.SHORTCUTS[which].desktop_id) + self._refresh_shortcut_status(which) - def _refresh_meeting_shortcut_status(self): - current = hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID) - self.meeting_shortcut_status.setText( + def _refresh_shortcut_status(self, which): + _box, status, missing = self._shortcut_rows[which] + current = hotkey.shortcut_status(hotkey.SHORTCUTS[which].desktop_id) + status.setText( t("Registered in {desktop}: {shortcut}", desktop=hotkey.desktop_name(), shortcut=current) if current - else t("No global shortcut installed. The tray menu starts a meeting too.") + else missing ) - # ---- Claude ---------------------------------------------------------- - - def _install_ask_shortcut(self): - combo = self.assistant_shortcut.currentText().strip() - if not combo: - QMessageBox.information(self, t("Shortcut"), - t("Type a key combination first.")) - return - clashes = hotkey.conflicting_shortcuts(combo, hotkey.ASK_DESKTOP_ID) - if clashes: - answer = QMessageBox.question( - self, t("Shortcut conflict"), - t("{shortcut} is also used by:\n\n{list}\n\nInstall anyway?", - shortcut=combo, list="\n".join(clashes[:6])), - ) - if answer != QMessageBox.StandardButton.Yes: - return - ok, message = hotkey.install_shortcut( - combo, self.ask_command, name="Dikte: ask Claude Code", - desktop_id=hotkey.ASK_DESKTOP_ID, - ) - QMessageBox.information(self, t("Shortcut"), message) - if ok: - self.conf["assistant_shortcut"] = combo - self.conf.save() - self._refresh_ask_shortcut_status() - - def _remove_ask_shortcut(self): - hotkey.remove_shortcut(hotkey.ASK_DESKTOP_ID) - self._refresh_ask_shortcut_status() - - def _refresh_ask_shortcut_status(self): - current = hotkey.shortcut_status(hotkey.ASK_DESKTOP_ID) - self.assistant_shortcut_status.setText( - t("Registered in {desktop}: {shortcut}", - desktop=hotkey.desktop_name(), shortcut=current) if current - else t("No global shortcut installed. The tray menu asks it too.") - ) + def _cleanup_provider_changed(self): + provider = self.cleanup_provider.currentData() or "openrouter" + self.cleanup_form.setRowVisible(self.cleanup_model_row, + provider == "openrouter") + self.cleanup_form.setRowVisible(self.cleanup_claude_model, + provider == "claude") + self.cleanup_form.setRowVisible(self.cleanup_codex_model, + provider == "codex") + self.cleanup_form.setRowVisible(self.cleanup_reasoning, + provider != "local") + self.cleanup_form.setRowVisible(self.local_llm, provider == "local") + self.cleanup_form.setRowVisible(self.local_llm_options, provider == "local") + binary = cleanup.executable(provider) + found = shutil.which(binary) if binary else "" + if provider == "local": + self.models_label.setText(t("Runs on this machine, on llama.cpp.")) + elif not binary: + self.models_label.setText(t("Runs on OpenRouter.")) + elif found: + self.models_label.setText(t("Found: {path}", path=found)) + else: + self.models_label.setText(t( + "{binary} is not on your PATH, so cleanup would fail and the raw " + "transcript would be pasted. Install it, or pick another one " + "above.", binary=binary, + )) def _assistant_provider_changed(self): provider = self.assistant_provider.currentData() or "claude" diff --git a/tests/__init__.py b/tests/__init__.py index bcbdba8..5b00175 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -27,7 +27,7 @@ atexit.register(shutil.rmtree, _SANDBOX, True) # A key sitting in the environment would otherwise reach the code that falls # back to it, and the tests for "there is no key" would pass only on a machine # without one. -for _var in ("OPENAI_API_KEY", "OPENROUTER_API_KEY"): +for _var in ("OPENAI_API_KEY", "GROQ_API_KEY", "OPENROUTER_API_KEY"): os.environ.pop(_var, None) # The interface language leaks through module-level state, so the tests fix it diff --git a/tests/support.py b/tests/support.py index b199940..26d069b 100644 --- a/tests/support.py +++ b/tests/support.py @@ -18,6 +18,7 @@ import sys import tempfile import unittest import urllib.error +import urllib.request import wave from unittest import mock @@ -40,6 +41,12 @@ linux_only = unittest.skipUnless( ) +def _no_network(*args, **kwargs): + raise AssertionError( + "a test reached the network; wrap the call in support.fake_urlopen" + ) + + def _no_exec(*args, **kwargs): raise AssertionError( "a test reached os.execv, which would replace the test process with the " @@ -78,6 +85,11 @@ class DikteTest(unittest.TestCase): # with it and hang, so it fails loudly here instead. self.patch_attr(os, "execv", _no_exec) + # Every way out of here goes through urllib, so closing it is enough to + # keep the suite offline. A test that means to answer a request patches + # this again through fake_urlopen. + self.patch_attr(urllib.request, "urlopen", _no_network) + # ---- helpers --------------------------------------------------------- def path(self, *parts): diff --git a/tests/test_api.py b/tests/test_api.py index 30e7e3e..f45ca21 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,13 +3,21 @@ Nothing here reaches the network. What is checked is the request that would have gone out, because that is what a new provider changes and what an old one notices: the URL, the headers, the fields of the multipart body, the JSON. + +Stopping one is the exception. Cutting a request off is done to the socket it +is blocked on, and a faked urlopen has no socket to cut, so those tests talk to +a server of their own on the loopback interface. """ +import http.server import json import os +import threading +import time import unittest import api +import ggml from tests.support import ( DikteTest, fake_urlopen, @@ -21,6 +29,7 @@ from tests.support import ( ) OPENAI = api.Target("openai", "OpenAI", "sk-test", api.OPENAI_URL, "gpt-4o-transcribe") +GROQ = api.Target("groq", "Groq", "gsk-test", api.GROQ_URL, "whisper-large-v3-turbo") OPENROUTER = api.Target("openrouter", "OpenRouter", "sk-or-test", api.OPENROUTER_URL, "openai/gpt-4o-transcribe") @@ -32,6 +41,18 @@ class TimestampModel(unittest.TestCase): def test_openrouter_namespaces_the_id(self): self.assertEqual(api.timestamp_model("openrouter"), "openai/whisper-1") + def test_groq_keeps_the_model_that_was_chosen(self): + """Every model it transcribes with is a whisper, so all of them do times.""" + self.assertEqual(api.timestamp_model("groq", "whisper-large-v3"), + "whisper-large-v3") + + def test_groq_with_nothing_chosen_falls_back(self): + self.assertEqual(api.timestamp_model("groq"), "whisper-large-v3-turbo") + + def test_the_others_ignore_what_was_chosen(self): + self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"), + "whisper-1") + class Explain(DikteTest): def error(self, status): @@ -176,13 +197,28 @@ class Transcribe(DikteTest): self.assertEqual(multipart_fields(calls[0])["language"], "tr") self.assertNotIn("language", multipart_fields(calls[1])) - def test_the_glossary_goes_to_openai_only(self): + def test_the_glossary_goes_everywhere_but_openrouter(self): """OpenRouter takes the field and throws it away, so spare it the bytes.""" with fake_urlopen({"text": "hi"}) as calls: api.transcribe(OPENAI, self.wav, prompt="Paraşüt, OpenFrame") + api.transcribe(GROQ, self.wav, prompt="Paraşüt, OpenFrame") api.transcribe(OPENROUTER, self.wav, prompt="Paraşüt, OpenFrame") self.assertIn("prompt", multipart_fields(calls[0])) - self.assertNotIn("prompt", multipart_fields(calls[1])) + self.assertIn("prompt", multipart_fields(calls[1])) + self.assertNotIn("prompt", multipart_fields(calls[2])) + + def test_groq_goes_to_groq(self): + with fake_urlopen({"text": "hi"}) as calls: + api.transcribe(GROQ, self.wav) + self.assertEqual(calls[0].full_url, + "https://api.groq.com/openai/v1/audio/transcriptions") + self.assertEqual(multipart_fields(calls[0])["model"], "whisper-large-v3-turbo") + + def test_a_refused_groq_key_is_explained_in_groq_s_name(self): + with fake_urlopen(http_error(401, '{"error": {"message": "bad key"}}')), \ + self.assertRaises(api.ApiError) as caught: + api.transcribe(GROQ, self.wav) + self.assertIn("Groq", str(caught.exception)) def test_openrouter_is_attributed(self): with fake_urlopen({"text": "hi"}) as calls: @@ -242,6 +278,12 @@ class TranscribeSegments(DikteTest): api.transcribe_segments(OPENROUTER, self.wav) self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1") + def test_groq_stays_on_the_model_it_was_given(self): + target = GROQ._replace(model="whisper-large-v3") + with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls: + api.transcribe_segments(target, self.wav) + self.assertEqual(multipart_fields(calls[0])["model"], "whisper-large-v3") + def test_the_segments_come_back_as_numbers(self): with fake_urlopen(self.reply([ {"start": "0.5", "end": "2.25", "text": " hello "}, @@ -432,6 +474,229 @@ class ModelLists(DikteTest): with self.assertRaises(api.ApiError): api.openai_models("") + def test_the_same_list_read_from_groq(self): + with fake_urlopen({"data": [{"id": "llama-3.3-70b"}, + {"id": "whisper-large-v3"}]}) as calls: + models = api.openai_models("gsk-test", api.GROQ_URL, "Groq") + self.assertEqual(calls[0].full_url, "https://api.groq.com/openai/v1/models") + self.assertEqual(models, ["whisper-large-v3"]) + + def test_a_missing_groq_key_says_groq(self): + with self.assertRaises(api.ApiError) as caught: + api.openai_models("", api.GROQ_URL, "Groq") + self.assertIn("Groq", str(caught.exception)) + if __name__ == "__main__": unittest.main() + + +class FakeServer: + """A ggml.Server as far as api.py is concerned.""" + + def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log=""): + self.url = url + self.fails = fails + self.log = log + self.starts = 0 + + def serve(self): + self.starts += 1 + if self.fails: + raise ggml.LocalError(self.fails) + return self.url + + def error(self): + return self.log + + +LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin") + + +class TranscribeHere(DikteTest): + def setUp(self): + super().setUp() + self.wav = str(self.path("clip.wav")) + os.makedirs(self.root, exist_ok=True) + with open(self.wav, "wb") as fh: + fh.write(b"RIFFfake") + self.server = FakeServer() + self.patch_attr(ggml, "whisper", self.server) + + def test_the_address_comes_from_the_server_it_starts(self): + with fake_urlopen({"text": "hello"}) as calls: + api.transcribe(LOCAL, self.wav) + self.assertEqual(self.server.starts, 1) + self.assertEqual(calls[0].full_url, + "http://127.0.0.1:9999/v1/audio/transcriptions") + + def test_nothing_local_is_authorised(self): + with fake_urlopen({"text": "hello"}) as calls: + api.transcribe(LOCAL, self.wav) + self.assertNotIn("Authorization", calls[0].headers) + + def test_a_server_that_will_not_start_is_the_error_shown(self): + self.patch_attr(ggml, "whisper", FakeServer(fails="no model downloaded")) + with self.assertRaises(api.ApiError) as caught: + api.transcribe(LOCAL, self.wav) + self.assertIn("no model downloaded", str(caught.exception)) + + def test_a_server_that_dies_mid_request_says_what_it_printed(self): + self.patch_attr(ggml, "whisper", FakeServer(log="out of memory")) + with fake_urlopen(url_error("connection reset")): + with self.assertRaises(api.ApiError) as caught: + api.transcribe(LOCAL, self.wav) + self.assertIn("out of memory", str(caught.exception)) + + def test_the_hint_reaches_whisper_as_its_initial_prompt(self): + with fake_urlopen({"text": "hi"}) as calls: + api.transcribe(LOCAL, self.wav, prompt="Dikte, Paraşüt") + self.assertEqual(multipart_fields(calls[0])["prompt"], "Dikte, Paraşüt") + + def test_a_word_broken_over_two_lines_is_put_back_together(self): + # whisper.cpp cuts on tokens and writes one segment per line, which in + # Turkish lands inside a word about as often as between two. + with fake_urlopen({"text": "Onlar akraba değ\niller. Ve\n devamı."}): + # The line break inside a word leaves nothing in its place; the + # one between two words is where whisper's own leading space is. + self.assertEqual(api.transcribe(LOCAL, self.wav), + "Onlar akraba değiller. Ve devamı.") + + def test_a_local_timeout_is_not_a_hosted_one(self): + # Nothing is being spent but time, and a long file on a machine without + # a graphics card takes a good deal of it. + with fake_urlopen({"text": "hi"}): + api.transcribe(LOCAL, self.wav, timeout=300) + self.assertGreaterEqual(api.LOCAL_TIMEOUT, 600) + + def test_segments_that_continue_a_word_are_merged(self): + reply = {"segments": [ + {"start": 0.0, "end": 1.0, "text": " Onlar akraba değ"}, + {"start": 1.0, "end": 1.4, "text": "iller."}, + {"start": 2.0, "end": 3.0, "text": " Başka bir cümle."}, + ]} + with fake_urlopen(reply): + out = api.transcribe_segments(LOCAL, self.wav) + self.assertEqual([text for _, _, text in out], + ["Onlar akraba değiller.", "Başka bir cümle."]) + self.assertEqual(out[0][1], 1.4) # the merged cue covers the whole word + + def test_the_loaded_model_is_the_one_asked_for_again(self): + with fake_urlopen({"segments": [{"start": 0, "end": 1, "text": " hi"}]}) as calls: + api.transcribe_segments(LOCAL, self.wav) + self.assertEqual(multipart_fields(calls[0])["model"], "ggml-base.bin") + + +class Stopping(unittest.TestCase): + """The Stop button, from the far end: a request already blocked on a reply. + + The one that matters is a whisper on this machine, which answers minutes + after it was asked, so it is a real socket here rather than a fake urlopen. + Nothing leaves the loopback interface. + """ + + def setUp(self): + answering = threading.Event() + + class Slow(http.server.BaseHTTPRequestHandler): + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length") or 0)) + answering.set() + time.sleep(30) # the model, thinking + + def log_message(self, *args): + pass + + self.answering = answering + self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Slow) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.addCleanup(self.server.server_close) + self.addCleanup(self.server.shutdown) + self.url = f"http://127.0.0.1:{self.server.server_address[1]}/v1/x" + + def post(self, aborter, out): + try: + api._request(self.url, b"{}", {}, timeout=30, aborter=aborter) + out.append("answered") + except BaseException as exc: # noqa: BLE001 - the type is the result + out.append(type(exc).__name__) + + def test_a_request_waiting_on_a_reply_is_cut_off(self): + aborter, out = api.Aborter(), [] + thread = threading.Thread(target=self.post, args=(aborter, out)) + thread.start() + self.assertTrue(self.answering.wait(10)) + aborter.abort() + thread.join(timeout=10) + self.assertFalse(thread.is_alive()) + self.assertEqual(out, ["Aborted"]) + + def test_a_request_that_starts_after_the_stop_never_goes_out(self): + aborter, out = api.Aborter(), [] + aborter.abort() + self.post(aborter, out) + self.assertEqual(out, ["Aborted"]) + self.assertFalse(self.answering.is_set()) + + def test_without_one_the_request_is_the_plain_urllib_one(self): + """Everything that is not stoppable keeps the opener it always had.""" + with fake_urlopen({"text": "hi"}) as calls: + api._request(self.url, b"{}", {}) + self.assertEqual(len(calls), 1) + + +class Sockets(unittest.TestCase): + """The few lines urllib takes between making a connection and blocking on + it. A stop that lands in there must not leave the request waiting out its + hour-long local timeout.""" + + class FakeConn: + auto_open = 1 + sock = None + closed = False + + def close(self): + self.closed = True + + def test_a_connection_opened_after_the_stop_is_refused(self): + sockets = api._Sockets() + sockets.cut() + with self.assertRaises(api.Aborted): + sockets.add(self.FakeConn()) + + def test_one_that_is_already_open_is_closed_where_it_stands(self): + sockets, conn = api._Sockets(), self.FakeConn() + sockets.add(conn) + sockets.cut() + self.assertTrue(conn.closed) + + def test_one_with_no_socket_yet_is_stopped_from_making_another(self): + """close() leaves auto_open on, and the next line would reconnect.""" + sockets, conn = api._Sockets(), self.FakeConn() + sockets.add(conn) + sockets.cut() + self.assertEqual(conn.auto_open, 0) + + +class Aborter(unittest.TestCase): + def test_what_was_registered_is_run_once_the_stop_lands(self): + aborter, cut = api.Aborter(), [] + with aborter.holding(lambda: cut.append(True)): + aborter.abort() + self.assertEqual(cut, [True]) + + def test_a_block_that_ended_is_not_cut_afterwards(self): + aborter, cut = api.Aborter(), [] + with aborter.holding(lambda: cut.append(True)): + pass + aborter.abort() + self.assertEqual(cut, []) + + def test_a_stop_that_already_landed_stops_the_next_step_too(self): + aborter = api.Aborter() + aborter.abort() + with self.assertRaises(api.Aborted): + aborter.check() + with self.assertRaises(api.Aborted): + with aborter.holding(lambda: None): + pass diff --git a/tests/test_assistant.py b/tests/test_assistant.py index 8fee724..c57cfbe 100644 --- a/tests/test_assistant.py +++ b/tests/test_assistant.py @@ -79,9 +79,12 @@ class Effort(unittest.TestCase): self.assertEqual(assistant.CODEX_EFFORT["xhigh"], "high") self.assertEqual(assistant.CODEX_EFFORT["max"], "high") - def test_claude_has_no_rung_below_low(self): - self.assertEqual(assistant.CLAUDE_EFFORT["none"], "low") - self.assertEqual(assistant.CLAUDE_EFFORT["minimal"], "low") + def test_neither_one_asks_for_a_rung_below_low(self): + # Claude has none; Codex has one, but calls it "minimal" on the older + # models and "none" on the newer ones, and refuses the wrong word. + for scale in (assistant.CLAUDE_EFFORT, assistant.CODEX_EFFORT): + self.assertEqual(scale["none"], "low") + self.assertEqual(scale["minimal"], "low") def test_an_empty_setting_asks_for_nothing(self): self.assertEqual(assistant.CLAUDE_EFFORT.get("", ""), "") @@ -232,10 +235,10 @@ class SessionMissing(unittest.TestCase): self.assertFalse(assistant._session_missing(text)) def test_the_last_line_is_the_one_worth_showing(self): - self.assertEqual(assistant._last_line("warning\n\nreal error\n"), + self.assertEqual(assistant.last_line("warning\n\nreal error\n"), "real error") - self.assertEqual(assistant._last_line(""), "") - self.assertEqual(assistant._last_line(None), "") + self.assertEqual(assistant.last_line(""), "") + self.assertEqual(assistant.last_line(None), "") class Conclude(DikteTest): diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py new file mode 100644 index 0000000..efb8417 --- /dev/null +++ b/tests/test_cleanup.py @@ -0,0 +1,286 @@ +"""Who cleans the transcript up, and what they are asked. + +The CLIs are faked at subprocess.run: what the tests read is the argument list +each one is given, where the answer is picked up from, and what happens to the +chain when the program is missing, slow or unhappy. The OpenRouter path is the +one that was always there and is checked here only for still being taken. +""" + +import os +import subprocess +import unittest +from unittest import mock + +import api +import cleanup +import ggml +from tests.support import DikteTest, fake_urlopen, sent_json, url_error +from tests.test_api import FakeServer, chat_reply + + +def fake_run(stdout="", code=0, stderr="", last_message=""): + """Stand in for subprocess.run, writing the file Codex would have written.""" + calls = [] + + def run(cmd, **kwargs): + calls.append(cmd) + if last_message and "-o" in cmd: + with open(cmd[cmd.index("-o") + 1], "w", encoding="utf-8") as fh: + fh.write(last_message) + return subprocess.CompletedProcess(cmd, code, stdout, stderr) + + return mock.patch.object(subprocess, "run", side_effect=run), calls + + +class Provider(DikteTest): + def test_the_default_is_still_openrouter(self): + self.assertEqual(cleanup.provider(self.config()), "openrouter") + + def test_a_provider_this_version_does_not_have(self): + self.assertEqual( + cleanup.provider(self.config(cleanup_provider="ollama")), "openrouter") + + def test_each_one_is_recognised(self): + for name in cleanup.PROVIDERS: + with self.subTest(name=name): + self.assertEqual( + cleanup.provider(self.config(cleanup_provider=name)), name) + + def test_what_each_one_runs(self): + self.assertEqual(cleanup.executable("claude"), "claude") + self.assertEqual(cleanup.executable("codex"), "codex") + self.assertEqual(cleanup.executable("openrouter"), "") + + def test_the_model_named_in_the_history_is_the_one_that_did_it(self): + self.assertEqual(cleanup.model(self.config(cleanup_model="some/model")), + "some/model") + self.assertEqual( + cleanup.model(self.config(cleanup_provider="claude")), "haiku") + self.assertEqual( + cleanup.model(self.config(cleanup_provider="claude", + cleanup_claude_model="opus")), "opus") + # Codex on its own default has no model id to report, only a name. + self.assertEqual( + cleanup.model(self.config(cleanup_provider="codex")), "codex") + self.assertEqual( + cleanup.model(self.config(cleanup_provider="codex", + cleanup_codex_model="gpt-5.4")), "gpt-5.4") + + +class OpenRouter(DikteTest): + def test_it_is_still_one_request_with_the_settings_as_they_were(self): + conf = self.config(openrouter_api_key="sk-or-test", + cleanup_model="some/model", cleanup_reasoning="low") + with mock.patch.object(api, "cleanup", return_value="Done.") as call: + self.assertEqual(cleanup.run("uh, done", conf, "the rules"), "Done.") + text, key, model, prompt = call.call_args.args + self.assertEqual((text, key, model, prompt), + ("uh, done", "sk-or-test", "some/model", "the rules")) + self.assertEqual(call.call_args.kwargs["reasoning"], "low") + + def test_no_cli_is_started_for_it(self): + conf = self.config(openrouter_api_key="sk-or-test") + patcher, calls = fake_run(stdout="never") + with patcher, mock.patch.object(api, "cleanup", return_value="Done."): + cleanup.run("uh, done", conf, "the rules") + self.assertEqual(calls, []) + + +class ClaudeCode(DikteTest): + def setUp(self): + super().setUp() + self.conf = self.config(cleanup_provider="claude") + self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}") + + def run_cleanup(self, text="uh, book it", **kwargs): + patcher, calls = fake_run(**kwargs) + with patcher: + answer = cleanup.run(text, self.conf, "the rules") + return answer, calls[0] + + def test_the_transcript_goes_in_fenced_and_the_rules_go_in_as_the_prompt(self): + answer, cmd = self.run_cleanup(stdout="Book it.\n") + self.assertEqual(answer, "Book it.") + self.assertEqual(cmd[0], "claude") + self.assertIn("\nuh, book it\n", cmd) + self.assertEqual(cmd[cmd.index("--system-prompt") + 1], "the rules") + self.assertEqual(cmd[cmd.index("--model") + 1], "haiku") + + def test_it_is_given_nothing_to_run_and_nothing_to_remember(self): + _, cmd = self.run_cleanup(stdout="Book it.") + self.assertEqual(cmd[cmd.index("--tools") + 1], "") + self.assertIn("--strict-mcp-config", cmd) + self.assertIn("--no-session-persistence", cmd) + + def test_the_thinking_setting_is_carried_over_in_its_own_words(self): + self.conf["cleanup_reasoning"] = "none" + _, cmd = self.run_cleanup(stdout="Book it.") + self.assertEqual(cmd[cmd.index("--effort") + 1], "low") + + def test_no_thinking_setting_means_no_flag(self): + _, cmd = self.run_cleanup(stdout="Book it.") + self.assertNotIn("--effort", cmd) + + def test_a_model_of_your_own(self): + self.conf["cleanup_claude_model"] = "claude-sonnet-5" + _, cmd = self.run_cleanup(stdout="Book it.") + self.assertEqual(cmd[cmd.index("--model") + 1], "claude-sonnet-5") + + def test_an_answer_of_nothing_is_a_failure_rather_than_an_empty_paste(self): + with self.assertRaises(cleanup.CleanupError): + self.run_cleanup(stdout=" \n") + + def test_the_last_line_of_the_complaint_is_what_gets_shown(self): + with self.assertRaises(cleanup.CleanupError) as caught: + self.run_cleanup(code=1, stderr="a warning\nout of credit\n") + self.assertEqual(str(caught.exception), "out of credit") + + def test_a_failure_is_the_same_kind_the_chain_already_catches(self): + # worker, the file transcriber and the meeting all keep the raw + # transcript when an ApiError comes out of here. + self.assertTrue(issubclass(cleanup.CleanupError, api.ApiError)) + + def test_a_program_that_is_not_installed_says_so_before_running_anything(self): + self.patch_attr(cleanup.shutil, "which", lambda name: "") + with self.assertRaises(cleanup.CleanupError) as caught: + self.run_cleanup(stdout="Book it.") + self.assertIn("claude", str(caught.exception)) + + def test_a_run_that_never_ends(self): + def run(cmd, **kwargs): + raise subprocess.TimeoutExpired(cmd, 180) + + with mock.patch.object(subprocess, "run", side_effect=run): + with self.assertRaises(cleanup.CleanupError) as caught: + cleanup.run("uh, book it", self.conf, "the rules") + self.assertIn("180", str(caught.exception)) + + +class Codex(DikteTest): + def setUp(self): + super().setUp() + self.conf = self.config(cleanup_provider="codex") + self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}") + + def run_cleanup(self, text="uh, book it", **kwargs): + patcher, calls = fake_run(**kwargs) + with patcher: + answer = cleanup.run(text, self.conf, "the rules") + return answer, calls[0] + + def test_the_rules_ride_in_front_of_the_transcript(self): + answer, cmd = self.run_cleanup(last_message="Book it.\n") + self.assertEqual(answer, "Book it.") + self.assertEqual(cmd[:2], ["codex", "exec"]) + self.assertEqual(cmd[-1], + "the rules\n\n---\n\n\nuh, book it\n") + + def test_the_answer_is_read_from_the_file_rather_than_the_noise_on_stdout(self): + answer, _ = self.run_cleanup( + stdout="workdir: /home\nmodel: gpt-5.4\ntokens used 400\n", + last_message="Book it.", + ) + self.assertEqual(answer, "Book it.") + + def test_that_file_does_not_stay_behind(self): + _, cmd = self.run_cleanup(last_message="Book it.") + self.assertFalse(os.path.exists(cmd[cmd.index("-o") + 1])) + + def test_it_may_read_but_not_write_and_has_nobody_to_ask(self): + _, cmd = self.run_cleanup(last_message="Book it.") + self.assertEqual(cmd[cmd.index("--sandbox") + 1], "read-only") + self.assertIn('approval_policy="never"', cmd) + self.assertIn("--ephemeral", cmd) + + def test_the_model_is_left_alone_until_one_is_typed_in(self): + _, cmd = self.run_cleanup(last_message="Book it.") + self.assertNotIn("-m", cmd) + self.conf["cleanup_codex_model"] = "gpt-5.4" + _, cmd = self.run_cleanup(last_message="Book it.") + self.assertEqual(cmd[cmd.index("-m") + 1], "gpt-5.4") + + def test_the_thinking_setting_lands_on_the_nearest_rung_codex_has(self): + self.conf["cleanup_reasoning"] = "xhigh" + _, cmd = self.run_cleanup(last_message="Book it.") + self.assertIn('model_reasoning_effort="high"', cmd) + + def test_an_answer_of_nothing(self): + with self.assertRaises(cleanup.CleanupError): + self.run_cleanup(stdout="tokens used 400", last_message="") + + +if __name__ == "__main__": + unittest.main() + + +class Here(DikteTest): + """llama.cpp, answering the request OpenRouter answers.""" + + def setUp(self): + super().setUp() + self.conf = self.config(cleanup_provider="local", + local_llm_model="gemma.gguf") + self.server = FakeServer() + self.patch_attr(ggml, "llm", self.server) + + def test_the_address_comes_from_the_server_it_starts(self): + with fake_urlopen(chat_reply("Done.")) as calls: + self.assertEqual(cleanup.run("uh, done", self.conf, "the rules"), + "Done.") + self.assertEqual(self.server.starts, 1) + self.assertEqual(calls[0].full_url, + "http://127.0.0.1:9999/v1/chat/completions") + + def test_no_key_is_wanted_and_none_is_sent(self): + with fake_urlopen(chat_reply("Done.")) as calls: + cleanup.run("uh, done", self.conf, "the rules") + self.assertNotIn("Authorization", calls[0].headers) + + def test_thinking_is_turned_off_in_the_words_llama_cpp_uses(self): + with fake_urlopen(chat_reply("Done.")) as calls: + cleanup.run("uh, done", self.conf, "the rules") + self.assertEqual(sent_json(calls[0])["chat_template_kwargs"], + {"enable_thinking": False}) + + def test_the_models_own_default_asks_for_nothing(self): + self.conf["local_llm_reasoning"] = "" + with fake_urlopen(chat_reply("Done.")) as calls: + cleanup.run("uh, done", self.conf, "the rules") + self.assertNotIn("chat_template_kwargs", sent_json(calls[0])) + + def test_a_reply_longer_than_the_transcript_is_cut_off(self): + # A small model will repeat the transcript until the context is full, + # and every one of those tokens is a second of somebody waiting. + with fake_urlopen(chat_reply("Done.")) as calls: + cleanup.run("x" * 4000, self.conf, "the rules") + self.assertEqual(sent_json(calls[0])["max_tokens"], 4000) + + def test_a_short_dictation_still_gets_room_to_answer(self): + with fake_urlopen(chat_reply("Done.")) as calls: + cleanup.run("uh, done", self.conf, "the rules") + self.assertEqual(sent_json(calls[0])["max_tokens"], 512) + + def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self): + reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]} + with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught: + cleanup.run("uh, done", self.conf, "the rules") + self.assertIn("Thinking", str(caught.exception)) + + def test_a_server_that_will_not_start_is_the_error_shown(self): + self.patch_attr(ggml, "llm", FakeServer(fails="llama.cpp is not installed")) + with self.assertRaises(api.ApiError) as caught: + cleanup.run("uh, done", self.conf, "the rules") + self.assertIn("llama.cpp", str(caught.exception)) + + def test_a_server_that_dies_mid_request_says_what_it_printed(self): + self.patch_attr(ggml, "llm", FakeServer(log="out of memory")) + with fake_urlopen(url_error("connection reset")): + with self.assertRaises(api.ApiError) as caught: + cleanup.run("uh, done", self.conf, "the rules") + self.assertIn("out of memory", str(caught.exception)) + + def test_no_cli_is_started_for_it(self): + patcher, calls = fake_run(stdout="never") + with patcher, fake_urlopen(chat_reply("Done.")): + cleanup.run("uh, done", self.conf, "the rules") + self.assertEqual(calls, []) diff --git a/tests/test_cli.py b/tests/test_cli.py index 98145c3..9547df2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,8 +14,9 @@ from unittest import mock import cli import config as cfg +import hotkey import ipc -from tests.support import DikteTest +from tests.support import DikteTest, fake_urlopen class Options: @@ -144,6 +145,22 @@ class Parser(unittest.TestCase): self.assertIsNone(opts.verb) self.assertEqual(opts.func, cli.cmd_plain) + def test_every_global_shortcut_runs_a_verb_that_exists(self): + """A shortcut registers a command line; a verb the parser never heard of + is a key that does nothing at all when it is pressed.""" + for name, spec in hotkey.SHORTCUTS.items(): + with self.subTest(name=name): + opts = self.parse(spec.verb) + self.assertTrue(callable(opts.func)) + + def test_every_shortcut_can_be_installed_and_removed_by_name(self): + for name in hotkey.SHORTCUTS: + with self.subTest(name=name): + self.assertEqual(self.parse("shortcut", "install", name).which, + name) + self.assertEqual(self.parse("shortcut", "remove", name).which, + name) + def test_every_verb_is_wired_to_something(self): for verb in ("record", "toggle", "start", "stop", "cancel", "ask", "session", "transcribe", "meeting", "meetings", "history", @@ -316,6 +333,67 @@ class ConfigCommands(DikteTest): {"cleanup", "subtitles", "meeting", "agent"}) +class Providers(DikteTest): + """The terminal reaches every provider the settings window does.""" + + def run_cmd(self, func, **values): + with captured() as (out, err): + code = func(Options(**values)) + return code, out.getvalue(), err.getvalue() + + def test_a_provider_the_settings_window_offers_is_a_choice_here_too(self): + parser = cli.build_parser() + for provider in cfg.TRANSCRIBERS: + with self.subTest(provider=provider): + opts = parser.parse_args(["models", "--provider", provider]) + self.assertEqual(opts.provider, provider) + self.assertEqual(parser.parse_args(["test-key", provider]).which, + provider) + + def test_the_model_list_is_read_from_the_chosen_provider(self): + self.write_config({"groq_api_key": "gsk-test"}) + with fake_urlopen({"data": [{"id": "whisper-large-v3"}]}) as calls: + code, out, _ = self.run_cmd(cli.cmd_models, provider="groq", + transcription=False) + self.assertEqual(code, 0) + self.assertEqual(calls[0].full_url, "https://api.groq.com/openai/v1/models") + self.assertEqual(out.strip(), "whisper-large-v3") + + def test_a_key_that_is_not_there_is_reported_under_its_own_name(self): + code, out, _ = self.run_cmd(cli.cmd_test_key, which="groq") + self.assertEqual(code, 1) + self.assertIn("groq", out) + self.assertIn("Groq", out) + + +class Doctor(DikteTest): + """One pass over everything the settings window checks behind its buttons.""" + + def run_doctor(self, as_json=True, **settings): + self.write_config(settings) + with mock.patch.object(ipc, "send", return_value=None), \ + captured() as (out, _err): + cli.cmd_doctor(Options(json=as_json)) + return json.loads(out.getvalue()) if as_json else out.getvalue() + + def test_cleanup_on_openrouter_is_a_question_about_the_key(self): + reply = self.run_doctor(cleanup_model="some/model") + self.assertEqual(reply["cleanup"]["provider"], "openrouter") + self.assertEqual(reply["cleanup"]["model"], "some/model") + self.assertIn("OpenRouter key, cleaning up on some/model", + self.run_doctor(as_json=False, cleanup_model="some/model")) + + def test_cleanup_on_a_cli_is_a_question_about_the_program(self): + reply = self.run_doctor(cleanup_provider="codex", + cleanup_codex_model="gpt-5.4") + self.assertEqual(reply["cleanup"]["provider"], "codex") + self.assertEqual(reply["cleanup"]["model"], "gpt-5.4") + self.assertIn("codex", reply["programs"]) + self.assertIn("codex, cleaning up on gpt-5.4", + self.run_doctor(as_json=False, cleanup_provider="codex", + cleanup_codex_model="gpt-5.4")) + + class Finding(DikteTest): def test_no_history_at_all(self): self.assertIsNone(cli._find_history("last")) diff --git a/tests/test_config.py b/tests/test_config.py index f9f16ab..31e153c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,7 +12,9 @@ import unittest from unittest import mock import api +import cleanup import config as cfg +import ggml import i18n import paste from tests.support import DikteTest @@ -126,10 +128,23 @@ class Keys(DikteTest): def test_no_key_anywhere(self): self.assertEqual(cfg.Config().openai_key(), "") + def test_every_provider_falls_back_to_the_variable_of_its_own_name(self): + with mock.patch.dict(os.environ, {"GROQ_API_KEY": "gsk-env"}): + self.assertEqual(cfg.Config().groq_key(), "gsk-env") + class TranscribeTarget(DikteTest): - def test_openai_by_default(self): - target = self.config(openai_api_key="sk-test").transcribe_target() + def test_this_machine_by_default(self): + target = cfg.Config().transcribe_target() + self.assertEqual(target.provider, "local") + self.assertEqual(target.api_key, "") + # Empty on purpose: the server picks a port when it starts, and reading + # a setting must not be what starts it. + self.assertEqual(target.base_url, "") + + def test_openai_when_it_is_picked(self): + target = self.config(transcribe_provider="openai", + openai_api_key="sk-test").transcribe_target() self.assertEqual(target.provider, "openai") self.assertEqual(target.service, "OpenAI") self.assertEqual(target.api_key, "sk-test") @@ -146,8 +161,24 @@ class TranscribeTarget(DikteTest): self.assertEqual(target.api_key, "sk-or-test") self.assertEqual(target.model, "openai/whisper-1") + def test_groq_when_it_is_picked(self): + conf = self.config(transcribe_provider="groq", groq_api_key="gsk-test", + groq_transcribe_model="whisper-large-v3") + target = conf.transcribe_target() + self.assertEqual(target.provider, "groq") + self.assertEqual(target.service, "Groq") + self.assertEqual(target.api_key, "gsk-test") + self.assertEqual(target.base_url, api.GROQ_URL) + self.assertEqual(target.model, "whisper-large-v3") + + def test_a_provider_this_version_has_never_heard_of(self): + """A config written by a fork, or by a version that dropped one.""" + target = self.config(transcribe_provider="deepgram").transcribe_target() + self.assertEqual(target.provider, "openai") + def test_a_self_hosted_endpoint(self): - conf = self.config(openai_base_url="http://localhost:8080/v1") + conf = self.config(transcribe_provider="openai", + openai_base_url="http://localhost:8080/v1") self.assertEqual(conf.transcribe_target().base_url, "http://localhost:8080/v1") @@ -459,3 +490,69 @@ class Directories(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class LocalCleanup(DikteTest): + def test_the_local_model_is_what_the_history_records(self): + conf = self.config(cleanup_provider="local", + local_llm_model="gemma-3-4b-it-Q4_K_M.gguf") + self.assertEqual(cleanup.provider(conf), "local") + self.assertEqual(cleanup.model(conf), "gemma-3-4b-it-Q4_K_M.gguf") + + def test_it_needs_no_program_on_the_path(self): + # whisper.cpp and llama.cpp are fetched rather than installed, so unlike + # Claude Code and Codex there is no executable to look for. + self.assertEqual(cleanup.executable("local"), "") + + def test_the_minutes_do_not_follow_the_cleanup_provider(self): + # A 4B model here will strip the filler words out of a dictation and + # will not write up an hour long meeting. + conf = self.config(cleanup_provider="local") + self.assertEqual(conf["meeting_model"], cfg.DEFAULTS["meeting_model"]) + + def test_only_the_cleanup_setting_asks_for_the_local_model(self): + self.assertFalse(cfg.Config().uses_local_llm()) + self.assertTrue(self.config(cleanup_provider="local").uses_local_llm()) + + +class ReadyToRun(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(ggml, "MODELS_DIR", self.path("models")) + + def install(self, name): + path = ggml.whisper_model_path(name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"model") + + def test_a_missing_program_is_not_ready(self): + with mock.patch("shutil.which", return_value=None): + self.install("ggml-base.bin") + conf = self.config(local_model="ggml-base.bin") + self.assertFalse(conf.transcribe_ready()) + + def test_a_missing_model_is_not_ready_either(self): + with mock.patch("shutil.which", return_value="/usr/bin/whisper-server"): + conf = self.config(local_model="ggml-base.bin") + self.assertFalse(conf.transcribe_ready()) + + def test_both_halves_in_place(self): + with mock.patch("shutil.which", return_value="/usr/bin/whisper-server"): + self.install("ggml-base.bin") + conf = self.config(local_model="ggml-base.bin") + self.assertTrue(conf.transcribe_ready()) + + def test_a_hosted_provider_is_ready_when_it_has_a_key(self): + conf = self.config(transcribe_provider="openai", openai_api_key="sk-test") + self.assertTrue(conf.transcribe_ready()) + + def test_the_settings_reach_the_servers(self): + conf = self.config(local_model="ggml-base.bin", local_threads=4, + local_gpu=False, local_llm_model="gemma.gguf", + local_llm_context=4096) + conf.apply_local() + self.addCleanup(ggml.whisper.configure, model="", threads=0, gpu=True) + self.assertEqual(ggml.whisper.settings()["model"], "ggml-base.bin") + self.assertEqual(ggml.whisper.settings()["threads"], 4) + self.assertFalse(ggml.whisper.settings()["gpu"]) + self.assertEqual(ggml.llm.settings()["context"], 4096) diff --git a/tests/test_filetranscribe.py b/tests/test_filetranscribe.py index def4f5c..b45a385 100644 --- a/tests/test_filetranscribe.py +++ b/tests/test_filetranscribe.py @@ -7,6 +7,7 @@ made up a stamp nobody recorded. """ import contextlib +import time import unittest import wave from unittest import mock @@ -170,7 +171,7 @@ class Transcriber(DikteTest): worker.failed.connect(failures.append) worker.progress.connect(progress.append) - def to_wav(path, workdir): + def to_wav(path, workdir, aborter=None): return make_wav(self.path("converted.wav"), tone(1.0)) with mock.patch.object(ft, "_to_wav", side_effect=to_wav), \ @@ -225,6 +226,40 @@ class Transcriber(DikteTest): _, _, _, cleanup_call = self.run_chain(cleanup=True, transcript="") cleanup_call.assert_not_called() + def test_a_stopped_run_is_not_a_failure(self): + def stopped(*args, **kwargs): + raise api.Aborted + done, failures, progress, _ = self.run_chain(fail=stopped) + self.assertEqual(failures, []) + self.assertEqual(done, []) + self.assertEqual(progress[-1], "Stopped.") + + def test_the_request_is_handed_the_stop_to_watch(self): + worker = ft.FileTranscriber(self.conf) + with mock.patch.object(ft, "_to_wav", side_effect=lambda *a: self.source), \ + mock.patch.object(ft.shutil, "which", return_value="/usr/bin/ffmpeg"), \ + mock.patch.object(api, "transcribe", return_value="text") as call: + worker._work(self.source, False, False) + self.assertIs(call.call_args.kwargs["aborter"], worker._abort) + + def test_stopping_a_local_run_stops_the_model_with_it(self): + """Closing the socket is nothing to a process of ours: it would grind on + to the end of the chunk with nobody left to hand the answer to.""" + worker = ft.FileTranscriber(self.conf) + worker._local = mock.Mock() + worker.stop() + self.assertTrue(worker._abort.aborted) + for _ in range(100): + if worker._local.stop.called: + break + time.sleep(0.01) + worker._local.stop.assert_called_once_with() + + def test_a_run_that_is_over_leaves_the_model_alone(self): + worker = ft.FileTranscriber(self.conf) + worker.stop() + self.assertTrue(worker._abort.aborted) + def test_a_second_start_while_one_is_running_is_ignored(self): worker = ft.FileTranscriber(self.conf) worker._thread = mock.Mock(is_alive=lambda: True) diff --git a/tests/test_ggml.py b/tests/test_ggml.py new file mode 100644 index 0000000..548d4c3 --- /dev/null +++ b/tests/test_ggml.py @@ -0,0 +1,664 @@ +"""Fetching a program and a model, and keeping a server alive on them. + +No network and no whisper.cpp: the downloads are answered from memory, and the +servers are stand-in scripts that take the same arguments and open their port +when they are told to, which is the only thing the code waits on. +""" + +import contextlib +import hashlib +import io +import os +import signal +import sys +import tarfile +import textwrap +import threading +import time +from unittest import mock + +import ggml +import hub +from tests.support import (DikteTest, fake_urlopen, http_error, json_body, + linux_only, url_error) + + +def body(data, length=None): + """What urlopen hands back for a download: a reader with a length header.""" + class Body: + def __init__(self): + self._buf = io.BytesIO(data) + self.headers = {"Content-Length": + str(len(data) if length is None else length)} + + def read(self, count=-1): + return self._buf.read(count) + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + return Body() + + +def item(name, data, url="https://example.invalid/f", sha=True): + return hub.Item(name, url, len(data), + hashlib.sha256(data).hexdigest() if sha else "") + + +@contextlib.contextmanager +def serving(release, archive): + """Answer by what is being asked for rather than by what came before. + + An install asks GitHub what the release is and then asks for one file out of + it, and the first of those two comes from the cache the second time around. + Answering in order would then hand the archive request the release listing. + """ + def opener(request, timeout=None): + url = request.full_url + if "api.github.com" in url: + return json_body(release) + return body(archive) + + with mock.patch("urllib.request.urlopen", side_effect=opener) as calls: + yield calls + + +def tarball(entries): + """A .tar.gz laid out the way the releases are: one directory of files.""" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + for name, content in entries.items(): + info = tarfile.TarInfo(name) + info.size = len(content) + info.mode = 0o755 + tar.addfile(info, io.BytesIO(content)) + return buf.getvalue() + + +class Local(DikteTest): + """A test with its own bin, models and cache directories.""" + + def setUp(self): + super().setUp() + self.patch_attr(ggml, "DATA_DIR", self.path("data")) + self.patch_attr(ggml, "BIN_DIR", self.path("data", "bin")) + self.patch_attr(ggml, "MODELS_DIR", self.path("data", "models")) + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + + +# --- downloading ---------------------------------------------------------- + + +class Download(Local): + def test_it_lands_and_the_part_file_is_gone(self): + data = b"a model, more or less" * 100 + target = self.path("data", "models", "m.bin") + with fake_urlopen(body(data)): + self.assertTrue(ggml.download(item("m.bin", data), target)) + self.assertEqual(target.read_bytes(), data) + self.assertFalse(target.with_name("m.bin.part").exists()) + + def test_a_wrong_checksum_installs_nothing(self): + data = b"the bytes that arrived" + wrong = hub.Item("m.bin", "https://example.invalid/f", len(data), "f" * 64) + target = self.path("data", "models", "m.bin") + with fake_urlopen(body(data)): + with self.assertRaises(ggml.LocalError) as caught: + ggml.download(wrong, target) + self.assertIn("checksum", str(caught.exception)) + self.assertFalse(target.exists()) + self.assertFalse(target.with_name("m.bin.part").exists()) + + def test_a_body_shorter_than_its_header_installs_nothing(self): + data = b"half of it" + target = self.path("data", "models", "m.bin") + with fake_urlopen(body(data, length=len(data) * 2)): + with self.assertRaises(ggml.LocalError): + ggml.download(item("m.bin", data), target) + self.assertFalse(target.exists()) + + def test_a_file_with_no_published_checksum_is_refused(self): + # Everything fetched here is run or parsed by something written in C++, + # and GitHub did not always publish a digest. + data = b"a program, say" + target = self.path("data", "models", "m.bin") + with fake_urlopen(body(data)): + with self.assertRaises(ggml.LocalError) as caught: + ggml.download(item("m.bin", data, sha=False), target) + self.assertIn("checksum", str(caught.exception)) + self.assertFalse(target.exists()) + + def test_nothing_is_asked_for_before_it_is_refused(self): + # The refusal is not worth a gigabyte of somebody's bandwidth first. + with fake_urlopen(body(b"never read")) as calls: + with self.assertRaises(ggml.LocalError): + ggml.download(item("m.bin", b"x", sha=False), self.path("m.bin")) + self.assertEqual(calls, []) + + def test_stopping_leaves_nothing_behind(self): + data = b"x" * (ggml.DOWNLOAD_CHUNK * 3) + target = self.path("data", "models", "m.bin") + with fake_urlopen(body(data)): + landed = ggml.download(item("m.bin", data), target, + should_stop=lambda: True) + self.assertFalse(landed) + self.assertFalse(target.exists()) + self.assertFalse(target.with_name("m.bin.part").exists()) + + def test_progress_is_reported_against_the_total(self): + data = b"y" * (ggml.DOWNLOAD_CHUNK + 5) + seen = [] + with fake_urlopen(body(data)): + ggml.download(item("m.bin", data), self.path("data", "m.bin"), + on_progress=lambda done, total: seen.append((done, total))) + self.assertEqual(seen[-1], (len(data), len(data))) + self.assertGreater(len(seen), 1) + + def test_a_refused_connection_says_which_file(self): + with fake_urlopen(url_error("no route to host")): + with self.assertRaises(ggml.LocalError) as caught: + ggml.download(item("m.bin", b"x"), self.path("data", "m.bin")) + self.assertIn("m.bin", str(caught.exception)) + + def test_an_http_error_is_not_written_to_disk(self): + target = self.path("data", "m.bin") + with fake_urlopen(http_error(404)): + with self.assertRaises(ggml.LocalError): + ggml.download(item("m.bin", b"x"), target) + self.assertFalse(target.exists()) + + +# --- installing a program ------------------------------------------------- + + +class InstallProgram(Local): + def setUp(self): + super().setUp() + # Built once, because the release listing has to publish its checksum + # and a tarball is not the same bytes twice. + self.archive = tarball({ + "whisper-bin-ubuntu-x64/whisper-server": b"#!/bin/sh\nexit 0\n", + "whisper-bin-ubuntu-x64/libwhisper.so": b"not really a library", + }) + + def release(self, *names, archive=None): + digest = hashlib.sha256(self.archive if archive is None else archive) + return {"tag_name": "v1.9.1", "assets": [ + {"name": name, "browser_download_url": f"https://example.invalid/{name}", + "size": 10, "digest": "sha256:" + digest.hexdigest()} + for name in names]} + + def install(self, *names, archive=None): + self.patch_attr(ggml, "_arch", lambda: "x64") + blob = self.archive if archive is None else archive + with serving(self.release(*names, archive=blob), blob) as calls: + path = ggml.install_program(ggml.WHISPER) + return path, [call.args[0].full_url for call in calls.call_args_list] + + def test_the_binary_and_its_libraries_land_together(self): + path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz") + self.assertTrue(os.path.isfile(path)) + self.assertTrue(os.access(path, os.X_OK)) + self.assertTrue(os.path.isfile(os.path.join(os.path.dirname(path), + "libwhisper.so"))) + + def test_the_build_for_this_machine_is_the_one_fetched(self): + _, urls = self.install("whisper-bin-x64.zip", "whisper-bin-ubuntu-arm64.tar.gz", + "whisper-bin-ubuntu-x64.tar.gz") + self.assertTrue(urls[1].endswith("whisper-bin-ubuntu-x64.tar.gz")) + + def test_a_release_with_nothing_for_this_machine_says_so(self): + self.patch_attr(ggml, "_arch", lambda: "x64") + with fake_urlopen(self.release("whisper-bin-Win32.zip")): + with self.assertRaises(ggml.LocalError) as caught: + ggml.install_program(ggml.WHISPER) + self.assertIn("this machine", str(caught.exception)) + + def test_what_was_installed_is_remembered(self): + path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz") + self.assertEqual(ggml.installed_program(ggml.WHISPER), path) + self.assertEqual(ggml.installed_version(ggml.WHISPER), "v1.9.1") + + def test_a_record_pointing_at_a_deleted_binary_counts_for_nothing(self): + path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz") + os.unlink(path) + self.assertEqual(ggml.installed_program(ggml.WHISPER), "") + + def test_the_archive_is_not_kept(self): + self.install("whisper-bin-ubuntu-x64.tar.gz") + left = list((self.path("data", "bin", "whisper")).glob("*.tar.gz")) + self.assertEqual(left, []) + + def test_the_previous_version_is_swept_up(self): + self.install("whisper-bin-ubuntu-x64.tar.gz") + old = self.path("data", "bin", "whisper", "v1.9.0") + old.mkdir(parents=True) + (old / "whisper-server").write_bytes(b"older") + self.install("whisper-bin-ubuntu-x64.tar.gz") + self.assertFalse(old.exists()) + + def test_an_archive_without_the_binary_is_refused(self): + empty = tarball({"whisper-bin-ubuntu-x64/README": b"nothing here"}) + with self.assertRaises(ggml.LocalError) as caught: + self.install("whisper-bin-ubuntu-x64.tar.gz", archive=empty) + self.assertIn("whisper-server", str(caught.exception)) + + + def test_a_release_without_a_published_checksum_is_refused(self): + # GitHub did not always publish one, and whisper.cpp v1.8.0 and older + # still have none. + self.patch_attr(ggml, "_arch", lambda: "x64") + listing = {"tag_name": "v1.8.0", "assets": [ + {"name": "whisper-bin-ubuntu-x64.tar.gz", + "browser_download_url": "https://example.invalid/w.tar.gz", + "size": 10}]} + with serving(listing, self.archive): + with self.assertRaises(ggml.LocalError) as caught: + ggml.install_program(ggml.WHISPER) + self.assertIn("checksum", str(caught.exception)) + self.assertEqual(ggml.installed_program(ggml.WHISPER), "") + + def test_an_archive_that_is_not_what_was_promised_installs_nothing(self): + listing = self.release("whisper-bin-ubuntu-x64.tar.gz") + other = tarball({"whisper-bin-ubuntu-x64/whisper-server": b"#!/bin/sh\nrm -rf\n"}) + self.patch_attr(ggml, "_arch", lambda: "x64") + with serving(listing, other): + with self.assertRaises(ggml.LocalError) as caught: + ggml.install_program(ggml.WHISPER) + self.assertIn("checksum", str(caught.exception)) + self.assertEqual(ggml.installed_program(ggml.WHISPER), "") + + def test_an_archive_cannot_write_outside_the_directory_it_is_opened_in(self): + # An archive is not a trusted thing to unpack: a member named ../../ is + # how one writes over a file it was never given. + escape = tarball({"../../../escaped": b"should not land"}) + path = self.path("data", "bin", "whisper", "v1.9.1") + with self.assertRaises(ggml.LocalError): + self.install("whisper-bin-ubuntu-x64.tar.gz", archive=escape) + self.assertFalse(self.path("escaped").exists()) + self.assertFalse((path.parent.parent / "escaped").exists()) + + def test_a_symlink_out_of_the_directory_does_not_survive_either(self): + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + info = tarfile.TarInfo("whisper-bin-ubuntu-x64/whisper-server") + info.type, info.linkname = tarfile.SYMTYPE, "/etc/passwd" + tar.addfile(info) + with self.assertRaises(ggml.LocalError): + self.install("whisper-bin-ubuntu-x64.tar.gz", archive=buf.getvalue()) + + def test_everything_is_asked_for_over_tls(self): + for url in (hub.GITHUB_API, hub.HF_API, hub.HF_FILES): + with self.subTest(url=url): + self.assertTrue(url.startswith("https://")) + + def test_llama_takes_the_vulkan_build_when_there_is_a_loader(self): + self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: True) + self.assertEqual(ggml._wanted_assets(ggml.LLAMA)[0], + "bin-ubuntu-vulkan-x64.tar.gz") + + def test_llama_falls_back_to_the_plain_build_without_one(self): + self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: False) + self.assertEqual(ggml._wanted_assets(ggml.LLAMA), ("bin-ubuntu-x64.tar.gz",)) + + +class WhichCopyRuns(Local): + def test_a_system_build_wins_over_a_downloaded_one(self): + self.patch_attr(ggml, "installed_program", lambda program: "/data/whisper-server") + with mock.patch("shutil.which", return_value="/usr/bin/whisper-server"): + self.assertEqual(ggml.program_path(ggml.WHISPER), "/usr/bin/whisper-server") + + def test_the_downloaded_one_is_used_when_there_is_no_system_build(self): + self.patch_attr(ggml, "installed_program", lambda program: "/data/whisper-server") + with mock.patch("shutil.which", return_value=None): + self.assertEqual(ggml.program_path(ggml.WHISPER), "/data/whisper-server") + + def test_a_setting_pointing_at_nothing_is_no_program(self): + self.assertEqual(ggml.program_path(ggml.WHISPER, "/nowhere/whisper-server"), "") + + def test_a_setting_pointing_at_a_program_wins(self): + mine = self.path("mine") + mine.write_text("#!/bin/sh\n") + mine.chmod(0o755) + with mock.patch("shutil.which", return_value="/usr/bin/whisper-server"): + self.assertEqual(ggml.program_path(ggml.WHISPER, str(mine)), str(mine)) + + +# --- the lists ------------------------------------------------------------ + + +WHISPER_TREE = [ + {"type": "file", "path": "ggml-base.bin", "size": 147951465, + "lfs": {"oid": "a" * 64}}, + {"type": "file", "path": "ggml-large-v3-turbo-q5_0.bin", "size": 574041195, + "lfs": {"oid": "b" * 64}}, + {"type": "file", "path": "ggml-base-encoder.mlmodelc.zip", "size": 37922638, + "lfs": {"oid": "c" * 64}}, + {"type": "file", "path": "README.md", "size": 3196}, +] + +GGUF_TREE = [ + {"type": "file", "path": "gemma-3-4b-it-Q4_K_M.gguf", "size": 2489000000, + "lfs": {"oid": "a" * 64}}, + {"type": "file", "path": "gemma-3-4b-it-Q8_0.gguf", "size": 4130000000, + "lfs": {"oid": "b" * 64}}, + {"type": "file", "path": "mmproj-model-f16.gguf", "size": 851000000, + "lfs": {"oid": "c" * 64}}, + {"type": "file", "path": "mtp-gemma-4-E4B-it-Q4_0.gguf", "size": 59000000, + "lfs": {"oid": "d" * 64}}, + {"type": "file", "path": "huge-00001-of-00009.gguf", "size": 40000000000, + "lfs": {"oid": "e" * 64}}, + {"type": "file", "path": "README.md", "size": 100}, +] + + +class Catalogue(Local): + def test_only_models_are_offered_and_the_small_ones_first(self): + with fake_urlopen(WHISPER_TREE): + models = ggml.whisper_models() + self.assertEqual([m.name for m in models], + ["ggml-base.bin", "ggml-large-v3-turbo-q5_0.bin"]) + + def test_the_core_ml_encoders_are_not_models(self): + with fake_urlopen(WHISPER_TREE): + names = [m.name for m in ggml.whisper_models()] + self.assertNotIn("ggml-base-encoder.mlmodelc.zip", names) + + def test_the_projector_and_the_draft_head_are_not_models(self): + with fake_urlopen(GGUF_TREE): + names = [q.name for q in ggml.llm_quants("ggml-org/gemma-3-4b-it-GGUF")] + self.assertEqual(names, + ["gemma-3-4b-it-Q4_K_M.gguf", "gemma-3-4b-it-Q8_0.gguf"]) + + def test_a_model_split_across_files_is_left_out(self): + with fake_urlopen(GGUF_TREE): + names = [q.name for q in ggml.llm_quants("ggml-org/gemma-3-4b-it-GGUF")] + self.assertNotIn("huge-00001-of-00009.gguf", names) + + def test_the_suggestions_come_first_and_the_rest_follow(self): + listing = [{"id": "ggml-org/something-new-GGUF"}, + {"id": ggml.SUGGESTED_LLM[0]}] + with fake_urlopen(listing): + found = ggml.llm_repos() + self.assertEqual(found[0], ggml.SUGGESTED_LLM[0]) + self.assertIn("ggml-org/something-new-GGUF", found) + + def test_an_unreachable_list_still_offers_the_suggestions(self): + with fake_urlopen(url_error()): + self.assertEqual(ggml.llm_repos(), list(ggml.SUGGESTED_LLM)) + + def test_an_unreachable_whisper_list_is_an_error_worth_showing(self): + with fake_urlopen(url_error()): + with self.assertRaises(ggml.LocalError): + ggml.whisper_models() + + def test_what_is_on_disk_is_read_from_disk(self): + self.assertEqual(ggml.installed_whisper_models(), []) + path = ggml.whisper_model_path("ggml-base.bin") + path.parent.mkdir(parents=True) + path.write_bytes(b"model") + self.assertEqual(ggml.installed_whisper_models(), ["ggml-base.bin"]) + self.assertTrue(ggml.have_model(path)) + + def test_an_empty_file_is_not_a_model(self): + path = ggml.llm_model_path("ggml-org/x-GGUF/model.gguf") + path.parent.mkdir(parents=True) + path.write_bytes(b"") + self.assertFalse(ggml.have_model(path)) + + def test_a_model_is_named_by_its_file_not_its_repository(self): + self.assertEqual(ggml.llm_model_path("ggml-org/x-GGUF/model.gguf").name, + "model.gguf") + + +# --- keeping a server alive ----------------------------------------------- + + +STAND_IN = textwrap.dedent(""" + import http.server, sys, threading, time + + args = sys.argv[1:] + + def opt(name, default=""): + return args[args.index(name) + 1] if name in args else default + + if "--die" in args: + print("could not load model: no such file") + sys.exit(2) + + time.sleep(float(opt("--wait", "0"))) + + started = time.monotonic() + healthy_after = float(opt("--healthy-after", "0")) + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + ok = time.monotonic() - started >= healthy_after + self.send_response(200 if ok else 503) + self.end_headers() + self.wfile.write(b"{}") + + def log_message(self, *a): + pass + + server = http.server.HTTPServer((opt("--host"), int(opt("--port"))), Handler) + print("listening on " + opt("--port"), flush=True) + server.serve_forever() +""") + + +class Servers(Local): + def setUp(self): + super().setUp() + self.path("data").mkdir(parents=True, exist_ok=True) + # Named for the program and kept inside the data directory, because that + # is what the sweep looks for on a command line. + self.script = self.path("data", "whisper-server.py") + self.script.write_text(STAND_IN) + self.addCleanup(ggml.stop_all) + self.servers = [] + + def server(self, program=ggml.WHISPER, **settings): + defaults = {"extra": []} + defaults.update(settings) + made = ggml.Server( + program, + lambda values: [sys.executable, str(self.script)] + list(values["extra"]), + defaults, + ) + self.servers.append(made) + self.addCleanup(made.stop) + return made + + def test_a_started_server_hands_back_its_address(self): + server = self.server() + url = server.serve() + self.assertRegex(url, r"^http://127\.0\.0\.1:\d+/v1$") + self.assertTrue(server.running) + + def test_the_second_call_does_not_start_a_second_one(self): + server = self.server() + first = server.serve() + self.assertEqual(server.serve(), first) + + def test_a_settings_change_stops_what_was_running(self): + server = self.server() + server.serve() + server.configure(extra=["--wait", "0"]) + self.assertFalse(server.running) + + def test_the_new_settings_are_what_the_next_start_uses(self): + server = self.server() + server.serve() + server.configure(extra=["--healthy-after", "0"]) + second = server.serve() + self.assertTrue(server.running) + self.assertTrue(second) + + def test_a_program_that_dies_reports_what_it_printed(self): + server = self.server(extra=["--die"]) + with self.assertRaises(ggml.LocalError) as caught: + server.serve() + self.assertIn("no such file", str(caught.exception)) + self.assertFalse(server.running) + + def test_a_model_that_is_still_loading_is_not_ready_yet(self): + # llama binds its port first and answers /health with 503 until the + # model is in memory, so the open port on its own is not the signal. + server = self.server(program=ggml.LLAMA, extra=["--healthy-after", "0.4"]) + started = time.monotonic() + server.serve() + self.assertGreaterEqual(time.monotonic() - started, 0.4) + + def test_a_start_that_never_becomes_ready_gives_up(self): + self.patch_attr(ggml, "STARTUP_TIMEOUT", 0.5) + server = self.server(program=ggml.LLAMA, extra=["--healthy-after", "30"]) + with self.assertRaises(ggml.LocalError): + server.serve() + + def test_stopping_leaves_nothing_running(self): + server = self.server() + server.serve() + server.stop() + self.assertFalse(server.running) + self.assertEqual(server.base_url(), "") + + def test_the_last_thing_it_printed_is_available(self): + server = self.server() + server.serve() + self.assertIn("listening", server.error()) + + def test_asking_what_is_running_does_not_wait_for_a_start(self): + """A model being loaded must not freeze the settings window. + + The interface asks a running server what it is doing while a start is in + flight, and a lock held across the whole start would stop it dead. + """ + server = self.server(extra=["--wait", "0.6"]) + answers = [] + + def start(): + server.serve() + + thread = __import__("threading").Thread(target=start) + thread.start() + try: + time.sleep(0.15) + began = time.monotonic() + answers.append(server.settings()) + answers.append(server.running) + self.assertLess(time.monotonic() - began, 0.2) + finally: + thread.join(timeout=10) + + @linux_only + def test_a_server_a_killed_dikte_left_behind_is_swept_up(self): + server = self.server() + server.serve() + # What a SIGKILL of Dikte leaves: the child still running, the pid file + # still on disk, and nothing left that knows about either. + proc, server._proc = server._proc, None + self.assertTrue(server.sweep()) + self.assertEqual(proc.wait(timeout=5), -signal.SIGTERM) + + @linux_only + def test_a_pid_that_belongs_to_something_else_is_left_alone(self): + server = self.server() + server._remember(os.getpid()) # this test runner, not a server + self.assertFalse(server.sweep()) + + def test_no_pid_file_is_nothing_to_sweep(self): + self.assertFalse(self.server().sweep()) + + def test_a_start_that_goes_wrong_takes_its_process_with_it(self): + started = [] + + def explode(inner, proc, port): + started.append(proc) + raise RuntimeError("something in the wait went wrong") + + self.patch_attr(ggml.Server, "_wait_ready", explode) + server = self.server() + with self.assertRaises(RuntimeError): + server.serve() + # Nothing else holds a reference to it, so leaving it running would leak + # a loaded model with nobody left to ask it anything. + self.assertIsNotNone(started[0].poll()) + self.assertFalse(server.sweep()) # and the pid file went with it + + +class Arguments(Local): + """What the two command lines say, since neither program is here to say it.""" + + def setUp(self): + super().setUp() + self.binary = self.path("whisper-server") + self.binary.write_text("#!/bin/sh\n") + self.binary.chmod(0o755) + + def whisper_model(self, name="ggml-base.bin"): + path = ggml.whisper_model_path(name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"model") + return name + + def test_the_inference_path_is_the_one_api_py_builds(self): + args = ggml._whisper_args({"binary": str(self.binary), "gpu": True, + "threads": 0, "model": self.whisper_model()}) + self.assertIn("--inference-path", args) + self.assertEqual(args[args.index("--inference-path") + 1], + "/v1/audio/transcriptions") + + def test_detection_rather_than_english_when_nothing_is_asked_for(self): + args = ggml._whisper_args({"binary": str(self.binary), "gpu": True, + "threads": 0, "model": self.whisper_model()}) + self.assertEqual(args[args.index("-l") + 1], "auto") + + def test_the_graphics_card_is_turned_off_rather_than_asked_for(self): + settings = {"binary": str(self.binary), "gpu": False, "threads": 2, + "model": self.whisper_model()} + args = ggml._whisper_args(settings) + self.assertIn("-ng", args) + self.assertEqual(args[args.index("-t") + 1], "2") + + def test_a_missing_model_is_a_message_about_settings(self): + with self.assertRaises(ggml.LocalError) as caught: + ggml._whisper_args({"binary": str(self.binary), "gpu": True, + "threads": 0, "model": "ggml-nothing.bin"}) + self.assertIn("Settings", str(caught.exception)) + + def test_a_missing_program_says_so_before_a_missing_model(self): + with mock.patch("shutil.which", return_value=None): + with self.assertRaises(ggml.LocalError) as caught: + ggml._whisper_args({"binary": "", "gpu": True, "threads": 0, + "model": self.whisper_model()}) + self.assertIn("whisper.cpp", str(caught.exception)) + + def test_the_layers_go_to_the_card_when_there_is_one(self): + model = ggml.llm_model_path("m.gguf") + model.parent.mkdir(parents=True, exist_ok=True) + model.write_bytes(b"gguf") + args = ggml._llm_args({"binary": str(self.binary), "gpu": True, + "threads": 0, "model": "m.gguf", "context": 4096}) + self.assertEqual(args[args.index("-ngl") + 1], "99") + self.assertEqual(args[args.index("-c") + 1], "4096") + + def test_no_card_means_no_layers_offloaded(self): + model = ggml.llm_model_path("m.gguf") + model.parent.mkdir(parents=True, exist_ok=True) + model.write_bytes(b"gguf") + args = ggml._llm_args({"binary": str(self.binary), "gpu": False, + "threads": 0, "model": "m.gguf", "context": 4096}) + self.assertEqual(args[args.index("-ngl") + 1], "0") + + +class Sizes(DikteTest): + def test_bytes_are_written_the_way_a_download_is_talked_about(self): + self.assertEqual(ggml.human_size(512), "512 B") + self.assertEqual(ggml.human_size(574041195), "547.4 MB") + self.assertEqual(ggml.human_size(3_095_033_483), "2.9 GB") + diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index 455c1ee..c6ec50f 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -6,6 +6,7 @@ import subprocess import unittest from unittest import mock +import config as cfg import hotkey from tests.support import DikteTest, FakeCompleted, linux_only @@ -56,6 +57,28 @@ class ParseShortcut(unittest.TestCase): self.assertEqual(hotkey.parse_shortcut(None), (None, None)) +class Table(unittest.TestCase): + """The one list of global shortcuts. The command line, the settings window + and install.sh read it instead of keeping a copy each, so what it has to + hold together is checked here rather than in three places.""" + + def test_every_shortcut_remembers_itself_in_a_real_setting(self): + for name, spec in hotkey.SHORTCUTS.items(): + with self.subTest(name=name): + self.assertIn(spec.setting, cfg.DEFAULTS) + + def test_no_two_share_a_desktop_entry(self): + ids = [spec.desktop_id for spec in hotkey.SHORTCUTS.values()] + self.assertEqual(len(ids), len(set(ids))) + + def test_only_the_toggle_falls_back_to_a_key_of_its_own(self): + """The rest are off until you pick one, and emptying the box is how you + turn them off again.""" + self.assertEqual(hotkey.SHORTCUTS["toggle"].fallback, "Ctrl+Space") + self.assertEqual([name for name, spec in hotkey.SHORTCUTS.items() + if spec.fallback], ["toggle"]) + + class ModsMatch(unittest.TestCase): """The combination has to be exact, or Ctrl+Space fires on Ctrl+Shift+Space.""" @@ -122,6 +145,23 @@ class Bindings(DikteTest): thread.assert_called_once() self.assertEqual(len(listener._bindings[57]), 2) + def test_starting_and_discarding_do_not_fire_on_each_other(self): + """The two defaults are one modifier apart on the same key code, so the + modifier set is the only thing keeping them apart.""" + listener = hotkey.EvdevHotkey() + self.addCleanup(listener.stop) + with mock.patch.object(listener, "_open_devices", return_value=[99]), \ + mock.patch.object(hotkey.threading, "Thread"): + listener.start({"toggle": "Ctrl+Space", "cancel": "Ctrl+Alt+Space"}) + + def fired(held): + return [name for mods, name in listener._bindings[57] + if hotkey.EvdevHotkey._mods_match(held, mods)] + + self.assertEqual(fired({29}), ["toggle"]) # ctrl + self.assertEqual(fired({29, 56}), ["cancel"]) # ctrl + alt + self.assertEqual(fired({29, 42}), []) # ctrl + shift + class Chooser(DikteTest): """Which desktop is asked to register the shortcut.""" diff --git a/tests/test_hub.py b/tests/test_hub.py new file mode 100644 index 0000000..f3741c2 --- /dev/null +++ b/tests/test_hub.py @@ -0,0 +1,184 @@ +"""What GitHub and Hugging Face are asked, and what is believed of the answer.""" + +import json + +import hub +from tests.support import DikteTest, fake_urlopen, http_error, url_error + +RELEASE = { + "tag_name": "v1.9.1", + "assets": [ + {"name": "whisper-bin-ubuntu-x64.tar.gz", + "browser_download_url": "https://example.invalid/ubuntu-x64.tar.gz", + "size": 9379235, "digest": "sha256:" + "a" * 64}, + {"name": "whisper-bin-x64.zip", + "browser_download_url": "https://example.invalid/win.zip", + "size": 100, "digest": "sha256:" + "b" * 64}, + {"name": "no-url-here.zip", "size": 1}, + ], +} + +TREE = [ + {"type": "file", "path": ".gitattributes", "size": 1477}, + {"type": "file", "path": "ggml-base.bin", "size": 147951465, + "lfs": {"oid": "c" * 64, "size": 147951465}}, + {"type": "directory", "path": "extra"}, + {"type": "file", "path": "extra/ggml-tiny.bin", "size": 77691713, + "lfs": {"oid": "d" * 64, "size": 77691713}}, +] + +MODELS = [ + {"id": "ggml-org/gemma-3-4b-it-GGUF", "downloads": 44606, + "lastModified": "2026-07-01T00:00:00.000Z"}, + {"id": "ggml-org/gpt-oss-20b-GGUF", "downloads": 47975}, + {"noid": True}, +] + + +class Releases(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + + def test_the_tag_and_the_assets_come_back(self): + with fake_urlopen(RELEASE) as calls: + tag, assets = hub.release("ggml-org/whisper.cpp") + self.assertEqual(tag, "v1.9.1") + self.assertEqual([a.name for a in assets], + ["whisper-bin-ubuntu-x64.tar.gz", "whisper-bin-x64.zip"]) + self.assertEqual(calls[0].full_url, + "https://api.github.com/repos/ggml-org/whisper.cpp/" + "releases/latest") + + def test_the_sha256_prefix_is_dropped(self): + with fake_urlopen(RELEASE): + _, assets = hub.release("ggml-org/whisper.cpp") + self.assertEqual(assets[0].sha256, "a" * 64) + + def test_a_tag_asks_for_that_tag(self): + with fake_urlopen(RELEASE) as calls: + hub.release("ggml-org/whisper.cpp", "v1.9.1") + self.assertTrue(calls[0].full_url.endswith("/releases/tags/v1.9.1")) + + def test_a_release_with_no_assets_is_an_error(self): + with fake_urlopen({"tag_name": "v1", "assets": []}): + with self.assertRaises(hub.HubError): + hub.release("ggml-org/whisper.cpp") + + def test_the_second_call_asks_nobody(self): + with fake_urlopen(RELEASE) as calls: + hub.release("ggml-org/whisper.cpp") + hub.release("ggml-org/whisper.cpp") + self.assertEqual(len(calls), 1) + + def test_a_refresh_asks_again(self): + with fake_urlopen(RELEASE) as calls: + hub.release("ggml-org/whisper.cpp") + hub.release("ggml-org/whisper.cpp", refresh=True) + self.assertEqual(len(calls), 2) + + def test_an_old_cache_beats_no_answer(self): + with fake_urlopen(RELEASE): + hub.release("ggml-org/whisper.cpp") + # Old enough that it would normally be fetched again, and no network + # to fetch it with. + for path in self.path("cache").iterdir(): + os_utime(path) + with fake_urlopen(url_error()): + tag, assets = hub.release("ggml-org/whisper.cpp") + self.assertEqual(tag, "v1.9.1") + self.assertEqual(len(assets), 2) + + def test_no_cache_and_no_network_says_so(self): + with fake_urlopen(url_error("no route to host")): + with self.assertRaises(hub.HubError) as caught: + hub.release("ggml-org/whisper.cpp") + self.assertIn("api.github.com", str(caught.exception)) + + def test_an_http_error_names_the_host_and_the_code(self): + with fake_urlopen(http_error(404, "nope")): + with self.assertRaises(hub.HubError) as caught: + hub.release("ggml-org/nothing") + self.assertIn("404", str(caught.exception)) + + +class Files(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + + def test_directories_are_left_out(self): + with fake_urlopen(TREE): + files = hub.files("ggerganov/whisper.cpp") + self.assertEqual([f.name for f in files], + [".gitattributes", "ggml-base.bin", "extra/ggml-tiny.bin"]) + + def test_the_url_is_the_one_that_serves_the_bytes(self): + with fake_urlopen(TREE): + files = hub.files("ggerganov/whisper.cpp") + self.assertEqual( + files[1].url, + "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin") + + def test_the_lfs_object_id_is_the_checksum(self): + with fake_urlopen(TREE): + files = hub.files("ggerganov/whisper.cpp") + self.assertEqual(files[1].sha256, "c" * 64) + self.assertEqual(files[1].size, 147951465) + + def test_a_file_outside_lfs_has_no_checksum(self): + with fake_urlopen(TREE): + files = hub.files("ggerganov/whisper.cpp") + self.assertEqual(files[0].sha256, "") + + def test_an_answer_that_is_not_a_list_is_an_error(self): + with fake_urlopen({"error": "Invalid username or password."}): + with self.assertRaises(hub.HubError): + hub.files("ggml-org/whisper.cpp") + + +class Repos(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + + def test_it_asks_for_one_author_and_for_gguf(self): + with fake_urlopen(MODELS) as calls: + found = hub.repos(author="ggml-org") + self.assertIn("author=ggml-org", calls[0].full_url) + self.assertIn("filter=gguf", calls[0].full_url) + self.assertEqual([r.id for r in found], + ["ggml-org/gemma-3-4b-it-GGUF", "ggml-org/gpt-oss-20b-GGUF"]) + + def test_a_missing_download_count_is_zero(self): + with fake_urlopen(MODELS): + found = hub.repos(author="ggml-org") + self.assertEqual(found[0].downloads, 44606) + self.assertEqual(found[1].updated, "") + + +def os_utime(path): + """Backdate a cache file past its time to live.""" + import os + import time + old = time.time() - hub.CACHE_TTL - 60 + os.utime(path, (old, old)) + + +class CacheOnDisk(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + + def test_what_is_stored_is_what_came_back(self): + with fake_urlopen(RELEASE): + hub.release("ggml-org/whisper.cpp") + stored = [json.loads(p.read_text()) for p in self.path("cache").iterdir()] + self.assertEqual(stored[0]["tag_name"], "v1.9.1") + + def test_a_cache_that_cannot_be_written_is_not_a_failure(self): + self.patch_attr(hub, "CACHE_DIR", self.path("nope", "deeper")) + self.path("nope").write_text("a file where a directory would go") + with fake_urlopen(RELEASE): + tag, _ = hub.release("ggml-org/whisper.cpp") + self.assertEqual(tag, "v1.9.1") diff --git a/tests/test_ui.py b/tests/test_ui.py index 1bdc4c3..e4cf51d 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -13,7 +13,9 @@ from unittest import mock from PyQt6.QtWidgets import QApplication, QMessageBox +import cleanup import config as cfg +import hotkey import overlay as overlay_module import paste import settings_ui @@ -39,13 +41,27 @@ CHANGED = { "filter_hallucinations": False, "keep_audio": True, "openai_api_key": "sk-test-key", + "groq_api_key": "gsk-test-key", "openrouter_api_key": "sk-or-test-key", "transcribe_provider": "openrouter", "transcribe_model": "whisper-1", + "groq_transcribe_model": "whisper-large-v3", "openrouter_transcribe_model": "openai/whisper-1", "cleanup_enabled": False, + "cleanup_provider": "local", "cleanup_model": "some/other-model", + "cleanup_claude_model": "opus", + "cleanup_codex_model": "gpt-5", "cleanup_reasoning": "high", + "local_model": "ggml-small.bin", + "local_gpu": False, + "local_preload": False, + "local_threads": 6, + "local_llm_model": "gemma-3-4b-it-Q4_K_M.gguf", + "local_llm_repo": "ggml-org/gemma-4-E2B-it-GGUF", + "local_llm_gpu": False, + "local_llm_preload": True, + "local_llm_reasoning": "low", "cleanup_prompt": "Only fix the punctuation.", "file_cleanup_prompt": "Keep the stamps where they are.", "transcribe_prompt": "Paraşüt, OpenFrame", @@ -77,6 +93,7 @@ CHANGED = { "file_timestamps": True, "file_cleanup": False, "shortcut": "Ctrl+Alt+Space", + "cancel_shortcut": "Meta+Shift+Space", "evdev_hotkey": True, "history_limit": 50, } @@ -105,7 +122,7 @@ class Settings(DikteTest): self.path("kglobalshortcutsrc"))) def window(self, conf): - window = settings_ui.SettingsWindow(conf, "dikte toggle") + window = settings_ui.SettingsWindow(conf) self.addCleanup(window.deleteLater) self.addCleanup(window.close) return window @@ -133,6 +150,20 @@ class Settings(DikteTest): with self.subTest(key=key): self.assertEqual(stored[key], value) + def test_the_model_box_on_screen_belongs_to_whoever_cleans_up(self): + """An OpenRouter id and a Claude alias are not the same field.""" + window = self.window(cfg.Config()) + boxes = {"openrouter": window.cleanup_model_row, + "claude": window.cleanup_claude_model, + "codex": window.cleanup_codex_model} + for provider, box in boxes.items(): + with self.subTest(provider=provider): + window._select_data(window.cleanup_provider, provider) + shown = [name for name, other in boxes.items() + if not other.isHidden()] + self.assertEqual(shown, [provider]) + self.assertFalse(box.isHidden()) + def test_the_settings_the_window_does_not_show_are_left_alone(self): """A tab nobody wrote must not reset what the command line set.""" self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0, @@ -143,6 +174,37 @@ class Settings(DikteTest): self.assertEqual(stored["speech_margin_db"], 15.0) self.assertEqual(stored["openrouter_base_url"], "http://localhost:1234/v1") + def test_every_global_shortcut_has_a_row_of_its_own(self): + window = self.window(cfg.Config()) + self.assertEqual(set(window._shortcut_rows), set(hotkey.SHORTCUTS)) + + def test_emptying_a_shortcut_turns_it_off_but_not_the_toggle(self): + """The application is unusable without the toggle, so that one box + falls back. The rest stay empty, which is how they are switched off.""" + conf = cfg.Config() + window = self.window(conf) + for box, _status, _missing in window._shortcut_rows.values(): + box.setCurrentText("") + window._save() + self.assertEqual(conf["shortcut"], "Ctrl+Space") + self.assertEqual(conf["cancel_shortcut"], "") + self.assertEqual(conf["assistant_shortcut"], "") + self.assertEqual(conf["meeting_shortcut"], "") + + def test_installing_the_discard_key_writes_its_own_entry(self): + conf = cfg.Config() + window = self.window(conf) + window._shortcut_rows["cancel"][0].setCurrentText("Meta+Shift+Space") + with mock.patch.object(settings_ui.hotkey, "install_shortcut", + return_value=(True, "saved")) as install: + window._install_shortcut("cancel") + combo, command = install.call_args.args + self.assertEqual(combo, "Meta+Shift+Space") + self.assertTrue(command.endswith(" cancel")) + self.assertEqual(install.call_args.kwargs["desktop_id"], + hotkey.CANCEL_DESKTOP_ID) + self.assertEqual(conf["cancel_shortcut"], "Meta+Shift+Space") + def test_a_prompt_left_at_its_default_is_stored_as_empty(self): """So that switching the interface language switches the prompt too.""" conf = cfg.Config() @@ -154,14 +216,44 @@ class Settings(DikteTest): def test_each_provider_keeps_its_own_transcription_model(self): self.write_config({"transcribe_provider": "openai", "transcribe_model": "gpt-4o-transcribe", + "groq_transcribe_model": "whisper-large-v3", "openrouter_transcribe_model": "openai/whisper-1"}) conf = cfg.Config() window = self.window(conf) - window.transcribe_provider.setCurrentIndex( - window.transcribe_provider.findData("openrouter")) + for provider in ("groq", "openrouter"): + window.transcribe_provider.setCurrentIndex( + window.transcribe_provider.findData(provider)) window._save() self.assertEqual(conf["transcribe_provider"], "openrouter") self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe") + self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3") + + def test_the_provider_box_offers_every_provider_config_knows(self): + window = self.window(cfg.Config()) + offered = [window.transcribe_provider.itemData(i) + for i in range(window.transcribe_provider.count())] + self.assertEqual(offered, ["local"] + list(cfg.TRANSCRIBERS)) + + def test_the_cleanup_box_offers_everyone_cleanup_py_dispatches_to(self): + window = self.window(cfg.Config()) + offered = [window.cleanup_provider.itemData(i) + for i in range(window.cleanup_provider.count())] + self.assertEqual(sorted(offered), sorted(cleanup.PROVIDERS)) + + def test_the_answer_to_a_test_lands_under_the_key_it_was_asked_about(self): + """One signal serves all three buttons, so it carries which one asked.""" + window = self.window(cfg.Config()) + window._on_test_done("groq", True, "it works") + button, answer = window._testers["groq"] + self.assertEqual(answer.text(), "✓ it works") + self.assertTrue(button.isEnabled()) + self.assertEqual(window._testers["openai"][1].text(), "") + + def test_a_key_lands_in_the_field_of_its_own_provider(self): + self.write_config({"groq_api_key": "gsk-mine"}) + window = self.window(cfg.Config()) + self.assertEqual(window.groq_key.text(), "gsk-mine") + self.assertEqual(window.openai_key.text(), "") def test_saving_applies_the_lowered_history_limit_at_once(self): for index in range(10): @@ -183,6 +275,43 @@ class Settings(DikteTest): window = self.window(cfg.Config()) self.assertEqual(window.windowTitle(), "Dikte Ayarları") + def test_the_audio_file_switches_are_kept_without_the_save_button(self): + """They are ticked to transcribe one file, not to fill in a form.""" + self.write_config({"file_timestamps": False, "file_cleanup": True}) + window = self.window(cfg.Config()) + window.file_timestamps.setChecked(True) + window.file_cleanup.setChecked(False) + stored = self.read_config_file() + self.assertTrue(stored["file_timestamps"]) + self.assertFalse(stored["file_cleanup"]) + + def test_loading_the_audio_file_tab_is_not_taken_for_a_change(self): + self.write_config({"file_timestamps": True, "file_cleanup": False}) + conf = cfg.Config() + with mock.patch.object(conf, "save") as save: + window = self.window(conf) + save.assert_not_called() + self.assertTrue(window.file_timestamps.isChecked()) + self.assertFalse(window.file_cleanup.isChecked()) + + def test_the_run_button_comes_back_when_the_stop_lands(self): + """In whichever language, since the worker says so through t() too.""" + for language in ("auto", "tr"): + with self.subTest(language=language): + self.write_config({"ui_language": language}) + window = self.window(cfg.Config()) + window.file_run.setEnabled(False) + window._on_file_progress(settings_ui.t("Stopped.")) + self.assertTrue(window.file_run.isEnabled()) + + def test_stop_leaves_nothing_to_press_twice(self): + window = self.window(cfg.Config()) + with mock.patch.object(window.transcriber, "stop") as stop: + window.file_stop.setEnabled(True) + window._stop_file() + stop.assert_called_once_with() + self.assertFalse(window.file_stop.isEnabled()) + class MacSettings(Settings): """The same window and the same round trip, standing on a Mac. @@ -296,3 +425,78 @@ class Overlay(DikteTest): if __name__ == "__main__": unittest.main() + + +class LocalModels(DikteTest): + """The download boxes, without a network and without either program.""" + + def window(self, conf): + window = settings_ui.SettingsWindow(conf) + self.addCleanup(window.deleteLater) + self.addCleanup(window.close) + return window + + def test_it_opens_where_the_missing_model_is_fixed(self): + # Nothing can transcribe on a fresh install, which is why this window + # was opened at all. + window = self.window(cfg.Config()) + self.assertEqual(window.tabs.currentIndex(), window.api_tab_index) + + def test_it_opens_where_it_was_left_when_everything_works(self): + conf = self.config(transcribe_provider="openai", openai_api_key="sk-test") + self.assertEqual(self.window(conf).tabs.currentIndex(), 0) + + def test_a_model_that_is_not_here_yet_survives_a_save(self): + # The box is filled from what is on this disk, so a model that was + # deleted from underneath is not in the list. Dropping it on save would + # quietly empty the setting instead of asking for the download again. + conf = self.config(local_model="ggml-large-v3-turbo-q5_0.bin") + with mock.patch.object(QMessageBox, "information"): + self.window(conf)._save() + self.assertEqual(conf["local_model"], "ggml-large-v3-turbo-q5_0.bin") + + def test_nothing_is_fetched_for_a_window_nobody_opened(self): + # DikteTest closes the network, so a request would fail the test. The + # lists are asked for when the box is shown, not when it is built. + window = self.window(cfg.Config()) + self.assertTrue(window.local_whisper._pending) + + def test_a_model_bigger_than_two_gigabytes_counts_up_rather_than_down(self): + # Qt's int is C++'s 32-bit one, and a 2.3 GB model is more than fits in + # it: the count came out the far side negative, at "-1%". + box = self.window(cfg.Config()).local_llm + box._downloading = True + box._report(1_048_576, 2_489_757_856) + _app.processEvents() + self.assertIn("2.3 GB", box.status.text()) + self.assertNotIn("-", box.status.text()) + + def test_a_long_model_name_is_not_cut_in_half(self): + # The list under a combo box takes the box's width and elides what does + # not fit, in the middle: "ggml-org/Qwen....7B-Base-GGUF". + box = self.window(cfg.Config()).local_llm + box.repo.addItem("ggml-org/a-model-with-a-name-that-runs-on-and-on-GGUF") + box._fit_popup(box.repo) + view = box.repo.view() + self.assertEqual(view.textElideMode(), settings_ui.Qt.TextElideMode.ElideNone) + widest = max(box.repo.fontMetrics().horizontalAdvance(box.repo.itemText(row)) + for row in range(box.repo.count())) + self.assertGreaterEqual(view.minimumWidth(), widest) + + def test_only_the_chosen_transcriber_is_on_screen(self): + window = self.window(self.config(transcribe_provider="openai")) + self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row)) + self.assertFalse(window.stt_form.isRowVisible(window.local_whisper)) + window._select_data(window.transcribe_provider, "local") + self.assertFalse(window.stt_form.isRowVisible(window.transcribe_model_row)) + self.assertTrue(window.stt_form.isRowVisible(window.local_whisper)) + + def test_only_the_chosen_cleaner_is_on_screen(self): + window = self.window(cfg.Config()) + self.assertTrue(window.cleanup_form.isRowVisible(window.cleanup_model_row)) + self.assertFalse(window.cleanup_form.isRowVisible(window.local_llm)) + window._select_data(window.cleanup_provider, "local") + self.assertTrue(window.cleanup_form.isRowVisible(window.local_llm)) + self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_model_row)) + # Its own thinking box, because the two default to opposite things. + self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_reasoning)) diff --git a/tests/test_worker.py b/tests/test_worker.py index d128378..1617efb 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -234,7 +234,7 @@ class Chain(DikteTest): self.assertEqual(row["raw"], "uh, book it for Thursday") self.assertEqual(row["text"], "Book it for Thursday.") self.assertEqual(row["duration"], 2.0) - self.assertEqual(row["model"], self.conf["transcribe_model"]) + self.assertEqual(row["model"], self.conf.transcribe_target().model) self.assertEqual(row["mode"], "") def test_a_command_is_recorded_as_one(self): diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..dfbdaa1 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# Dikte uninstaller: takes back what install.sh put down, and nothing else +# unless asked. Your settings and your dictations survive a plain run; --purge +# is the word that deletes them. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PY="$(command -v python3 || true)" +USER_NAME="$(id -un)" +BIN_DIR="$HOME/.local/bin" +APP_DIR="$HOME/.local/share/applications" +AUTOSTART_DIR="$HOME/.config/autostart" +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte" +DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte" + +PURGE=0 +ASSUME_YES=0 + +say() { printf ' %s\n' "$1"; } +ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } +warn() { printf ' \033[33m!\033[0m %s\n' "$1"; } +gone() { printf ' \033[90m·\033[0m %s\n' "$1"; } +# "1 dictation", "3 dictations": how many is the point of printing it at all. +count() { + if (( $1 == 1 )); then printf '%s %s' "$1" "$2"; else printf '%s %s' "$1" "$3"; fi +} + +usage() { + cat <&2; usage >&2; exit 2 ;; + esac +done + +# A symlink whose target is gone is still a file to remove, hence -L. +remove() { + if [[ -e "$1" || -L "$1" ]]; then + rm -f "$1" + ok "Removed $1" + else + gone "Was not there: $1" + fi +} + +echo +echo "Uninstalling Dikte" +echo "──────────────────" + +# 1. Global shortcuts ------------------------------------------------------ +# Handed to Dikte while it can still run, because it is the half that knows +# whether they went into KDE's kglobalshortcutsrc or GNOME's gsettings. +if [[ -n "$PY" ]] && python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then + for which in toggle cancel ask meeting; do + "$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true + done + ok "Global shortcuts unregistered" + say "KWin reads that file at startup, so the keys are free after your next login." +else + warn "PyQt6 is missing, so the shortcuts were left registered." + say "Remove them in your desktop's shortcut settings." +fi + +# 2. The running instance -------------------------------------------------- +# It holds a tray icon and a socket; asking it to quit is tidier than pulling +# its launchers out from under it. +if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then + [[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" quit >/dev/null 2>&1 || true + sleep 0.5 + if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then + warn "Dikte is still running; close it from the tray icon" + else + ok "Stopped the running instance" + fi +fi + +# 3. Launchers ------------------------------------------------------------- +# Only our own symlink goes: a file of the same name that somebody else put +# there is not ours to delete. +if [[ -L "$BIN_DIR/dikte" ]]; then + remove "$BIN_DIR/dikte" +elif [[ -e "$BIN_DIR/dikte" ]]; then + warn "$BIN_DIR/dikte is not our symlink, leaving it alone" +else + gone "Was not there: $BIN_DIR/dikte" +fi +remove "$APP_DIR/dikte.desktop" +remove "$AUTOSTART_DIR/dikte.desktop" +# Removing the shortcut takes its desktop file with it, but an install from +# before this script existed may have left one behind on a desktop that never +# used them. +for id in dikte-toggle dikte-cancel dikte-ask dikte-meeting; do + if [[ -e "$APP_DIR/$id.desktop" ]]; then + remove "$APP_DIR/$id.desktop" + fi +done + +# 4. Settings and dictations ----------------------------------------------- +echo +if ((PURGE)); then + warn "--purge also deletes:" + if [[ -f "$CONFIG_DIR/config.json" ]]; then + say "$CONFIG_DIR/config.json (your API keys and every setting)" + fi + if [[ -f "$DATA_DIR/history.jsonl" ]]; then + # grep -c rather than wc -l: a last line with no newline is still a dictation. + say "$DATA_DIR/history.jsonl ($(count "$(grep -c '' "$DATA_DIR/history.jsonl" 2>/dev/null || echo 0)" dictation dictations))" + fi + if [[ -d "$DATA_DIR/meetings" ]]; then + say "$DATA_DIR/meetings ($(count "$(find "$DATA_DIR/meetings" -name '*.md' | wc -l)" meeting meetings))" + fi + if [[ -d "$DATA_DIR/recordings" ]]; then + say "$DATA_DIR/recordings ($(du -sh "$DATA_DIR/recordings" | cut -f1) of audio)" + fi + + if ((!ASSUME_YES)); then + if [[ -t 0 ]]; then + printf ' Type yes to delete them: ' + read -r reply + [[ "$reply" == "yes" ]] || { PURGE=0; say "Kept."; } + else + PURGE=0 + warn "Not a terminal, so nothing was deleted. Pass --yes if you meant it." + fi + fi +fi + +if ((PURGE)); then + rm -rf "$CONFIG_DIR" "$DATA_DIR" + ok "Settings and dictations deleted" +else + say "Settings kept: $CONFIG_DIR" + say "Dictations kept: $DATA_DIR" + say "Delete them too with: ./uninstall.sh --purge" +fi + +echo +ok "Done." +say "The source directory is untouched: $DIR" +echo diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..9194407 --- /dev/null +++ b/update.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Dikte updater: pull, put the launchers back, restart what was running. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PY="$(command -v python3 || true)" +USER_NAME="$(id -un)" + +say() { printf ' %s\n' "$1"; } +ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } +warn() { printf ' \033[33m!\033[0m %s\n' "$1"; } +die() { printf ' \033[31m✗\033[0m %s\n' "$1"; echo; exit 1; } + +# The combination stored in the settings, which is where Dikte itself reads it +# from and the one place that is the same on KDE and on GNOME. +setting() { + [[ -n "$PY" ]] || return 0 + "$PY" "$DIR/dikte.py" config get "$1" 2>/dev/null || true +} + +echo +echo "Updating Dikte" +echo "──────────────" + +cd "$DIR" + +# 1. Somewhere there is something to pull ---------------------------------- +command -v git >/dev/null || die "git not found; update by downloading the source again" +git rev-parse --git-dir >/dev/null 2>&1 \ + || die "$DIR is not a git checkout; update by downloading the source again" + +before="$(git rev-parse HEAD)" + +# 2. Is there anything to come? --------------------------------------------- +# Asked before anything else is complained about: an unfinished afternoon in +# the working tree is nobody's problem on a day when nothing has been +# published. Fetching leaves the working tree alone. +git fetch --quiet || die "Could not reach the remote." +upstream="$(git rev-parse '@{u}' 2>/dev/null)" \ + || die "This branch is not tracking a remote one; pull by hand." + +if [[ "$before" == "$upstream" ]]; then + echo + ok "Already up to date ($(git log -1 --format=%s))" + echo + exit 0 +fi + +# 3. Only now, your own edits ----------------------------------------------- +# They would be overwritten by a fast-forward or would block it, and either way +# that is your call to make, not this script's. Untracked files are counted +# too: a fast-forward that adds a file of that name stops on them. +if [[ -n "$(git status --porcelain)" ]]; then + warn "There is an update waiting, but you have changes of your own here:" + git --no-pager status --short | sed 's/^/ /' + say "Commit them, or put them aside with: git stash --include-untracked" + die "Nothing was updated." +fi + +# --ff-only: an update should be somebody else's commits arriving, never a +# merge this script decided to make on your behalf. The fetch above already +# brought them, so this touches no network. +# advice off: git's suggestion is a merge or a rebase, and which of those you +# want is the sentence below, not a wall of hints. +if ! merge_log="$(git -c advice.diverging=false merge --ff-only '@{u}' 2>&1)"; then + printf '%s\n' "$merge_log" | sed 's/^/ /' + say "Your branch has commits the remote does not. To put them on top of the" + say "update instead: git pull --rebase" + die "Could not fast-forward." +fi +after="$(git rev-parse HEAD)" + +echo +say "What arrived:" +git --no-pager log --oneline "$before..$after" | sed 's/^/ /' +echo + +# 4. Launchers -------------------------------------------------------------- +# An update can add a dependency or move a file, so the installer runs again. +# It would otherwise register its own defaults over the keys you chose, so it +# is told what those are. Read before the installer runs, since it is the one +# writing them. +shortcut="$(setting shortcut)" +cancel_shortcut="$(setting cancel_shortcut)" +# Positional, so a chosen discard key cannot be passed without the other one. +"$DIR/install.sh" "${shortcut:-Ctrl+Space}" "${cancel_shortcut:-}" + +# 5. The running instance --------------------------------------------------- +# It is still running the code from before the pull. +if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then + if [[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" restart >/dev/null 2>&1; then + ok "Restarted, so the new version is the one running" + else + warn "Could not restart it; use the tray menu → Restart" + fi +else + say "Dikte was not running. Start it with: dikte" +fi +echo diff --git a/worker.py b/worker.py index ba27107..012ab07 100644 --- a/worker.py +++ b/worker.py @@ -18,6 +18,7 @@ from PyQt6.QtCore import QObject, pyqtSignal import api import assistant import audio +import cleanup import config as cfg import i18n import paste @@ -112,14 +113,7 @@ class Pipeline(QObject): if (conf["assistant_cleanup"] if ask else conf["cleanup_enabled"]): self.stage.emit(t("Cleaning up…")) try: - text = api.cleanup( - raw, - conf.openrouter_key(), - conf["cleanup_model"], - conf.cleanup_prompt(), - reasoning=conf["cleanup_reasoning"], - base_url=conf["openrouter_base_url"], - ) + text = cleanup.run(raw, conf, conf.cleanup_prompt()) except api.ApiError as exc: # Keep the transcript, but never let the failure pass unseen: # a rejected key would otherwise look like working dictation. @@ -159,7 +153,7 @@ class Pipeline(QObject): "duration": round(duration, 1), "elapsed": round(time.monotonic() - started, 1), "model": target.model, - "cleanup_model": conf["cleanup_model"] if conf["cleanup_enabled"] else "", + "cleanup_model": cleanup.model(conf) if conf["cleanup_enabled"] else "", "cleanup_error": warning, "mode": "ask" if ask else "", "question": question,