whisper-dictation/docs/superpowers/plans/2026-03-20-gui-app.md

1335 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# GUI App Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Convert `dictate.py` into a modular packaged desktop app with tray-based log panel, no terminal window, and system integration (autostart, start menu, desktop shortcut).
**Architecture:** Split the monolithic `dictate.py` into a `whisper_app/` package with one module per concern. A central log queue in `app.py` replaces all `print()` calls; a compact tkinter panel displays the queue. PyInstaller builds a `--onedir --noconsole` binary per platform.
**Tech Stack:** Python 3.10+, tkinter, pystray, faster-whisper, sounddevice, pynput, PyInstaller, Pillow, pywin32 (Windows only)
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `whisper_app/__init__.py` | Create | Package marker |
| `whisper_app/app.py` | Create | AppState enum, log queue, `log()`, `set_log_queue()` |
| `whisper_app/config.py` | Create | Path resolution, load/save config + vocab |
| `whisper_app/typer.py` | Create | `type_text()` cross-platform |
| `whisper_app/hotkey.py` | Create | `HotkeyListener` (move from dictate.py) |
| `whisper_app/audio.py` | Create | `get_audio_stream()`, `audio_callback()` |
| `whisper_app/transcriber.py` | Create | `load_model()`, `stop_and_transcribe()` |
| `whisper_app/overlay.py` | Create | Recording overlay window |
| `whisper_app/tray.py` | Create | pystray icon + menu |
| `whisper_app/log_window.py` | Create | Compact log panel (380×220px) |
| `whisper_app/settings_window.py` | Create | Settings dialog + INSTALLATION section |
| `whisper_app/vocab_window.py` | Create | Vocabulary dialog |
| `whisper_app/installer.py` | Create | Autostart, start menu, desktop shortcut |
| `main.py` | Create | Entry point, wires all modules, error logfile fallback |
| `build.py` | Create | Generates icon.ico, runs PyInstaller, copies config files |
| `whisper-dictation.spec` | Create | Manual PyInstaller spec for ctranslate2 |
| `dictate.py` | Delete | Replaced by the above |
| `tests/test_config.py` | Create | Path resolution tests |
| `tests/test_app_log.py` | Create | Log queue / buffer tests |
| `tests/test_installer.py` | Create | Installer status detection tests |
---
## Task 1: Package skeleton + config module
**Files:**
- Create: `whisper_app/__init__.py`
- Create: `whisper_app/config.py`
- Create: `tests/test_config.py`
- [ ] **Step 1: Write failing tests for path resolution**
```python
# tests/test_config.py
import sys, os, importlib
def test_app_dir_dev_mode(monkeypatch, tmp_path):
"""In dev mode, _app_dir() returns the repo root (two levels above config.py)."""
monkeypatch.delattr(sys, "frozen", raising=False)
from whisper_app import config
importlib.reload(config)
result = config._app_dir()
# Should be the repo root — parent of whisper_app/
assert os.path.isdir(result)
assert os.path.basename(result) != "whisper_app"
def test_app_dir_frozen_mode(monkeypatch, tmp_path):
"""In frozen mode, _app_dir() returns dirname(sys.executable)."""
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "executable", str(tmp_path / "whisper-dictation.exe"))
from whisper_app import config
importlib.reload(config)
assert config._app_dir() == str(tmp_path)
```
- [ ] **Step 2: Run test to verify it fails**
```
pytest tests/test_config.py -v
```
Expected: `ModuleNotFoundError` — package doesn't exist yet.
- [ ] **Step 3: Create package and config module**
```python
# whisper_app/__init__.py
# (empty)
```
```python
# whisper_app/config.py
import json
import os
import sys
def _app_dir() -> str:
"""Root dir for config.json and vocabulary.json."""
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
# config.py lives at whisper_app/config.py → parent of parent = repo root
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.environ.get("WHISPER_DATA_DIR") or _app_dir()
_env_local = os.environ.get("WHISPER_LOCAL_DIR")
if _env_local:
_local_dir = _env_local
elif os.name == "nt":
_local_dir = os.path.join(os.environ.get("LOCALAPPDATA", DATA_DIR), "WhisperDictation")
else:
_local_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "WhisperDictation")
os.makedirs(_local_dir, exist_ok=True)
CONFIG_FILE = os.path.join(DATA_DIR, "config.json")
CONFIG_LOCAL_FILE = os.path.join(_local_dir, "config_local.json")
VOCAB_FILE = os.path.join(DATA_DIR, "vocabulary.json")
DEFAULT_CONFIG = {
"hotkey": "ctrl+shift+space",
"model": "medium",
"device": "cuda",
"compute_type": "float16",
"language": "de",
"audio_device": None,
"sample_rate": 16000,
}
MODELS = ["tiny", "base", "small", "medium", "large-v2", "large-v3"]
LANGUAGES = {"Deutsch": "de", "English": "en", "Français": "fr", "Español": "es",
"Italiano": "it", "Auto": None}
DEVICES = ["cuda", "cpu"]
COMPUTE_TYPES = {"float16 (GPU)": "float16", "int8 (CPU/GPU)": "int8", "float32": "float32"}
LOCAL_KEYS = {"audio_device", "device", "compute_type"}
config: dict = {}
vocab: dict = {"words": [], "replacements": []}
def load_config() -> None:
global config
config = dict(DEFAULT_CONFIG)
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE) as f:
config.update(json.load(f))
if os.path.exists(CONFIG_LOCAL_FILE):
with open(CONFIG_LOCAL_FILE) as f:
config.update(json.load(f))
def save_config() -> None:
shared = {k: v for k, v in config.items() if k not in LOCAL_KEYS}
local = {k: v for k, v in config.items() if k in LOCAL_KEYS}
with open(CONFIG_FILE, "w") as f:
json.dump(shared, f, indent=2)
with open(CONFIG_LOCAL_FILE, "w") as f:
json.dump(local, f, indent=2)
def load_vocab() -> None:
global vocab
if os.path.exists(VOCAB_FILE):
with open(VOCAB_FILE) as f:
vocab = json.load(f)
else:
vocab = {"words": [], "replacements": []}
def save_vocab() -> None:
with open(VOCAB_FILE, "w") as f:
json.dump(vocab, f, indent=2, ensure_ascii=False)
def apply_vocab(text: str) -> str:
for r in vocab.get("replacements", []):
text = text.replace(r["from"], r["to"])
return text
def get_initial_prompt() -> str:
words = vocab.get("words", [])
return ", ".join(words) if words else ""
```
- [ ] **Step 4: Run tests**
```
pytest tests/test_config.py -v
```
Expected: 2 PASSED
- [ ] **Step 5: Commit**
```bash
git add whisper_app/__init__.py whisper_app/config.py tests/test_config.py
git commit -m "feat: add whisper_app package with config module"
```
---
## Task 2: Log queue in app.py
**Files:**
- Create: `whisper_app/app.py`
- Create: `tests/test_app_log.py`
- [ ] **Step 1: Write failing tests**
```python
# tests/test_app_log.py
import queue, threading, importlib
import whisper_app.app as app_mod
def setup_function():
importlib.reload(app_mod)
def test_log_before_queue_buffers():
app_mod.log("hello")
app_mod.log("world")
assert app_mod._log_buffer == ["hello", "world"]
assert app_mod._log_queue is None
def test_log_buffer_capped_at_500():
for i in range(600):
app_mod.log(str(i))
assert len(app_mod._log_buffer) <= 500
def test_set_log_queue_flushes_buffer():
app_mod.log("buffered")
q = queue.Queue()
app_mod.set_log_queue(q)
assert q.get_nowait() == "buffered"
assert app_mod._log_buffer == []
def test_log_after_queue_goes_to_queue():
q = queue.Queue()
app_mod.set_log_queue(q)
app_mod.log("direct")
assert q.get_nowait() == "direct"
def test_log_thread_safe():
q = queue.Queue()
app_mod.set_log_queue(q)
threads = [threading.Thread(target=lambda: app_mod.log("x")) for _ in range(50)]
for t in threads: t.start()
for t in threads: t.join()
assert q.qsize() == 50
```
- [ ] **Step 2: Run test to verify it fails**
```
pytest tests/test_app_log.py -v
```
Expected: `ModuleNotFoundError`
- [ ] **Step 3: Implement app.py**
```python
# whisper_app/app.py
import queue
import threading
from enum import Enum
class AppState(Enum):
IDLE = "idle"
RECORDING = "recording"
TRANSCRIBING = "transcribing"
# ── Log queue ─────────────────────────────────────────────────────────────────
_log_buffer: list[str] = []
_log_queue: queue.Queue | None = None
_log_lock = threading.Lock()
_MAX_BUFFER = 500
def log(msg: str) -> None:
with _log_lock:
if _log_queue is not None:
_log_queue.put(msg)
else:
if len(_log_buffer) >= _MAX_BUFFER:
_log_buffer.pop(0)
_log_buffer.append(msg)
def set_log_queue(q: queue.Queue) -> None:
global _log_queue
with _log_lock:
_log_queue = q
buffered = list(_log_buffer)
_log_buffer.clear()
for msg in buffered:
q.put_nowait(msg)
# ── Global state ──────────────────────────────────────────────────────────────
state: AppState = AppState.IDLE
audio_chunks: list = []
model = None
tray_icon = None
overlay_window = None
overlay_tk = None
hotkey_listener = None
```
- [ ] **Step 4: Run tests**
```
pytest tests/test_app_log.py -v
```
Expected: 5 PASSED
- [ ] **Step 5: Commit**
```bash
git add whisper_app/app.py tests/test_app_log.py
git commit -m "feat: add app module with thread-safe log queue"
```
---
## Task 3: Move hotkey, typer, audio, transcriber modules
**Files:**
- Create: `whisper_app/hotkey.py`
- Create: `whisper_app/typer.py`
- Create: `whisper_app/audio.py`
- Create: `whisper_app/transcriber.py`
No new tests for this task — these are direct extractions with no logic change. Functional testing happens in Task 8 (manual smoke test).
- [ ] **Step 1: Create hotkey.py** — copy `_MODIFIER_MAP`, `_KEY_MAP`, `_parse_hotkey`, `HotkeyListener` verbatim from `dictate.py`. No changes.
```python
# whisper_app/hotkey.py
import threading
from pynput.keyboard import Controller as KeyboardController, Listener as KeyboardListener, Key, KeyCode
_MODIFIER_MAP = { ... } # copy verbatim from dictate.py line 93-100
_KEY_MAP = { ... } # copy verbatim from dictate.py line 102-110
def _parse_hotkey(hotkey_str: str):
... # copy verbatim from dictate.py line 113-133
class HotkeyListener:
... # copy verbatim from dictate.py line 136-168
```
- [ ] **Step 2: Create typer.py** — copy `type_text()` verbatim from `dictate.py`.
```python
# whisper_app/typer.py
import os, shutil, subprocess, time
from pynput.keyboard import Controller as KeyboardController
def type_text(text: str) -> None:
... # copy verbatim from dictate.py line 75-88
```
- [ ] **Step 3: Create audio.py**
```python
# whisper_app/audio.py
import sounddevice as sd
from whisper_app import app, config
def audio_callback(indata, frames, time_info, status):
if app.state == app.AppState.RECORDING:
app.audio_chunks.append(indata.copy())
def get_audio_stream():
device = config.config.get("audio_device")
return sd.InputStream(
samplerate=config.config["sample_rate"],
channels=1,
device=device,
callback=audio_callback,
)
```
- [ ] **Step 4: Create transcriber.py**
```python
# whisper_app/transcriber.py
import threading
import time
import numpy as np
from faster_whisper import WhisperModel
from whisper_app import app, config, typer
def load_model() -> None:
app.log(f"Loading {config.config['model']} on {config.config['device']}...")
app.model = WhisperModel(
config.config["model"],
device=config.config["device"],
compute_type=config.config["compute_type"],
)
app.log("Model ready.")
def stop_and_transcribe() -> None:
if app.state != app.AppState.RECORDING:
return
set_state(app.AppState.TRANSCRIBING)
chunks = list(app.audio_chunks)
if not chunks:
set_state(app.AppState.IDLE)
return
audio = np.concatenate(chunks, axis=0).flatten().astype(np.float32)
duration = len(audio) / config.config["sample_rate"]
rms = float(np.sqrt(np.mean(audio ** 2)))
app.log(f"Audio: {duration:.1f}s RMS: {rms:.5f}")
if duration < 0.3 or rms < 0.0001:
app.log("Too short or silent — skipped.")
set_state(app.AppState.IDLE)
return
target_rms = 0.05
if rms > 0:
audio = audio * (target_rms / rms)
audio = np.clip(audio, -1.0, 1.0)
lang = config.config["language"] if config.config["language"] else None
prompt = config.get_initial_prompt()
segments, _ = app.model.transcribe(
audio, language=lang, beam_size=5, vad_filter=True,
initial_prompt=prompt if prompt else None,
)
text = " ".join(s.text for s in segments).strip()
text = config.apply_vocab(text)
app.log(f"Result: {repr(text)}")
set_state(app.AppState.IDLE)
if text:
time.sleep(0.15)
typer.type_text(text)
def set_state(new_state: app.AppState) -> None: # semi-public, used by main.py
app.state = new_state
if app.tray_icon:
from whisper_app import tray
tray.update_icon(new_state)
if new_state == app.AppState.RECORDING:
from whisper_app import overlay
overlay.show()
else:
from whisper_app import overlay
overlay.hide()
```
- [ ] **Step 5: Commit**
```bash
git add whisper_app/hotkey.py whisper_app/typer.py whisper_app/audio.py whisper_app/transcriber.py
git commit -m "feat: extract hotkey, typer, audio, transcriber modules"
```
---
## Task 4: Overlay and tray modules
**Files:**
- Create: `whisper_app/overlay.py`
- Create: `whisper_app/tray.py`
- [ ] **Step 1: Create overlay.py** — extract from `dictate.py` lines 246-288.
```python
# whisper_app/overlay.py
import os
import tkinter as tk
from whisper_app import app
_window: tk.Toplevel | None = None
def create(root: tk.Tk) -> None:
global _window
win = tk.Toplevel(root)
win.withdraw()
win.overrideredirect(True)
win.attributes("-topmost", True)
win.attributes("-alpha", 0.92)
win.configure(bg="#1a1a1a")
frame = tk.Frame(win, bg="#1a1a1a", padx=12, pady=10)
frame.pack(fill="both", expand=True)
dot = tk.Canvas(frame, width=14, height=14, bg="#1a1a1a", highlightthickness=0)
dot.create_oval(2, 2, 12, 12, fill="#e03030", outline="")
dot.pack(side="left", padx=(0, 8))
_sans = "Segoe UI" if os.name == "nt" else "sans-serif"
tk.Label(frame, text="Aufnahme läuft …", fg="white", bg="#1a1a1a",
font=(_sans, 11)).pack(side="left")
_window = win
def show() -> None:
if app.overlay_tk and _window:
app.overlay_tk.after(0, _show_main)
def hide() -> None:
if app.overlay_tk and _window:
app.overlay_tk.after(0, _hide_main)
def _show_main() -> None:
sw = app.overlay_tk.winfo_screenwidth()
sh = app.overlay_tk.winfo_screenheight()
_window.geometry(f"220x54+{sw - 240}+{sh - 100}")
_window.deiconify()
_window.lift()
def _hide_main() -> None:
_window.withdraw()
```
- [ ] **Step 2: Create tray.py**
```python
# whisper_app/tray.py
import pystray
from PIL import Image, ImageDraw
from whisper_app import app
from whisper_app.app import AppState
def _make_icon(color: tuple) -> Image.Image:
img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.ellipse([4, 4, 60, 60], fill=color)
return img
ICONS = {
AppState.IDLE: _make_icon((40, 200, 80)),
AppState.RECORDING: _make_icon((220, 50, 50)),
AppState.TRANSCRIBING: _make_icon((220, 180, 30)),
}
def update_icon(state: AppState) -> None:
if app.tray_icon:
app.tray_icon.icon = ICONS[state]
def create(on_settings, on_vocab, on_show_log, on_quit) -> pystray.Icon:
menu = pystray.Menu(
pystray.MenuItem("Anzeigen", on_show_log, default=True),
pystray.MenuItem("Einstellungen", on_settings),
pystray.MenuItem("Vokabular", on_vocab),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Beenden", on_quit),
)
icon = pystray.Icon("whisper", ICONS[AppState.IDLE], "Whisper Dictation", menu)
app.tray_icon = icon
return icon
```
- [ ] **Step 3: Commit**
```bash
git add whisper_app/overlay.py whisper_app/tray.py
git commit -m "feat: add overlay and tray modules"
```
---
## Task 5: Log window
**Files:**
- Create: `whisper_app/log_window.py`
- [ ] **Step 1: Create log_window.py**
```python
# whisper_app/log_window.py
import os
import queue
import tkinter as tk
from whisper_app import app
BG = "#18181f"
BG2 = "#22222c"
BG3 = "#2c2c38"
BORDER = "#38384a"
FG = "#e8e8f0"
FG2 = "#7878a0"
AMBER = "#f5a623"
GREEN = "#4ade80"
RED = "#f87171"
YELLOW = "#facc15"
_sans = "Segoe UI" if os.name == "nt" else "sans-serif"
_mono = "Consolas" if os.name == "nt" else "monospace"
_window: tk.Toplevel | None = None
_text: tk.Text | None = None
_log_q: queue.Queue | None = None
_MAX_LINES = 200
def create(root: tk.Tk, log_queue: queue.Queue,
on_settings, on_vocab) -> tk.Toplevel:
global _window, _text, _log_q
_log_q = log_queue
win = tk.Toplevel(root)
win.title("Whisper Dictation")
win.configure(bg=BG)
win.resizable(False, False)
win.protocol("WM_DELETE_WINDOW", win.withdraw)
# ── Header ──
hdr = tk.Frame(win, bg=BG2)
hdr.pack(fill="x")
tk.Frame(hdr, bg=AMBER, height=2).pack(fill="x")
hdr_inner = tk.Frame(hdr, bg=BG2, padx=12, pady=6)
hdr_inner.pack(fill="x")
tk.Label(hdr_inner, text="WHISPER DICTATION",
font=(_sans, 11, "bold"), bg=BG2, fg=FG).pack(side="left")
tk.Button(hdr_inner, text="✕", command=win.withdraw,
bg=BG2, fg=FG2, relief="flat", bd=0,
font=(_sans, 11), cursor="hand2").pack(side="right")
# ── Log text ──
txt = tk.Text(win, bg=BG, fg=FG, font=(_mono, 10),
relief="flat", bd=0, padx=10, pady=6,
state="disabled", wrap="none",
width=48, height=10,
highlightthickness=0)
txt.pack(fill="both", expand=True)
txt.tag_config("green", foreground=GREEN)
txt.tag_config("red", foreground=RED)
txt.tag_config("yellow", foreground=YELLOW)
txt.tag_config("grey", foreground=FG2)
_text = txt
# ── Button bar ──
bar = tk.Frame(win, bg=BG2, pady=6, padx=10)
bar.pack(fill="x")
for label, cmd in [("⚙ Einstellungen", on_settings), ("📚 Vokabular", on_vocab)]:
b = tk.Button(bar, text=label, command=cmd,
bg=BG3, fg=FG, relief="flat", bd=0,
font=(_sans, 10), padx=10, pady=4, cursor="hand2")
b.pack(side="left", padx=(0, 4))
tk.Button(bar, text="🗑", command=_clear_log,
bg=BG3, fg=FG2, relief="flat", bd=0,
font=(_sans, 10), padx=8, pady=4, cursor="hand2").pack(side="right")
_window = win
win.withdraw()
root.after(100, _poll)
return win
def show() -> None:
if _window:
_window.deiconify()
_window.lift()
def _clear_log() -> None:
if _text:
_text.config(state="normal")
_text.delete("1.0", "end")
_text.config(state="disabled")
def _tag_for(msg: str) -> str:
low = msg.lower()
if any(x in low for x in ("recording", "aufnahme")):
return "red"
if any(x in low for x in ("transcrib", "loading", "laden")):
return "yellow"
if any(x in low for x in ("result:", "ready", "bereit")):
return "green"
return "grey"
def _poll() -> None:
if _log_q and _text:
try:
while True:
msg = _log_q.get_nowait()
_append(msg)
except queue.Empty:
pass
if app.overlay_tk:
app.overlay_tk.after(100, _poll)
def _append(msg: str) -> None:
_text.config(state="normal")
# Trim to MAX_LINES
lines = int(_text.index("end-1c").split(".")[0])
if lines >= _MAX_LINES:
_text.delete("1.0", "2.0")
_text.insert("end", msg + "\n", _tag_for(msg))
_text.see("end")
_text.config(state="disabled")
```
- [ ] **Step 2: Commit**
```bash
git add whisper_app/log_window.py
git commit -m "feat: add compact log panel"
```
---
## Task 6: Settings and vocab windows
**Files:**
- Create: `whisper_app/settings_window.py`
- Create: `whisper_app/vocab_window.py`
- [ ] **Step 1: Create settings_window.py**
Extract `_open_settings_main()` from `dictate.py` into `open(root, on_reload)`. Change all `config["x"]` references to use `config.config["x"]` and `config.save_config()`. Add an INSTALLATION section at the bottom that calls `installer.py` (created in Task 7).
```python
# whisper_app/settings_window.py
import os, threading
import tkinter as tk
from tkinter import ttk
import sounddevice as sd
from whisper_app import config, installer
# (colour constants identical to dictate.py — copy BG/BG2/BG3/BORDER/FG/FG2/AMBER/AMBER2/GREEN)
def open(root: tk.Tk, on_reload) -> None:
root.after(0, lambda: _open_main(root, on_reload))
def _open_main(root: tk.Tk, on_reload) -> None:
# Copy dictate.py lines 381548 (_open_settings_main) verbatim, then apply:
#
# 1. Remove the global `config` reference at the top — import from module instead:
# from whisper_app import config as cfg
#
# 2. Replace every occurrence of:
# config["x"] → cfg.config["x"]
# config.get("x") → cfg.config.get("x")
# config["x"] = y → cfg.config["x"] = y
#
# 3. Replace:
# save_config() → cfg.save_config()
#
# 4. In the save() callback, replace:
# threading.Thread(target=reload_model_and_hotkey, daemon=True).start()
# → threading.Thread(target=on_reload, daemon=True).start()
#
# 5. After the existing button bar at the end of content, add:
# _add_installation_section(win, content, BG, BG3, BORDER, FG, FG2, AMBER, FONT_UI, FONT_S, FONT_B)
pass # copy + apply substitutions above
def _add_installation_section(win, content, BG, BG3, BORDER, FG, FG2, AMBER, FONT_UI, FONT_S, FONT_B) -> None:
"""Append INSTALLATION section to settings window."""
import sys
from whisper_app import installer
is_frozen = getattr(sys, "frozen", False)
# Section header — same helper as AUDIO/MODELL sections above
section("INSTALLATION")
features = [
("Autostart", installer.autostart_installed, installer.install_autostart, installer.remove_autostart),
("Startmenü-Eintrag", installer.startmenu_installed, installer.install_startmenu, installer.remove_startmenu),
("Desktop-Verknüpfung", installer.desktop_installed, installer.install_desktop, installer.remove_desktop),
]
for label, is_installed_fn, install_fn, remove_fn in features:
f = row(label)
status_var = tk.StringVar(value="eingerichtet" if is_installed_fn() else "nicht eingerichtet")
status_lbl = tk.Label(f, textvariable=status_var, font=FONT_S,
bg=BG, fg=AMBER if is_installed_fn() else FG2)
status_lbl.pack(side="left", padx=(0, 12))
def make_callbacks(install_f, remove_f, sv, lbl):
def do_install():
install_f()
sv.set("eingerichtet")
lbl.config(fg=AMBER)
def do_remove():
remove_f()
sv.set("nicht eingerichtet")
lbl.config(fg=FG2)
return do_install, do_remove
do_install, do_remove = make_callbacks(install_fn, remove_fn, status_var, status_lbl)
btn_install = tk.Button(f, text="Einrichten", command=do_install,
bg=BG3, fg=FG, font=FONT_S, relief="flat",
padx=8, pady=3, cursor="hand2" if is_frozen else "arrow", bd=0)
btn_install.pack(side="left", padx=(0, 4))
btn_remove = tk.Button(f, text="Entfernen", command=do_remove,
bg=BG3, fg=FG2, font=FONT_S, relief="flat",
padx=8, pady=3, cursor="hand2" if is_frozen else "arrow", bd=0)
btn_remove.pack(side="left")
if not is_frozen:
for btn in (btn_install, btn_remove):
btn.config(state="disabled")
# Simple tooltip via label
tk.Label(f, text="Nur im gebauten Binary verfügbar",
font=FONT_S, bg=BG, fg=FG2).pack(side="left", padx=(8, 0))
```
- [ ] **Step 2: Create vocab_window.py**
Extract `_open_vocab_main()` from `dictate.py` verbatim into `open(root)`. Replace `config.config` references and `save_vocab()`/`vocab` references to use `config` module.
```python
# whisper_app/vocab_window.py
import tkinter as tk
from whisper_app import config
def open(root: tk.Tk) -> None:
root.after(0, lambda: _open_main(root))
def _open_main(root: tk.Tk) -> None:
# copy dictate.py lines 556-741 verbatim
# Replace: vocab["x"] → config.vocab["x"]
# save_vocab() → config.save_vocab()
pass # implementer fills in
```
- [ ] **Step 3: Commit**
```bash
git add whisper_app/settings_window.py whisper_app/vocab_window.py
git commit -m "feat: extract settings and vocab windows to modules"
```
---
## Task 7: Installer module
**Files:**
- Create: `whisper_app/installer.py`
- Create: `tests/test_installer.py`
- [ ] **Step 1: Write failing tests**
```python
# tests/test_installer.py
import sys, os
def test_autostart_roundtrip(tmp_path, monkeypatch):
"""install then remove leaves no trace."""
import whisper_app.installer as inst
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "executable", str(tmp_path / "wd.exe"))
if sys.platform != "win32":
monkeypatch.setenv("HOME", str(tmp_path))
# Ensure autostart dir path is under tmp
monkeypatch.setattr(inst, "_autostart_path",
lambda: str(tmp_path / ".config/autostart/whisper-dictation.desktop"))
inst.install_autostart()
assert inst.autostart_installed()
inst.remove_autostart()
assert not inst.autostart_installed()
```
- [ ] **Step 2: Run test to verify it fails**
```
pytest tests/test_installer.py -v
```
Expected: `ModuleNotFoundError`
- [ ] **Step 3: Create installer.py**
```python
# whisper_app/installer.py
import os
import subprocess
import sys
def _exe_path() -> str:
return sys.executable if getattr(sys, "frozen", False) else ""
def _is_frozen() -> bool:
return getattr(sys, "frozen", False)
# ── Autostart ─────────────────────────────────────────────────────────────────
def _autostart_path() -> str:
if sys.platform == "win32":
return "" # Windows uses registry, not a file path
return os.path.join(os.path.expanduser("~"), ".config", "autostart",
"whisper-dictation.desktop")
def autostart_installed() -> bool:
if sys.platform == "win32":
import winreg
try:
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run")
winreg.QueryValueEx(key, "WhisperDictation")
return True
except FileNotFoundError:
return False
return os.path.exists(_autostart_path())
def install_autostart() -> None:
if sys.platform == "win32":
import winreg
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run", access=winreg.KEY_SET_VALUE)
winreg.SetValueEx(key, "WhisperDictation", 0, winreg.REG_SZ, f'"{_exe_path()}"')
else:
path = _autostart_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
_write_desktop(path)
def remove_autostart() -> None:
if sys.platform == "win32":
import winreg
try:
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run", access=winreg.KEY_SET_VALUE)
winreg.DeleteValue(key, "WhisperDictation")
except FileNotFoundError:
pass
else:
path = _autostart_path()
if os.path.exists(path):
os.remove(path)
# ── Start menu ────────────────────────────────────────────────────────────────
def _startmenu_path() -> str:
if sys.platform == "win32":
return os.path.join(os.environ.get("APPDATA", ""),
r"Microsoft\Windows\Start Menu\Programs\Whisper Dictation.lnk")
return os.path.join(os.path.expanduser("~"), ".local", "share", "applications",
"whisper-dictation.desktop")
def startmenu_installed() -> bool:
return os.path.exists(_startmenu_path())
def install_startmenu() -> None:
path = _startmenu_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
if sys.platform == "win32":
_create_lnk(path)
else:
_write_desktop(path)
def remove_startmenu() -> None:
path = _startmenu_path()
if os.path.exists(path):
os.remove(path)
# ── Desktop shortcut ──────────────────────────────────────────────────────────
def _desktop_dir() -> str:
if sys.platform == "win32":
return os.environ.get("USERPROFILE", os.path.expanduser("~"))
try:
result = subprocess.run(["xdg-user-dir", "DESKTOP"], capture_output=True, text=True)
path = result.stdout.strip()
if path:
return path
except FileNotFoundError:
pass
return os.path.join(os.path.expanduser("~"), "Desktop")
def _desktop_path() -> str:
if sys.platform == "win32":
return os.path.join(_desktop_dir(), "Whisper Dictation.lnk")
return os.path.join(_desktop_dir(), "whisper-dictation.desktop")
def desktop_installed() -> bool:
return os.path.exists(_desktop_path())
def install_desktop() -> None:
path = _desktop_path()
if sys.platform == "win32":
_create_lnk(path)
else:
_write_desktop(path)
os.chmod(path, 0o755)
def remove_desktop() -> None:
path = _desktop_path()
if os.path.exists(path):
os.remove(path)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _write_desktop(path: str) -> None:
icon_path = os.path.join(os.path.dirname(_exe_path()), "icon.png")
content = (
"[Desktop Entry]\n"
"Type=Application\n"
"Name=Whisper Dictation\n"
f"Exec={_exe_path()}\n"
f"Icon={icon_path}\n"
"Terminal=false\n"
"Categories=Utility;\n"
)
with open(path, "w") as f:
f.write(content)
def _create_lnk(path: str) -> None:
if sys.platform != "win32":
return
import win32com.client # guarded import — Windows only
shell = win32com.client.Dispatch("WScript.Shell")
lnk = shell.CreateShortCut(path)
lnk.Targetpath = _exe_path()
lnk.WorkingDirectory = os.path.dirname(_exe_path())
lnk.IconLocation = _exe_path()
lnk.save()
```
- [ ] **Step 4: Run tests**
```
pytest tests/test_installer.py -v
```
Expected: 1 PASSED (on Linux; on Windows the registry variant runs)
- [ ] **Step 5: Commit**
```bash
git add whisper_app/installer.py tests/test_installer.py
git commit -m "feat: add installer module for system integration"
```
---
## Task 8: main.py — wire everything together
**Files:**
- Create: `main.py`
- [ ] **Step 1: Create main.py**
```python
# main.py
import os
import queue
import sys
import threading
def _setup_error_log():
"""Last-resort error log for crashes before tray appears (frozen mode)."""
if not getattr(sys, "frozen", False):
return
import traceback
if os.name == "nt":
log_dir = os.path.join(os.environ.get("LOCALAPPDATA", ""), "WhisperDictation")
else:
log_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "WhisperDictation")
os.makedirs(log_dir, exist_ok=True)
log_path = os.path.join(log_dir, "error.log")
sys.excepthook = lambda *args: open(log_path, "a").write(
"".join(traceback.format_exception(*args)) + "\n"
)
def main():
_setup_error_log()
from whisper_app import app, config, audio, transcriber, hotkey
from whisper_app import overlay, tray, log_window, settings_window, vocab_window
import tkinter as tk
config.load_config()
config.load_vocab()
# Tkinter root (hidden)
root = tk.Tk()
root.withdraw()
app.overlay_tk = root
# Log queue — connect before model load so early messages appear
log_q: queue.Queue = queue.Queue()
app.set_log_queue(log_q)
# Windows
overlay.create(root)
log_win = log_window.create(
root, log_q,
on_settings=lambda: settings_window.open(root, _reload),
on_vocab=lambda: vocab_window.open(root),
)
# Load model in background so the tray appears immediately
threading.Thread(target=transcriber.load_model, daemon=True).start()
# Audio stream
stream = audio.get_audio_stream()
stream.start()
# Hotkey
def _on_release():
threading.Thread(target=transcriber.stop_and_transcribe, daemon=True).start()
app.hotkey_listener = hotkey.HotkeyListener(
config.config["hotkey"],
on_press=_start_recording,
on_release=_on_release,
)
# Tray
icon = tray.create(
on_settings=lambda: settings_window.open(root, _reload),
on_vocab=lambda: vocab_window.open(root),
on_show_log=lambda: root.after(0, log_window.show),
on_quit=lambda: _quit(stream, icon),
)
threading.Thread(target=icon.run, daemon=True).start()
app.log(f"Bereit. Hotkey: {config.config['hotkey']}")
root.mainloop()
stream.stop()
def _start_recording():
from whisper_app import app, transcriber
app.audio_chunks = []
transcriber.set_state(app.AppState.RECORDING) # set_state is semi-public by design
app.log("Recording...")
def _reload():
from whisper_app import app, config, transcriber, hotkey
if app.hotkey_listener:
app.hotkey_listener.stop()
threading.Thread(target=transcriber.load_model, daemon=True).start()
app.hotkey_listener = hotkey.HotkeyListener(
config.config["hotkey"],
on_press=_start_recording,
on_release=lambda: threading.Thread(
target=transcriber.stop_and_transcribe, daemon=True).start(),
)
app.log(f"Hotkey aktualisiert: {config.config['hotkey']}")
def _quit(stream, icon):
stream.stop()
icon.stop()
from whisper_app import app
if app.overlay_tk:
app.overlay_tk.after(0, app.overlay_tk.quit)
if __name__ == "__main__":
main()
```
- [ ] **Step 2: Smoke-test in dev mode**
```bash
# Windows
python main.py
# Linux
python3 main.py
```
Expected: tray icon appears, no terminal window needed, clicking tray shows log panel.
- [ ] **Step 3: Commit**
```bash
git add main.py
git commit -m "feat: add main.py entry point wiring all modules"
```
---
## Task 9: PyInstaller spec and build script
**Files:**
- Create: `whisper-dictation.spec`
- Create: `build.py`
- [ ] **Step 1: Create whisper-dictation.spec**
```python
# whisper-dictation.spec
# -*- mode: python ; coding: utf-8 -*-
import sys
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[
'ctranslate2',
'faster_whisper',
'sounddevice',
'pynput.keyboard._win32',
'pynput.keyboard._xorg',
'pynput.keyboard._uinput',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='whisper-dictation',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
icon='icon.ico',
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='whisper-dictation',
)
```
- [ ] **Step 2: Create build.py**
```python
# build.py
import os
import shutil
import sys
import subprocess
from PIL import Image
def generate_icon():
"""Generate icon.ico from tray icon colors (green dot on transparent)."""
sizes = [16, 32, 48, 256]
frames = []
for size in sizes:
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
from PIL import ImageDraw
d = ImageDraw.Draw(img)
margin = max(1, size // 16)
d.ellipse([margin, margin, size - margin, size - margin], fill=(40, 200, 80))
frames.append(img)
frames[0].save("icon.ico", format="ICO", sizes=[(s, s) for s in sizes],
append_images=frames[1:])
print("icon.ico generated.")
def build():
generate_icon()
subprocess.run([sys.executable, "-m", "PyInstaller", "whisper-dictation.spec",
"--noconfirm"], check=True)
dist_dir = os.path.join("dist", "whisper-dictation")
for fname in ["config.json", "vocabulary.json"]:
dest = os.path.join(dist_dir, fname)
if not os.path.exists(dest):
shutil.copy(fname, dest)
print(f"Copied {fname}{dist_dir}/")
else:
print(f"Skipped {fname} (already exists in dist — preserving user edits)")
print(f"\nBuild complete: {dist_dir}/")
if __name__ == "__main__":
build()
```
- [ ] **Step 3: Run build (on target platform)**
```bash
python build.py
```
Expected output ends with:
```
Build complete: dist/whisper-dictation/
```
- [ ] **Step 4: Test the binary**
```bash
# Windows
dist\whisper-dictation\whisper-dictation.exe
# Linux
./dist/whisper-dictation/whisper-dictation
```
Expected: tray icon appears, no terminal window, log panel opens on click.
- [ ] **Step 5: Commit**
```bash
git add whisper-dictation.spec build.py
git commit -m "feat: add PyInstaller spec and build script"
```
---
## Task 10: Delete dictate.py, update start scripts
**Files:**
- Delete: `dictate.py`
- Modify: `start.sh`
- Modify: `start.bat` (if exists)
- Modify: `README.md`
- [ ] **Step 1: Update start.sh**
```bash
#!/bin/bash
cd "$(dirname "$0")"
.venv-linux/bin/python -u main.py
```
- [ ] **Step 2: Update start.bat** (Windows dev mode)
```bat
@echo off
cd /d "%~dp0"
.venv-windows\Scripts\python.exe -u main.py
```
- [ ] **Step 3: Delete dictate.py**
```bash
git rm dictate.py
```
- [ ] **Step 4: Update README.md** — add section "Build" explaining `python build.py`, and add `pywin32` to Windows requirements. Keep existing usage instructions but replace `dictate.py` references with `main.py`.
- [ ] **Step 5: Run all tests**
```bash
pytest tests/ -v
```
Expected: all PASSED
- [ ] **Step 6: Final commit**
```bash
git add start.sh start.bat README.md
git commit -m "feat: replace dictate.py with modular app, update start scripts"
```