diff --git a/docs/superpowers/plans/2026-04-12-model-download-progress-dialog.md b/docs/superpowers/plans/2026-04-12-model-download-progress-dialog.md new file mode 100644 index 0000000..3d319bf --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-model-download-progress-dialog.md @@ -0,0 +1,443 @@ +# Model-Download-Fortschrittsdialog — Implementierungsplan + +> **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:** Beim ersten Start zeigt die App einen tkinter-Dialog mit Progressbar, der den echten Byte-Fortschritt des Whisper-Modell-Downloads anzeigt. Bei gecachtem Modell erscheint kein Dialog. + +**Architecture:** `tqdm.tqdm` wird vor dem `WhisperModel()`-Aufruf durch eine eigene Klasse (`TkProgressTqdm`) ersetzt, die Fortschrittsdaten thread-safe via `queue.Queue` an den Hauptthread weiterleitet. Der Hauptthread betreibt einen tkinter-Dialog, der sich erst bei der ersten Download-Meldung zeigt. Nach Abschluss kehrt `load_model_with_progress()` mit dem fertigen `WhisperModel` zurück. + +**Tech Stack:** Python 3.13+, tkinter, `tqdm`, `faster_whisper`, `threading`, `queue` + +--- + +## Dateien + +| Datei | Änderungstyp | Verantwortlichkeit | +|-------|-------------|-------------------| +| `whisper_local/tray/_download_progress.py` | Neu | `TkProgressTqdm`, `DownloadProgressDialog`-Logik, `load_model_with_progress()` | +| `whisper_local/transcriber.py` | Geändert | Optionaler `model`-Parameter in `Transcriber.__init__` | +| `whisper_local/__main__.py` | Geändert | Plattform-Guard + Aufruf von `load_model_with_progress` | +| `tests/test_download_progress.py` | Neu | Unit-Tests für `TkProgressTqdm` | +| `tests/test_transcriber.py` | Geändert | Test für neuen `model`-Parameter + Signatur-Korrektur | + +--- + +## Task 1: `Transcriber` um optionalen `model`-Parameter erweitern + +**Files:** +- Modify: `whisper_local/transcriber.py` +- Modify: `tests/test_transcriber.py` + +- [ ] **Schritt 1: Failing-Test für neuen `model`-Parameter schreiben** + +In `tests/test_transcriber.py` folgenden Test ergänzen: + +```python +def test_init_with_preloaded_model(): + mock_model = MagicMock() + t = Transcriber(language="de", model=mock_model) + assert t.model is mock_model + assert t.language == "de" +``` + +- [ ] **Schritt 2: Test ausführen — erwartet FAIL** + +``` +uv run pytest tests/test_transcriber.py::TestTranscriber::test_init_with_preloaded_model -v +``` + +Erwartete Ausgabe: `FAILED` — `TypeError: __init__() got an unexpected keyword argument 'model'` + +- [ ] **Schritt 3: Bestehenden `test_init_loads_model`-Test korrigieren** + +Der Test prüft derzeit `assert_called_once_with("small", compute_type="int8")`, übergeht aber `download_root=None`. Ersetzen durch: + +```python +@patch("whisper_local.transcriber.WhisperModel") +def test_init_loads_model(self, mock_model_class): + t = Transcriber(model_name="small", compute_type="int8", language="de") + mock_model_class.assert_called_once_with("small", compute_type="int8", download_root=None) + assert t.language == "de" +``` + +- [ ] **Schritt 4: `Transcriber.__init__` um optionalen `model`-Parameter erweitern** + +`whisper_local/transcriber.py` — `__init__`-Signatur und Body ersetzen: + +```python +from faster_whisper import WhisperModel + +class Transcriber: + def __init__( + self, + model_name: str = "small", + compute_type: str = "int8", + language: str = "de", + model: WhisperModel | None = None, + ): + self.language = language + if model is not None: + self.model = model + else: + logger.info("Lade Whisper-Modell '%s' (compute_type=%s)...", model_name, compute_type) + self.model = WhisperModel( + model_name, compute_type=compute_type, download_root=_model_cache_dir() + ) + logger.info("Modell geladen") +``` + +- [ ] **Schritt 5: Alle Transcriber-Tests ausführen — erwartet PASS** + +``` +uv run pytest tests/test_transcriber.py -v +``` + +Erwartete Ausgabe: alle Tests `PASSED` + +- [ ] **Schritt 6: Commit** + +```bash +git add whisper_local/transcriber.py tests/test_transcriber.py +git commit -m "feat: Transcriber akzeptiert optionales vorgeladenes WhisperModel" +``` + +--- + +## Task 2: `TkProgressTqdm` implementieren und testen + +**Files:** +- Create: `whisper_local/tray/_download_progress.py` +- Create: `tests/test_download_progress.py` + +- [ ] **Schritt 1: Test-Datei mit Failing-Tests anlegen** + +`tests/test_download_progress.py`: + +```python +import queue +from unittest.mock import MagicMock, patch + +import tqdm as tqdm_module + +from whisper_local.tray._download_progress import TkProgressTqdm + + +class TestTkProgressTqdm: + def test_update_puts_message_in_queue(self): + q = queue.Queue() + TkProgressTqdm._queue = q + + bar = TkProgressTqdm(total=1000, desc="model.bin", disable=True) + bar.update(300) + bar.close() + + TkProgressTqdm._queue = None + + msg = q.get_nowait() + assert msg["file"] == "model.bin" + assert msg["n"] == 300 + assert msg["total"] == 1000 + + def test_update_without_queue_does_not_raise(self): + TkProgressTqdm._queue = None + bar = TkProgressTqdm(total=100, desc="test.bin", disable=True) + bar.update(50) # darf nicht crashen + bar.close() + + def test_multiple_updates_accumulate(self): + q = queue.Queue() + TkProgressTqdm._queue = q + + bar = TkProgressTqdm(total=1000, desc="file.bin", disable=True) + bar.update(200) + bar.update(300) + bar.close() + + TkProgressTqdm._queue = None + + msgs = [] + while not q.empty(): + msgs.append(q.get_nowait()) + + assert len(msgs) == 2 + assert msgs[0]["n"] == 200 + assert msgs[1]["n"] == 500 # tqdm akkumuliert: 200+300 +``` + +- [ ] **Schritt 2: Tests ausführen — erwartet FAIL** + +``` +uv run pytest tests/test_download_progress.py -v +``` + +Erwartete Ausgabe: `ERROR` / `ImportError` — Modul existiert noch nicht + +- [ ] **Schritt 3: `_download_progress.py` mit `TkProgressTqdm` anlegen** + +`whisper_local/tray/_download_progress.py`: + +```python +"""Download-Fortschrittsdialog für den ersten Whisper-Modell-Download (Windows).""" + +from __future__ import annotations + +import queue +import sys +import threading +from typing import Any + +import tqdm as tqdm_module + + +class TkProgressTqdm(tqdm_module.tqdm): + """tqdm-Ersatz, der Fortschritts-Updates thread-safe in eine Queue schreibt.""" + + _queue: queue.Queue | None = None + + def update(self, n: int = 1) -> bool | None: + result = super().update(n) + if self._queue is not None: + self._queue.put({ + "file": self.desc or "", + "n": self.n, + "total": self.total or 0, + }) + return result +``` + +- [ ] **Schritt 4: Tests ausführen — erwartet PASS** + +``` +uv run pytest tests/test_download_progress.py -v +``` + +Erwartete Ausgabe: alle 3 Tests `PASSED` + +- [ ] **Schritt 5: Commit** + +```bash +git add whisper_local/tray/_download_progress.py tests/test_download_progress.py +git commit -m "feat: TkProgressTqdm leitet tqdm-Fortschritt an Queue weiter" +``` + +--- + +## Task 3: `load_model_with_progress` implementieren + +**Files:** +- Modify: `whisper_local/tray/_download_progress.py` + +- [ ] **Schritt 1: `load_model_with_progress` an `_download_progress.py` anhängen** + +Folgenden Code nach der `TkProgressTqdm`-Klasse einfügen: + +```python +def load_model_with_progress( + model_name: str, + compute_type: str, + download_root: str | None, +) -> Any: + """Lädt WhisperModel — zeigt bei Bedarf einen Download-Fortschrittsdialog. + + Wenn das Modell bereits gecacht ist (kein tqdm-Update kommt), erscheint + kein Dialog. Auf Download-Fehler wird ein Fehlerdialog gezeigt und sys.exit(1) + aufgerufen. + """ + import tkinter as tk + from tkinter import messagebox, ttk + + from faster_whisper import WhisperModel + + from whisper_local.tray._theme import apply_system_theme + + q: queue.Queue[dict[str, Any] | None] = queue.Queue() + result: list[WhisperModel | None] = [None] + error: list[BaseException | None] = [None] + original_tqdm = tqdm_module.tqdm + + def worker() -> None: + TkProgressTqdm._queue = q + tqdm_module.tqdm = TkProgressTqdm + try: + result[0] = WhisperModel( + model_name, + compute_type=compute_type, + download_root=download_root, + ) + except Exception as exc: + error[0] = exc + finally: + tqdm_module.tqdm = original_tqdm + TkProgressTqdm._queue = None + q.put(None) # Sentinel: signalisiert Fertigstellung + + thread = threading.Thread(target=worker, daemon=True) + thread.start() + + # --- tkinter-Dialog (lazy: erscheint nur bei echtem Download) --- + root = tk.Tk() + root.withdraw() # zunächst versteckt + root.title("whisper-local – Modell wird geladen") + root.resizable(False, False) + apply_system_theme(root) + + frame = ttk.Frame(root, padding=16) + frame.pack(fill=tk.BOTH, expand=True) + + ttk.Label(frame, text=f"Lade Whisper-Modell '{model_name}'...").pack(anchor=tk.W) + + file_var = tk.StringVar(value="") + ttk.Label(frame, textvariable=file_var, foreground="gray").pack(anchor=tk.W, pady=(4, 8)) + + progress_var = tk.DoubleVar(value=0.0) + ttk.Progressbar( + frame, variable=progress_var, maximum=100, length=320, mode="determinate" + ).pack(fill=tk.X) + + pct_var = tk.StringVar(value="0 %") + ttk.Label(frame, textvariable=pct_var).pack(anchor=tk.E, pady=(2, 0)) + + def poll() -> None: + try: + while True: + msg = q.get_nowait() + if msg is None: # Sentinel → fertig + root.quit() + return + # Erste echte Meldung → Dialog anzeigen + if not root.winfo_viewable(): + root.deiconify() + # UI aktualisieren + file_var.set(msg["file"]) + if msg["total"] > 0: + pct = 100.0 * msg["n"] / msg["total"] + progress_var.set(pct) + pct_var.set(f"{pct:.0f} %") + except queue.Empty: + pass + root.after(50, poll) + + root.after(50, poll) + root.mainloop() + root.destroy() + + if error[0] is not None: + messagebox.showerror("Fehler beim Modell-Download", str(error[0])) + sys.exit(1) + + return result[0] +``` + +- [ ] **Schritt 2: Vorhandene Tests noch laufen lassen** + +``` +uv run pytest tests/test_download_progress.py tests/test_transcriber.py -v +``` + +Erwartete Ausgabe: alle Tests `PASSED` + +- [ ] **Schritt 3: Commit** + +```bash +git add whisper_local/tray/_download_progress.py +git commit -m "feat: load_model_with_progress mit tkinter-Fortschrittsdialog" +``` + +--- + +## Task 4: `App.__init__` in `__main__.py` anpassen + +**Files:** +- Modify: `whisper_local/__main__.py` + +- [ ] **Schritt 1: Plattform-Guard und Modell-Vorladen einfügen** + +In `whisper_local/__main__.py` die `App.__init__`-Methode anpassen. + +Vorher (Zeile 33–37): + +```python +self.transcriber = Transcriber( + model_name=config.whisper_model, + compute_type=config.compute_type, + language=config.language, +) +``` + +Nachher — vollständiger Ersatz des Blocks: + +```python +if sys.platform == "win32": + from whisper_local.tray._download_progress import load_model_with_progress + from whisper_local.transcriber import _model_cache_dir + _preloaded_model = load_model_with_progress( + model_name=config.whisper_model, + compute_type=config.compute_type, + download_root=_model_cache_dir(), + ) +else: + _preloaded_model = None + +self.transcriber = Transcriber( + model_name=config.whisper_model, + compute_type=config.compute_type, + language=config.language, + model=_preloaded_model, +) +``` + +- [ ] **Schritt 2: Alle Tests ausführen** + +``` +uv run pytest -v +``` + +Erwartete Ausgabe: alle Tests `PASSED` + +- [ ] **Schritt 3: Commit** + +```bash +git add whisper_local/__main__.py +git commit -m "feat: App lädt Whisper-Modell auf Windows mit Fortschrittsdialog" +``` + +--- + +## Task 5: Manueller Test + +*Dieser Schritt kann nicht automatisiert werden, da er eine laufende GUI erfordert.* + +- [ ] **Schritt 1: Modell-Cache temporär umbenennen (Download erzwingen)** + +Finde den Cache-Pfad (Standard: `%APPDATA%\whisper-local\models\` oder HuggingFace-Cache): + +```bash +# Zeigt den Cache-Pfad +uv run python -c "from whisper_local.transcriber import _model_cache_dir; print(_model_cache_dir())" +``` + +Benenne den Modell-Ordner um (z.B. `models` → `models_bak`), damit beim Start ein Download ausgelöst wird. + +- [ ] **Schritt 2: App starten und Dialog beobachten** + +``` +uv run whisper-local +``` + +Erwartet: +- Kleines Fenster erscheint mit Label `"Lade Whisper-Modell 'small'..."` +- Dateiname wechselt während des Downloads (z.B. `model.bin`, `tokenizer.json`) +- Progressbar füllt sich von 0 % auf 100 % +- Dialog schließt sich automatisch, Tray-Icon erscheint + +- [ ] **Schritt 3: Neustart mit gecachtem Modell** + +``` +uv run whisper-local +``` + +Erwartet: **kein Dialog** erscheint — App startet direkt mit Tray-Icon. + +- [ ] **Schritt 4: Cache wiederherstellen** + +Benenne `models_bak` zurück zu `models`.