diff --git a/README.de.md b/README.de.md index 92044c9..bd10785 100644 --- a/README.de.md +++ b/README.de.md @@ -53,7 +53,7 @@ Blitztext registriert globale Hotkeys via `evdev`. Mit diesen Kombinationen hast | Workflow | Hotkey | LLM? | Beschreibung | | :--- | :--- | :---: | :--- | -| **Blitztext** | Alt (halten) | ❌ | Standard: Nimmt auf, solange die Taste gehalten wird, transkribiert und fügt den Text ein. Aufnahmetaste und Halten/Umschalten-Modus sind unter **Einstellungen → Spracherkennung** konfigurierbar. | +| **Blitztext** | Alt (halten) | ❌ | Standard: Nimmt auf, solange die Taste gehalten wird, transkribiert und fügt den Text ein. Sehr kurze Tastendrücke unter 150 ms werden im Halten-Modus als Fehldruck verworfen. Aufnahmetaste und Halten/Umschalten-Modus sind unter **Einstellungen → Spracherkennung** konfigurierbar. | | **Blitztext Lokal** | Meta + Shift + H | ❌ | Erzwingt eine reine **Offline-Transkription**. | | **Blitztext+** | Meta + Shift + T | ✅ | Formuliert deine Aufnahme professionell via LLM um. | | **Blitztext $%&!** | Meta + Shift + D | ✅ | Emotionale Entladung: Wandelt Frust in eine sachliche Nachricht um. | @@ -410,7 +410,7 @@ Der Einstellungs-Dialog hat drei Tabs: - **backend**: `openai-whisper` oder `faster-whisper`. - **hotkey_mode**: - `toggle`: Einmal drücken startet, erneutes Drücken beendet. - - `hold`: Aufnahme läuft solange der Hotkey gedrückt wird. + - `hold`: Aufnahme läuft solange der Hotkey gedrückt wird. Tastendrücke unter 150 ms werden verworfen; leere Aufnahmen kehren ohne dauerhaften Fehlerzustand zu Bereit zurück. - **transcription_hotkey**: Aufnahmetaste, die vom globalen Hotkey-Daemon überwacht wird. Standard: `KEY_LEFTALT`. - **openai_api_key_env**: Name der Umgebungsvariable für den API-Key. Standard: `OPENAI_API_KEY`. Für OpenRouter: `OPENROUTER_API_KEY`. - **llm_provider**: `openai` (Standard), `openrouter` oder `custom`. diff --git a/README.md b/README.md index 179b1dc..925c07e 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Blitztext registers global hotkeys via `evdev`. With these combinations you have | Workflow | Hotkey | LLM? | Description | | :--- | :--- | :---: | :--- | -| **Blitztext** | Alt (hold) | ❌ | Default: records while the key is held, transcribes, and pastes the text. Recording key and hold/toggle mode are configurable under **Settings → Speech Recognition**. | +| **Blitztext** | Alt (hold) | ❌ | Default: records while the key is held, transcribes, and pastes the text. Very short presses below 150 ms are discarded as accidental taps in hold mode. Recording key and hold/toggle mode are configurable under **Settings → Speech Recognition**. | | **Blitztext Local** | Meta + Shift + H | ❌ | Forces a pure **offline transcription**. | | **Blitztext+** | Meta + Shift + T | ✅ | Rephrases your recording professionally via LLM. | | **Blitztext $%&!** | Meta + Shift + D | ✅ | Emotional release: turns frustration into a matter-of-fact message. | @@ -408,7 +408,7 @@ The settings dialog has three tabs: - **backend**: `openai-whisper` or `faster-whisper`. - **hotkey_mode**: - `toggle`: press once to start, press again to stop. - - `hold`: recording runs as long as the hotkey is held. + - `hold`: recording runs as long as the hotkey is held. Presses below 150 ms are discarded; empty recordings return to ready without a persistent error state. - **transcription_hotkey**: Recording key captured by the global hotkey daemon. Default: `KEY_LEFTALT`. - **openai_api_key_env**: Name of the environment variable for the API key. Default: `OPENAI_API_KEY`. For OpenRouter use `OPENROUTER_API_KEY`. - **llm_provider**: `openai` (default), `openrouter`, or `custom`. diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index b6a1eff..3d02290 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -549,6 +549,7 @@ class _WorkerSignals(QObject): """Signals for background transcription/rewrite tasks.""" status_changed = pyqtSignal(str) # "transcribing" | "rewriting" result = pyqtSignal(str) + no_speech = pyqtSignal() error = pyqtSignal(str) finished = pyqtSignal(object) @@ -600,7 +601,8 @@ def run(self) -> None: ) if not transcript or not transcript.strip(): - raise TranscribeError("Keine Sprache im Audio erkannt.") + self._emit("no_speech") + return # Compose routing always receives the raw recognized text; the # compose window owns any later rewrite workflow selected there. @@ -926,6 +928,7 @@ def start_hotkey_worker(self) -> None: self.hotkey_thread.started.connect(self.hotkey_worker.run) self.hotkey_worker.workflow_triggered.connect(self._on_workflow_triggered) self.hotkey_worker.recording_stop.connect(self._on_recording_stop) + self.hotkey_worker.recording_discard.connect(self.gui_discard) self.hotkey_worker.error.connect(self._on_hotkey_error) self.hotkey_thread.start() @@ -1070,6 +1073,7 @@ def _stop_recording_and_process(self) -> None: result_text, route_to_compose=routed ) ) + worker.signals.no_speech.connect(self._on_no_speech) worker.signals.error.connect(self._on_worker_error) worker.signals.finished.connect(self._on_worker_finished) @@ -1105,12 +1109,20 @@ def _on_worker_result(self, result_text: str, route_to_compose: bool = False) -> self.current_workflow = None self._set_state("IDLE", "worker result") + @pyqtSlot() + def _on_no_speech(self) -> None: + logger.info("No speech detected; returning to idle without persistent error") + self.current_workflow = None + self._tray_error_message = None + self.show_tray_warning(t("app.name"), t("notify.no_speech.message")) + self._set_state("IDLE", "no speech detected") + @pyqtSlot(str) def _on_worker_error(self, err_msg: str) -> None: logger.error("Worker error: %s", err_msg) + self.current_workflow = None self.show_tray_error(t("notify.error.title"), err_msg) notify_service.notify(t("notify.error.title"), err_msg, urgency="critical") - self.current_workflow = None self._set_state("IDLE", "worker error", keep_error=True) # ------------------------------------------------------------------ diff --git a/app/hotkey_service.py b/app/hotkey_service.py index 8ac47f5..c787121 100644 --- a/app/hotkey_service.py +++ b/app/hotkey_service.py @@ -18,6 +18,7 @@ from app.workflows import WorkflowType DEBOUNCE_SECONDS = 0.6 +MIN_HOLD_PRESS_SECONDS = 0.15 DEVICE_REFRESH_SECONDS = 5.0 logger = logging.getLogger("blitztext.hotkey") @@ -108,6 +109,11 @@ class HotkeyMode(str, Enum): HOLD = "hold" +def classify_hold_release(held_seconds: float) -> str: + """Classify a hold release without starting transcription for accidental taps.""" + return "process" if held_seconds >= MIN_HOLD_PRESS_SECONDS else "discard" + + class HotkeyService: """Mockable Hotkey Service logic used by tests.""" @@ -164,7 +170,8 @@ class HotkeyWorker(QObject): """ workflow_triggered = pyqtSignal(object) # WorkflowType - recording_stop = pyqtSignal() # nur im Hold-Modus + recording_stop = pyqtSignal() # Hold-Modus: Aufnahme verarbeiten + recording_discard = pyqtSignal() # Hold-Modus: zu kurzen Druck verwerfen error = pyqtSignal(str) def __init__( @@ -200,6 +207,7 @@ def run(self) -> None: pressed: Set[int] = set() last_trigger: Dict[WorkflowType, float] = {} _hold_active: Optional[WorkflowType] = None + _hold_started_at: Optional[float] = None from evdev import ecodes as ec # noqa: PLC0415 all_meta_codes = { @@ -251,6 +259,7 @@ def run(self) -> None: fd_to_dev = {dev.fd: dev for dev in devices} pressed.clear() _hold_active = None + _hold_started_at = None next_device_refresh = time.monotonic() + DEVICE_REFRESH_SECONDS logger.debug("Keyboard devices reconnected after select error: %s", _device_paths(devices)) continue @@ -263,6 +272,7 @@ def run(self) -> None: fd_to_dev = new_fd_to_dev pressed.clear() _hold_active = None + _hold_started_at = None next_device_refresh = time.monotonic() + DEVICE_REFRESH_SECONDS for fd in rlist: @@ -279,6 +289,7 @@ def run(self) -> None: fd_to_dev = new_fd_to_dev pressed.clear() _hold_active = None + _hold_started_at = None continue for event in events: @@ -306,8 +317,20 @@ def run(self) -> None: if self._mode == "hold" and value == 0 and _hold_active is not None: for wf, tcode, _, _ in hotkeys: if wf == _hold_active and code == tcode: - self.recording_stop.emit() + held_seconds = ( + time.monotonic() - _hold_started_at + if _hold_started_at is not None else 0.0 + ) + if classify_hold_release(held_seconds) == "process": + self.recording_stop.emit() + else: + logger.info( + "Accidental hold press ignored: %.3fs < %.3fs", + held_seconds, MIN_HOLD_PRESS_SECONDS, + ) + self.recording_discard.emit() _hold_active = None + _hold_started_at = None break if value != 1: @@ -330,6 +353,7 @@ def run(self) -> None: self.workflow_triggered.emit(workflow) if self._mode == "hold": _hold_active = workflow + _hold_started_at = now break except OSError: @@ -339,6 +363,7 @@ def run(self) -> None: ) pressed.clear() _hold_active = None + _hold_started_at = None break def stop(self) -> None: diff --git a/app/i18n.py b/app/i18n.py index 8b514fc..833b2fd 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -194,6 +194,7 @@ "notify.dictation.mode_active": "Diktat-Modus aktiv. Aufnahmen werden als Notizen gesammelt.", "notify.dictation.merged": "Zusammengeführt und gespeichert:\n{path}", "notify.error.title": "Blitztext Fehler", + "notify.no_speech.message": "Keine Sprache im Audio erkannt.", "tray.tooltip.error": "Blitztext Fehler: {message}", "tray.tooltip.recording": "Aufnahme läuft… ({workflow})", "error.recording.title": "Aufnahme-Fehler", @@ -391,6 +392,7 @@ "notify.dictation.mode_active": "Dictation mode active. Recordings are collected as notes.", "notify.dictation.merged": "Merged and saved:\n{path}", "notify.error.title": "Blitztext Error", + "notify.no_speech.message": "No speech detected in the audio.", "tray.tooltip.error": "Blitztext Error: {message}", "tray.tooltip.recording": "Recording… ({workflow})", "error.recording.title": "Recording error", diff --git a/tests/test_hotkey_modes.py b/tests/test_hotkey_modes.py index b5de9ac..173595f 100644 --- a/tests/test_hotkey_modes.py +++ b/tests/test_hotkey_modes.py @@ -66,6 +66,23 @@ def test_toggle_sequence_start_stop_start(self, toggle_service, callbacks): assert callbacks["stop"].call_count == 1 +class TestHoldMinimumPressDuration: + def test_short_hold_requests_discard_instead_of_processing(self): + from app.hotkey_service import classify_hold_release + + assert classify_hold_release(0.149) == "discard" + + def test_hold_at_threshold_requests_processing(self): + from app.hotkey_service import classify_hold_release + + assert classify_hold_release(0.150) == "process" + + def test_normal_hold_requests_processing(self): + from app.hotkey_service import classify_hold_release + + assert classify_hold_release(0.8) == "process" + + class TestHoldMode: def test_keydown_starts(self, hold_service, callbacks): hold_service.simulate_key_down() diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py index d3de2f7..5882534 100644 --- a/tests/test_state_machine.py +++ b/tests/test_state_machine.py @@ -13,6 +13,7 @@ import subprocess import sys import types +from contextlib import nullcontext from unittest.mock import MagicMock, patch import pytest @@ -208,6 +209,40 @@ def test_routed_recording_uses_raw_transcript_instead_of_rewrite(self, tmp_path) llm_service.rewrite.assert_not_called() +class TestTranscribeWorkerNoSpeech: + def test_empty_transcript_emits_no_speech_not_error(self, tmp_path): + from app.blitztext_linux import _TranscribeWorker + + wav_path = tmp_path / "recording.wav" + wav_path.write_bytes(b"fake audio") + worker = _TranscribeWorker( + wav_file=wav_path, + model="base", + language="de", + backend="openai-whisper", + workflow=WorkflowType.TRANSCRIPTION, + llm_service=MagicMock(), + autopaste=False, + paste_service=MagicMock(), + ) + no_speech = [] + results = [] + errors = [] + worker.signals.no_speech.connect(lambda: no_speech.append(True)) + worker.signals.result.connect(results.append) + worker.signals.error.connect(errors.append) + + with patch("app.blitztext_linux.transcribe", return_value=" "): + worker.run() + + assert no_speech == [True] + assert results == [] + assert errors == [] + worker.paste_service.paste.assert_not_called() + worker.paste_service.clipboard_only.assert_not_called() + assert not wav_path.exists() + + # --------------------------------------------------------------------------- # Hilfen: minimales Fake-evdev fuer den HotkeyWorker-Event-Loop # --------------------------------------------------------------------------- @@ -260,14 +295,22 @@ def close(self): pass -def _run_worker_with_events(events, transcription_key="KEY_LEFTALT"): - """Startet HotkeyWorker.run() mit injiziertem Fake-evdev und einem - einzigen Event-Batch. Gibt die Liste ausgeloester WorkflowTypes zurueck.""" +def _run_worker_with_events( + events, + transcription_key="KEY_LEFTALT", + hotkey_mode="toggle", + monotonic_values=None, +): + """Run one fake evdev batch and capture all emitted worker signals.""" ec = _make_fake_ecodes() - worker = HotkeyWorker(hotkey_mode="toggle", transcription_key=transcription_key) + worker = HotkeyWorker(hotkey_mode=hotkey_mode, transcription_key=transcription_key) triggered = [] + stopped = [] + discarded = [] worker.workflow_triggered.connect(lambda wf: triggered.append(wf)) + worker.recording_stop.connect(lambda: stopped.append(True)) + worker.recording_discard.connect(lambda: discarded.append(True)) fake_dev_holder = {} @@ -290,17 +333,55 @@ def fake_select(rlist, wlist, xlist, timeout): dev._batch = batch return (list(rlist), [], []) + if monotonic_values is not None: + monotonic_iter = iter(monotonic_values) + last_monotonic = monotonic_values[-1] + + def fake_monotonic(): + nonlocal last_monotonic + last_monotonic = next(monotonic_iter, last_monotonic) + return last_monotonic + + monotonic_patch = patch("app.hotkey_service.time.monotonic", side_effect=fake_monotonic) + else: + monotonic_patch = nullcontext() with patch.dict(sys.modules, {"evdev": fake_evdev}), \ - patch("select.select", side_effect=fake_select): + patch("select.select", side_effect=fake_select), monotonic_patch: worker.run() - return triggered + if hotkey_mode == "toggle": + return triggered + return triggered, stopped, discarded # --------------------------------------------------------------------------- # Phase 3: evdev value-Handling (KEY_LEFTALT) # --------------------------------------------------------------------------- +class TestHoldWorkerEvents: + def test_short_hold_emits_discard_not_stop(self): + result = _run_worker_with_events( + [(_KEYCODES["KEY_LEFTALT"], 1), (_KEYCODES["KEY_LEFTALT"], 0)], + hotkey_mode="hold", + monotonic_values=[10.0, 10.0, 10.0, 10.149], + ) + triggered, stopped, discarded = result + assert triggered == [WorkflowType.TRANSCRIPTION] + assert stopped == [] + assert discarded == [True] + + def test_hold_at_threshold_emits_stop_not_discard(self): + result = _run_worker_with_events( + [(_KEYCODES["KEY_LEFTALT"], 1), (_KEYCODES["KEY_LEFTALT"], 0)], + hotkey_mode="hold", + monotonic_values=[10.0, 10.0, 10.0, 10.150], + ) + triggered, stopped, discarded = result + assert triggered == [WorkflowType.TRANSCRIPTION] + assert stopped == [True] + assert discarded == [] + + class TestLeftAltEvents: def test_leftalt_keydown_triggers_toggle(self): """value=1 (key-down) loest die Transkription aus.""" @@ -360,6 +441,17 @@ def test_state_returns_to_idle_after_result(self, gui_app): assert gui_app.state == "IDLE" assert gui_app.current_workflow is None + def test_no_speech_result_is_neutral_and_clears_tray_error(self, gui_app): + gui_app.state = "TRANSCRIBING" + gui_app.current_workflow = WorkflowType.TRANSCRIPTION + gui_app._tray_error_message = "alter Fehler" + + gui_app._on_no_speech() + + assert gui_app.state == "IDLE" + assert gui_app.current_workflow is None + assert gui_app._tray_error_message is None + def test_state_returns_to_idle_after_error(self, gui_app): gui_app.state = "TRANSCRIBING" gui_app.current_workflow = WorkflowType.TRANSCRIPTION