mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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
617 lines
24 KiB
Python
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))
|