Merge pull request #50 from huseyin-emre-tigci/single-instance

Survive every way a dictation was being lost
This commit is contained in:
Yusuf İpek
2026-08-27 15:28:05 +03:00
committed by GitHub
33 changed files with 2341 additions and 405 deletions
+95 -32
View File
@@ -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
@@ -194,10 +196,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)
@@ -245,7 +249,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)
@@ -348,9 +351,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()
@@ -412,7 +415,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))
@@ -441,8 +446,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:
@@ -450,15 +464,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)
@@ -524,6 +538,15 @@ class SettingsWindow(QDialog):
# hears about it from here rather than waiting for its own next check.
update_found = pyqtSignal(object)
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.
@@ -650,7 +673,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)
@@ -1115,7 +1138,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)
@@ -1628,9 +1651,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"])
@@ -1648,7 +1682,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"])
@@ -1660,10 +1694,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"])
@@ -1726,13 +1761,18 @@ class SettingsWindow(QDialog):
conf["local_llm_preload"] = self.local_llm_preload.isChecked()
conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or ""
# Store an empty prompt when it matches the default, so switching the
# interface language also switches the prompt language.
# Store an empty prompt when it matches a default: the one it was
# loaded with, or today's (a Reset click in a session that switched
# languages fills in the latter). Both count, so switching the
# interface language keeps switching the prompt language.
prompt = self.cleanup_prompt.toPlainText().strip()
conf["cleanup_prompt"] = "" if prompt == cfg.default_cleanup_prompt() else prompt
conf["cleanup_prompt"] = ("" if prompt in (
self._loaded_defaults["cleanup"], cfg.default_cleanup_prompt())
else prompt)
file_prompt = self.file_cleanup_prompt.toPlainText().strip()
conf["file_cleanup_prompt"] = ("" if file_prompt == cfg.default_file_cleanup_prompt()
else file_prompt)
conf["file_cleanup_prompt"] = ("" if file_prompt in (
self._loaded_defaults["file"], cfg.default_file_cleanup_prompt())
else file_prompt)
conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip()
conf["assistant_provider"] = self.assistant_provider.currentData() or "claude"
@@ -1759,8 +1799,9 @@ class SettingsWindow(QDialog):
conf["assistant_paste"] = self.assistant_paste.isChecked()
conf["assistant_cleanup"] = self.assistant_cleanup.isChecked()
assistant_prompt = self.assistant_prompt.toPlainText().strip()
conf["assistant_prompt"] = ("" if assistant_prompt == cfg.default_assistant_prompt()
else assistant_prompt)
conf["assistant_prompt"] = ("" if assistant_prompt in (
self._loaded_defaults["assistant"], cfg.default_assistant_prompt())
else assistant_prompt)
conf["meeting_mic_target"] = self.meeting_mic.currentData() or ""
conf["meeting_system_target"] = self.meeting_system.currentData() or ""
@@ -1772,11 +1813,16 @@ class SettingsWindow(QDialog):
conf["meeting_reasoning"] = self.meeting_reasoning.currentData() or ""
conf["meeting_language"] = self.meeting_language.currentData() or ""
conf["meeting_cleanup"] = self.meeting_cleanup.isChecked()
conf["meeting_max_seconds"] = self.meeting_max_minutes.value() * 60
# Only when the dial was actually turned: the box speaks whole minutes
# with a floor, and an unrelated Save must not rewrite a value the
# command line set in seconds.
if self.meeting_max_minutes.value() != max(5, self._meeting_max_loaded // 60):
conf["meeting_max_seconds"] = self.meeting_max_minutes.value() * 60
conf["meeting_keep_audio"] = self.meeting_keep_audio.isChecked()
meeting_prompt = self.meeting_prompt.toPlainText().strip()
conf["meeting_prompt"] = ("" if meeting_prompt == cfg.default_meeting_prompt()
else meeting_prompt)
conf["meeting_prompt"] = ("" if meeting_prompt in (
self._loaded_defaults["meeting"], cfg.default_meeting_prompt())
else meeting_prompt)
conf["file_timestamps"] = self.file_timestamps.isChecked()
conf["file_cleanup"] = self.file_cleanup.isChecked()
@@ -1790,7 +1836,16 @@ class SettingsWindow(QDialog):
or hotkey.default_combo(which))
conf["evdev_hotkey"] = self.evdev_enabled.isChecked()
conf["history_limit"] = self.history_limit.value()
conf.save()
try:
conf.save()
except OSError as exc:
# An antivirus or a sync tool holding the file for a beat is a
# message, not an exit: an exception out of a Qt slot takes the
# whole application down.
QMessageBox.warning(self, "Dikte",
t("Could not save the settings: {error}",
error=exc))
return
# A lowered limit should bite now, not on the next dictation.
try:
cfg.trim_history(conf["history_limit"])
@@ -2012,7 +2067,10 @@ class SettingsWindow(QDialog):
"""
self.conf["file_timestamps"] = self.file_timestamps.isChecked()
self.conf["file_cleanup"] = self.file_cleanup.isChecked()
self.conf.save()
try:
self.conf.save()
except OSError as exc:
print(f"dikte: could not save the settings: {exc}", file=sys.stderr)
def _run_file(self):
if not getattr(self, "file_path", "") or self.transcriber.busy:
@@ -2113,7 +2171,12 @@ class SettingsWindow(QDialog):
QMessageBox.information(self, t("Shortcut"), message)
if ok:
self.conf[spec.setting] = combo
self.conf.save()
try:
self.conf.save()
except OSError as exc:
QMessageBox.warning(self, "Dikte",
t("Could not save the settings: {error}",
error=exc))
self._refresh_shortcut_status(which)
def _remove_shortcut(self, which):