From 5748cec2d7522ac93bde1d768feb77ca72ff4459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=3D=3DTIM=2E=C2=A9=2EB=20=20=3D=3D?= <129355283+TimInTech@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:55:53 +0100 Subject: [PATCH 1/2] feat: add OpenRouter and OpenAI cloud transcription backends Co-Authored-By: Claude Sonnet 5.5 --- app/blitztext_linux.py | 19 ++- app/config.py | 18 ++- app/transcribe.py | 170 ++++++++++++++++++++++-- tests/test_config.py | 24 ++++ tests/test_transcribe.py | 90 +++++++++++++ tests/test_transcribe_worker_cleanup.py | 15 +++ 6 files changed, 319 insertions(+), 17 deletions(-) diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index 0297d92..36553cc 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -231,7 +231,7 @@ def init_ui(self) -> None: self.combo_model.setCurrentText(self.config.model) self.combo_backend = QComboBox() - self.combo_backend.addItems(["openai-whisper", "faster-whisper"]) + self.combo_backend.addItems(["openai-whisper", "faster-whisper", "openrouter", "openai-cloud"]) self.combo_backend.setCurrentText(self.config.backend) self.edit_language = QLineEdit() @@ -716,6 +716,7 @@ def __init__( skip_llm: bool = False, replacements: Optional[list[dict[str, str]]] = None, cleanup_wav: Callable[[], None] | None = None, + transcription_api_key: str = "", ) -> None: super().__init__() self.signals = _WorkerSignals() @@ -732,6 +733,7 @@ def __init__( self.replacements = list(replacements or []) self.route_to_compose = route_to_compose self.skip_llm = skip_llm or route_to_compose + self.transcription_api_key = transcription_api_key def _emit(self, signal_name: str, *args) -> None: try: @@ -748,6 +750,7 @@ def run(self) -> None: language=self.language, backend=self.backend, custom_terms=self.custom_terms, + api_key=self.transcription_api_key, ) transcript = clean_transcript(transcript, self.custom_terms, self.replacements, self.language) @@ -1406,12 +1409,23 @@ def _stop_recording_and_process(self) -> None: self.paste_service.autopaste = self.config.autopaste self.paste_service.key_delay_ms = self.config.paste_key_delay_ms + effective_backend = ( + "faster-whisper" + if self.current_workflow == WorkflowType.LOCAL and self.config.backend in {"openrouter", "openai-cloud"} + else self.config.backend + ) + transcription_api_key = ( + self.config.resolve_transcription_api_key() + if effective_backend in {"openrouter", "openai-cloud"} + else "" + ) + # Create the transcribe worker worker = _TranscribeWorker( wav_file=wav_path, model=self.config.model, language=self.config.language, - backend=self.config.backend, + backend=effective_backend, workflow=self.current_workflow, llm_service=self.llm_service, autopaste=self.config.autopaste, @@ -1421,6 +1435,7 @@ def _stop_recording_and_process(self) -> None: route_to_compose=route_to_compose, skip_llm=skip_llm, cleanup_wav=lambda: AudioRecorder.cleanup_recording(wav_path), + transcription_api_key=transcription_api_key, ) worker.signals.status_changed.connect(self._on_worker_status_changed) diff --git a/app/config.py b/app/config.py index 6744970..d54f39d 100644 --- a/app/config.py +++ b/app/config.py @@ -70,7 +70,7 @@ } VALID_MODELS = {"tiny", "base", "small", "medium", "large", "large-v2", "large-v3", "large-v3-turbo"} -VALID_BACKENDS = {"openai-whisper", "faster-whisper"} +VALID_BACKENDS = {"openai-whisper", "faster-whisper", "openrouter", "openai-cloud"} VALID_HOTKEY_MODES = {"toggle", "hold"} VALID_TONES = {"formal", "neutral", "locker"} VALID_EMOJI_DENSITIES = {"wenig", "mittel", "viel"} @@ -275,6 +275,22 @@ def resolve_llm_api_key(self) -> str: return os.environ.get(self.openai_api_key_env, "").strip() return self.resolve_openai_api_key() + def resolve_transcription_api_key(self) -> str: + """Return the API key for cloud transcription backends without cross-provider leaks.""" + if self.backend == "openrouter": + env_name = ( + self.openai_api_key_env + if self.llm_provider == "openrouter" and self.openai_api_key_env != DEFAULTS["openai_api_key_env"] + else self.openrouter_provider_key_env or "OPENROUTER_API_KEY" + ) + return os.environ.get(env_name, "").strip() + if self.backend == "openai-cloud": + if self.llm_provider == "openai": + return self.resolve_openai_api_key() + env_name = self.openai_provider_key_env or DEFAULTS["openai_api_key_env"] + return os.environ.get(env_name, "").strip() or self._legacy_openai_api_key_value + return "" + @property def has_legacy_openai_api_key(self) -> bool: return self._legacy_openai_api_key_present diff --git a/app/transcribe.py b/app/transcribe.py index 611f296..d6fa98a 100644 --- a/app/transcribe.py +++ b/app/transcribe.py @@ -9,11 +9,15 @@ """ from __future__ import annotations +import base64 import gc +import json import logging import os import sys import threading +import urllib.error +import urllib.request import warnings import wave from pathlib import Path @@ -25,7 +29,11 @@ "tiny", "base", "small", "medium", "large", "large-v2", "large-v3", "large-v3-turbo", } -VALID_BACKENDS = {"openai-whisper", "faster-whisper"} +VALID_BACKENDS = {"openai-whisper", "faster-whisper", "openrouter", "openai-cloud"} +DEFAULT_OPENROUTER_TRANSCRIBE_MODEL = "openai/gpt-4o-transcribe" +DEFAULT_OPENAI_CLOUD_TRANSCRIBE_MODEL = "gpt-4o-transcribe" +_OPENROUTER_TRANSCRIPTIONS_URL = "https://openrouter.ai/api/v1/audio/transcriptions" +_CLOUD_REQUEST_TIMEOUT = 45.0 # A WAV with only its header carries no samples (e.g. an accidental short press). _WAV_HEADER_BYTES = 44 # As in the original Blitztext app: vocabulary hints only for recordings of at @@ -56,15 +64,17 @@ def transcribe( language: str = "de", backend: str = "openai-whisper", custom_terms: list[str] | None = None, + api_key: str = "", ) -> str: """Transkribiert eine WAV-Datei und gibt den Text zurueck. Args: wav_file: Pfad zur WAV-Datei (16 kHz, mono). - model: Whisper-Modellname (z. B. 'base', 'small'). + model: Whisper-Modellname (z. B. 'base', 'small') oder Cloud-Modell-ID. language: Sprachcode ('de', 'en', ...) oder 'auto' fuer Autodetect. - backend: 'openai-whisper' | 'faster-whisper' + backend: 'openai-whisper' | 'faster-whisper' | 'openrouter' | 'openai-cloud' custom_terms: Optionale Eigennamen/Fachbegriffe als Transkriptions-Hint. + api_key: Optionaler API-Schluessel fuer Cloud-Backends (Fallback: Env). Returns: Transkribierter Text (stripped). Leer wenn nichts erkannt. @@ -75,14 +85,14 @@ def transcribe( """ wav_file = Path(wav_file).resolve() - if model not in VALID_MODEL_NAMES: - raise TranscribeError( - f"Ungültiger Modellname {model!r}. Gültig: {sorted(VALID_MODEL_NAMES)}" - ) if backend not in VALID_BACKENDS: raise TranscribeError( f"Ungültiger Backend {backend!r}. Gültig: {sorted(VALID_BACKENDS)}" ) + if backend in {"openai-whisper", "faster-whisper"} and model not in VALID_MODEL_NAMES: + raise TranscribeError( + f"Ungültiger Modellname {model!r}. Gültig: {sorted(VALID_MODEL_NAMES)}" + ) if not wav_file.is_file(): raise TranscribeError(f"WAV-Datei nicht gefunden: {wav_file}") if wav_file.stat().st_size <= _WAV_HEADER_BYTES: @@ -95,9 +105,15 @@ def transcribe( try: hint = _build_transcription_hint(custom_terms) hotwords = _build_hotwords(custom_terms) + if backend == "openrouter": + return _transcribe_openrouter(str(wav_file), model, language, hint=hint, api_key=api_key) + if backend == "openai-cloud": + return _transcribe_openai_cloud(str(wav_file), model, language, hint=hint, api_key=api_key) if backend == "faster-whisper": return _transcribe_faster(str(wav_file), model, language, hint=hint, hotwords=hotwords) return _transcribe_openai(str(wav_file), model, language, hint=hint) + except TranscribeError: + raise except ImportError as exc: raise TranscribeError(str(exc)) from exc except Exception as exc: @@ -299,6 +315,135 @@ def _transcribe_faster( return " ".join(parts).strip() +def _resolve_openrouter_model(model_name: str) -> str: + cleaned = (model_name or "").strip() + if not cleaned or cleaned in VALID_MODEL_NAMES: + return DEFAULT_OPENROUTER_TRANSCRIBE_MODEL + if "/" not in cleaned: + return f"openai/{cleaned}" + return cleaned + + +def _resolve_openai_cloud_model(model_name: str) -> str: + cleaned = (model_name or "").strip() + if not cleaned or cleaned in VALID_MODEL_NAMES: + return DEFAULT_OPENAI_CLOUD_TRANSCRIBE_MODEL + if cleaned.startswith("openai/"): + return cleaned.split("/", 1)[1] + return cleaned + + +def _transcribe_openrouter( + wav_file: str, + model_name: str, + language: str, + hint: str | None = None, + api_key: str = "", +) -> str: + effective_key = ( + api_key.strip() + or os.environ.get("OPENROUTER_API_KEY", "").strip() + ) + if not effective_key: + raise TranscribeError( + "OpenRouter API-Key nicht gesetzt. Bitte OPENROUTER_API_KEY in " + "~/.config/blitztext-linux/secrets.env setzen." + ) + target_model = _resolve_openrouter_model(model_name) + raw_bytes = Path(wav_file).read_bytes() + b64_audio = base64.b64encode(raw_bytes).decode("ascii") + payload: dict[str, Any] = { + "model": target_model, + "input_audio": { + "data": b64_audio, + "format": "wav", + }, + } + norm_lang = _normalize_language(language) + if norm_lang: + payload["language"] = norm_lang + if hint: + payload["prompt"] = hint + + req = urllib.request.Request( + _OPENROUTER_TRANSCRIPTIONS_URL, + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {effective_key}", + "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/TimInTech/blitztext-linux", + "X-Title": "BlitztextLinux", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=_CLOUD_REQUEST_TIMEOUT) as resp: + body = resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + detail = "" + try: + err_body = exc.read().decode("utf-8", errors="replace") + err_json = json.loads(err_body) + if isinstance(err_json, dict) and isinstance(err_json.get("error"), dict): + detail = str(err_json["error"].get("message") or "") + except Exception: + detail = "" + msg = f"OpenRouter STT HTTP {exc.code}" + if detail: + msg = f"{msg}: {detail}" + raise TranscribeError(msg) from exc + + data = json.loads(body) + if not isinstance(data, dict): + return "" + if "error" in data: + err = data["error"] + err_msg = err.get("message") if isinstance(err, dict) else str(err) + raise TranscribeError(f"OpenRouter STT Fehler: {err_msg}") + text = data.get("text") + return text.strip() if isinstance(text, str) else "" + + +def _transcribe_openai_cloud( + wav_file: str, + model_name: str, + language: str, + hint: str | None = None, + api_key: str = "", +) -> str: + effective_key = ( + api_key.strip() + or os.environ.get("OPENAI_API_KEY", "").strip() + ) + if not effective_key: + raise TranscribeError( + "OpenAI API-Key nicht gesetzt. Bitte OPENAI_API_KEY in " + "~/.config/blitztext-linux/secrets.env setzen." + ) + try: + import openai # noqa: PLC0415 + except ImportError as exc: + raise ImportError("openai-Paket nicht installiert. Bitte: pip install openai") from exc + + target_model = _resolve_openai_cloud_model(model_name) + client = openai.OpenAI(api_key=effective_key, max_retries=1, timeout=_CLOUD_REQUEST_TIMEOUT) + kwargs: dict[str, Any] = {"model": target_model} + norm_lang = _normalize_language(language) + if norm_lang: + kwargs["language"] = norm_lang + if hint: + kwargs["prompt"] = hint + + with open(wav_file, "rb") as audio_file: + response = client.audio.transcriptions.create(file=audio_file, **kwargs) + text = getattr(response, "text", None) + if isinstance(text, str): + return text.strip() + if isinstance(response, dict) and isinstance(response.get("text"), str): + return response["text"].strip() + return "" + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -313,12 +458,12 @@ def main() -> int: language = sys.argv[3] if len(sys.argv) > 3 else "de" backend = sys.argv[4] if len(sys.argv) > 4 else "openai-whisper" - if model_name not in VALID_MODEL_NAMES: - logger.error("Invalid model name '%s'. Valid: %s", model_name, sorted(VALID_MODEL_NAMES)) - return 1 if backend not in VALID_BACKENDS: logger.error("Invalid backend '%s'. Valid: %s", backend, sorted(VALID_BACKENDS)) return 1 + if backend in {"openai-whisper", "faster-whisper"} and model_name not in VALID_MODEL_NAMES: + logger.error("Invalid model name '%s'. Valid: %s", model_name, sorted(VALID_MODEL_NAMES)) + return 1 if not os.path.isfile(wav_file): logger.error("WAV file not found: %s", wav_file) return 1 @@ -327,10 +472,7 @@ def main() -> int: return 1 try: - if backend == "faster-whisper": - text = _transcribe_faster(wav_file, model_name, language) - else: - text = _transcribe_openai(wav_file, model_name, language) + text = transcribe(wav_file, model=model_name, language=language, backend=backend) except ImportError as exc: logger.error("Required dependency missing: %s", exc) return 1 diff --git a/tests/test_config.py b/tests/test_config.py index 8472700..15322db 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -928,3 +928,27 @@ def test_openrouter_provider_key_env_is_a_persisted_name(tmp_path): assert BlitztextConfig(config_dir=tmp_path).openrouter_provider_key_env == "MY_ROUTER_KEY" (tmp_path / "config.json").write_text(json.dumps({"openrouter_provider_key_env": 5}), encoding="utf-8") assert BlitztextConfig(config_dir=tmp_path).openrouter_provider_key_env == "" + + +class TestTranscriptionApiKey: + def test_local_backend_returns_no_key(self, config, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + assert config.resolve_transcription_api_key() == "" + + def test_openrouter_backend_uses_openrouter_env(self, config, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "test-or") + monkeypatch.setenv("OPENAI_API_KEY", "test-oa") + config.backend = "openrouter" + assert config.resolve_transcription_api_key() == "test-or" + + def test_openai_cloud_uses_openai_env_when_llm_is_openrouter(self, config, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "test-or") + monkeypatch.setenv("OPENAI_API_KEY", "test-oa") + config.backend = "openai-cloud" + config.llm_provider = "openrouter" + assert config.resolve_transcription_api_key() == "test-oa" + + def test_openai_cloud_without_env_returns_empty(self, config, monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + config.backend = "openai-cloud" + assert config.resolve_transcription_api_key() == "" diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 7fc981b..598156d 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -137,3 +137,93 @@ def test_vocabulary_hint_only_for_recordings_of_at_least_0_9s(tmp_path: Path, se hint = backend_mock.call_args.kwargs["hint"] assert (hint is not None) is expect_hint + + +def test_transcribe_openrouter_sends_base64_wav_and_prompt(tmp_path: Path, monkeypatch): + import json + + wav_file = tmp_path / "sample.wav" + _write_pcm_wav(wav_file, 1.5) + monkeypatch.setenv("OPENROUTER_API_KEY", "test-or-key") + + captured = {} + + class _FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return json.dumps({"text": " Hallo von OpenRouter "}).encode("utf-8") + + def _fake_urlopen(req, timeout=None): + captured["url"] = req.full_url + captured["auth"] = req.get_header("Authorization") + captured["payload"] = json.loads(req.data.decode("utf-8")) + return _FakeResponse() + + with patch("app.transcribe.urllib.request.urlopen", side_effect=_fake_urlopen): + result = transcribe( + wav_file, + model="base", + language="de", + backend="openrouter", + custom_terms=["Blitztext"], + ) + + assert result == "Hallo von OpenRouter" + assert captured["url"] == "https://openrouter.ai/api/v1/audio/transcriptions" + assert captured["auth"] == "Bearer test-or-key" + assert captured["payload"]["model"] == "openai/gpt-4o-transcribe" + assert captured["payload"]["language"] == "de" + assert captured["payload"]["input_audio"]["format"] == "wav" + assert captured["payload"]["prompt"] == "Eigennamen und Begriffe: Blitztext" + + +def test_transcribe_openrouter_raises_without_api_key(tmp_path: Path, monkeypatch): + wav_file = tmp_path / "sample.wav" + _write_pcm_wav(wav_file, 1.0) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + + with pytest.raises(TranscribeError, match="OpenRouter API-Key"): + transcribe(wav_file, backend="openrouter", api_key="") + + +def test_transcribe_openai_cloud_uses_client_and_maps_model(tmp_path: Path, monkeypatch): + import sys + import types + from unittest.mock import MagicMock + + wav_file = tmp_path / "sample.wav" + _write_pcm_wav(wav_file, 1.5) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + client = MagicMock() + client.audio.transcriptions.create.return_value = types.SimpleNamespace(text=" Hallo Cloud ") + fake_openai = types.SimpleNamespace(OpenAI=MagicMock(return_value=client)) + monkeypatch.setitem(sys.modules, "openai", fake_openai) + + result = transcribe(wav_file, model="base", language="de", backend="openai-cloud", api_key="test-oa-key") + + assert result == "Hallo Cloud" + assert fake_openai.OpenAI.call_args.kwargs["api_key"] == "test-oa-key" + kwargs = client.audio.transcriptions.create.call_args.kwargs + assert kwargs["model"] == "gpt-4o-transcribe" + assert kwargs["language"] == "de" + + +def test_transcribe_openai_cloud_raises_without_api_key(tmp_path: Path, monkeypatch): + wav_file = tmp_path / "sample.wav" + _write_pcm_wav(wav_file, 1.0) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + with pytest.raises(TranscribeError, match="OpenAI API-Key"): + transcribe(wav_file, backend="openai-cloud", api_key="") + + +def test_transcribe_cloud_backend_accepts_custom_model_id(): + assert transcribe_module._resolve_openrouter_model("whisper-1") == "openai/whisper-1" + assert transcribe_module._resolve_openrouter_model("vendor/model") == "vendor/model" + assert transcribe_module._resolve_openai_cloud_model("openai/whisper-1") == "whisper-1" diff --git a/tests/test_transcribe_worker_cleanup.py b/tests/test_transcribe_worker_cleanup.py index 59e52e1..8a7f8a4 100644 --- a/tests/test_transcribe_worker_cleanup.py +++ b/tests/test_transcribe_worker_cleanup.py @@ -79,3 +79,18 @@ def test_compose_routing_still_skips_llm_and_paste(tmp_path): llm.rewrite.assert_not_called() paste.clipboard_only.assert_not_called() paste.paste.assert_not_called() + + +def test_worker_passes_transcription_api_key_to_transcribe(tmp_path): + wav = tmp_path / "a.wav" + wav.write_bytes(b"RIFF") + worker = _TranscribeWorker( + wav_file=wav, model="base", language="de", backend="openrouter", + workflow=WorkflowType.TRANSCRIPTION, llm_service=MagicMock(), autopaste=False, + paste_service=MagicMock(), transcription_api_key="test-key", + ) + worker.signals = MagicMock() + with patch("app.blitztext_linux.transcribe", return_value="Hallo") as mock_transcribe: + worker.run() + assert mock_transcribe.call_args.kwargs["api_key"] == "test-key" + assert mock_transcribe.call_args.kwargs["backend"] == "openrouter" From 699192d329580bd98ddc42caf3287ac9ceb30ee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=3D=3DTIM=2E=C2=A9=2EB=20=20=3D=3D?= <129355283+TimInTech@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:56:48 +0100 Subject: [PATCH 2/2] docs: document cloud transcription backends and privacy note Co-Authored-By: Claude Sonnet 5.5 --- README.de.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.de.md b/README.de.md index ea30b46..fc50d01 100644 --- a/README.de.md +++ b/README.de.md @@ -455,7 +455,7 @@ Der Einstellungs-Dialog enthält Spracherkennung, Text & KI, Allgemein und Updat - **model**: Whisper-Modellgröße (`tiny`, `base`, `small`, `medium`, `large`, `large-v2`, `large-v3`, `large-v3-turbo`). Standard: `base`. - **language**: Transkriptions-Sprache (`de`, `en`) oder `auto`. - **ui_language**: Sprache der App-Oberfläche (`de` oder `en`). Standard: `de`. Änderungen greifen nach einem Neustart. -- **backend**: `openai-whisper` oder `faster-whisper`. +- **backend**: `openai-whisper` oder `faster-whisper` (lokal), oder `openrouter` / `openai-cloud` (Cloud-Transkription mit `openai/gpt-4o-transcribe` / `gpt-4o-transcribe`). **Datenschutz:** Bei einem Cloud-Backend wird die Aufnahme an OpenRouter bzw. OpenAI gesendet; der Schlüssel kommt aus `OPENROUTER_API_KEY` / `OPENAI_API_KEY` (oder den konfigurierten Variablennamen) und wird nie in der Config gespeichert. Der Workflow *Lokal* transkribiert immer lokal mit `faster-whisper`, auch wenn ein Cloud-Backend eingestellt ist. - **hotkey_mode**: - `toggle`: Einmal drücken startet, erneutes Drücken beendet. - `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. diff --git a/README.md b/README.md index 07f9f3d..b0283ce 100644 --- a/README.md +++ b/README.md @@ -453,7 +453,7 @@ The settings dialog includes Speech Recognition, Text & AI, General, and Updates - **model**: Whisper model size (`tiny`, `base`, `small`, `medium`, `large`, `large-v2`, `large-v3`, `large-v3-turbo`). Default: `base`. - **language**: Transcription language (`de`, `en`) or `auto`. - **ui_language**: Language of the app interface (`de` or `en`). Default: `de`. Changes take effect after a restart. -- **backend**: `openai-whisper` or `faster-whisper`. +- **backend**: `openai-whisper` or `faster-whisper` (local), or `openrouter` / `openai-cloud` (cloud transcription with `openai/gpt-4o-transcribe` / `gpt-4o-transcribe`). **Privacy:** with a cloud backend the recorded audio is sent to OpenRouter or OpenAI; the key is read from `OPENROUTER_API_KEY` / `OPENAI_API_KEY` (or your configured variable names), never stored in the config. The *Local* workflow always transcribes locally with `faster-whisper`, even if a cloud backend is configured. - **hotkey_mode**: - `toggle`: press once to start, press again to stop. - `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.