Files
whisper-local/docs/superpowers/plans/2026-04-12-model-download-progress-dialog.md
T
2026-04-12 12:24:34 +02:00

444 lines
13 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.
# 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 3337):
```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`.