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:
@@ -1,235 +1,67 @@
|
|||||||
# Model-Download-Fortschrittsdialog — Implementierungsplan
|
# Modell-Lade-Wartebalken — 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.
|
**Status:** Umgesetzt (Stand 2026-04-16)
|
||||||
|
**Zuletzt geändert:** 2026-04-16
|
||||||
|
|
||||||
**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.
|
**Goal:** Beim ersten Start zeigt die App einen tkinter-Dialog mit indeterminatem Wartebalken, der erscheint, wenn das Laden des Whisper-Modells länger als 500 ms dauert. Bei schnell bereitem 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.
|
**Architecture:** Ein Daemon-Thread lädt `WhisperModel`. Der Hauptthread wartet 500 ms via `threading.Event`; erst bei Timeout öffnet er einen tkinter-Dialog mit indeterminatem `ttk.Progressbar`, der via `root.after(100, poll)` auf `done_event.is_set()` pollt. Ein früher geplanter tqdm-Monkey-Patch (`TkProgressTqdm`) wurde verworfen, weil `huggingface_hub` für `model.bin` die Xet-Rust-Engine nutzt, die Python-tqdm bypasst.
|
||||||
|
|
||||||
**Tech Stack:** Python 3.13+, tkinter, `tqdm`, `faster_whisper`, `threading`, `queue`
|
**Tech Stack:** Python 3.13+, tkinter, `faster_whisper`, `threading`, `queue` (historisch), `tqdm` (historisch)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dateien
|
## Dateien
|
||||||
|
|
||||||
| Datei | Änderungstyp | Verantwortlichkeit |
|
| Datei | Status | Verantwortlichkeit |
|
||||||
|-------|-------------|-------------------|
|
|-------|--------|-------------------|
|
||||||
| `whisper_local/tray/_download_progress.py` | Neu | `TkProgressTqdm`, `DownloadProgressDialog`-Logik, `load_model_with_progress()` |
|
| `whisper_local/tray/_download_progress.py` | Umgesetzt | `TkProgressTqdm` (Fallback, ungenutzt), `load_model_with_progress()` |
|
||||||
| `whisper_local/transcriber.py` | Geändert | Optionaler `model`-Parameter in `Transcriber.__init__` |
|
| `whisper_local/transcriber.py` | Umgesetzt | Optionaler `model`-Parameter in `Transcriber.__init__` |
|
||||||
| `whisper_local/__main__.py` | Geändert | Plattform-Guard + Aufruf von `load_model_with_progress` |
|
| `whisper_local/__main__.py` | Umgesetzt | Aufruf von `load_model_with_progress` (plattformübergreifend) |
|
||||||
| `tests/test_download_progress.py` | Neu | Unit-Tests für `TkProgressTqdm` |
|
| `tests/test_download_progress.py` | Umgesetzt | Unit-Tests für `TkProgressTqdm` |
|
||||||
| `tests/test_transcriber.py` | Geändert | Test für neuen `model`-Parameter + Signatur-Korrektur |
|
| `tests/test_transcriber.py` | Umgesetzt | Test für neuen `model`-Parameter + Signatur-Korrektur |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 1: `Transcriber` um optionalen `model`-Parameter erweitern
|
## Historie der Umsetzung
|
||||||
|
|
||||||
**Files:**
|
Die Implementierung erfolgte in zwei Phasen:
|
||||||
- Modify: `whisper_local/transcriber.py`
|
|
||||||
- Modify: `tests/test_transcriber.py`
|
|
||||||
|
|
||||||
- [ ] **Schritt 1: Failing-Test für neuen `model`-Parameter schreiben**
|
1. **Initialer Plan (April 2026):** tqdm-Monkey-Patch mit determinater Progressbar, Dateiname- und Prozentanzeige (Commits `e92f5f5`, `3067499`, `44c8d8e`, `c26dfa3`, `3d9f95b`, `e31230f`).
|
||||||
|
2. **Korrektur (Commit `753dbc5`):** Monkey-Patch verworfen, da Xet-Engine tqdm bypasst. Ersetzt durch Timeout-basierten indeterminaten Wartebalken.
|
||||||
|
|
||||||
In `tests/test_transcriber.py` folgenden Test ergänzen:
|
Die Tasks unten beschreiben den umgesetzten Endzustand.
|
||||||
|
|
||||||
```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
|
## Task 1: `Transcriber` um optionalen `model`-Parameter erweitern *(erledigt)*
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `whisper_local/tray/_download_progress.py`
|
- `whisper_local/transcriber.py`
|
||||||
- Create: `tests/test_download_progress.py`
|
- `tests/test_transcriber.py`
|
||||||
|
|
||||||
- [ ] **Schritt 1: Test-Datei mit Failing-Tests anlegen**
|
`Transcriber.__init__` akzeptiert `model: WhisperModel | None = None`. Wenn gesetzt, wird `WhisperModel` nicht selbst instanziiert. Test `test_init_with_preloaded_model` prüft diesen Pfad; `test_init_loads_model` wurde an die neue Signatur mit `download_root=None` angepasst.
|
||||||
|
|
||||||
`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
|
## Task 2: `TkProgressTqdm` implementieren und testen *(erledigt, aber ungenutzt)*
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `whisper_local/tray/_download_progress.py`
|
- `whisper_local/tray/_download_progress.py`
|
||||||
|
- `tests/test_download_progress.py`
|
||||||
|
|
||||||
- [ ] **Schritt 1: `load_model_with_progress` an `_download_progress.py` anhängen**
|
Klasse `TkProgressTqdm` erbt von `tqdm.tqdm`, akkumuliert Fortschritt in `_accumulated_n` und schreibt `{"file", "n", "total"}`-Dicts in `TkProgressTqdm._queue`, falls gesetzt. Unit-Tests decken Queue-Weiterleitung, Null-Queue-Fall und Akkumulation ab.
|
||||||
|
|
||||||
Folgenden Code nach der `TkProgressTqdm`-Klasse einfügen:
|
**Hinweis:** `load_model_with_progress` nutzt die Klasse nicht mehr (Xet-Bypass, siehe Historie). Die Klasse + Tests bleiben als dokumentierter Fallback erhalten — wird `faster_whisper` irgendwann wieder durch Python-tqdm laufen, kann sie reaktiviert werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `load_model_with_progress` implementieren *(erledigt, Ansatz geändert)*
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `whisper_local/tray/_download_progress.py`
|
||||||
|
|
||||||
|
Die finale Version nutzt **kein** tqdm-Patching. Stattdessen:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def load_model_with_progress(
|
def load_model_with_progress(
|
||||||
@@ -237,137 +69,85 @@ def load_model_with_progress(
|
|||||||
compute_type: str,
|
compute_type: str,
|
||||||
download_root: str | None,
|
download_root: str | None,
|
||||||
) -> Any:
|
) -> 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
|
import tkinter as tk
|
||||||
from tkinter import messagebox, ttk
|
from tkinter import messagebox, ttk
|
||||||
|
|
||||||
from faster_whisper import WhisperModel
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
from whisper_local.tray._theme import apply_system_theme
|
from whisper_local.tray._theme import apply_system_theme
|
||||||
|
|
||||||
q: queue.Queue[dict[str, Any] | None] = queue.Queue()
|
result: list[Any] = [None]
|
||||||
result: list[WhisperModel | None] = [None]
|
|
||||||
error: list[BaseException | None] = [None]
|
error: list[BaseException | None] = [None]
|
||||||
original_tqdm = tqdm_module.tqdm
|
done_event = threading.Event()
|
||||||
|
|
||||||
def worker() -> None:
|
def worker() -> None:
|
||||||
TkProgressTqdm._queue = q
|
|
||||||
tqdm_module.tqdm = TkProgressTqdm
|
|
||||||
try:
|
try:
|
||||||
result[0] = WhisperModel(
|
result[0] = WhisperModel(
|
||||||
model_name,
|
model_name, compute_type=compute_type, download_root=download_root
|
||||||
compute_type=compute_type,
|
|
||||||
download_root=download_root,
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error[0] = exc
|
error[0] = exc
|
||||||
finally:
|
finally:
|
||||||
tqdm_module.tqdm = original_tqdm
|
done_event.set()
|
||||||
TkProgressTqdm._queue = None
|
|
||||||
q.put(None) # Sentinel: signalisiert Fertigstellung
|
|
||||||
|
|
||||||
thread = threading.Thread(target=worker, daemon=True)
|
thread = threading.Thread(target=worker, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
# --- tkinter-Dialog (lazy: erscheint nur bei echtem Download) ---
|
# Kurz warten – schneller Cache-Hit überspringt Dialog
|
||||||
|
if done_event.wait(timeout=0.5):
|
||||||
|
if error[0] is not None:
|
||||||
|
root = tk.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
messagebox.showerror("Fehler beim Modell-Laden", str(error[0]))
|
||||||
|
root.destroy()
|
||||||
|
sys.exit(1)
|
||||||
|
return result[0]
|
||||||
|
|
||||||
|
# Dialog mit indeterminatem Wartebalken
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
root.withdraw() # zunächst versteckt
|
|
||||||
root.title("whisper-local – Modell wird geladen")
|
root.title("whisper-local – Modell wird geladen")
|
||||||
root.resizable(False, False)
|
root.resizable(False, False)
|
||||||
apply_system_theme(root)
|
apply_system_theme(root)
|
||||||
|
|
||||||
frame = ttk.Frame(root, padding=16)
|
frame = ttk.Frame(root, padding=16)
|
||||||
frame.pack(fill=tk.BOTH, expand=True)
|
frame.pack(fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
ttk.Label(frame, text=f"Lade Whisper-Modell '{model_name}'...").pack(anchor=tk.W)
|
ttk.Label(frame, text=f"Lade Whisper-Modell '{model_name}'...").pack(anchor=tk.W)
|
||||||
|
ttk.Label(frame, text="Bitte warten\u2026", foreground="gray").pack(
|
||||||
|
anchor=tk.W, pady=(4, 8)
|
||||||
|
)
|
||||||
|
|
||||||
file_var = tk.StringVar(value="")
|
pb = ttk.Progressbar(frame, length=320, mode="indeterminate")
|
||||||
ttk.Label(frame, textvariable=file_var, foreground="gray").pack(anchor=tk.W, pady=(4, 8))
|
pb.pack(fill=tk.X)
|
||||||
|
pb.start(10)
|
||||||
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:
|
def poll() -> None:
|
||||||
try:
|
if done_event.is_set():
|
||||||
while True:
|
|
||||||
msg = q.get_nowait()
|
|
||||||
if msg is None: # Sentinel → fertig
|
|
||||||
root.quit()
|
root.quit()
|
||||||
return
|
return
|
||||||
# Erste echte Meldung → Dialog anzeigen
|
root.after(100, poll)
|
||||||
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.after(100, poll)
|
||||||
root.mainloop()
|
root.mainloop()
|
||||||
root.destroy()
|
|
||||||
|
|
||||||
if error[0] is not None:
|
if error[0] is not None:
|
||||||
messagebox.showerror("Fehler beim Modell-Download", str(error[0]))
|
root.withdraw()
|
||||||
|
messagebox.showerror("Fehler beim Modell-Laden", str(error[0]))
|
||||||
|
root.destroy()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
root.destroy()
|
||||||
return result[0]
|
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
|
## Task 4: `App.__init__` in `__main__.py` anpassen *(erledigt)*
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `whisper_local/__main__.py`
|
- `whisper_local/__main__.py`
|
||||||
|
|
||||||
- [ ] **Schritt 1: Plattform-Guard und Modell-Vorladen einfügen**
|
Plattformübergreifend — der anfängliche `sys.platform == "win32"`-Guard wurde entfernt, weil der Dialog auch auf Linux gewünscht ist und tkinter zur Standardbibliothek gehört.
|
||||||
|
|
||||||
In `whisper_local/__main__.py` die `App.__init__`-Methode anpassen.
|
|
||||||
|
|
||||||
Vorher (Zeile 33–37):
|
|
||||||
|
|
||||||
```python
|
```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.tray._download_progress import load_model_with_progress
|
||||||
from whisper_local.transcriber import _model_cache_dir
|
from whisper_local.transcriber import _model_cache_dir
|
||||||
_preloaded_model = load_model_with_progress(
|
_preloaded_model = load_model_with_progress(
|
||||||
@@ -375,8 +155,6 @@ if sys.platform == "win32":
|
|||||||
compute_type=config.compute_type,
|
compute_type=config.compute_type,
|
||||||
download_root=_model_cache_dir(),
|
download_root=_model_cache_dir(),
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
_preloaded_model = None
|
|
||||||
|
|
||||||
self.transcriber = Transcriber(
|
self.transcriber = Transcriber(
|
||||||
model_name=config.whisper_model,
|
model_name=config.whisper_model,
|
||||||
@@ -386,37 +164,18 @@ self.transcriber = Transcriber(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **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
|
## Task 5: Manueller Test *(empfohlen nach Regressionen)*
|
||||||
|
|
||||||
*Dieser Schritt kann nicht automatisiert werden, da er eine laufende GUI erfordert.*
|
|
||||||
|
|
||||||
- [ ] **Schritt 1: Modell-Cache temporär umbenennen (Download erzwingen)**
|
- [ ] **Schritt 1: Modell-Cache temporär umbenennen (Download erzwingen)**
|
||||||
|
|
||||||
Finde den Cache-Pfad (Standard: `%APPDATA%\whisper-local\models\` oder HuggingFace-Cache):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Zeigt den Cache-Pfad
|
# Zeigt den Cache-Pfad
|
||||||
uv run python -c "from whisper_local.transcriber import _model_cache_dir; print(_model_cache_dir())"
|
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.
|
Modell-Ordner umbenennen (`models` → `models_bak`).
|
||||||
|
|
||||||
- [ ] **Schritt 2: App starten und Dialog beobachten**
|
- [ ] **Schritt 2: App starten und Dialog beobachten**
|
||||||
|
|
||||||
@@ -425,10 +184,9 @@ uv run whisper-local
|
|||||||
```
|
```
|
||||||
|
|
||||||
Erwartet:
|
Erwartet:
|
||||||
- Kleines Fenster erscheint mit Label `"Lade Whisper-Modell 'small'..."`
|
- Fenster mit Label `"Lade Whisper-Modell 'small'..."` + `"Bitte warten…"` erscheint nach ca. 500 ms
|
||||||
- Dateiname wechselt während des Downloads (z.B. `model.bin`, `tokenizer.json`)
|
- Indeterminater Wartebalken animiert durchlaufend
|
||||||
- Progressbar füllt sich von 0 % auf 100 %
|
- Dialog schließt automatisch nach Abschluss des Downloads, Tray-Icon erscheint
|
||||||
- Dialog schließt sich automatisch, Tray-Icon erscheint
|
|
||||||
|
|
||||||
- [ ] **Schritt 3: Neustart mit gecachtem Modell**
|
- [ ] **Schritt 3: Neustart mit gecachtem Modell**
|
||||||
|
|
||||||
@@ -436,8 +194,8 @@ Erwartet:
|
|||||||
uv run whisper-local
|
uv run whisper-local
|
||||||
```
|
```
|
||||||
|
|
||||||
Erwartet: **kein Dialog** erscheint — App startet direkt mit Tray-Icon.
|
Erwartet: **kein Dialog** erscheint — App startet direkt mit Tray-Icon (Modell < 500 ms bereit).
|
||||||
|
|
||||||
- [ ] **Schritt 4: Cache wiederherstellen**
|
- [ ] **Schritt 4: Cache wiederherstellen**
|
||||||
|
|
||||||
Benenne `models_bak` zurück zu `models`.
|
`models_bak` zurück zu `models` benennen.
|
||||||
|
|||||||
@@ -1,97 +1,105 @@
|
|||||||
# Design: Model-Download-Fortschrittsdialog
|
# Design: Modell-Lade-Wartebalken
|
||||||
|
|
||||||
**Datum:** 2026-04-12
|
**Datum:** 2026-04-12
|
||||||
**Status:** Genehmigt
|
**Zuletzt geändert:** 2026-04-16
|
||||||
|
**Status:** Umgesetzt
|
||||||
|
|
||||||
## Problemstellung
|
## 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
|
## 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
|
## Architektur & Ablauf
|
||||||
|
|
||||||
```
|
```
|
||||||
main()
|
main()
|
||||||
└─ App.__init__()
|
└─ App.__init__()
|
||||||
├─ [Windows] load_model_with_progress(model_name, compute_type, download_root)
|
├─ load_model_with_progress(model_name, compute_type, download_root)
|
||||||
│ ├─ [Daemon-Thread] WhisperModel(...) mit gepatchtem tqdm
|
│ ├─ [Daemon-Thread] WhisperModel(...)
|
||||||
│ └─ [Hauptthread] tkinter-Dialog pollt Queue via root.after(50, poll)
|
│ └─ [Hauptthread] done_event.wait(0.5)
|
||||||
│ └─ Sentinel in Queue → Dialog schließt sich, WhisperModel zurückgegeben
|
│ ├─ 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)
|
└─ 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
|
## Komponenten
|
||||||
|
|
||||||
### Neue Datei: `whisper_local/tray/_download_progress.py`
|
### Datei: `whisper_local/tray/_download_progress.py`
|
||||||
|
|
||||||
**`TkProgressTqdm`** — ersetzt `tqdm.tqdm` während des Downloads:
|
**`TkProgressTqdm`** — ungenutzter `tqdm.tqdm`-Ersatz (historischer Fallback):
|
||||||
- Erbt von `tqdm.tqdm` (damit `faster_whisper` keine TypeError bekommt)
|
- Erbt von `tqdm.tqdm`
|
||||||
- Überschreibt `update(n)` und schreibt `{"file": desc, "n": n, "total": total}` in eine `queue.Queue`
|
- Akkumuliert Fortschritt und schreibt Messages in `TkProgressTqdm._queue`, falls gesetzt
|
||||||
- Thread-safe, kein direkter tkinter-Zugriff aus dem Thread
|
- 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"`
|
- Titel: `"whisper-local – Modell wird geladen"`
|
||||||
- Label oben: `"Lade Whisper-Modell '<model_name>'..."`
|
- Label oben: `"Lade Whisper-Modell '<model_name>'..."`
|
||||||
- Label Mitte: aktueller Dateiname (z.B. `model.bin`)
|
- Label darunter: `"Bitte warten…"` (grau)
|
||||||
- `ttk.Progressbar` im `determinate`-Modus (0–100 %)
|
- `ttk.Progressbar` im `indeterminate`-Modus (Länge 320 px, via `pb.start(10)` animiert)
|
||||||
- Prozentzahl als Text daneben
|
|
||||||
- Kein Abbrechen-Button
|
- Kein Abbrechen-Button
|
||||||
- `apply_system_theme(root)` für konsistentes Aussehen
|
- `apply_system_theme(root)` für konsistentes Aussehen
|
||||||
- `root.resizable(False, False)`
|
- `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
|
## Threading-Modell
|
||||||
|
|
||||||
| Thread | Aufgabe |
|
| Thread | Aufgabe |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| Hauptthread | tkinter `mainloop()`, Queue-Polling via `root.after` |
|
| Hauptthread | `done_event.wait(0.5)`, tkinter `mainloop()`, Polling via `root.after` |
|
||||||
| Daemon-Thread | `WhisperModel(...)` mit gepatchtem tqdm |
|
| 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
|
## Fehlerbehandlung
|
||||||
|
|
||||||
- **Kein Internet / Download-Fehler:** Exception im Thread → via Queue in Hauptthread → `messagebox.showerror("Fehler beim Modell-Download", str(exc))` → `sys.exit(1)`
|
- **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:** tqdm wird nie aufgerufen → kein Dialog erscheint → `load_model_with_progress` kehrt sofort zurück
|
- **Modell bereits gecacht und schnell bereit:** `done_event.wait(0.5)` returnt `True` → kein Dialog erscheint
|
||||||
|
|
||||||
## Plattform-Abgrenzung
|
## Plattform
|
||||||
|
|
||||||
```python
|
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.
|
||||||
# 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dateien
|
## Dateien
|
||||||
|
|
||||||
| Datei | Änderungstyp |
|
| Datei | Status |
|
||||||
|-------|-------------|
|
|-------|--------|
|
||||||
| `whisper_local/tray/_download_progress.py` | Neu |
|
| `whisper_local/tray/_download_progress.py` | Umgesetzt (Timeout-basierter Wartebalken) |
|
||||||
| `whisper_local/__main__.py` | Geändert (App.__init__) |
|
| `whisper_local/__main__.py` | Umgesetzt (Preload plattformübergreifend) |
|
||||||
| `whisper_local/transcriber.py` | Geändert (optionaler model-Parameter) |
|
| `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) |
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ class App:
|
|||||||
min_duration=config.min_duration,
|
min_duration=config.min_duration,
|
||||||
device=config.microphone or None,
|
device=config.microphone or None,
|
||||||
)
|
)
|
||||||
if sys.platform == "win32":
|
|
||||||
from whisper_local.tray._download_progress import load_model_with_progress
|
from whisper_local.tray._download_progress import load_model_with_progress
|
||||||
from whisper_local.transcriber import _model_cache_dir
|
from whisper_local.transcriber import _model_cache_dir
|
||||||
_preloaded_model = load_model_with_progress(
|
_preloaded_model = load_model_with_progress(
|
||||||
@@ -39,8 +38,6 @@ class App:
|
|||||||
compute_type=config.compute_type,
|
compute_type=config.compute_type,
|
||||||
download_root=_model_cache_dir(),
|
download_root=_model_cache_dir(),
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
_preloaded_model = None
|
|
||||||
|
|
||||||
self.transcriber = Transcriber(
|
self.transcriber = Transcriber(
|
||||||
model_name=config.whisper_model,
|
model_name=config.whisper_model,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ def load_model_with_progress(
|
|||||||
frame.pack(fill=tk.BOTH, expand=True)
|
frame.pack(fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
ttk.Label(frame, text=f"Lade Whisper-Modell '{model_name}'...").pack(anchor=tk.W)
|
ttk.Label(frame, text=f"Lade Whisper-Modell '{model_name}'...").pack(anchor=tk.W)
|
||||||
ttk.Label(frame, text="Bitte warten\u2026", foreground="gray").pack(
|
ttk.Label(frame, text="Bitte warten…", foreground="gray").pack(
|
||||||
anchor=tk.W, pady=(4, 8)
|
anchor=tk.W, pady=(4, 8)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user