Let no failure eat a dictation, and none strand the state machine

The recorded WAV was deleted in a finally that did not care why the run
ended, so a whisper server being down destroyed the only copy of the
user's speech; a failed run now keeps its audio in the recordings
directory and names the path in the error. The history was written only
after the paste, and a failed key press restored the previous clipboard
over the fresh transcript: text not pasted, not on the clipboard, not in
the history, audio gone, all from one refused key. The history now comes
first, a failed press is a warning the row is amended to carry, and the
clipboard keeps the text the user has to paste by hand. Kept recordings
no longer overwrite each other inside one second, the keep_audio move
failing no longer falls through to the delete, and the history row
records whether cleanup actually ran rather than what the dictation gate
implies about an ask.

In the application: a recorder that failed to start emitted its error
synchronously and start() then wrote RECORDING over the handler's IDLE,
one more key press away from a BUSY nothing would ever end; start now
checks the recorder is running, like start_meeting always has. The new
died signal ends the run properly and transcribes what was captured. A
--paste override armed by a request that no-opped stopped haunting some
later unrelated run, and it dies with a cancelled or failed one. The
toggle, ask and pause debounce timers are per action, so a pause right
after a toggle is a pause and not a duplicate. Waiters outstanding at
quit or restart are settled instead of being read back as "the instance
is too old". The listing warm-up and the one-per-press backend lookup
move off the key press.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
huseyin-emre-tigci
2026-08-22 23:17:22 +03:00
co-authored by Claude Fable 5
parent 69194186c8
commit 6f79e93d53
3 changed files with 254 additions and 62 deletions
+120 -32
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.
@@ -82,6 +83,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.
@@ -126,9 +131,16 @@ 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.finished.connect(self._on_finished)
@@ -150,7 +162,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)
@@ -378,7 +390,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
@@ -447,10 +462,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":
@@ -461,13 +472,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):
@@ -532,7 +550,7 @@ 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()
@@ -541,17 +559,23 @@ class Dikte:
# 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 start(self):
@@ -559,6 +583,12 @@ class Dikte:
return
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):
@@ -566,6 +596,8 @@ class Dikte:
return
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):
@@ -603,7 +635,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:
@@ -635,6 +667,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
@@ -882,9 +916,28 @@ 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_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):
self._report(message, self.overlay)
self._set_state(IDLE)
@@ -956,10 +1009,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:
@@ -981,22 +1037,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()
@@ -1119,16 +1178,42 @@ 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, checked by asking rather than by listening: see
# ipc.already_serving. Before the QApplication, so a second copy costs a
# moment and not a second tray icon.
# 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; its Settings window has the attention")
print("dikte: already running; handing it the attention")
if command:
ipc.send(command)
else:
ipc.send("settings")
return 0
app = QApplication(sys.argv)
@@ -1158,6 +1243,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
+80 -26
View File
@@ -107,11 +107,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 +142,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"])
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:
+54 -4
View File
@@ -30,6 +30,7 @@ class Chain(DikteTest):
def run_chain(self, ask=False, paste_override=None, duration=2.0,
transcript="uh, book it for Thursday",
transcribe_error=None,
cleaned="Book it for Thursday.",
cleanup_error=None, answer=("Booked.", ""), rms=None,
clipboard=b"what was there before", paste_error=None):
@@ -46,7 +47,10 @@ class Chain(DikteTest):
# The chain reports its own failures on stderr, which a test run has no
# use for.
with contextlib.redirect_stderr(io.StringIO()), \
mock.patch.object(api, "transcribe", return_value=transcript) as tr, \
mock.patch.object(
api, "transcribe",
**({"side_effect": transcribe_error} if transcribe_error
else {"return_value": transcript})) as tr, \
mock.patch.object(api, "cleanup", cleanup), \
mock.patch.object(assistant, "ask", return_value=answer) as ask_call, \
mock.patch.object(paste, "copy") as copy, \
@@ -108,11 +112,57 @@ class Chain(DikteTest):
run = self.run_chain()
run["copy_bytes"].assert_not_called()
def test_the_clipboard_is_put_back_when_the_keypress_fails(self):
def test_a_failed_keypress_leaves_the_transcript_on_the_clipboard(self):
"""The press failing is a warning, not a lost dictation: restoring the
old clipboard over the text would leave nothing to paste by hand."""
self.conf["restore_clipboard"] = True
run = self.run_chain(paste_error=paste.PasteError("not trusted"))
self.assertIn("not trusted", run["failures"][0])
run["copy_bytes"].assert_called_once_with(b"what was there before")
self.assertEqual(run["failures"], [])
raw, text, warning = run["done"][0]
self.assertIn("not trusted", warning)
run["copy_bytes"].assert_not_called()
def test_a_failed_keypress_still_reaches_the_history(self):
self.run_chain(paste_error=paste.PasteError("not trusted"))
rows = cfg.read_history()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["text"], "Book it for Thursday.")
# The row goes in before the paste is attempted, so the paste failing
# has to be written back into it: the record tells the whole truth.
self.assertIn("not trusted", rows[0]["cleanup_error"])
def test_a_failed_transcription_keeps_the_audio(self):
"""Speech the user cannot repeat from memory must survive the failure."""
run = self.run_chain(transcribe_error=api.ApiError("server down"))
self.assertIn("server down", run["failures"][0])
self.assertIn("kept", run["failures"][0])
kept = list(cfg.RECORDINGS_DIR.glob("*.wav"))
self.assertEqual(len(kept), 1)
self.assertFalse(os.path.exists(self.wav))
def test_two_failures_in_one_second_keep_both_recordings(self):
self.run_chain(transcribe_error=api.ApiError("down"))
self.wav = make_wav(self.path("clip2.wav"), speech(2.0))
with mock.patch.object(worker.time, "strftime",
return_value="20260820-120000"):
self.run_chain(transcribe_error=api.ApiError("down"))
self.wav = make_wav(self.path("clip3.wav"), speech(2.0))
self.run_chain(transcribe_error=api.ApiError("down"))
self.assertEqual(len(list(cfg.RECORDINGS_DIR.glob("*.wav"))), 3)
def test_the_history_row_says_whether_cleanup_actually_ran(self):
"""The ask path cleans under its own setting; the record follows the
run, not the dictation gate."""
self.conf["cleanup_enabled"] = False
self.conf["assistant_cleanup"] = True
self.run_chain(ask=True)
row = cfg.read_history()[0]
self.assertNotEqual(row["cleanup_model"], "")
cfg.clear_history()
self.conf["cleanup_enabled"] = True
self.conf["assistant_cleanup"] = False
self.run_chain(ask=True)
self.assertEqual(cfg.read_history()[0]["cleanup_model"], "")
def test_the_transcription_is_told_the_language_and_the_glossary(self):
self.conf["language"] = "tr"