Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7386e62
refactor(rewrite): read stt_language directly from config
KristianP26 Jun 12, 2026
bd2ad66
docs(stt): explain why no-speech skips the fallback provider
KristianP26 Jun 12, 2026
0e261d1
fix(rewrite): raise groq completion ceiling for long dictations
KristianP26 Jun 12, 2026
8f39110
perf(injector): send text as a single batched SendInput call
KristianP26 Jun 12, 2026
263e2c6
fix(main): release finished worker threads
KristianP26 Jun 12, 2026
85f177d
fix(main): discard recording when disabled mid-session
KristianP26 Jun 12, 2026
8fee1aa
fix(main): skip startup save when .env adds nothing
KristianP26 Jun 12, 2026
8de82c1
fix(config): read .env from the exe directory in frozen builds
KristianP26 Jun 12, 2026
4ae5879
fix(config): store custom headers via DPAPI instead of plaintext ini
KristianP26 Jun 12, 2026
0419a93
fix(settings): validate config on every accept path
KristianP26 Jun 12, 2026
8c3b25a
fix(settings): replace focus-reveal API keys with explicit show toggle
KristianP26 Jun 12, 2026
64b6fdd
fix(settings): run RMS calibration off the UI thread
KristianP26 Jun 12, 2026
1247451
docs: update implementation notes for hardening fixes
KristianP26 Jun 12, 2026
22ea4a7
chore(settings): drop orphaned section header
KristianP26 Jun 12, 2026
870713b
fix(main): cancel in-flight processing when disabled
KristianP26 Jun 12, 2026
2615176
feat(config): detect stale plaintext secrets in settings.ini
KristianP26 Jun 12, 2026
53b3987
fix(main): purge lingering plaintext secrets at startup
KristianP26 Jun 12, 2026
0e8ef62
fix(settings): drop late calibration results on dialog close
KristianP26 Jun 12, 2026
2a1f89f
fix(settings): follow palette text color for reveal icon
KristianP26 Jun 12, 2026
726a76b
docs(injector): note partial SendInput semantics
KristianP26 Jun 12, 2026
2800e17
test(settings): make calibration test deterministic
KristianP26 Jun 12, 2026
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
9 changes: 6 additions & 3 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,10 @@ def load_config() -> AppConfig: ...
"""Load QSettings + DPAPI. Unknown keys get field defaults."""

def save_config(cfg: AppConfig) -> None: ...
"""Persist to QSettings + DPAPI. api_key fields go through DPAPI."""
"""Persist to QSettings + DPAPI. api_key and custom-header fields go through DPAPI."""

def has_plaintext_secrets() -> bool: ...
"""True if any secret field still sits as plaintext in settings.ini."""

def reset_config() -> AppConfig: ...
"""Fresh AppConfig with all defaults. Does not write disk."""
Expand Down Expand Up @@ -282,7 +285,7 @@ class RecordingSnackbar(QWidget):

