Add clipboard cleanup option for clipboard backend
This commit is contained in:
parent
1423e44008
commit
ccf968a410
9 changed files with 114 additions and 11 deletions
|
|
@ -90,7 +90,10 @@ Create `~/.config/lel/config.json`:
|
||||||
"daemon": { "hotkey": "Cmd+m" },
|
"daemon": { "hotkey": "Cmd+m" },
|
||||||
"recording": { "input": "0" },
|
"recording": { "input": "0" },
|
||||||
"stt": { "model": "base", "device": "cpu" },
|
"stt": { "model": "base", "device": "cpu" },
|
||||||
"injection": { "backend": "clipboard" },
|
"injection": {
|
||||||
|
"backend": "clipboard",
|
||||||
|
"remove_transcription_from_clipboard": false
|
||||||
|
},
|
||||||
"ai": { "enabled": true },
|
"ai": { "enabled": true },
|
||||||
"vocabulary": {
|
"vocabulary": {
|
||||||
"replacements": [
|
"replacements": [
|
||||||
|
|
@ -161,6 +164,7 @@ Injection backends:
|
||||||
|
|
||||||
- `clipboard`: copy to clipboard and inject via Ctrl+Shift+V (GTK clipboard + XTest)
|
- `clipboard`: copy to clipboard and inject via Ctrl+Shift+V (GTK clipboard + XTest)
|
||||||
- `injection`: type the text with simulated keypresses (XTest)
|
- `injection`: type the text with simulated keypresses (XTest)
|
||||||
|
- `injection.remove_transcription_from_clipboard`: when `true` and backend is `clipboard`, restores/clears the clipboard after paste so the transcript is not kept there
|
||||||
|
|
||||||
AI processing:
|
AI processing:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
"device": "cpu"
|
"device": "cpu"
|
||||||
},
|
},
|
||||||
"injection": {
|
"injection": {
|
||||||
"backend": "clipboard"
|
"backend": "clipboard",
|
||||||
|
"remove_transcription_from_clipboard": false
|
||||||
},
|
},
|
||||||
"ai": {
|
"ai": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ class SttConfig:
|
||||||
@dataclass
|
@dataclass
|
||||||
class InjectionConfig:
|
class InjectionConfig:
|
||||||
backend: str = DEFAULT_INJECTION_BACKEND
|
backend: str = DEFAULT_INJECTION_BACKEND
|
||||||
|
remove_transcription_from_clipboard: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -115,6 +116,8 @@ def validate(cfg: Config) -> None:
|
||||||
allowed = ", ".join(sorted(ALLOWED_INJECTION_BACKENDS))
|
allowed = ", ".join(sorted(ALLOWED_INJECTION_BACKENDS))
|
||||||
raise ValueError(f"injection.backend must be one of: {allowed}")
|
raise ValueError(f"injection.backend must be one of: {allowed}")
|
||||||
cfg.injection.backend = backend
|
cfg.injection.backend = backend
|
||||||
|
if not isinstance(cfg.injection.remove_transcription_from_clipboard, bool):
|
||||||
|
raise ValueError("injection.remove_transcription_from_clipboard must be boolean")
|
||||||
|
|
||||||
if not isinstance(cfg.ai.enabled, bool):
|
if not isinstance(cfg.ai.enabled, bool):
|
||||||
raise ValueError("ai.enabled must be boolean")
|
raise ValueError("ai.enabled must be boolean")
|
||||||
|
|
@ -180,6 +183,11 @@ def _from_dict(data: dict[str, Any], cfg: Config) -> Config:
|
||||||
cfg.stt.device = _as_nonempty_str(stt["device"], "stt.device")
|
cfg.stt.device = _as_nonempty_str(stt["device"], "stt.device")
|
||||||
if "backend" in injection:
|
if "backend" in injection:
|
||||||
cfg.injection.backend = _as_nonempty_str(injection["backend"], "injection.backend")
|
cfg.injection.backend = _as_nonempty_str(injection["backend"], "injection.backend")
|
||||||
|
if "remove_transcription_from_clipboard" in injection:
|
||||||
|
cfg.injection.remove_transcription_from_clipboard = _as_bool(
|
||||||
|
injection["remove_transcription_from_clipboard"],
|
||||||
|
"injection.remove_transcription_from_clipboard",
|
||||||
|
)
|
||||||
if "enabled" in ai:
|
if "enabled" in ai:
|
||||||
cfg.ai.enabled = _as_bool(ai["enabled"], "ai.enabled")
|
cfg.ai.enabled = _as_bool(ai["enabled"], "ai.enabled")
|
||||||
if "replacements" in vocabulary:
|
if "replacements" in vocabulary:
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,13 @@ class DesktopAdapter(Protocol):
|
||||||
def start_cancel_listener(self, callback: Callable[[], None]) -> None:
|
def start_cancel_listener(self, callback: Callable[[], None]) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def inject_text(self, text: str, backend: str) -> None:
|
def inject_text(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
backend: str,
|
||||||
|
*,
|
||||||
|
remove_transcription_from_clipboard: bool = False,
|
||||||
|
) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def run_tray(self, state_getter: Callable[[], str], on_quit: Callable[[], None]) -> None:
|
def run_tray(self, state_getter: Callable[[], str], on_quit: Callable[[], None]) -> None:
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,14 @@ class WaylandAdapter:
|
||||||
def start_cancel_listener(self, _callback: Callable[[], None]) -> None:
|
def start_cancel_listener(self, _callback: Callable[[], None]) -> None:
|
||||||
raise SystemExit("Wayland hotkeys are not supported yet.")
|
raise SystemExit("Wayland hotkeys are not supported yet.")
|
||||||
|
|
||||||
def inject_text(self, _text: str, _backend: str) -> None:
|
def inject_text(
|
||||||
|
self,
|
||||||
|
_text: str,
|
||||||
|
_backend: str,
|
||||||
|
*,
|
||||||
|
remove_transcription_from_clipboard: bool = False,
|
||||||
|
) -> None:
|
||||||
|
_ = remove_transcription_from_clipboard
|
||||||
raise SystemExit("Wayland text injection is not supported yet.")
|
raise SystemExit("Wayland text injection is not supported yet.")
|
||||||
|
|
||||||
def run_tray(self, _state_getter: Callable[[], str], _on_quit: Callable[[], None]) -> None:
|
def run_tray(self, _state_getter: Callable[[], str], _on_quit: Callable[[], None]) -> None:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import warnings
|
import warnings
|
||||||
from typing import Callable, Iterable
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@ MOD_MAP = {
|
||||||
"cmd": X.Mod4Mask,
|
"cmd": X.Mod4Mask,
|
||||||
"command": X.Mod4Mask,
|
"command": X.Mod4Mask,
|
||||||
}
|
}
|
||||||
|
CLIPBOARD_RESTORE_DELAY_SEC = 0.15
|
||||||
|
|
||||||
|
|
||||||
class X11Adapter:
|
class X11Adapter:
|
||||||
|
|
@ -70,17 +72,35 @@ class X11Adapter:
|
||||||
thread = threading.Thread(target=self._listen, args=("Escape", callback), daemon=True)
|
thread = threading.Thread(target=self._listen, args=("Escape", callback), daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
def inject_text(self, text: str, backend: str) -> None:
|
def inject_text(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
backend: str,
|
||||||
|
*,
|
||||||
|
remove_transcription_from_clipboard: bool = False,
|
||||||
|
) -> None:
|
||||||
backend = (backend or "").strip().lower()
|
backend = (backend or "").strip().lower()
|
||||||
if backend in ("", "clipboard"):
|
if backend in ("", "clipboard"):
|
||||||
|
previous_clipboard = None
|
||||||
|
if remove_transcription_from_clipboard:
|
||||||
|
previous_clipboard = self._read_clipboard_text()
|
||||||
self._write_clipboard(text)
|
self._write_clipboard(text)
|
||||||
self._paste_clipboard()
|
self._paste_clipboard()
|
||||||
|
if remove_transcription_from_clipboard:
|
||||||
|
time.sleep(CLIPBOARD_RESTORE_DELAY_SEC)
|
||||||
|
self._restore_clipboard_text(previous_clipboard)
|
||||||
return
|
return
|
||||||
if backend == "injection":
|
if backend == "injection":
|
||||||
self._type_text(text)
|
self._type_text(text)
|
||||||
return
|
return
|
||||||
raise ValueError(f"unknown injection backend: {backend}")
|
raise ValueError(f"unknown injection backend: {backend}")
|
||||||
|
|
||||||
|
def _read_clipboard_text(self) -> str | None:
|
||||||
|
Gtk.init([])
|
||||||
|
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||||
|
text = clipboard.wait_for_text()
|
||||||
|
return str(text) if text is not None else None
|
||||||
|
|
||||||
def run_tray(self, state_getter: Callable[[], str], on_quit: Callable[[], None]) -> None:
|
def run_tray(self, state_getter: Callable[[], str], on_quit: Callable[[], None]) -> None:
|
||||||
self.menu = Gtk.Menu()
|
self.menu = Gtk.Menu()
|
||||||
quit_item = Gtk.MenuItem(label="Quit")
|
quit_item = Gtk.MenuItem(label="Quit")
|
||||||
|
|
@ -165,6 +185,14 @@ class X11Adapter:
|
||||||
while Gtk.events_pending():
|
while Gtk.events_pending():
|
||||||
Gtk.main_iteration()
|
Gtk.main_iteration()
|
||||||
|
|
||||||
|
def _restore_clipboard_text(self, text: str | None) -> None:
|
||||||
|
Gtk.init([])
|
||||||
|
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||||
|
clipboard.set_text(text or "", -1)
|
||||||
|
clipboard.store()
|
||||||
|
while Gtk.events_pending():
|
||||||
|
Gtk.main_iteration()
|
||||||
|
|
||||||
def _paste_clipboard(self) -> None:
|
def _paste_clipboard(self) -> None:
|
||||||
dpy = display.Display()
|
dpy = display.Display()
|
||||||
self._send_combo(dpy, ["Control_L", "Shift_L", "v"])
|
self._send_combo(dpy, ["Control_L", "Shift_L", "v"])
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,13 @@ class Daemon:
|
||||||
self.set_state(State.OUTPUTTING)
|
self.set_state(State.OUTPUTTING)
|
||||||
logging.info("outputting started")
|
logging.info("outputting started")
|
||||||
backend = self.cfg.injection.backend
|
backend = self.cfg.injection.backend
|
||||||
self.desktop.inject_text(text, backend)
|
self.desktop.inject_text(
|
||||||
|
text,
|
||||||
|
backend,
|
||||||
|
remove_transcription_from_clipboard=(
|
||||||
|
self.cfg.injection.remove_transcription_from_clipboard
|
||||||
|
),
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.error("output failed: %s", exc)
|
logging.error("output failed: %s", exc)
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ class ConfigTests(unittest.TestCase):
|
||||||
self.assertEqual(cfg.stt.model, "base")
|
self.assertEqual(cfg.stt.model, "base")
|
||||||
self.assertEqual(cfg.stt.device, "cpu")
|
self.assertEqual(cfg.stt.device, "cpu")
|
||||||
self.assertEqual(cfg.injection.backend, "clipboard")
|
self.assertEqual(cfg.injection.backend, "clipboard")
|
||||||
|
self.assertFalse(cfg.injection.remove_transcription_from_clipboard)
|
||||||
self.assertTrue(cfg.ai.enabled)
|
self.assertTrue(cfg.ai.enabled)
|
||||||
self.assertEqual(cfg.vocabulary.replacements, [])
|
self.assertEqual(cfg.vocabulary.replacements, [])
|
||||||
self.assertEqual(cfg.vocabulary.terms, [])
|
self.assertEqual(cfg.vocabulary.terms, [])
|
||||||
|
|
@ -38,7 +39,10 @@ class ConfigTests(unittest.TestCase):
|
||||||
"daemon": {"hotkey": "Ctrl+space"},
|
"daemon": {"hotkey": "Ctrl+space"},
|
||||||
"recording": {"input": 3},
|
"recording": {"input": 3},
|
||||||
"stt": {"model": "small", "device": "cuda"},
|
"stt": {"model": "small", "device": "cuda"},
|
||||||
"injection": {"backend": "injection"},
|
"injection": {
|
||||||
|
"backend": "injection",
|
||||||
|
"remove_transcription_from_clipboard": True,
|
||||||
|
},
|
||||||
"ai": {"enabled": False},
|
"ai": {"enabled": False},
|
||||||
"vocabulary": {
|
"vocabulary": {
|
||||||
"replacements": [
|
"replacements": [
|
||||||
|
|
@ -62,6 +66,7 @@ class ConfigTests(unittest.TestCase):
|
||||||
self.assertEqual(cfg.stt.model, "small")
|
self.assertEqual(cfg.stt.model, "small")
|
||||||
self.assertEqual(cfg.stt.device, "cuda")
|
self.assertEqual(cfg.stt.device, "cuda")
|
||||||
self.assertEqual(cfg.injection.backend, "injection")
|
self.assertEqual(cfg.injection.backend, "injection")
|
||||||
|
self.assertTrue(cfg.injection.remove_transcription_from_clipboard)
|
||||||
self.assertFalse(cfg.ai.enabled)
|
self.assertFalse(cfg.ai.enabled)
|
||||||
self.assertEqual(cfg.vocabulary.max_rules, 100)
|
self.assertEqual(cfg.vocabulary.max_rules, 100)
|
||||||
self.assertEqual(cfg.vocabulary.max_terms, 200)
|
self.assertEqual(cfg.vocabulary.max_terms, 200)
|
||||||
|
|
@ -92,6 +97,7 @@ class ConfigTests(unittest.TestCase):
|
||||||
self.assertEqual(cfg.stt.model, "tiny")
|
self.assertEqual(cfg.stt.model, "tiny")
|
||||||
self.assertEqual(cfg.stt.device, "cpu")
|
self.assertEqual(cfg.stt.device, "cpu")
|
||||||
self.assertEqual(cfg.injection.backend, "clipboard")
|
self.assertEqual(cfg.injection.backend, "clipboard")
|
||||||
|
self.assertFalse(cfg.injection.remove_transcription_from_clipboard)
|
||||||
self.assertFalse(cfg.ai.enabled)
|
self.assertFalse(cfg.ai.enabled)
|
||||||
self.assertEqual(cfg.vocabulary.replacements, [])
|
self.assertEqual(cfg.vocabulary.replacements, [])
|
||||||
|
|
||||||
|
|
@ -104,6 +110,15 @@ class ConfigTests(unittest.TestCase):
|
||||||
with self.assertRaisesRegex(ValueError, "injection.backend"):
|
with self.assertRaisesRegex(ValueError, "injection.backend"):
|
||||||
load(str(path))
|
load(str(path))
|
||||||
|
|
||||||
|
def test_invalid_clipboard_remove_option_raises(self):
|
||||||
|
payload = {"injection": {"remove_transcription_from_clipboard": "yes"}}
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
path = Path(td) / "config.json"
|
||||||
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "injection.remove_transcription_from_clipboard"):
|
||||||
|
load(str(path))
|
||||||
|
|
||||||
def test_removed_logging_section_raises(self):
|
def test_removed_logging_section_raises(self):
|
||||||
payload = {"logging": {"log_transcript": True}}
|
payload = {"logging": {"log_transcript": True}}
|
||||||
with tempfile.TemporaryDirectory() as td:
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,14 @@ class FakeDesktop:
|
||||||
self.inject_calls = []
|
self.inject_calls = []
|
||||||
self.quit_calls = 0
|
self.quit_calls = 0
|
||||||
|
|
||||||
def inject_text(self, text: str, backend: str) -> None:
|
def inject_text(
|
||||||
self.inject_calls.append((text, backend))
|
self,
|
||||||
|
text: str,
|
||||||
|
backend: str,
|
||||||
|
*,
|
||||||
|
remove_transcription_from_clipboard: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.inject_calls.append((text, backend, remove_transcription_from_clipboard))
|
||||||
|
|
||||||
def request_quit(self) -> None:
|
def request_quit(self) -> None:
|
||||||
self.quit_calls += 1
|
self.quit_calls += 1
|
||||||
|
|
@ -101,7 +107,7 @@ class DaemonTests(unittest.TestCase):
|
||||||
daemon.toggle()
|
daemon.toggle()
|
||||||
|
|
||||||
self.assertEqual(daemon.get_state(), leld.State.IDLE)
|
self.assertEqual(daemon.get_state(), leld.State.IDLE)
|
||||||
self.assertEqual(desktop.inject_calls, [("hello world", "clipboard")])
|
self.assertEqual(desktop.inject_calls, [("hello world", "clipboard", False)])
|
||||||
|
|
||||||
@patch("leld.stop_audio_recording", return_value=FakeAudio(8))
|
@patch("leld.stop_audio_recording", return_value=FakeAudio(8))
|
||||||
@patch("leld.start_audio_recording", return_value=(object(), object()))
|
@patch("leld.start_audio_recording", return_value=(object(), object()))
|
||||||
|
|
@ -143,7 +149,7 @@ class DaemonTests(unittest.TestCase):
|
||||||
daemon.toggle()
|
daemon.toggle()
|
||||||
daemon.toggle()
|
daemon.toggle()
|
||||||
|
|
||||||
self.assertEqual(desktop.inject_calls, [("good morning Marta", "clipboard")])
|
self.assertEqual(desktop.inject_calls, [("good morning Marta", "clipboard", False)])
|
||||||
|
|
||||||
def test_transcribe_skips_hints_when_model_does_not_support_them(self):
|
def test_transcribe_skips_hints_when_model_does_not_support_them(self):
|
||||||
desktop = FakeDesktop()
|
desktop = FakeDesktop()
|
||||||
|
|
@ -189,6 +195,28 @@ class DaemonTests(unittest.TestCase):
|
||||||
daemon_verbose = leld.Daemon(cfg, desktop, verbose=True)
|
daemon_verbose = leld.Daemon(cfg, desktop, verbose=True)
|
||||||
self.assertTrue(daemon_verbose.log_transcript)
|
self.assertTrue(daemon_verbose.log_transcript)
|
||||||
|
|
||||||
|
@patch("leld.stop_audio_recording", return_value=FakeAudio(8))
|
||||||
|
@patch("leld.start_audio_recording", return_value=(object(), object()))
|
||||||
|
def test_passes_clipboard_remove_option_to_desktop(self, _start_mock, _stop_mock):
|
||||||
|
desktop = FakeDesktop()
|
||||||
|
model = FakeModel(text="hello world")
|
||||||
|
cfg = self._config()
|
||||||
|
cfg.injection.remove_transcription_from_clipboard = True
|
||||||
|
|
||||||
|
with patch("leld._build_whisper_model", return_value=model):
|
||||||
|
daemon = leld.Daemon(cfg, desktop, verbose=False)
|
||||||
|
daemon.ai_processor = FakeAIProcessor()
|
||||||
|
daemon._start_stop_worker = (
|
||||||
|
lambda stream, record, trigger, process_audio: daemon._stop_and_process(
|
||||||
|
stream, record, trigger, process_audio
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
daemon.toggle()
|
||||||
|
daemon.toggle()
|
||||||
|
|
||||||
|
self.assertEqual(desktop.inject_calls, [("hello world", "clipboard", True)])
|
||||||
|
|
||||||
def test_state_changes_are_debug_level(self):
|
def test_state_changes_are_debug_level(self):
|
||||||
desktop = FakeDesktop()
|
desktop = FakeDesktop()
|
||||||
with patch("leld._build_whisper_model", return_value=FakeModel()):
|
with patch("leld._build_whisper_model", return_value=FakeModel()):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue