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
80 changes: 80 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

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.

> 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.

## Commands

```powershell
# Dev setup
python -m venv .venv; .\.venv\Scripts\Activate.ps1
pip install -r requirements.txt

# Run the tray app
python -m src.main

# Build a Windows .exe (creates .venv, installs deps, runs PyInstaller)
.\build_windows.ps1 # output: dist\Screamer\Screamer.exe

# Verification (must pass on any OS)
python -m compileall src/
python -c "import src; print('OK')"
```

### Per-module smoke tests

There is **no pytest suite**. Each backend module has a `__main__` block used as its smoke test:

```powershell
python -m src.icons # writes 3 test PNGs (32x32)
python -m src.config # prints defaults, DPAPI roundtrip, creates APP_DIR
python -m src.audio # records 3s -> test.wav, prints duration + RMS
python -m src.hotkey # prints pressed/released (Windows only)
python -m src.injector "hello" # types into active window (Windows only)
python -m src.stt test.wav # transcribes (needs API config)
python -m src.rewrite "test sentense" # corrects text (needs API config)
python -m src.settings_dialog # launches the 4-tab dialog standalone
```

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.

## Architecture

The codebase is a strict DAG rooted at `main.py` (the composition root). These dependency rules are load-bearing — preserve them when editing:

- **`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.
- **The five backend modules (`audio`, `hotkey`, `stt`, `rewrite`, `injector`) must NOT import each other.** They may import only `utils.py`.
- **`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`.
- **Qt (PySide6) lives only in `utils.py`, `icons.py`, `settings_dialog.py`, `main.py`.** The backend modules are Qt-free.
- **`settings_dialog.py` imports only `config.py` and `utils.py`.** It edits a *copy* of the config; the original is untouched until accept.

### Threading model

- The Qt main thread owns all UI. Recording start/stop runs on the main thread.
- 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`.
- 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.

### Error handling

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.

### Config & secrets

- Plain settings persist via `QSettings` (IniFormat). API-key fields are encrypted with **Windows DPAPI** before being written (see `_SECRET_FIELDS` in `config.py`).
- All app data lives under `%LOCALAPPDATA%/Screamer/` (`APP_DIR` in `utils.py`). Logs go to a rotating `screamer.log` there.
- **Never log `api_key` values. Never log transcript text unless `setup_logging(debug=True)`.**

### Platform guards

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.

## Conventions

- 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.
- No new third-party dependencies and no new modules beyond the 10 in `src/` without a strong reason; the project is deliberately small.
- 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`.
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ The LLM rewrite step is optional. Leave it off if you want raw transcription.

## Hotkeys

Available hotkey options:
Quick-pick presets:

```text
Ctrl+Alt+Space
Expand All @@ -121,6 +121,12 @@ Pause

Default: `Ctrl+Alt+Space`

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

## For developers

Run from source:
Expand Down Expand Up @@ -177,7 +183,7 @@ Screamer is built for Windows.

It depends on Windows-specific features including:

- global hotkeys via `RegisterHotKey`
- global hotkeys (keyboard or mouse) via low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`)
- text injection via `SendInput`
- tray integration
- DPAPI key storage
Expand Down
37 changes: 26 additions & 11 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,24 @@ DEFAULT_LLM_SYSTEM_PROMPT: str = (
DEFAULT_RMS_THRESHOLD: float = 5.0

```python
@dataclass(frozen=True)
class HotkeyBinding:
modifiers: int
vk: int
MOUSE_X1 = 1; MOUSE_X2 = 2; MOUSE_MIDDLE = 3 # mouse trigger ids

HOTKEY_OPTIONS: list[tuple[str, str]] # (canonical_string, display_label) preset pairs
SAFE_STANDALONE_KEYS: frozenset[int] # VKs bindable without a modifier (F-keys, locks, etc.)
MODIFIER_VK_TO_NAME: dict[int, str] # LL-hook modifier VK → "ctrl"/"alt"/"shift"/"win"

HOTKEY_OPTIONS: list[tuple[str, str]] # (key, display_label) pairs for combo hotkeys
HOTKEY_BINDINGS: dict[str, HotkeyBinding] # maps hotkey name → HotkeyBinding
@dataclass(frozen=True)
class Hotkey:
"""Modifiers + a single key/mouse trigger. Serialized to one canonical string."""
mods: frozenset # subset of {"ctrl","alt","shift","win"}
kind: str # "key" | "mouse"
code: int # Win32 VK (kind="key") or a MOUSE_* id (kind="mouse")

def to_canonical(self) -> str: ... # "ctrl+alt+key:0x20", "ctrl+mouse:x1", "key:0x91"
def to_label(self) -> str: ... # "Ctrl+Alt+Space", "Mouse Back"
def validate(self) -> str | None: ... # error message if unsafe, else None
@classmethod
def parse(cls, value: str) -> "Hotkey | None": ... # canonical OR legacy preset key

@dataclass(frozen=True)
class ProviderConfig:
Expand All @@ -106,7 +117,7 @@ class ConfigValidationIssue:

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

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

### stt.py
Expand Down
Loading
Loading