Run a dictation and a command to the agent at the same time

The two shared one state machine, which meant an agent thinking for a minute
was a minute in which nothing could be dictated. That is the wrong trade: the
work has nothing in common past the microphone, and the microphone is busy for
the seconds you are speaking, not the minute afterwards.

So they are two flows now, each with its own state, its own pipeline and its own
indicator. Either can be working while the other records. Only the recorder is
taken in turns, because there is one of it: whichever asks second is refused
while the first holds it, and the menu entry greys out to say so rather than
failing quietly. "Am I recording" is read off the two states rather than off the
recorder's owner, which outlives the recording by the moment it takes the audio
to arrive, and would otherwise report a microphone as busy after it was free.

Two indicators in one corner would sit on top of each other, so an indicator can
be told what it stacks on: it offsets by that one's height while it is showing
and drops back into the corner when it goes, which the animation timer notices
without anything having to tell it.

Pasting is now under a lock. It is three steps rather than one, read the
clipboard, write ours, press the key, and two runs finishing together would
paste one answer and restore the other's clipboard over it.

Calling off the agent is its own menu entry and its own command, instead of a
cancel that meant different things depending on what was running. The tray icon
still ends whichever recording is going.
This commit is contained in:
yusufipk
2026-07-28 18:29:48 +07:00
parent 590c6cbe1a
commit e2551343e4
5 changed files with 236 additions and 112 deletions
+3
View File
@@ -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
+4 -1
View File
@@ -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
+190 -101
View File
@@ -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,
+24 -2
View File
@@ -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]
+15 -8
View File
@@ -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"),