mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Merge pull request #34 from yusufipk/pause-resume
Let a recording be held while something else happens
This commit is contained in:
@@ -103,6 +103,7 @@ set next to it.
|
||||
| What | How |
|
||||
| --- | --- |
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
@@ -103,6 +103,7 @@ yanındaki kutudan düşünme seviyesini de seçebilirsin.
|
||||
| Ne | Nasıl |
|
||||
| --- | --- |
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
@@ -72,12 +72,31 @@ class Recorder(QObject):
|
||||
self._rms = []
|
||||
self._cancelled = False
|
||||
self._stopping = False
|
||||
self._paused = False
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def active(self):
|
||||
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):
|
||||
if self.active:
|
||||
return
|
||||
@@ -102,6 +121,7 @@ class Recorder(QObject):
|
||||
self._rms = []
|
||||
self._cancelled = False
|
||||
self._stopping = False
|
||||
self._paused = False
|
||||
self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS)
|
||||
self._thread = threading.Thread(target=self._pump, daemon=True)
|
||||
self._thread.start()
|
||||
@@ -114,6 +134,11 @@ class Recorder(QObject):
|
||||
chunk = stdout.read(CHUNK_BYTES)
|
||||
if not chunk:
|
||||
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)
|
||||
with self._lock:
|
||||
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;
|
||||
# it is already in the state that was asked for.
|
||||
IDEMPOTENT_VERBS = {"cancel", "stop", "quit", "restart", "ask-cancel",
|
||||
"ask-reset", "meeting-cancel"}
|
||||
"ask-reset", "meeting-cancel", "pause"}
|
||||
|
||||
_app = None
|
||||
|
||||
@@ -765,7 +765,8 @@ def cmd_status(opts):
|
||||
return fail(opts, "the running instance is from before it could answer "
|
||||
"questions; reload it with: dikte restart", 1, running=True)
|
||||
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"meeting: {reply.get('meeting', '?')}"
|
||||
+ (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.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)
|
||||
|
||||
# --- the agent --------------------------------------------------------
|
||||
|
||||
@@ -435,6 +435,10 @@ DEFAULTS = {
|
||||
# trick lands on the toggle, Alt and Option being one key, so discarding
|
||||
# gets a letter instead.
|
||||
"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,
|
||||
"overlay_corner": "bottom-left",
|
||||
"keep_audio": False,
|
||||
|
||||
@@ -83,6 +83,14 @@ class Dikte:
|
||||
self.ask_state = IDLE
|
||||
# Which of the two the microphone is currently serving, or 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_base = ""
|
||||
self.meeting_message = ""
|
||||
@@ -159,6 +167,12 @@ class Dikte:
|
||||
self.toggle_action.triggered.connect(self._toggle)
|
||||
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.
|
||||
self.ask_action = QAction("", self.menu)
|
||||
self.ask_action.triggered.connect(self._toggle_ask)
|
||||
@@ -283,6 +297,9 @@ class Dikte:
|
||||
or (self.ask_state == IDLE and not self.recording)
|
||||
)
|
||||
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)
|
||||
# A command to the agent is the one job long enough to be worth calling
|
||||
# off once it is already running.
|
||||
@@ -299,6 +316,12 @@ class Dikte:
|
||||
else:
|
||||
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 = {
|
||||
M_IDLE: "Record a meeting",
|
||||
M_RECORDING: "End the meeting and write it up",
|
||||
@@ -334,6 +357,9 @@ class Dikte:
|
||||
def toggle_meeting(self):
|
||||
self._external("meeting", self._toggle_meeting)
|
||||
|
||||
def toggle_pause(self):
|
||||
self._external("pause", self._toggle_pause)
|
||||
|
||||
def cancel(self):
|
||||
self._external("cancel", self._cancel)
|
||||
|
||||
@@ -358,7 +384,7 @@ class Dikte:
|
||||
timer = self.last_evdev[name] = QElapsedTimer()
|
||||
timer.restart()
|
||||
handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask,
|
||||
"cancel": self._cancel}
|
||||
"cancel": self._cancel, "pause": self._toggle_pause}
|
||||
handlers.get(name, self._toggle)()
|
||||
|
||||
def _retire_listener(self):
|
||||
@@ -393,6 +419,7 @@ class Dikte:
|
||||
else:
|
||||
handler = {
|
||||
"cancel": self.cancel,
|
||||
"pause": self.toggle_pause,
|
||||
"ask-cancel": self.cancel_ask,
|
||||
"ask-reset": self.reset_conversation,
|
||||
"meeting-cancel": self.cancel_meeting,
|
||||
@@ -475,6 +502,7 @@ class Dikte:
|
||||
"ok": True,
|
||||
"running": True,
|
||||
"dictation": self.state,
|
||||
"paused": self.paused,
|
||||
"ask": self.ask_state,
|
||||
"meeting": self.meeting_state,
|
||||
"meeting_base": self.meetings.running_base,
|
||||
@@ -538,6 +566,7 @@ class Dikte:
|
||||
"""One microphone, so one of the two holds it at a time."""
|
||||
self.recorder_owner = owner
|
||||
self._run_id += 1
|
||||
self._clear_pause()
|
||||
self.elapsed.restart()
|
||||
self.ticker.start()
|
||||
self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"])
|
||||
@@ -546,6 +575,7 @@ class Dikte:
|
||||
if self.state != RECORDING:
|
||||
return
|
||||
self.ticker.stop()
|
||||
self._clear_pause()
|
||||
self._set_state(BUSY)
|
||||
self.overlay.show_busy(t("Transcribing…"))
|
||||
self.recorder.stop()
|
||||
@@ -554,16 +584,50 @@ class Dikte:
|
||||
if self.ask_state != RECORDING:
|
||||
return
|
||||
self.ticker.stop()
|
||||
self._clear_pause()
|
||||
self._set_ask_state(BUSY)
|
||||
self.ask_overlay.show_busy(t("Transcribing…"))
|
||||
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):
|
||||
"""Throw away whichever recording is running."""
|
||||
if not self.recording:
|
||||
return
|
||||
asking = self.ask_state == RECORDING
|
||||
self.ticker.stop()
|
||||
self._clear_pause()
|
||||
self.recorder.cancel()
|
||||
self.recorder_owner = None
|
||||
# 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)
|
||||
|
||||
def _tick(self):
|
||||
seconds = self.elapsed.elapsed() / 1000.0
|
||||
seconds = self._recorded_seconds()
|
||||
self._recording_overlay().set_seconds(seconds)
|
||||
if seconds >= self.conf["max_seconds"]:
|
||||
(self.stop_ask if self.recorder_owner == ASK else self.stop)()
|
||||
|
||||
@@ -29,6 +29,7 @@ from i18n import t
|
||||
|
||||
DESKTOP_ID = "dikte-toggle.desktop"
|
||||
CANCEL_DESKTOP_ID = "dikte-cancel.desktop"
|
||||
PAUSE_DESKTOP_ID = "dikte-pause.desktop"
|
||||
MEETING_DESKTOP_ID = "dikte-meeting.desktop"
|
||||
ASK_DESKTOP_ID = "dikte-ask.desktop"
|
||||
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")
|
||||
|
||||
# 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
|
||||
# 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
|
||||
@@ -47,6 +48,8 @@ Shortcut = collections.namedtuple("Shortcut", "verb desktop_id name setting fall
|
||||
SHORTCUTS = {
|
||||
"toggle": Shortcut("toggle", DESKTOP_ID, "Dikte: start/stop recording",
|
||||
"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", ""),
|
||||
"ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask Claude Code",
|
||||
|
||||
@@ -56,12 +56,15 @@ TR = {
|
||||
"Start recording": "Kaydı başlat",
|
||||
"Stop and transcribe": "Kaydı bitir ve yaz",
|
||||
"Working…": "İşleniyor…",
|
||||
"Pause the recording": "Kaydı duraklat",
|
||||
"Resume the recording": "Kayda devam et",
|
||||
"Discard the recording": "Kaydı iptal et",
|
||||
"Settings…": "Ayarlar…",
|
||||
"Restart": "Yeniden başlat",
|
||||
"Quit": "Çık",
|
||||
"Dikte: ready": "Dikte: hazır",
|
||||
"Dikte: recording": "Dikte: kaydediyor",
|
||||
"Dikte: paused": "Dikte: duraklatıldı",
|
||||
"Dikte: working": "Dikte: işleniyor",
|
||||
|
||||
# --- overlay / pipeline -------------------------------------------
|
||||
@@ -312,7 +315,14 @@ TR = {
|
||||
"Global kısayol kurulu değil. Tepsi menüsünden de soru sorulabilir.",
|
||||
"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.",
|
||||
"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",
|
||||
"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 "
|
||||
"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, "
|
||||
|
||||
+39
-4
@@ -26,6 +26,9 @@ WARN = QColor(240, 180, 80)
|
||||
THEM = QColor(110, 190, 255) # the other side of a meeting
|
||||
|
||||
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,
|
||||
"done": OK, "warning": WARN, "error": ERR}
|
||||
@@ -47,6 +50,10 @@ class Overlay(QWidget):
|
||||
self.dismissable = dismissable
|
||||
self.muted = 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.message = ""
|
||||
self.levels = [0.0] * BARS
|
||||
@@ -102,6 +109,7 @@ class Overlay(QWidget):
|
||||
self.message = ""
|
||||
self.seconds = 0.0
|
||||
self.levels = [0.0] * BARS
|
||||
self.paused = False
|
||||
self.muted = False # a new run starts visible, whatever the last one did
|
||||
self._hide_timer.stop()
|
||||
self._appear()
|
||||
@@ -113,6 +121,7 @@ class Overlay(QWidget):
|
||||
self.seconds = 0.0
|
||||
self.levels = [0.0] * BARS
|
||||
self.levels2 = [0.0] * BARS
|
||||
self.paused = False
|
||||
self._hide_timer.stop()
|
||||
self._appear()
|
||||
|
||||
@@ -174,6 +183,17 @@ class Overlay(QWidget):
|
||||
def set_seconds(self, 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 -----------------------------------------------------
|
||||
|
||||
def _appear(self):
|
||||
@@ -243,11 +263,11 @@ class Overlay(QWidget):
|
||||
# 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:
|
||||
if self.state in LIVE and not self.paused:
|
||||
# keep the ribbon moving even through a pause in speech
|
||||
self.levels = self.levels[1:] + [self.levels[-1] * 0.72]
|
||||
if self.state == "meeting":
|
||||
self.levels2 = self.levels2[1:] + [self.levels2[-1] * 0.72]
|
||||
if self.state == "meeting":
|
||||
self.levels2 = self.levels2[1:] + [self.levels2[-1] * 0.72]
|
||||
self.update()
|
||||
|
||||
def _label_font(self):
|
||||
@@ -272,6 +292,8 @@ class Overlay(QWidget):
|
||||
painter.drawPath(path)
|
||||
|
||||
accent = STATE_COLORS.get(self.state, MUTED)
|
||||
if self._held:
|
||||
accent = HELD
|
||||
self._draw_indicator(painter, accent)
|
||||
|
||||
if self.state in LIVE:
|
||||
@@ -282,10 +304,23 @@ class Overlay(QWidget):
|
||||
if self._can_dismiss:
|
||||
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):
|
||||
cx, cy = 26.0, self.height() / 2
|
||||
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))
|
||||
glow = QColor(accent)
|
||||
glow.setAlphaF(0.22 * pulse)
|
||||
|
||||
+10
-2
@@ -1321,6 +1321,14 @@ class SettingsWindow(QDialog):
|
||||
form, "toggle", t("Start and stop"),
|
||||
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
|
||||
# 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.
|
||||
@@ -1700,8 +1708,8 @@ class SettingsWindow(QDialog):
|
||||
conf["file_cleanup"] = self.file_cleanup.isChecked()
|
||||
|
||||
# Left empty, only the toggle falls back to a default: the application
|
||||
# is unusable without it. The other three stay empty, which is what
|
||||
# turns them off.
|
||||
# is unusable without it. The rest stay empty, which is what turns
|
||||
# them off.
|
||||
for which, (box, _status, _missing) in self._shortcut_rows.items():
|
||||
spec = hotkey.SHORTCUTS[which]
|
||||
conf[spec.setting] = (box.currentText().strip()
|
||||
|
||||
@@ -280,6 +280,26 @@ class _StalledStream:
|
||||
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):
|
||||
"""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.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):
|
||||
recorder = audio.Recorder()
|
||||
failures = []
|
||||
|
||||
+9
-1
@@ -162,7 +162,7 @@ class Parser(unittest.TestCase):
|
||||
name)
|
||||
|
||||
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",
|
||||
"config", "prompt", "devices", "models", "test-key",
|
||||
"doctor", "shortcut", "status", "settings", "restart",
|
||||
@@ -614,6 +614,14 @@ class Replies(DikteTest):
|
||||
captured():
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -96,6 +96,7 @@ CHANGED = {
|
||||
"file_cleanup": False,
|
||||
"shortcut": "Ctrl+Alt+Space",
|
||||
"cancel_shortcut": "Meta+Shift+Space",
|
||||
"pause_shortcut": "Meta+P",
|
||||
"evdev_hotkey": True,
|
||||
"history_limit": 50,
|
||||
}
|
||||
@@ -269,6 +270,7 @@ class Settings(DikteTest):
|
||||
self.assertTrue(conf["shortcut"])
|
||||
self.assertEqual(conf["shortcut"], hotkey.default_combo("toggle"))
|
||||
self.assertEqual(conf["cancel_shortcut"], "")
|
||||
self.assertEqual(conf["pause_shortcut"], "")
|
||||
self.assertEqual(conf["assistant_shortcut"], "")
|
||||
self.assertEqual(conf["meeting_shortcut"], "")
|
||||
|
||||
@@ -466,6 +468,27 @@ class Overlay(DikteTest):
|
||||
widget._conceal()
|
||||
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):
|
||||
widget = self.overlay()
|
||||
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`
|
||||
from whatever icon theme is installed, and Qt finds them through
|
||||
QIcon.fromTheme. macOS has no such registry: fromTheme returns a null icon
|
||||
there, and a null icon in the menu bar is an item you cannot see, which is the
|
||||
whole of Dikte's interface gone. So the same three shapes are drawn here, and
|
||||
used whenever the theme has nothing to offer.
|
||||
Linux hands out `audio-input-microphone`, `media-record`, `view-refresh` and
|
||||
`media-playback-pause` from whatever icon theme is installed, and Qt finds them
|
||||
through QIcon.fromTheme. macOS has no such registry: fromTheme returns a null
|
||||
icon there, and a null icon in the menu bar is an item you cannot see, which is
|
||||
the whole of Dikte's interface gone. So the same four shapes are drawn here,
|
||||
and used whenever the theme has nothing to offer.
|
||||
|
||||
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
|
||||
@@ -75,6 +75,18 @@ def _record(painter, size):
|
||||
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):
|
||||
"""An arrow chasing its own circle: transcribing, cleaning up, thinking."""
|
||||
unit = size / 22.0
|
||||
@@ -102,6 +114,7 @@ def _working(painter, size):
|
||||
SHAPES = {
|
||||
"audio-input-microphone": _microphone,
|
||||
"media-record": _record,
|
||||
"media-playback-pause": _paused,
|
||||
"view-refresh": _working,
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -90,7 +90,7 @@ echo "──────────────────"
|
||||
if ((MACOS)); then
|
||||
say "Nothing to unregister: macOS shortcuts live only while Dikte runs."
|
||||
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
|
||||
done
|
||||
ok "Global shortcuts unregistered"
|
||||
@@ -151,7 +151,7 @@ if ((!MACOS)); then
|
||||
# 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
|
||||
# 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
|
||||
remove "$APP_DIR/$id.desktop"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user