From 952bbb6158f562d44e5a9eef921295b0f2977113 Mon Sep 17 00:00:00 2001 From: captain nemorg Date: Thu, 9 Jul 2026 23:00:31 +0200 Subject: [PATCH] =?UTF-8?q?Initialer=20Push,=20Grunds=C3=A4tzlich=20funkti?= =?UTF-8?q?oniert=20das=20Programm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 + install.sh | 81 ++++++++ pyproject.toml | 28 +++ reference/call-recorder.sh | 54 +++++ reference/transkribiere_datei.py | 65 ++++++ reference/transkribiere_datei.sh | 5 + reference/transkribieren.py | 65 ++++++ reference/transkribieren_1.py | 66 ++++++ reference/zusammenfassung_prompt.md | 94 +++++++++ src/notarius/__init__.py | 3 + src/notarius/__main__.py | 13 ++ src/notarius/app.py | 57 ++++++ src/notarius/audio/__init__.py | 0 src/notarius/audio/devices.py | 40 ++++ src/notarius/audio/ffprobe.py | 35 ++++ src/notarius/audio/lock.py | 87 ++++++++ src/notarius/audio/recorder.py | 78 +++++++ src/notarius/autostart.py | 41 ++++ src/notarius/config.py | 63 ++++++ src/notarius/gui/__init__.py | 0 src/notarius/gui/controller.py | 115 +++++++++++ src/notarius/gui/format_utils.py | 21 ++ src/notarius/gui/main_window.py | 98 +++++++++ src/notarius/gui/tabs/__init__.py | 0 src/notarius/gui/tabs/processing_tab.py | 142 +++++++++++++ src/notarius/gui/tabs/recordings_tab.py | 91 +++++++++ src/notarius/gui/tabs/settings_tab.py | 146 +++++++++++++ src/notarius/gui/tabs/summary_tab.py | 82 ++++++++ src/notarius/gui/tabs/transcript_tab.py | 192 ++++++++++++++++++ src/notarius/gui/tray.py | 79 +++++++ src/notarius/gui/widgets/__init__.py | 0 src/notarius/gui/widgets/audio_player.py | 67 ++++++ src/notarius/gui/widgets/editable_field.py | 120 +++++++++++ src/notarius/keyring_store.py | 73 +++++++ src/notarius/logging_setup.py | 40 ++++ src/notarius/menu_launcher.py | 51 +++++ src/notarius/models/__init__.py | 0 src/notarius/models/recording.py | 189 +++++++++++++++++ src/notarius/paths.py | 35 ++++ src/notarius/rec_cli.py | 46 +++++ src/notarius/recording_session.py | 84 ++++++++ .../resources/zusammenfassung_prompt.md | 69 +++++++ src/notarius/summarization/__init__.py | 0 src/notarius/summarization/client.py | 63 ++++++ src/notarius/summarization/prompts.py | 29 +++ src/notarius/summarization/worker.py | 100 +++++++++ src/notarius/transcription/__init__.py | 0 src/notarius/transcription/cli.py | 98 +++++++++ src/notarius/transcription/formatting.py | 30 +++ src/notarius/transcription/worker.py | 102 ++++++++++ uninstall.sh | 22 ++ 51 files changed, 3066 insertions(+) create mode 100644 .gitignore create mode 100755 install.sh create mode 100644 pyproject.toml create mode 100644 reference/call-recorder.sh create mode 100644 reference/transkribiere_datei.py create mode 100755 reference/transkribiere_datei.sh create mode 100644 reference/transkribieren.py create mode 100644 reference/transkribieren_1.py create mode 100644 reference/zusammenfassung_prompt.md create mode 100644 src/notarius/__init__.py create mode 100644 src/notarius/__main__.py create mode 100644 src/notarius/app.py create mode 100644 src/notarius/audio/__init__.py create mode 100644 src/notarius/audio/devices.py create mode 100644 src/notarius/audio/ffprobe.py create mode 100644 src/notarius/audio/lock.py create mode 100644 src/notarius/audio/recorder.py create mode 100644 src/notarius/autostart.py create mode 100644 src/notarius/config.py create mode 100644 src/notarius/gui/__init__.py create mode 100644 src/notarius/gui/controller.py create mode 100644 src/notarius/gui/format_utils.py create mode 100644 src/notarius/gui/main_window.py create mode 100644 src/notarius/gui/tabs/__init__.py create mode 100644 src/notarius/gui/tabs/processing_tab.py create mode 100644 src/notarius/gui/tabs/recordings_tab.py create mode 100644 src/notarius/gui/tabs/settings_tab.py create mode 100644 src/notarius/gui/tabs/summary_tab.py create mode 100644 src/notarius/gui/tabs/transcript_tab.py create mode 100644 src/notarius/gui/tray.py create mode 100644 src/notarius/gui/widgets/__init__.py create mode 100644 src/notarius/gui/widgets/audio_player.py create mode 100644 src/notarius/gui/widgets/editable_field.py create mode 100644 src/notarius/keyring_store.py create mode 100644 src/notarius/logging_setup.py create mode 100644 src/notarius/menu_launcher.py create mode 100644 src/notarius/models/__init__.py create mode 100644 src/notarius/models/recording.py create mode 100644 src/notarius/paths.py create mode 100644 src/notarius/rec_cli.py create mode 100644 src/notarius/recording_session.py create mode 100644 src/notarius/resources/zusammenfassung_prompt.md create mode 100644 src/notarius/summarization/__init__.py create mode 100644 src/notarius/summarization/client.py create mode 100644 src/notarius/summarization/prompts.py create mode 100644 src/notarius/summarization/worker.py create mode 100644 src/notarius/transcription/__init__.py create mode 100644 src/notarius/transcription/cli.py create mode 100644 src/notarius/transcription/formatting.py create mode 100644 src/notarius/transcription/worker.py create mode 100755 uninstall.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..281b8c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.egg-info/ +.venv/ +venv/ +build/ +dist/ diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..237ca6f --- /dev/null +++ b/install.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Installiert Notarius in eine lokale virtuelle Umgebung (.venv) und richtet +# einen Menü-Eintrag im Anwendungsmenü ein. Setzt voraus, dass ffmpeg, pactl +# und eine funktionierende CUDA-Umgebung bereits auf dem System vorhanden sind +# (siehe README.md). +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENV_DIR="$PROJECT_DIR/.venv" + +# whisperx zieht (über faster-whisper) eine exakt gepinnte ctranslate2-Version, +# für die es auf ganz neuen Python-Versionen (3.13/3.14 - z.B. Fedoras aktueller +# Standard-python3) noch keine Wheels gibt. Deshalb wird hier gezielt nach einer +# bekannt kompatiblen Version gesucht, statt blind "python3" zu verwenden. +find_compatible_python() { + if [[ -n "${PYTHON_BIN:-}" ]]; then + echo "$PYTHON_BIN" + return + fi + for candidate in python3.12 python3.11 python3.10; do + if command -v "$candidate" &>/dev/null; then + echo "$candidate" + return + fi + done + return 1 +} + +# Prüft, ob eine bestehende .venv mit einer der bekannt kompatiblen Versionen läuft. +venv_is_compatible() { + local venv_python="$VENV_DIR/bin/python3" + [[ -x "$venv_python" ]] || return 1 + local version + version="$("$venv_python" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" + [[ "$version" == "3.10" || "$version" == "3.11" || "$version" == "3.12" ]] +} + +if [[ -d "$VENV_DIR" ]] && venv_is_compatible; then + echo "Verwende bestehende virtuelle Umgebung: $VENV_DIR" +else + if [[ -d "$VENV_DIR" ]]; then + echo "Bestehende .venv nutzt eine mit whisperx inkompatible Python-Version, wird neu angelegt..." + rm -rf "$VENV_DIR" + fi + + if ! PYTHON_BIN="$(find_compatible_python)"; then + cat >&2 <<'EOF' +Kein kompatibler Python-Interpreter (3.10/3.11/3.12) gefunden. +whisperx benötigt (über faster-whisper) ctranslate2 in einer Version, für die es +auf neueren Python-Versionen (3.13/3.14) noch keine Wheels gibt. + +Bitte auf Fedora nachinstallieren, z.B.: + sudo dnf install python3.12 + +Danach install.sh erneut ausführen, oder gezielt vorgeben mit: + PYTHON_BIN=python3.12 ./install.sh +EOF + exit 1 + fi + + echo "Erstelle virtuelle Umgebung mit $PYTHON_BIN ($("$PYTHON_BIN" --version)): $VENV_DIR" + "$PYTHON_BIN" -m venv "$VENV_DIR" +fi + +echo "Installiere Notarius und Python-Abhängigkeiten..." +"$VENV_DIR/bin/pip" install --upgrade pip --quiet +"$VENV_DIR/bin/pip" install -e "$PROJECT_DIR" + +echo "Richte Menü-Eintrag ein..." +"$VENV_DIR/bin/notarius-install-launcher" + +cat <=6.7", + "anthropic>=0.40", + "keyring>=25.0", + "whisperx", +] + +[project.scripts] +notarius = "notarius.__main__:main" +notarius-install-launcher = "notarius.menu_launcher:main" +notarius-rec = "notarius.rec_cli:main" + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +notarius = ["resources/*.md"] diff --git a/reference/call-recorder.sh b/reference/call-recorder.sh new file mode 100644 index 0000000..3ab4614 --- /dev/null +++ b/reference/call-recorder.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# ~/bin/call-recorder.sh +set -euo pipefail + +REC_DIR="$HOME" +PIDFILE="/tmp/call-recorder.pid" +LOGFILE="/tmp/call-recorder.log" + +start_recording() { + if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + notify-send "Call Recorder" "Läuft bereits (PID $(cat "$PIDFILE"))" + exit 1 + fi + + local sink source outfile + sink="$(pactl get-default-sink).monitor" + source="$(pactl get-default-source)" + outfile="${REC_DIR}/call_$(date +%Y-%m-%d_%H-%M-%S).mp3" + + ffmpeg -nostdin -y \ + -f pulse -i "$sink" \ + -f pulse -i "$source" \ + -filter_complex "[0:a]pan=mono|c0=0.5*c0+0.5*c1[browser];[1:a]pan=mono|c0=c0[mic];[browser][mic]amerge=inputs=2[aout]" \ + -map "[aout]" -ac 2 -c:a libmp3lame -q:a 2 \ + "$outfile" \ + > "$LOGFILE" 2>&1 & + + echo $! > "$PIDFILE" + notify-send "Call Recorder" "Aufnahme gestartet: $(basename "$outfile")" +} + +stop_recording() { + if [[ ! -f "$PIDFILE" ]]; then + notify-send "Call Recorder" "Keine laufende Aufnahme." + exit 1 + fi + local pid + pid="$(cat "$PIDFILE")" + kill -INT "$pid" 2>/dev/null || true + # ffmpeg braucht einen Moment, um den MP3-Frame sauber abzuschließen + for _ in {1..20}; do + kill -0 "$pid" 2>/dev/null || break + sleep 0.2 + done + rm -f "$PIDFILE" + notify-send "Call Recorder" "Aufnahme gestoppt." +} + +case "${1:-toggle}" in + start) start_recording ;; + stop) stop_recording ;; + toggle) if [[ -f "$PIDFILE" ]]; then stop_recording; else start_recording; fi ;; + *) echo "Usage: $0 {start|stop|toggle}"; exit 1 ;; +esac \ No newline at end of file diff --git a/reference/transkribiere_datei.py b/reference/transkribiere_datei.py new file mode 100644 index 0000000..977c1ce --- /dev/null +++ b/reference/transkribiere_datei.py @@ -0,0 +1,65 @@ +import whisperx +import gc +import os +import sys + +HF_TOKEN="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" +DEVICE = "cuda" +COMPUTE_TYPE = "float16" +BATCH_SIZE = 4 + +if len(sys.argv) < 2: + print("Verwendung: python transkribiere_datei.py [...]") + sys.exit(1) + +audio_files = sys.argv[1:] +print(f"{len(audio_files)} Datei(en) zu transkribieren\n") + +print("Lade Whisper large-v3...") +model = whisperx.load_model("large-v3", DEVICE, compute_type=COMPUTE_TYPE, language="de") + +print("Lade Diarization-Pipeline...") +diarize_model = whisperx.diarize.DiarizationPipeline(token=HF_TOKEN, device=DEVICE) + +for audio_path in audio_files: + base = os.path.splitext(audio_path)[0] + out_path = base + ".txt" + fname = os.path.basename(audio_path) + + print(f"\n{'='*60}") + print(f"Verarbeite: {fname}") + print(f"{'='*60}") + + audio = whisperx.load_audio(audio_path) + + print(" 1/4 Transkription...") + result = model.transcribe(audio, batch_size=BATCH_SIZE, language="de") + + print(" 2/4 Alignment...") + model_a, metadata = whisperx.load_align_model(language_code="de", device=DEVICE) + result = whisperx.align(result["segments"], model_a, metadata, audio, DEVICE, + return_char_alignments=False) + del model_a; gc.collect() + + print(" 3/4 Speaker Diarization...") + diarize_segments = diarize_model(audio) + result = whisperx.assign_word_speakers(diarize_segments, result) + + print(" 4/4 Speichere Transkript...") + with open(out_path, "w", encoding="utf-8") as f: + f.write(f"Transkript: {fname}\n") + f.write("=" * 60 + "\n\n") + current_speaker = None + for seg in result["segments"]: + speaker = seg.get("speaker", "UNBEKANNT") + text = seg["text"].strip() + start = seg["start"] + end = seg["end"] + if speaker != current_speaker: + f.write(f"\n[{speaker}]\n") + current_speaker = speaker + f.write(f" [{start:6.1f}s – {end:5.1f}s] {text}\n") + + print(f" -> Gespeichert: {out_path}") + +print("\nAlle Dateien fertig!") diff --git a/reference/transkribiere_datei.sh b/reference/transkribiere_datei.sh new file mode 100755 index 0000000..65cfd9f --- /dev/null +++ b/reference/transkribiere_datei.sh @@ -0,0 +1,5 @@ +source /home/timbfrank/whisperx-env/bin/activate +python /home/timbfrank/transkribiere_datei.py "$@" +echo "" +echo "Fertig! Fenster schließt in 10 Sekunden..." +sleep 10 diff --git a/reference/transkribieren.py b/reference/transkribieren.py new file mode 100644 index 0000000..9c149de --- /dev/null +++ b/reference/transkribieren.py @@ -0,0 +1,65 @@ +import whisperx +import gc +import os +import sys + +HF_TOKEN = "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" +DEVICE = "cuda" +COMPUTE_TYPE = "float16" +BATCH_SIZE = 4 + +if len(sys.argv) < 2: + print("Verwendung: python transkribiere_datei.py [...]") + sys.exit(1) + +audio_files = sys.argv[1:] +print(f"{len(audio_files)} Datei(en) zu transkribieren\n") + +print("Lade Whisper large-v3...") +model = whisperx.load_model("large-v3", DEVICE, compute_type=COMPUTE_TYPE, language="de") + +print("Lade Diarization-Pipeline...") +diarize_model = whisperx.diarize.DiarizationPipeline(token=HF_TOKEN, device=DEVICE) + +for audio_path in audio_files: + base = os.path.splitext(audio_path)[0] + out_path = base + ".txt" + fname = os.path.basename(audio_path) + + print(f"\n{'='*60}") + print(f"Verarbeite: {fname}") + print(f"{'='*60}") + + audio = whisperx.load_audio(audio_path) + + print(" 1/4 Transkription...") + result = model.transcribe(audio, batch_size=BATCH_SIZE, language="de") + + print(" 2/4 Alignment...") + model_a, metadata = whisperx.load_align_model(language_code="de", device=DEVICE) + result = whisperx.align(result["segments"], model_a, metadata, audio, DEVICE, + return_char_alignments=False) + del model_a; gc.collect() + + print(" 3/4 Speaker Diarization...") + diarize_segments = diarize_model(audio) + result = whisperx.assign_word_speakers(diarize_segments, result) + + print(" 4/4 Speichere Transkript...") + with open(out_path, "w", encoding="utf-8") as f: + f.write(f"Transkript: {fname}\n") + f.write("=" * 60 + "\n\n") + current_speaker = None + for seg in result["segments"]: + speaker = seg.get("speaker", "UNBEKANNT") + text = seg["text"].strip() + start = seg["start"] + end = seg["end"] + if speaker != current_speaker: + f.write(f"\n[{speaker}]\n") + current_speaker = speaker + f.write(f" [{start:6.1f}s – {end:5.1f}s] {text}\n") + + print(f" -> Gespeichert: {out_path}") + +print("\nAlle Dateien fertig!") diff --git a/reference/transkribieren_1.py b/reference/transkribieren_1.py new file mode 100644 index 0000000..7525609 --- /dev/null +++ b/reference/transkribieren_1.py @@ -0,0 +1,66 @@ +import whisperx +import gc +import os + +HF_TOKEN = "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" +DEVICE = "cuda" +COMPUTE_TYPE = "float16" +BATCH_SIZE = 4 + +AUDIO_DIR = os.path.expanduser( + "~/VRtual X Dropbox/Tim B. Frank/Apps/Tims Hermes/Projekte/battenfeld-cincinnati/2026-06-30_Audionotizen" +) +OUTPUT_DIR = os.path.expanduser("~/Transkripte/battenfeld-cincinnati") +os.makedirs(OUTPUT_DIR, exist_ok=True) + +files = sorted([f for f in os.listdir(AUDIO_DIR) if f.endswith(".mp3")]) +print(f"{len(files)} Dateien gefunden: {files}\n") + +print("Lade Whisper large-v3...") +model = whisperx.load_model("large-v3", DEVICE, compute_type=COMPUTE_TYPE, language="de") + +print("Lade Diarization-Pipeline...") +diarize_model = whisperx.diarize.DiarizationPipeline(token=HF_TOKEN, device=DEVICE) + +for fname in files: + audio_path = os.path.join(AUDIO_DIR, fname) + out_name = fname.replace(".mp3", ".txt") + out_path = os.path.join(OUTPUT_DIR, out_name) + + print(f"\n{'='*60}") + print(f"Verarbeite: {fname}") + print(f"{'='*60}") + + audio = whisperx.load_audio(audio_path) + + print(" 1/4 Transkription...") + result = model.transcribe(audio, batch_size=BATCH_SIZE, language="de") + + print(" 2/4 Alignment...") + model_a, metadata = whisperx.load_align_model(language_code="de", device=DEVICE) + result = whisperx.align(result["segments"], model_a, metadata, audio, DEVICE, + return_char_alignments=False) + del model_a; gc.collect() + + print(" 3/4 Speaker Diarization...") + diarize_segments = diarize_model(audio) + result = whisperx.assign_word_speakers(diarize_segments, result) + + print(" 4/4 Speichere Transkript...") + with open(out_path, "w", encoding="utf-8") as f: + f.write(f"Transkript: {fname}\n") + f.write("="*60 + "\n\n") + current_speaker = None + for seg in result["segments"]: + speaker = seg.get("speaker", "UNBEKANNT") + text = seg["text"].strip() + start = seg["start"] + end = seg["end"] + if speaker != current_speaker: + f.write(f"\n[{speaker}]\n") + current_speaker = speaker + f.write(f" [{start:6.1f}s – {end:5.1f}s] {text}\n") + + print(f" -> Gespeichert: {out_path}") + +print("\nAlle Dateien fertig!") diff --git a/reference/zusammenfassung_prompt.md b/reference/zusammenfassung_prompt.md new file mode 100644 index 0000000..4668565 --- /dev/null +++ b/reference/zusammenfassung_prompt.md @@ -0,0 +1,94 @@ + +HERMES: +Hier der vollständige Inhalt: + +--- + +Trigger-Keywords: +- transkript verarbeiten +- gesprächsprotokoll +- gespräch aufbereiten +- meeting zusammenfassen +- kundengespräch + +--- + +Pflicht: Fehlende Infos zuerst klären + +Bevor du mit der Verarbeitung beginnst, prüfe ob folgende Infos vorhanden sind. Wenn nicht → explizit nachfragen: + +- Datum des Gesprächs +- Gesprächsort / Kanal (Telefon, vor Ort, Video) +- Gesprächsteilnehmer (Name + Rolle / Unternehmen) — wer spricht für wen? +- Projektzuordnung (welches Projekt oder welche Kampagne?) +- Falls Personen im Transkript nicht namentlich zugeordnet sind: Wer ist wer? + +Erst wenn diese Infos vollständig sind, mit dem Template beginnen. + +--- + +Output-Template + +Ausgabe immer in sauberem Markdown. Dateiname-Vorschlag: YYYY-MM-DD_Gesprächsprotokoll_[Kundenname].md + +# Gesprächsprotokoll – [Kundenname / Projekt] + +--- + +## Basisinfos +- **Datum:** +- **Kanal:** (Telefon / vor Ort / Video) +- **Ort / Plattform:** +- **Teilnehmer:** + - [Name] – [Rolle], [Unternehmen] + - [Name] – [Rolle], [Unternehmen] +- **Projekt / Kontext:** +- **Gesprächstyp:** (Erstgespräch / Follow-up / Demo / Projektgespräch / Abschluss) + +--- + +## Gesprächszusammenfassung +[Fließtext, maximal zwei Absätze. Die wichtigsten Themen, Stimmung, roter Faden des Gesprächs. Kein Aufzählungs-Stil — echte Prosa.] + +--- + +## Entscheidungen & Fakten + +**Getroffene Entscheidungen:** +- [Entscheidung, ggf. mit wer entschieden hat] + +**Genannte Fakten & Rahmenbedingungen:** +- [Budget, Timeline, Teamgröße, technische Vorgaben, Vertragsinfos etc.] + +--- + +## ToDos & Nächste Schritte +- [ ] [Aufgabe] — [Person / Unternehmen] — [Datum falls genannt] +- [ ] [Aufgabe] — [Person / Unternehmen] — [Datum falls genannt] + +--- + +## Nebeninformationen *(intern, nicht zur Weitergabe)* +- [Interessante Kontextinfos, persönliche Details, Brancheninfos, die zufällig erwähnt wurden — nicht projektbezogen, aber möglicherweise nützlich] + + +--- + +Verarbeitungshinweise + +1. Transkript-Input: Kann roher Text, korrigiertes Transkript oder Stichpunkte sein. +2. Sprache: Immer auf Deutsch ausgeben, auch wenn das Transkript teilweise Englisch enthält. +3. Block 2: Kein Marketing-Sprech, kein „stell dir vor" — sachlich, direkt, wie Tim es formulieren würde. +4. Block 4: Format [ ] Aufgabe — Person/Unternehmen — Datum konsequent einhalten. Wenn keine Person zuordenbar → [ ] Aufgabe — unklar, klären +5. Block 5: Nur wirklich interessante Nebenfakten, keine Füller. Lieber leer lassen als auffüllen. +6. Datei speichern: Wenn möglich, als .md-Datei unter einem sinnvollen Pfad ablegen (z.B. Obsidian-Vault oder lokaler Projektordner). Tim entscheidet ob er sie weiterleitet. + +--- + +Typischer Workflow + +1. Tim gibt Transkript-Text (oder Datei) + ggf. Kontextinfos +2. Fehlende Pflichtinfos abfragen +3. 5-Block-Template befüllen +4. Als Markdown-Datei ausgeben (MEDIA-Link wenn lokal gespeichert) +5. Tim kann Block 5 für sich behalten und den Rest weitergeben diff --git a/src/notarius/__init__.py b/src/notarius/__init__.py new file mode 100644 index 0000000..4e95189 --- /dev/null +++ b/src/notarius/__init__.py @@ -0,0 +1,3 @@ +"""Notarius: lokale Aufnahme, Transkription und KI-Zusammenfassung von Gesprächen.""" + +__version__ = "0.1.0" diff --git a/src/notarius/__main__.py b/src/notarius/__main__.py new file mode 100644 index 0000000..d409abf --- /dev/null +++ b/src/notarius/__main__.py @@ -0,0 +1,13 @@ +"""Ermöglicht `python -m notarius` und den Konsolen-Befehl `notarius`.""" + +import sys + +from notarius.app import main as app_main + + +def main() -> None: + sys.exit(app_main()) + + +if __name__ == "__main__": + main() diff --git a/src/notarius/app.py b/src/notarius/app.py new file mode 100644 index 0000000..4ba7d82 --- /dev/null +++ b/src/notarius/app.py @@ -0,0 +1,57 @@ +"""Anwendungs-Bootstrap: Logging, QApplication, Tray-Icon, Hauptfenster. + +Beim Start erscheint bewusst kein Fenster - nur das Tray-Icon. Das Hauptfenster +wird lediglich erzeugt (damit seine Widgets/Zustände existieren) und bleibt +verborgen, bis der Nutzer "Open" im Tray-Menü wählt. +""" + +from __future__ import annotations + +import logging +import sys + +from PySide6.QtWidgets import QApplication, QSystemTrayIcon + +from notarius.gui.controller import AppController +from notarius.gui.main_window import MainWindow +from notarius.gui.tray import TrayIcon +from notarius.logging_setup import configure_logging +from notarius.paths import ensure_app_dirs + +logger = logging.getLogger(__name__) + + +def main() -> int: + configure_logging() + ensure_app_dirs() + + app = QApplication(sys.argv) + # Schließen des Hauptfensters darf die Anwendung nicht beenden - sie lebt im Tray weiter. + app.setQuitOnLastWindowClosed(False) + app.setApplicationName("Notarius") + + if not QSystemTrayIcon.isSystemTrayAvailable(): + logger.warning("Kein System-Tray verfügbar - Notarius benötigt eine Desktop-Umgebung mit Tray-Unterstützung") + + controller = AppController() + controller.config.recordings_path.mkdir(parents=True, exist_ok=True) + + main_window = MainWindow(controller) + + tray = TrayIcon(controller) + tray.open_action.triggered.connect(lambda: _show_main_window(main_window)) + tray.show() + + logger.info("Notarius gestartet") + return app.exec() + + +def _show_main_window(main_window: MainWindow) -> None: + main_window.show_recordings_tab() + main_window.show() + main_window.raise_() + main_window.activateWindow() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/notarius/audio/__init__.py b/src/notarius/audio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/notarius/audio/devices.py b/src/notarius/audio/devices.py new file mode 100644 index 0000000..4303edd --- /dev/null +++ b/src/notarius/audio/devices.py @@ -0,0 +1,40 @@ +"""Ermittlung der PulseAudio/PipeWire-Standardgeräte über pactl. + +Wird gebraucht, um den Mix aus System-Audio-Ausgabe (Sink-Monitor) und +Mikrofon (Source) zusammenzustellen, siehe recorder.py. +""" + +from __future__ import annotations + +import subprocess + + +class AudioDeviceError(RuntimeError): + """Wird geworfen, wenn pactl nicht erreichbar ist oder kein Standardgerät liefert.""" + + +def _run_pactl(*args: str) -> str: + try: + result = subprocess.run( + ["pactl", *args], capture_output=True, text=True, check=True + ) + except FileNotFoundError as exc: + raise AudioDeviceError("pactl ist nicht installiert oder nicht im PATH") from exc + except subprocess.CalledProcessError as exc: + raise AudioDeviceError(f"pactl {' '.join(args)} fehlgeschlagen: {exc.stderr.strip()}") from exc + + output = result.stdout.strip() + if not output: + raise AudioDeviceError(f"pactl {' '.join(args)} lieferte keine Ausgabe") + return output + + +def get_default_sink_monitor() -> str: + """Liefert den Monitor-Quellnamen des Standard-Ausgabegeräts (System-Audio).""" + sink = _run_pactl("get-default-sink") + return f"{sink}.monitor" + + +def get_default_source() -> str: + """Liefert den Namen der Standard-Eingabequelle (Mikrofon).""" + return _run_pactl("get-default-source") diff --git a/src/notarius/audio/ffprobe.py b/src/notarius/audio/ffprobe.py new file mode 100644 index 0000000..5b43fef --- /dev/null +++ b/src/notarius/audio/ffprobe.py @@ -0,0 +1,35 @@ +"""Kleiner Wrapper um ffprobe zur Längenermittlung von Audiodateien.""" + +from __future__ import annotations + +import logging +import subprocess +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def probe_duration_seconds(audio_path: Path) -> float: + """Ermittelt die Länge einer Audiodatei in Sekunden via ffprobe. + + Gibt 0.0 zurück, wenn ffprobe die Datei nicht auswerten kann, statt + abzustürzen - die Länge ist reine Anzeige-Information und darf das + Hinzufügen einer Datei nicht verhindern. + """ + try: + result = subprocess.run( + [ + "ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + str(audio_path), + ], + capture_output=True, + text=True, + check=True, + ) + return float(result.stdout.strip()) + except (subprocess.CalledProcessError, ValueError, FileNotFoundError): + logger.exception("ffprobe konnte die Länge von %s nicht ermitteln", audio_path) + return 0.0 diff --git a/src/notarius/audio/lock.py b/src/notarius/audio/lock.py new file mode 100644 index 0000000..eaff5de --- /dev/null +++ b/src/notarius/audio/lock.py @@ -0,0 +1,87 @@ +"""PID-Lock-Datei für die aktuell laufende Aufnahme. + +Macht den Aufnahmestatus prozessübergreifend sichtbar: sowohl das laufende +Notarius-GUI (Tray-Icon, Tab 1) als auch der eigenständige `notarius-rec`-Befehl +(z.B. für einen KDE-Tastenkurzbefehl, ganz ohne laufende GUI) lesen/schreiben +dieselbe Datei und können sich so nicht gegenseitig in einen inkonsistenten +Zustand bringen. Entspricht fachlich dem PIDFILE-Ansatz aus reference/call-recorder.sh. +""" + +from __future__ import annotations + +import json +import logging +import os +import signal +import time +from dataclasses import dataclass + +from notarius.paths import APP_CONFIG_DIR + +logger = logging.getLogger(__name__) + +LOCK_FILE = APP_CONFIG_DIR / "recording.lock" + +# Wie lange nach SIGINT auf einen sauberen ffmpeg-Exit gewartet wird, bevor +# hart abgebrochen wird (SIGINT lässt ffmpeg den MP3-Frame korrekt abschließen). +_GRACEFUL_STOP_TIMEOUT = 5.0 +_POLL_INTERVAL = 0.2 + + +@dataclass +class RecordingLock: + pid: int + base_name: str + + +def write(pid: int, base_name: str) -> None: + APP_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + LOCK_FILE.write_text(json.dumps({"pid": pid, "base_name": base_name}), encoding="utf-8") + + +def read() -> RecordingLock | None: + if not LOCK_FILE.exists(): + return None + try: + raw = json.loads(LOCK_FILE.read_text(encoding="utf-8")) + return RecordingLock(pid=raw["pid"], base_name=raw["base_name"]) + except (OSError, json.JSONDecodeError, KeyError): + logger.exception("Lock-Datei konnte nicht gelesen werden, wird ignoriert") + return None + + +def clear() -> None: + LOCK_FILE.unlink(missing_ok=True) + + +def is_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True # Prozess existiert, gehört aber z.B. einem anderen Nutzer. + return True + + +def terminate(pid: int) -> None: + """Beendet den Aufnahmeprozess sauber (SIGINT) und wartet, bis er tatsächlich + beendet ist. Nutzt Polling statt Popen.wait(), da der Aufrufer (z.B. ein frisch + gestarteter `notarius-rec`-Prozess) nicht der Elternprozess von ffmpeg sein muss. + """ + try: + os.kill(pid, signal.SIGINT) + except ProcessLookupError: + return # Bereits beendet. + + deadline = time.monotonic() + _GRACEFUL_STOP_TIMEOUT + while time.monotonic() < deadline: + if not is_alive(pid): + return + time.sleep(_POLL_INTERVAL) + + logger.warning("Aufnahmeprozess (PID %s) reagierte nicht auf SIGINT, erzwinge Abbruch", pid) + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass diff --git a/src/notarius/audio/recorder.py b/src/notarius/audio/recorder.py new file mode 100644 index 0000000..e47bf40 --- /dev/null +++ b/src/notarius/audio/recorder.py @@ -0,0 +1,78 @@ +"""Audiomitschnitt von Videokonferenzen: System-Audio + Mikrofon gemischt. + +Fachliche Logik entspricht reference/call-recorder.sh (Sink-Monitor + Source +gemischt über ffmpeg), hier aber als eigenständige Python-Klasse statt eines +eingebundenen Shell-Skripts. + +Startet den ffmpeg-Prozess nur; die Steuerung (Stoppen per PID, Lock-Datei) +übernimmt notarius.audio.lock, da ein Stopp auch aus einem völlig anderen +Prozess ausgelöst werden kann (z.B. `notarius-rec stop` über einen +KDE-Kurzbefehl, ohne dass die Notarius-GUI läuft). +""" + +from __future__ import annotations + +import logging +import subprocess +from pathlib import Path + +from notarius.audio import devices + +logger = logging.getLogger(__name__) + + +class RecorderError(RuntimeError): + """Fehler beim Starten der Aufnahme.""" + + +class CallRecorder: + """Startet genau einen ffmpeg-Aufnahmeprozess und gibt dessen PID preis.""" + + def __init__(self) -> None: + self._process: subprocess.Popen[bytes] | None = None + + @property + def pid(self) -> int | None: + return self._process.pid if self._process else None + + def start(self, output_path: Path) -> None: + """Startet die Aufnahme in `output_path` (üblicherweise eine .mp3-Datei). + + Wirft RecorderError, wenn kein Standard-Sink/-Source über pactl ermittelt + werden kann oder ffmpeg nicht gestartet werden konnte. + """ + try: + sink_monitor = devices.get_default_sink_monitor() + source = devices.get_default_source() + except devices.AudioDeviceError as exc: + raise RecorderError(f"Audiogeräte konnten nicht ermittelt werden: {exc}") from exc + + # [0:a] = System-Audio (Sink-Monitor), [1:a] = Mikrofon (Source). + # Beide werden auf Mono heruntergemischt und dann zu einem Stereo-Stream gemerged, + # damit beide Quellen in der Aufnahme gleichzeitig hörbar sind. + filter_complex = ( + "[0:a]pan=mono|c0=0.5*c0+0.5*c1[system];" + "[1:a]pan=mono|c0=c0[mic];" + "[system][mic]amerge=inputs=2[aout]" + ) + cmd = [ + "ffmpeg", "-nostdin", "-y", + "-f", "pulse", "-i", sink_monitor, + "-f", "pulse", "-i", source, + "-filter_complex", filter_complex, + "-map", "[aout]", "-ac", "2", + "-c:a", "libmp3lame", "-q:a", "2", + str(output_path), + ] + + try: + # start_new_session löst den Prozess von der Prozessgruppe des Aufrufers + # ab: notarius-rec darf sich sofort beenden, ffmpeg läuft unabhängig + # davon weiter (analog zum "ffmpeg ... &" in reference/call-recorder.sh). + self._process = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True + ) + except OSError as exc: + raise RecorderError(f"ffmpeg konnte nicht gestartet werden: {exc}") from exc + + logger.info("ffmpeg gestartet: %s (PID %s)", output_path, self._process.pid) diff --git a/src/notarius/autostart.py b/src/notarius/autostart.py new file mode 100644 index 0000000..65faa30 --- /dev/null +++ b/src/notarius/autostart.py @@ -0,0 +1,41 @@ +"""Verwaltung des Autostart-Eintrags (~/.config/autostart/notarius.desktop). + +Wird ausschließlich über den Schalter in Tab 5 (Settings) angesteuert. +""" + +from __future__ import annotations + +import logging +import sys + +from notarius.paths import AUTOSTART_DESKTOP_FILE + +logger = logging.getLogger(__name__) + +_DESKTOP_ENTRY_TEMPLATE = """[Desktop Entry] +Type=Application +Name=Notarius +Comment=Aufnahme, Transkription und Zusammenfassung von Gesprächen +Exec={python_executable} -m notarius +Icon=audio-input-microphone +Terminal=false +X-GNOME-Autostart-enabled=true +""" + + +def enable_autostart() -> None: + """Schreibt den Autostart-Eintrag. Startet Notarius künftig minimiert im Tray.""" + AUTOSTART_DESKTOP_FILE.parent.mkdir(parents=True, exist_ok=True) + content = _DESKTOP_ENTRY_TEMPLATE.format(python_executable=sys.executable) + AUTOSTART_DESKTOP_FILE.write_text(content, encoding="utf-8") + logger.info("Autostart aktiviert (%s)", AUTOSTART_DESKTOP_FILE) + + +def disable_autostart() -> None: + """Entfernt den Autostart-Eintrag, falls vorhanden.""" + AUTOSTART_DESKTOP_FILE.unlink(missing_ok=True) + logger.info("Autostart deaktiviert") + + +def is_autostart_enabled() -> bool: + return AUTOSTART_DESKTOP_FILE.exists() diff --git a/src/notarius/config.py b/src/notarius/config.py new file mode 100644 index 0000000..ab4172c --- /dev/null +++ b/src/notarius/config.py @@ -0,0 +1,63 @@ +"""Laden und Speichern der Programmkonfiguration (config.json). + +Enthält ausdrücklich KEINE Zugangsdaten. Der Anthropic-API-Key wird separat +über notarius.keyring_store verwaltet und landet nie in dieser Datei. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import asdict, dataclass +from pathlib import Path + +from notarius.paths import CONFIG_FILE, DEFAULT_RECORDINGS_DIR, ensure_app_dirs + +logger = logging.getLogger(__name__) + +# Default-Modell für die KI-Zusammenfassung. Über Tab 5 (Settings) änderbar, +# damit ein zukünftiger Modellwechsel keine Codeänderung erfordert. +DEFAULT_CLAUDE_MODEL = "claude-sonnet-5" + + +@dataclass +class AppConfig: + """Struktur von ~/.config/notarius/config.json.""" + + claude_model: str = DEFAULT_CLAUDE_MODEL + autostart_enabled: bool = False + recordings_dir: str = str(DEFAULT_RECORDINGS_DIR) + + @property + def recordings_path(self) -> Path: + return Path(self.recordings_dir).expanduser() + + +def load_config() -> AppConfig: + """Lädt die Konfiguration von der Festplatte oder erzeugt eine Default-Konfiguration. + + Unbekannte oder fehlende Felder in einer vorhandenen (evtl. älteren) config.json + werden stillschweigend mit den Defaults aufgefüllt, damit ein manuelles Editieren + oder ein zukünftiges Schema-Update nicht zum Absturz führt. + """ + if not CONFIG_FILE.exists(): + return AppConfig() + + try: + raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + logger.exception("config.json konnte nicht gelesen werden, verwende Defaults") + return AppConfig() + + defaults = asdict(AppConfig()) + defaults.update({k: v for k, v in raw.items() if k in defaults}) + return AppConfig(**defaults) + + +def save_config(cfg: AppConfig) -> None: + """Schreibt die Konfiguration atomar auf die Festplatte.""" + ensure_app_dirs() + tmp_path = CONFIG_FILE.with_suffix(".json.tmp") + tmp_path.write_text(json.dumps(asdict(cfg), indent=2, ensure_ascii=False), encoding="utf-8") + tmp_path.replace(CONFIG_FILE) + logger.info("Konfiguration gespeichert (%s)", CONFIG_FILE) diff --git a/src/notarius/gui/__init__.py b/src/notarius/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/notarius/gui/controller.py b/src/notarius/gui/controller.py new file mode 100644 index 0000000..841fb48 --- /dev/null +++ b/src/notarius/gui/controller.py @@ -0,0 +1,115 @@ +"""Zentraler Anwendungszustand, geteilt zwischen Tray-Icon und Hauptfenster. + +Die eigentliche Aufnahmesteuerung liegt in notarius.recording_session (Lock-Datei- +basiert, prozessunabhängig) - dieselbe Logik, die auch der eigenständige +`notarius-rec`-Befehl (z.B. für einen KDE-Tastenkurzbefehl) verwendet. Ein +Poll-Timer erkennt daher auch Start/Stop-Aktionen, die nicht über das +Tray-Menü, sondern extern (Tastenkurzbefehl) ausgelöst wurden, und hält +Tray-Icon und Tab 1 synchron. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from PySide6.QtCore import QObject, QTimer, Signal + +from notarius import recording_session +from notarius.config import AppConfig, load_config, save_config +from notarius.models.recording import Recording, import_audio_file, list_recordings + +logger = logging.getLogger(__name__) + +# Wie oft geprüft wird, ob sich der Aufnahmestatus extern (z.B. über einen +# KDE-Tastenkurzbefehl) geändert hat. Eine Sekunde ist für ein paar Mal am Tag +# genutztes Tool responsiv genug und vernachlässigbar günstig. +_POLL_INTERVAL_MS = 1000 + + +class AppController(QObject): + # True während eine Aufnahme läuft, sonst False. Wird vom Tray-Icon + # verwendet, um Menütext/Icon umzuschalten. + recording_state_changed = Signal(bool) + + # Aufnahmenliste hat sich geändert (neue Aufnahme, Import, Löschung) -> Tab 1 neu laden. + recordings_changed = Signal() + + # (Titel, Nachricht, ist_fehler) - für Desktop-Benachrichtigungen über das Tray-Icon. + notification_requested = Signal(str, str, bool) + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self.config: AppConfig = load_config() + self._is_recording_cached = False + + self._poll_timer = QTimer(self) + self._poll_timer.setInterval(_POLL_INTERVAL_MS) + self._poll_timer.timeout.connect(self._poll_recording_state) + self._poll_timer.start() + self._poll_recording_state() + + # -- Aufnahme ------------------------------------------------------------------ + + @property + def is_recording(self) -> bool: + return self._is_recording_cached + + def toggle_recording(self) -> None: + if self.is_recording: + self.stop_recording() + else: + self.start_recording() + + def start_recording(self) -> None: + try: + recording = recording_session.start() + except Exception as exc: # noqa: BLE001 - jeder Fehler muss dem Nutzer angezeigt werden + logger.error("Aufnahme konnte nicht gestartet werden: %s", exc) + self.notification_requested.emit("Aufnahme fehlgeschlagen", str(exc), True) + self._poll_recording_state() + return + + self.recordings_changed.emit() + self.notification_requested.emit("Notarius", f"Aufnahme gestartet: {recording.meta.title}", False) + self._poll_recording_state() + + def stop_recording(self) -> None: + recording = recording_session.stop() + self.recordings_changed.emit() + if recording is not None: + self.notification_requested.emit("Notarius", "Aufnahme gestoppt.", False) + self._poll_recording_state() + + def _poll_recording_state(self) -> None: + now_recording = recording_session.is_recording() + if now_recording == self._is_recording_cached: + return + + self._is_recording_cached = now_recording + self.recording_state_changed.emit(now_recording) + if not now_recording: + # Eine extern (z.B. per Tastenkurzbefehl) beendete Aufnahme hat jetzt + # ihre finale Dauer - Tab 1 soll das zeigen, auch ohne eigenes Zutun. + self.recordings_changed.emit() + + # -- Aufnahmenverwaltung --------------------------------------------------------- + + def list_recordings(self) -> list[Recording]: + return list_recordings(self.config.recordings_path) + + def import_audio_file(self, source_path: Path) -> Recording: + recordings_dir = self.config.recordings_path + recordings_dir.mkdir(parents=True, exist_ok=True) + recording = import_audio_file(recordings_dir, source_path) + self.recordings_changed.emit() + return recording + + def delete_recording(self, recording: Recording) -> None: + recording.delete_all_files() + self.recordings_changed.emit() + + # -- Konfiguration --------------------------------------------------------------- + + def save_config(self) -> None: + save_config(self.config) diff --git a/src/notarius/gui/format_utils.py b/src/notarius/gui/format_utils.py new file mode 100644 index 0000000..6917ce4 --- /dev/null +++ b/src/notarius/gui/format_utils.py @@ -0,0 +1,21 @@ +"""Gemeinsame Formatierungshelfer für die GUI (Dauer- und Zeitstempelanzeige).""" + +from __future__ import annotations + +from datetime import datetime + + +def format_duration(seconds: float) -> str: + total_seconds = max(0, int(seconds)) + hours, remainder = divmod(total_seconds, 3600) + minutes, secs = divmod(remainder, 60) + if hours: + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + return f"{minutes:02d}:{secs:02d}" + + +def format_timestamp(iso_timestamp: str) -> str: + try: + return datetime.fromisoformat(iso_timestamp).strftime("%d.%m.%Y %H:%M:%S") + except ValueError: + return iso_timestamp diff --git a/src/notarius/gui/main_window.py b/src/notarius/gui/main_window.py new file mode 100644 index 0000000..8078ba6 --- /dev/null +++ b/src/notarius/gui/main_window.py @@ -0,0 +1,98 @@ +"""Hauptfenster: fünf Tabs, deren Aktivierung von Auswahl & Verarbeitungsstand abhängt. + +Tab-Aktivierungslogik (siehe Projektspezifikation): +- Tab 1 (Recordings) und Tab 5 (Settings) sind immer wählbar. +- Tab 2 (Transcript) ist nur aktiv, wenn in Tab 1 eine Aufnahme gewählt wurde. +- Tab 3 (Processing) ist nur aktiv, wenn zur gewählten Aufnahme ein Transkript existiert. +- Tab 4 (Summary) ist nur aktiv, wenn zur gewählten Aufnahme eine Summary existiert. +Die Existenzprüfungen laufen direkt über das Dateisystem (Recording.has_transcript/ +has_summary), es gibt keinen separat gepflegten Verarbeitungsstatus. +""" + +from __future__ import annotations + +from PySide6.QtWidgets import QMainWindow, QTabWidget + +from notarius.gui.controller import AppController +from notarius.gui.tabs.processing_tab import ProcessingTab +from notarius.gui.tabs.recordings_tab import RecordingsTab +from notarius.gui.tabs.settings_tab import SettingsTab +from notarius.gui.tabs.summary_tab import SummaryTab +from notarius.gui.tabs.transcript_tab import TranscriptTab +from notarius.models.recording import Recording + +_TAB_RECORDINGS = 0 +_TAB_TRANSCRIPT = 1 +_TAB_PROCESSING = 2 +_TAB_SUMMARY = 3 +_TAB_SETTINGS = 4 + + +class MainWindow(QMainWindow): + def __init__(self, controller: AppController) -> None: + super().__init__() + self.setWindowTitle("Notarius") + self.resize(900, 700) + + self._controller = controller + self._selected_recording: Recording | None = None + + self._recordings_tab = RecordingsTab(controller) + self._transcript_tab = TranscriptTab(controller) + self._processing_tab = ProcessingTab(controller) + self._summary_tab = SummaryTab() + self._settings_tab = SettingsTab(controller) + + self._tabs = QTabWidget() + self._tabs.addTab(self._recordings_tab, "Recordings") + self._tabs.addTab(self._transcript_tab, "Transcript") + self._tabs.addTab(self._processing_tab, "Processing") + self._tabs.addTab(self._summary_tab, "Summary") + self._tabs.addTab(self._settings_tab, "Settings") + self.setCentralWidget(self._tabs) + + self._recordings_tab.recording_selected.connect(self._on_recording_selected) + self._transcript_tab.transcription_finished.connect(self._on_transcription_finished) + self._processing_tab.summarization_finished.connect(self._on_summarization_finished) + + self._update_tab_states() + + def _on_recording_selected(self, recording: Recording | None) -> None: + self._selected_recording = recording + self._transcript_tab.set_recording(recording) + self._processing_tab.set_recording(recording) + self._summary_tab.set_recording(recording) + self._update_tab_states() + + if recording is not None: + # "Klick auf eine Aufnahme öffnet automatisch Tab 2." + self._tabs.setCurrentIndex(_TAB_TRANSCRIPT) + + def _on_transcription_finished(self) -> None: + self._update_tab_states() + self._processing_tab.set_recording(self._selected_recording) + + def _on_summarization_finished(self) -> None: + self._summary_tab.set_recording(self._selected_recording) + self._update_tab_states() + # "Nach erfolgreichem Abschluss öffnet sich automatisch Tab 4." + self._tabs.setCurrentIndex(_TAB_SUMMARY) + + def _update_tab_states(self) -> None: + recording = self._selected_recording + has_recording = recording is not None + has_transcript = has_recording and recording.has_transcript + has_summary = has_recording and recording.has_summary + + self._tabs.setTabEnabled(_TAB_TRANSCRIPT, has_recording) + self._tabs.setTabEnabled(_TAB_PROCESSING, has_transcript) + self._tabs.setTabEnabled(_TAB_SUMMARY, has_summary) + + def show_recordings_tab(self) -> None: + """Wird beim Öffnen über das Tray-Menü angesprungen (Default-Ansicht).""" + self._tabs.setCurrentIndex(_TAB_RECORDINGS) + + def closeEvent(self, event) -> None: # noqa: N802 - Qt-Methodenname + # Fenster schließen soll das Programm NICHT beenden - nur "Quit" im Tray-Menü tut das. + event.ignore() + self.hide() diff --git a/src/notarius/gui/tabs/__init__.py b/src/notarius/gui/tabs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/notarius/gui/tabs/processing_tab.py b/src/notarius/gui/tabs/processing_tab.py new file mode 100644 index 0000000..90d2fb6 --- /dev/null +++ b/src/notarius/gui/tabs/processing_tab.py @@ -0,0 +1,142 @@ +"""Tab 3 - Processing: Instructions/Notes editieren, KI-Zusammenfassung anstoßen. + +Ausgegraut, solange kein Transkript existiert (siehe MainWindow-Tab-Aktivierungslogik). +""" + +from __future__ import annotations + +import logging + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QHBoxLayout, QLabel, QMessageBox, QPushButton, QTextEdit, QVBoxLayout, QWidget + +from notarius.gui.controller import AppController +from notarius.gui.format_utils import format_duration, format_timestamp +from notarius.keyring_store import get_api_key +from notarius.models.recording import Recording +from notarius.summarization.worker import SummarizationWorker + +logger = logging.getLogger(__name__) + + +class ProcessingTab(QWidget): + # MainWindow öffnet daraufhin automatisch Tab 4 und prüft die Tab-Aktivierung neu. + summarization_finished = Signal() + + def __init__(self, controller: AppController, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._controller = controller + self._recording: Recording | None = None + self._worker = SummarizationWorker() + + self._header_label = QLabel() + self._header_label.setWordWrap(True) + + self._notes_edit = QTextEdit() + self._notes_edit.setPlaceholderText( + "Zusatzinfos für die KI: Zuordnung SPEAKER_00 -> Name, Projektkontext, " + "Teilnehmer, Kanal, Datum, ..." + ) + self._notes_save_button = QPushButton("Save") + self._notes_save_button.clicked.connect(self._on_notes_save_clicked) + + self._summarize_button = QPushButton("Summarize") + self._summarize_button.clicked.connect(self._on_summarize_clicked) + self._status_label = QLabel("idle") + + action_row = QHBoxLayout() + action_row.addWidget(self._summarize_button) + action_row.addWidget(self._status_label) + action_row.addStretch(1) + + layout = QVBoxLayout(self) + layout.addWidget(self._header_label) + layout.addWidget(QLabel("Instructions and Notes")) + layout.addWidget(self._notes_edit, stretch=1) + layout.addWidget(self._notes_save_button) + layout.addLayout(action_row) + + self.set_recording(None) + + def set_recording(self, recording: Recording | None) -> None: + self._recording = recording + has_recording = recording is not None + self.setEnabled(has_recording) + if not has_recording: + return + + self._header_label.setText( + f"Titel: {recording.meta.title}
" + f"Aufzeichnungszeitpunkt: {format_timestamp(recording.meta.created_at)}
" + f"Länge: {format_duration(recording.meta.duration_seconds)}
" + f"Sprecher: {', '.join(recording.meta.speakers) or '-'}" + ) + + if recording.prompt_notes_path.exists(): + self._notes_edit.setPlainText(recording.prompt_notes_path.read_text(encoding="utf-8")) + else: + self._notes_edit.clear() + + self._status_label.setText("ready" if recording.has_summary else "idle") + self._summarize_button.setText("Summarize") + self._summarize_button.setEnabled(True) + + def _on_notes_save_clicked(self) -> None: + if self._recording is None: + return + self._recording.prompt_notes_path.write_text(self._notes_edit.toPlainText(), encoding="utf-8") + + def _on_summarize_clicked(self) -> None: + if self._worker.is_running: + self._worker.cancel() + return + + if self._recording is None: + return + + api_key = get_api_key() + if not api_key: + QMessageBox.information( + self, "Kein API-Key", "Bitte zuerst in Tab 5 (Settings) einen Anthropic-API-Key hinterlegen." + ) + return + + if self._recording.has_summary: + answer = QMessageBox.question( + self, "Summary überschreiben?", "Es existiert bereits eine Summary. Überschreiben?" + ) + if answer != QMessageBox.StandardButton.Yes: + return + + transcript_markdown = self._recording.transcript_path.read_text(encoding="utf-8") + notes = self._notes_edit.toPlainText() + + self._summarize_button.setText("Stop") + self._status_label.setText("processing") + + self._worker.start( + api_key, + self._controller.config.claude_model, + transcript_markdown, + notes, + self._on_summary_ok, + self._on_summary_error, + self._on_summary_cancelled, + ) + + def _on_summary_ok(self, summary_markdown: str) -> None: + if self._recording is None: + return + self._recording.summary_path.write_text(summary_markdown, encoding="utf-8") + self._summarize_button.setText("Summarize") + self._status_label.setText("ready") + self.summarization_finished.emit() + + def _on_summary_error(self, error_message: str) -> None: + self._summarize_button.setText("Summarize") + self._status_label.setText("error") + QMessageBox.critical(self, "Zusammenfassung fehlgeschlagen", error_message) + + def _on_summary_cancelled(self) -> None: + self._summarize_button.setText("Summarize") + self._status_label.setText("idle") diff --git a/src/notarius/gui/tabs/recordings_tab.py b/src/notarius/gui/tabs/recordings_tab.py new file mode 100644 index 0000000..dd8ad82 --- /dev/null +++ b/src/notarius/gui/tabs/recordings_tab.py @@ -0,0 +1,91 @@ +"""Tab 1 - Recordings: chronologische Liste aller Aufnahmen, Default-Ansicht beim Öffnen.""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QFileDialog, QListWidget, QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QWidget + +from notarius.gui.controller import AppController +from notarius.gui.format_utils import format_duration, format_timestamp +from notarius.models.recording import Recording + +_AUDIO_FILE_FILTER = "Audiodateien (*.mp3 *.wav *.m4a *.ogg *.flac);;Alle Dateien (*)" + + +class RecordingsTab(QWidget): + recording_selected = Signal(object) # Recording | None + + def __init__(self, controller: AppController, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._controller = controller + self._recordings: list[Recording] = [] + + self._list_widget = QListWidget() + self._list_widget.currentRowChanged.connect(self._on_current_row_changed) + + self._add_button = QPushButton("Add…") + self._add_button.clicked.connect(self._on_add_clicked) + + layout = QVBoxLayout(self) + layout.addWidget(self._list_widget, stretch=1) + layout.addWidget(self._add_button) + + controller.recordings_changed.connect(self.refresh) + self.refresh() + + def refresh(self) -> None: + """Lädt die Liste neu ein, neueste Aufnahme oben, und versucht die Auswahl zu erhalten. + + Bleibt dieselbe Aufnahme ausgewählt (z.B. weil nur ihr Titel geändert wurde), + wird recording_selected bewusst NICHT erneut ausgelöst: Tab 2-4 würden sonst + ihren Inhalt (inkl. evtl. ungespeicherter Freitext-Änderungen) neu laden. + """ + previously_selected = self.current_recording() + previous_base_name = previously_selected.base_name if previously_selected else None + + self._recordings = self._controller.list_recordings() + + new_row = -1 + if previous_base_name is not None: + for row, recording in enumerate(self._recordings): + if recording.base_name == previous_base_name: + new_row = row + break + + self._list_widget.blockSignals(True) + self._list_widget.clear() + for recording in self._recordings: + self._list_widget.addItem(QListWidgetItem(self._format_item_text(recording))) + self._list_widget.setCurrentRow(new_row) + self._list_widget.blockSignals(False) + + if new_row == -1 and previous_base_name is not None: + # Die zuvor ausgewählte Aufnahme existiert nicht mehr (z.B. gelöscht). + self.recording_selected.emit(None) + + def current_recording(self) -> Recording | None: + row = self._list_widget.currentRow() + if 0 <= row < len(self._recordings): + return self._recordings[row] + return None + + @staticmethod + def _format_item_text(recording: Recording) -> str: + duration = format_duration(recording.meta.duration_seconds) + created = format_timestamp(recording.meta.created_at) + return f"{recording.meta.title} [{duration}] {created}" + + def _on_current_row_changed(self, row: int) -> None: + recording = self._recordings[row] if 0 <= row < len(self._recordings) else None + self.recording_selected.emit(recording) + + def _on_add_clicked(self) -> None: + file_path, _ = QFileDialog.getOpenFileName(self, "Audiodatei hinzufügen", "", _AUDIO_FILE_FILTER) + if not file_path: + return + try: + self._controller.import_audio_file(Path(file_path)) + except OSError as exc: + QMessageBox.critical(self, "Fehler", f"Datei konnte nicht hinzugefügt werden:\n{exc}") diff --git a/src/notarius/gui/tabs/settings_tab.py b/src/notarius/gui/tabs/settings_tab.py new file mode 100644 index 0000000..87ef1de --- /dev/null +++ b/src/notarius/gui/tabs/settings_tab.py @@ -0,0 +1,146 @@ +"""Tab 5 - Settings: API-Key/HF-Token (Keyring), Modell-Auswahl, Autostart. + +Der API-Key und der HuggingFace-Token werden ausschließlich im System-Keyring +gespeichert - niemals in config.json, niemals im Quellcode. Die Eingabefelder +zeigen einen vorhandenen Wert daher nie im Klartext an, sondern nur einen +Platzhalter-Hinweis; zum Ändern muss ein neuer Wert eingegeben werden. +""" + +from __future__ import annotations + +from PySide6.QtCore import QThread, Signal +from PySide6.QtWidgets import ( + QCheckBox, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, +) + +from notarius import autostart, keyring_store +from notarius.gui.controller import AppController +from notarius.summarization.client import validate_api_key + + +class _ValidateApiKeyThread(QThread): + ok = Signal() + error = Signal(str) + + def __init__(self, api_key: str, parent=None) -> None: + super().__init__(parent) + self._api_key = api_key + + def run(self) -> None: + try: + validate_api_key(self._api_key) + except Exception as exc: # noqa: BLE001 - jeder Fehler soll dem Nutzer angezeigt werden + self.error.emit(str(exc)) + else: + self.ok.emit() + + +class SettingsTab(QWidget): + def __init__(self, controller: AppController, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._controller = controller + self._validate_thread: _ValidateApiKeyThread | None = None + + self._api_key_edit = QLineEdit() + self._api_key_edit.setEchoMode(QLineEdit.EchoMode.Password) + self._test_button = QPushButton("Testen") + self._test_button.clicked.connect(self._on_test_clicked) + self._test_status_label = QLabel() + + self._hf_token_edit = QLineEdit() + self._hf_token_edit.setEchoMode(QLineEdit.EchoMode.Password) + + self._model_edit = QLineEdit() + + self._autostart_checkbox = QCheckBox("Notarius beim Login automatisch starten") + + self._save_button = QPushButton("Save") + self._save_button.clicked.connect(self._on_save_clicked) + + api_key_row = QHBoxLayout() + api_key_row.addWidget(self._api_key_edit) + api_key_row.addWidget(self._test_button) + + form = QFormLayout() + form.addRow("Anthropic-API-Key:", api_key_row) + form.addRow("", self._test_status_label) + form.addRow("HuggingFace-Token (Diarization):", self._hf_token_edit) + form.addRow("Claude-Modell:", self._model_edit) + form.addRow(self._autostart_checkbox) + + layout = QVBoxLayout(self) + layout.addLayout(form) + layout.addWidget(self._save_button) + layout.addStretch(1) + + self._load_current_values() + + def _load_current_values(self) -> None: + if keyring_store.get_api_key(): + self._api_key_edit.setPlaceholderText("Ist hinterlegt - zum Ändern neu eingeben") + else: + self._api_key_edit.setPlaceholderText("Noch kein API-Key hinterlegt") + + if keyring_store.get_hf_token(): + self._hf_token_edit.setPlaceholderText("Ist hinterlegt - zum Ändern neu eingeben") + else: + self._hf_token_edit.setPlaceholderText("Noch kein Token hinterlegt") + + self._model_edit.setText(self._controller.config.claude_model) + self._autostart_checkbox.setChecked(autostart.is_autostart_enabled()) + + def _on_save_clicked(self) -> None: + api_key = self._api_key_edit.text().strip() + if api_key: + keyring_store.set_api_key(api_key) + self._api_key_edit.clear() + + hf_token = self._hf_token_edit.text().strip() + if hf_token: + keyring_store.set_hf_token(hf_token) + self._hf_token_edit.clear() + + self._controller.config.claude_model = self._model_edit.text().strip() or self._controller.config.claude_model + self._controller.config.autostart_enabled = self._autostart_checkbox.isChecked() + self._controller.save_config() + + if self._autostart_checkbox.isChecked(): + autostart.enable_autostart() + else: + autostart.disable_autostart() + + self._load_current_values() + QMessageBox.information(self, "Gespeichert", "Einstellungen wurden gespeichert.") + + def _on_test_clicked(self) -> None: + api_key = self._api_key_edit.text().strip() or keyring_store.get_api_key() + if not api_key: + QMessageBox.information(self, "Kein API-Key", "Bitte zuerst einen API-Key eingeben.") + return + + self._test_button.setEnabled(False) + self._test_status_label.setText("Prüfe…") + + self._validate_thread = _ValidateApiKeyThread(api_key, self) + self._validate_thread.ok.connect(self._on_validate_ok) + self._validate_thread.error.connect(self._on_validate_error) + self._validate_thread.finished.connect(self._on_validate_thread_finished) + self._validate_thread.start() + + def _on_validate_ok(self) -> None: + self._test_status_label.setText("✓ API-Key gültig") + + def _on_validate_error(self, error_message: str) -> None: + self._test_status_label.setText(f"✗ Ungültig: {error_message}") + + def _on_validate_thread_finished(self) -> None: + self._test_button.setEnabled(True) + self._validate_thread = None diff --git a/src/notarius/gui/tabs/summary_tab.py b/src/notarius/gui/tabs/summary_tab.py new file mode 100644 index 0000000..11bc47c --- /dev/null +++ b/src/notarius/gui/tabs/summary_tab.py @@ -0,0 +1,82 @@ +"""Tab 4 - Summary: editierbare Zusammenfassung, Save und Save as… + +Ausgegraut, solange keine Summary-Datei existiert (siehe MainWindow-Tab-Aktivierungslogik). +""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtWidgets import QFileDialog, QHBoxLayout, QLabel, QMessageBox, QPushButton, QTextEdit, QVBoxLayout, QWidget + +from notarius.gui.format_utils import format_duration, format_timestamp +from notarius.models.recording import Recording, sanitize_filename + + +class SummaryTab(QWidget): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._recording: Recording | None = None + + self._header_label = QLabel() + self._header_label.setWordWrap(True) + + self._text_edit = QTextEdit() + + self._save_button = QPushButton("Save") + self._save_button.clicked.connect(self._on_save_clicked) + + self._save_as_button = QPushButton("Save as…") + self._save_as_button.clicked.connect(self._on_save_as_clicked) + + button_row = QHBoxLayout() + button_row.addWidget(self._save_button) + button_row.addWidget(self._save_as_button) + button_row.addStretch(1) + + layout = QVBoxLayout(self) + layout.addWidget(self._header_label) + layout.addWidget(self._text_edit, stretch=1) + layout.addLayout(button_row) + + self.set_recording(None) + + def set_recording(self, recording: Recording | None) -> None: + self._recording = recording + has_recording = recording is not None + self.setEnabled(has_recording) + if not has_recording: + return + + self._header_label.setText( + f"Titel: {recording.meta.title}
" + f"Aufzeichnungszeitpunkt: {format_timestamp(recording.meta.created_at)}
" + f"Länge: {format_duration(recording.meta.duration_seconds)}
" + f"Sprecher: {', '.join(recording.meta.speakers) or '-'}" + ) + + if recording.has_summary: + self._text_edit.setPlainText(recording.summary_path.read_text(encoding="utf-8")) + else: + self._text_edit.clear() + + def _on_save_clicked(self) -> None: + if self._recording is None: + return + self._recording.summary_path.write_text(self._text_edit.toPlainText(), encoding="utf-8") + + def _on_save_as_clicked(self) -> None: + if self._recording is None: + return + + suggested_name = f"{sanitize_filename(self._recording.meta.title)}_summary.md" + file_path, _ = QFileDialog.getSaveFileName( + self, "Summary speichern unter…", suggested_name, "Markdown (*.md);;Alle Dateien (*)" + ) + if not file_path: + return + + try: + Path(file_path).write_text(self._text_edit.toPlainText(), encoding="utf-8") + except OSError as exc: + QMessageBox.critical(self, "Fehler", f"Datei konnte nicht gespeichert werden:\n{exc}") diff --git a/src/notarius/gui/tabs/transcript_tab.py b/src/notarius/gui/tabs/transcript_tab.py new file mode 100644 index 0000000..32121a8 --- /dev/null +++ b/src/notarius/gui/tabs/transcript_tab.py @@ -0,0 +1,192 @@ +"""Tab 2 - Transcript: Metadaten-Kopf, Miniplayer, Sprecher, Transkription, Freitext.""" + +from __future__ import annotations + +import logging + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from notarius.gui.controller import AppController +from notarius.gui.format_utils import format_duration +from notarius.gui.widgets.audio_player import MiniAudioPlayer +from notarius.gui.widgets.editable_field import EditableDateTimeField, EditableTextField +from notarius.models.recording import Recording +from notarius.transcription.worker import STAGE_LABELS, TranscriptionWorker + +logger = logging.getLogger(__name__) + + +class TranscriptTab(QWidget): + # Wird ausgelöst, sobald ein Transkript neu erzeugt wurde - MainWindow prüft + # daraufhin erneut, ob Tab 3 aktiviert werden kann. + transcription_finished = Signal() + + def __init__(self, controller: AppController, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._controller = controller + self._recording: Recording | None = None + self._worker: TranscriptionWorker | None = None + + self._title_field = EditableTextField("Titel") + self._title_field.value_changed.connect(self._on_title_changed) + + self._timestamp_field = EditableDateTimeField("Aufzeichnungszeitpunkt") + self._timestamp_field.value_changed.connect(self._on_timestamp_changed) + + self._duration_label = QLabel() + + self._player = MiniAudioPlayer() + + self._speaker_field = QLineEdit() + self._speaker_field.setPlaceholderText("Sprecher, kommagetrennt (z.B. Anna, Max)") + self._speaker_field.editingFinished.connect(self._on_speakers_edited) + + self._transcribe_button = QPushButton("Transcribe") + self._transcribe_button.clicked.connect(self._on_transcribe_clicked) + + self._status_label = QLabel("idle") + + self._text_edit = QTextEdit() + self._text_edit.setPlaceholderText("Noch kein Transkript vorhanden.") + + self._save_button = QPushButton("Save") + self._save_button.clicked.connect(self._on_save_clicked) + + self._delete_button = QPushButton("Delete") + self._delete_button.clicked.connect(self._on_delete_clicked) + + transcribe_row = QHBoxLayout() + transcribe_row.addWidget(self._transcribe_button) + transcribe_row.addWidget(self._status_label) + transcribe_row.addStretch(1) + + layout = QVBoxLayout(self) + layout.addWidget(self._title_field) + layout.addWidget(self._timestamp_field) + layout.addWidget(self._duration_label) + layout.addWidget(self._player) + layout.addWidget(self._speaker_field) + layout.addLayout(transcribe_row) + layout.addWidget(self._text_edit, stretch=1) + layout.addWidget(self._save_button) + layout.addStretch(0) + layout.addWidget(self._delete_button) + + self.set_recording(None) + + def set_recording(self, recording: Recording | None) -> None: + self._recording = recording + has_recording = recording is not None + + self.setEnabled(has_recording) + if not has_recording: + return + + self._title_field.set_value(recording.meta.title) + self._timestamp_field.set_value(recording.meta.created_at) + self._duration_label.setText(f"Länge: {format_duration(recording.meta.duration_seconds)}") + self._player.load(recording.audio_path) + self._speaker_field.setText(", ".join(recording.meta.speakers)) + + if recording.has_transcript: + self._text_edit.setPlainText(recording.transcript_path.read_text(encoding="utf-8")) + self._status_label.setText("done") + else: + self._text_edit.clear() + self._status_label.setText("idle") + + self._transcribe_button.setEnabled(True) + + # -- Metadaten-Bearbeitung ---------------------------------------------------- + + def _on_title_changed(self, new_title: str) -> None: + if self._recording is None: + return + self._recording.meta.title = new_title + self._recording.save_meta() + self._controller.recordings_changed.emit() + + def _on_timestamp_changed(self, new_iso: str) -> None: + if self._recording is None: + return + self._recording.meta.created_at = new_iso + self._recording.save_meta() + self._controller.recordings_changed.emit() + + def _on_speakers_edited(self) -> None: + if self._recording is None: + return + speakers = [s.strip() for s in self._speaker_field.text().split(",") if s.strip()] + self._recording.meta.speakers = speakers + self._recording.save_meta() + + # -- Transkription ------------------------------------------------------------- + + def _on_transcribe_clicked(self) -> None: + if self._recording is None: + return + + if self._recording.has_transcript: + answer = QMessageBox.question( + self, "Transkript überschreiben?", "Es existiert bereits ein Transkript. Überschreiben?" + ) + if answer != QMessageBox.StandardButton.Yes: + return + + self._transcribe_button.setEnabled(False) + self._status_label.setText(STAGE_LABELS["loading_models"]) + + self._worker = TranscriptionWorker(self) + self._worker.stage_changed.connect(self._on_stage_changed) + self._worker.finished.connect(self._on_transcription_worker_finished) + self._worker.start( + self._recording.audio_path, self._recording.transcript_path, self._recording.meta.title + ) + + def _on_stage_changed(self, stage: str) -> None: + self._status_label.setText(STAGE_LABELS.get(stage, stage)) + + def _on_transcription_worker_finished(self, success: bool, error_message: str) -> None: + self._transcribe_button.setEnabled(True) + self._worker = None + + if not self._recording: + return + + if success: + self._status_label.setText("done") + self._text_edit.setPlainText(self._recording.transcript_path.read_text(encoding="utf-8")) + self.transcription_finished.emit() + else: + self._status_label.setText("error") + QMessageBox.critical(self, "Transkription fehlgeschlagen", error_message) + + # -- Freitext / Löschen ---------------------------------------------------------- + + def _on_save_clicked(self) -> None: + if self._recording is None: + return + self._recording.transcript_path.write_text(self._text_edit.toPlainText(), encoding="utf-8") + + def _on_delete_clicked(self) -> None: + if self._recording is None: + return + answer = QMessageBox.question( + self, + "Aufnahme löschen?", + "Aufnahme und alle zugehörigen Dateien (Audio, Transkript, Notizen, Summary) " + "werden unwiderruflich gelöscht. Fortfahren?", + ) + if answer != QMessageBox.StandardButton.Yes: + return + self._controller.delete_recording(self._recording) diff --git a/src/notarius/gui/tray.py b/src/notarius/gui/tray.py new file mode 100644 index 0000000..d9bf8e9 --- /dev/null +++ b/src/notarius/gui/tray.py @@ -0,0 +1,79 @@ +"""System-Tray-Icon: einziger sichtbarer Programmteil direkt nach dem Start. + +Zeigt per Icon an, ob eine Aufnahme läuft, und bietet Start/Stop Rec, Open +und Quit über das Kontextmenü (Klick auf das Icon selbst öffnet das Menü). +""" + +from __future__ import annotations + +import logging + +from PySide6.QtGui import QAction, QIcon +from PySide6.QtWidgets import QApplication, QMenu, QStyle, QSystemTrayIcon + +from notarius.gui.controller import AppController + +logger = logging.getLogger(__name__) + + +class TrayIcon(QSystemTrayIcon): + def __init__(self, controller: AppController, parent=None) -> None: + super().__init__(parent) + self._controller = controller + + style = QApplication.instance().style() + self._icon_idle = style.standardIcon(QStyle.StandardPixmap.SP_MediaVolume) + self._icon_recording = style.standardIcon(QStyle.StandardPixmap.SP_MediaStop) + self.setIcon(self._icon_idle) + + self._menu = QMenu() + self._toggle_action = QAction("Start Rec") + self._toggle_action.triggered.connect(self._controller.toggle_recording) + self._menu.addAction(self._toggle_action) + + self._open_action = QAction("Open") + self._menu.addAction(self._open_action) + + self._menu.addSeparator() + + self._quit_action = QAction("Quit") + self._quit_action.triggered.connect(QApplication.instance().quit) + self._menu.addAction(self._quit_action) + + self.setContextMenu(self._menu) + self.activated.connect(self._on_activated) + + controller.recording_state_changed.connect(self._on_recording_state_changed) + controller.notification_requested.connect(self._on_notification_requested) + + self.setToolTip("Notarius – keine Aufnahme aktiv") + + @property + def open_action(self) -> QAction: + """Wird von app.py mit dem Anzeigen des Hauptfensters verbunden.""" + return self._open_action + + def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: + # Linksklick öffnet ebenfalls das Kontextmenü (Trigger = Standard-Klick auf das Icon). + if reason == QSystemTrayIcon.ActivationReason.Trigger: + self.contextMenu().popup( + self.geometry().topLeft() if not self.geometry().isNull() else self.contextMenu().pos() + ) + + def _on_recording_state_changed(self, is_recording: bool) -> None: + if is_recording: + self.setIcon(self._icon_recording) + self.setToolTip("Notarius – Aufnahme läuft") + self._toggle_action.setText("Stop Rec") + else: + self.setIcon(self._icon_idle) + self.setToolTip("Notarius – keine Aufnahme aktiv") + self._toggle_action.setText("Start Rec") + + def _on_notification_requested(self, title: str, message: str, is_error: bool) -> None: + icon = ( + QSystemTrayIcon.MessageIcon.Critical + if is_error + else QSystemTrayIcon.MessageIcon.Information + ) + self.showMessage(title, message, icon) diff --git a/src/notarius/gui/widgets/__init__.py b/src/notarius/gui/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/notarius/gui/widgets/audio_player.py b/src/notarius/gui/widgets/audio_player.py new file mode 100644 index 0000000..6286838 --- /dev/null +++ b/src/notarius/gui/widgets/audio_player.py @@ -0,0 +1,67 @@ +"""Miniplayer für Tab 2: Play/Pause, Zeitleiste, Positionsanzeige hh:mm:ss.""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QUrl, Qt +from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QSlider, QWidget + + +def _format_ms(milliseconds: int) -> str: + total_seconds = max(0, milliseconds) // 1000 + hours, remainder = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + if hours: + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + return f"{minutes:02d}:{seconds:02d}" + + +class MiniAudioPlayer(QWidget): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + + self._player = QMediaPlayer(self) + self._audio_output = QAudioOutput(self) + self._player.setAudioOutput(self._audio_output) + + self._play_button = QPushButton("▶") + self._play_button.setFixedWidth(36) + self._play_button.clicked.connect(self._toggle_play_pause) + + self._position_slider = QSlider(Qt.Orientation.Horizontal) + self._position_slider.sliderMoved.connect(self._player.setPosition) + + self._time_label = QLabel("00:00 / 00:00") + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._play_button) + layout.addWidget(self._position_slider, stretch=1) + layout.addWidget(self._time_label) + + self._player.positionChanged.connect(self._on_position_changed) + self._player.durationChanged.connect(self._on_duration_changed) + self._player.playbackStateChanged.connect(self._on_playback_state_changed) + + def load(self, audio_path: Path) -> None: + self._player.stop() + self._player.setSource(QUrl.fromLocalFile(str(audio_path))) + + def _toggle_play_pause(self) -> None: + if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self._player.pause() + else: + self._player.play() + + def _on_playback_state_changed(self, state: QMediaPlayer.PlaybackState) -> None: + self._play_button.setText("⏸" if state == QMediaPlayer.PlaybackState.PlayingState else "▶") + + def _on_position_changed(self, position_ms: int) -> None: + self._position_slider.setValue(position_ms) + self._time_label.setText(f"{_format_ms(position_ms)} / {_format_ms(self._player.duration())}") + + def _on_duration_changed(self, duration_ms: int) -> None: + self._position_slider.setRange(0, duration_ms) + self._time_label.setText(f"{_format_ms(self._player.position())} / {_format_ms(duration_ms)}") diff --git a/src/notarius/gui/widgets/editable_field.py b/src/notarius/gui/widgets/editable_field.py new file mode 100644 index 0000000..33dd390 --- /dev/null +++ b/src/notarius/gui/widgets/editable_field.py @@ -0,0 +1,120 @@ +"""Kopfzeilen-Felder in Tab 2 (Titel, Aufzeichnungszeitpunkt), die per Editieren-Symbol +umschaltbar sind: normalerweise nur Anzeige, per Klick auf das Stift-Icon editierbar. +""" + +from __future__ import annotations + +from datetime import datetime + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QDateTimeEdit, QHBoxLayout, QLabel, QLineEdit, QPushButton, QStackedWidget, QWidget + +_EDIT_ICON_TEXT = "✎" + + +class EditableTextField(QWidget): + """Einzeiliges Textfeld (z.B. Titel) mit Anzeige-/Bearbeiten-Umschaltung.""" + + value_changed = Signal(str) + + def __init__(self, label_prefix: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._label_prefix = label_prefix + self._value = "" + + self._display_label = QLabel() + self._edit_field = QLineEdit() + + self._stack = QStackedWidget() + self._stack.addWidget(self._display_label) + self._stack.addWidget(self._edit_field) + + self._edit_button = QPushButton(_EDIT_ICON_TEXT) + self._edit_button.setFixedWidth(28) + self._edit_button.setCheckable(True) + self._edit_button.toggled.connect(self._on_edit_toggled) + + self._edit_field.editingFinished.connect(self._commit_edit) + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._stack, stretch=1) + layout.addWidget(self._edit_button) + + def set_value(self, value: str) -> None: + self._value = value + self._display_label.setText(f"{self._label_prefix}: {value}") + self._edit_field.setText(value) + + def value(self) -> str: + return self._value + + def _on_edit_toggled(self, checked: bool) -> None: + if checked: + self._edit_field.setText(self._value) + self._stack.setCurrentWidget(self._edit_field) + self._edit_field.setFocus() + else: + self._stack.setCurrentWidget(self._display_label) + + def _commit_edit(self) -> None: + new_value = self._edit_field.text().strip() + self._edit_button.setChecked(False) + if new_value and new_value != self._value: + self.set_value(new_value) + self.value_changed.emit(new_value) + + +class EditableDateTimeField(QWidget): + """Aufzeichnungszeitpunkt: rein editierbares Metadatenfeld ohne Rückwirkung auf den Dateinamen.""" + + value_changed = Signal(str) # ISO-8601 + + _DISPLAY_FORMAT = "%d.%m.%Y %H:%M:%S" + + def __init__(self, label_prefix: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._label_prefix = label_prefix + self._value_iso = "" + + self._display_label = QLabel() + self._edit_field = QDateTimeEdit() + self._edit_field.setDisplayFormat("dd.MM.yyyy HH:mm:ss") + self._edit_field.setCalendarPopup(True) + + self._stack = QStackedWidget() + self._stack.addWidget(self._display_label) + self._stack.addWidget(self._edit_field) + + self._edit_button = QPushButton(_EDIT_ICON_TEXT) + self._edit_button.setFixedWidth(28) + self._edit_button.setCheckable(True) + self._edit_button.toggled.connect(self._on_edit_toggled) + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._stack, stretch=1) + layout.addWidget(self._edit_button) + + def set_value(self, value_iso: str) -> None: + self._value_iso = value_iso + try: + dt = datetime.fromisoformat(value_iso) + self._display_label.setText(f"{self._label_prefix}: {dt.strftime(self._DISPLAY_FORMAT)}") + self._edit_field.setDateTime(dt) + except ValueError: + self._display_label.setText(f"{self._label_prefix}: {value_iso}") + + def value(self) -> str: + return self._value_iso + + def _on_edit_toggled(self, checked: bool) -> None: + if checked: + self._stack.setCurrentWidget(self._edit_field) + else: + new_dt = self._edit_field.dateTime().toPython() + new_iso = new_dt.isoformat() + if new_iso != self._value_iso: + self.set_value(new_iso) + self.value_changed.emit(new_iso) + self._stack.setCurrentWidget(self._display_label) diff --git a/src/notarius/keyring_store.py b/src/notarius/keyring_store.py new file mode 100644 index 0000000..1ff7434 --- /dev/null +++ b/src/notarius/keyring_store.py @@ -0,0 +1,73 @@ +"""Verwaltung des Anthropic-API-Keys über das System-Keyring (KWallet unter KDE). + +Dies ist der EINZIGE Ort im Programm, an dem der API-Key gelesen oder geschrieben +wird. Er landet nie in config.json, nie in der Logdatei und nie im Quellcode. +""" + +from __future__ import annotations + +import logging + +import keyring +import keyring.errors + +logger = logging.getLogger(__name__) + +_SERVICE_NAME = "notarius" +_ANTHROPIC_KEY_NAME = "anthropic_api_key" +# Diarization (Pyannote, via whisperx) benötigt einen HuggingFace-Token zum Download +# der Modellgewichte. Gilt aus denselben Sicherheitsgründen wie der Anthropic-Key: +# nie im Code, nie in config.json - nur im System-Keyring. +_HF_KEY_NAME = "huggingface_token" + + +def get_api_key() -> str | None: + """Liest den gespeicherten Anthropic-API-Key, oder None falls keiner hinterlegt ist.""" + return _get(_ANTHROPIC_KEY_NAME) + + +def set_api_key(api_key: str) -> None: + """Speichert den Anthropic-API-Key im System-Keyring (überschreibt einen evtl. vorhandenen).""" + _set(_ANTHROPIC_KEY_NAME, api_key) + + +def delete_api_key() -> None: + """Entfernt einen gespeicherten Anthropic-API-Key, falls vorhanden.""" + _delete(_ANTHROPIC_KEY_NAME) + + +def get_hf_token() -> str | None: + """Liest den gespeicherten HuggingFace-Token für die Diarization-Pipeline.""" + return _get(_HF_KEY_NAME) + + +def set_hf_token(token: str) -> None: + """Speichert den HuggingFace-Token im System-Keyring.""" + _set(_HF_KEY_NAME, token) + + +def delete_hf_token() -> None: + """Entfernt einen gespeicherten HuggingFace-Token, falls vorhanden.""" + _delete(_HF_KEY_NAME) + + +def _get(key_name: str) -> str | None: + try: + return keyring.get_password(_SERVICE_NAME, key_name) + except keyring.errors.KeyringError: + logger.exception("Keyring konnte nicht gelesen werden (%s)", key_name) + return None + + +def _set(key_name: str, value: str) -> None: + keyring.set_password(_SERVICE_NAME, key_name, value) + logger.info("Secret im System-Keyring gespeichert (%s)", key_name) + + +def _delete(key_name: str) -> None: + try: + keyring.delete_password(_SERVICE_NAME, key_name) + logger.info("Secret aus System-Keyring entfernt (%s)", key_name) + except keyring.errors.PasswordDeleteError: + # Kein Secret vorhanden -> nichts zu tun. + pass diff --git a/src/notarius/logging_setup.py b/src/notarius/logging_setup.py new file mode 100644 index 0000000..d0ef337 --- /dev/null +++ b/src/notarius/logging_setup.py @@ -0,0 +1,40 @@ +"""Zentrale Logging-Konfiguration. + +Alle Module verwenden logging.getLogger(__name__); hier wird einmalig beim +Programmstart festgelegt, wohin diese Log-Records geschrieben werden. +""" + +from __future__ import annotations + +import logging +from logging.handlers import RotatingFileHandler + +from notarius.paths import LOG_FILE, ensure_app_dirs + +_LOG_FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s" + +# 5 MB pro Datei, 3 Backups -> reicht bei "wenige Nutzungen pro Tag" für Monate. +_MAX_BYTES = 5 * 1024 * 1024 +_BACKUP_COUNT = 3 + + +def configure_logging(level: int = logging.INFO) -> None: + """Richtet Root-Logger mit rotierender Logdatei + Konsolenausgabe ein.""" + ensure_app_dirs() + + root = logging.getLogger() + root.setLevel(level) + + formatter = logging.Formatter(_LOG_FORMAT) + + file_handler = RotatingFileHandler( + LOG_FILE, maxBytes=_MAX_BYTES, backupCount=_BACKUP_COUNT, encoding="utf-8" + ) + file_handler.setFormatter(formatter) + root.addHandler(file_handler) + + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + root.addHandler(console_handler) + + logging.getLogger(__name__).info("Logging initialisiert (%s)", LOG_FILE) diff --git a/src/notarius/menu_launcher.py b/src/notarius/menu_launcher.py new file mode 100644 index 0000000..de26437 --- /dev/null +++ b/src/notarius/menu_launcher.py @@ -0,0 +1,51 @@ +"""Installiert/entfernt den Notarius-Eintrag im Anwendungsmenü. + +Getrennt von autostart.py: dieser Eintrag lässt Notarius im Kickoff/Kicker-Menü +auftauchen, der Autostart-Eintrag startet es beim Login. Beide Vorlagen +generieren den Exec-Pfad aus sys.executable, damit er exakt auf den Python- +Interpreter zeigt, mit dem Notarius installiert wurde (üblicherweise eine venv) - +unabhängig davon, ob dieser im PATH liegt. + +Wird von install.sh über den Konsolenbefehl `notarius-install-launcher` +aufgerufen, kann aber auch direkt mit `python -m notarius.menu_launcher` genutzt +werden. +""" + +from __future__ import annotations + +import sys + +from notarius.paths import MENU_DESKTOP_FILE + +_DESKTOP_ENTRY_TEMPLATE = """[Desktop Entry] +Type=Application +Name=Notarius +Comment=Aufnahme, Transkription und Zusammenfassung von Gesprächen +Exec={python_executable} -m notarius +Icon=audio-input-microphone +Terminal=false +Categories=AudioVideo;Utility; +""" + + +def install() -> None: + MENU_DESKTOP_FILE.parent.mkdir(parents=True, exist_ok=True) + content = _DESKTOP_ENTRY_TEMPLATE.format(python_executable=sys.executable) + MENU_DESKTOP_FILE.write_text(content, encoding="utf-8") + print(f"Menü-Eintrag installiert: {MENU_DESKTOP_FILE}") + + +def remove() -> None: + MENU_DESKTOP_FILE.unlink(missing_ok=True) + print("Menü-Eintrag entfernt.") + + +def main() -> None: + if "--remove" in sys.argv[1:]: + remove() + else: + install() + + +if __name__ == "__main__": + main() diff --git a/src/notarius/models/__init__.py b/src/notarius/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/notarius/models/recording.py b/src/notarius/models/recording.py new file mode 100644 index 0000000..871773b --- /dev/null +++ b/src/notarius/models/recording.py @@ -0,0 +1,189 @@ +"""Datenmodell einer Aufnahme: der Dateiname ist eine unveränderliche interne ID, +alle für den Nutzer sichtbaren Informationen (Titel, Zeitpunkt, Sprecher, Dauer) +leben ausschließlich in der begleitenden `.meta.json`. + +Es gibt bewusst KEINE zentrale Datenbank: der Aufnahmeordner selbst ist die +Quelle der Wahrheit, damit er portabel bleibt (z.B. Kopie auf ein anderes Gerät). +""" + +from __future__ import annotations + +import json +import logging +import re +import shutil +from dataclasses import asdict, dataclass, field +from datetime import datetime +from pathlib import Path + +from notarius.audio.ffprobe import probe_duration_seconds + +logger = logging.getLogger(__name__) + +_FILENAME_TIMESTAMP_FORMAT = "%Y-%m-%d_%H-%M-%S" +_BASENAME_PREFIX = "Notarius" + +_META_SUFFIX = ".meta.json" +_AUDIO_SUFFIX = ".mp3" +_TRANSCRIPT_SUFFIX = ".md" +_PROMPT_SUFFIX = "_prompt.md" +_SUMMARY_SUFFIX = "_summary.md" + + +@dataclass +class RecordingMeta: + """Schema von `.meta.json`, siehe Projektspezifikation.""" + + title: str + created_at: str + duration_seconds: float = 0.0 + speakers: list[str] = field(default_factory=list) + last_modified: str = "" + + def __post_init__(self) -> None: + if not self.last_modified: + self.last_modified = self.created_at + + +class Recording: + """Bindet einen Basisnamen (die unveränderliche interne ID) an seine Metadaten + und stellt die Pfade der Begleitdateien (Audio, Transkript, Prompt, Summary) bereit. + """ + + def __init__(self, recordings_dir: Path, base_name: str, meta: RecordingMeta) -> None: + self.recordings_dir = recordings_dir + self.base_name = base_name + self.meta = meta + + # -- Pfade der Begleitdateien ------------------------------------------------- + + @property + def meta_path(self) -> Path: + return self.recordings_dir / f"{self.base_name}{_META_SUFFIX}" + + @property + def audio_path(self) -> Path: + return self.recordings_dir / f"{self.base_name}{_AUDIO_SUFFIX}" + + @property + def transcript_path(self) -> Path: + return self.recordings_dir / f"{self.base_name}{_TRANSCRIPT_SUFFIX}" + + @property + def prompt_notes_path(self) -> Path: + return self.recordings_dir / f"{self.base_name}{_PROMPT_SUFFIX}" + + @property + def summary_path(self) -> Path: + return self.recordings_dir / f"{self.base_name}{_SUMMARY_SUFFIX}" + + # -- Existenzprüfungen: Quelle der Wahrheit ist die Datei selbst, nicht meta.json -- + + @property + def has_transcript(self) -> bool: + return self.transcript_path.exists() + + @property + def has_summary(self) -> bool: + return self.summary_path.exists() + + # -- Persistenz ---------------------------------------------------------------- + + def save_meta(self) -> None: + """Schreibt meta.json atomar und aktualisiert last_modified.""" + self.meta.last_modified = datetime.now().isoformat() + tmp_path = self.meta_path.with_suffix(".json.tmp") + tmp_path.write_text( + json.dumps(asdict(self.meta), indent=2, ensure_ascii=False), encoding="utf-8" + ) + tmp_path.replace(self.meta_path) + + def delete_all_files(self) -> None: + """Löscht Audio, meta.json, Transkript, Prompt-Notizen und Summary, falls vorhanden.""" + for path in ( + self.audio_path, + self.meta_path, + self.transcript_path, + self.prompt_notes_path, + self.summary_path, + ): + path.unlink(missing_ok=True) + logger.info("Aufnahme gelöscht: %s", self.base_name) + + @classmethod + def load(cls, meta_path: Path) -> "Recording": + """Lädt eine bestehende Aufnahme anhand ihrer meta.json-Datei.""" + base_name = meta_path.name.removesuffix(_META_SUFFIX) + raw = json.loads(meta_path.read_text(encoding="utf-8")) + meta = RecordingMeta( + title=raw.get("title", base_name), + created_at=raw.get("created_at", ""), + duration_seconds=raw.get("duration_seconds", 0.0), + speakers=raw.get("speakers", []), + last_modified=raw.get("last_modified", ""), + ) + return cls(meta_path.parent, base_name, meta) + + +def generate_base_name(now: datetime | None = None) -> str: + """Erzeugt den unveränderlichen Basisnamen einer neuen Aufnahme.""" + timestamp = (now or datetime.now()).strftime(_FILENAME_TIMESTAMP_FORMAT) + return f"{_BASENAME_PREFIX}_{timestamp}" + + +def create_new_recording(recordings_dir: Path) -> Recording: + """Legt sofort bei Aufnahmestart eine neue meta.json an (Titel = Basisname).""" + now = datetime.now() + base_name = generate_base_name(now) + meta = RecordingMeta(title=base_name, created_at=now.isoformat()) + recording = Recording(recordings_dir, base_name, meta) + recording.save_meta() + return recording + + +def import_audio_file(recordings_dir: Path, source_path: Path) -> Recording: + """Kopiert eine vom Nutzer gewählte Audiodatei unverändert in den Aufnahmeordner + und legt eine passende meta.json dafür an (Titel = Dateiname ohne Endung). + """ + base_name = source_path.stem + target_path = recordings_dir / source_path.name + shutil.copy2(source_path, target_path) + + duration = probe_duration_seconds(target_path) + meta = RecordingMeta( + title=base_name, + created_at=datetime.now().isoformat(), + duration_seconds=duration, + ) + # Der Basisname für die Begleitdateien richtet sich nach dem kopierten Dateinamen + # ohne Endung, damit z.B. "kundencall.wav" zu "kundencall.md" etc. passt. + recording = Recording(recordings_dir, target_path.stem, meta) + recording.save_meta() + return recording + + +def list_recordings(recordings_dir: Path) -> list[Recording]: + """Liest alle *.meta.json-Dateien im Aufnahmeordner ein, neueste zuerst.""" + if not recordings_dir.exists(): + return [] + + recordings = [] + for meta_path in recordings_dir.glob(f"*{_META_SUFFIX}"): + try: + recordings.append(Recording.load(meta_path)) + except (OSError, json.JSONDecodeError, KeyError): + logger.exception("Konnte Metadaten nicht laden: %s", meta_path) + + recordings.sort(key=lambda r: r.meta.created_at, reverse=True) + return recordings + + +_UNSAFE_FILENAME_CHARS = re.compile(r'[\\/:*?"<>|]') + + +def sanitize_filename(name: str) -> str: + """Entfernt dateisystemkritische Zeichen aus einem Titel für Datei-Vorschläge + (z.B. beim "Save as..."-Dialog in Tab 4). + """ + cleaned = _UNSAFE_FILENAME_CHARS.sub("_", name).strip() + return cleaned or "Notarius" diff --git a/src/notarius/paths.py b/src/notarius/paths.py new file mode 100644 index 0000000..8da0bb3 --- /dev/null +++ b/src/notarius/paths.py @@ -0,0 +1,35 @@ +"""Zentrale Pfaddefinitionen. + +Trennt bewusst zwei Bereiche: +- APP_CONFIG_DIR: Programmkonfiguration (config.json, Logdatei) unter ~/.config/notarius +- Der Aufnahmeordner selbst liegt NICHT hier fest verdrahtet, sondern kommt aus der + Konfiguration (config.recordings_dir), da er in Zukunft konfigurierbar sein soll. + Der Default dafür ist DEFAULT_RECORDINGS_DIR (~/Notarius). +""" + +from pathlib import Path + +# Programmkonfiguration: Modelleinstellungen, Autostart-Flag, Standardpfade, Logdatei. +# Enthält bewusst NIE den API-Key (der liegt ausschließlich im System-Keyring). +APP_CONFIG_DIR = Path.home() / ".config" / "notarius" +CONFIG_FILE = APP_CONFIG_DIR / "config.json" +LOG_FILE = APP_CONFIG_DIR / "notarius.log" + +# Default-Speicherort für Aufnahmen und alle zugehörigen Begleitdateien. +DEFAULT_RECORDINGS_DIR = Path.home() / "Notarius" + +# Autostart-Eintrag (nur vorhanden, wenn Autostart in Tab 5 aktiviert ist). +AUTOSTART_DESKTOP_FILE = Path.home() / ".config" / "autostart" / "notarius.desktop" + +# Menü-Eintrag (Anwendungsmenü/Kickoff), getrennt vom Autostart-Eintrag - wird von +# install.sh bzw. `notarius-install-launcher` geschrieben, nicht von der GUI selbst. +MENU_DESKTOP_FILE = Path.home() / ".local" / "share" / "applications" / "notarius.desktop" + + +def ensure_app_dirs() -> None: + """Legt die Verzeichnisse an, die für Config/Logging benötigt werden. + + Der Aufnahmeordner wird separat über recordings.ensure_recordings_dir() + angelegt, da sein Pfad aus der (erst danach geladenen) Konfiguration stammt. + """ + APP_CONFIG_DIR.mkdir(parents=True, exist_ok=True) diff --git a/src/notarius/rec_cli.py b/src/notarius/rec_cli.py new file mode 100644 index 0000000..05550b1 --- /dev/null +++ b/src/notarius/rec_cli.py @@ -0,0 +1,46 @@ +"""Eigenständiger Befehl zum Starten/Stoppen einer Aufnahme, unabhängig davon, +ob die Notarius-GUI läuft - gedacht zum Binden an einen KDE-Tastenkurzbefehl +(Systemeinstellungen -> Kurzbefehle -> Eigene Kurzbefehle), analog zu +`call-recorder.sh toggle` aus reference/. + +Importiert bewusst kein PySide6, damit der Aufruf ohne GUI-Overhead sofort reagiert. +""" + +from __future__ import annotations + +import argparse +import sys + +from notarius import recording_session +from notarius.logging_setup import configure_logging + + +def main() -> None: + parser = argparse.ArgumentParser(description="Notarius-Aufnahme steuern (auch ohne laufende GUI)") + parser.add_argument("action", choices=["start", "stop", "toggle"], nargs="?", default="toggle") + args = parser.parse_args() + + configure_logging() + + try: + if args.action == "start": + recording = recording_session.start() + print(f"Aufnahme gestartet: {recording.meta.title}") + elif args.action == "stop": + recording = recording_session.stop() + if recording is None: + print("Keine laufende Aufnahme.") + else: + print(f"Aufnahme gestoppt: {recording.meta.title}") + else: + state, recording = recording_session.toggle() + verb = "gestartet" if state == "started" else "gestoppt" + title = f": {recording.meta.title}" if recording else "" + print(f"Aufnahme {verb}{title}") + except recording_session.RecordingSessionError as exc: + print(f"Fehler: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/notarius/recording_session.py b/src/notarius/recording_session.py new file mode 100644 index 0000000..b7f0d56 --- /dev/null +++ b/src/notarius/recording_session.py @@ -0,0 +1,84 @@ +"""Startet/stoppt eine Aufnahme unabhängig davon, ob die Notarius-GUI läuft. + +Wird sowohl vom GUI-Controller (Tray-Menü) als auch vom eigenständigen +`notarius-rec`-Kommandozeilenbefehl verwendet - z.B. gebunden an einen +KDE-Tastenkurzbefehl, ganz ohne laufende GUI. Beide teilen sich dieselbe +Lock-Datei (audio.lock), damit ein per Kurzbefehl ausgelöster Stopp und ein +Klick im Tray-Menü sich nicht gegenseitig in einen inkonsistenten Zustand +bringen können - es gibt exakt eine Quelle der Wahrheit für "läuft gerade +eine Aufnahme". + +Importiert bewusst kein Qt, damit `notarius-rec` ohne PySide6-Overhead sofort +reagiert. +""" + +from __future__ import annotations + +import logging + +from notarius.audio import lock +from notarius.audio.ffprobe import probe_duration_seconds +from notarius.audio.recorder import CallRecorder, RecorderError +from notarius.config import load_config +from notarius.models.recording import Recording, create_new_recording + +logger = logging.getLogger(__name__) + + +class RecordingSessionError(RuntimeError): + """Aufnahme konnte nicht gestartet werden (z.B. läuft bereits eine).""" + + +def is_recording() -> bool: + active = lock.read() + return active is not None and lock.is_alive(active.pid) + + +def start() -> Recording: + if is_recording(): + raise RecordingSessionError("Es läuft bereits eine Aufnahme") + + config = load_config() + recordings_dir = config.recordings_path + recordings_dir.mkdir(parents=True, exist_ok=True) + + recording = create_new_recording(recordings_dir) + recorder = CallRecorder() + try: + recorder.start(recording.audio_path) + except RecorderError: + # meta.json wurde bereits angelegt, aber ohne Audiodatei nutzlos -> aufräumen. + recording.delete_all_files() + raise + + lock.write(recorder.pid, recording.base_name) + logger.info("Aufnahmesession gestartet: %s (PID %s)", recording.base_name, recorder.pid) + return recording + + +def stop() -> Recording | None: + """Stoppt die laufende Aufnahme. Gibt None zurück, wenn gerade keine läuft.""" + active = lock.read() + if active is None: + return None + + config = load_config() + meta_path = config.recordings_path / f"{active.base_name}.meta.json" + recording = Recording.load(meta_path) if meta_path.exists() else None + + lock.terminate(active.pid) + lock.clear() + + if recording is not None: + recording.meta.duration_seconds = probe_duration_seconds(recording.audio_path) + recording.save_meta() + logger.info("Aufnahmesession gestoppt: %s", recording.base_name) + + return recording + + +def toggle() -> tuple[str, Recording | None]: + """Kippt den Aufnahmestatus. Gibt ("started"|"stopped", Recording) zurück.""" + if is_recording(): + return "stopped", stop() + return "started", start() diff --git a/src/notarius/resources/zusammenfassung_prompt.md b/src/notarius/resources/zusammenfassung_prompt.md new file mode 100644 index 0000000..0f075e6 --- /dev/null +++ b/src/notarius/resources/zusammenfassung_prompt.md @@ -0,0 +1,69 @@ +Du erstellst aus einem Gesprächstranskript ein strukturiertes Gesprächsprotokoll. + +Du bekommst zwei Texte: +1. Das Transkript des Gesprächs (roh, ggf. mit Sprecherlabeln wie SPEAKER_00/SPEAKER_01 + und Zeitstempeln). +2. Zusätzliche Hinweise des Nutzers ("Instructions and Notes"): Zuordnung der + Sprecherlabel zu echten Namen, Projektkontext, Kanal, Teilnehmerliste und + sonstige Metadaten, die nicht aus dem Transkript allein hervorgehen. + +Dies ist ein einmaliger, nicht-interaktiver Verarbeitungsschritt - du kannst nicht +nachfragen. Wenn eine Pflichtangabe (Datum, Kanal, Teilnehmer) weder im Transkript +noch in den Notes vorkommt, trage im entsprechenden Feld "nicht angegeben" ein, +anstatt sie zu erfinden oder danach zu fragen. + +Gib ausschließlich sauberes Markdown nach exakt folgendem Template aus, ohne +einleitenden oder abschließenden Kommentar davor/danach: + +# Gesprächsprotokoll – [Kundenname / Projekt] + +--- + +## Basisinfos +- **Datum:** +- **Kanal:** (Telefon / vor Ort / Video) +- **Ort / Plattform:** +- **Teilnehmer:** + - [Name] – [Rolle], [Unternehmen] + - [Name] – [Rolle], [Unternehmen] +- **Projekt / Kontext:** +- **Gesprächstyp:** (Erstgespräch / Follow-up / Demo / Projektgespräch / Abschluss) + +--- + +## Gesprächszusammenfassung +[Fließtext, maximal zwei Absätze. Die wichtigsten Themen, Stimmung, roter Faden des +Gesprächs. Kein Aufzählungs-Stil — echte Prosa.] + +--- + +## Entscheidungen & Fakten + +**Getroffene Entscheidungen:** +- [Entscheidung, ggf. mit wer entschieden hat] + +**Genannte Fakten & Rahmenbedingungen:** +- [Budget, Timeline, Teamgröße, technische Vorgaben, Vertragsinfos etc.] + +--- + +## ToDos & Nächste Schritte +- [ ] [Aufgabe] — [Person / Unternehmen] — [Datum falls genannt] +- [ ] [Aufgabe] — [Person / Unternehmen] — [Datum falls genannt] + +--- + +## Nebeninformationen *(intern, nicht zur Weitergabe)* +- [Interessante Kontextinfos, persönliche Details, Brancheninfos, die zufällig + erwähnt wurden — nicht projektbezogen, aber möglicherweise nützlich] + +--- + +Verarbeitungshinweise: +1. Sprache: Immer auf Deutsch ausgeben, auch wenn das Transkript teilweise Englisch enthält. +2. Block "Gesprächszusammenfassung": Kein Marketing-Sprech, kein "stell dir vor" — + sachlich und direkt. +3. Block "ToDos & Nächste Schritte": Format `[ ] Aufgabe — Person/Unternehmen — Datum` + konsequent einhalten. Wenn keine Person zuordenbar → `[ ] Aufgabe — unklar, klären`. +4. Block "Nebeninformationen": Nur wirklich interessante Nebenfakten, keine Füller. + Lieber leer lassen als auffüllen. diff --git a/src/notarius/summarization/__init__.py b/src/notarius/summarization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/notarius/summarization/client.py b/src/notarius/summarization/client.py new file mode 100644 index 0000000..8f8dd27 --- /dev/null +++ b/src/notarius/summarization/client.py @@ -0,0 +1,63 @@ +"""Dünner Wrapper um das Anthropic Python SDK für die KI-Zusammenfassung. + +Es wird ausschließlich Text übermittelt: Transkript-Inhalt (lokal von whisperx +erzeugt) + der feste Zusammenfassungs-Prompt + die Notes aus Tab 3. Es gibt keine +Audioübertragung an die API. +""" + +from __future__ import annotations + +import threading + +import anthropic + +from notarius.summarization.prompts import build_user_message, load_summary_system_prompt + +# Zusammenfassungen können lang werden (5-Block-Template) - Obergrenze bewusst großzügig. +_MAX_OUTPUT_TOKENS = 4096 + + +class SummarizationCancelled(Exception): + """Wird geworfen, wenn der Nutzer die Verarbeitung über den Stop-Button abbricht.""" + + +def validate_api_key(api_key: str) -> None: + """Minimaler Testaufruf für den 'Testen'-Button in Tab 5. + + Nutzt die Modell-Liste statt eines echten Completion-Calls, damit die + Validierung keine Tokens verbraucht. Wirft bei ungültigem Key eine + anthropic.APIStatusError. + """ + client = anthropic.Anthropic(api_key=api_key) + client.models.list(limit=1) + + +def stream_summary( + api_key: str, + model: str, + transcript_markdown: str, + instructions_and_notes: str, + cancel_event: threading.Event, +) -> str: + """Ruft die Anthropic-API streamend auf und baut den Volltext der Antwort zusammen. + + `cancel_event` wird nach jedem empfangenen Chunk geprüft, damit der + Stop-Button in Tab 3 die laufende Verarbeitung zeitnah abbrechen kann. + """ + client = anthropic.Anthropic(api_key=api_key) + system_prompt = load_summary_system_prompt() + user_message = build_user_message(transcript_markdown, instructions_and_notes) + + chunks: list[str] = [] + with client.messages.stream( + model=model, + max_tokens=_MAX_OUTPUT_TOKENS, + system=system_prompt, + messages=[{"role": "user", "content": user_message}], + ) as stream: + for text in stream.text_stream: + if cancel_event.is_set(): + raise SummarizationCancelled() + chunks.append(text) + + return "".join(chunks) diff --git a/src/notarius/summarization/prompts.py b/src/notarius/summarization/prompts.py new file mode 100644 index 0000000..9149451 --- /dev/null +++ b/src/notarius/summarization/prompts.py @@ -0,0 +1,29 @@ +"""Lädt den fest im Programm hinterlegten Zusammenfassungs-Prompt und baut daraus +die an die Anthropic-API zu sendenden Nachrichten. +""" + +from __future__ import annotations + +from importlib import resources + + +def load_summary_system_prompt() -> str: + """Liest resources/zusammenfassung_prompt.md (im Paket gebündelt, nicht user-editierbar).""" + return resources.files("notarius.resources").joinpath("zusammenfassung_prompt.md").read_text( + encoding="utf-8" + ) + + +def build_user_message(transcript_markdown: str, instructions_and_notes: str) -> str: + """Kombiniert Transkript-Inhalt und Nutzer-Notizen zu einer einzigen User-Message. + + Es wird ausschließlich Text übermittelt - keine Audiodaten verlassen den Rechner, + die Transkription läuft ausschließlich lokal (Tab 2 / whisperx). + """ + parts = ["## Transkript", "", transcript_markdown.strip()] + + notes = instructions_and_notes.strip() + if notes: + parts += ["", "## Instructions and Notes", "", notes] + + return "\n".join(parts) diff --git a/src/notarius/summarization/worker.py b/src/notarius/summarization/worker.py new file mode 100644 index 0000000..d4ca6dd --- /dev/null +++ b/src/notarius/summarization/worker.py @@ -0,0 +1,100 @@ +"""Führt den Anthropic-API-Aufruf in einem eigenen QThread aus, damit die GUI +während der Zusammenfassung nicht einfriert und der Stop-Button den Aufruf +abbrechen kann. +""" + +from __future__ import annotations + +import logging +import threading + +from PySide6.QtCore import QThread, Signal + +from notarius.summarization.client import SummarizationCancelled, stream_summary + +logger = logging.getLogger(__name__) + + +class _SummarizeThread(QThread): + """Interner QThread, der genau einen API-Aufruf ausführt.""" + + finished_ok = Signal(str) + finished_error = Signal(str) + finished_cancelled = Signal() + + def __init__( + self, + api_key: str, + model: str, + transcript_markdown: str, + instructions_and_notes: str, + cancel_event: threading.Event, + parent=None, + ) -> None: + super().__init__(parent) + self._api_key = api_key + self._model = model + self._transcript_markdown = transcript_markdown + self._instructions_and_notes = instructions_and_notes + self._cancel_event = cancel_event + + def run(self) -> None: + try: + summary = stream_summary( + self._api_key, + self._model, + self._transcript_markdown, + self._instructions_and_notes, + self._cancel_event, + ) + except SummarizationCancelled: + self.finished_cancelled.emit() + except Exception as exc: # noqa: BLE001 - jeder Fehler muss in der GUI landen + logger.exception("Zusammenfassung fehlgeschlagen") + self.finished_error.emit(str(exc)) + else: + self.finished_ok.emit(summary) + + +class SummarizationWorker: + """Öffentliche Fassade: startet/stoppt die Zusammenfassung, reicht Qt-Signale durch.""" + + def __init__(self) -> None: + self._thread: _SummarizeThread | None = None + self._cancel_event = threading.Event() + + @property + def is_running(self) -> bool: + return self._thread is not None and self._thread.isRunning() + + def start( + self, + api_key: str, + model: str, + transcript_markdown: str, + instructions_and_notes: str, + on_finished_ok, + on_finished_error, + on_finished_cancelled, + ) -> None: + if self.is_running: + raise RuntimeError("Es läuft bereits eine Zusammenfassung") + + self._cancel_event = threading.Event() + thread = _SummarizeThread( + api_key, model, transcript_markdown, instructions_and_notes, self._cancel_event + ) + thread.finished_ok.connect(on_finished_ok) + thread.finished_error.connect(on_finished_error) + thread.finished_cancelled.connect(on_finished_cancelled) + # Thread-Objekt erst nach dem Beenden freigeben, sonst reißt eine laufende + # Signal-Verbindung ab, bevor das finished-Signal die GUI erreicht hat. + thread.finished.connect(self._on_thread_finished) + self._thread = thread + thread.start() + + def cancel(self) -> None: + self._cancel_event.set() + + def _on_thread_finished(self) -> None: + self._thread = None diff --git a/src/notarius/transcription/__init__.py b/src/notarius/transcription/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/notarius/transcription/cli.py b/src/notarius/transcription/cli.py new file mode 100644 index 0000000..30c47ff --- /dev/null +++ b/src/notarius/transcription/cli.py @@ -0,0 +1,98 @@ +"""Eigenständiger Transkriptions-Prozess (wird per `python -m notarius.transcription.cli` +als Subprozess gestartet, siehe worker.py). + +Läuft absichtlich als separater Prozess statt als Hintergrunddienst: Whisper- und +Diarization-Modelle werden erst hier geladen und geben beim Prozessende automatisch +ihren VRAM/RAM wieder frei. Notarius selbst hält also außerhalb einer aktiven +Transkription keine schweren ML-Modelle im Speicher. + +Kommuniziert den Fortschritt über stdout in einem einfachen Zeilenprotokoll, das +worker.py parst: + STAGE: - neue Verarbeitungsstufe erreicht + ERROR: - nicht behebbarer Fehler, Prozess beendet sich mit Exitcode 1 +""" + +from __future__ import annotations + +import argparse +import gc +import sys +from pathlib import Path + +from notarius.keyring_store import get_hf_token +from notarius.transcription.formatting import format_transcript_markdown + +DEVICE = "cuda" +COMPUTE_TYPE = "float16" +BATCH_SIZE = 4 +WHISPER_MODEL_NAME = "large-v3" + + +def _emit_stage(name: str) -> None: + print(f"STAGE:{name}", flush=True) + + +def _emit_error(message: str) -> None: + print(f"ERROR:{message}", flush=True) + + +def transcribe(audio_path: Path, output_path: Path, title: str, language: str) -> None: + # Import erst hier (statt auf Modulebene), damit `python -m notarius.transcription.cli --help` + # bzw. der GUI-Prozess selbst nicht das schwere whisperx/torch mitimportieren muss. + import whisperx + + hf_token = get_hf_token() + if not hf_token: + _emit_error( + "Kein HuggingFace-Token hinterlegt (Settings). Für die Sprechererkennung " + "wird ein Token mit akzeptierten Pyannote-Nutzungsbedingungen benötigt." + ) + sys.exit(1) + + _emit_stage("loading_models") + model = whisperx.load_model(WHISPER_MODEL_NAME, DEVICE, compute_type=COMPUTE_TYPE, language=language) + diarize_model = whisperx.diarize.DiarizationPipeline(token=hf_token, device=DEVICE) + + audio = whisperx.load_audio(str(audio_path)) + + _emit_stage("transcribing") + result = model.transcribe(audio, batch_size=BATCH_SIZE, language=language) + + _emit_stage("aligning") + model_a, metadata = whisperx.load_align_model(language_code=language, device=DEVICE) + result = whisperx.align( + result["segments"], model_a, metadata, audio, DEVICE, return_char_alignments=False + ) + del model_a + gc.collect() + + _emit_stage("diarizing") + diarize_segments = diarize_model(audio) + result = whisperx.assign_word_speakers(diarize_segments, result) + + _emit_stage("writing") + markdown = format_transcript_markdown(title, result["segments"]) + output_path.write_text(markdown, encoding="utf-8") + + _emit_stage("done") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Notarius Transkriptions-Worker") + parser.add_argument("audio_path", type=Path) + parser.add_argument("output_path", type=Path) + parser.add_argument("--title", default=None, help="Titel für die Transkript-Überschrift") + parser.add_argument("--language", default="de") + args = parser.parse_args() + + title = args.title or args.audio_path.stem + + try: + transcribe(args.audio_path, args.output_path, title, args.language) + except Exception as exc: # noqa: BLE001 - jeder Fehler muss dem Elternprozess gemeldet werden + _emit_error(str(exc)) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/notarius/transcription/formatting.py b/src/notarius/transcription/formatting.py new file mode 100644 index 0000000..14cc637 --- /dev/null +++ b/src/notarius/transcription/formatting.py @@ -0,0 +1,30 @@ +"""Formatiert whisperx-Segmente (Text + Sprecher + Zeitstempel) als lesbares Markdown.""" + +from __future__ import annotations + +from typing import Any, Iterable + +UNKNOWN_SPEAKER = "UNBEKANNT" + + +def format_transcript_markdown(title: str, segments: Iterable[dict[str, Any]]) -> str: + """Baut das `.md`-Transkript: eine Überschrift je Sprecherwechsel, darunter + Zeilen mit Zeitstempel-Bereich und Text. + """ + lines = [f"# Transkript: {title}", ""] + + current_speaker: str | None = None + for segment in segments: + speaker = segment.get("speaker", UNKNOWN_SPEAKER) + text = segment["text"].strip() + start = segment["start"] + end = segment["end"] + + if speaker != current_speaker: + lines.append(f"## {speaker}") + lines.append("") + current_speaker = speaker + + lines.append(f"`[{start:6.1f}s – {end:5.1f}s]` {text}") + + return "\n".join(lines) + "\n" diff --git a/src/notarius/transcription/worker.py b/src/notarius/transcription/worker.py new file mode 100644 index 0000000..f2dde9e --- /dev/null +++ b/src/notarius/transcription/worker.py @@ -0,0 +1,102 @@ +"""Startet und überwacht den Transkriptions-Subprozess (transcription/cli.py) aus der GUI heraus. + +Läuft asynchron über QProcess, damit die GUI während der (oft mehrere Minuten +dauernden) Transkription nicht einfriert. +""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path + +from PySide6.QtCore import QObject, QProcess, Signal + +logger = logging.getLogger(__name__) + +# Deutsche Anzeigetexte für die STAGE:-Meldungen aus cli.py, siehe Statusanzeige in Tab 2. +STAGE_LABELS: dict[str, str] = { + "loading_models": "Lade Modelle…", + "transcribing": "Transkribiere…", + "aligning": "Richte Wortzeiten aus…", + "diarizing": "Erkenne Sprecher…", + "writing": "Speichere Transkript…", + "done": "Fertig", +} + + +class TranscriptionWorker(QObject): + """Kapselt genau einen laufenden Transkriptions-Subprozess.""" + + stage_changed = Signal(str) # einer der Schlüssel aus STAGE_LABELS + finished = Signal(bool, str) # (erfolgreich, Fehlermeldung falls nicht erfolgreich) + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self._process: QProcess | None = None + self._error_message = "" + self._stdout_buffer = "" + + @property + def is_running(self) -> bool: + return self._process is not None + + def start(self, audio_path: Path, output_path: Path, title: str, language: str = "de") -> None: + if self.is_running: + raise RuntimeError("Es läuft bereits eine Transkription") + + self._error_message = "" + self._stdout_buffer = "" + + process = QProcess(self) + process.setProgram(sys.executable) + process.setArguments( + [ + "-m", "notarius.transcription.cli", + str(audio_path), str(output_path), + "--title", title, + "--language", language, + ] + ) + process.readyReadStandardOutput.connect(self._handle_stdout) + process.finished.connect(self._handle_finished) + self._process = process + + logger.info("Starte Transkription für %s", audio_path) + process.start() + + def cancel(self) -> None: + """Bricht eine laufende Transkription ab (z.B. beim Schließen des Fensters).""" + if self._process is not None: + self._process.kill() + + def _handle_stdout(self) -> None: + process = self._process + if process is None: + return + + self._stdout_buffer += bytes(process.readAllStandardOutput()).decode( + "utf-8", errors="replace" + ) + *complete_lines, self._stdout_buffer = self._stdout_buffer.split("\n") + + for line in complete_lines: + line = line.strip() + if line.startswith("STAGE:"): + self.stage_changed.emit(line.removeprefix("STAGE:")) + elif line.startswith("ERROR:"): + self._error_message = line.removeprefix("ERROR:") + elif line: + logger.debug("Transkription: %s", line) + + def _handle_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: + success = exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit + error_message = "" if success else (self._error_message or "Unbekannter Fehler beim Transkribieren") + + if not success: + logger.error("Transkription fehlgeschlagen: %s", error_message) + else: + logger.info("Transkription abgeschlossen") + + self._process = None + self.finished.emit(success, error_message) diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..03e5193 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Entfernt Menü-Eintrag, Autostart-Eintrag und optional die virtuelle Umgebung. +# Aufnahmen (~/Notarius) und Konfiguration (~/.config/notarius) bleiben erhalten. +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENV_DIR="$PROJECT_DIR/.venv" + +if [[ -x "$VENV_DIR/bin/notarius-install-launcher" ]]; then + "$VENV_DIR/bin/notarius-install-launcher" --remove +fi + +rm -f "$HOME/.config/autostart/notarius.desktop" +echo "Autostart-Eintrag entfernt (falls vorhanden)." + +read -r -p "Virtuelle Umgebung ($VENV_DIR) ebenfalls löschen? [y/N] " confirm +if [[ "$confirm" =~ ^[yY]$ ]]; then + rm -rf "$VENV_DIR" + echo "Virtuelle Umgebung entfernt." +fi + +echo "Hinweis: ~/Notarius (Aufnahmen) und ~/.config/notarius (Konfiguration, Log) wurden NICHT gelöscht."