diff --git a/README.md b/README.md index dc78e9d..e805283 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ set next to it. An indicator in the screen corner shows a red dot, a live waveform and the elapsed time, then the stage it is on. It never takes focus. Pressing `Ctrl+Space` again while Dikte is still working does nothing; nothing queues up. +A dictation and a command to the agent do wait on each other for the microphone, +which is one device, but for nothing else: each has its own indicator, and the +second one stacks above the first while both are up. ## What it does diff --git a/README.tr.md b/README.tr.md index 300e377..840d2c6 100644 --- a/README.tr.md +++ b/README.tr.md @@ -54,7 +54,10 @@ yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebili Ekranın köşesindeki gösterge kırmızı kayıt noktasını, canlı ses dalgasını ve süreyi, ardından hangi aşamada olduğunu gösterir. Odak almaz. Dikte çalışırken -`Ctrl+Space`'e tekrar basmak bir şey yapmaz, sıraya da girmez. +`Ctrl+Space`'e tekrar basmak bir şey yapmaz, sıraya da girmez. Dikte ile ajana +verilen komut yalnızca mikrofon için birbirini bekler, o da tek aygıt olduğu +için; başka hiçbir şeyde beklemezler. Her birinin kendi göstergesi var, ikisi +birden ekrandayken ikincisi birincinin üstüne yerleşir. ## Neler yapıyor diff --git a/dikte.py b/dikte.py index fdd5dd9..1f78c7d 100755 --- a/dikte.py +++ b/dikte.py @@ -5,8 +5,9 @@ Usage: dikte.py run in the background (tray icon) dikte.py toggle start / stop recording dikte.py cancel discard the current recording - dikte.py ask start / stop recording a command for Claude Code - dikte.py ask-reset forget the conversation Claude has been following + dikte.py ask start / stop recording a command for the agent + dikte.py ask-cancel call off the command the agent is working on + dikte.py ask-reset forget the conversation the agent has been following dikte.py meeting start / end a meeting recording dikte.py meeting-cancel discard the meeting being recorded dikte.py settings open the settings window @@ -41,6 +42,11 @@ from worker import Pipeline # noqa: E402 SERVER_NAME = "dikte-" + str(os.getuid()) IDLE, RECORDING, BUSY = "idle", "recording", "busy" +# Dictation and a command for the agent are two runs of the same machinery, kept +# apart so that neither waits on the other: an agent can spend a minute thinking, +# and having dictation blocked for that minute is the whole problem. They share +# only the microphone, which is one device and so can serve one of them at a time. +DICTATION, ASK = "dictation", "ask" # A meeting runs alongside dictation rather than through it: writing up an hour # of audio takes minutes, and dictation should not be held hostage to it. M_IDLE, M_RECORDING, M_WORKING = "idle", "recording", "working" @@ -61,10 +67,9 @@ class Dikte: self.app = app self.conf = cfg.Config() self.state = IDLE - # Dictation and a command for Claude share the recorder and the state - # machine; which of the two is being recorded is decided when the - # recording starts, and holds until it is finished with. - self.ask_mode = False + self.ask_state = IDLE + # Which of the two the microphone is currently serving, or None. + self.recorder_owner = None self.meeting_state = M_IDLE self.meeting_base = "" self.meeting_message = "" @@ -72,19 +77,26 @@ class Dikte: self._quitting = False self.overlay = Overlay(self.conf["overlay_corner"]) + # The agent's indicator sits on top of the dictation one when both are + # up, and drops into the corner when it is alone there. + self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay) self.recorder = audio.Recorder() self.pipeline = Pipeline(self.conf) + self.ask_pipeline = Pipeline(self.conf) self.meeting_recorder = audio.MeetingRecorder() self.meetings = MeetingPipeline(self.conf) self.evdev = hotkey.EvdevHotkey() - self.recorder.level.connect(self.overlay.push_level) + self.recorder.level.connect(self._on_level) self.recorder.stopped.connect(self._on_recorded) - self.recorder.failed.connect(self._on_error) + self.recorder.failed.connect(self._on_recorder_error) self.pipeline.stage.connect(self.overlay.show_busy) self.pipeline.finished.connect(self._on_finished) self.pipeline.failed.connect(self._on_error) - self.pipeline.cancelled.connect(self._on_cancelled) + 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) + self.ask_pipeline.cancelled.connect(self._on_ask_cancelled) self.meeting_recorder.levels.connect(self._on_meeting_levels) self.meeting_recorder.stopped.connect(self._on_meeting_recorded) self.meeting_recorder.died.connect(self._on_meeting_died) @@ -120,7 +132,8 @@ class Dikte: self.toggle_action.triggered.connect(self._toggle) self.menu.addAction(self.toggle_action) - self.ask_action = QAction(t("Ask Claude"), self.menu) + # Named in _refresh_tray, which is where the chosen provider is known. + self.ask_action = QAction("", self.menu) self.ask_action.triggered.connect(self._toggle_ask) self.menu.addAction(self.ask_action) @@ -128,6 +141,11 @@ class Dikte: self.reset_action.triggered.connect(self.reset_conversation) self.menu.addAction(self.reset_action) + self.ask_cancel_action = QAction("", self.menu) + self.ask_cancel_action.triggered.connect(self.cancel_ask) + self.ask_cancel_action.setEnabled(False) + self.menu.addAction(self.ask_cancel_action) + self.cancel_action = QAction(t("Cancel recording"), self.menu) self.cancel_action.triggered.connect(self.cancel) self.cancel_action.setEnabled(False) @@ -169,7 +187,7 @@ class Dikte: # The two shortcuts are each tied to their own mode, on purpose, but the # icon is one button: having it refuse to stop a recording it can see is # just a button that does nothing. - if self.state == RECORDING and self.ask_mode: + if self.ask_state == RECORDING: self._toggle_ask() else: self._toggle() @@ -182,10 +200,22 @@ class Dikte: # ---- state ---------------------------------------------------------- + @property + def recording(self): + """True while the microphone is serving either of the two. + + Read off the states rather than off recorder_owner, which outlives the + recording: it is still set between stop() and the audio arriving, and + the microphone is free in that gap. + """ + return RECORDING in (self.state, self.ask_state) + def _set_state(self, state): self.state = state - if state == IDLE: - self.ask_mode = False + self._refresh_tray() + + def _set_ask_state(self, state): + self.ask_state = state self._refresh_tray() def _set_meeting_state(self, state): @@ -202,36 +232,38 @@ class Dikte: } label, icon, tip = labels[self.state] agent = assistant.display_name(self.conf) - if self.ask_mode: - tip = "Dikte: talking to Claude" - if self.state == RECORDING: - label, tip = "Start recording", "Dikte: recording for Claude" + self.toggle_action.setText(t(label)) - # Each of the two owns its own entry, so the one that is not recording - # goes grey rather than offering to end a recording it did not start. + # Free while the other one is thinking, blocked only while it is holding + # the microphone. self.toggle_action.setEnabled( - self.state == IDLE or (self.state == RECORDING and not self.ask_mode) + self.state == RECORDING or (self.state == IDLE and not self.recording) ) asked = i18n.name(agent, "dative") self.ask_action.setText( - t("Stop and ask {name}", name=asked) - if self.state == RECORDING and self.ask_mode + t("Stop and ask {name}", name=asked) if self.ask_state == RECORDING else t("Ask {name}", name=asked) ) self.ask_action.setEnabled( - self.state == IDLE or (self.state == RECORDING and self.ask_mode) + self.ask_state == RECORDING + or (self.ask_state == IDLE and not self.recording) ) - self.reset_action.setEnabled(self.state != BUSY) - # A Claude command is the one job long enough to be worth calling off - # once it is already running. - self.cancel_action.setText( + self.reset_action.setEnabled(self.ask_state != BUSY) + self.cancel_action.setEnabled(self.recording) + # A command to the agent is the one job long enough to be worth calling + # off once it is already running. + self.ask_cancel_action.setText( t("Stop {name}", name=i18n.name(agent, "accusative")) - if self.state == BUSY and self.ask_mode - else t("Cancel recording") - ) - self.cancel_action.setEnabled( - self.state == RECORDING or (self.state == BUSY and self.ask_mode) ) + self.ask_cancel_action.setEnabled(self.ask_state == BUSY) + + # The agent speaks through the icon only when dictation has nothing to + # say, since dictation is the one being waited on in front of a screen. + if self.state == IDLE and self.ask_state != IDLE: + if self.ask_state == RECORDING: + icon, tip = "media-record", "Dikte: recording for Claude" + else: + icon, tip = "view-refresh", "Dikte: talking to Claude" meeting_labels = { M_IDLE: "Record a meeting", @@ -242,9 +274,9 @@ class Dikte: self.meeting_action.setEnabled(self.meeting_state != M_WORKING) self.meeting_cancel_action.setEnabled(self.meeting_state == M_RECORDING) - # Dictation owns the icon while it is doing something, because it is the - # one you are waiting on; otherwise the meeting gets to speak. - if self.state == IDLE and self.meeting_state != M_IDLE: + # A meeting speaks last: it runs for an hour and then works for minutes, + # so it would otherwise own the icon for most of the day. + if self.state == IDLE and self.ask_state == IDLE and self.meeting_state != M_IDLE: if self.meeting_state == M_RECORDING: icon, tip = "media-record", t("Dikte: in a meeting") else: @@ -304,19 +336,19 @@ class Dikte: # land on top of a key press; swallow the immediate repeat. if self._repeated(): return - if self.state == IDLE: - self.start() - elif self.state == RECORDING and not self.ask_mode: + if self.state == RECORDING: self.stop() - # requests during BUSY, or for the other mode's recording, are ignored + elif self.state == IDLE: + self.start() + # a request during its own BUSY is ignored; nothing queues up def _toggle_ask(self): if self._repeated(): return - if self.state == IDLE: - self.start(ask=True) - elif self.state == RECORDING and self.ask_mode: - self.stop() + 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: @@ -324,18 +356,26 @@ class Dikte: self.last_toggle.restart() return False - def start(self, ask=False): - if self.state != IDLE: + def start(self): + if self.state != IDLE or self.recording: return - self.ask_mode = ask - self.overlay.show_recording(asking=ask) - self.elapsed.restart() - self.ticker.start() + self.overlay.show_recording() + self._begin_recording(DICTATION) self._set_state(RECORDING) - self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"]) def start_ask(self): - self.start(ask=True) + if self.ask_state != IDLE or self.recording: + return + self.ask_overlay.show_recording(asking=True) + self._begin_recording(ASK) + self._set_ask_state(RECORDING) + + def _begin_recording(self, owner): + """One microphone, so one of the two holds it at a time.""" + self.recorder_owner = owner + self.elapsed.restart() + self.ticker.start() + self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"]) def stop(self): if self.state != RECORDING: @@ -345,35 +385,57 @@ class Dikte: self.overlay.show_busy(t("Transcribing…")) self.recorder.stop() - def cancel(self): - if self.state == BUSY: - # Only a Claude command can be called off once it is under way, and - # it says so itself when it lets go. - if self.ask_mode: - self.overlay.show_busy(t("Stopping…")) - self.pipeline.cancel() - return - if self.state != RECORDING: + def stop_ask(self): + if self.ask_state != RECORDING: return self.ticker.stop() + self._set_ask_state(BUSY) + self.ask_overlay.show_busy(t("Transcribing…")) + self.recorder.stop() + + def cancel(self): + """Throw away whichever recording is running.""" + if not self.recording: + return + asking = self.ask_state == RECORDING + self.ticker.stop() self.recorder.cancel() - self.overlay.dismiss() - self._set_state(IDLE) + self.recorder_owner = None + if asking: + self.ask_overlay.dismiss() + self._set_ask_state(IDLE) + else: + self.overlay.dismiss() + self._set_state(IDLE) + + def cancel_ask(self): + """Call off the agent, whether it is still recording or already working.""" + if self.ask_state == RECORDING: + self.cancel() + elif self.ask_state == BUSY: + self.ask_overlay.show_busy(t("Stopping…")) + self.ask_pipeline.cancel() def reset_conversation(self): """Drop the thread Claude has been following, so the next command starts a conversation of its own.""" assistant.clear_session() - self.overlay.show_done( + self.ask_overlay.show_done( t("{name} starts fresh next time.", name=assistant.display_name(self.conf)), 2500 ) + def _recording_overlay(self): + return self.ask_overlay if self.recorder_owner == ASK else self.overlay + + def _on_level(self, level): + self._recording_overlay().push_level(level) + def _tick(self): seconds = self.elapsed.elapsed() / 1000.0 - self.overlay.set_seconds(seconds) + self._recording_overlay().set_seconds(seconds) if seconds >= self.conf["max_seconds"]: - self.stop() + (self.stop_ask if self.recorder_owner == ASK else self.stop)() # ---- meetings --------------------------------------------------------- @@ -505,55 +567,74 @@ class Dikte: self.stop_meeting() def _on_recorded(self, wav_path, duration, rms_values): - self.pipeline.run(wav_path, duration, rms_values, ask=self.ask_mode) + owner, self.recorder_owner = self.recorder_owner, None + if owner == ASK: + self.ask_pipeline.run(wav_path, duration, rms_values, ask=True) + else: + self.pipeline.run(wav_path, duration, rms_values) def _on_finished(self, _raw, text, warning): - asked = self.ask_mode - agent = assistant.display_name(self.conf) if warning: - # The text was still pasted, but something on the way did not run. - # Say so loudly: a rejected key, or a tool Claude was not allowed to - # touch, otherwise looks exactly like a job that worked. - first_line = warning.splitlines()[0] + # 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("{name} answered, but: {error}", name=agent, error=first_line) - if asked - else t("Pasted raw, cleanup failed: {error}", error=first_line) + t("Pasted raw, cleanup failed: {error}", error=warning.splitlines()[0]) ) self.tray.showMessage( - t("Dikte: {name} could not do all of it", name=agent) if asked - else t("Dikte: cleanup failed"), - f"{warning}\n\n{text}" if asked else warning, + t("Dikte: cleanup failed"), warning, QSystemTrayIcon.MessageIcon.Warning, 10000, ) else: - preview = text.replace("\n", " ") - preview = preview[:48] + ("…" if len(preview) > 48 else "") - if asked: - # Longer than a dictation's flash: this one is an answer, and - # it is worth being able to read the start of it in the corner. - self.overlay.show_done( - t("{name}: {preview}", name=agent, preview=preview), 6000 - ) - else: - action = t("Pasted") if self.conf["auto_paste"] else t("Copied") - self.overlay.show_done( - t("{action}: {preview}", action=action, preview=preview) - ) + 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) - def _on_cancelled(self): - self.overlay.show_done(t("Stopped."), 2000) - self._set_state(IDLE) + def _on_ask_finished(self, _raw, text, warning): + agent = assistant.display_name(self.conf) + if warning: + # A tool the agent was not allowed to touch otherwise looks exactly + # like a job that worked: the reply reads perfectly normal. + self.ask_overlay.show_warning( + t("{name} answered, but: {error}", + name=agent, error=warning.splitlines()[0]) + ) + self.tray.showMessage( + t("Dikte: {name} could not do all of it", name=agent), + f"{warning}\n\n{text}", QSystemTrayIcon.MessageIcon.Warning, 10000, + ) + else: + # Longer than a dictation's flash: this one is an answer, and it is + # worth being able to read the start of it in the corner. + self.ask_overlay.show_done( + t("{name}: {preview}", name=agent, preview=_preview(text)), 6000 + ) + self._set_ask_state(IDLE) + + def _on_ask_cancelled(self): + self.ask_overlay.show_done(t("Stopped."), 2000) + self._set_ask_state(IDLE) + + 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.ticker.stop() + (self._on_ask_error if owner == ASK else self._on_error)(message) def _on_error(self, message): + self._report(message, self.overlay) + self._set_state(IDLE) + + def _on_ask_error(self, message): + self._report(message, self.ask_overlay) + self._set_ask_state(IDLE) + + def _report(self, message, overlay): first_line = message.strip().splitlines()[0] - self.overlay.show_error(first_line) + overlay.show_error(first_line) if len(message) > len(first_line): self.tray.showMessage("Dikte", message, QSystemTrayIcon.MessageIcon.Warning, 8000) - if self.state == RECORDING: - self.ticker.stop() - self._set_state(IDLE) # ---- settings --------------------------------------------------------- @@ -575,6 +656,7 @@ class Dikte: def _apply_settings(self): self.overlay.corner = self.conf["overlay_corner"] + self.ask_overlay.corner = self.conf["overlay_corner"] self._build_tray() self._refresh_tray() if self.conf["evdev_hotkey"]: @@ -596,7 +678,7 @@ class Dikte: def shutdown(self): self._quitting = True self.evdev.stop() - if self.state == RECORDING: + if self.recording: self.recorder.cancel() # A meeting in progress is closed properly rather than thrown away: the # WAV ends up valid and listed, ready to be written up after the restart. @@ -604,9 +686,15 @@ class Dikte: self.meeting_ticker.stop() self.meeting_recorder.stop() self.overlay.dismiss() + self.ask_overlay.dismiss() self.tray.hide() +def _preview(text): + line = text.replace("\n", " ") + return line[:48] + ("…" if len(line) > 48 else "") + + def _clock(seconds): minutes, secs = divmod(int(seconds), 60) hours, minutes = divmod(minutes, 60) @@ -646,7 +734,7 @@ def main(): if command and command not in ("toggle", "cancel", "settings", "restart", "quit", "start", "stop", "ask", "ask-reset", - "meeting", "meeting-cancel"): + "ask-cancel", "meeting", "meeting-cancel"): print(__doc__) return 2 @@ -660,7 +748,7 @@ def main(): return 0 if command in ("cancel", "quit", "stop", "restart", "meeting-cancel", - "ask-reset"): + "ask-reset", "ask-cancel"): return 0 if not QSystemTrayIcon.isSystemTrayAvailable(): @@ -689,6 +777,7 @@ def main(): "stop": dikte.stop, "cancel": dikte.cancel, "ask": dikte.toggle_ask, + "ask-cancel": dikte.cancel_ask, "ask-reset": dikte.reset_conversation, "meeting": dikte.toggle_meeting, "meeting-cancel": dikte.cancel_meeting, diff --git a/overlay.py b/overlay.py index a2273cb..6b25118 100644 --- a/overlay.py +++ b/overlay.py @@ -11,6 +11,7 @@ HEIGHT = 56 MIN_WIDTH = 210 MAX_WIDTH = 460 MARGIN = 28 +GAP = 10 # between two indicators sharing a corner BG = QColor(22, 24, 29, 238) BORDER = QColor(255, 255, 255, 28) @@ -31,9 +32,15 @@ LIVE = ("recording", "asking", "meeting") class Overlay(QWidget): - def __init__(self, corner="bottom-left"): + """One indicator. Give it `below` and it stacks on top of that one instead + of covering it, which is what lets a dictation and a command to the agent be + under way at the same time and still both be visible.""" + + def __init__(self, corner="bottom-left", below=None): super().__init__(None) self.corner = corner + self.below = below + self._stacked = False self.state = "idle" self.message = "" self.levels = [0.0] * BARS @@ -114,6 +121,12 @@ class Overlay(QWidget): self._hide_timer.stop() self._conceal() + @property + def showing(self): + """Mapped and actually painting something. The window stays mapped while + idle, so isVisible() alone would always say yes.""" + return self.isVisible() and not self._concealed + def push_level(self, level): self.levels = self.levels[1:] + [level] @@ -168,12 +181,21 @@ class Overlay(QWidget): area = screen.availableGeometry() left = "left" in self.corner top = "top" in self.corner + self._stacked = self.below is not None and self.below.showing + # Stack away from the edge the corner sits on, so the pair grows into the + # screen rather than off it. + step = (self.below.height() + GAP) if self._stacked else 0 x = area.left() + MARGIN if left else area.right() - self.width() - MARGIN - y = area.top() + MARGIN if top else area.bottom() - self.height() - MARGIN + y = (area.top() + MARGIN + step if top + else area.bottom() - self.height() - MARGIN - step) self.move(int(x), int(y)) def _tick(self): self._phase += 0.12 + # The one underneath can come and go while this one is up; drop back to + # the corner when it does rather than leaving a gap where it was. + if self.below is not None and self.below.showing != self._stacked: + self._reposition() if self.state in LIVE: # keep the ribbon moving even through a pause in speech self.levels = self.levels[1:] + [self.levels[-1] * 0.72] diff --git a/worker.py b/worker.py index 65411eb..7ecb04f 100644 --- a/worker.py +++ b/worker.py @@ -26,6 +26,12 @@ from i18n import t CHUNK_SECONDS = audio.CHUNK_FRAMES / audio.RATE +# A dictation and a command to the agent run side by side and can finish at the +# same moment. Pasting is not one step but three that must not interleave: read +# what is on the clipboard, put ours there, press the key. Two runs doing that +# at once would paste one answer and restore the other's clipboard over it. +_paste_lock = threading.Lock() + class Pipeline(QObject): stage = pyqtSignal(str) # human-readable progress line @@ -129,15 +135,16 @@ class Pipeline(QObject): ) warning = "\n".join(x for x in (warning, denied) if x) - previous = paste.read_clipboard() if conf["restore_clipboard"] else None - paste.copy(text) + with _paste_lock: + previous = paste.read_clipboard() if conf["restore_clipboard"] else None + paste.copy(text) - if (conf["assistant_paste"] if ask else conf["auto_paste"]): - self.stage.emit(t("Pasting…")) - paste.press(conf["paste_shortcut"]) - if previous is not None: - time.sleep(0.35) - paste.copy_bytes(previous) + if (conf["assistant_paste"] if ask else conf["auto_paste"]): + self.stage.emit(t("Pasting…")) + paste.press(conf["paste_shortcut"]) + if previous is not None: + time.sleep(0.35) + paste.copy_bytes(previous) cfg.append_history({ "ts": time.strftime("%Y-%m-%d %H:%M:%S"),