kimal3 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ # Werkzeug hinter `kimal3 zeigen`: spricht mit dem Aufnehmer (openadapt-capture 1.3.0) ueber dessen Python-Funktionen,
2
+ # nie ueber dessen Befehlszeile (Python Fire zerlegt dort Werte, Spec US-4). Laeuft mit dem Python des Aufnehmers.
3
+ #
4
+ # werkzeug.py starten <konfig.json> Starter mit Bremse (von Node mit pythonw abgehaengt gestartet, keine Ausgabe)
5
+ # werkzeug.py stoppen <ordner> -> eine JSON-Zeile: Ergebnis und Zahlen
6
+ # werkzeug.py zustand <ordner|-> -> eine JSON-Zeile: aktive Sitzungen des Aufnehmers, Bericht des Starters
7
+ # werkzeug.py teile -> eine JSON-Zeile: was eingerichtet ist
8
+ # werkzeug.py zeitleiste <ordner> <de|en> [--ohne-getipptes] -> schreibt zeitleiste.md, eine JSON-Zeile mit Zahlen
9
+ # werkzeug.py bild <ordner> <sekunden> -> bilder/<m-ss>.png, eine JSON-Zeile
10
+ # werkzeug.py modell-laden -> eine JSON-Zeile (laedt large-v3-turbo, Netz)
11
+ # Die drei letzten wohnen in zeitleiste.py (Ruling R-6); hier steht nur der Aufruf.
12
+ # Der Starter wohnt in starter.py (Ruling R-12), was beide brauchen in gemeinsam.py; Einstieg bleibt diese Datei.
13
+ #
14
+ # 🔴 Keine Ausgabe enthaelt Inhalte aus der Aufnahme: Das Protokoll des Aufnehmers traegt Fenstertitel. Es wird nur nach
15
+ # festen Mustern durchsucht (fehler_grund), nie zurueckgegeben -- auch nicht auf stderr, auch nicht im Fehlerfall.
16
+ # Gedeckt von `npm run check:zeigen-python` (scripts/zeigen/test_werkzeug.py). Spec 2026-10-18-k3zeigen-design.md.
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sqlite3
21
+ import sys
22
+ import time
23
+
24
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # Nachbardateien auch unter -I/-P, die den Ordner weglassen
25
+ sys.dont_write_bytecode = True # kein __pycache__ neben den Dateien: der Ordner geht so, wie er ist, ins npm-Paket
26
+ from gemeinsam import (ABBRECHEN, ROH, STARTER, UMGEBUNG, ZUSTAND_AUFNEHMER, _stoppe_sitzung, # noqa: E402
27
+ fehler_grund, lies_json, lies_protokoll, sauber)
28
+ from starter import starte # noqa: E402
29
+
30
+ WARTEN_SEK = 15.0 # so lange wartet `stoppen` nach dem Stopp auf einen Starter ohne frischen Herzschlag
31
+ # Claude Code bricht einen Werkzeugaufruf nach 10 Minuten ab -- dann kaeme gar kein Satz. Deshalb endet `stoppen`
32
+ # spaetestens nach STOPP_SEK + WARTEN_SEK (495 s) mit einer Antwort; schliesst der Aufnehmer dann noch ab, heisst sie
33
+ # "wird-abgeschlossen". Node gibt dem Aufruf STOPPEN_MS (zeigen-umgebung.ts) als letztes Netz.
34
+ STOPP_SEK = 480.0
35
+ LEBENSZEICHEN_SEK = 10 # Zwilling von LEBENSZEICHEN_SEK in zeigen-rechnung.ts: so alt darf `zuletzt` hoechstens sein
36
+ MODELL_REPO = "mobiuslabsgmbh/faster-whisper-large-v3-turbo" # faster_whisper.utils._MODELS["large-v3-turbo"]
37
+
38
+
39
+ def aufnahme_zahlen(ordner: str, ende: float | None = None) -> dict | None:
40
+ """Zahlen aus recording.db; None, wenn sie nicht lesbar ist. `ende` = Moment der Bremse (sonst letztes Ereignis)."""
41
+ pfad = os.path.join(ordner, ROH, "recording.db")
42
+ if not os.path.isfile(pfad):
43
+ return None
44
+ try:
45
+ db = sqlite3.connect(f"file:{pfad}?mode=ro", uri=True)
46
+ try:
47
+ start = float(db.execute("select min(timestamp) from recording").fetchone()[0])
48
+ klicks = db.execute("select count(*) from action_event where name = 'click'").fetchone()[0]
49
+ tasten = db.execute("select count(*) from action_event where name = 'press'").fetchone()[0]
50
+ ton = db.execute("select count(*) from audio_info where length(flac_data) > 0").fetchone()[0] > 0
51
+ letzte = db.execute("select max(timestamp) from action_event").fetchone()[0]
52
+ finally:
53
+ db.close()
54
+ except (sqlite3.Error, TypeError, ValueError):
55
+ return None
56
+ if ende is None and letzte is not None:
57
+ ende = float(letzte)
58
+ if ende is None:
59
+ ende = (lies_json(os.path.join(ordner, ROH, ZUSTAND_AUFNEHMER)) or {}).get("finalized_at")
60
+ dauer = max(0.0, float(ende) - start) if isinstance(ende, (int, float)) else 0.0
61
+ return {"dauer_sek": round(dauer, 2), "klicks": klicks, "tasten": tasten, "ton": ton}
62
+
63
+
64
+ # --- Stoppen, Zustand, Teile -----------------------------------------------------------------------------------
65
+
66
+ def _frisch(bericht: dict) -> bool:
67
+ zuletzt = bericht.get("zuletzt")
68
+ return isinstance(zuletzt, (int, float)) and time.time() - zuletzt <= LEBENSZEICHEN_SEK
69
+
70
+
71
+ def _warte_auf_starter(ordner: str, solange: str, bis: float) -> dict:
72
+ """Wartet, solange der Starter `solange` meldet und lebt -- hoechstens bis `bis`. Ein Starter ohne frischen
73
+ Herzschlag schreibt nie mehr etwas und bekommt nur WARTEN_SEK; einer, der `nicht-beendet` meldet, gar keine."""
74
+ gnade = time.time() + WARTEN_SEK
75
+ bericht = lies_json(os.path.join(ordner, STARTER)) or {}
76
+ while (bericht.get("zustand") == solange and bericht.get("grund") != "nicht-beendet"
77
+ and time.time() < (bis if _frisch(bericht) else min(bis, gnade))):
78
+ time.sleep(0.25)
79
+ bericht = lies_json(os.path.join(ordner, STARTER)) or bericht
80
+ return bericht
81
+
82
+
83
+ def stoppen(ordner: str) -> dict:
84
+ def antwort(ergebnis, grund=None, zahlen=None):
85
+ z = zahlen or {"dauer_sek": 0.0, "klicks": 0, "tasten": 0, "ton": False}
86
+ return {"ergebnis": ergebnis, **z, "grund": grund}
87
+
88
+ bis = time.time() + STOPP_SEK + WARTEN_SEK
89
+ bericht = lies_json(os.path.join(ordner, STARTER))
90
+ if bericht is None:
91
+ return antwort("keine")
92
+ if bericht.get("zustand") == "startet": # noch keine Sitzung, die man stoppen koennte: Bremse ziehen
93
+ open(os.path.join(ordner, ABBRECHEN), "w").close()
94
+ bericht = _warte_auf_starter(ordner, "startet", bis)
95
+ if bericht.get("zustand") == "laeuft":
96
+ # Nach der Frist stoppt ein lebender Starter schon selbst (die Bremse): dann nur auf ihn warten. Ein zweiter
97
+ # Stopp fuehrte zum selben Abschluss, liefe aber gegen die eigene Uhr -- und meldete "nicht sauber" (Review).
98
+ # Der Stopp bekommt nur, was von `bis` uebrig ist: Hat das Warten auf "startet" schon Zeit verbraucht, kaeme
99
+ # `stoppen` sonst erst nach bis zu 495 + 480 s zurueck -- Node braeche bei 540 s ab, und es gaebe keinen Satz.
100
+ # Ist nichts mehr uebrig, wird nicht gestoppt: Die Bremse (`abbrechen`) liegt dann schon.
101
+ rest = min(STOPP_SEK, bis - time.time())
102
+ if rest > 0 and not (time.time() >= (bericht.get("frist") or float("inf")) and _frisch(bericht)):
103
+ try:
104
+ _stoppe_sitzung(bericht.get("sitzung"), rest)
105
+ except Exception:
106
+ pass # ob die Aufnahme zu ist, sagen danach der Starter und capture-state.json
107
+ bericht = _warte_auf_starter(ordner, "laeuft", bis)
108
+ zustand = bericht.get("zustand")
109
+ if zustand in ("startet", "laeuft"):
110
+ # Der Starter hat kein Ende gemeldet. Lebt er und laeuft die Aufnahme, schliesst er noch ab. Steht er trotz
111
+ # Bremse noch in "startet" (oder meldet `nicht-beendet`), schliesst er nichts ab, sondern wird sie nicht los:
112
+ # dann "unklar". Ist er still, entscheidet die Liste des Aufnehmers -- im Zweifel nie ein Ergebnis, das wie ein
113
+ # Ende aussieht.
114
+ if _frisch(bericht):
115
+ abschluss = zustand == "laeuft" and bericht.get("grund") != "nicht-beendet"
116
+ return antwort("wird-abgeschlossen" if abschluss else "unklar")
117
+ aktive = _aktive_sitzungen()
118
+ if aktive is None or (bericht.get("sitzung") in aktive if zustand == "laeuft" else aktive):
119
+ return antwort("unklar")
120
+ if zustand == "startet": # ohne Sitzung gibt es keine Aufnahme, die man abschliessen koennte
121
+ return antwort("abgebrochen", fehler_grund(lies_protokoll(ordner)), aufnahme_zahlen(ordner))
122
+ if zustand in ("abgebrochen", "fehlgeschlagen"):
123
+ return antwort("abgebrochen", bericht.get("grund") or fehler_grund(lies_protokoll(ordner)), aufnahme_zahlen(ordner))
124
+ if zustand not in ("gestoppt", "selbst-gestoppt", "laeuft"): # "laeuft" = Starter still, Sitzung nicht mehr aktiv
125
+ return antwort("nicht-sauber", fehler_grund(lies_protokoll(ordner)), aufnahme_zahlen(ordner))
126
+ ergebnis = "selbst-gestoppt" if zustand == "selbst-gestoppt" else "gestoppt"
127
+ zahlen = aufnahme_zahlen(ordner, bericht.get("frist") if ergebnis == "selbst-gestoppt" else None)
128
+ if zahlen is None or not sauber(ordner):
129
+ return antwort("nicht-sauber", fehler_grund(lies_protokoll(ordner)), zahlen)
130
+ return antwort(ergebnis, None, zahlen)
131
+
132
+
133
+ def _aktive_sitzungen() -> list | None:
134
+ try:
135
+ from openadapt_capture.control import discover_recorders # nur lebende Prozesse, Steuerdateien des Aufnehmers
136
+
137
+ return [str(s) for s in discover_recorders()]
138
+ except Exception:
139
+ return None
140
+
141
+
142
+ def zustand(ordner: str | None) -> dict:
143
+ return {"aktive_sitzungen": _aktive_sitzungen(),
144
+ "starter": lies_json(os.path.join(ordner, STARTER)) if ordner else None}
145
+
146
+
147
+ def modell_da(cache: str) -> bool:
148
+ """large-v3-turbo vollstaendig im Hugging-Face-Zwischenspeicher? Ohne Netz: Groessen gegen den Dateibaum."""
149
+ repo = os.path.join(cache, "models--" + MODELL_REPO.replace("/", "--"))
150
+ try:
151
+ with open(os.path.join(repo, "refs", "main"), encoding="utf-8") as f:
152
+ stand = f.read().strip()
153
+ except OSError:
154
+ return False
155
+ snapshot = os.path.join(repo, "snapshots", stand)
156
+ baum = (lies_json(os.path.join(repo, "trees", stand + ".json")) or {}).get("files", {})
157
+ for name in ("config.json", "preprocessor_config.json", "model.bin", "tokenizer.json", "vocabulary.json"):
158
+ datei = os.path.join(snapshot, name)
159
+ if not os.path.isfile(datei):
160
+ return False
161
+ soll = (baum.get(name) or {}).get("size")
162
+ ist = os.path.getsize(datei)
163
+ if (isinstance(soll, int) and ist != soll) or (name == "model.bin" and ist == 0):
164
+ return False
165
+ return True
166
+
167
+
168
+ def _hf_cache() -> str:
169
+ try:
170
+ from huggingface_hub import constants
171
+
172
+ return constants.HF_HUB_CACHE
173
+ except Exception:
174
+ return os.path.join(os.path.expanduser("~"), ".cache", "huggingface", "hub")
175
+
176
+
177
+ def _ffmpeg_da() -> bool:
178
+ try:
179
+ from openadapt_capture.video import resolve_ffmpeg # genau die Suche des Aufnehmers
180
+
181
+ resolve_ffmpeg()
182
+ return True
183
+ except Exception:
184
+ return False
185
+
186
+
187
+ def teile() -> dict:
188
+ from importlib.metadata import PackageNotFoundError, version
189
+
190
+ try:
191
+ aufnehmer = version("openadapt-capture")
192
+ except PackageNotFoundError:
193
+ aufnehmer = None
194
+ return {"aufnehmer": aufnehmer, "ffmpeg": _ffmpeg_da(), "modell": modell_da(_hf_cache())}
195
+
196
+
197
+ def main(argv: list) -> int:
198
+ parser = argparse.ArgumentParser(prog="werkzeug.py")
199
+ befehle = parser.add_subparsers(dest="befehl", required=True)
200
+ befehle.add_parser("starten").add_argument("konfig")
201
+ befehle.add_parser("stoppen").add_argument("ordner")
202
+ befehle.add_parser("zustand").add_argument("ordner")
203
+ befehle.add_parser("teile")
204
+ zl = befehle.add_parser("zeitleiste")
205
+ zl.add_argument("ordner")
206
+ zl.add_argument("sprache", choices=["de", "en"])
207
+ zl.add_argument("--ohne-getipptes", action="store_true")
208
+ bi = befehle.add_parser("bild")
209
+ bi.add_argument("ordner")
210
+ bi.add_argument("sekunden", type=float)
211
+ befehle.add_parser("modell-laden")
212
+ args = parser.parse_args(argv)
213
+ os.environ.update(UMGEBUNG)
214
+ try:
215
+ if args.befehl == "starten":
216
+ konfig = lies_json(args.konfig)
217
+ konfig["ordner"] = os.path.abspath(konfig["ordner"]) # vor chdir: ein relativer Pfad zeigte sonst ins Leere
218
+ os.chdir(konfig["ordner"]) # Arbeitsordner = Aufnahmeordner: nie eine fremde .env (Spec US-4)
219
+ starte(konfig)
220
+ return 0
221
+ ordner = getattr(args, "ordner", "-")
222
+ ordner = ordner if ordner == "-" else os.path.abspath(ordner)
223
+ fehlt = args.befehl in ("zeitleiste", "bild") and not os.path.isdir(ordner) # zeitleiste.py meldet ordner-fehlt
224
+ os.chdir(os.path.dirname(os.path.abspath(__file__)) if ordner == "-" or fehlt else ordner)
225
+ if args.befehl == "stoppen":
226
+ daten = stoppen(ordner)
227
+ elif args.befehl == "zustand":
228
+ daten = zustand(None if ordner == "-" else ordner)
229
+ elif args.befehl in ("zeitleiste", "bild", "modell-laden"):
230
+ import zeitleiste
231
+
232
+ if args.befehl == "zeitleiste":
233
+ daten = zeitleiste.auswerten(ordner, args.sprache, args.ohne_getipptes, lambda: modell_da(_hf_cache()))
234
+ elif args.befehl == "bild":
235
+ daten = zeitleiste.bild(ordner, args.sekunden)
236
+ else:
237
+ daten = zeitleiste.modell_laden(lambda: modell_da(_hf_cache()))
238
+ else:
239
+ daten = teile()
240
+ except Exception as fehler:
241
+ # Fester Code statt Meldung: eine Meldung koennte einen Pfad oder eine Zeile des Aufnehmers tragen.
242
+ if sys.stderr is not None: # unter pythonw (Starter) gibt es keinen stderr
243
+ sys.stderr.write(f"werkzeug-fehler:{type(fehler).__name__}\n")
244
+ return 1
245
+ sys.stdout.write(json.dumps(daten) + "\n")
246
+ return 0
247
+
248
+
249
+ if __name__ == "__main__":
250
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,393 @@
1
+ # Zeitleiste, Einzelbild und Sprachmodell hinter `kimal3 zeigen auswerten | bild` und `einrichten` (Ruling R-6: eigene
2
+ # Datei, werkzeug.py ruft nur auf). Laeuft mit dem Python des Aufnehmers, Arbeitsordner = Aufnahmeordner.
3
+ #
4
+ # Die Regel steckt in `zeitleiste_aus` (rein, ohne Modell geprueft von scripts/zeigen/test_zeitleiste.py); `auswerten`
5
+ # liest nur die Aufnahme, wandelt Ton in Woerter und schreibt die Datei. Spec 2026-10-18-k3zeigen-design.md, US-7/US-8.
6
+ #
7
+ # 🔴 Was hier zurueckkommt, sind Zahlen und feste Codes -- nie Gesagtes, Getipptes, Fenstertitel, keine Meldung einer
8
+ # fremden Bibliothek. Die Zeitleiste selbst liest Claude erst nach Sabines Ja.
9
+ import io
10
+ import json
11
+ import logging
12
+ import math
13
+ import os
14
+ import re
15
+ import sqlite3
16
+ import time
17
+ import warnings
18
+
19
+ # Vor jedem Import von huggingface_hub (auch dem in werkzeug._hf_cache, das nach `import zeitleiste` laeuft): sonst
20
+ # schreibt der Download Fortschrittsbalken nach stderr.
21
+ os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
22
+
23
+ MODELL = "large-v3-turbo"
24
+ SATZ_PAUSE_SEK = 0.8
25
+ TIPP_PAUSE_SEK = 2.0
26
+ BILD_BREITE = 1600 # Lesekosten bei 3440 x 2880 (Spec US-8)
27
+ DATEI = "zeitleiste.md"
28
+ ROH = "roh" # Zwilling von gemeinsam.ROH: die Aufnahme selbst liegt dort, Zeitleiste und Bilder eine Ebene hoeher
29
+
30
+ TEXTE = {
31
+ "de": {"titel": "# Zeitleiste", "aufgabe": "Aufgabe", "datum": "Datum", "dauer": "Dauer", "ohne_aufgabe": "(keine)",
32
+ "sagt": "sagt", "tippt": "tippt", "klickt": "klickt", "auf": "„", "zu": "\"",
33
+ "passwort": "[Passwortfeld, ausgeblendet]", "ausgelassen": "[getippt, ausgelassen]",
34
+ "unbekannt": "[unbekanntes Element]", "Strg": "Strg", "Umschalt": "Umschalt"},
35
+ "en": {"titel": "# Timeline", "aufgabe": "Task", "datum": "Date", "dauer": "Duration", "ohne_aufgabe": "(none)",
36
+ "sagt": "says", "tippt": "types", "klickt": "clicks", "auf": "“", "zu": "”",
37
+ "passwort": "[password field, hidden]", "ausgelassen": "[typed, left out]",
38
+ "unbekannt": "[unknown element]", "Strg": "Ctrl", "Umschalt": "Shift"},
39
+ }
40
+
41
+ # Tasten des Aufnehmers (pynput-Namen). AltGr fehlt in KOMBI absichtlich: AltGr+Q ist „@", getippt, keine Kombination.
42
+ UMSCHALTER = {"ctrl": "Strg", "ctrl_l": "Strg", "ctrl_r": "Strg", "alt": "Alt", "alt_l": "Alt", "alt_r": "Alt",
43
+ "cmd": "Win", "cmd_l": "Win", "cmd_r": "Win", "shift": "Umschalt", "shift_l": "Umschalt",
44
+ "shift_r": "Umschalt", "alt_gr": "AltGr"}
45
+ KOMBI = ("Strg", "Alt", "Win")
46
+ ZEICHEN = {"space": " ", "enter": "⏎", "tab": "⇥", "up": "↑", "down": "↓", "left": "←", "right": "→"}
47
+ # Passwortfelder: Windows meldet `IsPassword`, gespeichert wird das Merkmal aber nicht (StructuralElement kennt kein
48
+ # Feld dafuer, structural.py). Deshalb wie der Aufnehmer selbst (shadow/events.py native_field_is_secure) zusaetzlich
49
+ # an Rolle, Klasse und Automation-ID -- und am Namen eines Eingabefelds, dort auch auf Deutsch.
50
+ # `\bpass\d*\b`/`\bpw\d*\b` fuer Web-Kennungen wie user_pass, pass2, login-pw (nach _woerter_teilen: "user pass").
51
+ GEHEIM = re.compile(r"passw|kennw|passcode|\bpass\d*\b|\bpw\d*\b|\bpin\b|\bpwd\b|\botp\b|\btan\b|secret|geheim",
52
+ re.IGNORECASE)
53
+
54
+
55
+ def _uhr(sek: float) -> str:
56
+ sek = max(0, int(sek))
57
+ return f"{sek // 60}:{sek % 60:02d}"
58
+
59
+
60
+ def _eine_zeile(wert) -> str:
61
+ """Jedes Ereignis bleibt eine Zeile: Zeilenumbrueche und Tabulatoren in Namen und Gesagtem werden Leerzeichen."""
62
+ return " ".join(str(wert or "").split())
63
+
64
+
65
+ def _woerter_teilen(text: str) -> str:
66
+ return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", re.sub(r"[-_.#]+", " ", text))
67
+
68
+
69
+ def passwortfeld(beobachtung: dict | None) -> bool:
70
+ if not isinstance(beobachtung, dict):
71
+ return False
72
+ e = beobachtung.get("element") or {}
73
+ if e.get("is_password") is True or e.get("IsPassword") is True:
74
+ return True
75
+ rollen = " ".join(str(e.get(k) or "") for k in ("role", "control_type", "class_name")).casefold()
76
+ if "password" in rollen or "secure" in rollen:
77
+ return True
78
+ namen = [e.get("automation_id"), e.get("class_name")]
79
+ if str(e.get("control_type") or e.get("role") or "").casefold() == "edit":
80
+ namen.append(e.get("name"))
81
+ return any(isinstance(n, str) and GEHEIM.search(_woerter_teilen(n)) for n in namen)
82
+
83
+
84
+ def _programm(beobachtung: dict) -> str | None:
85
+ name = ((beobachtung.get("process") or {}).get("process_name") or "").strip() # nie window.title (Spec US-7)
86
+ return re.sub(r"\.exe$", "", name, flags=re.IGNORECASE).casefold() or None
87
+
88
+
89
+ def _klick_text(beobachtung: dict | None, t: dict) -> str:
90
+ if not isinstance(beobachtung, dict) or not beobachtung.get("element"):
91
+ return t["unbekannt"]
92
+ e = beobachtung["element"]
93
+ teile = [_eine_zeile(e.get("role") or e.get("control_type"))]
94
+ if _eine_zeile(e.get("name")):
95
+ teile.append(f"{t['auf']}{_eine_zeile(e['name'])}{t['zu']}")
96
+ programm = _programm(beobachtung)
97
+ if programm:
98
+ teile.append(f"({programm})")
99
+ return " ".join(x for x in teile if x) or t["unbekannt"]
100
+
101
+
102
+ def _saetze(woerter: list) -> list:
103
+ # Pause = Stille zwischen Ende des einen und Anfang des naechsten Worts. Nur Anfaenge zu vergleichen trennte
104
+ # gedehnte Woerter mitten im Satz (gemessen an wetter-3: „aktuellen" 1,6 s lang, 0 s Stille vor „Tag.").
105
+ saetze, satz, ende = [], [], None
106
+ for zeit, bis, wort in sorted(woerter):
107
+ wort = _eine_zeile(wort)
108
+ if not wort:
109
+ continue
110
+ if satz and zeit - ende > SATZ_PAUSE_SEK:
111
+ saetze.append(satz)
112
+ satz = []
113
+ satz.append((zeit, wort))
114
+ ende = bis
115
+ if wort[-1] in ".!?…":
116
+ saetze.append(satz)
117
+ satz = []
118
+ return saetze + ([satz] if satz else [])
119
+
120
+
121
+ def _kombi_name(e: dict) -> str:
122
+ zeichen = e.get("zeichen") or ""
123
+ if len(zeichen) == 1 and 1 <= ord(zeichen) <= 26: # Strg+Buchstabe kommt als Steuerzeichen: \x03 = Strg+C
124
+ return chr(64 + ord(zeichen))
125
+ if zeichen.isprintable() and zeichen:
126
+ return zeichen.upper()
127
+ return (e.get("taste") or "?").replace("_", " ").title()
128
+
129
+
130
+ def zeitleiste_aus(kopf: dict, woerter: list, ereignisse: list, sprache: str, ohne_getipptes: bool) -> tuple:
131
+ """Text der Zeitleiste und ihre Zahlen. kopf: aufgabe, datum (fertiger Text), dauer_sek. woerter: (Anfang, Ende, Wort)
132
+ in Sekunden ab Aufnahmestart. ereignisse: dicts mit t, art (click|press|release|…), beobachtung, bei Tasten
133
+ taste und zeichen."""
134
+ t = TEXTE[sprache]
135
+ zahlen = {"saetze": 0, "klicks": 0, "tippstellen": 0, "passwortfelder": 0}
136
+ zeilen = [] # (Sekunde, Verb, Text)
137
+
138
+ for satz in _saetze(woerter):
139
+ zeilen.append((satz[0][0], t["sagt"], f"{t['auf']}{' '.join(w for _, w in satz)}{t['zu']}"))
140
+ zahlen["saetze"] += 1
141
+
142
+ folge = None # laufende Tippfolge: t, text, geheim, zuletzt
143
+ gehalten = set()
144
+ klick_geheim = False
145
+
146
+ def folge_ende():
147
+ nonlocal folge
148
+ if folge and folge["text"]:
149
+ if folge["geheim"]:
150
+ text, zaehler = t["passwort"], "passwortfelder"
151
+ elif ohne_getipptes:
152
+ text, zaehler = t["ausgelassen"], "tippstellen"
153
+ else:
154
+ text, zaehler = f"{t['auf']}{folge['text']}{t['zu']}", "tippstellen"
155
+ zeilen.append((folge["t"], t["tippt"], text))
156
+ zahlen[zaehler] += 1
157
+ folge = None
158
+
159
+ for e in sorted(ereignisse, key=lambda e: e["t"]):
160
+ art, taste = e["art"], e.get("taste")
161
+ if art == "click":
162
+ folge_ende()
163
+ gehalten.clear()
164
+ klick_geheim = passwortfeld(e.get("beobachtung"))
165
+ zeilen.append((e["t"], t["klickt"], _klick_text(e.get("beobachtung"), t)))
166
+ zahlen["klicks"] += 1
167
+ continue
168
+ if art == "release":
169
+ gehalten.discard(UMSCHALTER.get(taste))
170
+ continue
171
+ if art != "press":
172
+ continue # Mausbewegung, Scrollen
173
+ if taste in UMSCHALTER:
174
+ gehalten.add(UMSCHALTER[taste])
175
+ continue
176
+ zeichen = e.get("zeichen") or ""
177
+ steuer = taste is None and len(zeichen) == 1 and 1 <= ord(zeichen) <= 26 # Tab/Enter haben einen Namen
178
+ # Windows meldet AltGr als ctrl_l + alt_r, also Strg+Alt: ein druckbares Zeichen dabei ist Text („@", „€").
179
+ altgr = bool(zeichen) and zeichen.isprintable() and ("AltGr" in gehalten or {"Strg", "Alt"} <= gehalten)
180
+ # Im Passwortfeld wird keine Kombination eine eigene Zeile: sie verriete Tasten und teilte die Ausblendung.
181
+ geheim = klick_geheim or (folge is not None and folge["geheim"]) or passwortfeld(e.get("beobachtung"))
182
+ kombi = [m for m in KOMBI if m in gehalten] or (["Strg"] if steuer else [])
183
+ if kombi and not altgr and not geheim:
184
+ folge_ende()
185
+ mods = kombi + (["Umschalt"] if "Umschalt" in gehalten else [])
186
+ zeilen.append((e["t"], t["tippt"], "[" + "+".join(t.get(m, m) for m in mods) + f"+{_kombi_name(e)}]"))
187
+ gehalten.clear() # eine Kombination verbraucht die Umschalter; die naechste Taste kommt wieder mit eigenen
188
+ continue
189
+ gehalten.clear()
190
+ if folge and e["t"] - folge["zuletzt"] > TIPP_PAUSE_SEK:
191
+ folge_ende()
192
+ if folge is None:
193
+ folge = {"t": e["t"], "text": "", "geheim": klick_geheim, "zuletzt": e["t"]}
194
+ folge["zuletzt"] = e["t"]
195
+ folge["geheim"] = folge["geheim"] or passwortfeld(e.get("beobachtung"))
196
+ if taste == "backspace":
197
+ folge["text"] = folge["text"][:-1]
198
+ elif zeichen and zeichen.isprintable():
199
+ folge["text"] += zeichen
200
+ else:
201
+ folge["text"] += ZEICHEN.get(taste) or f"[{(taste or '?').replace('_', ' ').title()}]"
202
+ folge_ende()
203
+
204
+ kopfzeilen = [t["titel"], "", f"{t['aufgabe']}: {kopf.get('aufgabe') or t['ohne_aufgabe']}",
205
+ f"{t['datum']}: {kopf['datum']}", f"{t['dauer']}: {_uhr(kopf['dauer_sek'])}", ""]
206
+ rumpf = [f"{_uhr(s)} {verb:<8}{text}" for s, verb, text in sorted(zeilen, key=lambda z: z[0])]
207
+ return "\n".join(kopfzeilen + rumpf) + "\n", zahlen
208
+
209
+
210
+ # --- Aufnahme lesen, Ton in Woerter ------------------------------------------------------------------------------
211
+
212
+ def _lies_aufnahme(ordner: str):
213
+ db = sqlite3.connect(f"file:{os.path.join(ordner, ROH, 'recording.db')}?mode=ro", uri=True)
214
+ try:
215
+ start, beschreibung = db.execute("select timestamp, task_description from recording").fetchone()
216
+ start = float(start)
217
+ ereignisse = []
218
+ for zeit, art, taste, zeichen, gedrueckt, roh in db.execute(
219
+ "select timestamp, name, key_name, key_char, mouse_pressed, structural_observation from action_event "
220
+ "where name in ('click', 'press', 'release') order by timestamp"):
221
+ if art == "click" and not gedrueckt:
222
+ continue # Loslassen der Maustaste: doppelt zum Klick davor
223
+ try:
224
+ beobachtung = json.loads(roh) if roh else None
225
+ except ValueError:
226
+ beobachtung = None
227
+ ereignisse.append({"t": float(zeit) - start, "art": art, "taste": taste, "zeichen": zeichen,
228
+ "beobachtung": beobachtung})
229
+ letzte = db.execute("select max(timestamp) from action_event").fetchone()[0]
230
+ ton = [(float(ts) - start, flac) for ts, flac in
231
+ db.execute("select timestamp, flac_data from audio_info where length(flac_data) > 0 order by timestamp")]
232
+ finally:
233
+ db.close()
234
+ return start, beschreibung, ereignisse, (float(letzte) - start if letzte is not None else 0.0), ton
235
+
236
+
237
+ def _still() -> None:
238
+ """Fremde Bibliotheken schreiben sonst nach stderr (loguru im Aufnehmer, Warnungen, logging) -- womoeglich mit
239
+ Fenstertiteln oder Erkanntem. Vor ihrem Import aufrufen."""
240
+ warnings.simplefilter("ignore")
241
+ logging.disable(logging.CRITICAL)
242
+ try:
243
+ from loguru import logger
244
+
245
+ logger.remove()
246
+ except ImportError:
247
+ pass
248
+
249
+
250
+ def _dekodiere(flac: bytes):
251
+ """16 kHz mono float32 direkt ueber PyAV (faster_whisper.decode_audio scheitert an PyAV 19, Befund B3)."""
252
+ import av
253
+ import numpy
254
+
255
+ teile = []
256
+ with av.open(io.BytesIO(flac)) as quelle:
257
+ wandler = av.AudioResampler(format="flt", layout="mono", rate=16000)
258
+ for rahmen in quelle.decode(audio=0):
259
+ teile += [r.to_ndarray().reshape(-1) for r in wandler.resample(rahmen)]
260
+ teile += [r.to_ndarray().reshape(-1) for r in wandler.resample(None)]
261
+ return numpy.concatenate(teile) if teile else numpy.zeros(0, dtype=numpy.float32)
262
+
263
+
264
+ def _erkenne(ton: list, sprache: str) -> tuple:
265
+ """Woerter (Anfang, Ende ab Aufnahmestart, Wort) und Ende des Tons. Ob das Modell da ist, prueft auswerten vorher."""
266
+ os.environ["HF_HUB_OFFLINE"] = "1" # das Modell hat `einrichten` geladen; hier geht nichts ins Netz
267
+ _still()
268
+ from faster_whisper import WhisperModel
269
+
270
+ modell = WhisperModel(MODELL, device="cpu", compute_type="int8")
271
+ woerter, ende = [], 0.0
272
+ for abstand, flac in ton:
273
+ samples = _dekodiere(flac)
274
+ ende = max(ende, abstand + len(samples) / 16000)
275
+ if not len(samples):
276
+ continue
277
+ segmente, _ = modell.transcribe(samples, language=sprache, word_timestamps=True, vad_filter=True)
278
+ woerter += [(abstand + w.start, abstand + w.end, w.word) for s in segmente for w in (s.words or [])]
279
+ return woerter, ende
280
+
281
+
282
+ def _aufgabe(ordner: str, beschreibung: str | None) -> str | None:
283
+ try:
284
+ with open(os.path.join(ordner, "aufgabe.txt"), encoding="utf-8-sig") as f:
285
+ return f.read().strip() or beschreibung
286
+ except OSError:
287
+ return beschreibung
288
+
289
+
290
+ def _weg(pfad: str) -> None:
291
+ try:
292
+ os.remove(pfad)
293
+ except FileNotFoundError:
294
+ pass
295
+
296
+
297
+ def auswerten(ordner: str, sprache: str, ohne_getipptes: bool, modell_da) -> dict:
298
+ """`modell_da()` = werkzeug.modell_da gegen den Zwischenspeicher: nur ein fehlendes Modell heisst modell-fehlt."""
299
+ daten = {"ergebnis": "fehler", "dauer_sek": 0.0, "saetze": 0, "klicks": 0, "tippstellen": 0, "passwortfelder": 0,
300
+ "grund": None}
301
+ if not os.path.isdir(ordner):
302
+ return dict(daten, grund="ordner-fehlt")
303
+ ziel = os.path.join(ordner, DATEI)
304
+ _weg(ziel) # eine alte Zeitleiste darf nie als Ergebnis dieses Laufs dastehen
305
+ _weg(ziel + ".tmp")
306
+ if not os.path.isfile(os.path.join(ordner, ROH, "recording.db")):
307
+ return dict(daten, grund="keine-aufnahme")
308
+ try:
309
+ start, beschreibung, ereignisse, letzte, ton = _lies_aufnahme(ordner)
310
+ except (sqlite3.Error, TypeError, ValueError):
311
+ return dict(daten, grund="aufnahme-unlesbar")
312
+ daten["dauer_sek"] = round(max(letzte, 0.0), 2)
313
+ if not ton:
314
+ return dict(daten, ergebnis="kein-ton")
315
+ if not modell_da():
316
+ return dict(daten, grund="modell-fehlt")
317
+ try:
318
+ woerter, ton_ende = _erkenne(ton, sprache)
319
+ except Exception:
320
+ return dict(daten, grund="erkennung") # kein Text der Ausnahme: sie koennte Erkanntes tragen
321
+ dauer = max(letzte, ton_ende)
322
+ kopf = {"aufgabe": _aufgabe(ordner, beschreibung), "datum": time.strftime("%Y-%m-%d %H:%M", time.localtime(start)),
323
+ "dauer_sek": dauer}
324
+ text, zahlen = zeitleiste_aus(kopf, woerter, ereignisse, sprache, ohne_getipptes)
325
+ daten.update(zahlen, dauer_sek=round(dauer, 2))
326
+ if zahlen["saetze"] == 0:
327
+ return dict(daten, ergebnis="keine-sprache")
328
+ try:
329
+ with open(ziel + ".tmp", "w", encoding="utf-8", newline="\n") as f:
330
+ f.write(text)
331
+ os.replace(ziel + ".tmp", ziel)
332
+ except OSError:
333
+ _weg(ziel + ".tmp")
334
+ return dict(daten, grund="schreiben-gescheitert")
335
+ return dict(daten, ergebnis="fertig")
336
+
337
+
338
+ # --- Einzelbild ----------------------------------------------------------------------------------------------------
339
+
340
+ def bild_name(sekunden: float) -> str:
341
+ return _uhr(sekunden).replace(":", "-") + ".png"
342
+
343
+
344
+ def _einzelbild(ordner: str, sekunden: float):
345
+ _still()
346
+ from openadapt_capture.capture import CaptureSession # erst nach chdir: der Aufnehmer liest beim Import eine .env
347
+
348
+ sitzung = CaptureSession.load(os.path.join(ordner, ROH))
349
+ if sitzung.video_path is None:
350
+ return None
351
+ return sitzung.get_frame_at(sitzung._recording.timestamp + sekunden, tolerance=1.0)
352
+
353
+
354
+ def bild(ordner: str, sekunden: float) -> dict:
355
+ if not os.path.isdir(ordner):
356
+ return {"ergebnis": "fehler", "datei": None, "grund": "ordner-fehlt"}
357
+ if not math.isfinite(sekunden) or sekunden < 0:
358
+ return {"ergebnis": "fehler", "datei": None, "grund": "zeit-ungueltig"}
359
+ roh_ordner = os.path.join(ordner, ROH)
360
+ if not (os.path.isdir(roh_ordner) and any(n.endswith(".mp4") for n in os.listdir(roh_ordner))):
361
+ return {"ergebnis": "fehler", "datei": None, "grund": "kein-video"}
362
+ try:
363
+ roh = _einzelbild(ordner, sekunden)
364
+ except Exception:
365
+ roh = None
366
+ if roh is None:
367
+ return {"ergebnis": "fehler", "datei": None, "grund": "kein-bild"}
368
+ if roh.width > BILD_BREITE:
369
+ roh = roh.resize((BILD_BREITE, round(roh.height * BILD_BREITE / roh.width)))
370
+ os.makedirs(os.path.join(ordner, "bilder"), exist_ok=True)
371
+ datei = os.path.join(ordner, "bilder", bild_name(sekunden))
372
+ roh.convert("RGB").save(datei, "PNG")
373
+ return {"ergebnis": "fertig", "datei": datei, "grund": None}
374
+
375
+
376
+ # --- Sprachmodell ----------------------------------------------------------------------------------------------------
377
+
378
+ def _lade_modell() -> None:
379
+ _still()
380
+ from faster_whisper.utils import download_model
381
+
382
+ download_model(MODELL)
383
+
384
+
385
+ def modell_laden(vollstaendig) -> dict:
386
+ """`vollstaendig()` = werkzeug.modell_da gegen den Zwischenspeicher; erst danach zaehlt ein Laden als geladen."""
387
+ if vollstaendig():
388
+ return {"ergebnis": "da", "grund": None}
389
+ try:
390
+ _lade_modell()
391
+ except Exception:
392
+ return {"ergebnis": "fehler", "grund": "laden-gescheitert"}
393
+ return {"ergebnis": "geladen", "grund": None} if vollstaendig() else {"ergebnis": "fehler", "grund": "unvollstaendig"}