40 KiB
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
# 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
# whisper_app/__init__.py
# (empty)
# 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
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
# 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
# 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
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,HotkeyListenerverbatim fromdictate.py. No changes.
# 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 fromdictate.py.
# 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
# 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
# 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
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.pylines 246-288.
# 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
# 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
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
# 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
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).
# 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 381–548 (_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.
# 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
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
# 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
# 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
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
# 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
# Windows
python main.py
# Linux
python3 main.py
Expected: tray icon appears, no terminal window needed, clicking tray shows log panel.
- Step 3: Commit
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
# 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
# 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)
python build.py
Expected output ends with:
Build complete: dist/whisper-dictation/
- Step 4: Test the binary
# 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
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
#!/bin/bash
cd "$(dirname "$0")"
.venv-linux/bin/python -u main.py
- Step 2: Update start.bat (Windows dev mode)
@echo off
cd /d "%~dp0"
.venv-windows\Scripts\python.exe -u main.py
- Step 3: Delete dictate.py
git rm dictate.py
-
Step 4: Update README.md — add section "Build" explaining
python build.py, and addpywin32to Windows requirements. Keep existing usage instructions but replacedictate.pyreferences withmain.py. -
Step 5: Run all tests
pytest tests/ -v
Expected: all PASSED
- Step 6: Final commit
git add start.sh start.bat README.md
git commit -m "feat: replace dictate.py with modular app, update start scripts"