Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Blitztext registriert globale Hotkeys via `evdev`. Mit diesen Kombinationen hast

| Workflow | Hotkey | LLM? | Beschreibung |
| :--- | :--- | :---: | :--- |
| **Blitztext** | <kbd>Alt</kbd> (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** | <kbd>Alt</kbd> (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** | <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>H</kbd> | ❌ | Erzwingt eine reine **Offline-Transkription**. |
| **Blitztext+** | <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>T</kbd> | ✅ | Formuliert deine Aufnahme professionell via LLM um. |
| **Blitztext $%&!** | <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>D</kbd> | ✅ | Emotionale Entladung: Wandelt Frust in eine sachliche Nachricht um. |
Expand Down Expand Up @@ -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`.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Blitztext registers global hotkeys via `evdev`. With these combinations you have

| Workflow | Hotkey | LLM? | Description |
| :--- | :--- | :---: | :--- |
| **Blitztext** | <kbd>Alt</kbd> (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** | <kbd>Alt</kbd> (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** | <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>H</kbd> | ❌ | Forces a pure **offline transcription**. |
| **Blitztext+** | <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>T</kbd> | ✅ | Rephrases your recording professionally via LLM. |
| **Blitztext $%&!** | <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>D</kbd> | ✅ | Emotional release: turns frustration into a matter-of-fact message. |
Expand Down Expand Up @@ -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`.
Expand Down
21 changes: 11 additions & 10 deletions app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@ class _WorkerSignals(QObject):
"""Signals for background transcription/rewrite tasks."""
status_changed = pyqtSignal(str) # "transcribing" | "rewriting"
result = pyqtSignal(str)
empty_transcript = pyqtSignal(str)
no_speech = pyqtSignal()
error = pyqtSignal(str)
finished = pyqtSignal(object)

Expand Down Expand Up @@ -601,7 +601,7 @@ def run(self) -> None:
)

if not transcript or not transcript.strip():
self._emit("empty_transcript", "Keine Sprache im Audio erkannt.")
self._emit("no_speech")
return

# Compose routing always receives the raw recognized text; the
Expand Down Expand Up @@ -928,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()
Expand Down Expand Up @@ -1087,7 +1088,7 @@ def _stop_recording_and_process(self) -> None:
result_text, route_to_compose=routed
)
)
worker.signals.empty_transcript.connect(self._on_empty_transcript)
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)

Expand Down Expand Up @@ -1123,13 +1124,13 @@ def _on_worker_result(self, result_text: str, route_to_compose: bool = False) ->
self.current_workflow = None
self._set_state("IDLE", "worker result")

@pyqtSlot(str)
def _on_empty_transcript(self, err_msg: str) -> None:
logger.warning(
"Empty transcription received; releasing recording state for the next hotkey: %s",
err_msg,
)
self._finish_worker_with_error(err_msg, "empty transcription")
@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:
Expand Down
29 changes: 27 additions & 2 deletions app/hotkey_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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__(
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the debounce after discarding a short hold

When a press shorter than 150 ms is discarded, last_trigger[workflow] still contains the key-down timestamp, so an immediate corrective press within DEBOUNCE_SECONDS (600 ms) is silently rejected by the debounce check and never starts recording. Clear that workflow's debounce entry when emitting recording_discard, or otherwise exempt the next key-down after a discard.

Useful? React with 👍 / 👎.

_hold_active = None
_hold_started_at = None
break

if value != 1:
Expand All @@ -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:
Expand All @@ -339,6 +363,7 @@ def run(self) -> None:
)
pressed.clear()
_hold_active = None
_hold_started_at = None
break

def stop(self) -> None:
Expand Down
2 changes: 2 additions & 0 deletions app/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions tests/test_hotkey_modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading