1335 lines
40 KiB
Markdown
1335 lines
40 KiB
Markdown
# 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 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.
|
||
|
||
```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"
|
||
```
|