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 <[email protected]>
This commit is contained in:
huseyin-emre-tigci
2026-08-22 23:17:22 +03:00
co-authored by Claude Fable 5
parent 6f79e93d53
commit e8147f49f8
14 changed files with 403 additions and 88 deletions
+55 -29
View File
@@ -133,21 +133,10 @@ def _ask_instance(opts, cmd, wait=False, **args):
def launch_gui(verb=""): def launch_gui(verb=""):
"""No instance running, so become the application itself.""" """No instance running, so become the application itself."""
args = ipc.launcher() ipc.respawn(([verb] if verb else []) + ["--gui"])
if verb: # respawn only returns on Windows, where the application was started
args.append(verb) # detached and this console process's job is over.
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) sys.exit(0)
os.execv(args[0], args)
def _not_running(opts): def _not_running(opts):
@@ -516,7 +505,7 @@ def cmd_history_clear(opts):
# --- settings --------------------------------------------------------------- # --- settings ---------------------------------------------------------------
SECRET_KEYS = ("openai_api_key", "openrouter_api_key") SECRET_KEYS = ("openai_api_key", "groq_api_key", "openrouter_api_key")
def _mask(key, value): def _mask(key, value):
@@ -839,27 +828,51 @@ def cmd_doctor(opts):
programs = {name: shutil.which(name) or "" for name in wanted if name} programs = {name: shutil.which(name) or "" for name in wanted if name}
target = conf.transcribe_target() target = conf.transcribe_target()
cleaner = cleanup.provider(conf) 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 = { checks = {
"programs": programs, "programs": programs,
"transcription": {"provider": target.provider, "model": target.model, "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, "cleanup": {"enabled": conf["cleanup_enabled"], "provider": cleaner,
"model": cleanup.model(conf), "model": cleanup.model(conf),
"key": bool(conf.openrouter_key())}, "key": bool(conf.openrouter_key()),
"ready": cleanup_ready},
"agent": {"provider": assistant.provider(conf), "agent": {"provider": assistant.provider(conf),
"directory": assistant.working_dir(conf)}, "directory": assistant.working_dir(conf)},
"running": ipc.send("status") is not None, "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'}" lines = [f"{'' if path else ''} {name:14} {path or 'not on your PATH'}"
for name, path in programs.items()] for name, path in programs.items()]
lines += [ lines += [
f"{'' if target.api_key else ''} {target.service} key, transcribing on " transcribe_line,
f"{target.model}", cleanup_line,
# Cleanup on a CLI needs no key, so what is checked is the program.
(f"{'' if conf.openrouter_key() else ''} OpenRouter key, cleaning up on "
f"{conf['cleanup_model']}") if cleaner == "openrouter" else
(f"{'' if programs[cleanup.executable(cleaner)] else ''} "
f"{cleanup.executable(cleaner)}, cleaning up on {cleanup.model(conf)}"),
f"{'' if checks['running'] else '·'} application " f"{'' if checks['running'] else '·'} application "
+ ("running" if checks["running"] else "not running"), + ("running" if checks["running"] else "not running"),
] ]
@@ -982,13 +995,14 @@ def build_parser():
transcribe.set_defaults(func=cmd_transcribe) transcribe.set_defaults(func=cmd_transcribe)
# --- meetings --------------------------------------------------------- # --- meetings ---------------------------------------------------------
for name, help_text in (("meeting", "start a meeting, or end it and write it up"), page = leaf(subs, "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", page.add_argument("--wait", action="store_true",
help="wait for the minutes to be written") help="wait for the minutes to be written")
page.add_argument("--timeout", type=float, default=0) page.add_argument("--timeout", type=float, default=0)
page.set_defaults(func=cmd_meeting) 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") meetings = leaf(subs, "meetings", "recorded meetings and their minutes")
inner = meetings.add_subparsers(dest="meetings", metavar="") inner = meetings.add_subparsers(dest="meetings", metavar="")
@@ -1007,12 +1021,14 @@ def build_parser():
delete.add_argument("which", nargs="+") delete.add_argument("which", nargs="+")
delete.set_defaults(func=cmd_meetings_delete) delete.set_defaults(func=cmd_meetings_delete)
for name, verb, help_text in (("start", "meeting-start", "start recording one"), for name, verb, help_text in (("start", "meeting-start", "start recording one"),
("stop", "meeting-stop", "end it and write it up"), ("stop", "meeting-stop", "end it and write it up")):
("cancel", "meeting-cancel", "throw the recording away")):
page = leaf(inner, name, help_text) page = leaf(inner, name, help_text)
page.add_argument("--wait", action="store_true") page.add_argument("--wait", action="store_true")
page.add_argument("--timeout", type=float, default=0) page.add_argument("--timeout", type=float, default=0)
page.set_defaults(func=cmd_meeting, verb=verb) 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 ----------------------------------------------------------
history = leaf(subs, "history", "past dictations") history = leaf(subs, "history", "past dictations")
@@ -1118,6 +1134,16 @@ def _needs_subcommand(parser):
def run(argv): def run(argv):
global _app 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() parser = build_parser()
opts = parser.parse_args(argv) opts = parser.parse_args(argv)
# No verb at all is the plain `dikte`, which means the settings window. # No verb at all is the plain `dikte`, which means the settings window.
+6 -2
View File
@@ -26,6 +26,7 @@ from PyQt6.QtCore import QObject, pyqtSignal
from . import api from . import api
from . import cleanup from . import cleanup
from . import ggml from . import ggml
from . import paths
from .i18n import t from .i18n import t
UPLOAD_LIMIT = 24 * 1024 * 1024 # the APIs take 25 MB; leave the form its room 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( proc = subprocess.Popen(
["ffmpeg", "-nostdin", "-y", *args], ["ffmpeg", "-nostdin", "-y", *args],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, # ffmpeg writes UTF-8 whatever the locale says; read as the Windows
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), # 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 # 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. # doing nothing unless the abort reaches the process itself.
+1 -3
View File
@@ -970,9 +970,7 @@ def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID):
if "=" not in line or desktop_id in section: if "=" not in line or desktop_id in section:
continue continue
key, _, value = line.partition("=") key, _, value = line.partition("=")
if shortcut.lower() in value.lower().split(","): if any(shortcut.lower() == part.strip().lower()
hits.append(f"{section}{key}")
elif any(shortcut.lower() == part.strip().lower()
for part in re.split(r"[,\t]", value)): for part in re.split(r"[,\t]", value)):
hits.append(f"{section}{key}") hits.append(f"{section}{key}")
return hits return hits
+4 -6
View File
@@ -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 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. 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 Nothing here imports the rest of Dikte apart from two leaves, the string table
knows two websites and nothing about dictation. and the path map: this module knows two websites and nothing about dictation.
""" """
import collections import collections
import json import json
import os
import pathlib
import time import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from . import paths
from .i18n import t from .i18n import t
GITHUB_API = "https://api.github.com" GITHUB_API = "https://api.github.com"
@@ -32,8 +31,7 @@ HF_API = "https://huggingface.co/api"
HF_FILES = "https://huggingface.co" HF_FILES = "https://huggingface.co"
USER_AGENT = "dikte/1.0 (+https://github.com/yusufipk/dikte)" USER_AGENT = "dikte/1.0 (+https://github.com/yusufipk/dikte)"
CACHE_DIR = (pathlib.Path(os.environ.get("XDG_CACHE_HOME") CACHE_DIR = paths.cache_dir()
or os.path.expanduser("~/.cache")) / "dikte")
# Long enough that opening the settings window twice in an evening asks nobody # 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. # anything, short enough that a model published this morning is offered today.
CACHE_TTL = 6 * 3600 CACHE_TTL = 6 * 3600
+131
View File
@@ -725,4 +725,135 @@ TR = {
"This one is being written up right now.": "Bunun tutanağı şu anda çıkarılıyor.", "This one is being written up right now.": "Bunun tutanağı şu anda çıkarılıyor.",
"Delete this meeting, its minutes and its recording?": "Delete this meeting, its minutes and its recording?":
"Bu toplantı, tutanağı ve ses kaydı silinsin mi?", "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}",
} }
+22 -5
View File
@@ -24,7 +24,7 @@ NO_WINDOW = (getattr(subprocess, "CREATE_NO_WINDOW", 0)
if sys.platform == "win32" else 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.""" """The directory a variable names, or the one it stands in for."""
return pathlib.Path(os.environ.get(var) or os.path.expanduser(default)) 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" support = pathlib.Path.home() / "Library/Application Support/Dikte"
return support, support return support, support
if here == "win32": if here == "win32":
roaming = _env("APPDATA", "~/AppData/Roaming") roaming = env_path("APPDATA", "~/AppData/Roaming")
local = _env("LOCALAPPDATA", "~/AppData/Local") local = env_path("LOCALAPPDATA", "~/AppData/Local")
return roaming / "Dikte", local / "Dikte" return roaming / "Dikte", local / "Dikte"
return (_env("XDG_CONFIG_HOME", "~/.config") / "dikte", return (env_path("XDG_CONFIG_HOME", "~/.config") / "dikte",
_env("XDG_DATA_HOME", "~/.local/share") / "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() CONFIG_DIR, DATA_DIR = directories()
+88 -25
View File
@@ -1,7 +1,9 @@
"""Settings window.""" """Settings window."""
import functools
import os import os
import shutil import shutil
import sys
import threading import threading
from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QUrl, pyqtSignal from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QUrl, pyqtSignal
@@ -190,10 +192,12 @@ class LocalModelBox(QGroupBox):
""" """
_listed = pyqtSignal(list, str) _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 # 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. # 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) _finished = pyqtSignal(str, str)
_installed = pyqtSignal(str, str) _installed = pyqtSignal(str, str)
@@ -241,7 +245,6 @@ class LocalModelBox(QGroupBox):
form.addRow(self.status) form.addRow(self.status)
self._listed.connect(self._on_listed) self._listed.connect(self._on_listed)
self._quants.connect(self._on_listed)
self._progress.connect(self._on_progress) self._progress.connect(self._on_progress)
self._finished.connect(self._on_finished) self._finished.connect(self._on_finished)
self._installed.connect(self._on_installed) self._installed.connect(self._on_installed)
@@ -344,9 +347,9 @@ class LocalModelBox(QGroupBox):
def work(): def work():
try: try:
found = self._models(repo) if self._repos is not None else self._models() 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: except ggml.LocalError as exc:
self._quants.emit([], str(exc)) self._listed.emit([], str(exc))
threading.Thread(target=work, daemon=True).start() threading.Thread(target=work, daemon=True).start()
@@ -408,7 +411,9 @@ class LocalModelBox(QGroupBox):
def work(): def work():
try: 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("", "") self._installed.emit("", "")
except ggml.LocalError as exc: except ggml.LocalError as exc:
self._installed.emit("", str(exc)) self._installed.emit("", str(exc))
@@ -437,8 +442,17 @@ class LocalModelBox(QGroupBox):
def work(): def work():
try: try:
landed = ggml.download(item, self._model_path(item.name), target_path = self._model_path(item.name)
on_progress=self._report, # 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) should_stop=lambda: self._stop)
self._finished.emit(item.name if landed else "", "") self._finished.emit(item.name if landed else "", "")
except ggml.LocalError as exc: except ggml.LocalError as exc:
@@ -446,15 +460,15 @@ class LocalModelBox(QGroupBox):
threading.Thread(target=work, daemon=True).start() threading.Thread(target=work, daemon=True).start()
def _report(self, done, total): def _report(self, job, done, total):
self._progress.emit(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 "" share = f" ({done * 100 // total}%)" if total else ""
text = t("Downloading: {done} of {total}{share}", text = t("Downloading: {done} of {total}{share}",
done=ggml.human_size(done), total=ggml.human_size(total or done), done=ggml.human_size(done), total=ggml.human_size(total or done),
share=share) share=share)
if self._downloading: if job == "model":
self.status.setText(text) self.status.setText(text)
else: else:
self.program_label.setText(text) self.program_label.setText(text)
@@ -513,6 +527,15 @@ class LocalModelBox(QGroupBox):
class SettingsWindow(QDialog): class SettingsWindow(QDialog):
applied = pyqtSignal() 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) _models_loaded = pyqtSignal(list, str)
_transcribe_models_loaded = pyqtSignal(list, str) _transcribe_models_loaded = pyqtSignal(list, str)
# Which key was tested, whether it worked, and what to write under it. # 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 = QComboBox()
self.mic.addItem(t("Default microphone"), "") 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) self.mic.addItem(desc, name)
form.addRow(t("Microphone"), self.mic) form.addRow(t("Microphone"), self.mic)
@@ -1082,7 +1105,7 @@ class SettingsWindow(QDialog):
sources_form = QFormLayout(sources) sources_form = QFormLayout(sources)
self.meeting_mic = QComboBox() self.meeting_mic = QComboBox()
self.meeting_mic.addItem(t("Same as dictation"), "") 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) self.meeting_mic.addItem(desc, name)
sources_form.addRow(t("Microphone"), self.meeting_mic) 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.local_llm_preload.setChecked(conf["local_llm_preload"])
self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"]) self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"])
self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"]) 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( 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"]) self.transcribe_prompt.setPlainText(conf["transcribe_prompt"])
@@ -1613,7 +1647,7 @@ class SettingsWindow(QDialog):
self.assistant_paste.setChecked(conf["assistant_paste"]) self.assistant_paste.setChecked(conf["assistant_paste"])
self.assistant_cleanup.setChecked(conf["assistant_cleanup"]) self.assistant_cleanup.setChecked(conf["assistant_cleanup"])
self.assistant_prompt.setPlainText( 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"]) 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_reasoning, conf["meeting_reasoning"])
self._select_data(self.meeting_language, conf["meeting_language"]) self._select_data(self.meeting_language, conf["meeting_language"])
self.meeting_cleanup.setChecked(conf["meeting_cleanup"]) 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_keep_audio.setChecked(conf["meeting_keep_audio"])
self.meeting_prompt.setPlainText( 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"]) self.file_timestamps.setChecked(conf["file_timestamps"])
@@ -1690,12 +1725,17 @@ class SettingsWindow(QDialog):
conf["local_llm_preload"] = self.local_llm_preload.isChecked() conf["local_llm_preload"] = self.local_llm_preload.isChecked()
conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or "" conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or ""
# Store an empty prompt when it matches the default, so switching the # Store an empty prompt when it matches a default: the one it was
# interface language also switches the prompt language. # 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() 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() file_prompt = self.file_cleanup_prompt.toPlainText().strip()
conf["file_cleanup_prompt"] = ("" if file_prompt == cfg.default_file_cleanup_prompt() conf["file_cleanup_prompt"] = ("" if file_prompt in (
self._loaded_defaults["file"], cfg.default_file_cleanup_prompt())
else file_prompt) else file_prompt)
conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip() conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip()
@@ -1723,7 +1763,8 @@ class SettingsWindow(QDialog):
conf["assistant_paste"] = self.assistant_paste.isChecked() conf["assistant_paste"] = self.assistant_paste.isChecked()
conf["assistant_cleanup"] = self.assistant_cleanup.isChecked() conf["assistant_cleanup"] = self.assistant_cleanup.isChecked()
assistant_prompt = self.assistant_prompt.toPlainText().strip() assistant_prompt = self.assistant_prompt.toPlainText().strip()
conf["assistant_prompt"] = ("" if assistant_prompt == cfg.default_assistant_prompt() conf["assistant_prompt"] = ("" if assistant_prompt in (
self._loaded_defaults["assistant"], cfg.default_assistant_prompt())
else assistant_prompt) else assistant_prompt)
conf["meeting_mic_target"] = self.meeting_mic.currentData() or "" conf["meeting_mic_target"] = self.meeting_mic.currentData() or ""
@@ -1736,10 +1777,15 @@ class SettingsWindow(QDialog):
conf["meeting_reasoning"] = self.meeting_reasoning.currentData() or "" conf["meeting_reasoning"] = self.meeting_reasoning.currentData() or ""
conf["meeting_language"] = self.meeting_language.currentData() or "" conf["meeting_language"] = self.meeting_language.currentData() or ""
conf["meeting_cleanup"] = self.meeting_cleanup.isChecked() conf["meeting_cleanup"] = self.meeting_cleanup.isChecked()
# 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_max_seconds"] = self.meeting_max_minutes.value() * 60
conf["meeting_keep_audio"] = self.meeting_keep_audio.isChecked() conf["meeting_keep_audio"] = self.meeting_keep_audio.isChecked()
meeting_prompt = self.meeting_prompt.toPlainText().strip() meeting_prompt = self.meeting_prompt.toPlainText().strip()
conf["meeting_prompt"] = ("" if meeting_prompt == cfg.default_meeting_prompt() conf["meeting_prompt"] = ("" if meeting_prompt in (
self._loaded_defaults["meeting"], cfg.default_meeting_prompt())
else meeting_prompt) else meeting_prompt)
conf["file_timestamps"] = self.file_timestamps.isChecked() conf["file_timestamps"] = self.file_timestamps.isChecked()
@@ -1754,7 +1800,16 @@ class SettingsWindow(QDialog):
or hotkey.default_combo(which)) or hotkey.default_combo(which))
conf["evdev_hotkey"] = self.evdev_enabled.isChecked() conf["evdev_hotkey"] = self.evdev_enabled.isChecked()
conf["history_limit"] = self.history_limit.value() conf["history_limit"] = self.history_limit.value()
try:
conf.save() 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. # A lowered limit should bite now, not on the next dictation.
try: try:
cfg.trim_history(conf["history_limit"]) cfg.trim_history(conf["history_limit"])
@@ -1917,7 +1972,10 @@ class SettingsWindow(QDialog):
""" """
self.conf["file_timestamps"] = self.file_timestamps.isChecked() self.conf["file_timestamps"] = self.file_timestamps.isChecked()
self.conf["file_cleanup"] = self.file_cleanup.isChecked() self.conf["file_cleanup"] = self.file_cleanup.isChecked()
try:
self.conf.save() self.conf.save()
except OSError as exc:
print(f"dikte: could not save the settings: {exc}", file=sys.stderr)
def _run_file(self): def _run_file(self):
if not getattr(self, "file_path", "") or self.transcriber.busy: if not getattr(self, "file_path", "") or self.transcriber.busy:
@@ -2018,7 +2076,12 @@ class SettingsWindow(QDialog):
QMessageBox.information(self, t("Shortcut"), message) QMessageBox.information(self, t("Shortcut"), message)
if ok: if ok:
self.conf[spec.setting] = combo self.conf[spec.setting] = combo
try:
self.conf.save() 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) self._refresh_shortcut_status(which)
def _remove_shortcut(self, which): def _remove_shortcut(self, which):
+3 -1
View File
@@ -17,13 +17,15 @@ import unicodedata
# Stock phrases the models produce when handed silence. Kept deliberately # Stock phrases the models produce when handed silence. Kept deliberately
# narrow: only sentences nobody dictates on purpose in a two-second clip. # 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 = { HALLUCINATIONS = {
"altyazi mk", "altyazi m k", "altyazi", "altyazilar", "altyazi mk", "altyazi m k", "altyazi", "altyazilar",
"abone olmayi unutmayin", "izlediginiz icin tesekkurler", "abone olmayi unutmayin", "izlediginiz icin tesekkurler",
"izlediginiz icin tesekkur ederim", "izlediginiz icin tesekkur ederiz", "izlediginiz icin tesekkur ederim", "izlediginiz icin tesekkur ederiz",
"kanalima abone olmayi unutmayin", "altyazi mk altyazi mk", "kanalima abone olmayi unutmayin", "altyazi mk altyazi mk",
"thanks for watching", "thank you for watching", "thanks for watching!", "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", "mbc masr", "sous titres realises par la communaute damara org",
"amara org community", "sous titrage st 501", "amara org community", "sous titrage st 501",
} }
+1
View File
@@ -17,6 +17,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
_SANDBOX = tempfile.mkdtemp(prefix="dikte-tests-") _SANDBOX = tempfile.mkdtemp(prefix="dikte-tests-")
os.environ["XDG_CONFIG_HOME"] = os.path.join(_SANDBOX, "config") os.environ["XDG_CONFIG_HOME"] = os.path.join(_SANDBOX, "config")
os.environ["XDG_DATA_HOME"] = os.path.join(_SANDBOX, "data") 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 # 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 # macOS path start from it rather than from an XDG variable, and a test run is
# not allowed to touch the real one. # not allowed to touch the real one.
+22
View File
@@ -223,6 +223,28 @@ class ChunkSeconds(DikteTest):
self.assertEqual(ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 0), 0.0) 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): class Chunks(DikteTest):
"""What each provider is handed, and in how many pieces.""" """What each provider is handed, and in how many pieces."""
+10
View File
@@ -3,6 +3,7 @@
import json import json
from dikte import hub from dikte import hub
from dikte import paths
from tests.support import DikteTest, fake_urlopen, http_error, url_error from tests.support import DikteTest, fake_urlopen, http_error, url_error
RELEASE = { RELEASE = {
@@ -165,6 +166,15 @@ def os_utime(path):
os.utime(path, (old, old)) 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): class CacheOnDisk(DikteTest):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
+28
View File
@@ -61,6 +61,34 @@ class Directories(unittest.TestCase):
self.assertTrue(data_dir.as_posix().endswith("/AppData/Local/Dikte")) 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): class OnePlace(unittest.TestCase):
"""The programs and the models go where everything else goes. """The programs and the models go where everything else goes.
+11 -2
View File
@@ -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 # 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%". # it: the count came out the far side negative, at "-1%".
box = self.window(cfg.Config()).local_llm box = self.window(cfg.Config()).local_llm
box._downloading = True box._report("model", 1_048_576, 2_489_757_856)
box._report(1_048_576, 2_489_757_856)
_app.processEvents() _app.processEvents()
self.assertIn("2.3 GB", box.status.text()) self.assertIn("2.3 GB", box.status.text())
self.assertNotIn("-", 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): 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 # 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". # not fit, in the middle: "ggml-org/Qwen....7B-Base-GGUF".
+8 -2
View File
@@ -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("Bugün toplantı var.", 2.0))
self.assertFalse(vad.looks_like_hallucination("Send it on Thursday.", 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): 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))
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)) self.assertTrue(vad.looks_like_hallucination(text, 2.0))
def test_the_boundary_is_the_max_duration(self): def test_the_boundary_is_the_max_duration(self):
self.assertTrue(vad.looks_like_hallucination("you", 6.0)) self.assertTrue(vad.looks_like_hallucination("thanks for watching", 6.0))
self.assertFalse(vad.looks_like_hallucination("you", 6.1)) self.assertFalse(vad.looks_like_hallucination("thanks for watching", 6.1))
if __name__ == "__main__": if __name__ == "__main__":