diff --git a/dikte/app.py b/dikte/app.py index 7df7cf1..ceaa9f5 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -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 diff --git a/dikte/worker.py b/dikte/worker.py index 16cdbad..649a538 100644 --- a/dikte/worker.py +++ b/dikte/worker.py @@ -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: diff --git a/tests/test_worker.py b/tests/test_worker.py index 1ea30c3..da4b476 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -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"