mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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:
co-authored by
Claude Fable 5
parent
6f79e93d53
commit
e8147f49f8
+55
-29
@@ -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,
|
||||
)
|
||||
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)
|
||||
os.execv(args[0], args)
|
||||
|
||||
|
||||
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 = 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-3
@@ -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()
|
||||
if any(shortcut.lower() == part.strip().lower()
|
||||
for part in re.split(r"[,\t]", value)):
|
||||
hits.append(f"{section} → {key}")
|
||||
return hits
|
||||
|
||||
+4
-6
@@ -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
|
||||
|
||||
+131
@@ -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}",
|
||||
}
|
||||
|
||||
+22
-5
@@ -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()
|
||||
|
||||
+88
-25
@@ -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,12 +1725,17 @@ 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()
|
||||
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()
|
||||
|
||||
@@ -1723,7 +1763,8 @@ 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()
|
||||
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 ""
|
||||
@@ -1736,10 +1777,15 @@ 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()
|
||||
# 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()
|
||||
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()
|
||||
@@ -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()
|
||||
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()
|
||||
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
|
||||
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):
|
||||
|
||||
+3
-1
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+11
-2
@@ -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".
|
||||
|
||||
+8
-2
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user