Skip to content
Open
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
16 changes: 14 additions & 2 deletions app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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

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

# ------------------------------------------------------------------
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
104 changes: 98 additions & 6 deletions tests/test_state_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import subprocess
import sys
import types
from contextlib import nullcontext
from unittest.mock import MagicMock, patch

import pytest
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 = {}

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