From 2cfbbb2d990905df8841d97e9a6172c834bd4a8c Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 1 Aug 2026 20:00:35 +0300 Subject: [PATCH] Transcribe and clean up on this machine, without installing anything first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit whisper-server is started on --inference-path /v1/audio/transcriptions, which is exactly the path api.py already builds for the hosted providers, and llama-server answers /chat/completions the way OpenRouter does. So the local half is one more base URL rather than a second code path: worker.py, filetranscribe.py and meeting.py are untouched, and dictation, subtitles and meetings all work here on the first try. Three findings worth naming, none of them in the new code: whisper.cpp cuts segments on tokens, which in Turkish lands inside a word about as often as between two. Pasted raw that gives "akraba değ\niller."; in a subtitle it gives a cue reading "değ". Whisper marks the start of a word with a leading space, so a piece that does not begin with one continues the word above it. A small model will repeat the transcript until the context is full, and every one of those tokens is a second of somebody waiting: measured at 206 seconds, and 25 with a ceiling on the reply. Hosted models are left alone, where the same runaway is rare and a ceiling would cut the minutes short. A server outlives SIGTERM and SIGKILL holding its model in memory. Signals are now turned into an event Qt delivers, since Qt blocks in C where a Python handler never runs, and a pid file lets the next start sweep up what a SIGKILL left behind. The minutes keep their own provider rather than following cleanup's. The two jobs are not the same size: a 4B model here will strip the filler words out of a dictation and will not write up an hour long meeting. The suite runs offline now: a test that reaches the network says so instead of quietly going there. --- README.md | 22 +- README.tr.md | 23 +- api.py | 208 +++++++++++++--- config.py | 111 ++++++++- dikte.py | 94 +++++++- filetranscribe.py | 10 +- install.sh | 3 +- meeting.py | 24 +- settings_ui.py | 452 +++++++++++++++++++++++++++++++++-- tests/support.py | 12 + tests/test_api.py | 192 ++++++++++++++- tests/test_config.py | 87 ++++++- tests/test_filetranscribe.py | 2 +- tests/test_ui.py | 60 +++++ tests/test_worker.py | 2 +- worker.py | 12 +- 16 files changed, 1194 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 1d36007..bd27986 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. @@ -39,10 +39,12 @@ 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. -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 +Speech to text and cleanup each pick a provider in the settings window. Both can +run here, on whisper.cpp and llama.cpp: the program and the model are downloaded +from that window (checksummed, into `~/.local/share/dikte`), so nothing has to be +installed first and nothing leaves the machine. The hosted alternatives want a +key: **OpenAI** or **OpenRouter** for speech to text, **OpenRouter** for cleanup +(`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. @@ -144,7 +146,9 @@ 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 on any provider (stdlib only) +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..454ec6a 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. @@ -39,10 +38,14 @@ 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. -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. İkisi de burada çalışabilir, whisper.cpp ve llama.cpp üzerinde: program +da model de o pencereden indirilir (sha256 doğrulamasıyla, +`~/.local/share/dikte` altına), yani önceden hiçbir şey kurman gerekmez ve +makineden hiçbir şey çıkmaz. Bulut seçenekleri anahtar ister: yazıya çevirme +için **OpenAI** ya da **OpenRouter**, temizleme için **OpenRouter** +(`google/gemini-3.5-flash-lite`), yani tek bir OpenRouter anahtarı ikisine de +yeter. Boş bırakırsan `OPENAI_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. @@ -142,7 +145,9 @@ 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 her sağlayıcıda transkript ve temizleme (yalnız stdlib) +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..df9bab6 100644 --- a/api.py +++ b/api.py @@ -1,9 +1,14 @@ -"""OpenAI and OpenRouter calls, stdlib only. +"""OpenAI, 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 three providers and cleanup on two, and none of +them needs code of its own. OpenRouter mirrors OpenAI's /audio/transcriptions +endpoint field for field, and ggml.py starts whisper.cpp on that same path, so +one multipart request serves all three; llama.cpp answers /chat/completions the +way OpenRouter does, so one JSON request serves both. What changes between them +is the key, the base URL and the model id. + +The local ones have no key, and their base URL is not known until a server is +up, which is the one thing this module has to fill in for them. """ import collections @@ -14,6 +19,7 @@ import secrets import urllib.error import urllib.request +import ggml from i18n import t APP_URL = "https://github.com/yusufipk/dikte" @@ -21,14 +27,29 @@ USER_AGENT = f"dikte/1.0 (+{APP_URL})" OPENAI_URL = "https://api.openai.com/v1" OPENROUTER_URL = "https://openrouter.ai/api/v1" -# 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") +# 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 request goes; built by config.Config's *_target() methods. `service` +# is the name the user sees in an error, `provider` the one the code branches +# on. `reasoning` is only read by cleanup, which is the only job with a model +# that might think about anything. +Target = collections.namedtuple( + "Target", "provider service api_key base_url model reasoning", defaults=("",)) -def timestamp_model(provider): - """Only whisper-1 returns segment times, and OpenRouter namespaces the id.""" +def timestamp_model(provider, model): + """Only whisper-1 returns segment times, and OpenRouter namespaces the id. + + Whisper is what the local server runs whatever the file is called, so there + it stays on the model that is already loaded; asking for another one would + name a model that server has never heard of. + """ + if provider == "local": + return model return "openai/whisper-1" if provider == "openrouter" else "whisper-1" @@ -106,7 +127,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 +141,44 @@ def _headers(provider, api_key, content_type=None): return headers +def _serving(target, server, timeout): + """A local target with the address of a running server in it. + + The server is started on demand and picks its own port, so this is the first + moment its address exists. serve() is idempotent: once it is up this costs + nothing. + """ + try: + return target._replace(base_url=server.serve()), max(timeout, LOCAL_TIMEOUT) + except ggml.LocalError as exc: + raise ApiError(str(exc)) from None + + +def _local_failure(target, 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"{target.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: + if target.provider == "local": + target, timeout = _serving(target, ggml.whisper, 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 in ("openai", "local"): fields.append(("prompt", prompt)) if granularity: fields.append(("timestamp_granularities[]", granularity)) @@ -137,14 +189,58 @@ def _transcribe_request(target, wav_path, language, prompt, response_format, _headers(target.provider, target.api_key, ctype), timeout=timeout, ) except ApiError as exc: + if target.provider == "local": + raise _local_failure(target, ggml.whisper, exc) from None raise explain(exc, target.service) from None +# 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): data = _transcribe_request( target, wav_path, language, prompt, "json", timeout=timeout ) - 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 @@ -153,11 +249,13 @@ def transcribe(target, wav_path, language="", prompt="", timeout=300): def transcribe_segments(target, wav_path, language="", prompt="", timeout=300): """[(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, ) 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,45 +264,91 @@ 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 cleanup(text, api_key, model, system_prompt, reasoning="", - base_url=OPENROUTER_URL, timeout=180): - if not api_key: +def _thinking(target, payload): + """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 providers 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 target.reasoning: + return + if target.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": target.reasoning != "none"} + return + if target.reasoning != "none": + # The thinking itself is never shown, so ask for it to be left out. + payload["reasoning"] = {"effort": target.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(target, text, system_prompt, timeout=180): + if target.provider == "local-llm": + target, timeout = _serving(target, ggml.llm, timeout) + elif not target.api_key: raise ApiError(t("{service} API key is empty. Add it in Settings.", - service="OpenRouter")) + service=target.service)) payload = { - "model": model, + "model": target.model, "temperature": 0, "messages": [ {"role": "system", "content": system_prompt}, {"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 target.provider == "local-llm": + payload["max_tokens"] = _local_ceiling(text) + _thinking(target, payload) try: data = _request( - f"{base_url.rstrip('/')}/chat/completions", + f"{target.base_url.rstrip('/')}/chat/completions", json.dumps(payload).encode("utf-8"), - _headers("openrouter", api_key, "application/json"), + _headers(target.provider, target.api_key, "application/json"), timeout=timeout, ) except ApiError as exc: - raise explain(exc, "OpenRouter") from None + if target.provider == "local-llm": + raise _local_failure(target, ggml.llm, exc) from None + raise explain(exc, target.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 “Off”.")) raise ApiError(t("The cleanup model returned an empty reply.")) return content diff --git a/config.py b/config.py index 396bd58..16d9c0d 100644 --- a/config.py +++ b/config.py @@ -6,7 +6,9 @@ import os import pathlib import api +import ggml import i18n +from i18n import t def _xdg(var, default): @@ -365,14 +367,46 @@ DEFAULTS = { "openai_base_url": "https://api.openai.com/v1", "openrouter_api_key": "", "openrouter_base_url": "https://openrouter.ai/api/v1", - "transcribe_provider": "openai", # openai | openrouter + "transcribe_provider": "local", # local | openai | openrouter "transcribe_model": "gpt-4o-transcribe", # used when provider is openai "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", # openrouter | local "cleanup_model": "google/gemini-3.5-flash-lite", "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": "ctrl+v", @@ -400,6 +434,7 @@ DEFAULTS = { "meeting_language": "", # empty -> the dictation speech language "meeting_max_seconds": 14400, # 4 hours "meeting_cleanup": True, + "meeting_provider": "openrouter", # openrouter | local "meeting_model": "google/gemini-3.5-flash", "meeting_reasoning": "", "meeting_prompt": "", # empty -> language-specific default @@ -495,14 +530,84 @@ class Config: return self["openrouter_api_key"].strip() or os.environ.get("OPENROUTER_API_KEY", "").strip() def transcribe_target(self): - """Key, endpoint and model for whichever provider does speech to text.""" - if self["transcribe_provider"] == "openrouter": + """Key, endpoint and model for whichever provider does speech to text. + + The local one leaves its base URL empty on purpose: the server picks a + port when it starts, and starting it here would make reading a setting + launch 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. + """ + provider = self["transcribe_provider"] + if provider == "local": + return api.Target("local", t("Local whisper"), "", "", + self["local_model"]) + if 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"]) + def cleanup_target(self): + """The same, for the model that tidies a transcript up.""" + if self["cleanup_provider"] == "local": + return api.Target("local-llm", t("Local model"), "", "", + self["local_llm_model"], self["local_llm_reasoning"]) + return api.Target("openrouter", "OpenRouter", self.openrouter_key(), + self["openrouter_base_url"], self["cleanup_model"], + self["cleanup_reasoning"]) + + def minutes_target(self): + """The same again, for the minutes. + + Its own provider rather than the cleanup one. The two jobs are not the + same size: a 4B model on this machine will strip the filler words out of + a dictation perfectly well and will not write up an hour long meeting, + so choosing it for the first must not quietly choose it for the second. + """ + if self["meeting_provider"] == "local": + return api.Target("local-llm", t("Local model"), "", "", + self["local_llm_model"], self["local_llm_reasoning"]) + return api.Target("openrouter", "OpenRouter", self.openrouter_key(), + self["openrouter_base_url"], self["meeting_model"], + self["meeting_reasoning"]) + + 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 "local" in (self["cleanup_provider"], self["meeting_provider"]) + def cleanup_prompt(self, with_timestamps=False, with_speakers=False, subtitles=False): turkish = i18n.language() == "tr" diff --git a/dikte.py b/dikte.py index cc17c6c..c667634 100755 --- a/dikte.py +++ b/dikte.py @@ -7,16 +7,20 @@ 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. if os.environ.get("XDG_SESSION_TYPE") == "wayland" and os.environ.get("DISPLAY"): os.environ.setdefault("QT_QPA_PLATFORM", "xcb") -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 @@ -25,6 +29,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 @@ -92,6 +97,9 @@ class Dikte: self.meeting_recorder = audio.MeetingRecorder() self.meetings = MeetingPipeline(self.conf) self.evdev = hotkey.EvdevHotkey() + # 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) @@ -802,9 +810,44 @@ 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() if self.conf["evdev_hotkey"]: @@ -834,6 +877,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() @@ -872,6 +918,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 "" @@ -879,6 +960,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") @@ -927,7 +1014,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/filetranscribe.py b/filetranscribe.py index 4839d13..d931264 100644 --- a/filetranscribe.py +++ b/filetranscribe.py @@ -127,17 +127,11 @@ class FileTranscriber(QObject): def _cleanup(self, text, timestamps): conf = self.conf prompt = conf.cleanup_prompt(with_timestamps=timestamps, subtitles=True) + target = conf.cleanup_target() 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(api.cleanup(target, block, prompt)) return ("\n" if timestamps else "\n\n").join(out) diff --git a/install.sh b/install.sh index 2cbaaee..10a89c4 100755 --- a/install.sh +++ b/install.sh @@ -116,5 +116,6 @@ 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..d6bfbbe 100644 --- a/meeting.py +++ b/meeting.py @@ -118,18 +118,12 @@ class MeetingPipeline(QObject): self._check() self._say(t("Writing the minutes…")) - minutes = api.cleanup( - transcript, - self.conf.openrouter_key(), - self.conf["meeting_model"], - self.conf.meeting_prompt(), - reasoning=self.conf["meeting_reasoning"], - base_url=self.conf["openrouter_base_url"], - timeout=600, - ) + writer = self.conf.minutes_target() + minutes = api.cleanup(writer, transcript, self.conf.meeting_prompt(), + timeout=600) title = self._write(doc_path, minutes, transcript, entry) cfg.update_meeting(base, status="done", error="", title=title, - model=self.conf["meeting_model"]) + model=writer.model) self._discard_audio(wav_path) self.finished.emit(base, title) @@ -201,6 +195,7 @@ class MeetingPipeline(QObject): def _cleanup(self, transcript): conf = self.conf prompt = conf.cleanup_prompt(with_timestamps=True, with_speakers=True) + target = conf.cleanup_target() out = [] blocks = filetranscribe.split_text(transcript, True) for index, block in enumerate(blocks, start=1): @@ -208,14 +203,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(api.cleanup(target, block, prompt)) return "\n".join(out) def _write(self, doc_path, minutes, transcript, entry): diff --git a/settings_ui.py b/settings_ui.py index 1690a6b..c70ed66 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -18,6 +18,7 @@ import assistant import audio import config as cfg import filetranscribe +import ggml import hotkey import meeting from filetranscribe import FileTranscriber @@ -29,7 +30,10 @@ LANGUAGES = [ ("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"), ] CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"] -TRANSCRIBE_PROVIDERS = [("OpenAI", "openai"), ("OpenRouter", "openrouter")] +TRANSCRIBE_PROVIDERS = [("This machine (whisper.cpp)", "local"), + ("OpenAI", "openai"), ("OpenRouter", "openrouter")] +CLEANUP_PROVIDERS = [("OpenRouter", "openrouter"), + ("This machine (llama.cpp)", "local")] # Starting points for the model box; "Fetch model list" replaces them with # whatever the provider offers today. TRANSCRIBE_MODELS = { @@ -103,6 +107,316 @@ 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) + _progress = pyqtSignal(int, int) + _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 _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._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) + 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._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() @@ -127,9 +441,9 @@ class SettingsWindow(QDialog): 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")) @@ -161,6 +475,10 @@ class SettingsWindow(QDialog): self.meetings.finished.connect(self._on_minutes_finished) self.meetings.failed.connect(self._on_minutes_failed) self._load() + # 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 ---------------------------------------------------------- @@ -269,20 +587,55 @@ class SettingsWindow(QDialog): 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) + # The hosted providers take any model id that is typed at them; the + # local one offers what has been published, so the two are separate + # blocks and only one of them is ever visible. + self.hosted_stt = QWidget() + hosted_form = QFormLayout(self.hosted_stt) + hosted_form.setContentsMargins(0, 0, 0, 0) 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)) + hosted_form.addRow(t("Model"), + self._row(self.transcribe_model, + self.refresh_transcribe_models)) # 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) + hosted_form.addRow(self.transcribe_status) + stt_form.addRow(self.hosted_stt) + + 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) @@ -291,12 +644,22 @@ class SettingsWindow(QDialog): 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.currentIndexChanged.connect(self._cleanup_provider_changed) + orr_form.addRow(t("Provider"), self.cleanup_provider) + + self.hosted_cleanup = QWidget() + cleanup_form = QFormLayout(self.hosted_cleanup) + cleanup_form.setContentsMargins(0, 0, 0, 0) 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)) + cleanup_form.addRow(t("Model"), self._row(self.cleanup_model, + self.refresh_models)) self.cleanup_reasoning = QComboBox() for label, value in REASONING_LEVELS: @@ -306,11 +669,38 @@ class SettingsWindow(QDialog): "a light job, so more thinking mostly costs time and tokens. Models " "that cannot think ignore this.") ) - orr_form.addRow(t("Thinking"), self.cleanup_reasoning) + cleanup_form.addRow(t("Thinking"), self.cleanup_reasoning) self.models_label = QLabel(t("Runs on OpenRouter.")) self.models_label.setWordWrap(True) - orr_form.addRow(self.models_label) + cleanup_form.addRow(self.models_label) + orr_form.addRow(self.hosted_cleanup) + + 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 @@ -926,9 +1316,20 @@ class SettingsWindow(QDialog): 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._select_data(self.cleanup_provider, conf["cleanup_provider"]) + self._cleanup_provider_changed() self.cleanup_model.setCurrentText(conf["cleanup_model"]) 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() @@ -1003,16 +1404,27 @@ class SettingsWindow(QDialog): 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 TRANSCRIBE_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] + 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_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. @@ -1093,15 +1505,27 @@ 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.hosted_stt.setVisible(not local) + self.local_whisper.setVisible(local) + self.local_options.setVisible(local) + if local: + return self.transcribe_model.clear() self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider]) self.transcribe_model.setCurrentText(self._models[provider]) self.transcribe_status.setText("") + def _cleanup_provider_changed(self): + local = (self.cleanup_provider.currentData() or "openrouter") == "local" + self.hosted_cleanup.setVisible(not local) + self.local_llm.setVisible(local) + self.local_llm_options.setVisible(local) + def _load_transcribe_models(self): """The model list of whichever provider is selected.""" provider = self.transcribe_provider.currentData() or "openai" 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..0a4e69c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -10,6 +10,7 @@ import os import unittest import api +import ggml from tests.support import ( DikteTest, fake_urlopen, @@ -27,10 +28,18 @@ OPENROUTER = api.Target("openrouter", "OpenRouter", "sk-or-test", class TimestampModel(unittest.TestCase): def test_only_whisper_returns_segment_times(self): - self.assertEqual(api.timestamp_model("openai"), "whisper-1") + self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"), + "whisper-1") def test_openrouter_namespaces_the_id(self): - self.assertEqual(api.timestamp_model("openrouter"), "openai/whisper-1") + self.assertEqual(api.timestamp_model("openrouter", "openai/gpt-4o-transcribe"), + "openai/whisper-1") + + def test_the_local_server_stays_on_the_model_it_loaded(self): + # Asking it for whisper-1 would name a model it has never heard of, and + # it is running whisper whatever the file is called. + self.assertEqual(api.timestamp_model("local", "ggml-base.bin"), + "ggml-base.bin") class Explain(DikteTest): @@ -278,10 +287,15 @@ def chat_reply(content): return {"choices": [{"message": {"content": content}}]} +def openrouter(model="some/model", key="sk-or-test", reasoning="", + base_url="https://openrouter.ai/api/v1"): + return api.Target("openrouter", "OpenRouter", key, base_url, model, reasoning) + + class Cleanup(DikteTest): - def call(self, replies, **kwargs): + def call(self, replies, target=None, **kwargs): with fake_urlopen(replies) as calls: - result = api.cleanup("uh, hello", "sk-or-test", "some/model", + result = api.cleanup(target or openrouter(), "uh, hello", "you clean up text", **kwargs) return result, calls @@ -311,32 +325,34 @@ class Cleanup(DikteTest): self.assertNotIn("reasoning", sent_json(calls[0])) def test_an_effort_is_passed_on_and_the_thinking_left_out(self): - _, calls = self.call(chat_reply("Hello."), reasoning="high") + _, calls = self.call(chat_reply("Hello."), + target=openrouter(reasoning="high")) self.assertEqual(sent_json(calls[0])["reasoning"], {"effort": "high", "exclude": True}) def test_a_local_base_url(self): - _, calls = self.call(chat_reply("Hello."), base_url="http://localhost:1234/v1") + _, calls = self.call(chat_reply("Hello."), + target=openrouter(base_url="http://localhost:1234/v1")) self.assertEqual(calls[0].full_url, "http://localhost:1234/v1/chat/completions") def test_no_key(self): with self.assertRaises(api.ApiError): - api.cleanup("hello", "", "some/model", "prompt") + api.cleanup(openrouter(key=""), "hello", "prompt") def test_a_reply_with_no_choices_says_why(self): with fake_urlopen({"error": {"message": "model is offline"}}), \ self.assertRaises(api.ApiError) as caught: - api.cleanup("hello", "k", "m", "p") + api.cleanup(openrouter(), "hello", "p") self.assertIn("model is offline", str(caught.exception)) def test_an_empty_answer(self): with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError): - api.cleanup("hello", "k", "m", "p") + api.cleanup(openrouter(), "hello", "p") def test_a_rate_limit_is_explained(self): with fake_urlopen(http_error(429)), \ self.assertRaises(api.ApiError) as caught: - api.cleanup("hello", "k", "m", "p") + api.cleanup(openrouter(), "hello", "p") self.assertIn("OpenRouter", str(caught.exception)) @@ -435,3 +451,159 @@ class ModelLists(DikteTest): 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") +LOCAL_LLM = api.Target("local-llm", "Local model", "", "", "gemma.gguf", "none") + + +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 CleanupHere(DikteTest): + def setUp(self): + super().setUp() + self.server = FakeServer("http://127.0.0.1:8888/v1") + self.patch_attr(ggml, "llm", self.server) + + def test_it_goes_to_the_server_it_starts(self): + with fake_urlopen(chat_reply("Hello.")) as calls: + result = api.cleanup(LOCAL_LLM, "uh, hello", "clean it up") + self.assertEqual(result, "Hello.") + self.assertEqual(calls[0].full_url, + "http://127.0.0.1:8888/v1/chat/completions") + + def test_no_key_is_wanted_and_none_is_sent(self): + with fake_urlopen(chat_reply("Hello.")) as calls: + api.cleanup(LOCAL_LLM, "hello", "prompt") + self.assertNotIn("Authorization", calls[0].headers) + + def test_thinking_is_turned_off_in_the_words_llama_cpp_uses(self): + with fake_urlopen(chat_reply("Hello.")) as calls: + api.cleanup(LOCAL_LLM, "hello", "prompt") + self.assertEqual(sent_json(calls[0])["chat_template_kwargs"], + {"enable_thinking": False}) + + def test_the_models_own_default_asks_for_nothing(self): + with fake_urlopen(chat_reply("Hello.")) as calls: + api.cleanup(LOCAL_LLM._replace(reasoning=""), "hello", "prompt") + self.assertNotIn("chat_template_kwargs", sent_json(calls[0])) + + 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: + api.cleanup(LOCAL_LLM, "hello", "prompt") + self.assertIn("Thinking", str(caught.exception)) + + 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("Hello.")) as calls: + api.cleanup(LOCAL_LLM, "x" * 4000, "prompt") + 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("Hello.")) as calls: + api.cleanup(LOCAL_LLM, "uh, hi", "prompt") + self.assertEqual(sent_json(calls[0])["max_tokens"], 512) + + def test_a_hosted_model_is_left_to_answer_at_length(self): + with fake_urlopen(chat_reply("Hello.")) as calls: + api.cleanup(openrouter(), "uh, hi", "prompt") + self.assertNotIn("max_tokens", sent_json(calls[0])) + + 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: + api.cleanup(LOCAL_LLM, "hello", "prompt") + self.assertIn("llama.cpp", str(caught.exception)) diff --git a/tests/test_config.py b/tests/test_config.py index 7120a0c..28e3218 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -13,6 +13,7 @@ from unittest import mock import api import config as cfg +import ggml import i18n from tests.support import DikteTest @@ -127,8 +128,17 @@ class Keys(DikteTest): 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,7 +156,8 @@ class TranscribeTarget(DikteTest): self.assertEqual(target.model, "openai/whisper-1") 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") @@ -425,3 +436,73 @@ class Defaults(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class LocalTargets(DikteTest): + def test_cleanup_can_run_here_while_the_minutes_do_not(self): + # The two jobs are not the same size: a small model on this machine + # strips filler words perfectly well and will not write up an hour. + conf = self.config(cleanup_provider="local", local_llm_model="gemma.gguf") + self.assertEqual(conf.cleanup_target().provider, "local-llm") + self.assertEqual(conf.minutes_target().provider, "openrouter") + self.assertEqual(conf.minutes_target().model, cfg.DEFAULTS["meeting_model"]) + + def test_the_minutes_can_run_here_on_their_own(self): + conf = self.config(meeting_provider="local", local_llm_model="gemma.gguf") + self.assertEqual(conf.minutes_target().model, "gemma.gguf") + self.assertEqual(conf.cleanup_target().provider, "openrouter") + + def test_the_local_cleanup_target_carries_the_thinking_level(self): + conf = self.config(cleanup_provider="local", local_llm_model="gemma.gguf", + local_llm_reasoning="none") + target = conf.cleanup_target() + self.assertEqual(target.reasoning, "none") + self.assertEqual(target.api_key, "") + + def test_either_of_them_counts_as_using_the_local_model(self): + self.assertFalse(cfg.Config().uses_local_llm()) + self.assertTrue(self.config(cleanup_provider="local").uses_local_llm()) + self.assertTrue(self.config(meeting_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..5ed22d6 100644 --- a/tests/test_filetranscribe.py +++ b/tests/test_filetranscribe.py @@ -197,7 +197,7 @@ class Transcriber(DikteTest): def test_cleanup_is_told_it_is_writing_subtitles(self): _, _, _, cleanup_call = self.run_chain(cleanup=True) - prompt = cleanup_call.call_args.args[3] + prompt = cleanup_call.call_args.args[2] self.assertEqual(prompt, self.conf.cleanup_prompt(subtitles=True)) def test_timestamps_come_back_as_segments_and_as_stamped_lines(self): diff --git a/tests/test_ui.py b/tests/test_ui.py index a616e94..10fac61 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -41,8 +41,18 @@ CHANGED = { "transcribe_model": "whisper-1", "openrouter_transcribe_model": "openai/whisper-1", "cleanup_enabled": False, + "cleanup_provider": "local", "cleanup_model": "some/other-model", "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", @@ -259,3 +269,53 @@ 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, "dikte toggle") + 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_the_hosted_boxes_go_away_when_the_work_happens_here(self): + window = self.window(self.config(transcribe_provider="openai")) + self.assertTrue(window.hosted_stt.isVisibleTo(window)) + self.assertFalse(window.local_whisper.isVisibleTo(window)) + window._select_data(window.transcribe_provider, "local") + self.assertFalse(window.hosted_stt.isVisibleTo(window)) + self.assertTrue(window.local_whisper.isVisibleTo(window)) + + def test_the_same_for_cleanup(self): + window = self.window(cfg.Config()) + self.assertTrue(window.hosted_cleanup.isVisibleTo(window)) + window._select_data(window.cleanup_provider, "local") + self.assertTrue(window.local_llm.isVisibleTo(window)) + self.assertFalse(window.hosted_cleanup.isVisibleTo(window)) 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/worker.py b/worker.py index ba27107..69871ab 100644 --- a/worker.py +++ b/worker.py @@ -106,20 +106,14 @@ class Pipeline(QObject): text = raw warning = "" + cleaner = conf.cleanup_target() # Claude reads through “eee” and “hani” without help, so a dictation # on its way there is normally sent as it was heard, one API call and # a second or two lighter. 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 = api.cleanup(cleaner, raw, 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": cleaner.model if conf["cleanup_enabled"] else "", "cleanup_error": warning, "mode": "ask" if ask else "", "question": question,