Skip to content

Commit 3c74f71

Browse files
committed
Merge remote-tracking branch 'origin/main' into chore/dependabot
2 parents 90d78ff + fe05927 commit 3c74f71

27 files changed

Lines changed: 4091 additions & 343 deletions

CLAUDE.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## What this is
6+
7+
Screamer is a Windows desktop push-to-talk dictation tool. Hold a hotkey, speak, release; audio is recorded at 16 kHz mono, sent to a Whisper-compatible STT endpoint, optionally cleaned up by an LLM rewrite, and typed into the active window via Win32 `SendInput`. It runs as a system-tray app with a settings dialog. Stack: Python 3 + PySide6 (Qt), `sounddevice`, `numpy`, `httpx`. Packaged with PyInstaller.
8+
9+
> Note: `docs/OVERVIEW.md` is a stale pre-fork scouting note (it references PyQt6 and a `transcriber.py` that no longer exists). The authoritative design doc is `docs/IMPLEMENTATION.md` + `docs/PLAN.md`, which match the current code.
10+
11+
## Commands
12+
13+
```powershell
14+
# Dev setup
15+
python -m venv .venv; .\.venv\Scripts\Activate.ps1
16+
pip install -r requirements.txt
17+
18+
# Run the tray app
19+
python -m src.main
20+
21+
# Build a Windows .exe (creates .venv, installs deps, runs PyInstaller)
22+
.\build_windows.ps1 # output: dist\Screamer\Screamer.exe
23+
24+
# Verification (must pass on any OS)
25+
python -m compileall src/
26+
python -c "import src; print('OK')"
27+
```
28+
29+
### Per-module smoke tests
30+
31+
There is **no pytest suite**. Each backend module has a `__main__` block used as its smoke test:
32+
33+
```powershell
34+
python -m src.icons # writes 3 test PNGs (32x32)
35+
python -m src.config # prints defaults, DPAPI roundtrip, creates APP_DIR
36+
python -m src.audio # records 3s -> test.wav, prints duration + RMS
37+
python -m src.hotkey # prints pressed/released (Windows only)
38+
python -m src.injector "hello" # types into active window (Windows only)
39+
python -m src.stt test.wav # transcribes (needs API config)
40+
python -m src.rewrite "test sentense" # corrects text (needs API config)
41+
python -m src.settings_dialog # launches the 4-tab dialog standalone
42+
```
43+
44+
CLI scripts resolve credentials in this order: `load_config()` (QSettings + DPAPI) → backfill empty fields from a `.env` at cwd via `import_from_env()` → if still empty, print a setup message to stderr and `exit(1)`. No hardcoded provider defaults.
45+
46+
## Architecture
47+
48+
The codebase is a strict DAG rooted at `main.py` (the composition root). These dependency rules are load-bearing — preserve them when editing:
49+
50+
- **`main.py` imports everything; nothing imports `main.py`.** It owns the tray icon, the `idle → recording → processing → idle` state machine, and the worker thread lifecycle.
51+
- **The five backend modules (`audio`, `hotkey`, `stt`, `rewrite`, `injector`) must NOT import each other.** They may import only `utils.py`.
52+
- **`stt.py` and `rewrite.py` receive `AppConfig` as a parameter** — they do not import `config.py`. `audio.py` receives device id / name / RMS threshold from `main.py`.
53+
- **Qt (PySide6) lives only in `utils.py`, `icons.py`, `settings_dialog.py`, `main.py`.** The backend modules are Qt-free.
54+
- **`settings_dialog.py` imports only `config.py` and `utils.py`.** It edits a *copy* of the config; the original is untouched until accept.
55+
56+
### Threading model
57+
58+
- The Qt main thread owns all UI. Recording start/stop runs on the main thread.
59+
- The full pipeline (`transcribe → rewrite → type_text`) runs in `_WorkerThread` (a `QThread`) so it never blocks the UI. It checks a `threading.Event` (`cancel_event`) before each blocking step and emits results back via `finished_signal`.
60+
- The hotkey listener runs its own daemon thread with a Win32 `GetMessage` pump. It communicates to the Qt main thread through `SignalBridge` (the `QObject`-with-`Signal` bridge in `utils.py`) — this cross-thread signal pattern is how worker/hotkey threads safely touch the UI.
61+
62+
### Error handling
63+
64+
Backend code raises `ScreamerError(AppError.X, detail=...)` — never bare `print()` or swallowed exceptions. `AppError` (in `utils.py`) is an enum whose `.value` is a user-facing message. `main.py` surfaces these as tray balloon notifications. Non-fatal issues (fallback used, rewrite failed) are carried as `PipelineResult.warnings` rather than raised. When adding a new failure mode, add an `AppError` enum member rather than inventing an ad-hoc message.
65+
66+
### Config & secrets
67+
68+
- Plain settings persist via `QSettings` (IniFormat). API-key fields are encrypted with **Windows DPAPI** before being written (see `_SECRET_FIELDS` in `config.py`).
69+
- All app data lives under `%LOCALAPPDATA%/Screamer/` (`APP_DIR` in `utils.py`). Logs go to a rotating `screamer.log` there.
70+
- **Never log `api_key` values. Never log transcript text unless `setup_logging(debug=True)`.**
71+
72+
### Platform guards
73+
74+
Windows-first, but every module must **import** cleanly on any OS (agents may run on Linux/macOS). Windows-only runtime paths (`hotkey.py`, `injector.py`, DPAPI in `config.py`) guard Win32 calls behind `platform.system() == "Windows"` and raise `ScreamerError(AppError.UNSUPPORTED_PLATFORM)` at *runtime* rather than crashing at import time. DPAPI roundtrip, `RegisterHotKey`, and `SendInput` can only be fully verified on Windows.
75+
76+
## Conventions
77+
78+
- Public API surface of each module is fixed by the contracts in `docs/IMPLEMENTATION.md`. Phase 2 (`main.py`, `settings_dialog.py`) wires Phase 1 modules using only those exports — if you change a backend signature, update that doc.
79+
- No new third-party dependencies and no new modules beyond the 10 in `src/` without a strong reason; the project is deliberately small.
80+
- Hotkeys are `config.Hotkey` value objects (modifiers + one key/mouse trigger), serialized to a canonical string (`ctrl+alt+key:0x20`, `ctrl+mouse:x1`); legacy preset keys auto-migrate via `Hotkey.parse`. Presets live in `HOTKEY_OPTIONS` (`config.py`); the listener uses low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) and swallows the matched trigger. Add safe-bind-alone keys via `SAFE_STANDALONE_KEYS` in `config.py`.

