mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user