Merge master: the shortcut table, local models, and a Mac still in them

Four files disagreed, and all four the same way: master had turned things the
Mac branch wrote out by hand into one list to read from.

Shortcuts are the whole of it. master gave every binding a row in
hotkey.SHORTCUTS, so the Mac's DESKTOP_IDS is gone and CarbonHotkey reads the
desktop id off that row, which also gives the new cancel key a status line on a
Mac. Settings builds its four rows through master's _shortcut_row, and that one
now asks _install_buttons for Install and Remove, so macOS gets a combination
box and nothing to press, and everywhere else the button says the desktop's own
name. dikte.py starts the listener from the same table, on macOS whatever the
setting says: there is nothing installed for it to be a fallback to.

The rest is two imports and a paste list that lives in paste.Desktop now.
This commit is contained in:
yusufipk
2026-08-02 09:36:22 +03:00
41 changed files with 4888 additions and 470 deletions
+106 -27
View File
@@ -7,9 +7,13 @@ terminal talks to. Every verb it answers is in cli.py, which is also what runs
command line says "there is no instance to talk to, so be one".
"""
import contextlib
import json
import os
import signal
import socket
import sys
import threading
# A Wayland client cannot place a window in a screen corner, so the indicator
# is drawn through XWayland.
@@ -25,7 +29,7 @@ if sys.platform == "darwin":
os.environ.get("PATH", "")) if part
)
from PyQt6.QtCore import QTimer, QElapsedTimer # noqa: E402
from PyQt6.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402
from PyQt6.QtGui import QAction, QIcon # noqa: E402
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402
@@ -34,6 +38,7 @@ import assistant # noqa: E402
import audio # noqa: E402
import cli # noqa: E402
import config as cfg # noqa: E402
import ggml # noqa: E402
import hotkey # noqa: E402
import i18n # noqa: E402
import ipc # noqa: E402
@@ -101,6 +106,9 @@ class Dikte:
self.meeting_recorder = audio.MeetingRecorder()
self.meetings = MeetingPipeline(self.conf)
self.evdev = hotkey.listener()
# Before anything of ours is started: a server from a Dikte that was
# killed outright is still holding a model in memory.
ggml.sweep()
self.recorder.level.connect(self._on_level)
self.recorder.stopped.connect(self._on_recorded)
@@ -161,8 +169,10 @@ class Dikte:
self.ask_cancel_action.setEnabled(False)
self.menu.addAction(self.ask_cancel_action)
self.cancel_action = QAction(t("Cancel recording"), self.menu)
self.cancel_action.triggered.connect(self.cancel)
self.cancel_action = QAction(t("Discard the recording"), self.menu)
# The inner method, so that a menu click is never mistaken for the KDE
# shortcut echoing the built-in listener's press.
self.cancel_action.triggered.connect(self._cancel)
self.cancel_action.setEnabled(False)
self.menu.addAction(self.cancel_action)
self.menu.addSeparator()
@@ -315,6 +325,9 @@ class Dikte:
def toggle_meeting(self):
self._external("meeting", self._toggle_meeting)
def cancel(self):
self._external("cancel", self._cancel)
def _external(self, name, handler):
# The built-in listener sees the key press the instant it happens, so a
# toggle arriving right behind one is the KDE shortcut catching up on
@@ -335,7 +348,8 @@ class Dikte:
if timer is None:
timer = self.last_evdev[name] = QElapsedTimer()
timer.restart()
handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask}
handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask,
"cancel": self._cancel}
handlers.get(name, self._toggle)()
def _retire_listener(self):
@@ -529,7 +543,7 @@ class Dikte:
self.ask_overlay.show_busy(t("Transcribing…"))
self.recorder.stop()
def cancel(self):
def _cancel(self):
"""Throw away whichever recording is running."""
if not self.recording:
return
@@ -553,7 +567,7 @@ class Dikte:
def cancel_ask(self):
"""Call off the agent, whether it is still recording or already working."""
if self.ask_state == RECORDING:
self.cancel()
self._cancel()
elif self.ask_state == BUSY:
self.ask_overlay.show_busy(t("Stopping…"))
self.ask_pipeline.cancel()
@@ -801,10 +815,7 @@ class Dikte:
def open_settings(self):
if self.settings_window is None:
self.settings_window = SettingsWindow(
self.conf, launch_command(), meeting_command(), self.meetings,
ask_command(),
)
self.settings_window = SettingsWindow(self.conf, self.meetings)
self.settings_window.applied.connect(self._apply_settings)
self.settings_window.finished.connect(self._settings_closed)
self.settings_window.show()
@@ -815,18 +826,52 @@ class Dikte:
# Don't drop the object while its own signal is still being delivered.
QTimer.singleShot(0, lambda: setattr(self, "settings_window", None))
def _apply_local(self):
"""Pass the local settings on, and hold the models ready if asked to.
Loading a model takes a second or two for whisper and longer for an LLM.
Doing it while Dikte starts rather than on the first dictation is the
whole reason a server is kept alive instead of running the program once
per recording; the checkboxes are there for the machine whose memory is
wanted elsewhere.
"""
self.conf.apply_local()
wanted = []
if self.conf["transcribe_provider"] == "local":
if self.conf["local_preload"] and self.conf.local_whisper_ready():
wanted.append((ggml.whisper, "whisper"))
else:
ggml.whisper.stop() # give the memory back when it is not in use
if self.conf.uses_local_llm():
if self.conf["local_llm_preload"] and self.conf.local_llm_ready():
wanted.append((ggml.llm, "llama"))
else:
ggml.llm.stop()
def warm():
for server, name in wanted:
try:
server.serve()
except ggml.LocalError as exc:
# Not worth an indicator: the first dictation raises the
# same thing where the user can act on it.
print(f"dikte: {name}: {exc}", file=sys.stderr)
if wanted:
threading.Thread(target=warm, daemon=True).start()
def _apply_settings(self):
self.overlay.corner = self.conf["overlay_corner"]
self.ask_overlay.corner = self.conf["overlay_corner"]
self._apply_local()
self._build_tray()
self._refresh_tray()
# Where the desktop has no shortcut registry of its own, the listener is
# not the fallback the setting offers to turn on: it is the only way the
# keys arrive at all, so it runs whatever the setting says.
if self.conf["evdev_hotkey"] or not hotkey.installs_shortcuts():
self.evdev.start({"toggle": self.conf["shortcut"],
"ask": self.conf["assistant_shortcut"],
"meeting": self.conf["meeting_shortcut"]})
self.evdev.start({name: self.conf[spec.setting]
for name, spec in hotkey.SHORTCUTS.items()})
else:
self.evdev.stop()
@@ -850,6 +895,9 @@ class Dikte:
self.meeting_recorder.stop()
self.overlay.dismiss()
self.ask_overlay.dismiss()
# Also on the restart path, which replaces the process without ever
# reaching atexit and would otherwise leave the models in memory.
ggml.stop_all()
self.tray.hide()
@@ -865,19 +913,6 @@ def _clock(seconds):
else f"{minutes}:{secs:02d}")
def launch_command():
"""The command the KDE shortcut will run."""
return ipc.command_for("toggle")
def meeting_command():
return ipc.command_for("meeting")
def ask_command():
return ipc.command_for("ask")
def main():
argv = sys.argv[1:]
# Anything typed at a terminal is the command line's business, including
@@ -888,6 +923,41 @@ def main():
return run_app([arg for arg in argv if arg != "--gui"])
def install_signal_handlers(app):
"""Quit properly on the signals a session sends, rather than dying where we stand.
Qt spends its time blocked inside C, and a Python signal handler only runs
between bytecodes, so on its own it would not run until the next event
arrived, which for an idle tray icon may be never. set_wakeup_fd writes the
signal number to a socket instead, and a notifier turns that into an event
Qt does deliver.
Worth the trouble because of what shutdown() does: a logout sends SIGTERM,
and without this a whisper.cpp or llama.cpp server outlives the session
holding its model in memory. SIGKILL cannot be caught at all, which is what
ggml.sweep() is for.
Returns the objects it made; they have to stay alive to keep working.
"""
reader, writer = socket.socketpair()
reader.setblocking(False)
writer.setblocking(False)
signal.set_wakeup_fd(writer.fileno())
notifier = QSocketNotifier(reader.fileno(), QSocketNotifier.Type.Read)
def woken():
with contextlib.suppress(OSError):
reader.recv(64)
app.quit() # aboutToQuit runs shutdown()
notifier.activated.connect(woken)
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
# A handler that does nothing, so that the default action, stopping the
# process where it stands, is replaced by the wakeup above.
signal.signal(sig, lambda *_: None)
return reader, writer, notifier
def run_app(args):
command = args[0] if args else ""
@@ -895,6 +965,12 @@ def run_app(args):
app.setApplicationName("Dikte")
app.setDesktopFileName("dikte")
app.setQuitOnLastWindowClosed(False)
# Before Dikte is built, because building it is what may start a server, and
# a signal arriving in the middle of that would otherwise take the default
# action and leave the server behind. A signal this early lands in the
# socket and is delivered as soon as the event loop starts. Held in a name
# so that the notifier and its socket outlive this function.
signal_plumbing = install_signal_handlers(app) # noqa: F841
if not QSystemTrayIcon.isSystemTrayAvailable():
print("dikte: no system tray found, running anyway")
@@ -943,7 +1019,10 @@ def run_app(args):
# No key for the chosen transcription provider means nothing can work yet,
# so the settings window is the only useful thing to open.
if command == "settings" or not dikte.conf.transcribe_target().api_key:
# A transcription provider that cannot run yet, whether that is a missing
# API key or a model nobody has downloaded, means nothing can work, so the
# settings window is the only useful thing to open.
if command == "settings" or not dikte.conf.transcribe_ready():
dikte.open_settings()
elif command == "toggle":
QTimer.singleShot(0, dikte.toggle)