README.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ That's it.
3434
- **OpenAI-compatible speech-to-text** - use OpenAI, Groq, or another compatible `/audio/transcriptions` endpoint.
3535
- **Optional AI cleanup** - fix punctuation, grammar, spelling, and capitalization after transcription.
3636
- **Fallback providers** - configure backup STT and LLM providers if the primary one fails.
37+
- **On-screen recording indicator** - a small pulsing pill appears at the bottom-center of the screen while recording and processing.
3738
- **System tray app** - enable/disable, change hotkey, toggle rewrite, open settings, or exit from the tray.
3839
- **Microphone selection** - pick your input device and calibrate silence detection.
3940
- **Post-type key** - optionally press `Enter`, `Tab`, `Space`, or `Backspace` after typing.
@@ -62,7 +63,17 @@ Model: whisper-1
6263
API key: your_api_key
6364
```
6465

65-
For Groq or another provider, use their OpenAI-compatible base URL and model name.
66+
For Groq, prefer:
67+
68+
```text
69+
Base URL: https://api.groq.com/openai/v1
70+
Model: whisper-large-v3-turbo
71+
Language: en
72+
```
73+
74+
If accuracy matters more than speed, use `whisper-large-v3` instead.
75+
76+
For another OpenAI-compatible provider, use its base URL and model name.
6677

6778
The LLM rewrite step is optional. Leave it off if you want raw transcription.
6879

@@ -97,7 +108,7 @@ The LLM rewrite step is optional. Leave it off if you want raw transcription.
97108

98109
## Hotkeys
99110

100-
Available hotkey options:
111+
Quick-pick presets:
101112

102113
```text
103114
Ctrl+Alt+Space
@@ -111,6 +122,12 @@ Pause
111122

112123
Default: `Ctrl+Alt+Space`
113124

125+
Or set a **custom hotkey**: in Settings, click **Record** and press any key
126+
combination, a function key, or a mouse side/middle button. Bare everyday keys
127+
need a modifier (Ctrl/Alt/Shift); function keys, lock/pause keys, and mouse
128+
side/middle buttons may be bound on their own. The matched trigger is swallowed
129+
so it won't reach the app underneath.
130+
114131
## For developers
115132

116133
Run from source:
@@ -167,7 +184,7 @@ Screamer is built for Windows.
167184

168185
It depends on Windows-specific features including:
169186

