Files
dikte/settings_ui.py
T
yusufipk 60c8006725 Repair misheard words, surface cleanup failures, add a restart action
The cleanup prompt now asks the model to fix words the transcriber misheard
when the context makes the intended one clear, and to leave them alone when
it does not. Speech models fail phonetically on proper nouns, and that is
exactly what context can recover.

The names you enter for the transcription hint are handed to the cleanup
model as a glossary too. Knowing the spelling is what lets it recognise
"kuber netis" as Kubernetes.

A failed cleanup used to be almost invisible: the raw transcript was pasted
and a progress line flashed by, so a rejected key looked exactly like
working dictation for days. It now leaves the indicator amber with the
reason, sends a notification, and records the error in the history. HTTP
401, 402 and 429 are reported as what they are, naming the service.

Also:
- Settings can test the OpenRouter key, not just the OpenAI one
- Tray menu and CLI gained Restart, which re-execs in place
- Defaults saved into the config by older versions are recognised by their
  fingerprint and dropped, so an untouched prompt keeps getting improvements
- The IPC socket is user-only; Qt puts it in /tmp
2026-07-25 19:51:32 +07:00

617 lines
24 KiB
Python

"""Settings window."""
import os
import threading
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QGuiApplication
from PyQt6.QtWidgets import (
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog,
QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, QSpinBox,
QTabWidget, QVBoxLayout, QWidget,
)
import api
import audio
import config as cfg
import hotkey
from filetranscribe import FileTranscriber
from i18n import t
UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")]
LANGUAGES = [
("Detect automatically", "auto"), ("Turkish", "tr"), ("English", "en"),
("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"),
]
CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"]
TRANSCRIBE_MODELS = ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"]
CLEANUP_MODELS = [
"google/gemini-3.5-flash-lite", "google/gemini-3.1-flash-lite",
"google/gemini-2.5-flash-lite", "anthropic/claude-haiku-4.5",
"openai/gpt-5-mini", "meta-llama/llama-3.3-70b-instruct",
]
PASTE_SHORTCUTS = ["ctrl+v", "ctrl+shift+v", "shift+insert"]
AUDIO_FILTER = ("*.mp3 *.wav *.m4a *.ogg *.opus *.flac *.aac *.wma "
"*.mp4 *.mkv *.webm *.mov *.avi")
class SettingsWindow(QDialog):
applied = pyqtSignal()
_models_loaded = pyqtSignal(list, str)
_test_done = pyqtSignal(bool, str)
_or_test_done = pyqtSignal(bool, str)
def __init__(self, conf, launch_command, parent=None):
super().__init__(parent)
self.conf = conf
self.launch_command = launch_command
self.transcriber = FileTranscriber(conf, self)
self.setWindowTitle(t("Dikte Settings"))
self.resize(680, 640)
tabs = QTabWidget(self)
tabs.addTab(self._general_tab(), t("General"))
tabs.addTab(self._api_tab(), t("API and models"))
tabs.addTab(self._prompt_tab(), t("Cleanup rules"))
tabs.addTab(self._file_tab(), t("Audio file"))
tabs.addTab(self._shortcut_tab(), t("Shortcut"))
tabs.addTab(self._history_tab(), t("History"))
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel
)
buttons.button(QDialogButtonBox.StandardButton.Save).setText(t("Save"))
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(t("Cancel"))
buttons.accepted.connect(self._save)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addWidget(tabs)
layout.addWidget(buttons)
self._models_loaded.connect(self._on_models_loaded)
self._test_done.connect(self._on_test_done)
self._or_test_done.connect(self._on_or_test_done)
self.transcriber.progress.connect(self._on_file_progress)
self.transcriber.finished.connect(self._on_file_finished)
self.transcriber.failed.connect(self._on_file_failed)
self._load()
# ---- tabs ----------------------------------------------------------
def _general_tab(self):
page = QWidget()
form = QFormLayout(page)
self.ui_language = QComboBox()
for label, code in UI_LANGUAGES:
self.ui_language.addItem(t(label), code)
self.ui_language.setToolTip(
t("Restart Dikte for the language change to reach every window.")
)
form.addRow(t("Interface language"), self.ui_language)
self.mic = QComboBox()
self.mic.addItem(t("Default microphone"), "")
for name, desc in audio.list_sources():
self.mic.addItem(desc, name)
form.addRow(t("Microphone"), self.mic)
self.language = QComboBox()
for label, code in LANGUAGES:
self.language.addItem(t(label), code)
form.addRow(t("Speech language"), self.language)
self.auto_paste = QCheckBox(t("Paste the text into the focused window"))
form.addRow("", self.auto_paste)
self.paste_shortcut = QComboBox()
self.paste_shortcut.addItems(PASTE_SHORTCUTS)
self.paste_shortcut.setToolTip(
t("Terminals usually want ctrl+shift+v. Change this if pasting does nothing.")
)
form.addRow(t("Paste key"), self.paste_shortcut)
self.restore_clipboard = QCheckBox(t("Restore the previous clipboard after pasting"))
form.addRow("", self.restore_clipboard)
self.corner = QComboBox()
for value in CORNERS:
self.corner.addItem(t(value), value)
form.addRow(t("Indicator corner"), self.corner)
self.max_seconds = QSpinBox()
self.max_seconds.setRange(10, 3600)
self.max_seconds.setSuffix(t(" s"))
form.addRow(t("Longest recording"), self.max_seconds)
self.skip_silent = QCheckBox(t("Skip silent recordings (don't call the API)"))
form.addRow("", self.skip_silent)
self.silence_db = QSpinBox()
self.silence_db.setRange(-80, -20)
self.silence_db.setSuffix(" dB")
self.silence_db.setToolTip(t(
"Speech also has to rise {margin} dB above the recording's own noise "
"floor, so this absolute floor rarely needs touching. Lower it if quiet "
"speech gets dropped; raise it if noise still gets through.",
margin=10,
))
form.addRow(t("Silence threshold"), self.silence_db)
self.filter_hallucinations = QCheckBox(
t("Discard stock phrases models invent for near-silent audio")
)
self.filter_hallucinations.setToolTip(
t("Whisper answers silence with things like “Thanks for watching”.")
)
form.addRow("", self.filter_hallucinations)
self.keep_audio = QCheckBox(t("Keep audio files (~/.local/share/dikte/recordings)"))
form.addRow("", self.keep_audio)
return page
def _api_tab(self):
page = QWidget()
outer = QVBoxLayout(page)
oai = QGroupBox(t("OpenAI: speech to text"))
oai_form = QFormLayout(oai)
self.openai_key = QLineEdit()
self.openai_key.setEchoMode(QLineEdit.EchoMode.Password)
self.openai_key.setPlaceholderText(t("sk-… (falls back to OPENAI_API_KEY)"))
oai_form.addRow(t("API key"), self.openai_key)
self.transcribe_model = QComboBox()
self.transcribe_model.setEditable(True)
self.transcribe_model.addItems(TRANSCRIBE_MODELS)
oai_form.addRow(t("Model"), self.transcribe_model)
self.test_button = QPushButton(t("Test key"))
self.test_button.clicked.connect(self._test_openai)
self.test_label = QLabel("")
self.test_label.setWordWrap(True)
row = QHBoxLayout()
row.addWidget(self.test_button)
row.addWidget(self.test_label, 1)
oai_form.addRow("", self._wrap(row))
outer.addWidget(oai)
orr = QGroupBox(t("OpenRouter: transcript cleanup"))
orr_form = QFormLayout(orr)
self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model"))
orr_form.addRow("", self.cleanup_enabled)
self.openrouter_key = QLineEdit()
self.openrouter_key.setEchoMode(QLineEdit.EchoMode.Password)
self.openrouter_key.setPlaceholderText(t("sk-or-… (falls back to OPENROUTER_API_KEY)"))
orr_form.addRow(t("API key"), self.openrouter_key)
self.cleanup_model = QComboBox()
self.cleanup_model.setEditable(True)
self.cleanup_model.addItems(CLEANUP_MODELS)
self.refresh_models = QPushButton(t("Fetch model list"))
self.refresh_models.clicked.connect(self._load_models)
model_row = QHBoxLayout()
model_row.addWidget(self.cleanup_model, 1)
model_row.addWidget(self.refresh_models)
orr_form.addRow(t("Model"), self._wrap(model_row))
self.or_test_button = QPushButton(t("Test key"))
self.or_test_button.clicked.connect(self._test_openrouter)
self.models_label = QLabel("")
self.models_label.setWordWrap(True)
test_row = QHBoxLayout()
test_row.addWidget(self.or_test_button)
test_row.addWidget(self.models_label, 1)
orr_form.addRow("", self._wrap(test_row))
outer.addWidget(orr)
outer.addStretch(1)
return page
def _prompt_tab(self):
page = QWidget()
layout = QVBoxLayout(page)
intro = QLabel(t("System instruction given to the cleanup model. This is where "
"you decide how much it may touch your words."))
intro.setWordWrap(True)
layout.addWidget(intro)
self.cleanup_prompt = QPlainTextEdit()
layout.addWidget(self.cleanup_prompt, 1)
reset = QPushButton(t("Reset to default"))
reset.clicked.connect(
lambda: self.cleanup_prompt.setPlainText(cfg.default_cleanup_prompt())
)
layout.addWidget(reset, 0, Qt.AlignmentFlag.AlignRight)
hint = QLabel(t("Names and terms you say often (optional). They go to the "
"transcription model as a hint, and to the cleanup model as a "
"glossary, so it can repair the ones that still come out wrong."))
hint.setWordWrap(True)
layout.addWidget(hint)
self.transcribe_prompt = QPlainTextEdit()
self.transcribe_prompt.setMaximumHeight(90)
layout.addWidget(self.transcribe_prompt)
return page
def _file_tab(self):
page = QWidget()
layout = QVBoxLayout(page)
intro = QLabel(t("Transcribe an existing audio or video file with the same models."))
intro.setWordWrap(True)
layout.addWidget(intro)
pick = QPushButton(t("Choose file…"))
pick.clicked.connect(self._choose_file)
self.file_label = QLabel(t("No file selected"))
self.file_label.setWordWrap(True)
row = QHBoxLayout()
row.addWidget(pick)
row.addWidget(self.file_label, 1)
layout.addLayout(row)
self.file_timestamps = QCheckBox(t("Add timestamps"))
self.file_timestamps.setToolTip(
t("Prefixes every segment with [mm:ss]. Uses whisper-1, the only model "
"that returns segment times.")
)
layout.addWidget(self.file_timestamps)
self.file_cleanup = QCheckBox(t("Run the cleanup model afterwards"))
layout.addWidget(self.file_cleanup)
self.file_run = QPushButton(t("Transcribe"))
self.file_run.clicked.connect(self._run_file)
self.file_stop = QPushButton(t("Stop"))
self.file_stop.clicked.connect(self.transcriber.stop)
self.file_stop.setEnabled(False)
run_row = QHBoxLayout()
run_row.addWidget(self.file_run)
run_row.addWidget(self.file_stop)
run_row.addStretch(1)
layout.addLayout(run_row)
self.file_status = QLabel("")
self.file_status.setWordWrap(True)
layout.addWidget(self.file_status)
self.file_output = QPlainTextEdit()
self.file_output.setPlaceholderText("…")
layout.addWidget(self.file_output, 1)
copy = QPushButton(t("Copy"))
copy.clicked.connect(
lambda: QGuiApplication.clipboard().setText(self.file_output.toPlainText())
)
save = QPushButton(t("Save as .txt"))
save.clicked.connect(self._save_transcript)
out_row = QHBoxLayout()
out_row.addWidget(copy)
out_row.addWidget(save)
out_row.addStretch(1)
layout.addLayout(out_row)
return page
def _shortcut_tab(self):
page = QWidget()
layout = QVBoxLayout(page)
form = QFormLayout()
self.shortcut = QLineEdit()
self.shortcut.setPlaceholderText("Ctrl+Space")
form.addRow(t("Shortcut"), self.shortcut)
layout.addLayout(form)
install = QPushButton(t("Install as a KDE shortcut"))
install.clicked.connect(self._install_shortcut)
remove = QPushButton(t("Remove"))
remove.clicked.connect(self._remove_shortcut)
row = QHBoxLayout()
row.addWidget(install)
row.addWidget(remove)
row.addStretch(1)
layout.addLayout(row)
self.shortcut_status = QLabel("")
self.shortcut_status.setWordWrap(True)
layout.addWidget(self.shortcut_status)
self.evdev_enabled = QCheckBox(t(
"Use the built-in listener (/dev/input), for when the KDE shortcut is "
"not active yet"
))
self.evdev_enabled.setToolTip(t(
"Works immediately, no session restart. The only difference: the key "
"combination also reaches the focused application."
))
layout.addWidget(self.evdev_enabled)
note = QLabel(t(
"KWin only reads shortcut settings at startup. After 'Install' the "
"shortcut shows up under System Settings → Shortcuts, but it will not "
"fire until you log out and back in. Until then, use the built-in listener."
))
note.setWordWrap(True)
layout.addWidget(note)
layout.addStretch(1)
return page
def _history_tab(self):
page = QWidget()
layout = QVBoxLayout(page)
self.history = QListWidget()
self.history.setWordWrap(True)
layout.addWidget(self.history, 1)
copy = QPushButton(t("Copy selected to clipboard"))
copy.clicked.connect(self._copy_history)
reload_ = QPushButton(t("Reload"))
reload_.clicked.connect(self._load_history)
row = QHBoxLayout()
row.addWidget(copy)
row.addWidget(reload_)
row.addStretch(1)
layout.addLayout(row)
return page
@staticmethod
def _wrap(layout):
widget = QWidget()
widget.setLayout(layout)
return widget
# ---- load / save ----------------------------------------------------
def _load(self):
conf = self.conf
self._select_data(self.ui_language, conf["ui_language"])
self._select_data(self.mic, conf["mic_target"])
self._select_data(self.language, conf["language"])
self.auto_paste.setChecked(conf["auto_paste"])
self.paste_shortcut.setCurrentText(conf["paste_shortcut"])
self.restore_clipboard.setChecked(conf["restore_clipboard"])
self._select_data(self.corner, conf["overlay_corner"])
self.max_seconds.setValue(conf["max_seconds"])
self.skip_silent.setChecked(conf["skip_silent"])
self.silence_db.setValue(int(conf["silence_db"]))
self.filter_hallucinations.setChecked(conf["filter_hallucinations"])
self.keep_audio.setChecked(conf["keep_audio"])
self.openai_key.setText(conf["openai_api_key"])
self.transcribe_model.setCurrentText(conf["transcribe_model"])
self.cleanup_enabled.setChecked(conf["cleanup_enabled"])
self.openrouter_key.setText(conf["openrouter_api_key"])
self.cleanup_model.setCurrentText(conf["cleanup_model"])
self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_prompt())
self.transcribe_prompt.setPlainText(conf["transcribe_prompt"])
self.file_timestamps.setChecked(conf["file_timestamps"])
self.file_cleanup.setChecked(conf["file_cleanup"])
self.file_path = ""
self.shortcut.setText(conf["shortcut"])
self.evdev_enabled.setChecked(conf["evdev_hotkey"])
self._refresh_shortcut_status()
self._load_history()
def _save(self):
conf = self.conf
conf["ui_language"] = self.ui_language.currentData() or "auto"
conf["mic_target"] = self.mic.currentData() or ""
conf["language"] = self.language.currentData() or "auto"
conf["auto_paste"] = self.auto_paste.isChecked()
conf["paste_shortcut"] = self.paste_shortcut.currentText().strip()
conf["restore_clipboard"] = self.restore_clipboard.isChecked()
conf["overlay_corner"] = self.corner.currentData() or "bottom-left"
conf["max_seconds"] = self.max_seconds.value()
conf["skip_silent"] = self.skip_silent.isChecked()
conf["silence_db"] = float(self.silence_db.value())
conf["filter_hallucinations"] = self.filter_hallucinations.isChecked()
conf["keep_audio"] = self.keep_audio.isChecked()
conf["openai_api_key"] = self.openai_key.text().strip()
conf["transcribe_model"] = self.transcribe_model.currentText().strip()
conf["cleanup_enabled"] = self.cleanup_enabled.isChecked()
conf["openrouter_api_key"] = self.openrouter_key.text().strip()
conf["cleanup_model"] = self.cleanup_model.currentText().strip()
# Store an empty prompt when it matches the default, so switching the
# interface language also switches the prompt language.
prompt = self.cleanup_prompt.toPlainText().strip()
conf["cleanup_prompt"] = "" if prompt == cfg.default_cleanup_prompt() else prompt
conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip()
conf["file_timestamps"] = self.file_timestamps.isChecked()
conf["file_cleanup"] = self.file_cleanup.isChecked()
conf["shortcut"] = self.shortcut.text().strip() or "Ctrl+Space"
conf["evdev_hotkey"] = self.evdev_enabled.isChecked()
conf.save()
self.applied.emit()
self.accept()
@staticmethod
def _select_data(combo, value):
index = combo.findData(value)
combo.setCurrentIndex(index if index >= 0 else 0)
# ---- api helpers -----------------------------------------------------
def _load_models(self):
self.refresh_models.setEnabled(False)
self.models_label.setText(t("Fetching model list…"))
key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
def work():
try:
self._models_loaded.emit(api.openrouter_models(key), "")
except api.ApiError as exc:
self._models_loaded.emit([], str(exc))
threading.Thread(target=work, daemon=True).start()
def _on_models_loaded(self, models, error):
self.refresh_models.setEnabled(True)
if error:
self.models_label.setText(t("Could not fetch the list: {error}", error=error))
return
current = self.cleanup_model.currentText()
self.cleanup_model.clear()
self.cleanup_model.addItems(models)
self.cleanup_model.setCurrentText(current)
self.models_label.setText(t("{count} models loaded.", count=len(models)))
def _test_openai(self):
self.test_button.setEnabled(False)
self.test_label.setText(t("Trying…"))
key = self.openai_key.text().strip() or self.conf.openai_key()
base = self.conf["openai_base_url"]
def work():
try:
models = api.openai_models(key, base)
self._test_done.emit(
True, t("Connection works. {count} audio models visible.", count=len(models))
)
except api.ApiError as exc:
self._test_done.emit(False, str(exc))
threading.Thread(target=work, daemon=True).start()
def _test_openrouter(self):
self.or_test_button.setEnabled(False)
self.models_label.setText(t("Trying…"))
key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
def work():
try:
self._or_test_done.emit(True, api.openrouter_key_status(key))
except api.ApiError as exc:
self._or_test_done.emit(False, str(exc))
threading.Thread(target=work, daemon=True).start()
def _on_or_test_done(self, ok, message):
self.or_test_button.setEnabled(True)
self.models_label.setText(("✓ " if ok else "✗ ") + message)
def _on_test_done(self, ok, message):
self.test_button.setEnabled(True)
self.test_label.setText(("✓ " if ok else "✗ ") + message)
# ---- audio file ------------------------------------------------------
def _choose_file(self):
start = self.conf["file_last_dir"] or os.path.expanduser("~")
path, _ = QFileDialog.getOpenFileName(
self, t("Select an audio file"), start,
f"{t('Audio and video files')} ({AUDIO_FILTER});;{t('All files')} (*)",
)
if not path:
return
self.file_path = path
self.file_label.setText(os.path.basename(path))
self.conf["file_last_dir"] = os.path.dirname(path)
def _run_file(self):
if not getattr(self, "file_path", "") or self.transcriber.busy:
return
self.file_output.clear()
self.file_run.setEnabled(False)
self.file_stop.setEnabled(True)
self.transcriber.start(
self.file_path,
self.file_timestamps.isChecked(),
self.file_cleanup.isChecked(),
)
def _on_file_progress(self, message):
self.file_status.setText(message)
if message == t("Stopped."):
self._file_idle()
def _on_file_finished(self, text):
self.file_output.setPlainText(text)
self.file_status.setText(t("Done: {chars} characters.", chars=len(text)))
self._file_idle()
def _on_file_failed(self, error):
self.file_status.setText(t("Failed: {error}", error=error))
self._file_idle()
def _file_idle(self):
self.file_run.setEnabled(True)
self.file_stop.setEnabled(False)
def _save_transcript(self):
text = self.file_output.toPlainText()
if not text:
return
base = os.path.splitext(os.path.basename(getattr(self, "file_path", "")))[0]
start = os.path.join(self.conf["file_last_dir"] or os.path.expanduser("~"),
f"{base or 'transcript'}.txt")
path, _ = QFileDialog.getSaveFileName(
self, t("Save transcript"), start, f"{t('Text files')} (*.txt)"
)
if not path:
return
try:
with open(path, "w", encoding="utf-8") as fh:
fh.write(text)
self.file_status.setText(t("Saved: {path}", path=path))
except OSError as exc:
self.file_status.setText(t("Failed: {error}", error=exc))
# ---- shortcut --------------------------------------------------------
def _install_shortcut(self):
combo = self.shortcut.text().strip() or "Ctrl+Space"
clashes = hotkey.conflicting_shortcuts(combo)
if clashes:
answer = QMessageBox.question(
self, t("Shortcut conflict"),
t("{shortcut} is also used by:\n\n{list}\n\nInstall anyway?",
shortcut=combo, list="\n".join(clashes[:6])),
)
if answer != QMessageBox.StandardButton.Yes:
return
ok, message = hotkey.install_kde_shortcut(combo, self.launch_command)
QMessageBox.information(self, t("Shortcut"), message)
if ok:
self.conf["shortcut"] = combo
self.conf.save()
self._refresh_shortcut_status()
def _remove_shortcut(self):
hotkey.remove_kde_shortcut()
self._refresh_shortcut_status()
def _refresh_shortcut_status(self):
current = hotkey.kde_shortcut_status()
self.shortcut_status.setText(
t("Registered in KDE: {shortcut}", shortcut=current) if current
else t("No KDE shortcut installed.")
)
# ---- history ---------------------------------------------------------
def _load_history(self):
self.history.clear()
for row in reversed(cfg.read_history(200)):
text = (row.get("text") or "").replace("\n", " ")
preview = text[:110] + ("…" if len(text) > 110 else "")
header = t("{ts} ({duration} s)",
ts=row.get("ts", ""), duration=row.get("duration", 0))
item = QListWidgetItem(f"{header}\n{preview}")
item.setData(Qt.ItemDataRole.UserRole, row.get("text", ""))
self.history.addItem(item)
def _copy_history(self):
item = self.history.currentItem()
if item:
QGuiApplication.clipboard().setText(item.data(Qt.ItemDataRole.UserRole))