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