170-
- global hotkeys via `RegisterHotKey`
187+
- global hotkeys (keyboard or mouse) via low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`)
171188
- text injection via `SendInput`
172189
- tray integration
173190
- DPAPI key storage

docs/FEATURES.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,22 @@
22

33
## PRIORITY FEATURE**
44

5-
Async batching of transcriptions after 5 seconds using queue to boost performance significantly.
5+
Optimization of the STT transcription pipeline speed.
6+
7+
Fix this issue:
8+
When dictating with Screamer, injected text appears correctly in web browsers but NOT in Windows Notepad. The app reports success - no error is shown.
9+
Root Cause: Modifier Key Interference
10+
The default hotkey is Ctrl+Alt+Space. In src/hotkey.py:215-229, the release watcher (_watch_release) only polls for the primary key (Space, VK=0x20). It does not monitor the modifier keys (Ctrl, Alt):
11+
state = user32.GetAsyncKeyState(vk) # only checks 0x20 (Space)
12+
In toggle mode, _finalize_recording() is called directly from the hotkey-pressed handler while Ctrl+Alt+Space is still physically held. The type_text() call via SendInput with KEYEVENTF_UNICODE then runs while Ctrl/Alt are still logically down.
13+
Notepad uses the classic Win32 EDIT control, which respects the current keyboard modifier state. When Ctrl or Alt is held, characters injected via KEYEVENTF_UNICODE/VK_PACKET can be dropped or misinterpreted as accelerators.
14+
Browsers work because they use modern text frameworks (TSF, DirectInput, contenteditable) that handle VK_PACKET robustly regardless of modifier state - so the same SendInput call succeeds there.
15+
Possible Fixes
16+
Fix What Where
17+
1. Wait for all hotkey keys Poll GetAsyncKeyState for both the primary key AND modifier keys (Ctrl, Alt) before emitting hotkey_released src/hotkey.py:_watch_release
18+
2. Explicitly init KEYBDINPUT Set wVk = 0, time = 0, dwExtraInfo = 0 explicitly in _send_unicode and _send_vk (currently relies on implicit ctypes zero-init) src/injector.py:_send_unicode, _send_vk
19+
3. Log target window Call GetForegroundWindow + GetClassNameW before SendInput to see which window actually receives the text src/injector.py:type_text
20+
Fix #1 is the most important - it prevents the pipeline from injecting text while modifier keys are still physically held. Fixes #2 and #3 are low-risk hygiene improvements that help with debugging.
621

722
---
823

docs/IMPLEMENTATION.md

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,24 @@ DEFAULT_LLM_SYSTEM_PROMPT: str = (
7979
DEFAULT_RMS_THRESHOLD: float = 5.0
8080

8181
```python
82-
@dataclass(frozen=True)
83-
class HotkeyBinding:
84-
modifiers: int
85-
vk: int
82+
MOUSE_X1 = 1; MOUSE_X2 = 2; MOUSE_MIDDLE = 3 # mouse trigger ids
83+
84+
HOTKEY_OPTIONS: list[tuple[str, str]] # (canonical_string, display_label) preset pairs
85+
SAFE_STANDALONE_KEYS: frozenset[int] # VKs bindable without a modifier (F-keys, locks, etc.)
86+
MODIFIER_VK_TO_NAME: dict[int, str] # LL-hook modifier VK → "ctrl"/"alt"/"shift"/"win"
8687

87-
HOTKEY_OPTIONS: list[tuple[str, str]] # (key, display_label) pairs for combo hotkeys
88-
HOTKEY_BINDINGS: dict[str, HotkeyBinding] # maps hotkey name → HotkeyBinding
88+
@dataclass(frozen=True)
89+
class Hotkey:
90+
"""Modifiers + a single key/mouse trigger. Serialized to one canonical string."""
91+
mods: frozenset # subset of {"ctrl","alt","shift","win"}
92+
kind: str # "key" | "mouse"
93+
code: int # Win32 VK (kind="key") or a MOUSE_* id (kind="mouse")
94+
95+
def to_canonical(self) -> str: ... # "ctrl+alt+key:0x20", "ctrl+mouse:x1", "key:0x91"
96+
def to_label(self) -> str: ... # "Ctrl+Alt+Space", "Mouse Back"
97+
def validate(self) -> str | None: ... # error message if unsafe, else None
98+
@classmethod
99+
def parse(cls, value: str) -> "Hotkey | None": ... # canonical OR legacy preset key
89100

90101
@dataclass(frozen=True)
91102
class ProviderConfig:
@@ -106,7 +117,7 @@ class ConfigValidationIssue:
106117

107118
@dataclass
108119
class AppConfig:
109-
hotkey: str = "ctrl_alt_space"
120+
hotkey: str = "ctrl+alt+key:0x20" # canonical Hotkey string (see Hotkey.parse)
110121
recording_mode: str = "hold" # "hold" | "toggle"
111122
post_type_key: str = "none" # "none" | "enter" | "tab" | "space" | "backspace"
112123
start_with_windows: bool = False
@@ -198,13 +209,17 @@ class HotkeyMode(Enum):
198209
HOLD = "hold"; TOGGLE = "toggle"
199210

