mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user