From e8147f49f850cfd39bd8451d61314ed9581d1326 Mon Sep 17 00:00:00 2001 From: huseyin-emre-tigci Date: Thu, 20 Aug 2026 12:29:39 +0300 Subject: [PATCH] Say true things in every language, and stop paying twice doctor judged the local provider by the API key it does not use, so a fully local machine always saw a red mark, and it crashed outright with local cleanup picked; both lines now ask readiness. config list printed the Groq key in plaintext while masking the other two. The one subprocess decoded with the locale codepage gets its UTF-8 back, so a Turkish filename cannot hang a file transcription, and a redirected stdout on Windows replaces what it cannot encode instead of failing after the work succeeded. meeting-cancel stops advertising a --wait the server never honoured. "you" and "bye" leave the hallucination list: people dictate them. The minutes stage failing no longer burns the transcription checkpoint, and an untouched meeting-length dial no longer rewrites a value the command line set in seconds. A prompt box compared against the wrong language's default after a switch no longer fossilizes the old default as a custom prompt. The 67 strings of the local-model box, the whole first-run screen of the shipped default, get their Turkish. The hub cache moves to the platform's cache directory instead of ~/.cache on every system; the old directory is a few orphaned kilobytes with a six-hour shelf life. The last NO_WINDOW spellings collapse into the constant paths already carries, one windowed-executable lookup, one session-file reader, one install-record reader, one download progress signal carrying its destination, and the KDE conflict scan loses the branch its other branch already covered. Co-Authored-By: Claude Fable 5 --- dikte/cli.py | 94 ++++++++++++++++--------- dikte/filetranscribe.py | 8 ++- dikte/hotkey.py | 6 +- dikte/hub.py | 10 ++- dikte/i18n.py | 131 +++++++++++++++++++++++++++++++++++ dikte/paths.py | 27 ++++++-- dikte/settings_ui.py | 127 ++++++++++++++++++++++++--------- dikte/vad.py | 4 +- tests/__init__.py | 1 + tests/test_filetranscribe.py | 22 ++++++ tests/test_hub.py | 10 +++ tests/test_paths.py | 28 ++++++++ tests/test_ui.py | 13 +++- tests/test_vad.py | 10 ++- 14 files changed, 403 insertions(+), 88 deletions(-) diff --git a/dikte/cli.py b/dikte/cli.py index 8bfcf56..d85b5c5 100644 --- a/dikte/cli.py +++ b/dikte/cli.py @@ -133,21 +133,10 @@ def _ask_instance(opts, cmd, wait=False, **args): def launch_gui(verb=""): """No instance running, so become the application itself.""" - args = ipc.launcher() - if verb: - args.append(verb) - args.append("--gui") - if sys.platform == "win32": - # execv on Windows mangles arguments with spaces and would leave the - # application tied to this console; start it detached instead. - subprocess.Popen( - args, - creationflags=(subprocess.DETACHED_PROCESS - | subprocess.CREATE_NEW_PROCESS_GROUP), - close_fds=True, - ) - sys.exit(0) - os.execv(args[0], args) + ipc.respawn(([verb] if verb else []) + ["--gui"]) + # respawn only returns on Windows, where the application was started + # detached and this console process's job is over. + sys.exit(0) def _not_running(opts): @@ -516,7 +505,7 @@ def cmd_history_clear(opts): # --- settings --------------------------------------------------------------- -SECRET_KEYS = ("openai_api_key", "openrouter_api_key") +SECRET_KEYS = ("openai_api_key", "groq_api_key", "openrouter_api_key") def _mask(key, value): @@ -839,27 +828,51 @@ def cmd_doctor(opts): programs = {name: shutil.which(name) or "" for name in wanted if name} target = conf.transcribe_target() cleaner = cleanup.provider(conf) + # What each provider actually needs: the local ones have no key to check, + # and marking them by the key they do not use reported every fully local + # setup as broken. + transcribe_ready = conf.transcribe_ready() + if cleaner == "openrouter": + cleanup_ready = bool(conf.openrouter_key()) + elif cleaner == "local": + cleanup_ready = conf.local_llm_ready() + else: + cleanup_ready = bool(programs.get(cleanup.executable(cleaner), "")) checks = { "programs": programs, "transcription": {"provider": target.provider, "model": target.model, - "key": bool(target.api_key)}, + "key": bool(target.api_key), + "ready": transcribe_ready}, "cleanup": {"enabled": conf["cleanup_enabled"], "provider": cleaner, "model": cleanup.model(conf), - "key": bool(conf.openrouter_key())}, + "key": bool(conf.openrouter_key()), + "ready": cleanup_ready}, "agent": {"provider": assistant.provider(conf), "directory": assistant.working_dir(conf)}, "running": ipc.send("status") is not None, } + if target.provider == "local": + transcribe_line = (f"{'✓' if transcribe_ready else '✗'} {target.service}, " + f"transcribing on {target.model or 'no model yet'}") + else: + transcribe_line = (f"{'✓' if transcribe_ready else '✗'} {target.service} " + f"key, transcribing on {target.model}") + if cleaner == "openrouter": + cleanup_line = (f"{'✓' if cleanup_ready else '✗'} OpenRouter key, " + f"cleaning up on {conf['cleanup_model']}") + elif cleaner == "local": + cleanup_line = (f"{'✓' if cleanup_ready else '✗'} Local model, " + f"cleaning up on {conf['local_llm_model'] or 'no model yet'}") + else: + # Cleanup on a CLI needs no key, so what is checked is the program. + cleanup_line = (f"{'✓' if cleanup_ready else '✗'} " + f"{cleanup.executable(cleaner)}, cleaning up on " + f"{cleanup.model(conf)}") lines = [f"{'✓' if path else '✗'} {name:14} {path or 'not on your PATH'}" for name, path in programs.items()] lines += [ - f"{'✓' if target.api_key else '✗'} {target.service} key, transcribing on " - f"{target.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)}"), + transcribe_line, + cleanup_line, f"{'✓' if checks['running'] else '·'} application " + ("running" if checks["running"] else "not running"), ] @@ -982,13 +995,14 @@ def build_parser(): transcribe.set_defaults(func=cmd_transcribe) # --- meetings --------------------------------------------------------- - for name, help_text in (("meeting", "start a meeting, or end it and write it up"), - ("meeting-cancel", "")): - page = leaf(subs, name, help_text) - page.add_argument("--wait", action="store_true", - help="wait for the minutes to be written") - page.add_argument("--timeout", type=float, default=0) - page.set_defaults(func=cmd_meeting) + page = leaf(subs, "meeting", "start a meeting, or end it and write it up") + page.add_argument("--wait", action="store_true", + help="wait for the minutes to be written") + page.add_argument("--timeout", type=float, default=0) + page.set_defaults(func=cmd_meeting) + # No --wait here: a cancel is answered on the spot, and a flag the server + # would ignore is a promise the help text cannot keep. + leaf(subs, "meeting-cancel", "").set_defaults(func=cmd_meeting) meetings = leaf(subs, "meetings", "recorded meetings and their minutes") inner = meetings.add_subparsers(dest="meetings", metavar="") @@ -1007,12 +1021,14 @@ def build_parser(): delete.add_argument("which", nargs="+") delete.set_defaults(func=cmd_meetings_delete) for name, verb, help_text in (("start", "meeting-start", "start recording one"), - ("stop", "meeting-stop", "end it and write it up"), - ("cancel", "meeting-cancel", "throw the recording away")): + ("stop", "meeting-stop", "end it and write it up")): page = leaf(inner, name, help_text) page.add_argument("--wait", action="store_true") page.add_argument("--timeout", type=float, default=0) page.set_defaults(func=cmd_meeting, verb=verb) + # cancel takes no --wait: see the top-level meeting-cancel. + leaf(inner, "cancel", "throw the recording away").set_defaults( + func=cmd_meeting, verb="meeting-cancel") # --- history ---------------------------------------------------------- history = leaf(subs, "history", "past dictations") @@ -1118,6 +1134,16 @@ def _needs_subcommand(parser): def run(argv): global _app + # A redirected stdout on Windows falls back to the console codepage, + # strict, and a transcript (or doctor's ✓) with a character outside it + # would then fail the run after the work succeeded. Interactively nothing + # changes: the console is written through its own Unicode API. + if sys.platform == "win32" and not os.environ.get("PYTHONIOENCODING"): + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(errors="replace") + except (AttributeError, OSError): + pass parser = build_parser() opts = parser.parse_args(argv) # No verb at all is the plain `dikte`, which means the settings window. diff --git a/dikte/filetranscribe.py b/dikte/filetranscribe.py index 8415752..3838e3e 100644 --- a/dikte/filetranscribe.py +++ b/dikte/filetranscribe.py @@ -26,6 +26,7 @@ from PyQt6.QtCore import QObject, pyqtSignal from . import api from . import cleanup from . import ggml +from . import paths from .i18n import t UPLOAD_LIMIT = 24 * 1024 * 1024 # the APIs take 25 MB; leave the form its room @@ -282,8 +283,11 @@ def _ffmpeg(args, out, aborter=None): proc = subprocess.Popen( ["ffmpeg", "-nostdin", "-y", *args], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + # ffmpeg writes UTF-8 whatever the locale says; read as the Windows + # codepage its messages mojibake, and a byte the codepage cannot place + # raises from inside communicate itself. + text=True, encoding="utf-8", errors="replace", + creationflags=paths.NO_WINDOW, ) # 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. diff --git a/dikte/hotkey.py b/dikte/hotkey.py index 649ef36..91e4c6b 100644 --- a/dikte/hotkey.py +++ b/dikte/hotkey.py @@ -970,9 +970,7 @@ def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID): if "=" not in line or desktop_id in section: continue key, _, value = line.partition("=") - if shortcut.lower() in value.lower().split(","): - hits.append(f"{section} → {key}") - elif any(shortcut.lower() == part.strip().lower() - for part in re.split(r"[,\t]", value)): + if any(shortcut.lower() == part.strip().lower() + for part in re.split(r"[,\t]", value)): hits.append(f"{section} → {key}") return hits diff --git a/dikte/hub.py b/dikte/hub.py index afd8133..2ff115c 100644 --- a/dikte/hub.py +++ b/dikte/hub.py @@ -12,19 +12,18 @@ 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. +Nothing here imports the rest of Dikte apart from two leaves, the string table +and the path map: 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 . import paths from .i18n import t GITHUB_API = "https://api.github.com" @@ -32,8 +31,7 @@ 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") +CACHE_DIR = paths.cache_dir() # 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 diff --git a/dikte/i18n.py b/dikte/i18n.py index 9b4826c..b9a9667 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -725,4 +725,135 @@ TR = { "This one is being written up right now.": "Bunun tutanağı şu anda çıkarılıyor.", "Delete this meeting, its minutes and its recording?": "Bu toplantı, tutanağı ve ses kaydı silinsin mi?", + + # --- local models and downloads ------------------------------------ + # The whole box was born after the last translation pass, which left the + # first-run screen half English on a Turkish machine. + "Download": "İndir", + "Delete": "Sil", + "Program": "Program", + "Publisher": "Yayıncı", + "Automatic": "Otomatik", + "Threads": "İş parçacığı", + "On this machine": "Bu makinede", + "Use the graphics card": "Ekran kartını kullan", + "Load the model when Dikte starts": "Modeli Dikte açılırken yükle", + "Local whisper": "Yerel whisper", + "Local model": "Yerel model", + "Not installed.": "Kurulu değil.", + "Installed on the system: {path}": "Sistemde kurulu: {path}", + "Downloaded, version {version}.": "İndirildi, sürüm {version}.", + "Fetching the model list…": "Model listesi çekiliyor…", + "Downloading…": "İndiriliyor…", + "Downloading: {done} of {total}{share}": "İndiriliyor: {done} / {total}{share}", + "Download stopped.": "İndirme durduruldu.", + "Ready: {name}.": "Hazır: {name}.", + "Nothing downloaded yet.": "Henüz bir şey indirilmedi.", + "{name} has not been downloaded yet.": "{name} henüz indirilmedi.", + "downloaded": "indirildi", + "not downloaded": "indirilmedi", + "Delete model": "Modeli sil", + "Delete {name} from this machine?": "{name} bu makineden silinsin mi?", + "Runs on this machine, on llama.cpp.": "Bu makinede, llama.cpp üzerinde çalışır.", + "A Hugging Face repository of GGUF files. The list is fetched; any other " + "one can be typed in.": + "GGUF dosyaları içeren bir Hugging Face deposu. Liste internetten " + "çekilir; başka bir depo da yazılabilir.", + "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.": + "Büyük bir modelin yüklenmesi bir iki saniye sürer. Baştan yüklemek bu " + "bedeli ilk diktede değil bir kez öder; karşılığı, modelin oturduğu " + "bellektir.", + "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.": + "Bir LLM, whisper modelinden daha geç yüklenir ve daha çok bellekte " + "oturur. Kapalı, ilk temizlemede yüklenmesi demektir.", + "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.": + "Düşünmeye eğitilmiş bir model, aksi söylenmedikçe düşünür; bir virgül " + "için 300 token akıl yürütmek 300 token'lık bekleyiştir. Temizleme için " + "doğrusu Kapalı.", + "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.": + "OpenRouter en hızlısıdır ve kurulum istemeyen tek seçenektir. " + "llama.cpp burada, aşağıda indirilen bir modelle çalışır. Claude Code " + "ve Codex, ikinci bir anahtar olmadan zaten sahip olduğun abonelikle " + "temizler; her biri bunun için bir oturum açtığından birkaç saniye " + "daha sürer.", + "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.": + "whisper.cpp karta CUDA, ROCm ya da Vulkan üzerinden ulaşır; koştuğu " + "derleme bunlardan biriyle yapılmışsa. Hiçbiri olmadan derlenmiş bir " + "kopya, bu ne derse desin işlemcide çalışır.", + "whisper.cpp is not installed. Settings → API and models → Download.": + "whisper.cpp kurulu değil. Ayarlar → API ve modeller → İndir.", + "llama.cpp is not installed. Settings → API and models → Download.": + "llama.cpp kurulu değil. Ayarlar → API ve modeller → İndir.", + "No whisper model has been downloaded yet. Settings → API and models → " + "Download.": + "Henüz whisper modeli indirilmedi. Ayarlar → API ve modeller → İndir.", + "No local cleanup model has been downloaded yet. Settings → API and " + "models → Download.": + "Henüz yerel temizleme modeli indirilmedi. Ayarlar → API ve modeller " + "→ İndir.", + "Hugging Face did not return a model list.": + "Hugging Face model listesi döndürmedi.", + "{repo} did not return a file list.": "{repo} dosya listesi döndürmedi.", + "{repo} has no downloadable release.": + "{repo} deposunun indirilebilir bir sürümü yok.", + "{repo} {tag} has no build for this machine.": + "{repo} {tag} bu makine için derleme içermiyor.", + "{url} answered HTTP {code}.": "{url} HTTP {code} yanıtı verdi.", + "Could not reach {url}: {error}": "{url} adresine ulaşılamadı: {error}", + "Could not read the answer from {url}: {error}": + "{url} yanıtı okunamadı: {error}", + "Could not create {path}: {error}": "{path} oluşturulamadı: {error}", + "Could not download {name}: HTTP {code}": + "{name} indirilemedi: HTTP {code}", + "Could not download {name}: {error}": "{name} indirilemedi: {error}", + "Could not write {name}: {error}": "{name} yazılamadı: {error}", + "Could not unpack {name}: {error}": "{name} açılamadı: {error}", + "Could not install {name}: {error}": "{name} kurulamadı: {error}", + "Could not start {name}: {error}": "{name} başlatılamadı: {error}", + "Could not delete the model: {error}": "Model silinemedi: {error}", + "Could not replace {path}: a file in it is still open: {error}": + "{path} değiştirilemedi: içindeki bir dosya hâlâ açık: {error}", + "{name} did not start: {error}": "{name} başlamadı: {error}", + "no output": "çıktı yok", + "The download stopped early ({done} of {total}).": + "İndirme erken kesildi ({done} / {total}).", + "{name} is longer than it said it would be.": + "{name} bildirdiğinden daha uzun çıktı.", + "{name} does not match its published checksum. Nothing was installed.": + "{name} yayımlanan sağlama toplamıyla uyuşmuyor. Hiçbir şey kurulmadı.", + "{name} is published without a checksum, so there is no way to tell what " + "arrived. Nothing was installed.": + "{name} sağlama toplamı olmadan yayımlanmış; gelenin ne olduğu " + "doğrulanamaz. Hiçbir şey kurulmadı.", + "{name} was not in the download.": "{name} indirilenin içinde yoktu.", + "{name} downloaded, but the old file is held open by the running server. " + "Stop it and try again.": + "{name} indirildi ama eski dosyayı çalışan sunucu açık tutuyor. " + "Sunucuyu durdurup yeniden dene.", + "The cleanup model spent its whole reply on thinking. Set Thinking to " + "“Off”.": + "Temizleme modeli bütün yanıtını düşünmeye harcadı. Düşünme'yi " + "“Kapalı” yap.", + + # --- this pass's new messages --------------------------------------- + "Audio recorder stopped before receiving sound": + "Ses kayıt aracı veri alamadan kapandı", + "Could not write the recording: {error}": "Kayıt dosyası yazılamadı: {error}", + "Copied, but pasting failed: {error}": + "Kopyalandı ama yapıştırma başarısız: {error}", + "The recording was kept: {path}": "Kayıt saklandı: {path}", + "The recording stopped on its own; transcribing what was captured.": + "Kayıt kendi kendine durdu; yakalanan kısım yazıya dökülüyor.", + "Could not save the settings: {error}": "Ayarlar kaydedilemedi: {error}", } diff --git a/dikte/paths.py b/dikte/paths.py index fecbb1f..8f2e057 100644 --- a/dikte/paths.py +++ b/dikte/paths.py @@ -24,7 +24,7 @@ NO_WINDOW = (getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0) -def _env(var, default): +def env_path(var, default): """The directory a variable names, or the one it stands in for.""" return pathlib.Path(os.environ.get(var) or os.path.expanduser(default)) @@ -42,11 +42,28 @@ def directories(platform=None): support = pathlib.Path.home() / "Library/Application Support/Dikte" return support, support if here == "win32": - roaming = _env("APPDATA", "~/AppData/Roaming") - local = _env("LOCALAPPDATA", "~/AppData/Local") + roaming = env_path("APPDATA", "~/AppData/Roaming") + local = env_path("LOCALAPPDATA", "~/AppData/Local") return roaming / "Dikte", local / "Dikte" - return (_env("XDG_CONFIG_HOME", "~/.config") / "dikte", - _env("XDG_DATA_HOME", "~/.local/share") / "dikte") + return (env_path("XDG_CONFIG_HOME", "~/.config") / "dikte", + env_path("XDG_DATA_HOME", "~/.local/share") / "dikte") + + +def cache_dir(platform=None): + """The directory for answers worth keeping but never worth backing up. + + A third place because a cache is neither settings nor data: losing it costs + a network request, not a model or a preference, and every system sets aside + a directory for exactly that kind of file, one that backups skip and + cleanup tools may empty. Storing it with the data would ask a backup to + carry files whose whole point is that they can be thrown away. + """ + here = platform or sys.platform + if here == "darwin": + return pathlib.Path.home() / "Library/Caches/Dikte" + if here == "win32": + return env_path("LOCALAPPDATA", "~/AppData/Local") / "Dikte" / "cache" + return env_path("XDG_CACHE_HOME", "~/.cache") / "dikte" CONFIG_DIR, DATA_DIR = directories() diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 4563d1a..fe6f461 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -1,7 +1,9 @@ """Settings window.""" +import functools import os import shutil +import sys import threading from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QUrl, pyqtSignal @@ -190,10 +192,12 @@ class LocalModelBox(QGroupBox): """ _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") + # The tag says which label the numbers belong to: the program download and + # a model download can run at once, and routing by a flag read when the + # queued event lands put one job's bytes in the other's label. + _progress = pyqtSignal(str, "qint64", "qint64") _finished = pyqtSignal(str, str) _installed = pyqtSignal(str, str) @@ -241,7 +245,6 @@ class LocalModelBox(QGroupBox): 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) @@ -344,9 +347,9 @@ class LocalModelBox(QGroupBox): def work(): try: found = self._models(repo) if self._repos is not None else self._models() - self._quants.emit([("models", found)], "") + self._listed.emit([("models", found)], "") except ggml.LocalError as exc: - self._quants.emit([], str(exc)) + self._listed.emit([], str(exc)) threading.Thread(target=work, daemon=True).start() @@ -408,7 +411,9 @@ class LocalModelBox(QGroupBox): def work(): try: - ggml.install_program(self.program, on_progress=self._report) + ggml.install_program( + self.program, + on_progress=functools.partial(self._report, "program")) self._installed.emit("", "") except ggml.LocalError as exc: self._installed.emit("", str(exc)) @@ -437,8 +442,17 @@ class LocalModelBox(QGroupBox): def work(): try: - landed = ggml.download(item, self._model_path(item.name), - on_progress=self._report, + target_path = self._model_path(item.name) + # A model the running server holds open cannot be replaced on + # Windows, and the finished download would be thrown away over + # it; a re-download lets go of the server first, and the next + # run starts it again on the fresh file. + if target_path.exists(): + (ggml.whisper if self.program is ggml.WHISPER + else ggml.llm).stop() + landed = ggml.download(item, target_path, + on_progress=functools.partial( + self._report, "model"), should_stop=lambda: self._stop) self._finished.emit(item.name if landed else "", "") except ggml.LocalError as exc: @@ -446,15 +460,15 @@ class LocalModelBox(QGroupBox): threading.Thread(target=work, daemon=True).start() - def _report(self, done, total): - self._progress.emit(done, total) + def _report(self, job, done, total): + self._progress.emit(job, done, total) - def _on_progress(self, done, total): + def _on_progress(self, job, 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: + if job == "model": self.status.setText(text) else: self.program_label.setText(text) @@ -513,6 +527,15 @@ class LocalModelBox(QGroupBox): class SettingsWindow(QDialog): applied = pyqtSignal() + def _sources_once(self): + """One device listing per window build, shared by every combo. + + Three listings at open were three subprocess runs on the main thread, + which on a slow ffmpeg was most of the wait for the window.""" + if not hasattr(self, "_sources"): + self._sources = audio.list_sources() + return self._sources + _models_loaded = pyqtSignal(list, str) _transcribe_models_loaded = pyqtSignal(list, str) # Which key was tested, whether it worked, and what to write under it. @@ -634,7 +657,7 @@ class SettingsWindow(QDialog): self.mic = QComboBox() self.mic.addItem(t("Default microphone"), "") - for name, desc in audio.list_sources(): + for name, desc in self._sources_once(): self.mic.addItem(desc, name) form.addRow(t("Microphone"), self.mic) @@ -1082,7 +1105,7 @@ class SettingsWindow(QDialog): sources_form = QFormLayout(sources) self.meeting_mic = QComboBox() self.meeting_mic.addItem(t("Same as dictation"), "") - for name, desc in audio.list_sources(): + for name, desc in self._sources_once(): self.meeting_mic.addItem(desc, name) sources_form.addRow(t("Microphone"), self.meeting_mic) @@ -1593,9 +1616,20 @@ class SettingsWindow(QDialog): 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()) + # The defaults as they read NOW, kept for the save comparison: after a + # language switch the boxes still hold the old language's default, and + # comparing against the new one would store that text as a custom + # prompt shadowing every future improvement. + self._loaded_defaults = { + "cleanup": cfg.default_cleanup_prompt(), + "file": cfg.default_file_cleanup_prompt(), + "assistant": cfg.default_assistant_prompt(), + "meeting": cfg.default_meeting_prompt(), + } + self.cleanup_prompt.setPlainText( + conf["cleanup_prompt"] or self._loaded_defaults["cleanup"]) self.file_cleanup_prompt.setPlainText( - conf["file_cleanup_prompt"] or cfg.default_file_cleanup_prompt() + conf["file_cleanup_prompt"] or self._loaded_defaults["file"] ) self.transcribe_prompt.setPlainText(conf["transcribe_prompt"]) @@ -1613,7 +1647,7 @@ class SettingsWindow(QDialog): self.assistant_paste.setChecked(conf["assistant_paste"]) self.assistant_cleanup.setChecked(conf["assistant_cleanup"]) self.assistant_prompt.setPlainText( - conf["assistant_prompt"] or cfg.default_assistant_prompt() + conf["assistant_prompt"] or self._loaded_defaults["assistant"] ) self._select_data(self.meeting_mic, conf["meeting_mic_target"]) @@ -1625,10 +1659,11 @@ class SettingsWindow(QDialog): self._select_data(self.meeting_reasoning, conf["meeting_reasoning"]) self._select_data(self.meeting_language, conf["meeting_language"]) self.meeting_cleanup.setChecked(conf["meeting_cleanup"]) - self.meeting_max_minutes.setValue(max(5, int(conf["meeting_max_seconds"]) // 60)) + self._meeting_max_loaded = int(conf["meeting_max_seconds"]) + self.meeting_max_minutes.setValue(max(5, self._meeting_max_loaded // 60)) self.meeting_keep_audio.setChecked(conf["meeting_keep_audio"]) self.meeting_prompt.setPlainText( - conf["meeting_prompt"] or cfg.default_meeting_prompt() + conf["meeting_prompt"] or self._loaded_defaults["meeting"] ) self.file_timestamps.setChecked(conf["file_timestamps"]) @@ -1690,13 +1725,18 @@ class SettingsWindow(QDialog): 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. + # Store an empty prompt when it matches a default: the one it was + # loaded with, or today's (a Reset click in a session that switched + # languages fills in the latter). Both count, so switching the + # interface language keeps switching the prompt language. prompt = self.cleanup_prompt.toPlainText().strip() - conf["cleanup_prompt"] = "" if prompt == cfg.default_cleanup_prompt() else prompt + conf["cleanup_prompt"] = ("" if prompt in ( + self._loaded_defaults["cleanup"], cfg.default_cleanup_prompt()) + else prompt) file_prompt = self.file_cleanup_prompt.toPlainText().strip() - conf["file_cleanup_prompt"] = ("" if file_prompt == cfg.default_file_cleanup_prompt() - else file_prompt) + conf["file_cleanup_prompt"] = ("" if file_prompt in ( + self._loaded_defaults["file"], cfg.default_file_cleanup_prompt()) + else file_prompt) conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip() conf["assistant_provider"] = self.assistant_provider.currentData() or "claude" @@ -1723,8 +1763,9 @@ class SettingsWindow(QDialog): conf["assistant_paste"] = self.assistant_paste.isChecked() conf["assistant_cleanup"] = self.assistant_cleanup.isChecked() assistant_prompt = self.assistant_prompt.toPlainText().strip() - conf["assistant_prompt"] = ("" if assistant_prompt == cfg.default_assistant_prompt() - else assistant_prompt) + conf["assistant_prompt"] = ("" if assistant_prompt in ( + self._loaded_defaults["assistant"], cfg.default_assistant_prompt()) + else assistant_prompt) conf["meeting_mic_target"] = self.meeting_mic.currentData() or "" conf["meeting_system_target"] = self.meeting_system.currentData() or "" @@ -1736,11 +1777,16 @@ class SettingsWindow(QDialog): conf["meeting_reasoning"] = self.meeting_reasoning.currentData() or "" conf["meeting_language"] = self.meeting_language.currentData() or "" conf["meeting_cleanup"] = self.meeting_cleanup.isChecked() - conf["meeting_max_seconds"] = self.meeting_max_minutes.value() * 60 + # Only when the dial was actually turned: the box speaks whole minutes + # with a floor, and an unrelated Save must not rewrite a value the + # command line set in seconds. + if self.meeting_max_minutes.value() != max(5, self._meeting_max_loaded // 60): + conf["meeting_max_seconds"] = self.meeting_max_minutes.value() * 60 conf["meeting_keep_audio"] = self.meeting_keep_audio.isChecked() meeting_prompt = self.meeting_prompt.toPlainText().strip() - conf["meeting_prompt"] = ("" if meeting_prompt == cfg.default_meeting_prompt() - else meeting_prompt) + conf["meeting_prompt"] = ("" if meeting_prompt in ( + self._loaded_defaults["meeting"], cfg.default_meeting_prompt()) + else meeting_prompt) conf["file_timestamps"] = self.file_timestamps.isChecked() conf["file_cleanup"] = self.file_cleanup.isChecked() @@ -1754,7 +1800,16 @@ class SettingsWindow(QDialog): or hotkey.default_combo(which)) conf["evdev_hotkey"] = self.evdev_enabled.isChecked() conf["history_limit"] = self.history_limit.value() - conf.save() + try: + conf.save() + except OSError as exc: + # An antivirus or a sync tool holding the file for a beat is a + # message, not an exit: an exception out of a Qt slot takes the + # whole application down. + QMessageBox.warning(self, "Dikte", + t("Could not save the settings: {error}", + error=exc)) + return # A lowered limit should bite now, not on the next dictation. try: cfg.trim_history(conf["history_limit"]) @@ -1917,7 +1972,10 @@ class SettingsWindow(QDialog): """ self.conf["file_timestamps"] = self.file_timestamps.isChecked() self.conf["file_cleanup"] = self.file_cleanup.isChecked() - self.conf.save() + try: + self.conf.save() + except OSError as exc: + print(f"dikte: could not save the settings: {exc}", file=sys.stderr) def _run_file(self): if not getattr(self, "file_path", "") or self.transcriber.busy: @@ -2018,7 +2076,12 @@ class SettingsWindow(QDialog): QMessageBox.information(self, t("Shortcut"), message) if ok: self.conf[spec.setting] = combo - self.conf.save() + try: + self.conf.save() + except OSError as exc: + QMessageBox.warning(self, "Dikte", + t("Could not save the settings: {error}", + error=exc)) self._refresh_shortcut_status(which) def _remove_shortcut(self, which): diff --git a/dikte/vad.py b/dikte/vad.py index 082d14d..4f784ae 100644 --- a/dikte/vad.py +++ b/dikte/vad.py @@ -17,13 +17,15 @@ import unicodedata # Stock phrases the models produce when handed silence. Kept deliberately # narrow: only sentences nobody dictates on purpose in a two-second clip. +# Whisper does invent "you" and "bye" too, but people dictate both as whole +# answers, so a single word never belongs here. HALLUCINATIONS = { "altyazi mk", "altyazi m k", "altyazi", "altyazilar", "abone olmayi unutmayin", "izlediginiz icin tesekkurler", "izlediginiz icin tesekkur ederim", "izlediginiz icin tesekkur ederiz", "kanalima abone olmayi unutmayin", "altyazi mk altyazi mk", "thanks for watching", "thank you for watching", "thanks for watching!", - "please subscribe", "subscribe to my channel", "you", "bye", + "please subscribe", "subscribe to my channel", "mbc masr", "sous titres realises par la communaute damara org", "amara org community", "sous titrage st 501", } diff --git a/tests/__init__.py b/tests/__init__.py index 5b00175..094b8ec 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -17,6 +17,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") _SANDBOX = tempfile.mkdtemp(prefix="dikte-tests-") os.environ["XDG_CONFIG_HOME"] = os.path.join(_SANDBOX, "config") os.environ["XDG_DATA_HOME"] = os.path.join(_SANDBOX, "data") +os.environ["XDG_CACHE_HOME"] = os.path.join(_SANDBOX, "cache") # Home goes with them: the shortcut file, the applications directory and every # macOS path start from it rather than from an XDG variable, and a test run is # not allowed to touch the real one. diff --git a/tests/test_filetranscribe.py b/tests/test_filetranscribe.py index 2adc5ee..cab88dd 100644 --- a/tests/test_filetranscribe.py +++ b/tests/test_filetranscribe.py @@ -223,6 +223,28 @@ class ChunkSeconds(DikteTest): self.assertEqual(ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 0), 0.0) +class Ffmpeg(DikteTest): + """How the converter process is started.""" + + def test_its_output_is_read_as_utf8_whatever_the_locale_says(self): + """ffmpeg writes UTF-8; read as the locale codepage its messages + mojibake, and a byte the codepage cannot place raises from inside + communicate itself.""" + out = str(self.path("out.wav")) + with open(out, "wb") as fh: + fh.write(b"\x00") + proc = mock.Mock() + proc.communicate.return_value = ("", "") + proc.returncode = 0 + proc.poll.return_value = 0 + with mock.patch.object(ft.subprocess, "Popen", return_value=proc) as popen: + ft._ffmpeg(["-i", "in.mp4", out], out) + kwargs = popen.call_args.kwargs + self.assertTrue(kwargs["text"]) + self.assertEqual(kwargs["encoding"], "utf-8") + self.assertEqual(kwargs["errors"], "replace") + + class Chunks(DikteTest): """What each provider is handed, and in how many pieces.""" diff --git a/tests/test_hub.py b/tests/test_hub.py index 84cd8d0..1b0a630 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -3,6 +3,7 @@ import json from dikte import hub +from dikte import paths from tests.support import DikteTest, fake_urlopen, http_error, url_error RELEASE = { @@ -165,6 +166,15 @@ def os_utime(path): os.utime(path, (old, old)) +class CacheLocation(DikteTest): + """Resolved at import, like every other path constant.""" + + def test_the_cache_lives_in_the_system_cache_directory(self): + # One answer for both, the same way ggml and config share DATA_DIR: + # hub asked paths once, at import, and kept what it was told. + self.assertEqual(hub.CACHE_DIR, paths.cache_dir()) + + class CacheOnDisk(DikteTest): def setUp(self): super().setUp() diff --git a/tests/test_paths.py b/tests/test_paths.py index 8c076f4..df84a09 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -61,6 +61,34 @@ class Directories(unittest.TestCase): self.assertTrue(data_dir.as_posix().endswith("/AppData/Local/Dikte")) +class CacheDir(unittest.TestCase): + """The third place: files whose whole point is that they can be lost.""" + + def test_linux_follows_xdg(self): + with mock.patch.dict(os.environ, {"XDG_CACHE_HOME": "/k"}): + self.assertEqual(paths.cache_dir("linux").as_posix(), "/k/dikte") + + def test_linux_without_the_variable_set(self): + with mock.patch.dict(os.environ, {}, clear=True): + self.assertTrue(paths.cache_dir("linux").as_posix() + .endswith("/.cache/dikte")) + + def test_a_mac_caches_under_library_caches(self): + """Where Time Machine already knows not to look.""" + self.assertTrue(paths.cache_dir("darwin").as_posix() + .endswith("/Library/Caches/Dikte")) + + def test_windows_caches_outside_the_roaming_profile(self): + with mock.patch.dict(os.environ, {"LOCALAPPDATA": "C:/local"}): + self.assertEqual(paths.cache_dir("win32").as_posix(), + "C:/local/Dikte/cache") + + def test_windows_without_the_variable_set(self): + with mock.patch.dict(os.environ, {}, clear=True): + self.assertTrue(paths.cache_dir("win32").as_posix() + .endswith("/AppData/Local/Dikte/cache")) + + class OnePlace(unittest.TestCase): """The programs and the models go where everything else goes. diff --git a/tests/test_ui.py b/tests/test_ui.py index 6cd402c..7b9a297 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -692,12 +692,21 @@ class LocalModels(DikteTest): # 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) + box._report("model", 1_048_576, 2_489_757_856) _app.processEvents() self.assertIn("2.3 GB", box.status.text()) self.assertNotIn("-", box.status.text()) + def test_each_download_reports_into_its_own_label(self): + """The two can run at once; the tag, not a flag read later, says + which label the bytes belong to.""" + box = self.window(cfg.Config()).local_llm + box._report("program", 10, 100) + box._report("model", 20, 100) + _app.processEvents() + self.assertIn("10", box.program_label.text()) + self.assertIn("20", 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". diff --git a/tests/test_vad.py b/tests/test_vad.py index 71c6cfd..d7fb6e6 100644 --- a/tests/test_vad.py +++ b/tests/test_vad.py @@ -128,6 +128,12 @@ class Hallucinations(DikteTest): self.assertFalse(vad.looks_like_hallucination("Bugün toplantı var.", 2.0)) self.assertFalse(vad.looks_like_hallucination("Send it on Thursday.", 2.0)) + def test_a_one_word_answer_is_believed(self): + # Whisper invents both over silence, but people dictate both as whole + # answers, and losing a real answer costs more than passing a fake one. + self.assertFalse(vad.looks_like_hallucination("You.", 1.5)) + self.assertFalse(vad.looks_like_hallucination("Bye.", 1.5)) + def test_an_empty_transcript_counts_as_invented(self): self.assertTrue(vad.looks_like_hallucination(" ", 2.0)) self.assertTrue(vad.looks_like_hallucination("...", 2.0)) @@ -138,8 +144,8 @@ class Hallucinations(DikteTest): self.assertTrue(vad.looks_like_hallucination(text, 2.0)) def test_the_boundary_is_the_max_duration(self): - self.assertTrue(vad.looks_like_hallucination("you", 6.0)) - self.assertFalse(vad.looks_like_hallucination("you", 6.1)) + self.assertTrue(vad.looks_like_hallucination("thanks for watching", 6.0)) + self.assertFalse(vad.looks_like_hallucination("thanks for watching", 6.1)) if __name__ == "__main__":