```python
class PasswordField(QLineEdit):
"""Password line edit that reveals text only while focused."""
"""Masked line edit with an explicit trailing show/hide toggle action."""

class SettingsDialog(QDialog):
def __init__(
Expand Down Expand Up @@ -477,5 +480,5 @@ Do not proceed to Phase 2 until review passes.
- No modules beyond the 11 listed. No new dependencies.
- Do not implement packaging (PyInstaller), code signing, or cross-platform hotkey backends.
- Autostart registration is implemented in `startup.py`.
- All paths: `%LOCALAPPDATA%/Screamer/`. API keys: DPAPI. Plain settings: QSettings (IniFormat).
- All paths: `%LOCALAPPDATA%/Screamer/`. API keys + custom headers: DPAPI. Plain settings: QSettings (IniFormat).
- If a Phase 2 bug forces a Phase 1 API change, document it in the review checkpoint and get re-approval.
33 changes: 30 additions & 3 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import logging
import os
import platform
import sys
from dataclasses import dataclass, field, fields
from logging.handlers import RotatingFileHandler
from urllib.parse import urlsplit
Expand Down Expand Up @@ -304,12 +305,17 @@ def llm_fallback_provider(self) -> FallbackProviderConfig:
)


# Fields that contain secret API keys and must go through DPAPI.
# Fields that contain secrets and must go through DPAPI: API keys, plus custom
# headers (which routinely carry tokens such as X-Api-Key).
_SECRET_FIELDS = frozenset({
"stt_api_key",
"stt_fallback_api_key",
"llm_api_key",
"llm_fallback_api_key",
"stt_custom_headers",
"stt_fallback_custom_headers",
"llm_custom_headers",
"llm_fallback_custom_headers",
})

# DPAPI entropy string bound to this application.
Expand Down Expand Up @@ -482,12 +488,25 @@ def save_config(cfg: AppConfig) -> None:
settings = _get_qsettings()
for f in fields(AppConfig):
if f.name in _SECRET_FIELDS:
# Purge any plaintext value an older version left in the ini, so it
# cannot shadow the DPAPI-stored value on the next load.
settings.remove(f.name)
continue
settings.setValue(f.name, getattr(cfg, f.name))
settings.sync()
_save_secrets(cfg)


def has_plaintext_secrets() -> bool:
"""True if any secret field still sits as plaintext in settings.ini.

Older versions wrote custom headers to the ini; save_config purges them,
but the purge only happens on save — callers use this to force one.
"""
settings = _get_qsettings()
return any(settings.contains(name) for name in _SECRET_FIELDS)


def reset_config() -> AppConfig:
"""Fresh AppConfig with all defaults. Does not write disk."""
return AppConfig()
Expand Down Expand Up @@ -560,15 +579,23 @@ def validate_config(cfg: AppConfig) -> list[ConfigValidationIssue]:
return issues


def _env_path() -> str:
"""Locate .env: next to the executable when frozen, else at cwd (dev runs)."""
if getattr(sys, "frozen", False):
return os.path.join(os.path.dirname(sys.executable), ".env")
return os.path.join(os.getcwd(), ".env")


def import_from_env(cfg: AppConfig) -> AppConfig:
"""Read .env at cwd; backfill ONLY empty str fields. No-op if no .env file."""
"""Read .env (exe dir when frozen, else cwd); backfill ONLY empty str fields.
No-op if no .env file."""
try:
from dotenv import dotenv_values
except ImportError:
log.debug("python-dotenv not installed; skipping .env import")
return cfg

env_path = os.path.join(os.getcwd(), ".env")
env_path = _env_path()
if not os.path.exists(env_path):
return cfg

Expand Down
42 changes: 27 additions & 15 deletions src/injector.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
}


def _utf16_units(value: str) -> list[str]:
"""Split *value* into UTF-16 code units (surrogate pairs become two units)."""
encoded = value.encode("utf-16-le", errors="surrogatepass")
return [chr(int.from_bytes(encoded[i : i + 2], "little")) for i in range(0, len(encoded), 2)]


def type_text(text: str, post_key: str | None = None) -> None:
"""Type *text* into the active window via Win32 SendInput.

Expand Down Expand Up @@ -90,14 +96,6 @@ def _raise_sendinput_failed(detail: str) -> None:
detail = f"{detail} (WinError {err})"
raise ScreamerError(AppError.INJECTION_FAILED, detail)

def _send_unicode(char: str, key_up: bool = False) -> None:
inp = INPUT()
inp.type = INPUT_KEYBOARD
inp.ki.wScan = ord(char)
inp.ki.dwFlags = KEYEVENTF_UNICODE | (KEYEVENTF_KEYUP if key_up else 0)
if user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT)) != 1:
_raise_sendinput_failed(f"SendInput failed for U+{ord(char):04X}")

def _send_vk(vk: int, key_up: bool = False) -> None:
inp = INPUT()
inp.type = INPUT_KEYBOARD
Expand All @@ -106,16 +104,30 @@ def _send_vk(vk: int, key_up: bool = False) -> None:
if user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT)) != 1:
_raise_sendinput_failed(f"SendInput failed for VK 0x{vk:02X}")

def _utf16_units(value: str) -> list[str]:
encoded = value.encode("utf-16-le", errors="surrogatepass")
return [chr(int.from_bytes(encoded[i : i + 2], "little")) for i in range(0, len(encoded), 2)]

try:
with log_duration(log, f"Text injection ({len(text)} chars)"):
log.info("Typing %d characters", len(text))
for ch in _utf16_units(text):
_send_unicode(ch)
_send_unicode(ch, key_up=True)
# One batched SendInput call: atomic with respect to concurrent
# user input and far fewer syscalls than per-character sends.
units = _utf16_units(text)
if units:
count = 2 * len(units)
batch = (INPUT * count)()
for i, unit in enumerate(units):
code = ord(unit)
down = batch[2 * i]
down.type = INPUT_KEYBOARD
down.ki.wScan = code
down.ki.dwFlags = KEYEVENTF_UNICODE
up = batch[2 * i + 1]
up.type = INPUT_KEYBOARD
up.ki.wScan = code
up.ki.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP
sent = user32.SendInput(count, batch, ctypes.sizeof(INPUT))
# Partial injection (sent < count) means a prefix of the text
# was already typed; there is no rollback — surface the counts.
if sent != count:
_raise_sendinput_failed(f"SendInput injected {sent}/{count} events")

# Post-type key with 0.05s delay.
if post_key and post_key != "none":
Expand Down
32 changes: 28 additions & 4 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import copy
import logging
import threading
from typing import Any
Expand All @@ -29,6 +30,7 @@
POST_KEY_OPTIONS,
AppConfig,
Hotkey,
has_plaintext_secrets,
import_from_env,
load_config,
save_config,
Expand Down Expand Up @@ -116,8 +118,12 @@ def __init__(self, startup_mode: bool = False) -> None:
super().__init__()

self._config = load_config()
self._config = import_from_env(self._config)
save_config(self._config)
imported = import_from_env(copy.deepcopy(self._config))
if imported != self._config or has_plaintext_secrets():
# Save when .env added values, or to purge plaintext secrets an
# older version left in settings.ini (save_config removes them).
self._config = imported
save_config(self._config)

self._recorder = AudioRecorder()
self._bridge = SignalBridge()
Expand Down Expand Up @@ -325,8 +331,20 @@ def _finalize_recording(self) -> None:
self._worker.succeeded.connect(self._on_worker_succeeded)
self._worker.failed.connect(self._on_worker_failed)
self._worker.cancelled.connect(self._on_worker_cancelled)
# Result slots run first (queued in emission order), then the QThread
# object frees itself — otherwise one worker leaks per dictation.
self._worker.finished.connect(self._worker.deleteLater)
self._worker.start()

def _cancel_recording(self) -> None:
"""Stop and discard the in-flight recording without processing it."""
self._recording = False
try:
self._recorder.stop()
except ScreamerError:
pass
self._apply_state(TrayState.IDLE)

# ------------------------------------------------------------------
# Hotkey callbacks (called from hotkey thread via SignalBridge → Qt main)
# ------------------------------------------------------------------
Expand Down Expand Up @@ -409,8 +427,14 @@ def _on_error(self, code: AppError, detail: str | None = None) -> None:

def _toggle_enabled(self, checked: bool) -> None:
self._enabled = checked
if not checked and self._recording:
self._finalize_recording()
if not checked:
if self._recording:
# Disabling means "stop"; don't transcribe and type the leftovers.
self._cancel_recording()
elif self._worker is not None:
# Mid-processing: don't type into the focused window after the
# user disabled us. The worker checks this before each step.
self._cancel_event.set()
log.info("Screamer %s", "enabled" if checked else "disabled")

def _set_recording_mode(self, mode: str, rebuild_menu: bool = True) -> None:
Expand Down
4 changes: 2 additions & 2 deletions src/rewrite.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def rewrite(text: str, config: AppConfig) -> PipelineResult:
return PipelineResult(text=text)

system_prompt = config.llm_system_prompt or ""
language = getattr(config, "stt_language", "")
language = config.stt_language
if language:
system_prompt += f"\nThe speech language is {language}."

Expand Down Expand Up @@ -132,7 +132,7 @@ def _call_llm(
def _groq_completion_cap(user_text: str) -> int:
estimated_input_tokens = max(1, len(user_text) // 4)
cap = int(estimated_input_tokens * 1.5) + 32
return max(128, min(1024, cap))
return max(128, min(4096, cap))


# ---------------------------------------------------------------------------
Expand Down
Loading
Loading