200211
class HotkeyListener:
201-
def __init__(self, key: str, mode: HotkeyMode, bridge: SignalBridge): ...
212+
def __init__(self, hotkey: Hotkey, mode: HotkeyMode, bridge: SignalBridge): ...
202213
def start(self) -> None: ...
203-
"""Create message-only window, RegisterHotKey, GetMessage pump in daemon thread.
204-
Emits bridge.hotkey_pressed / bridge.hotkey_released."""
214+
"""Install WH_KEYBOARD_LL + WH_MOUSE_LL global hooks + GetMessage pump in a daemon thread.
215+
Matches modifiers + trigger, swallows the matched trigger event (returns 1 from the hook).
216+
Emits bridge.hotkey_pressed / bridge.hotkey_released; SetWindowsHookEx failure →
217+
bridge.error_occurred(AppError.HOTKEY_HOOK_FAILED)."""
205218
def stop(self) -> None: ...
206-
"""Post WM_QUIT, join thread, unregister hotkey."""
219+
"""PostThreadMessage WM_QUIT, join thread, unhook both hooks."""
207220
def set_mode(self, mode: HotkeyMode) -> None: ...
221+
# Pure, OS-independent matching core (unit-tested without Win32):
222+
# _on_kb_event(wparam, vk) -> bool ; _on_mouse_event(wparam, mouse_data) -> bool
208223
```
209224

210225
### stt.py
@@ -246,6 +261,23 @@ def get_icon_bytes(state: TrayState) -> bytes: ...
246261
"""Raw PNG bytes for testing without Qt."""
247262
```
248263

264+
### snackbar.py
265+
266+
```python
267+
def snackbar_content_for(state_value: str) -> tuple[str, tuple[int, int, int]] | None: ...
268+
"""Map a TrayState value to (label, dot_rgb); None for idle/unknown (hidden)."""
269+
270+
def bottom_center_xy(available: QRect, size: QSize, margin: int = 48) -> QPoint: ...
271+
"""Top-left point centering *size* horizontally in *available*, *margin* above bottom."""
272+
273+
class RecordingSnackbar(QWidget):
274+
"""Frameless, always-on-top, translucent, click-through status pill at the
275+
bottom-center of the primary screen. Pulsing dot + label.
276+
Never takes focus or appears in the taskbar."""
277+
def show_state(self, label: str, dot_rgb: tuple[int, int, int]) -> None: ...
278+
def hide_state(self) -> None: ...
279+
```
280+
249281
### settings_dialog.py
250282

251283
```python

docs/OVERVIEW.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ This repo is a small but useful prototype for a Groq-based push-to-talk dictatio
5353

5454
## Best fork direction
5555

56-
- Default STT model: `whisper-large-v3-turbo`.
56+
- Preferred Groq STT model: `whisper-large-v3-turbo`.
5757
- Quality fallback: `whisper-large-v3`.
5858
- Add persistent config, device selection, logging, and packaging first.
5959
- Then add VAD, better UX, and optional always-listening / streaming behavior.

docs/SUMMARY.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -457,14 +457,14 @@ Primary STT configuration:
457457

458458
- `STT_API_KEY`, default empty.
459459
- `STT_BASE_URL`, default `https://api.groq.com/openai/v1`.
460-
- `STT_MODEL`, default `whisper-large-v3`.
460+
- `STT_MODEL`, typically `whisper-large-v3-turbo` for Groq.
461461
- `STT_HEADERS`, default empty.
462462

463463
Fallback STT configuration:
464464

465465
- `STT_FALLBACK_API_KEY`, default empty.
466466
- `STT_FALLBACK_BASE_URL`, default `https://api.groq.com/openai/v1`.
467-
- `STT_FALLBACK_MODEL`, default `whisper-large-v3`.
467+
- `STT_FALLBACK_MODEL`, typically `whisper-large-v3` for quality fallback.
468468
- `STT_FALLBACK_HEADERS`, default empty.
469469

470470
Language configuration:
@@ -478,7 +478,7 @@ The fork overview recommends changing the preferred model defaults to:
478478
- Default primary: `whisper-large-v3-turbo`
479479
- Quality fallback: `whisper-large-v3`
480480

481-
The current code still defaults to `whisper-large-v3` for both primary and fallback.
481+
The current code leaves STT model choice to the user.
482482

483483
### LLM Rewrite Configuration
484484

0 commit comments

Comments
 (0)