Merge master into the OpenCode Go branch

Codex now asks itself for its model list, doctor grew ready flags and a
per-provider line, and the Groq key joined the masked ones; the OpenCode
additions are folded into each. The no-CLI cleanup test follows the
fake_run to fake_cli rename.
This commit is contained in:
2026-08-27 15:51:49 +03:00
39 changed files with 3445 additions and 469 deletions
+343 -52
View File
@@ -18,6 +18,7 @@ import socket
import subprocess
import sys
import threading
import time
# A Wayland client cannot place a window in a screen corner, so the indicator
# is drawn through XWayland.
@@ -49,6 +50,7 @@ from . import hub # noqa: E402
from . import i18n # noqa: E402
from . import integrate # noqa: E402
from . import ipc # noqa: E402
from . import mac_window # noqa: E402
from . import meeting # noqa: E402
from . import trayicon # noqa: E402
from . import update # noqa: E402
@@ -116,6 +118,10 @@ class Dikte:
def __init__(self, app):
self.app = app
self.conf = cfg.Config()
# run_app hands the one-instance lock over after construction; a Dikte
# built without one (the tests) restarts without touching a lock.
self.instance_lock = None
self._registry_shortcuts = True # settled by _apply_settings below
self.state = IDLE
self.ask_state = IDLE
# Which of the two the microphone is currently serving, or None.
@@ -145,6 +151,17 @@ class Dikte:
# Which recording is the current one, so a timer set for the run that
# started it cannot stop the one that came after.
self._run_id = 0
# Dictations handed to the pipeline and not yet out of it. More than
# one is normal: the microphone is free while a transcript is being
# cleaned up, so the next dictation can already be spoken, and it then
# queues up behind the one still going.
self._transcripts_pending = 0
# The application that was in front when the recording started, which
# is where the transcript is meant to go, and the timer watching for
# the moment it has to be put back there. macOS only; see
# _give_the_front_back.
self.front_before = None
self._front_watch = None
self.overlay = Overlay(self.conf["overlay_corner"])
# The agent's indicator sits on top of the dictation one when both are
@@ -160,13 +177,20 @@ class Dikte:
# Before anything of ours is started: a server from a Dikte that was
# killed outright is still holding a model in memory.
ggml.sweep()
# The first dictation with no microphone picked would otherwise pay for
# the ffmpeg device listing on the key press itself, seconds of nothing
# happening at the least explicable moment. Warmed the way the models
# are, off the main thread; the listing caches itself.
if sys.platform == "win32" and not self.conf["mic_target"]:
threading.Thread(target=audio.list_sources, daemon=True).start()
self.recorder.level.connect(self._on_level)
self.recorder.stopped.connect(self._on_recorded)
self.recorder.died.connect(self._on_recorder_died)
self.recorder.failed.connect(self._on_recorder_error)
self.pipeline.stage.connect(self.overlay.show_busy)
self.pipeline.stage.connect(self._on_stage)
self.pipeline.finished.connect(self._on_finished)
self.pipeline.failed.connect(self._on_error)
self.pipeline.failed.connect(self._on_pipeline_failed)
self.ask_pipeline.stage.connect(self.ask_overlay.show_busy)
self.ask_pipeline.finished.connect(self._on_ask_finished)
self.ask_pipeline.failed.connect(self._on_ask_error)
@@ -184,7 +208,7 @@ class Dikte:
self.elapsed = QElapsedTimer()
self.meeting_elapsed = QElapsedTimer()
self.last_toggle = QElapsedTimer()
self.last_toggle = {} # action name -> QElapsedTimer, see _repeated
self.last_evdev = {}
self.ticker = QTimer()
self.ticker.setInterval(100)
@@ -336,13 +360,18 @@ class Dikte:
BUSY: ("Working…", "view-refresh", "Dikte: working"),
}
label, icon, tip = labels[self.state]
if self.state == BUSY:
# Still working, but the microphone is free again: the menu offers
# the next dictation rather than a wait.
label = "Start recording"
agent = assistant.display_name(self.conf)
self.toggle_action.setText(t(label))
# Free while the other one is thinking, blocked only while it is holding
# the microphone.
# Blocked only while something is holding the microphone: a transcript
# still being cleaned up queues the next dictation behind it, and the
# agent thinking never blocked it at all.
self.toggle_action.setEnabled(
self.state == RECORDING or (self.state == IDLE and not self.recording)
self.state == RECORDING or not self.recording
)
asked = i18n.name(agent, "dative")
self.ask_action.setText(
@@ -429,7 +458,10 @@ class Dikte:
# Where nothing was installed there is no shortcut to catch up, and
# retiring the listener would leave the keys with nowhere to arrive.
timer = self.last_evdev.get(name)
if (hotkey.installs_shortcuts() and self.evdev.running
# The snapshot from _apply_settings: which desktop this is cannot
# change under a running process, and asking hotkey again here costs a
# PATH scan on every key press.
if (self._registry_shortcuts and self.evdev.running
and timer is not None and timer.elapsed() < ECHO_MS):
self._retire_listener()
return
@@ -498,10 +530,6 @@ class Dikte:
def _dictation_request(self, cmd, request, reply):
before = self.state
# Only a request that said something about pasting changes it, so that
# the stop half of a `start --paste` does not undo the start half.
if "paste" in request:
self.paste_override[DICTATION] = request["paste"]
if cmd == "toggle":
self.toggle()
elif cmd == "stop":
@@ -512,13 +540,20 @@ class Dikte:
if seconds > 0 and self.state == RECORDING:
run = self._run_id
QTimer.singleShot(int(seconds * 1000), lambda: self._auto_stop(run))
# Armed only when the request actually moved this run along: a request
# that no-opped (the microphone held by the other one, or nothing to
# stop) must not leave a preference behind for some later, unrelated
# run to pick up. A stop that lands keeps changing the run it ends,
# so the stop half of a `start --paste` still does not undo the start.
if "paste" in request and self.state != before:
self.paste_override[DICTATION] = request["paste"]
self._answer(DICTATION, before, self.state, request, reply)
def _ask_request(self, request, reply):
before = self.ask_state
if "paste" in request:
self.paste_override[ASK] = request["paste"]
self.toggle_ask()
if "paste" in request and self.ask_state != before:
self.paste_override[ASK] = request["paste"]
self._answer(ASK, before, self.ask_state, request, reply)
def _meeting_request(self, cmd, request, reply):
@@ -583,40 +618,70 @@ class Dikte:
def _toggle(self):
# Two /dev/input nodes can carry the same keyboard, and a menu click can
# land on top of a key press; swallow the immediate repeat.
if self._repeated():
if self._repeated("toggle"):
return
if self.state == RECORDING:
self.stop()
elif self.state == IDLE:
else:
# BUSY does not block: the microphone is free while the last
# dictation is being cleaned up, and the next one starts now and
# waits its turn in the pipeline.
self.start()
# a request during its own BUSY is ignored; nothing queues up
def _toggle_ask(self):
if self._repeated():
if self._repeated("ask"):
return
if self.ask_state == RECORDING:
self.stop_ask()
elif self.ask_state == IDLE:
self.start_ask()
def _repeated(self):
if self.last_toggle.isValid() and self.last_toggle.elapsed() < 400:
def _repeated(self, name):
# Per action, the way last_evdev already is: the window is meant to
# swallow a duplicate delivery of the same press, not a pause landing
# right after the toggle that started the recording.
timer = self.last_toggle.get(name)
if timer is None:
timer = self.last_toggle[name] = QElapsedTimer()
if timer.isValid() and timer.elapsed() < 400:
return True
self.last_toggle.restart()
timer.restart()
return False
def _the_front(self):
"""The application a recording is about to start from, or None.
Asked before the indicator goes up rather than alongside the
microphone: putting a window on screen can take the front as well, and
once it has, the only answer left to the question is Dikte.
"""
return mac_window.frontmost_pid() if sys.platform == "darwin" else None
def start(self):
if self.state != IDLE or self.recording:
# Only a held microphone blocks: a previous dictation still being
# transcribed or cleaned up is the pipeline's business, not the
# recorder's.
if self.state == RECORDING or self.recording:
return
self.front_before = self._the_front()
self.overlay.show_recording()
self._begin_recording(DICTATION)
# A recorder that could not start has already said so, synchronously,
# and the error handler put everything back; setting RECORDING on top
# of that would strand the state machine with no signal ever coming.
# The same guard start_meeting has always had.
if not self.recorder.active:
return
self._set_state(RECORDING)
def start_ask(self):
if self.ask_state != IDLE or self.recording:
return
self.front_before = self._the_front()
self.ask_overlay.show_recording(asking=True)
self._begin_recording(ASK)
if not self.recorder.active:
return
self._set_ask_state(RECORDING)
def _begin_recording(self, owner):
@@ -627,6 +692,80 @@ class Dikte:
self.elapsed.restart()
self.ticker.start()
self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"])
self._give_the_front_back(self.front_before)
def _give_the_front_back(self, was_in_front):
"""Hand the front back to whoever had it when the recording started.
On macOS a recording goes through ffmpeg's avfoundation input, and
opening a capture session there brings the process that did it to the
front. ffmpeg is a child of Dikte with no bundle of its own, so the
system credits the move to Dikte: the window the user was typing in
loses the front, its caret stops, its title bar greys out, and the
Cmd+V at the end of the dictation has nowhere to land. Measured with a
TextEdit document in front:
press the shortcut front = TextEdit
recorder.start returns front = TextEdit
89 ms later front = Dikte
Nothing about the capture session can be asked not to do this. It is
not a window of ours and no flag reaches it. Starting ffmpeg in its own
session, and clearing __CFBundleIdentifier from its environment, were
both tried and both measured to make no difference. So it is undone
instead. The move lands a moment after the process starts rather than
during the call, hence the short watch rather than one attempt: it
gives up as soon as it has put the front back, and in any case after a
second and a half, which is longer than the microphone has ever taken
to open.
Silent off macOS, and silent when the recording started from Dikte
itself: there is nothing to give back.
"""
# One watch at a time. A second recording started before the first
# watch had finished would otherwise leave two of them running, and the
# older one would put the front back where the older recording
# started, which by then is the wrong window.
if self._front_watch is not None:
self._front_watch.stop()
self._front_watch = None
if not was_in_front or was_in_front == os.getpid():
return
deadline = time.monotonic() + 1.5
watch = QTimer(self.app)
# Ten milliseconds because the front is already gone by the time this
# notices, and every tick it waits is a tick of the user's window drawn
# inactive: at forty the title bar visibly blinks, at ten it does not.
# Two messages to AppKit per tick, for at most a second and a half.
watch.setInterval(10)
# activateWithOptions: answers whether macOS accepted the request, not
# whether the other application is already back in front. Keep the
# watch alive until that asynchronous handoff is observable; on Intel
# Macs it can take hundreds of milliseconds after the call returned.
restore_requested = False
def look():
nonlocal restore_requested
if time.monotonic() > deadline:
self._stop_watching_the_front()
return
if mac_window.is_frontmost():
if not restore_requested:
restore_requested = mac_window.activate(was_in_front)
elif restore_requested:
# The request has landed. Stop only now, rather than as soon
# as AppKit accepted it, so a delayed or failed handoff stays
# under observation until the deadline guard above.
self._stop_watching_the_front()
watch.timeout.connect(look)
self._front_watch = watch
watch.start()
def _stop_watching_the_front(self):
if self._front_watch is not None:
self._front_watch.stop()
self._front_watch = None
def stop(self):
if self.state != RECORDING:
@@ -634,7 +773,9 @@ class Dikte:
self.ticker.stop()
self._clear_pause()
self._set_state(BUSY)
self.overlay.show_busy(t("Transcribing"))
self.overlay.show_busy(t("Waiting for the one before it")
if self._transcripts_pending
else t("Transcribing…"))
self.recorder.stop()
def stop_ask(self):
@@ -654,7 +795,7 @@ class Dikte:
the phone call in the middle of a dictation never reaches the model and
the sentence around it is still one sentence.
"""
if not self.recording or self._repeated():
if not self.recording or self._repeated("pause"):
return
self.paused = not self.paused
if self.paused:
@@ -686,6 +827,8 @@ class Dikte:
self.ticker.stop()
self._clear_pause()
self.recorder.cancel()
# The preference dies with the run it was given for.
self.paste_override.pop(ASK if asking else DICTATION, None)
self.recorder_owner = None
# What goes over the socket is read by a program as often as by a
# person, so it stays in one language; only what a run itself said
@@ -697,7 +840,9 @@ class Dikte:
self._settle(ASK, dropped)
else:
self.overlay.dismiss()
self._set_state(IDLE)
# An earlier dictation may still be in the pipeline; only the
# recording was thrown away.
self._set_state(BUSY if self._transcripts_pending else IDLE)
self._settle(DICTATION, dropped)
def cancel_ask(self):
@@ -742,6 +887,12 @@ class Dikte:
return
base = meeting.new_base()
_, wav_path = cfg.meeting_paths(base)
# A meeting opens the same capture as a dictation does, and takes the
# front the same way: whoever is being recorded is in a call, and
# having their window go inactive mid-sentence is worse here than
# anywhere else. Kept as a local rather than on self: a dictation may
# already be waiting on its own note for where to paste.
was_in_front = self._the_front()
self.meeting_recorder.start(
str(wav_path),
self.conf["meeting_mic_target"] or self.conf["mic_target"],
@@ -750,6 +901,7 @@ class Dikte:
)
if not self.meeting_recorder.active:
return # start() has already said what went wrong
self._give_the_front_back(was_in_front)
self.meeting_base = base
self.meeting_elapsed.restart()
self.meeting_ticker.start()
@@ -876,31 +1028,56 @@ class Dikte:
def _on_recorded(self, wav_path, duration, rms_values):
owner, self.recorder_owner = self.recorder_owner, None
wants_paste = self.paste_override.pop(owner, None)
focus, self.front_before = self.front_before, None
if owner == ASK:
self.ask_pipeline.run(wav_path, duration, rms_values, ask=True,
paste=wants_paste)
paste=wants_paste, focus=focus)
else:
self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste)
self._transcripts_pending += 1
self.pipeline.run(wav_path, duration, rms_values,
paste=wants_paste, focus=focus)
def _on_stage(self, message):
# The corner belongs to the recording when one is on: the previous
# run's progress must not wipe the waveform mid-sentence.
if self.state != RECORDING:
self.overlay.show_busy(message)
def _transcript_settled(self, payload):
"""One run out of the pipeline; where dictation stands now.
A request that asked to wait is answered once the queue is empty: with
runs finishing in the order they were spoken, the one it stopped is the
last of them, and an earlier run's result would be the wrong answer.
"""
self._transcripts_pending -= 1
if self.state != RECORDING:
self._set_state(BUSY if self._transcripts_pending else IDLE)
if not self._transcripts_pending:
self._settle(DICTATION, payload)
def _on_finished(self, _raw, text, warning):
if warning:
# The text was still pasted, but cleanup did not run. Say so loudly:
# a rejected key otherwise looks exactly like working dictation.
self.overlay.show_warning(
t("Pasted raw, cleanup failed: {error}", error=warning.splitlines()[0])
)
if self.state != RECORDING:
self.overlay.show_warning(
t("Pasted raw, cleanup failed: {error}",
error=warning.splitlines()[0])
)
self.tray.showMessage(
t("Dikte: cleanup failed"), warning,
QSystemTrayIcon.MessageIcon.Warning, 10000,
)
else:
elif self.state != RECORDING:
# While a new recording is on, the flash is skipped: the text
# arriving where the cursor is says everything it would have.
action = t("Pasted") if self.conf["auto_paste"] else t("Copied")
self.overlay.show_done(
t("{action}: {preview}", action=action, preview=_preview(text))
)
self._set_state(IDLE)
self._settle(DICTATION, {"ok": True, "text": text, "raw": _raw,
"warning": warning})
self._transcript_settled({"ok": True, "text": text, "raw": _raw,
"warning": warning})
def _on_ask_finished(self, _raw, text, warning):
agent = assistant.display_name(self.conf)
@@ -933,12 +1110,43 @@ class Dikte:
def _on_recorder_error(self, message):
"""The microphone itself could not run, so it belongs to whoever asked."""
owner, self.recorder_owner = self.recorder_owner, None
self.paste_override.pop(owner, None)
self.ticker.stop()
(self._on_ask_error if owner == ASK else self._on_error)(message)
def _on_pipeline_failed(self, message):
"""A run the pipeline gave up on; whatever queued behind it still runs."""
if self.state == RECORDING:
# The corner belongs to the new recording; the failure still has to
# be seen somewhere.
self.tray.showMessage("Dikte", message,
QSystemTrayIcon.MessageIcon.Warning, 8000)
else:
self._report(message, self.overlay)
self._transcript_settled({"ok": False, "error": message})
def _on_recorder_died(self):
"""The capture quit under a live recording: keep what it caught.
Ended the way a key press would end it, so the captured half is
transcribed rather than thrown away, and said out loud, because the
user is still talking at a microphone nobody is reading.
"""
owner = self.recorder_owner
self.tray.showMessage(
"Dikte",
t("The recording stopped on its own; transcribing what was captured."),
QSystemTrayIcon.MessageIcon.Warning, 8000,
)
if owner == ASK and self.ask_state == RECORDING:
self.stop_ask()
elif self.state == RECORDING:
self.stop()
def _on_error(self, message):
"""The recorder or the key listener failed; no run reached the pipeline."""
self._report(message, self.overlay)
self._set_state(IDLE)
self._set_state(BUSY if self._transcripts_pending else IDLE)
self._settle(DICTATION, {"ok": False, "error": message})
def _on_ask_error(self, message):
@@ -1001,18 +1209,57 @@ class Dikte:
def open_settings(self):
if self.settings_window is None:
self.settings_window = SettingsWindow(self.conf, self.meetings)
self.settings_window.applied.connect(self._apply_settings)
self.settings_window.update_found.connect(self._found_update)
self.settings_window.finished.connect(self._settings_closed)
self._make_settings()
self.settings_window.show()
self.settings_window.raise_()
self.settings_window.activateWindow()
def _make_settings(self):
"""Build the window without showing it, so a caller that knows where
it belongs can place it first."""
self.settings_window = SettingsWindow(self.conf, self.meetings)
self.settings_window.applied.connect(self._apply_settings)
self.settings_window.language_changed.connect(self._reopen_settings)
self.settings_window.update_found.connect(self._found_update)
self.settings_window.finished.connect(self._settings_closed)
def _settings_closed(self, *_):
# Don't drop the object while its own signal is still being delivered.
QTimer.singleShot(0, lambda: setattr(self, "settings_window", None))
def _reopen_settings(self):
"""Replace the settings window, so a language change reaches it too.
A save switches the language everywhere strings are made at the moment
they are shown: the tray is rebuilt, the indicator and the message box
translate as they speak. The settings window is the one place written
once, at construction, so the window that took the new language is the
one place still showing the old one. A fresh window comes up where the
old one stood, on the same tab.
"""
old = self.settings_window
if old is None:
return
tab = old.tabs.currentIndex()
geometry = old.geometry()
# Replaced rather than merely closed: left connected, _settings_closed
# would drop the reference to the new window a moment after it is made.
old.finished.disconnect(self._settings_closed)
old.close()
# No deleteLater: a daemon thread of the old window's may still be
# running, and a closure holding self is what keeps the object alive
# until the thread is done. Dropping the reference is how the ordinary
# close path lets a window go, and it is enough here too.
self.settings_window = None
self._make_settings()
# Placed and turned to the old tab before it is shown, so the new
# window does not come up at the default size and jump.
self.settings_window.setGeometry(geometry)
self.settings_window.tabs.setCurrentIndex(tab)
self.settings_window.show()
self.settings_window.raise_()
self.settings_window.activateWindow()
def _apply_local(self):
"""Pass the local settings on, and hold the models ready if asked to.
@@ -1053,10 +1300,13 @@ class Dikte:
self._apply_local()
self._build_tray()
self._refresh_tray()
# Taken once here for _external: the answer cannot change under a
# running process, and re-deriving it there is a PATH scan per press.
self._registry_shortcuts = hotkey.installs_shortcuts()
# 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():
if self.conf["evdev_hotkey"] or not self._registry_shortcuts:
self.evdev.start({name: self.conf[spec.setting]
for name, spec in hotkey.SHORTCUTS.items()})
else:
@@ -1078,22 +1328,25 @@ class Dikte:
if self.server is not None:
self.server.close()
QLocalServer.removeServer(SERVER_NAME)
args = ipc.launcher() + ["--gui"]
if sys.platform == "win32":
# execv on Windows mangles arguments with spaces and leaves the two
# processes sharing a console; a detached start does neither.
subprocess.Popen(
args,
creationflags=(subprocess.DETACHED_PROCESS
| subprocess.CREATE_NEW_PROCESS_GROUP),
close_fds=True,
)
QApplication.instance().quit()
return
os.execv(args[0], args)
# The lock too, or the replacement would take this restart for a
# double start and hand the attention back to a process on its way out.
if self.instance_lock is not None:
self.instance_lock.unlock()
ipc.respawn(["--gui"])
# respawn only returns on Windows, where the replacement was started
# detached and this process still has to leave on its own.
QApplication.instance().quit()
def shutdown(self):
self._quitting = True
# Waiters first, while the connections still work: a `--wait` left
# unanswered reads to the terminal as an instance too old to answer,
# which points the user at a version problem that does not exist.
# In one language, like every other error that goes over the socket:
# a script reads these as often as a person does.
for kind in (DICTATION, ASK, MEETING):
self._settle(kind, {"ok": False,
"error": "the instance is shutting down"})
self.evdev.stop()
if self.recording:
self.recorder.cancel()
@@ -1216,9 +1469,44 @@ def _stay_out_of_the_dock():
pass
def _hand_over(command):
"""Give the running instance the attention this start was asking for.
A start carrying a verb forwards only that verb; a bare double start asks
for the Settings window as the sign of life the click was looking for.
Retried for a moment, because the copy that won the lock may not be
listening yet.
"""
verb = command or "settings"
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if ipc.send(verb) is not None:
return
time.sleep(0.2)
print("dikte: another copy holds the lock but never answered")
def run_app(args):
command = args[0] if args else ""
# One Dikte per user. The lock closes the simultaneous-start window two
# probes would both fall through; the probe still runs behind it, because
# an instance from before the lock existed holds only the socket. Both
# sit before the QApplication, so a second copy costs a moment and not a
# second tray icon. The lock lives in this frame, which app.exec() below
# keeps alive for exactly the process's lifetime.
lock = ipc.instance_lock()
if lock is not None and not lock.tryLock(0):
_hand_over(command)
return 0
if ipc.already_serving():
print("dikte: already running; handing it the attention")
if command:
ipc.send(command)
else:
ipc.send("settings")
return 0
app = QApplication(sys.argv)
app.setApplicationName("Dikte")
app.setDesktopFileName("dikte")
@@ -1246,6 +1534,9 @@ def run_app(args):
print("dikte: no system tray found, running anyway")
dikte = Dikte(app)
# Handed over so that restart() can let go of it before the replacement
# tries to take it.
dikte.instance_lock = lock
server = QLocalServer()
# Qt puts the socket in /tmp, so keep it to this user: commands like
+120 -40
View File
@@ -25,13 +25,17 @@ while they work.
import json
import os
import re
import shutil
import signal
import subprocess
import tempfile
import threading
import time
from . import api
from . import config as cfg
from . import paths
from .i18n import t
SESSION_FILE = cfg.DATA_DIR / "assistant.json"
@@ -117,13 +121,19 @@ def display_name(conf):
# one along costs tokens and invites an answer to the wrong question. Switching
# provider drops it too, since none of them can pick up another's thread.
def _read_row(name, max_age_seconds):
def _read_session():
"""The stored conversation row, or {} however the file fails to read."""
try:
with open(SESSION_FILE, encoding="utf-8") as fh:
row = json.load(fh)
except (OSError, json.JSONDecodeError, ValueError):
return {}
if not isinstance(row, dict) or row.get("provider") != name:
return row if isinstance(row, dict) else {}
def _read_row(name, max_age_seconds):
row = _read_session()
if row.get("provider") != name:
return {}
if max_age_seconds and time.time() - row.get("ts", 0) > max_age_seconds:
return {}
@@ -162,22 +172,13 @@ def clear_session():
def stored_provider():
"""Whose conversation is on disk, whatever the setting says now."""
try:
with open(SESSION_FILE, encoding="utf-8") as fh:
row = json.load(fh)
except (OSError, json.JSONDecodeError, ValueError):
return ""
return str(row.get("provider", "")) if isinstance(row, dict) else ""
return str(_read_session().get("provider", ""))
def session_age():
"""Seconds since the stored conversation was last used, or None."""
try:
with open(SESSION_FILE, encoding="utf-8") as fh:
row = json.load(fh)
except (OSError, json.JSONDecodeError, ValueError):
return None
if not isinstance(row, dict) or not (row.get("session") or row.get("messages")):
row = _read_session()
if not (row.get("session") or row.get("messages")):
return None
return time.time() - row.get("ts", 0)
@@ -342,6 +343,33 @@ def _codex_label(item):
return t("Using {name}", name=item_type or "a tool")
def codex_models():
"""The models Codex itself would offer right now, best first.
`codex debug models` prints the catalog the CLI's own model picker reads,
fetched from OpenAI and cached beside Codex's config, so the list is as
current as the installed Codex and there is no second list to keep up to
date here. Entries the picker hides are internal and stay hidden. A machine
without Codex, or one too old to have the command, answers with nothing and
the caller keeps its built-in list.
"""
if not shutil.which("codex"):
return []
try:
proc = subprocess.run(["codex", "debug", "models"],
capture_output=True, text=True, timeout=30)
catalog = json.loads(proc.stdout or "null")
except (OSError, subprocess.SubprocessError, ValueError):
return []
if not isinstance(catalog, dict):
return []
rows = [row for row in catalog.get("models") or []
if isinstance(row, dict) and row.get("slug")
and row.get("visibility") != "hide"]
rows.sort(key=lambda row: row.get("priority") or 0)
return [row["slug"] for row in rows]
# --- OpenRouter and OpenCode Go -------------------------------------------
def _ask_chat(name, service, prompt, conf, on_stage):
@@ -382,14 +410,24 @@ def _stream(cmd, conf, on_event, should_stop):
Returns (exit code, stderr). Raises Cancelled when the stop was asked for,
and AssistantError when the clock ran out.
"""
# stderr lands in a file rather than a pipe: nobody drains it while stdout
# is being read, and a CLI chatty enough on stderr would fill the pipe's
# buffer and wedge both of us. A file has no such limit, and is read once
# at the end, which is the only moment stderr matters.
stderr_file = tempfile.TemporaryFile()
# On POSIX the run gets its own session, so that ending it can take down
# every subprocess it started, not just the CLI itself.
grouped = {"start_new_session": True} if os.name == "posix" else {}
try:
proc = subprocess.Popen(
cmd, cwd=working_dir(conf), stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=stderr_file,
text=True, encoding="utf-8", errors="replace", bufsize=1,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
creationflags=paths.NO_WINDOW,
**grouped,
)
except OSError as exc:
stderr_file.close()
raise AssistantError(t("Could not run {binary}: {error}",
binary=cmd[0], error=exc)) from exc
@@ -418,7 +456,7 @@ def _stream(cmd, conf, on_event, should_stop):
if isinstance(event, dict):
on_event(event)
finally:
stderr = _finish(proc)
stderr = _finish(proc, stderr_file)
watchdog.join(timeout=1)
if ended["cancelled"]:
@@ -429,12 +467,31 @@ def _stream(cmd, conf, on_event, should_stop):
return proc.returncode, stderr
# Failures that a fresh session cannot cure: an exhausted quota, a signed-out
# CLI, a network that is down. A resumed run that dies with one of these is
# reported as what it is, not retried without the session, because the retry
# would fail the same way after making the user wait through a second run.
_API_TROUBLE = re.compile(
r"(?i)rate.?limit|quota|overloaded|too many requests|credit|billing|"
r"insufficient|unauthorized|forbidden|authentication|invalid.{0,8}key|"
r"log ?in|logged.?out|network|connection|ECONN|ENOTFOUND|ETIMEDOUT|"
r"\b(401|403|429|5\d\d)\b")
def _conclude(found, code, stderr, session, service):
"""Turn what the stream said into an answer, or into the reason there is none."""
if code != 0 and not found["answer"]:
if session and _session_missing(stderr):
# A resumed run that died with nothing to show is treated as the
# session being gone, whatever the wording: this code used to look for
# "session ... not found" in stderr, but a CLI update or another
# language rewords that and the recovery stops working. Retrying costs
# one clean start, and cannot loop because the retry resumes nothing.
# Recognised API trouble is the exception: it is not the session's
# fault, and the retry would only repeat it.
blame = last_line(stderr) or found["failure"] or ""
if session and not _API_TROUBLE.search(blame):
raise _SessionGone()
raise AssistantError(last_line(stderr) or found["failure"] or t(
raise AssistantError(blame or t(
"{service} exited with code {code}.", service=service, code=code))
if found["failure"] and not found["answer"]:
raise AssistantError(found["failure"])
@@ -445,14 +502,6 @@ def _conclude(found, code, stderr, session, service):
return found["answer"], found["warning"]
def _session_missing(stderr):
lowered = (stderr or "").lower()
if "session" in lowered or "thread" in lowered or "conversation" in lowered:
return any(word in lowered for word in ("not found", "no such", "unknown",
"does not exist", "no conversation"))
return False
def _watch(proc, deadline, should_stop, ended):
while proc.poll() is None:
if should_stop is not None and should_stop():
@@ -463,29 +512,60 @@ def _watch(proc, deadline, should_stop, ended):
break
time.sleep(0.25)
if ended["cancelled"] or ended["timed_out"]:
_kill(proc)
kill_tree(proc)
def _kill(proc):
def kill_tree(proc):
"""End the process and everything it started.
A CLI runs tools as subprocesses of its own, and ending only the CLI would
leave those behind, still working on a question nobody is waiting for.
Shared with cleanup, which runs the same two programs. Every failure here
is swallowed: the process being already gone is the outcome being asked for.
"""
if os.name == "nt":
# There is no process group to signal on Windows; taskkill walks the
# tree instead. The wait after it is best-effort, so a tree that will
# not die does not hang the caller on top of everything else.
subprocess.run(
["taskkill", "/T", "/F", "/PID", str(proc.pid)],
capture_output=True,
creationflags=paths.NO_WINDOW,
)
try:
proc.wait(timeout=3)
except (subprocess.TimeoutExpired, OSError):
pass
return
# The Popen was started with start_new_session=True, so the pid names a
# whole session to signal. SIGTERM first for a clean exit, SIGKILL for a
# tree that ignored it.
try:
os.killpg(proc.pid, signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
return
try:
proc.terminate()
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
except OSError:
pass
try:
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
pass
def _finish(proc):
try:
stderr = proc.stderr.read() or ""
except (OSError, ValueError):
stderr = ""
def _finish(proc, stderr_file):
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
for stream in (proc.stdout, proc.stderr):
kill_tree(proc)
# Read back what the CLI wrote to its stderr file, decoded leniently: a
# dying CLI is exactly the one likely to print something half-encoded.
try:
stderr_file.seek(0)
stderr = stderr_file.read().decode("utf-8", "replace")
except (OSError, ValueError):
stderr = ""
for stream in (proc.stdout, stderr_file):
try:
stream.close()
except OSError:
+157 -50
View File
@@ -31,11 +31,21 @@ import wave
from PyQt6.QtCore import QObject, pyqtSignal
from . import paths
from .i18n import t
# Console programs started from a windowless process would otherwise each open
# a console window of their own on Windows.
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0
# Squaring a chunk sample by sample in Python is the most expensive thing the
# level meter does, and it does it for every chunk of every recording. sumprod
# stays in C for the whole sum; it arrived in 3.12 and the floor here is 3.11,
# so the plain loop remains as the fallback. Both produce the same integer.
try:
from math import sumprod
except ImportError:
sumprod = None
# See paths.NO_WINDOW; re-exported here because this module's callers and
# tests have always read it under this name.
NO_WINDOW = paths.NO_WINDOW
RATE = 16000
CHANNELS = 1
@@ -79,12 +89,15 @@ class Recorder(QObject):
level = pyqtSignal(float) # 0.0 - 1.0, for the waveform
stopped = pyqtSignal(str, float, object) # wav path, duration (s), per-chunk RMS
died = pyqtSignal() # the capture quit mid-recording
failed = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._proc = None
self._thread = None
self._log = None
self._run = None
self._buffer = bytearray()
self._rms = []
self._cancelled = False
@@ -126,12 +139,17 @@ class Recorder(QObject):
self.failed.emit(t(sound().missing))
return
# The recorder keeps talking to stderr for as long as it runs; a pipe
# nobody drains would eventually block it, so it writes to a file.
self._drop_log()
self._log = tempfile.TemporaryFile()
try:
self._proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0,
cmd, stdout=subprocess.PIPE, stderr=self._log, bufsize=0,
creationflags=NO_WINDOW,
)
except OSError as exc:
self._drop_log()
self.failed.emit(t("Could not start recording: {error}", error=exc))
return
@@ -141,15 +159,21 @@ class Recorder(QObject):
self._stopping = False
self._paused = False
self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS)
self._thread = threading.Thread(target=self._pump, daemon=True)
# The pump is handed this run's objects rather than reading them off
# self, and a token to say whose run it still is: a pump that outlives
# its 2 s join must not touch the recording that comes after it.
self._run = object()
self._thread = threading.Thread(
target=self._pump, daemon=True,
args=(self._run, self._proc, self._proc.stdout,
self._buffer, self._rms, self._max_bytes),
)
self._thread.start()
def _pump(self):
proc = self._proc
stdout = proc.stdout
def _pump(self, run, proc, stdout, buffer, rms, max_bytes):
try:
while True:
chunk = stdout.read(CHUNK_BYTES)
chunk = _read_exact(stdout, CHUNK_BYTES)
if not chunk:
break
if self._paused:
@@ -157,33 +181,58 @@ class Recorder(QObject):
# nobody empties fills up, and the capture program blocks on
# a full one instead of waiting quietly for the resume.
continue
peak, rms = chunk_levels(chunk)
peak, chunk_rms = chunk_levels(chunk)
with self._lock:
self._buffer.extend(chunk)
self._rms.append(rms)
too_long = len(self._buffer) >= self._max_bytes
buffer.extend(chunk)
rms.append(chunk_rms)
too_long = len(buffer) >= max_bytes
if self._run is not run:
# This recording was given up on; whatever happens now
# belongs to the run that replaced it, not to this one.
return
self.level.emit(peak)
if too_long:
self._terminate()
break
except (OSError, ValueError):
pass
if self._run is not run:
return
if self._stopping or self._cancelled:
return
with self._lock:
captured = bool(buffer)
if captured:
# Sound had already arrived and nobody asked it to end: the device
# went away, or the recorder fell over mid-dictation. That has to
# be said while there is still something worth keeping.
self.died.emit()
return
# Nobody asked it to end and it captured nothing: the recorder is not
# installed properly, or the device was refused. Said out loud here,
# because stop() would otherwise report it as a recording that was too
# short, which sends the user looking in the wrong place.
with self._lock:
captured = bool(self._buffer)
if self._stopping or self._cancelled or captured:
return
try:
detail = proc.stderr.read().decode("utf-8", "replace").strip()
except (AttributeError, OSError):
detail = ""
self.failed.emit(t(
"Audio recorder stopped before receiving sound: {error}",
error=detail or f"exit code {proc.returncode}",
))
detail = self._error_tail()
# poll() first, because returncode stays None until somebody reaps the
# process, and "exit code None" answers nothing.
code = proc.poll()
if not detail and code is not None:
detail = f"exit code {code}"
if detail:
self.failed.emit(t(
"Audio recorder stopped before receiving sound: {error}",
error=detail,
))
else:
self.failed.emit(t("Audio recorder stopped before receiving sound"))
def _error_tail(self):
log = self._log
return _last_log_line(log) if log is not None else ""
def _drop_log(self):
log, self._log = self._log, None
_close_log(log)
def _terminate(self):
self._stopping = True
@@ -195,7 +244,10 @@ class Recorder(QObject):
except (subprocess.TimeoutExpired, OSError):
try:
proc.kill()
except OSError:
# Reaped even after a kill, or the child stays a zombie
# holding its slot in the process table.
proc.wait(timeout=1)
except (subprocess.TimeoutExpired, OSError):
pass
def cancel(self):
@@ -205,6 +257,8 @@ class Recorder(QObject):
self._thread.join(timeout=2)
self._thread = None
self._proc = None
self._run = None
self._drop_log()
with self._lock:
self._buffer = bytearray()
@@ -217,7 +271,11 @@ class Recorder(QObject):
self._thread.join(timeout=2)
self._thread = None
self._proc = None
self._run = None
self._drop_log()
# The same buffer object the pump was handed, harvested under the same
# lock it appends with.
with self._lock:
pcm = bytes(self._buffer)
rms = list(self._rms)
@@ -231,7 +289,13 @@ class Recorder(QObject):
self.failed.emit(t("Recording too short, speak for at least 0.3 s"))
return
path = write_wav(pcm)
try:
path = write_wav(pcm)
except (OSError, wave.Error) as exc:
# A full disk or an unwritable temp directory costs this recording
# either way; a message beats a traceback in the journal.
self.failed.emit(t("Could not write the recording: {error}", error=exc))
return
self.stopped.emit(path, frames / RATE, rms)
@@ -284,6 +348,7 @@ class MeetingRecorder(QObject):
def __init__(self, parent=None):
super().__init__(parent)
self._procs = []
self._interrupted = set()
self._thread = None
self._wav = None
self._logs = []
@@ -336,6 +401,7 @@ class MeetingRecorder(QObject):
# nobody drains would eventually block it, so it writes to a file.
self._logs = [tempfile.TemporaryFile() for _ in commands]
self._procs = []
self._interrupted = set()
for command, log in zip(commands, self._logs):
self._procs.append(subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=log, bufsize=0,
@@ -440,14 +506,20 @@ class MeetingRecorder(QObject):
try:
_interrupt(proc)
except OSError:
pass
continue
# ffmpeg reports being interrupted as a failure; stop() needs to
# know which exits were our own doing and which were real deaths.
self._interrupted.add(proc)
for proc in running:
try:
proc.wait(timeout=2)
except (subprocess.TimeoutExpired, OSError):
try:
proc.kill()
except OSError:
# Reaped even after a kill, or the child stays a zombie
# holding its slot in the process table.
proc.wait(timeout=1)
except (subprocess.TimeoutExpired, OSError):
pass
def _close_file(self):
@@ -460,24 +532,23 @@ class MeetingRecorder(QObject):
pass
def _error_tail(self):
tails = []
for log in self._logs:
try:
log.seek(0)
text = log.read().decode("utf-8", "replace").strip()
except OSError:
continue
lines = [line for line in text.splitlines() if line.strip()]
if lines:
tails.append(lines[-1])
return " | ".join(tails)
tails = [_last_log_line(log) for log in self._logs]
return " | ".join(tail for tail in tails if tail)
def _finish_process(self):
self._terminate()
if self._thread:
self._thread.join(timeout=3)
self._thread = None
codes = [proc.poll() for proc in self._procs]
codes = []
for proc in self._procs:
code = proc.poll()
# A nonzero exit from a process we interrupted ourselves is ffmpeg
# complaining about our own stop; one that had already died on its
# own keeps its code, because that one is the story.
if code and proc in self._interrupted:
code = 0
codes.append(code)
code = next((value for value in codes if value), 0)
self._procs = []
self._close_file()
@@ -534,13 +605,30 @@ class MeetingRecorder(QObject):
def _drop_log(self):
for log in self._logs:
try:
log.close()
except OSError:
pass
_close_log(log)
self._logs = []
def _last_log_line(log):
"""The last thing a recorder said before it ended, or ''."""
try:
log.seek(0)
text = log.read().decode("utf-8", "replace").strip()
except (OSError, ValueError):
return ""
lines = [line for line in text.splitlines() if line.strip()]
return lines[-1] if lines else ""
def _close_log(log):
if log is None:
return
try:
log.close()
except OSError:
pass
def chunk_levels(chunk):
"""(peak, rms) in 0..1. Peak drives the waveform, RMS drives the silence check."""
samples = array.array("h")
@@ -549,7 +637,9 @@ def chunk_levels(chunk):
return 0.0, 0.0
samples.frombytes(chunk[:usable])
peak = max(abs(min(samples)), abs(max(samples))) / 32768.0
rms = math.sqrt(sum(s * s for s in samples) / len(samples)) / 32768.0
power = (sumprod(samples, samples) if sumprod is not None
else sum(s * s for s in samples))
rms = math.sqrt(power / len(samples)) / 32768.0
return min(1.0, peak), min(1.0, rms)
@@ -638,6 +728,13 @@ MERGE_FILTER = (
)
# Whether pw-record takes --raw, asked of the binary once per process: the
# probe costs a subprocess, and the answer cannot change under a running
# application. Kept here rather than inside _pw_record_raw_option so the probe
# itself stays testable against different binaries.
_PW_RAW = None
def _pulse_record(target):
"""parec, or pw-record where PulseAudio's tools were left out.
@@ -645,6 +742,7 @@ def _pulse_record(target):
service, and its source names are the same ones shown by list_sources().
Keep pw-record as the fallback for minimal native-PipeWire installations.
"""
global _PW_RAW
if shutil.which("parec"):
cmd = [
"parec", "--record", "--raw", f"--rate={RATE}",
@@ -660,8 +758,10 @@ def _pulse_record(target):
cmd.append(f"--device={target}")
return cmd
if shutil.which("pw-record"):
if _PW_RAW is None:
_PW_RAW = _pw_record_raw_option()
cmd = [
"pw-record", *_pw_record_raw_option(), f"--rate={RATE}",
"pw-record", *_PW_RAW, f"--rate={RATE}",
f"--channels={CHANNELS}", "--format=s16",
]
if target:
@@ -682,8 +782,11 @@ def _pw_record_raw_option():
that line, so ask the installed binary which form it understands.
"""
try:
# utf-8 spelled out: subprocess otherwise decodes with the locale's
# codec, and help text through a codec it was not written in raises.
result = subprocess.run(
["pw-record", "--help"], capture_output=True, text=True, timeout=2
["pw-record", "--help"], capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=2,
)
help_text = (result.stdout or "") + (result.stderr or "")
except (subprocess.SubprocessError, OSError):
@@ -708,9 +811,12 @@ def _pactl_sources():
if not shutil.which("pactl"):
return []
try:
# utf-8 spelled out: device descriptions carry whatever alphabet the
# machine speaks, and the locale's codec is not always able to say so.
out = subprocess.run(
["pactl", "-f", "json", "list", "sources"],
capture_output=True, text=True, timeout=5, check=True,
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=5, check=True,
).stdout
return json.loads(out)
except (subprocess.SubprocessError, OSError, json.JSONDecodeError):
@@ -739,7 +845,8 @@ def _pulse_default_output():
try:
sink = subprocess.run(
["pactl", "get-default-sink"],
capture_output=True, text=True, timeout=5, check=True,
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=5, check=True,
).stdout.strip()
except (subprocess.SubprocessError, OSError):
return ""
+39 -16
View File
@@ -21,6 +21,7 @@ import tempfile
from . import api
from . import assistant
from . import ggml
from . import paths
from .i18n import t
PROVIDERS = ("openrouter", "opencode", "local", "claude", "codex")
@@ -203,21 +204,43 @@ def _output(cmd, timeout, service):
"{binary} not found. Install it, or have OpenRouter clean up "
"instead, under Settings → API and models.", binary=binary,
))
# Both streams land in files rather than pipes: nobody drains a pipe while
# the process is being waited out, and a CLI chatty enough would fill the
# buffer and wedge. And a timeout must end the CLI's tool subprocesses too,
# not just the CLI, which subprocess.run's timeout does not do; hence the
# own session on POSIX and assistant.kill_tree on the way out.
out_file = tempfile.TemporaryFile()
err_file = tempfile.TemporaryFile()
grouped = {"start_new_session": True} if os.name == "posix" else {}
try:
done = subprocess.run(
cmd, cwd=os.path.expanduser("~"), stdin=subprocess.DEVNULL,
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except subprocess.TimeoutExpired:
raise CleanupError(t("{service} did not finish within {seconds} seconds.",
service=service, seconds=timeout)) from None
except OSError as exc:
raise CleanupError(t("Could not run {binary}: {error}",
binary=binary, error=exc)) from exc
if done.returncode != 0:
raise CleanupError(assistant.last_line(done.stderr) or t(
try:
proc = subprocess.Popen(
cmd, cwd=os.path.expanduser("~"), stdin=subprocess.DEVNULL,
stdout=out_file, stderr=err_file,
creationflags=paths.NO_WINDOW,
**grouped,
)
except OSError as exc:
raise CleanupError(t("Could not run {binary}: {error}",
binary=binary, error=exc)) from exc
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
assistant.kill_tree(proc)
raise CleanupError(t("{service} did not finish within {seconds} seconds.",
service=service, seconds=timeout)) from None
out_file.seek(0)
stdout = out_file.read().decode("utf-8", "replace")
err_file.seek(0)
stderr = err_file.read().decode("utf-8", "replace")
finally:
for handle in (out_file, err_file):
try:
handle.close()
except OSError:
pass
if proc.returncode != 0:
raise CleanupError(assistant.last_line(stderr) or t(
"{service} exited with code {code}.",
service=service, code=done.returncode))
return (done.stdout or "").strip()
service=service, code=proc.returncode))
return stdout.strip()
+66 -36
View File
@@ -136,21 +136,10 @@ def _ask_instance(opts, cmd, wait=False, **args):
def launch_gui(verb=""):
"""No instance running, so become the application itself."""
args = ipc.launcher()
if verb:
args.append(verb)
args.append("--gui")
if sys.platform == "win32":
# execv on Windows mangles arguments with spaces and would leave the
# application tied to this console; start it detached instead.
subprocess.Popen(
args,
creationflags=(subprocess.DETACHED_PROCESS
| subprocess.CREATE_NEW_PROCESS_GROUP),
close_fds=True,
)
sys.exit(0)
os.execv(args[0], args)
ipc.respawn(([verb] if verb else []) + ["--gui"])
# respawn only returns on Windows, where the application was started
# detached and this console process's job is over.
sys.exit(0)
def _not_running(opts):
@@ -520,7 +509,8 @@ def cmd_history_clear(opts):
# --- settings ---------------------------------------------------------------
SECRET_KEYS = ("openai_api_key", "openrouter_api_key", "opencode_api_key")
SECRET_KEYS = ("openai_api_key", "groq_api_key", "openrouter_api_key",
"opencode_api_key")
def _mask(key, value):
@@ -878,31 +868,58 @@ def cmd_doctor(opts):
programs = {name: shutil.which(name) or "" for name in wanted if name}
target = conf.transcribe_target()
cleaner = cleanup.provider(conf)
# What each provider actually needs: the local ones have no key to check,
# and marking them by the key they do not use reported every fully local
# setup as broken.
transcribe_ready = conf.transcribe_ready()
if cleaner == "openrouter":
cleanup_ready = bool(conf.openrouter_key())
elif cleaner == "opencode":
cleanup_ready = bool(conf.opencode_key())
elif cleaner == "local":
cleanup_ready = conf.local_llm_ready()
else:
cleanup_ready = bool(programs.get(cleanup.executable(cleaner), ""))
checks = {
"programs": programs,
"transcription": {"provider": target.provider, "model": target.model,
"key": bool(target.api_key)},
"key": bool(target.api_key),
"ready": transcribe_ready},
"cleanup": {"enabled": conf["cleanup_enabled"], "provider": cleaner,
"model": cleanup.model(conf),
"key": (bool(conf.openrouter_key()) if cleaner == "openrouter"
else bool(conf.opencode_key())
if cleaner == "opencode" else None)},
if cleaner == "opencode" else None),
"ready": cleanup_ready},
"agent": {"provider": assistant.provider(conf),
"directory": assistant.working_dir(conf)},
"running": ipc.send("status") is not None,
}
if target.provider == "local":
transcribe_line = (f"{'' if transcribe_ready else ''} {target.service}, "
f"transcribing on {target.model or 'no model yet'}")
else:
transcribe_line = (f"{'' if transcribe_ready else ''} {target.service} "
f"key, transcribing on {target.model}")
if cleaner == "openrouter":
cleanup_line = (f"{'' if cleanup_ready else ''} OpenRouter key, "
f"cleaning up on {conf['cleanup_model']}")
elif cleaner == "opencode":
cleanup_line = (f"{'' if cleanup_ready else ''} OpenCode Go key, "
f"cleaning up on {conf['cleanup_opencode_model']}")
elif cleaner == "local":
cleanup_line = (f"{'' if cleanup_ready else ''} Local model, "
f"cleaning up on {conf['local_llm_model'] or 'no model yet'}")
else:
# Cleanup on a CLI needs no key, so what is checked is the program.
cleanup_line = (f"{'' if cleanup_ready else ''} "
f"{cleanup.executable(cleaner)}, cleaning up on "
f"{cleanup.model(conf)}")
lines = [f"{'' if path else ''} {name:14} {path or 'not on your PATH'}"
for name, path in programs.items()]
lines += [
f"{'' if target.api_key else ''} {target.service} key, transcribing on "
f"{target.model}",
# Cleanup on a CLI needs no key, so what is checked is the program.
(f"{'' if conf.openrouter_key() else ''} OpenRouter key, cleaning up on "
f"{conf['cleanup_model']}") if cleaner == "openrouter" else
(f"{'' if conf.opencode_key() else ''} OpenCode Go key, cleaning up on "
f"{conf['cleanup_opencode_model']}") if cleaner == "opencode" else
(f"{'' if programs[cleanup.executable(cleaner)] else ''} "
f"{cleanup.executable(cleaner)}, cleaning up on {cleanup.model(conf)}"),
transcribe_line,
cleanup_line,
f"{'' if checks['running'] else '·'} application "
+ ("running" if checks["running"] else "not running"),
]
@@ -1025,13 +1042,14 @@ def build_parser():
transcribe.set_defaults(func=cmd_transcribe)
# --- meetings ---------------------------------------------------------
for name, help_text in (("meeting", "start a meeting, or end it and write it up"),
("meeting-cancel", "")):
page = leaf(subs, name, help_text)
page.add_argument("--wait", action="store_true",
help="wait for the minutes to be written")
page.add_argument("--timeout", type=float, default=0)
page.set_defaults(func=cmd_meeting)
page = leaf(subs, "meeting", "start a meeting, or end it and write it up")
page.add_argument("--wait", action="store_true",
help="wait for the minutes to be written")
page.add_argument("--timeout", type=float, default=0)
page.set_defaults(func=cmd_meeting)
# No --wait here: a cancel is answered on the spot, and a flag the server
# would ignore is a promise the help text cannot keep.
leaf(subs, "meeting-cancel", "").set_defaults(func=cmd_meeting)
meetings = leaf(subs, "meetings", "recorded meetings and their minutes")
inner = meetings.add_subparsers(dest="meetings", metavar="")
@@ -1050,12 +1068,14 @@ def build_parser():
delete.add_argument("which", nargs="+")
delete.set_defaults(func=cmd_meetings_delete)
for name, verb, help_text in (("start", "meeting-start", "start recording one"),
("stop", "meeting-stop", "end it and write it up"),
("cancel", "meeting-cancel", "throw the recording away")):
("stop", "meeting-stop", "end it and write it up")):
page = leaf(inner, name, help_text)
page.add_argument("--wait", action="store_true")
page.add_argument("--timeout", type=float, default=0)
page.set_defaults(func=cmd_meeting, verb=verb)
# cancel takes no --wait: see the top-level meeting-cancel.
leaf(inner, "cancel", "throw the recording away").set_defaults(
func=cmd_meeting, verb="meeting-cancel")
# --- history ----------------------------------------------------------
history = leaf(subs, "history", "past dictations")
@@ -1166,6 +1186,16 @@ def _needs_subcommand(parser):
def run(argv):
global _app
# A redirected stdout on Windows falls back to the console codepage,
# strict, and a transcript (or doctor's ✓) with a character outside it
# would then fail the run after the work succeeded. Interactively nothing
# changes: the console is written through its own Unicode API.
if sys.platform == "win32" and not os.environ.get("PYTHONIOENCODING"):
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(errors="replace")
except (AttributeError, OSError):
pass
parser = build_parser()
opts = parser.parse_args(argv)
# No verb at all is the plain `dikte`, which means the settings window.
+124 -33
View File
@@ -5,6 +5,8 @@ import hashlib
import json
import os
import sys
import threading
import time
from . import api
from . import ggml
@@ -532,6 +534,30 @@ TRANSCRIBERS = {
"openrouter_base_url", "openrouter_transcribe_model"),
}
# One lock for the history file and the meeting index both, rather than one
# each: the files are a few kilobytes, the writes happen a handful of times an
# hour, and a second lock would only add a way to take them in the wrong order.
_FILES_LOCK = threading.Lock()
def _replace_with_retry(tmp, target):
"""The atomic swap, tried again briefly when the target is held.
On Windows an antivirus or sync tool opens a freshly written file to look
at it, and a rename over the file fails for as long as it is held. The
hold lasts milliseconds, so three tries with a short sleep cover it; a
file held longer than that is a real error and is raised as one.
"""
for attempt in range(3):
try:
tmp.replace(target)
return
except OSError:
if attempt == 2:
raise
time.sleep(0.05)
# Corners used to be stored with Turkish names.
_CORNER_MIGRATION = {
"sol-alt": "bottom-left", "sağ-alt": "bottom-right",
@@ -552,7 +578,19 @@ class Config:
self.data.update({k: v for k, v in stored.items() if k in DEFAULTS})
except FileNotFoundError:
pass
except (json.JSONDecodeError, OSError) as exc:
except json.JSONDecodeError as exc:
# Set aside rather than left in place: the next save would write
# the defaults over it, and whatever broke the file deserves to
# still be there to look at. Best effort; a rename that fails
# changes nothing about falling back to the defaults.
broken = CONFIG_FILE.with_suffix(".json.broken")
try:
CONFIG_FILE.replace(broken)
except OSError:
pass
print(f"dikte: could not read settings ({exc}), using defaults; "
f"the unreadable file was kept as {broken}")
except OSError as exc:
print(f"dikte: could not read settings ({exc}), using defaults")
self.data["overlay_corner"] = _CORNER_MIGRATION.get(
self.data["overlay_corner"], self.data["overlay_corner"]
@@ -567,8 +605,13 @@ class Config:
tmp = CONFIG_FILE.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(self.data, fh, ensure_ascii=False, indent=2)
# Pushed to the disk before the rename: swapping in a file that
# still lives in the page cache turns a power cut into a settings
# wipe, which the atomic replace exists to prevent.
fh.flush()
os.fsync(fh.fileno())
os.chmod(tmp, 0o600)
tmp.replace(CONFIG_FILE)
_replace_with_retry(tmp, CONFIG_FILE)
i18n.set_language(self.data["ui_language"])
def __getitem__(self, key):
@@ -737,12 +780,22 @@ def default_assistant_prompt():
def append_history(entry):
DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(HISTORY_FILE, "a", encoding="utf-8") as fh:
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
with _FILES_LOCK:
with open(HISTORY_FILE, "a", encoding="utf-8") as fh:
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
def read_history(limit=None):
"""Newest last. A limit of None (or 0) reads the whole file."""
# Locked even though the rewrites are atomic: it costs nothing, and a read
# that waits out a rewrite in flight hands back the settled file rather
# than whichever side of the swap it happened to land on.
with _FILES_LOCK:
return _read_history(limit)
def _read_history(limit=None):
"""The body of read_history, for callers already holding the lock."""
try:
with open(HISTORY_FILE, encoding="utf-8") as fh:
lines = fh.readlines()
@@ -765,40 +818,66 @@ def _write_history(lines):
tmp = HISTORY_FILE.with_suffix(".jsonl.tmp")
with open(tmp, "w", encoding="utf-8") as fh:
fh.writelines(lines)
tmp.replace(HISTORY_FILE)
fh.flush()
os.fsync(fh.fileno())
_replace_with_retry(tmp, HISTORY_FILE)
def trim_history(limit):
"""Drop the oldest entries once the file passes `limit` rows. 0 means keep all."""
if not limit or limit < 0:
return
try:
with open(HISTORY_FILE, encoding="utf-8") as fh:
lines = fh.readlines()
except OSError:
return
if len(lines) <= limit:
return
_write_history(lines[-limit:])
# Read and rewrite under one lock, so a dictation appended in between the
# two is not erased by a rewrite that never saw it.
with _FILES_LOCK:
try:
with open(HISTORY_FILE, encoding="utf-8") as fh:
lines = fh.readlines()
except OSError:
return
if len(lines) <= limit:
return
_write_history(lines[-limit:])
def _row_key(row):
return json.dumps(row, ensure_ascii=False, sort_keys=True)
def amend_history(entry, **changes):
"""Patch one entry in place, matched on its whole content like delete_history.
For the caller that learns something after its row is already written: the
row goes in before the paste is attempted, and a paste that then fails
still has to end up in the record. None when the row is gone, which a trim
in between can legitimately make true."""
wanted = _row_key(entry)
with _FILES_LOCK:
rows = _read_history()
for row in rows:
if _row_key(row) == wanted:
row.update(changes)
_write_history([json.dumps(r, ensure_ascii=False) + "\n"
for r in rows])
return row
return None
def delete_history(rows):
"""Remove the given entries, matched on their whole content rather than on a
line number: the worker may have appended a new one since the list was read."""
doomed = {_row_key(row) for row in rows}
if not doomed:
return
kept = [json.dumps(row, ensure_ascii=False) + "\n"
for row in read_history() if _row_key(row) not in doomed]
_write_history(kept)
with _FILES_LOCK:
kept = [json.dumps(row, ensure_ascii=False) + "\n"
for row in _read_history() if _row_key(row) not in doomed]
_write_history(kept)
def clear_history():
HISTORY_FILE.unlink(missing_ok=True)
with _FILES_LOCK:
HISTORY_FILE.unlink(missing_ok=True)
# --- meetings -------------------------------------------------------------
@@ -814,6 +893,12 @@ def meeting_paths(base):
def read_meetings():
"""Newest last."""
with _FILES_LOCK:
return _read_meetings()
def _read_meetings():
"""The body of read_meetings, for callers already holding the lock."""
try:
with open(MEETINGS_FILE, encoding="utf-8") as fh:
lines = fh.readlines()
@@ -836,29 +921,33 @@ def _write_meetings(rows):
with open(tmp, "w", encoding="utf-8") as fh:
for row in rows:
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
tmp.replace(MEETINGS_FILE)
fh.flush()
os.fsync(fh.fileno())
_replace_with_retry(tmp, MEETINGS_FILE)
def save_meeting(entry):
"""Insert the row, or replace the one with the same base."""
rows = read_meetings()
for index, row in enumerate(rows):
if row["base"] == entry["base"]:
rows[index] = entry
break
else:
rows.append(entry)
_write_meetings(rows)
with _FILES_LOCK:
rows = _read_meetings()
for index, row in enumerate(rows):
if row["base"] == entry["base"]:
rows[index] = entry
break
else:
rows.append(entry)
_write_meetings(rows)
def update_meeting(base, **changes):
"""Patch one row and hand it back, or None when it is gone."""
rows = read_meetings()
for row in rows:
if row["base"] == base:
row.update(changes)
_write_meetings(rows)
return row
with _FILES_LOCK:
rows = _read_meetings()
for row in rows:
if row["base"] == base:
row.update(changes)
_write_meetings(rows)
return row
return None
@@ -867,7 +956,9 @@ def delete_meetings(bases):
doomed = set(bases)
if not doomed:
return
_write_meetings([row for row in read_meetings() if row["base"] not in doomed])
with _FILES_LOCK:
_write_meetings([row for row in _read_meetings()
if row["base"] not in doomed])
for base in doomed:
for path in meeting_paths(base):
try:
+6 -2
View File
@@ -31,6 +31,7 @@ from PyQt6.QtCore import QObject, pyqtSignal
from . import api
from . import cleanup
from . import ggml
from . import paths
from .i18n import t
UPLOAD_LIMIT = 24 * 1024 * 1024 # the APIs take 25 MB; leave the form its room
@@ -337,8 +338,11 @@ def _ffmpeg(args, out, aborter=None):
proc = subprocess.Popen(
["ffmpeg", "-nostdin", "-y", *args],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
# ffmpeg writes UTF-8 whatever the locale says; read as the Windows
# codepage its messages mojibake, and a byte the codepage cannot place
# raises from inside communicate itself.
text=True, encoding="utf-8", errors="replace",
creationflags=paths.NO_WINDOW,
)
# A two hour film is a minute of ffmpeg, which is a minute of a Stop button
# doing nothing unless the abort reaches the process itself.
+211 -51
View File
@@ -63,6 +63,10 @@ MODELS_DIR = DATA_DIR / "models"
# Loading a large model onto a GPU is the slow part of a start, and on a cold
# page cache a large LLM read from a spinning disk is slower still.
STARTUP_TIMEOUT = 180.0
# A child that loses the bind race fails and exits at once; a model that fails
# to load takes longer than this to be read in first. The line between "worth
# another port" and "would fail the same way again" is drawn on time.
EARLY_EXIT_WINDOW = 5.0
DOWNLOAD_CHUNK = 1 << 20
# `health` is the path that answers only once the model is in memory. whisper
@@ -193,7 +197,16 @@ def download(item, target, on_progress=None, should_stop=None, require_hash=True
part.unlink(missing_ok=True)
raise LocalError(t("{name} does not match its published checksum. "
"Nothing was installed.", name=item.name))
part.replace(target)
try:
part.replace(target)
except PermissionError as exc:
# Windows refuses to replace a file something has open, and a
# running server holds its model and its binary open. The bytes
# are complete and verified: keeping the .part costs a retry,
# deleting it costs the whole download again.
raise LocalError(t("{name} downloaded, but the old file is held "
"open by the running server. Stop it and try "
"again.", name=item.name)) from exc
return True
except urllib.error.HTTPError as exc:
part.unlink(missing_ok=True)
@@ -268,22 +281,23 @@ def _install_record(program):
return BIN_DIR / program.name / "installed.json"
def installed_program(program):
"""The binary Dikte downloaded, or "" when there is none that still runs."""
def _read_record(program):
"""The install record, or {} however it fails to read."""
try:
record = json.loads(_install_record(program).read_text(encoding="utf-8"))
path = record.get("binary") or ""
except (OSError, ValueError):
return ""
return {}
return record if isinstance(record, dict) else {}
def installed_program(program):
"""The binary Dikte downloaded, or "" when there is none that still runs."""
path = _read_record(program).get("binary") or ""
return path if os.path.isfile(path) and os.access(path, os.X_OK) else ""
def installed_version(program):
try:
record = json.loads(_install_record(program).read_text(encoding="utf-8"))
return record.get("tag") or ""
except (OSError, ValueError):
return ""
return _read_record(program).get("tag") or ""
def program_path(program, custom=""):
@@ -339,6 +353,19 @@ def _extract(archive, into):
name=os.path.basename(str(archive)), error=exc)) from exc
def _under(path, root):
"""Whether `path` lies inside `root`, symlinks and case resolved.
Resolved on both sides, because the same directory can be reached under
two spellings and this answer decides whether a server gets stopped.
"""
try:
pathlib.Path(path).resolve().relative_to(pathlib.Path(root).resolve())
return True
except (OSError, ValueError):
return False
def install_program(program, tag="", on_progress=None, should_stop=None,
refresh=False):
"""Fetch and unpack a release. The path to the binary, or "" when stopped.
@@ -374,17 +401,52 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
repo=program.repo, tag=tag))
into = BIN_DIR / program.name / tag
shutil.rmtree(into, ignore_errors=True)
fresh = into.with_name(tag + ".new")
archive = BIN_DIR / program.name / item.name
try:
if not download(item, archive, on_progress, should_stop):
return ""
_extract(archive, into)
binary = _find_binary(into, _binary_file(program))
if binary is None:
raise LocalError(t("{name} was not in the download.",
name=program.binary))
binary.chmod(binary.stat().st_mode | 0o111)
try:
# Unpacked into a sibling and swapped in only once the binary is
# known to be inside: a failure anywhere in here leaves the
# previous install, and its record, exactly as they were.
shutil.rmtree(fresh, ignore_errors=True)
_extract(archive, fresh)
binary = _find_binary(fresh, _binary_file(program))
if binary is None:
raise LocalError(t("{name} was not in the download.",
name=program.binary))
binary.chmod(binary.stat().st_mode | 0o111)
# A running server holds its binary open, and Windows will not
# delete an open file: whichever of our servers runs out of this
# program's directory is stopped here, after the download and the
# unpack are known good, so the outage is the swap and not the
# whole transfer.
for server in SERVERS:
current = program_path(server.program,
server.settings().get("binary", ""))
if current and _under(current, BIN_DIR / program.name):
server.stop()
if into.exists():
try:
shutil.rmtree(into)
except OSError as exc:
# Not ignore_errors: silently losing this would rename the
# new version somewhere it can never land, and the user can
# actually fix it by closing whatever holds the directory.
raise LocalError(t(
"Could not replace {path}: a file in it is still "
"open: {error}", path=into, error=exc)) from exc
fresh.rename(into)
except BaseException:
# Half an unpacked sibling is not worth keeping, and the swap
# never ran, so the previous install is still whole.
shutil.rmtree(fresh, ignore_errors=True)
raise
# Found under the sibling, run from the final directory.
binary = into / binary.relative_to(fresh)
# Written last, so the record never points at anything half-made.
_install_record(program).write_text(
json.dumps({"tag": tag, "binary": str(binary)}), encoding="utf-8")
except OSError as exc:
@@ -404,8 +466,15 @@ def _drop_old_versions(program, keep):
root = BIN_DIR / program.name
try:
for path in root.iterdir():
if path.is_dir() and path.name != keep:
shutil.rmtree(path, ignore_errors=True)
if not path.is_dir() or path.name == keep:
continue
# A ".new" sibling belongs to an install mid-swap; housekeeping
# must not pull it out from under it.
if path.name.endswith(".new"):
continue
# ignore_errors on purpose: this is housekeeping, and a locked old
# version is a little wasted disk rather than a failed install.
shutil.rmtree(path, ignore_errors=True)
except OSError:
pass
@@ -538,7 +607,12 @@ def _tail(path, lines=3):
def _win_image_name(pid):
"""The lower-cased file name of the process's executable, or ''."""
"""The full, lower-cased path of the process's executable, or ''.
The full path rather than the base name, because the name alone is anyone's
whisper-server.exe and this answer decides what gets killed. MAX_PATH is a
convention rather than a limit, so the buffer grows until the query fits.
"""
import ctypes
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.OpenProcess.restype = ctypes.c_void_p
@@ -548,11 +622,18 @@ def _win_image_name(pid):
if not handle:
return ""
try:
buffer = ctypes.create_unicode_buffer(260)
size = ctypes.c_uint32(len(buffer))
ok = kernel32.QueryFullProcessImageNameW(
ctypes.c_void_p(handle), 0, buffer, ctypes.byref(size))
return os.path.basename(buffer.value).lower() if ok else ""
length = 260
while length <= 32768:
buffer = ctypes.create_unicode_buffer(length)
size = ctypes.c_uint32(len(buffer))
ok = kernel32.QueryFullProcessImageNameW(
ctypes.c_void_p(handle), 0, buffer, ctypes.byref(size))
if ok:
return buffer.value.lower()
if ctypes.get_last_error() != 122: # ERROR_INSUFFICIENT_BUFFER
return ""
length *= 2
return ""
finally:
kernel32.CloseHandle(handle)
@@ -578,6 +659,9 @@ class Server:
self._port = 0
self._log = ""
self._key = None
# The pid this instance last wrote to its pid file, so _forget never
# removes a file some other Dikte wrote after us.
self._pid = 0
# ---- settings --------------------------------------------------------
@@ -626,7 +710,9 @@ class Server:
ready = self._current_url()
if ready:
return ready
self.stop()
# _stop_now rather than stop(): this thread already holds
# _starting, and the public stop() waits for it.
self._stop_now()
with self._lock:
settings, key = dict(self._settings), self._settings_key()
proc, port, log = self._launch(settings)
@@ -659,7 +745,7 @@ class Server:
stdout=sink, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
# No console window of its own on Windows.
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
creationflags=paths.NO_WINDOW,
)
except OSError as exc:
raise LocalError(t("Could not start {name}: {error}",
@@ -668,8 +754,9 @@ class Server:
# Written before it is ready rather than after, so that a kill
# during the model load leaves something for the sweep to find.
self._remember(proc.pid)
began = time.monotonic()
try:
ready = self._wait_ready(proc, port)
reason, listened = self._wait_ready(proc, port)
except BaseException:
# Whatever went wrong while waiting, the process is ours and
# nothing else is left holding a reference to it. Leaving it
@@ -679,31 +766,52 @@ class Server:
self._kill(proc)
self._forget()
raise
if ready:
if reason == "ready":
return proc, port, str(log)
last = _tail(log)
self._forget()
# A port taken between the probe and the bind is the one failure
# worth another go; anything else will fail the same way again.
if "address" not in last.lower() and "bind" not in last.lower():
# Losing the port between the probe and the bind is the one
# failure another port fixes, and it has a shape rather than a
# message: the child died at once without the port ever having
# answered as its own. Grepping the log for "bind" would tie this
# to one program's wording in one language.
early = time.monotonic() - began < EARLY_EXIT_WINDOW
if reason != "exited" or listened or not early:
break
raise LocalError(t("{name} did not start: {error}",
name=self.program.binary, error=last or t("no output")))
def _wait_ready(self, proc, port):
"""("ready" | "exited" | "timeout", whether the port answered as ours).
whisper binds after the model is loaded, so the open port is the
answer; llama binds first and answers /health with 503 until it is
ready. For the health-less case the open port alone is not proof: a
child that lost the bind race exits at once while the winner keeps the
port open, so "ready" also wants our child alive a beat after the port
was first seen open.
"""
deadline = time.monotonic() + STARTUP_TIMEOUT
seen_open = False
listened = False
while time.monotonic() < deadline:
if proc.poll() is not None:
return False
return "exited", listened
if seen_open:
# Port open on the last pass and our child still alive now:
# an imposter's port would have left our child dead by here.
return "ready", True
if _listening(port):
# whisper binds after the model is loaded, so the open port is
# the answer. llama binds first and answers /health with 503
# until it is ready.
if not self.program.health or _healthy(port, self.program.health):
return True
if self.program.health:
# llama did the binding itself, so the port is its.
listened = True
if _healthy(port, self.program.health):
return "ready", True
else:
seen_open = True
time.sleep(0.1)
self._kill(proc)
return False
return "timeout", listened
@staticmethod
def _kill(proc, gently=False):
@@ -724,6 +832,14 @@ class Server:
pass
def stop(self):
# Taking _starting means a stop cannot slide past a launch in flight:
# serve() finishes registering its child first, and the child is then
# killed here rather than surviving the shutdown unowned.
with self._starting:
self._stop_now()
def _stop_now(self):
"""stop() for a thread that already holds _starting."""
with self._lock:
proc, self._proc = self._proc, None
self._port, self._log, self._key = 0, "", None
@@ -737,6 +853,8 @@ class Server:
return DATA_DIR / f"{self.program.name}-server.pid"
def _remember(self, pid):
with self._lock:
self._pid = pid
try:
path = self._pid_file()
path.parent.mkdir(parents=True, exist_ok=True)
@@ -744,28 +862,62 @@ class Server:
except OSError:
pass # the sweep is a safety net, not something to fail a run over
def _forget(self):
def _forget(self, pid=None):
"""Remove the pid file, but only while it still holds our own pid.
Another Dikte started after us writes its pid over ours, and removing
that file would hide its server from every future sweep.
"""
if pid is None:
with self._lock:
pid = self._pid
try:
self._pid_file().unlink()
except OSError:
if int(self._pid_file().read_text().strip()) == pid:
self._pid_file().unlink()
except (OSError, ValueError):
pass
def _is_ours(self, pid):
"""Whether that pid is still the server this Dikte started.
"""True: still our server. False: definitely not. None: cannot tell now.
Asked because pids are handed out again: by the time anyone looks, the
number could belong to something else entirely, and killing it would be
a good deal worse than the leak being cleaned up. The program name alone
could be somebody else's copy; the name together with Dikte's own data
directory on the command line could not. Windows offers no command line
to read, so the executable's name is the whole of the answer there.
a good deal worse than the leak being cleaned up. The tri-state matters
for the pid file: a definitive "not ours" means the file is stale and
safe to drop, while "cannot tell" means it has to stay so a later start
can ask again.
On Linux the program name alone could be somebody else's copy; the name
together with Dikte's own data directory on the command line could not.
Windows offers no command line to read, so the executable's full path
is the answer there: under our bin directory, or exactly the binary the
settings point this server at. Never the base name alone, which is
anyone's whisper-server.exe.
"""
if sys.platform == "win32":
return _win_image_name(pid) == _binary_file(self.program).lower()
path = _win_image_name(pid)
if not path:
# OpenProcess said nothing: the process may be gone or merely
# unreadable from here, and the difference decides whether the
# pid file may be dropped, so no verdict rather than a wrong one.
return None
path = os.path.normcase(path)
if path.startswith(os.path.normcase(str(BIN_DIR)) + os.sep):
return True
with self._lock:
custom = self._settings.get("binary", "")
configured = program_path(self.program, custom)
if configured:
resolved = os.path.normcase(str(pathlib.Path(configured).resolve()))
if path == resolved:
return True
return False
try:
blob = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes()
except (FileNotFoundError, ProcessLookupError):
return False # the process is definitively gone
except OSError:
return False
return None # /proc would not answer just now
return (self.program.binary.encode() in blob
and str(DATA_DIR).encode() in blob)
@@ -781,13 +933,21 @@ class Server:
pid = int(self._pid_file().read_text().strip())
except (OSError, ValueError):
return False
self._forget()
if not self._is_ours(pid):
owned = self._is_ours(pid)
if owned is None:
# Could not be verified rather than known stale: the file stays,
# so the next start asks again instead of losing track of a server
# that may still be holding a model.
return False
if not owned:
self._forget(pid)
return False
try:
os.kill(pid, signal.SIGTERM)
except OSError:
self._forget(pid)
return False
self._forget(pid)
return True
+2 -4
View File
@@ -970,9 +970,7 @@ def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID):
if "=" not in line or desktop_id in section:
continue
key, _, value = line.partition("=")
if shortcut.lower() in value.lower().split(","):
hits.append(f"{section}{key}")
elif any(shortcut.lower() == part.strip().lower()
for part in re.split(r"[,\t]", value)):
if any(shortcut.lower() == part.strip().lower()
for part in re.split(r"[,\t]", value)):
hits.append(f"{section}{key}")
return hits
+4 -6
View File
@@ -12,19 +12,18 @@ means one for every whisper.cpp release; both of those are somebody else's news,
not Dikte's. Answers are cached for a few hours, and a cache that has gone stale
is still a better answer than none when the network is down.
Nothing here imports the rest of Dikte apart from the string table: this module
knows two websites and nothing about dictation.
Nothing here imports the rest of Dikte apart from two leaves, the string table
and the path map: this module knows two websites and nothing about dictation.
"""
import collections
import json
import os
import pathlib
import time
import urllib.error
import urllib.parse
import urllib.request
from . import paths
from .i18n import t
GITHUB_API = "https://api.github.com"
@@ -32,8 +31,7 @@ HF_API = "https://huggingface.co/api"
HF_FILES = "https://huggingface.co"
USER_AGENT = "dikte/1.0 (+https://github.com/yusufipk/dikte)"
CACHE_DIR = (pathlib.Path(os.environ.get("XDG_CACHE_HOME")
or os.path.expanduser("~/.cache")) / "dikte")
CACHE_DIR = paths.cache_dir()
# Long enough that opening the settings window twice in an evening asks nobody
# anything, short enough that a model published this morning is offered today.
CACHE_TTL = 6 * 3600
+136 -2
View File
@@ -71,6 +71,7 @@ TR = {
# --- overlay / pipeline -------------------------------------------
"Transcribing…": "Yazıya çevriliyor…",
"Waiting for the one before it…": "Öncekinin bitmesi bekleniyor…",
"Cleaning up…": "Temizleniyor…",
"Pasting…": "Yapıştırılıyor…",
"Pasted": "Yapıştırıldı",
@@ -164,8 +165,6 @@ TR = {
"Automatic (system)": "Otomatik (sistem)",
"Turkish": "Türkçe",
"English": "İngilizce",
"Restart Dikte for the language change to reach every window.":
"Dil değişikliğinin her pencereye işlemesi için Dikte'yi yeniden başlat.",
"Microphone": "Mikrofon",
"Default microphone": "Varsayılan mikrofon",
"Speech language": "Konuşma dili",
@@ -581,6 +580,10 @@ TR = {
"“sonnet” gibi bir ad her zaman o serinin en yenisini seçer. Opus daha "
"çok düşünür ve daha geç cevaplar; bu da en çok burada hissedilir, "
"çünkü ekranın başında bekliyorsun.",
"The list is a starting point, not a fence: any model name {name} accepts "
"can be typed straight in.":
"Liste başlangıç için, sınır değil: {name} hangi model adını kabul "
"ediyorsa buraya elle yazılabilir.",
"Permissions": "İzinler",
"Decide on its own, with the safety checks on":
"Kendi karar versin, güvenlik denetimleri açık",
@@ -773,4 +776,135 @@ TR = {
"This one is being written up right now.": "Bunun tutanağı şu anda çıkarılıyor.",
"Delete this meeting, its minutes and its recording?":
"Bu toplantı, tutanağı ve ses kaydı silinsin mi?",
# --- local models and downloads ------------------------------------
# The whole box was born after the last translation pass, which left the
# first-run screen half English on a Turkish machine.
"Download": "İndir",
"Delete": "Sil",
"Program": "Program",
"Publisher": "Yayıncı",
"Automatic": "Otomatik",
"Threads": "İş parçacığı",
"On this machine": "Bu makinede",
"Use the graphics card": "Ekran kartını kullan",
"Load the model when Dikte starts": "Modeli Dikte açılırken yükle",
"Local whisper": "Yerel whisper",
"Local model": "Yerel model",
"Not installed.": "Kurulu değil.",
"Installed on the system: {path}": "Sistemde kurulu: {path}",
"Downloaded, version {version}.": "İndirildi, sürüm {version}.",
"Fetching the model list…": "Model listesi çekiliyor…",
"Downloading…": "İndiriliyor…",
"Downloading: {done} of {total}{share}": "İndiriliyor: {done} / {total}{share}",
"Download stopped.": "İndirme durduruldu.",
"Ready: {name}.": "Hazır: {name}.",
"Nothing downloaded yet.": "Henüz bir şey indirilmedi.",
"{name} has not been downloaded yet.": "{name} henüz indirilmedi.",
"downloaded": "indirildi",
"not downloaded": "indirilmedi",
"Delete model": "Modeli sil",
"Delete {name} from this machine?": "{name} bu makineden silinsin mi?",
"Runs on this machine, on llama.cpp.": "Bu makinede, llama.cpp üzerinde çalışır.",
"A Hugging Face repository of GGUF files. The list is fetched; any other "
"one can be typed in.":
"GGUF dosyaları içeren bir Hugging Face deposu. Liste internetten "
"çekilir; başka bir depo da yazılabilir.",
"A large model takes a second or two to load. Loading it up front spends "
"that once instead of on the first dictation, at the cost of the memory "
"it sits in.":
"Büyük bir modelin yüklenmesi bir iki saniye sürer. Baştan yüklemek bu "
"bedeli ilk diktede değil bir kez öder; karşılığı, modelin oturduğu "
"bellektir.",
"An LLM is slower to load than a whisper model and sits in more memory. "
"Off means it is loaded on the first cleanup instead.":
"Bir LLM, whisper modelinden daha geç yüklenir ve daha çok bellekte "
"oturur. Kapalı, ilk temizlemede yüklenmesi demektir.",
"A model trained to think will think unless it is told not to, and "
"spending 300 tokens of reasoning on a comma is 300 tokens of waiting. "
"Off is what cleanup wants.":
"Düşünmeye eğitilmiş bir model, aksi söylenmedikçe düşünür; bir virgül "
"için 300 token akıl yürütmek 300 token'lık bekleyiştir. Temizleme için "
"doğrusu Kapalı.",
"OpenRouter is the quickest and the only one that needs nothing "
"installed. llama.cpp runs here, on a model downloaded below. Claude Code "
"and Codex clean up on the subscription you already have, without a "
"second key, and take a few seconds longer because each one opens a "
"session to do it.":
"OpenRouter en hızlısıdır ve kurulum istemeyen tek seçenektir. "
"llama.cpp burada, aşağıda indirilen bir modelle çalışır. Claude Code "
"ve Codex, ikinci bir anahtar olmadan zaten sahip olduğun abonelikle "
"temizler; her biri bunun için bir oturum açtığından birkaç saniye "
"daha sürer.",
"whisper.cpp reaches the card through CUDA, ROCm or Vulkan when the build "
"it is running was made with one. A build without any of them runs on the "
"processor whatever this says.":
"whisper.cpp karta CUDA, ROCm ya da Vulkan üzerinden ulaşır; koştuğu "
"derleme bunlardan biriyle yapılmışsa. Hiçbiri olmadan derlenmiş bir "
"kopya, bu ne derse desin işlemcide çalışır.",
"whisper.cpp is not installed. Settings → API and models → Download.":
"whisper.cpp kurulu değil. Ayarlar → API ve modeller → İndir.",
"llama.cpp is not installed. Settings → API and models → Download.":
"llama.cpp kurulu değil. Ayarlar → API ve modeller → İndir.",
"No whisper model has been downloaded yet. Settings → API and models → "
"Download.":
"Henüz whisper modeli indirilmedi. Ayarlar → API ve modeller → İndir.",
"No local cleanup model has been downloaded yet. Settings → API and "
"models → Download.":
"Henüz yerel temizleme modeli indirilmedi. Ayarlar → API ve modeller "
"→ İndir.",
"Hugging Face did not return a model list.":
"Hugging Face model listesi döndürmedi.",
"{repo} did not return a file list.": "{repo} dosya listesi döndürmedi.",
"{repo} has no downloadable release.":
"{repo} deposunun indirilebilir bir sürümü yok.",
"{repo} {tag} has no build for this machine.":
"{repo} {tag} bu makine için derleme içermiyor.",
"{url} answered HTTP {code}.": "{url} HTTP {code} yanıtı verdi.",
"Could not reach {url}: {error}": "{url} adresine ulaşılamadı: {error}",
"Could not read the answer from {url}: {error}":
"{url} yanıtı okunamadı: {error}",
"Could not create {path}: {error}": "{path} oluşturulamadı: {error}",
"Could not download {name}: HTTP {code}":
"{name} indirilemedi: HTTP {code}",
"Could not download {name}: {error}": "{name} indirilemedi: {error}",
"Could not write {name}: {error}": "{name} yazılamadı: {error}",
"Could not unpack {name}: {error}": "{name} açılamadı: {error}",
"Could not install {name}: {error}": "{name} kurulamadı: {error}",
"Could not start {name}: {error}": "{name} başlatılamadı: {error}",
"Could not delete the model: {error}": "Model silinemedi: {error}",
"Could not replace {path}: a file in it is still open: {error}":
"{path} değiştirilemedi: içindeki bir dosya hâlâ açık: {error}",
"{name} did not start: {error}": "{name} başlamadı: {error}",
"no output": "çıktı yok",
"The download stopped early ({done} of {total}).":
"İndirme erken kesildi ({done} / {total}).",
"{name} is longer than it said it would be.":
"{name} bildirdiğinden daha uzun çıktı.",
"{name} does not match its published checksum. Nothing was installed.":
"{name} yayımlanan sağlama toplamıyla uyuşmuyor. Hiçbir şey kurulmadı.",
"{name} is published without a checksum, so there is no way to tell what "
"arrived. Nothing was installed.":
"{name} sağlama toplamı olmadan yayımlanmış; gelenin ne olduğu "
"doğrulanamaz. Hiçbir şey kurulmadı.",
"{name} was not in the download.": "{name} indirilenin içinde yoktu.",
"{name} downloaded, but the old file is held open by the running server. "
"Stop it and try again.":
"{name} indirildi ama eski dosyayı çalışan sunucu açık tutuyor. "
"Sunucuyu durdurup yeniden dene.",
"The cleanup model spent its whole reply on thinking. Set Thinking to "
"“Off”.":
"Temizleme modeli bütün yanıtını düşünmeye harcadı. Düşünme'yi "
"“Kapalı” yap.",
# --- this pass's new messages ---------------------------------------
"Audio recorder stopped before receiving sound":
"Ses kayıt aracı veri alamadan kapandı",
"Could not write the recording: {error}": "Kayıt dosyası yazılamadı: {error}",
"Copied, but pasting failed: {error}":
"Kopyalandı ama yapıştırma başarısız: {error}",
"The recording was kept: {path}": "Kayıt saklandı: {path}",
"The recording stopped on its own; transcribing what was captured.":
"Kayıt kendi kendine durdu; yakalanan kısım yazıya dökülüyor.",
"Could not save the settings: {error}": "Ayarlar kaydedilemedi: {error}",
}
+58 -11
View File
@@ -56,6 +56,17 @@ def packaged():
return bool(getattr(sys, "frozen", False))
def windowed_executable(executable=None):
"""The windowed executable installed beside this one, or None.
Beside rather than at a known place, because the setup program lays the two
executables into the same directory wherever that directory was put: asking
from either of them finds the other without knowing where the install is.
"""
windowed = pathlib.Path(executable or sys.executable).with_name(WINDOWS_APP)
return windowed if windowed.is_file() else None
def target():
"""The file a launcher has to name to start this build again.
@@ -74,8 +85,8 @@ def target():
# The windowed executable, whichever of the two is running: the console
# one is what the `dikte` command names, and a sign-in that started
# that one would open a console window nobody asked for.
windowed = executable.with_name(WINDOWS_APP)
if windowed.is_file():
windowed = windowed_executable(executable)
if windowed is not None:
return windowed
return executable
@@ -572,24 +583,60 @@ def _run_entry_name():
return f"HKCU\\{RUN_KEY}\\{RUN_VALUE}"
def _run_target(value):
"""The executable a Run value names, out of the quoting the setup wrote.
Only the first word matters here: it is the file whose existence says
whether the entry still starts anything.
"""
if value.startswith('"'):
closing = value.find('"', 1)
return value[1:closing] if closing > 0 else ""
return value.split(" ", 1)[0]
def _startup_shortcut():
"""Where install.ps1 -Autostart puts a checkout's sign-in entry."""
appdata = os.environ.get("APPDATA")
if not appdata:
return None
return (pathlib.Path(appdata) / "Microsoft" / "Windows" / "Start Menu"
/ "Programs" / "Startup" / "Dikte.lnk")
def _windows_install(app, force=False):
"""Point the autostart entry at this build. What changed.
Only `force`, which is what typing `dikte integrate` means, creates one.
The call on every start repairs an entry that is already there and names an
executable somewhere else, which is what an installation moved to another
drive or reinstalled into another directory leaves behind; somebody who
unticked the box in the wizard, or turned it off since, is not asked again
by every start.
executable that is gone, which is what an installation moved to another
drive or reinstalled into another directory leaves behind. An entry naming
an executable that still exists is another installation that still works,
and is stood aside for the way the Linux half stands aside for another
menu entry; somebody who unticked the box in the wizard, or turned it off
since, is not asked again by every start either.
"""
command = f'"{app}"'
current = _run_entry()
changed = []
if force:
# install.ps1 -Autostart wrote this for a checkout. The Run value
# written below replaces it, and both left in place would be two
# Diktes at every sign-in. Only on force: the silent call on every
# start has not been asked to move the machine off its checkout.
shortcut = _startup_shortcut()
if shortcut is not None and shortcut.is_file():
shortcut.unlink()
changed.append(shortcut)
if not current and not force:
return []
if current == command:
return []
_write_run_entry(command)
return [_run_entry_name()]
return changed
if current != command:
theirs = _run_target(current) if current else ""
if not force and theirs and theirs != str(app) and os.path.exists(theirs):
return changed
_write_run_entry(command)
changed.append(_run_entry_name())
return changed
def _windows_remove():
+60 -4
View File
@@ -11,11 +11,14 @@ shortcut may still send.
import json
import os
import shlex
import subprocess
import sys
from PyQt6.QtCore import QLockFile
from PyQt6.QtNetwork import QLocalSocket
from . import integrate
from . import paths
SERVER_NAME = "dikte-" + (
str(os.getuid()) if hasattr(os, "getuid")
@@ -54,10 +57,9 @@ def launcher():
if not getattr(sys, "frozen", False):
return [sys.executable, script_path()]
if sys.platform == "win32":
windowed = os.path.join(os.path.dirname(sys.executable),
integrate.WINDOWS_APP)
if os.path.isfile(windowed):
return [windowed]
windowed = integrate.windowed_executable()
if windowed is not None:
return [str(windowed)]
return [os.environ.get("APPIMAGE") or sys.executable]
@@ -72,6 +74,60 @@ def command_for(verb):
return shlex.join(launcher() + ([verb] if verb else []))
def already_serving():
"""Whether a running instance answers on this user's name.
Asked before an instance opens a server of its own, because listen() is
not the check: a Windows named pipe takes a second server on the same name
rather than refusing it, and everywhere else removeServer() would first
take the live socket away from the instance holding it. Either way two
whole Diktes then run, and the newer one's sweep() kills the whisper the
older one is answering dictations with. The probe is "status" and nothing
else: a verb with a side effect here would fire it during the relaunch a
slow-to-answer instance provokes, on top of the verb being forwarded.
"""
return send("status") is not None
def instance_lock():
"""This user's one-Dikte lock, taken before anything else is built.
The probe above has a hole: two copies started in the same moment both ask
before either listens, and both come up. A lock file closes it, and
QLockFile writes the holder's pid into it, so a lock a killed instance
left behind identifies itself as stale and clears. None when the data
directory cannot be made, which a start should survive: the probe still
stands guard, just without the simultaneous-start case.
"""
try:
paths.DATA_DIR.mkdir(parents=True, exist_ok=True)
except OSError:
return None
lock = QLockFile(str(paths.DATA_DIR / "dikte.lock"))
# Never presume a lock is stale by age alone; the pid check is the truth.
lock.setStaleLockTime(0)
return lock
def respawn(arguments):
"""Start this installation again with `arguments`, leaving this process.
execv everywhere it works the way it says: the new process takes this
pid and nothing is left behind. On Windows execv mangles arguments with
spaces and leaves the two processes sharing a console, so the replacement
is started detached instead and the caller exits on its own.
"""
args = launcher() + list(arguments)
if sys.platform == "win32":
# By value where the names are missing, so the Windows half of this is
# testable from the suite's other platforms too.
detached = (getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200))
subprocess.Popen(args, creationflags=detached, close_fds=True)
return
os.execv(args[0], args)
def send(cmd, wait=False, timeout=0, **args):
"""Send one request; the reply, or None when no instance is running.
+203
View File
@@ -0,0 +1,203 @@
"""The parts of AppKit a dictation needs on macOS and Qt does not reach.
Two jobs, both about staying out of the user's way.
The first is keeping the indicator on screen. Qt draws it as a tool window,
which on macOS is an NSPanel, and an NSPanel is hidden by the system the moment
its application stops being the active one. For a dictation indicator that is
exactly backwards: you press the shortcut inside some other program, so Dikte is
never the active application, and the one window that has something to say
disappears as soon as you look away from it. Three settings AppKit has and Qt
does not expose:
hidesOnDeactivate = NO stay put when another application comes forward
collectionBehavior show on whichever desktop is in front, including
over a full screen window, and stay out of Cmd+Tab
nonactivating panel come to the front without bringing Dikte with it
The second is putting the front back. Opening the microphone activates Dikte
whatever the indicator does, so app.py watches for that and calls activate()
here. See _give_the_front_back() there for the measurement.
Done through the Objective-C runtime rather than a binding, because Dikte has no
third party Python packages and this is a handful of messages to three objects.
The runtime is loaded in _appkit() rather than at import, the way paste.py loads
its frameworks in _macos_api(): that is the one function a test fakes, and it is
what lets the tests below run on a machine that has no AppKit at all.
"""
import ctypes
import ctypes.util
import os
from PyQt6.QtGui import QGuiApplication
# NSWindowCollectionBehavior, as of the macOS these names come from:
CAN_JOIN_ALL_SPACES = 1 << 0
IGNORES_CYCLE = 1 << 6 # not a window Cmd+Tab should ever land on
FULL_SCREEN_AUXILIARY = 1 << 8
BEHAVIOUR = CAN_JOIN_ALL_SPACES | IGNORES_CYCLE | FULL_SCREEN_AUXILIARY
# NSWindowStyleMaskNonactivatingPanel. Without it, ordering the indicator to
# the front brings Dikte to the front with it, and the application the user was
# typing in loses focus the moment they start dictating: the Cmd+V at the end
# then lands on the indicator instead of their document. Qt has no flag for
# this; WA_ShowWithoutActivating governs the show, not what the panel does to
# the application afterwards.
NONACTIVATING_PANEL = 1 << 7
_appkit_runtime = None
class _AppKit:
"""objc_msgSend under the signatures this file sends it through.
It has no fixed signature of its own, and calling it through the wrong
argument or return types is how a Mac crashes rather than raises, so each
one is spelled out once here and used by name below.
"""
def __init__(self, objc):
self.objc = objc
objc.sel_registerName.restype = ctypes.c_void_p
objc.sel_registerName.argtypes = [ctypes.c_char_p]
objc.objc_getClass.restype = ctypes.c_void_p
objc.objc_getClass.argtypes = [ctypes.c_char_p]
self.ask = self._as(ctypes.c_void_p)
self.ask_bool = self._as(ctypes.c_bool)
self.ask_pid = self._as(ctypes.c_int) # pid_t is an int32
self.ask_unsigned = self._as(ctypes.c_ulong) # NSUInteger
self.tell_bool = self._as(None, ctypes.c_bool)
self.tell_unsigned = self._as(None, ctypes.c_ulong)
self.ask_of_class = self._as(ctypes.c_bool, ctypes.c_void_p)
self.ask_of_pid = self._as(ctypes.c_void_p, ctypes.c_int)
self.ask_with_options = self._as(ctypes.c_bool, ctypes.c_ulong)
def _as(self, returns, *arguments):
return ctypes.cast(self.objc.objc_msgSend, ctypes.CFUNCTYPE(
returns, ctypes.c_void_p, ctypes.c_void_p, *arguments))
def selector(self, name):
return self.objc.sel_registerName(name)
def shared(self, class_name, selector):
"""A class's singleton, e.g. +[NSWorkspace sharedWorkspace]."""
return ctypes.c_void_p(self.ask(
ctypes.c_void_p(self.objc.objc_getClass(class_name)),
self.selector(selector)))
def _appkit():
"""The Objective-C runtime, loaded the first time something needs it.
Loaded here rather than at import so that this module can be imported on a
machine that has no AppKit: the tests stand on macOS from a Linux machine
and back, and this is the one function they replace to do it.
"""
global _appkit_runtime
if _appkit_runtime is None:
_appkit_runtime = _AppKit(
ctypes.cdll.LoadLibrary(ctypes.util.find_library("objc")))
return _appkit_runtime
def frontmost_pid():
"""Which application is in front, by process id, or None when unasked.
A process id rather than the object itself: the object would have to be
retained to survive the trip, and a number needs nothing looking after it.
"""
try:
api = _appkit()
workspace = api.shared(b"NSWorkspace", b"sharedWorkspace")
running = ctypes.c_void_p(api.ask(
workspace, api.selector(b"frontmostApplication")))
if not running:
return None
return int(api.ask_pid(running, api.selector(b"processIdentifier")))
except Exception:
return None
def activate(pid):
"""Put the application with that process id back in front.
False when it has gone away in the meantime, or when the message could not
be sent at all: a dictation is not worth failing over the window behind it.
"""
if not pid:
return False
try:
api = _appkit()
running = ctypes.c_void_p(api.ask_of_pid(
ctypes.c_void_p(api.objc.objc_getClass(b"NSRunningApplication")),
api.selector(b"runningApplicationWithProcessIdentifier:"),
int(pid)))
if not running:
return False
# activateWithOptions: rather than the deprecated activate, and with no
# options: bringing every one of its windows forward is not asked for,
# only the application it was before Dikte took the front from it.
return bool(api.ask_with_options(
running, api.selector(b"activateWithOptions:"), 0))
except Exception:
return False
def is_frontmost():
"""Whether Dikte itself is the application in front.
By process id rather than -[NSRunningApplication isActive] on our own
process, which stays true once the application has ever been activated:
measured True with another application plainly in front.
"""
pid = frontmost_pid()
return pid is not None and pid == os.getpid()
def _is_panel(api, window):
"""Whether this window is an NSPanel, which is the only kind the
nonactivating bit is legal on: setting it on a plain NSWindow raises an
Objective-C exception, and an exception through ctypes takes the process
down with it."""
panel = api.objc.objc_getClass(b"NSPanel")
if not panel:
return False
return bool(api.ask_of_class(window, api.selector(b"isKindOfClass:"),
ctypes.c_void_p(panel)))
def keep_on_screen(widget):
"""Ask the window behind `widget` to stay while other programs are used.
Silent when anything is not as expected: an indicator that cannot be made
to linger is still an indicator, and a dictation should not fail over the
window it is drawn in.
"""
# Only the Cocoa backend hands out a real NSView. Under the offscreen
# platform the tests run on, winId() is a number that means something else
# entirely, and sending an Objective-C message to it is how a test run
# turns into a crash.
if QGuiApplication.platformName() != "cocoa":
return False
try:
api = _appkit()
view = ctypes.c_void_p(int(widget.winId()))
window = api.ask(view, api.selector(b"window"))
if not window:
return False
window = ctypes.c_void_p(window)
api.tell_bool(window, api.selector(b"setHidesOnDeactivate:"), False)
api.tell_unsigned(window, api.selector(b"setCollectionBehavior:"),
BEHAVIOUR)
# Only a panel may carry the nonactivating bit, and only a panel is
# asked to: on anything else the message raises, and an Objective-C
# exception through ctypes takes the process with it.
if _is_panel(api, window):
mask = api.ask_unsigned(window, api.selector(b"styleMask"))
if not mask & NONACTIVATING_PANEL:
api.tell_unsigned(window, api.selector(b"setStyleMask:"),
mask | NONACTIVATING_PANEL)
return True
except (AttributeError, OSError, RuntimeError, ValueError):
return False
+7
View File
@@ -7,6 +7,8 @@ from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF
from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics
from PyQt6.QtWidgets import QWidget, QApplication
from . import mac_window
BARS = 22
HEIGHT = 56
MIN_WIDTH = 210
@@ -202,6 +204,11 @@ class Overlay(QWidget):
self._reposition()
if not self.isVisible():
self.show()
if sys.platform == "darwin":
# After show(), because the window it works on does not exist until
# then, and every time, because a window Qt rebuilt has the setting
# again at its default.
mac_window.keep_on_screen(self)
if self._concealed:
self.raise_()
self._concealed = False
+37 -6
View File
@@ -147,7 +147,9 @@ def _program_keyboard(program, command, hint=""):
def ready():
return shutil.which(program) is not None
def press(shortcut, delay):
def press(shortcut, delay, _focus=None):
# Nothing here takes the front from the window being dictated into, so
# there is nothing to hand back: the process id is a macOS concern.
if not ready():
raise PasteError(t("{tool} not found, cannot paste automatically.",
tool=program))
@@ -296,11 +298,27 @@ def _ask_for_permission():
pass
def _macos_press(shortcut, delay):
def _macos_press(shortcut, delay, focus=None):
"""Post the key down and up straight into the window system.
Nothing is typed anywhere until macOS has been told to trust Dikte, and it
only asks once, when the paste it was granted for is first tried.
`focus` is the application that was in front when the recording began. The
keys land wherever the window system is pointing, so a Dikte that has ended
up in front would swallow its own transcript; when that has happened the
front is handed back before pressing. Nothing is taken from anyone else: an
application the user went to while the transcription ran is where they want
the text now.
This runs on the transcription's own thread rather than the main one, and
the two calls it makes are the kind AppKit documents as answering
atomically wherever they are asked from: NSRunningApplication is thread
safe by its own header, and the workspace lookup behind it returns a
reference rather than anything that has to be held. Stressed with four
threads and 32000 lookups against a running main loop without a fault; if
one ever does happen, mac_window answers None and the press goes ahead
where it would have gone anyway.
"""
keycode, flags = _macos_keys(shortcut)
services, core = _macos_api()
@@ -310,6 +328,12 @@ def _macos_press(shortcut, delay):
"macOS has not been told to let Dikte press keys. Turn Dikte on "
"under System Settings → Privacy & Security → Accessibility."
))
if focus:
# Imported here rather than at the top: it reaches for QtGui, and a
# terminal that only wants the clipboard should not pay for that.
from . import mac_window
if mac_window.is_frontmost():
mac_window.activate(focus)
time.sleep(delay) # let the selection settle and focus come back
down = services.CGEventCreateKeyboardEvent(None, keycode, True)
@@ -464,11 +488,14 @@ class _WinInput(ctypes.Structure):
_fields_ = [("type", ctypes.c_ulong), ("union", _WinInputUnion)]
def _win_press(shortcut, delay):
def _win_press(shortcut, delay, _focus=None):
"""Post the presses and releases straight into the input queue.
No permission stands in front of SendInput the way Accessibility does on
macOS: whatever window has focus receives the combination.
Nothing here takes the front from the window being dictated into, so the
remembered process id has nothing to hand back to: it is a macOS concern.
"""
codes = _win_keys(shortcut)
user32, _ = _win_api()
@@ -675,7 +702,11 @@ def paste_ready():
return desktop().ready()
def press(shortcut="", delay=0.12):
"""Press a paste combination, e.g. 'ctrl+v', or this desktop's own."""
def press(shortcut="", delay=0.12, focus=None):
"""Press a paste combination, e.g. 'ctrl+v', or this desktop's own.
`focus` is the process the keys are meant for, remembered when the
recording started: see the macOS press for what is done with it.
"""
here = desktop()
here.press(shortcut or here.shortcuts[0], delay)
here.press(shortcut or here.shortcuts[0], delay, focus)
+30 -5
View File
@@ -13,10 +13,18 @@ platform as an argument so that a test can stand on the other one.
import os
import pathlib
import subprocess
import sys
# The one other platform constant every subprocess site needs, kept in this
# leaf so no caller has to pull the audio stack in for it: console programs
# started from a windowless process would otherwise each open a console window
# of their own on Windows.
NO_WINDOW = (getattr(subprocess, "CREATE_NO_WINDOW", 0)
if sys.platform == "win32" else 0)
def _env(var, default):
def env_path(var, default):
"""The directory a variable names, or the one it stands in for."""
return pathlib.Path(os.environ.get(var) or os.path.expanduser(default))
@@ -34,11 +42,28 @@ def directories(platform=None):
support = pathlib.Path.home() / "Library/Application Support/Dikte"
return support, support
if here == "win32":
roaming = _env("APPDATA", "~/AppData/Roaming")
local = _env("LOCALAPPDATA", "~/AppData/Local")
roaming = env_path("APPDATA", "~/AppData/Roaming")
local = env_path("LOCALAPPDATA", "~/AppData/Local")
return roaming / "Dikte", local / "Dikte"
return (_env("XDG_CONFIG_HOME", "~/.config") / "dikte",
_env("XDG_DATA_HOME", "~/.local/share") / "dikte")
return (env_path("XDG_CONFIG_HOME", "~/.config") / "dikte",
env_path("XDG_DATA_HOME", "~/.local/share") / "dikte")
def cache_dir(platform=None):
"""The directory for answers worth keeping but never worth backing up.
A third place because a cache is neither settings nor data: losing it costs
a network request, not a model or a preference, and every system sets aside
a directory for exactly that kind of file, one that backups skip and
cleanup tools may empty. Storing it with the data would ask a backup to
carry files whose whole point is that they can be thrown away.
"""
here = platform or sys.platform
if here == "darwin":
return pathlib.Path.home() / "Library/Caches/Dikte"
if here == "win32":
return env_path("LOCALAPPDATA", "~/AppData/Local") / "Dikte" / "cache"
return env_path("XDG_CACHE_HOME", "~/.cache") / "dikte"
CONFIG_DIR, DATA_DIR = directories()
+167 -37
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
@@ -23,6 +25,7 @@ from . import filetranscribe
from . import ggml
from . import hotkey
from . import hub
from . import i18n
from . import ipc
from . import meeting
from . import paste
@@ -81,7 +84,11 @@ ASSISTANT_PROVIDERS = [
# Aliases resolve to the newest model of that name, so they age better than an
# id does; a full id can be typed in when a particular one is wanted.
ASSISTANT_MODELS = ["sonnet", "opus", "haiku", "fable"]
CODEX_MODELS = ["gpt-5.4-codex", "gpt-5.4", "o4-mini"]
# What the Codex boxes offer before Codex itself has answered, and everything
# they offer when it cannot: the real list comes from `codex debug models` when
# the window opens, so this only has to be roughly right.
CODEX_MODELS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
"gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]
# Starting points only; the box is editable and OpenRouter has hundreds.
ASSISTANT_OR_MODELS = [
"google/gemini-3.5-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.4",
@@ -102,6 +109,12 @@ PERMISSION_MODES = [
("Allow everything", "bypassPermissions"),
("Only what needs no permission", "manual"),
]
def _typed_model_note(name):
"""Every model box takes a typed name too; the tooltip that says so."""
return t("The list is a starting point, not a fence: any model name "
"{name} accepts can be typed straight in.", name=name)
# Codex confines the commands it runs instead of asking about them.
CODEX_SANDBOXES = [
("Read anything, write in the working directory", "workspace-write"),
@@ -203,10 +216,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)
@@ -254,7 +269,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)
@@ -357,9 +371,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()
@@ -421,7 +435,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))
@@ -450,8 +466,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:
@@ -459,15 +484,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)
@@ -525,12 +550,26 @@ class LocalModelBox(QGroupBox):
class SettingsWindow(QDialog):
applied = pyqtSignal()
# A save changed the interface language. Every label below is translated
# as the window is built, so the change cannot reach this window: the
# owner replaces it with a fresh one instead.
language_changed = pyqtSignal()
# A newer release this window's own check found, so that the tray icon
# 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, str)
_transcribe_models_loaded = pyqtSignal(list, str)
_codex_models_loaded = pyqtSignal(list)
# Which key was tested, whether it worked, and what to write under it.
_test_done = pyqtSignal(str, bool, str)
# The release that was found, or None, and what went wrong instead.
@@ -551,6 +590,8 @@ class SettingsWindow(QDialog):
self._key_fields = {}
self._testers = {}
self._shown_provider = ""
# What _save compares against to know a rebuild is due.
self._built_language = i18n.language()
# Where "Open the release page" goes: the release itself once a check
# has named one, and the page that redirects to the newest until then.
self._release_url = update.RELEASES_PAGE
@@ -587,6 +628,7 @@ class SettingsWindow(QDialog):
self._models_loaded.connect(self._on_models_loaded)
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
self._codex_models_loaded.connect(self._on_codex_models_loaded)
self._test_done.connect(self._on_test_done)
self._update_checked.connect(self._on_update_checked)
self.transcriber.progress.connect(self._on_file_progress)
@@ -597,6 +639,7 @@ class SettingsWindow(QDialog):
self.meetings.finished.connect(self._on_minutes_finished)
self.meetings.failed.connect(self._on_minutes_failed)
self._load()
self._load_codex_models()
# Connected after the load, so that filling the boxes in is not taken
# for the user ticking them.
self.file_timestamps.toggled.connect(self._remember_file_choices)
@@ -649,14 +692,11 @@ class SettingsWindow(QDialog):
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():
for name, desc in self._sources_once():
self.mic.addItem(desc, name)
form.addRow(t("Microphone"), self.mic)
@@ -854,11 +894,13 @@ class SettingsWindow(QDialog):
self.cleanup_claude_model = QComboBox()
self.cleanup_claude_model.setEditable(True)
self.cleanup_claude_model.addItems(CLEANUP_CLAUDE_MODELS)
self.cleanup_claude_model.setToolTip(_typed_model_note("Claude Code"))
orr_form.addRow(t("Model"), self.cleanup_claude_model)
self.cleanup_codex_model = QComboBox()
self.cleanup_codex_model.setEditable(True)
self.cleanup_codex_model.addItems([t("Codex's own default")] + CODEX_MODELS)
self.cleanup_codex_model.setToolTip(_typed_model_note("Codex"))
orr_form.addRow(t("Model"), self.cleanup_codex_model)
self.cleanup_opencode_model = QComboBox()
@@ -1023,7 +1065,7 @@ class SettingsWindow(QDialog):
"A name like “sonnet” always means the newest model of that line. "
"Opus thinks harder and answers slower, which is felt here more "
"than anywhere else: you are standing in front of the screen."
))
) + " " + _typed_model_note("Claude Code"))
claude_form.addRow(t("Model"), self.assistant_model)
self.assistant_permission = QComboBox()
for label, value in PERMISSION_MODES:
@@ -1038,6 +1080,7 @@ class SettingsWindow(QDialog):
self.assistant_codex_model.addItem(t("Codex's own default"), "")
for name in CODEX_MODELS:
self.assistant_codex_model.addItem(name, name)
self.assistant_codex_model.setToolTip(_typed_model_note("Codex"))
codex_form.addRow(t("Model"), self.assistant_codex_model)
self.assistant_codex_sandbox = QComboBox()
for label, value in CODEX_SANDBOXES:
@@ -1146,7 +1189,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)
@@ -1662,9 +1705,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"])
@@ -1683,7 +1737,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"])
@@ -1695,10 +1749,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"])
@@ -1766,13 +1821,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"
@@ -1803,8 +1863,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 ""
@@ -1816,11 +1877,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()
@@ -1834,7 +1900,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"])
@@ -1842,7 +1917,27 @@ class SettingsWindow(QDialog):
print(f"dikte: could not trim the history ({exc})")
self._load_history() # the trim may just have dropped rows from the list
self.applied.emit()
# conf.save() has switched the language t() speaks, so the message box
# already answers in the new one; the labels around it were translated
# when the window was built and stay behind. Asking for the rebuild
# waits until the box is dismissed, so the window is not pulled out
# from under a dialog it is holding up.
QMessageBox.information(self, t("Dikte Settings"), t("Saved successfully."))
if i18n.language() != self._built_language and not self._work_in_flight():
self.language_changed.emit()
def _work_in_flight(self):
"""A daemon thread of this window's is still running.
Replacing the window now would let it be collected, taking the C++
side of the model boxes down with it, and the thread's next progress
report would land on a deleted object. The stale labels stand until a
later save finds the window quiet; _built_language keeps the old
language, so that save asks for the rebuild by itself.
"""
return (self.transcriber.busy
or self.local_whisper._downloading
or self.local_llm._downloading)
@staticmethod
def _select_data(combo, value):
@@ -1950,6 +2045,33 @@ class SettingsWindow(QDialog):
self.meeting_model.setCurrentText(current)
self.models_label.setText(t("{count} models loaded.", count=len(models)))
def _load_codex_models(self):
"""Ask Codex which models it offers, off the interface thread.
No button and no network of ours: the CLI answers from its own cache in
well under a second. Skipped when Codex is not installed, which is also
when the built-in list stays on screen and nobody is running Codex
anyway.
"""
if not shutil.which("codex"):
return
def work():
found = assistant.codex_models()
if found:
self._codex_models_loaded.emit(found)
threading.Thread(target=work, daemon=True).start()
def _on_codex_models_loaded(self, models):
for combo in (self.cleanup_codex_model, self.assistant_codex_model):
current = combo.currentText()
combo.clear()
combo.addItem(t("Codex's own default"), "")
for name in models:
combo.addItem(name, name)
combo.setCurrentText(current)
def _test_openai(self):
key, base = self._typed_key("openai")
self._test_key("openai", lambda: t(
@@ -2074,7 +2196,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:
@@ -2175,7 +2300,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):
+3 -1
View File
@@ -17,13 +17,15 @@ import unicodedata
# Stock phrases the models produce when handed silence. Kept deliberately
# narrow: only sentences nobody dictates on purpose in a two-second clip.
# Whisper does invent "you" and "bye" too, but people dictate both as whole
# answers, so a single word never belongs here.
HALLUCINATIONS = {
"altyazi mk", "altyazi m k", "altyazi", "altyazilar",
"abone olmayi unutmayin", "izlediginiz icin tesekkurler",
"izlediginiz icin tesekkur ederim", "izlediginiz icin tesekkur ederiz",
"kanalima abone olmayi unutmayin", "altyazi mk altyazi mk",
"thanks for watching", "thank you for watching", "thanks for watching!",
"please subscribe", "subscribe to my channel", "you", "bye",
"please subscribe", "subscribe to my channel",
"mbc masr", "sous titres realises par la communaute damara org",
"amara org community", "sous titrage st 501",
}
+117 -36
View File
@@ -6,6 +6,7 @@ whatever came of it: an answer to a question, or a sentence saying what was
done.
"""
import collections
import os
import shutil
import sys
@@ -45,25 +46,50 @@ class Pipeline(QObject):
self.conf = conf
self._thread = None
self._stop = threading.Event()
# Recordings waiting their turn, and whether a thread is working them
# off. The flag rather than the thread's own liveness, because a thread
# stays alive for a moment after deciding it is done, and a job arriving
# in that moment would be left in the queue with nobody coming back.
self._jobs = collections.deque()
self._draining = False
self._jobs_lock = threading.Lock()
@property
def busy(self):
return self._thread is not None and self._thread.is_alive()
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None):
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None,
focus=None):
"""`paste` overrides the setting for this one run, which is what a
dictation asked for from a terminal wants: the text comes back down the
socket, and pasting it into whatever had focus is nobody's intention."""
if self.busy:
return
socket, and pasting it into whatever had focus is nobody's intention.
`focus` is the application that was in front when the recording began,
as a process id, and is where the paste is meant to land.
A run started while one is going waits its turn rather than being
dropped: the next dictation can be spoken while the last one is still
being cleaned up, and each one is finished, pasted and reported in the
order it was spoken."""
with self._jobs_lock:
self._jobs.append((wav_path, duration, list(rms_values), ask, paste,
focus))
if self._draining:
return
self._draining = True
self._stop.clear()
self._thread = threading.Thread(
target=self._work,
args=(wav_path, duration, list(rms_values), ask, paste),
daemon=True,
)
self._thread = threading.Thread(target=self._drain, daemon=True)
self._thread.start()
def _drain(self):
while True:
with self._jobs_lock:
if not self._jobs:
self._draining = False
return
job = self._jobs.popleft()
self._work(*job)
def cancel(self):
"""Give up on a job already under way.
@@ -73,7 +99,8 @@ class Pipeline(QObject):
"""
self._stop.set()
def _work(self, wav_path, duration, rms_values, ask, paste_override=None):
def _work(self, wav_path, duration, rms_values, ask, paste_override=None,
focus=None):
conf = self.conf
started = time.monotonic()
raw = ""
@@ -107,11 +134,16 @@ class Pipeline(QObject):
text = raw
warning = ""
# Remembered rather than re-derived at the history write below: the
# ask path runs cleanup under a different setting, and the record
# should say what happened, not what one of the two gates implies.
cleaned = False
# Claude reads through “eee” and “hani” without help, so a dictation
# on its way there is normally sent as it was heard, one API call and
# a second or two lighter.
if (conf["assistant_cleanup"] if ask else conf["cleanup_enabled"]):
self.stage.emit(t("Cleaning up…"))
cleaned = True
try:
text = cleanup.run(raw, conf, conf.cleanup_prompt())
except api.ApiError as exc:
@@ -137,62 +169,111 @@ class Pipeline(QObject):
if paste_override is not None:
wants_paste = paste_override
with _paste_lock:
previous = (paste.read_clipboard()
if conf["restore_clipboard"] and wants_paste else None)
try:
paste.copy(text)
if wants_paste:
self.stage.emit(t("Pasting…"))
paste.press(conf["paste_shortcut"])
finally:
if previous is not None:
# Let the focused application consume the temporary
# transcription before putting every old clipboard type
# back. This also runs when key injection fails.
time.sleep(0.35)
paste.copy_bytes(previous)
cfg.append_history({
# Into the history before the paste is attempted: the record says
# what was dictated, not whether a key press landed, and a paste
# that fails must not take the transcript down with it.
record = {
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
"duration": round(duration, 1),
"elapsed": round(time.monotonic() - started, 1),
"model": target.model,
"cleanup_model": cleanup.model(conf) if conf["cleanup_enabled"] else "",
"cleanup_model": cleanup.model(conf) if cleaned else "",
"cleanup_error": warning,
"mode": "ask" if ask else "",
"question": question,
"assistant_model": conf["assistant_model"] if ask else "",
"raw": raw,
"text": text,
})
}
cfg.append_history(record)
try:
cfg.trim_history(conf["history_limit"])
except OSError as exc:
print(f"dikte: could not trim the history: {exc}", file=sys.stderr)
with _paste_lock:
previous = (paste.read_clipboard()
if conf["restore_clipboard"] and wants_paste else None)
paste.copy(text)
if wants_paste:
self.stage.emit(t("Pasting…"))
try:
paste.press(conf["paste_shortcut"], focus=focus)
except paste.PasteError as exc:
# The transcript is on the clipboard and in the history;
# a key press that would not land is a warning, not a
# failure, and the old clipboard is NOT put back over
# the text the user now has to paste by hand.
previous = None
warning = "\n".join(x for x in (
warning,
t("Copied, but pasting failed: {error}", error=exc),
) if x)
# The row above was written before the paste, so it
# has to be told what the paste then did.
record = cfg.amend_history(
record, cleanup_error=warning) or record
if previous is not None:
# Let the focused application consume the temporary
# transcription before putting every old clipboard type
# back.
time.sleep(0.35)
paste.copy_bytes(previous)
self.finished.emit(raw, text, warning)
except assistant.Cancelled:
self.cancelled.emit()
except (api.ApiError, paste.PasteError, assistant.AssistantError) as exc:
print(f"dikte: {exc}", file=sys.stderr)
self.failed.emit(str(exc))
self.failed.emit(self._keeping(wav_path, str(exc)))
except Exception as exc: # never fail silently
traceback.print_exc()
self.failed.emit(t("Unexpected error: {error}", error=exc))
self.failed.emit(self._keeping(wav_path, t("Unexpected error: {error}",
error=exc)))
finally:
self._discard(wav_path)
def _keeping(self, wav_path, message):
"""Put the failed run's audio somewhere a retry can find it.
A dictation that died on the way to the model is speech the user cannot
say again from memory; deleting it because a server was down turns one
failure into two. Kept regardless of the keep_audio setting, which is
about the runs that succeeded.
"""
kept = self._keep(wav_path)
if not kept:
return message
return message + "\n" + t("The recording was kept: {path}", path=kept)
def _keep(self, wav_path):
"""Move the WAV into the recordings directory; its new path, or ''."""
try:
cfg.RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
base = time.strftime("%Y%m%d-%H%M%S")
# Two runs can finish inside the same second; the first one kept
# must not be overwritten by the second.
for suffix in ("",) + tuple(f"-{n}" for n in range(1, 100)):
target = cfg.RECORDINGS_DIR / f"{base}{suffix}.wav"
if not target.exists():
shutil.move(wav_path, target)
return str(target)
return ""
except OSError as exc:
print(f"dikte: could not keep the audio: {exc}", file=sys.stderr)
return ""
def _discard(self, wav_path):
if not os.path.exists(wav_path):
return
if self.conf["keep_audio"]:
try:
cfg.RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
shutil.move(wav_path, cfg.RECORDINGS_DIR / (time.strftime("%Y%m%d-%H%M%S") + ".wav"))
if self._keep(wav_path):
return
except OSError:
pass
# The move failing is no reason to delete what the user asked to
# keep: the temporary file stays where it is, named in the log.
print(f"dikte: the audio stays at {wav_path}", file=sys.stderr)
return
try:
os.unlink(wav_path)
except OSError: