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=""):
|
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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user