feat(tray): Modell-Lade-Wartebalken plattformübergreifend anzeigen

Entfernt den Windows-only-Guard in App.__init__, damit der Dialog mit
indeterminatem ttk.Progressbar auch unter Linux erscheint, wenn das Laden
länger als 500 ms dauert. Ersetzt das literale \u2026 im Label durch das
Zeichen … und passt Spec/Plan an den tatsächlichen Umsetzungsstand an
(Timeout-basierter Wartebalken statt tqdm-Monkey-Patch, da die Xet-Engine
Python-tqdm bypasst).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 20:56:34 +02:00
parent 16fccffa97
commit bead04ff09
4 changed files with 151 additions and 388 deletions
@@ -1,97 +1,105 @@
# Design: Model-Download-Fortschrittsdialog
# Design: Modell-Lade-Wartebalken
**Datum:** 2026-04-12
**Status:** Genehmigt
**Datum:** 2026-04-12
**Zuletzt geändert:** 2026-04-16
**Status:** Umgesetzt
## Problemstellung
Beim ersten Start lädt `faster_whisper` das Whisper-Modell von HuggingFace herunter (z.B. `small` ≈ 460 MB). Dieser Vorgang blockiert `App.__init__()` ohne jede Rückmeldung. Der Nutzer sieht einen eingefrorenen Start ohne Fortschrittsanzeige.
Beim ersten Start lädt `faster_whisper` das Whisper-Modell von HuggingFace herunter (z.B. `small` ≈ 460 MB). Dieser Vorgang blockiert `App.__init__()` ohne jede Rückmeldung. Der Nutzer sieht einen eingefrorenen Start ohne Statusanzeige.
## Ziel
Ein kleiner tkinter-Dialog erscheint automatisch während des Downloads und zeigt den echten Byte-Fortschritt an. Ist das Modell bereits gecacht, erscheint kein Dialog.
Ein kleiner tkinter-Dialog erscheint automatisch, wenn das Laden des Modells länger als 500 ms dauert, und zeigt einen animierten Wartebalken. Ist das Modell bereits vollständig gecacht und wird in unter 500 ms bereit, erscheint kein Dialog.
## Ansatz: tqdm-Monkey-Patch
## Verworfener Ansatz: tqdm-Monkey-Patch
`faster_whisper` nutzt intern `tqdm` für Download-Fortschritt. Wir ersetzen `tqdm.tqdm` kurzzeitig mit einer eigenen Klasse (`TkProgressTqdm`), die Fortschrittsdaten thread-safe in eine Queue schreibt. Der Hauptthread liest die Queue und aktualisiert den tkinter-Dialog.
Die ursprüngliche Idee war, `tqdm.tqdm` während des Downloads mit einer eigenen Klasse (`TkProgressTqdm`) zu ersetzen und echten Byte-Fortschritt anzuzeigen. **Dieser Ansatz funktioniert nicht mehr**, weil `huggingface_hub` für große Dateien (`model.bin`) die **Xet-Engine (Rust)** nutzt, die Python-`tqdm` komplett bypasst. Der Dialog blieb dadurch leer, bis der Download fertig war.
Die Klasse `TkProgressTqdm` existiert noch im Code (mit Unit-Tests), wird aber von der Lade-Funktion nicht mehr aktiviert — sie bleibt als möglicher Fallback erhalten, falls zukünftige `faster_whisper`-Versionen wieder durch Python-tqdm laufen.
## Aktueller Ansatz: Timeout-basierter Wartebalken
1. Worker-Thread startet `WhisperModel(...)`.
2. Hauptthread wartet via `threading.Event.wait(timeout=0.5)`.
3. Ist der Thread innerhalb von 500 ms fertig (vollständiger Cache) → kein Dialog, direkter Rückweg.
4. Sonst → tkinter-Dialog mit **indeterminatem** `ttk.Progressbar` öffnen, bis der Worker signalisiert.
## Architektur & Ablauf
```
main()
└─ App.__init__()
├─ [Windows] load_model_with_progress(model_name, compute_type, download_root)
│ ├─ [Daemon-Thread] WhisperModel(...) mit gepatchtem tqdm
│ └─ [Hauptthread] tkinter-Dialog pollt Queue via root.after(50, poll)
Sentinel in Queue → Dialog schließt sich, WhisperModel zurückgegeben
├─ load_model_with_progress(model_name, compute_type, download_root)
│ ├─ [Daemon-Thread] WhisperModel(...)
│ └─ [Hauptthread] done_event.wait(0.5)
sofort fertig → Modell zurückgeben (kein Dialog)
│ └─ Timeout → tkinter-Fenster + Indeterminate-Progressbar
│ └─ root.after(100, poll) bis done_event.is_set()
└─ Transcriber(model=fertig_geladenes_modell)
```
Auf Linux lädt `Transcriber` das Modell direkt wie bisher (kein Dialog).
Dialog läuft plattformübergreifend (Linux + Windows) — tkinter gehört zur Standardbibliothek.
## Komponenten
### Neue Datei: `whisper_local/tray/_download_progress.py`
### Datei: `whisper_local/tray/_download_progress.py`
**`TkProgressTqdm`** — ersetzt `tqdm.tqdm` während des Downloads:
- Erbt von `tqdm.tqdm` (damit `faster_whisper` keine TypeError bekommt)
- Überschreibt `update(n)` und schreibt `{"file": desc, "n": n, "total": total}` in eine `queue.Queue`
- Thread-safe, kein direkter tkinter-Zugriff aus dem Thread
**`TkProgressTqdm`** — ungenutzter `tqdm.tqdm`-Ersatz (historischer Fallback):
- Erbt von `tqdm.tqdm`
- Akkumuliert Fortschritt und schreibt Messages in `TkProgressTqdm._queue`, falls gesetzt
- Wird derzeit nicht aktiviert (Xet bypasst tqdm)
**`load_model_with_progress(model_name, compute_type, download_root) -> WhisperModel`** — öffentliche Funktion:
1. Startet Daemon-Thread mit `WhisperModel(...)`
2. Wartet 500 ms via `done_event.wait(timeout=0.5)`
3. Kehrt bei schnellem Abschluss direkt zurück
4. Öffnet sonst tkinter-Fenster mit indeterminatem `ttk.Progressbar`
5. Pollt `done_event` alle 100 ms via `root.after` und beendet `mainloop()` bei Signal
6. Bei Exception im Worker: `messagebox.showerror` + `sys.exit(1)`
### Datei: `whisper_local/__main__.py`
`App.__init__()` ruft `load_model_with_progress()` auf (plattformunabhängig) und übergibt das fertige `WhisperModel`-Objekt an `Transcriber`.
### Datei: `whisper_local/transcriber.py`
`Transcriber.__init__()` akzeptiert optional ein bereits geladenes `WhisperModel`-Objekt via Parameter `model: WhisperModel | None = None`.
## Dialog-Darstellung
**`DownloadProgressDialog`** — tkinter-Fenster:
- Titel: `"whisper-local Modell wird geladen"`
- Label oben: `"Lade Whisper-Modell '<model_name>'..."`
- Label Mitte: aktueller Dateiname (z.B. `model.bin`)
- `ttk.Progressbar` im `determinate`-Modus (0100 %)
- Prozentzahl als Text daneben
- Label darunter: `"Bitte warten…"` (grau)
- `ttk.Progressbar` im `indeterminate`-Modus (Länge 320 px, via `pb.start(10)` animiert)
- Kein Abbrechen-Button
- `apply_system_theme(root)` für konsistentes Aussehen
- `root.resizable(False, False)`
**`load_model_with_progress(model_name, compute_type, download_root) -> WhisperModel`** — öffentliche Funktion:
1. Erstellt `queue.Queue` und startet Daemon-Thread mit `WhisperModel(...)`
2. Öffnet tkinter-Fenster erst wenn erste tqdm-Meldung ankommt (kein leerer Dialog bei gecachtem Modell)
3. Pollt Queue via `root.after(50, ...)` und aktualisiert Dialog
4. Bei Sentinel (`None` in Queue): Dialog schließen, `WhisperModel` zurückgeben
5. Bei Exception in Queue: `messagebox.showerror` + `sys.exit(1)`
### Geänderte Datei: `whisper_local/__main__.py`
`App.__init__()` ruft auf Windows `load_model_with_progress()` auf und übergibt das fertige `WhisperModel`-Objekt an `Transcriber`.
### Geänderte Datei: `whisper_local/transcriber.py`
`Transcriber.__init__()` akzeptiert optional ein bereits geladenes `WhisperModel`-Objekt via Parameter `model: WhisperModel | None = None`. Falls übergeben, wird es direkt verwendet statt eines neuen Downloads.
## Threading-Modell
| Thread | Aufgabe |
|--------|---------|
| Hauptthread | tkinter `mainloop()`, Queue-Polling via `root.after` |
| Daemon-Thread | `WhisperModel(...)` mit gepatchtem tqdm |
| Hauptthread | `done_event.wait(0.5)`, tkinter `mainloop()`, Polling via `root.after` |
| Daemon-Thread | `WhisperModel(...)` |
Der tqdm-Patch ist lokal: vor dem Thread-Start wird `tqdm.tqdm` ersetzt, nach Thread-Ende wiederhergestellt (auch bei Fehler, via `try/finally`).
Kommunikation über `threading.Event` (kein Monkey-Patch, keine Queue in der Lade-Funktion).
## Fehlerbehandlung
- **Kein Internet / Download-Fehler:** Exception im Thread → via Queue in Hauptthread → `messagebox.showerror("Fehler beim Modell-Download", str(exc))``sys.exit(1)`
- **Modell bereits gecacht:** tqdm wird nie aufgerufen → kein Dialog erscheint → `load_model_with_progress` kehrt sofort zurück
- **Kein Internet / Download-Fehler:** Exception im Thread → nach `done_event.set()` im Hauptthread geprüft `messagebox.showerror("Fehler beim Modell-Laden", str(exc))``sys.exit(1)`
- **Modell bereits gecacht und schnell bereit:** `done_event.wait(0.5)` returnt `True` → kein Dialog erscheint
## Plattform-Abgrenzung
## Plattform
```python
# whisper_local/__main__.py
if sys.platform == "win32":
from whisper_local.tray._download_progress import load_model_with_progress
model = load_model_with_progress(...)
else:
model = None # Transcriber lädt selbst
```
Kein Plattform-Guard — `load_model_with_progress()` läuft auf Linux und Windows identisch. tkinter ist Teil der Python-Standardbibliothek und auf beiden Systemen verfügbar.
## Dateien
| Datei | Änderungstyp |
|-------|-------------|
| `whisper_local/tray/_download_progress.py` | Neu |
| `whisper_local/__main__.py` | Geändert (App.__init__) |
| `whisper_local/transcriber.py` | Geändert (optionaler model-Parameter) |
| Datei | Status |
|-------|--------|
| `whisper_local/tray/_download_progress.py` | Umgesetzt (Timeout-basierter Wartebalken) |
| `whisper_local/__main__.py` | Umgesetzt (Preload plattformübergreifend) |
| `whisper_local/transcriber.py` | Umgesetzt (optionaler `model`-Parameter) |
| `tests/test_download_progress.py` | Vorhanden (deckt ungenutzte `TkProgressTqdm` ab) |
| `tests/test_transcriber.py` | Vorhanden (deckt `model`-Parameter ab) |