mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Let a recording be held while something else happens
A phone call in the middle of a dictation left two choices: send what was said so far off to be transcribed, or throw it away. Both end the sentence you were in the middle of. Now the recording can be held: the microphone stays ours, what was said before the pause stays in the buffer, and what is said during it is dropped. The capture program keeps running and keeps handing blocks over, which are read and thrown away rather than left in the pipe. Stopping it instead would mean asking the sound server for the device again on the way back, and that is the one moment another application can take it: a recording would be lost to the phone call it was paused for. The clock stops with it. Paused time is time the recording does not have, so the indicator and the length limit both go by what was actually captured, and a five minute limit is not spent waiting. The indicator says so as well, since a pulsing dot, moving bars and a counting clock otherwise all say the words are still going in: the ribbon freezes where the pause found it and turns amber behind two bars. The tray menu holds and resumes it, `dikte pause` does, and so does a global shortcut, which starts empty because holding a recording is not something a keyboard has a habit for. Dictation and a command to the agent both, whichever is recording. A meeting is left out: it writes to a file as it goes and keeps two streams aligned itself, and neither of those wants a hole in it.
This commit is contained in:
@@ -103,6 +103,7 @@ set next to it.
|
|||||||
| What | How |
|
| What | How |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Start / stop recording | `Ctrl+Space`, or click the tray icon |
|
| Start / stop recording | `Ctrl+Space`, or click the tray icon |
|
||||||
|
| Pause / resume the recording | Tray menu, `dikte pause`, or a key you set |
|
||||||
| Discard the recording | `Ctrl+Alt+Space`, tray menu, or `dikte cancel` |
|
| Discard the recording | `Ctrl+Alt+Space`, tray menu, or `dikte cancel` |
|
||||||
| Speak a command to an agent | Tray menu → *Ask Claude*, or `dikte ask` |
|
| Speak a command to an agent | Tray menu → *Ask Claude*, or `dikte ask` |
|
||||||
| Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` |
|
| Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` |
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ yanındaki kutudan düşünme seviyesini de seçebilirsin.
|
|||||||
| Ne | Nasıl |
|
| Ne | Nasıl |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Kaydı başlat / bitir | `Ctrl+Space`, ya da tepsi simgesine tıkla |
|
| Kaydı başlat / bitir | `Ctrl+Space`, ya da tepsi simgesine tıkla |
|
||||||
|
| Kaydı duraklat / sürdür | Tepsi menüsü, `dikte pause`, ya da atadığın bir tuş |
|
||||||
| Kaydı iptal et | `Ctrl+Alt+Space`, tepsi menüsü, ya da `dikte cancel` |
|
| Kaydı iptal et | `Ctrl+Alt+Space`, tepsi menüsü, ya da `dikte cancel` |
|
||||||
| Ajana sesle komut ver | Tepsi menüsü → *Claude'a sor*, ya da `dikte ask` |
|
| Ajana sesle komut ver | Tepsi menüsü → *Claude'a sor*, ya da `dikte ask` |
|
||||||
| Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` |
|
| Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` |
|
||||||
|
|||||||
@@ -72,12 +72,31 @@ class Recorder(QObject):
|
|||||||
self._rms = []
|
self._rms = []
|
||||||
self._cancelled = False
|
self._cancelled = False
|
||||||
self._stopping = False
|
self._stopping = False
|
||||||
|
self._paused = False
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active(self):
|
def active(self):
|
||||||
return self._thread is not None and self._thread.is_alive()
|
return self._thread is not None and self._thread.is_alive()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def paused(self):
|
||||||
|
return self._paused
|
||||||
|
|
||||||
|
def pause(self, value=True):
|
||||||
|
"""Stop taking sound in without letting go of the microphone.
|
||||||
|
|
||||||
|
The capture program keeps running and keeps handing blocks over; they
|
||||||
|
are dropped as they arrive rather than kept. Stopping it instead would
|
||||||
|
mean asking the sound server for the device again on the way back, and
|
||||||
|
that is the one moment another application can take it: a recording
|
||||||
|
would be lost to the phone call it was paused for.
|
||||||
|
|
||||||
|
What was said while it was paused is gone, which is the point. The two
|
||||||
|
halves meet as one splice, with none of the room in between.
|
||||||
|
"""
|
||||||
|
self._paused = bool(value)
|
||||||
|
|
||||||
def start(self, target="", max_seconds=300):
|
def start(self, target="", max_seconds=300):
|
||||||
if self.active:
|
if self.active:
|
||||||
return
|
return
|
||||||
@@ -102,6 +121,7 @@ class Recorder(QObject):
|
|||||||
self._rms = []
|
self._rms = []
|
||||||
self._cancelled = False
|
self._cancelled = False
|
||||||
self._stopping = False
|
self._stopping = False
|
||||||
|
self._paused = False
|
||||||
self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS)
|
self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS)
|
||||||
self._thread = threading.Thread(target=self._pump, daemon=True)
|
self._thread = threading.Thread(target=self._pump, daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
@@ -114,6 +134,11 @@ class Recorder(QObject):
|
|||||||
chunk = stdout.read(CHUNK_BYTES)
|
chunk = stdout.read(CHUNK_BYTES)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
|
if self._paused:
|
||||||
|
# Read and thrown away rather than left in the pipe: a pipe
|
||||||
|
# nobody empties fills up, and the capture program blocks on
|
||||||
|
# a full one instead of waiting quietly for the resume.
|
||||||
|
continue
|
||||||
peak, rms = chunk_levels(chunk)
|
peak, rms = chunk_levels(chunk)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._buffer.extend(chunk)
|
self._buffer.extend(chunk)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"}
|
|||||||
# Asking a process that is not there to stop, cancel or quit is not a failure;
|
# Asking a process that is not there to stop, cancel or quit is not a failure;
|
||||||
# it is already in the state that was asked for.
|
# it is already in the state that was asked for.
|
||||||
IDEMPOTENT_VERBS = {"cancel", "stop", "quit", "restart", "ask-cancel",
|
IDEMPOTENT_VERBS = {"cancel", "stop", "quit", "restart", "ask-cancel",
|
||||||
"ask-reset", "meeting-cancel"}
|
"ask-reset", "meeting-cancel", "pause"}
|
||||||
|
|
||||||
_app = None
|
_app = None
|
||||||
|
|
||||||
@@ -765,7 +765,8 @@ def cmd_status(opts):
|
|||||||
return fail(opts, "the running instance is from before it could answer "
|
return fail(opts, "the running instance is from before it could answer "
|
||||||
"questions; reload it with: dikte restart", 1, running=True)
|
"questions; reload it with: dikte restart", 1, running=True)
|
||||||
lines = [
|
lines = [
|
||||||
f"dictation: {reply.get('dictation', '?')}",
|
f"dictation: {reply.get('dictation', '?')}"
|
||||||
|
+ (" (paused)" if reply.get("paused") else ""),
|
||||||
f"agent: {reply.get('ask', '?')} ({reply.get('agent', '?')})",
|
f"agent: {reply.get('ask', '?')} ({reply.get('agent', '?')})",
|
||||||
f"meeting: {reply.get('meeting', '?')}"
|
f"meeting: {reply.get('meeting', '?')}"
|
||||||
+ (f" {reply['meeting_message']}" if reply.get("meeting_message") else ""),
|
+ (f" {reply['meeting_message']}" if reply.get("meeting_message") else ""),
|
||||||
@@ -879,6 +880,10 @@ def build_parser():
|
|||||||
page.add_argument("--timeout", type=float, default=0)
|
page.add_argument("--timeout", type=float, default=0)
|
||||||
page.set_defaults(func=cmd_toggle)
|
page.set_defaults(func=cmd_toggle)
|
||||||
|
|
||||||
|
# One verb for both halves, the way `toggle` is one verb: a key can only be
|
||||||
|
# pressed, so a key that resumed nothing would need a second key.
|
||||||
|
leaf(subs, "pause", "hold the recording, or take it up again"
|
||||||
|
).set_defaults(func=cmd_plain)
|
||||||
leaf(subs, "cancel", "throw away the recording").set_defaults(func=cmd_cancel)
|
leaf(subs, "cancel", "throw away the recording").set_defaults(func=cmd_cancel)
|
||||||
|
|
||||||
# --- the agent --------------------------------------------------------
|
# --- the agent --------------------------------------------------------
|
||||||
|
|||||||
@@ -435,6 +435,10 @@ DEFAULTS = {
|
|||||||
# trick lands on the toggle, Alt and Option being one key, so discarding
|
# trick lands on the toggle, Alt and Option being one key, so discarding
|
||||||
# gets a letter instead.
|
# gets a letter instead.
|
||||||
"cancel_shortcut": "Ctrl+Option+D" if _MACOS else "Ctrl+Alt+Space",
|
"cancel_shortcut": "Ctrl+Option+D" if _MACOS else "Ctrl+Alt+Space",
|
||||||
|
# Empty -> tray only. Holding a recording is not something a keyboard has a
|
||||||
|
# habit for, and a combination nobody asked for is one taken away from
|
||||||
|
# whatever else was using it.
|
||||||
|
"pause_shortcut": "",
|
||||||
"evdev_hotkey": False,
|
"evdev_hotkey": False,
|
||||||
"overlay_corner": "bottom-left",
|
"overlay_corner": "bottom-left",
|
||||||
"keep_audio": False,
|
"keep_audio": False,
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ class Dikte:
|
|||||||
self.ask_state = IDLE
|
self.ask_state = IDLE
|
||||||
# Which of the two the microphone is currently serving, or None.
|
# Which of the two the microphone is currently serving, or None.
|
||||||
self.recorder_owner = None
|
self.recorder_owner = None
|
||||||
|
# A recording that is running but taking nothing in. Not a state of its
|
||||||
|
# own: everything that can be done to a recording can be done to a
|
||||||
|
# paused one, and a fourth state would have to say so four times over.
|
||||||
|
self.paused = False
|
||||||
|
# Paused time, which is time the recording does not have: the clock on
|
||||||
|
# screen and the limit both go by what was actually captured.
|
||||||
|
self._paused_ms = 0
|
||||||
|
self._paused_at = 0
|
||||||
self.meeting_state = M_IDLE
|
self.meeting_state = M_IDLE
|
||||||
self.meeting_base = ""
|
self.meeting_base = ""
|
||||||
self.meeting_message = ""
|
self.meeting_message = ""
|
||||||
@@ -159,6 +167,12 @@ class Dikte:
|
|||||||
self.toggle_action.triggered.connect(self._toggle)
|
self.toggle_action.triggered.connect(self._toggle)
|
||||||
self.menu.addAction(self.toggle_action)
|
self.menu.addAction(self.toggle_action)
|
||||||
|
|
||||||
|
# Named in _refresh_tray as well, since it says one of two things.
|
||||||
|
self.pause_action = QAction(t("Pause the recording"), self.menu)
|
||||||
|
self.pause_action.triggered.connect(self._toggle_pause)
|
||||||
|
self.pause_action.setEnabled(False)
|
||||||
|
self.menu.addAction(self.pause_action)
|
||||||
|
|
||||||
# Named in _refresh_tray, which is where the chosen provider is known.
|
# Named in _refresh_tray, which is where the chosen provider is known.
|
||||||
self.ask_action = QAction("", self.menu)
|
self.ask_action = QAction("", self.menu)
|
||||||
self.ask_action.triggered.connect(self._toggle_ask)
|
self.ask_action.triggered.connect(self._toggle_ask)
|
||||||
@@ -283,6 +297,9 @@ class Dikte:
|
|||||||
or (self.ask_state == IDLE and not self.recording)
|
or (self.ask_state == IDLE and not self.recording)
|
||||||
)
|
)
|
||||||
self.reset_action.setEnabled(self.ask_state != BUSY)
|
self.reset_action.setEnabled(self.ask_state != BUSY)
|
||||||
|
self.pause_action.setText(t("Resume the recording") if self.paused
|
||||||
|
else t("Pause the recording"))
|
||||||
|
self.pause_action.setEnabled(self.recording)
|
||||||
self.cancel_action.setEnabled(self.recording)
|
self.cancel_action.setEnabled(self.recording)
|
||||||
# A command to the agent is the one job long enough to be worth calling
|
# A command to the agent is the one job long enough to be worth calling
|
||||||
# off once it is already running.
|
# off once it is already running.
|
||||||
@@ -299,6 +316,12 @@ class Dikte:
|
|||||||
else:
|
else:
|
||||||
icon, tip = "view-refresh", "Dikte: talking to Claude"
|
icon, tip = "view-refresh", "Dikte: talking to Claude"
|
||||||
|
|
||||||
|
# Whichever of the two is holding the microphone, a recording dot that
|
||||||
|
# keeps burning while nothing goes in is the icon telling the opposite
|
||||||
|
# of what is happening.
|
||||||
|
if self.paused and self.recording:
|
||||||
|
icon, tip = "media-playback-pause", "Dikte: paused"
|
||||||
|
|
||||||
meeting_labels = {
|
meeting_labels = {
|
||||||
M_IDLE: "Record a meeting",
|
M_IDLE: "Record a meeting",
|
||||||
M_RECORDING: "End the meeting and write it up",
|
M_RECORDING: "End the meeting and write it up",
|
||||||
@@ -334,6 +357,9 @@ class Dikte:
|
|||||||
def toggle_meeting(self):
|
def toggle_meeting(self):
|
||||||
self._external("meeting", self._toggle_meeting)
|
self._external("meeting", self._toggle_meeting)
|
||||||
|
|
||||||
|
def toggle_pause(self):
|
||||||
|
self._external("pause", self._toggle_pause)
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
self._external("cancel", self._cancel)
|
self._external("cancel", self._cancel)
|
||||||
|
|
||||||
@@ -358,7 +384,7 @@ class Dikte:
|
|||||||
timer = self.last_evdev[name] = QElapsedTimer()
|
timer = self.last_evdev[name] = QElapsedTimer()
|
||||||
timer.restart()
|
timer.restart()
|
||||||
handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask,
|
handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask,
|
||||||
"cancel": self._cancel}
|
"cancel": self._cancel, "pause": self._toggle_pause}
|
||||||
handlers.get(name, self._toggle)()
|
handlers.get(name, self._toggle)()
|
||||||
|
|
||||||
def _retire_listener(self):
|
def _retire_listener(self):
|
||||||
@@ -393,6 +419,7 @@ class Dikte:
|
|||||||
else:
|
else:
|
||||||
handler = {
|
handler = {
|
||||||
"cancel": self.cancel,
|
"cancel": self.cancel,
|
||||||
|
"pause": self.toggle_pause,
|
||||||
"ask-cancel": self.cancel_ask,
|
"ask-cancel": self.cancel_ask,
|
||||||
"ask-reset": self.reset_conversation,
|
"ask-reset": self.reset_conversation,
|
||||||
"meeting-cancel": self.cancel_meeting,
|
"meeting-cancel": self.cancel_meeting,
|
||||||
@@ -475,6 +502,7 @@ class Dikte:
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"running": True,
|
"running": True,
|
||||||
"dictation": self.state,
|
"dictation": self.state,
|
||||||
|
"paused": self.paused,
|
||||||
"ask": self.ask_state,
|
"ask": self.ask_state,
|
||||||
"meeting": self.meeting_state,
|
"meeting": self.meeting_state,
|
||||||
"meeting_base": self.meetings.running_base,
|
"meeting_base": self.meetings.running_base,
|
||||||
@@ -538,6 +566,7 @@ class Dikte:
|
|||||||
"""One microphone, so one of the two holds it at a time."""
|
"""One microphone, so one of the two holds it at a time."""
|
||||||
self.recorder_owner = owner
|
self.recorder_owner = owner
|
||||||
self._run_id += 1
|
self._run_id += 1
|
||||||
|
self._clear_pause()
|
||||||
self.elapsed.restart()
|
self.elapsed.restart()
|
||||||
self.ticker.start()
|
self.ticker.start()
|
||||||
self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"])
|
self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"])
|
||||||
@@ -546,6 +575,7 @@ class Dikte:
|
|||||||
if self.state != RECORDING:
|
if self.state != RECORDING:
|
||||||
return
|
return
|
||||||
self.ticker.stop()
|
self.ticker.stop()
|
||||||
|
self._clear_pause()
|
||||||
self._set_state(BUSY)
|
self._set_state(BUSY)
|
||||||
self.overlay.show_busy(t("Transcribing…"))
|
self.overlay.show_busy(t("Transcribing…"))
|
||||||
self.recorder.stop()
|
self.recorder.stop()
|
||||||
@@ -554,16 +584,50 @@ class Dikte:
|
|||||||
if self.ask_state != RECORDING:
|
if self.ask_state != RECORDING:
|
||||||
return
|
return
|
||||||
self.ticker.stop()
|
self.ticker.stop()
|
||||||
|
self._clear_pause()
|
||||||
self._set_ask_state(BUSY)
|
self._set_ask_state(BUSY)
|
||||||
self.ask_overlay.show_busy(t("Transcribing…"))
|
self.ask_overlay.show_busy(t("Transcribing…"))
|
||||||
self.recorder.stop()
|
self.recorder.stop()
|
||||||
|
|
||||||
|
def _toggle_pause(self):
|
||||||
|
"""Hold the recording where it is, or take it up again.
|
||||||
|
|
||||||
|
A pause is not a stop: the microphone stays ours and what has been said
|
||||||
|
so far stays in the buffer. What is said while it is held is dropped, so
|
||||||
|
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():
|
||||||
|
return
|
||||||
|
self.paused = not self.paused
|
||||||
|
if self.paused:
|
||||||
|
self._paused_at = self.elapsed.elapsed()
|
||||||
|
else:
|
||||||
|
self._paused_ms += self.elapsed.elapsed() - self._paused_at
|
||||||
|
self.recorder.pause(self.paused)
|
||||||
|
self._recording_overlay().set_paused(self.paused)
|
||||||
|
self._refresh_tray()
|
||||||
|
|
||||||
|
def _clear_pause(self):
|
||||||
|
"""Every recording starts and ends taking sound in."""
|
||||||
|
self.paused = False
|
||||||
|
self._paused_ms = 0
|
||||||
|
self._paused_at = 0
|
||||||
|
|
||||||
|
def _recorded_seconds(self):
|
||||||
|
"""Wall clock less whatever was held: the length of what will be
|
||||||
|
transcribed, which is what the limit has to be measured against too."""
|
||||||
|
# A held recording is as long now as it was when it was held.
|
||||||
|
now = self._paused_at if self.paused else self.elapsed.elapsed()
|
||||||
|
return max(0.0, (now - self._paused_ms) / 1000.0)
|
||||||
|
|
||||||
def _cancel(self):
|
def _cancel(self):
|
||||||
"""Throw away whichever recording is running."""
|
"""Throw away whichever recording is running."""
|
||||||
if not self.recording:
|
if not self.recording:
|
||||||
return
|
return
|
||||||
asking = self.ask_state == RECORDING
|
asking = self.ask_state == RECORDING
|
||||||
self.ticker.stop()
|
self.ticker.stop()
|
||||||
|
self._clear_pause()
|
||||||
self.recorder.cancel()
|
self.recorder.cancel()
|
||||||
self.recorder_owner = None
|
self.recorder_owner = None
|
||||||
# What goes over the socket is read by a program as often as by a
|
# What goes over the socket is read by a program as often as by a
|
||||||
@@ -603,7 +667,7 @@ class Dikte:
|
|||||||
self._recording_overlay().push_level(level)
|
self._recording_overlay().push_level(level)
|
||||||
|
|
||||||
def _tick(self):
|
def _tick(self):
|
||||||
seconds = self.elapsed.elapsed() / 1000.0
|
seconds = self._recorded_seconds()
|
||||||
self._recording_overlay().set_seconds(seconds)
|
self._recording_overlay().set_seconds(seconds)
|
||||||
if seconds >= self.conf["max_seconds"]:
|
if seconds >= self.conf["max_seconds"]:
|
||||||
(self.stop_ask if self.recorder_owner == ASK else self.stop)()
|
(self.stop_ask if self.recorder_owner == ASK else self.stop)()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from i18n import t
|
|||||||
|
|
||||||
DESKTOP_ID = "dikte-toggle.desktop"
|
DESKTOP_ID = "dikte-toggle.desktop"
|
||||||
CANCEL_DESKTOP_ID = "dikte-cancel.desktop"
|
CANCEL_DESKTOP_ID = "dikte-cancel.desktop"
|
||||||
|
PAUSE_DESKTOP_ID = "dikte-pause.desktop"
|
||||||
MEETING_DESKTOP_ID = "dikte-meeting.desktop"
|
MEETING_DESKTOP_ID = "dikte-meeting.desktop"
|
||||||
ASK_DESKTOP_ID = "dikte-ask.desktop"
|
ASK_DESKTOP_ID = "dikte-ask.desktop"
|
||||||
APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications"
|
APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications"
|
||||||
@@ -39,7 +40,7 @@ GNOME_BINDING_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys.custom-keyb
|
|||||||
|
|
||||||
Shortcut = collections.namedtuple("Shortcut", "verb desktop_id name setting fallback")
|
Shortcut = collections.namedtuple("Shortcut", "verb desktop_id name setting fallback")
|
||||||
|
|
||||||
# Every global shortcut in one place, because there are four of them and the
|
# Every global shortcut in one place, because there are five of them and the
|
||||||
# command line, the settings window and the installer each used to carry their
|
# command line, the settings window and the installer each used to carry their
|
||||||
# own copy of the list. `fallback` is what to register when the setting is
|
# own copy of the list. `fallback` is what to register when the setting is
|
||||||
# empty: only the toggle has one, since it is the key the application is
|
# empty: only the toggle has one, since it is the key the application is
|
||||||
@@ -47,6 +48,8 @@ Shortcut = collections.namedtuple("Shortcut", "verb desktop_id name setting fall
|
|||||||
SHORTCUTS = {
|
SHORTCUTS = {
|
||||||
"toggle": Shortcut("toggle", DESKTOP_ID, "Dikte: start/stop recording",
|
"toggle": Shortcut("toggle", DESKTOP_ID, "Dikte: start/stop recording",
|
||||||
"shortcut", "Ctrl+Space"),
|
"shortcut", "Ctrl+Space"),
|
||||||
|
"pause": Shortcut("pause", PAUSE_DESKTOP_ID,
|
||||||
|
"Dikte: pause/resume the recording", "pause_shortcut", ""),
|
||||||
"cancel": Shortcut("cancel", CANCEL_DESKTOP_ID, "Dikte: discard the recording",
|
"cancel": Shortcut("cancel", CANCEL_DESKTOP_ID, "Dikte: discard the recording",
|
||||||
"cancel_shortcut", ""),
|
"cancel_shortcut", ""),
|
||||||
"ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask Claude Code",
|
"ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask Claude Code",
|
||||||
|
|||||||
@@ -56,12 +56,15 @@ TR = {
|
|||||||
"Start recording": "Kaydı başlat",
|
"Start recording": "Kaydı başlat",
|
||||||
"Stop and transcribe": "Kaydı bitir ve yaz",
|
"Stop and transcribe": "Kaydı bitir ve yaz",
|
||||||
"Working…": "İşleniyor…",
|
"Working…": "İşleniyor…",
|
||||||
|
"Pause the recording": "Kaydı duraklat",
|
||||||
|
"Resume the recording": "Kayda devam et",
|
||||||
"Discard the recording": "Kaydı iptal et",
|
"Discard the recording": "Kaydı iptal et",
|
||||||
"Settings…": "Ayarlar…",
|
"Settings…": "Ayarlar…",
|
||||||
"Restart": "Yeniden başlat",
|
"Restart": "Yeniden başlat",
|
||||||
"Quit": "Çık",
|
"Quit": "Çık",
|
||||||
"Dikte: ready": "Dikte: hazır",
|
"Dikte: ready": "Dikte: hazır",
|
||||||
"Dikte: recording": "Dikte: kaydediyor",
|
"Dikte: recording": "Dikte: kaydediyor",
|
||||||
|
"Dikte: paused": "Dikte: duraklatıldı",
|
||||||
"Dikte: working": "Dikte: işleniyor",
|
"Dikte: working": "Dikte: işleniyor",
|
||||||
|
|
||||||
# --- overlay / pipeline -------------------------------------------
|
# --- overlay / pipeline -------------------------------------------
|
||||||
@@ -312,7 +315,14 @@ TR = {
|
|||||||
"Global kısayol kurulu değil. Tepsi menüsünden de soru sorulabilir.",
|
"Global kısayol kurulu değil. Tepsi menüsünden de soru sorulabilir.",
|
||||||
"No global shortcut installed. The tray menu discards it too.":
|
"No global shortcut installed. The tray menu discards it too.":
|
||||||
"Global kısayol kurulu değil. Kayıt tepsi menüsünden de iptal edilebilir.",
|
"Global kısayol kurulu değil. Kayıt tepsi menüsünden de iptal edilebilir.",
|
||||||
|
"No global shortcut installed. The tray menu holds it too.":
|
||||||
|
"Global kısayol kurulu değil. Kayıt tepsi menüsünden de duraklatılabilir.",
|
||||||
"Start and stop": "Başlat ve bitir",
|
"Start and stop": "Başlat ve bitir",
|
||||||
|
"Pause and resume": "Duraklat ve devam et",
|
||||||
|
"Holds the recording without ending it. Nothing said while it is paused is "
|
||||||
|
"kept, and the clock stops with it.":
|
||||||
|
"Kaydı bitirmeden duraklatır. Duraklatıldığı sürede konuşulanlar "
|
||||||
|
"kaydedilmez, süre sayacı da onunla birlikte durur.",
|
||||||
"Throws the recording away without transcribing it. Works on a dictation "
|
"Throws the recording away without transcribing it. Works on a dictation "
|
||||||
"and on a command for the agent alike, whichever is running.":
|
"and on a command for the agent alike, whichever is running.":
|
||||||
"Kaydı yazıya dökmeden atar. Hangisi çalışıyorsa ona işler: dikteye de, "
|
"Kaydı yazıya dökmeden atar. Hangisi çalışıyorsa ona işler: dikteye de, "
|
||||||
|
|||||||
+37
-2
@@ -26,6 +26,9 @@ WARN = QColor(240, 180, 80)
|
|||||||
THEM = QColor(110, 190, 255) # the other side of a meeting
|
THEM = QColor(110, 190, 255) # the other side of a meeting
|
||||||
|
|
||||||
ASK = QColor(150, 140, 255) # recording a command rather than a dictation
|
ASK = QColor(150, 140, 255) # recording a command rather than a dictation
|
||||||
|
# Recording, but nothing is going in. The same amber a warning gets, and for
|
||||||
|
# the same reason: it is the colour that stops you walking away from it.
|
||||||
|
HELD = WARN
|
||||||
|
|
||||||
STATE_COLORS = {"recording": REC, "asking": ASK, "meeting": REC, "busy": BUSY,
|
STATE_COLORS = {"recording": REC, "asking": ASK, "meeting": REC, "busy": BUSY,
|
||||||
"done": OK, "warning": WARN, "error": ERR}
|
"done": OK, "warning": WARN, "error": ERR}
|
||||||
@@ -47,6 +50,10 @@ class Overlay(QWidget):
|
|||||||
self.dismissable = dismissable
|
self.dismissable = dismissable
|
||||||
self.muted = False
|
self.muted = False
|
||||||
self._stacked = False
|
self._stacked = False
|
||||||
|
# A pause is not a state of its own: what is on screen is still the
|
||||||
|
# recording, held. Keeping it beside the state is what lets the ribbon
|
||||||
|
# stay where the pause found it instead of being cleared and rebuilt.
|
||||||
|
self.paused = False
|
||||||
self.state = "idle"
|
self.state = "idle"
|
||||||
self.message = ""
|
self.message = ""
|
||||||
self.levels = [0.0] * BARS
|
self.levels = [0.0] * BARS
|
||||||
@@ -102,6 +109,7 @@ class Overlay(QWidget):
|
|||||||
self.message = ""
|
self.message = ""
|
||||||
self.seconds = 0.0
|
self.seconds = 0.0
|
||||||
self.levels = [0.0] * BARS
|
self.levels = [0.0] * BARS
|
||||||
|
self.paused = False
|
||||||
self.muted = False # a new run starts visible, whatever the last one did
|
self.muted = False # a new run starts visible, whatever the last one did
|
||||||
self._hide_timer.stop()
|
self._hide_timer.stop()
|
||||||
self._appear()
|
self._appear()
|
||||||
@@ -113,6 +121,7 @@ class Overlay(QWidget):
|
|||||||
self.seconds = 0.0
|
self.seconds = 0.0
|
||||||
self.levels = [0.0] * BARS
|
self.levels = [0.0] * BARS
|
||||||
self.levels2 = [0.0] * BARS
|
self.levels2 = [0.0] * BARS
|
||||||
|
self.paused = False
|
||||||
self._hide_timer.stop()
|
self._hide_timer.stop()
|
||||||
self._appear()
|
self._appear()
|
||||||
|
|
||||||
@@ -174,6 +183,17 @@ class Overlay(QWidget):
|
|||||||
def set_seconds(self, seconds):
|
def set_seconds(self, seconds):
|
||||||
self.seconds = seconds
|
self.seconds = seconds
|
||||||
|
|
||||||
|
def set_paused(self, paused):
|
||||||
|
"""Held, or taking sound in again.
|
||||||
|
|
||||||
|
Everything about the ribbon says a recording is running: a dot that
|
||||||
|
pulses, bars that move, a clock that counts. A pause that only stopped
|
||||||
|
the sound would leave all three saying the words are still going in, so
|
||||||
|
it is the ribbon that has to say otherwise.
|
||||||
|
"""
|
||||||
|
self.paused = bool(paused)
|
||||||
|
self.update()
|
||||||
|
|
||||||
# ---- internals -----------------------------------------------------
|
# ---- internals -----------------------------------------------------
|
||||||
|
|
||||||
def _appear(self):
|
def _appear(self):
|
||||||
@@ -243,7 +263,7 @@ class Overlay(QWidget):
|
|||||||
# the corner when it does rather than leaving a gap where it was.
|
# 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:
|
if self.below is not None and self.below.showing != self._stacked:
|
||||||
self._reposition()
|
self._reposition()
|
||||||
if self.state in LIVE:
|
if self.state in LIVE and not self.paused:
|
||||||
# keep the ribbon moving even through a pause in speech
|
# keep the ribbon moving even through a pause in speech
|
||||||
self.levels = self.levels[1:] + [self.levels[-1] * 0.72]
|
self.levels = self.levels[1:] + [self.levels[-1] * 0.72]
|
||||||
if self.state == "meeting":
|
if self.state == "meeting":
|
||||||
@@ -272,6 +292,8 @@ class Overlay(QWidget):
|
|||||||
painter.drawPath(path)
|
painter.drawPath(path)
|
||||||
|
|
||||||
accent = STATE_COLORS.get(self.state, MUTED)
|
accent = STATE_COLORS.get(self.state, MUTED)
|
||||||
|
if self._held:
|
||||||
|
accent = HELD
|
||||||
self._draw_indicator(painter, accent)
|
self._draw_indicator(painter, accent)
|
||||||
|
|
||||||
if self.state in LIVE:
|
if self.state in LIVE:
|
||||||
@@ -282,10 +304,23 @@ class Overlay(QWidget):
|
|||||||
if self._can_dismiss:
|
if self._can_dismiss:
|
||||||
self._draw_dismiss(painter)
|
self._draw_dismiss(painter)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _held(self):
|
||||||
|
"""A recording that is paused. Nothing else can be."""
|
||||||
|
return self.paused and self.state in LIVE
|
||||||
|
|
||||||
def _draw_indicator(self, painter, accent):
|
def _draw_indicator(self, painter, accent):
|
||||||
cx, cy = 26.0, self.height() / 2
|
cx, cy = 26.0, self.height() / 2
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
if self.state in LIVE:
|
if self._held:
|
||||||
|
# The two bars everything that plays sound uses, and no glow: a
|
||||||
|
# pulse is what says a recording is live.
|
||||||
|
painter.setBrush(accent)
|
||||||
|
for offset in (-4.4, 1.4):
|
||||||
|
painter.drawRoundedRect(
|
||||||
|
QRectF(cx + offset, cy - 6.5, 3.0, 13.0), 1.2, 1.2
|
||||||
|
)
|
||||||
|
elif self.state in LIVE:
|
||||||
pulse = 0.62 + 0.38 * (0.5 + 0.5 * math.sin(self._phase * 1.6))
|
pulse = 0.62 + 0.38 * (0.5 + 0.5 * math.sin(self._phase * 1.6))
|
||||||
glow = QColor(accent)
|
glow = QColor(accent)
|
||||||
glow.setAlphaF(0.22 * pulse)
|
glow.setAlphaF(0.22 * pulse)
|
||||||
|
|||||||
+10
-2
@@ -1321,6 +1321,14 @@ class SettingsWindow(QDialog):
|
|||||||
form, "toggle", t("Start and stop"),
|
form, "toggle", t("Start and stop"),
|
||||||
t("No global shortcut installed."), placeholder="Ctrl+Space",
|
t("No global shortcut installed."), placeholder="Ctrl+Space",
|
||||||
)
|
)
|
||||||
|
# The point of holding a recording is that something else came up, and
|
||||||
|
# something else is exactly when a hand is not free for a menu.
|
||||||
|
self._shortcut_row(
|
||||||
|
form, "pause", t("Pause and resume"),
|
||||||
|
t("No global shortcut installed. The tray menu holds it too."),
|
||||||
|
tooltip=t("Holds the recording without ending it. Nothing said "
|
||||||
|
"while it is paused is kept, and the clock stops with it."),
|
||||||
|
)
|
||||||
# Stopping is what sends the recording off to be transcribed, and that
|
# Stopping is what sends the recording off to be transcribed, and that
|
||||||
# is the step there is no taking back. By the time the tray menu is
|
# is the step there is no taking back. By the time the tray menu is
|
||||||
# open the sentence you did not mean to dictate is already on its way.
|
# open the sentence you did not mean to dictate is already on its way.
|
||||||
@@ -1700,8 +1708,8 @@ class SettingsWindow(QDialog):
|
|||||||
conf["file_cleanup"] = self.file_cleanup.isChecked()
|
conf["file_cleanup"] = self.file_cleanup.isChecked()
|
||||||
|
|
||||||
# Left empty, only the toggle falls back to a default: the application
|
# Left empty, only the toggle falls back to a default: the application
|
||||||
# is unusable without it. The other three stay empty, which is what
|
# is unusable without it. The rest stay empty, which is what turns
|
||||||
# turns them off.
|
# them off.
|
||||||
for which, (box, _status, _missing) in self._shortcut_rows.items():
|
for which, (box, _status, _missing) in self._shortcut_rows.items():
|
||||||
spec = hotkey.SHORTCUTS[which]
|
spec = hotkey.SHORTCUTS[which]
|
||||||
conf[spec.setting] = (box.currentText().strip()
|
conf[spec.setting] = (box.currentText().strip()
|
||||||
|
|||||||
@@ -280,6 +280,26 @@ class _StalledStream:
|
|||||||
self._released.set()
|
self._released.set()
|
||||||
|
|
||||||
|
|
||||||
|
class _HeldStream:
|
||||||
|
"""A capture that is paused and taken up again partway through, the way a
|
||||||
|
key press lands in the middle of a recording rather than between two."""
|
||||||
|
|
||||||
|
def __init__(self, data, recorder, pause_at, resume_at=None):
|
||||||
|
self._data = io.BytesIO(data)
|
||||||
|
self._recorder = recorder
|
||||||
|
self._pause_at = pause_at
|
||||||
|
self._resume_at = resume_at
|
||||||
|
self.reads = 0
|
||||||
|
|
||||||
|
def read(self, size):
|
||||||
|
if self.reads == self._pause_at:
|
||||||
|
self._recorder.pause()
|
||||||
|
elif self.reads == self._resume_at:
|
||||||
|
self._recorder.pause(False)
|
||||||
|
self.reads += 1
|
||||||
|
return self._data.read(size)
|
||||||
|
|
||||||
|
|
||||||
class RecordingCommand(OnLinux, DikteTest):
|
class RecordingCommand(OnLinux, DikteTest):
|
||||||
"""Which program captures the microphone, and how it is asked to."""
|
"""Which program captures the microphone, and how it is asked to."""
|
||||||
|
|
||||||
@@ -499,6 +519,51 @@ class RecorderChain(OnLinux, DikteTest):
|
|||||||
self.assertEqual(len(failures), 1)
|
self.assertEqual(len(failures), 1)
|
||||||
self.assertIn("0.3", failures[0])
|
self.assertIn("0.3", failures[0])
|
||||||
|
|
||||||
|
def held(self, data, pause_at, resume_at=None):
|
||||||
|
"""Record `data` with the recorder paused for part of it."""
|
||||||
|
recorder = audio.Recorder()
|
||||||
|
results = []
|
||||||
|
failures = []
|
||||||
|
recorder.stopped.connect(lambda *args: results.append(args))
|
||||||
|
recorder.failed.connect(failures.append)
|
||||||
|
proc = FakeProcess(data)
|
||||||
|
proc.stdout = _HeldStream(data, recorder, pause_at, resume_at)
|
||||||
|
with only_these_tools("pw-record"), \
|
||||||
|
mock.patch.object(subprocess, "Popen", return_value=proc):
|
||||||
|
recorder.start()
|
||||||
|
recorder._thread.join(timeout=5)
|
||||||
|
# Nothing has ended the capture: a pause holds the microphone.
|
||||||
|
self.assertEqual(proc.signals, [])
|
||||||
|
recorder.stop()
|
||||||
|
return results, failures
|
||||||
|
|
||||||
|
def test_what_is_said_while_it_is_held_is_not_in_the_recording(self):
|
||||||
|
"""The phone call in the middle of a dictation is the whole feature: it
|
||||||
|
must not reach the transcript, and the two halves must meet."""
|
||||||
|
results, failures = self.held(tone(2.0), pause_at=8, resume_at=16)
|
||||||
|
self.assertEqual(failures, [])
|
||||||
|
path, duration, _ = results[0]
|
||||||
|
self.addCleanup(os.unlink, path)
|
||||||
|
dropped = 8 * audio.CHUNK_FRAMES
|
||||||
|
self.assertAlmostEqual(duration, (2 * audio.RATE - dropped) / audio.RATE,
|
||||||
|
places=3)
|
||||||
|
|
||||||
|
def test_a_recording_held_all_the_way_through_captured_nothing(self):
|
||||||
|
results, failures = self.held(tone(2.0), pause_at=0)
|
||||||
|
self.assertEqual(results, [])
|
||||||
|
self.assertIn("0.3", failures[0])
|
||||||
|
|
||||||
|
def test_a_pause_does_not_outlive_the_recording_it_was_asked_for(self):
|
||||||
|
recorder = audio.Recorder()
|
||||||
|
recorder.pause()
|
||||||
|
proc = FakeProcess(tone(0.5))
|
||||||
|
with only_these_tools("pw-record"), \
|
||||||
|
mock.patch.object(subprocess, "Popen", return_value=proc):
|
||||||
|
recorder.start()
|
||||||
|
self.assertFalse(recorder.paused)
|
||||||
|
recorder._thread.join(timeout=5)
|
||||||
|
recorder.cancel()
|
||||||
|
|
||||||
def test_a_recorder_that_could_not_start(self):
|
def test_a_recorder_that_could_not_start(self):
|
||||||
recorder = audio.Recorder()
|
recorder = audio.Recorder()
|
||||||
failures = []
|
failures = []
|
||||||
|
|||||||
+9
-1
@@ -162,7 +162,7 @@ class Parser(unittest.TestCase):
|
|||||||
name)
|
name)
|
||||||
|
|
||||||
def test_every_verb_is_wired_to_something(self):
|
def test_every_verb_is_wired_to_something(self):
|
||||||
for verb in ("record", "toggle", "start", "stop", "cancel", "ask",
|
for verb in ("record", "toggle", "start", "stop", "pause", "cancel", "ask",
|
||||||
"session", "transcribe", "meeting", "meetings", "history",
|
"session", "transcribe", "meeting", "meetings", "history",
|
||||||
"config", "prompt", "devices", "models", "test-key",
|
"config", "prompt", "devices", "models", "test-key",
|
||||||
"doctor", "shortcut", "status", "settings", "restart",
|
"doctor", "shortcut", "status", "settings", "restart",
|
||||||
@@ -614,6 +614,14 @@ class Replies(DikteTest):
|
|||||||
captured():
|
captured():
|
||||||
self.assertEqual(cli.run(["cancel"]), 0)
|
self.assertEqual(cli.run(["cancel"]), 0)
|
||||||
|
|
||||||
|
def test_pausing_a_recording_nobody_started_is_not_a_failure_either(self):
|
||||||
|
"""A key that pauses can be pressed when there is nothing to pause, and
|
||||||
|
it must not start an application to tell you so."""
|
||||||
|
with mock.patch.object(ipc, "send", return_value=None), \
|
||||||
|
mock.patch.object(cli, "launch_gui") as launched, captured():
|
||||||
|
self.assertEqual(cli.run(["pause"]), 0)
|
||||||
|
self.assertFalse(launched.called)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ CHANGED = {
|
|||||||
"file_cleanup": False,
|
"file_cleanup": False,
|
||||||
"shortcut": "Ctrl+Alt+Space",
|
"shortcut": "Ctrl+Alt+Space",
|
||||||
"cancel_shortcut": "Meta+Shift+Space",
|
"cancel_shortcut": "Meta+Shift+Space",
|
||||||
|
"pause_shortcut": "Meta+P",
|
||||||
"evdev_hotkey": True,
|
"evdev_hotkey": True,
|
||||||
"history_limit": 50,
|
"history_limit": 50,
|
||||||
}
|
}
|
||||||
@@ -269,6 +270,7 @@ class Settings(DikteTest):
|
|||||||
self.assertTrue(conf["shortcut"])
|
self.assertTrue(conf["shortcut"])
|
||||||
self.assertEqual(conf["shortcut"], hotkey.default_combo("toggle"))
|
self.assertEqual(conf["shortcut"], hotkey.default_combo("toggle"))
|
||||||
self.assertEqual(conf["cancel_shortcut"], "")
|
self.assertEqual(conf["cancel_shortcut"], "")
|
||||||
|
self.assertEqual(conf["pause_shortcut"], "")
|
||||||
self.assertEqual(conf["assistant_shortcut"], "")
|
self.assertEqual(conf["assistant_shortcut"], "")
|
||||||
self.assertEqual(conf["meeting_shortcut"], "")
|
self.assertEqual(conf["meeting_shortcut"], "")
|
||||||
|
|
||||||
@@ -466,6 +468,27 @@ class Overlay(DikteTest):
|
|||||||
widget._conceal()
|
widget._conceal()
|
||||||
self.assertFalse(widget.showing)
|
self.assertFalse(widget.showing)
|
||||||
|
|
||||||
|
def test_a_held_recording_says_so_and_stops_moving(self):
|
||||||
|
"""Everything about the ribbon says a recording is running; a pause the
|
||||||
|
ribbon did not show would leave all of it saying the opposite."""
|
||||||
|
widget = self.overlay()
|
||||||
|
widget.show_recording()
|
||||||
|
widget.push_level(0.8)
|
||||||
|
widget.set_paused(True)
|
||||||
|
levels = list(widget.levels)
|
||||||
|
widget._tick()
|
||||||
|
self.assertEqual(widget.levels, levels)
|
||||||
|
widget.set_paused(False)
|
||||||
|
widget._tick()
|
||||||
|
self.assertNotEqual(widget.levels, levels)
|
||||||
|
|
||||||
|
def test_a_new_recording_is_never_the_last_one_still_held(self):
|
||||||
|
widget = self.overlay()
|
||||||
|
widget.show_recording()
|
||||||
|
widget.set_paused(True)
|
||||||
|
widget.show_recording()
|
||||||
|
self.assertFalse(widget.paused)
|
||||||
|
|
||||||
def test_a_meeting_shows_both_sides(self):
|
def test_a_meeting_shows_both_sides(self):
|
||||||
widget = self.overlay()
|
widget = self.overlay()
|
||||||
widget.show_meeting()
|
widget.show_meeting()
|
||||||
|
|||||||
+20
-7
@@ -1,11 +1,11 @@
|
|||||||
"""The three tray icons, drawn here for systems that have no icon theme.
|
"""The four tray icons, drawn here for systems that have no icon theme.
|
||||||
|
|
||||||
Linux hands out `audio-input-microphone`, `media-record` and `view-refresh`
|
Linux hands out `audio-input-microphone`, `media-record`, `view-refresh` and
|
||||||
from whatever icon theme is installed, and Qt finds them through
|
`media-playback-pause` from whatever icon theme is installed, and Qt finds them
|
||||||
QIcon.fromTheme. macOS has no such registry: fromTheme returns a null icon
|
through QIcon.fromTheme. macOS has no such registry: fromTheme returns a null
|
||||||
there, and a null icon in the menu bar is an item you cannot see, which is the
|
icon there, and a null icon in the menu bar is an item you cannot see, which is
|
||||||
whole of Dikte's interface gone. So the same three shapes are drawn here, and
|
the whole of Dikte's interface gone. So the same four shapes are drawn here,
|
||||||
used whenever the theme has nothing to offer.
|
and used whenever the theme has nothing to offer.
|
||||||
|
|
||||||
They are drawn as template images: one colour, transparent everywhere else,
|
They are drawn as template images: one colour, transparent everywhere else,
|
||||||
with isMask set. That is what lets macOS invert them for a dark menu bar and
|
with isMask set. That is what lets macOS invert them for a dark menu bar and
|
||||||
@@ -75,6 +75,18 @@ def _record(painter, size):
|
|||||||
painter.drawEllipse(QPointF(11 * unit, 11 * unit), 6.4 * unit, 6.4 * unit)
|
painter.drawEllipse(QPointF(11 * unit, 11 * unit), 6.4 * unit, 6.4 * unit)
|
||||||
|
|
||||||
|
|
||||||
|
def _paused(painter, size):
|
||||||
|
"""Two bars: the recording is still ours, and nothing is going into it."""
|
||||||
|
unit = size / 22.0
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(INK)
|
||||||
|
for left in (6.4, 12.4):
|
||||||
|
painter.drawRoundedRect(
|
||||||
|
QRectF(left * unit, 5.0 * unit, 3.2 * unit, 12.0 * unit),
|
||||||
|
1.2 * unit, 1.2 * unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _working(painter, size):
|
def _working(painter, size):
|
||||||
"""An arrow chasing its own circle: transcribing, cleaning up, thinking."""
|
"""An arrow chasing its own circle: transcribing, cleaning up, thinking."""
|
||||||
unit = size / 22.0
|
unit = size / 22.0
|
||||||
@@ -102,6 +114,7 @@ def _working(painter, size):
|
|||||||
SHAPES = {
|
SHAPES = {
|
||||||
"audio-input-microphone": _microphone,
|
"audio-input-microphone": _microphone,
|
||||||
"media-record": _record,
|
"media-record": _record,
|
||||||
|
"media-playback-pause": _paused,
|
||||||
"view-refresh": _working,
|
"view-refresh": _working,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -90,7 +90,7 @@ echo "──────────────────"
|
|||||||
if ((MACOS)); then
|
if ((MACOS)); then
|
||||||
say "Nothing to unregister: macOS shortcuts live only while Dikte runs."
|
say "Nothing to unregister: macOS shortcuts live only while Dikte runs."
|
||||||
elif [[ -n "$PY" ]] && "$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null; then
|
elif [[ -n "$PY" ]] && "$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null; then
|
||||||
for which in toggle cancel ask meeting; do
|
for which in toggle pause cancel ask meeting; do
|
||||||
"$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true
|
"$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true
|
||||||
done
|
done
|
||||||
ok "Global shortcuts unregistered"
|
ok "Global shortcuts unregistered"
|
||||||
@@ -151,7 +151,7 @@ if ((!MACOS)); then
|
|||||||
# Removing the shortcut takes its desktop file with it, but an install from
|
# Removing the shortcut takes its desktop file with it, but an install from
|
||||||
# before this script existed may have left one behind on a desktop that never
|
# before this script existed may have left one behind on a desktop that never
|
||||||
# used them.
|
# used them.
|
||||||
for id in dikte-toggle dikte-cancel dikte-ask dikte-meeting; do
|
for id in dikte-toggle dikte-pause dikte-cancel dikte-ask dikte-meeting; do
|
||||||
if [[ -e "$APP_DIR/$id.desktop" ]]; then
|
if [[ -e "$APP_DIR/$id.desktop" ]]; then
|
||||||
remove "$APP_DIR/$id.desktop"
|
remove "$APP_DIR/$id.desktop"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user